mant-engine 0.11.0

Structured manual and Markdown document engine used by ManT
Documentation
//! Native entry forms, names and ownership across query consumers.

#[test]
fn invocation_forms_and_aliases_agree_across_query_consumers() {
    for (section, head, form, names, rejected) in [
        (
            "OPTIONS",
            ".BI \"-n, \" -NUM",
            "-n, -NUM",
            vec!["-n"],
            "-NUM",
        ),
        (
            "OPTIONS",
            ".BI \"-n| \" -NUM",
            "-n| -NUM",
            vec!["-n"],
            "-NUM",
        ),
        (
            "OPTIONS",
            ".B \"-n, \\fI-NUM\"",
            "-n, -NUM",
            vec!["-n"],
            "-NUM",
        ),
        (
            "OPTIONS",
            ".B \"-h/--help\"",
            "-h/--help",
            vec!["-h", "--help"],
            "--missing",
        ),
        (
            "OPTIONS",
            ".BI \"-q \" -COUNT",
            "-q -COUNT",
            vec!["-q"],
            "-COUNT",
        ),
        (
            "COMMANDS",
            ".B \"create, duplicate\"",
            "create, duplicate",
            vec!["create", "duplicate"],
            "create, duplicate",
        ),
        (
            "ENVIRONMENT",
            ".B MODE=red,blue",
            "MODE=red,blue",
            vec!["MODE"],
            "blue",
        ),
    ] {
        assert_invocation_consumers(
            &format!(".TH NAMES 1\n.SH {section}\n.TP\n{head}\nOWNEDPAYLOAD.\n"),
            form,
            &names,
            rejected,
            3,
        );
    }
}

fn assert_invocation_consumers(
    source: &str,
    form: &str,
    names: &[&str],
    rejected: &str,
    source_line: u32,
) {
    use mant_protocol::{
        EntryProjection, OutlineNode, SearchCase, SearchQuery, SearchScope, SearchSyntax,
    };
    let query = mant_loader::load_roff_bytes(source.as_bytes()).unwrap();
    let index = mant_ir::SemanticIndex::build(query.document.as_ref().unwrap());
    let indexed = &index.section(&query.document.as_ref().unwrap().sections[0].id)[0];
    assert_eq!(indexed.names, names);
    assert_eq!(indexed.forms, [form]);
    let outline = mant_query::build_outline_projection(&query, EntryProjection::All, None).unwrap();
    let OutlineNode::DocumentSection { children, .. } = &outline.nodes[0] else {
        panic!("{outline:?}")
    };
    let OutlineNode::DocumentEntry {
        id,
        path,
        names: projected,
        forms,
        ..
    } = &children[0]
    else {
        panic!("{children:?}")
    };
    assert_eq!(projected, names);
    assert_eq!(forms, &[form]);
    let direct = mant_query::select_excerpt(
        &query,
        &[mant_protocol::ContentSelector::path(path.as_str())],
    )
    .unwrap();
    for alias in names {
        let explained = crate::semantic_test_read::semantic_excerpt(&query, &[alias]).unwrap();
        assert_eq!(explained.selections, direct.selections);
        assert!(mant_render::render_excerpt_text(&explained).contains("OWNEDPAYLOAD"));
    }
    assert!(crate::semantic_test_read::semantic_excerpt(&query, &[rejected]).is_err());
    for scope in [SearchScope::Visible, SearchScope::Markdown] {
        let found = mant_query::search_query(
            &query,
            &SearchQuery {
                pattern: "OWNEDPAYLOAD".into(),
                syntax: SearchSyntax::Literal,
                case: SearchCase::Sensitive,
                scope,
                word: false,
                context_lines: 0,
                offset: 0,
                limit: 10,
            },
        )
        .unwrap();
        assert_eq!(found.matches.len(), 1);
        let hit = &found.matches[0];
        assert!(
            matches!(&hit.outline.node, mant_protocol::OutlineNodeReference::DocumentEntry { id: found_id, path: found_path, .. } if found_id == id && found_path == path)
        );
        assert_eq!(hit.node_source.unwrap().line, source_line);
        assert_eq!(hit.occurrences[0].matched_text, "OWNEDPAYLOAD");
    }
}

