pasta_dsl 0.2.3

Pasta DSL - Independent DSL parser and AST definitions
Documentation
//! Parser module for Pasta DSL using the grammar.pest grammar.
//!
//! grammar.pest `file = ( file_scope | global_scene_scope )*` 仕様に完全準拠。
//! 複数の file_scope と global_scene_scope を任意順序・任意回数で正確に処理します。
//!
//! This module provides parsing functionality based on the authoritative
//! `grammar.pest` grammar specification.
//!
//! # Grammar Authority
//!
//! The `grammar.pest` file in this module is the authoritative specification
//! for Pasta DSL syntax (originally migrated from `pasta2.pest` without any
//! content changes) and must never be manually edited.
//!
//! # AST Structure (parser2-filescope-bug-fix)
//!
//! `PastaFile.items: Vec<FileItem>` でファイル内の全アイテムを記述順序で保持:
//! - `FileItem::FileAttr` - ファイルレベル属性
//! - `FileItem::GlobalWord` - ファイルレベル単語定義
//! - `FileItem::GlobalSceneScope` - グローバルシーン
//! - `FileItem::ActorScope` - アクター定義
//!
//! # Example
//!
//! ```no_run
//! use pasta_dsl::parser::{parse_str, parse_file, FileItem};
//! use std::path::Path;
//!
//! // Parse from string
//! let source = "*挨拶\n  Alice:こんにちは\n";
//! let ast = parse_str(source, "test.pasta").unwrap();
//!
//! // Iterate items directly for order-sensitive processing (recommended)
//! for item in &ast.items {
//!     match item {
//!         FileItem::FileAttr(attr) => println!("FileAttr: {}", attr.key),
//!         FileItem::GlobalWord(word) => println!("GlobalWord: {}", word.name()),
//!         FileItem::GlobalSceneScope(scene) => println!("Scene: {}", scene.name),
//!         FileItem::ActorScope(actor) => println!("Actor: {}", actor.name),
//!     }
//! }
//! ```

pub mod ast;

pub use ast::*;

use pest::Parser as PestParser;
use pest::iterators::{Pair, Pairs};
use pest_derive::Parser;
use std::path::Path;

use crate::error::ParseError;

/// Pest parser generated from grammar.pest (pasta2.pest).
///
/// This parser is automatically generated by pest_derive from the
/// grammar.pest file, which contains the authoritative Pasta DSL grammar.
#[derive(Parser)]
#[grammar = "parser/grammar.pest"]
pub struct PastaParser2;

/// Parse a Pasta script from a string using pasta2.pest grammar.
///
/// This function parses the provided source string and constructs a complete
/// AST representation following the 3-layer scope hierarchy defined in the grammar.
///
/// # Arguments
///
/// * `source` - Pasta DSL source code as a string
/// * `filename` - Filename for error reporting (does not need to exist)
///
/// # Returns
///
/// * `Ok(PastaFile)` - Successfully parsed AST
/// * `Err(ParseError)` - Parse error with location information
///
/// # Example
///
/// ```
/// use pasta_dsl::parser::{parse_str, FileItem};
///
/// let source = r#"*挨拶
///   Alice:こんにちは
/// "#;
///
/// match parse_str(source, "example.pasta") {
///     Ok(ast) => {
///         let scene_count = ast.items.iter().filter(|i| matches!(i, FileItem::GlobalSceneScope(_))).count();
///         println!("Parsed {} global scenes", scene_count);
///     }
///     Err(e) => eprintln!("Parse error: {}", e),
/// }
/// ```
pub fn parse_str(source: &str, filename: &str) -> Result<PastaFile, ParseError> {
    let pairs = PastaParser2::parse(Rule::file, source).map_err(|e| {
        let (line, column) = match e.line_col {
            pest::error::LineColLocation::Pos((l, c)) => (l, c),
            pest::error::LineColLocation::Span((l, c), _) => (l, c),
        };
        let message = format!("Parse error in {} at {}:{}: {}", filename, line, column, e);
        ParseError::SyntaxError {
            file: filename.to_string(),
            line,
            column,
            message,
        }
    })?;

    build_ast(pairs, filename, source)
}

/// Parse a Pasta script file using pasta2.pest grammar.
///
/// This function reads the file contents and delegates to `parse_str`.
///
/// # Arguments
///
/// * `path` - Path to the .pasta file
///
/// # Returns
///
/// * `Ok(PastaFile)` - Successfully parsed AST with path set to the input path
/// * `Err(ParseError)` - Parse or IO error
///
/// # Example
///
/// ```no_run
/// use pasta_dsl::parser::{parse_file, FileItem};
/// use pasta_dsl::ParseError;
/// use std::path::Path;
///
/// let ast = parse_file(Path::new("scripts/main.pasta"))?;
/// println!("File: {:?}", ast.path);
/// let scene_count = ast.items.iter().filter(|i| matches!(i, FileItem::GlobalSceneScope(_))).count();
/// println!("Global scenes: {}", scene_count);
/// # Ok::<(), ParseError>(())
/// ```
pub fn parse_file(path: &Path) -> Result<PastaFile, ParseError> {
    let source = std::fs::read_to_string(path)?;
    let filename = path.to_string_lossy();
    let mut ast = parse_str(&source, &filename)?;
    ast.path = path.to_path_buf();
    Ok(ast)
}

