@alexgorbatchev/typescript-ai-policy 0.1.0
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 +223 -0
- package/package.json +60 -0
- package/src/oxfmt/createOxfmtConfig.ts +26 -0
- package/src/oxlint/assertNoRuleCollisions.ts +40 -0
- package/src/oxlint/createOxlintConfig.ts +161 -0
- package/src/oxlint/oxlint.config.ts +3 -0
- package/src/oxlint/plugin.ts +90 -0
- package/src/oxlint/rules/component-directory-file-convention.ts +65 -0
- package/src/oxlint/rules/component-file-contract.ts +328 -0
- package/src/oxlint/rules/component-file-location-convention.ts +43 -0
- package/src/oxlint/rules/component-file-naming-convention.ts +260 -0
- package/src/oxlint/rules/component-story-file-convention.ts +108 -0
- package/src/oxlint/rules/fixture-export-naming-convention.ts +72 -0
- package/src/oxlint/rules/fixture-export-type-contract.ts +264 -0
- package/src/oxlint/rules/fixture-file-contract.ts +91 -0
- package/src/oxlint/rules/fixture-import-path-convention.ts +125 -0
- package/src/oxlint/rules/helpers.ts +544 -0
- package/src/oxlint/rules/hook-export-location-convention.ts +169 -0
- package/src/oxlint/rules/hook-file-contract.ts +179 -0
- package/src/oxlint/rules/hook-file-naming-convention.ts +151 -0
- package/src/oxlint/rules/hook-test-file-convention.ts +60 -0
- package/src/oxlint/rules/hooks-directory-file-convention.ts +75 -0
- package/src/oxlint/rules/index-file-contract.ts +177 -0
- package/src/oxlint/rules/interface-naming-convention.ts +72 -0
- package/src/oxlint/rules/no-conditional-logic-in-tests.ts +53 -0
- package/src/oxlint/rules/no-fixture-exports-outside-fixture-entrypoint.ts +68 -0
- package/src/oxlint/rules/no-imports-from-tests-directory.ts +114 -0
- package/src/oxlint/rules/no-inline-fixture-bindings-in-tests.ts +54 -0
- package/src/oxlint/rules/no-inline-type-expressions.ts +169 -0
- package/src/oxlint/rules/no-local-type-declarations-in-fixture-files.ts +55 -0
- package/src/oxlint/rules/no-module-mocking.ts +85 -0
- package/src/oxlint/rules/no-non-running-tests.ts +72 -0
- package/src/oxlint/rules/no-react-create-element.ts +59 -0
- package/src/oxlint/rules/no-test-file-exports.ts +52 -0
- package/src/oxlint/rules/no-throw-in-tests.ts +40 -0
- package/src/oxlint/rules/no-type-exports-from-constants.ts +97 -0
- package/src/oxlint/rules/no-type-imports-from-constants.ts +73 -0
- package/src/oxlint/rules/no-value-exports-from-types.ts +115 -0
- package/src/oxlint/rules/require-component-root-testid.ts +547 -0
- package/src/oxlint/rules/require-template-indent.ts +83 -0
- package/src/oxlint/rules/single-fixture-entrypoint.ts +142 -0
- package/src/oxlint/rules/stories-directory-file-convention.ts +55 -0
- package/src/oxlint/rules/story-export-contract.ts +343 -0
- package/src/oxlint/rules/story-file-location-convention.ts +64 -0
- package/src/oxlint/rules/story-meta-type-annotation.ts +129 -0
- package/src/oxlint/rules/test-file-location-convention.ts +115 -0
- package/src/oxlint/rules/testid-naming-convention.ts +63 -0
- package/src/oxlint/rules/tests-directory-file-convention.ts +55 -0
- package/src/oxlint/rules/types.ts +45 -0
- package/src/semantic-fixes/applyFileChanges.ts +81 -0
- package/src/semantic-fixes/applySemanticFixes.ts +239 -0
- package/src/semantic-fixes/applyTextEdits.ts +164 -0
- package/src/semantic-fixes/backends/tsgo-lsp/TsgoLspClient.ts +439 -0
- package/src/semantic-fixes/backends/tsgo-lsp/createTsgoLspSemanticFixBackend.ts +251 -0
- package/src/semantic-fixes/providers/createInterfaceNamingConventionSemanticFixProvider.ts +132 -0
- package/src/semantic-fixes/providers/createTestFileLocationConventionSemanticFixProvider.ts +52 -0
- package/src/semantic-fixes/readMovedFileTextEdits.ts +150 -0
- package/src/semantic-fixes/readSemanticFixRuntimePaths.ts +38 -0
- package/src/semantic-fixes/runApplySemanticFixes.ts +120 -0
- package/src/semantic-fixes/runOxlintJson.ts +139 -0
- package/src/semantic-fixes/types.ts +163 -0
- package/src/shared/mergeConfig.ts +38 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import type { ILineAndCharacter } from "../../types.ts";
|
|
5
|
+
|
|
6
|
+
type IJsonRpcIdentifier = number | string | null;
|
|
7
|
+
|
|
8
|
+
type IJsonRpcError = {
|
|
9
|
+
code: number;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type IJsonRpcErrorResponse = {
|
|
14
|
+
error: IJsonRpcError;
|
|
15
|
+
id: IJsonRpcIdentifier;
|
|
16
|
+
jsonrpc: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type IJsonRpcRequestMessage = {
|
|
20
|
+
id?: IJsonRpcIdentifier;
|
|
21
|
+
jsonrpc: string;
|
|
22
|
+
method: string;
|
|
23
|
+
params?: unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type IJsonRpcSuccessResponse = {
|
|
27
|
+
id: IJsonRpcIdentifier;
|
|
28
|
+
jsonrpc: string;
|
|
29
|
+
result?: unknown;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type IJsonRpcResponse = IJsonRpcErrorResponse | IJsonRpcSuccessResponse;
|
|
33
|
+
|
|
34
|
+
type ILspTextDocumentIdentifier = {
|
|
35
|
+
uri: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ILspPosition = {
|
|
39
|
+
character: number;
|
|
40
|
+
line: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type ILspWorkspaceFolder = {
|
|
44
|
+
name: string;
|
|
45
|
+
uri: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type ILspWorkspaceEditCapabilities = {
|
|
49
|
+
documentChanges: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type ILspWorkspaceCapabilities = {
|
|
53
|
+
workspaceEdit: ILspWorkspaceEditCapabilities;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type ILspRenameCapabilities = {
|
|
57
|
+
prepareSupport: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type ILspTextDocumentCapabilities = {
|
|
61
|
+
rename: ILspRenameCapabilities;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type ILspClientCapabilities = {
|
|
65
|
+
textDocument: ILspTextDocumentCapabilities;
|
|
66
|
+
workspace: ILspWorkspaceCapabilities;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type ILspInitializeParams = {
|
|
70
|
+
capabilities: ILspClientCapabilities;
|
|
71
|
+
processId: number;
|
|
72
|
+
rootUri: string;
|
|
73
|
+
workspaceFolders: readonly ILspWorkspaceFolder[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type ILspPrepareRenameParams = {
|
|
77
|
+
position: ILspPosition;
|
|
78
|
+
textDocument: ILspTextDocumentIdentifier;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type ILspRenameParams = {
|
|
82
|
+
newName: string;
|
|
83
|
+
position: ILspPosition;
|
|
84
|
+
textDocument: ILspTextDocumentIdentifier;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type ITsgoLspClientOptions = {
|
|
88
|
+
tsgoExecutablePath: string;
|
|
89
|
+
workspacePath: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type IPendingRequest = {
|
|
93
|
+
reject: (error: Error) => void;
|
|
94
|
+
resolve: (result: unknown) => void;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
98
|
+
return typeof value === "object" && value !== null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isJsonRpcError(value: unknown): value is IJsonRpcError {
|
|
102
|
+
if (!isRecord(value)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return typeof value.code === "number" && typeof value.message === "string";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isJsonRpcRequestMessage(value: unknown): value is IJsonRpcRequestMessage {
|
|
110
|
+
if (!isRecord(value)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return value.jsonrpc === "2.0" && typeof value.method === "string";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isJsonRpcResponse(value: unknown): value is IJsonRpcResponse {
|
|
118
|
+
if (!isRecord(value)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (value.jsonrpc !== "2.0") {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hasIdentifier = typeof value.id === "number" || typeof value.id === "string" || value.id === null;
|
|
127
|
+
if (!hasIdentifier) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (Reflect.has(value, "error")) {
|
|
132
|
+
return isJsonRpcError(value.error);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Reflect.has(value, "result");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readLspPosition(position: ILineAndCharacter): ILspPosition {
|
|
139
|
+
return {
|
|
140
|
+
character: position.character,
|
|
141
|
+
line: position.line,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class TsgoLspClient {
|
|
146
|
+
private readonly pendingRequests = new Map<number, IPendingRequest>();
|
|
147
|
+
private readonly process: ReturnType<typeof spawn>;
|
|
148
|
+
private readonly stderrChunks: string[] = [];
|
|
149
|
+
private stdoutBuffer = Buffer.alloc(0);
|
|
150
|
+
private requestId = 0;
|
|
151
|
+
|
|
152
|
+
public constructor(private readonly options: ITsgoLspClientOptions) {
|
|
153
|
+
this.process = spawn(this.options.tsgoExecutablePath, ["--lsp", "--stdio"], {
|
|
154
|
+
cwd: this.options.workspacePath,
|
|
155
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const stdoutStream = this.process.stdout;
|
|
159
|
+
if (!stdoutStream) {
|
|
160
|
+
throw new Error("tsgo LSP stdout is unavailable");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const stderrStream = this.process.stderr;
|
|
164
|
+
if (!stderrStream) {
|
|
165
|
+
throw new Error("tsgo LSP stderr is unavailable");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
stdoutStream.on("data", (chunk: Buffer) => {
|
|
169
|
+
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
|
|
170
|
+
this.consumeOutputBuffer();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
stderrStream.on("data", (chunk: Buffer) => {
|
|
174
|
+
this.stderrChunks.push(chunk.toString("utf8"));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
this.process.on("error", (error: Error) => {
|
|
178
|
+
this.rejectPendingRequests(error);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
this.process.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
182
|
+
const stderrOutput = this.readStderrOutput();
|
|
183
|
+
const failureReason = `tsgo LSP exited before the request completed (code=${String(code)}, signal=${String(signal)})${stderrOutput}`;
|
|
184
|
+
this.rejectPendingRequests(new Error(failureReason));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
public async initialize(): Promise<void> {
|
|
189
|
+
const rootUri = pathToFileURL(this.options.workspacePath).toString();
|
|
190
|
+
const initializeParams: ILspInitializeParams = {
|
|
191
|
+
capabilities: {
|
|
192
|
+
textDocument: {
|
|
193
|
+
rename: {
|
|
194
|
+
prepareSupport: true,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
workspace: {
|
|
198
|
+
workspaceEdit: {
|
|
199
|
+
documentChanges: false,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
processId: process.pid,
|
|
204
|
+
rootUri,
|
|
205
|
+
workspaceFolders: [
|
|
206
|
+
{
|
|
207
|
+
name: "workspace",
|
|
208
|
+
uri: rootUri,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
await this.sendRequest("initialize", initializeParams);
|
|
214
|
+
this.sendNotification("initialized");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public prepareRename(filePath: string, position: ILineAndCharacter): Promise<unknown> {
|
|
218
|
+
const params: ILspPrepareRenameParams = {
|
|
219
|
+
position: readLspPosition(position),
|
|
220
|
+
textDocument: {
|
|
221
|
+
uri: pathToFileURL(filePath).toString(),
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return this.sendRequest("textDocument/prepareRename", params);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public rename(filePath: string, position: ILineAndCharacter, newName: string): Promise<unknown> {
|
|
229
|
+
const params: ILspRenameParams = {
|
|
230
|
+
newName,
|
|
231
|
+
position: readLspPosition(position),
|
|
232
|
+
textDocument: {
|
|
233
|
+
uri: pathToFileURL(filePath).toString(),
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return this.sendRequest("textDocument/rename", params);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public async dispose(): Promise<void> {
|
|
241
|
+
try {
|
|
242
|
+
await this.sendRequest("shutdown");
|
|
243
|
+
} catch {
|
|
244
|
+
// The caller wants disposal to be best-effort.
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.sendNotification("exit");
|
|
248
|
+
|
|
249
|
+
const stdinStream = this.process.stdin;
|
|
250
|
+
if (stdinStream && !stdinStream.destroyed) {
|
|
251
|
+
stdinStream.end();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await this.waitForClose(1000);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private consumeOutputBuffer(): void {
|
|
258
|
+
while (true) {
|
|
259
|
+
const headerEnd = this.stdoutBuffer.indexOf("\r\n\r\n");
|
|
260
|
+
if (headerEnd === -1) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const headerText = this.stdoutBuffer.subarray(0, headerEnd).toString("utf8");
|
|
265
|
+
const contentLength = this.readContentLength(headerText);
|
|
266
|
+
const messageStart = headerEnd + 4;
|
|
267
|
+
const messageEnd = messageStart + contentLength;
|
|
268
|
+
|
|
269
|
+
if (this.stdoutBuffer.length < messageEnd) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const messageText = this.stdoutBuffer.subarray(messageStart, messageEnd).toString("utf8");
|
|
274
|
+
this.stdoutBuffer = this.stdoutBuffer.subarray(messageEnd);
|
|
275
|
+
|
|
276
|
+
const parsedMessage: unknown = JSON.parse(messageText);
|
|
277
|
+
this.handleMessage(parsedMessage);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private handleMessage(message: unknown): void {
|
|
282
|
+
if (isJsonRpcResponse(message)) {
|
|
283
|
+
this.handleResponse(message);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (isJsonRpcRequestMessage(message)) {
|
|
288
|
+
this.handleServerRequest(message);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private handleResponse(message: IJsonRpcResponse): void {
|
|
293
|
+
if (typeof message.id !== "number") {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const pendingRequest = this.pendingRequests.get(message.id);
|
|
298
|
+
if (!pendingRequest) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this.pendingRequests.delete(message.id);
|
|
303
|
+
|
|
304
|
+
if ("error" in message) {
|
|
305
|
+
pendingRequest.reject(new Error(`${message.error.message} (code=${String(message.error.code)})`));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
pendingRequest.resolve(message.result ?? null);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private handleServerRequest(message: IJsonRpcRequestMessage): void {
|
|
313
|
+
if (message.id === undefined) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.writeResponse(message.id, this.readServerRequestResult(message.method));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private readContentLength(headerText: string): number {
|
|
321
|
+
const headerLines = headerText.split("\r\n");
|
|
322
|
+
|
|
323
|
+
for (const headerLine of headerLines) {
|
|
324
|
+
const lowerCasedHeaderLine = headerLine.toLowerCase();
|
|
325
|
+
if (!lowerCasedHeaderLine.startsWith("content-length:")) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const rawValue = headerLine.slice("content-length:".length).trim();
|
|
330
|
+
const contentLength = Number(rawValue);
|
|
331
|
+
if (!Number.isInteger(contentLength) || contentLength < 0) {
|
|
332
|
+
throw new Error(`Invalid Content-Length header: ${headerLine}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return contentLength;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
throw new Error(`Missing Content-Length header: ${headerText}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private readServerRequestResult(method: string): unknown {
|
|
342
|
+
switch (method) {
|
|
343
|
+
case "client/registerCapability":
|
|
344
|
+
return null;
|
|
345
|
+
case "workspace/configuration":
|
|
346
|
+
return [];
|
|
347
|
+
default:
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private readStderrOutput(): string {
|
|
353
|
+
const stderrOutput = this.stderrChunks.join("").trim();
|
|
354
|
+
if (stderrOutput.length === 0) {
|
|
355
|
+
return "";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return `\n\nStderr:\n${stderrOutput}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private rejectPendingRequests(error: Error): void {
|
|
362
|
+
for (const [requestId, pendingRequest] of this.pendingRequests) {
|
|
363
|
+
this.pendingRequests.delete(requestId);
|
|
364
|
+
pendingRequest.reject(error);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private sendNotification(method: string, params?: unknown): void {
|
|
369
|
+
this.writeMessage(method, undefined, params);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private sendRequest(method: string, params?: unknown): Promise<unknown> {
|
|
373
|
+
const requestId = this.requestId;
|
|
374
|
+
this.requestId += 1;
|
|
375
|
+
|
|
376
|
+
const responsePromise = new Promise<unknown>((resolve, reject) => {
|
|
377
|
+
this.pendingRequests.set(requestId, {
|
|
378
|
+
reject,
|
|
379
|
+
resolve,
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
this.writeMessage(method, requestId, params);
|
|
384
|
+
|
|
385
|
+
return responsePromise;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async waitForClose(timeoutMs: number): Promise<void> {
|
|
389
|
+
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const closePromise = once(this.process, "close").then(() => undefined);
|
|
394
|
+
const killTimer = setTimeout(() => {
|
|
395
|
+
if (this.process.exitCode === null && this.process.signalCode === null) {
|
|
396
|
+
this.process.kill("SIGKILL");
|
|
397
|
+
}
|
|
398
|
+
}, timeoutMs);
|
|
399
|
+
|
|
400
|
+
await closePromise;
|
|
401
|
+
clearTimeout(killTimer);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private writeMessage(method: string, id?: IJsonRpcIdentifier, params?: unknown): void {
|
|
405
|
+
const message: Record<string, unknown> = {
|
|
406
|
+
jsonrpc: "2.0",
|
|
407
|
+
method,
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
if (id !== undefined) {
|
|
411
|
+
message.id = id;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (params !== undefined) {
|
|
415
|
+
message.params = params;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
this.writeSerializedMessage(message);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private writeResponse(id: IJsonRpcIdentifier, result: unknown): void {
|
|
422
|
+
this.writeSerializedMessage({
|
|
423
|
+
id,
|
|
424
|
+
jsonrpc: "2.0",
|
|
425
|
+
result,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private writeSerializedMessage(message: Record<string, unknown>): void {
|
|
430
|
+
const stdinStream = this.process.stdin;
|
|
431
|
+
if (!stdinStream || stdinStream.destroyed) {
|
|
432
|
+
throw new Error(`Cannot write to tsgo LSP stdin${this.readStderrOutput()}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const serializedMessage = JSON.stringify(message);
|
|
436
|
+
const contentLength = Buffer.byteLength(serializedMessage, "utf8");
|
|
437
|
+
stdinStream.write(`Content-Length: ${String(contentLength)}\r\n\r\n${serializedMessage}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { readMovedFileTextEdits } from "../../readMovedFileTextEdits.ts";
|
|
4
|
+
import { TsgoLspClient } from "./TsgoLspClient.ts";
|
|
5
|
+
import type {
|
|
6
|
+
IApplySemanticFixesOptions,
|
|
7
|
+
IFileMove,
|
|
8
|
+
IMoveFileOperation,
|
|
9
|
+
ISemanticFixBackend,
|
|
10
|
+
ISemanticFixBackendContext,
|
|
11
|
+
ISemanticFixOperation,
|
|
12
|
+
ISemanticFixPlan,
|
|
13
|
+
ISemanticFixPlanResult,
|
|
14
|
+
ISymbolRenameOperation,
|
|
15
|
+
ITextEdit,
|
|
16
|
+
} from "../../types.ts";
|
|
17
|
+
|
|
18
|
+
type ILspPosition = {
|
|
19
|
+
character: number;
|
|
20
|
+
line: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ILspRange = {
|
|
24
|
+
end: ILspPosition;
|
|
25
|
+
start: ILspPosition;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ILspTextEdit = {
|
|
29
|
+
newText: string;
|
|
30
|
+
range: ILspRange;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ILspWorkspaceChanges = Record<string, readonly ILspTextEdit[]>;
|
|
34
|
+
|
|
35
|
+
type ILspWorkspaceEdit = {
|
|
36
|
+
changes: ILspWorkspaceChanges;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type IClientCache = Map<string, TsgoLspClient>;
|
|
40
|
+
|
|
41
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isLspPosition(value: unknown): value is ILspPosition {
|
|
46
|
+
if (!isRecord(value)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return typeof value.line === "number" && typeof value.character === "number";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isLspRange(value: unknown): value is ILspRange {
|
|
54
|
+
if (!isRecord(value)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return isLspPosition(value.start) && isLspPosition(value.end);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isLspTextEdit(value: unknown): value is ILspTextEdit {
|
|
62
|
+
if (!isRecord(value)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return typeof value.newText === "string" && isLspRange(value.range);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isLspWorkspaceEdit(value: unknown): value is ILspWorkspaceEdit {
|
|
70
|
+
if (!isRecord(value) || !isRecord(value.changes)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const textEdits of Object.values(value.changes)) {
|
|
75
|
+
if (!Array.isArray(textEdits)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const textEdit of textEdits) {
|
|
80
|
+
if (!isLspTextEdit(textEdit)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compareTextEdit(left: ITextEdit, right: ITextEdit): number {
|
|
90
|
+
if (left.filePath !== right.filePath) {
|
|
91
|
+
return left.filePath.localeCompare(right.filePath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (left.start.line !== right.start.line) {
|
|
95
|
+
return left.start.line - right.start.line;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (left.start.character !== right.start.character) {
|
|
99
|
+
return left.start.character - right.start.character;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return left.newText.localeCompare(right.newText);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readTextEdit(uri: string, textEdit: ILspTextEdit): ITextEdit {
|
|
106
|
+
return {
|
|
107
|
+
end: textEdit.range.end,
|
|
108
|
+
filePath: fileURLToPath(uri),
|
|
109
|
+
newText: textEdit.newText,
|
|
110
|
+
start: textEdit.range.start,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readTextEdits(workspaceEdit: ILspWorkspaceEdit): readonly ITextEdit[] {
|
|
115
|
+
const textEdits: ITextEdit[] = [];
|
|
116
|
+
|
|
117
|
+
for (const [uri, edits] of Object.entries(workspaceEdit.changes)) {
|
|
118
|
+
for (const textEdit of edits) {
|
|
119
|
+
textEdits.push(readTextEdit(uri, textEdit));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return textEdits.sort(compareTextEdit);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readFailureReason(error: unknown): string {
|
|
127
|
+
if (error instanceof Error) {
|
|
128
|
+
return error.message;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return String(error);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readClient(
|
|
135
|
+
clientCache: IClientCache,
|
|
136
|
+
context: ISemanticFixBackendContext,
|
|
137
|
+
options: Pick<IApplySemanticFixesOptions, "tsgoExecutablePath">,
|
|
138
|
+
): Promise<TsgoLspClient> {
|
|
139
|
+
const cachedClient = clientCache.get(context.targetDirectoryPath);
|
|
140
|
+
if (cachedClient) {
|
|
141
|
+
return cachedClient;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const client = new TsgoLspClient({
|
|
145
|
+
tsgoExecutablePath: options.tsgoExecutablePath,
|
|
146
|
+
workspacePath: context.targetDirectoryPath,
|
|
147
|
+
});
|
|
148
|
+
await client.initialize();
|
|
149
|
+
clientCache.set(context.targetDirectoryPath, client);
|
|
150
|
+
return client;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readRenameSymbolDescription(operation: ISymbolRenameOperation): string {
|
|
154
|
+
return `Rename ${operation.symbolName} to ${operation.newName}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readMoveFileDescription(operation: IMoveFileOperation): string {
|
|
158
|
+
return `Move ${operation.filePath} to ${operation.newFilePath}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readPlan(
|
|
162
|
+
operation: ISemanticFixOperation,
|
|
163
|
+
textEdits: readonly ITextEdit[],
|
|
164
|
+
fileMoves: readonly IFileMove[] = [],
|
|
165
|
+
): ISemanticFixPlan {
|
|
166
|
+
return {
|
|
167
|
+
description:
|
|
168
|
+
operation.kind === "rename-symbol" ? readRenameSymbolDescription(operation) : readMoveFileDescription(operation),
|
|
169
|
+
fileMoves,
|
|
170
|
+
operationId: operation.id,
|
|
171
|
+
ruleCode: operation.ruleCode,
|
|
172
|
+
textEdits,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createTsgoLspSemanticFixBackend(
|
|
177
|
+
options: Pick<IApplySemanticFixesOptions, "tsgoExecutablePath">,
|
|
178
|
+
): ISemanticFixBackend {
|
|
179
|
+
const clientCache: IClientCache = new Map();
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
async createPlan(operation, context): Promise<ISemanticFixPlanResult> {
|
|
183
|
+
switch (operation.kind) {
|
|
184
|
+
case "rename-symbol": {
|
|
185
|
+
const client = await readClient(clientCache, context, options);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await client.prepareRename(operation.filePath, operation.position);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return {
|
|
191
|
+
kind: "skip",
|
|
192
|
+
reason: readFailureReason(error),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let renameResult: unknown;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
renameResult = await client.rename(operation.filePath, operation.position, operation.newName);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return {
|
|
202
|
+
kind: "skip",
|
|
203
|
+
reason: readFailureReason(error),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (renameResult === null) {
|
|
207
|
+
return {
|
|
208
|
+
kind: "skip",
|
|
209
|
+
reason: `tsgo returned no edits for ${operation.symbolName}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!isLspWorkspaceEdit(renameResult)) {
|
|
214
|
+
throw new Error(`Unexpected tsgo rename response: ${JSON.stringify(renameResult)}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
kind: "plan",
|
|
219
|
+
plan: readPlan(operation, readTextEdits(renameResult)),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
case "move-file": {
|
|
223
|
+
if (existsSync(operation.newFilePath)) {
|
|
224
|
+
return {
|
|
225
|
+
kind: "skip",
|
|
226
|
+
reason: `Cannot move test file because the canonical destination already exists: ${operation.newFilePath}`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
kind: "plan",
|
|
232
|
+
plan: readPlan(operation, readMovedFileTextEdits(operation.filePath, operation.newFilePath), [
|
|
233
|
+
{
|
|
234
|
+
destinationFilePath: operation.newFilePath,
|
|
235
|
+
sourceFilePath: operation.filePath,
|
|
236
|
+
},
|
|
237
|
+
]),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
async dispose(): Promise<void> {
|
|
243
|
+
for (const client of clientCache.values()) {
|
|
244
|
+
await client.dispose();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
clientCache.clear();
|
|
248
|
+
},
|
|
249
|
+
name: "tsgo-lsp+native",
|
|
250
|
+
};
|
|
251
|
+
}
|