@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/generate.js
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
/* Copyright (c) 2026 Richard Rodger, MIT License */
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
// The language-server generator (design §7): given ONE grammar — a
|
|
5
|
+
// fleet registry entry, a plugin module, a serialized GrammarSpec, or
|
|
6
|
+
// BNF-dialect text — emit a standalone single-language server plus the
|
|
7
|
+
// editor plugins needed to use it. The server runtime follows where
|
|
8
|
+
// the grammar can execute:
|
|
9
|
+
//
|
|
10
|
+
// input \ runtime | node | go
|
|
11
|
+
// ----------------+---------------------------+--------------------------
|
|
12
|
+
// entry | static reg. over this pkg| pure-data: embed spec
|
|
13
|
+
// module (npm) | package + module dep | ✗ (pass --go-plugin)
|
|
14
|
+
// spec (JSON) | embed + L2 load | go:embed, engine-only dep
|
|
15
|
+
// grammar (BNF) | compile at server start | pre-compile, then embed
|
|
16
|
+
//
|
|
17
|
+
// Emitted wrappers contain no pipeline logic: they pin WHAT is served,
|
|
18
|
+
// not HOW — a fix in this package reaches every generated Node server
|
|
19
|
+
// on update, and the Go module on rebuild.
|
|
20
|
+
//
|
|
21
|
+
// `--unified` instead generates the multi-language editor plugins for
|
|
22
|
+
// the whole bundled registry over `tabnas-lsp` itself (this is how the
|
|
23
|
+
// repo's own VS Code extension is built rather than hand-maintained).
|
|
24
|
+
|
|
25
|
+
const fs = require('fs')
|
|
26
|
+
const path = require('path')
|
|
27
|
+
|
|
28
|
+
const { loadBundled, normalize } = require('./registry')
|
|
29
|
+
const { firewallSpec, compileGrammarText, LoadError, DIALECTS } = require('./loaders')
|
|
30
|
+
|
|
31
|
+
const ALL_EDITORS = ['vscode', 'nvim', 'emacs', 'sublime', 'helix', 'kate', 'zed']
|
|
32
|
+
|
|
33
|
+
// Manifest of generator-owned files, written into every output: it is
|
|
34
|
+
// what lets a REgeneration delete files the previous run produced that
|
|
35
|
+
// this run did not (a removed language's Zed dir, a dropped editor) —
|
|
36
|
+
// overwrite-only regeneration leaves stale artifacts that still ship.
|
|
37
|
+
const MANIFEST = '.tabnas-lsp-gen.json'
|
|
38
|
+
|
|
39
|
+
class GenerateError extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message)
|
|
42
|
+
this.name = 'GenerateError'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// An exported Go identifier from a language id: ids commonly contain
|
|
47
|
+
// hyphens ('foo-lang'), which a naive capitalize turns into invalid
|
|
48
|
+
// source ('Foo-lang').
|
|
49
|
+
function goIdent(id) {
|
|
50
|
+
const ident = String(id).split(/[^A-Za-z0-9]+/)
|
|
51
|
+
.filter((s) => 0 < s.length)
|
|
52
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
53
|
+
.join('')
|
|
54
|
+
return /^[A-Za-z]/.test(ident) ? ident : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The exact installed version of a dependency, so generated servers
|
|
58
|
+
// freeze what they were generated against; `fallback` when the package
|
|
59
|
+
// is not resolvable at generation time. Two probes: the package.json
|
|
60
|
+
// subpath (blocked by an `exports` map that does not list it — the
|
|
61
|
+
// engine's does not), then the fleet's exported VERSION const, which
|
|
62
|
+
// every tabnas package carries and version-tests against package.json.
|
|
63
|
+
function depVersion(req, name, fallback) {
|
|
64
|
+
try {
|
|
65
|
+
const pkg = req(name + '/package.json')
|
|
66
|
+
if (pkg && 'string' === typeof pkg.version) return pkg.version
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// exports-mapped or not installed — try the VERSION const
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const mod = req(name)
|
|
72
|
+
if (mod && 'string' === typeof mod.VERSION) return mod.VERSION
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// not installed here — the fallback range documents the intent
|
|
75
|
+
}
|
|
76
|
+
return fallback
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// generate(opts) -> { files: [relpath...], out }
|
|
80
|
+
//
|
|
81
|
+
// opts:
|
|
82
|
+
// out target directory (created; must be empty or absent
|
|
83
|
+
// unless force)
|
|
84
|
+
// input { entry } | { module } | { spec } | { grammar }
|
|
85
|
+
// (file paths for spec/grammar)
|
|
86
|
+
// languageId served language id (defaulted from the input where
|
|
87
|
+
// derivable)
|
|
88
|
+
// extensions ['.x', ...]
|
|
89
|
+
// runtime 'node' | 'go' (default 'node')
|
|
90
|
+
// editors subset of vscode,nvim,emacs,sublime,helix,kate,zed;
|
|
91
|
+
// [] for none (default: all)
|
|
92
|
+
// unified editor plugins for the whole bundled registry
|
|
93
|
+
// goModule module path for the generated go.mod
|
|
94
|
+
// goPlugin Go import path of the plugin package (closure/
|
|
95
|
+
// imperative grammars)
|
|
96
|
+
// goPluginFunc exported plugin func name (default: CamelCase id)
|
|
97
|
+
// goReplace ['mod=../dir', ...] replace directives for local dev
|
|
98
|
+
// registryFile bundled registry override (tests)
|
|
99
|
+
// requireFn module resolver override (tests)
|
|
100
|
+
function generate(opts) {
|
|
101
|
+
const out = opts.out
|
|
102
|
+
if (!out) throw new GenerateError('out directory required')
|
|
103
|
+
const runtime = opts.runtime || 'node'
|
|
104
|
+
if ('node' !== runtime && 'go' !== runtime) {
|
|
105
|
+
throw new GenerateError(
|
|
106
|
+
"unknown runtime '" + runtime + "': node and go exist today; other " +
|
|
107
|
+
'engine ports arrive via the pure-GrammarSpec lane and the C ABI ' +
|
|
108
|
+
'(design §7.4)')
|
|
109
|
+
}
|
|
110
|
+
const editors = null == opts.editors ? ALL_EDITORS : opts.editors
|
|
111
|
+
for (const e of editors) {
|
|
112
|
+
if (!ALL_EDITORS.includes(e)) {
|
|
113
|
+
throw new GenerateError(
|
|
114
|
+
"unknown editor '" + e + "' (known: " + ALL_EDITORS.join(', ') + ')')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const files = new Map() // relpath -> content
|
|
119
|
+
|
|
120
|
+
if (opts.unified) {
|
|
121
|
+
generateUnified(opts, editors, files)
|
|
122
|
+
} else {
|
|
123
|
+
const lang = resolveInput(opts)
|
|
124
|
+
if ('node' === runtime) emitNodeServer(lang, files, opts.requireFn || require)
|
|
125
|
+
else emitGoServer(lang, opts, files)
|
|
126
|
+
// Editors launch servers from their own working directory, so the
|
|
127
|
+
// command is the PATH name for both runtimes — the README says how
|
|
128
|
+
// to put the built binary there; vscode's serverPath setting takes
|
|
129
|
+
// an absolute path override.
|
|
130
|
+
const bin = lang.id + '-lsp'
|
|
131
|
+
emitEditors(editors, [lang], bin, files, 'editors/')
|
|
132
|
+
files.set('README.md', readmeSingle(lang, runtime, editors))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
writeFiles(out, files)
|
|
136
|
+
return { files: [...files.keys()].sort(), out }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------
|
|
140
|
+
// Input resolution: everything becomes { id, extensions, kind, ... }.
|
|
141
|
+
|
|
142
|
+
function resolveInput(opts) {
|
|
143
|
+
const input = opts.input || {}
|
|
144
|
+
const req = opts.requireFn || require
|
|
145
|
+
|
|
146
|
+
if (input.entry) {
|
|
147
|
+
const entries = loadBundled(opts.registryFile)
|
|
148
|
+
const entry = entries.find((e) => e.languageId === input.entry)
|
|
149
|
+
if (!entry) {
|
|
150
|
+
throw new GenerateError(
|
|
151
|
+
"no bundled registry entry for '" + input.entry + "' (known: " +
|
|
152
|
+
entries.map((e) => e.languageId).join(', ') + ')')
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
id: entry.languageId,
|
|
156
|
+
extensions: opts.extensions || entry.extensions,
|
|
157
|
+
kind: 'entry',
|
|
158
|
+
entry,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (input.module) {
|
|
163
|
+
const id = opts.languageId ||
|
|
164
|
+
String(input.module).replace(/^@[^/]+\//, '').replace(/[^\w-]/g, '')
|
|
165
|
+
return {
|
|
166
|
+
id,
|
|
167
|
+
extensions: needExtensions(opts, id),
|
|
168
|
+
kind: 'module',
|
|
169
|
+
module: input.module,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (input.spec) {
|
|
174
|
+
const specText = fs.readFileSync(input.spec, 'utf8')
|
|
175
|
+
const spec = JSON.parse(specText)
|
|
176
|
+
// Generation-time firewall: a generator that emits a server around
|
|
177
|
+
// a poisoned grammar is just a slower way to load it (design §7.2).
|
|
178
|
+
const parserMod = req('@tabnas/parser')
|
|
179
|
+
const issues = firewallSpec(spec, parserMod)
|
|
180
|
+
if (0 < issues.length) {
|
|
181
|
+
throw new GenerateError(
|
|
182
|
+
'spec failed the firewall:\n ' +
|
|
183
|
+
issues.map((i) => i.path + ': ' + i.message).join('\n '))
|
|
184
|
+
}
|
|
185
|
+
const id = opts.languageId ||
|
|
186
|
+
path.basename(input.spec).replace(/\.[^.]*$/, '').replace(/[^\w-]/g, '')
|
|
187
|
+
return {
|
|
188
|
+
id,
|
|
189
|
+
extensions: needExtensions(opts, id),
|
|
190
|
+
kind: 'spec',
|
|
191
|
+
specText: JSON.stringify(spec, null, 1) + '\n',
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (input.grammar) {
|
|
196
|
+
const ext = path.extname(input.grammar).toLowerCase()
|
|
197
|
+
if (!DIALECTS[ext]) {
|
|
198
|
+
throw new GenerateError(
|
|
199
|
+
"unknown grammar dialect '" + ext + "' (known: " +
|
|
200
|
+
Object.keys(DIALECTS).join(', ') + ')')
|
|
201
|
+
}
|
|
202
|
+
const id = opts.languageId ||
|
|
203
|
+
path.basename(input.grammar).replace(/\.[^.]*$/, '').replace(/[^\w-]/g, '')
|
|
204
|
+
return {
|
|
205
|
+
id,
|
|
206
|
+
extensions: needExtensions(opts, id),
|
|
207
|
+
kind: 'grammar',
|
|
208
|
+
grammarFile: input.grammar,
|
|
209
|
+
grammarText: fs.readFileSync(input.grammar, 'utf8'),
|
|
210
|
+
dialect: ext.slice(1),
|
|
211
|
+
dialectPkg: DIALECTS[ext],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
throw new GenerateError(
|
|
216
|
+
'input required: one of { entry }, { module }, { spec }, { grammar }')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function needExtensions(opts, id) {
|
|
220
|
+
if (opts.extensions && 0 < opts.extensions.length) return opts.extensions
|
|
221
|
+
return ['.' + id]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------
|
|
225
|
+
// Node server package.
|
|
226
|
+
|
|
227
|
+
function emitNodeServer(lang, files, req) {
|
|
228
|
+
// Exact versions where resolvable: a generated server freezes
|
|
229
|
+
// grammar + engine behavior (design §7.1), and a range lets a later
|
|
230
|
+
// npm install move it without regeneration.
|
|
231
|
+
const deps = {
|
|
232
|
+
'@tabnas/lsp': depVersion(req, '@tabnas/lsp',
|
|
233
|
+
require('../package.json').version),
|
|
234
|
+
// Fleet convention (admin/publish.sh): every @tabnas peer/floor is
|
|
235
|
+
// ">=0" so installs resolve the latest published engine. Only an
|
|
236
|
+
// exact version we RESOLVED is worth pinning; a typed floor is a
|
|
237
|
+
// guess, and ">=0.9.0" was one that no published parser satisfied.
|
|
238
|
+
'@tabnas/parser': depVersion(req, '@tabnas/parser', '>=0'),
|
|
239
|
+
}
|
|
240
|
+
const entry = {
|
|
241
|
+
name: lang.id,
|
|
242
|
+
languageId: lang.id,
|
|
243
|
+
extensions: lang.extensions,
|
|
244
|
+
enabled: true,
|
|
245
|
+
}
|
|
246
|
+
let dataFile = null
|
|
247
|
+
|
|
248
|
+
if ('entry' === lang.kind) {
|
|
249
|
+
Object.assign(entry, {
|
|
250
|
+
name: lang.entry.name,
|
|
251
|
+
base: lang.entry.base,
|
|
252
|
+
pluginKind: lang.entry.pluginKind,
|
|
253
|
+
grammarKind: lang.entry.grammarKind,
|
|
254
|
+
lexStream: lang.entry.lexStream,
|
|
255
|
+
syncGroups: lang.entry.syncGroups,
|
|
256
|
+
semanticTokens: lang.entry.semanticTokens,
|
|
257
|
+
// A branded server serves its language by construction — the
|
|
258
|
+
// registry's editor-collision default applies to the UNIFIED
|
|
259
|
+
// server, not to a server someone generated for exactly this
|
|
260
|
+
// language.
|
|
261
|
+
enabled: true,
|
|
262
|
+
load: lang.entry.load,
|
|
263
|
+
options: lang.entry.options,
|
|
264
|
+
stack: lang.entry.stack,
|
|
265
|
+
})
|
|
266
|
+
deps[lang.entry.name] = depVersion(req, lang.entry.name, '*')
|
|
267
|
+
if (lang.entry.base && 'grammar' === lang.entry.pluginKind) {
|
|
268
|
+
deps[lang.entry.base] = depVersion(req, lang.entry.base, '*')
|
|
269
|
+
}
|
|
270
|
+
} else if ('module' === lang.kind) {
|
|
271
|
+
entry.name = lang.module
|
|
272
|
+
entry.load = { module: lang.module }
|
|
273
|
+
deps[lang.module] = depVersion(req, lang.module, '*')
|
|
274
|
+
} else if ('spec' === lang.kind) {
|
|
275
|
+
entry.grammarKind = 'data'
|
|
276
|
+
entry.load = { spec: 'grammar.json' }
|
|
277
|
+
dataFile = ['server/grammar.json', lang.specText]
|
|
278
|
+
} else if ('grammar' === lang.kind) {
|
|
279
|
+
entry.grammarKind = 'compiled'
|
|
280
|
+
entry.load = { grammar: path.basename(lang.grammarFile) }
|
|
281
|
+
deps[lang.dialectPkg] = depVersion(req, lang.dialectPkg, '*')
|
|
282
|
+
dataFile = ['server/' + path.basename(lang.grammarFile), lang.grammarText]
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
files.set('server/package.json', JSON.stringify({
|
|
286
|
+
name: lang.id + '-lsp',
|
|
287
|
+
version: '0.1.0',
|
|
288
|
+
description: 'Language server for ' + lang.id + ' (generated by tabnas-lsp-gen)',
|
|
289
|
+
license: 'MIT',
|
|
290
|
+
bin: { [lang.id + '-lsp']: 'server.js' },
|
|
291
|
+
dependencies: deps,
|
|
292
|
+
}, null, 2) + '\n')
|
|
293
|
+
|
|
294
|
+
files.set('server/server.js', [
|
|
295
|
+
'#!/usr/bin/env node',
|
|
296
|
+
"/* Generated by tabnas-lsp-gen. The pipeline lives in @tabnas/lsp;",
|
|
297
|
+
' * this wrapper only pins what is served. Regenerate rather than',
|
|
298
|
+
' * grow it. */',
|
|
299
|
+
"'use strict'",
|
|
300
|
+
'',
|
|
301
|
+
"const { startServer } = require('@tabnas/lsp/src/server')",
|
|
302
|
+
'',
|
|
303
|
+
'const entry = ' + JSON.stringify(entry, null, 2),
|
|
304
|
+
'',
|
|
305
|
+
'// Grammar files resolve against this package, sandboxed.',
|
|
306
|
+
'entry._dir = __dirname',
|
|
307
|
+
'',
|
|
308
|
+
'startServer({ entries: [entry] })',
|
|
309
|
+
'',
|
|
310
|
+
].join('\n'))
|
|
311
|
+
|
|
312
|
+
if (dataFile) files.set(dataFile[0], dataFile[1])
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------
|
|
316
|
+
// Go server module.
|
|
317
|
+
|
|
318
|
+
// Registry metadata that must survive into the generated Go Entry:
|
|
319
|
+
// dropping SyncGroups changes where recovery resynchronizes, dropping
|
|
320
|
+
// the semantic-token map or outline rules changes what the editor
|
|
321
|
+
// shows — the generated server would quietly disagree with the
|
|
322
|
+
// canonical TS server about the same grammar.
|
|
323
|
+
function goEntryMeta(entry) {
|
|
324
|
+
const goStringMap = (m) => 'map[string]string{' +
|
|
325
|
+
Object.keys(m).sort().map((k) =>
|
|
326
|
+
JSON.stringify(k) + ': ' + JSON.stringify(m[k])).join(', ') + '}'
|
|
327
|
+
const meta = []
|
|
328
|
+
if (entry.syncGroups && 0 < entry.syncGroups.length) {
|
|
329
|
+
meta.push(['SyncGroups', '[]string{' +
|
|
330
|
+
entry.syncGroups.map((s) => JSON.stringify(s)).join(', ') + '}'])
|
|
331
|
+
}
|
|
332
|
+
if (entry.lexStream && 'clean' !== entry.lexStream) {
|
|
333
|
+
meta.push(['LexStream', JSON.stringify(entry.lexStream)])
|
|
334
|
+
}
|
|
335
|
+
if (entry.semanticTokens && 0 < Object.keys(entry.semanticTokens).length) {
|
|
336
|
+
meta.push(['SemanticTokens', goStringMap(entry.semanticTokens)])
|
|
337
|
+
}
|
|
338
|
+
if (entry.outlineRules && 0 < Object.keys(entry.outlineRules).length) {
|
|
339
|
+
meta.push(['OutlineRules', goStringMap(entry.outlineRules)])
|
|
340
|
+
}
|
|
341
|
+
return meta
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function emitGoServer(lang, opts, files) {
|
|
345
|
+
if ('module' === lang.kind) {
|
|
346
|
+
throw new GenerateError(
|
|
347
|
+
'a TypeScript plugin module cannot run in a Go server. For a ' +
|
|
348
|
+
'grammar that lives in Go, pass --go-plugin <import-path>; for a ' +
|
|
349
|
+
'pure-data grammar, pass --spec.')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const goModule = opts.goModule || 'example.com/' + lang.id + '-lsp'
|
|
353
|
+
|
|
354
|
+
// NO hardcoded requires. A version written here is a guess about what
|
|
355
|
+
// is published, and a guess that is wrong makes the generated module
|
|
356
|
+
// unbuildable: `go mod tidy` fails outright on a require the proxy
|
|
357
|
+
// 404s. This generator emitted `github.com/tabnas/lsp/go v0.1.0` and
|
|
358
|
+
// `github.com/tabnas/parser/go v0.9.0`, and NEITHER has ever been
|
|
359
|
+
// published — every generated Go server was dead on arrival.
|
|
360
|
+
//
|
|
361
|
+
// `go mod tidy` resolves both from the imports in main.go, which is
|
|
362
|
+
// the only shape that self-heals as the fleet releases. The same rule
|
|
363
|
+
// already governs the plugin module below; it now governs all three.
|
|
364
|
+
// A version the CALLER supplies is not a guess, so it is pinned. This
|
|
365
|
+
// is how a generated module becomes reproducible: with nothing pinned,
|
|
366
|
+
// `go mod tidy` resolves from whatever the proxy serves at the moment
|
|
367
|
+
// it runs, so the same generator output can build against a different
|
|
368
|
+
// engine next month and quietly parse differently — which is the
|
|
369
|
+
// opposite of what design.md §7.1 promises. Omitting the requires is
|
|
370
|
+
// still the DEFAULT, because the alternative this code shipped with
|
|
371
|
+
// was a guessed version that 404'd and made every generated server
|
|
372
|
+
// unbuildable. Pinned when known, floating when not, and the emitted
|
|
373
|
+
// comment says which of the two happened.
|
|
374
|
+
const pins = [
|
|
375
|
+
[opts.goPlugin, opts.goPluginVersion],
|
|
376
|
+
['github.com/tabnas/lsp/go', opts.goLspVersion],
|
|
377
|
+
['github.com/tabnas/parser/go', opts.goParserVersion],
|
|
378
|
+
].filter(([mod, ver]) => mod && ver)
|
|
379
|
+
|
|
380
|
+
const requires = []
|
|
381
|
+
for (const [mod, ver] of pins) {
|
|
382
|
+
// The import path is preserved exactly, /vN suffixes included.
|
|
383
|
+
requires.push('require ' + mod + ' ' + ver)
|
|
384
|
+
}
|
|
385
|
+
if (0 < pins.length) requires.push('')
|
|
386
|
+
|
|
387
|
+
requires.push(
|
|
388
|
+
0 < pins.length
|
|
389
|
+
? '// The requirements above are pinned; anything else is resolved'
|
|
390
|
+
: '// Requirements are resolved by `go mod tidy` from the imports in',
|
|
391
|
+
0 < pins.length
|
|
392
|
+
? '// by `go mod tidy` from the imports in main.go — run it before'
|
|
393
|
+
: '// main.go — run it before the first build (the README says so).',
|
|
394
|
+
0 < pins.length
|
|
395
|
+
? '// the first build (the README says so).'
|
|
396
|
+
: '// Pass --go-lsp-version/--go-parser-version to pin them instead.',
|
|
397
|
+
'// For unreleased local checkouts, add replace directives (or pass',
|
|
398
|
+
'// --go-replace to the generator):',
|
|
399
|
+
)
|
|
400
|
+
for (const r of opts.goReplace || []) {
|
|
401
|
+
const [mod, dir] = String(r).split('=')
|
|
402
|
+
requires.push('replace ' + mod + ' => ' + dir)
|
|
403
|
+
}
|
|
404
|
+
if (0 === (opts.goReplace || []).length) {
|
|
405
|
+
requires.push('// replace github.com/tabnas/lsp/go => ../lsp/go')
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
files.set('server/go.mod', [
|
|
409
|
+
'module ' + goModule,
|
|
410
|
+
'',
|
|
411
|
+
'go 1.24',
|
|
412
|
+
'',
|
|
413
|
+
...requires,
|
|
414
|
+
'',
|
|
415
|
+
].join('\n'))
|
|
416
|
+
|
|
417
|
+
const metaLines = lang.entry ? goEntryMeta(lang.entry) : []
|
|
418
|
+
|
|
419
|
+
let spec = null
|
|
420
|
+
if ('spec' === lang.kind) {
|
|
421
|
+
spec = lang.specText
|
|
422
|
+
} else if ('grammar' === lang.kind) {
|
|
423
|
+
// Pre-compile BNF to a pure spec at generation time (the C-ABI
|
|
424
|
+
// precedent: pre-compile -> L2), so the Go module depends only on
|
|
425
|
+
// the engine.
|
|
426
|
+
const req = opts.requireFn || require
|
|
427
|
+
const compiled = compileGrammarText(req, lang.grammarFile, lang.grammarText)
|
|
428
|
+
const parserMod = req('@tabnas/parser')
|
|
429
|
+
const issues = firewallSpec(compiled, parserMod)
|
|
430
|
+
if (0 < issues.length) {
|
|
431
|
+
throw new GenerateError(
|
|
432
|
+
'compiled grammar failed the firewall:\n ' +
|
|
433
|
+
issues.map((i) => i.path + ': ' + i.message).join('\n '))
|
|
434
|
+
}
|
|
435
|
+
spec = JSON.stringify(compiled, null, 1) + '\n'
|
|
436
|
+
} else if ('entry' === lang.kind) {
|
|
437
|
+
if (opts.goPlugin) {
|
|
438
|
+
spec = null // plugin-linked below
|
|
439
|
+
} else if ('data' === lang.entry.grammarKind && lang.entry.load &&
|
|
440
|
+
lang.entry.load.spec && 'string' !== typeof lang.entry.load.spec) {
|
|
441
|
+
spec = JSON.stringify(lang.entry.load.spec, null, 1) + '\n'
|
|
442
|
+
} else {
|
|
443
|
+
throw new GenerateError(
|
|
444
|
+
"entry '" + lang.id + "' is grammarKind: " + lang.entry.grammarKind +
|
|
445
|
+
' — its grammar is live code, which a Go server cannot load ' +
|
|
446
|
+
'from npm. Pass --go-plugin <import-path> naming its Go twin, ' +
|
|
447
|
+
'or --spec with a pure-data grammar.')
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (null != spec) {
|
|
452
|
+
files.set('server/grammar.json', spec)
|
|
453
|
+
files.set('server/main.go', [
|
|
454
|
+
'// Generated by tabnas-lsp-gen. The pipeline lives in',
|
|
455
|
+
'// github.com/tabnas/lsp/go; this wrapper only pins what is served.',
|
|
456
|
+
'package main',
|
|
457
|
+
'',
|
|
458
|
+
'import (',
|
|
459
|
+
'\t_ "embed"',
|
|
460
|
+
'\t"log"',
|
|
461
|
+
'',
|
|
462
|
+
'\tlsp "github.com/tabnas/lsp/go"',
|
|
463
|
+
')',
|
|
464
|
+
'',
|
|
465
|
+
'//go:embed grammar.json',
|
|
466
|
+
'var grammarJSON []byte',
|
|
467
|
+
'',
|
|
468
|
+
'func main() {',
|
|
469
|
+
'\tentry, makeInstance := lsp.EntryFromSpecJSON(',
|
|
470
|
+
'\t\t' + JSON.stringify(lang.id) + ',',
|
|
471
|
+
'\t\t[]string{' + lang.extensions.map((e) => JSON.stringify(e)).join(', ') + '},',
|
|
472
|
+
'\t\tgrammarJSON,',
|
|
473
|
+
'\t)',
|
|
474
|
+
...metaLines.map(([f, v]) => '\tentry.' + f + ' = ' + v),
|
|
475
|
+
'\tif err := lsp.Serve(lsp.Config{',
|
|
476
|
+
'\t\tEntries: []*lsp.Entry{entry},',
|
|
477
|
+
'\t\tMakeInstance: makeInstance,',
|
|
478
|
+
'\t}); err != nil {',
|
|
479
|
+
'\t\tlog.Fatal(err)',
|
|
480
|
+
'\t}',
|
|
481
|
+
'}',
|
|
482
|
+
'',
|
|
483
|
+
].join('\n'))
|
|
484
|
+
} else {
|
|
485
|
+
const fn = opts.goPluginFunc || goIdent(lang.id)
|
|
486
|
+
if (null == fn) {
|
|
487
|
+
throw new GenerateError(
|
|
488
|
+
"cannot derive a Go plugin function name from '" + lang.id +
|
|
489
|
+
"' — pass --go-plugin-func")
|
|
490
|
+
}
|
|
491
|
+
files.set('server/main.go', [
|
|
492
|
+
'// Generated by tabnas-lsp-gen. The pipeline lives in',
|
|
493
|
+
'// github.com/tabnas/lsp/go; this wrapper only pins what is served.',
|
|
494
|
+
'package main',
|
|
495
|
+
'',
|
|
496
|
+
'import (',
|
|
497
|
+
'\t"log"',
|
|
498
|
+
'',
|
|
499
|
+
'\tlsp "github.com/tabnas/lsp/go"',
|
|
500
|
+
'\ttabnas "github.com/tabnas/parser/go"',
|
|
501
|
+
'\tplugin ' + JSON.stringify(opts.goPlugin),
|
|
502
|
+
')',
|
|
503
|
+
'',
|
|
504
|
+
'func main() {',
|
|
505
|
+
'\tentry := &lsp.Entry{',
|
|
506
|
+
'\t\tName: ' + JSON.stringify(lang.id) + ',',
|
|
507
|
+
'\t\tLanguageID: ' + JSON.stringify(lang.id) + ',',
|
|
508
|
+
'\t\tExtensions: []string{' + lang.extensions.map((e) => JSON.stringify(e)).join(', ') + '},',
|
|
509
|
+
'\t\tEnabled: true,',
|
|
510
|
+
...metaLines.map(([f, v]) => '\t\t' + f + ': ' + v + ','),
|
|
511
|
+
'\t}',
|
|
512
|
+
'\tmakeInstance := func(e *lsp.Entry) (*tabnas.Tabnas, error) {',
|
|
513
|
+
'\t\ttn := lsp.NewInstance(e)',
|
|
514
|
+
'\t\tif err := tn.Use(plugin.' + fn + '); err != nil {',
|
|
515
|
+
'\t\t\treturn nil, err',
|
|
516
|
+
'\t\t}',
|
|
517
|
+
'\t\treturn tn, nil',
|
|
518
|
+
'\t}',
|
|
519
|
+
'\tif err := lsp.Serve(lsp.Config{',
|
|
520
|
+
'\t\tEntries: []*lsp.Entry{entry},',
|
|
521
|
+
'\t\tMakeInstance: makeInstance,',
|
|
522
|
+
'\t}); err != nil {',
|
|
523
|
+
'\t\tlog.Fatal(err)',
|
|
524
|
+
'\t}',
|
|
525
|
+
'}',
|
|
526
|
+
'',
|
|
527
|
+
].join('\n'))
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------
|
|
532
|
+
// Unified mode: editor plugins for the whole bundled registry over the
|
|
533
|
+
// tabnas-lsp bin itself.
|
|
534
|
+
|
|
535
|
+
function generateUnified(opts, editors, files) {
|
|
536
|
+
const entries = loadBundled(opts.registryFile)
|
|
537
|
+
.map(normalize)
|
|
538
|
+
.filter((e) => e.enabled && 'modifier' !== e.pluginKind &&
|
|
539
|
+
0 < e.extensions.length)
|
|
540
|
+
const langs = entries.map((e) => ({ id: e.languageId, extensions: e.extensions }))
|
|
541
|
+
// No server/ half in unified mode, so the editor dirs sit at the out
|
|
542
|
+
// root (the repo's own editors/ is exactly this output).
|
|
543
|
+
emitEditors(editors, langs, 'tabnas-lsp', files, '')
|
|
544
|
+
files.set('README.md', readmeUnified(langs, editors))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------------
|
|
548
|
+
// Editor plugins. Everything derives from (languages, bin): languages
|
|
549
|
+
// with their extensions, and the server command. `prefix` places the
|
|
550
|
+
// per-editor dirs — 'editors/' beside a server/ half, '' at the out
|
|
551
|
+
// root in unified mode.
|
|
552
|
+
|
|
553
|
+
function emitEditors(editors, langs, bin, files, prefix) {
|
|
554
|
+
const view = {
|
|
555
|
+
set: (rel, content) => files.set(prefix + rel, content),
|
|
556
|
+
}
|
|
557
|
+
for (const editor of editors) {
|
|
558
|
+
EDITOR_EMITTERS[editor](langs, bin, view)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const EDITOR_EMITTERS = {
|
|
563
|
+
vscode(langs, bin, files) {
|
|
564
|
+
const single = 1 === langs.length
|
|
565
|
+
const name = single ? langs[0].id + '-lsp-vscode' : 'tabnas-lsp-vscode'
|
|
566
|
+
// Per-extension setting key: two installed branded extensions must
|
|
567
|
+
// not share (and fight over) one `tabnas.serverPath`; the unified
|
|
568
|
+
// extension keeps the plain key.
|
|
569
|
+
const settingKey = single ? 'tabnas.' + langs[0].id + '.serverPath' : 'tabnas.serverPath'
|
|
570
|
+
files.set('vscode/package.json', JSON.stringify({
|
|
571
|
+
name,
|
|
572
|
+
displayName: single ? langs[0].id + ' (tabnas)' : 'tabnas languages',
|
|
573
|
+
description: (single
|
|
574
|
+
? 'Language support for ' + langs[0].id
|
|
575
|
+
: 'Language support for tabnas grammars') +
|
|
576
|
+
' (generated by tabnas-lsp-gen)',
|
|
577
|
+
version: '0.1.0',
|
|
578
|
+
publisher: 'REPLACE-WITH-YOUR-PUBLISHER',
|
|
579
|
+
license: 'MIT',
|
|
580
|
+
engines: { vscode: '^1.85.0' },
|
|
581
|
+
categories: ['Programming Languages'],
|
|
582
|
+
main: './extension.js',
|
|
583
|
+
activationEvents: langs.map((l) => 'onLanguage:' + l.id),
|
|
584
|
+
contributes: {
|
|
585
|
+
languages: langs.map((l) => ({
|
|
586
|
+
id: l.id,
|
|
587
|
+
extensions: l.extensions,
|
|
588
|
+
aliases: [l.id],
|
|
589
|
+
configuration: './language-configuration.json',
|
|
590
|
+
})),
|
|
591
|
+
configuration: {
|
|
592
|
+
title: single ? langs[0].id : 'tabnas',
|
|
593
|
+
properties: {
|
|
594
|
+
[settingKey]: {
|
|
595
|
+
type: 'string',
|
|
596
|
+
default: bin,
|
|
597
|
+
description: 'Command that starts the language server (--stdio is appended).',
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
dependencies: { 'vscode-languageclient': '^9.0.0' },
|
|
603
|
+
}, null, 2) + '\n')
|
|
604
|
+
|
|
605
|
+
files.set('vscode/extension.js', [
|
|
606
|
+
"/* Generated by tabnas-lsp-gen. */",
|
|
607
|
+
"'use strict'",
|
|
608
|
+
"const vscode = require('vscode')",
|
|
609
|
+
"const { LanguageClient } = require('vscode-languageclient/node')",
|
|
610
|
+
'',
|
|
611
|
+
'let client',
|
|
612
|
+
'',
|
|
613
|
+
'function activate() {',
|
|
614
|
+
" const command = vscode.workspace.getConfiguration('tabnas')",
|
|
615
|
+
' .get(' + JSON.stringify(settingKey.replace(/^tabnas\./, '')) + ') || ' + JSON.stringify(bin),
|
|
616
|
+
' client = new LanguageClient(',
|
|
617
|
+
' ' + JSON.stringify(single ? langs[0].id : 'tabnas') + ',',
|
|
618
|
+
' ' + JSON.stringify((single ? langs[0].id : 'tabnas') + ' language server') + ',',
|
|
619
|
+
" { command, args: ['--stdio'] },",
|
|
620
|
+
' {',
|
|
621
|
+
' documentSelector: [',
|
|
622
|
+
langs.map((l) => " { language: " + JSON.stringify(l.id) + " },").join('\n'),
|
|
623
|
+
' ],',
|
|
624
|
+
' },',
|
|
625
|
+
' )',
|
|
626
|
+
' client.start()',
|
|
627
|
+
'}',
|
|
628
|
+
'',
|
|
629
|
+
'function deactivate() {',
|
|
630
|
+
' return client ? client.stop() : undefined',
|
|
631
|
+
'}',
|
|
632
|
+
'',
|
|
633
|
+
'module.exports = { activate, deactivate }',
|
|
634
|
+
'',
|
|
635
|
+
].join('\n'))
|
|
636
|
+
|
|
637
|
+
// jsonic-family defaults; adjust for the grammar's own comment and
|
|
638
|
+
// bracket forms.
|
|
639
|
+
files.set('vscode/language-configuration.json', JSON.stringify({
|
|
640
|
+
comments: { lineComment: '#', blockComment: ['/*', '*/'] },
|
|
641
|
+
brackets: [['{', '}'], ['[', ']'], ['(', ')']],
|
|
642
|
+
autoClosingPairs: [
|
|
643
|
+
{ open: '{', close: '}' },
|
|
644
|
+
{ open: '[', close: ']' },
|
|
645
|
+
{ open: '(', close: ')' },
|
|
646
|
+
{ open: '"', close: '"', notIn: ['string'] },
|
|
647
|
+
{ open: "'", close: "'", notIn: ['string'] },
|
|
648
|
+
],
|
|
649
|
+
surroundingPairs: [['{', '}'], ['[', ']'], ['(', ')'], ['"', '"'], ["'", "'"]],
|
|
650
|
+
}, null, 2) + '\n')
|
|
651
|
+
|
|
652
|
+
files.set('vscode/.vscodeignore', 'node_modules/**\n')
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
nvim(langs, bin, files) {
|
|
656
|
+
const lines = [
|
|
657
|
+
'-- Generated by tabnas-lsp-gen. Neovim >= 0.11 (vim.lsp.config).',
|
|
658
|
+
'-- For older Neovim, adapt to nvim-lspconfig custom-server setup.',
|
|
659
|
+
'',
|
|
660
|
+
'vim.filetype.add({ extension = {',
|
|
661
|
+
]
|
|
662
|
+
for (const l of langs) {
|
|
663
|
+
for (const x of l.extensions) {
|
|
664
|
+
lines.push(' [' + JSON.stringify(x.replace(/^\./, '')) + '] = ' +
|
|
665
|
+
JSON.stringify(l.id) + ',')
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
lines.push('} })', '')
|
|
669
|
+
const name = 1 === langs.length ? langs[0].id + '_lsp' : 'tabnas_lsp'
|
|
670
|
+
lines.push(
|
|
671
|
+
'vim.lsp.config[' + JSON.stringify(name) + '] = {',
|
|
672
|
+
' cmd = { ' + JSON.stringify(bin) + ", '--stdio' },",
|
|
673
|
+
' filetypes = { ' + langs.map((l) => JSON.stringify(l.id)).join(', ') + ' },',
|
|
674
|
+
" root_markers = { '.git' },",
|
|
675
|
+
'}',
|
|
676
|
+
'vim.lsp.enable(' + JSON.stringify(name) + ')',
|
|
677
|
+
'',
|
|
678
|
+
)
|
|
679
|
+
files.set('nvim/tabnas.lua', lines.join('\n'))
|
|
680
|
+
},
|
|
681
|
+
|
|
682
|
+
emacs(langs, bin, files) {
|
|
683
|
+
const name = 1 === langs.length ? langs[0].id : 'tabnas'
|
|
684
|
+
const lines = [
|
|
685
|
+
';; Generated by tabnas-lsp-gen. Eglot (built into Emacs 29+).',
|
|
686
|
+
'',
|
|
687
|
+
]
|
|
688
|
+
for (const l of langs) {
|
|
689
|
+
const mode = l.id + '-mode'
|
|
690
|
+
lines.push(
|
|
691
|
+
';; A minimal major mode so eglot has something to attach to.',
|
|
692
|
+
'(define-derived-mode ' + mode + ' prog-mode "' + l.id + '")',
|
|
693
|
+
...l.extensions.map((x) =>
|
|
694
|
+
"(add-to-list 'auto-mode-alist '(\"\\\\" + x + "\\\\'\" . " + mode + '))'),
|
|
695
|
+
"(with-eval-after-load 'eglot",
|
|
696
|
+
" (add-to-list 'eglot-server-programs",
|
|
697
|
+
" '(" + mode + ' . ("' + bin + '" "--stdio"))))',
|
|
698
|
+
'',
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
files.set('emacs/' + name + '-lsp.el', lines.join('\n'))
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
sublime(langs, bin, files) {
|
|
705
|
+
const clients = {}
|
|
706
|
+
for (const l of langs) {
|
|
707
|
+
clients[l.id + '-lsp'] = {
|
|
708
|
+
enabled: true,
|
|
709
|
+
command: [bin, '--stdio'],
|
|
710
|
+
// Sublime selects by syntax scope; without a dedicated syntax
|
|
711
|
+
// definition, scope by file extension via the selector below
|
|
712
|
+
// needs a syntax that claims these extensions. See README.
|
|
713
|
+
selector: 'source.' + l.id,
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
files.set('sublime/LSP.sublime-settings', JSON.stringify(
|
|
717
|
+
{ clients }, null, 2) + '\n')
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
helix(langs, bin, files) {
|
|
721
|
+
const name = 1 === langs.length ? langs[0].id + '-lsp' : 'tabnas-lsp'
|
|
722
|
+
const lines = [
|
|
723
|
+
'# Generated by tabnas-lsp-gen. Merge into ~/.config/helix/languages.toml',
|
|
724
|
+
'',
|
|
725
|
+
'[language-server.' + name + ']',
|
|
726
|
+
'command = ' + JSON.stringify(bin),
|
|
727
|
+
'args = ["--stdio"]',
|
|
728
|
+
'',
|
|
729
|
+
]
|
|
730
|
+
for (const l of langs) {
|
|
731
|
+
lines.push(
|
|
732
|
+
'[[language]]',
|
|
733
|
+
'name = ' + JSON.stringify(l.id),
|
|
734
|
+
'scope = ' + JSON.stringify('source.' + l.id),
|
|
735
|
+
'file-types = [' + l.extensions.map((x) =>
|
|
736
|
+
JSON.stringify(x.replace(/^\./, ''))).join(', ') + ']',
|
|
737
|
+
'language-servers = [' + JSON.stringify(name) + ']',
|
|
738
|
+
'',
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
files.set('helix/languages.toml', lines.join('\n'))
|
|
742
|
+
},
|
|
743
|
+
|
|
744
|
+
kate(langs, bin, files) {
|
|
745
|
+
const servers = {}
|
|
746
|
+
for (const l of langs) {
|
|
747
|
+
servers[l.id] = {
|
|
748
|
+
command: [bin, '--stdio'],
|
|
749
|
+
rootIndicationFileNames: ['.git'],
|
|
750
|
+
highlightingModeRegex: '^' + l.id + '$',
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
files.set('kate/lspclient-settings.json', JSON.stringify(
|
|
754
|
+
{ servers }, null, 2) + '\n')
|
|
755
|
+
},
|
|
756
|
+
|
|
757
|
+
zed(langs, bin, files) {
|
|
758
|
+
// Zed language extensions are declarative for the language itself,
|
|
759
|
+
// but binding a language server needs the extension's (small) Rust
|
|
760
|
+
// shim — scaffold the declarative half and document the rest.
|
|
761
|
+
const name = 1 === langs.length ? langs[0].id : 'tabnas'
|
|
762
|
+
files.set('zed/extension.toml', [
|
|
763
|
+
'# Generated by tabnas-lsp-gen — scaffold. Binding the language',
|
|
764
|
+
'# server requires the extension Rust shim; see README.md.',
|
|
765
|
+
'id = ' + JSON.stringify(name + '-lsp'),
|
|
766
|
+
'name = ' + JSON.stringify(name + ' (tabnas)'),
|
|
767
|
+
'version = "0.1.0"',
|
|
768
|
+
'schema_version = 1',
|
|
769
|
+
'',
|
|
770
|
+
'[language_servers.' + name + '-lsp]',
|
|
771
|
+
'name = ' + JSON.stringify(name + ' LSP'),
|
|
772
|
+
'languages = [' + langs.map((l) => JSON.stringify(l.id)).join(', ') + ']',
|
|
773
|
+
'',
|
|
774
|
+
].join('\n'))
|
|
775
|
+
for (const l of langs) {
|
|
776
|
+
files.set('zed/languages/' + l.id + '/config.toml', [
|
|
777
|
+
'name = ' + JSON.stringify(l.id),
|
|
778
|
+
'grammar = ' + JSON.stringify(l.id),
|
|
779
|
+
'path_suffixes = [' + l.extensions.map((x) =>
|
|
780
|
+
JSON.stringify(x.replace(/^\./, ''))).join(', ') + ']',
|
|
781
|
+
'',
|
|
782
|
+
].join('\n'))
|
|
783
|
+
}
|
|
784
|
+
files.set('zed/README.md', [
|
|
785
|
+
'# Zed extension scaffold',
|
|
786
|
+
'',
|
|
787
|
+
'Zed language extensions declare languages in TOML but bind',
|
|
788
|
+
'language servers through a small Rust shim (`src/lib.rs`',
|
|
789
|
+
'implementing `zed_extension_api`), and highlighting needs a',
|
|
790
|
+
'tree-sitter grammar reference. This scaffold carries the',
|
|
791
|
+
'declarative half; wire the shim to launch `' + bin + ' --stdio`.',
|
|
792
|
+
'See https://zed.dev/docs/extensions/languages',
|
|
793
|
+
'',
|
|
794
|
+
].join('\n'))
|
|
795
|
+
},
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---------------------------------------------------------------------
|
|
799
|
+
// READMEs.
|
|
800
|
+
|
|
801
|
+
function editorList(editors, prefix) {
|
|
802
|
+
return editors.map((e) => '- `' + (prefix || '') + e + '/`').join('\n')
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function readmeSingle(lang, runtime, editors) {
|
|
806
|
+
const build = 'node' === runtime
|
|
807
|
+
? ['```', 'cd server && npm install', 'npx ' + lang.id + '-lsp --stdio', '```',
|
|
808
|
+
'',
|
|
809
|
+
'Installing the package (`npm install -g ./server`, or publishing',
|
|
810
|
+
'it) puts the `' + lang.id + '-lsp` command on PATH, which is what',
|
|
811
|
+
'the generated editor configurations launch.']
|
|
812
|
+
: ['```', 'cd server && go mod tidy && go build -o ' + lang.id + '-lsp .', '```',
|
|
813
|
+
'',
|
|
814
|
+
'Put the built binary on PATH (`go install .` with GOBIN on PATH,',
|
|
815
|
+
'or copy it into a PATH directory): the generated editor',
|
|
816
|
+
'configurations launch `' + lang.id + '-lsp` by name — editors do',
|
|
817
|
+
'not run servers from the build directory. The VS Code setting',
|
|
818
|
+
'`tabnas.' + lang.id + '.serverPath` accepts an absolute path',
|
|
819
|
+
'instead.']
|
|
820
|
+
return [
|
|
821
|
+
'# ' + lang.id + ' language server',
|
|
822
|
+
'',
|
|
823
|
+
'Generated by `tabnas-lsp-gen` (from `@tabnas/lsp`). The server is a',
|
|
824
|
+
'thin wrapper over the tabnas LSP pipeline: diagnostics with',
|
|
825
|
+
'multi-error recovery, completion, semantic tokens, and outline are',
|
|
826
|
+
'derived from the grammar itself — regenerate rather than edit.',
|
|
827
|
+
'',
|
|
828
|
+
'## Server (' + runtime + ')',
|
|
829
|
+
'',
|
|
830
|
+
...build,
|
|
831
|
+
'',
|
|
832
|
+
'The server speaks LSP over stdio.',
|
|
833
|
+
'',
|
|
834
|
+
'## Editors',
|
|
835
|
+
'',
|
|
836
|
+
editorList(editors, 'editors/'),
|
|
837
|
+
'',
|
|
838
|
+
'Each directory contains the plugin or configuration fragment for',
|
|
839
|
+
'that editor, wired to launch the server above. VS Code: `cd',
|
|
840
|
+
'editors/vscode && npm install`, then package with `vsce` or run via',
|
|
841
|
+
'the Extension Development Host. Sublime needs a syntax definition',
|
|
842
|
+
'claiming the file extensions for its selector to match.',
|
|
843
|
+
'',
|
|
844
|
+
].join('\n')
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function readmeUnified(langs, editors) {
|
|
848
|
+
return [
|
|
849
|
+
'# tabnas unified language server — editor plugins',
|
|
850
|
+
'',
|
|
851
|
+
'Generated by `tabnas-lsp-gen --unified` from the bundled registry.',
|
|
852
|
+
'The server is `tabnas-lsp --stdio` (from `@tabnas/lsp`); these',
|
|
853
|
+
'plugins register it for every enabled registry language:',
|
|
854
|
+
'',
|
|
855
|
+
langs.map((l) => '- `' + l.id + '` (' + l.extensions.join(', ') + ')').join('\n'),
|
|
856
|
+
'',
|
|
857
|
+
'Languages with entrenched incumbent support (json, yaml, css, …)',
|
|
858
|
+
'are default-off in the registry and deliberately absent here —',
|
|
859
|
+
'enable them per workspace instead (design §5, collision policy).',
|
|
860
|
+
'',
|
|
861
|
+
'## Editors',
|
|
862
|
+
'',
|
|
863
|
+
editorList(editors),
|
|
864
|
+
'',
|
|
865
|
+
].join('\n')
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ---------------------------------------------------------------------
|
|
869
|
+
|
|
870
|
+
function writeFiles(out, files) {
|
|
871
|
+
// The manifest lists this run's files (itself excluded, sorted, no
|
|
872
|
+
// timestamps — regeneration must be byte-stable for the staleness
|
|
873
|
+
// gates). It is read back on the NEXT run to delete generator-owned
|
|
874
|
+
// files that run no longer produces; only manifested files are ever
|
|
875
|
+
// deleted, so user files beside the output are never touched.
|
|
876
|
+
const list = [...files.keys()].sort()
|
|
877
|
+
files.set(MANIFEST, JSON.stringify(
|
|
878
|
+
{ generated: 'tabnas-lsp-gen', files: list }, null, 1) + '\n')
|
|
879
|
+
|
|
880
|
+
let previous = []
|
|
881
|
+
try {
|
|
882
|
+
const m = JSON.parse(fs.readFileSync(path.join(out, MANIFEST), 'utf8'))
|
|
883
|
+
if (Array.isArray(m.files)) previous = m.files
|
|
884
|
+
} catch (e) {
|
|
885
|
+
// no previous run
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// The manifest is a FILE ON DISK, so it is input, not a trusted
|
|
889
|
+
// record: it can be hand-edited, merged badly, or written by an older
|
|
890
|
+
// version. Every entry is therefore contained to `out` before
|
|
891
|
+
// anything is unlinked. Without this, an entry like
|
|
892
|
+
// '../precious/keep.txt' deleted a file outside the output directory,
|
|
893
|
+
// and the prune loop below — which stopped only on EXACT equality
|
|
894
|
+
// with `out` — then climbed past it, rmdir'ing ancestors until one
|
|
895
|
+
// was non-empty. Non-string entries are dropped for the same reason.
|
|
896
|
+
const resolvedOut = path.resolve(out)
|
|
897
|
+
|
|
898
|
+
// Lexical containment is necessary but NOT sufficient: path.resolve
|
|
899
|
+
// normalises `..` and nothing else, so it cannot see a symlink. With
|
|
900
|
+
// `out/link` pointing outside the tree, `link/victim` passes every
|
|
901
|
+
// string test here while unlinkSync follows `link` straight out of
|
|
902
|
+
// it — and generated output is routinely a checked-out project, so
|
|
903
|
+
// the symlink is attacker-supplied in exactly the case that matters.
|
|
904
|
+
// The ANCESTOR is what has to be real: unlink does not follow a
|
|
905
|
+
// symlink at the final component (it removes the link itself), so
|
|
906
|
+
// resolving the containing directory closes the hole.
|
|
907
|
+
let realOut = resolvedOut
|
|
908
|
+
try { realOut = fs.realpathSync(resolvedOut) } catch (e) { /* new tree */ }
|
|
909
|
+
const under = (p, root) => p === root || p.startsWith(root + path.sep)
|
|
910
|
+
// Same rule for the prune loop below: it climbs from a deleted file's
|
|
911
|
+
// directory, so a symlinked ancestor would let rmdir walk out too.
|
|
912
|
+
const realDirUnder = (dir, root) => {
|
|
913
|
+
try { return under(fs.realpathSync(dir), root) } catch (e) { return false }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const inside = (rel) => {
|
|
917
|
+
if ('string' !== typeof rel) return false
|
|
918
|
+
const abs = path.resolve(out, rel) // an absolute rel resolves to itself
|
|
919
|
+
if (abs === resolvedOut || !abs.startsWith(resolvedOut + path.sep)) {
|
|
920
|
+
return false
|
|
921
|
+
}
|
|
922
|
+
let realDir
|
|
923
|
+
try {
|
|
924
|
+
realDir = fs.realpathSync(path.dirname(abs))
|
|
925
|
+
} catch (e) {
|
|
926
|
+
return false // cannot resolve it, so cannot vouch for it
|
|
927
|
+
}
|
|
928
|
+
return under(realDir, realOut)
|
|
929
|
+
}
|
|
930
|
+
const stale = previous.filter((rel) => inside(rel) && !files.has(rel))
|
|
931
|
+
|
|
932
|
+
for (const rel of stale) {
|
|
933
|
+
try {
|
|
934
|
+
fs.unlinkSync(path.join(out, rel))
|
|
935
|
+
} catch (e) {
|
|
936
|
+
// already gone
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// Prune directories the deletions emptied. Every `dir` here descends
|
|
940
|
+
// from `out` by construction (stale is contained), so the equality
|
|
941
|
+
// stop is sound; the containment re-check is belt-and-braces.
|
|
942
|
+
for (const rel of stale) {
|
|
943
|
+
let dir = path.dirname(path.join(out, rel))
|
|
944
|
+
while (realDirUnder(dir, realOut) && path.resolve(dir) !== resolvedOut &&
|
|
945
|
+
path.resolve(dir).startsWith(resolvedOut + path.sep)) {
|
|
946
|
+
try {
|
|
947
|
+
fs.rmdirSync(dir) // fails (kept) unless empty
|
|
948
|
+
} catch (e) {
|
|
949
|
+
break
|
|
950
|
+
}
|
|
951
|
+
dir = path.dirname(dir)
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
for (const [rel, content] of files) {
|
|
956
|
+
const abs = path.join(out, rel)
|
|
957
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
958
|
+
fs.writeFileSync(abs, content)
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
module.exports = { generate, GenerateError, ALL_EDITORS }
|