@knip/language-server 0.0.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 ADDED
@@ -0,0 +1,136 @@
1
+ # Knip Language Server
2
+
3
+ ## Knip
4
+
5
+ Find unused files, dependencies, and exports in your JavaScript/TypeScript
6
+ projects.
7
+
8
+ - Website: [knip.dev][1]
9
+ - GitHub repo: [webpro-nl/knip][2]
10
+ - Follow [@webpro.nl on Bluesky][3] for updates
11
+ - Blogpost: [Knip for Editors & Agents][4]
12
+ - [Sponsor Knip][5]
13
+
14
+ ## Contents
15
+
16
+ - [Configuration][6]
17
+ - [Diagnostics][7]
18
+ - [Code Actions][8]
19
+ - [File Descriptor][9]
20
+ - [Annotations][10]
21
+ - [Export Hover][11]
22
+ - [Imports][12]
23
+ - [Exports][13]
24
+
25
+ ## Configuration
26
+
27
+ Latest version of available settings: [types.d.ts][14]
28
+
29
+ ## Diagnostics
30
+
31
+ Diagnostics should work out of the box.
32
+
33
+ Most [Knip issue types][15] are translated to `Diagnostic` items with a
34
+ `DiagnosticSeverity` and emitted using `this.connection.sendDiagnostics()`. Also
35
+ see [diagnostics.js][16] for details.
36
+
37
+ ## Code Actions
38
+
39
+ Code actions should work out of the box.
40
+
41
+ Some issues/diagnostics have code actions available. Also see
42
+ [code-actions.js][17] for details.
43
+
44
+ ## File Descriptor
45
+
46
+ Clients request the `file` descriptor to get available data for a document by
47
+ sending the `REQUEST_FILE_NODE` request, in short:
48
+
49
+ ```ts
50
+ const file = await this.#client.sendRequest(REQUEST_FILE_NODE, {
51
+ uri: editor.document.uri.toString(),
52
+ });
53
+ ```
54
+
55
+ Type definition for `File`: [session/types.ts][18]
56
+
57
+ The `file` descriptor can be used to implement features like [Annotations][10],
58
+ [Export Hover][11], [Imports][12] and [Exports][13].
59
+
60
+ ### Annotations
61
+
62
+ Annotations (aka "Code Lens" or "Inlay Hint") for exported identifiers can be
63
+ implemented using data from the `file` descriptor.
64
+
65
+ Example:
66
+
67
+ - [registerCodeLensProvider][19]
68
+
69
+ ### Export Hover
70
+
71
+ On hover of an export identifier, the `file` descriptor can be used to render
72
+ import locations for the exported identifier.
73
+
74
+ Optionally, code snippets can be searched for using the provided locations and
75
+ mixed into the rendered list.
76
+
77
+ Example:
78
+
79
+ - [registerHoverProvider → getHoverContent][19]
80
+ - [Collect hover snippets][20]
81
+ - [Render export hover][21]
82
+
83
+ ### Imports
84
+
85
+ The `file` desciptor can be used to display an overview of imports of a document
86
+ with direct links to their definition location.
87
+
88
+ Optionally, the client can implement:
89
+
90
+ - Follow cursor between open document and highlight import in view
91
+ - Show cyclic dependencies
92
+
93
+ Example:
94
+
95
+ - [setupTreeViews + refresh → getFileForTreeViews][19]
96
+ - [Tree View Imports][22]
97
+
98
+ ### Exports
99
+
100
+ The `file` desciptor can be used to display an overview of exports of a document
101
+ with direct links to their usage locations.
102
+
103
+ Optionally, the client can implement:
104
+
105
+ - Follow cursor between open document and highlight export in view
106
+ - Show contention: naming conflicts through re-exports
107
+ - Show contention: branched/diamond-shaped re-export structures
108
+
109
+ Example:
110
+
111
+ - [setupTreeViews + refresh → getFileForTreeViews][19]
112
+ - [Tree View Exports][23]
113
+
114
+ [1]: https://knip.dev
115
+ [2]: https://github.com/webpro-nl/knip
116
+ [3]: https://bsky.app/profile/webpro.nl
117
+ [4]: https://knip.dev/blog/for-editors-and-agents
118
+ [5]: https://knip.dev/sponsors
119
+ [6]: #configuration
120
+ [7]: #diagnostics
121
+ [8]: #code-actions
122
+ [9]: #file-descriptor
123
+ [10]: #annotations
124
+ [11]: #export-hover
125
+ [12]: #imports
126
+ [13]: #exports
127
+ [14]: ./src/types.d.ts
128
+ [15]: https://knip.dev/reference/issue-types
129
+ [16]: ./src/diagnostics.js
130
+ [17]: ./src/code-actions.js
131
+ [18]: ../knip/src/session/types.ts
132
+ [19]: ../vscode-knip/src/index.js
133
+ [20]: ../vscode-knip/src/collect-hover-snippets.js
134
+ [21]: ../vscode-knip/src/render-export-hover.js
135
+ [22]: ../vscode-knip/src/tree-view-imports.js
136
+ [23]: ../vscode-knip/src/tree-view-exports.js
package/license ADDED
@@ -0,0 +1,15 @@
1
+ ISC License (ISC)
2
+
3
+ Copyright 2022-2025 Lars Kappert
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any purpose
6
+ with or without fee is hereby granted, provided that the above copyright notice
7
+ and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
11
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
13
+ OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
14
+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
15
+ THIS SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@knip/language-server",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/types.d.ts",
8
+ "default": "./src/index.js"
9
+ },
10
+ "./constants": "./src/constants.js"
11
+ },
12
+ "dependencies": {
13
+ "vscode-languageserver": "^9.0.1",
14
+ "vscode-languageserver-textdocument": "^1.0.12",
15
+ "knip": "5.75.0"
16
+ },
17
+ "engines": {
18
+ "node": ">=18.18.0"
19
+ }
20
+ }
@@ -0,0 +1,121 @@
1
+ import { DeleteFile, TextEdit } from 'vscode-languageserver/node.js';
2
+
3
+ /**
4
+ * @import { WorkspaceEdit } from 'vscode-languageserver';
5
+ * @import { TextDocument } from 'vscode-languageserver-textdocument';
6
+ * @import { Issue } from 'knip/session';
7
+ */
8
+
9
+ /**
10
+ * @param {TextDocument | undefined} document
11
+ * @param {string} uri
12
+ * @param {Issue} issue
13
+ * @returns {WorkspaceEdit | null}
14
+ */
15
+ export const createRemoveExportEdit = (document, uri, issue) => {
16
+ try {
17
+ if (!document || !issue.fixes?.length) return null;
18
+
19
+ const edits = issue.fixes.map(([start, end]) => {
20
+ return TextEdit.del({ start: document.positionAt(start), end: document.positionAt(end) });
21
+ });
22
+
23
+ return { changes: { [uri]: edits } };
24
+ } catch (_error) {
25
+ return null;
26
+ }
27
+ };
28
+
29
+ /**
30
+ * @param {TextDocument | undefined} document
31
+ * @param {string} uri
32
+ * @param {Issue} issue
33
+ * @returns {WorkspaceEdit | null}
34
+ */
35
+ export const createRemoveDependencyEdit = (document, uri, issue) => {
36
+ try {
37
+ if (!document || issue.line === undefined) return null;
38
+
39
+ const lineIndex = issue.line - 1;
40
+ const range = { start: { line: lineIndex, character: 0 }, end: { line: lineIndex + 1, character: 0 } };
41
+ const edits = [TextEdit.del(range)];
42
+
43
+ return { changes: { [uri]: edits } };
44
+ } catch (_error) {
45
+ return null;
46
+ }
47
+ };
48
+
49
+ /**
50
+ * @param {string} uri
51
+ * @returns {WorkspaceEdit | null}
52
+ */
53
+ export const createDeleteFileEdit = uri => {
54
+ try {
55
+ return {
56
+ documentChanges: [DeleteFile.create(uri, { recursive: false, ignoreIfNotExists: true })],
57
+ };
58
+ } catch (_error) {
59
+ return null;
60
+ }
61
+ };
62
+
63
+ /**
64
+ *
65
+ * @param {TextDocument} document
66
+ * @param {Issue} issue
67
+ * @param {string} tag
68
+ */
69
+ export function createAddJSDocTagEdit(document, issue, tag) {
70
+ try {
71
+ if (!document || issue.line === undefined) return null;
72
+
73
+ const lineIndex = issue.line - 1;
74
+ const lineText = document.getText({
75
+ start: { line: lineIndex, character: 0 },
76
+ end: { line: lineIndex + 1, character: 0 },
77
+ });
78
+ const indent = lineText.match(/^\s*/)?.[0] || '';
79
+
80
+ if (lineIndex > 0) {
81
+ const prevLineText = document.getText({
82
+ start: { line: lineIndex - 1, character: 0 },
83
+ end: { line: lineIndex, character: 0 },
84
+ });
85
+ const trimmedPrev = prevLineText.trim();
86
+
87
+ if (trimmedPrev.startsWith('/**') && trimmedPrev.endsWith('*/')) {
88
+ const content = trimmedPrev.slice(3, -2).trim();
89
+ const range = {
90
+ start: { line: lineIndex - 1, character: 0 },
91
+ end: { line: lineIndex - 1, character: prevLineText.length },
92
+ };
93
+
94
+ let multilineJSDoc = `${indent}/**\n${indent} * ${tag}`;
95
+ if (content) {
96
+ multilineJSDoc += `\n${indent} * ${content}`;
97
+ }
98
+ multilineJSDoc += `\n${indent} */`;
99
+
100
+ return [TextEdit.replace(range, multilineJSDoc)];
101
+ }
102
+
103
+ if (trimmedPrev.endsWith('*/')) {
104
+ for (let i = lineIndex - 2; i >= 0; i--) {
105
+ const text = document.getText({ start: { line: i, character: 0 }, end: { line: i + 1, character: 0 } });
106
+ if (text.trim().startsWith('/**')) {
107
+ const insertPosition = { line: i, character: text.trimEnd().length };
108
+ const tagLine = `\n${indent} * ${tag}`;
109
+ return [TextEdit.insert(insertPosition, tagLine)];
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ const jsdocComment = `${indent}/** ${tag} */\n`;
116
+ const insertPosition = { line: lineIndex, character: 0 };
117
+ return [TextEdit.insert(insertPosition, jsdocComment)];
118
+ } catch (_error) {
119
+ return null;
120
+ }
121
+ }
@@ -0,0 +1,13 @@
1
+ export const DEFAULT_JSDOC_TAGS = ['@public', '@internal', '@lintignore'];
2
+
3
+ export const REQUEST_START = 'knip.start';
4
+
5
+ export const REQUEST_STOP = 'knip.stop';
6
+
7
+ export const REQUEST_RESTART = 'knip.restart';
8
+
9
+ export const REQUEST_FILE_NODE = 'knip.getFileNode';
10
+
11
+ export const REQUEST_RESULTS = 'knip.getResults';
12
+
13
+ export const SESSION_LOADING = 'session-loading';
@@ -0,0 +1,95 @@
1
+ import { getIssuePrefix } from 'knip/session';
2
+ import { DiagnosticSeverity, DiagnosticTag } from 'vscode-languageserver/node.js';
3
+
4
+ /**
5
+ * @import { Diagnostic } from 'vscode-languageserver';
6
+ * @import { TextDocument } from 'vscode-languageserver-textdocument';
7
+ * @import { Config } from './types.js';
8
+ * @import { Issue, Rules } from 'knip/session';
9
+ */
10
+
11
+ const SEVERITY = {
12
+ error: DiagnosticSeverity.Error,
13
+ warn: DiagnosticSeverity.Warning,
14
+ off: DiagnosticSeverity.Information,
15
+ hint: DiagnosticSeverity.Hint,
16
+ };
17
+
18
+ /**
19
+ * @param {Issue} issue
20
+ * @param {Rules} rules
21
+ * @param {Config} config
22
+ * @param {TextDocument} [document]
23
+ * @returns {Diagnostic}
24
+ */
25
+ export const issueToDiagnostic = (issue, rules, config, document) => {
26
+ if (issue.type === 'files' && document) {
27
+ return {
28
+ severity: DiagnosticSeverity.Information,
29
+ range: {
30
+ start: { line: 0, character: 0 },
31
+ end: { line: 0, character: 1 },
32
+ },
33
+ message: formatMessage(issue),
34
+ source: 'knip',
35
+ code: issue.type,
36
+ };
37
+ }
38
+
39
+ /** @type {DiagnosticSeverity} */
40
+ let severity = SEVERITY[rules[issue.type]];
41
+
42
+ /** @type {DiagnosticTag[]} */
43
+ const tags = [];
44
+
45
+ if (issue.type === 'exports' && config.editor.exports.highlight.dimExports) {
46
+ severity = DiagnosticSeverity.Hint;
47
+ tags.push(DiagnosticTag.Unnecessary);
48
+ }
49
+
50
+ if (issue.type === 'types' && config.editor.exports.highlight.dimTypes) {
51
+ severity = DiagnosticSeverity.Hint;
52
+ tags.push(DiagnosticTag.Unnecessary);
53
+ }
54
+
55
+ const line = Math.max(0, (issue.line ?? 1) - 1);
56
+ const start = Math.max(0, (issue.col ?? 0) - 1);
57
+ let len = issue.symbol?.length ?? 1;
58
+
59
+ if (issue.symbol === 'default' && (issue.type === 'exports' || issue.type === 'types') && document) {
60
+ const lineText = document.getText({
61
+ start: { line, character: 0 },
62
+ end: { line: line + 1, character: 0 },
63
+ });
64
+
65
+ const match = /export\s+default\s+([A-Za-z0-9_$]+)/.exec(lineText);
66
+ if (match) {
67
+ const exportDefaultEnd = match.index + match[0].length;
68
+ len = exportDefaultEnd - start;
69
+ }
70
+ }
71
+
72
+ return {
73
+ severity,
74
+ range: {
75
+ start: { line, character: start },
76
+ end: { line, character: start + len },
77
+ },
78
+ message: formatMessage(issue),
79
+ source: 'knip',
80
+ code: issue.type,
81
+ tags: tags.length > 0 ? tags : undefined,
82
+ };
83
+ };
84
+
85
+ /** @param {Issue} issue */
86
+ const formatMessage = issue => {
87
+ if (issue.type === 'files') return 'Unused file';
88
+ return getIssueDescription(issue);
89
+ };
90
+
91
+ /** @param {Issue} issue */
92
+ const getIssueDescription = ({ type, symbol, symbols, parentSymbol }) => {
93
+ const symbolDescription = symbols ? `${symbols.map(s => s.symbol).join(', ')}` : symbol;
94
+ return `${getIssuePrefix(type)}: ${symbolDescription}${parentSymbol ? ` (${parentSymbol})` : ''}`;
95
+ };
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { LanguageServer } from './server.js';
2
+
3
+ new LanguageServer();
package/src/server.js ADDED
@@ -0,0 +1,368 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath, pathToFileURL } from 'node:url';
3
+ import { createOptions, createSession, KNIP_CONFIG_LOCATIONS } from 'knip/session';
4
+ import { FileChangeType, ProposedFeatures, TextDocuments } from 'vscode-languageserver';
5
+ import { CodeActionKind, createConnection } from 'vscode-languageserver/node.js';
6
+ import { TextDocument } from 'vscode-languageserver-textdocument';
7
+ import {
8
+ createAddJSDocTagEdit,
9
+ createDeleteFileEdit,
10
+ createRemoveDependencyEdit,
11
+ createRemoveExportEdit,
12
+ } from './code-actions.js';
13
+ import {
14
+ DEFAULT_JSDOC_TAGS,
15
+ REQUEST_FILE_NODE,
16
+ REQUEST_RESTART,
17
+ REQUEST_RESULTS,
18
+ REQUEST_START,
19
+ REQUEST_STOP,
20
+ SESSION_LOADING,
21
+ } from './constants.js';
22
+ import { issueToDiagnostic } from './diagnostics.js';
23
+
24
+ const RESTART_FOR = new Set(['package.json', ...KNIP_CONFIG_LOCATIONS]);
25
+
26
+ /** @param {string} value */
27
+ const toPosix = value => value.split(path.sep).join(path.posix.sep);
28
+
29
+ /**
30
+ * @import { Issues, Rules } from 'knip/session';
31
+ * @import { Connection, Diagnostic, CodeAction } from 'vscode-languageserver';
32
+ * @import { CodeActionParams, DidChangeWatchedFilesParams } from 'vscode-languageserver';
33
+ * @import { Config, IssuesByUri } from './types.js';
34
+ *
35
+ * @typedef {import('knip/session').Session} Session
36
+ * @typedef {import('knip/session').File} File
37
+ */
38
+
39
+ const FILE_CHANGE_TYPES = new Map([
40
+ [FileChangeType.Created, 'added'],
41
+ [FileChangeType.Deleted, 'deleted'],
42
+ [FileChangeType.Changed, 'modified'],
43
+ ]);
44
+
45
+ const ISSUE_DESC = {
46
+ classMembers: 'class member',
47
+ enumMembers: 'enum member',
48
+ types: 'export keyword',
49
+ exports: 'export keyword',
50
+ };
51
+
52
+ export class LanguageServer {
53
+ /** @type {Connection} */
54
+ connection;
55
+
56
+ /** @type {undefined | string} */
57
+ cwd;
58
+
59
+ /** @type Set<string> */
60
+ published = new Set();
61
+
62
+ /** @type {undefined | Session} */
63
+ session;
64
+
65
+ /** @type {Rules} */
66
+ rules = {};
67
+
68
+ /** @type {IssuesByUri} */
69
+ issuesByUri = new Map();
70
+
71
+ /** @type {Map<string, import('vscode-languageserver').Diagnostic[]>} */
72
+ cycleDiagnostics = new Map();
73
+
74
+ /** @type TextDocuments<TextDocument> */
75
+ documents;
76
+
77
+ constructor() {
78
+ this.connection = createConnection(ProposedFeatures.all);
79
+ this.documents = new TextDocuments(TextDocument);
80
+ this.setupHandlers();
81
+ this.documents.listen(this.connection);
82
+ this.connection.listen();
83
+ }
84
+
85
+ setupHandlers() {
86
+ this.connection.onInitialize(params => {
87
+ const uri = params.workspaceFolders?.[0]?.uri;
88
+
89
+ if (!uri) return { capabilities: {} };
90
+
91
+ this.cwd = fileURLToPath(uri);
92
+
93
+ const capabilities = {
94
+ codeActionProvider: {
95
+ codeActionKinds: [CodeActionKind.QuickFix],
96
+ },
97
+ };
98
+
99
+ return { capabilities };
100
+ });
101
+
102
+ this.connection.onInitialized(() => {});
103
+
104
+ this.connection.onRequest(REQUEST_START, () => this.start());
105
+
106
+ this.connection.onRequest(REQUEST_STOP, () => this.stop());
107
+
108
+ this.connection.onShutdown(() => this.stop());
109
+
110
+ this.connection.onRequest(REQUEST_RESTART, () => this.restart());
111
+
112
+ this.connection.onRequest(REQUEST_RESULTS, () => this.getResults());
113
+
114
+ this.connection.onRequest(REQUEST_FILE_NODE, async params => {
115
+ const config = await this.getConfig();
116
+ const isShowContention = config.exports?.contention?.enabled !== false;
117
+ return this.getFileDescriptor(fileURLToPath(params.uri), { isShowContention });
118
+ });
119
+
120
+ this.connection.onCodeAction(params => this.handleCodeAction(params));
121
+
122
+ this.connection.onDidChangeWatchedFiles(params => this.handleFileChanges(params));
123
+ }
124
+
125
+ /** @returns {Promise<Config>} */
126
+ async getConfig() {
127
+ return await this.connection.workspace.getConfiguration('knip');
128
+ }
129
+
130
+ /**
131
+ * @param {Issues} issues
132
+ * @param {Config} config
133
+ * @param {Rules} rules
134
+ * */
135
+ buildDiagnostics(issues, config, rules) {
136
+ /** @type {Map<string, Diagnostic[]>} */
137
+ const diagnostics = new Map();
138
+ this.issuesByUri.clear();
139
+
140
+ for (const issuesForType of Object.values(issues)) {
141
+ for (const issuesForFile of Object.values(issuesForType)) {
142
+ for (const issue of Object.values(issuesForFile)) {
143
+ const uri = pathToFileURL(issue.filePath).toString();
144
+ if (!diagnostics.has(uri)) diagnostics.set(uri, []);
145
+ const document = this.documents.get(uri);
146
+ const diagnostic = issueToDiagnostic(issue, rules, config, document);
147
+ diagnostics.get(uri)?.push(diagnostic);
148
+ if (!this.issuesByUri.has(uri)) this.issuesByUri.set(uri, new Map());
149
+ const key = `${diagnostic.range.start.line}:${diagnostic.range.start.character}`;
150
+ this.issuesByUri.get(uri)?.set(key, { issue, issueType: issue.type, diagnostic });
151
+ }
152
+ }
153
+ }
154
+ return diagnostics;
155
+ }
156
+
157
+ /** @param {Map<string, Diagnostic[]>} newDiags */
158
+ publishDiagnostics(newDiags) {
159
+ for (const [uri, diagnostics] of this.cycleDiagnostics) {
160
+ const existing = newDiags.get(uri) || [];
161
+ newDiags.set(uri, [...existing, ...diagnostics]);
162
+ }
163
+ for (const [uri, diagnostics] of newDiags) this.connection.sendDiagnostics({ uri, diagnostics });
164
+ for (const uri of this.published) if (!newDiags.has(uri)) this.connection.sendDiagnostics({ uri, diagnostics: [] });
165
+ this.published = new Set(newDiags.keys());
166
+ }
167
+
168
+ async start() {
169
+ if (this.session) return;
170
+
171
+ try {
172
+ const config = await this.getConfig();
173
+ if (!config?.enabled) return;
174
+
175
+ this.connection.console.log('Creating options');
176
+ const options = await createOptions({ cwd: this.cwd, isSession: true });
177
+ this.rules = options.rules;
178
+
179
+ this.connection.console.log('Building module graph...');
180
+ const start = Date.now();
181
+ const session = await createSession(options);
182
+ this.connection.console.log(`Finished building module graph (${Date.now() - start}ms)`);
183
+
184
+ this.session = session;
185
+ this.publishDiagnostics(this.buildDiagnostics(session.getIssues().issues, config, this.rules));
186
+ } catch (_error) {
187
+ this.connection.console.error(`Error: ${_error}`);
188
+ }
189
+ }
190
+
191
+ stop() {
192
+ this.session = undefined;
193
+ this.fileCache = undefined;
194
+ for (const uri of this.published) this.connection.sendDiagnostics({ uri, diagnostics: [] });
195
+ this.published.clear();
196
+ }
197
+
198
+ restart() {
199
+ this.stop();
200
+ this.start();
201
+ }
202
+
203
+ getResults() {
204
+ if (!this.session) return null;
205
+ return this.session.getResults();
206
+ }
207
+
208
+ /**
209
+ * @param {DidChangeWatchedFilesParams} params
210
+ * @return {Promise<void>}
211
+ */
212
+ async handleFileChanges(params) {
213
+ this.fileCache = undefined;
214
+ if (!this.session) return;
215
+
216
+ /** @type {{ type: "added" | "deleted" | "modified"; filePath: string }[]} */
217
+ const changes = [];
218
+ for (const change of params.changes) {
219
+ const filePath = fileURLToPath(change.uri);
220
+ if (RESTART_FOR.has(path.basename(change.uri))) return this.restart();
221
+ const type = FILE_CHANGE_TYPES.get(change.type);
222
+ if (!type) continue;
223
+ changes.push({ type, filePath });
224
+ }
225
+
226
+ const result = await this.session.handleFileChanges(changes);
227
+
228
+ if (result) {
229
+ this.connection.console.log(
230
+ `Module graph updated (${Math.floor(result.duration)}ms • ${(result.mem / 1024 / 1024).toFixed(2)}M)`
231
+ );
232
+ }
233
+
234
+ const config = await this.getConfig();
235
+ this.publishDiagnostics(this.buildDiagnostics(this.session.getIssues().issues, config, this.rules));
236
+ }
237
+
238
+ /**
239
+ * @param {string} filePath
240
+ * @param {{ isShowContention?: boolean }} [options]
241
+ * @returns {File | typeof SESSION_LOADING | undefined}
242
+ */
243
+ getFileDescriptor(filePath, options) {
244
+ if (!this.session) return SESSION_LOADING;
245
+ const relPath = toPosix(path.relative(this.cwd ?? process.cwd(), filePath));
246
+ if (this.fileCache?.filePath === relPath) return this.fileCache.file;
247
+ const startTime = performance.now();
248
+ const file = this.session.describeFile(relPath, options);
249
+ if (file) {
250
+ const duration = Math.round(performance.now() - startTime);
251
+ const mem = process.memoryUsage().heapUsed;
252
+ this.connection.console.log(
253
+ `Received file descriptor (${relPath} • ${duration}ms • ${(mem / 1024 / 1024).toFixed(2)}M)`
254
+ );
255
+ const m = file.metrics;
256
+ this.connection.console.log(
257
+ ` ↳ imports: ${Math.round(m.imports)}ms, exports: ${Math.round(m.exports)}ms, cycles: ${Math.round(m.cycles)}ms, contention: ${Math.round(m.contention)}ms`
258
+ );
259
+ this.fileCache = { filePath: relPath, file };
260
+ return file;
261
+ }
262
+ this.connection.console.log(`File not in project (${relPath})`);
263
+ }
264
+
265
+ /**
266
+ * @param {CodeActionParams} params
267
+ * @returns {Promise<CodeAction[]>}
268
+ */
269
+ async handleCodeAction(params) {
270
+ const config = await this.getConfig();
271
+ if (!config.editor.exports.quickfix.enabled) return [];
272
+
273
+ const uri = params.textDocument.uri;
274
+ const issuesForUri = this.issuesByUri.get(uri);
275
+ if (!issuesForUri) return [];
276
+ const document = this.documents.get(uri);
277
+ const jsdocTags = Array.isArray(config.editor.exports.quickfix.jsdocTags)
278
+ ? config.editor.exports.quickfix.jsdocTags
279
+ : DEFAULT_JSDOC_TAGS;
280
+
281
+ /** @type {CodeAction[]} */
282
+ const codeActions = [];
283
+
284
+ for (const diagnostic of params.context.diagnostics) {
285
+ if (diagnostic.source !== 'knip') continue;
286
+
287
+ const key = `${diagnostic.range.start.line}:${diagnostic.range.start.character}`;
288
+ const issuesForFile = issuesForUri.get(key);
289
+ if (!issuesForFile) continue;
290
+
291
+ const { issue, issueType } = issuesForFile;
292
+
293
+ if (
294
+ issueType === 'exports' ||
295
+ issueType === 'types' ||
296
+ issueType === 'classMembers' ||
297
+ issueType === 'enumMembers'
298
+ ) {
299
+ const removeExportEdit = createRemoveExportEdit(document, uri, issue);
300
+ if (!removeExportEdit) continue;
301
+ codeActions.push({
302
+ title: `Remove ${ISSUE_DESC[issueType]} (${issue.symbol})`,
303
+ kind: CodeActionKind.QuickFix,
304
+ diagnostics: [diagnostic],
305
+ edit: removeExportEdit,
306
+ });
307
+
308
+ if (document) {
309
+ for (const tag of jsdocTags) {
310
+ const jsdocEdit = createAddJSDocTagEdit(document, issue, tag);
311
+ if (!jsdocEdit) continue;
312
+ codeActions.push({
313
+ title: `Add ${tag} JSDoc tag`,
314
+ kind: CodeActionKind.QuickFix,
315
+ diagnostics: [diagnostic],
316
+ edit: { changes: { [uri]: jsdocEdit } },
317
+ });
318
+ }
319
+ }
320
+ }
321
+
322
+ if (issueType === 'dependencies' || issueType === 'devDependencies') {
323
+ const removeDependencyEdit = createRemoveDependencyEdit(document, uri, issue);
324
+ if (!removeDependencyEdit) continue;
325
+ codeActions.push({
326
+ title: `Remove dependency "${issue.symbol}"`,
327
+ kind: CodeActionKind.QuickFix,
328
+ diagnostics: [diagnostic],
329
+ edit: removeDependencyEdit,
330
+ });
331
+ }
332
+
333
+ if (issueType === 'unlisted') {
334
+ codeActions.push({
335
+ title: `Add '${issue.symbol}' to dependencies in package.json`,
336
+ kind: CodeActionKind.QuickFix,
337
+ diagnostics: [diagnostic],
338
+ });
339
+
340
+ codeActions.push({
341
+ title: `Add '${issue.symbol}' to devDependencies in package.json`,
342
+ kind: CodeActionKind.QuickFix,
343
+ diagnostics: [diagnostic],
344
+ });
345
+
346
+ codeActions.push({
347
+ title: `Add '@types/${issue.symbol}' to devDependencies in package.json`,
348
+ kind: CodeActionKind.QuickFix,
349
+ diagnostics: [diagnostic],
350
+ });
351
+ }
352
+
353
+ if (issueType === 'files') {
354
+ const deleteEdit = createDeleteFileEdit(uri);
355
+ if (deleteEdit) {
356
+ codeActions.push({
357
+ title: `Delete this file`,
358
+ kind: CodeActionKind.QuickFix,
359
+ diagnostics: [diagnostic],
360
+ edit: deleteEdit,
361
+ });
362
+ }
363
+ }
364
+ }
365
+
366
+ return codeActions;
367
+ }
368
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { Issue, IssueType } from 'knip/session';
2
+
3
+ export type Config = {
4
+ enabled: boolean;
5
+ editor: {
6
+ exports: {
7
+ codelens: {
8
+ enabled: boolean;
9
+ };
10
+ hover: {
11
+ enabled: boolean;
12
+ includeImportLocationSnippet: boolean;
13
+ maxSnippets: number;
14
+ timeout: number;
15
+ };
16
+ quickfix: {
17
+ enabled: boolean;
18
+ jsdocTags?: string[];
19
+ };
20
+ highlight: {
21
+ dimExports: boolean;
22
+ dimTypes: boolean;
23
+ };
24
+ };
25
+ };
26
+ imports: {
27
+ enabled: boolean;
28
+ };
29
+ exports: {
30
+ enabled: boolean;
31
+ contention: {
32
+ enabled: boolean;
33
+ };
34
+ };
35
+ };
36
+
37
+ export type IssueForFile = {
38
+ issue: Issue;
39
+ issueType: IssueType;
40
+ diagnostic: Diagnostic;
41
+ };
42
+
43
+ export type IssuesForFile = Map<string, IssueForFile>;
44
+
45
+ export type IssuesByUri = Map<string, IssuesForFile>;
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "checkJs": true,
4
+ "module": "nodenext",
5
+ "noEmit": true,
6
+ "resolveJsonModule": true,
7
+ "skipLibCheck": true,
8
+ "strict": true,
9
+ "target": "esnext"
10
+ }
11
+ }