diff --git a/AGENTS.md b/AGENTS.md index 0296e44..5f423d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -442,8 +442,9 @@ Rust-style error messages with multi-line source context, caret underlines, and **Exceptions** (no warning): - `{{.}}` and dot-prefixed names (current context) -- Paths that cross a map (e.g., `params.*` — user-defined namespace) +- Paths that cross a map (e.g., `params.*`) — validated for typos via Levenshtein: close matches warn with a suggestion, genuinely absent keys are suppressed silently - `Maybe(bool)` fields with nil value (field exists, value is nil — distinguished via `struct_has_field`) +- Found fields whose value is nil/empty (e.g., nil `json.Value` union) — the field exists, looking up sub-keys is valid "not found" behavior **Block override source tracking**: `Block_Override.source: Template` ensures warnings inside block overrides point at the override's source file (e.g., `page.html`), not the parent template (`base.html`). diff --git a/mustache/data.odin b/mustache/data.odin index c859a43..dc741c8 100644 --- a/mustache/data.odin +++ b/mustache/data.odin @@ -137,6 +137,22 @@ resolve_name :: proc(name: string, ctx: []any) -> any { return result } +collect_map_keys :: proc(container: any, allocator := context.temp_allocator) -> []string { + val, info := base_value(container) + if info == nil do return nil + if _, ok := info.variant.(runtime.Type_Info_Map); !ok { + return nil + } + out := make([dynamic]string, 0, 4, allocator) + it := 0 + for { + key, _ := reflect.iterate_map(val, &it) or_break + key_str := key.(string) or_continue + append(&out, key_str) + } + return out[:] +} + // is_truthy checks mustache truthiness. is_truthy :: proc(a: any) -> bool { if a == nil { @@ -302,3 +318,4 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) { strings.write_string(b, s[start:]) } } + diff --git a/mustache/suggest.odin b/mustache/suggest.odin index f4023ab..4fdc2ce 100644 --- a/mustache/suggest.odin +++ b/mustache/suggest.odin @@ -121,9 +121,18 @@ validate_key_path :: proc( for i in 1 ..< part_count { v, info := base_value(current) if info == nil { - return false, parts[i], nil + return true, "", nil } if _, is_map := info.variant.(runtime.Type_Info_Map); is_map { + val, found := lookup_in(current, parts[i]) + if found { + current = val + continue + } + available := collect_map_keys(current, allocator) + if suggest_correction(available, parts[i]) != "" { + return false, parts[i], available + } return true, "", nil } if _, is_struct := info.variant.(runtime.Type_Info_Struct); is_struct { diff --git a/mustache/suggest_test.odin b/mustache/suggest_test.odin index 95b5dc5..1d93c1e 100644 --- a/mustache/suggest_test.odin +++ b/mustache/suggest_test.odin @@ -2,6 +2,7 @@ #+feature dynamic-literals package mustache +import "core:encoding/json" import "core:fmt" import "core:testing" @@ -196,3 +197,68 @@ test_warn_no_false_positive_for_valid_keys :: proc(t: ^testing.T) { ok, missing, _ := validate_key_path(ctx[:], "name") testing.expect_value(t, ok, true) } + +// --- json.Value map crossing tests --- + +JSON_Params_Context :: struct { + params: json.Value, +} + +@(test) +test_validate_key_path_crosses_json_value_map :: proc(t: ^testing.T) { + params_map := make(json.Object, context.temp_allocator) + params_map["author"] = json.String("Tester") + + data := JSON_Params_Context { params = params_map } + ctx := make([dynamic]any, 0, 1, context.temp_allocator) + append(&ctx, data) + + ok, missing, _ := validate_key_path(ctx[:], "params.starred") + testing.expect(t, ok, "path crossing json.Value map should be ok") + testing.expect(t, missing == "", "no missing segment for map crossing") +} + +@(test) +test_validate_key_path_json_value_map_existing_key :: proc(t: ^testing.T) { + params_map := make(json.Object, context.temp_allocator) + params_map["author"] = json.String("Tester") + + data := JSON_Params_Context { params = params_map } + ctx := make([dynamic]any, 0, 1, context.temp_allocator) + append(&ctx, data) + + ok, missing, _ := validate_key_path(ctx[:], "params.author") + testing.expect(t, ok, "existing key in json.Value map should be ok") +} + +@(test) +test_validate_key_path_map_typo :: proc(t: ^testing.T) { + params_map := make(json.Object, context.temp_allocator) + params_map["author"] = json.String("Tester") + params_map["social"] = json.String("") + + data := JSON_Params_Context { params = params_map } + ctx := make([dynamic]any, 0, 1, context.temp_allocator) + append(&ctx, data) + + ok, missing, available := validate_key_path(ctx[:], "params.authr") + testing.expect(t, !ok, "typo of map key should not be ok") + testing.expect_value(t, missing, "authr") + suggestion := suggest_correction(available, "authr") + testing.expect_value(t, suggestion, "author") +} + +@(test) +test_validate_key_path_map_no_close_match :: proc(t: ^testing.T) { + params_map := make(json.Object, context.temp_allocator) + params_map["author"] = json.String("Tester") + params_map["social"] = json.String("") + + data := JSON_Params_Context { params = params_map } + ctx := make([dynamic]any, 0, 1, context.temp_allocator) + append(&ctx, data) + + ok, missing, _ := validate_key_path(ctx[:], "params.xyz") + testing.expect(t, ok, "unknown key with no close match should be ok (suppressed)") + testing.expect(t, missing == "", "no missing segment when suppressed") +}