Skip to content

Commit 11a068d

Browse files
fix: backslash prototype precedence for tied/tie/untie with glob arguments
PerlOnJava parsed `tied *STDOUT && expr` as `tied(*STDOUT && expr)` instead of `(tied *STDOUT) && expr`. This caused Capture::Tiny to skip its `local(*STDOUT)` call when STDOUT was tied by Test2::Plugin::IOEvents, corrupting the selectedHandle and breaking print output capture. The fix adds parseBackslashArgWithComma() which parses backslash prototype arguments at named-unary precedence (level 15, same as isa) instead of comma precedence (level 5). This matches Perl 5 behavior where comparison/logical operators are NOT consumed but arithmetic operators ARE consumed. App::perlbrew: 65/73 -> 66/73 tests pass. Generated with Devin (https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 9c45a21 commit 11a068d

3 files changed

Lines changed: 91 additions & 7 deletions

File tree

dev/modules/app_perlbrew.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# App::perlbrew CPAN Installation Plan
22

3-
## Status: Phase 7.1 complete — 65/73 tests pass (2026-04-07)
3+
## Status: Phase 7.4 complete — 66/73 tests pass (2026-04-07)
44

55
## Goal
66

@@ -621,3 +621,27 @@ than `stat(STDOUT)`, or a redesign of how `selectedHandle` interacts with tied h
621621

622622
Remaining tests: `command-info.t`, `12.destdir.t`, `12.sitecustomize.t`, `installation2.t`,
623623
`installation-perlbrew.t`
624+
625+
- [x] Phase 7.4: Fix backslash prototype precedence for `tied *GLOB && expr` (2026-04-07)
626+
- **Root cause**: PerlOnJava parsed `tied *STDOUT && $] >= 5.008` as `tied(*STDOUT && $] >= 5.008)`
627+
instead of `(tied *STDOUT) && ($] >= 5.008)`. This caused Capture::Tiny to skip
628+
`local(*STDOUT)` when STDOUT was tied (by IOEvents), corrupting `selectedHandle`.
629+
- **Fix**: `PrototypeArgs.java` — Added `parseBackslashArgWithComma()` that parses backslash
630+
prototype arguments at named-unary precedence (level 15, same as `isa`) instead of comma
631+
precedence (level 5). This matches Perl 5's parsing behavior where `\[$@%*]` prototypes
632+
consume the variable term but not comparison/logical operators.
633+
- **Effect**: Capture::Tiny's `local(*STDOUT)` now fires correctly when STDOUT is tied,
634+
`selectedHandle` is properly saved/restored through `local(*STDOUT)` scopes
635+
- Also cleaned up `RuntimeGlob.java` debug logging
636+
- **66/73 pass** (up from 65/73)
637+
638+
### Remaining 7 failures (Phase 7.4)
639+
| Test | Root Cause |
640+
|------|-----------|
641+
| `t/command-info.t` | `Compiled at:` field empty — PerlOnJava doesn't provide compile date |
642+
| `t/installation2.t` | Test2::Mock + Capture::Tiny crash |
643+
| `t/command-env.t` | Missing `local::lib` dependency |
644+
| `t/command-exec.t` | Missing `local::lib` dependency |
645+
| `t/command-make-shim.t` | Missing `local::lib` dependency |
646+
| `t/command-help.t` | Subprocess can't find dependencies (needs PERL5LIB) |
647+
| `t/09.exit_status.t` | Missing `Path::Class` dependency |

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

Lines changed: 1 addition & 1 deletion
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 = "f65300dfd";
36+
public static final String gitCommitId = "98a17e401";
3737

3838
/**
3939
* Git commit date of the build (ISO format: YYYY-MM-DD).

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

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ static ListNode consumeArgsWithPrototype(Parser parser, String prototype, boolea
209209
// element.setAnnotation("context", "LIST");
210210
// }
211211
} else {
212-
parsePrototypeArguments(parser, args, prototype);
212+
parsePrototypeArguments(parser, args, prototype, hasParentheses);
213213

214214
// Check for too many arguments without parentheses only if prototype expects 2+ args
215215
if (!hasParentheses && countPrototypeArgs(prototype) >= 2) {
@@ -328,8 +328,9 @@ private static boolean handleOpeningParenthesis(Parser parser) {
328328
* @param parser The parser instance
329329
* @param args The argument list to populate
330330
* @param prototype The prototype string to parse
331+
* @param hasParentheses Whether the function was called with explicit parentheses
331332
*/
332-
private static void parsePrototypeArguments(Parser parser, ListNode args, String prototype) {
333+
private static void parsePrototypeArguments(Parser parser, ListNode args, String prototype, boolean hasParentheses) {
333334
boolean isOptional = false;
334335
boolean needComma = false;
335336
int skipCount = 0; // Number of prototype characters to skip (for flattened my/our/state)
@@ -382,7 +383,7 @@ private static void parsePrototypeArguments(Parser parser, ListNode args, String
382383
needComma = true;
383384
}
384385
case '\\' -> {
385-
i = handleBackslashArgument(parser, args, prototype, i + 1, isOptional, needComma);
386+
i = handleBackslashArgument(parser, args, prototype, i + 1, isOptional, needComma, hasParentheses);
386387
needComma = true;
387388
}
388389
case ',' -> {
@@ -747,7 +748,7 @@ private static Node unwrapUnaryPlus(Node arg, char refType) {
747748
}
748749

749750
private static int handleBackslashArgument(Parser parser, ListNode args, String prototype, int prototypeIndex,
750-
boolean isOptional, boolean needComma) {
751+
boolean isOptional, boolean needComma, boolean hasParentheses) {
751752
if (prototypeIndex >= prototype.length()) {
752753
parser.throwError("syntax error, incomplete backslash reference in prototype");
753754
}
@@ -762,7 +763,22 @@ private static int handleBackslashArgument(Parser parser, ListNode args, String
762763
parser.parsingTakeReference = true;
763764
}
764765

765-
Node referenceArg = parseArgumentWithComma(parser, isOptional, needComma, expectedType);
766+
// Parse the backslash-prototype argument.
767+
// With parentheses: always parse at comma precedence (level 5).
768+
// Without parentheses:
769+
// - Single-arg prototypes (e.g. \[$@%*], \$): parse at named-unary precedence
770+
// so operators like && and == are NOT consumed. Example:
771+
// tied *STDOUT && $cond → (tied *STDOUT) && $cond
772+
// - Multi-arg prototypes (e.g. \$$, \$;$): parse at comma precedence
773+
// so assignment and other operators ARE consumed. Example:
774+
// sreftest my $a = 'val', $i++ → sreftest(\(my $a = 'val'), $i++)
775+
Node referenceArg;
776+
boolean useNamedUnary = !hasParentheses && countPrototypeArgs(prototype) <= 1;
777+
if (useNamedUnary) {
778+
referenceArg = parseBackslashArgWithComma(parser, isOptional, needComma, expectedType);
779+
} else {
780+
referenceArg = parseArgumentWithComma(parser, isOptional, needComma, expectedType);
781+
}
766782

767783
// Restore flag
768784
parser.parsingTakeReference = oldParsingTakeReference;
@@ -867,6 +883,50 @@ private static Node parseRequiredArgument(Parser parser, boolean isOptional) {
867883
return expr;
868884
}
869885

886+
/**
887+
* Parses a backslash-prototype argument at named-unary precedence.
888+
*
889+
* <p>Backslash prototypes like {@code \[$@%*]} expect a single variable term.
890+
* In Perl, these parse at named-unary precedence (between "isa" and shift operators),
891+
* so operators like {@code &&}, {@code ||}, {@code ==}, {@code <} are NOT consumed,
892+
* but arithmetic operators like {@code +}, {@code *}, {@code >>} ARE consumed.</p>
893+
*
894+
* @param parser The parser instance
895+
* @param isOptional Whether the argument is optional
896+
* @param needComma Whether a comma is required before the argument
897+
* @param expectedType Description of the expected argument type for error messages
898+
* @return The parsed argument node, or null if parsing failed and the argument was optional
899+
*/
900+
private static Node parseBackslashArgWithComma(Parser parser, boolean isOptional, boolean needComma, String expectedType) {
901+
if (isArgumentTerminator(parser)) {
902+
if (!isOptional) {
903+
throwNotEnoughArgumentsError(parser);
904+
}
905+
return null;
906+
}
907+
908+
if (needComma && !consumeCommaIfPresent(parser, isOptional)) {
909+
return null;
910+
}
911+
912+
if (isArgumentTerminator(parser)) {
913+
if (isOptional) {
914+
return null;
915+
}
916+
throwNotEnoughArgumentsError(parser);
917+
}
918+
919+
// Parse at named-unary precedence (level 15, same as "isa")
920+
// This ensures that comparison and logical operators are NOT consumed as part of the argument
921+
Node expr = parser.parseExpression(parser.getPrecedence("isa"));
922+
if (expr == null) {
923+
if (!isOptional) {
924+
throwNotEnoughArgumentsError(parser);
925+
}
926+
}
927+
return expr;
928+
}
929+
870930
/**
871931
* Checks if there are consecutive commas (like ", ,") which should be a syntax error.
872932
*

0 commit comments

Comments
 (0)