File Coverage

blib/lib/tstregex.pm
Criterion Covered Total %
statement 159 287 55.4
branch 37 102 36.2
condition 36 80 45.0
subroutine 27 38 71.0
pod 9 11 81.8
total 268 518 51.7


line stmt bran cond sub pod time code
1             #!/usr/bin/env perl
2              
3             ###############################################
4             # Author: Olivier Delouya - 2026
5             # File: tstregex.pm (Hybrid Modulino)
6             # Content: Regex longest match diagnostic
7             # indent: Whitesmith (perltidy -bl -bli)
8             ###############################################
9              
10 15     15   1993798 use strict;
  15         35  
  15         606  
11 15     15   74 use warnings;
  15         106  
  15         1478  
12              
13             =head1 NAME
14              
15             tstregex - A Hybrid Regex Diagnostic Tool (single file Library module and command tool)
16             shows the longest Regular Expression match / highlight the rejected part
17              
18             =head1 SYNOPSIS
19              
20             # Example of command and its terminal output:
21              
22             B
23              
24             abc123
25              
26             abc-B<12a> (^[a-z]*-B<\d{3}$>)
27              
28             # The bold parts above highlight the rejected string and regex token.
29              
30             =head1 OPTIONS (CLI)
31              
32             =head2 -h --help
33              
34             show that help..
35              
36             =head2 -v --verbose
37              
38             shows key info on (un)matching..
39              
40             =head2 -d --diag
41              
42             Triggers the Enriched Diagnostic View. It displays:
43             - The string with the failing part highlighted.
44             - The exact token in the regex that caused the break.
45             - A visual pointer (C<^--- HERE>) aligned with the regex syntax.
46             - Execution time (useful for spotting ReDoS/Exponential backtracking).
47              
48             =head2 -a --assert
49              
50             Misc: performs a huge test suite various a large collection of regexp tests with tstregex..
51              
52             =head2
53              
54             =head1 Perl Module SYNOPSIS
55              
56             use tstregex;
57             my $ctx = tstregex_init_desc('/^\d{3}/');
58             tstregex($ctx, '12a');
59             if (!tstregex_is_full_match($res))
60             {
61             my $token = tstregex_get_fail_token($res);
62             my $pos = tstregex_get_match_len($res);
63             print "Failure on token '$token' at column $pos\n";
64             }
65              
66             =head1 API
67              
68             =head2 tstregex_init_desc($raw_re)
69              
70             Pre-parses the regex, handles delimiters (m!!, //, etc.), extracts modifiers (i, s, m, x), and prepares the nibbling steps. Returns a context hash.
71              
72             =head2 tstregex($ctx, $string)
73              
74             Executes the diagnostic. Updates the context.
75              
76             =head2 tstregex_is_full_match
77              
78             Returns match status of input string (BOOL 0 OR 1)
79              
80             =head2 tstregex_get_match_portion
81              
82             Returns the matching portion in case of full match
83             (might be smaller than input string, depending on anchors..)
84              
85             =head2 tstregex_get_match_len
86              
87             Returns the matching substring length
88              
89             =head2 tstregex_get_fail_token
90              
91             Returns the failing token in the regexp
92              
93             =head2 tstregex_get_re_clean
94              
95             Returns the matching regexp subpart
96              
97             =head2 tstregex_get_re_raw
98              
99             Returns the internal representation of the regexp
100              
101             =head2 tstregex_get_prefix_offset
102              
103             Returns the offset of the original regexp in the raw regexp
104              
105             =head1 DESCRIPTION
106              
107             C is designed to solve the "Black Box" problem of Regular Expressions.
108             When a complex regex fails, Perl usually just says "No Match". This tool
109             identifies exactly B and B it failed by finding the longest possible
110             partial match.
111              
112             =head2 The "Nibbling" Engine
113              
114             The diagnostic logic uses a "Nibbling" (grignotage) strategy:
115              
116             =over 4
117              
118             =item 1. Decomposition
119              
120             The engine breaks down your regex into a hierarchy of valid sub-patterns (lexical groups, atoms, and quantifiers) from longest to shortest.
121              
122             =item 2. Longest Match Search
123              
124             It iteratively tests these sub-patterns against the input string. It's not just checking if the start matches, but what is the I sequence of instructions the engine could follow before hitting a wall.
125              
126             =item 3. Failure Point Identification
127              
128             Once the longest matching sub-pattern is found, the tool identifies the very next token in your regex syntax. This is your "Point of Failure".
129              
130             =back
131              
132             =head1 AUTHOR
133              
134             Olivier Delouya - 2026
135              
136             =head1 LICENSE
137              
138             Artistic Version 2
139              
140             =cut
141              
142              
143             package main;
144             {
145 15     15   198 use strict;
  15         61  
  15         575  
146 15     15   85 use warnings;
  15         44  
  15         847  
147 15     15   95 use Carp qw(confess);
  15         25  
  15         1368  
148             $SIG{__WARN__} = 'confess';
149             $SIG{__DIE__ } = 'confess';
150              
151 15     15   10704 use Term::ANSIColor qw(:constants);
  15         171161  
  15         26818  
152 15     15   161 use Time::HiRes qw(gettimeofday tv_interval);
  15         29  
  15         152  
153 15     15   9855 use utf8;
  15         4389  
  15         125  
154              
155             # --- Constants & ASCII Codes ---
156             use constant
157             {
158 15         5568 C_m => 109, C_g => 103, C_i => 105, C_s => 115,
159             C_x => 120, C_SLASH => 47, C_SPACE => 32, C_ZERO => 48,
160             C_NINE => 57, C_UP_A => 65, C_UP_Z => 90, C_LOW_A => 97,
161             C_LOW_Z => 122, C_UNDSC => 95,
162             OP_PAR => 40, CL_PAR => 41, OP_BRK => 91, CL_BRK => 93,
163             OP_BRC => 123, CL_BRC => 125, OP_ANG => 60, CL_ANG => 62,
164             ESC => "\e",
165             CUU => "\e[A", # Cursor Up
166             UI_OFFSET=> 11, # Alignment offset for " Syntax: "
167 15     15   1550 };
  15         30  
168              
169             # --- ENCAPSULATED DEBUG ALIAS ---
170             BEGIN
171             {
172 15 50   15   31498 if ($INC{'perl5db.pl'})
173             {
174 0         0 require Data::Dumper;
175 0         0 require Term::ANSIColor;
176 15     15   120 no strict 'refs';
  15         30  
  15         742  
177 15     15   78 no warnings 'once';
  15         25  
  15         5289  
178              
179             my $debug_sub = sub
180             {
181 0         0 my @args = @_;
182              
183             # Automatically detect flattened hashes:
184             # If even number of arguments and the first one isn't a reference
185 0 0 0     0 if (scalar @args > 1 && scalar @args % 2 == 0 && !ref($args[0]))
      0        
186             {
187             # Wrap the flattened list into a temporary hashref
188 0         0 my %tmp_hash = @args;
189 0         0 @args = (\%tmp_hash);
190             }
191              
192 0         0 print "\n", Term::ANSIColor::BOLD(), Term::ANSIColor::BLUE(),
193             'DEBUG (tstregex): ', Term::ANSIColor::RESET(),
194             Data::Dumper::Dumper(@args);
195 0         0 };
196              
197             # Force injection into all relevant namespaces
198 0         0 foreach my $pkg ('main', 'tstregex', 'DB')
199             {
200 0         0 *{"${pkg}::d"} = $debug_sub;
  0         0  
201             }
202              
203 0 0       0 my $cuu = defined &main::CUU ? main::CUU() : "\e[A";
204 0         0 print $cuu, Term::ANSIColor::BOLD(), Term::ANSIColor::CYAN(),
205             'INFO: ', Term::ANSIColor::RESET(),
206             "Alias 'd' ready (Auto-hash detection enabled)\n\n";
207             }
208             }
209            
210             exit(main(scalar(@ARGV), \@ARGV)) if(!caller);
211              
212             sub main
213             {
214 0     0   0 my ($argc, $argv) = @_;
215 0 0 0     0 if(!$argc || ($argc && $$argv[0] =~ /^-h|--help$/)) { help(); exit(0); }
  0   0     0  
  0         0  
216              
217 0         0 binmode STDOUT, ':utf8';
218 0         0 my ($mode_diag, $verbose, $assert) = (0)x2;
219 0         0 for(my $i=0; $i<$argc; $i++)
220             {
221 0 0 0     0 do { $mode_diag = 1; undef $$argv[$i]; next } if (!$mode_diag && $$argv[$i] =~ /^-d|--diag$/);
  0         0  
  0         0  
  0         0  
222 0 0 0     0 do { $verbose = 1; undef $$argv[$i]; next } if (!$verbose && $$argv[$i] =~ /^-v|--verbose$/);
  0         0  
  0         0  
  0         0  
223 0 0 0     0 do { $assert = 1; undef $$argv[$i]; next } if (!$assert && $$argv[$i] =~ /^-a|--assert$/);
  0         0  
  0         0  
  0         0  
224             }
225            
226 0 0       0 if ($assert)
227             {
228 0         0 print BOLD, BLUE, "--- Internal Test Suite (DATA Section) ---\n", RESET;
229 0         0 _run_internal_tests($mode_diag, $verbose);
230 0         0 exit(0);
231             }
232              
233 0         0 my @new_argv;
234 0         0 foreach(@$argv)
235             {
236 0 0       0 push @new_argv, $_ if(defined($_));
237             }
238 0         0 $argv = \@new_argv;
239 0         0 $argc = scalar @$argv;
240            
241 0         0 my $re_raw = shift @{$argv};
  0         0  
242 0         0 my $ctx = tstregex::tstregex_init_desc($re_raw);
243 0         0 my $global_result = 0; # success! BE POSITIVE !!
244 0         0 foreach my $pattern (@{$argv})
  0         0  
245             {
246 0 0       0 my $t0 = [gettimeofday] if $mode_diag;
247 0         0 my $result = tstregex::tstregex($ctx, $pattern);
248 0 0       0 $global_result = 1 if($result);
249 0 0       0 $mode_diag ? _display_enriched($pattern, $ctx, tv_interval($t0))
250             : _display_standard($pattern, $ctx);
251 0 0       0 if($verbose)
252             {
253 0 0       0 print $result? 'Match':'UNmatch', '! Match length: ', tstregex::tstregex_get_match_len($ctx), '; ';
254 0 0       0 print $result? ('Match portion: ', Term::ANSIColor::UNDERLINE(), tstregex::tstregex_get_match_portion($ctx))
255             : ('Fail token: ', tstregex::tstregex_get_fail_token($ctx));
256 0         0 print Term::ANSIColor::RESET(), "\n";
257 0 0 0     0 print $ctx->{warning} if($verbose && $ctx->{warning} ne '');
258             }
259             }
260 0         0 return $global_result;
261             }
262              
263             sub _run_internal_tests
264             {
265 0     0   0 my ($mode_diag, $verbose) = @_;
266 0         0 my $fh = \*main::DATA;
267 0         0 seek($fh, 0, 0);
268            
269 0         0 my $found_data_token = 0;
270            
271 0         0 while (<$fh>)
272             {
273 0         0 chomp;
274             # PHASE 1: Skip everything until we hit the __DATA__ or __END__ marker
275             # This prevents the script from parsing its own source code
276 0 0       0 if (!$found_data_token)
277             {
278 0 0       0 $found_data_token = 1 if /^__(DATA|END)__/;
279 0         0 next;
280             }
281             else
282             {
283 0 0       0 last if /^__(DATA|END)__/;
284             }
285 0 0 0     0 next if /^\s*$/ || /^#/;
286 0         0 my ($re, @rest) = split(/\s+|:::\s*/, $_);
287 0 0       0 next unless $re;
288 0 0       0 my @strings = grep { $_ ne '0' && $_ ne '1' } @rest;
  0         0  
289              
290 0         0 print BOLD, YELLOW, 'Testing Regex: ', RESET, "$re\n";
291 0         0 my $ctx = tstregex::tstregex_init_desc($re);
292 0 0 0     0 print 'Warning ', $ctx->{warning}, "\n" if($verbose && $ctx->{warning} ne '');
293 0         0 foreach my $s (@strings)
294             {
295 0         0 my $t0;
296 0 0       0 $t0 = [gettimeofday] if $mode_diag;
297 0         0 tstregex::tstregex($ctx, $s);
298 0 0       0 $mode_diag ? _display_enriched($s, $ctx, tv_interval($t0))
299             : _display_standard($s, $ctx);
300             }
301 0         0 print '-' x 40, "\n";
302             }
303             }
304              
305             sub _display_standard
306             {
307 0     0   0 my ($pattern, $ctx) = @_;
308 0 0       0 if (tstregex::tstregex_is_full_match($ctx))
309             {
310 0         0 print "$pattern";
311 0         0 print "\n";
312             }
313             else
314             {
315 0         0 my $match_len = tstregex::tstregex_get_match_len ($ctx);
316 0         0 my $fail_token = tstregex::tstregex_get_fail_token($ctx);
317 0         0 my $re_clean = tstregex::tstregex_get_re_clean ($ctx);
318 0         0 print substr($pattern, 0, $match_len), BOLD, substr($pattern, $match_len), RESET;
319 0         0 my $off = length($re_clean) - length($fail_token);
320 0         0 print ' (', substr($re_clean, 0, $off), BOLD, $fail_token, RESET, ")\n";
321             }
322             }
323              
324             sub _display_enriched
325             {
326 0     0   0 my ($pattern, $ctx, $elapsed) = @_;
327 0         0 print BOLD, MAGENTA, '--- Diagnostic View ---', RESET, "\n";
328              
329 0 0       0 if (tstregex::tstregex_is_full_match($ctx))
330             {
331 0         0 print GREEN, ' Result: ', RESET, "$pattern (FULL MATCH)\n";
332             }
333             else
334             {
335 0         0 my $match_len = tstregex::tstregex_get_match_len ($ctx);
336 0         0 my $fail_token = tstregex::tstregex_get_fail_token ($ctx);
337 0         0 my $re_clean = tstregex::tstregex_get_re_clean ($ctx);
338 0         0 my $prefix_off = tstregex::tstregex_get_prefix_offset($ctx);
339 0         0 my $re_raw = tstregex::tstregex_get_re_raw ($ctx);
340              
341 0         0 print YELLOW, ' Result: ', RESET, substr($pattern, 0, $match_len),
342             BOLD, WHITE, substr($pattern, $match_len), RESET;
343 0         0 print ' (at ', CYAN, $fail_token, RESET, ")\n";
344              
345 0         0 my $err_pos_in_clean = length($re_clean) - length($fail_token);
346 0         0 my $final_pointer_pos = $prefix_off + $err_pos_in_clean;
347              
348 0         0 print ' Syntax: ', WHITE, $re_raw, RESET, "\n";
349 0         0 print ' ', ' ' x $final_pointer_pos, BOLD, RED, '^--- HERE', RESET, "\n";
350             }
351 0         0 printf " Time: %.4fs\n\n", $elapsed;
352             }
353              
354             sub help
355             {
356 0     0   0 print BOLD, WHITE, "tstregex.pm - Longest match Regular Expression Diagnostic Tool (2026 - PerlOD)\n", RESET;
357 0         0 print "Usage:\n";
358 0         0 print " perl tstregex.pm [options] 'regex' 'string1' ['string2' ...]\n\n";
359 0         0 print "Examples:\n";
360 0         0 print " perl tstregex.pm '([0-3][0-9])/[0-1][0-9]/\\d{4}' '21/72/1985'\n";
361 0         0 print ' 21/', BOLD, '72/1985', RESET, ' ([0-3][0-9]/', BOLD, '[0-1][0-9]/\d{4}', RESET, ")\n\n";
362 0         0 print BOLD, 'DELIMITERS ', RESET, "are optional\n";
363 0         0 print " Supported: /.../, m!...!, m{...}. Modifiers (/i, /x, /s...) and captures are supported.\n\n";
364 0         0 print "Options:\n";
365 0         0 print "-h --help Shows that help\n";
366 0         0 print "-v --verbose Shows keys info on match/unmatch\n";
367 0         0 print "-d --diag Enriched diagnostic with timing and syntax pointers\n";
368 0         0 print "-a --assert Misc: shows a large test of regexp against tstregex..\n";
369             }
370              
371             }
372              
373             1;
374              
375             package tstregex;
376             {
377             our $VERSION = '1.00';
378 15     15   137 use Exporter qw(import);
  15         34  
  15         4656  
379              
380             our @EXPORT = qw(
381             tstregex
382             tstregex_init_desc
383             tstregex_get_match_len
384             tstregex_get_fail_token
385             tstregex_is_full_match
386             tstregex_get_re_clean
387             tstregex_get_prefix_offset
388             tstregex_get_re_raw
389             tstregex_get_match_portion
390             tstregex_get_info
391             );
392              
393             # --- PUBLIC API (The Getters) ---
394              
395 115     115 1 565 sub tstregex_get_match_len { return $_[0]->{match_len}; }
396 115     115 1 445 sub tstregex_get_fail_token { return $_[0]->{fail_token}; }
397 0     0 1 0 sub tstregex_is_full_match { return $_[0]->{full_match}; }
398 0     0 1 0 sub tstregex_get_re_clean { return $_[0]->{re_clean}; }
399 0     0 1 0 sub tstregex_get_prefix_offset { return $_[0]->{prefix_offset}; }
400 0     0 1 0 sub tstregex_get_re_raw { return $_[0]->{re_raw}; }
401 115   50 115 0 513 sub tstregex_get_captures { return $_[0]->{captures} // [];}
402 0     0 1 0 sub tstregex_get_match_portion { return $_[0]->{match_portion}; }
403 0     0 0 0 sub tstregex_get_info { return $_[0]->{warning}; }
404              
405             # Main diagnostic function
406 15     15   140 use constant;
  15         28  
  15         1830  
407             use constant
408             {
409 15         43087 C_EMPTY => '',
410             RE_EMPTY => qr/\0/,
411             ASCII_LPAREN => ord('('), # 40
412             ASCII_RPAREN => ord(')'), # 41
413             ASCII_LBRACE => ord('{'), # 123
414             ASCII_RBRACE => ord('}'), # 125
415             ASCII_LBRACK => ord('['), # 91
416             ASCII_RBRACK => ord(']'), # 93
417             ASCII_LT => ord('<'), # 60
418             ASCII_GT => ord('>'), # 62
419 15     15   103 };
  15         32  
420            
421             sub tstregex
422             {
423 115     115 1 577 my ($ctx, $pattern) = @_;
424 115         252 my $re_raw = $ctx->{re_raw};
425 115         189 my $org_pat = $pattern;
426 115         178 my $internal_offset = 0;
427              
428             # Handle prefix offset if pattern is wrapped like the RE
429 115 100 66     364 if ($ctx->{prefix_offset} > 0 && length($pattern) >= $ctx->{prefix_offset} + 1)
430             {
431 1         27 my $re_delim_char = _stringat($re_raw, $ctx->{prefix_offset} - 1);
432 1         4 my $pat_first_char = _stringat($pattern, 0);
433 1 50       5 if ($pat_first_char == $re_delim_char)
434             {
435 1         3 $pattern = substr($org_pat, $ctx->{prefix_offset});
436 1         3 chop $pattern;
437 1         3 $internal_offset = $ctx->{prefix_offset};
438             }
439             }
440              
441             # Fast track: check if it matches globally first
442 115 100       821 if ($pattern =~ $ctx->{re_compiled})
443             {
444 68         172 $ctx->{full_match} = 1;
445 68         128 $ctx->{match_len} = length($org_pat);
446 68         212 $ctx->{match_portion} = $&; # the exact sub portion that matched
447 68         145 $ctx->{left_unmatch} = $`; # the left part of matching sub part
448 68         133 $ctx->{right_unmatch} = $'; # the right part of matching sub part
449              
450             # --- Capture Groups Extraction ---
451             # We populate the captures array only on a successful global match
452 68         109 my @caps;
453              
454             # The special variable $#- contains the number of capture groups
455             # We start at 1 because $0 is the whole match
456 68         303 for my $i (1 .. $#-)
457             {
458 22 100       74 if (defined $-[$i])
459             {
460             # Extracting the substring using offsets from @- and @+
461 21         107 push @caps, substr($pattern, $-[$i], $+[$i] - $-[$i]);
462             }
463             else
464             {
465             # Optional group that participated but didn't catch text
466 1         11 push @caps, undef;
467             }
468             }
469 68         183 $ctx->{captures} = \@caps;
470              
471 68         211 return 1;
472             }
473              
474             # Nibbling phase: find the longest matching lexical group
475 47         143 my $match_reg = tstregex::C_EMPTY;
476 47         73 foreach my $step (@{$ctx->{steps}})
  47         144  
477             {
478 114 100       2292 if ($pattern =~ qr/$step/)
479             {
480 35         65 $match_reg = $step;
481 35         70 last;
482             }
483             }
484              
485             # ** SENSITIVE **
486             # Fine-tune the match length character by character
487             # Append a \z to avoid Perl to skip final \n
488             # if Nibbling failed ($match_reg empty), get the full regex for fine-tuning.
489 47 100       159 my $target_re = ($match_reg ne tstregex::C_EMPTY) ? $match_reg : $ctx->{re_clean};
490             # critical! add starting anchor (\A) to force coherency check from the first char..
491 47         137 my ($match_work, $warn) = _safe_qr("\\A$target_re\\z"); # (qr/\A$target_re\z/, undef); #;
492 47         194 for (my $i = length($pattern); $i >= 0; $i--)
493             {
494             # check if current prefix is valid according to the target
495 117 100       471 last if ($pattern =~ $match_work);
496 83         202 chop $pattern;
497             }
498 47         204 $ctx->{match_len} = length($pattern) + $internal_offset;
499              
500             # Identify the failing token for display
501 47 50       73 my $tail_re = (scalar @{$ctx->{steps}}) ? $ctx->{steps}->[0] : $ctx->{re_clean};
  47         154  
502 47         112 my $remaining_re = substr($tail_re, length($match_reg));
503              
504 47 50       121 if ($remaining_re ne tstregex::C_EMPTY)
505             {
506             # get the first token for analyse
507 47         152 my $next_tokens = _get_lex_groups($remaining_re);
508 47   50     139 my $first_token = $next_tokens->[0] // tstregex::C_EMPTY;
509 47         93 $ctx->{fail_token} = $remaining_re;
510 47 100       204 $ctx->{fail_token} = $first_token if ($first_token =~ /^(\\b|\^|\$)$/); # Anchor case (0 width): want detail (just \b, ^ or $)
511             }
512             else
513             {
514 0         0 $ctx->{fail_token} = tstregex::C_EMPTY;
515             }
516              
517             # Ensure captures is empty/undef on failure
518 47         220 $ctx->{captures} = [];
519 47 50       248 return $ctx->{match_undef}? undef:0;
520             }
521              
522             # Context initialization and RE peeling
523             sub tstregex_init_desc
524             {
525 115     115 1 2972728 my ($re_raw) = @_;
526             # The Shield: Catching the 5.28 deprecation warnings and fatal errors
527             # We use a localized __WARN__ handler to catch the "Unescaped left brace"
528             # even if it's not a fatal error yet in 5.28
529            
530 115         326 my ($re_compiled, $re_clean, $prefix_off, $last_warning) = _unwrap_regex($re_raw);
531 115 50       357 my $match_undef = $re_compiled eq RE_EMPTY ? 1:0;
532            
533 115         1531 my $steps = _parse_lex_groups($re_clean);
534             # {
535             # no warnings 'experimental::re_strict';
536             # use re 'strict';
537             # $steps = _parse_lex_groups($re_clean);
538             # };
539             return
540             {
541 115         1381 re_raw => $re_raw, re_compiled => $re_compiled, re_clean => $re_clean,
542             steps => $steps, prefix_offset => $prefix_off, match_len => 0,
543             fail_token => tstregex::C_EMPTY, full_match => 0,match_portion => undef,
544             match_undef => $match_undef, left_unmatch => undef, right_unmatch => undef,
545             warning => $last_warning,
546             };
547             }
548              
549             # Helper: get char code at position
550 2     2   6 sub _stringat($$) { return vec($_[0], $_[1], 8); }
551              
552             sub _unwrap_regex
553             {
554 115     115   264 my ($raw) = @_;
555 115 50 33     690 return (qr//, tstregex::C_EMPTY, 0) if !defined $raw || $raw eq tstregex::C_EMPTY;
556            
557 115         202 my $raw_org = $raw;
558 115         168 my $options = tstregex::C_EMPTY;
559            
560             # 1. Extract trailing options (ismxg)
561 115         587 while ($raw =~ s/([ismxg])$//)
562             {
563 0         0 $options = $1 . $options;
564             }
565            
566             # 2. Delegate peeling to _strop
567 115         285 my $clean = _strop($raw);
568            
569             # 3. Automatic offset calculation (locate the "juice" within the original string)
570 115         301 my $off = index($raw_org, $clean);
571 115 50       263 $off = 0 if $off < 0;
572            
573             # 4. Secure Forge (Remove 'g' as it is irrelevant for qr//)
574 115         208 $options =~ tr/g//d;
575 115 50       276 my $re_str = $options ? "(?$options)$clean" : $clean;
576            
577 115         282 my ($re_ret, $warn) = _safe_qr($re_str);
578            
579 115         423 return ($re_ret, $clean, $off, $warn);
580             }
581            
582             sub _safe_qr
583             {
584 162     162   296 my ($re_str) = @_;
585 162         257 my ($re, $err);
586             {
587 162         243 local $@;
  162         269  
588 162     1   1684 local $SIG{__DIE__} = local $SIG{__WARN__} = sub { };
589             # dont catch the warning there, let the eval fail instead and get the message back in $@
590 162         331 $re = eval { qr/$re_str/ };
  162         3166  
591 162   50     1208 $err = $@ // '';
592             }
593 162   50     852 return ($re // RE_EMPTY, $err);
594             }
595            
596             # _strop: strip operators
597             # Peels Perl operators (m!!, m{}, //) by checking extremities.
598             # It ensures only the core regex juice is returned.
599             sub _strop
600             {
601 115     115   221 my ($raw) = @_;
602 115 50 33     472 return $raw if !defined $raw || $raw eq tstregex::C_EMPTY;
603            
604             # Remove leading/trailing whitespace
605 115         554 $raw =~ s/^\s+|\s+$//g;
606            
607             # Identify opening delimiter (after an optional 'm')
608 115 100       715 if ($raw =~ /^((?:m\s*)?)([^\w\s])(.*)$/s)
609             {
610 50         149 my $prefix = $1;
611 50         96 my $open = $2;
612 50         104 my $body = $3;
613            
614             # Map symmetric pairs using ASCII constants
615 50         260 my %sym_or_eq =
616             (
617             chr(ASCII_LBRACE) => chr(ASCII_RBRACE),
618             chr(ASCII_LBRACK) => chr(ASCII_RBRACK),
619             chr(ASCII_LPAREN) => chr(ASCII_RPAREN),
620             chr(ASCII_LT) => chr(ASCII_GT),
621             );
622            
623             # Expected close is either the matching pair or the same character (e.g., m!!)
624 50   66     218 my $expected_close = $sym_or_eq{$open} || $open;
625              
626             # PROTECT CAPTURE GROUPS:
627             # If the delimiter is a parenthesis but there is no 'm' prefix,
628             # it is a capturing group, NOT an operator. Do not peel!
629 50 100 66     201 if ($open eq chr(ASCII_LPAREN) && !$prefix)
630             {
631 26         121 return $raw;
632             }
633              
634             # Check if the very last character matches our expected closing delimiter
635 24 100       76 if (substr($body, -1) eq $expected_close)
636             {
637 1         6 return substr($body, 0, -1);
638             }
639            
640             # If 'm' was present but closing failed, return body (best effort)
641 23 50       128 return $body if $prefix;
642             }
643            
644 88         203 return $raw;
645             }
646            
647             sub _parse_lex_groups
648             {
649 115     115   205 my ($regex) = @_;
650 115         1109 my $tokens = _get_lex_groups($regex);
651 115         167 my @results;
652 115         333 my $current = join(tstregex::C_EMPTY, @$tokens);
653              
654 115         286 while (@$tokens)
655             {
656 412         1072 my $opens = () = $current =~ /(?
657 412         799 my $closes = () = $current =~ /(?
658 412 50       778 if ($opens >= $closes)
659             {
660 412         858 my $v = $current . (')' x ($opens - $closes));
661 412         745 $v =~ s/(?
662 412 100       580 if (eval { qr/$v/ }) { push @results, $v; }
  412         7347  
  409         864  
663             }
664 412         928 my $last = pop @$tokens;
665 412 50       1397 substr($current, -length($last)) = tstregex::C_EMPTY if defined $last;
666             }
667 115         299 return \@results;
668             }
669              
670             # Lexical tokenizer for Perl Regex
671             sub _get_lex_groups
672             {
673 162     162   302 my ($regex) = @_;
674 162         272 my @groups;
675              
676             # --- START: class mismatch support (Added [.*?] to tokenizer) ---
677             # my $re = qr/(\(\?\#.*?\))|(\(\?[:=!<>]+)|(\{\d+,?\d*\})|(\[.*?\])|(\\.)|([\(\)\|^\$\+\*\?])|(.)/x;
678             # --- ENHANCED: Atomic Lookaround & Recursive Group Support ---
679             # Group 1: Comments (?#...)
680             # Group 2: Assertions and Groups (?=, (?:, (?<, etc. including nested parens
681             # Group 3: Quantifiers {n,m}
682             # Group 4: Character classes [...]
683             # Group 5: Escaped characters \.
684             # Group 6: Metacharacters ( ) | ^ $ + * ?
685             # Group 7: Any other character
686             # --- END: class mismatch support ---
687 162         533 my $re = qr/
688             (\(\?\#.*?\))
689             | ( # START GROUP 2
690             \(\?[:=!<>]+ # Assertion header
691             (?: # Content
692             (?> [^()]+ ) # Non-paren characters (atomic)
693             |
694             (?2) # Recursive call to Group 2
695             )*
696             \) # Matching closing paren
697             ) # END GROUP 2
698             | (\{\d+,?\d*\})
699             | (\[.*?\])
700             | (\\.)
701             | ([\(\)\|^\$\+\*\?])
702             | (.)
703             /x;
704              
705 162         1288 while ($regex =~ /$re/g)
706             {
707 561   100     5253 my $t = $1 // $2 // $3 // $4 // $5 // $6 // $7;
      100        
      100        
      100        
      100        
      66        
708 561 100 66     2843 if (defined $t && $t =~ /^[\+\*\?]$|^\{\d/ && @groups && $groups[-1] !~ /^[\(\)\|]$/)
      66        
      100        
709             {
710 71         335 $groups[-1] .= $t;
711             }
712 490         2527 else { push @groups, $t; }
713             }
714            
715             # TODO: test that optimized code fragment instead; Much time spent here, but sensitive part..
716             # my $quantifiers = '+*?{';
717             # while ($regex =~ /$re/g)
718             # {
719             # my $t = $1 // $2 // $3 // $4 // $5 // $6 // $7;
720             # if (defined $t && @groups)
721             # {
722             # my $char = substr($t, 0, 1);
723             # if (index($quantifiers, $char) != -1 && $groups[-1] !~ /^[\(\)\|]$/)
724             # {
725             # $groups[-1] .= $t;
726             # next;
727             # }
728             # }
729             # push @groups, $t;
730             # }
731            
732 162         543 return \@groups;
733             }
734              
735             }
736            
737             1;
738              
739             package main;
740              
741             __DATA__