vkit 0.1.4

Fast Rust dev CLI: manage git worktrees, Node ports, run scripts, install & sync VS Code / Cursor extensions.
//! 统一 diff 的 syntect 语法高亮(按文件扩展名,+/- 分流维护解析状态)。

use std::sync::OnceLock;

use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};

use crate::theme;

static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();

fn syntax_set() -> &'static SyntaxSet {
    SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
}

fn theme() -> &'static Theme {
    let themes = THEME_SET.get_or_init(ThemeSet::load_defaults);
    let name = if is_light_ui() {
        "base16-ocean.light"
    } else {
        "base16-ocean.dark"
    };
    themes
        .themes
        .get(name)
        .or_else(|| themes.themes.get("base16-ocean.dark"))
        .expect("syntect default theme")
}

fn is_light_ui() -> bool {
    match theme::surface() {
        Color::Rgb(r, g, b) => (r as u16) + (g as u16) + (b as u16) > 400,
        _ => false,
    }
}

fn to_ratatui_style(style: syntect::highlighting::Style) -> Style {
    let fg = style.foreground;
    let mut out = Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b));
    if style.font_style.contains(FontStyle::BOLD) {
        out = out.add_modifier(Modifier::BOLD);
    }
    if style.font_style.contains(FontStyle::ITALIC) {
        out = out.add_modifier(Modifier::ITALIC);
    }
    if style.font_style.contains(FontStyle::UNDERLINE) {
        out = out.add_modifier(Modifier::UNDERLINED);
    }
    out
}

fn highlight_code_line(
    hl: &mut HighlightLines<'_>,
    code: &str,
    ps: &SyntaxSet,
) -> Vec<Span<'static>> {
    let line = if code.ends_with('\n') {
        code.to_string()
    } else {
        format!("{code}\n")
    };
    match hl.highlight_line(&line, ps) {
        Ok(segments) => segments
            .into_iter()
            .map(|(style, text)| {
                // syntect 带换行;展示时去掉行尾 \n
                let text = text.trim_end_matches('\n').to_string();
                Span::styled(text, to_ratatui_style(style))
            })
            .filter(|s| !s.content.is_empty())
            .collect(),
        Err(_) => vec![Span::styled(
            code.to_string(),
            Style::default().fg(theme::text()),
        )],
    }
}

fn muted_line(text: &str) -> Line<'static> {
    Line::from(Span::styled(
        text.to_string(),
        Style::default().fg(theme::muted()),
    ))
}

fn path_from_diff_git(line: &str) -> Option<String> {
    // diff --git a/foo.rs b/foo.rs
    let rest = line.strip_prefix("diff --git ")?;
    let mut parts = rest.split_whitespace();
    let _a = parts.next()?;
    let b = parts.next()?;
    Some(b.trim_start_matches("b/").to_string())
}

fn path_from_plus_plus_plus(line: &str) -> Option<String> {
    // +++ b/path  /  +++ /dev/null
    let rest = line.strip_prefix("+++ ")?.trim();
    if rest == "/dev/null" {
        return None;
    }
    Some(rest.trim_start_matches("b/").to_string())
}

fn path_from_minus_minus_minus(line: &str) -> Option<String> {
    let rest = line.strip_prefix("--- ")?.trim();
    if rest == "/dev/null" {
        return None;
    }
    Some(rest.trim_start_matches("a/").to_string())
}

/// 按路径选语法。不用 `find_syntax_for_file`(会尝试读盘);
/// syntect 默认集无 TS/TSX,映射到 JavaScript。
fn syntax_for_path<'a>(ps: &'a SyntaxSet, path: &str) -> &'a SyntaxReference {
    let ext = std::path::Path::new(path)
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();

    let mapped = match ext.as_str() {
        "ts" | "tsx" | "mts" | "cts" | "jsx" => "js",
        "mjs" | "cjs" => "js",
        "vue" | "svelte" => "html",
        "yml" => "yaml",
        other => other,
    };

    ps.find_syntax_by_extension(mapped)
        .or_else(|| {
            // 少数语法挂在完整文件名上(如 Dockerfile)
            std::path::Path::new(path)
                .file_name()
                .and_then(|n| n.to_str())
                .and_then(|n| ps.find_syntax_by_extension(n))
        })
        .unwrap_or_else(|| ps.find_syntax_plain_text())
}

struct FileHighlighters<'a> {
    old: HighlightLines<'a>,
    new: HighlightLines<'a>,
}

impl<'a> FileHighlighters<'a> {
    fn new(syntax: &'a SyntaxReference, theme: &'a Theme) -> Self {
        Self {
            old: HighlightLines::new(syntax, theme),
            new: HighlightLines::new(syntax, theme),
        }
    }
}

