aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 08:01:11 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 08:01:11 +0200
commiteacb54ae44f655c010fdfc2b7292d24f06ffc445 (patch)
treefdcae49d2be2630b559a3b21a131cf4194feeb5e /test
parent7228d0634a960c38da96b80378ec617768dfedf7 (diff)
downloadcolitur-eacb54ae44f655c010fdfc2b7292d24f06ffc445.tar.gz
colitur-eacb54ae44f655c010fdfc2b7292d24f06ffc445.zip
feat(render): logic-less template parser
Placeholders, sections, inverted sections, comments. Nothing else: no partials, no lambdas, no expression evaluation, no raw form. A template is data, never a program, which is what keeps an untrusted template safe. Errors rather than silence on a malformed template: an unterminated tag, an unclosed section, a mismatched close and a partial all return Error. Swallowing '{{name' as text is how a typo becomes invisible missing output in a printed booklet.
Diffstat (limited to 'test')
-rw-r--r--test/test_colitur.ml3
-rw-r--r--test/test_template.ml61
2 files changed, 63 insertions, 1 deletions
diff --git a/test/test_colitur.ml b/test/test_colitur.ml
index 2295a3f..a264174 100644
--- a/test/test_colitur.ml
+++ b/test/test_colitur.ml
@@ -7,4 +7,5 @@ let () =
Test_differential.suite; Test_oracle.suite; Test_oracle.suite_2038; Test_oracle.suite_2035; Test_golden.suite;
("lectionary", Test_lectionary.suite);
("lectionary-ef", Test_lectionary_ef.suite);
- Test_escape.suite ]
+ Test_escape.suite;
+ Test_template.suite ]
diff --git a/test/test_template.ml b/test/test_template.ml
new file mode 100644
index 0000000..dee281b
--- /dev/null
+++ b/test/test_template.ml
@@ -0,0 +1,61 @@
+module T = Colitur_render.Template
+
+let parse_ok s = match T.parse s with Ok ns -> ns | Error e -> Alcotest.failf "parse: %s" e
+
+let test_plain_text () =
+ Alcotest.(check bool) "text only" true (parse_ok "hello" = [ T.Text "hello" ])
+
+let test_var () =
+ Alcotest.(check bool) "var" true (parse_ok "{{name}}" = [ T.Var [ "name" ] ]);
+ Alcotest.(check bool) "dotted" true (parse_ok "{{name.la}}" = [ T.Var [ "name"; "la" ] ]);
+ Alcotest.(check bool) "spaces trimmed" true (parse_ok "{{ name.la }}" = [ T.Var [ "name"; "la" ] ])
+
+let test_section () =
+ Alcotest.(check bool) "section" true
+ (parse_ok "{{#days}}x{{/days}}" = [ T.Section ([ "days" ], [ T.Text "x" ]) ]);
+ Alcotest.(check bool) "inverted" true
+ (parse_ok "{{^days}}x{{/days}}" = [ T.Inverted ([ "days" ], [ T.Text "x" ]) ])
+
+let test_nested_sections () =
+ Alcotest.(check bool) "nested" true
+ (parse_ok "{{#months}}{{#weeks}}w{{/weeks}}{{/months}}"
+ = [ T.Section ([ "months" ], [ T.Section ([ "weeks" ], [ T.Text "w" ]) ]) ])
+
+let test_comment_is_dropped () =
+ Alcotest.(check bool) "comment" true (parse_ok "a{{! note }}b" = [ T.Text "a"; T.Text "b" ])
+
+let test_unclosed_section_is_an_error () =
+ match T.parse "{{#days}}x" with
+ | Error _ -> ()
+ | Ok _ -> Alcotest.fail "an unclosed section must be a parse error, not silently accepted"
+
+let test_mismatched_close_is_an_error () =
+ match T.parse "{{#days}}x{{/weeks}}" with
+ | Error _ -> ()
+ | Ok _ -> Alcotest.fail "a mismatched close tag must be a parse error"
+
+let test_unterminated_tag_is_an_error () =
+ match T.parse "{{name" with
+ | Error _ -> ()
+ | Ok _ -> Alcotest.fail "an unterminated tag must be a parse error"
+
+(* The safety promise (spec section 5): a template is data, never a program.
+ There is no raw/triple-brace form to opt out of escaping, and no partial. *)
+let test_no_raw_or_partial_form () =
+ Alcotest.(check bool) "triple brace is not an unescaped var" true
+ (match T.parse "{{{name}}}" with Ok [ T.Var [ "{name" ] ] -> false | Ok _ -> true | Error _ -> true);
+ match T.parse "{{>partial}}" with
+ | Error _ -> ()
+ | Ok _ -> Alcotest.fail "partials must not parse: a template may not pull in another file"
+
+let suite =
+ ( "Template/parse",
+ [ Alcotest.test_case "plain text" `Quick test_plain_text;
+ Alcotest.test_case "vars" `Quick test_var;
+ Alcotest.test_case "sections" `Quick test_section;
+ Alcotest.test_case "nested sections" `Quick test_nested_sections;
+ Alcotest.test_case "comments dropped" `Quick test_comment_is_dropped;
+ Alcotest.test_case "unclosed section errors" `Quick test_unclosed_section_is_an_error;
+ Alcotest.test_case "mismatched close errors" `Quick test_mismatched_close_is_an_error;
+ Alcotest.test_case "unterminated tag errors" `Quick test_unterminated_tag_is_an_error;
+ Alcotest.test_case "no raw or partial form" `Quick test_no_raw_or_partial_form ] )