Skip to content
Open
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ With the index available, the agent answers from one to four `codegraph_explore`

## Built for speed — the Rust kernel

CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScript, JavaScript, Java, Python, Go, C, C++, Rust, C#, Ruby, PHP, Swift, Kotlin, Scala, Dart, R, Lua, Luau (Metal and CUDA ride the C++ path) — parse in compiled code with one boundary crossing per file. Every language shipped only after its graphs proved **byte-for-byte identical** to the reference engine on real repositories, from small libraries up to the Linux kernel; platforms without a prebuilt binary and files with syntax errors fall back per-file automatically, same graph either way.
CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScript, JavaScript, Java, Python, Go, C, C++, Rust, C#, Ruby, PHP, Swift, Kotlin, Scala, Dart, R, Lua, Luau (Metal and CUDA ride the C++ path) — parse in compiled code with one boundary crossing per file. Additional languages such as AL are supported via WASM grammars with the same extraction pipeline. Every language shipped only after its graphs proved **byte-for-byte identical** to the reference engine on real repositories, from small libraries up to the Linux kernel; platforms without a prebuilt binary and files with syntax errors fall back per-file automatically, same graph either way.

**And it scales itself to the machine it's on.** Worker pools, parallel resolution, and analysis caches are sized from what the system actually has — real core counts (container/cgroup-aware, so a VPS that grants 2 cores gets sized for 2, not the host's 64), honestly-measured available RAM on macOS and Linux, and the measured cost of *your* project's resolution work:

Expand All @@ -278,7 +278,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr
| **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, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi, AL (Business Central) |
| **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
156 changes: 156 additions & 0 deletions __tests__/al-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { loadGrammarsForLanguages } from '../src/extraction/grammars';

beforeAll(async () => {
await loadGrammarsForLanguages(['al']);
});

describe('AL Extraction', () => {
it('extracts Codeunit and procedure correctly', () => {
const source = `
codeunit 50100 "My Test Codeunit"
{
Access = Public;
Subtype = Normal;

trigger OnRun()
begin
Message('Hello World');
end;

procedure MyFunction(VarParam: Record "Sales Line")
var
LocalVar: Integer;
begin
LocalVar := 1;
CalculateRounding(LocalVar);
end;
}
`;

const result = extractFromSource('test.al', source);

expect(result.nodes.some(n => n.kind === 'class' && n.name === '"My Test Codeunit"')).toBe(true);
expect(result.nodes.some(n => n.kind === 'method' && n.name === 'OnRun')).toBe(true);
expect(result.nodes.some(n => n.kind === 'method' && n.name === 'MyFunction')).toBe(true);

// Check if the reference to CalculateRounding is picked up
const refs = result.unresolvedReferences;
const callRef = refs.find(r => r.referenceName === 'CalculateRounding' && r.referenceKind === 'calls');
expect(callRef).toBeDefined();
});

it('names enums and interfaces and extracts interface procedures', () => {
const source = `
enum 50100 "Color"
{
value(0; Red) { }
value(1; "Dark Blue") { }
}

interface "Color Provider"
{
procedure GetColor(): Enum "Color";
}
`;

const result = extractFromSource('types.al', source);

expect(result.nodes.some(n => n.kind === 'enum' && n.name === '"Color"')).toBe(true);
expect(result.nodes.some(n => n.kind === 'enum_member' && n.name === 'Red')).toBe(true);
expect(result.nodes.some(n => n.kind === 'enum_member' && n.name === '"Dark Blue"')).toBe(true);
expect(result.nodes.some(n => n.kind === 'interface' && n.name === '"Color Provider"')).toBe(true);
expect(result.nodes.some(n => n.kind === 'method' && n.name === 'GetColor')).toBe(true);
});

it('extracts table fields and nests field triggers beneath them', () => {
const source = `
table 50101 Customer
{
fields
{
field(1; Name; Text[100])
{
trigger OnValidate()
begin
ValidateName();
end;
}
}
}
`;

const result = extractFromSource('customer.al', source);
const field = result.nodes.find(n => n.kind === 'field' && n.name === 'Name');
const trigger = result.nodes.find(n => n.kind === 'method' && n.name === 'OnValidate');

expect(field?.qualifiedName).toBe('Customer::Name');
expect(trigger?.qualifiedName).toBe('Customer::Name::OnValidate');
expect(result.edges).toContainEqual({ source: field?.id, target: trigger?.id, kind: 'contains' });
expect(result.unresolvedReferences).toEqual(expect.arrayContaining([
expect.objectContaining({
fromNodeId: trigger?.id,
referenceName: 'ValidateName',
referenceKind: 'calls',
}),
]));
});

it('extracts namespaces, using directives, and extension base objects', () => {
const source = `
namespace Contoso.Extensions;
using Microsoft.Sales.Customer;

tableextension 50102 CustomerExtension extends Customer
{
}

permissionsetextension 50103 PermissionExtension extends BasePermissionSet
{
}

profileextension ProfileExtension extends BaseProfile
{
}
`;

const result = extractFromSource('customer-extension.al', source);
const namespace = result.nodes.find(n => n.kind === 'namespace');
const extension = result.nodes.find(n => n.kind === 'class' && n.name === 'CustomerExtension');
const permissionExtension = result.nodes.find(
n => n.kind === 'class' && n.name === 'PermissionExtension',
);
const profileExtension = result.nodes.find(
n => n.kind === 'class' && n.name === 'ProfileExtension',
);
const using = result.nodes.find(n => n.kind === 'import');

expect(namespace?.name).toBe('Contoso.Extensions');
expect(extension?.qualifiedName).toBe('Contoso.Extensions::CustomerExtension');
expect(permissionExtension?.qualifiedName).toBe('Contoso.Extensions::PermissionExtension');
expect(profileExtension?.qualifiedName).toBe('Contoso.Extensions::ProfileExtension');
expect(using?.name).toBe('Microsoft.Sales.Customer');
expect(result.unresolvedReferences).toEqual(expect.arrayContaining([
expect.objectContaining({
fromNodeId: extension?.id,
referenceName: 'Customer',
referenceKind: 'extends',
}),
expect.objectContaining({
referenceName: 'Microsoft.Sales.Customer',
referenceKind: 'imports',
}),
expect.objectContaining({
fromNodeId: permissionExtension?.id,
referenceName: 'BasePermissionSet',
referenceKind: 'extends',
}),
expect.objectContaining({
fromNodeId: profileExtension?.id,
referenceName: 'BaseProfile',
referenceKind: 'extends',
}),
]));
});
});
161 changes: 161 additions & 0 deletions __tests__/al-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import { ReferenceResolver } from '../src/resolution';
import { matchReference } from '../src/resolution/name-matcher';
import type { QueryBuilder } from '../src/db/queries';
import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
import type { Node } from '../src/types';

