From 6f9062031328526058a26078fdc1e46b255565f3 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 22 Mar 2026 08:31:35 +0100 Subject: [PATCH 1/8] Fix Module::Runtime test failures: #line directive, hints hash, reload message Three fixes that reduce Module::Runtime test failures from 23 to 8: 1. Honor #line directive in use statement caller info - parseUseDeclaration now uses getSourceLocationAccurate() to get the #line-adjusted filename and line number for CallerStack.push() - Fixes t/import_error.t tests where eval'd use statements with #line directives were reporting wrong locations 2. Prevent %^H hints hash from leaking into require'd modules - doFile() now saves, clears, and restores %^H around PerlLanguageProvider.executePerlCode() - In Perl >= 5.11 (which we emulate), hints don't leak into required files - Fixes tests that check $^H{...} is undef in BEGIN blocks of required modules 3. Fix cached require failure error message - Changed 'Compilation failed in require at ' to 'Attempt to reload aborted.' - Matches Perl's actual error message for cached compilation failures - Fixes the 'broken module is visibly broken when re-required' tests Remaining 8 failures are due to caller()[10] (hints hash per stack frame) returning undef - this is a known limitation requiring more complex tracking. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../frontend/parser/StatementParser.java | 10 +++++++--- .../runtime/operators/ModuleOperators.java | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/perlonjava/frontend/parser/StatementParser.java b/src/main/java/org/perlonjava/frontend/parser/StatementParser.java index 9055e8ff1..a8587c458 100644 --- a/src/main/java/org/perlonjava/frontend/parser/StatementParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/StatementParser.java @@ -526,6 +526,9 @@ public static Node parseUseDeclaration(Parser parser, LexerToken token) { if (CompilerOptions.DEBUG_ENABLED) ctx.logDebug("use: " + token.text); boolean isNoDeclaration = token.text.equals("no"); + // Capture token index for caller() before consuming any tokens + int useTokenIndex = parser.tokenIndex; + TokenUtils.consume(parser); // "use" token = TokenUtils.peek(parser); @@ -627,11 +630,12 @@ public static Node parseUseDeclaration(Parser parser, LexerToken token) { // execute the statement immediately, using: // `require "fullName.pm"` - // Setup the caller stack + // Setup the caller stack - use getSourceLocationAccurate to honor #line directives + ErrorMessageUtil.SourceLocation loc = ctx.errorUtil.getSourceLocationAccurate(useTokenIndex); CallerStack.push( ctx.symbolTable.getCurrentPackage(), - ctx.compilerOptions.fileName, - ctx.errorUtil.getLineNumber(parser.tokenIndex)); + loc.fileName(), + loc.lineNumber()); try { if (CompilerOptions.DEBUG_ENABLED) ctx.logDebug("Use statement: " + fullName + " called from " + CallerStack.peek(0)); diff --git a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java index e81239161..4fe8b826c 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java @@ -615,12 +615,22 @@ else if (code == null) { FeatureFlags outerFeature = featureManager; String savedPackage = InterpreterState.currentPackage.get().toString(); + // Save and clear %^H (hints hash) to prevent hint leakage into required modules. + // In Perl >= 5.11 (which we emulate), hints don't leak into require'd files. + // The hints hash affects compile-time behavior (strict, warnings, features), + // and a required module should start with clean compile-time state. + RuntimeHash hintHash = GlobalVariable.getGlobalHash(GlobalContext.encodeSpecialVar("H")); + java.util.Map savedHintHash = new java.util.HashMap<>(hintHash.elements); + // Notify B::Hooks::EndOfScope that we're starting to load a file // This enables on_scope_end callbacks to know which file they belong to BHooksEndOfScope.beginFileLoad(parsedArgs.fileName); try { featureManager = new FeatureFlags(); + + // Clear the hints hash for a fresh compilation context + hintHash.elements.clear(); result = PerlLanguageProvider.executePerlCode(parsedArgs, false, ctx); @@ -643,6 +653,10 @@ else if (code == null) { featureManager = outerFeature; InterpreterState.currentPackage.get().set(savedPackage); + + // Restore the caller's hints hash + hintHash.elements.clear(); + hintHash.elements.putAll(savedHintHash); } // Return result based on context @@ -732,7 +746,8 @@ public static RuntimeScalar require(RuntimeScalar runtimeScalar) { RuntimeScalar incEntry = incHash.elements.get(fileName); if (!incEntry.defined().getBoolean()) { // This was a compilation failure, throw the cached error - throw new PerlCompilerException("Compilation failed in require at " + fileName); + // Perl says "Attempt to reload aborted." for cached failures + throw new PerlCompilerException("Attempt to reload " + fileName + " aborted."); } // module was already loaded successfully - always return exactly 1 return getScalarInt(1); From 3f7540203537fa3a7d29a7f9166906d122a18e1e Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 22 Mar 2026 08:48:25 +0100 Subject: [PATCH 2/8] Fix base.pm isa check and error message formatting - Base.java: Add isa check before adding to @ISA, matching Perl base.pm behavior (skip redundant base classes when Middle->isa(Parent)) - PerlCompilerException.java, FileTestOperator.java: Add missing period before " at file line N" in error messages Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../runtime/operators/FileTestOperator.java | 2 +- .../perlonjava/runtime/perlmodule/Base.java | 41 ++++++++++++++++++- .../runtimetypes/PerlCompilerException.java | 2 +- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java b/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java index ab30dfb84..3222a1411 100644 --- a/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java @@ -81,7 +81,7 @@ private static RuntimeScalar callerWhere() { } String fileName = caller.elements.get(1).toString(); int line = ((RuntimeScalar) caller.elements.get(2)).getInt(); - return new RuntimeScalar(" at " + fileName + " line " + line + "\n"); + return new RuntimeScalar(" at " + fileName + " line " + line + ".\n"); } private static String filehandleShortName(RuntimeScalar fileHandle) { diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Base.java b/src/main/java/org/perlonjava/runtime/perlmodule/Base.java index 5ae2bea0e..00f9da3bd 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Base.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Base.java @@ -53,6 +53,9 @@ public static RuntimeList importBase(RuntimeArray args, int ctx) { RuntimeList callerList = RuntimeCode.caller(new RuntimeList(), RuntimeContextType.SCALAR); String inheritor = callerList.scalar().toString(); + // Keep track of bases we're adding in this import call + java.util.List basesToAdd = new java.util.ArrayList<>(); + // Process each base class specified in the arguments for (RuntimeScalar baseClass : args.elements) { String baseClassName = baseClass.toString(); @@ -62,6 +65,35 @@ public static RuntimeList importBase(RuntimeArray args, int ctx) { continue; } + // Check if inheritor or any base we're adding already isa this base class + // This matches Perl's base.pm line 92: next if grep $_->isa($base), ($inheritor, @bases); + boolean shouldSkip = false; + + // Check if inheritor already isa baseClassName + RuntimeArray isaArgs = new RuntimeArray(); + RuntimeArray.push(isaArgs, new RuntimeScalar(inheritor)); + RuntimeArray.push(isaArgs, new RuntimeScalar(baseClassName)); + if (Universal.isa(isaArgs, RuntimeContextType.SCALAR).getBoolean()) { + shouldSkip = true; + } + + // Check if any of the bases we're adding already isa baseClassName + if (!shouldSkip) { + for (String addedBase : basesToAdd) { + RuntimeArray isaArgs2 = new RuntimeArray(); + RuntimeArray.push(isaArgs2, new RuntimeScalar(addedBase)); + RuntimeArray.push(isaArgs2, new RuntimeScalar(baseClassName)); + if (Universal.isa(isaArgs2, RuntimeContextType.SCALAR).getBoolean()) { + shouldSkip = true; + break; + } + } + } + + if (shouldSkip) { + continue; + } + if (!GlobalVariable.isPackageLoaded(baseClassName)) { // Require the base class file String filename = baseClassName.replace("::", "/").replace("'", "/") + ".pm"; @@ -77,8 +109,13 @@ public static RuntimeList importBase(RuntimeArray args, int ctx) { } } - // Add the base class to the @ISA array of the inheritor - RuntimeArray isa = getGlobalArray(inheritor + "::ISA"); + // Add to our list of bases to add + basesToAdd.add(baseClassName); + } + + // Add all the bases to @ISA at the end (like Perl's base.pm line 138) + RuntimeArray isa = getGlobalArray(inheritor + "::ISA"); + for (String baseClassName : basesToAdd) { RuntimeArray.push(isa, new RuntimeScalar(baseClassName)); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlCompilerException.java b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlCompilerException.java index 47a556cd3..135c5ff7e 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlCompilerException.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlCompilerException.java @@ -67,7 +67,7 @@ private static String buildErrorMessage(String message) { } String fileName = caller.elements.get(1).toString(); int line = ((RuntimeScalar) caller.elements.get(2)).getInt(); - return message + " at " + fileName + " line " + line + "\n"; + return message + " at " + fileName + " line " + line + ".\n"; } catch (Throwable t) { // caller() failed (e.g. mid-exception in interpreter) — use bare message return message + "\n"; From d9b3ef9f5c545425896675d5611239d9a41215f2 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 22 Mar 2026 10:00:09 +0100 Subject: [PATCH 3/8] Fix parent.pm tests: normalize old-style package separator and improve error messages - NameNormalizer: Add normalizePackageName() to convert Foo'Bar to Foo::Bar - InheritanceResolver, DFS: Normalize package names when reading @ISA - Universal.isa: Normalize argument for consistent comparison - ModuleOperators: Include module name hint and @INC entries in "Can't locate" error message, matching Perl 5.17.5+ behavior All 8 parent.pm tests now pass. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../java/org/perlonjava/runtime/mro/DFS.java | 3 +++ .../runtime/mro/InheritanceResolver.java | 2 ++ .../runtime/operators/ModuleOperators.java | 17 ++++++++++++++++- .../runtime/perlmodule/Universal.java | 4 +++- .../runtime/runtimetypes/NameNormalizer.java | 18 ++++++++++++++++++ 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/mro/DFS.java b/src/main/java/org/perlonjava/runtime/mro/DFS.java index 66b5c6d94..af085c602 100644 --- a/src/main/java/org/perlonjava/runtime/mro/DFS.java +++ b/src/main/java/org/perlonjava/runtime/mro/DFS.java @@ -1,6 +1,7 @@ package org.perlonjava.runtime.mro; import org.perlonjava.runtime.runtimetypes.GlobalVariable; +import org.perlonjava.runtime.runtimetypes.NameNormalizer; import org.perlonjava.runtime.runtimetypes.PerlCompilerException; import org.perlonjava.runtime.runtimetypes.RuntimeArray; import org.perlonjava.runtime.runtimetypes.RuntimeBase; @@ -91,6 +92,8 @@ private static void populateIsaMapWithCycleDetection(String className, String parentName = entity.toString(); // FIXED: Skip empty or null parent names if (parentName != null && !parentName.isEmpty()) { + // Normalize old-style ' separator to :: (e.g., Foo'Bar -> Foo::Bar) + parentName = NameNormalizer.normalizePackageName(parentName); parents.add(parentName); } } diff --git a/src/main/java/org/perlonjava/runtime/mro/InheritanceResolver.java b/src/main/java/org/perlonjava/runtime/mro/InheritanceResolver.java index e34f40ef7..bfba25b4d 100644 --- a/src/main/java/org/perlonjava/runtime/mro/InheritanceResolver.java +++ b/src/main/java/org/perlonjava/runtime/mro/InheritanceResolver.java @@ -226,6 +226,8 @@ private static void populateIsaMapHelper(String className, } } if (!parentName.isEmpty()) { + // Normalize old-style ' separator to :: (e.g., Foo'Bar -> Foo::Bar) + parentName = NameNormalizer.normalizePackageName(parentName); parents.add(parentName); } } diff --git a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java index 4fe8b826c..24085233b 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java @@ -778,7 +778,22 @@ public static RuntimeScalar require(RuntimeScalar runtimeScalar) { message = fileName + " did not return a true value"; throw new PerlCompilerException(message); } else if (err.isEmpty()) { - message = "Can't locate " + fileName + " in @INC"; + // Derive module name from filename for helpful error message + String moduleName = fileName; + if (moduleName.endsWith(".pm")) { + moduleName = moduleName.substring(0, moduleName.length() - 3); + } + moduleName = moduleName.replace("/", "::"); + + // Build @INC list for error message + RuntimeArray incArray = GlobalVariable.getGlobalArray("main::INC"); + StringBuilder incList = new StringBuilder(); + for (int i = 0; i < incArray.size(); i++) { + if (i > 0) incList.append(" "); + incList.append(incArray.get(i).toString()); + } + + message = "Can't locate " + fileName + " in @INC (you may need to install the " + moduleName + " module) (@INC entries checked: " + incList + ")"; // Don't set %INC for file not found errors throw new PerlCompilerException(message); } else { diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java b/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java index c20aa0fa5..8c790e4a8 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java @@ -232,9 +232,11 @@ public static RuntimeList isa(RuntimeArray args, int ctx) { // Get the linearized inheritance hierarchy using C3 List linearizedClasses = InheritanceResolver.linearizeHierarchy(perlClassName); - // Normalize the argument: main::Foo -> Foo, ::Foo -> Foo + // Normalize the argument: main::Foo -> Foo, ::Foo -> Foo, Foo'Bar -> Foo::Bar // This is needed because isa("main::Foo") should match a class blessed as "Foo" String normalizedArg = argString; + // First normalize old-style ' separator to :: + normalizedArg = NameNormalizer.normalizePackageName(normalizedArg); if (normalizedArg.startsWith("main::")) { normalizedArg = normalizedArg.substring(6); } else if (normalizedArg.startsWith("::")) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/NameNormalizer.java b/src/main/java/org/perlonjava/runtime/runtimetypes/NameNormalizer.java index 7af572976..04cd69378 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/NameNormalizer.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/NameNormalizer.java @@ -183,6 +183,24 @@ public static String moduleToFilename(String moduleName) { return moduleName.replace("::", "/") + ".pm"; } + /** + * Normalizes a package name by converting old-style single-quote separator to '::'. + * In Perl, "Foo'Bar" is equivalent to "Foo::Bar". + * + * @param packageName The package name to normalize. + * @return The normalized package name with '::' separators. + */ + public static String normalizePackageName(String packageName) { + if (packageName == null || packageName.isEmpty()) { + return packageName; + } + // Replace old-style ' separator with :: + if (packageName.indexOf('\'') >= 0) { + return packageName.replace("'", "::"); + } + return packageName; + } + /** * Composite key for name cache to avoid string concatenation overhead. * Using a record provides efficient hashCode/equals with no allocation. From d5c09198e492d21e773585a44c9f82ca6d04f154 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 22 Mar 2026 10:09:47 +0100 Subject: [PATCH 4/8] Fix Module::Metadata tests: Unicode regex, File::Spec path handling - RegexFlags: Enable UNICODE_CHARACTER_CLASS so \w, \d, \s match Unicode characters by default (matches Perl behavior) - FileSpec.abs2rel: Fix to use user.dir property for relative base paths (Java Path.toAbsolutePath() ignores System.setProperty changes) - FileSpec.rel2abs: Same fix for relative base paths Module::Metadata tests: 137/138 pass (1 taint test expected to fail) Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../runtime/perlmodule/FileSpec.java | 26 +++++++++++++++++-- .../perlonjava/runtime/regex/RegexFlags.java | 5 ++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java index 94e83ce95..9d74294d3 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java @@ -6,6 +6,7 @@ import org.perlonjava.runtime.runtimetypes.SystemUtils; import java.io.File; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; @@ -425,7 +426,22 @@ public static RuntimeList abs2rel(RuntimeArray args, int ctx) { } String path = args.get(1).toString(); String base = args.size() == 3 ? args.get(2).toString() : System.getProperty("user.dir"); - String relPath = Paths.get(base).relativize(Paths.get(path)).toString(); + + // Ensure both paths are absolute before relativizing (like Perl does) + // Note: We use user.dir explicitly because Java's Path.toAbsolutePath() + // doesn't respect System.setProperty("user.dir", ...) set by chdir() + Path pathObj = Paths.get(path); + Path baseObj = Paths.get(base); + String userDir = System.getProperty("user.dir"); + + if (!pathObj.isAbsolute()) { + pathObj = Paths.get(userDir).resolve(pathObj).normalize(); + } + if (!baseObj.isAbsolute()) { + baseObj = Paths.get(userDir).resolve(baseObj).normalize(); + } + + String relPath = baseObj.relativize(pathObj).toString(); return new RuntimeScalar(relPath).getList(); } @@ -454,8 +470,14 @@ public static RuntimeList rel2abs(RuntimeArray args, int ctx) { return new RuntimeScalar(absPath).getList(); } + // If base is relative, resolve it against current working directory first + Path basePath = Paths.get(base); + if (!basePath.isAbsolute()) { + basePath = Paths.get(System.getProperty("user.dir")).resolve(basePath); + } + // For relative paths, resolve against the base directory - String absPath = Paths.get(base, path).toAbsolutePath().normalize().toString(); + String absPath = basePath.resolve(path).normalize().toString(); return new RuntimeScalar(absPath).getList(); } } diff --git a/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java b/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java index 5091c3c28..4144f8bd2 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java +++ b/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java @@ -63,6 +63,11 @@ public static void validateModifiers(String modifiers) { public int toPatternFlags() { int flags = 0; + + // Always enable UNICODE_CHARACTER_CLASS so \w, \d, \s match Unicode chars + // This matches Perl's default behavior where \w includes Unicode letters + flags |= UNICODE_CHARACTER_CLASS; + if (isCaseInsensitive) { // For proper Unicode case-insensitive matching, we need both flags: // - CASE_INSENSITIVE: enables case-insensitive matching From 2b8361e29110efdf7976f9867fff71285b292490 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 22 Mar 2026 10:17:20 +0100 Subject: [PATCH 5/8] Fix %main:: to include top-level packages in stash enumeration In Perl, $Foo::x and $main::Foo::x refer to the same variable, but PerlOnJava stores top-level package symbols without the 'main::' prefix. This caused %main:: (the main stash) to not include entries like 'Foo::' for top-level packages. The fix extends HashSpecialVariable.entrySet() to also include keys that start with a top-level package name (e.g., "Foo::test") when enumerating %main::. This allows Class::Inspector::_subnames to correctly find all child packages. Test results: - Class::Inspector: 55/56 tests pass (1 failure is unrelated INC hook issue) - All unit tests pass Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../runtimetypes/HashSpecialVariable.java | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/HashSpecialVariable.java b/src/main/java/org/perlonjava/runtime/runtimetypes/HashSpecialVariable.java index 3a3f7ef80..d927a5e57 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/HashSpecialVariable.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/HashSpecialVariable.java @@ -96,11 +96,14 @@ public Set> entrySet() { // Process each key to extract the namespace part Set uniqueKeys = new HashSet<>(); // Set to track unique keys + boolean isMainStash = "main::".equals(namespace); for (String key : allKeys) { + String entryKey = null; + String globName = null; + if (key.startsWith(namespace)) { String remainingKey = key.substring(namespace.length()); int nextSeparatorIndex = remainingKey.indexOf("::"); - String entryKey; if (nextSeparatorIndex == -1) { entryKey = remainingKey; } else { @@ -108,23 +111,38 @@ public Set> entrySet() { // (e.g. "Foo::" not "Foo") - this is how Perl indicates sub-packages entryKey = remainingKey.substring(0, nextSeparatorIndex + 2); } - - // Special sort variables should not show up in stash enumeration - if (entryKey.equals("a") || entryKey.equals("b")) { - continue; + // entryKey already includes "::" for nested packages + globName = namespace + entryKey; + } else if (isMainStash) { + // For %main::, also include top-level packages that aren't explicitly + // prefixed with "main::". In Perl, $Foo::x and $main::Foo::x are the same. + // Variables in top-level packages are stored as "Foo::x", not "main::Foo::x". + int separatorIndex = key.indexOf("::"); + if (separatorIndex > 0) { + // This is a top-level package (like "Foo::test") + // Extract "Foo::" as the entry key + entryKey = key.substring(0, separatorIndex + 2); + // The glob name is the original key prefix + globName = entryKey; } + } - if (entryKey.isEmpty()) { - continue; - } + if (entryKey == null) { + continue; + } - // entryKey already includes "::" for nested packages - String globName = namespace + entryKey; + // Special sort variables should not show up in stash enumeration + if (entryKey.equals("a") || entryKey.equals("b")) { + continue; + } - // Add the entry only if it's not already in the set of unique keys - if (uniqueKeys.add(entryKey)) { - entries.add(new SimpleEntry<>(entryKey, new RuntimeStashEntry(globName, true))); - } + if (entryKey.isEmpty()) { + continue; + } + + // Add the entry only if it's not already in the set of unique keys + if (uniqueKeys.add(entryKey)) { + entries.add(new SimpleEntry<>(entryKey, new RuntimeStashEntry(globName, true))); } } } From a4fd199738e791d20d8cd61b96c9d0f0da18ed9e Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 22 Mar 2026 10:23:26 +0100 Subject: [PATCH 6/8] Fix substr() with negative offsets that overshoot string start When substr() is called with a negative offset that goes before the beginning of the string, Perl's behavior is: 1. If the adjusted length would still be positive, clip offset to 0 and reduce length by the overshoot amount (no warning) Example: substr("a", -2, 2) returns "a" 2. If the adjusted length would be non-positive, warn and return undef Example: substr("hello", -10, 1) warns and returns undef This also fixes the 4-argument substr replacement behavior to correctly replace only the extracted portion when clipping occurs. Example: substr("ab", -3, 2, "X") returns "a" and sets str to "Xb" Test results: - All unit tests pass - Class::Inspector tests pass (no more substr outside of string warnings) Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../runtime/operators/Operator.java | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/Operator.java b/src/main/java/org/perlonjava/runtime/operators/Operator.java index 5b2ee702b..fc1c91d7a 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Operator.java +++ b/src/main/java/org/perlonjava/runtime/operators/Operator.java @@ -276,17 +276,18 @@ private static RuntimeScalar substrImpl(int ctx, boolean warnEnabled, RuntimeBas int length = hasExplicitLength ? ((RuntimeScalar) args[2]).getInt() : strLength - offset; String replacement = (size > 3) ? args[3].toString() : null; - // Store original offset and length for LValue creation - int originalOffset = offset; - int originalLength = length; - // Handle negative offsets (count from the end of the string) if (offset < 0) { offset = strLength + offset; - // When no explicit length is provided, Perl clips negative offsets to 0 (no warning) - // When explicit length IS provided, Perl warns and returns undef for too-negative offsets + // When computed offset goes negative (before string start): + // - Clip offset to 0 + // - Reduce length by the overshoot amount + // Example: substr("a", -2, 2) -> offset=-1, clip to 0, length=2+(-1)=1, returns "a" + // But: substr("hello", -10, 1) -> offset=-5, length=1+(-5)=-4 → warn and return undef if (offset < 0) { - if (hasExplicitLength) { + // Check if adjusted length would be non-positive (Perl warns in this case) + int adjustedLength = length + offset; + if (adjustedLength <= 0) { // Warn and return undef (same as positive offset out of bounds) if (warnEnabled) { WarnDie.warn(new RuntimeScalar("substr outside of string"), @@ -295,14 +296,14 @@ private static RuntimeScalar substrImpl(int ctx, boolean warnEnabled, RuntimeBas if (replacement != null) { return new RuntimeScalar(); } - var lvalue = new RuntimeSubstrLvalue((RuntimeScalar) args[0], "", originalOffset, originalLength); + var lvalue = new RuntimeSubstrLvalue((RuntimeScalar) args[0], "", 0, 0); lvalue.type = RuntimeScalarType.UNDEF; lvalue.value = null; return lvalue; - } else { - // Clip to 0 without warning - offset = 0; } + // Reduce length by the overshoot (negative offset value) + length = adjustedLength; + offset = 0; } } @@ -315,7 +316,7 @@ private static RuntimeScalar substrImpl(int ctx, boolean warnEnabled, RuntimeBas if (replacement != null) { return new RuntimeScalar(); } - var lvalue = new RuntimeSubstrLvalue((RuntimeScalar) args[0], "", originalOffset, originalLength); + var lvalue = new RuntimeSubstrLvalue((RuntimeScalar) args[0], "", offset, length); lvalue.type = RuntimeScalarType.UNDEF; lvalue.value = null; return lvalue; @@ -332,6 +333,17 @@ private static RuntimeScalar substrImpl(int ctx, boolean warnEnabled, RuntimeBas // Ensure length is non-negative and within bounds length = Math.max(0, Math.min(length, strLength - offset)); + // If length is zero or negative after all adjustments, return empty string + if (length <= 0) { + if (replacement != null) { + // With replacement, still need to handle the replacement at position 0 + var lvalue = new RuntimeSubstrLvalue((RuntimeScalar) args[0], "", offset, 0); + lvalue.set(replacement); + return new RuntimeScalar(""); + } + return new RuntimeSubstrLvalue((RuntimeScalar) args[0], "", offset, 0); + } + // Extract the substring (offset/length are in Unicode code points) int startIndex = str.offsetByCodePoints(0, offset); int endIndex = str.offsetByCodePoints(startIndex, length); @@ -339,7 +351,8 @@ private static RuntimeScalar substrImpl(int ctx, boolean warnEnabled, RuntimeBas // Return an LValue "RuntimeSubstrLvalue" that can be used to assign to the original string // This allows for in-place modification of the original string if needed - var lvalue = new RuntimeSubstrLvalue((RuntimeScalar) args[0], result, originalOffset, originalLength); + // Pass the adjusted offset and length, not the originals + var lvalue = new RuntimeSubstrLvalue((RuntimeScalar) args[0], result, offset, length); if (replacement != null) { // When replacement is provided, save the extracted substring before modifying From a58b4cc1b47d2ffbd518a1e72df4ad97a8a43ac3 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 22 Mar 2026 17:41:16 +0100 Subject: [PATCH 7/8] Fix regex /u flag: only enable Unicode character classes when requested Instead of unconditionally enabling UNICODE_CHARACTER_CLASS (which broke 308 tests in re/charset.t), now properly track the /u modifier and only enable Unicode character class matching when /u is specified. This fixes the regressions in: - re/charset.t: 5282/5552 (matches master) - uni/variables.t: 66880/66880 (matches master) - re/regex_sets.t: restored to master level - re/pat.t: restored to master level The /u flag can be used to enable Unicode matching: /\w+/u # matches Unicode word characters Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../perlonjava/runtime/regex/RegexFlags.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java b/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java index 4144f8bd2..1c5eb5906 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java +++ b/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java @@ -18,11 +18,12 @@ * @param isDotAll s flag - dot matches all characters including newline * @param isExtended x flag - ignore whitespace and # comments in pattern * @param preservesMatch p flag - preserve match after failed matches + * @param isUnicode u flag - Unicode semantics (\w, \d, \s match Unicode) */ public record RegexFlags(boolean isGlobalMatch, boolean keepCurrentPosition, boolean isNonDestructive, boolean isMatchExactlyOnce, boolean useGAssertion, boolean isExtendedWhitespace, boolean isNonCapturing, boolean isOptimized, boolean isCaseInsensitive, boolean isMultiLine, - boolean isDotAll, boolean isExtended, boolean preservesMatch) { + boolean isDotAll, boolean isExtended, boolean preservesMatch, boolean isUnicode) { public static RegexFlags fromModifiers(String modifiers, String patternString) { return new RegexFlags( @@ -38,7 +39,8 @@ public static RegexFlags fromModifiers(String modifiers, String patternString) { modifiers.contains("m"), modifiers.contains("s"), modifiers.contains("x"), - modifiers.contains("p") + modifiers.contains("p"), + modifiers.contains("u") ); } @@ -64,9 +66,10 @@ public static void validateModifiers(String modifiers) { public int toPatternFlags() { int flags = 0; - // Always enable UNICODE_CHARACTER_CLASS so \w, \d, \s match Unicode chars - // This matches Perl's default behavior where \w includes Unicode letters - flags |= UNICODE_CHARACTER_CLASS; + // /u flag enables Unicode semantics for \w, \d, \s + if (isUnicode) { + flags |= UNICODE_CHARACTER_CLASS; + } if (isCaseInsensitive) { // For proper Unicode case-insensitive matching, we need both flags: @@ -94,6 +97,7 @@ public RegexFlags with(String positiveFlags, String negativeFlags) { boolean newIsDotAll = this.isDotAll; boolean newIsExtended = this.isExtended; boolean newPreservesMatch = this.preservesMatch; + boolean newIsUnicode = this.isUnicode; // Handle positive flags if (positiveFlags.indexOf('n') >= 0) newFlagN = true; @@ -102,6 +106,7 @@ public RegexFlags with(String positiveFlags, String negativeFlags) { if (positiveFlags.indexOf('s') >= 0) newIsDotAll = true; if (positiveFlags.indexOf('x') >= 0) newIsExtended = true; if (positiveFlags.indexOf('p') >= 0) newPreservesMatch = true; + if (positiveFlags.indexOf('u') >= 0) newIsUnicode = true; // Handle negative flags if (negativeFlags.indexOf('n') >= 0) newFlagN = false; @@ -109,6 +114,7 @@ public RegexFlags with(String positiveFlags, String negativeFlags) { if (negativeFlags.indexOf('m') >= 0) newIsMultiLine = false; if (negativeFlags.indexOf('s') >= 0) newIsDotAll = false; if (negativeFlags.indexOf('x') >= 0) newIsExtended = false; + if (negativeFlags.indexOf('u') >= 0) newIsUnicode = false; return new RegexFlags( this.isGlobalMatch, @@ -123,7 +129,8 @@ public RegexFlags with(String positiveFlags, String negativeFlags) { newIsMultiLine, newIsDotAll, newIsExtended, - newPreservesMatch + newPreservesMatch, + newIsUnicode ); } @@ -138,6 +145,7 @@ public String toFlagString() { if (isNonCapturing) flagString.append('n'); if (isExtended) flagString.append('x'); if (isNonDestructive) flagString.append('r'); + if (isUnicode) flagString.append('u'); return flagString.toString(); } From ce00ee974f770c4fc73b4605d815092106f53169 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 22 Mar 2026 18:11:41 +0100 Subject: [PATCH 8/8] Fix cached require error message to include 'Compilation failed' Perl's error message for a cached compilation failure includes both: - 'Attempt to reload aborted.' - 'Compilation failed in require at ' The previous fix only included the first part, which broke comp/require.t test 32. Now includes both parts to match Perl. Fixes: comp/require.t 1743/1747 (matches master) Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../org/perlonjava/runtime/operators/ModuleOperators.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java index 24085233b..0d567b1de 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java @@ -746,8 +746,8 @@ public static RuntimeScalar require(RuntimeScalar runtimeScalar) { RuntimeScalar incEntry = incHash.elements.get(fileName); if (!incEntry.defined().getBoolean()) { // This was a compilation failure, throw the cached error - // Perl says "Attempt to reload aborted." for cached failures - throw new PerlCompilerException("Attempt to reload " + fileName + " aborted."); + // Perl outputs: "Attempt to reload aborted.\nCompilation failed in require at ..." + throw new PerlCompilerException("Attempt to reload " + fileName + " aborted.\nCompilation failed in require at " + fileName); } // module was already loaded successfully - always return exactly 1 return getScalarInt(1);