fix(zig): Union is class-like + missing-grammar warning (review pass)

Self-review findings on the Zig branch:

  - scope/walkers.ts `isClassLike` and finalize-algorithm's
    CALLABLE_OR_TYPE_LIKE did not include 'Union': a `union(enum)`
    container's methods got no ownerId from populateClassOwnedMembers, so
    method dispatch on union receivers silently dropped. Widened both
    sets; the zig-basic fixture gains a Tag method + a CALLS assertion
    (main → isEnergy) that fails without the widening (verified by
    reverting).
  - optional-grammars.ts now lists tree-sitter-zig with an npm `probe`
    (it is an optionalDependency, not vendored): users with .zig files
    and no prebuild get the standard one-line stderr warning instead of
    a silently degraded index.
  - Deduplicated the container-method predicate: `isZigContainerMethod`
    + ZIG_CONTAINER_TYPES now live once in languages/zig/captures.ts and
    feed both the provider labelOverride and the scope-capture relabel.
  - README language matrices: Zig row now claims Type Annotations,
    Constructor Inference, and Config (build.zig.zon) — all true since
    the scope-resolution hooks landed.
This commit is contained in:
Navid EMAD 2026-06-12 20:06:25 +02:00
parent 81eccec36e
commit 46bd72b8ca
No known key found for this signature in database
11 changed files with 64 additions and 36 deletions

View file

@ -580,7 +580,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Zig | ✓ | — | ✓ | — | — | — | — | — | ✓ |
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics

View file

@ -1072,6 +1072,7 @@ const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
'Interface',
'Enum',
'Struct',
'Union',
'Record',
'Trait',
'Namespace',

View file

@ -362,7 +362,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Zig | ✓ | — | ✓ | — | — | — | — | — | ✓ |
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics

View file

