diff --git a/AGENTS.md b/AGENTS.md index a1395b239..bc31a465a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,8 @@ PerlOnJava does **not** implement the following Perl features: ### Testing +**NEVER modify or delete existing tests.** Tests are the source of truth. If a test fails, fix the code, not the test. When in doubt, verify expected behavior with system Perl (`perl`, not `jperl`). + **ALWAYS use `make` commands. NEVER use raw mvn/gradlew commands.** | Command | What it does | diff --git a/build.gradle b/build.gradle index 8bb97092a..63ebb1661 100644 --- a/build.gradle +++ b/build.gradle @@ -195,6 +195,7 @@ dependencies { implementation libs.snakeyaml.engine // YAML processing implementation libs.tomlj // TOML processing implementation libs.commons.csv // CSV processing + implementation libs.sqlite.jdbc // SQLite JDBC driver // JNR-POSIX removed - using Java FFM API for native access (Java 22+) // Testing dependencies diff --git a/dev/modules/README.md b/dev/modules/README.md index 942924bdb..cbd3c708b 100644 --- a/dev/modules/README.md +++ b/dev/modules/README.md @@ -12,6 +12,7 @@ This directory contains design documents and guides related to porting CPAN modu | [xsloader.md](xsloader.md) | XSLoader architecture | | [makemaker_perlonjava.md](makemaker_perlonjava.md) | ExtUtils::MakeMaker implementation | | [cpan_client.md](cpan_client.md) | jcpan - CPAN client for PerlOnJava | +| [dbix_class.md](dbix_class.md) | DBIx::Class support (in progress) | ## Module Status Overview @@ -117,5 +118,6 @@ PERL_PARAMS_UTIL_PP=1 ./jcpan -t Class::Load - [moose_support.md](moose_support.md) - Moose support (in progress) - [moo_support.md](moo_support.md) - Moo support (working) - [JCPAN_DATETIME_FIXES.md](JCPAN_DATETIME_FIXES.md) - DateTime via jcpan +- [dbix_class.md](dbix_class.md) - DBIx::Class support - [log4perl-compatibility.md](log4perl-compatibility.md) - Log::Log4perl - [term_readkey.md](term_readkey.md) - Term::ReadKey diff --git a/dev/modules/dbix_class.md b/dev/modules/dbix_class.md new file mode 100644 index 000000000..8bea570a7 --- /dev/null +++ b/dev/modules/dbix_class.md @@ -0,0 +1,370 @@ +# DBIx::Class Fix Plan + +## Overview + +**Module**: DBIx::Class 0.082844 +**Test command**: `./jcpan -t DBIx::Class` +**Branch**: `feature/dbix-class-support` +**PR**: https://github.com/fglock/PerlOnJava/pull/415 +**Status**: Phase 5 — Fix runtime issues iteratively + +## Dependency Tree + +### Runtime Dependencies + +| Dependency | Required | Status | Notes | +|-----------|---------|--------|-------| +| DBI | >= 1.57 | PASS | Bundled Java JDBC implementation; `$VERSION = '1.643'` added | +| Sub::Name | >= 0.04 | PASS | Bundled Java implementation | +| Try::Tiny | >= 0.07 | PASS | Bundled pure Perl | +| Text::Balanced | >= 2.00 | PASS | Bundled core module | +| Moo | >= 2.000 | PASS | Installed (v2.005005) via jcpan | +| Sub::Quote | >= 2.006006 | PASS | Installed via jcpan | +| MRO::Compat | >= 0.12 | PASS | Installed (v0.15); uses native `mro` on PerlOnJava | +| namespace::clean | >= 0.24 | PASS | Installed (v0.27) | +| Scope::Guard | >= 0.03 | PASS | Installed | +| Class::Inspector | >= 1.24 | PASS | Installed | +| Class::Accessor::Grouped | >= 0.10012 | PASS | Installed via jcpan | +| Class::C3::Componentised | >= 1.0009 | PASS | Installed via jcpan | +| Config::Any | >= 0.20 | PASS | Installed via jcpan | +| Context::Preserve | >= 0.01 | PASS | Installed via jcpan | +| Data::Dumper::Concise | >= 2.020 | PASS | Installed via jcpan | +| Devel::GlobalDestruction | >= 0.09 | PASS | Installed via jcpan | +| Hash::Merge | >= 0.12 | PASS | Installed via jcpan | +| Module::Find | >= 0.07 | PASS | Installed via jcpan | +| Path::Class | >= 0.18 | PASS | Installed but has File::stat VerifyError (see Known Bugs) | +| SQL::Abstract::Classic | >= 1.91 | PASS | Installed via jcpan | + +### Test Dependencies + +| Dependency | Status | Notes | +|-----------|--------|-------| +| Test::More | >= 0.94 | PASS | Bundled | +| Test::Deep | >= 0.101 | PASS | Installed | +| Test::Warn | >= 0.21 | PASS | Installed | +| File::Temp | >= 0.22 | PASS | Bundled Java implementation | +| Package::Stash | >= 0.28 | PASS | Installed (PP fallback) | +| Test::Exception | >= 0.31 | PASS | Installed; Sub::Uplevel CORE::GLOBAL::caller bug fixed | +| DBD::SQLite | >= 1.29 | PASS | JDBC shim via `DBD/SQLite.pm` + sqlite-jdbc driver | + +### Supporting Modules (already installed) + +B::Hooks::EndOfScope, Package::Stash::PP, Role::Tiny, Class::Method::Modifiers, +Module::Implementation, Module::Runtime, Params::Util, Exporter::Tiny, Type::Tiny, +Scalar::Util, List::Util, Storable, Data::Dumper, mro, namespace::autoclean, +Sub::Util, Dist::CheckConflicts, Eval::Closure, Sub::Uplevel. + +--- + +## Fix Plan + +### Phase 1: Unblock Makefile.PL (DONE) + +Four blockers fixed to get `Makefile.PL` to complete: + +| Blocker | Error | Fix | Status | +|---------|-------|-----|--------| +| 1. `strict::bits` missing | `Undefined subroutine &strict::bits` | Added `bits`, `all_bits`, `all_explicit_bits` to Strict.java | DONE | +| 2. `UNIVERSAL::can` returning AUTOLOAD methods | Module::Install `$self->can('call')` resolved via AUTOLOAD | Added `isAutoloadDispatch()` filter in Universal.java | DONE | +| 3. `goto &sub` wantarray + eval{} @_ sharing | `Not an ARRAY reference` at AutoInstall.pm line 32 | Fixed tail call trampoline context propagation; eval{} now shares @_ | DONE | +| 4. `%{+{@a}}` parsing | `Type of arg 1 to keys must be hash or array` | Added +{ check in IdentifierParser.java for hash constructor disambiguation | DONE | + +### Phase 2: Install missing pure-Perl dependencies (DONE) + +All runtime and test dependencies installed via `./jcpan -fi`: + +| Step | Description | Status | +|------|-------------|--------| +| 2.1 | `./jcpan install Devel::GlobalDestruction` | DONE | +| 2.2 | `./jcpan install Context::Preserve` | DONE | +| 2.3 | `./jcpan install Data::Dumper::Concise` | DONE | +| 2.4 | `./jcpan install Module::Find` | DONE | +| 2.5 | `./jcpan install Path::Class` | DONE (has VerifyError, see Known Bugs) | +| 2.6 | `./jcpan install Hash::Merge` | DONE | +| 2.7 | `./jcpan install Config::Any` | DONE | +| 2.8 | `./jcpan install Class::Accessor::Grouped` | DONE | +| 2.9 | `./jcpan install Class::C3::Componentised` | DONE | +| 2.10 | `./jcpan install SQL::Abstract::Classic` | DONE | +| 2.11 | `./jcpan install Test::Exception` | DONE | + +### Phase 3: Fix DBI version detection (DONE) + +| Step | Description | Status | +|------|-------------|--------| +| 3.1 | Added `our $VERSION = '1.643';` to `src/main/perl/lib/DBI.pm` | DONE | +| 3.2 | Makefile.PL now recognizes DBI version correctly | DONE | + +### Phase 4: Create DBD::SQLite JDBC shim (DONE) + +| Step | Description | File | Status | +|------|-------------|------|--------| +| 4.1 | Created `DBD::SQLite` shim translating DSN format | `src/main/perl/lib/DBD/SQLite.pm` | DONE | +| 4.2 | Added sqlite-jdbc 3.49.1.0 dependency | `build.gradle`, `pom.xml`, `gradle/libs.versions.toml` | DONE | +| 4.3 | Added try/catch for metadata on DDL statements | `DBI.java` | DONE | +| 4.4 | Verified `DBI->connect("dbi:SQLite:dbname=:memory:")` works | manual test | DONE | + +### Phase 4.5: Fix CORE::GLOBAL::caller override bug (DONE) + +Sub::Uplevel (dependency of Test::Exception) overrides `*CORE::GLOBAL::caller`. +This caused a parse error when `caller` appeared as the RHS of an infix operator. + +| Step | Description | File | Status | +|------|-------------|------|--------| +| 4.5.1 | Fixed whitespace-sensitive token insertion for CORE::GLOBAL:: overrides | `ParsePrimary.java` | DONE | +| 4.5.2 | Test::Exception now loads and works correctly | verified | DONE | + +### Phase 4.6: Fix stash aliasing glob vivification (DONE) + +Package::Stash::PP's `add_symbol` does `*__ANON__:: = \%Pkg::` then `*{"__ANON__::foo"}`. +PerlOnJava's flat-map architecture stored the vivified glob under the wrong prefix. + +| Step | Description | File | Status | +|------|-------------|------|--------| +| 4.6.1 | Added `resolveStashHashRedirect()` to detect aliased stashes | `GlobalVariable.java` | DONE | +| 4.6.2 | Integrated redirect into `getGlobalIO()` and JVM backend | `GlobalVariable.java`, `EmitVariable.java` | DONE | + +### Phase 4.7: Fix mixed-context ternary lvalue assignment (DONE) + +`Class::Accessor::Grouped` uses `wantarray ? @rv = eval $src : $rv[0] = eval $src`. +Perl 5 parses this as `(wantarray ? (@rv = eval $src) : $rv[0]) = eval $src` — a +ternary-as-lvalue where the true branch contains an assignment expression. +`LValueVisitor` threw "Assignment to both a list and a scalar" at compile time. + +The fix matches Perl 5's `S_assignment_type()` from `op.c`: assignment ops +(`OP_AASSIGN`, `OP_SASSIGN`) are not in the `ASSIGN_LIST` set, so they return +`ASSIGN_SCALAR` when classifying ternary branches. This allows the CAG pattern +while still rejecting genuinely invalid patterns like `($c ? $a : @b) = 123`. + +| Step | Description | File | Status | +|------|-------------|------|--------| +| 4.7.1 | Add `assignmentTypeOf()` helper to classify ternary branches matching Perl 5's `S_assignment_type()` | `LValueVisitor.java` | DONE | + +**Known runtime limitation**: The ternary-as-lvalue emitter does not properly +handle assignment-expression branches with non-constant conditions (e.g., +`wantarray`). When the true branch is taken at runtime, the result of +`@rv = eval $src` is not returned as a modifiable lvalue, causing +"Modification of a read-only value attempted". Constant-folded cases +(`1 ? @rv = eval $src : $rv[0]`) work correctly. This is a separate JVM +backend code generation issue. + +### Phase 4.8: Fix `cp` on read-only installed files (DONE) + +`ExtUtils::MakeMaker`'s `_shell_cp` generated bare `cp` commands. When reinstalling +a module whose `.pod`/`.pm` files were previously installed as read-only (0444), +`cp` fails with "Permission denied". Fixed by adding `rm -f` before `cp`. + +| Step | Description | File | Status | +|------|-------------|------|--------| +| 4.8.1 | Changed `_shell_cp` to `rm -f` then `cp` | `ExtUtils/MakeMaker.pm` | DONE | + +### Phase 5: Fix runtime issues (CURRENT — iterative) + +| Step | Description | File | Status | +|------|-------------|------|--------| +| 5.1 | Fix `@${$v}` string interpolation | `StringSegmentParser.java` | DONE | +| 5.2 | Add `B::SV::REFCNT` method (returns 0 for JVM tracing GC) | `B.pm` | DONE | +| 5.3 | Add DBI `FETCH`/`STORE` methods for tied-hash compat | `DBI.pm` | DONE | +| 5.4 | Add `DBI::Const::GetInfoReturn` stub | `DBI/Const/GetInfoReturn.pm` | DONE | +| 5.5 | Fix list assignment autovivification (`($x, @$undef_ref) = ...`) | `RuntimeList.java` | DONE | +| 5.6 | Add DBI `execute_for_fetch` and `bind_param` methods | `DBI.pm` | DONE | +| 5.7 | Fix `&func` (no parens) to share caller's `@_` by alias | Parser, JVM emitter, interpreter | DONE | +| 5.8 | Fix DBI `execute()` return value (row count, not hash ref) | `DBI.java` | DONE | +| 5.9 | Set `$dbh->{Driver}` for SQLite driver detection | `DBI.pm` | DONE | +| 5.10 | Fix DBI `get_info()` to accept numeric constants per DBI spec | `DBI.java` | DONE | +| 5.11 | Add DBI SQL type constants (`SQL_BIGINT`, `SQL_INTEGER`, etc.) | `DBI.pm` | DONE | +| 5.12 | Fix `bind_columns` + `fetch` to update bound scalar references | `DBI.java` | DONE | + +**t/60core.t results** (17 tests emitted): +- **ok 1–12**: Basic CRUD, update, dirty columns — all pass +- **not ok 13–17**: Garbage collection tests — expected failures (JVM has no reference counting / `weaken`) +- RowParser.pm line 260 crash still occurs in END block cleanup (non-blocking — all real tests pass first) + +**Full test suite results** (92 test files): +- **15 fully passing** (no failures at all) +- **36 GC-only failures** (all real tests pass, only `weaken`-based GC leak tests fail) +- **12 tests with real failures** (see Blocking Issues below) +- **27 skipped** (DB-specific: Pg, Oracle, MSSQL, etc.; threads; fork) +- **2 zero-test** (MySQL-specific, result_set_column) + +**Effective pass rate**: 51/65 active test files have all real tests passing (78%). + +--- + +## Blocking Issues — Not Quick Fixes + +### HIGH PRIORITY: VerifyError (bytecode compiler bug) + +**Symptom**: `java.lang.VerifyError: Bad type on operand stack` when compiling complex anonymous subroutines with many local variables. + +**Affected tests**: `t/00describe_environment.t` (crashes after already emitting `1..0` skip) + +**Root cause**: The JVM bytecode emitter generates incorrect stack map frames when a subroutine has many locals and complex control flow (ternary chains, nested `eval`, `for` loops). The JVM verifier rejects the class because `java/lang/Object` on the stack is not assignable to `RuntimeScalar`. + +**What's needed to fix**: +- Debug the bytecode emitter's stack map frame generation (likely in `EmitSubroutine.java` or related emit classes) +- The anonymous sub `anon2920` in the test has ~100 local variable slots and deeply nested control flow +- May need to split large subroutines or fix how the stack map calculator handles branch merging +- This is the same class of bug as the File::stat VerifyError (see Known Bugs below) + +**Impact**: Currently low for DBIx::Class (test already skips), but affects any complex Perl subroutine. Could block other CPAN modules. + +### SYSTEMIC: GC / `weaken` / `isweak` absence + +**Symptom**: Every DBIx::Class test file appends 5+ garbage collection leak tests that always fail. + +**Affected tests**: All 36 "GC-only" failures, plus the GC portion of all 12 "real failure" tests. + +**Root cause**: JVM uses tracing GC, not reference counting. PerlOnJava cannot implement `weaken`/`isweak` from `Scalar::Util`. DBIx::Class uses `Test::DBIx::Class::LeakTracer` which inserts `is_refcount`-based leak tests at END time. + +**What's needed to fix**: +- **Option A (hard)**: Implement reference counting alongside JVM GC using a side table mapping object IDs to manual ref counts. Would require wrapping every `RuntimeScalar` assignment. Massive performance impact. +- **Option B (pragmatic)**: Accept these as known failures. The GC tests verify Perl-specific memory patterns that don't apply to JVM. Real functionality works correctly. +- **Option C (workaround)**: Patch DBIx::Class's test infrastructure to skip leak tests when `Scalar::Util::weaken` is not functional. Could set `$ENV{DBIC_SKIP_LEAK_TESTS}` or similar. + +**Impact**: Makes test output noisy (287 GC-only sub-test failures) but does NOT affect functionality. + +### RowParser.pm line 260 crash (post-test cleanup) + +**Symptom**: `Not a HASH reference at RowParser.pm line 260` — occurs 8 times across the test suite, always in END blocks or cleanup after tests have already completed. + +**Root cause**: During END-block teardown, `_resolve_collapse` is called with stale or partially-destroyed data structures. The code does `$my_cols->{$_}{via_fk}` where `$my_cols->{$_}` may have been clobbered during object destruction. Since PerlOnJava lacks `DESTROY`/`DEMOLISH`, circular references persist and cleanup code may run in unexpected order. + +**What's needed to fix**: +- Investigate exactly which END block triggers the call +- May be related to `weaken` absence — objects that should be dead are still alive +- Could potentially be fixed by adding defensive `ref()` checks in RowParser.pm, but that's patching the module rather than fixing the engine + +**Impact**: Non-blocking — all real tests complete before the crash. Only affects test harness exit code. + +--- + +## Remaining Real Failures (12 tests) + +### Tests needing DBI/Storage fixes + +| Test | Failing | Root cause | Fix needed | +|------|---------|------------|------------| +| `t/64db.t` | tests 3-4 | `column_info()` not implemented in DBI shim | Implement `$dbh->column_info()` using JDBC `DatabaseMetaData.getColumns()` | +| `t/752sqlite.t` | test 6 | Transaction state tracking incomplete — `$dbh->{AutoCommit}` not updated on BEGIN/COMMIT | Track txn state in DBI `do()` or implement `begin_work`/`commit`/`rollback` that update `AutoCommit` | + +### Tests needing caller/carp fixes + +| Test | Failing | Root cause | Fix needed | +|------|---------|------------|------------| +| `t/106dbic_carp.t` | tests 2-3 | DBIx::Class::Carp callsite detection — `caller()` returns wrong package/line | Fix `caller()` to return correct info through `namespace::clean`'d frames | +| `t/100populate.t` | test 2 | Exception message doesn't include expected callsite info | Same caller/carp issue as above | + +### Tests needing serialization/Storable fixes + +| Test | Failing | Root cause | Fix needed | +|------|---------|------------|------------| +| `t/84serialize.t` | test 2 | `Storable::dclone` fails on blessed DBI handle objects | Need `dclone` to handle Java-backed objects or provide STORABLE_freeze/thaw hooks in DBI | + +### Tests needing module loading fixes + +| Test | Failing | Root cause | Fix needed | +|------|---------|------------|------------| +| `t/90ensure_class_loaded.t` | tests 14,17,28 | PAR (Perl Archive) detection + `$INC{...}` manipulation edge cases | Fix `%INC` handling for modules that set `$INC{file}` without returning true | +| `t/40resultsetmanager.t` | tests 2-4 | Deprecated `ResultSetManager` uses source filtering (`Module::Pluggable` + runtime class creation) | Likely needs `Module::Pluggable` fixes or is acceptable as deprecated-feature failure | +| `t/53lean_startup.t` | test 5 | Module loading tracking — test checks exact set of loaded modules | PerlOnJava loads extra modules; would need to match exact Perl load footprint | + +### Tests needing misc fixes + +| Test | Failing | Root cause | Fix needed | +|------|---------|------------|------------| +| `t/40compose_connection.t` | (GC only) | Actually all real tests pass — has 7 GC failures instead of 5 | No fix needed (mis-categorized by test harness due to extra GC tests) | +| `t/52leaks.t` | test 4 | Dedicated leak testing — "how did we get so far?!" means previous leak tests should have aborted | `weaken` absence; same systemic issue as GC tests | +| `t/85utf8.t` | test 7 | Warning about incorrect `use utf8` ordering not issued | May need to implement `utf8` pragma ordering detection | +| `t/93single_accessor_object.t` | (GC only) | Actually all real tests pass — has 8 GC failures | No fix needed | + +--- + +## Known Bugs + +### File::stat VerifyError +- `use File::stat` triggers `java.lang.VerifyError: Bad type on operand stack` +- Root cause: bytecode generation issue with `Class::Struct` + `use overload` (`-X` operator) +- Minimal repro: `use Class::Struct; use overload ("-X" => sub { "" }, fallback => 1); struct( 'Foo' => [dev => "\$", ino => "\$"] );` +- Impact: Path::Class cannot load; DBIx::Class works without it +- Same class of bug as the t/00describe_environment.t VerifyError (see HIGH PRIORITY above) + +## Summary + +| Phase | Complexity | Description | Status | +|-------|-----------|-------------|--------| +| 1 | Medium | Unblock Makefile.PL (4 engine fixes) | DONE | +| 2 | Medium | Install ~11 missing pure-Perl deps via jcpan | DONE | +| 3 | Simple | Fix DBI version detection | DONE | +| 4 | Medium | Create DBD::SQLite JDBC compatibility shim | DONE | +| 4.5 | Medium | Fix CORE::GLOBAL::caller override bug | DONE | +| 4.6 | Medium | Fix stash aliasing glob vivification | DONE | +| 4.7 | Simple | Fix mixed-context ternary lvalue assignment | DONE | +| 4.8 | Simple | Fix `cp` on read-only installed files | DONE | +| 5 | Complex | Fix runtime issues iteratively | **CURRENT** | + +## Progress Tracking + +### Current Status: Phase 5 — fixing runtime issues iteratively + +### Completed Phases +- [x] Phase 1: Unblock Makefile.PL (2025-03-31) + - Blocker 1: Added strict::bits to Strict.java + - Blocker 2: Fixed UNIVERSAL::can AUTOLOAD filter in Universal.java + - Blocker 3: Fixed goto &sub wantarray propagation + eval{} @_ sharing + - Blocker 4: Fixed +{} hash constructor parsing in IdentifierParser.java +- [x] Phase 2: Install missing pure-Perl dependencies (2025-03-31) + - All 11 modules installed via `./jcpan -fi` +- [x] Phase 3: Fix DBI version detection (2025-03-31) + - Added `our $VERSION = '1.643'` to DBI.pm +- [x] Phase 4: Create DBD::SQLite JDBC shim (2025-03-31) + - Created DBD/SQLite.pm DSN translation shim + - Added sqlite-jdbc 3.49.1.0 dependency + - Wrapped getMetaData()/getParameterMetaData() in DBI.java +- [x] Phase 4.5: Fix CORE::GLOBAL::caller bug (2025-03-31) + - Fixed whitespace-sensitive token insertion in ParsePrimary.java + - Test::Exception + Sub::Uplevel now work correctly +- [x] Phase 4.6: Fix stash aliasing glob vivification (2025-03-31) + - Added `resolveStashHashRedirect()` to GlobalVariable.java + - Applied redirect in `getGlobalIO()` and EmitVariable.java (JVM backend) + - Unblocks Package::Stash::PP and namespace::clean +- [x] Phase 4.7: Fix mixed-context ternary lvalue assignment (2025-03-31) + - Added `assignmentTypeOf()` helper matching Perl 5's `S_assignment_type()` — assignment expressions classified as SCALAR in ternary branches + - Unblocks Class::Accessor::Grouped (compile-time) + - Known runtime limitation: ternary-as-lvalue with assignment branches fails for non-constant conditions (e.g., `wantarray`) +- [x] Phase 4.8: Fix `cp` on read-only installed files (2025-03-31) + - Changed `_shell_cp` in ExtUtils::MakeMaker.pm to `rm -f` then `cp` + - Fixes reinstall of modules with read-only (0444) .pod/.pm files +- [x] Phase 5 steps 5.1–5.8 (2026-03-31 / 2026-04-01) + - 5.1: Fixed `@${$v}` string interpolation in StringSegmentParser.java + - 5.2: Added `B::SV::REFCNT` returning 0 (JVM has no reference counting) + - 5.3: Added DBI `FETCH`/`STORE` wrappers for tied-hash compatibility + - 5.4: Created `DBI::Const::GetInfoReturn` stub module + - 5.5: Fixed list assignment autovivification in RuntimeList.java + - 5.6: Added DBI `execute_for_fetch` and `bind_param` methods + - 5.7: Fixed `&func` (no parens) to share caller's `@_` by alias — unblocks Hash::Merge + - 5.8: Fixed DBI `execute()` to return row count per DBI spec — unblocks UPDATE operations +- [x] Phase 5 steps 5.9–5.12 (2026-04-01) + - 5.9: Set `$dbh->{Driver}` with `DBI::dr` object — DBIC now detects SQLite driver + - 5.10: Fixed `get_info()` to accept numeric DBI constants and return scalar + - 5.11: Added DBI SQL type constants (`SQL_BIGINT`, `SQL_INTEGER`, etc.) + - 5.12: Fixed `bind_columns` + `fetch` to update bound scalar references — unblocks ALL join/prefetch queries + - Result: 51/65 active tests now pass all real tests (was ~15/65 before) + +### Next Steps +1. **Quick wins**: Implement `column_info()` in DBI (fixes t/64db.t) and `AutoCommit` txn tracking (fixes t/752sqlite.t) +2. **Medium**: Fix caller/carp callsite detection (fixes t/106dbic_carp.t, t/100populate.t) +3. **Long-term**: Investigate VerifyError bytecode compiler bug (HIGH PRIORITY for broader CPAN compat) +4. **Pragmatic**: Accept GC-only failures as known JVM limitation; consider adding skip-leak-tests env var + +### Open Questions +- `weaken`/`isweak` absence causes GC test noise but no functional impact — Option B (accept) or Option C (skip env var)? +- VerifyError: is this specific to `overload`-heavy code or a general large-subroutine issue? +- RowParser crash: is it safe to ignore since all real tests pass before it fires? + +## Related Documents + +- `dev/modules/moo_support.md` — Moo support (dependency of DBIx::Class) +- `dev/modules/xs_fallback.md` — XS fallback mechanism +- `dev/modules/makemaker_perlonjava.md` — MakeMaker for PerlOnJava +- `dev/modules/cpan_client.md` — jcpan CPAN client +- `docs/guides/database-access.md` — JDBC database guide (DBI, SQLite support) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10a2641b6..da3999446 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ fastjson2 = "2.0.61" icu4j = "78.3" junit-jupiter = "6.1.0-M1" snakeyaml-engine = "3.0.1" +sqlite-jdbc = "3.49.1.0" tomlj = "1.1.1" [libraries] @@ -17,6 +18,7 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-jupiter" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-jupiter" } snakeyaml-engine = { module = "org.snakeyaml:snakeyaml-engine", version.ref = "snakeyaml-engine" } +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } tomlj = { module = "org.tomlj:tomlj", version.ref = "tomlj" } [plugins] diff --git a/pom.xml b/pom.xml index 46acdff99..b9dadf71b 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,11 @@ commons-csv 1.14.1 + + org.xerial + sqlite-jdbc + 3.49.1.0 + diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java index fb6f7b156..c7a69dc37 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java @@ -834,10 +834,13 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c // SUBROUTINE CALLS // ================================================================= - case Opcodes.CALL_SUB -> { + case Opcodes.CALL_SUB, Opcodes.CALL_SUB_SHARE_ARGS -> { // Call subroutine: rd = coderef->(args) + // CALL_SUB_SHARE_ARGS: &func (no parens) shares caller's @_ by alias // May return RuntimeControlFlowList! // pcHolder[0] contains the PC of this opcode (set before opcode read) + boolean shareArgs = (opcode == Opcodes.CALL_SUB_SHARE_ARGS); + // pcHolder[0] contains the PC of this opcode (set before opcode read) int callSitePc = pcHolder[0]; int rd = bytecode[pc++]; int coderefReg = bytecode[pc++]; @@ -893,7 +896,12 @@ public static RuntimeList execute(InterpretedCode code, RuntimeArray args, int c } } else { // Slow path for JVM-compiled code, symbolic references, etc. - result = RuntimeCode.apply(codeRef, "", callArgs, context); + // For &func (shareArgs), use the apply overload that shares @_ + if (shareArgs) { + result = RuntimeCode.apply(codeRef, callArgs, context); + } else { + result = RuntimeCode.apply(codeRef, "", callArgs, context); + } } // Handle TAILCALL with trampoline loop (same as JVM backend) diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java index 4993e5fc3..0a7592d07 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperator.java @@ -372,8 +372,13 @@ else if (node.right instanceof BinaryOperatorNode rightCall) { bytecodeCompiler.compileNode(node.right, -1, RuntimeContextType.LIST); int rs2 = bytecodeCompiler.lastResultReg; - // Emit CALL_SUB opcode - int rd = CompileBinaryOperatorHelper.compileBinaryOperatorSwitch(bytecodeCompiler, node.operator, rs1, rs2, node.getIndex()); + // Check if this is a &func (no parens) call that should share caller's @_ + boolean shareCallerArgs = node.getBooleanAnnotation("shareCallerArgs"); + + // Emit CALL_SUB or CALL_SUB_SHARE_ARGS opcode + int rd = CompileBinaryOperatorHelper.compileBinaryOperatorSwitch( + bytecodeCompiler, node.operator, rs1, rs2, node.getIndex(), + 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 e6332deb0..6ece26fa9 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperatorHelper.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileBinaryOperatorHelper.java @@ -16,6 +16,10 @@ public class CompileBinaryOperatorHelper { * @return Result register containing the operation result */ public static int compileBinaryOperatorSwitch(BytecodeCompiler bytecodeCompiler, String operator, int rs1, int rs2, int tokenIndex) { + return compileBinaryOperatorSwitch(bytecodeCompiler, operator, rs1, rs2, tokenIndex, false); + } + + public static int compileBinaryOperatorSwitch(BytecodeCompiler bytecodeCompiler, String operator, int rs1, int rs2, int tokenIndex, boolean shareCallerArgs) { // Allocate result register int rd = bytecodeCompiler.allocateOutputRegister(); @@ -215,7 +219,8 @@ public static int compileBinaryOperatorSwitch(BytecodeCompiler bytecodeCompiler, // BytecodeInterpreter convert it to RuntimeArray // Emit CALL_SUB: rd = coderef.apply(args, context) - bytecodeCompiler.emit(Opcodes.CALL_SUB); + // Use CALL_SUB_SHARE_ARGS for &func (no parens) to share caller's @_ + bytecodeCompiler.emit(shareCallerArgs ? Opcodes.CALL_SUB_SHARE_ARGS : Opcodes.CALL_SUB); bytecodeCompiler.emitReg(rd); // Result register bytecodeCompiler.emitReg(rs1); // Code reference register bytecodeCompiler.emitReg(rs2); // Arguments register (RuntimeList to be converted to RuntimeArray) diff --git a/src/main/java/org/perlonjava/backend/bytecode/Disassemble.java b/src/main/java/org/perlonjava/backend/bytecode/Disassemble.java index 5451178a4..b97fdcd9c 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/Disassemble.java +++ b/src/main/java/org/perlonjava/backend/bytecode/Disassemble.java @@ -875,11 +875,13 @@ public static String disassemble(InterpretedCode interpretedCode) { break; } case Opcodes.CALL_SUB: + case Opcodes.CALL_SUB_SHARE_ARGS: rd = interpretedCode.bytecode[pc++]; int coderefReg = interpretedCode.bytecode[pc++]; int argsReg = interpretedCode.bytecode[pc++]; int ctx = interpretedCode.bytecode[pc++]; - sb.append("CALL_SUB r").append(rd).append(" = r").append(coderefReg) + sb.append(opcode == Opcodes.CALL_SUB_SHARE_ARGS ? "CALL_SUB_SHARE_ARGS r" : "CALL_SUB r") + .append(rd).append(" = r").append(coderefReg) .append("->(r").append(argsReg).append(", ctx=").append(ctx).append(")\n"); break; case Opcodes.CALL_METHOD: diff --git a/src/main/java/org/perlonjava/backend/bytecode/Opcodes.java b/src/main/java/org/perlonjava/backend/bytecode/Opcodes.java index f94bc41ff..787eb75ab 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/Opcodes.java +++ b/src/main/java/org/perlonjava/backend/bytecode/Opcodes.java @@ -2040,28 +2040,35 @@ public class Opcodes { // Effect: rd = CompareOperators.smartmatch(rs1, rs2) public static final short SMARTMATCH = 400; + /** + * Call subroutine sharing caller's @_: rd = RuntimeCode.apply(coderef_reg, args_reg, context) + * Used for &func (no parens) which shares caller's @_ by alias. + * Same format as CALL_SUB but uses the sharing apply() overload in slow path. + */ + public static final short CALL_SUB_SHARE_ARGS = 401; + // Missing system operators needed for interpreter fallback of large files (e.g. taint.t) - public static final short SYMLINK = 401; - public static final short CHROOT = 402; - public static final short MKDIR = 403; - public static final short MSGCTL = 404; - public static final short SHMCTL = 405; - public static final short SEMCTL = 406; - public static final short EXEC = 407; - public static final short FCNTL = 408; - public static final short IOCTL = 409; - public static final short GETPWENT = 410; - public static final short SETPWENT = 411; - public static final short ENDPWENT = 412; + public static final short SYMLINK = 402; + public static final short CHROOT = 403; + public static final short MKDIR = 404; + public static final short MSGCTL = 405; + public static final short SHMCTL = 406; + public static final short SEMCTL = 407; + public static final short EXEC = 408; + public static final short FCNTL = 409; + public static final short IOCTL = 410; + public static final short GETPWENT = 411; + public static final short SETPWENT = 412; + public static final short ENDPWENT = 413; /** * Dynamic loop control: last/next/redo with runtime-evaluated label expression. * Format: CREATE_LAST_DYNAMIC rd labelReg * Creates RuntimeControlFlowList with label from registers[labelReg].toString(). */ - public static final short CREATE_LAST_DYNAMIC = 413; - public static final short CREATE_NEXT_DYNAMIC = 414; - public static final short CREATE_REDO_DYNAMIC = 415; + public static final short CREATE_LAST_DYNAMIC = 443; + public static final short CREATE_NEXT_DYNAMIC = 444; + public static final short CREATE_REDO_DYNAMIC = 445; // ExtendedNativeUtils operators (user/group info, network lookups, enumeration) public static final short GETLOGIN = 416; diff --git a/src/main/java/org/perlonjava/backend/jvm/Dereference.java b/src/main/java/org/perlonjava/backend/jvm/Dereference.java index e21429ffa..d08b1cb9a 100644 --- a/src/main/java/org/perlonjava/backend/jvm/Dereference.java +++ b/src/main/java/org/perlonjava/backend/jvm/Dereference.java @@ -959,6 +959,11 @@ static void handleArrowOperator(EmitterVisitor emitterVisitor, BinaryOperatorNod } } + // Save the call context into a local slot for the TAILCALL trampoline. + int callContextSlot = emitterVisitor.ctx.symbolTable.allocateLocalVariable(); + emitterVisitor.pushCallContext(); + mv.visitVarInsn(Opcodes.ISTORE, callContextSlot); + // Allocate a unique callsite ID for inline method caching int callsiteId = nextMethodCallsiteId++; mv.visitLdcInsn(callsiteId); @@ -966,7 +971,7 @@ static void handleArrowOperator(EmitterVisitor emitterVisitor, BinaryOperatorNod mv.visitVarInsn(Opcodes.ALOAD, methodSlot); mv.visitVarInsn(Opcodes.ALOAD, subSlot); mv.visitVarInsn(Opcodes.ALOAD, argsArraySlot); - emitterVisitor.pushCallContext(); // push call context to stack (handles RUNTIME) + mv.visitVarInsn(Opcodes.ILOAD, callContextSlot); // push saved call context mv.visitMethodInsn( Opcodes.INVOKESTATIC, "org/perlonjava/runtime/runtimetypes/RuntimeCode", @@ -1048,7 +1053,7 @@ static void handleArrowOperator(EmitterVisitor emitterVisitor, BinaryOperatorNod mv.visitVarInsn(Opcodes.ALOAD, emitterVisitor.ctx.javaClassInfo.tailCallCodeRefSlot); mv.visitLdcInsn("tailcall"); mv.visitVarInsn(Opcodes.ALOAD, emitterVisitor.ctx.javaClassInfo.tailCallArgsSlot); - mv.visitVarInsn(Opcodes.ILOAD, 2); // context parameter (passed to current sub) + mv.visitVarInsn(Opcodes.ILOAD, callContextSlot); // context of the original call site mv.visitMethodInsn(Opcodes.INVOKESTATIC, "org/perlonjava/runtime/runtimetypes/RuntimeCode", "apply", diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java index 438ac3535..3aac6b8f4 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitSubroutine.java @@ -433,6 +433,69 @@ static void handleApplyOperator(EmitterVisitor emitterVisitor, BinaryOperatorNod } mv.visitVarInsn(Opcodes.ASTORE, codeRefSlot); + // Special handling for eval blocks: share @_ with enclosing sub directly. + // In Perl 5, eval { } shares @_ with its enclosing sub, so shift/pop inside + // eval { } modifies the caller's @_. We achieve this by passing the caller's + // RuntimeArray directly instead of expanding @_ into a new array. + // Note: use apply() not applyEval() because the eval block's own generated + // method already has try/catch handling (useTryCatch=true). Using applyEval + // would add a second layer that clears $@ after the block returns. + if (node.left instanceof SubroutineNode subNode && subNode.useTryCatch) { + mv.visitVarInsn(Opcodes.ALOAD, codeRefSlot); + mv.visitVarInsn(Opcodes.ALOAD, 1); // caller's @_ (slot 1) - shared, not copied + mv.visitVarInsn(Opcodes.ILOAD, callContextSlot); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, + "org/perlonjava/runtime/runtimetypes/RuntimeCode", + "apply", + "(Lorg/perlonjava/runtime/runtimetypes/RuntimeScalar;Lorg/perlonjava/runtime/runtimetypes/RuntimeArray;I)Lorg/perlonjava/runtime/runtimetypes/RuntimeList;", + false); + + if (pooledCodeRef) { + emitterVisitor.ctx.javaClassInfo.releaseSpillSlot(); + } + + if (emitterVisitor.ctx.contextType == RuntimeContextType.SCALAR) { + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, + "org/perlonjava/runtime/runtimetypes/RuntimeList", "scalar", + "()Lorg/perlonjava/runtime/runtimetypes/RuntimeScalar;", false); + } else if (emitterVisitor.ctx.contextType == RuntimeContextType.VOID) { + mv.visitInsn(Opcodes.POP); + } + return; + } + + // Special handling for &func (no parens): share @_ with caller directly. + // In Perl 5, &func without parens shares the caller's @_ by alias, + // so shift/pop inside the callee modifies the caller's @_. + // We achieve this by passing the caller's RuntimeArray (slot 1) directly + // instead of creating a new array from @_ elements. + if (node.getBooleanAnnotation("shareCallerArgs")) { + mv.visitVarInsn(Opcodes.ALOAD, codeRefSlot); + mv.visitVarInsn(Opcodes.ALOAD, 1); // caller's @_ (slot 1) - shared, not copied + mv.visitVarInsn(Opcodes.ILOAD, callContextSlot); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, + "org/perlonjava/runtime/runtimetypes/RuntimeCode", + "apply", + "(Lorg/perlonjava/runtime/runtimetypes/RuntimeScalar;Lorg/perlonjava/runtime/runtimetypes/RuntimeArray;I)Lorg/perlonjava/runtime/runtimetypes/RuntimeList;", + false); + + if (pooledCodeRef) { + emitterVisitor.ctx.javaClassInfo.releaseSpillSlot(); + } + + // Registry-based non-local control flow check (for next/last/redo LABEL from closures) + emitControlFlowCheck(emitterVisitor.ctx); + + if (emitterVisitor.ctx.contextType == RuntimeContextType.SCALAR) { + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, + "org/perlonjava/runtime/runtimetypes/RuntimeList", "scalar", + "()Lorg/perlonjava/runtime/runtimetypes/RuntimeScalar;", false); + } else if (emitterVisitor.ctx.contextType == RuntimeContextType.VOID) { + mv.visitInsn(Opcodes.POP); + } + return; + } + int nameSlot = emitterVisitor.ctx.javaClassInfo.acquireSpillSlot(); boolean pooledName = nameSlot >= 0; if (!pooledName) { @@ -594,7 +657,7 @@ static void handleApplyOperator(EmitterVisitor emitterVisitor, BinaryOperatorNod mv.visitVarInsn(Opcodes.ALOAD, emitterVisitor.ctx.javaClassInfo.tailCallCodeRefSlot); mv.visitLdcInsn("tailcall"); mv.visitVarInsn(Opcodes.ALOAD, emitterVisitor.ctx.javaClassInfo.tailCallArgsSlot); - mv.visitVarInsn(Opcodes.ILOAD, 2); // context parameter (passed to current sub) + mv.visitVarInsn(Opcodes.ILOAD, callContextSlot); // context of the original call site mv.visitMethodInsn(Opcodes.INVOKESTATIC, "org/perlonjava/runtime/runtimetypes/RuntimeCode", "apply", diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitVariable.java b/src/main/java/org/perlonjava/backend/jvm/EmitVariable.java index 944867b29..0613cec40 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitVariable.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitVariable.java @@ -331,12 +331,15 @@ private static void fetchGlobalVariable(EmitterContext ctx, boolean createIfNotE * @param node the OperatorNode representing the variable operation */ static void handleVariableOperator(EmitterVisitor emitterVisitor, OperatorNode node) { - // In void context, don't emit any code - if (emitterVisitor.ctx.contextType == RuntimeContextType.VOID) { + String sigil = node.operator; + + // In void context, don't emit any code — EXCEPT for glob (*) access, + // which has vivification side effects. In Perl, `*{"PKG::name"}` in void + // context still vivifies the glob entry in the stash. Package::Stash::PP + // relies on this for its `local *__ANON__:: = $namespace; *{"__ANON__::$name"};` pattern. + if (emitterVisitor.ctx.contextType == RuntimeContextType.VOID && !sigil.equals("*")) { return; } - - String sigil = node.operator; MethodVisitor mv = emitterVisitor.ctx.mv; // Case 1: Simple variable with identifier (most common case) @@ -363,6 +366,10 @@ static void handleVariableOperator(EmitterVisitor emitterVisitor, OperatorNode n "createDetachedCopy", "()Lorg/perlonjava/runtime/runtimetypes/RuntimeGlob;", false); + // In void context, pop the result — the access was needed for vivification side effects + if (emitterVisitor.ctx.contextType == RuntimeContextType.VOID) { + mv.visitInsn(Opcodes.POP); + } return; } @@ -555,6 +562,11 @@ static void handleVariableOperator(EmitterVisitor emitterVisitor, OperatorNode n emitterVisitor.pushCurrentPackage(); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "org/perlonjava/runtime/runtimetypes/RuntimeScalar", "globDerefNonStrict", "(Ljava/lang/String;)Lorg/perlonjava/runtime/runtimetypes/RuntimeGlob;", false); } + // In void context, pop the result off the JVM stack since no one consumes it. + // The glob access was still needed for its vivification side effect. + if (emitterVisitor.ctx.contextType == RuntimeContextType.VOID) { + mv.visitInsn(Opcodes.POP); + } return; case "&": // `&$a` or `&{sub ...}` diff --git a/src/main/java/org/perlonjava/core/Configuration.java b/src/main/java/org/perlonjava/core/Configuration.java index 13b46fbf6..cc3f6b755 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 = "80afde768"; + public static final String gitCommitId = "509cc4f94"; /** * Git commit date of the build (ISO format: YYYY-MM-DD). diff --git a/src/main/java/org/perlonjava/frontend/analysis/LValueVisitor.java b/src/main/java/org/perlonjava/frontend/analysis/LValueVisitor.java index 28898a0c5..e8a76ec45 100644 --- a/src/main/java/org/perlonjava/frontend/analysis/LValueVisitor.java +++ b/src/main/java/org/perlonjava/frontend/analysis/LValueVisitor.java @@ -185,13 +185,28 @@ public void visit(SubroutineNode node) { @Override public void visit(TernaryOperatorNode node) { - node.trueExpr.accept(this); - int context1 = context; - node.falseExpr.accept(this); - int context2 = context; + int context1 = assignmentTypeOf(node.trueExpr); + int context2 = assignmentTypeOf(node.falseExpr); if (context1 != context2) { throw new PerlCompilerException("Assignment to both a list and a scalar"); } + context = context1; + } + + /** + * Determine the assignment type of a ternary branch, matching Perl 5's S_assignment_type(). + * In Perl 5, assignment ops (both OP_SASSIGN and OP_AASSIGN) are not in the list of + * op types that return ASSIGN_LIST, so they fall through to ASSIGN_SCALAR. + * This allows patterns like: (wantarray ? @rv = eval $src : $rv[0]) = eval $src + * where the true branch is a list assignment but is treated as SCALAR for the + * mixed-context check. + */ + private int assignmentTypeOf(Node expr) { + if (expr instanceof BinaryOperatorNode binop && binop.operator.equals("=")) { + return RuntimeContextType.SCALAR; + } + expr.accept(this); + return context; } @Override diff --git a/src/main/java/org/perlonjava/frontend/parser/IdentifierParser.java b/src/main/java/org/perlonjava/frontend/parser/IdentifierParser.java index 1e0e07a7c..d6e1c6fae 100644 --- a/src/main/java/org/perlonjava/frontend/parser/IdentifierParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/IdentifierParser.java @@ -206,6 +206,12 @@ public static String parseComplexIdentifierInner(Parser parser, boolean insideBr if (insideBraces && firstChar == '&' && nextToken.text.equals("{")) { return null; // Force fallback to expression parsing for subroutine call } + // Special case: + followed by { is unary plus forcing hash constructor when inside braces + // %{+{@a}} should be parsed as %{ +{@a} }, not %+{@a} (hash subscript on %+) + // This is the canonical Perl idiom for disambiguating hash constructors from blocks + if (insideBraces && firstChar == '+' && nextToken.text.equals("{")) { + return null; // Force fallback to expression parsing for unary plus + hash constructor + } // Check if this is a leading single quote followed by an identifier ($'foo means $main::foo) if (firstChar == '\'' && (nextToken.type == LexerTokenType.IDENTIFIER || nextToken.type == LexerTokenType.NUMBER)) { // This is $'foo which means $main::foo diff --git a/src/main/java/org/perlonjava/frontend/parser/ParseInfix.java b/src/main/java/org/perlonjava/frontend/parser/ParseInfix.java index a4531de69..ef04d9367 100644 --- a/src/main/java/org/perlonjava/frontend/parser/ParseInfix.java +++ b/src/main/java/org/perlonjava/frontend/parser/ParseInfix.java @@ -418,6 +418,20 @@ static List parseHashSubscript(Parser parser) { // backtrack parser.tokenIndex = currentIndex; + // Check for -WORD} pattern: $hash{-key} autoquotes to "-key" + // This handles keywords like -join, -sort, -map etc. inside hash subscripts + if (ident.text.equals("-") && close.type == LexerTokenType.IDENTIFIER) { + int afterWord = currentIndex + 2; + if (afterWord < parser.tokens.size() && parser.tokens.get(afterWord).text.equals("}")) { + parser.tokenIndex = afterWord + 1; // skip past } + List list = new ArrayList<>(); + list.add(new StringNode("-" + close.text, currentIndex)); + return list; + } + } + // backtrack (in case the -WORD} check didn't match) + parser.tokenIndex = currentIndex; + // Handle optional empty parentheses LexerToken nextToken = peek(parser); if (nextToken.text.equals("(")) { diff --git a/src/main/java/org/perlonjava/frontend/parser/ParsePrimary.java b/src/main/java/org/perlonjava/frontend/parser/ParsePrimary.java index 618b5a4ac..cdd185646 100644 --- a/src/main/java/org/perlonjava/frontend/parser/ParsePrimary.java +++ b/src/main/java/org/perlonjava/frontend/parser/ParsePrimary.java @@ -197,12 +197,17 @@ private static Node parseIdentifier(Parser parser, int startIndex, LexerToken to } } - parser.tokenIndex = startIndex; // backtrack + // Skip whitespace to find the actual position of the operator token. + // startIndex may point to a whitespace token before the operator, + // and inserting CORE::GLOBAL:: before whitespace would leave the + // whitespace between GLOBAL:: and the operator, causing a parse error. + int insertPos = Whitespace.skipWhitespace(parser, startIndex, parser.tokens); + parser.tokenIndex = insertPos; // backtrack to operator position // Rewrite the tokens to call CORE::GLOBAL::operator - parser.tokens.add(startIndex, new LexerToken(LexerTokenType.IDENTIFIER, "CORE")); - parser.tokens.add(startIndex + 1, new LexerToken(LexerTokenType.OPERATOR, "::")); - parser.tokens.add(startIndex + 2, new LexerToken(LexerTokenType.IDENTIFIER, "GLOBAL")); - parser.tokens.add(startIndex + 3, new LexerToken(LexerTokenType.OPERATOR, "::")); + parser.tokens.add(insertPos, new LexerToken(LexerTokenType.IDENTIFIER, "CORE")); + parser.tokens.add(insertPos + 1, new LexerToken(LexerTokenType.OPERATOR, "::")); + parser.tokens.add(insertPos + 2, new LexerToken(LexerTokenType.IDENTIFIER, "GLOBAL")); + parser.tokens.add(insertPos + 3, new LexerToken(LexerTokenType.OPERATOR, "::")); return SubroutineParser.parseSubroutineCall(parser, false); } } diff --git a/src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java b/src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java index 54f04aa86..c34bc3872 100644 --- a/src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java +++ b/src/main/java/org/perlonjava/frontend/parser/PrototypeArgs.java @@ -89,7 +89,23 @@ private static boolean allowsZeroArguments(String prototype) { } char firstChar = prototype.charAt(i); - return firstChar == ';' || firstChar == '@' || firstChar == '%'; + return firstChar == ';' || firstChar == '@' || firstChar == '%' || firstChar == '_'; + } + + /** + * Checks if the prototype represents a named unary operator. + * In Perl, functions with exactly prototype ($) or (_) are parsed as named unary + * operators with higher precedence than comparison operators. + * Prototypes like ($;), (_;), ($;$) are NOT named unary - the trailing semicolon + * makes them list operators. + * + * @param prototype The prototype string + * @return true if the prototype is exactly "$" or "_" + */ + private static boolean isNamedUnaryPrototype(String prototype) { + if (prototype.length() != 1) return false; + char first = prototype.charAt(0); + return first == '$' || first == '_'; } /** @@ -140,6 +156,35 @@ static ListNode consumeArgsWithPrototype(Parser parser, String prototype, boolea ListNode args = new ListNode(parser.tokenIndex); boolean hasParentheses = handleParen && handleOpeningParenthesis(parser); + // For single-scalar prototypes ("$" or "_") without parentheses, parse as named + // unary operator. In Perl, func($) acts like a named unary with higher precedence + // 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("=>")) { + // No argument - check if optional + if (!allowsZeroArguments(prototype)) { + throwNotEnoughArgumentsError(parser); + } + // _ prototype defaults to $_ when no argument is provided + if (prototype.charAt(0) == '_') { + Node underscoreArg = new OperatorNode( + "$", new IdentifierNode("_", parser.tokenIndex), parser.tokenIndex); + underscoreArg.setAnnotation("context", "SCALAR"); + args.elements.add(underscoreArg); + } + return args; + } + // Parse one argument at named unary precedence (higher than comparison ops) + Node expr = parser.parseExpression(parser.getPrecedence("isa") + 1); + if (expr != null) { + Node scalarArg = ParserNodeUtils.toScalarContext(expr); + scalarArg.setAnnotation("context", "SCALAR"); + args.elements.add(scalarArg); + } + return args; + } + // Check for => immediately after subroutine name (no parentheses) if (!hasParentheses && TokenUtils.peek(parser).text.equals("=>")) { // This is a subroutine call with zero arguments diff --git a/src/main/java/org/perlonjava/frontend/parser/SignatureParser.java b/src/main/java/org/perlonjava/frontend/parser/SignatureParser.java index 61d8315ad..b683acc5f 100644 --- a/src/main/java/org/perlonjava/frontend/parser/SignatureParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/SignatureParser.java @@ -339,9 +339,10 @@ private Node parseDefaultValue(Node paramVariable) { return null; } - // Parse the default value expression - ListNode arguments = consumeArgsWithPrototype(parser, "$", false); - return arguments.elements.getFirst(); + // Parse the default value expression at comma precedence, so that + // complex expressions (ternary, comparisons, logical ops) are included + // but ',' and ')' correctly terminate the default value. + return parser.parseExpression(parser.getPrecedence(",")); } /** diff --git a/src/main/java/org/perlonjava/frontend/parser/StringSegmentParser.java b/src/main/java/org/perlonjava/frontend/parser/StringSegmentParser.java index 2e0661fae..8dd36815a 100644 --- a/src/main/java/org/perlonjava/frontend/parser/StringSegmentParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/StringSegmentParser.java @@ -418,9 +418,18 @@ private Node parseSimpleVariableInterpolation(String sigil) { if ("@".equals(sigil) && parser.tokenIndex < parser.tokens.size()) { LexerToken nextToken = parser.tokens.get(parser.tokenIndex); if (nextToken.text.startsWith("$")) { - // This is @$var - array of scalar variable + // This is @$var or @${expr} - array dereference of scalar // Consume the $ token TokenUtils.consume(parser); + + // Check if next is { for @${expr} pattern (e.g., @${$v}) + if (parser.tokenIndex < parser.tokens.size() && + parser.tokens.get(parser.tokenIndex).text.equals("{")) { + // @${...} - parse as ${...} then wrap in @ + Node scalarExpr = Variable.parseBracedVariable(parser, "$", true); + return new OperatorNode("@", scalarExpr, tokenIndex); + } + // Now parse the rest of the identifier identifier = IdentifierParser.parseComplexIdentifier(parser); if (identifier == null || identifier.isEmpty()) { diff --git a/src/main/java/org/perlonjava/frontend/parser/Variable.java b/src/main/java/org/perlonjava/frontend/parser/Variable.java index 5daa0553b..905d7669c 100644 --- a/src/main/java/org/perlonjava/frontend/parser/Variable.java +++ b/src/main/java/org/perlonjava/frontend/parser/Variable.java @@ -533,12 +533,16 @@ static Node parseCoderefVariable(Parser parser, LexerToken token) { // Handle arguments if present Node list; + boolean shareArgs = false; if (!TokenUtils.peek(parser).text.equals("(")) { list = atUnderscore(parser); + shareArgs = true; // &func shares caller's @_ } else { list = ListParser.parseZeroOrMoreList(parser, 0, false, true, false, false); } - return new BinaryOperatorNode("(", qualifiedNode, list, index); + BinaryOperatorNode callNode = new BinaryOperatorNode("(", qualifiedNode, list, index); + if (shareArgs) callNode.setAnnotation("shareCallerArgs", true); + return callNode; } } @@ -578,12 +582,16 @@ static Node parseCoderefVariable(Parser parser, LexerToken token) { // Handle arguments for actual calls (&foo or &foo()) // Use $hiddenVar directly - the () operator will handle dereferencing Node list; + boolean shareArgs = false; if (!TokenUtils.peek(parser).text.equals("(")) { list = atUnderscore(parser); + shareArgs = true; // &func shares caller's @_ } else { list = ListParser.parseZeroOrMoreList(parser, 0, false, true, false, false); } - return new BinaryOperatorNode("(", dollarOp, list, index); + BinaryOperatorNode callNode = new BinaryOperatorNode("(", dollarOp, list, index); + if (shareArgs) callNode.setAnnotation("shareCallerArgs", true); + return callNode; } } } @@ -623,9 +631,11 @@ static Node parseCoderefVariable(Parser parser, LexerToken token) { } Node list; + boolean shareArgs = false; // If the next token is not `(`, handle auto-call by transforming `&subr` to `&subr(@_)` if (!peek(parser).text.equals("(")) { list = atUnderscore(parser); + shareArgs = true; // &func shares caller's @_ } else { // Otherwise, parse the list of arguments list = ListParser.parseZeroOrMoreList(parser, @@ -648,7 +658,9 @@ static Node parseCoderefVariable(Parser parser, LexerToken token) { } // Return a new BinaryOperatorNode representing the function call with arguments - return new BinaryOperatorNode("(", node, list, parser.tokenIndex); + BinaryOperatorNode callNode = new BinaryOperatorNode("(", node, list, parser.tokenIndex); + if (shareArgs) callNode.setAnnotation("shareCallerArgs", true); + return callNode; } /** diff --git a/src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java b/src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java index ff78f5680..1451ab3e8 100644 --- a/src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java +++ b/src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java @@ -174,11 +174,19 @@ public int enterScope() { /** * Exits the current scope by popping the top SymbolTable from the stack. * Also removes the top state of warnings, features, and strict options. + *

