File Coverage

blib/lib/Tstregex.pm
Criterion Covered Total %
statement 165 293 56.3
branch 37 102 36.2
condition 36 80 45.0
subroutine 27 38 71.0
pod 9 11 81.8
total 274 524 52.2


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