/** * Mustache template engine for D * * Implemented according to mustach(5). * * Copyright: Copyright Masahiro Nakagawa 2011-. * License: Boost License 1.0. * Authors: Masahiro Nakagawa */ module mustache; import std.algorithm : all; import std.array; // empty, back, popBack, appender import std.conv; // to import std.datetime; // SysTime (I think std.file should import std.datetime as public) import std.file; // read, timeLastModified import std.path; // buildPath import std.range; // isOutputRange import std.string; // strip, chomp, stripLeft import std.traits; // isSomeString, isAssociativeArray static import std.ascii; // isWhite; version(unittest) import core.thread; /** * Exception for Mustache */ class MustacheException : Exception { this(string messaage) { super(messaage); } } /** * Core implementation of Mustache * * $(D_PARAM String) parameter means a string type to render. * * Example: * ----- * alias MustacheEngine!(string) Mustache; * * Mustache mustache; * auto context = new Mustache.Context; * * context["name"] = "Chris"; * context["value"] = 10000; * context["taxed_value"] = 10000 - (10000 * 0.4); * context.useSection("in_ca"); * * write(mustache.render("sample", context)); * ----- * sample.mustache: * ----- * Hello {{name}} * You have just won ${{value}}! * {{#in_ca}} * Well, ${{taxed_value}}, after taxes. * {{/in_ca}} * ----- * Output: * ----- * Hello Chris * You have just won $10000! * Well, $6000, after taxes. * ----- */ struct MustacheEngine(String = string) if (isSomeString!(String)) { static assert(!is(String == wstring), "wstring is unsupported. It's a buggy!"); public: alias String delegate(String) Handler; alias string delegate(string) FindPath; /** * Cache level for compile result */ static enum CacheLevel { no, /// No caching check, /// Caches compiled result and checks the freshness of template once /// Caches compiled result but not check the freshness of template } /** * Options for rendering */ static struct Option { string ext = "mustache"; /// template file extenstion string path = "."; /// root path for template file searching FindPath findPath; /// dynamically finds the path for a name CacheLevel level = CacheLevel.check; /// See CacheLevel Handler handler; /// Callback handler for unknown name } /** * Mustache context for setting values * * Variable: * ----- * //{{name}} to "Chris" * context["name"] = "Chirs" * ----- * * Lists section("addSubContext" name is drived from ctemplate's API): * ----- * //{{#repo}} * //{{name}} * //{{/repo}} * // to * //resque * //hub * //rip * foreach (name; ["resque", "hub", "rip"]) { * auto sub = context.addSubContext("repo"); * sub["name"] = name; * } * ----- * * Variable section: * ----- * //{{#person?}}Hi {{name}}{{/person?}} to "Hi Jon" * context["person?"] = ["name" : "Jon"]; * ----- * * Lambdas section: * ----- * //{{#wrapped}}awesome{{/wrapped}} to "awesome" * context["Wrapped"] = (string str) { return "" ~ str ~ ""; }; * ----- * * Inverted section: * ----- * //{{#repo}}{{name}}{{/repo}} * //{{^repo}}No repos :({{/repo}} * // to * //No repos :( * context["foo"] = "bar"; // not set to "repo" * ----- */ static final class Context { private: enum SectionType { nil, use, var, func, list } struct Section { SectionType type; union { String[String] var; String delegate(String) func; // func type is String delegate(String) delegate()? Context[] list; } @trusted nothrow { this(bool u) { type = SectionType.use; } this(String[String] v) { type = SectionType.var; var = v; } this(String delegate(String) f) { type = SectionType.func; func = f; } this(Context c) { type = SectionType.list; list = [c]; } this(Context[] c) { type = SectionType.list; list = c; } } /* nothrow : AA's length is not nothrow */ @trusted @property bool empty() const { final switch (type) { case SectionType.nil: return true; case SectionType.use: return false; case SectionType.var: return !var.length; // Why? case SectionType.func: return func is null; case SectionType.list: return !list.length; } } /* Convenience function */ @safe @property static Section nil() nothrow { Section result; result.type = SectionType.nil; return result; } } const Context parent; String[String] variables; Section[String] sections; public: @safe this(in Context context = null) nothrow { parent = context; } /** * Gets $(D_PARAM key)'s value. This method does not search Section. * * Params: * key = key string to search * * Returns: * a $(D_PARAM key) associated value. * * Throws: * a RangeError if $(D_PARAM key) does not exist. */ @safe String opIndex(in String key) const nothrow { return variables[key]; } /** * Assigns $(D_PARAM value)(automatically convert to String) to $(D_PARAM key) field. * * If you try to assign associative array or delegate, * This method assigns $(D_PARAM value) as Section. * * Arrays of Contexts are accepted, too. * * Params: * value = some type value to assign * key = key string to assign */ @trusted void opIndexAssign(T)(T value, in String key) { static if (isAssociativeArray!(T)) { static if (is(T V : V[K], K : String)) { String[String] aa; static if (is(V == String)) aa = value; else foreach (k, v; value) aa[k] = to!String(v); sections[key] = Section(aa); } else static assert(false, "Non-supported Associative Array type"); } else static if (isCallable!T) { import std.functional : toDelegate; auto v = toDelegate(value); static if (is(typeof(v) D == S delegate(S), S : String)) sections[key] = Section(v); else static assert(false, "Non-supported delegate type"); } else static if (isArray!T && !isSomeString!T) { static if (is(T : Context[])) sections[key] = Section(value); else static assert(false, "Non-supported array type"); } else { variables[key] = to!String(value); } } /** * Enable $(D_PARAM key)'s section. * * Params: * key = key string to enable * * NOTE: * I don't like this method, but D's typing can't well-handle Ruby's typing. */ @safe void useSection(in String key) { sections[key] = Section(true); } /** * Adds new context to $(D_PARAM key)'s section. This method overwrites with * list type if you already assigned other type to $(D_PARAM key)'s section. * * Params: * key = key string to add * size = reserve size for avoiding reallocation * * Returns: * new Context object that added to $(D_PARAM key) section list. */ @trusted Context addSubContext(in String key, lazy size_t size = 1) { auto c = new Context(this); auto p = key in sections; if (!p || p.type != SectionType.list) { sections[key] = Section(c); sections[key].list.reserve(size); } else { sections[key].list ~= c; } return c; } private: /* * Fetches $(D_PARAM)'s value. This method follows parent context. * * Params: * key = key string to fetch * * Returns: * a $(D_PARAM key) associated value. null if key does not exist. */ @trusted String fetch(in String[] key, lazy Handler handler = null) const { assert(key.length > 0); if (key.length == 1) { auto result = key[0] in variables; if (result !is null) return *result; if (parent !is null) return parent.fetch(key, handler); } else { auto contexts = fetchList(key[0..$-1]); foreach (c; contexts) { auto result = key[$-1] in c.variables; if (result !is null) return *result; } } return handler is null ? null : handler()(keyToString(key)); } @trusted const(Section) fetchSection()(in String[] key) const /* nothrow */ { assert(key.length > 0); // Ascend context tree to find the key's beginning auto currentSection = key[0] in sections; if (currentSection is null) { if (parent is null) return Section.nil; return parent.fetchSection(key); } // Decend context tree to match the rest of the key size_t keyIndex = 0; while (currentSection) { // Matched the entire key? if (keyIndex == key.length-1) return currentSection.empty ? Section.nil : *currentSection; if (currentSection.type != SectionType.list) return Section.nil; // Can't decend any further // Find next part of key keyIndex++; foreach (c; currentSection.list) { currentSection = key[keyIndex] in c.sections; if (currentSection) break; } } return Section.nil; } @trusted const(Result) fetchSection(Result, SectionType type, string name)(in String[] key) const /* nothrow */ { auto result = fetchSection(key); if (result.type == type) return result.empty ? null : mixin("result." ~ to!string(type)); return null; } alias fetchSection!(String[String], SectionType.var, "Var") fetchVar; alias fetchSection!(Context[], SectionType.list, "List") fetchList; alias fetchSection!(String delegate(String), SectionType.func, "Func") fetchFunc; } unittest { Context context = new Context(); context["name"] = "Red Bull"; assert(context["name"] == "Red Bull"); context["price"] = 275; assert(context["price"] == "275"); { // list foreach (i; 100..105) { auto sub = context.addSubContext("sub"); sub["num"] = i; foreach (b; [true, false]) { auto subsub = sub.addSubContext("subsub"); subsub["To be or not to be"] = b; } } foreach (i, sub; context.fetchList(["sub"])) { assert(sub.fetch(["name"]) == "Red Bull"); assert(sub["num"] == to!String(i + 100)); foreach (j, subsub; sub.fetchList(["subsub"])) { assert(subsub.fetch(["price"]) == to!String(275)); assert(subsub["To be or not to be"] == to!String(j == 0)); } } } { // variable String[String] aa = ["name" : "Ritsu"]; context["Value"] = aa; assert(context.fetchVar(["Value"]) == cast(const)aa); } { // func auto func = function (String str) { return "" ~ str ~ ""; }; context["Wrapped"] = func; assert(context.fetchFunc(["Wrapped"])("Ritsu") == func("Ritsu")); } { // handler Handler fixme = delegate String(String s) { assert(s=="unknown"); return "FIXME"; }; Handler error = delegate String(String s) { assert(s=="unknown"); throw new MustacheException("Unknow"); }; assert(context.fetch(["unknown"]) == ""); assert(context.fetch(["unknown"], fixme) == "FIXME"); try { assert(context.fetch(["unknown"], error) == ""); assert(false); } catch (const MustacheException e) { } } { // subcontext auto sub = new Context(); sub["num"] = 42; context["a"] = [sub]; auto list = context.fetchList(["a"]); assert(list.length == 1); foreach (i, s; list) assert(s["num"] == to!String(42)); } } private: // Internal cache struct Cache { Node[] compiled; SysTime modified; } Option option_; Cache[string] caches_; public: @safe this(Option option) nothrow { option_ = option; } @property @safe nothrow { /** * Property for template extenstion */ const(string) ext() const { return option_.ext; } /// ditto void ext(string ext) { option_.ext = ext; } /** * Property for template searche path */ const(string) path() const { return option_.path; } /// ditto void path(string path) { option_.path = path; } /** * Property for callback to dynamically search path. * The result of the delegate should return the full path for * the given name. */ FindPath findPath() const { return option_.findPath; } /// ditto void findPath(FindPath findPath) { option_.findPath = findPath; } /** * Property for cache level */ const(CacheLevel) level() const { return option_.level; } /// ditto void level(CacheLevel level) { option_.level = level; } /** * Property for callback handler */ const(Handler) handler() const { return option_.handler; } /// ditto void handler(Handler handler) { option_.handler = handler; } } /** * Clears the intenal cache. * Useful for forcing reloads when using CacheLevel.once. */ @safe void clearCache() { caches_ = null; } /** * Renders $(D_PARAM name) template with $(D_PARAM context). * * This method stores compile result in memory if you set check or once CacheLevel. * * Params: * name = template name without extenstion * context = Mustache context for rendering * * Returns: * rendered result. * * Throws: * object.Exception if String alignment is mismatched from template file. */ String render()(in string name, in Context context) { auto sink = appender!String(); render(name, context, sink); return sink.data; } /** * OutputRange version of $(D render). */ void render(Sink)(in string name, in Context context, ref Sink sink) if(isOutputRange!(Sink, String)) { /* * Helper for file reading * * Throws: * object.Exception if alignment is mismatched. */ @trusted static String readFile(string file) { // cast checks character encoding alignment. return cast(String)read(file); } string file; if (option_.findPath) { file = option_.findPath(name); } else { file = buildPath(option_.path, name ~ "." ~ option_.ext); } Node[] nodes; final switch (option_.level) { case CacheLevel.no: nodes = compile(readFile(file)); break; case CacheLevel.check: auto t = timeLastModified(file); auto p = file in caches_; if (!p || t > p.modified) caches_[file] = Cache(compile(readFile(file)), t); nodes = caches_[file].compiled; break; case CacheLevel.once: if (file !in caches_) caches_[file] = Cache(compile(readFile(file)), SysTime.min); nodes = caches_[file].compiled; break; } renderImpl(nodes, context, sink); } /** * string version of $(D render). */ String renderString()(in String src, in Context context) { auto sink = appender!String(); renderString(src, context, sink); return sink.data; } /** * string/OutputRange version of $(D render). */ void renderString(Sink)(in String src, in Context context, ref Sink sink) if(isOutputRange!(Sink, String)) { renderImpl(compile(src), context, sink); } private: /* * Implemention of render function. */ void renderImpl(Sink)(in Node[] nodes, in Context context, ref Sink sink) if(isOutputRange!(Sink, String)) { // helper for HTML escape(original function from std.xml.encode) static void encode(in String text, ref Sink sink) { size_t index; foreach (i, c; text) { String temp; switch (c) { case '&': temp = "&"; break; case '"': temp = """; break; case '<': temp = "<"; break; case '>': temp = ">"; break; default: continue; } sink.put(text[index .. i]); sink.put(temp); index = i + 1; } sink.put(text[index .. $]); } foreach (ref node; nodes) { final switch (node.type) { case NodeType.text: sink.put(node.text); break; case NodeType.var: auto value = context.fetch(node.key, option_.handler); if (value) { if(node.flag) sink.put(value); else encode(value, sink); } break; case NodeType.section: auto section = context.fetchSection(node.key); final switch (section.type) { case Context.SectionType.nil: if (node.flag) renderImpl(node.childs, context, sink); break; case Context.SectionType.use: if (!node.flag) renderImpl(node.childs, context, sink); break; case Context.SectionType.var: auto var = section.var; auto sub = new Context(context); foreach (k, v; var) sub[k] = v; renderImpl(node.childs, sub, sink); break; case Context.SectionType.func: auto func = section.func; renderImpl(compile(func(node.source)), context, sink); break; case Context.SectionType.list: auto list = section.list; if (!node.flag) { foreach (sub; list) renderImpl(node.childs, sub, sink); } break; } break; case NodeType.partial: render(to!string(node.key.front), context, sink); break; } } } unittest { MustacheEngine!(String) m; auto render = (String str, Context c) => m.renderString(str, c); { // var auto context = new Context; context["name"] = "Ritsu & Mio"; assert(render("Hello {{name}}", context) == "Hello Ritsu & Mio"); assert(render("Hello {{&name}}", context) == "Hello Ritsu & Mio"); assert(render("Hello {{{name}}}", context) == "Hello Ritsu & Mio"); } { // var with handler auto context = new Context; context["name"] = "Ritsu & Mio"; m.handler = delegate String(String s) { assert(s=="unknown"); return "FIXME"; }; assert(render("Hello {{unknown}}", context) == "Hello FIXME"); m.handler = delegate String(String s) { assert(s=="unknown"); throw new MustacheException("Unknow"); }; try { assert(render("Hello {{&unknown}}", context) == "Hello Ritsu & Mio"); assert(false); } catch (const MustacheException e) {} m.handler = null; } { // list section auto context = new Context; foreach (name; ["resque", "hub", "rip"]) { auto sub = context.addSubContext("repo"); sub["name"] = name; } assert(render("{{#repo}}\n {{name}}\n{{/repo}}", context) == " resque\n hub\n rip\n"); } { // var section auto context = new Context; String[String] aa = ["name" : "Ritsu"]; context["person?"] = aa; assert(render("{{#person?}} Hi {{name}}!\n{{/person?}}", context) == " Hi Ritsu!\n"); } { // inverted section { String temp = "{{#repo}}\n{{name}}\n{{/repo}}\n{{^repo}}\nNo repos :(\n{{/repo}}\n"; auto context = new Context; assert(render(temp, context) == "\nNo repos :(\n"); String[String] aa; context["person?"] = aa; assert(render(temp, context) == "\nNo repos :(\n"); } { auto temp = "{{^section}}This shouldn't be seen.{{/section}}"; auto context = new Context; context.addSubContext("section")["foo"] = "bar"; assert(render(temp, context).empty); } } { // comment auto context = new Context; assert(render("