Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions dev/design/ppr-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# PPR 0.001010 Compatibility

Issue: #1318

## Objective

Make the complete non-optional test suite for CPAN distribution
`DCONWAY/PPR-0.001010` pass under PerlOnJava. The final integration gate is a
bounded `./jcpan -t PPR` run with no failed test files or stuck harness
processes.

This work follows #1299, which fixed the Joni analysis hang while loading
PPR's large dynamic named-subexpression grammar. Loading the module is no
longer the acceptance boundary: PPR grammar matching must now be compatible.

## Known Baseline

On the merged #1317 source, `timeout 1800 ./jcpan -t PPR` configured and built
PPR successfully, then exposed these failures:

| Test | Observed symptom | Initial ownership hypothesis |
| --- | --- | --- |
| `t/blocks.t` | `Range [4, 0) out of bounds for length 61` | Joni matcher offset/capture state |
| `t/control.t` | `Range [7, 0) out of bounds for length 49` | Joni matcher offset/capture state |
| `t/for_ref_iterator.t` | `Range [1, 0) out of bounds for length 43` | Joni matcher offset/capture state |
| `t/format.t` | `Range [1, 0) out of bounds for length 427` | Joni matcher offset/capture state |
| `t/heredoc.t` | second heredoc block does not match; harness then stops advancing | grammar matching / progression state |

The current baseline also proves that `t/00.load.t`, `t/decomment.t`,
`t/decomment_heredoc_large.t`, `t/document_self.t`, `t/erudil.t`, and
`t/eyedrops.t` passed before the stalled test. `t/disapproval.t` is an
explicit optional-dependency skip.

## Design Constraints

- Keep native regex semantics in the Joni fork. Do not special-case PPR source
text, rewrite its patterns, or introduce a Java-regex fallback.
- Reduce each failure into a project-owned test under
`src/test/resources/unit` before its fix is considered complete.
- Run each new Perl-level test on system Perl first, then on JVM and
interpreter backends. Preserve positive and negative controls.
- Treat a timeout or a `0/0` test record as a failure, not a skip.
- Do not modify imported CPAN test files.

## Work Plan

### Phase 1: Establish the range-error owner

1. Reduce the first `t/blocks.t` grammar fragment to a small named-subpattern
match.
2. Capture JVM debug stack traces and compare JVM/interpreter outcomes.
3. Locate the earliest invalid begin/end offset in Joni matcher, capture
publication, or PerlOnJava's byte-to-character conversion.
4. Add the permanent reducer and correct the shared root cause.

### Phase 2: Complete adjacent grammar failures

1. Verify whether `control`, `for_ref_iterator`, and `format` share the Phase
1 root cause.
2. Add distinct reducers for any non-shared behavior.
3. Test both matching and non-matching paths, including nested and empty
recursive paths where relevant.

### Phase 3: Heredoc correctness and progress

1. Reduce PPR's second `t/heredoc.t` fixture unchanged in semantic form.
2. Establish the system Perl oracle and locate the first divergent regex stage.
3. Fix the mismatch and any subsequent non-progress condition separately if
they have different causes.

### Phase 4: Distribution acceptance

1. Run focused PPR tests on both PerlOnJava backends.
2. Run the complete bounded `./jcpan -t PPR` suite and inspect its full log.
3. Run `make`, scan for warnings, and validate the exact clean candidate head.

## Progress Tracking

### Current Status: Complete (2026-09-10)

### Completed Phases

- [x] Phase 0: #1299 load-time analysis hang (2026-09-09)
- Replaced exponential named-subexpression recursion analysis with graph
analysis and retained dynamic-callout safety handling.
- Added `src/test/resources/unit/regex_large_named_grammar.t`.
- Merged in PR #1317.
- [x] Phase 1: Capture-range publication (2026-09-09)
- Identified Joni's stale nonnegative begin / zero end sentinel as an
unmatched capture in PerlOnJava's adapter.
- Added direct adapter coverage and published it as `undef` rather than an
invalid Java substring range.
- This clears PPR's `blocks`, `control`, `for_ref_iterator`, and `format`
range-error family.
- [x] Phases 2–4: Recursive grammar execution and distribution acceptance (2026-09-10)
- Corrected nullable recursive-call empty checks, recursive capture
restoration for duplicate named groups, and duplicate-name backreference
selection in Joni.
- Kept callback-bearing regex programs intact for empty-pattern reuse with
changed modifiers, allowing PPR's unpunctuated JAPH to execute.
- Added focused coverage in `src/test/resources/unit/regex/` and validated
it on system Perl plus both PerlOnJava backends.
- `timeout 1800 ./jcpan -t PPR` passes: 75 files, 1,255 tests.

### Next Steps

