mdv 2.2.0

Terminal Markdown Viewer
Documentation
use crate::theme::{Color, parse_color_value};
use anyhow::{Context, Result, anyhow, bail};
use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CustomCalloutStyle {
    pub icon: Option<String>,
    pub color: Option<Color>,
}

pub(crate) fn parse_custom_callouts(input: &str) -> Result<HashMap<String, CustomCalloutStyle>> {
    let mut callouts = HashMap::new();
    let mut has_entries = false;

    for raw_entry in input.split(|ch| ch == ';' || ch == '\n') {
        let entry = raw_entry.trim();
        if entry.is_empty() {
            continue;
        }

        has_entries = true;

        let (name_raw, values_raw) = entry
            .split_once(':')
            .ok_or_else(|| anyhow!("Custom callout entry '{}' must contain ':'", entry))?;

        let name = name_raw.trim();
        if name.is_empty() {
            bail!("Custom callout entry '{}' is missing a name.", entry);
        }

        if !is_valid_callout_name(name) {
            bail!(
                "Custom callout name '{}' contains invalid characters.",
                name
            );
        }

        let values = values_raw.trim();
        if values.is_empty() {
            bail!(
                "Custom callout '{}' must define at least one of icon or color.",
                name
            );
        }

        let normalized_name = name.to_ascii_lowercase();

        let mut icon = None;
        let mut color = None;

        for (key_raw, value_raw) in parse_callout_options(values)
            .with_context(|| format!("Custom callout '{}' has invalid options.", name))?
        {
            let key_raw = key_raw.trim();
            let value_raw = value_raw.trim();

            let key = key_raw.to_ascii_lowercase();
            let value = value_raw;
            if value.is_empty() {
                bail!(
                    "Custom callout '{}' option '{}' cannot be empty.",
                    name,
                    key
                );
            }

            match key.as_str() {
                "icon" => {
                    if icon.is_some() {
                        bail!("Custom callout '{}' repeats the icon option.", name);
                    }
                    icon = Some(value.to_string());
                }
                "color" => {
                    if color.is_some() {
                        bail!("Custom callout '{}' repeats the color option.", name);
                    }
                    let parsed = parse_color_value(value).with_context(|| {
                        format!("Custom callout '{}' has invalid color '{}'.", name, value)
                    })?;
                    color = Some(parsed);
                }
                _ => {
                    bail!(
                        "Custom callout '{}' has unknown option '{}'. Expected icon or color.",
                        name,
                        key
                    );
                }
            }
        }

        if icon.is_none() && color.is_none() {
            bail!(
                "Custom callout '{}' must define at least one of icon or color.",
                name
            );
        }

        if callouts
            .insert(normalized_name.clone(), CustomCalloutStyle { icon, color })
            .is_some()
        {
            bail!("Custom callout '{}' is defined more than once.", name);
        }
    }

    if !has_entries {
        bail!("Custom callout string is empty.");
    }

    Ok(callouts)
}

fn is_valid_callout_name(name: &str) -> bool {
    name.chars()
        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
}

fn parse_callout_options(values: &str) -> Result<Vec<(String, String)>> {
    let mut options = Vec::new();
    let mut remaining = values.trim();

    while !remaining.is_empty() {
        let (key, value, rest) = parse_single_option(remaining)?;
        if key.trim().is_empty() {
            bail!("Custom callout option key cannot be empty.");
        }
        if value.trim().is_empty() {
            bail!("Custom callout option '{}' cannot be empty.", key.trim());
        }
        options.push((key.to_string(), value.to_string()));
        remaining = rest.trim_start();
        if remaining.starts_with(',') {
            remaining = remaining[1..].trim_start();
            if remaining.is_empty() {
                bail!("Custom callout contains a trailing comma.");
            }
        }
    }

    if options.is_empty() {
        bail!("Custom callout options are empty.");
    }

    Ok(options)
}

fn parse_single_option(input: &str) -> Result<(&str, &str, &str)> {
    let mut split = input.splitn(2, '=');
    let key = split
        .next()
        .ok_or_else(|| anyhow!("Custom callout option is missing a key."))?;
    let rest = split
        .next()
        .ok_or_else(|| anyhow!("Custom callout option '{}' must contain '='.", key.trim()))?;

    let (value, remaining) = split_value_and_rest(rest);
    Ok((key, value, remaining))
}

fn split_value_and_rest(input: &str) -> (&str, &str) {
    let mut idx = 0usize;
    let bytes = input.as_bytes();
    while idx < bytes.len() {
        if bytes[idx] == b',' {
            let candidate = &input[idx + 1..];
            if starts_with_option_key(candidate) {
                return (&input[..idx], &input[idx + 1..]);
            }
        }
        idx += 1;
    }
    (input, "")
}

fn starts_with_option_key(candidate: &str) -> bool {
    let trimmed = candidate.trim_start();
    trimmed
        .get(..5)
        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("icon="))
        || trimmed
            .get(..6)
            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("color="))
}

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

    #[test]
    fn parse_custom_callout_icon_only() {
        let parsed = parse_custom_callouts("custom:icon=*").expect("parse custom callout");
        let entry = parsed.get("custom").expect("custom callout present");
        assert_eq!(entry.icon.as_deref(), Some("*"));
        assert!(entry.color.is_none());
    }

    #[test]
    fn parse_custom_callout_color_only() {
        let parsed = parse_custom_callouts("note:color=ansi(42)").expect("parse custom callout");
        let entry = parsed.get("note").expect("note callout present");
        assert!(entry.icon.is_none());
        assert!(entry.color.is_some());
    }

    #[test]
    fn parse_custom_callout_multiple_entries() {
        let parsed =
            parse_custom_callouts("tip:icon=!;hint:icon=?").expect("parse custom callouts");
        assert_eq!(parsed.len(), 2);
        assert_eq!(parsed.get("tip").and_then(|v| v.icon.as_deref()), Some("!"));
        assert_eq!(
            parsed.get("hint").and_then(|v| v.icon.as_deref()),
            Some("?")
        );
    }

    #[test]
    fn parse_custom_callout_rejects_empty_input() {
        let err = parse_custom_callouts("  ").expect_err("empty input should fail");
        assert!(err.to_string().contains("empty"));
    }

    #[test]
    fn parse_custom_callout_rejects_missing_options() {
        let err = parse_custom_callouts("custom:").expect_err("missing options should fail");
        assert!(err.to_string().contains("at least one"));
    }

    #[test]
    fn parse_custom_callout_rejects_invalid_name() {
        let err = parse_custom_callouts("bad name:icon=*").expect_err("invalid name should fail");
        assert!(err.to_string().contains("invalid characters"));
    }

    #[test]
    fn parse_custom_callout_rejects_unknown_option() {
        let err =
            parse_custom_callouts("custom:shape=box").expect_err("unknown option should fail");
        assert!(err.to_string().contains("unknown option"));
    }

    #[test]
    fn parse_custom_callout_color_rgb_tuple() {
        let parsed =
            parse_custom_callouts("important:color=122,23,44").expect("parse custom callout");
        let entry = parsed.get("important").expect("callout present");
        assert!(entry.color.is_some());
    }

    #[test]
    fn parse_custom_callout_color_and_icon_with_commas() {
        let parsed =
            parse_custom_callouts("tip:color=rgb(1,2,3),icon=*").expect("parse custom callout");
        let entry = parsed.get("tip").expect("callout present");
        assert!(entry.color.is_some());
        assert_eq!(entry.icon.as_deref(), Some("*"));
    }
}