mod diff_parser;
pub mod file;
pub mod git;
mod hg;
mod jj;
pub(crate) mod traits;
pub use file::FileBackend;
pub use git::{GitBackend, GitBackendPreference};
pub use hg::HgBackend;
pub use jj::JjBackend;
pub use traits::{CommitInfo, VcsBackend, VcsChangeStatus, VcsInfo};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::error::{Result, TuicrError};
use crate::model::{DiffFile, LineSide};
use crate::syntax::{
HighlightedLines, HighlightedSpans, SyntaxHighlighter, needs_full_file_highlight,
};
pub(crate) const BATCH_BOUNDARY: &str = "@@TUICR_BATCH_BOUNDARY_e97f2d44_8b1a@@";
pub(crate) fn container_file_paths(files: &[DiffFile], side: LineSide) -> Vec<PathBuf> {
files
.iter()
.filter(|f| !f.is_binary && !f.is_too_large && !f.hunks.is_empty())
.filter_map(|f| {
let syntax_path = f.new_path.as_deref().or(f.old_path.as_deref())?;
if !needs_full_file_highlight(syntax_path) {
return None;
}
match side {
LineSide::Old => f.old_path.clone(),
LineSide::New => f.new_path.clone(),
}
})
.collect()
}
pub(crate) fn tabify(s: &str) -> String {
s.replace('\t', " ")
}
pub(crate) fn read_workdir_file(root: &Path, rel: &Path) -> Option<String> {
std::fs::read_to_string(root.join(rel)).ok()
}
pub(crate) fn parse_batched_files(output: &str) -> HashMap<PathBuf, String> {
let sep = format!("\n{BATCH_BOUNDARY}\n");
output
.split(&sep)
.filter(|s| !s.is_empty())
.filter_map(|block| {
let mut iter = block.splitn(2, '\n');
let path = iter.next()?;
let data = iter.next().unwrap_or("");
Some((PathBuf::from(path), data.to_string()))
})
.collect()
}
pub(crate) fn apply_container_full_file_highlight<F>(
root: &Path,
old_rev: &str,
new_rev: Option<&str>,
files: &mut [DiffFile],
highlighter: &SyntaxHighlighter,
fetch_batch: F,
) -> Result<()>
where
F: Fn(&Path, &str, &[PathBuf]) -> Result<HashMap<PathBuf, String>>,
{
let old_paths = container_file_paths(files, LineSide::Old);
let new_paths = container_file_paths(files, LineSide::New);
if old_paths.is_empty() && new_paths.is_empty() {
return Ok(());
}
let old_map = fetch_batch(root, old_rev, &old_paths)?;
let new_map = match new_rev {
Some(rev) => fetch_batch(root, rev, &new_paths)?,
None => HashMap::new(),
};
let workdir = new_rev.is_none().then(|| root.to_path_buf());
enhance_with_full_file_highlight(
files,
highlighter,
|p| old_map.get(p).cloned(),
|p| match (new_map.get(p), workdir.as_deref()) {
(Some(content), _) => Some(content.clone()),
(None, Some(root)) => read_workdir_file(root, p),
(None, None) => None,
},
);
Ok(())
}
const MAX_HIGHLIGHT_FILE_BYTES: usize = 1024 * 1024;
pub(crate) fn enhance_with_full_file_highlight<F, G>(
files: &mut [DiffFile],
highlighter: &SyntaxHighlighter,
mut fetch_old: F,
mut fetch_new: G,
) where
F: FnMut(&Path) -> Option<String>,
G: FnMut(&Path) -> Option<String>,
{
let mut jobs: Vec<HighlightJob> = Vec::new();
for (idx, file) in files.iter().enumerate() {
if file.is_binary || file.is_too_large || file.hunks.is_empty() {
continue;
}
let Some(syntax_path) = file.new_path.as_deref().or(file.old_path.as_deref()) else {
continue;
};
if !needs_full_file_highlight(syntax_path) {
continue;
}
let old_content = file.old_path.as_deref().and_then(&mut fetch_old);
let new_content = file.new_path.as_deref().and_then(&mut fetch_new);
if old_content.is_none() && new_content.is_none() {
continue;
}
jobs.push(HighlightJob {
file_idx: idx,
syntax_path: syntax_path.to_path_buf(),
old_content,
new_content,
});
}
if jobs.is_empty() {
return;
}
let results = highlight_jobs_parallel(&jobs, highlighter);
for (idx, old, new) in results {
if old.is_none() && new.is_none() {
continue;
}
apply_full_file_spans(&mut files[idx], highlighter, old.as_deref(), new.as_deref());
}
}
struct HighlightJob {
file_idx: usize,
syntax_path: PathBuf,
old_content: Option<String>,
new_content: Option<String>,
}
type HighlightResult = (usize, Option<HighlightedLines>, Option<HighlightedLines>);
fn highlight_jobs_parallel(
jobs: &[HighlightJob],
highlighter: &SyntaxHighlighter,
) -> Vec<HighlightResult> {
let parallelism = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(jobs.len());
if parallelism <= 1 {
return jobs
.iter()
.map(|j| highlight_single_job(j, highlighter))
.collect();
}
let chunk_size = jobs.len().div_ceil(parallelism);
std::thread::scope(|scope| {
let handles: Vec<_> = jobs
.chunks(chunk_size)
.map(|chunk| {
scope.spawn(move || {
chunk
.iter()
.map(|j| highlight_single_job(j, highlighter))
.collect::<Vec<_>>()
})
})
.collect();
handles
.into_iter()
.flat_map(|h| h.join().expect("highlight thread panicked"))
.collect()
})
}
fn highlight_single_job(job: &HighlightJob, highlighter: &SyntaxHighlighter) -> HighlightResult {
let old = job
.old_content
.as_deref()
.and_then(|c| highlight_content(highlighter, &job.syntax_path, c));
let new = job
.new_content
.as_deref()
.and_then(|c| highlight_content(highlighter, &job.syntax_path, c));
(job.file_idx, old, new)
}
fn highlight_content(
highlighter: &SyntaxHighlighter,
path: &Path,
content: &str,
) -> Option<HighlightedLines> {
if content.len() > MAX_HIGHLIGHT_FILE_BYTES || content.as_bytes().contains(&0u8) {
return None;
}
let lines: Vec<String> = content.lines().map(tabify).collect();
highlighter.highlight_file_lines(path, &lines)
}
fn apply_full_file_spans(
file: &mut DiffFile,
highlighter: &SyntaxHighlighter,
old_highlight: Option<&[Option<HighlightedSpans>]>,
new_highlight: Option<&[Option<HighlightedSpans>]>,
) {
for hunk in &mut file.hunks {
for line in &mut hunk.lines {
let old_idx = line.old_lineno.map(|n| n.saturating_sub(1) as usize);
let new_idx = line.new_lineno.map(|n| n.saturating_sub(1) as usize);
let spans = highlighter.highlighted_line_for_diff_with_background(
old_highlight,
new_highlight,
old_idx,
new_idx,
line.origin,
);
if spans.is_some() {
line.highlighted_spans = spans;
}
}
}
}
pub fn detect_vcs(git_backend_preference: GitBackendPreference) -> Result<Box<dyn VcsBackend>> {
if let Ok(backend) = JjBackend::discover() {
return Ok(Box::new(backend));
}
if let Ok(backend) = GitBackend::discover(git_backend_preference) {
return Ok(Box::new(backend));
}
if let Ok(backend) = HgBackend::discover() {
return Ok(Box::new(backend));
}
Err(TuicrError::NotARepository)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vcs::traits::VcsType;
use std::path::PathBuf;
#[test]
fn exports_are_accessible() {
let _: fn(GitBackendPreference) -> Result<Box<dyn VcsBackend>> = detect_vcs;
let info = VcsInfo {
root_path: PathBuf::from("/test"),
head_commit: "abc".to_string(),
branch_name: None,
vcs_type: VcsType::Git,
};
assert_eq!(info.head_commit, "abc");
let commit = CommitInfo {
id: "abc".to_string(),
short_id: "abc".to_string(),
branch_name: Some("main".to_string()),
summary: "test".to_string(),
body: None,
author: "author".to_string(),
time: chrono::Utc::now(),
};
assert_eq!(commit.id, "abc");
}
#[test]
fn detect_vcs_outside_repo_returns_error() {
let result = detect_vcs(GitBackendPreference::Libgit2);
match result {
Ok(backend) => {
let info = backend.info();
assert!(!info.head_commit.is_empty());
}
Err(TuicrError::NotARepository) => {
}
Err(e) => {
panic!("Unexpected error: {e:?}");
}
}
}
fn vue_diff_file(
idx: usize,
deleted_line: &str,
added_line: &str,
target_line: u32,
) -> DiffFile {
use crate::model::diff_types::{DiffHunk, DiffLine, FileStatus, LineOrigin};
let path = PathBuf::from(format!("Comp{idx}.vue"));
let hunk = DiffHunk {
header: format!("@@ -{target_line} +{target_line} @@"),
lines: vec![
DiffLine {
origin: LineOrigin::Deletion,
content: deleted_line.to_string(),
old_lineno: Some(target_line),
new_lineno: None,
highlighted_spans: None,
},
DiffLine {
origin: LineOrigin::Addition,
content: added_line.to_string(),
old_lineno: None,
new_lineno: Some(target_line),
highlighted_spans: None,
},
],
old_start: target_line,
old_count: 1,
new_start: target_line,
new_count: 1,
};
DiffFile {
old_path: Some(path.clone()),
new_path: Some(path),
status: FileStatus::Modified,
hunks: vec![hunk],
is_binary: false,
is_too_large: false,
is_commit_message: false,
content_hash: 0,
}
}
fn make_vue_file(idx: usize) -> (DiffFile, String, String) {
let old = "<template>\n <div>{{ msg }}</div>\n</template>\n\n<script setup>\n\
import { ref } from 'vue'\nconst msg = ref('hi')\nconst other = 1\n</script>\n";
let new = "<template>\n <div>{{ msg }}</div>\n</template>\n\n<script setup>\n\
import { ref } from 'vue'\nconst msg = ref('hello')\nconst other = 1\n</script>\n";
let file = vue_diff_file(idx, "const msg = ref('hi')", "const msg = ref('hello')", 7);
(file, old.to_string(), new.to_string())
}
fn highlight_n_vue_files(n: usize) -> Vec<DiffFile> {
use crate::syntax::SyntaxHighlighter;
let mut files = Vec::with_capacity(n);
let mut content_map: HashMap<PathBuf, (String, String)> = HashMap::new();
for i in 0..n {
let (file, old, new) = make_vue_file(i);
let path = file.new_path.clone().unwrap();
content_map.insert(path, (old, new));
files.push(file);
}
let highlighter = SyntaxHighlighter::default();
enhance_with_full_file_highlight(
&mut files,
&highlighter,
|p| content_map.get(p).map(|(o, _)| o.clone()),
|p| content_map.get(p).map(|(_, n)| n.clone()),
);
files
}
fn assert_all_lines_highlighted(files: &[DiffFile]) {
for (i, file) in files.iter().enumerate() {
for line in &file.hunks[0].lines {
let spans = line.highlighted_spans.as_ref().unwrap_or_else(|| {
panic!(
"file {i} line {:?} should have highlighted spans",
line.content
)
});
let unique_fgs: std::collections::HashSet<_> =
spans.iter().filter_map(|(s, _)| s.fg).collect();
assert!(
unique_fgs.len() > 1,
"file {i} line {:?} should have multiple distinct fg colors, got {unique_fgs:?}",
line.content
);
}
}
}
#[test]
fn enhance_full_file_highlight_serial_path_one_file() {
let files = highlight_n_vue_files(1);
assert_all_lines_highlighted(&files);
}
#[test]
fn enhance_full_file_highlight_parallel_path_many_files() {
let files = highlight_n_vue_files(12);
assert_eq!(files.len(), 12);
assert_all_lines_highlighted(&files);
}
fn synth_vue_file(idx: usize) -> (DiffFile, String, String) {
let mut html = String::from("<template>\n <div class=\"app\">\n");
for i in 0..80 {
html.push_str(&format!(" <span class=\"item-{i}\">item {i}</span>\n"));
}
html.push_str(" </div>\n</template>\n\n");
let mut script =
String::from("<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\n\n");
for i in 0..90 {
script.push_str(&format!("const value{i} = ref({i})\n"));
}
script.push_str("</script>\n\n");
let mut style = String::from("<style scoped>\n");
for i in 0..30 {
style.push_str(&format!(".item-{i} {{ color: rgb({i}, 0, 0); }}\n"));
}
style.push_str("</style>\n");
let old = format!("{html}{script}{style}");
let new = old.replace("const value0 = ref(0)", "const value0 = ref(42)");
let target_line = new
.lines()
.position(|l| l.starts_with("const value0 = ref(42)"))
.expect("synth content must contain target line") as u32
+ 1;
let file = vue_diff_file(
idx,
"const value0 = ref(0)",
"const value0 = ref(42)",
target_line,
);
(file, old, new)
}
#[test]
#[ignore]
fn bench_highlight_parallel_vs_serial() {
use crate::syntax::SyntaxHighlighter;
use std::time::Instant;
let highlighter = SyntaxHighlighter::default();
let scales = [1usize, 5, 12, 25, 50];
let runs = 5;
for &n in &scales {
let mut files_template = Vec::with_capacity(n);
let mut content_map: HashMap<PathBuf, (String, String)> = HashMap::new();
for i in 0..n {
let (file, old, new) = synth_vue_file(i);
content_map.insert(file.new_path.clone().unwrap(), (old, new));
files_template.push(file);
}
let jobs: Vec<HighlightJob> = files_template
.iter()
.enumerate()
.map(|(idx, f)| {
let path = f.new_path.clone().unwrap();
let (old, new) = content_map.get(&path).unwrap();
HighlightJob {
file_idx: idx,
syntax_path: path,
old_content: Some(old.clone()),
new_content: Some(new.clone()),
}
})
.collect();
let _ = highlight_jobs_parallel(&jobs, &highlighter);
let mut par_total = std::time::Duration::ZERO;
for _ in 0..runs {
let t = Instant::now();
let _ = highlight_jobs_parallel(&jobs, &highlighter);
par_total += t.elapsed();
}
let par_mean = par_total / runs as u32;
let mut ser_total = std::time::Duration::ZERO;
for _ in 0..runs {
let t = Instant::now();
let _: Vec<HighlightResult> = jobs
.iter()
.map(|j| highlight_single_job(j, &highlighter))
.collect();
ser_total += t.elapsed();
}
let ser_mean = ser_total / runs as u32;
let speedup = ser_mean.as_secs_f64() / par_mean.as_secs_f64().max(1e-9);
println!(
"N={n:>3}: serial={ser_mean:>10.2?} parallel={par_mean:>10.2?} speedup={speedup:.2}x"
);
}
}
#[test]
fn enhance_full_file_highlight_results_match_input_order() {
let files = highlight_n_vue_files(6);
for (i, file) in files.iter().enumerate() {
let path = file.new_path.as_ref().unwrap();
assert_eq!(path.to_str().unwrap(), format!("Comp{i}.vue"));
let added = file
.hunks
.iter()
.flat_map(|h| &h.lines)
.find(|l| l.origin == crate::model::diff_types::LineOrigin::Addition)
.expect("addition line");
assert!(
added.highlighted_spans.is_some(),
"file {i} addition unhighlighted"
);
}
}
}