OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / parsers / glsl.js

297 lines · javascript ref:
import { parse } from '../../vendor/glsl-parser/glsl-parser.mjs'

/**
 * GLSL parser — walks the @shaderfrog/glsl-parser AST to extract:
 *  - Functions (void main, helpers)
 *  - Uniforms (regular uniform vars + uniform blocks)
 *  - Buffers (readonly/writeonly storage buffers)
 *  - Push constants (uniform blocks with push_constant layout)
 *  - Structs → classes
 *  - #defines → constants
 *
 * GLSL has no import system in the general case (dc-terrain shaders are monolithic),
 * so imports[] is always empty. Godot .gdshader files may use `#include` but those
 * are engine-specific and not modeled here yet.
 */

export function parseFile (source, filePath) {
  // Always return these empty arrays so downstream consumers can `.length` safely even
  // on parse failure.
  const result = {
    filePath,
    imports: [],
    exports: [],
    classes: [],
    functions: [],
    constants: [],
    uniforms: [],
    buffers: [],
    pushConstants: []
  }

  // Godot .gdshader files are a GLSL superset with editor hints that standard GLSL
  // doesn't accept (e.g. `uniform sampler3D foo : filter_linear, repeat_enable;`).
  // Strip these hints — they're purely presentational, not part of the shader logic.
  const isGdShader = filePath && filePath.endsWith('.gdshader')
  const processed = isGdShader ? stripGodotShaderHints(source) : source

  // The parser emits warnings to console.warn for unknown identifiers (e.g. GLSL builtins
  // like gl_GlobalInvocationID). Silence them during structural extraction.
  const origWarn = console.warn
  console.warn = () => {}
  let ast
  try { ast = parse(processed) }
  catch (err) {
    console.warn = origWarn
    result.error = err.message
    return result
  }
  console.warn = origWarn

  if (!ast || !ast.program) return result

  for (const node of ast.program) {
    switch (node.type) {
      case 'preprocessor':
        handlePreprocessor(node, result)
        break
      case 'declaration_statement':
        handleDeclarationStatement(node, result)
        break
      case 'function':
        handleFunction(node, result)
        break
    }
  }

  return result
}

// --- Preprocessor (#define, #include) ---

