Commit 674e37f6c2 for perl

commit 674e37f6c20d7eeda5d02761e86ec468430d9e22
Author: Yves Orton <demerphq@gmail.com>
Date:   Mon Oct 12 23:45:02 2026 +0200

    regexec.c: restore captures after negative assertions

    We save the incoming capture state before evaluating a negative
    assertion and restore it after each attempt. A successful negative
    assertion means its body failed. Captures made along that failed path
    should be rolled back, just as they are when an alternation branch
    fails.

    For example, "a" =~ /\A(?!(a)b)a\z/ now leaves $1 undefined. A later
    backreference or condition can no longer use the stale capture. We
    also restore the incoming state before retrying a variable-length
    negative lookbehind at another starting position.

    Captures remain available to backreferences and embedded code while
    the assertion body is being evaluated. Positive assertions and atomic
    groups continue to retain captures from successful bodies. Assertions
    in patterns without captures do not need a capture checkpoint.

    Fixes:
        https://github.com/Perl/perl5/issues/24946
        https://github.com/Perl/perl5/issues/14387

diff --git a/pod/perldelta.pod b/pod/perldelta.pod
index c3c3ed2186..4b59cb71d6 100644
--- a/pod/perldelta.pod
+++ b/pod/perldelta.pod
@@ -434,6 +434,14 @@ C</\A(?=a*)[0-9]a\z/> now matches C<"0a">.

 =item *

