diff --git a/dev-guide/src/grammar.md b/dev-guide/src/grammar.md index 2f8d41f822..40e4883096 100644 --- a/dev-guide/src/grammar.md +++ b/dev-guide/src/grammar.md @@ -39,19 +39,34 @@ Sequence -> (` `* AdornedExpr)* ` `* Cut | (` `* AdornedExpr)+ -AdornedExpr -> ExprRepeat Suffix? Footnote? +AdornedExpr -> Expr1 Quantifier? Suffix? Footnote? Suffix -> ` _` * `_` Footnote -> `[^` ~[`]` LF]+ `]` -ExprRepeat -> - Expr1 `?` - | Expr1 `*?` - | Expr1 `*` - | Expr1 `+?` - | Expr1 `+` - | Expr1 `{` Range? `..` Range? `}` +Quantifier -> + Optional + | Repeat + | RepeatNonGreedy + | RepeatPlus + | RepeatPlusNonGreedy + | RepeatRange + | RepeatRangeInclusive + +Optional -> `?` + +Repeat -> `*` + +RepeatNonGreedy -> `*?` + +RepeatPlus -> `+` + +RepeatPlusNonGreedy -> `+?` + +RepeatRange -> `{` Range? `..` Range? `}` + +RepeatRangeInclusive -> `{` Range? `..=` Range `}` Range -> [0-9]+ @@ -66,7 +81,7 @@ Expr1 -> | Group | NegativeExpression -Unicode -> `U+` [`A`-`Z` `0`-`9`]4..4 +Unicode -> `U+` [`A`-`Z` `0`-`9`]4..=4 NonTerminal -> Name @@ -121,10 +136,11 @@ The general format is a series of productions separated by blank lines. The expr | Footnote | \[^extern-safe\] | Adds a footnote, which can supply extra information that may be helpful to the user. The footnote itself should be defined outside of the code block like a normal Markdown footnote. | | Optional | Expr? | The preceding expression is optional. | | Repeat | Expr* | The preceding expression is repeated 0 or more times. | -| Repeat (non-greedy) | Expr*? | The preceding expression is repeated 0 or more times without being greedy. | +| RepeatNonGreedy | Expr*? | The preceding expression is repeated 0 or more times without being greedy. | | RepeatPlus | Expr+ | The preceding expression is repeated 1 or more times. | -| RepeatPlus (non-greedy) | Expr+? | The preceding expression is repeated 1 or more times without being greedy. | +| RepeatPlusNonGreedy | Expr+? | The preceding expression is repeated 1 or more times without being greedy. | | RepeatRange | Expr{2..4} | The preceding expression is repeated between the range of times specified. Either bound can be excluded, which works just like Rust ranges. | +| RepeatRangeInclusive | Expr{2..=4} | The preceding expression is repeated between the inclusive range of times specified. The lower bound can be omitted. | ## Automatic linking diff --git a/src/notation.md b/src/notation.md index cda298a734..850ee9fb5e 100644 --- a/src/notation.md +++ b/src/notation.md @@ -16,7 +16,8 @@ The following notations are used by the *Lexer* and *Syntax* grammar snippets: | x? | `pub`? | An optional item | | x\* | _OuterAttribute_\* | 0 or more of x | | x+ | _MacroMatch_+ | 1 or more of x | -| xa..b | HEX_DIGIT1..6 | a to b repetitions of x | +| xa..b | HEX_DIGIT1..6 | a to b repetitions of x, exclusive of b | +| xa..=b | HEX_DIGIT1..=5 | a to b repetitions of x, inclusive of b | | Rule1 Rule2 | `fn` _Name_ _Parameters_ | Sequence of rules in order | | \| | `u8` \| `u16`, Block \| Item | Either one or another | | \[ ] | \[`b` `B`] | Any of the characters listed | diff --git a/src/tokens.md b/src/tokens.md index b6a0124320..047afd76a6 100644 --- a/src/tokens.md +++ b/src/tokens.md @@ -157,7 +157,7 @@ ASCII_ESCAPE -> | `\n` | `\r` | `\t` | `\\` | `\0` UNICODE_ESCAPE -> - `\u{` ( HEX_DIGIT `_`* ){1..6} _valid hex char value_ `}`[^valid-hex-char] + `\u{` ( HEX_DIGIT `_`* ){1..=6} _valid hex char value_ `}`[^valid-hex-char] ``` [^valid-hex-char]: See [lex.token.literal.char-escape.unicode]. diff --git a/tools/grammar/src/lib.rs b/tools/grammar/src/lib.rs index 70e1a8f9a8..6fbb886558 100644 --- a/tools/grammar/src/lib.rs +++ b/tools/grammar/src/lib.rs @@ -3,6 +3,7 @@ use diagnostics::{Diagnostics, warn_or_err}; use regex::Regex; use std::collections::{HashMap, HashSet}; +use std::fmt::{Display, Formatter}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use walkdir::WalkDir; @@ -58,8 +59,13 @@ pub enum ExpressionKind { RepeatPlus(Box), /// `A+?` RepeatPlusNonGreedy(Box), - /// `A{2..4}` - RepeatRange(Box, Option, Option), + /// `A{2..4}` or `A{2..=4}` + RepeatRange { + expr: Box, + min: Option, + max: Option, + limit: RangeLimit, + }, /// `NonTerminal` Nt(String), /// `` `string` `` @@ -82,6 +88,24 @@ pub enum ExpressionKind { Unicode(String), } +#[derive(Copy, Clone, Debug)] +pub enum RangeLimit { + /// `..` + HalfOpen, + /// `..=` + Closed, +} + +impl Display for RangeLimit { + fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + match self { + RangeLimit::HalfOpen => "..", + RangeLimit::Closed => "..=", + } + .fmt(f) + } +} + #[derive(Clone, Debug)] pub enum Characters { /// `LF` @@ -117,7 +141,7 @@ impl Expression { | ExpressionKind::RepeatNonGreedy(e) | ExpressionKind::RepeatPlus(e) | ExpressionKind::RepeatPlusNonGreedy(e) - | ExpressionKind::RepeatRange(e, _, _) + | ExpressionKind::RepeatRange { expr: e, .. } | ExpressionKind::NegExpression(e) | ExpressionKind::Cut(e) => { e.visit_nt(callback); diff --git a/tools/grammar/src/parser.rs b/tools/grammar/src/parser.rs index d4240ae4d7..f65cb80f97 100644 --- a/tools/grammar/src/parser.rs +++ b/tools/grammar/src/parser.rs @@ -1,6 +1,6 @@ //! A parser of the ENBF-like grammar. -use super::{Characters, Expression, ExpressionKind, Grammar, Production}; +use super::{Characters, Expression, ExpressionKind, Grammar, Production, RangeLimit}; use std::fmt; use std::fmt::Display; use std::path::Path; @@ -428,24 +428,43 @@ impl Parser<'_> { }) } - /// Parse `{a..}` | `{..b}` | `{a..b}` after expression. + /// Parse `{a..b}` | `{a..=b}` after expression. fn parse_repeat_range(&mut self, kind: ExpressionKind) -> Result { self.expect("{", "expected `{`")?; - let a = self.take_while(&|x| x.is_ascii_digit()); - let Ok(a) = (!a.is_empty()).then(|| a.parse::()).transpose() else { + let min = self.take_while(&|x| x.is_ascii_digit()); + let Ok(min) = (!min.is_empty()).then(|| min.parse::()).transpose() else { bail!(self, "malformed range start"); }; - self.expect("..", "expected `..`")?; - let b = self.take_while(&|x| x.is_ascii_digit()); - let Ok(b) = (!b.is_empty()).then(|| b.parse::()).transpose() else { + self.expect("..", "expected `..` or `..=`")?; + let limit = if self.take_str("=") { + RangeLimit::Closed + } else { + RangeLimit::HalfOpen + }; + let max = self.take_while(&|x| x.is_ascii_digit()); + let Ok(max) = (!max.is_empty()).then(|| max.parse::()).transpose() else { bail!(self, "malformed range end"); }; - match (a, b) { - (Some(a), Some(b)) if b < a => bail!(self, "range {a}..{b} is malformed"), + match (min, max, limit) { + (Some(min), Some(max), _) if max < min => { + bail!(self, "range {min}{limit}{max} is malformed") + } + (Some(min), Some(max), RangeLimit::HalfOpen) if max <= min => { + bail!(self, "half-open range maximum must be greater than minimum") + } + (None, Some(0), RangeLimit::HalfOpen) => { + bail!(self, "half-open range `..0` is empty") + } + (_, None, RangeLimit::Closed) => bail!(self, "closed range must have an upper bound"), _ => {} } self.expect("}", "expected `}`")?; - Ok(ExpressionKind::RepeatRange(box_kind(kind), a, b)) + Ok(ExpressionKind::RepeatRange { + expr: box_kind(kind), + min, + max, + limit, + }) } fn parse_suffix(&mut self) -> Result> { @@ -523,7 +542,7 @@ fn translate_position(input: &str, index: usize) -> (&str, usize, usize) { #[cfg(test)] mod tests { use crate::parser::{parse_grammar, translate_position}; - use crate::{ExpressionKind, Grammar}; + use crate::{ExpressionKind, Grammar, RangeLimit}; use std::path::Path; #[test] @@ -587,4 +606,145 @@ mod tests { let err = parse(input).unwrap_err(); assert!(err.contains("expected expression after cut operator")); } + + /// Extract the `RepeatRange` fields from a single-production + /// grammar whose rule body is a repeat-range expression. + fn repeat_range(input: &str) -> (Option, Option, RangeLimit) { + let grammar = parse(input).unwrap(); + let rule = grammar.productions.get("A").unwrap(); + let ExpressionKind::RepeatRange { + min, max, limit, .. + } = &rule.expression.kind + else { + panic!("expected RepeatRange, got {:?}", rule.expression.kind); + }; + (*min, *max, *limit) + } + + // -- Valid ranges ----------------------------------------------- + + #[test] + fn test_range_half_open() { + let (min, max, limit) = repeat_range("A -> x{2..5}"); + assert_eq!(min, Some(2)); + assert_eq!(max, Some(5)); + assert!(matches!(limit, RangeLimit::HalfOpen)); + } + + #[test] + fn test_range_half_open_no_min() { + let (min, max, limit) = repeat_range("A -> x{..5}"); + assert_eq!(min, None); + assert_eq!(max, Some(5)); + assert!(matches!(limit, RangeLimit::HalfOpen)); + } + + #[test] + fn test_range_half_open_no_max() { + let (min, max, limit) = repeat_range("A -> x{2..}"); + assert_eq!(min, Some(2)); + assert_eq!(max, None); + assert!(matches!(limit, RangeLimit::HalfOpen)); + } + + #[test] + fn test_range_half_open_unbounded() { + let (min, max, limit) = repeat_range("A -> x{..}"); + assert_eq!(min, None); + assert_eq!(max, None); + assert!(matches!(limit, RangeLimit::HalfOpen)); + } + + #[test] + fn test_range_closed() { + let (min, max, limit) = repeat_range("A -> x{2..=5}"); + assert_eq!(min, Some(2)); + assert_eq!(max, Some(5)); + assert!(matches!(limit, RangeLimit::Closed)); + } + + #[test] + fn test_range_closed_no_min() { + let (min, max, limit) = repeat_range("A -> x{..=5}"); + assert_eq!(min, None); + assert_eq!(max, Some(5)); + assert!(matches!(limit, RangeLimit::Closed)); + } + + // -- Invalid ranges --------------------------------------------- + + #[test] + fn test_range_err_max_less_than_min() { + let err = parse("A -> x{3..2}").unwrap_err(); + assert!( + err.contains("malformed"), + "expected malformed error, got: {err}" + ); + } + + #[test] + fn test_range_err_empty_exclusive_equal() { + let err = parse("A -> x{2..2}").unwrap_err(); + assert!( + err.contains("half-open range maximum must be greater"), + "expected empty-exclusive error, got: {err}" + ); + } + + #[test] + fn test_range_err_empty_exclusive_zero() { + let err = parse("A -> x{0..0}").unwrap_err(); + assert!( + err.contains("half-open range maximum must be greater"), + "expected empty-exclusive error, got: {err}" + ); + } + + #[test] + fn test_range_err_closed_no_upper() { + let err = parse("A -> x{..=}").unwrap_err(); + assert!( + err.contains("closed range must have an upper bound"), + "expected closed-needs-upper error, got: {err}" + ); + } + + #[test] + fn test_range_err_closed_no_upper_with_min() { + let err = parse("A -> x{2..=}").unwrap_err(); + assert!( + err.contains("closed range must have an upper bound"), + "expected closed-needs-upper error, got: {err}" + ); + } + + #[test] + fn test_range_err_half_open_zero_max() { + let err = parse("A -> x{..0}").unwrap_err(); + assert!( + err.contains("half-open range `..0` is empty"), + "expected half-open-zero error, got: {err}" + ); + } + + // -- Valid edge cases ------------------------------------------- + + #[test] + fn test_range_closed_exact() { + // `x{2..=2}` means exactly 2 — not empty. + let (min, max, limit) = repeat_range("A -> x{2..=2}"); + assert_eq!(min, Some(2)); + assert_eq!(max, Some(2)); + assert!(matches!(limit, RangeLimit::Closed)); + } + + #[test] + fn test_range_half_open_zero_to_one() { + // `x{0..1}` means exactly 0 repetitions (the half-open + // range contains only 0). + let (min, max, limit) = repeat_range("A -> x{0..1}"); + assert_eq!(min, Some(0)); + assert_eq!(max, Some(1)); + assert!(matches!(limit, RangeLimit::HalfOpen)); + } } diff --git a/tools/mdbook-spec/src/grammar.rs b/tools/mdbook-spec/src/grammar.rs index 576accd2d6..f0a4e3fe12 100644 --- a/tools/mdbook-spec/src/grammar.rs +++ b/tools/mdbook-spec/src/grammar.rs @@ -21,6 +21,17 @@ pub struct RenderCtx { for_summary: bool, } +#[cfg(test)] +impl RenderCtx { + pub(crate) fn for_test() -> Self { + RenderCtx { + md_link_map: HashMap::new(), + rr_link_map: HashMap::new(), + for_summary: false, + } + } +} + /// Replaces the text grammar in the given chapter with the rendered version. pub fn insert_grammar(grammar: &Grammar, chapter: &Chapter, diag: &mut Diagnostics) -> String { let link_map = make_relative_link_map(grammar, chapter); diff --git a/tools/mdbook-spec/src/grammar/render_markdown.rs b/tools/mdbook-spec/src/grammar/render_markdown.rs index a5540b4169..edec8da035 100644 --- a/tools/mdbook-spec/src/grammar/render_markdown.rs +++ b/tools/mdbook-spec/src/grammar/render_markdown.rs @@ -71,7 +71,7 @@ fn last_expr(expr: &Expression) -> &ExpressionKind { | ExpressionKind::RepeatNonGreedy(_) | ExpressionKind::RepeatPlus(_) | ExpressionKind::RepeatPlusNonGreedy(_) - | ExpressionKind::RepeatRange(_, _, _) + | ExpressionKind::RepeatRange { .. } | ExpressionKind::Nt(_) | ExpressionKind::Terminal(_) | ExpressionKind::Prose(_) @@ -135,13 +135,18 @@ fn render_expression(expr: &Expression, cx: &RenderCtx, output: &mut String) { render_expression(e, cx, output); output.push_str("+ (non-greedy)"); } - ExpressionKind::RepeatRange(e, a, b) => { - render_expression(e, cx, output); + ExpressionKind::RepeatRange { + expr, + min, + max, + limit, + } => { + render_expression(expr, cx, output); write!( output, - "{}..{}", - a.map(|v| v.to_string()).unwrap_or_default(), - b.map(|v| v.to_string()).unwrap_or_default(), + "{min}{limit}{max}", + min = min.map(|v| v.to_string()).unwrap_or_default(), + max = max.map(|v| v.to_string()).unwrap_or_default(), ) .unwrap(); } diff --git a/tools/mdbook-spec/src/grammar/render_railroad.rs b/tools/mdbook-spec/src/grammar/render_railroad.rs index 6efb065a34..ebb20af1bc 100644 --- a/tools/mdbook-spec/src/grammar/render_railroad.rs +++ b/tools/mdbook-spec/src/grammar/render_railroad.rs @@ -3,7 +3,7 @@ use super::RenderCtx; use crate::grammar::Grammar; use anyhow::bail; -use grammar::{Characters, Expression, ExpressionKind, Production}; +use grammar::{Characters, Expression, ExpressionKind, Production, RangeLimit}; use railroad::*; use regex::Regex; use std::fmt::Write; @@ -78,9 +78,13 @@ fn render_expression(expr: &Expression, cx: &RenderCtx, stack: bool) -> Option { - render_expression(e, cx, stack)? - } + ExpressionKind::Grouped(e) + | ExpressionKind::RepeatRange { + expr: e, + min: Some(1), + max: Some(1), + limit: RangeLimit::Closed, + } => render_expression(e, cx, stack)?, ExpressionKind::Alt(es) => { let choices: Vec<_> = es .iter() @@ -139,15 +143,25 @@ fn render_expression(expr: &Expression, cx: &RenderCtx, stack: bool) -> Option { + | ExpressionKind::RepeatRange { + expr: e, + min: None | Some(0), + max: Some(1), + limit: RangeLimit::Closed, + } => { let n = render_expression(e, cx, stack)?; Box::new(Optional::new(n)) } // Treat `e*` and `e{..}` / `e{0..}` equally. ExpressionKind::Repeat(e) - | ExpressionKind::RepeatRange(e, None | Some(0), None) => { + | ExpressionKind::RepeatRange { + expr: e, + min: None | Some(0), + max: None, + limit: RangeLimit::HalfOpen, + } => { let n = render_expression(e, cx, stack)?; Box::new(Optional::new(Repeat::new(n, railroad::Empty))) } @@ -158,7 +172,13 @@ fn render_expression(expr: &Expression, cx: &RenderCtx, stack: bool) -> Option { + ExpressionKind::RepeatPlus(e) + | ExpressionKind::RepeatRange { + expr: e, + min: Some(1), + max: None, + limit: RangeLimit::HalfOpen, + } => { let n = render_expression(e, cx, stack)?; Box::new(Repeat::new(n, railroad::Empty)) } @@ -168,38 +188,91 @@ fn render_expression(expr: &Expression, cx: &RenderCtx, stack: bool) -> Option Box::new(railroad::Empty), - // Treat `e{..b}` / `e{0..b}` as `(e{1..b})?`. - ExpressionKind::RepeatRange(e, None | Some(0), Some(b @ 2..)) => { + // For `e{..=0}` / `e{0..=0}` or `e{..1}` / `e{0..1}` render an empty node. + ExpressionKind::RepeatRange { max: Some(0), .. } + | ExpressionKind::RepeatRange { + max: Some(1), + limit: RangeLimit::HalfOpen, + .. + } => Box::new(railroad::Empty), + // Treat `e{..b}` / `e{0..b}` / `e{..=b}` / `e{0..=b}` as + // `(e{1..=b})?` (or `(e{1..b})?` for half-open). + ExpressionKind::RepeatRange { + expr: e, + min: None | Some(0), + max: Some(b @ 2..), + limit, + } => { state = ExpressionKind::Optional(Box::new(Expression::new_kind( - ExpressionKind::RepeatRange(e.clone(), Some(1), Some(*b)), + ExpressionKind::RepeatRange { + expr: e.clone(), + min: Some(1), + max: Some(*b), + limit: *limit, + }, ))); break 'cont &state; } - // Render `e{1..b}` directly. - ExpressionKind::RepeatRange(e, Some(1), Some(b @ 2..)) => { + // Render `e{1..b}` / `e{1..=b}` directly. + ExpressionKind::RepeatRange { + expr: e, + min: Some(1), + max: Some(b @ 2..), + limit, + } => { let n = render_expression(e, cx, stack)?; - let cmt = format!("at most {b} more times", b = b - 1); + let more = match limit { + RangeLimit::HalfOpen => b - 2, + RangeLimit::Closed => b - 1, + }; + let cmt = format!("at most {more} more times"); let r = Repeat::new(n, Comment::new(cmt)); Box::new(r) } - // Treat `e{a..}` as `e{a-1..a-1} e{1..}` and `e{a..b}` as - // `e{a-1..a-1} e{1..b-(a-1)}`, and treat `e{x..x}` for some - // `x` as a sequence of `e` nodes of length `x`. - ExpressionKind::RepeatRange(e, Some(a @ 2..), b) => { + // A half-open range where min >= max is empty (e.g., + // `e{2..2}` means zero repetitions). + ExpressionKind::RepeatRange { + min: Some(a), + max: Some(b), + limit: RangeLimit::HalfOpen, + .. + } if b <= a => Box::new(railroad::Empty), + + // Decompose ranges with min >= 2 into a fixed prefix + // and a remainder: + // - `e{a..}` as `e{0..a-1} e{1..}` + // - `e{a..=b}` as `e{0..a-1} e{1..=b-(a-1)}` + // - `e{a..b}` as `e{0..a-1} e{1..b-(a-1)}` + ExpressionKind::RepeatRange { + expr: e, + min: Some(a @ 2..), + max: b @ None, + limit, + } + | ExpressionKind::RepeatRange { + expr: e, + min: Some(a @ 2..), + max: b @ Some(_), + limit, + } => { let mut es = Vec::::new(); for _ in 0..(a - 1) { es.push(*e.clone()); } - es.push(Expression::new_kind(ExpressionKind::RepeatRange( - e.clone(), - Some(1), - b.map(|x| x - (a - 1)), - ))); + es.push(Expression::new_kind(ExpressionKind::RepeatRange { + expr: e.clone(), + min: Some(1), + max: b.map(|x| x - (a - 1)), + limit: *limit, + })); state = ExpressionKind::Sequence(es); break 'cont &state; } + ExpressionKind::RepeatRange { + max: None, + limit: RangeLimit::Closed, + .. + } => unreachable!("closed range must have upper bound"), ExpressionKind::Nt(nt) => node_for_nt(cx, nt), ExpressionKind::Terminal(t) => Box::new(Terminal::new(t.clone())), ExpressionKind::Prose(s) => Box::new(Terminal::new(s.clone())), @@ -298,3 +371,93 @@ impl Node for Except { self.inner.draw(x, y, h_dir) } } + +#[cfg(test)] +mod tests { + use super::*; + use grammar::{Expression, ExpressionKind, RangeLimit}; + + /// Render an expression to an SVG string fragment. + fn render_to_svg(expr: &Expression) -> Option { + let cx = RenderCtx::for_test(); + let node = render_expression(expr, &cx, false)?; + let svg = node.draw(0, 0, svg::HDir::LTR); + Some(svg.to_string()) + } + + /// Build a `RepeatRange` expression wrapping a nonterminal `e`. + fn range_expr(min: Option, max: Option, limit: RangeLimit) -> Expression { + Expression::new_kind(ExpressionKind::RepeatRange { + expr: Box::new(Expression::new_kind(ExpressionKind::Nt("e".to_string()))), + min, + max, + limit, + }) + } + + #[test] + fn test_empty_exclusive_equal() { + // `e{2..2}` (half-open, min == max) renders as empty. + let expr = range_expr(Some(2), Some(2), RangeLimit::HalfOpen); + let svg = render_to_svg(&expr).unwrap(); + // An empty node produces a minimal SVG path with no + // nonterminal content. + assert!( + !svg.contains("nonterminal"), + "expected empty rendering for e{{2..2}}, got: {svg}" + ); + } + + #[test] + fn test_empty_inverted() { + // `e{3..1}` (half-open, max < min) renders as empty. + let expr = range_expr(Some(3), Some(1), RangeLimit::HalfOpen); + let svg = render_to_svg(&expr).unwrap(); + assert!( + !svg.contains("nonterminal"), + "expected empty rendering for e{{3..1}}, got: {svg}" + ); + } + + #[test] + fn test_closed_exact_one() { + // `e{1..=1}` renders as a single `e` (no repeat). + let expr = range_expr(Some(1), Some(1), RangeLimit::Closed); + let svg = render_to_svg(&expr).unwrap(); + assert!( + svg.contains("nonterminal"), + "expected nonterminal for e{{1..=1}}, got: {svg}" + ); + // Should not contain "more times" (no repeat comment). + assert!( + !svg.contains("more times"), + "e{{1..=1}} should not show a repeat comment" + ); + } + + #[test] + fn test_closed_range() { + // `e{2..=4}` renders with repeat indicators. + let expr = range_expr(Some(2), Some(4), RangeLimit::Closed); + let svg = render_to_svg(&expr).unwrap(); + assert!( + svg.contains("nonterminal"), + "expected nonterminal for e{{2..=4}}, got: {svg}" + ); + assert!( + svg.contains("more times"), + "e{{2..=4}} should show a repeat comment" + ); + } + + #[test] + fn test_closed_optional() { + // `e{..=1}` renders as optional. + let expr = range_expr(None, Some(1), RangeLimit::Closed); + let svg = render_to_svg(&expr).unwrap(); + assert!( + svg.contains("nonterminal"), + "expected nonterminal for e{{..=1}}, got: {svg}" + ); + } +}