Skip to content

Commit 322920e

Browse files
committed
fix(parsers/zig): u14 function_extractor — fn-name/test-classification/container-methods (BUG-NEW 16,26,37)
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) <noreply@anthropic.com> (cherry picked from commit 1aae27a79420beb4ab1ad2f2c064124c221ec26d)
1 parent ecbab86 commit 322920e

3 files changed

Lines changed: 209 additions & 20 deletions

File tree

libs/openant-core/parsers/zig/function_extractor.py

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ class FunctionExtractor:
1919

2020
ZIG_LANGUAGE = Language(ts_zig.language())
2121

22+
# Real tree-sitter-zig node types for container declarations. The container
23+
# body (struct/enum/union/opaque) is a child of a `variable_declaration`
24+
# (`const Foo = struct {...}`). Legacy names (ContainerDecl/VarDecl) are kept
25+
# for forward/back compatibility with other grammar revisions.
26+
_CONTAINER_BODY_TYPES = frozenset(
27+
{
28+
"struct_declaration",
29+
"enum_declaration",
30+
"union_declaration",
31+
"opaque_declaration",
32+
"container_decl",
33+
"ContainerDecl",
34+
}
35+
)
36+
_VAR_DECL_TYPES = frozenset({"variable_declaration", "VarDecl"})
37+
2238
def __init__(self, repo_path: str, scan_results: Dict[str, Any]):
2339
self.repo_path = Path(repo_path).resolve()
2440
self.scan_results = scan_results
@@ -103,19 +119,30 @@ def _walk_node(
103119
func_id = f"{file_path}:{func_info['qualified_name']}"
104120
functions[func_id] = func_info
105121

106-
elif node.type == "VarDecl":
107-
# Check if this is a struct/enum definition
122+
elif node.type in self._VAR_DECL_TYPES:
123+
# Check if this is a struct/enum/union/opaque container definition.
108124
struct_info = self._extract_struct_from_var_decl(node, source, file_path)
109125
if struct_info:
110126
struct_id = f"{file_path}:{struct_info['name']}"
111127
structs[struct_id] = struct_info
112-
# Extract methods within the struct
113-
self._extract_struct_methods(
114-
node, source, file_path, struct_info["name"], functions
115-
)
116-
117-
elif node.type == "container_decl" or node.type == "ContainerDecl":
118-
# Direct struct/enum declarations
128+
# Recurse into the container body with the struct name as
129+
# context, so member functions are qualified `Foo.method`
130+
# rather than being re-emitted as bare `method` by the generic
131+
# recursion below.
132+
for child in node.children:
133+
self._walk_node(
134+
child,
135+
source,
136+
file_path,
137+
functions,
138+
structs,
139+
imports,
140+
struct_info["name"],
141+
)
142+
return
143+
144+
elif node.type in self._CONTAINER_BODY_TYPES:
145+
# Direct struct/enum declarations (anonymous container).
119146
struct_info = self._extract_container(node, source, file_path)
120147
if struct_info:
121148
struct_id = f"{file_path}:{struct_info['name']}"
@@ -145,7 +172,11 @@ def _extract_function(
145172

146173
for child in node.children:
147174
if child.type == "identifier" or child.type == "IDENTIFIER":
148-
name = self._get_node_text(child, source)
175+
# The FIRST identifier is the function name. A later identifier
176+
# child is the return type (e.g. `fn makeWidget() Widget`) and
177+
# must not overwrite the name.
178+
if name is None:
179+
name = self._get_node_text(child, source)
149180
elif child.type == "parameters" or child.type == "ParamDeclList":
150181
parameters = self._extract_parameters(child, source)
151182

@@ -196,8 +227,9 @@ def _extract_struct_from_var_decl(
196227

197228
for child in node.children:
198229
if child.type == "identifier" or child.type == "IDENTIFIER":
199-
name = self._get_node_text(child, source)
200-
elif child.type == "container_decl" or child.type == "ContainerDecl":
230+
if name is None:
231+
name = self._get_node_text(child, source)
232+
elif child.type in self._CONTAINER_BODY_TYPES:
201233
is_struct = True
202234

203235
if name and is_struct:
@@ -261,8 +293,11 @@ def _classify_function(self, name: str, file_path: str) -> str:
261293
"""Classify the function type based on name and context."""
262294
name_lower = name.lower()
263295

264-
# Test functions
265-
if name_lower.startswith("test") or "_test" in name_lower:
296+
# Test functions. Anchor on the underscore-delimited test convention
297+
# (`test_foo`, `foo_test`, or a bare `test`). A camelCase identifier
298+
# that merely starts with "test" (e.g. `testConnection`) is an ordinary
299+
# function, not a zig `test "..." {}` block.
300+
if name_lower == "test" or name_lower.startswith("test_") or name_lower.endswith("_test"):
266301
return "test"
267302

268303
# Init/constructor patterns

libs/openant-core/tests/parsers/zig/test_call_graph_builder_u13.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,26 +41,35 @@ def _run_pipeline(src: str) -> dict:
4141

4242

4343
def test_bug3_local_type_dispatch_method_call_edge():
44-
"""`const o = C{}; o.m()` must yield an `f -> m` call-graph edge."""
44+
"""`const o = C{}; o.m()` must yield an `f -> C.m` call-graph edge.
45+
46+
Note: the target id is the QUALIFIED `m.zig:C.m`. Prior to the u14 [BUG 37]
47+
fix, struct methods were (incorrectly) emitted under their bare name, so this
48+
assertion read `m.zig:m`. The method is now correctly keyed by its qualified
49+
`Container.method` id; the edge itself is unchanged.
50+
"""
4551
src = (
4652
"const C = struct { fn m(self: C) i32 { _ = self; return 1; } };\n"
4753
"fn f() i32 { const o = C{}; return o.m(); }\n"
4854
)
4955
cg = _run_pipeline(src)["call_graph"]
50-
assert "m.zig:m" in cg.get("m.zig:f", []), (
51-
f"Expected f -> m method-call edge, got call_graph={cg}"
56+
assert "m.zig:C.m" in cg.get("m.zig:f", []), (
57+
f"Expected f -> C.m method-call edge, got call_graph={cg}"
5258
)
5359

5460

5561
def test_bug3_direct_struct_init_method_call_edge():
56-
"""The direct `C{}.m()` form must also yield an `f -> m` edge."""
62+
"""The direct `C{}.m()` form must also yield an `f -> C.m` edge.
63+
64+
See the qualified-id note on test_bug3_local_type_dispatch_method_call_edge.
65+
"""
5766
src = (
5867
"const C = struct { fn m(self: C) i32 { _ = self; return 1; } };\n"
5968
"fn f() i32 { return C{}.m(); }\n"
6069
)
6170
cg = _run_pipeline(src)["call_graph"]
62-
assert "m.zig:m" in cg.get("m.zig:f", []), (
63-
f"Expected f -> m direct-init method-call edge, got call_graph={cg}"
71+
assert "m.zig:C.m" in cg.get("m.zig:f", []), (
72+
f"Expected f -> C.m direct-init method-call edge, got call_graph={cg}"
6473
)
6574

6675

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Regression tests for the Zig FunctionExtractor (u14).
2+
3+
Three confirmed extraction/metadata bugs, each reproduced through the REAL
4+
extractor (FunctionExtractor.extract()) on a temp .zig file, asserting on the
5+
emitted `functions` map.
6+
7+
- [BUG 16] fn-name extraction: a free fn whose return type is a bare named
8+
identifier (`fn makeWidget(v: i32) Widget { ... }`) is recorded
9+
under the RETURN-TYPE name (`Widget`) because the identifier loop
10+
overwrites `name` with the second `identifier` child (the return
11+
type). The real fn name must be the FIRST identifier.
12+
- [BUG 26] test classification: `pub fn testConnection() bool {}` is wrongly
13+
classified `test` because `_classify_function` matches the
14+
`startswith("test")` prefix. A plain function named testXxx is a
15+
regular function, not a zig `test "..." {}` block.
16+
- [BUG 37] struct/enum container methods: methods inside a
17+
`const Foo = struct { fn method() ... };` container are never
18+
extracted, because the walker keys on node types tree-sitter-zig
19+
never emits (`VarDecl`/`container_decl`). The real types are
20+
`variable_declaration` and `struct_declaration` / `enum_declaration`
21+
/ `union_declaration` / `opaque_declaration`.
22+
"""
23+
24+
import os
25+
import sys
26+
import tempfile
27+
from pathlib import Path
28+
29+
_CORE_ROOT = Path(__file__).resolve().parents[3]
30+
sys.path.insert(0, str(_CORE_ROOT))
31+
32+
from parsers.zig.function_extractor import FunctionExtractor
33+
34+
35+
def _extract(src: str) -> dict:
36+
"""Run the real extractor on a single zig source file; return extract() output."""
37+
workdir = tempfile.mkdtemp()
38+
file_path = os.path.join(workdir, "m.zig")
39+
with open(file_path, "w") as fh:
40+
fh.write(src)
41+
scan_results = {"files": [{"path": "m.zig"}]}
42+
return FunctionExtractor(workdir, scan_results).extract()
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# [BUG 16] fn name must be the first identifier, not the return-type identifier
47+
# ---------------------------------------------------------------------------
48+
49+
def test_bug16_named_return_type_does_not_shadow_fn_name():
50+
"""`fn makeWidget(v: i32) Widget {}` must be recorded as makeWidget, not Widget."""
51+
src = (
52+
"const Widget = struct { x: i32 };\n\n"
53+
"pub fn makeWidget(v: i32) Widget {\n"
54+
" return Widget{ .x = v };\n"
55+
"}\n"
56+
)
57+
out = _extract(src)
58+
funcs = out["functions"]
59+
assert "m.zig:makeWidget" in funcs, (
60+
f"makeWidget missing; functions keys = {sorted(funcs)}"
61+
)
62+
assert funcs["m.zig:makeWidget"]["name"] == "makeWidget"
63+
# The return-type identifier must NOT have become a phantom function.
64+
assert "m.zig:Widget" not in funcs, (
65+
f"return-type Widget leaked as a function; keys = {sorted(funcs)}"
66+
)
67+
68+
69+
def test_bug16_generic_named_return_type_build_T():
70+
"""Re-confirm the general case: `fn build() T` records build, not T."""
71+
src = "fn build() T {\n return undefined;\n}\n"
72+
out = _extract(src)
73+
funcs = out["functions"]
74+
assert "m.zig:build" in funcs, f"build missing; keys = {sorted(funcs)}"
75+
assert funcs["m.zig:build"]["name"] == "build"
76+
assert "m.zig:T" not in funcs
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# [BUG 26] a plain fn named testXxx must classify as 'function', not 'test'
81+
# ---------------------------------------------------------------------------
82+
83+
def test_bug26_fn_named_testconnection_is_function_not_test():
84+
"""`pub fn testConnection() bool {}` must have unit_type 'function'."""
85+
src = "pub fn testConnection() bool {\n return true;\n}\n"
86+
out = _extract(src)
87+
funcs = out["functions"]
88+
assert "m.zig:testConnection" in funcs, f"keys = {sorted(funcs)}"
89+
assert funcs["m.zig:testConnection"]["unit_type"] == "function", (
90+
f"testConnection wrongly classified: "
91+
f"{funcs['m.zig:testConnection']['unit_type']!r}"
92+
)
93+
94+
95+
# ---------------------------------------------------------------------------
96+
# [BUG 37] container methods (struct/enum/union/opaque) must be extracted
97+
# under the qualified Container.method name.
98+
# ---------------------------------------------------------------------------
99+
100+
def test_bug37_struct_method_qualified_name_extracted():
101+
"""`const Foo = struct { fn method() ... };` must yield Foo.method."""
102+
src = (
103+
"pub fn ordinary() void {}\n\n"
104+
"const Foo = struct {\n"
105+
" pub fn method(self: Foo) void {}\n"
106+
"};\n"
107+
)
108+
out = _extract(src)
109+
funcs = out["functions"]
110+
assert "m.zig:Foo.method" in funcs, (
111+
f"Foo.method missing; keys = {sorted(funcs)}"
112+
)
113+
info = funcs["m.zig:Foo.method"]
114+
assert info["qualified_name"] == "Foo.method"
115+
assert info["class_name"] == "Foo"
116+
assert info["unit_type"] == "method"
117+
# The struct itself should be recorded as a class.
118+
assert "m.zig:Foo" in out["classes"], f"classes = {sorted(out['classes'])}"
119+
120+
121+
def test_bug37_enum_method_extracted():
122+
"""`const E = enum { a, fn em() ... };` must yield E.em."""
123+
src = "const E = enum {\n a,\n pub fn em() void {}\n};\n"
124+
out = _extract(src)
125+
funcs = out["functions"]
126+
assert "m.zig:E.em" in funcs, f"keys = {sorted(funcs)}"
127+
assert funcs["m.zig:E.em"]["class_name"] == "E"
128+
129+
130+
def test_bug37_union_method_extracted():
131+
"""`const U = union(enum) { a: u8, fn um() ... };` must yield U.um."""
132+
src = "const U = union(enum) {\n a: u8,\n pub fn um() void {}\n};\n"
133+
out = _extract(src)
134+
funcs = out["functions"]
135+
assert "m.zig:U.um" in funcs, f"keys = {sorted(funcs)}"
136+
assert funcs["m.zig:U.um"]["class_name"] == "U"
137+
138+
139+
def test_bug37_opaque_method_extracted():
140+
"""`const O = opaque { fn om() ... };` must yield O.om."""
141+
src = "const O = opaque {\n pub fn om() void {}\n};\n"
142+
out = _extract(src)
143+
funcs = out["functions"]
144+
assert "m.zig:O.om" in funcs, f"keys = {sorted(funcs)}"
145+
assert funcs["m.zig:O.om"]["class_name"] == "O"

0 commit comments

Comments
 (0)