1. Monitor the PR checks and review feedback.
2. Keep PPR in the CPAN compatibility acceptance rotation.

### Open Questions

- None for the current PPR acceptance scope.

## Related Work

- Issue #1299: PPR grammar load-time Joni analysis hang.
- Issue #1318: PPR compatibility follow-up and acceptance tracking.
- `.agents/skills/debug-regex-engine/SKILL.md`.
12 changes: 12 additions & 0 deletions docs/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ priorities and future plans.

- Identify PerlOnJava, its copyright, and its dual-license terms in
`jperl -v` output while retaining the standard Perl text.

- Preserve source files when extensionless in-place editing aborts, and treat
a lone `'*'` in-place extension like Perl's extensionless form.
- Prevent eval-created named subs from treating lexical variables as
same-named constant calls, restoring `Types::Numbers` loading through
`Data::Float`.

- Restore Perl-compatible `<>` and `<<>>` ARGV traversal, `eof()` behavior,
diagnostics, and warning handling on both execution backends.

- Implement undef-aware experimental equality operators (`===`, `!==`, `equ`,
and `neu`) with lexical warnings and single-evaluation chained comparisons.

Expand All @@ -26,6 +32,12 @@ priorities and future plans.

- Fixed large dynamic named-subexpression grammars hanging during regex compilation.

- Restore PPR's complete suite by correcting recursive duplicate-name captures,
nullable recursion checks, and callback regex reuse.

- Route argumentless `readline` through localized `@ARGV`, matching Perl's
diamond-reader behavior and keeping PPR's self-document test warning-free.

