diff --git a/docs/about/changelog.md b/docs/about/changelog.md index cec5e2dd13..1d2b72042b 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -26,6 +26,8 @@ priorities and future plans. - Preserve the input line number when a borrowed anonymous filehandle alias leaves a JVM subroutine scope (#1933). +- Preserve filehandle line-counter assignments made through localized `$.` scopes (#1965). + - Resolve glob-backed filehandles in `POSIX::isatty` without assuming the glob value is a `RuntimeIO` (#1932). - Add an initial CryptX Java XS bridge for binary/base64url helpers, functional digests, and HMAC using the existing JCA and Bouncy Castle providers (#1220). diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java index 5ae387f9f2..a3e7200549 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java @@ -81,6 +81,10 @@ static boolean isImmutableProxy(RuntimeBase val) { // interpreter's mutating opcodes must NOT silently strip it -- // we want `for (3) { ++$_ }` to throw, not silently succeed. if (val instanceof ReadOnlyAlias) return false; + if (val instanceof ScalarSpecialVariable specialVariable + && specialVariable.hasSpecialAssignmentBehavior()) { + return false; + } return val instanceof RuntimeScalarReadOnly || val instanceof ScalarSpecialVariable; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java index 4d3bbf17d1..00c4638bdb 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java @@ -64,6 +64,14 @@ public boolean isNumberedCapture() { return variableId == Id.CAPTURE; } + /** Whether assignments must reach this special scalar's setter. */ + public boolean hasSpecialAssignmentBehavior() { + return switch (variableId) { + case INPUT_LINE_NUMBER, HINTS, WARNING_BITS, REMOVED_ENCODING -> true; + default -> false; + }; + } + /** * Throws an exception as this variable represents a constant item * and cannot be modified. @@ -530,9 +538,10 @@ public void dynamicRestoreState() { if (!inputLineStateStack.isEmpty()) { InputLineState previous = inputLineStateStack.pop(); RuntimeIO.setLastAccessedHandle(previous.lastHandle); - if (previous.lastHandle != null) { - previous.lastHandle.currentLineNumber = previous.lastLineNumber; - } + // $. localization restores which handle is current, but the + // line counter belongs to that handle. Assignments to $. (and + // reads from the handle) change its counter permanently, even + // when they happen inside a dynamically localized scope. lvalue = previous.localValue; // Do not sync this.type/value from lvalue here; $. reads always // delegate to currentLineNumber via getValueAsScalar(). diff --git a/src/test/resources/unit/localized_input_line_number.t b/src/test/resources/unit/localized_input_line_number.t new file mode 100644 index 0000000000..ef7c5c70ba --- /dev/null +++ b/src/test/resources/unit/localized_input_line_number.t @@ -0,0 +1,32 @@ +use strict; +use warnings; +use Test::More; + +my $contents = "one\ntwo\nthree\n"; +open my $fh, '<', \$contents or die "open scalar handle: $!"; + +<$fh>; +<$fh>; +is($., 2, 'reading updates the current handle line number'); + +seek($fh, 0, 0) or die "seek scalar handle: $!"; +if (1) { + local $. = 0; +} +is($., 0, 'localized assignment to $. remains on the filehandle after scope exit'); +is(scalar <$fh>, "one\n", 'the handle was rewound'); +is($., 1, 'reading after the rewind starts at line one'); + +my $other_contents = "other one\nother two\n"; +open my $other, '<', \$other_contents or die "open second scalar handle: $!"; +<$fh>; +<$fh>; +is($., 3, 'the original handle has advanced to line three'); +{ + local $.; + <$other>; + is($., 1, 'reading another handle updates $. inside the localized scope'); +} +is($., 3, 'scope exit restores the previously current handle'); + +done_testing;