1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184 |
x2
x2
x2
x2
x2
x2
x2
x2
x2
x21
x21
x21
x21
x33
x33
x33
x33
x33
x33
x33
x33
x103
x103
x103
x124
x31
x103
x103
x103
x103
x69
x69
x69
x69
x11
x11
x11
x11
x9
x9
x9
x9
x9
x9
x69
x41
x41
x41
x41
x41
x41
x41
x69
x11
x11
x9
x9
x9
x69
x3
x3
x1
x1
x1
x69
x3
x69
x69
x25
x25
x25
x25
x103
x103
x103
x103
x103
x8
x8
x103
x33
x41
x41
x41
x41
x33
x21
x21
x13
x21
x2
x28
x28
x28
x28
x2
x49
x49
x2
x44
x44
x2
x4
x4
|
|
const HUNK_HEADER = /^@@ -(?<ai>\d+)(?:,(?<aj>\d+))? \+(?<bi>\d+)(?:,(?<bj>\d+))? @@/
const NO_NEWLINE = "\\ No newline at end of file"
const ANSI_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TXZcf-nq-uy=><~]))",
].join("|"),
"g",
)
export function blame<T = string>(patches: string[], options: BlameOptions<T> = {}): Array<[line: string, meta: T]> {
const { parser = header as unknown as NonNullable<BlameOptions<T>["parser"]> } = options
const entries = [] as Array<[line: string, meta: T]>
const errors = []
for (let p = 0; p < patches.length; p++) {
const meta = parser(patches[p], p)
const clean = patches[p].replace(ANSI_PATTERN, "")
const patchable = clean.trim() ? clean.replace(/\n$/, "").split("\n") : []
const removed = new Map<string, T[]>()
const added = [] as Array<[line: string, meta: T]>
let offset = 0
let k = 0
for (let i = 0; i < patchable.length; i++) {
const head = patchable[i].match(HUNK_HEADER)
if (!head)
continue
const { ai, aj, bj } = Object.fromEntries(Object.entries(head.groups!).map(([k, v]) => [k, Number(v ?? 1)]))
try {
let j = (aj === 0 ? ai : ai - 1) + offset
const count = { aj: 0, bj: 0, context: 0 }
k++
while ((++i < patchable.length) && (!HUNK_HEADER.test(patchable[i]))) {
const diff = patchable[i]
const token = `${diff.slice(1)}${patchable[i + 1] === NO_NEWLINE ? "" : "\n"}`
switch (true) {
case diff.startsWith("-"): {
const [deletion] = entries.splice(j, 1)
count.aj++
if (deletion?.[0] !== token)
throw new SyntaxError(`Patch ${k}: line ${j} mismatch (expected "${diff.slice(1).trim()}", actual "${(deletion?.[0] ?? "").trim()}")`)
const key = token.replace(/\n$/, "")
if (!removed.has(key))
removed.set(key, [])
removed.get(key)!.push(deletion[1])
break
}
case diff.startsWith("+"): {
const entry = [token, copy(meta)] as [line: string, meta: T]
entries.splice(j, 0, entry)
added.push(entry)
j++
count.bj++
break
}
case diff.startsWith(" "):
if (entries[j]?.[0] !== token)
throw new SyntaxError(`Patch ${k}: line ${j} mismatch (expected "${diff.slice(1).trim()}", actual "${(entries[j]?.[0] ?? "").trim()}")`)
j++
count.context++
break
case diff === "":
if (entries[j]?.[0] !== "\n")
throw new SyntaxError(`Patch ${k}: line ${j} mismatch (expected "", actual "${(entries[j]?.[0] ?? "").trim()}")`)
j++
count.context++
break
case diff === NO_NEWLINE:
break
}
}
i--
offset += count.bj - count.aj
count.bj += count.context
count.aj += count.context
if (count.bj !== bj)
throw new SyntaxError(`Patch ${k}: hunk header text b count mismatch (expected ${bj}, actual ${count.bj})`)
if (count.aj !== aj)
throw new SyntaxError(`Patch ${k}: hunk header text a count mismatch (expected ${aj}, actual ${count.aj})`)
} catch (error) {
errors.push(error)
}
}
for (const entry of added) {
const pool = removed.get(entry[0].replace(/\n$/, ""))
if (pool?.length)
entry[1] = merge(pool.shift()!, meta)
}
}
if (errors.length)
throw new AggregateError(errors, entries.map(([line]) => line).join(""))
return entries
}
function header(patch: string, index: number): string {
const lines = patch.replace(ANSI_PATTERN, "").split("\n")
const i = lines.findIndex((line) => line.startsWith("--- "))
return (~i ? lines.slice(0, i).join("\n").trim() : "") || `${index}`
}
function object(meta: unknown): meta is Record<PropertyKey, unknown> {
return (typeof meta === "object") && (meta !== null) && (!Array.isArray(meta))
}
function copy<T>(meta: T): T {
return object(meta) ? { ...meta } as T : meta
}
function merge<T>(previous: T, meta: T): T {
return (object(previous) && object(meta)) ? { ...previous, ...meta } as T : copy(meta)
}
export type BlameOptions<T = string> = {
parser?: (patch: string, index: number) => T
}
|