function contextFor(nodes: Node[]): ResolutionContext {
return {
getNodesInFile: filePath => nodes.filter(node => node.filePath === filePath),
getNodesByName: name => nodes.filter(node => node.name === name),
getNodesByQualifiedName: qualifiedName => nodes.filter(node => node.qualifiedName === qualifiedName),
getNodesByKind: kind => nodes.filter(node => node.kind === kind),
fileExists: () => true,
readFile: () => null,
getProjectRoot: () => '/project',
getAllFiles: () => [...new Set(nodes.map(node => node.filePath))],
getNodesByLowerName: lowerName => nodes.filter(
node => node.name.toLowerCase() === lowerName.toLowerCase(),
),
getImportMappings: () => [],
};
}

function call(referenceName: string): UnresolvedRef {
return {
fromNodeId: 'caller',
referenceName,
referenceKind: 'calls',
line: 10,
column: 4,
filePath: 'caller.al',
language: 'al',
};
}

function symbol(
id: string,
kind: Node['kind'],
name: string,
qualifiedName: string,
filePath: string,
): Node {
return {
id,
kind,
name,
qualifiedName,
filePath,
language: 'al',
startLine: 1,
endLine: 2,
startColumn: 0,
endColumn: 0,
updatedAt: 0,
};
}

function resolverFor(nodes: Node[]): ReferenceResolver {
const queries = {
getAllFilePaths: () => [...new Set(nodes.map(node => node.filePath))],
getAllNodeNames: () => [...new Set(nodes.map(node => node.name))],
getNodesByFile: (filePath: string) => nodes.filter(node => node.filePath === filePath),
getNodesByName: (name: string) => nodes.filter(node => node.name === name),
getNodesByLowerName: (name: string) => nodes.filter(
node => node.name.toLowerCase() === name.toLowerCase(),
),
getNodesByQualifiedNameExact: (qualifiedName: string) => nodes.filter(
node => node.qualifiedName === qualifiedName,
),
getNodesByKind: (kind: Node['kind']) => nodes.filter(node => node.kind === kind),
iterateNodesByKind: function* (kind: Node['kind']) {
yield* nodes.filter(node => node.kind === kind);
},
getNodeById: (id: string) => nodes.find(node => node.id === id) ?? null,
} as unknown as QueryBuilder;
return new ReferenceResolver('/project', queries);
}

