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
+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
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, "<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 {
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 = "<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)
}
@@ -886,7 +945,14 @@ warn_missing_partial :: proc(
if path == "" {
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)
}
@@ -965,6 +1031,14 @@ warn_context_depth :: proc(current: Template, node: Node) {
if path == "" {
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)
}
+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
}