function handlePreprocessor (node, result) {
  const line = node.line || ''

  // #define NAME value — extract as constant
  const defMatch = line.match(/^\s*#\s*define\s+(\w+)\b/)
  if (defMatch) {
    result.constants.push({ name: defMatch[1], kind: 'define' })
    return
  }

  // #include "path" (Godot .gdshader style) — capture as import
  const incMatch = line.match(/^\s*#\s*include\s+["<]([^">]+)[">]/)
  if (incMatch) {
    result.imports.push({ specifier: incMatch[1], names: [] })
  }
}

// --- Declaration statements (uniforms, buffers, structs, layout) ---

function handleDeclarationStatement (node, result) {
  const decl = node.declaration
  if (!decl) return

  switch (decl.type) {
    case 'interface_declarator':
      handleInterfaceDeclarator(decl, result)
      return
    case 'declarator_list':
      handleDeclaratorList(decl, result)
      return
    case 'qualifier_declarator':
      // layout-only decls like `layout(local_size_x=256) in;` — no extraction needed
      return
  }
}

function handleInterfaceDeclarator (decl, result) {
  // Used for: buffer blocks, uniform blocks, push constants
  const qualTokens = extractQualifierTokens(decl.qualifiers || [])
  const hasLayout = (decl.qualifiers || []).some(q => q.type === 'layout_qualifier')
  const layoutIds = extractLayoutIds(decl.qualifiers || [])

  const blockName = decl.interface_type && decl.interface_type.identifier
    ? decl.interface_type.identifier
    : null
  const instanceName = decl.identifier && decl.identifier.identifier
    ? decl.identifier.identifier
    : null

  if (!blockName) return

  const isBuffer = qualTokens.includes('buffer')
  const isUniform = qualTokens.includes('uniform')
  const isPushConstant = layoutIds.some(id => id === 'push_constant')
  const isReadonly = qualTokens.includes('readonly')
  const isWriteonly = qualTokens.includes('writeonly')

  const entry = {
    name: blockName,
    instance: instanceName,
    binding: layoutIdValue(layoutIds, 'binding'),
    set: layoutIdValue(layoutIds, 'set'),
    std: layoutIds.find(id => id === 'std140' || id === 'std430') || null
  }
  if (isReadonly) entry.readonly = true
  if (isWriteonly) entry.writeonly = true

  if (isPushConstant) {
    result.pushConstants.push(entry)
  } else if (isBuffer) {
    result.buffers.push(entry)
  } else if (isUniform) {
    result.uniforms.push(entry)
  }
}

function handleDeclaratorList (decl, result) {
  // Could be:
  //  - struct declaration: specifier.type === 'struct'
  //  - uniform sampler2D u_tex;
  //  - in/out variables
  //  - regular global variables
  const specType = decl.specified_type && decl.specified_type.specifier
  const quals = (decl.specified_type && decl.specified_type.qualifiers) || []
  const qualTokens = extractQualifierTokens(quals)

  // Struct declaration
  if (specType && specType.specifier && specType.specifier.type === 'struct') {
    const structNode = specType.specifier
    const structName = structNode.typeName && structNode.typeName.identifier
      ? structNode.typeName.identifier
      : null
    if (structName) {
      const cls = {
        name: structName,
        kind: 'struct',
        extends: null,
        line: null,
        methods: [],
        getters: [],
        staticMethods: [],
        constructor: null,
        fields: extractStructFields(structNode)
      }
      result.classes.push(cls)
      result.exports.push({ name: structName, type: 'struct' })
    }
    return
  }

  // Regular variable declaration (uniform float u_time, in vec3 pos, etc.)
  const declarations = decl.declarations || []
  for (const d of declarations) {
    if (d.type === 'declaration' && d.identifier && d.identifier.identifier) {
      const name = d.identifier.identifier
      const typeToken = specType && specType.specifier && specType.specifier.token
        ? specType.specifier.token
        : null
      if (qualTokens.includes('uniform')) {
        result.uniforms.push({ name, type: typeToken, binding: null, set: null })
      } else if (qualTokens.includes('in') || qualTokens.includes('out')) {
        result.constants.push({ name, kind: qualTokens.includes('out') ? 'out' : 'in' })
      } else {
        result.constants.push({ name, kind: 'var' })
      }
    }
  }
}

function extractStructFields (structNode) {
  const fields = []
  for (const d of structNode.declarations || []) {
    if (d.type !== 'struct_declaration') continue
    const sd = d.declaration
    if (!sd || !sd.declarations) continue
    for (const q of sd.declarations) {
      if (q.type === 'quantified_identifier' && q.identifier) {
        fields.push(q.identifier.identifier)
      }
    }
  }
  return fields
}

// --- Function definitions ---

function handleFunction (node, result) {
  const proto = node.prototype
  if (!proto || !proto.header) return
  const header = proto.header
  const name = header.name && header.name.identifier ? header.name.identifier : null
  if (!name) return

  const params = []
  const pl = proto.parameters || []
  for (const p of pl) {
    if (p.type === 'parameter_declaration' && p.declaration && p.declaration.identifier) {
      params.push(p.declaration.identifier.identifier)
    }
  }

  result.functions.push({ name, params })
  // GLSL `void main()` is the shader's entry point — export it so downstream tools
  // can tell entry shaders from helpers.
  if (name === 'main') result.exports.push({ name: 'main', type: 'function' })
}

// --- Helpers ---

function extractQualifierTokens (quals) {
  const tokens = []
  for (const q of quals) {
    if (q.type === 'keyword' && q.token) tokens.push(q.token)
  }
  return tokens
}

function extractLayoutIds (quals) {
  const ids = []
  for (const q of quals) {
    if (q.type !== 'layout_qualifier') continue
    for (const lq of q.qualifiers || []) {
      if (lq.type === 'layout_qualifier_id' && lq.identifier && lq.identifier.identifier) {
        const key = lq.identifier.identifier
        const value = lq.expression && lq.expression.token ? lq.expression.token : null
        ids.push(value ? key + '=' + value : key)
      }
    }
  }
  return ids
}

function layoutIdValue (ids, key) {
  const match = ids.find(id => id.startsWith(key + '='))
  if (!match) return null
  return match.slice(key.length + 1)
}

// --- Godot .gdshader preprocessing ---

function stripGodotShaderHints (source) {
  // Remove:
  //   shader_type spatial; / shader_type canvas_item; / etc.   (top-of-file directive)
  //   render_mode foo, bar;                                     (mode modifiers)
  //   uniform T name : hint_name, other_hint;                   (editor hints)
  //   uniform T name : hint_name, other_hint = default;         (same with default)
  let s = source

  // shader_type line
  s = s.replace(/^\s*shader_type\s+\w+\s*;/gm, '')

  // render_mode line (may span across commas)
  s = s.replace(/^\s*render_mode\s+[^;]+;/gm, '')

  // Uniform hints: `uniform T name : hint, hint_range(0.0, 1.0) = 0.5;`
  // Strip the `: <hints>` portion while preserving any `= default` value.
  // Process line by line to avoid greedy matches spanning multiple uniforms.
  s = s.split('\n').map(line => {
    const m = line.match(/^(\s*uniform\s+[^;=:]+?)\s*:\s*([\s\S]+?)(\s*(?:=[^;]*)?;)\s*$/)
    if (!m) return line
    return m[1] + m[3]
  }).join('\n')

  return s
}