phenotyper 0.3.0

Core compiler library for the Phenotyper structural artifact definition language
// SPDX-License-Identifier: Apache-2.0
//! Parser module — Rustemo grammar integration and AST construction.
//!
//! The parser is generated by Rustemo from `phenotyper.rustemo`.
//! - The parser tables are generated into `OUT_DIR` during build.
//! - The actions file (`phenotyper_actions.rs`) is in the source tree
//!   and can be manually customized.
//!
//! # v2 Changes
//!
//! The v2 grammar uses GLR parsing to handle the `Ident ':'` ambiguity
//! between namespace scopes and phenotype definitions. The parser returns
//! a `Forest` (SPPF) which is resolved to a single AST via `get_first_tree()`.
//!
//! # Usage
//!
//! ```ignore
//! use phenotyper::parser;
//! let ast = parser::parse_pht(source, "file.pht")?;
//! ```

// Include generated parser from OUT_DIR and actions from source tree.
// The unreachable_patterns allow is needed for a harmless pattern in
// rustemo's GLR code generator output.
rustemo::rustemo_mod!(#[allow(unreachable_patterns)] pub(crate) phenotyper, "/src/parser");

#[allow(unused)]
#[rustfmt::skip]
pub mod phenotyper_actions;

use rustemo::Parser;

use crate::diagnostic::Diagnostic;
use crate::lexer::source_map::extract_pht_blocks;

/// Parse a `.pht` source string into the Rustemo-generated AST.
///
/// Uses GLR parsing. Returns the first valid parse tree, or diagnostics on failure.
pub fn parse_pht(source: &str, file: &str) -> Result<phenotyper_actions::File, Vec<Diagnostic>> {
    let parser = phenotyper::PhenotyperParser::new();
    let forest = parser
        .parse(source)
        .map_err(|e| rustemo_error_to_diags(e, file))?;

    forest
        .get_first_tree()
        .map(|tree| tree.build(&mut phenotyper::DefaultBuilder::new()))
        .ok_or_else(|| {
            vec![Diagnostic {
                severity: crate::diagnostic::Severity::Error,
                summary: "GLR parse produced no valid trees".to_string(),
                file: file.to_string(),
                line: 0,
                col: 0,
                explanation: Some(
                    "the parser could not disambiguate the input into a valid AST".to_string(),
                ),
                suggestion: None,
            }]
        })
}

/// Parse a `.md` source string by extracting `pht` blocks and parsing.
///
/// Uses the [`SourceMap`] to remap line numbers in diagnostics.
/// In v2, an implicit `.` is appended to the extracted source to close
/// the namespace scope (the markdown container makes `.` optional).
pub fn parse_md(markdown: &str, file: &str) -> Result<phenotyper_actions::File, Vec<Diagnostic>> {
    let (_blocks, source_map) = extract_pht_blocks(markdown);

    if source_map.source.is_empty() {
        return Err(vec![Diagnostic {
            severity: crate::diagnostic::Severity::Error,
            summary: "no ```pht blocks found in markdown file".to_string(),
            file: file.to_string(),
            line: 0,
            col: 0,
            explanation: Some(
                "the .md file must contain at least one fenced code block tagged 'pht'".to_string(),
            ),
            suggestion: Some("add a ```pht ... ``` block".to_string()),
        }]);
    }

    // T-217: Append implicit '.' for v2 namespace scope termination in .md containers
    let source_with_dot = format!("{}\n.", source_map.source);

    let parser = phenotyper::PhenotyperParser::new();
    let forest = parser.parse(&source_with_dot).map_err(|e| {
        let mut diags = rustemo_error_to_diags(e, file);
        // Remap line numbers via source map
        for diag in &mut diags {
            if diag.line > 0 {
                diag.line = source_map.original_line(diag.line);
            }
        }
        diags
    })?;

    forest
        .get_first_tree()
        .map(|tree| tree.build(&mut phenotyper::DefaultBuilder::new()))
        .ok_or_else(|| {
            vec![Diagnostic {
                severity: crate::diagnostic::Severity::Error,
                summary: "GLR parse produced no valid trees".to_string(),
                file: file.to_string(),
                line: 0,
                col: 0,
                explanation: Some(
                    "the parser could not disambiguate the input into a valid AST".to_string(),
                ),
                suggestion: None,
            }]
        })
}

/// Convert a Rustemo error into our Diagnostic type.
fn rustemo_error_to_diags(err: rustemo::Error, file: &str) -> Vec<Diagnostic> {
    vec![Diagnostic {
        severity: crate::diagnostic::Severity::Error,
        summary: format!("{err}"),
        file: file.to_string(),
        line: 0,
        col: 0,
        explanation: None,
        suggestion: None,
    }]
}

#[cfg(test)]
mod tests;