mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
fix(zig): address gitnexus-check review findings
One commit per the bot's list so each item is easy to check off:
- parser-loader: Zig row gains `userSkippable: true`, so
`GITNEXUS_SKIP_OPTIONAL_GRAMMARS` (=1 or a list naming `zig`) disables it
at analyze time like swift/dart/kotlin — as `optional-grammars.ts` already
documented. Covered in parser-loader-skip-optional.test.ts.
- export-detection: `zigExportChecker` stops at the first declaration it
reaches. A non-`pub` fn inside `pub const T = struct {…}` was reported
exported because the walk continued up to the wrapper.
- import-resolvers/zig: a `.path = "."` dep normalizes to '' and no longer
grows a leading slash (`/src/main.zig` could never match).
- language-config: build.zig.zon parsing strips `//` comments string-aware
(a `//` inside `.url = "https://…"` survives) and matches braces while
skipping string literals, so a commented-out `.path` cannot declare a dep
and a `}` in a comment/string cannot truncate the block.
- method-extractors/configs/zig: the leading `self` receiver is excluded
from `parameters` (Rust parity). Fixing that exposed a worse bug: the
`parameters` node is a plain child of `function_declaration`, not a
`parameters:` field, so `childForFieldName('parameters')` was always null
and every Zig method had no parameters, no receiver and `isStatic: true`.
One `zigParameterList` helper now feeds all three readers.
- variable-extractors/configs/zig: container (`struct`/`enum`/`union`) and
`@import` bindings are skipped via the same predicate the scope captures
use (`isZigContainerOrImportBinding`), instead of the comment merely
claiming they were.
- tree-sitter-languages.test.ts: the "missing grammar" case now forces the
absent-binding path through the loader's runtime opt-out on a fresh module
instead of passing vacuously when the package is installed.
New: test/unit/zig-extractors.test.ts (exports, receiver/parameters,
variable guard); zig-import-resolver.test.ts gains the `.` dep, comment and
brace cases.
The `createFieldExtractor` heads-up needs no change: the added branch is
unreachable for every existing config (none has empty `bodyNodeTypes`).
This commit is contained in:
parent
a69cb3a48a
commit
bc39f1b2d0
12 changed files with 378 additions and 49 deletions
|
|
@ -251,6 +251,11 @@ const ZIG_DECL_TYPES = new Set(['function_declaration', 'variable_declaration'])
|
|||
* keyword token (tree-sitter-zig models `pub` as an anonymous keyword child of
|
||||
* function_declaration / variable_declaration). Container fields (struct/enum
|
||||
* variants) are public if their enclosing variable_declaration is public.
|
||||
*
|
||||
* The walk stops at the FIRST declaration it reaches: a `fn` inside
|
||||
* `pub const T = struct { … }` carries its own `pub` (or not), independent of
|
||||
* the container's. Continuing up to the wrapper marked every private method of
|
||||
* a public container as exported.
|
||||
*/
|
||||
export const zigExportChecker: ExportChecker = (node, _name) => {
|
||||
let current: SyntaxNode | null = node;
|
||||
|
|
@ -260,13 +265,6 @@ export const zigExportChecker: ExportChecker = (node, _name) => {
|
|||
const child = current.child(i);
|
||||
if (child?.type === 'pub') return true;
|
||||
}
|
||||
// For nested function_declaration (a method inside a struct), keep walking
|
||||
// up to the outer variable_declaration — it's the binding that carries
|
||||
// module-level visibility.
|
||||
if (current.type === 'function_declaration') {
|
||||
current = current.parent;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
current = current.parent;
|
||||
|
|
|
|||
|
|
@ -67,8 +67,11 @@ export function resolveZigImportInternal(
|
|||
const normalized = normalizeDepPath(depPath);
|
||||
if (normalized !== null) {
|
||||
// Conventional Zig layout: <pkg_root>/src/<name>.zig (matches the
|
||||
// package's primary module name) or <pkg_root>/src/main.zig.
|
||||
const candidates = [`${normalized}/src/${importPath}.zig`, `${normalized}/src/main.zig`];
|
||||
// package's primary module name) or <pkg_root>/src/main.zig. A dep at
|
||||
// `.path = "."` (the repo itself) normalizes to '' and must not grow a
|
||||
// leading slash — `allFiles` keys are repo-relative.
|
||||
const prefix = normalized === '' ? '' : `${normalized}/`;
|
||||
const candidates = [`${prefix}src/${importPath}.zig`, `${prefix}src/main.zig`];
|
||||
for (const c of candidates) {
|
||||
if (allFiles.has(c)) return c;
|
||||
}
|
||||
|
|
@ -85,6 +88,7 @@ export function resolveZigImportInternal(
|
|||
* Normalize a `.path` value from build.zig.zon into a repo-relative form.
|
||||
* Returns null for paths that escape the repo root (start with `..`) or
|
||||
* are absolute — those point to files we don't index in `allFilePaths`.
|
||||
* `.` / `./` normalize to the empty string (the repo root itself).
|
||||
*/
|
||||
function normalizeDepPath(depPath: string): string | null {
|
||||
if (depPath.startsWith('/')) return null;
|
||||
|
|
|
|||
|
|
@ -501,8 +501,10 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
|
|||
* because their unpacked location lives outside the repo
|
||||
* (.zig-cache/p/<hash>/ or ~/.cache/zig/p/<hash>/) and is therefore
|
||||
* not in our `allFilePaths` set.
|
||||
* - Comments (`//`) inside the dep block are not stripped; if a `.path`
|
||||
* is commented out it will still match. Acceptable for an indexer.
|
||||
* - `//` line comments are stripped before scanning (string-aware, so a
|
||||
* `//` inside `.url = "https://…"` survives), and brace matching skips
|
||||
* string literals — a commented-out `.path` or a `}` inside a comment
|
||||
* or string cannot declare a dep or truncate the block.
|
||||
*/
|
||||
export async function loadZigBuildZon(repoRoot: string): Promise<ZigBuildZonConfig | null> {
|
||||
try {
|
||||
|
|
@ -514,36 +516,84 @@ export async function loadZigBuildZon(repoRoot: string): Promise<ZigBuildZonConf
|
|||
}
|
||||
}
|
||||
|
||||
/** Pure parser split out for testability. Returns null when no path-deps found. */
|
||||
export function parseZigBuildZon(raw: string): ZigBuildZonConfig | null {
|
||||
// Locate the `.dependencies = .{ ... }` block. Use brace counting because
|
||||
// dep entries are nested anonymous structs and a naive `}` match would stop early.
|
||||
const depsHeader = raw.match(/\.dependencies\s*=\s*\.\{/);
|
||||
if (!depsHeader) return null;
|
||||
const start = depsHeader.index! + depsHeader[0].length;
|
||||
let depth = 1;
|
||||
let end = -1;
|
||||
for (let i = start; i < raw.length; i++) {
|
||||
/**
|
||||
* Blank out `//` line comments (and `\\` multiline-string-literal lines) in
|
||||
* ZON source, string-aware: a `//` inside a `"…"` literal (`.url =
|
||||
* "https://…"`) is content, not a comment. Comment bytes are replaced with
|
||||
* spaces so every surviving character keeps its offset.
|
||||
*/
|
||||
function stripZonComments(raw: string): string {
|
||||
const out = raw.split('');
|
||||
let inString = false;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const ch = raw[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
if (inString) {
|
||||
if (ch === '\\')
|
||||
i++; // skip the escaped char
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
const isLineComment = ch === '/' && raw[i + 1] === '/';
|
||||
const isMultilineLiteral =
|
||||
ch === '\\' &&
|
||||
raw[i + 1] === '\\' &&
|
||||
/^[ \t]*$/.test(raw.slice(raw.lastIndexOf('\n', i) + 1, i));
|
||||
if (isLineComment || isMultilineLiteral) {
|
||||
while (i < raw.length && raw[i] !== '\n') out[i++] = ' ';
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the `}` matching the `{` that precedes `start`, skipping braces
|
||||
* inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text.
|
||||
*/
|
||||
function findZonBlockEnd(text: string, start: number): number {
|
||||
let depth = 1;
|
||||
let inString = false;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = true;
|
||||
else if (ch === '{') depth++;
|
||||
else if (ch === '}' && --depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Pure parser split out for testability. Returns null when no path-deps found. */
|
||||
export function parseZigBuildZon(raw: string): ZigBuildZonConfig | null {
|
||||
const text = stripZonComments(raw);
|
||||
// Locate the `.dependencies = .{ ... }` block. Use brace counting because
|
||||
// dep entries are nested anonymous structs and a naive `}` match would stop early.
|
||||
const depsHeader = text.match(/\.dependencies\s*=\s*\.\{/);
|
||||
if (!depsHeader) return null;
|
||||
const start = depsHeader.index! + depsHeader[0].length;
|
||||
const end = findZonBlockEnd(text, start);
|
||||
if (end < 0) return null;
|
||||
const block = raw.slice(start, end);
|
||||
const block = text.slice(start, end);
|
||||
|
||||
const pathDeps = new Map<string, string>();
|
||||
// Match each `.<name> = .{ ... }` entry; capture the entry body.
|
||||
const entryRe = /\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\.\{([\s\S]*?)\}\s*,?/g;
|
||||
// Walk each `.<name> = .{ ... }` entry; the body ends at the matching brace
|
||||
// (string-aware), not at the first `}` in the text.
|
||||
const entryHeaderRe = /\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\.\{/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = entryRe.exec(block)) !== null) {
|
||||
while ((m = entryHeaderRe.exec(block)) !== null) {
|
||||
const depName = m[1];
|
||||
const body = m[2];
|
||||
const bodyStart = m.index + m[0].length;
|
||||
const bodyEnd = findZonBlockEnd(block, bodyStart);
|
||||
if (bodyEnd < 0) break;
|
||||
const body = block.slice(bodyStart, bodyEnd);
|
||||
entryHeaderRe.lastIndex = bodyEnd + 1;
|
||||
const pathMatch = body.match(/\.path\s*=\s*"([^"\n]+)"/);
|
||||
if (pathMatch) {
|
||||
pathDeps.set(depName, pathMatch[1]);
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ export const ZIG_CONTAINER_TYPES: ReadonlySet<string> = new Set([
|
|||
/** 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 {
|
||||
* the same node must be dropped so the name binds exactly once. Shared with
|
||||
* the variable extractor config so the structure-phase Variable records and
|
||||
* the scope-side bindings agree on what counts as a plain variable. */
|
||||
export function isZigContainerOrImportBinding(declNode: SyntaxNode): boolean {
|
||||
for (let i = 0; i < declNode.namedChildCount; i++) {
|
||||
const child = declNode.namedChild(i);
|
||||
if (child === null) continue;
|
||||
|
|
@ -118,7 +120,7 @@ export function emitZigScopeCaptures(
|
|||
// 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)) {
|
||||
if (variableAnchor !== undefined && isZigContainerOrImportBinding(variableAnchor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
export { emitZigScopeCaptures, isZigContainerMethod, ZIG_CONTAINER_TYPES } from './captures.js';
|
||||
export {
|
||||
emitZigScopeCaptures,
|
||||
isZigContainerMethod,
|
||||
isZigContainerOrImportBinding,
|
||||
ZIG_CONTAINER_TYPES,
|
||||
} from './captures.js';
|
||||
export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js';
|
||||
export {
|
||||
zigArityCompatibility,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,18 @@ const extractZigName = (node: SyntaxNode): string | undefined => {
|
|||
return nameNode?.text;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `parameters` node of a function_declaration. tree-sitter-zig 1.1.2
|
||||
* attaches it as a plain named child — NOT under a `parameters:` field (only
|
||||
* `name`, `type` and `body` are fields) — so `childForFieldName('parameters')`
|
||||
* is always null. Reading it that way silently produced empty parameter lists,
|
||||
* no receiver, and `isStatic: true` for every method.
|
||||
*/
|
||||
const zigParameterList = (node: SyntaxNode): SyntaxNode | null =>
|
||||
node.childForFieldName('parameters') ??
|
||||
node.namedChildren.find((child): child is SyntaxNode => child?.type === 'parameters') ??
|
||||
null;
|
||||
|
||||
const extractZigReturnType = (node: SyntaxNode): string | undefined => {
|
||||
// tree-sitter-zig labels the return type as the `type` field on
|
||||
// function_declaration (the same field name used for parameter types).
|
||||
|
|
@ -35,15 +47,24 @@ const extractZigReturnType = (node: SyntaxNode): string | undefined => {
|
|||
return typeNode?.text?.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Regular parameters only. A leading `self` parameter is the receiver — it is
|
||||
* reported through `extractReceiverType`, not the parameter list (same split
|
||||
* as Rust's `self_parameter` skip in `configs/rust.ts`).
|
||||
*/
|
||||
const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => {
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
const paramList = zigParameterList(node);
|
||||
if (!paramList) return [];
|
||||
const params: ParameterInfo[] = [];
|
||||
let seenParameter = false;
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param || param.type !== 'parameter') continue;
|
||||
const nameNode = param.childForFieldName('name');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const isReceiver = !seenParameter && nameNode?.text === 'self';
|
||||
seenParameter = true;
|
||||
if (isReceiver) continue;
|
||||
params.push({
|
||||
name: nameNode?.text ?? '?',
|
||||
type: typeNode?.text?.trim() ?? null,
|
||||
|
|
@ -64,7 +85,7 @@ const hasPubKeyword = (node: SyntaxNode): boolean => {
|
|||
};
|
||||
|
||||
const extractZigReceiverType = (node: SyntaxNode): string | undefined => {
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
const paramList = zigParameterList(node);
|
||||
if (!paramList) return undefined;
|
||||
const first = paramList.namedChild(0);
|
||||
if (!first || first.type !== 'parameter') return undefined;
|
||||
|
|
@ -88,7 +109,7 @@ export const zigMethodConfig: MethodExtractionConfig = {
|
|||
|
||||
isStatic(node) {
|
||||
// A Zig "method" is effectively static if its first parameter is not `self`.
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
const paramList = zigParameterList(node);
|
||||
if (!paramList) return true;
|
||||
const first = paramList.namedChild(0);
|
||||
if (!first || first.type !== 'parameter') return true;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { VariableExtractionConfig, VariableVisibility } from '../../variable-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { isZigContainerOrImportBinding } from '../../languages/zig/captures.js';
|
||||
|
||||
/**
|
||||
* Zig variable extraction.
|
||||
|
|
@ -11,9 +12,10 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
|||
*
|
||||
* Excludes variable_declarations whose value is a struct/enum/union or an
|
||||
* `@import(...)` builtin — those are handled by the class extractor / import
|
||||
* pipeline. The exclusion is by-effect rather than by-config: when one of
|
||||
* those values is present, the surrounding pipeline labels the node as
|
||||
* Struct/Enum/Import-edge and the variable record is redundant.
|
||||
* pipeline, and a Variable record beside the Struct/Enum/Union node (or the
|
||||
* import edge) would be a duplicate. `extractName` returns undefined for them,
|
||||
* which is the generic extractor's skip signal (see `generic.ts`, and Python's
|
||||
* broad `expression_statement` config for the same pattern).
|
||||
*/
|
||||
|
||||
const hasPubKeyword = (node: SyntaxNode): boolean => {
|
||||
|
|
@ -39,6 +41,7 @@ export const zigVariableConfig: VariableExtractionConfig = {
|
|||
variableNodeTypes: [],
|
||||
|
||||
extractName(node) {
|
||||
if (isZigContainerOrImportBinding(node)) return undefined;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'identifier') return child.text;
|
||||
|
|
|
|||
|
|
@ -182,10 +182,13 @@ const SOURCES: Record<string, GrammarSource> = {
|
|||
// (verified by load-time smoke test). The peer-dep mismatch is suppressed
|
||||
// via the `overrides` block in package.json. Listed as `optional: true`
|
||||
// because the package is in `optionalDependencies` — users on platforms
|
||||
// without a prebuild may not have it.
|
||||
// without a prebuild may not have it — and `userSkippable` because it is
|
||||
// a genuinely-optional grammar: `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` must be
|
||||
// able to disable it at analyze time like swift/dart/kotlin.
|
||||
[SupportedLanguages.Zig]: {
|
||||
load: () => _require('@tree-sitter-grammars/tree-sitter-zig'),
|
||||
optional: true,
|
||||
userSkippable: true,
|
||||
unavailableNote:
|
||||
'Zig parsing disabled: `@tree-sitter-grammars/tree-sitter-zig` is an ' +
|
||||
'optionalDependency and is not installed (or its native binding failed ' +
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
|
|
@ -700,11 +700,25 @@ describe('Tree-sitter multi-language parsing', () => {
|
|||
expect(defTypes).toContain('definition.enum');
|
||||
});
|
||||
|
||||
it('gracefully handles missing tree-sitter-zig', async () => {
|
||||
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';
|
||||
try {
|
||||
await loadLanguage(SupportedLanguages.Zig);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain('Unsupported language');
|
||||
vi.resetModules();
|
||||
const fresh = await import('../../src/core/tree-sitter/parser-loader.js');
|
||||
expect(fresh.isLanguageAvailable(SupportedLanguages.Zig)).toBe(false);
|
||||
await expect(fresh.loadLanguage(SupportedLanguages.Zig)).rejects.toThrow(
|
||||
/Unsupported language/,
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = previous;
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,6 +39,17 @@ describe('parser-loader GITNEXUS_SKIP_OPTIONAL_GRAMMARS runtime gate', () => {
|
|||
expect(isLanguageAvailable(SupportedLanguages.Swift)).toBe(false);
|
||||
expect(isLanguageAvailable(SupportedLanguages.Dart)).toBe(false);
|
||||
expect(isLanguageAvailable(SupportedLanguages.Kotlin)).toBe(false);
|
||||
// Zig is an npm optionalDependency rather than a vendored grammar, but it
|
||||
// is just as genuinely optional: the documented opt-out must cover it too
|
||||
// (`optional-grammars.ts` lists it among the skippable grammars).
|
||||
expect(isLanguageAvailable(SupportedLanguages.Zig)).toBe(false);
|
||||
});
|
||||
|
||||
it('a comma list can name zig on its own', async () => {
|
||||
const { isLanguageAvailable, isGrammarRuntimeSkipped } = await freshLoader('zig');
|
||||
expect(isLanguageAvailable(SupportedLanguages.Zig)).toBe(false);
|
||||
expect(isGrammarRuntimeSkipped(SupportedLanguages.Zig)).toBe(true);
|
||||
expect(isGrammarRuntimeSkipped(SupportedLanguages.Swift)).toBe(false);
|
||||
});
|
||||
|
||||
it('skip=all/true/* also skip every optional grammar', async () => {
|
||||
|
|
|
|||
153
gitnexus/test/unit/zig-extractors.test.ts
Normal file
153
gitnexus/test/unit/zig-extractors.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Zig structure-phase extractors: export detection, method parameters, and
|
||||
* the variable extractor's container/import guard. Each case pins a finding
|
||||
* from the PR review of the Zig provider — the assertion is the behavior that
|
||||
* was wrong, not merely that extraction runs.
|
||||
*
|
||||
* `@tree-sitter-grammars/tree-sitter-zig` is an optionalDependency: the whole
|
||||
* file skips cleanly when it is absent, mirroring the Dart/Kotlin suites.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
import Parser from 'tree-sitter';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
|
||||
import { zigExportChecker } from '../../src/core/ingestion/export-detection.js';
|
||||
import { createMethodExtractor } from '../../src/core/ingestion/method-extractors/generic.js';
|
||||
import { zigMethodConfig } from '../../src/core/ingestion/method-extractors/configs/zig.js';
|
||||
import { createVariableExtractor } from '../../src/core/ingestion/variable-extractors/generic.js';
|
||||
import { zigVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/zig.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
let Zig: unknown = null;
|
||||
try {
|
||||
Zig = _require('@tree-sitter-grammars/tree-sitter-zig');
|
||||
} catch {
|
||||
// optional grammar absent on this platform — suite skips below
|
||||
}
|
||||
|
||||
const describeZig = Zig ? describe : describe.skip;
|
||||
|
||||
const parser = new Parser();
|
||||
const parse = (code: string) => {
|
||||
parser.setLanguage(Zig as Parser.Language);
|
||||
return parser.parse(code);
|
||||
};
|
||||
|
||||
/** Depth-first search for the first node of `type` whose text starts with `prefix`. */
|
||||
function find(root: SyntaxNode, type: string, prefix = ''): SyntaxNode {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
while (stack.length > 0) {
|
||||
const n = stack.pop()!;
|
||||
if (n.type === type && n.text.startsWith(prefix)) return n;
|
||||
for (let i = n.namedChildCount - 1; i >= 0; i--) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) stack.push(c);
|
||||
}
|
||||
}
|
||||
throw new Error(`no ${type} node starting with ${JSON.stringify(prefix)}`);
|
||||
}
|
||||
|
||||
describeZig('zigExportChecker', () => {
|
||||
const src = `
|
||||
pub const Point = struct {
|
||||
x: i32,
|
||||
pub fn public(self: Point) i32 { return self.x; }
|
||||
fn private(self: Point) i32 { return self.x; }
|
||||
};
|
||||
const Hidden = struct {
|
||||
pub fn shown() void {}
|
||||
};
|
||||
`;
|
||||
|
||||
it('reads a method’s own `pub`, not the enclosing container’s', () => {
|
||||
// Before the fix the walk continued from a non-`pub` fn up to
|
||||
// `pub const Point`, so every private method of a public container was
|
||||
// reported exported.
|
||||
const root = parse(src).rootNode;
|
||||
expect(zigExportChecker(find(root, 'function_declaration', 'pub fn public'), 'public')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(zigExportChecker(find(root, 'function_declaration', 'fn private'), 'private')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('a `pub fn` inside a private container is still marked pub on its own terms', () => {
|
||||
const root = parse(src).rootNode;
|
||||
expect(zigExportChecker(find(root, 'function_declaration', 'pub fn shown'), 'shown')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('container fields inherit the wrapper’s visibility', () => {
|
||||
const root = parse(src).rootNode;
|
||||
expect(zigExportChecker(find(root, 'container_field', 'x'), 'x')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describeZig('Zig MethodExtractor — receiver vs parameters', () => {
|
||||
const extractor = createMethodExtractor(zigMethodConfig);
|
||||
const ctx = { filePath: 'test.zig', language: SupportedLanguages.Zig };
|
||||
|
||||
it('excludes the leading `self` receiver from `parameters` (Rust parity)', () => {
|
||||
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 .{ .n = n }; }
|
||||
};
|
||||
`).rootNode;
|
||||
const result = extractor.extract(find(root, 'struct_declaration'), ctx);
|
||||
expect(result).not.toBeNull();
|
||||
const byName = new Map(result!.methods.map((m) => [m.name, m]));
|
||||
|
||||
const add = byName.get('add')!;
|
||||
expect(add.receiverType).toBe('*Counter');
|
||||
expect(add.parameters.map((p) => p.name)).toEqual(['by']);
|
||||
expect(add.isStatic).toBe(false);
|
||||
|
||||
// No receiver: every parameter is regular, even when one is not first.
|
||||
const make = byName.get('make')!;
|
||||
expect(make.receiverType).toBeNull();
|
||||
expect(make.parameters.map((p) => p.name)).toEqual(['n']);
|
||||
expect(make.isStatic).toBe(true);
|
||||
});
|
||||
|
||||
it('only a FIRST parameter named self is the receiver', () => {
|
||||
const root = parse(`
|
||||
const S = struct {
|
||||
fn f(other: u32, self: u32) void { _ = other; _ = self; }
|
||||
};
|
||||
`).rootNode;
|
||||
const result = extractor.extract(find(root, 'struct_declaration'), ctx);
|
||||
expect(result!.methods[0].parameters.map((p) => p.name)).toEqual(['other', 'self']);
|
||||
expect(result!.methods[0].receiverType).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describeZig('Zig VariableExtractor — container and import bindings are not variables', () => {
|
||||
const extractor = createVariableExtractor(zigVariableConfig);
|
||||
const ctx = { filePath: 'test.zig', language: SupportedLanguages.Zig };
|
||||
|
||||
it('skips `const T = struct/enum/union {…}` and `const x = @import(…)`', () => {
|
||||
// These nodes are already emitted as Struct/Enum/Union nodes and IMPORTS
|
||||
// edges; a Variable record beside them was a duplicate.
|
||||
const root = parse(`
|
||||
const std = @import("std");
|
||||
pub const Point = struct { x: i32 };
|
||||
const Color = enum { red, green };
|
||||
const Tag = union(enum) { a: u8, b: u16 };
|
||||
const limit: u32 = 10;
|
||||
var count = @as(u32, 0);
|
||||
`).rootNode;
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < root.namedChildCount; i++) {
|
||||
const decl = root.namedChild(i)!;
|
||||
expect(extractor.isVariableDeclaration(decl)).toBe(true); // node-type hint stays broad
|
||||
const info = extractor.extract(decl, ctx);
|
||||
if (info) names.push(info.name);
|
||||
}
|
||||
expect(names).toEqual(['limit', 'count']);
|
||||
});
|
||||
});
|
||||
|
|
@ -52,6 +52,23 @@ describe('resolveZigImportInternal', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('resolves a `.path = "."` dep against the repo root without a leading slash', () => {
|
||||
// `normalizeDepPath('.')` is '' — the candidates used to become
|
||||
// `/src/<name>.zig`, which can never match a repo-relative file key.
|
||||
const files = new Set<string>(['src/main.zig', 'src/mylib.zig', 'examples/demo.zig']);
|
||||
for (const dot of ['.', './']) {
|
||||
const buildZon = { pathDeps: new Map([['mylib', dot]]) };
|
||||
expect(resolveZigImportInternal('examples/demo.zig', 'mylib', files, buildZon)).toBe(
|
||||
'src/mylib.zig',
|
||||
);
|
||||
}
|
||||
// …and the `src/main.zig` fallback for a root dep too.
|
||||
const buildZon = { pathDeps: new Map([['root_pkg', '.']]) };
|
||||
expect(resolveZigImportInternal('examples/demo.zig', 'root_pkg', files, buildZon)).toBe(
|
||||
'src/main.zig',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for `.path` deps that escape the repo root (`..`)', () => {
|
||||
const files = new Set<string>(['src/main.zig']);
|
||||
const buildZon = { pathDeps: new Map([['ziggit', '../ziggit']]) };
|
||||
|
|
@ -100,6 +117,54 @@ describe('parseZigBuildZon', () => {
|
|||
expect(cfg!.pathDeps.has('ziggit_pkg')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores commented-out entries and `.path` lines', () => {
|
||||
// A `// .path = "vendor/foo"` inside an entry used to be captured as a real
|
||||
// dep because the regex ran over raw source. `//` inside a `.url` string
|
||||
// must survive the strip — it is not a comment.
|
||||
const raw = `
|
||||
.{
|
||||
.dependencies = .{
|
||||
// .disabled = .{ .path = "vendor/disabled" },
|
||||
.remote = .{
|
||||
.url = "https://github.com/x/y/archive/abc.tar.gz",
|
||||
// .path = "vendor/remote-override",
|
||||
.hash = "1220abc",
|
||||
},
|
||||
.live = .{ .path = "vendor/live" }, // trailing note: .path = "nope"
|
||||
},
|
||||
}
|
||||
`;
|
||||
const cfg = parseZigBuildZon(raw);
|
||||
expect(cfg).not.toBeNull();
|
||||
expect([...cfg!.pathDeps.entries()]).toEqual([['live', 'vendor/live']]);
|
||||
});
|
||||
|
||||
it('is not derailed by braces inside comments or string literals', () => {
|
||||
// Every `{`/`}` used to count toward the block depth, so a `// }` comment or
|
||||
// a `}` inside a string closed the `.dependencies` block early and dropped
|
||||
// every dep after it.
|
||||
const raw = `
|
||||
.{
|
||||
.dependencies = .{
|
||||
.first = .{
|
||||
.url = "https://example.com/weird}name{.tar.gz",
|
||||
.hash = "1220x", // } stray brace in a comment
|
||||
},
|
||||
// } another one
|
||||
.second = .{ .path = "vendor/second" },
|
||||
.third = .{ .path = "vendor/third" },
|
||||
},
|
||||
.paths = .{ "" },
|
||||
}
|
||||
`;
|
||||
const cfg = parseZigBuildZon(raw);
|
||||
expect(cfg).not.toBeNull();
|
||||
expect([...cfg!.pathDeps.entries()]).toEqual([
|
||||
['second', 'vendor/second'],
|
||||
['third', 'vendor/third'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when no `.dependencies` block is present', () => {
|
||||
const raw = `.{ .name = "x", .version = "0.0.0", .paths = .{""} }`;
|
||||
expect(parseZigBuildZon(raw)).toBeNull();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue