@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,194 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+ 'use strict'
3
+
4
+ // Grammar registry: document -> grammar resolution for the unified
5
+ // language server. Entries come from three sources with precedence
6
+ // workspace > user > bundled (design §3, admin
7
+ // notes/2026-08-17-unified-lsp-design.md).
8
+ //
9
+ // Workspace entries are FOLDER-SCOPED: they carry the folder they came
10
+ // from (`_dir`) and apply only to documents inside it, resolved by
11
+ // path containment with the deepest folder winning. Two roots
12
+ // declaring the same languageId therefore keep their own grammars —
13
+ // a flat languageId-keyed map made the last-loaded folder capture the
14
+ // first folder's documents (review catch on #1). User and bundled
15
+ // entries are global and keyed by languageId as before.
16
+ //
17
+ // A registry entry is a tabnas.plugin.json descriptor plus the
18
+ // LSP-specific fields the descriptors do not carry yet (languageId,
19
+ // grammarKind, pluginKind, syncGroups, semanticTokens, lexStream) —
20
+ // supplied by the overrides table in data/registry.json until the
21
+ // ax-descriptor extension (plan C1) lands them per repo.
22
+
23
+ const path = require('path')
24
+
25
+ // Load the generated bundle (tools/gen-registry.js).
26
+ function loadBundled(file) {
27
+ const bundle = require(file || path.join(__dirname, '..', 'data', 'registry.json'))
28
+ return bundle.entries.map(normalize)
29
+ }
30
+
31
+ function normalize(e) {
32
+ return {
33
+ name: e.name,
34
+ languageId: e.languageId || (e.name || '').replace(/^@tabnas\//, ''),
35
+ extensions: e.extensions || [],
36
+ mediaTypes: e.mediaTypes || [],
37
+ base: e.base || null,
38
+ pluginKind: e.pluginKind || 'grammar', // grammar | compiler | modifier
39
+ grammarKind: e.grammarKind || 'closure', // data | compiled | closure | imperative | external
40
+ lexStream: e.lexStream || 'clean', // clean | speculative
41
+ syncGroups: e.syncGroups || null,
42
+ semanticTokens: e.semanticTokens || {},
43
+ outlineRules: e.outlineRules || null,
44
+ errorCodes: e.errorCodes || [],
45
+ enabled: false !== e.enabled,
46
+ // How to load: { module } | { spec } | { grammar } — the loader
47
+ // (loaders.js) dispatches on this.
48
+ load: e.load || { module: e.name },
49
+ options: e.options || {},
50
+ stack: e.stack || null, // explicit plugin stack override
51
+ // Provenance, stamped by the host: which config tier supplied this
52
+ // entry ('bundled' | 'user' | 'workspace').
53
+ _source: e._source || 'bundled',
54
+ // _dir is the SANDBOX BASE: the folder a workspace entry's relative
55
+ // grammar paths resolve against (loaders.js resolveSandboxed), and
56
+ // part of the instance cache key.
57
+ _dir: e._dir || null,
58
+ // _scope is the ROUTING SCOPE and is deliberately separate: the
59
+ // folder whose documents this entry serves, or null for "anywhere".
60
+ // Conflating the two broke client-supplied entries — they need a
61
+ // sandbox base (some folder) but are session-wide, so scoping them
62
+ // to that base made them invisible in every other workspace folder.
63
+ // Defaults to _dir so a folder manifest stays folder-scoped.
64
+ _scope: '_scope' in e ? e._scope : (e._dir || null),
65
+ }
66
+ }
67
+
68
+ // The filesystem path of a document URI, null for non-file documents
69
+ // (untitled:, vscode-notebook-cell:, …) — workspace scoping applies
70
+ // only to documents that live in a folder.
71
+ function fsPathOf(uri) {
72
+ const m = /^file:\/\/[^/]*(\/.*)$/.exec(uri || '')
73
+ if (!m) return null
74
+ let p = decodeURIComponent(m[1])
75
+ // Windows drive form: /c:/dir -> c:/dir
76
+ if (/^\/[A-Za-z]:/.test(p)) p = p.slice(1)
77
+ return p
78
+ }
79
+
80
+ // Containment over the two path shapes this server actually holds.
81
+ // fsPathOf() always yields forward slashes; the folder side comes from
82
+ // url.fileURLToPath, which yields BACKSLASHES on win32 — so on Windows
83
+ // the two sides never matched and folder-scoped routing was dead. The
84
+ // dir side is normalised to forward slashes before comparing.
85
+ //
86
+ // That rewrite applies only to paths that are actually WINDOWS-SHAPED
87
+ // — a drive letter (`c:\ws\alpha`) or a UNC root (`\\srv\share`). The
88
+ // discriminator is the path's shape, not process.platform, and both
89
+ // halves of that choice are deliberate:
90
+ //
91
+ // - Unconditional rewriting was wrong. On POSIX a backslash is an
92
+ // ordinary filename character, so a workspace rooted at `/work/a\b`
93
+ // stopped containing its own documents and started matching the
94
+ // unrelated `/work/a/b` tree instead.
95
+ // - Sniffing process.platform would be wrong too. Windows routing is
96
+ // tested on Linux (there is no win32 runner in this repo's matrix),
97
+ // and a platform gate makes that test vacuous on every host that
98
+ // runs it.
99
+ const WINDOWSY = /^([A-Za-z]:|\\\\)/
100
+
101
+ function contains(dir, fsPath) {
102
+ if (null == dir || null == fsPath) return false
103
+ let d = String(dir)
104
+ if (WINDOWSY.test(d)) d = d.replace(/\\/g, '/')
105
+ d = d.replace(/\/+$/, '')
106
+ return fsPath === d || fsPath.startsWith(d + '/')
107
+ }
108
+
109
+ class Registry {
110
+ constructor(bundled, workspace, user) {
111
+ // Global tiers, keyed by languageId: user beats bundled.
112
+ this.global = new Map()
113
+ for (const list of [bundled || [], user || []]) {
114
+ for (const raw of list) {
115
+ const e = normalize(raw)
116
+ this.global.set(e.languageId, e)
117
+ }
118
+ }
119
+ // Workspace tier: folder-scoped, ordered as given.
120
+ this.workspace = (workspace || []).map(normalize)
121
+ }
122
+
123
+ get(languageId) {
124
+ return this.global.get(languageId)
125
+ }
126
+
127
+ // Resolve a document to an entry.
128
+ //
129
+ // Workspace entries win for documents inside their folder — matched
130
+ // by languageId first, then by extension, deepest folder first.
131
+ // Outside any workspace folder (or when none matches), the client
132
+ // languageId wins only when it names an ENABLED global entry
133
+ // (editors send generic ids like 'plaintext' for unknown
134
+ // extensions); otherwise fall back to the most-specific extension
135
+ // match. Ties surface for diagnostics.
136
+ resolve(languageId, uri) {
137
+ const fsPath = fsPathOf(uri)
138
+ const ext = extOf(uri)
139
+
140
+ // Workspace candidates, most specific first: entries scoped to a
141
+ // folder containing this document (deepest folder wins), then
142
+ // unscoped session-wide entries. An unscoped entry still applies to
143
+ // a non-file document (untitled:, vscode-notebook-cell:), which a
144
+ // folder-scoped one never can.
145
+ const usable = this.workspace.filter(
146
+ (e) => e.enabled && 'modifier' !== e.pluginKind)
147
+ const scoped = null == fsPath ? [] : usable
148
+ .filter((e) => null != e._scope && contains(e._scope, fsPath))
149
+ .sort((a, b) => String(b._scope).length - String(a._scope).length)
150
+ const candidates = scoped.concat(usable.filter((e) => null == e._scope))
151
+
152
+ if (0 < candidates.length) {
153
+ const byId = candidates.find((e) => e.languageId === languageId)
154
+ if (byId) return { entry: byId, via: 'workspace:languageId' }
155
+ const byExt = candidates.find((e) =>
156
+ null != ext && e.extensions.some((x) => x.toLowerCase() === ext))
157
+ if (byExt) return { entry: byExt, via: 'workspace:extension' }
158
+ }
159
+
160
+ const direct = this.global.get(languageId)
161
+ if (direct && direct.enabled && 'modifier' !== direct.pluginKind) {
162
+ return { entry: direct, via: 'languageId' }
163
+ }
164
+
165
+ if (!ext) return { entry: null, via: null }
166
+
167
+ let best = null
168
+ let ambiguous = []
169
+ for (const e of this.global.values()) {
170
+ if (!e.enabled || 'modifier' === e.pluginKind) continue
171
+ for (const x of e.extensions) {
172
+ if (x.toLowerCase() === ext) {
173
+ if (best && best.entry !== e) ambiguous.push(e)
174
+ else best = { entry: e, via: 'extension' }
175
+ }
176
+ }
177
+ }
178
+ if (best && 0 < ambiguous.length) {
179
+ best.ambiguous = [best.entry.languageId, ...ambiguous.map((e) => e.languageId)]
180
+ }
181
+ return best || { entry: null, via: null }
182
+ }
183
+
184
+ all() {
185
+ return [...this.global.values(), ...this.workspace]
186
+ }
187
+ }
188
+
189
+ function extOf(uri) {
190
+ const m = /(\.[^./\\]+)$/.exec(uri || '')
191
+ return m ? m[1].toLowerCase() : null
192
+ }
193
+
194
+ module.exports = { Registry, loadBundled, normalize, fsPathOf }
package/src/server.js ADDED
@@ -0,0 +1,371 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+ 'use strict'
3
+
4
+ // Protocol front-end: a thin vscode-languageserver wiring over core.js
5
+ // (design §11). Incremental sync, version-stamped push diagnostics,
6
+ // per-language routing via the registry, workspace-registered grammars
7
+ // (the dynamic-add lanes, design §6), and the tabnas/status custom
8
+ // request.
9
+ //
10
+ // The protocol library is required lazily so core.js stays testable
11
+ // without it (and so a browser build can substitute
12
+ // vscode-languageserver/browser).
13
+
14
+ const fs = require('fs')
15
+ const path = require('path')
16
+ const url = require('url')
17
+
18
+ const { Registry, loadBundled } = require('./registry')
19
+ const { DocumentStore } = require('./documents')
20
+ const { SerialInstances } = require('./instances')
21
+ const { makeLoader, LoadError } = require('./loaders')
22
+ const core = require('./core')
23
+
24
+ const DEBOUNCE_MS = 150
25
+
26
+ // Name of the per-workspace-folder grammar manifest.
27
+ const WORKSPACE_MANIFEST = '.tabnas/lsp.json'
28
+
29
+ function folderPathOf(uriOrPath) {
30
+ if (null == uriOrPath) return null
31
+ if (/^file:/.test(uriOrPath)) {
32
+ try {
33
+ return url.fileURLToPath(uriOrPath)
34
+ } catch (e) {
35
+ return null
36
+ }
37
+ }
38
+ return uriOrPath
39
+ }
40
+
41
+ // Read a folder's .tabnas/lsp.json manifest: { languages: [entry...] }.
42
+ // Entries are stamped workspace-sourced with the folder as their
43
+ // sandbox dir; a malformed manifest is reported, never fatal.
44
+ function readWorkspaceManifest(folder, report) {
45
+ const file = path.join(folder, WORKSPACE_MANIFEST)
46
+ let text
47
+ try {
48
+ text = fs.readFileSync(file, 'utf8')
49
+ } catch (e) {
50
+ return [] // no manifest — the common case
51
+ }
52
+ try {
53
+ const manifest = JSON.parse(text)
54
+ const languages = Array.isArray(manifest.languages) ? manifest.languages : []
55
+ return languages.map((e) =>
56
+ Object.assign({}, e, { _source: 'workspace', _dir: folder }))
57
+ } catch (e) {
58
+ if (report) report(file + ': ' + e.message)
59
+ return []
60
+ }
61
+ }
62
+
63
+ function startServer(opts) {
64
+ const lsp = require('vscode-languageserver/node')
65
+ const connection = opts?.connection || lsp.createConnection(lsp.ProposedFeatures.all)
66
+
67
+ // Late-bound loader options: initializationOptions arrive after the
68
+ // loader is constructed; the same object is mutated at initialize.
69
+ const loaderOpts = { trust: {} }
70
+
71
+ // A generated single-language server passes `entries` — the exact
72
+ // list it serves — replacing the bundled fleet registry entirely.
73
+ const baseEntries = () =>
74
+ opts?.entries ? opts.entries : loadBundled(opts?.registryFile)
75
+
76
+ let registry = new Registry(
77
+ baseEntries(),
78
+ opts?.workspaceEntries,
79
+ opts?.userEntries,
80
+ )
81
+ const docs = new DocumentStore()
82
+ const instances = new SerialInstances(makeLoader(opts?.require, loaderOpts))
83
+ const timers = new Map()
84
+ const lastGood = new Map() // uri -> { version, analysis }
85
+ let workspaceFolders = []
86
+ let initLangs = []
87
+
88
+ // Build (or rebuild) the registry's workspace tier from
89
+ // initializationOptions.languages plus each folder's .tabnas/lsp.json
90
+ // (dynamic add, design §6). Called at initialize and again whenever a
91
+ // manifest changes — invalidating instances alone would keep serving
92
+ // the OLD entry objects: edited grammar paths and options would be
93
+ // ignored, added languages never routed, removed ones never dropped.
94
+ function rebuildRegistry() {
95
+ for (const e of registry ? registry.all() : []) {
96
+ if ('workspace' === e._source) instances.invalidate(e)
97
+ }
98
+ const workspace = []
99
+ // Client-supplied entries are session-wide: _dir gives their
100
+ // relative grammar paths a sandbox base, but _scope is null so they
101
+ // route in EVERY folder. Scoping them to folder[0] (the old
102
+ // behaviour) made them dead in every other root of a multi-root
103
+ // session, and dead everywhere at all when the client sent no
104
+ // folders, since the base then fell back to the server's cwd.
105
+ const defaultDir = workspaceFolders[0] || process.cwd()
106
+ for (const e of initLangs) {
107
+ workspace.push(Object.assign(
108
+ { _source: 'workspace', _dir: defaultDir }, e, { _scope: null }))
109
+ }
110
+ for (const folder of workspaceFolders) {
111
+ workspace.push(...readWorkspaceManifest(folder, (msg) =>
112
+ connection.console.warn('tabnas-lsp: workspace manifest ignored: ' + msg)))
113
+ }
114
+ registry = new Registry(
115
+ baseEntries(),
116
+ (opts?.workspaceEntries || []).concat(workspace),
117
+ opts?.userEntries,
118
+ )
119
+ for (const e of registry.all()) {
120
+ if ('workspace' === e._source) instances.invalidate(e)
121
+ }
122
+ }
123
+
124
+ connection.onInitialize((params) => {
125
+ const init = params?.initializationOptions || {}
126
+ if (true === init.trustWorkspaceModules) {
127
+ loaderOpts.trust.workspaceModules = true
128
+ }
129
+
130
+ workspaceFolders = (params?.workspaceFolders || [])
131
+ .map((f) => folderPathOf(f.uri))
132
+ .filter(Boolean)
133
+ if (0 === workspaceFolders.length && params?.rootUri) {
134
+ const root = folderPathOf(params.rootUri)
135
+ if (root) workspaceFolders = [root]
136
+ }
137
+
138
+ initLangs = Array.isArray(init.languages) ? init.languages : []
139
+ rebuildRegistry()
140
+
141
+ return {
142
+ capabilities: {
143
+ textDocumentSync: {
144
+ openClose: true,
145
+ change: lsp.TextDocumentSyncKind.Incremental,
146
+ },
147
+ completionProvider: { triggerCharacters: [':', ',', '{', '[', '"'] },
148
+ documentSymbolProvider: true,
149
+ semanticTokensProvider: {
150
+ legend: { tokenTypes: core.LEGEND, tokenModifiers: [] },
151
+ full: true,
152
+ },
153
+ hoverProvider: true,
154
+ },
155
+ }
156
+ })
157
+
158
+ connection.onInitialized(() => {
159
+ // Watch workspace grammar sources so the L4 dev loop works: an
160
+ // edited spec/BNF file rebuilds its instance and re-analyzes open
161
+ // documents, and an edited manifest rebuilds the registry tier.
162
+ // Registration is best-effort — plain clients without dynamic
163
+ // registration simply do not get hot reload.
164
+ //
165
+ // Registering whenever there is a workspace folder, NOT only when
166
+ // some entry already names a grammar file: the manifest watcher
167
+ // lives in this same registration, so gating it on existing
168
+ // file-backed entries meant a workspace with no manifest (or one
169
+ // declaring only `load:{module}` entries) never watched the
170
+ // manifest — and so could never pick up a language ADDED to it.
171
+ // The first manifest is exactly when hot reload matters most.
172
+ if (0 === workspaceFolders.length) return
173
+ try {
174
+ connection.client.register(lsp.DidChangeWatchedFilesNotification.type, {
175
+ watchers: [
176
+ { globPattern: '**/' + WORKSPACE_MANIFEST },
177
+ { globPattern: '**/*.{abnf,ebnf,gbnf}' },
178
+ { globPattern: '**/*.json' },
179
+ ],
180
+ }).catch(() => {})
181
+ } catch (e) {
182
+ // client does not support dynamic registration
183
+ }
184
+ })
185
+
186
+ connection.onDidChangeWatchedFiles((p) => {
187
+ const changedPaths = (p?.changes || [])
188
+ .map((c) => folderPathOf(c.uri))
189
+ .filter(Boolean)
190
+ if (0 === changedPaths.length) return
191
+
192
+ // A changed manifest means the workspace TIER changed — added,
193
+ // removed, or re-configured languages — so the registry itself is
194
+ // rebuilt, and every open document re-resolves and re-analyzes.
195
+ const manifestChanged = workspaceFolders.some((folder) =>
196
+ changedPaths.includes(path.join(folder, WORKSPACE_MANIFEST)))
197
+ if (manifestChanged) {
198
+ rebuildRegistry()
199
+ for (const doc of docs.docs.values()) {
200
+ lastGood.delete(doc.uri)
201
+ schedule(doc.uri)
202
+ }
203
+ return
204
+ }
205
+
206
+ // Otherwise: a grammar source file changed — rebuild that entry's
207
+ // instance and re-analyze its documents.
208
+ for (const entry of registry.all()) {
209
+ if ('workspace' !== entry._source || null == entry._dir) continue
210
+ const file = entry.load?.grammar ||
211
+ ('string' === typeof entry.load?.spec ? entry.load.spec : null)
212
+ if (null == file) continue
213
+ const abs = path.resolve(entry._dir, file)
214
+ if (changedPaths.includes(abs)) {
215
+ instances.invalidate(entry)
216
+ for (const doc of docs.docs.values()) {
217
+ if (entryFor(doc) === entry) schedule(doc.uri)
218
+ }
219
+ }
220
+ }
221
+ })
222
+
223
+ function entryFor(doc) {
224
+ const { entry } = registry.resolve(doc.languageId, doc.uri)
225
+ return entry
226
+ }
227
+
228
+ function schedule(uri) {
229
+ clearTimeout(timers.get(uri))
230
+ timers.set(
231
+ uri,
232
+ setTimeout(() => {
233
+ timers.delete(uri)
234
+ run(uri)
235
+ }, DEBOUNCE_MS),
236
+ )
237
+ }
238
+
239
+ function run(uri) {
240
+ const doc = docs.get(uri)
241
+ if (!doc) return
242
+ const entry = entryFor(doc)
243
+ if (!entry) return
244
+ let inst
245
+ try {
246
+ inst = instances.get(entry)
247
+ } catch (e) {
248
+ const detail = e instanceof LoadError ? e.message : String(e && e.message)
249
+ connection.console.error('tabnas-lsp: grammar load failed: ' + detail)
250
+ return
251
+ }
252
+ if (!inst) return // quarantined
253
+ let analysis
254
+ try {
255
+ analysis = core.analyze(instances, inst, entry, doc)
256
+ } catch (e) {
257
+ instances.recordFailure(entry)
258
+ connection.console.error('tabnas-lsp: analysis failed (' + entry.languageId + '): ' + e.message)
259
+ return
260
+ }
261
+ // Version-stamped push: stale results never land on newer content.
262
+ connection.sendDiagnostics({
263
+ uri,
264
+ version: doc.version,
265
+ diagnostics: analysis.diagnostics,
266
+ })
267
+ if (!analysis.failed) {
268
+ lastGood.set(uri, { version: doc.version, analysis })
269
+ }
270
+ }
271
+
272
+ // Serve from the current version, else last-good ONLY at the same
273
+ // version (edit-transformation of cached spans is tracked work; the
274
+ // stale-range review rule says suppress rather than mis-highlight).
275
+ function currentAnalysis(uri) {
276
+ const doc = docs.get(uri)
277
+ const lg = lastGood.get(uri)
278
+ if (doc && lg && lg.version === doc.version) return lg.analysis
279
+ return null
280
+ }
281
+
282
+ connection.onDidOpenTextDocument((p) => {
283
+ const d = p.textDocument
284
+ docs.open(d.uri, d.languageId, d.version, d.text)
285
+ schedule(d.uri)
286
+ })
287
+
288
+ connection.onDidChangeTextDocument((p) => {
289
+ const doc = docs.get(p.textDocument.uri)
290
+ if (!doc) return
291
+ let text = doc.text
292
+ for (const change of p.contentChanges) {
293
+ if (null == change.range) {
294
+ text = change.text
295
+ } else {
296
+ const start = doc.offsetAt(change.range.start)
297
+ const end = doc.offsetAt(change.range.end)
298
+ text = text.substring(0, start) + change.text + text.substring(end)
299
+ }
300
+ // After EVERY change, not only ranged ones. A didChange array may
301
+ // legally mix a full replacement with later ranged edits, and the
302
+ // replacement branch used to leave doc (and its line index) on the
303
+ // PREVIOUS text — so the next ranged edit computed offsets against
304
+ // a document that no longer existed, silently corrupting the text
305
+ // here and panicking the Go port on the same input.
306
+ doc.update(text, doc.version) // keep line index fresh mid-loop
307
+ }
308
+ doc.update(text, p.textDocument.version)
309
+ lastGood.delete(p.textDocument.uri) // suppress stale structural results
310
+ schedule(p.textDocument.uri)
311
+ })
312
+
313
+ connection.onDidCloseTextDocument((p) => {
314
+ docs.close(p.textDocument.uri)
315
+ lastGood.delete(p.textDocument.uri)
316
+ connection.sendDiagnostics({ uri: p.textDocument.uri, diagnostics: [] })
317
+ })
318
+
319
+ connection.onCompletion((p) => {
320
+ const doc = docs.get(p.textDocument.uri)
321
+ if (!doc) return []
322
+ const entry = entryFor(doc)
323
+ if (!entry) return []
324
+ // instances.get RETHROWS a grammar load failure (after counting it
325
+ // toward quarantine). run() has always caught that and logged; here
326
+ // it escaped the handler and surfaced to the client as an
327
+ // InternalError on every keystroke of a broken grammar.
328
+ let inst
329
+ try {
330
+ inst = instances.get(entry)
331
+ } catch (e) {
332
+ connection.console.error(
333
+ 'tabnas-lsp: grammar load failed (' + entry.languageId + '): ' + e.message)
334
+ return []
335
+ }
336
+ if (!inst) return [] // quarantined
337
+ return core.completion(inst, entry, doc, p.position)
338
+ })
339
+
340
+ connection.onDocumentSymbol((p) => {
341
+ const a = currentAnalysis(p.textDocument.uri)
342
+ return a ? a.outline : []
343
+ })
344
+
345
+ connection.languages.semanticTokens.on((p) => {
346
+ const a = currentAnalysis(p.textDocument.uri)
347
+ return { data: a && a.semanticTokens ? a.semanticTokens.data : [] }
348
+ })
349
+
350
+ connection.onHover((p) => {
351
+ const doc = docs.get(p.textDocument.uri)
352
+ const a = currentAnalysis(p.textDocument.uri)
353
+ if (!doc || !a) return null
354
+ return null // v1: hover ships with tokenDesc wiring (tracked)
355
+ })
356
+
357
+ connection.onRequest('tabnas/status', () => ({
358
+ languages: registry.all().map((e) => ({
359
+ languageId: e.languageId,
360
+ enabled: e.enabled,
361
+ source: e._source,
362
+ quarantined: instances.quarantined(e),
363
+ lexStream: e.lexStream,
364
+ })),
365
+ }))
366
+
367
+ connection.listen()
368
+ return { connection, registry: () => registry, docs, instances }
369
+ }
370
+
371
+ module.exports = { startServer, makeLoader, DEBOUNCE_MS, WORKSPACE_MANIFEST, readWorkspaceManifest }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
3
+ 'use strict'
4
+
5
+ // Generate diagnostics conformance fixtures from the fleet's existing
6
+ // test/spec TSV corpus (plan B5): every ERROR:<code> row becomes a
7
+ // fixture { languageId, input, expect: { codes: [code] } }. These are
8
+ // the cross-runtime parity contract for the Go server (plan P2) —
9
+ // generated, never hand-authored (derive-don't-duplicate).
10
+
11
+ const fs = require('fs')
12
+ const path = require('path')
13
+
14
+ function unescapeTSV(s) {
15
+ return s.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t')
16
+ }
17
+
18
+ function collect(root) {
19
+ const fixtures = []
20
+ let repos = []
21
+ try {
22
+ repos = fs.readdirSync(root)
23
+ } catch (e) {
24
+ return fixtures
25
+ }
26
+ for (const repo of repos) {
27
+ const specDir = path.join(root, repo, 'test', 'spec')
28
+ let files = []
29
+ try {
30
+ files = fs.readdirSync(specDir).filter((f) => f.endsWith('.tsv'))
31
+ } catch (e) {
32
+ continue
33
+ }
34
+ for (const f of files) {
35
+ const lines = fs.readFileSync(path.join(specDir, f), 'utf8').split('\n')
36
+ for (let i = 1; i < lines.length; i++) {
37
+ const cols = lines[i].split('\t')
38
+ if (cols.length < 2) continue
39
+ const m = /^ERROR:([a-z_0-9]+)/.exec(cols[1] || '')
40
+ if (m) {
41
+ fixtures.push({
42
+ languageId: repo,
43
+ file: f,
44
+ row: i + 1,
45
+ input: unescapeTSV(cols[0]),
46
+ expect: { codes: [m[1]] },
47
+ })
48
+ }
49
+ }
50
+ }
51
+ }
52
+ return fixtures
53
+ }
54
+
55
+ function main() {
56
+ // Fleet root default matches gen-registry.js: two levels above the
57
+ // ts/ package (ts/tools -> ts -> lsp -> fleet).
58
+ const root = process.argv[2] || path.join(__dirname, '..', '..', '..')
59
+ const fixtures = collect(root)
60
+
61
+ // Same refusal shape as gen-registry: a near-empty result means the
62
+ // fleet is not where we looked (a partial checkout sees only the
63
+ // engine's own spec dir), not that the corpus shrank. Overwriting
64
+ // the committed 262-fixture corpus with that would silently gut the
65
+ // cross-runtime parity contract. A real fleet checkout spans many
66
+ // grammar repos, so require at least three before writing.
67
+ const languages = new Set(fixtures.map((f) => f.languageId))
68
+ if (languages.size < 3) {
69
+ console.error(
70
+ 'gen-fixtures: only ' + languages.size + ' repo(s) with ERROR rows ' +
71
+ 'under ' + root + ' — this looks like a partial fleet checkout.\n' +
72
+ 'Pass the fleet root explicitly: node tools/gen-fixtures.js <root>\n' +
73
+ 'Refusing to overwrite data/diagnostic-fixtures.json.',
74
+ )
75
+ process.exitCode = 1
76
+ return
77
+ }
78
+
79
+ const out = {
80
+ generated: 'tools/gen-fixtures.js',
81
+ count: fixtures.length,
82
+ fixtures,
83
+ }
84
+ const file = path.join(__dirname, '..', 'data', 'diagnostic-fixtures.json')
85
+ fs.writeFileSync(file, JSON.stringify(out, null, 1) + '\n')
86
+ console.log('wrote ' + file + ' (' + fixtures.length + ' fixtures)')
87
+ }
88
+
89
+ if (require.main === module) main()
90
+ module.exports = { collect }