55use std:: borrow:: Cow ;
66use std:: sync:: LazyLock ;
77
8- use regex:: Regex ;
8+ use regex:: { Regex , RegexSet } ;
99
1010use crate :: domain:: StagedChanges ;
1111
@@ -28,6 +28,55 @@ pub struct SecretPattern {
2828 pub description : Cow < ' static , str > ,
2929}
3030
31+ /// Bundle of secret patterns plus a derived `RegexSet` for batched first-pass
32+ /// matching.
33+ ///
34+ /// `RegexSet` compiles all patterns into a single combined NFA, so checking
35+ /// a line against the whole set is dramatically cheaper than running each
36+ /// `Regex::is_match` independently. On a hit the indices returned by
37+ /// `RegexSet::matches` map back into `patterns` for the descriptive name.
38+ pub struct PatternSet {
39+ pub patterns : Vec < SecretPattern > ,
40+ pub set : RegexSet ,
41+ }
42+
43+ impl PatternSet {
44+ /// Build a `PatternSet` from an owned `Vec<SecretPattern>`.
45+ ///
46+ /// The `RegexSet` is built once at construction time. If any individual
47+ /// pattern would fail to compile in the combined set (it should not — the
48+ /// patterns already passed `Regex::new`) we fall back to an empty
49+ /// `RegexSet`, in which case `first_match` simply returns `None` for
50+ /// every input. The per-pattern `Regex::is_match` fallback below then
51+ /// preserves correctness.
52+ pub fn new ( patterns : Vec < SecretPattern > ) -> Self {
53+ let sources: Vec < & str > = patterns. iter ( ) . map ( |p| p. regex . as_str ( ) ) . collect ( ) ;
54+ let set = RegexSet :: new ( & sources) . unwrap_or_else ( |_| RegexSet :: empty ( ) ) ;
55+ Self { patterns, set }
56+ }
57+
58+ /// First-pass match. Returns the index of the *lowest-index* pattern that
59+ /// matches `line`, or `None` if no pattern matches. Uses the combined
60+ /// `RegexSet` (one NFA traversal) instead of N independent `Regex::is_match`
61+ /// calls.
62+ fn first_match ( & self , line : & str ) -> Option < usize > {
63+ // `matches()` returns a SetMatches; iterating it yields hit indices in
64+ // ascending order, so `next()` is the smallest index.
65+ self . set . matches ( line) . into_iter ( ) . next ( )
66+ }
67+ }
68+
69+ /// Convenience: build a `PatternSet` from a borrowed slice. Used by callers
70+ /// that hold an owned `Vec<SecretPattern>` and want first-pass acceleration.
71+ fn pattern_set_from_slice ( patterns : & [ SecretPattern ] ) -> PatternSet {
72+ PatternSet :: new ( patterns. to_vec ( ) )
73+ }
74+
75+ /// Cached `PatternSet` over the built-in patterns. The pattern vector and the
76+ /// `RegexSet` are both compiled exactly once per process.
77+ static BUILTIN_PATTERN_SET : LazyLock < PatternSet > =
78+ LazyLock :: new ( || PatternSet :: new ( BUILTIN_PATTERNS . clone ( ) ) ) ;
79+
3180/// Build the full set of secret patterns, applying custom additions and disabled names.
3281///
3382/// Custom patterns are compiled from user-provided regex strings. Invalid regexes
@@ -229,14 +278,28 @@ static BUILTIN_PATTERNS: LazyLock<Vec<SecretPattern>> = LazyLock::new(|| {
229278/// for library consumers who only have `StagedChanges`.
230279#[ allow( dead_code) ]
231280pub fn scan_for_secrets ( changes : & StagedChanges ) -> Vec < SecretMatch > {
232- scan_for_secrets_with_patterns ( changes, builtin_patterns ( ) )
281+ scan_with_pattern_set ( changes, & BUILTIN_PATTERN_SET )
233282}
234283
235- /// Scan per-file truncated diffs for secrets using the given pattern set.
284+ /// Scan per-file truncated diffs for secrets using the given pattern slice.
285+ ///
286+ /// One-shot callers pay the cost of building a `RegexSet` here; repeated
287+ /// callers should construct a `PatternSet` once and call
288+ /// [`scan_with_pattern_set`] directly.
236289pub fn scan_for_secrets_with_patterns (
237290 changes : & StagedChanges ,
238291 patterns : & [ SecretPattern ] ,
239292) -> Vec < SecretMatch > {
293+ let set = pattern_set_from_slice ( patterns) ;
294+ scan_with_pattern_set ( changes, & set)
295+ }
296+
297+ /// Scan per-file truncated diffs for secrets using a pre-built `PatternSet`.
298+ ///
299+ /// Each added line is first matched against the combined `RegexSet`; only on
300+ /// a hit do we look up the individual pattern's name. This avoids running
301+ /// every `Regex::is_match` independently per line.
302+ fn scan_with_pattern_set ( changes : & StagedChanges , pset : & PatternSet ) -> Vec < SecretMatch > {
240303 let mut found = Vec :: new ( ) ;
241304
242305 for file in & changes. files {
@@ -253,15 +316,13 @@ pub fn scan_for_secrets_with_patterns(
253316 continue ;
254317 }
255318
256- for pat in patterns {
257- if pat. regex . is_match ( line) {
258- found. push ( SecretMatch {
259- pattern_name : pat. name . to_string ( ) ,
260- file : file. path . display ( ) . to_string ( ) ,
261- line : Some ( line_num) ,
262- } ) ;
263- break ; // One match per line is enough
264- }
319+ if let Some ( idx) = pset. first_match ( line) {
320+ let pat = & pset. patterns [ idx] ;
321+ found. push ( SecretMatch {
322+ pattern_name : pat. name . to_string ( ) ,
323+ file : file. path . display ( ) . to_string ( ) ,
324+ line : Some ( line_num) ,
325+ } ) ;
265326 }
266327 }
267328 }
@@ -272,7 +333,7 @@ pub fn scan_for_secrets_with_patterns(
272333/// Scan the full unified diff for secrets using default patterns.
273334#[ allow( dead_code) ]
274335pub fn scan_full_diff_for_secrets ( full_diff : & str ) -> Vec < SecretMatch > {
275- scan_full_diff_with_patterns ( full_diff, builtin_patterns ( ) )
336+ scan_full_diff_with_pattern_set ( full_diff, & BUILTIN_PATTERN_SET )
276337}
277338
278339static HUNK_HEADER : LazyLock < Regex > =
@@ -288,6 +349,13 @@ pub fn scan_full_diff_with_patterns(
288349 full_diff : & str ,
289350 patterns : & [ SecretPattern ] ,
290351) -> Vec < SecretMatch > {
352+ let pset = pattern_set_from_slice ( patterns) ;
353+ scan_full_diff_with_pattern_set ( full_diff, & pset)
354+ }
355+
356+ /// Same as `scan_full_diff_with_patterns` but takes a pre-built `PatternSet`,
357+ /// avoiding the per-call `RegexSet` construction.
358+ fn scan_full_diff_with_pattern_set ( full_diff : & str , pset : & PatternSet ) -> Vec < SecretMatch > {
291359 let mut found = Vec :: new ( ) ;
292360 let mut current_file = String :: new ( ) ;
293361 let mut current_line: Option < usize > = None ;
@@ -342,15 +410,13 @@ pub fn scan_full_diff_with_patterns(
342410 // guard, which would also drop legitimate added lines whose content
343411 // begins with `++`.
344412 if let Some ( content) = line. strip_prefix ( '+' ) {
345- for pat in patterns {
346- if pat. regex . is_match ( content) {
347- found. push ( SecretMatch {
348- pattern_name : pat. name . to_string ( ) ,
349- file : current_file. clone ( ) ,
350- line : Some ( * line_num) ,
351- } ) ;
352- break ;
353- }
413+ if let Some ( idx) = pset. first_match ( content) {
414+ let pat = & pset. patterns [ idx] ;
415+ found. push ( SecretMatch {
416+ pattern_name : pat. name . to_string ( ) ,
417+ file : current_file. clone ( ) ,
418+ line : Some ( * line_num) ,
419+ } ) ;
354420 }
355421 * line_num += 1 ;
356422 } else if line. starts_with ( ' ' ) {
0 commit comments