Skip to content

Commit 3d14205

Browse files
authored
Merge pull request #975 from fglock/feature/joni-optimistic-callback-condition
Execute optimistic regex callbacks in Joni
2 parents 5f6c842 + 91a3047 commit 3d14205

18 files changed

Lines changed: 154 additions & 7 deletions

File tree

docs/design/joni-callout-fork.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ The parser emits a `regexTemplate` operation whose ordered parts contain normal
135135
interpolation values and explicit `regexCallback` wrappers. Runtime template
136136
construction assigns callback IDs and produces the engine-facing skeleton.
137137

138+
Optimistic callbacks use the same structured path. Standalone `(*{ code })`
139+
callbacks execute as zero-width BLOCK callouts and publish their result through
140+
`$^R`; `(?(*{ code })yes|no)` predicates execute as CONDITION callouts, select
141+
their branch from the result's truth, and leave `$^R` unchanged. The matcher
142+
publishes `$^N` from capture-close order rather than deriving it from `$+`, whose
143+
meaning is the highest-numbered defined capture.
144+
138145
An interpolated coderef remains ordinary interpolation because only the parser
139146
can create a callback wrapper. Runtime strings containing Perl eval groups never
140147
become trusted callback skeletons. They retain the existing security checks and,
@@ -242,6 +249,8 @@ keep their established fold-to-pattern path.
242249
- Focused tests cover provisional captures, repeated execution after
243250
backtracking, `FAIL`, nested frames, handler exceptions, interruption, and
244251
exact-once reverse-order unwind.
252+
- Optimistic-callback tests cover standalone execution, conditional truth,
253+
`$^R`, exact `$^N` capture-close order, `$+`, and alternative reachability.
245254
- The standalone JAR contains no `org/joni` or `org/jcodings` classes, contains
246255
both relocated trees, and includes all required notices.
247256
- An embedding smoke test can load stock Joni and PerlOnJava together.

docs/reference/feature-matrix.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,10 +394,10 @@ my @copy = @{$z}; # ERROR
394394
-**Lookbehind Assertions**: Variable-length negative or positive lookbehind assertions, e.g., `(?<=...)` or `(?<!...)`, are not supported.
395395
-**Branch Reset Groups**: `(?|...)` resets capture numbering across alternatives and preserves mapped match variables.
396396
-**Advanced Subroutine Calls**: Sub-pattern calls with numbered or named references like `(?1)` and `(?&name)` execute through Joni.
397-
- 🟡 **Conditional Expressions**: Executable callback conditions `(?(?{ code })yes|no)` execute through Joni; other condition forms are not yet complete.
397+
- 🟡 **Conditional Expressions**: Executable callback conditions `(?(?{ code })yes|no)` and optimistic predicates `(?(*{ code })yes|no)` execute through Joni; other condition forms are not yet complete.
398398
-**Extended Unicode Regex Features**: Some extended Unicode regex functionalities are not supported.
399399
-**Extended Grapheme Clusters**: Matching with `\X` for extended grapheme clusters is not supported.
400-
- 🟡 **Embedded Code in Regex**: `(?{ code })`, executable callback conditions, and `(??{ code })` run as lexical closures in Joni with provisional captures and backtracking unwind. Optimistic callbacks `(*{ code })` are not supported.
400+
- **Embedded Code in Regex**: `(?{ code })`, optimistic callbacks `(*{ code })`, executable callback conditions, and `(??{ code })` run as lexical closures in Joni with provisional captures and backtracking unwind. `$^N` follows capture-close order independently of `$+`.
401401
-**Regex Debugging**: Lexically scoped `use/no re 'debug'` and `debugcolor` are supported, including runtime snapshot ownership.
402402
- 🟡 **Runtime Regex Evaluation**: `use re 'eval'` controls whether interpolated patterns containing eval groups may compile, but runtime strings cannot manufacture trusted callback closures.
403403
-**Regex Compilation Flags**: Setting default regex flags with `use re '/flags';` is not supported.

src/main/java/org/perlonjava/frontend/parser/StringSegmentParser.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,9 @@ protected boolean handleSpecialToken(String text) {
804804
} else if (isRegex && regexCodeBlocksAreActive() && isRegexRecursiveBlock()) {
805805
parseRegexCodeBlock(true); // (??{...}) - recursive pattern
806806
yield true;
807+
} else if (isRegex && regexCodeBlocksAreActive() && isRegexOptimisticBlock()) {
808+
parseRegexOptimisticBlock(); // (*{...}) - optimization-preserving callback
809+
yield true;
807810
}
808811
yield false;
809812
}
@@ -835,7 +838,8 @@ private boolean isRegexCallbackCondition() {
835838
return currentPos + 3 < parser.tokens.size()
836839
&& "?".equals(parser.tokens.get(currentPos).text)
837840
&& "(".equals(parser.tokens.get(currentPos + 1).text)
838-
&& "?".equals(parser.tokens.get(currentPos + 2).text)
841+
&& ("?".equals(parser.tokens.get(currentPos + 2).text)
842+
|| "*".equals(parser.tokens.get(currentPos + 2).text))
839843
&& "{".equals(parser.tokens.get(currentPos + 3).text);
840844
}
841845

