@stateward/language-server 1.0.1 → 1.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/dist/adoption.d.ts +5 -0
- package/dist/adoption.js +4 -2
- package/dist/fix-all.d.ts +1 -1
- package/dist/fix-all.js +14 -5
- package/dist/guarantees.d.ts +46 -0
- package/dist/guarantees.js +180 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +87 -24
- package/package.json +4 -3
package/dist/adoption.d.ts
CHANGED
|
@@ -47,6 +47,11 @@ export type NextAnswer = {
|
|
|
47
47
|
| {
|
|
48
48
|
kind: "blocked";
|
|
49
49
|
message: string;
|
|
50
|
+
}
|
|
51
|
+
/** Every rule left could not run here (a library's types are missing): nothing was turned on. */
|
|
52
|
+
| {
|
|
53
|
+
kind: "not-checked";
|
|
54
|
+
message: string;
|
|
50
55
|
} | {
|
|
51
56
|
kind: "rollout-off";
|
|
52
57
|
message: string;
|
package/dist/adoption.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RolloutError, advance, buildPlan, describeTurnOn, readRollout, ruleIsOn, writeRollout, } from "@stateward/core/internal";
|
|
1
|
+
import { RolloutError, advance, buildPlan, describeNotChecked, describeTurnOn, readRollout, ruleIsOn, writeRollout, } from "@stateward/core/internal";
|
|
2
2
|
import { countFindings } from "./report.js";
|
|
3
3
|
export const STATUS_NOTIFICATION = "stateward/status";
|
|
4
4
|
export const PLAN_REQUEST = "stateward/plan";
|
|
@@ -70,7 +70,7 @@ export function parseNextParams(params) {
|
|
|
70
70
|
export function rolloutOffAnswer(configFileName) {
|
|
71
71
|
return {
|
|
72
72
|
kind: "rollout-off",
|
|
73
|
-
message: `
|
|
73
|
+
message: `Set \`rollout: true\` in ${configFileName} first: without it every rule runs, and there is no next rule to turn on.`,
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
@@ -87,6 +87,8 @@ export function applyNext(config, result, anyway) {
|
|
|
87
87
|
const enabled = readRollout(config);
|
|
88
88
|
const step = advance(buildPlan(config, result), enabled, anyway);
|
|
89
89
|
switch (step.kind) {
|
|
90
|
+
case "not-checked":
|
|
91
|
+
return { kind: "not-checked", message: describeNotChecked(step.rows) };
|
|
90
92
|
case "all-on":
|
|
91
93
|
return {
|
|
92
94
|
kind: "all-on",
|
package/dist/fix-all.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare function kindWanted(only: readonly string[] | undefined, kind: st
|
|
|
38
38
|
* edit whose version is not the document's current one, so a fix computed before an edit can
|
|
39
39
|
* never be applied to the text after it.
|
|
40
40
|
*/
|
|
41
|
-
export declare function versionedEdit(changes: Readonly<Record<string, readonly TextEdit[]>>, versionOf: (uri: string) => number | null): WorkspaceEdit;
|
|
41
|
+
export declare function versionedEdit(changes: Readonly<Record<string, readonly TextEdit[]>>, versionOf: (uri: string) => number | null, exists?: (uri: string) => boolean): WorkspaceEdit;
|
|
42
42
|
/** `action` with its `changes` edit turned into versioned `documentChanges`; unchanged when it has none. */
|
|
43
43
|
export declare function versionedAction(action: CodeAction, versionOf: (uri: string) => number | null): CodeAction;
|
|
44
44
|
//# sourceMappingURL=fix-all.d.ts.map
|
package/dist/fix-all.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
1
3
|
function compare(a, b) {
|
|
2
4
|
return a.line - b.line || a.character - b.character;
|
|
3
5
|
}
|
|
@@ -74,14 +76,21 @@ export function kindWanted(only, kind) {
|
|
|
74
76
|
* edit whose version is not the document's current one, so a fix computed before an edit can
|
|
75
77
|
* never be applied to the text after it.
|
|
76
78
|
*/
|
|
77
|
-
export function versionedEdit(changes, versionOf) {
|
|
79
|
+
export function versionedEdit(changes, versionOf, exists = fileExists) {
|
|
78
80
|
return {
|
|
79
|
-
documentChanges: Object.entries(changes).
|
|
80
|
-
|
|
81
|
-
edits: [...edits]
|
|
82
|
-
|
|
81
|
+
documentChanges: Object.entries(changes).flatMap(([uri, edits]) => {
|
|
82
|
+
const version = versionOf(uri);
|
|
83
|
+
const edit = { textDocument: { uri, version }, edits: [...edits] };
|
|
84
|
+
// A fix may write a file once (a guarantees file): it is created first, then filled.
|
|
85
|
+
if (version !== null || exists(uri))
|
|
86
|
+
return [edit];
|
|
87
|
+
return [{ kind: "create", uri, options: { ignoreIfExists: true } }, edit];
|
|
88
|
+
}),
|
|
83
89
|
};
|
|
84
90
|
}
|
|
91
|
+
function fileExists(uri) {
|
|
92
|
+
return !uri.startsWith("file:") || existsSync(fileURLToPath(uri));
|
|
93
|
+
}
|
|
85
94
|
/** `action` with its `changes` edit turned into versioned `documentChanges`; unchanged when it has none. */
|
|
86
95
|
export function versionedAction(action, versionOf) {
|
|
87
96
|
const changes = action.edit?.changes;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type CodeAction, type CodeLens, type Range } from "vscode-languageserver/node";
|
|
2
|
+
import type { GuaranteeProof } from "@stateward/core/internal";
|
|
3
|
+
/**
|
|
4
|
+
* The editor's view of the stated guarantees: a hover and a code lens on each marked
|
|
5
|
+
* declaration, and "Show proof path" on each error about one. Everything here answers from the
|
|
6
|
+
* last full pass (its proofs and the texts it read), so nothing is type checked on a request.
|
|
7
|
+
*/
|
|
8
|
+
/** Opens the places a guarantee covers (its errors first, then its readers) in a list to jump through. */
|
|
9
|
+
export declare const SHOW_COVERS_COMMAND = "stateward.showGuaranteeCovers";
|
|
10
|
+
/** Walks an error about a guarantee: where it fails, then each step of its path, then the declaration. */
|
|
11
|
+
export declare const SHOW_PROOF_PATH_COMMAND = "stateward.showProofPath";
|
|
12
|
+
/** One place to jump to, as the client's commands take it. */
|
|
13
|
+
export type Place = {
|
|
14
|
+
uri: string;
|
|
15
|
+
range: Range;
|
|
16
|
+
label: string;
|
|
17
|
+
};
|
|
18
|
+
/** The argument of both commands: a title and the places, in order. */
|
|
19
|
+
export type PlaceList = {
|
|
20
|
+
title: string;
|
|
21
|
+
places: Place[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The proofs of the last full pass, with the text of every file they point into as that pass read
|
|
25
|
+
* it: offsets are turned into lines with that text, and a file whose text has changed since gets
|
|
26
|
+
* no hover or lens until the next pass.
|
|
27
|
+
*/
|
|
28
|
+
export type ProofView = {
|
|
29
|
+
readonly proofs: readonly GuaranteeProof[];
|
|
30
|
+
/** By TypeScript path. */
|
|
31
|
+
readonly texts: ReadonlyMap<string, string>;
|
|
32
|
+
/** The project folder, for paths in a hover. */
|
|
33
|
+
readonly cwd: string;
|
|
34
|
+
};
|
|
35
|
+
export declare const NO_PROOFS: ProofView;
|
|
36
|
+
export declare function proofView(proofs: readonly GuaranteeProof[], textOf: (file: string) => string | undefined, cwd: string): ProofView;
|
|
37
|
+
/**
|
|
38
|
+
* The hover on a marked declaration: "Guaranteed: <what>; proven for N readers", or the first
|
|
39
|
+
* error and its path when it is not proven. Undefined when `offset` is on no declaration.
|
|
40
|
+
*/
|
|
41
|
+
export declare function guaranteeHover(view: ProofView, file: string, text: string, offset: number): string | undefined;
|
|
42
|
+
/** A code lens above each marked declaration of `file`: what it covers, and a command that lists it. */
|
|
43
|
+
export declare function guaranteeLenses(view: ProofView, file: string, text: string): CodeLens[];
|
|
44
|
+
/** "Show proof path" for each error about a guarantee whose place in `file` meets `range`. */
|
|
45
|
+
export declare function proofPathActions(view: ProofView, file: string, text: string, range: Range): CodeAction[];
|
|
46
|
+
//# sourceMappingURL=guarantees.d.ts.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { CodeActionKind, } from "vscode-languageserver/node";
|
|
3
|
+
import { describeStatus, displayPath } from "@stateward/core/internal";
|
|
4
|
+
import { buildLineIndex, toRange } from "./mapping.js";
|
|
5
|
+
/**
|
|
6
|
+
* The editor's view of the stated guarantees: a hover and a code lens on each marked
|
|
7
|
+
* declaration, and "Show proof path" on each error about one. Everything here answers from the
|
|
8
|
+
* last full pass (its proofs and the texts it read), so nothing is type checked on a request.
|
|
9
|
+
*/
|
|
10
|
+
/** Opens the places a guarantee covers (its errors first, then its readers) in a list to jump through. */
|
|
11
|
+
export const SHOW_COVERS_COMMAND = "stateward.showGuaranteeCovers";
|
|
12
|
+
/** Walks an error about a guarantee: where it fails, then each step of its path, then the declaration. */
|
|
13
|
+
export const SHOW_PROOF_PATH_COMMAND = "stateward.showProofPath";
|
|
14
|
+
/** At most this many steps of a path are shown in a hover; the command shows them all. */
|
|
15
|
+
const HOVER_STEPS = 4;
|
|
16
|
+
export const NO_PROOFS = { proofs: [], texts: new Map(), cwd: "" };
|
|
17
|
+
/** Every file a proof points into. */
|
|
18
|
+
function filesOf(proofs) {
|
|
19
|
+
const files = new Set();
|
|
20
|
+
for (const { stated, status } of proofs) {
|
|
21
|
+
files.add(stated.location.file);
|
|
22
|
+
for (const place of stated.covered)
|
|
23
|
+
files.add(place.location.file);
|
|
24
|
+
if (status.kind !== "unproven")
|
|
25
|
+
continue;
|
|
26
|
+
for (const error of status.errors) {
|
|
27
|
+
files.add(error.location.file);
|
|
28
|
+
for (const note of error.related)
|
|
29
|
+
files.add(note.location.file);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
export function proofView(proofs, textOf, cwd) {
|
|
35
|
+
const texts = new Map();
|
|
36
|
+
for (const file of filesOf(proofs)) {
|
|
37
|
+
const text = textOf(file);
|
|
38
|
+
if (text !== undefined)
|
|
39
|
+
texts.set(file, text);
|
|
40
|
+
}
|
|
41
|
+
return { proofs, texts, cwd };
|
|
42
|
+
}
|
|
43
|
+
function indexer(view) {
|
|
44
|
+
const indexes = new Map();
|
|
45
|
+
return (file) => {
|
|
46
|
+
const known = indexes.get(file);
|
|
47
|
+
if (known !== undefined)
|
|
48
|
+
return known;
|
|
49
|
+
const index = buildLineIndex(view.texts.get(file) ?? "");
|
|
50
|
+
indexes.set(file, index);
|
|
51
|
+
return index;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** The proofs declared in `file`, when the editor's text is still the one the pass read. */
|
|
55
|
+
function declaredIn(view, file, text) {
|
|
56
|
+
if (view.texts.get(file) !== text)
|
|
57
|
+
return [];
|
|
58
|
+
return view.proofs.filter((proof) => proof.stated.location.file === file);
|
|
59
|
+
}
|
|
60
|
+
function place(index, location, label) {
|
|
61
|
+
return {
|
|
62
|
+
uri: pathToFileURL(location.file).href,
|
|
63
|
+
range: toRange(index(location.file), location),
|
|
64
|
+
label,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function lineOf(view, index, location) {
|
|
68
|
+
const line = toRange(index(location.file), location).start.line + 1;
|
|
69
|
+
return `${displayPath(view.cwd, location.file)}:${String(line)}`;
|
|
70
|
+
}
|
|
71
|
+
/** An error's path as places: where it fails, each note (the declaration's last). */
|
|
72
|
+
function pathOf(view, index, error) {
|
|
73
|
+
return [
|
|
74
|
+
place(index, error.location, error.message),
|
|
75
|
+
...error.related.map((note) => place(index, note.location, note.message)),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
function errorsOf(proof) {
|
|
79
|
+
return proof.status.kind === "unproven" ? proof.status.errors : [];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The hover on a marked declaration: "Guaranteed: <what>; proven for N readers", or the first
|
|
83
|
+
* error and its path when it is not proven. Undefined when `offset` is on no declaration.
|
|
84
|
+
*/
|
|
85
|
+
export function guaranteeHover(view, file, text, offset) {
|
|
86
|
+
const proof = declaredIn(view, file, text).find(({ stated }) => offset >= stated.location.start && offset <= stated.location.end);
|
|
87
|
+
if (proof === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const { stated } = proof;
|
|
90
|
+
const head = `**Guaranteed:** ${stated.promise} (\`${stated.marker}\`); ${describeStatus(proof)}`;
|
|
91
|
+
const first = errorsOf(proof)[0];
|
|
92
|
+
if (first === undefined)
|
|
93
|
+
return head;
|
|
94
|
+
const index = indexer(view);
|
|
95
|
+
// The declaration's own note says what the hover already says.
|
|
96
|
+
const steps = first.related.filter((note) => note.location.file !== stated.location.file || note.location.start !== stated.location.start);
|
|
97
|
+
const shown = steps
|
|
98
|
+
.slice(0, HOVER_STEPS)
|
|
99
|
+
.map((note) => `- ${lineOf(view, index, note.location)}: ${note.message}`);
|
|
100
|
+
const more = steps.length > shown.length
|
|
101
|
+
? [`- … ${String(steps.length - shown.length)} more: "Show proof path" on the error`]
|
|
102
|
+
: [];
|
|
103
|
+
return [
|
|
104
|
+
head,
|
|
105
|
+
`First: ${first.message} (${lineOf(view, index, first.location)})`,
|
|
106
|
+
[...shown, ...more].join("\n"),
|
|
107
|
+
]
|
|
108
|
+
.filter((part) => part !== "")
|
|
109
|
+
.join("\n\n");
|
|
110
|
+
}
|
|
111
|
+
function lensTitle(proof) {
|
|
112
|
+
const { stated, status } = proof;
|
|
113
|
+
const count = stated.covered.length;
|
|
114
|
+
const covers = count === 1 ? stated.covers.replace(/s$/, "") : stated.covers;
|
|
115
|
+
switch (status.kind) {
|
|
116
|
+
case "proven":
|
|
117
|
+
return `${String(count)} ${covers} proven · show`;
|
|
118
|
+
case "unproven":
|
|
119
|
+
return `${String(status.errors.length)} not proven · ${String(count)} ${covers} · show`;
|
|
120
|
+
case "unused":
|
|
121
|
+
case "off":
|
|
122
|
+
return describeStatus(proof);
|
|
123
|
+
default: {
|
|
124
|
+
const unreachable = status;
|
|
125
|
+
return unreachable;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** A code lens above each marked declaration of `file`: what it covers, and a command that lists it. */
|
|
130
|
+
export function guaranteeLenses(view, file, text) {
|
|
131
|
+
const index = indexer(view);
|
|
132
|
+
return declaredIn(view, file, text).map((proof) => {
|
|
133
|
+
const { stated } = proof;
|
|
134
|
+
const places = [
|
|
135
|
+
...errorsOf(proof).map((error) => place(index, error.location, `not proven: ${error.message}`)),
|
|
136
|
+
...stated.covered.map((covered) => place(index, covered.location, covered.name)),
|
|
137
|
+
];
|
|
138
|
+
const argument = { title: `${stated.name}: ${describeStatus(proof)}`, places };
|
|
139
|
+
const range = toRange(index(file), stated.location);
|
|
140
|
+
return {
|
|
141
|
+
range: { start: range.start, end: range.start },
|
|
142
|
+
command: {
|
|
143
|
+
title: lensTitle(proof),
|
|
144
|
+
// A lens with nothing to list is a label: no command runs when it is clicked.
|
|
145
|
+
command: places.length === 0 ? "" : SHOW_COVERS_COMMAND,
|
|
146
|
+
arguments: places.length === 0 ? [] : [argument],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** "Show proof path" for each error about a guarantee whose place in `file` meets `range`. */
|
|
152
|
+
export function proofPathActions(view, file, text, range) {
|
|
153
|
+
if (view.texts.get(file) !== text)
|
|
154
|
+
return [];
|
|
155
|
+
const index = indexer(view);
|
|
156
|
+
return view.proofs.flatMap((proof) => errorsOf(proof).flatMap((error) => {
|
|
157
|
+
if (error.location.file !== file)
|
|
158
|
+
return [];
|
|
159
|
+
const at = toRange(index(file), error.location);
|
|
160
|
+
const meets = !(isBefore(at.end, range.start) || isBefore(range.end, at.start));
|
|
161
|
+
if (!meets)
|
|
162
|
+
return [];
|
|
163
|
+
const argument = {
|
|
164
|
+
title: `Proof path: ${error.message}`,
|
|
165
|
+
places: pathOf(view, index, error),
|
|
166
|
+
};
|
|
167
|
+
const title = `Show the proof path of ${proof.stated.name}`;
|
|
168
|
+
return [
|
|
169
|
+
{
|
|
170
|
+
title,
|
|
171
|
+
kind: CodeActionKind.QuickFix,
|
|
172
|
+
command: { title, command: SHOW_PROOF_PATH_COMMAND, arguments: [argument] },
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
function isBefore(a, b) {
|
|
178
|
+
return a.line < b.line || (a.line === b.line && a.character < b.character);
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=guarantees.js.map
|
package/dist/server.d.ts
CHANGED
|
@@ -23,12 +23,16 @@ export declare const DISK_SAVE_MS: number;
|
|
|
23
23
|
* Wires the stateward language server to `connection`. The caller starts listening. The returned
|
|
24
24
|
* function stops the server's timers (also done on shutdown); call it before disposing the connection.
|
|
25
25
|
*/
|
|
26
|
-
export declare function startServer(connection: Connection, { idleMs, cacheDir, saveMs, }?: {
|
|
26
|
+
export declare function startServer(connection: Connection, { idleMs, cacheDir, saveMs, fastMs, fullMs, }?: {
|
|
27
27
|
/** How long a nested config's engine waits, with none of its files open, before it stops. */
|
|
28
28
|
idleMs?: number;
|
|
29
29
|
/** The results cache folder of a project, as the CLI finds it; undefined for none. */
|
|
30
30
|
cacheDir?: (project: string) => string | undefined;
|
|
31
31
|
/** The least time between two writes of a config's results cache. */
|
|
32
32
|
saveMs?: number;
|
|
33
|
+
/** How long after an edit its fast pass waits (tests: long, to see the state before it). */
|
|
34
|
+
fastMs?: number;
|
|
35
|
+
/** How long after an edit the full pass waits. */
|
|
36
|
+
fullMs?: number;
|
|
33
37
|
}): () => void;
|
|
34
38
|
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
-
import { CodeActionKind, DidChangeConfigurationNotification, DidChangeWatchedFilesNotification, MarkupKind, ErrorCodes, MessageType, ResponseError, ShowMessageNotification, TextDocumentSyncKind, TextDocuments, } from "vscode-languageserver/node";
|
|
4
|
+
import { CodeActionKind, CodeLensRefreshRequest, DidChangeConfigurationNotification, DidChangeWatchedFilesNotification, MarkupKind, ErrorCodes, MessageType, ResponseError, ShowMessageNotification, TextDocumentSyncKind, TextDocuments, } from "vscode-languageserver/node";
|
|
5
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";
|
|
6
|
+
import { BaselineError, NothingToCheckError, activeRuleIds, TsconfigError, TypeScriptLibMissingError, check, computeImplementationId, configLocalImports, createChecker, defaultCacheDir, displayPath, loadConfigFile, proveGuarantees, openDiskCache, reloadsLocalImports, resultsCacheLimit, toTypeScriptPath, } from "@stateward/core/internal";
|
|
7
7
|
import { InvalidNextParamsError, NEXT_REQUEST, PLAN_REQUEST, RECHECK_REQUEST, STATUS_NOTIFICATION, applyNext, parseNextParams, planAnswer, rolloutOffAnswer, statusReport, } from "./adoption.js";
|
|
8
8
|
import { layBaseline } from "./baseline.js";
|
|
9
9
|
import { DEFAULT_HINT_SETTINGS, hintEnabled, readHintSettings, toInlayHints, } from "./inlay-hints.js";
|
|
10
10
|
import { InvalidExplainParamsError, answerExplain } from "./explain.js";
|
|
11
11
|
import { fixAllEdits, kindWanted, versionedAction, versionedEdit, } from "./fix-all.js";
|
|
12
|
+
import { NO_PROOFS, guaranteeHover, guaranteeLenses, proofPathActions, proofView, } from "./guarantees.js";
|
|
12
13
|
import { mergeFastPass } from "./merge.js";
|
|
13
14
|
import { SILENT_PROGRESS, withProgress } from "./progress.js";
|
|
14
15
|
import { fastPassLine, fullPassLine, longestStepLine } from "./report.js";
|
|
15
|
-
import { TOKEN_TYPES, buildLineIndex, encodeTokens, offsetAt, rangesIntersect,
|
|
16
|
+
import { TOKEN_TYPES, buildLineIndex, encodeTokens, offsetAt, rangesIntersect, toCodeActions, toLspChanges, toDiagnostic, toHiddenDiagnostic, toIgnoreAction, } from "./mapping.js";
|
|
16
17
|
import { SCRIPT_GLOB, classifyChange, createErrorGate, isImportableJson } from "./watch.js";
|
|
17
18
|
import { createConfigLocator, folderOf } from "./configs.js";
|
|
18
19
|
/** The wait after a config load, a watched-file change or a closed document, before a full pass. */
|
|
@@ -89,7 +90,7 @@ function describe(error) {
|
|
|
89
90
|
* Wires the stateward language server to `connection`. The caller starts listening. The returned
|
|
90
91
|
* function stops the server's timers (also done on shutdown); call it before disposing the connection.
|
|
91
92
|
*/
|
|
92
|
-
export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (project) => defaultCacheDir(project, process.env), saveMs = DISK_SAVE_MS, } = {}) {
|
|
93
|
+
export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (project) => defaultCacheDir(project, process.env), saveMs = DISK_SAVE_MS, fastMs = FAST_MS, fullMs = FULL_MS, } = {}) {
|
|
93
94
|
const documents = new TextDocuments(TextDocument);
|
|
94
95
|
/** The workspace folders; undefined until the client has initialized. */
|
|
95
96
|
let folders = [];
|
|
@@ -97,6 +98,8 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
97
98
|
let watchScripts = false;
|
|
98
99
|
/** The client can be told to ask for inline hints again (after a check, a settings change). */
|
|
99
100
|
let refreshHints = false;
|
|
101
|
+
/** The client can be told to ask for code lenses again (after a full pass). */
|
|
102
|
+
let refreshLenses = false;
|
|
100
103
|
/** The client answers `workspace/configuration`. */
|
|
101
104
|
let canPullSettings = false;
|
|
102
105
|
/** The client lets the server ask to be told when settings change. */
|
|
@@ -114,6 +117,20 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
114
117
|
.all()
|
|
115
118
|
.filter((document) => isTypeScriptFile(document.uri))
|
|
116
119
|
.map((document) => [toTypeScriptPath(fileURLToPath(document.uri)), document.version]));
|
|
120
|
+
/**
|
|
121
|
+
* The version each open file document had when it was opened, by path (`toTypeScriptPath`): one
|
|
122
|
+
* still at that version holds the text it was opened with, so its fast pass may take its results
|
|
123
|
+
* from the cache.
|
|
124
|
+
*/
|
|
125
|
+
const versionsAtOpen = new Map();
|
|
126
|
+
/** The files of `files` whose document is unedited since it was opened. */
|
|
127
|
+
const uneditedSinceOpen = (files) => {
|
|
128
|
+
const now = openVersions();
|
|
129
|
+
return files.filter((file) => {
|
|
130
|
+
const version = now.get(toTypeScriptPath(file));
|
|
131
|
+
return version !== undefined && version === versionsAtOpen.get(toTypeScriptPath(file));
|
|
132
|
+
});
|
|
133
|
+
};
|
|
117
134
|
/** The engine of the config `file` belongs to, if it runs. */
|
|
118
135
|
const engineOf = (file) => {
|
|
119
136
|
const configFile = locator.configOf(file);
|
|
@@ -134,16 +151,18 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
134
151
|
const folder = folderOf(engine.configFile, folders) ?? engine.cwd;
|
|
135
152
|
return ` (${displayPath(folder, engine.configFile)})`;
|
|
136
153
|
};
|
|
154
|
+
/** A file's text on disk; empty for a file a fix creates (it does not exist yet). */
|
|
155
|
+
const textOnDisk = (file) => (existsSync(file) ? readFileSync(file, "utf8") : "");
|
|
137
156
|
const fileMapper = (open) => {
|
|
138
157
|
const indexes = new Map();
|
|
139
158
|
return {
|
|
140
159
|
uriOf: (file) => pathToFileURL(file).href,
|
|
141
|
-
textOf: (file) => open.get(file) ??
|
|
160
|
+
textOf: (file) => open.get(file) ?? textOnDisk(file),
|
|
142
161
|
indexOf: (file) => {
|
|
143
162
|
const cached = indexes.get(file);
|
|
144
163
|
if (cached !== undefined)
|
|
145
164
|
return cached;
|
|
146
|
-
const index = buildLineIndex(open.get(file) ??
|
|
165
|
+
const index = buildLineIndex(open.get(file) ?? textOnDisk(file));
|
|
147
166
|
indexes.set(file, index);
|
|
148
167
|
return index;
|
|
149
168
|
},
|
|
@@ -230,7 +249,10 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
230
249
|
return;
|
|
231
250
|
}
|
|
232
251
|
const raw = outcome.result;
|
|
233
|
-
const
|
|
252
|
+
const active = activeRuleIds(state.config, { kind: "configured" });
|
|
253
|
+
const pass = layBaseline(state.config, raw, active, open);
|
|
254
|
+
// Before the baseline: an accepted error still leaves its guarantee unproven.
|
|
255
|
+
engine.proofs = proofView(proveGuarantees(state.checker.guarantees(), raw.findings, active), (file) => state.checker.analysedText(file), engine.cwd);
|
|
234
256
|
const result = pass.fresh;
|
|
235
257
|
const { byUri, sites } = render(pass.findings.filter((finding) => shows(engine, finding.location.file)), pass.hidden.filter((entry) => shows(engine, entry.file)), open, pass.baselined);
|
|
236
258
|
for (const uri of engine.published) {
|
|
@@ -248,9 +270,11 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
248
270
|
...statusReport(state.config, result, pass.baselined.size),
|
|
249
271
|
configFile: engine.configFile,
|
|
250
272
|
});
|
|
251
|
-
// The hints come from this pass's summaries.
|
|
273
|
+
// The hints and lenses come from this pass's summaries.
|
|
252
274
|
if (refreshHints)
|
|
253
275
|
connection.languages.inlayHint.refresh().catch(logUnexpected);
|
|
276
|
+
if (refreshLenses)
|
|
277
|
+
connection.sendRequest(CodeLensRefreshRequest.type).catch(logUnexpected);
|
|
254
278
|
saveSoon(engine);
|
|
255
279
|
}
|
|
256
280
|
catch (error) {
|
|
@@ -353,7 +377,9 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
353
377
|
if (targets.length === 0)
|
|
354
378
|
return;
|
|
355
379
|
try {
|
|
356
|
-
|
|
380
|
+
// A document just opened usually holds the text the saved results were computed on: its
|
|
381
|
+
// findings then come from the cache, with no type checking (a large app's first findings).
|
|
382
|
+
const result = editor.checkFiles(targets, open, uneditedSinceOpen(targets));
|
|
357
383
|
for (const file of targets) {
|
|
358
384
|
const merged = mergeFastPass({ file, fast: result });
|
|
359
385
|
// Only the file rules ran: the project rules' entries are not matched here.
|
|
@@ -414,8 +440,8 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
414
440
|
clearTimeout(engine.fastTimer);
|
|
415
441
|
engine.fastTimer = setTimeout(() => {
|
|
416
442
|
fastPass(engine);
|
|
417
|
-
},
|
|
418
|
-
scheduleFull(engine,
|
|
443
|
+
}, fastMs);
|
|
444
|
+
scheduleFull(engine, fullMs);
|
|
419
445
|
};
|
|
420
446
|
const clearDiagnostics = (engine) => {
|
|
421
447
|
for (const uri of engine.published)
|
|
@@ -524,6 +550,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
524
550
|
disk: undefined,
|
|
525
551
|
saveTimer: undefined,
|
|
526
552
|
savedAt: undefined,
|
|
553
|
+
proofs: NO_PROOFS,
|
|
527
554
|
};
|
|
528
555
|
engines.set(configFile, engine);
|
|
529
556
|
void loadEngine(engine).catch(logUnexpected);
|
|
@@ -590,6 +617,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
590
617
|
watchScripts =
|
|
591
618
|
params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
|
|
592
619
|
refreshHints = params.capabilities.workspace?.inlayHint?.refreshSupport === true;
|
|
620
|
+
refreshLenses = params.capabilities.workspace?.codeLens?.refreshSupport === true;
|
|
593
621
|
canPullSettings = params.capabilities.workspace?.configuration === true;
|
|
594
622
|
watchSettings =
|
|
595
623
|
params.capabilities.workspace?.didChangeConfiguration?.dynamicRegistration === true;
|
|
@@ -600,6 +628,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
600
628
|
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
601
629
|
hoverProvider: true,
|
|
602
630
|
inlayHintProvider: true,
|
|
631
|
+
codeLensProvider: { resolveProvider: false },
|
|
603
632
|
codeActionProvider: {
|
|
604
633
|
codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.RefactorRewrite, FIX_ALL_KIND],
|
|
605
634
|
},
|
|
@@ -677,6 +706,9 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
677
706
|
}
|
|
678
707
|
});
|
|
679
708
|
documents.onDidOpen((event) => {
|
|
709
|
+
if (event.document.uri.startsWith("file:")) {
|
|
710
|
+
versionsAtOpen.set(toTypeScriptPath(fileURLToPath(event.document.uri)), event.document.version);
|
|
711
|
+
}
|
|
680
712
|
engineForDocument(event.document.uri);
|
|
681
713
|
});
|
|
682
714
|
documents.onDidChangeContent((event) => {
|
|
@@ -688,7 +720,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
688
720
|
// An imported JSON file: no findings of its own, but the files that import it change, and
|
|
689
721
|
// only the full pass checks those.
|
|
690
722
|
cancelFull(engine);
|
|
691
|
-
scheduleFull(engine,
|
|
723
|
+
scheduleFull(engine, fullMs);
|
|
692
724
|
return;
|
|
693
725
|
}
|
|
694
726
|
// Fixes hold offsets of the text before this edit: offer none until the fast pass recomputes them.
|
|
@@ -703,6 +735,9 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
703
735
|
void runFull(engine).catch(logUnexpected);
|
|
704
736
|
});
|
|
705
737
|
documents.onDidClose((event) => {
|
|
738
|
+
if (event.document.uri.startsWith("file:")) {
|
|
739
|
+
versionsAtOpen.delete(toTypeScriptPath(fileURLToPath(event.document.uri)));
|
|
740
|
+
}
|
|
706
741
|
if (!isSyncedFile(event.document.uri))
|
|
707
742
|
return;
|
|
708
743
|
const engine = engineOf(fileURLToPath(event.document.uri));
|
|
@@ -866,10 +901,15 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
866
901
|
actions.push(...sites
|
|
867
902
|
.filter((site) => rangesIntersect(site.range, params.range))
|
|
868
903
|
.flatMap((site) => [...site.actions, ...(site.extra ?? [])])
|
|
869
|
-
.map((action) => versionedAction(action, versionOf)), ...explainActions(params.context.diagnostics))
|
|
904
|
+
.map((action) => versionedAction(action, versionOf)), ...explainActions(params.context.diagnostics), ...(guarded(() => {
|
|
905
|
+
const document = documents.get(params.textDocument.uri);
|
|
906
|
+
if (engine === undefined || key === undefined || document === undefined)
|
|
907
|
+
return [];
|
|
908
|
+
return proofPathActions(engine.proofs, key, document.getText(), params.range);
|
|
909
|
+
}) ?? []));
|
|
870
910
|
if (all.count >= 2) {
|
|
871
911
|
actions.push({
|
|
872
|
-
title: "Fix all stateward problems in
|
|
912
|
+
title: "Fix all stateward problems in the file",
|
|
873
913
|
kind: CodeActionKind.QuickFix,
|
|
874
914
|
...fixAll,
|
|
875
915
|
});
|
|
@@ -877,7 +917,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
877
917
|
}
|
|
878
918
|
if (all.count >= 1 && kindWanted(only, FIX_ALL_KIND)) {
|
|
879
919
|
actions.push({
|
|
880
|
-
title: "Fix all stateward problems in
|
|
920
|
+
title: "Fix all stateward problems in the file",
|
|
881
921
|
kind: FIX_ALL_KIND,
|
|
882
922
|
...fixAll,
|
|
883
923
|
});
|
|
@@ -903,17 +943,12 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
903
943
|
return [];
|
|
904
944
|
const index = buildLineIndex(text);
|
|
905
945
|
const found = editor.refactors(file, offsetAt(index, range.start), offsetAt(index, range.end));
|
|
946
|
+
// The edits may reach other files (a helper file the refactoring writes once).
|
|
947
|
+
const files = fileMapper(new Map([[file, text]]));
|
|
906
948
|
return found.map((action) => versionedAction({
|
|
907
949
|
title: action.title,
|
|
908
950
|
kind: CodeActionKind.RefactorRewrite,
|
|
909
|
-
edit: {
|
|
910
|
-
changes: {
|
|
911
|
-
[uri]: action.edits.map((edit) => ({
|
|
912
|
-
range: toRange(index, edit.location),
|
|
913
|
-
newText: edit.newText,
|
|
914
|
-
})),
|
|
915
|
-
},
|
|
916
|
-
},
|
|
951
|
+
edit: { changes: toLspChanges(action.edits, files) },
|
|
917
952
|
}, versionOf));
|
|
918
953
|
}) ?? [];
|
|
919
954
|
/** The checker of the config `uri`'s file belongs to, when it is ready. */
|
|
@@ -926,11 +961,34 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
926
961
|
const editor = checkerOf(params.textDocument.uri);
|
|
927
962
|
if (document === undefined || editor === undefined)
|
|
928
963
|
return null;
|
|
929
|
-
const markdown = guarded(() =>
|
|
964
|
+
const markdown = guarded(() => {
|
|
965
|
+
const file = fileURLToPath(document.uri);
|
|
966
|
+
const text = document.getText();
|
|
967
|
+
const offset = offsetAt(buildLineIndex(text), params.position);
|
|
968
|
+
// From the last full pass's proofs: no type checking for it.
|
|
969
|
+
const engine = engineOf(file);
|
|
970
|
+
const guarantee = engine === undefined
|
|
971
|
+
? undefined
|
|
972
|
+
: guaranteeHover(engine.proofs, toTypeScriptPath(file), text, offset);
|
|
973
|
+
return [...(guarantee === undefined ? [] : [guarantee]), ...editor.hover(file, offset)];
|
|
974
|
+
});
|
|
930
975
|
if (markdown === undefined || markdown.length === 0)
|
|
931
976
|
return null;
|
|
932
977
|
return { contents: { kind: MarkupKind.Markdown, value: markdown.join("\n\n---\n\n") } };
|
|
933
978
|
});
|
|
979
|
+
connection.onCodeLens((params) => {
|
|
980
|
+
const document = documents.get(params.textDocument.uri);
|
|
981
|
+
if (document === undefined)
|
|
982
|
+
return [];
|
|
983
|
+
const lenses = guarded(() => {
|
|
984
|
+
const file = fileURLToPath(document.uri);
|
|
985
|
+
const engine = engineOf(file);
|
|
986
|
+
if (engine === undefined)
|
|
987
|
+
return [];
|
|
988
|
+
return guaranteeLenses(engine.proofs, toTypeScriptPath(file), document.getText());
|
|
989
|
+
});
|
|
990
|
+
return lenses ?? [];
|
|
991
|
+
});
|
|
934
992
|
/** Which inline hints the editor's settings turn on (`stateward.inlayHints.*`). */
|
|
935
993
|
let hintSettings = DEFAULT_HINT_SETTINGS;
|
|
936
994
|
connection.languages.inlayHint.on((params) => {
|
|
@@ -938,6 +996,11 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
|
|
|
938
996
|
const editor = checkerOf(params.textDocument.uri);
|
|
939
997
|
if (document === undefined || editor === undefined)
|
|
940
998
|
return [];
|
|
999
|
+
// Hints come from a full pass's summaries, and the client is asked again after each one: before
|
|
1000
|
+
// the first, answering would build and bind the programs ahead of the open file's findings.
|
|
1001
|
+
const engine = guarded(() => engineOf(fileURLToPath(document.uri)));
|
|
1002
|
+
if (refreshHints && engine?.firstFullDone !== true)
|
|
1003
|
+
return [];
|
|
941
1004
|
const hints = guarded(() => {
|
|
942
1005
|
const index = buildLineIndex(document.getText());
|
|
943
1006
|
const found = editor.inlayHints(fileURLToPath(document.uri), (kind) => hintEnabled(hintSettings, kind));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stateward/language-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "The stateward language server: exclusive states and predictable code paths in any editor that speaks LSP, with findings, quick fixes and hints.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,12 +41,13 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"vscode-languageserver": "^11.0.0",
|
|
43
43
|
"vscode-languageserver-textdocument": "^1.0.16",
|
|
44
|
-
"@stateward/core": "1.0
|
|
44
|
+
"@stateward/core": "1.1.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/react": "^19.3.0",
|
|
48
48
|
"react": "^19.3.1",
|
|
49
49
|
"vscode-jsonrpc": "10.0.0",
|
|
50
|
-
"@stateward/plugin-
|
|
50
|
+
"@stateward/plugin-ownership": "1.1.0",
|
|
51
|
+
"@stateward/plugin-react-context": "1.1.0"
|
|
51
52
|
}
|
|
52
53
|
}
|