diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 92717759..60b79fcd 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3895,3 +3895,170 @@ local count = 0 }); }); }); + +// ============================================================================= +// Nix Extraction +// ============================================================================= + +describe('Nix Extraction', () => { + describe('Language detection', () => { + it('should detect Nix files', () => { + expect(detectLanguage('default.nix')).toBe('nix'); + expect(detectLanguage('pkgs/development/tools/misc/codegraph/default.nix')).toBe('nix'); + }); + + it('should report Nix as supported', () => { + expect(isLanguageSupported('nix')).toBe(true); + expect(getSupportedLanguages()).toContain('nix'); + }); + }); + + describe('Variables and Functions', () => { + it('should extract variables and functions from bindings', () => { + const code = ` + let + x = 10; + y = arg: arg + 1; + z = { name }: "Hello " + name; + in + { + a = x; + b = y; + } + `; + const result = extractFromSource('test.nix', code); + + const variables = result.nodes.filter(n => n.kind === 'variable').map(n => n.name); + const functions = result.nodes.filter(n => n.kind === 'function').map(n => n.name); + + expect(variables).toContain('x'); + expect(variables).toContain('a'); + expect(variables).toContain('b'); + + expect(functions).toContain('y'); + expect(functions).toContain('z'); + + const yFunc = result.nodes.find(n => n.name === 'y'); + expect(yFunc?.signature).toBe('(arg)'); + + const zFunc = result.nodes.find(n => n.name === 'z'); + expect(zFunc?.signature).toBe('{ name }'); + }); + + it('should handle curried functions and destructuring patterns', () => { + const code = ` + let + curried = a: b: c: a + b + c; + destruct = { x, y } @ args: someCall x y; + destructPrefix = args @ { x, y }: otherCall x y; + in + { + f1 = curried; + f2 = destruct; + f3 = destructPrefix; + } + `; + const result = extractFromSource('test.nix', code); + + const functions = result.nodes.filter(n => n.kind === 'function').map(n => n.name); + expect(functions).toContain('curried'); + expect(functions).toContain('destruct'); + expect(functions).toContain('destructPrefix'); + + const curriedFunc = result.nodes.find(n => n.name === 'curried'); + expect(curriedFunc?.signature).toBe('a : b : c'); + + const destructFunc = result.nodes.find(n => n.name === 'destruct'); + expect(destructFunc?.signature).toBe('{ x, y } @ args'); + + const destructPrefixFunc = result.nodes.find(n => n.name === 'destructPrefix'); + expect(destructPrefixFunc?.signature).toBe('args @ { x, y }'); + + // Verify that call references inside destructured functions are correctly extracted + // because we traversed their bodies (last named child) rather than mistaking 'args' or '{ x, y }' as the body. + const calls = result.unresolvedReferences.filter(r => r.referenceKind === 'calls').map(r => r.referenceName); + expect(calls).toContain('someCall'); + expect(calls).toContain('otherCall'); + }); + }); + + describe('Inherits', () => { + it('should extract inherited attributes as variables', () => { + const code = ` + let + inherit (pkgs) lib stdenv; + inherit writeShellScriptBin; + in + stdenv.mkDerivation {} + `; + const result = extractFromSource('test.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'); + }); + }); + + describe('Imports and Calls', () => { + it('should extract import statements and function calls', () => { + const code = ` + let + pkgs = import {}; + myLib = import ./lib.nix; + someVal = pkgs.lib.mkIf true "val"; + curried = map (x: x + 1) [ 1 2 3 ]; + in + someVal + `; + const result = extractFromSource('test.nix', code); + + const imports = result.nodes.filter(n => n.kind === 'import').map(n => n.name); + expect(imports).toContain(''); + expect(imports).toContain('./lib.nix'); + + const callRefs = result.unresolvedReferences.filter(r => r.referenceKind === 'calls').map(r => r.referenceName); + expect(callRefs).toContain('pkgs.lib.mkIf'); + expect(callRefs).toContain('map'); + + const importRefs = result.unresolvedReferences.filter(r => r.referenceKind === 'imports').map(r => r.referenceName); + expect(importRefs).toContain(''); + expect(importRefs).toContain('./lib.nix'); + }); + }); + + describe('Exports and Scopes', () => { + it('should identify top-level attributes as exported and let-bindings/nested attributes as private', () => { + const code = ` + let + localVal = 10; + in + { + exportedVal = localVal; + exportedFunc = x: x + 1; + nestedAttr = { + privateVal = 20; + }; + inherit (pkgs) exportedInherit; + } + `; + const result = extractFromSource('test.nix', code); + + const localVal = result.nodes.find(n => n.name === 'localVal'); + expect(localVal?.isExported).toBe(false); + + const exportedVal = result.nodes.find(n => n.name === 'exportedVal'); + expect(exportedVal?.isExported).toBe(true); + + const exportedFunc = result.nodes.find(n => n.name === 'exportedFunc'); + expect(exportedFunc?.isExported).toBe(true); + + const privateVal = result.nodes.find(n => n.name === 'privateVal'); + expect(privateVal?.isExported).toBe(false); + + const exportedInherit = result.nodes.find(n => n.name === 'exportedInherit'); + expect(exportedInherit?.isExported).toBe(true); + }); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 1ca3a3f8..c4906e0f 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -846,4 +846,52 @@ def bootstrap(): expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); }); }); + + describe('Nix Import Path Resolution', () => { + it('resolves relative Nix imports to file nodes', async () => { + // Create a Nix project layout + const coreDir = path.join(tempDir, 'core'); + const dataDir = path.join(tempDir, 'data'); + fs.mkdirSync(coreDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + + // Create core/ports.nix + fs.writeFileSync( + path.join(coreDir, 'ports.nix'), + `{ + http = 80; + https = 443; + }` + ); + + // Create data/postgresql.nix that imports core/ports.nix + fs.writeFileSync( + path.join(dataDir, 'postgresql.nix'), + `let + ports = import ../core/ports.nix; + in + { + port = ports.https; + }` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + // Find the file node for postgresql.nix + const postgresqlFileNode = cg.getNodesByKind('file').find((n) => n.filePath === 'data/postgresql.nix'); + expect(postgresqlFileNode).toBeDefined(); + + // Find outgoing edges from postgresql.nix + // (The import expression inside data/postgresql.nix is contained by the file, so it should resolve to core/ports.nix file node) + const outgoing = cg.getOutgoingEdges(postgresqlFileNode!.id); + const importEdge = outgoing.find((e) => e.kind === 'imports'); + expect(importEdge).toBeDefined(); + + const targetNode = cg.getNodesByKind('file').find((n) => n.id === importEdge!.target); + expect(targetNode).toBeDefined(); + expect(targetNode?.kind).toBe('file'); + expect(targetNode?.filePath).toBe('core/ports.nix'); + }); + }); }); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index c78c52ce..b51e5b3d 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -37,6 +37,7 @@ const WASM_GRAMMAR_FILES: Record = { scala: 'tree-sitter-scala.wasm', lua: 'tree-sitter-lua.wasm', luau: 'tree-sitter-luau.wasm', + nix: 'tree-sitter-nix.wasm', }; /** @@ -92,6 +93,7 @@ export const EXTENSION_MAP: Record = { '.sc': 'scala', '.lua': 'lua', '.luau': 'luau', + '.nix': 'nix', }; /** @@ -155,7 +157,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -47,4 +48,5 @@ export const EXTRACTORS: Partial> = { scala: scalaExtractor, lua: luaExtractor, luau: luauExtractor, + nix: nixExtractor, }; diff --git a/src/extraction/languages/nix.ts b/src/extraction/languages/nix.ts new file mode 100644 index 00000000..fdcd7e1d --- /dev/null +++ b/src/extraction/languages/nix.ts @@ -0,0 +1,288 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +/** Helper to get callee name of an apply_expression */ +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; + } + if (current.type === 'variable_expression') { + const inner = current.namedChild(0); + if (inner) current = inner; + } + if (current.type === 'identifier' || current.type === 'select_expression') { + return getNodeText(current, source).trim(); + } + return null; +} + +/** Helper to get direct callee name of an apply_expression without unwinding all applications */ +function getDirectCalleeName(node: SyntaxNode, source: string): string | null { + let funcNode = node.childForFieldName('function') || node.namedChild(0); + if (!funcNode) return null; + if (funcNode.type === 'variable_expression') { + const inner = funcNode.namedChild(0); + if (inner) funcNode = inner; + } + return getNodeText(funcNode, source).trim(); +} + +/** Helper to get argument value for an import call */ +function getImportPath(argNode: SyntaxNode, source: string): string | null { + let current = argNode; + while (current.type === 'parenthesized_expression') { + const inner = current.namedChild(0); + if (!inner) break; + current = inner; + } + const text = getNodeText(current, source).trim(); + if (text.startsWith('"') && text.endsWith('"')) { + return text.slice(1, -1); + } + if (text.startsWith("'") && text.endsWith("'")) { + return text.slice(1, -1); + } + return text; +} + +/** Helper to determine if a Nix binding or inherit attribute is exported at the top-level of the file */ +function isExportedNode(node: SyntaxNode): boolean { + let current: SyntaxNode | null = node; + let insideAttrSet = false; + + while (current) { + const parent: SyntaxNode | null = current.parent; + if (!parent) break; + + const parentType = parent.type; + + // Let bindings are local definitions, unless they are inside the let body (expression) + if (parentType === 'let_expression') { + const bodyNode = parent.childForFieldName('body') || parent.childForFieldName('expression'); + if (!bodyNode || !bodyNode.equals(current)) { + return false; + } + } + + // Value nested inside another binding attribute (e.g. nested attribute sets) + if (parentType === 'binding' && !current.equals(node)) { + return false; + } + + // Function parameter lists + if (parentType === 'formal_parameters' || parentType === 'formals') { + return false; + } + + // Attribute sets represent exported scopes if at the top level + if ( + parentType === 'attrset' || + parentType === 'rec_attrset' || + parentType === 'attrset_expression' || + parentType === 'rec_attrset_expression' + ) { + insideAttrSet = true; + } + + current = parent; + } + + return insideAttrSet; +} + +/** Helper to traverse nested function_expressions and collect curried parameters and the final body node */ +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; + + // Slice the parameter part: everything before the bodyNode + const paramPart = source.substring(current.startIndex, bodyNode.startIndex).trim(); + // Remove the trailing colon + 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 }; +} + +export const nixExtractor: LanguageExtractor = { + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + callTypes: [], + variableTypes: [], + nameField: '', + bodyField: '', + paramsField: '', + + visitNode: (node, ctx) => { + const source = ctx.source; + const type = node.type; + + // 1. Handle bindings: x = value; + if (type === 'binding') { + const attrpath = node.childForFieldName('attrpath') || node.namedChild(0); + if (!attrpath) return false; + const name = getNodeText(attrpath, source).trim(); + if (!name) return false; + + // Find the value node + const valueNode = node.childForFieldName('expression') || node.childForFieldName('value') || node.namedChild(1); + if (!valueNode) return false; + + if (valueNode.type === 'function_expression') { + // It's a function definition! + const { params, bodyNode } = getCurriedParamsAndBody(valueNode, source); + + let signature = '()'; + if (params.length > 0) { + if (params.length === 1) { + const paramText = params[0]; + if (paramText) { + signature = paramText.startsWith('(') || paramText.includes('{') || paramText.includes('@') ? paramText : `(${paramText})`; + } + } else { + signature = params.join(' : '); + } + } + + const funcNode = ctx.createNode('function', name, node, { signature, isExported: isExportedNode(node) }); + if (funcNode) { + ctx.pushScope(funcNode.id); + if (bodyNode) { + ctx.visitNode(bodyNode); + } + ctx.popScope(); + } + } else { + // It's a variable definition! + const initValue = getNodeText(valueNode, source).slice(0, 100); + const signature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; + + ctx.createNode('variable', name, node, { signature, isExported: isExportedNode(node) }); + // Still visit the value node to extract any nested calls/imports in it! + ctx.visitNode(valueNode); + } + return true; + } + + // 2. Handle anonymous or top-level function_expressions (not in a binding) + if (type === 'function_expression') { + const bodyNode = node.namedChild(node.namedChildCount - 1); + if (bodyNode) { + ctx.visitNode(bodyNode); + } + return true; + } + + // 3. Handle inherits: inherit (pkgs) lib; or inherit lib; + if (type === 'inherit' || type === 'inherit_from') { + const inheritedAttrsNode = node.namedChildren.find(c => c.type === 'inherited_attrs'); + if (inheritedAttrsNode) { + for (let i = 0; i < inheritedAttrsNode.namedChildCount; i++) { + const child = inheritedAttrsNode.namedChild(i); + if (child) { + const name = getNodeText(child, source).trim(); + if (name) { + ctx.createNode('variable', name, child, { isExported: isExportedNode(child) }); + } + } + } + } + // Also visit other children (e.g. the variable expression pkgs in inherit_from) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type !== 'inherited_attrs') { + ctx.visitNode(child); + } + } + return true; + } + + // 4. Handle apply_expressions (calls and imports) + if (type === 'apply_expression') { + const directCallee = getDirectCalleeName(node, source); + const isDirectImport = directCallee === 'import' || directCallee === 'builtins.import'; + + // Skip inner curried application nodes to avoid registering duplicate calls to the same function. + // Exception: do NOT skip if this node is a direct import call, because we need to extract the import from it. + const isCalleeOfParent = node.parent?.type === 'apply_expression' && + (node.parent.childForFieldName('function') === node || node.parent.namedChild(0) === node); + + const shouldSkip = isCalleeOfParent && !isDirectImport; + + if (!shouldSkip) { + if (isDirectImport) { + const argNode = node.childForFieldName('argument') || node.namedChild(1); + if (argNode) { + const pathText = getImportPath(argNode, source); + if (pathText) { + const impNode = ctx.createNode('import', pathText, node, { + signature: getNodeText(node, source).trim().slice(0, 100), + }); + if (impNode && ctx.nodeStack.length > 0) { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (parentId) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: pathText, + referenceKind: 'imports', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + } + } + } else { + // Standard function call + const calleeName = getCalleeName(node, source); + const isImportCall = calleeName === 'import' || calleeName === 'builtins.import'; + + if (calleeName && !isImportCall) { + if (ctx.nodeStack.length > 0) { + const callerId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (callerId) { + ctx.addUnresolvedReference({ + fromNodeId: callerId, + referenceName: calleeName, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + } + } + } + + // Manually visit children so nested calls/imports in the argument are processed + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) 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 00000000..fb541ab6 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 5b41a57d..cd0b0566 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -24,6 +24,7 @@ const EXTENSION_RESOLUTION: Record = { csharp: ['.cs'], php: ['.php'], ruby: ['.rb'], + nix: ['.nix', '/default.nix'], }; /** diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 34aa4b90..2fd76f48 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -17,7 +17,7 @@ import { ImportMapping, } from './types'; import { matchReference } from './name-matcher'; -import { resolveViaImport, extractImportMappings, extractReExports } from './import-resolver'; +import { resolveViaImport, extractImportMappings, extractReExports, resolveImportPath } from './import-resolver'; import { detectFrameworks } from './frameworks'; import { loadProjectAliases, type AliasMap } from './path-aliases'; import { logDebug } from '../errors'; @@ -453,13 +453,38 @@ export class ReferenceResolver { return null; } + // Special resolution for file-level imports (e.g. Nix, Liquid) + if (ref.referenceKind === 'imports') { + const resolvedPath = resolveImportPath( + ref.referenceName, + ref.filePath, + ref.language, + this.context + ); + if (resolvedPath) { + const targetNodeId = `file:${resolvedPath}`; + if (this.context.fileExists(resolvedPath)) { + return { + original: ref, + targetNodeId, + confidence: 0.95, + resolvedBy: 'file-path', + }; + } + } + } + // 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 } // from './barrel'` where the barrel has `export { signIn as login } // from './auth'`) intentionally call a name that has no // declaration anywhere — only the renamed upstream symbol does. - if (!this.hasAnyPossibleMatch(ref.referenceName) && !this.matchesAnyImport(ref)) { + if ( + ref.referenceKind !== 'imports' && + !this.hasAnyPossibleMatch(ref.referenceName) && + !this.matchesAnyImport(ref) + ) { return null; } diff --git a/src/types.ts b/src/types.ts index 0168665d..3d72bb5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,6 +89,7 @@ export const LANGUAGES = [ 'luau', 'yaml', 'twig', + 'nix', 'unknown', ] as const;