Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions dev-guide/src/grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,34 @@ Sequence ->
(` `* AdornedExpr)* ` `* Cut
| (` `* AdornedExpr)+

AdornedExpr -> ExprRepeat Suffix? Footnote?
AdornedExpr -> Expr1 Quantifier? Suffix? Footnote?

Suffix -> ` _` <not underscore, unless in backtick>* `_`

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]+

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion src/notation.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ The following notations are used by the *Lexer* and *Syntax* grammar snippets:
| x<sup>?</sup> | `pub`<sup>?</sup> | An optional item |
| x<sup>\*</sup> | _OuterAttribute_<sup>\*</sup> | 0 or more of x |
| x<sup>+</sup> | _MacroMatch_<sup>+</sup> | 1 or more of x |
| x<sup>a..b</sup> | HEX_DIGIT<sup>1..6</sup> | a to b repetitions of x |
| x<sup>a..b</sup> | HEX_DIGIT<sup>1..6</sup> | a to b repetitions of x, exclusive of b |
| x<sup>a..=b</sup> | HEX_DIGIT<sup>1..=5</sup> | 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 |
Expand Down
2 changes: 1 addition & 1 deletion src/tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
30 changes: 27 additions & 3 deletions tools/grammar/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,8 +59,13 @@ pub enum ExpressionKind {
RepeatPlus(Box<Expression>),
/// `A+?`
RepeatPlusNonGreedy(Box<Expression>),
/// `A{2..4}`
RepeatRange(Box<Expression>, Option<u32>, Option<u32>),
/// `A{2..4}` or `A{2..=4}`
RepeatRange {
expr: Box<Expression>,
min: Option<u32>,
max: Option<u32>,
limit: RangeLimit,
},
/// `NonTerminal`
Nt(String),
/// `` `string` ``
Expand All @@ -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`
Expand Down Expand Up @@ -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);
Expand Down
182 changes: 171 additions & 11 deletions tools/grammar/src/parser.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -428,24 +428,43 @@ impl Parser<'_> {
})
}

/// Parse `{a..}` | `{..b}` | `{a..b}` after expression.
/// Parse `{a..b}` | `{a..=b}` after expression.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In testing, the renderer still seems to support {..b} and {..=b}.

fn parse_repeat_range(&mut self, kind: ExpressionKind) -> Result<ExpressionKind> {
self.expect("{", "expected `{`")?;
let a = self.take_while(&|x| x.is_ascii_digit());
let Ok(a) = (!a.is_empty()).then(|| a.parse::<u32>()).transpose() else {
let min = self.take_while(&|x| x.is_ascii_digit());
let Ok(min) = (!min.is_empty()).then(|| min.parse::<u32>()).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::<u32>()).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::<u32>()).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<Option<String>> {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<u32>, Option<u32>, 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));
}
}
11 changes: 11 additions & 0 deletions tools/mdbook-spec/src/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading