deepdiff-rs 0.5.4__tar.gz → 0.6.0__tar.gz

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.
Files changed (60) hide show
  1. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/Cargo.lock +3 -3
  2. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/PKG-INFO +6 -1
  4. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/README.md +5 -0
  5. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/array.rs +3 -0
  6. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/object.rs +2 -0
  7. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/scalar.rs +1 -0
  8. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/lcs.rs +233 -56
  9. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/lcs_tests.rs +146 -5
  10. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/lib.rs +1 -0
  11. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/report.rs +22 -1
  12. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/report_tests.rs +40 -0
  13. deepdiff_rs-0.6.0/crates/onix-core/src/unified_diff.rs +181 -0
  14. deepdiff_rs-0.6.0/crates/onix-core/src/unified_diff_tests.rs +261 -0
  15. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_differential_fuzz.py +131 -3
  16. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/Cargo.toml +0 -0
  17. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  18. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/datetime.rs +0 -0
  19. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/datetime_tests.rs +0 -0
  20. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/dispatch.rs +0 -0
  21. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/mod.rs +0 -0
  22. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/options.rs +0 -0
  23. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/set.rs +0 -0
  24. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/tests.rs +0 -0
  25. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/error.rs +0 -0
  26. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/distance.rs +0 -0
  27. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/fxhash.rs +0 -0
  28. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/hash.rs +0 -0
  29. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/memo.rs +0 -0
  30. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/mod.rs +0 -0
  31. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
  32. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/tests.rs +0 -0
  33. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/path.rs +0 -0
  34. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/test_support.rs +0 -0
  35. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/value.rs +0 -0
  36. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/value_tests.rs +0 -0
  37. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/golden.rs +0 -0
  38. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
  39. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/memory_footprint.rs +0 -0
  40. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/proptest_diff.rs +0 -0
  41. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  42. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/Cargo.toml +0 -0
  43. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  44. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/convert.rs +0 -0
  45. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/deepdiff.rs +0 -0
  46. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/errors.rs +0 -0
  47. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/fast_path.rs +0 -0
  48. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/guard.rs +0 -0
  49. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/lib.rs +0 -0
  50. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  51. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_conversions.py +0 -0
  52. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_datetimes.py +0 -0
  53. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_depth_guard.py +0 -0
  54. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_golden_parity.py +0 -0
  55. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_sets.py +0 -0
  56. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_signed_zero.py +0 -0
  57. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_smoke.py +0 -0
  58. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  59. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_tuples.py +0 -0
  60. {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/pyproject.toml +0 -0
@@ -127,7 +127,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
127
127
 
128
128
  [[package]]
129
129
  name = "onix-cli"
130
- version = "0.5.4"
130
+ version = "0.6.0"
131
131
  dependencies = [
132
132
  "onix-core",
133
133
  "serde_json",
@@ -135,7 +135,7 @@ dependencies = [
135
135
 
136
136
  [[package]]
137
137
  name = "onix-core"
138
- version = "0.5.4"
138
+ version = "0.6.0"
139
139
  dependencies = [
140
140
  "proptest",
141
141
  "serde",
@@ -145,7 +145,7 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "onix-py"
148
- version = "0.5.4"
148
+ version = "0.6.0"
149
149
  dependencies = [
150
150
  "onix-core",
151
151
  "pyo3",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/onix-core", "crates/onix-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.5.4"
6
+ version = "0.6.0"
7
7
  edition = "2024"
8
8
  license = "MIT"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepdiff-rs
3
- Version: 0.5.4
3
+ Version: 0.6.0
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -122,6 +122,7 @@ Pass `--ignore-order` to compare every list by value instead of by position, mir
122
122
  - A `str` inside a `tuple` or `frozenset` set item is rendered with Python's `repr()`, which escapes every non-printable character; onix escapes those below `U+0100` (the complete set in that range) and passes higher non-printable code points through literally, since escaping them would mean carrying a Unicode category table. Exact for all of ASCII and all printable text. See [`crates/onix-core/src/path.rs`](crates/onix-core/src/path.rs).
123
123
  - Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
124
124
  - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
125
+ - A `values_changed` between two multi-line strings runs a `difflib`-style `O(N*M)` line diff on the default path with no opt-out, worst when changes are spread evenly through the text (about 35 s for a heavily edited 1 MB string and growing quadratically, so a few megabytes is minutes), so bound the size of untrusted strings yourself. See [`crates/onix-core/src/unified_diff.rs`](crates/onix-core/src/unified_diff.rs).
125
126
  - Output is byte-identical to DeepDiff except for the cases listed above and two path-rendering quirks; [`tests/golden/README.md`](tests/golden/README.md) enumerates every accepted exception, including integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists and `ignore_order` pairing among naive datetimes, which DeepDiff ranks using the *process's local timezone* while onix reads a naive value as UTC everywhere.
126
127
 
127
128
  ## Performance
@@ -213,3 +214,7 @@ Issues and pull requests are welcome. Open an issue to report a bug, a DeepDiff
213
214
 
214
215
  MIT: see [LICENSE](LICENSE).
215
216
 
217
+ onix reimplements algorithms from CPython's `difflib` (PSF License) and
218
+ reproduces the behavior of DeepDiff (MIT); their notices and license texts are
219
+ in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
220
+
@@ -106,6 +106,7 @@ Pass `--ignore-order` to compare every list by value instead of by position, mir
106
106
  - A `str` inside a `tuple` or `frozenset` set item is rendered with Python's `repr()`, which escapes every non-printable character; onix escapes those below `U+0100` (the complete set in that range) and passes higher non-printable code points through literally, since escaping them would mean carrying a Unicode category table. Exact for all of ASCII and all printable text. See [`crates/onix-core/src/path.rs`](crates/onix-core/src/path.rs).
107
107
  - Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
108
108
  - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
109
+ - A `values_changed` between two multi-line strings runs a `difflib`-style `O(N*M)` line diff on the default path with no opt-out, worst when changes are spread evenly through the text (about 35 s for a heavily edited 1 MB string and growing quadratically, so a few megabytes is minutes), so bound the size of untrusted strings yourself. See [`crates/onix-core/src/unified_diff.rs`](crates/onix-core/src/unified_diff.rs).
109
110
  - Output is byte-identical to DeepDiff except for the cases listed above and two path-rendering quirks; [`tests/golden/README.md`](tests/golden/README.md) enumerates every accepted exception, including integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists and `ignore_order` pairing among naive datetimes, which DeepDiff ranks using the *process's local timezone* while onix reads a naive value as UTC everywhere.
110
111
 
111
112
  ## Performance
@@ -196,3 +197,7 @@ Issues and pull requests are welcome. Open an issue to report a bug, a DeepDiff
196
197
  ## License
197
198
 
198
199
  MIT: see [LICENSE](LICENSE).
200
+
201
+ onix reimplements algorithms from CPython's `difflib` (PSF License) and
202
+ reproduces the behavior of DeepDiff (MIT); their notices and license texts are
203
+ in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
@@ -185,6 +185,8 @@ fn insert_lcs_pair_finding(
185
185
  report.insert_values_changed(
186
186
  path.clone(),
187
187
  ValuesChangedEntry {
188
+ // Datetimes are never strings, so `_diff_str` never runs.
189
+ diff: None,
188
190
  old_value: Value::DateTime(old_value),
189
191
  new_value: Value::DateTime(new_value),
190
192
  new_path,
@@ -199,6 +201,7 @@ fn insert_lcs_pair_finding(
199
201
  report.insert_values_changed(
200
202
  path.clone(),
201
203
  ValuesChangedEntry {
204
+ diff: crate::unified_diff::str_diff_field(old_value, new_value),
202
205
  old_value: old_value.clone(),
203
206
  new_value: new_value.clone(),
204
207
  new_path,
@@ -85,6 +85,8 @@ pub(crate) fn object_diff(
85
85
  report.insert_values_changed(
86
86
  path.clone(),
87
87
  ValuesChangedEntry {
88
+ // Both sides are dicts here, never strings, so no `diff`.
89
+ diff: None,
88
90
  old_value,
89
91
  new_value,
90
92
  new_path: None,
@@ -94,6 +94,7 @@ pub(crate) fn scalar_diff(
94
94
  report.insert_values_changed(
95
95
  path.to_vec(),
96
96
  ValuesChangedEntry {
97
+ diff: crate::unified_diff::str_diff_field(a, b),
97
98
  old_value: a.clone(),
98
99
  new_value: b.clone(),
99
100
  new_path: None,
@@ -1,6 +1,13 @@
1
+ // Portions of this module reimplement algorithms from CPython 3.14.6's
2
+ // `difflib` standard-library module (`SequenceMatcher` and its autojunk
3
+ // heuristic), used under the PSF License Agreement version 2. See
4
+ // THIRD-PARTY-NOTICES.md at the repository root.
1
5
  //! A faithful, from-scratch port of Python's `difflib.SequenceMatcher`
2
- //! opcode algorithm, restricted to the one configuration `DeepDiff` actually
3
- //! uses for ordered-list comparison: `isjunk=None, autojunk=False`.
6
+ //! opcode algorithm, in the two configurations this engine needs: always
7
+ //! `isjunk=None`, and `autojunk` either off ([`compute_opcodes`], for
8
+ //! `DeepDiff`'s ordered-list comparison) or on ([`grouped_opcodes`], for the
9
+ //! multi-line string diff `unified_diff` produces — see
10
+ //! [`mod@crate::unified_diff`]).
4
11
  //!
5
12
  //! This module is deliberately *pure*: it knows nothing about
6
13
  //! [`crate::report::Report`], [`crate::path::PathSegment`], or recursion
@@ -24,19 +31,28 @@
24
31
  //! surprising matching-equality and `new_path` details this module's
25
32
  //! algorithm alone doesn't explain.
26
33
  //!
27
- //! # No junk, no autojunk
34
+ //! # Junk and autojunk
28
35
  //!
29
- //! `DeepDiff` always constructs its matcher with `isjunk=None` and
30
- //! `autojunk=False` (`deepdiff/diff.py::_diff_ordered_iterable_by_difflib`).
31
- //! `autojunk` is `difflib`'s *default-on* heuristic that treats an
32
- //! element appearing in more than 1% of a ≥200-item sequence as "popular"
33
- //! and excludes it from matching — `DeepDiff` explicitly opts out, so this
34
- //! port implements no such thing, and there is no ≥200-item behavior change
35
- //! to replicate (confirmed empirically against real `deepdiff==9.1.0` with a
36
- //! 250-item, one-popular-value fixture: the popular value matches exactly
37
- //! like any other). Skipping junk/autojunk also sidesteps `SequenceMatcher`'s
38
- //! own two-pass junk-then-popular pruning of its `b2j` index, considerably
39
- //! simplifying this port relative to the full standard-library algorithm.
36
+ //! `isjunk` is always `None` here — both call sites pass it — so this port
37
+ //! never builds a `bjunk` set and `SequenceMatcher`'s junk-specific branches
38
+ //! (its `b2j` junk pruning and its two junk-extension loops) are all dead.
39
+ //!
40
+ //! `autojunk`, by contrast, differs by call site. It is `difflib`'s
41
+ //! *default-on* heuristic that treats an element appearing in more than 1%
42
+ //! of a ≥200-element sequence as "popular" and drops it from the `b2j`
43
+ //! index. `DeepDiff` explicitly opts out for ordered-list comparison
44
+ //! (`autojunk=False`; `deepdiff/diff.py::_diff_ordered_iterable_by_difflib`),
45
+ //! so [`compute_opcodes`] disables it — confirmed empirically against real
46
+ //! `deepdiff==9.1.0` with a 250-item, one-popular-value fixture: the popular
47
+ //! value matches exactly like any other. But `_diff_str` diffs two strings
48
+ //! with a plain `difflib.unified_diff`, whose `SequenceMatcher(None, a, b)`
49
+ //! keeps `difflib`'s default (`autojunk=True`), so [`grouped_opcodes`]
50
+ //! enables it; a 250-line string with a popular line genuinely aligns
51
+ //! differently, and matching it is what byte-parity on the `diff` field
52
+ //! requires. [`build_b2j`] performs the popular-element purge and
53
+ //! [`find_longest_match`]'s extension step re-bridges a run across a purged
54
+ //! element (the standard-library step this port omits when autojunk is off,
55
+ //! where it is provably a no-op — see [`find_longest_match`]).
40
56
 
41
57
  use std::collections::HashMap;
42
58
 
@@ -258,49 +274,62 @@ struct Match {
258
274
 
259
275
  /// Finds the longest matching block within `a[alo..ahi]` / `b[blo..bhi]` —
260
276
  /// a port of `difflib.SequenceMatcher.find_longest_match`'s core
261
- /// sparse-DP chaining, with its `isjunk`/`autojunk` branches dropped
262
- /// (`DeepDiff` never enables either; see this module's doc). Ties (multiple
263
- /// equal-longest candidates) resolve exactly like `difflib`'s: earliest
264
- /// `i`, then earliest `j` — a direct consequence of scanning `i` ascending
265
- /// and only updating on a *strictly greater* `k`.
277
+ /// sparse-DP chaining, with its `isjunk` branches dropped (`isjunk` is
278
+ /// always `None` here; see this module's doc). Ties (multiple equal-longest
279
+ /// candidates) resolve exactly like `difflib`'s: earliest `i`, then earliest
280
+ /// `j` — a direct consequence of scanning `i` ascending and only updating on
281
+ /// a *strictly greater* `k`.
266
282
  ///
267
- /// **Deliberately omits `difflib`'s post-DP greedy-extension step**
268
- /// (`while ... and not isbjunk(...): besti -= 1; ...`, run twice — backward
269
- /// then forward). That step exists in the standard library to bridge a
270
- /// match across an *excluded* `b` element (junk, or an autojunk-pruned
271
- /// popular one) that the DP's `b2j` chain skips entirely because such
272
- /// elements are deleted from `b2j` (`__chain_b`). With `isjunk=None` (this
273
- /// module's only configuration — see its doc), `b2j` never excludes
274
- /// anything, so the DP chain alone already finds every genuinely
275
- /// contiguous run the extension step could ever find; re-extending
276
- /// afterwards can only ever re-derive a run already reflected in some
277
- /// `run_length` the DP itself computed. Dropping it therefore changes no
278
- /// opcode output for this configuration — verified by a 400,000-trial
279
- /// randomized differential test against real `difflib` (zero divergences),
280
- /// not only by the argument above.
283
+ /// **`difflib`'s post-DP greedy-extension step runs only when `extend` is
284
+ /// set** (`while ... and not isbjunk(...): besti -= 1; ...`, run twice —
285
+ /// backward then forward). That step exists in the standard library to
286
+ /// bridge a match across an *excluded* `b` element (junk, or an
287
+ /// autojunk-pruned popular one) that the DP's `b2j` chain skips entirely
288
+ /// because such elements are deleted from `b2j` (`__chain_b`). When autojunk
289
+ /// is off ([`compute_opcodes`], `extend == false`), `b2j` excludes nothing,
290
+ /// so the DP chain alone already finds every genuinely contiguous run the
291
+ /// extension step could ever find; re-extending afterwards can only re-derive
292
+ /// a run already reflected in some `run_length` the DP computed, so skipping
293
+ /// it changes no opcode output — verified by a 400,000-trial randomized
294
+ /// differential test against real `difflib` (zero divergences), not only by
295
+ /// the argument. When autojunk is on ([`grouped_opcodes`], `extend == true`),
296
+ /// [`build_b2j`] *does* purge popular elements, so the step is load-bearing
297
+ /// and must run.
281
298
  ///
282
299
  /// `b2j` maps each of `b`'s [`ScalarKey`]s to the (ascending) list of
283
- /// indices it occurs at in `b`; built once per `a`/`b` pair and reused
284
- /// across every call this function makes during [`get_matching_blocks`]'s
285
- /// traversal, exactly like `difflib`'s own `self.b2j`.
300
+ /// indices it occurs at in `b`; built once per `a`/`b` pair (see
301
+ /// [`build_b2j`]) and reused across every call this function makes during
302
+ /// [`get_matching_blocks`]'s traversal, exactly like `difflib`'s own
303
+ /// `self.b2j`.
304
+ ///
305
+ /// When `extend` is `true`, `difflib`'s post-DP greedy-extension step runs
306
+ /// (it is gated rather than always on — see [`build_b2j`] and
307
+ /// [`get_matching_blocks`]). It bridges a match across a `b` element the DP
308
+ /// chain skipped because [`build_b2j`] purged it as *popular*: such an
309
+ /// element is absent from `b2j`, so the DP never chains through it, yet it
310
+ /// still equals its `a` counterpart and `difflib` re-extends the run over
311
+ /// it. `difflib`'s `isjunk` is always `None` here (`bjunk` empty), so its
312
+ /// two junk-extension loops are dead and only its two non-junk loops matter;
313
+ /// with an empty `bjunk`, `not isbjunk(...)` is always true, leaving element
314
+ /// equality (by [`ScalarKey`], `difflib`'s `==`) as the sole condition.
286
315
  ///
287
316
  /// Returns `(best_a, best_b, best_size)`; `best_size == 0` means no match
288
317
  /// was found in the given range at all.
289
318
  fn find_longest_match(
290
- a: &[Value],
291
- alo: usize,
292
- ahi: usize,
293
- blo: usize,
294
- bhi: usize,
319
+ a_keys: &[ScalarKey],
320
+ b_keys: &[ScalarKey],
321
+ window: Window,
295
322
  b2j: &HashMap<ScalarKey, Vec<usize>>,
323
+ extend: bool,
296
324
  ) -> (usize, usize, usize) {
325
+ let Window { alo, ahi, blo, bhi } = window;
297
326
  let (mut best_a, mut best_b, mut best_size) = (alo, blo, 0);
298
327
  let mut run_length_by_b_index: HashMap<usize, usize> = HashMap::new();
299
328
 
300
- for (offset, item) in a[alo..ahi].iter().enumerate() {
329
+ for (offset, key) in a_keys[alo..ahi].iter().enumerate() {
301
330
  let a_index = alo + offset;
302
331
  let mut next_run_length_by_b_index: HashMap<usize, usize> = HashMap::new();
303
- if let Some(b_indices) = b2j.get(&scalar_key(item)) {
332
+ if let Some(b_indices) = b2j.get(key) {
304
333
  for &b_index in b_indices {
305
334
  if b_index < blo {
306
335
  continue;
@@ -328,9 +357,57 @@ fn find_longest_match(
328
357
  run_length_by_b_index = next_run_length_by_b_index;
329
358
  }
330
359
 
360
+ if extend {
361
+ while best_a > alo && best_b > blo && a_keys[best_a - 1] == b_keys[best_b - 1] {
362
+ best_a -= 1;
363
+ best_b -= 1;
364
+ best_size += 1;
365
+ }
366
+ while best_a + best_size < ahi
367
+ && best_b + best_size < bhi
368
+ && a_keys[best_a + best_size] == b_keys[best_b + best_size]
369
+ {
370
+ best_size += 1;
371
+ }
372
+ }
373
+
331
374
  (best_a, best_b, best_size)
332
375
  }
333
376
 
377
+ /// A half-open search window into `a`/`b`, bundling `difflib`'s
378
+ /// `alo`/`ahi`/`blo`/`bhi` bounds so [`find_longest_match`] takes them as one
379
+ /// argument.
380
+ #[derive(Clone, Copy)]
381
+ struct Window {
382
+ alo: usize,
383
+ ahi: usize,
384
+ blo: usize,
385
+ bhi: usize,
386
+ }
387
+
388
+ /// Builds `difflib`'s `b2j` index — each of `b`'s [`ScalarKey`]s mapped to
389
+ /// the ascending list of indices it occurs at.
390
+ ///
391
+ /// When `autojunk` is set, this also applies `difflib`'s *autojunk*
392
+ /// heuristic (`SequenceMatcher.__chain_b`): for a `b` of 200 or more
393
+ /// elements, any element occurring more than `len(b) / 100 + 1` times is
394
+ /// "popular" and dropped from the index entirely, so the matcher never
395
+ /// chains a run through it (the run is re-bridged instead by
396
+ /// [`find_longest_match`]'s extension step). See this module's doc for which
397
+ /// call site passes which value. `isjunk` is `None` in both, so no `bjunk`
398
+ /// set is ever built.
399
+ fn build_b2j(b_keys: &[ScalarKey], autojunk: bool) -> HashMap<ScalarKey, Vec<usize>> {
400
+ let mut b2j: HashMap<ScalarKey, Vec<usize>> = HashMap::new();
401
+ for (b_index, key) in b_keys.iter().enumerate() {
402
+ b2j.entry(key.clone()).or_default().push(b_index);
403
+ }
404
+ if autojunk && b_keys.len() >= 200 {
405
+ let ntest = b_keys.len() / 100 + 1;
406
+ b2j.retain(|_, indices| indices.len() <= ntest);
407
+ }
408
+ b2j
409
+ }
410
+
334
411
  /// Returns every non-empty matching block between `a` and `b`, sorted and
335
412
  /// with adjacent blocks collapsed, terminated by a dummy zero-size block at
336
413
  /// `(a.len(), b.len())` — a direct, iterative (explicit work-stack, no
@@ -338,16 +415,32 @@ fn find_longest_match(
338
415
  /// `difflib` itself switched to this iterative shape for the same reason
339
416
  /// this whole engine avoids native recursion on untrusted input: naive
340
417
  /// recursion here overflowed the stack for some real-world inputs.
341
- fn get_matching_blocks(a: &[Value], b: &[Value]) -> Vec<Match> {
342
- let mut b2j: HashMap<ScalarKey, Vec<usize>> = HashMap::new();
343
- for (b_index, item) in b.iter().enumerate() {
344
- b2j.entry(scalar_key(item)).or_default().push(b_index);
345
- }
418
+ ///
419
+ /// `autojunk` is threaded through to [`build_b2j`] (which decides whether to
420
+ /// purge popular elements) and to [`find_longest_match`] (which extends a
421
+ /// match over any purged element); the two must agree, so this is the single
422
+ /// switch that turns the whole heuristic on for the string-diff path and off
423
+ /// for the ordered-list path.
424
+ fn get_matching_blocks(a: &[Value], b: &[Value], autojunk: bool) -> Vec<Match> {
425
+ // Compute each element's `ScalarKey` once, up front, rather than
426
+ // recomputing it inside every `find_longest_match` window: for a
427
+ // string-diff `a`/`b` of long lines this turns the worst case's O(N^2)
428
+ // key rebuilds (each cloning the line) into O(N).
429
+ let a_keys: Vec<ScalarKey> = a.iter().map(scalar_key).collect();
430
+ let b_keys: Vec<ScalarKey> = b.iter().map(scalar_key).collect();
431
+ let b2j = build_b2j(&b_keys, autojunk);
346
432
 
347
- let mut stack = vec![(0_usize, a.len(), 0_usize, b.len())];
433
+ let mut stack = vec![Window {
434
+ alo: 0,
435
+ ahi: a.len(),
436
+ blo: 0,
437
+ bhi: b.len(),
438
+ }];
348
439
  let mut raw_matches = Vec::new();
349
- while let Some((alo, ahi, blo, bhi)) = stack.pop() {
350
- let (match_a, match_b, match_size) = find_longest_match(a, alo, ahi, blo, bhi, &b2j);
440
+ while let Some(window) = stack.pop() {
441
+ let Window { alo, ahi, blo, bhi } = window;
442
+ let (match_a, match_b, match_size) =
443
+ find_longest_match(&a_keys, &b_keys, window, &b2j, autojunk);
351
444
  if match_size > 0 {
352
445
  raw_matches.push(Match {
353
446
  a: match_a,
@@ -355,10 +448,20 @@ fn get_matching_blocks(a: &[Value], b: &[Value]) -> Vec<Match> {
355
448
  size: match_size,
356
449
  });
357
450
  if alo < match_a && blo < match_b {
358
- stack.push((alo, match_a, blo, match_b));
451
+ stack.push(Window {
452
+ alo,
453
+ ahi: match_a,
454
+ blo,
455
+ bhi: match_b,
456
+ });
359
457
  }
360
458
  if match_a + match_size < ahi && match_b + match_size < bhi {
361
- stack.push((match_a + match_size, ahi, match_b + match_size, bhi));
459
+ stack.push(Window {
460
+ alo: match_a + match_size,
461
+ ahi,
462
+ blo: match_b + match_size,
463
+ bhi,
464
+ });
362
465
  }
363
466
  }
364
467
  }
@@ -412,12 +515,11 @@ fn get_matching_blocks(a: &[Value], b: &[Value]) -> Vec<Match> {
412
515
  /// Never, for any `a`/`b` for which [`all_basic_scalars`] holds on both —
413
516
  /// see [`scalar_key`]'s doc for the sole (structurally unreachable here)
414
517
  /// panic path this function's callees have.
415
- #[must_use]
416
- pub(crate) fn compute_opcodes(a: &[Value], b: &[Value]) -> Vec<Opcode> {
518
+ fn opcodes_with(a: &[Value], b: &[Value], autojunk: bool) -> Vec<Opcode> {
417
519
  let mut opcodes = Vec::new();
418
520
  let (mut i, mut j) = (0_usize, 0_usize);
419
521
 
420
- for m in get_matching_blocks(a, b) {
522
+ for m in get_matching_blocks(a, b, autojunk) {
421
523
  let tag = if i < m.a && j < m.b {
422
524
  Some(Tag::Replace)
423
525
  } else if i < m.a {
@@ -451,6 +553,81 @@ pub(crate) fn compute_opcodes(a: &[Value], b: &[Value]) -> Vec<Opcode> {
451
553
  opcodes
452
554
  }
453
555
 
556
+ /// Computes the ordered-list [`Opcode`]s turning `a` into `b`, with
557
+ /// `difflib`'s autojunk heuristic disabled — the exact configuration
558
+ /// `DeepDiff` uses for default list comparison (see this module's doc).
559
+ #[must_use]
560
+ pub(crate) fn compute_opcodes(a: &[Value], b: &[Value]) -> Vec<Opcode> {
561
+ opcodes_with(a, b, false)
562
+ }
563
+
564
+ /// Groups `a`→`b`'s opcodes into change clusters, each with up to `n` lines
565
+ /// of surrounding context, dropping the long unchanged stretches between
566
+ /// them — a port of `difflib.SequenceMatcher.get_grouped_opcodes`, run with
567
+ /// autojunk **on** because it feeds `unified_diff`, which constructs its
568
+ /// matcher with `difflib`'s default (`SequenceMatcher(None, a, b)`).
569
+ ///
570
+ /// Each returned inner `Vec` is one contiguous group of opcodes, exactly as
571
+ /// `difflib` yields them, and is **never empty**: both sites that emit a
572
+ /// group (the long-equal split and the final flush) push at least one opcode
573
+ /// into it first. An empty *outer* `Vec` means the two inputs produced no
574
+ /// change worth a group (identical, or empty).
575
+ #[must_use]
576
+ pub(crate) fn grouped_opcodes(a: &[Value], b: &[Value], n: usize) -> Vec<Vec<Opcode>> {
577
+ let mut codes = opcodes_with(a, b, true);
578
+ if codes.is_empty() {
579
+ codes.push(Opcode {
580
+ tag: Tag::Equal,
581
+ a1: 0,
582
+ a2: 1,
583
+ b1: 0,
584
+ b2: 1,
585
+ });
586
+ }
587
+
588
+ // Fix up a leading/trailing all-equal opcode so a group never carries
589
+ // more than `n` lines of leading or trailing context.
590
+ if let Some(first) = codes.first_mut()
591
+ && first.tag == Tag::Equal
592
+ {
593
+ first.a1 = first.a1.max(first.a2.saturating_sub(n));
594
+ first.b1 = first.b1.max(first.b2.saturating_sub(n));
595
+ }
596
+ if let Some(last) = codes.last_mut()
597
+ && last.tag == Tag::Equal
598
+ {
599
+ last.a2 = last.a2.min(last.a1 + n);
600
+ last.b2 = last.b2.min(last.b1 + n);
601
+ }
602
+
603
+ let nn = n + n;
604
+ let mut groups: Vec<Vec<Opcode>> = Vec::new();
605
+ let mut group: Vec<Opcode> = Vec::new();
606
+ for mut code in codes {
607
+ // A long unchanged run ends the current group and starts the next,
608
+ // keeping only `n` lines of context on either side of the boundary.
609
+ if code.tag == Tag::Equal && code.a2 - code.a1 > nn {
610
+ group.push(Opcode {
611
+ tag: Tag::Equal,
612
+ a1: code.a1,
613
+ a2: code.a2.min(code.a1 + n),
614
+ b1: code.b1,
615
+ b2: code.b2.min(code.b1 + n),
616
+ });
617
+ groups.push(std::mem::take(&mut group));
618
+ code.a1 = code.a1.max(code.a2.saturating_sub(n));
619
+ code.b1 = code.b1.max(code.b2.saturating_sub(n));
620
+ }
621
+ group.push(code);
622
+ }
623
+ let trivial_equal = matches!(group.as_slice(), [only] if only.tag == Tag::Equal);
624
+ if !group.is_empty() && !trivial_equal {
625
+ groups.push(group);
626
+ }
627
+
628
+ groups
629
+ }
630
+
454
631
  #[cfg(test)]
455
632
  #[path = "lcs_tests.rs"]
456
633
  mod tests;
@@ -15,16 +15,24 @@ fn scalar_key(value: &serde_json::Value) -> super::ScalarKey {
15
15
  }
16
16
 
17
17
  /// Python-`==` equality for two JSON scalars, per [`super::ScalarKey`]'s
18
- /// doc. Test-only: production code has no remaining use for this as a
19
- /// standalone function (see [`super::find_longest_match`]'s doc on why
20
- /// its own extend-by-direct-comparison step was removed) — it survives
18
+ /// doc. Test-only: the engine compares scalars directly by
19
+ /// [`super::ScalarKey`] (including [`super::find_longest_match`]'s autojunk
20
+ /// extension step) rather than through a standalone predicate — this survives
21
21
  /// here purely to assert the hashability/cross-type-equality semantics
22
- /// directly, and to state the `Replace`-opcode non-matching-pair
23
- /// invariant precisely in [`replace_opcode_ranges_never_share_a_matching_element`].
22
+ /// directly, and to state the `Replace`-opcode non-matching-pair invariant
23
+ /// precisely in [`replace_opcode_ranges_never_share_a_matching_element`].
24
24
  fn python_scalar_eq(a: &serde_json::Value, b: &serde_json::Value) -> bool {
25
25
  scalar_key(a) == scalar_key(b)
26
26
  }
27
27
 
28
+ fn grouped_opcodes(
29
+ a: &[serde_json::Value],
30
+ b: &[serde_json::Value],
31
+ n: usize,
32
+ ) -> Vec<Vec<super::Opcode>> {
33
+ super::grouped_opcodes(&cvec(a), &cvec(b), n)
34
+ }
35
+
28
36
  // --- all_basic_scalars ---------------------------------------------
29
37
 
30
38
  #[test]
@@ -561,3 +569,136 @@ fn mix_float_bits_spreads_low_bits_of_integral_and_half_integer_floats() {
561
569
  super::mix_float_bits(2.0_f64.to_bits())
562
570
  );
563
571
  }
572
+
573
+ // --- grouped_opcodes -----------------------------------------------
574
+
575
+ #[test]
576
+ fn grouped_opcodes_of_empty_inputs_yields_no_groups() {
577
+ // `get_opcodes` is empty for two empty sequences, so the fallback dummy
578
+ // "equal" opcode is inserted and then dropped as a trivial single-equal
579
+ // group — no group is emitted. (`unified_diff` never reaches this, since
580
+ // its trigger requires a newline, but the port mirrors difflib exactly.)
581
+ assert!(grouped_opcodes(&[], &[], 3).is_empty());
582
+ }
583
+
584
+ #[test]
585
+ fn grouped_opcodes_of_identical_inputs_yields_no_groups() {
586
+ let same = vec![json!("a"), json!("b"), json!("c")];
587
+ assert!(grouped_opcodes(&same, &same, 3).is_empty());
588
+ }
589
+
590
+ #[test]
591
+ fn grouped_opcodes_splits_far_apart_changes_into_separate_groups() {
592
+ // Changes at index 1 and 15 with >2n unchanged lines between them, so the
593
+ // long equal run splits the opcodes into two groups (difflib's cluster
594
+ // isolation).
595
+ let a: Vec<serde_json::Value> = (0..20).map(|i| json!(format!("L{i}"))).collect();
596
+ let b: Vec<serde_json::Value> = (0..20)
597
+ .map(|i| match i {
598
+ 1 => json!("X1"),
599
+ 15 => json!("X15"),
600
+ _ => json!(format!("L{i}")),
601
+ })
602
+ .collect();
603
+ assert_eq!(grouped_opcodes(&a, &b, 3).len(), 2);
604
+ }
605
+
606
+ // --- find_longest_match extension step (autojunk-only) --------------
607
+ //
608
+ // These call `find_longest_match` directly with a `b2j` that deliberately
609
+ // omits a "popular" element (as `build_b2j`'s autojunk purge would), so the
610
+ // DP chain cannot match that element and only the greedy extension step can
611
+ // re-bridge it. The window and match offsets are asymmetric between the two
612
+ // sides so each loop bound (`best_a > alo`, `best_b > blo`, and the two
613
+ // forward `< ahi`/`< bhi` checks) is exercised as the binding constraint.
614
+
615
+ fn keys(items: &[serde_json::Value]) -> Vec<super::ScalarKey> {
616
+ items.iter().map(scalar_key).collect()
617
+ }
618
+
619
+ fn b2j_from(keys: &[super::ScalarKey]) -> std::collections::HashMap<super::ScalarKey, Vec<usize>> {
620
+ // A b2j built by hand so a chosen key can be omitted (purged).
621
+ let mut map: std::collections::HashMap<super::ScalarKey, Vec<usize>> =
622
+ std::collections::HashMap::new();
623
+ for (i, k) in keys.iter().enumerate() {
624
+ map.entry(k.clone()).or_default().push(i);
625
+ }
626
+ map
627
+ }
628
+
629
+ #[test]
630
+ fn extension_bridges_a_purged_element_backward() {
631
+ // a = [P, P, u], b = [P, u]; P is purged from b2j, so the DP only finds
632
+ // `u` (a[2] == b[1]); the backward extension must re-bridge one `P` to
633
+ // give the full match a[1..3] == b[0..2].
634
+ let a_keys = keys(&[json!("P"), json!("P"), json!("u")]);
635
+ let b_keys = keys(&[json!("P"), json!("u")]);
636
+ let mut b2j = b2j_from(&b_keys);
637
+ b2j.remove(&scalar_key(&json!("P")));
638
+ let window = super::Window {
639
+ alo: 0,
640
+ ahi: a_keys.len(),
641
+ blo: 0,
642
+ bhi: b_keys.len(),
643
+ };
644
+ assert_eq!(
645
+ super::find_longest_match(&a_keys, &b_keys, window, &b2j, true),
646
+ (1, 0, 2),
647
+ );
648
+ }
649
+
650
+ #[test]
651
+ fn extension_bridges_a_purged_element_forward() {
652
+ // a = [u, P], b = [u, P, P]; P purged, DP finds only `u` (a[0] == b[0]);
653
+ // the forward extension must re-bridge one `P`, giving a[0..2] == b[0..2].
654
+ let a_keys = keys(&[json!("u"), json!("P")]);
655
+ let b_keys = keys(&[json!("u"), json!("P"), json!("P")]);
656
+ let mut b2j = b2j_from(&b_keys);
657
+ b2j.remove(&scalar_key(&json!("P")));
658
+ let window = super::Window {
659
+ alo: 0,
660
+ ahi: a_keys.len(),
661
+ blo: 0,
662
+ bhi: b_keys.len(),
663
+ };
664
+ assert_eq!(
665
+ super::find_longest_match(&a_keys, &b_keys, window, &b2j, false),
666
+ (0, 0, 1),
667
+ "with extend=false the purged P is not bridged"
668
+ );
669
+ assert_eq!(
670
+ super::find_longest_match(&a_keys, &b_keys, window, &b2j, true),
671
+ (0, 0, 2),
672
+ "with extend=true the forward extension bridges one P"
673
+ );
674
+ }
675
+
676
+ // --- build_b2j autojunk purge --------------------------------------
677
+
678
+ #[test]
679
+ fn build_b2j_purges_only_above_the_autojunk_threshold() {
680
+ // 200 elements: `ntest = 200 / 100 + 1 = 3`. "pop" (4 occurrences) is
681
+ // purged; "keep3" (exactly 3) is kept, as is every unique filler.
682
+ let mut items: Vec<serde_json::Value> = Vec::new();
683
+ items.extend(std::iter::repeat_n(json!("pop"), 4));
684
+ items.extend(std::iter::repeat_n(json!("keep3"), 3));
685
+ for i in 0..193 {
686
+ items.push(json!(format!("u{i}")));
687
+ }
688
+ assert_eq!(items.len(), 200);
689
+ let b_keys = keys(&items);
690
+
691
+ let purged = super::build_b2j(&b_keys, true);
692
+ assert!(
693
+ !purged.contains_key(&scalar_key(&json!("pop"))),
694
+ "pop occurs 4 times (> ntest 3) and must be purged"
695
+ );
696
+ assert!(
697
+ purged.contains_key(&scalar_key(&json!("keep3"))),
698
+ "keep3 occurs exactly 3 times (== ntest) and must be kept"
699
+ );
700
+
701
+ // With autojunk off, nothing is ever purged even past 200 elements.
702
+ let unpurged = super::build_b2j(&b_keys, false);
703
+ assert!(unpurged.contains_key(&scalar_key(&json!("pop"))));
704
+ }