diehard 1.0.0

A dice roll simulator for tabletop RPGs (and other dice games)
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Michael Dippery <michael@monkey-robot.com>

//! Dice roll expression tokenizer.

use crate::die::Sides;
use std::fmt::{self, Display, Formatter};

#[derive(Debug)]
pub struct Tokenizer {
    input: String,
}

impl Tokenizer {
    /// Tokenizes the given dice roll expression.
    pub fn new(input: impl Into<String>) -> Self {
        Self {
            input: input.into(),
        }
    }

    /// A stream of tokens from the input.
    pub fn tokens(&self) -> TokenizerIterator {
        TokenizerIterator::new(&self.input)
    }
}

#[derive(Debug)]
pub struct TokenizerIterator {
    input: Vec<char>,
    index: usize,
}

impl TokenizerIterator {
    fn new(input: impl AsRef<str>) -> Self {
        Self {
            // Would be nice to just use Chars here but nth()
            // consumes the iterator and makes managing the
            // index pointer harder. Something to change in the
            // future though.
            input: input.as_ref().chars().collect(),
            index: 0,
        }
    }

    fn consume(&mut self) {
        self.index += 1;
    }

    fn peek(&mut self) -> Option<&char> {
        self.input.get(self.index)
    }

    fn tokenize_int(&mut self) -> Token {
        let mut buf = String::new();

        while let Some(c) = self.peek() {
            if !c.is_ascii_digit() {
                break;
            }

            buf.push(*c);
            self.consume();
        }

        buf.parse::<IntVal>()
            .map(Token::Int)
            .unwrap_or_else(|_| Token::String(buf))
    }

    fn tokenize_string(&mut self) -> Token {
        let mut buf = String::new();

        while let Some(c) = self.peek() {
            if *c == ' ' {
                break;
            }

            buf.push(*c);
            self.consume();
        }

        Token::String(buf)
    }

    fn tokenize_die(&mut self) -> Token {
        let mut buf = String::new();

        while let Some(c) = self.peek() {
            if !c.is_ascii_digit() {
                break;
            }

            buf.push(*c);
            self.consume();
        }

        buf.parse::<DieVal>()
            .map(Token::Die)
            .unwrap_or_else(|_| Token::String(format!("d{buf}")))
    }
}

impl Iterator for TokenizerIterator {
    type Item = Token;

    fn next(&mut self) -> Option<Self::Item> {
        while let Some(' ') = self.peek() {
            self.consume();
        }

        if let Some(c) = self.peek() {
            if *c == 'd' || *c == 'D' {
                self.consume();
                Some(self.tokenize_die())
            } else if *c == '+' {
                self.consume();
                Some(Token::Plus)
            } else if *c == '-' {
                // It would be cool to return negative integers here,
                // but I might need a push() operation to pull that off,
                // and that's going to be a pain, so let's just return
                // a minus sign and let the parser handle it.
                self.consume();
                Some(Token::Minus)
            } else if c.is_ascii_digit() {
                Some(self.tokenize_int())
            } else {
                Some(self.tokenize_string())
            }
        } else {
            None
        }
    }
}

/// An integer value.
pub type IntVal = i64;

/// The number of sides in a die.
pub type DieVal = Sides;

/// A token in the dice expression language
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Token {
    /// A dice (e.g., "d20") with an integer representing the number of sides.
    Die(DieVal),

    /// An integer.
    Int(IntVal),

    /// A plus sign ("+").
    Plus,

    /// A minus sign ("-").
    Minus,

    /// A string value, which is probably bad input.
    String(String),

    /// End of expression
    End,
}

impl Token {
    /// True if the token is a die.
    pub fn is_die(&self) -> bool {
        matches!(self, Self::Die(_))
    }

    /// True if the token is an integer.
    pub fn is_int(&self) -> bool {
        matches!(self, Self::Int(_))
    }

    /// True if the token is a plus ("+") sign.
    pub fn is_plus(&self) -> bool {
        matches!(self, Self::Plus)
    }

    /// True if the token is a minus ("-") sign.
    pub fn is_minus(&self) -> bool {
        matches!(self, Self::Minus)
    }

    /// True if the token represents the end of the expression stream.
    pub fn is_end(&self) -> bool {
        matches!(self, Self::End)
    }

    /// True if the token is an empty string.
    pub fn is_empty(&self) -> bool {
        match self {
            Token::String(s) => s.is_empty(),
            _ => false,
        }
    }
}

impl Display for Token {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        match self {
            Self::Die(sides) => write!(f, "d{sides}"),
            Self::Int(n) => write!(f, "{n}"),
            Self::Plus => write!(f, "+"),
            Self::Minus => write!(f, "-"),
            Self::String(s) => write!(f, "{s}"),
            Self::End => write!(f, "<END>"),
        }
    }
}

#[cfg(test)]
mod tests {
    mod tokenizer {
        // TODO: Hypothesis tests

        use super::super::{Token, Tokenizer};

