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"><#assign text = "Line 1\nLine 2\Line 3\n">
+<#list text?lines as line>
+ - ${line}
+</#list></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><#assign text = "Line 1\nLine 2\Line
+ 3"></literal> (no <literal>\n</literal> at the end), you would
+ get the same output like above.</para>
+
+ <para>With <literal><#assign text = "Line 1\nLine 2\Line
+ 3\n\n"></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>