@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/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
+ }
@@ -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 }