- Preserve Data::Dumper's pure-Perl numeric-string behavior for
Test::Differences, including copied `qw` values and numeric zero fixtures.

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/org/perlonjava/app/cli/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.perlonjava.app.scriptengine.PerlLanguageProvider;
import org.perlonjava.runtime.operators.WarnDie;
import org.perlonjava.runtime.runtimetypes.ErrorMessageUtil;
import org.perlonjava.runtime.runtimetypes.DiamondIO;
import org.perlonjava.runtime.runtimetypes.GlobalVariable;
import org.perlonjava.runtime.runtimetypes.PerlExitException;
import org.perlonjava.runtime.runtimetypes.PerlRuntime;
Expand Down Expand Up @@ -155,6 +156,7 @@ private static void run(String[] args) {
System.exit(PerlRuntime.current().threadRegistry()
.requestedProcessExitOr(e.getExitCode()));
} catch (Throwable t) {
DiamondIO.abortInPlaceEditing();
if (parsedArgs.debugEnabled) {
// Print full JVM stack
t.printStackTrace();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1794,7 +1794,15 @@ private static void visitDiamond(BytecodeCompiler bc, OperatorNode node) {
bc.compileNode(node.operand, -1, RuntimeContextType.SCALAR);
int fhReg = bc.lastResultReg;
int rd = bc.allocateOutputRegister();
bc.emit(Opcodes.READLINE); bc.emitReg(rd); bc.emitReg(fhReg); bc.emit(bc.currentCallContext);
// Preserve that this READLINE originated from <>/<<>>. The runtime
// value can be a glob (not merely the empty-string marker), so it
// cannot reliably infer diamond semantics from the filehandle.
bc.emit(Opcodes.READLINE); bc.emitReg(rd); bc.emitReg(fhReg);
int diamondFlags = 0x100;
if (Boolean.TRUE.equals(node.getAnnotation("doubleDiamond"))) {
diamondFlags |= 0x200;
}
bc.emit(bc.currentCallContext | diamondFlags);
bc.lastResultReg = rd;
} else {
OperatorNode globNode = new OperatorNode("glob", node.operand, node.tokenIndex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -919,18 +919,18 @@ public static int executeReadline(int[] bytecode, int pc, RuntimeBase[] register
int rd = bytecode[pc++];
int fhReg = bytecode[pc++];
int ctx = bytecode[pc++];
boolean diamond = (ctx & 0x100) != 0;
boolean doubleDiamond = (ctx & 0x200) != 0;
ctx &= ~(0x100 | 0x200);

if (ctx == RuntimeContextType.RUNTIME) ctx = ((RuntimeScalar) registers[2]).getInt();
RuntimeScalar fh = (RuntimeScalar) registers[fhReg];
// Diamond operator <> passes a plain string scalar (not a glob/IO).
// Route to DiamondIO.readline which manages @ARGV / STDIN iteration.
// But blessed objects may have <> overload, so route those to Readline.
if (fh.getRuntimeIO() == null) {
if (RuntimeScalarType.blessedId(fh) < 0) {
registers[rd] = Readline.readline(fh, ctx);
} else {
registers[rd] = DiamondIO.readline(fh, ctx);
}
if (diamond || (RuntimeScalarType.blessedId(fh) < 0
&& (fh.toString().isEmpty() || "<>".equals(fh.toString())))) {
registers[rd] = DiamondIO.readline(fh, ctx, doubleDiamond);
} else {
registers[rd] = Readline.readline(fh, ctx);
}
Expand Down
18 changes: 17 additions & 1 deletion src/main/java/org/perlonjava/backend/jvm/EmitOperator.java
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ static void handleReadlineOperator(EmitterVisitor emitterVisitor, BinaryOperator

if (operator.equals("readline")) {
emitterVisitor.pushCallContext();
if (Boolean.TRUE.equals(node.getAnnotation("implicitArgvReadline"))) {
emitterVisitor.ctx.mv.visitMethodInsn(Opcodes.INVOKESTATIC,
"org/perlonjava/runtime/runtimetypes/DiamondIO",
"readline",
"(Lorg/perlonjava/runtime/runtimetypes/RuntimeScalar;I)"
+ "Lorg/perlonjava/runtime/runtimetypes/RuntimeBase;",
false);
if (emitterVisitor.ctx.contextType == RuntimeContextType.VOID) {
handleVoidContext(emitterVisitor);
} else if (emitterVisitor.ctx.contextType == RuntimeContextType.SCALAR) {
handleScalarContext(emitterVisitor, node);
}
return;
}
}
emitOperator(node, emitterVisitor);
}
Expand Down Expand Up @@ -679,11 +693,13 @@ static void handleDiamondBuiltin(EmitterVisitor emitterVisitor, OperatorNode nod
// Handle null filehandle: <> <<>>
node.operand.accept(emitterVisitor.with(RuntimeContextType.SCALAR));
emitterVisitor.pushCallContext();
mv.visitInsn(Boolean.TRUE.equals(node.getAnnotation("doubleDiamond"))
? Opcodes.ICONST_1 : Opcodes.ICONST_0);
// Invoke the static method for reading lines.
mv.visitMethodInsn(Opcodes.INVOKESTATIC,
"org/perlonjava/runtime/runtimetypes/DiamondIO",
"readline",
"(Lorg/perlonjava/runtime/runtimetypes/RuntimeScalar;I)Lorg/perlonjava/runtime/runtimetypes/RuntimeBase;", false);
"(Lorg/perlonjava/runtime/runtimetypes/RuntimeScalar;IZ)Lorg/perlonjava/runtime/runtimetypes/RuntimeBase;", false);

// Handle context
if (emitterVisitor.ctx.contextType == RuntimeContextType.VOID) {
Expand Down
24 changes: 23 additions & 1 deletion src/main/java/org/perlonjava/frontend/parser/OperatorParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,15 @@ static BinaryOperatorNode parsePrint(Parser parser, LexerToken token, int curren

handle = operand.handle;
operand.handle = null;
// `$.' immediately followed by concatenation, as in
// `print $..$ARGV.$_`, is a print argument rather than a filehandle.
// The lexer represents the second dot as the following expression,
// which otherwise makes the filehandle probe consume `$.' and discard
// the leading output value.
if (isInputLineNumber(handle)) {
operand.elements.addFirst(handle);
handle = null;
}
if (handle == null) {
// `print` without arguments means `print to last selected filehandle`
handle = new OperatorNode("select", new ListNode(currentIndex), currentIndex);
Expand All @@ -339,6 +348,13 @@ static BinaryOperatorNode parsePrint(Parser parser, LexerToken token, int curren
return new BinaryOperatorNode(token.text, handle, operand, currentIndex);
}

private static boolean isInputLineNumber(Node node) {
return node instanceof OperatorNode sigil
&& "$".equals(sigil.operator)
&& sigil.operand instanceof IdentifierNode identifier
&& ".".equals(identifier.name);
}

/** True for {@code print(foo(...), ...)}, but not {@code print(FH (...))}. */
private static boolean isParenthesizedBarewordCall(Parser parser) {
if (!peek(parser).text.equals("(")) {
Expand Down Expand Up @@ -1145,6 +1161,7 @@ static BinaryOperatorNode parseReadline(Parser parser, LexerToken token, int cur
// Handle file-related operators with special handling for default handles
ListNode operand = ListParser.parseZeroOrMoreList(parser, 0, false, true, false, false);
Node handle;
boolean implicitArgvReadline = false;
if (operand.elements.isEmpty()) {
String defaultHandle = switch (operator) {
case "readline" -> "main::ARGV";
Expand All @@ -1158,6 +1175,7 @@ static BinaryOperatorNode parseReadline(Parser parser, LexerToken token, int cur
handle = new OperatorNode("undef", null, currentIndex);
} else {
handle = new IdentifierNode(defaultHandle, currentIndex);
implicitArgvReadline = operator.equals("readline");
}
} else {
handle = operand.elements.removeFirst();
Expand All @@ -1173,7 +1191,11 @@ static BinaryOperatorNode parseReadline(Parser parser, LexerToken token, int cur
}
}
}
return new BinaryOperatorNode(operator, handle, operand, currentIndex);
BinaryOperatorNode result = new BinaryOperatorNode(operator, handle, operand, currentIndex);
if (implicitArgvReadline) {
result.setAnnotation("implicitArgvReadline", true);
}
return result;
}

static BinaryOperatorNode parseSplit(Parser parser, LexerToken token, int currentIndex) {
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/org/perlonjava/frontend/parser/Parser.java
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,20 @@ public Node parse() {
} finally {
compilationState.unitcheckQueueStack.get().pop();
}
// ParseBlock stops before a closing brace so callers parsing a nested
// block can consume it. At file/eval scope there is no such caller:
// leaving it accepted makes `eval 'sub {} }'` silently compile.
LexerToken remaining = TokenUtils.peek(this);
if (remaining.type == LexerTokenType.OPERATOR && "}".equals(remaining.text)) {
ErrorMessageUtil.SourceLocation loc = ctx.errorUtil
.getSourceLocationAccurate(tokenIndex);
String message = "Unmatched right curly bracket at " + loc.fileName()
+ " line " + loc.lineNumber() + ", at end of line\n"
+ ctx.errorUtil.errorMessage(tokenIndex, "syntax error")
+ "Execution of " + loc.fileName()
+ " aborted due to compilation errors.\n";
throw new PerlCompilerException(message);
}
// Mark the AST as a top-level file block for proper bare block return value handling
// This annotation is checked in EmitBlock to handle RUNTIME context bare blocks
if (!isTopLevelScript && ast instanceof AbstractNode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1097,7 +1097,14 @@ public static Node parseRawString(Parser parser, String operator) {
}
ListNode diamondList = new ListNode(rawStr.index);
diamondList.elements.add(interpolated);
return new OperatorNode("<>", diamondList, rawStr.index);
OperatorNode diamond = new OperatorNode("<>", diamondList, rawStr.index);
// <> interpolates to an empty string, while <<>> preserves a
// literal "<>" marker. Keep that syntactic distinction after
// the operand later resolves to the ARGV glob.
if (interpolated instanceof StringNode stringNode && "<>".equals(stringNode.value)) {
diamond.setAnnotation("doubleDiamond", true);
}
return diamond;
}
}

Expand Down
13 changes: 12 additions & 1 deletion src/main/java/org/perlonjava/runtime/io/CustomFileChannel.java
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,18 @@ public RuntimeScalar close() {
*/
@Override
public RuntimeScalar eof() {
return new RuntimeScalar(isEOF);
if (isEOF) {
return new RuntimeScalar(true);
}
// Perl's eof() probes a regular file even before readline has tried
// to consume it. In particular, a freshly opened /dev/null is EOF;
// waiting for a read first makes argumentless eof() disagree with
// standard Perl after STDIN is reopened.
try {
return new RuntimeScalar(fileChannel.position() >= fileChannel.size());
} catch (IOException e) {
return new RuntimeScalar(false);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,10 @@ public static RuntimeScalar tell(RuntimeScalar fileHandle) {
boolean argless = !fileHandle.getDefinedBoolean();
RuntimeIO fh = fileHandle.getRuntimeIO();

if (argless && DiamondIO.hasActiveTraversal()) {
return DiamondIO.eof();
}

// If no explicit filehandle was provided (tell with no args),
// fall back to the last accessed handle like Perl does.
if (fh == null) {
Expand Down Expand Up @@ -1159,6 +1163,10 @@ public static RuntimeScalar eof(RuntimeScalar fileHandle) {
boolean argless = !fileHandle.getDefinedBoolean();
RuntimeIO fh = fileHandle.getRuntimeIO();

if (argless && DiamondIO.hasActiveTraversal()) {
return DiamondIO.eof();
}

// Handle undefined or invalid filehandle
if (fh == null) {
if (argless) {
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/org/perlonjava/runtime/operators/WarnDie.java
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,9 @@ public static String getFilehandleContext() {
* @return String with the bare handle name (e.g., "DATA", "STDIN"), or null if not found
*/
private static String findFilehandleName(RuntimeIO handle) {
if (DiamondIO.isDiamondReader(handle)) {
return ""; // caller adds angle brackets: <> line N
}
if (handle.globName != null && !handle.globName.isEmpty()) {
// Strip package prefix (e.g., "main::DATA" -> "DATA")
String name = handle.globName;
Expand Down
Loading
Loading