You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2017/06/18 14:20:24 UTC

incubator-freemarker git commit: Started working in freemarker-converter, which converts FM2 templates to FM3 syntax (as far as it can be automated). It's not working yet, very far from complete. We will need this before we start to change the syntax, as

Repository: incubator-freemarker
Updated Branches:
  refs/heads/3 cb4a93dc2 -> c73dc5678


Started working in freemarker-converter, which converts FM2 templates to FM3 syntax (as far as it can be automated). It's not working yet, very far from complete. We will need this before we start to change the syntax, as we have a lot of test templates.


Project: http://git-wip-us.apache.org/repos/asf/incubator-freemarker/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-freemarker/commit/c73dc567
Tree: http://git-wip-us.apache.org/repos/asf/incubator-freemarker/tree/c73dc567
Diff: http://git-wip-us.apache.org/repos/asf/incubator-freemarker/diff/c73dc567

Branch: refs/heads/3
Commit: c73dc567834059e710bed252253d4a0b7b6568c0
Parents: cb4a93d
Author: ddekany <dd...@apache.org>
Authored: Sun Jun 18 16:20:12 2017 +0200
Committer: ddekany <dd...@apache.org>
Committed: Sun Jun 18 16:20:12 2017 +0200

----------------------------------------------------------------------
 freemarker-converter/build.gradle               |  72 +++
 .../core/FM2ASTToFM3SourceConverter.java        | 500 +++++++++++++++++++
 .../core/UnexpectedNodeContentException.java    |  41 ++
 .../apache/freemarker/converter/Converter.java  | 273 ++++++++++
 .../converter/ConverterException.java           |  32 ++
 .../freemarker/converter/ConverterUtils.java    |  55 ++
 .../freemarker/converter/FM2ToFM3Converter.java | 133 +++++
 .../MissingRequiredPropertyException.java       |  34 ++
 .../converter/PropertyValidationException.java  |  51 ++
 .../freemarker/converter/ConverterUtilTest.java |  42 ++
 .../converter/FM2ToFM3ConverterTest.java        | 173 +++++++
 .../converter/GenericConverterTest.java         | 180 +++++++
 .../converter/test/ConverterTest.java           |  46 ++
 settings.gradle                                 |   1 +
 14 files changed, 1633 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/build.gradle
