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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- GitHub Copilot is now a supported agent: `codegraph install` can configure Copilot Chat in VS Code (`copilot-vscode`), the GitHub Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`). Installed Copilot surfaces are auto-detected like every other agent, existing MCP server entries in their config files are preserved, and `codegraph uninstall` reverses the setup cleanly. Restart VS Code or your JetBrains IDE after installing so Copilot picks up the server.

- Added full Gleam language support for `.gleam` files, including the vendored
tree-sitter grammar, functions, types, constants, imports, call references,
and cross-file module resolution.

### Fixes

- C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, Gleam |
| **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 @@ -787,6 +787,7 @@ is written):
| Swift | `.swift` | Full support |
| Kotlin | `.kt`, `.kts` | Full support |
| Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) |
| Gleam | `.gleam` | Full support (functions, external functions, custom types and constructors, type aliases, constants, imports, call edges, and cross-file module resolution) |
| Dart | `.dart` | Full support |
| Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) |
| Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) |
Expand Down
115 changes: 115 additions & 0 deletions __tests__/db-perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,101 @@ describe('deleteResolvedReferences (chunking)', () => {
});
});

describe('unresolved reference metadata', () => {
let dir: string;
let db: DatabaseConnection;
let q: QueryBuilder;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-unresolved-metadata-'));
db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
q = new QueryBuilder(db.getDb());
});

afterEach(() => {
db.close();
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});

it('round-trips structured metadata through a single unresolved-ref insert', () => {
q.insertNode(makeNode('caller'));
const metadata = {
ffi: true,
targetLanguage: 'erlang',
module: 'config_ffi',
function: 'getenv',
arity: 2,
} as const;

q.insertUnresolvedRef({
fromNodeId: 'caller',
referenceName: 'config_ffi.getenv',
referenceKind: 'calls',
line: 1,
column: 0,
metadata,
});

expect(q.getUnresolvedReferences()).toHaveLength(1);
expect(q.getUnresolvedReferences()[0]?.metadata).toEqual(metadata);
});

it('round-trips structured metadata through a batch unresolved-ref insert', () => {
q.insertNode(makeNode('caller'));
const metadata = {
ffi: true,
targetLanguage: 'erlang',
module: 'config_ffi',
function: 'getenv',
arity: 2,
} as const;

q.insertUnresolvedRefsBatch([
{
fromNodeId: 'caller',
referenceName: 'config_ffi::getenv',
referenceKind: 'calls',
line: 1,
column: 0,
metadata,
},
]);

expect(q.getUnresolvedReferences()).toHaveLength(1);
expect(q.getUnresolvedReferences()[0]?.metadata).toEqual(metadata);
});

it('retains structured metadata when a failed reference is retried', () => {
q.insertNode(makeNode('caller'));
const metadata = {
ffi: true,
targetLanguage: 'erlang',
module: 'config_ffi',
function: 'getenv',
arity: 2,
} as const;

q.insertUnresolvedRef({
fromNodeId: 'caller',
referenceName: 'config_ffi::getenv',
referenceKind: 'calls',
line: 1,
column: 0,
metadata,
});
q.markReferencesFailed([
{
fromNodeId: 'caller',
referenceName: 'config_ffi::getenv',
referenceKind: 'calls',
},
]);

expect(q.getRetryableFailedReferences(['getenv'])).toHaveLength(1);
expect(q.getRetryableFailedReferences(['getenv'])[0]?.metadata).toEqual(metadata);
});
});

describe('insertNode cache invalidation', () => {
let dir: string;
let db: DatabaseConnection;
Expand Down Expand Up @@ -363,3 +458,23 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', ()
fs.rmSync(dir, { recursive: true, force: true });
});
});

describe('migration v10: persist unresolved reference metadata', () => {
it('adds the metadata column to a version 9 database', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-mig10-'));
const db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
const raw = db.getDb();

raw.exec('ALTER TABLE unresolved_refs DROP COLUMN metadata');
raw.prepare('DELETE FROM schema_versions WHERE version >= 10').run();

runMigrations(raw, 9);

const columns = raw.prepare('PRAGMA table_info(unresolved_refs)').all() as Array<{ name: string }>;
expect(columns.some((column) => column.name === 'metadata')).toBe(true);
expect(getCurrentVersion(raw)).toBe(10);

db.close();
fs.rmSync(dir, { recursive: true, force: true });
});
});
168 changes: 168 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execFileSync } from 'node:child_process';
import { CodeGraph } from '../src';
import { extractFromSource, scanDirectory, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore } from '../src/extraction';
import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars';
Expand All @@ -19,6 +20,133 @@ beforeAll(async () => {
await loadAllGrammars();
});

// =============================================================================
// Gleam
// =============================================================================

describe('Gleam Extraction', () => {
describe('Language detection', () => {
it('should detect Gleam files', () => {
expect(detectLanguage('main.gleam')).toBe('gleam');
expect(detectLanguage('src/app/server.gleam')).toBe('gleam');
});

it('should report Gleam as supported', () => {
expect(isLanguageSupported('gleam')).toBe(true);
expect(getSupportedLanguages()).toContain('gleam');
});
});

describe('Function extraction', () => {
it('should extract public function declarations', () => {
const code = `
pub fn add(a: Int, b: Int) -> Int {
a + b
}
`;
const result = extractFromSource('math.gleam', code);
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'add');
expect(fn).toBeDefined();
expect(fn?.language).toBe('gleam');
expect(fn?.isExported).toBe(true);
expect(fn?.signature).toContain('Int');
});

it('should extract private function declarations as not exported', () => {
const code = `
fn helper(x: Int) -> Int {
x * 2
}
`;
const result = extractFromSource('util.gleam', code);
const fn = result.nodes.find((n) => n.name === 'helper');
expect(fn).toBeDefined();
expect(fn?.isExported).toBe(false);
});

it('should extract external functions', () => {
const code = `
@external(erlang, "io", "format")
pub fn ext_format(fmt: String) -> Nil
`;
const result = extractFromSource('ffi.gleam', code);
expect(result.nodes.find((n) => n.name === 'ext_format')).toBeDefined();
});
});

describe('Type extraction', () => {
it('should extract custom type as enum with data constructors as enum_member', () => {
const code = `
pub type Shape {
Circle(radius: Float)
Square(side: Float)
Rectangle(width: Float, height: Float)
}
`;
const result = extractFromSource('shape.gleam', code);
const shape = result.nodes.find((n) => n.kind === 'enum' && n.name === 'Shape');
expect(shape).toBeDefined();
expect(shape?.isExported).toBe(true);
const members = result.nodes.filter((n) => n.kind === 'enum_member');
expect(members.find((m) => m.name === 'Circle')).toBeDefined();
expect(members.find((m) => m.name === 'Square')).toBeDefined();
expect(members.find((m) => m.name === 'Rectangle')).toBeDefined();
});

it('should extract type aliases', () => {
const code = `
pub type UserId = String
type Pair = #(Int, Int)
`;
const result = extractFromSource('types.gleam', code);
const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
expect(aliases.find((a) => a.name === 'UserId')).toBeDefined();
expect(aliases.find((a) => a.name === 'Pair')).toBeDefined();
});
});

describe('Constant extraction', () => {
it('should extract top-level constants', () => {
const code = `
pub const max_retries = 3
const default_timeout = 30
`;
const result = extractFromSource('config.gleam', code);
const constants = result.nodes.filter((n) => n.kind === 'constant');
expect(constants.find((c) => c.name === 'max_retries')).toBeDefined();
expect(constants.find((c) => c.name === 'default_timeout')).toBeDefined();
});
});

describe('Import and call extraction', () => {
it('should extract bare and selective imports', () => {
const code = `
import gleam/io as out
import gleam/list.{map, filter}
`;
const result = extractFromSource('main.gleam', code);
const imports = result.nodes.filter((n) => n.kind === 'import');
expect(imports.find((i) => i.name === 'gleam/io')).toBeDefined();
expect(imports.find((i) => i.name === 'gleam/list')).toBeDefined();
});

it('should emit unresolved refs for bare and qualified calls', () => {
const code = `
import gleam/io.{println}

