@knip/language-server 3.0.6 → 3.1.1

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 CHANGED
@@ -105,8 +105,10 @@ with direct links to their usage locations.
105
105
  Optionally, the client can implement:
106
106
 
107
107
  - Follow cursor between open document and highlight export in view
108
- - Show contention: naming conflicts through re-exports
109
- - Show contention: branched/diamond-shaped re-export structures
108
+ - Show contention: ambiguous or shadowed exports behind re-exports
109
+ - Show contention: converged (diamond-shaped) re-export paths
110
+
111
+ Each `File.contention[name].sites` entry describes where contention occurs, its kind, and the originating bindings.
110
112
 
111
113
  Example:
112
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knip/language-server",
3
- "version": "3.0.6",
3
+ "version": "3.1.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/webpro-nl/knip.git",
@@ -18,14 +18,15 @@
18
18
  "./constants": "./src/constants.js"
19
19
  },
20
20
  "dependencies": {
21
- "vscode-languageserver": "^10.1.0",
22
- "vscode-languageserver-textdocument": "^1.0.13",
23
- "knip": "^6.32.0"
21
+ "vscode-languageserver": "^10.1.1",
22
+ "vscode-languageserver-textdocument": "^1.0.14",
23
+ "knip": "^6.36.0"
24
24
  },
25
25
  "engines": {
26
26
  "node": "^20.19.0 || >=22.12.0"
27
27
  },
28
28
  "scripts": {
29
+ "test": "node --test",
29
30
  "release": "release-it -c ../../.release-it.json"
30
31
  }
31
32
  }
