@preventive/compare 0.0.0-stage → 1.0.1
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/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/compare.d.ts +185 -0
- package/compare.js +29 -0
- package/names.d.ts +42 -0
- package/names.js +9 -0
- package/package.json +131 -4
- package/pretty.d.ts +14 -0
- package/pretty.js +4 -0
- package/src/blocks.js +17 -0
- package/src/changes.js +90 -0
- package/src/css.js +70 -0
- package/src/error.js +12 -0
- package/src/layout.js +94 -0
- package/src/marked.js +20 -0
- package/src/names/analyze.js +235 -0
- package/src/names/binds.js +20 -0
- package/src/names/check.js +68 -0
- package/src/names/diff.js +33 -0
- package/src/names/misread.js +38 -0
- package/src/names/pairing.js +111 -0
- package/src/names/rename.js +69 -0
- package/src/names/syntax.js +63 -0
- package/src/oxc.js +6 -0
- package/src/oxc.node.js +16 -0
- package/src/pairs.js +100 -0
- package/src/patch.js +78 -0
- package/src/paths.js +22 -0
- package/src/peers.js +18 -0
- package/src/pretty.js +106 -0
- package/src/quote.js +16 -0
- package/src/renames.js +206 -0
- package/src/same-bytes.js +18 -0
- package/src/same-bytes.node.js +7 -0
- package/src/script.js +57 -0
- package/src/similarity.js +53 -0
- package/src/text.js +41 -0
- package/src/tree.js +98 -0
- package/src/whitespace.js +44 -0
- package/src/words.js +57 -0
- package/README.md +0 -3
package/src/pretty.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// A file formatted by oxfmt, used only where checks apart from oxfmt find it differs in layout
|
|
2
|
+
// alone: a script's syntax tree as oxc parses it, a style sheet's as css-tree does (an optional peer,
|
|
3
|
+
// CSS refused without it), JSON's strings and comments as written.
|
|
4
|
+
|
|
5
|
+
import { getOxc } from '#oxc'
|
|
6
|
+
import { indentless, sameCss } from './css.js'
|
|
7
|
+
import { loadCssTree, loadFormatter } from './peers.js'
|
|
8
|
+
|
|
9
|
+
const PRETTY_OPTIONS = { quoteProps: 'preserve', trailingComma: 'none', arrowParens: 'avoid', embeddedLanguageFormatting: 'off' }
|
|
10
|
+
|
|
11
|
+
const SCRIPTS = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx'])
|
|
12
|
+
const NUMBER = /(?<![\p{L}\p{N}_$])(?:0[box][\d_a-f]+n?|\d[\d_]*(?:\.[\d_]*)?(?:e[+-]?\d+)?n?)|(?<![\p{N}.])\.\d[\d_]*(?:e[+-]?\d+)?/giu
|
|
13
|
+
const POSITION = new Set(['start', 'end', 'range', 'loc'])
|
|
14
|
+
// JSON's strings, quoted either way or in backticks as its kin may have them, and its comments, compared
|
|
15
|
+
// as written, split from the rest; one unclosed runs to its line's end, or the end.
|
|
16
|
+
const LITERAL = /("(?:[^"\\\n]|\\(?:\r\n|[^]))*(?:"|(?=\n)|$)|'(?:[^'\\\n]|\\(?:\r\n|[^]))*(?:'|(?=\n)|$)|`(?:[^`\\]|\\[^])*(?:`|$)|\/\*[^]*?(?:\*\/|$)|\/\/.*)/u
|
|
17
|
+
|
|
18
|
+
// A number as the formatter spells it (`.50E+3` as `0.5e3`), but for what JSON may read as another
|
|
19
|
+
// type: a point keeps a digit after it, a whole number its zero exponent (`1.0`, `1e0`).
|
|
20
|
+
function numberSpelling(text) {
|
|
21
|
+
const number = text.toLowerCase()
|
|
22
|
+
const parts = /^0[box]|n$/u.test(number) ? null : /^([^e]*)(?:e([+-]?)0*(\d+))?$/u.exec(number)
|
|
23
|
+
if (!parts) return number
|
|
24
|
+
const [, mantissa = '', sign = '', exponent = ''] = parts
|
|
25
|
+
const fraction = (mantissa.startsWith('.') ? `0${mantissa}` : mantissa).replace(/(\.\d+?)0+$/u, '$1').replace(/\.$/u, '')
|
|
26
|
+
const zero = /^0+$/u.test(exponent)
|
|
27
|
+
return exponent === '' || (zero && fraction.includes('.')) ? fraction : `${fraction}e${sign === '-' && !zero ? '-' : ''}${exponent}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sorted = (flags) => [...flags].toSorted().join('')
|
|
31
|
+
const scriptForm = (text) => text.replaceAll(NUMBER, numberSpelling).replaceAll(/\\(?=["'])|[\s();]/gu, '').replaceAll("'", '"')
|
|
32
|
+
.replaceAll(/(?<=\/)[dgimsuvy]{2,}(?![\p{L}\p{N}_$])/gu, sorted)
|
|
33
|
+
|
|
34
|
+
// JSON has no point without a digit either side: such a number is kept as written, or a copy would read as
|
|
35
|
+
// valid, as would one with other blanks than JSON's four, which stay.
|
|
36
|
+
const jsonNumber = (text) => (/^\.|\.(?!\d)/u.test(text) ? text.toLowerCase() : numberSpelling(text))
|
|
37
|
+
const jsonForm = (text) => text.split(LITERAL).map((part, i) => (i % 2 ? (part.startsWith('/') ? indentless(part) : part) : part.replaceAll(/[\t\n\r ]/gu, '').replaceAll(NUMBER, jsonNumber)))
|
|
38
|
+
|
|
39
|
+
const sameList = (a, b) => a.length === b.length && a.every((item, i) => item === b[i])
|
|
40
|
+
|
|
41
|
+
function parsed(text, extension) {
|
|
42
|
+
const lang = extension === 'tsx' ? 'tsx' : /^[cm]?ts$/u.test(extension) ? 'ts' : 'jsx'
|
|
43
|
+
const sourceType = extension.startsWith('m') ? 'module' : extension.startsWith('c') ? 'commonjs' : 'unambiguous'
|
|
44
|
+
const result = getOxc().parseSync(`file.${extension}`, text, { lang, sourceType, preserveParens: false })
|
|
45
|
+
return result.errors.length > 0 ? null : result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const comments = ({ comments: list }) => list.map(({ type, value }) => `${type} ${indentless(value)}`)
|
|
49
|
+
// `a && (b && c)` and `(a && b) && c` read alike, as do `||` and `??` chains.
|
|
50
|
+
function chain(node, operator) {
|
|
51
|
+
const operands = [], rest = [node]
|
|
52
|
+
while (rest.length > 0) {
|
|
53
|
+
const next = rest.pop()
|
|
54
|
+
if (next?.type === 'LogicalExpression' && next.operator === operator) rest.push(next.right, next.left)
|
|
55
|
+
else operands.push(next)
|
|
56
|
+
}
|
|
57
|
+
return operands
|
|
58
|
+
}
|
|
59
|
+
const fields = (node) => Object.keys(node).filter((key) => !POSITION.has(key) && !(node.type === 'Literal' && (key === 'raw' || key === 'regex' || (key === 'value' && node.regex))))
|
|
60
|
+
|
|
61
|
+
function sameTree(a, b) {
|
|
62
|
+
const work = [[a, b]]
|
|
63
|
+
while (work.length > 0) {
|
|
64
|
+
const [x, y] = work.pop()
|
|
65
|
+
if (x === y) continue
|
|
66
|
+
if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null || Array.isArray(x) !== Array.isArray(y)) return false
|
|
67
|
+
let pairs
|
|
68
|
+
if (Array.isArray(x)) pairs = [x, y].map((list) => list.filter((node) => node?.type !== 'EmptyStatement'))
|
|
69
|
+
else if (x.type === 'LogicalExpression' && y.type === 'LogicalExpression' && x.operator === y.operator) pairs = [chain(x, x.operator), chain(y, y.operator)]
|
|
70
|
+
else {
|
|
71
|
+
const [keys, others] = [fields(x), fields(y)]
|
|
72
|
+
if (keys.join() !== others.join()) return false
|
|
73
|
+
if (x.type === 'Literal' && x.regex && (x.regex.pattern !== y.regex?.pattern || sorted(x.regex.flags) !== sorted(y.regex.flags))) return false
|
|
74
|
+
pairs = [keys.map((key) => x[key]), keys.map((key) => y[key])]
|
|
75
|
+
}
|
|
76
|
+
if (pairs[0].length !== pairs[1].length) return false
|
|
77
|
+
pairs[0].forEach((node, i) => work.push([node, pairs[1][i]]))
|
|
78
|
+
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A safeguard on oxfmt's own output, not a comparison of any two texts: exported
|
|
83
|
+
// for tests alone, not by the package. CSS is read by `csstree` where given.
|
|
84
|
+
export function sameCode(text, formatted, extension = 'js', csstree = null) {
|
|
85
|
+
if (extension === 'css') return csstree !== null && sameCss(csstree, text, formatted)
|
|
86
|
+
if (extension === 'json') return sameList(jsonForm(text), jsonForm(formatted))
|
|
87
|
+
if (!SCRIPTS.has(extension) || scriptForm(text) !== scriptForm(formatted)) return false
|
|
88
|
+
const [before, after] = [parsed(text, extension), parsed(formatted, extension)]
|
|
89
|
+
return before !== null && after !== null && sameList(comments(before), comments(after)) && sameTree(before.program, after.program)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Only what can be checked is formatted: scripts, JSON, and CSS where css-tree is there to read it.
|
|
93
|
+
// oxfmt is loaded outside the try: one missing is thrown, not taken for a file that won't format.
|
|
94
|
+
export async function format(text, extension) {
|
|
95
|
+
const csstree = extension === 'css' ? await loadCssTree() : null
|
|
96
|
+
if (!(SCRIPTS.has(extension) || extension === 'json' || csstree)) return { error: 'unsupported' }
|
|
97
|
+
const oxfmt = await loadFormatter()
|
|
98
|
+
const result = await oxfmt(`pretty.${extension}`, text, PRETTY_OPTIONS).catch(() => null)
|
|
99
|
+
if (result === null || result.errors.length > 0) return { error: 'unformattable' }
|
|
100
|
+
// A check that fails in itself, its stack say, refuses the copy as one that finds it changed would.
|
|
101
|
+
try {
|
|
102
|
+
return sameCode(text, result.code, extension, csstree) ? { code: result.code } : { error: 'pretty-mismatch' }
|
|
103
|
+
} catch {
|
|
104
|
+
return { error: 'pretty-mismatch' }
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/quote.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Paths as git's quote_c_style writes them under core.quotePath: C-quoted when
|
|
2
|
+
// holding a control, `"`, `\` or anything past ASCII, each UTF-8 byte of such
|
|
3
|
+
// a character escaped by its C letter or in octal. A space stays bare.
|
|
4
|
+
|
|
5
|
+
import { utf8fromString } from '@exodus/bytes/utf8.js'
|
|
6
|
+
|
|
7
|
+
const LETTERS = new Map([[0x07, 'a'], [0x08, 'b'], [0x09, 't'], [0x0A, 'n'], [0x0B, 'v'], [0x0C, 'f'], [0x0D, 'r'], [0x22, '"'], [0x5C, '\\']])
|
|
8
|
+
const UNSAFE = /[^ !#-[\]-~]/gu
|
|
9
|
+
|
|
10
|
+
const escapeByte = (byte) => `\\${LETTERS.get(byte) ?? byte.toString(8).padStart(3, '0')}`
|
|
11
|
+
|
|
12
|
+
// `prefix`, git's `a/` or `b/`, goes inside the quotes: "a/x\ty".
|
|
13
|
+
export function quote(name, prefix = '') {
|
|
14
|
+
const escaped = name.replace(UNSAFE, (char) => Array.from(utf8fromString(char), escapeByte).join(''))
|
|
15
|
+
return escaped === name ? prefix + name : `"${prefix}${escaped}"`
|
|
16
|
+
}
|
package/src/renames.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// git's -M pairing (diffcore_rename_extended): identical content, then a
|
|
2
|
+
// basename unique among both sides' leftovers at a stricter score, then every
|
|
3
|
+
// pair left scored; between the first two, ours, renames told by path.
|
|
4
|
+
|
|
5
|
+
import { crc32 } from '@exodus/bytes/crc.js'
|
|
6
|
+
import { MAX_SCORE, similarity, spanCounts } from './similarity.js'
|
|
7
|
+
import { bytesOf, sameContent } from './tree.js'
|
|
8
|
+
|
|
9
|
+
// git's limits: identical files weighed per destination, scores kept per one.
|
|
10
|
+
const SAME_CONTENT_CANDIDATES = 100
|
|
11
|
+
const CANDIDATES = 4
|
|
12
|
+
|
|
13
|
+
const basename = (path) => path.slice(path.lastIndexOf('/') + 1)
|
|
14
|
+
|
|
15
|
+
// A CRC-32, to bucket files of equal bytes; sameContent still decides.
|
|
16
|
+
function contentKey(file, bytes) {
|
|
17
|
+
if (file.type === 'symlink') return `l${file.target}`
|
|
18
|
+
const data = bytes(file)
|
|
19
|
+
return `f${data.length}:${crc32(data)}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// git's find_identical_files: the first unused identical source of the same
|
|
23
|
+
// basename among the first 100, or else the first of them.
|
|
24
|
+
function exactRenames({ sources, dests, sourceNames, destNames, used, record, bytes }) {
|
|
25
|
+
const byContent = Map.groupBy(sources.keys(), (s) => contentKey(sources[s], bytes))
|
|
26
|
+
for (let d = 0; d < dests.length; d++) {
|
|
27
|
+
let best = -1, left = SAME_CONTENT_CANDIDATES
|
|
28
|
+
for (const s of byContent.get(contentKey(dests[d], bytes)) ?? []) {
|
|
29
|
+
if (used.has(s) || !sameContent(sources[s], dests[d])) continue
|
|
30
|
+
if (best === -1 || sourceNames[s] === destNames[d]) best = s
|
|
31
|
+
if (sourceNames[s] === destNames[d] || --left === 0) break
|
|
32
|
+
}
|
|
33
|
+
if (best !== -1) record(d, best, MAX_SCORE)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A TypeScript extension and the JavaScript one it compiles to.
|
|
38
|
+
const SCRIPTS = new Map([['.ts', '.js'], ['.mts', '.mjs'], ['.cts', '.cjs'], ['.tsx', '.jsx']])
|
|
39
|
+
// Extensions a file keeps its kind across, as triage's Compare groups them.
|
|
40
|
+
const FAMILIES = new Map([
|
|
41
|
+
...['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx'].map((extension) => [extension, 'script']),
|
|
42
|
+
...['.css', '.scss', '.sass', '.less'].map((extension) => [extension, 'style']),
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
function splitPath(path) {
|
|
46
|
+
const dirs = path.split('/')
|
|
47
|
+
const name = dirs.pop()
|
|
48
|
+
const dot = name.lastIndexOf('.')
|
|
49
|
+
return dot > 0 ? { dirs, stem: name.slice(0, dot), extension: name.slice(dot) } : { dirs, stem: name, extension: '' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A path less what a build changes: `lib` and `src` alike, and a TypeScript
|
|
53
|
+
// extension as its JavaScript one.
|
|
54
|
+
function buildKey(path) {
|
|
55
|
+
const { dirs, stem, extension } = splitPath(path)
|
|
56
|
+
return JSON.stringify([dirs.map((dir) => (dir === 'lib' || dir === 'src' ? null : dir)), stem, SCRIPTS.get(extension) ?? extension])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The keys a path meets a partner on, as triage's Compare has them: its stem,
|
|
60
|
+
// its extension's family, and its directories, as they are or with one open --
|
|
61
|
+
// past the last node_modules and its package's name, which a group keeps.
|
|
62
|
+
function familyKeys(path) {
|
|
63
|
+
const { dirs, stem, extension } = splitPath(path)
|
|
64
|
+
const head = [stem, FAMILIES.get(extension) ?? extension]
|
|
65
|
+
const modules = dirs.lastIndexOf('node_modules')
|
|
66
|
+
const first = modules === -1 ? 0 : modules + (dirs[modules + 1]?.startsWith('@') ? 3 : 2)
|
|
67
|
+
const keys = [JSON.stringify([...head, dirs])]
|
|
68
|
+
for (let i = first; i < dirs.length; i++) keys.push(JSON.stringify([...head, dirs.toSpliced(i, 1, null)]))
|
|
69
|
+
return keys
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Whether either file of a pair by path has a candidate that is a rename by
|
|
73
|
+
// itself and scores at least halfway from the pair's score to 100%: sizes too
|
|
74
|
+
// far apart for that go unread.
|
|
75
|
+
function outdone({ pairs, used, scoreOf }, { remaining, open }, { s, d, score }, minimum) {
|
|
76
|
+
const better = Math.max(minimum, score + Math.trunc((MAX_SCORE - score) / 2))
|
|
77
|
+
return open.some((o) => o !== d && !pairs.has(o) && scoreOf(s, o, better) >= better)
|
|
78
|
+
|| remaining.some((r) => r !== s && !used.has(r) && scoreOf(r, d, better) >= better)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Paths a build changes: however a pair scores, the best of a file's partners.
|
|
82
|
+
function buildRenames(context, left, weigh, minimum) {
|
|
83
|
+
const { sources, dests, pairs, used, record, scoreOf } = context
|
|
84
|
+
const byKey = Map.groupBy(left.remaining.filter((s) => sources[s].type === 'file'), (s) => buildKey(sources[s].path))
|
|
85
|
+
const candidates = left.open.filter((d) => dests[d].type === 'file')
|
|
86
|
+
.flatMap((d) => (byKey.get(buildKey(dests[d].path)) ?? []).map((s) => ({ s, d, score: scoreOf(s, d, 0) })))
|
|
87
|
+
for (const pair of candidates.sort((x, y) => y.score - x.score)) {
|
|
88
|
+
if (!pairs.has(pair.d) && !used.has(pair.s) && !(weigh && outdone(context, left, pair, minimum))) record(pair.d, pair.s, pair.score)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Paths triage's Compare pairs: one file each other's only partner by family
|
|
93
|
+
// and directory (its detectRenames), however the pair scores.
|
|
94
|
+
function familyRenames(context, left, weigh, minimum) {
|
|
95
|
+
const { sources, dests, pairs, used, record, scoreOf } = context
|
|
96
|
+
const buckets = new Map()
|
|
97
|
+
const keyed = (files, indices, side) => new Map(indices.filter((i) => files[i].type === 'file').map((i) => {
|
|
98
|
+
const keys = familyKeys(files[i].path)
|
|
99
|
+
for (const key of keys) {
|
|
100
|
+
(buckets.get(key) ?? buckets.set(key, { sources: [], dests: [] }).get(key))[side].push(i)
|
|
101
|
+
}
|
|
102
|
+
return [i, keys]
|
|
103
|
+
}))
|
|
104
|
+
const destKeys = keyed(dests, left.open.filter((d) => !pairs.has(d)), 'dests')
|
|
105
|
+
const sourceKeys = keyed(sources, left.remaining.filter((s) => !used.has(s)), 'sources')
|
|
106
|
+
// The one file of `side` the keys meet, or -1 for none or several.
|
|
107
|
+
const partner = (keys, side) => {
|
|
108
|
+
const met = new Set(keys.flatMap((key) => buckets.get(key)[side]))
|
|
109
|
+
return met.size === 1 ? [...met][0] : -1
|
|
110
|
+
}
|
|
111
|
+
for (const [s, keys] of sourceKeys) {
|
|
112
|
+
const d = partner(keys, 'dests')
|
|
113
|
+
if (d === -1 || partner(destKeys.get(d), 'sources') !== s) continue
|
|
114
|
+
const pair = { s, d, score: scoreOf(s, d, 0) }
|
|
115
|
+
if (!(weigh && outdone(context, left, pair, minimum))) record(d, s, pair.score)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Renames told by path, which git does not look for. Under -M100%, or past the
|
|
120
|
+
// rename limit, no other candidate is scored, so none can be better.
|
|
121
|
+
function pathRenames(context, minimum, limit) {
|
|
122
|
+
const left = unpaired(context)
|
|
123
|
+
const weigh = minimum < MAX_SCORE && left.remaining.length * left.open.length <= limit * limit
|
|
124
|
+
buildRenames(context, left, weigh, minimum)
|
|
125
|
+
familyRenames(context, left, weigh, minimum)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Each index by its basename, or -1 where two share one.
|
|
129
|
+
function byBasename(names, indices) {
|
|
130
|
+
const map = new Map()
|
|
131
|
+
for (const i of indices) map.set(names[i], map.has(names[i]) ? -1 : i)
|
|
132
|
+
return map
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function basenameRenames({ sourceNames, destNames, record, scoreOf }, { remaining, open }, minimum) {
|
|
136
|
+
const byDest = byBasename(destNames, open), bySource = byBasename(sourceNames, remaining)
|
|
137
|
+
for (const s of remaining) {
|
|
138
|
+
const d = byDest.get(sourceNames[s])
|
|
139
|
+
if (d === undefined || d === -1 || bySource.get(sourceNames[s]) === -1) continue
|
|
140
|
+
const score = scoreOf(s, d, minimum)
|
|
141
|
+
if (score >= minimum) record(d, s, score)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// git's candidate order: best score, then a shared basename. Sorts are stable,
|
|
146
|
+
// as git's is, so equals keep destination and slot order.
|
|
147
|
+
const byScore = (a, b) => b.score - a.score || b.nameScore - a.nameScore
|
|
148
|
+
|
|
149
|
+
// git's record_if_better: the first of the worst kept is replaced by a better.
|
|
150
|
+
function keepIfBetter(kept, candidate) {
|
|
151
|
+
if (kept.length < CANDIDATES) {
|
|
152
|
+
kept.push(candidate)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
let worst = 0
|
|
156
|
+
for (let i = 1; i < kept.length; i++) if (byScore(kept[i], kept[worst]) > 0) worst = i
|
|
157
|
+
if (byScore(kept[worst], candidate) > 0) kept[worst] = candidate
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function scoredRenames({ sourceNames, destNames, pairs, used, record, scoreOf }, { remaining, open }, minimum) {
|
|
161
|
+
const all = []
|
|
162
|
+
for (const d of open) {
|
|
163
|
+
const kept = []
|
|
164
|
+
for (const s of remaining) keepIfBetter(kept, { score: scoreOf(s, d, minimum), nameScore: sourceNames[s] === destNames[d] ? 1 : 0, dst: d, src: s })
|
|
165
|
+
all.push(...kept)
|
|
166
|
+
}
|
|
167
|
+
for (const { dst, src, score } of all.sort(byScore)) {
|
|
168
|
+
if (score < minimum) break
|
|
169
|
+
if (!pairs.has(dst) && !used.has(src)) record(dst, src, score)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const unpaired = ({ sources, dests, pairs, used }) => ({
|
|
174
|
+
remaining: sources.keys().filter((s) => !used.has(s)).toArray(),
|
|
175
|
+
open: dests.keys().filter((d) => !pairs.has(d)).toArray(),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// Each added file's rename by its index in `dests`: { source, score }. A
|
|
179
|
+
// `minimum` of MAX_SCORE takes identical content alone, but for `byPath`, and
|
|
180
|
+
// more than `limit` squared pairs left skips the scoring.
|
|
181
|
+
export function findRenames(sources, dests, { minimum, limit, byPath }) {
|
|
182
|
+
const pairs = new Map(), used = new Set()
|
|
183
|
+
if (sources.length === 0 || dests.length === 0) return pairs
|
|
184
|
+
// A file's bytes, its text encoded once, and their span counts once.
|
|
185
|
+
const counted = new Map(), encoded = new Map()
|
|
186
|
+
const bytes = (file) => (file.type === 'file' ? encoded.get(file) ?? encoded.set(file, bytesOf(file.data)).get(file) : null)
|
|
187
|
+
const counts = (data) => counted.get(data) ?? counted.set(data, spanCounts(data)).get(data)
|
|
188
|
+
const context = {
|
|
189
|
+
sources, dests, pairs, used,
|
|
190
|
+
sourceNames: sources.map((file) => basename(file.path)),
|
|
191
|
+
destNames: dests.map((file) => basename(file.path)),
|
|
192
|
+
record(d, s, score) {
|
|
193
|
+
pairs.set(d, { source: s, score })
|
|
194
|
+
used.add(s)
|
|
195
|
+
},
|
|
196
|
+
bytes,
|
|
197
|
+
scoreOf: (s, d, least) => similarity(bytes(sources[s]), bytes(dests[d]), least, counts),
|
|
198
|
+
}
|
|
199
|
+
exactRenames(context)
|
|
200
|
+
if (byPath) pathRenames(context, minimum, limit)
|
|
201
|
+
if (minimum >= MAX_SCORE) return pairs
|
|
202
|
+
basenameRenames(context, unpaired(context), minimum + Math.trunc(0.5 * (MAX_SCORE - minimum)))
|
|
203
|
+
const left = unpaired(context)
|
|
204
|
+
if (left.remaining.length * left.open.length <= limit * limit) scoredRenames(context, left, minimum)
|
|
205
|
+
return pairs
|
|
206
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Whether two byte arrays hold the same bytes, four at a time where both line
|
|
2
|
+
// up alike: two to three times as fast as byte by byte. Node has its own
|
|
3
|
+
// (same-bytes.node.js); indexedDB.cmp, which copies both first, is slower.
|
|
4
|
+
|
|
5
|
+
export function sameBytes(x, y) {
|
|
6
|
+
const n = x.length
|
|
7
|
+
if (n !== y.length) return false
|
|
8
|
+
let i = 0
|
|
9
|
+
if (n >= 32 && (x.byteOffset & 3) === (y.byteOffset & 3)) {
|
|
10
|
+
for (const start = (4 - (x.byteOffset & 3)) & 3; i < start; i++) if (x[i] !== y[i]) return false
|
|
11
|
+
const words = (n - i) >>> 2
|
|
12
|
+
const a = new Int32Array(x.buffer, x.byteOffset + i, words), b = new Int32Array(y.buffer, y.byteOffset + i, words)
|
|
13
|
+
for (let j = 0; j < words; j++) if (a[j] !== b[j]) return false
|
|
14
|
+
i += words * 4
|
|
15
|
+
}
|
|
16
|
+
for (; i < n; i++) if (x[i] !== y[i]) return false
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// same-bytes.js on Node, by its memcmp: three or four times as fast again
|
|
2
|
+
// past a few hundred bytes. The one module here only Node runs, as
|
|
3
|
+
// package.json imports picks it under the `node` condition.
|
|
4
|
+
|
|
5
|
+
import { Buffer } from 'node:buffer'
|
|
6
|
+
|
|
7
|
+
export const sameBytes = (x, y) => x.length === y.length && Buffer.compare(x, y) === 0
|
package/src/script.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// One shortest edit script between two lines' words (Myers): which of them it
|
|
2
|
+
// keeps. Where each step's paths end, one per diagonal walked, is kept in one
|
|
3
|
+
// row, `reached` telling where a step's run starts and its lowest diagonal.
|
|
4
|
+
|
|
5
|
+
// Where the path on diagonal `k` ended at a step, as `reached` tells.
|
|
6
|
+
const endOf = (ends, { lo, start }, k) => ends[start + (k - lo) / 2]
|
|
7
|
+
|
|
8
|
+
// The diagonal a script reaches diagonal `k` from at step `d`: of those either
|
|
9
|
+
// side, the one whose path ends further on, as the step before tells.
|
|
10
|
+
function from(k, d, { low, high }, ends, before) {
|
|
11
|
+
return k === -d || k - 1 < low || (k !== d && k + 1 <= high && endOf(ends, before, k - 1) < endOf(ends, before, k + 1)) ? k + 1 : k - 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The words a script keeps, walked back from its end; the shared ends kept outright.
|
|
15
|
+
function mark(kept, ends, reached, { first, n, m, d: edits, ...bounds }) {
|
|
16
|
+
kept.a.fill(1, 0, first).fill(1, n)
|
|
17
|
+
kept.b.fill(1, 0, first).fill(1, m)
|
|
18
|
+
let x = n, y = m
|
|
19
|
+
for (let d = edits; d > 0; d--) {
|
|
20
|
+
const k = x - y
|
|
21
|
+
const back = from(k, d, bounds, ends, reached[d]), end = endOf(ends, reached[d], back)
|
|
22
|
+
for (let at = back > k ? end : end + 1; at < x; at++) kept.a[at] = kept.b[at - k] = 1
|
|
23
|
+
x = end
|
|
24
|
+
y = x - back
|
|
25
|
+
}
|
|
26
|
+
for (let at = first; at < x; at++) kept.a[at] = kept.b[at] = 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The words of each of two lines the script keeps, marked in `kept`, their
|
|
30
|
+
// shared ends matched outright; and the work it took, the diagonals off both
|
|
31
|
+
// middles never walked, each word read twice over to mark and range it.
|
|
32
|
+
export function script(a, b, kept) {
|
|
33
|
+
let first = 0, m = b.length, n = a.length, steps = 2 * (n + m)
|
|
34
|
+
while (first < n && first < m && a[first] === b[first]) first++
|
|
35
|
+
while (n > first && m > first && a[n - 1] === b[m - 1]) {
|
|
36
|
+
n--
|
|
37
|
+
m--
|
|
38
|
+
}
|
|
39
|
+
const bounds = { high: n - first, low: first - m }, ends = [first], reached = [{ lo: 1, start: 0 }]
|
|
40
|
+
for (let d = 0; d <= bounds.high - bounds.low; d++) {
|
|
41
|
+
const before = reached[d], hi = Math.min(d, bounds.high), lo = Math.max(-d, bounds.low + ((bounds.low + d) & 1))
|
|
42
|
+
reached.push({ lo, start: ends.length })
|
|
43
|
+
for (let k = lo; k <= hi; k += 2) {
|
|
44
|
+
const back = from(k, d, bounds, ends, before), end = endOf(ends, before, back)
|
|
45
|
+
let x = back > k ? end : end + 1
|
|
46
|
+
const start = x
|
|
47
|
+
while (x < n && x - k < m && a[x] === b[x - k]) x++
|
|
48
|
+
steps += 2 + x - start
|
|
49
|
+
ends.push(x)
|
|
50
|
+
if (x >= n && x - k >= m) {
|
|
51
|
+
mark(kept, ends, reached, { first, n, m, d, ...bounds })
|
|
52
|
+
return steps
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error('no edit script between two lines')
|
|
57
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// git's rename score (diffcore-delta.c, estimate_similarity in diffcore-rename.c,
|
|
2
|
+
// git >= 2.44), bit for bit since it decides which renames git finds: bytes of
|
|
3
|
+
// the new file in spans the old file has too, over the larger file's size.
|
|
4
|
+
|
|
5
|
+
// Scores are out of 60000; 50% is 30000.
|
|
6
|
+
export const MAX_SCORE = 60000
|
|
7
|
+
|
|
8
|
+
const HASHBASE = 107927
|
|
9
|
+
|
|
10
|
+
// git's binary test, used here only to decide whether a CR before an LF counts.
|
|
11
|
+
const looksBinary = (data) => data.subarray(0, 8000).includes(0)
|
|
12
|
+
|
|
13
|
+
const spanHash = (accum1, accum2) => ((accum1 + Math.imul(accum2, 0x61)) >>> 0) % HASHBASE
|
|
14
|
+
|
|
15
|
+
// Bytes per span hash, a span ending at an LF or after 64 bytes; sorted by
|
|
16
|
+
// hash, as git keeps them, so two files compare in one walk down both.
|
|
17
|
+
export function spanCounts(data) {
|
|
18
|
+
const text = !looksBinary(data)
|
|
19
|
+
const counts = new Map()
|
|
20
|
+
const add = (hash, n) => counts.set(hash, (counts.get(hash) ?? 0) + n)
|
|
21
|
+
let accum1 = 0, accum2 = 0, n = 0
|
|
22
|
+
for (let i = 0; i < data.length; i++) {
|
|
23
|
+
const c = data[i]
|
|
24
|
+
if (text && c === 0x0D && data[i + 1] === 0x0A) continue
|
|
25
|
+
const old1 = accum1
|
|
26
|
+
accum1 = (((accum1 << 7) ^ (accum2 >>> 25)) + c) >>> 0
|
|
27
|
+
accum2 = ((accum2 << 7) ^ (old1 >>> 25)) >>> 0
|
|
28
|
+
if (++n < 64 && c !== 0x0A) continue
|
|
29
|
+
add(spanHash(accum1, accum2), n)
|
|
30
|
+
n = 0
|
|
31
|
+
accum1 = 0
|
|
32
|
+
accum2 = 0
|
|
33
|
+
}
|
|
34
|
+
if (n > 0) add(spanHash(accum1, accum2), n)
|
|
35
|
+
const hashes = Uint32Array.from(counts.keys()).sort()
|
|
36
|
+
return { hashes, bytes: Float64Array.from(hashes, (hash) => counts.get(hash)) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Two files' bytes, null for a link, which scores 0, as do sizes too far apart
|
|
40
|
+
// for `minimum`, unread. `counts` gives span counts, cached: one file meets many.
|
|
41
|
+
export function similarity(src, dst, minimum, counts) {
|
|
42
|
+
if (src === null || dst === null || dst.length === 0) return 0
|
|
43
|
+
const delta = Math.abs(src.length - dst.length), max = Math.max(src.length, dst.length)
|
|
44
|
+
if (max * (MAX_SCORE - minimum) < delta * MAX_SCORE) return 0
|
|
45
|
+
const a = counts(src), b = counts(dst)
|
|
46
|
+
let copied = 0
|
|
47
|
+
for (let i = 0, j = 0; i < a.hashes.length && j < b.hashes.length;) {
|
|
48
|
+
if (a.hashes[i] < b.hashes[j]) i++
|
|
49
|
+
else if (a.hashes[i] > b.hashes[j]) j++
|
|
50
|
+
else copied += Math.min(a.bytes[i++], b.bytes[j++])
|
|
51
|
+
}
|
|
52
|
+
return Math.trunc(copied * MAX_SCORE / max)
|
|
53
|
+
}
|
package/src/text.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Content as @preventive/diff's lines. Text is UTF-8 (lines are strings) with no
|
|
2
|
+
// NUL, as deptree's compare command takes it; git only wants no NUL in the
|
|
3
|
+
// first 8000 bytes, so a file git diffs as bytes may be binary here.
|
|
4
|
+
|
|
5
|
+
import { utf8toString } from '@exodus/bytes/utf8.js'
|
|
6
|
+
import { changeSet } from '@preventive/diff'
|
|
7
|
+
|
|
8
|
+
// A link's content is its target, as git stores it; a missing side is empty.
|
|
9
|
+
export function textOf(file) {
|
|
10
|
+
if (file === null) return ''
|
|
11
|
+
const content = file.type === 'symlink' ? file.target : file.data
|
|
12
|
+
if (typeof content === 'string') return content.includes('\0') ? undefined : content
|
|
13
|
+
if (content.includes(0)) return undefined
|
|
14
|
+
try {
|
|
15
|
+
return utf8toString(content)
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const WHITESPACE = new Set(['none', 'all', 'change', 'trailing'])
|
|
22
|
+
|
|
23
|
+
// diff's options for a change set: how far into the lines the two files share
|
|
24
|
+
// at either end a run free to move may go (`horizon`: without limit, as git
|
|
25
|
+
// has it, unless given -- 0 as diff -U0 has it, the context as -u does); and
|
|
26
|
+
// which lines are the same (-w, -b, -Z as `whitespace`; -i).
|
|
27
|
+
export function checkLineOptions({ horizon = Infinity, whitespace = 'none', ignoreCase = false } = {}) {
|
|
28
|
+
if (horizon !== Infinity && !(Number.isSafeInteger(horizon) && horizon >= 0)) throw new RangeError(`horizon must be a whole number or Infinity, not ${horizon}`)
|
|
29
|
+
if (!WHITESPACE.has(whitespace)) throw new RangeError(`whitespace must be 'none', 'all', 'change' or 'trailing', not ${whitespace}`)
|
|
30
|
+
if (typeof ignoreCase !== 'boolean') throw new TypeError(`ignoreCase must be a boolean, not ${typeof ignoreCase}`)
|
|
31
|
+
return { horizon, whitespace, ignoreCase }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Binary content has no blocks and no counts, as --numstat prints `-` for it.
|
|
35
|
+
export function contentOf(old, fresh, options) {
|
|
36
|
+
const a = textOf(old), b = textOf(fresh)
|
|
37
|
+
if (a === undefined || b === undefined) return { binary: true, blocks: [], insertions: null, deletions: null }
|
|
38
|
+
const blocks = a === b ? [] : changeSet(a, b, options)
|
|
39
|
+
const lines = (from, to) => blocks.reduce((n, block) => n + block[to] - block[from], 0)
|
|
40
|
+
return { binary: false, blocks, insertions: lines('b0', 'b1'), deletions: lines('a0', 'a1') }
|
|
41
|
+
}
|
package/src/tree.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// A tree as git sees one: files and links by path, read from tar-shaped entries
|
|
2
|
+
// (archive's unpack, Vfs.entries), or by names taken as keys. Directories add
|
|
3
|
+
// nothing, and a hard link is a copy of its target.
|
|
4
|
+
|
|
5
|
+
import { utf8fromString } from '@exodus/bytes/utf8.js'
|
|
6
|
+
import { sameBytes } from '#same-bytes'
|
|
7
|
+
import { CompareError } from './error.js'
|
|
8
|
+
|
|
9
|
+
const FILE = new Set(['file', 'contiguous-file'])
|
|
10
|
+
const OTHER = new Set(['fifo', 'character-device', 'block-device'])
|
|
11
|
+
const BAD_SEGMENT = /(?:^|\/)\.{0,2}(?:\/|$)/u
|
|
12
|
+
|
|
13
|
+
function checkMode(mode) {
|
|
14
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o7777) throw new RangeError(`a mode must be an integer from 0 to 0o7777, not ${mode}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// `.` is the root, which only a directory may name. A lone surrogate has no
|
|
18
|
+
// UTF-8, so no name git could hold. A key may be any non-empty name, `.` too.
|
|
19
|
+
function checkName(name, type, tree, keys) {
|
|
20
|
+
if (typeof name !== 'string') throw new TypeError(`an entry's name must be a string, not ${typeof name}`)
|
|
21
|
+
if (name === '.' && type === 'directory') return
|
|
22
|
+
if (keys ? name === '' : BAD_SEGMENT.test(name)) throw new CompareError(keys ? 'a name must not be empty' : 'a name must be a relative path with no empty, `.` or `..` segment', name, tree)
|
|
23
|
+
if (!name.isWellFormed()) throw new CompareError('a name must be well-formed Unicode', name, tree)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function checkLinkname(linkname, name, tree) {
|
|
27
|
+
if (typeof linkname !== 'string' || linkname === '') throw new CompareError('a link must name its target', name, tree)
|
|
28
|
+
if (!linkname.isWellFormed()) throw new CompareError('a link target must be well-formed Unicode', name, tree)
|
|
29
|
+
return linkname
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Text is kept as text, to be compared as it is; it must have UTF-8 to stand for.
|
|
33
|
+
function dataOf(data) {
|
|
34
|
+
if (data === undefined) return new Uint8Array(0)
|
|
35
|
+
if (data instanceof Uint8Array) return data
|
|
36
|
+
if (typeof data !== 'string') throw new TypeError(`a file's data must be a string or a Uint8Array, not ${typeof data}`)
|
|
37
|
+
if (!data.isWellFormed()) throw new TypeError("a file's text must be well-formed Unicode")
|
|
38
|
+
return data
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The bytes a file's data stands for: its text as UTF-8.
|
|
42
|
+
export const bytesOf = (data) => (typeof data === 'string' ? utf8fromString(data) : data)
|
|
43
|
+
|
|
44
|
+
function readEntry(entry, tree, keys) {
|
|
45
|
+
if (typeof entry !== 'object' || entry === null) throw new TypeError(`an entry must be an object, not ${entry === null ? 'null' : typeof entry}`)
|
|
46
|
+
const { name, type = 'file', mode, linkname, data } = entry
|
|
47
|
+
checkName(name, type, tree, keys)
|
|
48
|
+
if (mode !== undefined) checkMode(mode)
|
|
49
|
+
if (FILE.has(type)) return { path: name, type: 'file', mode: mode ?? 0o644, data: dataOf(data) }
|
|
50
|
+
if (type === 'symlink' || type === 'hardlink') return { path: name, type, target: checkLinkname(linkname, name, tree) }
|
|
51
|
+
if (type === 'directory') return { path: name, type }
|
|
52
|
+
if (OTHER.has(type)) throw new CompareError(`a ${type} is neither a file nor a link`, name, tree)
|
|
53
|
+
throw new TypeError(`${JSON.stringify(type)} is not an entry type`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A mode is not content.
|
|
57
|
+
export function sameContent(a, b) {
|
|
58
|
+
if (a.type !== b.type) return false
|
|
59
|
+
if (a.type === 'symlink') return a.target === b.target
|
|
60
|
+
if (a.data === b.data) return true
|
|
61
|
+
if (typeof a.data === 'string' && typeof b.data === 'string') return false
|
|
62
|
+
return sameBytes(bytesOf(a.data), bytesOf(b.data))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Through any chain of hard links, to a file or a link of the tree.
|
|
66
|
+
function resolveLink(link, held, tree) {
|
|
67
|
+
const seen = new Set([link.path])
|
|
68
|
+
let target = held.get(link.target)
|
|
69
|
+
while (target?.type === 'hardlink') {
|
|
70
|
+
if (seen.has(target.path)) throw new CompareError('a hard link leads back to itself', link.path, tree)
|
|
71
|
+
seen.add(target.path)
|
|
72
|
+
target = held.get(target.target)
|
|
73
|
+
}
|
|
74
|
+
if (target === undefined || target.type === 'directory') throw new CompareError('a hard link must name a file or a link in the tree', link.path, tree)
|
|
75
|
+
return { ...target, path: link.path }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Nothing may lie under a file or a link, where a tar extracting it would
|
|
79
|
+
// write through the link -- unless names are keys, and no tree's paths.
|
|
80
|
+
export function readTree(entries, tree, keys) {
|
|
81
|
+
const held = new Map()
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const read = readEntry(entry, tree, keys)
|
|
84
|
+
if (read.type === 'directory' && read.path === '.') continue
|
|
85
|
+
if (held.has(read.path)) throw new CompareError('a name is given twice', read.path, tree)
|
|
86
|
+
held.set(read.path, read)
|
|
87
|
+
}
|
|
88
|
+
const files = new Map()
|
|
89
|
+
for (const [path, read] of held) {
|
|
90
|
+
for (let at = keys ? -1 : path.lastIndexOf('/'); at > 0; at = path.lastIndexOf('/', at - 1)) {
|
|
91
|
+
const above = held.get(path.slice(0, at))
|
|
92
|
+
if (above !== undefined && above.type !== 'directory') throw new CompareError(`a path lies under a ${above.type === 'symlink' ? 'link' : 'file'}`, path, tree)
|
|
93
|
+
}
|
|
94
|
+
if (read.type === 'hardlink') files.set(path, resolveLink(read, held, tree))
|
|
95
|
+
else if (read.type !== 'directory') files.set(path, read)
|
|
96
|
+
}
|
|
97
|
+
return files
|
|
98
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Whether diff -w, as `blocks` were found with it, hides a change in blanks of
|
|
2
|
+
// more than one kind: a line kept as unchanged whose blanks between the same
|
|
3
|
+
// two characters differ and mix kinds, as spaces turned to tabs.
|
|
4
|
+
|
|
5
|
+
import { blocksHold } from './blocks.js'
|
|
6
|
+
|
|
7
|
+
// The blanks diff -w leaves out of a line: the C locale's.
|
|
8
|
+
const BLANK = /[ \t\n\v\f\r]/u
|
|
9
|
+
|
|
10
|
+
const lines = (text) => {
|
|
11
|
+
const list = text.split('\n')
|
|
12
|
+
if (list.at(-1) === '') list.pop()
|
|
13
|
+
return list
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Two lines alike but for their blanks: whether, before any of their other
|
|
17
|
+
// characters or after the last, their blanks differ and hold two kinds.
|
|
18
|
+
function mixesBlanks(x, y) {
|
|
19
|
+
for (let i = 0, j = 0; ; i++, j++) {
|
|
20
|
+
let gapX = '', gapY = ''
|
|
21
|
+
while (i < x.length && BLANK.test(x[i])) gapX += x[i++]
|
|
22
|
+
while (j < y.length && BLANK.test(y[j])) gapY += y[j++]
|
|
23
|
+
if (gapX !== gapY && new Set(gapX + gapY).size > 1) return true
|
|
24
|
+
if (i >= x.length || j >= y.length) return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Blocks that don't carry `before` to `after` under -w, or are not blocks at
|
|
29
|
+
// all, may hide anything and would line up the wrong lines here: they count as hiding.
|
|
30
|
+
export function hidesMixedWhitespace(before, after, blocks) {
|
|
31
|
+
try {
|
|
32
|
+
if (!blocksHold(before, after, blocks, { whitespace: 'all' })) return true
|
|
33
|
+
} catch {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
const a = lines(before), b = lines(after)
|
|
37
|
+
let ai = 0, bi = 0
|
|
38
|
+
for (let run = 0; run <= blocks.length; run++) {
|
|
39
|
+
const end = run < blocks.length ? blocks[run].a0 : a.length
|
|
40
|
+
for (; ai < end; ai++, bi++) if (a[ai] !== b[bi] && mixesBlanks(a[ai], b[bi])) return true
|
|
41
|
+
if (run < blocks.length) ({ a1: ai, b1: bi } = blocks[run])
|
|
42
|
+
}
|
|
43
|
+
return false
|
|
44
|
+
}
|