@crewhaus/tool-text 0.7.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shaping text to fit somewhere: a token budget, a column width, or another
3
+ * language's syntax.
4
+ */
5
+ export type TruncateStrategy = "head" | "tail" | "middle";
6
+ /**
7
+ * Cut text to at most `maxChars`, keeping the end that matters for the
8
+ * chosen strategy and marking where bytes were dropped. Returns the input
9
+ * untouched when it already fits, so a caller can pipe everything through
10
+ * this without a size check first.
11
+ */
12
+ export declare function truncateToChars(text: string, maxChars: number, strategy: TruncateStrategy, marker: string): {
13
+ text: string;
14
+ truncated: boolean;
15
+ droppedChars: number;
16
+ };
17
+ /**
18
+ * Greedy word wrap at `width`, preserving blank-line paragraph breaks and
19
+ * prefixing every line (for quoting, comment blocks, commit bodies). A word
20
+ * longer than the width is left whole rather than split, because splitting a
21
+ * URL or an identifier makes it wrong, not merely ugly.
22
+ */
23
+ export declare function wrapText(text: string, width: number, prefix: string, hangingIndent: string): string;
24
+ export type EscapeTarget = "regex" | "shellSingle" | "shellDouble" | "json" | "url" | "urlComponent" | "html" | "markdown" | "csv" | "sqlLike";
25
+ /**
26
+ * Escape text for embedding in another syntax.
27
+ *
28
+ * This is the function that stops a harness building an injection into its
29
+ * own next command: a scraped string going into a regex, a filename going
30
+ * into a shell word, a customer's text going into a CSV cell. It performs no
31
+ * execution of any kind — it only quotes.
32
+ */
33
+ export declare function escapeFor(text: string, target: EscapeTarget): string;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Shaping text to fit somewhere: a token budget, a column width, or another
3
+ * language's syntax.
4
+ */
5
+ /**
6
+ * Cut text to at most `maxChars`, keeping the end that matters for the
7
+ * chosen strategy and marking where bytes were dropped. Returns the input
8
+ * untouched when it already fits, so a caller can pipe everything through
9
+ * this without a size check first.
10
+ */
11
+ export function truncateToChars(text, maxChars, strategy, marker) {
12
+ if (text.length <= maxChars)
13
+ return { text, truncated: false, droppedChars: 0 };
14
+ if (maxChars <= marker.length) {
15
+ return {
16
+ text: marker.slice(0, maxChars),
17
+ truncated: true,
18
+ droppedChars: text.length - maxChars,
19
+ };
20
+ }
21
+ const budget = maxChars - marker.length;
22
+ let out;
23
+ if (strategy === "head") {
24
+ out = text.slice(0, budget) + marker;
25
+ }
26
+ else if (strategy === "tail") {
27
+ out = marker + text.slice(text.length - budget);
28
+ }
29
+ else {
30
+ const half = Math.floor(budget / 2);
31
+ out = text.slice(0, half) + marker + text.slice(text.length - (budget - half));
32
+ }
33
+ return { text: out, truncated: true, droppedChars: text.length - budget };
34
+ }
35
+ /**
36
+ * Greedy word wrap at `width`, preserving blank-line paragraph breaks and
37
+ * prefixing every line (for quoting, comment blocks, commit bodies). A word
38
+ * longer than the width is left whole rather than split, because splitting a
39
+ * URL or an identifier makes it wrong, not merely ugly.
40
+ */
41
+ export function wrapText(text, width, prefix, hangingIndent) {
42
+ const usable = Math.max(1, width - prefix.length);
43
+ const out = [];
44
+ for (const paragraph of text.split(/\n{2,}/)) {
45
+ const words = paragraph.split(/\s+/).filter((w) => w !== "");
46
+ if (words.length === 0)
47
+ continue;
48
+ let line = "";
49
+ const lines = [];
50
+ for (const word of words) {
51
+ if (line === "") {
52
+ line = (lines.length === 0 ? "" : hangingIndent) + word;
53
+ continue;
54
+ }
55
+ if (line.length + 1 + word.length <= usable) {
56
+ line += ` ${word}`;
57
+ continue;
58
+ }
59
+ lines.push(line);
60
+ line = hangingIndent + word;
61
+ }
62
+ if (line !== "")
63
+ lines.push(line);
64
+ out.push(lines.map((l) => prefix + l).join("\n"));
65
+ }
66
+ return out.join("\n\n");
67
+ }
68
+ /**
69
+ * Escape text for embedding in another syntax.
70
+ *
71
+ * This is the function that stops a harness building an injection into its
72
+ * own next command: a scraped string going into a regex, a filename going
73
+ * into a shell word, a customer's text going into a CSV cell. It performs no
74
+ * execution of any kind — it only quotes.
75
+ */
76
+ export function escapeFor(text, target) {
77
+ switch (target) {
78
+ case "regex":
79
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ case "shellSingle":
81
+ // POSIX single-quoting: close the quote, emit an escaped quote, reopen.
82
+ return `'${text.replace(/'/g, `'\\''`)}'`;
83
+ case "shellDouble":
84
+ return `"${text.replace(/(["$`\\])/g, "\\$1")}"`;
85
+ case "json":
86
+ return JSON.stringify(text);
87
+ case "url":
88
+ return encodeURI(text);
89
+ case "urlComponent":
90
+ return encodeURIComponent(text);
91
+ case "html":
92
+ return text
93
+ .replace(/&/g, "&amp;")
94
+ .replace(/</g, "&lt;")
95
+ .replace(/>/g, "&gt;")
96
+ .replace(/"/g, "&quot;")
97
+ .replace(/'/g, "&#39;");
98
+ case "markdown":
99
+ return text.replace(/([\\`*_{}[\]()#+\-.!|>~])/g, "\\$1");
100
+ case "csv":
101
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
102
+ case "sqlLike":
103
+ return text.replace(/([\\%_])/g, "\\$1");
104
+ default:
105
+ return text;
106
+ }
107
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Offset bookkeeping shared by every tool that reports where something was
3
+ * found. Kept in one place so "line 12, column 3" means the same thing
4
+ * whether it came from a regex match or an entity scan.
5
+ */
6
+ /** Byte offsets at which each line starts, so an offset maps to a line. */
7
+ export declare function lineStarts(text: string): number[];
8
+ /** Turn a 0-based offset into a 1-based {line, column} by binary search. */
9
+ export declare function offsetToLineCol(starts: ReadonlyArray<number>, offset: number): {
10
+ line: number;
11
+ column: number;
12
+ };
13
+ /**
14
+ * The token estimate used across this package.
15
+ *
16
+ * Four characters per token is the well-worn heuristic for English prose and
17
+ * is deliberately NOT a real tokenizer: a real BPE would make the answer
18
+ * depend on a vocabulary file, which is the kind of hidden state that stops
19
+ * a tool being reproducible. Callers needing exactness should measure with
20
+ * their provider's own counter.
21
+ */
22
+ export declare function estimateTokens(text: string): number;
23
+ /** Lowercase word tokens — the shared basis for keyword and token measures. */
24
+ export declare function tokenize(text: string): string[];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Offset bookkeeping shared by every tool that reports where something was
3
+ * found. Kept in one place so "line 12, column 3" means the same thing
4
+ * whether it came from a regex match or an entity scan.
5
+ */
6
+ /** Byte offsets at which each line starts, so an offset maps to a line. */
7
+ export function lineStarts(text) {
8
+ const starts = [0];
9
+ for (let i = 0; i < text.length; i++) {
10
+ if (text.charCodeAt(i) === 10)
11
+ starts.push(i + 1);
12
+ }
13
+ return starts;
14
+ }
15
+ /** Turn a 0-based offset into a 1-based {line, column} by binary search. */
16
+ export function offsetToLineCol(starts, offset) {
17
+ let lo = 0;
18
+ let hi = starts.length - 1;
19
+ while (lo < hi) {
20
+ const mid = (lo + hi + 1) >> 1;
21
+ if (starts[mid] <= offset)
22
+ lo = mid;
23
+ else
24
+ hi = mid - 1;
25
+ }
26
+ return { line: lo + 1, column: offset - starts[lo] + 1 };
27
+ }
28
+ /**
29
+ * The token estimate used across this package.
30
+ *
31
+ * Four characters per token is the well-worn heuristic for English prose and
32
+ * is deliberately NOT a real tokenizer: a real BPE would make the answer
33
+ * depend on a vocabulary file, which is the kind of hidden state that stops
34
+ * a tool being reproducible. Callers needing exactness should measure with
35
+ * their provider's own counter.
36
+ */
37
+ export function estimateTokens(text) {
38
+ return Math.ceil(text.length / 4);
39
+ }
40
+ /** Lowercase word tokens — the shared basis for keyword and token measures. */
41
+ export function tokenize(text) {
42
+ return (text.toLowerCase().match(/[a-z0-9][a-z0-9'-]*/g) ?? []).filter((t) => t.length > 0);
43
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Replace the volatile head of a log line — ISO timestamps, bracketed clock
3
+ * times — with a placeholder, so two lines differing only in when they
4
+ * happened collapse into one during dedupe.
5
+ */
6
+ export declare function stripTimestamps(line: string): string;
7
+ export declare function looksLikeError(line: string): boolean;
8
+ export type CompactedLine = {
9
+ readonly line: string;
10
+ readonly count: number;
11
+ readonly isError: boolean;
12
+ };
13
+ export type CompactLogOptions = {
14
+ readonly dedupe: boolean;
15
+ readonly stripTimestamps: boolean;
16
+ readonly stripAnsiCodes: boolean;
17
+ readonly errorsFirst: boolean;
18
+ readonly maxLines: number;
19
+ };
20
+ /**
21
+ * Collapse a log to its distinct lines, optionally floating failures to the
22
+ * top. Order within each group is first-seen, so the result still reads
23
+ * chronologically rather than being reshuffled by frequency.
24
+ *
25
+ * This is the tool that turns a 50k-line CI log into something a model can
26
+ * read in one turn — or, better, that a rule can decide on with no model at
27
+ * all.
28
+ */
29
+ export declare function compactLogLines(text: string, opts: CompactLogOptions): {
30
+ lines: CompactedLine[];
31
+ totalLines: number;
32
+ distinctLines: number;
33
+ truncated: boolean;
34
+ };
@@ -0,0 +1,63 @@
1
+ import { stripAnsi } from "./normalize";
2
+ /**
3
+ * Replace the volatile head of a log line — ISO timestamps, bracketed clock
4
+ * times — with a placeholder, so two lines differing only in when they
5
+ * happened collapse into one during dedupe.
6
+ */
7
+ export function stripTimestamps(line) {
8
+ return line
9
+ .replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, "<ts>")
10
+ .replace(/\[\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\]/g, "[<ts>]")
11
+ .replace(/\b\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\b/g, "<ts>");
12
+ }
13
+ /** Lines matching this read as failures and are surfaced first. */
14
+ const ERROR_PATTERN = /\b(error|errors|err|fail|failed|failure|fatal|panic|exception|traceback|refused|denied|timeout|timed out|cannot|unable to|not found|missing|invalid|unexpected)\b/i;
15
+ export function looksLikeError(line) {
16
+ return ERROR_PATTERN.test(line);
17
+ }
18
+ /**
19
+ * Collapse a log to its distinct lines, optionally floating failures to the
20
+ * top. Order within each group is first-seen, so the result still reads
21
+ * chronologically rather than being reshuffled by frequency.
22
+ *
23
+ * This is the tool that turns a 50k-line CI log into something a model can
24
+ * read in one turn — or, better, that a rule can decide on with no model at
25
+ * all.
26
+ */
27
+ export function compactLogLines(text, opts) {
28
+ const raw = (opts.stripAnsiCodes ? stripAnsi(text) : text).split("\n");
29
+ const totalLines = raw.length;
30
+ const counts = new Map();
31
+ const order = [];
32
+ for (const original of raw) {
33
+ const trimmed = original.trimEnd();
34
+ if (trimmed.trim() === "")
35
+ continue;
36
+ const key = opts.stripTimestamps ? stripTimestamps(trimmed) : trimmed;
37
+ const prior = counts.get(key);
38
+ if (prior === undefined) {
39
+ counts.set(key, 1);
40
+ order.push(key);
41
+ }
42
+ else if (opts.dedupe) {
43
+ counts.set(key, prior + 1);
44
+ }
45
+ else {
46
+ order.push(key);
47
+ }
48
+ }
49
+ const entries = order.map((line) => ({
50
+ line,
51
+ count: opts.dedupe ? (counts.get(line) ?? 1) : 1,
52
+ isError: looksLikeError(line),
53
+ }));
54
+ const sorted = opts.errorsFirst
55
+ ? [...entries.filter((e) => e.isError), ...entries.filter((e) => !e.isError)]
56
+ : entries;
57
+ return {
58
+ lines: sorted.slice(0, opts.maxLines),
59
+ totalLines,
60
+ distinctLines: entries.length,
61
+ truncated: sorted.length > opts.maxLines,
62
+ };
63
+ }
@@ -0,0 +1,34 @@
1
+ export type Heading = {
2
+ readonly depth: number;
3
+ readonly title: string;
4
+ readonly line: number;
5
+ readonly slug: string;
6
+ };
7
+ /** GitHub-style heading anchor: lowercase, drop punctuation, spaces to dashes. */
8
+ export declare function slugify(title: string): string;
9
+ /**
10
+ * Collect ATX headings, ignoring any inside a fenced code block — a
11
+ * `# comment` in a shell sample is not a section, and treating it as one
12
+ * corrupts every section boundary after it.
13
+ */
14
+ export declare function parseHeadings(markdown: string): Heading[];
15
+ /**
16
+ * Return one section's body: the heading plus everything until the next
17
+ * heading at the same or shallower depth. Matches on slug or exact title, so
18
+ * a caller can address a section either way.
19
+ */
20
+ export declare function sectionBody(markdown: string, wanted: string): string | undefined;
21
+ /** Render the heading tree as an indented outline with line numbers. */
22
+ export declare function renderOutline(headings: ReadonlyArray<Heading>, maxDepth: number): string;
23
+ /** Every fenced code block, with its language tag and start line. */
24
+ export declare function codeBlocks(markdown: string): Array<{
25
+ language: string;
26
+ code: string;
27
+ line: number;
28
+ }>;
29
+ /**
30
+ * Render rows as a GitHub-flavoured markdown table, padded so the source is
31
+ * readable. Column order comes from `columns`, or from the union of keys in
32
+ * first-seen order when it is omitted.
33
+ */
34
+ export declare function markdownTable(rows: ReadonlyArray<Readonly<Record<string, unknown>>>, columns?: ReadonlyArray<string>): string;
@@ -0,0 +1,120 @@
1
+ /** GitHub-style heading anchor: lowercase, drop punctuation, spaces to dashes. */
2
+ export function slugify(title) {
3
+ return title
4
+ .toLowerCase()
5
+ .replace(/[^\w\s-]/g, "")
6
+ .trim()
7
+ .replace(/\s+/g, "-");
8
+ }
9
+ /**
10
+ * Collect ATX headings, ignoring any inside a fenced code block — a
11
+ * `# comment` in a shell sample is not a section, and treating it as one
12
+ * corrupts every section boundary after it.
13
+ */
14
+ export function parseHeadings(markdown) {
15
+ const out = [];
16
+ let inFence = false;
17
+ let fenceChar = "";
18
+ const lines = markdown.split("\n");
19
+ for (let i = 0; i < lines.length; i++) {
20
+ const line = lines[i];
21
+ const fence = line.match(/^\s*(`{3,}|~{3,})/);
22
+ if (fence !== null) {
23
+ const marker = fence[1][0];
24
+ if (!inFence) {
25
+ inFence = true;
26
+ fenceChar = marker;
27
+ }
28
+ else if (marker === fenceChar) {
29
+ inFence = false;
30
+ }
31
+ continue;
32
+ }
33
+ if (inFence)
34
+ continue;
35
+ const m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
36
+ if (m === null)
37
+ continue;
38
+ const title = m[2].trim();
39
+ out.push({ depth: m[1].length, title, line: i + 1, slug: slugify(title) });
40
+ }
41
+ return out;
42
+ }
43
+ /**
44
+ * Return one section's body: the heading plus everything until the next
45
+ * heading at the same or shallower depth. Matches on slug or exact title, so
46
+ * a caller can address a section either way.
47
+ */
48
+ export function sectionBody(markdown, wanted) {
49
+ const headings = parseHeadings(markdown);
50
+ const target = slugify(wanted);
51
+ const idx = headings.findIndex((h) => h.slug === target || h.title === wanted);
52
+ if (idx === -1)
53
+ return undefined;
54
+ const start = headings[idx];
55
+ const next = headings.slice(idx + 1).find((h) => h.depth <= start.depth);
56
+ const lines = markdown.split("\n");
57
+ const end = next === undefined ? lines.length : next.line - 1;
58
+ return lines
59
+ .slice(start.line - 1, end)
60
+ .join("\n")
61
+ .trimEnd();
62
+ }
63
+ /** Render the heading tree as an indented outline with line numbers. */
64
+ export function renderOutline(headings, maxDepth) {
65
+ const kept = headings.filter((h) => h.depth <= maxDepth);
66
+ if (kept.length === 0)
67
+ return "(no headings)";
68
+ const shallowest = Math.min(...kept.map((h) => h.depth));
69
+ return kept.map((h) => `${" ".repeat(h.depth - shallowest)}L${h.line} ${h.title}`).join("\n");
70
+ }
71
+ /** Every fenced code block, with its language tag and start line. */
72
+ export function codeBlocks(markdown) {
73
+ const out = [];
74
+ const lines = markdown.split("\n");
75
+ let open;
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const line = lines[i];
78
+ const fence = line.match(/^\s*(`{3,}|~{3,})\s*(\S*)/);
79
+ if (fence !== null) {
80
+ const marker = fence[1][0];
81
+ if (open === undefined) {
82
+ open = { language: fence[2] ?? "", startLine: i + 1, fenceChar: marker, body: [] };
83
+ }
84
+ else if (marker === open.fenceChar) {
85
+ out.push({ language: open.language, code: open.body.join("\n"), line: open.startLine });
86
+ open = undefined;
87
+ }
88
+ continue;
89
+ }
90
+ if (open !== undefined)
91
+ open.body.push(line);
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * Render rows as a GitHub-flavoured markdown table, padded so the source is
97
+ * readable. Column order comes from `columns`, or from the union of keys in
98
+ * first-seen order when it is omitted.
99
+ */
100
+ export function markdownTable(rows, columns) {
101
+ const cols = columns ?? [...new Set(rows.flatMap((r) => Object.keys(r)))].filter((c) => c.length > 0);
102
+ if (cols.length === 0)
103
+ return "";
104
+ const cell = (v) => {
105
+ if (v === null || v === undefined)
106
+ return "";
107
+ const s = typeof v === "object" ? JSON.stringify(v) : String(v);
108
+ // A literal pipe would break the row into extra columns.
109
+ return s.replace(/\|/g, "\\|").replace(/\n/g, " ");
110
+ };
111
+ const body = rows.map((r) => cols.map((c) => cell(r[c])));
112
+ const widths = cols.map((c, i) => Math.max(c.length, ...body.map((r) => r[i].length), 3));
113
+ const pad = (s, w) => s + " ".repeat(w - s.length);
114
+ const lines = [
115
+ `| ${cols.map((c, i) => pad(c, widths[i])).join(" | ")} |`,
116
+ `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`,
117
+ ...body.map((r) => `| ${r.map((v, i) => pad(v, widths[i])).join(" | ")} |`),
118
+ ];
119
+ return lines.join("\n");
120
+ }
@@ -0,0 +1,39 @@
1
+ /** Strip ANSI escapes so a captured terminal log compares as plain text. */
2
+ export declare function stripAnsi(text: string): string;
3
+ export type NormalizeOptions = {
4
+ readonly eol?: "lf" | "crlf";
5
+ readonly trimTrailingWhitespace?: boolean;
6
+ readonly collapseBlankLines?: boolean;
7
+ readonly tabsToSpaces?: number;
8
+ readonly stripAnsiCodes?: boolean;
9
+ readonly stripInvisible?: boolean;
10
+ readonly unicode?: "NFC" | "NFD" | "NFKC" | "NFKD";
11
+ readonly ensureFinalNewline?: boolean;
12
+ readonly lowercase?: boolean;
13
+ };
14
+ /**
15
+ * Canonicalize text so two copies differing only in invisible ways compare
16
+ * equal. Run this before hashing, diffing, or de-duplicating; it is why a
17
+ * "file changed" check stops firing on a line-ending flip.
18
+ */
19
+ export declare function normalizeText(text: string, opts: NormalizeOptions): string;
20
+ export type SortOptions = {
21
+ readonly order?: "asc" | "desc";
22
+ readonly unique?: boolean;
23
+ readonly numeric?: boolean;
24
+ readonly ignoreCase?: boolean;
25
+ readonly field?: number;
26
+ readonly delimiter?: string;
27
+ readonly keepEmpty?: boolean;
28
+ };
29
+ /** Sort, de-duplicate, and optionally key lines by a delimited field. */
30
+ export declare function sortLines(text: string, opts: SortOptions): string[];
31
+ /**
32
+ * Replace terms using operator-supplied mappings, longest term first so a
33
+ * multi-word entry wins over a substring of itself. Whole-word by default,
34
+ * which is what stops "AI" rewriting the middle of "CHAIN".
35
+ */
36
+ export declare function glossaryReplace(text: string, mapping: Readonly<Record<string, string>>, wholeWord: boolean, caseSensitive: boolean): {
37
+ text: string;
38
+ replacements: Record<string, number>;
39
+ };
@@ -0,0 +1,122 @@
1
+ import { escapeFor } from "./format";
2
+ /** CSI/SGR escapes emitted by terminals into captured output. */
3
+ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
4
+ /** Strip ANSI escapes so a captured terminal log compares as plain text. */
5
+ export function stripAnsi(text) {
6
+ return text.replace(ANSI_PATTERN, "");
7
+ }
8
+ /**
9
+ * Zero-width and bidirectional-control characters that survive copy-paste and
10
+ * silently break diffs and equality checks. Built from explicit code points
11
+ * rather than written as a character range: a range spanning the zero-width
12
+ * joiner can split a joined sequence, which is what
13
+ * `noMisleadingCharacterClass` warns about.
14
+ */
15
+ const INVISIBLE_CODE_POINTS = [
16
+ 0x200b, // zero-width space
17
+ 0x200c, // zero-width non-joiner
18
+ 0x200d, // zero-width joiner
19
+ 0xfeff, // byte-order mark
20
+ 0x2060, // word joiner
21
+ 0x180e, // Mongolian vowel separator
22
+ 0x202a, // bidi embedding and override
23
+ 0x202b,
24
+ 0x202c,
25
+ 0x202d,
26
+ 0x202e,
27
+ 0x2066, // bidi isolates
28
+ 0x2067,
29
+ 0x2068,
30
+ 0x2069,
31
+ ];
32
+ const INVISIBLE_PATTERN = new RegExp(INVISIBLE_CODE_POINTS.map((cp) => `\\u${cp.toString(16).padStart(4, "0")}`).join("|"), "g");
33
+ /**
34
+ * Canonicalize text so two copies differing only in invisible ways compare
35
+ * equal. Run this before hashing, diffing, or de-duplicating; it is why a
36
+ * "file changed" check stops firing on a line-ending flip.
37
+ */
38
+ export function normalizeText(text, opts) {
39
+ let out = text;
40
+ if (opts.stripAnsiCodes === true)
41
+ out = stripAnsi(out);
42
+ if (opts.stripInvisible === true)
43
+ out = out.replace(INVISIBLE_PATTERN, "");
44
+ if (opts.unicode !== undefined)
45
+ out = out.normalize(opts.unicode);
46
+ if (opts.tabsToSpaces !== undefined)
47
+ out = out.replace(/\t/g, " ".repeat(opts.tabsToSpaces));
48
+ // Fold to LF first so later rules see one shape, then convert at the end.
49
+ out = out.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
50
+ if (opts.trimTrailingWhitespace === true) {
51
+ out = out
52
+ .split("\n")
53
+ .map((l) => l.replace(/[ \t]+$/, ""))
54
+ .join("\n");
55
+ }
56
+ if (opts.collapseBlankLines === true)
57
+ out = out.replace(/\n{3,}/g, "\n\n");
58
+ if (opts.lowercase === true)
59
+ out = out.toLowerCase();
60
+ if (opts.ensureFinalNewline === true && !out.endsWith("\n"))
61
+ out += "\n";
62
+ if (opts.eol === "crlf")
63
+ out = out.replace(/\n/g, "\r\n");
64
+ return out;
65
+ }
66
+ /** Sort, de-duplicate, and optionally key lines by a delimited field. */
67
+ export function sortLines(text, opts) {
68
+ let lines = text.split("\n");
69
+ if (opts.keepEmpty !== true)
70
+ lines = lines.filter((l) => l.trim() !== "");
71
+ const keyOf = (line) => {
72
+ const base = opts.field !== undefined ? (line.split(opts.delimiter ?? "\t")[opts.field] ?? "") : line;
73
+ return opts.ignoreCase === true ? base.toLowerCase() : base;
74
+ };
75
+ if (opts.unique === true) {
76
+ const seen = new Set();
77
+ lines = lines.filter((l) => {
78
+ const k = keyOf(l);
79
+ if (seen.has(k))
80
+ return false;
81
+ seen.add(k);
82
+ return true;
83
+ });
84
+ }
85
+ const sorted = [...lines].sort((a, b) => {
86
+ const ka = keyOf(a);
87
+ const kb = keyOf(b);
88
+ if (opts.numeric === true) {
89
+ const na = Number.parseFloat(ka);
90
+ const nb = Number.parseFloat(kb);
91
+ const aNum = Number.isNaN(na) ? Number.POSITIVE_INFINITY : na;
92
+ const bNum = Number.isNaN(nb) ? Number.POSITIVE_INFINITY : nb;
93
+ if (aNum !== bNum)
94
+ return aNum - bNum;
95
+ return ka.localeCompare(kb);
96
+ }
97
+ return ka.localeCompare(kb);
98
+ });
99
+ return opts.order === "desc" ? sorted.reverse() : sorted;
100
+ }
101
+ /**
102
+ * Replace terms using operator-supplied mappings, longest term first so a
103
+ * multi-word entry wins over a substring of itself. Whole-word by default,
104
+ * which is what stops "AI" rewriting the middle of "CHAIN".
105
+ */
106
+ export function glossaryReplace(text, mapping, wholeWord, caseSensitive) {
107
+ const replacements = {};
108
+ let out = text;
109
+ for (const term of Object.keys(mapping).sort((a, b) => b.length - a.length)) {
110
+ const escaped = escapeFor(term, "regex");
111
+ const source = wholeWord ? `(?<![\\w-])${escaped}(?![\\w-])` : escaped;
112
+ const re = new RegExp(source, caseSensitive ? "g" : "gi");
113
+ let count = 0;
114
+ out = out.replace(re, () => {
115
+ count++;
116
+ return mapping[term];
117
+ });
118
+ if (count > 0)
119
+ replacements[term] = count;
120
+ }
121
+ return { text: out, replacements };
122
+ }
@@ -0,0 +1,20 @@
1
+ /** A single regex hit, located by offset AND by line/column. */
2
+ export type RegexMatch = {
3
+ readonly match: string;
4
+ readonly index: number;
5
+ readonly line: number;
6
+ readonly column: number;
7
+ readonly groups: Readonly<Record<string, string>>;
8
+ readonly captures: ReadonlyArray<string | undefined>;
9
+ };
10
+ /**
11
+ * Run a regex over text and return located matches.
12
+ *
13
+ * Always compiles with `g` so the scan advances, and a zero-width match bumps
14
+ * `lastIndex` by one so a pattern like `a*` terminates instead of spinning
15
+ * forever at the same offset.
16
+ */
17
+ export declare function regexExtractAll(text: string, pattern: string, flags: string, maxMatches: number): {
18
+ matches: RegexMatch[];
19
+ truncated: boolean;
20
+ };