multilinear-parser 0.5.1

A parser for the multilinear story systems
Documentation
use std::{
    fs::{File, read_dir},
    io::{BufRead, BufReader},
    path::{Path, PathBuf},
};

use super::{AspectExpressionError, Error, MultilinearParser, NamedMultilinearInfo};

/// Represents the kind of errors that can occur during aspect file parsing.
#[derive(Error, Debug)]
pub enum AspectErrorKind {
    /// Failed to open the aspects configuration file.
    #[error("Failed to open multilinear aspect file")]
    OpeningFile,
    /// Failed to read lines from the aspects configuration file.
    #[error("Failed to read multilinear aspect file")]
    ReadingFile,
    /// Error while adding an aspect default from an expression.
    #[error("{0}")]
    AddingAspectExpression(#[source] AspectExpressionError),
}

impl AspectErrorKind {
    fn error(self, path: PathBuf) -> AspectError {
        AspectError { path, kind: self }
    }
}

/// Represents errors that can occur during aspect file parsing.
#[derive(Error, Debug)]
#[error("Error parsing aspect file \"{path}\": {kind}", path = path.display())]
pub struct AspectError {
    path: PathBuf,
    kind: AspectErrorKind,
}

/// Represents the kind of errors that can occur during directory or file parsing.
#[derive(Error, Debug)]
pub enum DirectoryOrFileErrorKind {
    /// The specified path does not exist or cannot be accessed.
    #[error("Path does not exist")]
    PathNotFound,
    /// Failed to open a multilinear definition file for reading.
    #[error("Failed to open multilinear definition file")]
    OpeningFile,
    /// A parsing error occurred within a specific file.
    #[error("Parsing error: {0}")]
    Parsing(#[source] Error),
    /// Failed to read directory contents.
    #[error("Failed to read directory")]
    ReadingDirectory,
}

impl DirectoryOrFileErrorKind {
    fn error(self, path: PathBuf) -> DirectoryOrFileError {
        DirectoryOrFileError { path, kind: self }
    }
}

/// Represents errors that can occur during directory or file parsing.
#[derive(Error, Debug)]
#[error("Error parsing \"{path}\": {kind}", path = path.display())]
pub struct DirectoryOrFileError {
    path: PathBuf,
    kind: DirectoryOrFileErrorKind,
}

/// Represents errors that can occur during extended parsing operations.
///
/// This includes filesystem errors and aspect initialization errors.
#[derive(Error, Debug)]
pub enum ExtendedError {
    /// Error while parsing directory or file.
    #[error("{0}")]
    DirectoryOrFile(
        #[source]
        #[from]
        DirectoryOrFileError,
    ),
    /// Error while parsing the aspect file.
    #[error("{0}")]
    AspectFile(
        #[source]
        #[from]
        AspectError,
    ),
}

impl MultilinearParser {
    /// Parses aspect defaults from a file without parsing story content.
    ///
    /// The format for each line is `aspect_name: default_value`.
    /// Empty lines are ignored.
    ///
    /// # Arguments
    ///
    /// - `path` - Path to the aspects file (typically with `.mla` extension)
    ///
    /// # Errors
    ///
    /// Returns `AspectError` if the file cannot be opened, read, or contains invalid data.
    pub fn parse_aspect_defaults(&mut self, path: &Path) -> Result<(), AspectError> {
        let Ok(file) = File::open(path) else {
            return Err(AspectErrorKind::OpeningFile.error(path.into()));
        };
        for line in BufReader::new(file).lines() {
            let Ok(line) = line else {
                return Err(AspectErrorKind::ReadingFile.error(path.into()));
            };

            if let Err(err) = self.add_aspect_expression(&line) {
                return Err(AspectErrorKind::AddingAspectExpression(err).error(path.into()));
            }
        }
        Ok(())
    }

