gedcomkit 0.1.9__tar.gz → 0.1.10__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.
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/CHANGELOG.md +36 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/Cargo.lock +1 -1
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/Cargo.toml +1 -1
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/PKG-INFO +1 -1
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/bindings/gedcomkit-py/Cargo.lock +2 -2
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/bindings/gedcomkit-py/Cargo.toml +1 -1
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/bindings/gedcomkit-py/src/lib.rs +20 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/build.rs +426 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/lib.rs +109 -5
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/LICENSE +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/README.md +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/bindings/gedcomkit-py/LICENSE +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/bindings/gedcomkit-py/README.md +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/bindings/gedcomkit-py/gedcomkit.pyi +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/LICENSE +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/README.md +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/cardinalities.tsv +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/enumerations.tsv +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/enumerationsets.tsv +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/grammar.abnf +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/grammar.gedstruct +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/payloads.tsv +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/substructures.tsv +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/age-invalid.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/age-valid.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/atsign.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_ascii_1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_ascii_2.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf16be-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf16be-2.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf16le-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf16le-2.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf8-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf8-2.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf8-3.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/date-all.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/date-dual-invalid.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/date-dual-valid.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/enum-ext.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/filename-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/lang-all.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/notes-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/obje-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/obsolete-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/pedi-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/rela_1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/sour-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/tiny-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/xref-case.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/age-invalid.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/age-valid.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/atsign.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_ascii_1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_ascii_2.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf16be-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf16be-2.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf16le-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf16le-2.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf8-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf8-2.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf8-3.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/date-all.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/date-dual-invalid.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/enum-ext.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/filename-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/lang-all.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/notes-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/obje-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/obsolete-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/pedi-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/rela_1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/sour-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/tiny-1.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/xref-case.ged +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/LICENSE +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/UPSTREAM-README.md +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/zip-interop/README.md +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/zip-interop/python-zip64.gdz +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/fixtures/vendored/zip-interop/python-zipfile.gdz +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/pyproject.toml +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/convert.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/dates.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/decode.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/family.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/gedzip.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/names.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/schema.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/synthetic.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/tags.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/validate.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/src/view.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/tests/endurance.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/tests/external_charset.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/tests/properties.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/tests/requirement_corpus.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/tests/spec_conformance.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/tests/vendored_corpus.rs +0 -0
- {gedcomkit-0.1.9 → gedcomkit-0.1.10}/tests/version_conversion.rs +0 -0
|
@@ -5,6 +5,42 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
the project uses semantic versioning within the usual pre-1.0 compatibility
|
|
6
6
|
rules.
|
|
7
7
|
|
|
8
|
+
## [0.1.10] - 2026-09-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Node::fit_line_limit`, `Document::fit_line_limit` (the same, addressed by
|
|
13
|
+
a record's cross-reference identifier), and the `MAX_LINE_LENGTH` constant
|
|
14
|
+
for GEDCOM 5.5.1's 255-byte line, for a caller to fit an edited record to
|
|
15
|
+
a pre-7.0 document's line limit just before serializing:
|
|
16
|
+
`set_logical_value` only ever splits a payload across `CONT` at the
|
|
17
|
+
newlines it is given, so a long edited line was otherwise written over
|
|
18
|
+
length. Only nodes with no verbatim source line are touched, checked
|
|
19
|
+
recursively, so a node no caller reached into keeps its exact bytes; a
|
|
20
|
+
pointer payload is never split either. The line is measured with a
|
|
21
|
+
2-byte terminator reserve — wider than `wrap_long_lines`' 1 byte — because
|
|
22
|
+
this is meant for a caller whose own pipeline may keep a source file's
|
|
23
|
+
`\r\n` terminator rather than always writing `\n`. Bound in both language
|
|
24
|
+
bindings as `fit_line_limit` (Python) and `fitLineLimit` (WASM), mirroring
|
|
25
|
+
`wrap_long_lines`/`wrapLongLines`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- `Document::next_xref` matched its prefix case-sensitively, so in a file
|
|
30
|
+
using lowercase identifiers (`@i1@`) `next_xref("I")` minted `@I1@` —
|
|
31
|
+
which `Document::record`'s case-insensitive lookup resolves to the
|
|
32
|
+
existing `@i1@`, silently colliding with it. The prefix match is now
|
|
33
|
+
case-insensitive, and the returned identifier is checked against every
|
|
34
|
+
existing xref case-insensitively and advanced past any collision. Its
|
|
35
|
+
doc comment claimed "the lowest unused" identifier while returning one
|
|
36
|
+
past the highest; the comment now says that.
|
|
37
|
+
- The same method minted `highest + 1` with unchecked arithmetic: a hostile
|
|
38
|
+
or fuzzed file carrying `@I18446744073709551615@` (`u64::MAX`) made it
|
|
39
|
+
panic in a debug build and wrap around to `@I0@` in release. Arithmetic
|
|
40
|
+
is now checked throughout, and once the series above the high-water mark
|
|
41
|
+
is exhausted, `next_xref` falls back to the lowest unused number instead
|
|
42
|
+
of overflowing.
|
|
43
|
+
|
|
8
44
|
## [0.1.9] - 2026-09-27
|
|
9
45
|
|
|
10
46
|
### Fixed
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# was checked on crates.io, npm, and PyPI before the initial release.
|
|
4
4
|
[package]
|
|
5
5
|
name = "gedcomkit"
|
|
6
|
-
version = "0.1.
|
|
6
|
+
version = "0.1.10"
|
|
7
7
|
description = "A byte-preserving GEDCOM document model: decoding, parsing, readings, version conversion, plausibility checks, and the GEDZIP container, for GEDCOM 5.5 through 7.x."
|
|
8
8
|
edition = "2024"
|
|
9
9
|
# The true minimum, not the workspace's toolchain: a parser library is a leaf
|
|
@@ -35,7 +35,7 @@ dependencies = [
|
|
|
35
35
|
|
|
36
36
|
[[package]]
|
|
37
37
|
name = "gedcomkit"
|
|
38
|
-
version = "0.1.
|
|
38
|
+
version = "0.1.10"
|
|
39
39
|
dependencies = [
|
|
40
40
|
"flate2",
|
|
41
41
|
"serde",
|
|
@@ -43,7 +43,7 @@ dependencies = [
|
|
|
43
43
|
|
|
44
44
|
[[package]]
|
|
45
45
|
name = "gedcomkit-py"
|
|
46
|
-
version = "0.1.
|
|
46
|
+
version = "0.1.10"
|
|
47
47
|
dependencies = [
|
|
48
48
|
"gedcomkit",
|
|
49
49
|
"pyo3",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# abi3 keeps it to one wheel per platform across Python versions.
|
|
3
3
|
[package]
|
|
4
4
|
name = "gedcomkit-py"
|
|
5
|
-
version = "0.1.
|
|
5
|
+
version = "0.1.10"
|
|
6
6
|
description = "Python bindings for gedcomkit: an archival-grade GEDCOM library — byte-preserving round-trip, encoding detection, GEDCOM 5.5 through 7.x, GEDZIP."
|
|
7
7
|
edition = "2024"
|
|
8
8
|
rust-version = "1.88"
|
|
@@ -167,6 +167,26 @@ impl Gedcom {
|
|
|
167
167
|
wrapped.iter().map(|line| to_py(py, line)).collect()
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/// Fits one record inside a 5.5.x document's line limit
|
|
171
|
+
/// (`max_line_bytes`, terminator included; 255 is GEDCOM 5.5.1's limit)
|
|
172
|
+
/// by inserting `CONC` lines, addressed by cross-reference identifier.
|
|
173
|
+
/// Call this on a record just edited with `set_logical_value`, since
|
|
174
|
+
/// that only ever splits a payload at the newlines it is given. Returns
|
|
175
|
+
/// whether anything changed; a node still carrying its original,
|
|
176
|
+
/// unedited line is never touched. Raises `KeyError` when no record
|
|
177
|
+
/// answers to `xref`.
|
|
178
|
+
#[pyo3(signature = (xref, max_line_bytes = 255))]
|
|
179
|
+
fn fit_line_limit(&mut self, xref: &str, max_line_bytes: usize) -> PyResult<bool> {
|
|
180
|
+
let changed = self
|
|
181
|
+
.document
|
|
182
|
+
.fit_line_limit(xref, max_line_bytes)
|
|
183
|
+
.map_err(|_| PyKeyError::new_err(xref.to_owned()))?;
|
|
184
|
+
if changed {
|
|
185
|
+
self.graph = None;
|
|
186
|
+
}
|
|
187
|
+
Ok(changed)
|
|
188
|
+
}
|
|
189
|
+
|
|
170
190
|
/// What decoding decided, one line — show it before accepting an import.
|
|
171
191
|
#[getter]
|
|
172
192
|
fn encoding(&self) -> String {
|
|
@@ -186,6 +186,222 @@ pub struct WrappedLine {
|
|
|
186
186
|
/// The fewest bytes a line limit may leave for level, tag, and payload.
|
|
187
187
|
const SMALLEST_LINE_LIMIT: usize = 32;
|
|
188
188
|
|
|
189
|
+
/// GEDCOM 5.5.1's line length limit, in bytes.
|
|
190
|
+
///
|
|
191
|
+
/// The specification: "The total length of a GEDCOM line, including level
|
|
192
|
+
/// number, cross-reference number, tag, value, delimiters, and terminator,
|
|
193
|
+
/// must not exceed 255 (wide) characters." GEDCOM 7.0 removed `CONC` and
|
|
194
|
+
/// every length limit, so this only matters for a document that declares
|
|
195
|
+
/// 5.5.x. Pass it to [`Node::fit_line_limit`] or
|
|
196
|
+
/// [`Document::wrap_long_lines`] — the two reserve a different number of
|
|
197
|
+
/// bytes for the line terminator; see [`Node::fit_line_limit`]'s own
|
|
198
|
+
/// documentation for why.
|
|
199
|
+
pub const MAX_LINE_LENGTH: usize = 255;
|
|
200
|
+
|
|
201
|
+
/// Bytes [`Node::fit_line_limit`] reserves for the line terminator when
|
|
202
|
+
/// measuring whether a line fits: exactly 2, for a `\r\n` (CRLF)
|
|
203
|
+
/// terminator. [`Document::wrap_long_lines`] reserves only 1, for the
|
|
204
|
+
/// `\n` this crate's own writer emits; `fit_line_limit` reserves the wider
|
|
205
|
+
/// CRLF instead because callers of this function may hand the result to a
|
|
206
|
+
/// pipeline that preserves the source file's own terminator — CRLF
|
|
207
|
+
/// included — rather than always writing LF. A line this leaves alone is
|
|
208
|
+
/// therefore safe under either terminator convention.
|
|
209
|
+
const TERMINATOR_RESERVE: usize = 2;
|
|
210
|
+
|
|
211
|
+
/// The bytes a line spends before its payload, payload delimiter included:
|
|
212
|
+
/// level, a space, the identifier and a space when there is one, the tag,
|
|
213
|
+
/// and the space that introduces the payload.
|
|
214
|
+
fn segment_prefix_bytes(level: usize, xref: Option<&str>, tag: &str) -> usize {
|
|
215
|
+
level.to_string().len() + 1 + xref.map_or(0, |xref| xref.len() + 1) + tag.len() + 1
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Fits one payload segment — a node's own line plus the `CONC` lines that
|
|
219
|
+
/// continue it without a newline — inside `max_line` bytes, the way
|
|
220
|
+
/// [`Node::fit_line_limit`] documents. `old_concs` is that segment's
|
|
221
|
+
/// existing trailing `CONC` children (already removed from the tree by the
|
|
222
|
+
/// caller); the return value is what should replace them.
|
|
223
|
+
///
|
|
224
|
+
/// Left alone, unconditionally: a segment whose head carries a verbatim
|
|
225
|
+
/// source line (its line is fixed, not ours to move text out of), a
|
|
226
|
+
/// segment with a verbatim `CONC` in it (same reason), a pointer payload
|
|
227
|
+
/// (pointers are short; splitting one would corrupt it), and a segment that
|
|
228
|
+
/// already fits. Otherwise every byte of the segment's folded text — the
|
|
229
|
+
/// head's value followed by each old `CONC`'s value, exactly as
|
|
230
|
+
/// [`Node::logical_value`] would fold them — is recut with [`cut`] and
|
|
231
|
+
/// redistributed, which is what guarantees the fold-after-split round trip.
|
|
232
|
+
fn resplit_segment(
|
|
233
|
+
level: usize,
|
|
234
|
+
xref: Option<&str>,
|
|
235
|
+
tag: &str,
|
|
236
|
+
verbatim: Option<&str>,
|
|
237
|
+
value: &mut Option<String>,
|
|
238
|
+
old_concs: Vec<Node>,
|
|
239
|
+
max_line: usize,
|
|
240
|
+
) -> (Vec<Node>, bool) {
|
|
241
|
+
if verbatim.is_some() || old_concs.iter().any(|conc| conc.verbatim.is_some()) {
|
|
242
|
+
return (old_concs, false);
|
|
243
|
+
}
|
|
244
|
+
if value.as_deref().is_some_and(is_xref) {
|
|
245
|
+
return (old_concs, false);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let head_budget =
|
|
249
|
+
max_line.checked_sub(TERMINATOR_RESERVE + segment_prefix_bytes(level, xref, tag));
|
|
250
|
+
let conc_budget =
|
|
251
|
+
max_line.checked_sub(TERMINATOR_RESERVE + segment_prefix_bytes(level + 1, None, "CONC"));
|
|
252
|
+
// Four bytes is the widest UTF-8 character: less and a cut may not be
|
|
253
|
+
// able to make progress, so the line is left as it is.
|
|
254
|
+
let (Some(head_budget), Some(conc_budget)) = (head_budget, conc_budget) else {
|
|
255
|
+
return (old_concs, false);
|
|
256
|
+
};
|
|
257
|
+
if head_budget < 4 || conc_budget < 4 {
|
|
258
|
+
return (old_concs, false);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let head_fits = value.as_deref().map_or(0, str::len) <= head_budget;
|
|
262
|
+
let concs_fit = old_concs
|
|
263
|
+
.iter()
|
|
264
|
+
.all(|conc| conc.value.as_deref().map_or(0, str::len) <= conc_budget);
|
|
265
|
+
if head_fits && concs_fit {
|
|
266
|
+
return (old_concs, false);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let mut text = value.clone().unwrap_or_default();
|
|
270
|
+
for conc in &old_concs {
|
|
271
|
+
text.push_str(conc.value.as_deref().unwrap_or_default());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let mut pieces = cut(&text, head_budget, conc_budget).into_iter();
|
|
275
|
+
let head_piece = pieces.next().unwrap_or_default();
|
|
276
|
+
*value = (!head_piece.is_empty()).then(|| head_piece.to_owned());
|
|
277
|
+
let new_concs = pieces
|
|
278
|
+
.map(|piece| Node::with_value("CONC", piece))
|
|
279
|
+
.collect();
|
|
280
|
+
(new_concs, true)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
impl Node {
|
|
284
|
+
/// Fits this node and its substructures inside a 5.5.x document's line
|
|
285
|
+
/// limit — [`MAX_LINE_LENGTH`] for the specification's own 255 bytes —
|
|
286
|
+
/// by inserting `CONC` lines. Returns whether anything changed.
|
|
287
|
+
///
|
|
288
|
+
/// Call this on the records a caller edited in a pre-7.0 document, just
|
|
289
|
+
/// before serializing: [`Node::set_logical_value`] only ever splits a
|
|
290
|
+
/// payload across `CONT` at the newlines it is given, so an edited note
|
|
291
|
+
/// with one long paragraph is otherwise written as a single over-long
|
|
292
|
+
/// line. `level` is the nesting level this node is rendered at — the
|
|
293
|
+
/// same number [`Node::at`]'s caller already tracks, since a node does
|
|
294
|
+
/// not store its own level (see the type's own documentation).
|
|
295
|
+
///
|
|
296
|
+
/// Only nodes with no verbatim source line are touched, checked at
|
|
297
|
+
/// every node this walks into, recursively: a node still carrying its
|
|
298
|
+
/// verbatim line — meaning a caller never reached into it — is left
|
|
299
|
+
/// exactly as it was, whether or not its rendered line is over length.
|
|
300
|
+
/// That is what keeps this additive next to the byte-preservation
|
|
301
|
+
/// guarantee: it only ever rewrites a node through the same setters an
|
|
302
|
+
/// editing caller already surrendered the node's verbatim line to use.
|
|
303
|
+
/// A payload read as a pointer ([`Node::pointer`]) is never split
|
|
304
|
+
/// either — pointers are well under any real line limit, and cutting
|
|
305
|
+
/// one would corrupt it.
|
|
306
|
+
///
|
|
307
|
+
/// A too-long line becomes its payload's head plus `CONC` children
|
|
308
|
+
/// inserted right after whatever `CONC` lines already continued that
|
|
309
|
+
/// same line (so an existing, already-fitting split is preserved in
|
|
310
|
+
/// place rather than reordered); a too-long `CONT` line is split the
|
|
311
|
+
/// same way, into that `CONT` followed by its own new `CONC` siblings.
|
|
312
|
+
/// Every cut lands on a UTF-8 character boundary and, whenever the
|
|
313
|
+
/// payload has one within the limit, on a byte with a non-space
|
|
314
|
+
/// character on each side, since GEDCOM 5.5.1 warns that a line broken
|
|
315
|
+
/// beside a space loses that space on re-joining. Failing that — a run
|
|
316
|
+
/// of spaces wider than the limit, say — the cut falls back to the
|
|
317
|
+
/// latest legal byte boundary instead, which is documented behavior of
|
|
318
|
+
/// [`cut`] this call reuses, not a special case here.
|
|
319
|
+
///
|
|
320
|
+
/// The rendered line is measured in UTF-8 bytes, including level,
|
|
321
|
+
/// optional cross-reference, tag, delimiters, and a terminator reserved
|
|
322
|
+
/// at 2 bytes — a `\r\n` (CRLF) terminator, not the single-byte `\n`
|
|
323
|
+
/// this crate's own writer emits. That is deliberately wider than
|
|
324
|
+
/// [`Document::wrap_long_lines`]'s 1-byte reserve: this function exists
|
|
325
|
+
/// for a caller that fits one edited record before handing the document
|
|
326
|
+
/// on to something that may write it with the source file's own
|
|
327
|
+
/// terminator, CRLF included, so a line this leaves alone is safe under
|
|
328
|
+
/// either convention.
|
|
329
|
+
///
|
|
330
|
+
/// ```
|
|
331
|
+
/// use gedcomkit::{Node, build::MAX_LINE_LENGTH};
|
|
332
|
+
///
|
|
333
|
+
/// let mut note = Node::record("@N1@", "NOTE");
|
|
334
|
+
/// note.set_logical_value(&"word ".repeat(80));
|
|
335
|
+
/// let changed = note.fit_line_limit(0, MAX_LINE_LENGTH);
|
|
336
|
+
/// assert!(changed);
|
|
337
|
+
/// assert_eq!(note.logical_value(), "word ".repeat(80));
|
|
338
|
+
/// ```
|
|
339
|
+
pub fn fit_line_limit(&mut self, level: usize, max_line: usize) -> bool {
|
|
340
|
+
fit_node(self, level, max_line)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
fn fit_node(node: &mut Node, level: usize, max_line: usize) -> bool {
|
|
345
|
+
let run_len = node
|
|
346
|
+
.children
|
|
347
|
+
.iter()
|
|
348
|
+
.take_while(|child| matches!(child.tag.as_str(), "CONT" | "CONC"))
|
|
349
|
+
.count();
|
|
350
|
+
let mut run: Vec<Node> = node.children.drain(0..run_len).collect();
|
|
351
|
+
let self_conc_end = run
|
|
352
|
+
.iter()
|
|
353
|
+
.position(|child| child.tag == "CONT")
|
|
354
|
+
.unwrap_or(run.len());
|
|
355
|
+
let self_concs: Vec<Node> = run.drain(0..self_conc_end).collect();
|
|
356
|
+
|
|
357
|
+
let xref = node.xref.clone();
|
|
358
|
+
let tag = node.tag.clone();
|
|
359
|
+
let verbatim = node.verbatim.clone();
|
|
360
|
+
let (mut output, mut changed) = resplit_segment(
|
|
361
|
+
level,
|
|
362
|
+
xref.as_deref(),
|
|
363
|
+
&tag,
|
|
364
|
+
verbatim.as_deref(),
|
|
365
|
+
&mut node.value,
|
|
366
|
+
self_concs,
|
|
367
|
+
max_line,
|
|
368
|
+
);
|
|
369
|
+
if changed {
|
|
370
|
+
node.verbatim = None;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let mut segments = run.into_iter().peekable();
|
|
374
|
+
while let Some(mut cont) = segments.next() {
|
|
375
|
+
let mut concs = Vec::new();
|
|
376
|
+
while segments.peek().is_some_and(|next| next.tag == "CONC") {
|
|
377
|
+
concs.push(segments.next().expect("peeked"));
|
|
378
|
+
}
|
|
379
|
+
let cont_verbatim = cont.verbatim.clone();
|
|
380
|
+
let (new_concs, seg_changed) = resplit_segment(
|
|
381
|
+
level + 1,
|
|
382
|
+
None,
|
|
383
|
+
"CONT",
|
|
384
|
+
cont_verbatim.as_deref(),
|
|
385
|
+
&mut cont.value,
|
|
386
|
+
concs,
|
|
387
|
+
max_line,
|
|
388
|
+
);
|
|
389
|
+
if seg_changed {
|
|
390
|
+
cont.verbatim = None;
|
|
391
|
+
}
|
|
392
|
+
changed |= seg_changed;
|
|
393
|
+
output.push(cont);
|
|
394
|
+
output.extend(new_concs);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let inserted = output.len();
|
|
398
|
+
node.children.splice(0..0, output);
|
|
399
|
+
for child in node.children.iter_mut().skip(inserted) {
|
|
400
|
+
changed |= fit_node(child, level + 1, max_line);
|
|
401
|
+
}
|
|
402
|
+
changed
|
|
403
|
+
}
|
|
404
|
+
|
|
189
405
|
impl Document {
|
|
190
406
|
/// Adds substructures after the existing ones of the structure at `path`
|
|
191
407
|
/// in `record` — a header's `DATE` and `SUBM`, say, which
|
|
@@ -301,6 +517,14 @@ impl Document {
|
|
|
301
517
|
/// specification's warning, since some readers strip the space at the
|
|
302
518
|
/// end of a line and lose it from the joined value.
|
|
303
519
|
///
|
|
520
|
+
/// The terminator is reserved at 1 byte here, matching the single `\n`
|
|
521
|
+
/// this crate's own writer emits ([`Document::to_text`],
|
|
522
|
+
/// [`Document::to_bytes`]) — this function measures the document it is
|
|
523
|
+
/// about to write out itself. [`Node::fit_line_limit`] reserves 2 bytes
|
|
524
|
+
/// instead, for a caller that fits one record ahead of writing the
|
|
525
|
+
/// document through something else that may keep the source file's own
|
|
526
|
+
/// terminator, CRLF included.
|
|
527
|
+
///
|
|
304
528
|
/// This rewrites what it touches, so it is an explicit operation with a
|
|
305
529
|
/// report rather than something writing does: a structure that fits is
|
|
306
530
|
/// left exactly as it is, and one that does not has its payload's
|
|
@@ -354,6 +578,37 @@ impl Document {
|
|
|
354
578
|
}
|
|
355
579
|
Ok(wrapped)
|
|
356
580
|
}
|
|
581
|
+
|
|
582
|
+
/// [`Node::fit_line_limit`] for one record, addressed by cross-reference
|
|
583
|
+
/// identifier — the `Document`-level entry point for it, the way
|
|
584
|
+
/// [`Document::record_mut`] is for reaching a record to edit it in the
|
|
585
|
+
/// first place. Records sit at level 0, so unlike [`Node::fit_line_limit`]
|
|
586
|
+
/// there is no level for the caller to track. Returns whether anything
|
|
587
|
+
/// changed.
|
|
588
|
+
///
|
|
589
|
+
/// ```
|
|
590
|
+
/// use gedcomkit::{Document, Node, build::MAX_LINE_LENGTH};
|
|
591
|
+
///
|
|
592
|
+
/// let mut document = Document::new_v551("EXAMPLE");
|
|
593
|
+
/// let mut note = Node::record("@N1@", "NOTE");
|
|
594
|
+
/// note.set_logical_value(&"word ".repeat(80));
|
|
595
|
+
/// document.append_records(vec![note])?;
|
|
596
|
+
///
|
|
597
|
+
/// assert!(document.fit_line_limit("@N1@", MAX_LINE_LENGTH)?);
|
|
598
|
+
/// assert!(document.to_text().lines().all(|line| line.len() < MAX_LINE_LENGTH));
|
|
599
|
+
/// # Ok::<(), gedcomkit::GedcomError>(())
|
|
600
|
+
/// ```
|
|
601
|
+
///
|
|
602
|
+
/// # Errors
|
|
603
|
+
///
|
|
604
|
+
/// A [`GedcomErrorKind::Structure`] error when no record answers to
|
|
605
|
+
/// `xref`.
|
|
606
|
+
pub fn fit_line_limit(&mut self, xref: &str, max_line: usize) -> Result<bool, GedcomError> {
|
|
607
|
+
let record = self
|
|
608
|
+
.record_mut(xref)
|
|
609
|
+
.ok_or_else(|| fault(0, GedcomErrorKind::Structure, format!("no record {xref}")))?;
|
|
610
|
+
Ok(record.fit_line_limit(0, max_line))
|
|
611
|
+
}
|
|
357
612
|
}
|
|
358
613
|
|
|
359
614
|
fn wrap_node(
|
|
@@ -708,4 +963,175 @@ mod tests {
|
|
|
708
963
|
assert!(!piece.ends_with(' '), "{piece:?}");
|
|
709
964
|
}
|
|
710
965
|
}
|
|
966
|
+
|
|
967
|
+
fn fits(node: &Node, max_line: usize) -> bool {
|
|
968
|
+
Document {
|
|
969
|
+
records: vec![node.clone()],
|
|
970
|
+
}
|
|
971
|
+
.to_text()
|
|
972
|
+
.lines()
|
|
973
|
+
.all(|line| line.len() < max_line)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
#[test]
|
|
977
|
+
fn fit_line_limit_splits_a_long_edited_ascii_payload() {
|
|
978
|
+
let mut note = Node::record("@N1@", "NOTE");
|
|
979
|
+
let text = "word ".repeat(80);
|
|
980
|
+
note.set_logical_value(&text);
|
|
981
|
+
assert!(note.verbatim().is_none(), "an edited node has no verbatim");
|
|
982
|
+
|
|
983
|
+
assert!(note.fit_line_limit(0, MAX_LINE_LENGTH));
|
|
984
|
+
assert_eq!(note.logical_value(), text, "fold(split(x)) == x");
|
|
985
|
+
assert!(fits(¬e, MAX_LINE_LENGTH));
|
|
986
|
+
assert!(
|
|
987
|
+
note.all("CONC").count() > 0,
|
|
988
|
+
"the overflow became CONC children"
|
|
989
|
+
);
|
|
990
|
+
// A second pass has nothing left to do.
|
|
991
|
+
assert!(!note.fit_line_limit(0, MAX_LINE_LENGTH));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
#[test]
|
|
995
|
+
fn fit_line_limit_splits_multibyte_text_on_character_boundaries() {
|
|
996
|
+
let mut note = Node::record("@N1@", "NOTE");
|
|
997
|
+
let text = "Pointe-Claire, Île de Montréal, Québec ".repeat(12);
|
|
998
|
+
note.set_logical_value(text.trim_end());
|
|
999
|
+
|
|
1000
|
+
assert!(note.fit_line_limit(0, 80));
|
|
1001
|
+
assert_eq!(note.logical_value(), text.trim_end());
|
|
1002
|
+
assert!(fits(¬e, 80));
|
|
1003
|
+
assert!(note.all("CONC").count() > 0);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
#[test]
|
|
1007
|
+
fn fit_line_limit_handles_a_space_at_every_candidate_boundary() {
|
|
1008
|
+
let mut note = Node::record("@N1@", "NOTE");
|
|
1009
|
+
// Every legal cut point in `x x x ...` has a space on at least one
|
|
1010
|
+
// side of it, exercising the fallback in `best_cut`.
|
|
1011
|
+
let text = "x ".repeat(200);
|
|
1012
|
+
note.set_logical_value(text.trim_end());
|
|
1013
|
+
|
|
1014
|
+
assert!(note.fit_line_limit(0, 40));
|
|
1015
|
+
assert_eq!(note.logical_value(), text.trim_end());
|
|
1016
|
+
assert!(fits(¬e, 40));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
#[test]
|
|
1020
|
+
fn fit_line_limit_splits_an_over_long_cont_line_in_place() {
|
|
1021
|
+
let mut note = Node::record("@N1@", "NOTE");
|
|
1022
|
+
let long_second_line = "b".repeat(80);
|
|
1023
|
+
let logical = format!("short first line\n{long_second_line}\nlast line");
|
|
1024
|
+
note.set_logical_value(&logical);
|
|
1025
|
+
note.push(Node::with_value("SOUR", "@S1@"));
|
|
1026
|
+
|
|
1027
|
+
assert!(note.fit_line_limit(0, 40));
|
|
1028
|
+
assert_eq!(note.logical_value(), logical);
|
|
1029
|
+
assert!(fits(¬e, 40));
|
|
1030
|
+
// The CONT run still comes before the untouched substructure that
|
|
1031
|
+
// followed it.
|
|
1032
|
+
assert_eq!(note.children.last().map(Node::tag), Some("SOUR"));
|
|
1033
|
+
let tags: Vec<&str> = note.children.iter().map(Node::tag).collect();
|
|
1034
|
+
assert_eq!(tags.last(), Some(&"SOUR"));
|
|
1035
|
+
assert_eq!(tags.iter().filter(|tag| **tag == "CONT").count(), 2);
|
|
1036
|
+
assert!(tags.contains(&"CONC"));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
#[test]
|
|
1040
|
+
fn fit_line_limit_never_splits_a_pointer_payload() {
|
|
1041
|
+
let long_pointer = format!("@{}@", "I".repeat(300));
|
|
1042
|
+
let mut record = Node::record("@F1@", "FAM");
|
|
1043
|
+
record.push(Node::with_value("HUSB", long_pointer.clone()));
|
|
1044
|
+
|
|
1045
|
+
let husb = record.first_mut("HUSB").expect("HUSB");
|
|
1046
|
+
assert!(husb.pointer().is_some());
|
|
1047
|
+
assert!(!husb.fit_line_limit(1, MAX_LINE_LENGTH));
|
|
1048
|
+
assert_eq!(husb.value(), Some(long_pointer.as_str()));
|
|
1049
|
+
assert!(husb.all("CONC").next().is_none());
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
#[test]
|
|
1053
|
+
fn fit_line_limit_never_touches_a_node_still_carrying_its_verbatim_line() {
|
|
1054
|
+
// The double space between the level and the xref is not what
|
|
1055
|
+
// canonical rendering would produce, so the parser keeps this
|
|
1056
|
+
// line's exact bytes — the mark of a node no caller has reached
|
|
1057
|
+
// into yet.
|
|
1058
|
+
let long = "y".repeat(300);
|
|
1059
|
+
let input = format!("0 HEAD\n0 @N1@ NOTE {long}\n0 TRLR\n");
|
|
1060
|
+
let mut document = Document::parse(&input).expect("parses");
|
|
1061
|
+
let note = document.record_mut("@N1@").expect("note");
|
|
1062
|
+
assert!(note.verbatim().is_some(), "kept because of the odd spacing");
|
|
1063
|
+
|
|
1064
|
+
let before = note.clone();
|
|
1065
|
+
assert!(!note.fit_line_limit(0, MAX_LINE_LENGTH));
|
|
1066
|
+
assert_eq!(*note, before, "an untouched node is not rewritten");
|
|
1067
|
+
assert_eq!(document.to_text(), input);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
#[test]
|
|
1071
|
+
fn fit_line_limit_recurses_past_an_untouched_verbatim_child() {
|
|
1072
|
+
// The record itself has no verbatim (it was appended fresh), but
|
|
1073
|
+
// its NOTE child is a kept verbatim line the caller never reached
|
|
1074
|
+
// into — that child must be left exactly as it was even though the
|
|
1075
|
+
// record around it is being fitted.
|
|
1076
|
+
let long = "z".repeat(300);
|
|
1077
|
+
let odd_note = format!("0 NOTE {long}\n");
|
|
1078
|
+
let reparsed = Document::parse(&format!("0 HEAD\n{odd_note}0 TRLR\n"))
|
|
1079
|
+
.expect("parses")
|
|
1080
|
+
.records
|
|
1081
|
+
.remove(1);
|
|
1082
|
+
assert!(reparsed.verbatim().is_some());
|
|
1083
|
+
|
|
1084
|
+
let mut record = Node::record("@I1@", "INDI");
|
|
1085
|
+
record.push(reparsed.clone());
|
|
1086
|
+
let mut edited_name = Node::with_value("NAME", "x".repeat(300));
|
|
1087
|
+
edited_name.set_logical_value(&"x".repeat(300));
|
|
1088
|
+
record.push(edited_name);
|
|
1089
|
+
|
|
1090
|
+
assert!(record.fit_line_limit(0, MAX_LINE_LENGTH));
|
|
1091
|
+
assert_eq!(
|
|
1092
|
+
record.children[0], reparsed,
|
|
1093
|
+
"the verbatim child is untouched"
|
|
1094
|
+
);
|
|
1095
|
+
assert!(
|
|
1096
|
+
record.children[1].all("CONC").count() > 0,
|
|
1097
|
+
"the edited sibling was still fitted"
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
#[test]
|
|
1102
|
+
fn fit_line_limit_covers_a_record_with_an_xref() {
|
|
1103
|
+
let mut record = Node::record("@I123456@", "INDI");
|
|
1104
|
+
record.push(Node::with_value("NOTE", "n".repeat(260)));
|
|
1105
|
+
|
|
1106
|
+
assert!(record.fit_line_limit(0, MAX_LINE_LENGTH));
|
|
1107
|
+
assert!(fits(&record, MAX_LINE_LENGTH));
|
|
1108
|
+
let note = record.first("NOTE").expect("note");
|
|
1109
|
+
assert_eq!(note.logical_value(), "n".repeat(260));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
#[test]
|
|
1113
|
+
fn document_fit_line_limit_reaches_one_record_by_xref() {
|
|
1114
|
+
let mut document = Document::new_v551("TEST");
|
|
1115
|
+
let mut note = Node::record("@N1@", "NOTE");
|
|
1116
|
+
note.set_logical_value(&"word ".repeat(80));
|
|
1117
|
+
document.append_records(vec![note]).expect("appended");
|
|
1118
|
+
|
|
1119
|
+
assert!(
|
|
1120
|
+
document
|
|
1121
|
+
.fit_line_limit("@N1@", MAX_LINE_LENGTH)
|
|
1122
|
+
.expect("record exists")
|
|
1123
|
+
);
|
|
1124
|
+
assert!(
|
|
1125
|
+
document
|
|
1126
|
+
.to_text()
|
|
1127
|
+
.lines()
|
|
1128
|
+
.all(|line| line.len() < MAX_LINE_LENGTH)
|
|
1129
|
+
);
|
|
1130
|
+
|
|
1131
|
+
let missing = document.fit_line_limit("@N9@", MAX_LINE_LENGTH);
|
|
1132
|
+
assert_eq!(
|
|
1133
|
+
missing.expect_err("no such record").kind(),
|
|
1134
|
+
GedcomErrorKind::Structure
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
711
1137
|
}
|
|
@@ -884,22 +884,64 @@ impl Document {
|
|
|
884
884
|
.is_some_and(|major| major >= 7)
|
|
885
885
|
}
|
|
886
886
|
|
|
887
|
-
///
|
|
888
|
-
/// `@I123@` style every producer uses
|
|
887
|
+
/// One past the highest existing cross-reference identifier with this
|
|
888
|
+
/// prefix, in the `@I123@` style every producer uses — or, when that
|
|
889
|
+
/// would overflow `u64` (a hostile or fuzzed file can carry
|
|
890
|
+
/// `@I18446744073709551615@`, `u64::MAX`), the lowest unused number
|
|
891
|
+
/// instead. Arithmetic is checked throughout, so a file at or near the
|
|
892
|
+
/// top of the `u64` range is handled, never panics, and never wraps.
|
|
893
|
+
///
|
|
894
|
+
/// The prefix match is case-insensitive, the same way [`Document::record`]
|
|
895
|
+
/// resolves identifiers: a file numbering its records `@i1@`, `@i2@` would
|
|
896
|
+
/// otherwise let `next_xref("I")` mint `@I1@`, which `record` treats as
|
|
897
|
+
/// the same identifier as the existing `@i1@`. The returned identifier is
|
|
898
|
+
/// also checked against every existing xref case-insensitively and
|
|
899
|
+
/// advanced past any collision, so the result is always genuinely unused
|
|
900
|
+
/// even when the file mixes padding or case within one prefix (an
|
|
901
|
+
/// `@I01@` alongside an `@I1@`, say).
|
|
889
902
|
#[must_use]
|
|
890
903
|
pub fn next_xref(&self, prefix: &str) -> String {
|
|
904
|
+
let prefix_upper = prefix.to_ascii_uppercase();
|
|
891
905
|
let mut highest = 0u64;
|
|
892
906
|
for record in &self.records {
|
|
893
907
|
let Some(xref) = &record.xref else { continue };
|
|
894
|
-
let inner = xref.trim_matches('@');
|
|
895
|
-
let Some(digits) = inner.strip_prefix(
|
|
908
|
+
let inner = xref.trim_matches('@').to_ascii_uppercase();
|
|
909
|
+
let Some(digits) = inner.strip_prefix(prefix_upper.as_str()) else {
|
|
896
910
|
continue;
|
|
897
911
|
};
|
|
898
912
|
if let Ok(number) = digits.parse::<u64>() {
|
|
899
913
|
highest = highest.max(number);
|
|
900
914
|
}
|
|
901
915
|
}
|
|
902
|
-
|
|
916
|
+
// Every existing identifier, uppercased, built once: a collision
|
|
917
|
+
// check through `Document::record` is a scan of the whole document
|
|
918
|
+
// per candidate, which a hostile file can turn quadratic.
|
|
919
|
+
let taken: HashSet<String> = self
|
|
920
|
+
.records
|
|
921
|
+
.iter()
|
|
922
|
+
.filter_map(|record| record.xref.as_ref())
|
|
923
|
+
.map(|xref| xref.to_ascii_uppercase())
|
|
924
|
+
.collect();
|
|
925
|
+
let unused = |number: u64| {
|
|
926
|
+
let xref = format!("@{prefix}{number}@");
|
|
927
|
+
(!taken.contains(&xref.to_ascii_uppercase())).then_some(xref)
|
|
928
|
+
};
|
|
929
|
+
// Above the high-water mark, skipping any case-insensitive collision.
|
|
930
|
+
// At most `taken.len()` candidates can collide, so this ends.
|
|
931
|
+
let mut candidate = highest.checked_add(1);
|
|
932
|
+
while let Some(number) = candidate {
|
|
933
|
+
if let Some(xref) = unused(number) {
|
|
934
|
+
return xref;
|
|
935
|
+
}
|
|
936
|
+
candidate = number.checked_add(1);
|
|
937
|
+
}
|
|
938
|
+
// The series above the high-water mark is exhausted — `highest` was
|
|
939
|
+
// `u64::MAX`, or every candidate up to it collided. The lowest unused
|
|
940
|
+
// number exists among the first `taken.len() + 1`, so this finds it
|
|
941
|
+
// without overflowing.
|
|
942
|
+
(1..=u64::MAX)
|
|
943
|
+
.find_map(unused)
|
|
944
|
+
.unwrap_or_else(|| format!("@{prefix}0@"))
|
|
903
945
|
}
|
|
904
946
|
|
|
905
947
|
/// Every pointer in the document whose target is not a record, reported as
|
|
@@ -1256,6 +1298,68 @@ mod tests {
|
|
|
1256
1298
|
assert_eq!(document.next_xref("S"), "@S1@");
|
|
1257
1299
|
}
|
|
1258
1300
|
|
|
1301
|
+
#[test]
|
|
1302
|
+
fn next_xref_matches_a_lowercase_files_prefix_case_insensitively() {
|
|
1303
|
+
let document = Document::parse("0 HEAD\n0 @i1@ INDI\n0 TRLR\n").expect("parse");
|
|
1304
|
+
|
|
1305
|
+
let minted = document.next_xref("I");
|
|
1306
|
+
assert_eq!(minted, "@I2@");
|
|
1307
|
+
assert!(
|
|
1308
|
+
document.record(&minted).is_none(),
|
|
1309
|
+
"a freshly minted xref must not already resolve to a record"
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
#[test]
|
|
1314
|
+
fn next_xref_treats_mixed_case_prefixes_as_the_same_series() {
|
|
1315
|
+
let document = Document::parse("0 HEAD\n0 @I1@ INDI\n0 @i5@ INDI\n0 @I3@ INDI\n0 TRLR\n")
|
|
1316
|
+
.expect("parse");
|
|
1317
|
+
|
|
1318
|
+
assert_eq!(document.next_xref("I"), "@I6@");
|
|
1319
|
+
assert_eq!(document.next_xref("i"), "@i6@");
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
#[test]
|
|
1323
|
+
fn next_xref_skips_past_a_case_insensitive_collision() {
|
|
1324
|
+
// @I1@ and @i1@ both exist as distinct source lines (a malformed but
|
|
1325
|
+
// real-world file), so the numeric high-water mark alone is not
|
|
1326
|
+
// enough: candidate @I1@ would resolve back to the existing @i1@ via
|
|
1327
|
+
// `Document::record`'s case-insensitive lookup, and @i1@ likewise.
|
|
1328
|
+
let document = Document::parse(
|
|
1329
|
+
"0 HEAD\n0 @I1@ INDI\n1 NAME First\n0 @i1@ INDI\n1 NAME Second\n0 TRLR\n",
|
|
1330
|
+
)
|
|
1331
|
+
.expect("parse");
|
|
1332
|
+
|
|
1333
|
+
let minted = document.next_xref("I");
|
|
1334
|
+
assert!(document.record(&minted).is_none());
|
|
1335
|
+
assert_eq!(minted, "@I2@");
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
#[test]
|
|
1339
|
+
fn next_xref_falls_back_to_the_lowest_unused_number_past_u64_max() {
|
|
1340
|
+
// A hostile or fuzzed file can carry `@I{u64::MAX}@`; `highest + 1`
|
|
1341
|
+
// would panic in debug and wrap in release. next_xref must do
|
|
1342
|
+
// neither, and must still return a genuinely unused identifier.
|
|
1343
|
+
let document =
|
|
1344
|
+
Document::parse(&format!("0 HEAD\n0 @I{}@ INDI\n0 TRLR\n", u64::MAX)).expect("parse");
|
|
1345
|
+
|
|
1346
|
+
let minted = document.next_xref("I");
|
|
1347
|
+
assert_eq!(minted, "@I1@", "falls back to the lowest unused number");
|
|
1348
|
+
assert!(document.record(&minted).is_none());
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
#[test]
|
|
1352
|
+
fn next_xref_at_u64_max_minus_one_advances_to_u64_max_without_overflow() {
|
|
1353
|
+
// One below the top of the range: `highest + 1` is exactly
|
|
1354
|
+
// `u64::MAX`, which must not overflow computing it or checking it.
|
|
1355
|
+
let document = Document::parse(&format!("0 HEAD\n0 @I{}@ INDI\n0 TRLR\n", u64::MAX - 1))
|
|
1356
|
+
.expect("parse");
|
|
1357
|
+
|
|
1358
|
+
let minted = document.next_xref("I");
|
|
1359
|
+
assert_eq!(minted, format!("@I{}@", u64::MAX));
|
|
1360
|
+
assert!(document.record(&minted).is_none());
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1259
1363
|
#[test]
|
|
1260
1364
|
fn version_is_read_from_the_header() {
|
|
1261
1365
|
let five = Document::parse("0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 TRLR\n").expect("parse 5.5.1");
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|