marustdown 0.1.0

A fast, configurable terminal markdown viewer with syntax highlighting, task toggling and keyboard link following
use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::{env, fs};

use serde::Deserialize;
use serde::de::{self, Deserializer, Visitor};
use toml::{Table, Value};

const DEFAULT: &str = include_str!("../assets/config.toml");
const ASCII: &str = include_str!("../assets/ascii.toml");
const PRESETS: [(&str, &str); 3] = [
    ("dark", include_str!("../assets/themes/dark.toml")),
    ("light", include_str!("../assets/themes/light.toml")),
    ("ansi", include_str!("../assets/themes/ansi.toml")),
];

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub layout: Layout,
    pub colors: BTreeMap<String, ColorValue>,
    pub styles: Styles<StyleSpec>,
    pub glyphs: Glyphs,
    pub keys: Keys,
}

#[derive(Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Layout {
    pub width: usize,
    pub margin: usize,
    pub center: bool,
    pub line_numbers: bool,
    pub tab_width: usize,
    pub scroll_off: usize,
    pub color: bool,
    pub icons: bool,
    pub status_bar: bool,
}

impl Layout {
    /// Content width and left margin for a terminal `cols` wide.
    pub fn fit(&self, cols: usize) -> (usize, usize) {
        let width = self.width.min(cols.saturating_sub(2 * self.margin)).max(1);
        let margin = if self.center {
            cols.saturating_sub(width) / 2
        } else {
            self.margin
        };
        (width, margin)
    }
}

#[derive(Clone, Debug, PartialEq)]
pub enum ColorValue {
    Index(u8),
    Name(String),
}

impl<'de> Deserialize<'de> for ColorValue {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        struct V;
        impl Visitor<'_> for V {
            type Value = ColorValue;
            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
                f.write_str("a color name, \"#rrggbb\", \"default\" or 0-255")
            }
            fn visit_i64<E: de::Error>(self, n: i64) -> Result<ColorValue, E> {
                u8::try_from(n)
                    .map(ColorValue::Index)
                    .map_err(|_| E::invalid_value(de::Unexpected::Signed(n), &self))
            }
            fn visit_str<E: de::Error>(self, s: &str) -> Result<ColorValue, E> {
                Ok(ColorValue::Name(s.to_owned()))
            }
        }
        d.deserialize_any(V)
    }
}

#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct StyleSpec {
    pub fg: Option<ColorValue>,
    pub bg: Option<ColorValue>,
    pub bold: bool,
    pub dim: bool,
    pub italic: bool,
    pub underline: bool,
    pub strike: bool,
    pub reverse: bool,
}

