diff --git a/README.md b/README.md index e26373aff..c5ea044ab 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas | C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ | | C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| 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 diff --git a/gitnexus-shared/src/language-detection.ts b/gitnexus-shared/src/language-detection.ts index 6d031d761..e805d4e4c 100644 --- a/gitnexus-shared/src/language-detection.ts +++ b/gitnexus-shared/src/language-detection.ts @@ -53,6 +53,7 @@ const EXTENSION_MAP: Record = { [SupportedLanguages.Dart]: ['.dart'], [SupportedLanguages.Vue]: ['.vue'], [SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'], + [SupportedLanguages.Zig]: ['.zig'], } satisfies Record; // Ensure exhaustiveness /** Pre-built reverse lookup: extension → language (built once at module load). */ @@ -121,6 +122,7 @@ const SYNTAX_MAP: Record = { [SupportedLanguages.Dart]: 'dart', [SupportedLanguages.Vue]: 'typescript', [SupportedLanguages.Cobol]: 'cobol', + [SupportedLanguages.Zig]: 'zig', } satisfies Record; // Ensure exhaustiveness /** Non-code file extensions → Prism-compatible syntax identifiers */ diff --git a/gitnexus-shared/src/languages.ts b/gitnexus-shared/src/languages.ts index 29edb8d1e..d16adbd1a 100644 --- a/gitnexus-shared/src/languages.ts +++ b/gitnexus-shared/src/languages.ts @@ -22,4 +22,5 @@ export enum SupportedLanguages { Vue = 'vue', /** Standalone regex processor — no tree-sitter, no LanguageProvider. */ Cobol = 'cobol', + Zig = 'zig', } diff --git a/gitnexus-shared/src/scope-resolution/language-classification.ts b/gitnexus-shared/src/scope-resolution/language-classification.ts index 10c556cda..11b737a22 100644 --- a/gitnexus-shared/src/scope-resolution/language-classification.ts +++ b/gitnexus-shared/src/scope-resolution/language-classification.ts @@ -41,6 +41,7 @@ export const LanguageClassifications: Readonly=0.35.0", "@huggingface/transformers": { "onnxruntime-node": "$onnxruntime-node" + }, + "@tree-sitter-grammars/tree-sitter-zig": { + "tree-sitter": "$tree-sitter" } }, "engines": { diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/call-extractors/configs/zig.ts new file mode 100644 index 000000000..93f3621ff --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/zig.ts @@ -0,0 +1,6 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const zigCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Zig, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts new file mode 100644 index 000000000..116755d8f --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts @@ -0,0 +1,35 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig, ClassLikeNodeLabel } from '../../class-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Zig containers (struct/enum/union) are anonymous in the grammar: + * + * const Point = struct { ... }; + * + * The binding name is the first identifier child of the parent + * variable_declaration. Walk up one level to find it. + */ +const extractZigContainerName = (node: SyntaxNode): string | undefined => { + const parent = node.parent; + if (!parent || parent.type !== 'variable_declaration') return undefined; + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child?.type === 'identifier') return child.text; + } + return undefined; +}; + +const extractZigContainerType = (node: SyntaxNode): ClassLikeNodeLabel | undefined => { + if (node.type === 'struct_declaration') return 'Struct'; + if (node.type === 'enum_declaration') return 'Enum'; + if (node.type === 'union_declaration') return 'Struct'; + return undefined; +}; + +export const zigClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Zig, + typeDeclarationNodes: ['struct_declaration', 'enum_declaration', 'union_declaration'], + extractName: extractZigContainerName, + extractType: extractZigContainerType, +}; diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 17494e7bb..3ff99b5ca 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -242,3 +242,34 @@ export const rubyExportChecker: ExportChecker = (_node, _name) => true; /** Dart: public if no leading underscore (convention, same as Python). */ export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_'); + +/** Zig declaration node types whose `pub` keyword child marks the symbol public. */ +const ZIG_DECL_TYPES = new Set(['function_declaration', 'variable_declaration']); + +/** + * Zig: walk to the enclosing decl, scan its direct children for an unnamed `pub` + * 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. + */ +export const zigExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + if (ZIG_DECL_TYPES.has(current.type)) { + for (let i = 0; i < current.childCount; i++) { + 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; + } + return false; +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts new file mode 100644 index 000000000..a5ac342b6 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts @@ -0,0 +1,57 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import type { FieldExtractionConfig } from '../generic.js'; + +/** + * Zig containers (struct/enum/union) are anonymous in tree-sitter-zig; the + * binding name is the first identifier child of the parent variable_declaration. + */ +const extractZigOwnerName = (node: SyntaxNode): string | undefined => { + const parent = node.parent; + if (!parent || parent.type !== 'variable_declaration') return undefined; + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child?.type === 'identifier') return child.text; + } + return undefined; +}; + +/** + * Container fields appear as direct children of struct_declaration / + * enum_declaration / union_declaration — there is no separate body wrapper + * in this grammar, so `bodyNodeTypes` is empty and the generic factory's + * "iterate immediate children" pass picks them up. + */ +export const zigFieldConfig: FieldExtractionConfig = { + language: SupportedLanguages.Zig, + typeDeclarationNodes: ['struct_declaration', 'enum_declaration', 'union_declaration'], + fieldNodeTypes: ['container_field'], + bodyNodeTypes: [], + defaultVisibility: 'public', + extractOwnerName: extractZigOwnerName, + + extractName(node) { + const name = node.childForFieldName('name'); + return name?.text; + }, + + extractType(node) { + const typeNode = node.childForFieldName('type'); + return typeNode?.text?.trim(); + }, + + extractVisibility() { + // Zig has no per-field visibility — fields inherit the container's + // module-level visibility. Treat as public; the export checker decides + // what the *container* exposes. + return 'public'; + }, + + isStatic() { + return false; + }, + + isReadonly() { + return false; + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/generic.ts b/gitnexus/src/core/ingestion/field-extractors/generic.ts index d102ca1a8..51b61dc1f 100644 --- a/gitnexus/src/core/ingestion/field-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -148,6 +148,13 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac if (result.length === 0 && bodyField) { result.push(bodyField); } + // Last resort: when no body wrapper exists (e.g. tree-sitter-zig's + // struct_declaration directly contains its container_field children), + // use the type-declaration node itself as the body. The downstream + // walk filters by `fieldNodeTypes`, so unrelated children are ignored. + if (result.length === 0 && bodyNodeSet.size === 0) { + result.push(node); + } return result; } diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts new file mode 100644 index 000000000..de325d97f --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts @@ -0,0 +1,34 @@ +/** + * Zig import resolution. + * + * v1 scope: only `@import("./foo.zig")`-style local-file imports. Standard + * library and external packages (`@import("std")`, `@import("mod")`) are + * treated as external — return an empty result so they don't produce ghost + * import edges. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { resolveStandard } from '../standard.js'; + +const stripQuotes = (s: string): string => s.replace(/^['"]|['"]$/g, ''); + +export const zigImportStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => { + const stripped = stripQuotes(rawImportPath); + + // Local-file imports always reference a `.zig` path. Anything else + // (`std`, `builtin`, package names) is external — stop the chain. + if (!stripped.endsWith('.zig')) { + return { kind: 'files', files: [] }; + } + + // Treat as relative to the importing file. tree-sitter-zig captures the + // string with surrounding quotes; resolveStandard handles `./` and `../`. + const relPath = stripped.startsWith('.') ? stripped : './' + stripped; + return resolveStandard(relPath, filePath, ctx, SupportedLanguages.Zig); +}; + +export const zigImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Zig, + strategies: [zigImportStrategy], +}; diff --git a/gitnexus/src/core/ingestion/languages/index.ts b/gitnexus/src/core/ingestion/languages/index.ts index a67f8bba2..d36c70d9f 100644 --- a/gitnexus/src/core/ingestion/languages/index.ts +++ b/gitnexus/src/core/ingestion/languages/index.ts @@ -25,6 +25,7 @@ import { swiftProvider } from './swift.js'; import { dartProvider } from './dart.js'; import { vueProvider } from './vue.js'; import { cobolProvider } from './cobol.js'; +import { zigProvider } from './zig.js'; export const providers = { [SupportedLanguages.JavaScript]: javascriptProvider, @@ -43,6 +44,7 @@ export const providers = { [SupportedLanguages.Dart]: dartProvider, [SupportedLanguages.Vue]: vueProvider, [SupportedLanguages.Cobol]: cobolProvider, + [SupportedLanguages.Zig]: zigProvider, } satisfies Record; /** Get provider by language enum (always succeeds for SupportedLanguages). */ diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts new file mode 100644 index 000000000..5b8472d17 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -0,0 +1,51 @@ +/** + * Zig Language Provider. + * + * Key Zig traits: + * - mroStrategy: default 'first-wins' is irrelevant — Zig has no inheritance, + * and no heritage hooks are provided (Zig queries never produce + * `@heritage.*` captures). + * - exportChecker: walks to the enclosing variable_declaration / + * function_declaration and looks for a `pub` keyword child. + * - importResolver: only resolves local `@import("./foo.zig")` paths; + * `@import("std")` and external packages are deliberately external. + * - namedBindingExtractor: omitted — `const Foo = @import("x").Foo` is a + * const declaration, not import-statement syntax. + * - scope-resolution hooks (emitScopeCaptures, interpretImport, …) are + * omitted — Zig is classified `experimental` and uses the generic + * fallback resolution path. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import { defineLanguage } from '../language-provider.js'; +import { ZIG_QUERIES } from '../tree-sitter-queries.js'; +import { zigExportChecker } from '../export-detection.js'; +import { createImportResolver } from '../import-resolvers/resolver-factory.js'; +import { zigImportConfig } from '../import-resolvers/configs/zig.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { zigCallConfig } from '../call-extractors/configs/zig.js'; +import { createClassExtractor } from '../class-extractors/generic.js'; +import { zigClassConfig } from '../class-extractors/configs/zig.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { zigFieldConfig } from '../field-extractors/configs/zig.js'; +import { createMethodExtractor } from '../method-extractors/generic.js'; +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'; + +export const zigProvider = defineLanguage({ + id: SupportedLanguages.Zig, + extensions: ['.zig'], + entryPointPatterns: [/^main$/], + astFrameworkPatterns: [], + treeSitterQueries: ZIG_QUERIES, + typeConfig: zigTypeConfig, + exportChecker: zigExportChecker, + importResolver: createImportResolver(zigImportConfig), + callExtractor: createCallExtractor(zigCallConfig), + classExtractor: createClassExtractor(zigClassConfig), + fieldExtractor: createFieldExtractor(zigFieldConfig), + methodExtractor: createMethodExtractor(zigMethodConfig), + variableExtractor: createVariableExtractor(zigVariableConfig), +}); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts new file mode 100644 index 000000000..8844c9e1a --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts @@ -0,0 +1,106 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { MethodExtractionConfig, ParameterInfo } from '../../method-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Zig method extraction. + * + * tree-sitter-zig containers (struct/enum/union) are anonymous; the binding + * name lives on the parent variable_declaration. Methods inside a container + * appear as plain `function_declaration` children of the container node. + * + * The first parameter is the receiver iff it is named `self` (convention) — + * unlike Rust, Zig has no dedicated `self_parameter` node type. + */ + +const extractZigOwnerName = (node: SyntaxNode): string | undefined => { + const parent = node.parent; + if (!parent || parent.type !== 'variable_declaration') return undefined; + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child?.type === 'identifier') return child.text; + } + return undefined; +}; + +const extractZigName = (node: SyntaxNode): string | undefined => { + const nameNode = node.childForFieldName('name'); + return nameNode?.text; +}; + +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). + const typeNode = node.childForFieldName('type'); + return typeNode?.text?.trim(); +}; + +const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => { + const paramList = node.childForFieldName('parameters'); + if (!paramList) return []; + const params: ParameterInfo[] = []; + 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'); + params.push({ + name: nameNode?.text ?? '?', + type: typeNode?.text?.trim() ?? null, + rawType: typeNode?.text?.trim() ?? null, + isOptional: false, + isVariadic: false, + }); + } + return params; +}; + +const hasPubKeyword = (node: SyntaxNode): boolean => { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.type === 'pub') return true; + } + return false; +}; + +const extractZigReceiverType = (node: SyntaxNode): string | undefined => { + const paramList = node.childForFieldName('parameters'); + if (!paramList) return undefined; + const first = paramList.namedChild(0); + if (!first || first.type !== 'parameter') return undefined; + const nameNode = first.childForFieldName('name'); + if (nameNode?.text !== 'self') return undefined; + const typeNode = first.childForFieldName('type'); + return typeNode?.text?.trim(); +}; + +export const zigMethodConfig: MethodExtractionConfig = { + language: SupportedLanguages.Zig, + typeDeclarationNodes: ['struct_declaration', 'enum_declaration', 'union_declaration'], + methodNodeTypes: ['function_declaration'], + bodyNodeTypes: [], + extractOwnerName: extractZigOwnerName, + extractName: extractZigName, + extractReturnType: extractZigReturnType, + extractParameters: extractZigParameters, + extractVisibility: (node) => (hasPubKeyword(node) ? 'public' : 'private'), + extractReceiverType: extractZigReceiverType, + + isStatic(node) { + // A Zig "method" is effectively static if its first parameter is not `self`. + const paramList = node.childForFieldName('parameters'); + if (!paramList) return true; + const first = paramList.namedChild(0); + if (!first || first.type !== 'parameter') return true; + const nameNode = first.childForFieldName('name'); + return nameNode?.text !== 'self'; + }, + + isAbstract() { + return false; + }, + + isFinal() { + return false; + }, +}; diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index e1a10f825..2b1fe5747 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -166,6 +166,13 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set): SyntaxNode[] { result.push(bodyField); addNestedBodies(bodyField, bodyNodeSet, result); } + // Last resort: when no body wrapper exists (e.g. tree-sitter-zig's + // struct_declaration directly contains its function_declaration children), + // use the type-declaration node itself as the body. The downstream walk + // filters by `methodNodeTypes`, so unrelated children are ignored. + if (result.length === 0 && bodyNodeSet.size === 0) { + result.push(node); + } return result; } diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index bd0ae6700..aa495d144 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -2447,6 +2447,58 @@ export const DART_QUERIES = ` right: (_)) @assignment `; +// ── Zig ────────────────────────────────────────────────────────────────────── +// Verified against @tree-sitter-grammars/tree-sitter-zig 1.1.2. +// Container declarations (struct/enum/union) are anonymous in the grammar; the +// binding name lives on the parent variable_declaration's first identifier +// child. Heritage queries are intentionally absent — Zig has no inheritance. +export const ZIG_QUERIES = ` +; Functions (top-level + methods inside struct/enum/union containers) +(function_declaration + name: (identifier) @name) @definition.function + +; Struct: const Foo = struct { ... } +(variable_declaration + (identifier) @name + (struct_declaration)) @definition.struct + +; Enum: const Foo = enum { ... } +(variable_declaration + (identifier) @name + (enum_declaration)) @definition.enum + +; Union: const Foo = union { ... } (and tagged-union union(enum) { ... }) +; Labeled as @definition.struct because CONTAINER_TYPE_TO_LABEL has no Union +; label by default; the CONTAINER_TYPE_TO_LABEL update for union_declaration +; lives in ast-helpers.ts. +(variable_declaration + (identifier) @name + (union_declaration)) @definition.struct + +; Container fields (struct fields, enum variants, union variants). +(container_field + name: (identifier) @name) @definition.property + +; @import("path") — capture the string argument as @import.source. +; The #eq? predicate restricts the match to the @import builtin (other +; builtins like @sizeOf, @TypeOf, @as are not import statements). +(variable_declaration + (builtin_function + (builtin_identifier) @builtin + (arguments + (string) @import.source)) + (#eq? @builtin "@import")) @import + +; Free calls: foo(...) +(call_expression + function: (identifier) @call.name) @call + +; Member calls: obj.method(...) and namespace.fn(...) (e.g. std.debug.print). +(call_expression + function: (field_expression + member: (identifier) @call.name)) @call +`; + import { SupportedLanguages } from 'gitnexus-shared'; export const LANGUAGE_QUERIES: Record = { @@ -2466,4 +2518,5 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.Dart]: DART_QUERIES, [SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue