pi-rtk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/RTK.md +845 -0
- package/config.ts +84 -0
- package/index.ts +228 -0
- package/metrics.ts +83 -0
- package/package.json +35 -0
- package/techniques/ansi.ts +19 -0
- package/techniques/build.ts +172 -0
- package/techniques/git.ts +245 -0
- package/techniques/index.ts +16 -0
- package/techniques/linter.ts +191 -0
- package/techniques/search.ts +100 -0
- package/techniques/source.ts +246 -0
- package/techniques/test-output.ts +172 -0
- package/techniques/truncate.ts +27 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const GIT_COMMANDS = ["git diff", "git status", "git log", "git show", "git stash"];
|
|
2
|
+
|
|
3
|
+
export function isGitCommand(command: string | undefined | null): boolean {
|
|
4
|
+
if (typeof command !== "string" || command.length === 0) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const cmdLower = command.toLowerCase();
|
|
9
|
+
return GIT_COMMANDS.some((gc) => cmdLower.startsWith(gc));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function compactDiff(output: string, maxLines: number = 50): string {
|
|
13
|
+
const lines = output.split("\n");
|
|
14
|
+
const result: string[] = [];
|
|
15
|
+
let currentFile = "";
|
|
16
|
+
let added = 0;
|
|
17
|
+
let removed = 0;
|
|
18
|
+
let inHunk = false;
|
|
19
|
+
let hunkLines = 0;
|
|
20
|
+
const maxHunkLines = 10;
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (result.length >= maxLines) {
|
|
24
|
+
result.push("\n... (more changes truncated)");
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// New file
|
|
29
|
+
if (line.startsWith("diff --git")) {
|
|
30
|
+
// Flush previous file stats
|
|
31
|
+
if (currentFile && (added > 0 || removed > 0)) {
|
|
32
|
+
result.push(` +${added} -${removed}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Extract filename
|
|
36
|
+
const match = line.match(/diff --git a\/(.+) b\/(.+)/);
|
|
37
|
+
currentFile = match ? match[2] : "unknown";
|
|
38
|
+
result.push(`\nš ${currentFile}`);
|
|
39
|
+
added = 0;
|
|
40
|
+
removed = 0;
|
|
41
|
+
inHunk = false;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Hunk header
|
|
46
|
+
if (line.startsWith("@@")) {
|
|
47
|
+
inHunk = true;
|
|
48
|
+
hunkLines = 0;
|
|
49
|
+
const hunkInfo = line.match(/@@ .+ @@/)?.[0] || "@@";
|
|
50
|
+
result.push(` ${hunkInfo}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Hunk content
|
|
55
|
+
if (inHunk) {
|
|
56
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
57
|
+
added++;
|
|
58
|
+
if (hunkLines < maxHunkLines) {
|
|
59
|
+
result.push(` ${line}`);
|
|
60
|
+
hunkLines++;
|
|
61
|
+
}
|
|
62
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
63
|
+
removed++;
|
|
64
|
+
if (hunkLines < maxHunkLines) {
|
|
65
|
+
result.push(` ${line}`);
|
|
66
|
+
hunkLines++;
|
|
67
|
+
}
|
|
68
|
+
} else if (hunkLines < maxHunkLines && !line.startsWith("\\")) {
|
|
69
|
+
if (hunkLines > 0) {
|
|
70
|
+
result.push(` ${line}`);
|
|
71
|
+
hunkLines++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (hunkLines === maxHunkLines) {
|
|
76
|
+
result.push(" ... (truncated)");
|
|
77
|
+
hunkLines++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Flush last file stats
|
|
83
|
+
if (currentFile && (added > 0 || removed > 0)) {
|
|
84
|
+
result.push(` +${added} -${removed}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface StatusStats {
|
|
91
|
+
staged: number;
|
|
92
|
+
modified: number;
|
|
93
|
+
untracked: number;
|
|
94
|
+
conflicts: number;
|
|
95
|
+
stagedFiles: string[];
|
|
96
|
+
modifiedFiles: string[];
|
|
97
|
+
untrackedFiles: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function compactStatus(output: string): string {
|
|
101
|
+
const lines = output.split("\n");
|
|
102
|
+
|
|
103
|
+
if (lines.length === 0 || (lines.length === 1 && lines[0].trim() === "")) {
|
|
104
|
+
return "Clean working tree";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stats: StatusStats = {
|
|
108
|
+
staged: 0,
|
|
109
|
+
modified: 0,
|
|
110
|
+
untracked: 0,
|
|
111
|
+
conflicts: 0,
|
|
112
|
+
stagedFiles: [],
|
|
113
|
+
modifiedFiles: [],
|
|
114
|
+
untrackedFiles: [],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
let branchName = "";
|
|
118
|
+
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
// Extract branch name from first line
|
|
121
|
+
if (line.startsWith("##")) {
|
|
122
|
+
const match = line.match(/## (.+)/);
|
|
123
|
+
if (match) {
|
|
124
|
+
branchName = match[1].split("...")[0];
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (line.length < 3) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const status = line.slice(0, 2);
|
|
134
|
+
const filename = line.slice(3);
|
|
135
|
+
|
|
136
|
+
// Parse two-character status
|
|
137
|
+
const indexStatus = status[0];
|
|
138
|
+
const worktreeStatus = status[1];
|
|
139
|
+
|
|
140
|
+
if (["M", "A", "D", "R", "C"].includes(indexStatus)) {
|
|
141
|
+
stats.staged++;
|
|
142
|
+
stats.stagedFiles.push(filename);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (indexStatus === "U") {
|
|
146
|
+
stats.conflicts++;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (["M", "D"].includes(worktreeStatus)) {
|
|
150
|
+
stats.modified++;
|
|
151
|
+
stats.modifiedFiles.push(filename);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (status === "??") {
|
|
155
|
+
stats.untracked++;
|
|
156
|
+
stats.untrackedFiles.push(filename);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Build summary
|
|
161
|
+
let result = `š ${branchName}\n`;
|
|
162
|
+
|
|
163
|
+
if (stats.staged > 0) {
|
|
164
|
+
result += `ā
Staged: ${stats.staged} files\n`;
|
|
165
|
+
const shown = stats.stagedFiles.slice(0, 5);
|
|
166
|
+
for (const file of shown) {
|
|
167
|
+
result += ` ${file}\n`;
|
|
168
|
+
}
|
|
169
|
+
if (stats.staged > 5) {
|
|
170
|
+
result += ` ... +${stats.staged - 5} more\n`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (stats.modified > 0) {
|
|
175
|
+
result += `š Modified: ${stats.modified} files\n`;
|
|
176
|
+
const shown = stats.modifiedFiles.slice(0, 5);
|
|
177
|
+
for (const file of shown) {
|
|
178
|
+
result += ` ${file}\n`;
|
|
179
|
+
}
|
|
180
|
+
if (stats.modified > 5) {
|
|
181
|
+
result += ` ... +${stats.modified - 5} more\n`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (stats.untracked > 0) {
|
|
186
|
+
result += `ā Untracked: ${stats.untracked} files\n`;
|
|
187
|
+
const shown = stats.untrackedFiles.slice(0, 3);
|
|
188
|
+
for (const file of shown) {
|
|
189
|
+
result += ` ${file}\n`;
|
|
190
|
+
}
|
|
191
|
+
if (stats.untracked > 3) {
|
|
192
|
+
result += ` ... +${stats.untracked - 3} more\n`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (stats.conflicts > 0) {
|
|
197
|
+
result += `ā ļø Conflicts: ${stats.conflicts} files\n`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return result.trim();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function compactLog(output: string, limit: number = 20): string {
|
|
204
|
+
const lines = output.split("\n");
|
|
205
|
+
const result: string[] = [];
|
|
206
|
+
|
|
207
|
+
for (const line of lines.slice(0, limit)) {
|
|
208
|
+
if (line.length > 80) {
|
|
209
|
+
result.push(line.slice(0, 77) + "...");
|
|
210
|
+
} else {
|
|
211
|
+
result.push(line);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (lines.length > limit) {
|
|
216
|
+
result.push(`... and ${lines.length - limit} more commits`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return result.join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function compactGitOutput(
|
|
223
|
+
output: string,
|
|
224
|
+
command: string | undefined | null
|
|
225
|
+
): string | null {
|
|
226
|
+
if (typeof command !== "string" || !isGitCommand(command)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const cmdLower = command.toLowerCase();
|
|
231
|
+
|
|
232
|
+
if (cmdLower.startsWith("git diff")) {
|
|
233
|
+
return compactDiff(output);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (cmdLower.startsWith("git status")) {
|
|
237
|
+
return compactStatus(output);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (cmdLower.startsWith("git log")) {
|
|
241
|
+
return compactLog(output);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Re-export all techniques
|
|
2
|
+
export { stripAnsi, stripAnsiFast } from "./ansi";
|
|
3
|
+
export { truncate, truncateLines } from "./truncate";
|
|
4
|
+
export { filterBuildOutput, isBuildCommand } from "./build";
|
|
5
|
+
export { aggregateTestOutput, isTestCommand } from "./test-output";
|
|
6
|
+
export { aggregateLinterOutput, isLinterCommand } from "./linter";
|
|
7
|
+
export {
|
|
8
|
+
detectLanguage,
|
|
9
|
+
filterMinimal,
|
|
10
|
+
filterAggressive,
|
|
11
|
+
smartTruncate,
|
|
12
|
+
filterSourceCode,
|
|
13
|
+
type Language,
|
|
14
|
+
} from "./source";
|
|
15
|
+
export { compactDiff, compactStatus, compactLog, compactGitOutput, isGitCommand } from "./git";
|
|
16
|
+
export { groupSearchResults, isSearchCommand } from "./search";
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
const LINTER_COMMANDS = [
|
|
2
|
+
"eslint",
|
|
3
|
+
"prettier",
|
|
4
|
+
"ruff",
|
|
5
|
+
"pylint",
|
|
6
|
+
"mypy",
|
|
7
|
+
"flake8",
|
|
8
|
+
"black",
|
|
9
|
+
"clippy",
|
|
10
|
+
"golangci-lint",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
interface Issue {
|
|
14
|
+
severity: "ERROR" | "WARNING";
|
|
15
|
+
rule: string;
|
|
16
|
+
file: string;
|
|
17
|
+
line?: number;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isLinterCommand(command: string | undefined | null): boolean {
|
|
22
|
+
if (typeof command !== "string" || command.length === 0) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cmdLower = command.toLowerCase();
|
|
27
|
+
return LINTER_COMMANDS.some((lc) => cmdLower.includes(lc));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseIssues(output: string, linterType: string): Issue[] {
|
|
31
|
+
const issues: Issue[] = [];
|
|
32
|
+
const lines = output.split("\n");
|
|
33
|
+
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const issue = parseLine(line, linterType);
|
|
36
|
+
if (issue) {
|
|
37
|
+
issues.push(issue);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseLine(line: string, linterType: string): Issue | null {
|
|
45
|
+
// ESLint: /path/to/file.js:10:5: Error message [rule-id]
|
|
46
|
+
// Ruff: /path/to/file.py:10:5: E501 Error message
|
|
47
|
+
// Pylint: /path/to/file.py:10:5: E0001: Error message (rule-id)
|
|
48
|
+
// Clippy: error: message at src/main.rs:10:5
|
|
49
|
+
|
|
50
|
+
const patterns = [
|
|
51
|
+
// file:line:col: message [rule]
|
|
52
|
+
{
|
|
53
|
+
pattern: /^(.+):(\d+):(\d+):\s*(.+)$/,
|
|
54
|
+
extract: (match: RegExpMatchArray) => ({
|
|
55
|
+
file: match[1],
|
|
56
|
+
line: parseInt(match[2], 10),
|
|
57
|
+
content: match[4],
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
// error: message at file:line:col
|
|
61
|
+
{
|
|
62
|
+
pattern: /^(error|warning):\s*(.+?)\s+at\s+(.+):(\d+):(\d+)$/,
|
|
63
|
+
extract: (match: RegExpMatchArray) => ({
|
|
64
|
+
severity: match[1].toUpperCase() as "ERROR" | "WARNING",
|
|
65
|
+
message: match[2],
|
|
66
|
+
file: match[3],
|
|
67
|
+
line: parseInt(match[4], 10),
|
|
68
|
+
content: match[2],
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
for (const { pattern, extract } of patterns) {
|
|
74
|
+
const match = line.match(pattern);
|
|
75
|
+
if (match) {
|
|
76
|
+
const extracted = extract(match);
|
|
77
|
+
return {
|
|
78
|
+
severity: extracted.severity || "ERROR",
|
|
79
|
+
rule: extracted.content?.match(/\[(.+?)\]$/)?.[1] || "unknown",
|
|
80
|
+
file: extracted.file,
|
|
81
|
+
line: extracted.line,
|
|
82
|
+
message: extracted.content || extracted.message || line,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function aggregateLinterOutput(
|
|
91
|
+
output: string,
|
|
92
|
+
command: string | undefined | null
|
|
93
|
+
): string | null {
|
|
94
|
+
if (typeof command !== "string" || !isLinterCommand(command)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Detect linter type from command
|
|
99
|
+
const linterType = detectLinterType(command);
|
|
100
|
+
|
|
101
|
+
// Parse issues
|
|
102
|
+
const issues = parseIssues(output, linterType);
|
|
103
|
+
|
|
104
|
+
if (issues.length === 0) {
|
|
105
|
+
return `ā ${linterType}: No issues found`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Count by severity
|
|
109
|
+
const errors = issues.filter((i) => i.severity === "ERROR").length;
|
|
110
|
+
const warnings = issues.filter((i) => i.severity === "WARNING").length;
|
|
111
|
+
|
|
112
|
+
// Group by rule
|
|
113
|
+
const byRule = new Map<string, number>();
|
|
114
|
+
for (const issue of issues) {
|
|
115
|
+
byRule.set(issue.rule, (byRule.get(issue.rule) || 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Group by file
|
|
119
|
+
const byFile = new Map<string, Issue[]>();
|
|
120
|
+
for (const issue of issues) {
|
|
121
|
+
const existing = byFile.get(issue.file) || [];
|
|
122
|
+
existing.push(issue);
|
|
123
|
+
byFile.set(issue.file, existing);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Build output
|
|
127
|
+
let result = `${linterType}: ${errors} errors, ${warnings} warnings in ${byFile.size} files\n`;
|
|
128
|
+
result += "āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n";
|
|
129
|
+
|
|
130
|
+
// Top rules
|
|
131
|
+
const sortedRules = Array.from(byRule.entries())
|
|
132
|
+
.sort((a, b) => b[1] - a[1])
|
|
133
|
+
.slice(0, 10);
|
|
134
|
+
result += "Top rules:\n";
|
|
135
|
+
for (const [rule, count] of sortedRules) {
|
|
136
|
+
result += ` ${rule} (${count}x)\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Top files
|
|
140
|
+
result += "\nTop files:\n";
|
|
141
|
+
const sortedFiles = Array.from(byFile.entries())
|
|
142
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
143
|
+
.slice(0, 10);
|
|
144
|
+
|
|
145
|
+
for (const [file, fileIssues] of sortedFiles) {
|
|
146
|
+
const compact = compactPath(file, 40);
|
|
147
|
+
result += ` ${compact} (${fileIssues.length} issues)\n`;
|
|
148
|
+
|
|
149
|
+
// Show top 3 rules per file
|
|
150
|
+
const fileRules = new Map<string, number>();
|
|
151
|
+
for (const issue of fileIssues) {
|
|
152
|
+
fileRules.set(issue.rule, (fileRules.get(issue.rule) || 0) + 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const sortedFileRules = Array.from(fileRules.entries())
|
|
156
|
+
.sort((a, b) => b[1] - a[1])
|
|
157
|
+
.slice(0, 3);
|
|
158
|
+
|
|
159
|
+
for (const [rule, count] of sortedFileRules) {
|
|
160
|
+
result += ` ${rule} (${count})\n`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function detectLinterType(command: string): string {
|
|
168
|
+
const cmdLower = command.toLowerCase();
|
|
169
|
+
if (cmdLower.includes("eslint")) return "ESLint";
|
|
170
|
+
if (cmdLower.includes("ruff")) return "Ruff";
|
|
171
|
+
if (cmdLower.includes("pylint")) return "Pylint";
|
|
172
|
+
if (cmdLower.includes("mypy")) return "MyPy";
|
|
173
|
+
if (cmdLower.includes("flake8")) return "Flake8";
|
|
174
|
+
if (cmdLower.includes("clippy")) return "Clippy";
|
|
175
|
+
if (cmdLower.includes("golangci")) return "GolangCI-Lint";
|
|
176
|
+
if (cmdLower.includes("prettier")) return "Prettier";
|
|
177
|
+
return "Linter";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function compactPath(path: string, maxLength: number): string {
|
|
181
|
+
if (path.length <= maxLength) {
|
|
182
|
+
return path;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const parts = path.split("/");
|
|
186
|
+
if (parts.length <= 3) {
|
|
187
|
+
return path;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return `${parts[0]}/.../${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
|
191
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const SEARCH_COMMANDS = ["grep", "rg", "find", "ack", "ag"];
|
|
2
|
+
|
|
3
|
+
export function isSearchCommand(command: string | undefined | null): boolean {
|
|
4
|
+
if (typeof command !== "string" || command.length === 0) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const cmdLower = command.toLowerCase();
|
|
9
|
+
return SEARCH_COMMANDS.some((sc) => cmdLower.includes(sc));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SearchResult {
|
|
13
|
+
file: string;
|
|
14
|
+
lineNumber: string;
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function groupSearchResults(
|
|
19
|
+
output: string,
|
|
20
|
+
maxResults: number = 50
|
|
21
|
+
): string | null {
|
|
22
|
+
const lines = output.split("\n");
|
|
23
|
+
const results: SearchResult[] = [];
|
|
24
|
+
|
|
25
|
+
// Parse search results
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (!line.trim()) continue;
|
|
28
|
+
|
|
29
|
+
// Match patterns like: file:line:content or file:content
|
|
30
|
+
const match = line.match(/^(.+?):(\d+)?:(.+)$/);
|
|
31
|
+
if (match) {
|
|
32
|
+
results.push({
|
|
33
|
+
file: match[1],
|
|
34
|
+
lineNumber: match[2] || "?",
|
|
35
|
+
content: match[3],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (results.length === 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Group by file
|
|
45
|
+
const byFile = new Map<string, SearchResult[]>();
|
|
46
|
+
for (const result of results) {
|
|
47
|
+
const existing = byFile.get(result.file) || [];
|
|
48
|
+
existing.push(result);
|
|
49
|
+
byFile.set(result.file, existing);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build output
|
|
53
|
+
let output_text = `š ${results.length} matches in ${byFile.size} files:\n\n`;
|
|
54
|
+
|
|
55
|
+
const files = Array.from(byFile.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
56
|
+
let shown = 0;
|
|
57
|
+
|
|
58
|
+
for (const [file, matches] of files) {
|
|
59
|
+
if (shown >= maxResults) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const compactFile = compactPath(file, 50);
|
|
64
|
+
output_text += `š ${compactFile} (${matches.length} matches):\n`;
|
|
65
|
+
|
|
66
|
+
for (const match of matches.slice(0, 10)) {
|
|
67
|
+
let cleaned = match.content.trim();
|
|
68
|
+
if (cleaned.length > 70) {
|
|
69
|
+
cleaned = cleaned.slice(0, 67) + "...";
|
|
70
|
+
}
|
|
71
|
+
output_text += ` ${match.lineNumber}: ${cleaned}\n`;
|
|
72
|
+
shown++;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (matches.length > 10) {
|
|
76
|
+
output_text += ` +${matches.length - 10} more\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
output_text += "\n";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (results.length > shown) {
|
|
83
|
+
output_text += `... +${results.length - shown} more\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return output_text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compactPath(path: string, maxLength: number): string {
|
|
90
|
+
if (path.length <= maxLength) {
|
|
91
|
+
return path;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const parts = path.split("/");
|
|
95
|
+
if (parts.length <= 3) {
|
|
96
|
+
return path;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return `${parts[0]}/.../${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
|
100
|
+
}
|