@knip/language-server 3.1.1 → 3.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knip/language-server",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/webpro-nl/knip.git",
@@ -18,9 +18,9 @@
18
18
  "./constants": "./src/constants.js"
19
19
  },
20
20
  "dependencies": {
21
- "vscode-languageserver": "^10.1.1",
22
- "vscode-languageserver-textdocument": "^1.0.14",
23
- "knip": "^6.36.0"
21
+ "knip": "^6.44.0",
22
+ "vscode-languageserver": "^10.1.2",
23
+ "vscode-languageserver-textdocument": "^1.0.16"
24
24
  },
25
25
  "engines": {
26
26
  "node": "^20.19.0 || >=22.12.0"
package/src/server.js CHANGED
@@ -99,6 +99,7 @@ export class LanguageServer {
99
99
  /** @type {undefined | Session} */
100
100
  session;
101
101
 
102
+ revision = 0;
102
103
  /** @type {Rules} */
103
104
  rules = {};
104
105
 
@@ -152,8 +153,9 @@ export class LanguageServer {
152
153
  });
153
154
 
154
155
  this.connection.onInitialized(async () => {
156
+ const revision = this.revision;
155
157
  const config = await this.getConfig();
156
- if (config?.deferSession !== true) this.start();
158
+ if (revision === this.revision && config?.deferSession !== true) this.start();
157
159
  });
158
160
 
159
161
  this.connection.onRequest(REQUEST_START, () => this.start());
@@ -244,10 +246,13 @@ export class LanguageServer {
244
246
 
245
247
  async start() {
246
248
  if (this.session) return;
249
+ const revision = ++this.revision;
247
250
 
248
251
  try {
249
252
  const config = await this.getConfig();
253
+ if (revision !== this.revision) return;
250
254
  const knip = await this.#resolveKnipSession();
255
+ if (revision !== this.revision) return;
251
256
 
252
257
  const configFilePath = config?.configFilePath
253
258
  ? path.isAbsolute(config.configFilePath)
@@ -257,31 +262,38 @@ export class LanguageServer {
257
262
 
258
263
  this.connection.console.log('Creating options');
259
264
  const options = await knip.createOptions({ cwd: this.cwd, isSession: true, args: { config: configFilePath } });
265
+ if (revision !== this.revision) return;
260
266
  this.rules = options.rules;
261
267
 
262
268
  this.connection.console.log('Building module graph...');
263
269
  const start = Date.now();
264
270
  const session = await knip.createSession(options);
271
+ if (revision !== this.revision) return;
265
272
  this.connection.console.log(`Finished building module graph (${Date.now() - start}ms)`);
266
273
 
267
274
  this.session = session;
268
275
  this.publishDiagnostics(this.buildDiagnostics(session.getIssues().issues, config, this.rules));
269
276
  this.connection.sendNotification(NOTIFICATION_MODULE_GRAPH_BUILT, { duration: Date.now() - start });
270
277
  } catch (_error) {
278
+ if (revision !== this.revision) return;
271
279
  this.connection.console.error(`Error: ${_error}`);
272
280
  }
273
281
  }
274
282
 
275
283
  stop() {
284
+ this.revision++;
276
285
  this.session = undefined;
277
286
  this.fileCache = undefined;
287
+ this.packageJsonCache = undefined;
288
+ this.cycleDiagnostics.clear();
289
+ this.issuesByUri.clear();
278
290
  for (const uri of this.published) this.connection.sendDiagnostics({ uri, diagnostics: [] });
279
291
  this.published.clear();
280
292
  }
281
293
 
282
- restart() {
294
+ async restart() {
283
295
  this.stop();
284
- this.start();
296
+ await this.start();
285
297
  }
286
298
 
287
299
  getResults() {
@@ -297,6 +309,7 @@ export class LanguageServer {
297
309
  this.fileCache = undefined;
298
310
  this.packageJsonCache = undefined;
299
311
  if (!this.session) return;
312
+ const session = this.session;
300
313
 
301
314
  const cwd = this.cwd ?? process.cwd();
302
315
 
@@ -315,17 +328,23 @@ export class LanguageServer {
315
328
  if (changes.length === 0) return;
316
329
 
317
330
  try {
318
- const result = await this.session.handleFileChanges(changes);
331
+ const result = await session.handleFileChanges(changes);
319
332
 
320
- if (!result) return;
333
+ if (!result || this.session !== session) return;
334
+ this.fileCache = undefined;
335
+ this.packageJsonCache = undefined;
336
+ this.cycleDiagnostics.clear();
321
337
 
322
338
  this.connection.console.log(
323
339
  `Module graph updated (${Math.floor(result.duration)}ms • ${(result.mem / 1024 / 1024).toFixed(2)}M)`
324
340
  );
325
341
 
326
342
  const config = await this.getConfig();
327
- this.publishDiagnostics(this.buildDiagnostics(this.session.getIssues().issues, config, this.rules));
343
+ if (this.session !== session) return;
344
+ this.publishDiagnostics(this.buildDiagnostics(session.getIssues().issues, config, this.rules));
345
+ this.connection.sendNotification(NOTIFICATION_MODULE_GRAPH_BUILT, { duration: result.duration });
328
346
  } catch (_error) {
347
+ if (this.session !== session) return;
329
348
  this.connection.console.error(`Error handling file changes: ${_error}`);
330
349
  }
331
350
  }
@@ -0,0 +1,14 @@
1
+ import { PassThrough } from 'node:stream';
2
+ import {
3
+ createOptions as options,
4
+ createSession as session,
5
+ KNIP_CONFIG_LOCATIONS,
6
+ } from 'knip/session';
7
+ import { CodeActionKind, createConnection as connect } from 'vscode-languageserver/node';
8
+
9
+ export { CodeActionKind, KNIP_CONFIG_LOCATIONS };
10
+ export const knip = { createOptions: options, createSession: session };
11
+ export const createOptions = options => knip.createOptions(options);
12
+ export const createSession = (...args) => knip.createSession(...args);
13
+ export const connection = connect(new PassThrough(), new PassThrough());
14
+ export const createConnection = () => connection;
@@ -0,0 +1,221 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { registerHooks } from 'node:module';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import test, { after } from 'node:test';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { NOTIFICATION_MODULE_GRAPH_BUILT, SESSION_LOADING } from '../src/constants.js';
9
+ import { connection, knip } from './fixtures/lifecycle.js';
10
+
11
+ const serverUrl = new URL('../src/server.js', import.meta.url).href;
12
+ const fixtureUrl = new URL('./fixtures/lifecycle.js', import.meta.url).href;
13
+ registerHooks({
14
+ resolve(specifier, context, nextResolve) {
15
+ if (
16
+ context.parentURL === serverUrl &&
17
+ (specifier === 'vscode-languageserver/node' || specifier === 'knip/session')
18
+ ) {
19
+ return { url: fixtureUrl, shortCircuit: true };
20
+ }
21
+ return nextResolve(specifier, context);
22
+ },
23
+ });
24
+
25
+ const { LanguageServer } = await import('../src/server.js');
26
+ const cwd = mkdtempSync(path.join(tmpdir(), 'knip-ls-lifecycle-'));
27
+ after(() => rmSync(cwd, { recursive: true, force: true }));
28
+ const filePath = path.join(cwd, 'value.js');
29
+ const changes = { changes: [{ uri: pathToFileURL(filePath).href, type: 2 }] };
30
+ const updated = { duration: 1, mem: 1024 };
31
+ const config = { editor: { exports: { quickfix: { enabled: true } } } };
32
+ const options = { rules: { exports: 'warn' } };
33
+ const deferred = () => Promise.withResolvers();
34
+
35
+ const createSession = (name = 'current') => ({
36
+ getIssues: () => ({ issues: {} }),
37
+ getResults: () => ({ name }),
38
+ describeFile: () => ({ name, metrics: {} }),
39
+ describePackageJson: () => ({ dependenciesUsage: new Map([[name, { imports: [] }]]) }),
40
+ handleFileChanges: async () => updated,
41
+ });
42
+
43
+ const setup = t => {
44
+ t.mock.method(connection, 'listen', () => {});
45
+ t.mock.method(connection.workspace, 'getConfiguration', async () => config);
46
+ t.mock.method(connection.console, 'log', () => {});
47
+ t.mock.method(connection.console, 'error', () => {});
48
+ t.mock.method(connection, 'sendDiagnostics', () => {});
49
+ t.mock.method(connection, 'sendNotification', () => {});
50
+ t.mock.method(connection, 'onInitialized');
51
+ t.mock.method(knip, 'createOptions', async () => options);
52
+ t.mock.method(knip, 'createSession', async () => createSession());
53
+ const originalConsole = { ...console };
54
+ const server = new LanguageServer();
55
+ Object.assign(console, originalConsole);
56
+ server.cwd = cwd;
57
+ return server;
58
+ };
59
+
60
+ test('stop cancels automatic startup while initialization configuration is pending', async t => {
61
+ const server = setup(t);
62
+ const pending = deferred();
63
+ t.mock.method(server, 'getConfig', () => pending.promise);
64
+ const start = t.mock.method(server, 'start');
65
+ const initialized = connection.onInitialized.mock.calls[0].arguments[0]();
66
+ server.stop();
67
+ pending.resolve(config);
68
+ await initialized;
69
+ for (const call of start.mock.calls) await call.result;
70
+ assert.equal(server.getResults(), null);
71
+ assert.equal(connection.sendNotification.mock.callCount(), 0);
72
+ });
73
+
74
+ for (const phase of ['createOptions', 'createSession']) {
75
+ test(`stop discards a pending ${phase}`, { timeout: 3000 }, async t => {
76
+ const server = setup(t);
77
+ const entered = deferred();
78
+ const pending = deferred();
79
+ t.mock.method(knip, phase, () => {
80
+ entered.resolve();
81
+ return pending.promise;
82
+ });
83
+ const start = server.start();
84
+ await entered.promise;
85
+ server.stop();
86
+ pending.resolve(phase === 'createOptions' ? options : createSession('obsolete'));
87
+ await start;
88
+ assert.equal(server.getResults(), null);
89
+ assert.equal(server.getFileDescriptor(filePath), SESSION_LOADING);
90
+ assert.equal(connection.sendNotification.mock.callCount(), 0);
91
+ assert.equal(connection.sendDiagnostics.mock.callCount(), 0);
92
+ if (phase === 'createOptions') assert.equal(knip.createSession.mock.callCount(), 0);
93
+ });
94
+
95
+ test(`restart keeps the new session when obsolete ${phase} finishes last`, { timeout: 3000 }, async t => {
96
+ const server = setup(t);
97
+ const entered = deferred();
98
+ const pending = deferred();
99
+ let calls = 0;
100
+ t.mock.method(knip, phase, () => {
101
+ if (++calls > 1) return phase === 'createOptions' ? options : createSession();
102
+ entered.resolve();
103
+ return pending.promise;
104
+ });
105
+ const start = server.start();
106
+ await entered.promise;
107
+ await server.restart();
108
+ assert.deepEqual(server.getResults(), { name: 'current' });
109
+ pending.resolve(phase === 'createOptions' ? { rules: { exports: 'error' } } : createSession('obsolete'));
110
+ await start;
111
+ assert.deepEqual(server.getResults(), { name: 'current' });
112
+ assert.deepEqual(server.rules, options.rules);
113
+ assert.equal(connection.sendNotification.mock.callCount(), 1);
114
+ });
115
+ }
116
+
117
+ test('overlapping starts retain the latest session and ignore obsolete startup errors', { timeout: 3000 }, async t => {
118
+ const server = setup(t);
119
+ const entered = deferred();
120
+ const pending = deferred();
121
+ let calls = 0;
122
+ t.mock.method(knip, 'createSession', () => {
123
+ if (++calls > 1) return createSession();
124
+ entered.resolve();
125
+ return pending.promise;
126
+ });
127
+ const start = server.start();
128
+ await entered.promise;
129
+ await server.start();
130
+ pending.reject(new Error('obsolete startup failure'));
131
+ await start;
132
+ assert.deepEqual(server.getResults(), { name: 'current' });
133
+ assert.equal(connection.console.error.mock.callCount(), 0);
134
+ assert.equal(connection.sendNotification.mock.callCount(), 1);
135
+ });
136
+
137
+ test('graph updates invalidate descriptors requested during the update and notify without diagnostics', async t => {
138
+ const server = setup(t);
139
+ let session = createSession('before');
140
+ const pending = deferred();
141
+ server.session = {
142
+ ...session,
143
+ describeFile: () => session.describeFile(),
144
+ describePackageJson: () => session.describePackageJson(),
145
+ handleFileChanges: () => pending.promise,
146
+ };
147
+ const update = server.handleFileChanges(changes);
148
+ assert.equal(server.getFileDescriptor(filePath).name, 'before');
149
+ assert.deepEqual(Object.keys(server.getPackageJsonDescriptor().dependenciesUsage), ['before']);
150
+ session = createSession('after');
151
+ pending.resolve(updated);
152
+ await update;
153
+ assert.equal(server.getFileDescriptor(filePath).name, 'after');
154
+ assert.deepEqual(Object.keys(server.getPackageJsonDescriptor().dependenciesUsage), ['after']);
155
+ assert.equal(connection.sendDiagnostics.mock.callCount(), 0);
156
+ assert.deepEqual(connection.sendNotification.mock.calls[0]?.arguments, [
157
+ NOTIFICATION_MODULE_GRAPH_BUILT,
158
+ { duration: updated.duration },
159
+ ]);
160
+ });
161
+
162
+ test('stop clears package descriptors before another session starts', async t => {
163
+ const server = setup(t);
164
+ server.session = createSession('obsolete');
165
+ assert.deepEqual(Object.keys(server.getPackageJsonDescriptor().dependenciesUsage), ['obsolete']);
166
+ server.stop();
167
+ await server.start();
168
+ assert.deepEqual(Object.keys(server.getPackageJsonDescriptor().dependenciesUsage), ['current']);
169
+ });
170
+
171
+ for (const replaced of [false, true]) {
172
+ for (const phase of ['update', 'configuration']) {
173
+ test(`discard an obsolete update awaiting ${phase} after ${replaced ? 'replacement' : 'stop'}`, async t => {
174
+ const server = setup(t);
175
+ const pending = deferred();
176
+ const entered = deferred();
177
+ const old = createSession('obsolete');
178
+ server.session = old;
179
+ if (phase === 'update') old.handleFileChanges = () => pending.promise;
180
+ else {
181
+ t.mock.method(server, 'getConfig', () => {
182
+ entered.resolve();
183
+ return pending.promise;
184
+ });
185
+ }
186
+ const update = server.handleFileChanges(changes);
187
+ if (phase === 'configuration') await entered.promise;
188
+ server.stop();
189
+ if (replaced) {
190
+ server.session = createSession();
191
+ server.session.getIssues = () => ({ issues: { files: { [filePath]: { file: { type: 'files', filePath } } } } });
192
+ server.getFileDescriptor(filePath);
193
+ server.getPackageJsonDescriptor();
194
+ }
195
+ const file = server.fileCache;
196
+ const manifest = server.packageJsonCache;
197
+ pending.resolve(phase === 'update' ? updated : config);
198
+ await update;
199
+ assert.equal(server.fileCache, file);
200
+ assert.equal(server.packageJsonCache, manifest);
201
+ assert.equal(connection.sendDiagnostics.mock.callCount(), 0);
202
+ assert.equal(connection.sendNotification.mock.callCount(), 0);
203
+ assert.equal(connection.console.error.mock.callCount(), 0);
204
+ });
205
+ }
206
+ }
207
+
208
+ test('obsolete update errors cannot affect the replacement session', async t => {
209
+ const server = setup(t);
210
+ await server.start();
211
+ const pending = deferred();
212
+ server.session.handleFileChanges = () => pending.promise;
213
+ const update = server.handleFileChanges(changes);
214
+ await server.restart();
215
+ pending.reject(new Error('obsolete update failure'));
216
+ await update;
217
+ assert.deepEqual(server.getResults(), { name: 'current' });
218
+ assert.deepEqual(server.rules, options.rules);
219
+ assert.equal(connection.console.error.mock.callCount(), 0);
220
+ assert.equal(connection.sendNotification.mock.callCount(), 2);
221
+ });
@@ -0,0 +1,114 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { cpSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { NOTIFICATION_MODULE_GRAPH_BUILT, REQUEST_FILE_NODE, REQUEST_RESULTS } from '../src/constants.js';
9
+
10
+ const startServer = (cwd, t) => {
11
+ const child = spawn(process.execPath, [fileURLToPath(new URL('../src/cli.js', import.meta.url)), '--stdio'], {
12
+ cwd,
13
+ stdio: ['pipe', 'pipe', 'pipe'],
14
+ });
15
+ t.after(() => child.kill());
16
+ let buffer = Buffer.alloc(0);
17
+ let nextId = 1;
18
+ const messages = [];
19
+ const send = payload => {
20
+ const body = JSON.stringify({ jsonrpc: '2.0', ...payload });
21
+ child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
22
+ };
23
+ child.stdout.on('data', chunk => {
24
+ buffer = Buffer.concat([buffer, chunk]);
25
+ for (;;) {
26
+ const header = buffer.indexOf('\r\n\r\n');
27
+ if (header === -1) return;
28
+ const length = Number(
29
+ buffer
30
+ .subarray(0, header)
31
+ .toString()
32
+ .match(/Content-Length: (\d+)/i)?.[1]
33
+ );
34
+ if (buffer.length < header + 4 + length) return;
35
+ const message = JSON.parse(buffer.subarray(header + 4, header + 4 + length).toString());
36
+ buffer = buffer.subarray(header + 4 + length);
37
+ if (message.method === 'workspace/configuration') send({ id: message.id, result: [{}] });
38
+ else messages.push(message);
39
+ }
40
+ });
41
+ const wait = async predicate => {
42
+ const deadline = Date.now() + 15000;
43
+ while (Date.now() < deadline) {
44
+ const index = messages.findIndex(predicate);
45
+ if (index !== -1) return messages.splice(index, 1)[0];
46
+ await new Promise(resolve => setTimeout(resolve, 10));
47
+ }
48
+ assert.fail(`Timed out waiting for server: ${JSON.stringify(messages)}`);
49
+ };
50
+ const request = async (method, params) => {
51
+ const id = nextId++;
52
+ send({ id, method, params });
53
+ const message = await wait(message => message.id === id);
54
+ assert.equal(message.error, undefined);
55
+ return message.result;
56
+ };
57
+ const initialize = async () => {
58
+ await request('initialize', {
59
+ processId: process.pid,
60
+ capabilities: {},
61
+ workspaceFolders: [{ uri: pathToFileURL(cwd).href, name: 'lifecycle' }],
62
+ });
63
+ send({ method: 'initialized', params: {} });
64
+ await wait(message => message.method === NOTIFICATION_MODULE_GRAPH_BUILT);
65
+ };
66
+ return { child, send, wait, request, initialize };
67
+ };
68
+
69
+ test('source updates refresh descriptors and notify clients with unchanged empty diagnostics', async t => {
70
+ const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), 'knip-ls-graph-update-')));
71
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
72
+ writeFileSync(path.join(cwd, 'package.json'), '{"name":"graph-update","type":"module"}');
73
+ writeFileSync(path.join(cwd, 'knip.json'), '{"entry":["index.js"],"project":["*.js"]}');
74
+ writeFileSync(path.join(cwd, 'index.js'), "import { used } from './value.js'; console.log(used);");
75
+ writeFileSync(path.join(cwd, 'value.js'), 'export const used = 1;');
76
+ const server = startServer(cwd, t);
77
+ await server.initialize();
78
+ const uri = pathToFileURL(path.join(cwd, 'value.js')).href;
79
+ const initial = await server.request(REQUEST_FILE_NODE, { uri });
80
+ const before = await server.request(REQUEST_RESULTS);
81
+ for (const issues of Object.values(before.issues)) assert.deepEqual(issues, {});
82
+ writeFileSync(path.join(cwd, 'value.js'), '\nexport const used = 1;');
83
+ server.send({ method: 'workspace/didChangeWatchedFiles', params: { changes: [{ uri, type: 2 }] } });
84
+ await server.wait(message => message.method === NOTIFICATION_MODULE_GRAPH_BUILT);
85
+ const updated = await server.request(REQUEST_FILE_NODE, { uri });
86
+ assert.equal(updated.exports[0].line, initial.exports[0].line + 1);
87
+ assert.deepEqual((await server.request(REQUEST_RESULTS)).issues, before.issues);
88
+ });
89
+
90
+ test('older local Knip sessions keep the existing manifest reload fallback without exiting the server', async t => {
91
+ const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), 'knip-ls-legacy-')));
92
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
93
+ cpSync(
94
+ new URL('../../vscode-knip/test/fixtures/version-bump/knip', import.meta.url),
95
+ path.join(cwd, 'node_modules/knip'),
96
+ {
97
+ recursive: true,
98
+ }
99
+ );
100
+ writeFileSync(path.join(cwd, 'package.json'), '{"name":"legacy","type":"module"}');
101
+ writeFileSync(path.join(cwd, 'index.js'), '');
102
+ const server = startServer(cwd, t);
103
+ await server.initialize();
104
+ const before = await server.request(REQUEST_RESULTS);
105
+ assert.ok(before.issues.exports['index.js']['knip-version-1.0.0']);
106
+ writeFileSync(path.join(cwd, 'package.json'), '{"name":"legacy","type":"module","description":"updated"}');
107
+ server.send({
108
+ method: 'workspace/didChangeWatchedFiles',
109
+ params: { changes: [{ uri: pathToFileURL(path.join(cwd, 'package.json')).href, type: 2 }] },
110
+ });
111
+ await server.wait(message => message.method === NOTIFICATION_MODULE_GRAPH_BUILT);
112
+ assert.deepEqual((await server.request(REQUEST_RESULTS)).issues, before.issues);
113
+ assert.equal(server.child.exitCode, null);
114
+ });