deps: drop regex, toml, indexmap - std-only minirx, toml_lite, inferred IndexMap
This commit is contained in:
parent
59637e50dc
commit
d19e4f784d
19 changed files with 1171 additions and 96 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(®ex::escape(&c.to_string()));
|
||||
out.push_str(&minirx::escape(&c.to_string()));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
82
crates/noxis-analysis/src/minirx/matcher.rs
Normal file
82
crates/noxis-analysis/src/minirx/matcher.rs
Normal 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
|
||||
}
|
||||
135
crates/noxis-analysis/src/minirx/mod.rs
Normal file
135
crates/noxis-analysis/src/minirx/mod.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
202
crates/noxis-analysis/src/minirx/parse.rs
Normal file
202
crates/noxis-analysis/src/minirx/parse.rs
Normal 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 })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue