@tabnas/lsp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+ 'use strict'
3
+
4
+ // Engine-instance management (design §6). One long-lived Tabnas
5
+ // instance per cache key, with:
6
+ // - exactly ONE permanent mux subscriber installed at creation
7
+ // (ctx.sub aliases the instance's shared list and parent_ctx
8
+ // deep-merge mutates it in place — per-parse subscription corrupts
9
+ // the instance), forwarding to the single active collector, wrapped
10
+ // in an exception guard so a consumer throw cannot abort a user
11
+ // parse;
12
+ // - rebuild-on-reload (tn.grammar() prepends; never re-apply);
13
+ // - per-grammar failure quarantine (a throwing grammar is disabled,
14
+ // the server survives).
15
+ //
16
+ // Parses are SERIALIZED: the protocol layer is single-threaded and
17
+ // runs one parse at a time, so a single active-collector slot is the
18
+ // whole demux. A speculative WeakMap-keyed variant for concurrent
19
+ // parses existed here once and was dead code with a broken mux (its
20
+ // permanent subscriber never consulted the trampoline its parse()
21
+ // installed — review catch on #1); concurrency support starts from
22
+ // the design, not by resurrecting it.
23
+
24
+ const QUARANTINE_LIMIT = 3
25
+
26
+ class Instances {
27
+ // makeInstance(entry) -> Tabnas (loader supplied by the host: module
28
+ // require, GrammarSpec load, or BNF dialect compile).
29
+ constructor(makeInstance) {
30
+ this.makeInstance = makeInstance
31
+ this.cache = new Map()
32
+ this.failures = new Map()
33
+ this._active = null
34
+ }
35
+
36
+ // An entry's identity, independent of WHERE it is being used. This
37
+ // has to name the grammar, not just the language: two entries can
38
+ // share a languageId, options and sandbox base and still load
39
+ // different grammars — an initializationOptions language (session
40
+ // wide, _scope null) and a workspace manifest entry in the folder
41
+ // that happens to be its _dir. Keyed on (languageId, options, dir)
42
+ // alone they collided, and whichever document arrived first decided
43
+ // which grammar served BOTH routes.
44
+ entryPrefix(entry) {
45
+ return entry.languageId + ' ' + JSON.stringify([
46
+ entry.options || {},
47
+ entry.module || null,
48
+ entry.grammar || null,
49
+ entry.dialect || null,
50
+ '_scope' in entry ? entry._scope : null,
51
+ ]) + ' '
52
+ }
53
+
54
+ key(entry, folder) {
55
+ return this.entryPrefix(entry) + (folder || entry._dir || '')
56
+ }
57
+
58
+ // Quarantine is keyed the same way the instance cache is. Keying it
59
+ // by languageId alone, while the cache keys on (languageId, options,
60
+ // folder), meant one workspace folder's broken `mydsl` grammar
61
+ // disabled every OTHER folder's working `mydsl` too — and a
62
+ // languageId is not unique across folders by design.
63
+ quarantined(entry, folder) {
64
+ return QUARANTINE_LIMIT <= (this.failures.get(this.key(entry, folder)) || 0)
65
+ }
66
+
67
+ recordFailure(entry, folder) {
68
+ const k = this.key(entry, folder)
69
+ this.failures.set(k, 1 + (this.failures.get(k) || 0))
70
+ }
71
+
72
+ get(entry, folder) {
73
+ if (this.quarantined(entry, folder)) return null
74
+ const k = this.key(entry, folder)
75
+ let inst = this.cache.get(k)
76
+ if (!inst) {
77
+ try {
78
+ inst = this.makeInstance(entry)
79
+ } catch (e) {
80
+ this.recordFailure(entry, folder)
81
+ throw e
82
+ }
83
+ this.installMux(inst)
84
+ this.cache.set(k, inst)
85
+ }
86
+ return inst
87
+ }
88
+
89
+ // Grammar hot-reload: rebuild, never re-apply (tn.grammar prepends).
90
+ // Clears the failure count for the same keys it drops from the cache,
91
+ // so a reloaded grammar leaves quarantine.
92
+ //
93
+ // The prefix is THIS ENTRY across every folder it is cached under —
94
+ // not every entry sharing its languageId. Matching on the languageId
95
+ // alone meant that editing one folder's grammar released an entirely
96
+ // different folder's quarantined grammar back into service, where it
97
+ // resumed failing on every keystroke until it was quarantined again.
98
+ invalidate(entry) {
99
+ const prefix = this.entryPrefix(entry)
100
+ for (const k of [...this.cache.keys()]) {
101
+ if (k.startsWith(prefix)) this.cache.delete(k)
102
+ }
103
+ for (const k of [...this.failures.keys()]) {
104
+ if (k.startsWith(prefix)) this.failures.delete(k)
105
+ }
106
+ }
107
+
108
+ installMux(inst) {
109
+ const self = this
110
+ inst.sub({
111
+ lex: (tkn, rule, ctx) => {
112
+ const c = self._active
113
+ if (c && c.lex) {
114
+ try { c.lex(tkn, rule, ctx) } catch (e) { c.err = e }
115
+ }
116
+ },
117
+ ruleDone: (rule, ctx, done) => {
118
+ const c = self._active
119
+ if (c && c.ruleDone) {
120
+ try { c.ruleDone(rule, ctx, done) } catch (e) { c.err = e }
121
+ }
122
+ },
123
+ })
124
+ }
125
+
126
+ parse(inst, src, collector) {
127
+ const prev = this._active
128
+ this._active = collector || null
129
+ try {
130
+ return inst.parse(src)
131
+ } finally {
132
+ this._active = prev
133
+ }
134
+ }
135
+ }
136
+
137
+ // Historical name for the same class, kept so existing callers and
138
+ // tests keep working: serialization is now the only implementation.
139
+ const SerialInstances = Instances
140
+
141
+ module.exports = { Instances, SerialInstances, QUARANTINE_LIMIT }
package/src/loaders.js ADDED
@@ -0,0 +1,515 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+ 'use strict'
3
+
4
+ // Grammar loading: the dynamism ladder's L1/L2/L3 lanes (design §6).
5
+ // A registry entry's `load` field says how its grammar arrives:
6
+ //
7
+ // { module: '@tabnas/toml' } L1 live plugin code, require()d
8
+ // { spec: './grammar.json' | {..} } L2 serialized GrammarSpec data
9
+ // { grammar: './my.abnf' } L3 BNF-dialect text, compiled
10
+ //
11
+ // Grammar CODE is trusted like any dependency the host installed —
12
+ // except workspace-supplied modules, which are gated behind an explicit
13
+ // trust flag (a workspace manifest must not be able to run npm code
14
+ // just by being opened). Grammar DATA is never trusted: every spec —
15
+ // file, inline, or compiled from BNF — passes the firewall below
16
+ // before any engine load.
17
+
18
+ const fs = require('fs')
19
+ const path = require('path')
20
+
21
+ // Caps bounding grammar LOAD cost (parse cost is bounded separately by
22
+ // parse budgets, design §10). MAX_GRAMMAR_RULES is ported from mcp;
23
+ // the others exist because a rule-count cap alone lets one rule carry
24
+ // an arbitrarily large alts array, an arbitrarily deep options tree
25
+ // (deep enough to overflow the recursive scans), or an arbitrarily
26
+ // large file — L2/L3 grammar data is untrusted, so total complexity is
27
+ // bounded, not just the rule-name count.
28
+ const MAX_GRAMMAR_RULES = 5000
29
+ const MAX_GRAMMAR_ALTS = 10000
30
+ const MAX_GRAMMAR_DEPTH = 100
31
+ const MAX_GRAMMAR_BYTES = 1000000
32
+
33
+ // The three BNF dialects, by grammar-file extension. Separate packages
34
+ // and separate dialects — ABNF's compiler rejects the other two, so
35
+ // dispatch is by extension, never "try them all".
36
+ const DIALECTS = {
37
+ '.abnf': '@tabnas/abnf',
38
+ '.ebnf': '@tabnas/ebnf',
39
+ '.gbnf': '@tabnas/gbnf',
40
+ }
41
+
42
+ class LoadError extends Error {
43
+ constructor(message, issues) {
44
+ super(message + (issues && issues.length
45
+ ? '\n ' + issues.map((i) => i.path + ': ' + i.message).join('\n ')
46
+ : ''))
47
+ this.name = 'LoadError'
48
+ this.issues = issues || []
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------
53
+ // The grammar firewall, ported from mcp/ts/src/core.ts (design §10).
54
+ // Layers: prototype-pollution keys, live-code refusals (`ref`,
55
+ // `plugins`), non-builtin @-refs, rule-count cap, schema-v gate.
56
+ // Structural junk beyond these is caught loudly by the trial load —
57
+ // tn.grammar() rejects malformed specs — so the Ajv structural pass mcp
58
+ // runs is deliberately not duplicated here.
59
+
60
+ const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype']
61
+
62
+ // Alt keys whose string values the engine resolves as function
63
+ // references (grammar.schema.json $defs.alt). `a` may be an array.
64
+ const ALT_FUNC_KEYS = ['b', 'p', 'r', 'a', 'e', 'h', 'c']
65
+
66
+ function scanForbiddenKeys(val, p, out, depth) {
67
+ depth = depth || 0
68
+ if (null === val || 'object' !== typeof val) return
69
+ if (MAX_GRAMMAR_DEPTH < depth) {
70
+ out.push({
71
+ path: p,
72
+ message: 'grammar nesting deeper than ' + MAX_GRAMMAR_DEPTH +
73
+ ' levels: refused (no real GrammarSpec is this deep, and the ' +
74
+ 'scans must not be recursed off the stack)',
75
+ })
76
+ return
77
+ }
78
+ if (Array.isArray(val)) {
79
+ val.forEach((v, i) => scanForbiddenKeys(v, p + '[' + i + ']', out, depth + 1))
80
+ return
81
+ }
82
+ // getOwnPropertyNames, not Object.keys: JSON.parse creates __proto__
83
+ // as a real own property that hides behind the inherited accessor.
84
+ for (const key of Object.getOwnPropertyNames(val)) {
85
+ const childPath = p + '.' + key
86
+ if (FORBIDDEN_KEYS.includes(key)) {
87
+ out.push({
88
+ path: childPath,
89
+ message: "forbidden key '" + key + "': refused to prevent " +
90
+ 'prototype pollution (a serialized grammar is data, never a ' +
91
+ 'route to Object.prototype)',
92
+ })
93
+ continue
94
+ }
95
+ const desc = Object.getOwnPropertyDescriptor(val, key)
96
+ if (desc && 'value' in desc) {
97
+ scanForbiddenKeys(desc.value, childPath, out, depth + 1)
98
+ }
99
+ }
100
+ }
101
+
102
+ const REF_SHAPED = /^@[A-Za-z_$][\w$.-]*$/
103
+
104
+ function makeRefScanner(builtinRefs) {
105
+ const isBuiltin = (v) =>
106
+ v.endsWith('$') && Object.prototype.hasOwnProperty.call(builtinRefs, v)
107
+ const badRef = (v, p) => ({
108
+ path: p,
109
+ message: "unknown function reference '" + v + "': a serialized " +
110
+ 'grammar may only name $-suffixed engine builtins',
111
+ })
112
+
113
+ // Options strings: '@@…' (escaped literal), '@SKIP' (merge sentinel)
114
+ // and '@/re/flags' / '@~/re/flags' (serialized RegExps) are data;
115
+ // $-suffixed builtins pass; any other ref-shaped '@name' would be
116
+ // resolved from a ref bag this lane refuses to accept.
117
+ function scanOptionsRefs(val, p, out, depth) {
118
+ depth = depth || 0
119
+ if (MAX_GRAMMAR_DEPTH < depth || 100 < out.length) return
120
+ if ('string' === typeof val) {
121
+ if ('@' !== val[0] || val.startsWith('@@') || '@SKIP' === val ||
122
+ /^@~?\/.*\/[\w]*$/.test(val) || isBuiltin(val)) return
123
+ if (REF_SHAPED.test(val)) out.push(badRef(val, p))
124
+ return
125
+ }
126
+ if (Array.isArray(val)) {
127
+ val.forEach((v, i) => scanOptionsRefs(v, p + '[' + i + ']', out, depth + 1))
128
+ return
129
+ }
130
+ if (null !== val && 'object' === typeof val) {
131
+ for (const k of Object.keys(val)) {
132
+ scanOptionsRefs(val[k], p + '.' + k, out, depth + 1)
133
+ }
134
+ }
135
+ }
136
+
137
+ // In alt function positions EVERY '@'-string is a reference, so the
138
+ // rule is strict: builtin or refused.
139
+ function scanAltRefs(alt, p, out) {
140
+ if (null == alt || 'object' !== typeof alt || Array.isArray(alt)) return
141
+ for (const k of ALT_FUNC_KEYS) {
142
+ const v = alt[k]
143
+ if ('string' === typeof v && v.startsWith('@')) {
144
+ if (!isBuiltin(v)) out.push(badRef(v, p + '.' + k))
145
+ } else if ('a' === k && Array.isArray(v)) {
146
+ v.forEach((item, i) => {
147
+ if ('string' === typeof item && item.startsWith('@') &&
148
+ !isBuiltin(item)) out.push(badRef(item, p + '.a[' + i + ']'))
149
+ })
150
+ }
151
+ }
152
+ }
153
+
154
+ return { scanOptionsRefs, scanAltRefs }
155
+ }
156
+
157
+ function altsOf(stateVal) {
158
+ if (Array.isArray(stateVal)) return stateVal
159
+ if (null != stateVal && 'object' === typeof stateVal &&
160
+ Array.isArray(stateVal.alts)) return stateVal.alts
161
+ return []
162
+ }
163
+
164
+ // Full firewall over a candidate spec. `parserMod` supplies the
165
+ // engine's BUILTIN_REFS and BUILTIN_SCHEMA_VERSION so the accepted
166
+ // builtin set and version ceiling are the engine's own, never a copy.
167
+ function firewallSpec(gs, parserMod) {
168
+ if (null == gs || 'object' !== typeof gs || Array.isArray(gs)) {
169
+ return [{ path: '$', message: 'grammar must be a JSON object (the serialized GrammarSpec form)' }]
170
+ }
171
+ const out = []
172
+ scanForbiddenKeys(gs, '$', out)
173
+ if (0 < out.length) return out // poisoned: nothing below touches it
174
+
175
+ if ('ref' in gs) {
176
+ out.push({
177
+ path: '$.ref',
178
+ message: "'ref' is not part of the serialized grammar form: live " +
179
+ 'functions are not JSON. Name $-suffixed engine builtins instead.',
180
+ })
181
+ }
182
+ if (null != gs.options && 'object' === typeof gs.options &&
183
+ !Array.isArray(gs.options) &&
184
+ Object.prototype.hasOwnProperty.call(gs.options, 'plugins')) {
185
+ out.push({
186
+ path: '$.options.plugins',
187
+ message: 'plugins cannot be supplied through a serialized grammar: ' +
188
+ 'a plugin is live code, and this lane accepts only data',
189
+ })
190
+ }
191
+
192
+ const v = 'number' === typeof gs.v ? gs.v : 1
193
+ if (v > parserMod.BUILTIN_SCHEMA_VERSION) {
194
+ out.push({
195
+ path: '$.v',
196
+ message: 'grammar declares builtin schema version ' + v +
197
+ '; this engine supports up to ' + parserMod.BUILTIN_SCHEMA_VERSION,
198
+ })
199
+ }
200
+
201
+ const { scanOptionsRefs, scanAltRefs } = makeRefScanner(parserMod.BUILTIN_REFS)
202
+ if (null != gs.options && 'object' === typeof gs.options) {
203
+ scanOptionsRefs(gs.options, '$.options', out)
204
+ }
205
+ if (null != gs.rule && 'object' === typeof gs.rule) {
206
+ const ruleNames = Object.keys(gs.rule)
207
+ if (ruleNames.length > MAX_GRAMMAR_RULES) {
208
+ out.push({
209
+ path: '$.rule',
210
+ message: 'grammar defines ' + ruleNames.length +
211
+ ' rules, more than ' + MAX_GRAMMAR_RULES,
212
+ })
213
+ }
214
+ // Total alternates are capped as well: one rule can carry an
215
+ // arbitrarily large alts array, and the rule-count cap alone would
216
+ // wave it through.
217
+ let altCount = 0
218
+ for (const rulename of ruleNames) {
219
+ const rulespec = gs.rule[rulename]
220
+ if (null == rulespec || 'object' !== typeof rulespec) continue
221
+ for (const state of ['open', 'close']) {
222
+ const alts = altsOf(rulespec[state])
223
+ altCount += alts.length
224
+ alts.forEach((alt, i) =>
225
+ scanAltRefs(alt, '$.rule.' + rulename + '.' + state + '[' + i + ']', out))
226
+ }
227
+ if (altCount > MAX_GRAMMAR_ALTS) break
228
+ }
229
+ if (altCount > MAX_GRAMMAR_ALTS) {
230
+ out.push({
231
+ path: '$.rule',
232
+ message: 'grammar defines more than ' + MAX_GRAMMAR_ALTS +
233
+ ' alternates in total',
234
+ })
235
+ }
236
+ }
237
+ return out
238
+ }
239
+
240
+ // ---------------------------------------------------------------------
241
+ // Path resolution, workspace-sandboxed. A workspace manifest names
242
+ // files relative to its own folder and may not reach outside it —
243
+ // document-controlled paths are attack surface (design §10). The check
244
+ // runs on REAL paths: a lexical prefix test alone accepts `grammar
245
+ // .json` that is a symlink out of the workspace, and the read then
246
+ // follows the link (review catch on #1).
247
+
248
+ function resolveSandboxed(file, baseDir) {
249
+ if (null == baseDir) {
250
+ throw new LoadError('grammar file paths need a base directory: ' + file)
251
+ }
252
+ let base
253
+ try {
254
+ base = fs.realpathSync(path.resolve(baseDir))
255
+ } catch (e) {
256
+ throw new LoadError('workspace folder not readable: ' + baseDir + ': ' + e.message)
257
+ }
258
+ const abs = path.resolve(base, file)
259
+ // Lexical gate first, so a plainly escaping RELATIVE path is refused
260
+ // with the clear message even when its target does not exist.
261
+ if (abs !== base && !abs.startsWith(base + path.sep)) {
262
+ throw new LoadError(
263
+ 'grammar file escapes its workspace folder: ' + file + ' (from ' + base + ')')
264
+ }
265
+ let real
266
+ try {
267
+ real = fs.realpathSync(abs)
268
+ } catch (e) {
269
+ throw new LoadError('grammar file not readable: ' + file + ': ' + e.message)
270
+ }
271
+ if (real !== base && !real.startsWith(base + path.sep)) {
272
+ throw new LoadError(
273
+ 'grammar file escapes its workspace folder (via symlink): ' +
274
+ file + ' -> ' + real)
275
+ }
276
+ return real
277
+ }
278
+
279
+ // ---------------------------------------------------------------------
280
+ // L1: which export of a plugin module is the plugin. Export SHAPE does
281
+ // not answer that — @tabnas/jsonic exports its root instance wrapper as
282
+ // the module and the real plugin as the lowercase name, and applying
283
+ // the wrapper is silently harmless (a parse function returns non-string
284
+ // input unchanged), which serves an EMPTY grammar with no error.
285
+ // Candidates are probed on a throwaway child instance; only the export
286
+ // that actually installs rules is applied to the real one, and no
287
+ // candidate is ever applied twice.
288
+
289
+ function pluginCandidates(req, name) {
290
+ let mod
291
+ try {
292
+ mod = req(name)
293
+ } catch (e) {
294
+ // The @tabnas/<name> fallback is for SHORT names only. A module
295
+ // that exists but throws while initializing must surface its own
296
+ // error — falling back would either hide it behind a not-found for
297
+ // a name nobody asked for, or silently serve a different package.
298
+ const notFound = 'MODULE_NOT_FOUND' === e.code &&
299
+ String(e.message).includes("'" + name + "'")
300
+ if (!notFound || name.startsWith('@')) throw e
301
+ mod = req('@tabnas/' + name)
302
+ }
303
+ const short = String(name).replace(/^@tabnas\//, '')
304
+ const camel = short.charAt(0).toUpperCase() + short.slice(1)
305
+ const out = []
306
+ const add = (fn) => {
307
+ if ('function' === typeof fn && !out.includes(fn)) out.push(fn)
308
+ }
309
+ add(mod)
310
+ if (mod) {
311
+ add(mod.default)
312
+ add(mod[camel])
313
+ add(mod[short])
314
+ }
315
+ return out
316
+ }
317
+
318
+ function ruleNames(tn) {
319
+ try {
320
+ return Object.keys(tn.rule()).sort().join(',')
321
+ } catch (e) {
322
+ return ''
323
+ }
324
+ }
325
+
326
+ function installsRules(tn, fn) {
327
+ try {
328
+ const probe = tn.make()
329
+ const before = ruleNames(probe)
330
+ const after = ruleNames(probe.use(fn) || probe)
331
+ return after !== before
332
+ } catch (e) {
333
+ return false
334
+ }
335
+ }
336
+
337
+ function applyPlugin(tn, req, name) {
338
+ const candidates = pluginCandidates(req, name)
339
+ if (0 === candidates.length) {
340
+ throw new LoadError('tabnas-lsp: plugin is not a function: ' + name)
341
+ }
342
+ if (1 === candidates.length) return tn.use(candidates[0]) || tn
343
+ for (const fn of candidates) {
344
+ if (installsRules(tn, fn)) return tn.use(fn) || tn
345
+ }
346
+ // A modifier plugin legitimately installs no rules; fall back to the
347
+ // documented preference order (bare, .default, CamelCase, lowercase).
348
+ return tn.use(candidates[0]) || tn
349
+ }
350
+
351
+ // Grammar-layer base chains are applied host-side (toml-style: callers
352
+ // compose .use(jsonic).use(Toml)); compiler bases are library deps of
353
+ // the compiler and are NOT applied (abnf-style: .use(bnf).use(abnf)
354
+ // cannot parse .abnf documents at all).
355
+ function buildStack(entry) {
356
+ const stack = []
357
+ if (entry.base && 'grammar' === entry.pluginKind) stack.push(entry.base)
358
+ stack.push(entry.name)
359
+ return stack
360
+ }
361
+
362
+ // ---------------------------------------------------------------------
363
+ // L3: dialect compile. Each dialect package exports a converter
364
+ // (`abnfConvert` / `<dialect>Convert` / `convert`) and, in compile
365
+ // mode, `toPureSpec`. Compile with `builtins: true` explicitly — the
366
+ // default conversion is closure mode and is not data.
367
+
368
+ function dialectOf(file) {
369
+ const ext = path.extname(file || '').toLowerCase()
370
+ const pkg = DIALECTS[ext]
371
+ if (!pkg) {
372
+ throw new LoadError(
373
+ 'unknown grammar dialect ' + (ext || '(no extension)') + ' for ' + file +
374
+ ' — expected one of: ' + Object.keys(DIALECTS).join(', '))
375
+ }
376
+ return { ext, pkg, dialect: ext.slice(1) }
377
+ }
378
+
379
+ function compileGrammarText(req, file, src) {
380
+ const { pkg, dialect } = dialectOf(file)
381
+ let mod
382
+ try {
383
+ mod = req(pkg)
384
+ } catch (e) {
385
+ throw new LoadError(
386
+ 'grammar dialect package not installed: ' + pkg +
387
+ ' (needed to compile ' + file + '): ' + e.message)
388
+ }
389
+ const convert = mod[dialect + 'Convert'] || mod.convert
390
+ if ('function' !== typeof convert) {
391
+ throw new LoadError(pkg + ' exports no ' + dialect + 'Convert/convert function')
392
+ }
393
+ let spec = convert(src, { builtins: true })
394
+ // Pure lowering when the package offers it: strips compiler marks,
395
+ // stamps v, carries meta.provenance for rule-name canonicalization.
396
+ if ('function' === typeof mod.toPureSpec) spec = mod.toPureSpec(spec)
397
+ return spec
398
+ }
399
+
400
+ // ---------------------------------------------------------------------
401
+ // The loader. makeLoader(requireFn, opts) -> makeInstance(entry):
402
+ // dispatches on entry.load and returns a configured Tabnas instance
403
+ // with recovery enabled. opts.trust.workspaceModules gates L1 for
404
+ // workspace-sourced entries (default: refused).
405
+
406
+ function makeLoader(requireFn, opts) {
407
+ const req = requireFn || require
408
+ // opts is read at instance-make time, not captured here: the server
409
+ // learns trust settings from initializationOptions AFTER the loader
410
+ // is constructed, and mutates the same opts object.
411
+
412
+ return function makeInstance(entry) {
413
+ const trust = (opts && opts.trust) || {}
414
+ const parserMod = req('@tabnas/parser')
415
+ const { Tabnas } = parserMod
416
+ // Nested merge, not a shallow spread: an entry that configures any
417
+ // options.parse setting must not silently lose recover.enabled —
418
+ // multi-error diagnostics are the pipeline's foundation. Explicit
419
+ // entry recover settings still win over these defaults.
420
+ const entryOpts = entry.options || {}
421
+ const entryParse = entryOpts.parse || {}
422
+ const entryRecover = entryParse.recover || {}
423
+ const tnOpts = Object.assign({}, entryOpts, {
424
+ parse: Object.assign({}, entryParse, {
425
+ recover: Object.assign({ enabled: true }, entryRecover),
426
+ }),
427
+ })
428
+ if (entry.syncGroups && undefined === entryRecover.syncGroups) {
429
+ tnOpts.parse.recover.syncGroups = entry.syncGroups
430
+ }
431
+
432
+ const load = entry.load || { module: entry.name }
433
+
434
+ // --- L2: serialized GrammarSpec ---
435
+ if (null != load.spec) {
436
+ let gs = load.spec
437
+ if ('string' === typeof gs) {
438
+ const file = resolveSandboxed(gs, entry._dir)
439
+ gs = JSON.parse(readCapped(file, entry))
440
+ }
441
+ return installSpec(gs, entry, tnOpts)
442
+ }
443
+
444
+ // --- L3: BNF-dialect grammar text ---
445
+ if (null != load.grammar) {
446
+ const file = resolveSandboxed(load.grammar, entry._dir)
447
+ const src = readCapped(file, entry)
448
+ const gs = compileGrammarText(req, file, src)
449
+ return installSpec(gs, entry, tnOpts)
450
+ }
451
+
452
+ // --- L1: plugin module ---
453
+ const name = load.module || entry.name
454
+ if ('workspace' === entry._source && true !== trust.workspaceModules) {
455
+ throw new LoadError(
456
+ 'workspace entry ' + entry.languageId + ' loads module ' + name +
457
+ ', which runs code from the workspace. Refused: set ' +
458
+ 'trustWorkspaceModules in initializationOptions to allow it.')
459
+ }
460
+ let tn = new Tabnas(tnOpts)
461
+ for (const stackName of entry.stack || buildStack(entry)) {
462
+ tn = applyPlugin(tn, req, stackName)
463
+ }
464
+ entry._inst = tn
465
+ return tn
466
+
467
+ function installSpec(gs, entry_, tnOpts_) {
468
+ const issues = firewallSpec(gs, parserMod)
469
+ if (0 < issues.length) {
470
+ throw new LoadError(
471
+ 'grammar for ' + entry_.languageId + ' failed the firewall', issues)
472
+ }
473
+ let tn_ = new Tabnas(tnOpts_)
474
+ // Composition-aware: a spec layering on a base grammar validates
475
+ // and loads against that declared stack, not a bare engine.
476
+ if (entry_.base && 'grammar' === entry_.pluginKind) {
477
+ tn_ = applyPlugin(tn_, req, entry_.base)
478
+ }
479
+ if (entry_.stack) {
480
+ for (const stackName of entry_.stack) {
481
+ tn_ = applyPlugin(tn_, req, stackName)
482
+ }
483
+ }
484
+ tn_.grammar(gs)
485
+ entry_._inst = tn_
486
+ return tn_
487
+ }
488
+ }
489
+ }
490
+
491
+ // Read a grammar file with the byte cap applied: the untrusted-data
492
+ // bound has to hold before JSON.parse or a dialect compile sees the
493
+ // content, not after.
494
+ function readCapped(file, entry) {
495
+ const stat = fs.statSync(file)
496
+ if (stat.size > MAX_GRAMMAR_BYTES) {
497
+ throw new LoadError(
498
+ 'grammar file for ' + entry.languageId + ' is ' + stat.size +
499
+ ' bytes, larger than ' + MAX_GRAMMAR_BYTES)
500
+ }
501
+ return fs.readFileSync(file, 'utf8')
502
+ }
503
+
504
+ module.exports = {
505
+ makeLoader,
506
+ firewallSpec,
507
+ compileGrammarText,
508
+ resolveSandboxed,
509
+ LoadError,
510
+ MAX_GRAMMAR_RULES,
511
+ MAX_GRAMMAR_ALTS,
512
+ MAX_GRAMMAR_DEPTH,
513
+ MAX_GRAMMAR_BYTES,
514
+ DIALECTS,
515
+ }