use crate::{Document, GedcomError, GedcomErrorKind, Limits, Node, fault, is_tag, is_xref};
use std::collections::HashSet;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct NodeSpec {
#[cfg_attr(feature = "serde", serde(default))]
pub xref: Option<String>,
pub tag: String,
#[cfg_attr(feature = "serde", serde(default))]
pub value: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub children: Vec<Self>,
}
impl NodeSpec {
#[must_use]
pub fn new(tag: impl Into<String>) -> Self {
Self {
tag: tag.into(),
..Self::default()
}
}
#[must_use]
pub fn with_value(tag: impl Into<String>, value: impl Into<String>) -> Self {
Self {
value: Some(value.into()),
..Self::new(tag)
}
}
#[must_use]
pub fn record(xref: impl Into<String>, tag: impl Into<String>) -> Self {
Self {
xref: Some(xref.into()),
..Self::new(tag)
}
}
pub fn into_node(self, limits: Limits) -> Result<Node, GedcomError> {
let mut structures = 0;
self.build(0, limits, &mut structures)
}
pub fn into_substructure(self, limits: Limits) -> Result<Node, GedcomError> {
let mut structures = 0;
self.build(1, limits, &mut structures)
}
fn build(
self,
level: u16,
limits: Limits,
structures: &mut usize,
) -> Result<Node, GedcomError> {
if level > limits.depth {
return Err(fault(
0,
GedcomErrorKind::Limit,
"the structure is nested deeper than the limit",
));
}
*structures += 1;
if *structures > limits.structures {
return Err(fault(
0,
GedcomErrorKind::Limit,
"the record has too many structures",
));
}
if !is_tag(&self.tag) {
return Err(fault(
0,
GedcomErrorKind::Syntax,
format!(
"{:?} is not a GEDCOM tag: ASCII letters, digits, or underscore",
self.tag
),
));
}
let mut node = match self.xref {
Some(xref) if level > 0 => {
return Err(fault(
0,
GedcomErrorKind::Syntax,
format!(
"{xref} is on a {} below level zero, where only records carry identifiers",
self.tag
),
));
}
Some(xref) if !is_xref(&xref) || xref.contains(' ') => {
return Err(fault(
0,
GedcomErrorKind::Syntax,
format!("{xref:?} is not a cross-reference identifier: the @I1@ shape"),
));
}
Some(xref) => Node::record(xref, self.tag),
None => Node::new(self.tag),
};
if let Some(value) = self.value.filter(|value| !value.is_empty()) {
node.set_logical_value(&value);
}
for child in self.children {
node.push(child.build(level + 1, limits, structures)?);
}
Ok(node)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct WrappedLine {
pub record: String,
pub path: Vec<usize>,
pub tag: String,
}
const SMALLEST_LINE_LIMIT: usize = 32;
impl Document {
pub fn append_structures(
&mut self,
record: &str,
path: &[usize],
structures: Vec<Node>,
) -> Result<(), GedcomError> {
if let Some(named) = structures.iter().find(|node| node.xref.is_some()) {
return Err(fault(
0,
GedcomErrorKind::Syntax,
format!(
"a {} below level zero cannot carry an identifier",
named.tag
),
));
}
let wanted = record.to_ascii_uppercase();
let owner = self
.records
.iter_mut()
.find(|node| {
node.xref.as_ref().map_or_else(
|| node.tag == record,
|xref| xref.to_ascii_uppercase() == wanted,
)
})
.ok_or_else(|| fault(0, GedcomErrorKind::Structure, format!("no record {record}")))?;
let target = owner.at_mut(path).ok_or_else(|| {
fault(
0,
GedcomErrorKind::Structure,
format!("no structure at {path:?} in {record}"),
)
})?;
target.children.extend(structures);
Ok(())
}
pub fn append_records(&mut self, records: Vec<Node>) -> Result<(), GedcomError> {
let mut seen: HashSet<String> = self.xref_index().into_keys().collect();
for record in &records {
if matches!(record.tag.as_str(), "HEAD" | "TRLR") {
return Err(fault(
0,
GedcomErrorKind::Structure,
format!(
"a document has one {}, and this one already does",
record.tag
),
));
}
if let Some(xref) = &record.xref
&& !seen.insert(xref.to_ascii_uppercase())
{
return Err(fault(
0,
GedcomErrorKind::Structure,
format!("the document already has a record {xref}"),
));
}
}
let at = self
.records
.iter()
.rposition(|record| record.tag == "TRLR")
.unwrap_or(self.records.len());
self.records.splice(at..at, records);
Ok(())
}
pub fn wrap_long_lines(
&mut self,
max_line_bytes: usize,
) -> Result<Vec<WrappedLine>, GedcomError> {
if self.is_version_7_or_later() {
return Err(fault(
0,
GedcomErrorKind::Structure,
"version 7 has no CONC and no line limit, so there is nothing to wrap",
));
}
if max_line_bytes < SMALLEST_LINE_LIMIT {
return Err(fault(
0,
GedcomErrorKind::Limit,
format!(
"a line limit under {SMALLEST_LINE_LIMIT} bytes leaves no room for a payload"
),
));
}
let budget = max_line_bytes - 1;
let mut wrapped = Vec::new();
for record in &mut self.records {
let owner = record.xref.clone().unwrap_or_else(|| record.tag.clone());
let mut path = Vec::new();
wrap_node(record, 0, budget, &owner, &mut path, &mut wrapped);
}
Ok(wrapped)
}
}
fn wrap_node(
node: &mut Node,
level: u16,
budget: usize,
owner: &str,
path: &mut Vec<usize>,
wrapped: &mut Vec<WrappedLine>,
) {
if needs_wrapping(node, level, budget) && rewrap(node, level, budget) {
wrapped.push(WrappedLine {
record: owner.to_owned(),
path: path.clone(),
tag: node.tag.clone(),
});
}
for (index, child) in node.children.iter_mut().enumerate() {
if matches!(child.tag.as_str(), "CONT" | "CONC") {
continue;
}
path.push(index);
wrap_node(child, level + 1, budget, owner, path, wrapped);
path.pop();
}
}
fn prefix_bytes(level: u16, xref: Option<&str>, tag: &str) -> usize {
level.to_string().len() + 1 + xref.map_or(0, |xref| xref.len() + 1) + tag.len() + 1
}
fn line_bytes(node: &Node, level: u16) -> usize {
node.verbatim.as_deref().map_or_else(
|| {
prefix_bytes(level, node.xref.as_deref(), &node.tag) - 1
+ node.value.as_deref().map_or(0, |value| value.len() + 1)
},
str::len,
)
}
fn needs_wrapping(node: &Node, level: u16, budget: usize) -> bool {
line_bytes(node, level) > budget
|| node
.children
.iter()
.filter(|child| matches!(child.tag.as_str(), "CONT" | "CONC"))
.any(|child| line_bytes(child, level + 1) > budget)
}
fn rewrap(node: &mut Node, level: u16, budget: usize) -> bool {
let Some(first_room) = budget.checked_sub(prefix_bytes(level, node.xref.as_deref(), &node.tag))
else {
return false;
};
let continuation_room = budget.saturating_sub(prefix_bytes(level + 1, None, "CONC"));
if first_room < 4 || continuation_room < 4 {
return false;
}
let logical = node.logical_value();
let mut value = None;
let mut continuations = Vec::new();
for (index, line) in logical.split('\n').enumerate() {
let room = if index == 0 {
first_room
} else {
continuation_room
};
let mut pieces = cut(line, room, continuation_room).into_iter();
let head = pieces.next().filter(|piece| !piece.is_empty());
if index == 0 {
value = head.map(str::to_owned);
} else {
continuations.push(
head.map_or_else(|| Node::new("CONT"), |head| Node::with_value("CONT", head)),
);
}
continuations.extend(pieces.map(|piece| Node::with_value("CONC", piece)));
}
node.children
.retain(|child| !matches!(child.tag.as_str(), "CONT" | "CONC"));
node.set_value(value);
for (index, continuation) in continuations.into_iter().enumerate() {
node.children.insert(index, continuation);
}
true
}
fn cut(line: &str, first: usize, rest: usize) -> Vec<&str> {
let mut pieces = Vec::new();
let mut remaining = line;
let mut room = first;
while remaining.len() > room {
let at = best_cut(remaining, room);
pieces.push(&remaining[..at]);
remaining = &remaining[at..];
room = rest;
}
pieces.push(remaining);
pieces
}
fn best_cut(text: &str, room: usize) -> usize {
let widest = floor_boundary(text, room);
let lowest = (widest / 2).max(1);
let boundaries = || {
(lowest..=widest)
.rev()
.filter(|&at| text.is_char_boundary(at))
};
boundaries()
.find(|&at| !text[..at].ends_with(' ') && !text[at..].starts_with(' '))
.or_else(|| boundaries().find(|&at| !text[..at].ends_with(' ')))
.unwrap_or(widest)
}
fn floor_boundary(text: &str, index: usize) -> usize {
let mut at = index.min(text.len());
while !text.is_char_boundary(at) {
at -= 1;
}
at
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(tag: &str) -> NodeSpec {
NodeSpec::new(tag)
}
#[test]
fn a_description_becomes_the_node_it_describes() {
let mut record = NodeSpec::record("@I1@", "INDI");
let mut birth = spec("BIRT");
birth
.children
.push(NodeSpec::with_value("DATE", "5 JAN 1882"));
record
.children
.push(NodeSpec::with_value("NAME", "Ada /Example/"));
record.children.push(birth);
record
.children
.push(NodeSpec::with_value("NOTE", "one\ntwo"));
record.children.push(NodeSpec::with_value("SEX", ""));
let node = record.into_node(Limits::DEFAULT).expect("valid");
let text = Document {
records: vec![node],
}
.to_text();
assert_eq!(
text,
"0 @I1@ INDI\n1 NAME Ada /Example/\n1 BIRT\n2 DATE 5 JAN 1882\n1 NOTE one\n2 CONT two\n1 SEX\n"
);
}
#[test]
fn a_description_the_parser_would_not_read_back_is_refused() {
let refused = |spec: NodeSpec| spec.into_node(Limits::DEFAULT).expect_err("refused").kind();
assert_eq!(refused(spec("NA ME")), GedcomErrorKind::Syntax);
assert_eq!(refused(spec("")), GedcomErrorKind::Syntax);
assert_eq!(
refused(NodeSpec::record("I1", "INDI")),
GedcomErrorKind::Syntax
);
assert_eq!(
refused(NodeSpec::record("@I 1@", "INDI")),
GedcomErrorKind::Syntax
);
let mut nested = spec("INDI");
nested.children.push(NodeSpec::record("@X1@", "NOTE"));
assert_eq!(refused(nested), GedcomErrorKind::Syntax);
}
#[test]
fn a_description_is_bounded() {
let mut deep = spec("A");
for _ in 0..10 {
let mut parent = spec("A");
parent.children.push(deep);
deep = parent;
}
let shallow = Limits::DEFAULT.with_depth(5);
let error = deep.into_node(shallow).expect_err("too deep");
assert_eq!(error.kind(), GedcomErrorKind::Limit);
}
#[test]
fn substructures_join_the_header_or_a_record_and_nothing_else_moves() {
let input = "0 HEAD\n1 SOUR ODD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 NAME Ada /Example/\n0 TRLR\n";
let mut document = Document::parse(input).expect("parses");
document
.append_structures("HEAD", &[], vec![Node::with_value("SUBM", "@U1@")])
.expect("header");
document
.append_structures("@i1@", &[0], vec![Node::with_value("GIVN", "Ada")])
.expect("record");
assert_eq!(
document.to_text(),
"0 HEAD\n1 SOUR ODD\n1 GEDC\n2 VERS 5.5.1\n1 SUBM @U1@\n0 @I1@ INDI\n1 NAME Ada /Example/\n2 GIVN Ada\n0 TRLR\n"
);
let refused = |result: Result<(), GedcomError>| result.expect_err("refused").kind();
assert_eq!(
refused(document.append_structures("@I9@", &[], vec![Node::new("NOTE")])),
GedcomErrorKind::Structure
);
assert_eq!(
refused(document.append_structures("@I1@", &[7], vec![Node::new("NOTE")])),
GedcomErrorKind::Structure
);
assert_eq!(
refused(document.append_structures("@I1@", &[], vec![Node::record("@N1@", "NOTE")])),
GedcomErrorKind::Syntax
);
let spec = NodeSpec::record("@N1@", "NOTE");
assert!(spec.into_substructure(Limits::DEFAULT).is_err());
}
#[test]
fn records_go_before_the_trailer_all_or_none() {
let mut document = Document::new_v551("TEST");
document
.append_records(vec![
Node::record("@I1@", "INDI"),
Node::record("@F1@", "FAM"),
])
.expect("appended");
let tags: Vec<&str> = document.records.iter().map(Node::tag).collect();
assert_eq!(tags, ["HEAD", "INDI", "FAM", "TRLR"]);
let before = document.clone();
let repeated = document.append_records(vec![
Node::record("@I2@", "INDI"),
Node::record("@i1@", "INDI"),
]);
assert_eq!(
repeated.expect_err("a repeated identifier").kind(),
GedcomErrorKind::Structure
);
let twice = document.append_records(vec![
Node::record("@I3@", "INDI"),
Node::record("@I3@", "INDI"),
]);
assert!(twice.is_err());
assert!(document.append_records(vec![Node::new("TRLR")]).is_err());
assert_eq!(document, before, "a refused call adds nothing");
}
#[test]
fn long_lines_are_wrapped_at_characters_not_beside_spaces() {
let text = "Pointe-Claire, Île de Montréal, Québec ".repeat(12);
let mut document = Document::new_v551("TEST");
let record = Node::record("@N1@", "NOTE").child(Node::with_value("NOTE", text.trim_end()));
document.append_records(vec![record]).expect("appended");
let untouched = document.records[0].clone();
let wrapped = document.wrap_long_lines(80).expect("wrapped");
assert_eq!(
wrapped,
[WrappedLine {
record: "@N1@".into(),
path: vec![0],
tag: "NOTE".into()
}]
);
let written = document.to_text();
for line in written.lines() {
assert!(line.len() < 80, "{line:?}");
assert!(!line.ends_with(' '), "{line:?}");
}
let reread = Document::parse(&written).expect("reads back");
let note = reread
.record("@N1@")
.and_then(|note| note.first("NOTE"))
.expect("note");
assert_eq!(note.logical_value(), text.trim_end());
assert_eq!(
document.records[0], untouched,
"the header fits and is not touched"
);
}
#[test]
fn a_long_continuation_line_is_wrapped_in_place() {
let long = "word ".repeat(80);
let logical = format!("short\n{}\nend", long.trim_end());
let mut document = Document::new_v551("TEST");
let mut note = Node::record("@N1@", "NOTE");
note.set_logical_value(&logical);
let note = note.child(Node::with_value("SOUR", "@S1@"));
document.append_records(vec![note]).expect("appended");
document.wrap_long_lines(255).expect("wrapped");
let reread = Document::parse(&document.to_text()).expect("reads back");
let note = reread.record("@N1@").expect("note");
assert_eq!(note.logical_value(), logical);
assert_eq!(note.children.last().map(Node::tag), Some("SOUR"));
assert!(document.to_text().lines().all(|line| line.len() < 255));
}
#[test]
fn a_file_that_fits_is_left_exactly_as_it_was() {
let input = "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 NAME Ada /Example/\n0 TRLR\n";
let mut document = Document::parse(input).expect("parses");
assert!(document.wrap_long_lines(255).expect("wrapped").is_empty());
assert_eq!(document.to_text(), input);
}
#[test]
fn version_seven_and_tiny_limits_are_refused() {
let mut seven = Document::new_v7();
assert_eq!(
seven.wrap_long_lines(255).expect_err("7.0").kind(),
GedcomErrorKind::Structure
);
let mut five = Document::new_v551("TEST");
assert_eq!(
five.wrap_long_lines(10).expect_err("tiny").kind(),
GedcomErrorKind::Limit
);
}
#[test]
fn cuts_fall_on_character_boundaries() {
let text = "ÅÅÅÅÅÅÅÅÅÅ";
for piece in cut(text, 5, 5) {
assert!(piece.len() <= 5);
}
assert_eq!(cut(text, 5, 5).concat(), text);
assert_eq!(cut("a b", 1, 1), ["a", " ", "b"]);
for piece in cut(&"x ".repeat(40), 9, 9).iter().rev().skip(1) {
assert!(!piece.ends_with(' '), "{piece:?}");
}
}
}