OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / parsers / gdscript.js

289 lines · javascript ref:
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
}