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