leaf-markdown-viewer 1.26.2

Terminal Markdown previewer with a GUI-like experience
use super::{test_assets, test_md_theme};
use crate::markdown::{highlight_line, parse_markdown, resolve_syntax};
use crate::theme::app_theme;
use ratatui::{
    style::Style,
    text::{Line, Span},
};
use syntect::parsing::SyntaxSet;

#[test]
fn blockquote_bold_link_preserves_link_color() {
    let (ss, theme) = test_assets();
    let src = "> text [**lien bold**](https://rivolink.mg)\n";
    let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into();
    let app_theme = app_theme();
    let theme_colors = &app_theme.markdown;

    let bq_line = &lines[0];
    let link_span = bq_line.spans.iter().find(|s| s.content.contains("lien"));
    assert!(link_span.is_some(), "should find 'lien' span");
    let span = link_span.unwrap();
    assert_eq!(
        span.style.fg,
        Some(theme_colors.link_text),
        "bold link in blockquote should preserve link_text color"
    );
}

#[test]
fn link_spans_detected_for_all_link_types() {
    let (ss, theme) = test_assets();
    let md = "\
[Simple](https://example.com/simple)

**[Bold link](https://example.com/bold)**

*[Italic link](https://example.com/italic)*

~~[Strike link](https://example.com/strike)~~

[Internal](#section)

### [Heading link](https://example.com/heading)

> [Blockquote link](https://example.com/quote)

[A](https://example.com/a) and [B](https://example.com/b)
";
    let (_, _, link_spans, _) =
        parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();

    let urls: Vec<&str> = link_spans.iter().map(|ls| ls.url.as_str()).collect();

    assert!(
        urls.contains(&"https://example.com/simple"),
        "simple link missing: {urls:?}"
    );
    assert!(
        urls.contains(&"https://example.com/bold"),
        "bold link missing: {urls:?}"
    );
    assert!(
        urls.contains(&"https://example.com/italic"),
        "italic link missing: {urls:?}"
    );
    assert!(
        urls.contains(&"https://example.com/strike"),
        "strikethrough link missing: {urls:?}"
    );
    assert!(
        urls.contains(&"#section"),
        "internal link missing: {urls:?}"
    );
    assert!(
        urls.contains(&"https://example.com/heading"),
        "heading link missing: {urls:?}"
    );
    assert!(
        urls.contains(&"https://example.com/quote"),
        "blockquote link missing: {urls:?}"
    );
    assert!(
        urls.contains(&"https://example.com/a"),
        "multi-link A missing: {urls:?}"
    );
    assert!(
        urls.contains(&"https://example.com/b"),
        "multi-link B missing: {urls:?}"
    );

    for ls in &link_spans {
        assert!(
            ls.end_col > ls.start_col,
            "link {:?} has zero width (start={} end={})",
            ls.url,
            ls.start_col,
            ls.end_col,
        );
    }
}

#[test]
fn link_spans_in_table_are_detected() {
    let (ss, theme) = test_assets();
    let md = "\
| Name | Link |
|------|------|
| Test | [example](https://example.com/table) |
";
    let (_, _, link_spans, _) =
        parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();

    let urls: Vec<&str> = link_spans.iter().map(|ls| ls.url.as_str()).collect();
    assert!(
        urls.contains(&"https://example.com/table"),
        "table link missing: {urls:?}"
    );
}

#[test]
fn highlight_line_single_match() {
    let theme = test_md_theme();
    let line_bg = theme.search_highlight_bg;
    let match_bg = theme.search_match_bg;
    let line = Line::from(vec![Span::raw("hello world")]);
    let result = highlight_line(&line, &theme, "world");
    assert_eq!(result.spans.len(), 2);
    assert_eq!(result.spans[0].content.as_ref(), "hello ");
    assert_eq!(result.spans[0].style.bg, Some(line_bg));
    assert_eq!(result.spans[1].content.as_ref(), "world");
    assert_eq!(result.spans[1].style.bg, Some(match_bg));
    assert!(result.spans[1]
        .style
        .add_modifier
        .contains(ratatui::style::Modifier::BOLD));
}

#[test]
fn highlight_line_multiple_matches() {
    let theme = test_md_theme();
    let match_bg = theme.search_match_bg;
    let line = Line::from(vec![Span::raw("abcabcabc")]);
    let result = highlight_line(&line, &theme, "abc");
    assert_eq!(result.spans.len(), 3);
    for span in &result.spans {
        assert_eq!(span.content.as_ref(), "abc");
        assert_eq!(span.style.bg, Some(match_bg));
        assert!(span
            .style
            .add_modifier
            .contains(ratatui::style::Modifier::BOLD));
    }
}

#[test]
fn highlight_line_case_insensitive() {
    let theme = test_md_theme();
    let line_bg = theme.search_highlight_bg;
    let match_bg = theme.search_match_bg;
    let line = Line::from(vec![Span::raw("Hello World")]);
    let result = highlight_line(&line, &theme, "hello");
    assert_eq!(result.spans.len(), 2);
    assert_eq!(result.spans[0].content.as_ref(), "Hello");
    assert_eq!(result.spans[0].style.bg, Some(match_bg));
    assert!(result.spans[0]
        .style
        .add_modifier
        .contains(ratatui::style::Modifier::BOLD));
    assert_eq!(result.spans[1].content.as_ref(), " World");
    assert_eq!(result.spans[1].style.bg, Some(line_bg));
    assert!(!result.spans[1]
        .style
        .add_modifier
        .contains(ratatui::style::Modifier::BOLD));
}

#[test]
fn highlight_line_cross_span() {
    let theme = test_md_theme();
    let line_bg = theme.search_highlight_bg;
    let match_bg = theme.search_match_bg;
    let bold = Style::default().add_modifier(ratatui::style::Modifier::BOLD);
    let line = Line::from(vec![Span::styled("hel", bold), Span::raw("lo world")]);
    let result = highlight_line(&line, &theme, "hello");
    assert_eq!(result.spans[0].content.as_ref(), "hel");
    assert_eq!(result.spans[0].style.bg, Some(match_bg));
    assert!(result.spans[0]
        .style
        .add_modifier
        .contains(ratatui::style::Modifier::BOLD));
    assert_eq!(result.spans[1].content.as_ref(), "lo");
    assert_eq!(result.spans[1].style.bg, Some(match_bg));
    assert!(result.spans[1]
        .style
        .add_modifier
        .contains(ratatui::style::Modifier::BOLD));
    assert_eq!(result.spans[2].content.as_ref(), " world");
    assert_eq!(result.spans[2].style.bg, Some(line_bg));
}

#[test]
fn highlight_line_no_match_returns_clone() {
    let theme = test_md_theme();
    let line = Line::from(vec![Span::raw("hello world")]);
    let result = highlight_line(&line, &theme, "xyz");
    assert_eq!(result.spans.len(), 1);
    assert_eq!(result.spans[0].content.as_ref(), "hello world");
    assert_eq!(result.spans[0].style.bg, None);
}

#[test]
fn resolve_syntax_supports_common_language_aliases() {
    let ss = SyntaxSet::load_defaults_newlines();

    assert_eq!(
        resolve_syntax("py", &ss).name,
        resolve_syntax("python", &ss).name
    );
    assert_eq!(
        resolve_syntax("cpp", &ss).name,
        resolve_syntax("c++", &ss).name
    );
    assert_eq!(resolve_syntax("json", &ss).name, "JSON");
    assert_eq!(resolve_syntax("json5", &ss).name, "JSON");
    assert_eq!(
        resolve_syntax("ps1", &ss).name,
        resolve_syntax("powershell", &ss).name
    );

    for tag in &["kotlin", "toml", "jsx", "dockerfile"] {
        assert_ne!(
            resolve_syntax(tag, &ss).name,
            "Plain Text",
            "{tag} should not fall back to Plain Text"
        );
    }
    assert_eq!(
        resolve_syntax("kt", &ss).name,
        resolve_syntax("kotlin", &ss).name
    );
    assert_eq!(
        resolve_syntax("docker", &ss).name,
        resolve_syntax("dockerfile", &ss).name
    );
    assert_eq!(
        resolve_syntax("pwsh", &ss).name,
        resolve_syntax("ps1", &ss).name
    );
}

#[test]
fn resolve_syntax_php_uses_php_source() {
    let ss = SyntaxSet::load_defaults_newlines();

    for tag in &["php", "PHP", "php3", "php4", "php5", "php7", "phtml"] {
        assert_eq!(
            resolve_syntax(tag, &ss).name,
            "PHP Source",
            "{tag} should resolve to PHP Source"
        );
    }
}

#[test]
fn php_code_block_without_open_tag_is_highlighted() {
    let (ss, theme) = test_assets();
    let md = "```php\nforeach ($map as $item) {\n    $scores[] = $item ?? 0;\n}\n```\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();

    let span_fg = |needle: &str| {
        lines
            .iter()
            .flat_map(|l| l.spans.iter())
            .find(|s| s.content.contains(needle))
            .and_then(|s| s.style.fg)
    };
    let keyword_fg = span_fg("foreach");
    let variable_fg = span_fg("scores");

    assert!(keyword_fg.is_some(), "should find 'foreach' span");
    assert!(variable_fg.is_some(), "should find 'scores' span");
    assert_ne!(
        keyword_fg, variable_fg,
        "php block without <?php should highlight keywords and variables differently"
    );
}