From 42ea23cb47309a75044022b14997bed4c2306038 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 18 Aug 2026 19:39:04 +0200 Subject: [PATCH] feat(regex): resolve exact Age properties in Joni Pass exact Age, In, and Present_In assignments to the pinned Joni character property resolver with explicit no-fold policy. Keep wildcard and composed class forms in the frontend until their syntax semantics are represented by the engine. Generated with Codex (https://openai.com/codex) Co-Authored-By: Codex --- docs/reference/feature-matrix.md | 2 +- .../runtime/regex/UnicodeResolver.java | 48 ++++++++++++++----- .../runtime/regex/JoniRegexPatternTest.java | 21 ++++++++ 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/docs/reference/feature-matrix.md b/docs/reference/feature-matrix.md index 938c5ae4b..3a7bb069c 100644 --- a/docs/reference/feature-matrix.md +++ b/docs/reference/feature-matrix.md @@ -379,7 +379,7 @@ my @copy = @{$z}; # ERROR - ✅ **Backreferences to Named Groups**: Using `\k` or `\g{name}` for backreferences to named groups is supported. - ✅ **Relative Backreferences**: Using `\g{-n}` for relative backreferences. - ✅ **Basic Unicode Properties**: Common `\p{...}` and `\P{...}` forms such as `\p{L}` execute through Joni. General_Category assignments now enter the forked Joni parser unchanged and resolve to pinned Perl ranges there. -- 🟡 **Perl Unicode Property Syntax**: Perl-specific properties execute through Joni. `Age`, cumulative `In`/`Present_In`, General_Category, Canonical_Combining_Class, Bidi_Class, Decomposition_Type, East_Asian_Width, Numeric_Value, Joining_Group, Block, Script, and Script_Extensions assignments are generated from pinned Perl 5.44 Unicode 17.0 data with loose and wildcard aliases, ordered missing defaults, official compact aliases and shortcuts, Script-versus-Script_Extensions policy, reserved or composite values, exact rationals, and Perl's generated decimal keyword aliases; `ASCII_Hex_Digit`/`AHex` accepts Perl's eight boolean value aliases. General_Category and standalone exact Block, Script, Script_Extensions, Canonical_Combining_Class, Bidi_Class, Decomposition_Type, East_Asian_Width, Numeric_Value, and Joining_Group assignments use Joni's range-resolver API with explicit per-family case-fold policy. No-fold properties inside composed character classes, wildcard values, and Age-family assignments remain adapter-translated until Joni represents those syntax semantics natively. Other generated property/value aliases still have pinned acceptance/rejection gaps. +- 🟡 **Perl Unicode Property Syntax**: Perl-specific properties execute through Joni. `Age`, cumulative `In`/`Present_In`, General_Category, Canonical_Combining_Class, Bidi_Class, Decomposition_Type, East_Asian_Width, Numeric_Value, Joining_Group, Block, Script, and Script_Extensions assignments are generated from pinned Perl 5.44 Unicode 17.0 data with loose and wildcard aliases, ordered missing defaults, official compact aliases and shortcuts, Script-versus-Script_Extensions policy, reserved or composite values, exact rationals, and Perl's generated decimal keyword aliases; `ASCII_Hex_Digit`/`AHex` accepts Perl's eight boolean value aliases. General_Category and standalone exact Age, `In`/`Present_In`, Block, Script, Script_Extensions, Canonical_Combining_Class, Bidi_Class, Decomposition_Type, East_Asian_Width, Numeric_Value, and Joining_Group assignments use Joni's range-resolver API with explicit per-family case-fold policy. No-fold properties inside composed character classes and wildcard values remain adapter-translated until Joni represents those syntax semantics natively. Other generated property/value aliases still have pinned acceptance/rejection gaps. - ✅ **Possessive Quantifiers**: Quantifiers like `*+`, `++`, `?+`, and `{n,m}+`, which disable backtracking, are supported. - ✅ **Atomic Grouping**: Use of `(?>...)` for atomic groups is supported. - ✅ **`\K` assertion**: Keep left — in `s///`, text before `\K` is preserved; match variables reflect only the portion after `\K`. Ordinary KEEP assertions route through native Joni and no longer use the Java marker rewrite; the adapter still rejects KEEP inside lookaround until the Joni analyser emits Perl's diagnostic directly. diff --git a/src/main/java/org/perlonjava/runtime/regex/UnicodeResolver.java b/src/main/java/org/perlonjava/runtime/regex/UnicodeResolver.java index 486a970bf..13b452433 100644 --- a/src/main/java/org/perlonjava/runtime/regex/UnicodeResolver.java +++ b/src/main/java/org/perlonjava/runtime/regex/UnicodeResolver.java @@ -983,6 +983,7 @@ static boolean isPerlBuiltInPropertyAlias(String property) { static CharacterPropertyResolver.Result resolveJoniProperty( String property, boolean inCharacterClass) { if (property == null) return null; + property = normalizePerlIsPropertyAssignment(property); int assignment = propertyValueDelimiter(property); if (assignment <= 0 || assignment == property.length() - 1) { return null; @@ -1008,6 +1009,9 @@ static CharacterPropertyResolver.Result resolveJoniProperty( || PerlUnicodeJoiningGroupData.isPropertyAlias(name)) { if (perlNumericWildcardBody(value) != null) return null; caseFold = false; + } else if (isPerlAgeProperty(name)) { + if (isPerlAgeWildcard(value)) return null; + caseFold = false; } else { return null; } @@ -1060,7 +1064,7 @@ private static boolean isPerlSpecialPropertyAlias(String property) { private static UnicodeSet resolvePerlBuiltInPropertyAlias(String property) { if (property == null) return null; - String alias = property.trim(); + String alias = normalizePerlIsPropertyAssignment(property.trim()); int assignment = propertyValueDelimiter(alias); if (assignment == alias.length() - 1 && (PerlUnicodeScriptData.isScriptPropertyAlias( @@ -1081,6 +1085,8 @@ && isGeneralCategoryProperty(alias.substring(0, assignment))) { } return category; } + UnicodeSet age = resolvePerlAgeProperty(alias, true); + if (age != null) return age; if (assignment > 0 && assignment < alias.length() - 1 && isCanonicalCombiningClassProperty(alias.substring(0, assignment))) { UnicodeSet combiningClass = PerlUnicodeCombiningClassData.resolve( @@ -1722,26 +1728,33 @@ private static int unicodePropertyValue(int property, String alias) { } private static String translatePerlAgeProperty(String property, boolean negated) { - int delimiter = property.indexOf('='); - int colon = property.indexOf(':'); - if (delimiter < 0 || colon > 0 && colon < delimiter) delimiter = colon; + UnicodeSet result = resolvePerlAgeProperty(property, true); + return result == null ? null + : wrapCharClass(unicodeSetToJavaPattern(result), negated); + } + + private static UnicodeSet resolvePerlAgeProperty( + String property, boolean allowWildcard) { + property = normalizePerlIsPropertyAssignment(property); + int delimiter = propertyValueDelimiter(property); if (delimiter <= 0 || delimiter == property.length() - 1) return null; - String name = property.substring(0, delimiter) - .replace("_", "").replace("-", "").replace(" ", ""); + String name = property.substring(0, delimiter); boolean exact; - if (name.equalsIgnoreCase("Age")) { + String looseName = loosePropertyName(name); + if (looseName.equals("age")) { exact = true; - } else if (name.equalsIgnoreCase("In") || name.equalsIgnoreCase("PresentIn")) { + } else if (looseName.equals("in") || looseName.equals("presentin")) { exact = false; } else { return null; } - String requested = normalizeUnicodeAgeVersion(property.substring(delimiter + 1)); + String value = property.substring(delimiter + 1); + if (!allowWildcard && isPerlAgeWildcard(value)) return null; + String requested = normalizeUnicodeAgeVersion(value); if (requested.equalsIgnoreCase("NA") || requested.equalsIgnoreCase("Unassigned")) { - return wrapCharClass( - unicodeSetToJavaPattern(PerlUnicodeAgeData.unassignedSet()), negated); + return PerlUnicodeAgeData.unassignedSet(); } UnicodeSet result = exact @@ -1750,7 +1763,18 @@ private static String translatePerlAgeProperty(String property, boolean negated) if (result == null) { throw new IllegalArgumentException("Unsupported Unicode age version: " + requested); } - return wrapCharClass(unicodeSetToJavaPattern(result), negated); + return result; + } + + private static boolean isPerlAgeProperty(String name) { + String looseName = loosePropertyName(name); + return looseName.equals("age") || looseName.equals("in") + || looseName.equals("presentin"); + } + + private static boolean isPerlAgeWildcard(String value) { + String trimmed = value.trim(); + return trimmed.startsWith(":\\A") && trimmed.endsWith("\\z:"); } private static String normalizeUnicodeAgeVersion(String value) { diff --git a/src/test/java/org/perlonjava/runtime/regex/JoniRegexPatternTest.java b/src/test/java/org/perlonjava/runtime/regex/JoniRegexPatternTest.java index 2bc11c2f5..eaeaf0f8e 100644 --- a/src/test/java/org/perlonjava/runtime/regex/JoniRegexPatternTest.java +++ b/src/test/java/org/perlonjava/runtime/regex/JoniRegexPatternTest.java @@ -145,6 +145,27 @@ void passesRemainingExactEnumeratedPropertiesToJoni() { } } + @Test + void passesExactAgePropertiesToJoniWithoutTextExpansion() { + String[][] cases = { + {"\\p{Age=2.1}", "\u20AC"}, + {"\\p{In=3.0}", "\u20AC"}, + {"\\p{Present_In=3.0}", "\u20AC"}, + {"\\p{Is_Age=6.1}", "\uD83D\uDE00"}, + {"\\p{Age=Unassigned}", "\uD88D\uDC7A"}, + }; + + for (String[] testCase : cases) { + JoniRegexPattern pattern = new JoniRegexPattern(testCase[0], FLAGS); + assertEquals(testCase[0], pattern.patternDescription()); + assertTrue(pattern.matcher(testCase[1], java.util.List.of()).find()); + } + + JoniRegexPattern wildcard = new JoniRegexPattern( + "\\p{Age=:\\AV16_0\\z:}", FLAGS); + assertFalse(wildcard.patternDescription().contains("Age=")); + } + @Test void flattensTranslatedPropertiesInsideOrdinaryCharacterClasses() { JoniRegexPattern pattern = new JoniRegexPattern(