Skip to main content

nightshade_api/web/
diff.rs

1//! Line-level LCS diff and a side-by-side rendering component.
2
3use leptos::prelude::*;
4
5/// Whether a diffed line is unchanged, added, or removed.
6#[derive(Clone, Copy, PartialEq, Eq)]
7pub enum LineKind {
8    /// Present unchanged in both sides.
9    Equal,
10    /// Added in the new text.
11    Insert,
12    /// Removed from the old text.
13    Delete,
14}
15
16/// A single line of a computed diff, with its content and its line numbers on
17/// each side (`None` where the line is absent).
18#[derive(Clone)]
19pub struct DiffLine {
20    /// Whether the line is equal, inserted, or deleted.
21    pub kind: LineKind,
22    /// The line's text.
23    pub text: String,
24    /// Line number in the old text, if present there.
25    pub old_line: Option<usize>,
26    /// Line number in the new text, if present there.
27    pub new_line: Option<usize>,
28}
29
30/// Computes a line-level diff of `old` against `new` using a longest-common-
31/// subsequence, returning the merged sequence of equal, inserted, and deleted
32/// lines in order.
33pub fn diff_lines(old: &str, new: &str) -> Vec<DiffLine> {
34    let left: Vec<&str> = old.lines().collect();
35    let right: Vec<&str> = new.lines().collect();
36    let rows = left.len();
37    let cols = right.len();
38
39    let mut lcs = vec![vec![0usize; cols + 1]; rows + 1];
40    for index in (0..rows).rev() {
41        for other in (0..cols).rev() {
42            lcs[index][other] = if left[index] == right[other] {
43                lcs[index + 1][other + 1] + 1
44            } else {
45                lcs[index + 1][other].max(lcs[index][other + 1])
46            };
47        }
48    }
49
50    let mut out = Vec::new();
51    let mut index = 0;
52    let mut other = 0;
53    let mut old_line = 1;
54    let mut new_line = 1;
55    while index < rows && other < cols {
56        if left[index] == right[other] {
57            out.push(DiffLine {
58                kind: LineKind::Equal,
59                text: left[index].to_string(),
60                old_line: Some(old_line),
61                new_line: Some(new_line),
62            });
63            index += 1;
64            other += 1;
65            old_line += 1;
66            new_line += 1;
67        } else if lcs[index + 1][other] >= lcs[index][other + 1] {
68            out.push(DiffLine {
69                kind: LineKind::Delete,
70                text: left[index].to_string(),
71                old_line: Some(old_line),
72                new_line: None,
73            });
74            index += 1;
75            old_line += 1;
76        } else {
77            out.push(DiffLine {
78                kind: LineKind::Insert,
79                text: right[other].to_string(),
80                old_line: None,
81                new_line: Some(new_line),
82            });
83            other += 1;
84            new_line += 1;
85        }
86    }
87    while index < rows {
88        out.push(DiffLine {
89            kind: LineKind::Delete,
90            text: left[index].to_string(),
91            old_line: Some(old_line),
92            new_line: None,
93        });
94        index += 1;
95        old_line += 1;
96    }
97    while other < cols {
98        out.push(DiffLine {
99            kind: LineKind::Insert,
100            text: right[other].to_string(),
101            old_line: None,
102            new_line: Some(new_line),
103        });
104        other += 1;
105        new_line += 1;
106    }
107    out
108}
109
110fn marker(kind: LineKind) -> &'static str {
111    match kind {
112        LineKind::Equal => " ",
113        LineKind::Insert => "+",
114        LineKind::Delete => "-",
115    }
116}
117
118fn kind_class(kind: LineKind) -> &'static str {
119    match kind {
120        LineKind::Equal => "equal",
121        LineKind::Insert => "insert",
122        LineKind::Delete => "delete",
123    }
124}
125
126/// Renders a unified diff of the `old` and `new` signals, one row per line with
127/// old/new line numbers, a `+`/`-`/space marker, and per-kind styling.
128#[component]
129pub fn Diff(#[prop(into)] old: Signal<String>, #[prop(into)] new: Signal<String>) -> impl IntoView {
130    view! {
131        <div class="nightshade-diff">
132            {move || {
133                diff_lines(&old.get(), &new.get())
134                    .into_iter()
135                    .map(|line| {
136                        let old_number = line
137                            .old_line
138                            .map(|value| value.to_string())
139                            .unwrap_or_default();
140                        let new_number = line
141                            .new_line
142                            .map(|value| value.to_string())
143                            .unwrap_or_default();
144                        view! {
145                            <div class=format!("nightshade-diff-line {}", kind_class(line.kind))>
146                                <span class="nightshade-diff-num">{old_number}</span>
147                                <span class="nightshade-diff-num">{new_number}</span>
148                                <span class="nightshade-diff-marker">{marker(line.kind)}</span>
149                                <span class="nightshade-diff-text">{line.text}</span>
150                            </div>
151                        }
152                    })
153                    .collect_view()
154            }}
155        </div>
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{LineKind, diff_lines};
162
163    #[test]
164    fn identical_text_is_all_equal() {
165        let lines = diff_lines("a\nb\nc", "a\nb\nc");
166        assert!(lines.iter().all(|line| line.kind == LineKind::Equal));
167        assert_eq!(lines.len(), 3);
168    }
169
170    #[test]
171    fn detects_insert_and_delete() {
172        let lines = diff_lines("a\nb\nc", "a\nc\nd");
173        let inserts = lines.iter().filter(|l| l.kind == LineKind::Insert).count();
174        let deletes = lines.iter().filter(|l| l.kind == LineKind::Delete).count();
175        assert_eq!(deletes, 1);
176        assert_eq!(inserts, 1);
177        assert!(
178            lines
179                .iter()
180                .any(|l| l.kind == LineKind::Equal && l.text == "a")
181        );
182    }
183}