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));
if warm_loop(&text, |line| {
call_highlight_line(&mut hl, &a.syntaxes, line).map(|_| ())
}) {
mark_warm(ext); }
}
fn warm_loop(text: &str, mut try_line: impl FnMut(&str) -> Option<()>) -> bool {
for line in LinesWithEndings::from(text) {
if try_line(line).is_none() {
return false;
}
}
true
}
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,
}
}
fn call_highlight_line<'b>(
hl: &mut HighlightLines<'_>,
ss: &SyntaxSet,
line: &'b str,
) -> Option<Option<Vec<(SynStyle, &'b str)>>> {
crate::preview::markdown::catch_silent(|| hl.highlight_line(line, ss).ok())
}
fn spans_for_line(
hl: &mut HighlightLines<'_>,
ss: &SyntaxSet,
line: &str,
) -> Option<Vec<Span<'static>>> {
let ranges = call_highlight_line(hl, ss, line).flatten()?;
Some(
ranges
.into_iter()
.map(|(st, text)| Span::styled(trim_eol(text).to_string(), to_style(st)))
.filter(|s| !s.content.is_empty())
.collect(),
)
}
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));
line_spans_with(line, || spans_for_line(&mut hl, &assets.syntaxes, &owned))
}
fn line_spans_with(
line: &str,
spans_for: impl FnOnce() -> Option<Vec<Span<'static>>>,
) -> Vec<Span<'static>> {
let Some(spans) = spans_for() else {
return vec![Span::raw(line.to_string())];
};
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);
highlight_lines_with(src, |line| spans_for_line(&mut hl, &assets.syntaxes, line))
}
fn highlight_lines_with(
src: &str,
mut spans_for: impl FnMut(&str) -> Option<Vec<Span<'static>>>,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
for line in LinesWithEndings::from(src) {
let Some(spans) = spans_for(line) else {
out.push(Line::from(trim_eol(line).to_string()));
continue;
};
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>> {
let tab_width = crate::config::clamp_tab_width(tab_width);
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 crate::test_support::unique_tmp;
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 = unique_tmp("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]
fn warm_dir_makes_subsequent_highlight_fast() {
let dir = crate::test_support::unique_tmp("konoma_code_warmdir");
std::fs::create_dir_all(&dir).unwrap();
let src = "-- a lua comment\nlocal function greet(name)\n print('hello, ' .. name)\nend\n";
let f = dir.join("a.lua");
std::fs::write(&f, src).unwrap();
assert!(
has_named_syntax(&f),
"lua の文法が解決できない(テスト前提が崩れている)"
);
warm_dir(dir.clone());
let alloc = crate::mem_tests::allocated_by(|| {
let lines = highlight(src, &f, "TwoDark");
assert!(!lines.is_empty());
});
assert!(
alloc < 2_000_000, "warm_dir 後の初回ハイライトの確保バイト数が多すぎる(warm_dir が効いていない?): {alloc}"
);
std::fs::remove_dir_all(&dir).ok();
}
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 expand_tabs_clamps_unreasonably_large_tab_width() {
let line = Line::from("\tx".to_string());
let out = expand_tabs(vec![line], 1_000);
assert_eq!(
line_str(&out[0]),
"→ x",
"現実的範囲(1..=16)外の tab_width は既定(4)にクランプされるべき"
);
}
#[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 = unique_tmp("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 = unique_tmp("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();
}
#[test]
fn highlight_lines_with_degrades_a_panic_to_plain_text_preserving_line_count() {
let src = "alpha\nbeta\ngamma\n";
let lines = highlight_lines_with(src, |_line| {
crate::preview::markdown::catch_silent(|| -> Vec<Span<'static>> {
panic!("simulated syntect panic")
})
});
assert_eq!(
lines.len(),
3,
"パニックしても行数はソースと一致するはず(欠落しない): {}",
lines.len()
);
let texts: Vec<String> = lines.iter().map(line_str).collect();
assert_eq!(
texts,
vec!["alpha", "beta", "gamma"],
"各行のテキストは保たれる"
);
for l in &lines {
assert!(
l.spans.iter().all(|s| s.style.fg.is_none()),
"パニックした行は無装飾のはず: {l:?}"
);
}
}
#[test]
fn highlight_lines_with_degrades_only_the_panicking_line() {
let src = "one\ntwo\nthree\n";
let colored =
|text: &str| vec![Span::styled(text.to_string(), Style::new().fg(Color::Red))];
let mut call = 0;
let lines = highlight_lines_with(src, |line| {
call += 1;
if call == 2 {
crate::preview::markdown::catch_silent(|| -> Vec<Span<'static>> {
panic!("simulated syntect panic on line 2")
})
} else {
Some(colored(trim_eol(line)))
}
});
assert_eq!(lines.len(), 3);
assert_eq!(line_str(&lines[0]), "one");
assert!(
lines[0]
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Red)),
"パニックしていない1行目は着色されたまま"
);
assert_eq!(
line_str(&lines[1]),
"two",
"パニックした行もテキストは保たれる"
);
assert!(
lines[1].spans.iter().all(|s| s.style.fg.is_none()),
"パニックした2行目だけ無装飾に降格するはず"
);
assert_eq!(line_str(&lines[2]), "three");
assert!(
lines[2]
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Red)),
"パニックしていない3行目は着色されたまま(後続行に波及しない)"
);
}
#[test]
fn line_spans_with_degrades_a_panic_to_one_unstyled_span_with_original_text() {
let spans = line_spans_with("hello world", || {
crate::preview::markdown::catch_silent(|| -> Vec<Span<'static>> {
panic!("simulated syntect panic")
})
});
assert_eq!(spans.len(), 1, "1 span に降格するはず: {spans:?}");
assert_eq!(
spans[0].content.as_ref(),
"hello world",
"元の行テキストを保持するはず(diff の行が消えない)"
);
assert!(spans[0].style.fg.is_none(), "無装飾のはず");
}
#[test]
fn line_spans_with_passes_a_successful_result_through_unchanged() {
let styled = vec![Span::styled(
"colored".to_string(),
Style::new().fg(Color::Red),
)];
let spans = line_spans_with("unused-fallback-text", || Some(styled.clone()));
assert_eq!(spans, styled);
}
#[test]
fn warm_loop_stops_early_on_panic_without_completing() {
let text = "line1\nline2\nline3\n";
let mut seen = 0;
let completed = warm_loop(text, |_line| {
seen += 1;
if seen == 2 {
crate::preview::markdown::catch_silent(|| panic!("simulated syntect panic"))
} else {
Some(())
}
});
assert!(
!completed,
"パニックしたら completed=false(=呼び出し側は mark_warm しない合図)のはず"
);
assert_eq!(seen, 2, "パニックした行で止まり、以降の行は処理しないはず");
}
#[test]
fn warm_loop_completes_when_every_line_is_ok_or_a_benign_err() {
let text = "a\nb\nc\n";
let mut seen = 0;
let completed = warm_loop(text, |_line| {
seen += 1;
Some(()) });
assert!(completed, "パニックが無ければ最後まで完走するはず");
assert_eq!(seen, 3, "全行処理されるはず");
}
#[test]
fn warm_file_does_not_mark_warm_when_the_underlying_call_panics() {
let text = "does not matter\n";
let completed = warm_loop(text, |_line| -> Option<()> { None });
assert!(
!completed,
"warm_loop が false を返すことが mark_warm 抑止の唯一の根拠"
);
}
#[test]
fn highlight_line_by_ext_still_colors_a_real_line_after_the_refactor() {
let spans = highlight_line_by_ext("fn add(x: i32) -> i32 { x + 1 }", "rs", "TwoDark");
assert!(
spans.len() > 1,
"着色されていない(単一 span のまま): {spans:?}"
);
assert!(
spans
.iter()
.any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _)))),
"前景色が付いていない"
);
let joined: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(
joined, "fn add(x: i32) -> i32 { x + 1 }",
"テキスト内容は保たれる"
);
}
}