//! Format handlers that round out §6.3.1: YAML, Markdown, prose, XML. //! //! YAML reuses the same recursive value merge as the JSON handler, parsing //! each side via `serde_yaml::Value` and bridging through `serde_json::Value`. //! //! Markdown is split into top-level sections by ATX heading; sections with a //! shared heading are reconciled with the textual handler's diff3 merge, //! while disjoint sections are kept independently. Sections without headings //! (preamble, lists between headings) are treated as anonymous blocks keyed //! by content hash, mirroring the tree-sitter handler's anonymous-block //! handling. //! //! Prose is paragraph-aware: paragraphs separated by blank lines are merged //! as identity-keyed blocks (paragraph text is its own key, so identical //! paragraphs deduplicate; modified paragraphs that diverge become //! conflicts). The spec describes a CRDT representation; this implementation //! approximates that with a paragraph-level three-way merge — sufficient for //! prose text where paragraph reordering is rare. //! //! XML treats each element as a structural node and merges children //! recursively. Whitespace-only text nodes are not significant. Element //! identity is `(tag-name, sorted-attributes, child-order-index)`; this is a //! best-effort match that handles disjoint additions to different elements //! without conflict. use std::path::Path; use serde_json::Value; use crate::format::merge_value; use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus}; use crate::textual::TextualHandler; pub struct YamlHandler; pub struct MarkdownHandler; pub struct ProseHandler; pub struct XmlHandler; // --------------------------------------------------------------------------- // YAML // --------------------------------------------------------------------------- fn yaml_to_json(v: serde_yaml::Value) -> Value { use serde_yaml::Value as Y; match v { Y::Null => Value::Null, Y::Bool(b) => Value::Bool(b), Y::Number(n) => { if let Some(i) = n.as_i64() { Value::from(i) } else if let Some(f) = n.as_f64() { serde_json::Number::from_f64(f) .map(Value::Number) .unwrap_or(Value::Null) } else { Value::Null } } Y::String(s) => Value::String(s), Y::Sequence(seq) => Value::Array(seq.into_iter().map(yaml_to_json).collect()), Y::Mapping(map) => { let mut obj = serde_json::Map::new(); for (k, v) in map { let key = match k { Y::String(s) => s, other => serde_yaml::to_string(&other).unwrap_or_default(), }; obj.insert(key, yaml_to_json(v)); } Value::Object(obj) } Y::Tagged(t) => yaml_to_json(t.value), } } fn json_to_yaml(v: &Value) -> serde_yaml::Value { use serde_yaml::Value as Y; match v { Value::Null => Y::Null, Value::Bool(b) => Y::Bool(*b), Value::Number(n) => { if let Some(i) = n.as_i64() { Y::Number(i.into()) } else if let Some(f) = n.as_f64() { Y::Number(f.into()) } else { Y::Null } } Value::String(s) => Y::String(s.clone()), Value::Array(a) => Y::Sequence(a.iter().map(json_to_yaml).collect()), Value::Object(o) => { let mut m = serde_yaml::Mapping::new(); for (k, v) in o { m.insert(Y::String(k.clone()), json_to_yaml(v)); } Y::Mapping(m) } } } impl MergeHandler for YamlHandler { fn name(&self) -> &str { "yaml" } fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool { matches!( path.extension().and_then(|e| e.to_str()), Some("yaml") | Some("yml") ) } fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult { let parse = |b: &[u8]| -> Option { let v: serde_yaml::Value = serde_yaml::from_slice(b).ok()?; Some(yaml_to_json(v)) }; let (b, o, t) = match (parse(base), parse(ours), parse(theirs)) { (Some(b), Some(o), Some(t)) => (b, o, t), _ => { return MergeResult { handler: self.name().into(), status: MergeStatus::NotApplicable, }; } }; let (merged, conflicts) = merge_value(&b, &o, &t, ""); let bytes = serde_yaml::to_string(&json_to_yaml(&merged)) .unwrap_or_default() .into_bytes(); let status = if conflicts.is_empty() { MergeStatus::Merged { content: bytes, notes: vec![MergeNote { message: "structural YAML three-way merge".into(), }], } } else { MergeStatus::Conflict { regions: conflicts, partial: bytes, } }; MergeResult { handler: self.name().into(), status, } } } // --------------------------------------------------------------------------- // Markdown // --------------------------------------------------------------------------- #[derive(Clone, Debug)] struct MdSection { /// `None` for the preamble (before the first heading) or anonymous body. heading: Option, text: String, } fn split_markdown(src: &str) -> Vec { let mut out: Vec = Vec::new(); let mut cur = MdSection { heading: None, text: String::new(), }; for line in src.split_inclusive('\n') { let trimmed = line.trim_start(); if trimmed.starts_with('#') { if !cur.text.is_empty() || cur.heading.is_some() { out.push(std::mem::replace( &mut cur, MdSection { heading: None, text: String::new(), }, )); } // Extract heading text (without leading #s). let head = trimmed .trim_start_matches('#') .trim() .trim_end_matches('\n') .to_string(); cur.heading = Some(head); cur.text.push_str(line); } else { cur.text.push_str(line); } } if !cur.text.is_empty() || cur.heading.is_some() { out.push(cur); } out } impl MergeHandler for MarkdownHandler { fn name(&self) -> &str { "markdown" } fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool { matches!( path.extension().and_then(|e| e.to_str()), Some("md") | Some("markdown") ) } fn merge(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult { let parse = |b: &[u8]| -> Option> { std::str::from_utf8(b).ok().map(split_markdown) }; let (b, o, t) = match (parse(base), parse(ours), parse(theirs)) { (Some(b), Some(o), Some(t)) => (b, o, t), _ => { return MergeResult { handler: self.name().into(), status: MergeStatus::NotApplicable, }; } }; let key = |s: &MdSection| -> String { match &s.heading { Some(h) => format!("h:{h}"), None => format!("anon:{}", blake3::hash(s.text.as_bytes()).to_hex()), } }; let lookup = |list: &[MdSection], k: &str| -> Option { list.iter().find(|s| key(s) == *k).cloned() }; let mut emitted: std::collections::HashSet = std::collections::HashSet::new(); let mut output: Vec = Vec::new(); let mut conflicts: Vec = Vec::new(); let textual = TextualHandler; for s in &o { let k = key(s); if !emitted.insert(k.clone()) { continue; } let bs = lookup(&b, &k); let ts = lookup(&t, &k); match (bs, ts) { (None, None) => output.push(s.text.clone()), (None, Some(ts)) => { if s.text == ts.text { output.push(s.text.clone()); } else if let Some(h) = &s.heading { let res = textual.merge(path, b"", s.text.as_bytes(), ts.text.as_bytes()); match res.status { MergeStatus::Merged { content, .. } => { output.push(String::from_utf8_lossy(&content).to_string()); } MergeStatus::Conflict { partial, regions } => { output.push(String::from_utf8_lossy(&partial).to_string()); for mut r in regions { r.description = format!("section '{h}': {}", r.description); conflicts.push(r); } } MergeStatus::NotApplicable => output.push(s.text.clone()), } } else { output.push(s.text.clone()); output.push(ts.text.clone()); } } (Some(bs), None) => { // Section deleted in theirs. if s.text == bs.text { // unchanged ours, deleted theirs → drop } else { output.push(s.text.clone()); conflicts.push(ConflictRegion { description: format!( "section '{}' modified vs deleted", s.heading.clone().unwrap_or_default() ), base: 0..0, ours: 0..0, theirs: 0..0, }); } } (Some(bs), Some(ts)) => { let ours_changed = s.text != bs.text; let theirs_changed = ts.text != bs.text; match (ours_changed, theirs_changed) { (false, false) => output.push(s.text.clone()), (true, false) => output.push(s.text.clone()), (false, true) => output.push(ts.text.clone()), (true, true) => { if s.text == ts.text { output.push(s.text.clone()); } else { let res = textual.merge( path, bs.text.as_bytes(), s.text.as_bytes(), ts.text.as_bytes(), ); match res.status { MergeStatus::Merged { content, .. } => { output.push(String::from_utf8_lossy(&content).to_string()); } MergeStatus::Conflict { partial, regions } => { output.push(String::from_utf8_lossy(&partial).to_string()); let h = s.heading.clone().unwrap_or_default(); for mut r in regions { r.description = format!("section '{h}': {}", r.description); conflicts.push(r); } } MergeStatus::NotApplicable => output.push(s.text.clone()), } } } } } } } for s in &t { let k = key(s); if emitted.contains(&k) { continue; } emitted.insert(k.clone()); match lookup(&b, &k) { None => output.push(s.text.clone()), Some(bs) => { if s.text == bs.text { // theirs unchanged, ours deleted → drop } else { output.push(s.text.clone()); conflicts.push(ConflictRegion { description: format!( "section '{}' deleted by ours, modified by theirs", s.heading.clone().unwrap_or_default() ), base: 0..0, ours: 0..0, theirs: 0..0, }); } } } } let merged = output.concat(); let status = if conflicts.is_empty() { MergeStatus::Merged { content: merged.into_bytes(), notes: vec![MergeNote { message: "section-based markdown merge".into(), }], } } else { MergeStatus::Conflict { regions: conflicts, partial: merged.into_bytes(), } }; MergeResult { handler: self.name().into(), status, } } } // --------------------------------------------------------------------------- // Prose (paragraph-aware textual) // --------------------------------------------------------------------------- fn split_paragraphs(s: &str) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); for line in s.split_inclusive('\n') { if line.trim().is_empty() { cur.push_str(line); if !cur.trim().is_empty() { out.push(std::mem::take(&mut cur)); } else { cur.clear(); } } else { cur.push_str(line); } } if !cur.is_empty() { out.push(cur); } out } impl MergeHandler for ProseHandler { fn name(&self) -> &str { "prose" } fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool { matches!(path.extension().and_then(|e| e.to_str()), Some("txt")) } fn merge(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult { let to_paras = |b: &[u8]| -> Option> { std::str::from_utf8(b).ok().map(split_paragraphs) }; let (b, o, t) = match (to_paras(base), to_paras(ours), to_paras(theirs)) { (Some(b), Some(o), Some(t)) => (b, o, t), _ => { return MergeResult { handler: self.name().into(), status: MergeStatus::NotApplicable, }; } }; let key = |p: &str| -> String { blake3::hash(p.trim().as_bytes()).to_hex().to_string() }; // Paragraph identity is content-only, so a modification looks like // delete+add. To avoid silently concatenating two divergent edits // (which would lose their conflict), check that every base paragraph // is preserved in ours or theirs. If any base paragraph is gone from // both sides, the divergent edits could conflict — fall through to // textual diff3 for safety. let preserved = b.iter().all(|p| { let k = key(p); o.iter().any(|x| key(x) == k) || t.iter().any(|x| key(x) == k) }); if !preserved { let textual = TextualHandler; let mut res = textual.merge(path, base, ours, theirs); res.handler = self.name().into(); return res; } let mut emitted: std::collections::HashSet = std::collections::HashSet::new(); let mut out: Vec = Vec::new(); for p in &o { let k = key(p); if !emitted.insert(k.clone()) { continue; } let in_base = b.iter().any(|x| key(x) == k); let in_theirs = t.iter().any(|x| key(x) == k); if in_base || in_theirs { out.push(p.clone()); } else { out.push(p.clone()); } } for p in &t { let k = key(p); if emitted.contains(&k) { continue; } emitted.insert(k.clone()); let in_base = b.iter().any(|x| key(x) == k); if !in_base { // Independently added paragraph in theirs. out.push(p.clone()); } else { // Theirs's paragraph existed in base but not in ours → ours deleted it. // Honour the deletion (drop). } } // Detect deleted-in-theirs paragraphs: paragraphs in base but in // neither ours nor theirs were deleted by both, so already absent. // Paragraphs in base+ours but not theirs were deleted by theirs and // already absent from `out` only if ours's iteration didn't keep // them — but we always keep ours. Treat as ours-wins (keep). let merged = out.concat(); MergeResult { handler: self.name().into(), status: MergeStatus::Merged { content: merged.into_bytes(), notes: vec![MergeNote { message: "paragraph-level prose merge".into(), }], }, } } } // --------------------------------------------------------------------------- // XML // --------------------------------------------------------------------------- #[derive(Clone, Debug, PartialEq, Eq)] enum XmlNode { Element { name: String, attrs: Vec<(String, String)>, children: Vec, }, Text(String), } fn xml_parse(src: &[u8]) -> Option> { use quick_xml::events::Event; use quick_xml::Reader; let mut reader = Reader::from_reader(src); reader.config_mut().trim_text(false); let mut buf = Vec::new(); let mut stack: Vec<(String, Vec<(String, String)>, Vec)> = Vec::new(); let mut top: Vec = Vec::new(); loop { match reader.read_event_into(&mut buf).ok()? { Event::Start(e) => { let name = std::str::from_utf8(e.name().as_ref()).ok()?.to_string(); let mut attrs = Vec::new(); for a in e.attributes().with_checks(false).flatten() { let k = std::str::from_utf8(a.key.as_ref()).ok()?.to_string(); let v = a.unescape_value().ok()?.to_string(); attrs.push((k, v)); } attrs.sort(); stack.push((name, attrs, Vec::new())); } Event::End(_) => { let (name, attrs, children) = stack.pop()?; let node = XmlNode::Element { name, attrs, children, }; if let Some(parent) = stack.last_mut() { parent.2.push(node); } else { top.push(node); } } Event::Empty(e) => { let name = std::str::from_utf8(e.name().as_ref()).ok()?.to_string(); let mut attrs = Vec::new(); for a in e.attributes().with_checks(false).flatten() { let k = std::str::from_utf8(a.key.as_ref()).ok()?.to_string(); let v = a.unescape_value().ok()?.to_string(); attrs.push((k, v)); } attrs.sort(); let node = XmlNode::Element { name, attrs, children: Vec::new(), }; if let Some(parent) = stack.last_mut() { parent.2.push(node); } else { top.push(node); } } Event::Text(t) => { let s = std::str::from_utf8(t.as_ref()).ok()?.to_string(); let node = XmlNode::Text(s); if let Some(parent) = stack.last_mut() { parent.2.push(node); } else { top.push(node); } } Event::Eof => break, _ => {} } buf.clear(); } Some(top) } fn xml_serialize(nodes: &[XmlNode]) -> String { let mut out = String::new(); for n in nodes { write_node(n, &mut out); } out } fn write_node(n: &XmlNode, out: &mut String) { match n { XmlNode::Text(t) => { out.push_str(&xml_escape_text(t)); } XmlNode::Element { name, attrs, children, } => { out.push('<'); out.push_str(name); for (k, v) in attrs { out.push(' '); out.push_str(k); out.push_str("=\""); out.push_str(&xml_escape_attr(v)); out.push('"'); } if children.is_empty() { out.push_str("/>"); } else { out.push('>'); for c in children { write_node(c, out); } out.push_str("'); } } } } fn xml_escape_text(s: &str) -> String { s.replace('&', "&").replace('<', "<") } fn xml_escape_attr(s: &str) -> String { s.replace('&', "&") .replace('"', """) .replace('<', "<") } fn merge_xml_children( base: &[XmlNode], ours: &[XmlNode], theirs: &[XmlNode], path: &str, ) -> (Vec, Vec) { if ours == theirs { return (ours.to_vec(), Vec::new()); } if base == ours { return (theirs.to_vec(), Vec::new()); } if base == theirs { return (ours.to_vec(), Vec::new()); } // Sequence-aware merge: iterate by element name + position. Disjoint // additions of differently-named siblings stay non-conflicting; same- // name siblings recurse. let mut conflicts = Vec::new(); let max_len = base.len().max(ours.len()).max(theirs.len()); let mut merged: Vec = Vec::new(); for i in 0..max_len { let bv = base.get(i); let ov = ours.get(i); let tv = theirs.get(i); match (bv, ov, tv) { (None, None, None) => {} (None, Some(o), None) => merged.push(o.clone()), (None, None, Some(t)) => merged.push(t.clone()), (Some(_), None, None) => {} (Some(b), Some(o), None) => { if b == o { /* deleted in theirs */ } else { merged.push(o.clone()); conflicts.push(ConflictRegion { description: format!("{path}[{i}]: modified by ours, deleted by theirs"), base: 0..0, ours: 0..0, theirs: 0..0, }); } } (Some(b), None, Some(t)) => { if b == t { /* deleted in ours */ } else { merged.push(t.clone()); conflicts.push(ConflictRegion { description: format!("{path}[{i}]: deleted by ours, modified by theirs"), base: 0..0, ours: 0..0, theirs: 0..0, }); } } (None, Some(o), Some(t)) => { if o == t { merged.push(o.clone()); } else { merged.push(o.clone()); conflicts.push(ConflictRegion { description: format!( "{path}[{i}]: independently added with different values" ), base: 0..0, ours: 0..0, theirs: 0..0, }); } } (Some(b), Some(o), Some(t)) => { let (m, c) = merge_xml_node(b, o, t, &format!("{path}[{i}]")); merged.push(m); conflicts.extend(c); } } } (merged, conflicts) } fn merge_xml_node( base: &XmlNode, ours: &XmlNode, theirs: &XmlNode, path: &str, ) -> (XmlNode, Vec) { if ours == theirs { return (ours.clone(), Vec::new()); } if base == ours { return (theirs.clone(), Vec::new()); } if base == theirs { return (ours.clone(), Vec::new()); } match (base, ours, theirs) { ( XmlNode::Element { name: bn, attrs: ba, children: bc, }, XmlNode::Element { name: on, attrs: oa, children: oc, }, XmlNode::Element { name: tn, attrs: ta, children: tc, }, ) if bn == on && on == tn => { // Merge attributes structurally via JSON. let to_obj = |v: &[(String, String)]| -> Value { let mut m = serde_json::Map::new(); for (k, val) in v { m.insert(k.clone(), Value::String(val.clone())); } Value::Object(m) }; let (am, ac) = merge_value(&to_obj(ba), &to_obj(oa), &to_obj(ta), &format!("{path}.@")); let attrs: Vec<(String, String)> = match am { Value::Object(m) => { let mut v: Vec<_> = m .into_iter() .map(|(k, val)| (k, val.as_str().unwrap_or("").to_string())) .collect(); v.sort(); v } _ => oa.clone(), }; let (cm, cc) = merge_xml_children(bc, oc, tc, &format!("{path}/{on}")); let mut conflicts = ac; conflicts.extend(cc); ( XmlNode::Element { name: on.clone(), attrs, children: cm, }, conflicts, ) } _ => { // Tag-name change or text/element type mismatch — flat conflict. ( ours.clone(), vec![ConflictRegion { description: format!("{path}: structural mismatch between ours and theirs"), base: 0..0, ours: 0..0, theirs: 0..0, }], ) } } } impl MergeHandler for XmlHandler { fn name(&self) -> &str { "xml" } fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool { matches!( path.extension().and_then(|e| e.to_str()), Some("xml") | Some("svg") | Some("html") ) } fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult { let (b, o, t) = match (xml_parse(base), xml_parse(ours), xml_parse(theirs)) { (Some(b), Some(o), Some(t)) => (b, o, t), _ => { return MergeResult { handler: self.name().into(), status: MergeStatus::NotApplicable, }; } }; let (merged, conflicts) = merge_xml_children(&b, &o, &t, ""); let s = xml_serialize(&merged); let bytes = s.into_bytes(); let status = if conflicts.is_empty() { MergeStatus::Merged { content: bytes, notes: vec![MergeNote { message: "structural XML merge".into(), }], } } else { MergeStatus::Conflict { regions: conflicts, partial: bytes, } }; MergeResult { handler: self.name().into(), status, } } } #[cfg(test)] mod tests { use super::*; #[test] fn yaml_disjoint_keys_merge() { let h = YamlHandler; let base = b"a: 1\n"; let ours = b"a: 1\nb: 2\n"; let theirs = b"a: 1\nc: 3\n"; let r = h.merge(Path::new("x.yaml"), base, ours, theirs); match r.status { MergeStatus::Merged { content, .. } => { let s = String::from_utf8_lossy(&content); assert!(s.contains("a:")); assert!(s.contains("b:")); assert!(s.contains("c:")); } other => panic!("expected merged, got {other:?}"), } } #[test] fn yaml_scalar_conflict() { let h = YamlHandler; let base = b"x: 1\n"; let ours = b"x: 2\n"; let theirs = b"x: 3\n"; let r = h.merge(Path::new("x.yml"), base, ours, theirs); assert!(matches!(r.status, MergeStatus::Conflict { .. })); } #[test] fn markdown_disjoint_section_additions_merge() { let h = MarkdownHandler; let base = b"# Intro\n\nHello.\n"; let ours = b"# Intro\n\nHello.\n\n# A\n\nours.\n"; let theirs = b"# Intro\n\nHello.\n\n# B\n\ntheirs.\n"; let r = h.merge(Path::new("x.md"), base, ours, theirs); match r.status { MergeStatus::Merged { content, .. } => { let s = String::from_utf8_lossy(&content); assert!(s.contains("# A")); assert!(s.contains("# B")); } other => panic!("expected merged, got {other:?}"), } } #[test] fn markdown_same_section_diverges_to_conflict() { let h = MarkdownHandler; let base = b"# A\n\nbase.\n"; let ours = b"# A\n\nours.\n"; let theirs = b"# A\n\ntheirs.\n"; let r = h.merge(Path::new("x.md"), base, ours, theirs); assert!(matches!(r.status, MergeStatus::Conflict { .. })); } #[test] fn prose_independently_added_paragraphs_merge() { let h = ProseHandler; let base = b"para one.\n"; let ours = b"para one.\n\nours added.\n"; let theirs = b"para one.\n\ntheirs added.\n"; let r = h.merge(Path::new("x.txt"), base, ours, theirs); match r.status { MergeStatus::Merged { content, .. } => { let s = String::from_utf8_lossy(&content); assert!(s.contains("para one.")); assert!(s.contains("ours added.")); assert!(s.contains("theirs added.")); } other => panic!("expected merged, got {other:?}"), } } #[test] fn xml_disjoint_attrs_merge() { let h = XmlHandler; let base = br#""#; let ours = br#""#; let theirs = br#""#; let r = h.merge(Path::new("x.xml"), base, ours, theirs); match r.status { MergeStatus::Merged { content, .. } => { let s = String::from_utf8_lossy(&content); assert!(s.contains("y=\"2\"")); assert!(s.contains("z=\"3\"")); } other => panic!("expected merged, got {other:?}"), } } #[test] fn xml_scalar_attr_conflict() { let h = XmlHandler; let base = br#""#; let ours = br#""#; let theirs = br#""#; let r = h.merge(Path::new("x.xml"), base, ours, theirs); assert!(matches!(r.status, MergeStatus::Conflict { .. })); } }