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
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ below.
the public API).
- `EngineError` implements `Clone`; `StringGenerator` implements `Debug` and
`FusedIterator`; `ExecutionProfileBuilder` implements `Debug` and `Clone`.
- `GenerationOrder`, the order `generate_strings`/`iter_strings` walk a
language in. `Exhaustive` is the previous behaviour: shortest strings
first, one path expanded in full before the next. `Sampled` covers every
shape the automaton holds before asking any of them for a second string,
and picks representative characters (`a`, `0`, `A`, ` `, ...) spread over
each range, so `.*abc.*` yields `abc`, `abc `, `aabc`, `0abc`, ... instead
of a million variations of `abc\u{0}`. It stays deterministic and pages
with `offset` the same way.
- `GenerationOptions`, what `generate_strings`/`iter_strings` may generate:
the order, plus an optional charset (`with_charset(CharRange)`) that keeps
generation to a set of characters. Only strings made entirely of them come
out — a path needing a ruled-out character is dropped whole, never
shortened — so `.*abc.*` over `[ -~]` yields `abc`, `abc `, `abc!`, ...

### Changed
- `Term::to_regex`/`to_pattern` and `FastAutomaton::to_regex` are now fallible
Expand All @@ -70,8 +83,10 @@ below.
`&[a, b]`, `[&a, &b]`, and `Vec<Term>` all work without cloning.
- `repeat` now takes `impl RangeBounds<u32>` (e.g. `3..6`, `..=2`) instead of
explicit min/max parameters.
- `generate_strings` now takes `(limit, offset)` for pagination instead of a
single `count`.
- `generate_strings` now takes `(limit, offset, options)`: pagination instead
of a single `count`, plus the `GenerationOptions` to generate under (an
order, and optionally a charset). A `GenerationOrder` converts into options,
so it can be passed on its own. `iter_strings` takes the same `options`.
- `is_empty`, `is_total`, and `is_empty_string` now return
`Result<bool, EngineError>` instead of `bool`.
- `are_equivalent`/`is_subset_of` renamed to `equivalent`/`subset`.
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
The `regex` crate tells you whether a *string* matches a pattern. **RegexSolver treats patterns as the sets of strings they match** — so you can intersect, subtract, compare, complement, and enumerate them, and get the result back as a regex.

```rust
use regexsolver::Term;
use regexsolver::{Term, fast_automaton::GenerationOrder};

let a: Term = "(ab|xy){2}".parse()?;
let b: Term = ".*xy".parse()?;
Expand All @@ -21,13 +21,13 @@ assert_eq!(both.to_pattern()?, "(ab|xy)xy");
assert!(both.matches("abxy")?);

// ...and sample them:
assert_eq!(both.generate_strings(2, 0)?, ["xyxy", "abxy"]);
assert_eq!(both.generate_strings(2, 0, GenerationOrder::Exhaustive)?, ["xyxy", "abxy"]);
```

## What would you use this for?

