@preventive/compare 1.2.7 → 1.2.8

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/compare.d.ts CHANGED
@@ -84,7 +84,9 @@ export interface FileChange extends FileStatus {
84
84
  // `diff -U0` and the normal format, the context to match `diff -u` (diff's
85
85
  // --horizon-lines); `whitespace` (-w as 'all', -b as 'change', -Z as
86
86
  // 'trailing') and `ignoreCase` (-i) say which lines are the same, so that a
87
- // file changed in nothing else has no blocks.
87
+ // file changed in nothing else has no blocks. Ignoring blanks or case never
88
+ // shows more lines than minding them, and where it shows no fewer, its blocks
89
+ // are those minding them: it does nothing there.
88
90
  export interface LineOptions {
89
91
  horizon?: number
90
92
  whitespace?: 'none' | 'all' | 'change' | 'trailing'
@@ -110,17 +112,19 @@ export interface LineOptions {
110
112
  // and their directories but for one -- not a package's name under
111
113
  // node_modules -- each the other's only such partner. Either gives way only
112
114
  // to a candidate that is a rename by itself and scores at least halfway from
113
- // the pair's score to 100%. Last, of the files no other pass took, it pairs
114
- // bundle chunks whose hash changed with their content: in one directory, of
115
- // one extension, names alike but for what follows the last separator (`-`,
116
- // `.`, `_`) they share, six or more characters of a hash's (letters, digits,
117
- // `-` and `_`, not lowercase letters alone), as `index-CyWAfh-p.js` and
118
- // `index-hRvO4yU2.js` are. Minified onto a line, such a chunk scores little
119
- // as git splits it, so it is scored with its spans ending after `,` `;` `=`
120
- // `{` `}` too, and taken at git's stricter score for a basename, halfway from
121
- // -M's to 100%: the best pairs first, its similarity that score, and none
122
- // past the rename limit, as they are scored. git has no such passes, so false
123
- // is git's own -M.
115
+ // the pair's score to 100%. And before git scores every pair left, lest a file
116
+ // elsewhere take either version, it pairs bundle chunks whose hash changed
117
+ // with their content: in one directory, names alike but for what follows the
118
+ // last separator (`-`, `.`, `_`) they share, up to the extensions they end in
119
+ // alike, six or more characters of a hash's (letters, digits, `-` and `_`, not
120
+ // lowercase letters alone), as `index-CyWAfh-p.js` and `index-hRvO4yU2.js`
121
+ // are, or `main.3f9a2c1d.chunk.js` and `main.8b7e6a5f.chunk.js`. Minified onto
122
+ // a line, such a chunk scores little as git splits it, so it is scored with
123
+ // its spans ending after `,` `;` `=` `{` `}` too, and taken at git's stricter
124
+ // score for a basename, halfway from -M's to 100%: the best pairs first, its
125
+ // similarity that score, giving way as a pair by path does but only where git
126
+ // takes the candidate it gives way to, and none past the rename limit, as they
127
+ // are scored. git has no such passes, so false is git's own -M.
124
128
  //
125
129
  // `renameLimit` is git's diff.renameLimit: when more deleted files times added
126
130
  // files are left, in a group, after the renames that need no scoring than its
package/names.d.ts CHANGED
@@ -27,35 +27,47 @@ export type RenameReason = 'unparsed' | 'dynamic' | 'jsx' | 'block-function' | '
27
27
  // other script reads it: the same file loaded alike -- a name exported or
28
28
  // imported as itself, or a shorthand property's), checked to bind
29
29
  // every name as before by this package's scope analysis and again by oxc's
30
- // minifier; formatted again where that changes its layout alone and still binds
31
- // alike; diffed as diff -U0 has it, and as --minimal does where that is cheap
32
- // (lines by lines changed up to 2e8); and the blocks checked to take `before` to
30
+ // minifier, line for line the old side's (`before`, each line where it was);
31
+ // diffed as diff -U0 has it, and as --minimal does where that is cheap
32
+ // (the square of the lines changed up to 2e8, or 10,000 a line of the two
33
+ // texts); and the blocks checked to take `before` to
33
34
  // the new side (thrown should they not). `reason` says why no names were given;
34
35
  // `reads` the properties either text reads names through (`name`, `toString`,
35
36
  // `stack`, `callee`, `caller`), sorted, as code reading its names may act
36
37
  // otherwise renamed: none where none is renamed, and `name` only where a name
37
38
  // renamed is a function's or class's, the one name it reads (`function f`,
38
- // `f = () => {}`); `marks` the plain diff's blocks that differ in renamed names
39
- // alone; and `warnings` what a check that should never fail found.
39
+ // `f = () => {}`); `assumed` all a script's top level was taken as its own on,
40
+ // where a name there changed or was given to one from there (none where
41
+ // none): 'module' where a version of the file is one, 'minified' where a
42
+ // script's top level declares 16 names or more, every one short; `marks` the
43
+ // plain diff's blocks that differ in renamed names alone; and `warnings` what
44
+ // a check that should never fail found.
40
45
  export function renamedDiff(before: string, after: string, options?: RenameOptions & { whitespace?: Whitespace }): Promise<{
41
46
  before: string
42
47
  blocks: Pick<Block, 'a0' | 'a1' | 'b0' | 'b1'>[]
43
48
  reason: RenameReason | null
44
49
  reads: string[]
50
+ assumed: TopLevelAssumption[]
45
51
  marks: Pick<Block, 'a0' | 'a1' | 'b0' | 'b1'>[]
46
52
  warnings: string[]
47
53
  }>
48
54
 
55
+ // What a script's top level renamed as a module's is taken to be on, each one apart.
56
+ export type TopLevelAssumption = 'module' | 'minified'
57
+
49
58
  export type Whitespace = 'none' | 'all' | 'change' | 'trailing'
50
59
 
51
60
  // renamedDiff for each of `whitespace`'s settings (`['none']` unless given),
52
61
  // `diffs` in their order: the names found and the old side given them once
53
- // for all, as are `before`, `reason`, `reads` and `warnings`; each setting's
54
- // `blocks` checked as renamedDiff's are, and its `marks` read as they are.
62
+ // for all, as are `before`, `reason`, `reads`, `assumed` and `warnings`; each setting's
63
+ // `blocks` checked as renamedDiff's are, and its `marks` read as they are. A
64
+ // setting ignoring blanks never shows more lines than 'none', and where it
65
+ // shows no fewer, its blocks are those of 'none': it does nothing there.
55
66
  export function renamedDiffs(before: string, after: string, options?: RenameOptions & { whitespace?: Whitespace[] }): Promise<{
56
67
  before: string
57
68
  reason: RenameReason | null
58
69
  reads: string[]
70
+ assumed: TopLevelAssumption[]
59
71
  diffs: { whitespace: Whitespace; blocks: Pick<Block, 'a0' | 'a1' | 'b0' | 'b1'>[]; marks: Pick<Block, 'a0' | 'a1' | 'b0' | 'b1'>[] }[]
60
72
  warnings: string[]
61
73
  }>
package/names.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // JavaScript minified on both sides, its renamed names told apart from its
2
2
  // changes: the old side given the new side's names wherever a minifier
3
3
  // renamed a variable alike throughout, so that the two then diff as they are.
4
- // Built on oxc-parser and oxc-minify, and oxfmt for renamedDiff, optional
5
- // peers: on Node loaded on first use, elsewhere oxc with this module.
4
+ // Built on oxc-parser and oxc-minify, optional peers: on Node loaded on first
5
+ // use, elsewhere oxc with this module.
6
6
  // compare.js loads none. Typed and described in names.d.ts.
7
7
 
8
8
  // The diff of two such files with renamed names set aside, checked to hide no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/compare",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Git-compatible file tree diffs and minified JavaScript comparison",
5
5
  "type": "module",
6
6
  "main": "./compare.js",
package/src/chunks.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Bundle chunks a build renamed: `index-CyWAfh-p.js` for `index-hRvO4yU2.js`
2
- // in one directory, the hash in a name following its content. Minified code
3
- // is a line or a few, so a chunk is scored with spans ending after punctuation.
2
+ // in one directory, the hash in a name following its content, and the same
3
+ // for `main.3f9a2c1d.chunk.js` or `index-CyWAfh-p.js.map`, extensions after
4
+ // the hash. Minified code is a line or a few, so a chunk is scored with spans
5
+ // ending after punctuation.
4
6
 
5
7
  // A content hash: six or more of base64url's characters, not all lowercase
6
8
  // letters, which a word is.
@@ -13,23 +15,28 @@ function nameOf(path) {
13
15
  const name = path.slice(slash + 1)
14
16
  const dot = name.lastIndexOf('.')
15
17
  const extension = dot > 0 ? name.slice(dot) : ''
16
- const stem = name.slice(0, name.length - extension.length)
17
- return { stem, key: `${path.slice(0, slash + 1)}\0${extension}\0${stem.length}` }
18
+ return { name, key: `${path.slice(0, slash + 1)}\0${extension}\0${name.length}` }
18
19
  }
19
20
 
20
- // Whether two stems of a length are one chunk's, its hash all that differs:
21
- // what follows the last separator they share before they differ.
21
+ // Whether two names of a length are one chunk's, its hash all that differs:
22
+ // what follows the last separator they share before they differ, up to the
23
+ // first `.` of the end they share, their extensions.
22
24
  function oneChunk(a, b) {
23
- let start = 0
25
+ let end = a.length, start = 0
24
26
  while (start < a.length && a[start] === b[start]) start++
27
+ while (end > start && a[end - 1] === b[end - 1]) end--
25
28
  while (start > 0 && !SEPARATORS.includes(a[start - 1])) start--
26
- const x = a.slice(start), y = b.slice(start)
29
+ while (end < a.length && a[end] !== '.') end++
30
+ const x = a.slice(start, end), y = b.slice(start, end)
27
31
  return start > 0 && HASH.test(x) && HASH.test(y) && (RANDOM.test(x) || RANDOM.test(y))
28
32
  }
29
33
 
30
- // Chunks no other pass claimed, by directory, extension and name's length,
31
- // each pair at least `bar`: the best first, as git takes its candidates.
32
- export function chunkRenames({ sources, dests, pairs, used, record, punctuatedScoreOf }, { remaining, open }, bar) {
34
+ // Chunks git's scored pass has yet to claim, by directory, extension and
35
+ // name's length, each pair at least `bar`: the best first, as git takes its
36
+ // candidates, but for a pair `outdone` by a file's other candidate, set aside
37
+ // and returned, best first, for git to pair that candidate with another file:
38
+ // it holds its files, so that a later pair of either is set aside behind it.
39
+ export function chunkRenames({ sources, dests, pairs, used, record, punctuatedScoreOf }, { remaining, open }, bar, outdone) {
33
40
  const names = (files, indices) => new Map(indices.filter((i) => files[i].type === 'file').map((i) => [i, nameOf(files[i].path)]))
34
41
  const destNames = names(dests, open), sourceNames = names(sources, remaining)
35
42
  const byKey = Map.groupBy(sourceNames.keys(), (s) => sourceNames.get(s).key)
@@ -39,13 +46,20 @@ export function chunkRenames({ sources, dests, pairs, used, record, punctuatedSc
39
46
  const ss = byKey.get(key) ?? []
40
47
  for (const d of ds) {
41
48
  for (const s of ss) {
42
- if (!oneChunk(sourceNames.get(s).stem, destNames.get(d).stem)) continue
49
+ if (!oneChunk(sourceNames.get(s).name, destNames.get(d).name)) continue
43
50
  const score = punctuatedScoreOf(s, d, bar)
44
51
  if (score >= bar) candidates.push({ s, d, score })
45
52
  }
46
53
  }
47
54
  }
48
- for (const { s, d, score } of candidates.sort((x, y) => y.score - x.score)) {
49
- if (!pairs.has(d) && !used.has(s)) record(d, s, score)
55
+ const aside = [], heldDests = new Set(), heldSources = new Set()
56
+ for (const pair of candidates.sort((x, y) => y.score - x.score)) {
57
+ if (pairs.has(pair.d) || used.has(pair.s)) continue
58
+ if (heldSources.has(pair.s) || heldDests.has(pair.d) || outdone(pair)) {
59
+ aside.push(pair)
60
+ heldSources.add(pair.s)
61
+ heldDests.add(pair.d)
62
+ } else record(pair.d, pair.s, pair.score)
50
63
  }
64
+ return aside
51
65
  }
package/src/marked.js CHANGED
@@ -2,9 +2,8 @@
2
2
  // (`before` renamed, line for line) has them, are their new lines: changed in
3
3
  // renamed names alone. null where `given` has another number of lines.
4
4
 
5
- import { changeSet } from '@preventive/diff'
6
5
  import { blocksHold } from './blocks.js'
7
- import { checkLineOptions } from './text.js'
6
+ import { checkLineOptions, lineChangeSet } from './text.js'
8
7
 
9
8
  const records = (text) => (text === '' ? [] : text.split(/(?<=\n)/u))
10
9
 
@@ -14,7 +13,7 @@ export function markedBlocks(before, given, after, options) {
14
13
  if (renamed.length !== records(before).length) return null
15
14
  // Lines alike are those an empty change set takes from one to the other.
16
15
  const alike = (olds, news) => blocksHold(olds.join(''), news.join(''), [], lineOptions)
17
- return changeSet(before, after, lineOptions)
16
+ return lineChangeSet(before, after, lineOptions)
18
17
  .filter(({ a0, a1, b0, b1, insert }) => a1 - a0 === b1 - b0 && alike(renamed.slice(a0, a1), insert))
19
18
  .map(({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 }))
20
19
  }
package/src/names/diff.js CHANGED
@@ -1,47 +1,46 @@
1
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 at its shortest where affordable, and the diff
4
- // checked to hide no change.
2
+ // old side renamed, line for line the old side's, diffed at its shortest where
3
+ // affordable, and the diff checked to hide no change.
5
4
 
6
5
  import { blocksHold } from '../blocks.js'
7
6
  import { markedBlocks } from '../marked.js'
8
- import { format } from '../pretty.js'
9
- import { checkLineOptions, shortestChangeSet } from '../text.js'
10
- import { bindsAlike } from './binds.js'
7
+ import { checkLineOptions, shownChangeSet } from '../text.js'
11
8
  import { renameMap } from './rename.js'
12
9
 
13
10
  const ranges = ({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 })
14
11
 
15
12
  // The same, diffed once for each whitespace setting given, in order: the names
16
13
  // found and the old side given them once for all.
17
- export async function renamedDiffs(before, after, { extension = 'js', otherExtension = extension, whitespace = ['none'] } = {}) {
14
+ function diffsOf(before, after, { extension = 'js', otherExtension = extension, whitespace = ['none'] } = {}) {
18
15
  if (!Array.isArray(whitespace)) throw new TypeError(`whitespace must be an array, not ${typeof whitespace}`)
19
16
  const settings = whitespace.map((setting) => checkLineOptions({ horizon: 0, whitespace: setting }))
20
17
  const warnings = []
21
18
  const mapped = renameMap(before, after, { extension, otherExtension })
22
19
  if (mapped.invariant) warnings.push(`a rename map check failed: ${mapped.invariant}`)
23
- let renamed = before
24
- if (!mapped.reason) {
25
- // A name's new length may move the lines it is on.
26
- const formatted = await format(mapped.text, extension)
27
- const read = 'code' in formatted && bindsAlike(before, formatted.code, mapped.kind)
28
- if ('code' in formatted && !read) warnings.push("the renamed old side, formatted, read otherwise")
29
- renamed = read ? formatted.code : mapped.text
30
- }
20
+ // Not formatted again: its lines are the old side's, each where it was, and
21
+ // formatting a formatted text lays it out other than the new side was.
22
+ const renamed = mapped.reason ? before : mapped.text
31
23
  let unmarked = false
32
24
  const diffs = settings.map((options) => {
33
- const blocks = shortestChangeSet(renamed, after, options).map(ranges)
25
+ const blocks = shownChangeSet(renamed, after, options).map(ranges)
34
26
  if (!blocksHold(renamed, after, blocks, options)) throw new Error('change blocks that do not take the old side to the new')
35
- // Read off the renamed text before formatting, line for line the old side's.
27
+ // The plain diff's blocks the renamed text, line for line the old side's, says differ in names alone.
36
28
  const marks = mapped.reason ? [] : markedBlocks(before, mapped.text, after, options)
37
29
  unmarked ||= marks === null
38
30
  return { whitespace: options.whitespace, blocks, marks: marks ?? [] }
39
31
  })
40
32
  if (unmarked) warnings.push('a renamed text with lines the old one does not have; none marked')
41
- return { before: renamed, reason: mapped.reason ?? null, reads: mapped.reads ?? [], diffs, warnings }
33
+ return { before: renamed, reason: mapped.reason ?? null, reads: mapped.reads ?? [], assumed: mapped.assumed ?? [], diffs, warnings }
34
+ }
35
+
36
+ // A promise still, as callers await it: what goes wrong rejects it.
37
+ export function renamedDiffs(before, after, options) {
38
+ return new Promise((resolve) => {
39
+ resolve(diffsOf(before, after, options))
40
+ })
42
41
  }
43
42
 
44
43
  export async function renamedDiff(before, after, { whitespace = 'none', ...options } = {}) {
45
- const { before: renamed, reason, reads, diffs: [{ blocks, marks }], warnings } = await renamedDiffs(before, after, { ...options, whitespace: [whitespace] })
46
- return { before: renamed, blocks, reason, reads, marks, warnings }
44
+ const { before: renamed, reason, reads, assumed, diffs: [{ blocks, marks }], warnings } = await renamedDiffs(before, after, { ...options, whitespace: [whitespace] })
45
+ return { before: renamed, blocks, reason, reads, assumed, marks, warnings }
47
46
  }
@@ -42,7 +42,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
42
42
  // by its syntax: a bundle's chunk that one version exports from, the other not,
43
43
  // or that neither does, its top level renamed by a minifier as a module's is.
44
44
  // Where either version is one, a script's top level is its own too.
45
- if ([a, b].some((side) => side.module || minifiedTopLevel(side))) [a, b] = [ownTopLevel(a), ownTopLevel(b)]
45
+ // Each says so apart: a version a module; a script's top level a minifier's throughout.
46
+ const scripts = [a, b].map((side) => !side.own)
47
+ const why = [[a, b].some((side) => side.module) && 'module', [a, b].some((side, i) => scripts[i] && minifiedTopLevel(side)) && 'minified'].filter(Boolean)
48
+ if (why.length > 0) [a, b] = [ownTopLevel(a), ownTopLevel(b)]
46
49
  checkAnalysis(before, a)
47
50
  checkAnalysis(after, b)
48
51
  const paired = pairing(before, after, a, b)
@@ -65,7 +68,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
65
68
  // class's alone: one only where a name renamed is one's.
66
69
  const naming = changed.some((variable) => variable.naming || paired.map.get(variable)?.naming)
67
70
  const reads = changed.length === 0 ? [] : [...new Set([...a.reads, ...b.reads])].filter((read) => read !== 'name' || naming).toSorted()
68
- return { text, renamed, apart: changed.length - renamed, kind: { module: a.module, commonjs: a.commonjs, own: a.own }, reads }
71
+ // A script's top level taken as its own, as all `why` says, where a name there changed or took one from there.
72
+ const root = (variable) => variable.scope.parent === null
73
+ const assumed = changed.some((variable) => (scripts[0] && root(variable)) || (scripts[1] && paired.map.has(variable) && root(paired.map.get(variable)))) ? why : []
74
+ return { text, renamed, apart: changed.length - renamed, kind: { module: a.module, commonjs: a.commonjs, own: a.own }, reads, assumed }
69
75
  }
70
76
 
71
77
  // Any failure gives no names, and tells what failed. oxc is loaded first, out
package/src/patch.js CHANGED
@@ -3,9 +3,9 @@
3
3
  // blocks, so a run of lines free to move may sit elsewhere than git puts it.
4
4
 
5
5
  import { utf8fromString, utf8toString } from '@exodus/bytes/utf8.js'
6
- import { changeSet, formatChangeSet } from '@preventive/diff'
6
+ import { formatChangeSet } from '@preventive/diff'
7
7
  import { quote } from './quote.js'
8
- import { checkLineOptions, textOf } from './text.js'
8
+ import { checkLineOptions, lineChangeSet, textOf } from './text.js'
9
9
  import { sameContent } from './tree.js'
10
10
 
11
11
  const STATUSES = new Set(['added', 'deleted', 'modified', 'renamed'])
@@ -45,7 +45,7 @@ function body(one, two, blocks, from, to, { context, ...options }) {
45
45
  if (oldText === undefined || newText === undefined) {
46
46
  return one !== null && two !== null && sameContent(one, two) ? '' : `Binary files ${from} and ${to} differ\n`
47
47
  }
48
- const hunks = formatChangeSet(oldText, newText, blocks ?? changeSet(oldText, newText, options), { ...options, context, label: hunkNames(oldText) })
48
+ const hunks = formatChangeSet(oldText, newText, blocks ?? lineChangeSet(oldText, newText, options), { ...options, context, label: hunkNames(oldText) })
49
49
  return hunks === '' ? '' : `--- ${label(from)}\n+++ ${label(to)}\n${hunks}`
50
50
  }
51
51
 
package/src/pretty.js CHANGED
@@ -31,8 +31,10 @@ const sorted = (flags) => [...flags].toSorted().join('')
31
31
  // A script's text but for what the formatter may respell, beside the syntax tree compared after:
32
32
  // blanks, parentheses, semicolons, quotes and numbers, and a comma before a closing bracket, past any
33
33
  // comments (`import { a, b, }`). A regular expression's flags it may reorder are sorted where the
34
- // syntax tree has them (respelled).
35
- const scriptForm = (text) => text.replaceAll(NUMBER, numberSpelling).replaceAll(/,(?=(?:\s|\/\/[^\n]*|\/\*[^]*?\*\/)*[\])}>])/gu, '')
34
+ // syntax tree has them (respelled). Each comment read one way alone, to its line's end (any line
35
+ // terminator's) or its first `*/`: one that might end anywhere tries, where no bracket follows, every
36
+ // way to split a run of them.
37
+ const scriptForm = (text) => text.replaceAll(NUMBER, numberSpelling).replaceAll(/,(?=(?:\s|\/\/[^\n\r\u2028\u2029]*(?![^\n\r\u2028\u2029])|\/\*(?:[^*]|\*(?!\/))*\*\/)*[\])}>])/gu, '')
36
38
  .replaceAll(/\\(?=["'])|[\s();]/gu, '').replaceAll("'", '"')
37
39
 
38
40
  // JSON has no point without a digit either side: such a number is kept as written, or a copy would read as
package/src/renames.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // git's -M pairing (diffcore_rename_extended): identical content, then a
2
2
  // basename unique among both sides' leftovers at a stricter score, then every
3
- // pair left scored; between the first two and after the last, ours, renames
3
+ // pair left scored; between the first two and before the last, ours, renames
4
4
  // told by path, and bundle chunks (chunks.js).
5
5
 
6
6
  import { crc32 } from '@exodus/bytes/crc.js'
@@ -71,11 +71,14 @@ function familyKeys(path) {
71
71
  return keys
72
72
  }
73
73
 
74
- // Whether either file of a pair by path has a candidate that is a rename by
75
- // itself and scores at least halfway from the pair's score to 100%: sizes too
76
- // far apart for that go unread.
74
+ // What a candidate scores to outdo a pair by path or a chunk's: halfway from
75
+ // the pair's score to 100%, and a rename by itself.
76
+ const halfway = (score, minimum) => Math.max(minimum, score + Math.trunc((MAX_SCORE - score) / 2))
77
+
78
+ // Whether either file of such a pair has a candidate that outdoes it: sizes
79
+ // too far apart for that go unread.
77
80
  function outdone({ pairs, used, scoreOf }, { remaining, open }, { s, d, score }, minimum) {
78
- const better = Math.max(minimum, score + Math.trunc((MAX_SCORE - score) / 2))
81
+ const better = halfway(score, minimum)
79
82
  return open.some((o) => o !== d && !pairs.has(o) && scoreOf(s, o, better) >= better)
80
83
  || remaining.some((r) => r !== s && !used.has(r) && scoreOf(r, d, better) >= better)
81
84
  }
@@ -159,17 +162,29 @@ function keepIfBetter(kept, candidate) {
159
162
  if (byScore(kept[worst], candidate) > 0) kept[worst] = candidate
160
163
  }
161
164
 
162
- function scoredRenames({ sourceNames, destNames, pairs, used, record, scoreOf }, { remaining, open }, minimum) {
165
+ // git's walk down every pair left, best first. A chunk set aside (`aside`,
166
+ // best first) is taken where its files are both free once the walk is past
167
+ // what could outdo it, before a pair scoring less takes either.
168
+ function scoredRenames({ sourceNames, destNames, pairs, used, record, scoreOf }, { remaining, open }, minimum, aside) {
163
169
  const all = []
164
170
  for (const d of open) {
165
171
  const kept = []
166
172
  for (const s of remaining) keepIfBetter(kept, { score: scoreOf(s, d, minimum), nameScore: sourceNames[s] === destNames[d] ? 1 : 0, dst: d, src: s })
167
173
  all.push(...kept)
168
174
  }
175
+ let next = 0
176
+ const settle = (below) => {
177
+ for (; next < aside.length && halfway(aside[next].score, minimum) > below; next++) {
178
+ const { s, d, score } = aside[next]
179
+ if (!pairs.has(d) && !used.has(s)) record(d, s, score)
180
+ }
181
+ }
169
182
  for (const { dst, src, score } of all.sort(byScore)) {
170
183
  if (score < minimum) break
184
+ settle(score)
171
185
  if (!pairs.has(dst) && !used.has(src)) record(dst, src, score)
172
186
  }
187
+ settle(-Infinity)
173
188
  }
174
189
 
175
190
  const unpaired = ({ sources, dests, pairs, used }) => ({
@@ -210,7 +225,7 @@ export function findRenames(sources, dests, { minimum, limit, byPath }) {
210
225
  basenameRenames(context, unpaired(context), stricter(minimum))
211
226
  const left = unpaired(context)
212
227
  if (left.remaining.length * left.open.length > limit * limit) return pairs
213
- scoredRenames(context, left, minimum)
214
- if (byPath) chunkRenames(context, unpaired(context), stricter(minimum))
228
+ const aside = byPath ? chunkRenames(context, left, stricter(minimum), (pair) => outdone(context, left, pair, minimum)) : []
229
+ scoredRenames(context, unpaired(context), minimum, aside)
215
230
  return pairs
216
231
  }
package/src/text.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // first 8000 bytes, so a file git diffs as bytes may be binary here.
4
4
 
5
5
  import { utf8toString } from '@exodus/bytes/utf8.js'
6
- import { changeSet } from '@preventive/diff'
6
+ import { DiffError, changeSet, formatChangeSet } from '@preventive/diff'
7
7
 
8
8
  // A link's content is its target, as git stores it; a missing side is empty.
9
9
  export function textOf(file) {
@@ -31,22 +31,72 @@ export function checkLineOptions({ horizon = Infinity, whitespace = 'none', igno
31
31
  return { horizon, whitespace, ignoreCase }
32
32
  }
33
33
 
34
- // The shortest diff where its cost, lines by lines changed, allows, as --minimal
35
- // has it; past that the search's cutoff, which can face a run with lines alike
36
- // far from its own: old code that new code now comes before, faced with its braces.
37
- const SHORTEST_COST = 2e8
34
+ const changes = (blocks) => blocks.reduce((n, { a0, a1, b0, b1 }) => n + a1 - a0 + b1 - b0, 0)
35
+ const records = (text) => (text === '' ? [] : text.split(/(?<=\n)/u))
36
+ const minding = (options) => ({ ...options, ignoreCase: false, whitespace: 'none' })
37
+ const ignoring = (options) => (options.whitespace ?? 'none') !== 'none' || Boolean(options.ignoreCase)
38
+
39
+ // Ignoring blanks or case keeps every line minding them keeps, so it never
40
+ // shows more: past the search's cutoff it may, and then it is the diff minding
41
+ // them, its blocks diffed again ignoring them. Where it shows no fewer lines,
42
+ // it is that diff, block for block: ignoring them does nothing there.
43
+ function noLonger(a, b, options, blocks, minded) {
44
+ const A = records(a), B = records(b)
45
+ const block = (a0, a1, b0, b1) => ({ a0, a1, b0, b1, remove: A.slice(a0, a1), insert: B.slice(b0, b1) })
46
+ const again = minded.flatMap(({ a0, a1, b0, b1 }) => (a0 === a1 || b0 === b1
47
+ ? [block(a0, a1, b0, b1)]
48
+ : changeSet(A.slice(a0, a1).join(''), B.slice(b0, b1).join(''), options).map((part) => block(a0 + part.a0, a0 + part.a1, b0 + part.b0, b0 + part.b1))))
49
+ // Blocks put together from diffs of their own are held to what diff holds its own to.
50
+ const holds = (set) => {
51
+ try {
52
+ formatChangeSet(a, b, set, { format: 'normal', ignoreCase: options.ignoreCase, whitespace: options.whitespace })
53
+ return true
54
+ } catch (error) {
55
+ if (error instanceof DiffError) return false
56
+ throw error
57
+ }
58
+ }
59
+ const best = changes(again) < changes(blocks) && holds(again) ? again : blocks
60
+ return changes(best) < changes(minded) ? best : minded
61
+ }
62
+
63
+ // The diff as diff finds it, ignoring blanks or case never showing more.
64
+ export function lineChangeSet(a, b, options) {
65
+ const blocks = changeSet(a, b, options)
66
+ return ignoring(options) ? noLonger(a, b, options, blocks, changeSet(a, b, minding(options))) : blocks
67
+ }
68
+
69
+ // The shortest diff where its cost allows, as --minimal has it; past that the
70
+ // search's cutoff, which can face a run with lines alike far from its own: old
71
+ // code that new code now comes before, faced with its braces. The search's work
72
+ // grows as the square of the lines it changes: at most the quick diff's, or one
73
+ // minding blanks and case, which changes no fewer. It may cost 2e8, or 10,000 a
74
+ // line of the two texts, as their lines cost already to read.
75
+ const SHORTEST_A_LINE = 1e4, SHORTEST_COST = 2e8
38
76
  const lineCount = (text) => text.split('\n').length
77
+ function shortest(a, b, options, lines, bound) {
78
+ const budget = Math.max(SHORTEST_COST, SHORTEST_A_LINE * lines), quick = changeSet(a, b, options)
79
+ let changed = changes(quick)
80
+ if (changed ** 2 > budget) changed = Math.min(changed, bound())
81
+ return changed ** 2 > budget ? quick : changeSet(a, b, { ...options, minimal: true })
82
+ }
83
+ // For lines read, as names are paired by: ignoring blanks or case, what it
84
+ // finds, its lines alike kept where minding them ties.
39
85
  export function shortestChangeSet(a, b, options, lines = lineCount(a) + lineCount(b)) {
40
- const quick = changeSet(a, b, options)
41
- const changed = quick.reduce((n, { a0, a1, b0, b1 }) => n + a1 - a0 + b1 - b0, 0)
42
- return lines * changed > SHORTEST_COST ? quick : changeSet(a, b, { ...options, minimal: true })
86
+ return shortest(a, b, options, lines, () => (ignoring(options) ? changes(changeSet(a, b, minding(options))) : Infinity))
87
+ }
88
+ // For lines shown: ignoring blanks or case, never more than minding them.
89
+ export function shownChangeSet(a, b, options, lines = lineCount(a) + lineCount(b)) {
90
+ if (!ignoring(options)) return shortestChangeSet(a, b, options, lines)
91
+ const minded = shortestChangeSet(a, b, minding(options), lines)
92
+ return noLonger(a, b, options, shortest(a, b, options, lines, () => changes(minded)), minded)
43
93
  }
44
94
 
45
95
  // Binary content has no blocks and no counts, as --numstat prints `-` for it.
46
96
  export function contentOf(old, fresh, options) {
47
97
  const a = textOf(old), b = textOf(fresh)
48
98
  if (a === undefined || b === undefined) return { binary: true, blocks: [], insertions: null, deletions: null }
49
- const blocks = a === b ? [] : changeSet(a, b, options)
99
+ const blocks = a === b ? [] : lineChangeSet(a, b, options)
50
100
  const lines = (from, to) => blocks.reduce((n, block) => n + block[to] - block[from], 0)
51
101
  return { binary: false, blocks, insertions: lines('b0', 'b1'), deletions: lines('a0', 'a1') }
52
102
  }