diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index d4127631d..c026c5d45 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', }; /** @@ -75,6 +76,7 @@ export const EXTENSION_MAP: Record = { '.py': 'python', '.pyw': 'python', '.go': 'go', + '.gd': 'gdscript', '.rs': 'rust', '.java': 'java', '.c': 'c', @@ -289,6 +291,7 @@ export async function initGrammars(): Promise { * the vendored wasm together. */ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ + 'gdscript', 'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery', 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', 'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', @@ -652,6 +655,7 @@ export function getLanguageDisplayName(language: Language): string { cfquery: 'CFQuery (SQL)', cobol: 'COBOL', vbnet: 'Visual Basic .NET', + gdscript: 'GDScript', erlang: 'Erlang', terraform: 'Terraform', arkts: 'ArkTS', diff --git a/src/extraction/languages/gdscript.ts b/src/extraction/languages/gdscript.ts new file mode 100644 index 000000000..026ed95f4 --- /dev/null +++ b/src/extraction/languages/gdscript.ts @@ -0,0 +1,140 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +// Node names follow PrestonKnopp/tree-sitter-gdscript (ABI-15). +// GDScript is Python-like: indentation-based, dynamically typed, with +// Godot-specific constructs (class_name, extends, preload, signals, @export). + +/** First descendant of a given type (breadth-first), or null. */ +function findDescendant(node: SyntaxNode, type: string): SyntaxNode | null { + const queue: SyntaxNode[] = [...node.namedChildren]; + while (queue.length) { + const n = queue.shift()!; + if (n.type === type) return n; + queue.push(...n.namedChildren); + } + return null; +} + +/** Extract string content from a string literal node, stripping quotes. */ +function extractString(node: SyntaxNode, source: string): string | null { + const content = findDescendant(node, 'string_content'); + if (content) return getNodeText(content, source).trim() || null; + const text = getNodeText(node, source).trim(); + return text.replace(/^["']|["']$/g, '') || null; +} + +/** + * If `callNode` is `preload("res://...")` or `load("res://...")`, + * return the path string; otherwise null. + */ +function preloadOrLoad(callNode: SyntaxNode, source: string): string | null { + const name = getChildByField(callNode, 'name'); + if (!name || name.type !== 'identifier') return null; + const callee = getNodeText(name, source); + if (callee !== 'preload' && callee !== 'load') return null; + + const args = getChildByField(callNode, 'arguments'); + if (!args) return null; + const str = findDescendant(args, 'string'); + return str ? extractString(str, source) : null; +} + +/** + * Extract the base name from an extends_statement. + * Returns null if no extends clause found. + */ +function extractExtends(node: SyntaxNode, source: string): string | null { + // Look for extends_statement child (used by class_name_statement) + const extendsStmt = node.namedChildren.find((c) => c.type === 'extends_statement'); + if (!extendsStmt) return null; + + // extends_statement > type > identifier or string + const typeNode = getChildByField(extendsStmt, 'type'); + if (!typeNode) return null; + + const ident = typeNode.namedChildren.find((c) => c.type === 'identifier'); + if (ident) return getNodeText(ident, source); + + const str = typeNode.namedChildren.find((c) => c.type === 'string'); + if (str) return extractString(str, source); + return null; +} + +export const gdscriptExtractor: LanguageExtractor = { + // GDScript functions: `func name(...)` — top-level or inside class. + // class_name_statement creates global classes (no class keyword needed). + functionTypes: ['function_definition'], + classTypes: ['class_name_statement'], + methodTypes: [], // methods are function_definition inside class context + interfaceTypes: [], + structTypes: [], + // enum blocks: `enum { A, B }` or `enum Name { A, B }` + enumTypes: ['enum_definition'], + typeAliasTypes: [], + importTypes: [], // preload/load are function calls — handled in visitNode + callTypes: ['call'], + variableTypes: [], // GDScript vars are not first-class symbols in most cases + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + + getSignature: (node: SyntaxNode, source: string) => { + const params = getChildByField(node, 'parameters'); + return params ? getNodeText(params, source) : undefined; + }, + + // For function_definition inside a class, the class name is the receiver. + // This is handled by the extraction framework via nodeStack context. + getReceiverType: () => undefined, + + // Handle preload/load calls and extends clauses. + visitNode: (node: SyntaxNode, ctx) => { + const source = ctx.source; + + // Handle preload/load as import edges. + if (node.type === 'call') { + const path = preloadOrLoad(node, source); + if (path) { + const imp = ctx.createNode('import', path, node, { + signature: getNodeText(node, source).trim().slice(0, 100), + }); + if (imp && ctx.nodeStack.length > 0) { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (parentId) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: path, + referenceKind: 'imports', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + return true; // claimed as import, not a generic call + } + return false; + } + + // Handle extends clauses on class_name_statement. + if (node.type === 'class_name_statement') { + const base = extractExtends(node, source); + if (base && ctx.nodeStack.length > 0) { + const classId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (classId) { + ctx.addUnresolvedReference({ + fromNodeId: classId, + referenceName: base, + referenceKind: 'extends', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + return false; + } + + return false; + }, +}; \ No newline at end of file diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..4e32f489b 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -24,6 +24,7 @@ import { dartExtractor } from './dart'; import { pascalExtractor } from './pascal'; import { scalaExtractor } from './scala'; import { luaExtractor } from './lua'; +import { gdscriptExtractor } from './gdscript'; import { rExtractor } from './r'; import { luauExtractor } from './luau'; import { objcExtractor } from './objc'; @@ -59,6 +60,7 @@ export const EXTRACTORS: Partial> = { lua: luaExtractor, r: rExtractor, luau: luauExtractor, + gdscript: gdscriptExtractor, objc: objcExtractor, cfscript: cfscriptExtractor, cfquery: cfqueryExtractor, diff --git a/src/extraction/wasm/tree-sitter-gdscript.wasm b/src/extraction/wasm/tree-sitter-gdscript.wasm new file mode 100755 index 000000000..7159ed3f1 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 8cc600b1c..a150671f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,6 +114,7 @@ export const LANGUAGES = [ 'cfquery', 'cobol', 'vbnet', + 'gdscript', 'erlang', 'terraform', 'unknown',