/// 将 `git show` 统一 diff 转为带语法高亮的行。
pub fn highlight_unified_diff(patch: &str) -> Vec<Line<'static>> {
    if patch.is_empty() {
        return vec![muted_line("(无文件变更)")];
    }

    let ps = syntax_set();
    let th = theme();
    let plain = ps.find_syntax_plain_text();
    let mut file_hl = FileHighlighters::new(plain, th);
    let mut out = Vec::new();

    for raw in patch.lines() {
        if raw.starts_with("diff --git ") {
            if let Some(path) = path_from_diff_git(raw) {
                let syn = syntax_for_path(ps, &path);
                file_hl = FileHighlighters::new(syn, th);
            }
            out.push(muted_line(raw));
            continue;
        }
        if raw.starts_with("--- ") {
            if let Some(path) = path_from_minus_minus_minus(raw) {
                let syn = syntax_for_path(ps, &path);
                // 仅刷新 old 侧;new 等 +++ 再定
                file_hl.old = HighlightLines::new(syn, th);
            }
            out.push(muted_line(raw));
            continue;
        }
        if raw.starts_with("+++ ") {
            if let Some(path) = path_from_plus_plus_plus(raw) {
                let syn = syntax_for_path(ps, &path);
                file_hl.new = HighlightLines::new(syn, th);
            }
            out.push(muted_line(raw));
            continue;
        }
        if raw.starts_with("@@")
            || raw.starts_with("index ")
            || raw.starts_with("new file")
            || raw.starts_with("deleted file")
            || raw.starts_with("similarity")
            || raw.starts_with("rename ")
            || raw.starts_with("Binary ")
            || raw.starts_with("old mode")
            || raw.starts_with("new mode")
        {
            out.push(muted_line(raw));
            continue;
        }

        let (marker, code) = match raw.chars().next() {
            Some('+') => ('+', &raw[1..]),
            Some('-') => ('-', &raw[1..]),
            Some(' ') => (' ', &raw[1..]),
            _ => {
                out.push(Line::from(Span::styled(
                    raw.to_string(),
                    Style::default().fg(theme::text()),
                )));
                continue;
            }
        };

        let mut spans = Vec::new();
        match marker {
            '+' => {
                spans.push(Span::styled(
                    "+".to_string(),
                    Style::default().fg(theme::success()),
                ));
                spans.extend(highlight_code_line(&mut file_hl.new, code, ps));
            }
            '-' => {
                spans.push(Span::styled(
                    "-".to_string(),
                    Style::default().fg(theme::danger()),
                ));
                spans.extend(highlight_code_line(&mut file_hl.old, code, ps));
            }
            _ => {
                spans.push(Span::styled(
                    " ".to_string(),
                    Style::default().fg(theme::muted()),
                ));
                // context:两侧同步推进,展示用 new 侧着色
                let _ = highlight_code_line(&mut file_hl.old, code, ps);
                spans.extend(highlight_code_line(&mut file_hl.new, code, ps));
            }
        }
        out.push(Line::from(spans));
    }

    out
}

/// 按显示宽度截断已高亮行(字符数,与 truncate_text 一致)。
pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
    if max_width == 0 {
        return Line::from("");
    }
    let mut used = 0usize;
    let mut spans = Vec::new();
    for span in line.spans {
        let chars: Vec<char> = span.content.chars().collect();
        let len = chars.len();
        if used >= max_width {
            break;
        }
        if used + len <= max_width {
            used += len;
            spans.push(span);
            continue;
        }
        let remain = max_width - used;
        if remain == 1 {
            spans.push(Span::styled("".to_string(), span.style));
        } else {
            let head: String = chars.into_iter().take(remain - 1).collect();
            spans.push(Span::styled(format!("{head}"), span.style));
        }
        break;
    }
    Line::from(spans)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn highlights_rust_diff_tokens() {
        let patch = "\
diff --git a/src/a.rs b/src/a.rs
--- a/src/a.rs
+++ b/src/a.rs
@@ -1 +1 @@
-fn old() {}
+fn new() {}
";
        let lines = highlight_unified_diff(patch);
        assert!(lines.len() >= 5);
        // 增行前缀为 +
        let add = lines.iter().find(|l| {
            l.spans
                .first()
                .is_some_and(|s| s.content.as_ref() == "+")
        });
        assert!(add.is_some());
        // Rust 关键字应被拆成多 span(不只是整行单色)
        let add = add.unwrap();
        assert!(
            add.spans.len() > 2,
            "expected syntax spans, got {:?}",
            add.spans
        );
    }

    #[test]
    fn highlights_tsx_via_javascript_fallback() {
        let ps = syntax_set();
        assert_eq!(syntax_for_path(ps, "src/App.tsx").name, "JavaScript");
        assert_eq!(syntax_for_path(ps, "x.ts").name, "JavaScript");
        assert_eq!(syntax_for_path(ps, "README.md").name, "Markdown");

        let patch = "\
diff --git a/src/App.tsx b/src/App.tsx
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1 +1 @@
-const x = 1;
+export function App() { return null; }
";
        let lines = highlight_unified_diff(patch);
        let add = lines
            .iter()
            .find(|l| l.spans.first().is_some_and(|s| s.content.as_ref() == "+"))
            .expect("add line");
        assert!(
            add.spans.len() > 2,
            "tsx should highlight via JS, got {:?}",
            add.spans
        );
    }
}