diff --git a/dev/design/phase36-regex-parity.md b/dev/design/phase36-regex-parity.md index 0e93bf785..b515113cd 100644 --- a/dev/design/phase36-regex-parity.md +++ b/dev/design/phase36-regex-parity.md @@ -136,6 +136,15 @@ affected corpus before taking another slice. before loose alias normalization, so uncased letters no longer enter that class. The focused system-Perl oracle, four runtime legs, and imported `regexp.t` identity agree. +- Native Joni diagnostics distinguish invalid non-braced `\p`/`\P` followers, + unterminated inline-option and comment groups, incomplete `(?` group effects, + and empty control verbs. The focused system-Perl/direct-Joni/four-leg gates + remove the corresponding `regexp.t` identities with no introductions. +- Runtime `(??{...})` sources execute through native Joni continuations; the + dynamic Java fallback adapter is gone. Callback aggregate mutations unwind + when the complete match fails, remain visible when another alternative + succeeds, and commit when destructive control verbs cut the path, including + across a dynamic continuation. ## Execution Phases @@ -365,7 +374,7 @@ gates may reopen it if a semantic regression appears. - [x] Native ordinary lookbehind and removal of its Java translation - [x] Native branch reset and removal of its capture-map adapter - [x] Native plain `\N` non-newline atom and interval forms -- [ ] Native recursive/runtime `(??{...})` and removal of dynamic adapters +- [x] Native recursive/runtime `(??{...})` and removal of dynamic adapters - [ ] Retire proven-obsolete `dev/import-perl5` regex patches - [ ] Refresh the complete Unicode, `pat.t`, `pat_advanced.t`, `reg_mesg.t`, and 80-file forced-Joni gates on one integrated artifact diff --git a/src/main/java/org/perlonjava/runtime/regex/JoniRegexPattern.java b/src/main/java/org/perlonjava/runtime/regex/JoniRegexPattern.java index 6c27d524d..af6af11f5 100644 --- a/src/main/java/org/perlonjava/runtime/regex/JoniRegexPattern.java +++ b/src/main/java/org/perlonjava/runtime/regex/JoniRegexPattern.java @@ -26,6 +26,7 @@ import static org.joni.constants.SyntaxProperties.OP2_PLUS_POSSESSIVE_INTERVAL; import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; import java.util.Iterator; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -1358,6 +1359,9 @@ static CaptureSnapshot of(MatchView match) { private boolean executedNestedCallbackPattern; private String failedNestedLastClosedCapture; private String failedNestedLastParenMatch; + private final ArrayDeque callbackMutations = + new ArrayDeque<>(); + private boolean preserveCallbackMutations; PerlCalloutHandler(String input, int[] byteToChar, List callbacks, RegexFlags outerFlags, boolean publishesControlVerbState, @@ -1491,6 +1495,9 @@ private Evaluation evaluate(RuntimeRegexCallback callback, MatchView match) { } } CaptureSnapshot priorDynamicView = previousDynamicView; + if (callback.kind == RuntimeRegexCallback.Kind.BLOCK && parent == null) { + callbackMutations.addLast(RegexCallbackMutationSnapshot.capture(callback.code)); + } MatchView provisional = callback.kind == RuntimeRegexCallback.Kind.DYNAMIC ? dynamicCaptureView(match, priorDynamicView) : match; publishProvisional(provisional); @@ -1563,7 +1570,9 @@ public void unwind(Object value) { @Override public void complete(Object value) { - restore((Token) value, true); + Token token = (Token) value; + if (token.block() && parent == null) preserveCallbackMutations = true; + restore(token, true); } @Override @@ -1582,6 +1591,7 @@ public void finish(boolean matched) { GlobalVariable.getGlobalVariable(GlobalContext.encodeSpecialVar("R")) .set(completedResult); } else if (!matched) { + if (!preserveCallbackMutations) restoreCallbackMutations(); initialRegexState.restore(); if (hasFailedNestedCaptureState) { state.lastClosedCapture = failedNestedLastClosedCapture; @@ -1590,6 +1600,7 @@ public void finish(boolean matched) { } } } finally { + callbackMutations.clear(); DynamicVariableManager.popToLocalLevel(initialLocalLevel); } } @@ -1601,6 +1612,8 @@ private void recordFailedNestedCaptureState(String lastClosed, String lastParen) } void abort() { + restoreCallbackMutations(); + callbackMutations.clear(); DynamicVariableManager.popToLocalLevel(initialLocalLevel); } @@ -1619,6 +1632,12 @@ private void restore(Token token, boolean completed) { } } + private void restoreCallbackMutations() { + while (!callbackMutations.isEmpty()) { + callbackMutations.removeLast().restore(); + } + } + private static MatchView dynamicCaptureView( MatchView current, CaptureSnapshot previous) { CaptureSnapshot adjusted = CaptureSnapshot.of(current); diff --git a/src/main/java/org/perlonjava/runtime/regex/RegexMarkers.java b/src/main/java/org/perlonjava/runtime/regex/RegexMarkers.java index 6f61e8793..a279578c4 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RegexMarkers.java +++ b/src/main/java/org/perlonjava/runtime/regex/RegexMarkers.java @@ -4,8 +4,7 @@ * Shared placeholder markers used by the string-interpolation parser to * stand in for regex constructs that PerlOnJava cannot compile literally * (because they require features unsupported by the underlying Java regex - * engine — e.g. arbitrary {@code (?{ CODE })} code blocks and - * {@code (??{ CODE })} recursive/dynamic patterns). + * engine — e.g. arbitrary {@code (?{ CODE })} code blocks). * *

