use super::model::{HexColor, ParsedPalette, ParsedRc, ThemeLayer, ThemeName, ThemeNameError};
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
#[derive(Copy, Clone, PartialEq, Eq)]
enum Section {
Base,
Dark,
Light,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DropReason {
MalformedLine,
UnknownSection(String),
UnknownKey(String),
InvalidColor { key: String, value: String },
InvalidExtendsName {
key: String,
value: String,
error: ThemeNameError,
},
}
pub fn parse_palette_file(path: &Path) -> Result<ParsedPalette> {
let content =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Ok(parse_palette_str(&content))
}
pub fn parse_palette_str(content: &str) -> ParsedPalette {
parse_palette_str_with_diagnostics(content).0
}
pub fn parse_palette_str_with_diagnostics(
content: &str,
) -> (ParsedPalette, Vec<(usize, DropReason)>) {
let mut layer = ThemeLayer::default();
let mut diags: Vec<(usize, DropReason)> = Vec::new();
for (idx, raw) in content.lines().enumerate() {
let lineno = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
continue;
}
let Some(eq_idx) = line.find('=') else {
diags.push((lineno, DropReason::MalformedLine));
continue;
};
let key = line[..eq_idx].trim();
let value = line[eq_idx + 1..].trim();
if key.is_empty() {
diags.push((lineno, DropReason::MalformedLine));
continue;
}
apply_kv(&mut layer, key, value, lineno, &mut diags);
}
(ParsedPalette { layer }, diags)
}
pub fn parse_rc_file(path: &Path) -> Result<ParsedRc> {
let content =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Ok(parse_rc_str(&content))
}
pub fn parse_rc_str(content: &str) -> ParsedRc {
parse_rc_str_with_diagnostics(content).0
}
pub fn parse_rc_str_with_diagnostics(content: &str) -> (ParsedRc, Vec<(usize, DropReason)>) {
let mut rc = ParsedRc::default();
let mut diags: Vec<(usize, DropReason)> = Vec::new();
let mut section = Section::Base;
for (idx, raw) in content.lines().enumerate() {
let lineno = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(stripped) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let original = stripped.trim();
section = match original.to_ascii_lowercase().as_str() {
"dark" => Section::Dark,
"light" => Section::Light,
_ => {
diags.push((lineno, DropReason::UnknownSection(original.to_string())));
Section::Unknown
}
};
continue;
}
let Some(eq_idx) = line.find('=') else {
diags.push((lineno, DropReason::MalformedLine));
continue;
};
let key = line[..eq_idx].trim();
let value = line[eq_idx + 1..].trim();
if key.is_empty() {
diags.push((lineno, DropReason::MalformedLine));
continue;
}
match section {
Section::Unknown => continue,
Section::Base => apply_rc_base_kv(&mut rc, key, value, lineno, &mut diags),
Section::Dark => apply_kv(&mut rc.dark, key, value, lineno, &mut diags),
Section::Light => apply_kv(&mut rc.light, key, value, lineno, &mut diags),
}
}
(rc, diags)
}
fn apply_rc_base_kv(
rc: &mut ParsedRc,
key: &str,
value: &str,
lineno: usize,
diags: &mut Vec<(usize, DropReason)>,
) {
let extends_slot = match key {
"extends" => Some(&mut rc.extends),
"extends.dark" => Some(&mut rc.extends_dark),
"extends.light" => Some(&mut rc.extends_light),
_ => None,
};
if let Some(slot) = extends_slot {
match ThemeName::parse(value) {
Ok(name) => *slot = Some(name),
Err(error) => diags.push((
lineno,
DropReason::InvalidExtendsName {
key: key.to_string(),
value: value.to_string(),
error,
},
)),
}
return;
}
apply_kv(&mut rc.base, key, value, lineno, diags);
}
fn apply_kv(
layer: &mut ThemeLayer,
key: &str,
value: &str,
lineno: usize,
diags: &mut Vec<(usize, DropReason)>,
) {
let slot: Option<&mut Option<HexColor>> = match key {
"fg" => Some(&mut layer.fg),
"bg" => Some(&mut layer.bg),
"cursor" => Some(&mut layer.cursor),
"tab_bg" => Some(&mut layer.tab_bg),
k if k.starts_with("color") => {
let suffix = &k[5..];
match suffix.parse::<usize>() {
Ok(idx) if idx < 16 => Some(&mut layer.palette[idx]),
_ => {
diags.push((lineno, DropReason::UnknownKey(key.to_string())));
return;
}
}
}
_ => None,
};
let Some(slot) = slot else {
diags.push((lineno, DropReason::UnknownKey(key.to_string())));
return;
};
match HexColor::parse(value) {
Some(c) => *slot = Some(c),
None => diags.push((
lineno,
DropReason::InvalidColor {
key: key.to_string(),
value: value.to_string(),
},
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::model::{HexColor, ThemeName};
#[test]
fn parses_simple_palette() {
let pal = parse_palette_str("fg = #112233\nbg = #aabbcc\n");
assert_eq!(pal.layer.fg, HexColor::parse("#112233"));
assert_eq!(pal.layer.bg, HexColor::parse("#aabbcc"));
}
#[test]
fn parses_tab_bg_key() {
let pal = parse_palette_str("tab_bg = #1e1e2e\n");
assert_eq!(pal.layer.tab_bg, HexColor::parse("#1e1e2e"));
}
#[test]
fn palette_ignores_sections_and_extends() {
let p = parse_palette_str(
"extends = nope\n[dark]\nfg = #111111\nextends.dark = also-nope\nbg = #222222\n",
);
assert_eq!(p.layer.fg.unwrap().as_str(), "#111111");
assert_eq!(p.layer.bg.unwrap().as_str(), "#222222");
}
#[test]
fn palette_diagnostics_report_extends_as_unknown_key() {
let (_, diags) = parse_palette_str_with_diagnostics("extends = catppuccin-mocha\n");
assert_eq!(diags, vec![(1, DropReason::UnknownKey("extends".into()))]);
}
#[test]
fn palette_diagnostics_do_not_report_section_headers() {
let (_, diags) = parse_palette_str_with_diagnostics("[dark]\nfg = #111111\n");
assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}");
}
#[test]
fn diagnostics_palette_path_produces_same_palette_as_shim() {
let input = "extends = nope\nfg = #111111\nforground = #aabbcc\nbg = bad\n";
let plain = parse_palette_str(input);
let (with_diag, diags) = parse_palette_str_with_diagnostics(input);
assert_eq!(plain, with_diag);
assert!(!diags.is_empty());
}
#[test]
fn palette_diagnostics_report_malformed_line() {
let (_, diags) =
parse_palette_str_with_diagnostics("no equals here\nfg = #111111\n= no key\n");
assert_eq!(
diags,
vec![
(1, DropReason::MalformedLine),
(3, DropReason::MalformedLine),
]
);
}
#[test]
fn parses_rc_with_per_mode_extends() {
let rc = parse_rc_str(
"extends.dark = catppuccin-mocha\nextends.light = catppuccin-latte\nfg = #ffffff\n",
);
assert_eq!(
rc.extends_dark,
Some(ThemeName::parse("catppuccin-mocha").unwrap())
);
assert_eq!(
rc.extends_light,
Some(ThemeName::parse("catppuccin-latte").unwrap())
);
assert_eq!(rc.base.fg, HexColor::parse("#ffffff"));
}
#[test]
fn rc_global_extends_still_works() {
let rc = parse_rc_str("extends = ayu\n");
assert_eq!(rc.extends, Some(ThemeName::parse("ayu").unwrap()));
}
#[test]
fn rc_drops_invalid_extends_name() {
let rc = parse_rc_str("extends = bad/name\n");
assert!(rc.extends.is_none());
}
#[test]
fn rc_duplicate_extends_last_wins() {
let rc = parse_rc_str("extends = a\nextends = b\n");
assert_eq!(rc.extends, Some(ThemeName::parse("b").unwrap()));
}
#[test]
fn rc_invalid_duplicate_does_not_clobber_prior_valid() {
let rc = parse_rc_str("extends = good\nextends = bad/name\n");
assert_eq!(rc.extends, Some(ThemeName::parse("good").unwrap()));
}
#[test]
fn rc_rejects_invalid_color() {
let rc = parse_rc_str("fg = not-a-color\n");
assert!(rc.base.fg.is_none());
}
#[test]
fn rc_unknown_keys_and_sections_dropped() {
let rc = parse_rc_str("forground = #112233\n[lite]\nfg = #aabbcc\n");
assert!(rc.base.fg.is_none());
assert!(rc.dark.fg.is_none());
assert!(rc.light.fg.is_none());
}
#[test]
fn rc_case_insensitive_sections() {
let rc = parse_rc_str("[Dark]\nfg = #112233\n");
assert_eq!(rc.dark.fg, HexColor::parse("#112233"));
}
#[test]
fn palette_keys_in_rc() {
let rc = parse_rc_str("color0 = #112233\ncolor15 = #aabbcc\n");
assert_eq!(rc.base.palette[0], HexColor::parse("#112233"));
assert_eq!(rc.base.palette[15], HexColor::parse("#aabbcc"));
}
#[test]
fn diagnostics_empty_for_clean_rc() {
let (_, diags) = parse_rc_str_with_diagnostics(
"extends = catppuccin-mocha\nfg = #ffffff\n[dark]\ncursor = #ff00ff\n",
);
assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}");
}
#[test]
fn diagnostics_report_unknown_key() {
let (_, diags) = parse_rc_str_with_diagnostics("forground = #112233\n");
assert_eq!(diags, vec![(1, DropReason::UnknownKey("forground".into()))]);
}
#[test]
fn diagnostics_report_invalid_color() {
let (_, diags) = parse_rc_str_with_diagnostics("fg = nope\n");
assert_eq!(
diags,
vec![(
1,
DropReason::InvalidColor {
key: "fg".into(),
value: "nope".into()
}
)]
);
}
#[test]
fn diagnostics_report_invalid_extends() {
let (_, diags) = parse_rc_str_with_diagnostics("extends.dark = bad/name\n");
match diags.as_slice() {
[(1, DropReason::InvalidExtendsName { key, value, .. })] => {
assert_eq!(key, "extends.dark");
assert_eq!(value, "bad/name");
}
other => panic!("unexpected diagnostics: {other:?}"),
}
}
#[test]
fn diagnostics_report_unknown_section_once() {
let (rc, diags) = parse_rc_str_with_diagnostics("\n[lite]\nfg = #112233\n");
assert_eq!(diags, vec![(2, DropReason::UnknownSection("lite".into()))]);
assert!(rc.base.fg.is_none());
assert!(rc.dark.fg.is_none());
assert!(rc.light.fg.is_none());
}
#[test]
fn diagnostics_path_produces_same_rc_as_shim() {
let input = "extends = good\n\
extends.dark = bad/name\n\
fg = #112233\n\
forground = #aabbcc\n\
[dark]\n\
cursor = #ff00ff\n\
bg = nope\n\
[lite]\n\
fg = #000000\n";
let plain = parse_rc_str(input);
let (with_diag, diags) = parse_rc_str_with_diagnostics(input);
assert_eq!(plain, with_diag);
assert!(!diags.is_empty());
}
#[test]
fn diagnostics_preserve_original_section_casing() {
let (_, diags) = parse_rc_str_with_diagnostics("[Lite]\n");
assert_eq!(diags, vec![(1, DropReason::UnknownSection("Lite".into()))]);
}
#[test]
fn diagnostics_report_malformed_line() {
let (_, diags) = parse_rc_str_with_diagnostics("no equals sign here\n= no key\n");
assert_eq!(
diags,
vec![
(1, DropReason::MalformedLine),
(2, DropReason::MalformedLine)
]
);
}
#[test]
fn diagnostics_color_out_of_range_is_unknown_key() {
let (_, diags) = parse_rc_str_with_diagnostics("color99 = #112233\n");
assert_eq!(diags, vec![(1, DropReason::UnknownKey("color99".into()))]);
}
#[test]
fn diagnostics_preserve_file_order() {
let (_, diags) =
parse_rc_str_with_diagnostics("fg = #ffffff\nforground = #112233\nbg = bad\n[lite]\n");
let kinds: Vec<_> = diags.iter().map(|(line, r)| (*line, r.clone())).collect();
assert_eq!(
kinds,
vec![
(2, DropReason::UnknownKey("forground".into())),
(
3,
DropReason::InvalidColor {
key: "bg".into(),
value: "bad".into()
}
),
(4, DropReason::UnknownSection("lite".into())),
]
);
}
#[test]
fn parent_for_picks_specific_then_global() {
let rc = parse_rc_str("extends = global\nextends.dark = darkonly\n");
use crate::theme::model::Mode;
assert_eq!(
rc.parent_for(Mode::Dark),
Some(&ThemeName::parse("darkonly").unwrap())
);
assert_eq!(
rc.parent_for(Mode::Light),
Some(&ThemeName::parse("global").unwrap())
);
}
#[test]
fn parent_for_falls_back_to_global() {
let rc = parse_rc_str("extends = global\n");
use crate::theme::model::Mode;
assert_eq!(
rc.parent_for(Mode::Dark),
Some(&ThemeName::parse("global").unwrap())
);
}
}