        macro_rules! str_token {
            ($str:literal) => {
                Token::String(String::from($str))
            };
        }

        fn tokens(expr: impl Into<String>) -> Vec<Token> {
            Tokenizer::new(expr).tokens().collect()
        }

        #[test]
        fn tokenizes_an_integer() {
            assert_eq!(vec![Token::Int(4)], tokens("4"));
        }

        #[test]
        fn tokenizes_a_negative_integer() {
            // Ideally this would return Int(-4), but that's going to be
            // a pain to implement, so let's just return a Token::Minus
            // and a positive int and let the parser handle it for now.
            //assert_eq!(vec![Token::Int(-4)], tokens("-4"));
            assert_eq!(vec![Token::Minus, Token::Int(4)], tokens("-4"));
        }

        #[test]
        fn tokenizes_a_die_with_no_quantity() {
            assert_eq!(vec![Token::Die(20)], tokens("d20"));
        }

        #[test]
        fn tokenizes_a_die_with_a_quantity() {
            assert_eq!(vec![Token::Int(2), Token::Die(20)], tokens("2d20"),);
        }

        #[test]
        fn tokenizes_a_die_with_a_modifier() {
            assert_eq!(
                vec![Token::Die(20), Token::Plus, Token::Int(4)],
                tokens("d20 + 4"),
            )
        }

        #[test]
        fn tokenizes_a_die_with_a_modifier_with_no_spaces() {
            assert_eq!(
                vec![Token::Die(20), Token::Plus, Token::Int(4)],
                tokens("d20+4"),
            )
        }

        #[test]
        fn tokenizes_dice_with_a_modifier() {
            assert_eq!(
                vec![Token::Int(2), Token::Die(20), Token::Plus, Token::Int(4)],
                tokens("2d20 + 4"),
            )
        }

        #[test]
        fn tokenizes_dice_with_a_modifier_with_no_spaces() {
            assert_eq!(
                vec![Token::Int(2), Token::Die(20), Token::Plus, Token::Int(4)],
                tokens("2d20+4"),
            )
        }

        #[test]
        fn tokenizes_dice_with_a_negative_modifier() {
            assert_eq!(
                vec![Token::Int(2), Token::Die(20), Token::Minus, Token::Int(4)],
                tokens("2d20 - 4"),
            )
        }

        #[test]
        fn tokenizes_dice_with_a_negative_modifier_with_no_spaces() {
            assert_eq!(
                vec![Token::Int(2), Token::Die(20), Token::Minus, Token::Int(4)],
                tokens("2d20-4"),
            )
        }

        #[test]
        fn tokenizes_a_random_string() {
            assert_eq!(vec![str_token!("aaabbbcccddd")], tokens("aaabbbcccddd"));
        }

        #[test]
        fn tokenizes_multiple_random_strings() {
            assert_eq!(
                vec![str_token!("aaabbbcccddd"), str_token!("xxxyyyzzz")],
                tokens("aaabbbcccddd xxxyyyzzz")
            );
        }

        #[test]
        fn tokenizes_a_random_string_on_the_end() {
            assert_eq!(
                vec![
                    Token::Int(2),
                    Token::Die(20),
                    Token::Plus,
                    str_token!("abcd")
                ],
                tokens("2d20 +abcd")
            );
        }

        #[test]
        fn tokenizes_a_random_string_on_at_the_start() {
            assert_eq!(
                vec![
                    str_token!("abcd"),
                    Token::Plus,
                    Token::Int(2),
                    Token::Die(20),
                ],
                tokens("abcd +2d20")
            );
        }

        #[test]
        fn tokenizes_an_invalid_die_representation() {
            assert_eq!(vec![Token::Die(20), str_token!("xxx")], tokens("d20xxx"))
        }

        #[test]
        fn tokenizes_a_lone_d() {
            assert_eq!(
                vec![Token::Int(4), Token::Plus, str_token!("d")],
                tokens("4 + d")
            );
        }

        #[test]
        fn tokenizes_a_lone_d_followed_by_a_space() {
            assert_eq!(
                vec![Token::Int(4), Token::Plus, str_token!("d")],
                tokens("4 + d ")
            );
        }

        #[test]
        fn tokenizes_a_d_preceded_by_a_string() {
            assert_eq!(
                vec![Token::Int(4), Token::Plus, str_token!("ad")],
                tokens("4 + ad")
            );
        }

        #[test]
        fn tokenizes_a_die_separated_by_a_space() {
            assert_eq!(vec![str_token!("d"), Token::Int(20)], tokens("d 20"));
        }

        #[test]
        fn tokenizes_dice_separated_by_a_space() {
            assert_eq!(vec![Token::Int(4), Token::Die(20)], tokens("4 d20"));
        }

        #[test]
        fn tokenizes_a_decimal_die() {
            assert_eq!(vec![Token::Die(2), str_token!(".5")], tokens("d2.5"))
        }

        #[test]
        fn tokenizes_an_empty_string() {
            assert_eq!(Vec::<Token>::new(), tokens(""));
        }
    }
}