acdc-parser 0.10.0

`AsciiDoc` parser
Documentation
//! Manpage-specific parsing utilities for doctype=manpage documents.
//!
//! This module provides functions to derive manpage attributes from document content,
//! following the asciidoctor pattern where these attributes are set during parsing
//! (not conversion) so they're available for attribute substitution in the body.

use crate::{
    DocumentAttributes, Header, InlineNode,
    error::{Error, SourceLocation},
};

/// Parsed manpage title components.
#[derive(Debug, Clone)]
struct ManpageTitle {
    /// The program/command name (e.g., "git-commit").
    name: String,
    /// The volume number (e.g., "1", "3p", "8").
    volume: String,
}

/// Parse a manpage title in the format `name(volume)`.
///
/// Volume must be a digit optionally followed by a letter (e.g., "1", "3p", "8").
///
/// # Arguments
///
/// * `title` - The raw title text (e.g., "git-commit(1)")
///
/// # Returns
///
/// The parsed title components, or None if the format is invalid.
fn parse_manpage_title(title: &str) -> Option<ManpageTitle> {
    // Find the last '(' and matching ')'
    let title = title.trim();
    if !title.ends_with(')') {
        return None;
    }

    let open_paren = title.rfind('(')?;
    if open_paren == 0 {
        return None; // No name before the paren
    }

    let name = title[..open_paren].trim();
    if name.is_empty() {
        return None;
    }

    let volume = title[open_paren + 1..title.len() - 1].trim();
    // Validate volume: must be digit optionally followed by a letter
    match volume.chars().collect::<Vec<char>>().as_slice() {
        [first] if first.is_ascii_digit() => {} // valid,
        [first, second] if first.is_ascii_digit() && second.is_ascii_alphabetic() => {}
        _ => {
            tracing::warn!(%title, %volume, "invalid manpage volume format in title");
            return None;
        }
    }

    Some(ManpageTitle {
        name: name.into(),
        volume: volume.into(),
    })
}

/// Extract plain text from inline nodes (for title parsing).
pub(super) fn extract_plain_text(nodes: &[InlineNode]) -> String {
    let mut result = String::new();
    for node in nodes {
        match node {
            InlineNode::PlainText(text) => result.push_str(text.content),
            InlineNode::RawText(text) => result.push_str(text.content),
            InlineNode::VerbatimText(text) => result.push_str(text.content),
            InlineNode::BoldText(bold) => result.push_str(&extract_plain_text(&bold.content)),
            InlineNode::ItalicText(italic) => result.push_str(&extract_plain_text(&italic.content)),
            InlineNode::MonospaceText(mono) => result.push_str(&extract_plain_text(&mono.content)),
            InlineNode::HighlightText(highlight) => {
                result.push_str(&extract_plain_text(&highlight.content));
            }
            InlineNode::SubscriptText(sub) => result.push_str(&extract_plain_text(&sub.content)),
            InlineNode::SuperscriptText(sup) => result.push_str(&extract_plain_text(&sup.content)),
            InlineNode::CurvedQuotationText(quoted) => {
                result.push_str(&extract_plain_text(&quoted.content));
            }
            InlineNode::CurvedApostropheText(quoted) => {
                result.push_str(&extract_plain_text(&quoted.content));
            }
            // These nodes don't contribute plain text
            InlineNode::StandaloneCurvedApostrophe(_)
            | InlineNode::LineBreak(_)
            | InlineNode::InlineAnchor(_)
            | InlineNode::CalloutRef(_)
            | InlineNode::Macro(_) => {}
        }
    }
    result
}

/// Sanitize a name for use as mantitle when the title doesn't conform to name(volume) format.
///
/// Transforms the input by:
/// - Converting to lowercase
/// - Replacing non-alphanumeric characters (except `-` and `_`) with hyphens
/// - Collapsing multiple hyphens into one
/// - Trimming leading/trailing hyphens
///
/// Used primarily to sanitize filenames (without extension) for mantitle fallback.
fn sanitize_mantitle(name: &str) -> String {
    let sanitized: String = name
        .to_lowercase()
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect();

    // Collapse multiple hyphens and trim
    let mut result = String::new();
    let mut prev_hyphen = false;
    for c in sanitized.chars() {
        if c == '-' {
            if !prev_hyphen && !result.is_empty() {
                result.push(c);
            }
            prev_hyphen = true;
        } else {
            result.push(c);
            prev_hyphen = false;
        }
    }
    result.trim_end_matches('-').into()
}

