diff --git a/AGENTS.md b/AGENTS.md index 25313ebe3..ef71d1b20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,6 +186,19 @@ The perl_test_runner.pl sets these automatically based on the test file being ru - Reference the design doc or issue in commit messages when relevant - Use conventional commit format when possible +- **Write commit messages to a file** to avoid shell quoting issues (apostrophes, backticks, special characters). Use `git commit -F /tmp/commit_msg.txt` instead of `-m`: + ```bash + cat > /tmp/commit_msg.txt << 'ENDMSG' + fix: description of the change + + Details about what was fixed and why. + + Generated with [TOOL_NAME](TOOL_DOCS_URL) + + Co-Authored-By: TOOL_NAME + ENDMSG + git commit -F /tmp/commit_msg.txt + ``` - **Commit Attribution:** AI-assisted commits must include attribution markers in the commit message (see [AI_POLICY.md](AI_POLICY.md)): ``` Generated with [TOOL_NAME](TOOL_DOCS_URL) diff --git a/dev/modules/type_tiny.md b/dev/modules/type_tiny.md new file mode 100644 index 000000000..c04640692 --- /dev/null +++ b/dev/modules/type_tiny.md @@ -0,0 +1,512 @@ +# Type::Tiny Support for PerlOnJava + +## Overview + +Type::Tiny 2.010001 is a widely-used Perl type constraint library used by Moo, Moose, +and many CPAN modules. This document tracks the work needed to make +`./jcpan --jobs 8 -t Type::Tiny` pass its test suite on PerlOnJava. + +## Current Status + +**Branch:** `feature/type-tiny-support` +**Module version:** Type::Tiny 2.010001 (375 test programs) +**Pass rate:** 99.5% (2476/2488 individual tests in tests that ran, 6 files with real failures) +**Phase:** 5e complete (2026-04-09) + +### Baseline Results + +The test run was cut short (SIGPIPE) but enough data was captured to identify +all major failure categories. From the partial output (~120 test programs observed): + +| Category | Programs | Key Error | +|----------|----------|-----------| +| Passing | ~45 | — | +| Skipped (missing optional deps) | ~15 | Moose, Mouse, namespace::clean, etc. | +| Failing | ~60+ | See categorized issues below | + +**Note:** The skipped tests are expected — Type::Tiny's only hard runtime dependency +is `Exporter::Tiny` (installed, v1.006003). Modules like Moose, Mouse, Return::Type, +etc. are optional (`suggests`/`recommends`) and tests skip gracefully without them. + +--- + +## Categorized Issues + +### Issue 1: `looks_like_number` only checks internal type (HIGH IMPACT) + +**Symptom:** +``` +Value "1.1" did not pass type constraint "LaxNum" (in $_[0]) +``` + +**Reproduction:** +```perl +use Scalar::Util qw(looks_like_number); +print looks_like_number("1.1"); # PerlOnJava: "" (false!) Perl: 1 +print looks_like_number("42"); # PerlOnJava: "" (false!) Perl: 1 +print looks_like_number(1.1); # PerlOnJava: 1 Perl: 1 +``` + +**Root cause:** `ScalarUtil.java` `looks_like_number()` only checks if the +RuntimeScalar's internal type is INTEGER or DOUBLE. It does NOT parse strings +to see if they look like numbers. All string arguments return false. + +**File:** `src/main/java/org/perlonjava/runtime/perlmodule/ScalarUtil.java` line 259 + +**Fix:** Parse the string value using a regex or numeric parsing logic matching +Perl's `looks_like_number` semantics (integers, floats, scientific notation, +leading/trailing whitespace, infinity, NaN, hex with `0x` prefix, binary `0b`, +octal `0` prefix). + +**Tests affected:** positional.t, noninline.t, and many t/21-types/ tests +that use inline type checks with `Scalar::Util::looks_like_number()`. + +--- + +### Issue 2: `my` scoping in `for` statement modifier (HIGH IMPACT — ~40+ tests) + +**Symptom:** +``` +Global symbol "$s" requires explicit package name (did you forget to declare "my $s"?) + at Type/Tiny.pm line 610 +``` + +**Reproduction:** +```perl +sub test { + defined && s/[\x00-\x1F]//smg for ( my ( $s, @a ) = @_ ); + print "s=$s\n"; # PerlOnJava: s= (empty!) Perl: s=hello +} +test("hello", "world"); +``` + +**Root cause:** `for (my ($s, @a) = @_)` should declare `$s` and `@a` in the +enclosing block scope. PerlOnJava scopes them only within the `for` loop's +iteration scope, so they're undefined on the next line. + +This is in `_build_name_generator` (Type/Tiny.pm line 609-611): +```perl +defined && s/[\x00-\x1F]//smg for ( my ( $s, @a ) = @_ ); +sprintf( '%s[%s]', $s, join q[,], ... ); +``` + +**Fix:** In the parser/compiler, ensure that `my` declarations inside a `for` +statement modifier's list expression create variables scoped to the enclosing +block, not the `for` loop body. + +**Tests affected:** This is the **single biggest blocker** — it causes a cascade +compilation failure in Type::Tiny.pm that breaks ~40+ test programs across all +categories (Type-Library, Type-Params, Type-Coercion, Type-Tiny, Types-Standard, +Types-Common, etc.). Many tests that "All subtests passed" still fail because +this error fires during done_testing/cleanup. + +--- + +### Issue 3: Prototype `;$` with `|` infix operator (MEDIUM IMPACT — ~5+ tests) + +**Symptom:** +``` +syntax error at ... near "HashRef;" +``` + +**Reproduction:** +```perl +sub Foo (;$) { return $_[0] // "default" } +sub Bar (;$) { return $_[0] // "default" } +my $x = Foo | Bar; # syntax error! +# Workaround: my $x = Foo() | Bar(); # works +``` + +**Root cause:** PerlOnJava's parser treats `|` after a `;$`-prototyped function +call as part of the argument to the function, rather than as an infix operator. +Since `|` cannot start an expression, the parser should recognize the function +was called with 0 args and parse `|` as infix bitwise-or. + +**Tests affected:** 03-leak.t (uses `ArrayRef | HashRef`), arithmetic.t, +Type-Tiny-Intersection/cmp.t, and any test using the `Type1 | Type2` union syntax. + +--- + +### Issue 4: `\my $x = \$y` alias assignment (LOW IMPACT — 2 tests) + +**Symptom:** +``` +Assignment to unsupported operator: \ at (eval 9) line 3 +``` + +**Root cause:** Perl's native aliasing syntax (`\$alias = \$original`) is not +implemented in PerlOnJava. This is used by Eval::TypeTiny for the "native aliases" +code path. + +**Tests affected:** aliases-native.t, Eval-TypeTiny/basic.t + +**Note:** Type::Tiny gracefully falls back to tie-based or non-alias behavior +when this feature is unavailable, so fixing this is lower priority. + +--- + +### Issue 5: TIESCALAR in string eval (LOW IMPACT — 1 test) + +**Symptom:** +``` +Can't locate object method "TIESCALAR" via package "Eval::TypeTiny::_TieScalar" +``` + +**Root cause:** The `Eval::TypeTiny::_TieScalar` package defines its TIESCALAR +method, but when used inside a string eval, PerlOnJava can't find it. This may +be a package visibility issue in eval context. + +**Tests affected:** aliases-tie.t + +--- + +### Issue 6: `builtin::export_lexically` not implemented (LOW IMPACT — 2 tests) + +**Symptom:** +``` +Undefined subroutine &builtin::export_lexically called +``` + +**Root cause:** `builtin::export_lexically` is a Perl 5.37.2+ feature that +Exporter::Tiny uses for lexical exports. Not yet implemented in PerlOnJava. + +**Tests affected:** Type-Registry/lexical.t, Type-Tiny-Enum/exporter_lexical.t + +--- + +### Issue 7: `Function 'X' not found to wrap!` (MEDIUM IMPACT — ~5 tests) + +**Symptom:** +``` +Function 'test1' not found to wrap! at .../v2-allowdash.t line 32 +``` + +**Root cause:** Type::Params v2's `wrap_subs` feature looks up functions by name +in the caller's symbol table using `\&{"$pkg::$name"}`. The function may not be +visible yet at the time the wrap is attempted. This could be a compile-time vs +runtime ordering issue, or a problem with how PerlOnJava populates the symbol table +for forward-declared subs. + +**Tests affected:** v2-allowdash.t, v2-listtonamed.t, v2-positional.t, +v2-positional-backcompat.t, v2-named-backcompat.t + +--- + +### Issue 8: `matchfor(qr//)` comparison failures (LOW IMPACT — 1 test) + +**Symptom:** Test::TypeTiny's `matchfor` fails when comparing with `qr//` objects. +The test expects regex matching but gets string comparison instead. + +**Tests affected:** Test-TypeTiny/matchfor.t (3/6 subtests) + +--- + +### Issue 9: Type::Tie failures (LOW IMPACT — ~4 tests) + +**Symptom:** Various tied variable issues: +- STORE not triggering type checks after tie +- clone/Storable roundtrip losing tie magic +- `Can't use string ("Type::Tiny") as a HASH ref` + +**Tests affected:** 01basic.t (2/17), 06clone.t (3/6), 06storable.t (3/6), +basic.t (1/1), 03prototypicalweirdness.t + +--- + +### Issue 10: Overloaded `[]` operator / `_HalfOp` (LOW IMPACT — 3 tests) + +**Symptom:** Type::Tiny::_HalfOp tests fail — double-union, extra-params, +overload-precedence each fail 1/7. + +**Tests affected:** 3 _HalfOp test files (1 subtest each, rest skipped) + +--- + +### Issue 11: Enum sorter ordering (LOW IMPACT — 1 test) + +**Symptom:** `Type::Tiny::Enum->sorter` returns wrong order. + +**Tests affected:** Type-Tiny-Enum/sorter.t + +--- + +### Issue 12: Lexical sub closure (LOW IMPACT — 1 test) + +**Symptom:** Closure over lexical sub doesn't capture the correct value. +``` +got: 'quuux' +expected: '42' +``` + +**Tests affected:** Eval-TypeTiny/lexical-subs.t (1/12) + +--- + +### Issue 13: Type::Library exportables `+Type` syntax (LOW IMPACT — 1 test) + +**Symptom:** `Could not find sub '+Rainbow' exported by My::Types` + +**Tests affected:** Type-Library/exportables.t + +--- + +### Issue 14: `Type::Utils` `isa` on undef (LOW IMPACT — 1 test) + +**Symptom:** `Can't call method "isa" on an undefined value at Type/Utils.pm line 159` + +**Tests affected:** Type-Tiny-Enum/basic.t + +--- + +## Fix Priority + +### Phase 1: `looks_like_number` string parsing (HIGH — easy fix, big impact) + +Fix `ScalarUtil.java` to parse string values as numbers. This unblocks all +type constraint validation for string inputs and fixes the LaxNum/Num/Int/etc. +inline checks used throughout the test suite. + +### Phase 2: `my` scoping in `for` statement modifier (HIGH — biggest blocker) + +Fix the parser/compiler to scope `my` declarations from `for (EXPR)` into the +enclosing block. This single fix should unblock ~40+ test programs that currently +fail due to the Type::Tiny.pm line 610 cascade error. + +### Phase 3: Prototype `;$` with `|` infix (MEDIUM — parser fix) + +Fix parsing of `Func | Func` when Func has `;$` prototype. This unblocks +type union syntax (`ArrayRef | HashRef`) and parameterized types. + +### Phase 4: `Function not found to wrap!` investigation (MEDIUM) + +Investigate why Type::Params v2 can't find functions in the caller's stash. +May require fixes to symbol table population timing. + +### Phase 5: Remaining issues (LOW — incremental) + +Address remaining issues (alias assignment, TIESCALAR in eval, matchfor, +Type::Tie, _HalfOp overloading, etc.) as time permits. + +### Not planned (known limitations) + +- **`builtin::export_lexically`** — Perl 5.37+ feature, out of scope +- **Moose/Mouse integration tests** — Optional deps not installed, tests skip + +--- + +## Progress Tracking + +### Current Status: Phase 5 completed — 99.0% pass rate (2879/2907) + +### Results History + +| Phase | Files Passed | Files Failed | Tests OK | Tests Total | Pass Rate | +|-------|-------------|-------------|----------|-------------|-----------| +| Baseline | ~45 | ~60+ | — | — | — | +| Phase 4 | 186 | 57 | — | — | — | +| Phase 5a | 318 | 13 | 2812 | 2869 | 98.0% | +| Phase 5b | 331 | 10 | 2879 | 2907 | 99.0% | + +### Completed Phases +- [x] Phase 1: `looks_like_number` string parsing (2026-04-09) + - Fixed `ScalarUtil.java` to parse string content for numeric patterns + - File: `src/main/java/org/perlonjava/runtime/perlmodule/ScalarUtil.java` +- [x] Phase 2: `my` scoping in `for` statement modifier (2026-04-09) + - Fixed parser to unwrap single-element ListNode before checking for my-assignment + - File: `src/main/java/org/perlonjava/frontend/parser/StatementResolver.java` +- [x] Phase 3: Prototype `;$` with `|` infix operator (2026-04-09) + - Added binary-only infix operators (`|`, `^`, `==`, `!=`, `>`, `>=`, + `..`, `...`, `=~`, `!~`, `?`, `|.`, `^.`, `&.`) to `isArgumentTerminator` + - This matches Perl's behavior: `Foo | Bar` with `;$` prototype parses as + `(Foo()) | (Bar())` not `Foo(| Bar)` + - File: `src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java` + - Test: `src/test/resources/unit/subroutine_prototype_args.t` +- [x] Phase 4: `Function not found to wrap!` / caller() in interpreter fallback (2026-04-09) + - Root cause: `goto $variable` triggers interpreter fallback for the entire sub. + In interpreter mode, `caller(0)` returned the wrong package because + `ExceptionFormatter` was using CallerStack entries from compile-time contexts + (BEGIN/use) for interpreter frames. + - Fix: Removed CallerStack usage from interpreter frame processing entirely. + Interpreter frames now always use tokenIndex/PC-based lookup via + `ByteCodeSourceMapper` to get location info, avoiding contamination from + compile-time CallerStack entries. + - Also removed pre-scan code for compile-time CallerStack entries (no longer needed) + and cleaned up all DEBUG_CALLER instrumentation. + - Files: `ExceptionFormatter.java`, `CallerStack.java`, `ByteCodeSourceMapper.java`, + `ErrorMessageUtil.java` + - Tests now passing: v2-defaults.t (2/2), v2-positional.t (13/13), + v2-named.t (15/15), v2-allowdash.t (20/20), v2-listtonamed.t (17/17) +- [x] Phase 5a: `~` overload, `;$` prototype, eq/ne interpreter overload (2026-04-09) + - Added `~` (bitwise not) operator overload dispatch + - Extended `;$` prototype parsing to accept named-unary behavior + - Added comma as argument terminator for `;$` prototypes + - Fixed interpreter `EQ_STR`/`NE_STR` opcodes to call `eq()`/`ne()` instead + of `cmp()` for proper overload dispatch + - Files: `BitwiseOperators.java`, `PrototypeArgs.java`, `BytecodeInterpreter.java` + - Tests fixed: matchfor.t (3/6 → 6/6), multisig-custom-message.t (9/18 → 15/18) +- [x] Phase 5b: grep/map/sort context bug + looks_like_number -Inf (2026-04-09) + - **Root cause (grep-in-eval):** Bytecode compiler propagated the outer SCALAR + context to grep/map/sort's list operand. This collapsed `@array` to its count + before filtering. The JVM backend always uses LIST context for the list operand. + - **Fix:** Added `isListOp` check in `CompileBinaryOperator.java` to force + LIST context for the right operand and SCALAR context for the left (closure) + operand of grep/map/sort/all/any operators. + - Also simplified ARRAY_SIZE opcode to use `operand.scalar()` uniformly. + - Also fixed `looks_like_number` to recognize signed Inf/NaN (e.g., `-Inf`, `+NaN`). + - Files: `CompileBinaryOperator.java`, `InlineOpcodeHandler.java`, `ScalarUtils.java` + - Tests fixed: structured.t (100/115 → 110/115), basic.t (82/83 → 83/83), + rt86239.t (4/6 → 6/6), rt90096.t (0/3 → 3/3), extra-params.t (4/7 → 7/7) +- [x] Phase 5c: numify overload, AUTOLOAD dispatch, interpreter grep/map outer @_ (2026-04-09) + - **Numify overload:** `Overload.numify()` could return a STRING (e.g., "3.1" from + a `0+` handler). `getNumberLarge()` now converts string results to proper numeric + types, fixing `0+$obj` returning truncated integer instead of float. + - **AUTOLOAD $AUTOLOAD persistence:** `autoloadVariableName` was permanently set on + RuntimeCode objects after first AUTOLOAD fallback resolution. This caused + `$self->SUPER::AUTOLOAD(@_)` to overwrite `$AUTOLOAD` with garbage. Fixed by + skipping `$AUTOLOAD` assignment when the method name IS "AUTOLOAD" (direct call, + not fallback dispatch). + - **Interpreter grep/map outer @_:** Bytecode interpreter's `executeGrep`/`executeMap` + didn't pass the enclosing sub's `@_` to grep/map blocks, unlike the JVM backend. + Fixed by reading register 1 (always `@_`) and passing it as `outerArgs`. + - Files: `RuntimeScalar.java`, `RuntimeCode.java`, `InlineOpcodeHandler.java` + - Tests fixed: structured.t (110/115 → 115/115), Bitfield/basic.t (80/81 → 81/81), + ConstrainedObject/basic.t (24/27 → 27/27), multisig-custom-message.t (15/18 → 21/21) +- [x] Phase 5d: Fix `local @_ = @_` bug in interpreter backend (2026-04-09) + - **Root cause:** In `CompileAssignment.java`, when compiling `local @_ = @_`, the + RHS `@_` evaluated to register 1 (the @_ register). Then `PUSH_LOCAL_VARIABLE reg1` + cleared that same register before `ARRAY_SET_FROM_LIST` could read from it, resulting + in `local @_ = @_` always producing an empty `@_`. + - **Fix:** When `valueReg == regIdx` (source and destination are the same register), + copy the RHS to a temporary register via `NEW_ARRAY` + `ARRAY_SET_FROM_LIST` before + calling `PUSH_LOCAL_VARIABLE`. + - **JVM backend:** Not affected — already clones the RHS list before localizing + (see `EmitVariable.java` line 956). + - Files: `CompileAssignment.java` + - Tests fixed: v2-returns.t (4/5 → 5/5), structured.t (already passing), + Type-Tiny-Bitfield/basic.t (already passing via Phase 5c AUTOLOAD fix) + - Total test count increased from 2920 → 3166 (246 more tests now executing) + because `local @_ = @_` fix unlocked previously-dying code paths in + Type::Params::Alternatives multisig dispatch. + +### Remaining 30 Failing Test Files (after Phase 5d, 36 individual subtest failures) + +**Tests that pass all subtests but exit non-zero (4 files, 0 real failures):** + +| Test | Subtests | Issue | +|------|----------|-------| +| `t/00-begin.t` | 0/0 | Return::Type version check (optional dep) | +| `Eval-TypeTiny/basic.t` | 3/3 ok | Dies after done_testing (`\=` ref alias) | +| `Type-Tiny-Enum/basic.t` | 17/17 ok | Dies after done_testing (`->values` on unblessed ref) | +| `Type-Params/v2-multi.t` | 1/1 ok | Dies after done_testing (multisig alternative fails) | + +**Tests with 0 subtests (11 files, missing features/deps):** + +| Test | Issue | +|------|-------| +| `Eval-TypeTiny/aliases-native.t` | `\$var = \$other` ref aliasing not supported | +| `Eval-TypeTiny/aliases-tie.t` | TIESCALAR not found (class loading issue) | +| `Type-Library/exportables.t` | `+Rainbow` sub not found (exporter edge case) | +| `Type-Registry/lexical.t` | `builtin::export_lexically` not implemented | +| `Type-Tiny-Enum/exporter_lexical.t` | `builtin::export_lexically` not implemented | +| `Type-Tiny-Intersection/cmp.t` | Syntax error `,=> Int` (parser limitation) | +| `Types-Standard/strmatch-allow-callbacks.t` | `(?{...})` code blocks in regex | +| `Types-Standard/strmatch-avoid-callbacks.t` | `(?{...})` code blocks in regex | +| `Types-Standard/tied.t` | Unsupported variable type for `tie()` | +| `Moo/coercion-inlining-avoidance.t` | Dies early (Moo coercion issue) | +| `gh1.t` | Dies early | + +**Tests with actual subtest failures (15 files, 36 failures):** + +| Test | Result | Root Cause | +|------|--------|-----------| +| `Error-TypeTiny-Assertion/basic.t` | 28/29 | B::Deparse output differs | +| `Eval-TypeTiny/lexical-subs.t` | 11/12 | Lexical sub without parens returns bareword | +| `Type-Library/exportables-duplicated.t` | 0/1 | Warning message format mismatch | +| `Type-Params/multisig-gotonext.t` | 1/6 | `goto &next` doesn't propagate `@_` correctly | +| `Type-Tie/01basic.t` | 15/17 | Tied array edge cases | +| `Type-Tie/06clone.t` | 3/6 | Clone::PP doesn't preserve tie magic | +| `Type-Tie/06storable.t` | 3/6 | Storable::dclone doesn't preserve tie magic | +| `Type-Tie/basic.t` | 1/2 | Unsupported tie on arrays | +| `Type-Tiny-Enum/sorter.t` | 0/1 | Custom sort with `$a`/`$b` cmp callback | +| `Type-Tiny/list-methods.t` | 0/2 | Custom sort with numeric comparator | +| `Moo/basic.t` | 4/5 | Moo isa coercion | +| `Moo/coercion.t` | 9/19 | Moo coercion inlining | +| `Moo/exceptions.t` | 13/15 | Exception `->value` metadata | +| `Moo/inflation.t` | 9/11 | Moo → Moose inflation | +| `gh14.t` | 0/1 | Deep coercion edge case | + +- [x] Phase 5e: Fix `our` list in eval, empty prototype parser, eval local restore (2026-04-09) + - **`our ($a, $b)` in eval STRING:** The list form of `our` in eval STRING reused + the captured register from outer `my` variables without rebinding to the package + global. Added `LOAD_GLOBAL_SCALAR/ARRAY/HASH` emission in the list `our` path to + match the single-variable `our` handling. + - **Empty prototype parser:** For subs with empty prototype `()`, `parsePrototypeArguments` + was still called and incorrectly checked for consecutive commas in the outer context + (e.g., `Num ,=> Int` seen as syntax error). Added early return for empty prototypes. + - **eval {} local variable restoration:** The interpreter's `eval {}` block did not + restore `local` variables at block exit. Added `evalLocalLevelStack` tracking to + properly scope `local` variables inside eval blocks, matching Perl 5 semantics. + - Files: `BytecodeCompiler.java`, `PrototypeArgs.java`, `BytecodeInterpreter.java` + - Tests fixed: + - `Type-Tiny-Enum/sorter.t` (0/1 → 1/1) — our list fix + - `Type-Tiny/list-methods.t` (0/2 → 2/2) — our list fix + - `Type-Tiny-Intersection/cmp.t` (0/0 → 17/17) — empty prototype fix + - `Type-Params/multisig-gotonext.t` (1/6 → 8/8) — eval local restore fix + - `00-begin.t` (0/0 → 1/1) — eval local restore fix + +### Remaining Failing Test Files (after Phase 5e) + +**Tests with 0 subtests (9 files, missing features/deps):** + +| Test | Issue | +|------|-------| +| `Eval-TypeTiny/aliases-native.t` | `\$var = \$other` ref aliasing not supported | +| `Eval-TypeTiny/aliases-tie.t` | TIESCALAR not found (class loading issue) | +| `Type-Library/exportables.t` | `+Rainbow` sub not found (exporter edge case) | +| `Type-Registry/lexical.t` | `builtin::export_lexically` not implemented | +| `Type-Tiny-Enum/exporter_lexical.t` | `builtin::export_lexically` not implemented | +| `Types-Standard/strmatch-allow-callbacks.t` | `(?{...})` code blocks in regex | +| `Types-Standard/strmatch-avoid-callbacks.t` | `(?{...})` code blocks in regex | +| `Types-Standard/tied.t` | Unsupported variable type for `tie()` | +| `gh1.t` | Dies early | + +**Tests with actual subtest failures (6 files, 12 failures):** + +| Test | Result | Root Cause | +|------|--------|-----------| +| `Error-TypeTiny-Assertion/basic.t` | 28/29 | B::Deparse output differs | +| `Eval-TypeTiny/lexical-subs.t` | 11/12 | Lexical sub without parens returns bareword | +| `Type-Tie/01basic.t` | 15/17 | Tied array edge cases | +| `Type-Tie/06clone.t` | 3/6 | Clone::PP doesn't preserve tie magic | +| `Type-Tie/06storable.t` | 3/6 | Storable::dclone doesn't preserve tie magic | +| `Type-Tie/basic.t` | 1/2 | Unsupported tie on arrays | + +**Tests with runner flakiness (varies per run, ~5 Moo tests):** + +| Test | Best Result | Issue | +|------|-------------|-------| +| `Type-Library/exportables-duplicated.t` | 0/1 | `caller()` corruption after eval require | +| `Moo/basic.t` | 4/5 | Moo isa coercion | +| `Moo/coercion.t` | 9/19 | Moo coercion inlining | +| `Moo/exceptions.t` | 13/15 | Exception `->value` metadata | +| `Moo/inflation.t` | 9/11 | Moo → Moose inflation | +| `gh14.t` | 0/1 | Deep coercion edge case | + +### Next Steps +1. Address Tie-related failures (4 files, requires deeper tie infrastructure work) +2. Investigate `caller()` corruption after `eval require` (exportables-duplicated.t) +3. Investigate Moo coercion failures (5 test files) +4. Consider B::Deparse output compatibility (1 test) + +### Open Questions +- `ArrayRef[Int] | HashRef` triggers `Can't call method "isa" on unblessed reference` + at Type/Tiny/Union.pm line 60 — separate runtime issue, not parser-related +- Test runner shows variable error counts (29-66 `!` errors) due to parallel JVM startup + contention — actual failures are consistent across runs + +--- + +## Related Documents + +- `dev/modules/moo_support.md` — Moo support (Type::Tiny's primary consumer) +- `dev/modules/xs_fallback.md` — XS fallback mechanism diff --git a/src/main/java/org/perlonjava/app/scriptengine/PerlLanguageProvider.java b/src/main/java/org/perlonjava/app/scriptengine/PerlLanguageProvider.java index b987a7af9..bdeda3b91 100644 --- a/src/main/java/org/perlonjava/app/scriptengine/PerlLanguageProvider.java +++ b/src/main/java/org/perlonjava/app/scriptengine/PerlLanguageProvider.java @@ -89,6 +89,14 @@ public static RuntimeList executePerlCode(CompilerOptions compilerOptions, // This is critical because require/do should not leak their scope to the caller. ScopedSymbolTable savedCurrentScope = SpecialBlockParser.getCurrentScope(); + // Save and clear the eval runtime context so that modules loaded via require/do + // during eval STRING execution don't see the eval's captured variables. + // Without this, SpecialBlockParser.runSpecialBlock would incorrectly alias + // local variables in required modules to the eval's captured variables when + // they share the same name (e.g., $caller in constant.pm vs $caller in eval scope). + RuntimeCode.EvalRuntimeContext savedEvalRuntimeContext = + RuntimeCode.saveAndClearEvalRuntimeContext(); + // Store the isMainProgram flag in CompilerOptions for use during code generation compilerOptions.isMainProgram = isTopLevelScript; @@ -218,6 +226,9 @@ public static RuntimeList executePerlCode(CompilerOptions compilerOptions, if (savedCurrentScope != null && !isTopLevelScript) { SpecialBlockParser.setCurrentScope(savedCurrentScope); } + // Restore the eval runtime context so the caller's eval STRING compilation + // can continue with its captured variables. + RuntimeCode.restoreEvalRuntimeContext(savedEvalRuntimeContext); } } @@ -253,6 +264,10 @@ public static RuntimeList executePerlAST(Node ast, // Save the current scope so we can restore it after execution. ScopedSymbolTable savedCurrentScope = SpecialBlockParser.getCurrentScope(); + // Save and clear the eval runtime context (same reason as executePerlCode) + RuntimeCode.EvalRuntimeContext savedEvalRuntimeContext = + RuntimeCode.saveAndClearEvalRuntimeContext(); + ScopedSymbolTable globalSymbolTable = new ScopedSymbolTable(); globalSymbolTable.enterScope(); globalSymbolTable.addVariable("this", "", null); @@ -320,6 +335,8 @@ public static RuntimeList executePerlAST(Node ast, WarningBitsRegistry.snapshotCurrentHintHash(); SpecialBlockParser.setCurrentScope(savedCurrentScope); } + // Restore the eval runtime context + RuntimeCode.restoreEvalRuntimeContext(savedEvalRuntimeContext); } } diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index 56222c801..f8222e5a0 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -3276,6 +3276,35 @@ void compileVariableDeclaration(OperatorNode node, String op) { if (hasVariable(varName)) { // Already declared, just use existing register reg = getVariableRegister(varName); + + // For 'our' declarations, we must ALWAYS load from the global table + // even if the variable name already exists in scope. This handles + // the case where eval STRING captures outer 'my' variables into + // registers, but the eval's 'our $var' needs to rebind the register + // to the actual package global. + // (Matches the single-variable our handling above.) + String globalVarName = NameNormalizer.normalizeVariableName( + ((IdentifierNode) sigilOp.operand).name, + getCurrentPackage() + ); + int nameIdx = addToStringPool(globalVarName); + switch (sigil) { + case "$" -> { + emit(Opcodes.LOAD_GLOBAL_SCALAR); + emitReg(reg); + emit(nameIdx); + } + case "@" -> { + emit(Opcodes.LOAD_GLOBAL_ARRAY); + emitReg(reg); + emit(nameIdx); + } + case "%" -> { + emit(Opcodes.LOAD_GLOBAL_HASH); + emitReg(reg); + emit(nameIdx); + } + } } else { // Allocate register and add to symbol table reg = addVariable(varName, "our"); diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java index 40e55cc24..f89cdecdb 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java @@ -96,6 +96,12 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c // Use ArrayDeque instead of Stack for better performance (no synchronization) java.util.ArrayDeque evalCatchStack = new java.util.ArrayDeque<>(); + // Parallel stack tracking DynamicVariableManager local level at eval entry. + // When EVAL_TRY is executed, save the current local level. + // On eval exit (both normal EVAL_END and exception catch), restore to this level + // so that `local` variables inside the eval block are properly unwound. + java.util.ArrayDeque evalLocalLevelStack = new java.util.ArrayDeque<>(); + // Labeled block stack for non-local last/next/redo handling. // When a function call returns a RuntimeControlFlowList, we check this stack // to see if the label matches an enclosing labeled block. @@ -1012,6 +1018,11 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c // Set $@ to the error message String errorMsg = flow.marker.buildErrorMessage(); GlobalVariable.setGlobalVariable("main::@", errorMsg); + // Restore local variables pushed inside the eval block + if (!evalLocalLevelStack.isEmpty()) { + int savedLevel = evalLocalLevelStack.pop(); + DynamicVariableManager.popToLocalLevel(savedLevel); + } // Jump to eval catch handler pc = evalCatchStack.pop(); RuntimeCode.evalDepth--; @@ -1122,6 +1133,11 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c && !evalCatchStack.isEmpty()) { String errorMsg = flow.marker.buildErrorMessage(); GlobalVariable.setGlobalVariable("main::@", errorMsg); + // Restore local variables pushed inside the eval block + if (!evalLocalLevelStack.isEmpty()) { + int savedLevel = evalLocalLevelStack.pop(); + DynamicVariableManager.popToLocalLevel(savedLevel); + } pc = evalCatchStack.pop(); RuntimeCode.evalDepth--; break; @@ -1497,6 +1513,9 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c // Push catch PC onto eval stack evalCatchStack.push(catchPc); + // Save local level so we can restore local variables on eval exit + evalLocalLevelStack.push(DynamicVariableManager.getLocalLevel()); + // Track eval depth for $^S RuntimeCode.evalDepth++; @@ -1516,6 +1535,13 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c evalCatchStack.pop(); } + // Restore local variables that were pushed inside the eval block + // e.g., `eval { local @_ = @_ }` should restore @_ on eval exit + if (!evalLocalLevelStack.isEmpty()) { + int savedLevel = evalLocalLevelStack.pop(); + DynamicVariableManager.popToLocalLevel(savedLevel); + } + // Track eval depth for $^S RuntimeCode.evalDepth--; } @@ -2037,6 +2063,11 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c // Check if we're inside an eval block first if (!evalCatchStack.isEmpty()) { int catchPc = evalCatchStack.pop(); + // Restore local variables pushed inside the eval block + if (!evalLocalLevelStack.isEmpty()) { + int savedLevel = evalLocalLevelStack.pop(); + DynamicVariableManager.popToLocalLevel(savedLevel); + } RuntimeCode.evalDepth--; WarnDie.catchEval(e); pc = catchPc; @@ -2073,6 +2104,12 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c // Inside eval block - catch the exception int catchPc = evalCatchStack.pop(); // Pop the catch handler + // Restore local variables pushed inside the eval block + if (!evalLocalLevelStack.isEmpty()) { + int savedLevel = evalLocalLevelStack.pop(); + DynamicVariableManager.popToLocalLevel(savedLevel); + } + // Track eval depth for $^S RuntimeCode.evalDepth--; @@ -2248,9 +2285,7 @@ private static int executeComparisons(int opcode, int[] bytecode, int pc, RuntimeBase val2 = registers[rs2]; RuntimeScalar s1 = (val1 instanceof RuntimeScalar) ? (RuntimeScalar) val1 : val1.scalar(); RuntimeScalar s2 = (val2 instanceof RuntimeScalar) ? (RuntimeScalar) val2 : val2.scalar(); - RuntimeScalar cmpResult = CompareOperators.cmp(s1, s2); - boolean isEqual = (cmpResult.getInt() == 0); - registers[rd] = isEqual ? RuntimeScalarCache.scalarTrue : RuntimeScalarCache.scalarFalse; + registers[rd] = CompareOperators.eq(s1, s2); return pc; } @@ -2263,9 +2298,7 @@ private static int executeComparisons(int opcode, int[] bytecode, int pc, RuntimeBase val2 = registers[rs2]; RuntimeScalar s1 = (val1 instanceof RuntimeScalar) ? (RuntimeScalar) val1 : val1.scalar(); RuntimeScalar s2 = (val2 instanceof RuntimeScalar) ? (RuntimeScalar) val2 : val2.scalar(); - RuntimeScalar cmpResult = CompareOperators.cmp(s1, s2); - boolean isNotEqual = (cmpResult.getInt() != 0); - registers[rd] = isNotEqual ? RuntimeScalarCache.scalarTrue : RuntimeScalarCache.scalarFalse; + registers[rd] = CompareOperators.ne(s1, s2); return pc; } diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileAssignment.java b/src/main/java/org/perlonjava/backend/bytecode/CompileAssignment.java index 489281603..642384312 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileAssignment.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileAssignment.java @@ -57,11 +57,23 @@ private static boolean handleLocalAssignment(BytecodeCompiler bc, BinaryOperator // For reserved variables like @_, use register-based localization if (bc.isReservedVariable(varName)) { int regIdx = bc.getVariableRegister(varName); + // If RHS and LHS use the same register (e.g. local @_ = @_), + // PUSH_LOCAL_VARIABLE would clear the array before ARRAY_SET_FROM_LIST + // can read from it. Copy RHS to a temp register first. + int srcReg = valueReg; + if (valueReg == regIdx) { + srcReg = bc.allocateRegister(); + bc.emit(Opcodes.NEW_ARRAY); + bc.emitReg(srcReg); + bc.emit(Opcodes.ARRAY_SET_FROM_LIST); + bc.emitReg(srcReg); + bc.emitReg(valueReg); + } bc.emit(Opcodes.PUSH_LOCAL_VARIABLE); bc.emitReg(regIdx); bc.emit(Opcodes.ARRAY_SET_FROM_LIST); bc.emitReg(regIdx); - bc.emitReg(valueReg); + bc.emitReg(srcReg); bc.lastResultReg = regIdx; return true; } @@ -1090,6 +1102,41 @@ public static void compileAssignmentOperator(BytecodeCompiler bytecodeCompiler, } else { bytecodeCompiler.throwCompilerException("Assignment to unsupported hash dereference"); } + } else if (leftOp.operator.equals("\\")) { + // Ref aliasing: \$y = $ref + // Check that refaliasing feature is enabled + if (!bytecodeCompiler.symbolTable.isFeatureCategoryEnabled("refaliasing")) { + bytecodeCompiler.throwCompilerException("Experimental aliasing via reference not enabled"); + } + // Handle scalar ref aliasing: \$y = $ref + if (leftOp.operand instanceof OperatorNode varNode && varNode.operator.equals("$")) { + String varName; + if (varNode.operand instanceof IdentifierNode idNode) { + varName = "$" + idNode.name; + } else { + bytecodeCompiler.throwCompilerException("Assignment to unsupported ref aliasing target"); + return; + } + + if (bytecodeCompiler.hasVariable(varName)) { + int targetReg = bytecodeCompiler.getVariableRegister(varName); + // Dereference the RHS to get the aliased scalar + int derefReg = bytecodeCompiler.allocateRegister(); + bytecodeCompiler.emitWithToken(Opcodes.DEREF_SCALAR_STRICT, node.getIndex()); + bytecodeCompiler.emitReg(derefReg); + bytecodeCompiler.emitReg(valueReg); + // Alias: make targetReg share the same object as derefReg + // This creates a true alias (same RuntimeScalar object) + bytecodeCompiler.emit(Opcodes.ALIAS); + bytecodeCompiler.emitReg(targetReg); + bytecodeCompiler.emitReg(derefReg); + bytecodeCompiler.lastResultReg = targetReg; + } else { + bytecodeCompiler.throwCompilerException("Variable " + varName + " not found for ref aliasing"); + } + } else { + bytecodeCompiler.throwCompilerException("Assignment to unsupported ref aliasing target: " + leftOp.operator); + } } else { if (leftOp.operator.equals("chop") || leftOp.operator.equals("chomp")) { bytecodeCompiler.throwCompilerException("Can't modify " + leftOp.operator + " in scalar assignment"); diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java index dc20172c6..5e3428dfb 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java @@ -626,13 +626,25 @@ else if (node.right instanceof BinaryOperatorNode rightCall) { "&.", "|.", "^." -> true; default -> false; }; + // For grep/map/sort/all/any, the right operand (list) must always be in LIST context + // and the left operand (closure) in SCALAR context, matching the JVM backend. + boolean isListOp = switch (node.operator) { + case "grep", "map", "sort", "all", "any" -> true; + default -> false; + }; int outerCtx = bytecodeCompiler.currentCallContext; - int leftCtx = forceScalar ? RuntimeContextType.SCALAR : outerCtx; + int leftCtx = (forceScalar || isListOp) ? RuntimeContextType.SCALAR : outerCtx; bytecodeCompiler.compileNode(node.left, -1, leftCtx); int rs1 = bytecodeCompiler.lastResultReg; - int rightCtx = (forceScalar || node.operator.equals("=~") || node.operator.equals("!~")) - ? RuntimeContextType.SCALAR : outerCtx; + int rightCtx; + if (isListOp) { + rightCtx = RuntimeContextType.LIST; + } else if (forceScalar || node.operator.equals("=~") || node.operator.equals("!~")) { + rightCtx = RuntimeContextType.SCALAR; + } else { + rightCtx = outerCtx; + } bytecodeCompiler.compileNode(node.right, -1, rightCtx); int rs2 = bytecodeCompiler.lastResultReg; diff --git a/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java b/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java index 472908162..0172c88b7 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java @@ -116,8 +116,13 @@ public static RuntimeList evalStringList(String perlCode, // Create minimal EmitterContext for parsing // IMPORTANT: Inherit strict/feature/warning flags from parent scope // This matches Perl's eval STRING semantics where eval inherits lexical pragmas + // Generate a unique eval filename so ByteCodeSourceMapper entries from + // different evals don't collide (each eval's token indices start from 0, + // so sharing a single filename would mix package-at-location data). + String evalFileName = RuntimeCode.getNextEvalFilename(); + CompilerOptions opts = new CompilerOptions(); - opts.fileName = sourceName + " (eval)"; + opts.fileName = evalFileName; ScopedSymbolTable symbolTable = new ScopedSymbolTable(); // Add standard variables that are always available in eval context. @@ -243,7 +248,7 @@ public static RuntimeList evalStringList(String perlCode, // Step 4: Compile AST to interpreter bytecode with adjusted variable registry. // The compile-time package is already propagated via ctx.symbolTable. BytecodeCompiler compiler = new BytecodeCompiler( - sourceName + " (eval)", + evalFileName, sourceLine, errorUtil, adjustedRegistry // Pass adjusted registry for variable capture @@ -327,8 +332,11 @@ public static RuntimeScalar evalString(String perlCode, Lexer lexer = new Lexer(perlCode); List tokens = lexer.tokenize(); + // Generate a unique eval filename (see comment in evalStringList above) + String evalFileName = RuntimeCode.getNextEvalFilename(); + CompilerOptions opts = new CompilerOptions(); - opts.fileName = sourceName + " (eval)"; + opts.fileName = evalFileName; ScopedSymbolTable symbolTable = new ScopedSymbolTable(); // Add standard variables that are always available in eval context. @@ -358,7 +366,7 @@ public static RuntimeScalar evalString(String perlCode, // IMPORTANT: Do NOT call compiler.setCompilePackage() here — same reason as the // first evalString overload above: it corrupts die/warn location baking. BytecodeCompiler compiler = new BytecodeCompiler( - sourceName + " (eval)", + evalFileName, sourceLine, errorUtil ); diff --git a/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java index 8e217ebe5..08ef455ea 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java @@ -499,19 +499,15 @@ public static int executeArrayUnshift(int[] bytecode, int pc, RuntimeBase[] regi } /** - * Array size: rd = scalar(@array) or scalar(value) - * Special case for RuntimeList: return size, not last element. + * Scalar conversion: rd = operand.scalar() + * Converts arrays to count, lists to last element, scalars to self. * Format: ARRAY_SIZE rd operandReg */ public static int executeArraySize(int[] bytecode, int pc, RuntimeBase[] registers) { int rd = bytecode[pc++]; int operandReg = bytecode[pc++]; RuntimeBase operand = registers[operandReg]; - if (operand instanceof RuntimeList) { - registers[rd] = new RuntimeScalar(((RuntimeList) operand).size()); - } else { - registers[rd] = operand.scalar(); - } + registers[rd] = operand.scalar(); return pc; } @@ -944,7 +940,9 @@ public static int executeMap(int[] bytecode, int pc, RuntimeBase[] registers) { RuntimeBase listBase = registers[listReg]; RuntimeList list = listBase.getList(); RuntimeScalar closure = (RuntimeScalar) registers[closureReg]; - RuntimeList result = ListOperators.map(list, closure, ctx); + // Pass outer @_ (register 1) so map blocks can access $_[0], $_[1], etc. + RuntimeArray outerArgs = (registers[1] instanceof RuntimeArray) ? (RuntimeArray) registers[1] : null; + RuntimeList result = ListOperators.map(list, closure, outerArgs, ctx); registers[rd] = result; return pc; } @@ -964,7 +962,9 @@ public static int executeGrep(int[] bytecode, int pc, RuntimeBase[] registers) { RuntimeBase listBase = registers[listReg]; RuntimeList list = listBase.getList(); RuntimeScalar closure = (RuntimeScalar) registers[closureReg]; - RuntimeList result = ListOperators.grep(list, closure, ctx); + // Pass outer @_ (register 1) so grep blocks can access $_[0], $_[1], etc. + RuntimeArray outerArgs = (registers[1] instanceof RuntimeArray) ? (RuntimeArray) registers[1] : null; + RuntimeList result = ListOperators.grep(list, closure, outerArgs, ctx); registers[rd] = result; return pc; } diff --git a/src/main/java/org/perlonjava/backend/jvm/ByteCodeSourceMapper.java b/src/main/java/org/perlonjava/backend/jvm/ByteCodeSourceMapper.java index 2354ff446..8ecdcc620 100644 --- a/src/main/java/org/perlonjava/backend/jvm/ByteCodeSourceMapper.java +++ b/src/main/java/org/perlonjava/backend/jvm/ByteCodeSourceMapper.java @@ -153,12 +153,6 @@ public static void saveSourceLocation(EmitterContext ctx, int tokenIndex) { LineInfo existingEntry = info.tokenToLineInfo.get(tokenIndex); if (existingEntry != null) { // Entry already exists from parse-time - preserve it entirely - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG saveSourceLocation: SKIP (exists) file=" + ctx.compilerOptions.fileName - + " tokenIndex=" + tokenIndex + " existingLine=" + existingEntry.lineNumber() - + " existingPkg=" + packageNamePool.get(existingEntry.packageNameId()) - + " existingSourceFile=" + fileNamePool.get(existingEntry.sourceFileNameId())); - } return; } @@ -194,12 +188,6 @@ public static void saveSourceLocation(EmitterContext ctx, int tokenIndex) { int sourceFileNameId = getOrCreateFileId(sourceFileName); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG saveSourceLocation: STORE origFile=" + ctx.compilerOptions.fileName - + " sourceFile=" + sourceFileName - + " tokenIndex=" + tokenIndex + " line=" + lineNumber - + " pkg=" + ctx.symbolTable.getCurrentPackage() + " sub=" + subroutineName); - } // Map the token index to a LineInfo object containing line, package, subroutine, and source file info.tokenToLineInfo.put(tokenIndex, new LineInfo( @@ -222,33 +210,20 @@ public static void saveSourceLocation(EmitterContext ctx, int tokenIndex) { public static String getPackageAtLocation(String fileName, int tokenIndex) { int fileId = fileNameToId.getOrDefault(fileName, -1); if (fileId == -1) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG getPackageAtLocation: NO FILE ID for fileName=" + fileName); - } return null; } SourceFileInfo info = sourceFiles.get(fileId); if (info == null) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG getPackageAtLocation: NO SOURCE INFO for fileName=" + fileName + " fileId=" + fileId); - } return null; } Map.Entry entry = info.tokenToLineInfo.floorEntry(tokenIndex); if (entry == null) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG getPackageAtLocation: NO ENTRY for fileName=" + fileName + " tokenIndex=" + tokenIndex); - } return null; } String pkg = packageNamePool.get(entry.getValue().packageNameId()); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG getPackageAtLocation: fileName=" + fileName + " tokenIndex=" + tokenIndex - + " foundTokenIndex=" + entry.getKey() + " pkg=" + pkg); - } return pkg; } @@ -264,18 +239,12 @@ public static SourceLocation parseStackTraceElement(StackTraceElement element, H SourceFileInfo info = sourceFiles.get(fileId); if (info == null) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG parseStackTraceElement: NO INFO for file=" + element.getFileName() + " fileId=" + fileId); - } return new SourceLocation(element.getFileName(), "", tokenIndex, null); } // Use TreeMap's floorEntry to find the nearest defined token index Map.Entry entry = info.tokenToLineInfo.floorEntry(tokenIndex); if (entry == null) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG parseStackTraceElement: NO ENTRY for file=" + element.getFileName() + " tokenIndex=" + tokenIndex); - } return new SourceLocation(element.getFileName(), "", element.getLineNumber(), null); } @@ -298,9 +267,6 @@ public static SourceLocation parseStackTraceElement(StackTraceElement element, H while (lowerEntry != null && (entry.getKey() - lowerEntry.getKey()) < 300) { String lowerSourceFile = fileNamePool.get(lowerEntry.getValue().sourceFileNameId()); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG parseStackTraceElement: checking lowerEntry key=" + lowerEntry.getKey() + " sourceFile=" + lowerSourceFile + " line=" + lowerEntry.getValue().lineNumber() + " entryKey=" + entry.getKey()); - } if (!lowerSourceFile.equals(element.getFileName())) { // Found an entry with #line-adjusted filename // Calculate the offset: the difference between the original line and the #line-adjusted line @@ -328,9 +294,6 @@ public static SourceLocation parseStackTraceElement(StackTraceElement element, H lineNumber = lowerEntry.getValue().lineNumber() + estimatedExtraLines; packageName = packageNamePool.get(lowerEntry.getValue().packageNameId()); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG parseStackTraceElement: APPLYING lowerEntry sourceFile=" + sourceFileName + " adjustedLine=" + lineNumber + " tokenDist=" + tokenDistFromLineDirective); - } break; } // This lower entry still has the original file, keep looking @@ -343,9 +306,6 @@ public static SourceLocation parseStackTraceElement(StackTraceElement element, H var higherEntry = info.tokenToLineInfo.higherEntry(currentKey); while (higherEntry != null && (higherEntry.getKey() - entry.getKey()) < 50) { String higherSourceFile = fileNamePool.get(higherEntry.getValue().sourceFileNameId()); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG parseStackTraceElement: checking higherEntry key=" + higherEntry.getKey() + " sourceFile=" + higherSourceFile + " entryKey=" + entry.getKey() + " currentKey=" + currentKey); - } if (!higherSourceFile.equals(element.getFileName())) { // Higher entry has #line-adjusted filename - use it sourceFileName = higherSourceFile; @@ -353,28 +313,15 @@ public static SourceLocation parseStackTraceElement(StackTraceElement element, H (higherEntry.getKey() - entry.getKey()); // Approximate adjustment if (lineNumber < 1) lineNumber = 1; packageName = packageNamePool.get(higherEntry.getValue().packageNameId()); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG parseStackTraceElement: APPLYING higherEntry sourceFile=" + sourceFileName + " adjustedLine=" + lineNumber); - } break; } // This higher entry still has the original file, keep looking currentKey = higherEntry.getKey(); higherEntry = info.tokenToLineInfo.higherEntry(currentKey); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG parseStackTraceElement: next higherEntry for key=" + currentKey + " is " + - (higherEntry != null ? "key=" + higherEntry.getKey() : "null")); - } } } } - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG parseStackTraceElement: file=" + element.getFileName() - + " sourceFile=" + sourceFileName - + " lookupTokenIndex=" + tokenIndex + " foundTokenIndex=" + entry.getKey() - + " line=" + lineNumber + " pkg=" + packageName); - } // Retrieve subroutine name String subroutineName = subroutineNamePool.get(lineInfo.subroutineNameId()); diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitOperatorLocal.java b/src/main/java/org/perlonjava/backend/jvm/EmitOperatorLocal.java index c430f4240..982c42e10 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitOperatorLocal.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitOperatorLocal.java @@ -75,7 +75,11 @@ static void handleLocal(EmitterVisitor emitterVisitor, OperatorNode node) { var symbolEntry = emitterVisitor.ctx.symbolTable.getSymbolEntry(varName); isOurVariable = symbolEntry != null && "our".equals(symbolEntry.decl()); } - if (varIndex == -1 || isOurVariable) { + // @_ is always lexical (parameter at slot 1) - use register-based + // localization, not global. This matches EmitVariable.java line 410 + // where @_ access is always treated as lexical. + boolean isAtUnderscore = varName.equals("@_"); + if ((varIndex == -1 || isOurVariable) && !isAtUnderscore) { String fullName = NameNormalizer.normalizeVariableName(idNode.name, emitterVisitor.ctx.symbolTable.getCurrentPackage()); mv.visitLdcInsn(fullName); mv.visitMethodInsn(Opcodes.INVOKESTATIC, diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitVariable.java b/src/main/java/org/perlonjava/backend/jvm/EmitVariable.java index 6daed4f60..0ecce00c2 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitVariable.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitVariable.java @@ -881,35 +881,71 @@ static void handleAssignOperator(EmitterVisitor emitterVisitor, BinaryOperatorNo } } - node.left.accept(emitterVisitor.with(RuntimeContextType.SCALAR)); // emit the variable - - if (spillRhs) { - mv.visitVarInsn(Opcodes.ALOAD, rhsSlot); - mv.visitInsn(Opcodes.SWAP); - } + // Check for ref aliasing (\$y = $ref) BEFORE emitting LHS + if (nodeLeft != null && nodeLeft.operator.equals("\\")) { + // `\$b = \$a` requires "refaliasing" + if (!ctx.symbolTable.isFeatureCategoryEnabled("refaliasing")) { + throw new PerlCompilerException(node.tokenIndex, "Experimental aliasing via reference not enabled", ctx.errorUtil); + } + // Emit experimental warning if warnings are enabled + if (ctx.symbolTable.isWarningCategoryEnabled("experimental::refaliasing")) { + try { + WarnDie.warn( + new RuntimeScalar("Aliasing via reference is experimental"), + new RuntimeScalar(ctx.errorUtil.warningLocation(node.tokenIndex)) + ); + } catch (Exception e) { + // If warning system isn't initialized yet, fall back to System.err + System.err.println("Aliasing via reference is experimental" + ctx.errorUtil.warningLocation(node.tokenIndex) + "."); + } + } - if (nodeLeft != null) { - if (nodeLeft.operator.equals("\\")) { - // `\\$b = \\$a` requires "refaliasing" - if (!ctx.symbolTable.isFeatureCategoryEnabled("refaliasing")) { - throw new PerlCompilerException(node.tokenIndex, "Experimental aliasing via reference not enabled", ctx.errorUtil); + // Handle scalar ref aliasing: \$y = $ref + // Makes $y an alias for the scalar referenced by $ref + if (nodeLeft.operand instanceof OperatorNode varNode && varNode.operator.equals("$")) { + String varName; + if (varNode.operand instanceof IdentifierNode idNode) { + varName = "$" + idNode.name; + } else { + throw new PerlCompilerException(node.tokenIndex, "Assignment to unsupported ref aliasing target", ctx.errorUtil); } - // Emit experimental warning if warnings are enabled - if (ctx.symbolTable.isWarningCategoryEnabled("experimental::refaliasing")) { - try { - WarnDie.warn( - new RuntimeScalar("Aliasing via reference is experimental"), - new RuntimeScalar(ctx.errorUtil.warningLocation(node.tokenIndex)) - ); - } catch (Exception e) { - // If warning system isn't initialized yet, fall back to System.err - System.err.println("Aliasing via reference is experimental" + ctx.errorUtil.warningLocation(node.tokenIndex) + "."); + SymbolTable.SymbolEntry symEntry = ctx.symbolTable.getSymbolEntry(varName); + + if (symEntry != null && (symEntry.decl().equals("my") || symEntry.decl().equals("state"))) { + // Load RHS (the reference) onto stack + if (spillRhs) { + mv.visitVarInsn(Opcodes.ALOAD, rhsSlot); } + // else RHS is already on top of stack + + // Dereference: get the RuntimeScalar that the reference points to + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, + "org/perlonjava/runtime/runtimetypes/RuntimeScalar", + "scalarDeref", + "()Lorg/perlonjava/runtime/runtimetypes/RuntimeScalar;", + false); + + // Duplicate: one copy for the return value, one for ASTORE + mv.visitInsn(Opcodes.DUP); + + // Store the dereferenced scalar directly into the variable's local slot + // This creates the alias: $y now points to the same RuntimeScalar object + mv.visitVarInsn(Opcodes.ASTORE, symEntry.index()); + + if (pooledRhs) { + ctx.javaClassInfo.releaseSpillSlot(); + } + break; } - // TODO: Implement proper reference aliasing - // For now, we just assign the reference value without creating an alias - // This is not fully correct but allows tests to progress } + // Fall through for unsupported ref aliasing targets (global vars, etc.) + } + + node.left.accept(emitterVisitor.with(RuntimeContextType.SCALAR)); // emit the variable + + if (spillRhs) { + mv.visitVarInsn(Opcodes.ALOAD, rhsSlot); + mv.visitInsn(Opcodes.SWAP); } boolean isGlob = false; diff --git a/src/main/java/org/perlonjava/core/Configuration.java b/src/main/java/org/perlonjava/core/Configuration.java index 89698069b..4dcd7910d 100644 --- a/src/main/java/org/perlonjava/core/Configuration.java +++ b/src/main/java/org/perlonjava/core/Configuration.java @@ -33,7 +33,7 @@ public final class Configuration { * Automatically populated by Gradle/Maven during build. * DO NOT EDIT MANUALLY - this value is replaced at build time. */ - public static final String gitCommitId = "bd1ecc0e6"; + public static final String gitCommitId = "b8043f312"; /** * Git commit date of the build (ISO format: YYYY-MM-DD). @@ -48,7 +48,7 @@ public final class Configuration { * Parsed by App::perlbrew and other tools via: perl -V | grep "Compiled at" * DO NOT EDIT MANUALLY - this value is replaced at build time. */ - public static final String buildTimestamp = "Apr 10 2026 10:53:25"; + public static final String buildTimestamp = "Apr 10 2026 11:59:23"; // Prevent instantiation private Configuration() { diff --git a/src/main/java/org/perlonjava/frontend/parser/IdentifierParser.java b/src/main/java/org/perlonjava/frontend/parser/IdentifierParser.java index 694c984dd..dc200d71b 100644 --- a/src/main/java/org/perlonjava/frontend/parser/IdentifierParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/IdentifierParser.java @@ -356,9 +356,9 @@ public static String parseComplexIdentifierInner(Parser parser, boolean insideBr // `@{${...}` should fall back to block parsing return null; } - if (token.text.equals("^") && nextToken.type == LexerTokenType.IDENTIFIER && Character.isUpperCase(nextToken.text.charAt(0))) { - // `$^` can be followed by an optional uppercase identifier: `$^A` - // ^A is control-A char(1) + if (token.text.equals("^") && nextToken.type == LexerTokenType.IDENTIFIER && (Character.isUpperCase(nextToken.text.charAt(0)) || nextToken.text.charAt(0) == '_')) { + // `$^` can be followed by an optional uppercase or underscore identifier: `$^A`, `${^_THING}` + // ^A is control-A char(1), ^_ is char(31) TokenUtils.consume(parser); // consume the ^ if (CompilerOptions.DEBUG_ENABLED) parser.ctx.logDebug("parse $^ at token " + TokenUtils.peek(parser).text); // `$^LAST_FH` is parsed as `$^L` + `AST_FH` diff --git a/src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java b/src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java index c03a1f5b1..d553256d6 100644 --- a/src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java +++ b/src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java @@ -100,12 +100,21 @@ private static boolean allowsZeroArguments(String prototype) { * makes them list operators. * * @param prototype The prototype string - * @return true if the prototype is exactly "$" or "_" + * @return true if the prototype is "$", "_", ";$", or ";_" */ private static boolean isNamedUnaryPrototype(String prototype) { - if (prototype.length() != 1) return false; - char first = prototype.charAt(0); - return first == '$' || first == '_'; + if (prototype.length() == 1) { + char first = prototype.charAt(0); + return first == '$' || first == '_'; + } + // ";$" and ";_" are also named unary (optional single scalar argument). + // This matters for expressions like ArrayRef[Int] | HashRef[Int] where + // the | must NOT be consumed as part of ArrayRef's argument. + if (prototype.length() == 2 && prototype.charAt(0) == ';') { + char second = prototype.charAt(1); + return second == '$' || second == '_'; + } + return false; } /** @@ -137,7 +146,26 @@ private static boolean isArgumentTerminator(Parser parser) { next.text.equals("||=") || next.text.equals("//=") || next.text.equals("x=") || - next.text.equals(".="); + next.text.equals(".=") || + // Binary-only infix operators that cannot start a primary expression + // should terminate argument parsing. For example, with `;$` prototype: + // Foo | Bar → (Foo()) | (Bar()) not Foo(| Bar) + // This matches Perl's behavior where these operators signal + // "no argument provided" to the function. + next.text.equals("|") || + next.text.equals("^") || + next.text.equals("|.") || + next.text.equals("^.") || + next.text.equals("&.") || + next.text.equals("==") || + next.text.equals("!=") || + next.text.equals(">") || + next.text.equals(">=") || + next.text.equals("..") || + next.text.equals("...") || + next.text.equals("=~") || + next.text.equals("!~") || + next.text.equals("?"); } /** @@ -164,7 +192,7 @@ static ListNode consumeArgsWithPrototype(Parser parser, String prototype, boolea // than comparison operators. So `reftype $h eq 'HASH'` parses as // `(reftype($h)) eq 'HASH'`, not `reftype($h eq 'HASH')`. if (!hasParentheses && prototype != null && isNamedUnaryPrototype(prototype)) { - if (isArgumentTerminator(parser) || TokenUtils.peek(parser).text.equals("=>")) { + if (isArgumentTerminator(parser) || TokenUtils.peek(parser).text.equals("=>") || isComma(TokenUtils.peek(parser))) { // No argument - check if optional if (!allowsZeroArguments(prototype)) { throwNotEnoughArgumentsError(parser); @@ -208,6 +236,10 @@ static ListNode consumeArgsWithPrototype(Parser parser, String prototype, boolea // for (Node element : args.elements) { // element.setAnnotation("context", "LIST"); // } + } else if (prototype.isEmpty()) { + // Empty prototype "()" means zero arguments - nothing to parse. + // Don't enter parsePrototypeArguments which would incorrectly check + // for consecutive commas in the outer context (e.g., "Num ,=> Int"). } else { parsePrototypeArguments(parser, args, prototype, hasParentheses); diff --git a/src/main/java/org/perlonjava/frontend/parser/StatementResolver.java b/src/main/java/org/perlonjava/frontend/parser/StatementResolver.java index b5631bf0f..2b281678f 100644 --- a/src/main/java/org/perlonjava/frontend/parser/StatementResolver.java +++ b/src/main/java/org/perlonjava/frontend/parser/StatementResolver.java @@ -728,15 +728,30 @@ yield dieWarnNode(parser, "die", new ListNode(List.of( // declaration before the BlockNode to preserve correct scoping. // This is analogous to handleStatementModifierWithMy for if/unless. Node hoistedMyDecl = null; - if (modifierExpression instanceof BinaryOperatorNode assignNode + + // Unwrap single-element ListNode from outer parentheses. + // "for (my @w = LIST)" parses as ListNode([BinaryOperatorNode]) + // while "for my @w = LIST" parses as BinaryOperatorNode directly. + Node listExprCandidate = modifierExpression; + if (listExprCandidate instanceof ListNode ln && ln.elements.size() == 1) { + listExprCandidate = ln.elements.get(0); + } + + if (listExprCandidate instanceof BinaryOperatorNode assignNode && assignNode.operator.equals("=")) { Node left = assignNode.left; if (left instanceof OperatorNode myNode && myNode.operator.equals("my")) { - // Extract "my @w" as a standalone declaration + // Extract "my @w" or "my ($s, @a)" as a standalone declaration hoistedMyDecl = left; // Replace "my @w = LIST" with "@w = LIST" in the foreach list - modifierExpression = new BinaryOperatorNode( + Node newAssign = new BinaryOperatorNode( "=", myNode.operand, assignNode.right, parser.tokenIndex); + // Preserve ListNode wrapper if present (from outer parens) + if (modifierExpression instanceof ListNode) { + modifierExpression = new ListNode(List.of(newAssign), parser.tokenIndex); + } else { + modifierExpression = newAssign; + } } } diff --git a/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java b/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java index 0e027811f..be554e872 100644 --- a/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java @@ -215,6 +215,14 @@ public static RuntimeScalar bitwiseXorBinary(RuntimeScalar runtimeScalar, Runtim * @return A new RuntimeScalar with the result of the bitwise NOT operation. */ public static RuntimeScalar bitwiseNot(RuntimeScalar runtimeScalar) { + // Check for overloaded '~' operator on blessed objects + int blessId = blessedId(runtimeScalar); + if (blessId < 0) { + RuntimeScalar result = OverloadContext.tryOneArgumentOverload( + runtimeScalar, blessId, "(~", "~", BitwiseOperators::bitwiseNot); + if (result != null) return result; + } + // Fetch tied/readonly scalar once to avoid redundant FETCH calls RuntimeScalar val = runtimeScalar.type < RuntimeScalarType.TIED_SCALAR ? runtimeScalar : runtimeScalar.type == RuntimeScalarType.TIED_SCALAR ? runtimeScalar.tiedFetch() : @@ -257,6 +265,14 @@ public static RuntimeScalar bitwiseNotBinary(RuntimeScalar runtimeScalar) { * @return A new RuntimeScalar with the result of the integer bitwise NOT operation. */ public static RuntimeScalar integerBitwiseNot(RuntimeScalar runtimeScalar) { + // Check for overloaded '~' operator on blessed objects + int blessId = blessedId(runtimeScalar); + if (blessId < 0) { + RuntimeScalar result = OverloadContext.tryOneArgumentOverload( + runtimeScalar, blessId, "(~", "~", BitwiseOperators::integerBitwiseNot); + if (result != null) return result; + } + // Fetch tied/readonly scalar once to avoid redundant FETCH calls RuntimeScalar val = runtimeScalar.type < RuntimeScalarType.TIED_SCALAR ? runtimeScalar : runtimeScalar.type == RuntimeScalarType.TIED_SCALAR ? runtimeScalar.tiedFetch() : diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java b/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java index 0e60c2b62..e71321cc1 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java @@ -411,14 +411,18 @@ public static RuntimeList VERSION(RuntimeArray args, int ctx) { // Retrieve the $VERSION variable from the package String versionVariableName = NameNormalizer.normalizeVariableName("VERSION", perlClassName); RuntimeScalar hasVersion = GlobalVariable.getGlobalVariable(versionVariableName); - if (hasVersion.toString().isEmpty()) { - throw new PerlCompilerException(perlClassName + " does not define $" + perlClassName + "::VERSION--version check failed"); - } + // If no version argument was provided, just return the current $VERSION (may be undef) + // Perl 5: Module->VERSION with no args returns $VERSION or undef, never throws if (!wantVersion.getDefinedBoolean()) { return hasVersion.getList(); } + // A version argument was provided - check requirement + if (hasVersion.toString().isEmpty()) { + throw new PerlCompilerException(perlClassName + " does not define $" + perlClassName + "::VERSION--version check failed"); + } + RuntimeScalar packageVersion = VersionHelper.compareVersion(hasVersion, wantVersion, perlClassName); return new RuntimeScalar(packageVersion).getList(); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/CallerStack.java b/src/main/java/org/perlonjava/runtime/runtimetypes/CallerStack.java index 36379f1d3..7857cd39d 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/CallerStack.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/CallerStack.java @@ -21,9 +21,6 @@ public class CallerStack { * @param line The line number in the file where the call originated. */ public static void push(String packageName, String filename, int line) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG CallerStack.push: pkg=" + packageName + " file=" + filename + " line=" + line + " (stack size now " + (callerStack.size() + 1) + ")"); - } callerStack.add(new CallerInfo(packageName, filename, line)); } @@ -36,9 +33,6 @@ public static void push(String packageName, String filename, int line) { * @param resolver A function to compute the CallerInfo when needed. */ public static void pushLazy(String packageName, CallerInfoResolver resolver) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG CallerStack.pushLazy: pkg=" + packageName + " (stack size now " + (callerStack.size() + 1) + ")"); - } callerStack.add(new LazyCallerInfo(packageName, resolver)); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ErrorMessageUtil.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ErrorMessageUtil.java index 5dcd38f8a..c543f0dd7 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ErrorMessageUtil.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ErrorMessageUtil.java @@ -202,9 +202,6 @@ public String getFileName() { } public void setFileName(String fileName) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG setFileName: " + this.fileName + " -> " + fileName); - } this.fileName = fileName; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ExceptionFormatter.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ExceptionFormatter.java index 9ca5e906c..428f217d6 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ExceptionFormatter.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ExceptionFormatter.java @@ -106,10 +106,6 @@ private static StackTraceResult formatThrowable(Throwable t) { boolean lastWasRunSpecialBlock = false; for (var element : t.getStackTrace()) { - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG ExceptionFormatter: element class=" + element.getClassName() + " method=" + element.getMethodName() + " file=" + element.getFileName() + " line=" + element.getLineNumber()); - } - boolean isRunSpecialBlock = element.getClassName().equals("org.perlonjava.frontend.parser.SpecialBlockParser") && element.getMethodName().equals("runSpecialBlock"); @@ -131,9 +127,6 @@ private static StackTraceResult formatThrowable(Throwable t) { } lastFileName = callerInfo.filename() != null ? callerInfo.filename() : ""; callerStackIndex++; - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG ExceptionFormatter: runSpecialBlock corrected frame with CallerStack[" + (callerStackIndex - 1) + "] pkg=" + callerInfo.packageName() + " file=" + callerInfo.filename() + " line=" + callerInfo.line()); - } } lastWasRunSpecialBlock = true; continue; @@ -171,64 +164,45 @@ private static StackTraceResult formatThrowable(Throwable t) { if (!addedFrameForCurrentLevel && interpreterFrameIndex < interpreterFrames.size()) { var frame = interpreterFrames.get(interpreterFrameIndex); if (frame != null && frame.code() != null) { - // First check CallerStack for accurate call site info. - // CallerStack entries are pushed by CALL_SUB/CALL_METHOD with the exact - // call site location, which is more accurate than the current PC. - var callerInfo = CallerStack.peek(interpreterFrameIndex); + // For interpreter frames, use tokenIndex/PC-based lookup to get + // the sub's own location. Unlike CallerStack entries (which may come + // from compile-time contexts like runSpecialBlock), the PC-based + // lookup always gives the correct location for this interpreter frame. + // The caller's location will come from the NEXT frame in the stack + // (either a JVM anon class or another interpreter frame), and + // caller() will skip this frame to reach it. String pkg = null; String filename = frame.code().sourceName; String line = String.valueOf(frame.code().sourceLine); - - if (callerInfo != null && callerInfo.filename() != null) { - // Use CallerStack info for call site location - pkg = callerInfo.packageName(); - filename = callerInfo.filename(); - line = String.valueOf(callerInfo.line()); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG ExceptionFormatter: using CallerStack[" + interpreterFrameIndex + - "] pkg=" + pkg + " file=" + filename + " line=" + line); - } - } else { - // Fallback: get tokenIndex from PC mapping - Integer tokenIndex = null; - int pc = -1; - if (interpreterFrameIndex < interpreterPcs.size()) { - pc = interpreterPcs.get(interpreterFrameIndex); - if (frame.code().pcToTokenIndex != null && !frame.code().pcToTokenIndex.isEmpty()) { - var entryPc = frame.code().pcToTokenIndex.floorEntry(pc); - if (entryPc != null) { - tokenIndex = entryPc.getValue(); - } + // Get tokenIndex from PC mapping + Integer tokenIndex = null; + int pc = -1; + if (interpreterFrameIndex < interpreterPcs.size()) { + pc = interpreterPcs.get(interpreterFrameIndex); + if (frame.code().pcToTokenIndex != null && !frame.code().pcToTokenIndex.isEmpty()) { + var entryPc = frame.code().pcToTokenIndex.floorEntry(pc); + if (entryPc != null) { + tokenIndex = entryPc.getValue(); } } - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG ExceptionFormatter: fallback interpreter frame " + interpreterFrameIndex + - " pc=" + pc + " tokenIndex=" + tokenIndex + - " sourceName=" + frame.code().sourceName); - } - - // Look up package from ByteCodeSourceMapper using tokenIndex - if (tokenIndex != null && frame.code().sourceName != null) { - pkg = ByteCodeSourceMapper.getPackageAtLocation(frame.code().sourceName, tokenIndex); - } - if (pkg == null) { - // Fallback: runtime package for innermost frame, compile-time for others - pkg = (interpreterFrameIndex == 0) - ? InterpreterState.currentPackage.get().toString() - : frame.packageName(); - } + } + // Look up package from ByteCodeSourceMapper using tokenIndex + if (tokenIndex != null && frame.code().sourceName != null) { + pkg = ByteCodeSourceMapper.getPackageAtLocation(frame.code().sourceName, tokenIndex); + } + if (pkg == null) { + // Fallback: runtime package for innermost frame, compile-time for others + pkg = (interpreterFrameIndex == 0) + ? InterpreterState.currentPackage.get().toString() + : frame.packageName(); + } - // Use tokenIndex for line lookup - if (tokenIndex != null && frame.code().errorUtil != null) { - ErrorMessageUtil.SourceLocation loc = frame.code().errorUtil.getSourceLocationAccurate(tokenIndex); - filename = loc.fileName(); - line = String.valueOf(loc.lineNumber()); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG ExceptionFormatter: tokenIndex " + tokenIndex + - " -> file=" + filename + " line=" + line); - } - } + // Use tokenIndex for line lookup + if (tokenIndex != null && frame.code().errorUtil != null) { + ErrorMessageUtil.SourceLocation loc = frame.code().errorUtil.getSourceLocationAccurate(tokenIndex); + filename = loc.fileName(); + line = String.valueOf(loc.lineNumber()); } String subName = frame.subroutineName(); @@ -242,9 +216,9 @@ private static StackTraceResult formatThrowable(Throwable t) { entry.add(filename); entry.add(line); entry.add(subName); - if (stackTrace.isEmpty()) { - firstFrameFromInterpreter = true; - } + // Interpreter frames from tokenIndex/PC represent the sub's OWN + // location (like JVM frames), so firstFrameFromInterpreter stays + // false and caller() will skip this frame to reach the actual caller. stackTrace.add(entry); lastFileName = filename != null ? filename : ""; addedFrameForCurrentLevel = true; @@ -255,6 +229,13 @@ private static StackTraceResult formatThrowable(Throwable t) { // parseStackTraceElement returns null if location already seen in a different class var loc = ByteCodeSourceMapper.parseStackTraceElement(element, locationToClassName); if (loc != null) { + // Skip frames with invalid line numbers (e.g., line -1 from compiler + // infrastructure like runSpecialBlock's anonymous sub wrappers). + // These frames don't represent real user-visible call sites. + if (loc.lineNumber() < 0) { + continue; + } + // Get subroutine name from the source location (now preserved in bytecode metadata) String subName = loc.subroutineName(); @@ -270,9 +251,6 @@ private static StackTraceResult formatThrowable(Throwable t) { entry.add(loc.sourceFileName()); entry.add(String.valueOf(loc.lineNumber())); entry.add(subName); // Add subroutine name - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG ExceptionFormatter: adding frame pkg=" + loc.packageName() + " file=" + loc.sourceFileName() + " line=" + loc.lineNumber()); - } stackTrace.add(entry); lastFileName = loc.sourceFileName() != null ? loc.sourceFileName() : ""; } @@ -288,10 +266,6 @@ private static StackTraceResult formatThrowable(Throwable t) { // Add the outermost artificial stack entry if different from last file var callerInfo = CallerStack.peek(effectiveCallerStackIndex); - if (System.getenv("DEBUG_CALLER") != null) { - System.err.println("DEBUG ExceptionFormatter: CallerStack at index " + effectiveCallerStackIndex + " = " + - (callerInfo != null ? "pkg=" + callerInfo.packageName() + " file=" + callerInfo.filename() + " line=" + callerInfo.line() : "null")); - } if (callerInfo != null && callerInfo.filename() != null && !lastFileName.equals(callerInfo.filename())) { var entry = new ArrayList(); String outerPkg = callerInfo.packageName(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index ac31756b2..bf34c2905 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -403,6 +403,32 @@ public static EvalRuntimeContext getEvalRuntimeContext() { return evalRuntimeContext.get(); } + /** + * Save and clear the eval runtime context. + * Used by require/do to prevent inner compilations from seeing the eval's captured variables. + * The returned value should be passed to {@link #restoreEvalRuntimeContext} to restore it. + * + * @return The saved eval runtime context (may be null) + */ + public static EvalRuntimeContext saveAndClearEvalRuntimeContext() { + EvalRuntimeContext saved = evalRuntimeContext.get(); + if (saved != null) { + evalRuntimeContext.remove(); + } + return saved; + } + + /** + * Restore a previously saved eval runtime context. + * + * @param saved The context returned by {@link #saveAndClearEvalRuntimeContext} + */ + public static void restoreEvalRuntimeContext(EvalRuntimeContext saved) { + if (saved != null) { + evalRuntimeContext.set(saved); + } + } + /** * Gets the next eval sequence number and generates a filename. * Used by both baseline compiler and interpreter for consistent naming. @@ -1423,8 +1449,13 @@ public static RuntimeList callCached(int callsiteId, int callContext) { // Fast path: check inline cache for monomorphic call sites if (method.type == RuntimeScalarType.STRING || method.type == RuntimeScalarType.BYTE_STRING) { - if (RuntimeScalarType.isReference(runtimeScalar)) { - int blessId = ((RuntimeBase) runtimeScalar.value).blessId; + // Unwrap READONLY_SCALAR for blessId check (same as in call()) + RuntimeScalar invocant = runtimeScalar; + while (invocant.type == RuntimeScalarType.READONLY_SCALAR) { + invocant = (RuntimeScalar) invocant.value; + } + if (RuntimeScalarType.isReference(invocant)) { + int blessId = ((RuntimeBase) invocant.value).blessId; if (blessId != 0) { int methodHash = System.identityHashCode(method.value); int cacheIndex = callsiteId & (METHOD_CALL_CACHE_SIZE - 1); @@ -1446,12 +1477,17 @@ public static RuntimeList callCached(int callsiteId, String autoloadVariableName = cachedCode.autoloadVariableName; if (autoloadVariableName != null) { String methodName = method.toString(); - // Use the original calling class (perlClassName), not the class - // where AUTOLOAD was found. Perl sets $AUTOLOAD to Child::method - // even when AUTOLOAD is inherited from Base. - String perlClassName = NameNormalizer.getBlessStr(blessId); - String fullMethodName = NameNormalizer.normalizeVariableName(methodName, perlClassName); - getGlobalVariable(autoloadVariableName).set(fullMethodName); + // Only set $AUTOLOAD when dispatching to AUTOLOAD as a fallback + // (method name != "AUTOLOAD"). When calling AUTOLOAD directly + // (e.g., $self->SUPER::AUTOLOAD), the caller has already set it. + if (!methodName.equals("AUTOLOAD")) { + // Use the original calling class (perlClassName), not the class + // where AUTOLOAD was found. Perl sets $AUTOLOAD to Child::method + // even when AUTOLOAD is inherited from Base. + String perlClassName = NameNormalizer.getBlessStr(blessId); + String fullMethodName = NameNormalizer.normalizeVariableName(methodName, perlClassName); + getGlobalVariable(autoloadVariableName).set(fullMethodName); + } } // Prefer PerlSubroutine interface over MethodHandle @@ -1499,7 +1535,7 @@ public static RuntimeList callCached(int callsiteId, } String autoloadVariableName = code.autoloadVariableName; - if (autoloadVariableName != null) { + if (autoloadVariableName != null && !methodName.equals("AUTOLOAD")) { // Use the original calling class, not where AUTOLOAD was found String fullMethodName = NameNormalizer.normalizeVariableName(methodName, perlClassName); getGlobalVariable(autoloadVariableName).set(fullMethodName); @@ -1542,21 +1578,30 @@ public static RuntimeList call(RuntimeScalar runtimeScalar, String methodName = method.toString(); + // Unwrap READONLY_SCALAR for method dispatch. + // Constants created via `use constant` with blessed refs go through + // Internals::SvREADONLY which wraps the scalar in READONLY_SCALAR. + // We must unwrap to see the actual reference type and blessId. + RuntimeScalar invocant = runtimeScalar; + while (invocant.type == RuntimeScalarType.READONLY_SCALAR) { + invocant = (RuntimeScalar) invocant.value; + } + // Retrieve Perl class name String perlClassName; - if (RuntimeScalarType.isReference(runtimeScalar)) { + if (RuntimeScalarType.isReference(invocant)) { // Handle all reference types (REFERENCE, ARRAYREFERENCE, HASHREFERENCE, etc.) - int blessId = ((RuntimeBase) runtimeScalar.value).blessId; + int blessId = ((RuntimeBase) invocant.value).blessId; if (blessId == 0) { - if (runtimeScalar.type == GLOBREFERENCE) { + if (invocant.type == GLOBREFERENCE) { // Auto-bless file handler to IO::File which inherits from both IO::Handle and IO::Seekable // This allows GLOBs to call methods like seek, tell, etc. perlClassName = "IO::File"; // Load the module if needed // TODO - optimize by creating a flag in RuntimeIO ModuleOperators.require(new RuntimeScalar("IO/File.pm")); - } else if (runtimeScalar.type == REGEX) { + } else if (invocant.type == REGEX) { // qr// objects are implicitly blessed into the Regexp class in Perl 5 // This allows $qr->isa("Regexp"), $qr->can("..."), etc. perlClassName = "Regexp"; @@ -1567,15 +1612,15 @@ public static RuntimeList call(RuntimeScalar runtimeScalar, } else { perlClassName = NameNormalizer.getBlessStr(blessId); } - } else if (runtimeScalar.type == RuntimeScalarType.GLOB) { + } else if (invocant.type == RuntimeScalarType.GLOB) { // Bare typeglob used as method invocant (e.g., *FH->print(...)) // Auto-bless to IO::File, same as GLOBREFERENCE perlClassName = "IO::File"; ModuleOperators.require(new RuntimeScalar("IO/File.pm")); - } else if (!runtimeScalar.getDefinedBoolean()) { + } else if (!invocant.getDefinedBoolean()) { throw new PerlCompilerException("Can't call method \"" + methodName + "\" on an undefined value"); } else { - perlClassName = runtimeScalar.toString(); + perlClassName = invocant.toString(); if (perlClassName.isEmpty()) { throw new PerlCompilerException("Can't call method \"" + methodName + "\" on an undefined value"); } @@ -1674,7 +1719,8 @@ public static RuntimeList call(RuntimeScalar runtimeScalar, // System.out.println("call ->" + method + " " + currentPackage + " " + args + " AUTOLOAD: " + ((RuntimeCode) method.value).autoloadVariableName); String autoloadVariableName = ((RuntimeCode) method.value).autoloadVariableName; - if (autoloadVariableName != null) { + if (autoloadVariableName != null + && !methodName.equals("AUTOLOAD") && !methodName.endsWith("::AUTOLOAD")) { // The inherited method is an autoloaded subroutine // Set the $AUTOLOAD variable to the name of the method that was called diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 153fbb278..548c52610 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -347,7 +347,13 @@ public RuntimeScalar getNumberLarge() { case TIED_SCALAR -> this.tiedFetch().getNumber(); case READONLY_SCALAR -> ((RuntimeScalar) this.value).getNumber(); case DUALVAR -> ((DualVar) this.value).numericValue(); - default -> Overload.numify(this); + default -> { + RuntimeScalar result = Overload.numify(this); + // Overload may return a string (e.g., "3.1" from 0+ handler); + // ensure it's converted to a proper numeric type + yield (result.type == INTEGER || result.type == DOUBLE) + ? result : result.getNumber(); + } }; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarUtils.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarUtils.java index 28f8ad70b..39ed13e94 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarUtils.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarUtils.java @@ -152,8 +152,12 @@ private static boolean looksLikeNumberSlow(RuntimeScalar runtimeScalar, int t) { if (str.isEmpty()) { return false; } - // Check for Inf and NaN - if (str.equalsIgnoreCase("Inf") || str.equalsIgnoreCase("Infinity") || str.equalsIgnoreCase("NaN")) { + // Check for Inf and NaN (with optional sign prefix) + String check = str; + if ((str.charAt(0) == '+' || str.charAt(0) == '-') && str.length() > 1) { + check = str.substring(1); + } + if (check.equalsIgnoreCase("Inf") || check.equalsIgnoreCase("Infinity") || check.equalsIgnoreCase("NaN")) { return true; } // Fast check: if first char isn't digit, +, -, or . it's not a number diff --git a/src/main/perl/lib/B.pm b/src/main/perl/lib/B.pm index ac60c53f5..6e6d0b062 100644 --- a/src/main/perl/lib/B.pm +++ b/src/main/perl/lib/B.pm @@ -197,6 +197,12 @@ package B::CV { $self->_introspect; return $self->{_is_anon} ? 0x0004 : 0; # CVf_ANON for anonymous subs } + + sub XSUB { + # PerlOnJava has no XSUBs (all code is Java bytecode or interpreted Perl). + # Return 0 (false) so callers like Type::Tiny::_has_xsub() get the right answer. + return 0; + } } package B::GV { diff --git a/src/test/resources/unit/subroutine_prototype_args.t b/src/test/resources/unit/subroutine_prototype_args.t index 96541e2dc..8aec0f266 100644 --- a/src/test/resources/unit/subroutine_prototype_args.t +++ b/src/test/resources/unit/subroutine_prototype_args.t @@ -254,5 +254,25 @@ subtest 'Glob and reference prototype *\$$;$' => sub { "Hash ref in \$ position without parentheses gives ref constructor error" ); }; +subtest "Optional scalar prototype with binary-only infix operators" => sub { + # Functions with ;$ prototype should treat binary-only operators as + # argument terminators, parsing Foo | Bar as (Foo()) | (Bar()) + sub opt_five (;$) { return $_[0] // 5 } + sub opt_three (;$) { return $_[0] // 3 } + + is( opt_five | opt_three, 7, 'optional proto with | infix operator' ); + is( opt_five ^ opt_three, 6, 'optional proto with ^ infix operator' ); + ok( opt_five == 5, 'optional proto with == infix operator' ); + ok( opt_five != 3, 'optional proto with != infix operator' ); + ok( opt_five > 3, 'optional proto with > infix operator' ); + ok( opt_five >= 5, 'optional proto with >= infix operator' ); + ok( opt_five =~ /5/, 'optional proto with =~ infix operator' ); + ok( opt_five !~ /3/, 'optional proto with !~ infix operator' ); + is( (opt_five ? "yes" : "no"), "yes", 'optional proto with ? ternary operator' ); + + # With parentheses should still work normally + is( opt_five(2) | opt_three(1), 3, 'optional proto with explicit args and | operator' ); +}; + done_testing();