feat(zig): scope-resolution hooks — IMPORTS and CALLS edges (Ring 3)

Implements the registry-primary scope-resolution path for Zig, the
prerequisite for cross-file edges since the legacy DAG removal. Adds the
standard per-language stack under languages/zig/:

  - query.ts: scope query (containers as Class scopes, blocks, functions),
    declarations (container anchors placed on the container node itself so
    the def lands in its own Class scope and the name binding auto-hoists
    to the parent — populateClassOwnedMembers needs the class-like def
    among the class scope's ownedDefs), @import statements (#eq?-gated
    builtin), parameter/constructor type bindings, and call/constructor
    reference sites. The grammar is required lazily (optionalDependency).
  - captures.ts: emitZigScopeCaptures — groups query matches, drops the
    plain-variable group for container/import bindings (their dedicated
    rules bind the name), and relabels container-nested fns
    @declaration.function → @declaration.method (labelOverride parity).
  - interpret.ts: namespace-kind imports (const x = @import("…")) and
    type bindings — self-parameter convention marks the receiver, Zig
    sigils (*, ?, [], error unions, const) stripped from type names while
    dotted qualifiers (mod.T) are preserved for Case-3 namespace-prefix
    receiver dispatch.
  - simple-hooks.ts: parameter bindings stay function-local (Go
    rationale), local-over-import merge precedence, bounds-check arity
    (always 'unknown' today — no synthesized arity metadata).
  - scope-resolver.ts: emit-side wiring; build.zig.zon threads through
    loadResolutionConfig into the same resolveZigImportInternal the legacy
    resolver config wraps. fieldFallbackOnMethodLookup off (statically
    typed). Registered in SCOPE_RESOLVERS.

The resolvers integration test un-skips the import-edge case and gains
CALLS assertions: free call (main → helper) and receiver-bound method
dispatch through a namespace-qualified constructor
(var p = pioneer.Pioneer{…}; p.tick() → main → tick).
This commit is contained in:
Navid EMAD 2026-06-12 19:42:05 +02:00
parent 4dd87b16ad
commit 81eccec36e
No known key found for this signature in database
10 changed files with 447 additions and 16 deletions

View file