/// Derive manpage attributes from the document header.
///
/// This function should be called during parsing, after the header is parsed but
/// before body blocks are processed. It sets:
/// - `mantitle`: The program name from the document title (lowercase)
/// - `manvolnum`: The volume number from the document title
///
/// Existing caller values are preserved.
///
/// When the title doesn't conform to `name(volume)` format:
/// - In strict mode: returns an error
/// - Otherwise: uses fallback values (filename without extension, volume "1")
///
/// # Arguments
///
/// * `header` - The parsed document header (may be None)
/// * `attrs` - Mutable reference to document attributes
/// * `strict` - Whether to fail on non-conforming titles
/// * `source_file` - Optional source filename (used for mantitle fallback)
///
/// # Returns
///
/// `Ok(true)` if manpage attributes were derived,
/// `Ok(false)` if no header was provided,
/// `Err` if strict mode and title doesn't conform
pub(super) fn derive_manpage_header_attrs<'a>(
    header: Option<&Header<'a>>,
    attrs: &mut DocumentAttributes<'a>,
    strict: bool,
    source_file: Option<&std::path::Path>,
) -> Result<bool, Error> {
    let Some(header) = header else {
        return Ok(false);
    };

    let title_text = extract_plain_text(header.title.as_ref());

    if let Some(manpage_title) = parse_manpage_title(&title_text) {
        // Conforming title: use parsed name and volume
        attrs.insert_text("mantitle".into(), manpage_title.name.to_lowercase().into());
        attrs.insert_text("manvolnum".into(), manpage_title.volume.into());

        tracing::debug!(
            mantitle = manpage_title.name,
            manvolnum = ?attrs.text("manvolnum"),
            "derived manpage attributes from header"
        );
    } else {
        // Non-conforming title
        if strict {
            return Err(Error::NonConformingManpageTitle(
                Box::new(SourceLocation {
                    file: source_file.map(std::path::Path::to_path_buf),
                    location: header.location.clone(),
                }),
                format!("title '{title_text}' does not match 'name(volume)' format"),
            ));
        }

        // Use fallbacks (matching asciidoctor behavior):
        // - mantitle: filename without extension (or sanitized title if no file)
        // - manvolnum: "1"
        let fallback_name = source_file
            .and_then(|p| p.file_stem())
            .and_then(|s| s.to_str())
            .unwrap_or(&title_text);

        let sanitized = sanitize_mantitle(fallback_name);

        tracing::warn!(
            ?title_text,
            ?source_file,
            mantitle = %sanitized,
            "doctype=manpage but title doesn't match name(volume) format; using filename as fallback"
        );

        attrs.insert_text("mantitle".into(), sanitized.into());
        attrs.insert_text("manvolnum".into(), "1".into());

        tracing::debug!(
            mantitle = ?attrs.text("mantitle"),
            manvolnum = "1",
            "using fallback manpage attributes for non-conforming title"
        );
    }

    if attrs.text("backend").map(crate::strip_quotes) == Some("manpage")
        && let Some(manvolnum) = attrs.text("manvolnum").map(crate::strip_quotes)
    {
        attrs.set_text("outfilesuffix".into(), format!(".{manvolnum}").into());
    }

    Ok(true)
}

/// Values read from the required first section of a manpage document.
#[derive(Debug, PartialEq)]
pub(super) struct NameSectionAttributes {
    pub(super) name: String,
    pub(super) purpose: String,
}

/// Derive manpage name metadata from the paragraph lines found by the grammar.
pub(super) fn derive_name_section_attrs<'a>(
    lines: impl IntoIterator<Item = Option<&'a str>>,
) -> Option<NameSectionAttributes> {
    let content = lines
        .into_iter()
        .flatten()
        .map(str::trim_start)
        .collect::<Vec<_>>()
        .join(" ");
    let (name, purpose) = split_name_purpose(&content)?;

    Some(NameSectionAttributes {
        name: name.to_string(),
        purpose: purpose.to_string(),
    })
}

