v / vlib / v2 / markused / markused_diff_test.v
510 lines · 434 sloc · 12.44 KB · b384d9b10920270ef9a04eca8044157ae01aa4c1
Raw
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.
20module markused
21
22import os
23import time
24import v2.ast
25import v2.parser
26import v2.pref
27import v2.token
28import 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.
33struct ParsedFixture {
34 files []ast.File
35 flat ast.FlatAst
36 env &types.Environment
37}
38
39fn 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.
68fn 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.
76struct DiffReport {
77 only_in_a []string
78 only_in_b []string
79 equal bool
80}
81
82fn 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
104fn 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".
127fn run_legacy(p ParsedFixture) map[string]bool {
128 return mark_used(p.files, p.env)
129}
130
131fn 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
140const fixture_plain_fn = 'module main
141
142fn add(a int, b int) int {
143 return a + b
144}
145
146fn main() {
147 println(add(1, 2))
148}
149'
150
151const fixture_method_call = 'module main
152
153struct Counter {
154mut:
155 n int
156}
157
158fn (mut c Counter) inc() {
159 c.n++
160}
161
162fn (c Counter) value() int {
163 return c.n
164}
165
166fn main() {
167 mut c := Counter{}
168 c.inc()
169 println(c.value())
170}
171'
172
173const fixture_infix_operator = 'module main
174
175struct Vec {
176 x int
177 y int
178}
179
180fn (a Vec) + (b Vec) Vec {
181 return Vec{a.x + b.x, a.y + b.y}
182}
183
184fn main() {
185 v := Vec{1, 2} + Vec{3, 4}
186 println(v.x)
187}
188'
189
190const fixture_array_and_loop = 'module main
191
192fn sum(xs []int) int {
193 mut s := 0
194 for x in xs {
195 s += x
196 }
197 return s
198}
199
200fn main() {
201 xs := [1, 2, 3]
202 println(sum(xs))
203}
204'
205
206const fixture_match_expr = 'module main
207
208fn describe(n int) string {
209 return match n {
210 0 { "zero" }
211 1 { "one" }
212 else { "many" }
213 }
214}
215
216fn main() {
217 println(describe(1))
218}
219'
220
221const fixture_if_guard = 'module main
222
223fn maybe(n int) ?int {
224 if n > 0 {
225 return n
226 }
227 return none
228}
229
230fn main() {
231 if v := maybe(3) {
232 println(v)
233 }
234}
235'
236
237const fixture_global_init = 'module main
238
239__global g_counter = 0
240
241fn bump() {
242 g_counter++
243}
244
245fn main() {
246 bump()
247 println(g_counter)
248}
249'
250
251const fixture_global_init_calls_fn = 'module main
252
253struct Value {
254 n int
255}
256
257fn make_value() Value {
258 return Value{n: 1}
259}
260
261__global state = make_value()
262
263fn 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.
272const fixture_generic_fn = 'module main
273
274fn identity[T](x T) T {
275 return x
276}
277
278fn 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.
289const fixture_closure = 'module main
290
291fn make_counter() fn () int {
292 mut n := 0
293 return fn [mut n] () int {
294 n++
295 return n
296 }
297}
298
299fn 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.
308const fixture_sumtype_is_as = 'module main
309
310type Shape = Circle | Square
311
312struct Circle {
313 r f64
314}
315
316struct Square {
317 side f64
318}
319
320fn 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
327fn 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.
339const fixture_defer_and_map = 'module main
340
341fn 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
352fn 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 ${...}.
361const fixture_string_interp_and_array_slice = r'module main
362
363fn 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
372fn main() {
373 println(slice_sum([10, 20, 30, 40]))
374}
375'
376
377fn 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
403fn 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
408fn 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
413fn 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
418fn 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
423fn 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
428fn 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
433fn 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
438fn 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
443fn 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
448fn 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
453fn 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
458fn 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
463fn 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.
472fn 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.
483fn 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.
495fn 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