sokoban-elements 0.2.1

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
Documentation
// Generated code - `clippy::all` alone is not enough: the workspace lint table also
// enables pedantic, restriction and nursery lints, none of which are members of `all`.
#[allow(clippy::all, clippy::pedantic, clippy::restriction, clippy::nursery)]
mod grammar;

mod parser;

use std::error::Error;
use std::fmt::{Display, Formatter};

use parser::Parser;

/// String run-length encoding and decoding specialized for game boards and saved games.
///
/// ```sok_file_format
/// ::   Boards may be run-length encoded (RLE), e.g.,        ::
/// ::   "###----p.#" may be encoded as "3#4-p.#", and        ::
/// ::   "#-#-#-##-#-#-#" may be encoded as "2(3(#-)#)".      ::
/// ::   A row cannot be split over multiple lines.           ::
/// ::                                                        ::
/// ::   Rows may be combined on a single line by using "|"   ::
/// ::   as a row separator, e.g., "--3#|3#-#|#pb.#|5#".      ::
/// ::   A "|" at the end of a line is optional and may be    ::
/// ::   omitted.                                             ::
/// ```
///
/// and
///
/// ```sok_file_format
/// ::   Moves may be run-length encoded, e.g., "3r4U" means  ::
/// ::   "rrrUUUU", and "2(3(dr)R)" means "drdrdrRdrdrdrR".   ::
/// ::   Each line must, however, have at least one proper    ::
/// ::   non-digit character. Spaces between moves are        ::
/// ::   allowed.                                             ::
/// ```
pub(crate) struct Rle {}

impl Rle {
    pub const GROUP_START: char = '(';
    pub const GROUP_END: char = ')';
    pub const EOL: char = '|';

    /// Decodes run-length encoded string.
    ///
    /// # Arguments
    ///
    /// - `input`
    ///   - must contain at least one non-digit character
    ///   - must not end with digit
    ///   - may contain recursive groups marked by [`GROUP_START`](Rle::GROUP_START) and
    ///     [`GROUP_END`](Rle::GROUP_END)
    ///   - whitespace is not skipped, it is treated as any other character
    /// - `drop_spaces`
    ///   - if true, filters decoded output dropping any ASCII whitespace
    pub fn decode(input: &str, drop_spaces: bool) -> Result<String, RleSyntaxError> {
        Parser::parse(input, drop_spaces)
    }

    /// Simple run-length encoding for game board strings and save game strings
    ///
    /// # Arguments
    ///
    /// - `input`
    ///   - can't contain digits
    ///   - whitespace is not skipped, it is treated as any other character
    pub fn encode(input: &str) -> Result<String, RleSyntaxError> {
        if input.chars().any(|chr| chr.is_ascii_digit()) {
            return Err(RleSyntaxError(RleSyntaxErrorKind::DigitsInEncoderInput));
        }

        // C/P from https://rosettacode.org/wiki/Run-length_encoding#Rust :-)
        Ok(input
            .chars()
            .map(|c| if c == '\n' { Self::EOL } else { c })
            // wrap all values in Option::Some
            .map(Some)
            // add an Option::None onto the iterator to clean the pipeline at the end
            .chain(std::iter::once(None))
            .scan((0_usize, '\0'), |(n, c), elem| {
                match elem {
                    Some(elem) if *n == 0 || *c == elem => {
                        // the run continues or starts here
                        *n += 1;
                        *c = elem;
                        // this will not have an effect on the final string because it is empty
                        Some(String::new())
                    }
                    Some(elem) => {
                        // the run ends here
                        let run = if *n > 1 {
                            format!("{n}{c}")
                        } else {
                            format!("{c}")
                        };

                        *n = 1;
                        *c = elem;
                        Some(run)
                    }
                    None => {
                        // the string ends here
                        Some(if *n > 1 {
                            format!("{n}{c}")
                        } else {
                            format!("{c}")
                        })
                    }
                }
            })
            // concatenate together all sub results
            .collect())
    }
}

#[derive(Debug, Clone)]
enum RleSyntaxErrorKind {
    DecodingFailure { position: usize, input: String },
    DigitsInEncoderInput,
}

#[derive(Debug, Clone)]
pub(crate) struct RleSyntaxError(RleSyntaxErrorKind);

impl Display for RleSyntaxError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match &self.0 {
            RleSyntaxErrorKind::DecodingFailure { position, input } => {
                write!(
                    f,
                    "run length decoding syntax error, check that all pairs of opened parens \
                    '(' are correctly closed with ')', at {position}, in \"{input}\""
                )
            }
            RleSyntaxErrorKind::DigitsInEncoderInput => {
                write!(f, "run length encoder input can't contain any digits")
            }
        }
    }
}

impl Error for RleSyntaxError {}

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

    #[test]
    fn it_rle_encodes_strings() -> Result<(), RleSyntaxError> {
        for (input, output) in [
            ("aaaabbbbccdeee", "4a4b2cd3e"),
            ("aaaabbbbccdeeefghij", "4a4b2cd3efghij"),
            ("aaaabbbbccdeeef", "4a4b2cd3ef"),
            ("aaaabbfbbccdeee", "4a2bf2b2cd3e"),
            ("aaaabbfghijbbccdeee", "4a2bfghij2b2cd3e"),
            (" aaaaaaaaaa   ", " 10a3 "),
            ("   ", "3 "),
        ] {
            assert_eq!(Rle::encode(input)?, output);
        }
        Ok(())
    }

    #[test]
    fn when_rle_encoding_replaces_newline_with_rle_newline() -> Result<(), RleSyntaxError> {
        let input = "aaaa\nbbbbccdeee";
        let output = "4a|4b2cd3e";
        assert_eq!(Rle::encode(input)?, output);
        Ok(())
    }

    #[test]
    fn it_refuses_to_encode_strings_having_any_digits() {
        for input_string in ["sdfdfsg2dsfsdf", "0123456789"] {
            assert!(Rle::encode(input_string).is_err());
        }
    }
}