@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/registry.js
ADDED
|
@@ -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 }
|