Skip to main content

rust_roll/
notation.rs

1//! Parsing and validation of dice notation.
2//!
3//! Notation takes the form `XdY?Z`: an optional number of dice `X`, a die
4//! with `Y` sides, and an optional modifier made of an operator (`+`, `-`,
5//! `*` or `/`) and a value `Z`. Whitespace between the parts is ignored,
6//! and `d` may be upper or lower case.
7//!
8//! A [`Notation`] can only be created through [`Notation::try_new`] or by
9//! parsing, so every notation built from user input is within bounds.
10
11use regex::{Captures, Regex};
12use std::fmt;
13use std::{str::FromStr, sync::LazyLock};
14
15mod error;
16mod modifier;
17
18pub use error::NotationError;
19pub use modifier::{Modifier, Operator};
20
21/// The largest value allowed for the number of dice, the number of sides,
22/// or a modifier's value.
23pub const MAX_VALUE: u32 = 1_000;
24
25static NOTATION_RE: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(
27        r"(?x)                                    # verbose mode, literal whitespace ignored
28      ^ \s*
29      (?<count>[0-9]+)? \s*                           # optional number of dice
30      [dD] \s*
31      (?<sides>[0-9]+)                                # faces per die
32      (?: \s* (?<op>[-+*/]) \s* (?<value>[0-9]+) )?   # optional modifier
33      \s* $
34    ",
35    )
36    .expect("Notation regex should be valid")
37});
38
39/// A validated dice expression, such as `2d6+3`.
40///
41/// Create one by parsing a string or with [`Notation::try_new`].
42///
43/// # Examples
44///
45/// ```
46/// use rust_roll::notation::Notation;
47///
48/// let notation: Notation = "2d6+3".parse().unwrap();
49///
50/// assert_eq!(notation.count(), 2);
51/// assert_eq!(notation.sides(), 6);
52/// assert!(notation.modifier().is_some());
53/// ```
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Notation {
56    count: u32,
57    sides: u32,
58    modifier: Option<Modifier>,
59}
60
61impl Notation {
62    /// Creates a notation, checking that `count` and `sides` are within bounds.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`NotationError::Zero`] if `count` or `sides` is zero, or
67    /// [`NotationError::NumberTooLarge`] if either exceeds [`MAX_VALUE`].
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use rust_roll::notation::Notation;
73    ///
74    /// let notation = Notation::try_new(2, 6, None).unwrap();
75    /// assert_eq!(notation.count(), 2);
76    /// ```
77    pub fn try_new(
78        count: u32,
79        sides: u32,
80        modifier: Option<Modifier>,
81    ) -> Result<Self, NotationError> {
82        Ok(Notation {
83            count: check_bounds(count, "count")?,
84            sides: check_bounds(sides, "sides")?,
85            modifier,
86        })
87    }
88
89    /// The number of dice to roll.
90    #[must_use]
91    pub fn count(self) -> u32 {
92        self.count
93    }
94
95    /// The number of sides of each die.
96    #[must_use]
97    pub fn sides(self) -> u32 {
98        self.sides
99    }
100
101    /// The modifier to apply to the total, if any.
102    #[must_use]
103    pub fn modifier(self) -> Option<Modifier> {
104        self.modifier
105    }
106
107    /// A single d20 with an optional modifier: the default roll for
108    /// advantage and disadvantage.
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// use rust_roll::notation::Notation;
114    ///
115    /// let notation = Notation::d20(None);
116    /// assert_eq!((notation.count(), notation.sides()), (1, 20));
117    /// ```
118    #[must_use]
119    pub fn d20(modifier: Option<Modifier>) -> Notation {
120        Notation {
121            count: 1,
122            sides: 20,
123            modifier,
124        }
125    }
126
127    /// Returns the notation for a critical hit, following the D&D 5e (2014)
128    /// rules: the number of dice is doubled, while the sides and modifier are
129    /// unchanged, so the modifier is still applied only once.
130    ///
131    /// The doubled count may exceed [`MAX_VALUE`]: a critical `1000d6` becomes
132    /// `2000d6`. This is deliberate, as capping it would change the rule.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use rust_roll::notation::Notation;
138    ///
139    /// let notation: Notation = "1d8+2".parse().unwrap();
140    /// let critical = notation.critical();
141    ///
142    /// assert_eq!(critical.count(), 2);
143    /// assert_eq!(critical.modifier(), notation.modifier());
144    /// ```
145    #[must_use]
146    pub fn critical(self) -> Notation {
147        Notation {
148            count: self.count * 2,
149            ..self
150        }
151    }
152}
153
154impl FromStr for Notation {
155    type Err = NotationError;
156
157    fn from_str(s: &str) -> Result<Self, Self::Err> {
158        let captures = NOTATION_RE
159            .captures(s)
160            .ok_or(NotationError::InvalidFormat)?;
161
162        Notation::try_new(
163            parse_count(&captures)?,
164            parse_sides(&captures)?,
165            parse_modifier(&captures)?,
166        )
167    }
168}
169
170impl fmt::Display for Notation {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        if self.count != 1 {
173            write!(f, "{}", self.count)?;
174        }
175
176        write!(f, "d{}", self.sides)?;
177
178        if let Some(modifier) = self.modifier {
179            write!(f, "{modifier}")?;
180        }
181
182        Ok(())
183    }
184}
185
186fn parse_count(captures: &Captures) -> Result<u32, NotationError> {
187    let count = captures
188        .name("count")
189        .map(|m| m.as_str().parse::<u32>())
190        .transpose()?
191        .unwrap_or(1);
192
193    Ok(count)
194}
195
196fn parse_sides(captures: &Captures) -> Result<u32, NotationError> {
197    let sides = captures["sides"].parse()?;
198
199    Ok(sides)
200}
201
202fn parse_modifier(captures: &Captures) -> Result<Option<Modifier>, NotationError> {
203    match (captures.name("op"), captures.name("value")) {
204        (Some(op), Some(value)) => {
205            let operator = op.as_str().parse()?;
206            let value = value.as_str().parse()?;
207
208            Modifier::try_new(operator, value).map(Some)
209        }
210        (None, None) => Ok(None),
211        _ => Err(NotationError::InvalidFormat),
212    }
213}
214
215fn check_bounds(value: u32, name: &'static str) -> Result<u32, NotationError> {
216    match value {
217        0 => Err(NotationError::Zero(name)),
218        1..=MAX_VALUE => Ok(value),
219        _ => Err(NotationError::NumberTooLarge(MAX_VALUE)),
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::test_helpers::modifier;
227    use ::rstest::rstest;
228
229    mod try_new {
230        use super::*;
231
232        #[rstest]
233        #[case::typical(2, 6, None)]
234        #[case::with_modifier(1, 20, Some(modifier(Operator::Add, 5)))]
235        #[case::max_count(MAX_VALUE, 100, None)]
236        #[case::max_sides(5, MAX_VALUE, None)]
237        fn accepts_valid_values(
238            #[case] count: u32,
239            #[case] sides: u32,
240            #[case] modifier: Option<Modifier>,
241        ) {
242            let result = Notation::try_new(count, sides, modifier);
243
244            assert_eq!(
245                result,
246                Ok(Notation {
247                    count,
248                    sides,
249                    modifier
250                })
251            );
252        }
253
254        #[rstest]
255        #[case::zero_count(0, 6, NotationError::Zero("count"))]
256        #[case::zero_sides(1, 0, NotationError::Zero("sides"))]
257        #[case::count_too_large(MAX_VALUE + 1, 6, NotationError::NumberTooLarge(MAX_VALUE))]
258        #[case::sides_too_large(1, MAX_VALUE + 1, NotationError::NumberTooLarge(MAX_VALUE))]
259        fn rejects_invalid_values(
260            #[case] count: u32,
261            #[case] sides: u32,
262            #[case] expected: NotationError,
263        ) {
264            let result = Notation::try_new(count, sides, None);
265
266            assert_eq!(result, Err(expected));
267        }
268    }
269
270    mod from_str {
271        use super::*;
272
273        #[test]
274        fn defaults_to_one() {
275            let input = "d20";
276
277            let result: Notation = input.parse().unwrap();
278
279            assert_eq!(
280                result,
281                Notation {
282                    count: 1,
283                    sides: 20,
284                    modifier: None
285                }
286            );
287        }
288
289        #[test]
290        fn ignores_whitespace() {
291            let input = "   3    d   4  + 5  ";
292
293            let result: Notation = input.parse().unwrap();
294
295            assert_eq!(
296                result,
297                Notation {
298                    count: 3,
299                    sides: 4,
300                    modifier: Some(modifier(Operator::Add, 5))
301                }
302            );
303        }
304
305        #[test]
306        fn accepts_uppercase_d() {
307            let input = "D100";
308
309            let result: Notation = input.parse().unwrap();
310
311            assert_eq!(
312                result,
313                Notation {
314                    count: 1,
315                    sides: 100,
316                    modifier: None
317                }
318            );
319        }
320
321        #[rstest]
322        #[case::word("banana")]
323        #[case::whitespace_between_digits("2d1 0")]
324        #[case::operator_without_value("d10+")]
325        fn rejects_invalid_format(#[case] input: &str) {
326            let result = input.parse::<Notation>();
327
328            assert!(matches!(result, Err(NotationError::InvalidFormat)));
329        }
330
331        #[rstest]
332        #[case::count("99999999999d6")]
333        #[case::sides("d99999999999")]
334        #[case::modifier("d10+99999999999")]
335        fn rejects_overflow(#[case] input: &str) {
336            let result = input.parse::<Notation>();
337
338            assert!(matches!(result, Err(NotationError::InvalidNumber(_))));
339        }
340
341        #[rstest]
342        #[case::add("+", Operator::Add)]
343        #[case::sub("-", Operator::Sub)]
344        #[case::mul("*", Operator::Mul)]
345        #[case::div("/", Operator::Div)]
346        fn accepts_modifier(#[case] input: &str, #[case] expected: Operator) {
347            let input = format!("1d8{input}2");
348
349            let result: Notation = input.parse().unwrap();
350
351            assert_eq!(
352                result,
353                Notation {
354                    count: 1,
355                    sides: 8,
356                    modifier: Some(modifier(expected, 2))
357                }
358            );
359        }
360
361        #[rstest]
362        #[case::zero_count("0d6", NotationError::Zero("count"))]
363        #[case::number_too_large("d1001", NotationError::NumberTooLarge(MAX_VALUE))]
364        #[case::zero_divisor("d6/0", NotationError::Zero("divisor"))]
365        fn passes_on_validation(#[case] input: &str, #[case] expected: NotationError) {
366            let result = input.parse::<Notation>();
367
368            assert_eq!(result, Err(expected));
369        }
370    }
371
372    mod display {
373        use super::*;
374
375        #[rstest]
376        #[case::one_die(Notation { count: 1, sides: 100, modifier: None}, "d100")]
377        #[case::many_dice(Notation {count: 5, sides: 6, modifier: None}, "5d6")]
378        #[case::with_modifier(Notation { count: 10, sides: 4, modifier: Some(modifier(Operator::Sub, 5))}, "10d4-5")]
379        fn returns_formatted_string(#[case] notation: Notation, #[case] expected: &str) {
380            let result = notation.to_string();
381
382            assert_eq!(result, expected);
383        }
384    }
385
386    mod d20 {
387        use super::*;
388
389        #[rstest]
390        #[case::plain(None)]
391        #[case::with_modifier(Some(modifier(Operator::Add, 5)))]
392        fn builds_correct_notation(#[case] modifier: Option<Modifier>) {
393            let result = Notation::d20(modifier);
394
395            assert_eq!(
396                result,
397                Notation {
398                    count: 1,
399                    sides: 20,
400                    modifier
401                }
402            );
403        }
404    }
405
406    mod critical {
407        use super::*;
408
409        #[test]
410        fn doubles_count() {
411            let modifier = modifier(Operator::Add, 2);
412            let notation = Notation {
413                count: 1,
414                sides: 8,
415                modifier: Some(modifier),
416            };
417
418            let result = notation.critical();
419
420            assert_eq!(
421                result,
422                Notation {
423                    count: 2,
424                    sides: 8,
425                    modifier: Some(modifier)
426                }
427            );
428        }
429    }
430}