All files / diff / blame.ts

100.00% Branches 67/67
100.00% Functions 5/5
100.00% Lines 104/104
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
 
 
 
 
 
 
 
 
 
 
 






















































































































































































/**
 * Compute a blame output from a list of unified patches.
 * @module
 */

/** Hunk header regular expression */
const HUNK_HEADER = /^@@ -(?<ai>\d+)(?:,(?<aj>\d+))? \+(?<bi>\d+)(?:,(?<bj>\d+))? @@/

/** No newline at end of file marker */
const NO_NEWLINE = "\\ No newline at end of file"

/**
 * ANSI patterns
 * https://github.com/chalk/ansi-regex/blob/02fa893d619d3da85411acc8fd4e2eea0e95a9d9/index.js
 */
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",
)

/**
 * Compute a blame output from a list of unified patches.
 *
 * Patches are applied in order onto an initially empty text, and each resulting line is attributed the metadata of the last patch that introduced it.
 * Unchanged context lines keep their attribution, while lines that are moved by a patch (i.e. removed and re-added with the same content) are re-attributed:
 * - if the metadata is a string (or any non-plain object), it is simply replaced by the new patch metadata
 * - if the metadata is a plain object, it is merged with the previous attribution (only the properties present in the new patch metadata are replaced)
 *
 * Metadata is extracted from each patch by the {@linkcode BlameOptions.parser | parser} option.
 * By default, it is the informative header of the patch (the content preceding the `---` line, which can be set through {@link https://jsr.io/@libs/diff/doc/diff | diff}'s `header` option),
 * falling back on the patch index when absent.
 *
 * Returned lines keep their trailing newline (except the last one when the text does not end with a newline),
 * so that joining them back yields the patched text.
 *
 * ```ts
 * import { blame } from "./blame.ts"
 * import { diff } from "./diff.ts"
 * const a = "foo\nbar\n"
 * const b = "foo\nbaz\n"
 * blame([
 *   diff("", a, { header: "alice" }),
 *   diff(a, b, { header: "bob" }),
 * ]) // Returns [["foo\n", "alice"], ["baz\n", "bob"]]
 * ```
 *
 * Metadata can be customized through the {@linkcode BlameOptions.parser | parser} option.
 * ```ts
 * import { blame } from "./blame.ts"
 * import { diff } from "./diff.ts"
 * const a = "foo\nbar\n"
 * const b = "foo\nbaz\n"
 * blame<{ sha: string }>([
 *   diff("", a, { header: "9f209e3" }),
 *   diff(a, b, { header: "4651806" }),
 * ], { parser: (patch) => ({ sha: patch.split("\n")[0] }) }) // Returns [["foo\n", { sha: "9f209e3" }], ["baz\n", { sha: "4651806" }]]
 * ```
 *
 * @author Simon Lecoq (lowlighter)
 * @license MIT
 */
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++) {
      // Parse hunk header
      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)]))
      // Apply hunk
      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
        // Check hunk header counts
        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)
      }
    }
    // Re-attribute moved lines (removed and re-added with the same content by the current patch)
    for (const entry of added) {
      const pool = removed.get(entry[0].replace(/\n$/, ""))
      if (pool?.length)
        entry[1] = merge(pool.shift()!, meta)
    }
  }
  // Return blamed lines
  if (errors.length)
    throw new AggregateError(errors, entries.map(([line]) => line).join(""))
  return entries
}

/** Default {@linkcode BlameOptions.parser | parser}, extracting the informative header of a patch and falling back on its index. */
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}`
}

/** Check whether a metadata value is a plain object. */
function object(meta: unknown): meta is Record<PropertyKey, unknown> {
  return (typeof meta === "object") && (meta !== null) && (!Array.isArray(meta))
}

/** Shallow copy metadata so each line owns its own attribution. */
function copy<T>(meta: T): T {
  return object(meta) ? { ...meta } as T : meta
}

/** Merge the previous attribution of a moved line with new metadata (plain objects only replace the properties present in the new metadata, anything else is replaced). */
function merge<T>(previous: T, meta: T): T {
  return (object(previous) && object(meta)) ? { ...previous, ...meta } as T : copy(meta)
}

/** Options for {@linkcode blame()}. */
export type BlameOptions<T = string> = {
  /**
   * Parse the metadata attributed to lines introduced by a patch.
   *
   * It is called once per patch with the raw patch and its index, and defaults to extracting the informative header of the patch
   * (the content preceding the `---` line), falling back on the patch index when absent.
   */
  parser?: (patch: string, index: number) => T
}