mdv 4.2.1

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

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CustomCodeBlock {
    pub icon: Option<String>,
    pub label: Option<String>,
    pub aliases: Vec<String>,
}

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

    for raw_entry in input.split([';', '\n']) {
        let entry = raw_entry.trim_start();
        if entry.is_empty() {
            continue;
        }

        has_entries = true;

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

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

        if !is_valid_code_block_name(name) {
            bail!(
                "Custom code block name '{}' contains invalid characters.",
                name
            );
        }

        let values = values_raw.trim_start();
        if values.is_empty() {
            bail!(
                "Custom code block '{}' must define at least one option.",
                name
            );
        }

        let normalized_name = name.to_ascii_lowercase();
        let mut icon = None;
        let mut label = None;
        let mut aliases = Vec::new();

        for (key_raw, value_raw) in parse_code_block_options(values)
            .with_context(|| format!("Custom code block '{}' has invalid options.", name))?
        {
            let key = key_raw.trim().to_ascii_lowercase();
            let value = value_raw;
            if value.is_empty() {
                bail!(
                    "Custom code block '{}' option '{}' cannot be empty.",
                    name,
                    key_raw
                );
            }

            match key.as_str() {
                "icon" => {
                    if icon.is_some() {
                        bail!("Custom code block '{}' repeats the icon option.", name);
                    }
                    icon = Some(value.to_string());
                }
                "label" => {
                    if label.is_some() {
                        bail!("Custom code block '{}' repeats the label option.", name);
                    }
                    label = Some(value.to_string());
                }
                "aliases" => {
                    if !aliases.is_empty() {
                        bail!("Custom code block '{}' repeats the aliases option.", name);
                    }
                    aliases = value
                        .split('|')
                        .map(|s| s.trim().to_ascii_lowercase())
                        .filter(|s| !s.is_empty())
                        .collect();
                }
                _ => {
                    bail!(
                        "Custom code block '{}' has unknown option '{}'. Expected icon, label or aliases.",
                        name,
                        key_raw
                    );
                }
            }
        }

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

        if blocks
            .insert(
                normalized_name,
                CustomCodeBlock {
                    icon,
                    label,
                    aliases,
                },
            )
            .is_some()
        {
            bail!("Custom code block '{}' is defined more than once.", name);
        }
    }

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

    Ok(blocks)
}

fn is_valid_code_block_name(name: &str) -> bool {
    name.chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '+' | '#' | '.'))
}

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

    while !remaining.is_empty() {
        let (key, rest) = split_value_and_rest(&remaining);
        if key.is_empty() {
            bail!("Empty key in custom code block options '{}'.", values);
        }

        let (value, rest) = split_value_and_rest(&rest);
        if value.is_empty() {
            bail!("Option '{}' in custom code block has no value.", key);
        }

        options.push((key, value));
        remaining = rest.trim_start_matches([',', ';', '=']).trim().to_string();
    }

    Ok(options)
}

fn split_value_and_rest(input: &str) -> (String, String) {
    let mut chars = input.chars().peekable();
    let mut value = String::new();

    while let Some(&ch) = chars.peek() {
        if ch == ',' || ch == ';' || ch == '=' {
            break;
        }
        value.push(ch);
        chars.next();
    }

    let rest: String = chars.collect();
    let rest = rest.trim_start_matches([',', ';', '=']);

    (value, rest.to_string())
}

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

    #[test]
    fn parses_icon_and_label() {
        let blocks =
            parse_custom_code_blocks("rust:icon=*,label=russst;py:icon=?,label=pyt4on").unwrap();
        assert_eq!(blocks.get("rust").unwrap().icon.as_deref(), Some("*"));
        assert_eq!(blocks.get("rust").unwrap().label.as_deref(), Some("russst"));
        assert_eq!(blocks.get("py").unwrap().icon.as_deref(), Some("?"));
        assert_eq!(blocks.get("py").unwrap().label.as_deref(), Some("pyt4on"));
    }

    #[test]
    fn parses_aliases() {
        let blocks = parse_custom_code_blocks("python:icon=*,aliases=py|py3").unwrap();
        let block = blocks.get("python").unwrap();
        assert_eq!(block.icon.as_deref(), Some("*"));
        assert_eq!(block.aliases, vec!["py", "py3"]);
    }

    #[test]
    fn parses_icon_only() {
        let blocks = parse_custom_code_blocks("rust:icon=").unwrap();
        assert_eq!(blocks.get("rust").unwrap().icon.as_deref(), Some(""));
        assert!(blocks.get("rust").unwrap().label.is_none());
    }

    #[test]
    fn parses_label_only() {
        let blocks = parse_custom_code_blocks("rust:label=RustLang").unwrap();
        assert!(blocks.get("rust").unwrap().icon.is_none());
        assert_eq!(
            blocks.get("rust").unwrap().label.as_deref(),
            Some("RustLang")
        );
    }

    #[test]
    fn preserves_trailing_spaces_in_icon_value() {
        let blocks = parse_custom_code_blocks("python:icon=*   ").unwrap();
        let icon = blocks.get("python").cloned().unwrap().icon.unwrap();
        assert_eq!(icon.as_bytes(), b"*   ", "icon={:?}", icon);
    }

    #[test]
    fn normalizes_language_name_to_lowercase() {
        let blocks = parse_custom_code_blocks("Rust:icon=").unwrap();
        assert_eq!(blocks.get("rust").unwrap().icon.as_deref(), Some(""));
    }

    #[test]
    fn rejects_empty_string() {
        assert!(parse_custom_code_blocks("").is_err());
    }

    #[test]
    fn rejects_missing_options() {
        assert!(parse_custom_code_blocks("rust:").is_err());
    }

    #[test]
    fn rejects_empty_option_value() {
        assert!(parse_custom_code_blocks("rust:icon=").is_err());
    }

    #[test]
    fn rejects_unknown_option() {
        assert!(parse_custom_code_blocks("rust:color=red").is_err());
    }

    #[test]
    fn allows_special_language_characters() {
        let blocks = parse_custom_code_blocks("c++:icon=;c#:icon=").unwrap();
        assert_eq!(blocks.get("c++").unwrap().icon.as_deref(), Some(""));
        assert_eq!(blocks.get("c#").unwrap().icon.as_deref(), Some(""));
    }
}