@ -7,19 +7,23 @@
* prebuilds activated via node-gyp-build. All can be skipped via
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently
* soft-fail when no prebuild matches the host platform (and a source build was
* unavailable / not attempted).
* unavailable / not attempted). tree-sitter-zig is the one npm-installed
* optionalDependency in the list; its `probe` overrides the vendored load.
*
* Either path produces the same observable: the .node binding is absent
* at runtime. This helper detects that condition and surfaces a single
* stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt
* stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt/.zig
* support is unavailable instead of silently getting a degraded index.
*/
import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
import { isGrammarRuntimeSkipped } from '../core/tree-sitter/parser-loader.js';
import { requireVendoredGrammar } from '../core/tree-sitter/vendored-grammars.js';
import { cliWarn } from './cli-message.js';
const _require = createRequire(import.meta.url);
interface OptionalGrammar {
/** Display name in warnings */
name: string;
@ -34,6 +38,12 @@ interface OptionalGrammar {
* `.proto`, which is a gRPC-extractor concern, not a SupportedLanguages.
*/
language?: SupportedLanguages;
/**
* Availability probe. Defaults to `requireVendoredGrammar(pkg)`; grammars
* that install from npm as an optionalDependency (zig) override it with a
* plain `require` of the package.
*/
probe?: () => unknown;
}
const OPTIONAL_GRAMMARS: OptionalGrammar[] = [
@ -56,6 +66,14 @@ const OPTIONAL_GRAMMARS: OptionalGrammar[] = [
extensions: ['.kt', '.kts'],
language: SupportedLanguages.Kotlin,
},
{
name: 'tree-sitter-zig',
pkg: '@tree-sitter-grammars/tree-sitter-zig',
extensions: ['.zig'],
language: SupportedLanguages.Zig,
// npm optionalDependency, not vendored — probe via plain require.
probe: () => _require('@tree-sitter-grammars/tree-sitter-zig'),
},
];
/**
@ -105,7 +123,11 @@ export function detectMissingOptionalGrammars(): MissingGrammar[] {
continue;
}
try {
requireVendoredGrammar(g.pkg);
if (g.probe !== undefined) {
g.probe();
} else {
requireVendoredGrammar(g.pkg);
}
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const msg = err instanceof Error ? err.message : String(err);

View file

@ -37,34 +37,16 @@ import { zigMethodConfig } from '../method-extractors/configs/zig.js';
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,
isZigContainerMethod,
zigArityCompatibility,
zigBindingScopeFor,
zigReceiverBinding,
} from './zig/index.js';
const ZIG_CONTAINER_TYPES = new Set(['struct_declaration', 'enum_declaration', 'union_declaration']);
/**
* A Zig `fn` declared directly inside a struct/enum/union container is a
* method (mirrors `isKotlinClassMethod`). Containers nest function
* declarations directly — there is no intermediate body node.
*/
function isZigContainerMethod(
captureNode: { parent?: SyntaxNode | null } | null | undefined,
): boolean {
let ancestor = captureNode?.parent;
while (ancestor) {
if (ZIG_CONTAINER_TYPES.has(ancestor.type)) return true;
ancestor = ancestor.parent;
}
return false;
}
export const zigProvider = defineLanguage({
id: SupportedLanguages.Zig,
extensions: ['.zig'],

View file

@ -4,7 +4,7 @@ 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([
export const ZIG_CONTAINER_TYPES: ReadonlySet<string> = new Set([
'struct_declaration',
'enum_declaration',
'union_declaration',
@ -27,11 +27,15 @@ function isContainerOrImportBinding(declNode: SyntaxNode): boolean {
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;
/** A `fn` nested in a struct/enum/union container is a method. Single
* predicate shared between the provider's `labelOverride` (worker
* structure phase) and the scope-capture relabel below, so the graph
* node label and the scope-side def label cannot drift apart. The loose
* parameter shape matches what `labelOverride` receives. */
export function isZigContainerMethod(
captureNode: { readonly parent?: SyntaxNode | null } | null | undefined,
): boolean {
let ancestor = captureNode?.parent;
while (ancestor) {
if (ZIG_CONTAINER_TYPES.has(ancestor.type)) return true;
ancestor = ancestor.parent;
@ -75,7 +79,7 @@ export function emitZigScopeCaptures(
// 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)) {
if (fnAnchor !== undefined && isZigContainerMethod(fnAnchor)) {
const fnCapture = grouped['@declaration.function']!;
delete grouped['@declaration.function'];
grouped['@declaration.method'] = { ...fnCapture, name: '@declaration.method' };

View file

@ -1,4 +1,4 @@
export { emitZigScopeCaptures } from './captures.js';
export { emitZigScopeCaptures, isZigContainerMethod, ZIG_CONTAINER_TYPES } from './captures.js';
export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js';
export {
zigArityCompatibility,

View file

@ -173,9 +173,10 @@ export function namesAtScope(scopeId: ScopeId, scopes: ScopeResolutionIndexes):
* that collapses to `@scope.class` in the scope-extractor query contract.
*
* Semantics widened historically from `'Class' | 'Interface'` to cover
* C#-shape languages (struct, record, enum, trait). Languages that emit
* only `'Class'` are unaffected — the extra kinds never appear in their
* parsed output.
* C#-shape languages (struct, record, enum, trait) and Zig tagged unions
* (`union(enum)` containers own methods like structs do). Languages that
* emit only `'Class'` are unaffected — the extra kinds never appear in
* their parsed output.
*/
export function isClassLike(t: string): boolean {
return (
@ -184,7 +185,8 @@ export function isClassLike(t: string): boolean {
t === 'Struct' ||
t === 'Record' ||
t === 'Enum' ||
t === 'Trait'
t === 'Trait' ||
t === 'Union'
);
}

View file

@ -5,6 +5,8 @@ pub fn main() void {
var p = pioneer.Pioneer{ .energy = 0 };
p.tick();
helper();
const t = pioneer.Tag{ .energy = 5 };
_ = t.isEnergy();
}
fn helper() void {

View file

@ -3,6 +3,10 @@ pub const State = enum { idle, working };
pub const Tag = union(enum) {
none,
energy: u32,
pub fn isEnergy(self: Tag) bool {
return self == .energy;
}
};
pub const Pioneer = struct {

View file

@ -42,6 +42,17 @@ describe('Zig basic resolution', () => {
expect(methods).toContain('reset');
});
it('extracts union(enum) methods as Methods (Union is class-like)', () => {
expect(getNodesByLabel(result, 'Method')).toContain('isEnergy');
});
it('dispatches method calls on a union receiver (main → isEnergy)', () => {
// Pins the `isClassLike('Union')` widening in scope/walkers.ts: without
// it `populateClassOwnedMembers` finds no class-like def in the Tag
// scope, the method gets no ownerId, and dispatch silently drops.
expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('main → isEnergy');
});
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'));