Skip to main content

rich_ext/diff/
source.rs

1//! [`SourceDiff`]: a syntax-highlighted source diff.
2
3use std::sync::Arc;
4
5use rich::measure::Measurement;
6use rich::{Console, ConsoleOptions, Renderable, Segment, Text};
7#[cfg(feature = "syntax")]
8use rich::{Style, StyleType, Syntax};
9
10use super::engine::split_lines_inclusive;
11#[cfg(feature = "syntax")]
12use super::view::text_lines;
13use super::view::{DiffView, LineLinks, Side};
14use super::Layout;
15use crate::hyperlink::Hyperlinker;
16
17/// The language name to hand core [`Syntax`] for `path`: its extension, or
18/// the file name when it has none (`Makefile`).
19pub(crate) fn language_for_path(path: &str) -> String {
20    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
21    match name.rsplit_once('.') {
22        Some((stem, ext)) if !stem.is_empty() => ext.to_string(),
23        _ => name.to_string(),
24    }
25}
26
27/// A style without its background, so diff line styles show through.
28#[cfg(feature = "syntax")]
29fn foreground_only(style: &Style) -> Style {
30    let definition = style.definition();
31    let kept = match definition.split_once(" on ") {
32        Some((before, _)) => before.to_string(),
33        None if definition.starts_with("on ") => String::new(),
34        None => definition,
35    };
36    Style::parse(&kept).unwrap_or_default()
37}
38
39/// `code` split into unstyled lines, one per `split_lines_inclusive` line.
40fn plain_lines(code: &str) -> Vec<Text> {
41    split_lines_inclusive(code)
42        .into_iter()
43        .map(|l| Text::new(super::engine::strip_eol(l)))
44        .collect()
45}
46
47/// Highlight `code` as `language` and split it into lines, one per
48/// `split_lines_inclusive` line. The whole side is highlighted at once so
49/// multi-line constructs (block comments, strings) colour correctly. With a
50/// `console`, its default code highlighter applies.
51#[cfg(feature = "syntax")]
52pub(crate) fn highlight_lines(
53    code: &str,
54    language: Option<&str>,
55    console: Option<&Console>,
56) -> Vec<Text> {
57    let count = split_lines_inclusive(code).len();
58    let Some(language) = language else {
59        return plain_lines(code);
60    };
61    let syntax = Syntax::new(code, language);
62    let highlighted = match console {
63        Some(console) => syntax.highlight_for(console),
64        None => syntax.highlight(),
65    };
66    let mut plain = Text::new(highlighted.plain());
67    for span in highlighted.spans() {
68        if let StyleType::Style(style) = &span.style {
69            let fg = foreground_only(style);
70            if !fg.is_null() {
71                plain.stylize(fg, span.start, span.end);
72            }
73        }
74    }
75    let mut lines = text_lines(&plain);
76    lines.resize_with(count, || Text::new(""));
77    lines
78}
79
80/// Without the `syntax` feature there is no highlighter: every side is plain.
81#[cfg(not(feature = "syntax"))]
82pub(crate) fn highlight_lines(
83    code: &str,
84    _language: Option<&str>,
85    _console: Option<&Console>,
86) -> Vec<Text> {
87    plain_lines(code)
88}
89
90/// A syntax-highlighted diff of two versions of a source file.
91///
92/// Each side is highlighted whole, then split into lines; word-level
93/// emphasis goes on top. The language comes from [`language`](Self::language)
94/// or the extension of [`path`](Self::path).
95///
96/// ```
97/// use rich::Console;
98/// use rich_ext::diff::SourceDiff;
99///
100/// let diff = SourceDiff::new("fn a() {}\n", "fn b() {}\n").path("src/lib.rs");
101/// let console = Console::builder().width(40).no_color(true).build();
102/// assert!(console.render_to_string(&diff).contains("+ fn b() {}"));
103/// ```
104#[derive(Clone, Debug)]
105pub struct SourceDiff {
106    old: String,
107    new: String,
108    language: Option<String>,
109    old_path: Option<String>,
110    new_path: Option<String>,
111    layout: Layout,
112    line_numbers: bool,
113    wrap: bool,
114    context: usize,
115    titles: bool,
116    linker: Option<Hyperlinker>,
117}
118
119impl SourceDiff {
120    /// Diff two versions of a source text.
121    pub fn new(old: impl Into<String>, new: impl Into<String>) -> Self {
122        SourceDiff {
123            old: old.into(),
124            new: new.into(),
125            language: None,
126            old_path: None,
127            new_path: None,
128            layout: Layout::Unified,
129            line_numbers: true,
130            wrap: true,
131            context: 3,
132            titles: true,
133            linker: None,
134        }
135    }
136    /// The language (a name or extension core `Syntax` knows).
137    pub fn language(mut self, language: impl Into<String>) -> Self {
138        self.language = Some(language.into());
139        self
140    }
141    /// The file's path, for the title, links and (without
142    /// [`language`](Self::language)) the language.
143    pub fn path(mut self, path: impl Into<String>) -> Self {
144        let path = path.into();
145        self.old_path = Some(path.clone());
146        self.new_path = Some(path);
147        self
148    }
149    /// Different old and new paths (a rename).
150    pub fn paths(mut self, old: impl Into<String>, new: impl Into<String>) -> Self {
151        self.old_path = Some(old.into());
152        self.new_path = Some(new.into());
153        self
154    }
155    /// Unified (default) or side by side.
156    pub fn layout(mut self, layout: Layout) -> Self {
157        self.layout = layout;
158        self
159    }
160    /// Show line numbers (default on).
161    pub fn line_numbers(mut self, show: bool) -> Self {
162        self.line_numbers = show;
163        self
164    }
165    /// Wrap long lines (default) or truncate them.
166    pub fn wrap(mut self, wrap: bool) -> Self {
167        self.wrap = wrap;
168        self
169    }
170    /// Unchanged lines around each change (default 3).
171    pub fn context(mut self, lines: usize) -> Self {
172        self.context = lines;
173        self
174    }
175    /// Show the paths as titles (default on, when a path is set).
176    pub fn titles(mut self, show: bool) -> Self {
177        self.titles = show;
178        self
179    }
180    /// Link line numbers through an editor or web URL template with `{path}`
181    /// and `{line}` (and `{column}`, always 1), such as
182    /// `vscode://file/{path}:{line}`.
183    pub fn link_template(mut self, template: impl Into<String>) -> Self {
184        self.linker = Some(Hyperlinker::new().editor(template));
185        self
186    }
187    /// Link line numbers through a [`Hyperlinker`] (`file://` URLs by
188    /// default, relative paths resolved against its base directory).
189    pub fn hyperlinker(mut self, linker: Hyperlinker) -> Self {
190        self.linker = Some(linker);
191        self
192    }
193
194    fn resolved_language(&self) -> Option<String> {
195        self.language.clone().or_else(|| {
196            self.new_path
197                .as_deref()
198                .or(self.old_path.as_deref())
199                .map(language_for_path)
200        })
201    }
202
203    /// The view this diff renders as.
204    pub fn view(&self) -> DiffView {
205        self.view_with(None)
206    }
207
208    /// [`view`](Self::view), highlighting with `console`'s default code
209    /// highlighter. Rendering uses this.
210    pub fn view_for(&self, console: &Console) -> DiffView {
211        self.view_with(Some(console))
212    }
213
214    fn view_with(&self, console: Option<&Console>) -> DiffView {
215        let language = self.resolved_language();
216        let old = highlight_lines(&self.old, language.as_deref(), console);
217        let new = highlight_lines(&self.new, language.as_deref(), console);
218        let links: Option<LineLinks> = match (&self.linker, &self.old_path, &self.new_path) {
219            (Some(linker), Some(old_path), Some(new_path)) => {
220                let (linker, old_path, new_path) =
221                    (linker.clone(), old_path.clone(), new_path.clone());
222                Some(Arc::new(move |side: Side, line: usize| {
223                    let path = if side == Side::Old {
224                        &old_path
225                    } else {
226                        &new_path
227                    };
228                    linker.file_url(path, Some(line), None)
229                }))
230            }
231            _ => None,
232        };
233        let mut view = DiffView::from_parts(
234            old,
235            new,
236            &split_lines_inclusive(&self.old),
237            &split_lines_inclusive(&self.new),
238            false,
239        )
240        .layout(self.layout)
241        .line_numbers(self.line_numbers)
242        .wrap(self.wrap)
243        .context(self.context)
244        .links(links);
245        if self.titles {
246            if let (Some(old), Some(new)) = (&self.old_path, &self.new_path) {
247                view = view.titles(old.clone(), new.clone());
248            }
249        }
250        view
251    }
252}
253
254impl Renderable for SourceDiff {
255    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
256        self.view_for(console).rich_render(console, options)
257    }
258    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
259        self.view_for(console).measure(console, options)
260    }
261}