diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java index 1d53f617c..b77314386 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()); @@ -272,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()); @@ -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..f064e07f6 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java @@ -495,7 +495,353 @@ 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, 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()) { + 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()); + } + } + + /** + * 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); + } + } + + 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 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 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..6ef8d4ab8 --- /dev/null +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -0,0 +1,298 @@ +/* + * 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)); + } + + // ---- ?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 + public void testRightPadLinesBasic() throws Exception { + assertEquals("a \nbb \nccc \n", + eval("'a\\nbb\\nccc\\n'?right_pad_lines(10)")); + } + + @Test + public void testRightPadLinesWithFillChar() throws Exception { + assertEquals("a.........\nbb........\n", + eval("'a\\nbb\\n'?right_pad_lines(10, '.')")); + } + + @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)")); + } + + @Test + public void testRightPadLinesNoTrailingNewline() throws Exception { + assertEquals("a ", + eval("'a'?right_pad_lines(10)")); + } + + @Test + public void testRightPadLinesEmpty() throws Exception { + assertEquals("", eval("''?right_pad_lines(10)")); + } + + @Test + public void testRightPadLinesCamelCase() throws Exception { + assertEquals("a \nbb \n", + eval("'a\\nbb\\n'?rightPadLines(5)")); + } + + @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]); + } +} diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index c4318bea8..2e78de80c 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 @@ -13385,6 +13393,11 @@ grant codeBase "file:/path/to/freemarker.jar" linkend="ref_builtin_right_pad">right_pad + + right_pad_lines + + round @@ -13530,6 +13543,10 @@ grant codeBase "file:/path/to/freemarker.jar" linkend="ref_builtin_word_list">word_list + + wrap + + xhtml @@ -14039,6 +14056,81 @@ 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 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. + + 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(" ")}] + + 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 +14426,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 +15144,65 @@ ${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, + CR, or CRLF, 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): + + 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"?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 @@ -15956,6 +16155,65 @@ ${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. + + + 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. + +
+
xhtml (deprecated)