test: Converted DIAGNOSTIC_TODOS.yaml into a test suite.

This commit is contained in:
Spencer Brower
2026-08-10 16:34:43 -04:00
parent b779cd68a5
commit cf32ff0218
9 changed files with 944 additions and 126 deletions
+177 -80
View File
@@ -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 name: Missing 2nd closing brace
enable: true
should: error
input: | input: |
<menu> <menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}</a> <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}</a>
@@ -7,20 +11,20 @@ input: |
{{/params.social}} {{/params.social}}
</menu> </menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
expected: |
expected: Somthing that points to the opening and lack of close. Missing closing brace
actual: | --> <test>:1:1
[ERROR] --- [383:load_partials()] unexpected {{/params.social}} 1 | <menu>
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5 2 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}</a>
| ^^ opened ^ never closed
3 | {{#params.social}}
4 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
5 | {{/params.social}}
| |
4 | {{#params.social}}
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
6 | {{/params.social}}
| ^^^^^^^^^^^^^^^^^^
7 | </menu>
8 | <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
--- ---
name: Missing 2nd opening brace name: Missing 2nd opening brace
enable: true
should: error
input: | input: |
<menu> <menu>
<a class="goto-top opacity-0" href="#">{> icons/chevron_up}}</a> <a class="goto-top opacity-0" href="#">{> icons/chevron_up}}</a>
@@ -29,10 +33,21 @@ input: |
{{/params.social}} {{/params.social}}
</menu> </menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
expected: Somthing that shows the whole block, and points out the missing opening brace expected: |
actual: Nothing. The page compiles with no warnings or errors Missing opening brace
--> <test>:1:1
1 | <menu>
2 | <a class="goto-top opacity-0" href="#">{> icons/chevron_up}}</a>
| ^ opened ^^ closed
3 | {{#params.social}}
4 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
5 | {{/params.social}}
|
--- ---
# TODO: make it work
name: Invalid timezone name: Invalid timezone
enable: false
should: warn
input: | input: |
{ {
"date": { "date": {
@@ -44,6 +59,8 @@ actual: |
[WARN ] --- [148:init_site()] unable to load timezone 'America/New_Yorkside' [WARN ] --- [148:init_site()] unable to load timezone 'America/New_Yorkside'
--- ---
name: Mismatched section tags name: Mismatched section tags
enable: true
should: error
input: | input: |
<footer> <footer>
<menu> <menu>
@@ -57,19 +74,20 @@ input: |
</p> </p>
<p><small>&copy;</small> {{now | format "2006" }} {{params.author.name}}</p> <p><small>&copy;</small> {{now | format "2006" }} {{params.author.name}}</p>
</footer> </footer>
expected: Mostly the same, but with rust style markers at the opening and close expected: |
actual: | Mismatched section: 'ocial' opened, 'social' closed
[ERROR] --- [383:load_partials()] expected {{/params.ocial}}, got {{/params.social}} --> <test>:4:5
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
| |
4 | {{#params.ocial}} 4 | {{#params.ocial}}
| ^^^^^ opened — did you mean 'social'?
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a> 5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
6 | {{/params.social}} 6 | {{/params.social}}
| ^^^^^^^^^^^^^^^^^^ | ^^^^^^ expected {{/params.ocial}}, got {{/params.social}}
7 | </menu> |
8 | <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
--- ---
name: Missing closing section name: Missing closing section
enable: true
should: error
input: | input: |
<footer> <footer>
<menu> <menu>
@@ -83,10 +101,20 @@ input: |
</p> </p>
<p><small>&copy;</small> {{now | format "2006" }} {{params.author.name}}</p> <p><small>&copy;</small> {{now | format "2006" }} {{params.author.name}}</p>
</footer> </footer>
expected: "Missing closing tag '{{/params.social}}' ...opened <here> ... expected close <here>" expected: |
actual: Nothing. The page compiles with no warnings or errors Missing closing tag '{{/params.social}}'
--> <test>:4:5
|
4 | {{#params.social}}
| ^^^^^^^^^^^^^^^^^ opened
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
6 | <!-- {{/params.social}} -->
| ^^^^^^^^^^^^^^^^^^ closing tag is commented out
|
--- ---
name: empty tag name: empty tag
enable: true
should: error
input: | input: |
<footer> <footer>
<menu> <menu>
@@ -100,18 +128,20 @@ input: |
</p> </p>
<p><small>&copy;</small> {{}} {{params.author.name}}</p> <p><small>&copy;</small> {{}} {{params.author.name}}</p>
</footer> </footer>
expected: "Found an empty tag at <line:col>" expected: |
actual: | Empty tag
[WARN ] --- [920:warn_unknown_key()] unknown key '' --> <test>:11:28
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1 |
| 9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
1 | <footer> 10 | </p>
| ^ 11 | <p><small>&copy;</small> {{}} {{params.author.name}}</p>
2 | <menu> | ^^^^ tag must contain a key
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a> 12 | </footer>
| |
--- ---
name: Missing pipe arguement name: Missing pipe arguement
enable: true
should: error
input: | input: |
<footer> <footer>
<menu> <menu>
@@ -125,18 +155,20 @@ input: |
</p> </p>
<p><small>&copy;</small> {{now | group_by }} {{params.author.name}}</p> <p><small>&copy;</small> {{now | group_by }} {{params.author.name}}</p>
</footer> </footer>
expected: "Missing filter argument <pointing to exact location>" expected: |
actual: | group_by expects 1 argument, got 0
[ERROR] --- [152:render_template()] group_by expects 1 argument, got 0 --> <test>:11:36
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
1 | <footer>
| ^
2 | <menu>
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
| |
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{now | group_by }} {{params.author.name}}</p>
| ^^^^^^^^^ missing argument
12 | </footer>
|
--- ---
name: Double dot access name: Double dot access
enable: true
should: error
input: | input: |
<footer> <footer>
<menu> <menu>
@@ -150,10 +182,20 @@ input: |
</p> </p>
<p><small>&copy;</small> {{now | format "2006" }} {{params..name}}</p> <p><small>&copy;</small> {{now | format "2006" }} {{params..name}}</p>
</footer> </footer>
expected: Error "Invalid access key" expected: |
actual: Nothing. The page compiles with no warnings or errors Invalid key path: consecutive dots
--> <test>:11:60
|
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{now | format "2006" }} {{params..name}}</p>
| ^^ missing path name
12 | </footer>
|
--- ---
name: Too many opening braces name: Too many opening braces
enable: true
should: error
input: | input: |
<footer> <footer>
<menu> <menu>
@@ -167,18 +209,20 @@ input: |
</p> </p>
<p><small>&copy;</small> {{{{now | format "2006" }}} {{params.author.name}}</p> <p><small>&copy;</small> {{{{now | format "2006" }}} {{params.author.name}}</p>
</footer> </footer>
expected: "Too many opening braces <point to location>" expected: |
actual: | Too many opening braces
[WARN ] --- [920:warn_unknown_key()] unknown key '{now' --> <test>:11:28
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1 |
| 9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
1 | <footer> 10 | </p>
| ^ did you mean 'now'? 11 | <p><small>&copy;</small> {{{{now | format "2006" }}} {{params.author.name}}</p>
2 | <menu> | ^^^^ expected {{{, got {{{{
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a> 12 | </footer>
| |
--- ---
name: Too many closing braces name: Too many closing braces
enable: true
should: error
input: | input: |
<footer> <footer>
<menu> <menu>
@@ -192,10 +236,21 @@ input: |
</p> </p>
<p><small>&copy;</small> {{now | format "2006" }}}} {{params.author.name}}</p> <p><small>&copy;</small> {{now | format "2006" }}}} {{params.author.name}}</p>
</footer> </footer>
expected: "Too many closing braces <Point to location>" expected: |
actual: Nothing. The page compiles with no warnings or errors Too many closing braces
--> <test>:11:50
|
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{now | format "2006" }}}} {{params.author.name}}</p>
| ^^^^ expected }}, got }}}
12 | </footer>
|
--- ---
# TODO: Implement
name: Frontmatter missing closing quote on key name: Frontmatter missing closing quote on key
enable: false
should: warn
input: | input: |
{ {
"title: Docs" "title: Docs"
@@ -208,7 +263,10 @@ expected: 'TODO:'
actual: | actual: |
[ERROR] --- [42:parse_frontmatter()] failed to parse frontmatter JSON: Expected_Colon_After_Key [ERROR] --- [42:parse_frontmatter()] failed to parse frontmatter JSON: Expected_Colon_After_Key
--- ---
# TODO: Implement
name: Fronmatter missing opening quote on value name: Fronmatter missing opening quote on value
should: warn
enable: false
input: | input: |
{ {
"title": Docs" "title": Docs"
@@ -220,48 +278,87 @@ input: |
expected: 'detailed diagnostic pointing to the point of failure and suggesting the addition of a quote' 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 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/' 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 name: format pipe with no date format set
enable: true
should: warn
input: '{{ now | format }}'
expected: | expected: |
Show where the error was triggered. Should also show the format that will be used no date format configured — set date.format in thor.json or specify a format, e.g. {{key | format "2 Jan 2006"}}
actual: | --> <test>:1:10
[ERROR] --- [333:apply_format()] format pipe used but no date format configured (set date.format in thor.json) Default will be used |
1 | {{now | format}}
| ^^^^^^ defaulting to "2 Jan 2006"
--- ---
# TODO: called First/last, but only tests first
name: first/last with zero argument name: first/last with zero argument
enable: true
should: error
input: | input: |
<ul>{{#items | first 0}}<li>{{.}}</li>{{/items}}</ul> <ul>{{#items | first 0}}<li>{{.}}</li>{{/items}}</ul>
expected: Error with source location — "argument must not be zero" expected: |
actual: | argument must not be zero
[ERROR] --- [render_template()] argument must not be zero --> <test>:1:5
--> <file>:<line>:<col>
| |
N | <ul>{{#items | first 0}}<li>{{.}}</li>{{/items}}</ul> 1 | <ul>{{#items | first 0}}<li>{{.}}</li>{{/items}}</ul>
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^
| |
--- ---
# TODO: called First/last, but only tests first
name: first/last with negative argument name: first/last with negative argument
enable: true
should: warn
input: | input: |
<ul>{{#items | first -3}}<li>{{.}}</li>{{/items}}</ul> <ul>{{#items | first -3}}<li>{{.}}</li>{{/items}}</ul>
expected: Warning with source location — "first -3 is equivalent to last 3 — consider using the clearer form" expected: |
actual: | first -3 is equivalent to last 3
[WARN ] --- [take()] first -3 is equivalent to last 3 --> <test>:1:5
--> <file>:<line>:<col>
| |
N | <ul>{{#items | first -3}}<li>{{.}}</li>{{/items}}</ul> 1 | <ul>{{#items | first -3}}<li>{{.}}</li>{{/items}}</ul>
| ^^^^^^^^^^^^^^^^^^^^^^^^^ consider using the clearer form | ^^^^^^^^ consider using the clearer form
| |
--- ---
# TODO: called First/last, but only tests first
name: first/last on string with no argument name: first/last on string with no argument
input: | # enable: true
{{word | first}} input: '{{word | first}}'
expected: Warning with source location — "defaulting to n=1 for string; consider specifying n explicitly" should: warn
actual: | expected: |
[WARN ] --- [take()] defaulting to n=1 for string defaulting to n=1 for string
--> <file>:<line>:<col> --> <test>:1:1
| |
N | {{word | first}} 1 | {{word | first}}
| ^^^^^^^^^^^^^^^ consider specifying n explicitly | ^^^^^ 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 | <footer>
| ^ consider using the clearer form
2 | <menu>
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
---
name: "Pipes are ignored in comments"
enable: true
should: ok
input: 'Hello, {{! now | format "2006" }}'
expected: 'Hello, '
+1
View File
@@ -37,6 +37,7 @@
- [ ] merge `render_{section,home_html,page_html}` procs. - [ ] merge `render_{section,home_html,page_html}` procs.
- [ ] try to combine render_page_html and render_home_html? - [ ] try to combine render_page_html and render_home_html?
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something. - [ ] 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 ### Menus
+109
View File
@@ -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 | <ul>{{#items | first -3}}<li>{{.}}</li>{{/items}}</ul>
| ^^^^^^^^^^^^^^^^^^^^^ 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 : "<input>"
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
+149
View File
@@ -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(&current, 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(&current, 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(&current, 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
}
}
+243
View File
@@ -1,7 +1,10 @@
#+test #+test
package mustache package mustache
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:testing" 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), 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, "<test>", 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, "<test>", 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, "<test>", 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, "<test>", 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,
)
}
+85
View File
@@ -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")
}
@@ -0,0 +1,12 @@
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{! now | format "2006" }} {{params.author.name | first -3 }}</p>
</footer>
+120 -46
View File
@@ -23,9 +23,9 @@ Context_Stack :: [dynamic]any
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Error_Kind :: enum { Error_Kind :: enum {
Syntax, // parse-time: malformed template Syntax, // parse-time: malformed template
Data, // render-time: template fine, data wrong (e.g. filter misuse) Data, // render-time: template fine, data wrong (e.g. filter misuse)
Warning, // non-fatal: operation succeeded but user should know Warning, // non-fatal: operation succeeded but user should know
} }
Error_Body :: struct { Error_Body :: struct {
@@ -60,13 +60,13 @@ tag_error :: proc(err: Error, tmpl: Template) -> Error {
if err == nil do return nil if err == nil do return nil
b := body(err) b := body(err)
return Error_Body { return Error_Body {
msg = b.msg, msg = b.msg,
pos = b.pos, pos = b.pos,
span = b.span, span = b.span,
hint = b.hint, hint = b.hint,
kind = b.kind, kind = b.kind,
source = tmpl.source, source = tmpl.source,
path = tmpl.path, path = tmpl.path,
} }
} }
@@ -156,18 +156,22 @@ parse :: proc(
tmpl: Template, tmpl: Template,
err: Error, err: Error,
) { ) {
tmpl.source = source
tmpl.path = path
tokens, terr := tokenize(source, tokens_allocator) tokens, terr := tokenize(source, tokens_allocator)
if terr != nil { if terr != nil {
return {}, terr return tmpl, terr
} }
tmpl.nodes, err = parse_tokens(tokens[:], source, allocator) tmpl.nodes, err = parse_tokens(tokens[:], source, allocator)
if err != nil { if err != nil {
delete(tmpl.nodes) 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) deindent_blocks(tmpl.nodes[:], allocator)
return tmpl, nil return tmpl, nil
} }
@@ -269,7 +273,12 @@ parse_section :: proc(
case .Variable: case .Variable:
idx := len(nodes) idx := len(nodes)
append(nodes, Node{kind = .Variable}) 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 { if perr != nil {
return Error_Body { return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr), msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr),
@@ -283,7 +292,12 @@ parse_section :: proc(
case .Unescaped: case .Unescaped:
idx := len(nodes) idx := len(nodes)
append(nodes, Node{kind = .Unescaped}) 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 { if perr != nil {
return Error_Body { return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr), msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr),
@@ -303,7 +317,12 @@ parse_section :: proc(
content_start := 0 content_start := 0
if pos^ < len(tokens) {content_start = tokens[pos^].pos} if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Section, pos = tok.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 { if perr != nil {
return Error_Body { return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{#%s}}}}': %v", tok.value, perr), msg = fmt.tprintf("pipe parse error in '{{{{#%s}}}}': %v", tok.value, perr),
@@ -324,7 +343,12 @@ parse_section :: proc(
content_start := 0 content_start := 0
if pos^ < len(tokens) {content_start = tokens[pos^].pos} if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Inverted, pos = tok.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 { if perr != nil {
return Error_Body { return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{^%s}}}}': %v", tok.value, perr), 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) warn_unknown_key(current, ctx[:], node)
} }
if len(node.filters) > 0 { 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 { if perr != nil {
return tag_error(perr, current) return tag_error(perr, current)
} }
@@ -644,7 +675,14 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node) warn_unknown_key(current, ctx[:], node)
} }
if len(node.filters) > 0 { 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 { if perr != nil {
return tag_error(perr, current) return tag_error(perr, current)
} }
@@ -659,7 +697,14 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node) warn_unknown_key(current, ctx[:], node)
} }
if len(node.filters) > 0 { 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 { if perr != nil {
return tag_error(perr, current) return tag_error(perr, current)
} }
@@ -673,16 +718,16 @@ render_nodes :: proc(
elem := extract_list_element(elem_info, data, j) elem := extract_list_element(elem_info, data, j)
context_push(ctx, elem, current, node) context_push(ctx, elem, current, node)
defer pop(ctx) defer pop(ctx)
render_nodes( render_nodes(
current, current,
children, children,
ctx, ctx,
partials, partials,
b, b,
blocks, blocks,
indent_state, indent_state,
warnings, warnings,
) or_return ) or_return
} }
} else { } else {
context_push(ctx, val, current, node) context_push(ctx, val, current, node)
@@ -707,24 +752,31 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node) warn_unknown_key(current, ctx[:], node)
} }
if len(node.filters) > 0 { 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 { if perr != nil {
return tag_error(perr, current) return tag_error(perr, current)
} }
val = transformed val = transformed
} }
if !is_truthy(val) { if !is_truthy(val) {
render_nodes( render_nodes(
current, current,
node.children, node.children,
ctx, ctx,
partials, partials,
b, b,
blocks, blocks,
indent_state, indent_state,
warnings, warnings,
) or_return ) or_return
} }
i += 1 + len(node.children) i += 1 + len(node.children)
case .Partial: case .Partial:
@@ -863,7 +915,14 @@ warn_unknown_key :: proc(current: Template, ctx: []any, node: Node) {
if path == "" { if path == "" {
path = "<input>" path = "<input>"
} }
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) log.warnf("%s", diag)
} }
@@ -886,7 +945,14 @@ warn_missing_partial :: proc(
if path == "" { if path == "" {
path = "<input>" path = "<input>"
} }
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) log.warnf("%s", diag)
} }
@@ -965,6 +1031,14 @@ warn_context_depth :: proc(current: Template, node: Node) {
if path == "" { if path == "" {
path = "<input>" path = "<input>"
} }
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) log.warnf("%s", diag)
} }
+48
View File
@@ -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
}