import { parser } from '../../vendor/lezer-gdscript/lezer-gdscript.mjs'
/**
* GDScript parser — walks the Lezer concrete syntax tree to extract:
* - class_name (treated as the file's exported class)
* - extends (base class)
* - signals (GDScript-specific)
* - functions / methods
* - constants and variables
* - preload() calls → imports
*
* A .gd file IS a class (the class_name declares its global identity).
* Top-level funcs are methods of that class; top-level vars are its state.
*/
export function parseFile (source, filePath) {
let tree
try { tree = parser.parse(source) }
catch (err) { return { error: err.message, filePath } }
const result = {
filePath,
imports: [],
exports: [],
classes: [],
functions: [],
constants: [],
signals: [],
enums: []
}
// Single implicit class for the file — filled in as we walk
const fileClass = {
name: null, // filled from class_name, or file basename
extends: null,
line: 1,
methods: [],
getters: [],
staticMethods: [],
constructor: null
}
const cursor = tree.cursor()
// Walk only top-level statements inside Script
if (cursor.firstChild()) {
do {
visitTopLevel(cursor, source, fileClass, result)
} while (cursor.nextSibling())
}
// Default name from filename if no class_name found
if (!fileClass.name) {
const base = filePath.split('/').pop().replace(/\.gd$/, '')
fileClass.name = base
}
// Emit the class unless the file is clearly not a class (no name, no body)
if (fileClass.methods.length > 0 || fileClass.getters.length > 0 ||
fileClass.staticMethods.length > 0 || fileClass.extends || result.exports.length > 0) {
result.classes.push(fileClass)
}
return result
}
function visitTopLevel (cursor, source, fileClass, result) {
const name = cursor.name
// SimpleStatement wraps most top-level statements
if (name === 'SimpleStatement' || name === 'SmallStatement') {
if (cursor.firstChild()) {
do { visitTopLevel(cursor, source, fileClass, result) } while (cursor.nextSibling())
cursor.parent()
}
return
}
// CompoundStatement wraps function/class bodies
if (name === 'CompoundStatement') {
if (cursor.firstChild()) {
do { visitTopLevel(cursor, source, fileClass, result) } while (cursor.nextSibling())
cursor.parent()
}
return
}
// class_name → file's class name + exported as a class
if (name === 'ClassNameStatement') {
const ident = identifierText(cursor, source)
if (ident) {
fileClass.name = ident
fileClass.line = getLine(source, cursor.from)
result.exports.push({ name: ident, type: 'class' })
}
return
}
// extends → base class
if (name === 'ExtendsStatement') {
const ident = identifierText(cursor, source)
if (ident) fileClass.extends = ident
return
}
// signal → signals array
if (name === 'SignalStatement') {
const sig = extractSignal(cursor, source)
if (sig) result.signals.push(sig)
return
}
// func / static func → method
if (name === 'FunctionNode') {
const fn = extractFunction(cursor, source)
if (fn) {
// Check if preceded by a "static" keyword (GDScript puts static before func)
const isStatic = isPrecededByStatic(source, cursor.from)
if (isStatic) fn.static = true
if (fn.name === '_init') {
fileClass.constructor = { params: fn.params, line: fn.line }
} else if (isStatic) {
fileClass.staticMethods.push(fn)
} else {
fileClass.methods.push(fn)
}
}
return
}
// AssignmentExpressionNode wraps var/const declarations
if (name === 'AssignmentExpressionNode') {
extractAssignment(cursor, source, result)
return
}
}
// --- Helpers ---
function identifierText (cursor, source) {
if (!cursor.firstChild()) return null
try {
do {
if (cursor.name === 'Identifier') {
const text = source.slice(cursor.from, cursor.to)
// Skip keyword-like identifiers
if (text !== 'class_name' && text !== 'extends' && text !== 'signal' &&
text !== 'func' && text !== 'var' && text !== 'const' && text !== 'enum') {
return text
}
}
} while (cursor.nextSibling())
} finally {
cursor.parent()
}
return null
}
function extractSignal (cursor, source) {
const sig = { name: null, params: [] }
if (!cursor.firstChild()) return null
try {
do {
if (cursor.name === 'Identifier' && !sig.name) {
const text = source.slice(cursor.from, cursor.to)
if (text !== 'signal') sig.name = text
} else if (cursor.name === 'CallParamsExpressionNode') {
sig.params = extractParamsFromCall(cursor, source)
}
} while (cursor.nextSibling())
} finally {
cursor.parent()
}
return sig.name ? sig : null
}
function extractFunction (cursor, source) {
const fn = { name: null, params: [], line: getLine(source, cursor.from) }
if (!cursor.firstChild()) return null
try {
do {
if (cursor.name === 'Identifier' && !fn.name) {
const text = source.slice(cursor.from, cursor.to)
if (text !== 'func' && text !== 'static') fn.name = text
} else if (cursor.name === 'CallParamsParameterNode') {
fn.params = extractParamsFromCall(cursor, source)
}
} while (cursor.nextSibling())
} finally {
cursor.parent()
}
return fn.name ? fn : null
}
function extractParamsFromCall (cursor, source) {
// cursor is at CallParamsParameterNode or CallParamsExpressionNode
const params = []
if (!cursor.firstChild()) return params
try {
do {
if (cursor.name === 'ParameterNode' || cursor.name === 'AssignmentExpressionNode') {
// Grab first identifier as param name
const paramName = firstIdentifier(cursor, source)
if (paramName) params.push(paramName)
}
} while (cursor.nextSibling())
} finally {
cursor.parent()
}
return params
}
function firstIdentifier (cursor, source) {
if (!cursor.firstChild()) return null
try {
do {
if (cursor.name === 'Identifier') {
return source.slice(cursor.from, cursor.to)
}
} while (cursor.nextSibling())
} finally {
cursor.parent()
}
return null
}
function extractAssignment (cursor, source, result) {
// AssignmentExpressionNode may contain VariableNode, ConstantNode, and/or PreloadExpressionNode
if (!cursor.firstChild()) return
try {
do {
if (cursor.name === 'VariableNode' || cursor.name === 'ConstantNode') {
const kind = cursor.name === 'ConstantNode' ? 'const' : 'var'
const ident = firstIdentifier(cursor, source)
if (ident) result.constants.push({ name: ident, kind })
} else if (cursor.name === 'PreloadExpressionNode') {
const path = extractPreloadPath(cursor, source)
if (path) result.imports.push({ specifier: path, names: [] })
}
} while (cursor.nextSibling())
} finally {
cursor.parent()
}
}
function extractPreloadPath (cursor, source) {
// Find LiteralExpressionNode within CallParamsExpressionNode
if (!cursor.firstChild()) return null
try {
do {
if (cursor.name === 'CallParamsExpressionNode') {
const lit = findLiteralInCall(cursor, source)
if (lit) {
// Strip quotes
return lit.replace(/^['"]|['"]$/g, '')
}
}
} while (cursor.nextSibling())
} finally {
cursor.parent()
}
return null
}
function findLiteralInCall (cursor, source) {
if (!cursor.firstChild()) return null
try {
do {
if (cursor.name === 'LiteralExpressionNode') {
return source.slice(cursor.from, cursor.to)
}
} while (cursor.nextSibling())
} finally {
cursor.parent()
}
return null
}
function isPrecededByStatic (source, from) {
// Look backwards from `from` for the word 'static' before 'func'
// GDScript: `static func` — static appears on same line, before func
const before = source.slice(Math.max(0, from - 60), from)
return /\bstatic\s*$/.test(before) || /\bstatic\s+\n?\s*$/.test(before)
}
function getLine (source, offset) {
return source.slice(0, offset).split('\n').length
}