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,29 @@
|
|
|
1
|
+
/** File checks for the compactors that need to know what is on disk. */
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** A check whether `path`, relative to `cwd`, is an existing file. Without a cwd nothing is. */
|
|
6
|
+
export function fileIn(cwd: string | undefined): (path: string) => boolean {
|
|
7
|
+
if (!cwd) return () => false;
|
|
8
|
+
return (path) => {
|
|
9
|
+
try {
|
|
10
|
+
return statSync(resolve(cwd, path)).isFile();
|
|
11
|
+
} catch {
|
|
12
|
+
return false; // missing, not a directory on the way, a NUL byte, no permission
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Full outputs larger than this are not read back: compacting them would cost more than it saves. */
|
|
18
|
+
export const FULL_OUTPUT_MAX_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/** The full output pi saved for a bash run it truncated, or undefined when there is none to read. */
|
|
21
|
+
export function readFullOutput(path: unknown, maxBytes = FULL_OUTPUT_MAX_BYTES): string | undefined {
|
|
22
|
+
if (typeof path !== "string") return undefined;
|
|
23
|
+
try {
|
|
24
|
+
if (statSync(path).size > maxBytes) return undefined;
|
|
25
|
+
return readFileSync(path, "utf8");
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined; // gone already, unreadable
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clean-up for any command output, without leaving anything out that a reader would miss:
|
|
3
|
+
* ANSI colour codes go, progress bars redrawn with `\r` keep their last frame, a line
|
|
4
|
+
* repeated 4+ times in a row becomes `line [×N]`, and 3+ blank lines become one.
|
|
5
|
+
*
|
|
6
|
+
* Never used on commands that print file content (cat, head, jq, curl…): there, every
|
|
7
|
+
* byte is the point.
|
|
8
|
+
*/
|
|
9
|
+
import type { Limits, Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
// CSI sequences (colours, cursor moves) and OSC sequences (titles, hyperlinks).
|
|
12
|
+
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
13
|
+
|
|
14
|
+
/** Commands whose output is content, not a report. Checked anywhere in the command line. */
|
|
15
|
+
const CONTENT = /(?:^|[\s|;&(])(cat|head|tail|sed|awk|jq|yq|bat|less|more|curl|wget|base64|xxd|od|hexdump|tee|printf|echo|diff|cmp|sort|uniq|cut|tr|wc)(?=\s|$)/;
|
|
16
|
+
|
|
17
|
+
export const isContent = (command: string): boolean => CONTENT.test(command);
|
|
18
|
+
|
|
19
|
+
/** Colour codes and terminal hyperlinks out; the text they wrapped stays. */
|
|
20
|
+
export const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
|
21
|
+
|
|
22
|
+
export function compactGeneric(output: string, _limits?: Limits): Squeezed | undefined {
|
|
23
|
+
const lines = stripAnsi(output).split("\n").map((l) => {
|
|
24
|
+
const frame = l.replace(/\r+$/, "");
|
|
25
|
+
return (frame.includes("\r") ? frame.slice(frame.lastIndexOf("\r") + 1) : frame).trimEnd();
|
|
26
|
+
});
|
|
27
|
+
const out: string[] = [];
|
|
28
|
+
for (let i = 0; i < lines.length; ) {
|
|
29
|
+
let j = i;
|
|
30
|
+
while (j < lines.length && lines[j] === lines[i]) j++;
|
|
31
|
+
const run = j - i;
|
|
32
|
+
if (lines[i] === "") {
|
|
33
|
+
const keep = run >= 3 ? 1 : run;
|
|
34
|
+
for (let k = 0; k < keep; k++) out.push("");
|
|
35
|
+
} else if (run >= 4) out.push(`${lines[i]} [×${run}]`);
|
|
36
|
+
else for (let k = 0; k < run; k++) out.push(lines[i]!);
|
|
37
|
+
i = j;
|
|
38
|
+
}
|
|
39
|
+
return { text: out.join("\n"), truncated: false };
|
|
40
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git log` and `git show` in their default format, one line per commit:
|
|
3
|
+
*
|
|
4
|
+
* 0a845ae 2026-11-01 Sorin Albu-Irimies docs(readme): add the badges
|
|
5
|
+
*
|
|
6
|
+
* The message body stays (indented, at most 8 lines per commit). Strict: anything that is
|
|
7
|
+
* not the default format (`--stat`, `-p` and `--oneline` are handled elsewhere or skipped)
|
|
8
|
+
* returns undefined.
|
|
9
|
+
*/
|
|
10
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
11
|
+
import { compactDiff } from "./diff.ts";
|
|
12
|
+
|
|
13
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
14
|
+
const BODY_LINES = 8;
|
|
15
|
+
|
|
16
|
+
type Commit = { hash: string; author: string; date: string; message: string[] };
|
|
17
|
+
|
|
18
|
+
/** Parses the commit blocks in `lines`; returns the commits and the index of the first line after them. */
|
|
19
|
+
function parseCommits(lines: string[]): { commits: Commit[]; next: number } | undefined {
|
|
20
|
+
const commits: Commit[] = [];
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < lines.length) {
|
|
23
|
+
const m = /^commit ([0-9a-f]{7,64})(?: \(.*\))?$/.exec(lines[i]!);
|
|
24
|
+
if (!m) break;
|
|
25
|
+
i++;
|
|
26
|
+
if (lines[i]?.startsWith("Merge: ")) i++;
|
|
27
|
+
const a = /^Author: (.*?)(?: <[^>]*>)?$/.exec(lines[i++] ?? "");
|
|
28
|
+
const d = /^Date: +\w{3} (\w{3}) +(\d+) [\d:]+ (\d{4}) [+-]\d{4}$/.exec(lines[i++] ?? "");
|
|
29
|
+
if (!a || !d || lines[i++] !== "") return undefined;
|
|
30
|
+
const month = MONTHS.indexOf(d[1]!) + 1;
|
|
31
|
+
if (!month) return undefined;
|
|
32
|
+
const message: string[] = [];
|
|
33
|
+
for (; i < lines.length; i++) {
|
|
34
|
+
if (lines[i]!.startsWith(" ")) message.push(lines[i]!.slice(4));
|
|
35
|
+
else if (lines[i] === "" && lines.slice(i).find((l) => l !== "")?.startsWith(" ")) message.push("");
|
|
36
|
+
else break;
|
|
37
|
+
}
|
|
38
|
+
if (lines[i] === "") i++;
|
|
39
|
+
commits.push({ hash: m[1]!.slice(0, 7), author: a[1]!, date: `${d[3]}-${String(month).padStart(2, "0")}-${d[2]!.padStart(2, "0")}`, message });
|
|
40
|
+
}
|
|
41
|
+
return commits.length ? { commits, next: i } : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function render(commits: Commit[], limits: Limits): string[] {
|
|
45
|
+
// One author for the whole list: say so once instead of on every line.
|
|
46
|
+
const solo = commits.length > 1 && commits.every((c) => c.author === commits[0]!.author);
|
|
47
|
+
const out: string[] = solo ? [`by ${commits[0]!.author}`] : [];
|
|
48
|
+
for (const c of commits) {
|
|
49
|
+
const [subject = "", ...rest] = c.message;
|
|
50
|
+
out.push(cutLine(`${c.hash} ${c.date}${solo ? "" : ` ${c.author}`} ${subject}`, limits.lineChars));
|
|
51
|
+
const body = rest.filter((l, i) => !(l === "" && (i === 0 || rest[i - 1] === "")));
|
|
52
|
+
for (const l of body.slice(0, BODY_LINES)) out.push(l ? cutLine(` ${l}`, limits.lineChars) : "");
|
|
53
|
+
if (body.length > BODY_LINES) out.push(` … [+${body.length - BODY_LINES} more lines]`);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function compactLog(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
59
|
+
const lines = output.split("\n");
|
|
60
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
61
|
+
const parsed = parseCommits(lines);
|
|
62
|
+
if (!parsed || parsed.next !== lines.length) return undefined;
|
|
63
|
+
return capLines(render(parsed.commits, limits), limits.total);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `git show` of a commit: the commit line above, then the (compacted) diff. */
|
|
67
|
+
export function compactShow(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
68
|
+
const lines = output.split("\n");
|
|
69
|
+
const at = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
70
|
+
if (at < 0) return compactLog(output, limits);
|
|
71
|
+
const parsed = parseCommits(lines.slice(0, at));
|
|
72
|
+
const diff = compactDiff(lines.slice(at).join("\n"), limits);
|
|
73
|
+
if (!parsed || parsed.next !== at || !diff) return undefined;
|
|
74
|
+
return { text: `${render(parsed.commits, limits).join("\n")}\n${diff.text}`, truncated: diff.truncated };
|
|
75
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git status` (long form) without the hints:
|
|
3
|
+
*
|
|
4
|
+
* ## main...origin/main [ahead 2]
|
|
5
|
+
* staged:
|
|
6
|
+
* M src/a.ts
|
|
7
|
+
* untracked:
|
|
8
|
+
* notes.txt
|
|
9
|
+
*
|
|
10
|
+
* Strict: a line it does not know makes it return undefined.
|
|
11
|
+
*/
|
|
12
|
+
import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
13
|
+
|
|
14
|
+
const SECTIONS: Record<string, string> = {
|
|
15
|
+
"Changes to be committed:": "staged",
|
|
16
|
+
"Changes not staged for commit:": "unstaged",
|
|
17
|
+
"Untracked files:": "untracked",
|
|
18
|
+
"Unmerged paths:": "unmerged",
|
|
19
|
+
"Ignored files:": "ignored",
|
|
20
|
+
};
|
|
21
|
+
const CODES: Record<string, string> = { modified: "M", "new file": "A", deleted: "D", renamed: "R", copied: "C", "both modified": "UU", "both added": "AA", "deleted by us": "DU", "deleted by them": "UD", "added by us": "AU", "added by them": "UA", "both deleted": "DD", "typechange": "T" };
|
|
22
|
+
const FOOTER = /^(no changes added to commit|nothing added to commit|nothing to commit|nothing to commit, working tree clean)/;
|
|
23
|
+
|
|
24
|
+
export function compactStatus(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
25
|
+
const lines = output.split("\n");
|
|
26
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
27
|
+
let head = "";
|
|
28
|
+
let track = "";
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
let section = "";
|
|
31
|
+
for (const l of lines) {
|
|
32
|
+
let m: RegExpExecArray | null;
|
|
33
|
+
if ((m = /^On branch (.+)$/.exec(l))) head = m[1]!;
|
|
34
|
+
else if ((m = /^HEAD detached (?:at|from) (.+)$/.exec(l))) head = `(detached ${m[1]})`;
|
|
35
|
+
else if ((m = /^Your branch is up to date with '(.+)'\.$/.exec(l))) track = `...${m[1]}`;
|
|
36
|
+
else if ((m = /^Your branch is (ahead of|behind) '(.+)' by (\d+) commits?(?:, and can be fast-forwarded)?\.$/.exec(l))) track = `...${m[2]} [${m[1]!.split(" ")[0]} ${m[3]}]`;
|
|
37
|
+
else if ((m = /^Your branch and '(.+)' have diverged,$/.exec(l))) track = `...${m[1]} [diverged]`;
|
|
38
|
+
else if ((m = /^and have (\d+) and (\d+) different commits each, respectively\.$/.exec(l))) track = track.replace("[diverged]", `[ahead ${m[1]}, behind ${m[2]}]`);
|
|
39
|
+
else if (SECTIONS[l]) {
|
|
40
|
+
section = SECTIONS[l]!;
|
|
41
|
+
out.push(`${section}:`);
|
|
42
|
+
} else if (/^\s+\(use "git .*\)$/.test(l) || l === "" || FOOTER.test(l) || /^\s*\(all conflicts fixed.*\)$/.test(l)) continue;
|
|
43
|
+
else if ((m = /^\t([a-z ]+?):\s+(.+)$/.exec(l)) && CODES[m[1]!]) out.push(` ${CODES[m[1]!]} ${m[2]}`);
|
|
44
|
+
else if (section === "untracked" || section === "ignored") {
|
|
45
|
+
if (!l.startsWith("\t")) return undefined;
|
|
46
|
+
out.push(` ${l.slice(1)}`);
|
|
47
|
+
} else return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (!head) return undefined;
|
|
50
|
+
const clean = out.length === 0 ? [" clean"] : [];
|
|
51
|
+
return capLines([`## ${head}${track}${clean[0] ?? ""}`, ...out], limits.total);
|
|
52
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gradle` / `./gradlew` output:
|
|
3
|
+
*
|
|
4
|
+
* * every `> Task :x UP-TO-DATE` line (and NO-SOURCE, FROM-CACHE, SKIPPED, plain executed
|
|
5
|
+
* ones) becomes one counting line; failed tasks, errors, test failures and the `BUILD`
|
|
6
|
+
* result stay;
|
|
7
|
+
* * daemon, configuration-cache, AGP-option, JVM and "Run with --scan" chatter goes, as do
|
|
8
|
+
* the wrappers around a failure (`A failure occurred while executing …`), the lint
|
|
9
|
+
* baseline advice and dependencyInsight's attribute tables;
|
|
10
|
+
* * repeated warnings and `--warning-mode all` deprecations are said once, with a count,
|
|
11
|
+
* without the upgrade-guide links; the failure summary does not repeat what was printed
|
|
12
|
+
* above it;
|
|
13
|
+
* * Kotlin / KSP warnings, detekt and ktlint findings and Android Lint issues are grouped by
|
|
14
|
+
* message, each location listed once (see `renderDiags`); errors stay one per line;
|
|
15
|
+
* * R8 "Missing class" lines become one list of class names, "Duplicate class" lines one
|
|
16
|
+
* list per pair of modules; "Searched in the following locations" lists become a count;
|
|
17
|
+
* * `tasks` and `projects` reports become names per group (see gradlereports.ts);
|
|
18
|
+
* * reported files lose the directory they share; framework stack frames fold.
|
|
19
|
+
*/
|
|
20
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
21
|
+
import { squeezeProjects, squeezeTaskReport } from "./gradlereports.ts";
|
|
22
|
+
import { isJvmNoise } from "./jvm.ts";
|
|
23
|
+
import { type Diag, renderDiags } from "./lint.ts";
|
|
24
|
+
import { foldFrames } from "./logs.ts";
|
|
25
|
+
import { compactTree } from "./tree.ts";
|
|
26
|
+
|
|
27
|
+
const TASK = /^> Task (\S+)(?: ([A-Z-]+))?$/;
|
|
28
|
+
const NOISE = [
|
|
29
|
+
/^Starting a Gradle Daemon/,
|
|
30
|
+
/^Daemon will be stopped/,
|
|
31
|
+
/^Calculating task graph/,
|
|
32
|
+
/^(Reusing|Configuration) cache/,
|
|
33
|
+
/^Reusing configuration cache\.$/,
|
|
34
|
+
/^Configuration cache entry/,
|
|
35
|
+
/^Isolated Projects is an incubating/,
|
|
36
|
+
/^Consider enabling configuration cache/,
|
|
37
|
+
/^> Run with --(stacktrace|info|debug|scan)/,
|
|
38
|
+
/^> Get more help at/,
|
|
39
|
+
/^> Configure project /,
|
|
40
|
+
/^Deprecated Gradle features were used/,
|
|
41
|
+
/^You can use '--warning-mode all'/,
|
|
42
|
+
/^For more on this, please refer to/,
|
|
43
|
+
/^\[Incubating\] Problems report is available/,
|
|
44
|
+
/^Type-safe project accessors is an incubating/,
|
|
45
|
+
// AGP's explanation under each `WARNING: The option setting …` line.
|
|
46
|
+
/^The current default is '/,
|
|
47
|
+
/^It will be removed in version [\d.]+ of the Android Gradle plugin/,
|
|
48
|
+
/^Add android\.sync\.suppress\w+=/,
|
|
49
|
+
/^Download https?:\/\//,
|
|
50
|
+
/^={20,}$/,
|
|
51
|
+
/^-{5,}$/,
|
|
52
|
+
// `gradle dependencyUpdates`: the URL under each dependency.
|
|
53
|
+
/^\s+https?:\/\/\S+$/,
|
|
54
|
+
// Wrappers around the actual failure.
|
|
55
|
+
/^\s*> A failure occurred while executing [\w.$]+$/,
|
|
56
|
+
/^\s*> There was a failure while executing work items$/,
|
|
57
|
+
/^\s*Go to the documentation to learn how to <a href=/,
|
|
58
|
+
// dependencyInsight: attribute tables and the legend.
|
|
59
|
+
/^\s+\|.*\|\s*$/,
|
|
60
|
+
/^\(\*\) - Indicates repeated occurrences/,
|
|
61
|
+
/^\([cn]\) - (?:A dependency constraint|A dependency or dependency configuration)/,
|
|
62
|
+
/^A web-based, searchable dependency report is available/,
|
|
63
|
+
// `gradle projects`.
|
|
64
|
+
/^To see a list of the tasks of a project, run/,
|
|
65
|
+
/^For example, try running gradlew/,
|
|
66
|
+
/^Project locations:$/,
|
|
67
|
+
// javac's advice to rerun with -Xlint.
|
|
68
|
+
/^Note: (?:Recompile with -Xlint|Some input files use)/,
|
|
69
|
+
];
|
|
70
|
+
/** Blocks of advice, dropped from the first line to the last. */
|
|
71
|
+
const ADVICE = [{ start: /^\s*Fix the issues identified by lint, or create a baseline/, end: /^\s*For more details, see https:\/\/developer\.android\.com\/studio\/write\/lint/ }];
|
|
72
|
+
/** Lines said once are enough (their indented continuation goes with a repeat too). */
|
|
73
|
+
const ONCE = /^(w: |e: |WARNING: |Lint found \d+|The full lint text report is located at:)/;
|
|
74
|
+
/** `--warning-mode all` deprecations, said once and without the guide link. */
|
|
75
|
+
const DEPRECATION = /scheduled to be removed in Gradle \d+|will fail with an error in Gradle \d+|has been deprecated\./;
|
|
76
|
+
const GUIDE = /\s*(?:Consult the upgrading guide for further information|For more information, please refer to|Please refer to|See)\s*:?\s*https?:\/\/\S+.*$/;
|
|
77
|
+
|
|
78
|
+
/** Findings that group by message: Kotlin / KSP warnings, detekt / ktlint, Android Lint. */
|
|
79
|
+
const KOTLIN_W = /^w: (?:\[ksp\] )?(?:file:\/\/)?(\/\S+?):(\d+)(?::(\d+))?:? (.*)$/;
|
|
80
|
+
const LINT_ISSUE = /^(\/\S+?):(\d+): (Error|Warning|Information|Hint|Fatal): (.*) \[(\w+)\]$/;
|
|
81
|
+
const CHECKER = /^(\/\S+?):(\d+):(\d+): (.*?)(?: \[([\w:-]+)\])?$/;
|
|
82
|
+
/** R8 and AGP's duplicate-class check. */
|
|
83
|
+
const R8_MISSING = /^(?:ERROR: )?(?:R8: )?Missing class (\S+)(?: \(referenced from: .*\))?$/;
|
|
84
|
+
const DUPLICATE = /^\s*(?:> )?Duplicate class (\S+) found in modules? (.+)$/;
|
|
85
|
+
|
|
86
|
+
/** Every absolute file path followed by a line number, `:12` or Maven's `:[12,5]` (not a URL's host and port). */
|
|
87
|
+
const PATH_AT = /(?<![\w/:.])(?:file:\/\/)?(\/[^\s:'"()[\]]+\.[A-Za-z]\w*):(?:\d|\[\d)/g;
|
|
88
|
+
|
|
89
|
+
type Group = { tool: string; diags: Diag[] };
|
|
90
|
+
type Listed = { label: string; names: string[] };
|
|
91
|
+
|
|
92
|
+
export function compactGradle(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
93
|
+
const lines = output.split("\n");
|
|
94
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
95
|
+
if (!lines.some((l) => TASK.test(l)) || !lines.some((l) => /^BUILD (SUCCESSFUL|FAILED)/.test(l))) return undefined;
|
|
96
|
+
|
|
97
|
+
const s = scan(lines);
|
|
98
|
+
shortenPaths(s.kept, s.slots);
|
|
99
|
+
const width = limits.lineChars * 8;
|
|
100
|
+
let cut = s.cut;
|
|
101
|
+
const expanded = s.kept.flatMap((l, i) => {
|
|
102
|
+
const key = s.slots.get(i);
|
|
103
|
+
if (key === undefined) return [l];
|
|
104
|
+
const g = s.groups.get(key);
|
|
105
|
+
if (g) {
|
|
106
|
+
const r = renderDiags({ tool: g.tool, diags: g.diags, notes: [] }, { ...limits, total: Number.MAX_SAFE_INTEGER });
|
|
107
|
+
cut ||= r.truncated;
|
|
108
|
+
return r.text.split("\n");
|
|
109
|
+
}
|
|
110
|
+
const { label, names } = s.listed.get(key)!;
|
|
111
|
+
return [cutLine(`${label} (${names.length}): ${names.join(" ")}`, width)];
|
|
112
|
+
});
|
|
113
|
+
const tasksReport = squeezeTaskReport(expanded, width);
|
|
114
|
+
const projects = squeezeProjects(tasksReport.lines, width);
|
|
115
|
+
const { lines: folded, folded: didFold } = foldFrames(projects.lines);
|
|
116
|
+
|
|
117
|
+
// Blank lines only separate: never two in a row, never first. Lists may run to `width`.
|
|
118
|
+
const body: string[] = [];
|
|
119
|
+
for (const l of folded) {
|
|
120
|
+
const blank = l.trim() === "";
|
|
121
|
+
if (blank && (body.length === 0 || body.at(-1) === "")) continue;
|
|
122
|
+
body.push(blank ? "" : cutLine(l, LIST_LINE.test(l) ? width : limits.lineChars));
|
|
123
|
+
}
|
|
124
|
+
while (body.at(-1) === "") body.pop();
|
|
125
|
+
|
|
126
|
+
const parts = [...s.states].map(([k, v]) => `${v} ${k.toLowerCase()}`);
|
|
127
|
+
const head = `gradle: ${s.tasks} tasks (${parts.join(", ")})${s.ran.length ? ` ran: ${s.ran.slice(0, 12).join(" ")}${s.ran.length > 12 ? ` … +${s.ran.length - 12}` : ""}` : ""}`;
|
|
128
|
+
const capped = capLines([head, ...body], limits.total);
|
|
129
|
+
return { text: capped.text, truncated: capped.truncated || didFold || cut || tasksReport.cut || projects.cut };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `label (N): name name …`: one line of names, which may run long. */
|
|
133
|
+
const LIST_LINE = /^\S.* \(\d+\): /;
|
|
134
|
+
|
|
135
|
+
type Scan = {
|
|
136
|
+
kept: string[];
|
|
137
|
+
states: Map<string, number>;
|
|
138
|
+
ran: string[];
|
|
139
|
+
tasks: number;
|
|
140
|
+
groups: Map<string, Group>;
|
|
141
|
+
listed: Map<string, Listed>;
|
|
142
|
+
/** Lines where a group of findings or a list of names goes once all of it is known. */
|
|
143
|
+
slots: Map<number, string>;
|
|
144
|
+
/** Something was left out that the text does not show: R8 / duplicate-class references. */
|
|
145
|
+
cut: boolean;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Goes through the lines once: counts tasks, drops noise and repeats, collects findings. */
|
|
149
|
+
function scan(lines: string[]): Scan {
|
|
150
|
+
const s: Scan = { kept: [], states: new Map(), ran: [], tasks: 0, groups: new Map(), listed: new Map(), slots: new Map(), cut: false };
|
|
151
|
+
const { kept } = s;
|
|
152
|
+
/** Where each said-once line went, and how often it came. */
|
|
153
|
+
const said = new Map<string, { at: number; n: number }>();
|
|
154
|
+
/** Trimmed lines printed before the failure summary, which repeats them. */
|
|
155
|
+
const printed = new Set<string>();
|
|
156
|
+
let skip: "try" | "repeat" | "issue" | RegExp | undefined;
|
|
157
|
+
let summary = false;
|
|
158
|
+
let searched: { at: number; n: number; indent: string } | undefined;
|
|
159
|
+
|
|
160
|
+
const slot = (key: string) => s.slots.set(kept.push("") - 1, key);
|
|
161
|
+
const group = (tool: string, d: Diag) => {
|
|
162
|
+
const g = s.groups.get(tool) ?? { tool, diags: [] };
|
|
163
|
+
if (!s.groups.has(tool)) slot(tool);
|
|
164
|
+
if (!g.diags.some((x) => x.file === d.file && x.line === d.line && x.message === d.message)) g.diags.push(d);
|
|
165
|
+
s.groups.set(tool, g);
|
|
166
|
+
};
|
|
167
|
+
const list = (key: string, label: string, name: string) => {
|
|
168
|
+
const g = s.listed.get(key) ?? { label, names: [] };
|
|
169
|
+
if (!s.listed.has(key)) slot(key);
|
|
170
|
+
if (!g.names.includes(name)) g.names.push(name);
|
|
171
|
+
s.listed.set(key, g);
|
|
172
|
+
s.cut = true;
|
|
173
|
+
};
|
|
174
|
+
const endSearch = () => {
|
|
175
|
+
kept[searched!.at] = `${searched!.indent}Searched in ${searched!.n} locations`;
|
|
176
|
+
searched = undefined;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
for (const raw of lines) {
|
|
180
|
+
const l = DEPRECATION.test(raw) ? raw.replace(GUIDE, "") : raw;
|
|
181
|
+
s.cut ||= l !== raw;
|
|
182
|
+
if (/^(?:\* What went wrong:|FAILURE: )/.test(l)) summary = true;
|
|
183
|
+
if (!summary) printed.add(l.trim());
|
|
184
|
+
const t = TASK.exec(l);
|
|
185
|
+
if (t) {
|
|
186
|
+
s.tasks++;
|
|
187
|
+
if (t[2] === "FAILED") kept.push(l);
|
|
188
|
+
else if (t[2]) s.states.set(t[2], (s.states.get(t[2]) ?? 0) + 1);
|
|
189
|
+
else (s.states.set("executed", (s.states.get("executed") ?? 0) + 1), s.ran.push(t[1]!));
|
|
190
|
+
skip = undefined;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (searched) {
|
|
194
|
+
if (/^\s+- \S+$/.test(l)) {
|
|
195
|
+
searched.n++;
|
|
196
|
+
s.cut = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
endSearch();
|
|
200
|
+
}
|
|
201
|
+
if (skip instanceof RegExp) {
|
|
202
|
+
if (skip.test(l)) skip = undefined;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (skip) {
|
|
206
|
+
// `* Try:` runs to a blank line; a repeat's and an issue's continuation is indented.
|
|
207
|
+
const more = skip === "try" ? l.trim() !== "" : l === "" || /^\s/.test(l);
|
|
208
|
+
if (more) continue;
|
|
209
|
+
skip = undefined;
|
|
210
|
+
}
|
|
211
|
+
if (l === "* Try:") {
|
|
212
|
+
skip = "try";
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const advice = ADVICE.find((a) => a.start.test(l));
|
|
216
|
+
if (advice) {
|
|
217
|
+
skip = advice.end;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (NOISE.some((n) => n.test(l)) || isJvmNoise(l)) continue;
|
|
221
|
+
// The failure summary repeats lint's first issue, Kotlin's error and the like: once is enough.
|
|
222
|
+
const trimmed = l.trim();
|
|
223
|
+
if (summary && trimmed.length >= 4 && !trimmed.startsWith("* ") && printed.has(trimmed)) continue;
|
|
224
|
+
|
|
225
|
+
const kw = KOTLIN_W.exec(l);
|
|
226
|
+
const li = LINT_ISSUE.exec(l);
|
|
227
|
+
const ck = CHECKER.exec(l);
|
|
228
|
+
const r8 = R8_MISSING.exec(l);
|
|
229
|
+
const dup = DUPLICATE.exec(l);
|
|
230
|
+
if (kw) group("kotlin", { file: kw[1]!, line: kw[2]!, col: kw[3], severity: "warning", message: kw[4]! });
|
|
231
|
+
else if (li) {
|
|
232
|
+
group("android lint", { file: li[1]!, line: li[2]!, severity: li[3]!.toLowerCase(), rule: li[5], message: li[4]! });
|
|
233
|
+
skip = "issue";
|
|
234
|
+
s.cut = true;
|
|
235
|
+
} else if (ck) group("detekt / ktlint", { file: ck[1]!, line: ck[2]!, col: ck[3], severity: "", rule: ck[5], message: ck[4]! });
|
|
236
|
+
else if (r8) list("r8", "R8: missing classes", r8[1]!);
|
|
237
|
+
else if (dup) list(`dup ${dup[2]}`, `Duplicate classes in ${dup[2]}`, dup[1]!);
|
|
238
|
+
else if (ONCE.test(l) || DEPRECATION.test(l)) {
|
|
239
|
+
const seen = said.get(l);
|
|
240
|
+
if (seen) {
|
|
241
|
+
seen.n++;
|
|
242
|
+
skip = "repeat";
|
|
243
|
+
} else said.set(l, { at: kept.push(l) - 1, n: 1 });
|
|
244
|
+
} else {
|
|
245
|
+
const sl = /^(\s*)Searched in the following locations:$/.exec(l);
|
|
246
|
+
if (sl) searched = { at: kept.length, n: 0, indent: sl[1]! };
|
|
247
|
+
kept.push(l);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (searched) endSearch();
|
|
251
|
+
for (const { at, n } of said.values()) if (n > 1) kept[at] += ` (×${n})`;
|
|
252
|
+
return s;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Writes the directory every reported file shares once (grouped findings do their own). */
|
|
256
|
+
export function shortenPaths(kept: string[], slots: Map<number, string> = new Map()) {
|
|
257
|
+
const paths = kept.flatMap((l, i) => (slots.has(i) ? [] : [...l.matchAll(PATH_AT)].map((m) => m[1]!)));
|
|
258
|
+
if (paths.length < 2) return;
|
|
259
|
+
let prefix = paths[0]!.slice(0, paths[0]!.lastIndexOf("/") + 1);
|
|
260
|
+
for (const p of paths) while (!p.startsWith(prefix)) prefix = prefix.slice(0, prefix.lastIndexOf("/", prefix.length - 2) + 1);
|
|
261
|
+
if (prefix.length > 1) for (let i = 0; i < kept.length; i++) kept[i] = kept[i]!.replaceAll(`file://${prefix}`, "…/").replaceAll(prefix, "…/");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `gradle dependencies`: the gradle clean-up, then the tree cut below its depth. */
|
|
265
|
+
export function compactGradleDeps(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
266
|
+
const cleaned = compactGradle(output, limits);
|
|
267
|
+
const tree = compactTree(cleaned?.text ?? output, limits);
|
|
268
|
+
return tree ?? cleaned;
|
|
269
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gradle's own reports, which list far more than anyone reads:
|
|
3
|
+
*
|
|
4
|
+
* * `gradle tasks [--all]`: per group, the task names on one line; the descriptions go.
|
|
5
|
+
* * `gradle projects`: every project path (Gradle prints them twice, flat and as a tree)
|
|
6
|
+
* once, grouped by the first segment: `:api (60): analytics analytics:core …`.
|
|
7
|
+
*/
|
|
8
|
+
import { cutLine } from "./elide.ts";
|
|
9
|
+
|
|
10
|
+
type Rewritten = { lines: string[]; cut: boolean };
|
|
11
|
+
|
|
12
|
+
const TASKS_HEAD = /^Tasks runnable from (?:root )?project\b/;
|
|
13
|
+
|
|
14
|
+
export function squeezeTaskReport(lines: string[], width: number): Rewritten {
|
|
15
|
+
const start = lines.findIndex((l) => TASKS_HEAD.test(l));
|
|
16
|
+
if (start < 0) return { lines, cut: false };
|
|
17
|
+
const out = lines.slice(0, start + 1);
|
|
18
|
+
let cut = false;
|
|
19
|
+
let i = start + 1;
|
|
20
|
+
while (i < lines.length) {
|
|
21
|
+
const l = lines[i]!;
|
|
22
|
+
if (/^To see (?:all tasks|more detail about a task)/.test(l)) {
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// A group: its title, then one task per line up to a blank line.
|
|
27
|
+
const body: string[] = [];
|
|
28
|
+
let j = i + 1;
|
|
29
|
+
while (j < lines.length && lines[j] !== "") body.push(lines[j++]!);
|
|
30
|
+
if (l === "" || !body.length || !/tasks$/i.test(l)) {
|
|
31
|
+
out.push(l);
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const line = `${l} (${body.length}): ${body.map((b) => b.split(" - ")[0]).join(" ")}`;
|
|
36
|
+
// The descriptions go: the full output keeps them.
|
|
37
|
+
cut = true;
|
|
38
|
+
out.push(cutLine(line, width));
|
|
39
|
+
i = j;
|
|
40
|
+
}
|
|
41
|
+
return { lines: out, cut };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PROJECT = /^[|+\\ -]*[Pp]roject '(:[^']+)'/;
|
|
45
|
+
|
|
46
|
+
export function squeezeProjects(lines: string[], width: number): Rewritten {
|
|
47
|
+
const paths = new Set<string>();
|
|
48
|
+
let at = -1;
|
|
49
|
+
const rest: string[] = [];
|
|
50
|
+
const roots = new Set<string>();
|
|
51
|
+
for (const l of lines) {
|
|
52
|
+
const m = PROJECT.exec(l);
|
|
53
|
+
if (m) {
|
|
54
|
+
if (at < 0) at = rest.length;
|
|
55
|
+
paths.add(m[1]!);
|
|
56
|
+
} else if (/^Root project '/.test(l)) {
|
|
57
|
+
if (!roots.has(l)) rest.push(l);
|
|
58
|
+
roots.add(l);
|
|
59
|
+
} else rest.push(l);
|
|
60
|
+
}
|
|
61
|
+
if (paths.size < 5) return { lines, cut: false };
|
|
62
|
+
const groups = new Map<string, string[]>();
|
|
63
|
+
for (const p of [...paths].sort()) {
|
|
64
|
+
const [top, ...sub] = p.slice(1).split(":");
|
|
65
|
+
const names = groups.get(top!) ?? [];
|
|
66
|
+
if (sub.length) names.push(sub.join(":"));
|
|
67
|
+
groups.set(top!, names);
|
|
68
|
+
}
|
|
69
|
+
let cut = false;
|
|
70
|
+
const listed = [`${paths.size} projects`];
|
|
71
|
+
for (const [top, names] of groups) {
|
|
72
|
+
const line = names.length ? `:${top} (${names.length}): ${names.join(" ")}` : `:${top}`;
|
|
73
|
+
cut ||= line.length > width;
|
|
74
|
+
listed.push(cutLine(line, width));
|
|
75
|
+
}
|
|
76
|
+
rest.splice(at, 0, ...listed);
|
|
77
|
+
return { lines: rest, cut };
|
|
78
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package installs (npm, pnpm, yarn, bun, pip, uv, CocoaPods, Bundler, pub): download and progress lines
|
|
3
|
+
* go, per-package lines collapse into one line each (`Collecting (9): requests, rich, …`),
|
|
4
|
+
* deprecation warnings become one list of names. Errors, the summary and anything not
|
|
5
|
+
* recognised stay where they were.
|
|
6
|
+
*/
|
|
7
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
8
|
+
import { stripAnsi } from "./generic.ts";
|
|
9
|
+
|
|
10
|
+
type Rule = { re: RegExp; label: string; item: (m: RegExpExecArray) => string };
|
|
11
|
+
|
|
12
|
+
/** Lines that say nothing once the install is over. */
|
|
13
|
+
const DROP: RegExp[] = [
|
|
14
|
+
// npm
|
|
15
|
+
/^\d+ packages? (are|is) looking for funding$/,
|
|
16
|
+
/^ {2}run `npm fund` for details$/,
|
|
17
|
+
/^To address (all|issues that do not require attention|all issues possible)/,
|
|
18
|
+
/^ {2}npm audit fix/,
|
|
19
|
+
/^Some issues need review, and may require choosing$/,
|
|
20
|
+
/^a different dependency\.$/,
|
|
21
|
+
/^npm notice/,
|
|
22
|
+
/^(up to date|changed \d+ packages?), audited/,
|
|
23
|
+
// pip
|
|
24
|
+
/^ {2}Downloading \S+/,
|
|
25
|
+
/^ {2}Using cached \S+/,
|
|
26
|
+
/^ {2}Preparing metadata/,
|
|
27
|
+
/^ {2}(Installing|Getting requirements to) build/,
|
|
28
|
+
/^ {2}(Building|Created|Stored in directory)/,
|
|
29
|
+
/^Building wheels? for /,
|
|
30
|
+
/^Installing collected packages: /,
|
|
31
|
+
/^ {2}Attempting uninstall: /,
|
|
32
|
+
/^ {4}(Found existing installation|Uninstalling) /,
|
|
33
|
+
/^ {6}Successfully uninstalled /,
|
|
34
|
+
/^WARNING: You are using pip version/,
|
|
35
|
+
/^You should consider upgrading via/,
|
|
36
|
+
/^\[notice\] /,
|
|
37
|
+
/^\s*[━╸╺\-]+ [\d.]+\/[\d.]+ [kMG]?B /,
|
|
38
|
+
// bun, uv, pnpm, yarn
|
|
39
|
+
/^bun (add|install|update|remove) v[\d.]+/,
|
|
40
|
+
/^Resolving dependencies$/,
|
|
41
|
+
/^Resolved, downloaded and extracted/,
|
|
42
|
+
/^Saved lockfile$/,
|
|
43
|
+
/^(Resolved|Prepared|Audited|Uninstalled) \d+ packages? in /,
|
|
44
|
+
/^Progress: resolved \d+/,
|
|
45
|
+
/^Packages: [+-]\d+/,
|
|
46
|
+
/^[+-]+$/,
|
|
47
|
+
/^\[\d\/\d\] /,
|
|
48
|
+
/^(yarn (install|add) v|info (No lockfile|Lockfile|fsevents)|success Saved lockfile|Done in [\d.]+s)/,
|
|
49
|
+
// CocoaPods, pub
|
|
50
|
+
/^(Analyzing|Downloading) dependencies$/,
|
|
51
|
+
/^Fetching gem metadata from /,
|
|
52
|
+
/^(Generating Pods project|Integrating client project|Resolving dependencies\.\.\.|Downloading packages\.\.\.|Got dependencies!?)$/,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** Lines that become one counted line, at the place of the first one. */
|
|
56
|
+
const COLLECT: Rule[] = [
|
|
57
|
+
{ re: /^npm warn deprecated (\S+): /, label: "npm warn deprecated", item: (m) => m[1]! },
|
|
58
|
+
{ re: /^\s*WARN\s+deprecated (\S+): /, label: "pnpm WARN deprecated", item: (m) => m[1]! },
|
|
59
|
+
{ re: /^warning (\S+(?: > \S+)*): .*deprecated/, label: "yarn warning deprecated", item: (m) => m[1]! },
|
|
60
|
+
{ re: /^Collecting (\S+)/, label: "Collecting", item: (m) => m[1]! },
|
|
61
|
+
{ re: /^Requirement already satisfied: (\S+)/, label: "Requirement already satisfied", item: (m) => m[1]! },
|
|
62
|
+
{ re: /^installed (\S+)/, label: "installed", item: (m) => m[1]! },
|
|
63
|
+
{ re: /^ ?\+ (\S+)(?: (\S+))?$/, label: "+", item: (m) => (m[2] ? `${m[1]} ${m[2]}` : m[1]!) },
|
|
64
|
+
{ re: /^ - (\S+)(?: (\S+))?$/, label: "-", item: (m) => (m[2] ? `${m[1]} ${m[2]}` : m[1]!) },
|
|
65
|
+
{ re: /^Installing (\S+) \(([^)]+)\)$/, label: "Installing", item: (m) => `${m[1]} ${m[2]}` },
|
|
66
|
+
{ re: /^Using (\S+) \(([^)]+)\)$/, label: "Using", item: (m) => `${m[1]} ${m[2]}` },
|
|
67
|
+
// Bundler: `Using rake 13.0.6`, `Installing nokogiri 1.16.0 with native extensions`.
|
|
68
|
+
{ re: /^Using (\S+) ([\w.]+)$/, label: "Using", item: (m) => `${m[1]} ${m[2]}` },
|
|
69
|
+
{ re: /^Installing (\S+) ([\w.]+)(?: with native extensions)?$/, label: "Installing", item: (m) => `${m[1]} ${m[2]}` },
|
|
70
|
+
{ re: /^Fetching (\S+) ([\w.]+)$/, label: "Fetching", item: (m) => `${m[1]} ${m[2]}` },
|
|
71
|
+
{ re: /^ {2}(\S+) ([\w.+-]+) \(([\w.+-]+) available\)$/, label: "newer versions available", item: (m) => `${m[1]} ${m[2]}→${m[3]}` },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Names shown per collected line before `… +N`. */
|
|
75
|
+
const SHOWN = 12;
|
|
76
|
+
|
|
77
|
+
export function compactInstall(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
78
|
+
const lines = stripAnsi(output)
|
|
79
|
+
.split("\n")
|
|
80
|
+
.map((l) => {
|
|
81
|
+
// A progress bar redrawn with `\r`: only its last frame counts.
|
|
82
|
+
const frame = l.replace(/\r+$/, "");
|
|
83
|
+
return frame.slice(frame.lastIndexOf("\r") + 1);
|
|
84
|
+
});
|
|
85
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
86
|
+
const out: (string | { label: string; items: string[] })[] = [];
|
|
87
|
+
const groups = new Map<string, { label: string; items: string[] }>();
|
|
88
|
+
let touched = 0;
|
|
89
|
+
for (const l of lines) {
|
|
90
|
+
if (DROP.some((d) => d.test(l))) {
|
|
91
|
+
touched++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const rule = COLLECT.find((r) => r.re.test(l));
|
|
95
|
+
if (rule) {
|
|
96
|
+
touched++;
|
|
97
|
+
const label = rule.label;
|
|
98
|
+
let g = groups.get(label);
|
|
99
|
+
if (!g) {
|
|
100
|
+
g = { label, items: [] };
|
|
101
|
+
groups.set(label, g);
|
|
102
|
+
out.push(g);
|
|
103
|
+
}
|
|
104
|
+
g.items.push(rule.item(rule.re.exec(l)!));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (l === "" && (out.length === 0 || out.at(-1) === "")) continue;
|
|
108
|
+
out.push(l);
|
|
109
|
+
}
|
|
110
|
+
if (!touched) return undefined;
|
|
111
|
+
// A collected line keeps the names, not the rest of each line (paths, deprecation notes).
|
|
112
|
+
let cut = false;
|
|
113
|
+
const text = out.map((o) => {
|
|
114
|
+
if (typeof o === "string") return cutLine(o, limits.lineChars);
|
|
115
|
+
const shown = o.items.slice(0, SHOWN).join(", ");
|
|
116
|
+
if (o.items.length > SHOWN) cut = true;
|
|
117
|
+
return `${o.label} (${o.items.length}): ${shown}${o.items.length > SHOWN ? `, … +${o.items.length - SHOWN}` : ""}`;
|
|
118
|
+
});
|
|
119
|
+
while (text.at(-1) === "") text.pop();
|
|
120
|
+
const capped = capLines(text, limits.total);
|
|
121
|
+
return { text: capped.text, truncated: cut || capped.truncated || groups.size > 0 };
|
|
122
|
+
}
|