- **Safe migrations** - `old_rule.subset(&new_rule)?`: does the new validation pattern accept *everything* the old one did?
- **Test-data generation** - `term.generate_strings(100, 0)?`: produce strings matching any pattern, with pagination.
- **Test-data generation** - `term.generate_strings(100, 0, GenerationOrder::Sampled)?`: produce strings matching any pattern, spread over the cases the pattern allows, restricted to the characters you can use, with pagination.
- **Rule analysis**: find shadowed or overlapping routes, firewall rules, and validators with `intersection` / `difference`.
- **Equivalence proofs** - `a.equivalent(&b)?`: show that two differently-written patterns match exactly the same strings.
- **Pattern simplification**: every operation returns a `Term` you can turn back into a regex pattern with `to_pattern()`.
Expand Down Expand Up @@ -90,8 +90,8 @@ RegexSolver is based on the [regex-syntax](https://docs.rs/regex-syntax/0.8.5/re
| `concat(&self, terms)` / `repeat(&self, range)` | Sequence and repeat languages; `range` is any Rust range expression (`2..=5`, `1..`, `..3`, ...). |
| `equivalent(&self, other)` / `subset(&self, other)` | Compare languages. |
| `is_empty()` / `is_total()` / `length()` / `cardinality()` | Analyze a language: matches nothing? everything? string lengths? how many strings? |
| `generate_strings(limit, offset)` | Enumerate matching strings eagerly (call `minimize()` once first when paginating). |
| `iter_strings()` | Lazy iterator equivalent; computes the automaton once and yields strings in batches. |
| `generate_strings(limit, offset, options)` | Enumerate matching strings eagerly (call `determinize()` or `minimize()` once first when paginating). |
| `iter_strings(options)` | Lazy iterator equivalent; computes the deterministic automaton once and yields strings in batches. |
| `to_pattern()` / `to_automaton()` / `to_regex()` | Convert back out. |

All fallible operations return `Result<_, EngineError>`.
Expand Down Expand Up @@ -166,7 +166,7 @@ Automaton operations can blow up on adversarial inputs, so the engine is built t
### Time-Bounded Execution

```rust
use regexsolver::{Term, execution_profile::{ExecutionProfile, ExecutionProfileBuilder}, error::EngineError};
use regexsolver::{Term, execution_profile::{ExecutionProfile, ExecutionProfileBuilder}, error::EngineError, fast_automaton::GenerationOrder};

let term = Term::from_pattern(".*abc.*cdef.*sqdsqf.*")?;

Expand All @@ -176,7 +176,7 @@ let execution_profile = ExecutionProfileBuilder::new()

// We run the operation with the defined limitation
execution_profile.run(|| {
assert_eq!(EngineError::OperationTimeOutError, term.generate_strings(1000, 1_000_000).unwrap_err());
assert_eq!(EngineError::OperationTimeOutError, term.generate_strings(1000, 1_000_000, GenerationOrder::Exhaustive).unwrap_err());
});
```

Expand Down
35 changes: 32 additions & 3 deletions benches/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main};
use regex_charclass::char::Char;
use regexsolver::fast_automaton::FastAutomaton;
use regexsolver::fast_automaton::{FastAutomaton, GenerationOptions, GenerationOrder};
use regexsolver::regex::RegularExpression;
use regexsolver::{CharRange, Term};
use std::hint::black_box;
Expand Down Expand Up @@ -215,13 +215,42 @@ fn bench_generate_strings(c: &mut Criterion) {

let automaton = dfa("[a-z]{1,4}");
group.bench_function("first_2000", |b| {
b.iter(|| black_box(&automaton).generate_strings(2000, 0).unwrap())
b.iter(|| {
black_box(&automaton)
.generate_strings(2000, 0, GenerationOrder::Exhaustive)
.unwrap()
})
});

// The offset fast-skips whole subtrees by counting paths.
let deep = dfa("[a-z]{1,10}");
group.bench_function("deep_offset", |b| {
b.iter(|| black_box(&deep).generate_strings(100, 1_000_000).unwrap())
b.iter(|| {
black_box(&deep)
.generate_strings(100, 1_000_000, GenerationOrder::Exhaustive)
.unwrap()
})
});

// Sampling walks the automaton once per pass instead of settling on one
// path, so it pays for the paths it spreads over.
group.bench_function("sampled_2000", |b| {
b.iter(|| {
black_box(&automaton)
.generate_strings(2000, 0, GenerationOrder::Sampled)
.unwrap()
})
});

// A charset costs one intersection per transition condition, up front.
let printable = CharRange::new_from_range(Char::new(' ')..=Char::new('~'));
let options = GenerationOptions::from(GenerationOrder::Exhaustive).with_charset(printable);
group.bench_function("charset_2000", |b| {
b.iter(|| {
black_box(&automaton)
.generate_strings(2000, 0, options.clone())
.unwrap()
})
});

group.finish();
Expand Down
39 changes: 36 additions & 3 deletions examples/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,55 @@
//!
//! ```text
//! cargo run --example generate -- "[a-z]{2}[0-9]" 20
//! cargo run --example generate -- "[a-z]{2}[0-9]" 20 sampled
//! cargo run --example generate -- ".{4}" 20 sampled "[ -~]"
//! ```

use regexsolver::Term;
use regexsolver::regex::RegularExpression;
use regexsolver::{
Term,
fast_automaton::{GenerationOptions, GenerationOrder},
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let Some(pattern) = args.next() else {
eprintln!("Usage: cargo run --example generate -- <pattern> [count]");
eprintln!(
"Usage: cargo run --example generate -- <pattern> [count] [exhaustive|sampled] [charset]"
);
std::process::exit(2);
};
let count: usize = args.next().map(|c| c.parse()).transpose()?.unwrap_or(10);

// `exhaustive` sweeps the language in order, `sampled` spreads the strings
// over the shapes the pattern allows (see `GenerationOrder`).
let order = match args.next().as_deref() {
None | Some("exhaustive") => GenerationOrder::Exhaustive,
Some("sampled") => GenerationOrder::Sampled,
Some(other) => {
eprintln!("Unknown order {other:?}, expected `exhaustive` or `sampled`");
std::process::exit(2);
}
};

let mut options = GenerationOptions::from(order);

// A charset is a plain character class, e.g. "[ -~]" for printable ASCII:
// only the strings made entirely of its characters are generated.
if let Some(charset) = args.next() {
match RegularExpression::new(&charset)? {
RegularExpression::Character(charset) => options = options.with_charset(charset),
_ => {
eprintln!("The charset has to be a single character class, e.g. \"[ -~]\"");
std::process::exit(2);
}
}
}

// Minimize once: pagination over the same minimized term yields
// disjoint, consistent pages (see `Term::generate_strings`).
let term = Term::from_pattern(&pattern)?.minimize()?;
for string in term.generate_strings(count, 0)? {
for string in term.generate_strings(count, 0, options)? {
println!("{string:?}");
}

Expand Down
7 changes: 5 additions & 2 deletions examples/relate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! cargo run --example relate -- "(abc|de){2}" ".*xy"
//! ```

use regexsolver::Term;
use regexsolver::{Term, fast_automaton::GenerationOrder};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
Expand Down Expand Up @@ -34,7 +34,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("a ∩ b = [] (no string matches both)");
} else {
println!("a ∩ b = {}", intersection.to_pattern()?);
println!(" e.g. {:?}", intersection.generate_strings(5, 0)?);
println!(
" e.g. {:?}",
intersection.generate_strings(5, 0, GenerationOrder::Sampled)?
);
}

let pattern_or_empty = |term: Term| -> Result<String, Box<dyn std::error::Error>> {
Expand Down
7 changes: 7 additions & 0 deletions proptest-regressions/regex/analyze/number_of_states.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc c974b0759317710e3901bbc0b13a84ff1cd218b3abbaae98f4fe0ab7bc03505a # shrinks to regex = Alternation([Character(RangeSet([Char('a'), Char('a')])), Concat([Repetition(Character(RangeSet([Char('a'), Char('a')])), 0, None), Character(RangeSet([Char('a'), Char('a')]))])])
35 changes: 29 additions & 6 deletions src/execution_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::error::EngineError;
///
/// ## Limiting the number of states
/// ```
/// use regexsolver::{Term, execution_profile::{ExecutionProfile, ExecutionProfileBuilder}, error::EngineError};
/// use regexsolver::{Term, execution_profile::{ExecutionProfile, ExecutionProfileBuilder}, error::EngineError, fast_automaton::GenerationOrder};
///
/// let term1 = Term::from_pattern(".*abcdef.*").unwrap();
/// let term2 = Term::from_pattern(".*defabc.*").unwrap();
Expand All @@ -27,7 +27,7 @@ use crate::error::EngineError;
///
/// ## Limiting the execution time
/// ```
/// use regexsolver::{Term, execution_profile::{ExecutionProfile, ExecutionProfileBuilder}, error::EngineError};
/// use regexsolver::{Term, execution_profile::{ExecutionProfile, ExecutionProfileBuilder}, error::EngineError, fast_automaton::GenerationOrder};
///
/// let term = Term::from_pattern(".*abc.*cdef.*sqdsqf.*").unwrap();
///
Expand All @@ -36,7 +36,7 @@ use crate::error::EngineError;
/// .build();
///
/// execution_profile.run(|| {
/// assert_eq!(EngineError::OperationTimeOutError, term.generate_strings(1000, 1_000_000).unwrap_err());
/// assert_eq!(EngineError::OperationTimeOutError, term.generate_strings(100_000_000, 0, GenerationOrder::Exhaustive).unwrap_err());
/// });
/// ```
///
Expand Down Expand Up @@ -119,6 +119,15 @@ impl ExecutionProfile {
ThreadLocalParams::get_execution_profile()
}

/// Whether a execution deadline is configured. When it is not,
/// [`assert_not_timed_out`](Self::assert_not_timed_out) can be
/// skipped entirely instead of being computed for a check that
/// cannot fail.
#[inline]
pub fn limits_execution_time(&self) -> bool {
self.execution_deadline.is_some()
}

/// Assert that `execution_timeout` is not exceeded.
///
/// Return empty if `execution_timeout` is not exceeded.
Expand All @@ -136,6 +145,16 @@ impl ExecutionProfile {
}
}

/// Whether a maximum number of states is configured. When it is not, the
/// state-count heuristics feeding
/// [`assert_max_number_of_states`](Self::assert_max_number_of_states) can
/// be skipped entirely instead of being computed for a check that cannot
/// fail.
#[inline]
pub fn limits_number_of_states(&self) -> bool {
self.max_number_of_states.is_some()
}

/// Assert that `max_number_of_states` is not exceeded.
///
/// `max_number_of_states` is the largest number of states an automaton may
Expand Down Expand Up @@ -372,7 +391,7 @@ impl ThreadLocalParams {

#[cfg(test)]
mod tests {
use crate::{Term, regex::RegularExpression};
use crate::{Term, fast_automaton::GenerationOrder, regex::RegularExpression};

use super::*;

Expand Down Expand Up @@ -531,7 +550,10 @@ mod tests {
assert!(term.is_total().is_ok());
assert!(term.cardinality().is_ok());
assert!(term.minimize().is_ok());
assert!(term.generate_strings(5, 0).is_ok());
assert!(
term.generate_strings(5, 0, GenerationOrder::Exhaustive)
.is_ok()
);

// ...and the rest of the API never needed one.
assert!(term.concat(std::slice::from_ref(&other)).is_ok());
Expand Down Expand Up @@ -577,7 +599,8 @@ mod tests {
.run(|| {
assert_eq!(
EngineError::OperationTimeOutError,
term.generate_strings(100, 1_000_000).unwrap_err()
term.generate_strings(100_000_000, 1_000_000, GenerationOrder::Exhaustive)
.unwrap_err()
);

let run_duration = Instant::now().duration_since(start_time).as_millis();
Expand Down
Loading
Loading