@@ -844,7 +848,10 @@ private void parseRegexCallbackCondition() {
844848
int start = tokenIndex;
845849
TokenUtils.consume(parser, LexerTokenType.OPERATOR, "?");
846850
TokenUtils.consume(parser, LexerTokenType.OPERATOR, "(");
847-
TokenUtils.consume(parser, LexerTokenType.OPERATOR, "?");
851+
LexerToken callbackType = TokenUtils.consume(parser);
852+
if (!"?".equals(callbackType.text) && !"*".equals(callbackType.text)) {
853+
throw new IllegalStateException("invalid regex callback condition marker");
854+
}
848855
TokenUtils.consume(parser, LexerTokenType.OPERATOR, "{");
849856
Node block = parseBlock(parser);
850857
TokenUtils.consume(parser, LexerTokenType.OPERATOR, "}");
@@ -853,6 +860,24 @@ private void parseRegexCallbackCondition() {
853860
segments.add(regexCallback(block, "CONDITION", start));
854861
}
855862

863+
private boolean isRegexOptimisticBlock() {
864+
int currentPos = parser.tokenIndex;
865+
return currentPos + 1 < parser.tokens.size()
866+
&& "*".equals(parser.tokens.get(currentPos).text)
867+
&& "{".equals(parser.tokens.get(currentPos + 1).text);
868+
}
869+
870+
private void parseRegexOptimisticBlock() {
871+
flushCurrentSegment();
872+
int start = tokenIndex;
873+
TokenUtils.consume(parser, LexerTokenType.OPERATOR, "*");
874+
TokenUtils.consume(parser, LexerTokenType.OPERATOR, "{");
875+
Node block = parseBlock(parser);
876+
TokenUtils.consume(parser, LexerTokenType.OPERATOR, "}");
877+
TokenUtils.consume(parser, LexerTokenType.OPERATOR, ")");
878+
segments.add(regexCallback(block, "BLOCK", start));
879+
}
880+
856881
/**
857882
* Checks if the current tokens form a (??{...}) recursive regex pattern.
858883
* This is similar to (?{...}) but uses the result as a regex pattern.

src/main/java/org/perlonjava/runtime/regex/JoniRegexPattern.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ public String group(String name) {
421421
}
422422

423423
@Override public int groupCount() { return regex.numberOfCaptures(); }
424+
@Override public int lastClosedCapture() { return matcher.lastClosedCapture(); }
424425
@Override public Map<String, Integer> namedGroups() { return namedGroups; }
425426
@Override public String patternDescription() { return sourcePattern; }
426427

@@ -649,6 +650,9 @@ private void publishProvisional(MatchView match) {
649650
state.lastCaptureGroups[group - 1] = input.substring(begin, end);
650651
}
651652
}
653+
int lastClosed = match.lastClosedCapture();
654+
state.lastClosedCapture = lastClosed > 0 && lastClosed <= count
655+
? state.lastCaptureGroups[lastClosed - 1] : null;
652656
}
653657

654658
private int charOffset(int byteOffset) {

src/main/java/org/perlonjava/runtime/regex/RegexMatcher.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ public interface RegexMatcher {
3232

3333
int groupCount();
3434

35+
/** Most recently closed capture number, or -1 when the backend cannot provide it. */
36+
default int lastClosedCapture() {
37+
int selected = -1;
38+
int selectedEnd = -1;
39+
for (int group = 1; group <= groupCount(); group++) {
40+
int end = end(group);
41+
if (end > selectedEnd || (end == selectedEnd && end >= 0
42+
&& (selected < 0 || group < selected))) {
43+
selected = group;
44+
selectedEnd = end;
45+
}
46+
}
47+
return selected;
48+
}
49+
3550
Map<String, Integer> namedGroups();
3651

3752
String patternDescription();

src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,6 +1543,9 @@ private static void updateNumberedCaptureGroups(RuntimeRegex regex, RegexMatcher
15431543
regexState.manualCaptureStarts = null;
15441544
regexState.manualCaptureEnds = null;
15451545
int captureCount = matcher.groupCount();
1546+
int lastClosedCapture = matcher.lastClosedCapture();
1547+
regexState.lastClosedCapture = lastClosedCapture > 0
1548+
&& lastClosedCapture <= captureCount ? matcher.group(lastClosedCapture) : null;
15461549
if (captureCount == 0) {
15471550
regexState.lastCaptureGroups = null;
15481551
return;

src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ public static void initializeGlobals(CompilerOptions compilerOptions) {
7575
String varName = "main::" + Character.toString(c - 'A' + 1);
7676
GlobalVariable.getGlobalVariable(varName);
7777
}
78-
// $^N - last capture group closed (not yet implemented, but must be read-only)
79-
GlobalVariable.globalVariables.put(encodeSpecialVar("N"), new RuntimeScalarReadOnly());
78+
GlobalVariable.globalVariables.put(encodeSpecialVar("N"),
79+
new ScalarSpecialVariable(ScalarSpecialVariable.Id.LAST_CLOSED_PAREN_MATCH));
8080
// $^S - current state of the interpreter (undef=compiling, 0=not in eval, 1=in eval)
8181
GlobalVariable.globalVariables.put("main::" + Character.toString('S' - 'A' + 1),
8282
new ScalarSpecialVariable(ScalarSpecialVariable.Id.EVAL_STATE));

src/main/java/org/perlonjava/runtime/runtimetypes/RegexState.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public class RegexState implements DynamicState {
2222
private final boolean lastMatchUsedPFlag;
2323
private final boolean lastMatchUsedBackslashK;
2424
private final String[] lastCaptureGroups;
25+
private final String lastClosedCapture;
2526
private final Map<String, List<String>> lastNamedCaptureGroups;
2627
private final boolean lastMatchWasByteString;
2728
private final boolean lastMatchResultsTainted;
@@ -44,6 +45,7 @@ public RegexState() {
4445
lastMatchUsedPFlag = state.lastMatchUsedPFlag;
4546
lastMatchUsedBackslashK = state.lastMatchUsedBackslashK;
4647
lastCaptureGroups = state.lastCaptureGroups;
48+
lastClosedCapture = state.lastClosedCapture;
4749
lastNamedCaptureGroups = state.lastNamedCaptureGroups;
4850
lastMatchWasByteString = state.lastMatchWasByteString;
4951
lastMatchResultsTainted = state.lastMatchResultsTainted;
@@ -82,6 +84,7 @@ public void dynamicRestoreState() {
8284
state.lastMatchUsedPFlag = lastMatchUsedPFlag;
8385
state.lastMatchUsedBackslashK = lastMatchUsedBackslashK;
8486
state.lastCaptureGroups = lastCaptureGroups;
87+
state.lastClosedCapture = lastClosedCapture;
8588
state.lastNamedCaptureGroups = lastNamedCaptureGroups;
8689
state.lastMatchWasByteString = lastMatchWasByteString;
8790
state.lastMatchResultsTainted = lastMatchResultsTainted;

src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeRegexState.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public final class RuntimeRegexState {
3232
public boolean lastMatchUsedPFlag;
3333
public boolean lastMatchUsedBackslashK;
3434
public String[] lastCaptureGroups;
35+
public String lastClosedCapture;
3536
public Map<String, List<String>> lastNamedCaptureGroups;
3637
public boolean lastMatchWasByteString;
3738
public boolean lastMatchResultsTainted;
@@ -85,6 +86,7 @@ public void clearMatchState() {
8586
lastMatchUsedPFlag = false;
8687
lastMatchUsedBackslashK = false;
8788
lastCaptureGroups = null;
89+
lastClosedCapture = null;
8890
lastNamedCaptureGroups = null;
8991
lastMatchWasByteString = false;
9092
lastMatchResultsTainted = false;

src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ public RuntimeScalar getValueAsScalar() {
227227
String lastCapture = RuntimeRegex.lastCaptureString();
228228
yield lastCapture != null ? makeRegexResultScalar(lastCapture) : scalarUndef;
229229
}
230+
case LAST_CLOSED_PAREN_MATCH -> {
231+
String lastCapture = PerlRuntime.current().regexState.lastClosedCapture;
232+
yield lastCapture != null ? makeRegexResultScalar(lastCapture) : scalarUndef;
233+
}
230234
case LAST_SUCCESSFUL_PATTERN -> PerlRuntime.current().regexState.lastSuccessfulPattern != null
231235
? new RuntimeScalar(PerlRuntime.current().regexState.lastSuccessfulPattern) : scalarUndef;
232236
case LAST_REGEXP_CODE_RESULT -> {
@@ -527,6 +531,7 @@ public enum Id {
527531
LAST_FH, // Represents the last filehandle used in an input operation.
528532
INPUT_LINE_NUMBER, // Represents the current line number in an input operation.
529533
LAST_PAREN_MATCH, // The highest capture variable ($1, $2, ...) which has a defined value.
534+
LAST_CLOSED_PAREN_MATCH, // $^N - most recently closed capture in the current match.
530535
LAST_SUCCESSFUL_PATTERN, // ${^LAST_SUCCESSFUL_PATTERN}
531536
LAST_REGEXP_CODE_RESULT, // $^R - Result of last (?{...}) code block in regex
532537
HINTS, // $^H - Compile-time hints (strict, etc.)

0 commit comments

Comments
 (0)