@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.
@@ -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. */
@@ -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) ?? readFileSync(file, "utf8"),
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) ?? readFileSync(file, "utf8"));
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 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);
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
- const result = editor.checkFiles(targets, open);
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
- }, FAST_MS);
418
- scheduleFull(engine, FULL_MS);
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, FULL_MS);
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 this file",
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 this file",
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(() => 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
+ });
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.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"
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"
50
+ "@stateward/plugin-ownership": "1.1.0",
51
+ "@stateward/plugin-react-context": "1.1.0"
51
52
  }
52
53
  }