@stateward/language-server 0.0.0-stage → 1.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ import { CodeActionKind, DiagnosticSeverity, SemanticTokensBuilder, } from "vscode-languageserver";
2
+ import { ignoreFix } from "@stateward/core/internal";
3
+ export function buildLineIndex(text) {
4
+ const starts = [0];
5
+ for (let i = 0; i < text.length; i++) {
6
+ if (text.charCodeAt(i) === 10)
7
+ starts.push(i + 1);
8
+ }
9
+ return { starts, length: text.length };
10
+ }
11
+ /** The 0-based line and UTF-16 character of `offset`, clamped to the text. */
12
+ export function positionAt(index, offset) {
13
+ const clamped = Math.min(Math.max(offset, 0), index.length);
14
+ let low = 0;
15
+ let high = index.starts.length - 1;
16
+ while (low < high) {
17
+ const middle = Math.ceil((low + high) / 2);
18
+ if ((index.starts[middle] ?? 0) <= clamped)
19
+ low = middle;
20
+ else
21
+ high = middle - 1;
22
+ }
23
+ return { line: low, character: clamped - (index.starts[low] ?? 0) };
24
+ }
25
+ /** The offset of `position`, clamped to the end of its line (and to the text). */
26
+ export function offsetAt(index, position) {
27
+ const start = index.starts[position.line];
28
+ if (start === undefined)
29
+ return index.length;
30
+ const next = index.starts[position.line + 1] ?? index.length + 1;
31
+ return Math.min(start + position.character, next - 1, index.length);
32
+ }
33
+ export function toRange(index, location) {
34
+ return { start: positionAt(index, location.start), end: positionAt(index, location.end) };
35
+ }
36
+ export function toDiagnosticSeverity(severity) {
37
+ switch (severity) {
38
+ case "error":
39
+ return DiagnosticSeverity.Error;
40
+ case "warning":
41
+ return DiagnosticSeverity.Warning;
42
+ case "info":
43
+ return DiagnosticSeverity.Information;
44
+ default: {
45
+ const unreachable = severity;
46
+ return unreachable;
47
+ }
48
+ }
49
+ }
50
+ /** The same words as the terminal: the headline, then the fix. */
51
+ function diagnosticMessage(finding, baselined) {
52
+ return `${baselined ? "baselined: " : ""}${finding.message}\nFix: ${finding.hint}`;
53
+ }
54
+ /**
55
+ * A finding the baseline accepts is a Hint (shown faintly, not in the Problems count) whatever
56
+ * its severity, and its message starts with `baselined: `.
57
+ */
58
+ export function toDiagnostic(finding, files, baselined = false) {
59
+ const range = toRange(files.indexOf(finding.location.file), finding.location);
60
+ switch (finding.kind) {
61
+ case "problem":
62
+ return {
63
+ range,
64
+ severity: baselined ? DiagnosticSeverity.Hint : toDiagnosticSeverity(finding.severity),
65
+ code: finding.ruleId,
66
+ source: "stateward",
67
+ message: diagnosticMessage(finding, baselined),
68
+ ...(finding.related.length === 0
69
+ ? {}
70
+ : {
71
+ relatedInformation: finding.related.map((related) => ({
72
+ location: {
73
+ uri: files.uriOf(related.location.file),
74
+ range: toRange(files.indexOf(related.location.file), related.location),
75
+ },
76
+ message: related.message,
77
+ })),
78
+ }),
79
+ };
80
+ case "unknown":
81
+ return {
82
+ range,
83
+ // A rule's place not checked is a faint hint (hundreds of info squiggles would
84
+ // bury the problems); as a warning (`unknowns: "warning"`) it is something to act on,
85
+ // and stateward's own (a file it could not read) stays an info.
86
+ severity: baselined
87
+ ? DiagnosticSeverity.Hint
88
+ : finding.shown === "warning"
89
+ ? DiagnosticSeverity.Warning
90
+ : finding.ruleId.startsWith("stateward/")
91
+ ? DiagnosticSeverity.Information
92
+ : DiagnosticSeverity.Hint,
93
+ code: finding.ruleId,
94
+ source: "stateward",
95
+ message: diagnosticMessage(finding, baselined),
96
+ };
97
+ case "failure":
98
+ return {
99
+ range,
100
+ severity: DiagnosticSeverity.Warning,
101
+ code: finding.ruleId,
102
+ source: "stateward",
103
+ message: diagnosticMessage(finding, false),
104
+ };
105
+ default: {
106
+ const unreachable = finding;
107
+ return unreachable;
108
+ }
109
+ }
110
+ }
111
+ /** An information at the start of the file for findings that are counted but not listed. */
112
+ export function toHiddenDiagnostic(hidden) {
113
+ return {
114
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
115
+ severity: DiagnosticSeverity.Information,
116
+ code: hidden.ruleId,
117
+ source: "stateward",
118
+ message: `${String(hidden.count)} more ${hidden.ruleId} findings in this file are not shown`,
119
+ };
120
+ }
121
+ /** Core's edits as LSP edits, by document URI. */
122
+ export function toLspChanges(edits, files) {
123
+ const changes = {};
124
+ for (const edit of edits) {
125
+ const uri = files.uriOf(edit.location.file);
126
+ const range = toRange(files.indexOf(edit.location.file), edit.location);
127
+ (changes[uri] ??= []).push({ range, newText: edit.newText });
128
+ }
129
+ return changes;
130
+ }
131
+ /** A quickfix that applies `fix`; its edits may touch several files. */
132
+ function toCodeAction(fix, diagnostic, files) {
133
+ const changes = toLspChanges(fix.edits, files);
134
+ return {
135
+ title: fix.title,
136
+ kind: CodeActionKind.QuickFix,
137
+ diagnostics: [diagnostic],
138
+ edit: { changes },
139
+ };
140
+ }
141
+ /**
142
+ * One quickfix per fix of a problem. Give it a finding whose fixes were tried (`reviewFixes`): a
143
+ * fix that was not is offered as it is.
144
+ */
145
+ export function toCodeActions(finding, diagnostic, files) {
146
+ if (finding.kind !== "problem")
147
+ return [];
148
+ return finding.fixes.map((fix) => toCodeAction(fix, diagnostic, files));
149
+ }
150
+ /**
151
+ * "Ignore this case here" (core's `ignoreFix`): offered for any finding that a comment can cover,
152
+ * after the fixes, and never applied by "fix all".
153
+ */
154
+ export function toIgnoreAction(finding, diagnostic, files) {
155
+ const form = ignoreFix(finding, files.textOf(finding.location.file));
156
+ return form === undefined ? undefined : toCodeAction(form, diagnostic, files);
157
+ }
158
+ function isBefore(a, b) {
159
+ return a.line < b.line || (a.line === b.line && a.character < b.character);
160
+ }
161
+ /** Ranges that overlap or touch. */
162
+ export function rangesIntersect(a, b) {
163
+ return !isBefore(a.end, b.start) && !isBefore(b.end, a.start);
164
+ }
165
+ const TOKEN_INDEX = {
166
+ class: 0,
167
+ function: 1,
168
+ variable: 2,
169
+ type: 3,
170
+ decorator: 4,
171
+ keyword: 5,
172
+ };
173
+ /** The LSP semantic token legend: the TokenType list, in index order. */
174
+ export const TOKEN_TYPES = Object.entries(TOKEN_INDEX)
175
+ .sort(([, a], [, b]) => a - b)
176
+ .map(([name]) => name);
177
+ /**
178
+ * Encodes tokens for LSP. Tokens are sorted by position; a token that spans lines is cut at the
179
+ * end of its first line, because LSP semantic tokens are single-line.
180
+ */
181
+ export function encodeTokens(tokens, index) {
182
+ const builder = new SemanticTokensBuilder();
183
+ const sorted = [...tokens].sort((a, b) => a.location.start - b.location.start);
184
+ for (const { location, type } of sorted) {
185
+ const start = positionAt(index, location.start);
186
+ const lineEnd = (index.starts[start.line + 1] ?? index.length + 1) - 1;
187
+ const length = Math.min(location.end, lineEnd) - location.start;
188
+ if (length > 0)
189
+ builder.push(start.line, start.character, length, TOKEN_INDEX[type], 0);
190
+ }
191
+ return builder.build();
192
+ }
193
+ //# sourceMappingURL=mapping.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * How far, in percent, V8 lets the heap grow past what the last full garbage collection kept before
3
+ * it collects again. Left to itself V8 picks 10% to 300% from how fast it collects, and the server,
4
+ * whose every edit drops a type checker's worth of objects, then reached 2 to 3 times what it keeps
5
+ * (1.1 to 1.6 GB on the 3,000-file bench, 1.5 GB on excalidraw).
6
+ */
7
+ export declare const HEAP_GROWING_PERCENT = 10;
8
+ /**
9
+ * Sets V8's heap growth for this process (`--heap-growing-percent`). For the server's own process,
10
+ * called before it starts: V8 reads the flag at each collection, so it holds from the next one on.
11
+ */
12
+ export declare function limitHeapGrowth(): void;
13
+ //# sourceMappingURL=memory.d.ts.map
package/dist/memory.js ADDED
@@ -0,0 +1,16 @@
1
+ import v8 from "node:v8";
2
+ /**
3
+ * How far, in percent, V8 lets the heap grow past what the last full garbage collection kept before
4
+ * it collects again. Left to itself V8 picks 10% to 300% from how fast it collects, and the server,
5
+ * whose every edit drops a type checker's worth of objects, then reached 2 to 3 times what it keeps
6
+ * (1.1 to 1.6 GB on the 3,000-file bench, 1.5 GB on excalidraw).
7
+ */
8
+ export const HEAP_GROWING_PERCENT = 10;
9
+ /**
10
+ * Sets V8's heap growth for this process (`--heap-growing-percent`). For the server's own process,
11
+ * called before it starts: V8 reads the flag at each collection, so it holds from the next one on.
12
+ */
13
+ export function limitHeapGrowth() {
14
+ v8.setFlagsFromString(`--heap-growing-percent=${String(HEAP_GROWING_PERCENT)}`);
15
+ }
16
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1,16 @@
1
+ import type { CheckResult, Finding, HiddenFindings } from "@stateward/core/internal";
2
+ /** What is known about one file: its findings in order, and the counts of those not listed. */
3
+ export type FileFindings = {
4
+ findings: readonly Finding[];
5
+ hidden: readonly HiddenFindings[];
6
+ };
7
+ /**
8
+ * The findings of `file` after a fast pass: the fast pass's file-rule findings only. The project
9
+ * rules' findings of that file are dropped until the next full pass (about a second later): their
10
+ * offsets belong to the text before the edit, so kept they would be drawn in the wrong place.
11
+ */
12
+ export declare function mergeFastPass({ file, fast, }: {
13
+ file: string;
14
+ fast: Pick<CheckResult, "findings" | "hidden">;
15
+ }): FileFindings;
16
+ //# sourceMappingURL=merge.d.ts.map
package/dist/merge.js ADDED
@@ -0,0 +1,20 @@
1
+ import { compareFindings, toTypeScriptPath } from "@stateward/core/internal";
2
+ function compareText(a, b) {
3
+ if (a < b)
4
+ return -1;
5
+ return a > b ? 1 : 0;
6
+ }
7
+ /**
8
+ * The findings of `file` after a fast pass: the fast pass's file-rule findings only. The project
9
+ * rules' findings of that file are dropped until the next full pass (about a second later): their
10
+ * offsets belong to the text before the edit, so kept they would be drawn in the wrong place.
11
+ */
12
+ export function mergeFastPass({ file, fast, }) {
13
+ const key = toTypeScriptPath(file);
14
+ const inFile = (found) => toTypeScriptPath(found.file) === key;
15
+ return {
16
+ findings: fast.findings.filter((finding) => inFile(finding.location)).sort(compareFindings),
17
+ hidden: fast.hidden.filter(inFile).sort((a, b) => compareText(a.ruleId, b.ruleId)),
18
+ };
19
+ }
20
+ //# sourceMappingURL=merge.js.map
@@ -0,0 +1,16 @@
1
+ /** The moves of a work-done progress that the server makes. */
2
+ export type ProgressReporter = {
3
+ begin(title: string): void;
4
+ /** Where the work stands: a short message and a percentage from 0 to 100. */
5
+ report(message: string, percentage: number): void;
6
+ done(): void;
7
+ };
8
+ /** What a client that shows no progress gets: every move does nothing. */
9
+ export declare const SILENT_PROGRESS: ProgressReporter;
10
+ /**
11
+ * Runs `work` inside a progress titled `title`; `work` reports on it. `begin` is always followed by `done`, also when
12
+ * `work` throws (the error then goes on to the caller). A progress that cannot be opened (the
13
+ * client refused it) does not stop the work.
14
+ */
15
+ export declare function withProgress<T>(open: () => Promise<ProgressReporter>, title: string, work: (reporter: ProgressReporter) => Promise<T>): Promise<T>;
16
+ //# sourceMappingURL=progress.d.ts.map
@@ -0,0 +1,22 @@
1
+ /** What a client that shows no progress gets: every move does nothing. */
2
+ export const SILENT_PROGRESS = {
3
+ begin: () => undefined,
4
+ report: () => undefined,
5
+ done: () => undefined,
6
+ };
7
+ /**
8
+ * Runs `work` inside a progress titled `title`; `work` reports on it. `begin` is always followed by `done`, also when
9
+ * `work` throws (the error then goes on to the caller). A progress that cannot be opened (the
10
+ * client refused it) does not stop the work.
11
+ */
12
+ export async function withProgress(open, title, work) {
13
+ const reporter = await open().catch(() => undefined);
14
+ reporter?.begin(title);
15
+ try {
16
+ return await work(reporter ?? SILENT_PROGRESS);
17
+ }
18
+ finally {
19
+ reporter?.done();
20
+ }
21
+ }
22
+ //# sourceMappingURL=progress.js.map
@@ -0,0 +1,21 @@
1
+ import type { CheckResult, LongestStep } from "@stateward/core/internal";
2
+ export type Counts = {
3
+ errors: number;
4
+ warnings: number;
5
+ infos: number;
6
+ };
7
+ /** How many findings of each editor severity a result has. */
8
+ export declare function countFindings(result: CheckResult): Counts;
9
+ /** `checked 3002 files in 1.4 s: 0 errors, 1 warnings, 2 infos`: the line logged after a full pass. */
10
+ export declare function fullPassLine(result: CheckResult): string;
11
+ /** `checked src/App.tsx in 38 ms`: the line logged after a fast pass. */
12
+ export declare function fastPassLine(files: readonly string[], result: CheckResult): string;
13
+ /** A step this long is logged: an edit made during it waited that long for its findings. */
14
+ export declare const LONG_STEP_MS = 300;
15
+ /**
16
+ * `longest step 2900 ms: errors/result-unchecked on src/a.tsx`: logged after a project check
17
+ * whose longest step kept edits waiting at least `LONG_STEP_MS`; undefined
18
+ * otherwise. Paths are relative to `cwd`, with `/`.
19
+ */
20
+ export declare function longestStepLine(longest: LongestStep, cwd: string): string | undefined;
21
+ //# sourceMappingURL=report.d.ts.map
package/dist/report.js ADDED
@@ -0,0 +1,70 @@
1
+ import { displayPath } from "@stateward/core/internal";
2
+ /** How a finding is counted: a problem by its own severity, an unknown with the infos (shown as a faint hint) or the warnings (as `unknowns` says), a failure as a warning. */
3
+ function bucket(finding) {
4
+ switch (finding.kind) {
5
+ case "problem":
6
+ switch (finding.severity) {
7
+ case "error":
8
+ return "errors";
9
+ case "warning":
10
+ return "warnings";
11
+ case "info":
12
+ return "infos";
13
+ default: {
14
+ const unreachable = finding.severity;
15
+ return unreachable;
16
+ }
17
+ }
18
+ case "unknown":
19
+ return finding.shown === "warning" ? "warnings" : "infos";
20
+ case "failure":
21
+ return "warnings";
22
+ default: {
23
+ const unreachable = finding;
24
+ return unreachable;
25
+ }
26
+ }
27
+ }
28
+ /** How many findings of each editor severity a result has. */
29
+ export function countFindings(result) {
30
+ const counts = { errors: 0, warnings: 0, infos: 0 };
31
+ for (const finding of result.findings)
32
+ counts[bucket(finding)] += 1;
33
+ return counts;
34
+ }
35
+ /** `checked 3002 files in 1.4 s: 0 errors, 1 warnings, 2 infos`: the line logged after a full pass. */
36
+ export function fullPassLine(result) {
37
+ const counts = countFindings(result);
38
+ const seconds = (result.stats.durationMs / 1000).toFixed(1);
39
+ return `checked ${String(result.stats.files)} files in ${seconds} s: ${String(counts.errors)} errors, ${String(counts.warnings)} warnings, ${String(counts.infos)} infos`;
40
+ }
41
+ /** `checked src/App.tsx in 38 ms`: the line logged after a fast pass. */
42
+ export function fastPassLine(files, result) {
43
+ return `checked ${files.join(", ")} in ${String(result.stats.durationMs)} ms`;
44
+ }
45
+ /** A step this long is logged: an edit made during it waited that long for its findings. */
46
+ export const LONG_STEP_MS = 300;
47
+ /**
48
+ * `longest step 2900 ms: errors/result-unchecked on src/a.tsx`: logged after a project check
49
+ * whose longest step kept edits waiting at least `LONG_STEP_MS`; undefined
50
+ * otherwise. Paths are relative to `cwd`, with `/`.
51
+ */
52
+ export function longestStepLine(longest, cwd) {
53
+ if (longest.ms < LONG_STEP_MS)
54
+ return undefined;
55
+ const ms = String(Math.round(longest.ms));
56
+ const at = longest.at;
57
+ if (at === undefined)
58
+ return `longest step ${ms} ms: finishing the project check`;
59
+ if (at.phase === "files" && at.done === 0 && at.rule === undefined) {
60
+ return `longest step ${ms} ms: building the programs`;
61
+ }
62
+ const file = at.file === undefined ? undefined : displayPath(cwd, at.file);
63
+ if (at.rule !== undefined && file !== undefined) {
64
+ return `longest step ${ms} ms: ${at.rule} on ${file}`;
65
+ }
66
+ if (at.rule !== undefined)
67
+ return `longest step ${ms} ms: ${at.rule} over the whole project`;
68
+ return `longest step ${ms} ms: ${at.phase === "files" ? "checking files" : "project rules"}`;
69
+ }
70
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1,34 @@
1
+ import { type Connection } from "vscode-languageserver/node";
2
+ /** The wait after a config load, a watched-file change or a closed document, before a full pass. */
3
+ export declare const DEBOUNCE_MS = 200;
4
+ /** Quiet time after an edit before the fast pass (the edited file's own rules). */
5
+ export declare const FAST_MS = 100;
6
+ /** Quiet time after an edit before the full pass (the whole project); a save does not wait. */
7
+ export declare const FULL_MS = 1000;
8
+ export declare const PROGRESS_TITLE = "Stateward: checking project";
9
+ /** Tells the extension a config's full pass started or ended, for its status bar. */
10
+ export declare const CHECKING_NOTIFICATION = "stateward/checking";
11
+ export declare const FIX_ALL_KIND: string;
12
+ export declare const EXPLAIN_COMMAND = "stateward.explain";
13
+ export declare const EXPLAIN_REQUEST = "stateward/explain";
14
+ /** Quiet time after the last file of a nested config's project is closed, before its engine stops. */
15
+ export declare const ENGINE_IDLE_MS: number;
16
+ /**
17
+ * The least time between two writes of a config's results cache to disk: a write follows a full
18
+ * pass that completed, at most this often, and a config that stops (or a server that shuts down)
19
+ * writes what it has not written yet.
20
+ */
21
+ export declare const DISK_SAVE_MS: number;
22
+ /**
23
+ * Wires the stateward language server to `connection`. The caller starts listening. The returned
24
+ * function stops the server's timers (also done on shutdown); call it before disposing the connection.
25
+ */
26
+ export declare function startServer(connection: Connection, { idleMs, cacheDir, saveMs, }?: {
27
+ /** How long a nested config's engine waits, with none of its files open, before it stops. */
28
+ idleMs?: number;
29
+ /** The results cache folder of a project, as the CLI finds it; undefined for none. */
30
+ cacheDir?: (project: string) => string | undefined;
31
+ /** The least time between two writes of a config's results cache. */
32
+ saveMs?: number;
33
+ }): () => void;
34
+ //# sourceMappingURL=server.d.ts.map