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..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,28 +483,178 @@ 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); + } + } + + 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); + return new SimpleScalar(_CoreStringUtils.indent(s, prefix)); + } + } + + @Override + TemplateModel calculateResult(String s, Environment env) throws TemplateException { + return new BIMethod(s); + } + } + + static class dedentBI extends BuiltInForString { + + private class BIMethod implements TemplateScalarModel, TemplateMethodModelEx { + + private final String targetAsString; + private String cachedResult; + + private BIMethod(String targetAsString) { + this.targetAsString = targetAsString; + } + + @Override + public Object exec(List args) throws TemplateModelException { + int argCnt = args.size(); + checkMethodArgCount(argCnt, 1); + + String prefix = getStringMethodArg(args, 0); + return new SimpleScalar(_CoreStringUtils.dedent(targetAsString, prefix)); + } + + @Override + public String getAsString() { + if (cachedResult == null) { + cachedResult = _CoreStringUtils.dedent(targetAsString); + } + return cachedResult; + } + } + + @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 targetAsString; + + private BIMethod(String targetAsString) { + this.targetAsString = targetAsString; + } + + @Override + public Object exec(List args) throws TemplateModelException { + int argCnt = args.size(); + checkMethodArgCount(argCnt, 1, 3); + + int width = getNumberMethodArg(args, 0).intValue(); + if (width < 1) { + throw new _TemplateModelException( + "?", key, "(...) argument #1 (width) must be at least 1."); + } + + 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 { + String restPrefix = getStringMethodArg(args, 2); + result = _CoreStringUtils.wrap(targetAsString, width, firstPrefix, restPrefix); + } + } else { + throw new BugException("Unexpected argCnt"); + } + + return new SimpleScalar(result); + } + } + + @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 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 { private String s; - + private BIMethod(String s) { this.s = s; } - + @Override public Object exec(List args) throws TemplateModelException { checkMethodArgCount(args, 1); @@ -512,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); @@ -520,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); @@ -535,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; @@ -564,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); @@ -592,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); @@ -600,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) { @@ -638,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( @@ -647,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), "."); } - + }; } } @@ -684,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( @@ -819,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) { @@ -851,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 new file mode 100644 index 000000000..2d91ee05e --- /dev/null +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -0,0 +1,139 @@ +/* + * 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.IOException; + +import org.junit.Test; + +import freemarker.template.Configuration; +import freemarker.template.TemplateException; +import freemarker.test.TemplateTest; + +/** + * Checks indent/dedend/wrap built-ns; for the thorough testing of the text transformations see the + * {@link _CoreStringUtilsTest}! + */ +public class IndentAndWrapBuiltInTest extends TemplateTest { + + @Override + protected Configuration createConfiguration() throws Exception { + return new Configuration(Configuration.VERSION_2_3_35); + } + + @Test + public void testIndentBasic() throws Exception { + assertExpOutput("'line1\\nline2'?indent(' * ')", " * line1\n * line2"); + } + + @Test + public void testIndentBadNumberOfArgs() { + assertErrorContains("${''?indent()}", "?indent", "expects 1 argument"); + assertErrorContains("${''?indent(1, 2)}", "?indent", "expects 1 argument"); + } + + @Test + public void testWrap1Arg() throws Exception { + assertExpOutput("'Hello world'?wrap(4)", "Hello\nworld\n"); + assertExpOutput("'Hello world'?wrap(40)", "Hello world\n"); + } + + @Test + public void testWrap2Arg() throws Exception { + assertExpOutput("'Hello world'?wrap(4, '* ')", "* Hello\n* world\n"); + } + + @Test + public void testWrap3Args() throws Exception { + assertExpOutput("'Hello world'?wrap(4, '* ', ' ')", "* Hello\n world\n"); + } + + @Test + public void testWrapNoArgTypeCoercion() throws Exception { + assertErrorContains("${''?wrap(4, 1)}", "string as argument #2"); + } + + @Test + public void testWrapBadNumberOfArgs() { + assertErrorContains("${''?wrap()}", "?wrap", "expects 1 to 3 arguments"); + assertErrorContains("${''?wrap(4, '*', '**', '***')}", "?wrap", "expects 1 to 3 arguments"); + } + + @Test + public void testWrapArg1AtLeast1() throws TemplateException, IOException { + assertErrorContains("${''?wrap(0, '* ')}", "width", "at least 1"); + assertErrorContains("${''?wrap(-1, '* ')}", "width", "at least 1"); + } + + @Test + public void testWrapBadArgTypeError() { + assertErrorContains("${''?wrap('4', '*')}", "number as argument #1"); + } + + @Test + 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 testDedent0Arg() throws Exception { + assertExpOutput("' a\\n b\\n c'?dedent", "a\n b\nc"); + } + + @Test + public void testDedentBadNumberOfArgs() { + assertErrorContains("${''?dedent()}", "?dedent", "expects 1 argument"); + assertErrorContains("${''?dedent(' ', 2)}", "?dedent", "expects 1 argument"); + } + + @Test + 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 new file mode 100644 index 000000000..32c9d4894 --- /dev/null +++ b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java @@ -0,0 +1,409 @@ +/* + * 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 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 c4318bea8..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); @@ -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,91 @@ 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 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 (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 + empty/whitespace-only lines are ignored when computing the common + prefix. The semantics match Python's + textwrap.dedent. + + For example: + + <#assign code = " if (x) {\n foo();\n }" /> +[${code?dedent}] + + will output this (the common prefix was 2 spaces, which was + removed): + + if (x) { + foo(); +} + + 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 = " if (x) {\n foo();\n }" /> +${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) { + foo(); + } + + Lines that don't start with the prefix are unaffected, so with + a 4-space prefix: + + <#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): + + if (x) { +foo(); + } + + See also the indent + built-in, which is its inverse. +
+
empty_to_null @@ -14334,6 +14436,55 @@ 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 (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(" ")} + + 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 +15155,68 @@ ${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 @@ -15956,6 +16169,100 @@ ${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 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. + + White-space + treatment: + + + + 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 + + + + The result always ends with a single line-break + + + + 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.) + + + + Example: + + ${"Some long text that need to be wrapper at reasonable width"?wrap(25)} + + will output this: + + Some long text that need +to be wrapper at +reasonable width + + + The width parameter is truncated to integer, and must be at + least 1. + +
+ 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 +
+
+
xhtml (deprecated) @@ -30591,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;