#[test]
fn slash_alias_candidates_retain_parameter_styles_across_native_dialects() {
    for (mdoc, man, form, names, rejected) in [
        (
            "Fl n Ns / Ns Ar -NUM",
            ".BI \"-n/\" -NUM",
            "-n/-NUM",
            vec!["-n"],
            "-NUM",
        ),
        (
            "Fl n Ns Ar /-NUM",
            ".BI -n /-NUM",
            "-n/-NUM",
            vec!["-n"],
            "-NUM",
        ),
        (
            "Fl n Ns / Ns Fl -number",
            ".B \"-n/--number\"",
            "-n/--number",
            vec!["-n", "--number"],
            "-NUM",
        ),
        (
            "Fl n Ns / Ns Ar -NUM",
            ".B \"-n/\\fI-NUM\"",
            "-n/-NUM",
            vec!["-n"],
            "-NUM",
        ),
    ] {
        assert_invocation_consumers(
            &format!(
                ".Dd September 7, 2026\n.Dt NAMES 1\n.Os\n.Sh OPTIONS\n.Bl -tag -width Ds\n.It {mdoc}\nOWNEDPAYLOAD.\n.El\n"
            ),
            form,
            &names,
            rejected,
            6,
        );
        assert_invocation_consumers(
            &format!(".TH NAMES 1\n.SH OPTIONS\n.TP\n{man}\nOWNEDPAYLOAD.\n"),
            form,
            &names,
            rejected,
            3,
        );
    }
}

#[test]
fn incomplete_tag_paragraphs_preserve_visible_terms_at_eof() {
    for tail in [
        ".B --unfinished",
        ".BI \"--unfinished \" VALUE",
        ".B --first\n.TP\n.B --unfinished",
    ] {
        let query = mant_loader::load_roff_bytes(
            format!(".TH TAGS 1\n.SH OPTIONS\n.TP\n{tail}\n").as_bytes(),
        )
        .unwrap();
        for text in [
            mant_render::render_query_text(&query),
            mant_codec::encode::render_markdown(&query),
        ] {
            assert!(text.contains("--unfinished"), "{tail}: {text}");
            if tail.contains("--first") {
                assert!(text.contains("--first"), "{text}");
            }
        }
    }
}

#[test]
fn variable_subscripts_must_be_complete_authored_forms() {
    for name in ["FOO", "FOO[bar]", "FOO[0]", "$FOO[_index]"] {
        assert_names(
            &format!(".TH PROBE 1\n.SH VARIABLES\n.TP\n.B {name}\nValue.\n"),
            &[name],
            "missing",
        );
    }
    for name in [
        "FOO[bar",
        "FOO[bar]TRAILING",
        "FOO[]",
        "FOO[[bar]]",
        "FOO[a][b]",
        "FOO]",
        "FOO[bar]]",
    ] {
        let source = format!(".TH PROBE 1\n.SH VARIABLES\n.TP\n.B {name}\nValue.\n");
        let query = mant_loader::load_roff_bytes(source.as_bytes()).unwrap();
        let document = query.document.as_ref().unwrap();
        let index = mant_ir::SemanticIndex::build(document);
        assert!(
            index.section(&document.sections[0].id)[0].names.is_empty(),
            "{name}"
        );
        assert!(document.diagnostics.iter().any(|d| d.code.as_deref() == Some("manual.semantic-entry.unclassified-definition")), "{name}: {:?}", document.diagnostics);
        assert!(
            mant_render::render_query_text(&query).contains(name),
            "{name}"
        );
    }
}

#[test]
fn option_arguments_never_become_aliases() {
    for (head, names, missed) in [
        (".It Fl n Ar -NUM", vec!["-n"], "-NUM"),
        (".It Fl x Ar -1 | 1", vec!["-x"], "-1"),
        (
            ".It Fl n , Fl -number Ar NUM",
            vec!["-n", "--number"],
            "NUM",
        ),
        (".It Fl -arg Ns = Ns Ar VALUE", vec!["--arg"], "VALUE"),
    ] {
        let source = format!(
            ".Dd September 7, 2026\n.Dt PROBE 1\n.Os\n.Sh OPTIONS\n.Bl -tag -width Ds\n{head}\nDescription.\n.El\n"
        );
        assert_names(&source, &names, missed);
    }
    for head in [".BI \"-n \" -NUM", ".B -n\n.I -NUM", ".B \"-n -NUM\""] {
        assert_names(
            &format!(".TH PROBE 1\n.SH OPTIONS\n.TP\n{head}\nDescription.\n"),
            &["-n"],
            "-NUM",
        );
    }
}

