Skip to main content

rdice_core/
expr.rs

1//! Parsers for compact dice roll expressions.
2//!
3//! Expressions are provided as already-tokenized command-line style strings,
4//! such as `["3d6", "d20", "5"]`. Dice counts are expanded into repeated die
5//! names and integer tokens are treated as modifiers where allowed.
6
7use crate::error::{DiceError, Result};
8
9/// Parsed roll input.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ParsedRoll {
12    /// Expanded die names in roll order.
13    pub dice: Vec<String>,
14    /// Integer modifiers that should be added to roll analysis.
15    pub modifiers: Vec<i64>,
16}
17
18/// Parses dice expressions and integer modifiers.
19///
20/// Dice expressions use an optional positive count followed by a die name:
21/// `d6`, `3d6`, `D20`, or `2Coin`. Bare integers are returned as modifiers.
22///
23/// # Errors
24///
25/// Returns [`DiceError::InvalidArguments`] when `tokens` is empty and
26/// [`DiceError::InvalidExpression`] when a token cannot be interpreted as a die
27/// expression or integer modifier.
28///
29/// # Examples
30///
31/// ```
32/// use rdice_core::parse_roll_exprs;
33///
34/// let parsed = parse_roll_exprs(&["3d6", "Coin", "-2"])?;
35///
36/// assert_eq!(parsed.dice, vec!["d6", "d6", "d6", "Coin"]);
37/// assert_eq!(parsed.modifiers, vec![-2]);
38///
39/// # Ok::<(), rdice_core::DiceError>(())
40/// ```
41pub fn parse_roll_exprs(tokens: &[&str]) -> Result<ParsedRoll> {
42    if tokens.is_empty() {
43        return Err(DiceError::InvalidArguments(
44            "expected at least one roll token".to_string(),
45        ));
46    }
47
48    let mut dice = Vec::new();
49    let mut modifiers = Vec::new();
50
51    for token in tokens {
52        if let Ok(value) = token.parse::<i64>() {
53            modifiers.push(value);
54            continue;
55        }
56
57        dice.extend(parse_die_token(token)?);
58    }
59
60    if dice.is_empty() && modifiers.is_empty() {
61        return Err(DiceError::InvalidExpression(
62            "expression did not contain dice or modifiers".to_string(),
63        ));
64    }
65
66    Ok(ParsedRoll { dice, modifiers })
67}
68
69/// Parses dice expressions and rejects integer modifiers.
70///
71/// Use this for commands that accept only dice names or counted dice
72/// expressions.
73///
74/// # Errors
75///
76/// Returns [`DiceError::InvalidArguments`] when `tokens` is empty,
77/// [`DiceError::InvalidExpression`] when a token is a modifier or malformed,
78/// and [`DiceError::InvalidFaceCount`] when a counted expression uses `0`.
79///
80/// # Examples
81///
82/// ```
83/// use rdice_core::parse_dice_only_exprs;
84///
85/// let dice = parse_dice_only_exprs(&["2d6", "Coin"])?;
86///
87/// assert_eq!(dice, vec!["d6", "d6", "Coin"]);
88///
89/// # Ok::<(), rdice_core::DiceError>(())
90/// ```
91pub fn parse_dice_only_exprs(tokens: &[&str]) -> Result<Vec<String>> {
92    if tokens.is_empty() {
93        return Err(DiceError::InvalidArguments(
94            "expected at least one dice expression".to_string(),
95        ));
96    }
97
98    let mut dice = Vec::new();
99    for token in tokens {
100        if token.parse::<i64>().is_ok() {
101            return Err(DiceError::InvalidExpression(format!(
102                "modifiers are not allowed in dice-only expressions: {token}"
103            )));
104        }
105
106        dice.extend(parse_die_token(token)?);
107    }
108
109    Ok(dice)
110}
111
112fn parse_die_token(token: &str) -> Result<Vec<String>> {
113    let digit_end = token
114        .char_indices()
115        .find(|(_, c)| !c.is_ascii_digit())
116        .map(|(index, _)| index)
117        .unwrap_or(token.len());
118
119    if digit_end == token.len() {
120        return Err(DiceError::InvalidExpression(format!(
121            "expected a die expression like d6 or 2custom, got {token}"
122        )));
123    }
124
125    let count = if digit_end == 0 {
126        1
127    } else {
128        token[..digit_end]
129            .parse::<usize>()
130            .map_err(|_| DiceError::InvalidExpression(format!("invalid dice count in {token}")))?
131    };
132
133    if count == 0 {
134        return Err(DiceError::InvalidFaceCount);
135    }
136
137    let die_name = &token[digit_end..];
138    if die_name.trim().is_empty() {
139        return Err(DiceError::InvalidExpression(format!(
140            "missing die name in expression {token}"
141        )));
142    }
143
144    Ok((0..count).map(|_| die_name.to_string()).collect())
145}
146
147#[cfg(test)]
148mod tests {
149    use super::{parse_dice_only_exprs, parse_roll_exprs};
150    use crate::error::DiceError;
151
152    #[test]
153    fn parse_roll_exprs_expands_dice_and_modifiers() {
154        let parsed = parse_roll_exprs(&["3d6", "2d20", "d100", "2custom", "5", "-3"]).unwrap();
155        assert_eq!(
156            parsed.dice,
157            vec!["d6", "d6", "d6", "d20", "d20", "d100", "custom", "custom"]
158        );
159        assert_eq!(parsed.modifiers, vec![5, -3]);
160    }
161
162    #[test]
163    fn parse_dice_only_rejects_modifiers() {
164        let err = parse_dice_only_exprs(&["2d6", "-3"]).unwrap_err();
165        assert!(matches!(err, DiceError::InvalidExpression(message) if message.contains("-3")));
166    }
167}