import { test } from 'brittle'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs'
import { deriveFile } from '../lib/derive.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const FIXTURE = resolve(HERE, 'fixtures/derive')
// Ground truth for fixtures/derive/sample.gd, counted by hand.
//
// tally()
// rows == null binary op + GUARD zone (return null)
// return null return op + exit -> function output
// for row in rows FOREACH zone, item 'row', carried ['total']
// row.skip a path, folds to a socket label (R5)
// continue exit -> the foreach zone's entry, in a GUARD zone
// (parses as IdentifierExpressionNode: this grammar
// has NO break/continue node at all)
// total + row.count binary op
// total = ... assign op (writes the class-level 'total' wire)
// total > limit binary op + GUARD zone (break)
// break exit -> the foreach zone's exit
// total > 0 binary op
// if/else TWO-EXIT zone, 2 bands; both paths exit
// return total, return 0 two return ops + two exits
//
// label()
// Time.get_ticks_msec() call op, 'Time' is a FREE wire
// str(row.name, at) call op, 'str' is FREE
// return return op + exit
//
// Totals: 2 functions, 11 operation nodes, 5 zones, 6 exits, 2 named wires
// ('total' at class scope, 'at' in label; the loop's 'row' is the
// zone's item, not a named wire)
function derive (name) {
const path = resolve(FIXTURE, name)
return deriveFile(fs.readFileSync(path, 'utf8'), path)
}
test('derive gd: the counts the fixture documents', (t) => {
const m = derive('sample.gd')
t.absent(m.error, 'no error')
t.is(m.language, 'gdscript', 'reports its language')
t.is(m.functions.length, 2, '2 functions')
t.is(m.nodes.length, 11, '11 operation nodes')
t.is(m.zones.length, 5, '5 zones')
t.is(m.exits.length, 6, '6 exits')
t.is(m.names.length, 2, '2 named wires')
})
test('derive gd: Z1 — three conditional shapes', (t) => {
const m = derive('sample.gd')
const shapes = {}
for (const z of m.zones) shapes[z.shape] = (shapes[z.shape] || 0) + 1
t.is(shapes.guard, 3, '3 guards')
t.is(shapes.foreach, 1, '1 foreach loop')
t.is(shapes['two-exit'], 1, '1 two-exit conditional')
const cond = m.zones.find(z => z.shape === 'two-exit')
t.is(cond.bands.length, 2, 'then + else')
})
test('derive gd: break and continue resolve despite having no grammar node', (t) => {
const m = derive('sample.gd')
const loop = m.zones.find(z => z.shape === 'foreach')
const cont = m.exits.find(e => e.kind === 'continue')
const brk = m.exits.find(e => e.kind === 'break')
t.ok(cont, 'found the continue')
t.ok(brk, 'found the break')
t.is(cont.lands.on, 'zone-entry', 'continue lands on a zone entry')
t.is(cont.lands.id, loop.id, 'the enclosing loop')
t.is(brk.lands.on, 'zone-exit', 'break lands on a zone exit')
t.is(brk.lands.id, loop.id, 'the enclosing loop')
// And they are NOT mistaken for operations or wires.
const labels = m.nodes.map(n => n.label)
t.absent(labels.includes('break'), 'break is not an operation')
t.absent(labels.includes('continue'), 'continue is not an operation')
})
test('derive gd: Z3 — carried state', (t) => {
const m = derive('sample.gd')
const loop = m.zones.find(z => z.shape === 'foreach')
t.alike(loop.carried, ['total'], 'the loop carries total')
t.is(loop.item, 'row', 'and binds row as its item')
const cond = m.zones.find(z => z.shape === 'two-exit')
t.alike(cond.carried, [], 'the two-exit zone converges nothing')
})
test('derive gd: R5 — a property path folds into a socket label', (t) => {
const m = derive('sample.gd')
const add = m.nodes.find(n => n.label === '+')
t.ok(add, 'found the + node')
const names = add.inputs.map(i => i.name)
t.ok(names.includes('total'), 'left input is the total wire')
t.ok(names.includes('row.count'), 'right input is the folded path')
const kinds = m.nodes.map(n => n.kind)
t.absent(kinds.includes('SubscriptExpressionNode'), 'no path node survives')
})
test('derive gd: R3 — declarations name wires', (t) => {
const m = derive('sample.gd')
const names = m.names.map(n => n.name).sort()
t.alike(names, ['at', 'total'], 'total and at')
t.is(m.names.find(n => n.name === 'total').from, 'literal', 'total from a literal')
t.is(m.names.find(n => n.name === 'at').from, 'node', 'at from an operation')
})
test('derive gd: E5 — references resolve or are marked free', (t) => {
const m = derive('sample.gd')
const call = m.nodes.find(n => n.label === 'Time.get_ticks_msec')
t.ok(call, 'found the call')
t.is(call.inputs[0].source, 'free', 'Time is free — a GDScript global')
const add = m.nodes.find(n => n.label === '+')
const totalIn = add.inputs.find(i => i.name === 'total')
t.is(totalIn.source, 'local', 'the class-level var resolves')
for (const n of m.nodes) {
for (const i of n.inputs) {
if (i.literal) continue
t.ok(i.source, 'every non-literal input names where it came from')
}
}
})
test('derive gd: E2/R7 — positions and derived ids', (t) => {
const m = derive('sample.gd')
for (const list of [m.nodes, m.zones, m.exits, m.names]) {
for (const item of list) {
t.ok(Number.isInteger(item.range.startByte), 'has startByte')
t.ok(item.range.startLine >= 1, 'has a 1-based line')
t.ok(item.range.endLine >= item.range.startLine, 'endLine follows')
}
}
for (const n of m.nodes) {
t.is(n.id, m.filePath + ':' + n.range.startByte + '-' + n.range.endByte, 'id is file:start-end')
}
})
test('derive gd: E1 — the derivation is pure', (t) => {
const a = derive('sample.gd')
const b = derive('sample.gd')
t.is(JSON.stringify(a), JSON.stringify(b), 'byte-identical model')
})
test('derive gd: an error node does not stop the derivation', (t) => {
const m = derive('inferred.gd')
t.ok(m.errors > 0, 'the := is reported as a grammar error')
t.absent(m.error, 'but the file still derives')
t.alike(m.names.map(n => n.name), ['count'], 'and the wire is still named')
t.ok(m.functions.length === 1, 'and the function is still found')
})
test('derive gd: a mis-parse marks the model PARTIAL', (t) => {
// lezer RECOVERS from a construct it does not know, so the model looks
// complete while structure has gone missing. That must be visible.
const clean = derive('sample.gd')
t.absent(clean.partial, 'the clean fixture is not partial')
const m = derive('inferred.gd')
t.ok(m.partial, ':= makes the model partial')
t.ok(m.partialReason.includes('grammar error'), 'and says why: ' + m.partialReason)
})
test('derive gd: `static func` is mis-parsed, so the model says PARTIAL', (t) => {
const src = [
'static func f(rows):',
'\tfor row in rows:',
'\t\tif row.skip:',
'\t\t\tcontinue',
'\treturn 0',
''
].join('\n')
const m = deriveFile(src, resolve(FIXTURE, 'synthetic-static.gd'))
t.ok(m.errors > 0, 'the grammar reports errors on `static func`')
t.ok(m.partial, 'so the model is partial')
// Recovery is good enough HERE that the loop survives and the continue still
// lands. On real code it is not: material-maker/parse_args.gd has 111 error
// nodes, loops that come back spanning a single line, and three continues
// whose enclosing `for` was lost entirely. That file is not a fixture on
// purpose — pinning a test to a repo we do not own is the mistake that has
// parser/traverse/tree red right now.
t.is(m.exits.filter(e => e.lands.on === 'unresolved').length, 0,
'this small case still resolves, which is why PARTIAL is the signal, not the exits')
})
test('derive gd: an exit with nowhere to land is reported, never dropped (E3)', (t) => {
// A `continue` outside any loop. Invalid GDScript, which the grammar happily
// reads as an identifier — so this is also how a LOST loop presents.
const m = deriveFile('func f():\n\tcontinue\n', resolve(FIXTURE, 'synthetic-orphan.gd'))
const orphans = m.exits.filter(e => e.lands.on === 'unresolved')
t.is(orphans.length, 1, 'the continue is still reported')
t.is(orphans[0].kind, 'continue', 'as a continue')
t.is(orphans[0].lands.id, null, 'with no landing id')
t.ok(m.partial, 'and the model is partial')
t.ok(m.partialReason.includes('landing nowhere'), 'saying so: ' + m.partialReason)
})
// The point of the model: the SAME program in two languages agrees on control
// flow. Node counts differ (JS has a template literal), the structure does not.
test('derive: the JS and GDScript twins agree on structure', (t) => {
const js = derive('sample.js')
const gd = derive('sample.gd')
const shapes = (m) => {
const out = {}
for (const z of m.zones) out[z.shape] = (out[z.shape] || 0) + 1
return out
}
const exits = (m) => {
const out = {}
for (const e of m.exits) out[e.kind] = (out[e.kind] || 0) + 1
return out
}
t.is(gd.zones.length, js.zones.length, 'same zone count')
t.alike(shapes(gd), shapes(js), 'same zone shapes')
t.alike(exits(gd), exits(js), 'same exit kinds')
t.is(gd.functions.length, js.functions.length, 'same function count')
const gdLoop = gd.zones.find(z => z.shape === 'foreach')
const jsLoop = js.zones.find(z => z.shape === 'foreach')
t.alike(gdLoop.carried, jsLoop.carried, 'same carried state')
t.is(gdLoop.item, jsLoop.item, 'same loop item')
t.is(gdLoop.condition.text, 'rows', 'the foreach is driven by the ITERABLE')
t.is(jsLoop.condition.text, 'rows', 'and JS agrees')
})