+ * The child scope's local variable index is propagated to the parent scope + * to prevent slot reuse across conditional branches. Without this, the JVM + * verifier can fail with VerifyError when the same slot holds different types + * (e.g., int vs reference, or RuntimeScalar vs RegexState) in different branches, + * causing ASM's COMPUTE_FRAMES to merge them as Top or java/lang/Object. * * @param scopeIndex The index representing the starting point of the scope to exit. */ public void exitScope(int scopeIndex) { clearVisibleVariablesCache(); + // Capture the child scope's max local variable index before popping + int childIndex = symbolTableStack.peek().index; // Pop entries from the stacks until reaching the specified scope index while (symbolTableStack.size() > scopeIndex) { symbolTableStack.pop(); @@ -191,6 +199,13 @@ public void exitScope(int scopeIndex) { featureFlagsStack.pop(); strictOptionsStack.pop(); } + // Propagate the child scope's index to the parent to prevent slot reuse. + // This ensures that local variable slots allocated inside conditional branches + // (e.g., if/else blocks) are not reused in subsequent code, avoiding type + // conflicts at JVM branch merge points. + if (symbolTableStack.peek().index < childIndex) { + symbolTableStack.peek().index = childIndex; + } } /** diff --git a/src/main/java/org/perlonjava/runtime/operators/VersionHelper.java b/src/main/java/org/perlonjava/runtime/operators/VersionHelper.java index 2de094029..fc63df494 100644 --- a/src/main/java/org/perlonjava/runtime/operators/VersionHelper.java +++ b/src/main/java/org/perlonjava/runtime/operators/VersionHelper.java @@ -197,7 +197,7 @@ public static RuntimeScalar compareVersion(RuntimeScalar hasVersion, RuntimeScal throw new PerlCompilerException("Either package version or REQUIRE is not a lax version number"); } if (compareVersions(hasStr, wantStr) < 0) { - throw new PerlCompilerException(perlClassName + " version " + wantStr + " required--this is only version " + hasVersion); + throw new PerlCompilerException(perlClassName + " version " + wantVersion + " required--this is only version " + hasVersion); } } return hasVersion; @@ -222,6 +222,13 @@ public static String normalizeVersion(RuntimeScalar wantVersion) { if (parts.length < 3) { String major = parts[0]; String minor = parts.length > 1 ? parts[1] : "0"; + // Right-pad minor with zeros to at least 3 chars. + // In Perl's version system, decimal digits are grouped in 3s: + // 0.01 -> "010" -> v0.10.0, not v0.1.0 + // 0.5 -> "500" -> v0.500.0 + while (minor.length() < 3) { + minor = minor + "0"; + } String patch = minor.length() > 3 ? minor.substring(3) : "0"; if (minor.length() > 3) { minor = minor.substring(0, 3); diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Builtin.java b/src/main/java/org/perlonjava/runtime/perlmodule/Builtin.java index dd14ea023..a5d2a0c39 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Builtin.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Builtin.java @@ -110,9 +110,8 @@ public static RuntimeList unweaken(RuntimeArray args, int ctx) { } public static RuntimeList isWeak(RuntimeArray args, int ctx) { - RuntimeScalar ref = args.get(0); - // Implementation to check if reference is weak - return new RuntimeList(scalarFalse); + // Delegate to Scalar::Util::isweak - on JVM all refs are effectively weak + return ScalarUtil.isweak(args, ctx); } public static RuntimeList blessed(RuntimeArray args, int ctx) { diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/DBI.java b/src/main/java/org/perlonjava/runtime/perlmodule/DBI.java index 97a519ea9..0eea451d3 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/DBI.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/DBI.java @@ -203,13 +203,25 @@ public static RuntimeList prepare(RuntimeArray args, int ctx) { sth.put("Type", new RuntimeScalar("st")); // Add NUM_OF_FIELDS by getting metadata - ResultSetMetaData metaData = stmt.getMetaData(); - int numFields = (metaData != null) ? metaData.getColumnCount() : 0; + int numFields = 0; + try { + ResultSetMetaData metaData = stmt.getMetaData(); + if (metaData != null) { + numFields = metaData.getColumnCount(); + } + } catch (Exception e) { + // Some drivers (e.g. sqlite-jdbc) throw on DDL statements + } sth.put("NUM_OF_FIELDS", new RuntimeScalar(numFields)); // Add NUM_OF_PARAMS by getting parameter count - ParameterMetaData paramMetaData = stmt.getParameterMetaData(); - int numParams = paramMetaData.getParameterCount(); + int numParams = 0; + try { + ParameterMetaData paramMetaData = stmt.getParameterMetaData(); + numParams = paramMetaData.getParameterCount(); + } catch (Exception e) { + // Some drivers (e.g. sqlite-jdbc) throw on DDL/non-parameterized statements + } sth.put("NUM_OF_PARAMS", new RuntimeScalar(numParams)); // Create blessed reference for statement handle @@ -317,7 +329,19 @@ public static RuntimeList execute(RuntimeArray args, int ctx) { // Store execution result in statement handle sth.put("execute_result", result.createReference()); - return result.createReference().getList(); + + // Return value per DBI spec: + // - For DML (INSERT/UPDATE/DELETE): number of rows affected, or "0E0" for 0 rows + // - For SELECT: -1 (unknown number of rows) + if (hasResultSet) { + return new RuntimeScalar(-1).getList(); + } else { + int updateCount = stmt.getUpdateCount(); + if (updateCount == 0) { + return new RuntimeScalar("0E0").getList(); + } + return new RuntimeScalar(updateCount).getList(); + } }, dbh, "execute"); } @@ -341,10 +365,25 @@ public static RuntimeList fetchrow_arrayref(RuntimeArray args, int ctx) { if (rs.next()) { RuntimeArray row = new RuntimeArray(); ResultSetMetaData metaData = rs.getMetaData(); + int colCount = metaData.getColumnCount(); // Convert each column value to string and add to row array - for (int i = 1; i <= metaData.getColumnCount(); i++) { + for (int i = 1; i <= colCount; i++) { RuntimeArray.push(row, RuntimeScalar.newScalarOrString(rs.getObject(i))); } + + // Update bound columns if any (for bind_columns + fetch pattern) + RuntimeScalar boundRef = sth.get("bound_columns"); + if (boundRef != null && boundRef.type != RuntimeScalarType.UNDEF) { + RuntimeHash boundColumns = boundRef.hashDeref(); + for (int i = 1; i <= colCount; i++) { + RuntimeScalar ref = boundColumns.get(String.valueOf(i)); + if (ref != null && ref.type != RuntimeScalarType.UNDEF) { + // Dereference the scalar ref and set its value + ref.scalarDeref().set(row.get(i - 1)); + } + } + } + return row.createReference().getList(); } @@ -788,25 +827,63 @@ public static RuntimeList data_sources(RuntimeArray args, int ctx) { public static RuntimeList get_info(RuntimeArray args, int ctx) { RuntimeHash dbh = args.get(0).hashDeref(); + int infoType = args.size() > 1 ? args.get(1).getInt() : -1; return executeWithErrorHandling(() -> { - RuntimeHash info = new RuntimeHash(); Connection conn = (Connection) dbh.get("connection").value; DatabaseMetaData meta = conn.getMetaData(); - // Add standard database information using available JDBC methods - info.put("DBMS_NAME", RuntimeScalar.newScalarOrString(meta.getDatabaseProductName())); - info.put("DBMS_VERSION", RuntimeScalar.newScalarOrString(meta.getDatabaseProductVersion())); - info.put("DRIVER_NAME", RuntimeScalar.newScalarOrString(meta.getDriverName())); - info.put("DRIVER_VERSION", RuntimeScalar.newScalarOrString(meta.getDriverVersion())); - info.put("IDENTIFIER_QUOTE_CHAR", RuntimeScalar.newScalarOrString(meta.getIdentifierQuoteString())); - info.put("SQL_KEYWORDS", RuntimeScalar.newScalarOrString(meta.getSQLKeywords())); - info.put("MAX_CONNECTIONS", RuntimeScalar.newScalarOrString(meta.getMaxConnections())); - info.put("USER_NAME", RuntimeScalar.newScalarOrString(meta.getUserName())); - info.put("NUMERIC_FUNCTIONS", RuntimeScalar.newScalarOrString(meta.getNumericFunctions())); - info.put("STRING_FUNCTIONS", RuntimeScalar.newScalarOrString(meta.getStringFunctions())); - - return info.createReference().getList(); + // DBI get_info() takes a numeric SQL info type constant and returns a scalar. + // Standard DBI::Const::GetInfoType constants: + // 6 = SQL_DRIVER_NAME + // 7 = SQL_DRIVER_VER + // 17 = SQL_DBMS_NAME + // 18 = SQL_DBMS_VER + // 29 = SQL_IDENTIFIER_QUOTE_CHAR + // 41 = SQL_CATALOG_NAME_SEPARATOR + // 47 = SQL_USER_NAME + // 89 = SQL_KEYWORDS + // 112 = SQL_NUMERIC_FUNCTIONS + // 116 = SQL_MAX_CONNECTIONS (0 = no limit) + // 119 = SQL_STRING_FUNCTIONS + String result; + switch (infoType) { + case 6: + result = meta.getDriverName(); + break; + case 7: + result = meta.getDriverVersion(); + break; + case 17: + result = meta.getDatabaseProductName(); + break; + case 18: + result = meta.getDatabaseProductVersion(); + break; + case 29: + result = meta.getIdentifierQuoteString(); + break; + case 41: + result = meta.getCatalogSeparator(); + break; + case 47: + result = meta.getUserName(); + break; + case 89: + result = meta.getSQLKeywords(); + break; + case 112: + result = meta.getNumericFunctions(); + break; + case 116: + return new RuntimeScalar(meta.getMaxConnections()).getList(); + case 119: + result = meta.getStringFunctions(); + break; + default: + return new RuntimeScalar().getList(); + } + return RuntimeScalar.newScalarOrString(result != null ? result : "").getList(); }, dbh, "get_info"); } diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/ScalarUtil.java b/src/main/java/org/perlonjava/runtime/perlmodule/ScalarUtil.java index b216db5a0..a4977d3b5 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/ScalarUtil.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/ScalarUtil.java @@ -185,8 +185,14 @@ public static RuntimeList isweak(RuntimeArray args, int ctx) { if (args.size() != 1) { throw new IllegalStateException("Bad number of arguments for isweak() method"); } - // Placeholder for isweak functionality - return new RuntimeScalar(false).getList(); + // On the JVM, the tracing garbage collector handles circular references + // natively, so all references are effectively "weak" from a GC perspective. + // Return true for any reference to indicate it has been "weakened". + RuntimeScalar arg = args.get(0); + boolean isRef = arg.type == RuntimeScalarType.REFERENCE + || arg.type == RuntimeScalarType.ARRAYREFERENCE + || arg.type == RuntimeScalarType.HASHREFERENCE; + return new RuntimeScalar(isRef).getList(); } /** diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Strict.java b/src/main/java/org/perlonjava/runtime/perlmodule/Strict.java index 22645f091..5a4ff6318 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Strict.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Strict.java @@ -54,6 +54,9 @@ public static void initialize() { try { strict.registerMethod("import", "useStrict", ";$"); strict.registerMethod("unimport", "noStrict", ";$"); + strict.registerMethod("bits", "strictBits", null); + strict.registerMethod("all_bits", "strictAllBits", null); + strict.registerMethod("all_explicit_bits", "strictAllExplicitBits", null); // Set $VERSION so CPAN.pm can detect our bundled version GlobalVariable.getGlobalVariable("strict::VERSION").set(new RuntimeScalar("1.14")); } catch (NoSuchMethodException e) { @@ -127,6 +130,67 @@ public static RuntimeList noStrict(RuntimeArray args, int ctx) { return new RuntimeScalar().getList(); } + // Combined bitmask for all strict options + private static final int ALL_BITS = HINT_STRICT_REFS | HINT_STRICT_SUBS | HINT_STRICT_VARS; + // Combined bitmask for all explicit strict options + private static final int ALL_EXPLICIT_BITS = HINT_EXPLICIT_STRICT_REFS | HINT_EXPLICIT_STRICT_SUBS | HINT_EXPLICIT_STRICT_VARS; + + /** + * Returns the bitmask for the given strict categories. + * Called as strict::bits('refs', 'subs', 'vars') from Perl. + * When called from the strict package itself, also includes explicit bits. + * + * @param args The category names. + * @param ctx The context in which the method is called. + * @return A RuntimeList containing the integer bitmask. + */ + public static RuntimeList strictBits(RuntimeArray args, int ctx) { + int bits = 0; + if (args.size() == 0) { + bits = ALL_BITS; + } else { + for (int i = 0; i < args.size(); i++) { + String category = args.get(i).toString(); + switch (category) { + case "refs": + bits |= HINT_STRICT_REFS; + break; + case "subs": + bits |= HINT_STRICT_SUBS; + break; + case "vars": + bits |= HINT_STRICT_VARS; + break; + default: + throw new IllegalArgumentException("Unknown 'strict' tag(s) '" + category + "'"); + } + } + } + return new RuntimeScalar(bits).getList(); + } + + /** + * Returns the combined bitmask for all strict categories (refs | subs | vars). + * + * @param args Unused. + * @param ctx The context in which the method is called. + * @return A RuntimeList containing the integer bitmask 0x602. + */ + public static RuntimeList strictAllBits(RuntimeArray args, int ctx) { + return new RuntimeScalar(ALL_BITS).getList(); + } + + /** + * Returns the combined bitmask for all explicit strict categories. + * + * @param args Unused. + * @param ctx The context in which the method is called. + * @return A RuntimeList containing the integer bitmask 0xe0. + */ + public static RuntimeList strictAllExplicitBits(RuntimeArray args, int ctx) { + return new RuntimeScalar(ALL_EXPLICIT_BITS).getList(); + } + public static String stringifyStrictOptions(int strictOptions) { StringBuilder result = new StringBuilder(); diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java b/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java index 8469ada16..708632d53 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Universal.java @@ -132,15 +132,18 @@ public static RuntimeList can(RuntimeArray args, int ctx) { } } + // Perl's can() must NOT consider AUTOLOAD - it should only find + // methods that are actually defined in the hierarchy. + // See perlobj: "can cannot know whether an object will be able to + // provide a method through AUTOLOAD" RuntimeScalar method = InheritanceResolver.findMethodInHierarchy(methodName, perlClassName, null, 0); - if (method != null) { + if (method != null && !isAutoloadDispatch(method, methodName, perlClassName)) { return method.getList(); } String normalizedName = NameNormalizer.normalizeVariableName(methodName, perlClassName); if (GlobalVariable.existsGlobalCodeRef(normalizedName)) { RuntimeScalar codeRef = GlobalVariable.getGlobalCodeRef(normalizedName); - // Only return the code ref if it's actually defined (has a real subroutine) if (codeRef.getDefinedBoolean()) { return codeRef.getList(); } @@ -154,7 +157,7 @@ public static RuntimeList can(RuntimeArray args, int ctx) { String effectiveMethodName = decodedMethodName != null ? decodedMethodName : methodName; String effectiveClassName = decodedClassName != null ? decodedClassName : perlClassName; method = InheritanceResolver.findMethodInHierarchy(effectiveMethodName, effectiveClassName, null, 0); - if (method != null) { + if (method != null && !isAutoloadDispatch(method, effectiveMethodName, effectiveClassName)) { return method.getList(); } } @@ -167,13 +170,47 @@ public static RuntimeList can(RuntimeArray args, int ctx) { String effectiveMethodName = methodNameAsOctets != null ? methodNameAsOctets : methodName; String effectiveClassName = classNameAsOctets != null ? classNameAsOctets : perlClassName; method = InheritanceResolver.findMethodInHierarchy(effectiveMethodName, effectiveClassName, null, 0); - if (method != null) { + if (method != null && !isAutoloadDispatch(method, effectiveMethodName, effectiveClassName)) { return method.getList(); } } return new RuntimeList(); } + /** + * Check if a method resolution result was found via AUTOLOAD dispatch + * rather than being a directly defined method. + *

+ * The AUTOLOAD coderef has autoloadVariableName set (e.g. "Foo::AUTOLOAD"). + * We detect AUTOLOAD dispatch by checking if the resolved coderef is actually + * an AUTOLOAD handler AND the method we asked for is not "AUTOLOAD" itself. + * We also verify the coderef came from the AUTOLOAD hierarchy by checking + * that the method doesn't actually exist as a direct definition. + */ + private static boolean isAutoloadDispatch(RuntimeScalar method, String methodName, String className) { + if (!(method.value instanceof RuntimeCode code)) { + return false; + } + if (code.autoloadVariableName == null) { + return false; + } + // If the method IS "AUTOLOAD", it's a direct lookup, not AUTOLOAD dispatch + if ("AUTOLOAD".equals(methodName)) { + return false; + } + // Verify by checking if the method actually exists as a real subroutine + // in the class hierarchy. The autoloadVariableName indicates it was + // resolved via the AUTOLOAD fallback path. + String normalizedName = NameNormalizer.normalizeVariableName(methodName, className); + if (GlobalVariable.existsGlobalCodeRef(normalizedName)) { + RuntimeScalar directRef = GlobalVariable.getGlobalCodeRef(normalizedName); + if (directRef.getDefinedBoolean() && directRef != method) { + return false; // There's a real method with this name + } + } + return true; + } + /** * Checks if the object is of a given class or a subclass. * Note: This is a Perl method, it expects `this` to be the first argument. diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Utf8.java b/src/main/java/org/perlonjava/runtime/perlmodule/Utf8.java index d23a3d4d4..cf51b187f 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Utf8.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Utf8.java @@ -39,7 +39,7 @@ public static void initialize() { try { utf8.registerMethod("import", "useUtf8", ";$"); utf8.registerMethod("unimport", "noUtf8", ";$"); - utf8.registerMethod("upgrade", "$"); + utf8.registerMethod("upgrade", null); utf8.registerMethod("downgrade", "$;$"); utf8.registerMethod("encode", "$"); utf8.registerMethod("decode", "$"); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalVariable.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalVariable.java index f3ba027f4..7ccd0744c 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalVariable.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalVariable.java @@ -508,26 +508,63 @@ public static boolean isPackageLoaded(String className) { // Ensure we have the :: suffix for the prefix check final String prefix = className.endsWith("::") ? className : className + "::"; - // Check if any code references exist with this class prefix + // Check if any code references exist directly in this class (not in sub-packages). + // A key like "Foo::Bar::baz" belongs to package "Foo::Bar", not "Foo". + // After stripping the prefix, the remaining part must NOT contain "::" + // to be a direct member of this package. boolean exists = globalCodeRefs.keySet().stream() - .anyMatch(key -> key.startsWith(prefix)); + .anyMatch(key -> key.startsWith(prefix) && !key.substring(prefix.length()).contains("::")); // Cache the result packageExistsCache.put(className, exists); return exists; } + /** + * Resolves a fully-qualified variable name through stash hash redirections. + *

+ * When {@code *PKG:: = \%OtherPkg::} is executed, accesses to {@code PKG::name} + * should resolve to {@code OtherPkg::name}. This method checks if the package + * portion of the name has been redirected to another package's RuntimeStash, and + * if so, rewrites the name accordingly. + *

+ * This is critical for the {@code local *__ANON__:: = $namespace} pattern used + * by Package::Stash::PP, where glob vivification through the aliased stash must + * create entries visible in the target package's symbol table. + * + * @param fullName The fully-qualified variable name (e.g., "__ANON__::foo"). + * @return The resolved name (e.g., "Foo::foo" if __ANON__:: was redirected to Foo::), + * or the original name if no redirection is active. + */ + public static String resolveStashHashRedirect(String fullName) { + int lastDoubleColon = fullName.lastIndexOf("::"); + if (lastDoubleColon >= 0) { + String pkgPart = fullName.substring(0, lastDoubleColon + 2); + RuntimeHash stashHash = globalHashes.get(pkgPart); + if (stashHash instanceof RuntimeStash stash && !stash.namespace.equals(pkgPart)) { + String shortName = fullName.substring(lastDoubleColon + 2); + return stash.namespace + shortName; + } + } + return fullName; + } + /** * Retrieves a global IO reference by its key, initializing it if necessary. + *

+ * Resolves stash hash redirections so that glob vivification through an aliased + * stash (e.g., after {@code *__ANON__:: = \%Foo::}) creates entries in the correct + * package's symbol table. * * @param key The key of the global IO reference. * @return The RuntimeScalar representing the global IO reference. */ public static RuntimeGlob getGlobalIO(String key) { - RuntimeGlob glob = globalIORefs.get(key); + String resolvedKey = resolveStashHashRedirect(key); + RuntimeGlob glob = globalIORefs.get(resolvedKey); if (glob == null) { - glob = new RuntimeGlob(key); - globalIORefs.put(key, glob); + glob = new RuntimeGlob(resolvedKey); + globalIORefs.put(resolvedKey, glob); } return glob; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java index 631e1d189..8c307bd52 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeList.java @@ -567,7 +567,9 @@ public RuntimeArray setFromList(RuntimeList value) { // Overwriting `elements` would discard the TieArray wrapper while leaving // `type == TIED_ARRAY`, leading to ClassCastException when tie operations // cast `array.elements` back to TieArray. - if (runtimeArray.type == RuntimeArray.TIED_ARRAY) { + // For autovivify arrays, we must also go through setFromList() to trigger + // the autovivification that converts the parent scalar from UNDEF to ARRAYREFERENCE. + if (runtimeArray.type == RuntimeArray.TIED_ARRAY || runtimeArray.type == RuntimeArray.AUTOVIVIFY_ARRAY) { RuntimeList remainingList = new RuntimeList(); remainingList.elements.addAll(remaining); runtimeArray.setFromList(remainingList); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java b/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java index 2d82af4a8..ddbc282c6 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java @@ -59,6 +59,7 @@ public class WarningFlags { warningHierarchy.put("exec", new String[]{"io::exec"}); warningHierarchy.put("reserved", new String[]{}); warningHierarchy.put("prototype", new String[]{}); + warningHierarchy.put("qw", new String[]{"syntax::qw"}); warningHierarchy.put("newline", new String[]{"io::newline"}); warningHierarchy.put("NONFATAL", new String[]{}); warningHierarchy.put("non_unicode", new String[]{}); diff --git a/src/main/perl/lib/B.pm b/src/main/perl/lib/B.pm index 4021cfd12..321ed2e22 100644 --- a/src/main/perl/lib/B.pm +++ b/src/main/perl/lib/B.pm @@ -49,6 +49,12 @@ package B::SV { return bless { ref => $ref }, $class; } + sub REFCNT { + # JVM uses tracing GC, not reference counting. + # Return 0 to indicate objects are always reclaimable. + return 0; + } + sub FLAGS { my $self = shift; my $r = $self->{ref}; diff --git a/src/main/perl/lib/DBD/SQLite.pm b/src/main/perl/lib/DBD/SQLite.pm new file mode 100644 index 000000000..624d63390 --- /dev/null +++ b/src/main/perl/lib/DBD/SQLite.pm @@ -0,0 +1,53 @@ +package DBD::SQLite; +use strict; +use warnings; + +our $VERSION = '1.74'; + +# Translate Perl DBI DSN to JDBC URL for SQLite +# Handles: +# dbi:SQLite:dbname=:memory: -> jdbc:sqlite::memory: +# dbi:SQLite::memory: -> jdbc:sqlite::memory: +# dbi:SQLite:dbname=/path/to/db -> jdbc:sqlite:/path/to/db +# dbi:SQLite:/path/to/db -> jdbc:sqlite:/path/to/db +# dbi:SQLite:dbname=file.db -> jdbc:sqlite:file.db +sub _dsn_to_jdbc { + my ($class, $dsn_rest) = @_; + + my $dbname; + if ($dsn_rest =~ /(?:^|;)dbname=(.+?)(?:;|$)/) { + $dbname = $1; + } elsif ($dsn_rest =~ /(?:^|;)database=(.+?)(?:;|$)/i) { + $dbname = $1; + } elsif ($dsn_rest =~ /^:memory:$/) { + $dbname = ':memory:'; + } elsif ($dsn_rest !~ /=/) { + $dbname = $dsn_rest; + } else { + $dbname = ':memory:'; + } + + return "jdbc:sqlite:$dbname"; +} + +1; + +__END__ + +=head1 NAME + +DBD::SQLite - PerlOnJava SQLite driver via JDBC (sqlite-jdbc) + +=head1 SYNOPSIS + + use DBI; + my $dbh = DBI->connect("dbi:SQLite:dbname=:memory:", "", ""); + my $dbh = DBI->connect("dbi:SQLite::memory:", "", ""); + my $dbh = DBI->connect("dbi:SQLite:dbname=/path/to/db.sqlite", "", ""); + +=head1 DESCRIPTION + +This is a PerlOnJava compatibility shim that translates Perl DBI DSN format +to JDBC URL format for the Xerial sqlite-jdbc driver bundled with PerlOnJava. + +=cut diff --git a/src/main/perl/lib/DBI.pm b/src/main/perl/lib/DBI.pm index e60afd62a..9cd00af4d 100644 --- a/src/main/perl/lib/DBI.pm +++ b/src/main/perl/lib/DBI.pm @@ -3,10 +3,88 @@ use strict; use warnings; use XSLoader; +our $VERSION = '1.643'; + XSLoader::load( 'DBI' ); # NOTE: The rest of the code is in file: -# src/main/java/org/perlonjava/perlmodule/DBI.java +# src/main/java/org/perlonjava/runtime/perlmodule/DBI.java + +# SQL type constants (from DBI spec, java.sql.Types values) +# Used by DBIx::Class::Storage::DBI::SQLite and others +use constant { + SQL_GUID => -11, + SQL_WLONGVARCHAR => -10, + SQL_WVARCHAR => -9, + SQL_WCHAR => -8, + SQL_BIGINT => -5, + SQL_BIT => -7, + SQL_TINYINT => -6, + SQL_LONGVARBINARY => -4, + SQL_VARBINARY => -3, + SQL_BINARY => -2, + SQL_LONGVARCHAR => -1, + SQL_UNKNOWN_TYPE => 0, + SQL_ALL_TYPES => 0, + SQL_CHAR => 1, + SQL_NUMERIC => 2, + SQL_DECIMAL => 3, + SQL_INTEGER => 4, + SQL_SMALLINT => 5, + SQL_FLOAT => 6, + SQL_REAL => 7, + SQL_DOUBLE => 8, + SQL_DATETIME => 9, + SQL_DATE => 9, + SQL_INTERVAL => 10, + SQL_TIME => 10, + SQL_TIMESTAMP => 11, + SQL_VARCHAR => 12, + SQL_BOOLEAN => 16, + SQL_UDT => 17, + SQL_UDT_LOCATOR => 18, + SQL_ROW => 19, + SQL_REF => 20, + SQL_BLOB => 30, + SQL_BLOB_LOCATOR => 31, + SQL_CLOB => 40, + SQL_CLOB_LOCATOR => 41, + SQL_ARRAY => 50, + SQL_MULTISET => 55, + SQL_TYPE_DATE => 91, + SQL_TYPE_TIME => 92, + SQL_TYPE_TIMESTAMP => 93, + SQL_TYPE_TIME_WITH_TIMEZONE => 94, + SQL_TYPE_TIMESTAMP_WITH_TIMEZONE => 95, +}; + +# DSN translation: convert Perl DBI DSN format to JDBC URL +# This wraps the Java-side connect() to support dbi:Driver:... format +{ + no warnings 'redefine'; + my $orig_connect = \&connect; + *connect = sub { + my ($class, $dsn, $user, $pass, $attr) = @_; + $dsn = '' unless defined $dsn; + my $driver_name; + if ($dsn =~ /^dbi:(\w+):(.*)$/i) { + my ($driver, $rest) = ($1, $2); + $driver_name = $driver; + my $dbd_class = "DBD::$driver"; + eval "require $dbd_class"; + if ($dbd_class->can('_dsn_to_jdbc')) { + $dsn = $dbd_class->_dsn_to_jdbc($rest); + } + } + my $dbh = $orig_connect->($class, $dsn, $user, $pass, $attr); + if ($dbh && $driver_name) { + # Set Driver attribute so DBIx::Class can detect the driver + # (e.g. $dbh->{Driver}{Name} returns "SQLite") + $dbh->{Driver} = bless { Name => $driver_name }, 'DBI::dr'; + } + return $dbh; + }; +} # Example: # @@ -26,6 +104,19 @@ our $MAX_CACHED_STATEMENTS = 100; our %CACHED_CONNECTIONS; our $MAX_CACHED_CONNECTIONS = 10; +# FETCH/STORE methods for tied-hash compatibility +# In real Perl DBI, handles are tied hashes. DBIx::Class calls +# $dbh->FETCH('Active') explicitly, so we need method wrappers. +sub FETCH { + my ($self, $key) = @_; + return $self->{$key}; +} + +sub STORE { + my ($self, $key, $value) = @_; + $self->{$key} = $value; +} + sub do { my ($dbh, $statement, $attr, @params) = @_; my $sth = $dbh->prepare($statement, $attr) or return undef; @@ -39,6 +130,37 @@ sub finish { $sth->{Active} = 0; } +# Batch execution: calls $fetch_tuple->() repeatedly to get parameter arrays, +# executes the prepared statement for each, and tracks results in $tuple_status. +sub execute_for_fetch { + my ($sth, $fetch_tuple, $tuple_status) = @_; + $tuple_status ||= []; + @$tuple_status = (); + + my $total_rows = 0; + while (my $tuple = $fetch_tuple->()) { + my $rv; + eval { + $rv = $sth->execute(@$tuple); + }; + if ($@) { + push @$tuple_status, [$@]; + next; + } + push @$tuple_status, $rv; + $total_rows += $rv if defined $rv && $rv >= 0; + } + return $total_rows; +} + +sub bind_param { + my ($sth, $param_num, $value, $attr) = @_; + # Store bind parameter for later use + $sth->{_bind_params} ||= {}; + $sth->{_bind_params}{$param_num} = $value; + return 1; +} + sub clone { my ($dbh) = @_; my %new_dbh = %{$dbh}; # Shallow copy diff --git a/src/main/perl/lib/DBI/Const/GetInfoReturn.pm b/src/main/perl/lib/DBI/Const/GetInfoReturn.pm new file mode 100644 index 000000000..4d372f8e6 --- /dev/null +++ b/src/main/perl/lib/DBI/Const/GetInfoReturn.pm @@ -0,0 +1,18 @@ +package DBI::Const::GetInfoReturn; +use strict; +use warnings; + +# Minimal stub for PerlOnJava - provides human-readable descriptions +# of DBI get_info() return values. Used by DBIx::Class for diagnostics. + +sub Explain { + my ($info_type, $value) = @_; + return ''; +} + +sub Format { + my ($info_type, $value) = @_; + return defined $value ? "$value" : ''; +} + +1; diff --git a/src/main/perl/lib/ExtUtils/MM_Unix.pm b/src/main/perl/lib/ExtUtils/MM_Unix.pm index 229565c2e..034f3fdbd 100644 --- a/src/main/perl/lib/ExtUtils/MM_Unix.pm +++ b/src/main/perl/lib/ExtUtils/MM_Unix.pm @@ -80,6 +80,50 @@ sub get_version { return; } +# parse_abstract - extract ABSTRACT from a Perl module's POD +sub parse_abstract { + my($self,$parsefile) = @_; + my $result; + + local $/ = "\n"; + open(my $fh, '<', $parsefile) or die "Could not open '$parsefile': $!"; + binmode $fh; + my $inpod = 0; + my $pod_encoding; + my $package = $self->{DISTNAME}; + $package =~ s/-/::/g; + while (<$fh>) { + $inpod = /^=(?!cut)/ ? 1 : /^=cut/ ? 0 : $inpod; + next if !$inpod; + s#\r*\n\z##; # handle CRLF input + + if ( /^=encoding\s*(.*)$/i ) { + $pod_encoding = $1; + } + + if ( /^($package(?:\.pm)? \s+ -+ \s+)(.*)/x ) { + $result = $2; + next; + } + next unless $result; + + if ( $result && ( /^\s*$/ || /^\=/ ) ) { + last; + } + $result = join ' ', $result, $_; + } + close $fh; + + if ( $pod_encoding ) { + eval { + require Encode; + $result = Encode::decode($pod_encoding, $result); + } + } + + return $result; +} + # maybe_command - check if a file is an executable command (Unix version) sub maybe_command { my($self,$file) = @_; diff --git a/src/main/perl/lib/ExtUtils/MakeMaker.pm b/src/main/perl/lib/ExtUtils/MakeMaker.pm index 7b964f2b6..f02f52ba6 100644 --- a/src/main/perl/lib/ExtUtils/MakeMaker.pm +++ b/src/main/perl/lib/ExtUtils/MakeMaker.pm @@ -516,7 +516,7 @@ sub _shell_cp { my ($src, $dest) = @_; $src =~ s/'/'\\''/g; $dest =~ s/'/'\\''/g; - return "\t\@cp '$src' '$dest'"; + return "\t\@rm -f '$dest' && cp '$src' '$dest'"; } sub _create_mymeta { diff --git a/src/test/resources/unit/string_interpolation.t b/src/test/resources/unit/string_interpolation.t index 543b3edab..104cd93fe 100644 --- a/src/test/resources/unit/string_interpolation.t +++ b/src/test/resources/unit/string_interpolation.t @@ -334,5 +334,27 @@ EOT ok(defined($result) || $@, "Here-doc in array ref interpolation handled"); }; +# Test @${$v} interpolation - array dereference of scalar dereference in strings +{ + my @a = (10, 20, 30); + my $r = \@a; + my $v = \$r; + is("@${$v}", "10 20 30", '@${$v} interpolates array via double dereference'); +} + +# Test @${$v} with array ref directly +{ + my $v = [4, 5, 6]; + my $rv = \$v; + is("@${$rv}", "4 5 6", '@${$rv} interpolates array ref via scalar deref'); +} + +# Test @$r still works (simple array deref in string) +{ + my @a = (7, 8, 9); + my $r = \@a; + is("@$r", "7 8 9", '@$r simple array dereference in string'); +} + done_testing(); diff --git a/src/test/resources/unit/subroutine.t b/src/test/resources/unit/subroutine.t index 77b9f455e..abb7ee007 100644 --- a/src/test/resources/unit/subroutine.t +++ b/src/test/resources/unit/subroutine.t @@ -177,4 +177,57 @@ sub hoisted_with_prototype($) { } } +############################ +# &func (no parens) shares caller's @_ by alias +# shift() in the callee should modify the caller's @_ + +{ + sub _get_first { shift } + + sub caller_of_get_first { + my $first = &_get_first; + return ($first, scalar @_); + } + + my ($result, $remaining) = caller_of_get_first("a", "b", "c"); + is($result, "a", '&func shares @_ - shift returns first element'); + is($remaining, 2, '&func shares @_ - shift modifies caller @_'); +} + +# _get_obj pattern (used by Hash::Merge and other CPAN modules) +{ + use Scalar::Util "blessed"; + + package TestGetObj; + sub new { bless {val => $_[1]}, $_[0] } + + package main; + my $fallback; + + sub _test_get_obj { + if (my $type = ref $_[0]) { + return shift() + if $type eq "TestGetObj" + || (blessed $_[0] && $_[0]->isa("TestGetObj")); + } + defined $fallback or $fallback = TestGetObj->new("default"); + return $fallback; + } + + sub do_merge { + my $self = &_test_get_obj; + my ($left, $right) = @_; + return "$self->{val}:$left:$right"; + } + + # OO call - object is shifted from @_, remaining are args + my $obj = TestGetObj->new("custom"); + is(do_merge($obj, "L", "R"), "custom:L:R", + '&_get_obj pattern - OO call shifts object from @_'); + + # Functional call - no object, uses fallback + is(do_merge("L", "R"), "default:L:R", + '&_get_obj pattern - functional call uses fallback'); +} + done_testing();