Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ my @copy = @{$z}; # ERROR
- ✅ **Preprocessor**: `\Q`, `\L`, `\U`, `\l`, `\u`, `\E` are preprocessed in regex.
- ✅ **Overloading**: `qr` overloading is implemented. See also [overload pragma](#pragmas).
- ✅ **Python-style named groups**: `(?P<name>...)` and `(?P=name)` are parsed natively by Joni with Perl capture numbering, duplicate-name behavior, and malformed/unknown-name diagnostics.
- ✅ **Alpha assertion aliases**: `(*pla:...)`, `(*plb:...)`, `(*nla:...)`, `(*nlb:...)`, and `(*atomic:...)` are parsed natively by Joni with Perl nesting, capture numbering, backtracking, and malformed-form diagnostics.
- ✅ **Alpha assertion aliases**: `(*pla:...)`, `(*plb:...)`, `(*nla:...)`, `(*nlb:...)`, `(*atomic:...)`, and the corresponding long spellings are parsed natively by Joni with Perl nesting, capture numbering, backtracking, assertion-condition predicates, and malformed-form diagnostics.
- 🟡 **Underscored numeric regex escapes**: Joni natively parses Perl spellings such as `\x{0_0_4_1}` and `\o{0_0_1_0_1}` through U+10FFFF, including literal/class forms, bare high-octal UTF-8 code points, truncation behavior, and structural diagnostics. The frontend normalization remains for forced-Java compatibility; exact `use re 'strict'` diagnostics and Perl code points above U+10FFFF through signed IV max remain source-policy/representation debt.

- ✅ **Dynamically-scoped regex variables**: Provisional captures, `$^R`, `$^N`, match positions, and callback locals follow matcher paths and unwind on backtracking.
Expand Down
33 changes: 29 additions & 4 deletions src/main/java/org/perlonjava/runtime/regex/JoniRegexPattern.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.joni.Syntax;

import static org.joni.constants.SyntaxProperties.ALLOW_MULTIPLEX_DEFINITION_NAME_CALL;
import static org.joni.constants.SyntaxProperties.OP2_ESC_H_HORIZONTAL_WHITESPACE;
import static org.joni.constants.SyntaxProperties.OP2_OPTION_PERL;
import static org.joni.constants.SyntaxProperties.OP2_OPTION_RUBY;
import static org.joni.constants.SyntaxProperties.OP2_PLUS_POSSESSIVE_INTERVAL;
Expand Down Expand Up @@ -45,7 +46,8 @@ final class JoniRegexPattern {
// by callouts and control verbs while changing only that default policy.
private static final Syntax PERLONJAVA_SYNTAX = new Syntax(
"PERLONJAVA", Syntax.RUBY.op,
(Syntax.RUBY.op2 & ~OP2_OPTION_RUBY) | OP2_OPTION_PERL | OP2_PLUS_POSSESSIVE_INTERVAL,
(Syntax.RUBY.op2 & ~OP2_OPTION_RUBY) | OP2_OPTION_PERL
| OP2_PLUS_POSSESSIVE_INTERVAL | OP2_ESC_H_HORIZONTAL_WHITESPACE,
Syntax.RUBY.op3,
Syntax.RUBY.behavior | ALLOW_MULTIPLEX_DEFINITION_NAME_CALL,
Syntax.RUBY.options & ~(Option.ASCII_RANGE
Expand Down Expand Up @@ -348,6 +350,7 @@ static boolean requiresJoniBackend(String pattern, RegexFlags flags) {
pattern, flags != null && flags.isExtended());
return syntaxFeatures.keepPresent()
|| syntaxFeatures.conditionalPresent()
|| syntaxFeatures.alphaAssertionPresent()
|| pattern.contains("(?{=CALL:")
|| pattern.contains("(?{=DYNAMIC:")
|| pattern.contains("(*ACCEPT)")
Expand All @@ -362,7 +365,8 @@ static boolean requiresJoniBackend(String pattern, RegexFlags flags) {

private record PerlSyntaxFeatures(boolean keepPresent,
boolean keepInLookaround,
boolean conditionalPresent) {}
boolean conditionalPresent,
boolean alphaAssertionPresent) {}

private static PerlSyntaxFeatures analyzePerlSyntax(String pattern, boolean extended) {
boolean quoted = false;
Expand All @@ -373,6 +377,7 @@ private static PerlSyntaxFeatures analyzePerlSyntax(String pattern, boolean exte
java.util.ArrayDeque<Boolean> groups = new java.util.ArrayDeque<>();
boolean keepPresent = false;
boolean conditionalPresent = false;
boolean alphaAssertionPresent = false;

for (int i = 0; i < pattern.length(); i++) {
char ch = pattern.charAt(i);
Expand Down Expand Up @@ -436,7 +441,8 @@ private static PerlSyntaxFeatures analyzePerlSyntax(String pattern, boolean exte
} else if (escaped == 'K') {
keepPresent = true;
if (lookaroundDepth > 0) {
return new PerlSyntaxFeatures(true, true, conditionalPresent);
return new PerlSyntaxFeatures(true, true, conditionalPresent,
alphaAssertionPresent);
}
}
continue;
Expand All @@ -449,6 +455,24 @@ private static PerlSyntaxFeatures analyzePerlSyntax(String pattern, boolean exte
continue;
}
if (pattern.startsWith("(?(", i)) conditionalPresent = true;
if (pattern.startsWith("(*", i)) {
int nameEnd = i + 2;
while (nameEnd < pattern.length()) {
char nameChar = pattern.charAt(nameEnd);
if (!Character.isLetter(nameChar) && nameChar != '_') break;
nameEnd++;
}
String name = pattern.substring(i + 2, nameEnd);
alphaAssertionPresent |= name.equals("pla")
|| name.equals("positive_lookahead")
|| name.equals("plb")
|| name.equals("positive_lookbehind")
|| name.equals("nla")
|| name.equals("negative_lookahead")
|| name.equals("nlb")
|| name.equals("negative_lookbehind")
|| name.equals("atomic");
}
boolean lookaround = pattern.startsWith("(?=", i)
|| pattern.startsWith("(?!", i)
|| pattern.startsWith("(?<=", i)
Expand All @@ -459,7 +483,8 @@ private static PerlSyntaxFeatures analyzePerlSyntax(String pattern, boolean exte
if (groups.pop()) lookaroundDepth--;
}
}
return new PerlSyntaxFeatures(keepPresent, false, conditionalPresent);
return new PerlSyntaxFeatures(keepPresent, false, conditionalPresent,
alphaAssertionPresent);
}

private static boolean hasControlVerbState(String pattern) {
Expand Down
52 changes: 18 additions & 34 deletions src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -991,8 +991,8 @@ private static int handleParentheses(String s, int offset, int length, StringBui

// Check for (*...) verb patterns FIRST, before checking (?
if (c2 == '*') {
// (*...) control verbs like (*ACCEPT), (*FAIL), (*COMMIT), etc.
// Also handles alpha assertion aliases: (*pla:...), (*plb:...), etc.
// Java-backend compatibility for (*...) control verbs such as
// (*FAIL). Alpha assertions are routed to Joni before preprocessing.

// Find the verb name (up to ':' or ')')
int verbNameEnd = offset + 2;
Expand All @@ -1012,42 +1012,26 @@ private static int handleParentheses(String s, int offset, int length, StringBui
return verbNameEnd;
}

// Check for alpha assertion aliases (Perl 5.28+)
String replacement = switch (verbName) {
case "pla", "positive_lookahead" -> "(?=";
case "plb", "positive_lookbehind" -> "(?<=";
case "nla", "negative_lookahead" -> "(?!";
case "nlb", "negative_lookbehind" -> "(?<!";
case "atomic" -> "(?>";
default -> null;
};

if (replacement != null && verbNameEnd < length && s.codePointAt(verbNameEnd) == ':') {
// Alpha assertion with content: (*pla:...) -> (?=...)
sb.append(replacement);
offset = handleRegex(s, verbNameEnd + 1, sb, regexFlags, true);
// Fall through to common ')' handling at end of handleParentheses
} else {
// Find the end of the verb for error reporting
int verbEnd = offset + 2;
while (verbEnd < length && s.codePointAt(verbEnd) != ')') {
verbEnd++;
}
if (verbEnd < length) {
verbEnd++; // Include the closing paren
}
// Find the end of the verb for error reporting
int verbEnd = offset + 2;
while (verbEnd < length && s.codePointAt(verbEnd) != ')') {
verbEnd++;
}
if (verbEnd < length) {
verbEnd++; // Include the closing paren
}

// Extract the verb name for error reporting
String verb = s.substring(offset, Math.min(verbEnd, length));
// Extract the verb name for error reporting
String verb = s.substring(offset, Math.min(verbEnd, length));

// Replace with empty non-capturing group as placeholder
sb.append("(?:)");
// Replace with empty non-capturing group as placeholder
sb.append("(?:)");

// Throw error that can be caught by JPERL_UNIMPLEMENTED=warn
regexUnimplemented(s, offset + 2, "Regex control verb " + verb + " not implemented");
// Throw error that can be caught by JPERL_UNIMPLEMENTED=warn
regexUnimplemented(s, offset + 2,
"Regex control verb " + verb + " not implemented");

return verbEnd; // Skip past the entire verb construct
}
return verbEnd; // Skip past the entire verb construct
} else if (c2 == '?') {
if (offset + 2 >= length) {
// Marker should be after the ?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.perlonjava.runtime.regex;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

@Tag("unit")
class NativeAlphaAssertionRoutingTest {
@Test
void routesShortAndLongAlphaAssertionsToJoni() {
assertTrue(JoniRegexPattern.requiresJoniBackend("a(*pla:b)b"));
assertTrue(JoniRegexPattern.requiresJoniBackend("a(*positive_lookahead:b)b"));
assertTrue(JoniRegexPattern.requiresJoniBackend("a(*plb:a)b"));
assertTrue(JoniRegexPattern.requiresJoniBackend("a(*positive_lookbehind:a)b"));
assertTrue(JoniRegexPattern.requiresJoniBackend("a(*nla:c)b"));
assertTrue(JoniRegexPattern.requiresJoniBackend("a(*negative_lookahead:c)b"));
assertTrue(JoniRegexPattern.requiresJoniBackend("a(*nlb:c)b"));
assertTrue(JoniRegexPattern.requiresJoniBackend("a(*negative_lookbehind:c)b"));
assertTrue(JoniRegexPattern.requiresJoniBackend("(*atomic:a|ab)c"));
assertTrue(JoniRegexPattern.requiresJoniBackend("(*pla)"));
assertTrue(JoniRegexPattern.requiresJoniBackend("(*positive_lookahead"));
}

@Test
void ignoresAlphaAssertionLookalikes() {
assertFalse(JoniRegexPattern.requiresJoniBackend("\\(\\*pla:a\\)"));
assertFalse(JoniRegexPattern.requiresJoniBackend("[(?*pla:)]"));
assertFalse(JoniRegexPattern.requiresJoniBackend("\\Q(*pla:a)\\E"));
assertFalse(JoniRegexPattern.requiresJoniBackend("(?# (*pla:a))ordinary"));
assertFalse(JoniRegexPattern.requiresJoniBackend("(*planet:a)"));
}
}
39 changes: 39 additions & 0 deletions src/test/resources/unit/regex/alpha_assertion_native_routing.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use strict;
use warnings;
use Test::More;

ok('ab' =~ /a(*pla:b)b/, 'short positive lookahead alias');
ok('ab' =~ /a(*positive_lookahead:b)b/, 'long positive lookahead alias');
ok('ab' =~ /a(*plb:a)b/, 'short positive lookbehind alias');
ok('ab' =~ /a(*positive_lookbehind:a)b/, 'long positive lookbehind alias');
ok('ab' =~ /a(*nla:c)b/, 'short negative lookahead alias');
ok('ab' =~ /a(*negative_lookahead:c)b/, 'long negative lookahead alias');
ok('ab' =~ /a(*nlb:c)b/, 'short negative lookbehind alias');
ok('ab' =~ /a(*negative_lookbehind:c)b/, 'long negative lookbehind alias');

ok('abc' !~ /(*atomic:a|ab)c/, 'atomic alias prevents alternative retry');
ok('ab' =~ /a(*pla:(*nla:c)b)b/, 'nested alpha assertions');

my $captured = 'ab';
ok($captured =~ /a(*pla:(b))b/, 'capture inside alpha assertion participates');
is($1, 'b', 'alpha assertion publishes its capture');

ok('a' =~ /(?(*pla:a)a|b)/,
'positive alpha assertion works as a conditional predicate');
ok('b' =~ /(?(*pla:a)a|b)/,
'positive alpha assertion conditional takes its alternate');
ok('a' =~ /(?(*nla:a)b|a)/,
'negative alpha assertion conditional takes its alternate');
ok('b' =~ /(?(*nla:a)b|a)/,
'negative alpha assertion works as a conditional predicate');

for my $invalid (
'(*positive_lookahead)',
'(*positive_lookahead:a',
'(*positive_lookaround:a)',
) {
my $compiled = eval "qr/$invalid/";
ok(!defined($compiled) && length($@), "malformed long alias is rejected: $invalid");
}

done_testing;
83 changes: 83 additions & 0 deletions src/test/resources/unit/regex/horizontal_whitespace_escape.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use strict;
use warnings;
use feature 'unicode_strings';
use Test::More;

my @horizontal = (0x09, 0x20, 0xA0, 0x1680, 0x2000, 0x200A,
0x202F, 0x205F, 0x3000);
my @other = (0x0A, 0x0B, 0x41, 0x85, 0x180E, 0x2028, 0x2029);

sub check_code_point {
my ($code, $expected, $upgraded) = @_;
my $character = chr($code);
if ($upgraded) {
utf8::upgrade($character);
}
else {
utf8::downgrade($character, 1)
or die sprintf "U+%04X cannot be represented as bytes", $code;
}
my $mode = $upgraded ? 'Unicode' : 'byte';
my $label = sprintf 'U+%04X %s', $code, $mode;

is($character =~ /\A\h\z/ ? 1 : 0, $expected,
"direct h $label");
is($character =~ /\A\H\z/ ? 1 : 0, 1 - $expected,
"direct H $label");
is($character =~ /\A[\h]\z/ ? 1 : 0, $expected,
"class h $label");
is($character =~ /\A[\H]\z/ ? 1 : 0, 1 - $expected,
"class H $label");
}

for my $code (@horizontal) {
check_code_point($code, 1, 1);
check_code_point($code, 1, 0) if $code <= 0xFF;
}
for my $code (@other) {
check_code_point($code, 0, 1);
check_code_point($code, 0, 0) if $code <= 0xFF;
}

my $horizontal_run = join '', map chr, @horizontal;
my $other_run = join '', map chr, @other;
ok($horizontal_run =~ /\A\h+\z/,
'direct h matches a run of horizontal whitespace');
ok($horizontal_run =~ /\A[\h]+\z/,
'class h matches a run of horizontal whitespace');
ok($other_run =~ /\A\H+\z/,
'direct H matches a run without horizontal whitespace');
ok($other_run =~ /\A[\H]+\z/,
'class H matches a run without horizontal whitespace');

my $ideographic_space = chr 0x3000;
my $line_feed = "\n";
ok($ideographic_space =~ /\A(?a:\h)\z/,
'scoped a keeps direct h Unicode-aware');
ok($ideographic_space =~ /\A(?aa:\h)\z/,
'scoped aa keeps direct h Unicode-aware');
ok($ideographic_space =~ /\A(?a:[\h])\z/,
'scoped a keeps class h Unicode-aware');
ok($ideographic_space =~ /\A(?aa:[\h])\z/,
'scoped aa keeps class h Unicode-aware');
ok($line_feed =~ /\A(?a:\H)\z/,
'scoped a keeps direct H complement semantics');
ok($line_feed =~ /\A(?aa:\H)\z/,
'scoped aa keeps direct H complement semantics');
ok($line_feed =~ /\A(?a:[\H])\z/,
'scoped a keeps class H complement semantics');
ok($line_feed =~ /\A(?aa:[\H])\z/,
'scoped aa keeps class H complement semantics');

my $byte_nbsp = chr 0xA0;
utf8::downgrade($byte_nbsp, 1);
ok($byte_nbsp =~ /\A(?a:\h)\z/,
'scoped a keeps direct h byte semantics');
ok($byte_nbsp =~ /\A(?aa:\h)\z/,
'scoped aa keeps direct h byte semantics');
ok($byte_nbsp =~ /\A(?a:[\h])\z/,
'scoped a keeps class h byte semantics');
ok($byte_nbsp =~ /\A(?aa:[\h])\z/,
'scoped aa keeps class h byte semantics');

done_testing();
Loading