@narumitw/pi-lsp 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/package.json +49 -0
- package/src/adapters.ts +141 -0
- package/src/command.ts +93 -0
- package/src/files.ts +119 -0
- package/src/lsp-client.ts +426 -0
- package/src/pi-lsp.ts +214 -0
- package/src/runner.ts +251 -0
- package/src/text-edits.ts +96 -0
- package/src/types.ts +95 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { commandFromEnv, timeoutFromEnv } from "./command.js";
|
|
5
|
+
import { collectSupportedFiles, resolveRoot, resolveSupportedFile } from "./files.js";
|
|
6
|
+
import { LspClient } from "./lsp-client.js";
|
|
7
|
+
import { applyTextEdits, collectWorkspaceEdits, hasOverlappingTextEdits } from "./text-edits.js";
|
|
8
|
+
import type { CodeAction, DiagnosticEntry, LspServerAdapter, StatusContext } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
11
|
+
export const DEFAULT_FILE_LIMIT = 50;
|
|
12
|
+
|
|
13
|
+
export async function runDiagnostics(
|
|
14
|
+
adapter: LspServerAdapter,
|
|
15
|
+
params: { root?: string; paths?: string[]; limit?: number },
|
|
16
|
+
signal: AbortSignal | undefined,
|
|
17
|
+
ctx: StatusContext,
|
|
18
|
+
statusKey: string,
|
|
19
|
+
) {
|
|
20
|
+
const root = resolveRoot(params.root);
|
|
21
|
+
const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
|
|
22
|
+
const files = collectSupportedFiles(adapter, root, params.paths, params.limit ?? DEFAULT_FILE_LIMIT);
|
|
23
|
+
if (files.length === 0) {
|
|
24
|
+
return textResult(adapter.emptyDiagnosticsMessage, {
|
|
25
|
+
root,
|
|
26
|
+
command,
|
|
27
|
+
files: [],
|
|
28
|
+
summary: { files: 0, diagnostics: 0 },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const client = new LspClient(adapter, command, root, timeoutFromEnv(adapter.timeoutEnvVar, DEFAULT_TIMEOUT_MS));
|
|
33
|
+
const abort = () => client.close();
|
|
34
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
35
|
+
throwIfAborted(signal, adapter);
|
|
36
|
+
ctx.ui.setStatus(statusKey, `${adapter.statusPrefix} diagnostics`);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await client.start();
|
|
40
|
+
await client.initialize(root);
|
|
41
|
+
|
|
42
|
+
const entries: DiagnosticEntry[] = [];
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
throwIfAborted(signal, adapter);
|
|
45
|
+
const uri = pathToFileURL(file).href;
|
|
46
|
+
const text = readFileSync(file, "utf8");
|
|
47
|
+
client.didOpen(uri, text, adapter.languageIdFor(file));
|
|
48
|
+
try {
|
|
49
|
+
const diagnostics = await client.diagnostics(uri);
|
|
50
|
+
entries.push({ path: path.relative(root, file) || file, uri, diagnostics });
|
|
51
|
+
} finally {
|
|
52
|
+
client.didClose(uri);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return textResult(formatDiagnostics(adapter, entries), {
|
|
57
|
+
root,
|
|
58
|
+
command,
|
|
59
|
+
files: entries,
|
|
60
|
+
summary: summarize(entries),
|
|
61
|
+
});
|
|
62
|
+
} finally {
|
|
63
|
+
ctx.ui.setStatus(statusKey, undefined);
|
|
64
|
+
signal?.removeEventListener("abort", abort);
|
|
65
|
+
await client.shutdown();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runFormat(
|
|
70
|
+
adapter: LspServerAdapter,
|
|
71
|
+
params: { root?: string; path: string; write?: boolean },
|
|
72
|
+
signal: AbortSignal | undefined,
|
|
73
|
+
ctx: StatusContext,
|
|
74
|
+
statusKey: string,
|
|
75
|
+
) {
|
|
76
|
+
const root = resolveRoot(params.root);
|
|
77
|
+
const file = resolveSupportedFile(adapter, root, params.path);
|
|
78
|
+
const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
|
|
79
|
+
const client = new LspClient(adapter, command, root, timeoutFromEnv(adapter.timeoutEnvVar, DEFAULT_TIMEOUT_MS));
|
|
80
|
+
const abort = () => client.close();
|
|
81
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
82
|
+
throwIfAborted(signal, adapter);
|
|
83
|
+
ctx.ui.setStatus(statusKey, `${adapter.statusPrefix} format`);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await client.start();
|
|
87
|
+
await client.initialize(root);
|
|
88
|
+
throwIfAborted(signal, adapter);
|
|
89
|
+
const uri = pathToFileURL(file).href;
|
|
90
|
+
const text = readFileSync(file, "utf8");
|
|
91
|
+
client.didOpen(uri, text, adapter.languageIdFor(file));
|
|
92
|
+
let newText: string;
|
|
93
|
+
let edits;
|
|
94
|
+
try {
|
|
95
|
+
edits = await client.format(uri);
|
|
96
|
+
newText = applyTextEdits(text, edits);
|
|
97
|
+
} finally {
|
|
98
|
+
client.didClose(uri);
|
|
99
|
+
}
|
|
100
|
+
const changed = newText !== text;
|
|
101
|
+
|
|
102
|
+
if (params.write && changed) writeFileSync(file, newText);
|
|
103
|
+
|
|
104
|
+
return textResult(formatEditSummary(adapter, "format", root, file, changed, params.write, newText), {
|
|
105
|
+
path: path.relative(root, file) || file,
|
|
106
|
+
uri,
|
|
107
|
+
changed,
|
|
108
|
+
write: params.write ?? false,
|
|
109
|
+
edits,
|
|
110
|
+
text: params.write ? undefined : newText,
|
|
111
|
+
});
|
|
112
|
+
} finally {
|
|
113
|
+
ctx.ui.setStatus(statusKey, undefined);
|
|
114
|
+
signal?.removeEventListener("abort", abort);
|
|
115
|
+
await client.shutdown();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function runFix(
|
|
120
|
+
adapter: LspServerAdapter,
|
|
121
|
+
params: { root?: string; path: string; kind?: string; write?: boolean },
|
|
122
|
+
signal: AbortSignal | undefined,
|
|
123
|
+
ctx: StatusContext,
|
|
124
|
+
statusKey: string,
|
|
125
|
+
) {
|
|
126
|
+
const root = resolveRoot(params.root);
|
|
127
|
+
const file = resolveSupportedFile(adapter, root, params.path);
|
|
128
|
+
const actionKind = params.kind?.trim() || adapter.defaultFixKind;
|
|
129
|
+
if (!actionKind) throw new Error(`${adapter.label} LSP adapter does not support source fixes.`);
|
|
130
|
+
|
|
131
|
+
const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
|
|
132
|
+
const client = new LspClient(adapter, command, root, timeoutFromEnv(adapter.timeoutEnvVar, DEFAULT_TIMEOUT_MS));
|
|
133
|
+
const abort = () => client.close();
|
|
134
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
135
|
+
throwIfAborted(signal, adapter);
|
|
136
|
+
ctx.ui.setStatus(statusKey, `${adapter.statusPrefix} fix`);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await client.start();
|
|
140
|
+
await client.initialize(root);
|
|
141
|
+
throwIfAborted(signal, adapter);
|
|
142
|
+
const uri = pathToFileURL(file).href;
|
|
143
|
+
const text = readFileSync(file, "utf8");
|
|
144
|
+
client.didOpen(uri, text, adapter.languageIdFor(file));
|
|
145
|
+
let resolvedActions: CodeAction[];
|
|
146
|
+
let selectedActions: CodeAction[];
|
|
147
|
+
let edits;
|
|
148
|
+
let newText: string;
|
|
149
|
+
try {
|
|
150
|
+
const diagnostics = await client.diagnostics(uri);
|
|
151
|
+
const actions = await client.codeActions(uri, text, diagnostics, actionKind);
|
|
152
|
+
resolvedActions = await client.resolveActions(actions);
|
|
153
|
+
selectedActions = selectCodeActions(resolvedActions, actionKind);
|
|
154
|
+
edits = selectedActions.flatMap((action) => collectWorkspaceEdits(action.edit, uri));
|
|
155
|
+
if (hasOverlappingTextEdits(text, edits)) {
|
|
156
|
+
const relativePath = path.relative(root, file) || file;
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${adapter.label} LSP returned overlapping code-action edits for ${relativePath}; ` +
|
|
159
|
+
"use a narrower action kind.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
newText = applyTextEdits(text, edits);
|
|
163
|
+
} finally {
|
|
164
|
+
client.didClose(uri);
|
|
165
|
+
}
|
|
166
|
+
const changed = newText !== text;
|
|
167
|
+
|
|
168
|
+
if (params.write && changed) writeFileSync(file, newText);
|
|
169
|
+
|
|
170
|
+
return textResult(formatEditSummary(adapter, "fix", root, file, changed, params.write, newText), {
|
|
171
|
+
path: path.relative(root, file) || file,
|
|
172
|
+
uri,
|
|
173
|
+
changed,
|
|
174
|
+
write: params.write ?? false,
|
|
175
|
+
kind: actionKind,
|
|
176
|
+
actions: resolvedActions.map(({ title, kind }) => ({ title, kind })),
|
|
177
|
+
appliedActions: selectedActions.map(({ title, kind }) => ({ title, kind })),
|
|
178
|
+
edits,
|
|
179
|
+
text: params.write ? undefined : newText,
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
ctx.ui.setStatus(statusKey, undefined);
|
|
183
|
+
signal?.removeEventListener("abort", abort);
|
|
184
|
+
await client.shutdown();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function selectCodeActions(actions: CodeAction[], requestedKind: string) {
|
|
189
|
+
return actions.filter(
|
|
190
|
+
(action) => action.kind === requestedKind || action.kind?.startsWith(`${requestedKind}.`),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function formatDiagnostics(adapter: LspServerAdapter, entries: DiagnosticEntry[]) {
|
|
195
|
+
const lines = entries.flatMap((entry) => {
|
|
196
|
+
if (entry.diagnostics.length === 0) return [`${entry.path}: no diagnostics`];
|
|
197
|
+
|
|
198
|
+
return entry.diagnostics.map((diagnostic) => {
|
|
199
|
+
const line = diagnostic.range.start.line + 1;
|
|
200
|
+
const column = diagnostic.range.start.character + 1;
|
|
201
|
+
const severity = severityName(diagnostic.severity);
|
|
202
|
+
const source = diagnostic.source ?? adapter.label;
|
|
203
|
+
const code = diagnostic.code === undefined ? "" : ` ${diagnostic.code}`;
|
|
204
|
+
return `${entry.path}:${line}:${column}: ${severity} ${source}${code}: ${diagnostic.message}`;
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return [adapter.formatDiagnosticsHeader(summarize(entries)), "", ...lines].join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function formatEditSummary(
|
|
212
|
+
adapter: LspServerAdapter,
|
|
213
|
+
action: "fix" | "format",
|
|
214
|
+
root: string,
|
|
215
|
+
file: string,
|
|
216
|
+
changed: boolean,
|
|
217
|
+
write: boolean | undefined,
|
|
218
|
+
text: string,
|
|
219
|
+
) {
|
|
220
|
+
const relativePath = path.relative(root, file) || file;
|
|
221
|
+
const status = changed ? (write ? "updated" : "computed changes for") : "left unchanged";
|
|
222
|
+
const summary = `${adapter.editSummaryLabel} LSP ${action} ${status} ${relativePath}.`;
|
|
223
|
+
if (write || !changed) return summary;
|
|
224
|
+
return `${summary}\n\n${text}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function summarize(entries: DiagnosticEntry[]) {
|
|
228
|
+
return {
|
|
229
|
+
files: entries.length,
|
|
230
|
+
diagnostics: entries.reduce((total, entry) => total + entry.diagnostics.length, 0),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function severityName(severity: number | undefined) {
|
|
235
|
+
if (severity === 1) return "error";
|
|
236
|
+
if (severity === 2) return "warning";
|
|
237
|
+
if (severity === 3) return "info";
|
|
238
|
+
if (severity === 4) return "hint";
|
|
239
|
+
return "diagnostic";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function throwIfAborted(signal: AbortSignal | undefined, adapter: LspServerAdapter) {
|
|
243
|
+
if (signal?.aborted) throw new Error(`${adapter.label} LSP request aborted.`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function textResult(text: string, details: unknown) {
|
|
247
|
+
return {
|
|
248
|
+
content: [{ type: "text" as const, text }],
|
|
249
|
+
details,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { LspPosition, LspTextEdit, WorkspaceEdit } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function positionAt(text: string, offset: number): LspPosition {
|
|
4
|
+
const boundedOffset = Math.max(0, Math.min(offset, text.length));
|
|
5
|
+
let line = 0;
|
|
6
|
+
let lineStart = 0;
|
|
7
|
+
|
|
8
|
+
for (let index = 0; index < boundedOffset; index += 1) {
|
|
9
|
+
if (text[index] === "\n") {
|
|
10
|
+
line += 1;
|
|
11
|
+
lineStart = index + 1;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return { line, character: boundedOffset - lineStart };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function offsetAt(text: string, position: LspPosition) {
|
|
19
|
+
let line = 0;
|
|
20
|
+
let lineStart = 0;
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < text.length && line < position.line; index += 1) {
|
|
23
|
+
if (text[index] === "\n") {
|
|
24
|
+
line += 1;
|
|
25
|
+
lineStart = index + 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (line < position.line) return text.length;
|
|
30
|
+
|
|
31
|
+
let lineEnd = text.indexOf("\n", lineStart);
|
|
32
|
+
if (lineEnd < 0) lineEnd = text.length;
|
|
33
|
+
return Math.min(lineStart + position.character, lineEnd);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function applyTextEdits(text: string, edits: LspTextEdit[]) {
|
|
37
|
+
let output = text;
|
|
38
|
+
const sortedEdits = positionTextEdits(text, edits).sort((left, right) => {
|
|
39
|
+
if (left.start !== right.start) return right.start - left.start;
|
|
40
|
+
if (left.end !== right.end) return right.end - left.end;
|
|
41
|
+
return right.index - left.index;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
for (const { edit, start, end } of sortedEdits) {
|
|
45
|
+
output = `${output.slice(0, start)}${edit.newText}${output.slice(end)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return output;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function hasOverlappingTextEdits(text: string, edits: LspTextEdit[]) {
|
|
52
|
+
const positionedEdits = positionTextEdits(text, edits);
|
|
53
|
+
for (let leftIndex = 0; leftIndex < positionedEdits.length; leftIndex += 1) {
|
|
54
|
+
for (let rightIndex = leftIndex + 1; rightIndex < positionedEdits.length; rightIndex += 1) {
|
|
55
|
+
if (textEditRangesConflict(positionedEdits[leftIndex], positionedEdits[rightIndex])) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function positionTextEdits(text: string, edits: LspTextEdit[]) {
|
|
64
|
+
return edits.map((edit, index) => ({
|
|
65
|
+
edit,
|
|
66
|
+
index,
|
|
67
|
+
start: offsetAt(text, edit.range.start),
|
|
68
|
+
end: offsetAt(text, edit.range.end),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function textEditRangesConflict(
|
|
73
|
+
left: { start: number; end: number },
|
|
74
|
+
right: { start: number; end: number },
|
|
75
|
+
) {
|
|
76
|
+
if (left.start === left.end && right.start === right.end) return false;
|
|
77
|
+
|
|
78
|
+
if (left.start === left.end || right.start === right.end) {
|
|
79
|
+
const insert = left.start === left.end ? left : right;
|
|
80
|
+
const replacement = left.start === left.end ? right : left;
|
|
81
|
+
return replacement.start < insert.start && insert.start < replacement.end;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return Math.max(left.start, right.start) < Math.min(left.end, right.end);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function collectWorkspaceEdits(edit: WorkspaceEdit | undefined, uri: string) {
|
|
88
|
+
if (!edit) return [];
|
|
89
|
+
if (edit.documentChanges) {
|
|
90
|
+
return edit.documentChanges.flatMap((change) =>
|
|
91
|
+
change.textDocument?.uri === uri ? (change.edits ?? []) : [],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return edit.changes?.[uri] ?? [];
|
|
96
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export interface ServerCommand {
|
|
2
|
+
command: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface StatusContext {
|
|
7
|
+
ui: { setStatus: (key: string, value: string | undefined) => void };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LspPosition {
|
|
11
|
+
line: number;
|
|
12
|
+
character: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LspRange {
|
|
16
|
+
start: LspPosition;
|
|
17
|
+
end: LspPosition;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LspDiagnostic {
|
|
21
|
+
range: LspRange;
|
|
22
|
+
severity?: number;
|
|
23
|
+
code?: string | number;
|
|
24
|
+
codeDescription?: { href?: string };
|
|
25
|
+
source?: string;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LspTextEdit {
|
|
30
|
+
range: LspRange;
|
|
31
|
+
newText: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkspaceEdit {
|
|
35
|
+
changes?: Record<string, LspTextEdit[]>;
|
|
36
|
+
documentChanges?: Array<{
|
|
37
|
+
textDocument?: { uri?: string; version?: number | null };
|
|
38
|
+
edits?: LspTextEdit[];
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CodeAction {
|
|
43
|
+
title: string;
|
|
44
|
+
kind?: string;
|
|
45
|
+
edit?: WorkspaceEdit;
|
|
46
|
+
data?: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DiagnosticEntry {
|
|
50
|
+
path: string;
|
|
51
|
+
uri: string;
|
|
52
|
+
diagnostics: LspDiagnostic[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface JsonRpcMessage {
|
|
56
|
+
jsonrpc?: "2.0";
|
|
57
|
+
id?: number | string | null;
|
|
58
|
+
method?: string;
|
|
59
|
+
params?: unknown;
|
|
60
|
+
result?: unknown;
|
|
61
|
+
error?: { code: number; message: string; data?: unknown };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LspServerAdapter {
|
|
65
|
+
label: string;
|
|
66
|
+
statusPrefix: string;
|
|
67
|
+
defaultCommand: ServerCommand;
|
|
68
|
+
commandEnvVar: string;
|
|
69
|
+
timeoutEnvVar: string;
|
|
70
|
+
missingCommandHint: string;
|
|
71
|
+
skipDirectories: Set<string>;
|
|
72
|
+
isSupportedFile: (filePath: string) => boolean;
|
|
73
|
+
languageIdFor: (filePath: string) => string;
|
|
74
|
+
formattingOptions: { tabSize: number; insertSpaces: boolean };
|
|
75
|
+
initialize: {
|
|
76
|
+
codeAction: boolean;
|
|
77
|
+
diagnosticDynamicRegistration: boolean;
|
|
78
|
+
formattingDynamicRegistration?: boolean;
|
|
79
|
+
codeActionDynamicRegistration?: boolean;
|
|
80
|
+
didChangeConfigurationDynamicRegistration?: boolean;
|
|
81
|
+
didSaveDynamicRegistration?: boolean;
|
|
82
|
+
};
|
|
83
|
+
fallbackToPublishDiagnostics: boolean;
|
|
84
|
+
resolveUnsupportedCodeActions: boolean;
|
|
85
|
+
serverRequestWorkspaceFolders: boolean;
|
|
86
|
+
emptyDiagnosticsMessage: string;
|
|
87
|
+
formatDiagnosticsHeader: (summary: DiagnosticSummary) => string;
|
|
88
|
+
editSummaryLabel: string;
|
|
89
|
+
defaultFixKind?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface DiagnosticSummary {
|
|
93
|
+
files: number;
|
|
94
|
+
diagnostics: number;
|
|
95
|
+
}
|