pub fn main() -> Nil {
println("hi")
io.print("there")
}
`;
const result = extractFromSource('app.gleam', code);
const callRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
expect(callRefs.find((r) => r.referenceName === 'println')).toBeDefined();
expect(callRefs.find((r) => r.referenceName.startsWith('io.'))).toBeDefined();
});
});
});

// Create a temporary directory for each test
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-'));
Expand Down Expand Up @@ -6767,6 +6895,27 @@ describe('Full Indexing', () => {
cleanupTempDir(tempDir);
});

it('treats a tracked-but-deleted file as removed during full indexing', async () => {
const filePath = path.join(tempDir, 'src', 'gone.gleam');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, 'pub fn gone() -> Nil { Nil }\n');
execFileSync('git', ['init', '-q'], { cwd: tempDir });
execFileSync('git', ['add', 'src/gone.gleam'], { cwd: tempDir });

const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
expect(cg.getNodesInFile('src/gone.gleam').some((node) => node.name === 'gone')).toBe(true);

fs.unlinkSync(filePath);
const result = await cg.indexAll();

expect(result.errors.some((error) => error.filePath === 'src/gone.gleam')).toBe(false);
expect(result.filesErrored).toBe(0);
expect(cg.getFile('src/gone.gleam')).toBeNull();
expect(cg.getNodesInFile('src/gone.gleam')).toEqual([]);
cg.close();
});

it('should index a TypeScript file', async () => {
// Create test file
const srcDir = path.join(tempDir, 'src');
Expand Down Expand Up @@ -6981,6 +7130,25 @@ describe('Directory Exclusion', () => {
cleanupTempDir(tempDir);
});

it('excludes Gleam _build output by default', () => {
const ig = buildDefaultIgnore(tempDir);
expect(ig.ignores('_build/default/lib/dep/src/generated.gleam')).toBe(true);
expect(ig.ignores('src/_build_helpers/real.gleam')).toBe(false);
});

it('does not index tracked Gleam files under _build', () => {
const buildDir = path.join(tempDir, '_build', 'default', 'lib', 'dep', 'src');
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(buildDir, { recursive: true });
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(buildDir, 'generated.gleam'), 'pub fn generated() -> Nil { Nil }\n');
fs.writeFileSync(path.join(srcDir, 'main.gleam'), 'pub fn main() -> Nil { Nil }\n');
execFileSync('git', ['init', '-q'], { cwd: tempDir });
execFileSync('git', ['add', '.'], { cwd: tempDir });

expect(scanDirectory(tempDir)).toEqual(['src/main.gleam']);
});

it('should exclude directories listed in .gitignore', () => {
// Create structure: src/index.ts + node_modules/pkg/index.js, gitignore node_modules
const srcDir = path.join(tempDir, 'src');
Expand Down
Loading