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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
105 changes: 105 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 <nixpkgs> {};
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);
});
});

Expand Down
82 changes: 82 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <nixpkgs> {};
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([]);
});
});
});
5 changes: 4 additions & 1 deletion src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
r: 'tree-sitter-r.wasm',
luau: 'tree-sitter-luau.wasm',
objc: 'tree-sitter-objc.wasm',
nix: 'tree-sitter-nix.wasm',
};

/**
Expand Down Expand Up @@ -108,6 +109,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.luau': 'luau',
'.m': 'objc',
'.mm': 'objc',
'.nix': 'nix',
// XML: file-level tracking; the MyBatis extractor matches `<mapper namespace="...">`
// shape and emits SQL-statement nodes (other XML returns empty).
'.xml': 'xml',
Expand Down Expand Up @@ -221,7 +223,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// `class Foo(...)` as an ERROR that swallows the whole class (#237); we
// vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
// primary constructors natively.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r')
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'nix')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
Expand Down Expand Up @@ -432,6 +434,7 @@ export function getLanguageDisplayName(language: Language): string {
lua: 'Lua',
luau: 'Luau',
objc: 'Objective-C',
nix: 'Nix',
yaml: 'YAML',
twig: 'Twig',
xml: 'XML',
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 @@ -27,6 +27,7 @@ import { luaExtractor } from './lua';
import { rExtractor } from './r';
import { luauExtractor } from './luau';
import { objcExtractor } from './objc';
import { nixExtractor } from './nix';

export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
Expand All @@ -51,4 +52,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
r: rExtractor,
luau: luauExtractor,
objc: objcExtractor,
nix: nixExtractor,
};
Loading