- Task 1: OCaml/dune scaffold with woc CLI (exit codes 0/1/2), just woc-build/woc-test - Task 2: Diagnostics module — WO-E coded diagnostics with excerpts, related sites, ordered dedup collector, exit-code decision - Task 3: Newline-significant lexer mirroring rt conventions (self/me/subscribe/insert stay idents), --dump-tokens, golden test framework with bless mode - Task 4: Declaration parser — class/type/interface/fn signatures, @gc/@table annotations, brace-depth skip-on-block (service/policy/on), decl-level recovery, --dump-ast goldens - 13 golden fixtures (tokens + AST) with WOC_BLESS=1 support - just woc-test green (14 diag + 93 runner checks)
212 lines
7.7 KiB
OCaml
212 lines
7.7 KiB
OCaml
(* test_diag.ml — minimal unit-test seam for compiler/src/diag.ml.
|
|
|
|
This is NOT the golden-file runner (Task 3 adds that as
|
|
compiler/test/runner.ml, alongside this file). Plain assertions,
|
|
printed failures, nonzero exit on any failure — just enough to
|
|
drive diag.ml with TDD before the golden framework exists. *)
|
|
|
|
let checks = ref 0
|
|
let failures = ref 0
|
|
|
|
let check name cond =
|
|
incr checks;
|
|
if not cond then begin
|
|
incr failures;
|
|
Printf.printf "FAIL: %s\n" name
|
|
end
|
|
|
|
let check_eq name ~expected ~actual to_string =
|
|
incr checks;
|
|
if expected <> actual then begin
|
|
incr failures;
|
|
Printf.printf "FAIL: %s\n expected: %s\n actual: %s\n" name
|
|
(to_string expected) (to_string actual)
|
|
end
|
|
|
|
module D = Woc_lib.Diag
|
|
|
|
(* ---- fixture source text ------------------------------------------ *)
|
|
|
|
let pricing_wo =
|
|
String.concat "\n"
|
|
[ "type Widget {"
|
|
; " field name: Text"
|
|
; "}"
|
|
; ""
|
|
; "fn use_widget() {"
|
|
; " let p = Widget { name: \"a\" }"
|
|
; " -- noop"
|
|
; " -- noop"
|
|
; " -- noop"
|
|
; " let q = p"
|
|
; " -- noop"
|
|
; " -- noop"
|
|
; " -- noop"
|
|
; " take p"
|
|
]
|
|
|
|
let lookup file = if file = "pricing.wo" then Some pricing_wo else None
|
|
|
|
(* ---- rendering shape: code, position, caret excerpt, related site - *)
|
|
|
|
let () =
|
|
let related =
|
|
[ D.related_site ~file:"pricing.wo" ~line:6 ~col:7 ~label:"moved here" ]
|
|
in
|
|
let d =
|
|
D.error ~code:"WO-E301" ~file:"pricing.wo" ~line:14 ~col:8
|
|
~message:"value 'p' used after move" ~related ()
|
|
in
|
|
let rendered = D.render lookup d in
|
|
let lines = String.split_on_char '\n' rendered in
|
|
let expected =
|
|
[ "pricing.wo:14:8: error WO-E301: value 'p' used after move"
|
|
; " take p"
|
|
; String.make 7 ' ' ^ "^"
|
|
; " pricing.wo:6:7: moved here"
|
|
; " let p = Widget { name: \"a\" }"
|
|
; " " ^ String.make 6 ' ' ^ "^"
|
|
]
|
|
in
|
|
check_eq "render: full diagnostic shape (header/excerpt/caret + related)"
|
|
~expected ~actual:lines (String.concat "\n")
|
|
|
|
let () =
|
|
(* Warning severity, no related sites: header word changes, no
|
|
trailing related blocks appended. *)
|
|
let d =
|
|
D.warning ~code:"WO-E999" ~file:"pricing.wo" ~line:1 ~col:1
|
|
~message:"placeholder" ()
|
|
in
|
|
let rendered = D.render lookup d in
|
|
check "render: warning word + no related blocks"
|
|
(rendered = "pricing.wo:1:1: warning WO-E999: placeholder\ntype Widget {\n^")
|
|
|
|
let () =
|
|
(* Source unavailable: falls back to the header line only, no crash. *)
|
|
let d =
|
|
D.error ~code:"WO-E001" ~file:"missing.wo" ~line:3 ~col:2
|
|
~message:"no source available" ()
|
|
in
|
|
let rendered = D.render lookup d in
|
|
check "render: missing source falls back to header-only line"
|
|
(rendered = "missing.wo:3:2: error WO-E001: no source available")
|
|
|
|
let () =
|
|
(* Tabs count as one column: a leading tab is not expanded, so the
|
|
caret offset is exactly (col - 1) characters regardless of what
|
|
the preceding characters are. *)
|
|
let tabby_lookup file = if file = "t.wo" then Some "\tlet p = 1" else None in
|
|
let d = D.error ~code:"WO-E001" ~file:"t.wo" ~line:1 ~col:6 ~message:"x" () in
|
|
let rendered = D.render tabby_lookup d in
|
|
match String.split_on_char '\n' rendered with
|
|
| [ _header; _source; caret ] ->
|
|
check "render: tab counts as one column" (caret = String.make 5 ' ' ^ "^")
|
|
| _ -> check "render: tab counts as one column (unexpected shape)" false
|
|
|
|
let () =
|
|
(* A defensive edge case, not a real compiler output: line 0 (or
|
|
negative) is out of the 1-based contract. Rendering must fall
|
|
back to a header-only line, matching the missing-file case,
|
|
rather than raising (List.nth_opt raises Invalid_argument on a
|
|
negative index if this guard is ever removed). *)
|
|
let d =
|
|
D.error ~code:"WO-E001" ~file:"pricing.wo" ~line:0 ~col:1
|
|
~message:"out-of-range line stays non-fatal" ()
|
|
in
|
|
let rendered = D.render lookup d in
|
|
check "render: line < 1 falls back to header-only, does not raise"
|
|
(rendered = "pricing.wo:0:1: error WO-E001: out-of-range line stays non-fatal")
|
|
|
|
(* ---- collector: source-order accumulation, sort, dedup ------------ *)
|
|
|
|
let () =
|
|
let c = D.Collector.create () in
|
|
D.Collector.add c
|
|
(D.error ~code:"WO-E100" ~file:"b.wo" ~line:5 ~col:1 ~message:"m1" ());
|
|
D.Collector.add c
|
|
(D.error ~code:"WO-E100" ~file:"a.wo" ~line:10 ~col:2 ~message:"m2" ());
|
|
D.Collector.add c
|
|
(D.error ~code:"WO-E100" ~file:"a.wo" ~line:1 ~col:1 ~message:"m3" ());
|
|
let sites =
|
|
List.map
|
|
(fun (d : D.t) -> (d.site.file, d.site.line, d.site.col))
|
|
(D.Collector.diagnostics c)
|
|
in
|
|
check_eq "collector: output sorted by (file, line, col)"
|
|
~expected:[ ("a.wo", 1, 1); ("a.wo", 10, 2); ("b.wo", 5, 1) ]
|
|
~actual:sites (fun l ->
|
|
String.concat ", "
|
|
(List.map (fun (f, ln, cl) -> Printf.sprintf "%s:%d:%d" f ln cl) l))
|
|
|
|
let () =
|
|
let c = D.Collector.create () in
|
|
D.Collector.add c
|
|
(D.error ~code:"WO-E100" ~file:"a.wo" ~line:1 ~col:1 ~message:"first" ());
|
|
D.Collector.add c
|
|
(D.error ~code:"WO-E100" ~file:"a.wo" ~line:1 ~col:1 ~message:"duplicate" ());
|
|
D.Collector.add c
|
|
(D.error ~code:"WO-E200" ~file:"a.wo" ~line:1 ~col:1
|
|
~message:"different code, same site" ());
|
|
let ds = D.Collector.diagnostics c in
|
|
check_eq "collector: dedups identical (code, file, line, col)"
|
|
~expected:2 ~actual:(List.length ds) string_of_int;
|
|
(match ds with
|
|
| [ first; _second ] ->
|
|
check "collector: dedup keeps first-added occurrence"
|
|
(first.message = "first")
|
|
| _ -> check "collector: dedup result shape" false)
|
|
|
|
(* ---- exit-code decision -------------------------------------------- *)
|
|
|
|
let () =
|
|
let c = D.Collector.create () in
|
|
check_eq "exit code: empty collector is clean" ~expected:0
|
|
~actual:(D.Collector.exit_code c) string_of_int
|
|
|
|
let () =
|
|
let c = D.Collector.create () in
|
|
D.Collector.add c
|
|
(D.warning ~code:"WO-E900" ~file:"a.wo" ~line:1 ~col:1 ~message:"w" ());
|
|
check_eq "exit code: warnings only stays clean" ~expected:0
|
|
~actual:(D.Collector.exit_code c) string_of_int
|
|
|
|
let () =
|
|
let c = D.Collector.create () in
|
|
D.Collector.add c
|
|
(D.warning ~code:"WO-E900" ~file:"a.wo" ~line:1 ~col:1 ~message:"w" ());
|
|
D.Collector.add c
|
|
(D.error ~code:"WO-E301" ~file:"a.wo" ~line:2 ~col:1 ~message:"e" ());
|
|
check_eq "exit code: any error reported yields 1" ~expected:1
|
|
~actual:(D.Collector.exit_code c) string_of_int
|
|
|
|
let () =
|
|
(* Regression: exit_code must agree with the deduped, displayed
|
|
view, not the raw pre-dedup accumulation. A Warning added first
|
|
and an Error added second at the exact same (code, file, line,
|
|
col) dedup down to just the warning (first occurrence wins) — so
|
|
the report shows zero errors, and exit_code must be 0 to match,
|
|
not 1 from a dropped duplicate's severity. *)
|
|
let c = D.Collector.create () in
|
|
D.Collector.add c
|
|
(D.warning ~code:"WO-E301" ~file:"a.wo" ~line:1 ~col:1
|
|
~message:"warning first" ());
|
|
D.Collector.add c
|
|
(D.error ~code:"WO-E301" ~file:"a.wo" ~line:1 ~col:1
|
|
~message:"error second, same code+site" ());
|
|
let ds = D.Collector.diagnostics c in
|
|
check_eq "exit code: dedup survivor decides exit code, not raw items"
|
|
~expected:1 ~actual:(List.length ds) string_of_int;
|
|
(match ds with
|
|
| [ only ] ->
|
|
check "exit code: dedup keeps the first-added warning"
|
|
(only.severity = D.Warning && only.message = "warning first")
|
|
| _ -> check "exit code: dedup survivor shape" false);
|
|
check_eq "exit code: agrees with the deduped (warning-only) view"
|
|
~expected:0 ~actual:(D.Collector.exit_code c) string_of_int
|
|
|
|
(* ---- summary --------------------------------------------------------*)
|
|
|
|
let () =
|
|
Printf.printf "test_diag: %d checks, %d failures\n" !checks !failures;
|
|
if !failures > 0 then exit 1 else exit 0
|