From ecbab867234b5fdf55e9d1c2a324ef68698c4b9f Mon Sep 17 00:00:00 2001 From: Gadi Evron Date: Mon, 8 Jun 2026 23:34:04 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(parsers/zig):=20u13=20call=5Fgraph=5Fbu?= =?UTF-8?q?ilder=20=E2=80=94=20local-type/builtin/const-alias=20(BUG-NEW?= =?UTF-8?q?=203,17,41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-only finder-fixes-54 (base master 368b559). TDD; 5 tests in the new tests/parsers/zig/test_call_graph_builder_u*.py. Judge (vs prepared recommendation, independent re-derivation from raw): AGREE / SHIP-AS-IS. CM-B builtin pre-check scoped SAME-FILE (judge-confirmed: no global cross-file fallback; genuine-builtin non-link pinned by a negative test). Combined parser suite: 70 passed, 10 skipped. Local-only; not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 4294ffe24e0814bc2668f1f6c46c3874de56b01e) --- .../parsers/zig/call_graph_builder.py | 115 +++++++++++++++- .../zig/test_call_graph_builder_u13.py | 123 ++++++++++++++++++ 2 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py diff --git a/libs/openant-core/parsers/zig/call_graph_builder.py b/libs/openant-core/parsers/zig/call_graph_builder.py index fbd6fd59..54af692e 100644 --- a/libs/openant-core/parsers/zig/call_graph_builder.py +++ b/libs/openant-core/parsers/zig/call_graph_builder.py @@ -155,18 +155,22 @@ def build(self) -> Dict[str, Any]: # Build an index of function names to IDs for resolution name_to_ids = self._build_name_index() + # Build per-file simple const fn-alias bindings (`const f = handler;`) + # so that a later `f()` resolves to `handler`. + alias_to_target = self._build_alias_index(name_to_ids) + # For each function, find calls in its body for func_id, func_info in self.functions.items(): code = func_info.get("code", "") file_path = func_info.get("file_path", "") # Parse the function code to find call sites - calls = self._find_calls_in_code(code) + calls = self._find_calls_in_code(code, file_path) # Resolve each call to a function ID for call_name in calls: resolved_ids = self._resolve_call( - call_name, file_path, name_to_ids + call_name, file_path, name_to_ids, alias_to_target ) for resolved_id in resolved_ids: if resolved_id != func_id: # No self-calls @@ -219,7 +223,59 @@ def _build_name_index(self) -> Dict[str, List[str]]: return name_to_ids - def _find_calls_in_code(self, code: str) -> Set[str]: + def _build_alias_index( + self, name_to_ids: Dict[str, List[str]] + ) -> Dict[str, Dict[str, str]]: + """Index simple const fn-aliases per file: `const f = handler;` -> {f: handler}. + + Only bindings whose right-hand side is a bare identifier naming a known + function are tracked (a genuine fn alias), so arbitrary const dataflow + (`const x = 1;`) is ignored. Scoped per file to avoid cross-file leaks. + """ + alias_to_target: Dict[str, Dict[str, str]] = defaultdict(dict) + + for func_info in self.functions.values(): + file_path = func_info.get("file_path", "") + code = func_info.get("code", "") + if not code: + continue + try: + tree = self.parser.parse(code.encode("utf-8")) + except Exception: + continue + self._collect_aliases_from_node( + tree.root_node, + code.encode("utf-8"), + name_to_ids, + alias_to_target[file_path], + ) + + return alias_to_target + + def _collect_aliases_from_node( + self, + node: Node, + source: bytes, + name_to_ids: Dict[str, List[str]], + aliases: Dict[str, str], + ) -> None: + """Collect `const = ;` bindings from a parse tree.""" + if node.type in ("variable_declaration", "VarDecl"): + ident_children = [ + c for c in node.children if c.type in ("identifier", "IDENTIFIER") + ] + # A simple alias is exactly: const = ; + if len(ident_children) == 2: + alias_name = self._get_node_text(ident_children[0], source) + target_name = self._get_node_text(ident_children[1], source) + # Only record when the target is a known function name. + if alias_name and target_name in name_to_ids: + aliases[alias_name] = target_name + + for child in node.children: + self._collect_aliases_from_node(child, source, name_to_ids, aliases) + + def _find_calls_in_code(self, code: str, caller_file: str = "") -> Set[str]: """Find all function calls in a code snippet.""" calls = set() @@ -230,11 +286,32 @@ def _find_calls_in_code(self, code: str) -> Set[str]: # Fallback to regex-based extraction calls = self._find_calls_with_regex(code) - # Filter out builtins - calls = {c for c in calls if c not in self.ZIG_BUILTINS and not c.startswith("@")} + # Filter out builtins, but NEVER filter a name that a same-file user + # function actually defines. A user fn whose name collides with a + # ZIG_BUILTINS entry (e.g. `expect`) must keep its edge. Scope the + # shadow check to the caller's own file so a builtin call is not + # spuriously linked to an unrelated same-named user fn elsewhere. + shadowing = self._same_file_function_names(caller_file) + calls = { + c + for c in calls + if c in shadowing or (c not in self.ZIG_BUILTINS and not c.startswith("@")) + } return calls + def _same_file_function_names(self, caller_file: str) -> Set[str]: + """Names of user functions defined in `caller_file` (same-file scope).""" + if not caller_file: + return set() + names: Set[str] = set() + for func_info in self.functions.values(): + if func_info.get("file_path") == caller_file: + name = func_info.get("name", "") + if name: + names.add(name) + return names + def _extract_calls_from_node( self, node: Node, source: bytes, calls: Set[str] ) -> None: @@ -243,7 +320,25 @@ def _extract_calls_from_node( if node.type in ("call_expr", "call_expression", "CallExpr"): # Get the function being called for child in node.children: - if child.type in ("identifier", "IDENTIFIER", "field_access"): + if child.type in ( + "identifier", + "IDENTIFIER", + "field_access", + "field_expression", + ): + # For a field access / expression (e.g. `o.m` or `C{}.m`), + # the method name is the trailing identifier child. Prefer + # that over text-splitting, which is brittle when the + # receiver itself contains punctuation (e.g. `C{}.m`). + if child.type in ("field_access", "field_expression"): + method_name = None + for sub in child.children: + if sub.type in ("identifier", "IDENTIFIER"): + method_name = self._get_node_text(sub, source) + if method_name: + calls.add(method_name) # the method name + calls.add(self._get_node_text(child, source)) # full path + break call_name = self._get_node_text(child, source) # Handle method calls (obj.method) if "." in call_name: @@ -286,6 +381,7 @@ def _resolve_call( call_name: str, caller_file: str, name_to_ids: Dict[str, List[str]], + alias_to_target: Dict[str, Dict[str, str]] | None = None, ) -> List[str]: """ Resolve a call name to function ID(s). @@ -295,6 +391,13 @@ def _resolve_call( 2. Imported files 3. Unique name match """ + # Resolve a same-file const fn-alias (`const f = handler; f()`) to its + # target function name before looking up candidates. + if alias_to_target is not None: + target = alias_to_target.get(caller_file, {}).get(call_name) + if target is not None: + call_name = target + candidates = name_to_ids.get(call_name, []) if not candidates: diff --git a/libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py b/libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py new file mode 100644 index 00000000..64215304 --- /dev/null +++ b/libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py @@ -0,0 +1,123 @@ +"""Regression tests for the Zig call graph builder (u13). + +Three confirmed call-graph recall bugs, each reproduced through the REAL +extractor -> builder pipeline (FunctionExtractor.extract() feeding +CallGraphBuilder(...).build()), asserting the dropped edge is present. + +- [BUG 3] local-type dispatch: `const o = Foo{}; o.method()` (and the + direct `Foo{}.method()`) produces no `caller -> method` edge, + because call-name extraction never recognises a tree-sitter + `field_expression` callee, so the method name is never emitted. +- [BUG 17] builtin-filter leak: a user-defined fn whose name collides with + a ZIG_BUILTINS entry (e.g. `expect`) is dropped by the builtin + filter before resolution, even though a same-file user function + of that name exists. +- [BUG 41] const-alias dataflow: `const f = handler; f()` loses the edge to + `handler`, because the name index maps only fn-decl names, never + the simple const alias binding. +""" + +import os +import sys +import tempfile +from pathlib import Path + +_CORE_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_CORE_ROOT)) + +from parsers.zig.function_extractor import FunctionExtractor +from parsers.zig.call_graph_builder import CallGraphBuilder + + +def _run_pipeline(src: str) -> dict: + """Run the real extractor -> builder pipeline on a single zig source file.""" + workdir = tempfile.mkdtemp() + file_path = os.path.join(workdir, "m.zig") + with open(file_path, "w") as fh: + fh.write(src) + scan_results = {"files": [{"path": "m.zig"}]} + extractor_output = FunctionExtractor(workdir, scan_results).extract() + return CallGraphBuilder(extractor_output).build() + + +def test_bug3_local_type_dispatch_method_call_edge(): + """`const o = C{}; o.m()` must yield an `f -> m` call-graph edge.""" + src = ( + "const C = struct { fn m(self: C) i32 { _ = self; return 1; } };\n" + "fn f() i32 { const o = C{}; return o.m(); }\n" + ) + cg = _run_pipeline(src)["call_graph"] + assert "m.zig:m" in cg.get("m.zig:f", []), ( + f"Expected f -> m method-call edge, got call_graph={cg}" + ) + + +def test_bug3_direct_struct_init_method_call_edge(): + """The direct `C{}.m()` form must also yield an `f -> m` edge.""" + src = ( + "const C = struct { fn m(self: C) i32 { _ = self; return 1; } };\n" + "fn f() i32 { return C{}.m(); }\n" + ) + cg = _run_pipeline(src)["call_graph"] + assert "m.zig:m" in cg.get("m.zig:f", []), ( + f"Expected f -> m direct-init method-call edge, got call_graph={cg}" + ) + + +def test_bug17_user_fn_shadowing_builtin_is_not_filtered(): + """A user fn named `expect` (a ZIG_BUILTINS name) must keep its edge.""" + src = ( + "fn expect(ok: bool) void {\n" + " _ = ok;\n" + "}\n" + "\n" + "fn main() void {\n" + " expect(true);\n" + "}\n" + ) + cg = _run_pipeline(src)["call_graph"] + assert "m.zig:expect" in cg.get("m.zig:main", []), ( + f"Expected main -> expect edge (user fn shadows builtin), got call_graph={cg}" + ) + + +def test_bug17_genuine_builtin_call_is_still_filtered(): + """Scope guard: a builtin call with NO same-file user fn stays filtered. + + `@import` is a genuine builtin and there is no user `@import` function, + so it must not appear as an edge — the fix only un-filters builtins that + are shadowed by a same-file user definition. + """ + src = ( + "fn main() void {\n" + " const std = @import(\"std\");\n" + " _ = std;\n" + "}\n" + ) + cg = _run_pipeline(src)["call_graph"] + # No user fn named @import / import exists, so main has no resolvable edge. + assert cg.get("m.zig:main", []) == [], ( + f"Genuine builtin call should not produce an edge, got call_graph={cg}" + ) + + +def test_bug41_const_alias_call_edge(): + """`const f = handler; f()` must yield a `viaAlias -> handler` edge.""" + src = ( + "fn handler() void {}\n" + "fn viaAlias() void {\n" + " const f = handler;\n" + " f();\n" + "}\n" + "fn direct() void {\n" + " handler();\n" + "}\n" + ) + cg = _run_pipeline(src)["call_graph"] + assert "m.zig:handler" in cg.get("m.zig:viaAlias", []), ( + f"Expected viaAlias -> handler alias edge, got call_graph={cg}" + ) + # Control: the direct call must keep working too. + assert "m.zig:handler" in cg.get("m.zig:direct", []), ( + f"Direct call edge regressed, got call_graph={cg}" + ) From 322920e0262dc4ccbf2ee5b34de87fc0a692e944 Mon Sep 17 00:00:00 2001 From: Gadi Evron Date: Mon, 8 Jun 2026 23:57:43 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(parsers/zig):=20u14=20function=5Fextrac?= =?UTF-8?q?tor=20=E2=80=94=20fn-name/test-classification/container-methods?= =?UTF-8?q?=20(BUG-NEW=2016,26,37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-only finder-fixes-54. 7 new tests. [37] fixed grammar node-type keys (variable_declaration + struct/enum/union/opaque_declaration, members=function_declaration) so container methods extract as C.m; [16] first identifier as fn name; [26] anchored test classification. Cross-unit: the [37] fix changes the call-graph edge target to the now-correct qualified id m.zig:C.m -> updated 2 test_call_graph_builder_u13 assertions (judge-verified legitimate, not a weakening). Judge: AGREE / SHIP-AS-IS. (Follow-up: dead _extract_struct_methods left for a separate cleanup.) Local-only; not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 1aae27a79420beb4ab1ad2f2c064124c221ec26d) --- .../parsers/zig/function_extractor.py | 63 ++++++-- .../zig/test_call_graph_builder_u13.py | 21 ++- .../zig/test_function_extractor_u14.py | 145 ++++++++++++++++++ 3 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 libs/openant-core/tests/parsers/zig/test_function_extractor_u14.py diff --git a/libs/openant-core/parsers/zig/function_extractor.py b/libs/openant-core/parsers/zig/function_extractor.py index 647f0cd5..9c289b0c 100644 --- a/libs/openant-core/parsers/zig/function_extractor.py +++ b/libs/openant-core/parsers/zig/function_extractor.py @@ -19,6 +19,22 @@ class FunctionExtractor: ZIG_LANGUAGE = Language(ts_zig.language()) + # Real tree-sitter-zig node types for container declarations. The container + # body (struct/enum/union/opaque) is a child of a `variable_declaration` + # (`const Foo = struct {...}`). Legacy names (ContainerDecl/VarDecl) are kept + # for forward/back compatibility with other grammar revisions. + _CONTAINER_BODY_TYPES = frozenset( + { + "struct_declaration", + "enum_declaration", + "union_declaration", + "opaque_declaration", + "container_decl", + "ContainerDecl", + } + ) + _VAR_DECL_TYPES = frozenset({"variable_declaration", "VarDecl"}) + def __init__(self, repo_path: str, scan_results: Dict[str, Any]): self.repo_path = Path(repo_path).resolve() self.scan_results = scan_results @@ -103,19 +119,30 @@ def _walk_node( func_id = f"{file_path}:{func_info['qualified_name']}" functions[func_id] = func_info - elif node.type == "VarDecl": - # Check if this is a struct/enum definition + elif node.type in self._VAR_DECL_TYPES: + # Check if this is a struct/enum/union/opaque container definition. struct_info = self._extract_struct_from_var_decl(node, source, file_path) if struct_info: struct_id = f"{file_path}:{struct_info['name']}" structs[struct_id] = struct_info - # Extract methods within the struct - self._extract_struct_methods( - node, source, file_path, struct_info["name"], functions - ) - - elif node.type == "container_decl" or node.type == "ContainerDecl": - # Direct struct/enum declarations + # Recurse into the container body with the struct name as + # context, so member functions are qualified `Foo.method` + # rather than being re-emitted as bare `method` by the generic + # recursion below. + for child in node.children: + self._walk_node( + child, + source, + file_path, + functions, + structs, + imports, + struct_info["name"], + ) + return + + elif node.type in self._CONTAINER_BODY_TYPES: + # Direct struct/enum declarations (anonymous container). struct_info = self._extract_container(node, source, file_path) if struct_info: struct_id = f"{file_path}:{struct_info['name']}" @@ -145,7 +172,11 @@ def _extract_function( for child in node.children: if child.type == "identifier" or child.type == "IDENTIFIER": - name = self._get_node_text(child, source) + # The FIRST identifier is the function name. A later identifier + # child is the return type (e.g. `fn makeWidget() Widget`) and + # must not overwrite the name. + if name is None: + name = self._get_node_text(child, source) elif child.type == "parameters" or child.type == "ParamDeclList": parameters = self._extract_parameters(child, source) @@ -196,8 +227,9 @@ def _extract_struct_from_var_decl( for child in node.children: if child.type == "identifier" or child.type == "IDENTIFIER": - name = self._get_node_text(child, source) - elif child.type == "container_decl" or child.type == "ContainerDecl": + if name is None: + name = self._get_node_text(child, source) + elif child.type in self._CONTAINER_BODY_TYPES: is_struct = True if name and is_struct: @@ -261,8 +293,11 @@ def _classify_function(self, name: str, file_path: str) -> str: """Classify the function type based on name and context.""" name_lower = name.lower() - # Test functions - if name_lower.startswith("test") or "_test" in name_lower: + # Test functions. Anchor on the underscore-delimited test convention + # (`test_foo`, `foo_test`, or a bare `test`). A camelCase identifier + # that merely starts with "test" (e.g. `testConnection`) is an ordinary + # function, not a zig `test "..." {}` block. + if name_lower == "test" or name_lower.startswith("test_") or name_lower.endswith("_test"): return "test" # Init/constructor patterns diff --git a/libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py b/libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py index 64215304..e5955c54 100644 --- a/libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py +++ b/libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py @@ -41,26 +41,35 @@ def _run_pipeline(src: str) -> dict: def test_bug3_local_type_dispatch_method_call_edge(): - """`const o = C{}; o.m()` must yield an `f -> m` call-graph edge.""" + """`const o = C{}; o.m()` must yield an `f -> C.m` call-graph edge. + + Note: the target id is the QUALIFIED `m.zig:C.m`. Prior to the u14 [BUG 37] + fix, struct methods were (incorrectly) emitted under their bare name, so this + assertion read `m.zig:m`. The method is now correctly keyed by its qualified + `Container.method` id; the edge itself is unchanged. + """ src = ( "const C = struct { fn m(self: C) i32 { _ = self; return 1; } };\n" "fn f() i32 { const o = C{}; return o.m(); }\n" ) cg = _run_pipeline(src)["call_graph"] - assert "m.zig:m" in cg.get("m.zig:f", []), ( - f"Expected f -> m method-call edge, got call_graph={cg}" + assert "m.zig:C.m" in cg.get("m.zig:f", []), ( + f"Expected f -> C.m method-call edge, got call_graph={cg}" ) def test_bug3_direct_struct_init_method_call_edge(): - """The direct `C{}.m()` form must also yield an `f -> m` edge.""" + """The direct `C{}.m()` form must also yield an `f -> C.m` edge. + + See the qualified-id note on test_bug3_local_type_dispatch_method_call_edge. + """ src = ( "const C = struct { fn m(self: C) i32 { _ = self; return 1; } };\n" "fn f() i32 { return C{}.m(); }\n" ) cg = _run_pipeline(src)["call_graph"] - assert "m.zig:m" in cg.get("m.zig:f", []), ( - f"Expected f -> m direct-init method-call edge, got call_graph={cg}" + assert "m.zig:C.m" in cg.get("m.zig:f", []), ( + f"Expected f -> C.m direct-init method-call edge, got call_graph={cg}" ) diff --git a/libs/openant-core/tests/parsers/zig/test_function_extractor_u14.py b/libs/openant-core/tests/parsers/zig/test_function_extractor_u14.py new file mode 100644 index 00000000..68bf4981 --- /dev/null +++ b/libs/openant-core/tests/parsers/zig/test_function_extractor_u14.py @@ -0,0 +1,145 @@ +"""Regression tests for the Zig FunctionExtractor (u14). + +Three confirmed extraction/metadata bugs, each reproduced through the REAL +extractor (FunctionExtractor.extract()) on a temp .zig file, asserting on the +emitted `functions` map. + +- [BUG 16] fn-name extraction: a free fn whose return type is a bare named + identifier (`fn makeWidget(v: i32) Widget { ... }`) is recorded + under the RETURN-TYPE name (`Widget`) because the identifier loop + overwrites `name` with the second `identifier` child (the return + type). The real fn name must be the FIRST identifier. +- [BUG 26] test classification: `pub fn testConnection() bool {}` is wrongly + classified `test` because `_classify_function` matches the + `startswith("test")` prefix. A plain function named testXxx is a + regular function, not a zig `test "..." {}` block. +- [BUG 37] struct/enum container methods: methods inside a + `const Foo = struct { fn method() ... };` container are never + extracted, because the walker keys on node types tree-sitter-zig + never emits (`VarDecl`/`container_decl`). The real types are + `variable_declaration` and `struct_declaration` / `enum_declaration` + / `union_declaration` / `opaque_declaration`. +""" + +import os +import sys +import tempfile +from pathlib import Path + +_CORE_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_CORE_ROOT)) + +from parsers.zig.function_extractor import FunctionExtractor + + +def _extract(src: str) -> dict: + """Run the real extractor on a single zig source file; return extract() output.""" + workdir = tempfile.mkdtemp() + file_path = os.path.join(workdir, "m.zig") + with open(file_path, "w") as fh: + fh.write(src) + scan_results = {"files": [{"path": "m.zig"}]} + return FunctionExtractor(workdir, scan_results).extract() + + +# --------------------------------------------------------------------------- +# [BUG 16] fn name must be the first identifier, not the return-type identifier +# --------------------------------------------------------------------------- + +def test_bug16_named_return_type_does_not_shadow_fn_name(): + """`fn makeWidget(v: i32) Widget {}` must be recorded as makeWidget, not Widget.""" + src = ( + "const Widget = struct { x: i32 };\n\n" + "pub fn makeWidget(v: i32) Widget {\n" + " return Widget{ .x = v };\n" + "}\n" + ) + out = _extract(src) + funcs = out["functions"] + assert "m.zig:makeWidget" in funcs, ( + f"makeWidget missing; functions keys = {sorted(funcs)}" + ) + assert funcs["m.zig:makeWidget"]["name"] == "makeWidget" + # The return-type identifier must NOT have become a phantom function. + assert "m.zig:Widget" not in funcs, ( + f"return-type Widget leaked as a function; keys = {sorted(funcs)}" + ) + + +def test_bug16_generic_named_return_type_build_T(): + """Re-confirm the general case: `fn build() T` records build, not T.""" + src = "fn build() T {\n return undefined;\n}\n" + out = _extract(src) + funcs = out["functions"] + assert "m.zig:build" in funcs, f"build missing; keys = {sorted(funcs)}" + assert funcs["m.zig:build"]["name"] == "build" + assert "m.zig:T" not in funcs + + +# --------------------------------------------------------------------------- +# [BUG 26] a plain fn named testXxx must classify as 'function', not 'test' +# --------------------------------------------------------------------------- + +def test_bug26_fn_named_testconnection_is_function_not_test(): + """`pub fn testConnection() bool {}` must have unit_type 'function'.""" + src = "pub fn testConnection() bool {\n return true;\n}\n" + out = _extract(src) + funcs = out["functions"] + assert "m.zig:testConnection" in funcs, f"keys = {sorted(funcs)}" + assert funcs["m.zig:testConnection"]["unit_type"] == "function", ( + f"testConnection wrongly classified: " + f"{funcs['m.zig:testConnection']['unit_type']!r}" + ) + + +# --------------------------------------------------------------------------- +# [BUG 37] container methods (struct/enum/union/opaque) must be extracted +# under the qualified Container.method name. +# --------------------------------------------------------------------------- + +def test_bug37_struct_method_qualified_name_extracted(): + """`const Foo = struct { fn method() ... };` must yield Foo.method.""" + src = ( + "pub fn ordinary() void {}\n\n" + "const Foo = struct {\n" + " pub fn method(self: Foo) void {}\n" + "};\n" + ) + out = _extract(src) + funcs = out["functions"] + assert "m.zig:Foo.method" in funcs, ( + f"Foo.method missing; keys = {sorted(funcs)}" + ) + info = funcs["m.zig:Foo.method"] + assert info["qualified_name"] == "Foo.method" + assert info["class_name"] == "Foo" + assert info["unit_type"] == "method" + # The struct itself should be recorded as a class. + assert "m.zig:Foo" in out["classes"], f"classes = {sorted(out['classes'])}" + + +def test_bug37_enum_method_extracted(): + """`const E = enum { a, fn em() ... };` must yield E.em.""" + src = "const E = enum {\n a,\n pub fn em() void {}\n};\n" + out = _extract(src) + funcs = out["functions"] + assert "m.zig:E.em" in funcs, f"keys = {sorted(funcs)}" + assert funcs["m.zig:E.em"]["class_name"] == "E" + + +def test_bug37_union_method_extracted(): + """`const U = union(enum) { a: u8, fn um() ... };` must yield U.um.""" + src = "const U = union(enum) {\n a: u8,\n pub fn um() void {}\n};\n" + out = _extract(src) + funcs = out["functions"] + assert "m.zig:U.um" in funcs, f"keys = {sorted(funcs)}" + assert funcs["m.zig:U.um"]["class_name"] == "U" + + +def test_bug37_opaque_method_extracted(): + """`const O = opaque { fn om() ... };` must yield O.om.""" + src = "const O = opaque {\n pub fn om() void {}\n};\n" + out = _extract(src) + funcs = out["functions"] + assert "m.zig:O.om" in funcs, f"keys = {sorted(funcs)}" + assert funcs["m.zig:O.om"]["class_name"] == "O"