Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions __tests__/zig-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Zig Extraction Tests
*
* Zig has no classes — types are values bound to a const (`const X = struct{}`)
* and modules are `const m = @import(...)`. These tests pin the idioms the
* extractor must get right: container types, scope-based methods, fields, enum
* members, constants, imports, test blocks, and the import-namespace mappings
* that make cross-file `callers`/`callees` resolve.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import type { ExtractionResult } from '../src/types';
import { extractFromSource } from '../src/extraction';
import {
detectLanguage,
isLanguageSupported,
getSupportedLanguages,
initGrammars,
loadAllGrammars,
} from '../src/extraction/grammars';
import { extractImportMappings } from '../src/resolution/import-resolver';

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});

const SAMPLE = `
const std = @import("std");
const helper = @import("./util/helper.zig");

pub const max_items: u32 = 8;

pub const Color = enum { red, green, blue };

pub const Point = struct {
x: f32,
y: f32,

pub fn add(self: Point, other: Point) Point {
return .{ .x = self.x + other.x, .y = self.y + other.y };
}
};

pub fn translate(p: Point) Point {
return helper.shift(p);
}

test "point add" {
_ = Point.add;
}
`;

describe('Zig language wiring', () => {
it('detects .zig as zig and reports it supported', () => {
expect(detectLanguage('src/main.zig')).toBe('zig');
expect(isLanguageSupported('zig')).toBe(true);
expect(getSupportedLanguages()).toContain('zig');
});
});

describe('Zig extraction', () => {
// Computed in beforeAll, not at collection time — the file-level beforeAll
// must load the grammar first.
let result: ExtractionResult;
beforeAll(() => {
result = extractFromSource('shapes.zig', SAMPLE, 'zig');
});

const byKind = (kind: string, name: string) =>
result.nodes.find((n) => n.kind === kind && n.name === name);

it('extracts a const-bound struct as a struct, not a constant', () => {
expect(byKind('struct', 'Point')).toBeDefined();
expect(byKind('constant', 'Point')).toBeUndefined();
});

it('extracts a struct method as a method (scope-based, no receiver syntax)', () => {
const add = byKind('method', 'add');
expect(add).toBeDefined();
expect(add!.qualifiedName).toContain('Point');
});

it('extracts struct fields', () => {
expect(byKind('field', 'x')).toBeDefined();
expect(byKind('field', 'y')).toBeDefined();
});

it('extracts a const-bound enum and its members', () => {
expect(byKind('enum', 'Color')).toBeDefined();
expect(byKind('enum_member', 'red')).toBeDefined();
expect(byKind('enum_member', 'blue')).toBeDefined();
});

it('extracts a top-level fn as a function and a plain const as a constant', () => {
expect(byKind('function', 'translate')).toBeDefined();
expect(byKind('constant', 'max_items')).toBeDefined();
});

it('extracts @import as import nodes', () => {
expect(byKind('import', 'std')).toBeDefined();
expect(byKind('import', './util/helper.zig')).toBeDefined();
});

it('extracts a test block as a callable function node', () => {
expect(byKind('function', 'point add')).toBeDefined();
});

it('emits a calls reference for a namespaced member call', () => {
// `helper.shift(p)` — the dotted ref the resolver maps through the import.
const ref = result.unresolvedReferences.find(
(r) => r.referenceName === 'helper.shift' && r.referenceKind === 'calls'
);
expect(ref).toBeDefined();
});
});

describe('Zig import mappings (cross-file resolution)', () => {
it('maps @import bindings to namespace imports', () => {
const maps = extractImportMappings('shapes.zig', SAMPLE, 'zig');
const helper = maps.find((m) => m.localName === 'helper');
expect(helper).toBeDefined();
expect(helper!.source).toBe('./util/helper.zig');
expect(helper!.isNamespace).toBe(true);
expect(maps.find((m) => m.localName === 'std')).toBeDefined();
});
});

describe('Zig generic-type factories', () => {
// `fn List(T) type { return struct {...} }` — Zig's generic types are
// functions returning an anonymous container.
const FACTORY = `
pub fn List(comptime T: type) type {
return struct {
items: []T,
pub fn append(self: *@This(), x: T) void { _ = self; _ = x; }
pub fn clear(self: *@This()) void { _ = self; }
};
}`;
let nodes: { kind: string; name: string; qualifiedName?: string }[];
beforeAll(() => {
nodes = extractFromSource('list.zig', FACTORY, 'zig').nodes;
});

it('indexes the factory as a struct named for the function', () => {
expect(nodes.find((n) => n.kind === 'struct' && n.name === 'List')).toBeDefined();
});

it('indexes the returned container declarations as methods of that type', () => {
const append = nodes.find((n) => n.kind === 'method' && n.name === 'append');
expect(append).toBeDefined();
expect(append!.qualifiedName).toContain('List');
expect(nodes.find((n) => n.kind === 'method' && n.name === 'clear')).toBeDefined();
expect(nodes.find((n) => n.kind === 'field' && n.name === 'items')).toBeDefined();
});
});
27 changes: 27 additions & 0 deletions src/extraction/function-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,21 @@ const RUST_SPEC: FnRefSpec = {
]),
};

