diff --git a/CHANGELOG.md b/CHANGELOG.md index f44be8edd..7cf479fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. + - Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list. - `codegraph_explore` no longer re-sends source it already returned earlier in the same conversation. A file it has already shown you comes back as a short pointer — the path, the symbols and the exact line range, with confirmation that the file hasn't changed since — and the space that frees is spent on code you haven't seen yet, so a follow-up call covers new ground instead of repeating the last one. If a file was edited in between, its source is always shown again in full. Set `CODEGRAPH_EXPLORE_DEDUP=0` to turn this off. diff --git a/README.md b/README.md index f8bb60bbc..2ad45b8f5 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 | @@ -796,6 +796,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 292658822..470a8798d 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'); @@ -205,6 +211,7 @@ describe('Language Support', () => { expect(languages).toContain('dart'); expect(languages).toContain('solidity'); expect(languages).toContain('nix'); + expect(languages).toContain('gdscript'); }); }); @@ -347,6 +354,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 d4127631d..27e84c6dd 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 @@ -640,6 +642,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',