935 lines
32 KiB
Rust
935 lines
32 KiB
Rust
//! 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<Value> {
|
|
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<String>,
|
|
text: String,
|
|
}
|
|
|
|
fn split_markdown(src: &str) -> Vec<MdSection> {
|
|
let mut out: Vec<MdSection> = 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<Vec<MdSection>> {
|
|
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<MdSection> {
|
|
list.iter().find(|s| key(s) == *k).cloned()
|
|
};
|
|
|
|
let mut emitted: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
let mut output: Vec<String> = Vec::new();
|
|
let mut conflicts: Vec<ConflictRegion> = 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<String> {
|
|
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<Vec<String>> { 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<String> = std::collections::HashSet::new();
|
|
let mut out: Vec<String> = 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<XmlNode>,
|
|
},
|
|
Text(String),
|
|
}
|
|
|
|
fn xml_parse(src: &[u8]) -> Option<Vec<XmlNode>> {
|
|
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<XmlNode>)> = Vec::new();
|
|
let mut top: Vec<XmlNode> = 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("</");
|
|
out.push_str(name);
|
|
out.push('>');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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<XmlNode>, Vec<ConflictRegion>) {
|
|
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<XmlNode> = 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<ConflictRegion>) {
|
|
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#"<root><a x="1"/></root>"#;
|
|
let ours = br#"<root><a x="1" y="2"/></root>"#;
|
|
let theirs = br#"<root><a x="1" z="3"/></root>"#;
|
|
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#"<a x="1"/>"#;
|
|
let ours = br#"<a x="2"/>"#;
|
|
let theirs = br#"<a x="3"/>"#;
|
|
let r = h.merge(Path::new("x.xml"), base, ours, theirs);
|
|
assert!(matches!(r.status, MergeStatus::Conflict { .. }));
|
|
}
|
|
}
|