+Negative lookahead and lookbehind assertions restore capture buffers after
+evaluating their bodies. Captures from failed assertion attempts no longer
+appear in a successful match or affect later backreferences and conditions.
+
+[GH #14387] [GH #24946]
+
+=item *
+
 The regular expression optimizer no longer uses the fixed-width repeat
 optimization for a body containing captures inside nested quantifiers.
 This prevents failed or backtracked iterations from leaving incorrect
diff --git a/pod/perlre.pod b/pod/perlre.pod
index 3834519dde..1c67ce0d57 100644
--- a/pod/perlre.pod
+++ b/pod/perlre.pod
@@ -1770,6 +1770,9 @@ will not do what you want.  That's because the C<(?!foo)> is just saying that
 the next thing cannot be "foo"--and it's not, it's a "bar", so "foobar" will
 match.  Use lookbehind instead (see below).

+Captures made inside a negative lookahead are discarded before matching
+continues. Captures made before the assertion retain their incoming values.
+
 =item C<(?<=I<pattern>)>

 =item C<\K>
@@ -1869,8 +1872,9 @@ has been reduced, and experimental warnings will only be produced when
 the construct contains capturing parentheses. The warnings will be
 raised at pattern compilation time, unless turned off, in the
 C<experimental::vlb> category.  This is to warn you that the exact
-contents of capturing buffers in a variable length negative lookbehind
-is not well defined and is subject to change in a future release of perl.
+contents of capturing buffers while evaluating a variable length negative
+lookbehind is not well defined and is subject to change in a future release
+of perl.

 Currently if you use capture buffers inside of a negative variable length
 lookbehind the result may not be what you expect, for instance:
@@ -1880,23 +1884,24 @@ lookbehind the result may not be what you expect, for instance:
 will output the following:

     a
-    no
+    n

 which does not make sense as this should print out "ax" as the "a" does
-not line up at the correct place. Another example would be:
+not line up at the correct place. Captures made inside a negative
+lookbehind are discarded before matching continues. For example:

     say "yes: '$1-$2'" if "aayfoo"=~/(?=foo)(?<!(a|aa)(a|aa)x)/;

 will output the following:

-    yes: 'aa-a'
+    yes: '-'

-It is possible in a future release of perl we will change this behavior
-so both of these examples produced more reasonable output.
+It is possible in a future release of perl we will change which capture
+values are visible to embedded code while evaluating the assertion.

 Note that we are confident that the construct will match and reject
 patterns appropriately, the undefined behavior strictly relates to the
-value of the capture buffer during or after matching.
+value of the capture buffer while evaluating the assertion.

 There is a technique that can be used to handle variable length
 lookbehind on earlier releases, and longer than 255 characters.  It is
diff --git a/regexec.c b/regexec.c
index ffc6563bf0..85f1aaa729 100644
--- a/regexec.c
+++ b/regexec.c
@@ -10225,6 +10225,14 @@ NULL
             ST.logical = logical;
             logical = 0; /* XXX: reset state of logical once it has been saved into ST */

+            /* A negative assertion never contributes captures to the match.
+             * Restore the incoming state after each attempt, including when
+             * failure of the body makes the assertion succeed. */
+            if (!ST.wanted && rex->nparens) {
+                (void)regcppush(rex, 0, maxopenparen);
+                REGCP_SET(ST.lastcp);
+            }
+
             /* execute body of (?...A) */
             PUSH_YES_STATE_GOTO(IFMATCH_A, REGNODE_AFTER(scan), ST.start,
                                 ST.end, script_run_begin);
@@ -10234,7 +10242,18 @@ NULL
             bool matched;

         case IFMATCH_A_fail: /* body of (?...A) failed */
-            if (! ST.logical && ST.count > 1) {
+            matched = false;
+            goto ifmatch_done;
+
+        case IFMATCH_A: /* body of (?...A) succeeded */
+            matched = true;
+          ifmatch_done:
+            if (!ST.wanted && rex->nparens) {
+                REGCP_UNWIND(ST.lastcp);
+                regcppop(rex, &maxopenparen);
+            }
+
+            if (!matched && ! ST.logical && ST.count > 1) {

                 /* It isn't a real failure until we've tried all starting
                  * positions.  Move to the next starting position and retry */
@@ -10245,13 +10264,6 @@ NULL
                 goto do_ifmatch;
             }

-            /* Here, all starting positions have been tried. */
-            matched = false;
-            goto ifmatch_done;
-
-        case IFMATCH_A: /* body of (?...A) succeeded */
-            matched = true;
-          ifmatch_done:
             sw = matched == ST.wanted;
             match_end = ST.prev_match_end;
             if (! ST.logical && !sw) {
diff --git a/regexp.h b/regexp.h
index 7d57cd9a10..10b9f99dac 100644
--- a/regexp.h
+++ b/regexp.h
@@ -1007,6 +1007,7 @@ typedef struct regmatch_state {
             struct regmatch_state *prev_yes_state;
             I32     wanted;
             I32     logical;    /* saved copy of 'logical' var */
+            CHECKPOINT lastcp;  /* capture state before a negative assertion */
             U8      count;      /* number of beginning positions */
             char    *start;
             char    *end;
diff --git a/t/re/re_tests b/t/re/re_tests
index b13f3112ef..44c6bc4765 100644
--- a/t/re/re_tests
+++ b/t/re/re_tests
@@ -2327,6 +2327,29 @@ a+X(*COMMIT)j	aXaXj	n	-	-	-	PLUS skip after (*COMMIT) [ASCII]
 (?:(\x{100}\x{101}){2})*	\x{100}\x{101}	y	${\(defined($1) ? $1 : q(undef))}	undef	-	failed UTF-8 iteration must not leave a capture
 \A(?:([\x{100}-\x{103}]{2}){2})*\x{102}\x{102}\x{103}\x{103}\z	\x{100}\x{100}\x{101}\x{101}\x{102}\x{102}\x{103}\x{103}	y	$1	\x{101}\x{101}	-	UTF-8 backtracking restores the capture

+# GH #14387, GH #24946: negative assertions must discard their captures
+\A(?!(a)b)a\z	a	y	${\(defined($1) ? $1 : q(undef))}	undef	-	partial negative lookahead body
+\A(?!(a)b)\1\z	a	n	-	-	-	backreference must not use a failed assertion capture
+\A(?!(?:(a)b|z))\1\z	a	n	-	-	-	negative assertion with an impossible alternative
+\A(?!(a)b)(?(1)a|b)\z	a	n	-	-	-	conditional must not see a failed assertion capture
+\A(?!(a)b)(?(1)a|b)\z	b	y	$&	b	-	conditional sees an unset assertion capture
+\A(a)(?!(b)c)b\z	ab	y	$1-${\(defined($2) ? $2 : q(undef))}	a-undef	-	capture before the assertion is preserved
+\A(?!(?=(a))b)a\z	a	y	${\(defined($1) ? $1 : q(undef))}	undef	-	nested positive assertion in a negative body
+\A(?!((a))b)a\z	a	y	${\(defined($1) || defined($2) ? q(set) : q(undef))}	undef	-	nested captures in a negative body
+\A..(?<!(a)b)c\z	aac	y	${\(defined($1) ? $1 : q(undef))}	undef	-	partial fixed-width negative lookbehind body
+\A..(?<!(a)b)\1\z	aaa	n	-	-	-	backreference must not use a failed lookbehind capture
+\A..(?<!(a)b)c\z	abc	n	-	-	-	matching negative lookbehind body rejects the subject
+\A(?!(a))a\z	a	n	-	-	-	matching negative lookahead body rejects the subject
+\A(?=(a))a\z	a	y	$1	a	-	positive assertions retain successful captures
+\A(?>(a))\z	a	y	$1	a	-	atomic groups retain successful captures
+\A(?!(\x{100})b)\x{100}\z	\x{100}	y	${\(defined($1) ? $1 : q(undef))}	undef	-	Unicode negative lookahead body
+\A..(?<!(\x{100})b)c\z	\x{100}\x{100}c	y	${\(defined($1) ? $1 : q(undef))}	undef	-	Unicode fixed-width negative lookbehind
+/a*?(?!a()).a/s	a\na	y	${\(defined($1) ? q(defined) : q(undef))}	undef	-	GH #24946: captures from a rejected assertion attempt
+\A(a)(?!(b)c)b\z	ab	y	$1-$^N-$+	a-a-a	-	negative assertion restores last-capture bookkeeping
+\A(?<X>[ab])(?!(?&X)c)b\z	ab	y	$1	a	-	negative assertion restores a capture changed by recursion
+\A(?(?!(a)b)a|b)\z	a	y	${\(defined($1) ? $1 : q(undef))}	undef	-	negative assertion used as a condition
+\A...(?<!(a{1,2})b)c\z	aaac	y	${\(defined($1) ? $1 : q(undef))}	undef	-	variable-length negative lookbehind restores each attempt
+
 # Keep these lines at the end of the file
 # pat	string	y/n/etc	expr	expected-expr	skip-reason	comment
 # vim: softtabstop=0 noexpandtab