-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathextension.js
More file actions
607 lines (536 loc) · 18.9 KB
/
Copy pathextension.js
File metadata and controls
607 lines (536 loc) · 18.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
const childProcess = require('child_process');
const fs = require('fs');
const path = require('path');
const vscode = require('vscode');
const {
findOnPath,
managedBinaryPath,
detectInstallSource
} = require('./serverInstaller');
const {
normalizeShowReferencesArguments
} = require('./commandAdapters');
const commandInstall = 'ridl.installLanguageServer';
const commandUpdate = 'ridl.updateLanguageServer';
const commandRestart = 'ridl.restartLanguageServer';
const commandShowReferences = 'ridl.showReferences';
const defaultImportPath = 'github.com/webrpc/ridl-lsp/cmd/ridl-lsp';
const brewFormula = 'ridl-lsp';
const brewInstallTarget = 'webrpc/tap/ridl-lsp';
const outputChannelName = 'RIDL Language Server';
const traceOutputChannelName = 'RIDL Language Server Trace';
// Bump on notable ridl-lsp releases this extension has been validated against.
// The update prompt nudges users whose installed server is older than this.
const recommendedServerVersion = '1.5.0';
const dismissedServerUpdateKey = 'ridl.dismissedServerUpdateVersion';
let client;
let clientModule;
// Prompt to update at most once per session — startLanguageServer can run again
// on restart/re-open, and the post-update restart would otherwise re-trigger it.
let serverUpdatePrompted = false;
let outputChannel;
let traceOutputChannel;
let startingPromise;
async function activate(context) {
outputChannel = vscode.window.createOutputChannel(outputChannelName);
traceOutputChannel = vscode.window.createOutputChannel(traceOutputChannelName);
context.subscriptions.push(
outputChannel,
traceOutputChannel,
vscode.commands.registerCommand(commandInstall, () => installLanguageServer(context)),
vscode.commands.registerCommand(commandUpdate, () => updateLanguageServer(context)),
vscode.commands.registerCommand(commandRestart, () => restartLanguageServer(context)),
vscode.commands.registerCommand(commandShowReferences, (...args) => showReferences(...args))
);
if (hasRIDLOpen()) {
await ensureLanguageServerStarted(context, true);
}
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument(async (document) => {
if (document.languageId === 'ridl') {
await ensureLanguageServerStarted(context, true);
}
})
);
}
async function deactivate() {
if (!client) {
return;
}
const current = client;
client = undefined;
await current.stop();
}
function hasRIDLOpen() {
return vscode.workspace.textDocuments.some((document) => document.languageId === 'ridl');
}
function configuration() {
return vscode.workspace.getConfiguration('ridl');
}
// serverEnv layers the configured log level onto the inherited environment, so
// RIDL_LSP_LOG_LEVEL can be set from VS Code settings without relaunching the
// editor from a shell. An empty setting leaves the server's own default.
function serverEnv() {
const logLevel = configuration().get('languageServer.logLevel', '').trim();
if (!logLevel) {
return process.env;
}
return { ...process.env, RIDL_LSP_LOG_LEVEL: logLevel };
}
async function ensureLanguageServerStarted(context, promptOnMissing) {
if (client) {
return;
}
if (startingPromise) {
return startingPromise;
}
startingPromise = (async () => {
try {
const serverPath = await resolveLanguageServerPath(context, promptOnMissing);
if (!serverPath) {
return;
}
await startLanguageServer(context, serverPath);
} finally {
startingPromise = undefined;
}
})();
return startingPromise;
}
async function startLanguageServer(context, serverPath) {
const module = await loadLanguageClientModule();
if (!module) {
return;
}
const { LanguageClient, TransportKind, Trace } = module;
const traceValue = configuration().get('languageServer.trace.server', 'off');
const trace = typeof Trace?.fromString === 'function' ? Trace.fromString(traceValue) : Trace.Off;
const serverOptions = {
command: serverPath,
transport: TransportKind.stdio,
options: {
env: serverEnv()
}
};
const watcher = vscode.workspace.createFileSystemWatcher('**/*.ridl');
appendOutputLine(`Starting ridl-lsp from ${serverPath}`);
client = new LanguageClient(
'ridl',
outputChannelName,
serverOptions,
{
documentSelector: [
{ scheme: 'file', language: 'ridl' },
{ scheme: 'untitled', language: 'ridl' }
],
synchronize: {
fileEvents: watcher
},
outputChannel,
traceOutputChannel
}
);
try {
await client.start();
context.subscriptions.push(watcher);
} catch (error) {
client = undefined;
watcher.dispose();
throw error;
}
await client.setTrace(trace);
// Fire-and-forget: nudge if the running server is behind the recommended
// version. Never block startup on this.
void maybePromptServerUpdate(context, serverPath).catch(() => {});
}
async function restartLanguageServer(context) {
appendOutputLine('Restarting ridl-lsp');
if (client) {
const current = client;
client = undefined;
await current.stop();
}
await ensureLanguageServerStarted(context, false);
}
// restartLanguageServerAt starts the server at a known binary path, bypassing
// path re-discovery. Used right after an install/upgrade, where we already know
// where the binary is and `which` may not see a freshly-linked one yet.
async function restartLanguageServerAt(context, serverPath) {
appendOutputLine('Restarting ridl-lsp');
if (client) {
const current = client;
client = undefined;
await current.stop();
}
await startLanguageServer(context, serverPath);
}
async function showReferences(...args) {
const normalizedArgs = normalizeShowReferencesArguments(vscode, args);
appendOutputLine(`Showing references for ${String(normalizedArgs?.[0] ?? 'unknown document')}`);
return vscode.commands.executeCommand('editor.action.showReferences', ...normalizedArgs);
}
async function loadLanguageClientModule() {
if (clientModule) {
return clientModule;
}
try {
clientModule = require('vscode-languageclient/node');
return clientModule;
} catch (error) {
const selection = await vscode.window.showErrorMessage(
'The RIDL extension needs the vscode-languageclient dependency. Run `npm install` in the extension folder before local development.',
'Open Extension Folder'
);
if (selection === 'Open Extension Folder') {
await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(__dirname), true);
}
return null;
}
}
async function resolveLanguageServerPath(context, promptOnMissing) {
const configuredPath = configuration().get('languageServer.path', '').trim();
if (configuredPath) {
if (fs.existsSync(configuredPath)) {
return configuredPath;
}
void vscode.window.showWarningMessage(`Configured RIDL language server was not found: ${configuredPath}`);
return '';
}
const pathBinary = findOnPath();
if (pathBinary) {
return pathBinary;
}
const goEnv = await readGoEnv();
const managedPath = managedBinaryPath(goEnv.gopath);
if (managedPath && fs.existsSync(managedPath)) {
return managedPath;
}
if (promptOnMissing && configuration().get('languageServer.promptToInstall', true)) {
const choice = await vscode.window.showWarningMessage(
'ridl-lsp was not found. Install it now?',
'Install',
'Not Now'
);
if (choice === 'Install') {
// Use the path the installer reports. Re-discovering via `which` is
// unreliable for a freshly-linked Homebrew binary and would leave the
// server unstarted until a window reload.
return await installLanguageServer(context, { restart: false });
}
}
return '';
}
// goInstallLanguageServer runs `go install` and returns the resulting binary
// path ($GOPATH/bin/ridl-lsp), or '' on failure. It does not start the server —
// the caller starts at the returned path.
async function goInstallLanguageServer(isUpdate = false) {
const goEnv = await readGoEnv();
if (!goEnv.gopath) {
void vscode.window.showErrorMessage('RIDL could not determine GOPATH.');
return '';
}
const importPath = configuration().get('languageServer.importPath', defaultImportPath).trim() || defaultImportPath;
const label = isUpdate ? 'Updating ridl-lsp' : 'Installing ridl-lsp';
appendOutputLine(`${label} via go install ${importPath}@latest`);
const ok = await runGoInstall(label, ['install', `${importPath}@latest`]);
if (!ok) {
return '';
}
const binaryPath = managedBinaryPath(goEnv.gopath);
appendOutputLine(`ridl-lsp installed via go install at ${binaryPath}`);
void vscode.window.showInformationMessage(`RIDL language server is ready at ${binaryPath}.`);
return binaryPath;
}
// installLanguageServer installs ridl-lsp via Homebrew or go install. When both
// are available it asks which to use, so a fresh install matches the tool the
// user expects (and the update command can later track that source).
async function installLanguageServer(context, options = {}) {
const [goAvailable, brewAvailable] = await Promise.all([isGoAvailable(), isBrewAvailable()]);
if (!goAvailable && !brewAvailable) {
void vscode.window.showErrorMessage(
'Installing ridl-lsp needs Homebrew or Go. Install one, or set ridl.languageServer.path to an existing binary.'
);
return false;
}
let source;
if (goAvailable && brewAvailable) {
source = await pickInstallSource();
if (!source) {
return false;
}
} else {
source = brewAvailable ? 'brew' : 'go';
}
const binaryPath = source === 'brew'
? await brewInstallLanguageServer()
: await goInstallLanguageServer();
if (!binaryPath) {
return '';
}
if (options.restart !== false) {
await restartLanguageServerAt(context, binaryPath);
}
return binaryPath;
}
async function pickInstallSource() {
const importPath = configuration().get('languageServer.importPath', defaultImportPath).trim() || defaultImportPath;
const picked = await vscode.window.showQuickPick(
[
{ label: 'Homebrew', description: `brew install ${brewInstallTarget}`, source: 'brew' },
{ label: 'Go', description: `go install ${importPath}@latest`, source: 'go' }
],
{
title: 'Install ridl-lsp',
placeHolder: 'Choose how to install the RIDL language server'
}
);
return picked ? picked.source : '';
}
// brewInstallLanguageServer runs `brew install` and returns the resulting binary
// path (<brew --prefix>/bin/ridl-lsp), or '' on failure. The path comes from the
// Homebrew prefix, not `which`, which doesn't reliably see a freshly-linked
// binary. It does not start the server.
async function brewInstallLanguageServer() {
const label = `Installing ridl-lsp via brew install ${brewInstallTarget}`;
appendOutputLine(label);
const ok = await runWithProgress(label, 'brew', ['install', brewInstallTarget]);
if (!ok) {
return '';
}
const prefix = await readBrewPrefix();
if (!prefix) {
void vscode.window.showErrorMessage('ridl-lsp installed, but the Homebrew prefix could not be resolved to locate it.');
return '';
}
const binaryPath = path.join(prefix, 'bin', brewFormula);
appendOutputLine(`ridl-lsp installed via Homebrew at ${binaryPath}`);
void vscode.window.showInformationMessage('ridl-lsp installed via Homebrew.');
return binaryPath;
}
async function isGoAvailable() {
const { gopath } = await readGoEnv();
return Boolean(gopath);
}
async function isBrewAvailable() {
return Boolean(await readBrewPrefix());
}
// updateLanguageServer updates whatever binary the editor actually runs, using the
// tool it was installed with. Updating via the other source would drop a second
// copy the PATH binary shadows (e.g. a go-install that never replaces a brew
// install), so for a binary that is neither brew- nor go-managed we refuse rather
// than cross-install.
async function updateLanguageServer(context) {
const serverPath = await resolveLanguageServerPath(context, false);
if (!serverPath) {
void vscode.window.showWarningMessage(
'ridl-lsp is not installed. Run "RIDL: Install Language Server", or install it with: brew install webrpc/tap/ridl-lsp'
);
return false;
}
const source = await detectActiveSource(serverPath);
appendOutputLine(`Updating ridl-lsp at ${serverPath} (install source: ${source})`);
if (source === 'go') {
const binaryPath = await goInstallLanguageServer(true);
if (!binaryPath) {
return false;
}
await restartLanguageServerAt(context, binaryPath);
return true;
}
if (source === 'brew') {
return brewUpgradeLanguageServer(context, serverPath);
}
void vscode.window.showWarningMessage(
`ridl-lsp at ${serverPath} was not installed via Homebrew or go install — update it with the tool you installed it with.`
);
return false;
}
async function detectActiveSource(serverPath) {
const [goEnv, brewPrefix] = await Promise.all([readGoEnv(), readBrewPrefix()]);
let realBinaryPath = serverPath;
try {
realBinaryPath = fs.realpathSync(serverPath);
} catch {
// Fall back to the unresolved path; detection still handles it.
}
return detectInstallSource({ binaryPath: serverPath, realBinaryPath, gopath: goEnv.gopath, brewPrefix });
}
async function brewUpgradeLanguageServer(context, serverPath) {
const label = 'Updating ridl-lsp via brew upgrade';
appendOutputLine(label);
const ok = await runWithProgress(label, 'brew', ['upgrade', brewFormula]);
if (!ok) {
return false;
}
void vscode.window.showInformationMessage('ridl-lsp upgraded via Homebrew.');
// Restart at the stable Homebrew bin symlink, not the pre-upgrade path: that
// path may be a versioned Cellar keg (e.g. a configured languageServer.path)
// that `brew upgrade` just retired. Fall back to serverPath if the prefix
// lookup fails.
const prefix = await readBrewPrefix();
const binaryPath = prefix ? path.join(prefix, 'bin', brewFormula) : serverPath;
await restartLanguageServerAt(context, binaryPath);
return true;
}
async function readGoEnv() {
try {
const output = await execFile('go', ['env', 'GOPATH']);
return { gopath: output.trim() };
} catch (error) {
return { gopath: '' };
}
}
async function readBrewPrefix() {
try {
const output = await execFile('brew', ['--prefix']);
return output.trim();
} catch (error) {
return '';
}
}
async function runGoInstall(title, args, cwd) {
return runWithProgress(title, 'go', args, {
cwd,
env: {
...process.env,
GOBIN: ''
}
});
}
async function runWithProgress(title, command, args, options = {}) {
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title,
cancellable: false
},
async () => {
try {
await execFile(command, args, { timeout: 120_000, ...options });
return true;
} catch (error) {
const message = error && error.message ? error.message : String(error);
appendOutputLine(`${title} failed: ${message}`);
void vscode.window.showErrorMessage(`${title} failed: ${message}`);
return false;
}
}
);
}
function appendOutputLine(message) {
if (outputChannel) {
outputChannel.appendLine(message);
}
}
// compareVersions compares two MAJOR.MINOR.PATCH strings numerically (so
// 1.10.0 > 1.9.0), tolerating a leading "v" and any -prerelease/+build suffix.
// Unparseable input compares as 0.0.0.
function compareVersions(a, b) {
const parse = (v) => {
const m = /(\d+)\.(\d+)\.(\d+)/.exec(String(v));
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0];
};
const pa = parse(a);
const pb = parse(b);
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) {
return pa[i] < pb[i] ? -1 : 1;
}
}
return 0;
}
// getServerVersion runs `ridl-lsp --version` and returns the parsed x.y.z, or
// null on spawn failure / unparseable output (e.g. a dev build) — null means
// "skip the update prompt" so dev/unknown builds are never nagged.
async function getServerVersion(serverPath) {
try {
const stdout = await execFile(serverPath, ['--version'], { timeout: 5_000 });
const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout);
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
} catch {
return null;
}
}
// maybePromptServerUpdate shows a one-time, non-blocking toast when the installed
// server is older than recommendedServerVersion, offering to run the update flow.
// The whole body is wrapped so it NEVER rejects: it runs fire-and-forget from
// startup, and showInformationMessage/updateLanguageServer/globalState.update can
// reject — an unhandled rejection there must never disrupt activation.
async function maybePromptServerUpdate(context, serverPath) {
try {
if (serverUpdatePrompted || !configuration().get('languageServer.promptToUpdate', true)) {
return;
}
const installed = await getServerVersion(serverPath);
if (!installed || compareVersions(installed, recommendedServerVersion) >= 0) {
return;
}
const dismissed = context.globalState.get(dismissedServerUpdateKey);
if (dismissed && compareVersions(recommendedServerVersion, dismissed) <= 0) {
return;
}
// Claim the once-per-session slot atomically (no await between the check and
// the set), so two checks that both passed the early guard before either
// resolved its version probe can't both reach the toast.
if (serverUpdatePrompted) {
return;
}
serverUpdatePrompted = true;
const updateLabel = 'Update';
const dismissLabel = `Don't ask for ${recommendedServerVersion}`;
const choice = await vscode.window.showInformationMessage(
`ridl-lsp ${recommendedServerVersion} is available (you have ${installed}).`,
updateLabel,
'Later',
dismissLabel
);
if (choice === updateLabel) {
await updateLanguageServer(context);
return;
}
if (choice === dismissLabel) {
await context.globalState.update(dismissedServerUpdateKey, recommendedServerVersion);
}
} catch {
// Silent by design: an update nudge must never affect startup.
}
}
function execFile(command, args, options = {}) {
return new Promise((resolve, reject) => {
childProcess.execFile(command, args, options, (error, stdout, stderr) => {
if (error) {
const details = stderr || stdout || error.message;
reject(new Error(details.trim() || error.message));
return;
}
resolve(stdout);
});
});
}
module.exports = {
activate,
deactivate,
__test: {
ensureLanguageServerStarted,
installLanguageServer,
updateLanguageServer,
restartLanguageServer,
compareVersions,
getServerVersion,
maybePromptServerUpdate,
resetState() {
client = undefined;
clientModule = undefined;
outputChannel = undefined;
traceOutputChannel = undefined;
startingPromise = undefined;
serverUpdatePrompted = false;
},
setClientModuleForTests(module) {
clientModule = module;
},
setClientForTests(nextClient) {
client = nextClient;
}
}
};