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/9] 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/9] 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/9] 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/9] 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. +
From 4ab4d64f8d02a9c9efa22ae33dc15f185182a0b8 Mon Sep 17 00:00:00 2001 From: ddekany Date: Mon, 27 Jul 2026 01:21:48 +0200 Subject: [PATCH 5/9] [PR-130] Adjustments: - ?dedent() => ?dedent, and ?dedent() is error - ?wrap(width) is now valid (prefix defaults to "") - Reworked Manual sections, especially for ?wrap - Moved string transformation methods from Builtins to _CoreStringUtils - Moved most string transformation testing _CoreStringUtilsTest. Add some more test cases. - IndentAndWrapBuiltInTest now focues on the FTL interface issues, like number and type of arguments. - Adjusted IndentAndWrapBuiltInTest to code use FTL JUnit test conventions in this project --- .../core/BuiltInsForStringsBasic.java | 459 +++++------------- .../freemarker/core/_CoreStringUtils.java | 252 ++++++++++ .../core/IndentAndWrapBuiltInTest.java | 267 +++------- .../freemarker/core/_CoreStringUtilsTest.java | 391 +++++++++++++++ .../src/main/docgen/en_US/book.xml | 224 ++++++--- .../java/freemarker/test/TemplateTest.java | 5 + 6 files changed, 978 insertions(+), 620 deletions(-) create mode 100644 freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java index f064e07f6..d1c8fce23 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java @@ -46,7 +46,7 @@ static class cap_firstBI extends BuiltInForString { TemplateModel calculateResult(String s, Environment env) { int i = 0; int ln = s.length(); - while (i < ln && Character.isWhitespace(s.charAt(i))) { + while (i < ln && Character.isWhitespace(s.charAt(i))) { i++; } if (i < ln) { @@ -73,15 +73,15 @@ TemplateModel calculateResult(String s, Environment env) { } static class containsBI extends BuiltIn { - + private class BIMethod implements TemplateMethodModelEx { - + private final String s; - + private BIMethod(String s) { this.s = s; } - + @Override public Object exec(List args) throws TemplateModelException { checkMethodArgCount(args, 1); @@ -89,7 +89,7 @@ public Object exec(List args) throws TemplateModelException { ? TemplateBooleanModel.TRUE : TemplateBooleanModel.FALSE; } } - + @Override TemplateModel _eval(Environment env) throws TemplateException { return new BIMethod(target.evalAndCoerceToStringOrUnsupportedMarkup(env, @@ -98,14 +98,14 @@ TemplateModel _eval(Environment env) throws TemplateException { } static class ends_withBI extends BuiltInForString { - + private class BIMethod implements TemplateMethodModelEx { private String s; - + private BIMethod(String s) { this.s = s; } - + @Override public Object exec(List args) throws TemplateModelException { checkMethodArgCount(args, 1); @@ -113,7 +113,7 @@ public Object exec(List args) throws TemplateModelException { TemplateBooleanModel.TRUE : TemplateBooleanModel.FALSE; } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new BIMethod(s); @@ -121,14 +121,14 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio } static class ensure_ends_withBI extends BuiltInForString { - + private class BIMethod implements TemplateMethodModelEx { private String s; - + private BIMethod(String s) { this.s = s; } - + @Override public Object exec(List args) throws TemplateModelException { checkMethodArgCount(args, 1); @@ -136,7 +136,7 @@ public Object exec(List args) throws TemplateModelException { return new SimpleScalar(s.endsWith(suffix) ? s : s + suffix); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new BIMethod(s); @@ -144,28 +144,28 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio } static class ensure_starts_withBI extends BuiltInForString { - + private class BIMethod implements TemplateMethodModelEx { private String s; - + private BIMethod(String s) { this.s = s; } - + @Override public Object exec(List args) throws TemplateModelException { checkMethodArgCount(args, 1, 3); - + final String checkedPrefix = getStringMethodArg(args, 0); - + final boolean startsWithPrefix; - final String addedPrefix; + final String addedPrefix; if (args.size() > 1) { addedPrefix = getStringMethodArg(args, 1); long flags = args.size() > 2 ? RegexpHelper.parseFlagString(getStringMethodArg(args, 2)) : RegexpHelper.RE_FLAG_REGEXP; - + if ((flags & RegexpHelper.RE_FLAG_REGEXP) == 0) { RegexpHelper.checkOnlyHasNonRegexpFlags(key, flags, true); if ((flags & RegexpHelper.RE_FLAG_CASE_INSENSITIVE) == 0) { @@ -177,7 +177,7 @@ public Object exec(List args) throws TemplateModelException { Pattern pattern = RegexpHelper.getPattern(checkedPrefix, (int) flags); final Matcher matcher = pattern.matcher(s); startsWithPrefix = matcher.lookingAt(); - } + } } else { startsWithPrefix = s.startsWith(checkedPrefix); addedPrefix = checkedPrefix; @@ -185,7 +185,7 @@ public Object exec(List args) throws TemplateModelException { return new SimpleScalar(startsWithPrefix ? s : addedPrefix + s); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new BIMethod(s); @@ -193,15 +193,15 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio } static class index_ofBI extends BuiltIn { - + 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(); @@ -215,13 +215,13 @@ public Object exec(List args) throws TemplateModelException { } } } - + private final boolean findLast; - + index_ofBI(boolean findLast) { this.findLast = findLast; } - + @Override TemplateModel _eval(Environment env) throws TemplateException { return new BIMethod(target.evalAndCoerceToStringOrUnsupportedMarkup(env, @@ -243,7 +243,7 @@ public Object exec(List args) throws TemplateModelException { checkMethodArgCount(argCnt, 1, 2); String separatorString = getStringMethodArg(args, 0); long flags = argCnt > 1 ? RegexpHelper.parseFlagString(getStringMethodArg(args, 1)) : 0; - + int startIndex; if ((flags & RegexpHelper.RE_FLAG_REGEXP) == 0) { RegexpHelper.checkOnlyHasNonRegexpFlags(key, flags, true); @@ -263,18 +263,18 @@ public Object exec(List args) throws TemplateModelException { } else { startIndex = -1; } - } + } return startIndex == -1 ? TemplateScalarModel.EMPTY_STRING : new SimpleScalar(s.substring(startIndex)); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateModelException { return new KeepAfterMethod(s); } - + } - + static class keep_after_lastBI extends BuiltInForString { class KeepAfterMethod implements TemplateMethodModelEx { private String s; @@ -289,7 +289,7 @@ public Object exec(List args) throws TemplateModelException { checkMethodArgCount(argCnt, 1, 2); String separatorString = getStringMethodArg(args, 0); long flags = argCnt > 1 ? RegexpHelper.parseFlagString(getStringMethodArg(args, 1)) : 0; - + int startIndex; if ((flags & RegexpHelper.RE_FLAG_REGEXP) == 0) { RegexpHelper.checkOnlyHasNonRegexpFlags(key, flags, true); @@ -316,18 +316,18 @@ public Object exec(List args) throws TemplateModelException { startIndex = -1; } } - } + } return startIndex == -1 ? TemplateScalarModel.EMPTY_STRING : new SimpleScalar(s.substring(startIndex)); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateModelException { return new KeepAfterMethod(s); } - + } - + static class keep_beforeBI extends BuiltInForString { class KeepUntilMethod implements TemplateMethodModelEx { private String s; @@ -342,7 +342,7 @@ public Object exec(List args) throws TemplateModelException { checkMethodArgCount(argCnt, 1, 2); String separatorString = getStringMethodArg(args, 0); long flags = argCnt > 1 ? RegexpHelper.parseFlagString(getStringMethodArg(args, 1)) : 0; - + int stopIndex; if ((flags & RegexpHelper.RE_FLAG_REGEXP) == 0) { RegexpHelper.checkOnlyHasNonRegexpFlags(key, flags, true); @@ -359,18 +359,18 @@ public Object exec(List args) throws TemplateModelException { } else { stopIndex = -1; } - } + } return stopIndex == -1 ? new SimpleScalar(s) : new SimpleScalar(s.substring(0, stopIndex)); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateModelException { return new KeepUntilMethod(s); } - + } - + // TODO static class keep_before_lastBI extends BuiltInForString { class KeepUntilMethod implements TemplateMethodModelEx { @@ -386,7 +386,7 @@ public Object exec(List args) throws TemplateModelException { checkMethodArgCount(argCnt, 1, 2); String separatorString = getStringMethodArg(args, 0); long flags = argCnt > 1 ? RegexpHelper.parseFlagString(getStringMethodArg(args, 1)) : 0; - + int stopIndex; if ((flags & RegexpHelper.RE_FLAG_REGEXP) == 0) { RegexpHelper.checkOnlyHasNonRegexpFlags(key, flags, true); @@ -410,26 +410,26 @@ public Object exec(List args) throws TemplateModelException { stopIndex = -1; } } - } + } return stopIndex == -1 ? new SimpleScalar(s) : new SimpleScalar(s.substring(0, stopIndex)); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateModelException { return new KeepUntilMethod(s); } - + } - + static class lengthBI extends BuiltInForString { - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new SimpleNumber(s.length()); } - - } + + } static class lower_caseBI extends BuiltInForString { @Override @@ -446,22 +446,22 @@ TemplateModel calculateResult(String s, Environment env) { } static class padBI 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(); + int argCnt = args.size(); checkMethodArgCount(argCnt, 1, 2); - + int width = getNumberMethodArg(args, 0).intValue(); - + if (argCnt > 1) { String filling = getStringMethodArg(args, 1); try { @@ -483,13 +483,13 @@ public Object exec(List args) throws TemplateModelException { } } } - + private final boolean leftPadder; - + padBI(boolean leftPadder) { this.leftPadder = leftPadder; } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new BIMethod(s); @@ -512,23 +512,7 @@ public Object exec(List args) throws TemplateModelException { 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()); + return new SimpleScalar(_CoreStringUtils.indent(s, prefix)); } } @@ -540,165 +524,31 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio static class dedentBI extends BuiltInForString { - private class BIMethod implements TemplateMethodModelEx { + private class BIMethod implements TemplateScalarModel, TemplateMethodModelEx { - private final String s; + private final String targetAsString; + private String cachedResult; - private BIMethod(String s) { - this.s = s; + private BIMethod(String targetAsString) { + this.targetAsString = targetAsString; } @Override public Object exec(List args) throws TemplateModelException { int argCnt = args.size(); - 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)); - } + checkMethodArgCount(argCnt, 1); - // 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()) { - 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()); + return new SimpleScalar(_CoreStringUtils.dedent(targetAsString, prefix)); } - } - /** - * 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; - } + @Override + public String getAsString() { + if (cachedResult == null) { + cachedResult = _CoreStringUtils.dedent(targetAsString); } + return cachedResult; } - return sb.toString(); } @Override @@ -711,16 +561,16 @@ static class wrapBI extends BuiltInForString { private class BIMethod implements TemplateMethodModelEx { - private final String s; + private final String targetAsString; - private BIMethod(String s) { - this.s = s; + private BIMethod(String targetAsString) { + this.targetAsString = targetAsString; } @Override public Object exec(List args) throws TemplateModelException { int argCnt = args.size(); - checkMethodArgCount(argCnt, 2, 3); + checkMethodArgCount(argCnt, 1, 3); int width = getNumberMethodArg(args, 0).intValue(); if (width < 1) { @@ -728,42 +578,22 @@ public Object exec(List args) throws 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; + String result; + if (argCnt == 1) { + result = _CoreStringUtils.wrap(targetAsString, width); + } else if (argCnt >= 2) { + String firstPrefix = getStringMethodArg(args, 1); + if (argCnt == 2) { + result = _CoreStringUtils.wrap(targetAsString, width, firstPrefix); } 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(); - } + String restPrefix = getStringMethodArg(args, 2); + result = _CoreStringUtils.wrap(targetAsString, width, firstPrefix, restPrefix); } + } else { + throw new BugException("Unexpected argCnt"); } - sb.append('\n'); - return new SimpleScalar(sb.toString()); + + return new SimpleScalar(result); } } @@ -788,51 +618,25 @@ public Object exec(List args) throws TemplateModelException { int argCnt = args.size(); checkMethodArgCount(argCnt, 1, 2); - int column = getNumberMethodArg(args, 0).intValue(); - if (column < 0) { + int width = getNumberMethodArg(args, 0).intValue(); + if (width < 0) { throw new _TemplateModelException( "?", key, "(...) argument #1 must be non-negative."); } - char fillChar = ' '; + String result; 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); + result = _CoreStringUtils.rightPadLines(s, width, filling.charAt(0)); + } else { + result = _CoreStringUtils.rightPadLines(s, width); } - 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()); + return new SimpleScalar(result); } } @@ -843,14 +647,14 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio } static class remove_beginningBI extends BuiltInForString { - + private class BIMethod implements TemplateMethodModelEx { private String s; - + private BIMethod(String s) { this.s = s; } - + @Override public Object exec(List args) throws TemplateModelException { checkMethodArgCount(args, 1); @@ -858,7 +662,7 @@ public Object exec(List args) throws TemplateModelException { return new SimpleScalar(s.startsWith(prefix) ? s.substring(prefix.length()) : s); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new BIMethod(s); @@ -866,14 +670,14 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio } static class remove_endingBI extends BuiltInForString { - + private class BIMethod implements TemplateMethodModelEx { private String s; - + private BIMethod(String s) { this.s = s; } - + @Override public Object exec(List args) throws TemplateModelException { checkMethodArgCount(args, 1); @@ -881,13 +685,13 @@ public Object exec(List args) throws TemplateModelException { return new SimpleScalar(s.endsWith(suffix) ? s.substring(0, s.length() - suffix.length()) : s); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new BIMethod(s); } } - + static class split_BI extends BuiltInForString { class SplitMethod implements TemplateMethodModel { private String s; @@ -910,27 +714,27 @@ public Object exec(List args) throws TemplateModelException { } else { Pattern pattern = RegexpHelper.getPattern(splitString, (int) flags); result = pattern.split(s); - } + } return ObjectWrapper.DEFAULT_WRAPPER.wrap(result); } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateModelException { return new SplitMethod(s); } - + } - + static class starts_withBI extends BuiltInForString { - + private class BIMethod implements TemplateMethodModelEx { private String s; - + private BIMethod(String s) { this.s = s; } - + @Override public Object exec(List args) throws TemplateModelException { checkMethodArgCount(args, 1); @@ -938,7 +742,7 @@ public Object exec(List args) throws TemplateModelException { TemplateBooleanModel.TRUE : TemplateBooleanModel.FALSE; } } - + @Override TemplateModel calculateResult(String s, Environment env) throws TemplateException { return new BIMethod(s); @@ -946,26 +750,26 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio } static class substringBI extends BuiltInForString { - + @Override TemplateModel calculateResult(final String s, final Environment env) throws TemplateException { return new TemplateMethodModelEx() { - + @Override public Object exec(java.util.List args) throws TemplateModelException { int argCount = args.size(); checkMethodArgCount(argCount, 1, 2); - + int beginIdx = getNumberMethodArg(args, 0).intValue(); - + final int len = s.length(); - + if (beginIdx < 0) { throw newIndexLessThan0Exception(0, beginIdx); } else if (beginIdx > len) { throw newIndexGreaterThanLengthException(0, beginIdx, len); } - + if (argCount > 1) { int endIdx = getNumberMethodArg(args, 1).intValue(); if (endIdx < 0) { @@ -984,7 +788,7 @@ public Object exec(java.util.List args) throws TemplateModelException { return new SimpleScalar(s.substring(beginIdx)); } } - + private TemplateModelException newIndexGreaterThanLengthException( int argIdx, int idx, final int len) throws TemplateModelException { return _MessageUtil.newMethodArgInvalidValueException( @@ -993,14 +797,14 @@ private TemplateModelException newIndexGreaterThanLengthException( Integer.valueOf(len), ", but it was ", Integer.valueOf(idx), "."); } - + private TemplateModelException newIndexLessThan0Exception( int argIdx, int idx) throws TemplateModelException { return _MessageUtil.newMethodArgInvalidValueException( "?" + key, argIdx, "The index must be at least 0, but was ", Integer.valueOf(idx), "."); } - + }; } } @@ -1030,7 +834,7 @@ public Object exec(java.util.List args) throws TemplateModelException { Integer terminatorLength; if (argCount > 1) { terminator = (TemplateModel) args.get(1); - if (!(terminator instanceof TemplateScalarModel)) { + if (!(terminator instanceof TemplateScalarModel)) { if (allowMarkupTerminator()) { if (!(terminator instanceof TemplateMarkupOutputModel)) { throw _MessageUtil.newMethodArgMustBeStringOrMarkupOutputException( @@ -1165,7 +969,7 @@ static class uncap_firstBI extends BuiltInForString { TemplateModel calculateResult(String s, Environment env) { int i = 0; int ln = s.length(); - while (i < ln && Character.isWhitespace(s.charAt(i))) { + while (i < ln && Character.isWhitespace(s.charAt(i))) { i++; } if (i < ln) { @@ -1197,13 +1001,14 @@ TemplateModel calculateResult(String s, Environment env) { SimpleSequence result = new SimpleSequence(_ObjectWrappers.SAFE_OBJECT_WRAPPER); StringTokenizer st = new StringTokenizer(s); while (st.hasMoreTokens()) { - result.add(st.nextToken()); + result.add(st.nextToken()); } return result; } } // Can't be instantiated - private BuiltInsForStringsBasic() { } - + private BuiltInsForStringsBasic() { + } + } diff --git a/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java index ce9fb9d18..989b2a371 100644 --- a/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java +++ b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java @@ -22,6 +22,7 @@ import java.util.Collection; import freemarker.template.Configuration; +import freemarker.template.utility.NullArgumentException; import freemarker.template.utility.StringUtil; /** @@ -154,4 +155,255 @@ public static String commaSeparatedJQuotedItems(Collection items) { } return sb.toString(); } + + public static String indent(String s, String prefix) { + if (s == null || s.isEmpty() || prefix.isEmpty()) { + return 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 sb.toString(); + } + + /** + * Remove the given prefix from each line that starts with it; leave other lines unchanged. + */ + public static String dedent(String s, String prefix) { + if (s == null || s.isEmpty() || prefix.isEmpty()) { + return 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 sb.toString(); + } + + /** + * 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. + */ + public static String dedent(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(); + } + + public static String wrap(String s, int width) { + return wrap(s, width, ""); + } + + public static String wrap(String s, int width, String firstPrefix) { + return wrap(s, width, firstPrefix, firstPrefix); + } + + public static String wrap(String s, int width, String firstPrefix, String restPrefix) { + NullArgumentException.check(firstPrefix, "firstPrefix"); + NullArgumentException.check(restPrefix, "restPrefix"); + if (width <= 0) { + throw new IllegalArgumentException("width must be at least 1"); + } + + String[] words = s.split("\\s+"); + if (words.length == 0 || (words.length == 1 && words[0].isEmpty())) { + return 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 sb.toString(); + } + + public static String rightPadLines(String s, int width) { + return rightPadLines(s, width, ' '); + } + + public static String rightPadLines(String s, int width, char fillChar) { + if (s.isEmpty()) { + return s; + } + + if (width < 0) { + throw new IllegalArgumentException("width must be non-negative"); + } + + StringBuilder sb = new StringBuilder(s.length() + width); + 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 < width; 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 sb.toString(); + } + } diff --git a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java index 6ef8d4ab8..2d91ee05e 100644 --- a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -20,279 +20,120 @@ import static org.junit.Assert.*; -import java.io.StringReader; -import java.io.StringWriter; -import java.util.HashMap; -import java.util.Map; +import java.io.IOException; import org.junit.Test; import freemarker.template.Configuration; -import freemarker.template.Template; import freemarker.template.TemplateException; +import freemarker.test.TemplateTest; -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(' ')")); - } +/** + * Checks indent/dedend/wrap built-ns; for the thorough testing of the text transformations see the + * {@link _CoreStringUtilsTest}! + */ +public class IndentAndWrapBuiltInTest extends TemplateTest { - @Test - public void testDedentEmptyString() throws Exception { - assertEquals("", eval("''?dedent(' ')")); + @Override + protected Configuration createConfiguration() throws Exception { + return new Configuration(Configuration.VERSION_2_3_35); } @Test - public void testDedentEmptyPrefix() throws Exception { - assertEquals(" hello", eval("' hello'?dedent('')")); + public void testIndentBasic() throws Exception { + assertExpOutput("'line1\\nline2'?indent(' * ')", " * line1\n * line2"); } @Test - public void testDedentNoTrailingNewline() throws Exception { - assertEquals("hello", - eval("' hello'?dedent(' ')")); + public void testIndentBadNumberOfArgs() { + assertErrorContains("${''?indent()}", "?indent", "expects 1 argument"); + assertErrorContains("${''?indent(1, 2)}", "?indent", "expects 1 argument"); } @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)); + public void testWrap1Arg() throws Exception { + assertExpOutput("'Hello world'?wrap(4)", "Hello\nworld\n"); + assertExpOutput("'Hello world'?wrap(40)", "Hello world\n"); } - // ---- ?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()")); + public void testWrap2Arg() throws Exception { + assertExpOutput("'Hello world'?wrap(4, '* ')", "* Hello\n* world\n"); } @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()")); + public void testWrap3Args() throws Exception { + assertExpOutput("'Hello world'?wrap(4, '* ', ' ')", "* Hello\n world\n"); } @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()")); + public void testWrapNoArgTypeCoercion() throws Exception { + assertErrorContains("${''?wrap(4, 1)}", "string as argument #2"); } @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()")); + public void testWrapBadNumberOfArgs() { + assertErrorContains("${''?wrap()}", "?wrap", "expects 1 to 3 arguments"); + assertErrorContains("${''?wrap(4, '*', '**', '***')}", "?wrap", "expects 1 to 3 arguments"); } @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()")); + public void testWrapArg1AtLeast1() throws TemplateException, IOException { + assertErrorContains("${''?wrap(0, '* ')}", "width", "at least 1"); + assertErrorContains("${''?wrap(-1, '* ')}", "width", "at least 1"); } @Test - public void testDedentNoArgsTabsOnly() throws Exception { - assertEquals("a\nb", - eval("'\\t\\ta\\n\\t\\tb'?dedent()")); + public void testWrapBadArgTypeError() { + assertErrorContains("${''?wrap('4', '*')}", "number as argument #1"); } @Test - public void testDedentNoArgsEmptyString() throws Exception { - assertEquals("", eval("''?dedent()")); + public void testDedent1Arg() throws Exception { + assertExpOutput("' int x;\\n int y;\\n'?dedent(' ')", "int x;\nint y;\n"); + assertExpOutput("' hello'?dedent('')", " hello"); } @Test - public void testDedentNoArgsAlreadyDedented() throws Exception { - // No common leading whitespace => no change. - assertEquals("a\nb\nc", - eval("'a\\nb\\nc'?dedent()")); + public void testDedent0Arg() throws Exception { + assertExpOutput("' a\\n b\\n c'?dedent", "a\n b\nc"); } - // ---- ?right_pad_lines tests ---- - @Test - public void testRightPadLinesBasic() throws Exception { - assertEquals("a \nbb \nccc \n", - eval("'a\\nbb\\nccc\\n'?right_pad_lines(10)")); + public void testDedentBadNumberOfArgs() { + assertErrorContains("${''?dedent()}", "?dedent", "expects 1 argument"); + assertErrorContains("${''?dedent(' ', 2)}", "?dedent", "expects 1 argument"); } @Test - public void testRightPadLinesWithFillChar() throws Exception { - assertEquals("a.........\nbb........\n", - eval("'a\\nbb\\n'?right_pad_lines(10, '.')")); + public void testDedentNoArgTypeCoercion() throws Exception { + assertErrorContains("${''?dedent(1)}", "string as argument #1"); } @Test - 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'?right_pad_lines(5)")); + public void testRightPad1Arg() throws Exception { + assertExpOutput("'a\nbb\nccc'?right_pad_lines(5)", "a \nbb \nccc "); } @Test - public void testRightPadLinesNoTrailingNewline() throws Exception { - assertEquals("a ", - eval("'a'?right_pad_lines(10)")); + public void testRightPad2Arg() throws Exception { + assertExpOutput("'a\nbb\nccc'?right_pad_lines(5, '.')", "a....\nbb...\nccc.."); } @Test - public void testRightPadLinesEmpty() throws Exception { - assertEquals("", eval("''?right_pad_lines(10)")); + public void testRightPadLinesCamelCase() throws Exception { + assertExpOutput("'a\nbb\nccc'?rightPadLines(5, '.')", "a....\nbb...\nccc.."); } @Test - public void testRightPadLinesCamelCase() throws Exception { - assertEquals("a \nbb \n", - eval("'a\\nbb\\n'?rightPadLines(5)")); + public void testRightPadLinesBadNumberOfArgs() { + assertErrorContains("${''?right_pad_lines()}", "?right_pad_lines", "expects 1 or 2 arguments"); + assertErrorContains("${''?rightPadLines(1, '.', 3)}", "?rightPadLines", "expects 1 or 2 arguments"); } @Test - 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?right_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]); + public void testRightPadLinesNoArgTypeCoercion() throws Exception { + assertErrorContains("${''?right_pad_lines('1', '.')}", "number as argument #1"); + assertErrorContains("${''?right_pad_lines(1, 2)}", "string as argument #2"); } } diff --git a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java new file mode 100644 index 000000000..c9db7df23 --- /dev/null +++ b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java @@ -0,0 +1,391 @@ +package freemarker.core; + +import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import freemarker.template.utility.StringUtil; +import freemarker.test.hamcerst.Matchers; + +public class _CoreStringUtilsTest { + + // ---- indent tests ---- + + @Test + public void testIndentSingleLine() { + assertEquals( + " hello", + _CoreStringUtils.indent("hello", " ")); + } + + @Test + public void testIndentMultiLine() { + assertEquals( + " line1\n line2\n line3", + _CoreStringUtils.indent("line1\nline2\nline3", " ")); + } + + @Test + public void testIndentWithPrefix() { + assertEquals( + " * line1\n * line2", + _CoreStringUtils.indent("line1\nline2", " * ")); + } + + @Test + public void testIndentEmptyString() { + assertEquals( + "", + _CoreStringUtils.indent("", " ")); + } + + @Test + public void testIndentPreservesBlankLines() { + assertEquals( + " a\n\n b", + _CoreStringUtils.indent("a\n\nb", " ")); + } + + @Test + public void testIndentTrailingNewline() { + assertEquals( + " a\n b\n", + _CoreStringUtils.indent("a\nb\n", " ")); + } + + // ---- wrap tests ---- + + @Test + public void testWrapBasic() { + assertEquals( + " * @brief Hello world.\n", + _CoreStringUtils.wrap("Hello world.", 40, " * @brief ")); + } + + @Test + public void testWrapLongTextNoPrefix() { + testWrapLongText(null, null); + } + + @Test + public void testWrapLongTextFirstPrefixOnly() { + testWrapLongText(" * ", null); + } + + @Test + public void testWrapLongTextWithDifferentPrefixes() { + testWrapLongText(" * @brief ", " * "); + } + + private void testWrapLongText(String firstPrefix, String restPrefix) { + for (int width = 30; width <= 100; width += 10) { + testWrapLongText(width, firstPrefix, restPrefix); + } + } + + private void testWrapLongText(int width, String firstPrefix, String restPrefix) { + String text = "This is a description that needs wrapping to fit within bounds. Also it's a very long text."; + + String result = + firstPrefix == null ? _CoreStringUtils.wrap(text, width) + : restPrefix == null ? _CoreStringUtils.wrap(text, width, firstPrefix) + : _CoreStringUtils.wrap(text, width, firstPrefix, restPrefix); + + String effFirstPrefix = firstPrefix != null ? firstPrefix : ""; + String effRestPrefix = restPrefix != null ? restPrefix : effFirstPrefix; + + assertTrue(result.startsWith(effFirstPrefix)); + + // Second line should start with rest prefix + String[] lines = result.split("\n", -1); + for (int i = 1; i < lines.length - 1; i++) { + String line = lines[i]; + if (line.length() > width) { + fail("Line " + i + " is too long: " + StringUtil.jQuote(line)); + } + if (!line.startsWith(effRestPrefix)) { + fail("Line " + i + " doesn't start as expected: " + StringUtil.jQuote(line)); + } + } + + assertEquals("", lines[lines.length - 1]); + assertTrue(result.endsWith("\n")); + } + + @Test + public void testWrapSamePrefix() { + assertEquals( + "// hello world\n", + _CoreStringUtils.wrap("hello world", 40, "// ")); + } + + @Test + public void testWrapSingleLongWord() { + // A single word longer than width — can't break, just emit it + assertEquals( + "superlongword\n", + _CoreStringUtils.wrap("superlongword", 4, "")); + // Not even after the prefix + assertEquals( + " * superlongword\n", + _CoreStringUtils.wrap("superlongword", 4, " * ")); + } + + @Test + public void testWrapCollapsesWhitespaces() { + assertEquals( + "a b c d e\n", + _CoreStringUtils.wrap(" a \n b \n c\t\td e ", 40, "")); + } + + @Test + public void testWrapWithNbsp() { + // No NBSP: + assertEquals( + "word1\nword2\nword3\nword4\n", + _CoreStringUtils.wrap("word1 word2 word3 word4", 4)); + // With NBSP: + assertEquals( + "word1\u00A0word2\u00A0word3\u00A0word4\n", + _CoreStringUtils.wrap("word1\u00A0word2\u00A0word3\u00A0word4", 4)); + assertEquals( + "word1\u00A0word2\nword3\u00A0word4\n", + _CoreStringUtils.wrap("word1\u00A0word2 word3\u00A0word4", 4)); + assertEquals( + "word1\u00A0\nword2\n\u00A0word3\n", + _CoreStringUtils.wrap("word1\u00A0 word2 \u00A0word3", 4)); + assertEquals( + "\u00A0 a \u00A0\u00A0 b \u00A0\n", + _CoreStringUtils.wrap(" \u00A0 a \u00A0\u00A0 b \u00A0 ", 40, "")); + } + + @Test + public void testWrapWithInputLeadingTrailingEmptyLinesDoesntMatter() { + assertEquals( + "word1\nword2\n", + _CoreStringUtils.wrap("word1 word2", 4)); + assertEquals( + "word1\nword2\n", + _CoreStringUtils.wrap("word1 word2\n", 4)); + assertEquals( + "word1\nword2\n", + _CoreStringUtils.wrap("\n\nword1 word2\n\n", 4)); + } + + @Test + public void testWrapZeroWidthThrows() { + try { + _CoreStringUtils.wrap("hello", 0); + fail(); + } catch (IllegalArgumentException e) { + assertThat( + e.getMessage(), + Matchers.containsStringIgnoringCase("must be at least 1")); + } + } + + // ---- dedent tests ---- + + @Test + public void testDedentBasic() { + assertEquals( + "int x;\nint y;\n", + _CoreStringUtils.dedent(" int x;\n int y;\n", " ") + ); + } + + @Test + public void testDedentNoMatch() { + // 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", + _CoreStringUtils.dedent(" short\n full\n", " ") + ); + } + + @Test + public void testDedentMixed() { + // Some lines match, some don't + assertEquals( + "a\n b\nc\n", + _CoreStringUtils.dedent(" a\n b\n c\n", " ") + ); + } + + @Test + public void testDedentEmptyString() { + assertEquals( + "", + _CoreStringUtils.dedent("", " ") + ); + } + + @Test + public void testDedentEmptyPrefix() { + assertEquals( + " hello", + _CoreStringUtils.dedent(" hello", "") + ); + } + + @Test + public void testDedentNoTrailingNewline() { + assertEquals( + "hello", + _CoreStringUtils.dedent(" hello", " ") + ); + } + + @Test + public void testDedentSymmetryWithIndent() { + // indent then dedent should round-trip + String text = "line1\n line2\nline3"; + String prefix = " "; + assertEquals( + text, + _CoreStringUtils.dedent(_CoreStringUtils.indent(text, prefix), prefix) + ); + } + + // ---- dedent no-args (Python textwrap.dedent-style) tests ---- + + @Test + public void testDedentNoArgsUniformIndent() { + assertEquals( + "a\nb\nc", + _CoreStringUtils.dedent(" a\n b\n c") + ); + } + + @Test + public void testDedentNoArgsMixedIndent() { + // The longest common leading whitespace across non-empty lines is 2 spaces. + assertEquals( + "a\n b\n c", + _CoreStringUtils.dedent(" a\n b\n c") + ); + } + + @Test + public void testDedentNoArgsRespectsEmptyLines() { + // Empty/whitespace-only lines are ignored when computing the common prefix + // and pass through unchanged. + assertEquals( + "a\n\nb", + _CoreStringUtils.dedent(" a\n\n b") + ); + } + + @Test + public void testDedentNoArgsNoCommonPrefix() { + // If lines have no common leading whitespace, nothing is stripped. + assertEquals( + "a\n b", + _CoreStringUtils.dedent("a\n b") + ); + } + + @Test + public void testDedentNoArgsTabAndSpaceDistinct() { + // A leading tab and a leading space have no common prefix. + // (Same behaviour as Python textwrap.dedent.) + assertEquals( + "\ta\n b", + _CoreStringUtils.dedent("\ta\n b") + ); + } + + @Test + public void testDedentNoArgsTabsOnly() { + assertEquals( + "a\nb", + _CoreStringUtils.dedent("\t\ta\n\t\tb") + ); + } + + @Test + public void testDedentNoArgsEmptyString() { + assertEquals( + "", + _CoreStringUtils.dedent("") + ); + } + + @Test + public void testDedentNoArgsAlreadyDedented() { + // No common leading whitespace => no change. + assertEquals( + "a\nb\nc", + _CoreStringUtils.dedent("a\nb\nc") + ); + } + + // ---- rightPadLines tests ---- + + @Test + public void testRightPadLinesBasic() { + assertEquals( + "a \nbb \nccc \n", + _CoreStringUtils.rightPadLines("a\nbb\nccc\n", 10) + ); + } + + @Test + public void testRightPadLinesWithFillChar() { + assertEquals( + "a.........\nbb........\n", + _CoreStringUtils.rightPadLines("a\nbb\n", 10, '.') + ); + } + + @Test + public void testRightPadLinesLinePastColumn() { + // "long line" (9 chars) past column 5 — no padding + // "ab" (2 chars) shorter than column 5 — padded + assertEquals( + "long line\nab \n", + _CoreStringUtils.rightPadLines("long line\nab\n", 5) + ); + } + + @Test + public void testRightPadLinesNoTrailingNewline() { + assertEquals( + "a ", + _CoreStringUtils.rightPadLines("a", 10) + ); + } + + @Test + public void testRightPadLinesEmpty() { + assertEquals( + "", + _CoreStringUtils.rightPadLines("", 10) + ); + } + + @Test + public void testRightPadLinesCamelCase() { + assertEquals( + "a \nbb \n", + _CoreStringUtils.rightPadLines("a\nbb\n", 5) + ); + } + + @Test + public void testRightPadLinesCodeAlignment() { + // Practical use: align code for trailing comments + String code = "int x;\nString name;\nboolean active;\n"; + String result = _CoreStringUtils.rightPadLines(code, 20); + String[] lines = result.split("\n", -1); + assertEquals("int x; ", lines[0]); + assertEquals("String name; ", lines[1]); + assertEquals("boolean active; ", lines[2]); + assertEquals("", lines[3]); + } + +} diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index 2e78de80c..8ac13aa18 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -11298,8 +11298,8 @@ TemplateHashModel fileStatics = And you will get a template hash model that exposes all static methods and static fields (both final and non-final) of the - java.io.File class as hash keys. Suppose that - you put the previous model in your root model: + java.io.File class as hash keys. Suppose that you + put the previous model in your root model: root.put("File", fileStatics); @@ -14071,17 +14071,17 @@ Green Mouse This built-in is available since FreeMarker 2.3.35. - 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 Removes a leading 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. + built-in. Line-breaks can be LF (Linux), or + CRLF (DOS/Windows), even CR + (old Mac), and are kept as is. The no-argument form - (?dedent()) finds the longest leading whitespace + (?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 @@ -14091,14 +14091,15 @@ Green Mouse For example: - <#assign code = " int x;\n int y;\n int z;" /> -[${code?dedent()}] + <#assign code = " if (x) {\n foo();\n }" /> +[${code?dedent}] - will output this (the common prefix is 2 spaces): + will output this (the common prefix was 2 spaces, which was + removed): - [int x; - int y; -int z;] + if (x) { + foo(); +} A leading tab and a leading space are treated as distinct characters (they have no common prefix), matching Python's @@ -14106,29 +14107,38 @@ int z;] 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: + 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(" ")}] + <#assign code = " if (x) {\n foo();\n }" /> +${code?dedent(" ")} - will output this: + will output this (removed just 1 space of indentation, so the + 1st line still has 1 space of indentation, and the 2nd has 3 spaces + of indentation): - [int x; -int y;] + if (x) { + foo(); + } Lines that don't start with the prefix are unaffected, so with a 4-space prefix: - <#assign text = " short\n long" /> -${text?dedent(" ")} + <#assign code = " if (x) {\n foo();\n }" /> +${code?dedent(" ")} + + will output this (the second line had at least 4 space + indentation, so 4 were removed, the other lines only had 2 spaces, + so they were left alone): - 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): + if (x) { +foo(); + } - short - long + See also the indent + built-in, which is its inverse.
@@ -14445,19 +14455,20 @@ R&amp;D 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. + is the empty last line) are not prefixed. Line-breaks can be + LF (Linux), or CRLF + (DOS/Windows), even CR (old Mac), and are kept as + is. For example, this: <#assign code = "int x;\nint y;" /> -[${code?indent(" ")}] +${code?indent(" ")} will output this: - [ int x; - int y;] + int x; + int y; Another example, using a non-whitespace prefix: @@ -14469,9 +14480,9 @@ ${text?indent(" * ")} * First line. * Second line. - See also: the dedent - built-in, which is its inverse. + See also the dedent + built-in, which is its inverse.
@@ -15164,25 +15175,28 @@ ${s?no_esc} 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. + separately, which is useful for aligning multi-line text. Empty + lines are not padded. Line-breaks can be LF + (Linux), or CRLF (DOS/Windows), even + CR (old Mac), and are kept as is. For example, this: <#assign code = "int x;\nString name;\nboolean active;" /> ${code?right_pad_lines(20)}done - will output this (each line padded to width 20): + will output this (each line padded to width 20, the [BR] is only shown below to + illustrate the line-break): - int x; -String name; + int x; [BR] +String name; [BR] 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: + 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"?right_pad_lines(5, ".")} @@ -16170,48 +16184,83 @@ ${x?url} 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. + Word-wraps the string so that if possible, no line is longer + than the width given as the 1st parameter. It breaks line at white-space only. A section + without white-space that's longer than the width is emitted on its + own line without being broken. - This is useful for generating wrapped comments, such as - documentation blocks. For example: + White-space + treatment: - <#assign text = "This is a long description that should be wrapped" /> -${text?wrap(40, " * @brief ", " * ")} + + + Each continuous sequence of white-space characters in the + input is in effect collapsed to a single space, or removed if + they are before a word-wrapping line-break + - will output this: + + The result always ends with a single line-break + - * @brief This is a long description - * that should be wrapped + + All leading and trailing whitespace of the input + (including line-breaks!) is removed in effect + + + + Non-breaking space (U+00A0) is not + treated as white-space by this built-in. (We treat the Java regular expression + \s as white-space, which does + not include the non-breaking + space.) + + - With a single prefix used for all lines: + Example: - ${"A comment that needs to be wrapped at a reasonable width"?wrap(40, "// ")} + ${"Some long text that need to be wrapper at reasonable width"?wrap(25)} will output this: - // A comment that needs to be wrapped at -// a reasonable width + Some long text that need +to be wrapper at +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. + The width parameter is truncated to integer, and must be at + least 1. - - 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. - +
+ Adding line suffixed + + An optional 2nd parameter is a prefix prepended to the first + output line. The optional 3rd parameter is a prefix prepended to + all subsequent lines. If the 3rd parameter is omitted, the 2nd + parameter is used for all lines. + + 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 +
@@ -30849,6 +30898,21 @@ TemplateModel x = env.getVariable("x"); // get variable x Release date: [TODO] +
+ Changes on the FTL side + + + + New built-ins, mostly useful for source code (or + configuration file) generation: dedent, indent, right_pad_lines, + wrap + + +
+
Changes on the Java side diff --git a/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java b/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java index c67117ffa..73845351c 100644 --- a/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java +++ b/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java @@ -99,6 +99,11 @@ protected void assertOutput(String ftl, String expectedOut) throws IOException, assertOutput(createTemplate(ftl), expectedOut, false); } + // !!T exchange params + protected void assertExpOutput(String ftlExpression, String expectedOut) throws IOException, TemplateException { + assertOutput("${" + ftlExpression + "}", expectedOut); + } + private Template createTemplate(String ftl) throws IOException { Template t = new Template(null, ftl, getConfiguration()); return t; From a633172c73b8fd9a4b746913ed68d403c398c8c0 Mon Sep 17 00:00:00 2001 From: Giovanni Di Sirio Date: Sun, 2 Aug 2026 18:27:15 +0200 Subject: [PATCH 6/9] Add the missing ASF license header to _CoreStringUtilsTest.java. Without it the rat and ratDistSrc tasks fail with "1 unapproved license", so ./gradlew check doesn't pass. --- .../freemarker/core/_CoreStringUtilsTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java index c9db7df23..32c9d4894 100644 --- a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java +++ b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java @@ -1,3 +1,21 @@ +/* + * 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.*; From e0f32f00f9138ed786b51f43cfdf5bf3c302e3e9 Mon Sep 17 00:00:00 2001 From: Giovanni Di Sirio Date: Mon, 3 Aug 2026 19:35:24 +0200 Subject: [PATCH 7/9] Review feedback: dedent partial prefixes, indent every line, drop ?right_pad_lines. ?dedent(prefix) now removes from each line the longest prefix of the parameter that the line actually starts with, instead of only acting on lines that carry the whole prefix. Since ?indent adds the prefix unconditionally, the all-or-nothing behavior could leave a line that was originally the least indented as the most indented one. ?indent(prefix) now adds the prefix to every line, including empty ones, and then right-trims each resulting line. One rule then gives the wanted result in both cases: with a "# " prefix an empty line becomes "#" rather than a line with trailing whitespace, and with a whitespace-only prefix it becomes empty, which is what it already did. An optional 2nd boolean argument switches the trimming off; it defaults to true, so this isn't a behavioral change for whitespace indentation. Lines that contain whitespace only are now treated as empty throughout. Previously the no-argument ?dedent ignored them when computing the common prefix while ?indent treated them as content, so the family disagreed with itself. In generated output such whitespace is an accident of whichever loop emitted it, so behavior shouldn't depend on it. This also matches textwrap.dedent, which normalizes such lines to empty. ?right_pad_lines is removed. Aligning a column of lines is better done by splitting and looping, which keeps all the single-line built-ins available and, unlike a whole-block built-in, lets you append something after the padding of each line -- which the motivating use case (trailing "\" macro continuations) requires. Added getBooleanMethodArg/getOptBooleanMethodArg to BuiltIn, next to the existing string and number variants. --- .../main/java/freemarker/core/BuiltIn.java | 24 ++- .../core/BuiltInsForStringsBasic.java | 48 +---- .../freemarker/core/_CoreStringUtils.java | 196 ++++++++++-------- .../core/IndentAndWrapBuiltInTest.java | 47 ++--- .../freemarker/core/_CoreStringUtilsTest.java | 167 ++++++++------- .../src/main/docgen/en_US/book.xml | 168 +++++++-------- 6 files changed, 323 insertions(+), 327 deletions(-) diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java index b77314386..d14c1e2cf 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java @@ -67,6 +67,7 @@ import freemarker.core.BuiltInsForStringsMisc.evalBI; import freemarker.core.BuiltInsForStringsMisc.evalJsonBI; import freemarker.template.Configuration; +import freemarker.template.TemplateBooleanModel; import freemarker.template.TemplateDateModel; import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; @@ -274,7 +275,6 @@ 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()); @@ -495,6 +495,28 @@ protected final Number getNumberMethodArg(List args, int argIdx) } } + /** + * Same as {@link #getBooleanMethodArg}, but checks if {@code args} is big enough, and returns {@code defaultValue} + * if it isn't. + */ + protected final boolean getOptBooleanMethodArg(List args, int argIdx, boolean defaultValue) + throws TemplateModelException { + return args.size() > argIdx ? getBooleanMethodArg(args, argIdx) : defaultValue; + } + + /** + * Gets a method argument and checks if it's a boolean; it does NOT check if {@code args} is big enough. + */ + protected final boolean getBooleanMethodArg(List args, int argIdx) + throws TemplateModelException { + TemplateModel arg = (TemplateModel) args.get(argIdx); + if (!(arg instanceof TemplateBooleanModel)) { + throw _MessageUtil.newMethodArgMustBeBooleanException("?" + key, argIdx, arg); + } else { + return ((TemplateBooleanModel) arg).getAsBoolean(); + } + } + protected final TemplateModelException newMethodArgInvalidValueException(int argIdx, Object[] details) { return _MessageUtil.newMethodArgInvalidValueException("?" + key, argIdx, details); } diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java index d1c8fce23..f03f784db 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java @@ -509,10 +509,11 @@ private BIMethod(String s) { @Override public Object exec(List args) throws TemplateModelException { int argCnt = args.size(); - checkMethodArgCount(argCnt, 1, 1); + checkMethodArgCount(argCnt, 1, 2); String prefix = getStringMethodArg(args, 0); - return new SimpleScalar(_CoreStringUtils.indent(s, prefix)); + boolean rightTrim = getOptBooleanMethodArg(args, 1, true); + return new SimpleScalar(_CoreStringUtils.indent(s, prefix, rightTrim)); } } @@ -603,49 +604,6 @@ TemplateModel calculateResult(String s, Environment env) throws TemplateExceptio } } - static class right_pad_linesBI 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 width = getNumberMethodArg(args, 0).intValue(); - if (width < 0) { - throw new _TemplateModelException( - "?", key, "(...) argument #1 must be non-negative."); - } - - String result; - if (argCnt > 1) { - String filling = getStringMethodArg(args, 1); - if (filling.length() != 1) { - throw new _TemplateModelException( - "?", key, "(...) argument #2 must be a single character string."); - } - result = _CoreStringUtils.rightPadLines(s, width, filling.charAt(0)); - } else { - result = _CoreStringUtils.rightPadLines(s, width); - } - - return new SimpleScalar(result); - } - } - - @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/main/java/freemarker/core/_CoreStringUtils.java b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java index 989b2a371..9d5679cd8 100644 --- a/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java +++ b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java @@ -156,27 +156,62 @@ public static String commaSeparatedJQuotedItems(Collection items) { return sb.toString(); } + /** + * Same as {@link #indent(String, String, boolean)} with {@code rightTrim} set to {@code true}. + */ public static String indent(String s, String prefix) { - if (s == null || s.isEmpty() || prefix.isEmpty()) { + return indent(s, prefix, true); + } + + /** + * Prepends {@code prefix} to each line, then, if {@code rightTrim} is {@code true}, removes the trailing + * whitespace of each resulting line. + * + *

The prefix is added unconditionally, including to lines that are empty or contain whitespace only. The + * right-trimming is what keeps that from leaving junk behind: with a prefix like {@code "# "} an empty line + * becomes {@code "#"} rather than a line with a trailing space, and with a whitespace-only prefix it becomes + * empty. That's also why empty and whitespace-only lines end up treated alike, without either being a special + * case in the code. + * + *

Note that a non-breaking space (U+00A0) isn't whitespace as far as trimming is concerned, so it's kept; + * that's the point of a non-breaking space. + */ + public static String indent(String s, String prefix, boolean rightTrim) { + if (s == null || s.isEmpty() || (prefix.isEmpty() && !rightTrim)) { return 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); + StringBuilder sb = new StringBuilder(len + prefix.length() * 8); + int i = 0; + while (i < len) { + int lineEnd = findLineEnd(s, i); + + int lineStartInSb = sb.length(); + sb.append(prefix); + sb.append(s, i, lineEnd); + if (rightTrim) { + int end = sb.length(); + while (end > lineStartInSb && isTrimmableSpace(sb.charAt(end - 1))) { + end--; + } + sb.setLength(end); } - sb.append(c); - atLineStart = (c == '\n' || (c == '\r' && (i + 1 >= len || s.charAt(i + 1) != '\n'))); + + i = appendEol(s, lineEnd, sb); } return sb.toString(); } /** - * Remove the given prefix from each line that starts with it; leave other lines unchanged. + * Removes from each line the longest prefix of {@code prefix} that the line starts with. Lines that carry the + * whole prefix lose all of it; lines that only carry part of it lose that part; lines that share nothing with it + * are left alone. + * + *

This deliberately doesn't require an exact match. Since {@link #indent(String, String, boolean)} adds the + * prefix unconditionally, an all-or-nothing dedent could leave a line that was originally the least indented as + * the most indented one — so partial matches are shortened rather than ignored. Whitespace-only lines lose their + * whitespace up to the length of the prefix, which is what makes them behave like empty lines here. */ public static String dedent(String s, String prefix) { if (s == null || s.isEmpty() || prefix.isEmpty()) { @@ -184,53 +219,71 @@ public static String dedent(String s, String prefix) { } 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; + StringBuilder sb = new StringBuilder(len); + int i = 0; + while (i < len) { + int lineEnd = findLineEnd(s, i); + + int matched = 0; + while (matched < prefixLen && i + matched < lineEnd + && s.charAt(i + matched) == prefix.charAt(matched)) { + matched++; } - } - // Handle trailing partial match (line without newline) - if (stripping && matchPos > 0 && matchPos < prefixLen) { - sb.append(prefix, 0, matchPos); + sb.append(s, i + matched, lineEnd); + + i = appendEol(s, lineEnd, sb); } return sb.toString(); } + /** + * Returns the index of the first line-terminator character at or after {@code from}, or the length of {@code s} + * if there's none. + */ + private static int findLineEnd(String s, int from) { + int len = s.length(); + int i = from; + while (i < len && s.charAt(i) != '\n' && s.charAt(i) != '\r') { + i++; + } + return i; + } + + /** + * Appends the line terminator found at {@code lineEnd} (if any, treating {@code "\r\n"} as one) to {@code sb}, + * and returns the index at which the next line starts. + */ + private static int appendEol(String s, int lineEnd, StringBuilder sb) { + int len = s.length(); + if (lineEnd >= len) { + return len; + } + char c = s.charAt(lineEnd); + sb.append(c); + if (c == '\r' && lineEnd + 1 < len && s.charAt(lineEnd + 1) == '\n') { + sb.append('\n'); + return lineEnd + 2; + } + return lineEnd + 1; + } + + /** + * Whether the character counts as trailing whitespace for right-trimming purposes. Line terminators are + * excluded, as they're handled separately, and so is anything that {@link Character#isWhitespace(char)} rejects + * (notably the non-breaking space). + */ + private static boolean isTrimmableSpace(char c) { + return c != '\n' && c != '\r' && Character.isWhitespace(c); + } + /** * 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. + * is a common prefix of every non-empty line. Lines that are empty or + * contain whitespace only are ignored when computing the prefix, and are + * 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. */ public static String dedent(String s) { if (s.isEmpty()) { @@ -299,10 +352,10 @@ public static String dedent(String s) { 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); } + // Else: a whitespace-only line, which is normalized to empty rather than kept as is. Its + // whitespace is accidental (whatever the emitting loop happened to produce), and keeping it would + // mean leaving trailing whitespace behind. This also matches textwrap.dedent. if (!atEnd) { sb.append(s.charAt(i)); if (s.charAt(i) == '\r' && i + 1 < len && s.charAt(i + 1) == '\n') { @@ -366,44 +419,5 @@ public static String wrap(String s, int width, String firstPrefix, String restPr return sb.toString(); } - public static String rightPadLines(String s, int width) { - return rightPadLines(s, width, ' '); - } - - public static String rightPadLines(String s, int width, char fillChar) { - if (s.isEmpty()) { - return s; - } - - if (width < 0) { - throw new IllegalArgumentException("width must be non-negative"); - } - - StringBuilder sb = new StringBuilder(s.length() + width); - 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 < width; 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 sb.toString(); - } } diff --git a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java index 2d91ee05e..ab80f64d5 100644 --- a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -44,10 +44,27 @@ public void testIndentBasic() throws Exception { assertExpOutput("'line1\\nline2'?indent(' * ')", " * line1\n * line2"); } + @Test + public void testIndent2Arg() throws Exception { + assertExpOutput("'a\\n\\nb'?indent('# ', true)", "# a\n#\n# b"); + assertExpOutput("'a\\n\\nb'?indent('# ', false)", "# a\n# \n# b"); + } + + @Test + public void testIndentRightTrimDefaultsToTrue() throws Exception { + assertExpOutput("'a\\n\\nb'?indent('# ')", "# a\n#\n# b"); + } + @Test public void testIndentBadNumberOfArgs() { - assertErrorContains("${''?indent()}", "?indent", "expects 1 argument"); - assertErrorContains("${''?indent(1, 2)}", "?indent", "expects 1 argument"); + assertErrorContains("${''?indent()}", "?indent", "expects 1 or 2 arguments"); + assertErrorContains("${''?indent(1, 2, 3)}", "?indent", "expects 1 or 2 arguments"); + } + + @Test + public void testIndentArgTypeCoercion() { + assertErrorContains("${''?indent(1)}", "string as argument #1"); + assertErrorContains("${''?indent(' ', 'yes')}", "boolean as argument #2"); } @Test @@ -110,30 +127,4 @@ public void testDedentNoArgTypeCoercion() throws Exception { assertErrorContains("${''?dedent(1)}", "string as argument #1"); } - @Test - public void testRightPad1Arg() throws Exception { - assertExpOutput("'a\nbb\nccc'?right_pad_lines(5)", "a \nbb \nccc "); - } - - @Test - public void testRightPad2Arg() throws Exception { - assertExpOutput("'a\nbb\nccc'?right_pad_lines(5, '.')", "a....\nbb...\nccc.."); - } - - @Test - public void testRightPadLinesCamelCase() throws Exception { - assertExpOutput("'a\nbb\nccc'?rightPadLines(5, '.')", "a....\nbb...\nccc.."); - } - - @Test - public void testRightPadLinesBadNumberOfArgs() { - assertErrorContains("${''?right_pad_lines()}", "?right_pad_lines", "expects 1 or 2 arguments"); - assertErrorContains("${''?rightPadLines(1, '.', 3)}", "?rightPadLines", "expects 1 or 2 arguments"); - } - - @Test - public void testRightPadLinesNoArgTypeCoercion() throws Exception { - assertErrorContains("${''?right_pad_lines('1', '.')}", "number as argument #1"); - assertErrorContains("${''?right_pad_lines(1, 2)}", "string as argument #2"); - } } diff --git a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java index 32c9d4894..0e13bd0c3 100644 --- a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java +++ b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java @@ -73,6 +73,53 @@ public void testIndentTrailingNewline() { _CoreStringUtils.indent("a\nb\n", " ")); } + @Test + public void testIndentNonWhitespacePrefixOnBlankLine() { + // The prefix is added to the blank line too, then right-trimmed, so it becomes "#" rather + // than "# " — the space in "# " is a separator, only wanted when there's content after it. + assertEquals( + "# a\n#\n# b", + _CoreStringUtils.indent("a\n\nb", "# ")); + } + + @Test + public void testIndentWhitespaceOnlyLineTreatedAsBlank() { + // A line of accidental spaces behaves the same as a truly empty one. + assertEquals( + "# a\n#\n# b", + _CoreStringUtils.indent("a\n \nb", "# ")); + } + + @Test + public void testIndentRightTrimOff() { + assertEquals( + "# a\n# \n# b", + _CoreStringUtils.indent("a\n\nb", "# ", false)); + } + + @Test + public void testIndentRemovesTrailingWhitespaceFromContentLines() { + assertEquals( + " a\n b", + _CoreStringUtils.indent("a \nb\t", " ")); + } + + @Test + public void testIndentKeepsNonBreakingSpace() { + // U+00A0 isn't whitespace for trimming purposes — that's the point of a non-breaking space. + assertEquals( + " a\u00A0", + _CoreStringUtils.indent("a\u00A0", " ")); + } + + @Test + public void testIndentDedentRoundTrip() { + String original = "int x;\n\nint y;\n"; + assertEquals( + original, + _CoreStringUtils.dedent(_CoreStringUtils.indent(original, " "), " ")); + } + // ---- wrap tests ---- @Test @@ -215,16 +262,44 @@ public void testDedentBasic() { } @Test - public void testDedentNoMatch() { - // 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 + public void testDedentPartialPrefixIsShortened() { + // A line carrying only part of the prefix loses that part, rather than being left alone. + // " short" shares 2 characters with the 4-space prefix → those 2 are removed. + // " full" carries the whole prefix → all 4 are removed. assertEquals( - " short\nfull\n", + "short\nfull\n", _CoreStringUtils.dedent(" short\n full\n", " ") ); } + @Test + public void testDedentPartialPrefixNonWhitespace() { + // Every one of these loses whatever it shares with "---", so all end up as "a". + assertEquals( + "a\na\na\na\n", + _CoreStringUtils.dedent("a\n-a\n--a\n---a\n", "---") + ); + } + + @Test + public void testDedentUnrelatedPrefixLeftAlone() { + // Shares nothing with the prefix → untouched. + assertEquals( + "xa\nyb\n", + _CoreStringUtils.dedent("xa\nyb\n", "---") + ); + } + + @Test + public void testDedentWhitespaceOnlyLineBecomesEmpty() { + // The 2 spaces are all this line shares with the 4-space prefix, so it's left empty + // instead of keeping accidental trailing whitespace. + assertEquals( + "a\n\nb\n", + _CoreStringUtils.dedent(" a\n \n b\n", " ") + ); + } + @Test public void testDedentMixed() { // Some lines match, some don't @@ -290,14 +365,28 @@ public void testDedentNoArgsMixedIndent() { @Test public void testDedentNoArgsRespectsEmptyLines() { - // Empty/whitespace-only lines are ignored when computing the common prefix - // and pass through unchanged. + // Empty/whitespace-only lines are ignored when computing the common prefix. assertEquals( "a\n\nb", _CoreStringUtils.dedent(" a\n\n b") ); } + @Test + public void testDedentNoArgsNormalizesWhitespaceOnlyLines() { + // A whitespace-only line doesn't constrain the common prefix, and comes out empty rather + // than keeping whitespace that was accidental to begin with. Same as textwrap.dedent. + assertEquals( + "a\n\nb", + _CoreStringUtils.dedent(" a\n \n b") + ); + // Including when it's longer than the common prefix. + assertEquals( + "a\n\nb", + _CoreStringUtils.dedent(" a\n \n b") + ); + } + @Test public void testDedentNoArgsNoCommonPrefix() { // If lines have no common leading whitespace, nothing is stripped. @@ -342,68 +431,4 @@ public void testDedentNoArgsAlreadyDedented() { ); } - // ---- rightPadLines tests ---- - - @Test - public void testRightPadLinesBasic() { - assertEquals( - "a \nbb \nccc \n", - _CoreStringUtils.rightPadLines("a\nbb\nccc\n", 10) - ); - } - - @Test - public void testRightPadLinesWithFillChar() { - assertEquals( - "a.........\nbb........\n", - _CoreStringUtils.rightPadLines("a\nbb\n", 10, '.') - ); - } - - @Test - public void testRightPadLinesLinePastColumn() { - // "long line" (9 chars) past column 5 — no padding - // "ab" (2 chars) shorter than column 5 — padded - assertEquals( - "long line\nab \n", - _CoreStringUtils.rightPadLines("long line\nab\n", 5) - ); - } - - @Test - public void testRightPadLinesNoTrailingNewline() { - assertEquals( - "a ", - _CoreStringUtils.rightPadLines("a", 10) - ); - } - - @Test - public void testRightPadLinesEmpty() { - assertEquals( - "", - _CoreStringUtils.rightPadLines("", 10) - ); - } - - @Test - public void testRightPadLinesCamelCase() { - assertEquals( - "a \nbb \n", - _CoreStringUtils.rightPadLines("a\nbb\n", 5) - ); - } - - @Test - public void testRightPadLinesCodeAlignment() { - // Practical use: align code for trailing comments - String code = "int x;\nString name;\nboolean active;\n"; - String result = _CoreStringUtils.rightPadLines(code, 20); - String[] lines = result.split("\n", -1); - assertEquals("int x; ", lines[0]); - assertEquals("String name; ", lines[1]); - assertEquals("boolean active; ", lines[2]); - assertEquals("", lines[3]); - } - } diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index 8ac13aa18..33518369d 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -13393,11 +13393,6 @@ grant codeBase "file:/path/to/freemarker.jar" linkend="ref_builtin_right_pad">right_pad - - right_pad_lines - - round @@ -14084,9 +14079,10 @@ Green Mouse (?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 + different leading-whitespace amounts work as expected, and lines that + are empty or contain whitespace only are ignored when computing the + common prefix (and are empty in the output, as their whitespace was + accidental anyway). The semantics match Python's textwrap.dedent. For example: @@ -14106,10 +14102,14 @@ Green Mouse 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: + (?dedent(prefix)) removes from each line the + longest prefix of the prefix parameter that the + line actually starts with. So a line that carries the whole prefix + loses all of it, a line that carries only part of it loses that + part, and a line that has nothing in common with it is left + unchanged. Use this form when you want to specify the amount of + indentation to remove, rather than letting it be detected. For + example: <#assign code = " if (x) {\n foo();\n }" /> ${code?dedent(" ")} @@ -14122,19 +14122,33 @@ ${code?dedent(" ")} foo(); } - Lines that don't start with the prefix are unaffected, so with - a 4-space prefix: + With a 4-space prefix, lines that have less indentation than + that simply lose all the indentation they have: <#assign code = " if (x) {\n foo();\n }" /> ${code?dedent(" ")} - will output this (the second line had at least 4 space - indentation, so 4 were removed, the other lines only had 2 spaces, - so they were left alone): + will output this (the 2nd line had 4 spaces, so all 4 were + removed; the other lines only had 2, so those 2 were removed): - if (x) { + if (x) { foo(); - } +} + + + Partial matches are shortened rather than ignored on + purpose. As indent adds + the prefix to every line unconditionally, an all-or-nothing dedent + could leave a line that was originally the least indented as the + most indented one, which is more surprising than flattening the + indentation unevenly. + + + Since a line containing whitespace only can't carry more than + whitespace, such a line loses its whitespace up to the length of the + prefix; that's why it ends up behaving like an empty line + here. See also the indent @@ -14451,14 +14465,13 @@ R&amp;D 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 (Linux), or CRLF - (DOS/Windows), even CR (old Mac), and are kept as - is. + Prepends the string given as the 1st parameter to the beginning + of each line, then removes the trailing whitespace of each resulting + line (unless that's switched off with the 2nd parameter). The 1st + parameter is most often some spaces or tabs used for indentation, but + can be any string. Line-breaks can be LF (Linux), + or CRLF (DOS/Windows), even CR + (old Mac), and are kept as is. For example, this: @@ -14480,6 +14493,42 @@ ${text?indent(" * ")} * First line. * Second line. + The prefix is added to every line, + including lines that are empty or contain whitespace only. That would + leave a trailing prefix on such lines, which is why the trailing + whitespace of each line is removed afterwards. Consider commenting + out a block of text with a "# " prefix: + + <#assign text = "First paragraph.\n\nSecond paragraph." /> +${text?indent("# ")} + + will output this, where the empty line became + "#" rather than "# ", as the + space in "# " was only meant to separate the + prefix from the content of a line: + + # First paragraph. +# +# Second paragraph. + + With a whitespace-only prefix this leaves empty lines empty, so + the trimming is only visible with prefixes like the above. It also + means that lines that are empty and lines that only contain + whitespace give the same result, and that accidental trailing + whitespace is removed from the other lines as well. + + Set the optional 2nd parameter to false to + switch the trimming off, and thus get the prefix on every line + verbatim: + + ${text?indent("# ", false)} + + + A non-breaking space (U+00A0) isn't + treated as whitespace when trimming, so it's kept; that's the + purpose of a non-breaking space. + + See also the dedent built-in, which is its inverse. @@ -15155,68 +15204,6 @@ ${s?no_esc} above.

-
- right_pad_lines - - - right_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 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 - (Linux), or CRLF (DOS/Windows), even - CR (old Mac), and are kept as is. - - For example, this: - - <#assign code = "int x;\nString name;\nboolean active;" /> -${code?right_pad_lines(20)}done - - will output this (each line padded to width 20, the [BR] is only shown below to - illustrate the line-break): - - int x; [BR] -String name; [BR] -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"?right_pad_lines(5, ".")} - - will output this: - - 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. - -
-
replace @@ -30907,8 +30894,7 @@ TemplateModel x = env.getVariable("x"); // get variable x configuration file) generation: dedent, indent, right_pad_lines, - wrap + linkend="ref_builtin_wrap">wrap
From 621a8ac890b690e5b6aa61ce5a12097bf4dcdba9 Mon Sep 17 00:00:00 2001 From: ddekany Date: Tue, 4 Aug 2026 01:04:29 +0200 Subject: [PATCH 8/9] - Code cleanup: Mostly, the refactoring of indent/dedent algorithms to be more readable - Javadoc improvements - Some rewording/cleanup in the Manual --- .../main/java/freemarker/core/BuiltIn.java | 3 +- .../freemarker/core/_CoreStringUtils.java | 340 ++++++++++-------- .../freemarker/core/_CoreStringUtilsTest.java | 20 +- .../src/main/docgen/en_US/book.xml | 118 +++--- 4 files changed, 276 insertions(+), 205 deletions(-) diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java index d14c1e2cf..bf0b89141 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java @@ -507,8 +507,7 @@ protected final boolean getOptBooleanMethodArg(List args, int argIdx, boolean de /** * Gets a method argument and checks if it's a boolean; it does NOT check if {@code args} is big enough. */ - protected final boolean getBooleanMethodArg(List args, int argIdx) - throws TemplateModelException { + protected final boolean getBooleanMethodArg(List args, int argIdx) throws TemplateModelException { TemplateModel arg = (TemplateModel) args.get(argIdx); if (!(arg instanceof TemplateBooleanModel)) { throw _MessageUtil.newMethodArgMustBeBooleanException("?" + key, argIdx, arg); diff --git a/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java index 9d5679cd8..6b4f13324 100644 --- a/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java +++ b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java @@ -26,10 +26,10 @@ import freemarker.template.utility.StringUtil; /** - * For internal use only; don't depend on this, there's no backward compatibility guarantee at all! - * This class is to work around the lack of module system in Java, i.e., so that other FreeMarker packages can - * access things inside this package that users shouldn't. - */ + * For internal use only; don't depend on this, there's no backward compatibility guarantee at all! This class is to + * work around the lack of module system in Java, i.e., so that other FreeMarker packages can access things inside this + * package that users shouldn't. + */ public final class _CoreStringUtils { private _CoreStringUtils() { @@ -46,7 +46,8 @@ public static String toFTLTopLevelIdentifierReference(String name) { public static String toFTLTopLevelTragetIdentifier(final String name) { char quotationType = 0; - scanForQuotationType: for (int i = 0; i < name.length(); i++) { + scanForQuotationType: + for (int i = 0; i < name.length(); i++) { final char c = name.charAt(i); if (!(i == 0 ? StringUtil.isFTLIdentifierStart(c) : StringUtil.isFTLIdentifierPart(c)) && c != '@') { if ((quotationType == 0 || quotationType == '\\') @@ -59,14 +60,14 @@ public static String toFTLTopLevelTragetIdentifier(final String name) { } } switch (quotationType) { - case 0: - return name; - case '"': - return StringUtil.ftlQuote(name); - case '\\': - return backslashEscapeIdentifier(name); - default: - throw new BugException(); + case 0: + return name; + case '"': + return StringUtil.ftlQuote(name); + case '\\': + return backslashEscapeIdentifier(name); + default: + throw new BugException(); } } @@ -94,8 +95,8 @@ public static String backslashEscapeIdentifier(String name) { } /** - * @return {@link Configuration#CAMEL_CASE_NAMING_CONVENTION}, or {@link Configuration#LEGACY_NAMING_CONVENTION} - * or, {@link Configuration#AUTO_DETECT_NAMING_CONVENTION} when undecidable. + * @return {@link Configuration#CAMEL_CASE_NAMING_CONVENTION}, or {@link Configuration#LEGACY_NAMING_CONVENTION} or, + * {@link Configuration#AUTO_DETECT_NAMING_CONVENTION} when undecidable. */ public static int getIdentifierNamingConvention(String name) { final int ln = name.length(); @@ -110,11 +111,12 @@ public static int getIdentifierNamingConvention(String name) { } return Configuration.AUTO_DETECT_NAMING_CONVENTION; } - + // [2.4] Won't be needed anymore + /** - * A deliberately very inflexible camel case to underscored converter; it must not convert improper camel case - * names to a proper underscored name. + * A deliberately very inflexible camel case to underscored converter; it must not convert improper camel case names + * to a proper underscored name. */ public static String camelCaseToUnderscored(String camelCaseName) { int i = 0; @@ -125,7 +127,7 @@ public static String camelCaseToUnderscored(String camelCaseName) { // No conversion needed return camelCaseName; } - + StringBuilder sb = new StringBuilder(); sb.append(camelCaseName.substring(0, i)); while (i < camelCaseName.length()) { @@ -140,7 +142,7 @@ public static String camelCaseToUnderscored(String camelCaseName) { } return sb.toString(); } - + public static boolean isUpperUSASCII(char c) { return c >= 'A' && c <= 'Z'; } @@ -164,17 +166,16 @@ public static String indent(String s, String prefix) { } /** - * Prepends {@code prefix} to each line, then, if {@code rightTrim} is {@code true}, removes the trailing - * whitespace of each resulting line. + * Prepends {@code prefix} to each line, then, if {@code rightTrim} is {@code true}, removes the trailing whitespace + * of each resulting line. * *

The prefix is added unconditionally, including to lines that are empty or contain whitespace only. The - * right-trimming is what keeps that from leaving junk behind: with a prefix like {@code "# "} an empty line - * becomes {@code "#"} rather than a line with a trailing space, and with a whitespace-only prefix it becomes - * empty. That's also why empty and whitespace-only lines end up treated alike, without either being a special - * case in the code. + * right-trimming is what keeps that from leaving superfluous whitespace behind: with a prefix like {@code "# "} an + * empty line becomes {@code "#"} rather than a line with a trailing space, and with a whitespace-only prefix it + * becomes empty. That's also why empty and whitespace-only lines end up treated alike, without either being a + * special case in the code. * - *

Note that a non-breaking space (U+00A0) isn't whitespace as far as trimming is concerned, so it's kept; - * that's the point of a non-breaking space. + *

Note that a non-breaking space (U+00A0) isn't whitespace as far as trimming is concerned, so it's kept. */ public static String indent(String s, String prefix, boolean rightTrim) { if (s == null || s.isEmpty() || (prefix.isEmpty() && !rightTrim)) { @@ -182,193 +183,237 @@ public static String indent(String s, String prefix, boolean rightTrim) { } int len = s.length(); - StringBuilder sb = new StringBuilder(len + prefix.length() * 8); - int i = 0; - while (i < len) { - int lineEnd = findLineEnd(s, i); + StringBuilder sb = new StringBuilder(len + prefix.length() * (4 + len / 20)); + int lineStartPos = 0; + while (lineStartPos < len) { + int lineBreakPos = findLineBreakFrom(s, lineStartPos); - int lineStartInSb = sb.length(); + int lineStartPosSb = sb.length(); sb.append(prefix); - sb.append(s, i, lineEnd); + sb.append(s, lineStartPos, lineBreakPos); if (rightTrim) { int end = sb.length(); - while (end > lineStartInSb && isTrimmableSpace(sb.charAt(end - 1))) { + while (end > lineStartPosSb && isTrimmableInlineWhitespace(sb.charAt(end - 1))) { end--; } sb.setLength(end); } - i = appendEol(s, lineEnd, sb); + lineStartPos = appendSameTypeLineBreak(sb, s, lineBreakPos); } return sb.toString(); } /** - * Removes from each line the longest prefix of {@code prefix} that the line starts with. Lines that carry the - * whole prefix lose all of it; lines that only carry part of it lose that part; lines that share nothing with it - * are left alone. + * Removes from each line the longest prefix of {@code prefixToRemove} that the line starts with. Lines that start + * with the whole prefix lose all of it; lines that only start with some head part of the prefix lose that part; + * lines that share nothing with it are left alone. * - *

This deliberately doesn't require an exact match. Since {@link #indent(String, String, boolean)} adds the - * prefix unconditionally, an all-or-nothing dedent could leave a line that was originally the least indented as - * the most indented one — so partial matches are shortened rather than ignored. Whitespace-only lines lose their - * whitespace up to the length of the prefix, which is what makes them behave like empty lines here. + *

This is consistent with how code editors behave when you repeatedly dedent a block of code, and some lines + * reach 0 indentation earlier than others. In that case, the dedent is not blocked, instead the code hierarchy will + * start to flatten (so you lose information, be it's tolerated as least wrong outcome). + * + *

Also, an all-or-nothing dedent could leave a line that was originally the least indented as the most + * indented one, which is much more confusing that a (partially) flattened hierarchy. */ - public static String dedent(String s, String prefix) { - if (s == null || s.isEmpty() || prefix.isEmpty()) { + public static String dedent(String s, String prefixToRemove) { + if (s == null || s.isEmpty() || prefixToRemove.isEmpty()) { return s; } - int prefixLen = prefix.length(); + int prefixToRemoveLen = prefixToRemove.length(); int len = s.length(); StringBuilder sb = new StringBuilder(len); - int i = 0; - while (i < len) { - int lineEnd = findLineEnd(s, i); - - int matched = 0; - while (matched < prefixLen && i + matched < lineEnd - && s.charAt(i + matched) == prefix.charAt(matched)) { - matched++; + int lineStartPos = 0; + while (lineStartPos < len) { + int lineBreakPos = findLineBreakFrom(s, lineStartPos); + + int matchedLen = 0; + while (matchedLen < prefixToRemoveLen && lineStartPos + matchedLen < lineBreakPos + && s.charAt(lineStartPos + matchedLen) == prefixToRemove.charAt(matchedLen)) { + matchedLen++; } - sb.append(s, i + matched, lineEnd); + sb.append(s, lineStartPos + matchedLen, lineBreakPos); - i = appendEol(s, lineEnd, sb); + lineStartPos = appendSameTypeLineBreak(sb, s, lineBreakPos); } return sb.toString(); } /** - * Returns the index of the first line-terminator character at or after {@code from}, or the length of {@code s} - * if there's none. + * Returns the index of the first line-break character at or after {@code from}, or the length of {@code s} if + * there's none. */ - private static int findLineEnd(String s, int from) { + private static int findLineBreakFrom(String s, int from) { int len = s.length(); int i = from; - while (i < len && s.charAt(i) != '\n' && s.charAt(i) != '\r') { + while (i < len && !isLineBreakChar(s.charAt(i))) { i++; } return i; } /** - * Appends the line terminator found at {@code lineEnd} (if any, treating {@code "\r\n"} as one) to {@code sb}, - * and returns the index at which the next line starts. + * Appends the same type of line-break (LF, or CRLF, or CR) to {@code sb}, which is found at {@code lineBreakPos} in + * {@code s}; if the position is after the last char of {@code s}, then this appends nothing. + * + * @return the position at which the next line starts in {@code s} */ - private static int appendEol(String s, int lineEnd, StringBuilder sb) { + private static int appendSameTypeLineBreak(StringBuilder sb, String s, int lineBreakPos) { int len = s.length(); - if (lineEnd >= len) { + if (lineBreakPos >= len) { return len; } - char c = s.charAt(lineEnd); + char c = s.charAt(lineBreakPos); sb.append(c); - if (c == '\r' && lineEnd + 1 < len && s.charAt(lineEnd + 1) == '\n') { + if (c == '\r' && lineBreakPos + 1 < len && s.charAt(lineBreakPos + 1) == '\n') { sb.append('\n'); - return lineEnd + 2; + return lineBreakPos + 2; } - return lineEnd + 1; + return lineBreakPos + 1; + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private static boolean isLineBreakChar(char c) { + return c == '\r' || c == '\n'; } /** - * Whether the character counts as trailing whitespace for right-trimming purposes. Line terminators are - * excluded, as they're handled separately, and so is anything that {@link Character#isWhitespace(char)} rejects - * (notably the non-breaking space). + * Whether the character counts as removable whitespace inside a line. Anything that satisfies + * {@link Character#isWhitespace(char)} and is not a line terminator. Note the non-breaking space is not considered + * as trimmable. */ - private static boolean isTrimmableSpace(char c) { - return c != '\n' && c != '\r' && Character.isWhitespace(c); + private static boolean isTrimmableInlineWhitespace(char c) { + return !isLineBreakChar(c) && Character.isWhitespace(c); } /** - * Strip the longest leading-whitespace string (spaces and tabs only) that - * is a common prefix of every non-empty line. Lines that are empty or - * contain whitespace only are ignored when computing the prefix, and are - * 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. + * Strip the longest leading indentation-white-space string (contains spaces and tabs only) that is a common amongst + * of all non-blank line (blank meaning only containing indentation-white-space). Lines that are blank are ignored + * and are 0-length in the output. Mirrors Python's {@code textwrap.dedent} semantics. + * + *

Note: this methods can't resolve tabs to spaces, nor convert spaces to tabs, so a tab in line only matches + * another tab in the other lines, not some number spaces. */ public static String dedent(String s) { if (s.isEmpty()) { return s; } + + String commonPrefix = getLongestCommonIndentationWhitespace(s); + if (commonPrefix == null || commonPrefix.isEmpty()) { + return s; + } + + return removeCommonIndentationWhitespacePrefixFromLines(s, commonPrefix); + } + + /** + * Finds the longest common leading white-space indentation of the lines, but ignoring lines that only contains + * indentation-white-space. + */ + private static String getLongestCommonIndentationWhitespace(String 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++; + String longestCommonIndent = null; + int lineStartPos = 0; + int contentEndPos; + while (lineStartPos < len) { + char c = s.charAt(lineStartPos); + + int identEndPos = lineStartPos; + while (isIndentationWhitespace(c)) { + identEndPos++; + if (identEndPos == len) { + break; + } + c = s.charAt(identEndPos); + } + + contentEndPos = identEndPos; + while (!isLineBreakChar(c)) { + contentEndPos++; + if (contentEndPos == len) { + break; } - 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 + c = s.charAt(contentEndPos); + } + + if (identEndPos < contentEndPos) { + if (longestCommonIndent == null) { + longestCommonIndent = s.substring(lineStartPos, identEndPos); + } else { + int maxLen = Math.min(longestCommonIndent.length(), identEndPos - lineStartPos); + int matchingLen = 0; + while (matchingLen < maxLen + && longestCommonIndent.charAt(matchingLen) == s.charAt(lineStartPos + matchingLen)) { + matchingLen++; + } + if (matchingLen < longestCommonIndent.length()) { + longestCommonIndent = longestCommonIndent.substring(0, matchingLen); + } + if (longestCommonIndent.isEmpty()) { + return longestCommonIndent; // 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 (contentEndPos < len) { + if (c == '\r' && contentEndPos + 1 < len && s.charAt(contentEndPos + 1) == '\n') { + // Skip CRLF + contentEndPos += 2; + } else { + contentEndPos++; } } - } - if (commonPrefix == null || commonPrefix.isEmpty()) { - return s; + lineStartPos = contentEndPos; } + return longestCommonIndent; + } - // Second pass: emit each line with the common prefix stripped (from - // non-empty lines only). + /** + * Removes common prefix from each line, but replace indentation-white-space-only lines with empty line. We assume + * that each non-blank line starts with the given common prefix, and that it only contains indentation white-space, + * otherwise behavior is undefined. + */ + private static String removeCommonIndentationWhitespacePrefixFromLines(String s, String commonPrefix) { + int len = s.length(); 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++; + int lineStartPos = 0; + while (lineStartPos < len) { + int lineBreakPos = lineStartPos; + boolean blankLine = true; + while (lineBreakPos < len) { + char c = s.charAt(lineBreakPos); + if (isLineBreakChar(c)) { + break; } - boolean nonEmpty = contentStart < i; - if (nonEmpty) { - // Non-empty line: by construction it has the common prefix. - sb.append(s, lineStart + prefixLen, i); - } - // Else: a whitespace-only line, which is normalized to empty rather than kept as is. Its - // whitespace is accidental (whatever the emitting loop happened to produce), and keeping it would - // mean leaving trailing whitespace behind. This also matches textwrap.dedent. - 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; + if (blankLine && !isIndentationWhitespace(c)) { + blankLine = false; + // Don't break from the loop! It's faster and more logical to get to the line-break in this loop. } + lineBreakPos++; + } + + // An identation-whitespace-only line is normalized to empty rather than kept as is. Its whitespace is + // assumed to be accidental. This also matches Python textwrap.dedent behavior. + if (!blankLine) { + sb.append(s, lineStartPos + prefixLen, lineBreakPos); + } + if (lineBreakPos < len) { + lineBreakPos = appendSameTypeLineBreak(sb, s, lineBreakPos); } + lineStartPos = lineBreakPos; } return sb.toString(); } + private static boolean isIndentationWhitespace(char c) { + return c == ' ' || c == '\t'; + } + public static String wrap(String s, int width) { return wrap(s, width, ""); } @@ -377,6 +422,20 @@ public static String wrap(String s, int width, String firstPrefix) { return wrap(s, width, firstPrefix, firstPrefix); } + /** + * In effect trims the input, breaks it to item at whitespace (except unbreakable space), and the reflow the items + * into space-separated lines no longer than the specified width, or a single item if that's longer. Inside a line, + * all interleaving whitespace is replaced with a single space. Unbreakable space is not considered to be + * white-space here. + * + * @param width + * Maximum line width, when possible. If a single unbreakable section is longer than this, then that will be + * in its own line. + * @param firstPrefix + * Prefix of the first line + * @param restPrefix + * Prefix of all lines after the first + */ public static String wrap(String s, int width, String firstPrefix, String restPrefix) { NullArgumentException.check(firstPrefix, "firstPrefix"); NullArgumentException.check(restPrefix, "restPrefix"); @@ -419,5 +478,4 @@ public static String wrap(String s, int width, String firstPrefix, String restPr return sb.toString(); } - } diff --git a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java index 0e13bd0c3..f070cdb1b 100644 --- a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java +++ b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java @@ -276,8 +276,8 @@ public void testDedentPartialPrefixIsShortened() { public void testDedentPartialPrefixNonWhitespace() { // Every one of these loses whatever it shares with "---", so all end up as "a". assertEquals( - "a\na\na\na\n", - _CoreStringUtils.dedent("a\n-a\n--a\n---a\n", "---") + "a\na\na\na\nx123a\n23a", + _CoreStringUtils.dedent("a\n1a\n12a\n123a\nx123a\n23a", "123") ); } @@ -291,12 +291,12 @@ public void testDedentUnrelatedPrefixLeftAlone() { } @Test - public void testDedentWhitespaceOnlyLineBecomesEmpty() { + public void testDedentWhitespaceOnlyLineIsNotTrimmed() { // The 2 spaces are all this line shares with the 4-space prefix, so it's left empty // instead of keeping accidental trailing whitespace. assertEquals( - "a\n\nb\n", - _CoreStringUtils.dedent(" a\n \n b\n", " ") + "a\n\nb\n \n", + _CoreStringUtils.dedent(" a\n \n b\n \n", " ") ); } @@ -363,6 +363,16 @@ public void testDedentNoArgsMixedIndent() { ); } + // !!T + @Test + public void testDedentNoArgsMixedIndentCrLf() { + // The longest common leading whitespace across non-empty lines is 2 spaces. + assertEquals( + "a\r\n b\r\n c", + _CoreStringUtils.dedent(" a\r\n b\r\n c") + ); + } + @Test public void testDedentNoArgsRespectsEmptyLines() { // Empty/whitespace-only lines are ignored when computing the common prefix. diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index 33518369d..e0bed168e 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -14066,59 +14066,63 @@ Green Mouse This built-in is available since FreeMarker 2.3.35. - Removes a leading 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 + Removes a leading prefix (usually indentation) 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 (Linux), or CRLF (DOS/Windows), even CR (old Mac), and are kept as is. 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 lines that - are empty or contain whitespace only are ignored when computing the - common prefix (and are empty in the output, as their whitespace was - accidental anyway). The semantics match Python's - textwrap.dedent. + (?dedent) finds the longest indentation (spaces + and tabs only) that is a common prefix and removes it. Lines that + are empty or only contain spaces and tabs are treated specially; + they don't influence the computation of the longest indentation, and + will be replaced with an empty (line-break only) line in the output. + (These semantics match Python's + textwrap.dedent.) For example: <#assign code = " if (x) {\n foo();\n }" /> -[${code?dedent}] +// <- touches left margin +${code?dedent} - will output this (the common prefix was 2 spaces, which was - removed): + will output this (the common indentation prefix was 2 spaces, + which was now removed from each line): - if (x) { + // <- touches left margin +if (x) { foo(); } A leading tab and a leading space are treated as distinct - characters (they have no common prefix), matching Python's - behaviour. + characters (we don't resolve tabs to space or vice versa). The explicit-prefix form (?dedent(prefix)) removes from each line the - longest prefix of the prefix parameter that the - line actually starts with. So a line that carries the whole prefix - loses all of it, a line that carries only part of it loses that - part, and a line that has nothing in common with it is left - unchanged. Use this form when you want to specify the amount of - indentation to remove, rather than letting it be detected. For - example: + longest leading section of the prefix parameter + that the line starts with. So for + ?dedent("1234"), the line 1234x is + reduced x, and so are 123x, + 12x, and 1x too, as they start the + same as 1234. But a line like x1234x + or 234x, would remain unchanged. Use this form when + you want to specify the amount of indentation to remove, rather than + letting it be detected. For example: <#assign code = " if (x) {\n foo();\n }" /> +// <- touches left margin ${code?dedent(" ")} will output this (removed just 1 space of indentation, so the 1st line still has 1 space of indentation, and the 2nd has 3 spaces of indentation): - if (x) { + // <- touches left margin + if (x) { foo(); } @@ -14126,33 +14130,31 @@ ${code?dedent(" ")} that simply lose all the indentation they have: <#assign code = " if (x) {\n foo();\n }" /> +// <- touches left margin ${code?dedent(" ")} will output this (the 2nd line had 4 spaces, so all 4 were - removed; the other lines only had 2, so those 2 were removed): + removed; the other lines only had 2, so those 2 were + removed): - if (x) { + // <- touches left margin +if (x) { foo(); } Partial matches are shortened rather than ignored on - purpose. As indent adds - the prefix to every line unconditionally, an all-or-nothing dedent - could leave a line that was originally the least indented as the - most indented one, which is more surprising than flattening the - indentation unevenly. + purpose. This is what's familiar from code text editors, when you + repeatedly dedent a block of text, and some lines already have too + low indentation. Also, an all-or-nothing dedent could leave a line + that was originally the least indented as the most indented one, + which is a more confusing result than partially flattening + indentation hierarchy. - Since a line containing whitespace only can't carry more than - whitespace, such a line loses its whitespace up to the length of the - prefix; that's why it ends up behaving like an empty line - here. - See also the indent - built-in, which is its inverse. + built-in, which is its inverse (roughly).

@@ -14465,13 +14467,14 @@ R&amp;D This built-in is available since FreeMarker 2.3.35. - Prepends the string given as the 1st parameter to the beginning - of each line, then removes the trailing whitespace of each resulting - line (unless that's switched off with the 2nd parameter). The 1st - parameter is most often some spaces or tabs used for indentation, but - can be any string. Line-breaks can be LF (Linux), - or CRLF (DOS/Windows), even CR - (old Mac), and are kept as is. + Prepends the string given as the 1st parameter to the + beginning of each line, then removes the trailing whitespace of each + resulting line (unless that's switched off with the 2nd parameter). + The 1st parameter is most often some spaces or tabs used for + indentation, but can be any string. Line-breaks can be + LF (Linux), or CRLF + (DOS/Windows), even CR (old Mac), and are kept as + is. For example, this: @@ -14494,10 +14497,11 @@ ${text?indent(" * ")} * Second line. The prefix is added to every line, - including lines that are empty or contain whitespace only. That would - leave a trailing prefix on such lines, which is why the trailing - whitespace of each line is removed afterwards. Consider commenting - out a block of text with a "# " prefix: + including lines that are empty or contain whitespace only. That + would leave a trailing prefix on such lines, which is why the + trailing whitespace of each line is removed afterwards. Consider + commenting out a block of text with a "# " + prefix: <#assign text = "First paragraph.\n\nSecond paragraph." /> ${text?indent("# ")} @@ -14511,9 +14515,9 @@ ${text?indent("# ")} # # Second paragraph. - With a whitespace-only prefix this leaves empty lines empty, so - the trimming is only visible with prefixes like the above. It also - means that lines that are empty and lines that only contain + With a whitespace-only prefix this leaves empty lines empty, + so the trimming is only visible with prefixes like the above. It + also means that lines that are empty and lines that only contain whitespace give the same result, and that accidental trailing whitespace is removed from the other lines as well. @@ -14531,7 +14535,7 @@ ${text?indent("# ")} See also the dedent - built-in, which is its inverse. + built-in, which is its inverse (roughly).
@@ -16188,7 +16192,7 @@ ${x?url} - The result always ends with a single line-break + The result always ends with a line-break From 99bcddc620d6fbda27d0d41db567a3e3949bf34c Mon Sep 17 00:00:00 2001 From: Giovanni Di Sirio Date: Tue, 4 Aug 2026 16:11:08 +0200 Subject: [PATCH 9/9] Review feedback: ?dedent(prefix) trims too, and parametric indent/dedent tests. ?dedent(prefix) now removes the trailing whitespace of each resulting line, with an optional 2nd boolean argument to switch that off, defaulting to true, mirroring ?indent. Removing a prefix can leave whitespace behind that only looks like indentation: a line containing whitespace only loses just as much of it as the prefix is long, so with a 4 character prefix a line of 5 spaces kept 1 space. That was also asymmetric with ?indent, which trims, so ?indent(p)?dedent(p) didn't round-trip for such lines. An empty prefix is a no-op for both built-ins, and doesn't trim either. There's nothing to add or remove then, so doing nothing is what's least surprising for something called indent or dedent. _CoreStringUtilsTest is now split into two nested classes: the indent/dedent tests are parametric, running for each line-break type (LF, CRLF, CR) and with and without a line-break at the end of the input, while the wrap tests stay plain, as wrap collapses all whitespace including line-breaks and so can't depend on the line-break type. Test data is written with "\n" and no trailing line-break, and adapted to the parameters. This subsumes the tests that existed only to check a specific line ending or the presence of a trailing line-break, including the one marked "!!T". That's 31 test methods over 6 parameter combinations. All pass, so the line-break handling was already correct for CR and CRLF; the coverage was what was missing. --- .../core/BuiltInsForStringsBasic.java | 5 +- .../freemarker/core/_CoreStringUtils.java | 28 +- .../core/IndentAndWrapBuiltInTest.java | 21 +- .../freemarker/core/_CoreStringUtilsTest.java | 687 +++++++++--------- .../src/main/docgen/en_US/book.xml | 13 + 5 files changed, 393 insertions(+), 361 deletions(-) diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java index f03f784db..404199864 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java @@ -537,10 +537,11 @@ private BIMethod(String targetAsString) { @Override public Object exec(List args) throws TemplateModelException { int argCnt = args.size(); - checkMethodArgCount(argCnt, 1); + checkMethodArgCount(argCnt, 1, 2); String prefix = getStringMethodArg(args, 0); - return new SimpleScalar(_CoreStringUtils.dedent(targetAsString, prefix)); + boolean rightTrim = getOptBooleanMethodArg(args, 1, true); + return new SimpleScalar(_CoreStringUtils.dedent(targetAsString, prefix, rightTrim)); } @Override diff --git a/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java index 6b4f13324..054745e0e 100644 --- a/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java +++ b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java @@ -178,7 +178,9 @@ public static String indent(String s, String prefix) { *

Note that a non-breaking space (U+00A0) isn't whitespace as far as trimming is concerned, so it's kept. */ public static String indent(String s, String prefix, boolean rightTrim) { - if (s == null || s.isEmpty() || (prefix.isEmpty() && !rightTrim)) { + // An empty prefix adds nothing, so this does nothing at all then, not even trimming; that's the least + // surprising behavior for something called "indent". + if (s == null || s.isEmpty() || prefix.isEmpty()) { return s; } @@ -217,6 +219,22 @@ public static String indent(String s, String prefix, boolean rightTrim) { * indented one, which is much more confusing that a (partially) flattened hierarchy. */ public static String dedent(String s, String prefixToRemove) { + return dedent(s, prefixToRemove, true); + } + + /** + * Same as {@link #dedent(String, String)}, but you can also specify if the trailing whitespace of each resulting + * line should be removed. + * + *

The trimming matters because removing a prefix can leave whitespace behind that only looks like indentation: + * a line that contains whitespace only loses just as much of it as the prefix is long, so with a 4 character long + * prefix a line of 5 spaces would keep 1 space. Trimming removes such remains, and so a line that contains + * whitespace only becomes empty. It also keeps this symmetrical with + * {@link #indent(String, String, boolean)}, which trims too. + */ + public static String dedent(String s, String prefixToRemove, boolean rightTrim) { + // An empty prefix removes nothing, so this does nothing at all then, not even trimming; same as with + // indent. if (s == null || s.isEmpty() || prefixToRemove.isEmpty()) { return s; } @@ -233,7 +251,15 @@ public static String dedent(String s, String prefixToRemove) { && s.charAt(lineStartPos + matchedLen) == prefixToRemove.charAt(matchedLen)) { matchedLen++; } + int lineStartPosSb = sb.length(); sb.append(s, lineStartPos + matchedLen, lineBreakPos); + if (rightTrim) { + int end = sb.length(); + while (end > lineStartPosSb && isTrimmableInlineWhitespace(sb.charAt(end - 1))) { + end--; + } + sb.setLength(end); + } lineStartPos = appendSameTypeLineBreak(sb, s, lineBreakPos); } diff --git a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java index ab80f64d5..22568ef98 100644 --- a/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -116,10 +116,27 @@ public void testDedent0Arg() throws Exception { assertExpOutput("' a\\n b\\n c'?dedent", "a\n b\nc"); } + @Test + public void testDedent2Arg() throws Exception { + assertExpOutput("' a\\n \\n b'?dedent(' ', true)", "a\n\nb"); + assertExpOutput("' a\\n \\n b'?dedent(' ', false)", "a\n \nb"); + } + + @Test + public void testDedentRightTrimDefaultsToTrue() throws Exception { + assertExpOutput("' a\\n \\n b'?dedent(' ')", "a\n\nb"); + } + @Test public void testDedentBadNumberOfArgs() { - assertErrorContains("${''?dedent()}", "?dedent", "expects 1 argument"); - assertErrorContains("${''?dedent(' ', 2)}", "?dedent", "expects 1 argument"); + assertErrorContains("${''?dedent()}", "?dedent", "expects 1 or 2 arguments"); + assertErrorContains("${''?dedent(' ', true, 3)}", "?dedent", "expects 1 or 2 arguments"); + } + + @Test + public void testDedentArgTypeCoercion() { + assertErrorContains("${''?dedent(1)}", "string as argument #1"); + assertErrorContains("${''?dedent(' ', 2)}", "boolean as argument #2"); } @Test diff --git a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java index f070cdb1b..d5c3f3d0d 100644 --- a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java +++ b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java @@ -19,426 +19,401 @@ package freemarker.core; import static org.junit.Assert.*; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Collection; import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; import freemarker.template.utility.StringUtil; import freemarker.test.hamcerst.Matchers; +@RunWith(Enclosed.class) public class _CoreStringUtilsTest { - // ---- indent tests ---- + /** + * Tests of {@code indent} and {@code dedent}, repeated for each line-break type, and with and without a + * line-break at the end of the input. + * + *

The test data is written with {@code "\n"} as the line-break and with no line-break at the end; + * {@link #lb(String)} adapts both the input and the expected value to the parameters of the actual run. So + * {@code assertIndent(" a\n b", "a\nb", " ")} also checks + * {@code indent("a\r\nb\r\n", " ") == " a\r\n b\r\n"}, and so on. + */ + @RunWith(Parameterized.class) + public static class IndentAndDedentTest { + + @Parameters(name = "{0}, trailingLineBreak={2}") + public static Collection parameters() { + Collection result = new ArrayList<>(); + for (String[] lineBreak : new String[][] { { "LF", "\n" }, { "CRLF", "\r\n" }, { "CR", "\r" } }) { + for (boolean trailingLineBreak : new boolean[] { false, true }) { + result.add(new Object[] { lineBreak[0], lineBreak[1], trailingLineBreak }); + } + } + return result; + } - @Test - public void testIndentSingleLine() { - assertEquals( - " hello", - _CoreStringUtils.indent("hello", " ")); - } + private final String lineBreak; + private final boolean trailingLineBreak; - @Test - public void testIndentMultiLine() { - assertEquals( - " line1\n line2\n line3", - _CoreStringUtils.indent("line1\nline2\nline3", " ")); - } + public IndentAndDedentTest(String lineBreakName, String lineBreak, boolean trailingLineBreak) { + this.lineBreak = lineBreak; + this.trailingLineBreak = trailingLineBreak; + } - @Test - public void testIndentWithPrefix() { - assertEquals( - " * line1\n * line2", - _CoreStringUtils.indent("line1\nline2", " * ")); - } + /** + * Adapts test data written with {@code "\n"} and without a trailing line-break to the parameters of this run. + */ + private String lb(String s) { + return s.replace("\n", lineBreak) + (trailingLineBreak ? lineBreak : ""); + } - @Test - public void testIndentEmptyString() { - assertEquals( - "", - _CoreStringUtils.indent("", " ")); - } + private void assertIndent(String expected, String s, String prefix) { + assertEquals(lb(expected), _CoreStringUtils.indent(lb(s), prefix)); + } - @Test - public void testIndentPreservesBlankLines() { - assertEquals( - " a\n\n b", - _CoreStringUtils.indent("a\n\nb", " ")); - } + private void assertIndent(String expected, String s, String prefix, boolean rightTrim) { + assertEquals(lb(expected), _CoreStringUtils.indent(lb(s), prefix, rightTrim)); + } - @Test - public void testIndentTrailingNewline() { - assertEquals( - " a\n b\n", - _CoreStringUtils.indent("a\nb\n", " ")); - } + private void assertDedent(String expected, String s, String prefixToRemove) { + assertEquals(lb(expected), _CoreStringUtils.dedent(lb(s), prefixToRemove)); + } - @Test - public void testIndentNonWhitespacePrefixOnBlankLine() { - // The prefix is added to the blank line too, then right-trimmed, so it becomes "#" rather - // than "# " — the space in "# " is a separator, only wanted when there's content after it. - assertEquals( - "# a\n#\n# b", - _CoreStringUtils.indent("a\n\nb", "# ")); - } + private void assertDedent(String expected, String s, String prefixToRemove, boolean rightTrim) { + assertEquals(lb(expected), _CoreStringUtils.dedent(lb(s), prefixToRemove, rightTrim)); + } - @Test - public void testIndentWhitespaceOnlyLineTreatedAsBlank() { - // A line of accidental spaces behaves the same as a truly empty one. - assertEquals( - "# a\n#\n# b", - _CoreStringUtils.indent("a\n \nb", "# ")); - } + private void assertDedent(String expected, String s) { + assertEquals(lb(expected), _CoreStringUtils.dedent(lb(s))); + } - @Test - public void testIndentRightTrimOff() { - assertEquals( - "# a\n# \n# b", - _CoreStringUtils.indent("a\n\nb", "# ", false)); - } + // ---- indent ---- - @Test - public void testIndentRemovesTrailingWhitespaceFromContentLines() { - assertEquals( - " a\n b", - _CoreStringUtils.indent("a \nb\t", " ")); - } + @Test + public void testIndentSingleLine() { + assertIndent(" hello", "hello", " "); + } - @Test - public void testIndentKeepsNonBreakingSpace() { - // U+00A0 isn't whitespace for trimming purposes — that's the point of a non-breaking space. - assertEquals( - " a\u00A0", - _CoreStringUtils.indent("a\u00A0", " ")); - } + @Test + public void testIndentMultiLine() { + assertIndent(" line1\n line2\n line3", "line1\nline2\nline3", " "); + } - @Test - public void testIndentDedentRoundTrip() { - String original = "int x;\n\nint y;\n"; - assertEquals( - original, - _CoreStringUtils.dedent(_CoreStringUtils.indent(original, " "), " ")); - } + @Test + public void testIndentNonWhitespacePrefix() { + assertIndent(" * line1\n * line2", "line1\nline2", " * "); + } - // ---- wrap tests ---- + @Test + public void testIndentEmptyString() { + assertIndent("", "", " "); + } - @Test - public void testWrapBasic() { - assertEquals( - " * @brief Hello world.\n", - _CoreStringUtils.wrap("Hello world.", 40, " * @brief ")); - } + @Test + public void testIndentWhitespacePrefixLeavesBlankLinesEmpty() { + // The prefix is added to the blank line too, but then trimmed away again. + assertIndent(" a\n\n b", "a\n\nb", " "); + } - @Test - public void testWrapLongTextNoPrefix() { - testWrapLongText(null, null); - } + @Test + public void testIndentNonWhitespacePrefixOnBlankLine() { + // The blank line becomes "#" rather than "# ": the space in "# " is only meant to separate + // the prefix from the content of a line, and there's no content here. + assertIndent("# a\n#\n# b", "a\n\nb", "# "); + } - @Test - public void testWrapLongTextFirstPrefixOnly() { - testWrapLongText(" * ", null); - } + @Test + public void testIndentWhitespaceOnlyLineTreatedAsBlank() { + // A line of accidental spaces gives the same result as a truly empty one. + assertIndent("# a\n#\n# b", "a\n \nb", "# "); + } - @Test - public void testWrapLongTextWithDifferentPrefixes() { - testWrapLongText(" * @brief ", " * "); - } + @Test + public void testIndentRightTrimOff() { + assertIndent("# a\n# \n# b", "a\n\nb", "# ", false); + assertIndent("# \n# a", " \na", "# ", false); + } - private void testWrapLongText(String firstPrefix, String restPrefix) { - for (int width = 30; width <= 100; width += 10) { - testWrapLongText(width, firstPrefix, restPrefix); + @Test + public void testIndentRemovesTrailingWhitespaceOfContentLines() { + assertIndent(" a\n b", "a \nb\t", " "); } - } - private void testWrapLongText(int width, String firstPrefix, String restPrefix) { - String text = "This is a description that needs wrapping to fit within bounds. Also it's a very long text."; + @Test + public void testIndentKeepsNonBreakingSpace() { + // U+00A0 isn't whitespace as far as trimming is concerned; that's the point of it. + assertIndent(" a\u00A0", "a\u00A0", " "); + } - String result = - firstPrefix == null ? _CoreStringUtils.wrap(text, width) - : restPrefix == null ? _CoreStringUtils.wrap(text, width, firstPrefix) - : _CoreStringUtils.wrap(text, width, firstPrefix, restPrefix); + @Test + public void testIndentEmptyPrefixDoesNothing() { + // Nothing to add, so nothing happens; not even the trailing whitespace is trimmed. + assertIndent("a \n \nb", "a \n \nb", ""); + } - String effFirstPrefix = firstPrefix != null ? firstPrefix : ""; - String effRestPrefix = restPrefix != null ? restPrefix : effFirstPrefix; + // ---- dedent with an explicit prefix ---- - assertTrue(result.startsWith(effFirstPrefix)); + @Test + public void testDedentBasic() { + assertDedent("int x;\nint y;", " int x;\n int y;", " "); + } - // Second line should start with rest prefix - String[] lines = result.split("\n", -1); - for (int i = 1; i < lines.length - 1; i++) { - String line = lines[i]; - if (line.length() > width) { - fail("Line " + i + " is too long: " + StringUtil.jQuote(line)); - } - if (!line.startsWith(effRestPrefix)) { - fail("Line " + i + " doesn't start as expected: " + StringUtil.jQuote(line)); - } + @Test + public void testDedentPartialPrefixIsShortened() { + // " short" shares 2 characters with the 4 character long prefix, so it loses those 2. + // " full" starts with the whole prefix, so it loses all 4. + assertDedent("short\nfull", " short\n full", " "); } - assertEquals("", lines[lines.length - 1]); - assertTrue(result.endsWith("\n")); - } + @Test + public void testDedentPartialPrefixNonWhitespace() { + assertDedent("a\na\na\na\nx123a\n23a", "a\n1a\n12a\n123a\nx123a\n23a", "123"); + } - @Test - public void testWrapSamePrefix() { - assertEquals( - "// hello world\n", - _CoreStringUtils.wrap("hello world", 40, "// ")); - } + @Test + public void testDedentUnrelatedPrefixLeftAlone() { + assertDedent("xa\nyb", "xa\nyb", "---"); + } - @Test - public void testWrapSingleLongWord() { - // A single word longer than width — can't break, just emit it - assertEquals( - "superlongword\n", - _CoreStringUtils.wrap("superlongword", 4, "")); - // Not even after the prefix - assertEquals( - " * superlongword\n", - _CoreStringUtils.wrap("superlongword", 4, " * ")); - } + @Test + public void testDedentWhitespaceOnlyLineIsTrimmed() { + // The 3rd line only shares 4 of its 5 spaces with the prefix; the leftover space is trimmed + // away, so the line ends up empty, as with indent. + assertDedent("a\n\nb\n", " a\n \n b\n ", " "); + } - @Test - public void testWrapCollapsesWhitespaces() { - assertEquals( - "a b c d e\n", - _CoreStringUtils.wrap(" a \n b \n c\t\td e ", 40, "")); - } + @Test + public void testDedentWhitespaceOnlyLineKeptWithTrimOff() { + assertDedent("a\n\nb\n ", " a\n \n b\n ", " ", false); + } - @Test - public void testWrapWithNbsp() { - // No NBSP: - assertEquals( - "word1\nword2\nword3\nword4\n", - _CoreStringUtils.wrap("word1 word2 word3 word4", 4)); - // With NBSP: - assertEquals( - "word1\u00A0word2\u00A0word3\u00A0word4\n", - _CoreStringUtils.wrap("word1\u00A0word2\u00A0word3\u00A0word4", 4)); - assertEquals( - "word1\u00A0word2\nword3\u00A0word4\n", - _CoreStringUtils.wrap("word1\u00A0word2 word3\u00A0word4", 4)); - assertEquals( - "word1\u00A0\nword2\n\u00A0word3\n", - _CoreStringUtils.wrap("word1\u00A0 word2 \u00A0word3", 4)); - assertEquals( - "\u00A0 a \u00A0\u00A0 b \u00A0\n", - _CoreStringUtils.wrap(" \u00A0 a \u00A0\u00A0 b \u00A0 ", 40, "")); - } + @Test + public void testDedentRemovesTrailingWhitespaceOfContentLines() { + assertDedent("a\nb", " a \n b\t", " "); + assertDedent("a \nb\t", " a \n b\t", " ", false); + } - @Test - public void testWrapWithInputLeadingTrailingEmptyLinesDoesntMatter() { - assertEquals( - "word1\nword2\n", - _CoreStringUtils.wrap("word1 word2", 4)); - assertEquals( - "word1\nword2\n", - _CoreStringUtils.wrap("word1 word2\n", 4)); - assertEquals( - "word1\nword2\n", - _CoreStringUtils.wrap("\n\nword1 word2\n\n", 4)); - } + @Test + public void testDedentMixed() { + assertDedent("a\n b\nc", " a\n b\n c", " "); + } - @Test - public void testWrapZeroWidthThrows() { - try { - _CoreStringUtils.wrap("hello", 0); - fail(); - } catch (IllegalArgumentException e) { - assertThat( - e.getMessage(), - Matchers.containsStringIgnoringCase("must be at least 1")); + @Test + public void testDedentEmptyString() { + assertDedent("", "", " "); } - } - // ---- dedent tests ---- + @Test + public void testDedentEmptyPrefixDoesNothing() { + // Nothing to remove, so nothing happens; not even the trailing whitespace is trimmed. + assertDedent(" hello", " hello", ""); + assertDedent(" hello ", " hello ", ""); + } - @Test - public void testDedentBasic() { - assertEquals( - "int x;\nint y;\n", - _CoreStringUtils.dedent(" int x;\n int y;\n", " ") - ); - } + @Test + public void testIndentDedentRoundTrip() { + String prefix = " "; + // Note that the blank line must have no trailing whitespace for this to round-trip, which is + // what the trimming of both built-ins ensures. + String s = lb("line1\n line2\n\nline3"); + assertEquals(s, _CoreStringUtils.dedent(_CoreStringUtils.indent(s, prefix), prefix)); + } - @Test - public void testDedentPartialPrefixIsShortened() { - // A line carrying only part of the prefix loses that part, rather than being left alone. - // " short" shares 2 characters with the 4-space prefix → those 2 are removed. - // " full" carries the whole prefix → all 4 are removed. - assertEquals( - "short\nfull\n", - _CoreStringUtils.dedent(" short\n full\n", " ") - ); - } + // ---- dedent with no prefix (Python textwrap.dedent-style) ---- - @Test - public void testDedentPartialPrefixNonWhitespace() { - // Every one of these loses whatever it shares with "---", so all end up as "a". - assertEquals( - "a\na\na\na\nx123a\n23a", - _CoreStringUtils.dedent("a\n1a\n12a\n123a\nx123a\n23a", "123") - ); - } + @Test + public void testDedentNoArgsUniformIndent() { + assertDedent("a\nb\nc", " a\n b\n c"); + } - @Test - public void testDedentUnrelatedPrefixLeftAlone() { - // Shares nothing with the prefix → untouched. - assertEquals( - "xa\nyb\n", - _CoreStringUtils.dedent("xa\nyb\n", "---") - ); - } + @Test + public void testDedentNoArgsMixedIndent() { + // The longest common leading whitespace across non-empty lines is 2 spaces. + assertDedent("a\n b\n c", " a\n b\n c"); + } - @Test - public void testDedentWhitespaceOnlyLineIsNotTrimmed() { - // The 2 spaces are all this line shares with the 4-space prefix, so it's left empty - // instead of keeping accidental trailing whitespace. - assertEquals( - "a\n\nb\n \n", - _CoreStringUtils.dedent(" a\n \n b\n \n", " ") - ); - } + @Test + public void testDedentNoArgsRespectsEmptyLines() { + // Empty lines are ignored when computing the common prefix. + assertDedent("a\n\nb", " a\n\n b"); + } - @Test - public void testDedentMixed() { - // Some lines match, some don't - assertEquals( - "a\n b\nc\n", - _CoreStringUtils.dedent(" a\n b\n c\n", " ") - ); - } + @Test + public void testDedentNoArgsNormalizesWhitespaceOnlyLines() { + // A whitespace-only line doesn't constrain the common prefix, and comes out empty rather than + // keeping whitespace that was accidental to begin with. Same as textwrap.dedent. + assertDedent("a\n\nb", " a\n \n b"); + // Including when it has more whitespace than the common prefix: + assertDedent("a\n\nb", " a\n \n b"); + } - @Test - public void testDedentEmptyString() { - assertEquals( - "", - _CoreStringUtils.dedent("", " ") - ); - } + @Test + public void testDedentNoArgsNoCommonPrefix() { + assertDedent("a\n b", "a\n b"); + } - @Test - public void testDedentEmptyPrefix() { - assertEquals( - " hello", - _CoreStringUtils.dedent(" hello", "") - ); - } + @Test + public void testDedentNoArgsTabAndSpaceDistinct() { + // A leading tab and a leading space have no common prefix. (Same as textwrap.dedent.) + assertDedent("\ta\n b", "\ta\n b"); + } - @Test - public void testDedentNoTrailingNewline() { - assertEquals( - "hello", - _CoreStringUtils.dedent(" hello", " ") - ); - } + @Test + public void testDedentNoArgsTabsOnly() { + assertDedent("a\nb", "\t\ta\n\t\tb"); + } + + @Test + public void testDedentNoArgsEmptyString() { + assertDedent("", ""); + } - @Test - public void testDedentSymmetryWithIndent() { - // indent then dedent should round-trip - String text = "line1\n line2\nline3"; - String prefix = " "; - assertEquals( - text, - _CoreStringUtils.dedent(_CoreStringUtils.indent(text, prefix), prefix) - ); + @Test + public void testDedentNoArgsAlreadyDedented() { + assertDedent("a\nb\nc", "a\nb\nc"); + } } - // ---- dedent no-args (Python textwrap.dedent-style) tests ---- + /** + * Tests of {@code wrap}, which doesn't preserve the line structure of its input (it collapses all whitespace, + * including line-breaks), and so isn't affected by the line-break type of the input. + */ + public static class WrapTest { - @Test - public void testDedentNoArgsUniformIndent() { - assertEquals( - "a\nb\nc", - _CoreStringUtils.dedent(" a\n b\n c") - ); - } + @Test + public void testWrapBasic() { + assertEquals( + " * @brief Hello world.\n", + _CoreStringUtils.wrap("Hello world.", 40, " * @brief ")); + } - @Test - public void testDedentNoArgsMixedIndent() { - // The longest common leading whitespace across non-empty lines is 2 spaces. - assertEquals( - "a\n b\n c", - _CoreStringUtils.dedent(" a\n b\n c") - ); - } + @Test + public void testWrapLongTextNoPrefix() { + testWrapLongText(null, null); + } - // !!T - @Test - public void testDedentNoArgsMixedIndentCrLf() { - // The longest common leading whitespace across non-empty lines is 2 spaces. - assertEquals( - "a\r\n b\r\n c", - _CoreStringUtils.dedent(" a\r\n b\r\n c") - ); - } + @Test + public void testWrapLongTextFirstPrefixOnly() { + testWrapLongText(" * ", null); + } - @Test - public void testDedentNoArgsRespectsEmptyLines() { - // Empty/whitespace-only lines are ignored when computing the common prefix. - assertEquals( - "a\n\nb", - _CoreStringUtils.dedent(" a\n\n b") - ); - } + @Test + public void testWrapLongTextWithDifferentPrefixes() { + testWrapLongText(" * @brief ", " * "); + } - @Test - public void testDedentNoArgsNormalizesWhitespaceOnlyLines() { - // A whitespace-only line doesn't constrain the common prefix, and comes out empty rather - // than keeping whitespace that was accidental to begin with. Same as textwrap.dedent. - assertEquals( - "a\n\nb", - _CoreStringUtils.dedent(" a\n \n b") - ); - // Including when it's longer than the common prefix. - assertEquals( - "a\n\nb", - _CoreStringUtils.dedent(" a\n \n b") - ); - } + private void testWrapLongText(String firstPrefix, String restPrefix) { + for (int width = 30; width <= 100; width += 10) { + testWrapLongText(width, firstPrefix, restPrefix); + } + } - @Test - public void testDedentNoArgsNoCommonPrefix() { - // If lines have no common leading whitespace, nothing is stripped. - assertEquals( - "a\n b", - _CoreStringUtils.dedent("a\n b") - ); - } + private void testWrapLongText(int width, String firstPrefix, String restPrefix) { + String text = "This is a description that needs wrapping to fit within bounds. Also it's a very long text."; + + String result = + firstPrefix == null ? _CoreStringUtils.wrap(text, width) + : restPrefix == null ? _CoreStringUtils.wrap(text, width, firstPrefix) + : _CoreStringUtils.wrap(text, width, firstPrefix, restPrefix); + + String effFirstPrefix = firstPrefix != null ? firstPrefix : ""; + String effRestPrefix = restPrefix != null ? restPrefix : effFirstPrefix; + + assertTrue(result.startsWith(effFirstPrefix)); + + // Second line should start with rest prefix + String[] lines = result.split("\n", -1); + for (int i = 1; i < lines.length - 1; i++) { + String line = lines[i]; + if (line.length() > width) { + fail("Line " + i + " is too long: " + StringUtil.jQuote(line)); + } + if (!line.startsWith(effRestPrefix)) { + fail("Line " + i + " doesn't start as expected: " + StringUtil.jQuote(line)); + } + } - @Test - public void testDedentNoArgsTabAndSpaceDistinct() { - // A leading tab and a leading space have no common prefix. - // (Same behaviour as Python textwrap.dedent.) - assertEquals( - "\ta\n b", - _CoreStringUtils.dedent("\ta\n b") - ); - } + assertEquals("", lines[lines.length - 1]); + assertTrue(result.endsWith("\n")); + } - @Test - public void testDedentNoArgsTabsOnly() { - assertEquals( - "a\nb", - _CoreStringUtils.dedent("\t\ta\n\t\tb") - ); - } + @Test + public void testWrapSamePrefix() { + assertEquals( + "// hello world\n", + _CoreStringUtils.wrap("hello world", 40, "// ")); + } - @Test - public void testDedentNoArgsEmptyString() { - assertEquals( - "", - _CoreStringUtils.dedent("") - ); - } + @Test + public void testWrapSingleLongWord() { + // A single word longer than width — can't break, just emit it + assertEquals( + "superlongword\n", + _CoreStringUtils.wrap("superlongword", 4, "")); + // Not even after the prefix + assertEquals( + " * superlongword\n", + _CoreStringUtils.wrap("superlongword", 4, " * ")); + } + + @Test + public void testWrapCollapsesWhitespaces() { + assertEquals( + "a b c d e\n", + _CoreStringUtils.wrap(" a \n b \n c\t\td e ", 40, "")); + } - @Test - public void testDedentNoArgsAlreadyDedented() { - // No common leading whitespace => no change. - assertEquals( - "a\nb\nc", - _CoreStringUtils.dedent("a\nb\nc") - ); + @Test + public void testWrapWithNbsp() { + // No NBSP: + assertEquals( + "word1\nword2\nword3\nword4\n", + _CoreStringUtils.wrap("word1 word2 word3 word4", 4)); + // With NBSP: + assertEquals( + "word1\u00A0word2\u00A0word3\u00A0word4\n", + _CoreStringUtils.wrap("word1\u00A0word2\u00A0word3\u00A0word4", 4)); + assertEquals( + "word1\u00A0word2\nword3\u00A0word4\n", + _CoreStringUtils.wrap("word1\u00A0word2 word3\u00A0word4", 4)); + assertEquals( + "word1\u00A0\nword2\n\u00A0word3\n", + _CoreStringUtils.wrap("word1\u00A0 word2 \u00A0word3", 4)); + assertEquals( + "\u00A0 a \u00A0\u00A0 b \u00A0\n", + _CoreStringUtils.wrap(" \u00A0 a \u00A0\u00A0 b \u00A0 ", 40, "")); + } + + @Test + public void testWrapWithInputLeadingTrailingEmptyLinesDoesntMatter() { + assertEquals( + "word1\nword2\n", + _CoreStringUtils.wrap("word1 word2", 4)); + assertEquals( + "word1\nword2\n", + _CoreStringUtils.wrap("word1 word2\n", 4)); + assertEquals( + "word1\nword2\n", + _CoreStringUtils.wrap("\n\nword1 word2\n\n", 4)); + } + + @Test + public void testWrapZeroWidthThrows() { + try { + _CoreStringUtils.wrap("hello", 0); + fail(); + } catch (IllegalArgumentException e) { + assertThat( + e.getMessage(), + Matchers.containsStringIgnoringCase("must be at least 1")); + } + } } } diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index e0bed168e..eec1b2619 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -14152,6 +14152,19 @@ foo(); indentation hierarchy. + The explicit-prefix form also removes the trailing whitespace + of each resulting line. This matters because removing a prefix can + leave whitespace behind that only looks like indentation: a line that + contains whitespace only loses just as much of it as the prefix is + long, so with a 4 character long prefix a line of 5 spaces would keep + 1 space. With the trimming, such a line becomes empty instead, like + it does with indent, which + also trims. Set the optional 2nd parameter to + false to switch the trimming off: + + ${code?dedent(" ", false)} + See also the indent built-in, which is its inverse (roughly).