fix(zig): address fourth gitnexus-check review pass

- language-config: `parseZigBuildZon` only accepts a `.path` that is a
  DIRECT field of a dependency entry. Nested blocks inside the entry body
  are blanked (string-aware, offsets preserved) before the `.path` regex
  runs, and a match starting inside a string literal is rejected, so
  `.dep = .{ .url = "…", .meta = .{ .path = "x" } }` no longer becomes a
  path dep. Regression test in zig-import-resolver.test.ts (fails on the
  previous code).
- tree-sitter-languages test: the "grammar is absent" case now drives the
  loader's real `source.load()` catch branch — `node:module` is stood in
  with a `createRequire` whose require throws MODULE_NOT_FOUND for
  `@tree-sitter-grammars/tree-sitter-zig` and delegates everything else —
  instead of the `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` opt-out, which has its
  own test. It also asserts the opt-out flag is NOT set and that other
  grammars still load (non-fatal optional failure).

Not re-fixed:
- "Return type is read from the wrong tree-sitter field": tree-sitter-zig
  1.1.2 has NO `return_type` field on function_declaration — the type after
  `)` is the `type` field (AST dump: `builtin_type "i32" field=type`; the
  proposed `childForFieldName('return_type')` is null for every function).
  A pin test in zig-extractors.test.ts asserts both the grammar fact and
  that `returnType` is extracted (`void`, `!*Counter`).
- "`DIR_LANG` has no zig entry": it does (added in the first pass and
  answered again in the third); the finding is carried over unchanged.
This commit is contained in:
Navid EMAD 2026-08-18 04:53:53 +02:00
parent b654b66261
commit 4674308b9f
No known key found for this signature in database
4 changed files with 119 additions and 12 deletions

View file