The markers are emitted by {@code StringSegmentParser} when a code * block can't be constant-folded. {@link RegexPreprocessor} detects them @@ -15,12 +14,6 @@ * or a no-op fallback only when {@link #CODE_BLOCK_NOOP_ENV} is set. * Plain {@code JPERL_UNIMPLEMENTED=warn} still reports the unsupported * feature without pretending the callback ran. - *

  • {@link #RECURSIVE_PATTERN} — a hard error under default die mode, - * or a warning under {@code JPERL_UNIMPLEMENTED=warn} followed by - * the soft {@code (?:} fallback so the surrounding pattern still - * compiles (many CPAN modules build dynamic patterns that happen - * to work with the empty-group fallback; under warn mode we want - * tests to continue but the user must see a diagnostic).
  • * * *

    Why these specific spellings? The preprocessor performs some @@ -50,12 +43,5 @@ public final class RegexMarkers { */ public static final String CODE_BLOCK = "(?{UNIMPLEMENTED_CODE_BLOC})"; - /** - * Marker for a {@code (??{ CODE })} recursive/dynamic pattern that - * could not be constant-folded at parse time. Contains no - * fold-affected letters. - */ - public static final String RECURSIVE_PATTERN = "(??{UNIMPLEMENTED_RECURSIVE_PATTERN})"; - private RegexMarkers() {} } diff --git a/src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java b/src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java index 2d7cfd83c..8dabd0f75 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java +++ b/src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java @@ -1041,28 +1041,10 @@ private static int handleParentheses(String s, int offset, int length, StringBui offset = handleCodeBlock(s, offset, length, sb, regexFlags); } } else if (c3 == '?' && c4 == '{') { - // Check if this is the unimplemented marker for (??{...}). - // Under JPERL_UNIMPLEMENTED=warn, warn and fall through to the - // existing non-constant handling (which appends "(?:"); under - // die mode, abort with a clean diagnostic. Either way the user - // sees the issue — silent substitution would be a lie. - if (s.startsWith(RegexMarkers.RECURSIVE_PATTERN, offset)) { - regexUnimplementedSoft(s, offset + 3, - "(??{...}) recursive/dynamic regex patterns not implemented"); - if (isUnimplementedWarnMode()) { - // The marker includes the source construct's closing - // parenthesis. Emit a complete empty-group fallback - // and return its closing position so the dynamic - // construct is not parsed a second time below. - sb.append("(?:)"); - offset += RegexMarkers.RECURSIVE_PATTERN.length() - 1; - return offset; - } - } - // Handle (??{ ... }) recursive/dynamic regex patterns - // These insert a regex pattern at runtime based on code execution - - // Skip the (??{ part to find the code content + // Runtime executable-source compilation replaces this construct + // with a structured DYNAMIC_CALLOUT before matching. The ordinary + // preprocessor sees it only during literal syntax validation, so + // validate its extent and use an inert group for that validation. int codeStart = offset + 4; int codeOffset = findRegexCodeBlockClosingBrace(s, codeStart); if (codeOffset < 0) { @@ -1070,28 +1052,8 @@ private static int handleParentheses(String s, int offset, int length, StringBui "Unmatched '{' in (??{...}) dynamic pattern"); } // codeOffset points at the closing '}' - String codeBlock = s.substring(codeStart, codeOffset).trim(); offset = codeOffset + 1; // Skip past '}' - - // For simple constant expressions, inline the value as a regex pattern. - // (??{1}) means "evaluate 1 and use result as pattern" → matches literal "1" - // (??{"[x]"}) → matches character class [x] - if (isSimpleConstant(codeBlock)) { - String value = evaluateSimpleConstant(codeBlock); - if (value != null) { - // Insert the constant value as a non-capturing group pattern. - // Run through handleRegex to process any regex constructs - // (e.g. (?[...]) from regex_sets transformation). - sb.append("(?:"); - handleRegex(value, 0, sb, regexFlags, false); - } else { - // Fallback: empty non-capturing group - sb.append("(?:"); - } - } else { - // Non-constant: replace with empty non-capturing group - sb.append("(?:"); - } + sb.append("(?:"); // offset now points at ')' closing the (??{...}) construct // Fall through to common ')' handling at end of handleParentheses diff --git a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java index fbeeb788a..38c281d9b 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java +++ b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java @@ -227,9 +227,6 @@ static void updateControlVerbVariables(String mark, String error) { // 0 = off, 1 = debug, 2 = debugcolor. Captured at the regex call site. private int lexicalDebugMode; private boolean lexicalReStrict; - private static final String DYNAMIC_PATTERN_ERROR = - "\u0000(??{...}) recursive regex patterns not implemented (dynamic pattern)"; - public RuntimeRegex() { this.regexFlags = null; } @@ -450,9 +447,6 @@ private void emitWarningsOnUse() { && !activeCodeEnablesRegexp) { return; } - if (warningsOnUse.contains(DYNAMIC_PATTERN_ERROR)) { - throw new PerlJavaUnimplementedException(DYNAMIC_PATTERN_ERROR.substring(1)); - } for (String warning : warningsOnUse) { WarnDie.warnWithCategory(new RuntimeScalar(warning), RuntimeScalarCache.scalarEmptyString, "regexp"); } @@ -597,22 +591,6 @@ private static synchronized RuntimeRegex compileSynchronized( String originalPatternString = patternString; String compilePatternString = patternString; - boolean hasDynamicPattern = compilePatternString != null - && compilePatternString.contains(RegexMarkers.RECURSIVE_PATTERN); - boolean warnOnUnimplemented = "warn".equals( - GlobalVariable.getGlobalHash("main::ENV") - .get("JPERL_UNIMPLEMENTED").toString()); - boolean hasDeferredDynamicPattern = hasDynamicPattern && !warnOnUnimplemented; - boolean hasWarnDynamicFallback = hasDynamicPattern && warnOnUnimplemented; - if (hasDeferredDynamicPattern || hasWarnDynamicFallback) { - // Perl permits qr// construction before the dynamic callback is needed. - // Default mode keeps a never-matching placeholder and reports the hard - // error on use. Warn mode retains the historical compatibility fallback - // that ignores the unsupported dynamic component after warning. - compilePatternString = compilePatternString.replace( - RegexMarkers.RECURSIVE_PATTERN, - hasWarnDynamicFallback ? "(?:)" : "(?!)"); - } List quoteMetaWarningsOnUse = new ArrayList<>(); if (compilePatternString != null && compilePatternString.contains("\\Q")) { // Interpolated-pattern warnings are lexical diagnostics for each @@ -621,9 +599,7 @@ private static synchronized RuntimeRegex compileSynchronized( quoteMetaWarningsOnUse = RegexQuoteMeta.getWarningsOnUse(); } - // Dynamic patterns compile differently in normal and warn modes. Do not - // let a placeholder cached in one mode leak into the other. Lexical - // regex debugging also changes the compiled representation. + // Lexical regex debugging changes the compiled representation. // A lexical charname translator may return a different expansion for // each compilation of the same spelling. Literal syntax validation is // the first leg of one logical compilation: refresh the raw-source @@ -642,8 +618,7 @@ private static synchronized RuntimeRegex compileSynchronized( + "#bytepattern=" + effectivePatternByteBacked + "#strict=" + lexicalReStrict + (namedCharacterTranslator == null ? "" : "#charnames=" - + namedCharacterTranslator.toString()) - + (hasDynamicPattern ? (warnOnUnimplemented ? "\0warn" : "\0defer") : ""); + + namedCharacterTranslator.toString()); // Check if the regex is already cached RuntimeRegex regex = refreshLexicalNamedCharacter @@ -746,12 +721,6 @@ private static synchronized RuntimeRegex compileSynchronized( if (constructionPolicyWarning != null) { regex.inlineModifierWarnings.add(constructionPolicyWarning); } - if (hasDeferredDynamicPattern) { - regex.warningsOnUse.add(DYNAMIC_PATTERN_ERROR); - } else if (hasWarnDynamicFallback) { - regex.warningsOnUse.add( - "(??{...}) recursive/dynamic regex patterns not implemented\n"); - } if (usesRecursiveBackend) { regex.recursivePattern = new JoniRegexPattern(compilePatternString, regex.regexFlags, trustedCalloutCount, diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RegexCallbackMutationSnapshot.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RegexCallbackMutationSnapshot.java new file mode 100644 index 000000000..06dc45b9d --- /dev/null +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RegexCallbackMutationSnapshot.java @@ -0,0 +1,83 @@ +package org.perlonjava.runtime.runtimetypes; + +import java.util.ArrayDeque; +import java.util.IdentityHashMap; +import java.util.Map; + +/** Backtracking savepoint for mutations made by a plain {@code (?{...})} block. */ +public final class RegexCallbackMutationSnapshot { + private final IdentityHashMap arrays = new IdentityHashMap<>(); + private final IdentityHashMap hashes = new IdentityHashMap<>(); + private final IdentityHashMap seen = new IdentityHashMap<>(); + + private RegexCallbackMutationSnapshot(RuntimeCode callback) { + ArrayDeque work = new ArrayDeque<>(); + if (callback.closedOverVariables != null) { + addAll(work, callback.closedOverVariables.values()); + } + addAll(work, callback.capturedScalars); + addAll(work, callback.capturedAggregates); + if (callback.ourVariableRegistry != null) { + for (Map.Entry entry : callback.ourVariableRegistry.entrySet()) { + String name = entry.getKey(); + String packageName = entry.getValue(); + if (name == null || name.length() < 2 || packageName == null) continue; + String fullName = packageName + "::" + name.substring(1); + RuntimeBase cell = switch (name.charAt(0)) { + case '@' -> GlobalVariable.getGlobalArray(fullName); + case '%' -> GlobalVariable.getGlobalHash(fullName); + default -> GlobalVariable.getGlobalVariable(fullName); + }; + work.add(cell); + } + } + capture(work); + } + + public static RegexCallbackMutationSnapshot capture(RuntimeCode callback) { + return new RegexCallbackMutationSnapshot(callback); + } + + private void capture(ArrayDeque work) { + while (!work.isEmpty()) { + RuntimeBase value = work.removeLast(); + if (value == null || seen.put(value, Boolean.TRUE) != null) continue; + if (value instanceof RuntimeScalar scalar) { + if (scalar.value instanceof RuntimeArray array) work.add(array); + else if (scalar.value instanceof RuntimeHash hash) work.add(hash); + else if (scalar.value instanceof RuntimeScalar nested) work.add(nested); + } else if (value instanceof RuntimeArray array) { + Object state = array.snapshotRegexMutationState(); + if (state == null) continue; + arrays.put(array, state); + addAll(work, array.elements); + } else if (value instanceof RuntimeHash hash) { + Object state = hash.snapshotRegexMutationState(); + if (state == null) continue; + hashes.put(hash, state); + addAll(work, hash.elements.values()); + } + } + } + + public void restore() { + for (Map.Entry entry : arrays.entrySet()) { + entry.getKey().restoreRegexMutationState(entry.getValue()); + } + for (Map.Entry entry : hashes.entrySet()) { + entry.getKey().restoreRegexMutationState(entry.getValue()); + } + MortalList.flush(); + } + + private static void addAll(ArrayDeque work, + Iterable values) { + if (values == null) return; + for (RuntimeBase value : values) if (value != null) work.add(value); + } + + private static void addAll(ArrayDeque work, RuntimeBase[] values) { + if (values == null) return; + for (RuntimeBase value : values) if (value != null) work.add(value); + } +} diff --git a/src/test/resources/unit/regex/empty_control_verb_diagnostics.t b/src/test/resources/unit/regex/empty_control_verb_diagnostics.t new file mode 100644 index 000000000..975563409 --- /dev/null +++ b/src/test/resources/unit/regex/empty_control_verb_diagnostics.t @@ -0,0 +1,10 @@ +use strict; +use warnings; +use Test::More tests => 4; + +for my $pattern ('(*)', '(*)b') { + my $ok = eval "qr/$pattern/; 1"; + ok(!$ok, "$pattern is rejected"); + ok(index($@, "Unknown verb pattern ''") >= 0, + "$pattern reports an empty verb pattern"); +} diff --git a/src/test/resources/unit/regex/incomplete_group_effect_diagnostics.t b/src/test/resources/unit/regex/incomplete_group_effect_diagnostics.t new file mode 100644 index 000000000..146ff5848 --- /dev/null +++ b/src/test/resources/unit/regex/incomplete_group_effect_diagnostics.t @@ -0,0 +1,10 @@ +use strict; +use warnings; +use Test::More tests => 4; + +for my $pattern ('(?', 'a(?') { + my $ok = eval "qr/$pattern/; 1"; + ok(!$ok, "$pattern is rejected"); + ok(index($@, 'Sequence (? incomplete') >= 0, + "$pattern reports an incomplete group effect"); +} diff --git a/src/test/resources/unit/regex/native_callback_backtrack_transaction.t b/src/test/resources/unit/regex/native_callback_backtrack_transaction.t new file mode 100644 index 000000000..8de3a52e6 --- /dev/null +++ b/src/test/resources/unit/regex/native_callback_backtrack_transaction.t @@ -0,0 +1,29 @@ +use strict; +use warnings; +use Test::More tests => 10; + +my @lexical; +ok('abc' !~ /^a(?{ push @lexical, 1 })b(?{ push @lexical, 2 })$/, + 'path with two plain callbacks fails'); +is_deeply(\@lexical, [], 'both lexical mutations unwind in reverse order'); + +our @package; +ok('abc' !~ /^a(?{ push @package, 1 })b(?{ push @package, 2 })$/, + 'package mutation path with two callbacks fails'); +is_deeply(\@package, [], 'both package mutations unwind in reverse order'); + +my @dynamic; +ok('abc' !~ /^a(??{ push @dynamic, 1; 'b' })$/, + 'dynamic callback nested program fails'); +is_deeply(\@dynamic, [1], 'dynamic expression mutation survives failure'); + +my @nested; +ok('abc' !~ /^(??{ qr{a(?{ push @nested, 1 })b(?{ push @nested, 2 })} })$/, + 'returned program containing plain callbacks fails'); +is_deeply(\@nested, [1, 2], + 'plain callback mutations inside a dynamic program survive failure'); + +my @condition; +ok('ac' !~ /^a(?(?{ push @condition, 1; 1 })b|c)$/, + 'conditional callback chooses a failing branch'); +is_deeply(\@condition, [1], 'conditional expression mutation survives failure'); diff --git a/src/test/resources/unit/regex/native_callback_destructive_commit.t b/src/test/resources/unit/regex/native_callback_destructive_commit.t new file mode 100644 index 000000000..6cd62160c --- /dev/null +++ b/src/test/resources/unit/regex/native_callback_destructive_commit.t @@ -0,0 +1,17 @@ +use strict; +use warnings; +use Test::More; + +for my $verb (qw(FAIL PRUNE SKIP THEN COMMIT)) { + my @events; + my $pattern = qr/\A(?{ push @events, $verb })(*$verb)(*FAIL)\z/; + ok('x' !~ $pattern, "$verb path fails"); + is_deeply(\@events, [$verb], "$verb commits callback mutation"); +} + +my @marked; +ok('x' !~ /\A(?{ push @marked, 'MARK' })(*MARK:plain)z\z/, + 'MARK path fails normally'); +is_deeply(\@marked, [], 'MARK does not commit callback mutation'); + +done_testing; diff --git a/src/test/resources/unit/regex/native_dynamic_pattern_contract.t b/src/test/resources/unit/regex/native_dynamic_pattern_contract.t new file mode 100644 index 000000000..9e19421fa --- /dev/null +++ b/src/test/resources/unit/regex/native_dynamic_pattern_contract.t @@ -0,0 +1,72 @@ +use strict; +use warnings; +use Test::More; + +my $evaluations = 0; +ok('ab' =~ /^(??{ ++$evaluations; 'ab|a' })b$/, + 'nested alternatives backtrack into the outer suffix'); +is($evaluations, 1, 'one dynamic entry retains its nested alternatives'); + +my $repeat_count = 0; +ok('aa' =~ /^(?:(??{ ++$repeat_count; 'a' })){2}$/, + 'a quantified dynamic program executes at each entry'); +is($repeat_count, 2, 'quantified dynamic expression is reevaluated'); + +my ($seen_capture, $entry_pos); +ok('abc' =~ /^(a)(??{ $seen_capture = $1; $entry_pos = pos; '(b)' })(c)$/, + 'dynamic program sees provisional state and may contain captures'); +is($seen_capture, 'a', 'dynamic expression sees the preceding outer capture'); +is($entry_pos, 1, 'pos reports the dynamic entry offset'); +is($1, 'a', 'outer capture before the dynamic program is preserved'); +is($2, 'c', 'nested captures do not consume outer capture numbers'); + +my $returned_qr = qr/b/i; +ok('aB' =~ /^a(??{ $returned_qr })$/, + 'dynamic expression accepts a compiled qr value'); +ok(!('aB' =~ /^a(??{ qr{b} })$/i), + 'returned qr retains its own modifiers'); +ok('aB' =~ /^a(??{ 'b' })$/i, + 'returned string inherits outer modifiers'); + +my $seed; +ok('seed' =~ /^(?{ $seed = 'seed' })(??{ $^R })$/, + 'dynamic expression sees the prior callback result'); +is($^R, 'seed', 'successful dynamic execution preserves callback result'); + +my $failed_effects = 0; +ok(!('x' =~ /^(??{ ++$failed_effects; 'y' })$/), + 'a returned program may fail normally'); +is($failed_effects, 1, 'ordinary dynamic side effects survive failure'); + +my $unreached = 0; +ok('x' =~ /^x|y(??{ ++$unreached; 'z' })$/, + 'an earlier branch can bypass a dynamic program'); +is($unreached, 0, 'an unreached dynamic expression is not executed'); + +my $exception_ok = eval { 'x' =~ /^(??{ die "dynamic boom\n" })$/; 1 }; +ok(!$exception_ok, 'dynamic exception aborts matching'); +is($@, "dynamic boom\n", 'dynamic exception propagates unchanged'); + +{ + no warnings 'uninitialized'; + ok('a' =~ /^a(??{ undef })$/, 'undef dynamic result is an empty pattern'); +} +ok('a' =~ /^a(??{ '' })$/, 'empty dynamic result is an empty pattern'); + +my $wide = "\x{100}"; +ok($wide =~ /^(??{ $wide })$/u, 'Unicode dynamic source preserves its scalar'); +{ + use bytes; + my $octet = "\xE9"; + ok($octet =~ /^(??{ $octet })$/, + 'byte-mode dynamic source preserves its octet provenance'); +} + +my $recursive; +$recursive = qr{ \( (?: [^()]+ | (??{ $recursive }) )* \) }x; +ok('(a(b)c)' =~ /^$recursive$/, + 'self-referential dynamic qr matches nested input'); +ok(!('(a(b)' =~ /^$recursive$/), + 'self-referential dynamic qr still rejects unbalanced input'); + +done_testing; diff --git a/src/test/resources/unit/regex/unterminated_comment_group_diagnostics.t b/src/test/resources/unit/regex/unterminated_comment_group_diagnostics.t new file mode 100644 index 000000000..b0b365d6a --- /dev/null +++ b/src/test/resources/unit/regex/unterminated_comment_group_diagnostics.t @@ -0,0 +1,10 @@ +use strict; +use warnings; +use Test::More tests => 6; + +for my $pattern ('x(?#', 'x(?#:', '(?#abc') { + my $ok = eval "qr/$pattern/; 1"; + ok(!$ok, "$pattern is rejected"); + ok(index($@, 'Sequence (?#... not terminated') >= 0, + "$pattern reports an unterminated comment sequence"); +} diff --git a/src/test/resources/unit/regex/unterminated_option_group_diagnostics.t b/src/test/resources/unit/regex/unterminated_option_group_diagnostics.t new file mode 100644 index 000000000..72ea7917b --- /dev/null +++ b/src/test/resources/unit/regex/unterminated_option_group_diagnostics.t @@ -0,0 +1,10 @@ +use strict; +use warnings; +use Test::More tests => 6; + +for my $pattern ('(?i', '(?a-x', '(?-') { + my $ok = eval "qr/$pattern/; 1"; + ok(!$ok, "$pattern is rejected"); + ok(index($@, 'Sequence (?... not terminated') >= 0, + "$pattern reports an unterminated option sequence"); +} diff --git a/third_party/joni/src/org/joni/ByteCodeMachine.java b/third_party/joni/src/org/joni/ByteCodeMachine.java index 8219c88f5..257901d3e 100644 --- a/third_party/joni/src/org/joni/ByteCodeMachine.java +++ b/third_party/joni/src/org/joni/ByteCodeMachine.java @@ -60,6 +60,8 @@ class ByteCodeMachine extends StackMachine implements MatchView { private int pkeep; private int currentRegexOptions; private int pendingControlAction; + private boolean preserveCalloutMutations; + private boolean exportedDestructiveControl; private final int[]code; // byte code private int ip; // instruction pointer @@ -222,6 +224,8 @@ protected final int matchAt(int _range, int _sstart, int _sprev, boolean interru ip = 0; currentRegexOptions = regex.options; controlMark = null; + preserveCalloutMutations = false; + exportedDestructiveControl = false; if (Config.DEBUG_MATCH) debugMatchBegin(); stackInit(); @@ -2996,6 +3000,7 @@ private boolean opAccept() { private void opControlFail() { controlVerbEncountered = true; + markDestructiveControl(); String name = controlVerbName(code[ip++]); // An unnamed FAIL terminates the current path without replacing a // more specific PRUNE/SKIP/THEN/COMMIT error already encountered on @@ -3008,6 +3013,7 @@ private void opControlFail() { private void opPrune() { controlVerbEncountered = true; + markDestructiveControl(); String name = controlVerbName(code[ip++]); controlError = name == null ? "1" : name; cutAlternatives(false); @@ -3016,6 +3022,7 @@ private void opPrune() { private void opSkip() { controlVerbEncountered = true; + markDestructiveControl(); String name = controlVerbName(code[ip++]); controlError = name == null ? "1" : name; if (name != null) { @@ -3033,6 +3040,7 @@ private void opSkip() { private void opThen() { controlVerbEncountered = true; + markDestructiveControl(); String name = controlVerbName(code[ip++]); controlError = name == null ? "1" : name; cutAlternatives(true, s, sprev, pkeep); @@ -3041,6 +3049,7 @@ private void opThen() { private void opCommit() { controlVerbEncountered = true; + markDestructiveControl(); String name = controlVerbName(code[ip++]); controlError = name == null ? "1" : name; cutAlternatives(false); @@ -3055,6 +3064,16 @@ private void opMark() { controlMark = next; } + private void markDestructiveControl() { + preserveCalloutMutations = true; + exportedDestructiveControl = true; + } + + @Override + protected boolean completeCalloutsOnUnwind() { + return preserveCalloutMutations; + } + @Override protected void restoreControlMark(String name) { if (controlMark != null) controlError = controlMark; @@ -3406,6 +3425,10 @@ void propagateControlTo(ByteCodeMachine outer) { int action = machine.pendingControlAction; machine.pendingControlAction = CONTROL_NONE; if (machine.controlVerbEncountered) outer.controlVerbEncountered = true; + if (machine.exportedDestructiveControl) { + outer.markDestructiveControl(); + machine.exportedDestructiveControl = false; + } if (machine.controlError != null) outer.controlError = machine.controlError; switch (action) { case CONTROL_PRUNE: diff --git a/third_party/joni/src/org/joni/Lexer.java b/third_party/joni/src/org/joni/Lexer.java index 033a38a62..434470c3d 100644 --- a/third_party/joni/src/org/joni/Lexer.java +++ b/third_party/joni/src/org/joni/Lexer.java @@ -1928,7 +1928,11 @@ protected final void fetchToken() { if (peekIs('#')) { fetch(); while (true) { - if (!left()) newSyntaxException(END_PATTERN_IN_GROUP); + if (!left()) { + newSyntaxException(syntax.op2OptionPerl() + ? PERL_COMMENT_GROUP_NOT_TERMINATED + : END_PATTERN_IN_GROUP); + } fetch(); if (c == syntax.metaCharTable.esc) { if (left()) fetch(); diff --git a/third_party/joni/src/org/joni/Parser.java b/third_party/joni/src/org/joni/Parser.java index 0ee1339eb..5bc165739 100644 --- a/third_party/joni/src/org/joni/Parser.java +++ b/third_party/joni/src/org/joni/Parser.java @@ -738,7 +738,11 @@ private Node parseEnclose(TokenType term) { if (peekIs('?') && syntax.op2QMarkGroupEffect()) { inc(); - if (!left()) newSyntaxException(END_PATTERN_IN_GROUP); + if (!left()) { + newSyntaxException(syntax.op2OptionPerl() + ? PERL_GROUP_EFFECT_INCOMPLETE + : END_PATTERN_IN_GROUP); + } boolean listCapture = false; @@ -1145,7 +1149,11 @@ && left() && enc.isDigit(peek())) { returnCode = 0; return node; } - if (!left()) newSyntaxException(END_PATTERN_IN_GROUP); + if (!left()) { + newSyntaxException(syntax.op2OptionPerl() + ? PERL_OPTION_GROUP_NOT_TERMINATED + : END_PATTERN_IN_GROUP); + } fetch(); } // while @@ -1238,7 +1246,8 @@ private Node parseControlVerb() { verb = "MARK"; } else { String construct = controlConstructName(); - if (!construct.isEmpty() && Character.isUpperCase(construct.codePointAt(0))) { + if (construct.isEmpty() + || Character.isUpperCase(construct.codePointAt(0))) { newValueException(PERL_UNKNOWN_VERB_PATTERN, construct); } newValueException(PERL_UNKNOWN_CONTROL_CONSTRUCT, construct); diff --git a/third_party/joni/src/org/joni/StackMachine.java b/third_party/joni/src/org/joni/StackMachine.java index a5711be34..c91cbaf71 100644 --- a/third_party/joni/src/org/joni/StackMachine.java +++ b/third_party/joni/src/org/joni/StackMachine.java @@ -578,7 +578,14 @@ private void completeCallout(StackEntry entry) { private void unwindCallout(StackEntry entry) { Object token = entry.takeCalloutToken(); - if (token != null) getCalloutHandler().unwind(token); + if (token == null) return; + if (completeCalloutsOnUnwind()) getCalloutHandler().complete(token); + else getCalloutHandler().unwind(token); + } + + /** Whether the current failure path commits callback side effects. */ + protected boolean completeCalloutsOnUnwind() { + return false; } private void completeDynamic(StackEntry entry) { diff --git a/third_party/joni/src/org/joni/exception/ErrorMessages.java b/third_party/joni/src/org/joni/exception/ErrorMessages.java index a9833c1f9..6a0cfab3a 100644 --- a/third_party/joni/src/org/joni/exception/ErrorMessages.java +++ b/third_party/joni/src/org/joni/exception/ErrorMessages.java @@ -55,6 +55,9 @@ public interface ErrorMessages extends org.jcodings.exception.ErrorMessages { String UNMATCHED_CLOSE_PARENTHESIS = "unmatched close parenthesis"; String END_PATTERN_WITH_UNMATCHED_PARENTHESIS = "end pattern with unmatched parenthesis"; String END_PATTERN_IN_GROUP = "end pattern in group"; + String PERL_OPTION_GROUP_NOT_TERMINATED = "Sequence (?... not terminated"; + String PERL_COMMENT_GROUP_NOT_TERMINATED = "Sequence (?#... not terminated"; + String PERL_GROUP_EFFECT_INCOMPLETE = "Sequence (? incomplete"; String UNDEFINED_GROUP_OPTION = "undefined group option"; String INVALID_POSIX_BRACKET_TYPE = "invalid POSIX bracket type"; String INVALID_LOOK_BEHIND_PATTERN = "invalid pattern in look-behind"; diff --git a/third_party/joni/test/org/joni/test/TestPerlCommentGroupDiagnostics.java b/third_party/joni/test/org/joni/test/TestPerlCommentGroupDiagnostics.java new file mode 100644 index 000000000..62c2dc317 --- /dev/null +++ b/third_party/joni/test/org/joni/test/TestPerlCommentGroupDiagnostics.java @@ -0,0 +1,53 @@ +/* + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.joni.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.nio.charset.StandardCharsets; + +import org.jcodings.specific.UTF8Encoding; +import org.joni.Option; +import org.joni.Regex; +import org.joni.Syntax; +import org.joni.WarnCallback; +import org.joni.exception.SyntaxException; +import org.junit.Test; + +public class TestPerlCommentGroupDiagnostics { + private static void assertUnterminated(String pattern) { + byte[] bytes = pattern.getBytes(StandardCharsets.UTF_8); + try { + new Regex(bytes, 0, bytes.length, Option.NONE, UTF8Encoding.INSTANCE, + Syntax.PerlNG, WarnCallback.NONE); + fail("expected unterminated Perl comment group for " + pattern); + } catch (SyntaxException error) { + assertEquals("Sequence (?#... not terminated", error.getMessage()); + } + } + + @Test + public void reportsUnterminatedCommentGroups() { + assertUnterminated("x(?#"); + assertUnterminated("x(?#:"); + assertUnterminated("(?#abc"); + } +} diff --git a/third_party/joni/test/org/joni/test/TestPerlDynamicExecution.java b/third_party/joni/test/org/joni/test/TestPerlDynamicExecution.java new file mode 100644 index 000000000..75c3d1c30 --- /dev/null +++ b/third_party/joni/test/org/joni/test/TestPerlDynamicExecution.java @@ -0,0 +1,156 @@ +/* + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.joni.test; + +import static org.junit.Assert.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.jcodings.specific.ASCIIEncoding; +import org.joni.CalloutHandler; +import org.joni.CalloutResult; +import org.joni.DynamicPatternResult; +import org.joni.MatchView; +import org.joni.Matcher; +import org.joni.Option; +import org.joni.Regex; +import org.joni.Syntax; +import org.junit.Test; + +public class TestPerlDynamicExecution { + private static Regex regex(String pattern) { + byte[] bytes = pattern.getBytes(StandardCharsets.US_ASCII); + return new Regex(bytes, 0, bytes.length, Option.NONE, + ASCIIEncoding.INSTANCE, Syntax.RUBY); + } + + @Test + public void nestedAlternativesResolveTokensOnlyWhenTheOuterPathCommits() { + List events = new ArrayList<>(); + Regex outer = regex("\\A(?{=DYNAMIC:1})b\\z"); + Regex nested = regex("ab(?{=CALL:2})|a(?{=CALL:3})"); + CalloutHandler nestedHandler = new CalloutHandler() { + @Override + public CalloutResult execute(int id, MatchView match) { + events.add("nested-execute:" + id); + return CalloutResult.continueWith("nested-" + id); + } + + @Override + public void unwind(Object token) { + events.add("nested-unwind:" + token); + } + + @Override + public void complete(Object token) { + events.add("nested-complete:" + token); + } + + @Override + public void finish(boolean matched) { + events.add("nested-finish:" + matched); + } + }; + CalloutHandler outerHandler = new CalloutHandler() { + @Override + public CalloutResult execute(int id, MatchView match) { + throw new AssertionError("plain outer callout not expected"); + } + + @Override + public DynamicPatternResult executeDynamic(int id, MatchView match) { + events.add("dynamic:" + id); + return new DynamicPatternResult(nested, nestedHandler, "outer-dynamic"); + } + + @Override + public void unwind(Object token) { + events.add("outer-unwind:" + token); + } + + @Override + public void complete(Object token) { + events.add("outer-complete:" + token); + } + + }; + + byte[] input = "ab".getBytes(StandardCharsets.US_ASCII); + Matcher matcher = outer.matcher(input); + matcher.setCalloutHandler(outerHandler); + + assertEquals(0, matcher.search(0, input.length, Option.NONE)); + assertEquals(Arrays.asList( + "dynamic:1", + "nested-execute:2", + "nested-unwind:nested-2", + "nested-execute:3", + "nested-complete:nested-3", + "nested-finish:true", + "outer-complete:outer-dynamic"), events); + } + + @Test + public void destructiveControlsCommitCalloutsButMarkDoesNot() { + String[] committing = {"FAIL", "PRUNE)(*FAIL", "SKIP)(*FAIL", + "THEN)(*FAIL", "COMMIT)(*FAIL"}; + for (String control : committing) { + List events = new ArrayList<>(); + Regex pattern = regex("a(?{=CALL:1})(*" + control + ")"); + assertEquals(-1, search(pattern, "a", recordingHandler(events))); + assertEquals(control, Arrays.asList("execute:1", "complete:1"), events); + } + + List markEvents = new ArrayList<>(); + Regex mark = regex("a(?{=CALL:1})(*MARK:seen)b"); + assertEquals(-1, search(mark, "a", recordingHandler(markEvents))); + assertEquals(Arrays.asList("execute:1", "unwind:1"), markEvents); + } + + private static int search(Regex regex, String input, CalloutHandler handler) { + byte[] bytes = input.getBytes(StandardCharsets.US_ASCII); + Matcher matcher = regex.matcher(bytes); + matcher.setCalloutHandler(handler); + return matcher.search(0, bytes.length, Option.NONE); + } + + private static CalloutHandler recordingHandler(List events) { + return new CalloutHandler() { + @Override + public CalloutResult execute(int id, MatchView match) { + events.add("execute:" + id); + return CalloutResult.continueWith(id); + } + + @Override + public void unwind(Object token) { + events.add("unwind:" + token); + } + + @Override + public void complete(Object token) { + events.add("complete:" + token); + } + }; + } +} diff --git a/third_party/joni/test/org/joni/test/TestPerlEmptyVerbDiagnostics.java b/third_party/joni/test/org/joni/test/TestPerlEmptyVerbDiagnostics.java new file mode 100644 index 000000000..f213d0a1b --- /dev/null +++ b/third_party/joni/test/org/joni/test/TestPerlEmptyVerbDiagnostics.java @@ -0,0 +1,52 @@ +/* + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.joni.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.nio.charset.StandardCharsets; + +import org.jcodings.specific.UTF8Encoding; +import org.joni.Option; +import org.joni.Regex; +import org.joni.Syntax; +import org.joni.WarnCallback; +import org.joni.exception.ValueException; +import org.junit.Test; + +public class TestPerlEmptyVerbDiagnostics { + private static void assertEmptyVerb(String pattern) { + byte[] bytes = pattern.getBytes(StandardCharsets.UTF_8); + try { + new Regex(bytes, 0, bytes.length, Option.NONE, UTF8Encoding.INSTANCE, + Syntax.PerlNG, WarnCallback.NONE); + fail("expected empty Perl verb failure for " + pattern); + } catch (ValueException error) { + assertEquals("Unknown verb pattern ''", error.getMessage()); + } + } + + @Test + public void reportsEmptyVerbPatterns() { + assertEmptyVerb("(*)"); + assertEmptyVerb("(*)b"); + } +} diff --git a/third_party/joni/test/org/joni/test/TestPerlGroupEffectDiagnostics.java b/third_party/joni/test/org/joni/test/TestPerlGroupEffectDiagnostics.java new file mode 100644 index 000000000..bcd680a30 --- /dev/null +++ b/third_party/joni/test/org/joni/test/TestPerlGroupEffectDiagnostics.java @@ -0,0 +1,52 @@ +/* + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.joni.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.nio.charset.StandardCharsets; + +import org.jcodings.specific.UTF8Encoding; +import org.joni.Option; +import org.joni.Regex; +import org.joni.Syntax; +import org.joni.WarnCallback; +import org.joni.exception.SyntaxException; +import org.junit.Test; + +public class TestPerlGroupEffectDiagnostics { + private static void assertIncomplete(String pattern) { + byte[] bytes = pattern.getBytes(StandardCharsets.UTF_8); + try { + new Regex(bytes, 0, bytes.length, Option.NONE, UTF8Encoding.INSTANCE, + Syntax.PerlNG, WarnCallback.NONE); + fail("expected incomplete Perl group effect for " + pattern); + } catch (SyntaxException error) { + assertEquals("Sequence (? incomplete", error.getMessage()); + } + } + + @Test + public void reportsIncompleteGroupEffects() { + assertIncomplete("(?"); + assertIncomplete("a(?"); + } +} diff --git a/third_party/joni/test/org/joni/test/TestPerlOptionGroupDiagnostics.java b/third_party/joni/test/org/joni/test/TestPerlOptionGroupDiagnostics.java new file mode 100644 index 000000000..1c9093cdb --- /dev/null +++ b/third_party/joni/test/org/joni/test/TestPerlOptionGroupDiagnostics.java @@ -0,0 +1,53 @@ +/* + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.joni.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.nio.charset.StandardCharsets; + +import org.jcodings.specific.UTF8Encoding; +import org.joni.Option; +import org.joni.Regex; +import org.joni.Syntax; +import org.joni.WarnCallback; +import org.joni.exception.SyntaxException; +import org.junit.Test; + +public class TestPerlOptionGroupDiagnostics { + private static void assertUnterminated(String pattern) { + byte[] bytes = pattern.getBytes(StandardCharsets.UTF_8); + try { + new Regex(bytes, 0, bytes.length, Option.NONE, UTF8Encoding.INSTANCE, + Syntax.PerlNG, WarnCallback.NONE); + fail("expected unterminated Perl option group for " + pattern); + } catch (SyntaxException error) { + assertEquals("Sequence (?... not terminated", error.getMessage()); + } + } + + @Test + public void reportsUnterminatedOptionGroups() { + assertUnterminated("(?i"); + assertUnterminated("(?a-x"); + assertUnterminated("(?-"); + } +}