| 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 the Cursor API live alongside the implementation since they |
| 7 | // operate on hand-built FlatAst fixtures — building them via the parser |
| 8 | // would introduce a circular dep (parser depends on ast). The fixtures |
| 9 | // mirror the shape that parse_file emits for a few representative inputs. |
| 10 | |
| 11 | // build_minimal_file constructs a FlatAst with one file containing: |
| 12 | // module foo |
| 13 | // fn add(a int, b int) int { return a + b } |
| 14 | // using the FlatBuilder API directly. We then exercise Cursor over it. |
| 15 | fn build_minimal_file() FlatAst { |
| 16 | mut b := new_flat_builder() |
| 17 | src := File{ |
| 18 | name: 'inline_minimal.v' |
| 19 | mod: 'foo' |
| 20 | stmts: [ |
| 21 | Stmt(ModuleStmt{ |
| 22 | name: 'foo' |
| 23 | }), |
| 24 | Stmt(FnDecl{ |
| 25 | name: 'add' |
| 26 | typ: FnType{ |
| 27 | params: [ |
| 28 | Parameter{ |
| 29 | name: 'a' |
| 30 | typ: Expr(Ident{ |
| 31 | name: 'int' |
| 32 | }) |
| 33 | }, |
| 34 | Parameter{ |
| 35 | name: 'b' |
| 36 | typ: Expr(Ident{ |
| 37 | name: 'int' |
| 38 | }) |
| 39 | }, |
| 40 | ] |
| 41 | return_type: Expr(Ident{ |
| 42 | name: 'int' |
| 43 | }) |
| 44 | } |
| 45 | stmts: [ |
| 46 | Stmt(ReturnStmt{ |
| 47 | exprs: [ |
| 48 | Expr(InfixExpr{ |
| 49 | op: .plus |
| 50 | lhs: Expr(Ident{ |
| 51 | name: 'a' |
| 52 | }) |
| 53 | rhs: Expr(Ident{ |
| 54 | name: 'b' |
| 55 | }) |
| 56 | }), |
| 57 | ] |
| 58 | }), |
| 59 | ] |
| 60 | }), |
| 61 | ] |
| 62 | } |
| 63 | b.append_file(src) |
| 64 | return b.flat |
| 65 | } |
| 66 | |
| 67 | fn test_file_cursor_basics() { |
| 68 | flat := build_minimal_file() |
| 69 | assert flat.files.len == 1 |
| 70 | fc := flat.file_cursor(0) |
| 71 | assert fc.name() == 'inline_minimal.v' |
| 72 | assert fc.mod() == 'foo' |
| 73 | root := fc.root() |
| 74 | assert root.is_valid() |
| 75 | assert root.kind() == .file |
| 76 | } |
| 77 | |
| 78 | fn test_file_cursor_stmts_iteration() { |
| 79 | flat := build_minimal_file() |
| 80 | fc := flat.file_cursor(0) |
| 81 | stmts := fc.stmts() |
| 82 | // module decl + fn decl |
| 83 | assert stmts.len() == 2 |
| 84 | assert stmts.at(0).kind() == .stmt_module |
| 85 | assert stmts.at(0).name() == 'foo' |
| 86 | assert stmts.at(1).kind() == .stmt_fn_decl |
| 87 | assert stmts.at(1).name() == 'add' |
| 88 | } |
| 89 | |
| 90 | fn test_fn_decl_flags_and_body() { |
| 91 | flat := build_minimal_file() |
| 92 | fc := flat.file_cursor(0) |
| 93 | fn_cur := fc.stmts().at(1) |
| 94 | assert fn_cur.kind() == .stmt_fn_decl |
| 95 | // is_method/is_public/is_static all false for plain `fn add(...)` |
| 96 | assert !fn_cur.flag(flag_is_method) |
| 97 | assert !fn_cur.flag(flag_is_public) |
| 98 | assert !fn_cur.flag(flag_is_static) |
| 99 | // edge layout per read_stmt: 0=receiver, 1=typ, 2=attrs, 3=stmts(aux_list) |
| 100 | stmts := fn_cur.list_at(3) |
| 101 | assert stmts.len() == 1 |
| 102 | ret := stmts.at(0) |
| 103 | assert ret.kind() == .stmt_return |
| 104 | } |
| 105 | |
| 106 | fn test_fn_decl_signature_from_cursor() { |
| 107 | flat := build_minimal_file() |
| 108 | fn_cur := flat.file_cursor(0).stmts().at(1) |
| 109 | decl := fn_cur.fn_decl_signature() |
| 110 | assert decl.name == 'add' |
| 111 | assert !decl.is_method |
| 112 | assert decl.stmts.len == 0 |
| 113 | assert decl.typ.params.len == 2 |
| 114 | assert decl.typ.params[0].name == 'a' |
| 115 | assert decl.typ.params[0].typ is Ident |
| 116 | assert (decl.typ.params[0].typ as Ident).name == 'int' |
| 117 | assert decl.typ.params[1].name == 'b' |
| 118 | assert decl.typ.return_type is Ident |
| 119 | assert (decl.typ.return_type as Ident).name == 'int' |
| 120 | } |
| 121 | |
| 122 | fn test_fn_decl_from_cursor_reads_body() { |
| 123 | flat := build_minimal_file() |
| 124 | fn_cur := flat.file_cursor(0).stmts().at(1) |
| 125 | decl := fn_cur.fn_decl() |
| 126 | assert decl.name == 'add' |
| 127 | assert decl.stmts.len == 1 |
| 128 | assert decl.stmts[0] is ReturnStmt |
| 129 | } |
| 130 | |
| 131 | fn test_stmt_and_expr_escape_hatches_from_cursor() { |
| 132 | flat := build_minimal_file() |
| 133 | ret_cur := flat.file_cursor(0).stmts().at(1).list_at(3).at(0) |
| 134 | ret_stmt := ret_cur.stmt() |
| 135 | assert ret_stmt is ReturnStmt |
| 136 | infix_expr := ret_cur.edge(0).expr() |
| 137 | assert infix_expr is InfixExpr |
| 138 | assert (infix_expr as InfixExpr).op == .plus |
| 139 | } |
| 140 | |
| 141 | fn test_return_stmt_exprs_via_edges() { |
| 142 | flat := build_minimal_file() |
| 143 | fc := flat.file_cursor(0) |
| 144 | ret := fc.stmts().at(1).list_at(3).at(0) |
| 145 | assert ret.kind() == .stmt_return |
| 146 | // ReturnStmt stores its exprs as direct edges of the parent (not aux_list) |
| 147 | assert ret.edge_count() == 1 |
| 148 | infix := ret.edge(0) |
| 149 | assert infix.kind() == .expr_infix |
| 150 | // InfixExpr edges: 0=lhs, 1=rhs (per read_expr) |
| 151 | assert infix.edge_count() == 2 |
| 152 | lhs := infix.edge(0) |
| 153 | rhs := infix.edge(1) |
| 154 | assert lhs.kind() == .expr_ident |
| 155 | assert lhs.name() == 'a' |
| 156 | assert rhs.kind() == .expr_ident |
| 157 | assert rhs.name() == 'b' |
| 158 | } |
| 159 | |
| 160 | fn test_invalid_cursor_sentinels() { |
| 161 | flat := build_minimal_file() |
| 162 | fc := flat.file_cursor(0) |
| 163 | bogus := fc.stmts().at(99) // out of range |
| 164 | assert !bogus.is_valid() |
| 165 | bogus_edge := fc.root().edge(99) // out of range |
| 166 | assert !bogus_edge.is_valid() |
| 167 | empty_list := fc.root().list_at(99) |
| 168 | assert empty_list.len() == 0 |
| 169 | } |
| 170 | |
| 171 | fn test_type_expr_from_cursor_reads_signature_types() { |
| 172 | mut b := new_flat_builder() |
| 173 | src := File{ |
| 174 | name: 'inline_type_expr.v' |
| 175 | mod: 'foo' |
| 176 | stmts: [ |
| 177 | Stmt(ModuleStmt{ |
| 178 | name: 'foo' |
| 179 | }), |
| 180 | Stmt(FnDecl{ |
| 181 | name: 'use_box' |
| 182 | typ: FnType{ |
| 183 | params: [ |
| 184 | Parameter{ |
| 185 | name: 'items' |
| 186 | typ: Expr(Type(ArrayType{ |
| 187 | elem_type: Expr(GenericArgs{ |
| 188 | lhs: Expr(Ident{ |
| 189 | name: 'Box' |
| 190 | }) |
| 191 | args: [ |
| 192 | Expr(Ident{ |
| 193 | name: 'int' |
| 194 | }), |
| 195 | ] |
| 196 | }) |
| 197 | })) |
| 198 | }, |
| 199 | ] |
| 200 | return_type: Expr(Type(OptionType{ |
| 201 | base_type: Expr(Ident{ |
| 202 | name: 'string' |
| 203 | }) |
| 204 | })) |
| 205 | } |
| 206 | }), |
| 207 | ] |
| 208 | } |
| 209 | b.append_file(src) |
| 210 | fn_cur := b.flat.file_cursor(0).stmts().at(1) |
| 211 | fn_type := fn_cur.edge(1) |
| 212 | params := fn_type.list_at(1) |
| 213 | param_typ := params.at(0).edge(0).type_expr() |
| 214 | assert param_typ is Type |
| 215 | array_typ := param_typ as Type |
| 216 | assert array_typ is ArrayType |
| 217 | array_expr := array_typ as ArrayType |
| 218 | assert array_expr.elem_type is GenericArgs |
| 219 | generic := array_expr.elem_type as GenericArgs |
| 220 | assert generic.lhs is Ident |
| 221 | assert (generic.lhs as Ident).name == 'Box' |
| 222 | assert generic.args.len == 1 |
| 223 | assert generic.args[0] is Ident |
| 224 | assert (generic.args[0] as Ident).name == 'int' |
| 225 | return_typ := fn_type.edge(2).type_expr() |
| 226 | assert return_typ is Type |
| 227 | option_typ := return_typ as Type |
| 228 | assert option_typ is OptionType |
| 229 | option_expr := option_typ as OptionType |
| 230 | assert option_expr.base_type is Ident |
| 231 | assert (option_expr.base_type as Ident).name == 'string' |
| 232 | } |
| 233 | |
| 234 | fn test_decl_readers_from_cursor() { |
| 235 | mut b := new_flat_builder() |
| 236 | src := File{ |
| 237 | name: 'inline_decl_readers.v' |
| 238 | mod: 'foo' |
| 239 | stmts: [ |
| 240 | Stmt(ModuleStmt{ |
| 241 | name: 'foo' |
| 242 | }), |
| 243 | Stmt(ConstDecl{ |
| 244 | fields: [ |
| 245 | FieldInit{ |
| 246 | name: 'answer' |
| 247 | value: Expr(BasicLiteral{ |
| 248 | kind: .number |
| 249 | value: '42' |
| 250 | }) |
| 251 | }, |
| 252 | ] |
| 253 | }), |
| 254 | Stmt(EnumDecl{ |
| 255 | name: 'Mode' |
| 256 | attributes: [ |
| 257 | Attribute{ |
| 258 | name: 'flag' |
| 259 | }, |
| 260 | ] |
| 261 | fields: [ |
| 262 | FieldDecl{ |
| 263 | name: 'read' |
| 264 | value: Expr(BasicLiteral{ |
| 265 | kind: .number |
| 266 | value: '1' |
| 267 | }) |
| 268 | }, |
| 269 | ] |
| 270 | }), |
| 271 | Stmt(StructDecl{ |
| 272 | name: 'Person' |
| 273 | attributes: [ |
| 274 | Attribute{ |
| 275 | name: 'heap' |
| 276 | }, |
| 277 | ] |
| 278 | fields: [ |
| 279 | FieldDecl{ |
| 280 | name: 'name' |
| 281 | typ: Expr(Ident{ |
| 282 | name: 'string' |
| 283 | }) |
| 284 | value: Expr(StringLiteral{ |
| 285 | value: 'unknown' |
| 286 | }) |
| 287 | attributes: [ |
| 288 | Attribute{ |
| 289 | name: 'json' |
| 290 | value: Expr(StringLiteral{ |
| 291 | value: 'full_name' |
| 292 | }) |
| 293 | }, |
| 294 | ] |
| 295 | is_public: true |
| 296 | }, |
| 297 | ] |
| 298 | }), |
| 299 | ] |
| 300 | } |
| 301 | b.append_file(src) |
| 302 | stmts := b.flat.file_cursor(0).stmts() |
| 303 | const_decl := stmts.at(1).const_decl() |
| 304 | assert const_decl.fields.len == 1 |
| 305 | assert const_decl.fields[0].name == 'answer' |
| 306 | assert const_decl.fields[0].value is BasicLiteral |
| 307 | assert (const_decl.fields[0].value as BasicLiteral).value == '42' |
| 308 | enum_decl := stmts.at(2).enum_decl(true) |
| 309 | assert enum_decl.name == 'Mode' |
| 310 | assert enum_decl.attributes.has('flag') |
| 311 | assert enum_decl.fields.len == 1 |
| 312 | assert enum_decl.fields[0].value is BasicLiteral |
| 313 | assert (enum_decl.fields[0].value as BasicLiteral).value == '1' |
| 314 | struct_decl := stmts.at(3).struct_decl() |
| 315 | assert struct_decl.name == 'Person' |
| 316 | assert struct_decl.attributes.has('heap') |
| 317 | assert struct_decl.fields.len == 1 |
| 318 | assert struct_decl.fields[0].name == 'name' |
| 319 | assert struct_decl.fields[0].is_public |
| 320 | assert struct_decl.fields[0].typ is Ident |
| 321 | assert (struct_decl.fields[0].typ as Ident).name == 'string' |
| 322 | assert struct_decl.fields[0].value is StringLiteral |
| 323 | assert (struct_decl.fields[0].value as StringLiteral).value == 'unknown' |
| 324 | assert struct_decl.fields[0].attributes.len == 1 |
| 325 | assert struct_decl.fields[0].attributes[0].name == 'json' |
| 326 | assert struct_decl.fields[0].attributes[0].value is StringLiteral |
| 327 | assert (struct_decl.fields[0].attributes[0].value as StringLiteral).value == 'full_name' |
| 328 | } |
| 329 | |
| 330 | // count_unique_kinds_via_cursor walks the whole graph rooted at fc through |
| 331 | // edges with dedup (the FlatBuilder shares aux_list nodes for empty lists, |
| 332 | // so a naive DFS revisits them). Used to cross-check that Cursor traversal |
| 333 | // reaches every reachable node in the FlatAst. |
| 334 | fn count_unique_kinds_via_cursor(fc FileCursor) (map[string]int, int) { |
| 335 | flat := fc.flat |
| 336 | mut seen := []bool{len: flat.nodes.len} |
| 337 | mut out := map[string]int{} |
| 338 | mut stack := []Cursor{cap: 64} |
| 339 | mut unique := 0 |
| 340 | stack << fc.root() |
| 341 | for stack.len > 0 { |
| 342 | c := stack.pop() |
| 343 | if !c.is_valid() || seen[c.id] { |
| 344 | continue |
| 345 | } |
| 346 | seen[c.id] = true |
| 347 | unique++ |
| 348 | out[c.kind().str()]++ |
| 349 | for i in 0 .. c.edge_count() { |
| 350 | stack << c.edge(i) |
| 351 | } |
| 352 | } |
| 353 | return out, unique |
| 354 | } |
| 355 | |
| 356 | fn test_cursor_walk_matches_reachable_count() { |
| 357 | flat := build_minimal_file() |
| 358 | _, unique := count_unique_kinds_via_cursor(flat.file_cursor(0)) |
| 359 | // Walking from the single file root via Cursor must reach the same |
| 360 | // set of unique nodes as the reference reachability walker. |
| 361 | assert unique == flat.count_reachable_nodes() |
| 362 | } |
| 363 | |