@ -31,7 +31,7 @@ const ZIG_STDLIB_NAMES = new Set(['std', 'builtin', 'root']);
export function resolveZigImportInternal(
currentFile: string,
importPath: string,
allFiles: Set<string>,
allFiles: ReadonlySet<string>,
buildZon?: ZigBuildZonConfig | null,
): string | null {
// Stdlib / compiler builtin / root — not resolvable from source files alone.
@ -68,10 +68,7 @@ export function resolveZigImportInternal(
if (normalized !== null) {
// Conventional Zig layout: <pkg_root>/src/<name>.zig (matches the
// package's primary module name) or <pkg_root>/src/main.zig.
const candidates = [
`${normalized}/src/${importPath}.zig`,
`${normalized}/src/main.zig`,
];
const candidates = [`${normalized}/src/${importPath}.zig`, `${normalized}/src/main.zig`];
for (const c of candidates) {
if (allFiles.has(c)) return c;
}

View file

@ -11,9 +11,13 @@
* `@import("std")` and external packages are deliberately external.
* - namedBindingExtractor: omitted — `const Foo = @import("x").Foo` is a
* const declaration, not import-statement syntax.
* - scope-resolution hooks (emitScopeCaptures, interpretImport, …) are
* omitted — Zig is classified `experimental` and uses the generic
* fallback resolution path.
* - scope-resolution hooks (Ring 3): `emitScopeCaptures` walks the file via
* `zig/query.ts` (containers as Class scopes, container-nested fns
* relabeled @declaration.method, plain-variable groups filtered for
* container/import bindings); `interpretImport` maps
* `const x = @import("…")` to a namespace import; receiver dispatch
* rides the `self`-parameter convention. The emit-side wiring lives in
* `zig/scope-resolver.ts` (SCOPE_RESOLVERS registry).
*/
import { SupportedLanguages } from 'gitnexus-shared';
@ -34,6 +38,14 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
import { zigVariableConfig } from '../variable-extractors/configs/zig.js';
import { zigTypeConfig } from '../type-extractors/zig.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import {
emitZigScopeCaptures,
interpretZigImport,
interpretZigTypeBinding,
zigArityCompatibility,
zigBindingScopeFor,
zigReceiverBinding,
} from './zig/index.js';
const ZIG_CONTAINER_TYPES = new Set(['struct_declaration', 'enum_declaration', 'union_declaration']);
@ -75,4 +87,14 @@ export const zigProvider = defineLanguage({
if (isZigContainerMethod(functionNode)) return 'Method';
return defaultLabel;
},
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitZigScopeCaptures,
interpretImport: interpretZigImport,
interpretTypeBinding: interpretZigTypeBinding,
bindingScopeFor: zigBindingScopeFor,
receiverBinding: zigReceiverBinding,
// Provider contract is (def, callsite); the ScopeResolver contract is
// (callsite, def) — same function, adapted argument order.
arityCompatibility: (def, callsite) => zigArityCompatibility(callsite, def),
});

View file

@ -0,0 +1,88 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { getZigParser, getZigScopeQuery } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
const ZIG_CONTAINER_TYPES = new Set([
'struct_declaration',
'enum_declaration',
'union_declaration',
]);
/** Is this variable_declaration a container binding (`const T = struct {…}`)
* or an import binding (`const x = @import("…")`)? Those groups are emitted
* by their dedicated query rules; the plain @declaration.variable match for
* the same node must be dropped so the name binds exactly once. */
function isContainerOrImportBinding(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.namedChildCount; i++) {
const child = declNode.namedChild(i);
if (child === null) continue;
if (ZIG_CONTAINER_TYPES.has(child.type)) return true;
if (child.type === 'builtin_function') {
const builtin = child.namedChild(0);
if (builtin?.type === 'builtin_identifier' && builtin.text === '@import') return true;
}
}
return false;
}
/** A `fn` nested in a struct/enum/union container is a method — mirror the
* provider's `labelOverride` so scope-side defs carry the same label the
* worker gives the graph node. */
function isContainerMethod(fnNode: SyntaxNode): boolean {
let ancestor = fnNode.parent;
while (ancestor) {
if (ZIG_CONTAINER_TYPES.has(ancestor.type)) return true;
ancestor = ancestor.parent;
}
return false;
}
export function emitZigScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getZigParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getZigParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
}
const rawMatches = getZigScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
const nodeMap: Record<string, SyntaxNode> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
if (tag.startsWith('@_')) continue; // skip anonymous predicate captures
grouped[tag] = nodeToCapture(tag, c.node);
nodeMap[tag] = c.node;
}
if (Object.keys(grouped).length === 0) continue;
// Drop the plain-variable group for container/import bindings — their
// dedicated rules already bind the name (as Struct/Enum/Union or import).
const variableAnchor = nodeMap['@declaration.variable'];
if (variableAnchor !== undefined && isContainerOrImportBinding(variableAnchor)) {
continue;
}
// Relabel container-nested fns Function → Method (provider labelOverride
// parity). The anchor capture name carries the kind, so rebuild it.
const fnAnchor = nodeMap['@declaration.function'];
if (fnAnchor !== undefined && isContainerMethod(fnAnchor)) {
const fnCapture = grouped['@declaration.function']!;
delete grouped['@declaration.function'];
grouped['@declaration.method'] = { ...fnCapture, name: '@declaration.method' };
}
out.push(grouped);
}
return out;
}

View file

@ -0,0 +1,8 @@
export { emitZigScopeCaptures } from './captures.js';
export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js';
export {
zigArityCompatibility,
zigBindingScopeFor,
zigMergeBindings,
zigReceiverBinding,
} from './simple-hooks.js';

View file

@ -0,0 +1,55 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
const stripQuotes = (s: string): string => s.replace(/^["']|["']$/g, '');
/**
* `const std = @import("std");` binds the imported module to a const handle
* accessed via qualified syntax — a namespace import (closest peers: Python
* `import numpy`, Go `import "pkg/bar"`). The local name and imported name
* are always the same identifier; Zig has no rename syntax at the import
* site (renames are ordinary const aliases handled as variable bindings).
*/
export function interpretZigImport(captures: CaptureMatch): ParsedImport | null {
const name = captures['@import.name']?.text;
const source = captures['@import.source']?.text;
if (name === undefined || source === undefined) return null;
const targetRaw = stripQuotes(source);
if (targetRaw.length === 0) return null;
return { kind: 'namespace', localName: name, importedName: name, targetRaw };
}
/**
* Strip Zig type sigils that wrap the nominal type: pointers (`*T`, `[*]T`),
* optionals (`?T`), error unions (`!T` / `E!T`), slices (`[]T`), arrays
* (`[N]T`), and `const` qualifiers. Keeps the bare type name so registry
* lookup matches the container declaration.
*/
export function normalizeZigTypeName(text: string): string {
let t = text.trim();
let previous: string;
do {
previous = t;
t = t.replace(/^(\*|\?|\[\*?c?\]|\[[^\]]*\])\s*/, '');
t = t.replace(/^const\s+/, '');
} while (t !== previous);
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
return t;
}
export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
let source: TypeRef['source'] = 'annotation';
if (captures['@type-binding.parameter'] !== undefined) {
// Zig has no implicit receiver keyword; the convention is a first
// parameter named `self`. Mark it so `receiverBinding` finds it.
source = name === 'self' ? 'self' : 'parameter-annotation';
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';
}
return { boundName: name, rawTypeName: normalizeZigTypeName(type), source };
}

