@stateward/language-server 1.0.1-rc.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/README.md CHANGED
@@ -11,3 +11,19 @@ npm install --save-dev @stateward/language-server
11
11
  Start it with `npx stateward-language-server`. It talks over stdin and stdout and checks the
12
12
  project of the nearest `stateward.config.ts`. The VS Code extension uses the copy installed in your
13
13
  project, else its own.
14
+
15
+ ## What you see
16
+
17
+ - Findings as diagnostics, as you type, with the rule id and the change to make.
18
+ - Quick fixes, each tried on a copy of your code first: one that would not compile is not offered.
19
+ "Ignore this case here" writes a `// stateward-ignore` comment with the rule.
20
+ - Inlay hints after a function's signature with the errors it can throw (`throws NotFoundError`),
21
+ and after a `catch` variable with the errors its `try` can throw (with the errors pack).
22
+ - Places stateward could not check, as faint hints; baselined findings, faintly.
23
+
24
+ ## Docs
25
+
26
+ - [Every rule](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/README.md), with
27
+ what it finds and its severity.
28
+ - [The config file](https://github.com/khoing1111/ts-stateward/blob/main/docs/user/config.md).
29
+ - [The VS Code extension](https://github.com/khoing1111/ts-stateward/blob/main/packages/vscode/README.md).
@@ -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: `Turn on \`rollout: true\` in ${configFileName} first: without it every rule runs, and there is no next rule to turn on.`,
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).map(([uri, edits]) => ({
80
- textDocument: { uri, version: versionOf(uri) },
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, toRange, toCodeActions, toLspChanges, toDiagnostic, toHiddenDiagnostic, toIgnoreAction, } from "./mapping.js";
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. */
@@ -148,16 +151,18 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
148
151
  const folder = folderOf(engine.configFile, folders) ?? engine.cwd;
149
152
  return ` (${displayPath(folder, engine.configFile)})`;
150
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") : "");
151
156
  const fileMapper = (open) => {
152
157
  const indexes = new Map();
153
158
  return {
154
159
  uriOf: (file) => pathToFileURL(file).href,
155
- textOf: (file) => open.get(file) ?? readFileSync(file, "utf8"),
160
+ textOf: (file) => open.get(file) ?? textOnDisk(file),
156
161
  indexOf: (file) => {
157
162
  const cached = indexes.get(file);
158
163
  if (cached !== undefined)
159
164
  return cached;
160
- const index = buildLineIndex(open.get(file) ?? readFileSync(file, "utf8"));
165
+ const index = buildLineIndex(open.get(file) ?? textOnDisk(file));
161
166
  indexes.set(file, index);
162
167
  return index;
163
168
  },
@@ -244,7 +249,10 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
244
249
  return;
245
250
  }
246
251
  const raw = outcome.result;
247
- const pass = layBaseline(state.config, raw, activeRuleIds(state.config, { kind: "configured" }), open);
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);
248
256
  const result = pass.fresh;
249
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);
250
258
  for (const uri of engine.published) {
@@ -262,9 +270,11 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
262
270
  ...statusReport(state.config, result, pass.baselined.size),
263
271
  configFile: engine.configFile,
264
272
  });
265
- // The hints come from this pass's summaries.
273
+ // The hints and lenses come from this pass's summaries.
266
274
  if (refreshHints)
267
275
  connection.languages.inlayHint.refresh().catch(logUnexpected);
276
+ if (refreshLenses)
277
+ connection.sendRequest(CodeLensRefreshRequest.type).catch(logUnexpected);
268
278
  saveSoon(engine);
269
279
  }
270
280
  catch (error) {
@@ -430,8 +440,8 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
430
440
  clearTimeout(engine.fastTimer);
431
441
  engine.fastTimer = setTimeout(() => {
432
442
  fastPass(engine);
433
- }, FAST_MS);
434
- scheduleFull(engine, FULL_MS);
443
+ }, fastMs);
444
+ scheduleFull(engine, fullMs);
435
445
  };
436
446
  const clearDiagnostics = (engine) => {
437
447
  for (const uri of engine.published)
@@ -540,6 +550,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
540
550
  disk: undefined,
541
551
  saveTimer: undefined,
542
552
  savedAt: undefined,
553
+ proofs: NO_PROOFS,
543
554
  };
