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.
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/Cargo.lock +3 -3
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/Cargo.toml +1 -1
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/PKG-INFO +6 -1
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/README.md +5 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/array.rs +3 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/object.rs +2 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/scalar.rs +1 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/lcs.rs +233 -56
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/lcs_tests.rs +146 -5
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/lib.rs +1 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/report.rs +22 -1
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/report_tests.rs +40 -0
- deepdiff_rs-0.6.0/crates/onix-core/src/unified_diff.rs +181 -0
- deepdiff_rs-0.6.0/crates/onix-core/src/unified_diff_tests.rs +261 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_differential_fuzz.py +131 -3
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/Cargo.toml +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/datetime.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/datetime_tests.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/dispatch.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/mod.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/options.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/set.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/diff/tests.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/error.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/distance.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/fxhash.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/hash.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/memo.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/mod.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/ignore_order/tests.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/path.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/test_support.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/value.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/src/value_tests.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/golden.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/memory_footprint.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/proptest_diff.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/Cargo.toml +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/convert.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/deepdiff.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/errors.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/fast_path.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/guard.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/src/lib.rs +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_bindings_memory.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_conversions.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_datetimes.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_depth_guard.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_golden_parity.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_sets.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_signed_zero.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_smoke.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
- {deepdiff_rs-0.5.4 → deepdiff_rs-0.6.0}/crates/onix-py/tests/test_tuples.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
148
|
+
version = "0.6.0"
|
|
149
149
|
dependencies = [
|
|
150
150
|
"onix-core",
|
|
151
151
|
"pyo3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepdiff-rs
|
|
3
|
-
Version: 0.
|
|
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,
|
|
@@ -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,
|
|
3
|
-
//!
|
|
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
|
-
//! #
|
|
34
|
+
//! # Junk and autojunk
|
|
28
35
|
//!
|
|
29
|
-
//! `
|
|
30
|
-
//! `
|
|
31
|
-
//! `
|
|
32
|
-
//!
|
|
33
|
-
//!
|
|
34
|
-
//!
|
|
35
|
-
//!
|
|
36
|
-
//!
|
|
37
|
-
//!
|
|
38
|
-
//!
|
|
39
|
-
//!
|
|
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
|
|
262
|
-
///
|
|
263
|
-
///
|
|
264
|
-
/// `
|
|
265
|
-
///
|
|
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
|
-
///
|
|
268
|
-
/// (`while ... and not isbjunk(...): besti -= 1; ...`, run twice —
|
|
269
|
-
/// then forward). That step exists in the standard library to
|
|
270
|
-
/// match across an *excluded* `b` element (junk, or an
|
|
271
|
-
/// popular one) that the DP's `b2j` chain skips entirely
|
|
272
|
-
/// elements are deleted from `b2j` (`__chain_b`).
|
|
273
|
-
///
|
|
274
|
-
///
|
|
275
|
-
///
|
|
276
|
-
///
|
|
277
|
-
///
|
|
278
|
-
///
|
|
279
|
-
///
|
|
280
|
-
///
|
|
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
|
|
284
|
-
/// across every call this function makes during
|
|
285
|
-
/// traversal, exactly like `difflib`'s own
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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![
|
|
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(
|
|
350
|
-
let
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
19
|
-
///
|
|
20
|
-
///
|
|
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
|
-
///
|
|
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
|
+
}
|