diff --git a/gitnexus/src/core/ingestion/import-resolvers/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/zig.ts index aa5dc139c..9c1200be3 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/zig.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/zig.ts @@ -31,7 +31,7 @@ const ZIG_STDLIB_NAMES = new Set(['std', 'builtin', 'root']); export function resolveZigImportInternal( currentFile: string, importPath: string, - allFiles: Set, + allFiles: ReadonlySet, 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: /src/.zig (matches the // package's primary module name) or /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; } diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts index 1b60620fc..3a15ccf4d 100644 --- a/gitnexus/src/core/ingestion/languages/zig.ts +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -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), }); diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts new file mode 100644 index 000000000..70e096e22 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -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['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 = {}; + const nodeMap: Record = {}; + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/zig/index.ts b/gitnexus/src/core/ingestion/languages/zig/index.ts new file mode 100644 index 000000000..9049ffc2a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/zig/interpret.ts b/gitnexus/src/core/ingestion/languages/zig/interpret.ts new file mode 100644 index 000000000..db3e192ff --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/interpret.ts @@ -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 }; +} diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts new file mode 100644 index 000000000..fb16a3432 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -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[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; +} diff --git a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts new file mode 100644 index 000000000..780a4fafa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts @@ -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, +}; diff --git a/gitnexus/src/core/ingestion/languages/zig/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/zig/simple-hooks.ts new file mode 100644 index 000000000..25b08f7a1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/simple-hooks.ts @@ -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 = { + 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(); + 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'; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index cd225d9a2..bcfe5a983 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -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 = n [SupportedLanguages.Swift, swiftScopeResolver], [SupportedLanguages.Dart, dartScopeResolver], [SupportedLanguages.Vue, vueScopeResolver], + [SupportedLanguages.Zig, zigScopeResolver], ]); diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index d2528d893..cf891f809 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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'); + }); });