wp-lang 0.3.0

WPL language crate with AST, parser, evaluator, builtins, and generators.
Documentation
use std::path::PathBuf;

use wp_primitives::Parser;

use crate::parser::wpl_rule::wpl_rule;
use crate::{WplCode, error_detail, wpl_express};

use super::{Mode, ParseResult};

pub fn validate_source(
    source: &str,
    origin: Option<&str>,
    mode: Mode,
) -> Result<ParseResult, String> {
    let code = WplCode::build(PathBuf::from(origin.unwrap_or("stdin")), source)
        .map_err(|err| err.to_string())?;
    let normalized = code.get_code().as_str();
    match mode {
        Mode::Auto => validate_auto_source(normalized, origin),
        Mode::Package => code
            .parse_pkg()
            .map(ParseResult::Package)
            .map_err(|err| format!("source: parse failed in package mode\n{err}")),
        Mode::Rule => wpl_rule
            .parse(normalized)
            .map(ParseResult::Rule)
            .map_err(|err| format!("source: parse failed in rule mode\n{}", error_detail(err))),
        Mode::Expr => wpl_express
            .parse(normalized)
            .map(ParseResult::Expr)
            .map_err(|err| format!("source: parse failed in expr mode\n{}", error_detail(err))),
    }
}

pub fn validate_rule_name_usage(
    parsed: &ParseResult,
    rule_name: Option<&str>,
) -> Result<(), String> {
    if rule_name.is_none() || matches!(parsed, ParseResult::Package(_)) {
        return Ok(());
    }

    Err("--rule-name is only valid for package source".to_string())
}

pub fn source_summary(parsed: &ParseResult) -> String {
    match parsed {
        ParseResult::Package(package) => {
            format!(
                "source: ok (package {}, {} rules)",
                package.name,
                package.rules.len()
            )
        }
        ParseResult::Rule(rule) => format!("source: ok (rule {})", rule.name),
        ParseResult::Expr(expr) => format!(
            "source: ok (expression, {} groups, {} pipe steps)",
            expr.group.len(),
            expr.pipe_process.len()
        ),
    }
}

pub fn normalized_output(parsed: &ParseResult) -> String {
    match parsed {
        ParseResult::Package(package) => package.to_string(),
        ParseResult::Rule(rule) => rule.to_string(),
        ParseResult::Expr(expr) => expr.to_string(),
    }
}

fn validate_auto_source(normalized: &str, origin: Option<&str>) -> Result<ParseResult, String> {
    let attempts = match infer_mode(normalized) {
        Mode::Package => [Mode::Package, Mode::Rule, Mode::Expr],
        Mode::Rule => [Mode::Rule, Mode::Package, Mode::Expr],
        Mode::Expr => [Mode::Expr, Mode::Rule, Mode::Package],
        Mode::Auto => unreachable!("auto mode cannot remain unresolved"),
    };

    let mut first_error = None;
    for mode in attempts {
        match validate_source(normalized, origin, mode) {
            Ok(parsed) => return Ok(parsed),
            Err(err) if first_error.is_none() => first_error = Some(err),
            Err(_) => {}
        }
    }

    Err(first_error.unwrap_or_else(|| "source: parse failed in auto mode".to_string()))
}

fn infer_mode(source: &str) -> Mode {
    let trimmed = strip_leading_annotations(source).trim_start();
    if trimmed.starts_with("package") || trimmed.starts_with("#[") {
        Mode::Package
    } else if trimmed.starts_with("rule") {
        Mode::Rule
    } else {
        Mode::Expr
    }
}

fn strip_leading_annotations(source: &str) -> &str {
    let mut rest = source.trim_start();

    while let Some(after) = rest.strip_prefix("#[") {
        if let Some(offset) = find_annotation_end(after) {
            rest = after[offset..].trim_start();
        } else {
            break;
        }
    }

    rest
}

fn find_annotation_end(input: &str) -> Option<usize> {
    let mut in_string = false;
    let mut escape = false;

    for (idx, ch) in input.char_indices() {
        if in_string {
            if escape {
                escape = false;
                continue;
            }

            match ch {
                '\\' => escape = true,
                '"' => in_string = false,
                _ => {}
            }
            continue;
        }

        match ch {
            '"' => in_string = true,
            ']' => return Some(idx + ch.len_utf8()),
            _ => {}
        }
    }

    None
}

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

    #[test]
    fn test_infer_mode() {
        assert_eq!(
            infer_mode("package demo { rule x { (digit) } }"),
            Mode::Package
        );
        assert_eq!(infer_mode("rule x { (digit) }"), Mode::Rule);
        assert_eq!(infer_mode("(digit:id,chars:name)"), Mode::Expr);
    }

    #[test]
    fn test_infer_mode_skips_leading_annotations() {
        assert_eq!(
            infer_mode("#[tag(t1:\"id\")]\nrule hello { (digit:id) }"),
            Mode::Rule
        );
        assert_eq!(
            infer_mode("#[tag(t1:\"id\")]\npackage demo { rule hello { (digit:id) } }"),
            Mode::Package
        );
    }

    #[test]
    fn test_validate_auto_accepts_annotated_rule() {
        let parsed = validate_source(
            "#[tag(t1:\"id\")]\nrule hello { (digit:id) }",
            Some("annotated_rule.wpl"),
            Mode::Auto,
        )
        .unwrap();

        assert_eq!(source_summary(&parsed), "source: ok (rule hello)");
    }

    #[test]
    fn test_validate_auto_reports_rule_error_for_annotated_rule() {
        let err = validate_source(
            "#[tag(t1:\"id\")]\nrule hello { (digit:id, }",
            Some("annotated_rule_bad.wpl"),
            Mode::Auto,
        )
        .unwrap_err();

        assert!(err.contains("source: parse failed in rule mode"));
        assert!(err.contains("line 2, column 25"));
    }

    #[test]
    fn test_validate_package() {
        let parsed = validate_source(
            include_str!("../../examples/wpl-check/package_demo/rule.wpl"),
            Some("examples/wpl-check/package_demo/rule.wpl"),
            Mode::Auto,
        )
        .unwrap();

        assert_eq!(
            source_summary(&parsed),
            "source: ok (package demo, 2 rules)"
        );
    }

    #[test]
    fn test_validate_error_contains_position() {
        let err =
            validate_source("rule demo { (digit:id, }", Some("bad.wpl"), Mode::Rule).unwrap_err();
        assert!(err.contains("source: parse failed in rule mode"));
        assert!(err.contains("parse error at line"));
    }
}