describe('AL resolution', () => {
it('resolves member calls case-insensitively', () => {
const target = symbol('target', 'method', 'DoWork', 'Target::DoWork', 'target.al');

expect(matchReference(call('service.dowork'), contextFor([target]))?.targetNodeId).toBe(target.id);
});

it('resolves case-insensitive AL calls through the existing production prefilter', () => {
const target = symbol('target', 'method', 'DoWork', 'Target::DoWork', 'target.al');
const result = resolverFor([target]).resolveAll([
call('dowork'),
call('service.dowork'),
]);

expect(result.resolved.map(ref => ref.targetNodeId)).toEqual([target.id, target.id]);
});

it('resolves quoted member calls case-insensitively', () => {
const target = symbol('target', 'method', '"Do Work"', 'Service::"Do Work"', 'target.al');

const result = resolverFor([target]).resolveAll([call('service."do work"')]);
expect(result.resolved[0]?.targetNodeId).toBe(target.id);
});

it('resolves Unicode member calls using AL identifier rules', () => {
const target = symbol('target', 'method', 'Oa\u0301k', 'Service::Oa\u0301k', 'target.al');

expect(matchReference(call('service.oa\u0301k'), contextFor([target]))?.targetNodeId).toBe(target.id);
});

it('does not treat dots inside quoted AL identifiers as member separators', () => {
const target = symbol('target', 'method', '"Do.Work"', 'Service::"Do.Work"', 'target.al');

expect(matchReference(call('service."do.work"'), contextFor([target]))?.targetNodeId).toBe(target.id);
expect(resolverFor([target]).resolveAll([call('"do.work"')]).resolved[0]?.targetNodeId).toBe(target.id);
});

it('does not guess when a case-insensitive member name is ambiguous', () => {
const first = symbol('first', 'method', 'DoWork', 'First::DoWork', 'first.al');
const second = symbol('second', 'method', 'DOWORK', 'Second::DOWORK', 'second.al');

expect(matchReference(call('service.dowork'), contextFor([first, second]))).toBeNull();
});

it('uses AL using directives to disambiguate object references', () => {
const currentNamespace = symbol(
'caller-ns',
'namespace',
'Contoso.Extension',
'Contoso.Extension',
'caller.al',
);
const wrong = symbol(
'wrong',
'class',
'Customer',
'Nearby.Unrelated::Customer',
'nearby/customer.al',
);
const target = symbol(
'target',
'class',
'Customer',
'Microsoft.Sales::Customer',
'base/customer.al',
);
const using = symbol(
'using-sales',
'import',
'Microsoft.Sales',
'Microsoft.Sales',
'caller.al',
);
const ref: UnresolvedRef = {
...call('customer'),
referenceKind: 'extends',
};

const result = resolverFor([currentNamespace, using, wrong, target]).resolveAll([ref]);
expect(result.resolved[0]?.targetNodeId).toBe(target.id);
});
});
5 changes: 5 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ describe('Language Detection', () => {
expect(isSourceFile('default.nix')).toBe(true);
});

it('should detect AL files', () => {
expect(detectLanguage('src/CustomerCard.al')).toBe('al');
expect(isSourceFile('src/CustomerCard.al')).toBe(true);
});

it('should detect a .h whose only C++ signal is an export-macro class as cpp', () => {
// Lean Unreal-Engine style header: the class is annotated with an export
// macro and carries no explicit `public:`/`virtual`/`namespace`/`template`,
Expand Down
5 changes: 4 additions & 1 deletion src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
terraform: 'tree-sitter-terraform.wasm',
arkts: 'tree-sitter-arkts.wasm',
nix: 'tree-sitter-nix.wasm',
al: 'tree-sitter-al.wasm',
};

/**
* File extension to Language mapping
*/
export const EXTENSION_MAP: Record<string, Language> = {
'.al': 'al',
'.ts': 'typescript',
'.tsx': 'tsx',
// ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366)
Expand Down Expand Up @@ -290,7 +292,7 @@ export async function initGrammars(): Promise<void> {
*/
const VENDORED_WASM_LANGS: ReadonlySet<GrammarLanguage> = new Set([
'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery',
'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix',
'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', 'al',
'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go',
// R7a (C/C++ kernel port prep): tree-sitter-c v0.24.2 (b780e47) +
// tree-sitter-cpp v0.23.4 (f41e1a0), parser.c/scanner.c sha-matched against
Expand Down Expand Up @@ -693,6 +695,7 @@ export function getLanguageDisplayName(language: Language): string {
vbnet: 'Visual Basic .NET',
erlang: 'Erlang',
terraform: 'Terraform',
al: 'AL (Business Central)',
arkts: 'ArkTS',
unknown: 'Unknown',
};
Expand Down
Loading