pi-squeeze 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +115 -2
- package/extensions/command/parse.ts +82 -0
- package/extensions/core/adb.ts +231 -0
- package/extensions/core/audit.ts +53 -0
- package/extensions/core/blame.ts +32 -0
- package/extensions/core/command.ts +86 -0
- package/extensions/core/compact.ts +332 -0
- package/extensions/core/config.ts +109 -0
- package/extensions/core/diff.ts +93 -0
- package/extensions/core/docker.ts +58 -0
- package/extensions/core/dust.ts +24 -0
- package/extensions/core/elide.ts +31 -0
- package/extensions/core/files.ts +29 -0
- package/extensions/core/generic.ts +40 -0
- package/extensions/core/gitlog.ts +75 -0
- package/extensions/core/gitstatus.ts +52 -0
- package/extensions/core/gradle.ts +269 -0
- package/extensions/core/gradlereports.ts +78 -0
- package/extensions/core/install.ts +122 -0
- package/extensions/core/javac.ts +44 -0
- package/extensions/core/jest.ts +38 -0
- package/extensions/core/jvm.ts +18 -0
- package/extensions/core/lint.ts +199 -0
- package/extensions/core/logs.ts +106 -0
- package/extensions/core/lsd.ts +64 -0
- package/extensions/core/maven.ts +122 -0
- package/extensions/core/paths.ts +50 -0
- package/extensions/core/pytest.ts +37 -0
- package/extensions/core/rg.ts +126 -0
- package/extensions/core/rtk.ts +50 -0
- package/extensions/core/runners.ts +50 -0
- package/extensions/core/rustc.ts +99 -0
- package/extensions/core/save.ts +11 -0
- package/extensions/core/stats.ts +31 -0
- package/extensions/core/tests.ts +46 -0
- package/extensions/core/tree.ts +51 -0
- package/extensions/core/tsc.ts +49 -0
- package/extensions/core/xcode.ts +123 -0
- package/extensions/squeeze.ts +122 -0
- package/package.json +32 -4
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/** Chooses the compactor for a bash command and applies the safety rules. */
|
|
2
|
+
import { hasFlag, type Invocation, parse } from "./command.ts";
|
|
3
|
+
import { DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
4
|
+
import { compactDumpsys, compactPackagePaths, compactPackages, compactProcesses, compactProperties, compactUiDump } from "./adb.ts";
|
|
5
|
+
import { compactAudit } from "./audit.ts";
|
|
6
|
+
import { compactBlame } from "./blame.ts";
|
|
7
|
+
import { compactDiff } from "./diff.ts";
|
|
8
|
+
import { compactDockerBuild } from "./docker.ts";
|
|
9
|
+
import { compactDust } from "./dust.ts";
|
|
10
|
+
import { compactGradle, compactGradleDeps } from "./gradle.ts";
|
|
11
|
+
import { compactInstall } from "./install.ts";
|
|
12
|
+
import { compactJavac } from "./javac.ts";
|
|
13
|
+
import { compactJest } from "./jest.ts";
|
|
14
|
+
import { compactLint, compactLintSniffed } from "./lint.ts";
|
|
15
|
+
import { compactLogs, compactTrace } from "./logs.ts";
|
|
16
|
+
import { compactMaven, compactMavenTree } from "./maven.ts";
|
|
17
|
+
import { compactPytest } from "./pytest.ts";
|
|
18
|
+
import { compactGeneric, isContent } from "./generic.ts";
|
|
19
|
+
import { compactLog, compactShow } from "./gitlog.ts";
|
|
20
|
+
import { compactStatus } from "./gitstatus.ts";
|
|
21
|
+
import { compactLsd } from "./lsd.ts";
|
|
22
|
+
import { compactPaths } from "./paths.ts";
|
|
23
|
+
import { compactRg, compactRgPlain } from "./rg.ts";
|
|
24
|
+
import { compactRustc } from "./rustc.ts";
|
|
25
|
+
import { compactBunTest, compactGoTest } from "./runners.ts";
|
|
26
|
+
import { compactTests } from "./tests.ts";
|
|
27
|
+
import { compactTree } from "./tree.ts";
|
|
28
|
+
import { compactTsc } from "./tsc.ts";
|
|
29
|
+
import { compactXcode, compactXcodeSniffed } from "./xcode.ts";
|
|
30
|
+
|
|
31
|
+
export const KINDS = ["search", "paths", "listing", "diff", "tests", "git", "build", "lint", "install", "logs", "tree", "generic"] as const;
|
|
32
|
+
export type Kind = (typeof KINDS)[number];
|
|
33
|
+
|
|
34
|
+
export type Result = { text: string; kind: Kind; truncated: boolean; before: number; after: number };
|
|
35
|
+
|
|
36
|
+
export type Options = {
|
|
37
|
+
limits?: Limits;
|
|
38
|
+
/** Output shorter than this is never touched. */
|
|
39
|
+
minChars?: number;
|
|
40
|
+
/** The result must be at most this share of the original, or the original is kept. */
|
|
41
|
+
maxRatio?: number;
|
|
42
|
+
/** Compactors that may run; a missing entry means on. */
|
|
43
|
+
kinds?: Partial<Record<Kind, boolean>>;
|
|
44
|
+
/** The command failed: only test, build, lint, install and log output is worth compacting then (the errors, or the stack trace, are the point). */
|
|
45
|
+
failed?: boolean;
|
|
46
|
+
/** Is `path` (relative to the command's directory) an existing file? Lets search output without line numbers be grouped. */
|
|
47
|
+
isFile?: (path: string) => boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
const SEARCH = new Set(["rg", "grep", "egrep", "ugrep", "ack"]);
|
|
52
|
+
const PATHS = new Set(["fd", "fdfind", "find"]);
|
|
53
|
+
const LISTING = new Set(["lsd", "eza", "exa", "ls"]);
|
|
54
|
+
const LIST_ONLY = { long: ["--files", "--files-with-matches", "--files-without-match"], short: ["l"] };
|
|
55
|
+
const NOT_A_LIST = { long: ["--json", "--count", "--null", "--print0", "--stats", "--vimgrep"], short: ["c", "0", "z"] };
|
|
56
|
+
|
|
57
|
+
/** Flags after which the output is no longer plain `path:line:text` / one path per line. */
|
|
58
|
+
const NOT_PLAIN_SEARCH = { long: ["--json", "--count", "--files-with-matches", "--files-without-match", "--files", "--heading", "--stats", "--vimgrep", "--only-matching", "--null", "--passthru"], short: ["c", "l", "L", "o", "z"] };
|
|
59
|
+
const NOT_PLAIN_PATHS = { long: ["--exec", "--exec-batch", "--list-details", "--print0", "--format"], short: ["x", "X", "l", "0"] };
|
|
60
|
+
|
|
61
|
+
/** Flags of `git log`, `show`, `blame` and `status` that leave the default format. */
|
|
62
|
+
const GIT_FORMAT = ["--oneline", "--pretty", "--stat", "--shortstat", "--numstat", "--name-only", "--name-status", "--raw", "--graph", "--patch", "--color-words", "--word-diff", "--porcelain", "--line-porcelain", "--incremental", "--short", "--branch", "--stage", "--eol", "--debug", "--format", "--null", "--color"];
|
|
63
|
+
const GIT_FORMAT_SHORT = ["p", "s", "z", "b", "e", "t", "v"];
|
|
64
|
+
|
|
65
|
+
/** `git diff` flags that change the output format away from a unified diff. */
|
|
66
|
+
const NOT_PLAIN_DIFF = {
|
|
67
|
+
long: ["--stat", "--numstat", "--shortstat", "--dirstat", "--summary", "--name-only", "--name-status", "--raw", "--check", "--patch-with-stat", "--patch-with-raw", "--word-diff", "--color-words", "--color", "--compact-summary", "--submodule", "--no-index", "--output"],
|
|
68
|
+
short: ["s", "z"],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function plainFind(args: string[]): boolean {
|
|
72
|
+
return !args.some((a) => ["-exec", "-execdir", "-ls", "-print0", "-printf", "-fprint", "-delete", "-ok"].includes(a));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pickCargo(all: string[]): ReturnType<typeof pick> {
|
|
76
|
+
// `cargo +nightly build`: the toolchain does not change the output.
|
|
77
|
+
const [sub, ...rest] = all[0]?.startsWith("+") ? all.slice(1) : all;
|
|
78
|
+
if (hasFlag(rest, ["--format", "--message-format", "--prefix", "--charset", "--timings"])) return undefined;
|
|
79
|
+
switch (sub) {
|
|
80
|
+
case "test":
|
|
81
|
+
return { kind: "tests", run: compactTests };
|
|
82
|
+
case "build":
|
|
83
|
+
case "check":
|
|
84
|
+
case "clippy":
|
|
85
|
+
case "doc":
|
|
86
|
+
case "bench":
|
|
87
|
+
return { kind: "build", run: compactRustc };
|
|
88
|
+
case "audit":
|
|
89
|
+
return { kind: "build", run: compactAudit };
|
|
90
|
+
case "tree":
|
|
91
|
+
return { kind: "tree", run: compactTree };
|
|
92
|
+
default:
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Output with a strong marker of its own is recognised whatever command produced it (`just check`, `npm run build`, pipes). */
|
|
98
|
+
const SNIFFERS: { kind: Kind; run: (out: string, l: Limits) => Squeezed | undefined }[] = [
|
|
99
|
+
{ kind: "tests", run: compactBunTest },
|
|
100
|
+
{ kind: "tests", run: compactGoTest },
|
|
101
|
+
{ kind: "tests", run: compactPytest },
|
|
102
|
+
{ kind: "tests", run: compactJest },
|
|
103
|
+
{ kind: "build", run: compactGradle },
|
|
104
|
+
{ kind: "build", run: compactXcodeSniffed },
|
|
105
|
+
{ kind: "build", run: compactTsc },
|
|
106
|
+
{ kind: "build", run: compactRustc },
|
|
107
|
+
{ kind: "build", run: compactAudit },
|
|
108
|
+
{ kind: "lint", run: compactLintSniffed },
|
|
109
|
+
{ kind: "build", run: compactMaven },
|
|
110
|
+
{ kind: "build", run: compactJavac },
|
|
111
|
+
{ kind: "tree", run: compactUiDump },
|
|
112
|
+
{ kind: "logs", run: compactTrace },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** `git` options before the subcommand that do not change its output: `-C dir`, `-c k=v`, `--no-pager`, ... */
|
|
116
|
+
function gitSubcommand(args: string[]): string[] {
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < args.length) {
|
|
119
|
+
const a = args[i]!;
|
|
120
|
+
if (a === "-C" || a === "-c") i += 2;
|
|
121
|
+
else if (["--no-pager", "-P", "--no-optional-locks", "--literal-pathspecs"].includes(a) || /^--(git-dir|work-tree)=/.test(a)) i++;
|
|
122
|
+
else break;
|
|
123
|
+
}
|
|
124
|
+
return args.slice(i);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `--color=never` and `--no-color` leave the output as it is without them. */
|
|
128
|
+
const noColor = (args: string[]) => args.filter((a) => a !== "--color=never" && a !== "--no-color");
|
|
129
|
+
|
|
130
|
+
function pickGit(all: string[]): ReturnType<typeof pick> {
|
|
131
|
+
const [sub, ...rest] = noColor(gitSubcommand(all));
|
|
132
|
+
switch (sub) {
|
|
133
|
+
case "diff":
|
|
134
|
+
if (hasFlag(rest, ["--name-only"]) && !hasFlag(rest, ["--null"], ["z"])) return { kind: "paths", run: compactPaths };
|
|
135
|
+
return hasFlag(rest, NOT_PLAIN_DIFF.long, NOT_PLAIN_DIFF.short) ? undefined : { kind: "diff", run: compactDiff };
|
|
136
|
+
case "ls-files":
|
|
137
|
+
return hasFlag(rest, ["--stage", "--eol", "--debug", "--format", "--deleted", "--others"], ["s", "z", "t", "v"]) ? undefined : { kind: "paths", run: compactPaths };
|
|
138
|
+
case "status":
|
|
139
|
+
return hasFlag(rest, GIT_FORMAT, GIT_FORMAT_SHORT) ? undefined : { kind: "git", run: compactStatus };
|
|
140
|
+
case "log":
|
|
141
|
+
return hasFlag(rest, GIT_FORMAT, GIT_FORMAT_SHORT) ? undefined : { kind: "git", run: compactLog };
|
|
142
|
+
case "show":
|
|
143
|
+
return hasFlag(rest, GIT_FORMAT, GIT_FORMAT_SHORT) ? undefined : { kind: "git", run: compactShow };
|
|
144
|
+
case "blame":
|
|
145
|
+
return hasFlag(rest, GIT_FORMAT, GIT_FORMAT_SHORT) ? undefined : { kind: "git", run: compactBlame };
|
|
146
|
+
default:
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Flags that print context lines (`path-text`, which cannot be told apart from a path with a dash). */
|
|
152
|
+
const CONTEXT = { long: ["--context", "--after-context", "--before-context"], short: /^-[A-Za-z]*[ABC]/ };
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `rg pattern dir` without -n: grouped only when the paths can be checked on disk, and not when the
|
|
156
|
+
* only path searched is itself a file (then there is no path prefix, and a colon in the text would pass for one).
|
|
157
|
+
*/
|
|
158
|
+
function plainSearch(args: string[], isFile: ((path: string) => boolean) | undefined): ReturnType<typeof pick> {
|
|
159
|
+
if (hasFlag(args, ["--line-number"], ["n"]) || hasFlag(args, CONTEXT.long) || args.some((a) => CONTEXT.short.test(a)) || !isFile) return undefined;
|
|
160
|
+
const operands = args.filter((a) => !a.startsWith("-")).slice(1);
|
|
161
|
+
if (operands.length === 1 && isFile(operands[0]!)) return undefined;
|
|
162
|
+
return { kind: "search", run: (out, l) => compactRgPlain(out, l, isFile) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `npx tool`, `uv run tool`, `python -m tool`, `xcrun tool`…: the tool is what prints. */
|
|
166
|
+
function unwrap({ program, args }: Invocation): Invocation {
|
|
167
|
+
const next = (rest: string[]): Invocation | undefined => {
|
|
168
|
+
const i = rest.findIndex((a) => !a.startsWith("-"));
|
|
169
|
+
return i < 0 ? undefined : { program: rest[i]!.split("/").pop()!, args: rest.slice(i + 1) };
|
|
170
|
+
};
|
|
171
|
+
let inner: Invocation | undefined;
|
|
172
|
+
if (["npx", "bunx", "pnpx", "uvx"].includes(program)) inner = next(args);
|
|
173
|
+
else if (["uv", "poetry", "pipenv", "pdm", "hatch"].includes(program) && args[0] === "run") inner = next(args.slice(1));
|
|
174
|
+
else if ((program === "pnpm" || program === "yarn") && (args[0] === "exec" || args[0] === "dlx")) inner = next(args.slice(1));
|
|
175
|
+
else if (/^python[\d.]*$/.test(program) && args[0] === "-m" && args[1]) inner = { program: args[1], args: args.slice(2) };
|
|
176
|
+
else if (program === "xcrun" && args[0] && !args[0].startsWith("-")) inner = { program: args[0], args: args.slice(1) };
|
|
177
|
+
return inner ? unwrap(inner) : { program, args };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `cmd | tail -50` / `cmd | head -n 20`: still `cmd`'s format, only fewer lines of it. */
|
|
181
|
+
export function unpipe(command: string): string {
|
|
182
|
+
return command.replace(/\s*\|\s*(?:tail|head)(?:\s+(?:-n\s*\d+|-\d+|--lines=\d+))?\s*$/, "");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const INSTALL: Record<string, string[]> = {
|
|
186
|
+
npm: ["install", "i", "ci", "add", "update", "up"],
|
|
187
|
+
pnpm: ["install", "i", "add", "update", "up"],
|
|
188
|
+
yarn: ["install", "add", "upgrade"],
|
|
189
|
+
bun: ["install", "i", "add", "update"],
|
|
190
|
+
pip: ["install", "download"],
|
|
191
|
+
pip3: ["install", "download"],
|
|
192
|
+
pod: ["install", "update"],
|
|
193
|
+
bundle: ["install", "update"],
|
|
194
|
+
gem: ["install", "update"],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Linters whose output `compactLint` reads (with their subcommand where they have one). */
|
|
198
|
+
const LINTERS: Record<string, string[] | undefined> = {
|
|
199
|
+
eslint: undefined,
|
|
200
|
+
biome: ["lint", "check", "ci"],
|
|
201
|
+
ruff: ["check"],
|
|
202
|
+
flake8: undefined,
|
|
203
|
+
mypy: undefined,
|
|
204
|
+
pylint: undefined,
|
|
205
|
+
ktlint: undefined,
|
|
206
|
+
detekt: undefined,
|
|
207
|
+
"detekt-cli": undefined,
|
|
208
|
+
swiftlint: ["lint"],
|
|
209
|
+
shellcheck: undefined,
|
|
210
|
+
flutter: ["analyze"],
|
|
211
|
+
dart: ["analyze"],
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const LOG_COMMANDS = (program: string, args: string[]): boolean =>
|
|
215
|
+
((program === "docker" || program === "podman") && (args[0] === "logs" || (args[0] === "compose" && args[1] === "logs"))) ||
|
|
216
|
+
(program === "docker-compose" && args[0] === "logs") ||
|
|
217
|
+
(program === "kubectl" && args[0] === "logs") ||
|
|
218
|
+
["journalctl", "stern", "pidcat"].includes(program);
|
|
219
|
+
|
|
220
|
+
/** `adb [-s serial|-d|-e|-t id] <command>`: logcat, and what `adb shell` prints for packages, properties, processes, dumpsys and UI dumps. */
|
|
221
|
+
function pickAdb(all: string[]): ReturnType<typeof pick> {
|
|
222
|
+
let i = 0;
|
|
223
|
+
while (i < all.length && /^-[sHPLt]$|^-[de]$/.test(all[i]!)) i += /^-[de]$/.test(all[i]!) ? 1 : 2;
|
|
224
|
+
const [sub, ...rest] = all.slice(i);
|
|
225
|
+
// `adb shell "dumpsys battery"` is `adb shell dumpsys battery`.
|
|
226
|
+
const cmd = rest.join(" ").trim().split(/\s+/);
|
|
227
|
+
const line = cmd.join(" ");
|
|
228
|
+
if (sub === "logcat") return { kind: "logs", run: compactLogs };
|
|
229
|
+
if ((sub === "shell" || sub === "exec-out") && /^uiautomator dump \/dev\/(?:tty|stdout)\b/.test(line)) return { kind: "tree", run: compactUiDump };
|
|
230
|
+
if (sub !== "shell") return undefined;
|
|
231
|
+
if (/^(?:pm|cmd package) list packages\b/.test(line)) return cmd.includes("-f") ? { kind: "listing", run: compactPackagePaths } : { kind: "listing", run: compactPackages };
|
|
232
|
+
if (line === "getprop" || /^settings list (?:global|secure|system)$/.test(line)) return { kind: "listing", run: compactProperties };
|
|
233
|
+
if (/^ps(?: -A| -e)*$/.test(line) || /^top\b/.test(line)) return { kind: "listing", run: compactProcesses };
|
|
234
|
+
if (cmd[0] === "dumpsys" && cmd.length > 1) return { kind: "tree", run: compactDumpsys };
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Package managers, linters, test runners, logs, docker and Xcode. */
|
|
239
|
+
function pickTool({ program, args }: Invocation): ReturnType<typeof pick> {
|
|
240
|
+
const sub = args[0] ?? "";
|
|
241
|
+
const machine = args.some((a) => /json|sarif|junit|checkstyle|xml|github|gitlab|codeclimate|teamcity|tap\b/i.test(a));
|
|
242
|
+
if (machine) return undefined;
|
|
243
|
+
if (INSTALL[program]?.includes(sub) || (program === "yarn" && !sub) || (program === "uv" && (["add", "sync"].includes(sub) || (sub === "pip" && ["install", "sync"].includes(args[1] ?? "")))) || ((program === "flutter" || program === "dart") && sub === "pub" && args[1] === "get"))
|
|
244
|
+
return { kind: "install", run: compactInstall };
|
|
245
|
+
if (program in LINTERS) {
|
|
246
|
+
const subs = LINTERS[program];
|
|
247
|
+
// `ruff .` is `ruff check .` in older versions.
|
|
248
|
+
if (!subs || subs.includes(sub) || (program === "ruff" && sub.startsWith("-"))) return { kind: "lint", run: compactLint };
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
if (program === "pytest" || program === "py.test") return { kind: "tests", run: compactPytest };
|
|
252
|
+
if (program === "jest" || program === "vitest") return { kind: "tests", run: compactJest };
|
|
253
|
+
if (program === "mvn" || program === "mvnw" || program === "mvnd")
|
|
254
|
+
return args.some((a) => /dependency:tree$/.test(a)) ? { kind: "tree", run: compactMavenTree } : { kind: args.includes("test") ? "tests" : "build", run: compactMaven };
|
|
255
|
+
if (program === "javac") return { kind: "build", run: compactJavac };
|
|
256
|
+
if (LOG_COMMANDS(program, args)) return hasFlag(args, ["--follow"], ["f"]) ? undefined : { kind: "logs", run: compactLogs };
|
|
257
|
+
if (program === "adb") return pickAdb(args);
|
|
258
|
+
if (((program === "docker" || program === "podman") && (sub === "build" || (sub === "buildx" && args[1] === "build") || (sub === "compose" && args[1] === "build"))) || (program === "docker-compose" && sub === "build"))
|
|
259
|
+
return { kind: "build", run: compactDockerBuild };
|
|
260
|
+
if (program === "xcodebuild") return { kind: args.includes("test") ? "tests" : "build", run: compactXcode };
|
|
261
|
+
if (program === "swift" && (sub === "build" || sub === "test")) return { kind: sub === "test" ? "tests" : "build", run: compactXcode };
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function pick(command: string, isFile?: (path: string) => boolean): { kind: Kind; run: (out: string, l: Limits) => Squeezed | undefined } | undefined {
|
|
266
|
+
const parsed = parse(command);
|
|
267
|
+
if (!parsed) return undefined;
|
|
268
|
+
const inv = unwrap(parsed);
|
|
269
|
+
// Relative paths in the output are relative to the `cd`, which the file check does not know.
|
|
270
|
+
const local = /^\s*cd\s/.test(command) ? undefined : isFile;
|
|
271
|
+
const { program, args } = inv;
|
|
272
|
+
if (SEARCH.has(program)) {
|
|
273
|
+
const files = hasFlag(args, LIST_ONLY.long, program === "rg" ? LIST_ONLY.short : [...LIST_ONLY.short, "L"]);
|
|
274
|
+
if (files) return hasFlag(args, NOT_A_LIST.long, NOT_A_LIST.short) ? undefined : { kind: "paths", run: compactPaths };
|
|
275
|
+
if (hasFlag(args, NOT_PLAIN_SEARCH.long, NOT_PLAIN_SEARCH.short)) return undefined;
|
|
276
|
+
// ack: `-f` / `-g` list files; `-h` drops the path.
|
|
277
|
+
if (program === "ack" && hasFlag(args, ["--no-filename"], ["f", "g", "h"])) return hasFlag(args, [], ["f", "g"]) ? { kind: "paths", run: compactPaths } : undefined;
|
|
278
|
+
const plain = plainSearch(args, local);
|
|
279
|
+
return plain ?? { kind: "search", run: compactRg };
|
|
280
|
+
}
|
|
281
|
+
if (PATHS.has(program)) {
|
|
282
|
+
if (program === "find" ? !plainFind(args) : hasFlag(args, NOT_PLAIN_PATHS.long, NOT_PLAIN_PATHS.short)) return undefined;
|
|
283
|
+
return { kind: "paths", run: compactPaths };
|
|
284
|
+
}
|
|
285
|
+
if (program === "git") return pickGit(args);
|
|
286
|
+
if (program === "cargo") return pickCargo(args);
|
|
287
|
+
if (program === "tsc") return { kind: "build", run: compactTsc };
|
|
288
|
+
if (program === "gradle" || program === "gradlew") return args.some((a) => /(^|:)dependencies$/.test(a)) ? { kind: "tree", run: compactGradleDeps } : { kind: "build", run: compactGradle };
|
|
289
|
+
if (program === "go" && args[0] === "test") return hasFlag(args, ["-json", "--json"]) ? undefined : { kind: "tests", run: compactGoTest };
|
|
290
|
+
if (program === "bun" && args[0] === "test") return { kind: "tests", run: compactBunTest };
|
|
291
|
+
if (program === "tree" || ((program === "eza" || program === "exa" || program === "lsd") && hasFlag(args, ["--tree"], ["T"]))) return hasFlag(args, ["-J", "--json", "-H", "--html"]) ? undefined : { kind: "tree", run: compactTree };
|
|
292
|
+
if (program === "dust") return hasFlag(args, ["--output-json"], ["j"]) ? undefined : { kind: "listing", run: compactDust };
|
|
293
|
+
if (LISTING.has(program)) {
|
|
294
|
+
if (!hasFlag(args, ["--long"], ["l"])) return undefined;
|
|
295
|
+
// Other column sets (inode, blocks, group only, full time, numeric ids, octal) do not parse.
|
|
296
|
+
if (hasFlag(args, ["--inode", "--blocks", "--size", "--full-time", "--time-style", "--numeric-uid-gid", "--git", "--header", "--octal-permissions", "--total-size", "--context"], ["i", "s", "n", "g", "o", "H", "Z"])) return undefined;
|
|
297
|
+
return { kind: "listing", run: compactLsd };
|
|
298
|
+
}
|
|
299
|
+
return pickTool(inv);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** True when a native compactor takes care of `command`'s output (whatever the switches say). */
|
|
303
|
+
export const handles = (command: string): boolean => pick(unpipe(command)) !== undefined;
|
|
304
|
+
|
|
305
|
+
/** Kinds whose output is worth compacting when the command failed: there the failures (or the crash's stack trace) are the point. */
|
|
306
|
+
const FAILED_OK = new Set<Kind>(["tests", "build", "lint", "install", "logs"]);
|
|
307
|
+
|
|
308
|
+
/** Compacts the output of `command`, or returns undefined to leave it exactly as it is. */
|
|
309
|
+
export function compact(full: string, output: string, opts: Options = {}): Result | undefined {
|
|
310
|
+
const command = unpipe(full);
|
|
311
|
+
const { limits = DEFAULT_LIMITS, minChars = 400, maxRatio = 0.9, kinds = {}, failed = false, isFile } = opts;
|
|
312
|
+
if (output.length < minChars) return undefined;
|
|
313
|
+
const small = (sq: Squeezed | undefined): sq is Squeezed => sq !== undefined && sq.text.length <= output.length * maxRatio;
|
|
314
|
+
const allowed = (kind: Kind) => kinds[kind] !== false && (!failed || FAILED_OK.has(kind));
|
|
315
|
+
const done = (sq: Squeezed, kind: Kind): Result => ({ text: sq.text, kind, truncated: sq.truncated, before: output.length, after: sq.text.length });
|
|
316
|
+
|
|
317
|
+
const chosen = pick(command, isFile);
|
|
318
|
+
if (chosen && allowed(chosen.kind)) {
|
|
319
|
+
const sq = chosen.run(output, limits);
|
|
320
|
+
if (small(sq)) return done(sq, chosen.kind);
|
|
321
|
+
}
|
|
322
|
+
if (isContent(command)) return undefined;
|
|
323
|
+
for (const s of SNIFFERS) {
|
|
324
|
+
if (!allowed(s.kind)) continue;
|
|
325
|
+
const sq = s.run(output, limits);
|
|
326
|
+
if (small(sq)) return done(sq, s.kind);
|
|
327
|
+
}
|
|
328
|
+
// Nothing specific fits: still drop colour codes, progress frames and repeated lines.
|
|
329
|
+
if (failed || kinds.generic === false) return undefined;
|
|
330
|
+
const sq = compactGeneric(output);
|
|
331
|
+
return small(sq) ? done(sq, "generic") : undefined;
|
|
332
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persisted settings: <agent dir>/squeeze.json.
|
|
3
|
+
*
|
|
4
|
+
* A hand-edited file can hold anything, so `sanitizeConfig` keeps valid values and defaults the
|
|
5
|
+
* rest. A missing file is not a problem (defaults); a broken one is reported, never thrown.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { KINDS, type Kind } from "./compact.ts";
|
|
11
|
+
|
|
12
|
+
export interface Config {
|
|
13
|
+
/** Master switch: when false nothing is compacted. */
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
/** Show the savings in the status line. */
|
|
16
|
+
showStatus: boolean;
|
|
17
|
+
/** Which compactors run. */
|
|
18
|
+
kinds: Record<Kind, boolean>;
|
|
19
|
+
/** Rewrite commands to their `rtk` form (git, cargo, docker, …) when the rtk binary is installed. */
|
|
20
|
+
rtk: boolean;
|
|
21
|
+
/** Output shorter than this many characters is never touched. */
|
|
22
|
+
minChars: number;
|
|
23
|
+
/** Longest line kept whole in search results. */
|
|
24
|
+
lineChars: number;
|
|
25
|
+
/** Match lines kept per file. */
|
|
26
|
+
perGroup: number;
|
|
27
|
+
/** Lines in a whole compacted result. */
|
|
28
|
+
total: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_CONFIG: Config = {
|
|
32
|
+
enabled: true,
|
|
33
|
+
showStatus: true,
|
|
34
|
+
rtk: true,
|
|
35
|
+
kinds: Object.fromEntries(KINDS.map((k) => [k, true])) as Record<Kind, boolean>,
|
|
36
|
+
minChars: 400,
|
|
37
|
+
lineChars: 240,
|
|
38
|
+
perGroup: 15,
|
|
39
|
+
total: 250,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const clampInt = (v: unknown, min: number, max: number, fallback: number): number =>
|
|
43
|
+
typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : fallback;
|
|
44
|
+
|
|
45
|
+
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === "boolean" ? v : fallback);
|
|
46
|
+
|
|
47
|
+
export function sanitizeConfig(raw: unknown): Config {
|
|
48
|
+
const r = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>;
|
|
49
|
+
const k = (typeof r.kinds === "object" && r.kinds !== null ? r.kinds : {}) as Record<string, unknown>;
|
|
50
|
+
const d = DEFAULT_CONFIG;
|
|
51
|
+
return {
|
|
52
|
+
enabled: bool(r.enabled, d.enabled),
|
|
53
|
+
showStatus: bool(r.showStatus, d.showStatus),
|
|
54
|
+
rtk: bool(r.rtk, d.rtk),
|
|
55
|
+
kinds: Object.fromEntries(KINDS.map((kind) => [kind, bool(k[kind], d.kinds[kind])])) as Record<Kind, boolean>,
|
|
56
|
+
minChars: clampInt(r.minChars, 100, 1_000_000, d.minChars),
|
|
57
|
+
lineChars: clampInt(r.lineChars, 40, 4000, d.lineChars),
|
|
58
|
+
perGroup: clampInt(r.perGroup, 1, 500, d.perGroup),
|
|
59
|
+
total: clampInt(r.total, 20, 5000, d.total),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
|
|
64
|
+
export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
|
|
65
|
+
if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
|
|
66
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
|
|
67
|
+
return join(home, ".pi", "agent");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const configPath = (env: Record<string, string | undefined> = process.env, home = homedir()): string =>
|
|
71
|
+
join(resolveAgentDir(env, home), "squeeze.json");
|
|
72
|
+
|
|
73
|
+
/** The config, plus a `problem` when the file exists but cannot be used. */
|
|
74
|
+
export async function readConfig(path = configPath()): Promise<{ config: Config; problem?: string }> {
|
|
75
|
+
let raw: string;
|
|
76
|
+
try {
|
|
77
|
+
raw = await readFile(path, "utf8");
|
|
78
|
+
} catch (e) {
|
|
79
|
+
const code = (e as { code?: string } | null)?.code;
|
|
80
|
+
return code === "ENOENT" ? { config: sanitizeConfig({}) } : { config: sanitizeConfig({}), problem: `cannot read ${path} (${code ?? "error"})` };
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return { config: sanitizeConfig(JSON.parse(raw)) };
|
|
84
|
+
} catch {
|
|
85
|
+
return { config: sanitizeConfig({}), problem: `${path} is not valid JSON` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function loadConfig(path = configPath()): Promise<Config> {
|
|
90
|
+
return (await readConfig(path)).config;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Atomic: written to a temp file and renamed, so a crash or a second pi session never leaves half a file. */
|
|
94
|
+
export async function saveConfig(config: Config, path = configPath()): Promise<void> {
|
|
95
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
96
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
97
|
+
try {
|
|
98
|
+
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
99
|
+
await rename(tmp, path);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
await Promise.allSettled([rm(tmp, { force: true })]); // the original error matters more
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The compaction options this config stands for. */
|
|
107
|
+
export function optionsOf(c: Config) {
|
|
108
|
+
return { limits: { lineChars: c.lineChars, perGroup: c.perGroup, total: c.total }, minChars: c.minChars, kinds: c.kinds };
|
|
109
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git diff` output: the file headers shrink to one line each (`path (new, +12 −0)`), the
|
|
3
|
+
* `index` / mode / `---` / `+++` lines go, and every hunk keeps all of its changed lines but
|
|
4
|
+
* only one line of context on each side of a change. Long files are cut with a marker.
|
|
5
|
+
*
|
|
6
|
+
* The parse is exact: hunk lengths come from the `@@` headers, so any line that does not fit
|
|
7
|
+
* the unified-diff grammar makes the whole output pass through untouched.
|
|
8
|
+
*/
|
|
9
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
/** Lines kept per file: a new file is mostly boilerplate to the reader, a change is not. */
|
|
12
|
+
const NEW_FILE_LINES = 40;
|
|
13
|
+
const FILE_LINES = 120;
|
|
14
|
+
|
|
15
|
+
const HEADER = /^(index |new file mode |deleted file mode |old mode |new mode |similarity index |dissimilarity index |rename from |rename to |copy from |copy to )/;
|
|
16
|
+
const HUNK = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/;
|
|
17
|
+
|
|
18
|
+
type File = { path: string; status: string; added: number; removed: number; body: string[]; binary: boolean };
|
|
19
|
+
|
|
20
|
+
function parseFile(lines: string[], start: number): { file: File; next: number } | undefined {
|
|
21
|
+
const m = /^diff --git a\/(.+) b\/(.+)$/.exec(lines[start]!);
|
|
22
|
+
if (!m) return undefined;
|
|
23
|
+
const file: File = { path: m[2]!, status: "", added: 0, removed: 0, body: [], binary: false };
|
|
24
|
+
let i = start + 1;
|
|
25
|
+
for (; i < lines.length; i++) {
|
|
26
|
+
const l = lines[i]!;
|
|
27
|
+
if (l.startsWith("diff --git ") || l.startsWith("@@")) break;
|
|
28
|
+
if (l.startsWith("--- ") || l.startsWith("+++ ")) continue;
|
|
29
|
+
if (l.startsWith("new file mode")) file.status = "new";
|
|
30
|
+
else if (l.startsWith("deleted file mode")) file.status = "deleted";
|
|
31
|
+
else if (l.startsWith("rename to ")) file.status = `renamed from ${m[1]}`;
|
|
32
|
+
else if (/^Binary files .* differ$/.test(l)) file.binary = true;
|
|
33
|
+
else if (!HEADER.test(l)) return undefined;
|
|
34
|
+
}
|
|
35
|
+
while (i < lines.length && lines[i]!.startsWith("@@")) {
|
|
36
|
+
const h = HUNK.exec(lines[i]!);
|
|
37
|
+
if (!h) return undefined;
|
|
38
|
+
let old = h[1] === undefined ? 1 : Number(h[1]);
|
|
39
|
+
let neu = h[2] === undefined ? 1 : Number(h[2]);
|
|
40
|
+
const hunk = [lines[i]!];
|
|
41
|
+
i++;
|
|
42
|
+
while (old > 0 || neu > 0 || lines[i]?.startsWith("\\")) {
|
|
43
|
+
const l = lines[i];
|
|
44
|
+
if (l === undefined) return undefined;
|
|
45
|
+
if (l.startsWith("\\")) hunk.push(l);
|
|
46
|
+
else if (l.startsWith("+")) (neu--, file.added++, hunk.push(l));
|
|
47
|
+
else if (l.startsWith("-")) (old--, file.removed++, hunk.push(l));
|
|
48
|
+
else if (l.startsWith(" ") || l === "") (old--, neu--, hunk.push(l));
|
|
49
|
+
else return undefined;
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
file.body.push(...trimContext(hunk));
|
|
53
|
+
}
|
|
54
|
+
return { file, next: i };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One context line on each side of a change; the `@@` header stays. */
|
|
58
|
+
function trimContext(hunk: string[]): string[] {
|
|
59
|
+
const isChange = (l: string) => l.startsWith("+") || l.startsWith("-") || l.startsWith("\\");
|
|
60
|
+
const keep = hunk.map((l, i) => i === 0 || isChange(l) || (i > 1 && isChange(hunk[i - 1]!)) || (i + 1 < hunk.length && isChange(hunk[i + 1]!)));
|
|
61
|
+
return hunk.filter((_, i) => keep[i]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function compactDiff(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
65
|
+
const lines = output.split("\n");
|
|
66
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
67
|
+
if (!lines[0]?.startsWith("diff --git ")) return undefined;
|
|
68
|
+
|
|
69
|
+
const files: File[] = [];
|
|
70
|
+
for (let i = 0; i < lines.length; ) {
|
|
71
|
+
const r = parseFile(lines, i);
|
|
72
|
+
if (!r) return undefined;
|
|
73
|
+
files.push(r.file);
|
|
74
|
+
i = r.next;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const added = files.reduce((n, f) => n + f.added, 0);
|
|
78
|
+
const removed = files.reduce((n, f) => n + f.removed, 0);
|
|
79
|
+
const out = [`${files.length} file${files.length === 1 ? "" : "s"} changed, +${added} −${removed} (context trimmed to 1 line)`];
|
|
80
|
+
let cut = false;
|
|
81
|
+
for (const f of files) {
|
|
82
|
+
const tag = f.binary ? "binary" : f.status;
|
|
83
|
+
out.push(`${f.path} (${tag ? `${tag}, ` : ""}+${f.added} −${f.removed})`);
|
|
84
|
+
const max = f.status === "new" ? NEW_FILE_LINES : FILE_LINES;
|
|
85
|
+
for (const l of f.body.slice(0, max)) out.push(cutLine(l, limits.lineChars));
|
|
86
|
+
if (f.body.length > max) {
|
|
87
|
+
out.push(`… [+${f.body.length - max} more lines in ${f.path}]`);
|
|
88
|
+
cut = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const capped = capLines(out, limits.total * 2);
|
|
92
|
+
return { text: capped.text, truncated: cut || capped.truncated };
|
|
93
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `docker build` (BuildKit plain progress and the classic builder): bookkeeping steps
|
|
3
|
+
* (`[internal]` loads, context transfer, CACHED, DONE, layer pushes, image export) go;
|
|
4
|
+
* each build step keeps its header and what its command printed, without the `#N 0.123`
|
|
5
|
+
* prefix. Errors stay whole.
|
|
6
|
+
*/
|
|
7
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
8
|
+
import { stripAnsi } from "./generic.ts";
|
|
9
|
+
|
|
10
|
+
const STEP = /^#(\d+) (.*)$/;
|
|
11
|
+
/** BuildKit lines with nothing to say once the build is over. */
|
|
12
|
+
const QUIET = /^(building with |DONE [\d.]+s|CACHED|transferring |resolve |sha256:[\da-f]+ |extracting sha256:|exporting |writing image |naming to |unpacking to |preparing layers|load build definition|load \.dockerignore|load metadata for |\[auth\] )/;
|
|
13
|
+
/** Classic builder bookkeeping. */
|
|
14
|
+
const CLASSIC = /^( ---> |Removing intermediate container |Sending build context to Docker daemon|Successfully built [\da-f]+$)/;
|
|
15
|
+
|
|
16
|
+
export function compactDockerBuild(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
17
|
+
const lines = stripAnsi(output).split("\n");
|
|
18
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
19
|
+
const buildkit = lines.some((l) => /^#\d+ \[/.test(l));
|
|
20
|
+
const classic = lines.some((l) => /^Step \d+\/\d+ : /.test(l));
|
|
21
|
+
if (!buildkit && !classic) return undefined;
|
|
22
|
+
const internal = new Set<string>();
|
|
23
|
+
const cached = new Set<string>();
|
|
24
|
+
/** Where each step's header went, to mark it CACHED. */
|
|
25
|
+
const steps = new Map<string, number>();
|
|
26
|
+
const out: string[] = [];
|
|
27
|
+
for (const l of lines) {
|
|
28
|
+
const m = STEP.exec(l);
|
|
29
|
+
if (!m) {
|
|
30
|
+
if (CLASSIC.test(l) || (l === "" && (out.length === 0 || out.at(-1) === ""))) continue;
|
|
31
|
+
out.push(cutLine(l, limits.lineChars));
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const [, id, rest] = m as unknown as [string, string, string];
|
|
35
|
+
if (/^\[internal\]/.test(rest) || internal.has(id)) {
|
|
36
|
+
internal.add(id);
|
|
37
|
+
if (/^ERROR/.test(rest)) out.push(l);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (rest === "CACHED" && steps.has(id)) {
|
|
41
|
+
cached.add(id);
|
|
42
|
+
out[steps.get(id)!] += " (cached)";
|
|
43
|
+
}
|
|
44
|
+
if (/^\[/.test(rest)) {
|
|
45
|
+
// BuildKit repeats a step's header when its output resumes after another step's.
|
|
46
|
+
if (!steps.has(id)) steps.set(id, out.push(cutLine(`#${id} ${rest}`, limits.lineChars)) - 1);
|
|
47
|
+
} else if (/^ERROR|^error/.test(rest)) out.push(cutLine(l, limits.lineChars));
|
|
48
|
+
else if (!QUIET.test(rest)) {
|
|
49
|
+
// `#7 0.512 npm warn …`: the command's own output.
|
|
50
|
+
const printed = /^[\d.]+(?: (.*))?$/.exec(rest);
|
|
51
|
+
if (printed && !printed[1]?.trim()) continue;
|
|
52
|
+
out.push(cutLine(printed ? ` ${printed[1]}` : `#${id} ${rest}`, limits.lineChars));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const head = buildkit ? [`docker build: ${steps.size} steps${cached.size ? `, ${cached.size} cached` : ""}`] : [];
|
|
56
|
+
const capped = capLines([...head, ...out], limits.total);
|
|
57
|
+
return { text: capped.text, truncated: capped.truncated };
|
|
58
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dust` without the bar chart: the size, the tree and the share stay, the 35 columns of
|
|
3
|
+
* `█░` bars go.
|
|
4
|
+
*
|
|
5
|
+
* 26Mi ┌── lib 14%
|
|
6
|
+
*
|
|
7
|
+
* Strict: a line without the bar column makes it return undefined.
|
|
8
|
+
*/
|
|
9
|
+
import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
const ROW = /^(\s*\S+\s+.*?)\s*│[█▓▒░ ]+│\s*(\d+%)\s*$/;
|
|
12
|
+
|
|
13
|
+
export function compactDust(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
14
|
+
const lines = output.split("\n").map((l) => l.replace(/^\r\s*\r/, "").replace(/\r$/, ""));
|
|
15
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
16
|
+
if (lines.length < 3) return undefined;
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
for (const l of lines) {
|
|
19
|
+
const m = ROW.exec(l);
|
|
20
|
+
if (!m) return undefined;
|
|
21
|
+
out.push(`${m[1]} ${m[2]}`);
|
|
22
|
+
}
|
|
23
|
+
return capLines(out, limits.total);
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Shared limits and truncation helpers: every cut leaves a visible marker behind. */
|
|
2
|
+
|
|
3
|
+
export type Limits = {
|
|
4
|
+
/** Longest line kept whole; longer ones are cut with a note. */
|
|
5
|
+
lineChars: number;
|
|
6
|
+
/** Lines kept per file or group. */
|
|
7
|
+
perGroup: number;
|
|
8
|
+
/** Lines in the whole result. */
|
|
9
|
+
total: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_LIMITS: Limits = { lineChars: 240, perGroup: 15, total: 250 };
|
|
13
|
+
|
|
14
|
+
export type Squeezed = {
|
|
15
|
+
text: string;
|
|
16
|
+
/** True when content was left out (not merely reformatted): the full output is worth saving. */
|
|
17
|
+
truncated: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function cutLine(line: string, max: number): string {
|
|
21
|
+
if (line.length <= max) return line;
|
|
22
|
+
return `${line.slice(0, max)}… [+${line.length - max} chars]`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Caps `lines` at `total`, replacing the rest with a one-line marker. */
|
|
26
|
+
export function capLines(lines: string[], total: number): Squeezed {
|
|
27
|
+
if (lines.length <= total) return { text: lines.join("\n"), truncated: false };
|
|
28
|
+
const kept = lines.slice(0, total);
|
|
29
|
+
kept.push(`… [+${lines.length - total} more lines]`);
|
|
30
|
+
return { text: kept.join("\n"), truncated: true };
|
|
31
|
+
}
|