View file

@ -0,0 +1,126 @@
import Parser from 'tree-sitter';
import { createRequire } from 'node:module';
const _require = createRequire(import.meta.url);
/**
* Zig scope-resolution query (RFC #909 Ring 3).
*
* The grammar is an optionalDependency (`@tree-sitter-grammars/tree-sitter-zig`),
* so the language module is required lazily and `getZigParser` /
* `getZigScopeQuery` throw only when actually invoked without the grammar
* installed. That is safe: the parse pipeline filters `.zig` files through
* `parser-loader.isLanguageAvailable` before any scope extraction runs.
*
* Zig specifics encoded here:
* - Containers (struct/enum/union) are anonymous nodes bound by the
* enclosing `variable_declaration`; declarations capture the binding
* identifier from the wrapper.
* - `@import` is a builtin call, not import-statement syntax; the
* `#eq?` predicate keeps other builtins (@sizeOf, @as, …) out.
* - A plain `(variable_declaration (identifier))` rule would also match
* container and import bindings — `emitZigScopeCaptures` filters those
* groups out so a name binds exactly once.
*/
const ZIG_SCOPE_QUERY = `
;; Scopes
(source_file) @scope.module
(struct_declaration) @scope.class
(enum_declaration) @scope.class
(union_declaration) @scope.class
(function_declaration) @scope.function
(block) @scope.block
;; Declarations — functions (relabeled @declaration.method inside containers
;; by emitZigScopeCaptures, mirroring the provider's labelOverride)
(function_declaration
name: (identifier) @declaration.name) @declaration.function
;; Declarations — containers. The binding name lives on the wrapper
;; variable_declaration, but the ANCHOR is the container node itself so its
;; range equals the @scope.class range: the extractor then attaches the def
;; to the class scope (walkers.populateClassOwnedMembers expects the
;; class-like def among the class scope's ownedDefs) and auto-hoists the
;; name binding to the parent scope.
(variable_declaration
(identifier) @declaration.name
(struct_declaration) @declaration.struct)
(variable_declaration
(identifier) @declaration.name
(enum_declaration) @declaration.enum)
(variable_declaration
(identifier) @declaration.name
(union_declaration) @declaration.union)
;; Declarations — container fields (struct fields, enum/union variants)
(container_field
name: (identifier) @declaration.name) @declaration.field
;; Declarations — const/var bindings (import/container groups filtered in TS)
(variable_declaration
(identifier) @declaration.name) @declaration.variable
;; Imports — const x = @import("...")
(variable_declaration
(identifier) @import.name
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.statement
;; Type bindings — parameter annotations (incl. self: *T receivers)
(parameter
name: (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.parameter
;; Type bindings — constructor inference: const p = T{ ... }
(variable_declaration
(identifier) @type-binding.name
(struct_initializer
(identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings — qualified constructor: const p = mod.T{ ... }. The whole
;; field_expression is captured so the dotted text "mod.T" survives —
;; receiver dispatch resolves the namespace prefix through the import
;; binding (emitReceiverBoundCalls Case 3).
(variable_declaration
(identifier) @type-binding.name
(struct_initializer
(field_expression) @type-binding.type)) @type-binding.constructor
;; References — free calls: foo(...)
(call_expression
function: (identifier) @reference.name) @reference.call.free
;; References — member calls: obj.method(...) / mod.fn(...)
(call_expression
function: (field_expression
object: (_) @reference.receiver
member: (identifier) @reference.name)) @reference.call.member
;; References — constructor uses: T{ ... }
(struct_initializer
(identifier) @reference.name) @reference.call.constructor
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
function getZigLanguage(): Parameters<Parser['setLanguage']>[0] {
return _require('@tree-sitter-grammars/tree-sitter-zig');
}
export function getZigParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(getZigLanguage());
}
return _parser;
}
export function getZigScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(getZigLanguage(), ZIG_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,50 @@
/**
* Zig `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by the
* generic `runScopeResolution` orchestrator.
*
* Thin wiring: Zig has no inheritance (default MRO linearization over an
* empty heritage set), no `super`, and is statically typed (field-fallback
* heuristic off per the contract guidance). Import resolution reuses the
* same `resolveZigImportInternal` the legacy import-resolver config wraps,
* with `build.zig.zon` `.path` deps threaded through `loadResolutionConfig`.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { loadZigBuildZon, type ZigBuildZonConfig } from '../../language-config.js';
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
import { zigProvider } from '../zig.js';
import { zigArityCompatibility, zigMergeBindings } from './index.js';
export const zigScopeResolver: ScopeResolver = {
language: SupportedLanguages.Zig,
languageProvider: zigProvider,
importEdgeReason: 'zig-scope: import',
loadResolutionConfig: (repoPath: string) => loadZigBuildZon(repoPath),
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolveZigImportInternal(
fromFile,
targetRaw,
allFilePaths,
(resolutionConfig as ZigBuildZonConfig | null | undefined) ?? null,
),
mergeBindings: zigMergeBindings,
arityCompatibility: zigArityCompatibility,
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// Zig has no `super`.
isSuperReceiver: () => false,
// Statically typed — the field-fallback heuristic over-connects.
fieldFallbackOnMethodLookup: false,
};

