use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Style as SynStyle};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use syntect::util::LinesWithEndings;
use two_face::theme::{EmbeddedLazyThemeSet, EmbeddedThemeName};
use unicode_width::UnicodeWidthStr;
struct Assets {
syntaxes: SyntaxSet,
themes: EmbeddedLazyThemeSet,
}
fn assets() -> &'static Assets {
static A: OnceLock<Assets> = OnceLock::new();
A.get_or_init(|| Assets {
syntaxes: two_face::syntax::extra_newlines(),
themes: two_face::theme::extra(),
})
}
fn parse_theme(name: &str) -> EmbeddedThemeName {
let norm = |s: &str| -> String {
s.chars()
.filter(|c| !matches!(c, ' ' | '-' | '_' | '.' | '(' | ')'))
.flat_map(|c| c.to_lowercase())
.collect()
};
let want = norm(name);
if matches!(want.as_str(), "" | "default" | "onedark" | "one") {
return EmbeddedThemeName::TwoDark;
}
EmbeddedLazyThemeSet::theme_names()
.iter()
.copied()
.find(|t| norm(t.as_name()) == want)
.unwrap_or(EmbeddedThemeName::TwoDark)
}
fn warmed_set() -> &'static Mutex<HashSet<String>> {
static WARMED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
WARMED.get_or_init(|| Mutex::new(HashSet::new()))
}
pub fn is_ext_warm(ext: &str) -> bool {
warmed_set()
.lock()
.map(|s| s.contains(ext))
.unwrap_or(false)
}
fn mark_warm(ext: &str) {
if !ext.is_empty() {
if let Ok(mut s) = warmed_set().lock() {
s.insert(ext.to_string());
}
}
}
pub fn warm_dir(root: PathBuf) {
let _ = assets();
for (ext, path) in warm_order(&root) {
warm_file(&ext, &path);
std::thread::yield_now(); }
}
fn warm_order(root: &Path) -> Vec<(String, PathBuf)> {
let mut count: HashMap<String, usize> = HashMap::new();
let mut sample: HashMap<String, PathBuf> = HashMap::new();
let mut budget = 20_000usize; collect_exts(root, &mut count, &mut sample, &mut budget);
let mut exts: Vec<(String, usize)> = count.into_iter().collect();
exts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
exts.into_iter()
.filter_map(|(ext, _)| sample.get(&ext).map(|p| (ext, p.clone())))
.collect()
}
fn collect_exts(
dir: &Path,
count: &mut HashMap<String, usize>,
sample: &mut HashMap<String, PathBuf>,
budget: &mut usize,
) {
if *budget == 0 {
return;
}
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let mut subdirs = Vec::new();
for entry in rd.flatten() {
if *budget == 0 {
return;
}
if entry.file_name().to_string_lossy().starts_with('.') {
continue; }
let Ok(ft) = entry.file_type() else {
continue;
};
let path = entry.path();
if ft.is_dir() {
subdirs.push(path); } else if ft.is_file() {
*budget -= 1;
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
*count.entry(ext.to_string()).or_insert(0) += 1;
sample.entry(ext.to_string()).or_insert(path);
}
}
}
for sub in subdirs {
collect_exts(&sub, count, sample, budget);
}
}
pub fn warm_file(ext: &str, path: &Path) {
if is_ext_warm(ext) {
return; }
let a = assets();
let Some(syntax) = a.syntaxes.find_syntax_by_extension(ext) else {
mark_warm(ext); return;
};
let Ok(text) = read_head(path, 64 * 1024) else {
return; };
let mut hl = HighlightLines::new(syntax, a.themes.get(EmbeddedThemeName::TwoDark));
for line in LinesWithEndings::from(&text) {
let _ = hl.highlight_line(line, &a.syntaxes); }
mark_warm(ext); }
fn read_head(path: &Path, max_bytes: usize) -> std::io::Result<String> {
use std::io::Read;
let mut f = std::fs::File::open(path)?;
let mut buf = vec![0u8; max_bytes];
let n = f.read(&mut buf)?;
buf.truncate(n);
Ok(String::from_utf8_lossy(&buf).into_owned())
}
fn alias_syntax_token(name: &str, ext: &str) -> Option<&'static str> {
if name.ends_with("ignore") {
return Some(".gitignore");
}
match ext {
"jsonc" | "json5" => Some("json"),
_ => None,
}
}
fn find_named_syntax<'a>(ss: &'a SyntaxSet, path: &Path) -> Option<&'a SyntaxReference> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let name = path.file_name().and_then(|e| e.to_str()).unwrap_or("");
(!ext.is_empty())
.then(|| ss.find_syntax_by_extension(ext))
.flatten()
.or_else(|| ss.find_syntax_by_extension(name))
.or_else(|| ss.find_syntax_by_extension(name.trim_start_matches('.')))
.or_else(|| alias_syntax_token(name, ext).and_then(|t| ss.find_syntax_by_extension(t)))
}
fn resolve_syntax<'a>(ss: &'a SyntaxSet, path: &Path, src: &str) -> &'a SyntaxReference {
find_named_syntax(ss, path)
.or_else(|| ss.find_syntax_by_first_line(src.lines().next().unwrap_or("")))
.unwrap_or_else(|| ss.find_syntax_plain_text())
}
pub fn has_named_syntax(path: &Path) -> bool {
let ss = &assets().syntaxes;
match find_named_syntax(ss, path) {
Some(s) => s.name != ss.find_syntax_plain_text().name,
None => false,
}
}
pub fn highlight(src: &str, path: &Path, theme: &str) -> Vec<Line<'static>> {
let assets = assets();
let syntax = resolve_syntax(&assets.syntaxes, path, src);
let out = highlight_with(src, syntax, theme);
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
mark_warm(ext);
out
}
pub fn highlight_line_by_ext(line: &str, ext: &str, theme: &str) -> Vec<Span<'static>> {
let assets = assets();
let Some(syntax) = assets.syntaxes.find_syntax_by_extension(ext) else {
return vec![Span::raw(line.to_string())];
};
let theme = assets.themes.get(parse_theme(theme));
let mut hl = HighlightLines::new(syntax, theme);
let owned = format!("{}\n", trim_eol(line));
let Ok(ranges) = hl.highlight_line(&owned, &assets.syntaxes) else {
return vec![Span::raw(line.to_string())];
};
let spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(st, text)| Span::styled(trim_eol(text).to_string(), to_style(st)))
.filter(|s| !s.content.is_empty())
.collect();
if spans.is_empty() {
vec![Span::raw(String::new())]
} else {
spans
}
}
pub fn highlight_lang(src: &str, lang: &str, theme: &str) -> Vec<Line<'static>> {
let assets = assets();
let syntax = assets
.syntaxes
.find_syntax_by_token(lang)
.unwrap_or_else(|| assets.syntaxes.find_syntax_plain_text());
highlight_with(src, syntax, theme)
}
fn highlight_with(src: &str, syntax: &SyntaxReference, theme: &str) -> Vec<Line<'static>> {
let assets = assets();
let theme = assets.themes.get(parse_theme(theme));
let mut hl = HighlightLines::new(syntax, theme);
let mut out = Vec::new();
for line in LinesWithEndings::from(src) {
let Ok(ranges) = hl.highlight_line(line, &assets.syntaxes) else {
out.push(Line::from(trim_eol(line).to_string()));
continue;
};
let spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(st, text)| Span::styled(trim_eol(text).to_string(), to_style(st)))
.filter(|s| !s.content.is_empty())
.collect();
out.push(Line::from(spans));
}
if out.is_empty() {
out.push(Line::from(""));
}
out
}
fn trim_eol(s: &str) -> &str {
s.trim_end_matches(['\n', '\r'])
}
const TAB_MARKER: char = '→';
pub fn expand_tabs(lines: Vec<Line<'static>>, tab_width: usize) -> Vec<Line<'static>> {
if tab_width == 0
|| !lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains('\t')))
{
return lines;
}
let marker_style = Style::new().fg(Color::DarkGray);
lines
.into_iter()
.map(|line| {
let line_style = line.style;
let mut col = 0usize; let mut out: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
for span in line.spans {
if !span.content.contains('\t') {
col += UnicodeWidthStr::width(span.content.as_ref());
out.push(span);
continue;
}
let style = span.style;
let mut buf = String::new();
for ch in span.content.chars() {
if ch == '\t' {
if !buf.is_empty() {
col += UnicodeWidthStr::width(buf.as_str());
out.push(Span::styled(std::mem::take(&mut buf), style));
}
let stop = tab_width - (col % tab_width); let mut marker = String::with_capacity(stop);
marker.push(TAB_MARKER);
for _ in 1..stop {
marker.push(' ');
}
out.push(Span::styled(marker, marker_style));
col += stop;
} else {
buf.push(ch);
}
}
if !buf.is_empty() {
col += UnicodeWidthStr::width(buf.as_str());
out.push(Span::styled(buf, style));
}
}
Line::from(out).style(line_style)
})
.collect()
}
fn to_style(st: SynStyle) -> Style {
let fg = st.foreground;
let mut style = Style::new().fg(Color::Rgb(fg.r, fg.g, fg.b));
if st.font_style.contains(FontStyle::BOLD) {
style = style.add_modifier(Modifier::BOLD);
}
if st.font_style.contains(FontStyle::ITALIC) {
style = style.add_modifier(Modifier::ITALIC);
}
if st.font_style.contains(FontStyle::UNDERLINE) {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn highlights_rust_into_multiple_colored_spans() {
let src = "fn main() {\n let x = 42;\n}\n";
let lines = highlight(src, &PathBuf::from("a.rs"), "TwoDark");
assert_eq!(lines.len(), 3, "行数が一致しない: {}", lines.len());
let multi = lines.iter().any(|l| l.spans.len() > 1);
assert!(multi, "ハイライトされていない(全行単一 span)");
let colored = lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _))));
assert!(colored, "前景色が付いていない");
}
#[test]
fn config_dotfiles_and_named_files_resolve_a_real_syntax() {
for name in [
".bashrc",
".zshrc",
".gitconfig",
"Makefile",
"Dockerfile",
".dockerignore",
".npmignore",
"tsconfig.jsonc",
"app.json5",
] {
assert!(
has_named_syntax(&PathBuf::from(name)),
"{name} が文法解決できていない(素テキスト扱い)"
);
}
for name in ["notes.txt", "README", "output.dat"] {
assert!(
!has_named_syntax(&PathBuf::from(name)),
"{name} は素テキスト扱いのはず"
);
}
let src = "# comment\nexport PATH=\"$HOME/bin:$PATH\"\nalias ll='ls -la'\n";
let lines = highlight(src, &PathBuf::from(".bashrc"), "TwoDark");
let distinct: std::collections::HashSet<_> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter_map(|s| match s.style.fg {
Some(Color::Rgb(r, g, b)) => Some((r, g, b)),
_ => None,
})
.collect();
assert!(
distinct.len() >= 2,
".bashrc が単色(ハイライトされていない): {} 色",
distinct.len()
);
}
#[test]
fn unknown_extension_falls_back_to_plain_text() {
let src = "hello world\nsecond line\n";
let lines = highlight(src, &PathBuf::from("note.unknownext"), "TwoDark");
assert_eq!(lines.len(), 2);
let joined: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("hello world") && joined.contains("second line"));
}
#[test]
fn empty_source_yields_one_line_no_panic() {
let lines = highlight("", &PathBuf::from("a.rs"), "TwoDark");
assert_eq!(lines.len(), 1);
}
fn distinct_colors(lines: &[Line<'static>]) -> usize {
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter_map(|s| match s.style.fg {
Some(Color::Rgb(r, g, b)) => Some((r, g, b)),
_ => None,
})
.collect::<std::collections::HashSet<_>>()
.len()
}
fn color_of(lines: &[Line<'static>], target: &str) -> Option<(u8, u8, u8)> {
lines.iter().flat_map(|l| l.spans.iter()).find_map(|s| {
if s.content.contains(target) {
match s.style.fg {
Some(Color::Rgb(r, g, b)) => Some((r, g, b)),
_ => None,
}
} else {
None
}
})
}
#[test]
fn theme_matches_zed_one_dark_palette() {
let lines = highlight(
"// c\nfn add() {\n let s = \"hi\";\n let n = 42;\n}\n",
&PathBuf::from("a.rs"),
"TwoDark",
);
assert_eq!(
color_of(&lines, "fn"),
Some((0xc6, 0x78, 0xdd)),
"keyword 紫"
);
assert_eq!(
color_of(&lines, "hi"),
Some((0x98, 0xc3, 0x79)),
"string 緑"
);
assert_eq!(
color_of(&lines, "42"),
Some((0xd1, 0x9a, 0x66)),
"number 橙"
);
assert_eq!(
color_of(&lines, " c"),
Some((0x5c, 0x63, 0x70)),
"comment 灰"
);
}
#[test]
fn typescript_and_toml_highlight_via_two_face() {
let ts = highlight(
"interface T { id: number; }\nconst x: T = { id: 1 }; // c\n",
&PathBuf::from("a.ts"),
"TwoDark",
);
assert!(distinct_colors(&ts) >= 2, "TS が着色されない(単一色)");
let toml = highlight(
"# c\ntitle = \"x\"\n[server]\nport = 8080\n",
&PathBuf::from("Cargo.toml"),
"TwoDark",
);
assert!(distinct_colors(&toml) >= 2, "TOML が着色されない(単一色)");
}
#[test]
fn warm_order_ranks_extensions_by_file_count() {
use std::io::Write;
let dir = std::env::temp_dir().join("konoma_warm_order_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
let touch = |p: PathBuf| {
let mut f = std::fs::File::create(p).unwrap();
f.write_all(b"x\n").unwrap();
};
touch(dir.join("a.rs"));
touch(dir.join("b.rs"));
touch(dir.join("sub").join("c.rs"));
touch(dir.join("d.py"));
touch(dir.join("e.py"));
touch(dir.join("f.md"));
touch(dir.join(".hidden.rs")); let order = warm_order(&dir);
let exts: Vec<&str> = order.iter().map(|(e, _)| e.as_str()).collect();
assert_eq!(exts, vec!["rs", "py", "md"], "ファイル数の多い順: {exts:?}");
for (_, p) in &order {
assert!(p.exists(), "代表ファイルが無い: {p:?}");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[ignore] fn warm_dir_makes_subsequent_highlight_fast() {
use std::time::Instant;
let root = std::path::Path::new("samples/code");
if !root.exists() {
return;
}
warm_dir(root.to_path_buf());
let ts = std::fs::read_to_string("samples/code/app.ts").unwrap();
let t = Instant::now();
let _ = highlight(&ts, &PathBuf::from("app.ts"), "TwoDark");
let ms = t.elapsed().as_secs_f64() * 1000.0;
eprintln!("app.ts after warm_dir: {ms:.0} ms");
assert!(ms < 200.0, "ウォーム後も遅い: {ms:.0} ms");
}
fn line_str(l: &Line<'_>) -> String {
l.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn expand_tabs_aligns_to_stops_and_marks() {
let src = "\t\tconst x = 1;\n";
let lines = expand_tabs(highlight(src, &PathBuf::from("a.ts"), "TwoDark"), 4);
let s = line_str(&lines[0]);
assert!(s.starts_with("→ → const"), "タブ展開がおかしい: {s:?}");
assert_eq!(UnicodeWidthStr::width(s.as_str()), s.chars().count());
let marker = lines[0]
.spans
.iter()
.find(|sp| sp.content.contains('→'))
.expect("マーカー span が無い");
assert_eq!(marker.style.fg, Some(Color::DarkGray));
}
#[test]
fn expand_tabs_mid_line_aligns_to_next_stop() {
let line = Line::from("ab\tc".to_string());
let out = expand_tabs(vec![line], 4);
assert_eq!(line_str(&out[0]), "ab→ c");
}
#[test]
fn expand_tabs_zero_width_is_noop() {
let line = Line::from("\tx".to_string());
let out = expand_tabs(vec![line], 0);
assert_eq!(line_str(&out[0]), "\tx", "0 は無変換");
}
#[test]
fn parse_theme_resolves_names_and_aliases() {
assert_eq!(parse_theme(""), EmbeddedThemeName::TwoDark);
assert_eq!(parse_theme("one-dark"), EmbeddedThemeName::TwoDark);
assert_eq!(parse_theme("TwoDark"), EmbeddedThemeName::TwoDark);
assert_eq!(parse_theme("dracula"), EmbeddedThemeName::Dracula);
assert_eq!(parse_theme("Nord"), EmbeddedThemeName::Nord);
assert_eq!(parse_theme("gruvbox-dark"), EmbeddedThemeName::GruvboxDark);
assert_eq!(
parse_theme("catppuccin mocha"),
EmbeddedThemeName::CatppuccinMocha
);
assert_eq!(parse_theme("no-such-theme"), EmbeddedThemeName::TwoDark);
}
#[test]
fn highlight_lang_tokenizes_known_and_plain() {
let lines = highlight_lang("let x = 1;\nlet y = 2;\n", "rust", "TwoDark");
assert_eq!(lines.len(), 2, "行数は入力どおり");
assert_eq!(line_str(&lines[0]), "let x = 1;");
assert!(
lines.iter().any(|l| l.spans.len() > 1),
"rust は複数 span に着色されるはず"
);
let plain = highlight_lang("hello\nworld\n", "no-such-lang", "TwoDark");
assert_eq!(plain.len(), 2);
assert_eq!(line_str(&plain[0]), "hello");
assert_eq!(line_str(&plain[1]), "world");
}
#[test]
fn read_head_caps_bytes_and_errors_on_missing() {
let dir = std::env::temp_dir().join("konoma_read_head_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("data.txt");
std::fs::write(&f, "0123456789ABCDEF".as_bytes()).unwrap();
let head = read_head(&f, 5).unwrap();
assert_eq!(head, "01234", "先頭5バイトだけ");
let all = read_head(&f, 1000).unwrap();
assert_eq!(all, "0123456789ABCDEF");
assert!(read_head(&dir.join("nope.txt"), 10).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn warm_file_marks_ext_warm_for_known_and_unknown() {
let dir = std::env::temp_dir().join("konoma_warm_file_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let rs = dir.join("sample.rs");
std::fs::write(&rs, b"fn main() { let x = 1; }\n").unwrap();
warm_file("rs", &rs);
assert!(is_ext_warm("rs"), "warm 後は rs が温まり済み");
let weird = dir.join("x.konoma_zzqq");
std::fs::write(&weird, b"x").unwrap();
warm_file("konoma_zzqq", &weird);
assert!(is_ext_warm("konoma_zzqq"), "文法なしでも warm 済み扱い");
std::fs::remove_dir_all(&dir).ok();
}
}