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
5 changes: 5 additions & 0 deletions .claude/skills/agent-eval/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@
{ "name": "expo-haptics", "repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics", "size": "Small", "files": "~15", "question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?" },
{ "name": "expo-camera", "repo": "https://github.com/expo/expo/tree/main/packages/expo-camera", "size": "Medium", "files": "~70", "question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?" }
],
"ReScript": [
{ "name": "rescript-core", "repo": "https://github.com/rescript-lang/rescript-core", "size": "Small", "files": "~100", "question": "How does rescript-core implement the Array module's map and reduce functions?" },
{ "name": "rescript-relay", "repo": "https://github.com/zth/rescript-relay", "size": "Medium", "files": "~250", "question": "How does rescript-relay transform a GraphQL query into typed ReScript modules at build time?" },
{ "name": "rescript", "repo": "https://github.com/rescript-lang/rescript", "size": "Large", "files": "~3500", "question": "How does the ReScript standard library implement the Belt.Array utility functions?" }
],
"React Native Fabric (view components)": [
{ "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `<SegmentedControl onChange={cb}/>` reach the native onChange handler on iOS/Android?" },
{ "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `<ScreenStack>` reach the native RNSScreenStackView component?" },
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- CodeGraph now indexes **ReScript** (`.res`) — functions, modules, records, variants, imports, and call edges. Tested on the ReScript compiler, rescript-core, and rescript-relay.
- `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329)

### Fixes
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ CodeGraph cuts **tokens, tool calls, and wall-clock time on every repo** — acr
| **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, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
| **21+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, ReScript, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 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 @@ -634,6 +634,7 @@ is written):
| Liquid | `.liquid` | Full support |
| Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) |
| Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) |
| ReScript | `.res` | Full support (functions, modules, records, variants, imports, call edges) |
| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |

## Troubleshooting
Expand Down
272 changes: 272 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ describe('Language Detection', () => {
expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c');
});

it('should detect ReScript files', () => {
expect(detectLanguage('Main.res')).toBe('rescript');
});

it('should return unknown for unsupported extensions', () => {
expect(detectLanguage('styles.css')).toBe('unknown');
expect(detectLanguage('data.json')).toBe('unknown');
Expand Down Expand Up @@ -129,6 +133,7 @@ describe('Language Support', () => {
expect(languages).toContain('swift');
expect(languages).toContain('kotlin');
expect(languages).toContain('dart');
expect(languages).toContain('rescript');
});
});

Expand Down Expand Up @@ -4459,3 +4464,270 @@ func (s Stack[T]) Len() int { return len(s.items) }
expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined();
});
});

describe('ReScript Extraction', () => {
it('should extract function declarations', () => {
const code = `
let makeUser = (id: int, name: string): user => {
{id, name}
}
`;
const result = extractFromSource('User.res', code);

const funcNode = result.nodes.find((n) => n.kind === 'function');
expect(funcNode).toBeDefined();
expect(funcNode?.name).toBe('makeUser');
expect(funcNode?.signature).toContain('(id: int, name: string)');
expect(funcNode?.language).toBe('rescript');
});

it('should extract constant declarations (immutable let bindings)', () => {
const code = `
let greeting = "Hello"
let count = 42
`;
const result = extractFromSource('Vars.res', code);

const constants = result.nodes.filter((n) => n.kind === 'constant');
expect(constants.length).toBe(2);
expect(constants.find((n) => n.name === 'greeting')).toBeDefined();
expect(constants.find((n) => n.name === 'count')).toBeDefined();
});

it('should extract module declarations', () => {
const code = `
module Utils = {
let add = (a: int, b: int): int => {
a + b
}
}
`;
const result = extractFromSource('Utils.res', code);

const moduleNode = result.nodes.find((n) => n.kind === 'module');
expect(moduleNode).toBeDefined();
expect(moduleNode?.name).toBe('Utils');

// The function inside the module should have a contains edge from the module
const funcNode = result.nodes.find((n) => n.kind === 'function');
expect(funcNode).toBeDefined();
expect(funcNode?.name).toBe('add');
});

it('should extract record types as structs', () => {
const code = `
type user = {
id: int,
name: string,
}
`;
const result = extractFromSource('Types.res', code);

const structNode = result.nodes.find((n) => n.kind === 'struct');
expect(structNode).toBeDefined();
expect(structNode?.name).toBe('user');

const fields = result.nodes.filter((n) => n.kind === 'field');
expect(fields.length).toBe(2);
expect(fields.find((n) => n.name === 'id')).toBeDefined();
expect(fields.find((n) => n.name === 'name')).toBeDefined();
});

it('should extract variant types as enums', () => {
const code = `
type status = | Pending | Done | Error
`;
const result = extractFromSource('Status.res', code);

const enumNode = result.nodes.find((n) => n.kind === 'enum');
expect(enumNode).toBeDefined();
expect(enumNode?.name).toBe('status');

const members = result.nodes.filter((n) => n.kind === 'enum_member');
expect(members.length).toBe(3);
expect(members.find((n) => n.name === 'Pending')).toBeDefined();
expect(members.find((n) => n.name === 'Done')).toBeDefined();
expect(members.find((n) => n.name === 'Error')).toBeDefined();
});

it('should extract open statements as imports', () => {
const code = `
open Belt
`;
const result = extractFromSource('Imports.res', code);

const importNode = result.nodes.find((n) => n.kind === 'import');
expect(importNode).toBeDefined();
expect(importNode?.name).toBe('Belt');
});

it('should extract call expressions', () => {
const code = `
let greet = () => {
Js.log("hello")
Belt.Array.map([1, 2], x => x)
}
`;
const result = extractFromSource('Calls.res', code);

const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
expect(calls.length).toBeGreaterThanOrEqual(2);
expect(calls.find((r) => r.referenceName === 'Js.log')).toBeDefined();
expect(calls.find((r) => r.referenceName === 'Belt.Array.map')).toBeDefined();
});

it('should emit references edges for function parameter and return types', () => {
const code = `
let makeUser = (id: int, name: string): user => {
{id, name}
}
`;
const result = extractFromSource('TypeRefs.res', code);

const typeRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
expect(typeRefs.find((r) => r.referenceName === 'user')).toBeDefined();
// 'int' and 'string' are built-in primitives and should NOT create references
expect(typeRefs.find((r) => r.referenceName === 'int')).toBeUndefined();
expect(typeRefs.find((r) => r.referenceName === 'string')).toBeUndefined();
});

it('should emit references edges for record field types', () => {
const code = `
type user = {
id: int,
name: string,
role: role_type,
}
`;
const result = extractFromSource('RecordTypeRefs.res', code);

const typeRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
expect(typeRefs.find((r) => r.referenceName === 'role_type')).toBeDefined();
expect(typeRefs.find((r) => r.referenceName === 'int')).toBeUndefined();
expect(typeRefs.find((r) => r.referenceName === 'string')).toBeUndefined();
});

it('should emit references edges for generic types', () => {
const code = `
let process = (x: myOption<int>): myResult<int, string> => {
x
}
`;
const result = extractFromSource('GenericTypeRefs.res', code);

const typeRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
expect(typeRefs.find((r) => r.referenceName === 'myOption')).toBeDefined();
expect(typeRefs.find((r) => r.referenceName === 'myResult')).toBeDefined();
// Built-ins should be filtered out
expect(typeRefs.find((r) => r.referenceName === 'int')).toBeUndefined();
expect(typeRefs.find((r) => r.referenceName === 'string')).toBeUndefined();
});

it('should emit references edges for typed variable declarations', () => {
const code = `
let x: userId = 42
`;
const result = extractFromSource('TypedVar.res', code);

const typeRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
expect(typeRefs.find((r) => r.referenceName === 'userId')).toBeDefined();
});

it('should extract abstract type aliases', () => {
const code = `
type userId
`;
const result = extractFromSource('AbstractType.res', code);

const aliasNode = result.nodes.find((n) => n.kind === 'type_alias');
expect(aliasNode).toBeDefined();
expect(aliasNode?.name).toBe('userId');
});

it('should extract nested modules', () => {
const code = `
module Outer = {
module Inner = {
let value = 1
}
}
`;
const result = extractFromSource('NestedModules.res', code);

const modules = result.nodes.filter((n) => n.kind === 'module');
expect(modules.length).toBe(2);
expect(modules.find((n) => n.name === 'Outer')).toBeDefined();
expect(modules.find((n) => n.name === 'Inner')).toBeDefined();

const constant = result.nodes.find((n) => n.name === 'value' && n.kind === 'constant');
expect(constant).toBeDefined();
});

it('should gracefully skip destructuring patterns', () => {
const code = `
let {id, name} = user
`;
const result = extractFromSource('Destructuring.res', code);

// No node should be created for the destructuring binding itself
const namedNodes = result.nodes.filter((n) => n.name === 'id' || n.name === 'name');
expect(namedNodes.length).toBe(0);
// No errors should be emitted
expect(result.errors.length).toBe(0);
});

it('should create contains edges from module to its members', () => {
const code = `
module Utils = {
let add = (a: int, b: int): int => {
a + b
}
}
`;
const result = extractFromSource('Utils.res', code);

const moduleNode = result.nodes.find((n) => n.kind === 'module');
const funcNode = result.nodes.find((n) => n.kind === 'function');
expect(moduleNode).toBeDefined();
expect(funcNode).toBeDefined();

const containsEdge = result.edges.find(
(e) => e.source === moduleNode?.id && e.target === funcNode?.id && e.kind === 'contains'
);
expect(containsEdge).toBeDefined();
});

it('should extract unit functions (no parameters)', () => {
const code = `
let init = () => {
Js.log("init")
}
`;
const result = extractFromSource('UnitFunc.res', code);

const funcNode = result.nodes.find((n) => n.kind === 'function');
expect(funcNode).toBeDefined();
expect(funcNode?.name).toBe('init');
expect(funcNode?.signature).toBe('()');
});

it('should extract decorators as decorates references', () => {
const code = `
@react.component
let make = (~name: string) => {
<div> {React.string(name)} </div>
}
`;
const result = extractFromSource('Decorator.res', code);

const funcNode = result.nodes.find((n) => n.kind === 'function');
expect(funcNode).toBeDefined();
expect(funcNode?.name).toBe('make');

const decoratorRef = result.unresolvedReferences.find(
(r) => r.referenceKind === 'decorates' && r.referenceName === 'react.component'
);
expect(decoratorRef).toBeDefined();
expect(decoratorRef?.fromNodeId).toBe(funcNode?.id);
});
});
4 changes: 4 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
lua: 'tree-sitter-lua.wasm',
luau: 'tree-sitter-luau.wasm',
objc: 'tree-sitter-objc.wasm',
rescript: 'tree-sitter-rescript.wasm',
};

/**
Expand Down Expand Up @@ -101,6 +102,8 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.luau': 'luau',
'.m': 'objc',
'.mm': 'objc',
'.res': 'rescript',
'.resi': 'rescript',
// 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 @@ -384,6 +387,7 @@ export function getLanguageDisplayName(language: Language): string {
lua: 'Lua',
luau: 'Luau',
objc: 'Objective-C',
rescript: 'ReScript',
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 @@ -26,6 +26,7 @@ import { scalaExtractor } from './scala';
import { luaExtractor } from './lua';
import { luauExtractor } from './luau';
import { objcExtractor } from './objc';
import { rescriptExtractor } from './rescript';

export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
Expand All @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
lua: luaExtractor,
luau: luauExtractor,
objc: objcExtractor,
rescript: rescriptExtractor,
};
Loading