fn assert_names(source: &str, names: &[&str], missed: &str) {
    let query = mant_loader::load_roff_bytes(source.as_bytes()).unwrap();
    let index = mant_ir::SemanticIndex::build(query.document.as_ref().unwrap());
    let entries = index.section(&query.document.as_ref().unwrap().sections[0].id);
    assert_eq!(entries[0].names, names, "{source}");
    for name in names {
        assert!(
            crate::semantic_test_read::semantic_excerpt(&query, &[name]).is_ok(),
            "{source}: {name}"
        );
    }
    assert!(
        crate::semantic_test_read::semantic_excerpt(&query, &[missed]).is_err(),
        "{source}: {missed}"
    );
}

#[test]
fn environment_assignment_values_are_not_alias_groups() {
    for form in ["FOO=one,two", "FOO=one|two", "\"FOO=one,two\""] {
        assert_names(
            &format!(".TH PROBE 1\n.SH ENVIRONMENT\n.TP\n.B {form}\nSet values.\n"),
            &["FOO"],
            "two",
        );
    }
    assert_names(
        ".TH PROBE 1\n.SH ENVIRONMENT\n.TP\n.B \"FOO, BAR\"\nSet values.\n",
        &["FOO", "BAR"],
        "missing",
    );
    for form in ["FOO=one, BAR=two", "FOO=one|BAR=two", "FOO=one BAR=two"] {
        let query = mant_loader::load_roff_bytes(
            format!(".TH PROBE 1\n.SH ENVIRONMENT\n.TP\n.B \"{form}\"\nSet values.\n").as_bytes(),
        )
        .unwrap();
        assert!(crate::semantic_test_read::semantic_excerpt(&query, &["BAR"]).is_err());
        assert!(crate::semantic_test_read::semantic_excerpt(&query, &["FOO"]).is_err());
        assert!(query.document.unwrap().diagnostics.iter().any(|d| d.code.as_deref() == Some("manual.semantic-entry.unclassified-definition")));
    }
}

#[test]
fn command_alias_groups_survive_styled_names_and_argument_boundaries() {
    for head in [
        ".It Ic clone , Ic copy",
        ".It clone , copy",
        ".It Ic clone Ar PATH , Ic copy Ar PATH",
        ".It Ic clone | Ic copy",
    ] {
        assert_names(
            &format!(
                ".Dd September 7, 2026\n.Dt PROBE 1\n.Os\n.Sh COMMANDS\n.Bl -tag -width Ds\n{head}\nClone repository.\n.El\n"
            ),
            &["clone", "copy"],
            "PATH",
        );
    }
    for head in [
        ".B \"clone, copy\"",
        ".BR clone \", \" copy",
        ".B \"clone|copy\"",
    ] {
        assert_names(
            &format!(".TH PROBE 1\n.SH COMMANDS\n.TP\n{head}\nClone repository.\n"),
            &["clone", "copy"],
            "PATH",
        );
    }
}

#[test]
fn visual_indentation_does_not_change_a_top_level_command_role() {
    for (open, close) in [
        ("", ""),
        (".RS 4\n", ".RE\n"),
        (".RS 0\n.RS 8\n", ".RE\n.RE\n"),
    ] {
        assert_names(
            &format!(
                ".TH PROBE 1\n.SH COMMANDS\n{open}.TP\n.B clone\nClone a repository.\n{close}"
            ),
            &["clone"],
            "missing",
        );
    }
    for (open, close) in [
        ("", ""),
        (".Bd -ragged -offset 4n\n", ".Ed\n"),
        (".Bl -bullet\n.It\n", ".El\n"),
    ] {
        assert_names(
            &format!(
                ".Dd September 7, 2026\n.Dt PROBE 1\n.Os\n.Sh COMMANDS\n{open}.Bl -tag -width Ds\n.It Ic clone\nClone a repository.\n.El\n{close}"
            ),
            &["clone"],
            "missing",
        );
    }
}