@knip/language-server 3.1.1 → 3.1.3
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/README.md +2 -1
- package/package.json +4 -4
- package/src/server.js +32 -8
- package/test/fixtures/lifecycle.js +14 -0
- package/test/lifecycle.test.js +269 -0
- package/test/refresh.test.js +114 -0
package/README.md
CHANGED
|
@@ -108,7 +108,8 @@ Optionally, the client can implement:
|
|
|
108
108
|
- Show contention: ambiguous or shadowed exports behind re-exports
|
|
109
109
|
- Show contention: converged (diamond-shaped) re-export paths
|
|
110
110
|
|
|
111
|
-
Each `File.contention[name].sites` entry describes where contention occurs, its
|
|
111
|
+
Each `File.contention[name].sites` entry describes where contention occurs, its
|
|
112
|
+
kind, and the originating bindings.
|
|
112
113
|
|
|
113
114
|
Example:
|
|
114
115
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knip/language-server",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
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
|
-
"
|
|
22
|
-
"vscode-languageserver
|
|
23
|
-
"
|
|
21
|
+
"knip": "^6.44.1",
|
|
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,11 @@ export class LanguageServer {
|
|
|
99
99
|
/** @type {undefined | Session} */
|
|
100
100
|
session;
|
|
101
101
|
|
|
102
|
+
revision = 0;
|
|
103
|
+
|
|
104
|
+
/** @type {undefined | number} */
|
|
105
|
+
failedRevision;
|
|
106
|
+
|
|
102
107
|
/** @type {Rules} */
|
|
103
108
|
rules = {};
|
|
104
109
|
|
|
@@ -152,8 +157,9 @@ export class LanguageServer {
|
|
|
152
157
|
});
|
|
153
158
|
|
|
154
159
|
this.connection.onInitialized(async () => {
|
|
160
|
+
const revision = this.revision;
|
|
155
161
|
const config = await this.getConfig();
|
|
156
|
-
if (config?.deferSession !== true) this.start();
|
|
162
|
+
if (revision === this.revision && config?.deferSession !== true) this.start();
|
|
157
163
|
});
|
|
158
164
|
|
|
159
165
|
this.connection.onRequest(REQUEST_START, () => this.start());
|
|
@@ -244,10 +250,13 @@ export class LanguageServer {
|
|
|
244
250
|
|
|
245
251
|
async start() {
|
|
246
252
|
if (this.session) return;
|
|
253
|
+
const revision = ++this.revision;
|
|
247
254
|
|
|
248
255
|
try {
|
|
249
256
|
const config = await this.getConfig();
|
|
257
|
+
if (revision !== this.revision) return;
|
|
250
258
|
const knip = await this.#resolveKnipSession();
|
|
259
|
+
if (revision !== this.revision) return;
|
|
251
260
|
|
|
252
261
|
const configFilePath = config?.configFilePath
|
|
253
262
|
? path.isAbsolute(config.configFilePath)
|
|
@@ -257,31 +266,39 @@ export class LanguageServer {
|
|
|
257
266
|
|
|
258
267
|
this.connection.console.log('Creating options');
|
|
259
268
|
const options = await knip.createOptions({ cwd: this.cwd, isSession: true, args: { config: configFilePath } });
|
|
269
|
+
if (revision !== this.revision) return;
|
|
260
270
|
this.rules = options.rules;
|
|
261
271
|
|
|
262
272
|
this.connection.console.log('Building module graph...');
|
|
263
273
|
const start = Date.now();
|
|
264
274
|
const session = await knip.createSession(options);
|
|
275
|
+
if (revision !== this.revision) return;
|
|
265
276
|
this.connection.console.log(`Finished building module graph (${Date.now() - start}ms)`);
|
|
266
277
|
|
|
267
278
|
this.session = session;
|
|
268
279
|
this.publishDiagnostics(this.buildDiagnostics(session.getIssues().issues, config, this.rules));
|
|
269
280
|
this.connection.sendNotification(NOTIFICATION_MODULE_GRAPH_BUILT, { duration: Date.now() - start });
|
|
270
281
|
} catch (_error) {
|
|
282
|
+
if (revision !== this.revision) return;
|
|
283
|
+
this.failedRevision = revision;
|
|
271
284
|
this.connection.console.error(`Error: ${_error}`);
|
|
272
285
|
}
|
|
273
286
|
}
|
|
274
287
|
|
|
275
288
|
stop() {
|
|
289
|
+
this.revision++;
|
|
276
290
|
this.session = undefined;
|
|
277
291
|
this.fileCache = undefined;
|
|
292
|
+
this.packageJsonCache = undefined;
|
|
293
|
+
this.cycleDiagnostics.clear();
|
|
294
|
+
this.issuesByUri.clear();
|
|
278
295
|
for (const uri of this.published) this.connection.sendDiagnostics({ uri, diagnostics: [] });
|
|
279
296
|
this.published.clear();
|
|
280
297
|
}
|
|
281
298
|
|
|
282
|
-
restart() {
|
|
299
|
+
async restart() {
|
|
283
300
|
this.stop();
|
|
284
|
-
this.start();
|
|
301
|
+
await this.start();
|
|
285
302
|
}
|
|
286
303
|
|
|
287
304
|
getResults() {
|
|
@@ -296,7 +313,8 @@ export class LanguageServer {
|
|
|
296
313
|
async handleFileChanges(params) {
|
|
297
314
|
this.fileCache = undefined;
|
|
298
315
|
this.packageJsonCache = undefined;
|
|
299
|
-
|
|
316
|
+
const session = this.session;
|
|
317
|
+
if (!session && this.failedRevision !== this.revision) return;
|
|
300
318
|
|
|
301
319
|
const cwd = this.cwd ?? process.cwd();
|
|
302
320
|
|
|
@@ -312,20 +330,26 @@ export class LanguageServer {
|
|
|
312
330
|
changes.push({ type, filePath });
|
|
313
331
|
}
|
|
314
332
|
|
|
315
|
-
if (changes.length === 0) return;
|
|
333
|
+
if (!session || changes.length === 0) return;
|
|
316
334
|
|
|
317
335
|
try {
|
|
318
|
-
const result = await
|
|
336
|
+
const result = await session.handleFileChanges(changes);
|
|
319
337
|
|
|
320
|
-
if (!result) return;
|
|
338
|
+
if (!result || this.session !== session) return;
|
|
339
|
+
this.fileCache = undefined;
|
|
340
|
+
this.packageJsonCache = undefined;
|
|
341
|
+
this.cycleDiagnostics.clear();
|
|
321
342
|
|
|
322
343
|
this.connection.console.log(
|
|
323
344
|
`Module graph updated (${Math.floor(result.duration)}ms • ${(result.mem / 1024 / 1024).toFixed(2)}M)`
|
|
324
345
|
);
|
|
325
346
|
|
|
326
347
|
const config = await this.getConfig();
|
|
327
|
-
|
|
348
|
+
if (this.session !== session) return;
|
|
349
|
+
this.publishDiagnostics(this.buildDiagnostics(session.getIssues().issues, config, this.rules));
|
|
350
|
+
this.connection.sendNotification(NOTIFICATION_MODULE_GRAPH_BUILT, { duration: result.duration });
|
|
328
351
|
} catch (_error) {
|
|
352
|
+
if (this.session !== session) return;
|
|
329
353
|
this.connection.console.error(`Error handling file changes: ${_error}`);
|
|
330
354
|
}
|
|
331
355
|
}
|
|
@@ -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,269 @@
|
|
|
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 changed = file => ({ changes: [{ uri: pathToFileURL(path.join(cwd, file)).href, type: 2 }] });
|
|
31
|
+
const updated = { duration: 1, mem: 1024 };
|
|
32
|
+
const config = { editor: { exports: { quickfix: { enabled: true } } } };
|
|
33
|
+
const options = { rules: { exports: 'warn' } };
|
|
34
|
+
const deferred = () => Promise.withResolvers();
|
|
35
|
+
|
|
36
|
+
const createSession = (name = 'current') => ({
|
|
37
|
+
getIssues: () => ({ issues: {} }),
|
|
38
|
+
getResults: () => ({ name }),
|
|
39
|
+
describeFile: () => ({ name, metrics: {} }),
|
|
40
|
+
describePackageJson: () => ({ dependenciesUsage: new Map([[name, { imports: [] }]]) }),
|
|
41
|
+
handleFileChanges: async () => updated,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const setup = t => {
|
|
45
|
+
t.mock.method(connection, 'listen', () => {});
|
|
46
|
+
t.mock.method(connection.workspace, 'getConfiguration', async () => config);
|
|
47
|
+
t.mock.method(connection.console, 'log', () => {});
|
|
48
|
+
t.mock.method(connection.console, 'error', () => {});
|
|
49
|
+
t.mock.method(connection, 'sendDiagnostics', () => {});
|
|
50
|
+
t.mock.method(connection, 'sendNotification', () => {});
|
|
51
|
+
t.mock.method(connection, 'onInitialized');
|
|
52
|
+
t.mock.method(knip, 'createOptions', async () => options);
|
|
53
|
+
t.mock.method(knip, 'createSession', async () => createSession());
|
|
54
|
+
const originalConsole = { ...console };
|
|
55
|
+
const server = new LanguageServer();
|
|
56
|
+
Object.assign(console, originalConsole);
|
|
57
|
+
server.cwd = cwd;
|
|
58
|
+
return server;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
test('stop cancels automatic startup while initialization configuration is pending', async t => {
|
|
62
|
+
const server = setup(t);
|
|
63
|
+
const pending = deferred();
|
|
64
|
+
t.mock.method(server, 'getConfig', () => pending.promise);
|
|
65
|
+
const start = t.mock.method(server, 'start');
|
|
66
|
+
const initialized = connection.onInitialized.mock.calls[0].arguments[0]();
|
|
67
|
+
server.stop();
|
|
68
|
+
pending.resolve(config);
|
|
69
|
+
await initialized;
|
|
70
|
+
for (const call of start.mock.calls) await call.result;
|
|
71
|
+
assert.equal(server.getResults(), null);
|
|
72
|
+
assert.equal(connection.sendNotification.mock.callCount(), 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
for (const phase of ['createOptions', 'createSession']) {
|
|
76
|
+
test(`stop discards a pending ${phase}`, { timeout: 3000 }, async t => {
|
|
77
|
+
const server = setup(t);
|
|
78
|
+
const entered = deferred();
|
|
79
|
+
const pending = deferred();
|
|
80
|
+
t.mock.method(knip, phase, () => {
|
|
81
|
+
entered.resolve();
|
|
82
|
+
return pending.promise;
|
|
83
|
+
});
|
|
84
|
+
const start = server.start();
|
|
85
|
+
await entered.promise;
|
|
86
|
+
server.stop();
|
|
87
|
+
pending.resolve(phase === 'createOptions' ? options : createSession('obsolete'));
|
|
88
|
+
await start;
|
|
89
|
+
assert.equal(server.getResults(), null);
|
|
90
|
+
assert.equal(server.getFileDescriptor(filePath), SESSION_LOADING);
|
|
91
|
+
assert.equal(connection.sendNotification.mock.callCount(), 0);
|
|
92
|
+
assert.equal(connection.sendDiagnostics.mock.callCount(), 0);
|
|
93
|
+
if (phase === 'createOptions') assert.equal(knip.createSession.mock.callCount(), 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test(`restart keeps the new session when obsolete ${phase} finishes last`, { timeout: 3000 }, async t => {
|
|
97
|
+
const server = setup(t);
|
|
98
|
+
const entered = deferred();
|
|
99
|
+
const pending = deferred();
|
|
100
|
+
let calls = 0;
|
|
101
|
+
t.mock.method(knip, phase, () => {
|
|
102
|
+
if (++calls > 1) return phase === 'createOptions' ? options : createSession();
|
|
103
|
+
entered.resolve();
|
|
104
|
+
return pending.promise;
|
|
105
|
+
});
|
|
106
|
+
const start = server.start();
|
|
107
|
+
await entered.promise;
|
|
108
|
+
await server.restart();
|
|
109
|
+
assert.deepEqual(server.getResults(), { name: 'current' });
|
|
110
|
+
pending.resolve(phase === 'createOptions' ? { rules: { exports: 'error' } } : createSession('obsolete'));
|
|
111
|
+
await start;
|
|
112
|
+
assert.deepEqual(server.getResults(), { name: 'current' });
|
|
113
|
+
assert.deepEqual(server.rules, options.rules);
|
|
114
|
+
assert.equal(connection.sendNotification.mock.callCount(), 1);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test('overlapping starts retain the latest session and ignore obsolete startup errors', { timeout: 3000 }, async t => {
|
|
119
|
+
const server = setup(t);
|
|
120
|
+
const entered = deferred();
|
|
121
|
+
const pending = deferred();
|
|
122
|
+
let calls = 0;
|
|
123
|
+
t.mock.method(knip, 'createSession', () => {
|
|
124
|
+
if (++calls > 1) return createSession();
|
|
125
|
+
entered.resolve();
|
|
126
|
+
return pending.promise;
|
|
127
|
+
});
|
|
128
|
+
const start = server.start();
|
|
129
|
+
await entered.promise;
|
|
130
|
+
await server.start();
|
|
131
|
+
pending.reject(new Error('obsolete startup failure'));
|
|
132
|
+
await start;
|
|
133
|
+
assert.deepEqual(server.getResults(), { name: 'current' });
|
|
134
|
+
assert.equal(connection.console.error.mock.callCount(), 0);
|
|
135
|
+
assert.equal(connection.sendNotification.mock.callCount(), 1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
for (const file of ['knip.json', 'package.json']) {
|
|
139
|
+
test(`${file} change restarts after a failed start`, { timeout: 3000 }, async t => {
|
|
140
|
+
const server = setup(t);
|
|
141
|
+
let calls = 0;
|
|
142
|
+
t.mock.method(knip, 'createOptions', async () => {
|
|
143
|
+
if (++calls === 1) throw new Error('invalid config');
|
|
144
|
+
return options;
|
|
145
|
+
});
|
|
146
|
+
await server.start();
|
|
147
|
+
assert.equal(server.getResults(), null);
|
|
148
|
+
assert.equal(connection.console.error.mock.callCount(), 1);
|
|
149
|
+
await server.handleFileChanges(changed(file));
|
|
150
|
+
assert.equal(calls, 2);
|
|
151
|
+
assert.deepEqual(server.getResults(), { name: 'current' });
|
|
152
|
+
assert.equal(connection.sendNotification.mock.callCount(), 1);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
test('config change does not start a server stopped after a failed start', { timeout: 3000 }, async t => {
|
|
157
|
+
const server = setup(t);
|
|
158
|
+
let calls = 0;
|
|
159
|
+
t.mock.method(knip, 'createOptions', async () => {
|
|
160
|
+
calls++;
|
|
161
|
+
throw new Error('invalid config');
|
|
162
|
+
});
|
|
163
|
+
await server.start();
|
|
164
|
+
server.stop();
|
|
165
|
+
await server.handleFileChanges(changed('knip.json'));
|
|
166
|
+
assert.equal(calls, 1);
|
|
167
|
+
assert.equal(server.getResults(), null);
|
|
168
|
+
assert.equal(connection.sendNotification.mock.callCount(), 0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('source change after a failed start neither restarts nor updates', { timeout: 3000 }, async t => {
|
|
172
|
+
const server = setup(t);
|
|
173
|
+
let calls = 0;
|
|
174
|
+
t.mock.method(knip, 'createOptions', async () => {
|
|
175
|
+
calls++;
|
|
176
|
+
throw new Error('invalid config');
|
|
177
|
+
});
|
|
178
|
+
await server.start();
|
|
179
|
+
await server.handleFileChanges(changes);
|
|
180
|
+
assert.equal(calls, 1);
|
|
181
|
+
assert.equal(server.getResults(), null);
|
|
182
|
+
assert.equal(connection.console.error.mock.callCount(), 1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('graph updates invalidate descriptors requested during the update and notify without diagnostics', async t => {
|
|
186
|
+
const server = setup(t);
|
|
187
|
+
let session = createSession('before');
|
|
188
|
+
const pending = deferred();
|
|
189
|
+
server.session = {
|
|
190
|
+
...session,
|
|
191
|
+
describeFile: () => session.describeFile(),
|
|
192
|
+
describePackageJson: () => session.describePackageJson(),
|
|
193
|
+
handleFileChanges: () => pending.promise,
|
|
194
|
+
};
|
|
195
|
+
const update = server.handleFileChanges(changes);
|
|
196
|
+
assert.equal(server.getFileDescriptor(filePath).name, 'before');
|
|
197
|
+
assert.deepEqual(Object.keys(server.getPackageJsonDescriptor().dependenciesUsage), ['before']);
|
|
198
|
+
session = createSession('after');
|
|
199
|
+
pending.resolve(updated);
|
|
200
|
+
await update;
|
|
201
|
+
assert.equal(server.getFileDescriptor(filePath).name, 'after');
|
|
202
|
+
assert.deepEqual(Object.keys(server.getPackageJsonDescriptor().dependenciesUsage), ['after']);
|
|
203
|
+
assert.equal(connection.sendDiagnostics.mock.callCount(), 0);
|
|
204
|
+
assert.deepEqual(connection.sendNotification.mock.calls[0]?.arguments, [
|
|
205
|
+
NOTIFICATION_MODULE_GRAPH_BUILT,
|
|
206
|
+
{ duration: updated.duration },
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('stop clears package descriptors before another session starts', async t => {
|
|
211
|
+
const server = setup(t);
|
|
212
|
+
server.session = createSession('obsolete');
|
|
213
|
+
assert.deepEqual(Object.keys(server.getPackageJsonDescriptor().dependenciesUsage), ['obsolete']);
|
|
214
|
+
server.stop();
|
|
215
|
+
await server.start();
|
|
216
|
+
assert.deepEqual(Object.keys(server.getPackageJsonDescriptor().dependenciesUsage), ['current']);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
for (const replaced of [false, true]) {
|
|
220
|
+
for (const phase of ['update', 'configuration']) {
|
|
221
|
+
test(`discard an obsolete update awaiting ${phase} after ${replaced ? 'replacement' : 'stop'}`, async t => {
|
|
222
|
+
const server = setup(t);
|
|
223
|
+
const pending = deferred();
|
|
224
|
+
const entered = deferred();
|
|
225
|
+
const old = createSession('obsolete');
|
|
226
|
+
server.session = old;
|
|
227
|
+
if (phase === 'update') old.handleFileChanges = () => pending.promise;
|
|
228
|
+
else {
|
|
229
|
+
t.mock.method(server, 'getConfig', () => {
|
|
230
|
+
entered.resolve();
|
|
231
|
+
return pending.promise;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const update = server.handleFileChanges(changes);
|
|
235
|
+
if (phase === 'configuration') await entered.promise;
|
|
236
|
+
server.stop();
|
|
237
|
+
if (replaced) {
|
|
238
|
+
server.session = createSession();
|
|
239
|
+
server.session.getIssues = () => ({ issues: { files: { [filePath]: { file: { type: 'files', filePath } } } } });
|
|
240
|
+
server.getFileDescriptor(filePath);
|
|
241
|
+
server.getPackageJsonDescriptor();
|
|
242
|
+
}
|
|
243
|
+
const file = server.fileCache;
|
|
244
|
+
const manifest = server.packageJsonCache;
|
|
245
|
+
pending.resolve(phase === 'update' ? updated : config);
|
|
246
|
+
await update;
|
|
247
|
+
assert.equal(server.fileCache, file);
|
|
248
|
+
assert.equal(server.packageJsonCache, manifest);
|
|
249
|
+
assert.equal(connection.sendDiagnostics.mock.callCount(), 0);
|
|
250
|
+
assert.equal(connection.sendNotification.mock.callCount(), 0);
|
|
251
|
+
assert.equal(connection.console.error.mock.callCount(), 0);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
test('obsolete update errors cannot affect the replacement session', async t => {
|
|
257
|
+
const server = setup(t);
|
|
258
|
+
await server.start();
|
|
259
|
+
const pending = deferred();
|
|
260
|
+
server.session.handleFileChanges = () => pending.promise;
|
|
261
|
+
const update = server.handleFileChanges(changes);
|
|
262
|
+
await server.restart();
|
|
263
|
+
pending.reject(new Error('obsolete update failure'));
|
|
264
|
+
await update;
|
|
265
|
+
assert.deepEqual(server.getResults(), { name: 'current' });
|
|
266
|
+
assert.deepEqual(server.rules, options.rules);
|
|
267
|
+
assert.equal(connection.console.error.mock.callCount(), 0);
|
|
268
|
+
assert.equal(connection.sendNotification.mock.callCount(), 2);
|
|
269
|
+
});
|
|
@@ -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
|
+
});
|