    /// Recursively parses multilinear definitions from a file or directory tree.
    ///
    /// If `path` is a file, it will be parsed directly (if it has the `.mld` extension).
    /// If `path` is a directory, all `.mld` files and subdirectories will be processed recursively.
    ///
    /// # Namespace Building
    ///
    /// When traversing directories, the directory names are appended to the namespace,
    /// creating a hierarchical structure. For example, a file at `chapters/intro/scene.mld`
    /// will receive the namespace `["chapters", "intro"]` in addition to any existing namespace.
    ///
    /// # Arguments
    ///
    /// - `path` - Path to a `.mld` file or directory containing `.mld` files
    /// - `namespace` - The current namespace stack (directory names are appended during traversal)
    ///
    /// # Errors
    ///
    /// Returns `DirectoryOrFileError` of this kind:
    /// - `PathNotFound` if the path doesn't exist
    /// - `OpeningFile` if a file cannot be opened
    /// - `Parsing` if a file contains invalid data
    /// - `ReadingDirectory` if a directory cannot be read
    ///
    /// # Example
    ///
    /// ```no_run
    /// use std::path::Path;
    /// use multilinear_parser::MultilinearParser;
    ///
    /// let mut parser = MultilinearParser::default();
    /// let mut namespace = Vec::new();
    /// parser.parse_directory_or_file(Path::new("story/"), &mut namespace).unwrap();
    /// ```
    pub fn parse_directory_or_file(
        &mut self,
        path: &Path,
        namespace: &mut Vec<Box<str>>,
    ) -> Result<(), DirectoryOrFileError> {
        if !path.exists() {
            return Err(DirectoryOrFileErrorKind::PathNotFound.error(path.into()));
        }
        if path.is_file() {
            let valid_path = path.extension().is_some_and(|e| e == "mld");
            if !valid_path {
                return Ok(());
            }
            let Ok(multilinear_file) = File::open(path) else {
                return Err(DirectoryOrFileErrorKind::OpeningFile.error(path.into()));
            };
            if let Err(source) = self.parse(multilinear_file, namespace.as_ref()) {
                return Err(DirectoryOrFileErrorKind::Parsing(source).error(path.into()));
            }
            return Ok(());
        }
        let Ok(dir) = read_dir(path) else {
            return Err(DirectoryOrFileErrorKind::ReadingDirectory.error(path.into()));
        };
        for file in dir.flatten() {
            let path = file.path();
            let Some(name) = path.file_stem() else {
                continue;
            };
            let Some(name) = name.to_str() else { continue };
            namespace.push(name.into());
            let result = self.parse_directory_or_file(&path, namespace);
            namespace.pop();
            result?;
        }
        Ok(())
    }
}

/// Parses a multilinear system from a directory or file, with optional aspect initialization.
///
/// This is a convenience function that creates a parser, optionally loads aspect defaults
/// via [`parse_aspect_defaults`](MultilinearParser::parse_aspect_defaults), and then
/// processes the input path via [`parse_directory_or_file`](MultilinearParser::parse_directory_or_file).
///
/// # Arguments
///
/// - `input_path` - Path to a `.mld` file or directory to parse
/// - `aspects_path` - Optional path to an aspects file defining initial conditions
///   Each line should be in the format `aspect_name:default_value`.
///
/// # Returns
///
/// Returns the fully parsed `NamedMultilinearInfo` on success.
///
/// # Errors
///
/// Returns `ExtendedError` variants for file system errors, parsing errors, or invalid aspect definitions.
///
/// # Example
///
/// ```no_run
/// use std::path::Path;
/// use multilinear_parser::parse_multilinear_extended;
///
/// // Parse a directory tree with custom aspect default values
/// let story = parse_multilinear_extended(
///     "story/chapters/".as_ref(),
///     Some("story/aspects.mla".as_ref())
/// ).unwrap();
///
/// // Or parse a single file without aspects
/// let story = parse_multilinear_extended(
///     "story.mld".as_ref(),
///     None
/// ).unwrap();
/// ```
pub fn parse_multilinear_extended(
    input_path: &Path,
    aspects_path: Option<&Path>,
) -> Result<NamedMultilinearInfo, ExtendedError> {
    let mut multilinear_parser = MultilinearParser::default();
    if let Some(aspects_path) = &aspects_path {
        multilinear_parser.parse_aspect_defaults(aspects_path)?;
    }
    multilinear_parser.parse_directory_or_file(input_path, &mut Vec::new())?;
    Ok(multilinear_parser.into_info())
}