Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852//! Syntax highlighting for fenced code blocks via syntect.//!//! `HighlightCache` wraps a lazily-initialised `SyntaxSet` + resolved `Theme`//! and memoises results keyed on `(language_tag, hash_of_block_content)`.//!//! ## Lazy initialisation//! `SyntaxSet` and `ThemeSet` are expensive to deserialise from their bundled//! binary assets. Both are stored in **process-level statics** (`OnceLock`)//! and initialised on the first call to `highlight_block` — so files with no//! fenced code blocks pay zero startup cost, and every `HighlightCache`//! instance (including new ones created on theme changes) reuses the already-//! loaded data without re-deserialising it. On the default production path//! (`use_palette_colors = true`) the `ThemeSet` is never initialised at all.//!//! The highlight memo-cache is stored in a `Mutex` so that//! `build_decoration_map` can take `Option<&HighlightCache>` (immutable) while//! still mutating the cache on first use, and so that `HighlightCache` is//! `Send + Sync` and can be wrapped in `Arc` for the background decoration thread.//!//! # Theme resolution (highest priority first)//! 1. Palette-derived theme — built from the yame colour palette when//! `use_palette_colors = true` (the default). Keywords use `accent`,//! strings use `code_color`, comments use `muted`, etc.//! 2. Named syntect built-in theme — used when `use_palette_colors = false`//! (e.g. `"base16-ocean.dark"`). Falls back to `"base16-ocean.dark"` on//! an unrecognised name.//!//! # Fallback behaviour//! - Highlighting disabled (`enabled = false`) → `None` for every block.//! - Unknown language tag → `None` (caller falls back to fenced_bg-only).
use std::collections::HashMap;use std::hash::{DefaultHasher, Hash, Hasher};use std::str::FromStr;use std::sync::{Mutex, OnceLock};
use ratatui::style::Color;use syntect::easy::HighlightLines;use syntect::highlighting::{ Color as SC, ScopeSelectors, StyleModifier, Theme as SyntectTheme, ThemeItem, ThemeSet, ThemeSettings,};use syntect::parsing::SyntaxSet;use syntect::util::LinesWithEndings;use two_face::syntax as tf_syntax;
// ---------------------------------------------------------------------------// Process-level syntax / theme sets// ---------------------------------------------------------------------------
// These three are loaded at most once per process lifetime. Storing them as// statics (rather than OnceLock fields on HighlightCache) means that creating// a new HighlightCache — e.g. when the user changes theme in the settings// modal — never re-deserialises the binary assets.static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();static EXTRA_SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
// ---------------------------------------------------------------------------// Types// ---------------------------------------------------------------------------
/// One highlighted span within a single line of a code block./// All indices are char-counts (not bytes) from the start of that line.#[derive(Debug, Clone, PartialEq, Eq)]pub struct HlSpan { pub char_start: usize, pub char_end: usize, pub fg: Color,}
/// Per-line highlight spans for an entire fenced code block./// Outer `Vec` index = line offset within the block content (0-based, fence/// delimiters excluded). Each inner `Vec` is the list of coloured spans for/// that line, in left-to-right order.pub type BlockHighlights = Vec<Vec<HlSpan>>;
// ---------------------------------------------------------------------------// Palette-derived theme builder// ---------------------------------------------------------------------------
/// Convert a `ratatui` `Color::Rgb` value to a `syntect` `Color`./// Non-Rgb values fall back to opaque white (won't happen with our palette).fn to_sc(c: Color) -> SC { match c { Color::Rgb(r, g, b) => SC { r, g, b, a: 0xFF }, _ => SC { r: 205, g: 214, b: 244, a: 0xFF, }, }}
/// Build a syntect `Theme` whose token colours are derived from the yame palette.////// Token → palette mapping:////// | Token scope | Colour source |/// |------------------------------------|----------------------------------------|/// | `keyword`, `storage.*` | `accent` |/// | `string`, `constant.character` | `code_color` |/// | `comment` | `muted` |/// | `constant.numeric` | blend(`accent`, `code_color`, 0.5) |/// | `constant.language` (true/false) | `accent` |/// | `entity.name.type`, `support.type` | blend(`accent`, `text`, 0.75) |/// | `entity.name.function` | blend(`accent`, `text`, 0.85) |/// | `invalid` | `warning` |/// | everything else | `text` (via `ThemeSettings.foreground`)|pub fn build_palette_theme(yame: &crate::config::Theme) -> SyntectTheme { use crate::config::blend_colors;
let accent = to_sc(yame.accent); let code_color = to_sc(yame.code_color); let muted = to_sc(yame.muted); let text = to_sc(yame.text); let bg = to_sc(yame.fenced_bg); let warning = to_sc(yame.warning); // Numbers: midpoint between accent and code_color — a blue-green that reads // as "literal value" without borrowing the warning/error hue. let number_color = to_sc(blend_colors(yame.accent, yame.code_color, 0.5)); // Types: accent blended toward text — softer than keywords, still in-family. let type_color = to_sc(blend_colors(yame.accent, yame.text, 0.75)); // Functions: even softer accent blend. let fn_color = to_sc(blend_colors(yame.accent, yame.text, 0.85)); // Operators: code_color blended toward accent (30%) → soft cyan/teal. // Distinct from strings (pure code_color), keywords (pure accent), and the // blue-white `text` used for plain identifiers — so `x + y` reads as three // different visual tokens. let op_color = to_sc(blend_colors(yame.code_color, yame.accent, 0.3));
// Scope selectors follow the TextMate convention used by syntect grammars. // More-specific selectors override less-specific ones (syntect scoring). // Listing both "keyword" and "keyword.control" is redundant but explicit. let rules: &[(&str, SC)] = &[ // Keywords and storage modifiers → accent ("keyword", accent), ("keyword.control", accent), ("storage.type", accent), ("storage.modifier", accent), // Operators → op_color (cyan/teal: distinct from keywords and strings) ("keyword.operator", op_color), // Strings → code_color ("string", code_color), ("string.quoted", code_color), ("constant.character", code_color), // Comments → muted ("comment", muted), ("comment.line", muted), ("comment.block", muted), // Numeric literals → number_color (blue-green midpoint) ("constant.numeric", number_color), // Language constants (true, false, nil, null) → accent ("constant.language", accent), // Types and built-in types → type_color ("entity.name.type", type_color), ("entity.name.class", type_color), ("support.type", type_color), ("support.class", type_color), // Function names → fn_color ("entity.name.function", fn_color), ("support.function", fn_color), ("variable.function", fn_color), // Invalid / error tokens → warning ("invalid", warning), ];
let scopes: Vec<ThemeItem> = rules .iter() .filter_map(|(scope_str, fg)| { ScopeSelectors::from_str(scope_str) .ok() .map(|scope| ThemeItem { scope, style: StyleModifier { foreground: Some(*fg), background: None, font_style: None, }, }) }) .collect();
SyntectTheme { name: Some("yame-palette".into()), author: Some("yame".into()), settings: ThemeSettings { foreground: Some(text), background: Some(bg), ..ThemeSettings::default() }, scopes, }}
// ---------------------------------------------------------------------------// Pre-warming// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------// HighlightCache// ---------------------------------------------------------------------------
/// Maximum number of `(lang, content_hash)` entries kept in the memo-cache.////// Each entry is a `Vec<Vec<HlSpan>>` — roughly proportional to the number of/// tokens in the block. 256 entries is a very generous bound: real documents/// rarely have more than a handful of distinct code blocks, so this cap is/// effectively never hit in normal use. When it is hit (e.g. a document with/// hundreds of distinct snippets, or a very long editing session), one arbitrary/// entry is evicted before the new one is inserted, keeping the map bounded.const MAX_CACHE_ENTRIES: usize = 256;
pub struct HighlightCache { /// Palette-derived theme, pre-built when `use_palette_colors = true`. /// When `Some`, this takes priority over `theme_name` and `THEME_SET` is /// never initialised. palette_theme: Option<SyntectTheme>, /// Named built-in theme fallback (used when `palette_theme` is `None`). theme_name: String, /// Memoisation: `(lang_lower, content_hash)` → per-line coloured spans. /// `Mutex` (rather than `RefCell`) makes `HighlightCache: Send + Sync` so /// it can be wrapped in `Arc` and shared with the background decoration thread. cache: Mutex<HashMap<(String, u64), BlockHighlights>>, /// Whether syntax highlighting is enabled. pub enabled: bool,}
impl HighlightCache { const FALLBACK_THEME: &'static str = "base16-ocean.dark";
/// Create a new cache. /// /// Construction is now O(1): `SyntaxSet` and `ThemeSet` are not deserialised /// here — they are initialised lazily on the first call to `highlight_block`. /// /// `palette_theme` — pass `Some(build_palette_theme(&yame_theme))` to use /// palette-derived colours (recommended), or `None` to fall through to the /// named `theme_name` built-in. pub fn new(enabled: bool, theme_name: String, palette_theme: Option<SyntectTheme>) -> Self { Self { palette_theme, theme_name, cache: Mutex::new(HashMap::new()), enabled, } }
/// Highlight `content` as `lang`, returning per-line coloured spans. /// /// Returns `None` when highlighting is disabled, the language is empty or /// unknown, or no theme can be resolved. /// /// Initialises `SyntaxSet` (and `ThemeSet` when needed) on first call. pub fn highlight_block(&self, lang: &str, content: &str) -> Option<BlockHighlights> { if !self.enabled || lang.is_empty() { return None; }
let lang_lower = lang.to_lowercase(); let hash = hash_str(content); let key = (lang_lower.clone(), hash);
// Cache hit — no syntax/theme work needed. if let Some(cached) = self .cache .lock() .unwrap_or_else(|e| e.into_inner()) .get(&key) .cloned() { return Some(cached); }
// Lazily initialise the process-level syntax sets on the first cache miss. // Try the syntect defaults first; fall back to the two-face extras. // The active set must be threaded into highlight_line because syntect // binds SyntaxReferences to the SyntaxSet they were parsed from. let default_set = SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines); let (syntax, active_set) = if let Some(s) = default_set .find_syntax_by_token(&lang_lower) .or_else(|| default_set.find_syntax_by_extension(&lang_lower)) { (s, default_set) } else { let extra_set = EXTRA_SYNTAX_SET.get_or_init(tf_syntax::extra_newlines); let s = extra_set .find_syntax_by_token(&lang_lower) .or_else(|| extra_set.find_syntax_by_extension(&lang_lower))?; (s, extra_set) };
// Resolve theme: palette first (THEME_SET never touched), then named // built-in (THEME_SET lazily loaded on first non-palette use). let theme: &SyntectTheme = if let Some(pt) = &self.palette_theme { pt } else { let theme_set = THEME_SET.get_or_init(ThemeSet::load_defaults); theme_set .themes .get(&self.theme_name) .or_else(|| theme_set.themes.get(Self::FALLBACK_THEME))? };
let mut highlighter = HighlightLines::new(syntax, theme); let mut block_hl: BlockHighlights = Vec::new();
for line in LinesWithEndings::from(content) { let ranges = highlighter.highlight_line(line, active_set).ok()?;
let mut line_spans: Vec<HlSpan> = Vec::new(); let mut char_col = 0usize;
for (style, piece) in &ranges { let char_count = piece.chars().filter(|&c| c != '\n').count(); if char_count > 0 { line_spans.push(HlSpan { char_start: char_col, char_end: char_col + char_count, fg: Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b), }); char_col += char_count; } }
block_hl.push(line_spans); }
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); // Evict one arbitrary entry before inserting when the cap is reached. // HashMap iteration order is non-deterministic, so "arbitrary" is fine — // we have no usage-frequency data and all entries are equally cheap to // recompute. This keeps peak memory O(MAX_CACHE_ENTRIES) without // pulling in an LRU crate. // // The key is cloned out of the immutable borrow before `remove` takes the // mutable one; the `bool::then` short-circuits when below the cap. let evict_key = (cache.len() >= MAX_CACHE_ENTRIES) .then(|| cache.keys().next().cloned()) .flatten(); if let Some(k) = evict_key { cache.remove(&k); } cache.insert(key, block_hl.clone()); Some(block_hl) }
/// Number of entries currently stored in the memo-cache. /// Exposed for unit tests; not used on the hot path. #[cfg(test)] fn cache_len(&self) -> usize { self.cache.lock().unwrap_or_else(|e| e.into_inner()).len() }}
// ---------------------------------------------------------------------------// Helpers// ---------------------------------------------------------------------------
fn hash_str(s: &str) -> u64 { let mut h = DefaultHasher::new(); s.hash(&mut h); h.finish()}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use super::*; use crate::config::Theme;
/// Print every bundled syntax name and its file extensions — run with /// `cargo test enumerate_bundled_syntaxes -- --nocapture --ignored`. #[test] #[ignore] fn enumerate_bundled_syntaxes() { let ss = SyntaxSet::load_defaults_newlines(); let mut names: Vec<String> = ss .syntaxes() .iter() .map(|s| format!("{:30} exts: {:?}", s.name, s.file_extensions)) .collect(); names.sort(); for n in &names { println!("{n}"); } }
/// Cache using the default built-in theme (no palette). fn make_cache() -> HighlightCache { HighlightCache::new(true, "base16-ocean.dark".into(), None) }
/// Cache using the palette-derived theme built from the default yame theme. fn make_palette_cache() -> HighlightCache { let theme = Theme::default_theme(); let pt = build_palette_theme(&theme); HighlightCache::new(true, "base16-ocean.dark".into(), Some(pt)) }
// ---- basic cache behaviour ----
#[test] fn disabled_cache_returns_none() { let cache = HighlightCache::new(false, "base16-ocean.dark".into(), None); assert!(cache.highlight_block("rust", "let x = 1;").is_none()); }
#[test] fn empty_lang_returns_none() { let cache = make_cache(); assert!(cache.highlight_block("", "fn main() {}").is_none()); }
#[test] fn unknown_lang_returns_none() { let cache = make_cache(); assert!( cache .highlight_block("notareallangnobodywoulduse", "hello") .is_none() ); }
#[test] fn rust_block_returns_spans() { let cache = make_cache(); let content = "let x: u32 = 42;\n"; let hl = cache .highlight_block("rust", content) .expect("rust should be recognised"); assert_eq!(hl.len(), 1, "one content line → one entry"); assert!(!hl[0].is_empty(), "rust line must produce spans"); }
#[test] fn yaml_block_returns_spans() { let cache = make_cache(); let content = "section:\n key: value\n"; let hl = cache .highlight_block("yaml", content) .expect("yaml should be recognised"); assert_eq!(hl.len(), 2, "two content lines → two entries"); }
#[test] fn cache_hit_returns_same_result() { let cache = make_cache(); let content = "fn foo() {}\n"; let first = cache.highlight_block("rust", content).unwrap(); let second = cache.highlight_block("rust", content).unwrap(); assert_eq!(first, second, "cache hit must return identical spans"); }
// Kills: highlighting.rs `replace hash_str -> u64 with 0` and `with 1`. // If hash_str always returns a constant, two different content blocks with // the same language key map to the same cache entry. The second lookup // returns the first block's spans (wrong). #[test] fn cache_miss_different_content_same_language_gives_different_spans() { let cache = make_cache(); let hl_let = cache.highlight_block("rust", "let x = 1;\n").unwrap(); let hl_fn = cache .highlight_block("rust", "fn foo() -> bool { true }\n") .unwrap(); // These two Rust snippets have different token structures and must // produce different span lists. If hash_str → constant, the second // call hits the entry from the first call and returns hl_let wrongly. assert_ne!( hl_let, hl_fn, "different Rust content must produce different span lists (hash must be content-dependent)" ); }
// Kills: highlighting.rs `replace > with >= in highlight_block` (char_count guard). // The syntect tokenizer emits a trailing `\n` piece at the end of each line. // With `>= 0` (always true for usize) that zero-char piece generates a span // with char_start == char_end, violating the non-zero-width invariant. #[test] fn highlight_block_produces_no_zero_width_spans() { let cache = make_cache(); let hl = cache.highlight_block("rust", "let x: i32 = 42;\n").unwrap(); for (line_idx, spans) in hl.iter().enumerate() { for span in spans { assert!( span.char_start < span.char_end, "span on line {line_idx} must be non-zero-width, got {span:?}" ); } } }
#[test] fn multiline_block_line_count_matches() { let cache = make_cache(); let content = "fn a() {}\nfn b() {}\nfn c() {}\n"; let hl = cache.highlight_block("rust", content).unwrap(); assert_eq!( hl.len(), 3, "three-line block must produce three line-span lists" ); }
#[test] fn span_char_ranges_are_non_overlapping_and_ordered() { let cache = make_cache(); let hl = cache.highlight_block("rust", "let x: i32 = 42;\n").unwrap(); for w in hl[0].windows(2) { assert!( w[0].char_end <= w[1].char_start, "spans must be non-overlapping and ordered: {:?}", w ); } }
#[test] fn unknown_theme_falls_back_gracefully() { let cache = HighlightCache::new(true, "no-such-theme-ever".into(), None); let _ = cache.highlight_block("rust", "let x = 1;\n"); }
#[test] fn json_block_produces_spans() { let cache = make_cache(); assert!(cache.highlight_block("json", "{\"key\": 42}\n").is_some()); }
#[test] fn python_block_produces_spans() { let cache = make_cache(); let hl = cache .highlight_block("python", "def foo():\n pass\n") .unwrap(); assert_eq!(hl.len(), 2); }
// ---- default-syntaxes coverage guard ---- // syntect 5.x bundles the Sublime Text 3 *default* package set — the classic // Sublime packages circa ~2015. Well-supported: Rust, Python, JS, Go, Bash, // Ruby, Java, C/C++, SQL, YAML, JSON, HTML, CSS, etc. // // NOT included (post-2015 / never in ST3 defaults): // TOML, TypeScript, Kotlin, Swift, Dart, SCSS, JSX/TSX, … // // The `two-face` crate (bat's asset collection) can supply those gaps; see // issue #135 if we decide to pull it in.
#[test] fn toml_block_produces_spans() { // TOML is not in the syntect default bundle but is supplied by two-face. let cache = make_cache(); let hl = cache .highlight_block("toml", "[package]\nname = \"yame\"\nversion = \"0.1.0\"\n") .expect("toml must be recognised via the two-face extra syntax set"); assert_eq!(hl.len(), 3, "three TOML lines → three span-lists"); assert!(!hl[0].is_empty(), "section header line must produce spans"); }
#[test] fn typescript_block_produces_spans() { // TypeScript is not in the syntect default bundle but is supplied by two-face. let cache = make_cache(); assert!( cache .highlight_block("typescript", "const x: number = 1;\n") .is_some(), "typescript must be recognised via the two-face extra syntax set" ); }
#[test] fn bash_block_produces_spans() { let cache = make_cache(); assert!( cache .highlight_block("bash", "#!/bin/bash\necho hello\n") .is_some(), "bash must be recognised by the default-syntaxes bundle" ); }
#[test] fn sh_tag_produces_spans() { // Both "bash" and "sh" are common fence tags; both should resolve. let cache = make_cache(); assert!( cache.highlight_block("sh", "echo hello\n").is_some(), "sh tag must resolve to a shell syntax" ); }
#[test] fn go_block_produces_spans() { let cache = make_cache(); assert!( cache .highlight_block("go", "package main\nfunc main() {}\n") .is_some(), "go must be recognised by the default-syntaxes bundle" ); }
#[test] fn case_insensitive_lang_lookup() { let cache = make_cache(); let content = "fn main() {}\n"; assert_eq!( cache.highlight_block("rust", content).is_some(), cache.highlight_block("Rust", content).is_some(), "language lookup must be case-insensitive" ); }
#[test] fn fg_colors_are_rgb() { let cache = make_cache(); let hl = cache.highlight_block("rust", "let x = 1;\n").unwrap(); for span in &hl[0] { assert!( matches!(span.fg, Color::Rgb(_, _, _)), "all fg values must be Color::Rgb: {:?}", span.fg ); } }
// ---- palette theme builder ----
#[test] fn build_palette_theme_produces_nonempty_scopes() { let theme = Theme::default_theme(); let pt = build_palette_theme(&theme); assert!(!pt.scopes.is_empty(), "palette theme must have scope rules"); }
#[test] fn build_palette_theme_name_is_yame_palette() { let theme = Theme::default_theme(); let pt = build_palette_theme(&theme); assert_eq!(pt.name.as_deref(), Some("yame-palette")); }
// Kills: highlighting.rs `delete field foreground from ThemeSettings` // and `delete field background from ThemeSettings` in build_palette_theme. // Without these fields the syntect theme uses None for fg/bg, which means // unmatched text falls back to syntect defaults instead of the yame palette. #[test] fn build_palette_theme_sets_foreground_and_background() { let theme = Theme::default_theme(); let pt = build_palette_theme(&theme); assert!( pt.settings.foreground.is_some(), "palette theme must have foreground set in ThemeSettings" ); assert!( pt.settings.background.is_some(), "palette theme must have background set in ThemeSettings" ); }
#[test] fn palette_cache_rust_returns_spans() { let cache = make_palette_cache(); let hl = cache.highlight_block("rust", "let x = 1;\n").unwrap(); assert!( !hl[0].is_empty(), "palette cache must produce spans for rust" ); }
#[test] fn palette_cache_fg_colors_are_rgb() { let cache = make_palette_cache(); let hl = cache.highlight_block("rust", "let x = 1;\n").unwrap(); for span in &hl[0] { assert!(matches!(span.fg, Color::Rgb(_, _, _))); } }
#[test] fn palette_cache_keyword_uses_accent_color() { // "let" in Rust is a keyword → should render with accent color. let yame = Theme::default_theme(); let cache = make_palette_cache(); let hl = cache.highlight_block("rust", "let x = 1;\n").unwrap();
let accent = match yame.accent { Color::Rgb(r, g, b) => Color::Rgb(r, g, b), _ => panic!("accent must be Rgb"), };
// At least one span must use accent (the `let` keyword). assert!( hl[0].iter().any(|s| s.fg == accent), "a keyword span must use the accent colour; got: {:?}", hl[0] ); }
#[test] fn palette_cache_string_uses_code_color() { // A quoted string literal should render with code_color. let yame = Theme::default_theme(); let cache = make_palette_cache(); let hl = cache .highlight_block("rust", "let s = \"hello\";\n") .unwrap();
let code_color = match yame.code_color { Color::Rgb(r, g, b) => Color::Rgb(r, g, b), _ => panic!("code_color must be Rgb"), };
assert!( hl[0].iter().any(|s| s.fg == code_color), "a string span must use code_color; got: {:?}", hl[0] ); }
#[test] fn palette_cache_comment_uses_muted_color() { let yame = Theme::default_theme(); let cache = make_palette_cache(); let hl = cache.highlight_block("rust", "// a comment\n").unwrap();
let muted = match yame.muted { Color::Rgb(r, g, b) => Color::Rgb(r, g, b), _ => panic!("muted must be Rgb"), };
assert!( hl[0].iter().any(|s| s.fg == muted), "a comment span must use muted colour; got: {:?}", hl[0] ); }
#[test] fn palette_and_builtin_produce_different_keyword_colors() { // Palette keywords use accent (#cba6f7); base16-ocean.dark uses its own // purple (#b48ead). They must differ so we know the theme is actually wired. let content = "let x = 1;\n"; let builtin_cache = make_cache(); let palette_cache = make_palette_cache();
let builtin_hl = builtin_cache.highlight_block("rust", content).unwrap(); let palette_hl = palette_cache.highlight_block("rust", content).unwrap();
// Collect all unique fg colors from each. let builtin_colors: std::collections::HashSet<_> = builtin_hl[0].iter().map(|s| s.fg).collect(); let palette_colors: std::collections::HashSet<_> = palette_hl[0].iter().map(|s| s.fg).collect();
assert_ne!( builtin_colors, palette_colors, "palette theme and built-in theme must produce different colours" ); }
#[test] fn palette_cache_operator_uses_op_color() { // `+` in Rust has scope keyword.operator → should render with op_color, // which is blend(code_color, accent, 0.3) — distinct from both strings // and plain identifiers. use crate::config::blend_colors; let yame = Theme::default_theme(); let cache = make_palette_cache(); // A line with a clear arithmetic operator. let hl = cache.highlight_block("rust", "let z = x + y;\n").unwrap();
let op_color = match blend_colors(yame.code_color, yame.accent, 0.3) { Color::Rgb(r, g, b) => Color::Rgb(r, g, b), _ => panic!("op_color must be Rgb"), };
assert!( hl[0].iter().any(|s| s.fg == op_color), "an operator span must use op_color; got: {:?}", hl[0] ); }
// ---- cache eviction / boundedness ----
/// Regression for FEEDBACK-2 §2.3: the memo-cache must not grow without /// bound. Inserting MAX_CACHE_ENTRIES + 1 distinct blocks must leave the /// cache at or below the cap — i.e. at least one eviction must have fired. #[test] fn cache_is_bounded_by_max_entries() { let cache = make_cache(); // Each iteration uses unique content so every call is a cache miss, // producing a distinct (lang, hash) key. for i in 0..=MAX_CACHE_ENTRIES { let content = format!("let x{i} = {i};\n"); let _ = cache.highlight_block("rust", &content); } assert!( cache.cache_len() <= MAX_CACHE_ENTRIES, "cache must not exceed MAX_CACHE_ENTRIES ({MAX_CACHE_ENTRIES}) entries; \ got {}", cache.cache_len() ); }
/// Inserting exactly MAX_CACHE_ENTRIES entries must not trigger eviction /// (the cap is inclusive: we store up to MAX_CACHE_ENTRIES entries before /// evicting on the *next* insert). #[test] fn cache_at_capacity_does_not_lose_entries_prematurely() { let cache = make_cache(); // Fill to exactly the cap. for i in 0..MAX_CACHE_ENTRIES { let content = format!("let x{i} = {i};\n"); let _ = cache.highlight_block("rust", &content); } assert_eq!( cache.cache_len(), MAX_CACHE_ENTRIES, "cache filled to exactly the cap must hold MAX_CACHE_ENTRIES entries" ); }
#[test] fn op_color_is_distinct_from_string_and_keyword_colors() { // Validates that the computed op_color doesn't collapse to code_color or // accent — i.e. blend(code_color, accent, 0.3) ≠ either endpoint. use crate::config::blend_colors; let yame = Theme::default_theme(); let op_color = blend_colors(yame.code_color, yame.accent, 0.3); assert_ne!( op_color, yame.code_color, "op_color must differ from code_color (strings)" ); assert_ne!( op_color, yame.accent, "op_color must differ from accent (keywords)" ); assert_ne!( op_color, yame.text, "op_color must differ from text (identifiers)" ); }}