@ -574,6 +574,37 @@ function findZonBlockEnd(text: string, start: number): number {
return -1;
}
/**
* `body` with every nested `{ … }` block (string-aware) replaced by spaces of
* equal length, so a regex over the result only sees the block's DIRECT
* fields and offsets still line up with the original text.
*/
function zonBlankNestedBlocks(body: string): string {
const out = body.split('');
let depth = 0;
let inString = false;
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (inString) {
if (ch === '\\') {
if (depth > 0 && i + 1 < body.length) out[i + 1] = ' ';
i++;
} else if (ch === '"') inString = false;
if (depth > 0) out[i] = ' ';
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') depth++;
else if (ch === '}' && depth > 0) {
depth--;
out[i] = ' ';
continue;
}
if (depth > 0) out[i] = ' ';
}
return out.join('');
}
/**
* Per-offset "is inside a `"…"` literal" mask for comment-stripped ZON text,
* so header regexes can reject a match that merely LOOKS like a field
@ -644,9 +675,19 @@ export function parseZigBuildZon(raw: string): ZigBuildZonConfig | null {
const bodyStart = m.index + m[0].length;
const bodyEnd = findZonBlockEnd(text, bodyStart);
if (bodyEnd < 0 || bodyEnd > end) break;
const body = text.slice(bodyStart, bodyEnd);
cursor = bodyEnd + 1;
const pathMatch = body.match(/\.path\s*=\s*"([^"\n]+)"/);
// Only a `.path` that is a DIRECT field of the entry counts: a nested
// object inside the entry (`.foo = .{ .url = "…", .x = .{ .path = "…" } }`)
// must not turn a URL dep into a path dep. Blank nested blocks first and
// reject a match that starts inside a string literal.
const body = zonBlankNestedBlocks(text.slice(bodyStart, bodyEnd));
const pathMatch = matchZonHeader(
body,
/\.path\s*=\s*"([^"\n]+)"/g,
mask.subarray(bodyStart, bodyEnd),
0,
body.length,
);
if (pathMatch) {
pathDeps.set(depName, pathMatch[1]);
}

View file

@ -715,23 +715,46 @@ describe('Tree-sitter multi-language parsing', () => {
});
it('reports Zig unavailable and throws "Unsupported language" when the grammar is absent', async () => {
// Force the absent-binding path instead of hoping the package is missing:
// the loader's runtime opt-out (`GITNEXUS_SKIP_OPTIONAL_GRAMMARS`) makes a
// userSkippable grammar report exactly as an absent binding does. A fresh
// module copy is needed because the loader memoizes load results.
const ENV = 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS';
const previous = process.env[ENV];
process.env[ENV] = 'zig';
// Exercise the loader's real absent-binding branch (the `source.load()`
// catch in `loadGrammar`), not the `GITNEXUS_SKIP_OPTIONAL_GRAMMARS`
// opt-out, which is a separate code path with its own test
// (parser-loader-skip-optional.test.ts). The Zig row loads through the
// module-scoped `createRequire(import.meta.url)`, so stand in for
// `node:module` with a require that reports the package missing and
// delegates everything else. A fresh module copy is needed because the
// loader memoizes load results.
const ZIG_PKG = '@tree-sitter-grammars/tree-sitter-zig';
vi.doMock('node:module', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:module')>();
return {
...actual,
createRequire: (url: string | URL) => {
const real = actual.createRequire(url);
const stub = ((id: string) => {
if (id === ZIG_PKG) {
const err = new Error(`Cannot find module '${id}'`) as NodeJS.ErrnoException;
err.code = 'MODULE_NOT_FOUND';
throw err;
}
return real(id);
}) as unknown as NodeJS.Require;
return Object.assign(stub, real);
},
};
});
vi.resetModules();
try {
vi.resetModules();
const fresh = await import('../../src/core/tree-sitter/parser-loader.js');
// Not the opt-out path: the env is untouched.
expect(fresh.isGrammarRuntimeSkipped(SupportedLanguages.Zig)).toBe(false);
expect(fresh.isLanguageAvailable(SupportedLanguages.Zig)).toBe(false);
await expect(fresh.loadLanguage(SupportedLanguages.Zig)).rejects.toThrow(
/Unsupported language/,
);
// Optional-grammar failure is non-fatal: the other grammars still load.
expect(fresh.isLanguageAvailable(SupportedLanguages.TypeScript)).toBe(true);
} finally {
if (previous === undefined) delete process.env[ENV];
else process.env[ENV] = previous;
vi.doUnmock('node:module');
vi.resetModules();
}
});

View file

@ -116,6 +116,28 @@ const Counter = struct {
expect(make.isStatic).toBe(true);
});
it('reads the return type from function_declaration’s `type:` field (there is no `return_type`)', () => {
// tree-sitter-zig 1.1.2 labels the type after `)` as the `type` field on
// function_declaration — the same field NAME parameter nodes use, but on
// a different node. There is no `return_type` field: reading that would
// drop every Zig return type. Pin both the grammar fact and the extractor.
const root = parse(`
const Counter = struct {
n: u32,
pub fn add(self: *Counter, by: u32) void { self.n += by; }
pub fn make(n: u32) !*Counter { return error.Nope; }
};
`).rootNode;
const addDecl = find(root, 'function_declaration', 'pub fn add');
expect(addDecl.childForFieldName('return_type')).toBeNull();
expect(addDecl.childForFieldName('type')?.text).toBe('void');
const result = extractor.extract(find(root, 'struct_declaration'), ctx);
const byName = new Map(result!.methods.map((m) => [m.name, m]));
expect(byName.get('add')!.returnType).toBe('void');
expect(byName.get('make')!.returnType).toBe('!*Counter');
});
it('only a FIRST parameter named self is the receiver', () => {
const root = parse(`
const S = struct {

View file

@ -214,6 +214,27 @@ describe('parseZigBuildZon', () => {
expect(parseZigBuildZon(raw)).toBeNull();
});
it('ignores a `.path` nested inside an entry — only a direct field makes a path dep', () => {
// A URL dep whose body carries a nested object with its own `.path` must
// not be reported as a path dep: the resolver would otherwise add an
// import edge to an unrelated `<root>/<nested path>` for `@import("only_url")`.
const raw = `
.{
.dependencies = .{
.only_url = .{
.url = "https://x",
.hash = "1220y",
.meta = .{ .path = "vendor/unrelated" },
},
.real = .{ .path = "vendor/real", .extra = .{ .path = "vendor/nested" } },
},
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect([...cfg!.pathDeps.entries()]).toEqual([['real', 'vendor/real']]);
});
it('returns null when the deps block has no `.path` entries', () => {
const raw = `
.{