use crate::compat::{Box, Cow, String, ToString, Vec};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ValidationState {
Invalid,
#[default]
Intermediate,
Acceptable,
}
impl ValidationState {
pub fn is_acceptable(self) -> bool {
matches!(self, Self::Acceptable)
}
pub fn is_invalid(self) -> bool {
matches!(self, Self::Invalid)
}
}
pub trait Validator: core::fmt::Debug + Send + Sync {
fn state(&self, text: &str) -> ValidationState;
fn message(&self) -> &'static str;
fn message_for(&self, _text: &str) -> Cow<'_, str> {
Cow::Borrowed(self.message())
}
fn fixup(&self, _text: &str) -> Option<String> {
None
}
fn filter(&self, text: &str) -> String {
text.to_string()
}
fn builtin(&self) -> Option<InputValidator> {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputValidator {
Phone,
Email,
}
impl InputValidator {
pub(crate) fn filter(self, text: &str) -> crate::compat::String {
match self {
Self::Phone => text
.chars()
.filter(|ch| ch.is_ascii_digit() || matches!(ch, '+' | '-' | ' ' | '(' | ')'))
.collect::<crate::compat::String>()
.trim()
.chars()
.collect(),
Self::Email => {
text.chars().filter(|ch| !ch.is_whitespace() && !ch.is_control()).collect()
}
}
}
pub(crate) fn check(self, text: &str) -> (bool, &'static str) {
if text.is_empty() {
return (true, "");
}
match self {
Self::Phone => {
let digits = text.chars().filter(|ch| ch.is_ascii_digit()).count();
if (7..=15).contains(&digits) {
(true, "")
} else {
(false, "Enter a phone number (7 to 15 digits)")
}
}
Self::Email => {
let mut parts = text.split('@');
let local = parts.next().unwrap_or_default();
let domain = parts.next().unwrap_or_default();
let single_at = parts.next().is_none();
let domain_valid = !domain.is_empty()
&& domain.contains('.')
&& domain.split('.').all(|label| !label.is_empty());
if single_at && !local.is_empty() && domain_valid && text.len() <= 254 {
(true, "")
} else {
(false, "Enter an e-mail address (name@domain.com)")
}
}
}
}
pub fn fixup(self, text: &str) -> Option<String> {
match self {
Self::Phone => {
let trimmed =
text.trim_matches(|ch: char| matches!(ch, '+' | '-' | ' ' | '(' | ')'));
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
Self::Email => None,
}
}
}
impl Validator for InputValidator {
fn state(&self, text: &str) -> ValidationState {
if self.check(text).0 {
ValidationState::Acceptable
} else {
ValidationState::Invalid
}
}
fn message(&self) -> &'static str {
match self {
Self::Phone => "Enter a phone number (7 to 15 digits)",
Self::Email => "Enter an e-mail address (name@domain.com)",
}
}
fn fixup(&self, text: &str) -> Option<String> {
(*self).fixup(text)
}
fn filter(&self, text: &str) -> String {
(*self).filter(text)
}
fn builtin(&self) -> Option<InputValidator> {
Some(*self)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RangeValidator {
Int {
minimum: i64,
maximum: i64,
},
Double {
minimum: f64,
maximum: f64,
},
}
impl RangeValidator {
pub fn int(minimum: i64, maximum: i64) -> Self {
Self::Int { minimum: minimum.min(maximum), maximum: maximum.max(minimum) }
}
pub fn double(minimum: f64, maximum: f64) -> Self {
Self::Double { minimum: minimum.min(maximum), maximum: maximum.max(minimum) }
}
fn range_message(self) -> &'static str {
match self {
Self::Int { .. } => "Enter a whole number in range",
Self::Double { .. } => "Enter a number in range",
}
}
}
impl Validator for RangeValidator {
fn state(&self, text: &str) -> ValidationState {
if text.is_empty() {
return ValidationState::Intermediate;
}
match self {
Self::Int { minimum, maximum } => match text.trim().parse::<i64>() {
Ok(value) if (*minimum..=*maximum).contains(&value) => ValidationState::Acceptable,
Ok(_) => ValidationState::Invalid,
Err(_) => ValidationState::Intermediate,
},
Self::Double { minimum, maximum } => match text.trim().parse::<f64>() {
Ok(value) if value.is_finite() && (*minimum..=*maximum).contains(&value) => {
ValidationState::Acceptable
}
Ok(value) if value.is_finite() => ValidationState::Invalid,
_ => ValidationState::Intermediate,
},
}
}
fn message(&self) -> &'static str {
self.range_message()
}
fn message_for(&self, text: &str) -> Cow<'_, str> {
let trimmed = text.trim();
match self {
Self::Int { minimum, maximum } => match trimmed.parse::<i64>() {
Ok(value) if !(*minimum..=*maximum).contains(&value) => {
Cow::Owned(format!("{value} is outside {minimum}..={maximum}"))
}
_ => Cow::Borrowed(self.range_message()),
},
Self::Double { minimum, maximum } => match trimmed.parse::<f64>() {
Ok(value) if value.is_finite() && !(*minimum..=*maximum).contains(&value) => {
Cow::Owned(format!("{value} is outside {minimum}..={maximum}"))
}
_ => Cow::Borrowed(self.range_message()),
},
}
}
fn fixup(&self, text: &str) -> Option<String> {
match self {
Self::Int { minimum, maximum } => text
.trim()
.parse::<i64>()
.ok()
.map(|value| value.clamp(*minimum, *maximum).to_string()),
Self::Double { minimum, maximum } => text
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.clamp(*minimum, *maximum).to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatternValidator {
pattern: String,
message: &'static str,
}
impl PatternValidator {
pub fn new(pattern: impl Into<String>, message: &'static str) -> Self {
Self { pattern: pattern.into(), message }
}
pub fn pattern(&self) -> &str {
&self.pattern
}
pub fn is_match(&self, text: &str) -> bool {
match CompiledPattern::compile(&self.pattern) {
Some(compiled) => compiled.is_match(text),
None => false,
}
}
}
impl Validator for PatternValidator {
fn state(&self, text: &str) -> ValidationState {
if text.is_empty() {
ValidationState::Intermediate
} else if self.is_match(text) {
ValidationState::Acceptable
} else {
ValidationState::Invalid
}
}
fn message(&self) -> &'static str {
self.message
}
}
#[derive(Debug, Clone)]
enum Node {
Atom { kind: AtomKind, min: usize, max: Option<usize> },
Group(Vec<Node>),
Alt(Vec<Vec<Node>>),
Repeat { node: Box<Node>, min: usize, max: Option<usize> },
}
#[derive(Debug, Clone)]
enum AtomKind {
Any,
Literal(char),
Class { negated: bool, members: Vec<(char, char)> },
}
impl AtomKind {
fn matches(&self, ch: char) -> bool {
match self {
Self::Any => true,
Self::Literal(literal) => *literal == ch,
Self::Class { negated, members } => {
let inside = members.iter().any(|(lo, hi)| (*lo..=*hi).contains(&ch));
inside != *negated
}
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct CompiledPattern {
anchored_start: bool,
anchored_end: bool,
nodes: Vec<Node>,
}
impl CompiledPattern {
pub(crate) fn compile(pattern: &str) -> Option<Self> {
let chars: Vec<char> = pattern.chars().collect();
let anchored_start = chars.first() == Some(&'^');
let start = if anchored_start { 1 } else { 0 };
let anchored_end = chars.len() > start && chars.last() == Some(&'$');
let limit = if anchored_end { chars.len() - 1 } else { chars.len() };
let (branches, next) = parse_alternation(&chars, start, limit)?;
if next < limit {
return None;
}
Some(Self { anchored_start, anchored_end, nodes: collapse(branches) })
}
pub(crate) fn is_match(&self, text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
self.matches_chars(&chars)
}
fn matches_chars(&self, text: &[char]) -> bool {
if self.anchored_start {
return accepts(&self.nodes, text, 0, self.anchored_end);
}
(0..=text.len()).any(|start| accepts(&self.nodes, text, start, self.anchored_end))
}
}
fn collapse(mut branches: Vec<Vec<Node>>) -> Vec<Node> {
if branches.len() == 1 {
branches.pop().unwrap_or_default()
} else {
vec![Node::Alt(branches)]
}
}
fn parse_alternation(
chars: &[char],
start: usize,
limit: usize,
) -> Option<(Vec<Vec<Node>>, usize)> {
let mut branches = Vec::new();
let mut index = start;
loop {
let (sequence, next) = parse_sequence(chars, index, limit)?;
branches.push(sequence);
index = next;
if index < limit && chars.get(index) == Some(&'|') {
index += 1;
continue;
}
return Some((branches, index));
}
}
fn parse_sequence(chars: &[char], mut index: usize, limit: usize) -> Option<(Vec<Node>, usize)> {
let mut nodes = Vec::new();
while index < limit {
if matches!(chars[index], '|' | ')') {
break;
}
let (node, next) = parse_node(chars, index, limit)?;
nodes.push(node);
index = next;
}
Some((nodes, index))
}
fn parse_node(chars: &[char], index: usize, limit: usize) -> Option<(Node, usize)> {
let ch = chars[index];
let mut next = index;
let base = match ch {
'.' => {
next += 1;
Node::Atom { kind: AtomKind::Any, min: 1, max: Some(1) }
}
'\\' => {
next += 1;
let escaped = *chars.get(next)?;
next += 1;
Node::Atom { kind: AtomKind::Literal(escaped), min: 1, max: Some(1) }
}
'[' => {
let (kind, after) = compile_class(chars, index)?;
next = after;
Node::Atom { kind, min: 1, max: Some(1) }
}
'(' => {
let (branches, after) = parse_alternation(chars, index + 1, limit)?;
if chars.get(after) != Some(&')') {
return None;
}
next = after + 1;
Node::Group(collapse(branches))
}
_ => {
next += 1;
Node::Atom { kind: AtomKind::Literal(ch), min: 1, max: Some(1) }
}
};
if next >= limit {
return Some((base, next));
}
let (min, max) = match chars[next] {
'*' => (0, None),
'+' => (1, None),
'?' => (0, Some(1)),
_ => return Some((base, next)),
};
let node = match base {
Node::Atom { kind, .. } => Node::Atom { kind, min, max },
other => Node::Repeat { node: Box::new(other), min, max },
};
Some((node, next + 1))
}
fn compile_class(chars: &[char], start: usize) -> Option<(AtomKind, usize)> {
let mut index = start + 1;
let mut negated = false;
if chars.get(index) == Some(&'^') {
negated = true;
index += 1;
}
let mut members: Vec<(char, char)> = Vec::new();
let mut first = true;
while index < chars.len() {
let ch = chars[index];
if ch == ']' && !first {
return Some((AtomKind::Class { negated, members }, index + 1));
}
first = false;
let lo = if ch == '\\' {
index += 1;
*chars.get(index)?
} else {
ch
};
index += 1;
if chars.get(index) == Some(&'-') && chars.get(index + 1).is_some_and(|next| *next != ']') {
index += 1;
let hi_raw = chars.get(index).copied()?;
let hi = if hi_raw == '\\' {
index += 1;
*chars.get(index)?
} else {
hi_raw
};
index += 1;
members.push((lo.min(hi), lo.max(hi)));
} else {
members.push((lo, lo));
}
}
None
}
fn accepts(nodes: &[Node], text: &[char], position: usize, anchored_end: bool) -> bool {
ends_of(nodes, text, position).into_iter().any(|end| !anchored_end || end == text.len())
}
fn ends_of(nodes: &[Node], text: &[char], position: usize) -> Vec<usize> {
let mut reached = vec![position];
for node in nodes {
let mut next: Vec<usize> = Vec::new();
for pos in reached {
for end in node_ends(node, text, pos) {
if !next.contains(&end) {
next.push(end);
}
}
}
if next.is_empty() {
return next;
}
reached = next;
}
reached
}
fn node_ends(node: &Node, text: &[char], position: usize) -> Vec<usize> {
match node {
Node::Atom { kind, min, max } => {
let mut ends = Vec::new();
if *min == 0 {
ends.push(position);
}
let mut count = 0usize;
let mut pos = position;
let limit = max.unwrap_or(text.len().saturating_add(1));
while count < limit && pos < text.len() && kind.matches(text[pos]) {
pos += 1;
count += 1;
if count >= *min {
ends.push(pos);
}
}
ends.reverse();
ends
}
Node::Group(nodes) => ends_of(nodes, text, position),
Node::Alt(branches) => {
let mut ends = Vec::new();
for branch in branches {
for end in ends_of(branch, text, position) {
if !ends.contains(&end) {
ends.push(end);
}
}
}
ends
}
Node::Repeat { node, min, max } => {
let mut ends = Vec::new();
if *min == 0 {
ends.push(position);
}
let max_count = max.unwrap_or(text.len().saturating_add(1));
let mut frontier = vec![position];
let mut count = 0usize;
while count < max_count && !frontier.is_empty() {
let mut next: Vec<usize> = Vec::new();
for pos in frontier {
for end in node_ends(node, text, pos) {
if end > pos && !next.contains(&end) {
next.push(end);
}
}
}
count += 1;
for pos in &next {
if count >= *min && !ends.contains(pos) {
ends.push(*pos);
}
}
frontier = next;
}
ends.reverse();
ends
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_phone_grammar_keeps_only_its_own_characters() {
assert_eq!(InputValidator::Phone.filter("call +1 (555) 010-9999!"), "+1 (555) 010-9999");
assert_eq!(InputValidator::Phone.filter("abc"), "");
}
#[test]
fn an_empty_value_is_valid_for_both_grammars() {
assert_eq!(InputValidator::Phone.check(""), (true, ""));
assert_eq!(InputValidator::Email.check(""), (true, ""));
}
#[test]
fn the_phone_grammar_measures_digits_not_characters() {
assert!(InputValidator::Phone.check("1234567").0);
assert!(InputValidator::Phone.check("+1 (555) 010-9999").0);
assert!(!InputValidator::Phone.check("123456").0, "six digits is too short");
assert!(!InputValidator::Phone.check("1234567890123456").0, "sixteen is too long");
}
#[test]
fn the_email_grammar_requires_one_at_and_a_dotted_domain() {
assert!(InputValidator::Email.check("name@domain.com").0);
assert!(!InputValidator::Email.check("name@domain").0, "no top-level label");
assert!(!InputValidator::Email.check("@domain.com").0, "no local part");
assert!(!InputValidator::Email.check("a@b@c.com").0, "two at signs");
assert!(!InputValidator::Email.check("name@.com").0, "an empty label");
}
#[test]
fn the_email_grammar_refuses_whitespace_and_control_characters() {
assert_eq!(InputValidator::Email.filter("a b\tc"), "abc");
assert_eq!(InputValidator::Email.filter("name@domain.com"), "name@domain.com");
}
#[test]
fn a_message_is_carried_only_when_the_value_is_refused() {
assert_eq!(InputValidator::Phone.check("12").1, "Enter a phone number (7 to 15 digits)");
assert_eq!(InputValidator::Email.check("x").1, "Enter an e-mail address (name@domain.com)");
}
#[test]
fn the_grammars_report_the_three_way_state() {
assert_eq!(InputValidator::Phone.state(""), ValidationState::Acceptable);
assert_eq!(InputValidator::Phone.state("1234567"), ValidationState::Acceptable);
assert_eq!(InputValidator::Email.state("not-an-address"), ValidationState::Invalid);
assert!(!ValidationState::Intermediate.is_acceptable());
assert!(!ValidationState::Intermediate.is_invalid());
}
#[test]
fn the_phone_grammar_repairs_its_own_edge_separators() {
assert_eq!(InputValidator::Phone.fixup(" 555-0100 "), Some("555-0100".to_string()));
assert_eq!(InputValidator::Phone.fixup("---"), None);
assert_eq!(InputValidator::Email.fixup("name@domain.com"), None);
}
#[test]
fn a_range_rule_separates_incomplete_from_out_of_range() {
let range = RangeValidator::int(1, 10);
assert_eq!(range.state(""), ValidationState::Intermediate);
assert_eq!(range.state("-"), ValidationState::Intermediate);
assert_eq!(range.state("11"), ValidationState::Invalid);
assert_eq!(range.state("5"), ValidationState::Acceptable);
assert_eq!(range.fixup("42"), Some("10".to_string()));
}
#[test]
fn a_double_range_rule_accepts_decimals_and_refuses_non_finite_values() {
let range = RangeValidator::double(0.0, 1.0);
assert_eq!(range.state("0.5"), ValidationState::Acceptable);
assert_eq!(range.state("1.5"), ValidationState::Invalid);
assert_eq!(range.state("NaN"), ValidationState::Intermediate);
assert_eq!(range.fixup("1.5"), Some("1".to_string()));
}
#[test]
fn a_pattern_rule_supports_anchors_classes_and_quantifiers() {
let code = PatternValidator::new("^[A-Z]{0}", "bad code");
assert_eq!(code.state("ABC"), ValidationState::Invalid);
let digits = PatternValidator::new("^[0-9]+$", "digits only");
assert!(digits.is_match("0123"));
assert!(!digits.is_match("0123a"));
assert_eq!(digits.state(""), ValidationState::Intermediate);
assert_eq!(digits.state("12"), ValidationState::Acceptable);
let name = PatternValidator::new("[a-z]+\\.[a-z]+", "name.ext");
assert!(name.is_match("report.txt"));
assert!(!name.is_match("report"));
assert_eq!(name.message(), "name.ext");
}
#[test]
fn a_validator_can_be_used_behind_the_trait() {
let rule: &dyn Validator = &RangeValidator::int(0, 3);
assert!(rule.state("2").is_acceptable());
assert_eq!(rule.filter(" anything "), " anything ");
}
#[test]
fn a_pattern_supports_groups_and_alternation() {
let images = PatternValidator::new("^(png|jpg|webp)$", "image extension");
assert!(images.is_match("png"));
assert!(images.is_match("jpg"));
assert!(images.is_match("webp"));
assert!(!images.is_match("gif"));
assert!(!images.is_match("pngjpg"), "the anchors apply to the whole alternation");
let pairs = PatternValidator::new("^(ab)+$", "pairs");
assert!(pairs.is_match("ab"));
assert!(pairs.is_match("abab"));
assert!(!pairs.is_match("a"));
assert!(!pairs.is_match("aba"));
let nested = PatternValidator::new("^((a|b)c)+$", "nested");
assert!(nested.is_match("acbc"));
assert!(!nested.is_match("acb"));
}
#[test]
fn a_compiled_pattern_matches_many_values() {
let compiled = CompiledPattern::compile("a|b").expect("a valid pattern");
assert!(compiled.is_match("a") && compiled.is_match("b"));
assert!(!compiled.is_match("c"));
assert!(CompiledPattern::compile("(a").is_none());
}
#[test]
fn a_range_message_names_the_bound_and_the_refused_value() {
let range = RangeValidator::int(1, 100);
assert_eq!(range.message(), "Enter a whole number in range");
assert_eq!(range.message_for("999"), "999 is outside 1..=100");
assert_eq!(range.message_for("50"), "Enter a whole number in range");
let builtin: &dyn Validator = &InputValidator::Phone;
assert_eq!(builtin.message_for("12"), builtin.message());
}
}