@stateward/language-server 0.0.0-stage → 1.0.1-rc.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/LICENSE +21 -0
- package/README.md +12 -2
- package/dist/adoption.d.ts +73 -0
- package/dist/adoption.js +122 -0
- package/dist/baseline.d.ts +17 -0
- package/dist/baseline.js +32 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.js +9 -0
- package/dist/configs.d.ts +13 -0
- package/dist/configs.js +53 -0
- package/dist/explain.d.ts +11 -0
- package/dist/explain.js +22 -0
- package/dist/fix-all.d.ts +44 -0
- package/dist/fix-all.js +92 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inlay-hints.d.ts +22 -0
- package/dist/inlay-hints.js +34 -0
- package/dist/mapping.d.ts +52 -0
- package/dist/mapping.js +193 -0
- package/dist/memory.d.ts +13 -0
- package/dist/memory.js +16 -0
- package/dist/merge.d.ts +16 -0
- package/dist/merge.js +20 -0
- package/dist/progress.d.ts +16 -0
- package/dist/progress.js +22 -0
- package/dist/report.d.ts +21 -0
- package/dist/report.js +70 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.js +1041 -0
- package/dist/watch.d.ts +31 -0
- package/dist/watch.js +59 -0
- package/package.json +49 -3
package/dist/server.js
ADDED
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { CodeActionKind, DidChangeConfigurationNotification, DidChangeWatchedFilesNotification, MarkupKind, ErrorCodes, MessageType, ResponseError, ShowMessageNotification, TextDocumentSyncKind, TextDocuments, } from "vscode-languageserver/node";
|
|
5
|
+
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
6
|
+
import { BaselineError, NothingToCheckError, activeRuleIds, TsconfigError, TypeScriptLibMissingError, check, computeImplementationId, configLocalImports, createChecker, defaultCacheDir, displayPath, loadConfigFile, openDiskCache, reloadsLocalImports, resultsCacheLimit, toTypeScriptPath, } from "@stateward/core/internal";
|
|
7
|
+
import { InvalidNextParamsError, NEXT_REQUEST, PLAN_REQUEST, RECHECK_REQUEST, STATUS_NOTIFICATION, applyNext, parseNextParams, planAnswer, rolloutOffAnswer, statusReport, } from "./adoption.js";
|
|
8
|
+
import { layBaseline } from "./baseline.js";
|
|
9
|
+
import { DEFAULT_HINT_SETTINGS, hintEnabled, readHintSettings, toInlayHints, } from "./inlay-hints.js";
|
|
10
|
+
import { InvalidExplainParamsError, answerExplain } from "./explain.js";
|
|
11
|
+
import { fixAllEdits, kindWanted, versionedAction, versionedEdit, } from "./fix-all.js";
|
|
12
|
+
import { mergeFastPass } from "./merge.js";
|
|
13
|
+
import { SILENT_PROGRESS, withProgress } from "./progress.js";
|
|
14
|
+
import { fastPassLine, fullPassLine, longestStepLine } from "./report.js";
|
|
15
|
+
import { TOKEN_TYPES, buildLineIndex, encodeTokens, offsetAt, rangesIntersect, toRange, toCodeActions, toLspChanges, toDiagnostic, toHiddenDiagnostic, toIgnoreAction, } from "./mapping.js";
|
|
16
|
+
import { SCRIPT_GLOB, classifyChange, createErrorGate, isImportableJson } from "./watch.js";
|
|
17
|
+
import { createConfigLocator, folderOf } from "./configs.js";
|
|
18
|
+
/** The wait after a config load, a watched-file change or a closed document, before a full pass. */
|
|
19
|
+
export const DEBOUNCE_MS = 200;
|
|
20
|
+
/** Quiet time after an edit before the fast pass (the edited file's own rules). */
|
|
21
|
+
export const FAST_MS = 100;
|
|
22
|
+
/** Quiet time after an edit before the full pass (the whole project); a save does not wait. */
|
|
23
|
+
export const FULL_MS = 1000;
|
|
24
|
+
/** A client that advertised progress but does not answer must not hold the check back. */
|
|
25
|
+
const PROGRESS_OPEN_TIMEOUT_MS = 500;
|
|
26
|
+
export const PROGRESS_TITLE = "Stateward: checking project";
|
|
27
|
+
/** Tells the extension a config's full pass started or ended, for its status bar. */
|
|
28
|
+
export const CHECKING_NOTIFICATION = "stateward/checking";
|
|
29
|
+
/** The least time between two progress reports of one full pass. */
|
|
30
|
+
const PROGRESS_REPORT_MS = 250;
|
|
31
|
+
export const FIX_ALL_KIND = `${CodeActionKind.SourceFixAll}.stateward`;
|
|
32
|
+
export const EXPLAIN_COMMAND = "stateward.explain";
|
|
33
|
+
export const EXPLAIN_REQUEST = "stateward/explain";
|
|
34
|
+
/** A review of fixes that takes at least this long is logged, to see what the menu costs. */
|
|
35
|
+
const REVIEW_LOG_MS = 50;
|
|
36
|
+
/** Quiet time after the last file of a nested config's project is closed, before its engine stops. */
|
|
37
|
+
export const ENGINE_IDLE_MS = 5 * 60 * 1000;
|
|
38
|
+
/**
|
|
39
|
+
* The least time between two writes of a config's results cache to disk: a write follows a full
|
|
40
|
+
* pass that completed, at most this often, and a config that stops (or a server that shuts down)
|
|
41
|
+
* writes what it has not written yet.
|
|
42
|
+
*/
|
|
43
|
+
export const DISK_SAVE_MS = 30 * 1000;
|
|
44
|
+
/** "1,200 of 3,000 files", or "project rules 3 of 13": where a full pass stands. */
|
|
45
|
+
function progressMessage(at) {
|
|
46
|
+
return at.phase === "files"
|
|
47
|
+
? `${at.done.toLocaleString("en-US")} of ${at.total.toLocaleString("en-US")} files`
|
|
48
|
+
: `project rules ${String(at.done)} of ${String(at.total)}`;
|
|
49
|
+
}
|
|
50
|
+
/** The files are most of a pass: they take 0 to 90%, the project rules the rest. */
|
|
51
|
+
function progressPercentage(at) {
|
|
52
|
+
const share = at.total === 0 ? 1 : at.done / at.total;
|
|
53
|
+
return Math.floor(at.phase === "files" ? 90 * share : 90 + 10 * share);
|
|
54
|
+
}
|
|
55
|
+
function isTypeScriptFile(uri) {
|
|
56
|
+
return uri.startsWith("file:") && /\.(?:[mc]?ts|tsx)$/.test(uri);
|
|
57
|
+
}
|
|
58
|
+
/** A document the editor syncs to the server: TypeScript, or a JSON file a program may import. */
|
|
59
|
+
function isSyncedFile(uri) {
|
|
60
|
+
return isTypeScriptFile(uri) || (uri.startsWith("file:") && isImportableJson(fileURLToPath(uri)));
|
|
61
|
+
}
|
|
62
|
+
/** The popup text for an error from a check. The errors stateward names are already readable. */
|
|
63
|
+
function checkErrorText(error) {
|
|
64
|
+
if (error instanceof NothingToCheckError ||
|
|
65
|
+
error instanceof TsconfigError ||
|
|
66
|
+
error instanceof TypeScriptLibMissingError) {
|
|
67
|
+
return `${error.message}. Fix: ${error.hint}`;
|
|
68
|
+
}
|
|
69
|
+
// The message already says which file and what to do.
|
|
70
|
+
if (error instanceof BaselineError)
|
|
71
|
+
return `stateward: ${error.message}`;
|
|
72
|
+
return `stateward check failed: ${describe(error)}`;
|
|
73
|
+
}
|
|
74
|
+
/** "Explain <rule id>" for each rule among the stateward diagnostics; the client runs `stateward.explain`. */
|
|
75
|
+
function explainActions(diagnostics) {
|
|
76
|
+
const ruleIds = new Set(diagnostics.flatMap((diagnostic) => diagnostic.source === "stateward" && typeof diagnostic.code === "string"
|
|
77
|
+
? [diagnostic.code]
|
|
78
|
+
: []));
|
|
79
|
+
return [...ruleIds].map((ruleId) => ({
|
|
80
|
+
title: `Explain ${ruleId}`,
|
|
81
|
+
kind: CodeActionKind.QuickFix,
|
|
82
|
+
command: { title: `Explain ${ruleId}`, command: EXPLAIN_COMMAND, arguments: [ruleId] },
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
function describe(error) {
|
|
86
|
+
return error instanceof Error ? error.message : String(error);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Wires the stateward language server to `connection`. The caller starts listening. The returned
|
|
90
|
+
* function stops the server's timers (also done on shutdown); call it before disposing the connection.
|
|
91
|
+
*/
|
|
92
|
+
export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (project) => defaultCacheDir(project, process.env), saveMs = DISK_SAVE_MS, } = {}) {
|
|
93
|
+
const documents = new TextDocuments(TextDocument);
|
|
94
|
+
/** The workspace folders; undefined until the client has initialized. */
|
|
95
|
+
let folders = [];
|
|
96
|
+
/** The client lets the server add file watchers (`SCRIPT_GLOB`). */
|
|
97
|
+
let watchScripts = false;
|
|
98
|
+
/** The client can be told to ask for inline hints again (after a check, a settings change). */
|
|
99
|
+
let refreshHints = false;
|
|
100
|
+
/** The client answers `workspace/configuration`. */
|
|
101
|
+
let canPullSettings = false;
|
|
102
|
+
/** The client lets the server ask to be told when settings change. */
|
|
103
|
+
let watchSettings = false;
|
|
104
|
+
let locator = createConfigLocator(folders);
|
|
105
|
+
/** One engine per config file, by its path. */
|
|
106
|
+
const engines = new Map();
|
|
107
|
+
const errorGate = createErrorGate();
|
|
108
|
+
let stopped = false;
|
|
109
|
+
const overlays = () => new Map(documents
|
|
110
|
+
.all()
|
|
111
|
+
.filter((document) => isSyncedFile(document.uri))
|
|
112
|
+
.map((document) => [fileURLToPath(document.uri), document.getText()]));
|
|
113
|
+
const openVersions = () => new Map(documents
|
|
114
|
+
.all()
|
|
115
|
+
.filter((document) => isTypeScriptFile(document.uri))
|
|
116
|
+
.map((document) => [toTypeScriptPath(fileURLToPath(document.uri)), document.version]));
|
|
117
|
+
/**
|
|
118
|
+
* The version each open file document had when it was opened, by path (`toTypeScriptPath`): one
|
|
119
|
+
* still at that version holds the text it was opened with, so its fast pass may take its results
|
|
120
|
+
* from the cache.
|
|
121
|
+
*/
|
|
122
|
+
const versionsAtOpen = new Map();
|
|
123
|
+
/** The files of `files` whose document is unedited since it was opened. */
|
|
124
|
+
const uneditedSinceOpen = (files) => {
|
|
125
|
+
const now = openVersions();
|
|
126
|
+
return files.filter((file) => {
|
|
127
|
+
const version = now.get(toTypeScriptPath(file));
|
|
128
|
+
return version !== undefined && version === versionsAtOpen.get(toTypeScriptPath(file));
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
/** The engine of the config `file` belongs to, if it runs. */
|
|
132
|
+
const engineOf = (file) => {
|
|
133
|
+
const configFile = locator.configOf(file);
|
|
134
|
+
return configFile === undefined ? undefined : engines.get(configFile);
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Whether `engine` shows the findings of `file`: the file's own config does. A file no config in
|
|
138
|
+
* the workspace claims (a shared folder outside it) is shown by whichever engine checked it.
|
|
139
|
+
*/
|
|
140
|
+
const shows = (engine, file) => {
|
|
141
|
+
const owner = locator.configOf(file);
|
|
142
|
+
return owner === undefined || owner === engine.configFile;
|
|
143
|
+
};
|
|
144
|
+
/** The config's name in log lines, only when several configs run (one is the usual case). */
|
|
145
|
+
const suffix = (engine) => {
|
|
146
|
+
if (engines.size < 2)
|
|
147
|
+
return "";
|
|
148
|
+
const folder = folderOf(engine.configFile, folders) ?? engine.cwd;
|
|
149
|
+
return ` (${displayPath(folder, engine.configFile)})`;
|
|
150
|
+
};
|
|
151
|
+
const fileMapper = (open) => {
|
|
152
|
+
const indexes = new Map();
|
|
153
|
+
return {
|
|
154
|
+
uriOf: (file) => pathToFileURL(file).href,
|
|
155
|
+
textOf: (file) => open.get(file) ?? readFileSync(file, "utf8"),
|
|
156
|
+
indexOf: (file) => {
|
|
157
|
+
const cached = indexes.get(file);
|
|
158
|
+
if (cached !== undefined)
|
|
159
|
+
return cached;
|
|
160
|
+
const index = buildLineIndex(open.get(file) ?? readFileSync(file, "utf8"));
|
|
161
|
+
indexes.set(file, index);
|
|
162
|
+
return index;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
/** The diagnostics by URI, and the fix sites by file, for `findings` (hidden counts come first in a file). */
|
|
167
|
+
const render = (findings, hidden, open, baselined = new Set()) => {
|
|
168
|
+
const files = fileMapper(open);
|
|
169
|
+
const byUri = new Map();
|
|
170
|
+
const sites = new Map();
|
|
171
|
+
for (const finding of findings) {
|
|
172
|
+
// `unknowns: "off"` leaves places not checked out of the editor; the log line counts them.
|
|
173
|
+
if (finding.kind === "unknown" && finding.shown === "off")
|
|
174
|
+
continue;
|
|
175
|
+
const uri = files.uriOf(finding.location.file);
|
|
176
|
+
const diagnostic = toDiagnostic(finding, files, baselined.has(finding));
|
|
177
|
+
byUri.set(uri, [...(byUri.get(uri) ?? []), diagnostic]);
|
|
178
|
+
const fixable = finding.kind === "problem" && finding.fixes.length > 0;
|
|
179
|
+
const ignore = toIgnoreAction(finding, diagnostic, files);
|
|
180
|
+
if (fixable || ignore !== undefined) {
|
|
181
|
+
const key = toTypeScriptPath(finding.location.file);
|
|
182
|
+
sites.set(key, [
|
|
183
|
+
...(sites.get(key) ?? []),
|
|
184
|
+
{
|
|
185
|
+
range: diagnostic.range,
|
|
186
|
+
finding,
|
|
187
|
+
diagnostic,
|
|
188
|
+
files,
|
|
189
|
+
...(ignore === undefined ? {} : { ignore }),
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const notShown = new Map();
|
|
195
|
+
for (const entry of hidden) {
|
|
196
|
+
const uri = files.uriOf(entry.file);
|
|
197
|
+
notShown.set(uri, [...(notShown.get(uri) ?? []), toHiddenDiagnostic(entry)]);
|
|
198
|
+
}
|
|
199
|
+
for (const [uri, diagnostics] of notShown) {
|
|
200
|
+
byUri.set(uri, [...diagnostics, ...(byUri.get(uri) ?? [])]);
|
|
201
|
+
}
|
|
202
|
+
return { byUri, sites };
|
|
203
|
+
};
|
|
204
|
+
/** One popup and one log entry for a check that threw; the diagnostics already shown stay. */
|
|
205
|
+
const reportFailure = (error) => {
|
|
206
|
+
showError(checkErrorText(error));
|
|
207
|
+
connection.console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* The whole project of one config: every rule, every file. Replaces its diagnostics. It checks in
|
|
211
|
+
* slices, so edits and requests are answered meanwhile; an edit cancels it (`run.cancelled`), as
|
|
212
|
+
* does a config that stopped or was reloaded, and then nothing is published.
|
|
213
|
+
*/
|
|
214
|
+
const fullPass = async (engine, run, progress) => {
|
|
215
|
+
const { state } = engine;
|
|
216
|
+
if (state.kind !== "ready")
|
|
217
|
+
return;
|
|
218
|
+
clearTimeout(engine.fastTimer);
|
|
219
|
+
engine.pendingFast.clear();
|
|
220
|
+
const open = overlays();
|
|
221
|
+
let reported = 0;
|
|
222
|
+
try {
|
|
223
|
+
const outcome = await state.checker.checkInSlices(open, {
|
|
224
|
+
cancelled: () => run.cancelled ||
|
|
225
|
+
stopped ||
|
|
226
|
+
engines.get(engine.configFile) !== engine ||
|
|
227
|
+
engine.state !== state,
|
|
228
|
+
progress: (at) => {
|
|
229
|
+
const now = performance.now();
|
|
230
|
+
if (now - reported < PROGRESS_REPORT_MS)
|
|
231
|
+
return;
|
|
232
|
+
reported = now;
|
|
233
|
+
progress.report(progressMessage(at), progressPercentage(at));
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
const longest = longestStepLine(outcome.longest, engine.cwd);
|
|
237
|
+
if (longest !== undefined && !stopped) {
|
|
238
|
+
connection.console.log(`stateward: ${longest}${suffix(engine)}`);
|
|
239
|
+
}
|
|
240
|
+
if (outcome.kind === "cancelled") {
|
|
241
|
+
if (!stopped) {
|
|
242
|
+
connection.console.log(`stateward: project check stopped by a newer change${suffix(engine)}`);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const raw = outcome.result;
|
|
247
|
+
const pass = layBaseline(state.config, raw, activeRuleIds(state.config, { kind: "configured" }), open);
|
|
248
|
+
const result = pass.fresh;
|
|
249
|
+
const { byUri, sites } = render(pass.findings.filter((finding) => shows(engine, finding.location.file)), pass.hidden.filter((entry) => shows(engine, entry.file)), open, pass.baselined);
|
|
250
|
+
for (const uri of engine.published) {
|
|
251
|
+
if (!byUri.has(uri))
|
|
252
|
+
void connection.sendDiagnostics({ uri, diagnostics: [] });
|
|
253
|
+
}
|
|
254
|
+
for (const [uri, diagnostics] of byUri)
|
|
255
|
+
void connection.sendDiagnostics({ uri, diagnostics });
|
|
256
|
+
engine.published = new Set(byUri.keys());
|
|
257
|
+
engine.fixSites = sites;
|
|
258
|
+
engine.fixVersions = openVersions();
|
|
259
|
+
errorGate.recovered();
|
|
260
|
+
connection.console.log(`${fullPassLine(result)}${suffix(engine)}`);
|
|
261
|
+
void connection.sendNotification(STATUS_NOTIFICATION, {
|
|
262
|
+
...statusReport(state.config, result, pass.baselined.size),
|
|
263
|
+
configFile: engine.configFile,
|
|
264
|
+
});
|
|
265
|
+
// The hints come from this pass's summaries.
|
|
266
|
+
if (refreshHints)
|
|
267
|
+
connection.languages.inlayHint.refresh().catch(logUnexpected);
|
|
268
|
+
saveSoon(engine);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
reportFailure(error);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
/** Opens a progress if the client advertised them; the library answers a no-op otherwise. */
|
|
275
|
+
const openProgress = async () => {
|
|
276
|
+
let timeout;
|
|
277
|
+
const silent = new Promise((resolve) => {
|
|
278
|
+
timeout = setTimeout(() => {
|
|
279
|
+
resolve(SILENT_PROGRESS);
|
|
280
|
+
}, PROGRESS_OPEN_TIMEOUT_MS);
|
|
281
|
+
});
|
|
282
|
+
const opened = connection.window
|
|
283
|
+
.createWorkDoneProgress()
|
|
284
|
+
.then((reporter) => ({
|
|
285
|
+
begin: (title) => {
|
|
286
|
+
reporter.begin(title, 0);
|
|
287
|
+
},
|
|
288
|
+
// A pass still unwinding after the server stopped must not write to a closed connection.
|
|
289
|
+
report: (message, percentage) => {
|
|
290
|
+
if (!stopped)
|
|
291
|
+
reporter.report(percentage, message);
|
|
292
|
+
},
|
|
293
|
+
done: () => {
|
|
294
|
+
if (!stopped)
|
|
295
|
+
reporter.done();
|
|
296
|
+
},
|
|
297
|
+
}));
|
|
298
|
+
try {
|
|
299
|
+
return await Promise.race([opened, silent]);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
clearTimeout(timeout);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const isCancelled = (run) => run.cancelled;
|
|
306
|
+
const quietProgress = () => Promise.resolve(SILENT_PROGRESS);
|
|
307
|
+
/** Stops the running full pass of `engine` at its next slice, if one runs. */
|
|
308
|
+
const cancelFull = (engine) => {
|
|
309
|
+
if (engine.fullRun !== undefined)
|
|
310
|
+
engine.fullRun.cancelled = true;
|
|
311
|
+
};
|
|
312
|
+
const runFull = async (engine) => {
|
|
313
|
+
if (engine.state.kind !== "ready")
|
|
314
|
+
return;
|
|
315
|
+
if (engine.fullRun !== undefined) {
|
|
316
|
+
// The newer pass supersedes the running one: it stops, and this one runs when it has.
|
|
317
|
+
engine.fullRun.cancelled = true;
|
|
318
|
+
engine.fullAgain = true;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const run = { cancelled: false };
|
|
322
|
+
engine.fullRun = run;
|
|
323
|
+
void connection.sendNotification(CHECKING_NOTIFICATION, {
|
|
324
|
+
configFile: engine.configFile,
|
|
325
|
+
checking: true,
|
|
326
|
+
});
|
|
327
|
+
try {
|
|
328
|
+
// Only the first pass of a config is waited for; later ones show in the status bar alone.
|
|
329
|
+
const open = engine.firstFullDone ? quietProgress : openProgress;
|
|
330
|
+
await withProgress(open, PROGRESS_TITLE, async (progress) => {
|
|
331
|
+
// Let the progress reach the client before the first slice.
|
|
332
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
333
|
+
// A config that stopped while the progress opened is not checked any more.
|
|
334
|
+
if (engines.get(engine.configFile) === engine && !run.cancelled) {
|
|
335
|
+
await fullPass(engine, run, progress);
|
|
336
|
+
if (!isCancelled(run))
|
|
337
|
+
engine.firstFullDone = true;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
engine.fullRun = undefined;
|
|
343
|
+
if (!stopped) {
|
|
344
|
+
void connection.sendNotification(CHECKING_NOTIFICATION, {
|
|
345
|
+
configFile: engine.configFile,
|
|
346
|
+
checking: false,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (engine.fullAgain) {
|
|
351
|
+
engine.fullAgain = false;
|
|
352
|
+
void runFull(engine).catch(logUnexpected);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
/** The open files that changed: their own rules only, merged with the project rules' last findings. */
|
|
356
|
+
const fastPass = (engine) => {
|
|
357
|
+
// Its findings would be older than this pass's: an edit already stopped it, this makes sure.
|
|
358
|
+
cancelFull(engine);
|
|
359
|
+
const files = [...engine.pendingFast];
|
|
360
|
+
engine.pendingFast.clear();
|
|
361
|
+
const { state } = engine;
|
|
362
|
+
if (state.kind !== "ready")
|
|
363
|
+
return;
|
|
364
|
+
const { checker: editor, config } = state;
|
|
365
|
+
const open = overlays();
|
|
366
|
+
const targets = files.filter((file) => open.has(file));
|
|
367
|
+
if (targets.length === 0)
|
|
368
|
+
return;
|
|
369
|
+
try {
|
|
370
|
+
// A document just opened usually holds the text the saved results were computed on: its
|
|
371
|
+
// findings then come from the cache, with no type checking (a large app's first findings).
|
|
372
|
+
const result = editor.checkFiles(targets, open, uneditedSinceOpen(targets));
|
|
373
|
+
for (const file of targets) {
|
|
374
|
+
const merged = mergeFastPass({ file, fast: result });
|
|
375
|
+
// Only the file rules ran: the project rules' entries are not matched here.
|
|
376
|
+
const fileRules = new Set([...activeRuleIds(config, { kind: "configured" })].filter((id) => !editor.projectRuleIds.has(id)));
|
|
377
|
+
const pass = layBaseline(config, { findings: merged.findings, hidden: [], stats: result.stats }, fileRules, open);
|
|
378
|
+
const { byUri, sites } = render(pass.findings, pass.hidden, open, pass.baselined);
|
|
379
|
+
const uri = pathToFileURL(file).href;
|
|
380
|
+
const diagnostics = byUri.get(uri) ?? [];
|
|
381
|
+
void connection.sendDiagnostics({ uri, diagnostics });
|
|
382
|
+
if (diagnostics.length === 0)
|
|
383
|
+
engine.published.delete(uri);
|
|
384
|
+
else
|
|
385
|
+
engine.published.add(uri);
|
|
386
|
+
engine.fixSites.set(toTypeScriptPath(file), sites.get(toTypeScriptPath(file)) ?? []);
|
|
387
|
+
const version = openVersions().get(toTypeScriptPath(file));
|
|
388
|
+
if (version !== undefined)
|
|
389
|
+
engine.fixVersions.set(toTypeScriptPath(file), version);
|
|
390
|
+
}
|
|
391
|
+
errorGate.recovered();
|
|
392
|
+
connection.console.log(`${fastPassLine(targets.map((file) => displayPath(engine.cwd, file)), result)}${suffix(engine)}`);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
reportFailure(error);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
/** A popup for `message`, unless it is the one shown last. */
|
|
399
|
+
const showError = (message) => {
|
|
400
|
+
if (!errorGate.shouldShow(message))
|
|
401
|
+
return;
|
|
402
|
+
void connection.sendNotification(ShowMessageNotification.type, {
|
|
403
|
+
type: MessageType.Error,
|
|
404
|
+
message,
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
/** A warning popup for what the config should change though it still works (old rule ids). */
|
|
408
|
+
const showWarnings = (warnings) => {
|
|
409
|
+
if (warnings.length === 0)
|
|
410
|
+
return;
|
|
411
|
+
void connection.sendNotification(ShowMessageNotification.type, {
|
|
412
|
+
type: MessageType.Warning,
|
|
413
|
+
message: `stateward: ${warnings.join("\n")}`,
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
const logUnexpected = (error) => {
|
|
417
|
+
connection.console.error(`stateward check failed unexpectedly: ${describe(error)}`);
|
|
418
|
+
};
|
|
419
|
+
/** A full pass of `engine` after `delay` ms of quiet; a later call restarts the wait. */
|
|
420
|
+
const scheduleFull = (engine, delay) => {
|
|
421
|
+
clearTimeout(engine.fullTimer);
|
|
422
|
+
engine.fullTimer = setTimeout(() => void runFull(engine).catch(logUnexpected), delay);
|
|
423
|
+
};
|
|
424
|
+
/** An edit of `file`: its fast pass soon, the full pass after a longer quiet. */
|
|
425
|
+
const scheduleEdit = (engine, file) => {
|
|
426
|
+
// A running full pass reads the text before this edit: stop it, so the fast pass runs soon and
|
|
427
|
+
// the full pass starts again after the pause.
|
|
428
|
+
cancelFull(engine);
|
|
429
|
+
engine.pendingFast.add(file);
|
|
430
|
+
clearTimeout(engine.fastTimer);
|
|
431
|
+
engine.fastTimer = setTimeout(() => {
|
|
432
|
+
fastPass(engine);
|
|
433
|
+
}, FAST_MS);
|
|
434
|
+
scheduleFull(engine, FULL_MS);
|
|
435
|
+
};
|
|
436
|
+
const clearDiagnostics = (engine) => {
|
|
437
|
+
for (const uri of engine.published)
|
|
438
|
+
void connection.sendDiagnostics({ uri, diagnostics: [] });
|
|
439
|
+
engine.published = new Set();
|
|
440
|
+
engine.fixSites = new Map();
|
|
441
|
+
engine.fixVersions = new Map();
|
|
442
|
+
};
|
|
443
|
+
/** Writes the results cache of `engine` now if it holds anything not written yet. */
|
|
444
|
+
const saveDisk = (engine) => {
|
|
445
|
+
clearTimeout(engine.saveTimer);
|
|
446
|
+
engine.saveTimer = undefined;
|
|
447
|
+
const { disk } = engine;
|
|
448
|
+
if (disk?.cache.changed !== true)
|
|
449
|
+
return;
|
|
450
|
+
const failed = disk.save();
|
|
451
|
+
engine.savedAt = performance.now();
|
|
452
|
+
// The findings are right; only the next start is slower. A log line, not a popup.
|
|
453
|
+
if (failed !== undefined) {
|
|
454
|
+
connection.console.log(`stateward: could not save the cache: ${failed.message}`);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
/** After a full pass completed: the results cache is written, at most once per `saveMs`. */
|
|
458
|
+
const saveSoon = (engine) => {
|
|
459
|
+
if (engine.disk?.cache.changed !== true || engine.saveTimer !== undefined)
|
|
460
|
+
return;
|
|
461
|
+
const wait = engine.savedAt === undefined ? 0 : engine.savedAt + saveMs - performance.now();
|
|
462
|
+
if (wait <= 0) {
|
|
463
|
+
saveDisk(engine);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
engine.saveTimer = setTimeout(() => {
|
|
467
|
+
engine.saveTimer = undefined;
|
|
468
|
+
// A pass that runs now holds results it has not stored yet: the next complete one writes.
|
|
469
|
+
if (engine.fullRun === undefined)
|
|
470
|
+
saveDisk(engine);
|
|
471
|
+
}, wait);
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* The results cache of `engine`'s project, read from the CLI's file; undefined without a cache
|
|
475
|
+
* folder. A missing, broken or other version's file is an empty cache (a cold start), never an
|
|
476
|
+
* error. The implementation id is computed again: a reload may have loaded changed local code.
|
|
477
|
+
*/
|
|
478
|
+
const openDisk = (engine) => {
|
|
479
|
+
const dir = cacheDir(engine.cwd);
|
|
480
|
+
if (dir === undefined)
|
|
481
|
+
return undefined;
|
|
482
|
+
return openDiskCache(dir, engine.cwd, engine.configFile, resultsCacheLimit(process.env), computeImplementationId(engine.configFile));
|
|
483
|
+
};
|
|
484
|
+
/** Loads the config of `engine` and replaces its checker; on failure nothing is checked until the config changes. */
|
|
485
|
+
const loadEngine = async (engine) => {
|
|
486
|
+
engine.generation += 1;
|
|
487
|
+
const generation = engine.generation;
|
|
488
|
+
engine.configImports = new Set(configLocalImports(engine.configFile).map(toTypeScriptPath));
|
|
489
|
+
const loaded = await loadConfigFile(engine.configFile);
|
|
490
|
+
if (generation !== engine.generation || engines.get(engine.configFile) !== engine)
|
|
491
|
+
return;
|
|
492
|
+
// The checker this replaces: what it computed is written, so a reload of the same code reads it.
|
|
493
|
+
saveDisk(engine);
|
|
494
|
+
engine.disk = undefined;
|
|
495
|
+
if (!loaded.ok) {
|
|
496
|
+
showError(`stateward: ${loaded.errors.join("\n")}`);
|
|
497
|
+
engine.state = { kind: "unconfigured" };
|
|
498
|
+
clearDiagnostics(engine);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
showWarnings(loaded.value.warnings);
|
|
502
|
+
const disk = openDisk(engine);
|
|
503
|
+
engine.disk = disk;
|
|
504
|
+
engine.savedAt = undefined;
|
|
505
|
+
engine.state = {
|
|
506
|
+
kind: "ready",
|
|
507
|
+
config: loaded.value,
|
|
508
|
+
// Uncapped: the baseline is laid over every finding, then the new ones are capped.
|
|
509
|
+
checker: createChecker({
|
|
510
|
+
config: loaded.value,
|
|
511
|
+
cwd: engine.cwd,
|
|
512
|
+
uncapped: true,
|
|
513
|
+
...(disk === undefined ? {} : { cache: disk.cache }),
|
|
514
|
+
}),
|
|
515
|
+
};
|
|
516
|
+
scheduleFull(engine, DEBOUNCE_MS);
|
|
517
|
+
};
|
|
518
|
+
/** The engine of `configFile`, started now if it was not running. */
|
|
519
|
+
const ensureEngine = (configFile, pinned) => {
|
|
520
|
+
const running = engines.get(configFile);
|
|
521
|
+
if (running !== undefined)
|
|
522
|
+
return running;
|
|
523
|
+
const engine = {
|
|
524
|
+
configFile,
|
|
525
|
+
cwd: path.dirname(configFile),
|
|
526
|
+
pinned,
|
|
527
|
+
state: { kind: "loading" },
|
|
528
|
+
generation: 0,
|
|
529
|
+
fastTimer: undefined,
|
|
530
|
+
fullTimer: undefined,
|
|
531
|
+
idleTimer: undefined,
|
|
532
|
+
pendingFast: new Set(),
|
|
533
|
+
fullRun: undefined,
|
|
534
|
+
fullAgain: false,
|
|
535
|
+
firstFullDone: false,
|
|
536
|
+
published: new Set(),
|
|
537
|
+
fixSites: new Map(),
|
|
538
|
+
fixVersions: new Map(),
|
|
539
|
+
configImports: new Set(),
|
|
540
|
+
disk: undefined,
|
|
541
|
+
saveTimer: undefined,
|
|
542
|
+
savedAt: undefined,
|
|
543
|
+
};
|
|
544
|
+
engines.set(configFile, engine);
|
|
545
|
+
void loadEngine(engine).catch(logUnexpected);
|
|
546
|
+
return engine;
|
|
547
|
+
};
|
|
548
|
+
/** Stops `engine`: its timers, its diagnostics, its checker. */
|
|
549
|
+
const disposeEngine = (engine, why) => {
|
|
550
|
+
if (engines.get(engine.configFile) !== engine)
|
|
551
|
+
return;
|
|
552
|
+
clearTimeout(engine.fastTimer);
|
|
553
|
+
clearTimeout(engine.fullTimer);
|
|
554
|
+
clearTimeout(engine.idleTimer);
|
|
555
|
+
engine.pendingFast.clear();
|
|
556
|
+
cancelFull(engine);
|
|
557
|
+
engine.generation += 1;
|
|
558
|
+
saveDisk(engine);
|
|
559
|
+
clearDiagnostics(engine);
|
|
560
|
+
engines.delete(engine.configFile);
|
|
561
|
+
connection.console.log(`stateward: stopped checking with ${engine.configFile}: ${why}`);
|
|
562
|
+
};
|
|
563
|
+
/** Whether a file of `engine`'s config is open in the editor. */
|
|
564
|
+
const hasOpenFile = (engine) => documents
|
|
565
|
+
.all()
|
|
566
|
+
.some((document) => isTypeScriptFile(document.uri) &&
|
|
567
|
+
locator.configOf(fileURLToPath(document.uri)) === engine.configFile);
|
|
568
|
+
/** A nested config with none of its files open stops after `idleMs`; opening one keeps it. */
|
|
569
|
+
const watchIdle = (engine) => {
|
|
570
|
+
clearTimeout(engine.idleTimer);
|
|
571
|
+
engine.idleTimer = undefined;
|
|
572
|
+
if (engine.pinned || hasOpenFile(engine))
|
|
573
|
+
return;
|
|
574
|
+
engine.idleTimer = setTimeout(() => {
|
|
575
|
+
if (!hasOpenFile(engine))
|
|
576
|
+
disposeEngine(engine, "none of its files is open");
|
|
577
|
+
}, idleMs);
|
|
578
|
+
};
|
|
579
|
+
/** The engine an open document belongs to, started if needed; undefined outside every config. */
|
|
580
|
+
const engineForDocument = (uri) => {
|
|
581
|
+
if (!isSyncedFile(uri) || stopped)
|
|
582
|
+
return undefined;
|
|
583
|
+
const configFile = locator.configOf(fileURLToPath(uri));
|
|
584
|
+
if (configFile === undefined)
|
|
585
|
+
return undefined;
|
|
586
|
+
const engine = ensureEngine(configFile, false);
|
|
587
|
+
clearTimeout(engine.idleTimer);
|
|
588
|
+
engine.idleTimer = undefined;
|
|
589
|
+
return engine;
|
|
590
|
+
};
|
|
591
|
+
/** After a config appeared or went: start what open files and folders need, stop what is gone. */
|
|
592
|
+
const reconcile = () => {
|
|
593
|
+
locator.forget();
|
|
594
|
+
for (const engine of [...engines.values()]) {
|
|
595
|
+
if (!existsSync(engine.configFile))
|
|
596
|
+
disposeEngine(engine, "the config file was removed");
|
|
597
|
+
}
|
|
598
|
+
for (const configFile of locator.rootConfigs())
|
|
599
|
+
ensureEngine(configFile, true);
|
|
600
|
+
for (const document of documents.all())
|
|
601
|
+
engineForDocument(document.uri);
|
|
602
|
+
for (const engine of engines.values())
|
|
603
|
+
watchIdle(engine);
|
|
604
|
+
};
|
|
605
|
+
connection.onInitialize((params) => {
|
|
606
|
+
watchScripts =
|
|
607
|
+
params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
|
|
608
|
+
refreshHints = params.capabilities.workspace?.inlayHint?.refreshSupport === true;
|
|
609
|
+
canPullSettings = params.capabilities.workspace?.configuration === true;
|
|
610
|
+
watchSettings =
|
|
611
|
+
params.capabilities.workspace?.didChangeConfiguration?.dynamicRegistration === true;
|
|
612
|
+
folders = (params.workspaceFolders ?? []).map((folder) => fileURLToPath(folder.uri));
|
|
613
|
+
locator = createConfigLocator(folders);
|
|
614
|
+
return {
|
|
615
|
+
capabilities: {
|
|
616
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
617
|
+
hoverProvider: true,
|
|
618
|
+
inlayHintProvider: true,
|
|
619
|
+
codeActionProvider: {
|
|
620
|
+
codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.RefactorRewrite, FIX_ALL_KIND],
|
|
621
|
+
},
|
|
622
|
+
semanticTokensProvider: {
|
|
623
|
+
legend: { tokenTypes: [...TOKEN_TYPES], tokenModifiers: [] },
|
|
624
|
+
full: true,
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
connection.onInitialized(() => {
|
|
630
|
+
if (watchSettings) {
|
|
631
|
+
connection.client
|
|
632
|
+
.register(DidChangeConfigurationNotification.type, { section: "stateward" })
|
|
633
|
+
.catch(logUnexpected);
|
|
634
|
+
}
|
|
635
|
+
if (canPullSettings)
|
|
636
|
+
pullSettings().catch(logUnexpected);
|
|
637
|
+
// The editor watches TypeScript files; a config's local `.mjs` import is watched from here.
|
|
638
|
+
if (watchScripts) {
|
|
639
|
+
connection.client
|
|
640
|
+
.register(DidChangeWatchedFilesNotification.type, {
|
|
641
|
+
watchers: [{ globPattern: SCRIPT_GLOB }],
|
|
642
|
+
})
|
|
643
|
+
.catch(logUnexpected);
|
|
644
|
+
}
|
|
645
|
+
if (folders.length === 0) {
|
|
646
|
+
connection.console.log("stateward: no workspace folder is open; not checking");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const roots = locator.rootConfigs();
|
|
650
|
+
if (roots.length === 0) {
|
|
651
|
+
connection.console.log(`stateward: no stateward.config found at the root of ${folders.join(", ")}; a file under a folder that has one is checked when you open it`);
|
|
652
|
+
}
|
|
653
|
+
reconcile();
|
|
654
|
+
});
|
|
655
|
+
connection.onDidChangeWatchedFiles((params) => {
|
|
656
|
+
const imported = new Set([...engines.values()].flatMap((engine) => [...engine.configImports]));
|
|
657
|
+
const changes = params.changes
|
|
658
|
+
.filter((change) => change.uri.startsWith("file:"))
|
|
659
|
+
.map((change) => ({
|
|
660
|
+
file: fileURLToPath(change.uri),
|
|
661
|
+
kind: classifyChange(fileURLToPath(change.uri), imported),
|
|
662
|
+
}));
|
|
663
|
+
const configs = changes.filter((change) => change.kind === "config");
|
|
664
|
+
const reload = new Set();
|
|
665
|
+
if (configs.length > 0) {
|
|
666
|
+
reconcile();
|
|
667
|
+
for (const { file } of configs) {
|
|
668
|
+
const engine = engines.get(file);
|
|
669
|
+
if (engine !== undefined)
|
|
670
|
+
reload.add(engine);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
for (const { file, kind } of changes) {
|
|
674
|
+
if (kind !== "config-import")
|
|
675
|
+
continue;
|
|
676
|
+
if (/\.m?js$/.test(file) && !reloadsLocalImports()) {
|
|
677
|
+
// This Node cannot load an ES module afresh (no `module.register`): the old one stays.
|
|
678
|
+
connection.console.log(`stateward: ${path.basename(file)} changed; reload the window to use it (this Node loads a config's JavaScript imports once per session)`);
|
|
679
|
+
}
|
|
680
|
+
for (const engine of engines.values()) {
|
|
681
|
+
if (engine.configImports.has(toTypeScriptPath(file)))
|
|
682
|
+
reload.add(engine);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
for (const engine of reload) {
|
|
686
|
+
// Just started by `reconcile` (a new config) or a changed one: load it from its new text.
|
|
687
|
+
if (engine.state.kind !== "loading")
|
|
688
|
+
void loadEngine(engine);
|
|
689
|
+
}
|
|
690
|
+
if (changes.some((change) => change.kind === "source")) {
|
|
691
|
+
for (const engine of engines.values())
|
|
692
|
+
scheduleFull(engine, DEBOUNCE_MS);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
documents.onDidOpen((event) => {
|
|
696
|
+
if (event.document.uri.startsWith("file:")) {
|
|
697
|
+
versionsAtOpen.set(toTypeScriptPath(fileURLToPath(event.document.uri)), event.document.version);
|
|
698
|
+
}
|
|
699
|
+
engineForDocument(event.document.uri);
|
|
700
|
+
});
|
|
701
|
+
documents.onDidChangeContent((event) => {
|
|
702
|
+
const engine = engineForDocument(event.document.uri);
|
|
703
|
+
if (engine === undefined)
|
|
704
|
+
return;
|
|
705
|
+
const file = fileURLToPath(event.document.uri);
|
|
706
|
+
if (!isTypeScriptFile(event.document.uri)) {
|
|
707
|
+
// An imported JSON file: no findings of its own, but the files that import it change, and
|
|
708
|
+
// only the full pass checks those.
|
|
709
|
+
cancelFull(engine);
|
|
710
|
+
scheduleFull(engine, FULL_MS);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
// Fixes hold offsets of the text before this edit: offer none until the fast pass recomputes them.
|
|
714
|
+
engine.fixSites.delete(toTypeScriptPath(file));
|
|
715
|
+
scheduleEdit(engine, file);
|
|
716
|
+
});
|
|
717
|
+
documents.onDidSave((event) => {
|
|
718
|
+
const engine = engineForDocument(event.document.uri);
|
|
719
|
+
if (engine === undefined)
|
|
720
|
+
return;
|
|
721
|
+
clearTimeout(engine.fullTimer);
|
|
722
|
+
void runFull(engine).catch(logUnexpected);
|
|
723
|
+
});
|
|
724
|
+
documents.onDidClose((event) => {
|
|
725
|
+
if (event.document.uri.startsWith("file:")) {
|
|
726
|
+
versionsAtOpen.delete(toTypeScriptPath(fileURLToPath(event.document.uri)));
|
|
727
|
+
}
|
|
728
|
+
if (!isSyncedFile(event.document.uri))
|
|
729
|
+
return;
|
|
730
|
+
const engine = engineOf(fileURLToPath(event.document.uri));
|
|
731
|
+
if (engine === undefined)
|
|
732
|
+
return;
|
|
733
|
+
engine.pendingFast.delete(fileURLToPath(event.document.uri));
|
|
734
|
+
scheduleFull(engine, DEBOUNCE_MS);
|
|
735
|
+
watchIdle(engine);
|
|
736
|
+
});
|
|
737
|
+
/** The ready engines, in a fixed order (by config path). */
|
|
738
|
+
const readyEngines = () => [...engines.values()]
|
|
739
|
+
.filter((engine) => engine.state.kind === "ready")
|
|
740
|
+
.sort((a, b) => (a.configFile < b.configFile ? -1 : a.configFile > b.configFile ? 1 : 0));
|
|
741
|
+
/**
|
|
742
|
+
* The engine a command means: the one of the file it names (`uri`, the editor's active file),
|
|
743
|
+
* else the only one, else the first in a fixed order.
|
|
744
|
+
*/
|
|
745
|
+
const engineForRequest = (params) => {
|
|
746
|
+
const given = typeof params === "object" && params !== null && "uri" in params ? params.uri : undefined;
|
|
747
|
+
const uri = typeof given === "string" ? given : undefined;
|
|
748
|
+
const ready = readyEngines();
|
|
749
|
+
if (uri?.startsWith("file:") === true) {
|
|
750
|
+
const configFile = guarded(() => locator.configOf(fileURLToPath(uri)));
|
|
751
|
+
const found = ready.find((engine) => engine.configFile === configFile);
|
|
752
|
+
if (found !== undefined)
|
|
753
|
+
return found;
|
|
754
|
+
}
|
|
755
|
+
return ready[0];
|
|
756
|
+
};
|
|
757
|
+
connection.onRequest(EXPLAIN_REQUEST, async (params) => {
|
|
758
|
+
try {
|
|
759
|
+
const ready = readyEngines();
|
|
760
|
+
if (ready.length === 0)
|
|
761
|
+
return await answerExplain(undefined, params);
|
|
762
|
+
// Every config's plugins may differ: the first that knows the rule answers.
|
|
763
|
+
for (const engine of ready) {
|
|
764
|
+
const answer = await answerExplain(engine.state.config, params);
|
|
765
|
+
if (answer !== null)
|
|
766
|
+
return answer;
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
if (error instanceof InvalidExplainParamsError) {
|
|
772
|
+
return new ResponseError(ErrorCodes.InvalidParams, error.message);
|
|
773
|
+
}
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
/** Every rule over the project, like `stateward plan`: the plan needs the findings of rules that are not on yet. */
|
|
778
|
+
const checkAll = (config, cwd) => {
|
|
779
|
+
const open = overlays();
|
|
780
|
+
const everything = { kind: "all" };
|
|
781
|
+
const raw = check({ config, cwd, overlays: open, rules: everything, uncapped: true });
|
|
782
|
+
// Baselined findings do not count for the plan or hold `next` back.
|
|
783
|
+
return layBaseline(config, raw, activeRuleIds(config, everything), open).fresh;
|
|
784
|
+
};
|
|
785
|
+
connection.onRequest(PLAN_REQUEST, (params) => {
|
|
786
|
+
const engine = engineForRequest(params);
|
|
787
|
+
if (engine === undefined)
|
|
788
|
+
return unavailable();
|
|
789
|
+
try {
|
|
790
|
+
return planAnswer(engine.state.config, checkAll(engine.state.config, engine.cwd));
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
return failedRequest(error);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
connection.onRequest(NEXT_REQUEST, (params) => {
|
|
797
|
+
try {
|
|
798
|
+
const anyway = parseNextParams(params);
|
|
799
|
+
const engine = engineForRequest(params);
|
|
800
|
+
if (engine === undefined)
|
|
801
|
+
return unavailable();
|
|
802
|
+
const { config } = engine.state;
|
|
803
|
+
if (!config.rollout)
|
|
804
|
+
return rolloutOffAnswer(path.basename(engine.configFile));
|
|
805
|
+
const answer = applyNext(config, checkAll(config, engine.cwd), anyway);
|
|
806
|
+
// The rollout file is not watched: the rules that are on changed, so check again.
|
|
807
|
+
if (answer.kind === "turned-on")
|
|
808
|
+
scheduleFull(engine, DEBOUNCE_MS);
|
|
809
|
+
return answer;
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
if (error instanceof InvalidNextParamsError) {
|
|
813
|
+
return new ResponseError(ErrorCodes.InvalidParams, error.message);
|
|
814
|
+
}
|
|
815
|
+
return failedRequest(error);
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
connection.onRequest(RECHECK_REQUEST, async () => {
|
|
819
|
+
for (const engine of readyEngines()) {
|
|
820
|
+
clearTimeout(engine.fullTimer);
|
|
821
|
+
await runFull(engine);
|
|
822
|
+
}
|
|
823
|
+
return null;
|
|
824
|
+
});
|
|
825
|
+
/**
|
|
826
|
+
* The sites with their actions: each finding's fixes are tried first, and only those that compile
|
|
827
|
+
* are offered. All fixes of the file are tried together, which costs
|
|
828
|
+
* one program, about as much as one fix; a verdict is kept, so asking again is free.
|
|
829
|
+
*/
|
|
830
|
+
const reviewSites = (engine, pending) => {
|
|
831
|
+
const checker = engine.state.kind === "ready" ? engine.state.checker : undefined;
|
|
832
|
+
const fixable = pending.filter((site) => site.finding.kind === "problem" && site.finding.fixes.length > 0);
|
|
833
|
+
const started = performance.now();
|
|
834
|
+
const reviews = checker === undefined || fixable.length === 0
|
|
835
|
+
? []
|
|
836
|
+
: (guarded(() => checker.reviewFixes(fixable.map((site) => site.finding))) ?? []);
|
|
837
|
+
const elapsed = performance.now() - started;
|
|
838
|
+
if (elapsed >= REVIEW_LOG_MS) {
|
|
839
|
+
connection.console.log(`tried ${String(fixable.length)} findings' fixes in ${String(Math.round(elapsed))} ms`);
|
|
840
|
+
}
|
|
841
|
+
const reviewed = new Map(fixable.map((site, i) => [site, reviews[i]?.finding]));
|
|
842
|
+
return pending.map((site) => {
|
|
843
|
+
const finding = reviewed.get(site);
|
|
844
|
+
const form = finding?.kind === "problem" ? finding.fixes[0] : undefined;
|
|
845
|
+
return {
|
|
846
|
+
range: site.range,
|
|
847
|
+
...(form === undefined ? {} : { form }),
|
|
848
|
+
// A fix that could not be tried (or the check threw) is not offered.
|
|
849
|
+
actions: finding === undefined ? [] : toCodeActions(finding, site.diagnostic, site.files),
|
|
850
|
+
...(site.ignore === undefined ? {} : { extra: [site.ignore] }),
|
|
851
|
+
};
|
|
852
|
+
});
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* Verifies the fixes "fix all" chose as one set, as they are applied together; undefined when no checker is ready, so the fixes are applied as they were offered.
|
|
856
|
+
*/
|
|
857
|
+
const verifier = (engine, pending, sites, uri) => {
|
|
858
|
+
const checker = engine?.state.kind === "ready" ? engine.state.checker : undefined;
|
|
859
|
+
if (checker === undefined)
|
|
860
|
+
return undefined;
|
|
861
|
+
return (chosen) => {
|
|
862
|
+
const forms = chosen.flatMap((at) => sites[at]?.form ?? []);
|
|
863
|
+
const files = pending[chosen[0] ?? 0]?.files;
|
|
864
|
+
const review = forms.length === chosen.length ? guarded(() => checker.verifyFixSet(forms)) : undefined;
|
|
865
|
+
if (review === undefined || files === undefined) {
|
|
866
|
+
return { kept: chosen.map(() => false), edits: [] };
|
|
867
|
+
}
|
|
868
|
+
return { kept: review.kept, edits: toLspChanges(review.edits, files)[uri] ?? [] };
|
|
869
|
+
};
|
|
870
|
+
};
|
|
871
|
+
connection.onCodeAction((params) => {
|
|
872
|
+
const file = guarded(() => fileURLToPath(params.textDocument.uri));
|
|
873
|
+
const engine = file === undefined ? undefined : engineOf(file);
|
|
874
|
+
const key = file === undefined ? undefined : toTypeScriptPath(file);
|
|
875
|
+
const pending = key === undefined || engine === undefined ? [] : (engine.fixSites.get(key) ?? []);
|
|
876
|
+
const { only } = params.context;
|
|
877
|
+
const actions = [];
|
|
878
|
+
// The editor asks on every move of the cursor: fixes are tried only when one could be shown.
|
|
879
|
+
const wanted = pending.some((site) => rangesIntersect(site.range, params.range)) ||
|
|
880
|
+
(only !== undefined && kindWanted(only, FIX_ALL_KIND));
|
|
881
|
+
const sites = engine === undefined || !wanted ? [] : reviewSites(engine, pending);
|
|
882
|
+
const all = fixAllEdits(sites, params.textDocument.uri, verifier(engine, pending, sites, params.textDocument.uri));
|
|
883
|
+
const versionOf = (uri) => guarded(() => engine?.fixVersions.get(toTypeScriptPath(fileURLToPath(uri)))) ?? null;
|
|
884
|
+
const fixAll = {
|
|
885
|
+
edit: versionedEdit({ [params.textDocument.uri]: all.edits }, versionOf),
|
|
886
|
+
};
|
|
887
|
+
if (kindWanted(only, CodeActionKind.QuickFix)) {
|
|
888
|
+
actions.push(...sites
|
|
889
|
+
.filter((site) => rangesIntersect(site.range, params.range))
|
|
890
|
+
.flatMap((site) => [...site.actions, ...(site.extra ?? [])])
|
|
891
|
+
.map((action) => versionedAction(action, versionOf)), ...explainActions(params.context.diagnostics));
|
|
892
|
+
if (all.count >= 2) {
|
|
893
|
+
actions.push({
|
|
894
|
+
title: "Fix all stateward problems in this file",
|
|
895
|
+
kind: CodeActionKind.QuickFix,
|
|
896
|
+
...fixAll,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (all.count >= 1 && kindWanted(only, FIX_ALL_KIND)) {
|
|
901
|
+
actions.push({
|
|
902
|
+
title: "Fix all stateward problems in this file",
|
|
903
|
+
kind: FIX_ALL_KIND,
|
|
904
|
+
...fixAll,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
if (kindWanted(only, CodeActionKind.RefactorRewrite)) {
|
|
908
|
+
actions.push(...refactorActions(params.textDocument.uri, params.range, versionOf));
|
|
909
|
+
}
|
|
910
|
+
return actions;
|
|
911
|
+
});
|
|
912
|
+
/**
|
|
913
|
+
* Refactorings plugins offer on a range (no finding behind them), from the last check's
|
|
914
|
+
* summaries and tried by core before they are offered. Only when the open text is the text that
|
|
915
|
+
* check read, since the offsets are that text's.
|
|
916
|
+
*/
|
|
917
|
+
const refactorActions = (uri, range, versionOf) => guarded(() => {
|
|
918
|
+
const document = documents.get(uri);
|
|
919
|
+
const editor = checkerOf(uri);
|
|
920
|
+
if (document === undefined || editor === undefined)
|
|
921
|
+
return [];
|
|
922
|
+
const file = fileURLToPath(uri);
|
|
923
|
+
const text = document.getText();
|
|
924
|
+
if (editor.analysedText(file) !== text)
|
|
925
|
+
return [];
|
|
926
|
+
const index = buildLineIndex(text);
|
|
927
|
+
const found = editor.refactors(file, offsetAt(index, range.start), offsetAt(index, range.end));
|
|
928
|
+
return found.map((action) => versionedAction({
|
|
929
|
+
title: action.title,
|
|
930
|
+
kind: CodeActionKind.RefactorRewrite,
|
|
931
|
+
edit: {
|
|
932
|
+
changes: {
|
|
933
|
+
[uri]: action.edits.map((edit) => ({
|
|
934
|
+
range: toRange(index, edit.location),
|
|
935
|
+
newText: edit.newText,
|
|
936
|
+
})),
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
}, versionOf));
|
|
940
|
+
}) ?? [];
|
|
941
|
+
/** The checker of the config `uri`'s file belongs to, when it is ready. */
|
|
942
|
+
const checkerOf = (uri) => {
|
|
943
|
+
const engine = guarded(() => engineOf(fileURLToPath(uri)));
|
|
944
|
+
return engine?.state.kind === "ready" ? engine.state.checker : undefined;
|
|
945
|
+
};
|
|
946
|
+
connection.onHover((params) => {
|
|
947
|
+
const document = documents.get(params.textDocument.uri);
|
|
948
|
+
const editor = checkerOf(params.textDocument.uri);
|
|
949
|
+
if (document === undefined || editor === undefined)
|
|
950
|
+
return null;
|
|
951
|
+
const markdown = guarded(() => editor.hover(fileURLToPath(document.uri), offsetAt(buildLineIndex(document.getText()), params.position)));
|
|
952
|
+
if (markdown === undefined || markdown.length === 0)
|
|
953
|
+
return null;
|
|
954
|
+
return { contents: { kind: MarkupKind.Markdown, value: markdown.join("\n\n---\n\n") } };
|
|
955
|
+
});
|
|
956
|
+
/** Which inline hints the editor's settings turn on (`stateward.inlayHints.*`). */
|
|
957
|
+
let hintSettings = DEFAULT_HINT_SETTINGS;
|
|
958
|
+
connection.languages.inlayHint.on((params) => {
|
|
959
|
+
const document = documents.get(params.textDocument.uri);
|
|
960
|
+
const editor = checkerOf(params.textDocument.uri);
|
|
961
|
+
if (document === undefined || editor === undefined)
|
|
962
|
+
return [];
|
|
963
|
+
// Hints come from a full pass's summaries, and the client is asked again after each one: before
|
|
964
|
+
// the first, answering would build and bind the programs ahead of the open file's findings.
|
|
965
|
+
const engine = guarded(() => engineOf(fileURLToPath(document.uri)));
|
|
966
|
+
if (refreshHints && engine?.firstFullDone !== true)
|
|
967
|
+
return [];
|
|
968
|
+
const hints = guarded(() => {
|
|
969
|
+
const index = buildLineIndex(document.getText());
|
|
970
|
+
const found = editor.inlayHints(fileURLToPath(document.uri), (kind) => hintEnabled(hintSettings, kind));
|
|
971
|
+
return toInlayHints(found, index, {
|
|
972
|
+
start: offsetAt(index, params.range.start),
|
|
973
|
+
end: offsetAt(index, params.range.end),
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
return hints ?? [];
|
|
977
|
+
});
|
|
978
|
+
/** The `stateward` settings, pulled from the client (`workspace/configuration`). */
|
|
979
|
+
const pullSettings = async () => {
|
|
980
|
+
const section = await connection.workspace.getConfiguration("stateward");
|
|
981
|
+
hintSettings = readHintSettings(section);
|
|
982
|
+
if (refreshHints)
|
|
983
|
+
await connection.languages.inlayHint.refresh();
|
|
984
|
+
};
|
|
985
|
+
connection.onDidChangeConfiguration((params) => {
|
|
986
|
+
const pushed = params.settings;
|
|
987
|
+
if (canPullSettings) {
|
|
988
|
+
pullSettings().catch(logUnexpected);
|
|
989
|
+
}
|
|
990
|
+
else if (typeof pushed === "object" && pushed !== null && "stateward" in pushed) {
|
|
991
|
+
// A client that pushes its settings (and cannot be asked for them).
|
|
992
|
+
hintSettings = readHintSettings(pushed.stateward);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
connection.languages.semanticTokens.on((params) => {
|
|
996
|
+
const document = documents.get(params.textDocument.uri);
|
|
997
|
+
const editor = checkerOf(params.textDocument.uri);
|
|
998
|
+
const empty = { data: [] };
|
|
999
|
+
if (document === undefined || editor === undefined)
|
|
1000
|
+
return empty;
|
|
1001
|
+
const encoded = guarded(() => encodeTokens(editor.tokens(fileURLToPath(document.uri)), buildLineIndex(document.getText())));
|
|
1002
|
+
return encoded ?? empty;
|
|
1003
|
+
});
|
|
1004
|
+
/** Stops the timers: one that fires after the connection is gone would write to a closed connection. */
|
|
1005
|
+
const stop = () => {
|
|
1006
|
+
stopped = true;
|
|
1007
|
+
for (const engine of engines.values()) {
|
|
1008
|
+
clearTimeout(engine.fastTimer);
|
|
1009
|
+
clearTimeout(engine.fullTimer);
|
|
1010
|
+
clearTimeout(engine.idleTimer);
|
|
1011
|
+
engine.pendingFast.clear();
|
|
1012
|
+
cancelFull(engine);
|
|
1013
|
+
// A load still running must not start a check after the stop.
|
|
1014
|
+
engine.generation += 1;
|
|
1015
|
+
saveDisk(engine);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
connection.onShutdown(stop);
|
|
1019
|
+
documents.listen(connection);
|
|
1020
|
+
return stop;
|
|
1021
|
+
/** The config is missing or cannot be loaded: the answer says so; the cause was already shown. */
|
|
1022
|
+
function unavailable() {
|
|
1023
|
+
return new ResponseError(ErrorCodes.InvalidRequest, "stateward has no usable config for this folder; fix the config (see the Stateward output), then try again");
|
|
1024
|
+
}
|
|
1025
|
+
/** A check that threw, as the error the client shows. */
|
|
1026
|
+
function failedRequest(error) {
|
|
1027
|
+
connection.console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
1028
|
+
return new ResponseError(ErrorCodes.InternalError, checkErrorText(error));
|
|
1029
|
+
}
|
|
1030
|
+
/** A plugin's editor code must not take the server down: log it and answer nothing. */
|
|
1031
|
+
function guarded(compute) {
|
|
1032
|
+
try {
|
|
1033
|
+
return compute();
|
|
1034
|
+
}
|
|
1035
|
+
catch (error) {
|
|
1036
|
+
connection.console.error(`stateward editor request failed: ${describe(error)}`);
|
|
1037
|
+
return undefined;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
//# sourceMappingURL=server.js.map
|