use std::borrow::Cow;
use pointbreak::highlight::TokenKind;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum DiffMode {
Light,
Dark,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum ThemePreference {
Auto,
Mode(DiffMode),
Named(String),
}
pub(super) fn parse_theme_value(value: &str) -> ThemePreference {
let value = value.trim();
if value == "auto" || value.starts_with("auto:") {
return ThemePreference::Auto;
}
match value {
"light" => ThemePreference::Mode(DiffMode::Light),
"dark" | "default" => ThemePreference::Mode(DiffMode::Dark),
other => ThemePreference::Named(other.to_string()),
}
}
#[derive(Debug)]
pub(super) struct DiffPalette {
pub(super) keyword: Cow<'static, str>,
pub(super) string: Cow<'static, str>,
pub(super) comment: Cow<'static, str>,
pub(super) number: Cow<'static, str>,
pub(super) r#type: Cow<'static, str>,
pub(super) function: Cow<'static, str>,
pub(super) constant: Cow<'static, str>,
pub(super) operator: Cow<'static, str>,
pub(super) punctuation: Cow<'static, str>,
pub(super) variable: Cow<'static, str>,
pub(super) emph_add_bg: Cow<'static, str>,
pub(super) emph_del_bg: Cow<'static, str>,
}
impl DiffPalette {
pub(super) fn sgr_for(&self, kind: TokenKind) -> &str {
match kind {
TokenKind::Keyword => &self.keyword,
TokenKind::String => &self.string,
TokenKind::Comment => &self.comment,
TokenKind::Number => &self.number,
TokenKind::Type => &self.r#type,
TokenKind::Function => &self.function,
TokenKind::Constant => &self.constant,
TokenKind::Operator => &self.operator,
TokenKind::Punctuation => &self.punctuation,
TokenKind::Variable => &self.variable,
TokenKind::Plain => "",
}
}
pub(super) fn builtin_for(mode: DiffMode) -> DiffPalette {
match mode {
DiffMode::Dark => Self::builtin_dark(),
DiffMode::Light => Self::builtin_light(),
}
}
pub(super) const fn builtin_dark() -> DiffPalette {
DiffPalette {
keyword: Cow::Borrowed("\x1b[38;2;179;136;255m"), string: Cow::Borrowed("\x1b[38;2;109;210;138m"), comment: Cow::Borrowed("\x1b[38;2;154;165;179m"), number: Cow::Borrowed("\x1b[38;2;79;208;192m"), r#type: Cow::Borrowed("\x1b[38;2;138;180;248m"), function: Cow::Borrowed("\x1b[38;2;90;169;230m"), constant: Cow::Borrowed("\x1b[38;2;240;183;90m"), operator: Cow::Borrowed("\x1b[38;2;215;221;229m"), punctuation: Cow::Borrowed("\x1b[38;2;154;165;179m"), variable: Cow::Borrowed("\x1b[38;2;215;221;229m"), emph_add_bg: Cow::Borrowed("\x1b[48;2;0;96;0m"), emph_del_bg: Cow::Borrowed("\x1b[48;2;144;16;17m"), }
}
pub(super) const fn builtin_light() -> DiffPalette {
DiffPalette {
keyword: Cow::Borrowed("\x1b[38;2;122;68;212m"), string: Cow::Borrowed("\x1b[38;2;26;127;55m"), comment: Cow::Borrowed("\x1b[38;2;76;97;115m"), number: Cow::Borrowed("\x1b[38;2;15;111;102m"), r#type: Cow::Borrowed("\x1b[38;2;9;105;218m"), function: Cow::Borrowed("\x1b[38;2;3;105;161m"), constant: Cow::Borrowed("\x1b[38;2;138;93;0m"), operator: Cow::Borrowed("\x1b[38;2;20;36;51m"), punctuation: Cow::Borrowed("\x1b[38;2;76;97;115m"), variable: Cow::Borrowed("\x1b[38;2;20;36;51m"), emph_add_bg: Cow::Borrowed("\x1b[48;2;160;239;160m"), emph_del_bg: Cow::Borrowed("\x1b[48;2;255;192;192m"), }
}
}
const REPRESENTATIVE_SCOPES: [(TokenKind, &str); 10] = [
(TokenKind::Keyword, "keyword"),
(TokenKind::String, "string"),
(TokenKind::Comment, "comment"),
(TokenKind::Number, "constant.numeric"),
(TokenKind::Type, "storage.type"),
(TokenKind::Function, "entity.name.function"),
(TokenKind::Constant, "constant"),
(TokenKind::Operator, "keyword.operator"),
(TokenKind::Punctuation, "punctuation"),
(TokenKind::Variable, "variable"),
];
fn sgr_fg_for_color(color: syntect::highlighting::Color) -> String {
match color.a {
0 if color.r < 8 => format!("\x1b[3{}m", color.r),
0 => format!("\x1b[38;5;{}m", color.r),
1 => String::new(),
_ => format!("\x1b[38;2;{};{};{}m", color.r, color.g, color.b),
}
}
impl DiffPalette {
pub(super) fn from_theme(theme: &syntect::highlighting::Theme, mode: DiffMode) -> DiffPalette {
use syntect::highlighting::Highlighter;
use syntect::parsing::Scope;
let highlighter = Highlighter::new(theme);
let sgr_for_kind = |kind: TokenKind| -> Cow<'static, str> {
let (_, scope) = REPRESENTATIVE_SCOPES
.iter()
.find(|(k, _)| *k == kind)
.expect("every non-Plain kind has a representative scope");
let scope = Scope::new(scope).expect("representative scope parses");
let style = highlighter.style_for_stack(&[scope]);
Cow::Owned(sgr_fg_for_color(style.foreground))
};
let base = DiffPalette::builtin_for(mode); DiffPalette {
keyword: sgr_for_kind(TokenKind::Keyword),
string: sgr_for_kind(TokenKind::String),
comment: sgr_for_kind(TokenKind::Comment),
number: sgr_for_kind(TokenKind::Number),
r#type: sgr_for_kind(TokenKind::Type),
function: sgr_for_kind(TokenKind::Function),
constant: sgr_for_kind(TokenKind::Constant),
operator: sgr_for_kind(TokenKind::Operator),
punctuation: sgr_for_kind(TokenKind::Punctuation),
variable: sgr_for_kind(TokenKind::Variable),
emph_add_bg: base.emph_add_bg,
emph_del_bg: base.emph_del_bg,
}
}
}
pub(super) fn detection_allowed(colored: bool, stdout_is_tty: bool, truecolor: bool) -> bool {
colored && stdout_is_tty && truecolor
}
pub(super) fn theme_by_name(name: &str) -> Option<syntect::highlighting::Theme> {
let set: two_face::theme::LazyThemeSet = two_face::theme::extra().into();
if let Some(theme) = set.get(name) {
return Some(theme.clone());
}
let canonical = two_face::theme::EmbeddedLazyThemeSet::theme_names()
.iter()
.map(|embedded| embedded.as_name())
.find(|candidate| candidate.eq_ignore_ascii_case(name))?;
set.get(canonical).cloned()
}
pub(super) fn available_theme_names() -> Vec<String> {
let mut names: Vec<String> = two_face::theme::EmbeddedLazyThemeSet::theme_names()
.iter()
.map(|name| name.as_name().to_string())
.collect();
names.sort();
names
}
pub(super) fn classify_theme_mode(theme: &syntect::highlighting::Theme) -> Option<DiffMode> {
let bg = theme.settings.background?;
if bg.a == 0 || bg.a == 1 {
return None;
}
let luma = (299 * u32::from(bg.r) + 587 * u32::from(bg.g) + 114 * u32::from(bg.b)) / 1000;
Some(if luma >= 128 {
DiffMode::Light
} else {
DiffMode::Dark
})
}
#[derive(Debug)]
pub(super) struct PaletteChoice {
pub(super) palette: DiffPalette,
pub(super) warning: Option<String>,
}
pub(super) fn detect_mode() -> Option<DiffMode> {
use terminal_colorsaurus::{QueryOptions, ThemeMode, theme_mode};
match theme_mode(QueryOptions::default()).ok()? {
ThemeMode::Dark => Some(DiffMode::Dark),
ThemeMode::Light => Some(DiffMode::Light),
}
}
pub(super) fn resolve_truecolor_palette(
selection: &ThemeSelection,
detect: impl FnOnce() -> Option<DiffMode>,
) -> Result<PaletteChoice, Box<dyn std::error::Error>> {
fn auto_choice(detected: Option<DiffMode>) -> PaletteChoice {
PaletteChoice {
palette: DiffPalette::builtin_for(detected.unwrap_or(DiffMode::Dark)),
warning: None,
}
}
match &selection.preference {
ThemePreference::Auto => Ok(auto_choice(detect())),
ThemePreference::Mode(mode) => Ok(PaletteChoice {
palette: DiffPalette::builtin_for(*mode),
warning: None,
}),
ThemePreference::Named(name) => match theme_by_name(name) {
Some(theme) => {
let mode = classify_theme_mode(&theme).unwrap_or(DiffMode::Dark);
Ok(PaletteChoice {
palette: DiffPalette::from_theme(&theme, mode),
warning: None,
})
}
None if selection.source == ThemeSource::Inherited => {
let mut choice = auto_choice(detect());
choice.warning = Some(format!(
"ignoring unknown BAT_THEME value {name:?}; using the built-in palette"
));
Ok(choice)
}
None => Err(format!(
"unknown theme {name:?}: expected auto, light, dark, default, or one of: {}",
available_theme_names().join(", ")
)
.into()),
},
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ThemeSource {
Explicit,
Inherited,
Default,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ThemeSelection {
pub(super) preference: ThemePreference,
pub(super) source: ThemeSource,
}
pub(super) fn resolve_theme_selection(
flag: Option<&str>,
shore_env: Option<&str>,
bat_env: Option<&str>,
) -> ThemeSelection {
fn pick(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
if let Some(value) = pick(flag).or(pick(shore_env)) {
return ThemeSelection {
preference: parse_theme_value(value),
source: ThemeSource::Explicit,
};
}
if let Some(value) = pick(bat_env) {
return ThemeSelection {
preference: parse_theme_value(value),
source: ThemeSource::Inherited,
};
}
ThemeSelection {
preference: ThemePreference::Auto,
source: ThemeSource::Default,
}
}
pub(super) fn theme_selection_from_env(flag: Option<&str>) -> ThemeSelection {
let shore = std::env::var("SHORE_THEME").ok();
let bat = std::env::var("BAT_THEME").ok();
resolve_theme_selection(flag, shore.as_deref(), bat.as_deref())
}
#[cfg(test)]
mod tests {
use pointbreak::highlight::TokenKind;
use super::*;
#[test]
fn builtin_dark_pins_todays_truecolor_bytes() {
let p = DiffPalette::builtin_dark();
assert_eq!(p.sgr_for(TokenKind::Keyword), "\x1b[38;2;179;136;255m");
assert_eq!(p.sgr_for(TokenKind::String), "\x1b[38;2;109;210;138m");
assert_eq!(p.sgr_for(TokenKind::Comment), "\x1b[38;2;154;165;179m");
assert_eq!(p.sgr_for(TokenKind::Number), "\x1b[38;2;79;208;192m");
assert_eq!(p.sgr_for(TokenKind::Type), "\x1b[38;2;138;180;248m");
assert_eq!(p.sgr_for(TokenKind::Function), "\x1b[38;2;90;169;230m");
assert_eq!(p.sgr_for(TokenKind::Constant), "\x1b[38;2;240;183;90m");
assert_eq!(p.sgr_for(TokenKind::Operator), "\x1b[38;2;215;221;229m");
assert_eq!(p.sgr_for(TokenKind::Punctuation), "\x1b[38;2;154;165;179m");
assert_eq!(p.sgr_for(TokenKind::Variable), "\x1b[38;2;215;221;229m");
assert_eq!(p.sgr_for(TokenKind::Plain), "");
}
#[test]
fn builtin_light_mirrors_inspector_light_tokens() {
let p = DiffPalette::builtin_light();
assert_eq!(p.sgr_for(TokenKind::Keyword), "\x1b[38;2;122;68;212m");
assert_eq!(p.sgr_for(TokenKind::String), "\x1b[38;2;26;127;55m");
assert_eq!(p.sgr_for(TokenKind::Comment), "\x1b[38;2;76;97;115m");
assert_eq!(p.sgr_for(TokenKind::Number), "\x1b[38;2;15;111;102m");
assert_eq!(p.sgr_for(TokenKind::Type), "\x1b[38;2;9;105;218m");
assert_eq!(p.sgr_for(TokenKind::Function), "\x1b[38;2;3;105;161m");
assert_eq!(p.sgr_for(TokenKind::Constant), "\x1b[38;2;138;93;0m");
assert_eq!(p.sgr_for(TokenKind::Operator), "\x1b[38;2;20;36;51m");
assert_eq!(p.sgr_for(TokenKind::Punctuation), "\x1b[38;2;76;97;115m");
assert_eq!(p.sgr_for(TokenKind::Variable), "\x1b[38;2;20;36;51m");
assert_eq!(p.sgr_for(TokenKind::Plain), "");
}
#[test]
fn builtin_emph_tints_are_deltas_constants() {
let dark = DiffPalette::builtin_dark();
assert_eq!(dark.emph_add_bg, "\x1b[48;2;0;96;0m"); assert_eq!(dark.emph_del_bg, "\x1b[48;2;144;16;17m"); let light = DiffPalette::builtin_light();
assert_eq!(light.emph_add_bg, "\x1b[48;2;160;239;160m"); assert_eq!(light.emph_del_bg, "\x1b[48;2;255;192;192m"); }
#[test]
fn builtin_for_mode_selects_the_matching_builtin() {
assert_eq!(
DiffPalette::builtin_for(DiffMode::Light).sgr_for(TokenKind::Keyword),
DiffPalette::builtin_light().sgr_for(TokenKind::Keyword)
);
assert_eq!(
DiffPalette::builtin_for(DiffMode::Dark).sgr_for(TokenKind::Keyword),
DiffPalette::builtin_dark().sgr_for(TokenKind::Keyword)
);
}
fn color(r: u8, g: u8, b: u8, a: u8) -> syntect::highlighting::Color {
syntect::highlighting::Color { r, g, b, a }
}
fn theme_with_background(
bg: Option<syntect::highlighting::Color>,
) -> syntect::highlighting::Theme {
let mut theme = syntect::highlighting::Theme::default();
theme.settings.background = bg;
theme
}
fn selection(preference: ThemePreference, source: ThemeSource) -> ThemeSelection {
ThemeSelection { preference, source }
}
#[test]
fn auto_uses_the_detector_and_falls_back_dark() {
let sel = selection(ThemePreference::Auto, ThemeSource::Default);
let light = resolve_truecolor_palette(&sel, || Some(DiffMode::Light)).unwrap();
assert_eq!(
light.palette.sgr_for(TokenKind::Keyword),
"\x1b[38;2;122;68;212m"
);
assert!(light.warning.is_none());
let dark = resolve_truecolor_palette(&sel, || None).unwrap();
assert_eq!(
dark.palette.sgr_for(TokenKind::Keyword),
"\x1b[38;2;179;136;255m"
);
}
#[test]
fn explicit_auto_detects_like_the_default() {
let sel = selection(ThemePreference::Auto, ThemeSource::Explicit);
let light = resolve_truecolor_palette(&sel, || Some(DiffMode::Light)).unwrap();
assert_eq!(
light.palette.sgr_for(TokenKind::Keyword),
"\x1b[38;2;122;68;212m"
);
}
#[test]
fn explicit_mode_never_calls_the_detector() {
let sel = selection(
ThemePreference::Mode(DiffMode::Light),
ThemeSource::Explicit,
);
let choice = resolve_truecolor_palette(&sel, || panic!("must not detect")).unwrap();
assert_eq!(
choice.palette.sgr_for(TokenKind::Keyword),
"\x1b[38;2;122;68;212m"
);
}
#[test]
fn named_theme_derives_and_never_calls_the_detector() {
let sel = selection(
ThemePreference::Named("Monokai Extended".to_string()),
ThemeSource::Explicit,
);
let choice = resolve_truecolor_palette(&sel, || panic!("must not detect")).unwrap();
assert!(
choice
.palette
.sgr_for(TokenKind::Keyword)
.starts_with("\x1b[38;2;")
);
assert_eq!(choice.palette.emph_add_bg, "\x1b[48;2;0;96;0m");
}
#[test]
fn unknown_name_errors_hard_when_explicit() {
let sel = selection(
ThemePreference::Named("no-such-theme".to_string()),
ThemeSource::Explicit,
);
let err = resolve_truecolor_palette(&sel, || None)
.unwrap_err()
.to_string();
assert!(err.contains("no-such-theme"));
assert!(err.contains("auto, light, dark, default"));
assert!(err.contains("Monokai Extended")); }
#[test]
fn unknown_name_warns_and_falls_back_when_inherited() {
let sel = selection(
ThemePreference::Named("no-such-theme".to_string()),
ThemeSource::Inherited,
);
let choice = resolve_truecolor_palette(&sel, || Some(DiffMode::Light)).unwrap();
assert_eq!(
choice.palette.sgr_for(TokenKind::Keyword),
"\x1b[38;2;122;68;212m"
);
let warning = choice.warning.expect("carries a warning");
assert!(warning.contains("BAT_THEME"));
assert!(warning.contains("no-such-theme"));
}
#[test]
fn detection_requires_color_on_tty_and_truecolor() {
assert!(detection_allowed(true, true, true));
assert!(!detection_allowed(false, true, true));
assert!(!detection_allowed(true, false, true));
assert!(!detection_allowed(true, true, false));
assert!(!detection_allowed(false, false, false));
}
#[test]
fn looks_up_embedded_themes_by_their_bat_names() {
assert!(theme_by_name("Monokai Extended").is_some());
assert!(theme_by_name("OneHalfLight").is_some());
assert!(theme_by_name("Solarized (dark)").is_some());
assert!(theme_by_name("no-such-theme").is_none());
}
#[test]
fn theme_lookup_is_case_insensitive() {
let canonical = theme_by_name("Monokai Extended").unwrap();
let lower = theme_by_name("monokai extended").unwrap();
assert_eq!(canonical.settings.background, lower.settings.background);
assert!(theme_by_name("NORD").is_some());
assert!(theme_by_name("onehalflight").is_some());
assert!(theme_by_name("no-such-theme").is_none());
}
#[test]
fn embedded_theme_names_are_unique_case_insensitively() {
let names = available_theme_names();
let mut folded: Vec<String> = names.iter().map(|n| n.to_ascii_lowercase()).collect();
folded.sort();
folded.dedup();
assert_eq!(folded.len(), names.len());
}
#[test]
fn lists_available_names_for_error_messages() {
let names = available_theme_names();
assert!(names.iter().any(|n| n == "Monokai Extended"));
assert!(names.iter().any(|n| n == "OneHalfLight"));
assert!(names.len() >= 20); }
#[test]
fn classifies_theme_mode_from_background_luminance() {
let light = theme_with_background(Some(color(0xff, 0xff, 0xff, 0xff)));
assert_eq!(classify_theme_mode(&light), Some(DiffMode::Light));
let dark = theme_with_background(Some(color(0x27, 0x28, 0x22, 0xff))); assert_eq!(classify_theme_mode(&dark), Some(DiffMode::Dark));
assert_eq!(classify_theme_mode(&theme_with_background(None)), None);
let sentinel = theme_with_background(Some(color(0, 0, 0, 0)));
assert_eq!(classify_theme_mode(&sentinel), None);
}
#[test]
fn embedded_light_and_dark_themes_classify_correctly() {
let one_half_light = theme_by_name("OneHalfLight").unwrap();
assert_eq!(classify_theme_mode(&one_half_light), Some(DiffMode::Light));
let monokai = theme_by_name("Monokai Extended").unwrap();
assert_eq!(classify_theme_mode(&monokai), Some(DiffMode::Dark));
}
#[test]
fn derives_a_palette_from_an_embedded_theme() {
let theme = theme_by_name("Monokai Extended").expect("embedded theme");
let p = DiffPalette::from_theme(&theme, DiffMode::Dark);
assert!(p.sgr_for(TokenKind::Keyword).starts_with("\x1b[38;2;"));
assert_ne!(p.sgr_for(TokenKind::Keyword), p.sgr_for(TokenKind::String));
assert_eq!(p.sgr_for(TokenKind::Plain), "");
assert_eq!(p.emph_add_bg, "\x1b[48;2;0;96;0m");
assert_eq!(p.emph_del_bg, "\x1b[48;2;144;16;17m");
}
#[test]
fn alpha_sentinels_map_to_ansi_indices_and_terminal_default() {
assert_eq!(sgr_fg_for_color(color(5, 0, 0, 0)), "\x1b[35m"); assert_eq!(sgr_fg_for_color(color(42, 0, 0, 0)), "\x1b[38;5;42m"); assert_eq!(sgr_fg_for_color(color(1, 2, 3, 1)), ""); assert_eq!(
sgr_fg_for_color(color(10, 20, 30, 255)),
"\x1b[38;2;10;20;30m"
);
}
#[test]
fn parses_keywords_and_names() {
assert_eq!(parse_theme_value("auto"), ThemePreference::Auto);
assert_eq!(
parse_theme_value("light"),
ThemePreference::Mode(DiffMode::Light)
);
assert_eq!(
parse_theme_value("dark"),
ThemePreference::Mode(DiffMode::Dark)
);
assert_eq!(
parse_theme_value("default"),
ThemePreference::Mode(DiffMode::Dark)
);
assert_eq!(parse_theme_value("auto:always"), ThemePreference::Auto);
assert_eq!(parse_theme_value("auto:system"), ThemePreference::Auto);
assert_eq!(
parse_theme_value("Monokai Extended"),
ThemePreference::Named("Monokai Extended".to_string())
);
assert_eq!(
parse_theme_value("Dark"),
ThemePreference::Named("Dark".to_string())
);
}
#[test]
fn precedence_flag_over_shore_env_over_bat_env() {
let sel = resolve_theme_selection(Some("light"), Some("dark"), Some("Nord"));
assert_eq!(sel.preference, ThemePreference::Mode(DiffMode::Light));
assert_eq!(sel.source, ThemeSource::Explicit);
let sel = resolve_theme_selection(None, Some("dark"), Some("Nord"));
assert_eq!(sel.preference, ThemePreference::Mode(DiffMode::Dark));
assert_eq!(sel.source, ThemeSource::Explicit);
let sel = resolve_theme_selection(None, None, Some("Nord"));
assert_eq!(sel.preference, ThemePreference::Named("Nord".to_string()));
assert_eq!(sel.source, ThemeSource::Inherited);
let sel = resolve_theme_selection(None, None, None);
assert_eq!(sel.preference, ThemePreference::Auto);
assert_eq!(sel.source, ThemeSource::Default);
}
#[test]
fn empty_or_blank_values_are_no_selection() {
let sel = resolve_theme_selection(None, Some(""), Some(" "));
assert_eq!(sel.preference, ThemePreference::Auto);
assert_eq!(sel.source, ThemeSource::Default);
let sel = resolve_theme_selection(None, Some(""), Some("Nord"));
assert_eq!(sel.source, ThemeSource::Inherited);
}
#[test]
fn trims_surrounding_whitespace_only() {
assert_eq!(
parse_theme_value(" light "),
ThemePreference::Mode(DiffMode::Light)
);
assert_eq!(
parse_theme_value(" Solarized (dark) "),
ThemePreference::Named("Solarized (dark)".to_string())
);
}
}