diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..aa3012278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- GDScript (`.gd`) is now a supported language: functions and typed signatures, `_init` constructors, inner classes with methods, the full `var`/`const`/`@export`/`@onready` variable family, signals (extracted as properties with their parameter lists), enums with members, `static func` detection, and call edges — including calls inside initializers like `preload(...)`. Grammar: PrestonKnopp/tree-sitter-gdscript v6.1.0, vendored as an ABI-15 wasm rebuilt from upstream source. + ## [1.6.0] - 2026-08-26 diff --git a/README.md b/README.md index 48323f6fd..5c224cb67 100644 --- a/README.md +++ b/README.md @@ -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, GDScript, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, 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 | @@ -818,6 +818,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`) | +| GDScript | `.gd` | Full support (functions with typed signatures, `_init` constructors, inner classes, `var`/`const`/`@export`/`@onready` variables, signals, enums with members, static detection, call edges — including calls inside initializers like `preload()`) | | CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based ``/`` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `` delegation, call edges) | | COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) | | Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) | diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index ad0ba2374..1f7a72c87 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -144,6 +144,12 @@ describe('Language Detection', () => { expect(detectLanguage('entry/src/main/ets/common/utils.ts')).toBe('typescript'); }); + it('should detect GDScript files', () => { + expect(detectLanguage('player.gd')).toBe('gdscript'); + expect(detectLanguage('scripts/enemies/boss_ai.gd')).toBe('gdscript'); + expect(isSourceFile('player.gd')).toBe(true); + }); + it('should detect Nix files', () => { expect(detectLanguage('default.nix')).toBe('nix'); expect(detectLanguage('pkgs/development/tools/misc/codegraph/default.nix')).toBe('nix'); @@ -252,6 +258,7 @@ describe('Language Support', () => { expect(languages).toContain('dart'); expect(languages).toContain('solidity'); expect(languages).toContain('nix'); + expect(languages).toContain('gdscript'); }); }); @@ -394,6 +401,112 @@ in }); }); +describe('GDScript Extraction', () => { + it('should extract functions, constructor, and signatures', () => { + const code = `extends Node2D + +func _ready() -> void: + set_process(true) + +func _init(width: int, height: int = 32): + pass + +static func clamp_value(v: float, lo: float, hi: float) -> float: + return clampf(v, lo, hi) +`; + + const result = extractFromSource('player.gd', code); + + const ready = result.nodes.find((n) => n.kind === 'function' && n.name === '_ready'); + expect(ready?.signature).toBe('() -> void'); + + // constructor_definition has no name field; resolveName supplies _init + const init = result.nodes.find((n) => n.name === '_init'); + expect(init).toBeDefined(); + expect(init?.signature).toBe('(width: int, height: int = 32)'); + + const clampValue = result.nodes.find((n) => n.name === 'clamp_value'); + expect(clampValue?.signature).toBe('(v: float, lo: float, hi: float) -> float'); + expect(clampValue?.isStatic).toBe(true); + + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('set_process'); + expect(calls).toContain('clampf'); + }); + + it('should extract the var/const family and walk initializers for calls', () => { + const code = `extends Node + +const MAX_SPEED := 300.0 +var health: int = 100 +@export var display_name: String = "Player" +@onready var sprite = get_node("Sprite2D") +var scene = preload("res://enemy.tscn") +`; + + const result = extractFromSource('stats.gd', code); + + const maxSpeed = result.nodes.find((n) => n.kind === 'constant' && n.name === 'MAX_SPEED'); + expect(maxSpeed).toBeDefined(); + + const health = result.nodes.find((n) => n.kind === 'variable' && n.name === 'health'); + expect(health?.signature).toBe(': int = 100'); + + expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'display_name')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'sprite')).toBeDefined(); + + // Initializers are walked, so calls inside them are captured. + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('get_node'); + expect(calls).toContain('preload'); + }); + + it('should extract signals as properties with their parameter list', () => { + const code = `extends Node + +signal died +signal health_changed(old_value, new_value) +`; + + const result = extractFromSource('events.gd', code); + + const died = result.nodes.find((n) => n.kind === 'property' && n.name === 'died'); + expect(died).toBeDefined(); + + const healthChanged = result.nodes.find((n) => n.kind === 'property' && n.name === 'health_changed'); + expect(healthChanged?.signature).toBe('(old_value, new_value)'); + }); + + it('should extract enums with members and inner classes with methods', () => { + const code = `extends Node + +enum State { IDLE, RUNNING = 10, DEAD } + +class Inventory: + var items := [] + + func add(item) -> void: + items.append(item) +`; + + const result = extractFromSource('game.gd', code); + + expect(result.nodes.find((n) => n.kind === 'enum' && n.name === 'State')).toBeDefined(); + const members = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name); + expect(members).toContain('IDLE'); + expect(members).toContain('RUNNING'); + expect(members).toContain('DEAD'); + + expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'Inventory')).toBeDefined(); + const add = result.nodes.find((n) => n.kind === 'method' && n.name === 'add'); + expect(add?.signature).toBe('(item) -> void'); + + // obj.method(args) parses as attribute_call — the bare method name is emitted. + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('append'); + }); +}); + describe('TypeScript Extraction', () => { it('should extract function declarations', () => { const code = ` diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 84647c3e4..bfa07209a 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -50,6 +50,7 @@ const WASM_GRAMMAR_FILES: Record = { terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', + gdscript: 'tree-sitter-gdscript.wasm', }; /** @@ -121,6 +122,7 @@ export const EXTENSION_MAP: Record = { '.sc': 'scala', '.lua': 'lua', '.luau': 'luau', + '.gd': 'gdscript', '.m': 'objc', '.mm': 'objc', '.sol': 'solidity', @@ -290,7 +292,7 @@ export async function initGrammars(): Promise { */ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ 'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery', - 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', + 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', 'gdscript', '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 @@ -679,6 +681,7 @@ export function getLanguageDisplayName(language: Language): string { scala: 'Scala', lua: 'Lua', luau: 'Luau', + gdscript: 'GDScript', objc: 'Objective-C', solidity: 'Solidity', nix: 'Nix', diff --git a/src/extraction/languages/gdscript.ts b/src/extraction/languages/gdscript.ts new file mode 100644 index 000000000..da0ed3886 --- /dev/null +++ b/src/extraction/languages/gdscript.ts @@ -0,0 +1,102 @@ +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +// GDScript (Godot, tree-sitter-gdscript). A Python-like indentation grammar +// where every `.gd` file is an implicit class — top-level `func`s extract as +// functions, `class X:` inner classes as classes with methods. +// +// Grammar shapes that need care: +// - The var/const family (`variable_statement`, `export_variable_statement`, +// `onready_variable_statement`, `const_statement`) names its target via a +// `name`-typed child, NOT `identifier`, so the core's generic variable +// fallback (which looks for identifier children) can't read them. The +// visitNode hook creates variable/constant nodes itself, then walks the +// initializer so calls inside it (`preload(...)`, `Foo.new()`) are captured. +// - `func _init(...)` (constructor_definition) has no name field; resolveName +// supplies the conventional `_init`. +// - `enumerator` names its identifier via `left`, not `name`. +// - `obj.method(args)` parses as attribute(identifier, attribute_call(...)), +// so attribute_call joins callTypes; the core's namedChild(0) callee +// fallback then yields the bare method name (resolution is name-match only, +// matching how self/this receivers are emitted elsewhere). +// - `signal foo(a, b)` extracts as a property carrying the parameter list, so +// connect()-heavy scripts expose their signal surface in the graph. +const VARIABLE_NODE_TYPES = new Set([ + 'variable_statement', + 'export_variable_statement', + 'onready_variable_statement', + 'const_statement', +]); + +export const gdscriptExtractor: LanguageExtractor = { + functionTypes: ['function_definition', 'constructor_definition'], + classTypes: ['class_definition'], + methodTypes: ['function_definition', 'constructor_definition'], + interfaceTypes: [], + structTypes: [], + enumTypes: ['enum_definition'], + enumMemberTypes: ['enumerator'], + typeAliasTypes: [], + importTypes: [], + callTypes: ['call', 'attribute_call', 'base_call'], + variableTypes: [], // handled by the visitNode hook (see above) + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + returnField: 'return_type', + + resolveName: (node, source) => { + if (node.type === 'constructor_definition') return '_init'; + if (node.type === 'enumerator') { + const left = getChildByField(node, 'left'); + return left ? getNodeText(left, source) : undefined; + } + return undefined; + }, + + // `static` is a named static_keyword CHILD (no field) on function_definition; + // the var statements carry it via a field, but a child scan covers both. + isStatic: (node) => node.namedChildren.some((c) => c?.type === 'static_keyword'), + + getSignature: (node, source) => { + const params = getChildByField(node, 'parameters'); + if (!params) return undefined; + let sig = getNodeText(params, source); + const ret = getChildByField(node, 'return_type'); + if (ret) sig += ' -> ' + getNodeText(ret, source); + return sig; + }, + + visitNode: (node, ctx) => { + if (node.type === 'signal_statement') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const params = node.childForFieldName('parameters'); + ctx.createNode('property', getNodeText(nameNode, ctx.source), node, { + signature: params ? getNodeText(params, ctx.source) : undefined, + }); + } + return true; + } + if (VARIABLE_NODE_TYPES.has(node.type)) { + const nameNode = node.childForFieldName('name'); + const valueNode = node.childForFieldName('value'); + if (nameNode) { + const typeNode = node.childForFieldName('type'); + const typeSig = typeNode ? `: ${getNodeText(typeNode, ctx.source)}` : ''; + const initValue = valueNode ? getNodeText(valueNode, ctx.source).slice(0, 100) : ''; + const initSig = initValue ? ` = ${initValue}${initValue.length >= 100 ? '...' : ''}` : ''; + ctx.createNode( + node.type === 'const_statement' ? 'constant' : 'variable', + getNodeText(nameNode, ctx.source), + node, + { signature: (typeSig + initSig).trim() || undefined }, + ); + } + // Walk the initializer so calls inside it (preload(), Foo.new()) are captured. + if (valueNode) ctx.visitNode(valueNode); + return true; + } + return false; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..1feacac5c 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -36,6 +36,7 @@ import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; +import { gdscriptExtractor } from './gdscript'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -69,4 +70,5 @@ export const EXTRACTORS: Partial> = { terraform: terraformExtractor, arkts: arktsExtractor, nix: nixExtractor, + gdscript: gdscriptExtractor, }; diff --git a/src/extraction/wasm/tree-sitter-gdscript.wasm b/src/extraction/wasm/tree-sitter-gdscript.wasm new file mode 100644 index 000000000..a67891fab Binary files /dev/null and b/src/extraction/wasm/tree-sitter-gdscript.wasm differ diff --git a/src/types.ts b/src/types.ts index 186f57adc..130dfb5af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,7 @@ export const LANGUAGES = [ 'scala', 'lua', 'luau', + 'gdscript', 'objc', 'r', 'solidity',