@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 +136 -0
- package/license +15 -0
- package/package.json +20 -0
- package/src/code-actions.js +121 -0
- package/src/constants.js +13 -0
- package/src/diagnostics.js +95 -0
- package/src/index.js +3 -0
- package/src/server.js +368 -0
- package/src/types.d.ts +45 -0
- package/tsconfig.json +11 -0
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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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
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>;
|