@preventive/compare 0.0.0-stage → 1.0.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
+ // A diff of a file minified on both sides with renamed names set aside: the
2
+ // old side renamed, formatted again where that changes its layout alone and
3
+ // still binds alike, diffed, and the diff checked to hide no change.
4
+
5
+ import { changeSet } from '@preventive/diff'
6
+ import { blocksHold } from '../blocks.js'
7
+ import { markedBlocks } from '../marked.js'
8
+ import { format } from '../pretty.js'
9
+ import { bindsAlike } from './binds.js'
10
+ import { renameMap } from './rename.js'
11
+
12
+ const ranges = ({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 })
13
+
14
+ export async function renamedDiff(before, after, { extension = 'js', otherExtension = extension, whitespace = 'none' } = {}) {
15
+ const warnings = []
16
+ const mapped = renameMap(before, after, { extension, otherExtension })
17
+ if (mapped.invariant) warnings.push(`a rename map check failed: ${mapped.invariant}`)
18
+ let renamed = before
19
+ if (!mapped.reason) {
20
+ // A name's new length may move the lines it is on.
21
+ const formatted = await format(mapped.text, extension)
22
+ const read = 'code' in formatted && bindsAlike(before, formatted.code, mapped.kind)
23
+ if ('code' in formatted && !read) warnings.push("the renamed old side, formatted, read otherwise")
24
+ renamed = read ? formatted.code : mapped.text
25
+ }
26
+ const options = { horizon: 0, whitespace }
27
+ const blocks = changeSet(renamed, after, options).map(ranges)
28
+ if (!blocksHold(renamed, after, blocks, options)) throw new Error('change blocks that do not take the old side to the new')
29
+ // Read off the renamed text before formatting, line for line the old side's.
30
+ const marks = mapped.reason ? [] : markedBlocks(before, mapped.text, after, options)
31
+ if (marks === null) warnings.push('a renamed text with lines the old one does not have; none marked')
32
+ return { before: renamed, blocks, reason: mapped.reason ?? null, reads: mapped.reads ?? [], marks: marks ?? [], warnings }
33
+ }
@@ -0,0 +1,38 @@
1
+ // The variables the names given would read wrongly: two of a scope named
2
+ // alike, a `var` named as a block's it hoists past, a body's as its parameter,
3
+ // or a name read resolving to another variable, a global's to any.
4
+
5
+ import { hoistedPast, resolve } from './syntax.js'
6
+
7
+ export function misread({ scopes, variables, occurrences, globals }, given) {
8
+ const names = new Map(scopes.map((at) => [at, new Map()])), wrong = new Set()
9
+ const named = (at) => names.get(at)
10
+ for (const at of scopes) {
11
+ for (const variable of at.names.values()) {
12
+ const taken = named(at).get(given.get(variable))
13
+ if (taken) wrong.add(taken).add(variable)
14
+ else named(at).set(given.get(variable), variable)
15
+ }
16
+ }
17
+ for (const variable of variables) {
18
+ for (const path of variable.through ?? []) for (const there of hoistedPast(variable, path, given.get(variable), named)) wrong.add(there).add(variable)
19
+ }
20
+ for (const at of scopes) {
21
+ if (!at.parameters) continue
22
+ for (const variable of new Set(at.names.values())) {
23
+ const parameter = named(at.parameters).get(given.get(variable))
24
+ if (parameter && parameter !== variable) wrong.add(parameter).add(variable)
25
+ }
26
+ }
27
+ for (const [, variable, where] of occurrences) {
28
+ const found = resolve(given.get(variable), where, named)
29
+ if (found === variable) continue
30
+ wrong.add(variable)
31
+ if (found) wrong.add(found)
32
+ }
33
+ for (const [node, where] of globals) {
34
+ const found = resolve(node.name, where, named)
35
+ if (found) wrong.add(found)
36
+ }
37
+ return wrong
38
+ }
@@ -0,0 +1,111 @@
1
+ // Which old variable became which new one: each pair of lines, as the diff of
2
+ // their keys (names set aside) pairs them, counts its variables as standing for
3
+ // each other, and two each the other's most counted pair, a tie pairing neither.
4
+
5
+ import { changeSet } from '@preventive/diff'
6
+
7
+ // Unchanged lines between changes that pair as unchanged ones, not as changed.
8
+ const ANCHORED_RUN = 3
9
+
10
+ // Whitespace alone never tells a name apart.
11
+ const changes = (a, b) => changeSet(a, b, { horizon: 0, whitespace: 'all' })
12
+
13
+ // A text's lines with its renamable variables' names set aside (`key`), and
14
+ // each line's such variables in order (`lines`).
15
+ function keyed(text, { occurrences }) {
16
+ const starts = [0]
17
+ for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', at + 1)) starts.push(at + 1)
18
+ const lines = starts.map(() => [])
19
+ let at = 0, key = '', line = 0
20
+ for (const [node, variable] of occurrences) {
21
+ if (!variable.aside) continue
22
+ key += text.slice(at, node.start)
23
+ at = node.end
24
+ while (line + 1 < starts.length && starts[line + 1] <= node.start) line++
25
+ lines[line].push(variable)
26
+ }
27
+ return { key: key + text.slice(at), lines }
28
+ }
29
+
30
+ const tallies = () => ({ backward: new Map(), forward: new Map() })
31
+ function count(map, from, to) {
32
+ if (!map.has(from)) map.set(from, new Map())
33
+ map.get(from).set(to, (map.get(from).get(to) ?? 0) + 1)
34
+ }
35
+
36
+ // Pairs of lines counted, a long run of unchanged ones apart from the rest. In
37
+ // a change of unlike lengths, lines alike but for a final `;` or `,` pair (a
38
+ // declaration others now follow: `let t = ...;` -> `let t = ...,`).
39
+ function tallied(ka, kb) {
40
+ const changed = tallies(), unchanged = tallies()
41
+ // Lines reading variables one side has and the other not say nothing.
42
+ const tally = ({ backward, forward }, pa, pb) => {
43
+ const news = kb.lines[pb], olds = ka.lines[pa]
44
+ if (olds.length !== news.length) return
45
+ olds.forEach((variable, k) => {
46
+ count(forward, variable, news[k])
47
+ count(backward, news[k], variable)
48
+ })
49
+ }
50
+ const keysA = ka.key.split('\n'), keysB = kb.key.split('\n')
51
+ const trimmed = (lines, from, to) => `${lines.slice(from, to).map((line) => line.replace(/[;,]\s*$/u, '')).join('\n')}\n`
52
+ let i = 0, j = 0
53
+ for (const block of [...changes(ka.key, kb.key), { a0: ka.lines.length, a1: ka.lines.length, b0: kb.lines.length, b1: kb.lines.length }]) {
54
+ const anchored = block.a0 - i >= ANCHORED_RUN
55
+ while (i < block.a0) tally(anchored ? unchanged : changed, i++, j++)
56
+ const [olds, news] = [block.a1 - block.a0, block.b1 - block.b0]
57
+ if (olds === news) for (let k = 0; k < olds; k++) tally(changed, block.a0 + k, block.b0 + k)
58
+ else if (olds > 0 && news > 0) {
59
+ let x = 0, y = 0
60
+ for (const inner of [...changes(trimmed(keysA, block.a0, block.a1), trimmed(keysB, block.b0, block.b1)), { a0: olds, a1: olds, b0: news, b1: news }]) {
61
+ while (x < inner.a0) tally(changed, block.a0 + x++, block.b0 + y++)
62
+ x = inner.a1
63
+ y = inner.b1
64
+ }
65
+ }
66
+ i = block.a1
67
+ j = block.b1
68
+ }
69
+ return { changed, unchanged }
70
+ }
71
+
72
+ // The most counted of those not yet paired, none where two tie.
73
+ function best(counts, taken) {
74
+ let found = null, most = 0
75
+ for (const [other, n] of counts) {
76
+ if (taken(other)) continue
77
+ if (n > most) {
78
+ found = other
79
+ most = n
80
+ } else if (n === most) found = null
81
+ }
82
+ return found
83
+ }
84
+
85
+ // Pairs each the other's best, into `map`, until none more pair.
86
+ function matched({ backward, forward }, { map, paired }, eligible = () => true) {
87
+ const chosen = []
88
+ for (let more = true; more;) {
89
+ more = false
90
+ for (const [variable, counts] of forward) {
91
+ if (map.has(variable)) continue
92
+ const to = best(counts, (other) => paired.has(other))
93
+ if (!to || best(backward.get(to), (other) => map.has(other)) !== variable || !eligible(variable, to)) continue
94
+ map.set(variable, to)
95
+ paired.add(to)
96
+ chosen.push(variable)
97
+ more = true
98
+ }
99
+ }
100
+ return chosen
101
+ }
102
+
103
+ // Unchanged lines pair first; changed ones then pair only what those say
104
+ // nothing of, as `weak` pairs, the first to give way where names read wrongly.
105
+ export function pairing(before, after, a, b) {
106
+ const { changed, unchanged } = tallied(keyed(before, a), keyed(after, b))
107
+ const found = { map: new Map(), paired: new Set() }
108
+ matched(unchanged, found)
109
+ const weak = new Set(matched(changed, found, (old, to) => !unchanged.forward.has(old) && !unchanged.backward.has(to)))
110
+ return { map: found.map, weak }
111
+ }
@@ -0,0 +1,69 @@
1
+ // The old side of a file minified on both sides given the new side's names:
2
+ // both analyzed, variables paired by their lines, the names given checked not
3
+ // to read wrongly, and the result checked again by oxc (bindsAlike).
4
+
5
+ import { getOxc } from '#oxc'
6
+ import { analyze } from './analyze.js'
7
+ import { bindsAlike } from './binds.js'
8
+ import { checkAnalysis, checkGiven, checkPairing, checkRenamed } from './check.js'
9
+ import { misread } from './misread.js'
10
+ import { pairing } from './pairing.js'
11
+ import { APART, Broken, holds } from './syntax.js'
12
+
13
+ // Each old variable's name: its counterpart's, else its own, or one apart where
14
+ // the new side has its own. Those misread yield, weak pairs first; null where
15
+ // none can.
16
+ function givenNames(a, b, { map, weak }) {
17
+ const apart = (name) => {
18
+ let named = name + APART
19
+ for (let n = 2; a.names.has(named) || b.names.has(named); n++) named = `${name}${APART}${n}`
20
+ return named
21
+ }
22
+ const given = new Map()
23
+ for (;;) {
24
+ for (const variable of a.variables) {
25
+ const own = variable.aside && !map.has(variable) && b.names.has(variable.name) ? apart(variable.name) : variable.name
26
+ given.set(variable, variable.aside && map.has(variable) ? map.get(variable).name : own)
27
+ }
28
+ const wrong = [...misread(a, given)]
29
+ if (wrong.length === 0) return given
30
+ const yielding = wrong.some((variable) => weak.has(variable) && map.has(variable)) ? wrong.filter((variable) => weak.has(variable)) : wrong
31
+ if (yielding.filter((variable) => map.delete(variable)).length === 0) return null
32
+ }
33
+ }
34
+
35
+ function renamedNames(before, after, { extension = 'js', otherExtension = extension }) {
36
+ const a = analyze(before, extension), b = analyze(after, otherExtension)
37
+ if (a.reason || b.reason) return { reason: a.reason ?? b.reason }
38
+ checkAnalysis(before, a)
39
+ checkAnalysis(after, b)
40
+ const paired = pairing(before, after, a, b)
41
+ checkPairing(a, b, paired)
42
+ const given = givenNames(a, b, paired)
43
+ if (!given) return { reason: 'unverified' }
44
+ checkGiven(a, b, paired.map, given)
45
+ let at = 0, text = ''
46
+ for (const [node, variable] of a.occurrences) {
47
+ text += before.slice(at, node.start) + given.get(variable)
48
+ at = node.end
49
+ }
50
+ text += before.slice(at)
51
+ holds(before !== after || text === before, 'a text renamed as itself changed')
52
+ checkRenamed(a, given, text, extension)
53
+ if (!bindsAlike(before, text, a)) return { reason: 'unverified' }
54
+ const changed = a.variables.filter((variable) => given.get(variable) !== variable.name)
55
+ const renamed = changed.filter((variable) => paired.map.has(variable)).length
56
+ const reads = [...new Set([...a.reads, ...b.reads])].toSorted()
57
+ return { text, renamed, apart: changed.length - renamed, kind: { module: a.module, commonjs: a.commonjs, own: a.own }, reads }
58
+ }
59
+
60
+ // Any failure gives no names, and tells what failed. oxc is loaded first, out
61
+ // of the try: one missing is thrown, not taken for a text it could not read.
62
+ export function renameMap(before, after, options = {}) {
63
+ getOxc()
64
+ try {
65
+ return renamedNames(before, after, options)
66
+ } catch (error) {
67
+ return { reason: 'unverified', invariant: error instanceof Broken ? error.message : String(error) }
68
+ }
69
+ }
@@ -0,0 +1,63 @@
1
+ // Which names renaming touches, and the rules of the language it leans on.
2
+
3
+ // A minifier's names: short and ASCII, so that none is renamed to or from one
4
+ // a reader can't tell apart (a Cyrillic a, U+0430, or a zero-width joiner).
5
+ export const RENAMED_MAX_LENGTH = 3
6
+ export const MINIFIED = /^[A-Za-z_$][\w$]*$/u
7
+ // Properties code reads names through, which renaming changes: as a member's
8
+ // or a pattern's key, or any string written out (not `'' + f`).
9
+ export const NAME_READS = new Set(['name', 'toString', 'stack', 'callee', 'caller'])
10
+ // What a name renamed apart ends in: `e_old`, then `e_old2`.
11
+ export const APART = '_old'
12
+ // Words no variable may be named, in strict code or in a module.
13
+ export const RESERVED = new Set(`await break case catch class const continue debugger default delete do else enum export extends false finally for
14
+ function if implements import in instanceof interface let new null package private protected public return static super switch this throw
15
+ true try typeof var void while with yield arguments eval`.split(/\s+/u))
16
+
17
+ // A check that should never fail: renameMap then gives no names, and tells why.
18
+ export class Broken extends Error {}
19
+ export function holds(condition, what) {
20
+ if (!condition) throw new Broken(what)
21
+ }
22
+
23
+ // The names a pattern declares: `{ a, b: [c, ...d] = e }` declares a, c and d.
24
+ export function declared(pattern, out = []) {
25
+ switch (pattern?.type) {
26
+ case 'Identifier': out.push(pattern); break
27
+ case 'ObjectPattern': for (const property of pattern.properties) declared(property.type === 'RestElement' ? property.argument : property.value, out); break
28
+ case 'ArrayPattern': for (const element of pattern.elements) declared(element, out); break
29
+ case 'RestElement': declared(pattern.argument, out); break
30
+ case 'AssignmentPattern': declared(pattern.left, out); break
31
+ default: break
32
+ }
33
+ return out
34
+ }
35
+
36
+ export function saysStrict(statements) {
37
+ for (const statement of statements) {
38
+ if (typeof statement.directive !== 'string') return false
39
+ if (statement.directive === 'use strict') return true
40
+ }
41
+ return false
42
+ }
43
+
44
+ // The variable a name read in `from` stands for, or null for a global's;
45
+ // `names` gives a scope's names, its own unless others are asked after.
46
+ export function resolve(name, from, names = (at) => at.names) {
47
+ for (let at = from; at; at = at.parent) if (names(at).has(name)) return names(at).get(name)
48
+ return null
49
+ }
50
+
51
+ // The variables of `name` a `var` hoists past up to its function: each a
52
+ // redeclaration that does not compile, but past a `catch` whose simple
53
+ // parameter it is, or one of its name beyond such a one.
54
+ export function hoistedPast(variable, path, name, named) {
55
+ const met = []
56
+ let caught = false
57
+ for (const at of path) {
58
+ const there = named(at).get(name)
59
+ if (there === variable) caught ||= Boolean(at.caught)
60
+ else if (there && !(at.caught && at.simple && caught)) met.push(there)
61
+ }
62
+ return met
63
+ }
package/src/oxc.js ADDED
@@ -0,0 +1,6 @@
1
+ // oxc-parser and oxc-minify where Node does not run this: their own builds for
2
+ // it (WASM, by their `browser` condition), loaded with names.js.
3
+ import { minifySync } from 'oxc-minify'
4
+ import { parseSync } from 'oxc-parser'
5
+
6
+ export const getOxc = () => ({ parseSync, minifySync })
@@ -0,0 +1,16 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ // oxc-parser and oxc-minify, optional peers, required on first use; one missing
4
+ // is thrown as the environment's error, not taken for a text that won't parse.
5
+ const require = createRequire(import.meta.url)
6
+ let oxc
7
+
8
+ function load(name) {
9
+ try {
10
+ return require(name)
11
+ } catch (cause) {
12
+ throw new Error(`@preventive/compare/names.js needs the optional '${name}' peer dependency; install it (e.g. \`npm i ${name}\`)`, { cause })
13
+ }
14
+ }
15
+
16
+ export const getOxc = () => (oxc ??= { parseSync: load('oxc-parser').parseSync, minifySync: load('oxc-minify').minifySync })
package/src/pairs.js ADDED
@@ -0,0 +1,100 @@
1
+ // Which of a change's removed lines was edited into which added one: the best
2
+ // pairing in order where weighing every pair is cheap enough, else a lookahead.
3
+
4
+ import { MAX_CHANGED, tokenized, wordView } from './words.js'
5
+
6
+ // The longest change whose lines are paired at all; the most work weighing all
7
+ // its pairs may take, in steps; and past it, how far ahead a removed line looks
8
+ // for an added one instead.
9
+ const MAX_LINES = 200
10
+ const BUDGET = 5_000_000
11
+ const LOOKAHEAD = 6
12
+ // What a pair weighed and a word's count compared cost, in steps of a script.
13
+ const PAIR = 60
14
+ const VISIT = 3
15
+
16
+ function counted(read) {
17
+ const counts = new Map()
18
+ for (const token of read?.tokens ?? []) counts.set(token, (counts.get(token) ?? 0) + 1)
19
+ return counts
20
+ }
21
+
22
+ // How much of each pair of lines changed, as wordRanges marks it, Infinity
23
+ // where they are no line edited; and the work weighing them has taken.
24
+ function weigher(olds, news) {
25
+ const ids = new Map(), reads = [olds, news].map((side) => side.map((line) => tokenized(line, ids)))
26
+ const counts = reads.map((side) => side.map(counted))
27
+ let work = 0
28
+ // The share of `line` in words `others` holds fewer of, changed whatever the script finds; past what a
29
+ // word view takes for one line edited, Infinity.
30
+ const unmatched = (mine, others, line) => {
31
+ let changed = 0
32
+ for (const [token, count] of mine) {
33
+ work += VISIT
34
+ if ((changed += token.length * Math.max(0, count - (others.get(token) ?? 0))) > MAX_CHANGED * line.length) return Infinity
35
+ }
36
+ return changed / Math.max(1, line.length)
37
+ }
38
+ // The least share of the pair changed, as their counts alone tell.
39
+ const floor = (i, j) => (reads[0][i] === null || reads[1][j] === null ? Infinity
40
+ : Math.min(unmatched(counts[0][i], counts[1][j], olds[i]), unmatched(counts[1][j], counts[0][i], news[j])))
41
+ const weighed = (i, j) => {
42
+ work += PAIR
43
+ if (floor(i, j) === Infinity) return Infinity
44
+ const { share, steps } = wordView(reads[0][i], reads[1][j])
45
+ work += steps
46
+ return share <= MAX_CHANGED ? share : Infinity
47
+ }
48
+ const shares = new Float64Array(olds.length * news.length).fill(Number.NaN)
49
+ const share = (i, j) => {
50
+ const key = i * news.length + j
51
+ if (Number.isNaN(shares[key])) shares[key] = olds[i] === news[j] ? 0 : weighed(i, j)
52
+ return shares[key]
53
+ }
54
+ return { share, floor, spent: () => work }
55
+ }
56
+
57
+ // The pairs in order that are the most, and of those the most alike, a pair
58
+ // weighed 1000 less its share; null where weighing them all passes the budget.
59
+ function aligned(n, m, { share, spent }) {
60
+ const value = Array.from({ length: n + 1 }, () => new Float64Array(m + 1))
61
+ const take = (i, j) => (share(i, j) === Infinity ? -Infinity : 1000 - share(i, j) + value[i][j])
62
+ for (let i = 0; i < n; i++) {
63
+ for (let j = 0; j < m; j++) {
64
+ value[i + 1][j + 1] = Math.max(value[i][j + 1], value[i + 1][j], take(i, j))
65
+ if (spent() > BUDGET) return null
66
+ }
67
+ }
68
+ const pairs = []
69
+ for (let i = n, j = m; i > 0 && j > 0;) {
70
+ if (value[i][j] === value[i][j - 1]) j--
71
+ else if (value[i][j] === value[i - 1][j]) i--
72
+ else pairs.push([--i, --j])
73
+ }
74
+ return pairs.toReversed()
75
+ }
76
+
77
+ // Each removed line in turn paired with the most alike of the next added lines
78
+ // after the last one paired, those whose counts show less changed weighed first.
79
+ function lookedAhead(n, m, { share, floor }) {
80
+ const pairs = []
81
+ for (let i = 0, next = 0; i < n && next < m; i++) {
82
+ let best = null, least = Infinity
83
+ const ahead = Array.from({ length: Math.min(m, next + LOOKAHEAD) - next }, (_, at) => [next + at, floor(i, next + at)])
84
+ for (const [j, low] of ahead.toSorted((p, q) => p[1] - q[1] || p[0] - q[0])) {
85
+ if (low > least) break
86
+ if (share(i, j) < least || (share(i, j) === least && j < best)) [best, least] = [j, share(i, j)]
87
+ }
88
+ if (best !== null) {
89
+ pairs.push([i, best])
90
+ next = best + 1
91
+ }
92
+ }
93
+ return pairs
94
+ }
95
+
96
+ export function pairLines(olds, news) {
97
+ if (olds.length > MAX_LINES || news.length > MAX_LINES) return []
98
+ const weigh = weigher(olds, news)
99
+ return aligned(olds.length, news.length, weigh) ?? lookedAhead(olds.length, news.length, weigh)
100
+ }
package/src/patch.js ADDED
@@ -0,0 +1,78 @@
1
+ // The patch `git diff` writes (builtin_diff, fill_metainfo in diff.c), less its
2
+ // `index` lines, which need git's object hashes. Hunks print each change's own
3
+ // blocks, so a run of lines free to move may sit elsewhere than git puts it.
4
+
5
+ import { utf8fromString, utf8toString } from '@exodus/bytes/utf8.js'
6
+ import { changeSet, formatChangeSet } from '@preventive/diff'
7
+ import { quote } from './quote.js'
8
+ import { checkLineOptions, textOf } from './text.js'
9
+ import { sameContent } from './tree.js'
10
+
11
+ const STATUSES = new Set(['added', 'deleted', 'modified', 'renamed'])
12
+ const gitMode = (file) => (file.type === 'symlink' ? '120000' : (0o100000 | file.mode).toString(8))
13
+
14
+ // git ends a label holding a space with a tab, so patch reads the whole name.
15
+ const label = (name) => (name.includes(' ') ? `${name}\t` : name)
16
+
17
+ // git's default hunk name (def_ff in xemit.c): the nearest line above the hunk
18
+ // starting with a letter, `_` or `$`, cut to 80 bytes, less trailing blanks.
19
+ const FUNCTION_LINE = /^[A-Za-z_$]/u
20
+ const BLANKS = /[\t\n\r ]+$/u
21
+
22
+ // git may cut a character in two; this cuts before it.
23
+ function cut(line) {
24
+ const bytes = utf8fromString(line)
25
+ let end = Math.min(bytes.length, 80)
26
+ while ((bytes[end] & 0xC0) === 0x80) end--
27
+ return utf8toString(bytes.subarray(0, end)).replace(BLANKS, '')
28
+ }
29
+
30
+ // git searches back only to the previous hunk's start, keeping what it found.
31
+ function hunkNames(text) {
32
+ const lines = text.split('\n')
33
+ let found = null, searched = -1
34
+ return (start) => {
35
+ const line = lines.slice(searched + 1, start).findLast((candidate) => FUNCTION_LINE.test(candidate))
36
+ if (line !== undefined) found = cut(line)
37
+ searched = start - 1
38
+ return found
39
+ }
40
+ }
41
+
42
+ // `blocks` are the change's, found under `options`, or are found here.
43
+ function body(one, two, blocks, from, to, { context, ...options }) {
44
+ const newText = textOf(two), oldText = textOf(one)
45
+ if (oldText === undefined || newText === undefined) {
46
+ return one !== null && two !== null && sameContent(one, two) ? '' : `Binary files ${from} and ${to} differ\n`
47
+ }
48
+ const hunks = formatChangeSet(oldText, newText, blocks ?? changeSet(oldText, newText, options), { ...options, context, label: hunkNames(oldText) })
49
+ return hunks === '' ? '' : `--- ${label(from)}\n+++ ${label(to)}\n${hunks}`
50
+ }
51
+
52
+ // One `diff --git` section; `similarity` is a rename's. As git does, a header
53
+ // with nothing under it is left out.
54
+ function section(one, two, similarity, blocks, options) {
55
+ const a = quote((one ?? two).path, 'a/'), b = quote((two ?? one).path, 'b/')
56
+ let meta = ''
57
+ if (one === null) meta += `new file mode ${gitMode(two)}\n`
58
+ else if (two === null) meta += `deleted file mode ${gitMode(one)}\n`
59
+ else if (gitMode(one) !== gitMode(two)) meta += `old mode ${gitMode(one)}\nnew mode ${gitMode(two)}\n`
60
+ if (similarity !== null) meta += `similarity index ${similarity}%\nrename from ${quote(one.path)}\nrename to ${quote(two.path)}\n`
61
+ const rest = meta + body(one, two, blocks, one === null ? '/dev/null' : a, two === null ? '/dev/null' : b, options)
62
+ return rest === '' ? '' : `diff --git ${a} ${b}\n${rest}`
63
+ }
64
+
65
+ // The changes were found under the line options given (text.js), as their blocks
66
+ // are held to them; a change found without lines has them found here.
67
+ export function formatPatch(changes, { context = 3, ...lineOptions } = {}) {
68
+ if (!(Number.isInteger(context) && context >= 0)) throw new RangeError(`context must be a whole number of lines, not ${context}`)
69
+ const options = { context, ...checkLineOptions(lineOptions) }
70
+ let out = ''
71
+ for (const { status, old, new: fresh, similarity, blocks } of changes) {
72
+ // git writes a type change as a deletion and a creation.
73
+ if (status === 'type-changed') out += section(old, null, null, null, options) + section(null, fresh, null, null, options)
74
+ else if (STATUSES.has(status)) out += section(old, fresh, status === 'renamed' ? similarity : null, blocks, options)
75
+ else throw new TypeError(`${JSON.stringify(status)} is not a change's status`)
76
+ }
77
+ return out
78
+ }
package/src/paths.js ADDED
@@ -0,0 +1,22 @@
1
+ // A rename written as `git diff --stat` writes one: the directories both share,
2
+ // what changed, and the rest.
3
+
4
+ export function renameParts(from, to, { extension = true } = {}) {
5
+ const a = from.split('/'), b = to.split('/')
6
+ let head = 0
7
+ while (head < a.length - 1 && head < b.length - 1 && a[head] === b[head]) head++
8
+ let tail = 0
9
+ while (tail < a.length - head - 1 && tail < b.length - head - 1 && a.at(-1 - tail) === b.at(-1 - tail)) tail++
10
+ const parts = {
11
+ head: head ? `${a.slice(0, head).join('/')}/` : '',
12
+ from: a.slice(head, a.length - tail).join('/'),
13
+ to: b.slice(head, b.length - tail).join('/'),
14
+ tail: tail ? `/${a.slice(-tail).join('/')}` : '',
15
+ }
16
+ const dot = parts.from.lastIndexOf('.')
17
+ if (extension && !parts.from.includes('/') && !parts.to.includes('/') && dot > 0
18
+ && parts.to.lastIndexOf('.') === dot && parts.from.slice(0, dot) === parts.to.slice(0, dot)) {
19
+ return { ...parts, head: parts.head + parts.from.slice(0, dot), from: parts.from.slice(dot), to: parts.to.slice(dot) }
20
+ }
21
+ return parts
22
+ }
package/src/peers.js ADDED
@@ -0,0 +1,18 @@
1
+ // pretty.js's optional peers, loaded on first use. oxfmt it can't do without:
2
+ // one missing is thrown as the environment's error, not taken for a file that
3
+ // won't format. css-tree checks CSS: null where missing, so CSS is refused.
4
+ let csstree, format
5
+
6
+ export async function loadFormatter() {
7
+ try {
8
+ format ??= (await import('oxfmt')).format
9
+ } catch (cause) {
10
+ throw new Error("@preventive/compare/pretty.js needs the optional 'oxfmt' peer dependency; install it (e.g. `npm i oxfmt`)", { cause })
11
+ }
12
+ return format
13
+ }
14
+
15
+ export async function loadCssTree() {
16
+ csstree ??= await import('css-tree').catch(() => null)
17
+ return csstree
18
+ }