----------------------------------------------------------------------
diff --git a/freemarker-converter/build.gradle b/freemarker-converter/build.gradle
new file mode 100644
index 0000000..04254f7
--- /dev/null
+++ b/freemarker-converter/build.gradle
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+
+title = "Apache FreeMarker Converter"
+description = """\
+FreeMarker template converter tool. Used to convert between template languages."""
+
+dependencies {
+    compile project(":freemarker-core")
+    compile "org.freemarker:freemarker:2.3.26-incubating"
+    compile libraries.commonsCli
+    compile libraries.commonsLang
+    compile libraries.commonsIo
+    compile libraries.guava
+}
+
+jar {
+    manifest {
+        instructionReplace 'Bundle-RequiredExecutionEnvironment', 'JavaSE-1.7'
+
+        attributes(
+                "Extension-name": "${project.group}:${project.name}",
+                "Specification-Title": project.title,
+                "Implementation-Title": project.title
+        )
+    }
+}
+
+javadoc {
+    title "${project.title} ${versionCanonical} API"
+}
+
+// The identical parts of Maven "deployer" and "installer" configurations:
+def mavenCommons = { callerDelegate ->
+    delegate = callerDelegate
+
+    pom.project {
+        description project.description
+    }
+}
+
+uploadArchives {
+    repositories {
+        mavenDeployer {
+            mavenCommons(delegate)
+        }
+    }
+}
+
+install {
+    repositories {
+        mavenInstaller {
+            mavenCommons(delegate)
+        }
+    }
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/main/java/freemarker/core/FM2ASTToFM3SourceConverter.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/main/java/freemarker/core/FM2ASTToFM3SourceConverter.java b/freemarker-converter/src/main/java/freemarker/core/FM2ASTToFM3SourceConverter.java
new file mode 100644
index 0000000..0c95e1d
--- /dev/null
+++ b/freemarker-converter/src/main/java/freemarker/core/FM2ASTToFM3SourceConverter.java
@@ -0,0 +1,500 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.freemarker.core.NamingConvention;
+import org.apache.freemarker.core.util._NullArgumentException;
+import org.apache.freemarker.converter.ConverterException;
+import org.apache.freemarker.converter.ConverterUtils;
+
+import freemarker.template.Configuration;
+import freemarker.template.Template;
+
+/**
+ * Takes a FreeMarker 2 AST, and converts it to an FreeMarker 3 source code.
+ * This has to be inside the "freemarker.core" package to access package visible members/classes.
+ * <p>
+ * Notes for implementators:
+ * <ul>
+ * <li>The tricky part is that the AST contains no nodes for insignificant white-space and comments inside expressions.
+ * Furthermore, directive calls are AST nodes, but the individual tags (the start- and end-tag) aren't, so restoring
+ * the insignificant white-space and comments inside the tags is even trickier.
+ * This information has to be restored from the source code string ({@link #src}), based on the positions (begin/end
+ * column/row number) of the AST nodes, and sometimes that has to be combined with some simple manual parsing.
+ * <li>Do not hard-code "<" and ">" into the code where you should use {@link #tagBeginChar} and {@link #tagEndChar}.
+ * <li>Stopping with error is always better than risking incorrect output. Use assertions. Don't be permissive with
+ * unexpected input.
+ * <li>Generally, try to use node parameters (via {@link #getOnlyParam(TemplateObject, ParameterRole, Class)},
+ * {@link #getParam(TemplateObject, int, ParameterRole, Class)}, etc.) instead of directly calling methods specific
+ * to the node subclass. Always process all parameters; where you don't use
+ * {@link #getOnlyParam(TemplateObject, ParameterRole, Class)}, use {@link #assertParamCount(TemplateObject, int)} to
+ * ensure that. If you know you don't need some of the paramters, still at least call
+ * {@link #assertParamRole(TemplateObject, int, ParameterRole)} for them. These ensure that if new parameters are
+ * added in FreeMarker 2.x, no information will be silently lost during conversion.
+ * </ul>
+ */
+@SuppressWarnings("deprecation")
+public class FM2ASTToFM3SourceConverter {
+
+    private final String src;
+    private final StringBuilder out;
+    private List<Integer> rowStartPositions;
+    private final char tagBeginChar;
+    private final char tagEndChar;
+    private final org.apache.freemarker.core.Configuration fm3Config = new org.apache.freemarker.core.Configuration
+            .Builder(org.apache.freemarker.core.Configuration.getVersion() /* highest possible by design */)
+            .namingConvention(NamingConvention.CAMEL_CASE)
+            .build();
+    private final Set<String> fm3BuiltInNames = fm3Config.getSupportedBuiltInNames();
+
+    /**
+     * @param template Must have been parsed with {@link Configuration#getWhitespaceStripping()} {@code false}.
+     */
+    public static String convert(Template template, String src) throws ConverterException {
+        FM2ASTToFM3SourceConverter instance = new FM2ASTToFM3SourceConverter(template, src);
+        instance.printNode(template.getRootTreeNode());
+        return instance.getOutput();
+    }
+
+    private FM2ASTToFM3SourceConverter(Template template, String src) {
+        _NullArgumentException.check("template", template);
+        if (template.getParserConfiguration().getWhitespaceStripping()) {
+            throw new IllegalArgumentException("The Template must have been parsed with whitespaceStripping false.");
+        }
+
+        _NullArgumentException.check("src", src);
+
+        this.src = src;
+        this.out = new StringBuilder();
+        if (template.getActualTagSyntax() == Configuration.SQUARE_BRACKET_TAG_SYNTAX) {
+            tagBeginChar = '[';
+            tagEndChar = ']';
+        } else {
+            tagBeginChar = '<';
+            tagEndChar = '>';
+        }
+    }
+
+    private String getOutput() throws ConverterException {
+        String s = out.toString();
+        try {
+            new org.apache.freemarker.core.Template(null, s, fm3Config);
+        } catch (Exception e) {
+            throw new ConverterException(
+                    "The result of the conversion wasn't valid FreeMarker 3 template; see cause exception", e);
+        }
+        return s;
+    }
+
+    private void printNode(TemplateObject node) throws ConverterException {
+        if (node instanceof TemplateElement) {
+            printTemplateElement((TemplateElement) node);
+        } else {
+            printExpressionNode(node);
+        }
+    }
+
+    private void printTemplateElement(TemplateElement node) throws ConverterException {
+        if (node instanceof MixedContent) {
+            printChildrenElements(node);
+        } else if (node instanceof TextBlock) {
+            print(getOnlyParam(node, ParameterRole.CONTENT, String.class));
+        } else if (node instanceof DollarVariable) {
+            printWithParamsLeadingSkippedTokens("${", node);
+            printNode(getOnlyParam(node, ParameterRole.CONTENT, TemplateObject.class));
+            printWithParamsTrailingSkippedTokens("}", node, 0);
+        } else if (node instanceof IfBlock) {
+            printChildrenElements(node);
+            print(tagBeginChar);
+            print("/#if");
+            printEndTagSkippedTokens(node);
+            print(tagEndChar);
+        } else if (node instanceof ConditionalBlock) {
+            assertParamCount(node,2);
+            TemplateObject conditionExp = getParam(node, 0, ParameterRole.CONDITION, TemplateObject.class);
+            int nodeSubtype = getParam(node, 1, ParameterRole.AST_NODE_SUBTYPE, Integer.class);
+
+            print(tagBeginChar);
+            String tagStart;
+            if (nodeSubtype == ConditionalBlock.TYPE_IF) {
+                tagStart = "#if";
+            } else if (nodeSubtype == ConditionalBlock.TYPE_ELSE) {
+                tagStart = "#else";
+            } else if (nodeSubtype == ConditionalBlock.TYPE_ELSE_IF) {
+                tagStart = "#elseIf";
+            } else {
+                throw new UnexpectedNodeContentException(node, "Unhandled subtype, {}.", nodeSubtype);
+            }
+            print(tagStart);
+            if (conditionExp != null) {
+                printWithParamsLeadingSkippedTokens(tagStart.length() + 1, node);
+                printNode(conditionExp);
+            }
+            printStartTagSkippedTokens(node, conditionExp, true);
+            print(tagEndChar);
+
+            printChildrenElements(node);
+
+            if (!(node.getParentElement() instanceof IfBlock)) {
+                print(tagBeginChar);
+                print("/#if");
+                printEndTagSkippedTokens(node);
+                print(tagEndChar);
+            }
+        } else if (node instanceof Comment) {
+            print(tagBeginChar);
+            print("#--");
+            print(getOnlyParam(node, ParameterRole.CONTENT, String.class));
+            print("--");
+            print(tagEndChar);
+        } else {
+            throw new ConverterException("Unhandled AST TemplateElement class: " + node.getClass().getName());
+        }
+    }
+
+    private void printExpressionNode(TemplateObject node) throws ConverterException {
+        if (node instanceof Identifier || node instanceof NumberLiteral || node instanceof BooleanLiteral) {
+            print(node.getCanonicalForm());
+        } else if (node instanceof AddConcatExpression) {
+            assertParamCount(node, 2);
+            TemplateObject lho = getParam(node, 0, ParameterRole.LEFT_HAND_OPERAND, TemplateObject.class);
+            TemplateObject rho = getParam(node, 1, ParameterRole.RIGHT_HAND_OPERAND, TemplateObject.class);
+            printNode(lho);
+            printParameterSeparatorSource(lho, rho);
+            printNode(rho);
+        } else if (node instanceof ArithmeticExpression) {
+            assertParamCount(node, 3);
+            assertParamRole(node, 2, ParameterRole.AST_NODE_SUBTYPE);
+            TemplateObject lho = getParam(node, 0, ParameterRole.LEFT_HAND_OPERAND, TemplateObject.class);
+            TemplateObject rho = getParam(node, 1, ParameterRole.RIGHT_HAND_OPERAND, TemplateObject.class);
+            printNode(lho);
+            printParameterSeparatorSource(lho, rho);
+            printNode(rho);
+        } else if (node instanceof UnaryPlusMinusExpression) {
+            assertParamCount(node, 2);
+            assertParamRole(node, 1, ParameterRole.AST_NODE_SUBTYPE);
+            printWithParamsLeadingSkippedTokens(node.getNodeTypeSymbol().substring(0, 1), node);
+            printNode(getParam(node, 0, ParameterRole.RIGHT_HAND_OPERAND, TemplateObject.class));
+        } else if (node instanceof ParentheticalExpression) {
+            printWithParamsLeadingSkippedTokens("(", node);
+            printNode(getOnlyParam(node, ParameterRole.ENCLOSED_OPERAND, TemplateObject.class));
+            printWithParamsTrailingSkippedTokens(")", node, 0);
+        } else if (node instanceof MethodCall) {
+            TemplateObject callee = getParam(node, 0, ParameterRole.CALLEE, TemplateObject.class);
+            printExpressionNode(callee);
+
+            TemplateObject prevParam = callee;
+            int argCnt = node.getParameterCount() - 1;
+            for (int argIdx = 0; argIdx < argCnt; argIdx++) {
+                TemplateObject argExp = getParam(node, argIdx + 1, ParameterRole.ARGUMENT_VALUE, TemplateObject.class);
+                printParameterSeparatorSource(prevParam, argExp);
+                printExpressionNode(argExp);
+                prevParam = argExp;
+            }
+            printWithParamsTrailingSkippedTokens(")", node, argCnt);
+        } else if (node instanceof BuiltIn) {
+            assertParamCount(node, 2);
+            TemplateObject lho = getParam(node, 0, ParameterRole.LEFT_HAND_OPERAND, TemplateObject.class);
+            String rho = getParam(node, 1, ParameterRole.RIGHT_HAND_OPERAND, String.class);
+
+            printExpressionNode(lho); // [lho]?biName
+
+            int postLHOPos = getPosition(lho.getEndColumn(), lho.getEndLine()) + 1;
+            int endPos = getPosition(node.getEndColumn(), node.getEndLine());
+            boolean foundQuestionMark = false;
+            int pos = postLHOPos;
+            scanForRHO: while (pos < endPos) {
+                char c = src.charAt(pos);
+                if (c == '?') {
+                    foundQuestionMark = true;
+                    pos++;
+                } else if (Character.isWhitespace(c)) {
+                    pos++;
+                } else if (isCoreNameChar(c)) {
+                    break scanForRHO;
+                } else {
+                    throw new UnexpectedNodeContentException(node,
+                            "Unexpected character when scanning for for built-in key: '{}'", c);
+                }
+            }
+            if (pos == endPos || !foundQuestionMark) {
+                throw new UnexpectedNodeContentException(node, "Couldn't find built-in key in source", null);
+            }
+            print(src.substring(postLHOPos, pos)); // lho[?]biName
+
+            print(convertBuiltInName(rho));
+        } else {
+            throw new ConverterException("Unhandled AST node class: " + node.getClass().getName());
+        }
+    }
+
+    private String convertBuiltInName(String name) throws ConverterException {
+        String converted = name.indexOf('_') == -1 ? name : ConverterUtils.snakeCaseToCamelCase(name);
+
+        if (converted.equals("webSafe")) {
+            return "html";
+        }
+
+        if (!fm3BuiltInNames.contains(converted)) {
+            throw new ConverterException("Couldn't map \"" + name + "\" to a valid FreeMarker 3 built-in name "
+                    + "(tried: " + converted + ")");
+        }
+        return converted;
+    }
+
+    private void printParameterSeparatorSource(TemplateObject lho, TemplateObject rho) {
+        print(getSrcSectionExclEnd(
+                lho.getEndColumn() + 1, lho.getEndLine(),
+                rho.getBeginColumn(), rho.getBeginLine()));
+    }
+
+    private void printChildrenElements(TemplateElement node) throws ConverterException {
+        int ln = node.getChildCount();
+        for (int i = 0; i < ln; i++) {
+            printNode(node.getChild(i));
+        }
+    }
+
+    private void printWithParamsLeadingSkippedTokens(String beforeParams, TemplateObject node)
+            throws ConverterException {
+        print(beforeParams);
+        printWithParamsLeadingSkippedTokens(beforeParams.length(), node);
+    }
+
+    private void printWithParamsLeadingSkippedTokens(int beforeParamsLength, TemplateObject node)
+            throws UnexpectedNodeContentException {
+        if (node.getParameterCount() == 0) {
+            return;
+        }
+        TemplateObject param = getParam(node, 0, null, TemplateObject.class);
+        print(getSrcSectionExclEnd(
+                node.getBeginColumn() + beforeParamsLength, node.getBeginLine(),
+                param.getBeginColumn(), param.getBeginLine()));
+    }
+
+    private void printWithEnclosedSkippedTokens(
+            String beforeSkippedTokens, String afterSkippedTokens, TemplateObject node)
+            throws ConverterException {
+        print(beforeSkippedTokens);
+        String skippedTokens = getSrcSectionExclEnd(
+                node.getBeginColumn() + beforeSkippedTokens.length(), node.getBeginLine(),
+                node.getEndColumn() - afterSkippedTokens.length() + 1, node.getEndLine());
+        print(skippedTokens);
+        print(afterSkippedTokens);
+    }
+
+    private void printWithParamsTrailingSkippedTokens(
+            String afterParams, TemplateObject node, int lastVisualParamIdx) throws
+            ConverterException {
+        int parameterCount = node.getParameterCount();
+        assertNodeContent(lastVisualParamIdx < parameterCount, node,
+                "Parameter count too low: {}", parameterCount);
+        TemplateObject param = getParam(node, lastVisualParamIdx, null, TemplateObject.class);
+        String skippedTokens = getSrcSectionExclEnd(
+                param.getEndColumn() + 1, param.getEndLine(),
+                node.getEndColumn() - afterParams.length() + 1, node.getEndLine());
+        print(skippedTokens);
+        print(afterParams);
+    }
+
+    /**
+     * Prints the part between the last parameter (or the directive name if there are no parameters) and the tag closer
+     * character, for a start tag. That part may contains whitespace or comments, which aren't visible in the AST.
+     */
+    private void printStartTagSkippedTokens(TemplateElement node, TemplateObject lastParam, boolean trimSlash)
+            throws UnexpectedNodeContentException {
+        int pos;
+        if (lastParam == null) {
+            // No parameter; must skip the tag name
+            pos = getPosition(node.getBeginColumn(), node.getBeginLine());
+            {
+                char c = src.charAt(pos++);
+                assertNodeContent(c == tagBeginChar, node,
+                        "tagBeginChar expected, found '{}'", c);
+            }
+            {
+                char c = src.charAt(pos++);
+                assertNodeContent(c == '#', node,
+                        "'#' expected, found '{}'", c);
+            }
+            findNameEnd: while (pos < src.length()) {
+                char c = src.charAt(pos);
+                if (!isCoreNameChar(c)) {
+                    break findNameEnd;
+                }
+                pos++;
+            }
+        } else {
+            pos = getPosition(lastParam.getEndColumn() + 1, lastParam.getEndLine());
+        }
+        final int startPos = pos;
+
+        while (pos < src.length()) {
+            char c = src.charAt(pos);
+            if ((c == '<' || c == '[')
+                    && (src.startsWith("!--", pos + 1) || src.startsWith("#--", pos + 1))) {
+                pos += 4;
+                scanForCommentEnd: while (pos < src.length()) {
+                    if (src.startsWith("-->", pos) || src.startsWith("--]", pos)) {
+                        pos += 3;
+                        break scanForCommentEnd;
+                    }
+                    pos++;
+                }
+                if (pos == src.length()) {
+                    throw new UnexpectedNodeContentException(node, "Can't find comment end in the start tag", null);
+                }
+            } else if (c == '/' && pos + 1 < src.length() && src.charAt(pos + 1) == tagEndChar) {
+                print(src.substring(startPos, trimSlash ? pos : pos + 1));
+                return;
+            } else if (c == tagEndChar) {
+                print(src.substring(startPos, pos));
+                return;
+            } else if (Character.isWhitespace(c)) {
+                pos++;
+            } else {
+                throw new UnexpectedNodeContentException(node,
+                        "Unexpected character when scanning for tag end: '{}'", c);
+            }
+        }
+        throw new UnexpectedNodeContentException(node, "Can't find start tag end", null);
+    }
+
+    private void printEndTagSkippedTokens(TemplateElement node) throws UnexpectedNodeContentException {
+        int tagEndPos = getPosition(node.getEndColumn(), node.getEndLine());
+        {
+            char c = src.charAt(tagEndPos);
+            assertNodeContent(c == tagEndChar, node,
+                    "tagEndChar expected, found '{}'", c);
+        }
+
+        int pos = tagEndPos - 1;
+        while (pos > 0 && Character.isWhitespace(src.charAt(pos))) {
+            pos--;
+        }
+
+        assertNodeContent(pos > 0 && isCoreNameChar(src.charAt(pos)), node,
+                "Can't find end tag name", null);
+
+        print(src.substring(pos + 1, tagEndPos));
+    }
+
+    private void print(String s) {
+        _NullArgumentException.check("s", s);
+        out.append(s);
+    }
+
+    private void print(char c) {
+        out.append(c);
+    }
+
+    @SuppressWarnings("unchecked")
+    private <T> T getOnlyParam(TemplateObject node, ParameterRole role, Class<T> valueClass)
+            throws UnexpectedNodeContentException {
+        int parameterCount = node.getParameterCount();
+        assertNodeContent(parameterCount == 1, node,
+                "Node expected to have exactly 1 parameter, but had {}.", parameterCount);
+        return (T) getParam(node, 0, role, valueClass);
+    }
+
+    @SuppressWarnings("unchecked")
+    private <T> T getParam(TemplateObject node, int index, ParameterRole expectedParamRole, Class<T>
+            valueClass) throws UnexpectedNodeContentException {
+        int paramCount = node.getParameterCount();
+        assertNodeContent(paramCount > index, node,
+                "Node only have {} parameters.", paramCount);
+
+        if (expectedParamRole != null) {
+            assertParamRole(node, index, expectedParamRole);
+        }
+
+        Object paramValue = node.getParameterValue(index);
+        if (paramValue != null) {
+            assertNodeContent(valueClass.isInstance(paramValue), node,
+                    "Unexpected node parameter value class: {}",
+                    paramValue == null ? "null" : paramValue.getClass());
+        }
+        return (T) paramValue;
+    }
+
+    private TemplateElement getOnlyChild(TemplateElement node) throws ConverterException {
+        int childCount = node.getChildCount();
+        assertNodeContent(childCount == 1, node,
+                "Node should have exactly 1 child, but had {}.", childCount);
+        return node.getChild(0);
+    }
+
+    private void assertParamCount(TemplateObject node, int expectedParamCount)
+            throws UnexpectedNodeContentException {
+        int paramCount = node.getParameterCount();
+        assertNodeContent(paramCount == expectedParamCount, node,
+                "Unexpected parameter count, {}.", paramCount);
+    }
+
+    private void assertParamRole(TemplateObject node, int index, ParameterRole expectedParamRole)
+            throws UnexpectedNodeContentException {
+        ParameterRole paramRole = node.getParameterRole(index);
+        assertNodeContent(paramRole == expectedParamRole, node,
+                "Unexpected node parameter role \"{}\".", paramRole);
+    }
+
+    private void assertNodeContent(boolean good, TemplateObject node, String
+            errorMessage, Object msgParam) throws UnexpectedNodeContentException {
+        if (!good) {
+            throw new UnexpectedNodeContentException(node, errorMessage, msgParam);
+        }
+    }
+
+    /**
+     * Returns the position of a character in the {@link #src} string.
+     *
+     * @param column 1-based column
+     * @param row 1-based row
+     */
+    private int getPosition(int column, int row) {
+        if (rowStartPositions == null) {
+            rowStartPositions = new ArrayList<>();
+            rowStartPositions.add(0);
+            for (int i = 0; i < src.length(); i++) {
+                char c = src.charAt(i);
+                if (c == '\n' || c == '\r') {
+                    if (c == '\r' && i + 1 < src.length() && src.charAt(i + 1) == '\n') {
+                        i++; // Skip +1 character as this is a CRLF
+                    }
+                    rowStartPositions.add(i + 1);
+                }
+            }
+        }
+        return rowStartPositions.get(row - 1) + column - 1;
+    }
+
+    private String getSrcSectionExclEnd(int startColumn, int startRow, int exclEndColumn, int endRow) {
+        return src.substring(getPosition(startColumn, startRow), getPosition(exclEndColumn, endRow));
+    }
+
+    private boolean isCoreNameChar(char c) {
+        return Character.isLetterOrDigit(c) || c == '_';
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/main/java/freemarker/core/UnexpectedNodeContentException.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/main/java/freemarker/core/UnexpectedNodeContentException.java b/freemarker-converter/src/main/java/freemarker/core/UnexpectedNodeContentException.java
new file mode 100644
index 0000000..81d9761
--- /dev/null
+++ b/freemarker-converter/src/main/java/freemarker/core/UnexpectedNodeContentException.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import java.util.Objects;
+
+import org.apache.freemarker.core.util._StringUtil;
+import org.apache.freemarker.converter.ConverterException;
+
+public class UnexpectedNodeContentException extends ConverterException {
+    public UnexpectedNodeContentException(TemplateObject node, String errorMessage, Object msgParam) {
+        super("Unexpected AST content for " + _StringUtil.jQuote(node.getNodeTypeSymbol()) + " node (class: "
+                + node.getClass().getName() + ") at " + node.getStartLocation() + ":\n"
+                + renderMessage(errorMessage, msgParam));
+    }
+
+    private static String renderMessage(String errorMessage, Object msgParam) {
+        int substIdx = errorMessage.indexOf("{}");
+        if (substIdx == -1) {
+            return errorMessage;
+        }
+        return errorMessage.substring(0, substIdx) + Objects.toString(msgParam) + errorMessage.substring(substIdx + 2);
+    }
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/main/java/org/apache/freemarker/converter/Converter.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/main/java/org/apache/freemarker/converter/Converter.java b/freemarker-converter/src/main/java/org/apache/freemarker/converter/Converter.java
new file mode 100644
index 0000000..0d5ff0b
--- /dev/null
+++ b/freemarker-converter/src/main/java/org/apache/freemarker/converter/Converter.java
@@ -0,0 +1,273 @@
+/*
+ * 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 org.apache.freemarker.converter;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.freemarker.core.util._NullArgumentException;
+import org.apache.freemarker.core.util._StringUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public abstract class Converter {
+
+    public static final String PROPERTY_NAME_SOURCE = "source";
+    public static final String PROPERTY_NAME_DESTINATION_DIRECTORY = "destinationDirectory";
+
+    private static final Logger LOG = LoggerFactory.getLogger(Converter.class);
+
+    private File source;
+    private File destinationDirectory;
+    private boolean createDestinationDirectory;
+    private boolean executed;
+    private Set<File> directoriesKnownToExist = new HashSet<>();
+
+    public File getSource() {
+        return source;
+    }
+
+    public void setSource(File source) {
+        this.source = source != null ? source.getAbsoluteFile() : null;
+    }
+
+    public File getDestinationDirectory() {
+        return destinationDirectory;
+    }
+
+    public void setDestinationDirectory(File destinationDirectory) {
+        this.destinationDirectory = destinationDirectory != null ? destinationDirectory.getAbsoluteFile() : null;
+    }
+
+    public boolean isCreateDestinationDirectory() {
+        return createDestinationDirectory;
+    }
+
+    public void setCreateDestinationDirectory(boolean createDestinationDirectory) {
+        this.createDestinationDirectory = createDestinationDirectory;
+    }
+
+    public final void execute()  throws ConverterException {
+        if (executed) {
+            throw new IllegalStateException("This converted was already invoked once.");
+        }
+        executed = true;
+
+        prepare();
+        LOG.debug("Source: {}", source);
+        LOG.debug("Destination directory: {}", destinationDirectory);
+
+        convertFiles(source, destinationDirectory, true);
+    }
+
+    /**
+     * Validate properties and prepare data structures and resources that are shared among individual
+     * {@link #convertFile(FileConversionContext)} calls.
+     */
+    protected void prepare() throws ConverterException {
+        MissingRequiredPropertyException.check(PROPERTY_NAME_SOURCE, source);
+        MissingRequiredPropertyException.check(PROPERTY_NAME_DESTINATION_DIRECTORY, destinationDirectory);
+
+        if (!source.exists()) {
+            throw new PropertyValidationException(PROPERTY_NAME_SOURCE, "File or directory doesn't exist: "
+                    + source);
+        }
+
+        if (destinationDirectory.isFile()) {
+            throw new PropertyValidationException(PROPERTY_NAME_DESTINATION_DIRECTORY,
+                    "Destination must a be directory, not a file: " + destinationDirectory);
+        }
+        if (!createDestinationDirectory && !fastIsDirectory(destinationDirectory)) {
+            throw new PropertyValidationException(PROPERTY_NAME_DESTINATION_DIRECTORY,
+                    "Directory doesn't exist: " + destinationDirectory);
+        }
+    }
+
+    private void convertFiles(File src, File dstDir, boolean processSrcDirContentOnly) throws ConverterException {
+        if (src.isFile()) {
+            convertFile(src, dstDir);
+        } else if (fastIsDirectory(src)) {
+            for (File sourceListItem : src.listFiles()) {
+                convertFiles(
+                        sourceListItem,
+                        processSrcDirContentOnly ? dstDir : new File(dstDir, src.getName()),
+                        false);
+            }
+        } else {
+            throw new ConverterException("Source item doesn't exist (not a file, nor a directory): " + src);
+        }
+    }
+
+    private void convertFile(File src, File dstDir) throws ConverterException {
+        InputStream srcStream;
+        try {
+            srcStream = new FileInputStream(src);
+        } catch (IOException e) {
+            throw new ConverterException("Failed to open file for reading: " + src, e);
+        }
+        try {
+            LOG.debug("Converting file: {}", src);
+            FileConversionContext fileTransCtx = null;
+            try {
+                fileTransCtx = new FileConversionContext(srcStream, src, dstDir);
+                convertFile(fileTransCtx);
+            } catch (IOException e) {
+                throw new ConverterException("I/O exception while converting " + _StringUtil.jQuote(src) + ".", e);
+            } finally {
+                try {
+                    if (fileTransCtx != null && fileTransCtx.outputStream != null) {
+                        fileTransCtx.outputStream.close();
+                    }
+                } catch (IOException e) {
+                    throw new ConverterException("Failed to close destination file", e);
+                }
+            }
+        } finally {
+            try {
+                srcStream.close();
+            } catch (IOException e) {
+                throw new ConverterException("Failed to close file: " + src, e);
+            }
+        }
+    }
+
+    private void ensureDirectoryExists(File dir) throws ConverterException {
+        if (dir == null || fastIsDirectory(dir)) {
+            return;
+        }
+        ensureDirectoryExists(dir.getParentFile());
+        if (!dir.mkdir()) {
+            throw new ConverterException("Failed to create directory: " + dir);
+        } else {
+            LOG.debug("Directory created: {}", dir);
+            directoriesKnownToExist.add(dir);
+        }
+    }
+
+    /**
+     * Only works correctly for directories that won't be deleted during the life of this {@link Converter} object.
+     */
+    private boolean fastIsDirectory(File dir) {
+        if (directoriesKnownToExist.contains(dir)) {
+            return true;
+        }
+
+        LOG.trace("Checking if is directory: {}", dir);
+        boolean exists = dir.isDirectory();
+
+        if (exists) {
+            directoriesKnownToExist.add(dir);
+        }
+
+        return exists;
+    }
+
+    /**
+     * Converts a single file. To content of file to convert should be accessed with
+     * {@link FileConversionContext#getSourceStream()}. To write the converted file, first you must call
+     * {@link FileConversionContext#setDestinationFileName(String)},
+     * then {@link FileConversionContext#getDestinationStream()} to start writing the converted file.
+     */
+    protected abstract void convertFile(FileConversionContext fileTransCtx) throws ConverterException, IOException;
+
+    public class FileConversionContext {
+
+        private final InputStream sourceStream;
+        private final File sourceFile;
+        private final File dstDir;
+        private String destinationFileName;
+        private OutputStream outputStream;
+
+        public FileConversionContext(
+                InputStream sourceStream, File sourceFile, File dstDir) {
+            this.sourceStream = sourceStream;
+            this.sourceFile = sourceFile;
+            this.dstDir = dstDir;
+        }
+
+        /**
+         * The source file; usually not used; to read the file use {@link #getSourceStream()}.
+         */
+        public File getSourceFile() {
+            return sourceFile;
+        }
+
+        public String getSourceFileName() {
+            return sourceFile.getName();
+        }
+
+        /**
+         * Read the content of the source file with this. You need not close this stream in
+         * {@link Converter#convertFile(FileConversionContext)}; the {@link Converter} will do that.
+         */
+        public InputStream getSourceStream() {
+            return sourceStream;
+        }
+
+        /**
+         * Write the content of the destination file with this.  You need not close this stream in
+s         * {@link Converter#convertFile(FileConversionContext)}; the {@link Converter} will do that.
+         */
+        public OutputStream getDestinationStream() throws ConverterException {
+            if (outputStream == null) {
+                if (destinationFileName == null) {
+                    throw new IllegalStateException("You must set FileConversionContext.destinationFileName before "
+                            + "starting to write the destination file.");
+                }
+
+                ensureDirectoryExists(dstDir);
+                File dstFile = new File(dstDir, destinationFileName);
+                try {
+                    outputStream = new FileOutputStream(dstFile);
+                } catch (IOException e) {
+                    throw new ConverterException("Failed to open file for writing: " + dstFile, e);
+                }
+            }
+            return outputStream;
+        }
+
+        public String getDestinationFileName() {
+            return destinationFileName;
+        }
+
+        /**
+         * Sets the name of the file where the output will be written.
+         * @param destinationFileName Can't contain directory name, only the file name.
+         */
+        public void setDestinationFileName(String destinationFileName) {
+            if (outputStream != null) {
+                throw new IllegalStateException("The destination file is already opened for writing");
+            }
+            _NullArgumentException.check("destinationFileName", destinationFileName);
+            if (destinationFileName.contains("/") || destinationFileName.contains(File.separator)) {
+                throw new IllegalArgumentException(
+                        "The destination file name can't contain directory name: " + destinationFileName);
+            }
+            this.destinationFileName = destinationFileName;
+        }
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/main/java/org/apache/freemarker/converter/ConverterException.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/main/java/org/apache/freemarker/converter/ConverterException.java b/freemarker-converter/src/main/java/org/apache/freemarker/converter/ConverterException.java
new file mode 100644
index 0000000..4a6533b
--- /dev/null
+++ b/freemarker-converter/src/main/java/org/apache/freemarker/converter/ConverterException.java
@@ -0,0 +1,32 @@
+/*
+ * 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 org.apache.freemarker.converter;
+
+public class ConverterException extends Exception {
+
+    public ConverterException(String message) {
+        this(message, null);
+    }
+
+    public ConverterException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/main/java/org/apache/freemarker/converter/ConverterUtils.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/main/java/org/apache/freemarker/converter/ConverterUtils.java b/freemarker-converter/src/main/java/org/apache/freemarker/converter/ConverterUtils.java
new file mode 100644
index 0000000..d4a64aa
--- /dev/null
+++ b/freemarker-converter/src/main/java/org/apache/freemarker/converter/ConverterUtils.java
@@ -0,0 +1,55 @@
+/*
+ * 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 org.apache.freemarker.converter;
+
+public final class ConverterUtils {
+
+    private ConverterUtils() {
+        //
+    }
+
+    public static String snakeCaseToCamelCase(String s) {
+        if (s == null) {
+            return null;
+        }
+
+        int wordEndIdx = s.indexOf('_');
+        if (wordEndIdx == -1) {
+            return s.toLowerCase();
+        }
+
+        StringBuilder sb = new StringBuilder(s.length());
+        int wordStartIdx = 0;
+        do {
+            if (wordStartIdx < wordEndIdx) {
+                char wordStartC = s.charAt(wordStartIdx);
+                sb.append(sb.length() != 0 ? Character.toUpperCase(wordStartC) : Character.toLowerCase(wordStartC));
+                sb.append(s.substring(wordStartIdx + 1, wordEndIdx).toLowerCase());
+            }
+
+            wordStartIdx = wordEndIdx + 1;
+            wordEndIdx = s.indexOf('_', wordStartIdx);
+            if (wordEndIdx == -1) {
+                wordEndIdx = s.length();
+            }
+        } while (wordStartIdx < s.length());
+        return sb.toString();
+    }
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/main/java/org/apache/freemarker/converter/FM2ToFM3Converter.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/main/java/org/apache/freemarker/converter/FM2ToFM3Converter.java b/freemarker-converter/src/main/java/org/apache/freemarker/converter/FM2ToFM3Converter.java
new file mode 100644
index 0000000..4668440
--- /dev/null
+++ b/freemarker-converter/src/main/java/org/apache/freemarker/converter/FM2ToFM3Converter.java
@@ -0,0 +1,133 @@
+/*
+ * 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 org.apache.freemarker.converter;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.freemarker.core.util._NullArgumentException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import freemarker.core.FM2ASTToFM3SourceConverter;
+import freemarker.template.Configuration;
+import freemarker.template.Template;
+
+public class FM2ToFM3Converter extends Converter {
+
+    public static final Logger LOG = LoggerFactory.getLogger(Converter.class);
+
+    private static final Map<String, String> DEFAULT_REPLACED_FILE_EXTENSIONS;
+    static {
+        DEFAULT_REPLACED_FILE_EXTENSIONS = new HashMap<>();
+        DEFAULT_REPLACED_FILE_EXTENSIONS.put("ftl", "fm3");
+        DEFAULT_REPLACED_FILE_EXTENSIONS.put("fm", "fm3");
+        DEFAULT_REPLACED_FILE_EXTENSIONS.put("ftlh", "fm3h");
+        DEFAULT_REPLACED_FILE_EXTENSIONS.put("fmh", "fm3h");
+        DEFAULT_REPLACED_FILE_EXTENSIONS.put("ftlx", "fm3x");
+        DEFAULT_REPLACED_FILE_EXTENSIONS.put("fmx", "fm3x");
+    }
+
+    private Map<String, String> outputFileExtensions = DEFAULT_REPLACED_FILE_EXTENSIONS;
+    private Properties freeMarker2Settings;
+    private Configuration fm2Cfg;
+
+    @Override
+    protected void prepare() throws ConverterException {
+        super.prepare();
+        fm2Cfg = new Configuration(Configuration.VERSION_2_3_19 /* To fix ignored initial unknown tags */);
+        fm2Cfg.setWhitespaceStripping(false);
+        fm2Cfg.setTabSize(1);
+        try {
+            fm2Cfg.setSettings(freeMarker2Settings);
+        } catch (Exception e) {
+            throw new ConverterException("Error while configuring FreeMarker 2", e);
+        }
+    }
+
+    private String getDestinationFileName(Template template) throws ConverterException {
+        String srcFileName = template.getName();
+        int lastDotIdx = srcFileName.lastIndexOf('.');
+        if (lastDotIdx == -1) {
+            return srcFileName;
+        }
+
+        String ext = srcFileName.substring(lastDotIdx + 1);
+
+        String replacementExt = getOutputFileExtensions().get(ext);
+        if (replacementExt == null) {
+            replacementExt = getOutputFileExtensions().get(ext.toLowerCase());
+        }
+        if (replacementExt == null) {
+            return srcFileName;
+        }
+
+        if (template.getActualTagSyntax() == Configuration.SQUARE_BRACKET_TAG_SYNTAX) {
+            replacementExt = replacementExt.replace("3", "3s");
+        }
+
+        return srcFileName.substring(0, lastDotIdx + 1) + replacementExt;
+    }
+    
+    @Override
+    protected void convertFile(FileConversionContext fileTransCtx) throws ConverterException, IOException {
+        String src = IOUtils.toString(fileTransCtx.getSourceStream(), StandardCharsets.UTF_8);
+        Template template;
+        try {
+            template = new Template(fileTransCtx.getSourceFile().getName(), src, fm2Cfg);
+        } catch (Exception e) {
+            throw new ConverterException("Failed to load FreeMarker 2.3.x template", e);
+        }
+
+        fileTransCtx.setDestinationFileName(getDestinationFileName(template));
+        fileTransCtx.getDestinationStream().write(
+                FM2ASTToFM3SourceConverter.convert(template, src).getBytes(getTemplateEncoding(template)));
+
+
+    }
+
+    private String getTemplateEncoding(Template template) {
+        String encoding = template.getEncoding();
+        return encoding != null ? encoding : fm2Cfg.getEncoding(template.getLocale());
+    }
+
+    public Map<String, String> getOutputFileExtensions() {
+        return outputFileExtensions;
+    }
+
+    public void setOutputFileExtensions(Map<String, String> outputFileExtensions) {
+        _NullArgumentException.check("outputFileExtensions", outputFileExtensions);
+        this.outputFileExtensions = outputFileExtensions;
+    }
+
+    public Properties getFreeMarker2Settings() {
+        return freeMarker2Settings;
+    }
+
+    public void setFreeMarker2Settings(Properties freeMarker2Settings) {
+        _NullArgumentException.check("freeMarker2Settings", freeMarker2Settings);
+        this.freeMarker2Settings = freeMarker2Settings;
+    }
+
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/main/java/org/apache/freemarker/converter/MissingRequiredPropertyException.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/main/java/org/apache/freemarker/converter/MissingRequiredPropertyException.java b/freemarker-converter/src/main/java/org/apache/freemarker/converter/MissingRequiredPropertyException.java
new file mode 100644
index 0000000..925338f
--- /dev/null
+++ b/freemarker-converter/src/main/java/org/apache/freemarker/converter/MissingRequiredPropertyException.java
@@ -0,0 +1,34 @@
+/*
+ * 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 org.apache.freemarker.converter;
+
+public class MissingRequiredPropertyException extends PropertyValidationException {
+
+    public MissingRequiredPropertyException(String propertyName) {
+        super(propertyName, "Required property wasn't set");
+    }
+
+    public static void check(String propertyName, Object value) throws MissingRequiredPropertyException {
+        if (value == null) {
+            throw new MissingRequiredPropertyException(propertyName);
+        }
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/main/java/org/apache/freemarker/converter/PropertyValidationException.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/main/java/org/apache/freemarker/converter/PropertyValidationException.java b/freemarker-converter/src/main/java/org/apache/freemarker/converter/PropertyValidationException.java
new file mode 100644
index 0000000..c215239
--- /dev/null
+++ b/freemarker-converter/src/main/java/org/apache/freemarker/converter/PropertyValidationException.java
@@ -0,0 +1,51 @@
+/*
+ * 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 org.apache.freemarker.converter;
+
+public class PropertyValidationException extends ConverterException {
+
+    private final String propertyName;
+    private final String reason;
+
+    public PropertyValidationException(String propertyName, String reason, Throwable cause) {
+        super("Bad value for property \"" + propertyName + "\""
+                + (reason != null ? ": " + reason : ""),
+                cause);
+        this.propertyName = propertyName;
+        this.reason = reason;
+    }
+
+    public PropertyValidationException(String propertyName, String reason) {
+        this(propertyName, reason, null);
+    }
+
+    public PropertyValidationException(String propertyName) {
+        this(propertyName, null);
+    }
+
+    public String getPropertyName() {
+        return propertyName;
+    }
+
+    public String getReason() {
+        return reason;
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/test/java/org/freemarker/converter/ConverterUtilTest.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/test/java/org/freemarker/converter/ConverterUtilTest.java b/freemarker-converter/src/test/java/org/freemarker/converter/ConverterUtilTest.java
new file mode 100644
index 0000000..811ea18
--- /dev/null
+++ b/freemarker-converter/src/test/java/org/freemarker/converter/ConverterUtilTest.java
@@ -0,0 +1,42 @@
+/*
+ * 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 org.freemarker.converter;
+
+import static junit.framework.TestCase.assertNull;
+import static org.junit.Assert.assertEquals;
+
+import org.apache.freemarker.converter.ConverterUtils;
+import org.junit.Test;
+
+public class ConverterUtilTest {
+
+    @Test
+    public void snakeCaseToCamelCase() {
+        assertNull(ConverterUtils.snakeCaseToCamelCase(null));
+        assertEquals("", ConverterUtils.snakeCaseToCamelCase(""));
+        assertEquals("x", ConverterUtils.snakeCaseToCamelCase("x"));
+        assertEquals("xxx", ConverterUtils.snakeCaseToCamelCase("xXx"));
+        assertEquals("fooBar", ConverterUtils.snakeCaseToCamelCase("foo_bar"));
+        assertEquals("fooBar", ConverterUtils.snakeCaseToCamelCase("FOO_BAR"));
+        assertEquals("fooBar", ConverterUtils.snakeCaseToCamelCase("_foo__bar_"));
+        assertEquals("aBC", ConverterUtils.snakeCaseToCamelCase("a_b_c"));
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/test/java/org/freemarker/converter/FM2ToFM3ConverterTest.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/test/java/org/freemarker/converter/FM2ToFM3ConverterTest.java b/freemarker-converter/src/test/java/org/freemarker/converter/FM2ToFM3ConverterTest.java
new file mode 100644
index 0000000..8dc89ec
--- /dev/null
+++ b/freemarker-converter/src/test/java/org/freemarker/converter/FM2ToFM3ConverterTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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 org.freemarker.converter;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.Assert.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Properties;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.freemarker.converter.FM2ToFM3Converter;
+import org.apache.freemarker.converter.ConverterException;
+import org.freemarker.converter.test.ConverterTest;
+import org.junit.Test;
+
+import freemarker.template.Configuration;
+
+public class FM2ToFM3ConverterTest extends ConverterTest {
+
+    protected void createSourceFiles() throws IOException {
+        //
+    }
+
+    @Test
+    public void testMixed() throws IOException, ConverterException {
+        assertConvertedSame("s1\n  <#if t>\n    ${var}\n  </#if>\ns2");
+    }
+
+    @Test
+    public void testInterpolations() throws IOException, ConverterException {
+        assertConvertedSame("${var}");
+        assertConvertedSame("${  var\n}");
+    }
+
+    @Test
+    public void testExpressions() throws IOException, ConverterException {
+        assertConvertedSame("${x + 1\r\n\t- y % 2 / 2 * +z / -1}");
+        assertConvertedSame("${x * (y + z) * (\ty+z\n)}");
+
+        assertConvertedSame("${f()}");
+        assertConvertedSame("${f(1)}");
+        assertConvertedSame("${f(1, 2)}");
+        assertConvertedSame("${f<#-- C1 -->(<#-- C2 --> 1, 2 ,<#-- C3 --> 3,<#-- C4 -->4 <#-- C5 -->)}");
+    }
+
+    @Test
+    public void testDirectives() throws IOException, ConverterException {
+        assertConvertedSame("<#if foo>1</#if>");
+        assertConvertedSame("<#if\n  foo\n>\n123\n</#if\n>");
+
+        assertConverted("<#if foo>1<#elseIf bar>2<#else>3</#if>", "<#if foo>1<#elseif bar>2<#else>3</#if>");
+        assertConvertedSame("<#if  foo >1<#elseIf  bar >2<#else >3</#if >");
+    }
+
+    @Test
+    public void testBuiltInExpressions() throws IOException, ConverterException {
+        assertConverted("${s?upperCase} ${s?leftPad(123)}", "${s?upper_case} ${s?left_pad(123)}");
+        assertConverted("${s?html}", "${s?web_safe}");
+        assertConvertedSame("${s  ?   upperCase\t?\t\tleftPad(5)}");
+    }
+
+    @Test
+    public void testComments() throws IOException, ConverterException {
+        assertConvertedSame("\n<#--\n  c\n\t-->\n");
+        assertConvertedSame("${1 + <#-- C1 -->\r\n2 +[#-- C2 --]3 +<!--\tC3\t-->4 +[!-- C4 --] 5 + -<!-- -->1}");
+    }
+
+    @Test
+    public void testSquareBracketTagSyntax() throws IOException, ConverterException {
+        assertConvertedSame("[#if true <#-- c -->[#-- c --]]${v}[/#if]", true);
+    }
+
+    @Test
+    public void testFileExtensions() throws IOException, ConverterException {
+        FileUtils.write(new File(srcDir, "t1"), "x", UTF_8);
+        FileUtils.write(new File(srcDir, "t2.foo"), "x", UTF_8);
+        FileUtils.write(new File(srcDir, "t3.ftl"), "x", UTF_8);
+        FileUtils.write(new File(srcDir, "t4.ftlh"), "x", UTF_8);
+        FileUtils.write(new File(srcDir, "t5.ftlx"), "x", UTF_8);
+        FileUtils.write(new File(srcDir, "t6.ftl"), "[#ftl]", UTF_8);
+        FileUtils.write(new File(srcDir, "t7.ftlh"), "[#ftl]", UTF_8);
+        FileUtils.write(new File(srcDir, "t8.ftlx"), "[#ftl]", UTF_8);
+        FileUtils.write(new File(srcDir, "t9.Ftl"), "x", UTF_8);
+        FileUtils.write(new File(srcDir, "t10.Foo3"), "x", UTF_8);
+
+        FM2ToFM3Converter converter = new FM2ToFM3Converter();
+        converter.setSource(srcDir);
+        converter.setDestinationDirectory(dstDir);
+        Properties properties = new Properties();
+        properties.setProperty(Configuration.DEFAULT_ENCODING_KEY, UTF_8.name());
+        converter.setFreeMarker2Settings(properties);
+
+        converter.execute();
+
+        assertTrue(new File(dstDir, "t1").exists());
+        assertTrue(new File(dstDir, "t2.foo").exists());
+        assertTrue(new File(dstDir, "t3.fm3").exists());
+        assertTrue(new File(dstDir, "t4.fm3h").exists());
+        assertTrue(new File(dstDir, "t5.fm3x").exists());
+        assertTrue(new File(dstDir, "t6.fm3s").exists());
+        assertTrue(new File(dstDir, "t7.fm3sh").exists());
+        assertTrue(new File(dstDir, "t8.fm3sx").exists());
+        assertTrue(new File(dstDir, "t9.fm3").exists());
+        assertTrue(new File(dstDir, "t10.Foo3").exists());
+    }
+
+    private void assertConvertedSame(String ftl2) throws IOException, ConverterException {
+        assertConverted(ftl2, ftl2);
+    }
+
+    private void assertConverted(String ftl3, String ftl2) throws IOException, ConverterException {
+        assertEquals(ftl3, convert(ftl2));
+    }
+
+    private void assertConvertedSame(String ftl2, boolean squareBracketTagSyntax)
+            throws IOException, ConverterException {
+        assertConverted(ftl2, ftl2, squareBracketTagSyntax);
+    }
+
+    private void assertConverted(String ftl3, String ftl2, boolean squareBracketTagSyntax)
+            throws IOException, ConverterException {
+        assertEquals(ftl3, convert(ftl2, squareBracketTagSyntax));
+    }
+
+    private String convert(String ftl2) throws IOException, ConverterException {
+        return convert(ftl2, false);
+    }
+
+    private String convert(String ftl2, boolean squareBracketTagSyntax) throws IOException, ConverterException {
+        File srcFile = new File(srcDir, "t");
+        FileUtils.write(srcFile, ftl2, UTF_8);
+
+        FM2ToFM3Converter converter = new FM2ToFM3Converter();
+        converter.setSource(srcFile);
+        converter.setDestinationDirectory(dstDir);
+        Properties properties = new Properties();
+        properties.setProperty(Configuration.DEFAULT_ENCODING_KEY, UTF_8.name());
+        if (squareBracketTagSyntax) {
+            properties.setProperty(Configuration.TAG_SYNTAX_KEY, "squareBracket");
+        }
+        converter.setFreeMarker2Settings(properties);
+
+        converter.execute();
+
+        File outputFile = new File(dstDir, "t");
+        String output = FileUtils.readFileToString(outputFile, UTF_8);
+        if (!outputFile.delete()) {
+            throw new IOException("Couldn't delete file: " + outputFile);
+        }
+
+        return output;
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/test/java/org/freemarker/converter/GenericConverterTest.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/test/java/org/freemarker/converter/GenericConverterTest.java b/freemarker-converter/src/test/java/org/freemarker/converter/GenericConverterTest.java
new file mode 100644
index 0000000..77abcfe
--- /dev/null
+++ b/freemarker-converter/src/test/java/org/freemarker/converter/GenericConverterTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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 org.freemarker.converter;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.apache.commons.io.FileUtils.*;
+import static org.hamcrest.Matchers.*;
+import static org.junit.Assert.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.freemarker.converter.MissingRequiredPropertyException;
+import org.apache.freemarker.converter.PropertyValidationException;
+import org.apache.freemarker.converter.Converter;
+import org.apache.freemarker.converter.ConverterException;
+import org.freemarker.converter.test.ConverterTest;
+import org.junit.Test;
+
+/**
+ * Test the common functionality implemented in {@link Converter}.
+ */
+public class GenericConverterTest extends ConverterTest {
+
+    protected void createSourceFiles() throws IOException {
+        write(new File(srcDir, "t1.txt"), "t1", UTF_8);
+        write(new File(srcDir, "t2.txt"), "t2", UTF_8);
+
+        File srcSubDir = new File(srcDir, "sub");
+        if (!srcSubDir.mkdir()) {
+            throw new IOException("Failed to create directory: " + srcSubDir);
+        }
+        write(new File(srcSubDir, "st1.txt"), "st1", UTF_8);
+        write(new File(srcSubDir, "st2.txt"), "st2", UTF_8);
+
+        File srcSub2Lv2Dir = new File(new File(srcDir, "sub2"), "lv2");
+        if (!srcSub2Lv2Dir.mkdirs()) {
+            throw new IOException("Failed to create directory: " + srcSubDir);
+        }
+        write(new File(srcSub2Lv2Dir, "s2lv2t1.txt"), "s2lv2t1", UTF_8);
+    }
+
+    @Test
+    public void testWithSourceFile() throws ConverterException, IOException {
+        ToUpperCaseConverter converter = new ToUpperCaseConverter();
+        converter.setSource(new File(srcDir, "t1.txt"));
+        converter.setDestinationDirectory(dstDir);
+        converter.execute();
+
+        assertEquals("T1", readFileToString(new File(dstDir, "t1.txt.uc"), UTF_8));
+        assertFalse(new File(dstDir, "t2.txt.uc").exists());
+        assertFalse(new File(dstDir, "sub").exists());
+    }
+
+    @Test
+    public void testWithSourceDirectory() throws ConverterException, IOException {
+        ToUpperCaseConverter converter = new ToUpperCaseConverter();
+        converter.setSource(srcDir);
+        converter.setDestinationDirectory(dstDir);
+        converter.execute();
+
+        assertEquals("T1", readFileToString(new File(dstDir, "t1.txt.uc"), UTF_8));
+        assertEquals("T2", readFileToString(new File(dstDir, "t2.txt.uc"), UTF_8));
+        assertEquals("ST1", readFileToString(new File(new File(dstDir, "sub"), "st1.txt.uc"), UTF_8));
+        assertEquals("ST2", readFileToString(new File(new File(dstDir, "sub"), "st2.txt.uc"), UTF_8));
+        assertEquals("S2LV2T1",
+                readFileToString(new File(new File(new File(dstDir, "sub2"), "lv2"), "s2lv2t1.txt.uc"),
+                        UTF_8));
+    }
+
+    @Test
+    public void testCanBeExecutedOnlyOnce() throws ConverterException, IOException {
+        ToUpperCaseConverter converter = new ToUpperCaseConverter();
+        converter.setSource(srcDir);
+        converter.setDestinationDirectory(dstDir);
+        converter.execute();
+        try {
+            converter.execute();
+            fail();
+        } catch (IllegalStateException e) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testSourcePropertyInvalid() throws ConverterException, IOException {
+        ToUpperCaseConverter converter = new ToUpperCaseConverter();
+        converter.setSource(new File(srcDir, "noSuchFile"));
+        converter.setDestinationDirectory(dstDir);
+        try {
+            converter.execute();
+            fail();
+        } catch (PropertyValidationException e) {
+            assertThat(e.getMessage(), containsString("noSuchFile"));
+        }
+    }
+
+    @Test
+    public void testCreateDstDisabled() throws ConverterException, IOException {
+        ToUpperCaseConverter converter = new ToUpperCaseConverter();
+        converter.setSource(srcDir);
+        File dstDir = new File(new File(this.dstDir, "foo"), "bar");
+        converter.setDestinationDirectory(dstDir);
+        try {
+            converter.execute();
+            fail();
+        } catch (PropertyValidationException e) {
+            assertThat(e.getMessage(), containsString("foo"));
+            assertFalse(dstDir.exists());
+        }
+    }
+
+    @Test
+    public void testCreateDstEnabled() throws ConverterException, IOException {
+        ToUpperCaseConverter converter = new ToUpperCaseConverter();
+        converter.setSource(srcDir);
+        File dstDir = new File(new File(this.dstDir, "foo"), "bar");
+        converter.setDestinationDirectory(dstDir);
+        converter.setCreateDestinationDirectory(true);
+        converter.execute();
+        assertTrue(dstDir.exists());
+    }
+
+    @Test
+    public void testSourcePropertyRequired() throws ConverterException, IOException {
+        ToUpperCaseConverter converter = new ToUpperCaseConverter();
+        converter.setDestinationDirectory(dstDir);
+        try {
+            converter.execute();
+            fail();
+        } catch (MissingRequiredPropertyException e) {
+            assertEquals(ToUpperCaseConverter.PROPERTY_NAME_SOURCE, e.getPropertyName());
+        }
+    }
+
+    @Test
+    public void testDestinationDirPropertyRequired() throws ConverterException, IOException {
+        ToUpperCaseConverter converter = new ToUpperCaseConverter();
+        converter.setSource(srcDir);
+        try {
+            converter.execute();
+            fail();
+        } catch (MissingRequiredPropertyException e) {
+            assertEquals(ToUpperCaseConverter.PROPERTY_NAME_DESTINATION_DIRECTORY, e.getPropertyName());
+        }
+    }
+
+    public static class ToUpperCaseConverter extends Converter {
+
+        public static final int BUFFER_SIZE = 4096;
+
+        @Override
+        protected void convertFile(FileConversionContext fileTransCtx) throws ConverterException, IOException {
+            String content = IOUtils.toString(fileTransCtx.getSourceStream(), StandardCharsets.UTF_8);
+            fileTransCtx.setDestinationFileName(fileTransCtx.getSourceFileName() + ".uc");
+            IOUtils.write(content.toUpperCase(), fileTransCtx.getDestinationStream(), StandardCharsets.UTF_8);
+        }
+
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/freemarker-converter/src/test/java/org/freemarker/converter/test/ConverterTest.java
----------------------------------------------------------------------
diff --git a/freemarker-converter/src/test/java/org/freemarker/converter/test/ConverterTest.java b/freemarker-converter/src/test/java/org/freemarker/converter/test/ConverterTest.java
new file mode 100644
index 0000000..81df566
--- /dev/null
+++ b/freemarker-converter/src/test/java/org/freemarker/converter/test/ConverterTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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 org.freemarker.converter.test;
+
+import java.io.File;
+import java.io.IOException;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.rules.TemporaryFolder;
+
+public abstract class ConverterTest {
+
+    @Rule
+    public TemporaryFolder folder = new TemporaryFolder();
+
+    protected File srcDir;
+    protected File dstDir;
+
+    @Before
+    public void setup() throws IOException {
+        srcDir = folder.newFolder("src");
+        dstDir = folder.newFolder("dst");
+        createSourceFiles();
+    }
+
+    protected abstract void createSourceFiles() throws IOException;
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/c73dc567/settings.gradle
----------------------------------------------------------------------
diff --git a/settings.gradle b/settings.gradle
index 47fc644..1f07da1 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -26,4 +26,5 @@ include 'freemarker-servlet'
 include 'freemarker-test-utils'
 include 'freemarker-manual'
 include 'freemarker-dom'
+include 'freemarker-converter'