544
555
  engines.set(configFile, engine);
545
556
  void loadEngine(engine).catch(logUnexpected);
@@ -606,6 +617,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
606
617
  watchScripts =
607
618
  params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
608
619
  refreshHints = params.capabilities.workspace?.inlayHint?.refreshSupport === true;
620
+ refreshLenses = params.capabilities.workspace?.codeLens?.refreshSupport === true;
609
621
  canPullSettings = params.capabilities.workspace?.configuration === true;
610
622
  watchSettings =
611
623
  params.capabilities.workspace?.didChangeConfiguration?.dynamicRegistration === true;
@@ -616,6 +628,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
616
628
  textDocumentSync: TextDocumentSyncKind.Incremental,
617
629
  hoverProvider: true,
618
630
  inlayHintProvider: true,
631
+ codeLensProvider: { resolveProvider: false },
619
632
  codeActionProvider: {
620
633
  codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.RefactorRewrite, FIX_ALL_KIND],
621
634
  },
@@ -707,7 +720,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
707
720
  // An imported JSON file: no findings of its own, but the files that import it change, and
708
721
  // only the full pass checks those.
709
722
  cancelFull(engine);
710
- scheduleFull(engine, FULL_MS);
723
+ scheduleFull(engine, fullMs);
711
724
  return;
712
725
  }
713
726
  // Fixes hold offsets of the text before this edit: offer none until the fast pass recomputes them.
@@ -888,10 +901,15 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
888
901
  actions.push(...sites
889
902
  .filter((site) => rangesIntersect(site.range, params.range))
890
903
  .flatMap((site) => [...site.actions, ...(site.extra ?? [])])
891
- .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
+ }) ?? []));
892
910
  if (all.count >= 2) {
893
911
  actions.push({
894
- title: "Fix all stateward problems in this file",
912
+ title: "Fix all stateward problems in the file",
895
913
  kind: CodeActionKind.QuickFix,
896
914
  ...fixAll,
897
915
  });
@@ -899,7 +917,7 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
899
917
  }
900
918
  if (all.count >= 1 && kindWanted(only, FIX_ALL_KIND)) {
901
919
  actions.push({
902
- title: "Fix all stateward problems in this file",
920
+ title: "Fix all stateward problems in the file",
903
921
  kind: FIX_ALL_KIND,
904
922
  ...fixAll,
905
923
  });
@@ -925,17 +943,12 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
925
943
  return [];
926
944
  const index = buildLineIndex(text);
927
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]]));
928
948
  return found.map((action) => versionedAction({
929
949
  title: action.title,
930
950
  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
- },
951
+ edit: { changes: toLspChanges(action.edits, files) },
939
952
  }, versionOf));
940
953
  }) ?? [];
941
954
  /** The checker of the config `uri`'s file belongs to, when it is ready. */
@@ -948,11 +961,34 @@ export function startServer(connection, { idleMs = ENGINE_IDLE_MS, cacheDir = (p
948
961
  const editor = checkerOf(params.textDocument.uri);
949
962
  if (document === undefined || editor === undefined)
950
963
  return null;
951
- const markdown = guarded(() => editor.hover(fileURLToPath(document.uri), offsetAt(buildLineIndex(document.getText()), params.position)));
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
+ });
952
975
  if (markdown === undefined || markdown.length === 0)
953
976
  return null;
954
977
  return { contents: { kind: MarkupKind.Markdown, value: markdown.join("\n\n---\n\n") } };
955
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
+ });
956
992
  /** Which inline hints the editor's settings turn on (`stateward.inlayHints.*`). */
957
993
  let hintSettings = DEFAULT_HINT_SETTINGS;
958
994
  connection.languages.inlayHint.on((params) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stateward/language-server",
3
- "version": "1.0.1-rc.1",
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.1-rc.1"
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-react-context": "1.0.1-rc.1"
50
+ "@stateward/plugin-ownership": "1.1.0",
51
+ "@stateward/plugin-react-context": "1.1.0"
51
52
  }
52
53
  }