@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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/tabnas-lsp-gen.js +98 -0
- package/bin/tabnas-lsp.js +7 -0
- package/data/diagnostic-fixtures.json +2888 -0
- package/data/registry.json +424 -0
- package/package.json +52 -0
- package/src/core.js +299 -0
- package/src/documents.js +97 -0
- package/src/generate.js +962 -0
- package/src/instances.js +141 -0
- package/src/loaders.js +515 -0
- package/src/registry.js +194 -0
- package/src/server.js +371 -0
- package/tools/gen-fixtures.js +90 -0
- package/tools/gen-registry.js +153 -0
package/src/instances.js
ADDED
|
@@ -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
|
+
}
|