diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java index 1d53f617c..bf0b89141 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java @@ -67,6 +67,7 @@ import freemarker.core.BuiltInsForStringsMisc.evalBI; import freemarker.core.BuiltInsForStringsMisc.evalJsonBI; import freemarker.template.Configuration; +import freemarker.template.TemplateBooleanModel; import freemarker.template.TemplateDateModel; import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; @@ -85,7 +86,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 +116,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 +140,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()); @@ -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()); @@ -491,6 +495,27 @@ protected final Number getNumberMethodArg(List args, int argIdx) } } + /** + * Same as {@link #getBooleanMethodArg}, but checks if {@code args} is big enough, and returns {@code defaultValue} + * if it isn't. + */ + protected final boolean getOptBooleanMethodArg(List args, int argIdx, boolean defaultValue) + throws TemplateModelException { + return args.size() > argIdx ? getBooleanMethodArg(args, argIdx) : defaultValue; + } + + /** + * Gets a method argument and checks if it's a boolean; it does NOT check if {@code args} is big enough. + */ + protected final boolean getBooleanMethodArg(List args, int argIdx) throws TemplateModelException { + TemplateModel arg = (TemplateModel) args.get(argIdx); + if (!(arg instanceof TemplateBooleanModel)) { + throw _MessageUtil.newMethodArgMustBeBooleanException("?" + key, argIdx, arg); + } else { + return ((TemplateBooleanModel) arg).getAsBoolean(); + } + } + protected final TemplateModelException newMethodArgInvalidValueException(int argIdx, Object[] details) { return _MessageUtil.newMethodArgInvalidValueException("?" + key, argIdx, details); } diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForStringsBasic.java index 8042a3d0b..404199864 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,137 @@ 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, 2); + + String prefix = getStringMethodArg(args, 0); + boolean rightTrim = getOptBooleanMethodArg(args, 1, true); + return new SimpleScalar(_CoreStringUtils.indent(s, prefix, rightTrim)); + } + } + + @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, 2); + + String prefix = getStringMethodArg(args, 0); + boolean rightTrim = getOptBooleanMethodArg(args, 1, true); + return new SimpleScalar(_CoreStringUtils.dedent(targetAsString, prefix, rightTrim)); + } + + @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 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 +621,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 +629,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 +644,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 +673,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 +701,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 +709,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 +747,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 +756,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 +793,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 +928,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 +960,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..054745e0e 100644 --- a/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java +++ b/freemarker-core/src/main/java/freemarker/core/_CoreStringUtils.java @@ -22,13 +22,14 @@ import java.util.Collection; import freemarker.template.Configuration; +import freemarker.template.utility.NullArgumentException; import freemarker.template.utility.StringUtil; /** - * For internal use only; don't depend on this, there's no backward compatibility guarantee at all! - * This class is to work around the lack of module system in Java, i.e., so that other FreeMarker packages can - * access things inside this package that users shouldn't. - */ + * For internal use only; don't depend on this, there's no backward compatibility guarantee at all! This class is to + * work around the lack of module system in Java, i.e., so that other FreeMarker packages can access things inside this + * package that users shouldn't. + */ public final class _CoreStringUtils { private _CoreStringUtils() { @@ -45,7 +46,8 @@ public static String toFTLTopLevelIdentifierReference(String name) { public static String toFTLTopLevelTragetIdentifier(final String name) { char quotationType = 0; - scanForQuotationType: for (int i = 0; i < name.length(); i++) { + scanForQuotationType: + for (int i = 0; i < name.length(); i++) { final char c = name.charAt(i); if (!(i == 0 ? StringUtil.isFTLIdentifierStart(c) : StringUtil.isFTLIdentifierPart(c)) && c != '@') { if ((quotationType == 0 || quotationType == '\\') @@ -58,14 +60,14 @@ public static String toFTLTopLevelTragetIdentifier(final String name) { } } switch (quotationType) { - case 0: - return name; - case '"': - return StringUtil.ftlQuote(name); - case '\\': - return backslashEscapeIdentifier(name); - default: - throw new BugException(); + case 0: + return name; + case '"': + return StringUtil.ftlQuote(name); + case '\\': + return backslashEscapeIdentifier(name); + default: + throw new BugException(); } } @@ -93,8 +95,8 @@ public static String backslashEscapeIdentifier(String name) { } /** - * @return {@link Configuration#CAMEL_CASE_NAMING_CONVENTION}, or {@link Configuration#LEGACY_NAMING_CONVENTION} - * or, {@link Configuration#AUTO_DETECT_NAMING_CONVENTION} when undecidable. + * @return {@link Configuration#CAMEL_CASE_NAMING_CONVENTION}, or {@link Configuration#LEGACY_NAMING_CONVENTION} or, + * {@link Configuration#AUTO_DETECT_NAMING_CONVENTION} when undecidable. */ public static int getIdentifierNamingConvention(String name) { final int ln = name.length(); @@ -109,11 +111,12 @@ public static int getIdentifierNamingConvention(String name) { } return Configuration.AUTO_DETECT_NAMING_CONVENTION; } - + // [2.4] Won't be needed anymore + /** - * A deliberately very inflexible camel case to underscored converter; it must not convert improper camel case - * names to a proper underscored name. + * A deliberately very inflexible camel case to underscored converter; it must not convert improper camel case names + * to a proper underscored name. */ public static String camelCaseToUnderscored(String camelCaseName) { int i = 0; @@ -124,7 +127,7 @@ public static String camelCaseToUnderscored(String camelCaseName) { // No conversion needed return camelCaseName; } - + StringBuilder sb = new StringBuilder(); sb.append(camelCaseName.substring(0, i)); while (i < camelCaseName.length()) { @@ -139,7 +142,7 @@ public static String camelCaseToUnderscored(String camelCaseName) { } return sb.toString(); } - + public static boolean isUpperUSASCII(char c) { return c >= 'A' && c <= 'Z'; } @@ -154,4 +157,351 @@ public static String commaSeparatedJQuotedItems(Collection items) { } return sb.toString(); } + + /** + * Same as {@link #indent(String, String, boolean)} with {@code rightTrim} set to {@code true}. + */ + public static String indent(String s, String prefix) { + return indent(s, prefix, true); + } + + /** + * Prepends {@code prefix} to each line, then, if {@code rightTrim} is {@code true}, removes the trailing whitespace + * of each resulting line. + * + *

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

Note that a non-breaking space (U+00A0) isn't whitespace as far as trimming is concerned, so it's kept. + */ + public static String indent(String s, String prefix, boolean rightTrim) { + // An empty prefix adds nothing, so this does nothing at all then, not even trimming; that's the least + // surprising behavior for something called "indent". + if (s == null || s.isEmpty() || prefix.isEmpty()) { + return s; + } + + int len = s.length(); + StringBuilder sb = new StringBuilder(len + prefix.length() * (4 + len / 20)); + int lineStartPos = 0; + while (lineStartPos < len) { + int lineBreakPos = findLineBreakFrom(s, lineStartPos); + + int lineStartPosSb = sb.length(); + sb.append(prefix); + sb.append(s, lineStartPos, lineBreakPos); + if (rightTrim) { + int end = sb.length(); + while (end > lineStartPosSb && isTrimmableInlineWhitespace(sb.charAt(end - 1))) { + end--; + } + sb.setLength(end); + } + + lineStartPos = appendSameTypeLineBreak(sb, s, lineBreakPos); + } + return sb.toString(); + } + + /** + * Removes from each line the longest prefix of {@code prefixToRemove} that the line starts with. Lines that start + * with the whole prefix lose all of it; lines that only start with some head part of the prefix lose that part; + * lines that share nothing with it are left alone. + * + *

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

Also, an all-or-nothing dedent could leave a line that was originally the least indented as the most + * indented one, which is much more confusing that a (partially) flattened hierarchy. + */ + public static String dedent(String s, String prefixToRemove) { + return dedent(s, prefixToRemove, true); + } + + /** + * Same as {@link #dedent(String, String)}, but you can also specify if the trailing whitespace of each resulting + * line should be removed. + * + *

The trimming matters because removing a prefix can leave whitespace behind that only looks like indentation: + * a line that contains whitespace only loses just as much of it as the prefix is long, so with a 4 character long + * prefix a line of 5 spaces would keep 1 space. Trimming removes such remains, and so a line that contains + * whitespace only becomes empty. It also keeps this symmetrical with + * {@link #indent(String, String, boolean)}, which trims too. + */ + public static String dedent(String s, String prefixToRemove, boolean rightTrim) { + // An empty prefix removes nothing, so this does nothing at all then, not even trimming; same as with + // indent. + if (s == null || s.isEmpty() || prefixToRemove.isEmpty()) { + return s; + } + + int prefixToRemoveLen = prefixToRemove.length(); + int len = s.length(); + StringBuilder sb = new StringBuilder(len); + int lineStartPos = 0; + while (lineStartPos < len) { + int lineBreakPos = findLineBreakFrom(s, lineStartPos); + + int matchedLen = 0; + while (matchedLen < prefixToRemoveLen && lineStartPos + matchedLen < lineBreakPos + && s.charAt(lineStartPos + matchedLen) == prefixToRemove.charAt(matchedLen)) { + matchedLen++; + } + int lineStartPosSb = sb.length(); + sb.append(s, lineStartPos + matchedLen, lineBreakPos); + if (rightTrim) { + int end = sb.length(); + while (end > lineStartPosSb && isTrimmableInlineWhitespace(sb.charAt(end - 1))) { + end--; + } + sb.setLength(end); + } + + lineStartPos = appendSameTypeLineBreak(sb, s, lineBreakPos); + } + return sb.toString(); + } + + /** + * Returns the index of the first line-break character at or after {@code from}, or the length of {@code s} if + * there's none. + */ + private static int findLineBreakFrom(String s, int from) { + int len = s.length(); + int i = from; + while (i < len && !isLineBreakChar(s.charAt(i))) { + i++; + } + return i; + } + + /** + * Appends the same type of line-break (LF, or CRLF, or CR) to {@code sb}, which is found at {@code lineBreakPos} in + * {@code s}; if the position is after the last char of {@code s}, then this appends nothing. + * + * @return the position at which the next line starts in {@code s} + */ + private static int appendSameTypeLineBreak(StringBuilder sb, String s, int lineBreakPos) { + int len = s.length(); + if (lineBreakPos >= len) { + return len; + } + char c = s.charAt(lineBreakPos); + sb.append(c); + if (c == '\r' && lineBreakPos + 1 < len && s.charAt(lineBreakPos + 1) == '\n') { + sb.append('\n'); + return lineBreakPos + 2; + } + return lineBreakPos + 1; + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private static boolean isLineBreakChar(char c) { + return c == '\r' || c == '\n'; + } + + /** + * Whether the character counts as removable whitespace inside a line. Anything that satisfies + * {@link Character#isWhitespace(char)} and is not a line terminator. Note the non-breaking space is not considered + * as trimmable. + */ + private static boolean isTrimmableInlineWhitespace(char c) { + return !isLineBreakChar(c) && Character.isWhitespace(c); + } + + /** + * Strip the longest leading indentation-white-space string (contains spaces and tabs only) that is a common amongst + * of all non-blank line (blank meaning only containing indentation-white-space). Lines that are blank are ignored + * and are 0-length in the output. Mirrors Python's {@code textwrap.dedent} semantics. + * + *

Note: this methods can't resolve tabs to spaces, nor convert spaces to tabs, so a tab in line only matches + * another tab in the other lines, not some number spaces. + */ + public static String dedent(String s) { + if (s.isEmpty()) { + return s; + } + + String commonPrefix = getLongestCommonIndentationWhitespace(s); + if (commonPrefix == null || commonPrefix.isEmpty()) { + return s; + } + + return removeCommonIndentationWhitespacePrefixFromLines(s, commonPrefix); + } + + /** + * Finds the longest common leading white-space indentation of the lines, but ignoring lines that only contains + * indentation-white-space. + */ + private static String getLongestCommonIndentationWhitespace(String s) { + int len = s.length(); + + String longestCommonIndent = null; + int lineStartPos = 0; + int contentEndPos; + while (lineStartPos < len) { + char c = s.charAt(lineStartPos); + + int identEndPos = lineStartPos; + while (isIndentationWhitespace(c)) { + identEndPos++; + if (identEndPos == len) { + break; + } + c = s.charAt(identEndPos); + } + + contentEndPos = identEndPos; + while (!isLineBreakChar(c)) { + contentEndPos++; + if (contentEndPos == len) { + break; + } + c = s.charAt(contentEndPos); + } + + if (identEndPos < contentEndPos) { + if (longestCommonIndent == null) { + longestCommonIndent = s.substring(lineStartPos, identEndPos); + } else { + int maxLen = Math.min(longestCommonIndent.length(), identEndPos - lineStartPos); + int matchingLen = 0; + while (matchingLen < maxLen + && longestCommonIndent.charAt(matchingLen) == s.charAt(lineStartPos + matchingLen)) { + matchingLen++; + } + if (matchingLen < longestCommonIndent.length()) { + longestCommonIndent = longestCommonIndent.substring(0, matchingLen); + } + if (longestCommonIndent.isEmpty()) { + return longestCommonIndent; // can't shrink further; finish quickly + } + } + } + + if (contentEndPos < len) { + if (c == '\r' && contentEndPos + 1 < len && s.charAt(contentEndPos + 1) == '\n') { + // Skip CRLF + contentEndPos += 2; + } else { + contentEndPos++; + } + } + + lineStartPos = contentEndPos; + } + return longestCommonIndent; + } + + /** + * Removes common prefix from each line, but replace indentation-white-space-only lines with empty line. We assume + * that each non-blank line starts with the given common prefix, and that it only contains indentation white-space, + * otherwise behavior is undefined. + */ + private static String removeCommonIndentationWhitespacePrefixFromLines(String s, String commonPrefix) { + int len = s.length(); + int prefixLen = commonPrefix.length(); + StringBuilder sb = new StringBuilder(len); + int lineStartPos = 0; + while (lineStartPos < len) { + int lineBreakPos = lineStartPos; + boolean blankLine = true; + while (lineBreakPos < len) { + char c = s.charAt(lineBreakPos); + if (isLineBreakChar(c)) { + break; + } + if (blankLine && !isIndentationWhitespace(c)) { + blankLine = false; + // Don't break from the loop! It's faster and more logical to get to the line-break in this loop. + } + lineBreakPos++; + } + + // An identation-whitespace-only line is normalized to empty rather than kept as is. Its whitespace is + // assumed to be accidental. This also matches Python textwrap.dedent behavior. + if (!blankLine) { + sb.append(s, lineStartPos + prefixLen, lineBreakPos); + } + if (lineBreakPos < len) { + lineBreakPos = appendSameTypeLineBreak(sb, s, lineBreakPos); + } + lineStartPos = lineBreakPos; + } + return sb.toString(); + } + + private static boolean isIndentationWhitespace(char c) { + return c == ' ' || c == '\t'; + } + + public static String wrap(String s, int width) { + return wrap(s, width, ""); + } + + public static String wrap(String s, int width, String firstPrefix) { + return wrap(s, width, firstPrefix, firstPrefix); + } + + /** + * In effect trims the input, breaks it to item at whitespace (except unbreakable space), and the reflow the items + * into space-separated lines no longer than the specified width, or a single item if that's longer. Inside a line, + * all interleaving whitespace is replaced with a single space. Unbreakable space is not considered to be + * white-space here. + * + * @param width + * Maximum line width, when possible. If a single unbreakable section is longer than this, then that will be + * in its own line. + * @param firstPrefix + * Prefix of the first line + * @param restPrefix + * Prefix of all lines after the first + */ + public static String wrap(String s, int width, String firstPrefix, String restPrefix) { + NullArgumentException.check(firstPrefix, "firstPrefix"); + NullArgumentException.check(restPrefix, "restPrefix"); + 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(); + } + } 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..22568ef98 --- /dev/null +++ b/freemarker-core/src/test/java/freemarker/core/IndentAndWrapBuiltInTest.java @@ -0,0 +1,147 @@ +/* + * 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 testIndent2Arg() throws Exception { + assertExpOutput("'a\\n\\nb'?indent('# ', true)", "# a\n#\n# b"); + assertExpOutput("'a\\n\\nb'?indent('# ', false)", "# a\n# \n# b"); + } + + @Test + public void testIndentRightTrimDefaultsToTrue() throws Exception { + assertExpOutput("'a\\n\\nb'?indent('# ')", "# a\n#\n# b"); + } + + @Test + public void testIndentBadNumberOfArgs() { + assertErrorContains("${''?indent()}", "?indent", "expects 1 or 2 arguments"); + assertErrorContains("${''?indent(1, 2, 3)}", "?indent", "expects 1 or 2 arguments"); + } + + @Test + public void testIndentArgTypeCoercion() { + assertErrorContains("${''?indent(1)}", "string as argument #1"); + assertErrorContains("${''?indent(' ', 'yes')}", "boolean as argument #2"); + } + + @Test + 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 testDedent2Arg() throws Exception { + assertExpOutput("' a\\n \\n b'?dedent(' ', true)", "a\n\nb"); + assertExpOutput("' a\\n \\n b'?dedent(' ', false)", "a\n \nb"); + } + + @Test + public void testDedentRightTrimDefaultsToTrue() throws Exception { + assertExpOutput("' a\\n \\n b'?dedent(' ')", "a\n\nb"); + } + + @Test + public void testDedentBadNumberOfArgs() { + assertErrorContains("${''?dedent()}", "?dedent", "expects 1 or 2 arguments"); + assertErrorContains("${''?dedent(' ', true, 3)}", "?dedent", "expects 1 or 2 arguments"); + } + + @Test + public void testDedentArgTypeCoercion() { + assertErrorContains("${''?dedent(1)}", "string as argument #1"); + assertErrorContains("${''?dedent(' ', 2)}", "boolean as argument #2"); + } + + @Test + public void testDedentNoArgTypeCoercion() throws Exception { + assertErrorContains("${''?dedent(1)}", "string as argument #1"); + } + +} 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..d5c3f3d0d --- /dev/null +++ b/freemarker-core/src/test/java/freemarker/core/_CoreStringUtilsTest.java @@ -0,0 +1,419 @@ +/* + * 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.util.ArrayList; +import java.util.Collection; + +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import freemarker.template.utility.StringUtil; +import freemarker.test.hamcerst.Matchers; + +@RunWith(Enclosed.class) +public class _CoreStringUtilsTest { + + /** + * Tests of {@code indent} and {@code dedent}, repeated for each line-break type, and with and without a + * line-break at the end of the input. + * + *

The test data is written with {@code "\n"} as the line-break and with no line-break at the end; + * {@link #lb(String)} adapts both the input and the expected value to the parameters of the actual run. So + * {@code assertIndent(" a\n b", "a\nb", " ")} also checks + * {@code indent("a\r\nb\r\n", " ") == " a\r\n b\r\n"}, and so on. + */ + @RunWith(Parameterized.class) + public static class IndentAndDedentTest { + + @Parameters(name = "{0}, trailingLineBreak={2}") + public static Collection parameters() { + Collection result = new ArrayList<>(); + for (String[] lineBreak : new String[][] { { "LF", "\n" }, { "CRLF", "\r\n" }, { "CR", "\r" } }) { + for (boolean trailingLineBreak : new boolean[] { false, true }) { + result.add(new Object[] { lineBreak[0], lineBreak[1], trailingLineBreak }); + } + } + return result; + } + + private final String lineBreak; + private final boolean trailingLineBreak; + + public IndentAndDedentTest(String lineBreakName, String lineBreak, boolean trailingLineBreak) { + this.lineBreak = lineBreak; + this.trailingLineBreak = trailingLineBreak; + } + + /** + * 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 : ""); + } + + private void assertIndent(String expected, String s, String prefix) { + assertEquals(lb(expected), _CoreStringUtils.indent(lb(s), prefix)); + } + + private void assertIndent(String expected, String s, String prefix, boolean rightTrim) { + assertEquals(lb(expected), _CoreStringUtils.indent(lb(s), prefix, rightTrim)); + } + + private void assertDedent(String expected, String s, String prefixToRemove) { + assertEquals(lb(expected), _CoreStringUtils.dedent(lb(s), prefixToRemove)); + } + + private void assertDedent(String expected, String s, String prefixToRemove, boolean rightTrim) { + assertEquals(lb(expected), _CoreStringUtils.dedent(lb(s), prefixToRemove, rightTrim)); + } + + private void assertDedent(String expected, String s) { + assertEquals(lb(expected), _CoreStringUtils.dedent(lb(s))); + } + + // ---- indent ---- + + @Test + public void testIndentSingleLine() { + assertIndent(" hello", "hello", " "); + } + + @Test + public void testIndentMultiLine() { + assertIndent(" line1\n line2\n line3", "line1\nline2\nline3", " "); + } + + @Test + public void testIndentNonWhitespacePrefix() { + assertIndent(" * line1\n * line2", "line1\nline2", " * "); + } + + @Test + public void testIndentEmptyString() { + assertIndent("", "", " "); + } + + @Test + public void testIndentWhitespacePrefixLeavesBlankLinesEmpty() { + // The prefix is added to the blank line too, but then trimmed away again. + assertIndent(" a\n\n b", "a\n\nb", " "); + } + + @Test + public void testIndentNonWhitespacePrefixOnBlankLine() { + // The blank line becomes "#" rather than "# ": the space in "# " is only meant to separate + // the prefix from the content of a line, and there's no content here. + assertIndent("# a\n#\n# b", "a\n\nb", "# "); + } + + @Test + public void testIndentWhitespaceOnlyLineTreatedAsBlank() { + // A line of accidental spaces gives the same result as a truly empty one. + assertIndent("# a\n#\n# b", "a\n \nb", "# "); + } + + @Test + public void testIndentRightTrimOff() { + assertIndent("# a\n# \n# b", "a\n\nb", "# ", false); + assertIndent("# \n# a", " \na", "# ", false); + } + + @Test + public void testIndentRemovesTrailingWhitespaceOfContentLines() { + assertIndent(" a\n b", "a \nb\t", " "); + } + + @Test + public void testIndentKeepsNonBreakingSpace() { + // U+00A0 isn't whitespace as far as trimming is concerned; that's the point of it. + assertIndent(" a\u00A0", "a\u00A0", " "); + } + + @Test + public void testIndentEmptyPrefixDoesNothing() { + // Nothing to add, so nothing happens; not even the trailing whitespace is trimmed. + assertIndent("a \n \nb", "a \n \nb", ""); + } + + // ---- dedent with an explicit prefix ---- + + @Test + public void testDedentBasic() { + assertDedent("int x;\nint y;", " int x;\n int y;", " "); + } + + @Test + public void testDedentPartialPrefixIsShortened() { + // " short" shares 2 characters with the 4 character long prefix, so it loses those 2. + // " full" starts with the whole prefix, so it loses all 4. + assertDedent("short\nfull", " short\n full", " "); + } + + @Test + public void testDedentPartialPrefixNonWhitespace() { + assertDedent("a\na\na\na\nx123a\n23a", "a\n1a\n12a\n123a\nx123a\n23a", "123"); + } + + @Test + public void testDedentUnrelatedPrefixLeftAlone() { + assertDedent("xa\nyb", "xa\nyb", "---"); + } + + @Test + public void testDedentWhitespaceOnlyLineIsTrimmed() { + // The 3rd line only shares 4 of its 5 spaces with the prefix; the leftover space is trimmed + // away, so the line ends up empty, as with indent. + assertDedent("a\n\nb\n", " a\n \n b\n ", " "); + } + + @Test + public void testDedentWhitespaceOnlyLineKeptWithTrimOff() { + assertDedent("a\n\nb\n ", " a\n \n b\n ", " ", false); + } + + @Test + public void testDedentRemovesTrailingWhitespaceOfContentLines() { + assertDedent("a\nb", " a \n b\t", " "); + assertDedent("a \nb\t", " a \n b\t", " ", false); + } + + @Test + public void testDedentMixed() { + assertDedent("a\n b\nc", " a\n b\n c", " "); + } + + @Test + public void testDedentEmptyString() { + assertDedent("", "", " "); + } + + @Test + public void testDedentEmptyPrefixDoesNothing() { + // Nothing to remove, so nothing happens; not even the trailing whitespace is trimmed. + assertDedent(" hello", " hello", ""); + assertDedent(" hello ", " hello ", ""); + } + + @Test + public void testIndentDedentRoundTrip() { + String prefix = " "; + // Note that the blank line must have no trailing whitespace for this to round-trip, which is + // what the trimming of both built-ins ensures. + String s = lb("line1\n line2\n\nline3"); + assertEquals(s, _CoreStringUtils.dedent(_CoreStringUtils.indent(s, prefix), prefix)); + } + + // ---- dedent with no prefix (Python textwrap.dedent-style) ---- + + @Test + public void testDedentNoArgsUniformIndent() { + assertDedent("a\nb\nc", " a\n b\n c"); + } + + @Test + public void testDedentNoArgsMixedIndent() { + // The longest common leading whitespace across non-empty lines is 2 spaces. + assertDedent("a\n b\n c", " a\n b\n c"); + } + + @Test + public void testDedentNoArgsRespectsEmptyLines() { + // Empty lines are ignored when computing the common prefix. + assertDedent("a\n\nb", " a\n\n b"); + } + + @Test + public void testDedentNoArgsNormalizesWhitespaceOnlyLines() { + // A whitespace-only line doesn't constrain the common prefix, and comes out empty rather than + // keeping whitespace that was accidental to begin with. Same as textwrap.dedent. + assertDedent("a\n\nb", " a\n \n b"); + // Including when it has more whitespace than the common prefix: + assertDedent("a\n\nb", " a\n \n b"); + } + + @Test + public void testDedentNoArgsNoCommonPrefix() { + assertDedent("a\n b", "a\n b"); + } + + @Test + public void testDedentNoArgsTabAndSpaceDistinct() { + // A leading tab and a leading space have no common prefix. (Same as textwrap.dedent.) + assertDedent("\ta\n b", "\ta\n b"); + } + + @Test + public void testDedentNoArgsTabsOnly() { + assertDedent("a\nb", "\t\ta\n\t\tb"); + } + + @Test + public void testDedentNoArgsEmptyString() { + assertDedent("", ""); + } + + @Test + public void testDedentNoArgsAlreadyDedented() { + assertDedent("a\nb\nc", "a\nb\nc"); + } + } + + /** + * Tests of {@code wrap}, which doesn't preserve the line structure of its input (it collapses all whitespace, + * including line-breaks), and so isn't affected by the line-break type of the input. + */ + public static class WrapTest { + + @Test + public void 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")); + } + } + } + +} diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index c4318bea8..eec1b2619 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 @@ -13530,6 +13538,10 @@ grant codeBase "file:/path/to/freemarker.jar" linkend="ref_builtin_word_list">word_list + + wrap + + xhtml @@ -14039,6 +14051,125 @@ 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 (usually indentation) from each line + of the string. The built-in has two forms: a no-argument form that + strips common leading whitespace automatically, and an + explicit-prefix form for exact control. This is the inverse of the + indent + built-in. Line-breaks can be LF (Linux), or + CRLF (DOS/Windows), even CR + (old Mac), and are kept as is. + + The no-argument form + (?dedent) finds the longest indentation (spaces + and tabs only) that is a common prefix and removes it. Lines that + are empty or only contain spaces and tabs are treated specially; + they don't influence the computation of the longest indentation, and + will be replaced with an empty (line-break only) line in the output. + (These semantics match Python's + textwrap.dedent.) + + For example: + + <#assign code = " if (x) {\n foo();\n }" /> +// <- touches left margin +${code?dedent} + + will output this (the common indentation prefix was 2 spaces, + which was now removed from each line): + + // <- touches left margin +if (x) { + foo(); +} + + A leading tab and a leading space are treated as distinct + characters (we don't resolve tabs to space or vice versa). + + The explicit-prefix form + (?dedent(prefix)) removes from each line the + longest leading section of the prefix parameter + that the line starts with. So for + ?dedent("1234"), the line 1234x is + reduced x, and so are 123x, + 12x, and 1x too, as they start the + same as 1234. But a line like x1234x + or 234x, would remain unchanged. Use this form when + you want to specify the amount of indentation to remove, rather than + letting it be detected. For example: + + <#assign code = " if (x) {\n foo();\n }" /> +// <- touches left margin +${code?dedent(" ")} + + will output this (removed just 1 space of indentation, so the + 1st line still has 1 space of indentation, and the 2nd has 3 spaces + of indentation): + + // <- touches left margin + if (x) { + foo(); + } + + With a 4-space prefix, lines that have less indentation than + that simply lose all the indentation they have: + + <#assign code = " if (x) {\n foo();\n }" /> +// <- touches left margin +${code?dedent(" ")} + + will output this (the 2nd line had 4 spaces, so all 4 were + removed; the other lines only had 2, so those 2 were + removed): + + // <- touches left margin +if (x) { +foo(); +} + + + Partial matches are shortened rather than ignored on + purpose. This is what's familiar from code text editors, when you + repeatedly dedent a block of text, and some lines already have too + low indentation. Also, an all-or-nothing dedent could leave a line + that was originally the least indented as the most indented one, + which is a more confusing result than partially flattening + indentation hierarchy. + + + The explicit-prefix form also removes the trailing whitespace + of each resulting line. This matters because removing a prefix can + leave whitespace behind that only looks like indentation: a line that + contains whitespace only loses just as much of it as the prefix is + long, so with a 4 character long prefix a line of 5 spaces would keep + 1 space. With the trimming, such a line becomes empty instead, like + it does with indent, which + also trims. Set the optional 2nd parameter to + false to switch the trimming off: + + ${code?dedent(" ", false)} + + See also the indent + built-in, which is its inverse (roughly). +
+
empty_to_null @@ -14334,6 +14465,92 @@ 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 1st parameter to the + beginning of each line, then removes the trailing whitespace of each + resulting line (unless that's switched off with the 2nd parameter). + The 1st parameter is most often some spaces or tabs used for + indentation, but can be any string. Line-breaks can be + LF (Linux), or CRLF + (DOS/Windows), even CR (old Mac), and are kept as + is. + + For example, this: + + <#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. + + The prefix is added to every line, + including lines that are empty or contain whitespace only. That + would leave a trailing prefix on such lines, which is why the + trailing whitespace of each line is removed afterwards. Consider + commenting out a block of text with a "# " + prefix: + + <#assign text = "First paragraph.\n\nSecond paragraph." /> +${text?indent("# ")} + + will output this, where the empty line became + "#" rather than "# ", as the + space in "# " was only meant to separate the + prefix from the content of a line: + + # First paragraph. +# +# Second paragraph. + + With a whitespace-only prefix this leaves empty lines empty, + so the trimming is only visible with prefixes like the above. It + also means that lines that are empty and lines that only contain + whitespace give the same result, and that accidental trailing + whitespace is removed from the other lines as well. + + Set the optional 2nd parameter to false to + switch the trimming off, and thus get the prefix on every line + verbatim: + + ${text?indent("# ", false)} + + + A non-breaking space (U+00A0) isn't + treated as whitespace when trimming, so it's kept; that's the + purpose of a non-breaking space. + + + See also the dedent + built-in, which is its inverse (roughly). +
+
index_of @@ -15956,6 +16173,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 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 +30902,20 @@ 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, 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;