From bd612b26220d3c179e0ff681180f2176c43dd7da Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Wed, 10 Jun 2026 20:30:13 +0200 Subject: [PATCH] fix: address CPAN random tester failures Fix remaining PerlOnJava compatibility gaps found by cpan_random_tester across Image::ExifTool, File::ShareDir::ProjectDistDir, Sub::Override, MIDI::Util, OLE::Storage_Lite, Module::Pluggable::Ordered, Email::Date::Format, and Test::Cmd. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- .../perlonjava/app/cli/ArgumentParser.java | 9 +- .../backend/bytecode/BytecodeCompiler.java | 109 +++++- .../backend/bytecode/BytecodeInterpreter.java | 17 +- .../backend/bytecode/CompileAssignment.java | 9 +- .../backend/bytecode/CompileOperator.java | 5 + .../backend/bytecode/MiscOpcodeHandler.java | 1 + .../frontend/parser/OperatorParser.java | 1 + .../runtime/io/PipeInputChannel.java | 10 +- .../runtime/io/PipeOutputChannel.java | 11 +- .../runtime/operators/ScalarGlobOperator.java | 56 +++- .../perlonjava/runtime/operators/Time.java | 80 ++++- .../runtime/perlmodule/FileSpec.java | 29 +- .../runtime/regex/RuntimeRegex.java | 314 +++++++++++++++++- .../runtime/runtimetypes/GlobalContext.java | 2 + .../runtimetypes/GlobalDestruction.java | 33 +- .../runtime/runtimetypes/MortalList.java | 2 +- .../runtime/runtimetypes/RegexState.java | 6 + .../runtimetypes/RuntimeBaseProxy.java | 26 ++ .../runtime/runtimetypes/RuntimeCode.java | 43 ++- src/main/perl/lib/CPAN/Config.pm | 3 + src/main/perl/lib/Config.pm | 14 +- .../Module-Pluggable-Ordered.yml | 13 + .../LimitFixturePlugins.patch | 26 ++ .../BytecodeCompilerEvalContextTest.java | 55 +++ .../resources/unit/config_startperl_exec.t | 28 ++ src/test/resources/unit/eval_block_return.t | 30 ++ .../unit/eval_named_sub_capture_fallback.t | 21 ++ src/test/resources/unit/filespec_tmpdir_env.t | 13 + src/test/resources/unit/for_loop_test.t | 26 ++ .../resources/unit/glob_absolute_directory.t | 21 ++ src/test/resources/unit/hash.t | 20 ++ .../unit/indirect_object_constructor.t | 45 +++ src/test/resources/unit/io_file_autoload.t | 13 + src/test/resources/unit/io_flock.t | 67 ++++ src/test/resources/unit/localtime_tz.t | 30 ++ src/test/resources/unit/pipe_close_status.t | 22 ++ .../global_destruction_nested_container.t | 59 ++++ .../resources/unit/regex_capture_sub_scope.t | 19 ++ .../unit/regex_capture_symbolic_deref.t | 17 + .../unit/regex_xml_element_large_scan.t | 35 ++ .../resources/unit/regex_xmpmeta_large_scan.t | 21 ++ src/test/resources/unit/undef_typeglob_sub.t | 26 ++ 42 files changed, 1310 insertions(+), 77 deletions(-) create mode 100644 src/main/perl/lib/PerlOnJava/CpanDistroprefs/Module-Pluggable-Ordered.yml create mode 100644 src/main/perl/lib/PerlOnJava/CpanPatches/Module-Pluggable-Ordered-1.5/LimitFixturePlugins.patch create mode 100644 src/test/java/org/perlonjava/backend/bytecode/BytecodeCompilerEvalContextTest.java create mode 100644 src/test/resources/unit/config_startperl_exec.t create mode 100644 src/test/resources/unit/eval_block_return.t create mode 100644 src/test/resources/unit/eval_named_sub_capture_fallback.t create mode 100644 src/test/resources/unit/filespec_tmpdir_env.t create mode 100644 src/test/resources/unit/glob_absolute_directory.t create mode 100644 src/test/resources/unit/indirect_object_constructor.t create mode 100644 src/test/resources/unit/io_file_autoload.t create mode 100644 src/test/resources/unit/io_flock.t create mode 100644 src/test/resources/unit/localtime_tz.t create mode 100644 src/test/resources/unit/pipe_close_status.t create mode 100644 src/test/resources/unit/refcount/global_destruction_nested_container.t create mode 100644 src/test/resources/unit/regex_capture_sub_scope.t create mode 100644 src/test/resources/unit/regex_capture_symbolic_deref.t create mode 100644 src/test/resources/unit/regex_xml_element_large_scan.t create mode 100644 src/test/resources/unit/regex_xmpmeta_large_scan.t create mode 100644 src/test/resources/unit/undef_typeglob_sub.t diff --git a/src/main/java/org/perlonjava/app/cli/ArgumentParser.java b/src/main/java/org/perlonjava/app/cli/ArgumentParser.java index 4ab13aaa8..608754117 100644 --- a/src/main/java/org/perlonjava/app/cli/ArgumentParser.java +++ b/src/main/java/org/perlonjava/app/cli/ArgumentParser.java @@ -58,11 +58,6 @@ public static CompilerOptions parseArguments(String[] args) { StringBuilder stdinContent = new StringBuilder(); BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); - if (isInteractive) { - // Interactive mode - prompt the user and read until EOF (Ctrl+D) - System.err.println("Enter Perl code (press Ctrl+D when done):"); - } - // Read from stdin regardless of whether it's interactive or not String line; while ((line = reader.readLine()) != null) { @@ -318,6 +313,10 @@ private static void processNonSwitchArgument(String[] args, CompilerOptions pars * @param index The current index in the arguments array. */ private static void processShebangLine(String[] args, CompilerOptions parsedArgs, String fileContent, int index) { + if (parsedArgs.discardLeadingGarbage) { + return; + } + String[] lines = fileContent.split("\n", 2); if (lines.length == 0 || !lines[0].startsWith("#!")) { return; diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index 7a7e52040..835389d0a 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -58,6 +58,10 @@ public class BytecodeCompiler implements Visitor { // instead of LOAD_UNDEF + SET_SCALAR, to preserve the RuntimeScalar identity that // closures share. final Set closureCapturedVarNames = new HashSet<>(); + // Lexical scalar variables currently used as foreach loop aliases. Assignments + // through these variables must update the aliased element in place, matching + // Perl's `foreach my $x (@array)` and `foreach $x (@array)` semantics. + private final Map foreachAliasLexicalCounts = new HashMap<>(); // Source information final String sourceName; final int sourceLine; @@ -115,6 +119,8 @@ public class BytecodeCompiler implements Visitor { // Nesting depth inside eval blocks (goto &sub from eval is prohibited) // 0 = not in eval block, >0 = inside eval block(s) int evalBlockDepth; + private final ArrayDeque evalReturnTargetRegs = new ArrayDeque<>(); + private final ArrayDeque> evalReturnGotoPatchPositions = new ArrayDeque<>(); // Counter tracking nesting depth inside finally blocks (control flow out of finally is prohibited) private int finallyBlockDepth; // Tracks whether any LOCAL_* or PUSH_LOCAL_VARIABLE opcodes are emitted (for DynamicVariableManager optimization) @@ -337,6 +343,26 @@ void registerVariable(String name, int reg) { symbolTable.addVariableWithIndex(name, reg, "my"); } + boolean isForeachAliasLexical(String name) { + return foreachAliasLexicalCounts.getOrDefault(name, 0) > 0; + } + + private void pushForeachAliasLexical(String name) { + foreachAliasLexicalCounts.merge(name, 1, Integer::sum); + } + + private void popForeachAliasLexical(String name) { + Integer count = foreachAliasLexicalCounts.get(name); + if (count == null) { + return; + } + if (count <= 1) { + foreachAliasLexicalCounts.remove(name); + } else { + foreachAliasLexicalCounts.put(name, count - 1); + } + } + private void enterScope() { int scopeIdx = symbolTable.enterScope(); scopeIndices.push(scopeIdx); @@ -742,6 +768,23 @@ String getEvalScopeType() { return null; } + boolean shouldReturnFromInlineEvalBlock() { + return evalBlockDepth > 0 && !isInMapGrepBlock; + } + + void emitInlineEvalReturn(int exprReg) { + if (evalReturnTargetRegs.isEmpty() || evalReturnGotoPatchPositions.isEmpty()) { + throwCompilerException("Internal error: return outside eval block has no eval target", currentTokenIndex); + return; + } + emitAliasWithTarget(evalReturnTargetRegs.peek(), exprReg); + emit(Opcodes.GOTO); + int patchPos = bytecode.size(); + emitInt(0); + evalReturnGotoPatchPositions.peek().add(patchPos); + lastResultReg = -1; + } + /** * Compile an AST node to InterpretedCode. * @@ -1052,17 +1095,11 @@ private RuntimeBase getVariableValueFromContext(String varName, EmitterContext c // For eval STRING, runtime values are available via evalRuntimeContext ThreadLocal RuntimeCode.EvalRuntimeContext evalCtx = RuntimeCode.getEvalRuntimeContext(); if (evalCtx != null && evalCtx.runtimeValues() != null) { - // Find variable in captured environment - String[] capturedEnv = evalCtx.capturedEnv(); - Object[] runtimeValues = evalCtx.runtimeValues(); - - for (int i = 0; i < capturedEnv.length; i++) { - if (capturedEnv[i].equals(varName)) { - Object value = runtimeValues[i]; - if (value instanceof RuntimeBase) { - return (RuntimeBase) value; - } - } + // Eval runtime values are packed without the reserved registers + // (`this`, `@_`, `wantarray`), while capturedEnv retains them. + Object value = evalCtx.getRuntimeValue(varName); + if (value instanceof RuntimeBase) { + return (RuntimeBase) value; } } @@ -5656,16 +5693,25 @@ private void visitEvalBlock(SubroutineNode node) { // Track eval block nesting for "goto &sub from eval" detection evalBlockDepth++; + evalReturnTargetRegs.push(resultReg); + evalReturnGotoPatchPositions.push(new ArrayList<>()); // Compile the eval block body compileNode(node.block, resultReg, currentCallContext); + List returnGotoPatchPositions = evalReturnGotoPatchPositions.pop(); + evalReturnTargetRegs.pop(); evalBlockDepth--; if (lastResultReg >= 0) { emitAliasWithTarget(resultReg, lastResultReg); } + int evalEndPc = bytecode.size(); + for (int patchPos : returnGotoPatchPositions) { + patchIntOffset(patchPos, evalEndPc); + } + // Emit EVAL_END (clears $@) emit(Opcodes.EVAL_END); @@ -5789,6 +5835,29 @@ public void visit(For1Node node) { varReg = allocateRegister(); } + String lexicalLoopVarName = null; + boolean restoreLexicalLoopVar = false; + if (globalLoopVarName == null && node.variable instanceof OperatorNode lexicalVarOp) { + if (lexicalVarOp.operator.equals("my") && lexicalVarOp.operand instanceof OperatorNode sigilOp + && sigilOp.operator.equals("$") && sigilOp.operand instanceof IdentifierNode idNode) { + lexicalLoopVarName = "$" + idNode.name; + } else if (lexicalVarOp.operator.equals("$") && lexicalVarOp.operand instanceof IdentifierNode idNode) { + String varName = "$" + idNode.name; + if (hasVariable(varName) && !isOurVariable(varName)) { + lexicalLoopVarName = varName; + restoreLexicalLoopVar = true; + } + } + } + + int savedLexicalLoopVarReg = -1; + if (restoreLexicalLoopVar) { + savedLexicalLoopVarReg = allocateRegister(); + emit(Opcodes.ALIAS); + emitReg(savedLexicalLoopVarReg); + emitReg(varReg); + } + // Step 3b: For global loop variable: emit LOCAL_SCALAR_SAVE_LEVEL. // This atomically saves getLocalLevel() into levelReg (pre-push), then calls makeLocal. // POP_LOCAL_LEVEL(levelReg) after the loop correctly restores $_ for any nesting depth. @@ -5830,8 +5899,17 @@ public void visit(For1Node node) { loopInfo.cleanupScopeIndex = symbolTable.currentScopeIndex() + 1; // Step 8: Execute body - if (node.body != null) { - node.body.accept(this); + if (lexicalLoopVarName != null) { + pushForeachAliasLexical(lexicalLoopVarName); + } + try { + if (node.body != null) { + node.body.accept(this); + } + } finally { + if (lexicalLoopVarName != null) { + popForeachAliasLexical(lexicalLoopVarName); + } } // Step 9: Loop check (next/continue jumps here) - the superinstruction @@ -5872,6 +5950,11 @@ public void visit(For1Node node) { emit(Opcodes.POP_LOCAL_LEVEL); emitReg(levelReg); } + if (savedLexicalLoopVarReg >= 0) { + emit(Opcodes.ALIAS); + emitReg(varReg); + emitReg(savedLexicalLoopVarReg); + } // Step 12: Patch all last/next/redo jumps for (int pc : loopInfo.breakPcs) { diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java index 871b1caa0..47c638aa5 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java @@ -208,19 +208,18 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c java.util.ArrayDeque regexStateStack = new java.util.ArrayDeque<>(); - // Optimization: only save/restore DynamicVariableManager state if the code uses localization. - // This avoids overhead for simple subroutines that don't use `local`. + // Only ordinary localized variables are conditional. Regex capture variables + // ($1, $&, @-, etc.) are dynamically scoped for every subroutine call, even + // when the callee does not use `local`. boolean usesLocalization = code.usesLocalization; // Record DVM level so the finally block can clean up everything pushed // by this subroutine (local variables AND regex state snapshot). - int savedLocalLevel = usesLocalization ? DynamicVariableManager.getLocalLevel() : 0; + int savedLocalLevel = DynamicVariableManager.getLocalLevel(); // Cache the currentPackage RuntimeScalar to avoid ThreadLocal lookups in hot loop RuntimeScalar currentPackageScalar = InterpreterState.currentPackage.get(); String savedPackage = currentPackageScalar.toString(); currentPackageScalar.set(framePackageName); - if (usesLocalization) { - RegexState.save(); - } + RegexState.save(); // Track whether an exception is propagating out of this frame, so the // finally block can do scope-exit cleanup for blessed objects in my-variables. // Without this, DESTROY doesn't fire for objects in subroutines that are @@ -2253,7 +2252,7 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c Opcodes.SYSWRITE, Opcodes.SYSOPEN, Opcodes.SOCKET, Opcodes.BIND, Opcodes.CONNECT, Opcodes.LISTEN, Opcodes.PIPE, Opcodes.SOCKETPAIR, Opcodes.WRITE, Opcodes.FORMLINE, Opcodes.PRINTF, Opcodes.ACCEPT, - Opcodes.SYSSEEK, Opcodes.TRUNCATE, Opcodes.READ, Opcodes.OPENDIR, Opcodes.READDIR, + Opcodes.SYSSEEK, Opcodes.TRUNCATE, Opcodes.FLOCK, Opcodes.READ, Opcodes.OPENDIR, Opcodes.READDIR, Opcodes.SEEKDIR -> { pc = MiscOpcodeHandler.execute(opcode, bytecode, pc, registers); } @@ -2698,9 +2697,7 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c // Outer finally: restore interpreter state saved at method entry. // Unwinds all `local` variables pushed during this frame, restores // the current package, and pops the InterpreterState call stack. - if (usesLocalization) { - DynamicVariableManager.popToLocalLevel(savedLocalLevel); - } + DynamicVariableManager.popToLocalLevel(savedLocalLevel); currentPackageScalar.set(savedPackage); InterpreterState.pop(); // Release cached registers for reuse diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileAssignment.java b/src/main/java/org/perlonjava/backend/bytecode/CompileAssignment.java index 86321b489..7393cf601 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileAssignment.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileAssignment.java @@ -899,10 +899,13 @@ public static void compileAssignmentOperator(BytecodeCompiler bytecodeCompiler, int targetReg = bytecodeCompiler.getVariableRegister(varName); if ((bytecodeCompiler.capturedVarIndices != null && bytecodeCompiler.capturedVarIndices.containsKey(varName)) - || bytecodeCompiler.closureCapturedVarNames.contains(varName)) { - // Captured variable - use SET_SCALAR to preserve aliasing. + || bytecodeCompiler.closureCapturedVarNames.contains(varName) + || bytecodeCompiler.isForeachAliasLexical(varName)) { + // Captured variables and active foreach lexical aliases use + // SET_SCALAR to preserve the existing RuntimeScalar identity. // LOAD_UNDEF would replace the register with a new RuntimeScalar, - // breaking the shared reference that closures depend on. + // breaking the shared reference that closures and foreach + // element aliases depend on. bytecodeCompiler.emit(Opcodes.SET_SCALAR); bytecodeCompiler.emitReg(targetReg); bytecodeCompiler.emitReg(valueReg); diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java b/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java index f7ded8410..7bdd17fac 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java @@ -1027,6 +1027,11 @@ public static void visitOperator(BytecodeCompiler bytecodeCompiler, OperatorNode } int exprReg = bytecodeCompiler.lastResultReg; + if (bytecodeCompiler.shouldReturnFromInlineEvalBlock()) { + bytecodeCompiler.emitInlineEvalReturn(exprReg); + break; + } + // Emit scope exit cleanup for all my-scalars, my-hashes, and my-arrays // in the subroutine scope (scope 0). Explicit 'return' bypasses the // normal scope exit cleanup at block end, so we must do it here. diff --git a/src/main/java/org/perlonjava/backend/bytecode/MiscOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/MiscOpcodeHandler.java index d2f7691f5..0bc713d4a 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/MiscOpcodeHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/MiscOpcodeHandler.java @@ -77,6 +77,7 @@ public static int execute(int opcode, int[] bytecode, int pc, RuntimeBase[] regi case Opcodes.ACCEPT -> IOOperator.accept(ctx, argsArray); case Opcodes.SYSSEEK -> IOOperator.sysseek(ctx, argsArray); case Opcodes.TRUNCATE -> IOOperator.truncate(ctx, argsArray); + case Opcodes.FLOCK -> IOOperator.flock(ctx, argsArray); case Opcodes.READ -> IOOperator.read(ctx, argsArray); case Opcodes.CHOWN -> ChownOperator.chown(ctx, argsArray); case Opcodes.WAITPID -> WaitpidOperator.waitpid(ctx, argsArray); diff --git a/src/main/java/org/perlonjava/frontend/parser/OperatorParser.java b/src/main/java/org/perlonjava/frontend/parser/OperatorParser.java index 465ef16f2..24379c9f0 100644 --- a/src/main/java/org/perlonjava/frontend/parser/OperatorParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/OperatorParser.java @@ -1382,6 +1382,7 @@ static OperatorNode parseRequire(Parser parser) { } String fileName = NameNormalizer.moduleToFilename(moduleName); + GlobalVariable.ensurePackageStash(moduleName); operand = ListNode.makeList(new StringNode(fileName, parser.tokenIndex)); } else { // Check for the specific pattern: :: followed by identifier (which is invalid for require) diff --git a/src/main/java/org/perlonjava/runtime/io/PipeInputChannel.java b/src/main/java/org/perlonjava/runtime/io/PipeInputChannel.java index 38fcd7720..848b6d4ba 100644 --- a/src/main/java/org/perlonjava/runtime/io/PipeInputChannel.java +++ b/src/main/java/org/perlonjava/runtime/io/PipeInputChannel.java @@ -15,6 +15,7 @@ import static org.perlonjava.runtime.runtimetypes.GlobalVariable.getGlobalVariable; import static org.perlonjava.runtime.runtimetypes.RuntimeIO.handleIOException; import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.getScalarInt; +import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.scalarFalse; import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.scalarTrue; /** @@ -181,8 +182,9 @@ public RuntimeScalar close() { inputStream.close(); } - if (process != null && process.isAlive()) { - // Give the process a moment to terminate naturally + if (process != null) { + // Reap the child even if it has already exited so close() + // can return false for non-zero pipe status like Perl. try { exitCode = process.waitFor(); } catch (InterruptedException e) { @@ -196,7 +198,7 @@ public RuntimeScalar close() { getGlobalVariable("main::?").set(exitCode << 8); isEOF = true; - return scalarTrue; + return exitCode == 0 ? scalarTrue : scalarFalse; } catch (IOException e) { return handleIOException(e, "close pipe failed"); } @@ -389,4 +391,4 @@ private void copyPerlEnvToProcessBuilder(ProcessBuilder processBuilder) { // If we can't access %ENV, just use inherited environment (default behavior) } } -} \ No newline at end of file +} diff --git a/src/main/java/org/perlonjava/runtime/io/PipeOutputChannel.java b/src/main/java/org/perlonjava/runtime/io/PipeOutputChannel.java index f6976261b..f8a6b3973 100644 --- a/src/main/java/org/perlonjava/runtime/io/PipeOutputChannel.java +++ b/src/main/java/org/perlonjava/runtime/io/PipeOutputChannel.java @@ -15,6 +15,7 @@ import static org.perlonjava.runtime.runtimetypes.GlobalVariable.getGlobalVariable; import static org.perlonjava.runtime.runtimetypes.RuntimeIO.handleIOException; import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.getScalarInt; +import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.scalarFalse; import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.scalarTrue; /** @@ -286,8 +287,10 @@ public RuntimeScalar close() { writer.close(); } - // Wait for the process to complete - if (process != null && process.isAlive()) { + // Wait for the process to complete. waitFor() returns immediately + // for an already-exited child, which still needs to be reaped so + // close() can report the correct pipe status. + if (process != null) { try { exitCode = process.waitFor(); } catch (InterruptedException e) { @@ -300,7 +303,7 @@ public RuntimeScalar close() { getGlobalVariable("main::?").set(exitCode << 8); isClosed = true; - return scalarTrue; + return exitCode == 0 ? scalarTrue : scalarFalse; } catch (IOException e) { return handleIOException(e, "close pipe failed"); } @@ -447,4 +450,4 @@ private void copyPerlEnvToProcessBuilder(ProcessBuilder processBuilder) { // If we can't access %ENV, just use inherited environment (default behavior) } } -} \ No newline at end of file +} diff --git a/src/main/java/org/perlonjava/runtime/operators/ScalarGlobOperator.java b/src/main/java/org/perlonjava/runtime/operators/ScalarGlobOperator.java index 6627765d8..b66799d40 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ScalarGlobOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/ScalarGlobOperator.java @@ -141,7 +141,7 @@ static List globSinglePattern(ScalarGlobOperator scalarGlobOperator, Str // Check if any directory component contains glob wildcards // If so, use recursive glob expansion if (hasWildcardInDirectoryComponent(normalizedPattern)) { - globRecursive(scalarGlobOperator, normalizedPattern, patternIsAbsolute, results); + globRecursive(scalarGlobOperator, pattern, normalizedPattern, patternIsAbsolute, results); } else { // Original path: only the filename part has wildcards globSimple(scalarGlobOperator, pattern, normalizedPattern, patternIsAbsolute, results); @@ -204,8 +204,8 @@ private static void globSimple(ScalarGlobOperator scalarGlobOperator, String ori * For example, "t/*/*.t" expands the directory wildcard by listing matching * subdirectories, then matches "*.t" files within each. */ - private static void globRecursive(ScalarGlobOperator scalarGlobOperator, String normalizedPattern, - boolean patternIsAbsolute, List results) { + private static void globRecursive(ScalarGlobOperator scalarGlobOperator, String originalPattern, + String normalizedPattern, boolean patternIsAbsolute, List results) { // Split the pattern into path segments String[] segments = normalizedPattern.split("/", -1); @@ -218,7 +218,7 @@ private static void globRecursive(ScalarGlobOperator scalarGlobOperator, String // Absolute path: start from root if (normalizedPattern.startsWith("/")) { startDir = RuntimeIO.resolveFile("/"); - prefix = ""; + prefix = "/"; startSegment = 1; // Skip the empty segment before leading / } else { // Windows drive letter path like C:/... @@ -232,7 +232,8 @@ private static void globRecursive(ScalarGlobOperator scalarGlobOperator, String startSegment = 0; } - globRecursiveStep(scalarGlobOperator, startDir, segments, startSegment, prefix, results); + globRecursiveStep(scalarGlobOperator, startDir, segments, startSegment, prefix, + originalPattern, patternIsAbsolute, results); } /** @@ -240,6 +241,7 @@ private static void globRecursive(ScalarGlobOperator scalarGlobOperator, String */ private static void globRecursiveStep(ScalarGlobOperator scalarGlobOperator, File currentDir, String[] segments, int segmentIndex, String prefix, + String originalPattern, boolean patternIsAbsolute, List results) { if (segmentIndex >= segments.length) { return; @@ -251,15 +253,16 @@ private static void globRecursiveStep(ScalarGlobOperator scalarGlobOperator, Fil if (!ScalarGlobOperatorHelper.containsGlobChars(segment)) { // Literal segment - just descend File next = new File(currentDir, segment); - String newPrefix = prefix.isEmpty() ? segment : prefix + "/" + segment; + String newPrefix = appendPathPrefix(prefix, segment); if (isLastSegment) { if (next.exists()) { - results.add(newPrefix); + results.add(formatRecursiveResult(next, newPrefix, originalPattern, patternIsAbsolute)); } } else { if (next.isDirectory()) { - globRecursiveStep(scalarGlobOperator, next, segments, segmentIndex + 1, newPrefix, results); + globRecursiveStep(scalarGlobOperator, next, segments, segmentIndex + 1, newPrefix, + originalPattern, patternIsAbsolute, results); } } } else { @@ -289,18 +292,49 @@ private static void globRecursiveStep(ScalarGlobOperator scalarGlobOperator, Fil } if (regex.matcher(name).matches()) { - String newPrefix = prefix.isEmpty() ? name : prefix + "/" + name; + String newPrefix = appendPathPrefix(prefix, name); if (isLastSegment) { - results.add(newPrefix); + results.add(formatRecursiveResult(entry, newPrefix, originalPattern, patternIsAbsolute)); } else if (entry.isDirectory()) { - globRecursiveStep(scalarGlobOperator, entry, segments, segmentIndex + 1, newPrefix, results); + globRecursiveStep(scalarGlobOperator, entry, segments, segmentIndex + 1, newPrefix, + originalPattern, patternIsAbsolute, results); } } } } } + private static String formatRecursiveResult(File file, String relativePrefix, + String originalPattern, boolean patternIsAbsolute) { + if (patternIsAbsolute) { + String absPath = file.getAbsolutePath(); + if (File.separatorChar == '\\' && originalPattern.indexOf('/') >= 0) { + absPath = absPath.replace('\\', '/'); + } + return absPath; + } + + if (File.separatorChar == '\\' && originalPattern.indexOf('\\') >= 0 + && originalPattern.indexOf('/') < 0) { + return relativePrefix.replace('/', '\\'); + } + return relativePrefix; + } + + private static String appendPathPrefix(String prefix, String name) { + if (prefix.isEmpty()) { + return name; + } + if (prefix.equals("/")) { + return "/" + name; + } + if (prefix.endsWith("/")) { + return prefix + name; + } + return prefix + "/" + name; + } + /** * Initializes the iterator with results from the given pattern. * diff --git a/src/main/java/org/perlonjava/runtime/operators/Time.java b/src/main/java/org/perlonjava/runtime/operators/Time.java index 5ad6c8102..087598c8d 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Time.java +++ b/src/main/java/org/perlonjava/runtime/operators/Time.java @@ -87,9 +87,10 @@ public static RuntimeList times(int ctx) { * @return a RuntimeList containing formatted local time components. */ public static RuntimeList localtime(RuntimeList args, int ctx) { + ZoneId localZone = localZoneId(); ZonedDateTime date; if (args.isEmpty()) { - date = ZonedDateTime.now(); + date = ZonedDateTime.now(localZone); } else { double dval = args.getFirst().getDouble(); if (Double.isNaN(dval) || Double.isInfinite(dval)) { @@ -97,7 +98,7 @@ public static RuntimeList localtime(RuntimeList args, int ctx) { } long arg = args.getFirst().getLong(); try { - date = Instant.ofEpochSecond(arg).atZone(ZoneId.systemDefault()); + date = Instant.ofEpochSecond(arg).atZone(localZone); } catch (DateTimeException e) { emitTimeOverflowWarnings("localtime", arg); return returnUndefOrEmptyList(ctx); @@ -133,6 +134,81 @@ public static RuntimeList gmtime(RuntimeList args, int ctx) { return getTimeComponents(ctx, date); } + private static ZoneId localZoneId() { + RuntimeScalar tzScalar = getGlobalHash("main::ENV").elements.get("TZ"); + String tz = tzScalar != null && tzScalar.getDefinedBoolean() + ? tzScalar.toString() + : System.getenv("TZ"); + if (tz == null || tz.isEmpty()) { + return ZoneId.systemDefault(); + } + + ZoneOffset posixOffset = parsePosixTzOffset(tz); + if (posixOffset != null) { + return posixOffset; + } + + try { + return ZoneId.of(tz); + } catch (DateTimeException ignored) { + return ZoneId.systemDefault(); + } + } + + private static ZoneOffset parsePosixTzOffset(String tz) { + String s = tz.trim(); + if (s.equals("UTC") || s.equals("GMT")) { + return ZoneOffset.UTC; + } + if (!(s.startsWith("UTC") || s.startsWith("GMT")) || s.length() < 5) { + return null; + } + + char sign = s.charAt(3); + if (sign != '+' && sign != '-') { + return null; + } + int pos = 4; + int hourStart = pos; + while (pos < s.length() && Character.isDigit(s.charAt(pos))) { + pos++; + } + if (pos == hourStart) { + return null; + } + + int hours; + int minutes = 0; + try { + hours = Integer.parseInt(s.substring(hourStart, pos)); + if (pos < s.length()) { + if (s.charAt(pos) == ':') { + pos++; + } + int minuteStart = pos; + while (pos < s.length() && Character.isDigit(s.charAt(pos)) && pos - minuteStart < 2) { + pos++; + } + if (pos > minuteStart) { + minutes = Integer.parseInt(s.substring(minuteStart, pos)); + } + } + } catch (NumberFormatException e) { + return null; + } + + if (hours > 18 || minutes > 59) { + return null; + } + + int totalSeconds = hours * 3600 + minutes * 60; + // POSIX TZ signs are inverted: UTC+9 means local time is UTC-09:00. + if (sign == '+') { + totalSeconds = -totalSeconds; + } + return ZoneOffset.ofTotalSeconds(totalSeconds); + } + // Perl's scalar gmtime/localtime returns ctime(3) format: "Sun Jan 1 00:00:00 1970" // Do NOT use DateTimeFormatter.RFC_1123_DATE_TIME — it produces "Sun, 1 Jan 1970 00:00:00 GMT" // which has wrong field order/format, and crashes with DateTimeException for years > 4 digits. diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java index 6ee1f8604..6f2748728 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java @@ -287,11 +287,32 @@ public static RuntimeList rootdir(RuntimeArray args, int ctx) { * @return A {@link RuntimeList} containing the temporary directory path. */ public static RuntimeList tmpdir(RuntimeArray args, int ctx) { - String tmpDir = System.getenv("TMPDIR"); - if (tmpDir == null || tmpDir.isEmpty()) { - tmpDir = SystemUtils.osIsWindows() ? System.getenv("TEMP") : "/tmp"; + RuntimeHash perlEnv = GlobalVariable.getGlobalHash("main::ENV"); + List candidates = new ArrayList<>(); + if (SystemUtils.osIsWindows()) { + candidates.add(perlEnvValue(perlEnv, "TMPDIR", System.getenv("TMPDIR"))); + candidates.add(perlEnvValue(perlEnv, "TEMP", System.getenv("TEMP"))); + candidates.add(perlEnvValue(perlEnv, "TMP", System.getenv("TMP"))); + } else { + candidates.add(perlEnvValue(perlEnv, "TMPDIR", System.getenv("TMPDIR"))); + candidates.add("/tmp"); } - return new RuntimeScalar(tmpDir).getList(); + + for (String candidate : candidates) { + if (candidate == null || candidate.isEmpty()) { + continue; + } + File dir = new File(candidate); + if (dir.isDirectory() && dir.canWrite()) { + return new RuntimeScalar(candidate).getList(); + } + } + return new RuntimeScalar(".").getList(); + } + + private static String perlEnvValue(RuntimeHash perlEnv, String key, String fallback) { + RuntimeScalar value = perlEnv.elements.get(key); + return value != null && value.getDefinedBoolean() ? value.toString() : fallback; } /** diff --git a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java index 7e39af73f..4d412f62f 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java +++ b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java @@ -70,6 +70,8 @@ protected boolean removeEldestEntry(Map.Entry eldest) { // Track whether the last successful match was on a BYTE_STRING input, // so that captures ($1, $2, $&, etc.) preserve BYTE_STRING type. public static boolean lastMatchWasByteString = false; + public static int[] manualCaptureStarts = null; + public static int[] manualCaptureEnds = null; // Compiled regex pattern (for byte strings - ASCII-only \w, \d) public Pattern pattern; // Compiled regex pattern for Unicode strings (Unicode \w, \d) @@ -1100,6 +1102,16 @@ private static RuntimeBase matchRegexDirect(RuntimeScalar quotedRegex, RuntimeSc posScalar = RuntimePosLvalue.pos(string); isPosDefined = posScalar.getDefinedBoolean(); startPos = isPosDefined ? posScalar.getInt() : 0; + + RuntimeBase fastXmlElementResult = tryFastXmlElementScan(regex, string, inputStr, startPos, posScalar, ctx); + if (fastXmlElementResult != null) { + return fastXmlElementResult; + } + + RuntimeBase fastXmpResult = tryFastXmpMetaElementScan(regex, string, inputStr, startPos, posScalar, ctx); + if (fastXmpResult != null) { + return fastXmpResult; + } // Check if previous call had zero-length match at this position (for SCALAR context) // This prevents infinite loops in: while ($str =~ /pat/g) @@ -1219,6 +1231,8 @@ private static RuntimeBase matchRegexDirect(RuntimeScalar quotedRegex, RuntimeSc // Always initialize $1, $2, @+, @-, $`, $&, $' for every successful match globalMatcher = matcher; + manualCaptureStarts = null; + manualCaptureEnds = null; globalMatchString = inputStr; lastMatchUsedBackslashK = regex.hasBackslashK; updateLastNamedCaptureGroups(matcher); @@ -1417,6 +1431,287 @@ private static RuntimeBase matchRegexDirect(RuntimeScalar quotedRegex, RuntimeSc } } + /** + * Avoid Java Pattern recursion for ExifTool's XMP element scan: + * {@code <(/?)x:xmpmeta([-\w:.\x80-\xff]*)(.*?(/?))>}. + * + *

On Google extended-XMP payloads, Java's backtracking engine can + * recurse deeply enough to throw {@link StackOverflowError}, while Perl + * handles the scan iteratively. This narrow path preserves the captures + * ExifTool uses while keeping other regexes on the normal engine.

+ */ + private static RuntimeBase tryFastXmpMetaElementScan(RuntimeRegex regex, + RuntimeScalar string, + String inputStr, + int startPos, + RuntimeScalar posScalar, + int ctx) { + if (ctx == RuntimeContextType.LIST || regex.regexFlags == null) { + return null; + } + if (!isXmpMetaElementScanPattern(regex)) { + return null; + } + + int tagStart = findXmpMetaTagStart(inputStr, startPos); + if (tagStart < 0) { + if (regex.regexFlags.isGlobalMatch() && !regex.regexFlags.keepCurrentPosition() && posScalar != null) { + posScalar.set(scalarUndef); + } + globalMatchString = null; + lastMatchedString = null; + lastMatchStart = -1; + lastMatchEnd = -1; + manualCaptureStarts = null; + manualCaptureEnds = null; + return ctx == RuntimeContextType.SCALAR ? RuntimeScalarCache.scalarFalse : scalarUndef; + } + + boolean closing = inputStr.charAt(tagStart + 1) == '/'; + int literalStart = tagStart + (closing ? 2 : 1); + int nameEnd = literalStart + "x:xmpmeta".length(); + int suffixEnd = nameEnd; + while (suffixEnd < inputStr.length() && isXmpMetaNameSuffixChar(inputStr.charAt(suffixEnd))) { + suffixEnd++; + } + int tagEnd = inputStr.indexOf('>', suffixEnd); + if (tagEnd < 0) { + if (regex.regexFlags.isGlobalMatch() && !regex.regexFlags.keepCurrentPosition() && posScalar != null) { + posScalar.set(scalarUndef); + } + globalMatchString = null; + lastMatchedString = null; + lastMatchStart = -1; + lastMatchEnd = -1; + manualCaptureStarts = null; + manualCaptureEnds = null; + return ctx == RuntimeContextType.SCALAR ? RuntimeScalarCache.scalarFalse : scalarUndef; + } + + String group1 = closing ? "/" : ""; + String group2 = inputStr.substring(nameEnd, suffixEnd); + String group3 = inputStr.substring(suffixEnd, tagEnd); + String group4 = group3.endsWith("/") ? "/" : ""; + + lastMatchWasByteString = (string.type == RuntimeScalarType.BYTE_STRING); + globalMatcher = null; + globalMatchString = inputStr; + lastMatchUsedBackslashK = false; + lastNamedCaptureGroups = new LinkedHashMap<>(); + lastCaptureGroups = new String[]{group1, group2, group3, group4}; + manualCaptureStarts = new int[]{ + closing ? tagStart + 1 : literalStart, + nameEnd, + suffixEnd, + group4.equals("/") ? tagEnd - 1 : tagEnd + }; + manualCaptureEnds = new int[]{ + closing ? tagStart + 2 : literalStart, + suffixEnd, + tagEnd, + group4.equals("/") ? tagEnd : tagEnd + }; + lastMatchedString = inputStr.substring(tagStart, tagEnd + 1); + lastMatchStart = tagStart; + lastMatchEnd = tagEnd + 1; + lastMatchUsedPFlag = regex.hasPreservesMatch; + lastSuccessfulPattern = regex; + lastSuccessfulMatchedString = lastMatchedString; + lastSuccessfulMatchStart = lastMatchStart; + lastSuccessfulMatchEnd = lastMatchEnd; + lastSuccessfulMatchString = globalMatchString; + regex.matched = true; + + if (regex.regexFlags.isGlobalMatch() && posScalar != null) { + posScalar.set(lastMatchEnd); + RuntimePosLvalue.recordNonZeroLengthMatch(string); + } + + return ctx == RuntimeContextType.SCALAR ? RuntimeScalarCache.scalarTrue : scalarUndef; + } + + private static boolean isXmpMetaElementScanPattern(RuntimeRegex regex) { + String perlPattern = "<(/?)x:xmpmeta([-\\w:.\\x80-\\xff]*)(.*?(/?))>"; + if (perlPattern.equals(regex.patternString)) { + return true; + } + return regex.pattern != null && perlPattern.equals(regex.pattern.pattern()); + } + + /** + * Avoid Java Pattern recursion for ExifTool's generic XML element scan: + * {@code <([?/]?)([-\w:.\x80-\xff]+|!--)([^>]*)>}. + */ + private static RuntimeBase tryFastXmlElementScan(RuntimeRegex regex, + RuntimeScalar string, + String inputStr, + int startPos, + RuntimeScalar posScalar, + int ctx) { + if (ctx == RuntimeContextType.LIST || regex.regexFlags == null) { + return null; + } + if (!isXmlElementScanPattern(regex)) { + return null; + } + + XmlElementMatch match = findXmlElement(inputStr, startPos); + if (match == null) { + if (regex.regexFlags.isGlobalMatch() && !regex.regexFlags.keepCurrentPosition() && posScalar != null) { + posScalar.set(scalarUndef); + } + globalMatchString = null; + lastMatchedString = null; + lastMatchStart = -1; + lastMatchEnd = -1; + manualCaptureStarts = null; + manualCaptureEnds = null; + return ctx == RuntimeContextType.SCALAR ? RuntimeScalarCache.scalarFalse : scalarUndef; + } + + String group1 = inputStr.substring(match.group1Start, match.group1End); + String group2 = inputStr.substring(match.group2Start, match.group2End); + String group3 = inputStr.substring(match.group3Start, match.group3End); + + lastMatchWasByteString = (string.type == RuntimeScalarType.BYTE_STRING); + globalMatcher = null; + globalMatchString = inputStr; + lastMatchUsedBackslashK = false; + lastNamedCaptureGroups = new LinkedHashMap<>(); + lastCaptureGroups = new String[]{group1, group2, group3}; + manualCaptureStarts = new int[]{match.group1Start, match.group2Start, match.group3Start}; + manualCaptureEnds = new int[]{match.group1End, match.group2End, match.group3End}; + lastMatchedString = inputStr.substring(match.matchStart, match.matchEnd); + lastMatchStart = match.matchStart; + lastMatchEnd = match.matchEnd; + lastMatchUsedPFlag = regex.hasPreservesMatch; + lastSuccessfulPattern = regex; + lastSuccessfulMatchedString = lastMatchedString; + lastSuccessfulMatchStart = lastMatchStart; + lastSuccessfulMatchEnd = lastMatchEnd; + lastSuccessfulMatchString = globalMatchString; + regex.matched = true; + + if (regex.regexFlags.isGlobalMatch() && posScalar != null) { + posScalar.set(lastMatchEnd); + RuntimePosLvalue.recordNonZeroLengthMatch(string); + } + + return ctx == RuntimeContextType.SCALAR ? RuntimeScalarCache.scalarTrue : scalarUndef; + } + + private static boolean isXmlElementScanPattern(RuntimeRegex regex) { + String perlPattern = "<([?/]?)([-\\w:.\\x80-\\xff]+|!--)([^>]*)>"; + if (perlPattern.equals(regex.patternString)) { + return true; + } + return regex.pattern != null && perlPattern.equals(regex.pattern.pattern()); + } + + private static XmlElementMatch findXmlElement(String inputStr, int startPos) { + int search = Math.max(0, startPos); + while (search < inputStr.length()) { + int tagStart = inputStr.indexOf('<', search); + if (tagStart < 0 || tagStart + 1 >= inputStr.length()) { + return null; + } + + int index = tagStart + 1; + int group1Start = index; + int group1End = index; + char marker = inputStr.charAt(index); + if (marker == '?' || marker == '/') { + group1End = ++index; + } + + int group2Start = index; + int group2End; + if (index + 3 <= inputStr.length() && inputStr.startsWith("!--", index)) { + group2End = index + 3; + } else { + while (index < inputStr.length() && isXmlElementNameChar(inputStr.charAt(index))) { + index++; + } + group2End = index; + if (group2End == group2Start) { + search = tagStart + 1; + continue; + } + } + + int group3Start = group2End; + int tagEnd = inputStr.indexOf('>', group3Start); + if (tagEnd < 0) { + return null; + } + + return new XmlElementMatch(tagStart, tagEnd + 1, + group1Start, group1End, + group2Start, group2End, + group3Start, tagEnd); + } + return null; + } + + private static boolean isXmlElementNameChar(char ch) { + return ch == '-' || ch == ':' || ch == '.' + || ch == '_' || Character.isLetterOrDigit(ch) + || (ch >= 0x80 && ch <= 0xff); + } + + private static class XmlElementMatch { + final int matchStart; + final int matchEnd; + final int group1Start; + final int group1End; + final int group2Start; + final int group2End; + final int group3Start; + final int group3End; + + XmlElementMatch(int matchStart, int matchEnd, + int group1Start, int group1End, + int group2Start, int group2End, + int group3Start, int group3End) { + this.matchStart = matchStart; + this.matchEnd = matchEnd; + this.group1Start = group1Start; + this.group1End = group1End; + this.group2Start = group2Start; + this.group2End = group2End; + this.group3Start = group3Start; + this.group3End = group3End; + } + } + + private static int findXmpMetaTagStart(String inputStr, int startPos) { + int search = Math.max(0, startPos); + while (search < inputStr.length()) { + int literal = inputStr.indexOf("x:xmpmeta", search); + if (literal < 0) { + return -1; + } + int openStart = literal - 1; + if (openStart >= startPos && openStart >= 0 && inputStr.charAt(openStart) == '<') { + return openStart; + } + int closeStart = literal - 2; + if (closeStart >= startPos && closeStart >= 0 + && inputStr.charAt(closeStart) == '<' + && inputStr.charAt(closeStart + 1) == '/') { + return closeStart; + } + search = literal + 1; + } + return -1; + } + + private static boolean isXmpMetaNameSuffixChar(char ch) { + return ch == '-' || ch == ':' || ch == '.' + || ch == '_' || Character.isLetterOrDigit(ch) + || (ch >= 0x80 && ch <= 0xff); + } + /** * Regex matching with timeout wrapper to handle catastrophic backtracking. * Runs the regex in a separate thread with a timeout. @@ -1785,13 +2080,15 @@ public static void initialize() { lastMatchUsedPFlag = false; lastCaptureGroups = null; lastNamedCaptureGroups = null; + manualCaptureStarts = null; + manualCaptureEnds = null; // Reset regex cache matched flags reset(); } public static String matchString() { - if (globalMatcher != null && lastMatchedString != null) { + if (lastMatchedString != null) { // Current match data available return lastMatchedString; } @@ -1799,7 +2096,7 @@ public static String matchString() { } public static String preMatchString() { - if (globalMatcher != null && globalMatchString != null && lastMatchStart != -1) { + if (globalMatchString != null && lastMatchStart != -1) { // Current match data available String result = globalMatchString.substring(0, lastMatchStart); return result; @@ -1808,7 +2105,7 @@ public static String preMatchString() { } public static String postMatchString() { - if (globalMatcher != null && globalMatchString != null && lastMatchEnd != -1) { + if (globalMatchString != null && lastMatchEnd != -1) { // Current match data available String result = globalMatchString.substring(lastMatchEnd); return result; @@ -1861,6 +2158,10 @@ public static RuntimeScalar matcherStart(int group) { return lastMatchStart >= 0 ? getScalarInt(lastMatchStart) : scalarUndef; } if (globalMatcher == null) { + if (manualCaptureStarts != null && group > 0 && group <= manualCaptureStarts.length) { + int start = manualCaptureStarts[group - 1]; + return start >= 0 ? getScalarInt(start) : scalarUndef; + } return scalarUndef; } try { @@ -1884,6 +2185,10 @@ public static RuntimeScalar matcherEnd(int group) { return lastMatchEnd >= 0 ? getScalarInt(lastMatchEnd) : scalarUndef; } if (globalMatcher == null) { + if (manualCaptureEnds != null && group > 0 && group <= manualCaptureEnds.length) { + int end = manualCaptureEnds[group - 1]; + return end >= 0 ? getScalarInt(end) : scalarUndef; + } return scalarUndef; } try { @@ -1904,6 +2209,9 @@ public static RuntimeScalar matcherEnd(int group) { public static int matcherSize() { if (globalMatcher == null) { + if (manualCaptureStarts != null) { + return manualCaptureStarts.length + 1; + } return 0; } int size = globalMatcher.groupCount(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java index 35e1bec3c..1964c1611 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java @@ -294,6 +294,8 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { // JavaSystem.initialize(); // Only for java:: integration PerlIO.initialize(); IOHandle.initialize(); // IO::Handle methods (_sync, _error, etc.) + GlobalVariable.ensurePackageStash("IO::File"); + GlobalVariable.ensurePackageStash("IO::Seekable"); Version.initialize(); // Initialize version module for version objects Attributes.initialize(); // attributes:: XS-equivalent functions (used by attributes.pm) // DBI JDBC backend: with the switch to upstream DBI.pm + DBI::PurePerl, diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalDestruction.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalDestruction.java index 061fe5a63..2ecc998e2 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalDestruction.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalDestruction.java @@ -1,6 +1,9 @@ package org.perlonjava.runtime.runtimetypes; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; /** * Handles global destruction at program exit. @@ -20,6 +23,8 @@ public class GlobalDestruction { public static void runGlobalDestruction() { // Set ${^GLOBAL_PHASE} to "DESTRUCT" GlobalVariable.getGlobalVariable(GlobalContext.GLOBAL_PHASE).set("DESTRUCT"); + Set visited = + Collections.newSetFromMap(new IdentityHashMap<>()); // Snapshot the collections before iterating: a DESTROY callback may // mutate GlobalVariable.{globalVariables,globalArrays,globalHashes} @@ -31,7 +36,7 @@ public static void runGlobalDestruction() { // Walk all global scalars for (RuntimeScalar val : new ArrayList<>(GlobalVariable.globalVariables.values())) { - destroyIfTracked(val); + destroyIfTracked(val, visited); } // Walk global arrays for blessed ref elements @@ -42,7 +47,7 @@ public static void runGlobalDestruction() { // destruction time (e.g., broken ties from eval+last). if (arr.type == RuntimeArray.TIED_ARRAY) continue; for (RuntimeScalar elem : new ArrayList<>(arr.elements)) { - destroyIfTracked(elem); + destroyIfTracked(elem, visited); } } @@ -53,7 +58,7 @@ public static void runGlobalDestruction() { // NEXTKEY/FETCH which may fail if the tie object is already gone. if (hash.type == RuntimeHash.TIED_HASH) continue; for (RuntimeScalar elem : new ArrayList<>(hash.elements.values())) { - destroyIfTracked(elem); + destroyIfTracked(elem, visited); } } } @@ -61,13 +66,33 @@ public static void runGlobalDestruction() { /** * Call DESTROY on a scalar if it holds a tracked blessed reference. */ - private static void destroyIfTracked(RuntimeScalar val) { + private static void destroyIfTracked(RuntimeScalar val, Set visited) { if (val != null && (val.type & RuntimeScalarType.REFERENCE_BIT) != 0 && val.value instanceof RuntimeBase base && base.refCount >= 0) { + destroyBaseIfTracked(base, visited); + } + } + + private static void destroyBaseIfTracked(RuntimeBase base, Set visited) { + if (base == null || base.refCount < 0 || !visited.add(base)) { + return; + } + if (base.blessId != 0 || WeakRefRegistry.hasWeakRefsTo(base) || base instanceof RuntimeCode) { base.refCount = Integer.MIN_VALUE; DestroyDispatch.callDestroy(base); + return; + } + if (base instanceof RuntimeArray arr && MortalList.containerMayContainCleanupTargets(base)) { + for (RuntimeScalar elem : new ArrayList<>(arr.elements)) { + destroyIfTracked(elem, visited); + } + } else if (base instanceof RuntimeHash hash && MortalList.containerMayContainCleanupTargets(base)) { + for (RuntimeScalar elem : new ArrayList<>(hash.elements.values())) { + destroyIfTracked(elem, visited); + } } } + } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java index 6c2e7494d..8c5479177 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/MortalList.java @@ -699,7 +699,7 @@ && isReachableFromExternalRootCached(base)))) { } } - private static boolean containerMayContainCleanupTargets(RuntimeBase base) { + static boolean containerMayContainCleanupTargets(RuntimeBase base) { if (!(base instanceof RuntimeArray || base instanceof RuntimeHash)) return false; ArrayDeque work = new ArrayDeque<>(); Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RegexState.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RegexState.java index 3cdb57528..beb33ab25 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RegexState.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RegexState.java @@ -29,6 +29,8 @@ public class RegexState implements DynamicState { private final String[] lastCaptureGroups; private final Map> lastNamedCaptureGroups; private final boolean lastMatchWasByteString; + private final int[] manualCaptureStarts; + private final int[] manualCaptureEnds; public RegexState() { this.globalMatcher = RuntimeRegex.globalMatcher; @@ -45,6 +47,8 @@ public RegexState() { this.lastCaptureGroups = RuntimeRegex.lastCaptureGroups; this.lastNamedCaptureGroups = RuntimeRegex.lastNamedCaptureGroups; this.lastMatchWasByteString = RuntimeRegex.lastMatchWasByteString; + this.manualCaptureStarts = RuntimeRegex.manualCaptureStarts; + this.manualCaptureEnds = RuntimeRegex.manualCaptureEnds; } public static void save() { @@ -75,5 +79,7 @@ public void dynamicRestoreState() { RuntimeRegex.lastCaptureGroups = this.lastCaptureGroups; RuntimeRegex.lastNamedCaptureGroups = this.lastNamedCaptureGroups; RuntimeRegex.lastMatchWasByteString = this.lastMatchWasByteString; + RuntimeRegex.manualCaptureStarts = this.manualCaptureStarts; + RuntimeRegex.manualCaptureEnds = this.manualCaptureEnds; } } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBaseProxy.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBaseProxy.java index 42a57cad0..7c4463974 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBaseProxy.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBaseProxy.java @@ -132,6 +132,32 @@ public RuntimeArray arrayDeref() { return result; } + @Override + public RuntimeScalar scalarDeref() { + if (this instanceof RuntimeScalarReadOnly || this instanceof ScalarSpecialVariable) { + return super.scalarDeref(); + } + vivify(); + RuntimeScalar result = lvalue.scalarDeref(); + this.type = lvalue.type; + this.value = lvalue.value; + return result; + } + + @Override + public RuntimeScalar scalarDerefNonStrict(String packageName) { + if (this instanceof RuntimeScalarReadOnly || this instanceof ScalarSpecialVariable) { + return super.scalarDerefNonStrict(packageName); + } + vivify(); + RuntimeScalar result = lvalue.type == RuntimeScalarType.UNDEF + ? lvalue.scalarDeref() + : lvalue.scalarDerefNonStrict(packageName); + this.type = lvalue.type; + this.value = lvalue.value; + return result; + } + @Override public RuntimeArray arrayDerefNonStrict(String packageName) { vivify(); // Ensure the scalar exists in parent hash/array diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 0f9370965..219601cf0 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -1025,6 +1025,30 @@ private static RuntimeScalar resolveDirectCallTarget(RuntimeScalar runtimeScalar return GlobalVariable.getLocalizedCodeRefForDirectCall(lookupName, runtimeScalar); } + private static String knownUndefinedSubroutineName(RuntimeScalar runtimeScalar, String subroutineName) { + if (runtimeScalar != null + && runtimeScalar.globalCodeRefFqn != null + && !runtimeScalar.globalCodeRefFqn.isEmpty()) { + return runtimeScalar.globalCodeRefFqn; + } + if (subroutineName != null && !subroutineName.isEmpty() && !"tailcall".equals(subroutineName)) { + return subroutineName; + } + return null; + } + + private static RuntimeList undefCodeRefResultOrThrow(RuntimeScalar runtimeScalar, String subroutineName, int callContext) { + String fullSubName = knownUndefinedSubroutineName(runtimeScalar, subroutineName); + if (fullSubName != null) { + throw new PerlCompilerException(gotoErrorPrefix(subroutineName) + + "ndefined subroutine &" + fullSubName + " called"); + } + if (RuntimeContextType.isListLike(callContext)) { + return new RuntimeList(); + } + return new RuntimeList(new RuntimeScalar()); + } + private static RuntimeScalar resolveLateDefinedForwardCodeRef(RuntimeScalar current, RuntimeCode code) { if (code == null || code.defined() @@ -1066,6 +1090,11 @@ public static void throwIfDirectCallUndefined(RuntimeScalar runtimeScalar, Strin continue; } if (curScalar.type == RuntimeScalarType.UNDEF) { + String fullSubName = knownUndefinedSubroutineName(curScalar, subroutineName); + if (fullSubName != null) { + throw new PerlCompilerException(gotoErrorPrefix(subroutineName) + + "ndefined subroutine &" + fullSubName + " called"); + } return; } @@ -3960,12 +3989,7 @@ public static RuntimeList apply(RuntimeScalar runtimeScalar, String subroutineNa // silently return undef so tests can continue running. // This is a temporary workaround for the architectural limitation that eval // contexts are captured at compile time. if (runtimeScalar.type == RuntimeScalarType.UNDEF) { - // Return undef in appropriate context - if (RuntimeContextType.isListLike(callContext)) { - return new RuntimeList(); - } else { - return new RuntimeList(new RuntimeScalar()); - } + return undefCodeRefResultOrThrow(runtimeScalar, subroutineName, callContext); } // Check if the type of this RuntimeScalar is CODE @@ -4225,12 +4249,7 @@ private static RuntimeList applyImpl(RuntimeScalar runtimeScalar, String subrout // This is a temporary workaround for the architectural limitation that eval // contexts are captured at compile time. if (runtimeScalar.type == RuntimeScalarType.UNDEF) { - // Return undef in appropriate context - if (RuntimeContextType.isListLike(callContext)) { - return new RuntimeList(); - } else { - return new RuntimeList(new RuntimeScalar()); - } + return undefCodeRefResultOrThrow(runtimeScalar, subroutineName, callContext); } // Check if the type of this RuntimeScalar is CODE diff --git a/src/main/perl/lib/CPAN/Config.pm b/src/main/perl/lib/CPAN/Config.pm index 255065747..2d15b6daa 100644 --- a/src/main/perl/lib/CPAN/Config.pm +++ b/src/main/perl/lib/CPAN/Config.pm @@ -59,6 +59,7 @@ sub _bootstrap_prefs { 'IPC-Run3.yml' => 'PerlOnJava/CpanDistroprefs/IPC-Run3.yml', 'Exception-Class.yml' => 'PerlOnJava/CpanDistroprefs/Exception-Class.yml', 'Module-Pluggable.yml' => 'PerlOnJava/CpanDistroprefs/Module-Pluggable.yml', + 'Module-Pluggable-Ordered.yml' => 'PerlOnJava/CpanDistroprefs/Module-Pluggable-Ordered.yml', 'Object-Event.yml' => 'PerlOnJava/CpanDistroprefs/Object-Event.yml', 'Path-Tiny.yml' => 'PerlOnJava/CpanDistroprefs/Path-Tiny.yml', 'Test2-Plugin-NoWarnings.yml' => 'PerlOnJava/CpanDistroprefs/Test2-Plugin-NoWarnings.yml', @@ -220,6 +221,8 @@ sub _bootstrap_patches { 'PerlOnJava/CpanPatches/Error-Pure-0.34/PlainLexicalConstants.patch' ], [ 'String-ShellQuote-1.04/SkipForkScriptTests.patch', 'PerlOnJava/CpanPatches/String-ShellQuote-1.04/SkipForkScriptTests.patch' ], + [ 'Module-Pluggable-Ordered-1.5/LimitFixturePlugins.patch', + 'PerlOnJava/CpanPatches/Module-Pluggable-Ordered-1.5/LimitFixturePlugins.patch' ], [ 'LWP-Protocol-https-6.15/SkipForkProxyTest.patch', 'PerlOnJava/CpanPatches/LWP-Protocol-https-6.15/SkipForkProxyTest.patch' ], [ 'Type-Tiny-2.010001/SkipRegexCallbackTests.patch', diff --git a/src/main/perl/lib/Config.pm b/src/main/perl/lib/Config.pm index 38787a080..e9b3a2c14 100644 --- a/src/main/perl/lib/Config.pm +++ b/src/main/perl/lib/Config.pm @@ -96,6 +96,12 @@ sub _perl_launcher_suffix { return '.bat'; } +sub _shell_single_quote { + my ($value) = @_; + $value =~ s/'/'"'"'/g; + return "'$value'"; +} + # Best-effort hostname; falls back to "localhost" if Java doesn't expose it. my $host_name = eval { require Sys::Hostname; @@ -135,6 +141,12 @@ my $system_cc = do { $os_name = _perl_os_name($os_name); my $is_windows = $os_name eq 'MSWin32'; my $perl_launcher_suffix = _perl_launcher_suffix($is_windows, $^X); +my $startperl = $is_windows + ? '#!' . $^X + : "#!/bin/sh\n" + . 'eval "exec ' . _shell_single_quote($^X) . ' -x \"\$0\" \"\$@\""' . "\n" + . " if 0;\n" + . "#!perl"; # tie returns the object, so the value returned to require will be true. %Config = ( @@ -301,7 +313,7 @@ my $perl_launcher_suffix = _perl_launcher_suffix($is_windows, $^X); exe_ext => $is_windows ? '.exe' : '', _exe => $perl_launcher_suffix, perlpath => $^X, # Path to the perl interpreter (jperl) - startperl => '#!' . $^X, # Shebang line for Perl scripts + startperl => $startperl, # Shebang line for Perl scripts sharpbang => '#!', # Shebang prefix eunicefix => ':', # No-op fixer (only used on EUNICE) diff --git a/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Module-Pluggable-Ordered.yml b/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Module-Pluggable-Ordered.yml new file mode 100644 index 000000000..c6cb99237 --- /dev/null +++ b/src/main/perl/lib/PerlOnJava/CpanDistroprefs/Module-Pluggable-Ordered.yml @@ -0,0 +1,13 @@ +--- +comment: | + PerlOnJava distroprefs for Module::Pluggable::Ordered. + + The upstream tests use a Foo::* fixture while Module::Pluggable + intentionally searches configured search_dirs before the rest of @INC. + PerlOnJava's CPAN home can contain unrelated installed Foo::* sample + modules from other distributions, so patch the tests to constrain the + fixture plugins with the module's own only option. +match: + distribution: "^APEIRON/Module-Pluggable-Ordered-1\\.5" +patches: + - "Module-Pluggable-Ordered-1.5/LimitFixturePlugins.patch" diff --git a/src/main/perl/lib/PerlOnJava/CpanPatches/Module-Pluggable-Ordered-1.5/LimitFixturePlugins.patch b/src/main/perl/lib/PerlOnJava/CpanPatches/Module-Pluggable-Ordered-1.5/LimitFixturePlugins.patch new file mode 100644 index 000000000..d3912fbf5 --- /dev/null +++ b/src/main/perl/lib/PerlOnJava/CpanPatches/Module-Pluggable-Ordered-1.5/LimitFixturePlugins.patch @@ -0,0 +1,26 @@ +--- t/1.t.orig ++++ t/1.t +@@ -5,7 +5,8 @@ + search_path => ['Foo'], +- sub_name => "test_plugins"; ++ sub_name => "test_plugins", ++ only => ["Foo::One", "Foo::Two", "Foo::Three"]; + ok(eq_set([Foo->test_plugins], + ["Foo::One", "Foo::Two", "Foo::Three"]), "We have three test plugins"); +--- t/2.t.orig ++++ t/2.t +@@ -5,7 +5,8 @@ + search_path => ['Foo'], + sub_name => "test_plugins", +- except => ["Foo::One"]; ++ except => ["Foo::One"], ++ only => ["Foo::Two", "Foo::Three"]; + ok(eq_set([Foo->test_plugins], +--- t/4.t.orig ++++ t/4.t +@@ -5,7 +5,8 @@ + search_path => ['Foo'], +- sub_name => "test_plugins"; ++ sub_name => "test_plugins", ++ only => ["Foo::One", "Foo::Two", "Foo::Three"]; + is_deeply( diff --git a/src/test/java/org/perlonjava/backend/bytecode/BytecodeCompilerEvalContextTest.java b/src/test/java/org/perlonjava/backend/bytecode/BytecodeCompilerEvalContextTest.java new file mode 100644 index 000000000..adf8e02a2 --- /dev/null +++ b/src/test/java/org/perlonjava/backend/bytecode/BytecodeCompilerEvalContextTest.java @@ -0,0 +1,55 @@ +package org.perlonjava.backend.bytecode; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.perlonjava.backend.jvm.EmitterContext; +import org.perlonjava.runtime.runtimetypes.RuntimeBase; +import org.perlonjava.runtime.runtimetypes.RuntimeCode; +import org.perlonjava.runtime.runtimetypes.RuntimeScalar; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertSame; + +@Tag("unit") +public class BytecodeCompilerEvalContextTest { + + @Test + void evalRuntimeContextLookupUsesPackedRuntimeValueIndex() throws Exception { + String[] capturedEnv = new String[42]; + capturedEnv[0] = "this"; + capturedEnv[1] = "@_"; + capturedEnv[2] = "wantarray"; + for (int i = 3; i < 41; i++) { + capturedEnv[i] = "$pad" + i; + } + capturedEnv[41] = "$target"; + + Object[] runtimeValues = new Object[41]; + RuntimeScalar target = new RuntimeScalar("captured"); + runtimeValues[41 - 3] = target; + + RuntimeCode.EvalRuntimeContext evalCtx = + new RuntimeCode.EvalRuntimeContext(runtimeValues, capturedEnv, "eval-test"); + + Method push = RuntimeCode.class.getDeclaredMethod( + "pushEvalRuntimeContext", RuntimeCode.EvalRuntimeContext.class); + Method pop = RuntimeCode.class.getDeclaredMethod( + "popEvalRuntimeContext", RuntimeCode.EvalRuntimeContext.class); + push.setAccessible(true); + pop.setAccessible(true); + + push.invoke(null, evalCtx); + try { + BytecodeCompiler compiler = new BytecodeCompiler("eval-test", 1); + Method lookup = BytecodeCompiler.class.getDeclaredMethod( + "getVariableValueFromContext", String.class, EmitterContext.class); + lookup.setAccessible(true); + + RuntimeBase value = (RuntimeBase) lookup.invoke(compiler, "$target", (EmitterContext) null); + assertSame(target, value); + } finally { + pop.invoke(null, evalCtx); + } + } +} diff --git a/src/test/resources/unit/config_startperl_exec.t b/src/test/resources/unit/config_startperl_exec.t new file mode 100644 index 000000000..a7721bef5 --- /dev/null +++ b/src/test/resources/unit/config_startperl_exec.t @@ -0,0 +1,28 @@ +use strict; +use warnings; +use Test::More tests => 3; +use Config; +use File::Spec; +use File::Temp qw(tempdir); + +SKIP: { + skip 'direct executable shebang test is Unix-specific', 3 + if $Config{osname} eq 'MSWin32' || $^O eq 'MSWin32'; + + my $dir = tempdir(CLEANUP => 1); + my $script = File::Spec->catfile($dir, 'startperl-script'); + + open my $fh, '>', $script or die "open $script: $!"; + print {$fh} $Config{startperl}, "\n"; + print {$fh} 'print "startperl-ok @ARGV\n";', "\n"; + close $fh or die "close $script: $!"; + chmod 0755, $script or die "chmod $script: $!"; + + ok(-x $script, 'generated script is executable'); + + my $output = `$script alpha beta 2>&1`; + my $status = $?; + + is($status, 0, 'generated script runs directly'); + is($output, "startperl-ok alpha beta\n", 'generated script receives argv'); +} diff --git a/src/test/resources/unit/eval_block_return.t b/src/test/resources/unit/eval_block_return.t new file mode 100644 index 000000000..c3f5f2100 --- /dev/null +++ b/src/test/resources/unit/eval_block_return.t @@ -0,0 +1,30 @@ +use strict; +use warnings; +use Test::More tests => 4; + +sub eval_return_list { + my @values = eval { return (1, 2, 3) }; + return "after:" . join("|", @values); +} + +sub eval_return_scalar { + my $value = eval { return (1, 2, 3) }; + return "after:$value"; +} + +sub eval_return_date_parts { + my @values = eval { + my @parts = (12, 11, 9, 2, 0, 124); + return @parts; + }; + $values[5] += 1900; + return join("|", @values); +} + +is(eval_return_list(), 'after:1|2|3', 'return inside eval block supplies list eval value'); +is(eval_return_scalar(), 'after:3', 'return inside eval block supplies scalar eval value'); +is(eval_return_date_parts(), '12|11|9|2|0|2024', 'caller continues after eval block return'); + +my $at = 'unchanged'; +eval { return 'ok' }; +is($@, '', 'successful eval block return clears eval error'); diff --git a/src/test/resources/unit/eval_named_sub_capture_fallback.t b/src/test/resources/unit/eval_named_sub_capture_fallback.t new file mode 100644 index 000000000..f8e504367 --- /dev/null +++ b/src/test/resources/unit/eval_named_sub_capture_fallback.t @@ -0,0 +1,21 @@ +use strict; +use warnings; +use Test::More tests => 2; + +my ( + $v00, $v01, $v02, $v03, $v04, $v05, $v06, $v07, + $v08, $v09, $v10, $v11, $v12, $v13, $v14, $v15, + $v16, $v17, $v18, $v19, $v20, $v21, $v22, $v23, + $v24, $v25, $v26, $v27, $v28, $v29, $v30, $v31, + $v32, $v33, $v34, $v35, $v36, $v37, $v38, $v39, + $v40, $v41, $v42, $v43, $v44, $v45, $v46, $v47, + $v48, $v49, $v50, $v51, $v52, $v53, $v54, $v55, +) = 0 .. 55; + +my $body = join q{}, map { q{$v55 += 0;} } 1 .. 12000; +my $code = 'sub generated_capture { ' . $body . ' return $v55 }'; + +my $ok = eval $code; +is($@, '', 'large named sub inside eval compiles'); + +is(generated_capture(), 55, 'fallback-compiled eval sub captures late lexical'); diff --git a/src/test/resources/unit/filespec_tmpdir_env.t b/src/test/resources/unit/filespec_tmpdir_env.t new file mode 100644 index 000000000..bf3516477 --- /dev/null +++ b/src/test/resources/unit/filespec_tmpdir_env.t @@ -0,0 +1,13 @@ +use strict; +use warnings; +use Test::More tests => 2; +use File::Spec; +use File::Temp qw(tempdir); + +my $dir = tempdir(CLEANUP => 1); +local $ENV{TMPDIR} = $dir; + +is(File::Spec->tmpdir, $dir, 'File::Spec->tmpdir honors current TMPDIR'); + +local $ENV{TMPDIR} = "$dir/missing"; +isnt(File::Spec->tmpdir, $ENV{TMPDIR}, 'File::Spec->tmpdir ignores missing TMPDIR'); diff --git a/src/test/resources/unit/for_loop_test.t b/src/test/resources/unit/for_loop_test.t index e401c5934..049cdedfa 100644 --- a/src/test/resources/unit/for_loop_test.t +++ b/src/test/resources/unit/for_loop_test.t @@ -127,4 +127,30 @@ for (my $j = 0; $j < 3; $j++) { } is($main::lv2, 'outer', 'local restored after for(;;) loop'); +{ + my @alias = ('', 'a'); + foreach my $v (@alias) { + $v = length($v) ? $v . 'x' : 'header'; + } + is_deeply(\@alias, ['header', 'ax'], 'foreach my loop variable aliases array elements'); +} + +{ + my $ary = ['']; + foreach my $v (@$ary) { + $v = 'abc'; + } + is($ary->[0], 'abc', 'foreach my loop variable aliases dereferenced array elements'); +} + +{ + my $x = 'original'; + my @alias = ('a', 'b'); + foreach $x (@alias) { + $x = 'z'; + } + is_deeply(\@alias, ['z', 'z'], 'foreach pre-existing lexical aliases array elements'); + is($x, 'original', 'foreach restores pre-existing lexical loop variable'); +} + done_testing(); diff --git a/src/test/resources/unit/glob_absolute_directory.t b/src/test/resources/unit/glob_absolute_directory.t new file mode 100644 index 000000000..717abc023 --- /dev/null +++ b/src/test/resources/unit/glob_absolute_directory.t @@ -0,0 +1,21 @@ +use strict; +use warnings; +use Test::More tests => 3; +use File::Spec; +use File::Temp qw(tempdir); + +my $base = tempdir(CLEANUP => 1); +my $dir = File::Spec->catdir($base, 'abc-testcmd-1'); +mkdir $dir or die "mkdir $dir: $!"; + +my $file = File::Spec->catfile($dir, 'file1'); +open my $fh, '>', $file or die "open $file: $!"; +print {$fh} "hello\n"; +close $fh or die "close $file: $!"; + +my $pattern = File::Spec->catfile($base, '*testcmd*', 'file1'); +my $match = glob($pattern); + +ok(defined $match, 'absolute recursive glob found a match'); +ok(File::Spec->file_name_is_absolute($match), 'absolute recursive glob returns an absolute path'); +is($match, $file, 'absolute recursive glob returns full path'); diff --git a/src/test/resources/unit/hash.t b/src/test/resources/unit/hash.t index 3cbf6e47c..c04be0cdb 100644 --- a/src/test/resources/unit/hash.t +++ b/src/test/resources/unit/hash.t @@ -61,6 +61,26 @@ ok(!exists $hash{key2} && !exists $hash{key3}, 'Slice delete successful'); $hash{outer}{inner} = 'nested'; is($hash{outer}{inner}, 'nested', 'Autovivification works'); +{ + my $hashref = {}; + ${$hashref->{a}} = 1; + ${$hashref->{b}} = 2; + ${$hashref->{c}} = 3; + is_deeply( + [${$hashref->{a}}, ${$hashref->{b}}, ${$hashref->{c}}], + [1, 2, 3], + 'Scalar dereference autovivifies distinct hash entries' + ); + + ${$hashref->{b}} = 9; + is_deeply( + [${$hashref->{a}}, ${$hashref->{b}}, ${$hashref->{c}}], + [1, 9, 3], + 'Autovivified scalar refs do not alias across hash keys' + ); + is(scalar(keys %$hashref), 3, 'Scalar dereference autovivifies hash keys'); +} + my @array; $array[2]{inner} = 'nested'; is($array[2]{inner}, 'nested', 'Autovivification works'); diff --git a/src/test/resources/unit/indirect_object_constructor.t b/src/test/resources/unit/indirect_object_constructor.t new file mode 100644 index 000000000..8246d2662 --- /dev/null +++ b/src/test/resources/unit/indirect_object_constructor.t @@ -0,0 +1,45 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use IO::File; +use Test::More; + +{ + package IndirectObjectWithNew; + sub new($$;$) { die "direct new should not be called" } + sub make_io_file { + my $fh = new IO::File; + return ref($fh); + } +} + +is( + IndirectObjectWithNew::make_io_file(), + 'IO::File', + 'new IO::File parses as an indirect constructor inside a package with new()' +); + +BEGIN { $INC{'IndirectObject/ReqCtor.pm'} = __FILE__ } + +{ + package IndirectObjectWithRequire; + sub new($$;$) { die "direct new should not be called" } + sub make_required { + require IndirectObject::ReqCtor; + my $obj = new IndirectObject::ReqCtor; + return ref($obj); + } +} + +{ + package IndirectObject::ReqCtor; + sub new { bless {}, shift } +} + +is( + IndirectObjectWithRequire::make_required(), + 'IndirectObject::ReqCtor', + 'require Foo::Bar marks the package for following indirect constructor syntax' +); + +done_testing; diff --git a/src/test/resources/unit/io_file_autoload.t b/src/test/resources/unit/io_file_autoload.t new file mode 100644 index 000000000..529bba904 --- /dev/null +++ b/src/test/resources/unit/io_file_autoload.t @@ -0,0 +1,13 @@ +#!/usr/bin/env perl + +print "1..2\n"; + +print !exists $INC{'IO/File.pm'} + ? "ok 1 - IO::File not loaded at startup\n" + : "not ok 1 - IO::File not loaded at startup\n"; + +eval { STDOUT->autoflush(1); 1 }; + +print exists $INC{'IO/File.pm'} + ? "ok 2 - filehandle method autoloads IO::File\n" + : "not ok 2 - filehandle method autoloads IO::File\n"; diff --git a/src/test/resources/unit/io_flock.t b/src/test/resources/unit/io_flock.t new file mode 100644 index 000000000..980a05267 --- /dev/null +++ b/src/test/resources/unit/io_flock.t @@ -0,0 +1,67 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Test::More; +use File::Temp qw(tempfile); +use Fcntl qw(:flock); + +sub exercise_flock { + my ($fh, $filename) = tempfile(UNLINK => 1); + print {$fh} "lock target\n"; + + ok(flock($fh, LOCK_EX), 'exclusive flock succeeds'); + ok(flock($fh, LOCK_UN), 'exclusive flock unlock succeeds'); + ok(flock($fh, LOCK_SH), 'shared flock succeeds'); + ok(flock($fh, LOCK_UN), 'shared flock unlock succeeds'); + + close $fh; +} + +exercise_flock(); + +SKIP: { + skip 'nested jperl interpreter check only runs under jperl', 2 + unless $^X =~ /jperl\z/ || $^X =~ m{/jperl\z}; + skip 'nested jperl interpreter check is unavailable on Windows', 2 + if $^O eq 'MSWin32'; + + my ($script_fh, $script_name) = tempfile(SUFFIX => '.pl'); + print {$script_fh} <<'END_CHILD'; +use strict; +use warnings; + +my $tmpdir = $ENV{TMPDIR} || '/tmp'; +my $filename = "$tmpdir/perlonjava_io_flock_$$.tmp"; +open(my $fh, '>', $filename) or die "open $filename: $!"; +print {$fh} "lock target\n"; +flock($fh, 2) or die "exclusive flock failed: $!"; +flock($fh, 8) or die "flock unlock failed: $!"; +close $fh or die "close $filename: $!"; +unlink $filename; +print "interpreter-flock-ok\n"; +END_CHILD + close $script_fh or die "close child script: $!"; + + my ($out_fh, $out_name) = tempfile(); + open(my $saved_stdout, '>&', \*STDOUT) or die "save stdout: $!"; + open(my $saved_stderr, '>&', \*STDERR) or die "save stderr: $!"; + open(STDOUT, '>&', $out_fh) or die "redirect stdout: $!"; + open(STDERR, '>&', $out_fh) or die "redirect stderr: $!"; + my $status = system('timeout', '60', $^X, '--interpreter', $script_name); + open(STDERR, '>&', $saved_stderr) or die "restore stderr: $!"; + open(STDOUT, '>&', $saved_stdout) or die "restore stdout: $!"; + close $saved_stderr; + close $saved_stdout; + + seek($out_fh, 0, 0); + my $output = do { local $/; <$out_fh> }; + close $out_fh; + unlink $out_name; + unlink $script_name; + + is($status, 0, 'interpreter flock script exits successfully') + or diag $output; + is($output, "interpreter-flock-ok\n", 'interpreter executes FLOCK opcode'); +} + +done_testing; diff --git a/src/test/resources/unit/localtime_tz.t b/src/test/resources/unit/localtime_tz.t new file mode 100644 index 000000000..f9ae84aca --- /dev/null +++ b/src/test/resources/unit/localtime_tz.t @@ -0,0 +1,30 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Test::More; +use Time::Local (); + +sub tz_offset { + my ($time) = @_; + my @local = localtime $time; + my @gmt = gmtime $time; + $local[5] += 1900; + $gmt[5] += 1900; + my $diff = Time::Local::timegm_modern(@local) + - Time::Local::timegm_modern(@gmt); + my $sign = $diff < 0 ? '-' : '+'; + $diff = abs $diff; + return sprintf '%s%02d%02d', $sign, int($diff / 3600), int($diff / 60) % 60; +} + +SKIP: { + skip 'TZ offset strings are not portable on Windows', 2 if $^O eq 'MSWin32'; + + local $ENV{TZ} = 'UTC-11'; + is(tz_offset(1153432704), '+1100', 'localtime honors positive POSIX TZ offset'); + + $ENV{TZ} = 'UTC+9'; + is(tz_offset(1153432704), '-0900', 'localtime honors negative POSIX TZ offset'); +} + +done_testing; diff --git a/src/test/resources/unit/pipe_close_status.t b/src/test/resources/unit/pipe_close_status.t new file mode 100644 index 000000000..79d316006 --- /dev/null +++ b/src/test/resources/unit/pipe_close_status.t @@ -0,0 +1,22 @@ +use strict; +use warnings; +use Test::More tests => 6; + +sub close_pipe_status { + my ($exit) = @_; + open my $fh, "|$^X -e \"exit $exit\"" or die "open pipe: $!"; + my $ok = close $fh; + return ($ok ? 1 : 0, $? >> 8); +} + +my ($ok0, $status0) = close_pipe_status(0); +is($ok0, 1, 'close returns true for zero pipe exit'); +is($status0, 0, 'zero pipe exit is stored in $?'); + +my ($ok1, $status1) = close_pipe_status(1); +is($ok1, 0, 'close returns false for exit 1 pipe'); +is($status1, 1, 'exit 1 pipe status is stored in $?'); + +my ($ok2, $status2) = close_pipe_status(2); +is($ok2, 0, 'close returns false for exit 2 pipe'); +is($status2, 2, 'exit 2 pipe status is stored in $?'); diff --git a/src/test/resources/unit/refcount/global_destruction_nested_container.t b/src/test/resources/unit/refcount/global_destruction_nested_container.t new file mode 100644 index 000000000..b71b2112c --- /dev/null +++ b/src/test/resources/unit/refcount/global_destruction_nested_container.t @@ -0,0 +1,59 @@ +use strict; +use warnings; +use Test::More tests => 2; +use File::Temp qw(tempfile); + +SKIP: { + skip 'nested launcher is unavailable on Windows', 2 + if $^O eq 'MSWin32'; + + my ($log_fh, $log_name) = tempfile(); + close $log_fh; + + my ($script_fh, $script_name) = tempfile(SUFFIX => '.pl'); + print {$script_fh} <<'CHILD'; +use strict; +use warnings; + +package GlobalDestructNested; + +sub DESTROY { + my $path = $_[0]->{path}; + open my $fh, '>>', $path or die "open log: $!"; + print {$fh} "destroy\n"; + close $fh; +} + +package main; + +our $root = { + plain => [ + { value => 1 }, + { value => 2 }, + ], + nested => { + object => bless({ path => $ARGV[0] }, 'GlobalDestructNested'), + }, +}; + +END { + open my $fh, '>>', $ARGV[0] or die "open log: $!"; + print {$fh} "end\n"; + close $fh; +} +CHILD + close $script_fh or die "close child script: $!"; + + my $runner = $^X eq 'jperl' ? './jperl' : $^X; + my $status = system('timeout', '60', $runner, $script_name, $log_name); + is($status, 0, 'child process exits cleanly'); + + open my $read_fh, '<', $log_name or die "read log: $!"; + my $content = do { local $/; <$read_fh> }; + close $read_fh; + unlink $script_name; + unlink $log_name; + + is($content, "end\ndestroy\n", + 'global destruction walks nested object inside an unblessed global container'); +} diff --git a/src/test/resources/unit/regex_capture_sub_scope.t b/src/test/resources/unit/regex_capture_sub_scope.t new file mode 100644 index 000000000..abca1c44b --- /dev/null +++ b/src/test/resources/unit/regex_capture_sub_scope.t @@ -0,0 +1,19 @@ +use strict; +use warnings; +use Test::More tests => 5; + +sub inner_entity_capture { + my ($text) = @_; + $text =~ /&(#?\w+);/; + return $1; +} + +my $value = q{&--&}; +ok($value =~ //sg, 'outer CDATA match succeeds'); +is($1, '&', 'outer capture is set'); +is(inner_entity_capture('&'), 'amp', 'inner helper captures entity name'); +is($1, '&', 'outer capture survives subroutine call'); + +my $pos = pos $value; +my $rebuilt = substr($value, 0, $pos - length($1) - 12) . $1 . substr($value, $pos); +is($rebuilt, q{&-&-&}, 'caller can use preserved capture after helper call'); diff --git a/src/test/resources/unit/regex_capture_symbolic_deref.t b/src/test/resources/unit/regex_capture_symbolic_deref.t new file mode 100644 index 000000000..0604f99f3 --- /dev/null +++ b/src/test/resources/unit/regex_capture_symbolic_deref.t @@ -0,0 +1,17 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Test::More; + +{ + no strict 'refs'; + q{$} =~ /(.)/; + is($$1, $$, '$$1 parses as ${$1}'); + + $main::doof = 'test'; + $main::test = 'Got here'; + $::{+$$} = *doof; + is($$$$1, $main::test, '$$$$1 parses as ${${${$1}}}'); +} + +done_testing; diff --git a/src/test/resources/unit/regex_xml_element_large_scan.t b/src/test/resources/unit/regex_xml_element_large_scan.t new file mode 100644 index 000000000..e030bb72b --- /dev/null +++ b/src/test/resources/unit/regex_xml_element_large_scan.t @@ -0,0 +1,35 @@ +use strict; +use warnings; +use Test::More tests => 18; + +my $large_attr = ' GCamera:HdrPlusMakernote="' . + ('SERSUAPvZDVtXnAeLOrjTFc6Z+BKrjw/q9nGM+nnwf9P' x 700) . + '"'; +my $xmp = '' . + '' . + ''; + +ok($xmp =~ m{<([?/]?)([-\w:.\x80-\xff]+|!--)([^>]*)>}sg, + 'generic XML scan matches opening wrapper'); +is($1, '', 'opening wrapper marker is empty'); +is($2, 'x:xmpmeta', 'opening wrapper name is captured'); +is($3, ' xmlns:x="adobe:ns:meta/"', 'opening wrapper attributes are captured'); +is($&, '', 'whole opening wrapper match is tracked'); + +ok($xmp =~ m{<([?/]?)([-\w:.\x80-\xff]+|!--)([^>]*)>}sg, + 'generic XML scan matches large description tag'); +is($1, '', 'description marker is empty'); +is($2, 'rdf:Description', 'description name is captured'); +like($3, qr/^ GCamera:HdrPlusMakernote="/, 'description attributes start correctly'); +like($3, qr/"\/$/, 'description attributes include self-closing slash'); +is(pos($xmp), length('') + length(''), + 'global match advances past large tag'); + +ok($xmp =~ m{<([?/]?)([-\w:.\x80-\xff]+|!--)([^>]*)>}sg, + 'generic XML scan matches closing wrapper'); +is($1, '/', 'closing wrapper marker is captured'); +is($2, 'x:xmpmeta', 'closing wrapper name is captured'); +is($3, '', 'closing wrapper has no attributes'); +is($&, '', 'whole closing wrapper match is tracked'); +is($-[2], length($xmp) - length('x:xmpmeta>'), 'capture start offset is tracked'); +is($+[2], $-[2] + length('x:xmpmeta'), 'capture end offset is tracked'); diff --git a/src/test/resources/unit/regex_xmpmeta_large_scan.t b/src/test/resources/unit/regex_xmpmeta_large_scan.t new file mode 100644 index 000000000..97ccf42d0 --- /dev/null +++ b/src/test/resources/unit/regex_xmpmeta_large_scan.t @@ -0,0 +1,21 @@ +use strict; +use warnings; +use Test::More tests => 9; + +my $open = ''; +my $payload = ''; +my $xmp = $open . $payload . ''; + +pos($xmp) = length($open); +ok($xmp =~ m{<(/?)x:xmpmeta([-\w:.\x80-\xff]*)(.*?(/?))>}sg, + 'slash-heavy XMP wrapper scan matches closing tag'); +is($1, '/', 'closing slash capture is set'); +is($2, '', 'name suffix capture is empty'); +is($3, '', 'attribute capture is empty for closing tag'); +is($4, '', 'self-closing slash capture is empty'); +is(pos($xmp), length($xmp), 'global match advances pos to end of closing tag'); +is($&, '', 'whole match is closing xmpmeta tag'); +is($-[1], length($xmp) - length('') + 1, 'capture start offset is tracked'); +is($+[1], $-[1] + 1, 'capture end offset is tracked'); diff --git a/src/test/resources/unit/undef_typeglob_sub.t b/src/test/resources/unit/undef_typeglob_sub.t new file mode 100644 index 000000000..305d7c21a --- /dev/null +++ b/src/test/resources/unit/undef_typeglob_sub.t @@ -0,0 +1,26 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Test::More; + +{ + package UndefTypeglobSubTest; + sub something { 42 } +} + +is(UndefTypeglobSubTest::something(), 42, 'sub exists before typeglob undef'); +undef *UndefTypeglobSubTest::something; +ok(!defined(&UndefTypeglobSubTest::something), 'typeglob undef clears CODE slot'); + +my $error = do { + local $@; + eval { UndefTypeglobSubTest::something() }; + $@; +}; +like( + $error, + qr/Undefined subroutine \&UndefTypeglobSubTest::something called/, + 'calling a sub after undef *glob dies' +); + +done_testing;