fn split_name_purpose(content: &str) -> Option<(&str, &str)> {
    for (index, character) in content.char_indices() {
        if character != '-' {
            continue;
        }
        let name = content.get(..index)?.trim_end();
        let purpose = content.get(index + 1..)?.trim_start();
        if !name.is_empty()
            && !purpose.is_empty()
            && content.get(..index)?.ends_with(' ')
            && content.get(index + 1..)?.starts_with(' ')
        {
            return Some((name, purpose));
        }
    }

    None
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used)]
    use super::*;

    #[test]
    fn test_parse_manpage_title_simple() {
        let title = parse_manpage_title("git(1)").expect("valid manpage title");
        assert_eq!(title.name, "git");
        assert_eq!(title.volume, "1");
    }

    #[test]
    fn test_parse_manpage_title_with_hyphen() {
        let title = parse_manpage_title("git-commit(1)").expect("valid manpage title");
        assert_eq!(title.name, "git-commit");
        assert_eq!(title.volume, "1");
    }

    #[test]
    fn test_parse_manpage_title_with_letter() {
        let title = parse_manpage_title("intro(3p)").expect("valid manpage title");
        assert_eq!(title.name, "intro");
        assert_eq!(title.volume, "3p");
    }

    #[test]
    fn test_parse_manpage_title_volume_5() {
        let title = parse_manpage_title("passwd(5)").expect("valid manpage title");
        assert_eq!(title.name, "passwd");
        assert_eq!(title.volume, "5");
    }

    #[test]
    fn test_parse_manpage_title_invalid() {
        assert!(parse_manpage_title("no-volume").is_none());
        assert!(parse_manpage_title("bad()").is_none());
        assert!(parse_manpage_title("wrong(abc)").is_none());
    }

    #[test]
    fn test_derive_name_section_attrs() {
        let attrs = derive_name_section_attrs([
            Some("myprogram, myalias - a test"),
            None,
            Some(" program"),
        ])
        .expect("valid NAME paragraph");
        assert_eq!(attrs.name, "myprogram, myalias");
        assert_eq!(attrs.purpose, "a test program");
    }

    #[test]
    fn test_derive_name_section_attrs_requires_a_spaced_separator() {
        assert!(derive_name_section_attrs([Some("cmd-test")]).is_none());
        assert!(derive_name_section_attrs([Some("cmd - ")]).is_none());
    }

    #[test]
    fn test_manpage_backend_uses_volume_as_output_suffix() -> Result<(), Error> {
        let mut attrs = DocumentAttributes::default();
        assert!(attrs.set("backend".into(), "manpage".into()).is_ok());
        assert!(attrs.set("doctype".into(), "manpage".into()).is_ok());
        assert!(attrs.set("outfilesuffix".into(), ".man".into()).is_ok());
        let options = crate::Options::with_attributes(attrs.into_inputs())?;

        let parsed = crate::parse("= cmd(7)\n\n== Name\n\ncmd - test\n", &options)?;

        assert_eq!(
            parsed.document().attributes.text("outfilesuffix"),
            Some(".7")
        );
        Ok(())
    }

    #[test]
    fn test_sanitize_mantitle_simple() {
        assert_eq!(sanitize_mantitle("My Document"), "my-document");
    }

    #[test]
    fn test_sanitize_mantitle_with_special_chars() {
        assert_eq!(
            sanitize_mantitle("Upcoming breaking changes"),
            "upcoming-breaking-changes"
        );
    }

    #[test]
    fn test_sanitize_mantitle_collapses_hyphens() {
        assert_eq!(sanitize_mantitle("foo  bar   baz"), "foo-bar-baz");
    }

    #[test]
    fn test_sanitize_mantitle_trims_hyphens() {
        assert_eq!(
            sanitize_mantitle("  Leading and trailing  "),
            "leading-and-trailing"
        );
    }

    #[test]
    fn test_sanitize_mantitle_preserves_underscores() {
        assert_eq!(sanitize_mantitle("my_document_name"), "my_document_name");
    }

    #[test]
    fn test_sanitize_mantitle_mixed_chars() {
        assert_eq!(
            sanitize_mantitle("Git 3.0: Breaking Changes!"),
            "git-3-0-breaking-changes"
        );
    }
}