@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/core.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/* Copyright (c) 2026 Richard Rodger, MIT License */
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
// The parse pipeline (design §5): ONE debounced parse per change, with
|
|
5
|
+
// the mux collectors installed, yields diagnostics, semantic tokens,
|
|
6
|
+
// and outline together. Protocol-independent — server.js is a thin
|
|
7
|
+
// front-end over this module, keeping the byte-parity discipline with
|
|
8
|
+
// the CLI/MCP surfaces possible.
|
|
9
|
+
|
|
10
|
+
// VERSION must equal package.json "version" (test/version.test.js) and
|
|
11
|
+
// the Go const in go/lsp.go (go/version_test.go): the release
|
|
12
|
+
// orchestrator rewrites all three together, and the tests are what
|
|
13
|
+
// turn a missed rewrite into a failure instead of a silent drift.
|
|
14
|
+
const VERSION = '0.1.0'
|
|
15
|
+
|
|
16
|
+
// Default engine-token -> LSP semantic-token-type map, sourced from
|
|
17
|
+
// railroad's CANON key set (engine-standard tokens only; #ID is
|
|
18
|
+
// per-plugin and comes from registry overrides), plus the prefix
|
|
19
|
+
// conventions for non-jsonic token schemes (design §5).
|
|
20
|
+
const DEFAULT_TOKEN_TYPES = {
|
|
21
|
+
'#ST': 'string',
|
|
22
|
+
'#NR': 'number',
|
|
23
|
+
'#CM': 'comment',
|
|
24
|
+
'#VL': 'keyword',
|
|
25
|
+
'#TX': 'string',
|
|
26
|
+
'#OB': 'operator',
|
|
27
|
+
'#CB': 'operator',
|
|
28
|
+
'#OS': 'operator',
|
|
29
|
+
'#CS': 'operator',
|
|
30
|
+
'#CL': 'operator',
|
|
31
|
+
'#CA': 'operator',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PREFIX_TYPES = [
|
|
35
|
+
[/^KW_/, 'keyword'],
|
|
36
|
+
[/^LIT_/, 'string'],
|
|
37
|
+
[/^TRIVIA_/, 'comment'],
|
|
38
|
+
[/^PP_/, 'macro'],
|
|
39
|
+
[/^PUNC_/, 'operator'],
|
|
40
|
+
[/^ID$|^#ID$/, 'variable'],
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
// The fixed superset legend (design §11): hot-added grammars never
|
|
44
|
+
// force re-registration.
|
|
45
|
+
const LEGEND = [
|
|
46
|
+
'string', 'number', 'comment', 'keyword', 'operator', 'variable',
|
|
47
|
+
'macro', 'type', 'property',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
function tokenType(name, overrides) {
|
|
51
|
+
if (overrides && overrides[name]) return overrides[name]
|
|
52
|
+
if (DEFAULT_TOKEN_TYPES[name]) return DEFAULT_TOKEN_TYPES[name]
|
|
53
|
+
for (const [re, type] of PREFIX_TYPES) {
|
|
54
|
+
if (re.test(name)) return type
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Structural rules for the outline, v1 rule-name filter (design §5);
|
|
60
|
+
// per-entry override via entry.semanticTokens/outlineRules.
|
|
61
|
+
const DEFAULT_OUTLINE_RULES = { map: 'Object', list: 'Array' }
|
|
62
|
+
|
|
63
|
+
// Run one parse and derive every artifact. `inst` must have recovery
|
|
64
|
+
// enabled (the pipeline still works fail-fast pre-P0 via the caller's
|
|
65
|
+
// last-good handling — a thrown error yields diagnostics only).
|
|
66
|
+
function analyze(instances, inst, entry, doc) {
|
|
67
|
+
const lexEvents = []
|
|
68
|
+
const ruleEvents = []
|
|
69
|
+
|
|
70
|
+
const collector = {
|
|
71
|
+
lex: (tkn) => {
|
|
72
|
+
if (0 <= tkn.sI) lexEvents.push(tkn)
|
|
73
|
+
},
|
|
74
|
+
ruleDone: (rule, ctx, done) => {
|
|
75
|
+
ruleEvents.push({
|
|
76
|
+
i: rule.i,
|
|
77
|
+
name: rule.name,
|
|
78
|
+
state: done.state,
|
|
79
|
+
forced: !!done.forced,
|
|
80
|
+
r: done.alt ? done.alt.r : '',
|
|
81
|
+
o0: 0 < rule.oN ? { sI: rule.o0.sI, rI: rule.o0.rI, cI: rule.o0.cI, len: rule.o0.len } : null,
|
|
82
|
+
c0: 0 < rule.cN ? { sI: rule.c0.sI, rI: rule.c0.rI, cI: rule.c0.cI, len: rule.c0.len } : null,
|
|
83
|
+
})
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let value = undefined
|
|
88
|
+
let errors = []
|
|
89
|
+
let failed = null
|
|
90
|
+
try {
|
|
91
|
+
const out = instances.parse(inst, doc.text, collector)
|
|
92
|
+
if (out && 'object' === typeof out && 'errors' in out && Array.isArray(out.errors)) {
|
|
93
|
+
value = out.value
|
|
94
|
+
errors = out.errors
|
|
95
|
+
} else {
|
|
96
|
+
value = out
|
|
97
|
+
}
|
|
98
|
+
} catch (e) {
|
|
99
|
+
failed = e
|
|
100
|
+
if (e && e.internal) errors = [e]
|
|
101
|
+
else throw e
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
value,
|
|
106
|
+
errors,
|
|
107
|
+
failed: null != failed,
|
|
108
|
+
diagnostics: diagnostics(errors, entry, doc),
|
|
109
|
+
semanticTokens:
|
|
110
|
+
'clean' === entry.lexStream ? semanticTokens(lexEvents, entry, doc) : null,
|
|
111
|
+
outline: outline(ruleEvents, entry, doc),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function diagnostics(errors, entry, doc) {
|
|
116
|
+
const out = []
|
|
117
|
+
for (const err of errors) {
|
|
118
|
+
let j
|
|
119
|
+
try {
|
|
120
|
+
j = JSON.parse(JSON.stringify(err))
|
|
121
|
+
} catch (e) {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const d = {
|
|
125
|
+
range: doc.rangeFrom(j.row, j.col, j.pos, j.len),
|
|
126
|
+
severity: 1, // Error
|
|
127
|
+
code: j.code,
|
|
128
|
+
source: 'tabnas' + (entry ? ':' + entry.languageId : ''),
|
|
129
|
+
message: j.message + (j.hint ? '\n\n' + j.hint : ''),
|
|
130
|
+
}
|
|
131
|
+
// Registry pages exist for known codes (engine + declared owners).
|
|
132
|
+
if (j.code && 'unknown' !== j.code) {
|
|
133
|
+
d.codeDescription = { href: 'https://tabnas.dev/errors/' + j.code }
|
|
134
|
+
}
|
|
135
|
+
out.push(d)
|
|
136
|
+
}
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Reconstruct final tokens per the documented lex-trace contract:
|
|
141
|
+
// newest event per position wins, spans shadow interior positions.
|
|
142
|
+
function reconcile(lexEvents) {
|
|
143
|
+
const out = []
|
|
144
|
+
const claimed = []
|
|
145
|
+
for (let i = lexEvents.length - 1; 0 <= i; i--) {
|
|
146
|
+
const t = lexEvents[i]
|
|
147
|
+
const len = Math.max(1, t.len | 0)
|
|
148
|
+
let shadowed = false
|
|
149
|
+
for (const [s, e] of claimed) {
|
|
150
|
+
if (t.sI >= s && t.sI < e) { shadowed = true; break }
|
|
151
|
+
}
|
|
152
|
+
if (shadowed) continue
|
|
153
|
+
claimed.push([t.sI, t.sI + len])
|
|
154
|
+
out.push(t)
|
|
155
|
+
}
|
|
156
|
+
return out.sort((a, b) => a.sI - b.sI)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function semanticTokens(lexEvents, entry, doc) {
|
|
160
|
+
const overrides = entry ? entry.semanticTokens : null
|
|
161
|
+
const inst = entry && entry._inst
|
|
162
|
+
const data = []
|
|
163
|
+
let prevLine = 0
|
|
164
|
+
let prevChar = 0
|
|
165
|
+
const emit = (line, char, len, typeI) => {
|
|
166
|
+
if (len < 1) return
|
|
167
|
+
const dLine = line - prevLine
|
|
168
|
+
const dChar = 0 === dLine ? char - prevChar : char
|
|
169
|
+
if (dLine < 0 || (0 === dLine && dChar < 0)) return // out-of-order guard
|
|
170
|
+
data.push(dLine, dChar, len, typeI, 0)
|
|
171
|
+
prevLine = line
|
|
172
|
+
prevChar = char
|
|
173
|
+
}
|
|
174
|
+
for (const t of reconcile(lexEvents)) {
|
|
175
|
+
const name = t.name || (inst && String(inst.token(t.tin))) || ''
|
|
176
|
+
const type = tokenType(name, overrides)
|
|
177
|
+
if (null == type) continue
|
|
178
|
+
const typeI = LEGEND.indexOf(type)
|
|
179
|
+
if (typeI < 0) continue
|
|
180
|
+
const line = Math.max(0, t.rI - 1)
|
|
181
|
+
const char = Math.max(0, t.cI - 1)
|
|
182
|
+
const len = Math.max(1, t.len | 0)
|
|
183
|
+
// A token spanning lines (multiline string, block comment) is
|
|
184
|
+
// split into line-local spans: multiline semantic tokens are an
|
|
185
|
+
// OPTIONAL client capability, and an unsplit one mis-highlights or
|
|
186
|
+
// is rejected by clients without it.
|
|
187
|
+
const text = doc ? doc.text.substr(t.sI, len) : ''
|
|
188
|
+
if (text.includes('\n')) {
|
|
189
|
+
const parts = text.split('\n')
|
|
190
|
+
for (let i = 0; i < parts.length; i++) {
|
|
191
|
+
const seg = parts[i].replace(/\r$/, '')
|
|
192
|
+
emit(line + i, 0 === i ? char : 0, seg.length, typeI)
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
emit(line, char, len, typeI)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { data, legend: LEGEND }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function outline(ruleEvents, entry, doc) {
|
|
202
|
+
const rules = Object.assign({}, DEFAULT_OUTLINE_RULES, entry && entry.outlineRules)
|
|
203
|
+
const open = new Map()
|
|
204
|
+
const symbols = []
|
|
205
|
+
for (const e of ruleEvents) {
|
|
206
|
+
if (null == rules[e.name]) continue
|
|
207
|
+
if ('o' === e.state && e.o0) {
|
|
208
|
+
open.set(e.i, e)
|
|
209
|
+
} else if ('c' === e.state) {
|
|
210
|
+
const o = open.get(e.i)
|
|
211
|
+
open.delete(e.i)
|
|
212
|
+
if (o && (e.c0 || e.forced)) {
|
|
213
|
+
const start = { line: o.o0.rI - 1, character: o.o0.cI - 1 }
|
|
214
|
+
const endTok = e.c0 || o.o0
|
|
215
|
+
const end = {
|
|
216
|
+
line: endTok.rI - 1,
|
|
217
|
+
character: endTok.cI - 1 + Math.max(1, endTok.len | 0),
|
|
218
|
+
}
|
|
219
|
+
symbols.push({
|
|
220
|
+
name: rules[e.name],
|
|
221
|
+
kind: 'Array' === rules[e.name] ? 18 : 19, // SymbolKind.Array/Object
|
|
222
|
+
range: { start, end },
|
|
223
|
+
selectionRange: { start, end: start },
|
|
224
|
+
_span: [o.o0.sI, endTok.sI],
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Nest by span containment.
|
|
230
|
+
symbols.sort((a, b) => a._span[0] - b._span[0] || b._span[1] - a._span[1])
|
|
231
|
+
const roots = []
|
|
232
|
+
const stack = []
|
|
233
|
+
for (const s of symbols) {
|
|
234
|
+
s.children = []
|
|
235
|
+
while (0 < stack.length && !(stack[stack.length - 1]._span[0] <= s._span[0] && s._span[1] <= stack[stack.length - 1]._span[1])) {
|
|
236
|
+
stack.pop()
|
|
237
|
+
}
|
|
238
|
+
if (0 < stack.length) stack[stack.length - 1].children.push(s)
|
|
239
|
+
else roots.push(s)
|
|
240
|
+
stack.push(s)
|
|
241
|
+
}
|
|
242
|
+
for (const s of symbols) delete s._span
|
|
243
|
+
return roots
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Tokens the engine may name as legal continuations that a user can
|
|
247
|
+
// never type. #ZZ is end-of-source: the engine returns it whenever the
|
|
248
|
+
// prefix parses (that is how it says "this document is already
|
|
249
|
+
// valid"), so it reaches completion on nearly every keystroke in a
|
|
250
|
+
// permissive grammar. #AA is the match-any sentinel, and #BD the
|
|
251
|
+
// bad-token marker — both are engine-internal.
|
|
252
|
+
const SENTINEL_TOKENS = new Set(['#ZZ', '#AA', '#BD'])
|
|
253
|
+
|
|
254
|
+
// Completion via the engine's continuation primitive (A6), with
|
|
255
|
+
// friendly labels for fixed tokens.
|
|
256
|
+
function completion(inst, entry, doc, position) {
|
|
257
|
+
const prefix = doc.text.substring(0, doc.offsetAt(position))
|
|
258
|
+
let cont
|
|
259
|
+
try {
|
|
260
|
+
cont = inst.continuations(prefix)
|
|
261
|
+
} catch (e) {
|
|
262
|
+
return []
|
|
263
|
+
}
|
|
264
|
+
const items = []
|
|
265
|
+
for (const name of cont.tokens) {
|
|
266
|
+
if (SENTINEL_TOKENS.has(name)) continue
|
|
267
|
+
const fixedSrc = fixedSource(inst, name)
|
|
268
|
+
items.push({
|
|
269
|
+
label: fixedSrc || name,
|
|
270
|
+
kind: fixedSrc ? 24 : 14, // Operator : Keyword
|
|
271
|
+
detail: name,
|
|
272
|
+
insertText: fixedSrc || undefined,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
return items
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function fixedSource(inst, name) {
|
|
279
|
+
try {
|
|
280
|
+
const tin = inst.token(name)
|
|
281
|
+
const src = inst.fixed(tin)
|
|
282
|
+
return 'string' === typeof src ? src : null
|
|
283
|
+
} catch (e) {
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
VERSION,
|
|
290
|
+
analyze,
|
|
291
|
+
completion,
|
|
292
|
+
diagnostics,
|
|
293
|
+
semanticTokens,
|
|
294
|
+
outline,
|
|
295
|
+
reconcile,
|
|
296
|
+
tokenType,
|
|
297
|
+
LEGEND,
|
|
298
|
+
DEFAULT_TOKEN_TYPES,
|
|
299
|
+
}
|
package/src/documents.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* Copyright (c) 2026 Richard Rodger, MIT License */
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
// Document store: line index and position-encoding conversion. All
|
|
5
|
+
// encoding knowledge lives here (design §5): engine diagnostics carry
|
|
6
|
+
// col/pos in UTF-16 units (TS engine) but `len` in Unicode CODE
|
|
7
|
+
// POINTS — both must convert to the client's negotiated encoding
|
|
8
|
+
// before a Range is built.
|
|
9
|
+
|
|
10
|
+
class Doc {
|
|
11
|
+
constructor(uri, languageId, version, text) {
|
|
12
|
+
this.uri = uri
|
|
13
|
+
this.languageId = languageId
|
|
14
|
+
this.version = version
|
|
15
|
+
this.text = text
|
|
16
|
+
this._lines = null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
update(text, version) {
|
|
20
|
+
this.text = text
|
|
21
|
+
this.version = version
|
|
22
|
+
this._lines = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Offsets (UTF-16 units) of each line start.
|
|
26
|
+
lineStarts() {
|
|
27
|
+
if (!this._lines) {
|
|
28
|
+
const starts = [0]
|
|
29
|
+
const t = this.text
|
|
30
|
+
for (let i = 0; i < t.length; i++) {
|
|
31
|
+
if ('\n' === t[i]) starts.push(i + 1)
|
|
32
|
+
}
|
|
33
|
+
this._lines = starts
|
|
34
|
+
}
|
|
35
|
+
return this._lines
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Engine (row, col) [1-based, col in UTF-16 units] -> LSP Position.
|
|
39
|
+
posFrom(row, col) {
|
|
40
|
+
return { line: Math.max(0, row - 1), character: Math.max(0, col - 1) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Engine diagnostic -> LSP Range. `len` counts CODE POINTS of the
|
|
44
|
+
// token source; convert to UTF-16 units by scanning the actual text
|
|
45
|
+
// from the start offset (astral chars take two units each).
|
|
46
|
+
rangeFrom(row, col, pos, lenCodePoints) {
|
|
47
|
+
const start = this.posFrom(row, col)
|
|
48
|
+
let units = 0
|
|
49
|
+
let cp = 0
|
|
50
|
+
const t = this.text
|
|
51
|
+
let i = 0 <= pos ? pos : this.offsetAt(start)
|
|
52
|
+
while (cp < lenCodePoints && i + units < t.length) {
|
|
53
|
+
const code = t.codePointAt(i + units)
|
|
54
|
+
units += 0xffff < code ? 2 : 1
|
|
55
|
+
cp++
|
|
56
|
+
}
|
|
57
|
+
const endOffset = i + Math.max(units, lenCodePoints > 0 ? 1 : 0)
|
|
58
|
+
return { start, end: this.positionAt(Math.min(endOffset, t.length)) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
offsetAt(position) {
|
|
62
|
+
const starts = this.lineStarts()
|
|
63
|
+
const line = Math.min(position.line, starts.length - 1)
|
|
64
|
+
return Math.min(starts[line] + position.character, this.text.length)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
positionAt(offset) {
|
|
68
|
+
const starts = this.lineStarts()
|
|
69
|
+
let lo = 0
|
|
70
|
+
let hi = starts.length - 1
|
|
71
|
+
while (lo < hi) {
|
|
72
|
+
const mid = (lo + hi + 1) >> 1
|
|
73
|
+
if (starts[mid] <= offset) lo = mid
|
|
74
|
+
else hi = mid - 1
|
|
75
|
+
}
|
|
76
|
+
return { line: lo, character: offset - starts[lo] }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class DocumentStore {
|
|
81
|
+
constructor() {
|
|
82
|
+
this.docs = new Map()
|
|
83
|
+
}
|
|
84
|
+
open(uri, languageId, version, text) {
|
|
85
|
+
const d = new Doc(uri, languageId, version, text)
|
|
86
|
+
this.docs.set(uri, d)
|
|
87
|
+
return d
|
|
88
|
+
}
|
|
89
|
+
get(uri) {
|
|
90
|
+
return this.docs.get(uri)
|
|
91
|
+
}
|
|
92
|
+
close(uri) {
|
|
93
|
+
this.docs.delete(uri)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { Doc, DocumentStore }
|