@@ -40,6 +40,15 @@ export const createRemoveDependencyEdit = (document, uri, issue) => {
40
40
  const range = { start: { line: lineIndex, character: 0 }, end: { line: lineIndex + 1, character: 0 } };
41
41
  const edits = [TextEdit.del(range)];
42
42
 
43
+ if (lineIndex > 0 && !document.getText(range).trimEnd().endsWith(',')) {
44
+ const prevRange = { start: { line: lineIndex - 1, character: 0 }, end: { line: lineIndex, character: 0 } };
45
+ const prevLine = document.getText(prevRange).trimEnd();
46
+ if (prevLine.endsWith(',')) {
47
+ const start = { line: lineIndex - 1, character: prevLine.length - 1 };
48
+ edits.push(TextEdit.del({ start, end: { line: lineIndex - 1, character: prevLine.length } }));
49
+ }
50
+ }
51
+
43
52
  return { changes: { [uri]: edits } };
44
53
  } catch (_error) {
45
54
  return null;
@@ -5,14 +5,14 @@ import { DiagnosticSeverity, DiagnosticTag } from 'vscode-languageserver/node';
5
5
  * @import { Diagnostic } from 'vscode-languageserver';
6
6
  * @import { TextDocument } from 'vscode-languageserver-textdocument';
7
7
  * @import { Config } from './types.js';
8
- * @import { Issue, Rules } from 'knip/session';
8
+ * @import { Issue, IssueType, Rules } from 'knip/session';
9
9
  */
10
10
 
11
+ /** Superseded by `editor.severity`; kept until the settings are removed */
11
12
  const dimmedIssueTypes = {
12
13
  exports: 'dimExports',
13
14
  types: 'dimTypes',
14
15
  enumMembers: 'dimEnumMembers',
15
- classMembers: 'dimClassMembers',
16
16
  duplicates: 'dimDuplicates',
17
17
  };
18
18
 
@@ -23,6 +23,57 @@ const SEVERITY = {
23
23
  hint: DiagnosticSeverity.Hint,
24
24
  };
25
25
 
26
+ const ABSOLUTE_SEVERITY = {
27
+ error: DiagnosticSeverity.Error,
28
+ warning: DiagnosticSeverity.Warning,
29
+ information: DiagnosticSeverity.Information,
30
+ hint: DiagnosticSeverity.Hint,
31
+ };
32
+
33
+ /** Loudest to quietest, so `downgrade` and `upgrade` can step along it */
34
+ const SEVERITY_ORDER = [
35
+ DiagnosticSeverity.Error,
36
+ DiagnosticSeverity.Warning,
37
+ DiagnosticSeverity.Information,
38
+ DiagnosticSeverity.Hint,
39
+ ];
40
+
41
+ /**
42
+ * @param {DiagnosticSeverity} severity
43
+ * @param {number} steps
44
+ * @returns {DiagnosticSeverity}
45
+ */
46
+ const step = (severity, steps) => {
47
+ const index = SEVERITY_ORDER.indexOf(severity);
48
+ if (index === -1) return severity;
49
+ return SEVERITY_ORDER[Math.min(SEVERITY_ORDER.length - 1, Math.max(0, index + steps))];
50
+ };
51
+
52
+ /**
53
+ * Editor-only: `rules` decides what fails a run, this decides how the editor renders it.
54
+ * Most specific wins: the issue type, then a dim setting, then `*`, then `rules`.
55
+ * @param {IssueType} type
56
+ * @param {DiagnosticSeverity} base severity `rules` would give this issue type
57
+ * @param {Config} config
58
+ * @returns {DiagnosticSeverity}
59
+ */
60
+ const resolveSeverity = (type, base, config) => {
61
+ const configured = config.editor.severity;
62
+ const isScalar = typeof configured === 'string';
63
+
64
+ const dimSetting = dimmedIssueTypes[type];
65
+ const isDimmed = dimSetting ? config.editor.exports.highlight[dimSetting] === true : false;
66
+
67
+ const value =
68
+ (isScalar ? undefined : configured?.[type]) ??
69
+ (isDimmed ? 'hint' : undefined) ??
70
+ (isScalar ? configured : configured?.['*']);
71
+
72
+ if (value === 'downgrade') return step(base, 1);
73
+ if (value === 'upgrade') return step(base, -1);
74
+ return ABSOLUTE_SEVERITY[value] ?? base;
75
+ };
76
+
26
77
  /**
27
78
  * @param {Issue} issue
28
79
  * @param {Rules} rules
@@ -31,9 +82,14 @@ const SEVERITY = {
31
82
  * @returns {Diagnostic}
32
83
  */
33
84
  export const issueToDiagnostic = (issue, rules, config, document) => {
85
+ // unused files are informational by default: the whole file is the finding, not a symbol in it
86
+ const base = issue.type === 'files' ? DiagnosticSeverity.Information : SEVERITY[rules[issue.type]];
87
+ const severity = resolveSeverity(issue.type, base, config);
88
+
34
89
  if (issue.type === 'files' && document) {
35
90
  return {
36
- severity: DiagnosticSeverity.Information,
91
+ severity,
92
+ tags: severity === DiagnosticSeverity.Hint ? [DiagnosticTag.Unnecessary] : undefined,
37
93
  range: {
38
94
  start: { line: 0, character: 0 },
39
95
  end: { line: 0, character: 1 },
@@ -44,16 +100,8 @@ export const issueToDiagnostic = (issue, rules, config, document) => {
44
100
  };
45
101
  }
46
102
 
47
- /** @type {DiagnosticSeverity} */
48
- let severity = SEVERITY[rules[issue.type]];
49
-
50
- /** @type {DiagnosticTag[]} */
51
- const tags = [];
52
-
53
- if (dimmedIssueTypes[issue.type] && config.editor.exports.highlight[dimmedIssueTypes[issue.type]]) {
54
- severity = DiagnosticSeverity.Hint;
55
- tags.push(DiagnosticTag.Unnecessary);
56
- }
103
+ // a hint is nearly invisible without it, and this is what makes `hint` fade the code
104
+ const tags = severity === DiagnosticSeverity.Hint ? [DiagnosticTag.Unnecessary] : [];
57
105
 
58
106
  const line = Math.max(0, (issue.line ?? 1) - 1);
59
107
  const start = Math.max(0, (issue.col ?? 0) - 1);
package/src/server.js CHANGED
@@ -43,6 +43,7 @@ function readKnipVersion(resolvedPath) {
43
43
  const DEFAULT_CONFIG = {
44
44
  deferSession: false,
45
45
  editor: {
46
+ severity: 'default',
46
47
  exports: {
47
48
  codelens: { enabled: true },
48
49
  hover: { enabled: true, includeImportLocationSnippet: false, maxSnippets: 10, timeout: 300 },
@@ -51,7 +52,6 @@ const DEFAULT_CONFIG = {
51
52
  dimExports: false,
52
53
  dimTypes: false,
53
54
  dimEnumMembers: false,
54
- dimClassMembers: false,
55
55
  dimDuplicates: false,
56
56
  },
57
57
  },
@@ -237,7 +237,7 @@ export class LanguageServer {
237
237
  } catch {}
238
238
  }
239
239
  this.connection.console.log(
240
- `Using bundled knip${readKnipVersion(createRequire(__filename).resolve('knip/session'))}`
240
+ `Using bundled knip${readKnipVersion(createRequire(import.meta.url).resolve('knip/session'))}`
241
241
  );
242
242
  return { createOptions, createSession };
243
243
  }
package/src/types.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { Issue, IssueType } from 'knip/session';
2
2
 
3
+ export type EditorSeverity = 'default' | 'error' | 'warning' | 'information' | 'hint' | 'downgrade' | 'upgrade';
4
+
3
5
  export type Config = {
4
6
  deferSession: boolean;
5
7
  configFilePath?: string;
6
8
  editor: {
9
+ severity: EditorSeverity | Partial<Record<IssueType | '*', EditorSeverity>>;
7
10
  exports: {
8
11
  codelens: {
9
12
  enabled: boolean;
@@ -22,7 +25,6 @@ export type Config = {
22
25
  dimExports: boolean;
23
26
  dimTypes: boolean;
24
27
  dimEnumMembers: boolean;
25
- dimClassMembers: boolean;
26
28
  dimDuplicates: boolean;
27
29
  };
28
30
  };
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { TextDocument } from 'vscode-languageserver-textdocument';
4
+ import { createRemoveDependencyEdit } from '../src/code-actions.js';
5
+
6
+ const uri = 'file:///project/package.json';
7
+
8
+ const manifest = (eol = '\n') =>
9
+ [
10
+ '{',
11
+ ' "name": "project",',
12
+ ' "dependencies": {',
13
+ ' "react": "^19.0.0",',
14
+ ' "lodash": "^4.17.21",',
15
+ ' "chalk": "^5.0.0"',
16
+ ' },',
17
+ ' "devDependencies": {',
18
+ ' "typescript": "^5.0.0"',
19
+ ' }',
20
+ '}',
21
+ '',
22
+ ].join(eol);
23
+
24
+ const remove = (text, symbol) => {
25
+ const document = TextDocument.create(uri, 'json', 1, text);
26
+ const line = text.split(/\r?\n/).findIndex(l => l.includes(`"${symbol}"`)) + 1;
27
+ const edit = createRemoveDependencyEdit(document, uri, { type: 'dependencies', symbol, line });
28
+ return TextDocument.applyEdits(document, edit.changes[uri]);
29
+ };
30
+
31
+ for (const [label, eol] of [
32
+ ['LF', '\n'],
33
+ ['CRLF', '\r\n'],
34
+ ]) {
35
+ test(`removes first dependency (${label})`, () => {
36
+ const result = remove(manifest(eol), 'react');
37
+ assert.deepEqual(JSON.parse(result).dependencies, { lodash: '^4.17.21', chalk: '^5.0.0' });
38
+ });
39
+
40
+ test(`removes middle dependency (${label})`, () => {
41
+ const result = remove(manifest(eol), 'lodash');
42
+ assert.deepEqual(JSON.parse(result).dependencies, { react: '^19.0.0', chalk: '^5.0.0' });
43
+ });
44
+
45
+ test(`removes last dependency (${label})`, () => {
46
+ const result = remove(manifest(eol), 'chalk');
47
+ assert.deepEqual(JSON.parse(result).dependencies, { react: '^19.0.0', lodash: '^4.17.21' });
48
+ assert.ok(result.includes(`"lodash": "^4.17.21"${eol} }`));
49
+ });
50
+
51
+ test(`removes sole dependency (${label})`, () => {
52
+ const result = remove(manifest(eol), 'typescript');
53
+ assert.deepEqual(JSON.parse(result).devDependencies, {});
54
+ });
55
+ }
@@ -0,0 +1,149 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import assert from 'node:assert/strict';
3
+ import test from 'node:test';
4
+ import { ISSUE_TYPES } from 'knip/session';
5
+ import { DiagnosticSeverity, DiagnosticTag } from 'vscode-languageserver/node';
6
+ import { issueToDiagnostic } from '../src/diagnostics.js';
7
+
8
+ const manifest = JSON.parse(readFileSync(new URL('../../vscode-knip/package.json', import.meta.url), 'utf8'));
9
+ const editorSettings = manifest.contributes.configuration.find(section => section.title === 'Editor').properties;
10
+
11
+ const dimSettings = Object.keys(editorSettings)
12
+ .filter(key => key.includes('.highlight.dim'))
13
+ .map(key => key.slice(key.lastIndexOf('.') + 1));
14
+
15
+ const toIssueType = setting => setting.replace(/^dim/, '').replace(/^./, first => first.toLowerCase());
16
+
17
+ const NO_DIM = Object.fromEntries(dimSettings.map(setting => [setting, false]));
18
+
19
+ /**
20
+ * @param {unknown} [severity]
21
+ * @param {Record<string, boolean>} [highlight]
22
+ */
23
+ const config = (severity, highlight = NO_DIM) => ({ editor: { severity, exports: { highlight } } });
24
+
25
+ const rules = { exports: 'error', types: 'warn', files: 'error', enumMembers: 'error', duplicates: 'error' };
26
+
27
+ const unusedExport = { type: 'exports', symbol: 'unusedThing', line: 3, col: 14, filePath: '/project/module.ts' };
28
+ const unusedType = { ...unusedExport, type: 'types' };
29
+ const unusedFile = { type: 'files', symbol: 'unused.ts', filePath: '/project/unused.ts' };
30
+
31
+ const document = { getText: () => '' };
32
+
33
+ const severityOf = (issue, cfg, doc) => issueToDiagnostic(issue, rules, cfg, doc).severity;
34
+
35
+ test('severity follows the rules by default', () => {
36
+ assert.equal(severityOf(unusedExport, config('default')), DiagnosticSeverity.Error);
37
+ assert.equal(severityOf(unusedType, config('default')), DiagnosticSeverity.Warning);
38
+ });
39
+
40
+ test('severity falls back to the rules when a client sends no setting', () => {
41
+ assert.equal(severityOf(unusedExport, config(undefined)), DiagnosticSeverity.Error);
42
+ });
43
+
44
+ test('a scalar applies to every issue type', () => {
45
+ assert.equal(severityOf(unusedExport, config('warning')), DiagnosticSeverity.Warning);
46
+ assert.equal(severityOf(unusedType, config('information')), DiagnosticSeverity.Information);
47
+ assert.equal(severityOf(unusedFile, config('hint'), document), DiagnosticSeverity.Hint);
48
+ });
49
+
50
+ test('a scalar raises issue types the rules made a warning', () => {
51
+ assert.equal(severityOf(unusedType, config('error')), DiagnosticSeverity.Error);
52
+ });
53
+
54
+ test('downgrade and upgrade step relative to the rules', () => {
55
+ assert.equal(severityOf(unusedExport, config('downgrade')), DiagnosticSeverity.Warning);
56
+ assert.equal(severityOf(unusedType, config('downgrade')), DiagnosticSeverity.Information);
57
+ assert.equal(severityOf(unusedType, config('upgrade')), DiagnosticSeverity.Error);
58
+ });
59
+
60
+ test('downgrade and upgrade stop at the ends of the scale', () => {
61
+ assert.equal(severityOf(unusedExport, config('upgrade')), DiagnosticSeverity.Error);
62
+ assert.equal(severityOf(unusedFile, config({ files: 'hint' }), document), DiagnosticSeverity.Hint);
63
+ assert.equal(severityOf(unusedFile, config({ files: 'downgrade' }), document), DiagnosticSeverity.Hint);
64
+ });
65
+
66
+ test('a map keyed by issue type beats the wildcard', () => {
67
+ const cfg = config({ '*': 'hint', exports: 'error' });
68
+
69
+ assert.equal(severityOf(unusedExport, cfg), DiagnosticSeverity.Error);
70
+ assert.equal(severityOf(unusedType, cfg), DiagnosticSeverity.Hint);
71
+ });
72
+
73
+ test('the wildcard alone covers every issue type', () => {
74
+ const cfg = config({ '*': 'warning' });
75
+
76
+ assert.equal(severityOf(unusedExport, cfg), DiagnosticSeverity.Warning);
77
+ assert.equal(severityOf(unusedType, cfg), DiagnosticSeverity.Warning);
78
+ });
79
+
80
+ test('issue types absent from a map follow the rules', () => {
81
+ const cfg = config({ exports: 'hint' });
82
+
83
+ assert.equal(severityOf(unusedType, cfg), DiagnosticSeverity.Warning);
84
+ });
85
+
86
+ test('unused files are information by default', () => {
87
+ assert.equal(severityOf(unusedFile, config('default'), document), DiagnosticSeverity.Information);
88
+ assert.equal(severityOf(unusedFile, config({ '*': 'warning' }), document), DiagnosticSeverity.Warning);
89
+ });
90
+
91
+ test('a hint fades the code, whichever value produced it', () => {
92
+ for (const severity of ['hint', { exports: 'hint' }, { '*': 'hint' }]) {
93
+ const diagnostic = issueToDiagnostic(unusedExport, rules, config(severity));
94
+
95
+ assert.equal(diagnostic.severity, DiagnosticSeverity.Hint);
96
+ assert.deepEqual(diagnostic.tags, [DiagnosticTag.Unnecessary]);
97
+ }
98
+ });
99
+
100
+ test('every contributed dim setting names a real issue type, and dims it', () => {
101
+ assert.ok(dimSettings.length > 0, 'no dim settings found in the extension manifest');
102
+
103
+ for (const setting of dimSettings) {
104
+ const type = toIssueType(setting);
105
+ assert.ok(ISSUE_TYPES.includes(type), `${setting} names "${type}", which is not a Knip issue type`);
106
+
107
+ const issue = { ...unusedExport, type };
108
+ const diagnostic = issueToDiagnostic(issue, rules, config('default', { ...NO_DIM, [setting]: true }));
109
+
110
+ assert.equal(diagnostic.severity, DiagnosticSeverity.Hint, setting);
111
+ }
112
+ });
113
+
114
+ test('a dim setting still dims, and beats the wildcard', () => {
115
+ const cfg = config({ '*': 'error' }, { ...NO_DIM, dimExports: true });
116
+ const diagnostic = issueToDiagnostic(unusedExport, rules, cfg);
117
+
118
+ assert.equal(diagnostic.severity, DiagnosticSeverity.Hint);
119
+ assert.deepEqual(diagnostic.tags, [DiagnosticTag.Unnecessary]);
120
+ });
121
+
122
+ test('an entry for the issue type beats a dim setting', () => {
123
+ const cfg = config({ exports: 'error' }, { ...NO_DIM, dimExports: true });
124
+
125
+ assert.equal(severityOf(unusedExport, cfg), DiagnosticSeverity.Error);
126
+ });
127
+
128
+ test('a diagnostic carries the position and description of its issue', () => {
129
+ const diagnostic = issueToDiagnostic(unusedExport, rules, config('default'));
130
+
131
+ assert.deepEqual(diagnostic.range, {
132
+ start: { line: 2, character: 13 },
133
+ end: { line: 2, character: 24 },
134
+ });
135
+ assert.equal(diagnostic.message, 'Unused export: unusedThing');
136
+ assert.equal(diagnostic.source, 'knip');
137
+ assert.equal(diagnostic.code, 'exports');
138
+ assert.equal(diagnostic.tags, undefined);
139
+ });
140
+
141
+ test('an unused file is reported at the top of the file', () => {
142
+ const diagnostic = issueToDiagnostic(unusedFile, rules, config('default'), document);
143
+
144
+ assert.deepEqual(diagnostic.range, {
145
+ start: { line: 0, character: 0 },
146
+ end: { line: 0, character: 1 },
147
+ });
148
+ assert.equal(diagnostic.message, 'Unused file');
149
+ });
@@ -0,0 +1,71 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdtempSync, 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 } from '../src/constants.js';
9
+
10
+ const cli = fileURLToPath(new URL('../src/cli.js', import.meta.url));
11
+
12
+ const encode = payload => {
13
+ const body = JSON.stringify({ jsonrpc: '2.0', ...payload });
14
+ return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
15
+ };
16
+
17
+ test('falls back to bundled knip when the project has no local knip', async () => {
18
+ const cwd = mkdtempSync(path.join(tmpdir(), 'knip-ls-'));
19
+ writeFileSync(path.join(cwd, 'package.json'), '{"name":"standalone","main":"index.js"}');
20
+ writeFileSync(path.join(cwd, 'index.js'), '');
21
+
22
+ const server = spawn(process.execPath, [cli, '--stdio'], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
23
+ const logs = [];
24
+ let buffer = '';
25
+
26
+ const done = new Promise((resolve, reject) => {
27
+ const timer = setTimeout(() => reject(new Error(`Timeout. Logs:\n${logs.join('\n')}`)), 30_000);
28
+ server.stdout.on('data', chunk => {
29
+ buffer += chunk;
30
+ for (;;) {
31
+ const match = buffer.match(/Content-Length: (\d+)\r\n\r\n/);
32
+ if (!match || buffer.length < match.index + match[0].length + Number(match[1])) break;
33
+ const start = match.index + match[0].length;
34
+ const msg = JSON.parse(buffer.slice(start, start + Number(match[1])));
35
+ buffer = buffer.slice(start + Number(match[1]));
36
+ if (msg.method === 'window/logMessage') logs.push(msg.params.message);
37
+ if (msg.method === 'workspace/configuration') server.stdin.write(encode({ id: msg.id, result: [{}] }));
38
+ if (msg.method === NOTIFICATION_MODULE_GRAPH_BUILT) {
39
+ clearTimeout(timer);
40
+ resolve();
41
+ }
42
+ }
43
+ });
44
+ });
45
+
46
+ server.stdin.write(
47
+ encode({
48
+ id: 1,
49
+ method: 'initialize',
50
+ params: {
51
+ processId: process.pid,
52
+ capabilities: {},
53
+ workspaceFolders: [{ uri: pathToFileURL(cwd).href, name: 'standalone' }],
54
+ },
55
+ })
56
+ );
57
+ server.stdin.write(encode({ method: 'initialized', params: {} }));
58
+
59
+ try {
60
+ await done;
61
+ } finally {
62
+ server.kill();
63
+ rmSync(cwd, { recursive: true, force: true });
64
+ }
65
+
66
+ assert.ok(
67
+ logs.some(log => log.startsWith('Using bundled knip')),
68
+ logs.join('\n')
69
+ );
70
+ assert.ok(!logs.some(log => log.includes('ReferenceError')), logs.join('\n'));
71
+ });