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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pytest: the session header, progress dots and PASSED lines go; failures, errors, the
|
|
3
|
+
* warnings summary, the short test summary and the result line stay.
|
|
4
|
+
*/
|
|
5
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
6
|
+
import { stripAnsi } from "./generic.ts";
|
|
7
|
+
|
|
8
|
+
const RESULT = /^=+ .*\b(passed|failed|errors?|skipped|deselected|no tests ran)\b.* in [\d.]+s.* =+$/;
|
|
9
|
+
const DROP = [
|
|
10
|
+
/^=+ test session starts =+$/,
|
|
11
|
+
/^platform \S+ -- Python /,
|
|
12
|
+
/^(rootdir|cachedir|plugins|configfile|testpaths|cov|hypothesis profile|benchmark|asyncio|django|metadata): /,
|
|
13
|
+
/^collect(ing|ed) \d+ items?/,
|
|
14
|
+
/^collecting \.\.\./,
|
|
15
|
+
/^-- Docs: https:\/\/docs\.pytest\.org/,
|
|
16
|
+
// `test_a.py ..F.s [ 50%]`: the failures are listed again below.
|
|
17
|
+
/^\S+\.py [.sSxXfFE]+\s*(\[\s*\d+%\])?$/,
|
|
18
|
+
/ PASSED\s*(\[\s*\d+%\])?$/,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function compactPytest(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
22
|
+
const lines = stripAnsi(output).split("\n");
|
|
23
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
24
|
+
const result = lines.findLast((l) => RESULT.test(l));
|
|
25
|
+
if (!result) return undefined;
|
|
26
|
+
const kept: string[] = [];
|
|
27
|
+
for (const l of lines) {
|
|
28
|
+
if (RESULT.test(l) || DROP.some((d) => d.test(l))) continue;
|
|
29
|
+
if (l === "" && (kept.length === 0 || kept.at(-1) === "")) continue;
|
|
30
|
+
// `-v`: `test_a.py::test_x FAILED [ 50%]` keeps its verdict, loses the percentage;
|
|
31
|
+
// a full-width `==== FAILURES ====` / `____ test_x ____` banner keeps two of each.
|
|
32
|
+
const line = l.replace(/\s+\[\s*\d+%\]$/, "").replace(/^([=_])\1{2,} (.*?) \1{3,}$/, "$1$1 $2 $1$1");
|
|
33
|
+
kept.push(cutLine(line, limits.lineChars));
|
|
34
|
+
}
|
|
35
|
+
while (kept.at(-1) === "") kept.pop();
|
|
36
|
+
return capLines([`pytest: ${result.replace(/^=+ | =+$/g, "")}`, ...kept], limits.total);
|
|
37
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rg -n` / `grep -n` output (`path:line:text`, context lines `path-line-text`) grouped by
|
|
3
|
+
* file, so each path is written once instead of on every match:
|
|
4
|
+
*
|
|
5
|
+
* src/a.ts (3)
|
|
6
|
+
* 12: const x = 1;
|
|
7
|
+
* 13- context
|
|
8
|
+
*
|
|
9
|
+
* Without line numbers (`rg pattern dir`, `path:text`) the path cannot be told from text that
|
|
10
|
+
* has a colon, so `compactRgPlain` only groups when every path is a file that exists.
|
|
11
|
+
*
|
|
12
|
+
* Strict: when any line is not in that format (`--heading`, `-c`, `-l`, `--json`) it returns
|
|
13
|
+
* undefined and the output passes through unchanged.
|
|
14
|
+
*/
|
|
15
|
+
import { cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
16
|
+
|
|
17
|
+
type Row = { path: string; line: number; sep: ":" | "-"; text: string };
|
|
18
|
+
|
|
19
|
+
const MATCH = /^(.+?):(\d+):(.*)$/;
|
|
20
|
+
|
|
21
|
+
export function parseRows(output: string): Row[] | undefined {
|
|
22
|
+
const lines = output.split("\n");
|
|
23
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
24
|
+
if (!lines.length) return undefined;
|
|
25
|
+
const rows: Row[] = [];
|
|
26
|
+
const files = new Set<string>();
|
|
27
|
+
const pending: { index: number; raw: string }[] = [];
|
|
28
|
+
for (const raw of lines) {
|
|
29
|
+
if (raw === "--") continue;
|
|
30
|
+
const m = MATCH.exec(raw);
|
|
31
|
+
if (m) {
|
|
32
|
+
if (m[1]!.includes(":")) return undefined; // `12:foo:3:bar` is single-file output, not a path
|
|
33
|
+
files.add(m[1]!);
|
|
34
|
+
rows.push({ path: m[1]!, line: Number(m[2]), sep: ":", text: m[3]! });
|
|
35
|
+
} else {
|
|
36
|
+
pending.push({ index: rows.length, raw });
|
|
37
|
+
rows.push(undefined as unknown as Row);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!files.size) return undefined;
|
|
41
|
+
// Context lines (`path-line-text`) only count for files that also had a real match.
|
|
42
|
+
for (const { index, raw } of pending) {
|
|
43
|
+
let row: Row | undefined;
|
|
44
|
+
for (const path of files) {
|
|
45
|
+
if (!raw.startsWith(`${path}-`)) continue;
|
|
46
|
+
const m = /^(\d+)-(.*)$/.exec(raw.slice(path.length + 1));
|
|
47
|
+
if (m) {
|
|
48
|
+
row = { path, line: Number(m[1]), sep: "-", text: m[2]! };
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!row) return undefined;
|
|
53
|
+
rows[index] = row;
|
|
54
|
+
}
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** At most this many distinct paths are looked up on disk. */
|
|
59
|
+
const MAX_LOOKUPS = 500;
|
|
60
|
+
|
|
61
|
+
/** `path:text` rows (no line numbers), or undefined unless every path is an existing file. */
|
|
62
|
+
export function parsePlainRows(output: string, isFile: (path: string) => boolean): Row[] | undefined {
|
|
63
|
+
const lines = output.split("\n");
|
|
64
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
65
|
+
if (!lines.length) return undefined;
|
|
66
|
+
const known = new Map<string, boolean>();
|
|
67
|
+
const rows: Row[] = [];
|
|
68
|
+
for (const raw of lines) {
|
|
69
|
+
const at = raw.indexOf(":");
|
|
70
|
+
if (at <= 0) return undefined;
|
|
71
|
+
const path = raw.slice(0, at);
|
|
72
|
+
let ok = known.get(path);
|
|
73
|
+
if (ok === undefined) {
|
|
74
|
+
if (known.size >= MAX_LOOKUPS) return undefined;
|
|
75
|
+
ok = isFile(path);
|
|
76
|
+
known.set(path, ok);
|
|
77
|
+
}
|
|
78
|
+
if (!ok) return undefined;
|
|
79
|
+
rows.push({ path, line: 0, sep: ":", text: raw.slice(at + 1) });
|
|
80
|
+
}
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function compactRg(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
85
|
+
const rows = parseRows(output);
|
|
86
|
+
return rows && render(rows, limits);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function compactRgPlain(output: string, limits: Limits, isFile: (path: string) => boolean): Squeezed | undefined {
|
|
90
|
+
const rows = parsePlainRows(output, isFile);
|
|
91
|
+
return rows && render(rows, limits);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function render(rows: Row[], limits: Limits): Squeezed {
|
|
95
|
+
const groups = new Map<string, Row[]>();
|
|
96
|
+
for (const r of rows) {
|
|
97
|
+
const g = groups.get(r.path);
|
|
98
|
+
if (g) g.push(r);
|
|
99
|
+
else groups.set(r.path, [r]);
|
|
100
|
+
}
|
|
101
|
+
const matchCount = rows.filter((r) => r.sep === ":").length;
|
|
102
|
+
const out: string[] = [`${matchCount} matches in ${groups.size} file${groups.size === 1 ? "" : "s"}`];
|
|
103
|
+
let truncated = false;
|
|
104
|
+
let hiddenFiles = 0;
|
|
105
|
+
for (const [path, list] of groups) {
|
|
106
|
+
if (out.length >= limits.total) {
|
|
107
|
+
hiddenFiles++;
|
|
108
|
+
truncated = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const matches = list.filter((r) => r.sep === ":").length;
|
|
112
|
+
out.push(`${path} (${matches})`);
|
|
113
|
+
let shown = 0;
|
|
114
|
+
for (const r of list) {
|
|
115
|
+
if (shown >= limits.perGroup) break;
|
|
116
|
+
out.push(` ${r.line ? `${r.line}${r.sep} ` : ""}${cutLine(r.text.trim(), limits.lineChars)}`);
|
|
117
|
+
shown++;
|
|
118
|
+
}
|
|
119
|
+
if (list.length > shown) {
|
|
120
|
+
out.push(` … [+${list.length - shown} more in this file]`);
|
|
121
|
+
truncated = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (hiddenFiles) out.push(`… [+${hiddenFiles} more files]`);
|
|
125
|
+
return { text: out.join("\n"), truncated };
|
|
126
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional rtk layer: commands that have no native compactor here (git, cargo, docker, …) are
|
|
3
|
+
* rewritten to their `rtk` form, which prints a compact output itself.
|
|
4
|
+
*
|
|
5
|
+
* `rtk rewrite <command>` is the single source of truth for what rtk can take over. Exit 0 and 3
|
|
6
|
+
* mean "rewritten" (3: the host should ask for approval, pi has no such step); 1 means no rewrite
|
|
7
|
+
* and 2 a deny. Never rewritten: what a native compactor handles (its output format is ours to
|
|
8
|
+
* parse) and file reads (`rtk read` may drop lines, and a read must stay exact).
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { handles } from "./compact.ts";
|
|
12
|
+
|
|
13
|
+
export type Run = (command: string) => { status: number | null; stdout: string; missing?: boolean };
|
|
14
|
+
|
|
15
|
+
const spawnRun: Run = (command) => {
|
|
16
|
+
const r = spawnSync("rtk", ["rewrite", command], { encoding: "utf8", timeout: 2000 });
|
|
17
|
+
return { status: r.status, stdout: r.stdout ?? "", missing: (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let run: Run = spawnRun;
|
|
21
|
+
/** For tests: replace the process runner (no argument restores it). */
|
|
22
|
+
export const useRunner = (r: Run = spawnRun): void => {
|
|
23
|
+
run = r;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const cache = new Map<string, string | undefined>();
|
|
27
|
+
const CACHE_MAX = 500;
|
|
28
|
+
let missing = false;
|
|
29
|
+
|
|
30
|
+
/** The command to run instead of `command`, or undefined to run it as written. */
|
|
31
|
+
export function rewrite(command: string): string | undefined {
|
|
32
|
+
if (missing || command.trim() === "" || handles(command)) return undefined;
|
|
33
|
+
if (cache.has(command)) return cache.get(command);
|
|
34
|
+
const r = run(command);
|
|
35
|
+
if (r.missing) {
|
|
36
|
+
missing = true;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const out = r.stdout.trim();
|
|
40
|
+
const rewritten = (r.status === 0 || r.status === 3) && out !== "" && out !== command && !/^rtk read\b/.test(out) ? out : undefined;
|
|
41
|
+
if (cache.size >= CACHE_MAX) cache.clear();
|
|
42
|
+
cache.set(command, rewritten);
|
|
43
|
+
return rewritten;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** For tests: forget what was learned (the cache and a missing binary). */
|
|
47
|
+
export function resetRtk(): void {
|
|
48
|
+
cache.clear();
|
|
49
|
+
missing = false;
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test runners other than cargo: `bun test` and `go test`. Passing tests are counted, not
|
|
3
|
+
* listed; failures (with their source frame and message), skips and the totals stay.
|
|
4
|
+
*/
|
|
5
|
+
import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
6
|
+
|
|
7
|
+
/** `(pass) name [1ms]` lines go, and a file header left with nothing under it goes too. */
|
|
8
|
+
export function compactBunTest(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
9
|
+
const lines = output.split("\n");
|
|
10
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
11
|
+
if (!lines.some((l) => /^Ran \d+ tests? across \d+ files?\./.test(l))) return undefined;
|
|
12
|
+
const kept: string[] = [];
|
|
13
|
+
for (const l of lines) {
|
|
14
|
+
if (/^\(pass\) /.test(l)) continue;
|
|
15
|
+
// The file header (`path.test.ts:`) is only worth keeping when something stayed under it.
|
|
16
|
+
if (l === "" && (kept.length === 0 || kept.at(-1) === "")) continue;
|
|
17
|
+
kept.push(l);
|
|
18
|
+
}
|
|
19
|
+
const out: string[] = [];
|
|
20
|
+
for (let i = 0; i < kept.length; i++) {
|
|
21
|
+
const header = /^\S+\.(test|spec)\.\w+:$/.test(kept[i]!);
|
|
22
|
+
const empty = kept[i + 1] === undefined || kept[i + 1] === "" || /^\S+\.(test|spec)\.\w+:$/.test(kept[i + 1]!);
|
|
23
|
+
if (header && empty) continue;
|
|
24
|
+
out.push(kept[i]!);
|
|
25
|
+
}
|
|
26
|
+
const tidy = out.filter((l, i) => !(l === "" && (i === 0 || out[i - 1] === "")));
|
|
27
|
+
return capLines(tidy, limits.total);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `ok pkg 0.3s`, `? pkg [no test files]` and `PASS` collapse into one count line; `=== RUN` and `--- PASS` go. */
|
|
31
|
+
export function compactGoTest(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
32
|
+
const lines = output.split("\n");
|
|
33
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
34
|
+
let ok = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
let none = 0;
|
|
37
|
+
const kept: string[] = [];
|
|
38
|
+
for (const l of lines) {
|
|
39
|
+
if (/^ok\s+\S+/.test(l)) ok++;
|
|
40
|
+
else if (/^\?\s+\S+\s+\[no test files\]$/.test(l)) none++;
|
|
41
|
+
else if (/^(FAIL)\s+\S+(\s|$)/.test(l) && /\s\d/.test(l)) {
|
|
42
|
+
failed++;
|
|
43
|
+
kept.push(l);
|
|
44
|
+
} else if (/^(=== (RUN|PAUSE|CONT|NAME)|\s*--- PASS:|PASS$)/.test(l)) continue;
|
|
45
|
+
else kept.push(l);
|
|
46
|
+
}
|
|
47
|
+
if (!ok && !failed && !none) return undefined;
|
|
48
|
+
const parts = [`${ok} ok`, `${failed} failed`, ...(none ? [`${none} without tests`] : [])];
|
|
49
|
+
return capLines([`go test: ${parts.join(", ")}`, ...kept], limits.total);
|
|
50
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cargo build` / `check` / `clippy` output. Errors keep their full block (they are why you
|
|
3
|
+
* ran the command); warnings shrink to one line each, grouped by lint:
|
|
4
|
+
*
|
|
5
|
+
* clippy::needless_return (2)
|
|
6
|
+
* src/lib.rs:2:55 unneeded `return` statement
|
|
7
|
+
*
|
|
8
|
+
* Build progress (`Compiling`, `Checking`, `Finished`) goes, an identical warning reported
|
|
9
|
+
* for several targets is listed once, and the summary lines stay.
|
|
10
|
+
*/
|
|
11
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
12
|
+
|
|
13
|
+
const PROGRESS = /^\s+(Compiling|Checking|Downloaded|Downloading|Updating|Locking|Fresh|Blocking|Adding|Building|Documenting|Finished|Fixed|Generated) /;
|
|
14
|
+
const HEAD = /^(warning|error)(?:\[(E\d+)\])?: (.*)$/;
|
|
15
|
+
const WHERE = /^\s*--> (.+?:\d+:\d+)$/;
|
|
16
|
+
const ERROR_LINES = 40;
|
|
17
|
+
|
|
18
|
+
type Diag = { level: "warning" | "error"; code: string; message: string; where: string; lint: string; help: string; block: string[] };
|
|
19
|
+
|
|
20
|
+
function lintOf(block: string[]): string {
|
|
21
|
+
for (const l of block) {
|
|
22
|
+
const a = /#\[(?:warn|deny|forbid)\(([\w:]+)\)\]/.exec(l);
|
|
23
|
+
if (a) return a[1]!;
|
|
24
|
+
const b = /rust-clippy\/[^#]*#(\w+)$/.exec(l);
|
|
25
|
+
if (b) return `clippy::${b[1]}`;
|
|
26
|
+
}
|
|
27
|
+
return "other";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseBlock(block: string[]): Diag | undefined {
|
|
31
|
+
const h = HEAD.exec(block[0] ?? "");
|
|
32
|
+
const w = block.map((l) => WHERE.exec(l)).find(Boolean);
|
|
33
|
+
if (!h || !w) return undefined;
|
|
34
|
+
const help = block.map((l) => /\^+ help: (.*)$/.exec(l)).find(Boolean)?.[1] ?? "";
|
|
35
|
+
return { level: h[1] as Diag["level"], code: h[2] ?? "", message: h[3]!, where: w[1]!, lint: lintOf(block), help, block };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function compactRustc(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
39
|
+
const lines = output.split("\n");
|
|
40
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
41
|
+
const blocks: string[][] = [[]];
|
|
42
|
+
for (const l of lines) {
|
|
43
|
+
if (PROGRESS.test(l)) continue;
|
|
44
|
+
if (l === "") blocks.push([]);
|
|
45
|
+
else blocks.at(-1)!.push(l);
|
|
46
|
+
}
|
|
47
|
+
const diags: Diag[] = [];
|
|
48
|
+
const other: string[] = [];
|
|
49
|
+
let seen = false;
|
|
50
|
+
const order: (Diag | string)[] = [];
|
|
51
|
+
for (const b of blocks) {
|
|
52
|
+
if (!b.length) continue;
|
|
53
|
+
const d = parseBlock(b);
|
|
54
|
+
if (d) {
|
|
55
|
+
seen = true;
|
|
56
|
+
diags.push(d);
|
|
57
|
+
order.push(d);
|
|
58
|
+
} else {
|
|
59
|
+
other.push(...b);
|
|
60
|
+
order.push(b.join("\n"));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!seen) return undefined;
|
|
64
|
+
|
|
65
|
+
const errors = diags.filter((d) => d.level === "error");
|
|
66
|
+
const warnings = new Map<string, Map<string, string>>();
|
|
67
|
+
for (const d of diags) {
|
|
68
|
+
if (d.level !== "warning") continue;
|
|
69
|
+
const group = warnings.get(d.lint) ?? new Map<string, string>();
|
|
70
|
+
warnings.set(d.lint, group);
|
|
71
|
+
group.set(`${d.where} ${d.message}`, cutLine(` ${d.where} ${d.message}${d.help ? ` → ${d.help}` : ""}`, limits.lineChars));
|
|
72
|
+
}
|
|
73
|
+
const out: string[] = [];
|
|
74
|
+
const count = [...warnings.values()].reduce((n, g) => n + g.size, 0);
|
|
75
|
+
out.push(`${errors.length} error${errors.length === 1 ? "" : "s"}, ${count} warning${count === 1 ? "" : "s"}`);
|
|
76
|
+
let cut = false;
|
|
77
|
+
for (const e of errors) {
|
|
78
|
+
out.push("", ...e.block.slice(0, ERROR_LINES).map((l) => cutLine(l, limits.lineChars)));
|
|
79
|
+
if (e.block.length > ERROR_LINES) {
|
|
80
|
+
out.push(` … [+${e.block.length - ERROR_LINES} more lines]`);
|
|
81
|
+
cut = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (errors.length && warnings.size) out.push("");
|
|
85
|
+
for (const [lint, group] of warnings) {
|
|
86
|
+
out.push(`${lint} (${group.size})`);
|
|
87
|
+
const entries = [...group.values()];
|
|
88
|
+
out.push(...entries.slice(0, limits.perGroup));
|
|
89
|
+
if (entries.length > limits.perGroup) {
|
|
90
|
+
out.push(` … [+${entries.length - limits.perGroup} more]`);
|
|
91
|
+
cut = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Whatever is not a diagnostic (summaries, `error: could not compile`, notes) stays as it was.
|
|
95
|
+
const rest = other.filter((l) => l !== "");
|
|
96
|
+
if (rest.length) out.push("", ...rest);
|
|
97
|
+
const capped = capLines(out, limits.total);
|
|
98
|
+
return { text: capped.text, truncated: cut || capped.truncated };
|
|
99
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Writes the untouched output to a temp file so a truncated result can be recovered. */
|
|
6
|
+
export function saveFull(text: string, dir = join(tmpdir(), "pi-squeeze")): string {
|
|
7
|
+
mkdirSync(dir, { recursive: true });
|
|
8
|
+
const path = join(dir, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.txt`);
|
|
9
|
+
writeFileSync(path, text);
|
|
10
|
+
return path;
|
|
11
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { KINDS, type Kind, type Result } from "./compact.ts";
|
|
2
|
+
|
|
3
|
+
export type Stats = { calls: number; before: number; after: number; byKind: Record<Kind, { calls: number; saved: number }> };
|
|
4
|
+
|
|
5
|
+
export function emptyStats(): Stats {
|
|
6
|
+
return { calls: 0, before: 0, after: 0, byKind: Object.fromEntries(KINDS.map((k) => [k, { calls: 0, saved: 0 }])) as Stats["byKind"] };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function record(stats: Stats, r: Result): void {
|
|
10
|
+
stats.calls++;
|
|
11
|
+
stats.before += r.before;
|
|
12
|
+
stats.after += r.after;
|
|
13
|
+
stats.byKind[r.kind].calls++;
|
|
14
|
+
stats.byKind[r.kind].saved += r.before - r.after;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Characters saved so far. */
|
|
18
|
+
export const saved = (s: Stats): number => s.before - s.after;
|
|
19
|
+
|
|
20
|
+
/** Rough token estimate: 4 characters per token. */
|
|
21
|
+
export const tokens = (chars: number): number => Math.round(chars / 4);
|
|
22
|
+
|
|
23
|
+
export function summary(stats: Stats): string {
|
|
24
|
+
if (!stats.calls) return "squeeze: nothing compacted yet in this session";
|
|
25
|
+
const pct = Math.round((saved(stats) / stats.before) * 100);
|
|
26
|
+
const kinds = (Object.entries(stats.byKind) as [Kind, { calls: number; saved: number }][])
|
|
27
|
+
.filter(([, v]) => v.calls)
|
|
28
|
+
.map(([k, v]) => `${k} ${v.calls}× (~${tokens(v.saved)} tokens)`)
|
|
29
|
+
.join(", ");
|
|
30
|
+
return `squeeze: ${stats.calls} outputs compacted, ~${tokens(saved(stats))} tokens saved (${pct}%): ${kinds}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cargo test` output: the build progress, the `test … ok` lines and the `running N tests`
|
|
3
|
+
* headers go; warnings, failures, panics, `Running …` and `test result` lines stay. A one-line
|
|
4
|
+
* summary of the totals comes first.
|
|
5
|
+
*/
|
|
6
|
+
import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
7
|
+
|
|
8
|
+
const PROGRESS = /^\s+(Compiling|Downloaded|Downloading|Updating|Locking|Fresh|Blocking|Adding|Packaging|Checking|Building|Finished) /;
|
|
9
|
+
const OK = /^test .* \.\.\. (ok|ignored)(, .*)?$/;
|
|
10
|
+
const RUNNING_N = /^running \d+ tests?$/;
|
|
11
|
+
const RESULT = /^test result: (ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored;/;
|
|
12
|
+
|
|
13
|
+
export function compactTests(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
14
|
+
const lines = output.split("\n");
|
|
15
|
+
let passed = 0;
|
|
16
|
+
let failed = 0;
|
|
17
|
+
let ignored = 0;
|
|
18
|
+
let binaries = 0;
|
|
19
|
+
let sawProgress = false;
|
|
20
|
+
let inFailures = false;
|
|
21
|
+
const kept: string[] = [];
|
|
22
|
+
for (const l of lines) {
|
|
23
|
+
const r = RESULT.exec(l);
|
|
24
|
+
if (r) {
|
|
25
|
+
binaries++;
|
|
26
|
+
passed += Number(r[2]);
|
|
27
|
+
failed += Number(r[3]);
|
|
28
|
+
ignored += Number(r[4]);
|
|
29
|
+
}
|
|
30
|
+
if (l === "failures:") inFailures = true;
|
|
31
|
+
if (r) inFailures = false;
|
|
32
|
+
if (PROGRESS.test(l)) {
|
|
33
|
+
sawProgress = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (OK.test(l) || RUNNING_N.test(l)) continue;
|
|
37
|
+
const run = /^(\s+Running .*?) \(target\/.*\)$/.exec(l);
|
|
38
|
+
const text = run ? run[1]! : l;
|
|
39
|
+
// Blank lines only separate blocks: they stay only inside the failure report, where they separate panics.
|
|
40
|
+
if (text === "" && !inFailures) continue;
|
|
41
|
+
kept.push(text);
|
|
42
|
+
}
|
|
43
|
+
if (binaries === 0 && !sawProgress) return undefined;
|
|
44
|
+
const head = binaries ? [`cargo test: ${passed} passed, ${failed} failed, ${ignored} ignored (${binaries} test binar${binaries === 1 ? "y" : "ies"})`] : [];
|
|
45
|
+
return capLines([...head, ...kept], limits.total);
|
|
46
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree output (`cargo tree`, `tree`, `eza --tree`, `gradle dependencies`) cut below a depth, with a marker that says
|
|
3
|
+
* how many lines were left out under each cut:
|
|
4
|
+
*
|
|
5
|
+
* ├── ratatui v0.30.2
|
|
6
|
+
* │ ├── instability v0.3.14 (proc-macro)
|
|
7
|
+
* │ │ ├── darling v0.24.1
|
|
8
|
+
* … [+15 lines deeper]
|
|
9
|
+
*
|
|
10
|
+
* A cut of a single line is not worth a marker and is left in.
|
|
11
|
+
*/
|
|
12
|
+
import { cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
13
|
+
|
|
14
|
+
/** Box-drawing trees indent by 4; gradle's ASCII one (`+--- `, `| `) by 5, Maven's by 3. */
|
|
15
|
+
const STYLES = [
|
|
16
|
+
{ prefix: /^((?:│ | )*)/, branch: /^(?:│ | )*(?:├── |└── )/, unit: 4 },
|
|
17
|
+
{ prefix: /^((?:\| | {5})*)/, branch: /^(?:\| | {5})*(?:\+--- |\\--- )/, unit: 5 },
|
|
18
|
+
// Maven's `dependency:tree`: `+- `, `\- `, `| `.
|
|
19
|
+
{ prefix: /^((?:\| | {3})*)/, branch: /^(?:\| | {3})*(?:\+- |\\- )/, unit: 3 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** Levels kept below the root. */
|
|
23
|
+
const DEPTH = 3;
|
|
24
|
+
|
|
25
|
+
export function compactTree(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
26
|
+
const lines = output.split("\n");
|
|
27
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
28
|
+
const style = STYLES.find((s) => lines.some((l) => s.branch.test(l)));
|
|
29
|
+
if (lines.length < 5 || !style) return undefined;
|
|
30
|
+
const { prefix, branch, unit } = style;
|
|
31
|
+
const out: string[] = [];
|
|
32
|
+
let hidden: string[] = [];
|
|
33
|
+
const flush = () => {
|
|
34
|
+
if (hidden.length === 1) out.push(cutLine(hidden[0]!, limits.lineChars));
|
|
35
|
+
else if (hidden.length) out.push(`${" ".repeat(DEPTH * unit)}… [+${hidden.length} lines deeper]`);
|
|
36
|
+
hidden = [];
|
|
37
|
+
};
|
|
38
|
+
for (const l of lines) {
|
|
39
|
+
const units = prefix.exec(l)![1]!.length / unit;
|
|
40
|
+
const depth = branch.test(l) || units > 0 ? units + 1 : 0;
|
|
41
|
+
if (depth > DEPTH) {
|
|
42
|
+
hidden.push(l);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
flush();
|
|
46
|
+
out.push(cutLine(l, limits.lineChars));
|
|
47
|
+
}
|
|
48
|
+
flush();
|
|
49
|
+
if (out.length >= lines.length) return undefined;
|
|
50
|
+
return { text: out.join("\n"), truncated: true };
|
|
51
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tsc` (plain, not `--pretty`) errors grouped by file:
|
|
3
|
+
*
|
|
4
|
+
* a.ts (8)
|
|
5
|
+
* 1:7 TS2322 Type 'string' is not assignable to type 'number'.
|
|
6
|
+
*
|
|
7
|
+
* Continuation lines (indented) stay with their error. The `Found N errors` footer stays.
|
|
8
|
+
*/
|
|
9
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
const ERROR = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/;
|
|
12
|
+
const FOOTER = /^Found \d+ errors?( in \d+ files?)?\.?$|^Errors +Files$|^ +\d+ +\S.*$/;
|
|
13
|
+
|
|
14
|
+
export function compactTsc(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
15
|
+
const lines = output.split("\n");
|
|
16
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
17
|
+
const files = new Map<string, string[]>();
|
|
18
|
+
const tail: string[] = [];
|
|
19
|
+
let current: string[] | undefined;
|
|
20
|
+
let errors = 0;
|
|
21
|
+
for (const l of lines) {
|
|
22
|
+
const m = ERROR.exec(l);
|
|
23
|
+
if (m) {
|
|
24
|
+
errors++;
|
|
25
|
+
current = files.get(m[1]!) ?? [];
|
|
26
|
+
files.set(m[1]!, current);
|
|
27
|
+
current.push(cutLine(` ${m[2]}:${m[3]} ${m[4] === "warning" ? "warning " : ""}${m[5]} ${m[6]}`, limits.lineChars));
|
|
28
|
+
} else if (/^\s+\S/.test(l) && current && !tail.length) current.push(cutLine(` ${l.trim()}`, limits.lineChars));
|
|
29
|
+
else if (l === "" || FOOTER.test(l)) {
|
|
30
|
+
tail.push(l);
|
|
31
|
+
current = undefined;
|
|
32
|
+
} else return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (!errors) return undefined;
|
|
35
|
+
const out = [`tsc: ${errors} error${errors === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"}`];
|
|
36
|
+
let cut = false;
|
|
37
|
+
for (const [file, entries] of files) {
|
|
38
|
+
out.push(`${file} (${entries.filter((e) => !e.startsWith(" ")).length})`);
|
|
39
|
+
out.push(...entries.slice(0, limits.perGroup * 2));
|
|
40
|
+
if (entries.length > limits.perGroup * 2) {
|
|
41
|
+
out.push(` … [+${entries.length - limits.perGroup * 2} more lines]`);
|
|
42
|
+
cut = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const footer = tail.filter((l) => l !== "");
|
|
46
|
+
if (footer.length) out.push("", ...footer);
|
|
47
|
+
const capped = capLines(out, limits.total);
|
|
48
|
+
return { text: capped.text, truncated: cut || capped.truncated };
|
|
49
|
+
}
|