@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,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 }