| 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 | module ast |
| 5 | |
| 6 | // Tests for `FlatBuilder.append_flat` — the flat-to-flat merge primitive behind |
| 7 | // the flat parallel transform. It concatenates a whole `src` FlatAst into a |
| 8 | // builder, relocating node ids / edge targets and re-interning strings so the |
| 9 | // merged result decodes identically to `src` standalone. The key correctness |
| 10 | // risks: (1) node-id / edge relocation, (2) name_id string remap across a |
| 11 | // DIFFERENT intern order in the destination, (3) re-interning the three kinds |
| 12 | // whose `extra` slot holds a string id (file mod, stmt_directive value, |
| 13 | // stmt_import alias) while leaving packed-int extras (counts/flags) untouched. |
| 14 | |
| 15 | fn make_ident_stmt(name string) Stmt { |
| 16 | return Stmt(ExprStmt{ |
| 17 | expr: Expr(Ident{ |
| 18 | name: name |
| 19 | }) |
| 20 | }) |
| 21 | } |
| 22 | |
| 23 | fn make_for_with_idents(names []string) Stmt { |
| 24 | mut body := []Stmt{} |
| 25 | for n in names { |
| 26 | body << make_ident_stmt(n) |
| 27 | } |
| 28 | return Stmt(ForStmt{ |
| 29 | init: Stmt(EmptyStmt{}) |
| 30 | cond: Expr(BasicLiteral{ |
| 31 | kind: .number |
| 32 | value: '1' |
| 33 | }) |
| 34 | stmts: body |
| 35 | }) |
| 36 | } |
| 37 | |
| 38 | // build a small flat with a file root whose stmt list is `stmts`. |
| 39 | fn build_named_flat(file_name string, mod string, stmts []Stmt) (FlatBuilder, FlatNodeId) { |
| 40 | mut b := new_flat_builder() |
| 41 | mut ids := []FlatNodeId{} |
| 42 | for s in stmts { |
| 43 | ids << b.emit_stmt(s) |
| 44 | } |
| 45 | file_id := b.append_file_with_stmt_ids(File{ |
| 46 | name: file_name |
| 47 | mod: mod |
| 48 | }, ids) |
| 49 | return b, file_id |
| 50 | } |
| 51 | |
| 52 | // Seed a destination builder with DIFFERENT strings so the src->dst string |
| 53 | // remap is non-trivial (src ids must not coincide with dst ids). |
| 54 | fn seeded_dst() FlatBuilder { |
| 55 | mut dst := new_flat_builder() |
| 56 | dst.intern('zzz_pre_a') |
| 57 | dst.intern('zzz_pre_b') |
| 58 | dst.intern('zzz_pre_c') |
| 59 | dst.emit_stmt(make_ident_stmt('zzz_pre_d')) |
| 60 | return dst |
| 61 | } |
| 62 | |
| 63 | fn test_append_flat_preserves_subtree_structure_and_name_strings() { |
| 64 | mut a, a_file := build_named_flat('a.v', 'main', [ |
| 65 | make_for_with_idents(['alpha', 'beta']), |
| 66 | make_ident_stmt('gamma'), |
| 67 | ]) |
| 68 | // edge 2 of a .file node is the stmts list; rooting the signature there |
| 69 | // avoids the intern-order-dependent .file `extra` slot. |
| 70 | a_stmts := a.flat.child_at(a_file, 2) |
| 71 | a_sig := a.flat.subtree_signature(a_stmts) |
| 72 | |
| 73 | mut dst := seeded_dst() |
| 74 | pre_nodes := dst.flat.nodes.len |
| 75 | pre_edges := dst.flat.edges.len |
| 76 | |
| 77 | off := dst.append_flat(a.flat) |
| 78 | |
| 79 | assert off == pre_nodes |
| 80 | assert dst.flat.nodes.len == pre_nodes + a.flat.nodes.len |
| 81 | assert dst.flat.edges.len == pre_edges + a.flat.edges.len |
| 82 | // Structure + every interned ident/value survives the id+string relocation. |
| 83 | assert dst.flat.subtree_signature(a_stmts + off) == a_sig |
| 84 | } |
| 85 | |
| 86 | fn test_append_flat_two_sources_stay_independent() { |
| 87 | mut a, a_file := build_named_flat('a.v', 'main', [ |
| 88 | make_for_with_idents(['alpha', 'beta']), |
| 89 | ]) |
| 90 | mut b, b_file := build_named_flat('b.v', 'other', [make_ident_stmt('delta'), |
| 91 | make_ident_stmt('epsilon')]) |
| 92 | a_sig := a.flat.subtree_signature(a.flat.child_at(a_file, 2)) |
| 93 | b_sig := b.flat.subtree_signature(b.flat.child_at(b_file, 2)) |
| 94 | |
| 95 | mut dst := seeded_dst() |
| 96 | off_a := dst.append_flat(a.flat) |
| 97 | off_b := dst.append_flat(b.flat) |
| 98 | |
| 99 | assert dst.flat.files.len == 2 |
| 100 | assert dst.flat.subtree_signature(a.flat.child_at(a_file, 2) + off_a) == a_sig |
| 101 | assert dst.flat.subtree_signature(b.flat.child_at(b_file, 2) + off_b) == b_sig |
| 102 | // b's offset accounts for everything already present (seed + a). |
| 103 | assert off_b == off_a + a.flat.nodes.len |
| 104 | } |
| 105 | |
| 106 | fn test_append_flat_reinterns_string_extra_kinds() { |
| 107 | mut a := new_flat_builder() |
| 108 | imp_id := a.emit_stmt(Stmt(ImportStmt{ |
| 109 | name: 'os' |
| 110 | alias: 'myos' |
| 111 | is_aliased: true |
| 112 | })) |
| 113 | dir_id := a.emit_stmt(Stmt(Directive{ |
| 114 | name: 'flag' |
| 115 | value: '-lpthread' |
| 116 | })) |
| 117 | file_id := a.append_file_with_stmt_ids(File{ |
| 118 | name: 'a.v' |
| 119 | mod: 'mymod' |
| 120 | }, [imp_id, dir_id]) |
| 121 | |
| 122 | mut dst := new_flat_builder() |
| 123 | // Shift the intern table so src string ids differ from dst string ids. |
| 124 | dst.intern('os') |
| 125 | dst.intern('zzz') |
| 126 | dst.intern('flag_other') |
| 127 | dst.intern('unrelated_mod') |
| 128 | |
| 129 | off := dst.append_flat(a.flat) |
| 130 | |
| 131 | // stmt_import.alias lives in `extra` — must resolve to the merged table. |
| 132 | merged_imp := Cursor{ |
| 133 | flat: &dst.flat |
| 134 | id: imp_id + off |
| 135 | }.stmt() as ImportStmt |
| 136 | assert merged_imp.name == 'os' |
| 137 | assert merged_imp.alias == 'myos' |
| 138 | assert merged_imp.is_aliased |
| 139 | |
| 140 | // stmt_directive.value lives in `extra`. |
| 141 | merged_dir := Cursor{ |
| 142 | flat: &dst.flat |
| 143 | id: dir_id + off |
| 144 | }.stmt() as Directive |
| 145 | assert merged_dir.name == 'flag' |
| 146 | assert merged_dir.value == '-lpthread' |
| 147 | |
| 148 | // file mod is carried both in FlatFile.mod_idx (decoder reads this) and in |
| 149 | // the .file node's `extra` (must also be re-interned for consistency). |
| 150 | mf := dst.flat.files[dst.flat.files.len - 1] |
| 151 | assert dst.flat.string_at(mf.name_idx) == 'a.v' |
| 152 | assert dst.flat.string_at(mf.mod_idx) == 'mymod' |
| 153 | assert dst.flat.string_at(dst.flat.nodes[file_id + off].extra) == 'mymod' |
| 154 | } |
| 155 | |
| 156 | fn test_append_flat_empty_source_is_noop() { |
| 157 | mut dst := seeded_dst() |
| 158 | pre_nodes := dst.flat.nodes.len |
| 159 | pre_edges := dst.flat.edges.len |
| 160 | pre_files := dst.flat.files.len |
| 161 | empty := FlatAst{} |
| 162 | off := dst.append_flat(&empty) |
| 163 | assert off == pre_nodes |
| 164 | assert dst.flat.nodes.len == pre_nodes |
| 165 | assert dst.flat.edges.len == pre_edges |
| 166 | assert dst.flat.files.len == pre_files |
| 167 | } |
| 168 | |
| 169 | fn test_copy_subtree_from_preserves_stmt_shape_and_strings() { |
| 170 | mut src := new_flat_builder() |
| 171 | imp_id := src.emit_stmt(Stmt(ImportStmt{ |
| 172 | name: 'os' |
| 173 | alias: 'myos' |
| 174 | is_aliased: true |
| 175 | })) |
| 176 | dir_id := src.emit_stmt(Stmt(Directive{ |
| 177 | name: 'flag' |
| 178 | value: '-lm' |
| 179 | })) |
| 180 | for_id := src.emit_stmt(make_for_with_idents(['alpha', 'beta'])) |
| 181 | |
| 182 | mut dst := seeded_dst() |
| 183 | copied_imp := dst.copy_subtree_from(&src.flat, imp_id) |
| 184 | copied_dir := dst.copy_subtree_from(&src.flat, dir_id) |
| 185 | copied_for := dst.copy_subtree_from(&src.flat, for_id) |
| 186 | |
| 187 | assert dst.flat.subtree_signature(copied_for) == src.flat.subtree_signature(for_id) |
| 188 | |
| 189 | merged_imp := Cursor{ |
| 190 | flat: &dst.flat |
| 191 | id: copied_imp |
| 192 | }.stmt() as ImportStmt |
| 193 | assert merged_imp.name == 'os' |
| 194 | assert merged_imp.alias == 'myos' |
| 195 | assert merged_imp.is_aliased |
| 196 | |
| 197 | merged_dir := Cursor{ |
| 198 | flat: &dst.flat |
| 199 | id: copied_dir |
| 200 | }.stmt() as Directive |
| 201 | assert merged_dir.name == 'flag' |
| 202 | assert merged_dir.value == '-lm' |
| 203 | } |
| 204 | |
| 205 | fn test_copy_subtree_from_invalid_root_returns_invalid() { |
| 206 | mut src := new_flat_builder() |
| 207 | mut dst := new_flat_builder() |
| 208 | assert dst.copy_subtree_from(&src.flat, -1) == invalid_flat_node_id |
| 209 | assert dst.copy_subtree_from(&src.flat, 100) == invalid_flat_node_id |
| 210 | } |
| 211 | |