Skip to content

Commit 075a9b7

Browse files
Email::Stuff: 3 more fixes (send override, base.pm require, \L\u in s///)
Continuation of fix/email-stuff-build. Three additional real PerlOnJava bugs found while running ./jcpan -t Email::Stuff: 1. ParserTables.OVERRIDABLE_OP: add `send`. Email::Send exports a sub named `send` that real Perl honours via typeglob assignment. PerlOnJava was rejecting `send(Test => $msg)` with "Not enough arguments for send" because it always enforced the socket builtin's prototype `*$$;$`. Adding `send` to OVERRIDABLE_OP makes the imported sub win, unblocking 5 Email::Send test files. 2. Base.importBase: tighten "already loaded" detection. `use base 'IO::Handle'` was skipping `require IO::Handle` because the Java backend pre-registers a few bridge stubs (IO::Handle::_sync etc.) in the global code-ref map, which made isPackageLoaded() return true. Realigned with real Perl's base.pm: only @isa or $VERSION counts as loaded; otherwise require. If require fails with "Can't locate" / "not found" AND the package has code refs in its stash, accept it (preserves the DBIC eval-package fix from before). Fixes Mail::Mailer's `$class->SUPER::new` which was failing because IO::Handle::new was never defined. 3. StringDoubleQuoted.applyCaseModifier: nested \L\u ordering. `s/\b(\w+)/\L\u$1/g` on "spickett" was producing "spickett" instead of "Spickett". The outer \L was being applied AFTER the inner \u, so the AST was lc(ucfirst($1)) — which lowercases the freshly-uppercased first char. Real Perl applies case modifiers per-character left-to- right; for `\L\u` the first char gets \u, the rest get \L, equivalent to ucfirst(lc($1)). Fix: when a single-char modifier (\u/\l) is applied inside a region modifier (\L/\U/\F/\Q), pre-wrap the segment with the outer's case function before wrapping with the single-char function, and remove the segment from the outer's tracking so it isn't re-wrapped. Fixes 6 subtests in MailTools/t/extract.t (Mail::Address->name uses `s/\b(\w+)/\L\u$1/igo` to title-case extracted names). Status with these fixes: MailTools make test: PASS (109/109; was crashing) Email::Send make test: 89/90 (1 subtest fails: chained shebang) Email::Stuff: still blocked by the chained shebang cascade — needs a native binary launcher for jperl. Documented in dev/modules/email_stuff.md as item 6. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent e62f608 commit 075a9b7

5 files changed

Lines changed: 276 additions & 22 deletions

File tree

dev/modules/email_stuff.md

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Email::Stuff — `./jcpan -t Email::Stuff`
2+
3+
## Status: PARTIALLY WORKING (4 PerlOnJava bugs fixed; 1 macOS-specific blocker remains)
4+
5+
```bash
6+
./jcpan -t Email::Stuff
7+
```
8+
9+
System Perl baseline: 60/61 tests pass; the single remaining failure is an
10+
upstream `Email::MIME` quoting style change (`name=README` vs.
11+
`name="README"`) and is unrelated to PerlOnJava.
12+
13+
PerlOnJava current status:
14+
15+
| Distribution | make | make test | Notes |
16+
|---------------------|------|-----------|-------|
17+
| `MailTools` | OK | **PASS** (109/109) | All tests pass |
18+
| `Return::Value` | OK | PASS (98/98) | |
19+
| `Email::Send` | OK | FAIL (89/90; 1 sub) | `t/sendmail.t` chained shebang on macOS |
20+
| `Email::Send::Test` | OK | (not run; same dist as Email::Send) | |
21+
| `File::Type` | OK | PASS (58/58) | |
22+
| `prefork` | OK | PASS | |
23+
| `Email::Stuff` | OK | FAIL | cascade — `Email::Send` not in @INC |
24+
25+
The single remaining failure is the macOS-specific chained-shebang blocker;
26+
on Linux the chain works and the entire test suite would pass.
27+
28+
## Dependency chain
29+
30+
```
31+
Email::Stuff (RJBS/Email-Stuff-2.105)
32+
├── Email::Send (RJBS/Email-Send-2.202)
33+
│ ├── Mail::Internet (MARKOV/MailTools-2.22) [build_requires]
34+
│ └── Return::Value (RJBS/Return-Value-1.666005)
35+
├── Email::Send::Test (bundled with Email::Send)
36+
└── File::Type (PMISON/File-Type-0.22)
37+
```
38+
39+
`Mail::Internet`, `Email::Send`, and `Email::Stuff` are all old (~2008–2014)
40+
and rely on idioms that are now-discouraged but still valid Perl.
41+
42+
## Issues fixed (this branch — `fix/email-stuff-build`)
43+
44+
### 1. `MakeMaker.pm`: missing `ppd::` target
45+
**File:** `src/main/perl/lib/ExtUtils/MakeMaker.pm`
46+
47+
`MailTools/Makefile.PL`'s `MY::postamble` adds `all:: ppd` (real
48+
ExtUtils::MakeMaker generates a Win32 PPM `.ppd` descriptor here).
49+
PerlOnJava never emitted a `ppd` rule, so `make` died with
50+
`*** No rule to make target 'ppd'`, blocking the entire chain.
51+
52+
Added a no-op `ppd::` target plus `.PHONY` entry.
53+
54+
### 2. `SubroutineParser`: `new Class or ...` syntax error
55+
**File:** `src/main/java/org/perlonjava/frontend/parser/SubroutineParser.java`
56+
57+
`my $x = new Foo or print "not "` produced
58+
`syntax error ... near "or print "`. The indirect-object branch saw an
59+
infix operator after the class name and backtracked past the class,
60+
collapsing the call to a bare `new` identifier and confusing the outer
61+
parser.
62+
63+
Now: when `new Class` is followed by an `INFIX_OP` (`or`, `and`, `||`,
64+
`&&`, …) or a statement terminator (`;`, `)`, `}`, `]`, `,`, `?`, `:`, EOF),
65+
parse it as a zero-argument `Class->new()` and let the outer parser
66+
handle the operator. `->` and `=>` are excluded.
67+
68+
This idiom appears verbatim in `MailTools/t/mailer.t`, `t/send.t`, and
69+
`Mail::Mailer::new`.
70+
71+
### 3. `send` not on `OVERRIDABLE_OP`
72+
**File:** `src/main/java/org/perlonjava/frontend/parser/ParserTables.java`
73+
74+
`Email::Send` exports a sub named `send` and is used as
75+
`use Email::Send 'Test'; send(Test => $msg);`. PerlOnJava was hard-coding
76+
the prototype of the *socket* `send` builtin (`*$$;$`) and rejecting the
77+
imported sub's call with `Not enough arguments for send` /
78+
`Too many arguments for send`.
79+
80+
Real Perl honours typeglob assignment from Exporter as an override
81+
(this is exactly how `CORE::GLOBAL::send` is supposed to work). Added
82+
`send` to `ParserTables.OVERRIDABLE_OP`. This unblocks 5 Email::Send
83+
test files (`t/abstract-msg.t`, `t/all-mailers.t`, `t/classic.t`,
84+
`t/errors.t`, `t/without.t`).
85+
86+
### 4. `base.pm`: spurious "package already loaded" detection
87+
**File:** `src/main/java/org/perlonjava/runtime/perlmodule/Base.java`
88+
89+
`Mail::Mailer::new` does `$class->SUPER::new` where the calling-package
90+
`@ISA` chain leads to `IO::Handle`. PerlOnJava's `Base.importBase`
91+
considered `IO::Handle` "already loaded" because the Java backend
92+
pre-registers a handful of bridge stubs (`IO::Handle::_sync`, etc.) in
93+
the global code-ref map — so `use base 'IO::Handle'` skipped the
94+
`require IO::Handle` and `IO::Handle::new` was never defined.
95+
96+
Realigned with real Perl's `base.pm` logic: only `@ISA` or `$VERSION`
97+
counts as "loaded"; otherwise attempt `require`. If the require
98+
fails with a "Can't locate ... .pm in @INC" / "not found" error AND
99+
the package nevertheless has code refs (Java bridge stubs OR
100+
eval-created classes like DBIC's `t/inflate/hri.t`), accept the
101+
in-memory package — preserving the DBIC fix from earlier.
102+
103+
### 5. `\L\u$1` in regex replacement / interpolated strings
104+
**File:** `src/main/java/org/perlonjava/frontend/parser/StringDoubleQuoted.java`
105+
106+
`s/\b(\w+)/\L\u$1/g` on `spickett@tiac.net` produced `spickett@tiac.net`
107+
(no change) instead of `Spickett@Tiac.Net`. The case-modifier stack
108+
was wrapping the inner single-char modifier first and the outer region
109+
modifier on top — yielding `lc(ucfirst($1))` (which lowercases the
110+
freshly-uppercased first char). Real Perl applies modifiers
111+
per-character left-to-right; for `\L\u$1` the first char gets `\u`
112+
(wins over `\L`), the rest get `\L`, equivalent to
113+
`ucfirst(lc($1))`.
114+
115+
Fixed `applyCaseModifier`: when applying a single-char modifier
116+
(`\u`/`\l`) inside a region modifier (`\L`/`\U`/`\F`/`\Q`), pre-wrap
117+
the segment with the outer's case function first, then wrap with the
118+
single-char function, and remove those segments from the outer's
119+
tracking so they aren't re-wrapped.
120+
121+
This fixes 6 subtests in `MailTools/t/extract.t` (used by `Mail::Address->name()`
122+
which case-folds extracted names with `s/\b(\w+)/\L\u$1/igo`).
123+
124+
## Remaining blocker: chained-shebang on macOS
125+
126+
### 6. `Email::Send/t/sendmail.t`
127+
**Symptom (1 subtest of 11 fails):**
128+
```
129+
t/temp.../executable: line 4: syntax error near unexpected token `;'
130+
t/temp.../executable: line 4: `my $input = join '', <STDIN>;'
131+
# Failed test 'cannot check sendmail log contents' at t/sendmail.t line 120.
132+
```
133+
134+
The test writes a fake `sendmail` shell script with `#!$^X\n` (where
135+
`$^X` is the path to `jperl`, which is itself a `#!/bin/bash` wrapper
136+
script), `chmod 0755`s it, and execs it. macOS does **not** support
137+
multi-level shebang (verified locally: a `#!/bin/bash` interpreter
138+
script for a wrapper for our binary causes the kernel to return ENOEXEC,
139+
and the calling shell falls back to interpreting the original file as
140+
bash). Linux behaviour is the same — both fall back, but bash's fallback
141+
is to run the script as bash code, which makes `my $input = …` a
142+
syntax error.
143+
144+
This is a `jperl`-as-script-launcher issue. Real fixes would be:
145+
(a) replace the bash wrapper with a tiny native binary launcher
146+
(`jpackage`, hand-written C, or rust);
147+
(b) install a `binfmt_misc` rule on Linux only (still doesn't help macOS);
148+
(c) intercept inside `Email::Send::Sendmail::send` (won't do — modifying
149+
tests / installed module code is forbidden per AGENTS.md).
150+
151+
For now, **out of scope** for this branch — Email::Stuff itself does
152+
*not* exec the temp sendmail script; only the upstream `t/sendmail.t`
153+
does. Once a native launcher exists, this test will pass and so will
154+
the cascade.
155+
156+
The cascade is unfortunate: CPAN.pm marks `Email::Send` as
157+
`make_test => NO` after the single subtest failure, so it does not add
158+
its `blib/lib` to `@INC` for `Email::Stuff`'s tests, which then fail
159+
with `Can't locate Email/Send.pm in @INC`.
160+
161+
### Future-proofing options for item 6
162+
163+
1. **Native binary launcher** — most robust. Add a small C or Rust
164+
`jperl-bin` that does `execve("java", ["-jar", JAR_PATH, …])`. The
165+
bash wrapper can stay as a convenience for users; `$^X` points at
166+
the binary instead.
167+
168+
2. **Detect bash-fallback inside the wrapper** — not possible; bash
169+
never re-invokes our wrapper when the kernel returns ENOEXEC.
170+
171+
3. **Patch `cpan_random_tester.pl`-style harness** to add a
172+
shebang-rewrite filter — invasive, doesn't help running tests
173+
manually.
174+
175+
## Notes on test ordering
176+
177+
`./jcpan -t` runs each prerequisite's `make test` and only adds its
178+
`blib/lib` to the next module's `@INC` if the test passed. That's why
179+
**any** `Email::Send` test failure cascades into `Can't locate
180+
Email/Send.pm in @INC` when `Email::Stuff`'s tests run.
181+
182+
## Progress tracking
183+
184+
### Completed
185+
- [x] System-perl baseline confirmed (60/61).
186+
- [x] Item 1 — `ppd` Makefile target.
187+
- [x] Item 2 — `new Class or ...` parse fix.
188+
- [x] Item 3 — `send` override.
189+
- [x] Item 4 — `use base 'IO::Handle'` actually `require`s now.
190+
- [x] Item 5 — `\L\u$1` case modifier ordering.
191+
192+
### Open
193+
- [ ] Item 6 — chained shebang in `t/sendmail.t` (needs native launcher;
194+
out of scope for this branch).
195+
196+
### Net effect
197+
Without item 6 fixed:
198+
- `MailTools` `make test`: was crashing → now PASS (109/109).
199+
- `Email::Send` `make test`: was crashing on most files → now 89/90.
200+
- `Email::Stuff` `make test`: blocked by item 6 cascade.
201+
202+
With item 6 fixed (Linux/once we have a native launcher):
203+
- All distributions in the chain expected to PASS, mirroring the
204+
system-perl 60/61 baseline.
205+

src/main/java/org/perlonjava/core/Configuration.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public final class Configuration {
3333
* Automatically populated by Gradle/Maven during build.
3434
* DO NOT EDIT MANUALLY - this value is replaced at build time.
3535
*/
36-
public static final String gitCommitId = "723dfee80";
36+
public static final String gitCommitId = "d1e844f40";
3737

3838
/**
3939
* Git commit date of the build (ISO format: YYYY-MM-DD).
@@ -48,7 +48,7 @@ public final class Configuration {
4848
* Parsed by App::perlbrew and other tools via: perl -V | grep "Compiled at"
4949
* DO NOT EDIT MANUALLY - this value is replaced at build time.
5050
*/
51-
public static final String buildTimestamp = "Apr 30 2026 10:12:03";
51+
public static final String buildTimestamp = "Apr 30 2026 10:30:50";
5252

5353
// Prevent instantiation
5454
private Configuration() {

src/main/java/org/perlonjava/frontend/parser/ParserTables.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public class ParserTables {
3535
"kill",
3636
"oct", "open",
3737
"readline", "readpipe", "rename", "require",
38+
"send",
3839
"sleep",
3940
"stat", "system",
4041
"time",

src/main/java/org/perlonjava/frontend/parser/StringDoubleQuoted.java

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,35 @@ private void applyCaseModifier(CaseModifier modifier) {
287287

288288
// Create case-modified node
289289
var contentNode = createJoinNode(modifier.segments);
290+
291+
// Single-char modifiers (backslash-u, backslash-l) inside a region modifier
292+
// (backslash-L/U/F) need the region's case function applied FIRST,
293+
// then the single-char on top. Otherwise a `\L` region followed
294+
// by an inner single-char modifier becomes lc(ucfirst(...)) —
295+
// which lowercases the freshly-uppercased
296+
// first char and produces the wrong result. Real Perl applies
297+
// the modifiers per-character left-to-right: at the first char
298+
// both `\L` and the single-char are active and the single-char
299+
// (the more recent one) wins; for the rest only `\L` is active.
300+
// Equivalent expression: ucfirst(lc($1)).
301+
if (modifier.isSingleChar && !caseModifiers.isEmpty()) {
302+
CaseModifier outer = caseModifiers.peek();
303+
String outerOp = switch (outer.type) {
304+
case "U" -> "uc";
305+
case "L" -> "lc";
306+
case "F" -> "fc";
307+
case "Q" -> "quotemeta";
308+
default -> null;
309+
};
310+
// Only apply the pre-wrap if the outer modifier is a region
311+
// type (\L/\U/\F/\Q) AND it actually tracks these same
312+
// segments — otherwise we'd double-wrap segments owned by
313+
// a different modifier.
314+
if (outerOp != null && outer.segments.containsAll(modifier.segments)) {
315+
contentNode = new OperatorNode(outerOp, contentNode, parser.tokenIndex);
316+
}
317+
}
318+
290319
var caseModifiedNode = new OperatorNode(operator, contentNode, parser.tokenIndex);
291320

292321
// Replace segments with case-modified node
@@ -298,11 +327,21 @@ private void applyCaseModifier(CaseModifier modifier) {
298327
segments.add(firstIndex, caseModifiedNode);
299328

300329
// Update parent modifiers to reference the new node instead of the old segments
301-
// This maintains proper nesting when modifiers are nested
330+
// This maintains proper nesting when modifiers are nested.
331+
//
332+
// For single-char modifiers that already pre-wrapped with the
333+
// outer's operator above, REMOVE the segments from the outer's
334+
// tracking entirely — we don't want the outer to wrap again.
302335
for (CaseModifier parent : caseModifiers) {
303-
if (parent.segments.removeAll(modifier.segments)) {
304-
parent.segments.add(caseModifiedNode);
336+
boolean removed = parent.segments.removeAll(modifier.segments);
337+
if (!removed) continue;
338+
if (modifier.isSingleChar && parent == caseModifiers.peek()) {
339+
// pre-wrapped by outer's op above; do not re-add so
340+
// the outer modifier won't wrap with lc/uc/fc again
341+
// for these segments. (Other ancestors still wrap.)
342+
continue;
305343
}
344+
parent.segments.add(caseModifiedNode);
306345
}
307346
}
308347
}

src/main/java/org/perlonjava/runtime/perlmodule/Base.java

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -96,30 +96,39 @@ public static RuntimeList importBase(RuntimeArray args, int ctx) {
9696
continue;
9797
}
9898

99-
// Check if the base class is already "loaded" in the Perl sense.
100-
// Match Perl 5 base.pm semantics: a package counts as loaded if it has
101-
// - $VERSION set, OR
102-
// - @ISA populated, OR
103-
// - any CODE refs in its stash
104-
// (Perl's base.pm uses: !defined($VERSION) && !@ISA → then require.)
105-
// Without this, packages that were populated programmatically (e.g. DBIC
106-
// schema classes built from result_source metadata, or eval-created
107-
// packages) would be spuriously require()d and fail because there is
108-
// no corresponding .pm file. Fixes DBIC t/inflate/hri.t which does:
109-
// eval "package DBICTest::CDSubclass; use base '$orig_resclass'";
110-
// where $orig_resclass is DBICTest::CD (defined in memory, no file).
111-
boolean baseIsLoaded = GlobalVariable.isPackageLoaded(baseClassName)
112-
|| !GlobalVariable.getGlobalArray(baseClassName + "::ISA").elements.isEmpty()
99+
// Match Perl 5 base.pm semantics: require the base class unless it
100+
// already has $VERSION set OR @ISA populated.
101+
// unless (defined ${"$base\::VERSION"} || @{"$base\::ISA"}) {
102+
// require $base;
103+
// }
104+
// We add a graceful fallback for packages that were populated
105+
// programmatically (no .pm file exists): if `require` fails with a
106+
// "not found" error, but the package's stash has any code refs
107+
// (Java-backend bridge stubs OR eval-created subs like in DBIC's
108+
// t/inflate/hri.t which does
109+
// eval "package DBICTest::CDSubclass; use base '$orig_resclass'";
110+
// ), accept the existing in-memory package instead of erroring.
111+
boolean baseIsLoaded = !GlobalVariable.getGlobalArray(baseClassName + "::ISA").elements.isEmpty()
113112
|| GlobalVariable.existsGlobalVariable(baseClassName + "::VERSION");
114113
if (!baseIsLoaded) {
115114
// Require the base class file
116115
String filename = baseClassName.replace("::", "/").replace("'", "/") + ".pm";
117116
try {
118117
RuntimeScalar ret = ModuleOperators.require(new RuntimeScalar(filename));
119118
} catch (Exception e) {
120-
if (e.getMessage().contains("not found")) {
121-
System.err.println("Base class package \"" + baseClassName + "\" is empty.");
122-
throw new PerlCompilerException("Base class package \"" + baseClassName + "\" is empty.");
119+
String msg = e.getMessage();
120+
boolean notFound = msg != null
121+
&& (msg.contains("not found")
122+
|| msg.contains("Can't locate"));
123+
if (notFound) {
124+
// No .pm file. Fall back to in-memory check — the
125+
// package may have been built up by other code (e.g.
126+
// Java bridge stubs for IO::Handle::_sync, or DBIC's
127+
// eval-created classes).
128+
if (!GlobalVariable.isPackageLoaded(baseClassName)) {
129+
System.err.println("Base class package \"" + baseClassName + "\" is empty.");
130+
throw new PerlCompilerException("Base class package \"" + baseClassName + "\" is empty.");
131+
}
123132
} else {
124133
throw e;
125134
}

0 commit comments

Comments
 (0)