diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 9836dc0af..1a5257ec7 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -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]); } diff --git a/gitnexus/test/integration/tree-sitter-languages.test.ts b/gitnexus/test/integration/tree-sitter-languages.test.ts index 3fcf9045c..cdd3c155f 100644 --- a/gitnexus/test/integration/tree-sitter-languages.test.ts +++ b/gitnexus/test/integration/tree-sitter-languages.test.ts @@ -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(); + 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(); } }); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index 0598b8fac..afa2a0272 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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 { diff --git a/gitnexus/test/unit/zig-import-resolver.test.ts b/gitnexus/test/unit/zig-import-resolver.test.ts index 4b90a2552..db732d432 100644 --- a/gitnexus/test/unit/zig-import-resolver.test.ts +++ b/gitnexus/test/unit/zig-import-resolver.test.ts @@ -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 `/` 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 = ` .{