1use std::collections::HashSet;
19
20use serde_json::{json, Value};
21
22use crate::html::Html;
23
24pub type Path = Vec<u32>;
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum Op {
29 Replace {
31 path: Path,
32 html: Html,
33 },
34 SetText {
35 path: Path,
36 text: String,
37 },
38 SetAttr {
39 path: Path,
40 name: String,
41 value: String,
42 },
43 RemoveAttr {
44 path: Path,
45 name: String,
46 },
47 Insert {
49 path: Path,
50 index: u32,
51 html: Html,
52 },
53 Remove {
54 path: Path,
55 index: u32,
56 },
57 Move {
59 path: Path,
60 from: u32,
61 to: u32,
62 },
63}
64
65impl Op {
66 pub fn to_wire(&self) -> Value {
68 match self {
69 Op::Replace { path, html } => json!([0, path, html.to_wire()]),
70 Op::SetText { path, text } => json!([1, path, text]),
71 Op::SetAttr { path, name, value } => json!([2, path, name, value]),
72 Op::RemoveAttr { path, name } => json!([3, path, name]),
73 Op::Insert { path, index, html } => json!([4, path, index, html.to_wire()]),
74 Op::Remove { path, index } => json!([5, path, index]),
75 Op::Move { path, from, to } => json!([6, path, from, to]),
76 }
77 }
78}
79
80pub fn diff(old: &Html, new: &Html) -> Vec<Op> {
82 let mut ops = Vec::new();
83 let mut path = Vec::new();
84 diff_node(old, new, &mut path, &mut ops);
85 ops
86}
87
88fn diff_node(old: &Html, new: &Html, path: &mut Path, ops: &mut Vec<Op>) {
89 if old.hash() == new.hash() {
90 return; }
92 match (old, new) {
93 (Html::Text { .. }, Html::Text { text, .. }) => ops.push(Op::SetText {
94 path: path.clone(),
95 text: text.clone(),
96 }),
97 (
98 Html::Element {
99 tag: old_tag,
100 key: old_key,
101 attrs: old_attrs,
102 children: old_children,
103 ..
104 },
105 Html::Element {
106 tag: new_tag,
107 key: new_key,
108 attrs: new_attrs,
109 children: new_children,
110 ..
111 },
112 ) if old_tag == new_tag && old_key == new_key => {
113 diff_attrs(old_attrs, new_attrs, path, ops);
114 diff_children(old_children, new_children, path, ops);
115 }
116 _ => ops.push(Op::Replace {
117 path: path.clone(),
118 html: new.clone(),
119 }),
120 }
121}
122
123fn diff_attrs(old: &[(String, String)], new: &[(String, String)], path: &Path, ops: &mut Vec<Op>) {
124 for (name, value) in new {
125 match old.iter().find(|(k, _)| k == name) {
126 Some((_, old_value)) if old_value == value => {}
127 _ => ops.push(Op::SetAttr {
128 path: path.clone(),
129 name: name.clone(),
130 value: value.clone(),
131 }),
132 }
133 }
134 for (name, _) in old {
135 if !new.iter().any(|(k, _)| k == name) {
136 ops.push(Op::RemoveAttr {
137 path: path.clone(),
138 name: name.clone(),
139 });
140 }
141 }
142}
143
144fn diff_children(old: &[Html], new: &[Html], path: &mut Path, ops: &mut Vec<Op>) {
145 if keyed(old) && keyed(new) {
146 diff_keyed(old, new, path, ops);
147 } else {
148 diff_positional(old, new, path, ops);
149 }
150}
151
152fn keyed(children: &[Html]) -> bool {
155 if children.is_empty() {
156 return false;
157 }
158 let mut seen = HashSet::with_capacity(children.len());
159 children.iter().all(|c| match c.key_of() {
160 Some(k) => seen.insert(k),
161 None => false,
162 })
163}
164
165fn diff_positional(old: &[Html], new: &[Html], path: &mut Path, ops: &mut Vec<Op>) {
166 let common = old.len().min(new.len());
167 for i in 0..common {
168 path.push(i as u32);
169 diff_node(&old[i], &new[i], path, ops);
170 path.pop();
171 }
172 for i in (common..old.len()).rev() {
173 ops.push(Op::Remove {
174 path: path.clone(),
175 index: i as u32,
176 });
177 }
178 for (i, node) in new.iter().enumerate().skip(common) {
179 ops.push(Op::Insert {
180 path: path.clone(),
181 index: i as u32,
182 html: node.clone(),
183 });
184 }
185}
186
187fn diff_keyed(old: &[Html], new: &[Html], path: &mut Path, ops: &mut Vec<Op>) {
188 let wanted: HashSet<&str> = new.iter().filter_map(Html::key_of).collect();
189
190 let mut cursor: Vec<&Html> = old.iter().collect();
193
194 let mut i = 0;
195 while i < cursor.len() {
196 if cursor[i].key_of().is_some_and(|k| wanted.contains(k)) {
197 i += 1;
198 } else {
199 ops.push(Op::Remove {
200 path: path.clone(),
201 index: i as u32,
202 });
203 cursor.remove(i);
204 }
205 }
206
207 for (j, want) in new.iter().enumerate() {
208 let key = want.key_of();
209 let found = cursor[j..].iter().position(|c| c.key_of() == key);
210 match found {
211 Some(0) => {}
212 Some(offset) => {
213 let from = (j + offset) as u32;
214 ops.push(Op::Move {
215 path: path.clone(),
216 from,
217 to: j as u32,
218 });
219 let node = cursor.remove(j + offset);
220 cursor.insert(j, node);
221 }
222 None => {
223 ops.push(Op::Insert {
224 path: path.clone(),
225 index: j as u32,
226 html: want.clone(),
227 });
228 cursor.insert(j, want);
229 continue; }
231 }
232 path.push(j as u32);
233 diff_node(cursor[j], want, path, ops);
234 path.pop();
235 }
236}
237
238pub fn apply(root: &Html, ops: &[Op]) -> Html {
244 let mut root = root.clone();
245 for op in ops {
246 match op {
247 Op::Replace { path, html } => set_node(&mut root, path, html.clone()),
248 Op::SetText { path, text } => set_node(&mut root, path, Html::text(text.clone())),
249 Op::SetAttr { path, name, value } => {
250 let target = node_mut(&mut root, path);
251 *target = with_attr(target.clone(), name, Some(value));
252 }
253 Op::RemoveAttr { path, name } => {
254 let target = node_mut(&mut root, path);
255 *target = with_attr(target.clone(), name, None);
256 }
257 Op::Insert { path, index, html } => {
258 let parent = node_mut(&mut root, path);
259 *parent = rebuild(parent.clone(), |cs| {
260 cs.insert(*index as usize, html.clone());
261 });
262 }
263 Op::Remove { path, index } => {
264 let parent = node_mut(&mut root, path);
265 *parent = rebuild(parent.clone(), |cs| {
266 cs.remove(*index as usize);
267 });
268 }
269 Op::Move { path, from, to } => {
270 let parent = node_mut(&mut root, path);
271 *parent = rebuild(parent.clone(), |cs| {
272 let node = cs.remove(*from as usize);
273 cs.insert(*to as usize, node);
274 });
275 }
276 }
277 }
278 root.rehash()
281}
282
283fn node_mut<'a>(root: &'a mut Html, path: &[u32]) -> &'a mut Html {
284 let mut node = root;
285 for step in path {
286 node = match node {
287 Html::Element { children, .. } => &mut children[*step as usize],
288 Html::Text { .. } => panic!("patch path descends into a text node"),
289 };
290 }
291 node
292}
293
294fn set_node(root: &mut Html, path: &[u32], value: Html) {
295 *node_mut(root, path) = value;
296}
297
298fn rebuild(node: Html, f: impl FnOnce(&mut Vec<Html>)) -> Html {
300 match node {
301 Html::Element {
302 tag,
303 attrs,
304 key,
305 mut children,
306 ..
307 } => {
308 f(&mut children);
309 let mut el = Html::el(tag);
310 for (k, v) in attrs {
311 el = el.attr(k, v);
312 }
313 if let Some(k) = key {
314 el = el.key(k);
315 }
316 el.children(children)
317 }
318 text => text,
319 }
320}
321
322fn with_attr(node: Html, name: &str, value: Option<&str>) -> Html {
323 match node {
324 Html::Element {
325 tag,
326 attrs,
327 key,
328 children,
329 ..
330 } => {
331 let mut el = Html::el(tag);
332 let mut replaced = false;
333 for (k, v) in attrs {
334 if k == name {
335 replaced = true;
336 match value {
337 Some(new) => el = el.attr(k, new),
338 None => continue,
339 }
340 } else {
341 el = el.attr(k, v);
342 }
343 }
344 if !replaced {
345 if let Some(new) = value {
346 el = el.attr(name, new);
347 }
348 }
349 if let Some(k) = key {
350 el = el.key(k);
351 }
352 el.children(children)
353 }
354 text => text,
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 fn li(key: &str, text: &str, done: bool) -> Html {
363 Html::el("li")
364 .key(key)
365 .attr_if(done, "class", "done")
366 .child(Html::text(text))
367 }
368
369 fn list(items: Vec<Html>) -> Html {
370 Html::el("main")
371 .child(Html::el("h1").child(Html::text("todos")))
372 .child(Html::el("ul").children(items))
373 }
374
375 #[test]
376 fn identical_trees_produce_no_ops() {
377 let a = list(vec![li("1", "a", false), li("2", "b", false)]);
378 let b = list(vec![li("1", "a", false), li("2", "b", false)]);
379 assert!(diff(&a, &b).is_empty());
380 }
381
382 #[test]
383 fn a_toggle_touches_one_attribute_and_nothing_else() {
384 let a = list(vec![li("1", "a", false), li("2", "b", false)]);
385 let b = list(vec![li("1", "a", true), li("2", "b", false)]);
386 let ops = diff(&a, &b);
387 assert_eq!(
388 ops,
389 vec![Op::SetAttr {
390 path: vec![1, 0],
391 name: "class".into(),
392 value: "done".into(),
393 }]
394 );
395 assert_eq!(apply(&a, &ops), b);
396 }
397
398 #[test]
399 fn reordering_moves_rather_than_rebuilds() {
400 let a = list(vec![
401 li("1", "a", false),
402 li("2", "b", false),
403 li("3", "c", false),
404 ]);
405 let b = list(vec![
406 li("3", "c", false),
407 li("1", "a", false),
408 li("2", "b", false),
409 ]);
410 let ops = diff(&a, &b);
411 assert_eq!(
412 ops,
413 vec![Op::Move {
414 path: vec![1],
415 from: 2,
416 to: 0
417 }]
418 );
419 assert_eq!(apply(&a, &ops), b);
420 }
421
422 #[test]
423 fn insert_remove_and_edit_compose() {
424 let a = list(vec![
425 li("1", "a", false),
426 li("2", "b", false),
427 li("3", "c", false),
428 ]);
429 let b = list(vec![
430 li("2", "b!", true),
431 li("4", "d", false),
432 li("1", "a", false),
433 ]);
434 let ops = diff(&a, &b);
435 assert_eq!(apply(&a, &ops), b, "ops: {ops:#?}");
436 }
437
438 #[test]
439 fn unkeyed_children_fall_back_to_positional() {
440 let a = Html::el("footer").child(Html::text("2 remaining"));
441 let b = Html::el("footer").child(Html::text("1 remaining"));
442 let ops = diff(&a, &b);
443 assert_eq!(
444 ops,
445 vec![Op::SetText {
446 path: vec![0],
447 text: "1 remaining".into()
448 }]
449 );
450 assert_eq!(apply(&a, &ops), b);
451 }
452
453 #[test]
454 fn tag_change_replaces() {
455 let a = Html::el("main").child(Html::el("p").child(Html::text("x")));
456 let b = Html::el("main").child(Html::el("div").child(Html::text("x")));
457 let ops = diff(&a, &b);
458 assert!(matches!(ops.as_slice(), [Op::Replace { .. }]));
459 assert_eq!(apply(&a, &ops), b);
460 }
461
462 #[test]
463 fn round_trips_over_a_long_random_walk() {
464 let mut seed = 0x243f_6a88_85a3_08d3u64;
466 let mut rand = move || {
467 seed ^= seed << 13;
468 seed ^= seed >> 7;
469 seed ^= seed << 17;
470 seed
471 };
472
473 let mut items: Vec<(u32, String, bool)> = Vec::new();
474 let mut next_key = 0u32;
475 let mut current = list(vec![]);
476
477 for _ in 0..400 {
478 match rand() % 4 {
479 0 => {
480 next_key += 1;
481 let at = if items.is_empty() {
482 0
483 } else {
484 (rand() as usize) % (items.len() + 1)
485 };
486 items.insert(at, (next_key, format!("todo {next_key}"), false));
487 }
488 1 if !items.is_empty() => {
489 let at = (rand() as usize) % items.len();
490 items.remove(at);
491 }
492 2 if !items.is_empty() => {
493 let at = (rand() as usize) % items.len();
494 items[at].2 = !items[at].2;
495 }
496 3 if items.len() > 1 => {
497 let from = (rand() as usize) % items.len();
498 let to = (rand() as usize) % items.len();
499 let item = items.remove(from);
500 items.insert(to, item);
501 }
502 _ => {}
503 }
504
505 let next = list(
506 items
507 .iter()
508 .map(|(k, t, d)| li(&k.to_string(), t, *d))
509 .collect(),
510 );
511 let ops = diff(¤t, &next);
512 assert_eq!(apply(¤t, &ops), next, "ops: {ops:#?}");
513 current = next;
514 }
515 }
516}