View file

@ -0,0 +1,77 @@
import type {
BindingRef,
Callsite,
CaptureMatch,
Scope,
ScopeId,
ScopeTree,
SymbolDefinition,
TypeRef,
} from 'gitnexus-shared';
/** Keep parameter (incl. `self`) typeBindings in the function scope —
* hoisting them to Module would pollute other functions' receiver
* resolution (same rationale as `goBindingScopeFor`). */
export function zigBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.parameter'] !== undefined) {
return innermost.id;
}
return null; // default auto-hoist for other bindings
}
/** Zig's receiver convention is a first parameter named `self`; the
* `self`-sourced typeBinding on the function scope carries its type. */
export function zigReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
for (const binding of functionScope.typeBindings.values()) {
if (binding.source === 'self') return binding;
}
return null;
}
const TIER: Record<BindingRef['origin'], number> = {
local: 0,
namespace: 1,
import: 2,
reexport: 3,
wildcard: 4,
};
/** Local declarations shadow imports; deterministic order within a tier. */
export function zigMergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
_scopeId: string,
): BindingRef[] {
const seen = new Set<string>();
return [...existing, ...incoming]
.sort(
(a, b) =>
(TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
)
.filter((binding) => {
if (seen.has(binding.def.nodeId)) return false;
seen.add(binding.def.nodeId);
return true;
});
}
/** Zig has no overloading; without synthesized arity metadata on
* declarations the comparison is always 'unknown' — kept as a real
* bounds check so it turns on if arity captures are added later. */
export function zigArityCompatibility(
callsite: Callsite,
def: SymbolDefinition,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
if (min !== undefined && callsite.arity < min) return 'incompatible';
if (max !== undefined && callsite.arity > max) return 'incompatible';
return 'compatible';
}

View file

@ -27,6 +27,7 @@ import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js';
import { swiftScopeResolver } from '../../languages/swift/scope-resolver.js';
import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
import { zigScopeResolver } from '../../languages/zig/scope-resolver.js';
/** Map of `SupportedLanguages` → `ScopeResolver`. The scope-resolution phase
* iterates this map directly — every registered resolver runs. This is the
@ -51,4 +52,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Swift, swiftScopeResolver],
[SupportedLanguages.Dart, dartScopeResolver],
[SupportedLanguages.Vue, vueScopeResolver],
[SupportedLanguages.Zig, zigScopeResolver],
]);

View file

@ -4,6 +4,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
edgeSet,
FIXTURES,
getNodesByLabel,
getRelationships,
@ -41,17 +42,22 @@ describe('Zig basic resolution', () => {
expect(methods).toContain('reset');
});
// IMPORTS (and CALLS) edges are produced by the scope-resolution pipeline,
// which requires the provider to implement `emitScopeCaptures` /
// `interpretImport`. Zig is classified `experimental` and does not provide
// those hooks yet — the import RESOLVER itself (relative paths +
// build.zig.zon, see test/unit/zig-import-resolver.test.ts) is wired into
// the resolver factory and becomes live the moment the hooks land.
// Un-skip when Zig gains scope-resolution hooks.
it.skip('resolves the relative @import("./pioneer.zig") to pioneer.zig', () => {
it('resolves the relative @import("./pioneer.zig") to pioneer.zig', () => {
const imports = getRelationships(result, 'IMPORTS');
const internal = imports.filter((e) => e.targetFilePath.endsWith('pioneer.zig'));
expect(internal.length).toBeGreaterThan(0);
expect(internal[0].sourceFilePath).toContain('main.zig');
});
it('emits a CALLS edge for the free call main → helper', () => {
const calls = getRelationships(result, 'CALLS');
expect(edgeSet(calls)).toContain('main → helper');
});
it('emits a CALLS edge for the receiver-bound method call main → tick', () => {
const calls = getRelationships(result, 'CALLS');
// `var p = pioneer.Pioneer{…}; p.tick()` — constructor-inferred receiver
// type through the namespace import, dispatched onto Pioneer.tick.
expect(edgeSet(calls)).toContain('main → tick');
});
});