use super::ast::{Ast, ParsedRegex, PerlClassKind};
use super::backtrack::{
StartByteSet, concat_start_bytes, expand_case_insensitive_start_bytes,
is_cpp_space_comment_separator, is_perl_class, strip_nonsemantic_group,
};
const ASCII_WHITESPACE: [u8; 6] = [b' ', b'\t', b'\n', b'\r', 0x0b, 0x0c];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SkipGate {
rest_bytes: StartByteSet,
allow_empty: bool,
allow_whitespace: bool,
allow_comment: bool,
}
#[derive(Default)]
pub(crate) struct SkipGateLineState {
whitespace_run: Option<(usize, usize)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SkipGateDecision {
Allow,
Skip,
NeedsCommentCheck,
}
impl SkipGate {
#[cfg(test)]
pub(crate) fn analyze(parsed: &ParsedRegex) -> Option<Self> {
parsed.analysis().skip_gate().cloned()
}
pub(crate) fn analyze_with_effective_flags(
parsed: &ParsedRegex,
uniform_flags: Option<super::ast::RegexFlags>,
has_case_insensitive_scope: bool,
) -> Option<Self> {
let mut ast = &parsed.ast;
while let Ast::Flags { child, .. } = ast {
ast = child;
}
let Ast::Concat(nodes) = ast else {
return None;
};
let mut allow_empty = true;
let mut allow_whitespace = false;
let mut allow_comment = false;
let mut index = 0;
while index < nodes.len() {
match classify_skip_element(&nodes[index]) {
Some(SkipElement::Separator) => {
allow_whitespace = true;
allow_comment = true;
}
Some(SkipElement::Whitespace { nullable }) => {
allow_whitespace = true;
allow_empty &= nullable;
}
Some(SkipElement::ZeroWidth) => {}
None => break,
}
index += 1;
}
if index == 0 || !(allow_whitespace || allow_comment) {
return None;
}
let rest = &nodes[index..];
if rest.is_empty() {
return None;
}
if has_case_insensitive_scope && uniform_flags.is_none() {
return None;
}
let info = concat_start_bytes(rest)?;
if info.nullable || info.bytes.is_empty() {
return None;
}
let mut rest_bytes = info.bytes;
if uniform_flags.unwrap_or(parsed.flags).case_insensitive {
expand_case_insensitive_start_bytes(&mut rest_bytes);
}
if ASCII_WHITESPACE
.iter()
.any(|byte| rest_bytes.contains(*byte))
{
return None;
}
Some(Self {
rest_bytes,
allow_empty,
allow_whitespace,
allow_comment,
})
}
pub(crate) fn decide(
&self,
line: &str,
start: usize,
state: &mut SkipGateLineState,
) -> SkipGateDecision {
let bytes = line.as_bytes();
if self.allow_empty
&& bytes
.get(start)
.is_some_and(|byte| self.rest_bytes.contains(*byte))
{
return SkipGateDecision::Allow;
}
if self.allow_whitespace {
match whitespace_run_end(bytes, start, &mut state.whitespace_run) {
Some(end) => {
if end > start
&& bytes
.get(end)
.is_some_and(|byte| self.rest_bytes.contains(*byte))
{
return SkipGateDecision::Allow;
}
}
None => return SkipGateDecision::Allow,
}
}
if self.allow_comment {
SkipGateDecision::NeedsCommentCheck
} else {
SkipGateDecision::Skip
}
}
}
enum SkipElement {
Separator,
Whitespace { nullable: bool },
ZeroWidth,
}
fn classify_skip_element(ast: &Ast) -> Option<SkipElement> {
let stripped = strip_flags(strip_nonsemantic_group(ast));
if let Ast::Alternation(branches) = stripped
&& is_cpp_space_comment_separator(branches)
{
return Some(SkipElement::Separator);
}
if is_perl_class(strip_flags(stripped), PerlClassKind::Space) {
return Some(SkipElement::Whitespace { nullable: false });
}
if let Ast::Repeat { node, min, max, .. } = stripped
&& max.is_none_or(|max| max >= *min)
&& is_perl_class(
strip_flags(strip_nonsemantic_group(node)),
PerlClassKind::Space,
)
{
return Some(SkipElement::Whitespace {
nullable: *min == 0,
});
}
consumes_nothing(stripped).then_some(SkipElement::ZeroWidth)
}
fn strip_flags(ast: &Ast) -> &Ast {
let mut ast = ast;
loop {
match ast {
Ast::Flags { child, .. } => ast = strip_nonsemantic_group(child),
_ => return ast,
}
}
}
fn consumes_nothing(ast: &Ast) -> bool {
match ast {
Ast::Empty | Ast::Anchor(_) | Ast::Look { .. } => true,
Ast::Literal(literal) => literal.is_empty(),
Ast::Group { child, .. } | Ast::Flags { child, .. } => consumes_nothing(child),
Ast::Concat(nodes) => nodes.iter().all(consumes_nothing),
Ast::Alternation(branches) => !branches.is_empty() && branches.iter().all(consumes_nothing),
Ast::Repeat { node, max, .. } => *max == Some(0) || consumes_nothing(node),
_ => false,
}
}
fn whitespace_run_end(
bytes: &[u8],
start: usize,
memo: &mut Option<(usize, usize)>,
) -> Option<usize> {
if let Some((memo_start, memo_end)) = *memo
&& start >= memo_start
&& start < memo_end
{
return Some(memo_end);
}
let mut end = start;
while let Some(byte) = bytes.get(end) {
if ASCII_WHITESPACE.contains(byte) {
end += 1;
} else if !byte.is_ascii() {
return None;
} else {
break;
}
}
if end > start {
*memo = Some((start, end));
}
Some(end)
}
#[cfg(test)]
mod tests {
use super::super::ast::parse;
use super::*;
const SEPARATOR: &str =
r"((?:\s*+/\*(?:[^*]++|\*+(?!/))*+\*/\s*+)+|\s++|(?<=\W)|(?=\W)|^|\n?$|\A|\Z)";
fn gate(pattern: &str) -> Option<SkipGate> {
SkipGate::analyze(&parse(pattern))
}
fn allows(pattern: &str, line: &str, start: usize) -> bool {
match gate(pattern).expect("pattern should have a gate").decide(
line,
start,
&mut SkipGateLineState::default(),
) {
SkipGateDecision::Allow => true,
SkipGateDecision::Skip => false,
SkipGateDecision::NeedsCommentCheck => {
memchr::memmem::find(line.as_bytes(), b"/*").is_some()
}
}
}
#[test]
fn separator_prefixed_keyword_gates_on_token_byte() {
let pattern = format!("{SEPARATOR}((?<!\\w)this(?!\\w))");
assert!(allows(&pattern, "this", 0));
assert!(allows(&pattern, " this", 0));
assert!(allows(&pattern, "x this", 1));
assert!(!allows(&pattern, " that_", 7));
assert!(!allows(&pattern, " #define", 0));
assert!(allows(&pattern, "/* c */ this", 0));
assert!(allows(&pattern, " /* c */ x", 0));
}
#[test]
fn whitespace_prefixed_type_set_gates_on_first_letters() {
let pattern = r"\s*+(?<!\w)(?:(unsigned|signed|double)(?!\w))";
assert!(allows(pattern, " unsigned x", 0));
assert!(allows(pattern, "signed", 0));
assert!(!allows(pattern, " (cast)", 0));
assert!(!allows(pattern, " 12345", 1));
}
#[test]
fn mandatory_whitespace_requires_the_run() {
let pattern = r"\s++(#)";
assert!(!allows(pattern, "#x", 0));
assert!(allows(pattern, " #x", 0));
}
#[test]
fn non_ascii_whitespace_disables_the_gate() {
let pattern = format!("{SEPARATOR}(#)");
assert!(allows(&pattern, " \u{a0} #", 0));
}
#[test]
fn patterns_without_skip_shape_have_no_gate() {
assert!(gate(r"[A-Za-z_]\w*").is_none());
assert!(gate(r"(?<!\w)this").is_none());
assert!(gate(r"\s*+\S+").is_none(), "rest may start with anything");
assert!(
gate(r"\s*+ ?#").is_none(),
"rest starting with whitespace defeats the run shortcut"
);
}
#[test]
fn case_insensitive_rest_bytes_cover_both_cases() {
let gate = gate(r"(?i)\s*+(select|insert)\b").expect("gate");
let mut state = SkipGateLineState::default();
assert_eq!(
gate.decide(" SELECT", 0, &mut state),
SkipGateDecision::Allow
);
assert_eq!(
gate.decide(" select", 0, &mut state),
SkipGateDecision::Allow
);
let mut state = SkipGateLineState::default();
assert_eq!(
gate.decide(" update", 0, &mut state),
SkipGateDecision::Skip
);
}
}