From e8a479c02a5b9c61b1948b2db28cad36b966f3d2 Mon Sep 17 00:00:00 2001 From: Conor Bronsdon Date: Tue, 7 Jul 2026 20:08:27 -0700 Subject: [PATCH 1/2] style: commit canonical mojo format output --- examples/render_email.mojo | 9 +- src/template/lexer.mojo | 3 +- src/template/parser.mojo | 230 +++++++++++++++++++++++++++---------- src/template/render.mojo | 45 ++++++-- test/test_template.mojo | 59 ++++++++-- 5 files changed, 259 insertions(+), 87 deletions(-) diff --git a/examples/render_email.mojo b/examples/render_email.mojo index dc59d70..9cdc79c 100644 --- a/examples/render_email.mojo +++ b/examples/render_email.mojo @@ -9,14 +9,15 @@ from template import render, TemplateValue, Context def main() raises: var ctx = Context() ctx["subject"] = TemplateValue("Weekly digest for & the team") - ctx["user"] = TemplateValue.dict( - ["name"], [TemplateValue("Conor")] - ) + ctx["user"] = TemplateValue.dict(["name"], [TemplateValue("Conor")]) ctx["episodes"] = TemplateValue.list( [ TemplateValue.dict( ["title", "guest"], - [TemplateValue("Scaling inference"), TemplateValue("A. Rivera")], + [ + TemplateValue("Scaling inference"), + TemplateValue("A. Rivera"), + ], ), TemplateValue.dict( ["title", "guest"], diff --git a/src/template/lexer.mojo b/src/template/lexer.mojo index 3453cdd..cdb821a 100644 --- a/src/template/lexer.mojo +++ b/src/template/lexer.mojo @@ -107,8 +107,7 @@ def tokenize(source: String) raises -> List[Token]: j += 1 if close_at == -1: raise Error( - "mojo-template: unclosed tag opened on line " - + String(tag_line) + "mojo-template: unclosed tag opened on line " + String(tag_line) ) var trim_right = ( close_at > content_start and bytes[close_at - 1] == _MINUS diff --git a/src/template/parser.mojo b/src/template/parser.mojo index 7726a3e..2cf2ebb 100644 --- a/src/template/parser.mojo +++ b/src/template/parser.mojo @@ -157,8 +157,11 @@ def _lex_expr(src: String, line: Int) raises -> List[_ETok]: var is_float = False while i < n and _is_digit(bytes[i]): i += 1 - if i < n and Int(bytes[i]) == ord(".") and i + 1 < n and _is_digit( - bytes[i + 1] + if ( + i < n + and Int(bytes[i]) == ord(".") + and i + 1 < n + and _is_digit(bytes[i + 1]) ): is_float = True i += 1 @@ -188,7 +191,9 @@ def _lex_expr(src: String, line: Int) raises -> List[_ETok]: elif Int(e) == ord('"'): buf += '"' else: - buf += String(StringSlice(unsafe_from_utf8=bytes[i + 1 : i + 2])) + buf += String( + StringSlice(unsafe_from_utf8=bytes[i + 1 : i + 2]) + ) i += 2 continue buf += String(StringSlice(unsafe_from_utf8=bytes[i : i + 1])) @@ -277,11 +282,15 @@ struct _Parser(Copyable, Movable): ) return idx - def _p_or(mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int) raises -> Int: + def _p_or( + mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int + ) raises -> Int: if depth > _MAX_PARSE_DEPTH: raise Error( "mojo-template: maximum expression nesting depth exceeded" - " (line " + String(line) + ")" + " (line " + + String(line) + + ")" ) var left = self._p_and(toks, p, line, depth) while toks[p].kind == ET_NAME and toks[p].text == "or": @@ -292,7 +301,9 @@ struct _Parser(Copyable, Movable): ) return left - def _p_and(mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int) raises -> Int: + def _p_and( + mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int + ) raises -> Int: var left = self._p_not(toks, p, line, depth) while toks[p].kind == ET_NAME and toks[p].text == "and": p += 1 @@ -304,21 +315,29 @@ struct _Parser(Copyable, Movable): ) return left - def _p_not(mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int) raises -> Int: + def _p_not( + mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int + ) raises -> Int: if depth > _MAX_PARSE_DEPTH: raise Error( "mojo-template: maximum expression nesting depth exceeded" - " (line " + String(line) + ")" + " (line " + + String(line) + + ")" ) if toks[p].kind == ET_NAME and toks[p].text == "not": p += 1 var operand = self._p_not(toks, p, line, depth + 1) return self._emit( - _Expr(EX_NOT, operand, -1, 0, String(), [], TemplateValue.none()) + _Expr( + EX_NOT, operand, -1, 0, String(), [], TemplateValue.none() + ) ) return self._p_cmp(toks, p, line, depth) - def _p_cmp(mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int) raises -> Int: + def _p_cmp( + mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int + ) raises -> Int: var left = self._p_add(toks, p, line, depth) if toks[p].kind == ET_PUNCT: ref t = toks[p].text @@ -340,13 +359,20 @@ struct _Parser(Copyable, Movable): var right = self._p_add(toks, p, line, depth) return self._emit( _Expr( - EX_CMP, left, right, op, String(), [], + EX_CMP, + left, + right, + op, + String(), + [], TemplateValue.none(), ) ) return left - def _p_add(mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int) raises -> Int: + def _p_add( + mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int + ) raises -> Int: var left = self._p_unary(toks, p, line, depth) while toks[p].kind == ET_PUNCT and ( toks[p].text == "+" or toks[p].text == "-" @@ -357,26 +383,39 @@ struct _Parser(Copyable, Movable): left = self._emit( _Expr( EX_ADD if is_add else EX_SUB, - left, right, 0, String(), [], TemplateValue.none(), + left, + right, + 0, + String(), + [], + TemplateValue.none(), ) ) return left - def _p_unary(mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int) raises -> Int: + def _p_unary( + mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int + ) raises -> Int: if depth > _MAX_PARSE_DEPTH: raise Error( "mojo-template: maximum expression nesting depth exceeded" - " (line " + String(line) + ")" + " (line " + + String(line) + + ")" ) if toks[p].kind == ET_PUNCT and toks[p].text == "-": p += 1 var operand = self._p_unary(toks, p, line, depth + 1) return self._emit( - _Expr(EX_NEG, operand, -1, 0, String(), [], TemplateValue.none()) + _Expr( + EX_NEG, operand, -1, 0, String(), [], TemplateValue.none() + ) ) return self._p_postfix(toks, p, line, depth) - def _p_postfix(mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int) raises -> Int: + def _p_postfix( + mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int + ) raises -> Int: var node = self._p_primary(toks, p, line, depth) # `.attr`, `[idx]`, and `| filter` links accumulate iteratively here, # so a long postfix chain parses at constant recursion depth — but it @@ -396,14 +435,18 @@ struct _Parser(Copyable, Movable): if spine_depth > _MAX_PARSE_DEPTH: raise Error( "mojo-template: maximum expression nesting depth" - " exceeded (line " + String(line) + ")" + " exceeded (line " + + String(line) + + ")" ) if t.kind == ET_PUNCT and t.text == ".": p += 1 if toks[p].kind != ET_NAME: raise Error( "mojo-template: expected attribute name after '.'" - " (line " + String(line) + ")" + " (line " + + String(line) + + ")" ) var attr = toks[p].text.copy() p += 1 @@ -416,12 +459,18 @@ struct _Parser(Copyable, Movable): if not (toks[p].kind == ET_PUNCT and toks[p].text == "]"): raise Error( "mojo-template: expected ']' (line " - + String(line) + ")" + + String(line) + + ")" ) p += 1 node = self._emit( _Expr( - EX_ITEM, node, index, 0, String(), [], + EX_ITEM, + node, + index, + 0, + String(), + [], TemplateValue.none(), ) ) @@ -429,8 +478,9 @@ struct _Parser(Copyable, Movable): p += 1 if toks[p].kind != ET_NAME: raise Error( - "mojo-template: expected filter name after '|'" - " (line " + String(line) + ")" + "mojo-template: expected filter name after '|' (line " + + String(line) + + ")" ) var fname = toks[p].text.copy() p += 1 @@ -445,21 +495,35 @@ struct _Parser(Copyable, Movable): if not (toks[p].kind == ET_PUNCT and toks[p].text == ")"): raise Error( "mojo-template: expected ')' after filter args" - " (line " + String(line) + ")" + " (line " + + String(line) + + ")" ) p += 1 node = self._emit( - _Expr(EX_FILTER, node, -1, 0, fname^, args^, TemplateValue.none()) + _Expr( + EX_FILTER, + node, + -1, + 0, + fname^, + args^, + TemplateValue.none(), + ) ) else: break return node - def _p_primary(mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int) raises -> Int: + def _p_primary( + mut self, toks: List[_ETok], mut p: Int, line: Int, depth: Int + ) raises -> Int: if depth > _MAX_PARSE_DEPTH: raise Error( "mojo-template: maximum expression nesting depth exceeded" - " (line " + String(line) + ")" + " (line " + + String(line) + + ")" ) ref t = toks[p] if t.kind == ET_INT: @@ -476,7 +540,13 @@ struct _Parser(Copyable, Movable): p += 1 return self._emit( _Expr( - EX_LIT, -1, -1, 0, String(), [], TemplateValue(t.text.copy()) + EX_LIT, + -1, + -1, + 0, + String(), + [], + TemplateValue(t.text.copy()), ) ) if t.kind == ET_NAME: @@ -494,9 +564,7 @@ struct _Parser(Copyable, Movable): if nm == "none" or nm == "None": p += 1 return self._emit( - _Expr( - EX_LIT, -1, -1, 0, String(), [], TemplateValue.none() - ) + _Expr(EX_LIT, -1, -1, 0, String(), [], TemplateValue.none()) ) p += 1 return self._emit( @@ -512,8 +580,11 @@ struct _Parser(Copyable, Movable): p += 1 return inner raise Error( - "mojo-template: unexpected token '" + t.text - + "' in expression (line " + String(line) + ")" + "mojo-template: unexpected token '" + + t.text + + "' in expression (line " + + String(line) + + ")" ) # -- statement parsing ------------------------------------------------ @@ -537,13 +608,13 @@ struct _Parser(Copyable, Movable): i += word.byte_length() return String(StringSlice(unsafe_from_utf8=b[i : len(b)])) - def parse_body(mut self, stops: List[String], depth: Int) raises -> List[Int]: + def parse_body( + mut self, stops: List[String], depth: Int + ) raises -> List[Int]: """Parse statements until a block whose keyword is in `stops` (left unconsumed) or EOF. Returns the statement indices.""" if depth > _MAX_PARSE_DEPTH: - raise Error( - "mojo-template: maximum block nesting depth exceeded" - ) + raise Error("mojo-template: maximum block nesting depth exceeded") var body = List[Int]() while self.pos < len(self.tokens): var tok = self.tokens[self.pos].copy() @@ -551,8 +622,15 @@ struct _Parser(Copyable, Movable): body.append( self._emit_stmt( _Stmt( - ST_TEXT, tok.text.copy(), -1, String(), [], [], [], - [], False, + ST_TEXT, + tok.text.copy(), + -1, + String(), + [], + [], + [], + [], + False, ) ) ) @@ -563,7 +641,14 @@ struct _Parser(Copyable, Movable): body.append( self._emit_stmt( _Stmt( - ST_OUTPUT, String(), e, String(), [], [], [], [], + ST_OUTPUT, + String(), + e, + String(), + [], + [], + [], + [], False, ) ) @@ -584,7 +669,10 @@ struct _Parser(Copyable, Movable): else: raise Error( "mojo-template: unknown or misplaced block tag '" - + word + "' (line " + String(tok.line) + ")" + + word + + "' (line " + + String(tok.line) + + ")" ) return body^ @@ -602,7 +690,8 @@ struct _Parser(Copyable, Movable): if self.pos >= len(self.tokens): raise Error( "mojo-template: unclosed {% if %} (opened line " - + String(line) + ")" + + String(line) + + ")" ) var tok = self.tokens[self.pos].copy() var word = self._block_word(tok.text) @@ -621,47 +710,70 @@ struct _Parser(Copyable, Movable): else: raise Error( "mojo-template: expected elif/else/endif, got '" - + word + "' (line " + String(tok.line) + ")" + + word + + "' (line " + + String(tok.line) + + ")" ) return self._emit_stmt( _Stmt( - ST_IF, String(), -1, String(), [], conds^, branches^, - else_body^, has_else, + ST_IF, + String(), + -1, + String(), + [], + conds^, + branches^, + else_body^, + has_else, ) ) - def _parse_for(mut self, inner: String, line: Int, depth: Int) raises -> Int: + def _parse_for( + mut self, inner: String, line: Int, depth: Int + ) raises -> Int: var rest = self._after_word(inner, String("for")) var toks = _lex_expr(rest, line) if toks[0].kind != ET_NAME: raise Error( "mojo-template: expected loop variable in for (line " - + String(line) + ")" + + String(line) + + ")" ) var loop_var = toks[0].text.copy() if not (toks[1].kind == ET_NAME and toks[1].text == "in"): raise Error( "mojo-template: expected 'in' in for (line " - + String(line) + ")" + + String(line) + + ")" ) var p = 2 var iter_expr = self._p_or(toks, p, line, 0) if toks[p].kind != ET_EOF: raise Error( "mojo-template: trailing tokens in for (line " - + String(line) + ")" + + String(line) + + ")" ) self.pos += 1 # consume {% for %} var body = self.parse_body([String("endfor")], depth + 1) if self.pos >= len(self.tokens): raise Error( "mojo-template: unclosed {% for %} (opened line " - + String(line) + ")" + + String(line) + + ")" ) self.pos += 1 # consume {% endfor %} return self._emit_stmt( _Stmt( - ST_FOR, String(), iter_expr, loop_var^, body^, [], [], [], + ST_FOR, + String(), + iter_expr, + loop_var^, + body^, + [], + [], + [], False, ) ) @@ -672,26 +784,25 @@ struct _Parser(Copyable, Movable): if toks[0].kind != ET_NAME: raise Error( "mojo-template: expected name in set (line " - + String(line) + ")" + + String(line) + + ")" ) var target = toks[0].text.copy() if not (toks[1].kind == ET_PUNCT and toks[1].text == "="): raise Error( - "mojo-template: expected '=' in set (line " - + String(line) + ")" + "mojo-template: expected '=' in set (line " + String(line) + ")" ) var p = 2 var value_expr = self._p_or(toks, p, line, 0) if toks[p].kind != ET_EOF: raise Error( "mojo-template: trailing tokens in set (line " - + String(line) + ")" + + String(line) + + ")" ) self.pos += 1 return self._emit_stmt( - _Stmt( - ST_SET, String(), value_expr, target^, [], [], [], [], False - ) + _Stmt(ST_SET, String(), value_expr, target^, [], [], [], [], False) ) @@ -705,6 +816,7 @@ def parse_template(source: String) raises -> Template: "mojo-template: unexpected '" + parser._block_word(tok.text) + "' with no matching opener (line " - + String(tok.line) + ")" + + String(tok.line) + + ")" ) return parser^._finish(root^) diff --git a/src/template/render.mojo b/src/template/render.mojo index 8ec85c5..5a9e6bf 100644 --- a/src/template/render.mojo +++ b/src/template/render.mojo @@ -55,6 +55,7 @@ struct _Eval(Copyable, Movable): # ---- HTML escaping ------------------------------------------------------ + def _push(mut out: List[UInt8], s: StaticString): for b in s.as_bytes(): out.append(b) @@ -83,6 +84,7 @@ def escape_html(s: String) -> String: # ---- string filter helpers ---------------------------------------------- + def _is_alpha(b: UInt8) -> Bool: return (Int(b) >= ord("a") and Int(b) <= ord("z")) or ( Int(b) >= ord("A") and Int(b) <= ord("Z") @@ -98,9 +100,15 @@ def _title(s: String) -> String: for k in range(len(b)): var c = b[k] var is_boundary = ( - c == 0x20 or c == 0x09 or c == 0x0A or c == 0x0D - or Int(c) == ord("-") or Int(c) == ord("(") or Int(c) == ord("[") - or Int(c) == ord("{") or Int(c) == ord("<") + c == 0x20 + or c == 0x09 + or c == 0x0A + or c == 0x0D + or Int(c) == ord("-") + or Int(c) == ord("(") + or Int(c) == ord("[") + or Int(c) == ord("{") + or Int(c) == ord("<") ) if _is_alpha(c): if at_boundary and Int(c) >= ord("a") and Int(c) <= ord("z"): @@ -137,7 +145,9 @@ def _truncate(s: String, length: Int) -> String: var last_space = prefix.rfind(" ") var cut: String if last_space >= 0: - cut = String(StringSlice(unsafe_from_utf8=prefix.as_bytes()[0:last_space])) + cut = String( + StringSlice(unsafe_from_utf8=prefix.as_bytes()[0:last_space]) + ) else: cut = prefix^ return cut + "..." @@ -145,6 +155,7 @@ def _truncate(s: String, length: Int) -> String: # ---- expression evaluation ---------------------------------------------- + def _lookup(scope: Context, name: String) raises -> TemplateValue: if name in scope: return scope[name].copy() @@ -161,7 +172,9 @@ def _eval(tmpl: Template, idx: Int, scope: Context, depth: Int) raises -> _Eval: # would overflow the stack and SIGSEGV the process. Bound eval recursion # to the same 256 cap the parser uses so such a template raises cleanly. if depth > _MAX_DEPTH: - raise Error("mojo-template: maximum expression evaluation depth exceeded") + raise Error( + "mojo-template: maximum expression evaluation depth exceeded" + ) ref e = tmpl.exprs[idx] var k = e.kind if k == EX_LIT: @@ -229,7 +242,9 @@ def _eval(tmpl: Template, idx: Int, scope: Context, depth: Int) raises -> _Eval: if lk == VK_FLOAT or rk == VK_FLOAT: var x = l.value.as_number() var y = r.value.as_number() - return _Eval(TemplateValue(x + y if k == EX_ADD else x - y), False) + return _Eval( + TemplateValue(x + y if k == EX_ADD else x - y), False + ) var xi = Int(l.value.as_number()) var yi = Int(r.value.as_number()) return _Eval( @@ -244,9 +259,7 @@ def _eval(tmpl: Template, idx: Int, scope: Context, depth: Int) raises -> _Eval: TemplateValue(l.value.render_str() + r.value.render_str()), False, ) - raise Error( - "mojo-template: unsupported operand types for + / -" - ) + raise Error("mojo-template: unsupported operand types for + / -") raise Error("mojo-template: unknown expression node") @@ -276,12 +289,16 @@ def _apply_filter( raise Error("mojo-template: default() requires an argument") var fallback_falsy = False if nargs >= 2: - fallback_falsy = _eval(tmpl, e.args[1], scope, depth + 1).value.is_truthy() + fallback_falsy = _eval( + tmpl, e.args[1], scope, depth + 1 + ).value.is_truthy() var use_default = base.value.is_undefined() or ( fallback_falsy and not base.value.is_truthy() ) if use_default: - return _Eval(_eval(tmpl, e.args[0], scope, depth + 1).value.copy(), False) + return _Eval( + _eval(tmpl, e.args[0], scope, depth + 1).value.copy(), False + ) return base^ # String-transforming filters preserve the safe flag of their input, # as Jinja's Markup string methods do (`markup | upper` stays Markup). @@ -321,7 +338,9 @@ def _apply_filter( if name == "truncate": var length = 255 if nargs >= 1: - length = Int(_eval(tmpl, e.args[0], scope, depth + 1).value.as_number()) + length = Int( + _eval(tmpl, e.args[0], scope, depth + 1).value.as_number() + ) return _Eval( TemplateValue(_truncate(base.value.render_str(), length)), base.safe, @@ -335,6 +354,7 @@ def _apply_filter( # ---- loop metadata ------------------------------------------------------ + def _make_loop(index0: Int, length: Int) raises -> TemplateValue: var keys = [ String("index"), @@ -359,6 +379,7 @@ def _make_loop(index0: Int, length: Int) raises -> TemplateValue: # ---- statement rendering ------------------------------------------------ + def _render_body( tmpl: Template, body: List[Int], diff --git a/test/test_template.mojo b/test/test_template.mojo index fe63834..f69815d 100644 --- a/test/test_template.mojo +++ b/test/test_template.mojo @@ -18,6 +18,7 @@ from template import render, TemplateValue, Context, escape_html # ---- shared contexts ---------------------------------------------------- + def sample_context() raises -> Context: """Mirror of `CONTEXT` in test/data/gen_fixtures.py.""" var ctx = Context() @@ -28,7 +29,11 @@ def sample_context() raises -> Context: ctx["active"] = TemplateValue(True) ctx["html"] = TemplateValue("Hi") ctx["items"] = TemplateValue.list( - [TemplateValue("apple"), TemplateValue("banana"), TemplateValue("cherry")] + [ + TemplateValue("apple"), + TemplateValue("banana"), + TemplateValue("cherry"), + ] ) ctx["nums"] = TemplateValue.list( [TemplateValue(1), TemplateValue(2), TemplateValue(3)] @@ -58,6 +63,7 @@ def r(source: String) raises -> String: # ---- output, escaping, safe -------------------------------------------- + def test_plain_text() raises: assert_equal(render("no tags here", Context()), "no tags here") @@ -87,11 +93,15 @@ def test_string_literal_is_escaped() raises: def test_escape_html_helper() raises: - assert_equal(escape_html(String("'&")), "<a href="x">'&") + assert_equal( + escape_html(String('\'&')), + "<a href="x">'&", + ) # ---- access ------------------------------------------------------------- + def test_dotted_access() raises: assert_equal(r("{{ user.name }}"), "Conor") @@ -114,6 +124,7 @@ def test_negative_index() raises: # ---- filters ------------------------------------------------------------ + def test_filter_upper() raises: assert_equal(r("{{ greeting | upper }}"), "HELLO WORLD") @@ -176,6 +187,7 @@ def test_chained_filters() raises: # ---- arithmetic & concatenation ---------------------------------------- + def test_arith_add() raises: assert_equal(r("{{ count + 2 }}"), "5") @@ -194,6 +206,7 @@ def test_string_concat() raises: # ---- comparisons & logic ------------------------------------------------ + def test_cmp_eq() raises: assert_equal(r("{% if count == 3 %}yes{% else %}no{% endif %}"), "yes") @@ -204,17 +217,24 @@ def test_cmp_ne() raises: def test_cmp_chain_elif() raises: assert_equal( - r("{% if count > 5 %}big{% elif count > 1 %}mid{% else %}small{% endif %}"), + r( + "{% if count > 5 %}big{% elif count > 1 %}mid{% else %}small{%" + " endif %}" + ), "mid", ) def test_cmp_le_ge() raises: - assert_equal(r("{% if count <= 3 and count >= 3 %}exact{% endif %}"), "exact") + assert_equal( + r("{% if count <= 3 and count >= 3 %}exact{% endif %}"), "exact" + ) def test_logic_and() raises: - assert_equal(r("{% if active and count > 0 %}on{% else %}off{% endif %}"), "on") + assert_equal( + r("{% if active and count > 0 %}on{% else %}off{% endif %}"), "on" + ) def test_logic_not() raises: @@ -227,8 +247,11 @@ def test_logic_or_returns_operand() raises: # ---- for loops & metadata ---------------------------------------------- + def test_for_basic() raises: - assert_equal(r("{% for i in items %}{{ i }} {% endfor %}"), "apple banana cherry ") + assert_equal( + r("{% for i in items %}{{ i }} {% endfor %}"), "apple banana cherry " + ) def test_for_loop_index() raises: @@ -253,7 +276,10 @@ def test_for_loop_first_last() raises: def test_nested_for_if() raises: assert_equal( - r("{% for p in people %}{{ p.name }}{% if p.admin %}*{% endif %} {% endfor %}"), + r( + "{% for p in people %}{{ p.name }}{% if p.admin %}*{% endif %} {%" + " endfor %}" + ), "Ann* Bob ", ) @@ -268,6 +294,7 @@ def test_for_empty() raises: # ---- set, comments, whitespace ----------------------------------------- + def test_set() raises: assert_equal(r("{% set x = count + 10 %}{{ x }}"), "13") @@ -282,6 +309,7 @@ def test_whitespace_trim() raises: # ---- scalar output ------------------------------------------------------ + def test_bool_output() raises: assert_equal(r("{{ active }}"), "True") @@ -296,6 +324,7 @@ def test_none_output() raises: # ---- error cases (documented behavior) --------------------------------- + def test_error_unclosed_tag() raises: with assert_raises(): _ = render("{{ name ", Context()) @@ -480,6 +509,7 @@ def test_set_in_for_resets_each_iteration() raises: # ---- Jinja2 byte-for-byte parity --------------------------------------- + def _split(s: String, delim: UInt8) -> List[String]: var b = s.as_bytes() var out = List[String]() @@ -506,11 +536,20 @@ def test_jinja2_fixture_parity() raises: var got = render(template, sample_context()) if got != expected: raise Error( - "fixture '" + name + "' mismatch:\n template: " + template - + "\n expected: [" + expected + "]\n got: [" + got + "]" + "fixture '" + + name + + "' mismatch:\n template: " + + template + + "\n expected: [" + + expected + + "]\n got: [" + + got + + "]" ) matched += 1 - assert_true(matched >= 25, "expected >= 25 fixtures, got " + String(matched)) + assert_true( + matched >= 25, "expected >= 25 fixtures, got " + String(matched) + ) print("jinja2 fixture parity: matched", matched, "of", len(records)) From 84b86073dfea842fe3e109acaf2e81ff92776b0e Mon Sep 17 00:00:00 2001 From: Conor Bronsdon Date: Tue, 7 Jul 2026 20:12:13 -0700 Subject: [PATCH 2/2] Add API-doc site, format gate, recipe.yaml, bench (suite-wide quick wins) --- .github/workflows/docs.yaml | 49 +++++++++++ .github/workflows/test.yml | 5 ++ .gitignore | 4 + bench/bench_render.mojo | 103 ++++++++++++++++++++++ docs/render_api.py | 165 ++++++++++++++++++++++++++++++++++++ pixi.toml | 3 + recipe.yaml | 39 +++++++++ src/template/__init__.mojo | 2 +- 8 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yaml create mode 100755 bench/bench_render.mojo create mode 100644 docs/render_api.py create mode 100755 recipe.yaml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..3a02b1c --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,49 @@ +name: docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build-deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install Mojo nightly + run: | + uv venv + uv pip install mojo \ + --index https://whl.modular.com/nightly/simple/ \ + --prerelease allow + + - name: Generate API reference + run: | + .venv/bin/mojo doc -o docs/api.json -I src src/template + python3 docs/render_api.py docs/api.json docs/site/index.html + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/site + + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d946861..fa1b40f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,5 +23,10 @@ jobs: --prerelease allow .venv/bin/mojo --version + - name: Format check + run: | + .venv/bin/mojo format src/ test/ examples/ bench/ + git diff --exit-code || (echo "::error::Run 'pixi run fmt' — sources are not mojo-format clean" && exit 1) + - name: Tests run: .venv/bin/mojo run -I src test/test_template.mojo diff --git a/.gitignore b/.gitignore index fc1e371..b4accc0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ pixi.lock *.mojopkg __pycache__/ +.bench_render +docs/api.json +docs/api.html +docs/site/ diff --git a/bench/bench_render.mojo b/bench/bench_render.mojo new file mode 100755 index 0000000..1d01ba4 --- /dev/null +++ b/bench/bench_render.mojo @@ -0,0 +1,103 @@ +"""Throughput benchmark for `render` over the Jinja2 parity corpus. + +Times the full parse+render path (autoescape on, strict-undefined) across +every template in `test/data/fixtures/manifest.txt` — the same corpus the +byte-match parity test replays, against the same context. The fixtures are +tiny (~2.3 KB of template source total), so each measurement pass renders +the whole corpus and we run many passes for stable numbers. Run compiled +for meaningful numbers: +`mojo build -I src bench/bench_render.mojo -o .bench_render && +./.bench_render` (or `pixi run bench`). +""" +from std.time import perf_counter_ns + +from template import render, TemplateValue, Context + + +def bench_context() raises -> Context: + """Mirror of `CONTEXT` in test/data/gen_fixtures.py (same as the tests).""" + var ctx = Context() + ctx["name"] = TemplateValue("Conor & Kate") + ctx["greeting"] = TemplateValue("hello world") + ctx["count"] = TemplateValue(3) + ctx["price"] = TemplateValue(2.5) + ctx["active"] = TemplateValue(True) + ctx["html"] = TemplateValue("Hi") + ctx["items"] = TemplateValue.list( + [ + TemplateValue("apple"), + TemplateValue("banana"), + TemplateValue("cherry"), + ] + ) + ctx["nums"] = TemplateValue.list( + [TemplateValue(1), TemplateValue(2), TemplateValue(3)] + ) + ctx["user"] = TemplateValue.dict( + ["name", "role"], [TemplateValue("Conor"), TemplateValue("lead")] + ) + ctx["people"] = TemplateValue.list( + [ + TemplateValue.dict( + ["name", "admin"], [TemplateValue("Ann"), TemplateValue(True)] + ), + TemplateValue.dict( + ["name", "admin"], [TemplateValue("Bob"), TemplateValue(False)] + ), + ] + ) + ctx["empty"] = TemplateValue.list([]) + ctx["word"] = TemplateValue("the QUICK brown fox") + ctx["long"] = TemplateValue("the quick brown fox jumps over the lazy dog") + return ctx^ + + +def _split(s: String, delim: UInt8) -> List[String]: + var b = s.as_bytes() + var out = List[String]() + var start = 0 + for i in range(len(b)): + if b[i] == delim: + out.append(String(StringSlice(unsafe_from_utf8=b[start:i]))) + start = i + 1 + out.append(String(StringSlice(unsafe_from_utf8=b[start : len(b)]))) + return out^ + + +def main() raises: + var manifest = open("test/data/fixtures/manifest.txt", "r").read() + var records = _split(manifest, 0x1D) + var templates = List[String]() + var source_bytes = 0 + for rec in records: + var fields = _split(rec, 0x1E) + if len(fields) != 3: + continue + templates.append(fields[1]) + source_bytes += fields[1].byte_length() + var ctx = bench_context() + + # Warmup + correctness anchor: total output bytes must stay stable. + var expected_out = 0 + for tmpl in templates: + expected_out += render(tmpl, ctx).byte_length() + + comptime PASSES = 2000 + var start = perf_counter_ns() + for _ in range(PASSES): + var out_bytes = 0 + for tmpl in templates: + out_bytes += render(tmpl, ctx).byte_length() + if out_bytes != expected_out: + raise Error("inconsistent render") + var elapsed_ns = perf_counter_ns() - start + var per_pass_ms = Float64(elapsed_ns) / Float64(PASSES) / 1e6 + var per_render_us = ( + Float64(elapsed_ns) / Float64(PASSES * len(templates)) / 1e3 + ) + var mb_per_s = (Float64(source_bytes) / (1024.0 * 1024.0)) / ( + per_pass_ms / 1000.0 + ) + print(t"{len(templates)} templates, {source_bytes} bytes of source") + print(t" {per_pass_ms} ms/corpus pass ({PASSES} passes)") + print(t" {per_render_us} us/render, {mb_per_s} MB/s of template source") diff --git a/docs/render_api.py b/docs/render_api.py new file mode 100644 index 0000000..ee8fdc1 --- /dev/null +++ b/docs/render_api.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Render `mojo doc` JSON into a single self-contained HTML API reference. + +`mojo doc` emits JSON, not HTML; this turns that JSON into a searchable, +theme-aware, dependency-free page. Reusable across the pure-Mojo suite — it +reads the package name/version out of the JSON, nothing is hardcoded. + + mojo doc -o docs/api.json -I src src/ + python3 docs/render_api.py docs/api.json docs/api.html +""" +import html +import json +import os +import sys + + +def esc(s): + return html.escape(s or "") + + +def code(s): + return f'{esc(s)}' + + +def render_overload(o): + sig = o.get("signature", "") + doc = o.get("summary") or o.get("description") or "" + raises = o.get("raisesDoc") or "" + parts = [f'
{esc(sig)}
'] + if doc: + parts.append(f'

{esc(doc)}

') + if raises: + parts.append(f'

raises {esc(raises)}

') + return "".join(parts) + + +def render_function(f): + name = f.get("name", "") + body = "".join(render_overload(o) for o in f.get("overloads", [])) + return (f'
' + f'

{esc(name)}fn

{body}
') + + +def render_field(fl): + name = fl.get("name", "") + typ = fl.get("type", "") or fl.get("signature", "") + doc = fl.get("summary") or fl.get("description") or "" + d = f' — {esc(doc)}' if doc else "" + label = f'{code(name)}: {code(typ)}' if typ else code(name) + return f'
  • {label}{d}
  • ' + + +def render_struct(s): + name = s.get("name", "") + summary = s.get("summary") or s.get("description") or "" + traits = s.get("parentTraits") or [] + fields = s.get("fields", []) + methods = s.get("functions", []) + out = [f'
    '] + out.append(f'

    {esc(name)}struct

    ') + if traits: + names = [t.get("name", "") if isinstance(t, dict) else str(t) for t in traits] + out.append('

    ' + " · ".join(code(n) for n in names if n) + "

    ") + if summary: + out.append(f'

    {esc(summary)}

    ') + if fields: + out.append('
    Fields
      ') + out.extend(render_field(f) for f in fields) + out.append("
    ") + if methods: + out.append('
    Methods
    ') + out.extend(render_function(m) for m in methods) + out.append("
    ") + return "".join(out) + + +def render_alias(a): + name = a.get("name", "") + sig = a.get("signature", "") + doc = a.get("summary") or a.get("description") or "" + d = f' — {esc(doc)}' if doc else "" + return f'
  • {code(name)} = {code(sig)}{d}
  • ' if sig else f'
  • {code(name)}{d}
  • ' + + +def render_module(m): + name = m.get("name", "") + summary = m.get("summary") or m.get("description") or "" + aliases = m.get("aliases", []) + functions = m.get("functions", []) + structs = m.get("structs", []) + if not (aliases or functions or structs): + return "" + out = [f'

    {esc(name)}

    '] + if summary: + out.append(f'

    {esc(summary)}

    ') + if aliases: + out.append('
    Aliases
      ') + out.extend(render_alias(a) for a in aliases) + out.append("
    ") + if structs: + out.extend(render_struct(s) for s in structs) + if functions: + out.append('
    Functions
    ') + out.extend(render_function(f) for f in functions) + out.append("
    ") + return "".join(out) + + +CSS = """ +:root{color-scheme:light dark;--bg:#fcfcfb;--fg:#0b0b0b;--muted:#57564f;--panel:#f3f3ef;--border:rgba(0,0,0,.1);--accent:#2a78d6;--code:#0b7285} +@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8b949e;--panel:#161b22;--border:rgba(255,255,255,.1);--accent:#57c5bb;--code:#7ee0d6}} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif} +.wrap{max-width:860px;margin:0 auto;padding:32px 20px 80px} +h1{font-size:1.9rem;margin:0 0 4px}.ver{color:var(--muted);margin:0 0 20px} +code{font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.85em;color:var(--code)} +#q{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--panel);color:var(--fg);font-size:1rem;margin-bottom:24px} +.module{border-top:1px solid var(--border);padding-top:8px;margin-top:24px} +h2{font-size:1.3rem;color:var(--accent)} +.item{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:12px 0} +.item h3,.item h4{margin:0 0 8px;font-size:1.05rem} +.kind{font-size:.7rem;font-weight:600;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:1px 6px;margin-left:8px;vertical-align:middle} +.sig{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:.82rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow-x:auto;white-space:pre;margin:6px 0} +.doc{color:var(--fg);margin:6px 0}.sub{font-size:.75rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin:14px 0 4px} +.traits{color:var(--muted);margin:2px 0 8px}.raises{color:var(--muted);font-size:.9em;margin:4px 0}.raises span{color:#d03b3b;font-weight:600} +ul{margin:4px 0;padding-left:20px}li{margin:3px 0}.hidden{display:none} +footer{margin-top:40px;color:var(--muted);font-size:.85rem;border-top:1px solid var(--border);padding-top:16px} +a{color:var(--accent)} +""" + +JS = """ +const q=document.getElementById('q'); +q.addEventListener('input',()=>{const v=q.value.toLowerCase(); +document.querySelectorAll('.item').forEach(el=>{ + el.classList.toggle('hidden', v && !(el.dataset.name||'').includes(v) && !el.textContent.toLowerCase().includes(v));}); +document.querySelectorAll('.module').forEach(m=>{ + const any=[...m.querySelectorAll('.item')].some(i=>!i.classList.contains('hidden')); + m.style.display=any||!v?'':'none';});}); +""" + + +def main(): + src, out = sys.argv[1], sys.argv[2] + d = json.load(open(src)) + decl = d.get("decl", {}) + pkg = decl.get("name", "package") + version = d.get("version", "") + summary = decl.get("summary") or decl.get("description") or "" + if os.path.dirname(out): + os.makedirs(os.path.dirname(out), exist_ok=True) + modules = "".join(render_module(m) for m in decl.get("modules", [])) + page = f""" + +{esc(pkg)} — API reference
    +

    {esc(pkg)}

    API reference{f' · {esc(version)}' if version else ''}

    +{f'

    {esc(summary)}

    ' if summary else ''} + +{modules} +
    Generated from mojo doc JSON by docs/render_api.py. No hand-written HTML.
    +
    """ + open(out, "w").write(page) + print(f"wrote {out} ({len(page)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/pixi.toml b/pixi.toml index 5c22bb5..feec2e0 100644 --- a/pixi.toml +++ b/pixi.toml @@ -10,6 +10,9 @@ version = "0.1.0" test = "mojo run -I src test/test_template.mojo" demo = "mojo run -I src examples/render_email.mojo" fuzz = "mojo run -I src test/fuzz_runner.mojo" +bench = "mojo build -I src bench/bench_render.mojo -o .bench_render && ./.bench_render" +fmt = "mojo format src/ test/ examples/ bench/" +docs = "mojo doc -o docs/api.json -I src src/template && python3 docs/render_api.py docs/api.json docs/api.html" [dependencies] mojo = ">=1.0.0b3.dev0,<2" diff --git a/recipe.yaml b/recipe.yaml new file mode 100755 index 0000000..7b412c1 --- /dev/null +++ b/recipe.yaml @@ -0,0 +1,39 @@ +# rattler-build recipe for publishing mojo-template to a conda channel +# (e.g. modular-community). Not wired to CI yet — this documents the intended +# distribution shape and is ready to build the day we decide to publish. +# +# Distributes the pure-Mojo SOURCE TREE, not a compiled `.mojopkg`: a .mojopkg +# embeds the exact compiler version and refuses to load against any other, so a +# benign Mojo nightly bump would break every consumer. Shipping source + a +# runtime `mojo` pin gets reproducibility without ABI lock-in. (Pattern follows +# ehsanmok/flare's recipe.) +# +# Build locally with: rattler-build build --recipe recipe.yaml + +context: + version: "0.1.0" + +package: + name: mojo-template + version: ${{ version }} + +source: + path: . + +build: + number: 0 + noarch: generic + script: + - mkdir -p ${{ PREFIX }}/lib/mojo + - cp -r src/template ${{ PREFIX }}/lib/mojo/template + +requirements: + run: + - mojo >=1.0.0b3.dev0,<2 + +about: + homepage: https://github.com/conorbronsdon/mojo-template + repository: https://github.com/conorbronsdon/mojo-template + license: MIT + license_file: LICENSE + summary: A standalone Jinja-flavored template engine in pure Mojo diff --git a/src/template/__init__.mojo b/src/template/__init__.mojo index e6eeeac..3ed6ef9 100644 --- a/src/template/__init__.mojo +++ b/src/template/__init__.mojo @@ -1,4 +1,4 @@ -"""mojo-template: a standalone Jinja-flavored template engine for Mojo. +"""Jinja-flavored template engine in pure Mojo (mojo-template). from template import render, TemplateValue, Context