OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / test / derive-levels.test.js

167 lines · javascript ref:
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')
})