From 3e1e147319282ff6ce093845f37f20739017e905 Mon Sep 17 00:00:00 2001 From: ArneLaptop Date: Sat, 27 Jun 2026 12:15:32 +0200 Subject: [PATCH 1/3] added start and end markers --- CHANGELOG.md | 4 ++ guide/06-parser-matcher-reference.md | 12 +++++- guide/08-common-patterns.md | 6 +-- src/input.rs | 17 +++++++++ src/lib.rs | 6 +-- src/matcher/input_boundary.rs | 53 ++++++++++++++++++++++++++ src/matcher/mod.rs | 3 ++ tests/input_boundary.rs | 56 ++++++++++++++++++++++++++++ 8 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/matcher/input_boundary.rs create mode 100644 tests/input_boundary.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611bad..0ce7f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `matcher::start_of_input()` and `matcher::end_of_input()` zero-width matchers for input-boundary checks in grammars; `end_of_input()` is equivalent to `negative_lookahead(AnyToken)`. + ## [0.2.1] - 2026-06-18 ### Fixed diff --git a/guide/06-parser-matcher-reference.md b/guide/06-parser-matcher-reference.md index 8e0fd5d..6b32e2b 100644 --- a/guide/06-parser-matcher-reference.md +++ b/guide/06-parser-matcher-reference.md @@ -262,7 +262,17 @@ to commit immediately. of input. Use it for catch-all recovery, unknown tokens, or end-of-input checks with -`negative_lookahead(AnyToken)`. +[`end_of_input()`] or [`negative_lookahead(AnyToken)`](crate::matcher::negative_lookahead). + +### `start_of_input` and `end_of_input` + +[`end_of_input()`] succeeds when no tokens remain — equivalent to +[`negative_lookahead(AnyToken)`](crate::matcher::negative_lookahead). Use it in +grammars for explicit end-of-input checks (including with `.with_label(...)`). + +[`start_of_input()`] succeeds when the cursor equals [`Input::start_pos`](crate::input::Input::start_pos) +for the current stream. For a sub-slice of a larger buffer, that is the start of +the slice, not necessarily byte offset zero in the outer source. ### `StringMatcher`, `&str`, and `char` diff --git a/guide/08-common-patterns.md b/guide/08-common-patterns.md index 90d8d38..96d2bd5 100644 --- a/guide/08-common-patterns.md +++ b/guide/08-common-patterns.md @@ -57,11 +57,11 @@ ignoring it: ```rust use marser::capture; -use marser::matcher::{any_token::AnyToken, negative_lookahead::negative_lookahead}; +use marser::matcher::end_of_input; use marser::parser::{recursive, DeferredWeak, Parser}; let _value = recursive(|_weak: DeferredWeak<'_, '_, &str, ()>| { - capture!(negative_lookahead(AnyToken) => ()) + capture!(end_of_input() => ()) }); ``` @@ -83,7 +83,7 @@ Use the bind form that matches the boundary you want: ## Full-input parsing -`Parser::parse_str` / `parse_whole_input` use the same driver as `marser::parse`: the grammar is wrapped so **no trailing tokens** remain. Use `negative_lookahead(AnyToken)` patterns inside the library’s default wrapper; for sub-parsers that only parse a fragment, use segment-specific rules instead of the whole-input entrypoint. +`Parser::parse_str` / `parse_whole_input` use the same driver as `marser::parse`: the grammar is wrapped so **no trailing tokens** remain (via [`end_of_input()`](crate::matcher::end_of_input)). Use `end_of_input()` in grammars when you need an explicit end-of-input check; for sub-parsers that only parse a fragment, use segment-specific rules instead of the whole-input entrypoint. ## Type size and `erase_types` diff --git a/src/input.rs b/src/input.rs index 500781e..79736d4 100644 --- a/src/input.rs +++ b/src/input.rs @@ -154,6 +154,12 @@ impl<'src, I: Input<'src>> InputStream<'src, I> { self.pos = pos; } + /// `true` when the cursor equals [`Input::start_pos`] for this stream. + #[inline] + pub(crate) fn is_at_start(&self) -> bool { + self.pos.clone().into() == self.input.start_pos().into() + } + /// Forwards to [`Input::try_consume_prefix_bytes`] on the underlying input. #[inline] pub(crate) fn try_consume_prefix_bytes(&mut self, prefix: &[u8]) -> Option { @@ -191,6 +197,17 @@ impl<'src> InputStream<'src, &'src [u8]> { mod tests { use super::{Input, InputStream}; + #[test] + fn is_at_start_tracks_cursor_against_input_start_pos() { + let s = "abc"; + let mut stream = InputStream::new(s); + assert!(stream.is_at_start()); + assert_eq!(stream.next(), Some('a')); + assert!(!stream.is_at_start()); + stream.set_pos(Input::start_pos(&s)); + assert!(stream.is_at_start()); + } + #[test] fn str_try_consume_prefix_advances_pos() { let mut stream = InputStream::new("hello"); diff --git a/src/lib.rs b/src/lib.rs index ee4610b..b714493 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,7 +65,7 @@ use crate::{ error::{FurthestFailError, MatcherRunError, ParserError, error_handler::EmptyErrorHandler}, input::{Input, InputStream}, matcher::{ - any_token::AnyToken, commit_matcher::commit_on, negative_lookahead::negative_lookahead, + commit_matcher::commit_on, end_of_input, }, mode::Emit, parser::{Parser, internal::ParserImpl}, @@ -112,7 +112,7 @@ where let eof_wrapped = capture!( commit_on((), ( bind!(parser.clone(), result), - negative_lookahead(AnyToken), + end_of_input(), )) => result ); @@ -174,7 +174,7 @@ where let eof_wrapped = capture!( commit_on((), ( bind!(parser.clone(), result), - negative_lookahead(AnyToken), + end_of_input(), )) => result ); diff --git a/src/matcher/input_boundary.rs b/src/matcher/input_boundary.rs new file mode 100644 index 0000000..f71ef61 --- /dev/null +++ b/src/matcher/input_boundary.rs @@ -0,0 +1,53 @@ +//! AI assistance: this file was written with AI assistance. The maintainer reviewed it and did not find errors. +//! +//! Zero-width matchers at the start or end of an [`crate::input::Input`] stream. + +use crate::{ + error::{MatcherRunError, error_handler::ErrorHandler}, + input::{Input, InputStream}, + matcher::{ + MatchRunner, MatcherCombinator, any_token::AnyToken, + negative_lookahead::{NegativeLookahead, negative_lookahead}, + }, +}; + +/// Succeeds when no tokens remain (equivalent to [`negative_lookahead`](crate::matcher::negative_lookahead)([`AnyToken`])). +#[inline] +pub fn end_of_input() -> NegativeLookahead { + negative_lookahead(AnyToken) +} + +/// Zero-width matcher: succeeds when the cursor equals [`Input::start_pos`] for this stream. +#[derive(Clone, Debug)] +pub struct StartOfInput; + +/// See [`StartOfInput`]. +#[inline] +pub fn start_of_input() -> StartOfInput { + StartOfInput +} + +impl MatcherCombinator for StartOfInput {} + +impl<'src, Inp: Input<'src>, MRes> super::internal::MatcherImpl<'src, Inp, MRes> for StartOfInput +where + Inp: Input<'src>, +{ + const CAN_MATCH_DIRECTLY: bool = true; + const HAS_PROPERTY: bool = false; + const CAN_FAIL: bool = true; + + #[inline] + fn match_with_runner<'a, Runner, M: crate::mode::Mode>( + &'a self, + _runner: &mut Runner, + _error_handler: &mut impl ErrorHandler, + input: &mut InputStream<'src, Inp>, + ) -> Result + where + Runner: MatchRunner<'a, 'src, Inp, MRes = MRes>, + 'src: 'a, + { + Ok(input.is_at_start()) + } +} diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index bbe770e..12e1aef 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -32,6 +32,8 @@ pub mod commit_matcher; pub mod err_if; pub mod error_contextualizer; pub mod if_error; +/// Zero-width matchers at the start or end of an input stream. +pub mod input_boundary; /// Parser-as-matcher adapters that discard parser output. pub mod ignore_result; pub mod multiple; @@ -55,6 +57,7 @@ pub use err_if::{ try_insert_if_missing, unwanted, }; pub use error_contextualizer::ErrorContextualizer; +pub use input_boundary::{StartOfInput, end_of_input, start_of_input}; pub use multiple::{Multiple, many}; pub use negative_lookahead::{NegativeLookahead, negative_lookahead}; pub use one_or_more::{OneOrMore, one_or_more}; diff --git a/tests/input_boundary.rs b/tests/input_boundary.rs new file mode 100644 index 0000000..094c94c --- /dev/null +++ b/tests/input_boundary.rs @@ -0,0 +1,56 @@ +//! AI assistance: this file was written with AI assistance. The maintainer reviewed it and did not find errors. +//! +//! Integration tests for [`marser::matcher::start_of_input`] and [`marser::matcher::end_of_input`]. + +use marser::capture; +use marser::matcher::{end_of_input, start_of_input}; +use marser::parser::Parser; + +#[test] +fn end_of_input_succeeds_on_empty() { + let p = capture!(end_of_input() => ()); + let ((), errs) = p.parse_str("").expect("parse"); + assert!(errs.is_empty()); +} + +#[test] +fn end_of_input_fails_with_trailing() { + let p = capture!(end_of_input() => ()); + assert!(p.parse_str("x").is_err()); +} + +#[test] +fn end_of_input_after_consumed() { + let p = capture!(('a', end_of_input()) => ()); + let ((), errs) = p.parse_str("a").expect("parse"); + assert!(errs.is_empty()); + assert!(p.parse_str("ab").is_err()); +} + +#[test] +fn start_of_input_at_beginning() { + let p = capture!(start_of_input() => ()); + let ((), errs) = p.parse_str("").expect("parse empty"); + assert!(errs.is_empty()); + + let with_char = capture!((start_of_input(), 'x', end_of_input()) => ()); + let ((), errs) = with_char.parse_str("x").expect("parse non-empty"); + assert!(errs.is_empty()); +} + +#[test] +fn start_of_input_fails_after_advance() { + let p = capture!(('a', start_of_input()) => ()); + assert!(p.parse_str("a").is_err()); +} + +#[test] +fn start_of_input_with_subslice() { + let src = "prefix:suffix"; + let p = capture!((start_of_input(), "suffix") => ()); + let ((), errs) = p.parse_whole_input(&src[7..]).expect("parse subslice"); + assert!(errs.is_empty()); + + let p_after = capture!(('s', start_of_input()) => ()); + assert!(p_after.parse_whole_input(&src[7..]).is_err()); +} From 3f71593f92dfdc66549eb2b7795f2b7a0c1d7bde Mon Sep 17 00:00:00 2001 From: ArneLaptop Date: Mon, 29 Jun 2026 13:42:03 +0200 Subject: [PATCH 2/3] added repeat syntax for bounded repeats --- CHANGELOG.md | 1 + guide/02-core-concepts.md | 1 + guide/06-parser-matcher-reference.md | 25 +++++ src/matcher/mod.rs | 4 +- src/matcher/multiple.rs | 21 ++-- src/matcher/one_or_more.rs | 25 ++--- src/matcher/optional.rs | 14 ++- src/matcher/repeat.rs | 147 +++++++++++++++++++++++++++ tests/repeat.rs | 99 ++++++++++++++++++ 9 files changed, 303 insertions(+), 34 deletions(-) create mode 100644 src/matcher/repeat.rs create mode 100644 tests/repeat.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce7f29..1349967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `matcher::start_of_input()` and `matcher::end_of_input()` zero-width matchers for input-boundary checks in grammars; `end_of_input()` is equivalent to `negative_lookahead(AnyToken)`. +- `matcher::repeat(matcher, bounds)` for bounded greedy repetition with Rust count ranges (`2..5`, `1..`, `2..=4`, `3..=3`, etc.); related to `many` (`0..`), `one_or_more` (`1..`), and `optional` (`0..=1`). ## [0.2.1] - 2026-06-18 diff --git a/guide/02-core-concepts.md b/guide/02-core-concepts.md index 9ac332a..ca2424e 100644 --- a/guide/02-core-concepts.md +++ b/guide/02-core-concepts.md @@ -56,6 +56,7 @@ Use lookahead to avoid ambiguous parses and to improve diagnostics. - `many(x)` for zero or more - `one_or_more(x)` for one or more - `optional(x)` for optional segments +- `repeat(x, bounds)` for a bounded number of repetitions (e.g. `repeat(x, 2..5)` for 2–4 times, half-open on the count) These are core building blocks for lists, whitespace, and token groups. diff --git a/guide/06-parser-matcher-reference.md b/guide/06-parser-matcher-reference.md index 6b32e2b..32e6015 100644 --- a/guide/06-parser-matcher-reference.md +++ b/guide/06-parser-matcher-reference.md @@ -295,6 +295,31 @@ let _digit_run = one_or_more('0'..='9'); Use ranges for character or token classes. +### `repeat` + +`repeat(matcher, bounds)` is greedy bounded repetition. The second argument is a +**repetition count** [`RangeBounds`](https://doc.rust-lang.org/std/ops/trait.RangeBounds.html), +not a token class range. + +```rust +use marser::matcher::repeat::repeat; + +let _two_to_four = repeat('0'..='9', 2..5); // 2, 3, or 4 digits (half-open count) +let _at_least_two = repeat('a', 2..); +let _exactly_three = repeat('b', 3..=3); +``` + +Count ranges use the same half-open rules as Rust: `2..5` means 2, 3, or 4 +repetitions. Use `*name` binds inside `repeat(...)` when capturing each +occurrence. + +Equivalences for unbounded forms: + +- `many(m)` ≈ `repeat(m, 0..)` +- `one_or_more(m)` ≈ `repeat(m, 1..)` +- `optional(m)` ≈ `repeat(m, 0..=1)` (same attempt count; `optional` always + reports match success when the inner matcher fails once) + ### `many` `many(matcher)` is greedy zero-or-more repetition. It always succeeds, stops when diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 12e1aef..e46a11d 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -4,7 +4,7 @@ //! # For users //! //! - Matchers describe **structure**: sequences (`(a, b)`), [`crate::one_of::one_of`], repetition -//! ([`multiple::many`], [`one_or_more()`], [`optional()`]), lookahead ([`positive_lookahead()`], +//! ([`repeat()`], [`multiple::many`], [`one_or_more()`], [`optional()`]), lookahead ([`positive_lookahead()`], //! [`negative_lookahead()`]), and [`commit_on()`] for committed sub-rules. //! - They are composed with parsers through [`crate::capture`]; see [`crate::guide::capture_and_binds`]. //! - Extend matchers with [`MatcherCombinator`] (`with_label`, `try_insert_if_missing`, `unwanted`, …). @@ -44,6 +44,7 @@ pub mod one_or_more; pub mod optional; pub mod parser_matcher; pub mod positive_lookahead; +pub mod repeat; pub(crate) mod runner; pub mod sequence; pub mod string; @@ -64,6 +65,7 @@ pub use one_or_more::{OneOrMore, one_or_more}; pub use optional::{Optional, optional}; pub use parser_matcher::ParserMatcher; pub use positive_lookahead::{PositiveLookahead, positive_lookahead}; +pub use repeat::{Repeat, repeat}; pub(crate) use runner::{DirectMatchRunner, MatchRunner, NoMemoizeBacktrackingRunner}; pub use string::StringMatcher; pub use to_parser::ToParser; diff --git a/src/matcher/multiple.rs b/src/matcher/multiple.rs index abc69c5..09fabf8 100644 --- a/src/matcher/multiple.rs +++ b/src/matcher/multiple.rs @@ -3,7 +3,7 @@ use crate::{ error::{MatcherRunError, error_handler::ErrorHandler}, input::{Input, InputStream}, - matcher::{MatchRunner, Matcher}, + matcher::{MatchRunner, Matcher, repeat::run_repeat_loop}, }; /// Greedy `matcher*` at the matcher level (always reports match success after the loop). @@ -45,16 +45,13 @@ where Runner: MatchRunner<'a, 'src, Inp, MRes = MRes>, 'src: 'a, { - loop { - // TODO: maybe throw an error if an infinite loop is detected. - let before = input.get_pos(); - if !runner.run_match::<_, M, _>(&self.matcher, error_handler, input)? { - break; - } - if input.get_pos().into() == before.into() { - break; - } - } - Ok(true) + run_repeat_loop::( + &self.matcher, + 0, + None, + runner, + error_handler, + input, + ) } } diff --git a/src/matcher/one_or_more.rs b/src/matcher/one_or_more.rs index 7cfc24c..a1a6b88 100644 --- a/src/matcher/one_or_more.rs +++ b/src/matcher/one_or_more.rs @@ -3,7 +3,7 @@ use crate::{ error::{MatcherRunError, error_handler::ErrorHandler}, input::{Input, InputStream}, - matcher::{MatchRunner, Matcher}, + matcher::{MatchRunner, Matcher, repeat::run_repeat_loop}, }; /// Requires at least one successful `matcher`, then behaves like greedy repetition. @@ -47,20 +47,13 @@ where Runner: MatchRunner<'a, 'src, Inp, MRes = MRes>, 'src: 'a, { - // First match is mandatory — propagate the error if absent. - if !runner.run_match::<_, M, _>(&self.matcher, error_handler, input)? { - return Ok(false); - } - // Remaining matches are optional (same as Multiple). - loop { - let before = input.get_pos(); - if !runner.run_match::<_, M, _>(&self.matcher, error_handler, input)? { - break; - } - if input.get_pos().into() == before.into() { - break; - } - } - Ok(true) + run_repeat_loop::( + &self.matcher, + 1, + None, + runner, + error_handler, + input, + ) } } diff --git a/src/matcher/optional.rs b/src/matcher/optional.rs index 27a3dc5..6e440e2 100644 --- a/src/matcher/optional.rs +++ b/src/matcher/optional.rs @@ -3,7 +3,7 @@ use crate::{ error::{MatcherRunError, error_handler::ErrorHandler}, input::{Input, InputStream}, - matcher::{MatchRunner, Matcher}, + matcher::{MatchRunner, Matcher, repeat::run_repeat_loop}, }; /// `matcher?` at the matcher level. @@ -46,9 +46,13 @@ where Runner: MatchRunner<'a, 'src, Inp, MRes = MRes>, 'src: 'a, { - if runner.run_match::<_, M, _>(&self.matcher, error_handler, input)? { - return Ok(true); - } - Ok(true) + run_repeat_loop::( + &self.matcher, + 0, + Some(1), + runner, + error_handler, + input, + ) } } diff --git a/src/matcher/repeat.rs b/src/matcher/repeat.rs new file mode 100644 index 0000000..6280123 --- /dev/null +++ b/src/matcher/repeat.rs @@ -0,0 +1,147 @@ +//! Bounded repetition matcher (`repeat(m, bounds)`). + +use alloc::boxed::Box; +use core::{ + fmt::{Debug, Display}, + ops::{Bound, RangeBounds}, +}; + +use crate::{ + error::{MatcherRunError, error_handler::ErrorHandler}, + input::{Input, InputStream}, + matcher::{MatchRunner, Matcher}, +}; + +/// Decodes [`RangeBounds`] into a minimum count and inclusive maximum (`None` = unbounded). +fn decode_bounds>(bounds: &B) -> (usize, Option) { + let min = match bounds.start_bound() { + Bound::Unbounded => 0, + Bound::Included(&n) => n, + Bound::Excluded(&n) => n.saturating_add(1), + }; + + let max_inclusive = match bounds.end_bound() { + Bound::Unbounded => None, + Bound::Included(&n) => Some(n), + Bound::Excluded(&n) => { + if n <= min { + return (min, Some(min.wrapping_sub(1))); + } + Some(n - 1) + } + }; + + (min, max_inclusive) +} + +/// Greedy repetition with mandatory minimum, optional maximum, and no-progress guard. +#[inline(always)] +pub(crate) fn run_repeat_loop<'a, 'src, Inp, MRes, Runner, M, Match, EH>( + matcher: &'a Match, + min: usize, + max_inclusive: Option, + runner: &mut Runner, + error_handler: &mut EH, + input: &mut InputStream<'src, Inp>, +) -> Result +where + Inp: Input<'src>, + Match: Matcher<'src, Inp, MRes>, + Runner: MatchRunner<'a, 'src, Inp, MRes = MRes>, + M: crate::mode::Mode, + EH: ErrorHandler, + 'src: 'a, +{ + if max_inclusive.is_some_and(|m| m < min) { + return Ok(false); + } + + let mut count = 0usize; + + while count < min { + if !runner.run_match::<_, M, _>(matcher, error_handler, input)? { + return Ok(false); + } + count += 1; + } + + loop { + if max_inclusive.is_some_and(|m| count >= m) { + break; + } + let before = input.get_pos(); + if !runner.run_match::<_, M, _>(matcher, error_handler, input)? { + break; + } + if input.get_pos().into() == before.into() { + break; + } + count += 1; + } + + Ok(count >= min && max_inclusive.is_none_or(|m| count <= m)) +} + +/// Greedy bounded repetition of `matcher`. +#[derive(Clone, Debug)] +pub struct Repeat { + matcher: Match, + min: usize, + max_inclusive: Option, +} + +impl Repeat { + fn new(matcher: Match, min: usize, max_inclusive: Option) -> Self { + Self { + matcher, + min, + max_inclusive, + } + } +} + +/// `repeat(matcher, bounds)` — match `matcher` between `min` and `max` times (inclusive). +/// +/// `bounds` is a [`RangeBounds`] repetition count, e.g. `2..5` (2–4 times, half-open), +/// `1..`, `2..=4`, `..=3`, or `3..=3` for exactly three. +pub fn repeat(matcher: Match, bounds: B) -> Repeat +where + B: RangeBounds, +{ + let (min, max_inclusive) = decode_bounds(&bounds); + Repeat::new(matcher, min, max_inclusive) +} + +impl super::MatcherCombinator for Repeat where Match: super::MatcherCombinator {} + +impl<'src, Inp: Input<'src>, MRes, Match> super::internal::MatcherImpl<'src, Inp, MRes> + for Repeat +where + Match: Matcher<'src, Inp, MRes>, + Inp: Input<'src>, +{ + const CAN_MATCH_DIRECTLY: bool = Match::CAN_MATCH_DIRECTLY; + const HAS_PROPERTY: bool = Match::HAS_PROPERTY; + const CAN_FAIL: bool = true; + + #[inline] + fn match_with_runner<'a, Runner, M: crate::mode::Mode>( + &'a self, + runner: &mut Runner, + error_handler: &mut impl ErrorHandler, + input: &mut InputStream<'src, Inp>, + ) -> Result + where + Runner: MatchRunner<'a, 'src, Inp, MRes = MRes>, + 'src: 'a, + { + run_repeat_loop::( + &self.matcher, + self.min, + self.max_inclusive, + runner, + error_handler, + input, + ) + } +} \ No newline at end of file diff --git a/tests/repeat.rs b/tests/repeat.rs new file mode 100644 index 0000000..76a63f9 --- /dev/null +++ b/tests/repeat.rs @@ -0,0 +1,99 @@ +//! AI assistance: this file was written with AI assistance. The maintainer reviewed it and did not find errors. +//! +//! Integration tests for [`marser::matcher::repeat`]. + +use marser::capture; +use marser::matcher::{ + many, one_or_more, optional, repeat, +}; +use marser::parser::{token_parser, Parser}; + +fn parse_ok<'src, P: Parser<'src, &'src str>>(parser: P, src: &'src str) -> bool { + parser.parse_str(src).is_ok() +} + +#[test] +fn repeat_exactly_three() { + let p = capture!((repeat('a', 3..=3), marser::matcher::end_of_input()) => ()); + assert!(parse_ok(p.clone(), "aaa")); + assert!(!parse_ok(p.clone(), "aa")); + assert!(!parse_ok(p, "aaaa")); +} + +#[test] +fn repeat_half_open_range() { + let p = capture!((repeat('a', 2..5), marser::matcher::end_of_input()) => ()); + assert!(parse_ok(p.clone(), "aa")); + assert!(parse_ok(p.clone(), "aaa")); + assert!(parse_ok(p.clone(), "aaaa")); + assert!(!parse_ok(p.clone(), "a")); + assert!(!parse_ok(p, "aaaaa")); +} + +#[test] +fn repeat_unbounded_from_min() { + let p = capture!((repeat('a', 2..), marser::matcher::end_of_input()) => ()); + assert!(parse_ok(p.clone(), "aa")); + assert!(parse_ok(p.clone(), "aaaa")); + assert!(!parse_ok(p, "a")); +} + +#[test] +fn repeat_inclusive_range() { + let p = capture!((repeat('a', 2..=4), marser::matcher::end_of_input()) => ()); + assert!(parse_ok(p.clone(), "aa")); + assert!(parse_ok(p.clone(), "aaaa")); + assert!(!parse_ok(p.clone(), "a")); + assert!(!parse_ok(p, "aaaaa")); +} + +#[test] +fn repeat_empty_range_always_fails() { + let p = capture!(repeat('a', 2..2) => ()); + assert!(!parse_ok(p.clone(), "")); + assert!(!parse_ok(p, "aa")); +} + +#[test] +fn repeat_stops_on_no_progress() { + let p = capture!((repeat((), 0..), marser::matcher::end_of_input()) => ()); + assert!(parse_ok(p, "")); +} + +#[test] +fn repeat_capture_bounded() { + let digit = token_parser( + |c: &char| c.is_ascii_digit(), + |c| c.to_digit(10).unwrap(), + ); + let p = capture!(repeat(bind!(digit.clone(), *ds), 2..=3) => ds); + let (ds, _) = p.parse_str("12").expect("two digits"); + assert_eq!(ds, vec![1, 2]); + let (ds, _) = p.parse_str("123").expect("three digits"); + assert_eq!(ds, vec![1, 2, 3]); + assert!(p.parse_str("1").is_err()); + assert!(p.parse_str("1234").is_err()); +} + +#[test] +fn many_still_accepts_unbounded_run() { + let p = capture!((many('a'), marser::matcher::end_of_input()) => ()); + assert!(parse_ok(p.clone(), "")); + assert!(parse_ok(p.clone(), "aaa")); + assert!(!parse_ok(p, "b")); +} + +#[test] +fn one_or_more_fails_on_empty() { + let p = capture!((one_or_more('0'..='9'), marser::matcher::end_of_input()) => ()); + assert!(!parse_ok(p.clone(), "")); + assert!(parse_ok(p, "123")); +} + +#[test] +fn optional_unchanged() { + let p = capture!((optional('-'), marser::matcher::end_of_input()) => ()); + assert!(parse_ok(p.clone(), "")); + assert!(parse_ok(p.clone(), "-")); + assert!(!parse_ok(p, "--")); +} From 6e132abc96c75d317056234b8c4281335c951396 Mon Sep 17 00:00:00 2001 From: ArneLaptop Date: Mon, 29 Jun 2026 16:58:15 +0200 Subject: [PATCH 3/3] fixed clippy --- src/matcher/repeat.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/matcher/repeat.rs b/src/matcher/repeat.rs index 6280123..e32e35d 100644 --- a/src/matcher/repeat.rs +++ b/src/matcher/repeat.rs @@ -1,8 +1,7 @@ //! Bounded repetition matcher (`repeat(m, bounds)`). -use alloc::boxed::Box; use core::{ - fmt::{Debug, Display}, + fmt::Debug, ops::{Bound, RangeBounds}, };