use std::collections::HashMap;
use std::ops::Range;
use std::sync::OnceLock;
use ratatui::style::Color;
use similar::utils::diff_words;
use similar::{Algorithm, ChangeTag, DiffTag, capture_diff_slices};
use syntect::easy::HighlightLines;
use syntect::highlighting::{
Color as SyntectColor, ScopeSelectors, StyleModifier, Theme as SyntectTheme, ThemeItem,
};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use crate::app::FileMerge;
use crate::merge::{ChunkKind, MergeChunk};
const EMPH_MAX_LINES: usize = 400;
const EMPH_MAX_LINE_BYTES: usize = 2000;
const EMPH_MAX_RATIO: usize = 70;
const SYNTAX_MAX_LINES: usize = 10_000;
fn syntax_set() -> &'static SyntaxSet {
static SET: OnceLock<SyntaxSet> = OnceLock::new();
SET.get_or_init(SyntaxSet::load_defaults_newlines)
}
fn syntect_theme(light: bool) -> &'static SyntectTheme {
static DARK: OnceLock<SyntectTheme> = OnceLock::new();
static LIGHT: OnceLock<SyntectTheme> = OnceLock::new();
if light {
LIGHT.get_or_init(|| maple(true))
} else {
DARK.get_or_init(|| maple(false))
}
}
type Rgb = (u8, u8, u8);
const TOKEN_COLORS: &[(&str, Rgb, Rgb)] = &[
(
"comment, punctuation.definition.comment",
(0x99, 0x99, 0x99),
(0x80, 0x80, 0x80),
),
(
"punctuation, meta.brace, keyword.operator",
(0xb8, 0xd7, 0xf9),
(0x71, 0xa3, 0xa8),
),
(
"keyword, storage, support.type.builtin",
(0xd2, 0xcc, 0xff),
(0x72, 0x62, 0x93),
),
(
"constant, support.constant, variable.language",
(0xf0, 0xc0, 0xa8),
(0xc3, 0x75, 0x22),
),
("constant.numeric", (0xd5, 0xf2, 0x88), (0x73, 0x99, 0x00)),
("constant.language", (0xd2, 0xcc, 0xff), (0x72, 0x62, 0x93)),
("string", (0xa4, 0xdf, 0xae), (0x47, 0x8f, 0x14)),
(
"constant.character.escape",
(0x8f, 0xc7, 0xff),
(0x05, 0x85, 0xa8),
),
(
"entity.name.function, support.function, variable.function, \
support.macro, entity.name.macro",
(0x8f, 0xc7, 0xff),
(0x05, 0x85, 0xa8),
),
(
"entity.name.type, entity.name.class, entity.name.struct, entity.name.enum, \
entity.name.trait, entity.other.inherited-class, support.class, support.type",
(0xf0, 0xc0, 0xa8),
(0xc3, 0x75, 0x22),
),
(
"variable, variable.parameter",
(0xee, 0xcf, 0xa0),
(0xaa, 0x83, 0x0e),
),
(
"variable.other.member, meta.property-name, support.type.property-name",
(0xde, 0xd6, 0xcf),
(0x8d, 0x89, 0x49),
),
("entity.name.tag", (0xed, 0xab, 0xab), (0xbd, 0x51, 0x51)),
(
"entity.other.attribute-name",
(0xee, 0xcf, 0xa0),
(0xaa, 0x83, 0x0e),
),
(
"entity.name.namespace, keyword.control.import, keyword.other.import, \
keyword.other.package",
(0xe3, 0xcb, 0xeb),
(0xa6, 0x59, 0x73),
),
(
"markup.heading, entity.name.section",
(0xd2, 0xcc, 0xff),
(0x72, 0x62, 0x93),
),
("markup.quote", (0xa1, 0xe8, 0xe5), (0x12, 0x7d, 0x52)),
(
"markup.raw, markup.inline.raw",
(0xed, 0xab, 0xab),
(0xbd, 0x51, 0x51),
),
("markup.inserted", (0xa4, 0xdf, 0xae), (0x47, 0x8f, 0x14)),
("markup.deleted", (0xed, 0xab, 0xab), (0xbd, 0x51, 0x51)),
(
"markup.bold, markup.italic",
(0xf0, 0xc0, 0xa8),
(0xc3, 0x75, 0x22),
),
(
"markup.underline.link, string.other.link",
(0x8f, 0xc7, 0xff),
(0x05, 0x85, 0xa8),
),
];
fn maple(light: bool) -> SyntectTheme {
let rgb = |(r, g, b): Rgb| SyntectColor { r, g, b, a: 0xff };
let mut theme = SyntectTheme {
name: Some(if light { "Maple Light" } else { "Maple Dark" }.to_owned()),
..SyntectTheme::default()
};
theme.settings.foreground = Some(rgb(if light {
(0x47, 0x55, 0x69)
} else {
(0xcb, 0xd5, 0xe1)
}));
for (scopes, dark, light_color) in TOKEN_COLORS {
let Ok(scope) = scopes.parse::<ScopeSelectors>() else {
continue;
};
theme.scopes.push(ThemeItem {
scope,
style: StyleModifier {
foreground: Some(rgb(if light { *light_color } else { *dark })),
background: None,
font_style: None,
},
});
}
theme
}
pub(crate) type Emphasis = Vec<Range<usize>>;
#[derive(Debug, Default)]
pub(crate) struct ChunkEmphasis {
pub(crate) ours: Vec<Emphasis>,
pub(crate) theirs: Vec<Emphasis>,
}
#[derive(Debug, Default)]
pub(crate) struct PaneSyntax {
pub(crate) lines: Vec<Vec<(Color, Range<usize>)>>,
}
#[derive(Debug)]
pub(crate) struct FileHighlight {
pub(crate) emphasis: Vec<ChunkEmphasis>,
pub(crate) ours: Option<PaneSyntax>,
pub(crate) theirs: Option<PaneSyntax>,
pub(crate) result: Option<PaneSyntax>,
syntax: Option<&'static SyntaxReference>,
light: bool,
result_rev: u64,
}
impl FileHighlight {
fn build(merge: &FileMerge, rev: u64, light: bool) -> Self {
let emphasis = merge.chunks.iter().map(chunk_emphasis).collect();
let syntax = find_syntax(merge);
let highlight = |lines: &mut dyn Iterator<Item = &String>| {
syntax.map(|s| highlight_pane(lines, s, light))
};
Self {
emphasis,
ours: highlight(&mut merge.chunks.iter().flat_map(|c| c.ours.iter())),
theirs: highlight(&mut merge.chunks.iter().flat_map(|c| c.theirs.iter())),
result: syntax.map(|s| highlight_result(merge, s, light)),
syntax,
light,
result_rev: rev,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct HighlightCache {
files: HashMap<usize, FileHighlight>,
}
impl HighlightCache {
pub(crate) fn get(
&mut self,
file_idx: usize,
merge: &FileMerge,
rev: u64,
light: bool,
) -> &FileHighlight {
let entry = self
.files
.entry(file_idx)
.or_insert_with(|| FileHighlight::build(merge, rev, light));
if entry.result_rev != rev {
entry.result = entry
.syntax
.map(|s| highlight_result(merge, s, entry.light));
entry.result_rev = rev;
}
entry
}
}
fn find_syntax(merge: &FileMerge) -> Option<&'static SyntaxReference> {
let total: usize = merge
.chunks
.iter()
.map(|c| c.ours.len() + c.theirs.len() + c.base.len())
.sum();
if total > SYNTAX_MAX_LINES {
return None;
}
let ext = std::path::Path::new(&merge.path).extension()?.to_str()?;
let syntax = syntax_set().find_syntax_by_extension(ext)?;
(syntax.name != "Plain Text").then_some(syntax)
}
fn highlight_pane(
lines: &mut dyn Iterator<Item = &String>,
syntax: &SyntaxReference,
light: bool,
) -> PaneSyntax {
let set = syntax_set();
let theme = syntect_theme(light);
let default_fg = theme.settings.foreground;
let mut hl = HighlightLines::new(syntax, theme);
let mut out = Vec::new();
for line in lines {
let with_nl = format!("{line}\n");
let mut spans = Vec::new();
if let Ok(regions) = hl.highlight_line(&with_nl, set) {
let mut pos = 0usize;
for (style, token) in regions {
let end = pos + token.len();
let clipped = end.min(line.len());
if clipped > pos && Some(style.foreground) != default_fg {
let fg = style.foreground;
spans.push((super::theme::term_color(fg.r, fg.g, fg.b), pos..clipped));
}
pos = end;
}
}
out.push(spans);
}
PaneSyntax { lines: out }
}
fn highlight_result(merge: &FileMerge, syntax: &SyntaxReference, light: bool) -> PaneSyntax {
let lines: Vec<String> = (0..merge.chunks.len())
.flat_map(|i| merge.current_content(i))
.collect();
highlight_pane(&mut lines.iter(), syntax, light)
}
fn chunk_emphasis(chunk: &MergeChunk) -> ChunkEmphasis {
let mut out = ChunkEmphasis {
ours: vec![Emphasis::new(); chunk.ours.len()],
theirs: vec![Emphasis::new(); chunk.theirs.len()],
};
let oversized = chunk.base.len() > EMPH_MAX_LINES
|| chunk.ours.len() > EMPH_MAX_LINES
|| chunk.theirs.len() > EMPH_MAX_LINES;
if chunk.kind == ChunkKind::Stable || oversized {
return out;
}
if chunk.base.is_empty() {
if chunk.kind == ChunkKind::Conflict {
cross_emphasis(chunk, &mut out);
}
return out;
}
if matches!(
chunk.kind,
ChunkKind::Ours | ChunkKind::Agree | ChunkKind::Conflict
) {
side_emphasis(&chunk.base, &chunk.ours, &mut out.ours);
}
if matches!(
chunk.kind,
ChunkKind::Theirs | ChunkKind::Agree | ChunkKind::Conflict
) {
side_emphasis(&chunk.base, &chunk.theirs, &mut out.theirs);
}
out
}
fn side_emphasis(base: &[String], side: &[String], out: &mut [Emphasis]) {
for op in capture_diff_slices(Algorithm::Myers, base, side) {
if op.tag() != DiffTag::Replace {
continue;
}
for (b, s) in op.old_range().zip(op.new_range()) {
if let Some(slot) = out.get_mut(s) {
*slot = line_emphasis(&base[b], &side[s]);
}
}
}
}
fn cross_emphasis(chunk: &MergeChunk, out: &mut ChunkEmphasis) {
for op in capture_diff_slices(Algorithm::Myers, &chunk.ours, &chunk.theirs) {
if op.tag() != DiffTag::Replace {
continue;
}
for (o, t) in op.old_range().zip(op.new_range()) {
let (ours_line, theirs_line) = (&chunk.ours[o], &chunk.theirs[t]);
if let Some(slot) = out.ours.get_mut(o) {
*slot = line_emphasis(theirs_line, ours_line);
}
if let Some(slot) = out.theirs.get_mut(t) {
*slot = line_emphasis(ours_line, theirs_line);
}
}
}
}
fn line_emphasis(old: &str, new: &str) -> Emphasis {
if old.len() > EMPH_MAX_LINE_BYTES || new.len() > EMPH_MAX_LINE_BYTES {
return Emphasis::new();
}
let mut ranges = Emphasis::new();
let mut pos = 0usize;
for (tag, token) in diff_words(Algorithm::Myers, old, new) {
match tag {
ChangeTag::Delete => {}
ChangeTag::Equal => pos += token.len(),
ChangeTag::Insert => {
let next = pos + token.len();
match ranges.last_mut() {
Some(last) if new[last.end..pos].chars().all(char::is_whitespace) => {
last.end = next;
}
_ => ranges.push(pos..next),
}
pos = next;
}
}
}
let covered: usize = ranges.iter().map(ExactSizeIterator::len).sum();
if covered * 100 > new.len() * EMPH_MAX_RATIO {
return Emphasis::new();
}
ranges
}
#[cfg(test)]
mod tests {
use super::*;
fn chunk(kind: ChunkKind, base: &[&str], ours: &[&str], theirs: &[&str]) -> MergeChunk {
let v = |s: &[&str]| s.iter().map(|t| (*t).to_owned()).collect();
MergeChunk {
id: 0,
kind,
base: v(base),
ours: v(ours),
theirs: v(theirs),
base_start: 1,
ours_start: 1,
theirs_start: 1,
}
}
#[test]
fn emphasis_against_base() {
let c = chunk(
ChunkKind::Conflict,
&["let a = 1;"],
&["let a = 2;"],
&["let b = 1;"],
);
let e = chunk_emphasis(&c);
assert_eq!(e.ours[0], vec![8..10]);
assert_eq!(e.theirs[0], vec![4..5]);
}
#[test]
fn empty_base_falls_back_to_cross_compare() {
let c = chunk(ChunkKind::Conflict, &[], &["hello world"], &["hello rust"]);
let e = chunk_emphasis(&c);
assert_eq!(e.ours[0], vec![6..11]); assert_eq!(e.theirs[0], vec![6..10]); }
#[test]
fn whitespace_gaps_are_bridged() {
let e = line_emphasis("prefix stays aa bb", "prefix stays xx yy");
assert_eq!(e, vec![13..18]);
}
#[test]
fn mostly_changed_line_degrades_to_band() {
let e = line_emphasis("old", "an entirely different line");
assert!(e.is_empty());
}
#[test]
fn oversized_chunk_degrades_to_empty() {
let lines: Vec<String> = (0..=EMPH_MAX_LINES).map(|i| format!("l{i}")).collect();
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let c = chunk(ChunkKind::Conflict, &["a"], &refs, &["b"]);
let e = chunk_emphasis(&c);
assert!(e.ours.iter().all(Vec::is_empty));
assert!(e.theirs.iter().all(Vec::is_empty));
}
#[test]
fn syntax_matches_extension_or_falls_back() {
let mut cache = HighlightCache::default();
let rs = FileMerge::from_three_way(
"demo.rs".to_owned(),
"fn main() {}\n",
"fn main() { let a = 1; }\n",
"fn main() {}\n",
);
let hl = cache.get(0, &rs, 0, false);
let ours = hl.ours.as_ref().unwrap();
assert!(ours.lines.iter().any(|l| !l.is_empty()));
let unknown = FileMerge::from_three_way("demo.unknownext".to_owned(), "a\n", "b\n", "a\n");
let hl = cache.get(1, &unknown, 0, false);
assert!(hl.ours.is_none());
assert!(hl.result.is_none());
}
#[test]
fn result_pane_rehighlights_on_revision_change() {
let mut merge =
FileMerge::from_three_way("demo.rs".to_owned(), "a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
let mut cache = HighlightCache::default();
let before = cache
.get(0, &merge, 0, false)
.result
.as_ref()
.unwrap()
.lines
.len();
merge.apply(crate::app::Side::Ours);
merge.apply(crate::app::Side::Theirs);
let after = cache
.get(0, &merge, 1, false)
.result
.as_ref()
.unwrap()
.lines
.len();
assert_eq!(before, 3);
assert_eq!(after, 4);
}
#[test]
fn oversized_file_disables_syntax() {
let text: String = (0..4000).map(|i| format!("l{i}\n")).collect();
let merge = FileMerge::from_three_way("big.rs".to_owned(), &text, &text, &text);
let mut cache = HighlightCache::default();
let hl = cache.get(0, &merge, 0, false);
assert!(hl.ours.is_none());
assert_eq!(hl.emphasis.len(), merge.chunks.len());
}
}