@mrclrchtr/supi-code-intelligence 1.3.0 → 1.4.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 +70 -32
- package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
- package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
- package/node_modules/@mrclrchtr/{supi-lsp/node_modules/@mrclrchtr/supi-core/src → supi-core/src/context}/context-provider-registry.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
- package/node_modules/@mrclrchtr/{supi-lsp/node_modules/@mrclrchtr/supi-core/src → supi-core/src/settings}/settings-registry.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/README.md +58 -39
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +52 -41
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
- package/node_modules/@mrclrchtr/{supi-core/src → supi-lsp/node_modules/@mrclrchtr/supi-core/src/context}/context-provider-registry.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
- package/node_modules/@mrclrchtr/{supi-core/src → supi-lsp/node_modules/@mrclrchtr/supi-core/src/settings}/settings-registry.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/package.json +5 -3
- package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +16 -3
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-refresh.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +27 -3
- package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +61 -5
- package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +244 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/{types.ts → config/types.ts} +4 -2
- package/node_modules/@mrclrchtr/supi-lsp/src/coordinates.ts +11 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-augmentation.ts +5 -5
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-context.ts +115 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-display.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-summary.ts +3 -2
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostics.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/stale-diagnostics.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/suppression-diagnostics.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/{workspace-sentinels.ts → diagnostics/workspace-sentinels.ts} +2 -2
- package/node_modules/@mrclrchtr/supi-lsp/src/format.ts +2 -23
- package/node_modules/@mrclrchtr/supi-lsp/src/index.ts +18 -5
- package/node_modules/@mrclrchtr/supi-lsp/src/lsp.ts +72 -120
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-helpers.ts +4 -2
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-project-info.ts +10 -7
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-symbol.ts +158 -6
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +202 -43
- package/node_modules/@mrclrchtr/supi-lsp/src/{lsp-state.ts → session/lsp-state.ts} +22 -11
- package/node_modules/@mrclrchtr/supi-lsp/src/{scanner.ts → session/scanner.ts} +3 -3
- package/node_modules/@mrclrchtr/supi-lsp/src/{service-registry.ts → session/service-registry.ts} +104 -12
- package/node_modules/@mrclrchtr/supi-lsp/src/{settings-registration.ts → session/settings-registration.ts} +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/session/tree-persist.ts +75 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/summary.ts +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/tool/guidance.ts +138 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/tool/names.ts +19 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/{overrides.ts → tool/overrides.ts} +55 -24
- package/node_modules/@mrclrchtr/supi-lsp/src/tool/register-tools.ts +224 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/tool/service-actions.ts +258 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/{ui.ts → ui/ui.ts} +4 -4
- package/node_modules/@mrclrchtr/supi-lsp/src/utils.ts +11 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +46 -39
- package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/api.ts +1 -1
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/index.ts +1 -1
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/{runtime.ts → session/runtime.ts} +3 -3
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/{session.ts → session/session.ts} +4 -4
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/{callees.ts → tool/callees.ts} +3 -3
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/{exports.ts → tool/exports.ts} +4 -4
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/{formatting.ts → tool/formatting.ts} +1 -1
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/guidance.ts +22 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/{imports.ts → tool/imports.ts} +4 -4
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/{node-at.ts → tool/node-at.ts} +3 -3
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/{outline.ts → tool/outline.ts} +3 -3
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/tree-sitter.ts +6 -29
- package/package.json +8 -5
- package/src/actions/affected-action.ts +4 -4
- package/src/actions/brief-action.ts +12 -13
- package/src/actions/callees-action.ts +14 -10
- package/src/actions/callers-action.ts +4 -4
- package/src/actions/implementations-action.ts +4 -4
- package/src/code-intelligence.ts +1 -1
- package/src/pattern-structured.ts +20 -22
- package/src/providers/semantic-provider.ts +34 -0
- package/src/providers/structural-provider.ts +14 -0
- package/src/target-resolution.ts +26 -35
- package/src/tool/guidance.ts +21 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/guidance.ts +0 -163
- package/node_modules/@mrclrchtr/supi-lsp/src/search-fallback.ts +0 -98
- package/node_modules/@mrclrchtr/supi-lsp/src/tool-actions.ts +0 -430
- package/node_modules/@mrclrchtr/supi-lsp/src/tree-persist.ts +0 -48
- package/node_modules/@mrclrchtr/supi-lsp/src/tsconfig-scope.ts +0 -156
- package/src/guidance.ts +0 -42
- /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/src/{capabilities.ts → config/capabilities.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/src/{config.ts → config/config.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/src/{defaults.json → config/defaults.json} +0 -0
- /package/node_modules/@mrclrchtr/supi-lsp/src/{renderer.ts → ui/renderer.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-tree-sitter/src/{structure.ts → tool/structure.ts} +0 -0
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import * as path from "node:path";
|
|
2
|
-
import { splitSuppressionDiagnostics } from "./diagnostics/suppression-diagnostics.ts";
|
|
3
|
-
import type { OutstandingDiagnosticSummaryEntry } from "./manager/manager-types.ts";
|
|
4
|
-
import type { Diagnostic, ProjectServerInfo } from "./types.ts";
|
|
5
|
-
|
|
6
|
-
export const lspPromptSnippet =
|
|
7
|
-
"Use semantic code intelligence for hover, definitions, references, symbols, rename planning, code actions, and diagnostics in supported languages.";
|
|
8
|
-
|
|
9
|
-
export const lspPromptGuidelines = [
|
|
10
|
-
"Prefer the lsp tool over bash text search for supported source files when the task is semantic code navigation or diagnostics.",
|
|
11
|
-
"Use lsp for hover, definitions, references, document symbols, rename planning, code actions, and diagnostics before falling back to grep-style shell search.",
|
|
12
|
-
"Fall back to bash/read when LSP is unavailable, the file type is unsupported, or the task is plain-text search across docs, config files, or string literals.",
|
|
13
|
-
"Diagnostics are automatically delivered: inline after every write/edit tool result, and as context before each agent turn. You do not need to call the lsp tool to check them — they are already in your context.",
|
|
14
|
-
"When delivered diagnostics show errors, decide: (a) expected temporary state from a planned multi-step change — continue your sequence, then verify at the end; (b) unexpected 'Cannot find module', unresolved imports, or type mismatches — stop and fix the root cause before editing more files.",
|
|
15
|
-
"When the SAME error pattern appears across MULTIPLE files after you changed imports, dependencies, or shared types, it is a systemic root-cause issue (missing install, broken import path, wrong dependency version). Do not patch each file individually — find and fix the root cause first.",
|
|
16
|
-
"When diagnostics look stale after package.json, lockfile, tsconfig, or generated-type changes, use lsp recover before editing more files.",
|
|
17
|
-
"After changing package.json dependencies, imports, or peer dependencies, run the package manager install command (e.g., pnpm install) before concluding that module resolution errors are real code bugs.",
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Build per-project `promptGuidelines` for the `lsp` tool registration.
|
|
22
|
-
* These guidelines are part of pi's stable system prompt after session-start
|
|
23
|
-
* tool registration, avoiding per-turn `before_agent_start` prompt overrides.
|
|
24
|
-
*/
|
|
25
|
-
export function buildProjectGuidelines(servers: ProjectServerInfo[], cwd: string): string[] {
|
|
26
|
-
const dynamic = servers.map((server) => {
|
|
27
|
-
const root = displayRoot(server.root, cwd);
|
|
28
|
-
const fileTypes = server.fileTypes.map((entry) => `.${entry}`).join(", ");
|
|
29
|
-
const actions = server.supportedActions.join(", ");
|
|
30
|
-
const status = server.status === "running" ? "active" : "unavailable";
|
|
31
|
-
const actionText = actions.length > 0 ? ` | actions: ${actions}` : "";
|
|
32
|
-
return `LSP ${status}: ${server.name} | root: ${root} | files: ${fileTypes}${actionText}`;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
return [
|
|
36
|
-
...lspPromptGuidelines.slice(0, 2),
|
|
37
|
-
"Use lsp before grep/rg/find for understanding code, finding usages, diagnostics, symbol lookup, and refactors in supported languages.",
|
|
38
|
-
...dynamic,
|
|
39
|
-
"Use lsp actions by task: hover/definition/references/symbols for understanding code, references/workspace_symbol/search for usages, diagnostics/hover/code_actions for issues, and rename/code_actions for refactors.",
|
|
40
|
-
...lspPromptGuidelines.slice(2),
|
|
41
|
-
].filter(Boolean);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export const MAX_DETAILED_DIAGNOSTICS = 5;
|
|
45
|
-
const MAX_DETAIL_LINES_PER_FILE = 3;
|
|
46
|
-
|
|
47
|
-
interface DetailedDiagnostics {
|
|
48
|
-
file: string;
|
|
49
|
-
diagnostics: Diagnostic[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function formatDiagnosticsContext(
|
|
53
|
-
diagnostics: OutstandingDiagnosticSummaryEntry[],
|
|
54
|
-
maxFiles: number = 3,
|
|
55
|
-
detailed?: DetailedDiagnostics[],
|
|
56
|
-
staleWarning?: string | null,
|
|
57
|
-
): string | null {
|
|
58
|
-
if (diagnostics.length === 0) return null;
|
|
59
|
-
|
|
60
|
-
const totalDiags = diagnostics.reduce((sum, d) => sum + d.total, 0);
|
|
61
|
-
const detailMap = buildDetailMap(diagnostics, totalDiags, detailed);
|
|
62
|
-
|
|
63
|
-
const lines: string[] = [];
|
|
64
|
-
if (staleWarning) lines.push(staleWarning);
|
|
65
|
-
const visible = diagnostics.slice(0, maxFiles);
|
|
66
|
-
|
|
67
|
-
for (const entry of visible) {
|
|
68
|
-
lines.push(`- ${entry.file}: ${formatCounts(entry)}`);
|
|
69
|
-
appendDetailLines(lines, detailMap?.get(entry.file));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const remaining = diagnostics.length - visible.length;
|
|
73
|
-
if (remaining > 0) {
|
|
74
|
-
lines.push(`- +${remaining} more file${remaining === 1 ? "" : "s"}`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
appendSuppressionCleanup(
|
|
78
|
-
lines,
|
|
79
|
-
visible.map((entry) => entry.file),
|
|
80
|
-
detailMap,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
return [
|
|
84
|
-
'<extension-context source="supi-lsp">',
|
|
85
|
-
"Outstanding diagnostics — fix these before proceeding:",
|
|
86
|
-
...lines,
|
|
87
|
-
"</extension-context>",
|
|
88
|
-
].join("\n");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function buildDetailMap(
|
|
92
|
-
_diagnostics: OutstandingDiagnosticSummaryEntry[],
|
|
93
|
-
totalDiags: number,
|
|
94
|
-
detailed?: DetailedDiagnostics[],
|
|
95
|
-
): Map<string, Diagnostic[]> | null {
|
|
96
|
-
if (totalDiags > MAX_DETAILED_DIAGNOSTICS || !detailed || detailed.length === 0) return null;
|
|
97
|
-
return new Map(detailed.map((d) => [d.file, d.diagnostics]));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function appendDetailLines(lines: string[], details?: Diagnostic[]): void {
|
|
101
|
-
if (!details) return;
|
|
102
|
-
for (const d of details.slice(0, MAX_DETAIL_LINES_PER_FILE)) {
|
|
103
|
-
const line = d.range.start.line + 1;
|
|
104
|
-
const char = d.range.start.character + 1;
|
|
105
|
-
const source = d.source ? ` ${d.source}` : "";
|
|
106
|
-
lines.push(` L${line} C${char}${source}: ${d.message}`);
|
|
107
|
-
}
|
|
108
|
-
if (details.length > MAX_DETAIL_LINES_PER_FILE) {
|
|
109
|
-
const extra = details.length - MAX_DETAIL_LINES_PER_FILE;
|
|
110
|
-
lines.push(` +${extra} more`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function appendSuppressionCleanup(
|
|
115
|
-
lines: string[],
|
|
116
|
-
visibleFiles: string[],
|
|
117
|
-
detailMap: Map<string, Diagnostic[]> | null,
|
|
118
|
-
): void {
|
|
119
|
-
if (!detailMap) return;
|
|
120
|
-
|
|
121
|
-
const suppressionLines: string[] = [];
|
|
122
|
-
for (const file of visibleFiles) {
|
|
123
|
-
const diagnostics = detailMap.get(file);
|
|
124
|
-
if (!diagnostics) continue;
|
|
125
|
-
|
|
126
|
-
const { suppressions } = splitSuppressionDiagnostics(diagnostics, 1);
|
|
127
|
-
if (suppressions.length === 0) continue;
|
|
128
|
-
|
|
129
|
-
suppressionLines.push(`- ${file}`);
|
|
130
|
-
appendDetailLines(suppressionLines, suppressions);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (suppressionLines.length === 0) return;
|
|
134
|
-
lines.push("", "Stale suppression comments — clean these up:", ...suppressionLines);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function diagnosticsContextFingerprint(content: string | null): string | null {
|
|
138
|
-
return content;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// reorderDiagnosticContextMessages, getContextToken, and findLastUserMessageIndex
|
|
142
|
-
// have been extracted to supi-core/context-messages.ts.
|
|
143
|
-
// Use pruneAndReorderContextMessages(messages, "lsp-context", activeToken) instead.
|
|
144
|
-
|
|
145
|
-
function formatCounts(entry: OutstandingDiagnosticSummaryEntry): string {
|
|
146
|
-
const counts: string[] = [];
|
|
147
|
-
if (entry.errors > 0) counts.push(pluralize(entry.errors, "error"));
|
|
148
|
-
if (entry.warnings > 0) counts.push(pluralize(entry.warnings, "warning"));
|
|
149
|
-
if (entry.information > 0) counts.push(pluralize(entry.information, "info"));
|
|
150
|
-
if (entry.hints > 0) counts.push(pluralize(entry.hints, "hint"));
|
|
151
|
-
return counts.join(", ");
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function displayRoot(root: string, cwd: string): string {
|
|
155
|
-
const relative = path.relative(cwd, root);
|
|
156
|
-
if (relative === "") return ".";
|
|
157
|
-
if (relative.startsWith(`..${path.sep}`) || relative === "..") return root;
|
|
158
|
-
return relative.replaceAll(path.sep, "/");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function pluralize(count: number, word: string): string {
|
|
162
|
-
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
163
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
|
|
4
|
-
export interface GrepMatch {
|
|
5
|
-
file: string;
|
|
6
|
-
line: number;
|
|
7
|
-
text: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const IGNORE_DIRS = new Set([
|
|
11
|
-
"node_modules",
|
|
12
|
-
".git",
|
|
13
|
-
"dist",
|
|
14
|
-
"build",
|
|
15
|
-
".next",
|
|
16
|
-
"coverage",
|
|
17
|
-
"tmp",
|
|
18
|
-
".pnpm",
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
const SOURCE_EXTENSIONS = new Set([
|
|
22
|
-
".ts",
|
|
23
|
-
".tsx",
|
|
24
|
-
".js",
|
|
25
|
-
".jsx",
|
|
26
|
-
".mjs",
|
|
27
|
-
".cjs",
|
|
28
|
-
".py",
|
|
29
|
-
".rs",
|
|
30
|
-
".go",
|
|
31
|
-
".java",
|
|
32
|
-
".kt",
|
|
33
|
-
".swift",
|
|
34
|
-
".rb",
|
|
35
|
-
".c",
|
|
36
|
-
".cpp",
|
|
37
|
-
".h",
|
|
38
|
-
".hpp",
|
|
39
|
-
]);
|
|
40
|
-
|
|
41
|
-
/** Simple recursive text search in project source files. */
|
|
42
|
-
export function fallbackGrep(projectRoot: string, query: string): GrepMatch[] {
|
|
43
|
-
const results: GrepMatch[] = [];
|
|
44
|
-
walk(projectRoot, projectRoot, query, results);
|
|
45
|
-
return results;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function walk(dir: string, projectRoot: string, query: string, results: GrepMatch[]): void {
|
|
49
|
-
let entries: fs.Dirent[];
|
|
50
|
-
try {
|
|
51
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
52
|
-
} catch {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
for (const entry of entries) {
|
|
57
|
-
if (entry.isDirectory()) {
|
|
58
|
-
if (!IGNORE_DIRS.has(entry.name)) {
|
|
59
|
-
walk(path.join(dir, entry.name), projectRoot, query, results);
|
|
60
|
-
}
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (!entry.isFile() || !SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const filePath = path.join(dir, entry.name);
|
|
69
|
-
searchFile(filePath, projectRoot, query, results);
|
|
70
|
-
if (results.length >= 20) return;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function searchFile(
|
|
75
|
-
filePath: string,
|
|
76
|
-
projectRoot: string,
|
|
77
|
-
query: string,
|
|
78
|
-
results: GrepMatch[],
|
|
79
|
-
): void {
|
|
80
|
-
let content: string;
|
|
81
|
-
try {
|
|
82
|
-
content = fs.readFileSync(filePath, "utf-8");
|
|
83
|
-
} catch {
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const lines = content.split("\n");
|
|
88
|
-
for (let i = 0; i < lines.length; i++) {
|
|
89
|
-
if (lines[i].includes(query)) {
|
|
90
|
-
results.push({
|
|
91
|
-
file: path.relative(projectRoot, filePath),
|
|
92
|
-
line: i + 1,
|
|
93
|
-
text: lines[i].trim(),
|
|
94
|
-
});
|
|
95
|
-
if (results.length >= 20) return;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
@@ -1,430 +0,0 @@
|
|
|
1
|
-
// LSP tool action implementations — dispatches agent tool calls to LSP clients.
|
|
2
|
-
// biome-ignore-all lint/nursery/noExcessiveLinesPerFile: action dispatch remains cohesive; recovery helpers stay adjacent for readability.
|
|
3
|
-
|
|
4
|
-
import * as fs from "node:fs";
|
|
5
|
-
import * as path from "node:path";
|
|
6
|
-
import type { LspClient } from "./client/client.ts";
|
|
7
|
-
import { formatDiagnostics } from "./diagnostics/diagnostics.ts";
|
|
8
|
-
import {
|
|
9
|
-
formatCodeActions,
|
|
10
|
-
formatDocumentSymbols,
|
|
11
|
-
formatHover,
|
|
12
|
-
formatLocations,
|
|
13
|
-
formatSearchResults,
|
|
14
|
-
formatSymbolInformation,
|
|
15
|
-
formatWorkspaceEdit,
|
|
16
|
-
formatWorkspaceSymbols,
|
|
17
|
-
normalizeLocations,
|
|
18
|
-
} from "./format.ts";
|
|
19
|
-
import type { LspManager } from "./manager/manager.ts";
|
|
20
|
-
import { fallbackGrep } from "./search-fallback.ts";
|
|
21
|
-
import type { DocumentSymbol, Range, SymbolInformation } from "./types.ts";
|
|
22
|
-
import { uriToFile } from "./utils.ts";
|
|
23
|
-
|
|
24
|
-
// ── Types ─────────────────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
export type LspAction =
|
|
27
|
-
| "hover"
|
|
28
|
-
| "definition"
|
|
29
|
-
| "references"
|
|
30
|
-
| "diagnostics"
|
|
31
|
-
| "symbols"
|
|
32
|
-
| "rename"
|
|
33
|
-
| "code_actions"
|
|
34
|
-
| "workspace_symbol"
|
|
35
|
-
| "search"
|
|
36
|
-
| "symbol_hover"
|
|
37
|
-
| "recover";
|
|
38
|
-
|
|
39
|
-
export interface LspToolParams {
|
|
40
|
-
action: LspAction;
|
|
41
|
-
file?: string;
|
|
42
|
-
line?: number;
|
|
43
|
-
character?: number;
|
|
44
|
-
newName?: string;
|
|
45
|
-
query?: string;
|
|
46
|
-
symbol?: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Tool Description ──────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
export const lspToolDescription = `Language Server Protocol tool — provides type-aware code intelligence.
|
|
52
|
-
|
|
53
|
-
Actions:
|
|
54
|
-
- hover: Get type info and docs at a position. Params: file, line, character
|
|
55
|
-
- definition: Go to definition of a symbol. Params: file, line, character
|
|
56
|
-
- references: Find all references to a symbol. Params: file, line, character
|
|
57
|
-
- diagnostics: Get type errors and warnings. Params: file (optional — omit for all files)
|
|
58
|
-
- symbols: List all symbols in a file. Params: file
|
|
59
|
-
- rename: Rename a symbol across the project. Params: file, line, character, newName
|
|
60
|
-
- code_actions: Get available fixes/refactors at a position. Params: file, line, character
|
|
61
|
-
- workspace_symbol: Fuzzy symbol search across the project. Params: query
|
|
62
|
-
- search: Search for symbols (LSP first, then text fallback). Params: query
|
|
63
|
-
- symbol_hover: Hover info by symbol name (zero coordinates). Params: symbol
|
|
64
|
-
- recover: Refresh cached diagnostics after a workspace change. Params: none
|
|
65
|
-
|
|
66
|
-
Line and character are 1-based. File paths are relative to cwd.`;
|
|
67
|
-
|
|
68
|
-
// ── Action Dispatcher ─────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
export async function executeAction(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
71
|
-
const cwd = manager.getCwd();
|
|
72
|
-
switch (params.action) {
|
|
73
|
-
case "hover":
|
|
74
|
-
return handleHover(manager, params, cwd);
|
|
75
|
-
case "definition":
|
|
76
|
-
return handleDefinition(manager, params, cwd);
|
|
77
|
-
case "references":
|
|
78
|
-
return handleReferences(manager, params, cwd);
|
|
79
|
-
case "diagnostics":
|
|
80
|
-
return handleDiagnostics(manager, params, cwd);
|
|
81
|
-
case "symbols":
|
|
82
|
-
return handleSymbols(manager, params, cwd);
|
|
83
|
-
case "rename":
|
|
84
|
-
return handleRename(manager, params, cwd);
|
|
85
|
-
case "code_actions":
|
|
86
|
-
return handleCodeActions(manager, params, cwd);
|
|
87
|
-
case "workspace_symbol":
|
|
88
|
-
return handleWorkspaceSymbol(manager, params, cwd);
|
|
89
|
-
case "search":
|
|
90
|
-
return handleSearch(manager, params, cwd);
|
|
91
|
-
case "symbol_hover":
|
|
92
|
-
return handleSymbolHover(manager, params, cwd);
|
|
93
|
-
case "recover":
|
|
94
|
-
return handleRecover(manager);
|
|
95
|
-
default:
|
|
96
|
-
return `Unknown action: ${params.action}`;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Top-level error guard for unexpected throws in action handlers.
|
|
102
|
-
* Each handler aims to catch its own errors, but this ensures that
|
|
103
|
-
* an unforeseen runtime failure produces a descriptive string return
|
|
104
|
-
* instead of an unhandled tool error that pi would surface to the agent.
|
|
105
|
-
*/
|
|
106
|
-
export async function safeExecuteAction(
|
|
107
|
-
manager: LspManager,
|
|
108
|
-
params: LspToolParams,
|
|
109
|
-
): Promise<string> {
|
|
110
|
-
try {
|
|
111
|
-
return await executeAction(manager, params);
|
|
112
|
-
} catch (err) {
|
|
113
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
-
return `LSP action failed: ${message}`;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ── Validation helpers ────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
function validateFilePosition(
|
|
121
|
-
params: LspToolParams,
|
|
122
|
-
action: string,
|
|
123
|
-
): { file: string; line: number; character: number } | string {
|
|
124
|
-
if (!params.file) return `Validation error: 'file' is required for ${action} action.`;
|
|
125
|
-
if (params.line === undefined)
|
|
126
|
-
return `Validation error: 'line' is required for ${action} action.`;
|
|
127
|
-
if (params.character === undefined)
|
|
128
|
-
return `Validation error: 'character' is required for ${action} action.`;
|
|
129
|
-
if (!Number.isInteger(params.line) || params.line < 1)
|
|
130
|
-
return `Validation error: 'line' must be a positive 1-based integer for ${action} action.`;
|
|
131
|
-
if (!Number.isInteger(params.character) || params.character < 1)
|
|
132
|
-
return `Validation error: 'character' must be a positive 1-based integer for ${action} action.`;
|
|
133
|
-
return { file: params.file, line: params.line, character: params.character };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function validateNonEmptyString(
|
|
137
|
-
value: string | undefined,
|
|
138
|
-
name: string,
|
|
139
|
-
action: string,
|
|
140
|
-
): { value: string } | string {
|
|
141
|
-
if (!value || value.trim().length === 0) {
|
|
142
|
-
return `Validation error: '${name}' is required for ${action} action.`;
|
|
143
|
-
}
|
|
144
|
-
return { value };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function resolveFilePath(file: string, cwd: string): string {
|
|
148
|
-
return path.resolve(cwd, file);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function toZeroBased(line: number, character: number): { line: number; character: number } {
|
|
152
|
-
return { line: line - 1, character: character - 1 };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function noServerMessage(file: string): string {
|
|
156
|
-
return `No LSP server available for this file type (${path.extname(file) || "unknown"})`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ── Higher-order helpers ──────────────────────────────────────────────
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Encapsulates the common preamble shared by file-position-based action handlers:
|
|
163
|
-
* validates file/line/character, resolves the path, obtains the LSP client, and
|
|
164
|
-
* returns an error string if any step fails. Passes the ready client and zero-based
|
|
165
|
-
* coordinates to the callback.
|
|
166
|
-
*/
|
|
167
|
-
// biome-ignore lint/complexity/useMaxParams: Higher-order callback wrapper intentionally takes 5 params.
|
|
168
|
-
async function withFileClient(
|
|
169
|
-
manager: LspManager,
|
|
170
|
-
params: LspToolParams,
|
|
171
|
-
cwd: string,
|
|
172
|
-
action: string,
|
|
173
|
-
fn: (client: LspClient, file: string, line: number, character: number) => Promise<string>,
|
|
174
|
-
): Promise<string> {
|
|
175
|
-
const validation = validateFilePosition(params, action);
|
|
176
|
-
if (typeof validation === "string") return validation;
|
|
177
|
-
const { file, line, character } = validation;
|
|
178
|
-
const resolvedPath = resolveFilePath(file, cwd);
|
|
179
|
-
|
|
180
|
-
const client = await manager.ensureFileOpen(resolvedPath);
|
|
181
|
-
if (!client) return noServerMessage(resolvedPath);
|
|
182
|
-
|
|
183
|
-
return fn(client, resolvedPath, line, character);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ── Action Handlers ───────────────────────────────────────────────────
|
|
187
|
-
|
|
188
|
-
async function handleHover(
|
|
189
|
-
manager: LspManager,
|
|
190
|
-
params: LspToolParams,
|
|
191
|
-
cwd: string,
|
|
192
|
-
): Promise<string> {
|
|
193
|
-
return withFileClient(manager, params, cwd, "hover", async (client, file, line, character) => {
|
|
194
|
-
const hover = await client.hover(file, toZeroBased(line, character));
|
|
195
|
-
if (!hover) return "No hover information available at this position.";
|
|
196
|
-
return formatHover(hover);
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function handleDefinition(
|
|
201
|
-
manager: LspManager,
|
|
202
|
-
params: LspToolParams,
|
|
203
|
-
cwd: string,
|
|
204
|
-
): Promise<string> {
|
|
205
|
-
return withFileClient(
|
|
206
|
-
manager,
|
|
207
|
-
params,
|
|
208
|
-
cwd,
|
|
209
|
-
"definition",
|
|
210
|
-
async (client, file, line, character) => {
|
|
211
|
-
const result = await client.definition(file, toZeroBased(line, character));
|
|
212
|
-
if (!result) return "No definition found.";
|
|
213
|
-
|
|
214
|
-
const locations = normalizeLocations(result);
|
|
215
|
-
if (locations.length === 0) return "No definition found.";
|
|
216
|
-
|
|
217
|
-
return formatLocations("Definition", locations, cwd);
|
|
218
|
-
},
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async function handleReferences(
|
|
223
|
-
manager: LspManager,
|
|
224
|
-
params: LspToolParams,
|
|
225
|
-
cwd: string,
|
|
226
|
-
): Promise<string> {
|
|
227
|
-
return withFileClient(
|
|
228
|
-
manager,
|
|
229
|
-
params,
|
|
230
|
-
cwd,
|
|
231
|
-
"references",
|
|
232
|
-
async (client, file, line, character) => {
|
|
233
|
-
const locations = await client.references(file, toZeroBased(line, character));
|
|
234
|
-
if (!locations || locations.length === 0) return "No references found.";
|
|
235
|
-
|
|
236
|
-
return formatLocations("References", locations, cwd);
|
|
237
|
-
},
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async function handleDiagnostics(
|
|
242
|
-
manager: LspManager,
|
|
243
|
-
params: LspToolParams,
|
|
244
|
-
cwd: string,
|
|
245
|
-
): Promise<string> {
|
|
246
|
-
if (params.file) {
|
|
247
|
-
const resolvedPath = resolveFilePath(params.file, cwd);
|
|
248
|
-
const client = await manager.ensureFileOpen(resolvedPath);
|
|
249
|
-
if (!client) return noServerMessage(resolvedPath);
|
|
250
|
-
|
|
251
|
-
try {
|
|
252
|
-
const content = fs.readFileSync(resolvedPath, "utf-8");
|
|
253
|
-
const diags = await client.syncAndWaitForDiagnostics(resolvedPath, content);
|
|
254
|
-
return formatDiagnostics(resolvedPath, diags, cwd);
|
|
255
|
-
} catch {
|
|
256
|
-
return `Error: failed to read or query diagnostics for ${params.file}`;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const summary = manager.getDiagnosticSummary();
|
|
261
|
-
if (summary.length === 0) return "No diagnostics across any files.";
|
|
262
|
-
|
|
263
|
-
const lines = ["## Diagnostics Summary\n"];
|
|
264
|
-
for (const s of summary) {
|
|
265
|
-
lines.push(`- **${s.file}**: ${s.errors} error(s), ${s.warnings} warning(s)`);
|
|
266
|
-
}
|
|
267
|
-
return lines.join("\n");
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
async function handleSymbols(
|
|
271
|
-
manager: LspManager,
|
|
272
|
-
params: LspToolParams,
|
|
273
|
-
cwd: string,
|
|
274
|
-
): Promise<string> {
|
|
275
|
-
if (!params.file) return "Validation error: 'file' is required for symbols action.";
|
|
276
|
-
const resolvedPath = resolveFilePath(params.file, cwd);
|
|
277
|
-
|
|
278
|
-
const client = await manager.ensureFileOpen(resolvedPath);
|
|
279
|
-
if (!client) return noServerMessage(resolvedPath);
|
|
280
|
-
|
|
281
|
-
const symbols = await client.documentSymbols(resolvedPath);
|
|
282
|
-
if (!symbols || symbols.length === 0) return "No symbols found.";
|
|
283
|
-
|
|
284
|
-
if ("children" in symbols[0] || "selectionRange" in symbols[0]) {
|
|
285
|
-
return formatDocumentSymbols(symbols as DocumentSymbol[], 0);
|
|
286
|
-
}
|
|
287
|
-
return formatSymbolInformation(symbols as SymbolInformation[], cwd);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async function handleRename(
|
|
291
|
-
manager: LspManager,
|
|
292
|
-
params: LspToolParams,
|
|
293
|
-
cwd: string,
|
|
294
|
-
): Promise<string> {
|
|
295
|
-
const nameValidation = validateNonEmptyString(params.newName, "newName", "rename");
|
|
296
|
-
if (typeof nameValidation === "string") return nameValidation;
|
|
297
|
-
const newName = nameValidation.value;
|
|
298
|
-
|
|
299
|
-
return withFileClient(manager, params, cwd, "rename", async (client, file, line, character) => {
|
|
300
|
-
const edit = await client.rename(file, toZeroBased(line, character), newName);
|
|
301
|
-
if (!edit) return "Rename not available at this position.";
|
|
302
|
-
|
|
303
|
-
return formatWorkspaceEdit(edit, cwd);
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
async function handleCodeActions(
|
|
308
|
-
manager: LspManager,
|
|
309
|
-
params: LspToolParams,
|
|
310
|
-
cwd: string,
|
|
311
|
-
): Promise<string> {
|
|
312
|
-
return withFileClient(
|
|
313
|
-
manager,
|
|
314
|
-
params,
|
|
315
|
-
cwd,
|
|
316
|
-
"code_actions",
|
|
317
|
-
async (client, file, line, character) => {
|
|
318
|
-
const pos = toZeroBased(line, character);
|
|
319
|
-
const range: Range = { start: pos, end: pos };
|
|
320
|
-
const diags = client.getDiagnostics(file);
|
|
321
|
-
|
|
322
|
-
const relevantDiags = diags.filter(
|
|
323
|
-
(d) => d.range.start.line <= pos.line && d.range.end.line >= pos.line,
|
|
324
|
-
);
|
|
325
|
-
|
|
326
|
-
const actions = await client.codeActions(file, range, {
|
|
327
|
-
diagnostics: relevantDiags,
|
|
328
|
-
});
|
|
329
|
-
if (!actions || actions.length === 0) return "No code actions available at this position.";
|
|
330
|
-
|
|
331
|
-
return formatCodeActions(actions);
|
|
332
|
-
},
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
async function handleWorkspaceSymbol(
|
|
337
|
-
manager: LspManager,
|
|
338
|
-
params: LspToolParams,
|
|
339
|
-
cwd: string,
|
|
340
|
-
): Promise<string> {
|
|
341
|
-
const validation = validateNonEmptyString(params.query, "query", "workspace_symbol");
|
|
342
|
-
if (typeof validation === "string") return validation;
|
|
343
|
-
const query = validation.value;
|
|
344
|
-
|
|
345
|
-
const symbols = await manager.workspaceSymbol(query);
|
|
346
|
-
if (!symbols) return "Workspace symbol search not supported by this language server.";
|
|
347
|
-
if (symbols.length === 0) return `No symbols found for query "${query}".`;
|
|
348
|
-
|
|
349
|
-
return formatWorkspaceSymbols(symbols as SymbolInformation[], cwd);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function handleSearch(
|
|
353
|
-
manager: LspManager,
|
|
354
|
-
params: LspToolParams,
|
|
355
|
-
cwd: string,
|
|
356
|
-
): Promise<string> {
|
|
357
|
-
const validation = validateNonEmptyString(params.query, "query", "search");
|
|
358
|
-
if (typeof validation === "string") return validation;
|
|
359
|
-
const query = validation.value;
|
|
360
|
-
|
|
361
|
-
const symbols = await manager.workspaceSymbol(query);
|
|
362
|
-
if (symbols && symbols.length > 0) {
|
|
363
|
-
return formatWorkspaceSymbols(symbols as SymbolInformation[], cwd);
|
|
364
|
-
}
|
|
365
|
-
const grepMatches = fallbackGrep(cwd, query);
|
|
366
|
-
return formatSearchResults(null, grepMatches, cwd);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
async function handleSymbolHover(
|
|
370
|
-
manager: LspManager,
|
|
371
|
-
params: LspToolParams,
|
|
372
|
-
_cwd: string,
|
|
373
|
-
): Promise<string> {
|
|
374
|
-
const validation = validateNonEmptyString(params.symbol, "symbol", "symbol_hover");
|
|
375
|
-
if (typeof validation === "string") return validation;
|
|
376
|
-
const symbol = validation.value;
|
|
377
|
-
|
|
378
|
-
const symbols = await manager.workspaceSymbol(symbol);
|
|
379
|
-
if (!symbols || symbols.length === 0) {
|
|
380
|
-
return `Symbol "${symbol}" not found.`;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Use the first match arbitrarily; LSP servers return results in their own
|
|
384
|
-
// order. The agent can use workspace_symbol or search to disambiguate.
|
|
385
|
-
const match = symbols[0];
|
|
386
|
-
const filePath = uriToFile(match.location.uri);
|
|
387
|
-
const client = await manager.ensureFileOpen(filePath);
|
|
388
|
-
if (!client) return noServerMessage(filePath);
|
|
389
|
-
|
|
390
|
-
const hover = await client.hover(filePath, match.location.range.start);
|
|
391
|
-
if (!hover) return `No hover information available for "${symbol}".`;
|
|
392
|
-
|
|
393
|
-
let result = formatHover(hover);
|
|
394
|
-
if (symbols.length > 1) {
|
|
395
|
-
result += `\n\n(${symbols.length - 1} other match${symbols.length === 2 ? "" : "es"} found — use workspace_symbol or search to disambiguate)`;
|
|
396
|
-
}
|
|
397
|
-
return result;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
interface RecoveryAssessmentLike {
|
|
401
|
-
suspected: boolean;
|
|
402
|
-
matchedFiles: Array<{ file: string; diagnostics: unknown[] }>;
|
|
403
|
-
warning: string | null;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
interface RecoveryResultLike {
|
|
407
|
-
refreshedClients: number;
|
|
408
|
-
restartedClients: number;
|
|
409
|
-
staleAssessment: RecoveryAssessmentLike;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
async function handleRecover(manager: LspManager): Promise<string> {
|
|
413
|
-
const result = await manager.recoverWorkspaceDiagnostics({ restartIfStillStale: true });
|
|
414
|
-
return formatRecoveryResult(result);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function formatRecoveryResult(result: RecoveryResultLike): string {
|
|
418
|
-
const refreshed = pluralize(result.refreshedClients, "client");
|
|
419
|
-
const restarted = pluralize(result.restartedClients, "client");
|
|
420
|
-
const status = result.staleAssessment.suspected
|
|
421
|
-
? "stale diagnostics still suspected"
|
|
422
|
-
: "stale diagnostics cleared";
|
|
423
|
-
const warning = result.staleAssessment.warning ? ` — ${result.staleAssessment.warning}` : "";
|
|
424
|
-
|
|
425
|
-
return `LSP recovery complete: refreshed ${refreshed}, restarted ${restarted}, ${status}${warning}.`;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function pluralize(count: number, word: string): string {
|
|
429
|
-
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
430
|
-
}
|