diff --git a/DIAGNOSTIC_TODOS.yaml b/DIAGNOSTIC_TODOS.yaml
index 9116ca1..9befd7a 100644
--- a/DIAGNOSTIC_TODOS.yaml
+++ b/DIAGNOSTIC_TODOS.yaml
@@ -1,4 +1,8 @@
+# This file contains tests for all known mustache syntax errors and their
+# desired error messages. Each one is converted into an Odin test.
name: Missing 2nd closing brace
+enable: true
+should: error
input: |
{{> icons/chevron_up}
@@ -7,20 +11,20 @@ input: |
{{/params.social}}
Proudly built with Thor and /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
+expected: |
+ Missing closing brace
+ --> :1:1
+ 1 |
+ 2 | {{> icons/chevron_up}
+ | ^^ opened ^ never closed
+ 3 | {{#params.social}}
+ 4 | {{>* icon}}
+ 5 | {{/params.social}}
|
- 4 | {{#params.social}}
- 5 | {{>* icon}}
- 6 | {{/params.social}}
- | ^^^^^^^^^^^^^^^^^^
- 7 |
- 8 | Proudly built with Thor and
{> icons/chevron_up}}
@@ -29,10 +33,21 @@ input: |
{{/params.social}}
Proudly built with Thor and :1:1
+ 1 |
+ 2 | {> icons/chevron_up}}
+ | ^ opened ^^ closed
+ 3 | {{#params.social}}
+ 4 | {{>* icon}}
+ 5 | {{/params.social}}
+ |
---
+# TODO: make it work
name: Invalid timezone
+enable: false
+should: warn
input: |
{
"date": {
@@ -44,6 +59,8 @@ actual: |
[WARN ] --- [148:init_site()] unable to load timezone 'America/New_Yorkside'
---
name: Mismatched section tags
+enable: true
+should: error
input: |
@@ -57,19 +74,20 @@ input: |
© {{now | format "2006" }} {{params.author.name}}
-expected: Mostly the same, but with rust style markers at the opening and close
-actual: |
- [ERROR] --- [383:load_partials()] expected {{/params.ocial}}, got {{/params.social}}
- --> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
+expected: |
+ Mismatched section: 'ocial' opened, 'social' closed
+ --> :4:5
|
4 | {{#params.ocial}}
+ | ^^^^^ opened — did you mean 'social'?
5 | {{>* icon}}
6 | {{/params.social}}
- | ^^^^^^^^^^^^^^^^^^
- 7 |
- 8 | Proudly built with Thor and
@@ -83,10 +101,20 @@ input: |
© {{now | format "2006" }} {{params.author.name}}
-expected: "Missing closing tag '{{/params.social}}' ...opened ... expected close "
-actual: Nothing. The page compiles with no warnings or errors
+expected: |
+ Missing closing tag '{{/params.social}}'
+ --> :4:5
+ |
+ 4 | {{#params.social}}
+ | ^^^^^^^^^^^^^^^^^ opened
+ 5 | {{>* icon}}
+ 6 |
+ | ^^^^^^^^^^^^^^^^^^ closing tag is commented out
+ |
---
name: empty tag
+enable: true
+should: error
input: |
@@ -100,18 +128,20 @@ input: |
© {{}} {{params.author.name}}
-expected: "Found an empty tag at "
-actual: |
- [WARN ] --- [920:warn_unknown_key()] unknown key ''
- --> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
- |
- 1 |
- | ^
- 2 |
- 3 | {{> icons/chevron_up}}
- |
+expected: |
+ Empty tag
+ --> :11:28
+ |
+ 9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS
+ 10 |
+ 11 | © {{}} {{params.author.name}}
+ | ^^^^ tag must contain a key
+ 12 |
+ |
---
name: Missing pipe arguement
+enable: true
+should: error
input: |
-expected: "Missing filter argument "
-actual: |
- [ERROR] --- [152:render_template()] group_by expects 1 argument, got 0
- --> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
- |
- 1 |
- | ^
- 2 |
- 3 | {{> icons/chevron_up}}
+expected: |
+ group_by expects 1 argument, got 0
+ --> :11:36
|
+ 9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS
+ 10 |
+ 11 | © {{now | group_by }} {{params.author.name}}
+ | ^^^^^^^^^ missing argument
+ 12 |
+ |
---
name: Double dot access
+enable: true
+should: error
input: |
-expected: Error "Invalid access key"
-actual: Nothing. The page compiles with no warnings or errors
+expected: |
+ Invalid key path: consecutive dots
+ --> :11:60
+ |
+ 9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS
+ 10 |
+ 11 | © {{now | format "2006" }} {{params..name}}
+ | ^^ missing path name
+ 12 |
+ |
---
name: Too many opening braces
+enable: true
+should: error
input: |
-expected: "Too many opening braces "
-actual: |
- [WARN ] --- [920:warn_unknown_key()] unknown key '{now'
- --> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
- |
- 1 |
- | ^ did you mean 'now'?
- 2 |
- 3 | {{> icons/chevron_up}}
- |
+expected: |
+ Too many opening braces
+ --> :11:28
+ |
+ 9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS
+ 10 |
+ 11 | © {{{{now | format "2006" }}} {{params.author.name}}
+ | ^^^^ expected {{{, got {{{{
+ 12 |
+ |
---
name: Too many closing braces
+enable: true
+should: error
input: |
-expected: "Too many closing braces "
-actual: Nothing. The page compiles with no warnings or errors
+expected: |
+ Too many closing braces
+ --> :11:50
+ |
+ 9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS
+ 10 |
+ 11 | © {{now | format "2006" }}}} {{params.author.name}}
+ | ^^^^ expected }}, got }}}
+ 12 |
+ |
---
+# TODO: Implement
name: Frontmatter missing closing quote on key
+enable: false
+should: warn
input: |
{
"title: Docs"
@@ -208,7 +263,10 @@ expected: 'TODO:'
actual: |
[ERROR] --- [42:parse_frontmatter()] failed to parse frontmatter JSON: Expected_Colon_After_Key
---
+# TODO: Implement
name: Fronmatter missing opening quote on value
+should: warn
+enable: false
input: |
{
"title": Docs"
@@ -220,48 +278,87 @@ input: |
expected: 'detailed diagnostic pointing to the point of failure and suggesting the addition of a quote'
actual: Nothing. The page compiles with no warnings or errors
---
-name: No error when missing index page.
+name: No error when missing index page
+enable: true
+should: error
expected: 'Your site has no index page! Please create an index.md or index.html file in ./content/'
-actual: Nothing. The site compiles with no warnings or errors
---
+# FIXME: Doesn't work properly
name: format pipe with no date format set
+enable: true
+should: warn
+input: '{{ now | format }}'
expected: |
- Show where the error was triggered. Should also show the format that will be used
-actual: |
- [ERROR] --- [333:apply_format()] format pipe used but no date format configured (set date.format in thor.json) Default will be used
+ no date format configured — set date.format in thor.json or specify a format, e.g. {{key | format "2 Jan 2006"}}
+ --> :1:10
+ |
+ 1 | {{now | format}}
+ | ^^^^^^ defaulting to "2 Jan 2006"
---
+# TODO: called First/last, but only tests first
name: first/last with zero argument
+enable: true
+should: error
input: |
{{#items | first 0}}{{.}} {{/items}}
-expected: Error with source location — "argument must not be zero"
-actual: |
- [ERROR] --- [render_template()] argument must not be zero
- --> ::
+expected: |
+ argument must not be zero
+ --> :1:5
|
- N | {{#items | first 0}}{{.}} {{/items}}
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 1 | {{#items | first 0}}{{.}} {{/items}}
+ | ^^^^^^^
|
---
+# TODO: called First/last, but only tests first
name: first/last with negative argument
+enable: true
+should: warn
input: |
{{#items | first -3}}{{.}} {{/items}}
-expected: Warning with source location — "first -3 is equivalent to last 3 — consider using the clearer form"
-actual: |
- [WARN ] --- [take()] first -3 is equivalent to last 3
- --> ::
+expected: |
+ first -3 is equivalent to last 3
+ --> :1:5
|
- N | {{#items | first -3}}{{.}} {{/items}}
- | ^^^^^^^^^^^^^^^^^^^^^^^^^ consider using the clearer form
+ 1 | {{#items | first -3}}{{.}} {{/items}}
+ | ^^^^^^^^ consider using the clearer form
|
---
+# TODO: called First/last, but only tests first
name: first/last on string with no argument
-input: |
- {{word | first}}
-expected: Warning with source location — "defaulting to n=1 for string; consider specifying n explicitly"
-actual: |
- [WARN ] --- [take()] defaulting to n=1 for string
- --> ::
+# enable: true
+input: '{{word | first}}'
+should: warn
+expected: |
+ defaulting to n=1 for string
+ --> :1:1
|
- N | {{word | first}}
- | ^^^^^^^^^^^^^^^ consider specifying n explicitly
+ 1 | {{word | first}}
+ | ^^^^^ consider specifying n explicitly
|
+---
+name: "first succeeds on params.author.name"
+enable: true
+input: '{{ params.author.name | first 3 }}'
+should: ok
+expected: "foo"
+---
+# TODO: called First/last, but only tests first
+name: "first/last in partial"
+enable: true
+input: "{{> footer }}"
+should: error
+expected: |
+ first -3 is equivalent to last 3
+ --> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
+ |
+ 1 |
+ | ^ consider using the clearer form
+ 2 |
+ 3 | {{> icons/chevron_up}}
+ |
+---
+name: "Pipes are ignored in comments"
+enable: true
+should: ok
+input: 'Hello, {{! now | format "2006" }}'
+expected: 'Hello, '
diff --git a/ROADMAP.md b/ROADMAP.md
index 6d8bd0c..c165e2a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -37,6 +37,7 @@
- [ ] merge `render_{section,home_html,page_html}` procs.
- [ ] try to combine render_page_html and render_home_html?
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
+- [ ] refactor `mustache.load_partials` to accept an iterator so we don't copy `main.load_partials`
### Menus
diff --git a/WARNING_COUNTER.md b/WARNING_COUNTER.md
new file mode 100644
index 0000000..09386df
--- /dev/null
+++ b/WARNING_COUNTER.md
@@ -0,0 +1,109 @@
+# Warning Counter Plan
+
+## Goal
+
+When the same warning fires multiple times across a single page render, show it once with a `[×N]` count prefix instead of N identical diagnostic blocks.
+
+```
+[×5] first -3 is equivalent to last 3 — consider using the clearer form
+ --> layouts/home.html:12:1
+ |
+12 | {{#items | first -3}}{{.}} {{/items}}
+ | ^^^^^^^^^^^^^^^^^^^^^ consider using the clearer form
+ |
+```
+
+## Approach
+
+Module-level buffer in `pipes.odin`. Warnings are collected during render, deduplicated + counted + logged after the page completes. No parameter threading through `render_nodes`.
+
+## Changes
+
+### `mustache/pipes.odin`
+
+**New module-level state:**
+```odin
+_pending_warnings: [dynamic]Error
+```
+
+**New procs:**
+```odin
+reset_warnings :: proc() {
+ clear(&_pending_warnings)
+}
+
+flush_warnings :: proc(colorize: bool = false) {
+ if len(_pending_warnings) == 0 do return
+
+ counts := make(map[string]int, context.temp_allocator)
+ first: map[string]Error = make(map[string]Error, context.temp_allocator)
+ defer delete(counts)
+ defer delete(first)
+
+ for w in _pending_warnings {
+ b := body(w)
+ if _, seen := first[b.msg]; !seen {
+ first[b.msg] = w
+ }
+ counts[b.msg] += 1
+ }
+
+ for msg, w in first {
+ c := counts[msg]
+ b := body(w)
+ path := b.path != "" ? b.path : " "
+ prefix := c > 1 ? fmt.tprintf("[×%d] ", c) : ""
+ diag := diags.format_error(
+ path, b.source, b.pos, b.msg,
+ hint = b.hint,
+ colorize = colorize,
+ )
+ log.warnf("%s%s", prefix, diag)
+ }
+
+ clear(&_pending_warnings)
+}
+```
+
+**`apply_pipeline` change:**
+
+Replace the current warning handling (which logs immediately):
+```odin
+if warnings != nil {
+ append(warnings, ferr)
+} else {
+ log.warnf("%s", format_render_error(ferr, tmpl))
+}
+```
+
+With:
+```odin
+append(&_pending_warnings, ferr)
+```
+
+Drop the `warnings: ^[dynamic]Error` parameter — no longer needed.
+
+### `render.odin`
+
+In `render_template`, wrap the render call:
+```odin
+mustache.reset_warnings()
+result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials)
+mustache.flush_warnings(colorize = diags.should_colorize())
+```
+
+### `mustache/pipes.odin` cleanup
+
+- Remove `warnings` parameter from `apply_pipeline` signature
+- Remove `format_render_error` import/call from `apply_pipeline` (formatting moves to `flush_warnings`)
+- Remove the `log` import if no longer used directly in `apply_pipeline`
+
+## Scope
+
+~25 lines new code (`reset_warnings` + `flush_warnings`), ~3 lines changed in `apply_pipeline`, ~2 lines added in `render.odin`. Total ~30 lines.
+
+## Limitations
+
+- Module-level state means warnings don't survive across page renders (by design — `reset_warnings` is called per page)
+- Not thread-safe (fine for sequential rendering)
+- Counting is by exact `msg` string — warnings with different messages but same root cause are counted separately
diff --git a/mustache/diag_cases.odin b/mustache/diag_cases.odin
new file mode 100644
index 0000000..cc62e43
--- /dev/null
+++ b/mustache/diag_cases.odin
@@ -0,0 +1,149 @@
+package mustache
+
+import "core:os"
+import "core:path/filepath"
+import "core:strings"
+
+Diag_Case :: struct {
+ name: string,
+ enable: bool,
+ should: string, // "error" | "warn" | "pass"
+ expected: string, // exact formatted diagnostic output
+ input: string,
+}
+
+load_diag_cases :: proc(path: string, allocator := context.temp_allocator) -> []Diag_Case {
+ dirs := [?]string{#directory, path}
+ full_path, _ := filepath.join(dirs[:], allocator)
+ raw_data, err := os.read_entire_file(full_path, allocator)
+ if err != nil {
+ return {}
+ }
+
+ content := string(raw_data)
+ lines := strings.split(content, "\n", allocator)
+
+ cases := make([dynamic]Diag_Case, 0, 16, allocator)
+
+ current: Diag_Case
+ has_current := false
+
+ // Multiline block state
+ in_block := false
+ block_key: string
+ block_indent := -1
+ block_sb: strings.Builder
+
+ for line in lines {
+ raw := strings.trim_right(line, "\r")
+
+ if in_block {
+ // Check if this line belongs to the multiline block
+ is_indented := len(raw) == 0 || raw[0] == ' ' || raw[0] == '\t'
+ if is_indented {
+ if block_indent < 0 && len(raw) > 0 {
+ // Determine indentation from first content line
+ block_indent = 0
+ for block_indent < len(raw) && raw[block_indent] == ' ' {
+ block_indent += 1
+ }
+ }
+ // Strip block indentation
+ stripped := raw
+ if block_indent > 0 && len(stripped) >= block_indent {
+ stripped = stripped[block_indent:]
+ }
+ if strings.builder_len(block_sb) > 0 {
+ strings.write_string(&block_sb, "\n")
+ }
+ strings.write_string(&block_sb, stripped)
+ continue
+ } else {
+ // End of multiline block
+ in_block = false
+ strings.write_string(&block_sb, "\n") // YAML | trailing newline
+ value := strings.clone(strings.to_string(block_sb), allocator)
+ strings.builder_destroy(&block_sb)
+ set_field(¤t, block_key, value)
+ block_indent = -1
+ // Fall through to process this line
+ }
+ }
+
+ trimmed := strings.trim_space(raw)
+
+ // Entry separator
+ if trimmed == "---" {
+ if has_current {
+ append(&cases, current)
+ current = {}
+ has_current = false
+ }
+ continue
+ }
+
+ // Skip blank lines outside blocks
+ if trimmed == "" {
+ continue
+ }
+
+ // Multiline block start: "key: |"
+ if strings.has_suffix(trimmed, ": |") {
+ colon := strings.last_index(trimmed, ":")
+ if colon > 0 {
+ block_key = strings.trim_space(trimmed[:colon])
+ strings.builder_init(&block_sb, allocator)
+ in_block = true
+ has_current = true
+ continue
+ }
+ }
+
+ // Simple key: value
+ colon := strings.index(trimmed, ":")
+ if colon > 0 {
+ key := strings.trim_space(trimmed[:colon])
+ value := strings.trim_space(trimmed[colon + 1:])
+ // Strip matching outer quotes
+ if len(value) >= 2 {
+ if (value[0] == '"' && value[len(value) - 1] == '"') ||
+ (value[0] == '\'' && value[len(value) - 1] == '\'') {
+ value = value[1:len(value) - 1]
+ }
+ }
+ set_field(¤t, key, value)
+ has_current = true
+ }
+ }
+
+ // Flush trailing multiline block
+ if in_block {
+ strings.write_string(&block_sb, "\n") // YAML | trailing newline
+ value := strings.clone(strings.to_string(block_sb), allocator)
+ strings.builder_destroy(&block_sb)
+ set_field(¤t, block_key, value)
+ }
+
+ // Flush last entry
+ if has_current {
+ append(&cases, current)
+ }
+
+ return cases[:]
+}
+
+set_field :: proc(c: ^Diag_Case, key, value: string) {
+ switch key {
+ case "name":
+ c.name = value
+ case "enable":
+ c.enable = value == "true"
+ case "should":
+ c.should = value
+ case "expected":
+ c.expected = value
+ case "input":
+ c.input = value
+ }
+}
+
diff --git a/mustache/diagnostic_test.odin b/mustache/diagnostic_test.odin
index 8e2eb87..acf1f00 100644
--- a/mustache/diagnostic_test.odin
+++ b/mustache/diagnostic_test.odin
@@ -1,7 +1,10 @@
#+test
package mustache
+import "core:encoding/json"
import "core:fmt"
+import "core:os"
+import "core:path/filepath"
import "core:strings"
import "core:testing"
@@ -475,3 +478,243 @@ test_parse_error_pipe_parse_in_inverted_keeps_double_braces :: proc(t: ^testing.
fmt.tprintf("msg should contain literal '{{^', got %q", b.msg),
)
}
+
+// ---------------------------------------------------------------------------
+// Data-driven diagnostic cases (from DIAGNOSTIC_TODOS.yaml)
+//
+// write_diagnostic_tests generates diagnostic_test_cases_test.odin from the
+// enabled cases in DIAGNOSTIC_TODOS.yaml. On the first run (or when the YAML
+// changes), it regenerates the file and fails. On the second run, each case
+// runs as an individual test via run_diag_case.
+// ---------------------------------------------------------------------------
+
+Diag_Context :: struct {
+ items: [5]string,
+ word: string,
+ params: json.Object,
+ now: string,
+}
+
+diag_ctx :: proc() -> Diag_Context {
+ params_raw := `{ "author": { "name": "foo bar"}}`
+ params_val, err := json.parse(params_raw, allocator = context.temp_allocator)
+ assert(err == nil)
+ params, ok := params_val.(json.Object)
+ assert(ok)
+ return Diag_Context {
+ items = {"a", "b", "c", "d", "e"},
+ word = "Hello, World!",
+ params = params,
+ now = "2026-08-10T16:21:55-04:00",
+ }
+}
+
+diag_partials :: proc() -> map[string]Template {
+ dirs := [?]string{#directory, "fixtures/layouts/partials"}
+ partials_dir, _ := filepath.join(dirs[:], context.temp_allocator)
+ return load_partials(partials_dir, allocator = context.temp_allocator)
+}
+
+diag_slug :: proc(name: string) -> string {
+ sb: strings.Builder
+ strings.builder_init(&sb, context.temp_allocator)
+ prev_underscore := false
+ for r in name {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+ strings.write_rune(&sb, r)
+ prev_underscore = false
+ } else if r >= 'A' && r <= 'Z' {
+ strings.write_rune(&sb, r + 32)
+ prev_underscore = false
+ } else {
+ if !prev_underscore && strings.builder_len(sb) > 0 {
+ strings.write_rune(&sb, '_')
+ prev_underscore = true
+ }
+ }
+ }
+ s := strings.to_string(sb)
+ if len(s) > 0 && s[len(s) - 1] == '_' {
+ s = s[:len(s) - 1]
+ }
+ return s
+}
+
+run_diag_case :: proc(t: ^testing.T, name: string) {
+ cases := load_diag_cases("../DIAGNOSTIC_TODOS.yaml")
+ ctx := diag_ctx()
+ partials := diag_partials()
+
+ for c in cases {
+ if c.name != name do continue
+
+ switch c.should {
+ case "error":
+ tmpl, perr := parse(c.input, "", allocator = context.temp_allocator)
+ if perr != nil {
+ formatted := format_render_error(perr, tmpl, colorize = false)
+ testing.expect_value(t, formatted, c.expected)
+ return
+ }
+
+ _, rerr := render(tmpl, ctx, partials, context.temp_allocator)
+ testing.expect(
+ t,
+ rerr != nil,
+ fmt.tprintf("[%s] should error but render succeeded", c.name),
+ )
+ if rerr == nil do return
+
+ formatted := format_render_error(rerr, tmpl, colorize = false)
+ testing.expect_value(t, formatted, c.expected)
+
+ case "pass":
+ tmpl, perr := parse(c.input, "", allocator = context.temp_allocator)
+ testing.expect(
+ t,
+ perr == nil,
+ fmt.tprintf(
+ "[%s] should parse, got: %s",
+ c.name,
+ perr != nil ? format_render_error(perr, tmpl, colorize = false) : "",
+ ),
+ )
+ if perr != nil do return
+
+ _, rerr := render(tmpl, ctx, partials, context.temp_allocator)
+ testing.expect(
+ t,
+ rerr == nil,
+ fmt.tprintf(
+ "[%s] should not error, got: %s",
+ c.name,
+ rerr != nil ? format_render_error(rerr, tmpl, colorize = false) : "",
+ ),
+ )
+
+ case "ok":
+ tmpl, perr := parse(c.input, "", allocator = context.temp_allocator)
+ testing.expect(
+ t,
+ perr == nil,
+ fmt.tprintf(
+ "[%s] should parse, got: %s",
+ c.name,
+ perr != nil ? format_render_error(perr, tmpl, colorize = false) : "",
+ ),
+ )
+ if perr != nil do return
+
+ result, rerr := render(tmpl, ctx, partials, context.temp_allocator)
+ testing.expect(
+ t,
+ rerr == nil,
+ fmt.tprintf(
+ "[%s] should not error, got: %s",
+ c.name,
+ rerr != nil ? format_render_error(rerr, tmpl, colorize = false) : "",
+ ),
+ )
+ if rerr != nil do return
+
+ testing.expect_value(t, result, c.expected)
+
+ case "warn":
+ tmpl, perr := parse(c.input, "", allocator = context.temp_allocator)
+ testing.expect(
+ t,
+ perr == nil,
+ fmt.tprintf(
+ "[%s] should parse, got: %s",
+ c.name,
+ perr != nil ? format_render_error(perr, tmpl, colorize = false) : "",
+ ),
+ )
+ if perr != nil do return
+
+ warnings := make([dynamic]Error, 0, 4, context.temp_allocator)
+ _, rerr := render(tmpl, ctx, partials, context.temp_allocator, &warnings)
+ testing.expect(
+ t,
+ rerr == nil,
+ fmt.tprintf(
+ "[%s] should not error, got: %s",
+ c.name,
+ rerr != nil ? format_render_error(rerr, tmpl, colorize = false) : "",
+ ),
+ )
+ if rerr != nil do return
+
+ testing.expect(
+ t,
+ len(warnings) > 0,
+ fmt.tprintf("[%s] should produce a warning", c.name),
+ )
+ if len(warnings) == 0 do return
+
+ formatted := format_render_error(warnings[0], tmpl, colorize = false)
+ testing.expect_value(t, formatted, c.expected)
+
+ case:
+ testing.expectf(t, false, "[%s] unknown should value: '%s'", c.name, c.should)
+ }
+ return
+ }
+
+ testing.expectf(t, false, "case '%s' not found in DIAGNOSTIC_TODOS.yaml", name)
+}
+
+@(test)
+write_diagnostic_tests :: proc(t: ^testing.T) {
+ cases := load_diag_cases("../DIAGNOSTIC_TODOS.yaml")
+ testing.expect(t, len(cases) > 0, "should load diagnostic cases")
+
+ sb: strings.Builder
+ strings.builder_init(&sb, context.temp_allocator)
+ strings.write_string(&sb, "#+test\n")
+ strings.write_string(&sb, "package mustache\n\n")
+ strings.write_string(&sb, "import \"core:testing\"\n\n")
+
+ count := 0
+ for c in cases {
+ if !c.enable do continue
+ if c.should == "" {
+ testing.expectf(t, false, "[%s] has enable: true but no should value", c.name)
+ continue
+ }
+ count += 1
+ slug := diag_slug(c.name)
+ strings.write_string(&sb, "@(test)\n")
+ strings.write_string(&sb, "test_diag_")
+ strings.write_string(&sb, slug)
+ strings.write_string(&sb, " :: proc(t: ^testing.T) {\n")
+ strings.write_string(&sb, "\trun_diag_case(t, \"")
+ strings.write_string(&sb, c.name)
+ strings.write_string(&sb, "\")\n}\n\n")
+ }
+
+ testing.expect(t, count > 0, "at least one case should be enabled")
+
+ generated := strings.to_string(sb)
+
+ dirs := [?]string{#directory, "diagnostic_test_cases_test.odin"}
+ gen_path, _ := filepath.join(dirs[:], context.temp_allocator)
+
+ existing_data, _ := os.read_entire_file_from_path(gen_path, context.temp_allocator)
+ existing := string(existing_data)
+
+ if existing == generated do return
+
+ werr := os.write_entire_file(gen_path, transmute([]byte)generated)
+ if werr != nil {
+ testing.expectf(t, false, "failed to write %s: %v", gen_path, werr)
+ return
+ }
+ testing.expectf(
+ t,
+ false,
+ "diagnostic_test_cases_test.odin regenerated (%d cases), re-run tests to see results",
+ count,
+ )
+}
+
diff --git a/mustache/diagnostic_test_cases_test.odin b/mustache/diagnostic_test_cases_test.odin
new file mode 100644
index 0000000..ad83795
--- /dev/null
+++ b/mustache/diagnostic_test_cases_test.odin
@@ -0,0 +1,85 @@
+#+test
+package mustache
+
+import "core:testing"
+
+@(test)
+test_diag_missing_2nd_closing_brace :: proc(t: ^testing.T) {
+ run_diag_case(t, "Missing 2nd closing brace")
+}
+
+@(test)
+test_diag_missing_2nd_opening_brace :: proc(t: ^testing.T) {
+ run_diag_case(t, "Missing 2nd opening brace")
+}
+
+@(test)
+test_diag_mismatched_section_tags :: proc(t: ^testing.T) {
+ run_diag_case(t, "Mismatched section tags")
+}
+
+@(test)
+test_diag_missing_closing_section :: proc(t: ^testing.T) {
+ run_diag_case(t, "Missing closing section")
+}
+
+@(test)
+test_diag_empty_tag :: proc(t: ^testing.T) {
+ run_diag_case(t, "empty tag")
+}
+
+@(test)
+test_diag_missing_pipe_arguement :: proc(t: ^testing.T) {
+ run_diag_case(t, "Missing pipe arguement")
+}
+
+@(test)
+test_diag_double_dot_access :: proc(t: ^testing.T) {
+ run_diag_case(t, "Double dot access")
+}
+
+@(test)
+test_diag_too_many_opening_braces :: proc(t: ^testing.T) {
+ run_diag_case(t, "Too many opening braces")
+}
+
+@(test)
+test_diag_too_many_closing_braces :: proc(t: ^testing.T) {
+ run_diag_case(t, "Too many closing braces")
+}
+
+@(test)
+test_diag_no_error_when_missing_index_page :: proc(t: ^testing.T) {
+ run_diag_case(t, "No error when missing index page")
+}
+
+@(test)
+test_diag_format_pipe_with_no_date_format_set :: proc(t: ^testing.T) {
+ run_diag_case(t, "format pipe with no date format set")
+}
+
+@(test)
+test_diag_first_last_with_zero_argument :: proc(t: ^testing.T) {
+ run_diag_case(t, "first/last with zero argument")
+}
+
+@(test)
+test_diag_first_last_with_negative_argument :: proc(t: ^testing.T) {
+ run_diag_case(t, "first/last with negative argument")
+}
+
+@(test)
+test_diag_first_succeeds_on_params_author_name :: proc(t: ^testing.T) {
+ run_diag_case(t, "first succeeds on params.author.name")
+}
+
+@(test)
+test_diag_first_last_in_partial :: proc(t: ^testing.T) {
+ run_diag_case(t, "first/last in partial")
+}
+
+@(test)
+test_diag_pipes_are_ignored_in_comments :: proc(t: ^testing.T) {
+ run_diag_case(t, "Pipes are ignored in comments")
+}
+
diff --git a/mustache/fixtures/layouts/partials/footer.html b/mustache/fixtures/layouts/partials/footer.html
new file mode 100644
index 0000000..21eb26c
--- /dev/null
+++ b/mustache/fixtures/layouts/partials/footer.html
@@ -0,0 +1,12 @@
+
diff --git a/mustache/mustache.odin b/mustache/mustache.odin
index 537ab91..86b0482 100644
--- a/mustache/mustache.odin
+++ b/mustache/mustache.odin
@@ -23,9 +23,9 @@ Context_Stack :: [dynamic]any
// ---------------------------------------------------------------------------
Error_Kind :: enum {
- Syntax, // parse-time: malformed template
- Data, // render-time: template fine, data wrong (e.g. filter misuse)
- Warning, // non-fatal: operation succeeded but user should know
+ Syntax, // parse-time: malformed template
+ Data, // render-time: template fine, data wrong (e.g. filter misuse)
+ Warning, // non-fatal: operation succeeded but user should know
}
Error_Body :: struct {
@@ -60,13 +60,13 @@ tag_error :: proc(err: Error, tmpl: Template) -> Error {
if err == nil do return nil
b := body(err)
return Error_Body {
- msg = b.msg,
- pos = b.pos,
- span = b.span,
- hint = b.hint,
- kind = b.kind,
+ msg = b.msg,
+ pos = b.pos,
+ span = b.span,
+ hint = b.hint,
+ kind = b.kind,
source = tmpl.source,
- path = tmpl.path,
+ path = tmpl.path,
}
}
@@ -156,18 +156,22 @@ parse :: proc(
tmpl: Template,
err: Error,
) {
+ tmpl.source = source
+ tmpl.path = path
+
tokens, terr := tokenize(source, tokens_allocator)
if terr != nil {
- return {}, terr
+ return tmpl, terr
}
tmpl.nodes, err = parse_tokens(tokens[:], source, allocator)
if err != nil {
delete(tmpl.nodes)
- return {}, err
+ e := body(err)
+ e.source = source
+ e.path = path
+ return {}, e
}
- tmpl.source = source
- tmpl.path = path
deindent_blocks(tmpl.nodes[:], allocator)
return tmpl, nil
}
@@ -269,7 +273,12 @@ parse_section :: proc(
case .Variable:
idx := len(nodes)
append(nodes, Node{kind = .Variable})
- pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
+ pipe_key, perr := parse_pipeline(
+ tok.value,
+ &nodes[idx].filters,
+ tok.pos,
+ tag_content_base(source, tok.pos),
+ )
if perr != nil {
return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr),
@@ -283,7 +292,12 @@ parse_section :: proc(
case .Unescaped:
idx := len(nodes)
append(nodes, Node{kind = .Unescaped})
- pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
+ pipe_key, perr := parse_pipeline(
+ tok.value,
+ &nodes[idx].filters,
+ tok.pos,
+ tag_content_base(source, tok.pos),
+ )
if perr != nil {
return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr),
@@ -303,7 +317,12 @@ parse_section :: proc(
content_start := 0
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Section, pos = tok.pos})
- pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
+ pipe_key, perr := parse_pipeline(
+ tok.value,
+ &nodes[idx].filters,
+ tok.pos,
+ tag_content_base(source, tok.pos),
+ )
if perr != nil {
return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{#%s}}}}': %v", tok.value, perr),
@@ -324,7 +343,12 @@ parse_section :: proc(
content_start := 0
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Inverted, pos = tok.pos})
- pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
+ pipe_key, perr := parse_pipeline(
+ tok.value,
+ &nodes[idx].filters,
+ tok.pos,
+ tag_content_base(source, tok.pos),
+ )
if perr != nil {
return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{^%s}}}}': %v", tok.value, perr),
@@ -625,7 +649,14 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node)
}
if len(node.filters) > 0 {
- transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:], current, warnings)
+ transformed, perr := apply_pipeline(
+ val,
+ node.filters[:],
+ node.pos,
+ ctx[:],
+ current,
+ warnings,
+ )
if perr != nil {
return tag_error(perr, current)
}
@@ -644,7 +675,14 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node)
}
if len(node.filters) > 0 {
- transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:], current, warnings)
+ transformed, perr := apply_pipeline(
+ val,
+ node.filters[:],
+ node.pos,
+ ctx[:],
+ current,
+ warnings,
+ )
if perr != nil {
return tag_error(perr, current)
}
@@ -659,7 +697,14 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node)
}
if len(node.filters) > 0 {
- transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:], current, warnings)
+ transformed, perr := apply_pipeline(
+ val,
+ node.filters[:],
+ node.pos,
+ ctx[:],
+ current,
+ warnings,
+ )
if perr != nil {
return tag_error(perr, current)
}
@@ -673,16 +718,16 @@ render_nodes :: proc(
elem := extract_list_element(elem_info, data, j)
context_push(ctx, elem, current, node)
defer pop(ctx)
- render_nodes(
- current,
- children,
- ctx,
- partials,
- b,
- blocks,
- indent_state,
- warnings,
- ) or_return
+ render_nodes(
+ current,
+ children,
+ ctx,
+ partials,
+ b,
+ blocks,
+ indent_state,
+ warnings,
+ ) or_return
}
} else {
context_push(ctx, val, current, node)
@@ -707,24 +752,31 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node)
}
if len(node.filters) > 0 {
- transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:], current, warnings)
+ transformed, perr := apply_pipeline(
+ val,
+ node.filters[:],
+ node.pos,
+ ctx[:],
+ current,
+ warnings,
+ )
if perr != nil {
return tag_error(perr, current)
}
val = transformed
}
- if !is_truthy(val) {
- render_nodes(
- current,
- node.children,
- ctx,
- partials,
- b,
- blocks,
- indent_state,
- warnings,
- ) or_return
- }
+ if !is_truthy(val) {
+ render_nodes(
+ current,
+ node.children,
+ ctx,
+ partials,
+ b,
+ blocks,
+ indent_state,
+ warnings,
+ ) or_return
+ }
i += 1 + len(node.children)
case .Partial:
@@ -863,7 +915,14 @@ warn_unknown_key :: proc(current: Template, ctx: []any, node: Node) {
if path == "" {
path = " "
}
- diag := format_tag_error(path, current.source, node.pos, msg, hint, colorize = diags.should_colorize())
+ diag := format_tag_error(
+ path,
+ current.source,
+ node.pos,
+ msg,
+ hint,
+ colorize = diags.should_colorize(),
+ )
log.warnf("%s", diag)
}
@@ -886,7 +945,14 @@ warn_missing_partial :: proc(
if path == "" {
path = " "
}
- diag := format_tag_error(path, current.source, node.pos, msg, hint, colorize = diags.should_colorize())
+ diag := format_tag_error(
+ path,
+ current.source,
+ node.pos,
+ msg,
+ hint,
+ colorize = diags.should_colorize(),
+ )
log.warnf("%s", diag)
}
@@ -965,6 +1031,14 @@ warn_context_depth :: proc(current: Template, node: Node) {
if path == "" {
path = " "
}
- diag := format_tag_error(path, current.source, node.pos, msg, "", colorize = diags.should_colorize())
+ diag := format_tag_error(
+ path,
+ current.source,
+ node.pos,
+ msg,
+ "",
+ colorize = diags.should_colorize(),
+ )
log.warnf("%s", diag)
}
+
diff --git a/mustache/partials.odin b/mustache/partials.odin
new file mode 100644
index 0000000..5b85a06
--- /dev/null
+++ b/mustache/partials.odin
@@ -0,0 +1,48 @@
+package mustache
+
+import "core:fmt"
+import "core:log"
+import "core:os"
+import "core:strings"
+
+load_partials :: proc(
+ dir: string,
+ base: string = "",
+ allocator := context.allocator,
+) -> map[string]Template {
+ partials := make(map[string]Template, allocator)
+
+ entries, err := os.read_all_directory_by_path(dir, context.temp_allocator)
+ if err != nil {
+ log.warnf("cannot read partials dir %s: %v", dir, err)
+ return partials
+ }
+ defer os.file_info_slice_delete(entries, context.temp_allocator)
+
+ for entry in entries {
+ if entry.type == .Regular {
+ if !strings.has_suffix(entry.name, ".html") do continue
+ stripped := entry.name[:len(entry.name) - len(".html")]
+ key := fmt.tprintf("%s%s", base, stripped)
+ data, ferr := os.read_entire_file_from_path(entry.fullpath, allocator)
+ if ferr != nil do continue
+ source := string(data)
+ path := strings.clone(entry.fullpath, allocator)
+ tmpl, perr := parse(source, path, allocator = allocator)
+ if perr != nil {
+ b := body(perr)
+ log.warnf("failed to parse partial %s: %s", entry.fullpath, b.msg)
+ continue
+ }
+ partials[key] = tmpl
+ } else if entry.type == .Directory {
+ sub_base := fmt.tprintf("%s%s/", base, entry.name)
+ sub := load_partials(entry.fullpath, sub_base, allocator)
+ for k, v in sub {
+ partials[k] = v
+ }
+ }
+ }
+
+ return partials
+}