/// Declares a struct with one field per name, generic over the field type,
/// so the same list serves config specs and resolved styles or key maps.
macro_rules! fields {
    ($name:ident { $($field:ident),* $(,)? }) => {
        #[derive(Deserialize)]
        #[serde(deny_unknown_fields)]
        pub struct $name<T> { $(pub $field: T,)* }

        #[allow(dead_code)]
        impl<T> $name<T> {
            pub fn entries(&self) -> impl Iterator<Item = (&'static str, &T)> {
                [$((stringify!($field), &self.$field),)*].into_iter()
            }

            pub fn try_map<U, E>(
                self,
                mut f: impl FnMut(&'static str, T) -> Result<U, E>,
            ) -> Result<$name<U>, E> {
                Ok($name { $($field: f(stringify!($field), self.$field)?,)* })
            }
        }
    };
}

fields!(Styles {
    text,
    h1,
    h2,
    h3,
    h4,
    h5,
    h6,
    strong,
    emphasis,
    strike,
    code,
    link,
    link_icon,
    image,
    quote,
    quote_bar,
    alert_note,
    alert_tip,
    alert_important,
    alert_warning,
    alert_caution,
    bullet,
    number,
    task_done,
    task_todo,
    rule,
    code_block,
    code_border,
    code_label,
    line_number,
    table_border,
    table_header,
    syntax_keyword,
    syntax_string,
    syntax_number,
    syntax_comment,
    syntax_type,
    syntax_function,
    syntax_constant,
    syntax_operator,
    syntax_tag,
    syntax_attribute,
    syntax_inserted,
    syntax_deleted,
    cursor,
    status,
    search,
    outline_level,
    hint,
    link_selected,
});

fields!(KeyTable {
    down,
    up,
    page_down,
    page_up,
    half_down,
    half_up,
    top,
    bottom,
    next_heading,
    prev_heading,
    search,
    next_match,
    prev_match,
    toggle,
    next_link,
    prev_link,
    open,
    hints,
    back,
    copy,
    outline,
    edit,
    quit,
});

pub type Keys = KeyTable<Vec<String>>;

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Glyphs {
    pub heading: [String; 6],
    pub heading_rule: [String; 6],
    pub bullets: Vec<String>,
    pub task_done: String,
    pub task_todo: String,
    pub quote: String,
    pub link: String,
    pub image: String,
    pub rule: String,
    pub ellipsis: String,
    pub code_copy: String,
    pub code_box: [String; 6],
    pub table_box: [String; 11],
    pub alert_note: String,
    pub alert_tip: String,
    pub alert_important: String,
    pub alert_warning: String,
    pub alert_caution: String,
    pub breadcrumb: String,
    pub separator: String,
}

/// Loads `path`, or the user config at the XDG location when there is one,
/// layered over the built-in defaults.
pub fn load(path: Option<&Path>, theme: Option<&str>, no_icons: bool) -> Result<Config, String> {
    let dir = config_dir();
    let path = path.map(Path::to_path_buf).or_else(|| {
        dir.as_ref()
            .map(|d| d.join("config.toml"))
            .filter(|p| p.is_file())
    });
    let user = path.as_deref().map(read).transpose()?;
    build(user, dir.as_deref(), theme, no_icons).map_err(|e| match &path {
        Some(p) => format!("{}: {e}", p.display()),
        None => e,
    })
}

pub fn config_dir() -> Option<PathBuf> {
    let base = env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .filter(|p| p.is_absolute())
        .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
    Some(base.join("marustdown"))
}

fn build(
    user: Option<Table>,
    dir: Option<&Path>,
    theme: Option<&str>,
    no_icons: bool,
) -> Result<Config, String> {
    let setting = |section: Option<&str>, key: &str| {
        let table = user.as_ref()?;
        let table = match section {
            Some(s) => table.get(s)?.as_table()?,
            None => table,
        };
        table.get(key).cloned()
    };
    let icons = !no_icons
        && setting(Some("layout"), "icons")
            .and_then(|v| v.as_bool())
            .unwrap_or(true);
    let theme = match (theme, setting(None, "theme")) {
        (Some(t), _) => t.to_owned(),
        (None, Some(Value::String(s))) => s,
        (None, Some(_)) => return Err("theme: expected a string".into()),
        (None, None) => "dark".into(),
    };

    let mut merged = parse(DEFAULT);
    merge(&mut merged, preset(&theme, dir)?);
    if !icons {
        merge(&mut merged, parse(ASCII));
    }
    if let Some(user) = user {
        merge(&mut merged, user);
    }
    merged.remove("theme");
    let mut cfg: Config = merged.try_into().map_err(|e| e.to_string())?;
    cfg.layout.icons = icons;
    Ok(cfg)
}

fn preset(name: &str, dir: Option<&Path>) -> Result<Table, String> {
    if let Some((_, text)) = PRESETS.iter().find(|(n, _)| *n == name) {
        return Ok(parse(text));
    }
    match dir.map(|d| d.join("themes").join(format!("{name}.toml"))) {
        Some(path) if path.is_file() => read(&path),
        _ => Err(format!(
            "unknown theme {name:?}; built-in themes are dark, light and ansi"
        )),
    }
}

fn read(path: &Path) -> Result<Table, String> {
    let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
    text.parse().map_err(|e| format!("{}: {e}", path.display()))
}

fn parse(text: &str) -> Table {
    text.parse().expect("built-in config is valid TOML")
}

/// Deep merge: tables merge recursively, every other value replaces.
fn merge(base: &mut Table, over: Table) {
    for (key, value) in over {
        match (base.get_mut(&key), value) {
            (Some(Value::Table(b)), Value::Table(o)) => merge(b, o),
            (_, value) => {
                base.insert(key, value);
            }
        }
    }
}

#[cfg(test)]
pub fn defaults() -> Config {
    build(None, None, None, false).unwrap()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn user(text: &str) -> Result<Config, String> {
        build(Some(text.parse().unwrap()), None, None, false)
    }

    #[test]
    fn defaults_parse() {
        let cfg = defaults();
        assert_eq!(cfg.layout.width, 90);
        assert!(cfg.colors.contains_key("accent"));
    }

    #[test]
    fn user_values_merge_key_by_key() {
        let cfg = user("[styles.h1]\nfg = \"red\"\n[layout]\nwidth = 70").unwrap();
        assert_eq!(cfg.layout.width, 70);
        assert!(cfg.layout.center);
        assert_eq!(cfg.styles.h1.fg, Some(ColorValue::Name("red".into())));
        assert!(cfg.styles.h1.bold);
    }

    #[test]
    fn unknown_keys_are_errors() {
        assert!(user("[styles.h7]\nbold = true").is_err());
        assert!(user("[layout]\nwidht = 3").is_err());
    }

    #[test]
    fn presets_and_ascii() {
        let light = user("theme = \"light\"").unwrap();
        assert_eq!(light.colors["accent"], ColorValue::Name("#1e66f5".into()));
        assert!(user("theme = \"nope\"").is_err());
        let ascii = build(None, None, None, true).unwrap();
        assert_eq!(ascii.glyphs.task_done, "[x]");
        assert!(!ascii.layout.icons);
    }

    #[test]
    fn user_glyphs_win_over_ascii() {
        let cfg = user("[layout]\nicons = false\n[glyphs]\nrule = \"~\"").unwrap();
        assert_eq!(cfg.glyphs.rule, "~");
        assert_eq!(cfg.glyphs.task_todo, "[ ]");
    }

    #[test]
    fn fit_centers_and_clamps() {
        let l = defaults().layout;
        assert_eq!(l.fit(200), (90, 55));
        assert_eq!(l.fit(50), (46, 2));
    }
}