You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2026/08/08 17:38:41 UTC

(freemarker) 01/01: Added ?lines

This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a commit to branch feature/lines-bi
in repository https://gitbox.apache.org/repos/asf/freemarker.git

commit 5b5a1bb38bee6c5469a730f2c2c69fe482431be7
Author: ddekany <dd...@apache.org>
AuthorDate: Fri Aug 7 01:59:56 2026 +0200

    Added ?lines
---
 .../src/main/java/freemarker/core/BuiltIn.java     |   3 +-
 .../freemarker/core/BuiltInsForStringsBasic.java   |  25 +++++
 .../java/freemarker/core/LinesBuiltInTest.java     | 116 +++++++++++++++++++++
 .../java/freemarker/core/_CoreStringUtilsTest.java |  57 +++++++++-
 freemarker-manual/src/main/docgen/en_US/book.xml   |  71 ++++++++++++-
 5 files changed, 265 insertions(+), 7 deletions(-)

diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java
index bf0b8914..6194235e 100644
--- a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java
+++ b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java
@@ -86,7 +86,7 @@ abstract class BuiltIn extends Expression implements Cloneable {
 
     static final Set<String> CAMEL_CASE_NAMES = new TreeSet<>();
     static final Set<String> SNAKE_CASE_NAMES = new TreeSet<>();
-    static final int NUMBER_OF_BIS = 307;
+    static final int NUMBER_OF_BIS = 308;
     static final HashMap<String, BuiltIn> BUILT_INS_BY_NAME = new HashMap<>(NUMBER_OF_BIS * 3 / 2 + 1, 1f);
 
     static final String BI_NAME_SNAKE_CASE_WITH_ARGS = "with_args";
@@ -250,6 +250,7 @@ abstract class BuiltIn extends Expression implements Cloneable {
         putBI("last", new lastBI());
         putBI("left_pad", "leftPad", new BuiltInsForStringsBasic.padBI(true));
         putBI("length", new BuiltInsForStringsBasic.lengthBI());
+        putBI("lines", new BuiltInsForStringsBasic.linesBI());
         putBI("long", new longBI());
         putBI("lower_abc", "lowerAbc", new BuiltInsForNumbers.lower_abcBI());
         putBI("lower_case", "lowerCase", new BuiltInsForStringsBasic.lower_caseBI());
diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java
index 40419986..ec7ec3e3 100644
--- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java
+++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java
@@ -19,6 +19,7 @@
 
 package freemarker.core;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import java.util.StringTokenizer;
@@ -35,7 +36,9 @@ import freemarker.template.TemplateMethodModel;
 import freemarker.template.TemplateMethodModelEx;
 import freemarker.template.TemplateModel;
 import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateModelListSequence;
 import freemarker.template.TemplateScalarModel;
+import freemarker.template.TemplateSequenceModel;
 import freemarker.template._ObjectWrappers;
 import freemarker.template.utility.StringUtil;
 
@@ -682,7 +685,29 @@ class BuiltInsForStringsBasic {
         TemplateModel calculateResult(String s, Environment env) throws TemplateModelException {
             return new SplitMethod(s);
         }
+    }
+
+    static class linesBI extends BuiltInForString {
 
+        @Override
+        TemplateModel calculateResult(String s, Environment env) throws TemplateException {
+            List<TemplateModel> result = new ArrayList<>();
+            int lineStartPos = 0;
+            for (int pos = 0; pos < s.length(); pos++) {
+                char c = s.charAt(pos);
+                if (c == '\r' || c == '\n') {
+                    result.add(new SimpleScalar(s.substring(lineStartPos, pos)));
+                    if (c == '\r' && pos + 1 < s.length() && s.charAt(pos + 1) == '\n') {
+                        pos++;
+                    }
+                    lineStartPos = pos + 1;
+                }
+            }
+            if (lineStartPos < s.length()) {
+                result.add(new SimpleScalar(s.substring(lineStartPos)));
+            }
+            return new TemplateModelListSequence(result);
+        }
     }
 
     static class starts_withBI extends BuiltInForString {
diff --git a/freemarker-core/src/test/java/freemarker/core/LinesBuiltInTest.java b/freemarker-core/src/test/java/freemarker/core/LinesBuiltInTest.java
new file mode 100644
index 00000000..522e3fae
--- /dev/null
+++ b/freemarker-core/src/test/java/freemarker/core/LinesBuiltInTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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 java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import freemarker.template.Configuration;
+import freemarker.template.TemplateException;
+import freemarker.test.TemplateTest;
+
+@RunWith(Parameterized.class)
+public class LinesBuiltInTest extends TemplateTest {
+
+    private final String lineBreak;
+
+    public LinesBuiltInTest(String lineBreakName, String lineBreak) {
+        this.lineBreak = lineBreak;
+    }
+
+    @Parameterized.Parameters(name = "{0}")
+    public static Collection<Object[]> parameters() {
+        return List.of(
+                new Object[]{"LF", "\n"},
+                new Object[]{"CRLF", "\r\n"},
+                new Object[]{"CR", "\r"});
+    }
+
+    @Override
+    protected Configuration createConfiguration() throws Exception {
+        return new Configuration(Configuration.VERSION_2_3_35);
+    }
+
+    private String lb(String s) {
+        return s.replace("\n", lineBreak);
+    }
+
+    private void assertLinesOutput(String input, String expectedOutput) throws TemplateException, IOException {
+        addToDataModel("s", lb(input));
+        assertOutput(
+                "[<#list s?lines as line>'${line}'<#sep>, </#list>]",
+                expectedOutput);
+    }
+
+    @Test
+    public void testBasics() throws Exception {
+        assertLinesOutput("", "[]");
+
+        assertLinesOutput("\n", "['']");
+
+        assertLinesOutput("L1", "['L1']");
+        assertLinesOutput("L1\n", "['L1']");
+
+        assertLinesOutput("L1\nL2", "['L1', 'L2']");
+        assertLinesOutput("L1\nL2\n", "['L1', 'L2']");
+
+        assertLinesOutput("1\n2\n3", "['1', '2', '3']");
+        assertLinesOutput("1\n2\n3\n", "['1', '2', '3']");
+    }
+
+    @Test
+    public void testEmptyLines() throws Exception {
+        assertLinesOutput("L1", "['L1']");
+        assertLinesOutput("L1\n", "['L1']");
+        assertLinesOutput("L1\n\n", "['L1', '']");
+        assertLinesOutput("L1\n\n\n", "['L1', '', '']");
+
+        assertLinesOutput("L1\nL2", "['L1', 'L2']");
+        assertLinesOutput("L1\n\nL2", "['L1', '', 'L2']");
+        assertLinesOutput("L1\n\n\nL2", "['L1', '', '', 'L2']");
+
+        assertLinesOutput("\nL1\nL2", "['', 'L1', 'L2']");
+        assertLinesOutput("\n\nL1\nL2", "['', '', 'L1', 'L2']");
+        assertLinesOutput("\n\n\nL1\nL2", "['', '', '', 'L1', 'L2']");
+    }
+
+    @Test
+    public void testNoTrimming() throws Exception {
+        assertLinesOutput(" a \n\tb\nc \n \n", "[' a ', '\tb', 'c ', ' ']");
+    }
+
+    @Test
+    public void testExampleUseCase() throws Exception {
+        addToDataModel("s", "a\n\nbb\nccc\n");
+        assertOutput(
+                "<#list s?lines as line>${line?right_pad(3)}|\n</#list>",
+                """
+                        a  |
+                           |
+                        bb |
+                        ccc|
+                        """);
+    }
+}
diff --git a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java
index 4046b588..808c6d7d 100644
--- a/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java
+++ b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java
@@ -73,7 +73,11 @@ public class _CoreStringUtilsTest {
          * 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 : "");
+            return lbTypeOnly(s) + (trailingLineBreak ? lineBreak : "");
+        }
+
+        private @NonNull String lbTypeOnly(String s) {
+            return s.replace("\n", lineBreak);
         }
 
         private void assertIndent(String expected, String s, String prefix) {
@@ -294,6 +298,57 @@ public class _CoreStringUtilsTest {
         }
     }
 
+    @RunWith(Parameterized.class)
+    public static class IndentAndDedentLineEndingTests {
+
+        private final String lineBreak;
+
+        public IndentAndDedentLineEndingTests(String lineBreakName, String lineBreak) {
+            this.lineBreak = lineBreak;
+        }
+
+        @Parameterized.Parameters(name = "{0}")
+        public static Collection<Object[]> parameters() {
+            return List.of(
+                    new Object[]{"LF", "\n"},
+                    new Object[]{"CRLF", "\r\n"},
+                    new Object[]{"CR", "\r"});
+        }
+
+        private String lb(String s) {
+            return s.replace("\n", lineBreak);
+        }
+
+        @Test
+        public void testIndentTrailingEmptyLineTreatment() {
+            // The \n after the last non-whitespace line is significant, but doesn't create a new logical line.
+            assertEquals(lb("-1\n-b"), lb(_CoreStringUtils.indent("1\nb", "-")));
+            assertEquals(lb("-2\n-b\n"), lb(_CoreStringUtils.indent("2\nb\n", "-")));
+            assertEquals(lb("-2\n-b\n-\n"), lb(_CoreStringUtils.indent("2\nb\n\n", "-")));
+            assertEquals(lb("-3\n-b\n-\n-\n"), lb(_CoreStringUtils.indent("3\nb\n\n\n", "-")));
+        }
+
+        @Test
+        public void testDedentTrailingEmptyLineTreatment() {
+            // The \n after the last non-whitespace line is significant, but doesn't create a new logical line.
+            assertEquals(lb("1\nb"), lb(_CoreStringUtils.dedent("-1\n-b", "-")));
+            assertEquals(lb("2\nb\n"), lb(_CoreStringUtils.dedent("-2\n-b\n", "-")));
+            assertEquals(lb("2\nb\n"), lb(_CoreStringUtils.dedent("-2\n-b\n-", "-")));
+            assertEquals(lb("2\nb\n\n"), lb(_CoreStringUtils.dedent("-2\n-b\n-\n", "-")));
+            assertEquals(lb("2\nb\n\n"), lb(_CoreStringUtils.dedent("-2\n-b\n-\n-", "-")));
+            assertEquals(lb("3\nb\n\n\n"), lb(_CoreStringUtils.dedent("-3\n-b\n-\n-\n", "-")));
+        }
+
+        @Test
+        public void testIndentTrailingWhiteSpaceLineNotRemoved() {
+            assertEquals(lb("-2\n-b\n-"), lb(_CoreStringUtils.indent("2\nb\n ", "-")));
+            assertEquals(lb("-2\n-b\n- "), lb(_CoreStringUtils.indent("2\nb\n ", "-", false)));
+
+            assertEquals(lb("-3\n-b\n-\n-\n-"), lb(_CoreStringUtils.indent("3\nb\n \n \n ", "-")));
+            assertEquals(lb("-3\n-b\n- \n- \n- "), lb(_CoreStringUtils.indent("3\nb\n \n \n ", "-", false)));
+        }
+    }
+
     /**
      * 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.
diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml
index 62aa2a85..353a9535 100644
--- a/freemarker-manual/src/main/docgen/en_US/book.xml
+++ b/freemarker-manual/src/main/docgen/en_US/book.xml
@@ -13284,6 +13284,10 @@ grant codeBase "file:/path/to/freemarker.jar"
             <para><link linkend="ref_builtin_length">length</link></para>
           </listitem>
 
+          <listitem>
+            <para><link linkend="ref_builtin_lines">lines</link></para>
+          </listitem>
+
           <listitem>
             <para><link linkend="ref_builtin_numType">long</link></para>
           </listitem>
@@ -15028,6 +15032,48 @@ String BEAN_NAME = "${beanName?j_string}";</programlisting>
           built-in</link> instead!</para>
         </section>
 
+        <section xml:id="ref_builtin_lines">
+          <title>lines</title>
+
+          <indexterm>
+            <primary>lines built-in</primary>
+          </indexterm>
+
+          <note>
+            <para>This built-in is available since FreeMarker 2.3.35.</para>
+          </note>
+
+          <para>Splits the string at the line breaks to a sequence of lines.
+          The resulting lines themselves don't contain the line-break
+          character. All kind of line-breaks are recognized:
+          <literal>LF</literal> (Linux), or <literal>CRLF</literal>
+          (DOS/Windows), even <literal>CR</literal> (old Mac).</para>
+
+          <para>Example:</para>
+
+          <programlisting role="template">&lt;#assign text = "Line 1\nLine 2\Line 3\n"&gt;
+&lt;#list text?lines as line&gt;
+  - ${line}
+&lt;/#list&gt;</programlisting>
+
+          <para>will output:</para>
+
+          <programlisting role="output">- Line 1
+- Line 2
+- Line 3</programlisting>
+
+          <para>The basic approach is that each line should end with a
+          line-break, including the last line. But if the text doesn't end
+          with line-break, then it's implied to have line-break at the end.
+          Therefore, with <literal>&lt;#assign text = "Line 1\nLine 2\Line
+          3"&gt;</literal> (no <literal>\n</literal> at the end), you would
+          get the same output like above.</para>
+
+          <para>With <literal>&lt;#assign text = "Line 1\nLine 2\Line
+          3\n\n"&gt;</literal> (two <literal>\n</literal>-s at the end), you
+          would get and additional empty line in the output.</para>
+        </section>
+
         <section xml:id="ref_builtin_matches">
           <title>matches</title>
 
@@ -30941,11 +30987,26 @@ TemplateModel x = env.getVariable("x");  // get variable x</programlisting>
 
           <itemizedlist>
             <listitem>
-              <para>New built-ins, mostly useful for source code (or
-              configuration file) generation: <link
-              linkend="ref_builtin_dedent">dedent</link>, <link
-              linkend="ref_builtin_indent">indent</link>, <link
-              linkend="ref_builtin_wrap">wrap</link></para>
+              <para>New multi-line text built-ins, mostly useful for source
+              code (or configuration file) generation: </para>
+
+              <itemizedlist>
+                <listitem>
+                  <para>Dedent or indent each line of a string: <link
+                  linkend="ref_builtin_dedent">dedent</link>, <link
+                  linkend="ref_builtin_indent">indent</link></para>
+                </listitem>
+
+                <listitem>
+                  <para>Reflow text to lines of the specified maximum width:
+                  <link linkend="ref_builtin_wrap">wrap</link></para>
+                </listitem>
+
+                <listitem>
+                  <para>Split string to a sequence of lines: <link
+                  linkend="ref_builtin_lines">lines</link></para>
+                </listitem>
+              </itemizedlist>
             </listitem>
           </itemizedlist>
         </section>