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::Layout;
11use super::engine::split_lines_inclusive;
12#[cfg(feature = "syntax")]
13use super::view::text_lines;
14use super::view::{DiffView, LineLinks, Side};
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)]
105#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
106pub struct SourceDiff {
107    old: String,
108    new: String,
109    language: Option<String>,
110    old_path: Option<String>,
111    new_path: Option<String>,
112    layout: Layout,
113    line_numbers: bool,
114    wrap: bool,
115    context: usize,
116    titles: bool,
117    linker: Option<Hyperlinker>,
118}
119
120impl SourceDiff {
121    /// Diff two versions of a source text.
122    pub fn new(old: impl Into<String>, new: impl Into<String>) -> Self {
123        SourceDiff {
124            old: old.into(),
125            new: new.into(),
126            language: None,
127            old_path: None,
128            new_path: None,
129            layout: Layout::Unified,
130            line_numbers: true,
131            wrap: true,
132            context: 3,
133            titles: true,
134            linker: None,
135        }
136    }
137    /// The language (a name or extension core `Syntax` knows).
138    pub fn language(mut self, language: impl Into<String>) -> Self {
139        self.language = Some(language.into());
140        self
141    }
142    /// The file's path, for the title, links and (without
143    /// [`language`](Self::language)) the language.
144    pub fn path(mut self, path: impl Into<String>) -> Self {
145        let path = path.into();
146        self.old_path = Some(path.clone());
147        self.new_path = Some(path);
148        self
149    }
150    /// Different old and new paths (a rename).
151    pub fn paths(mut self, old: impl Into<String>, new: impl Into<String>) -> Self {
152        self.old_path = Some(old.into());
153        self.new_path = Some(new.into());
154        self
155    }
156    /// Unified (default) or side by side.
157    pub fn layout(mut self, layout: Layout) -> Self {
158        self.layout = layout;
159        self
160    }
161    /// Show line numbers (default on).
162    pub fn line_numbers(mut self, show: bool) -> Self {
163        self.line_numbers = show;
164        self
165    }
166    /// Wrap long lines (default) or truncate them.
167    pub fn wrap(mut self, wrap: bool) -> Self {
168        self.wrap = wrap;
169        self
170    }
171    /// Unchanged lines around each change (default 3).
172    pub fn context(mut self, lines: usize) -> Self {
173        self.context = lines;
174        self
175    }
176    /// Show the paths as titles (default on, when a path is set).
177    pub fn titles(mut self, show: bool) -> Self {
178        self.titles = show;
179        self
180    }
181    /// Link line numbers through an editor or web URL template with `{path}`
182    /// and `{line}` (and `{column}`, always 1), such as
183    /// `vscode://file/{path}:{line}`.
184    pub fn link_template(mut self, template: impl Into<String>) -> Self {
185        self.linker = Some(Hyperlinker::new().editor(template));
186        self
187    }
188    /// Link line numbers through a [`Hyperlinker`] (`file://` URLs by
189    /// default, relative paths resolved against its base directory).
190    pub fn hyperlinker(mut self, linker: Hyperlinker) -> Self {
191        self.linker = Some(linker);
192        self
193    }
194
195    fn resolved_language(&self) -> Option<String> {
196        self.language.clone().or_else(|| {
197            self.new_path
198                .as_deref()
199                .or(self.old_path.as_deref())
200                .map(language_for_path)
201        })
202    }
203
204    /// The view this diff renders as.
205    pub fn view(&self) -> DiffView {
206        self.view_with(None)
207    }
208
209    /// [`view`](Self::view), highlighting with `console`'s default code
210    /// highlighter. Rendering uses this.
211    pub fn view_for(&self, console: &Console) -> DiffView {
212        self.view_with(Some(console))
213    }
214
215    fn view_with(&self, console: Option<&Console>) -> DiffView {
216        let language = self.resolved_language();
217        let old = highlight_lines(&self.old, language.as_deref(), console);
218        let new = highlight_lines(&self.new, language.as_deref(), console);
219        let links: Option<LineLinks> = match (&self.linker, &self.old_path, &self.new_path) {
220            (Some(linker), Some(old_path), Some(new_path)) => {
221                let (linker, old_path, new_path) =
222                    (linker.clone(), old_path.clone(), new_path.clone());
223                Some(Arc::new(move |side: Side, line: usize| {
224                    let path = if side == Side::Old {
225                        &old_path
226                    } else {
227                        &new_path
228                    };
229                    linker.file_url(path, Some(line), None)
230                }))
231            }
232            _ => None,
233        };
234        let mut view = DiffView::from_parts(
235            old,
236            new,
237            &split_lines_inclusive(&self.old),
238            &split_lines_inclusive(&self.new),
239            false,
240        )
241        .layout(self.layout)
242        .line_numbers(self.line_numbers)
243        .wrap(self.wrap)
244        .context(self.context)
245        .links(links);
246        if self.titles
247            && let (Some(old), Some(new)) = (&self.old_path, &self.new_path)
248        {
249            view = view.titles(old.clone(), new.clone());
250        }
251        view
252    }
253}
254
255impl Renderable for SourceDiff {
256    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
257        self.view_for(console).rich_render(console, options)
258    }
259    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
260        self.view_for(console).measure(console, options)
261    }
262}