mdr 0.6.0

A lightweight Markdown viewer with live reload and multiple rendering backends
use crate::core::slug::SlugGenerator;
use comrak::nodes::NodeValue;
use comrak::{Arena, Options, parse_document};

#[derive(Debug, Clone)]
pub struct TocEntry {
    pub level: u8,
    pub text: String,
    /// Read by the webview backend, which renders the TOC as HTML links; the
    /// other two backends scroll to a section index instead.
    #[cfg_attr(not(feature = "webview-backend"), allow(dead_code))]
    pub anchor: String,
}

/// Extract table of contents entries from markdown content.
pub fn extract_toc(content: &str) -> Vec<TocEntry> {
    let arena = Arena::new();
    let mut options = Options::default();
    options.extension.strikethrough = true;
    options.extension.table = true;
    options.extension.autolink = true;
    options.extension.tasklist = true;
    options.extension.footnotes = true;
    // Keep front matter out of the TOC, and stop its `---` fence from being
    // read as a setext heading (#56).
    options.extension.front_matter_delimiter = Some("---".to_string());

    let root = parse_document(&arena, content, &options);
    let mut entries = Vec::new();
    // Same walk order as `markdown::add_heading_ids`, so the anchors match (#65).
    let mut slugs = SlugGenerator::new();

    for node in root.descendants() {
        if let NodeValue::Heading(heading) = &node.data.borrow().value {
            let level = heading.level;
            let text = collect_text(node);
            let anchor = slugs.generate(&text);
            entries.push(TocEntry {
                level,
                text,
                anchor,
            });
        }
    }

    entries
}

/// Collect all text content from a node and its children.
fn collect_text<'a>(
    node: &'a comrak::arena_tree::Node<'a, std::cell::RefCell<comrak::nodes::Ast>>,
) -> String {
    let mut text = String::new();
    for child in node.descendants() {
        if let NodeValue::Text(ref t) = child.data.borrow().value {
            text.push_str(t);
        }
        if let NodeValue::Code(ref c) = child.data.borrow().value {
            text.push_str(&c.literal);
        }
    }
    text
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::slug::slugify;

    // --- slugify tests ---

    #[test]
    fn slugify_simple_text() {
        assert_eq!(slugify("Hello World"), "hello-world");
    }

    #[test]
    fn slugify_preserves_hyphens_and_underscores() {
        assert_eq!(slugify("my-heading_here"), "my-heading_here");
    }

    #[test]
    fn slugify_strips_special_characters() {
        assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
    }

    #[test]
    fn slugify_multiple_spaces_become_multiple_hyphens() {
        // Each space maps to a hyphen; hyphens are kept as-is (alphanumeric-like),
        // so multiple spaces produce multiple hyphens.
        assert_eq!(slugify("hello   world"), "hello---world");
    }

    #[test]
    fn slugify_empty_string() {
        assert_eq!(slugify(""), "");
    }

    #[test]
    fn slugify_only_special_chars() {
        assert_eq!(slugify("!@#$%"), "");
    }

    #[test]
    fn slugify_unicode_alphanumeric() {
        // Unicode alphanumeric chars are preserved (lowercased)
        let result = slugify("Café Résumé");
        assert!(result.contains("café"));
        assert!(result.contains("résumé"));
    }

    #[test]
    fn slugify_numbers() {
        assert_eq!(slugify("Chapter 1"), "chapter-1");
    }

    // --- extract_toc tests ---

    #[test]
    fn extract_toc_empty_input() {
        let entries = extract_toc("");
        assert!(entries.is_empty());
    }

    #[test]
    fn extract_toc_no_headings() {
        let entries = extract_toc("Just some paragraph text.\n\nAnother paragraph.");
        assert!(entries.is_empty());
    }

    #[test]
    fn extract_toc_single_h1() {
        let entries = extract_toc("# Hello World");
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].level, 1);
        assert_eq!(entries[0].text, "Hello World");
        assert_eq!(entries[0].anchor, "hello-world");
    }

    #[test]
    fn extract_toc_multiple_levels() {
        let md = "# Title\n## Section\n### Subsection\n#### Deep";
        let entries = extract_toc(md);
        assert_eq!(entries.len(), 4);
        assert_eq!(entries[0].level, 1);
        assert_eq!(entries[1].level, 2);
        assert_eq!(entries[2].level, 3);
        assert_eq!(entries[3].level, 4);
    }

    #[test]
    fn extract_toc_heading_with_inline_code() {
        let md = "# The `main` function";
        let entries = extract_toc(md);
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].text, "The main function");
    }

    #[test]
    fn extract_toc_heading_with_special_chars() {
        let md = "## Hello, World! (2024)";
        let entries = extract_toc(md);
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].anchor, "hello-world-2024");
    }

    #[test]
    fn extract_toc_mixed_content_and_headings() {
        let md = "Some intro text.\n\n# First\n\nParagraph here.\n\n## Second\n\nMore text.";
        let entries = extract_toc(md);
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].text, "First");
        assert_eq!(entries[1].text, "Second");
    }

    #[test]
    fn extract_toc_h5_and_h6() {
        let md = "##### Level 5\n###### Level 6";
        let entries = extract_toc(md);
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].level, 5);
        assert_eq!(entries[1].level, 6);
    }

    #[test]
    fn extract_toc_preserves_order() {
        let md = "## B\n# A\n### C";
        let entries = extract_toc(md);
        assert_eq!(entries[0].text, "B");
        assert_eq!(entries[1].text, "A");
        assert_eq!(entries[2].text, "C");
    }
}

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

    #[test]
    fn duplicate_headings_get_distinct_anchors() {
        let toc = extract_toc("## Setup\n\ntext\n\n## Setup\n\nmore");
        assert_eq!(toc.len(), 2);
        assert_eq!(toc[0].anchor, "setup");
        assert_eq!(toc[1].anchor, "setup-1");
    }

    // The HTML renderer only exists in webview builds.
    #[cfg(feature = "webview-backend")]
    #[test]
    fn toc_anchors_match_the_rendered_html_ids() {
        let md = "# Intro\n\n## Setup\n\n## Setup\n\n## Usage\n";
        let html = crate::core::markdown::parse_markdown(md);
        for entry in extract_toc(md) {
            assert!(
                html.contains(&format!("id=\"{}\"", entry.anchor)),
                "anchor {} missing from {}",
                entry.anchor,
                html
            );
        }
    }

    #[test]
    fn front_matter_does_not_produce_a_toc_entry() {
        let md = "---\ntitle: front matter\ntags: [a, b]\n---\n\n# Title\n";
        let toc = extract_toc(md);
        assert_eq!(toc.len(), 1, "{toc:?}");
        assert_eq!(toc[0].text, "Title");
    }
}