diff --git a/README.md b/README.md index 999f352ba..e0056194b 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -714,6 +714,7 @@ is written): | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | | R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) | | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | +| Nix | `.nix` | Full support | ## Measured cross-file coverage diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 2f37d25ab..a4127150f 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -102,6 +102,12 @@ describe('Language Detection', () => { expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c'); }); + it('should detect Nix files', () => { + expect(detectLanguage('default.nix')).toBe('nix'); + expect(detectLanguage('pkgs/development/tools/misc/codegraph/default.nix')).toBe('nix'); + expect(isSourceFile('default.nix')).toBe(true); + }); + it('should return unknown for unsupported extensions', () => { expect(detectLanguage('styles.css')).toBe('unknown'); expect(detectLanguage('data.json')).toBe('unknown'); @@ -130,6 +136,105 @@ describe('Language Support', () => { expect(languages).toContain('swift'); expect(languages).toContain('kotlin'); expect(languages).toContain('dart'); + expect(languages).toContain('nix'); + }); +}); + +describe('Nix Extraction', () => { + it('should distinguish Nix variable and function bindings', () => { + const code = ` +let + plainValue = 10; + simpleFn = arg: arg + 1; + destructuredFn = { lib, stdenv }: lib.getName stdenv; + curriedFn = a: b: builtins.toString (a + b); +in +{ + exportedValue = plainValue; + exportedFn = curriedFn; +} +`; + + const result = extractFromSource('default.nix', code); + + expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'plainValue')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'exportedValue')).toBeDefined(); + + const simpleFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'simpleFn'); + const destructuredFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'destructuredFn'); + const curriedFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'curriedFn'); + + expect(simpleFn?.signature).toBe('(arg)'); + expect(destructuredFn?.signature).toBe('{ lib, stdenv }'); + expect(curriedFn?.signature).toBe('a : b'); + + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('lib.getName'); + expect(calls.filter((name) => name === 'builtins.toString')).toHaveLength(1); + }); + + it('should extract inherited Nix attributes as variables', () => { + const code = ` +let + inherit lib; + inherit (pkgs) stdenv writeShellScriptBin; +in +stdenv.mkDerivation {} +`; + + const result = extractFromSource('default.nix', code); + const variables = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name); + + expect(variables).toContain('lib'); + expect(variables).toContain('stdenv'); + expect(variables).toContain('writeShellScriptBin'); + }); + + it('should emit only static project path imports for Nix import calls', () => { + const code = ` +let + local = import ./x.nix; + defaultFile = builtins.import ./dir; + packageSet = import {}; + fromSources = import sources.nixpkgs {}; + dynamic = import selectedPath; +in +local +`; + + const result = extractFromSource('default.nix', code); + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName); + + expect(imports).toEqual(['./x.nix', './dir']); + expect(importRefs).toEqual(['./x.nix', './dir']); + }); + + it('should mark returned top-level Nix attrset members exported and keep let or nested attrs private', () => { + const code = ` +{ lib, stdenv }: +let + localValue = 10; +in +{ + exported = localValue; + package = { name }: stdenv.mkDerivation { inherit name; }; + nested = { + privateNested = true; + }; + inherit (lib) licenses; +} +`; + + const result = extractFromSource('default.nix', code); + const node = (name: string) => result.nodes.find((n) => n.name === name); + + expect(node('localValue')?.isExported).toBe(false); + expect(node('exported')?.isExported).toBe(true); + expect(node('package')?.kind).toBe('function'); + expect(node('package')?.isExported).toBe(true); + expect(node('privateNested')?.isExported).toBe(false); + expect(node('licenses')?.isExported).toBe(true); }); }); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index a6d455499..dff4a1f9a 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3714,4 +3714,86 @@ procedure Helper; var t: TTgt; begin t.Hit; end; expect(callerNamesOf('TTgt::Hit')).toEqual(['DoStuff', 'Helper']); }); }); + + describe('Nix path import resolution', () => { + function fileNode(filePath: string) { + return cg.getNodesByKind('file').find((n) => n.filePath === filePath); + } + + function importedFilePaths(fromFile: string): string[] { + const source = fileNode(fromFile); + expect(source, `${fromFile} file node`).toBeDefined(); + return cg + .getOutgoingEdges(source!.id) + .filter((edge) => edge.kind === 'imports') + .map((edge) => cg.getNodesByKind('file').find((n) => n.id === edge.target)?.filePath) + .filter((filePath): filePath is string => Boolean(filePath)) + .sort(); + } + + it('resolves relative Nix imports to indexed file nodes', async () => { + fs.mkdirSync(path.join(tempDir, 'core'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'core', 'ports.nix'), '{ http = 80; https = 443; }'); + fs.writeFileSync( + path.join(tempDir, 'data', 'postgresql.nix'), + `let + ports = import ../core/ports.nix; +in +{ + port = ports.https; +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + expect(importedFilePaths('data/postgresql.nix')).toEqual(['core/ports.nix']); + }); + + it('resolves Nix directory imports through default.nix and deduplicates called imports', async () => { + fs.mkdirSync(path.join(tempDir, 'dir'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'dir', 'default.nix'), '{ value = 1; }'); + fs.writeFileSync(path.join(tempDir, 'x.nix'), '{ value = 2; }'); + fs.writeFileSync( + path.join(tempDir, 'main.nix'), + `let + dir = import ./dir; + x = import ./x.nix {}; +in +{ + inherit dir x; +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + expect(importedFilePaths('main.nix')).toEqual(['dir/default.nix', 'x.nix']); + }); + + it('does not resolve Nix angle-bracket, attribute, or variable imports as project file edges', async () => { + fs.writeFileSync(path.join(tempDir, 'nixpkgs.nix'), '{ bogus = true; }'); + fs.writeFileSync(path.join(tempDir, 'selectedPath.nix'), '{ bogus = true; }'); + fs.writeFileSync( + path.join(tempDir, 'main.nix'), + `let + pkgs = import {}; + fromSources = import sources.nixpkgs {}; + dynamic = import selectedPath; +in +{ + inherit pkgs fromSources dynamic; +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + expect(importedFilePaths('main.nix')).toEqual([]); + }); + }); }); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 1b15996c0..932ddd1f6 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -39,6 +39,7 @@ const WASM_GRAMMAR_FILES: Record = { r: 'tree-sitter-r.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + nix: 'tree-sitter-nix.wasm', }; /** @@ -108,6 +109,7 @@ export const EXTENSION_MAP: Record = { '.luau': 'luau', '.m': 'objc', '.mm': 'objc', + '.nix': 'nix', // XML: file-level tracking; the MyBatis extractor matches `` // shape and emits SQL-statement nodes (other XML returns empty). '.xml': 'xml', @@ -221,7 +223,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -51,4 +52,5 @@ export const EXTRACTORS: Partial> = { r: rExtractor, luau: luauExtractor, objc: objcExtractor, + nix: nixExtractor, }; diff --git a/src/extraction/languages/nix.ts b/src/extraction/languages/nix.ts new file mode 100644 index 000000000..8cec800cb --- /dev/null +++ b/src/extraction/languages/nix.ts @@ -0,0 +1,259 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +function unwrapVariableExpression(node: SyntaxNode): SyntaxNode { + if (node.type !== 'variable_expression') return node; + return node.namedChild(0) ?? node; +} + +function getCalleeName(node: SyntaxNode, source: string): string | null { + let current = node; + while (current.type === 'apply_expression') { + const funcNode = current.childForFieldName('function') || current.namedChild(0); + if (!funcNode) break; + current = funcNode; + } + current = unwrapVariableExpression(current); + if (current.type === 'identifier' || current.type === 'select_expression') { + return getNodeText(current, source).trim(); + } + return null; +} + +function getDirectCalleeName(node: SyntaxNode, source: string): string | null { + let funcNode = node.childForFieldName('function') || node.namedChild(0); + if (!funcNode) return null; + funcNode = unwrapVariableExpression(funcNode); + return getNodeText(funcNode, source).trim(); +} + +function isStaticProjectPath(value: string): boolean { + return ( + (value.startsWith('./') || value.startsWith('../')) && + !/[\s{}()[\];"'<>$]/.test(value) + ); +} + +function getStaticImportPath(argNode: SyntaxNode, source: string): string | null { + let current = argNode; + while (current.type === 'parenthesized_expression') { + const inner = current.namedChild(0); + if (!inner) break; + current = inner; + } + + let text = getNodeText(current, source).trim(); + if ( + ((text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'"))) && + text.length >= 2 + ) { + text = text.slice(1, -1); + } + + return isStaticProjectPath(text) ? text : null; +} + +function isReturnedAttrsetMember(node: SyntaxNode): boolean { + let current: SyntaxNode | null = node; + let seenReturnedAttrset = false; + + while (current) { + const parent: SyntaxNode | null = current.parent; + if (!parent) break; + + if (parent.type === 'let_expression') { + const bodyNode = parent.childForFieldName('body') || parent.childForFieldName('expression'); + if (!bodyNode || !bodyNode.equals(current)) return false; + } + + if (parent.type === 'binding' && !current.equals(node)) return false; + if (parent.type === 'formal_parameters' || parent.type === 'formals') return false; + + if ( + parent.type === 'attrset' || + parent.type === 'rec_attrset' || + parent.type === 'attrset_expression' || + parent.type === 'rec_attrset_expression' + ) { + seenReturnedAttrset = true; + } + + current = parent; + } + + return seenReturnedAttrset; +} + +function getCurriedParamsAndBody(node: SyntaxNode, source: string): { params: string[]; bodyNode: SyntaxNode | null } { + const params: string[] = []; + let current = node; + + while (current.type === 'function_expression' && current.namedChildCount > 0) { + const bodyNode = current.namedChild(current.namedChildCount - 1); + if (!bodyNode) break; + + const paramPart = source.substring(current.startIndex, bodyNode.startIndex).trim(); + const paramText = paramPart.endsWith(':') ? paramPart.slice(0, -1).trim() : paramPart; + if (paramText) params.push(paramText); + + if (bodyNode.type === 'function_expression') { + current = bodyNode; + } else { + return { params, bodyNode }; + } + } + + return { + params, + bodyNode: current.namedChildCount > 0 ? current.namedChild(current.namedChildCount - 1) : null, + }; +} + +function formatFunctionSignature(params: string[]): string { + if (params.length === 0) return '()'; + if (params.length > 1) return params.join(' : '); + + const [param] = params; + if (!param) return '()'; + return param.startsWith('(') || param.includes('{') || param.includes('@') ? param : `(${param})`; +} + +function inheritedAttrs(node: SyntaxNode): SyntaxNode | null { + return node.namedChildren.find((child) => child.type === 'inherited_attrs') ?? null; +} + +export const nixExtractor: LanguageExtractor = { + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + callTypes: [], + variableTypes: [], + nameField: '', + bodyField: '', + paramsField: '', + + visitNode: (node, ctx) => { + const { source } = ctx; + + if (node.type === 'binding') { + const attrpath = node.childForFieldName('attrpath') || node.namedChild(0); + if (!attrpath) return false; + + const name = getNodeText(attrpath, source).trim(); + if (!name) return false; + + const valueNode = node.childForFieldName('expression') || node.childForFieldName('value') || node.namedChild(1); + if (!valueNode) return false; + + if (valueNode.type === 'function_expression') { + const { params, bodyNode } = getCurriedParamsAndBody(valueNode, source); + const funcNode = ctx.createNode('function', name, node, { + signature: formatFunctionSignature(params), + isExported: isReturnedAttrsetMember(node), + }); + + if (funcNode) { + ctx.pushScope(funcNode.id); + if (bodyNode) ctx.visitNode(bodyNode); + ctx.popScope(); + } + } else { + const initValue = getNodeText(valueNode, source).slice(0, 100); + ctx.createNode('variable', name, node, { + signature: initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined, + isExported: isReturnedAttrsetMember(node), + }); + ctx.visitNode(valueNode); + } + + return true; + } + + if (node.type === 'function_expression') { + const bodyNode = node.namedChild(node.namedChildCount - 1); + if (bodyNode) ctx.visitNode(bodyNode); + return true; + } + + if (node.type === 'inherit' || node.type === 'inherit_from') { + const attrs = inheritedAttrs(node); + if (attrs) { + for (const child of attrs.namedChildren) { + const name = getNodeText(child, source).trim(); + if (name) { + ctx.createNode('variable', name, child, { + isExported: isReturnedAttrsetMember(child), + }); + } + } + } + + for (const child of node.namedChildren) { + if (child.type !== 'inherited_attrs') ctx.visitNode(child); + } + return true; + } + + if (node.type === 'apply_expression') { + const directCallee = getDirectCalleeName(node, source); + const isDirectImport = directCallee === 'import' || directCallee === 'builtins.import'; + const isCalleeOfParent = + node.parent?.type === 'apply_expression' && + (node.parent.childForFieldName('function') === node || node.parent.namedChild(0) === node); + + if (!(isCalleeOfParent && !isDirectImport)) { + if (isDirectImport) { + const argNode = node.childForFieldName('argument') || node.namedChild(1); + const importPath = argNode ? getStaticImportPath(argNode, source) : null; + + if (importPath) { + const impNode = ctx.createNode('import', importPath, node, { + signature: getNodeText(node, source).trim().slice(0, 100), + }); + + if (impNode && ctx.nodeStack.length > 0) { + const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (fromNodeId) { + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: importPath, + referenceKind: 'imports', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + } + } else { + const calleeName = getCalleeName(node, source); + if (calleeName && calleeName !== 'import' && calleeName !== 'builtins.import' && ctx.nodeStack.length > 0) { + const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (fromNodeId) { + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: calleeName, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + } + } + + for (const child of node.namedChildren) { + ctx.visitNode(child); + } + return true; + } + + return false; + }, +}; diff --git a/src/extraction/wasm/tree-sitter-nix.wasm b/src/extraction/wasm/tree-sitter-nix.wasm new file mode 100755 index 000000000..fb541ab65 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-nix.wasm differ diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index badbe4b02..454eae504 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -35,8 +35,18 @@ const EXTENSION_RESOLUTION: Record = { php: ['.php'], ruby: ['.rb'], objc: ['.h', '.m', '.mm'], + nix: ['.nix', '/default.nix'], }; +export function isNixPathImportRef(ref: UnresolvedRef): boolean { + return ( + ref.language === 'nix' && + ref.referenceKind === 'imports' && + (ref.referenceName.startsWith('./') || ref.referenceName.startsWith('../')) && + !/[\s{}()[\];"'<>$]/.test(ref.referenceName) + ); +} + /** * Resolve an import path to an actual file */ @@ -1195,6 +1205,30 @@ export function resolveViaImport( return null; } + // Nix static project-path imports (`import ./x.nix`, `builtins.import ./dir`, + // `import ./x.nix {}`) resolve to file nodes only. Do not resolve + // angle-bracket channels, attribute expressions, variables, or other dynamic + // expressions as project files. + if (isNixPathImportRef(ref)) { + const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context); + if (!resolvedPath) return null; + + const basename = resolvedPath.split('/').pop()!; + const fileNode = context + .getNodesByName(basename) + .find((n) => n.kind === 'file' && n.filePath === resolvedPath); + + if (fileNode) { + return { + original: ref, + targetNodeId: fileNode.id, + confidence: 0.9, + resolvedBy: 'import', + }; + } + return null; + } + // Use cached import mappings (avoids re-reading and re-parsing per ref) const imports = context.getImportMappings(ref.filePath, ref.language); if (imports.length === 0 && !context.readFile(ref.filePath)) { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 0d7ec4309..ca84204bd 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -17,7 +17,7 @@ import { ImportMapping, } from './types'; import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher'; -import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef } from './import-resolver'; +import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isNixPathImportRef } from './import-resolver'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; import { loadProjectAliases, type AliasMap } from './path-aliases'; @@ -664,6 +664,15 @@ export class ReferenceResolver { return null; } + const isPathImportRef = + ref.referenceKind === 'imports' && + ( + ref.language === 'c' || + ref.language === 'cpp' || + isPhpIncludePathRef(ref) || + isNixPathImportRef(ref) + ); + // Fast pre-filter: skip if no symbol with this name exists anywhere // AND the name doesn't match a local import. The import escape is // necessary because re-export rename chains (`import { login } @@ -671,6 +680,7 @@ export class ReferenceResolver { // from './auth'`) intentionally call a name that has no // declaration anywhere — only the renamed upstream symbol does. if ( + !isPathImportRef && !this.hasAnyPossibleMatch(ref.referenceName) && !this.matchesAnyImport(ref) && !this.frameworks.some((f) => f.claimsReference?.(ref.referenceName)) diff --git a/src/types.ts b/src/types.ts index a3122bf9a..904a6ee7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,6 +91,7 @@ export const LANGUAGES = [ 'luau', 'objc', 'r', + 'nix', 'yaml', 'twig', 'xml',