/**
* The expression floor — one file becomes operations, wires, zones and exits.
*
* Specified in docs/EXPRESSION-FLOOR.md. The short version:
*
* an operation -> a node (R1)
* a value -> a wire endpoint (R2) — never a node
* a declaration -> a wire's NAME (R3) — never a node
* control flow -> a zone (region) (R4) — never a node
* a property path -> a socket label (R5)
*
* So `x + 1` is ONE node with one wired input and one literal input, not three.
* That single distinction is the difference between 23,164 nodes and ~4,900 on
* this repo, which is the difference between a readable graph and a wall.
*
* Everything carries a byte range, because a node has to be able to show the code
* it came from when you expand it, and because ids are derived from that range
* and never stored (R7).
*
* JS only for now. GDScript, C++ and GLSL all keep a full tree and can reach the
* floor the same way; they visit only top-level declarations today.
*/
import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs'
import { deriveGDScript } from './derive-gdscript.js'
const PARSE_OPTS = {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
ranges: true,
allowHashBang: true
}
// R1 — these compute or act, so they are nodes.
//
// Note what is NOT here: `await` and `...spread` are MARKERS on the thing they
// wrap (R5), not nodes of their own, so `await f()` is one call node flagged
// awaited rather than two nodes in a chain.
const OPS = new Set([
'CallExpression', 'NewExpression', 'BinaryExpression', 'LogicalExpression',
'UnaryExpression', 'UpdateExpression', 'AssignmentExpression',
'ConditionalExpression', 'ObjectExpression', 'ArrayExpression',
'TemplateLiteral', 'TaggedTemplateExpression', 'ReturnStatement',
'ThrowStatement', 'YieldExpression'
])
const LOOPS = new Set([
'ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement',
'DoWhileStatement'
])
const ZONES = new Set([...LOOPS, 'IfStatement', 'SwitchStatement', 'TryStatement'])
const JUMPS = new Set([
'ReturnStatement', 'BreakStatement', 'ContinueStatement', 'ThrowStatement'
])
const FUNCS = new Set([
'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'
])
// Children that are never part of the model: positions, and the raw token soup
// of a template literal.
const SKIP_KEYS = new Set(['loc', 'range', 'start', 'end', 'quasis'])
function rangeOf (node) {
return {
startByte: node.range[0],
endByte: node.range[1],
startLine: node.loc.start.line,
startCol: node.loc.start.column,
endLine: node.loc.end.line,
endCol: node.loc.end.column
}
}
function idOf (filePath, node) {
return filePath + ':' + node.range[0] + '-' + node.range[1]
}
// R5 — a property path is one label, not a chain of member nodes. Computed
// access becomes `[..]` because the key is a value, not a name.
function pathOf (node) {
if (!node) return null
if (node.type === 'Identifier') return node.name
if (node.type === 'ThisExpression') return 'this'
if (node.type === 'Literal') return String(node.value)
if (node.type === 'MemberExpression') {
const base = pathOf(node.object)
if (base === null) return null
if (node.computed) return base + '[..]'
return base + '.' + pathOf(node.property)
}
return null
}
// The identifier a path is rooted in — what actually gets resolved in scope.
function rootOf (node) {
let cur = node
while (cur && cur.type === 'MemberExpression') cur = cur.object
return cur
}
function labelFor (node) {
switch (node.type) {
case 'CallExpression':
case 'NewExpression': {
const p = pathOf(node.callee)
const prefix = node.type === 'NewExpression' ? 'new ' : ''
return prefix + (p === null ? '(expr)' : p)
}
case 'BinaryExpression':
case 'LogicalExpression':
case 'AssignmentExpression':
return node.operator
case 'UnaryExpression':
case 'UpdateExpression':
return node.operator
case 'ConditionalExpression': return '?:'
case 'ObjectExpression': return '{}'
case 'ArrayExpression': return '[]'
case 'TemplateLiteral':
case 'TaggedTemplateExpression': return 'template'
case 'ReturnStatement': return 'return'
case 'ThrowStatement': return 'throw'
case 'YieldExpression': return 'yield'
default: return node.type
}
}
/**
* The name a function takes from the thing it is bound to.
*
* `const bump = (n) => ...`, `{ read () {} }`, `class S { read () {} }` all give
* a function with no `id` of its own. Naming them '(anonymous)' made every class
* method in the model anonymous, which makes a class's interior unreadable — so
* the binding supplies the name, and the hint travels down one level only.
*/
function hintFor (node, key) {
if (node.type === 'VariableDeclarator' && key === 'init' && node.id.type === 'Identifier') {
return node.id.name
}
if ((node.type === 'MethodDefinition' || node.type === 'PropertyDefinition') && key === 'value') {
if (node.kind === 'constructor') return 'constructor'
if (node.key && node.key.type === 'Identifier') return node.key.name
if (node.key && node.key.type === 'Literal') return String(node.key.value)
}
if (node.type === 'Property' && key === 'value') {
if (node.key && node.key.type === 'Identifier') return node.key.name
if (node.key && node.key.type === 'Literal') return String(node.key.value)
}
if (node.type === 'AssignmentExpression' && key === 'right') {
const p = pathOf(node.left)
if (p) return p
}
return null
}
// `await x` / `...x` wrap the thing that matters. Unwrap to the node underneath
// and report the markers, so the operand is described once, with its flags.
function unwrap (node) {
const flags = {}
let cur = node
while (cur) {
if (cur.type === 'AwaitExpression') { flags.awaited = true; cur = cur.argument; continue }
if (cur.type === 'SpreadElement') { flags.spread = true; cur = cur.argument; continue }
if (cur.type === 'ParenthesizedExpression') { cur = cur.expression; continue }
if (cur.type === 'TSAsExpression' || cur.type === 'TSNonNullExpression') { cur = cur.expression; continue }
break
}
return { node: cur, flags }
}
/**
* One operand slot -> one input descriptor.
*
* A literal names itself and sits IN the socket (R2). A reference or a path
* names the wire and records where it resolved (E5). An operand that is itself
* an operation points at that node's id — the operand still becomes its own
* node when the walk reaches it.
*/
function inputFor (raw, slot, ctx) {
const { node, flags } = unwrap(raw)
if (!node) return null
const base = { slot }
if (flags.awaited) base.awaited = true
if (flags.spread) base.spread = true
if (node.type === 'Literal') {
return { ...base, name: node.raw === undefined ? String(node.value) : node.raw, literal: true }
}
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
return { ...base, name: 'template', literal: true }
}
if (node.type === 'Identifier' || node.type === 'ThisExpression' || node.type === 'MemberExpression') {
const path = pathOf(node)
const root = rootOf(node)
if (path !== null && root) {
return {
...base,
name: path,
source: root.type === 'ThisExpression' ? 'this' : ctx.resolve(root.name)
}
}
}
if (FUNCS.has(node.type)) {
return { ...base, name: '(fn)', source: 'closure', node: idOf(ctx.filePath, node) }
}
if (OPS.has(node.type)) {
return { ...base, name: labelFor(node), source: 'node', node: idOf(ctx.filePath, node) }
}
// Anything else (a class expression, a sequence, a regex-as-node): still an
// endpoint, still positioned, just not one we have a better name for.
return { ...base, name: node.type, source: 'expr' }
}
// Operand slots per operation, in the order they read.
function operandsOf (node) {
switch (node.type) {
case 'CallExpression':
case 'NewExpression': {
// The callee keeps a socket even though its PATH became the label, so the
// binding it resolves to (an import, a global) is never lost (E5).
const out = [[node.callee, 'callee']]
for (let i = 0; i < node.arguments.length; i++) out.push([node.arguments[i], 'arg' + i])
return out
}
case 'BinaryExpression':
case 'LogicalExpression':
return [[node.left, 'left'], [node.right, 'right']]
case 'AssignmentExpression':
// `left` is the WRITE target, recorded separately — only the value flows in.
return [[node.right, 'value']]
case 'UnaryExpression':
return [[node.argument, 'value']]
case 'UpdateExpression':
return [[node.argument, 'value']]
case 'ConditionalExpression':
return [[node.test, 'test'], [node.consequent, 'then'], [node.alternate, 'else']]
case 'ObjectExpression':
return node.properties.map((p, i) => [p.type === 'Property' ? p.value : p, keyName(p, i)])
case 'ArrayExpression':
return node.elements.map((e, i) => [e, 'item' + i])
case 'TemplateLiteral':
return node.expressions.map((e, i) => [e, 'slot' + i])
case 'TaggedTemplateExpression':
return [[node.tag, 'tag']]
case 'ReturnStatement':
case 'ThrowStatement':
case 'YieldExpression':
return node.argument ? [[node.argument, 'value']] : []
default:
return []
}
}
function keyName (prop, i) {
if (prop.type !== 'Property') return 'item' + i
if (prop.key && prop.key.type === 'Identifier') return prop.key.name
if (prop.key && prop.key.type === 'Literal') return String(prop.key.value)
return 'item' + i
}
// Z1 — a conditional's shape follows its control flow, not its keyword.
function shapeOfIf (node) {
const body = node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent]
if (!node.alternate && body.length === 1 && JUMPS.has(body[0].type)) return 'guard'
if (node.alternate) return 'two-exit'
return 'single-branch'
}
function shapeOfZone (node) {
if (node.type === 'IfStatement') return shapeOfIf(node)
if (node.type === 'ForOfStatement' || node.type === 'ForInStatement') return 'foreach'
if (LOOPS.has(node.type)) return 'repeat'
if (node.type === 'SwitchStatement') return 'switch'
if (node.type === 'TryStatement') return 'try'
return node.type
}
// The zone's driving input: a branch's test, a switch's discriminant, or — for
// for-of/for-in, which have neither — the ITERABLE being walked. Without that
// last case a foreach zone reported no driver at all, and the GDScript side
// reported the item binding instead, so the two languages disagreed on the same
// program.
function conditionOf (node, source) {
const test = node.test || node.discriminant ||
((node.type === 'ForOfStatement' || node.type === 'ForInStatement') ? node.right : null)
if (!test) return null
return { text: source.slice(test.range[0], test.range[1]), range: rangeOf(test) }
}
// The binding a foreach zone hands its body, e.g. `row` in `for (const row of rows)`.
function itemOf (node) {
if (node.type !== 'ForOfStatement' && node.type !== 'ForInStatement') return null
const left = node.left
if (left.type === 'VariableDeclaration' && left.declarations.length > 0) {
const id = left.declarations[0].id
return id.type === 'Identifier' ? id.name : null
}
if (left.type === 'Identifier') return left.name
return null
}
export function deriveJS (source, filePath) {
const empty = {
filePath,
language: 'javascript',
symbols: [],
functions: [],
nodes: [],
names: [],
zones: [],
exits: []
}
let ast
try {
ast = acornParse(source, PARSE_OPTS)
} catch (err) {
try {
ast = acornParse(source, { ...PARSE_OPTS, sourceType: 'script' })
} catch {
return { ...empty, error: err.message }
}
}
const model = { ...empty }
// --- scope chain. A reference resolves to a kind, or is marked free (E5).
const scopes = [new Map()]
function declare (name, kind) {
if (!name) return
scopes[scopes.length - 1].set(name, kind)
}
function resolve (name) {
for (let i = scopes.length - 1; i >= 0; i--) {
const kind = scopes[i].get(name)
if (kind) return kind
}
return 'free'
}
const ctx = { filePath, resolve }
// Hoist the declarations of one scope BEFORE walking it, so a reference that
// reads before its declaration still resolves instead of being called free.
function hoist (body) {
for (const stmt of body) {
if (!stmt || typeof stmt.type !== 'string') continue
const inner = stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportDefaultDeclaration'
? stmt.declaration
: stmt
if (!inner) continue
if (inner.type === 'VariableDeclaration') {
for (const d of inner.declarations) collectPattern(d.id, 'local')
} else if (inner.type === 'FunctionDeclaration' || inner.type === 'ClassDeclaration') {
if (inner.id) declare(inner.id.name, 'function')
} else if (inner.type === 'ImportDeclaration') {
for (const spec of inner.specifiers) declare(spec.local.name, 'import')
}
}
}
function collectPattern (pattern, kind) {
if (!pattern) return
switch (pattern.type) {
case 'Identifier': declare(pattern.name, kind); break
case 'ObjectPattern':
for (const p of pattern.properties) {
if (p.type === 'Property') collectPattern(p.value, kind)
else if (p.type === 'RestElement') collectPattern(p.argument, kind)
}
break
case 'ArrayPattern':
for (const e of pattern.elements) collectPattern(e, kind)
break
case 'AssignmentPattern': collectPattern(pattern.left, kind); break
case 'RestElement': collectPattern(pattern.argument, kind); break
}
}
// --- writes and reads, collected for the carried-state pass (Z3).
const writes = [] // { name, at, declaredAt }
const reads = [] // { name, at }
const declaredAt = new Map()
function noteWrite (target, at) {
const root = rootOf(target)
if (!root || root.type !== 'Identifier') return null
writes.push({ name: root.name, at })
return root.name
}
// --- the walk
let zoneStack = []
let fnStack = []
let classStack = []
// Top-level code has to live somewhere or it is unreachable from any node in
// the file view (spec L2): index.js has 87 operations outside every function.
const moduleBodyId = filePath + ':module-body'
let moduleBodyUsed = false
// The container of anything in the interior: the nearest enclosing function,
// else the module body. Never null, so "what is inside this node" is a lookup
// and never a range comparison (L3/E4b).
function container () {
if (fnStack.length > 0) return fnStack[fnStack.length - 1].id
moduleBodyUsed = true
return moduleBodyId
}
function currentLoop () {
for (let i = zoneStack.length - 1; i >= 0; i--) {
const z = zoneStack[i]
if (z.kind === 'loop') return z
}
return null
}
function currentBreakTarget () {
for (let i = zoneStack.length - 1; i >= 0; i--) {
const z = zoneStack[i]
if (z.kind === 'loop' || z.shape === 'switch') return z
}
return null
}
function currentTry () {
for (let i = zoneStack.length - 1; i >= 0; i--) {
if (zoneStack[i].shape === 'try') return zoneStack[i]
}
return null
}
function landingFor (node) {
const fn = fnStack[fnStack.length - 1]
switch (node.type) {
case 'ReturnStatement':
return fn ? { on: 'function-output', id: fn.id } : { on: 'module-output', id: filePath }
case 'ContinueStatement': {
const loop = currentLoop()
return loop ? { on: 'zone-entry', id: loop.id } : { on: 'unresolved', id: null }
}
case 'BreakStatement': {
const target = currentBreakTarget()
return target
? { on: target.shape === 'switch' ? 'zone-exit' : 'zone-exit', id: target.id }
: { on: 'unresolved', id: null }
}
case 'ThrowStatement': {
const tryZone = currentTry()
if (tryZone) return { on: 'catch-entry', id: tryZone.id }
return fn ? { on: 'function-error-output', id: fn.id } : { on: 'unresolved', id: null }
}
default:
return { on: 'unresolved', id: null }
}
}
// An `else if` chain is ONE multi-band zone, not N nested ones (Z1). Absorb
// the chain here and return the alternates the caller must NOT re-enter as
// zones of their own.
function bandsOf (node, absorbed) {
const bands = [{ kind: 'then', test: conditionOf(node, source), range: rangeOf(node.consequent) }]
let alt = node.alternate
while (alt && alt.type === 'IfStatement') {
absorbed.add(alt)
bands.push({ kind: 'else-if', test: conditionOf(alt, source), range: rangeOf(alt.consequent) })
alt = alt.alternate
}
if (alt) bands.push({ kind: 'else', test: null, range: rangeOf(alt) })
return bands
}
const absorbed = new Set()
function walk (node, hint) {
if (!node || typeof node.type !== 'string') return
let pushedScope = false
let pushedZone = null
let pushedFn = null
// --- functions: a new scope, and R6's group interface.
if (FUNCS.has(node.type)) {
const enclosing = fnStack.length > 0 ? fnStack[fnStack.length - 1] : null
const fn = {
id: idOf(filePath, node),
name: node.id ? node.id.name : (hint || '(anonymous)'),
kind: node.type,
range: rangeOf(node),
parent: enclosing ? enclosing.id : null,
params: [],
async: !!node.async,
generator: !!node.generator
}
// A method belongs to its class, not to a function, so it is a member
// rather than a file-level symbol.
if (classStack.length > 0 && !enclosing) {
classStack[classStack.length - 1].members.push(fn.id)
} else if (!enclosing && classStack.length === 0) {
model.symbols.push({
id: fn.id,
kind: 'function',
name: fn.name,
range: fn.range,
fn: fn.id,
members: []
})
}
scopes.push(new Map())
pushedScope = true
for (const p of node.params) {
collectPattern(p, 'param')
const n = p.type === 'Identifier' ? p.name : (p.type === 'AssignmentPattern' && p.left.type === 'Identifier' ? p.left.name : '(pattern)')
fn.params.push(n)
}
if (node.body && node.body.type === 'BlockStatement') hoist(node.body.body)
model.functions.push(fn)
fnStack.push(fn)
pushedFn = fn
}
// --- classes. A class is a file-level symbol with an interior of methods,
// which is the rung the old symbol view collapsed into one un-openable node
// labelled "N methods".
let pushedClass = null
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
const sym = {
id: idOf(filePath, node),
kind: 'class',
name: node.id ? node.id.name : (hint || '(anonymous class)'),
range: rangeOf(node),
fn: null,
members: []
}
if (fnStack.length === 0 && classStack.length === 0) model.symbols.push(sym)
classStack.push(sym)
pushedClass = sym
}
// --- zones (R4). An absorbed `else if` link is a band of its parent.
if (ZONES.has(node.type) && !absorbed.has(node)) {
const parent = zoneStack.length > 0 ? zoneStack[zoneStack.length - 1] : null
const zone = {
id: idOf(filePath, node),
type: node.type,
kind: LOOPS.has(node.type) ? 'loop' : (node.type === 'TryStatement' ? 'try' : 'branch'),
shape: shapeOfZone(node),
range: rangeOf(node),
depth: parent ? parent.depth + 1 : 1,
parent: parent ? parent.id : null,
container: container(),
condition: conditionOf(node, source),
item: itemOf(node),
bands: node.type === 'IfStatement' && node.alternate ? bandsOf(node, absorbed) : null,
carried: [],
exits: []
}
model.zones.push(zone)
zoneStack.push(zone)
pushedZone = zone
// A loop's binding lives in the loop's own scope.
if (zone.item) {
scopes.push(new Map())
declare(zone.item, 'local')
zone.__scoped = true
}
}
// --- a block introduces a scope, and hoists into it.
if (node.type === 'BlockStatement' && !(pushedFn)) {
scopes.push(new Map())
hoist(node.body)
pushedScope = true
}
if (node.type === 'Program') hoist(node.body)
// --- operations (R1)
if (OPS.has(node.type)) {
const op = {
id: idOf(filePath, node),
kind: node.type,
label: labelFor(node),
range: rangeOf(node),
zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
container: container(),
inputs: []
}
for (const [operand, slot] of operandsOf(node)) {
const input = inputFor(operand, slot, ctx)
if (input) op.inputs.push(input)
}
// An assignment WRITES a wire: that is a new version of it (R3/Z3).
if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') {
const target = node.type === 'AssignmentExpression' ? node.left : node.argument
const written = noteWrite(target, rangeOf(node))
if (written) {
op.writes = pathOf(target)
op.writesRoot = written
}
}
model.nodes.push(op)
}
// --- declarations name wires (R3), not nodes.
if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') {
const inLoopHead = zoneStack.length > 0 &&
zoneStack[zoneStack.length - 1].item === node.id.name &&
zoneStack[zoneStack.length - 1].range.startByte <= node.range[0] &&
node.range[0] < (zoneStack[zoneStack.length - 1].condition
? zoneStack[zoneStack.length - 1].condition.range.endByte
: zoneStack[zoneStack.length - 1].range.endByte)
// A loop binding is the zone's ITEM socket, not a named wire of its own.
if (!inLoopHead) {
const entry = {
name: node.id.name,
range: rangeOf(node),
container: container(),
from: 'none',
node: null
}
const init = node.init ? unwrap(node.init).node : null
if (!init) entry.from = 'none'
else if (init.type === 'Literal') entry.from = 'literal'
else if (OPS.has(init.type)) { entry.from = 'node'; entry.node = idOf(filePath, init) }
else if (FUNCS.has(init.type)) { entry.from = 'closure'; entry.node = idOf(filePath, init) }
else entry.from = 'wire'
model.names.push(entry)
declaredAt.set(node.id.name, node.range[0])
}
}
// --- exits (Z4)
if (JUMPS.has(node.type)) {
model.exits.push({
id: idOf(filePath, node) + ':exit',
kind: node.type === 'ReturnStatement' ? 'return'
: node.type === 'BreakStatement' ? 'break'
: node.type === 'ContinueStatement' ? 'continue' : 'throw',
node: OPS.has(node.type) ? idOf(filePath, node) : null,
range: rangeOf(node),
zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
container: container(),
lands: landingFor(node)
})
if (zoneStack.length > 0) {
zoneStack[zoneStack.length - 1].exits.push(idOf(filePath, node) + ':exit')
}
}
// --- reads, for the carried-state pass
if (node.type === 'Identifier') reads.push({ name: node.name, at: node.range[0] })
// A top-level declaration whose value is NOT a function is a const symbol.
// (The function case already became a symbol when the function was created.)
if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' &&
fnStack.length === 0 && classStack.length === 0) {
const init = node.init ? unwrap(node.init).node : null
const isCallable = init && (FUNCS.has(init.type) ||
init.type === 'ClassDeclaration' || init.type === 'ClassExpression')
if (!isCallable) {
model.symbols.push({
id: idOf(filePath, node),
kind: 'const',
name: node.id.name,
range: rangeOf(node),
fn: null,
members: []
})
}
}
// --- recurse, carrying a name hint exactly one level down
for (const key of Object.keys(node)) {
if (SKIP_KEYS.has(key)) continue
const value = node[key]
const childHint = hintFor(node, key)
if (Array.isArray(value)) {
for (const child of value) walk(child, childHint)
} else if (value && typeof value === 'object' && typeof value.type === 'string') {
walk(value, childHint)
}
}
if (pushedZone) {
if (pushedZone.__scoped) { scopes.pop(); delete pushedZone.__scoped }
zoneStack.pop()
}
if (pushedFn) fnStack.pop()
if (pushedClass) classStack.pop()
if (pushedScope) scopes.pop()
}
walk(ast, null)
// The module body is emitted only if something actually landed in it — a file
// of pure declarations has no top-level code to show.
if (moduleBodyUsed) {
model.symbols.push({
id: moduleBodyId,
kind: 'module-body',
name: '(module body)',
range: rangeOf(ast),
fn: moduleBodyId,
members: []
})
}
// Source order, so the file view is stable and diffable.
model.symbols.sort((a, b) => a.range.startByte - b.range.startByte)
// --- Z3: a zone's outputs are its phis. A name written inside the zone, whose
// declaration is OUTSIDE it, and which is read after the zone closes, converges
// out of it. Same rule for a loop's carried state and an if/else's convergence —
// which is the whole point of treating both as regions.
for (const zone of model.zones) {
const carried = new Set()
for (const w of writes) {
if (w.at.startByte < zone.range.startByte || w.at.endByte > zone.range.endByte) continue
const decl = declaredAt.get(w.name)
if (decl !== undefined && decl >= zone.range.startByte) continue // declared inside: cannot escape
const readAfter = reads.some(r => r.name === w.name && r.at > zone.range.endByte)
if (readAfter) carried.add(w.name)
}
zone.carried = [...carried].sort()
}
return model
}
/**
* Pick a derivation by extension.
*
* The model shape is identical whichever language answers — that is the whole
* point, because the renderer positions nodes by source range and must not care
* what produced them. A language we cannot derive yet says so in `error` rather
* than returning an empty model that looks like a file with no code in it.
*/
export function deriveFile (source, filePath) {
const path = String(filePath || '')
const dot = path.lastIndexOf('.')
const ext = dot === -1 ? '' : path.slice(dot).toLowerCase()
switch (ext) {
case '.js':
case '.mjs':
case '.cjs':
return deriveJS(source, filePath)
case '.gd':
return deriveGDScript(source, filePath)
default:
return {
filePath,
language: null,
functions: [],
nodes: [],
names: [],
zones: [],
exits: [],
error: 'no derivation for ' + (ext || 'a file with no extension') +
' — JS and GDScript only (C++ and GLSL keep a full tree and can follow)'
}
}
}