From 7af1fed7606099ce2f4f5f557e4f7771ce601c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20Mulet?= Date: Fri, 22 May 2026 15:05:52 +0200 Subject: [PATCH 1/3] feat: add Nix language support and symbol extraction --- __tests__/extraction.test.ts | 97 +++++++++++ src/extraction/grammars.ts | 5 +- src/extraction/languages/index.ts | 2 + src/extraction/languages/nix.ts | 210 +++++++++++++++++++++++ src/extraction/wasm/tree-sitter-nix.wasm | Bin 0 -> 81459 bytes src/types.ts | 1 + 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/extraction/languages/nix.ts create mode 100755 src/extraction/wasm/tree-sitter-nix.wasm diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 92717759..31a9a389 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3895,3 +3895,100 @@ 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 }'); + }); + }); + + 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'); + }); + }); +}); 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..c699c1ac --- /dev/null +++ b/src/extraction/languages/nix.ts @@ -0,0 +1,210 @@ +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; +} + +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 paramNode = valueNode.namedChild(0); + const bodyNode = valueNode.namedChild(1); + + const paramText = paramNode ? getNodeText(paramNode, source).trim() : ''; + const signature = paramText ? (paramText.startsWith('{') || paramText.startsWith('(') ? paramText : `(${paramText})`) : '()'; + + const funcNode = ctx.createNode('function', name, node, { signature }); + 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 }); + // 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(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); + } + } + } + } + // 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 0000000000000000000000000000000000000000..fb541ab65c48a66cc999da7bb19789ee0eae0274 GIT binary patch literal 81459 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�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ⅇ8?(Pz%_vX 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 F{|Ar8$fW=P literal 0 HcmV?d00001 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; From dbbb371425110e49a2b95a978d22b16fa34e77ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20Mulet?= Date: Fri, 22 May 2026 15:36:08 +0200 Subject: [PATCH 2/3] fix(nix): resolve relative imports and detect top-level exported symbols --- __tests__/extraction.test.ts | 34 +++++++++++++++++++++ __tests__/resolution.test.ts | 48 +++++++++++++++++++++++++++++ src/extraction/languages/nix.ts | 51 +++++++++++++++++++++++++++++-- src/resolution/import-resolver.ts | 1 + src/resolution/index.ts | 29 ++++++++++++++++-- 5 files changed, 158 insertions(+), 5 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 31a9a389..22489636 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3991,4 +3991,38 @@ describe('Nix Extraction', () => { 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/languages/nix.ts b/src/extraction/languages/nix.ts index c699c1ac..18dfea78 100644 --- a/src/extraction/languages/nix.ts +++ b/src/extraction/languages/nix.ts @@ -49,6 +49,51 @@ function getImportPath(argNode: SyntaxNode, source: string): string | null { 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; +} + export const nixExtractor: LanguageExtractor = { functionTypes: [], classTypes: [], @@ -87,7 +132,7 @@ export const nixExtractor: LanguageExtractor = { const paramText = paramNode ? getNodeText(paramNode, source).trim() : ''; const signature = paramText ? (paramText.startsWith('{') || paramText.startsWith('(') ? paramText : `(${paramText})`) : '()'; - const funcNode = ctx.createNode('function', name, node, { signature }); + const funcNode = ctx.createNode('function', name, node, { signature, isExported: isExportedNode(node) }); if (funcNode) { ctx.pushScope(funcNode.id); if (bodyNode) { @@ -100,7 +145,7 @@ export const nixExtractor: LanguageExtractor = { const initValue = getNodeText(valueNode, source).slice(0, 100); const signature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; - ctx.createNode('variable', name, node, { signature }); + 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); } @@ -125,7 +170,7 @@ export const nixExtractor: LanguageExtractor = { if (child) { const name = getNodeText(child, source).trim(); if (name) { - ctx.createNode('variable', name, child); + ctx.createNode('variable', name, child, { isExported: isExportedNode(child) }); } } } 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; } From 004355abd4a6d24ddefb03262115259cb332ccf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20Mulet?= Date: Fri, 22 May 2026 16:11:06 +0200 Subject: [PATCH 3/3] feat(nix): support curried and destructured functions --- __tests__/extraction.test.ts | 36 +++++++++++++++++++++++++++ src/extraction/languages/nix.ts | 43 +++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 22489636..60b79fcd 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3944,6 +3944,42 @@ describe('Nix Extraction', () => { 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', () => { diff --git a/src/extraction/languages/nix.ts b/src/extraction/languages/nix.ts index 18dfea78..fdcd7e1d 100644 --- a/src/extraction/languages/nix.ts +++ b/src/extraction/languages/nix.ts @@ -94,6 +94,31 @@ function isExportedNode(node: SyntaxNode): boolean { 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: [], @@ -126,11 +151,19 @@ export const nixExtractor: LanguageExtractor = { if (valueNode.type === 'function_expression') { // It's a function definition! - const paramNode = valueNode.namedChild(0); - const bodyNode = valueNode.namedChild(1); + const { params, bodyNode } = getCurriedParamsAndBody(valueNode, source); - const paramText = paramNode ? getNodeText(paramNode, source).trim() : ''; - const signature = paramText ? (paramText.startsWith('{') || paramText.startsWith('(') ? paramText : `(${paramText})`) : '()'; + 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) { @@ -154,7 +187,7 @@ export const nixExtractor: LanguageExtractor = { // 2. Handle anonymous or top-level function_expressions (not in a binding) if (type === 'function_expression') { - const bodyNode = node.namedChild(1); + const bodyNode = node.namedChild(node.namedChildCount - 1); if (bodyNode) { ctx.visitNode(bodyNode); }