From 097fdcf3cfa072afb264f32ae951561dad91040b Mon Sep 17 00:00:00 2001 From: Giovanni Di Sirio Date: Wed, 27 May 2026 10:22:39 +0200 Subject: [PATCH 1/4] Add string built-ins for multi-line text formatting: ?indent, ?dedent, ?wrap, ?pad_lines These four built-ins make it easier to format multi-line text, which is useful when generating source code, configuration files, documentation comments, and similar structured output. They all work on the string they're applied to and have no side effects; none require any new configuration or language mode. - ?indent(prefix): prepends prefix to each (non-empty) line. - ?dedent(prefix): removes prefix from the start of each line that has it (the inverse of ?indent). - ?wrap(width, firstPrefix[, restPrefix]): word-wraps the string to the given column width, with configurable per-line prefixes. Handy for wrapped comment blocks. - ?pad_lines(width[, fillChar]): pads each line on the right to the given column. Unlike ?right_pad, which pads the string as a whole, this operates per line, which is useful for aligning multi-line text. Line breaks (LF, CR, CRLF) are recognized and preserved. Added JUnit coverage and FreeMarker Manual reference entries (with @since 2.3.35). --- .../main/java/freemarker/core/BuiltIn.java | 6 +- .../core/BuiltInsForStringsBasic.java | 249 +++++++++++++++++- .../core/IndentAndWrapBuiltInTest.java | 242 +++++++++++++++++ .../src/main/docgen/en_US/book.xml | 207 +++++++++++++++ 4 files changed, 702 insertions(+), 2 deletions(-) create mode 100644 freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java index 1d53f617c..459caddf3 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java @@ -85,7 +85,7 @@ abstract class BuiltIn extends Expression implements Cloneable { static final Set CAMEL_CASE_NAMES = new TreeSet<>(); static final Set SNAKE_CASE_NAMES = new TreeSet<>(); - static final int NUMBER_OF_BIS = 302; + static final int NUMBER_OF_BIS = 307; static final HashMap BUILT_INS_BY_NAME = new HashMap<>(NUMBER_OF_BIS * 3 / 2 + 1, 1f); static final String BI_NAME_SNAKE_CASE_WITH_ARGS = "with_args"; @@ -115,6 +115,7 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI("date_if_unknown", "dateIfUnknown", new BuiltInsForDates.dateType_if_unknownBI(TemplateDateModel.DATE)); putBI("datetime", new BuiltInsForMultipleTypes.dateBI(TemplateDateModel.DATETIME)); putBI("datetime_if_unknown", "datetimeIfUnknown", new BuiltInsForDates.dateType_if_unknownBI(TemplateDateModel.DATETIME)); + putBI("dedent", new BuiltInsForStringsBasic.dedentBI()); putBI("default", new BuiltInsForExistenceHandling.defaultBI()); putBI("double", new doubleBI()); putBI("drop_while", "dropWhile", new BuiltInsForSequences.drop_whileBI()); @@ -138,6 +139,7 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI("has_next", "hasNext", new BuiltInsForLoopVariables.has_nextBI()); putBI("html", new BuiltInsForStringsEncoding.htmlBI()); putBI("if_exists", "ifExists", new BuiltInsForExistenceHandling.if_existsBI()); + putBI("indent", new BuiltInsForStringsBasic.indentBI()); putBI("index", new BuiltInsForLoopVariables.indexBI()); putBI("index_of", "indexOf", new BuiltInsForStringsBasic.index_ofBI(false)); putBI("int", new intBI()); @@ -265,6 +267,7 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI("number_to_date", "numberToDate", new number_to_dateBI(TemplateDateModel.DATE)); putBI("number_to_time", "numberToTime", new number_to_dateBI(TemplateDateModel.TIME)); putBI("number_to_datetime", "numberToDatetime", new number_to_dateBI(TemplateDateModel.DATETIME)); + putBI("pad_lines", "padLines", new BuiltInsForStringsBasic.padLinesBI()); putBI("parent", new parentBI()); putBI("previous_sibling", "previousSibling", new previousSiblingBI()); putBI("next_sibling", "nextSibling", new nextSiblingBI()); @@ -315,6 +318,7 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI(BI_NAME_SNAKE_CASE_WITH_ARGS_LAST, BI_NAME_CAMEL_CASE_WITH_ARGS_LAST, new BuiltInsForCallables.with_args_lastBI()); putBI("word_list", "wordList", new BuiltInsForStringsBasic.word_listBI()); + putBI("wrap", new BuiltInsForStringsBasic.wrapBI()); putBI("xhtml", new BuiltInsForStringsEncoding.xhtmlBI()); putBI("xml", new BuiltInsForStringsEncoding.xmlBI()); putBI("matches", new BuiltInsForStringsRegexp.matchesBI()); diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java index 8042a3d0b..12732ff02 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java @@ -495,7 +495,254 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio return new BIMethod(s); } } - + + static class indentBI extends BuiltInForString { + + private class BIMethod implements TemplateMethodModelEx { + + private final String s; + + private BIMethod(String s) { + this.s = s; + } + + @Override + public Object exec(List args) throws TemplateModelException { + int argCnt = args.size(); + checkMethodArgCount(argCnt, 1, 1); + + String prefix = getStringMethodArg(args, 0); + + if (s.isEmpty()) { + return new SimpleScalar(s); + } + + StringBuilder sb = new StringBuilder(s.length() + prefix.length() * 10); + int len = s.length(); + boolean atLineStart = true; + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + if (atLineStart && c != '\n' && c != '\r') { + sb.append(prefix); + } + sb.append(c); + atLineStart = (c == '\n' || (c == '\r' && (i + 1 >= len || s.charAt(i + 1) != '\n'))); + } + return new SimpleScalar(sb.toString()); + } + } + + @Override + TemplateModel calculateResult(String s, Environment env) throws TemplateException { + return new BIMethod(s); + } + } + + static class dedentBI extends BuiltInForString { + + private class BIMethod implements TemplateMethodModelEx { + + private final String s; + + private BIMethod(String s) { + this.s = s; + } + + @Override + public Object exec(List args) throws TemplateModelException { + int argCnt = args.size(); + checkMethodArgCount(argCnt, 1, 1); + + String prefix = getStringMethodArg(args, 0); + + if (s.isEmpty() || prefix.isEmpty()) { + return new SimpleScalar(s); + } + + int prefixLen = prefix.length(); + StringBuilder sb = new StringBuilder(s.length()); + int len = s.length(); + boolean atLineStart = true; + int matchPos = 0; + boolean stripping = true; + + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + if (atLineStart && stripping) { + if (matchPos < prefixLen && c == prefix.charAt(matchPos)) { + matchPos++; + if (matchPos == prefixLen) { + stripping = false; + } + continue; // consume prefix char + } else { + // Prefix didn't match — emit what we skipped + sb.append(prefix, 0, matchPos); + stripping = false; + } + } + sb.append(c); + if (c == '\n') { + atLineStart = true; + matchPos = 0; + stripping = true; + } else if (c == '\r') { + atLineStart = true; + matchPos = 0; + stripping = true; + } else { + atLineStart = false; + } + } + // Handle trailing partial match (line without newline) + if (stripping && matchPos > 0 && matchPos < prefixLen) { + sb.append(prefix, 0, matchPos); + } + return new SimpleScalar(sb.toString()); + } + } + + @Override + TemplateModel calculateResult(String s, Environment env) throws TemplateException { + return new BIMethod(s); + } + } + + static class wrapBI extends BuiltInForString { + + private class BIMethod implements TemplateMethodModelEx { + + private final String s; + + private BIMethod(String s) { + this.s = s; + } + + @Override + public Object exec(List args) throws TemplateModelException { + int argCnt = args.size(); + checkMethodArgCount(argCnt, 2, 3); + + int width = getNumberMethodArg(args, 0).intValue(); + if (width < 1) { + throw new _TemplateModelException( + "?", key, "(...) argument #1 (width) must be at least 1."); + } + + String firstPrefix = getStringMethodArg(args, 1); + String restPrefix = argCnt > 2 ? getStringMethodArg(args, 2) : firstPrefix; + + String[] words = s.split("\\s+"); + if (words.length == 0 || (words.length == 1 && words[0].isEmpty())) { + return new SimpleScalar(firstPrefix + "\n"); + } + + StringBuilder sb = new StringBuilder(); + String currentPrefix = firstPrefix; + int lineLen = currentPrefix.length(); + sb.append(currentPrefix); + boolean firstWord = true; + + for (String word : words) { + if (word.isEmpty()) continue; + if (firstWord) { + sb.append(word); + lineLen += word.length(); + firstWord = false; + } else { + if (lineLen + 1 + word.length() > width) { + sb.append('\n'); + currentPrefix = restPrefix; + sb.append(currentPrefix); + sb.append(word); + lineLen = currentPrefix.length() + word.length(); + } else { + sb.append(' '); + sb.append(word); + lineLen += 1 + word.length(); + } + } + } + sb.append('\n'); + return new SimpleScalar(sb.toString()); + } + } + + @Override + TemplateModel calculateResult(String s, Environment env) throws TemplateException { + return new BIMethod(s); + } + } + + static class padLinesBI extends BuiltInForString { + + private class BIMethod implements TemplateMethodModelEx { + + private final String s; + + private BIMethod(String s) { + this.s = s; + } + + @Override + public Object exec(List args) throws TemplateModelException { + int argCnt = args.size(); + checkMethodArgCount(argCnt, 1, 2); + + int column = getNumberMethodArg(args, 0).intValue(); + if (column < 0) { + throw new _TemplateModelException( + "?", key, "(...) argument #1 must be non-negative."); + } + + char fillChar = ' '; + if (argCnt > 1) { + String filling = getStringMethodArg(args, 1); + if (filling.length() != 1) { + throw new _TemplateModelException( + "?", key, "(...) argument #2 must be a single character string."); + } + fillChar = filling.charAt(0); + } + + if (s.isEmpty()) { + return new SimpleScalar(s); + } + + StringBuilder sb = new StringBuilder(s.length() + column); + int lineStart = 0; + int len = s.length(); + for (int i = 0; i <= len; i++) { + if (i == len || s.charAt(i) == '\n' || s.charAt(i) == '\r') { + int lineLen = i - lineStart; + sb.append(s, lineStart, i); + // Pad to column (skip empty lines) + if (lineLen > 0) { + for (int p = lineLen; p < column; p++) { + sb.append(fillChar); + } + } + // Append the line ending + if (i < len) { + sb.append(s.charAt(i)); + if (s.charAt(i) == '\r' && i + 1 < len && s.charAt(i + 1) == '\n') { + i++; + sb.append('\n'); + } + } + lineStart = i + 1; + } + } + return new SimpleScalar(sb.toString()); + } + } + + @Override + TemplateModel calculateResult(String s, Environment env) throws TemplateException { + return new BIMethod(s); + } + } + static class remove_beginningBI extends BuiltInForString { private class BIMethod implements TemplateMethodModelEx { diff --git a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java new file mode 100644 index 000000000..de24e2815 --- /dev/null +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -0,0 +1,242 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package freemarker.core; + +import static org.junit.Assert.*; + +import java.io.StringReader; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.TemplateException; + +public class IndentAndWrapBuiltInTest { + + private String eval(String expr) throws Exception { + return eval(expr, new HashMap()); + } + + private String eval(String expr, Map model) throws Exception { + String templateContent = "${" + expr + "}"; + Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); + Template t = new Template("test.ftl", new StringReader(templateContent), cfg); + StringWriter sw = new StringWriter(); + t.process(model, sw); + return sw.toString(); + } + + // ---- ?indent tests ---- + + @Test + public void testIndentSingleLine() throws Exception { + assertEquals(" hello", eval("'hello'?indent(' ')")); + } + + @Test + public void testIndentMultiLine() throws Exception { + assertEquals(" line1\n line2\n line3", + eval("'line1\\nline2\\nline3'?indent(' ')")); + } + + @Test + public void testIndentWithPrefix() throws Exception { + assertEquals(" * line1\n * line2", + eval("'line1\\nline2'?indent(' * ')")); + } + + @Test + public void testIndentEmptyString() throws Exception { + assertEquals("", eval("''?indent(' ')")); + } + + @Test + public void testIndentPreservesBlankLines() throws Exception { + assertEquals(" a\n\n b", + eval("'a\\n\\nb'?indent(' ')")); + } + + @Test + public void testIndentTrailingNewline() throws Exception { + assertEquals(" a\n b\n", + eval("'a\\nb\\n'?indent(' ')")); + } + + // ---- ?wrap tests ---- + + @Test + public void testWrapBasic() throws Exception { + assertEquals(" * @brief Hello world.\n", + eval("'Hello world.'?wrap(40, ' * @brief ')")); + } + + @Test + public void testWrapLongText() throws Exception { + String text = "This is a long description that should be wrapped at the specified width"; + Map model = new HashMap<>(); + model.put("text", text); + String result = eval("text?wrap(40, ' * ', ' * ')", model); + // Every line should end with \n and be <= 40 chars (excluding \n) + String[] lines = result.split("\n", -1); + // Last element is empty after trailing \n + for (int i = 0; i < lines.length - 1; i++) { + assertTrue("Line " + i + " too long: [" + lines[i] + "] (" + lines[i].length() + " chars)", + lines[i].length() <= 40); + } + assertTrue(result.startsWith(" * This")); + } + + @Test + public void testWrapWithDifferentPrefixes() throws Exception { + String text = "This is a description that needs wrapping to fit within bounds"; + Map model = new HashMap<>(); + model.put("text", text); + String result = eval("text?wrap(40, ' * @brief ', ' * ')", model); + assertTrue(result.startsWith(" * @brief ")); + // Second line should start with rest prefix + String[] lines = result.split("\n"); + if (lines.length > 1) { + assertTrue("Second line should start with rest prefix", + lines[1].startsWith(" * ")); + } + } + + @Test + public void testWrapSamePrefix() throws Exception { + // Two-arg form: same prefix for all lines + assertEquals("// hello world\n", + eval("'hello world'?wrap(40, '// ')")); + } + + @Test + public void testWrapSingleLongWord() throws Exception { + // A single word longer than width — can't break, just emit it + String result = eval("'superlongword'?wrap(5, '')"); + assertEquals("superlongword\n", result); + } + + @Test(expected = TemplateException.class) + public void testWrapZeroWidthThrows() throws Exception { + eval("'hello'?wrap(0, '')"); + } + + // ---- ?dedent tests ---- + + @Test + public void testDedentBasic() throws Exception { + assertEquals("int x;\nint y;\n", + eval("' int x;\\n int y;\\n'?dedent(' ')")); + } + + @Test + public void testDedentNoMatch() throws Exception { + // Line doesn't start with prefix — left unchanged + // " short" has only 2 spaces, doesn't match 4-space prefix → unchanged + // " full" has 4 spaces, matches prefix → stripped + assertEquals(" short\nfull\n", + eval("' short\\n full\\n'?dedent(' ')")); + } + + @Test + public void testDedentMixed() throws Exception { + // Some lines match, some don't + assertEquals("a\n b\nc\n", + eval("' a\\n b\\n c\\n'?dedent(' ')")); + } + + @Test + public void testDedentEmptyString() throws Exception { + assertEquals("", eval("''?dedent(' ')")); + } + + @Test + public void testDedentEmptyPrefix() throws Exception { + assertEquals(" hello", eval("' hello'?dedent('')")); + } + + @Test + public void testDedentNoTrailingNewline() throws Exception { + assertEquals("hello", + eval("' hello'?dedent(' ')")); + } + + @Test + public void testDedentSymmetryWithIndent() throws Exception { + // indent then dedent should round-trip + Map model = new HashMap<>(); + model.put("text", "line1\nline2\nline3"); + assertEquals("line1\nline2\nline3", + eval("text?indent(' ')?dedent(' ')", model)); + } + + // ---- ?pad_lines tests ---- + + @Test + public void testPadLinesBasic() throws Exception { + assertEquals("a \nbb \nccc \n", + eval("'a\\nbb\\nccc\\n'?pad_lines(10)")); + } + + @Test + public void testPadLinesWithFillChar() throws Exception { + assertEquals("a.........\nbb........\n", + eval("'a\\nbb\\n'?pad_lines(10, '.')")); + } + + @Test + public void testPadLinesLinePastColumn() throws Exception { + // "long line" (9 chars) past column 5 — no padding + // "ab" (2 chars) shorter than column 5 — padded + assertEquals("long line\nab \n", + eval("'long line\\nab\\n'?pad_lines(5)")); + } + + @Test + public void testPadLinesNoTrailingNewline() throws Exception { + assertEquals("a ", + eval("'a'?pad_lines(10)")); + } + + @Test + public void testPadLinesEmpty() throws Exception { + assertEquals("", eval("''?pad_lines(10)")); + } + + @Test + public void testPadLinesCamelCase() throws Exception { + assertEquals("a \nbb \n", + eval("'a\\nbb\\n'?padLines(5)")); + } + + @Test + public void testPadLinesCodeAlignment() throws Exception { + // Practical use: align code for trailing comments + Map model = new HashMap<>(); + model.put("code", "int x;\nString name;\nboolean active;\n"); + String result = eval("code?pad_lines(20)", model); + String[] lines = result.split("\n", -1); + assertEquals("int x; ", lines[0]); + assertEquals("String name; ", lines[1]); + assertEquals("boolean active; ", lines[2]); + } +} diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index c4318bea8..2317f62f4 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -13073,6 +13073,10 @@ grant codeBase "file:/path/to/freemarker.jar" linkend="ref_builtin_date_if_unknown">datetime_if_unknown + + dedent + + double @@ -13153,6 +13157,10 @@ grant codeBase "file:/path/to/freemarker.jar" html + + indent + + index @@ -13353,6 +13361,10 @@ grant codeBase "file:/path/to/freemarker.jar" number_to_datetime, number_to_time + + pad_lines + + parent @@ -13530,6 +13542,10 @@ grant codeBase "file:/path/to/freemarker.jar" linkend="ref_builtin_word_list">word_list + + wrap + + xhtml @@ -14039,6 +14055,52 @@ Green Mouse and a method and hash on the same time. +
+ dedent + + + dedent built-in + + + + indentation + + + + This built-in is available since FreeMarker 2.3.35. + + + Removes the string given as the parameter from the beginning of + each line, if that line starts with it. Lines that don't start with + the given prefix are left unchanged. This is the inverse of the indent + built-in. Line breaks can be LF, + CR, or CRLF, and are kept as + is. + + For example, this: + + <#assign code = " int x;\n int y;" /> +[${code?dedent(" ")}] + + will output this: + + [int x; +int y;] + + Lines that don't start with the prefix are unaffected, so with + a 4-space prefix: + + <#assign text = " short\n long" /> +${text?dedent(" ")} + + will output this (the first line had only 2 leading spaces, so + it's unchanged; the second had at least 4, so 4 were removed): + + short + long +
+
empty_to_null @@ -14334,6 +14396,54 @@ R&amp;D directive.
+
+ indent + + + indent built-in + + + + indentation + + + + This built-in is available since FreeMarker 2.3.35. + + + Prepends the string given as the parameter to the beginning of + each line. The parameter is most often some spaces or tabs used for + indentation, but can be any string. Lines that are empty (i.e., the + line break immediately follows the previous line break, or the line + is the empty last line) are not prefixed. Line breaks can be + LF, CR, or + CRLF, and are kept as is. + + For example, this: + + <#assign code = "int x;\nint y;" /> +[${code?indent(" ")}] + + will output this: + + [ int x; + int y;] + + Another example, using a non-whitespace prefix: + + <#assign text = "First line.\nSecond line." /> +${text?indent(" * ")} + + will output this: + + * First line. + * Second line. + + See also: the dedent + built-in, which is its inverse. +
+
index_of @@ -15004,6 +15114,54 @@ ${s?no_esc} above.
+
+ pad_lines + + + pad_lines built-in + + + + padding + + + + This built-in is available since FreeMarker 2.3.35. + + + Pads each line of the string with spaces on the right until it + reaches the column (width) specified as the 1st parameter. Lines that + are already at least that long are left unchanged. Unlike right_pad, + which operates on the string as a whole, this operates on each line + separately, which is useful for aligning multi-line text. Empty lines + are not padded. Line breaks can be LF, + CR, or CRLF, and are kept as + is. + + For example, this: + + <#assign code = "int x;\nString name;\nboolean active;" /> +${code?pad_lines(20)}done + + will output this (each line padded to column 20): + + int x; +String name; +boolean active; done + + If used with 2 parameters, the 2nd parameter specifies the fill + character to use instead of space. It must be a string exactly 1 + character long. For example: + + ${"a\nbb"?pad_lines(5, ".")} + + will output this: + + a.... +bb... +
+
replace @@ -15956,6 +16114,55 @@ ${x?url} [a][bcd,][.][1-2-3]
+
+ wrap + + + wrap built-in + + + + word wrapping + + + + This built-in is available since FreeMarker 2.3.35. + + + Word-wraps the string so that no line is longer than the column + (width) given as the 1st parameter, breaking only between words + (runs of white-space in the + input are treated as word boundaries and collapsed to a single + space). The 2nd parameter is a prefix prepended to the first output + line; the optional 3rd parameter is a prefix prepended to all + subsequent lines (if omitted, the 2nd parameter is used for all + lines). The result always ends with a line break. + + This is useful for generating wrapped comments, such as + documentation blocks. For example: + + <#assign text = "This is a long description that should be wrapped" /> +${text?wrap(40, " * @brief ", " * ")} + + will output this: + + * @brief This is a long description + * that should be wrapped + + With a single prefix used for all lines: + + ${"A comment that needs to be wrapped at a reasonable width"?wrap(40, "// ")} + + will output this: + + // A comment that needs to be wrapped at +// a reasonable width + + The 1st parameter (width) must be at least 1. A single word + longer than the width is emitted on its own line without being + broken. +
+
xhtml (deprecated) From ecfa04465958d81e88f8cb83f915fe4a7852fb83 Mon Sep 17 00:00:00 2001 From: Giovanni Di Sirio Date: Fri, 29 May 2026 22:54:26 +0200 Subject: [PATCH 2/4] Review feedback: rename ?pad_lines to ?right_pad_lines Aligns with ?right_pad / ?left_pad naming. Internal class renamed to right_pad_linesBI accordingly. Registration moved to the alphabetical position after right_pad. Tests, manual entry, and index link renamed. Per review comment by ddekany on PR #130. --- .../main/java/freemarker/core/BuiltIn.java | 2 +- .../core/BuiltInsForStringsBasic.java | 2 +- .../core/IndentAndWrapBuiltInTest.java | 30 +++++++++---------- .../src/main/docgen/en_US/book.xml | 25 ++++++++-------- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java index 459caddf3..b77314386 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java @@ -267,7 +267,6 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI("number_to_date", "numberToDate", new number_to_dateBI(TemplateDateModel.DATE)); putBI("number_to_time", "numberToTime", new number_to_dateBI(TemplateDateModel.TIME)); putBI("number_to_datetime", "numberToDatetime", new number_to_dateBI(TemplateDateModel.DATETIME)); - putBI("pad_lines", "padLines", new BuiltInsForStringsBasic.padLinesBI()); putBI("parent", new parentBI()); putBI("previous_sibling", "previousSibling", new previousSiblingBI()); putBI("next_sibling", "nextSibling", new nextSiblingBI()); @@ -275,6 +274,7 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI("item_parity_cap", "itemParityCap", new BuiltInsForLoopVariables.item_parity_capBI()); putBI("reverse", new reverseBI()); putBI("right_pad", "rightPad", new BuiltInsForStringsBasic.padBI(false)); + putBI("right_pad_lines", "rightPadLines", new BuiltInsForStringsBasic.right_pad_linesBI()); putBI("root", new rootBI()); putBI("round", new roundBI()); putBI("remove_ending", "removeEnding", new BuiltInsForStringsBasic.remove_endingBI()); diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java index 12732ff02..36e643d96 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java @@ -674,7 +674,7 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio } } - static class padLinesBI extends BuiltInForString { + static class right_pad_linesBI extends BuiltInForString { private class BIMethod implements TemplateMethodModelEx { diff --git a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java index de24e2815..fe49693be 100644 --- a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -189,51 +189,51 @@ public void testDedentSymmetryWithIndent() throws Exception { eval("text?indent(' ')?dedent(' ')", model)); } - // ---- ?pad_lines tests ---- + // ---- ?right_pad_lines tests ---- @Test - public void testPadLinesBasic() throws Exception { + public void testRightPadLinesBasic() throws Exception { assertEquals("a \nbb \nccc \n", - eval("'a\\nbb\\nccc\\n'?pad_lines(10)")); + eval("'a\\nbb\\nccc\\n'?right_pad_lines(10)")); } @Test - public void testPadLinesWithFillChar() throws Exception { + public void testRightPadLinesWithFillChar() throws Exception { assertEquals("a.........\nbb........\n", - eval("'a\\nbb\\n'?pad_lines(10, '.')")); + eval("'a\\nbb\\n'?right_pad_lines(10, '.')")); } @Test - public void testPadLinesLinePastColumn() throws Exception { + public void testRightPadLinesLinePastColumn() throws Exception { // "long line" (9 chars) past column 5 — no padding // "ab" (2 chars) shorter than column 5 — padded assertEquals("long line\nab \n", - eval("'long line\\nab\\n'?pad_lines(5)")); + eval("'long line\\nab\\n'?right_pad_lines(5)")); } @Test - public void testPadLinesNoTrailingNewline() throws Exception { + public void testRightPadLinesNoTrailingNewline() throws Exception { assertEquals("a ", - eval("'a'?pad_lines(10)")); + eval("'a'?right_pad_lines(10)")); } @Test - public void testPadLinesEmpty() throws Exception { - assertEquals("", eval("''?pad_lines(10)")); + public void testRightPadLinesEmpty() throws Exception { + assertEquals("", eval("''?right_pad_lines(10)")); } @Test - public void testPadLinesCamelCase() throws Exception { + public void testRightPadLinesCamelCase() throws Exception { assertEquals("a \nbb \n", - eval("'a\\nbb\\n'?padLines(5)")); + eval("'a\\nbb\\n'?rightPadLines(5)")); } @Test - public void testPadLinesCodeAlignment() throws Exception { + public void testRightPadLinesCodeAlignment() throws Exception { // Practical use: align code for trailing comments Map model = new HashMap<>(); model.put("code", "int x;\nString name;\nboolean active;\n"); - String result = eval("code?pad_lines(20)", model); + String result = eval("code?right_pad_lines(20)", model); String[] lines = result.split("\n", -1); assertEquals("int x; ", lines[0]); assertEquals("String name; ", lines[1]); diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index 2317f62f4..05760bb74 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -13361,10 +13361,6 @@ grant codeBase "file:/path/to/freemarker.jar" number_to_datetime, number_to_time - - pad_lines - - parent @@ -13397,6 +13393,11 @@ grant codeBase "file:/path/to/freemarker.jar" linkend="ref_builtin_right_pad">right_pad + + right_pad_lines + + round @@ -15114,11 +15115,11 @@ ${s?no_esc} above.
-
- pad_lines +
+ right_pad_lines - pad_lines built-in + right_pad_lines built-in @@ -15130,8 +15131,8 @@ ${s?no_esc} Pads each line of the string with spaces on the right until it - reaches the column (width) specified as the 1st parameter. Lines that - are already at least that long are left unchanged. Unlike right_pad, which operates on the string as a whole, this operates on each line separately, which is useful for aligning multi-line text. Empty lines @@ -15142,9 +15143,9 @@ ${s?no_esc} For example, this: <#assign code = "int x;\nString name;\nboolean active;" /> -${code?pad_lines(20)}done +${code?right_pad_lines(20)}done - will output this (each line padded to column 20): + will output this (each line padded to width 20): int x; String name; @@ -15154,7 +15155,7 @@ boolean active; done character to use instead of space. It must be a string exactly 1 character long. For example: - ${"a\nbb"?pad_lines(5, ".")} + ${"a\nbb"?right_pad_lines(5, ".")} will output this: From 3abd7f0ad956f592cc514a0a99553a8067712b6b Mon Sep 17 00:00:00 2001 From: Giovanni Di Sirio Date: Fri, 29 May 2026 23:02:27 +0200 Subject: [PATCH 3/4] Review feedback: add no-argument ?dedent() form (Python textwrap.dedent-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a more robust default behaviour for ?dedent. The no-argument form finds the longest leading whitespace (spaces and tabs only) that is a common prefix of every non-empty line, and removes that. This handles imperfect input (lines with different leading-whitespace amounts) gracefully, whereas the original explicit-prefix form leaves any line not starting with the exact prefix unchanged. Semantics match Python's textwrap.dedent. Empty/whitespace-only lines are ignored when computing the common prefix and pass through unchanged. A leading tab and a leading space are distinct (no implicit collapsing), again matching Python. The explicit-prefix form ?dedent(prefix) remains for cases where exact control is wanted; it's not redundant — just less robust by design. 8 new JUnit tests covering uniform indent, mixed indent, blank-line handling, no-common-prefix passthrough, tabs, tabs+spaces distinction, empty input, and already-dedented input. Manual section expanded with the new form and a worked example. Per review comment by ddekany on PR #130. --- .../core/BuiltInsForStringsBasic.java | 101 +++++++++++++++++- .../core/IndentAndWrapBuiltInTest.java | 56 ++++++++++ .../src/main/docgen/en_US/book.xml | 37 ++++++- 3 files changed, 189 insertions(+), 5 deletions(-) diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java index 36e643d96..f064e07f6 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java @@ -551,8 +551,18 @@ private BIMethod(String s) { @Override public Object exec(List args) throws TemplateModelException { int argCnt = args.size(); - checkMethodArgCount(argCnt, 1, 1); + checkMethodArgCount(argCnt, 0, 1); + + if (argCnt == 0) { + // No-argument form: strip the longest common leading whitespace + // (spaces and tabs) across all non-empty lines, like Python's + // textwrap.dedent. Empty lines are ignored when computing the + // common prefix. + return new SimpleScalar(dedentCommonLeadingWhitespace(s)); + } + // Explicit-prefix form: remove the given prefix from each line that + // starts with it; leave other lines unchanged. String prefix = getStringMethodArg(args, 0); if (s.isEmpty() || prefix.isEmpty()) { @@ -602,6 +612,95 @@ public Object exec(List args) throws TemplateModelException { } } + /** + * Strip the longest leading-whitespace string (spaces and tabs only) that + * is a common prefix of every non-empty line. Empty lines are ignored when + * computing the prefix but remain empty in the output. Mirrors Python's + * textwrap.dedent semantics. Note: a leading tab and a leading space do + * not collapse — they're distinct characters with no common prefix. + */ + private static String dedentCommonLeadingWhitespace(String s) { + if (s.isEmpty()) return s; + int len = s.length(); + + // First pass: walk lines, find the leading-whitespace run of each, + // and compute the common prefix among non-empty lines. + String commonPrefix = null; + int lineStart = 0; + for (int i = 0; i <= len; i++) { + boolean atEnd = (i == len); + char c = atEnd ? '\n' : s.charAt(i); + if (atEnd || c == '\n' || c == '\r') { + int contentStart = lineStart; + while (contentStart < i) { + char cc = s.charAt(contentStart); + if (cc != ' ' && cc != '\t') break; + contentStart++; + } + boolean nonEmpty = contentStart < i; + if (nonEmpty) { + if (commonPrefix == null) { + commonPrefix = s.substring(lineStart, contentStart); + } else { + int maxLen = Math.min(commonPrefix.length(), contentStart - lineStart); + int matched = 0; + while (matched < maxLen + && commonPrefix.charAt(matched) == s.charAt(lineStart + matched)) { + matched++; + } + if (matched < commonPrefix.length()) { + commonPrefix = commonPrefix.substring(0, matched); + } + if (commonPrefix.isEmpty()) break; // can't shrink further; finish quickly + } + } + if (!atEnd) { + // Step past \r\n if applicable + if (c == '\r' && i + 1 < len && s.charAt(i + 1) == '\n') i++; + lineStart = i + 1; + } + } + } + + if (commonPrefix == null || commonPrefix.isEmpty()) { + return s; + } + + // Second pass: emit each line with the common prefix stripped (from + // non-empty lines only). + int prefixLen = commonPrefix.length(); + StringBuilder sb = new StringBuilder(len); + lineStart = 0; + for (int i = 0; i <= len; i++) { + boolean atEnd = (i == len); + if (atEnd || s.charAt(i) == '\n' || s.charAt(i) == '\r') { + int contentStart = lineStart; + while (contentStart < i) { + char cc = s.charAt(contentStart); + if (cc != ' ' && cc != '\t') break; + contentStart++; + } + boolean nonEmpty = contentStart < i; + if (nonEmpty) { + // Non-empty line: by construction it has the common prefix. + sb.append(s, lineStart + prefixLen, i); + } else { + // Whitespace-only or empty line — keep as is. + sb.append(s, lineStart, i); + } + if (!atEnd) { + sb.append(s.charAt(i)); + if (s.charAt(i) == '\r' && i + 1 < len && s.charAt(i + 1) == '\n') { + i++; + sb.append('\n'); + } + lineStart = i + 1; + } + } + } + return sb.toString(); + } + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new BIMethod(s); diff --git a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java index fe49693be..6ef8d4ab8 100644 --- a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -189,6 +189,62 @@ public void testDedentSymmetryWithIndent() throws Exception { eval("text?indent(' ')?dedent(' ')", model)); } + // ---- ?dedent (no-args, Python textwrap.dedent-style) tests ---- + + @Test + public void testDedentNoArgsUniformIndent() throws Exception { + assertEquals("a\nb\nc", + eval("' a\\n b\\n c'?dedent()")); + } + + @Test + public void testDedentNoArgsMixedIndent() throws Exception { + // The longest common leading whitespace across non-empty lines is 2 spaces. + assertEquals("a\n b\n c", + eval("' a\\n b\\n c'?dedent()")); + } + + @Test + public void testDedentNoArgsRespectsEmptyLines() throws Exception { + // Empty/whitespace-only lines are ignored when computing the common prefix + // and pass through unchanged. + assertEquals("a\n\nb", + eval("' a\\n\\n b'?dedent()")); + } + + @Test + public void testDedentNoArgsNoCommonPrefix() throws Exception { + // If lines have no common leading whitespace, nothing is stripped. + assertEquals("a\n b", + eval("'a\\n b'?dedent()")); + } + + @Test + public void testDedentNoArgsTabAndSpaceDistinct() throws Exception { + // A leading tab and a leading space have no common prefix. + // (Same behaviour as Python textwrap.dedent.) + assertEquals("\ta\n b", + eval("'\\ta\\n b'?dedent()")); + } + + @Test + public void testDedentNoArgsTabsOnly() throws Exception { + assertEquals("a\nb", + eval("'\\t\\ta\\n\\t\\tb'?dedent()")); + } + + @Test + public void testDedentNoArgsEmptyString() throws Exception { + assertEquals("", eval("''?dedent()")); + } + + @Test + public void testDedentNoArgsAlreadyDedented() throws Exception { + // No common leading whitespace => no change. + assertEquals("a\nb\nc", + eval("'a\\nb\\nc'?dedent()")); + } + // ---- ?right_pad_lines tests ---- @Test diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index 05760bb74..1dc7f5524 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -14071,15 +14071,44 @@ Green Mouse This built-in is available since FreeMarker 2.3.35. - Removes the string given as the parameter from the beginning of - each line, if that line starts with it. Lines that don't start with - the given prefix are left unchanged. This is the inverse of the Removes a leading-whitespace prefix from each line of the + string. The built-in has two forms: a no-argument form that strips + common leading whitespace automatically, and an explicit-prefix form + for exact control. This is the inverse of the indent built-in. Line breaks can be LF, CR, or CRLF, and are kept as is. - For example, this: + The no-argument form + (?dedent()) finds the longest leading whitespace + (spaces and tabs only) that is a common prefix of every non-empty + line, and removes it. This is robust to imperfect input: lines with + different leading-whitespace amounts work as expected, and + empty/whitespace-only lines are ignored when computing the common + prefix. The semantics match Python's + textwrap.dedent. + + For example: + + <#assign code = " int x;\n int y;\n int z;" /> +[${code?dedent()}] + + will output this (the common prefix is 2 spaces): + + [int x; + int y; +int z;] + + A leading tab and a leading space are treated as distinct + characters (they have no common prefix), matching Python's + behaviour. + + The explicit-prefix form + (?dedent(prefix)) removes the given prefix from + each line that starts with it. Lines that don't start with the prefix + are left unchanged. Use this when you want exact control rather than + automatic common-prefix detection. For example: <#assign code = " int x;\n int y;" /> [${code?dedent(" ")}] From 0b6e6179ffacbe3147362b9c3be58c0cfed57245 Mon Sep 17 00:00:00 2001 From: Giovanni Di Sirio Date: Fri, 29 May 2026 23:13:06 +0200 Subject: [PATCH 4/4] Review feedback: clarify character-width semantics and non-breaking space behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds notes in the manual for the new built-ins: - For ?right_pad_lines: widths are counted in Java chars (UTF-16 code units), not visual display columns — same as ?right_pad / ?left_pad. A tab counts as one character, not as an advance to the next tab stop. Visual alignment for tab-containing input requires expanding tabs first. - For ?wrap: same width semantics, plus a note that words are split on Java's \s+, which does NOT include U+00A0 (non-breaking space) — so a non-breaking space correctly stays inside a word and is never used as a break point. This is the intended behaviour. Per review comment by ddekany on PR #130 about tab and non-breaking space handling. --- .../src/main/docgen/en_US/book.xml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index 1dc7f5524..2e78de80c 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -15190,6 +15190,17 @@ boolean active; done a.... bb... + + + Widths are counted in Java chars (UTF-16 + code units), not visual display columns — same as right_pad + and left_pad. + A tab counts as one character, not as "advance to the next tab + stop". If you need visual alignment for content containing tabs, + expand the tabs to spaces first. +
@@ -16191,6 +16202,16 @@ ${text?wrap(40, " * @brief ", " * ")} The 1st parameter (width) must be at least 1. A single word longer than the width is emitted on its own line without being broken. + + + Widths are counted in Java chars (UTF-16 + code units), not visual display columns. A tab counts as one + character. Words are split on Java's \s+ + character class, which does not include + U+00A0 (the non-breaking space) — so a non-breaking space stays + inside a word and is never used as a break point. This is the + intended behaviour of non-breaking spaces. +