diff --git a/.gitignore b/.gitignore index 337e8caf..95fb6e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ junit.xml /stencil-native /*.docx .DS_Store -.lsp/ \ No newline at end of file +.lsp/ +*.jfr \ No newline at end of file diff --git a/README.md b/README.md index 5a003ea3..8ed88240 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ The project has a simple [service implementation](https://github.com/erdos/stenc ## Version -**Latest stable** version is `0.3.31` +**Latest stable** version is `0.5.0` -**Latest snapshot** version is `0.3.32-SNAPSHOT` +**Latest snapshot** version is `0.5.1-SNAPSHOT` If you are using Maven, add the followings to your `pom.xml`: @@ -57,7 +57,7 @@ The dependency: io.github.erdos stencil-core - 0.3.31 + 0.5.0 ``` @@ -72,7 +72,7 @@ And the [Clojars](https://clojars.org) repository: Alternatively, if you are using Leiningen, add the following to the `:dependencies` section of your `project.clj` -file: `[io.github.erdos/stencil-core "0.3.31"]` +file: `[io.github.erdos/stencil-core "0.5.0"]` Previous versions are available on the [Stencil Clojars](https://clojars.org/io.github.erdos/stencil-core) page. diff --git a/docs/Functions.md b/docs/Functions.md index c7acca80..60df5531 100644 --- a/docs/Functions.md +++ b/docs/Functions.md @@ -25,6 +25,7 @@ This is a short description of the functions implemented in Stencil: - [list](#list) - [lowercase](#lowercase) - [map](#map) +- [pageBreak](#pagebreak) - `percent` - `range` - [round](#round) @@ -164,6 +165,10 @@ The rendering throws an exception on invalid HTML input or unexpected HTML tags. Write the following to embed the content of `x` as HTML in the document: - {%=html(x) %}. +### PageBreak + +Inserts a page break at the place of the call. Example: {%=pageBreak()%} + ### XML You can embed custom xml fragments in the document with the `xml()` function. The parameter is a string containing the XML nodes to insert. @@ -200,6 +205,10 @@ The `lowercase(x)` function turns its string argument into a lowercase string. F The `str(x)` functions convers its non-null arguments into a string. Returns an empty string when all arguments are null. +## Replace + +The `replace(text, pattern, replacement)` function replaces all occurrence of `pattern` in `text` by `replacement`. + ## Numeric functions ### Round diff --git a/docs/Syntax.md b/docs/Syntax.md index db19ca7c..bab74a64 100644 --- a/docs/Syntax.md +++ b/docs/Syntax.md @@ -104,6 +104,13 @@ Syntax: In this example we iterate over the contents of the `elements` array. The text `BODY` is inserted for every element. +### Iteration with index + +A special syntax is implemented for iterating over a collection (vector or map) with the indices bound to a new variable. Syntax: + +- {%for idx, x in elements %}BODY{%end%} + + ## Finding errors - Check that every control structure is properly closed! diff --git a/java-src/io/github/erdos/stencil/TemplateData.java b/java-src/io/github/erdos/stencil/TemplateData.java index 83862b0d..782bde2f 100644 --- a/java-src/io/github/erdos/stencil/TemplateData.java +++ b/java-src/io/github/erdos/stencil/TemplateData.java @@ -43,7 +43,7 @@ public static TemplateData fromMap(Map data) { * * @return template data map. Not null. */ - public final Map getData() { + public Map getData() { return data; } } diff --git a/java-src/io/github/erdos/stencil/TemplateVariables.java b/java-src/io/github/erdos/stencil/TemplateVariables.java index 3c08be62..71d8f87c 100644 --- a/java-src/io/github/erdos/stencil/TemplateVariables.java +++ b/java-src/io/github/erdos/stencil/TemplateVariables.java @@ -151,14 +151,13 @@ private List validate(Object data, Node schema) { return validateImpl("", data, schema).collect(toList()); } - @SuppressWarnings("unchecked") private Stream validateImpl(String path, Object data, Node schema) { return schema.accept(new NodeVisitor>() { @Override public Stream visitArray(Node wrapped) { if (data instanceof List) { final AtomicInteger index = new AtomicInteger(); - return ((List) data).stream().flatMap(x -> { + return ((List) data).stream().flatMap(x -> { final String newPath = path + "[" + index.getAndIncrement() + "]"; return validateImpl(newPath, x, wrapped); }); @@ -170,7 +169,7 @@ public Stream visitArray(Node wrapped) { @Override public Stream visitMap(Map items) { if (data instanceof Map) { - Map dataMap = (Map) data; + Map dataMap = (Map) data; return items.entrySet().stream().flatMap(schemaEntry -> { if (dataMap.containsKey(schemaEntry.getKey())) { return validateImpl(path + "." + schemaEntry.getKey(), dataMap.get(schemaEntry.getKey()), schemaEntry.getValue()); @@ -192,7 +191,7 @@ public Stream visitLeaf() { } @SuppressWarnings("unused") - class SchemaError { + private static final class SchemaError { private final String path; private final String msg; diff --git a/java-src/io/github/erdos/stencil/exceptions/EvalException.java b/java-src/io/github/erdos/stencil/exceptions/EvalException.java index 46860669..f896e277 100644 --- a/java-src/io/github/erdos/stencil/exceptions/EvalException.java +++ b/java-src/io/github/erdos/stencil/exceptions/EvalException.java @@ -5,6 +5,10 @@ */ public final class EvalException extends RuntimeException { + public EvalException(String message, Exception cause) { + super(message, cause); + } + private EvalException(Exception cause) { super(cause); } @@ -12,8 +16,4 @@ private EvalException(Exception cause) { private EvalException(String message) { super(message); } - - public static EvalException wrapping(Exception cause) { - return new EvalException(cause); - } } diff --git a/java-src/io/github/erdos/stencil/functions/BasicFunctions.java b/java-src/io/github/erdos/stencil/functions/BasicFunctions.java index 638776ed..cfb8bc3f 100644 --- a/java-src/io/github/erdos/stencil/functions/BasicFunctions.java +++ b/java-src/io/github/erdos/stencil/functions/BasicFunctions.java @@ -37,7 +37,7 @@ else if (expr != null && expr.equals(value)) }, /** - * Returns the first non-null an non-empty value. + * Returns the first non-null a non-empty value. *

* Accepts any arguments. Skips null values, empty strings and empty collections. */ diff --git a/java-src/io/github/erdos/stencil/functions/LocaleFunctions.java b/java-src/io/github/erdos/stencil/functions/LocaleFunctions.java index 4c08c731..fbf14e5a 100644 --- a/java-src/io/github/erdos/stencil/functions/LocaleFunctions.java +++ b/java-src/io/github/erdos/stencil/functions/LocaleFunctions.java @@ -10,7 +10,7 @@ public enum LocaleFunctions implements Function { /** - * Formats number as localized currency. An optional second argument can be used to specify locale code. + * Formats number as a localized monetary amount with currency. An optional second argument can be used to specify locale code. * Returns a string. *

* Usage: currency(34) or currency(34, "HU") @@ -23,7 +23,7 @@ public Object call(Object... arguments) throws IllegalArgumentException { }, /** - * Formats number as localized percent. An optional second argument can be used to specify locale code. + * Formats number as a localized percentage value. An optional second argument can be used to specify locale code. * Returns a string. *

* Usage: percent(34) or percent(34, "HU") diff --git a/java-src/io/github/erdos/stencil/functions/StringFunctions.java b/java-src/io/github/erdos/stencil/functions/StringFunctions.java index b4b9d695..ca2bb6d8 100644 --- a/java-src/io/github/erdos/stencil/functions/StringFunctions.java +++ b/java-src/io/github/erdos/stencil/functions/StringFunctions.java @@ -2,7 +2,6 @@ import java.util.Arrays; import java.util.Collection; -import java.util.IllegalFormatException; import java.util.Objects; import java.util.stream.Collectors; @@ -58,7 +57,7 @@ public Object call(Object... arguments) throws IllegalArgumentException { StringBuilder builder = new StringBuilder(); for (Object argument : arguments) { if (argument != null) - builder.append(argument.toString()); + builder.append(argument); } return builder.toString(); } diff --git a/java-src/io/github/erdos/stencil/impl/DirWatcherTemplateFactory.java b/java-src/io/github/erdos/stencil/impl/DirWatcherTemplateFactory.java index 41516acf..3370d870 100644 --- a/java-src/io/github/erdos/stencil/impl/DirWatcherTemplateFactory.java +++ b/java-src/io/github/erdos/stencil/impl/DirWatcherTemplateFactory.java @@ -180,7 +180,7 @@ public PreparedTemplate prepareTemplateFile(File inputTemplateFile, PrepareOptio .orElseThrow(() -> new IllegalArgumentException("Can not build template file: " + inputTemplateFile)); } - private final class DelayedContainer implements Delayed { + private static final class DelayedContainer implements Delayed { private final long expiration; private final X contents; diff --git a/java-src/io/github/erdos/stencil/impl/FileHelper.java b/java-src/io/github/erdos/stencil/impl/FileHelper.java index 75d929d5..1a429fd0 100644 --- a/java-src/io/github/erdos/stencil/impl/FileHelper.java +++ b/java-src/io/github/erdos/stencil/impl/FileHelper.java @@ -51,7 +51,7 @@ public static String removeExtension(File f) { * @return a new file object pointing to a non-existing file in temp directory. */ public static File createNonexistentTempFile(String prefix, String suffix) { - return new File(TEMP_DIRECTORY, prefix + UUID.randomUUID().toString() + suffix); + return new File(TEMP_DIRECTORY, prefix + UUID.randomUUID() + suffix); } /** @@ -101,7 +101,7 @@ public static void forceDelete(final File file) { * @param file to delete, not null * @throws NullPointerException on null or invalid file */ - @SuppressWarnings({"ResultOfMethodCallIgnored", "ConstantConditions"}) + @SuppressWarnings({"ConstantConditions"}) public static void forceDeleteOnExit(final File file) { file.deleteOnExit(); if (file.isDirectory()) { @@ -113,7 +113,7 @@ public static void forceDeleteOnExit(final File file) { /** * Returns a string representation of path with unix separators ("/") instead of the - * system-dependent separators (which is backslash on windows.) + * system-dependent separators (which is backslash on Windows). * * @param path not null path object * @return string of path with slash separators @@ -128,7 +128,7 @@ public static String toUnixSeparatedString(Path path) { // on unix systems return path.toString(); } else { - // on windows systems we replace backslash with slashes + // on Windows systems we replace backslash with slashes return path.toString().replaceAll(Pattern.quote(separator), "/"); } } diff --git a/java-src/io/github/erdos/stencil/impl/NativeEvaluator.java b/java-src/io/github/erdos/stencil/impl/NativeEvaluator.java index 13c07411..9cae6606 100644 --- a/java-src/io/github/erdos/stencil/impl/NativeEvaluator.java +++ b/java-src/io/github/erdos/stencil/impl/NativeEvaluator.java @@ -2,6 +2,7 @@ import clojure.lang.AFunction; import clojure.lang.IFn; +import clojure.lang.PersistentHashMap; import io.github.erdos.stencil.*; import io.github.erdos.stencil.exceptions.EvalException; import io.github.erdos.stencil.functions.FunctionEvaluator; @@ -49,13 +50,15 @@ public EvaluatedDocument render(PreparedTemplate template, Map "Starting document rendering for template " + template.getTemplateFile()); final IFn fn = ClojureHelper.findFunction("eval-template"); - final Map argsMap = makeArgsMap(template.getSecretObject(), fragments, data.getData()); + final Object argsMap = makeArgsMap(template.getSecretObject(), fragments, data.getData()); final Map result; try { result = (Map) fn.invoke(argsMap); + } catch (EvalException e) { + throw e; } catch (Exception e) { - throw EvalException.wrapping(e); + throw new EvalException("Unexpected error", e); } final Consumer stream = resultWriter(result); @@ -94,7 +97,7 @@ private static Consumer resultWriter(Map result) { } @SuppressWarnings("unchecked") - private Map makeArgsMap(Object template, Map fragments, Object data) { + private Object makeArgsMap(Object template, Map fragments, Object data) { final Map result = new HashMap(); result.put(ClojureHelper.Keywords.TEMPLATE.kw, template); result.put(ClojureHelper.Keywords.DATA.kw, data); @@ -103,9 +106,9 @@ private Map makeArgsMap(Object template, Map fragments // string to clojure map final Map kvs = fragments.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, v -> v.getValue().getImpl())); - result.put(ClojureHelper.Keywords.FRAGMENTS.kw, kvs); + result.put(ClojureHelper.Keywords.FRAGMENTS.kw, PersistentHashMap.create(kvs)); - return result; + return PersistentHashMap.create(result); } private final class FunctionCaller extends AFunction { diff --git a/java-src/io/github/erdos/stencil/impl/NativeTemplateFactory.java b/java-src/io/github/erdos/stencil/impl/NativeTemplateFactory.java index 90fa6dae..a2d88981 100644 --- a/java-src/io/github/erdos/stencil/impl/NativeTemplateFactory.java +++ b/java-src/io/github/erdos/stencil/impl/NativeTemplateFactory.java @@ -48,18 +48,11 @@ public PreparedFragment prepareFragmentFile(final File fragmentFile, PrepareOpti final Map prepared; try { - //noinspection unchecked - prepared = (Map) prepareFunction.invoke(fragmentFile, options); - } catch (ParsingException e) { + prepared = invokePrepareFunction(fragmentFile, options); + } catch (ParsingException | IOException e) { throw e; } catch (Exception e) { - //noinspection ConstantConditions - if (e instanceof IOException) { - // possible because of Clojure magic :-( - throw (IOException) e; - } else { - throw ParsingException.wrapping("Could not parse template file!", e); - } + throw ParsingException.wrapping("Could not parse template file!", e); } final File zipDirResource = (File) prepared.get(ClojureHelper.Keywords.SOURCE_FOLDER.kw); @@ -70,25 +63,31 @@ public PreparedFragment prepareFragmentFile(final File fragmentFile, PrepareOpti return new PreparedFragment(prepared, zipDirResource); } + @SuppressWarnings({"unchecked", "RedundantThrows"}) + private static Map invokePrepareFunction(File fragmentFile, PrepareOptions options) throws Exception { + final IFn prepareFunction = ClojureHelper.findFunction("prepare-fragment"); + return (Map) prepareFunction.invoke(fragmentFile, options); + } + /** * Retrieves content of :variables keyword from map as a set. */ @SuppressWarnings("unchecked") - private Set variableNames(Map prepared) { + private static Set variableNames(Map prepared) { return prepared.containsKey(ClojureHelper.Keywords.VARIABLES.kw) - ? unmodifiableSet(new HashSet((Collection) prepared.get(ClojureHelper.Keywords.VARIABLES.kw))) + ? unmodifiableSet(new HashSet<>((Collection) prepared.get(ClojureHelper.Keywords.VARIABLES.kw))) : emptySet(); } @SuppressWarnings("unchecked") - private Set fragmentNames(Map prepared) { + private static Set fragmentNames(Map prepared) { return prepared.containsKey(ClojureHelper.Keywords.FRAGMENTS.kw) - ? unmodifiableSet(new HashSet((Collection) prepared.get(ClojureHelper.Keywords.FRAGMENTS.kw))) + ? unmodifiableSet(new HashSet<>((Collection) prepared.get(ClojureHelper.Keywords.FRAGMENTS.kw))) : emptySet(); } @SuppressWarnings("unchecked") - private PreparedTemplate prepareTemplateImpl(TemplateDocumentFormats templateDocFormat, InputStream input, File originalFile, PrepareOptions options) { + private static PreparedTemplate prepareTemplateImpl(TemplateDocumentFormats templateDocFormat, InputStream input, File originalFile, PrepareOptions options) { final IFn prepareFunction = ClojureHelper.findFunction("prepare-template"); final String format = templateDocFormat.name(); diff --git a/java-src/io/github/erdos/stencil/standalone/ArgsParser.java b/java-src/io/github/erdos/stencil/standalone/ArgsParser.java index fdb57b4c..8fae6464 100644 --- a/java-src/io/github/erdos/stencil/standalone/ArgsParser.java +++ b/java-src/io/github/erdos/stencil/standalone/ArgsParser.java @@ -10,7 +10,7 @@ @SuppressWarnings("WeakerAccess") public class ArgsParser { - private final Set markers = new HashSet<>(); + private final Set> markers = new HashSet<>(); public ParamMarker addParam(char shortForm, String longForm, String description, Function parser) { final ParamMarker added = new ParamMarker<>(parser, shortForm, longForm, description, false); @@ -20,7 +20,7 @@ public ParamMarker addParam(char shortForm, String longForm, String descr public ParseResult parse(String... args) { - final Map result = new HashMap<>(); + final Map, String> result = new HashMap<>(); final List restArgs = new ArrayList<>(args.length); @@ -39,7 +39,7 @@ public ParseResult parse(String... args) { final String argName = parts[0]; final String argValue = parts[1]; - final Map.Entry flag = parsePair(argName, argValue); + final Map.Entry, String> flag = parsePair(argName, argValue); result.put(flag.getKey(), flag.getValue()); } else { @@ -47,21 +47,21 @@ public ParseResult parse(String... args) { if (item.startsWith("--no-")) { // long form if (markerForLong(item.substring(5)).orElseThrow(() -> new IllegalArgumentException("Unexpected option: " + item)).isFlag()) { - Map.Entry pair = parsePair(item, null); + Map.Entry, String> pair = parsePair(item, null); result.put(pair.getKey(), pair.getValue()); continue; } } else if (item.startsWith("--")) { // long form if (markerForLong(item.substring(2)).orElseThrow(() -> new IllegalArgumentException("Unexpected option: " + item)).isFlag()) { - Map.Entry pair = parsePair(item, null); + Map.Entry, String> pair = parsePair(item, null); result.put(pair.getKey(), pair.getValue()); continue; } } else { // short form if (markerForShort(item.charAt(1)).orElseThrow(() -> new IllegalArgumentException("Unexpected option: " + item)).isFlag()) { - Map.Entry pair = parsePair(item, null); + Map.Entry, String> pair = parsePair(item, null); result.put(pair.getKey(), pair.getValue()); continue; } @@ -70,10 +70,10 @@ public ParseResult parse(String... args) { // maybe the next item is a value final String nextItem = i + 1 < args.length ? args[i + 1] : null; if (nextItem == null || nextItem.startsWith("-")) { - final Map.Entry flag = parsePair(item, null); + final Map.Entry, String> flag = parsePair(item, null); result.put(flag.getKey(), flag.getValue()); } else { - final Map.Entry flag = parsePair(item, nextItem); + final Map.Entry, String> flag = parsePair(item, nextItem); result.put(flag.getKey(), flag.getValue()); } } @@ -88,32 +88,32 @@ public ParseResult parse(String... args) { return new ParseResult(result, restArgs); } - private Map.Entry parsePair(String k, String v) { + private Map.Entry, String> parsePair(String k, String v) { if (k.startsWith("--no-") && v == null) { final String longName = k.substring(5); - final ParamMarker marker = markerForLong(longName).get(); + final ParamMarker marker = markerForLong(longName).get(); return new AbstractMap.SimpleImmutableEntry<>(marker, "false"); } else if (k.startsWith("--")) { final String longName = k.substring(2); - final ParamMarker marker = markerForLong(longName).get(); + final ParamMarker marker = markerForLong(longName).get(); if (v == null) { return new AbstractMap.SimpleImmutableEntry<>(marker, "true"); } else { return new AbstractMap.SimpleImmutableEntry<>(marker, v); } } else if (k.startsWith("-") && k.length() == 2 && v == null) { - final ParamMarker marker = markerForShort(k.charAt(1)).get(); + final ParamMarker marker = markerForShort(k.charAt(1)).get(); return new AbstractMap.SimpleImmutableEntry<>(marker, "true"); } else { throw new IllegalArgumentException("Unexpected key, not a parameter: " + k); } } - private Optional markerForLong(String longName) { + private Optional> markerForLong(String longName) { return markers.stream().filter(x -> x.getLongName().equals(longName)).findAny(); } - private Optional markerForShort(char shortName) { + private Optional> markerForShort(char shortName) { return markers.stream().filter(x -> x.getShortForm() == shortName).findAny(); } @@ -138,17 +138,17 @@ public ParamMarker addFlagOption(char shortName, String longName, Strin } public static final class ParseResult { - private final Map args; + private final Map, String> args; private final List varargs; - private ParseResult(Map args, List varargs) { + private ParseResult(Map, String> args, List varargs) { this.args = unmodifiableMap(args); this.varargs = unmodifiableList(varargs); } public List getRestArgs() { - return Collections.unmodifiableList(varargs); + return varargs; } diff --git a/java-src/io/github/erdos/stencil/standalone/JsonParser.java b/java-src/io/github/erdos/stencil/standalone/JsonParser.java index e280f206..1f7b318d 100644 --- a/java-src/io/github/erdos/stencil/standalone/JsonParser.java +++ b/java-src/io/github/erdos/stencil/standalone/JsonParser.java @@ -15,7 +15,7 @@ public final class JsonParser { /** * Parses string and returns read object if any. */ - @SuppressWarnings({"unchecked", "unused", "WeakerAccess"}) + @SuppressWarnings({"unused", "WeakerAccess"}) public static Object parse(String contents) throws IOException { return read(new StringReader(contents)); } @@ -89,7 +89,7 @@ static Number readNumber(PushbackReader pb) throws IOException { static String readStr(PushbackReader pb) throws IOException { expectWord("\"", pb); - final StringBuffer buf = new StringBuffer(); + final StringBuilder buf = new StringBuilder(); while (true) { final int read = pb.read(); diff --git a/java-src/io/github/erdos/stencil/standalone/StandaloneApplication.java b/java-src/io/github/erdos/stencil/standalone/StandaloneApplication.java index 6937a1ea..edb8d713 100644 --- a/java-src/io/github/erdos/stencil/standalone/StandaloneApplication.java +++ b/java-src/io/github/erdos/stencil/standalone/StandaloneApplication.java @@ -1,13 +1,11 @@ package io.github.erdos.stencil.standalone; -import clojure.lang.IFn; import clojure.lang.RT; import clojure.lang.Symbol; import io.github.erdos.stencil.EvaluatedDocument; import io.github.erdos.stencil.PrepareOptions; import io.github.erdos.stencil.PreparedTemplate; import io.github.erdos.stencil.TemplateData; -import io.github.erdos.stencil.impl.ClojureHelper; import io.github.erdos.stencil.impl.FileHelper; import java.io.BufferedReader; diff --git a/java-test/io/github/erdos/stencil/IntegrationTest.java b/java-test/io/github/erdos/stencil/IntegrationTest.java deleted file mode 100644 index 89b6da0b..00000000 --- a/java-test/io/github/erdos/stencil/IntegrationTest.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.erdos.stencil; - - -/** - * Annotates a test that needs a LibreOffice process in the background. - */ -public interface IntegrationTest { - /* category marker */ -} diff --git a/project.clj b/project.clj index e9ebcdcf..addf9493 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject io.github.erdos/stencil-core "0.3.31" +(defproject io.github.erdos/stencil-core "0.5.1-SNAPSHOT" :url "https://github.com/erdos/stencil" :description "Templating engine for office documents." :license {:name "Eclipse Public License - v 2.0" @@ -13,10 +13,11 @@ :pom-plugins [[org.apache.maven.plugins/maven-surefire-plugin "2.20"]] :main io.github.erdos.stencil.Main :aliases {"junit" ["with-profile" "+test" "test-out" "junit" "junit.xml"] - "coverage" ["with-profile" "+ci" - "cloverage" "--codecov" + "coverage" ["with-profile" "+ci" "cloverage" "--codecov" "--exclude-call" "clojure.core/assert" - "--exclude-call" "stencil.util/fail"]} + "--exclude-call" "stencil.util/trace" + "--exclude-call" "stencil.util/fail" + "--exclude-call" "clojure.spec.alpha/def"]} :javadoc-opts {:package-names ["stencil"] :additional-args ["-overview" "java-src/overview.html" "-top" ""]} @@ -29,16 +30,20 @@ :filespecs [{:type :bytes, :path "stencil-version", :bytes ~(-> "project.clj" slurp read-string nnext first)}] :profiles {:uberjar {:aot :all} :dev {:aot :all + :injections [(require 'stencil.spec) + (require '[clojure.spec.alpha :as s])] :dependencies [[org.slf4j/slf4j-simple "1.7.32"]] :jvm-opts ["-Dorg.slf4j.simpleLogger.defaultLogLevel=debug"]} :test {:aot :all - :dependencies [[junit/junit "4.12"] + :dependencies [[junit/junit "4.13.2"] [org.xmlunit/xmlunit-core "2.5.1"] [hiccup "1.0.5"]] :plugins [[lein-test-out "0.3.1"]] :resource-paths ["test-resources"] :test-paths ["java-test"] - } + :injections [(require 'stencil.spec) + (require '[clojure.spec.test.alpha :as sta]) + (eval '(sta/instrument))]} :ci {:plugins [[lein-javadoc "0.3.0"] [lein-cloverage "1.2.2"]] }}) diff --git a/scripts/extract-wordxml.clj b/scripts/extract-wordxml.clj new file mode 100755 index 00000000..e26339a4 --- /dev/null +++ b/scripts/extract-wordxml.clj @@ -0,0 +1,17 @@ +#!/usr/bin/env bb + +(def input (first *command-line-args*)) +(assert input) + +(def input-file (clojure.java.io/file input)) +(assert (.exists input-file)) + +(with-open [fistream (new java.io.FileInputStream input-file) + zipstream (new java.util.zip.ZipInputStream fistream)] + (doseq [entry (repeatedly #(.getNextEntry zipstream)) + :while entry + :when (= "word/document.xml" (.getName entry))] + (-> zipstream + (clojure.data.xml/parse) + (clojure.data.xml/indent-str) + (println)))) diff --git a/service/Dockerfile b/service/Dockerfile index a5cc0468..201ade69 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -12,7 +12,7 @@ COPY . /usr/src/myapp RUN mv "$(lein uberjar | sed -n 's/^Created \(.*standalone\.jar\)/\1/p')" myapp-standalone.jar RUN jlink --strip-debug --add-modules "$(jdeps --print-module-deps --ignore-missing-deps myapp-standalone.jar)" --add-modules jdk.localedata --output /java -FROM debian:10.10-slim +FROM debian:10-slim ENV STENCIL_HTTP_PORT 8080 ENV STENCIL_TEMPLATE_DIR /templates diff --git a/service/README.md b/service/README.md index 07946072..075a5447 100644 --- a/service/README.md +++ b/service/README.md @@ -101,3 +101,27 @@ For example: ``` You can bind the `STENCIL_LOG_LEVEL` environment variable to change the default logging level from `info`. Acceptible values: `trace`, `debug`, `info`, `warn`, `error`, `fatal`. + +### Custom Functions + +It is possible to extend Stencil service with custom functions defined in Javascript. Add a `stencil.js` file in the template directory so it can be read and executed when the service loads. +The functions defined in the script will be available in the templates when rendered. + +The [Rhino](https://developer.mozilla.org/en-US/docs/Mozilla/Projects/Rhino) scripting engine is used for evaluating the scripts. + +In current version, the script file is not reloaded automatically, therefore a service restart is necessary on changes. + +Add a `stencil.js` file in the template directory with the following contents: +``` +function daysBetweenTimestamps(ts1, ts2) { + var time1 = java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochSecond(ts1), java.util.TimeZone.getDefault().toZoneId()); + var time2 = java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochSecond(ts2), java.util.TimeZone.getDefault().toZoneId()); + var duration = java.time.Duration.between(time1, time2); + return duration.toDays(); +} +``` + +The function now can be called in the templates like the following: +``` + {%=daysBetweenTimestamps(1499070300, 1644956311)%} +``` diff --git a/service/project.clj b/service/project.clj index a38f8833..7f31cdbc 100644 --- a/service/project.clj +++ b/service/project.clj @@ -1,12 +1,13 @@ -(defproject io.github.erdos/stencil-service "0.3.31" +(defproject io.github.erdos/stencil-service "0.5.1-SNAPSHOT" :description "Web service for the Stencil templating engine" :url "https://github.com/erdos/stencil" :license {:name "Eclipse Public License - v 2.0" :url "https://www.eclipse.org/legal/epl-2.0/"} - :dependencies [[org.clojure/clojure "1.10.2-alpha2"] - [io.github.erdos/stencil-core "0.3.31"] - [org.slf4j/slf4j-api "2.0.0-alpha5"] + :dependencies [[org.clojure/clojure "1.11.1"] + [io.github.erdos/stencil-core "0.5.1-SNAPSHOT"] + [org.slf4j/slf4j-api "2.0.0-alpha7"] + [org.mozilla/rhino-engine "1.7.14"] [http-kit "2.5.0"] - [ring/ring-json "0.4.0"]] + [ring/ring-json "0.5.0"]] :aot :all :main stencil.service) diff --git a/service/src/stencil/service.clj b/service/src/stencil/service.clj index 255f06c6..7b644923 100644 --- a/service/src/stencil/service.clj +++ b/service/src/stencil/service.clj @@ -3,10 +3,11 @@ (:import [java.io File]) (:require [org.httpkit.server :refer [run-server]] [stencil.api :as api] + [stencil.functions] [stencil.log :as log] [stencil.slf4j :as slf4j] [clojure.data :refer [diff]] - [clojure.java.io :refer [file]] + [clojure.java.io :as io :refer [file]] [ring.middleware.json :refer [wrap-json-body]])) (set! *warn-on-reflection* true) @@ -23,6 +24,21 @@ dir) (throw (ex-info "Missing STENCIL_TEMPLATE_DIR property!" {})))) +(defn eval-js-file [] + (let [f (file (get-template-dir) "stencil.js")] + (when (.exists f) + (log/info "Evaluating stencil.js file") + (let [manager (new javax.script.ScriptEngineManager) + engine (.getEngineByName manager "rhino") + invocable ^javax.script.Invocable engine + context (.getContext engine)] + (with-open [r (io/reader f)] + (.eval engine r context)) + (doseq [[k] (.getBindings context javax.script.ScriptContext/ENGINE_SCOPE)] + (log/info "Defining function created in stencil.js file: {}" k) + (defmethod stencil.functions/call-fn k [k & args] + (.invokeFunction invocable k (into-array Object args)))))))) + (def -prepared "Map of {file-name {timestamp prepared}}." (doto (atom {}) @@ -47,6 +63,9 @@ (prepared template) (throw (ex-info "Template file does not exist!" {:status 404}))))) +(defn- exception->str [e] + (clojure.string/join "\n" (for [e (iterate #(.getCause %) e) :while e] (.getMessage e)))) + (defn wrap-err [handler] (fn [request] (try (handler request) @@ -56,7 +75,10 @@ {:status status :body (str "ERROR: " (.getMessage e))}) (do (log/error "Error" e) - (throw e))))))) + (throw e)))) + (catch Exception e + {:status 400 + :body (exception->str e)})))) (defn- wrap-log [handler] (fn [req] @@ -94,6 +116,7 @@ (defn -main [& args] (log/info "Starting Stencil Service {}" api/version) + (eval-js-file) (let [http-port (get-http-port) template-dir ^File (get-template-dir) server (run-server app {:port http-port})] diff --git a/service/src/stencil/slf4j.clj b/service/src/stencil/slf4j.clj index ab9a5372..682fd810 100644 --- a/service/src/stencil/slf4j.clj +++ b/service/src/stencil/slf4j.clj @@ -22,7 +22,7 @@ (defn- msg+args [^String msg args] (if (empty? args) msg - (apply str (interleave (.split msg "\\{\\}" -1) (map str args))))) + (apply str (interleave (.split msg "\\{\\}" -1) args)))) (deftype StencilLoggerFactory [] org.slf4j.ILoggerFactory diff --git a/src/stencil/api.clj b/src/stencil/api.clj index 734a02bd..8ef589f0 100644 --- a/src/stencil/api.clj +++ b/src/stencil/api.clj @@ -2,20 +2,14 @@ "A simple public API for document generation from templates." (:require [clojure.walk :refer [stringify-keys]] [clojure.java.io :as io] - [clojure.pprint :refer [simple-dispatch]] [stencil.types]) (:import [io.github.erdos.stencil API PreparedFragment PreparedTemplate TemplateData] - [stencil.types OpenTag CloseTag TextTag] [java.util Map])) (set! *warn-on-reflection* true) (set! *unchecked-math* :warn-on-boxed) -(defmethod simple-dispatch OpenTag [t] (print (str "<" (:open t) ">"))) -(defmethod simple-dispatch CloseTag [t] (print (str ""))) -(defmethod simple-dispatch TextTag [t] (print (str "'" (:text t) "'"))) - (defn ^PreparedTemplate prepare "Creates a prepared template instance from an input document." [input] diff --git a/src/stencil/cleanup.clj b/src/stencil/cleanup.clj index 39055c4e..78336a79 100644 --- a/src/stencil/cleanup.clj +++ b/src/stencil/cleanup.clj @@ -8,12 +8,10 @@ valid XML String -> tokens -> Annotated Control AST -> Normalized Control AST -> Evaled AST -> Hiccup or valid XML String " (:require [stencil.util :refer [mod-stack-top-conj mod-stack-top-last parsing-exception stacks-difference-key]] - [stencil.types :refer [open-tag close-tag ->CloseTag]])) + [stencil.types :refer [open-tag close-tag]])) (set! *warn-on-reflection* true) -(declare control-ast-normalize) - (defn- tokens->ast-step [[queue & ss0 :as stack] token] (case (:cmd token) (:if :for) (conj (mod-stack-top-conj stack token) []) @@ -22,14 +20,14 @@ (if (empty? ss0) (throw (parsing-exception (str open-tag "else" close-tag) "Unexpected {%else%} tag, it must come right after a condition!")) - (conj (mod-stack-top-last ss0 update :blocks (fnil conj []) {:children queue}) [])) + (conj (mod-stack-top-last ss0 update ::blocks (fnil conj []) {::children queue}) [])) :else-if (if (empty? ss0) - (throw (parsing-exception (str open-tag "else" close-tag) - "Unexpected {%else%} tag, it must come right after a condition!")) + (throw (parsing-exception (str open-tag "else if" close-tag) + "Unexpected {%else if%} tag, it must come right after a condition!")) (-> ss0 - (mod-stack-top-last update :blocks (fnil conj []) {:children queue}) + (mod-stack-top-last update ::blocks (fnil conj []) {::children queue}) (conj [(assoc token :cmd :if :r true)]) (conj []))) @@ -38,7 +36,7 @@ (throw (parsing-exception (str open-tag "end" close-tag) "Too many {%end%} tags!")) (loop [[queue & ss0] stack] - (let [new-stack (mod-stack-top-last ss0 update :blocks conj {:children queue})] + (let [new-stack (mod-stack-top-last ss0 update ::blocks conj {::children queue})] (if (:r (peek (first new-stack))) (recur (mod-stack-top-last new-stack dissoc :r)) new-stack)))) @@ -55,88 +53,87 @@ "Missing {%end%} tag from document!")) (first result)))) -(defn nested-tokens-fmap-postwalk - "Melysegi bejaras egy XML fan. - - https://en.wikipedia.org/wiki/Depth-first_search" - [f-cmd-block-before f-cmd-block-after f-child nested-tokens] - (let [update-children - #(update % :children - (partial nested-tokens-fmap-postwalk - f-cmd-block-before f-cmd-block-after - f-child))] - (vec - (for [token nested-tokens] - (if (:cmd token) - (update token :blocks - (partial mapv - (comp (partial f-cmd-block-after token) - update-children - (partial f-cmd-block-before token)))) - (f-child token)))))) +(defn- nested-tokens-fmap-postwalk + "Depth-first traversal of the tree." + [f-cmd-block-before f-cmd-block-after f-child node] + (assert (map? node)) + (letfn [(children-mapper [children] + (mapv update-blocks children)) + (update-children [node] + (update node ::children children-mapper)) + (visit-block [block] + (-> block f-cmd-block-before update-children f-cmd-block-after)) + (blocks-mapper [blocks] + (mapv visit-block blocks)) + (update-blocks [node] + (if (:cmd node) + (update node ::blocks blocks-mapper) + (f-child node)))] + (update-blocks node))) (defn annotate-environments - "Vegigmegy minden tokenen es a parancs blokkok :before es :after kulcsaiba - beleteszi az adott token kornyezetet." + "Puts the context of each element into its :before and :after keys." [control-ast] + (assert (sequential? control-ast)) (let [stack (volatile! ())] - (nested-tokens-fmap-postwalk - (fn before-cmd-block [_ block] - (assoc block :before @stack)) - - (fn after-cmd-block [_ block] - (let [stack-before (:before block) - [a b] (stacks-difference-key :open stack-before @stack)] - (assoc block :before a :after b))) - - (fn child [item] - (cond - (:open item) - (vswap! stack conj item) - - (:close item) - (if (= (:close item) (:open (first @stack))) - (vswap! stack next) - (throw (ex-info "Unexpected stack state" {:stack @stack, :item item})))) - item) - control-ast))) + (mapv (partial nested-tokens-fmap-postwalk + (fn before-cmd-block [block] + (assoc block ::before @stack)) + + (fn after-cmd-block [block] + (let [stack-before (::before block) + [a b] (stacks-difference-key :open stack-before @stack)] + (assoc block ::before a ::after b))) + + (fn child [item] + (cond + (:open item) + (vswap! stack conj item) + + (:close item) + (if (= (:close item) (:open (first @stack))) + (vswap! stack next) + (throw (ex-info "Unexpected stack state" {:stack @stack, :item item})))) + item)) + control-ast))) (defn stack-revert-close - "Megfordítja a listát es az :open elemeket :close elemekre kicseréli." - [stack] (reduce (fn [stack item] (if (:open item) (conj stack (->CloseTag (:open item))) stack)) () stack)) + "Creates a seq of :close tags for each :open tag in the list in reverse order." + [stack] + (into () (comp (keep :open) (map #(do {:close %}))) stack)) ;; egy {:cmd ...} parancs objektumot kibont: ;; a :blocks kulcs alatt levo elemeket normalizalja es specialis kulcsok alatt elhelyezi ;; igy amikor vegrehajtjuk a parancs objektumot, akkor az eredmeny is ;; valid fa lesz, tehat a nyito-bezaro tagek helyesen fognak elhelyezkedni. -(defmulti control-ast-normalize-step :cmd) +(defmulti control-ast-normalize :cmd) ;; Itt nincsen blokk, amit normalizálni kellene -(defmethod control-ast-normalize-step :echo [echo-command] echo-command) +(defmethod control-ast-normalize :echo [echo-command] echo-command) -(defmethod control-ast-normalize-step :cmd/include [include-command] +(defmethod control-ast-normalize :cmd/include [include-command] (if-not (string? (:name include-command)) (throw (parsing-exception (pr-str (:name include-command)) "Parameter of include call must be a single string literal!")) include-command)) ;; A feltételes elágazásoknál mindig generálunk egy javított THEN ágat -(defmethod control-ast-normalize-step :if [control-ast] - (case (count (:blocks control-ast)) - 2 (let [[then else] (:blocks control-ast) - then2 (concat (keep control-ast-normalize (:children then)) - (stack-revert-close (:before else)) - (:after else)) - else2 (concat (stack-revert-close (:before then)) - (:after then) - (keep control-ast-normalize (:children else)))] - (-> (dissoc control-ast :blocks) +(defmethod control-ast-normalize :if [control-ast] + (case (count (::blocks control-ast)) + 2 (let [[then else] (::blocks control-ast) + then2 (concat (map control-ast-normalize (::children then)) + (stack-revert-close (::before else)) + (::after else)) + else2 (concat (stack-revert-close (::before then)) + (::after then) + (map control-ast-normalize (::children else)))] + (-> (dissoc control-ast ::blocks) (assoc :then (vec then2) :else (vec else2)))) - 1 (let [[then] (:blocks control-ast) - else (:after then)] - (-> (dissoc control-ast :blocks) - (assoc :then (vec (keep control-ast-normalize (:children then))) :else else))) + 1 (let [[then] (::blocks control-ast) + else (::after then)] + (-> (dissoc control-ast ::blocks) + (assoc :then (mapv control-ast-normalize (::children then)) :else (vec else)))) ;; default (throw (parsing-exception (str open-tag "else" close-tag) "Too many {%else%} tags in one condition!")))) @@ -146,79 +143,73 @@ ;; - body-run-once: a body resz eloszor fut le, ha a lista legalabb egy elemu ;; - body-run-next: a body resz masodik, harmadik, stb. beillesztese, haa lista legalabb 2 elemu. ;; Ezekbol az esetekbol kell futtataskor a megfelelo(ket) kivalasztani es behelyettesiteni. -(defmethod control-ast-normalize-step :for [control-ast] - (when-not (= 1 (count (:blocks control-ast))) +(defmethod control-ast-normalize :for [control-ast] + (when-not (= 1 (count (::blocks control-ast))) (throw (parsing-exception (str open-tag "else" close-tag) "Unexpected {%else%} in a loop!"))) - (let [[{:keys [children before after]}] (:blocks control-ast) - children (keep control-ast-normalize children)] + (let [[{::keys [children before after]}] (::blocks control-ast) + children (mapv control-ast-normalize children)] (-> control-ast - (dissoc :blocks) + (dissoc ::blocks) (assoc :body-run-none (vec (concat (stack-revert-close before) after)) :body-run-once (vec children) :body-run-next (vec (concat (stack-revert-close after) before children)))))) -(defn control-ast-normalize - "Mélységi bejárással rekurzívan normalizálja az XML fát." - [control-ast] - (cond - (vector? control-ast) (vec (flatten (keep control-ast-normalize control-ast))) - (:text control-ast) control-ast - (:open control-ast) control-ast - (:close control-ast) control-ast - (:cmd control-ast) (control-ast-normalize-step control-ast) - (:open+close control-ast) control-ast - :else (throw (ex-info (str "Unexpected object: " (type control-ast)) {:ast control-ast})))) +(defmethod control-ast-normalize :default [control-ast] + (assert (not (::blocks control-ast))) + control-ast) (defn find-variables [control-ast] ;; meg a normalizalas lepes elott ;; amikor van benne blocks ;; mapping: {Sym -> Str} (letfn [(resolve-sym [mapping s] - (assert (map? mapping)) - (assert (symbol? s)) - ;; megprobal egy adott szimbolumot a mapping alapjan rezolvalni. - ;; visszaad egy stringet - (if (.contains (name s) ".") - (let [[p1 p2] (vec (.split (name s) "\\." 2))] - (if-let [pt (mapping (symbol p1))] - (str pt "." p2) - (name s))) - (mapping s (name s)))) - (expr [mapping rpn] - (assert (sequential? rpn)) ;; RPN kifejezes kell legyen - (keep (partial resolve-sym mapping) (filter symbol? rpn))) - ;; iff rpn expr consists of 1 variable only -> resolves that one variable. - (maybe-variable [mapping rpn] - (when (and (= 1 (count rpn)) (symbol? (first rpn))) - (resolve-sym mapping (first rpn)))) + (assert (map? mapping)) + (assert (symbol? s)) + (mapping s (name s))) + (expr [mapping e] + (cond (symbol? e) [(resolve-sym mapping e)] + (not (sequential? e)) nil + (= :fncall (first e)) (mapcat (partial expr mapping) (nnext e)) + (= :get (first e)) (let [[ss rest] (split-with string? (nnext e))] + (cons + (reduce (fn [root item] (str root "." item)) + (resolve-sym mapping (second e)) + ss) + (mapcat (partial expr mapping) rest))) + :else (mapcat (partial expr mapping) (next e)))) + (maybe-variable [mapping e] + (cond (symbol? e) + (resolve-sym mapping e) + (and (sequential? e) (= :get (first e)) (symbol? (second e)) (every? string? (nnext e))) + (reduce (fn [a b] (str a "." b)) (resolve-sym mapping (second e)) (nnext e)))) (collect [m xs] (mapcat (partial collect-1 m) xs)) (collect-1 [mapping x] (case (:cmd x) :echo (expr mapping (:expression x)) :if (concat (expr mapping (:condition x)) - (collect mapping (apply concat (:blocks x)))) + (collect mapping (apply concat (::blocks x)))) :for (let [variable (maybe-variable mapping (:expression x)) exprs (expr mapping (:expression x)) mapping (if variable (assoc mapping (:variable x) (str variable "[]")) mapping)] - (concat exprs (collect mapping (apply concat (:blocks x))))) + (concat exprs (collect mapping (apply concat (::blocks x))))) []))] (distinct (collect {} control-ast)))) (defn- find-fragments [control-ast] ;; returns a set of fragment names use in this document - (set (for [item (tree-seq map? (comp flatten :blocks) {:blocks [control-ast]}) + (set (for [item (tree-seq map? (comp flatten ::blocks) {::blocks [control-ast]}) :when (map? item) :when (= :cmd/include (:cmd item))] (:name item)))) (defn process [raw-token-seq] (let [ast (tokens->ast raw-token-seq) - executable (control-ast-normalize (annotate-environments ast))] + executable (mapv control-ast-normalize (annotate-environments ast))] {:variables (find-variables ast) :fragments (find-fragments ast) :dynamic? (boolean (some :cmd executable)) diff --git a/src/stencil/eval.clj b/src/stencil/eval.clj index 02993e69..7f239590 100644 --- a/src/stencil/eval.clj +++ b/src/stencil/eval.clj @@ -4,6 +4,7 @@ [stencil.infix :refer [eval-rpn]] [stencil.types :refer [control?]] [stencil.tokenizer :as tokenizer] + [stencil.util :refer [eval-exception]] [stencil.tree-postprocess :as tree-postprocess])) (set! *warn-on-reflection* true) @@ -12,35 +13,45 @@ (defmethod eval-step :default [_ _ item] [item]) +(defn normal-control-ast->evaled-seq [data function items] + (assert (map? data)) + (assert (ifn? function)) + (assert (or (nil? items) (sequential? items))) + (eduction (mapcat (partial eval-step function data)) items)) + +(defn- eval-rpn* [data function expr raw-expr] + (try (eval-rpn data function expr) + (catch Exception e + (throw (eval-exception (str "Error evaluating expression: " raw-expr) e))))) + (defmethod eval-step :if [function data item] - (let [condition (eval-rpn data function (:condition item))] + (let [condition (eval-rpn* data function (:condition item) (:raw item))] (log/trace "Condition {} evaluated to {}" (:condition item) condition) - (if condition - (mapcat (partial eval-step function data) (:then item)) - (mapcat (partial eval-step function data) (:else item))))) + (->> (if condition (:then item) (:else item)) + (normal-control-ast->evaled-seq data function)))) (defmethod eval-step :echo [function data item] - (let [value (eval-rpn data function (:expression item))] + (let [value (eval-rpn* data function (:expression item) (:raw item))] (log/trace "Echoing {} as {}" (:expression item) value) [{:text (if (control? value) value (str value))}])) (defmethod eval-step :for [function data item] - (let [items (seq (eval-rpn data function (:expression item)))] + (let [items (eval-rpn* data function (:expression item) (:raw item))] (log/trace "Loop on {} will repeat {} times" (:expression item) (count items)) - (if (seq items) - (let [datas (map #(assoc data (name (:variable item)) %) items) + (if (not-empty items) + (let [index-var-name (name (:index-var item)) + loop-var-name (name (:variable item)) + datamapper (fn [key val] (assoc data, index-var-name key, loop-var-name val)) + datas (if (or (instance? java.util.Map items) (map? items)) + (map datamapper (keys items) (vals items)) + (map-indexed datamapper items)) bodies (cons (:body-run-once item) (repeat (:body-run-next item)))] - (mapcat (fn [data body] (mapcat (partial eval-step function data) body)) datas bodies)) + (mapcat (fn [data body] (normal-control-ast->evaled-seq data function body)) datas bodies)) (:body-run-none item)))) -(defn normal-control-ast->evaled-seq [data function items] - (assert (map? data)) - (assert (ifn? function)) - (assert (or (nil? items) (sequential? items))) - (mapcat (partial eval-step function data) items)) - (defn eval-executable [part data functions] - (assert (:executable part)) - (tree-postprocess/postprocess - (tokenizer/tokens-seq->document - (normal-control-ast->evaled-seq data functions (:executable part))))) + (->> (:executable part) + (#(doto % assert)) + (normal-control-ast->evaled-seq data functions) + (tokenizer/tokens-seq->document) + (tree-postprocess/postprocess))) diff --git a/src/stencil/functions.clj b/src/stencil/functions.clj index 01360417..d30721b9 100644 --- a/src/stencil/functions.clj +++ b/src/stencil/functions.clj @@ -1,7 +1,8 @@ (ns stencil.functions "Function definitions" (:require [clojure.string] - [stencil.types :refer [->HideTableColumnMarker ->HideTableRowMarker]] + [stencil.ooxml :as ooxml] + [stencil.types :refer [->HideTableColumnMarker ->HideTableRowMarker ->FragmentInvoke]] [stencil.util :refer [fail find-first]])) (set! *warn-on-reflection* true) @@ -18,7 +19,7 @@ ([_ x y z] (range x y z))) (defmethod call-fn "integer" [_ n] (some-> n biginteger)) -(defmethod call-fn "decimal" [_ f] (some-> f bigdec)) +(defmethod call-fn "decimal" [_ f] (with-precision 8 (some-> f bigdec))) ;; The format() function calls java.lang.String.format() ;; but it predicts the argument types from the format string and @@ -50,7 +51,7 @@ (string? value) (first value) :else (char (int value))) ("d" "o" "x" "X") (some-> value biginteger) - ("e" "E" "f" "g" "G" "a" "A") (some-> value bigdec) + ("e" "E" "f" "g" "G" "a" "A") (with-precision 8 (some-> value bigdec)) value))) (to-array) (String/format locale pattern-str))))) @@ -97,7 +98,7 @@ (instance? java.util.List e)))] (fail "Wrong data, expected sequence, got: " {:data e})) (mapcat seq elems)) - (do (doseq [e elems :when (not (or (nil? e) (map? e)))] + (do (doseq [e elems :when (not (or (nil? e) (map? e) (instance? java.util.Map e)))] (fail "Wrong data, expected map, got: " {:data e})) (keep (partial lookup p) elems)))) data @@ -108,3 +109,11 @@ 0 "" 1 (str (first elements)) (str (clojure.string/join separator1 (butlast elements)) separator2 (last elements)))) + +(defmethod call-fn "replace" [_ text pattern replacement] + (clojure.string/replace (str text) (str pattern) (str replacement))) + +;; inserts a page break at the current run. +(let [br {:tag ooxml/br :attrs {ooxml/type "page"}} + page-break (->FragmentInvoke {:frag-evaled-parts [br]})] + (defmethod call-fn "pageBreak" [_] page-break)) diff --git a/src/stencil/grammar.clj b/src/stencil/grammar.clj new file mode 100644 index 00000000..cf8be23b --- /dev/null +++ b/src/stencil/grammar.clj @@ -0,0 +1,89 @@ +(ns stencil.grammar) + +(defn- guarded [pred] + (fn [t] + (when (pred (first t)) + [(first t) (next t)]))) + +;; left-associative chained infix expression +(defn- chained [reader reader* reducer] + (fn [tokens] chained + (when-let [[result tokens] (reader tokens)] + (loop [tokens tokens + result result] + (if (empty? tokens) + [result nil] + (if-let [[fs tokens] (reader* tokens)] + (recur tokens (reducer result fs)) + [result tokens])))))) + +(defn- read-or-throw [reader tokens] + (or (reader tokens) + (throw (ex-info (str "Invalid stencil expression!") {:reader reader :prefix tokens})))) + +(defn- all [condition & readers] + (fn [tokens] + (when-let [[result tokens] (condition tokens)] + (reduce (fn [[result tokens] reader] + (let [[r tokens] (read-or-throw reader tokens)] + [(conj result r) tokens])) + [[result] tokens] readers)))) + +(defmacro ^:private grammar [bindings body] + `(letfn* [~@(for [[k v] (partition 2 bindings), x [k (list 'fn '[%] (list v '%))]] x)] ~body)) + +(defn- mapping [reader mapper] + (fn [tokens] + (when-let [[result tokens] (reader tokens)] + [(mapper result) tokens]))) + +(defn- parenthesed [reader] + (mapping (all (guarded #{:open}) reader (guarded #{:close})) second)) + +(defn- op-chain [operand operator] + (chained operand (all operator operand) (fn [a [op b]] (list op a b)))) + +(defn- op-chain-r [operand operator] + (mapping (chained (all operand) (all operator operand) (fn [a [op b]] (list* b op a))) + (fn [a] (reduce (fn [a [op c]] [op c a]) (first a) (partition 2 (next a)))))) + +(defn- at-least-one [reader] + (fn [tokens] + (when-let [[result tokens] (reader tokens)] + (loop [tokens tokens, result [result]] + (if-let [[res tokens] (reader tokens)] + (recur tokens (conj result res)) + [result tokens]))))) + +(defn- optional [reader] ;; always matches + (fn [t] (or (reader t) [nil t]))) + +(def testlang + (grammar [val (some-fn iden-or-fncall + (parenthesed expression) + (guarded number?) + (guarded string?)) + iden (guarded symbol?) + dotted (mapping (all (guarded #{:dot}) iden) (comp name second)) + bracketed (mapping (all (guarded #{:open-bracket}) expression (guarded #{:close-bracket})) second) + args (mapping (optional (chained (all expression) (all (guarded #{:comma}) expression) into)) + (fn [x] (take-nth 2 x))) + args-suffix (parenthesed args) + iden-or-fncall (mapping (all iden (optional args-suffix)) + (fn [[id xs]] (if xs (list* :fncall id xs) id))) + accesses (mapping (all val (optional (at-least-one (some-fn bracketed dotted)))) + (fn [[id chain]] (if chain (list* :get id chain) id))) + neg (some-fn (all (guarded #{:minus}) neg) accesses) + not (some-fn (all (guarded #{:not}) not) neg) + pow (op-chain-r not (guarded #{:power})) + mul (op-chain pow (guarded #{:times :divide :mod})) + add (op-chain mul (guarded #{:plus :minus})) + cmp (op-chain add (guarded #{:lt :gt :lte :gte})) + cmpe (op-chain cmp (guarded #{:eq :neq})) + and (op-chain cmpe (guarded #{:and})) + or (op-chain and (guarded #{:or})) + expression or] + expression)) + +(defn runlang [grammar input] + (ffirst (read-or-throw (all grammar {nil []}) input))) \ No newline at end of file diff --git a/src/stencil/infix.clj b/src/stencil/infix.clj index 06fd94bb..5767ab6a 100644 --- a/src/stencil/infix.clj +++ b/src/stencil/infix.clj @@ -2,16 +2,14 @@ "Parsing and evaluating infix algebraic expressions. https://en.wikipedia.org/wiki/Shunting-yard_algorithm" - (:require [stencil.util :refer [fail update-peek ->int]] - [stencil.log :as log] - [stencil.functions :refer [call-fn]])) + (:require [stencil.util :refer [->int string whitespace?]] + [stencil.functions :refer [call-fn]] + [stencil.grammar :as grammar])) (set! *warn-on-reflection* true) (def ^:dynamic ^:private *calc-vars* {}) -(defrecord FnCall [fn-name]) - (def ops {\+ :plus \- :minus @@ -28,7 +26,10 @@ \< :lt \> :gt \& :and - \| :or}) + \| :or + \, :comma \; :comma + \. :dot + }) (def ops2 {[\> \=] :gte [\< \=] :lte @@ -43,36 +44,7 @@ (def identifier "Characters found in an identifier" - (set "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM_.1234567890")) - -(def operation-tokens - "Operator precedences. - - source: http://en.cppreference.com/w/cpp/language/operator_precedence - " - {:open -999 - ;;;:close -998 - :comma -998 - :open-bracket -999 - - :or -21 - :and -20 - - :eq -10 :neq -10, - - :lt -9 :gt -9 :lte -9 :gte -9 - - :plus 2 :minus 2 - :times 3 :divide 4 - :power 5 - :not 6 - :neg 7}) - -(defn- precedence [token] - (get operation-tokens token)) - -(defn- associativity [token] - (if (#{:power :not :neg} token) :right :left)) + (set "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM_1234567890")) (def ^:private quotation-marks {\" \" ;; programmer quotes @@ -88,178 +60,87 @@ - First elem is read string literal. - Second elem is seq of remaining characters." [characters] - (let [until (quotation-marks (first characters)) - sb (new StringBuilder)] - (loop [[c & cs] (next characters)] - (cond (nil? c) (throw (ex-info "String parse error" - {:reason "Unexpected end of stream"})) - (= c until) [(.toString sb) cs] - (= c (first "\\")) (do (.append sb (first cs)) (recur (next cs))) - :else (do (.append sb c) (recur cs)))))) + (when-let [until (quotation-marks (first characters))] + (let [sb (new StringBuilder)] + (loop [[c & cs] (next characters)] + (cond (nil? c) (throw (ex-info "String parse error" + {:reason "Unexpected end of stream"})) + (= c until) [(.toString sb) cs] + (= c (first "\\")) (do (.append sb (first cs)) (recur (next cs))) + :else (do (.append sb c) (recur cs))))))) (defn read-number "Reads a number literal from a sequence. Returns a tuple of read number (Double or Long) and the sequence of remaining characters." [characters] - (let [content (take-while (set "1234567890._") characters) - content ^String (apply str content) - content (.replaceAll content "_" "") - number (if (some #{\.} content) - (Double/parseDouble content) - (Long/parseLong content))] - [number (drop (count content) characters)])) - -(defn tokenize - "Returns a sequence of tokens for an input string" - [original-string] - (loop [[first-char & next-chars :as characters] (str original-string) - tokens []] - (cond - (empty? characters) - tokens - - (contains? #{\space \tab \newline} first-char) - (recur next-chars tokens) - - (contains? #{\, \;} first-char) - (recur next-chars (conj tokens :comma)) - - (contains? ops2 [first-char (first next-chars)]) - (recur (next next-chars) (conj tokens (ops2 [first-char (first next-chars)]))) - - (and (= \- first-char) (or (nil? (peek tokens)) (and (not= (peek tokens) :close) (keyword? (peek tokens))))) - (recur next-chars (conj tokens :neg)) - - (contains? ops first-char) - (recur next-chars (conj tokens (ops first-char))) - - (contains? digits first-char) - (let [[n tail] (read-number characters)] - (recur tail (conj tokens n))) - - (quotation-marks first-char) - (let [[s tail] (read-string-literal characters)] - (recur tail (conj tokens s))) - - :else - (let [content (apply str (take-while identifier characters))] - (if (seq content) - (let [tail (drop-while #{\space \tab} (drop (count content) characters))] - (if (= \( (first tail)) - (recur (next tail) (conj tokens (->FnCall content))) - (recur tail (conj tokens (symbol content))))) - (throw (ex-info (str "Unexpected character: " first-char) - {:character first-char}))))))) + (when (contains? digits (first characters)) + (let [content (string (take-while (set "1234567890._")) characters) + content (.replaceAll content "_" "") + number (if (some #{\.} content) + (Double/parseDouble content) + (Long/parseLong content))] + [number (drop (count content) characters)]))) -;; throws ExceptionInfo when token sequence has invalid elems -(defn- validate-tokens [tokens] - (cond - (some true? (map #(and (or (symbol? %1) (number? %1) (#{:close} %1)) - (or (symbol? %2) (number? %2) (#{:open} %2))) - tokens (next tokens))) - (throw (ex-info "Could not parse!" {})) +(defn- read-ops2 [chars] + (when-let [op (get ops2 [(first chars) (second chars)])] + [op (nnext chars)])) - :else - tokens)) +(defn- read-ops1 [chars] + (when-let [op (get ops (first chars))] + [op (next chars)])) -(defn tokens->rpn - "Classic Shunting-Yard Algorithm extension to handle vararg fn calls." - [tokens] - (loop [[e0 & next-expr :as expr] tokens ;; bemeneti token lista - opstack () ;; stack of Shunting-Yard Algorithm - result [] ;; Vector of output tokens +(defn- read-iden [characters] + (when-let [content (not-empty (string (take-while identifier) characters))] + [(symbol content) (drop (count content) characters)])) +(def token-readers (some-fn read-number read-string-literal read-iden read-ops2 read-ops1)) - parentheses 0 ;; count of open parentheses - ;; on a function call we save function name here - functions ()] - (cond - (neg? parentheses) - (throw (ex-info "Parentheses are not balanced!" {})) - - (empty? expr) - (if (zero? parentheses) - (into result (remove #{:open} opstack)) - (throw (ex-info "Too many open parentheses!" {}))) - - (number? e0) - (recur next-expr opstack (conj result e0) parentheses functions) - - (symbol? e0) - (recur next-expr opstack (conj result e0) parentheses functions) - - (string? e0) - (recur next-expr opstack (conj result e0) parentheses functions) - - (= :open e0) - (recur next-expr (conj opstack :open) result (inc parentheses) (conj functions nil)) - - (= :open-bracket e0) - (recur next-expr (conj opstack :open-bracket) result (inc parentheses) functions) - - (instance? FnCall e0) - (recur next-expr (conj opstack :open) result - (inc parentheses) - (conj functions {:fn (:fn-name e0) - :args (if (= :close (first next-expr)) 0 1)})) - ;; (recur next-expr (conj opstack :fncall) result (conj functions {:fn e0})) - - (= :close-bracket e0) - (let [[popped-ops [_ & keep-ops]] - (split-with (partial not= :open-bracket) opstack)] - (recur next-expr - keep-ops - (into result (concat popped-ops [:get])) - (dec parentheses) - functions)) - - (= :close e0) - (let [[popped-ops [_ & keep-ops]] - (split-with (partial not= :open) opstack)] - (recur next-expr - keep-ops - (into result - (concat - (remove #{:comma} popped-ops) - (some-> functions first vector))) - (dec parentheses) - (next functions))) - - (empty? next-expr) ;; current is operator but without an operand - (throw (ex-info "Missing operand!" {})) - - :else ;; operator - (let [[popped-ops keep-ops] - (split-with #(if (= :left (associativity e0)) - (<= (precedence e0) (precedence %)) - (< (precedence e0) (precedence %))) opstack)] - (recur next-expr - (conj keep-ops e0) - (into result (remove #{:open :comma} popped-ops)) - parentheses - (if (= :comma e0) - (if (first functions) - (update-peek functions update :args inc) - (throw (ex-info "Unexpected ',' character!" {}))) - functions)))))) - -(defn- reduce-step-dispatch [_ cmd] - (cond (string? cmd) :string - (number? cmd) :number - (symbol? cmd) :symbol - (keyword? cmd) cmd - (map? cmd) FnCall - :else (fail "Unexpected opcode!" {:opcode cmd}))) - -(defmulti ^:private reduce-step reduce-step-dispatch) -(defmulti ^:private action-arity (partial reduce-step-dispatch [])) - -;; throws exception if there are operators out of place, returns input otherwise -(defn- validate-rpn [rpn] - (let [steps (map #(- 1 (action-arity %)) rpn)] - (if (or (not-every? pos? (reductions + steps)) (not (= 1 (reduce + steps)))) - (throw (ex-info (str "Wrong tokens, unsupported arities: " rpn) {:rpn rpn})) - rpn))) +(defn tokenize + "Returns a sequence of tokens for an input string" + [text] + (when-let [text (seq (drop-while (comp whitespace? char) text))] + (if-let [[token tail] (token-readers text)] + (cons token (lazy-seq (tokenize tail))) + (throw (ex-info "Unexpected end of string" {:index (.index ^clojure.lang.IndexedSeq text)}))))) + +(defmulti eval-tree (fn [tree] (if (sequential? tree) (first tree) (type tree)))) + +(defmethod eval-tree java.lang.Number [tree] tree) +(defmethod eval-tree String [s] s) +(defmethod eval-tree clojure.lang.Symbol [s] (get-in *calc-vars* (vec (.split (name s) "\\.")))) + +(defmethod eval-tree :eq [[_ a b]] (= (eval-tree a) (eval-tree b))) +(defmethod eval-tree :neq [[_ a b]] (not= (eval-tree a) (eval-tree b))) +(defmethod eval-tree :plus [[_ a b]] + (let [a (eval-tree a) b (eval-tree b)] + (if (or (string? a) (string? b)) + (str a b) + (+ a b)))) +(defmethod eval-tree :minus [[_ a b :as expr]] + (if (= 2 (count expr)) + (- (eval-tree a)) + (- (eval-tree a) (eval-tree b)))) +(defmethod eval-tree :times [[_ a b]] (* (eval-tree a) (eval-tree b))) +(defmethod eval-tree :divide [[_ a b]] (with-precision 8 (/ (eval-tree a) (eval-tree b)))) + +(defmethod eval-tree :or [[_ a b]] (or (eval-tree a) (eval-tree b))) +(defmethod eval-tree :and [[_ a b]] (and (eval-tree a) (eval-tree b))) +(defmethod eval-tree :mod [[_ a b]] (mod (eval-tree a) (eval-tree b))) +(defmethod eval-tree :power [[_ a b]] (Math/pow (eval-tree a) (eval-tree b))) +(defmethod eval-tree :not [[_ a]] (not (eval-tree a))) + +(defmethod eval-tree :gte [[_ a b]] (>= (eval-tree a) (eval-tree b))) +(defmethod eval-tree :lte [[_ a b]] (<= (eval-tree a) (eval-tree b))) +(defmethod eval-tree :gt [[_ a b]] (> (eval-tree a) (eval-tree b))) +(defmethod eval-tree :lt [[_ a b]] (< (eval-tree a) (eval-tree b))) + +(defmethod eval-tree :get [[_ m & path]] + (reduce (fn [b a] + (cond (sequential? b) (when (number? a) (get b (->int a))) + (string? b) (when (number? a) (get b (->int a))) + (instance? java.util.List b) (when (number? a) (.get ^java.util.List b (->int a))) + :else (get b (str a)))) + (eval-tree m) (map eval-tree path))) (defmethod call-fn :default [fn-name & args-seq] (if-let [default-fn (::functions *calc-vars*)] @@ -270,65 +151,22 @@ ;; Example: you can write data()['key1']['key2'] instead of key1.key2. (defmethod call-fn "data" [_] *calc-vars*) -(defmethod action-arity FnCall [{:keys [args]}] args) - -(defmethod reduce-step FnCall [stack {:keys [fn args]}] - (try - (log/trace "Calling function {} with arguments {}" fn args) - (let [[ops new-stack] (split-at args stack) - ops (reverse ops) - result (apply call-fn fn ops)] - (log/trace "Result was {}" result) - (conj new-stack result)) - (catch clojure.lang.ArityException e - (throw (ex-info (str "Wrong arity: " (.getMessage e)) - {:fn fn :expected args :got (count ops) :ops (vec ops)}))))) - -(defmacro def-reduce-step [cmd args body] - (assert (keyword? cmd)) - (assert (every? symbol? args)) - `(do (defmethod action-arity ~cmd [_#] ~(count args)) - (defmethod reduce-step ~cmd [[~@args ~'& stack#] action#] - (let [~'+action+ action#] (conj stack# ~body))))) - -(def-reduce-step :string [] +action+) -(def-reduce-step :number [] +action+) -(def-reduce-step :symbol [] (get-in *calc-vars* (vec (.split (name +action+) "\\.")))) - -(def-reduce-step :neg [s0] (- s0)) -(def-reduce-step :times [s0 s1] (* s0 s1)) -(def-reduce-step :divide [s0 s1] (/ s1 s0)) -(def-reduce-step :plus [s0 s1] (if (or (string? s0) (string? s1)) (str s1 s0) (+ s1 s0))) -(def-reduce-step :minus [s0 s1] (- s1 s0)) -(def-reduce-step :eq [a b] (= a b)) -(def-reduce-step :or [a b] (or b a)) -(def-reduce-step :not [b] (not b)) -(def-reduce-step :and [a b] (and b a)) -(def-reduce-step :neq [a b] (not= a b)) -(def-reduce-step :mod [s0 s1] (mod s1 s0)) -(def-reduce-step :lt [s0 s1] (< s1 s0)) -(def-reduce-step :lte [s0 s1] (<= s1 s0)) -(def-reduce-step :gt [s0 s1] (> s1 s0)) -(def-reduce-step :gte [s0 s1] (>= s1 s0)) -(def-reduce-step :power [s0 s1] (Math/pow s1 s0)) -(def-reduce-step :get [a b] - (cond (sequential? b) (when (number? a) (get b (->int a))) - (string? b) (when (number? a) (get b (->int a))) - (instance? java.util.List b) (when (number? a) (.get ^java.util.List b (->int a))) - :else (get b (str a)))) +(defmethod eval-tree :fncall [[_ f & args]] + (let [args (mapv eval-tree args)] + (try (apply call-fn (name f) args) + (catch clojure.lang.ArityException e + (throw (ex-info (format "Function '%s' was called with a wrong number of arguments (%d)" f (count args)) + {:fn f :args args})))))) (defn eval-rpn - ([bindings default-function tokens] + ([bindings default-function tree] (assert (ifn? default-function)) - (eval-rpn (assoc bindings ::functions default-function) tokens)) - ([bindings tokens] + (eval-rpn (assoc bindings ::functions default-function) tree)) + ([bindings tree] (assert (map? bindings)) - (assert (seq tokens)) (binding [*calc-vars* bindings] - (let [result (reduce reduce-step () tokens)] - (assert (= 1 (count result))) - (first result))))) + (eval-tree tree)))) -(def parse (comp validate-rpn tokens->rpn validate-tokens tokenize)) +(def parse (comp (partial grammar/runlang grammar/testlang) tokenize)) -:OK +:OK \ No newline at end of file diff --git a/src/stencil/merger.clj b/src/stencil/merger.clj index f9711eb5..ff002af0 100644 --- a/src/stencil/merger.clj +++ b/src/stencil/merger.clj @@ -1,11 +1,12 @@ (ns stencil.merger "Token listaban a text tokenekbol kiszedi a parancsokat es action tokenekbe teszi." (:require [clojure.data.xml :as xml] + [clojure.string :refer [index-of ends-with?]] [stencil.postprocess.ignored-tag :as ignored-tag] [stencil [types :refer [open-tag close-tag]] [tokenizer :as tokenizer] - [util :refer [prefixes suffixes subs-last]]])) + [util :refer [prefixes suffixes subs-last string parsing-exception]]])) (set! *warn-on-reflection* true) @@ -29,17 +30,14 @@ (defn find-first-code [^String s] (assert (string? s)) - (let [ind (.indexOf s (str open-tag))] - (when-not (neg? ind) - (let [after-idx (.indexOf s (str close-tag))] - (if (neg? after-idx) - (cond-> {:action-part (.substring s (+ ind (count open-tag)))} - (not (zero? ind)) (assoc :before (.substring s 0 ind))) - (cond-> {:action (.substring s (+ ind (count open-tag)) - after-idx)} - (not (zero? ind)) (assoc :before (.substring s 0 ind)) - (not (= (+ (count close-tag) after-idx) (count s))) - (assoc :after (.substring s (+ (count close-tag) after-idx))))))))) + (when-let [ind (index-of s (str open-tag))] + (if-let [after-idx (index-of s (str close-tag) ind)] + (cond-> {:action (subs s (+ ind (count open-tag)) after-idx)} + (pos? ind) (assoc :before (subs s 0 ind)) + (not= (+ (count close-tag) after-idx) (count s)) + (assoc :after (subs s (+ (count close-tag) after-idx)))) + (cond-> {:action-part (subs s (+ ind (count open-tag)))} + (not (zero? ind)) (assoc :before (subs s 0 ind)))))) (defn text-split-tokens [^String s] (assert (string? s)) @@ -57,36 +55,36 @@ {:tokens (conj output {:text s})} {:tokens output})))) -(declare cleanup-runs) - ;; returns a map of {:char :stack :text-rest :rest} (defn -find-open-tag [last-chars-count next-token-list] (assert (integer? last-chars-count)) (assert (pos? last-chars-count)) (assert (sequential? next-token-list)) - (when (= (drop last-chars-count open-tag) - (take (- (count open-tag) last-chars-count) - (map :char (peek-next-text next-token-list)))) - (nth (peek-next-text next-token-list) - (dec (- (count open-tag) last-chars-count))))) + (let [next-text (peek-next-text next-token-list) + n (- (count open-tag) last-chars-count)] + (when (= (drop last-chars-count open-tag) + (take n (map :char next-text))) + (nth next-text (dec n))))) (defn -last-chars-count [sts-tokens] (assert (sequential? sts-tokens)) - (when (:text (last sts-tokens)) - (some #(when (.endsWith - (str (apply str (:text (last sts-tokens)))) (apply str %)) + (when-let [last-text (some-> sts-tokens last :text string)] + (some #(when (ends-with? last-text (string %)) (count %)) (prefixes open-tag)))) (defn map-action-token [token] (if-let [action (:action token)] - (let [parsed (tokenizer/text->cmd action)] + (let [parsed (tokenizer/text->cmd action) + parsed (assoc parsed :raw (str open-tag action close-tag))] (if (and *only-includes* (not= :cmd/include (:cmd parsed))) {:text (str open-tag action close-tag)} {:action parsed})) token)) +(declare cleanup-runs) + (defn cleanup-runs-1 [[first-token & rest-tokens]] (assert (:text first-token)) (let [sts (text-split-tokens (:text first-token))] @@ -98,19 +96,21 @@ (take (count close-tag) (map :char %))) (suffixes (peek-next-text next-token-list))) that (if (empty? that) - (throw (ex-info "Tag is not closed? " {:read (first this)})) + (throw (parsing-exception "" (str "Stencil tag is not closed. Reading " open-tag + (string (comp (take 20) (map first) (map :char)) this)))) + ;; (throw (ex-info "Tag is not closed? " {:read (first this)})) (first (nth that (dec (count close-tag))))) ; action-content (apply str (map (comp :char first) this)) ] (concat (map map-action-token (:tokens sts)) - (let [ap (map-action-token {:action (apply str (map (comp :char first) this))})] + (let [ap (map-action-token {:action (string (map (comp :char first)) this)})] (if (:action ap) (concat [ap] (reverse (:stack that)) (if (seq (:text-rest that)) - (lazy-seq (cleanup-runs-1 (cons {:text (apply str (:text-rest that))} (:rest that)))) + (lazy-seq (cleanup-runs-1 (cons {:text (string (:text-rest that))} (:rest that)))) (lazy-seq (cleanup-runs (:rest that))))) (list* {:text (str open-tag (:action-part sts))} (lazy-seq (cleanup-runs rest-tokens))))))) @@ -124,7 +124,7 @@ [{:text (apply str s)}]) (let [tail (cleanup-runs-1 - (concat [{:text (str open-tag (apply str (:text-rest this)))}] + (concat [{:text (apply str open-tag (:text-rest this))}] (reverse (:stack this)) (:rest this)))] (if (:action (first tail)) diff --git a/src/stencil/model.clj b/src/stencil/model.clj index b575bf43..66e0a6bd 100644 --- a/src/stencil/model.clj +++ b/src/stencil/model.clj @@ -10,7 +10,7 @@ [stencil.model.numbering :as numbering] [stencil.types :refer [->FragmentInvoke ->ReplaceImage]] [stencil.postprocess.images :refer [img-data->extrafile]] - [stencil.util :refer [unlazy-tree assoc-if-val]] + [stencil.util :refer [unlazy-tree assoc-if-val eval-exception]] [stencil.model.relations :as relations] [stencil.model.common :refer [unix-path ->xml-writer resource-copier]] [stencil.functions :refer [call-fn]] @@ -230,7 +230,7 @@ elem))) -(defmethod eval/eval-step :cmd/include [_ local-data-map {frag-name :name}] +(defmethod eval/eval-step :cmd/include [function local-data-map {frag-name :name}] (assert (map? local-data-map)) (assert (string? frag-name)) (expect-fragment-context! @@ -239,10 +239,10 @@ style-ids-rename (-> fragment-model :main :style :parsed (doto assert) (style/insert-styles!)) relation-ids-rename (relations/ids-rename fragment-model frag-name) - relation-rename-map (into {} (map (juxt :old-id :new-id) relation-ids-rename)) + relation-rename-map (into {} (map (juxt :old-id :new-id)) relation-ids-rename) ;; evaluate - evaled (eval-template-model fragment-model local-data-map {} {}) + evaled (eval-template-model fragment-model local-data-map function {}) ;; write back get-xml (fn [x] (or (:xml x) @(:xml-delay x))) @@ -254,9 +254,7 @@ (swap! *inserted-fragments* conj frag-name) (run! add-extra-file! relation-ids-rename) [{:text (->FragmentInvoke {:frag-evaled-parts evaled-parts})}]) - (throw (ex-info "Did not find fragment for name!" - {:fragment-name frag-name - :all-fragment-names (set (keys *all-fragments*))}))))) + (throw (eval-exception (str "No fragment for name: " frag-name) nil))))) ;; replaces the nearest image with the content diff --git a/src/stencil/model/common.clj b/src/stencil/model/common.clj index 9234134b..94ac3026 100644 --- a/src/stencil/model/common.clj +++ b/src/stencil/model/common.clj @@ -3,8 +3,7 @@ [java.io File] [io.github.erdos.stencil.impl FileHelper]) (:require [clojure.data.xml :as xml] - [clojure.java.io :as io] - [stencil.util :refer :all])) + [clojure.java.io :as io])) (defn ->xml-writer [tree] diff --git a/src/stencil/model/relations.clj b/src/stencil/model/relations.clj index a69f7bb8..e18d6583 100644 --- a/src/stencil/model/relations.clj +++ b/src/stencil/model/relations.clj @@ -4,7 +4,7 @@ [clojure.java.io :as io :refer [file]] [stencil.ooxml :as ooxml] [stencil.util :refer :all] - [stencil.model.common :refer :all])) + [stencil.model.common :refer [->xml-writer]])) (def tag-relationships :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fpackage%2F2006%2Frelationships/Relationships) diff --git a/src/stencil/model/style.clj b/src/stencil/model/style.clj index 927e9a6c..8a5b8cf8 100644 --- a/src/stencil/model/style.clj +++ b/src/stencil/model/style.clj @@ -89,7 +89,7 @@ (defn main-style-item [^File dir main-document main-document-rels] - (when-let [main-style (some #(when (= rel-type (:stencil.model/type %)) %) + (when-let [main-style (find-first #(= rel-type (:stencil.model/type %)) (vals (:parsed main-document-rels)))] (let [main-style-file (io/file (.getParentFile (io/file main-document)) (:stencil.model/target main-style)) diff --git a/src/stencil/ooxml.clj b/src/stencil/ooxml.clj index 414c0243..171680ef 100644 --- a/src/stencil/ooxml.clj +++ b/src/stencil/ooxml.clj @@ -1,6 +1,6 @@ (ns stencil.ooxml "Contains common xml element tags and attributes" - (:refer-clojure :exclude [val name])) + (:refer-clojure :exclude [val name type])) ;; run and properties (def r :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/r) @@ -25,6 +25,8 @@ ;; Content Break: http://officeopenxml.com/WPtextSpecialContent-break.php (def br :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/br) +(def type :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/type) + (def w :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/w) ;; XML space attribute (eg.: preserve) diff --git a/src/stencil/postprocess/fragments.clj b/src/stencil/postprocess/fragments.clj index 85ca9a82..a5a80379 100644 --- a/src/stencil/postprocess/fragments.clj +++ b/src/stencil/postprocess/fragments.clj @@ -54,7 +54,7 @@ text (:content run) :when (map? text) :when (= ooxml/t (:tag text)) - c (:content run) + c (:content text) :when (string? c) :when (not-empty c)] c)))) @@ -82,7 +82,7 @@ rights1 (zip/rights (zip/up chunk-loc)) ;; style of run that is split - style (some #(when (= ooxml/rPr (:tag %)) %) (:content r)) + style (find-first #(= ooxml/rPr (:tag %)) (:content r)) ->t (fn [xs] {:tag ooxml/t :content (vec xs)}) ->run (fn [cts] (assoc r :content (vec (cons style cts))))] diff --git a/src/stencil/postprocess/html.clj b/src/stencil/postprocess/html.clj index 5fa4c25f..78469ebb 100644 --- a/src/stencil/postprocess/html.clj +++ b/src/stencil/postprocess/html.clj @@ -8,6 +8,8 @@ [stencil.util :refer :all] [stencil.ooxml :as ooxml])) +(set! *warn-on-reflection* true) + (defrecord HtmlChunk [content] ControlMarker) (defmethod call-fn "html" [_ content] (->HtmlChunk content)) @@ -16,19 +18,22 @@ "Set of supported HTML tags" #{:b :em :i :u :s :sup :sub :span :br :strong}) +(defn- kw-lowercase [kw] (-> kw name .toLowerCase keyword)) + (defn- validate-tags "Throws ExceptionInfo on invalid HTML tag in tree" [xml-tree] (->> (fn [node] - (if (legal-tags (:tag node)) + (if (legal-tags (-> node :tag kw-lowercase)) node (throw (ex-info (str "Unexpected HTML tag: " (:tag node)) {:tag (:tag node)})))) (dfs-walk-xml xml-tree map?))) (defn- parse-html [xml] (-> (str xml) - (.replaceAll "
" "
") + (.replaceAll "
" "
") + (.replaceAll "
" "
") (xml/parse-str) (doto (validate-tags)) (try (catch javax.xml.stream.XMLStreamException e @@ -36,21 +41,22 @@ (defn- walk-children [xml] (if (map? xml) - (if (= :br (:tag xml)) + (if (#{:br :BR} (:tag xml)) [{:text ::br}] (for [c (:content xml) x (walk-children c)] - (update x :path conj (:tag xml)))) + (update x :path conj (kw-lowercase (:tag xml))))) [{:text xml}])) -(defn- path->styles [path] - (cond-> [] - (some #{:b :em :strong} path) (conj {:tag ooxml/b :attrs {ooxml/val "true"}}) - (some #{:i} path) (conj {:tag ooxml/i :attrs {ooxml/val "true"}}) - (some #{:s} path) (conj {:tag ooxml/strike :attrs {ooxml/val "true"}}) - (some #{:u} path) (conj {:tag ooxml/u :attrs {ooxml/val "single"}}) - (some #{:sup} path) (conj {:tag ooxml/vertAlign :attrs {ooxml/val "superscript"}}) - (some #{:sub} path) (conj {:tag ooxml/vertAlign :attrs {ooxml/val "subscript"}}))) +(defn- path->style [p] + (case p + (:b :em :strong) {:tag ooxml/b :attrs {ooxml/val "true"}} + (:i) {:tag ooxml/i :attrs {ooxml/val "true"}} + (:s) {:tag ooxml/strike :attrs {ooxml/val "true"}} + (:u) {:tag ooxml/u :attrs {ooxml/val "single"}} + (:sup) {:tag ooxml/vertAlign :attrs {ooxml/val "superscript"}} + (:sub) {:tag ooxml/vertAlign :attrs {ooxml/val "subscript"}} + nil)) (defn html->ooxml-runs "Parses html string and returns a seq of ooxml run elements. @@ -59,7 +65,7 @@ (when (seq html) (let [ch (walk-children (parse-html (str "" html "")))] (for [parts (partition-by :path ch) - :let [prs (into (set base-style) (path->styles (:path (first parts))))]] + :let [prs (into (set base-style) (keep path->style) (:path (first parts)))]] {:tag ooxml/r :content (cons {:tag ooxml/rPr :content (vec prs)} (for [{:keys [text]} parts] @@ -69,7 +75,7 @@ (defn- current-run-style [chunk-loc] (let [r (zip/node (zip/up (zip/up chunk-loc)))] - (some #(when (= ooxml/rPr (:tag %)) %) (:content r)))) + (find-first #(= ooxml/rPr (:tag %)) (:content r)))) (defn- fix-html-chunk [chunk-loc] (assert (instance? HtmlChunk (zip/node chunk-loc))) diff --git a/src/stencil/postprocess/list_ref.clj b/src/stencil/postprocess/list_ref.clj index 2f07b204..cc2ff9b3 100644 --- a/src/stencil/postprocess/list_ref.clj +++ b/src/stencil/postprocess/list_ref.clj @@ -3,6 +3,7 @@ [stencil.ooxml :as ooxml] [stencil.model.numbering :as numbering] [stencil.log :as log] + [clojure.string] [clojure.zip :as zip])) (set! *warn-on-reflection* true) @@ -28,7 +29,7 @@ (loop [buf [], n number] (if (zero? n) (apply str buf) - (let [[value romnum] (some #(if (>= n (first %)) %) roman-digits)] + (let [[value romnum] (find-first #(>= n (first %)) roman-digits)] (recur (conj buf romnum) (- n value)))))) (defmethod render-number "lowerRoman" [_ number] @@ -175,7 +176,7 @@ (defn parse-instr-text [^String s] (assert (string? s)) - (let [[type id & flags] (vec (.split (.trim s) "\\s\\\\?+"))] + (let [[type id & flags] (vec (.split (trim s) "\\s\\\\?+"))] (when (= "REF" type) {:id id :flags (set (map keyword flags))}))) diff --git a/src/stencil/postprocess/table.clj b/src/stencil/postprocess/table.clj index 84535100..7fda2aa1 100644 --- a/src/stencil/postprocess/table.clj +++ b/src/stencil/postprocess/table.clj @@ -14,15 +14,10 @@ (defn- loc-row? [loc] (some-> loc zip/node :tag name #{"tr"})) (defn- loc-table? [loc] (some-> loc zip/node :tag name #{"tbl" "table"})) -(defn- find-first-in-tree [pred tree] - (assert (zipper? tree)) - (assert (fn? pred)) - (find-first (comp pred zip/node) (take-while (complement zip/end?) (iterate zip/next tree)))) - (defn- find-last-child [pred tree] (assert (zipper? tree)) (assert (fn? pred)) - (last (filter (comp pred zip/node) (iterations zip/right (zip/down tree))))) + (find-last (comp pred zip/node) (iterations zip/right (zip/down tree)))) (defn- first-right-sibling "Finds first right sibling that matches the predicate." diff --git a/src/stencil/postprocess/whitespaces.clj b/src/stencil/postprocess/whitespaces.clj index e60349fe..8b566e85 100644 --- a/src/stencil/postprocess/whitespaces.clj +++ b/src/stencil/postprocess/whitespaces.clj @@ -1,20 +1,44 @@ (ns stencil.postprocess.whitespaces - (:require [clojure.zip :as zip] + (:require [clojure.string :refer [includes? starts-with? ends-with? index-of]] + [clojure.zip :as zip] [stencil.ooxml :as ooxml] - [stencil.types :refer :all] [stencil.util :refer :all])) -(defn- should-fix? - "We only fix tags where the enclosed string starts or ends with whitespace." - [element] - (boolean - (when (and (map? element) - (= ooxml/t (:tag element)) - (seq (:content element))) - (or (.startsWith (str (first (:content element))) " ") - (.startsWith (str (last (:content element))) " "))))) +(defn- should-fix? [element] + (when (and (map? element) + (= ooxml/t (:tag element)) + (not-empty (:content element))) + (or (starts-with? (first (:content element)) " ") + (ends-with? (last (:content element)) " ") + (some #(includes? % "\n") (:content element))))) -(defn- fix-elem [element] - (assoc-in element [:attrs ooxml/space] "preserve")) +(defn- multi-replace [loc items] + (assert (zipper? loc)) + (assert (not-empty items)) + (reduce (comp zip/right zip/insert-right) (zip/replace loc (first items)) (next items))) -(defn fix-whitespaces [xml-tree] (dfs-walk-xml xml-tree should-fix? fix-elem)) +;; (defn- lines-of [s] (enumeration-seq (java.util.StringTokenizer. s "\n" true))) +;; (defn- lines-of [s] (remove #{""} (interpose "\n" (clojure.string/split s "\n" -1)))) + +(defn- lines-of [s] + (if-let [idx (index-of s "\n")] + (if (zero? idx) + (cons "\n" (lazy-seq (lines-of (subs s 1)))) + (list* (subs s 0 idx) "\n" (lazy-seq (lines-of (subs s (inc idx)))))) + (if (empty? s) [] (list s)))) + +(defn- item->elem [item] + (cond (= "\n" item) + ,,,{:tag ooxml/br} + (or (starts-with? item " ") (ends-with? item " ")) + ,,,{:tag ooxml/t :content [item] :attrs {ooxml/space "preserve"}} + :else + ,,,{:tag ooxml/t :content [item]})) + +(defn- fix-elem-node [loc] + (->> (apply str (:content (zip/node loc))) + (lines-of) + (map item->elem) + (multi-replace loc))) + +(defn fix-whitespaces [xml-tree] (dfs-walk-xml-node xml-tree should-fix? fix-elem-node)) diff --git a/src/stencil/spec.clj b/src/stencil/spec.clj new file mode 100644 index 00000000..f54ffe22 --- /dev/null +++ b/src/stencil/spec.clj @@ -0,0 +1,105 @@ +(ns stencil.spec + (:import [java.io File]) + (:require [clojure.spec.alpha :as s] + [stencil.model :as m] + [stencil.process])) + + + +;; TODO +(s/def :stencil.model/mode #{"External"}) + +;; keys are either all keywords or all strings +(s/def ::data map?) + +;; other types are also possible +(s/def :stencil.model/type #{stencil.model/rel-type-footer + stencil.model/rel-type-header + stencil.model/rel-type-main + stencil.model/rel-type-slide}) + +(s/def :stencil.model/path (s/and string? not-empty #(not (.startsWith ^String % "/")))) + +;; relationship file +(s/def ::relations (s/keys :req [:stencil.model/path] + :req-un [::source-file] + :opt-un [:stencil.model/parsed])) + +(s/def :?/relations (s/nilable ::relations)) + +(s/def ::result (s/keys :req-un [::writer])) + +(s/def ::style (s/keys :req [:stencil.model/path] + :opt-un [::result])) + +(s/def :stencil.model/headers+footers (s/* (s/keys :req [:stencil.model/path] + :req-un [::source-file :stencil.model/executable :?/relations] + :opt-un [::result]))) + +(s/def ::source-folder (s/and (partial instance? java.io.File) + #(.isDirectory ^File %) + #(.exists ^File %))) + +(s/def ::source-file (s/and (partial instance? java.io.File) + #(.isFile ^File %) + #(.exists ^File %))) + +(s/def ::main (s/keys :req [:stencil.model/path] + :opt-un [:stencil.model/headers+footers ::result] ;; not present in fragments + :opt [::numbering] + :req-un [::source-file + ::executable + ::style + ::relations])) + + +(s/def :stencil.model/content-types + (s/keys :req [:stencil.model/path] + :req-un [::source-file])) + +(s/def :stencil.model/model + (s/keys :req [] + :req-un [::main ::source-folder :stencil.model/content-types ::relations])) + +(s/def ::parsed any?) + +(s/def :stencil.model/numbering (s/nilable (s/keys :req [:stencil.model/path] + :req-un [::source-file ::parsed]))) + +(s/fdef stencil.model/load-template-model + :args (s/cat :dir ::source-folder, :opts map?) + :ret :stencil.model/model) + +(s/fdef stencil.model/load-fragment-model + :args (s/cat :dir ::source-folder, :opts map?) + :ret :stencil.model/model) + +(s/fdef stencil.model/eval-template-model + :args (s/cat :model :stencil.model/model + :data ::data + :unused/function-arg any? + :fragments (s/map-of string? :stencil.model/model)) + :ret :stencil.model/model) + +(s/def :exec/variables (s/coll-of string? :unique true)) +(s/def :exec/dynamic? boolean?) +(s/def :exec/executable any?) ;; seq of normalized control ast. +(s/def :exec/fragments (s/coll-of string? :unique true)) ;; what was that? + +(s/def ::executable (s/keys :req-un [:exec/variables :exec/dynamic? :exec/executable :exec/fragments])) + +;; prepared template +(s/def ::template any?) + +(s/def :eval-template/data any?) ;; map of string or keyword keys +(s/def :eval-template/fragments (s/map-of string? any?)) +(s/def :eval-template/function fn?) + +(s/fdef stencil.process/eval-template + :args (s/cat :ps (s/keys :req-un [::template :eval-template/data :eval-template/function :eval-template/fragments]))) + +(s/def ::writer (s/fspec :args (s/cat :writer (partial instance? java.io.Writer)) :ret nil?)) + +(s/fdef template-model->writers-map + :args (s/cat :model :stencil.model/model, :data ::data, :functions any?, :fragments any?) + :ret (s/map-of :stencil.model/path ::writer)) \ No newline at end of file diff --git a/src/stencil/tokenizer.clj b/src/stencil/tokenizer.clj index 923c159a..f453af6f 100644 --- a/src/stencil/tokenizer.clj +++ b/src/stencil/tokenizer.clj @@ -1,15 +1,15 @@ (ns stencil.tokenizer "Fog egy XML dokumentumot es tokenekre bontja" (:require [clojure.data.xml :as xml] + [clojure.string :refer [includes? split]] [stencil.infix :as infix] - [stencil.types :refer [open-tag close-tag]] - [stencil.util :refer [assoc-if-val mod-stack-top-conj mod-stack-top-last parsing-exception]])) + [stencil.util :refer [assoc-if-val mod-stack-top-conj mod-stack-top-last parsing-exception trim]])) (set! *warn-on-reflection* true) (defn- text->cmd-impl [^String text] (assert (string? text)) - (let [text (.trim text) + (let [text (trim text) pattern-elseif #"^(else\s*if|elif|elsif)(\(|\s+)"] (cond (#{"end" "endfor" "endif"} text) {:cmd :end} @@ -21,12 +21,14 @@ (.startsWith text "unless ") {:cmd :if - :condition (conj (vec (infix/parse (.substring text 7))) :not)} + :condition (list :not (infix/parse (.substring text 7)))} (.startsWith text "for ") - (let [[v expr] (vec (.split (.substring text 4) " in " 2))] + (let [[v expr] (split (subs text 4) #" in " 2) + [idx v] (if (includes? v ",") (split v #",") ["$" v])] {:cmd :for - :variable (symbol (.trim ^String v)) + :variable (symbol (trim v)) + :index-var (symbol (trim idx)) :expression (infix/parse expr)}) (.startsWith text "=") @@ -36,7 +38,7 @@ ;; fragment inclusion (.startsWith text "include ") {:cmd :cmd/include - :name (first (infix/parse (.substring text 8)))} + :name (infix/parse (.substring text 8))} ;; `else if` expression (seq (re-seq pattern-elseif text)) @@ -44,12 +46,12 @@ {:cmd :else-if :condition (infix/parse (.substring text prefix-len))}) - :else (throw (ex-info "Unexpected command" {:command text}))))) + :else (throw (ex-info (str "Unexpected command: " text) {}))))) (defn text->cmd [text] (try (text->cmd-impl text) - (catch clojure.lang.ExceptionInfo e - (throw (parsing-exception (str open-tag text close-tag) (.getMessage e)))))) + (catch clojure.lang.ExceptionInfo e + (throw (parsing-exception text (.getMessage e)))))) (defn structure->seq [parsed] (cond @@ -63,8 +65,7 @@ [{:close (:tag parsed)}]) :else - [(cond-> {:open+close (:tag parsed)} - (seq (:attrs parsed)) (assoc :attrs (:attrs parsed)))])) + [(assoc-if-val {:open+close (:tag parsed)} :attrs (not-empty (:attrs parsed)))])) (defn- tokens-seq-reducer [stack token] (cond diff --git a/src/stencil/types.clj b/src/stencil/types.clj index 95386dc5..b31804a0 100644 --- a/src/stencil/types.clj +++ b/src/stencil/types.clj @@ -5,14 +5,6 @@ (def open-tag "{%") (def close-tag "%}") -(defrecord OpenTag [open]) -(defrecord CloseTag [close]) -(defrecord TextTag [text]) - -(defn ->text [t] (->TextTag t)) -(defn ->close [t] (->CloseTag t)) -(def ->open ->OpenTag) - (defprotocol ControlMarker) ;; Invocation of a fragment by name diff --git a/src/stencil/util.clj b/src/stencil/util.clj index 9e82f211..26549bc7 100644 --- a/src/stencil/util.clj +++ b/src/stencil/util.clj @@ -1,6 +1,6 @@ (ns stencil.util (:require [clojure.zip]) - (:import [io.github.erdos.stencil.exceptions ParsingException])) + (:import [io.github.erdos.stencil.exceptions ParsingException EvalException])) (set! *warn-on-reflection* true) @@ -14,22 +14,18 @@ [(take (- (count stack1) cnt) stack1) (take (- (count stack2) cnt) stack2)])) -(defn mod-stack-top-last - "Egy stack legfelso elemenek legutolso elemet modositja. - Ha nincs elem, IllegalStateException kivetelt dob." - [stack f & args] - (assert (list? stack) (str "Stack is not a list: " (pr-str stack))) - (assert (ifn? f)) - (conj (rest stack) - (conj (pop (first stack)) - (apply f (peek (first stack)) args)))) - (defn update-peek "Updates top element of a stack." [xs f & args] (assert (ifn? f)) (conj (pop xs) (apply f (peek xs) args))) +(defn mod-stack-top-last + "Updatest last element of top elem of stack." + [stack f & args] + (assert (list? stack) (str "Stack is not a list: " (pr-str stack))) + (apply update-peek stack update-peek f args)) + (defn mod-stack-top-conj "Conjoins an element to the top item of a stack." [stack & items] @@ -40,10 +36,10 @@ (defn fixpt [f x] (let [fx (f x)] (if (= fx x) x (recur f fx)))) (defn zipper? [loc] (-> loc meta (contains? :zip/branch?))) -(defn iterations [f xs] (take-while some? (iterate f xs))) -(defn find-first [pred xs] (first (filter pred xs))) +(defn iterations [f elem] (eduction (take-while some?) (iterate f elem))) -;; same as (last (filter pred xs)) +;; same as (first (filter pred xs)) +(defn find-first [pred xs] (reduce (fn [_ x] (if (pred x) (reduced x))) nil xs)) (defn find-last [pred xs] (reduce (fn [a x] (if (pred x) x a)) nil xs)) (def xml-zip @@ -75,17 +71,48 @@ (defn parsing-exception [expression message] (ParsingException/fromMessage (str expression) (str message))) -(defn dfs-walk-xml-node [xml-tree predicate edit-fn] - (assert (map? xml-tree)) - (assert (fn? predicate)) - (assert (fn? edit-fn)) - (loop [loc (xml-zip xml-tree)] +(defn eval-exception [message expression] + (assert (string? message)) + (EvalException. message expression)) + +;; return xml zipper of location that matches predicate or nil +(defn find-first-in-tree [predicate tree-loc] + (assert (ifn? predicate)) + (assert (zipper? tree-loc)) + (letfn [(coords-of-first [node] + (loop [children (:content node) + index 0] + (when-let [[c & cs] (not-empty children)] + (if (predicate c) + [index] + (if-let [cf (coords-of-first c)] + (cons index cf) + (recur cs (inc index))))))) + (nth-child [loc i] + (loop [loc (clojure.zip/down loc), i i] + (if (zero? i) loc (recur (clojure.zip/right loc) (dec i)))))] + (if (predicate (clojure.zip/node tree-loc)) + tree-loc + (when-let [coords (coords-of-first (clojure.zip/node tree-loc))] + (reduce nth-child tree-loc coords))))) + +(defn- dfs-walk-xml-node-1 [loc predicate edit-fn] + (assert (zipper? loc)) + (loop [loc loc] (if (clojure.zip/end? loc) (clojure.zip/root loc) (if (predicate (clojure.zip/node loc)) (recur (clojure.zip/next (edit-fn loc))) (recur (clojure.zip/next loc)))))) +(defn dfs-walk-xml-node [xml-tree predicate edit-fn] + (assert (fn? predicate)) + (assert (fn? edit-fn)) + (assert (map? xml-tree)) + (if-let [loc (find-first-in-tree predicate (xml-zip xml-tree))] + (dfs-walk-xml-node-1 loc predicate edit-fn) + xml-tree)) + (defn dfs-walk-xml [xml-tree predicate edit-fn] (assert (fn? edit-fn)) (dfs-walk-xml-node xml-tree predicate #(clojure.zip/edit % edit-fn))) @@ -99,4 +126,26 @@ `(let [b# ~body] (when (~pred b#) b#))) +(defn ^String string + ([values] (apply str values)) + ([xform coll] (transduce xform (fn ([^Object s] (.toString s)) ([^StringBuilder b v] (.append b v))) (StringBuilder.) coll))) + +(defn ^{:inline (fn [c] `(case ~c (\tab \space \newline + \u00A0 \u2007 \u202F ;; non-breaking spaces + \u000B \u000C \u000D \u001C \u001D \u001E \u001F) + true false))} + whitespace? [c] (whitespace? c)) + +;; like clojure.string/trim but supports a wider range of whitespace characters +(defn ^String trim [^CharSequence s] + (loop [right-idx (.length s)] + (if (zero? right-idx) + "" + (if (whitespace? (.charAt s (dec right-idx))) + (recur (dec right-idx)) + (loop [left-idx 0] + (if (whitespace? (.charAt s left-idx)) + (recur (inc left-idx)) + (.toString (.subSequence s left-idx right-idx)))))))) + :OK diff --git a/test-resources/failures/test-eval-division.docx b/test-resources/failures/test-eval-division.docx new file mode 100644 index 00000000..40a5d670 Binary files /dev/null and b/test-resources/failures/test-eval-division.docx differ diff --git a/test-resources/failures/test-no-such-fn.docx b/test-resources/failures/test-no-such-fn.docx new file mode 100644 index 00000000..d65b5a63 Binary files /dev/null and b/test-resources/failures/test-no-such-fn.docx differ diff --git a/test-resources/failures/test-syntax-arity.docx b/test-resources/failures/test-syntax-arity.docx new file mode 100644 index 00000000..d9d6a6a6 Binary files /dev/null and b/test-resources/failures/test-syntax-arity.docx differ diff --git a/test-resources/failures/test-syntax-closed.docx b/test-resources/failures/test-syntax-closed.docx new file mode 100644 index 00000000..bc28d854 Binary files /dev/null and b/test-resources/failures/test-syntax-closed.docx differ diff --git a/test-resources/failures/test-syntax-fails.docx b/test-resources/failures/test-syntax-fails.docx new file mode 100644 index 00000000..3984dd56 Binary files /dev/null and b/test-resources/failures/test-syntax-fails.docx differ diff --git a/test-resources/failures/test-syntax-incomplete.docx b/test-resources/failures/test-syntax-incomplete.docx new file mode 100644 index 00000000..fa77989a Binary files /dev/null and b/test-resources/failures/test-syntax-incomplete.docx differ diff --git a/test-resources/failures/test-syntax-nonclosed.docx b/test-resources/failures/test-syntax-nonclosed.docx new file mode 100644 index 00000000..5aa24a85 Binary files /dev/null and b/test-resources/failures/test-syntax-nonclosed.docx differ diff --git a/test-resources/failures/test-syntax-unexpected-command.docx b/test-resources/failures/test-syntax-unexpected-command.docx new file mode 100644 index 00000000..e24be2e8 Binary files /dev/null and b/test-resources/failures/test-syntax-unexpected-command.docx differ diff --git a/test-resources/failures/test-syntax-unexpected-elif.docx b/test-resources/failures/test-syntax-unexpected-elif.docx new file mode 100644 index 00000000..9e9f838c Binary files /dev/null and b/test-resources/failures/test-syntax-unexpected-elif.docx differ diff --git a/test-resources/failures/test-syntax-unexpected-else.docx b/test-resources/failures/test-syntax-unexpected-else.docx new file mode 100644 index 00000000..db4e40e6 Binary files /dev/null and b/test-resources/failures/test-syntax-unexpected-else.docx differ diff --git a/test-resources/test-function-date.docx b/test-resources/test-function-date.docx new file mode 100644 index 00000000..36367418 Binary files /dev/null and b/test-resources/test-function-date.docx differ diff --git a/test/stencil/cleanup_test.clj b/test/stencil/cleanup_test.clj index 68b43974..07c16f94 100644 --- a/test/stencil/cleanup_test.clj +++ b/test/stencil/cleanup_test.clj @@ -4,13 +4,17 @@ [cleanup :refer :all] [types :refer :all]])) +(defn- ->text [t] {:text t}) +(defn- ->close [t] {:close t}) +(defn- ->open [t] {:open t}) + (deftest stack-revert-close-test (testing "Egyszeru es ures esetek" (is (= [] (stack-revert-close []))) (is (= [] (stack-revert-close nil))) (is (= [] (stack-revert-close [{:whatever 1} {:else 2}])))) (testing "A kinyito elemeket be kell csukni" - (is (= [(->CloseTag "a") (->CloseTag "b")] + (is (= [(->close "a") (->close "b")] (stack-revert-close [{:open "b"} {:open "a"}]))))) (deftest tokens->ast-test @@ -30,7 +34,7 @@ {:text "Then"} {:cmd :end}] [{:cmd :if :condition 1 - :blocks [{:children [{:text "Then"}]}]}] + :stencil.cleanup/blocks [{:stencil.cleanup/children [{:text "Then"}]}]}] ;; if-then-else-fi [{:cmd :if :condition 1} @@ -39,50 +43,52 @@ {:text "Else"} {:cmd :end}] [{:cmd :if, :condition 1 - :blocks [{:children [{:text "Then"}]} {:children [{:text "Else"}]}]}]))) + :stencil.cleanup/blocks [{:stencil.cleanup/children [{:text "Then"}]} {:stencil.cleanup/children [{:text "Else"}]}]}]))) (deftest normal-ast-test-1 - (is (= (control-ast-normalize + (is (= (map control-ast-normalize (annotate-environments - [(->OpenTag "html") - (->OpenTag "a") - (->TextTag "Inka") + [(->open "html") + (->open "a") + (->text "Inka") {:cmd :if - :blocks [{:children [(->TextTag "ikarusz") - (->CloseTag "a") - (->TextTag "bela") - (->OpenTag "b") - (->TextTag "Hello")]} - {:children [(->TextTag "Virag") - (->CloseTag "b") - (->TextTag "Hajdiho!") - (->OpenTag "c") - (->TextTag "Bogar")]}]} - (->TextTag "Kaktusz") - (->CloseTag "c") - (->CloseTag "html")])) - - [(->OpenTag "html") - (->OpenTag "a") - (->TextTag "Inka") + :stencil.cleanup/blocks [{:stencil.cleanup/children [ + (->text "ikarusz") + (->close "a") + (->text "bela") + (->open "b") + (->text "Hello")]} + {:stencil.cleanup/children [ + (->text "Virag") + (->close "b") + (->text "Hajdiho!") + (->open "c") + (->text "Bogar")]}]} + (->text "Kaktusz") + (->close "c") + (->close "html")])) + + [(->open "html") + (->open "a") + (->text "Inka") {:cmd :if - :then [(->TextTag "ikarusz") - (->CloseTag "a") - (->TextTag "bela") - (->OpenTag "b") - (->TextTag "Hello") - (->CloseTag "b") - (->OpenTag "c")] - :else [(->CloseTag "a") - (->OpenTag "b") - (->TextTag "Virag") - (->CloseTag "b") - (->TextTag "Hajdiho!") - (->OpenTag "c") - (->TextTag "Bogar")]} - (->TextTag "Kaktusz") - (->CloseTag "c") - (->CloseTag "html")]))) + :then [(->text "ikarusz") + (->close "a") + (->text "bela") + (->open "b") + (->text "Hello") + (->close "b") + (->open "c")] + :else [(->close "a") + (->open "b") + (->text "Virag") + (->close "b") + (->text "Hajdiho!") + (->open "c") + (->text "Bogar")]} + (->text "Kaktusz") + (->close "c") + (->close "html")]))) (def (->open "i")) (def </i> (->close "i")) (def (->open "b")) (def </b> (->close "b")) @@ -91,11 +97,11 @@ (deftest normal-ast-test-0 (testing "Amikor a formazas a THEN blokk kozepeig tart, akkor az ELSE blokk-ba is be kell tenni a lezaro taget." - (is (= (control-ast-normalize + (is (= (map control-ast-normalize (annotate-environments [{:cmd :if - :blocks [{:children [(->text "bela") (->text "Hello")]} - {:children [(->text "Virag")]}]} + :stencil.cleanup/blocks [{:stencil.cleanup/children [(->text "bela") (->text "Hello")]} + {:stencil.cleanup/children [(->text "Virag")]}]} (->close "b")])) [{:cmd :if @@ -105,11 +111,11 @@ (deftest normal-ast-test-0-deep (testing "Amikor a formazas a THEN blokk kozepeig tart, akkor az ELSE blokk-ba is be kell tenni a lezaro taget." - (is (= (control-ast-normalize + (is (= (map control-ast-normalize (annotate-environments [{:cmd :if - :blocks [{:children [(->text "bela") (->text "Hello") </i>]} - {:children [ (->text "Virag") </j>]}]} + :stencil.cleanup/blocks [{:stencil.cleanup/children [(->text "bela") (->text "Hello") </i>]} + {:stencil.cleanup/children [ (->text "Virag") </j>]}]} </b>])) [{:cmd :if @@ -119,11 +125,11 @@ (deftest normal-ast-test-condition-only-then (testing "Az elagazasban eredeetileg csak THEN ag volt de beszurjuk az else agat is." - (is (= (control-ast-normalize + (is (= (map control-ast-normalize (annotate-environments [ {:cmd :if - :blocks [{:children [(->text "bela") (->text "Hello") </i>]}]} + :stencil.cleanup/blocks [{:stencil.cleanup/children [(->text "bela") (->text "Hello") </i>]}]} </b> </a>])) @@ -133,11 +139,11 @@ </b> </a>])))) -(defn >>for-loop [& children] {:cmd :for :blocks [{:children (vec children)}]}) +(defn >>for-loop [& children] {:cmd :for :stencil.cleanup/blocks [{:stencil.cleanup/children (vec children)}]}) (deftest test-normal-ast-for-loop-1 (testing "ismetleses ciklusban" - (is (= (control-ast-normalize + (is (= (map control-ast-normalize (annotate-environments [ (->text "before") @@ -160,27 +166,27 @@ (is (= () (find-variables [{:open "a"} {:close "a"}])))) (testing "Variables from simple subsitutions" - (is (= ["a"] (find-variables [{:cmd :echo :expression '[a 1 :plus]}])))) + (is (= ["a"] (find-variables [{:cmd :echo :expression '[:plus a 1]}])))) (testing "Variables from if conditions" - (is (= ["a"] (find-variables [{:cmd :if :condition '[a 1 :eq]}])))) + (is (= ["a"] (find-variables [{:cmd :if :condition '[:eq a 1]}])))) (testing "Variables from if branches" - (is (= ["x"] (find-variables [{:cmd :if :condition [] - :blocks [[] [{:cmd :echo :expression '[x]}]]}])))) + (is (= ["x"] (find-variables [{:cmd :if :condition '3 + :stencil.cleanup/blocks [[] [{:cmd :echo :expression 'x}]]}])))) (testing "Variables from loop expressions" (is (= ["xs" "xs[]"] - (find-variables '[{:cmd :for, :variable y, :expression [xs], - :blocks [[{:cmd :echo, :expression [y 1 :plus]}]]}]))) + (find-variables '[{:cmd :for, :variable y, :expression xs, + :stencil.cleanup/blocks [[{:cmd :echo, :expression [:plus y 1]}]]}]))) (is (= ["xs" "xs[]" "xs[][]"] - (find-variables '[{:cmd :for, :variable y, :expression [xs] - :blocks [[{:cmd :for :variable w :expression [y] - :blocks [[{:cmd :echo :expression [1 w :plus]}]]}]]}]))) + (find-variables '[{:cmd :for, :variable y, :expression xs + :stencil.cleanup/blocks [[{:cmd :for :variable w :expression y + :stencil.cleanup/blocks [[{:cmd :echo :expression [:plus 1 w]}]]}]]}]))) (is (= ["xs" "xs[].z.k"] (find-variables - '[{:cmd :for :variable y :expression [xs] - :blocks [[{:cmd :echo :expression [y.z.k 1 :plus]}]]}])))) + '[{:cmd :for :variable y :expression xs + :stencil.cleanup/blocks [[{:cmd :echo :expression [:plus [:get y "z" "k"] 1]}]]}])))) (testing "Variables from loop bindings and bodies" ;; TODO: impls this test @@ -192,16 +198,16 @@ (testing "Nested loop bindings" (is (= ["xs" "xs[].t" "xs[].t[].n"] (find-variables - '[{:cmd :for :variable a :expression [xs] - :blocks [[{:cmd :for :variable b :expression [a.t] - :blocks [[{:cmd :echo :expression [b.n 1 :plus]}]]}]]}]))) + '[{:cmd :for :variable a :expression xs + :stencil.cleanup/blocks [[{:cmd :for :variable b :expression [:get a "t"] + :stencil.cleanup/blocks [[{:cmd :echo :expression [:plus [:get b "n"] 1]}]]}]]}]))) )) (deftest test-process-if-then-else (is (= '[{:open :body} {:open :a} - {:cmd :if, :condition [a], + {:cmd :if, :condition a, :then [{:close :a} {:open :a} {:text "THEN"} @@ -213,7 +219,7 @@ (:executable (process '({:open :body} {:open :a} - {:cmd :if, :condition [a]} + {:cmd :if, :condition a} {:close :a} {:open :a} {:text "THEN"} @@ -226,9 +232,9 @@ (deftest test-process-if-nested (is (= [ - {:cmd :if, :condition '[x.a], + {:cmd :if, :condition '[:get x "a"], :then [</a> - {:cmd :if, :condition '[x.b], + {:cmd :if, :condition '[:get x "b"], :then [ {:text "THEN"}] :else []} </a>] @@ -236,9 +242,9 @@ (:executable (process [ - ,,{:cmd :if, :condition '[x.a]} + ,,{:cmd :if, :condition '[:get x "a"]} </a> - {:cmd :if, :condition '[x.b]} + {:cmd :if, :condition '[:get x "b"]} ,,{:text "THEN"} ,,{:cmd :end} diff --git a/test/stencil/errors.clj b/test/stencil/errors.clj index ebb559b9..dca53c1c 100644 --- a/test/stencil/errors.clj +++ b/test/stencil/errors.clj @@ -1,6 +1,7 @@ (ns stencil.errors - (:import [io.github.erdos.stencil.exceptions ParsingException]) + (:import [io.github.erdos.stencil.exceptions ParsingException EvalException]) (:require [stencil.types :refer :all] + [stencil.integration :refer [test-fails]] [clojure.test :refer [deftest is are testing]] [stencil.model :as model])) @@ -9,10 +10,6 @@ (->> xml-str (str) (.getBytes) (new java.io.ByteArrayInputStream) (model/->exec))) -(defmacro ^:private throw-ex-info? [expr] - `(is (~'thrown? clojure.lang.ExceptionInfo (test-prepare ~expr)))) - - (defmacro ^:private throw-ex-parsing? [expr] `(is (~'thrown? ParsingException (test-prepare ~expr)))) @@ -54,12 +51,67 @@ (deftest test-not-closed (testing "Expressions are not closed properly" - (throw-ex-info? "{%=") - (throw-ex-info? "{%=x") - (throw-ex-info? "{%=x%") - (throw-ex-info? "{%=x}")) + (throw-ex-parsing? "{%=") + (throw-ex-parsing? "{%=x") + (throw-ex-parsing? "{%=x%") + (throw-ex-parsing? "{%=x}")) (testing "Middle expr is not closed" (throw-ex-parsing? "{%=1%}{%=3{%=4%}"))) + (deftest test-unexpected-cmd (throw-ex-parsing? "{% echo 3 %}")) + +;; integration tests + +(deftest test-parsing-errors + (testing "Closing tag is missing" + (test-fails "test-resources/failures/test-syntax-nonclosed.docx" nil + ParsingException "Missing {%end%} tag from document!")) + (testing "Extra closing tag is present" + (test-fails "test-resources/failures/test-syntax-closed.docx" nil + ParsingException "Too many {%end%} tags!")) + (testing "A tag not closed until the end of document" + (test-fails "test-resources/failures/test-syntax-incomplete.docx" nil + ParsingException "Stencil tag is not closed. Reading {% if x + y")) + (testing "Unexpected {%else%} tag" + (test-fails "test-resources/failures/test-syntax-unexpected-else.docx" nil + ParsingException "Unexpected {%else%} tag, it must come right after a condition!")) + (testing "Unexpected {%else if%} tag" + (test-fails "test-resources/failures/test-syntax-unexpected-elif.docx" nil + ParsingException "Unexpected {%else if%} tag, it must come right after a condition!")) + (testing "Cannot parse infix expression" + (test-fails "test-resources/failures/test-syntax-fails.docx" nil + ParsingException "Invalid stencil expression!")) + (testing "Test unexpected command" + (test-fails "test-resources/failures/test-syntax-unexpected-command.docx" nil + ParsingException "Unexpected command: unexpected"))) + +(deftest test-evaluation-errors + (testing "Division by zero" + (test-fails "test-resources/failures/test-eval-division.docx" {:x 1 :y 0} + EvalException "Error evaluating expression: {%=x/y%}" + java.lang.ArithmeticException "Divide by zero")) + (testing "NPE" + (test-fails "test-resources/failures/test-eval-division.docx" {:x nil :y nil} + EvalException "Error evaluating expression: {%=x/y%}" + java.lang.NullPointerException nil #_"Cannot invoke \"Object.getClass()\" because \"x\" is null")) + (testing "function does not exist" + (test-fails "test-resources/failures/test-no-such-fn.docx" {} + EvalException "Error evaluating expression: {%=nofun()%}" + java.lang.IllegalArgumentException "Did not find function for name nofun")) + (testing "function invoked with wrong arity" + (test-fails "test-resources/failures/test-syntax-arity.docx" {} + EvalException "Error evaluating expression: {%=decimal(1,2,3)%}" + clojure.lang.ExceptionInfo "Function 'decimal' was called with a wrong number of arguments (3)")) + (testing "Missing fragment" + (test-fails "test-resources/multipart/main.docx" {} + EvalException "No fragment for name: body")) + (testing "date() function has custom message" + (test-fails "test-resources/test-function-date.docx" {"date" "2022-01-04XXX11:22:33"} + EvalException "Error evaluating expression: {%=date(\"yyyy-MM-dd\", date)%}" + IllegalArgumentException "Could not parse date object 2022-01-04XXX11:22:33")) + + (testing "function invocation error" + ;; TODO: invoke fn with wrong types + )) \ No newline at end of file diff --git a/test/stencil/eval_test.clj b/test/stencil/eval_test.clj index c4b199a8..8eeea0a4 100644 --- a/test/stencil/eval_test.clj +++ b/test/stencil/eval_test.clj @@ -23,7 +23,7 @@ (deftest test-if (testing "THEN branch" (test-eval [-text1- - {:cmd :if :condition '[truthy] + {:cmd :if :condition 'truthy :then [{:text "ok"}] :else [{:text "err"}]} -text1-] @@ -33,7 +33,7 @@ (testing "ELSE branch" (test-eval [-text1- - {:cmd :if :condition '[falsey] + {:cmd :if :condition 'falsey :then [{:text "ok"}] :else [{:text "err"}]}] [-text1- @@ -41,17 +41,18 @@ (deftest test-echo (testing "Simple math expression" - (test-eval [{:cmd :echo :expression '[1 2 :plus]}] + (test-eval [{:cmd :echo :expression [:plus 1 2]}] [{:text "3"}])) (testing "Nested data access with path" - (test-eval [{:cmd :echo :expression '[abc.def]}] + (test-eval [{:cmd :echo :expression 'abc.def}] [{:text "Okay"}]))) (deftest test-for (testing "loop without any items" (test-eval [{:cmd :for :variable "index" - :expression '[list0] + :index-var "i" + :expression 'list0 :body-run-once [{:text "xx"}] :body-run-none [{:text "meh"}] :body-run-next [{:text "x"}]}] @@ -60,17 +61,29 @@ (testing "loop with exactly 1 item" (test-eval [{:cmd :for :variable "index" - :expression '[list1] - :body-run-once [{:cmd :echo :expression '[index]}] + :index-var "i" + :expression 'list1 + :body-run-once [{:cmd :echo :expression 'index}] :body-run-none [{:text "meh"}] :body-run-next [{:text "x"}]}] [{:text "1"}])) + (testing "loop with exactly 1 item and index var used" + (test-eval [{:cmd :for + :variable "index" + :index-var "i" + :expression 'abc + :body-run-once [{:cmd :echo :expression 'i} {:text "==>"} {:cmd :echo :expression 'index}] + :body-run-none [{:text "should-not-run"}] + :body-run-next [{:text "should-not-run"}]}] + [{:text "def"} {:text "==>"} {:text "Okay"}])) + (testing "loop with exactly 3 items" (test-eval [{:cmd :for :variable "index" - :expression '[list3] - :body-run-once [{:cmd :echo :expression '[index]}] + :index-var "i" + :expression 'list3 + :body-run-once [{:cmd :echo :expression 'index}] :body-run-none [{:text "meh"}] - :body-run-next [{:text "x"} {:cmd :echo :expression '[index]}]}] + :body-run-next [{:text "x"} {:cmd :echo :expression 'index}]}] [{:text "1"} {:text "x"} {:text "2"} {:text "x"} {:text "3"}]))) diff --git a/test/stencil/functions_test.clj b/test/stencil/functions_test.clj index e36908e3..6bcfde63 100644 --- a/test/stencil/functions_test.clj +++ b/test/stencil/functions_test.clj @@ -27,6 +27,8 @@ (is (= "Hello null" (call-fn "format" "Hello %c" nil))) (is (= "Hello x" (call-fn "format" "Hello %c" "x"))) (is (= "Hello X" (call-fn "format" "Hello %C" \x)))) + (testing "decimal precision" + (is (= "0.33" (call-fn "format" "%,.2f" 1/3)))) (testing "Indexed parameters" (is (= "hello 42 41.00" (call-fn "format" "hello %2$d %1$,.2f" 41.0 42.0)))) (is (= "hello john" (call-fn "format" "hello %s" "john"))) @@ -104,6 +106,11 @@ (is (thrown? ExceptionInfo (call-fn "map" "x" {:x 1 :y 2}))) (is (thrown? ExceptionInfo (call-fn "map" 1 []))))) +(deftest test-coalesce + (is (= 1 (call-fn "coalesce" 1 2))) + (is (= 1 (call-fn "coalesce" nil nil 1 nil 2 nil))) + (is (= nil (call-fn "coalesce" nil nil nil)))) + (deftest test-join-and (are [expect param] (= expect (call-fn "joinAnd" param ", " " and ")) "" nil @@ -112,6 +119,12 @@ "1 and 2" [1 2] "1, 2 and 3" [1 2 3])) +(deftest test-replace + (is (= "a1c" (call-fn "replace" "abc" "b" "1"))) + (is (= "a1a1" (call-fn "replace" "abab" "b" "1"))) + (is (= "a1a1" (call-fn "replace" "a.a." "." "1"))) + (is (= "123456" (call-fn "replace" " 12 34 56 " " " "")))) + (import '[stencil.types ReplaceImage]) (def data-uri "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==") diff --git a/test/stencil/infix_test.clj b/test/stencil/infix_test.clj index ed575d7a..f259dd0c 100644 --- a/test/stencil/infix_test.clj +++ b/test/stencil/infix_test.clj @@ -1,6 +1,6 @@ (ns stencil.infix-test (:import [clojure.lang ExceptionInfo]) - (:require [stencil.infix :as infix :refer :all] + (:require [stencil.infix :as infix] [stencil.types :refer [hide-table-column-marker?]] [clojure.test :refer [deftest testing is are]])) @@ -8,16 +8,24 @@ ([xs] (run xs {})) ([xs args] (infix/eval-rpn args (infix/parse xs)))) + (deftest tokenize-test (testing "simple fn call" - (is (= [(->FnCall "sin") 1 :plus 2 :close] (infix/tokenize " sin(1+2)")))) + (is (= ['sin :open 1 :plus 2 :close] (infix/tokenize " sin(1+2)")))) (testing "comma" - (is (= [:open 1 :comma 2 :comma 3 :close] (infix/tokenize " (1,2 ,3) "))))) + (is (= [:open 1 :comma 2 :comma 3 :close] (infix/tokenize " (1,2 ,3) ")))) + + (testing "Unexpected end of string" + (try (dorun (infix/tokenize "1 + 2 ##")) + (assert false "should have thrown") + (catch ExceptionInfo e + (is (= "Unexpected end of string" (.getMessage e))) + (is (= {:index 6} (ex-data e))))))) (deftest test-read-string-literal (testing "Incomplete string" - (is (thrown? ExceptionInfo (read-string-literal "'alaba"))))) + (is (thrown? ExceptionInfo (infix/read-string-literal "'alaba"))))) (deftest tokenize-string-literal (testing "spaces are kept" @@ -34,9 +42,9 @@ (deftest tokenize-string-fun-eq (testing "tricky" - (is (= ["1" :eq #stencil.infix.FnCall{:fn-name "str"} 1 :close] + (is (= ["1" :eq 'str :open 1 :close] (infix/tokenize "\"1\" = str(1)"))) - (is (= ["1" 1 {:fn "str" :args 1} :eq] + (is (= [:eq "1" [:fncall 'str 1]] (infix/parse "\"1\" = str(1)"))))) (deftest parse-simple @@ -45,20 +53,20 @@ (is (thrown? ExceptionInfo (infix/parse "")))) (testing "Simple values" - (is (= [12] (infix/parse " 12 ") (infix/parse "12"))) - (is (= '[ax.y] (infix/parse " ax.y ")))) + (is (= 12 (infix/parse " 12 ") (infix/parse "12"))) + (is (= '[:get ax "y"] (infix/parse " ax.y ")))) (testing "Simple operations" - (is (= [1 2 :plus] + (is (= [:plus 1 2] (infix/parse "1 + 2") (infix/parse "1+2 ") (infix/parse "1+2"))) - (is (= [3 2 :times] (infix/parse "3*2")))) + (is (= [:times 3 2] (infix/parse "3*2")))) (testing "Parentheses" - (is (= [3 2 :plus 4 :times] + (is (= [:times [:plus 3 2] 4] (infix/parse "(3+2)*4"))) - (is (= [3 2 :plus 4 1 :minus :times] + (is (= [:times [:plus 3 2] [:minus 4 1]] (infix/parse "(3+2)*(4 - 1)"))))) (deftest all-ops-supported @@ -66,9 +74,8 @@ (let [ops (-> #{} (into (vals infix/ops)) (into (vals infix/ops2)) - (into (keys infix/operation-tokens)) - (disj :open :close :comma :open-bracket :close-bracket)) - known-ops (set (filter keyword? (keys (methods @#'infix/reduce-step))))] + (disj :open :close :comma :open-bracket :close-bracket :dot)) + known-ops (set (filter keyword? (keys (methods @#'infix/eval-tree))))] (is (every? known-ops ops))))) (deftest basic-arithmetic @@ -85,6 +92,7 @@ (is (= 3 (run "coalesce(5, 1, 0) - coalesce(2, 1)"))) (is (= 6 (run "2 * 3"))) (is (= 3 (run "6 / 2"))) + (is (= 0.33333333M (run "1 / x" {"x" (bigdec 3)}))) ;; infinite expansion error (is (= 36.0 (run "6 ^ 2"))) (is (= 2 (run "12 % 5")))) @@ -131,9 +139,18 @@ (is (= 7 (run "a[a[1]+4]" {"a" {"1" 2 "6" 7}}))) (is (= 8 (run "a[1][2]" {"a" {"1" {"2" 8}}})))) + (testing "negation" + (is (= 6 (run "a[1]-2" {"a" {"1" 8}})))) + + (testing "mixed lookup" + (is (= 7 (run "a['x'].y" {"a" {"x" {"y" 7}}}))) + (is (= 8 (run "a.x['y']" {"a" {"x" {"y" 8}}})))) + (testing "syntax error" (is (thrown? ExceptionInfo (run "[3]" {}))) (is (thrown? ExceptionInfo (run "a[[3]]" {}))) + (is (thrown? ExceptionInfo (run "a.[1]b" {}))) + (is (thrown? ExceptionInfo (run "a..b" {}))) (is (thrown? ExceptionInfo (run "a[1,2]" {})))) (testing "key is missing from input" @@ -185,6 +202,8 @@ (is (= 2 (run "a || b" {"b" 2}))) (is (nil? (run "a || b" {"a" false})))))) + + (deftest operator-precedeces (testing "Operator precedencia" (is (= 36 (run "2*3+5*6")))) @@ -276,13 +295,11 @@ (run "sum(map(\"x\", vals))" {"vals" [{"x" 1} {"x" 2} {"x" 3}]}))))) - - (deftest test-colhide-expr (is (hide-table-column-marker? (run "hideColumn()")))) (deftest test-unexpected - (is (thrown? ExceptionInfo (parse "aaaa:bbbb")))) + (is (thrown? ExceptionInfo (infix/parse "aaaa:bbbb")))) (deftest tokenize-wrong-tokens (testing "Misplaced operators and operands" diff --git a/test/stencil/integration.clj b/test/stencil/integration.clj index 115b30b4..6703dd38 100644 --- a/test/stencil/integration.clj +++ b/test/stencil/integration.clj @@ -1,6 +1,7 @@ (ns stencil.integration "Integration test helpers" (:require [clojure.zip :as zip] + [clojure.test :refer [do-report is]] [stencil.api :as api])) @@ -23,3 +24,25 @@ :when (= stencil.ooxml/t (:tag (zip/node node))) c (:content (zip/node node))] c))))) + + +(defn test-fails + "Tests that rendering the template with the payload results in the given exception chain." + [template payload & bodies] + (assert (string? template)) + (assert (not-empty bodies)) + (try (with-open [template (api/prepare template)] + (api/render! template payload + :overwrite? true + :output (java.io.File/createTempFile "stencil" ".docx"))) + (do-report {:type :error + :message "Should have thrown exception" + :expected (first bodies) + :actual nil}) + (catch RuntimeException e + (let [e (reduce (fn [e [t reason]] + (is (instance? t e)) + (or (= t NullPointerException) (is (= reason (.getMessage e)))) + (.getCause e)) + e (partition 2 bodies))] + (is (= nil e) "Cause must be null."))))) \ No newline at end of file diff --git a/test/stencil/merger_test.clj b/test/stencil/merger_test.clj index 40f23f18..2c5c286c 100644 --- a/test/stencil/merger_test.clj +++ b/test/stencil/merger_test.clj @@ -25,6 +25,7 @@ "asdf{%xy%}" {:action "xy" :before "asdf"} "{%xy%}" {:action "xy"} "a{%xy" {:action-part "xy" :before "a"} + "a{%x%" {:action-part "x%" :before "a"} "{%xy" {:action-part "xy"}))) (deftest text-split-tokens-test @@ -105,25 +106,29 @@ [{:text "{%=1%}"}] [{:text "{%=1%}"}] - [{:action {:cmd :echo, :expression [1]}}] + [{:action {:cmd :echo, :expression 1 :raw "{%=1%}"}}] [{:text "abc{%=1%}b"}] [{:text "abc"} {:text "{%=1%}"} {:text "b"}] - [{:text "abc"} {:action {:cmd :echo, :expression [1]}} {:text "b"}] + [{:text "abc"} {:action {:cmd :echo, :expression 1 :raw "{%=1%}"}} {:text "b"}] [{:text "abc{%="} O1 O2 {:text "1"} O3 O4 {:text "%}b"}] [{:text "abc"} {:text "{%="} O1 O2 {:text "1"} O3 O4 {:text "%}b"}] - [{:text "abc"} {:action {:cmd :echo, :expression [1]}} O1 O2 O3 O4 {:text "b"}] + [{:text "abc"} {:action {:cmd :echo, :expression 1 :raw "{%=1%}"}} O1 O2 O3 O4 {:text "b"}] [{:text "abc{%="} O1 O2 {:text "1%"} O3 O4 {:text "}b"}] [{:text "abc"} {:text "{%="} O1 O2 {:text "1%"} O3 O4 {:text "}b"}] - [{:text "abc"} {:action {:cmd :echo, :expression [1]}} O1 O2 O3 O4 {:text "b"}] + [{:text "abc"} {:action {:cmd :echo, :expression 1 :raw "{%=1%}"}} O1 O2 O3 O4 {:text "b"}] [{:text "abcd{%="} O1 {:text "1"} O2 {:text "%"} O3 {:text "}"} O4 {:text "b"}] [{:text "abcd"} {:text "{%="} O1 {:text "1"} O2 {:text "%"} O3 {:text "}"} O4 {:text "b"}] - [{:text "abcd"} {:action {:cmd :echo, :expression [1]}} O1 O2 O3 O4{:text "b"}] + [{:text "abcd"} {:action {:cmd :echo, :expression 1 :raw "{%=1%}"}} O1 O2 O3 O4{:text "b"}] [{:text "abc{"} O1 {:text "%"} O2 {:text "=1"} O3 {:text "2"} O4 {:text "%"} O5 {:text "}"} {:text "b"}] [{:text "abc"} {:text "{"} O1 {:text "%"} O2 {:text "=1"} O3 {:text "2"} O4 {:text "%"} O5 {:text "}"} {:text "b"}] - [{:text "abc"} {:action {:cmd :echo, :expression [12]}} O1 O2 O3 O4 O5 {:text "b"}] + [{:text "abc"} {:action {:cmd :echo, :expression 12 :raw "{%=12%}"}} O1 O2 O3 O4 O5 {:text "b"}] + + [O1 {:text "{%if p"} O2 O3 {:text "%}one{%end%}"} O4] + [O1 {:text "{%if p"} O2 O3 {:text "%}one"} {:text "{%end%}"} O4] + [O1 {:action {:cmd :if, :condition 'p :raw "{%if p%}"}} O2 O3 {:text "one"} {:action {:cmd :end :raw "{%end%}"}} O4] )))) diff --git a/test/stencil/postprocess/fragments_test.clj b/test/stencil/postprocess/fragments_test.clj new file mode 100644 index 00000000..5ab0a94c --- /dev/null +++ b/test/stencil/postprocess/fragments_test.clj @@ -0,0 +1,25 @@ +(ns stencil.postprocess.fragments-test + (:require [clojure.test :refer [deftest testing is are]] + [clojure.zip :as zip] + [stencil.ooxml :as ooxml] + [stencil.util :refer [find-first-in-tree xml-zip]] + [stencil.postprocess.fragments :refer :all])) + +(defn- p [& xs] {:tag ooxml/p :content (vec xs)}) +(defn- r [& xs] {:tag ooxml/r :content (vec xs)}) +(defn- t [& xs] {:tag ooxml/t :content (vec xs)}) + +(def -split-paragraphs @#'stencil.postprocess.fragments/split-paragraphs) + +(deftest test-split-paragraphs-1 + (let [data {:tag :root :content [(p (r (t "hello" :HERE) (t "vilag")))]} + loc (find-first-in-tree #{:HERE} (xml-zip data))] + (-> loc + (doto assert) + (-split-paragraphs (p (r (t "one"))) (p (r (t "two")))) + (zip/root) + (= {:tag :root + :content [(p (r (t "hello"))) ;; before + (p (r (t "one"))) (p (r (t "two"))) ;; newly inserted + (p (r (t) (t "vilag")))]}) ;; after + (is)))) \ No newline at end of file diff --git a/test/stencil/postprocess/whitespaces_test.clj b/test/stencil/postprocess/whitespaces_test.clj index 8705444d..28581186 100644 --- a/test/stencil/postprocess/whitespaces_test.clj +++ b/test/stencil/postprocess/whitespaces_test.clj @@ -26,6 +26,11 @@ "Sum: 1 pieces" "Sum: {%=x %} pieces" {"x" 1})) + (testing "newline value splits t tags" + (test-equals + "two lines: firstsecond " + "two lines: {%=x %} " + {"x" "first\nsecond"})) (testing "existing space=preserve attributes are kept intact" (test-equals " Hello " @@ -33,3 +38,20 @@ {}))) ;; (test-eval "Sum: {%=x %} pieces" {"x" 1}) + +(let [target (the-ns 'stencil.postprocess.whitespaces)] + (doseq [[k v] (ns-map target) + :when (and (var? v) (= target (.ns v)))] + (eval `(defn ~(symbol (str "-" k)) [~'& args#] (apply (deref ~v) args#))))) + +(deftest test-lines-of + (is (= ["ab" "\n" "\n" "bc"] (-lines-of "ab\n\nbc"))) + (is (= ["\n" "xy" "\n"] (-lines-of "\nxy\n"))) + (is (= () (-lines-of "")))) + +(deftest test-multi-replace + (let [tree (stencil.util/xml-zip {:tag :a :content ["x" "y" "z"]}) + loc (clojure.zip/right (clojure.zip/down tree)) + replaced (-multi-replace loc ["1" "2" "3"])] + (is (= "3" (clojure.zip/node replaced))) + (is (= ["x" "1" "2" "3" "z"] (:content (clojure.zip/root replaced)))))) \ No newline at end of file diff --git a/test/stencil/process_test.clj b/test/stencil/process_test.clj index 2e30dbce..1c738414 100644 --- a/test/stencil/process_test.clj +++ b/test/stencil/process_test.clj @@ -28,7 +28,7 @@ (is (= {:dynamic? true, :executable [{:open :a} {:open :b} - {:blocks [], :cmd :cmd/include, :name "elefant"} + {:stencil.cleanup/blocks [], :cmd :cmd/include, :name "elefant" :raw "{%include \"elefant\"%}"} {:close :b} {:close :a}], :fragments #{"elefant"} diff --git a/test/stencil/tokenizer_test.clj b/test/stencil/tokenizer_test.clj index 200f66d4..6376d3e0 100644 --- a/test/stencil/tokenizer_test.clj +++ b/test/stencil/tokenizer_test.clj @@ -3,7 +3,9 @@ [clojure.test :refer [deftest testing is]])) (defn- run [s] - (m/parse-to-tokens-seq (java.io.ByteArrayInputStream. (.getBytes (str s))))) + (->> (java.io.ByteArrayInputStream. (.getBytes (str s))) + (m/parse-to-tokens-seq) + (map #(dissoc % :raw)))) (deftest read-tokens-nested (testing "Read a list of nested tokens" @@ -28,16 +30,16 @@ (is (= (run "elotte {%=a%} utana") [{:open :a} {:text "elotte "} - {:cmd :echo :expression '(a)} + {:cmd :echo :expression 'a} {:text " utana"} {:close :a}])))) (deftest read-tokens-if-then (testing "Simple conditional with THEN branch only" - (is (= (run "elotte {% if x%} akkor {% end %} utana") + (is (= (run "elotte {% if x%} akkor {% end %} utana") [{:open :a} {:text "elotte "} - {:cmd :if :condition '(x)} + {:cmd :if :condition 'x} {:text " akkor "} {:cmd :end} {:text " utana"} @@ -48,7 +50,7 @@ (is (= (run "elotte {% if x%} akkor {% else %} egyebkent {% end %} utana") [{:open :a} {:text "elotte "} - {:cmd :if :condition '(x)} + {:cmd :if :condition 'x} {:text " akkor "} {:cmd :else} {:text " egyebkent "} @@ -61,9 +63,9 @@ (is (= (run "elotte{%if x%}akkor{%else if y%}de{%end%}utana") '[{:open :a} {:text "elotte"} - {:cmd :if :condition [x]} + {:cmd :if :condition x} {:text "akkor"} - {:cmd :else-if :condition [y]} + {:cmd :else-if :condition y} {:text "de"} {:cmd :end} {:text "utana"} @@ -74,9 +76,9 @@ (is (= (run "elotte{%if x%}akkor{%else if y%}de{%else%}egyebkent{%end%}utana") '[{:open :a} {:text "elotte"} - {:cmd :if :condition [x]} + {:cmd :if :condition x} {:text "akkor"} - {:cmd :else-if :condition [y]} + {:cmd :else-if :condition y} {:text "de"} {:cmd :else} {:text "egyebkent"} @@ -87,17 +89,17 @@ (deftest read-tokens-unless-then (testing "Simple conditional with THEN branch only" (is (= (run "{%unless x%} akkor {% end %}") - [{:open :a} {:cmd :if :condition '(x :not)} {:text " akkor "} {:cmd :end} {:close :a}])))) + [{:open :a} {:cmd :if :condition '(:not x)} {:text " akkor "} {:cmd :end} {:close :a}])))) (deftest read-tokens-unless-then-else (testing "Simple conditional with THEN branch only" (is (= (run "{%unless x%} akkor {%else%} egyebkent {%end %}") - [{:open :a} {:cmd :if :condition '(x :not)} {:text " akkor "} {:cmd :else} {:text " egyebkent "} {:cmd :end} {:close :a}])))) + [{:open :a} {:cmd :if :condition '(:not x)} {:text " akkor "} {:cmd :else} {:text " egyebkent "} {:cmd :end} {:close :a}])))) (deftest read-tokens-if-elif-then-else (testing "If-else if-then-else branching" - (is (= '({:open :a} {:text "Hello "} {:cmd :if, :condition [x]} {:text "iksz"} - {:cmd :else-if, :condition [y]} {:text "ipszilon"} {:cmd :else} + (is (= '({:open :a} {:text "Hello "} {:cmd :if, :condition x} {:text "iksz"} + {:cmd :else-if, :condition y} {:text "ipszilon"} {:cmd :else} {:text "egyebkent"} {:cmd :end} {:text " Hola"} {:close :a}) (run "Hello {%if x%}iksz{%else if y%}ipszilon{%else%}egyebkent{%end%} Hola") (run "Hello {%if x%}iksz{%elseif y%}ipszilon{%else%}egyebkent{%end%} Hola") @@ -105,8 +107,12 @@ (run "Hello {%if x%}iksz{%elif y%}ipszilon{%else%}egyebkent{%end%} Hola"))))) (deftest read-tokens-for + (testing "Indexed loop" + (is (= '[{:open :a} {:cmd :for, :expression xs, :variable x :index-var idx} + {:text "item"} {:cmd :end} {:close :a}] + (run "{%for idx, x in xs%}item{% end %}")))) (testing "Simple loop" - (is (= '[{:open :a} {:cmd :for, :expression [xs], :variable x} + (is (= '[{:open :a} {:cmd :for, :expression xs, :variable x :index-var $} {:text "item"} {:cmd :end} {:close :a}] (run "{%for x in xs%}item{% end %}"))))) diff --git a/test/stencil/util_test.clj b/test/stencil/util_test.clj index 2231d625..853b59af 100644 --- a/test/stencil/util_test.clj +++ b/test/stencil/util_test.clj @@ -1,5 +1,5 @@ (ns stencil.util-test - (:require [clojure.test :refer [deftest testing is]] + (:require [clojure.test :refer [deftest testing is are]] [clojure.zip :as zip] [stencil.util :refer :all])) @@ -19,7 +19,7 @@ (deftest mod-stack-top-last-test (testing "Invalid input" (is (thrown? IllegalStateException (mod-stack-top-last '([]) inc))) - (is (thrown? NullPointerException (mod-stack-top-last '() inc)))) + (is (thrown? IllegalStateException (mod-stack-top-last '() inc)))) (testing "simple cases" (is (= '([3]) (mod-stack-top-last '([2]) inc))) @@ -104,3 +104,16 @@ (deftest suffixes-test (is (= [] (suffixes []) (suffixes nil))) (is (= [[1 2 3] [2 3] [3]] (suffixes [1 2 3])))) + +(deftest whitespace?-test + (is (= true (whitespace? \space))) + (is (= true (whitespace? \tab))) + (is (= false (whitespace? " "))) + (is (= false (whitespace? \A)))) + +(deftest trim-test + (are [input] (= "" (trim input)) + "", " ", "\t\t\n") + (are [input] (= "abc" (trim input)) + "abc", " abc", "abc ", " \t \n abc \t") + (is (= "a b c" (trim " a b c \t"))))