From 611b0a17c444a00533597fecb91fe4841b8f87b0 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 1 May 2026 23:23:56 +0200 Subject: [PATCH 1/5] fix: Test::Unit::Lite compatibility - three distinct bugs 1. FileSpec.splitdir scalar context: `scalar File::Spec->splitdir($dir)` was returning the last component instead of the component count. Fixed by adding a scalar-context branch that returns `dirs.length` as a RuntimeScalar. Affected Test::Unit::Lite::AllTests::suite() path-depth calculation. 2. Symbol::qualify_to_ref returned GLOB instead of GLOBREFERENCE: the Java implementation was using `new RuntimeScalar().set(new RuntimeGlob(...))` which creates a GLOB-typed scalar, not a reference-to-glob. Changed to `new RuntimeGlob(object.toString()).createReference()` which produces GLOBREFERENCE (type=GLOBREFERENCE), matching how gensym() works. Without this fix, `keys %{ *{Symbol::qualify_to_ref("Pkg::")} }` returned empty, so list_tests() found 0 test methods. 3. IOOperator.select auto-vivification: when `select $var` is called with an undefined scalar, Perl auto-vivifies it into an anonymous GLOB reference (enabling the idiom `select select my $fh_null; tie *$fh_null, 'Class'`). Added the same pattern used by open/socket/pipe: create a new RuntimeGlob, wrap a fresh RuntimeIO in it, create a GLOBREFERENCE, and set() it back on the lvalue via runtimeList.getFirst(). Also added unit tests for cases 1 and 2 in directory.t and typeglob.t. After these fixes: Test::Unit::Lite goes from 0/39 passing to 37/39 passing. Remaining 2 failures are a pre-existing caller() line-number off-by-one for multi-line function calls (not introduced by this PR). Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- dev/cpan-reports/cpan-compatibility.md | 2 +- .../runtime/operators/IOOperator.java | 17 ++++++++++++-- .../runtime/perlmodule/FileSpec.java | 10 +++++++++ .../perlonjava/runtime/perlmodule/Symbol.java | 7 ++++-- src/test/resources/unit/directory.t | 14 +++++++++++- src/test/resources/unit/typeglob.t | 22 +++++++++++++++++++ 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/dev/cpan-reports/cpan-compatibility.md b/dev/cpan-reports/cpan-compatibility.md index a0605e25b..47a81fcd9 100644 --- a/dev/cpan-reports/cpan-compatibility.md +++ b/dev/cpan-reports/cpan-compatibility.md @@ -3703,7 +3703,7 @@ | Test::Override::UserAgent | | Unknown test outcome | 2026-04-30 | | Test::Run | | Unknown test outcome | 2026-04-30 | | Test::Script | | Unknown test outcome | 2026-04-21 | -| Test::Unit::Lite | | | 2026-04-30 | +| Test::Unit::Lite | PARTIAL | 37/39 pass; 2 fail due to `caller()` line-number off-by-one for multi-line calls (pre-existing limitation) | 2026-05-01 | | Text::ASCIIMathML | | | 2026-04-30 | | Text::DelimMatch | | Unknown test outcome | 2026-04-30 | | Text::FillIn | | Unknown test outcome | 2026-04-12 | diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index 079705531..8fb6c6f68 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -87,8 +87,21 @@ public static RuntimeScalar select(RuntimeList runtimeList, int ctx) { } // select FILEHANDLE (returns/sets current filehandle) RuntimeScalar fh = new RuntimeScalar(RuntimeIO.selectedHandle); - RuntimeIO.selectedHandle = runtimeList.getFirst().getRuntimeIO(); - RuntimeIO.lastAccesseddHandle = RuntimeIO.selectedHandle; + RuntimeScalar fileHandleArg = runtimeList.getFirst(); + RuntimeIO newIO = fileHandleArg.getRuntimeIO(); + // Auto-vivify: when called with an undefined scalar, Perl creates a new anonymous + // GLOB reference and stores it back in the variable (like `open my $fh, ...` does). + // This enables the idiom: select select my $fh_null; tie *$fh_null, 'SomeClass'; + if (newIO == null && !fileHandleArg.getDefinedBoolean()) { + RuntimeGlob anonGlob = new RuntimeGlob(null); + RuntimeIO anonIO = new RuntimeIO(); + anonGlob.setIO(anonIO); + RuntimeScalar newGlobRef = anonGlob.createReference(); + fileHandleArg.set(newGlobRef); + newIO = anonIO; + } + RuntimeIO.selectedHandle = newIO; + RuntimeIO.lastAccesseddHandle = newIO; return fh; } diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java index 58e380112..e18682511 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java @@ -2,6 +2,7 @@ import org.perlonjava.runtime.runtimetypes.GlobalVariable; import org.perlonjava.runtime.runtimetypes.RuntimeArray; +import org.perlonjava.runtime.runtimetypes.RuntimeContextType; import org.perlonjava.runtime.runtimetypes.RuntimeHash; import org.perlonjava.runtime.runtimetypes.RuntimeList; import org.perlonjava.runtime.runtimetypes.RuntimeScalar; @@ -438,9 +439,18 @@ public static RuntimeList splitdir(RuntimeArray args, int ctx) { String directories = args.get(1).toString(); // Empty string returns empty list (Perl 5 behavior) if (directories.isEmpty()) { + // In scalar context, return count (0) — mirrors Perl's split behaviour + if (ctx == RuntimeContextType.SCALAR) { + return new RuntimeScalar(0).getList(); + } return new RuntimeList(new ArrayList<>()); } String[] dirs = directories.split(Pattern.quote(File.separator), -1); + // In scalar context, return the count — mirrors Perl's `split` returning + // the number of fields when evaluated in scalar context (perlop "split"). + if (ctx == RuntimeContextType.SCALAR) { + return new RuntimeScalar(dirs.length).getList(); + } List dirList = new ArrayList<>(); for (String dir : dirs) { dirList.add(new RuntimeScalar(dir)); diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Symbol.java b/src/main/java/org/perlonjava/runtime/perlmodule/Symbol.java index 82d28cfe9..542e067cc 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Symbol.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Symbol.java @@ -164,10 +164,13 @@ public static RuntimeList qualify_to_ref(RuntimeArray args, int ctx) { RuntimeScalar object = qualify(args, ctx).scalar(); RuntimeScalar result; if (!object.isString()) { + // Already a glob reference or similar — return as-is result = object; } else { - // System.out.println("qualify_to_ref"); - result = new RuntimeScalar().set(new RuntimeGlob(object.toString())); + // Create a named RuntimeGlob and return a GLOBREFERENCE to it. + // This mirrors Perl's \*{name}: the caller gets a reference whose + // hash slot (and other slots) delegate to the global symbol table. + result = new RuntimeGlob(object.toString()).createReference(); } // System.out.println("qualify_to_ref returns " + result.type); RuntimeList list = new RuntimeList(); diff --git a/src/test/resources/unit/directory.t b/src/test/resources/unit/directory.t index 9966b90c5..6f30f67ca 100644 --- a/src/test/resources/unit/directory.t +++ b/src/test/resources/unit/directory.t @@ -1,7 +1,7 @@ use 5.38.0; use strict; use warnings; -use Test::More tests => 9; +use Test::More tests => 12; use Cwd qw(getcwd abs_path); use File::Spec; @@ -91,3 +91,15 @@ if (-d $test_dir) { if (-d $test_dir || -e "$test_dir/$test_file") { diag "Warning: Cleanup verification found leftover files (will retry in END block)"; } + +# Test File::Spec->splitdir scalar context (mirrors Perl's `split` count semantics) +{ + my $count = scalar File::Spec->splitdir("a/b/c"); + is($count, 3, 'scalar File::Spec->splitdir returns count of components'); + + my $count2 = scalar File::Spec->splitdir("t/tlib"); + is($count2, 2, 'scalar File::Spec->splitdir("t/tlib") returns 2'); + + my $count3 = scalar File::Spec->splitdir(""); + is($count3, 0, 'scalar File::Spec->splitdir("") returns 0 for empty string'); +} diff --git a/src/test/resources/unit/typeglob.t b/src/test/resources/unit/typeglob.t index ac43171db..2fa624b42 100644 --- a/src/test/resources/unit/typeglob.t +++ b/src/test/resources/unit/typeglob.t @@ -75,4 +75,26 @@ subtest 'References in package code slots' => sub { } }; +subtest 'Symbol::qualify_to_ref returns a glob reference' => sub { + use Symbol; + + # qualify_to_ref must return a GLOB reference (ref eq "GLOB"), not the + # glob itself. Perl's list_tests idiom relies on: + # keys %{ *{ Symbol::qualify_to_ref("Pkg::") } } + # to inspect a package's symbol table. + + package QTRTest; + sub qtr_method { 1 } + + package main; + + my $ref = Symbol::qualify_to_ref("QTRTest::"); + like($ref, qr/^GLOB\(/, 'qualify_to_ref stringifies as GLOB(...)'); + is(ref($ref), 'GLOB', 'qualify_to_ref returns a reference of type GLOB'); + + # Dereference to get the typeglob, then access its HASH slot (package stash) + my %stash = %{ *{$ref} }; + ok(exists $stash{qtr_method}, 'hash slot of qualify_to_ref result contains package symbols'); +}; + done_testing(); From af4819279fcfc6ad79a17f23e900bab6564a7661 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 1 May 2026 23:40:23 +0200 Subject: [PATCH 2/5] fix: correct caller() line numbers for method and function calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In handleArrowOperator (Dereference.java) and handleApplyOperator (EmitSubroutine.java), the JVM line number at the callCached/apply INVOKESTATIC was set to the token index of the closing ')' of the argument list, not to the token index of the call site itself. This caused caller() inside a called subroutine to report the wrong source line — specifically the last line of the argument list rather than the line where the call begins. Fix: emit setDebugInfoLineNumber with node.left.getIndex() (the token index of the receiver object / function name) immediately before the INVOKESTATIC instruction in both call paths. This anchors the JVM bytecode to the Perl line where the call starts, so caller() returns the correct line number. Verified by ./jcpan -t Test::Unit::Lite: all 39 subtests now pass (was 37/39; test_assert_deep_equals and test_fail_assert_not_equals had been failing because check_failures() verified caller() line numbers from assertion failures). Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/main/java/org/perlonjava/backend/jvm/Dereference.java | 7 +++++++ .../java/org/perlonjava/backend/jvm/EmitSubroutine.java | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/main/java/org/perlonjava/backend/jvm/Dereference.java b/src/main/java/org/perlonjava/backend/jvm/Dereference.java index 3b0877c07..00ca80c7a 100644 --- a/src/main/java/org/perlonjava/backend/jvm/Dereference.java +++ b/src/main/java/org/perlonjava/backend/jvm/Dereference.java @@ -972,6 +972,13 @@ static void handleArrowOperator(EmitterVisitor emitterVisitor, BinaryOperatorNod // Allocate a unique callsite ID for inline method caching int callsiteId = nextMethodCallsiteId++; + // Set debug line number to the call site (the object/receiver expression), + // so that caller() inside the called method reports the correct source line. + // Without this, the JVM frame reports the line of the closing ')' instead. + if (node.left.getIndex() > 0) { + ByteCodeSourceMapper.setDebugInfoLineNumber(emitterVisitor.ctx, node.left.getIndex()); + } + mv.visitLdcInsn(callsiteId); mv.visitVarInsn(Opcodes.ALOAD, objectSlot); mv.visitVarInsn(Opcodes.ALOAD, methodSlot); diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java index 82c0f27f2..5521dba62 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java @@ -701,6 +701,13 @@ static void handleApplyOperator(EmitterVisitor emitterVisitor, BinaryOperatorNod } } + // Set debug line number to the call site (the function name/reference expression), + // so that caller() inside the called subroutine reports the correct source line. + // Without this, the JVM frame reports the line of the closing ')' instead. + if (node.left != null && node.left.getIndex() > 0) { + ByteCodeSourceMapper.setDebugInfoLineNumber(emitterVisitor.ctx, node.left.getIndex()); + } + mv.visitVarInsn(Opcodes.ALOAD, codeRefSlot); mv.visitVarInsn(Opcodes.ALOAD, nameSlot); mv.visitVarInsn(Opcodes.ALOAD, argsArraySlot); From ca08ecdd5de31a4221fbcded82f1389678170203 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Fri, 1 May 2026 23:40:38 +0200 Subject: [PATCH 3/5] docs: update Test::Unit::Lite status to PASS (39/39) The caller() line number fix resolved the 2 remaining failures. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- dev/cpan-reports/cpan-compatibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/cpan-reports/cpan-compatibility.md b/dev/cpan-reports/cpan-compatibility.md index 47a81fcd9..e292f7096 100644 --- a/dev/cpan-reports/cpan-compatibility.md +++ b/dev/cpan-reports/cpan-compatibility.md @@ -3703,7 +3703,7 @@ | Test::Override::UserAgent | | Unknown test outcome | 2026-04-30 | | Test::Run | | Unknown test outcome | 2026-04-30 | | Test::Script | | Unknown test outcome | 2026-04-21 | -| Test::Unit::Lite | PARTIAL | 37/39 pass; 2 fail due to `caller()` line-number off-by-one for multi-line calls (pre-existing limitation) | 2026-05-01 | +| Test::Unit::Lite | PASS | 39/39 pass | 2026-05-01 | | Text::ASCIIMathML | | | 2026-04-30 | | Text::DelimMatch | | Unknown test outcome | 2026-04-30 | | Text::FillIn | | Unknown test outcome | 2026-04-12 | From beee99faf9f6c0f958973f0dff0eedbc171fba77 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sat, 2 May 2026 08:41:42 +0200 Subject: [PATCH 4/5] fix: correct caller() line numbers in interpreter backend Same root cause as the JVM fix (af48192): CALL_SUB and CALL_METHOD instructions in the interpreter's bytecode had their pcToTokenIndex entries pointing to the token AFTER the closing ')' (inherited from the statement-level emit), instead of the token at the call-site. Fix all three interpreter call-emit sites: - CompileBinaryOperator.java, coderef->() path: use emitWithToken with node.left.getIndex() (the coderef token index). - CompileBinaryOperator.java, ->method() path: same, with the invocant's token index. - CompileBinaryOperatorHelper.java, "()" switch case: change from emit() to emitWithToken(), driven by the call-site tokenIndex now passed from CompileBinaryOperator (node.left.getIndex()). After fix: --interpreter and JVM both report the same line as system Perl for caller() inside a called subroutine with multi-line args. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../bytecode/CompileBinaryOperator.java | 25 ++++++++++++++++--- .../bytecode/CompileBinaryOperatorHelper.java | 6 ++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java index 65fa0eb03..5cedbc636 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java @@ -182,7 +182,14 @@ else if (node.right instanceof ListNode) { int rd = bytecodeCompiler.allocateOutputRegister(); // Emit CALL_SUB opcode - bytecodeCompiler.emit(Opcodes.CALL_SUB); + // Use emitWithToken so pcToTokenIndex maps the call instruction to the + // coderef's token index (call-site line), not the closing ')' line. + int callSiteToken = node.left.getIndex(); + if (callSiteToken > 0) { + bytecodeCompiler.emitWithToken(Opcodes.CALL_SUB, callSiteToken); + } else { + bytecodeCompiler.emit(Opcodes.CALL_SUB); + } bytecodeCompiler.emitReg(rd); bytecodeCompiler.emitReg(coderefReg); bytecodeCompiler.emitReg(argsReg); @@ -246,7 +253,15 @@ else if (node.right instanceof BinaryOperatorNode rightCall) { int rd = bytecodeCompiler.allocateOutputRegister(); // Emit CALL_METHOD - bytecodeCompiler.emit(Opcodes.CALL_METHOD); + // Use emitWithToken so pcToTokenIndex maps the call instruction to the + // invocant's token index (call-site line), not the closing ')' line. + // This ensures caller() inside the called method reports the correct line. + int callSiteToken = node.left.getIndex(); + if (callSiteToken > 0) { + bytecodeCompiler.emitWithToken(Opcodes.CALL_METHOD, callSiteToken); + } else { + bytecodeCompiler.emit(Opcodes.CALL_METHOD); + } bytecodeCompiler.emitReg(rd); bytecodeCompiler.emitReg(invocantReg); bytecodeCompiler.emitReg(methodReg); @@ -403,8 +418,12 @@ else if (node.right instanceof BinaryOperatorNode rightCall) { boolean shareCallerArgs = node.getBooleanAnnotation("shareCallerArgs"); // Emit CALL_SUB or CALL_SUB_SHARE_ARGS opcode + // Pass node.left.getIndex() so pcToTokenIndex maps the call to the function + // name / reference token index (call-site line) rather than the closing ')'. + int callSiteToken = (node.left != null && node.left.getIndex() > 0) + ? node.left.getIndex() : node.getIndex(); int rd = CompileBinaryOperatorHelper.compileBinaryOperatorSwitch( - bytecodeCompiler, node.operator, rs1, rs2, node.getIndex(), + bytecodeCompiler, node.operator, rs1, rs2, callSiteToken, shareCallerArgs); bytecodeCompiler.lastResultReg = rd; return; diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperatorHelper.java b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperatorHelper.java index 786d15282..2b91e1f58 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperatorHelper.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperatorHelper.java @@ -223,7 +223,11 @@ public static int compileBinaryOperatorSwitch(BytecodeCompiler bytecodeCompiler, // Emit CALL_SUB: rd = coderef.apply(args, context) // Use CALL_SUB_SHARE_ARGS for &func (no parens) to share caller's @_ - bytecodeCompiler.emit(shareCallerArgs ? Opcodes.CALL_SUB_SHARE_ARGS : Opcodes.CALL_SUB); + // emitWithToken records tokenIndex in pcToTokenIndex so caller() sees the + // call-site line (tokenIndex was set to node.left.getIndex() by the caller). + bytecodeCompiler.emitWithToken( + shareCallerArgs ? Opcodes.CALL_SUB_SHARE_ARGS : Opcodes.CALL_SUB, + tokenIndex); bytecodeCompiler.emitReg(rd); // Result register bytecodeCompiler.emitReg(rs1); // Code reference register bytecodeCompiler.emitReg(rs2); // Arguments register (RuntimeList to be converted to RuntimeArray) From fb5a217401eed020da417f0f3c4d1fc4e6947444 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sat, 2 May 2026 09:06:20 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20splitdir=20Windows=20path=20separato?= =?UTF-8?q?r=20=E2=80=94=20split=20on=20[/\\]=20not=20just=20File.separato?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, File.separator is '\', so the previous code split only on backslash, returning 1 component for forward-slash paths like "a/b/c". Perl's File::Spec::Win32::splitdir splits on [/\\] (both separators). Mirror that by using a platform-aware regex: "[/\\\\]" on Windows and Pattern.quote(File.separator) elsewhere. Fixes unit/directory.t test 10 on Windows CI. 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/perlmodule/FileSpec.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java index e18682511..46cda69fa 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/FileSpec.java @@ -445,7 +445,10 @@ public static RuntimeList splitdir(RuntimeArray args, int ctx) { } return new RuntimeList(new ArrayList<>()); } - String[] dirs = directories.split(Pattern.quote(File.separator), -1); + // On Windows, File::Spec::Win32::splitdir splits on both '/' and '\'. + // On Unix, File::Spec::Unix::splitdir splits on '/'. + String splitPattern = File.separator.equals("\\") ? "[/\\\\]" : Pattern.quote(File.separator); + String[] dirs = directories.split(splitPattern, -1); // In scalar context, return the count — mirrors Perl's `split` returning // the number of fields when evaluated in scalar context (perlop "split"). if (ctx == RuntimeContextType.SCALAR) {