Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <nixpkgs> {};
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('<nixpkgs>');
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('<nixpkgs>');
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);
});
});
});
48 changes: 48 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
5 changes: 4 additions & 1 deletion src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
scala: 'tree-sitter-scala.wasm',
lua: 'tree-sitter-lua.wasm',
luau: 'tree-sitter-luau.wasm',
nix: 'tree-sitter-nix.wasm',
};

/**
Expand Down Expand Up @@ -92,6 +93,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.sc': 'scala',
'.lua': 'lua',
'.luau': 'luau',
'.nix': 'nix',
};

/**
Expand Down Expand Up @@ -155,7 +157,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// ABI-13 build that corrupts the shared WASM heap under web-tree-sitter
// 0.25 (drops nested calls/imports on every file after the first); we
// vendor the upstream ABI-15 wasm instead.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau')
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'nix')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
Expand Down Expand Up @@ -325,6 +327,7 @@ export function getLanguageDisplayName(language: Language): string {
scala: 'Scala',
lua: 'Lua',
luau: 'Luau',
nix: 'Nix',
yaml: 'YAML',
twig: 'Twig',
unknown: 'Unknown',
Expand Down
2 changes: 2 additions & 0 deletions src/extraction/languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { pascalExtractor } from './pascal';
import { scalaExtractor } from './scala';
import { luaExtractor } from './lua';
import { luauExtractor } from './luau';
import { nixExtractor } from './nix';

export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
Expand All @@ -47,4 +48,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
scala: scalaExtractor,
lua: luaExtractor,
luau: luauExtractor,
nix: nixExtractor,
};
Loading