// Zig has no closures, so a callback is an inline anonymous-struct method passed
// by value: `std.sort.sort(items, ctx, struct { fn cmp(_,a,b) bool {…} }.cmp)`.
// The value is a `field_expression` (.cmp); comptime dispatch tables use
// `.{ .add = addKernel }` (field_initializer) and `.{ a, b }` (initializer_list).
const ZIG_SPEC: FnRefSpec = {
idTypes: new Set(['identifier']),
dispatch: new Map<string, CaptureRule>([
['arguments', { mode: 'args' }],
['assignment_expression', { mode: 'rhs', field: 'right' }],
['field_initializer', { mode: 'value' }],
['initializer_list', { mode: 'list' }],
]),
special: new Set(['field_expression']),
};

const JAVA_SPEC: FnRefSpec = {
// No bare-identifier function values in Java — only method references.
idTypes: new Set<string>(),
Expand Down Expand Up @@ -384,6 +399,7 @@ export const FN_REF_SPECS: Record<string, FnRefSpec | undefined> = {
python: PYTHON_SPEC,
go: GO_SPEC,
rust: RUST_SPEC,
zig: ZIG_SPEC,
java: JAVA_SPEC,
kotlin: KOTLIN_SPEC,
csharp: CSHARP_SPEC,
Expand Down Expand Up @@ -615,6 +631,17 @@ function normalizeSpecial(
source: string
): NormalizedRef[] {
switch (type) {
// Zig `Container.member` used as a value — the inline-anonymous-struct
// callback idiom `struct { fn cmp(…) {} }.cmp` (Zig's closure substitute).
// Capture the member; the same-file gate keeps only members naming a
// function defined here, so ordinary `obj.field` data reads drop out.
case 'field_expression': {
const member = getChildByField(node, 'member');
return member && member.type === 'identifier'
? [{ name: getNodeText(member, source), node: member }]
: [];
}

// Java method references. Receiver decides the resolution route (#808):
// `this::run0` / `super::close` → `this.<m>` (class-scoped resolver;
// super rides the inherited-member supertype pass)
Expand Down
10 changes: 9 additions & 1 deletion src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
python: 'tree-sitter-python.wasm',
go: 'tree-sitter-go.wasm',
rust: 'tree-sitter-rust.wasm',
// Vendored: built from tree-sitter-zig patched for Zig 0.16 (asm-clobbers
// struct, de-keyworded async/await/suspend/resume, error-set field types,
// reserved-word block labels, conditional slice/pointer element types) — not
// in tree-sitter-wasms. 100% parse across the Zig 0.16 standard library,
// Ghostty, TigerBeetle, libxev and http.zig.
zig: 'tree-sitter-zig.wasm',
java: 'tree-sitter-java.wasm',
c: 'tree-sitter-c.wasm',
cpp: 'tree-sitter-cpp.wasm',
Expand Down Expand Up @@ -61,6 +67,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.pyw': 'python',
'.go': 'go',
'.rs': 'rust',
'.zig': 'zig',
'.java': 'java',
'.c': 'c',
'.h': 'c', // Could also be C++, defaulting to C
Expand Down Expand Up @@ -221,7 +228,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// `class Foo(...)` as an ERROR that swallows the whole class (#237); we
// vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
// primary constructors natively.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r')
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'zig')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
Expand Down Expand Up @@ -412,6 +419,7 @@ export function getLanguageDisplayName(language: Language): string {
python: 'Python',
go: 'Go',
rust: 'Rust',
zig: 'Zig',
r: 'R',
java: 'Java',
c: 'C',
Expand Down
2 changes: 2 additions & 0 deletions src/extraction/languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { javascriptExtractor } from './javascript';
import { pythonExtractor } from './python';
import { goExtractor } from './go';
import { rustExtractor } from './rust';
import { zigExtractor } from './zig';
import { javaExtractor } from './java';
import { cExtractor, cppExtractor } from './c-cpp';
import { csharpExtractor } from './csharp';
Expand All @@ -36,6 +37,7 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
python: pythonExtractor,
go: goExtractor,
rust: rustExtractor,
zig: zigExtractor,
java: javaExtractor,
c: cExtractor,
cpp: cppExtractor,
Expand Down
Loading