@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.
- package/README.md +62 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +575 -0
- package/dist/lib/diff.d.ts +82 -0
- package/dist/lib/diff.js +541 -0
- package/dist/lib/entities.d.ts +15 -0
- package/dist/lib/entities.js +56 -0
- package/dist/lib/format.d.ts +33 -0
- package/dist/lib/format.js +107 -0
- package/dist/lib/locate.d.ts +24 -0
- package/dist/lib/locate.js +43 -0
- package/dist/lib/log.d.ts +34 -0
- package/dist/lib/log.js +63 -0
- package/dist/lib/markdown.d.ts +34 -0
- package/dist/lib/markdown.js +120 -0
- package/dist/lib/normalize.d.ts +39 -0
- package/dist/lib/normalize.js +122 -0
- package/dist/lib/regex.d.ts +20 -0
- package/dist/lib/regex.js +36 -0
- package/dist/lib/similarity.d.ts +33 -0
- package/dist/lib/similarity.js +216 -0
- package/dist/lib/template.d.ts +42 -0
- package/dist/lib/template.js +88 -0
- package/package.json +45 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type DiffOp = {
|
|
2
|
+
readonly kind: "eq" | "del" | "ins";
|
|
3
|
+
readonly line: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Line-level diff via the classic longest-common-subsequence dynamic
|
|
7
|
+
* program. O(n*m) in time and memory, which is why the tool caps its input:
|
|
8
|
+
* diffing two 50k-line files would allocate a 2.5-billion-cell table.
|
|
9
|
+
* Callers with bigger inputs should diff a narrowed region instead.
|
|
10
|
+
*/
|
|
11
|
+
export declare function diffLines(a: ReadonlyArray<string>, b: ReadonlyArray<string>): DiffOp[];
|
|
12
|
+
export type DiffStats = {
|
|
13
|
+
readonly added: number;
|
|
14
|
+
readonly removed: number;
|
|
15
|
+
readonly same: number;
|
|
16
|
+
};
|
|
17
|
+
/** Count each op kind — the "+12 -3" summary a reviewer actually reads. */
|
|
18
|
+
export declare function diffStats(ops: ReadonlyArray<DiffOp>): DiffStats;
|
|
19
|
+
/**
|
|
20
|
+
* Render ops as a unified diff keeping `context` unchanged lines around each
|
|
21
|
+
* change. Long runs of unchanged lines collapse, which is the entire point:
|
|
22
|
+
* the reader sees the change, not the file.
|
|
23
|
+
*/
|
|
24
|
+
export declare function renderUnified(ops: ReadonlyArray<DiffOp>, aLabel: string, bLabel: string, context: number): string;
|
|
25
|
+
/** What a body line does to the file: unchanged, introduced, or taken away. */
|
|
26
|
+
export type DiffLineKind = "context" | "added" | "removed";
|
|
27
|
+
export type ParsedDiffLine = {
|
|
28
|
+
readonly kind: DiffLineKind;
|
|
29
|
+
/** 1-based line number in the NEW file; null on a removed line, which has none. */
|
|
30
|
+
readonly newLine: number | null;
|
|
31
|
+
/** 1-based line number in the OLD file; null on an added line. */
|
|
32
|
+
readonly oldLine: number | null;
|
|
33
|
+
/** The content with the marker column removed — never including the marker. */
|
|
34
|
+
readonly text: string;
|
|
35
|
+
/** A `` marker followed this line. */
|
|
36
|
+
readonly noNewline?: boolean;
|
|
37
|
+
};
|
|
38
|
+
export type ParsedHunk = {
|
|
39
|
+
readonly oldStart: number;
|
|
40
|
+
readonly oldCount: number;
|
|
41
|
+
readonly newStart: number;
|
|
42
|
+
readonly newCount: number;
|
|
43
|
+
/** What git prints after the closing `@@` — usually the enclosing function. */
|
|
44
|
+
readonly section: string;
|
|
45
|
+
readonly lines: ReadonlyArray<ParsedDiffLine>;
|
|
46
|
+
};
|
|
47
|
+
export type DiffFileStatus = "added" | "deleted" | "modified" | "renamed" | "copied";
|
|
48
|
+
export type ParsedDiffFile = {
|
|
49
|
+
/** Path in the old tree, prefix stripped; null when the file was added (`/dev/null`). */
|
|
50
|
+
readonly oldPath: string | null;
|
|
51
|
+
/** Path in the new tree, prefix stripped; null when the file was deleted. */
|
|
52
|
+
readonly newPath: string | null;
|
|
53
|
+
readonly status: DiffFileStatus;
|
|
54
|
+
readonly binary: boolean;
|
|
55
|
+
readonly renamed: boolean;
|
|
56
|
+
readonly added: number;
|
|
57
|
+
readonly removed: number;
|
|
58
|
+
/** The old file's last line had no terminating newline. */
|
|
59
|
+
readonly oldNoFinalNewline: boolean;
|
|
60
|
+
/** The new file's last line had no terminating newline. */
|
|
61
|
+
readonly newNoFinalNewline: boolean;
|
|
62
|
+
readonly oldMode?: string;
|
|
63
|
+
readonly newMode?: string;
|
|
64
|
+
/** Percentage from a `similarity index` line, on a rename or a copy. */
|
|
65
|
+
readonly similarity?: number;
|
|
66
|
+
/** Empty for a rename, a mode change or a binary file — those carry no body. */
|
|
67
|
+
readonly hunks: ReadonlyArray<ParsedHunk>;
|
|
68
|
+
};
|
|
69
|
+
export type ParsedDiff = {
|
|
70
|
+
readonly files: ReadonlyArray<ParsedDiffFile>;
|
|
71
|
+
/** Everything the parser could not account for, so a caller is never silently short. */
|
|
72
|
+
readonly warnings: ReadonlyArray<string>;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Parse a unified diff — `git diff`, `git show`, `diff -u`, a patch file —
|
|
76
|
+
* into files, hunks and lines, each line carrying its number in the new file.
|
|
77
|
+
*
|
|
78
|
+
* Unparseable input never throws and never guesses: whatever could not be
|
|
79
|
+
* accounted for comes back in `warnings`, because a caller about to edit a
|
|
80
|
+
* file from these numbers needs to know the parse was incomplete.
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseUnifiedDiff(diff: string): ParsedDiff;
|
package/dist/lib/diff.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-level diff via the classic longest-common-subsequence dynamic
|
|
3
|
+
* program. O(n*m) in time and memory, which is why the tool caps its input:
|
|
4
|
+
* diffing two 50k-line files would allocate a 2.5-billion-cell table.
|
|
5
|
+
* Callers with bigger inputs should diff a narrowed region instead.
|
|
6
|
+
*/
|
|
7
|
+
export function diffLines(a, b) {
|
|
8
|
+
const n = a.length;
|
|
9
|
+
const m = b.length;
|
|
10
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
11
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
12
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
13
|
+
const row = lcs[i];
|
|
14
|
+
const next = lcs[i + 1];
|
|
15
|
+
row[j] =
|
|
16
|
+
a[i] === b[j]
|
|
17
|
+
? next[j + 1] + 1
|
|
18
|
+
: Math.max(next[j], row[j + 1]);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const ops = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
let j = 0;
|
|
24
|
+
while (i < n && j < m) {
|
|
25
|
+
if (a[i] === b[j]) {
|
|
26
|
+
ops.push({ kind: "eq", line: a[i] });
|
|
27
|
+
i++;
|
|
28
|
+
j++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const down = lcs[i + 1][j];
|
|
32
|
+
const right = lcs[i][j + 1];
|
|
33
|
+
if (down >= right) {
|
|
34
|
+
ops.push({ kind: "del", line: a[i] });
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
ops.push({ kind: "ins", line: b[j] });
|
|
39
|
+
j++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
while (i < n)
|
|
43
|
+
ops.push({ kind: "del", line: a[i++] });
|
|
44
|
+
while (j < m)
|
|
45
|
+
ops.push({ kind: "ins", line: b[j++] });
|
|
46
|
+
return ops;
|
|
47
|
+
}
|
|
48
|
+
/** Count each op kind — the "+12 -3" summary a reviewer actually reads. */
|
|
49
|
+
export function diffStats(ops) {
|
|
50
|
+
let added = 0;
|
|
51
|
+
let removed = 0;
|
|
52
|
+
let same = 0;
|
|
53
|
+
for (const op of ops) {
|
|
54
|
+
if (op.kind === "ins")
|
|
55
|
+
added++;
|
|
56
|
+
else if (op.kind === "del")
|
|
57
|
+
removed++;
|
|
58
|
+
else
|
|
59
|
+
same++;
|
|
60
|
+
}
|
|
61
|
+
return { added, removed, same };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Render ops as a unified diff keeping `context` unchanged lines around each
|
|
65
|
+
* change. Long runs of unchanged lines collapse, which is the entire point:
|
|
66
|
+
* the reader sees the change, not the file.
|
|
67
|
+
*/
|
|
68
|
+
export function renderUnified(ops, aLabel, bLabel, context) {
|
|
69
|
+
const keep = new Array(ops.length).fill(false);
|
|
70
|
+
for (let i = 0; i < ops.length; i++) {
|
|
71
|
+
if (ops[i]?.kind === "eq")
|
|
72
|
+
continue;
|
|
73
|
+
for (let j = Math.max(0, i - context); j <= Math.min(ops.length - 1, i + context); j++) {
|
|
74
|
+
keep[j] = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const out = [`--- ${aLabel}`, `+++ ${bLabel}`];
|
|
78
|
+
let aLine = 1;
|
|
79
|
+
let bLine = 1;
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < ops.length) {
|
|
82
|
+
if (!keep[i]) {
|
|
83
|
+
const op = ops[i];
|
|
84
|
+
if (op.kind !== "ins")
|
|
85
|
+
aLine++;
|
|
86
|
+
if (op.kind !== "del")
|
|
87
|
+
bLine++;
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const hunkStartA = aLine;
|
|
92
|
+
const hunkStartB = bLine;
|
|
93
|
+
const body = [];
|
|
94
|
+
let aCount = 0;
|
|
95
|
+
let bCount = 0;
|
|
96
|
+
while (i < ops.length && keep[i]) {
|
|
97
|
+
const op = ops[i];
|
|
98
|
+
if (op.kind === "eq") {
|
|
99
|
+
body.push(` ${op.line}`);
|
|
100
|
+
aLine++;
|
|
101
|
+
bLine++;
|
|
102
|
+
aCount++;
|
|
103
|
+
bCount++;
|
|
104
|
+
}
|
|
105
|
+
else if (op.kind === "del") {
|
|
106
|
+
body.push(`-${op.line}`);
|
|
107
|
+
aLine++;
|
|
108
|
+
aCount++;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
body.push(`+${op.line}`);
|
|
112
|
+
bLine++;
|
|
113
|
+
bCount++;
|
|
114
|
+
}
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
out.push(`@@ -${hunkStartA},${aCount} +${hunkStartB},${bCount} @@`);
|
|
118
|
+
out.push(...body);
|
|
119
|
+
}
|
|
120
|
+
return out.join("\n");
|
|
121
|
+
}
|
|
122
|
+
/** Standard hunk header. A missing count means 1 — `@@ -1 +1 @@` is one line. */
|
|
123
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
|
|
124
|
+
/** A merge/combined diff (`@@@ -1,2 -1,2 +1,3 @@@`) carries one column per parent. */
|
|
125
|
+
const COMBINED_HEADER = /^@@@+ /;
|
|
126
|
+
/**
|
|
127
|
+
* Undo git's C-style path quoting. git quotes only when it has to — a control
|
|
128
|
+
* character, a quote, a backslash, or (unless `core.quotePath=false`) a
|
|
129
|
+
* non-ASCII byte — and it escapes per BYTE, so the octal escapes have to be
|
|
130
|
+
* reassembled into bytes and decoded as UTF-8. Decoding them as code points
|
|
131
|
+
* turns every accented filename into mojibake.
|
|
132
|
+
*/
|
|
133
|
+
function dequotePath(raw) {
|
|
134
|
+
if (raw.length < 2 || !raw.startsWith('"') || !raw.endsWith('"'))
|
|
135
|
+
return raw;
|
|
136
|
+
const encoder = new TextEncoder();
|
|
137
|
+
const body = raw.slice(1, -1);
|
|
138
|
+
const bytes = [];
|
|
139
|
+
const simple = {
|
|
140
|
+
a: 7,
|
|
141
|
+
b: 8,
|
|
142
|
+
t: 9,
|
|
143
|
+
n: 10,
|
|
144
|
+
v: 11,
|
|
145
|
+
f: 12,
|
|
146
|
+
r: 13,
|
|
147
|
+
'"': 34,
|
|
148
|
+
"\\": 92,
|
|
149
|
+
};
|
|
150
|
+
for (let i = 0; i < body.length; i++) {
|
|
151
|
+
const ch = body[i];
|
|
152
|
+
if (ch !== "\\") {
|
|
153
|
+
for (const b of encoder.encode(ch))
|
|
154
|
+
bytes.push(b);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const next = body[i + 1];
|
|
158
|
+
if (next === undefined)
|
|
159
|
+
break;
|
|
160
|
+
i++;
|
|
161
|
+
const mapped = simple[next];
|
|
162
|
+
if (mapped !== undefined) {
|
|
163
|
+
bytes.push(mapped);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (next >= "0" && next <= "7") {
|
|
167
|
+
let octal = next;
|
|
168
|
+
while (octal.length < 3) {
|
|
169
|
+
const digit = body[i + 1];
|
|
170
|
+
if (digit === undefined || digit < "0" || digit > "7")
|
|
171
|
+
break;
|
|
172
|
+
octal += digit;
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
bytes.push(Number.parseInt(octal, 8) & 0xff);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const b of encoder.encode(next))
|
|
179
|
+
bytes.push(b);
|
|
180
|
+
}
|
|
181
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
182
|
+
}
|
|
183
|
+
/** Drop the `a/`/`b/` diff prefix; `/dev/null` means the file is absent on that side. */
|
|
184
|
+
function stripPrefix(value, prefix) {
|
|
185
|
+
// Deliberately no trim: a leading or trailing space is part of the filename,
|
|
186
|
+
// and git does not quote one. Every caller slices to an exact boundary.
|
|
187
|
+
const v = dequotePath(value);
|
|
188
|
+
if (v === "/dev/null")
|
|
189
|
+
return null;
|
|
190
|
+
return v.startsWith(prefix) ? v.slice(prefix.length) : v;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Read a path off a `---`/`+++` line. Plain `diff -u` appends a tab and a
|
|
194
|
+
* timestamp, which is not part of the name.
|
|
195
|
+
*/
|
|
196
|
+
function headerPath(rest, prefix) {
|
|
197
|
+
const tab = rest.indexOf("\t");
|
|
198
|
+
return stripPrefix(tab === -1 ? rest : rest.slice(0, tab), prefix);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Split `a/<old> b/<new>` off a `diff --git` line. Paths may contain spaces —
|
|
202
|
+
* git does not quote those — so the only sound split is the one where both
|
|
203
|
+
* halves name the same file, which is every case except a rename or a copy.
|
|
204
|
+
* Those carry explicit `rename from`/`rename to` lines that overwrite this
|
|
205
|
+
* guess, so the fallback only has to be reasonable, not right.
|
|
206
|
+
*/
|
|
207
|
+
function splitGitHeader(rest) {
|
|
208
|
+
// Quoting is decided per path, so a rename can quote one side only. Either
|
|
209
|
+
// quote is an unambiguous split point, which beats guessing at the spaces.
|
|
210
|
+
if (rest.startsWith('"')) {
|
|
211
|
+
let i = 1;
|
|
212
|
+
while (i < rest.length) {
|
|
213
|
+
if (rest[i] === "\\")
|
|
214
|
+
i += 2;
|
|
215
|
+
else if (rest[i] === '"')
|
|
216
|
+
break;
|
|
217
|
+
else
|
|
218
|
+
i++;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
old: stripPrefix(rest.slice(0, i + 1), "a/"),
|
|
222
|
+
new: stripPrefix(rest.slice(i + 2), "b/"),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const quotedNew = rest.indexOf(' "b/');
|
|
226
|
+
if (quotedNew !== -1) {
|
|
227
|
+
return {
|
|
228
|
+
old: stripPrefix(rest.slice(0, quotedNew), "a/"),
|
|
229
|
+
new: stripPrefix(rest.slice(quotedNew + 1), "b/"),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
let fallback = null;
|
|
233
|
+
for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) {
|
|
234
|
+
const left = stripPrefix(rest.slice(0, at), "a/");
|
|
235
|
+
const right = stripPrefix(rest.slice(at + 1), "b/");
|
|
236
|
+
if (left !== null && left === right)
|
|
237
|
+
return { old: left, new: right };
|
|
238
|
+
fallback ??= { old: left, new: right };
|
|
239
|
+
}
|
|
240
|
+
return fallback ?? { old: null, new: null };
|
|
241
|
+
}
|
|
242
|
+
function newFileDraft() {
|
|
243
|
+
return {
|
|
244
|
+
guessOld: null,
|
|
245
|
+
guessNew: null,
|
|
246
|
+
headerOld: null,
|
|
247
|
+
headerNew: null,
|
|
248
|
+
sawOldHeader: false,
|
|
249
|
+
sawNewHeader: false,
|
|
250
|
+
status: "modified",
|
|
251
|
+
binary: false,
|
|
252
|
+
renamed: false,
|
|
253
|
+
added: 0,
|
|
254
|
+
removed: 0,
|
|
255
|
+
oldNoFinalNewline: false,
|
|
256
|
+
newNoFinalNewline: false,
|
|
257
|
+
hunks: [],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function finishFile(draft) {
|
|
261
|
+
// `---`/`+++` win when present: they are what a patch applier reads, and
|
|
262
|
+
// they are the only place `/dev/null` appears. The `diff --git` guess and
|
|
263
|
+
// the rename stanza are the fallback, for a file with no body at all.
|
|
264
|
+
return {
|
|
265
|
+
oldPath: draft.sawOldHeader ? draft.headerOld : draft.guessOld,
|
|
266
|
+
newPath: draft.sawNewHeader ? draft.headerNew : draft.guessNew,
|
|
267
|
+
status: draft.status,
|
|
268
|
+
binary: draft.binary,
|
|
269
|
+
renamed: draft.renamed,
|
|
270
|
+
added: draft.added,
|
|
271
|
+
removed: draft.removed,
|
|
272
|
+
oldNoFinalNewline: draft.oldNoFinalNewline,
|
|
273
|
+
newNoFinalNewline: draft.newNoFinalNewline,
|
|
274
|
+
...(draft.oldMode !== undefined ? { oldMode: draft.oldMode } : {}),
|
|
275
|
+
...(draft.newMode !== undefined ? { newMode: draft.newMode } : {}),
|
|
276
|
+
...(draft.similarity !== undefined ? { similarity: draft.similarity } : {}),
|
|
277
|
+
hunks: draft.hunks,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Split the diff into lines. A diff transported as CRLF has a `\r` on every
|
|
282
|
+
* line including its headers; a LF diff of a CRLF-content file has one only
|
|
283
|
+
* on the content lines, where it is real data. Deciding once, for the whole
|
|
284
|
+
* document, is the only way to tell those apart — per line it is undecidable.
|
|
285
|
+
*/
|
|
286
|
+
function splitDiffLines(diff) {
|
|
287
|
+
const uniformCrlf = diff.includes("\r\n") && !/(^|[^\r])\n/.test(diff);
|
|
288
|
+
const lines = diff.split(uniformCrlf ? "\r\n" : "\n");
|
|
289
|
+
// A trailing newline ends the last line; it does not begin another one.
|
|
290
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
291
|
+
lines.pop();
|
|
292
|
+
return lines;
|
|
293
|
+
}
|
|
294
|
+
/** Structural lines are ASCII markers, so a stray `\r` on one is transport, not data. */
|
|
295
|
+
const structural = (line) => (line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
296
|
+
/**
|
|
297
|
+
* Parse a unified diff — `git diff`, `git show`, `diff -u`, a patch file —
|
|
298
|
+
* into files, hunks and lines, each line carrying its number in the new file.
|
|
299
|
+
*
|
|
300
|
+
* Unparseable input never throws and never guesses: whatever could not be
|
|
301
|
+
* accounted for comes back in `warnings`, because a caller about to edit a
|
|
302
|
+
* file from these numbers needs to know the parse was incomplete.
|
|
303
|
+
*/
|
|
304
|
+
export function parseUnifiedDiff(diff) {
|
|
305
|
+
const lines = splitDiffLines(diff);
|
|
306
|
+
const files = [];
|
|
307
|
+
const warnings = [];
|
|
308
|
+
let draft = null;
|
|
309
|
+
let i = 0;
|
|
310
|
+
const flush = () => {
|
|
311
|
+
if (draft !== null)
|
|
312
|
+
files.push(finishFile(draft));
|
|
313
|
+
draft = null;
|
|
314
|
+
};
|
|
315
|
+
/** Meta lines only make sense inside a stanza; tolerate a patch that omits one. */
|
|
316
|
+
const current = () => {
|
|
317
|
+
if (draft === null)
|
|
318
|
+
draft = newFileDraft();
|
|
319
|
+
return draft;
|
|
320
|
+
};
|
|
321
|
+
while (i < lines.length) {
|
|
322
|
+
const line = structural(lines[i]);
|
|
323
|
+
if (line.startsWith("diff --git ")) {
|
|
324
|
+
flush();
|
|
325
|
+
const split = splitGitHeader(line.slice("diff --git ".length));
|
|
326
|
+
const fresh = newFileDraft();
|
|
327
|
+
fresh.guessOld = split.old;
|
|
328
|
+
fresh.guessNew = split.new;
|
|
329
|
+
draft = fresh;
|
|
330
|
+
i++;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// Plain `diff -u` has no `diff --git` line, so the `---`/`+++` pair is
|
|
334
|
+
// what starts a file. Requiring the `+++` on the next line is what keeps
|
|
335
|
+
// `git format-patch`'s bare `---` separator from opening an empty stanza.
|
|
336
|
+
if (line.startsWith("--- ") && structural(lines[i + 1] ?? "").startsWith("+++ ")) {
|
|
337
|
+
if (draft === null || draft.sawOldHeader || draft.hunks.length > 0) {
|
|
338
|
+
flush();
|
|
339
|
+
draft = newFileDraft();
|
|
340
|
+
}
|
|
341
|
+
const file = current();
|
|
342
|
+
file.sawOldHeader = true;
|
|
343
|
+
file.headerOld = headerPath(line.slice(4), "a/");
|
|
344
|
+
if (file.headerOld === null)
|
|
345
|
+
file.status = "added";
|
|
346
|
+
i++;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (line.startsWith("+++ ") && draft !== null && draft.sawOldHeader) {
|
|
350
|
+
draft.sawNewHeader = true;
|
|
351
|
+
draft.headerNew = headerPath(line.slice(4), "b/");
|
|
352
|
+
if (draft.headerNew === null)
|
|
353
|
+
draft.status = "deleted";
|
|
354
|
+
i++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (COMBINED_HEADER.test(line)) {
|
|
358
|
+
// A merge diff has one marker column per parent, so single-column line
|
|
359
|
+
// numbers would be wrong. Refusing beats returning a plausible lie.
|
|
360
|
+
warnings.push(`line ${i + 1}: combined (merge) diff hunk skipped — it has one column per parent, which this parser does not number`);
|
|
361
|
+
i++;
|
|
362
|
+
while (i < lines.length && !structural(lines[i]).startsWith("diff "))
|
|
363
|
+
i++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (HUNK_HEADER.test(line)) {
|
|
367
|
+
i = readHunk(lines, i, current(), warnings);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (line.startsWith("Binary files ") || line.startsWith("Binary file ")) {
|
|
371
|
+
current().binary = true;
|
|
372
|
+
i++;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (line === "GIT binary patch") {
|
|
376
|
+
// The payload is base85, whose lines begin with a length letter and
|
|
377
|
+
// would otherwise read as stray content. Skip to the next stanza.
|
|
378
|
+
current().binary = true;
|
|
379
|
+
i++;
|
|
380
|
+
while (i < lines.length && !structural(lines[i]).startsWith("diff "))
|
|
381
|
+
i++;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (draft !== null) {
|
|
385
|
+
const file = draft;
|
|
386
|
+
const meta = (prefix) =>
|
|
387
|
+
// No trim: the rest of a `rename from` line is the path, spaces and all.
|
|
388
|
+
line.startsWith(prefix) ? line.slice(prefix.length) : null;
|
|
389
|
+
const newFileMode = meta("new file mode ");
|
|
390
|
+
const deletedFileMode = meta("deleted file mode ");
|
|
391
|
+
const oldMode = meta("old mode ");
|
|
392
|
+
const newMode = meta("new mode ");
|
|
393
|
+
const renameFrom = meta("rename from ");
|
|
394
|
+
const renameTo = meta("rename to ");
|
|
395
|
+
const copyFrom = meta("copy from ");
|
|
396
|
+
const copyTo = meta("copy to ");
|
|
397
|
+
const similarity = meta("similarity index ");
|
|
398
|
+
if (newFileMode !== null) {
|
|
399
|
+
file.status = "added";
|
|
400
|
+
file.newMode = newFileMode;
|
|
401
|
+
}
|
|
402
|
+
else if (deletedFileMode !== null) {
|
|
403
|
+
file.status = "deleted";
|
|
404
|
+
file.oldMode = deletedFileMode;
|
|
405
|
+
}
|
|
406
|
+
else if (oldMode !== null) {
|
|
407
|
+
file.oldMode = oldMode;
|
|
408
|
+
}
|
|
409
|
+
else if (newMode !== null) {
|
|
410
|
+
file.newMode = newMode;
|
|
411
|
+
}
|
|
412
|
+
else if (renameFrom !== null) {
|
|
413
|
+
file.renamed = true;
|
|
414
|
+
file.status = "renamed";
|
|
415
|
+
file.guessOld = dequotePath(renameFrom);
|
|
416
|
+
}
|
|
417
|
+
else if (renameTo !== null) {
|
|
418
|
+
file.renamed = true;
|
|
419
|
+
file.status = "renamed";
|
|
420
|
+
file.guessNew = dequotePath(renameTo);
|
|
421
|
+
}
|
|
422
|
+
else if (copyFrom !== null) {
|
|
423
|
+
file.status = "copied";
|
|
424
|
+
file.guessOld = dequotePath(copyFrom);
|
|
425
|
+
}
|
|
426
|
+
else if (copyTo !== null) {
|
|
427
|
+
file.status = "copied";
|
|
428
|
+
file.guessNew = dequotePath(copyTo);
|
|
429
|
+
}
|
|
430
|
+
else if (similarity !== null) {
|
|
431
|
+
const pct = Number.parseInt(similarity, 10);
|
|
432
|
+
if (Number.isFinite(pct))
|
|
433
|
+
file.similarity = pct;
|
|
434
|
+
}
|
|
435
|
+
else if (line.startsWith("index ")) {
|
|
436
|
+
// `index <old>..<new> <mode>` — the mode is present only when unchanged.
|
|
437
|
+
const mode = /^index [^ ]+ (\d{6})$/.exec(line)?.[1];
|
|
438
|
+
if (mode !== undefined) {
|
|
439
|
+
file.oldMode ??= mode;
|
|
440
|
+
file.newMode ??= mode;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
i++;
|
|
445
|
+
}
|
|
446
|
+
flush();
|
|
447
|
+
return { files, warnings };
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Consume one hunk, returning the index of the first line after it.
|
|
451
|
+
*
|
|
452
|
+
* The hunk header's counts, not the line markers, decide where the body ends.
|
|
453
|
+
* That matters: removing a line that itself starts with `--` renders as
|
|
454
|
+
* `--- foo`, and an added `+++ b/x` renders as `++++ b/x`. Inside the body
|
|
455
|
+
* the FIRST COLUMN is the marker and everything after it is data, so only the
|
|
456
|
+
* counts can say when the body is over.
|
|
457
|
+
*/
|
|
458
|
+
function readHunk(lines, start, file, warnings) {
|
|
459
|
+
const match = HUNK_HEADER.exec(structural(lines[start]));
|
|
460
|
+
if (match === null)
|
|
461
|
+
return start + 1;
|
|
462
|
+
const oldStart = Number.parseInt(match[1], 10);
|
|
463
|
+
const oldCount = match[2] === undefined ? 1 : Number.parseInt(match[2], 10);
|
|
464
|
+
const newStart = Number.parseInt(match[3], 10);
|
|
465
|
+
const newCount = match[4] === undefined ? 1 : Number.parseInt(match[4], 10);
|
|
466
|
+
let oldLine = oldStart;
|
|
467
|
+
let newLine = newStart;
|
|
468
|
+
let oldLeft = oldCount;
|
|
469
|
+
let newLeft = newCount;
|
|
470
|
+
const body = [];
|
|
471
|
+
let i = start + 1;
|
|
472
|
+
/** `` describes the line above it and costs no count. */
|
|
473
|
+
const markNoNewline = () => {
|
|
474
|
+
const last = body[body.length - 1];
|
|
475
|
+
if (last === undefined)
|
|
476
|
+
return;
|
|
477
|
+
last.noNewline = true;
|
|
478
|
+
if (last.kind !== "added")
|
|
479
|
+
file.oldNoFinalNewline = true;
|
|
480
|
+
if (last.kind !== "removed")
|
|
481
|
+
file.newNoFinalNewline = true;
|
|
482
|
+
};
|
|
483
|
+
while (i < lines.length && (oldLeft > 0 || newLeft > 0)) {
|
|
484
|
+
const raw = lines[i];
|
|
485
|
+
if (raw.startsWith("\\")) {
|
|
486
|
+
markNoNewline();
|
|
487
|
+
i++;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const marker = raw.charAt(0);
|
|
491
|
+
// An entirely empty line is a context line whose single leading space was
|
|
492
|
+
// eaten in transit — mail clients and editors strip trailing whitespace
|
|
493
|
+
// routinely, and git itself never emits one, so this is always that.
|
|
494
|
+
if (marker === " " || raw === "") {
|
|
495
|
+
body.push({ kind: "context", oldLine: oldLine++, newLine: newLine++, text: raw.slice(1) });
|
|
496
|
+
oldLeft--;
|
|
497
|
+
newLeft--;
|
|
498
|
+
}
|
|
499
|
+
else if (marker === "+") {
|
|
500
|
+
body.push({ kind: "added", oldLine: null, newLine: newLine++, text: raw.slice(1) });
|
|
501
|
+
newLeft--;
|
|
502
|
+
file.added++;
|
|
503
|
+
}
|
|
504
|
+
else if (marker === "-") {
|
|
505
|
+
body.push({ kind: "removed", oldLine: oldLine++, newLine: null, text: raw.slice(1) });
|
|
506
|
+
oldLeft--;
|
|
507
|
+
file.removed++;
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
warnings.push(`line ${i + 1}: hunk body ended early at an unrecognized marker ${JSON.stringify(marker)}`);
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
i++;
|
|
514
|
+
}
|
|
515
|
+
if (i < lines.length && lines[i].startsWith("\\")) {
|
|
516
|
+
markNoNewline();
|
|
517
|
+
i++;
|
|
518
|
+
}
|
|
519
|
+
if (oldLeft > 0 || newLeft > 0) {
|
|
520
|
+
warnings.push(`line ${start + 1}: hunk @@ -${oldStart},${oldCount} +${newStart},${newCount} @@ is short by ${oldLeft} old and ${newLeft} new lines — the diff is truncated`);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
// The counts, not the markers, ended the body — so a count that was too
|
|
524
|
+
// small drops real lines silently. A bare `+` after the body can only be
|
|
525
|
+
// that: `+++ ` is the only stanza line starting with one. The `-` side
|
|
526
|
+
// gets no such check, because `-- ` is format-patch's mail signature.
|
|
527
|
+
const after = lines[i];
|
|
528
|
+
if (after?.startsWith("+") && !after.startsWith("+++ ")) {
|
|
529
|
+
warnings.push(`line ${i + 1}: content past the end of hunk @@ -${oldStart},${oldCount} +${newStart},${newCount} @@ — its line counts are too small and lines were dropped`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
file.hunks.push({
|
|
533
|
+
oldStart,
|
|
534
|
+
oldCount,
|
|
535
|
+
newStart,
|
|
536
|
+
newCount,
|
|
537
|
+
section: match[5] ?? "",
|
|
538
|
+
lines: body,
|
|
539
|
+
});
|
|
540
|
+
return i;
|
|
541
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex-only entity extraction. Explicitly NOT named-entity recognition:
|
|
3
|
+
* these find things that have a syntax (a URL, a UUID, a semver), not things
|
|
4
|
+
* that have a meaning (a company, a person). That distinction is exactly
|
|
5
|
+
* what keeps the tool deterministic.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ENTITY_PATTERNS: Readonly<Record<string, RegExp>>;
|
|
8
|
+
/** Every kind `extractEntities` understands, for schema + error messages. */
|
|
9
|
+
export declare const ENTITY_KINDS: readonly string[];
|
|
10
|
+
export type EntityHit = {
|
|
11
|
+
readonly value: string;
|
|
12
|
+
readonly index: number;
|
|
13
|
+
readonly line: number;
|
|
14
|
+
};
|
|
15
|
+
export declare function extractEntities(text: string, kinds: ReadonlyArray<string>, unique: boolean): Record<string, EntityHit[]>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { lineStarts, offsetToLineCol } from "./locate";
|
|
2
|
+
/**
|
|
3
|
+
* Regex-only entity extraction. Explicitly NOT named-entity recognition:
|
|
4
|
+
* these find things that have a syntax (a URL, a UUID, a semver), not things
|
|
5
|
+
* that have a meaning (a company, a person). That distinction is exactly
|
|
6
|
+
* what keeps the tool deterministic.
|
|
7
|
+
*/
|
|
8
|
+
export const ENTITY_PATTERNS = Object.freeze({
|
|
9
|
+
url: /https?:\/\/[^\s<>"'`)\]]+/g,
|
|
10
|
+
email: /[\w.!#$%&'*+/=?^`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g,
|
|
11
|
+
ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g,
|
|
12
|
+
uuid: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
|
|
13
|
+
semver: /\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/g,
|
|
14
|
+
isoDate: /\b\d{4}-\d{2}-\d{2}\b/g,
|
|
15
|
+
time: /\b\d{1,2}:\d{2}(?::\d{2})?\b/g,
|
|
16
|
+
money: /(?:[$£€¥]\s?\d[\d,]*(?:\.\d+)?)|(?:\b\d[\d,]*(?:\.\d+)?\s?(?:USD|EUR|GBP|JPY|CAD|AUD)\b)/g,
|
|
17
|
+
percent: /\b\d+(?:\.\d+)?\s?%/g,
|
|
18
|
+
hashtag: /(?:^|\s)(#[A-Za-z][\w-]*)/g,
|
|
19
|
+
mention: /(?:^|\s)(@[A-Za-z][\w.-]*)/g,
|
|
20
|
+
ticket: /\b[A-Z][A-Z0-9]+-\d+\b/g,
|
|
21
|
+
jwt: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
|
|
22
|
+
macAddress: /\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b/g,
|
|
23
|
+
});
|
|
24
|
+
/** Every kind `extractEntities` understands, for schema + error messages. */
|
|
25
|
+
export const ENTITY_KINDS = Object.freeze(Object.keys(ENTITY_PATTERNS));
|
|
26
|
+
export function extractEntities(text, kinds, unique) {
|
|
27
|
+
const starts = lineStarts(text);
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const kind of kinds) {
|
|
30
|
+
const source = ENTITY_PATTERNS[kind];
|
|
31
|
+
if (source === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
const re = new RegExp(source.source, source.flags);
|
|
34
|
+
const hits = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (;;) {
|
|
37
|
+
const m = re.exec(text);
|
|
38
|
+
if (m === null)
|
|
39
|
+
break;
|
|
40
|
+
const whole = m[0];
|
|
41
|
+
// Patterns with a leading-boundary group (hashtag, mention) report the
|
|
42
|
+
// capture, so the preceding space is not part of the value.
|
|
43
|
+
const value = (m[1] ?? whole);
|
|
44
|
+
const index = m.index + whole.indexOf(value);
|
|
45
|
+
if (whole.length === 0)
|
|
46
|
+
re.lastIndex += 1;
|
|
47
|
+
if (unique && seen.has(value))
|
|
48
|
+
continue;
|
|
49
|
+
seen.add(value);
|
|
50
|
+
hits.push({ value, index, line: offsetToLineCol(starts, index).line });
|
|
51
|
+
}
|
|
52
|
+
if (hits.length > 0)
|
|
53
|
+
out[kind] = hits;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|