deps: drop regex, toml, indexmap - std-only minirx, toml_lite, inferred IndexMap

This commit is contained in:
Loki 2026-10-15 12:53:22 +02:00
parent 59637e50dc
commit d19e4f784d
Signed by: boba
GPG key ID: 253067914055423B
19 changed files with 1171 additions and 96 deletions

View file

@ -7,7 +7,6 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
regex = { workspace = true }
serde_json = { workspace = true }
noxis-classfile = { workspace = true }
noxis-core = { workspace = true }

View file

@ -3,7 +3,8 @@
use std::collections::BTreeSet;
use noxis_classfile::Program;
use regex::Regex;
use crate::minirx::{self, Regex};
use crate::entrypoints::Entrypoints;
use noxis_core::Config;
@ -14,7 +15,7 @@ mod scans;
pub use dispatch::external_dispatch_families;
/// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char.
fn compile(pattern: &str) -> Result<Regex, regex::Error> {
fn compile(pattern: &str) -> Result<Regex, minirx::Error> {
let mut out = String::from("^");
let chars: Vec<char> = pattern.chars().collect();
let mut i = 0;
@ -34,7 +35,7 @@ fn compile(pattern: &str) -> Result<Regex, regex::Error> {
i += 1;
}
c => {
out.push_str(&regex::escape(&c.to_string()));
out.push_str(&minirx::escape(&c.to_string()));
i += 1;
}
}

View file

@ -12,6 +12,7 @@ pub mod entrypoints;
pub mod hierarchy;
pub mod jdk;
pub mod keep;
pub mod minirx;
pub use entrypoints::Entrypoints;
pub use hierarchy::{ClassHierarchy, RenameSafety, method_rename_safety};

View file

@ -0,0 +1,82 @@
//! Backtracking matcher over a flat `Node` sequence.
use super::parse::Node;
/// True if `nodes` matches anywhere in `text` (unanchored search), honoring
/// any `Start`/`End` nodes as zero-width anchors at their actual position.
pub(super) fn search(nodes: &[Node], text: &[char]) -> bool {
// `^` only makes sense anchored at position 0: if the pattern starts
// with it, trying every start offset is still correct (it will simply
// fail at every offset except possibly 0) and keeps this function
// simple - no need to special-case it.
for start in 0..=text.len() {
if match_here(nodes, 0, text, start).is_some() {
return true;
}
}
false
}
/// Tries to match `nodes[idx..]` starting at `text[pos..]`; returns the end
/// position on success.
fn match_here(nodes: &[Node], idx: usize, text: &[char], pos: usize) -> Option<usize> {
let Some(node) = nodes.get(idx) else {
return Some(pos);
};
match node {
Node::Start => {
if pos != 0 {
return None;
}
match_here(nodes, idx + 1, text, pos)
}
Node::End => {
if pos != text.len() {
return None;
}
match_here(nodes, idx + 1, text, pos)
}
Node::WordBoundary => {
if !is_word_boundary(text, pos) {
return None;
}
match_here(nodes, idx + 1, text, pos)
}
Node::Rep { atom, min, max } => {
let run = count_run(atom, text, pos);
let hi = max.map_or(run, |m| m.min(run));
if hi < *min {
return None;
}
// Greedy: try the longest repetition first, backtrack shorter.
for count in (*min..=hi).rev() {
if let Some(end) = match_here(nodes, idx + 1, text, pos + count) {
return Some(end);
}
}
None
}
}
}
/// Counts how many consecutive chars from `pos` satisfy `atom`.
fn count_run(atom: &super::parse::Atom, text: &[char], pos: usize) -> usize {
let mut n = 0;
while text.get(pos + n).is_some_and(|&c| atom.matches(c)) {
n += 1;
}
n
}
/// A JVM/identifier-ish "word" char: alphanumeric or `_`.
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn is_word_boundary(text: &[char], pos: usize) -> bool {
let before = pos.checked_sub(1).and_then(|i| text.get(i)).copied();
let after = text.get(pos).copied();
let before_word = before.is_some_and(is_word_char);
let after_word = after.is_some_and(is_word_char);
before_word != after_word
}

View file

@ -0,0 +1,135 @@
//! Minimal std-only regex engine, covering exactly the pattern vocabulary
//! used in this codebase (keep-rule globs compiled to regex, plus the
//! fixed `RED_PATTERNS` secret-scan list in `noxis-verify`).
//!
//! Not a general-purpose regex engine: no groups, no alternation, no
//! backreferences, no `{n,m}` quantifiers. What it does support:
//! literal chars, `.`, character classes (`[...]`, `[^...]`, ranges),
//! `\s`, `\b` (word boundary), anchors `^`/`$`, quantifiers `*`/`+`/`?`
//! (greedy, backtracking), a leading `(?i)` case-insensitive flag, and
//! `\`-escaping of any non-alphanumeric char (what `escape` produces).
//!
//! Split into `parse` (pattern string -> `Node` sequence) and `matcher`
//! (backtracking match over a char slice) to stay under the structure
//! gate's per-file line limit.
mod matcher;
mod parse;
pub use parse::Error;
/// A compiled pattern.
#[derive(Debug, Clone)]
pub struct Regex {
nodes: Vec<parse::Node>,
case_insensitive: bool,
}
impl Regex {
/// Compiles `pattern`. See the module docs for the supported vocabulary.
pub fn new(pattern: &str) -> Result<Self, Error> {
let (nodes, case_insensitive) = parse::parse(pattern)?;
Ok(Self {
nodes,
case_insensitive,
})
}
/// Whether `text` matches anywhere (unanchored, unless the pattern
/// itself carries `^`/`$`) - mirrors `regex::Regex::is_match`.
pub fn is_match(&self, text: &str) -> bool {
let chars: Vec<char> = if self.case_insensitive {
text.chars().flat_map(char::to_lowercase).collect()
} else {
text.chars().collect()
};
matcher::search(&self.nodes, &chars)
}
}
/// Escapes every char that could be special in a pattern, so the result
/// matches `s` literally when embedded in a larger pattern.
///
/// Mirrors the subset of `regex::escape` this codebase actually relies on:
/// everything outside `[A-Za-z0-9_]` is escaped (harmless over-escaping for
/// chars this engine treats as plain literals anyway).
pub fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for c in s.chars() {
if !c.is_alphanumeric() && c != '_' {
out.push('\\');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn literal_and_anchors() {
let re = Regex::new("^abc$").unwrap();
assert!(re.is_match("abc"));
assert!(!re.is_match("abcd"));
assert!(!re.is_match("xabc"));
}
#[test]
fn glob_star_and_doublestar() {
// Mirrors `keep::compile` output for `com.foo.*`.
let re = Regex::new("^com/foo/[^/]*$").unwrap();
assert!(re.is_match("com/foo/Bar"));
assert!(!re.is_match("com/foo/Bar/Baz"));
let re2 = Regex::new("^com/.*$").unwrap();
assert!(re2.is_match("com/foo/Bar/Baz"));
}
#[test]
fn question_mark_class() {
let re = Regex::new("^a[^/]b$").unwrap();
assert!(re.is_match("axb"));
assert!(!re.is_match("a/b"));
}
#[test]
fn escaped_literal_dot() {
let pat = format!("^{}$", escape("a.b"));
let re = Regex::new(&pat).unwrap();
assert!(re.is_match("a.b"));
assert!(!re.is_match("axb"));
}
#[test]
fn red_patterns_compile_and_match() {
let re = Regex::new(r"(?i)password\s*=\s*[^\s;]+").unwrap();
assert!(re.is_match("password=hunter2"));
assert!(re.is_match("PASSWORD = hunter2"));
assert!(!re.is_match("not a secret"));
let re2 = Regex::new(r"(?i)\bapi[_-]?key\b").unwrap();
assert!(re2.is_match("api_key=abc"));
assert!(re2.is_match("API-KEY: x"));
assert!(!re2.is_match("recapitalized"));
let re3 = Regex::new(r#"jdbc:[a-z]+://[^\s"']+"#).unwrap();
assert!(re3.is_match("jdbc:mysql://host/db"));
let re4 = Regex::new(r"-----BEGIN [A-Z ]*PRIVATE KEY-----").unwrap();
assert!(re4.is_match("-----BEGIN RSA PRIVATE KEY-----"));
let re5 = Regex::new(r#"https?://[^\s"']+"#).unwrap();
assert!(re5.is_match("https://example.com/hook"));
assert!(re5.is_match("http://example.com"));
}
#[test]
fn clean_text_passes() {
let re = Regex::new(r"(?i)password\s*=\s*[^\s;]+").unwrap();
assert!(!re.is_match("hello world"));
}
}

View file

@ -0,0 +1,202 @@
//! Pattern string -> flat `Node` sequence (no groups, no alternation).
use std::fmt;
/// A single class-member: a literal char or an inclusive range.
#[derive(Debug, Clone, Copy)]
pub(super) enum ClassItem {
Char(char),
Range(char, char),
Space,
}
impl ClassItem {
fn matches(&self, c: char) -> bool {
match *self {
ClassItem::Char(x) => x == c,
ClassItem::Range(lo, hi) => lo <= c && c <= hi,
ClassItem::Space => c.is_whitespace(),
}
}
}
/// One zero-width or single-char-consuming atom in the pattern.
#[derive(Debug, Clone)]
pub(super) enum Atom {
Char(char),
Any,
Space,
Class {
negated: bool,
items: Vec<ClassItem>,
},
}
impl Atom {
pub(super) fn matches(&self, c: char) -> bool {
match self {
Atom::Char(x) => *x == c,
Atom::Any => true,
Atom::Space => c.is_whitespace(),
Atom::Class { negated, items } => {
let hit = items.iter().any(|item| item.matches(c));
hit != *negated
}
}
}
}
/// A node in the flat pattern sequence.
#[derive(Debug, Clone)]
pub(super) enum Node {
Start,
End,
WordBoundary,
/// `atom` repeated `min..=max` times (`max = None` means unbounded),
/// matched greedily with backtracking.
Rep {
atom: Atom,
min: usize,
max: Option<usize>,
},
}
/// A pattern compilation error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error(pub(super) String);
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for Error {}
/// Parses `pattern`, returning its node sequence and whether a leading
/// `(?i)` flag was present.
pub(super) fn parse(pattern: &str) -> Result<(Vec<Node>, bool), Error> {
let (rest, case_insensitive) = match pattern.strip_prefix("(?i)") {
Some(rest) => (rest, true),
None => (pattern, false),
};
let chars: Vec<char> = if case_insensitive {
rest.chars().flat_map(char::to_lowercase).collect()
} else {
rest.chars().collect()
};
let mut nodes = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let node = match c {
'^' => {
i += 1;
Node::Start
}
'$' => {
i += 1;
Node::End
}
'\\' => {
i += 1;
let Some(&esc) = chars.get(i) else {
return Err(Error("trailing backslash".into()));
};
i += 1;
if esc == 'b' {
Node::WordBoundary
} else {
let atom = if esc == 's' {
Atom::Space
} else {
Atom::Char(esc)
};
parse_quantifier(atom, &chars, &mut i)
}
}
'.' => {
i += 1;
parse_quantifier(Atom::Any, &chars, &mut i)
}
'[' => {
i += 1;
let atom = parse_class(&chars, &mut i)?;
parse_quantifier(atom, &chars, &mut i)
}
_ => {
i += 1;
parse_quantifier(Atom::Char(c), &chars, &mut i)
}
};
nodes.push(node);
}
Ok((nodes, case_insensitive))
}
/// Reads an optional trailing `*`/`+`/`?` and wraps `atom` into a `Rep`.
fn parse_quantifier(atom: Atom, chars: &[char], i: &mut usize) -> Node {
let (min, max) = match chars.get(*i) {
Some('*') => {
*i += 1;
(0, None)
}
Some('+') => {
*i += 1;
(1, None)
}
Some('?') => {
*i += 1;
(0, Some(1))
}
_ => (1, Some(1)),
};
Node::Rep { atom, min, max }
}
/// Parses `[...]` starting right after the opening `[`; advances `i` past
/// the closing `]`.
fn parse_class(chars: &[char], i: &mut usize) -> Result<Atom, Error> {
let negated = if matches!(chars.get(*i), Some('^')) {
*i += 1;
true
} else {
false
};
let mut items = Vec::new();
let mut closed = false;
while let Some(&c) = chars.get(*i) {
if c == ']' {
*i += 1;
closed = true;
break;
}
let lo = if c == '\\' {
*i += 1;
let esc = *chars
.get(*i)
.ok_or_else(|| Error("trailing backslash in class".into()))?;
*i += 1;
if esc == 's' {
items.push(ClassItem::Space);
continue;
}
esc
} else {
*i += 1;
c
};
if chars.get(*i) == Some(&'-') && chars.get(*i + 1).is_some_and(|&c| c != ']') {
*i += 1;
let hi = chars[*i];
*i += 1;
items.push(ClassItem::Range(lo, hi));
} else {
items.push(ClassItem::Char(lo));
}
}
if !closed {
return Err(Error("unterminated character class".into()));
}
Ok(Atom::Class { negated, items })
}