| 1 | // Copyright (c) 2026 Alexander Medvednikov. All rights reserved. |
| 2 | // Use of this source code is governed by an MIT license |
| 3 | // that can be found in the LICENSE file. |
| 4 | // vtest build: macos |
| 5 | // |
| 6 | // Differential test harness for the flat-native markused walker. |
| 7 | // |
| 8 | // Markused decides which functions/methods the backend must emit. A wrong |
| 9 | // answer is silent — too few keys → linker errors, too many keys → bloated |
| 10 | // binary and slow build. The cursor-based walker that will replace the |
| 11 | // recursive walker must produce a bit-identical key set on every program |
| 12 | // that exercises the dispatch surface. |
| 13 | // |
| 14 | // This file provides the comparison machinery and a growing fixture catalog. |
| 15 | // The fixtures are intentionally small, one per language construct, so that |
| 16 | // when the diff fails the failing fixture immediately localises the bug. |
| 17 | // Until mark_used_flat lands, the "differential" tests assert determinism |
| 18 | // (legacy == legacy across two runs) — they prove the harness itself works |
| 19 | // without depending on the new walker. |
| 20 | module markused |
| 21 | |
| 22 | import os |
| 23 | import time |
| 24 | import v2.ast |
| 25 | import v2.parser |
| 26 | import v2.pref |
| 27 | import v2.token |
| 28 | import v2.types |
| 29 | |
| 30 | // ParsedFixture bundles every artefact a markused walker (legacy or flat) |
| 31 | // needs to run on a source string. It is returned by parse_fixture so each |
| 32 | // test fixture writes the source once and gets everything else for free. |
| 33 | struct ParsedFixture { |
| 34 | files []ast.File |
| 35 | flat ast.FlatAst |
| 36 | env &types.Environment |
| 37 | } |
| 38 | |
| 39 | fn parse_fixture(src string) ParsedFixture { |
| 40 | tmp := '/tmp/v2_markused_diff_${os.getpid()}_${rand_suffix()}.v' |
| 41 | os.write_file(tmp, src) or { panic('write_file: ${err}') } |
| 42 | defer { |
| 43 | os.rm(tmp) or {} |
| 44 | } |
| 45 | prefs := &pref.Preferences{ |
| 46 | skip_builtin: true |
| 47 | } |
| 48 | mut fs := token.FileSet.new() |
| 49 | mut par := parser.Parser.new(prefs) |
| 50 | files := par.parse_files([tmp], mut fs) |
| 51 | flat := ast.flatten_files(files) |
| 52 | // Skip the type checker entirely — fixtures use println / etc. which |
| 53 | // would require pulling in builtin. The walker treats a near-empty env |
| 54 | // as "no type info available" and falls back to syntactic receiver |
| 55 | // candidates. Determinism is preserved (env in == env out for both runs) |
| 56 | // which is all the differential harness needs. |
| 57 | env := types.Environment.new() |
| 58 | return ParsedFixture{ |
| 59 | files: files |
| 60 | flat: flat |
| 61 | env: env |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // rand_suffix produces a short pseudo-unique tag so concurrent test runs |
| 66 | // don't trample each other's tempfiles. We avoid pulling in `rand` so the |
| 67 | // harness has no dependency on RNG seeding inside the test binary. |
| 68 | fn rand_suffix() string { |
| 69 | t := u64(time.now().unix_milli()) & 0xFFFFFFFF |
| 70 | return '${t:x}' |
| 71 | } |
| 72 | |
| 73 | // DiffReport captures the symmetric difference between two used_keys maps. |
| 74 | // It is a struct (not a bool) so failing tests can print exactly which keys |
| 75 | // drifted, which is essential for debugging walker bugs in 100+-key fixtures. |
| 76 | struct DiffReport { |
| 77 | only_in_a []string |
| 78 | only_in_b []string |
| 79 | equal bool |
| 80 | } |
| 81 | |
| 82 | fn diff_used_keys(a map[string]bool, b map[string]bool) DiffReport { |
| 83 | mut only_a := []string{} |
| 84 | mut only_b := []string{} |
| 85 | for k, _ in a { |
| 86 | if k !in b { |
| 87 | only_a << k |
| 88 | } |
| 89 | } |
| 90 | for k, _ in b { |
| 91 | if k !in a { |
| 92 | only_b << k |
| 93 | } |
| 94 | } |
| 95 | only_a.sort() |
| 96 | only_b.sort() |
| 97 | return DiffReport{ |
| 98 | only_in_a: only_a |
| 99 | only_in_b: only_b |
| 100 | equal: only_a.len == 0 && only_b.len == 0 |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | fn assert_used_keys_equal(label string, a map[string]bool, b map[string]bool) { |
| 105 | d := diff_used_keys(a, b) |
| 106 | if d.equal { |
| 107 | return |
| 108 | } |
| 109 | eprintln('[${label}] used_keys diverged:') |
| 110 | if d.only_in_a.len > 0 { |
| 111 | eprintln(' only in A (${d.only_in_a.len}):') |
| 112 | for k in d.only_in_a { |
| 113 | eprintln(' ${k}') |
| 114 | } |
| 115 | } |
| 116 | if d.only_in_b.len > 0 { |
| 117 | eprintln(' only in B (${d.only_in_b.len}):') |
| 118 | for k in d.only_in_b { |
| 119 | eprintln(' ${k}') |
| 120 | } |
| 121 | } |
| 122 | assert false, '${label}: used_keys differ (see above)' |
| 123 | } |
| 124 | |
| 125 | // run_legacy is a thin wrapper around mark_used so individual fixtures read |
| 126 | // as "legacy = run_legacy(p); flat = run_flat(p); assert_used_keys_equal". |
| 127 | fn run_legacy(p ParsedFixture) map[string]bool { |
| 128 | return mark_used(p.files, p.env) |
| 129 | } |
| 130 | |
| 131 | fn run_flat(p ParsedFixture) map[string]bool { |
| 132 | return mark_used_flat(&p.flat, p.env) |
| 133 | } |
| 134 | |
| 135 | // fixtures — each test exercises one slice of the walker dispatch surface. |
| 136 | // Adding a new fixture means: (1) write the smallest V program that hits the |
| 137 | // dispatch arm, (2) assert legacy determinism here, (3) when flat lands, |
| 138 | // flip to assert_used_keys_equal(name, run_legacy(p), run_flat(p)). |
| 139 | |
| 140 | const fixture_plain_fn = 'module main |
| 141 | |
| 142 | fn add(a int, b int) int { |
| 143 | return a + b |
| 144 | } |
| 145 | |
| 146 | fn main() { |
| 147 | println(add(1, 2)) |
| 148 | } |
| 149 | ' |
| 150 | |
| 151 | const fixture_method_call = 'module main |
| 152 | |
| 153 | struct Counter { |
| 154 | mut: |
| 155 | n int |
| 156 | } |
| 157 | |
| 158 | fn (mut c Counter) inc() { |
| 159 | c.n++ |
| 160 | } |
| 161 | |
| 162 | fn (c Counter) value() int { |
| 163 | return c.n |
| 164 | } |
| 165 | |
| 166 | fn main() { |
| 167 | mut c := Counter{} |
| 168 | c.inc() |
| 169 | println(c.value()) |
| 170 | } |
| 171 | ' |
| 172 | |
| 173 | const fixture_infix_operator = 'module main |
| 174 | |
| 175 | struct Vec { |
| 176 | x int |
| 177 | y int |
| 178 | } |
| 179 | |
| 180 | fn (a Vec) + (b Vec) Vec { |
| 181 | return Vec{a.x + b.x, a.y + b.y} |
| 182 | } |
| 183 | |
| 184 | fn main() { |
| 185 | v := Vec{1, 2} + Vec{3, 4} |
| 186 | println(v.x) |
| 187 | } |
| 188 | ' |
| 189 | |
| 190 | const fixture_array_and_loop = 'module main |
| 191 | |
| 192 | fn sum(xs []int) int { |
| 193 | mut s := 0 |
| 194 | for x in xs { |
| 195 | s += x |
| 196 | } |
| 197 | return s |
| 198 | } |
| 199 | |
| 200 | fn main() { |
| 201 | xs := [1, 2, 3] |
| 202 | println(sum(xs)) |
| 203 | } |
| 204 | ' |
| 205 | |
| 206 | const fixture_match_expr = 'module main |
| 207 | |
| 208 | fn describe(n int) string { |
| 209 | return match n { |
| 210 | 0 { "zero" } |
| 211 | 1 { "one" } |
| 212 | else { "many" } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | fn main() { |
| 217 | println(describe(1)) |
| 218 | } |
| 219 | ' |
| 220 | |
| 221 | const fixture_if_guard = 'module main |
| 222 | |
| 223 | fn maybe(n int) ?int { |
| 224 | if n > 0 { |
| 225 | return n |
| 226 | } |
| 227 | return none |
| 228 | } |
| 229 | |
| 230 | fn main() { |
| 231 | if v := maybe(3) { |
| 232 | println(v) |
| 233 | } |
| 234 | } |
| 235 | ' |
| 236 | |
| 237 | const fixture_global_init = 'module main |
| 238 | |
| 239 | __global g_counter = 0 |
| 240 | |
| 241 | fn bump() { |
| 242 | g_counter++ |
| 243 | } |
| 244 | |
| 245 | fn main() { |
| 246 | bump() |
| 247 | println(g_counter) |
| 248 | } |
| 249 | ' |
| 250 | |
| 251 | const fixture_global_init_calls_fn = 'module main |
| 252 | |
| 253 | struct Value { |
| 254 | n int |
| 255 | } |
| 256 | |
| 257 | fn make_value() Value { |
| 258 | return Value{n: 1} |
| 259 | } |
| 260 | |
| 261 | __global state = make_value() |
| 262 | |
| 263 | fn main() { |
| 264 | _ = state.n |
| 265 | } |
| 266 | ' |
| 267 | |
| 268 | // fixture_generic_fn exercises walker dispatch on generic function calls. |
| 269 | // The walker must mark both the generic base and any concrete bindings |
| 270 | // reachable through env.generic_types — the closest analogue of a real |
| 271 | // codebase\'s "[]T → []int" specialization path. |
| 272 | const fixture_generic_fn = 'module main |
| 273 | |
| 274 | fn identity[T](x T) T { |
| 275 | return x |
| 276 | } |
| 277 | |
| 278 | fn main() { |
| 279 | a := identity[int](42) |
| 280 | b := identity[string]("hi") |
| 281 | println(a) |
| 282 | println(b) |
| 283 | } |
| 284 | ' |
| 285 | |
| 286 | // fixture_closure exercises closure capture: the walker must not lose |
| 287 | // reachability when the called fn is the value of a local variable that |
| 288 | // itself was assigned a fn literal closing over outer state. |
| 289 | const fixture_closure = 'module main |
| 290 | |
| 291 | fn make_counter() fn () int { |
| 292 | mut n := 0 |
| 293 | return fn [mut n] () int { |
| 294 | n++ |
| 295 | return n |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | fn main() { |
| 300 | c := make_counter() |
| 301 | println(c()) |
| 302 | println(c()) |
| 303 | } |
| 304 | ' |
| 305 | |
| 306 | // fixture_sumtype_is_as covers the .is / as / smart-cast dispatch arms |
| 307 | // in walk_expr that resolve variant-specific method calls. |
| 308 | const fixture_sumtype_is_as = 'module main |
| 309 | |
| 310 | type Shape = Circle | Square |
| 311 | |
| 312 | struct Circle { |
| 313 | r f64 |
| 314 | } |
| 315 | |
| 316 | struct Square { |
| 317 | side f64 |
| 318 | } |
| 319 | |
| 320 | fn area(s Shape) f64 { |
| 321 | return match s { |
| 322 | Circle { 3.14 * s.r * s.r } |
| 323 | Square { s.side * s.side } |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | fn main() { |
| 328 | c := Shape(Circle{r: 2.0}) |
| 329 | if c is Circle { |
| 330 | println(c.r) |
| 331 | } |
| 332 | println(area(c)) |
| 333 | } |
| 334 | ' |
| 335 | |
| 336 | // fixture_defer_and_map exercises defer statement walking plus map literal |
| 337 | // init / index / "in" lookup — three distinct walk_stmt / walk_expr arms |
| 338 | // that share no helpers with the simpler fixtures. |
| 339 | const fixture_defer_and_map = 'module main |
| 340 | |
| 341 | fn lookup() int { |
| 342 | mut m := {"a": 1, "b": 2} |
| 343 | defer { |
| 344 | m["a"] = 9 |
| 345 | } |
| 346 | if "b" in m { |
| 347 | return m["b"] |
| 348 | } |
| 349 | return 0 |
| 350 | } |
| 351 | |
| 352 | fn main() { |
| 353 | println(lookup()) |
| 354 | } |
| 355 | ' |
| 356 | |
| 357 | // fixture_string_interp_and_array_slice covers string-interpolation |
| 358 | // expressions and array slicing — both have their own walk_expr arms |
| 359 | // with their own sub-expression edges. Uses a raw string (r'...') so |
| 360 | // the outer V parser does not try to interpolate the inner ${...}. |
| 361 | const fixture_string_interp_and_array_slice = r'module main |
| 362 | |
| 363 | fn slice_sum(xs []int) string { |
| 364 | tail := xs[1..] |
| 365 | mut s := 0 |
| 366 | for x in tail { |
| 367 | s += x |
| 368 | } |
| 369 | return "sum=${s} count=${tail.len}" |
| 370 | } |
| 371 | |
| 372 | fn main() { |
| 373 | println(slice_sum([10, 20, 30, 40])) |
| 374 | } |
| 375 | ' |
| 376 | |
| 377 | fn all_fixtures() []string { |
| 378 | return [ |
| 379 | fixture_plain_fn, |
| 380 | fixture_method_call, |
| 381 | fixture_infix_operator, |
| 382 | fixture_array_and_loop, |
| 383 | fixture_match_expr, |
| 384 | fixture_if_guard, |
| 385 | fixture_global_init, |
| 386 | fixture_global_init_calls_fn, |
| 387 | fixture_generic_fn, |
| 388 | fixture_closure, |
| 389 | fixture_sumtype_is_as, |
| 390 | fixture_defer_and_map, |
| 391 | fixture_string_interp_and_array_slice, |
| 392 | ] |
| 393 | } |
| 394 | |
| 395 | // --- flat-vs-legacy diff tests --- |
| 396 | // These are the real safety net for the markused migration. Each PR that |
| 397 | // rewrites part of mark_used_flat must keep every fixture green. While the |
| 398 | // shim still calls flat.to_files() + the legacy walker, both runs must |
| 399 | // trivially produce the same map — once the body diverges, these fixtures |
| 400 | // catch regressions immediately and the DiffReport pinpoints which key set |
| 401 | // drifted. |
| 402 | |
| 403 | fn test_flat_matches_legacy_plain_fn() { |
| 404 | p := parse_fixture(fixture_plain_fn) |
| 405 | assert_used_keys_equal('plain_fn', run_legacy(p), run_flat(p)) |
| 406 | } |
| 407 | |
| 408 | fn test_flat_matches_legacy_method_call() { |
| 409 | p := parse_fixture(fixture_method_call) |
| 410 | assert_used_keys_equal('method_call', run_legacy(p), run_flat(p)) |
| 411 | } |
| 412 | |
| 413 | fn test_flat_matches_legacy_infix_operator() { |
| 414 | p := parse_fixture(fixture_infix_operator) |
| 415 | assert_used_keys_equal('infix_operator', run_legacy(p), run_flat(p)) |
| 416 | } |
| 417 | |
| 418 | fn test_flat_matches_legacy_array_and_loop() { |
| 419 | p := parse_fixture(fixture_array_and_loop) |
| 420 | assert_used_keys_equal('array_and_loop', run_legacy(p), run_flat(p)) |
| 421 | } |
| 422 | |
| 423 | fn test_flat_matches_legacy_match_expr() { |
| 424 | p := parse_fixture(fixture_match_expr) |
| 425 | assert_used_keys_equal('match_expr', run_legacy(p), run_flat(p)) |
| 426 | } |
| 427 | |
| 428 | fn test_flat_matches_legacy_if_guard() { |
| 429 | p := parse_fixture(fixture_if_guard) |
| 430 | assert_used_keys_equal('if_guard', run_legacy(p), run_flat(p)) |
| 431 | } |
| 432 | |
| 433 | fn test_flat_matches_legacy_global_init() { |
| 434 | p := parse_fixture(fixture_global_init) |
| 435 | assert_used_keys_equal('global_init', run_legacy(p), run_flat(p)) |
| 436 | } |
| 437 | |
| 438 | fn test_flat_matches_legacy_global_init_calls_fn() { |
| 439 | p := parse_fixture(fixture_global_init_calls_fn) |
| 440 | assert_used_keys_equal('global_init_calls_fn', run_legacy(p), run_flat(p)) |
| 441 | } |
| 442 | |
| 443 | fn test_flat_matches_legacy_generic_fn() { |
| 444 | p := parse_fixture(fixture_generic_fn) |
| 445 | assert_used_keys_equal('generic_fn', run_legacy(p), run_flat(p)) |
| 446 | } |
| 447 | |
| 448 | fn test_flat_matches_legacy_closure() { |
| 449 | p := parse_fixture(fixture_closure) |
| 450 | assert_used_keys_equal('closure', run_legacy(p), run_flat(p)) |
| 451 | } |
| 452 | |
| 453 | fn test_flat_matches_legacy_sumtype_is_as() { |
| 454 | p := parse_fixture(fixture_sumtype_is_as) |
| 455 | assert_used_keys_equal('sumtype_is_as', run_legacy(p), run_flat(p)) |
| 456 | } |
| 457 | |
| 458 | fn test_flat_matches_legacy_defer_and_map() { |
| 459 | p := parse_fixture(fixture_defer_and_map) |
| 460 | assert_used_keys_equal('defer_and_map', run_legacy(p), run_flat(p)) |
| 461 | } |
| 462 | |
| 463 | fn test_flat_matches_legacy_string_interp_and_array_slice() { |
| 464 | p := parse_fixture(fixture_string_interp_and_array_slice) |
| 465 | assert_used_keys_equal('string_interp_and_array_slice', run_legacy(p), run_flat(p)) |
| 466 | } |
| 467 | |
| 468 | // test_flat_is_deterministic — two runs of mark_used_flat on the same fixture |
| 469 | // must produce the same map. Guards against accidental nondeterminism |
| 470 | // (e.g. iteration order over a map of types) creeping in as the body |
| 471 | // of the shim is progressively replaced. |
| 472 | fn test_flat_is_deterministic_all_fixtures() { |
| 473 | for i, src in all_fixtures() { |
| 474 | p := parse_fixture(src) |
| 475 | assert_used_keys_equal('fixture-${i}-determinism', run_flat(p), run_flat(p)) |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | // test_fixtures_produce_nonempty_results guards against silently broken |
| 480 | // harness setup: every fixture has at least main() reachable, so a zero-key |
| 481 | // result means our parse/typecheck/walk pipeline broke before we even got |
| 482 | // to compare anything. |
| 483 | fn test_fixtures_produce_nonempty_results() { |
| 484 | for i, src in all_fixtures() { |
| 485 | p := parse_fixture(src) |
| 486 | keys := run_legacy(p) |
| 487 | assert keys.len > 0, 'fixture #${i} produced empty used_keys — pipeline broken before walker' |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | // test_flat_ast_round_trips_every_fixture verifies the cursor-side machinery |
| 492 | // works end-to-end on the fixture surface: every fixture parses, flattens, |
| 493 | // and is reachable via the cursor API from each file root. This is what |
| 494 | // the future flat walker will consume, so it must not blow up here. |
| 495 | fn test_flat_ast_round_trips_every_fixture() { |
| 496 | for i, src in all_fixtures() { |
| 497 | p := parse_fixture(src) |
| 498 | assert p.flat.files.len > 0, 'fixture #${i}: flat has no files' |
| 499 | for fi in 0 .. p.flat.files.len { |
| 500 | fc := p.flat.file_cursor(fi) |
| 501 | assert fc.root().is_valid(), 'fixture #${i} file ${fi}: invalid root' |
| 502 | assert fc.root().kind() == .file, 'fixture #${i} file ${fi}: root not .file' |
| 503 | // Touch each top-level list so an aux_list miswiring trips here |
| 504 | // rather than deep inside the future walker port. |
| 505 | _ = fc.attrs().len() |
| 506 | _ = fc.imports().len() |
| 507 | _ = fc.stmts().len() |
| 508 | } |
| 509 | } |
| 510 | } |
| 511 | |