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')
// The LEVEL LADDER (spec section 3). A file-level node is a SYMBOL — a class, a
// top-level function, a const, or the module body — and "what is inside it" must
// be a lookup, never a range comparison, because a zone inside a nested closure
// is also inside its parent's range (L3).
const SRC = [
"import { helper } from './h.js'", // 1
'', // 2
'const LIMIT = 10', // 3 symbol: const
'', // 4
'export function tally (rows) {', // 5 symbol: function
' let total = 0', // 6
' for (const r of rows) {', // 7 zone, container = tally
' const bump = (n) => n + 1', // 8 nested function, named from its binding
' total = bump(total)', // 9
' }', // 10
' return total', // 11 exit, container = tally
'}', // 12
'', // 13
'export class Store {', // 14 symbol: class
' constructor (dir) {', // 15 member
' this._dir = dir', // 16
' }', // 17
'', // 18
' async read (name) {', // 19 member
' if (!name) return null', // 20 zone + exit, container = read
' return helper(this._dir, name)', // 21
' }', // 22
'}', // 23
'', // 24
'const ready = tally([1, 2])', // 25 TOP-LEVEL operation -> module body
'console.log(ready, LIMIT)', // 26 top-level too
''
].join('\n')
function model () {
return deriveFile(SRC, '/x/levels.js')
}
test('levels: the file level is symbols, not derived functions', (t) => {
const m = model()
t.ok(Array.isArray(m.symbols), 'the model names the file-level set')
const byName = {}
for (const s of m.symbols) byName[s.name] = s
// `ready` counts: a top-level declaration whose value is not callable is a
// const symbol, exactly as the parser's symbol list has always treated it.
t.alike(Object.keys(byName).sort(),
['(module body)', 'LIMIT', 'Store', 'ready', 'tally'].sort(), 'five file-level nodes')
t.is(byName.LIMIT.kind, 'const', 'LIMIT is a const')
t.is(byName.ready.kind, 'const', 'and so is ready')
t.is(byName.tally.kind, 'function', 'tally is a function')
t.is(byName.Store.kind, 'class', 'Store is a class')
t.is(byName['(module body)'].kind, 'module-body', 'and the module body exists')
// `bump` is nested inside tally, so it is NOT a file-level node (L1).
t.absent(byName.bump, 'a nested function is an interior, not a file-level node')
})
test('levels: a nested function is named from its binding, not anonymous', (t) => {
const m = model()
const names = m.functions.map(f => f.name)
t.ok(names.includes('bump'), 'the arrow bound to `bump` takes that name')
t.ok(names.includes('read'), 'a class method takes its key as its name')
t.ok(names.includes('constructor'), 'including the constructor')
t.absent(names.includes('(anonymous)'), 'nothing in this file is anonymous')
})
test('levels: functions carry their parent', (t) => {
const m = model()
const byName = {}
for (const f of m.functions) byName[f.name] = f
t.is(byName.tally.parent, null, 'a top-level function has no parent')
t.is(byName.bump.parent, byName.tally.id, 'bump`s parent is tally')
t.is(byName.read.parent, null, 'a method is not nested inside another function')
})
test('levels: a class node has members', (t) => {
const m = model()
const store = m.symbols.find(s => s.name === 'Store')
t.is(store.members.length, 2, 'two members')
const memberNames = store.members
.map(id => m.functions.find(f => f.id === id))
.map(f => f.name)
t.alike(memberNames.sort(), ['constructor', 'read'], 'constructor and read')
})
test('levels: L3 — the interior of a symbol is a lookup', (t) => {
const m = model()
const tally = m.symbols.find(s => s.name === 'tally')
const read = m.functions.find(f => f.name === 'read')
const bump = m.functions.find(f => f.name === 'bump')
// Every interior entry names its container (E4b).
for (const list of [m.nodes, m.zones, m.exits, m.names]) {
for (const item of list) {
t.ok('container' in item, 'entry carries a container')
}
}
const inTally = m.zones.filter(z => z.container === tally.fn)
t.is(inTally.length, 1, 'tally directly contains one zone — the for loop')
const inRead = m.zones.filter(z => z.container === read.id)
t.is(inRead.length, 1, 'read contains one zone — the guard')
// The discriminating case: range containment would give the WRONG answer for
// anything inside `bump`, because bump sits inside tally's range.
const bumpOps = m.nodes.filter(n => n.container === bump.id)
t.is(bumpOps.length, 1, 'the + belongs to bump')
const tallyOpsByRange = m.nodes.filter(n =>
n.range.startByte >= tally.range.startByte && n.range.endByte <= tally.range.endByte)
const tallyOpsByContainer = m.nodes.filter(n => n.container === tally.fn)
t.ok(tallyOpsByRange.length > tallyOpsByContainer.length,
'range containment over-claims: ' + tallyOpsByRange.length + ' vs ' + tallyOpsByContainer.length)
})
test('levels: L2 — top-level code belongs to the module body', (t) => {
const m = model()
const body = m.symbols.find(s => s.kind === 'module-body')
const topOps = m.nodes.filter(n => n.container === body.id)
t.ok(topOps.length >= 2, 'the top-level call and the log are in the module body')
t.absent(m.nodes.find(n => n.container === null), 'no operation is left homeless')
const topNames = m.names.filter(n => n.container === body.id).map(n => n.name)
t.ok(topNames.includes('LIMIT'), 'and so are the top-level named wires')
t.ok(topNames.includes('ready'), 'both of them')
})
test('levels: every symbol has a position and a derived id', (t) => {
const m = model()
for (const s of m.symbols) {
t.ok(Number.isInteger(s.range.startByte), s.name + ' has a range')
t.ok(s.range.startLine >= 1, s.name + ' has a line')
t.ok(typeof s.id === 'string' && s.id.length > 0, s.name + ' has an id')
}
})
test('levels: gdscript agrees on the ladder', (t) => {
const src = fs.readFileSync(resolve(FIXTURE, 'sample.gd'), 'utf8')
const m = deriveFile(src, resolve(FIXTURE, 'sample.gd'))
const kinds = {}
for (const s of m.symbols) kinds[s.kind] = (kinds[s.kind] || 0) + 1
t.is(kinds.function, 2, 'tally and label are file-level functions')
t.ok(kinds['const'] >= 1, 'the class-level var is a file-level const')
const tally = m.symbols.find(s => s.name === 'tally')
const zones = m.zones.filter(z => z.container === tally.fn)
t.ok(zones.length > 0, 'and its zones name it as their container')
for (const f of m.functions) t.ok('parent' in f, 'functions carry parent')
})