use crate::{
Anchor, CaptureId, EnginePolicy, ExecutionOutcome, IrNode, PatternIr, RepeatBounds,
ScalarDomain, compile, execute::execute_spanning,
};
use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TextClass {
Alpha,
Digit,
Lower,
Upper,
Alnum,
Space,
Punct,
Hex,
Zero,
Set {
chars: Vec<char>,
ranges: Vec<(char, char)>,
classes: Vec<TextClass>,
negated: bool,
},
Not(Box<TextClass>),
}
impl TextClass {
pub fn matches(&self, ch: char) -> bool {
match self {
Self::Alpha => ch.is_ascii_alphabetic(),
Self::Digit => ch.is_ascii_digit(),
Self::Lower => ch.is_ascii_lowercase(),
Self::Upper => ch.is_ascii_uppercase(),
Self::Alnum => ch.is_ascii_alphanumeric(),
Self::Space => ch.is_ascii_whitespace(),
Self::Punct => ch.is_ascii_punctuation(),
Self::Hex => ch.is_ascii_hexdigit(),
Self::Zero => ch == '\0',
Self::Set {
chars,
ranges,
classes,
negated,
} => {
let found = chars.contains(&ch)
|| ranges.iter().any(|(start, end)| *start <= ch && ch <= *end)
|| classes.iter().any(|class| class.matches(ch));
if *negated { !found } else { found }
}
Self::Not(class) => !class.matches(ch),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TextOp {
Class(TextClass),
Literal(char),
Any,
CaptureStart,
CaptureEnd,
Repeat {
min: usize,
max: Option<usize>,
greedy: bool,
},
Balanced {
open: char,
close: char,
},
Frontier(TextClass),
AnchorStart,
AnchorEnd,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextMatch {
pub start: usize,
pub end: usize,
pub captures: Vec<(usize, usize)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TextLimits {
pub max_steps: usize,
pub max_states: usize,
pub max_capture_history: usize,
pub max_subject_symbols: usize,
}
impl Default for TextLimits {
fn default() -> Self {
Self {
max_steps: 10_000,
max_states: 4_096,
max_capture_history: 10_000,
max_subject_symbols: 1_000_000,
}
}
}
#[derive(Clone, Debug)]
struct CursorText {
chars: Vec<char>,
offsets: Vec<usize>,
len_bytes: usize,
}
impl CursorText {
fn new(subject: &str) -> Self {
let mut chars = Vec::new();
let mut offsets = Vec::new();
for (offset, ch) in subject.char_indices() {
offsets.push(offset);
chars.push(ch);
}
Self {
chars,
offsets,
len_bytes: subject.len(),
}
}
fn cursor_for_byte(&self, byte: usize) -> Option<usize> {
if byte == self.len_bytes {
return Some(self.chars.len());
}
self.offsets.iter().position(|offset| *offset == byte)
}
fn byte_for_cursor(&self, cursor: usize) -> usize {
self.offsets.get(cursor).copied().unwrap_or(self.len_bytes)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum TextExtension {
Class(TextClass),
Balanced { open: char, close: char },
Frontier(TextClass),
}
pub fn run_text_pattern(
ops: &[TextOp],
subject: &str,
init: usize,
limits: TextLimits,
) -> Option<TextMatch> {
let anchored = matches!(ops.first(), Some(TextOp::AnchorStart));
let ir = lower_text_program(ops)?;
let automaton = compile(&ir);
let text = CursorText::new(subject);
let init_cursor = text.cursor_for_byte(init)?;
let starts: Box<dyn Iterator<Item = usize>> = if anchored {
Box::new(std::iter::once(init_cursor).filter(|cursor| *cursor == 0))
} else {
Box::new(init_cursor..=text.chars.len())
};
for start_cursor in starts {
let slice = &text.chars[start_cursor..];
let outcome =
execute_spanning(
&automaton,
slice,
limits,
|extension, _, position| match extension {
TextExtension::Class(class) => slice
.get(position)
.is_some_and(|ch| class.matches(*ch))
.then_some(position + 1),
TextExtension::Balanced { open, close } => {
match_balanced(slice, position, *open, *close)
}
TextExtension::Frontier(class) => {
let absolute = start_cursor + position;
let previous = absolute.checked_sub(1).and_then(|i| text.chars.get(i));
let current = text.chars.get(absolute);
(!previous.is_some_and(|ch| class.matches(*ch))
&& current.is_some_and(|ch| class.matches(*ch)))
.then_some(position)
}
},
);
if let ExecutionOutcome::Match { matched, .. } = outcome {
let captures = matched
.captures
.values()
.map(|span| {
(
text.byte_for_cursor(start_cursor + span.start),
text.byte_for_cursor(start_cursor + span.end),
)
})
.collect();
return Some(TextMatch {
start: text.byte_for_cursor(start_cursor),
end: text.byte_for_cursor(start_cursor + matched.end),
captures,
});
}
}
None
}
fn lower_text_program(ops: &[TextOp]) -> Option<PatternIr<ScalarDomain, TextExtension>> {
let mut frames = vec![Vec::new()];
let mut next_capture = 0u32;
for op in ops {
let nodes = frames.last_mut()?;
match op {
TextOp::Class(class) => {
nodes.push(IrNode::Extension(TextExtension::Class(class.clone())))
}
TextOp::Literal(ch) => nodes.push(IrNode::Symbol(*ch)),
TextOp::Any => nodes.push(IrNode::Any),
TextOp::Balanced { open, close } => {
nodes.push(IrNode::Extension(TextExtension::Balanced {
open: *open,
close: *close,
}))
}
TextOp::Repeat { min, max, greedy } => {
let node = nodes.pop()?;
nodes.push(IrNode::Repeat {
node: Box::new(node),
bounds: RepeatBounds::new(*min, *max).ok()?,
greedy: *greedy,
});
}
TextOp::CaptureStart => frames.push(Vec::new()),
TextOp::CaptureEnd => {
if frames.len() == 1 {
return None;
}
let body = IrNode::Concat(frames.pop()?);
let id = CaptureId(next_capture);
next_capture += 1;
frames.last_mut()?.push(IrNode::Capture {
id,
node: Box::new(body),
});
}
TextOp::Frontier(class) => {
nodes.push(IrNode::Extension(TextExtension::Frontier(class.clone())))
}
TextOp::AnchorStart => nodes.push(IrNode::Anchor(Anchor::SubjectStart)),
TextOp::AnchorEnd => nodes.push(IrNode::Anchor(Anchor::SubjectEnd)),
}
}
if frames.len() != 1 {
return None;
}
let extensions = ops.iter().filter_map(|op| match op {
TextOp::Class(class) => Some(TextExtension::Class(class.clone())),
TextOp::Balanced { open, close } => Some(TextExtension::Balanced {
open: *open,
close: *close,
}),
TextOp::Frontier(class) => Some(TextExtension::Frontier(class.clone())),
_ => None,
});
PatternIr::new(
IrNode::Concat(frames.pop()?),
BTreeMap::new(),
&EnginePolicy::new(extensions),
)
.ok()
}
fn match_balanced(text: &[char], cursor: usize, open: char, close: char) -> Option<usize> {
if text.get(cursor).copied() != Some(open) {
return None;
}
let mut depth = 0usize;
for (index, ch) in text.iter().copied().enumerate().skip(cursor) {
if ch == open {
depth += 1;
}
if ch == close {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(index + 1);
}
}
}
None
}