// ============================================================================
// AST Builder
// ============================================================================

/// Build AST from parsed pairs.
///
/// grammar.pest `file = ( file_scope | global_scene_scope | actor_scope )*` に準拠。
/// 複数の file_scope、global_scene_scope、actor_scope を任意順序で処理し、
/// 出現順序を items に保持します。
fn build_ast(pairs: Pairs<Rule>, filename: &str, source: &str) -> Result<PastaFile, ParseError> {
    let mut file = PastaFile::new(std::path::PathBuf::from(filename));
    let mut last_global_scene_name: Option<String> = None;

    // Set file span to cover the entire source
    let line_count = source.lines().count().max(1);
    let last_line_len = source.lines().last().map(|l| l.len()).unwrap_or(0);
    file.span = Span::new(1, 1, line_count, last_line_len + 1, 0, source.len());

    for pair in pairs {
        match pair.as_rule() {
            Rule::file_scope => {
                // file_scope 内の attrs と words を個別の FileItem として追加
                let scope = parse_file_scope(pair)?;
                for attr in scope.attrs {
                    file.items.push(FileItem::FileAttr(attr));
                }
                for word in scope.words {
                    file.items.push(FileItem::GlobalWord(word));
                }
            }
            Rule::global_scene_scope => {
                let scene = parse_global_scene_scope(pair, &mut last_global_scene_name, filename)?;
                file.items.push(FileItem::GlobalSceneScope(scene));
            }
            Rule::actor_scope => {
                let actor = parse_actor_scope(pair)?;
                file.items.push(FileItem::ActorScope(actor));
            }
            Rule::EOI => {}
            _ => {}
        }
    }

    Ok(file)
}

/// Parse file scope.
fn parse_file_scope(pair: Pair<Rule>) -> Result<FileScope, ParseError> {
    let mut scope = FileScope::default();

    for inner in pair.into_inner() {
        match inner.as_rule() {
            Rule::file_attr_line => {
                for attr_pair in inner.into_inner() {
                    if attr_pair.as_rule() == Rule::attr {
                        scope.attrs.push(parse_attr(attr_pair)?);
                    }
                }
            }
            Rule::file_word_line => {
                for kw_pair in inner.into_inner() {
                    if kw_pair.as_rule() == Rule::key_words {
                        scope.words.push(parse_key_words(kw_pair)?);
                    }
                }
            }
            _ => {}
        }
    }

    Ok(scope)
}

/// Parse actor scope.
///
/// grammar.pest `actor_scope = { actor_line ~ actor_scope_item* }` に対応。
/// actor_scope_item = _{ global_scene_attr_line | global_scene_word_line | var_set_line | code_scope | blank_line }
fn parse_actor_scope(pair: Pair<Rule>) -> Result<ActorScope, ParseError> {
    let span = Span::from(&pair.as_span());
    let mut name = String::new();
    let mut attrs = Vec::new();
    let mut words = Vec::new();
    let mut var_sets = Vec::new();
    let mut code_blocks = Vec::new();

    for inner in pair.into_inner() {
        match inner.as_rule() {
            Rule::actor_line => {
                // actor_line = { actor_marker ~ id ~ or_comment_eol }
                for id_pair in inner.into_inner() {
                    if id_pair.as_rule() == Rule::id {
                        name = id_pair.as_str().to_string();
                    }
                }
            }
            Rule::global_scene_attr_line => {
                for attr_pair in inner.into_inner() {
                    if attr_pair.as_rule() == Rule::attr {
                        attrs.push(parse_attr(attr_pair)?);
                    }
                }
            }
            Rule::global_scene_word_line => {
                for kw_pair in inner.into_inner() {
                    if kw_pair.as_rule() == Rule::key_words {
                        words.push(parse_key_words(kw_pair)?);
                    }
                }
            }
            Rule::var_set_local | Rule::var_set_global | Rule::var_set_none => {
                var_sets.push(parse_var_set(inner)?);
            }
            Rule::code_block => {
                code_blocks.push(parse_code_block(inner)?);
            }
            _ => {}
        }
    }

    Ok(ActorScope {
        name,
        attrs,
        words,
        var_sets,
        code_blocks,
        span,
    })
}

mod parse_scene;
mod parse_action;
mod parse_elements;

use parse_scene::*;
use parse_action::*;
use parse_elements::*;

// ============================================================================
// Tests
// ============================================================================

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

    #[test]
    fn test_normalize_number_str_half_width() {
        assert_eq!(normalize_number_str("123"), "123");
        assert_eq!(normalize_number_str("-456"), "-456");
        assert_eq!(normalize_number_str("3.14"), "3.14");
    }

    #[test]
    fn test_normalize_number_str_full_width() {
        assert_eq!(normalize_number_str("123"), "123");
        assert_eq!(normalize_number_str("-456"), "-456");
        assert_eq!(normalize_number_str("3.14"), "3.14");
    }

    #[test]
    fn test_normalize_number_str_mixed() {
        assert_eq!(normalize_number_str("123"), "123");
        assert_eq!(normalize_number_str("3.14"), "3.14");
        assert_eq!(normalize_number_str("-123"), "-123");
    }

    #[test]
    fn test_pest_parser_compiles() {
        // Verify that PastaParser2 can parse the file rule
        let result = PastaParser2::parse(Rule::file, "");
        assert!(result.is_ok());
    }
}