From 89ec7d0695fd929f2d00b31f2713bdb07a670441 Mon Sep 17 00:00:00 2001 From: Johannes Klauss Date: Sat, 6 Jun 2026 11:58:35 +0200 Subject: [PATCH] feat: add ReScript language support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New extractor src/extraction/languages/rescript.ts - Functions, modules, records (struct), variants (enum), constants - Imports (open statements), call expressions - Decorator extraction (@react.component, @module, etc.) - Type annotation edges for parameters, return types, record fields - Nested module support - Graceful handling of destructuring patterns - Grammar + extension registration: .res, .resi mapped to rescript - Added to TYPE_ANNOTATION_LANGUAGES and built-in type filter - Exposed extractTypeAnnotations on ExtractorContext - 18 extraction tests covering all constructs - README + CHANGELOG updated - Benchmarked on rescript-core (small), rescript-relay (medium), rescript compiler (large) — all PASS --- .claude/skills/agent-eval/corpus.json | 5 + CHANGELOG.md | 1 + README.md | 2 +- __tests__/extraction.test.ts | 267 ++++++++++++++++++++++++++ src/extraction/grammars.ts | 4 + src/extraction/languages/index.ts | 2 + src/extraction/languages/rescript.ts | 265 +++++++++++++++++++++++++ src/extraction/tree-sitter-types.ts | 2 + src/extraction/tree-sitter.ts | 5 +- src/types.ts | 1 + 10 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 src/extraction/languages/rescript.ts diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 2cfedac4f..3d8adf340 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -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 `` 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 `` reach the native RNSScreenStackView component?" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf4c5d4e..1bf714f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Cross-file impact and blast-radius coverage now spans **all 22 supported languages and 14 web frameworks**, each validated on a real-world repo — see the new coverage table in the README. This release ships the cross-file resolution behind it, including Lua and Luau `require`, Shopify OS 2.0 Liquid section templates, Delphi form code-behind, Rust cross-module calls and Rocket route macros, Swift Fluent relationships, and the SvelteKit / Nuxt / Vapor / Axum route conventions. The residual everywhere is genuine static-analysis frontiers (runtime dispatch, reflection / DI, framework-convention entry points), never hidden. - C# types are now tracked by their namespace-qualified name. Same-named types in different namespaces — a domain entity and a DTO both called `CatalogBrand`, say — are told apart instead of collapsing into one arbitrary match, so a reference resolves to the right one and impact no longer conflates them. (C#) - ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class; and the C# inside `@code { }` / `@functions { }` / `@{ }` blocks is analyzed too, so services and types used in component logic are linked. A view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor) +- CodeGraph now indexes **ReScript** (`.res`) — functions, modules, records, variants, imports, and call edges. Tested on the ReScript compiler, rescript-core, and rescript-relay. - A Razor/Blazor type reference now resolves through the component's `@using` namespaces — including the folder's cascading `_Imports.razor` — so a simple name that exists in several namespaces lands on the right one. A `@model` / `` / `@code` reference to `CatalogBrand` resolves to the `@using`'d DTO (`BlazorShared.Models.CatalogBrand`) rather than a same-named domain entity. (ASP.NET, Blazor) - `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) - TypeScript service/RPC contracts defined as a tuple of generic types — `type MyServiceList = [Service<'query_apply_record', …>, Service<'apply_confirm', …>]` — now index each entry's string-literal name as a searchable symbol. Previously these names existed only as type arguments, so `codegraph query query_apply_record` found nothing even though the names are the app's primary API surface. The pattern is common in typed RPC / BFF clients and mock servers where the types are the source of truth for a runtime proxy object. Utility types (`Pick`, `Omit`, `Record`) and route paths are deliberately left out to avoid noise. Thanks @jiezhiyong. (#634) (TypeScript) diff --git a/README.md b/README.md index bb86a697b..453e78918 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,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, Scala, Dart, Lua, Luau, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **21+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, ReScript, Lua, Luau, 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 | diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 7f2d13f5f..cea02b19f 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -6976,3 +6976,270 @@ describe('Swift property wrappers / attributes (blast-radius recall)', () => { } finally { cleanupTempDir(dir); } }); }); + +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): myResult => { + 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) => { +
{React.string(name)}
+} +`; + 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); + }); +}); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index eabdb598e..4836ab314 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record = { lua: 'tree-sitter-lua.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + rescript: 'tree-sitter-rescript.wasm', }; /** @@ -106,6 +107,8 @@ export const EXTENSION_MAP: Record = { '.luau': 'luau', '.m': 'objc', '.mm': 'objc', + '.res': 'rescript', + '.resi': 'rescript', // XML: file-level tracking; the MyBatis extractor matches `` // shape and emits SQL-statement nodes (other XML returns empty). '.xml': 'xml', @@ -420,6 +423,7 @@ export function getLanguageDisplayName(language: Language): string { lua: 'Lua', luau: 'Luau', objc: 'Objective-C', + rescript: 'ReScript', yaml: 'YAML', twig: 'Twig', xml: 'XML', diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 543598b8e..541ed929b 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -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> = { typescript: typescriptExtractor, @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial> = { lua: luaExtractor, luau: luauExtractor, objc: objcExtractor, + rescript: rescriptExtractor, }; diff --git a/src/extraction/languages/rescript.ts b/src/extraction/languages/rescript.ts new file mode 100644 index 000000000..baf650339 --- /dev/null +++ b/src/extraction/languages/rescript.ts @@ -0,0 +1,265 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types'; + +function getLetBindingName(binding: SyntaxNode, source: string): string | null { + const pattern = getChildByField(binding, 'pattern'); + if (!pattern) return null; + if (pattern.type === 'value_identifier') return getNodeText(pattern, source); + // Destructuring patterns and other shapes — skip for now + return null; +} + +function getModuleBindingName(binding: SyntaxNode, source: string): string | null { + const name = getChildByField(binding, 'name'); + if (!name) return null; + if (name.type === 'module_identifier') return getNodeText(name, source); + return null; +} + +function getTypeBindingName(binding: SyntaxNode, source: string): string | null { + const name = getChildByField(binding, 'name'); + if (!name) return null; + if (name.type === 'type_identifier') return getNodeText(name, source); + return null; +} + +function getFunctionSignature(func: SyntaxNode, source: string): string | undefined { + const params = getChildByField(func, 'parameters'); + const returnType = getChildByField(func, 'return_type'); + if (!params) return undefined; + let sig = getNodeText(params, source); + if (returnType) { + sig += ': ' + getNodeText(returnType, source).replace(/^:\s*/, ''); + } + return sig; +} + +/** + * ReScript decorators sit as preceding siblings of declarations (let_declaration, + * type_declaration, module_declaration, external_declaration). Scan both direct + * children and preceding siblings, matching the orchestrator's extractDecoratorsFor + * pattern, and emit unresolved 'decorates' references. + */ +function extractReScriptDecorators( + declNode: SyntaxNode, + source: string, + ctx: ExtractorContext, + decoratedId: string +): void { + const consider = (n: SyntaxNode | null): void => { + if (!n || n.type !== 'decorator') return; + const idNode = n.namedChildren.find((c) => c.type === 'decorator_identifier'); + if (!idNode) return; + const name = getNodeText(idNode, source).replace(/^@/, ''); + if (!name) return; + ctx.addUnresolvedReference({ + fromNodeId: decoratedId, + referenceName: name, + referenceKind: 'decorates', + line: n.startPosition.row + 1, + column: n.startPosition.column, + }); + }; + + // 1. Decorators that are direct children of the declaration (some grammars) + for (let i = 0; i < declNode.namedChildCount; i++) { + consider(declNode.namedChild(i)); + } + + // 2. Decorators that are preceding siblings of the declaration + const parent = declNode.parent; + if (parent) { + const declStart = declNode.startIndex; + let declIdx = -1; + for (let i = 0; i < parent.namedChildCount; i++) { + const sibling = parent.namedChild(i); + if (sibling && sibling.startIndex === declStart) { + declIdx = i; + break; + } + } + if (declIdx > 0) { + for (let j = declIdx - 1; j >= 0; j--) { + const sibling = parent.namedChild(j); + if (!sibling) continue; + if (sibling.type !== 'decorator') break; + consider(sibling); + } + } + } +} + +export const rescriptExtractor: LanguageExtractor = { + functionTypes: [], // function nodes are always inside let_binding, handled in visitNode + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], // record_type is inside type_binding, handled in visitNode + enumTypes: [], // variant_type is inside type_binding, handled in visitNode + enumMemberTypes: [], // variant_declaration is inside variant_type, handled in visitNode + typeAliasTypes: [], // type_declaration handled in visitNode (name is on type_binding, not directly) + importTypes: ['open_statement'], + callTypes: ['call_expression'], + variableTypes: [], // let_declaration handled in visitNode (name is on let_binding pattern, not directly) + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + returnField: 'return_type', + + // The orchestrator only calls getSignature for nodes matched via functionTypes / + // methodTypes. Since ReScript handles those in visitNode, this hook is only + // reached if a future change adds function nodes to functionTypes. + getSignature: (node, source) => { + if (node.type === 'function') { + return getFunctionSignature(node, source); + } + return undefined; + }, + + extractImport: (node, source) => { + const mod = node.namedChildren.find((c) => c.type === 'module_identifier'); + if (mod) { + const moduleName = getNodeText(mod, source); + return { + moduleName, + signature: getNodeText(node, source).trim().slice(0, 100), + }; + } + return null; + }, + + visitNode: (node, ctx) => { + const source = ctx.source; + + // let_declaration → let_binding → function | constant + if (node.type === 'let_declaration') { + const binding = node.namedChildren.find((c) => c.type === 'let_binding'); + if (!binding) return false; + + const name = getLetBindingName(binding, source); + if (!name) return false; + + const body = getChildByField(binding, 'body'); + const isFunction = body?.type === 'function'; + + if (isFunction && body) { + const signature = getFunctionSignature(body, source); + const funcNode = ctx.createNode('function', name, node, { signature }); + if (funcNode) { + ctx.extractTypeAnnotations(body, funcNode.id); + extractReScriptDecorators(node, source, ctx, funcNode.id); + const funcBody = getChildByField(body, 'body'); + if (funcBody) { + ctx.pushScope(funcNode.id); + ctx.visitFunctionBody(funcBody, funcNode.id); + ctx.popScope(); + } + } + } else { + // ReScript let bindings are immutable by default — use 'constant' + const constNode = ctx.createNode('constant', name, node); + if (constNode) { + ctx.extractTypeAnnotations(binding, constNode.id); + extractReScriptDecorators(node, source, ctx, constNode.id); + } + if (body) { + ctx.visitNode(body); + } + } + return true; + } + + // module_declaration → module_binding → module + if (node.type === 'module_declaration') { + const binding = node.namedChildren.find((c) => c.type === 'module_binding'); + if (!binding) return false; + + const name = getModuleBindingName(binding, source); + if (!name) return false; + + const moduleNode = ctx.createNode('module', name, node); + if (moduleNode) { + extractReScriptDecorators(node, source, ctx, moduleNode.id); + const definition = getChildByField(binding, 'definition'); + if (definition) { + ctx.pushScope(moduleNode.id); + for (let i = 0; i < definition.namedChildCount; i++) { + const child = definition.namedChild(i); + if (child) ctx.visitNode(child); + } + ctx.popScope(); + } + } + return true; + } + + // type_declaration → type_binding → struct | enum | type_alias + if (node.type === 'type_declaration') { + const binding = node.namedChildren.find((c) => c.type === 'type_binding'); + if (!binding) return false; + + const name = getTypeBindingName(binding, source); + if (!name) return false; + + const body = getChildByField(binding, 'body'); + if (!body) { + const aliasNode = ctx.createNode('type_alias', name, node); + if (aliasNode) { + extractReScriptDecorators(node, source, ctx, aliasNode.id); + } + return true; + } + + if (body.type === 'record_type') { + const structNode = ctx.createNode('struct', name, node); + if (structNode) { + extractReScriptDecorators(node, source, ctx, structNode.id); + ctx.pushScope(structNode.id); + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child?.type === 'record_type_field') { + const prop = child.namedChildren.find((c) => c.type === 'property_identifier'); + if (prop) { + const fieldName = getNodeText(prop, source); + const typeAnno = child.namedChildren.find((c) => c.type === 'type_annotation'); + const sig = typeAnno + ? `${fieldName}: ${getNodeText(typeAnno, source).replace(/^:\s*/, '')}` + : fieldName; + const fieldNode = ctx.createNode('field', fieldName, child, { signature: sig }); + if (fieldNode) { + ctx.extractTypeAnnotations(child, fieldNode.id); + } + } + } + } + ctx.popScope(); + } + } else if (body.type === 'variant_type') { + const enumNode = ctx.createNode('enum', name, node); + if (enumNode) { + extractReScriptDecorators(node, source, ctx, enumNode.id); + ctx.pushScope(enumNode.id); + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child?.type === 'variant_declaration') { + const variant = child.namedChildren.find((c) => c.type === 'variant_identifier'); + if (variant) { + ctx.createNode('enum_member', getNodeText(variant, source), child); + } + } + } + ctx.popScope(); + } + } else { + const aliasNode = ctx.createNode('type_alias', name, node); + if (aliasNode) { + extractReScriptDecorators(node, source, ctx, aliasNode.id); + } + } + return true; + } + + return false; + }, +}; diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 28338b0ac..ba16519d8 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -56,6 +56,8 @@ export interface ExtractorContext { visitFunctionBody(body: SyntaxNode, functionId: string): void; /** Add an unresolved reference */ addUnresolvedReference(ref: UnresolvedReference): void; + /** Extract type references (type_identifier nodes) from an AST subtree and emit unresolved 'references' edges */ + extractTypeAnnotations(node: SyntaxNode, nodeId: string): void; /** Push a node ID onto the scope stack (for containment/qualified name building) */ pushScope(nodeId: string): void; /** Pop the last node ID from the scope stack */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index e62f97578..c67310d99 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -930,6 +930,7 @@ export class TreeSitterExtractor { visitNode: (node) => self.visitNode(node), visitFunctionBody: (body, functionId) => self.visitFunctionBody(body, functionId), addUnresolvedReference: (ref) => self.unresolvedReferences.push(ref), + extractTypeAnnotations: (node, nodeId) => self.extractTypeAnnotations(node, nodeId), pushScope: (nodeId) => self.nodeStack.push(nodeId), popScope: () => self.nodeStack.pop(), get filePath() { return self.filePath; }, @@ -3832,7 +3833,7 @@ export class TreeSitterExtractor { * Languages that support type annotations (TypeScript, etc.) */ private readonly TYPE_ANNOTATION_LANGUAGES = new Set([ - 'typescript', 'tsx', 'dart', 'kotlin', 'swift', 'rust', 'go', 'java', 'csharp', 'scala', 'php', + 'typescript', 'tsx', 'dart', 'kotlin', 'swift', 'rust', 'go', 'java', 'csharp', 'scala', 'php', 'rescript', ]); /** @@ -3861,6 +3862,8 @@ export class TreeSitterExtractor { // Scala (capitalized primitives + ubiquitous stdlib aliases) 'Int', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Boolean', 'Char', 'Unit', 'String', 'Any', 'AnyRef', 'AnyVal', 'Nothing', 'Null', + // ReScript + 'unit', 'option', 'list', 'result', 'promise', 'array', 'dict', 'map', 'set', 'date', ]); /** diff --git a/src/types.ts b/src/types.ts index e57a74229..877fd5a34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,6 +90,7 @@ export const LANGUAGES = [ 'lua', 'luau', 'objc', + 'rescript', 'yaml', 'twig', 'xml',