This document describes the design for supporting Perl source filters in PerlOnJava, specifically enabling modules like Filter::Simple and Filter::Util::Call to work when filters are installed via use Module qw(:tag) statements.
PerlOnJava tokenizes the entire source file upfront before any code executes. This creates a fundamental incompatibility with Perl source filters:
use Log::Log4perl qw(:resurrect); # Installs filter in import()
###l4p DEBUG "hidden logging"; # Should be transformed to: DEBUG "hidden logging";
print "hello\n";What happens in PerlOnJava:
- Lexer tokenizes all source (including
###l4pcomments - unchanged) - Parser builds AST
use Log::Log4perlexecutes, callsimport(':resurrect')import()installs the filter viaFilter::Util::Call::filter_add()- Problem: Source is already tokenized - filter has nothing to filter!
What happens in standard Perl:
- Perl reads source incrementally (line by line or block by block)
use Log::Log4perlis parsed and executed immediatelyimport()installs the filter- Perl continues reading source through the filter
- The filter transforms
###l4p DEBUG "first";→DEBUG "first"; - Transformed source is then tokenized and compiled
Modules that rely on source filters:
- Log::Log4perl (
:resurrecttag) - Test: t/049Unhide.t - Filter::Simple - Simplified filter interface
- Switch (deprecated but common)
- Lingua::Romana::Perligata - Perl in Latin
- Acme::* modules
PerlOnJava has a partial implementation in FilterUtilCall.java:
public class FilterUtilCall extends PerlModuleBase {
// Thread-local filter stack
private static final ThreadLocal<FilterContext> filterContext;
// XS function implementations
public static RuntimeList real_import(...) // Called by filter_add()
public static RuntimeList filter_read(...) // Read next chunk
public static RuntimeList filter_del(...) // Remove filter
// Apply installed filters to source
public static String applyFilters(String sourceCode) {...}
// Workaround: preprocess BEGIN blocks containing filter_add
public static String preprocessWithBeginFilters(String sourceCode) {...}
}The current workaround handles explicit BEGIN { filter_add(...) } blocks:
- Scans source for
BEGIN { ... filter ... } - Extracts and executes the BEGIN block
- Applies any installed filters to remaining source
- Returns filtered source for parsing
Limitation: This only works for explicit BEGIN blocks, NOT for filters installed via use Module qw(:tag) because:
- The
usestatement is not recognized as a filter installer - The
import()method that installs the filter is called after tokenization
# Install a filter (usually in import())
filter_add($coderef_or_object);
# Inside the filter: read next chunk into $_
$status = filter_read(); # Line mode
$status = filter_read($size); # Block mode
# Remove current filter
filter_del();
# Return values:
# > 0 : More data available
# = 0 : EOF
# < 0 : Error-
Closure Filter: Anonymous sub passed to
filter_add()filter_add(sub { my $status = filter_read(); s/old/new/g if $status > 0; return $status; });
-
Method Filter: Blessed object with
filter()methodfilter_add(bless {}, $class); # Calls $obj->filter() repeatedly
Higher-level interface that collects all source then transforms:
use Filter::Simple;
FILTER { s/BANG/die/g }; # Transform all source
FILTER_ONLY
code => sub { ... }, # Transform code only
string => sub { ... }; # Transform strings onlyThe Lexer produces an array of LexerToken objects, each with a text field containing the original characters. These tokens can be rejoined back into source text by concatenating their text values, then re-tokenized after filtering.
This is much simpler than incremental parsing because:
- The parser already has access to
parser.tokens(the token array) - Tokens can be rejoined from any position:
tokens[i].text + tokens[i+1].text + ... - After filtering, we just re-tokenize and replace the remaining tokens
┌─────────────────────────────────────────────────────────────────┐
│ Parser processes use statement │
│ (tokenIndex points to position after use) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ import() installs a filter │
│ FilterState.markFilterInstalled() │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Check: FilterState.wasFilterInstalled()? │
└─────────────────────────────────────────────────────────────────┘
│ Yes
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. Rejoin remaining tokens: tokens[i..end].map(t => t.text) │
│ 2. Apply filters: FilterUtilCall.applyFilters(rejoined) │
│ 3. Re-tokenize: Lexer.tokenize(filtered) │
│ 4. Replace: parser.tokens = tokens[0..i-1] + newTokens │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Continue parsing with new tokens │
└─────────────────────────────────────────────────────────────────┘
// In FilterUtilCall.java or new FilterState.java
public class FilterState {
// Track if filters were installed during current use statement
private static final ThreadLocal<Boolean> filterInstalledDuringUse =
ThreadLocal.withInitial(() -> false);
public static void markFilterInstalled() {
filterInstalledDuringUse.set(true);
}
public static boolean wasFilterInstalled() {
boolean result = filterInstalledDuringUse.get();
filterInstalledDuringUse.set(false); // Reset
return result;
}
}In FilterUtilCall.real_import():
public static RuntimeList real_import(RuntimeArray args, int ctx) {
// ... existing code to add filter to stack ...
// Mark that a filter was installed
FilterState.markFilterInstalled();
return scalarTrue.getList();
}// In Parser.java or new utility class
public static String rejoinTokens(List<LexerToken> tokens, int fromIndex) {
StringBuilder sb = new StringBuilder();
for (int i = fromIndex; i < tokens.size(); i++) {
sb.append(tokens.get(i).text);
}
return sb.toString();
}In StatementParser.parseUseDeclaration(), after calling import():
// After import() completes (around line 708)
if (FilterState.wasFilterInstalled()) {
// A filter was installed - rejoin remaining tokens, filter, and re-tokenize
int currentPos = parser.tokenIndex;
// Step 1: Rejoin remaining tokens back to source text
String remainingSource = rejoinTokens(parser.tokens, currentPos);
// Step 2: Apply the installed filters
String filteredSource = FilterUtilCall.applyFilters(remainingSource);
// Step 3: Re-tokenize the filtered source
Lexer lexer = new Lexer(filteredSource);
List<LexerToken> newTokens = lexer.tokenize();
// Step 4: Replace remaining tokens with filtered tokens
// Keep tokens[0..currentPos-1], append newTokens
List<LexerToken> updatedTokens = new ArrayList<>(
parser.tokens.subList(0, currentPos));
updatedTokens.addAll(newTokens);
parser.tokens = updatedTokens;
// Clear the filter after applying (it's been consumed)
FilterUtilCall.clearFilters();
}function parseUseDeclaration(parser):
1. Parse and execute `use Module qw(args)`
- This calls require() then import()
- import() may call filter_add()
2. After import() returns, check FilterState.wasFilterInstalled()
3. If a filter was installed:
a. currentPos = parser.tokenIndex // Position after use statement
b. remainingSource = rejoinTokens(parser.tokens, currentPos)
c. filteredSource = FilterUtilCall.applyFilters(remainingSource)
d. newTokens = Lexer.tokenize(filteredSource)
e. parser.tokens = parser.tokens[0..currentPos-1] + newTokens
f. FilterUtilCall.clearFilters() // Filter consumed
4. Continue parsing (with potentially filtered tokens)
Given this source:
use Log::Log4perl qw(:resurrect);
###l4p DEBUG "hello";
print "world\n";Before filtering:
tokens = [
"use", " ", "Log::Log4perl", " ", "qw(:resurrect)", ";", "\n",
"###l4p DEBUG \"hello\";", "\n",
"print", " ", "\"world\\n\"", ";", "\n"
]
After use statement executes:
import(':resurrect')installs a filter that removes###l4pprefixesFilterState.wasFilterInstalled()returns trueparser.tokenIndexis at position 7 (after the semicolon and newline)
Rejoin remaining tokens:
remainingSource = "###l4p DEBUG \"hello\";\nprint \"world\\n\";\n"
Apply filter:
filteredSource = "DEBUG \"hello\";\nprint \"world\\n\";\n"
Re-tokenize and replace:
newTokens = ["DEBUG", " ", "\"hello\"", ";", "\n", "print", " ", "\"world\\n\"", ";", "\n"]
parser.tokens = tokens[0..6] + newTokens
Continue parsing with the filtered tokens.
Add state tracking to know when a filter was installed:
- Add
filterInstalledDuringUseflag toFilterUtilCall.java - Set flag in
real_import()when filter is added - Add
wasFilterInstalled()/clearFilterInstalledFlag()methods
Files to modify:
FilterUtilCall.java- Add state tracking
Estimated effort: ~30 minutes
Implement the token rejoin, filter, and re-tokenize logic:
- Add
rejoinTokens(List<LexerToken> tokens, int fromIndex)utility - After
import()inparseUseDeclaration(), check if filter was installed - If yes: rejoin → filter → re-tokenize → replace tokens
- Clear filter after applying
Files to modify:
StatementParser.java- Add post-import filter check (~20 lines)FilterUtilCall.java- May need to exposeapplyFilters()better
Estimated effort: ~2-3 hours
Currently applyFilters() only supports closure filters. Add method filter support:
if (!isCodeRef.getBoolean()) {
// Method filter: call $obj->filter()
RuntimeScalar filterMethod = Universal.can(filterObj, "filter");
// Call repeatedly until returns <= 0
}Files to modify:
FilterUtilCall.java- Add method filter support inapplyFilters()
Estimated effort: ~1-2 hours
Handle edge cases:
- Multiple
usestatements that install filters - Nested module loading where inner module installs filter
no Moduleremoving filters- Error reporting with correct line numbers after re-tokenization
Files to modify:
- Various - depends on edge cases discovered
Estimated effort: ~2-4 hours
Before parsing, scan source for known filter-installing modules:
- Build a list of modules known to install filters (Log::Log4perl with :resurrect, etc.)
- If source contains
use KnownFilterModule, use two-pass compilation - First pass: just execute use statements
- Second pass: parse filtered source
Pros: No runtime overhead for code without filters Cons: Requires maintaining a list of known filter modules
Modify Lexer to read source incrementally through filter chain:
- Lexer calls
filter_read()for each chunk - Filters transform source during reading
- Exactly matches Perl's behavior
Pros: Correct semantics, handles all edge cases Cons: Major Lexer rewrite, affects all code paths
- Filters add overhead to compilation
- Re-tokenization for filtered source adds latency
- Consider caching filtered source for repeated use
- Nested use statements: Module A uses Module B which installs a filter
- BEGIN blocks in filtered code: Must be executed correctly
- Filter affecting use statement itself: Pathological but possible
- Multiple filters: Must apply in correct order (LIFO stack)
- Must maintain backward compatibility for code without filters
- Filter installation via
@INChooks (less common) evalanddowith filters
# Test filter installation via use
use FilterModule qw(:filter_tag);
# ... code that should be transformed ...
# Test Filter::Simple
use SimpleFilter;
FILTER { s/X/Y/g };
print "X"; # Should print Y- Log::Log4perl
:resurrecttag (t/049Unhide.t) - Custom filter modules
- perldoc perlfilter - Source filters overview
- perldoc Filter::Util::Call - Low-level API
- perldoc Filter::Simple - High-level API
- Research Perl source filter semantics
- Analyze current PerlOnJava implementation
- Identify root cause of t/049Unhide.t failure
- Design token rejoin/re-tokenize solution
- Phase 1: Add filter state tracking to FilterUtilCall.java (2026-03-27)
- Added
filterInstalledDuringUseThreadLocal flag - Added
markFilterInstalled(),wasFilterInstalled(),hasActiveFilters()methods - Modified
real_import()to mark when filter is installed
- Added
- Phase 2: Implement token rejoin and re-tokenize in StatementParser.java (2026-03-27)
- Added
applySourceFilterToRemainingTokens()method - Integrated with
parseUseDeclaration()to check for filters after import() - Added
updateTokens()method to ErrorMessageUtil - Fixed EOF token handling (skip EOF tokens when rejoining to avoid garbage characters)
- Added
- Phase 3: Test with Log::Log4perl
:resurrecttag - PASSED
- Phase 4: Add method filter support (currently returns original source for method filters)
- Add debug environment variable documentation (JPERL_FILTER_DEBUG=1)
- Phase 5: Fix FILTER_ONLY @transforms issue in Java instead of patching Filter::Simple (see below)
Problem: When multiple filter modules using FILTER_ONLY are loaded in sequence, the second filter's $multitransform closure incorrectly includes transforms from the first module.
Root Cause: In Filter::Simple, @transforms is a package variable. In native Perl, this works because filters process source incrementally - each filter completes before the next filter module is loaded. In PerlOnJava, we tokenize upfront then apply filters, so multiple filter modules may be loaded before any filter runs, causing @transforms to accumulate transforms from different modules.
Current Fix: Patched Filter::Simple.pm to make @transforms lexical in FILTER_ONLY:
sub FILTER_ONLY {
my $caller = caller;
my @transforms; # Made lexical instead of package-scoped
...
}TODO - Proper Java-side Fix: The ideal solution would be to fix this in PerlOnJava's module loading code:
- Before loading a module that may use
FILTER_ONLY, save@Filter::Simple::transforms - Clear
@Filter::Simple::transforms - After module loading completes, restore the saved value
This would allow using unmodified upstream Filter::Simple. The challenge is detecting which modules will use FILTER_ONLY before loading them. Possible approaches:
- Clear
@Filter::Simple::transformsbefore everyrequire(may have side effects) - Track filter module loading depth and isolate transforms per level
- Hook into Filter::Simple's FILTER_ONLY to auto-reset before each call
Files affected by current fix:
src/main/perl/lib/Filter/Simple.pm(marked asprotected: truein config.yaml)
src/main/java/org/perlonjava/runtime/perlmodule/FilterUtilCall.javasrc/main/java/org/perlonjava/frontend/parser/StatementParser.javasrc/main/java/org/perlonjava/runtime/runtimetypes/ErrorMessageUtil.java
- Line number tracking after re-tokenization: We update ErrorMessageUtil with new tokens
- How to handle EOF tokens: Skip them when rejoining (they contain invalid characters)
FilterUtilCall keeps two pieces of state in ThreadLocals:
| field | purpose |
|---|---|
filterContext.filterStack |
stack of currently-installed source filters |
filterInstalledDuringUse |
one-shot flag set by real_import(), consumed by wasFilterInstalled() so the parser knows to re-tokenize after a use |
Both were process-global per thread — not scoped to the file
currently being compiled. A filter installed by an outer
use Foo (whose import() runs while the parent file is still
being parsed) leaked into whatever module Foo::import() happened
to require next.
The most visible victim was Spiffy (and therefore everything that
builds on Spiffy: Test::Base, the bulk of the YAML test suite,
Switch, Filter::Simple users, …):
package Test::Base;
use Spiffy -Base; # installs filter via filter_add
field _filters => [qw(norm trim)]; # ← Spiffy's filter is what makes
# `field _filters => [...]` parseWhat happened:
Spiffy::importcalledFilter::Util::Call::filter_add→real_import()pushed the filter onto the stack and setfilterInstalledDuringUse = true.Spiffy::importthen calledExporter::export(...)whichrequiredExporter::Heavy.pm.- The nested compilation of
Exporter::Heavy.pmencountered its ownusestatements; each one ranwasFilterInstalled()which returnedtrue(Spiffy's flag, set just earlier) and triggeredapplySourceFilterToRemainingTokens()againstExporter::Heavy.pm's source. - Spiffy's filter — which injects
my $self = shift;after everysub …{— rewroteExporter::Heavy.pm(visible as the warning"my" variable $self masks earlier declaration … at Exporter/Heavy.pm line 237).clearFilters()then emptied the stack. - Control returned to parsing
Test::Base.pmat the next token afteruse Spiffy -Base;. The flag was nowfalse, the stack was empty, andfield _filters => [qw(norm trim)]was parsed without Spiffy's filter applied →syntax error … near "=> [qw"atTest/Base.pmline 53.
Source filters are scoped per compilation unit
(PL_compiling / PL_rsfp_filters): each require,
do FILE, or string-eval starts with its own initially-empty
filter chain, and the outer chain is restored when the nested
compilation finishes. Spiffy itself relies on this — line 82 of
Spiffy.pm reads:
spiffy_filter()
if ($args->{-selfless} or $args->{-Base}) and
not $filtered_files->{(caller($stack_frame))[1]}++;i.e. "have I already filtered this caller's file?". The filter is intended to be scoped to that file.
Snapshot/reset/restore the filter state at the
ModuleOperators.do_file boundary — i.e. exactly when a require or
do FILE switches to compiling a different source file.
// FilterUtilCall.java
public static class FilterStateSnapshot {
final RuntimeList filterStack;
final boolean installedDuringUse;
...
}
public static FilterStateSnapshot saveAndResetFilterState() {
FilterContext context = filterContext.get();
FilterStateSnapshot snapshot =
new FilterStateSnapshot(context.filterStack,
filterInstalledDuringUse.get());
context.filterStack = new RuntimeList();
filterInstalledDuringUse.set(false);
...
return snapshot;
}
public static void restoreFilterState(FilterStateSnapshot snapshot) {
if (snapshot == null) return;
FilterContext context = filterContext.get();
context.filterStack = snapshot.filterStack;
filterInstalledDuringUse.set(snapshot.installedDuringUse);
...
}
// ModuleOperators.do_file
FilterUtilCall.FilterStateSnapshot filterSnapshot =
FilterUtilCall.saveAndResetFilterState();
try {
// existing require/do compilation body ...
} finally {
FilterUtilCall.restoreFilterState(filterSnapshot);
}This single change unblocked 27 of 35 previously-blocked tests
in the bundled YAML-1.31 distribution, plus everything else that
uses Spiffy / Test::Base / Filter::Simple underneath.
executePerlCode is the broader funnel — it covers require/do
and string-eval and the synthetic compile inside
preprocessWithBeginFilters. Wiring there would seem more
"thorough", but it has a subtle problem:
preprocessWithBeginFilters deliberately runs a
BEGIN { filter_add(...) } prefix through executePerlCode so
that the filter installed inside the BEGIN survives back to the
caller and can be applied to the parent file's remaining source.
A save/reset/restore wrapper around executePerlCode would undo
that install before applyFilters() could use it — the recursive
test perl5_t/t/op/incfilter.t then regresses from 143/153 to
14/153 (file-handle / coderef source filters from @INC break).
Working around that with a one-shot "skip save/restore" flag
threaded through preprocessWithBeginFilters works, but it's
ad-hoc.
do_file's placement is cleaner: it sits outside
executePerlCode, so preprocessWithBeginFilters' recursive
executePerlCode call (which doesn't go through do_file) is
naturally unaffected. Per-compilation-unit scoping for the
require/do path is exactly what we need to fix the Spiffy bug, and
nothing more.
eval STRING is not wrapped — but the filter chain installed
by an outer use Foo is applied to the parent file's remaining
source tokens before any eval STRING runs at runtime, so an
unprotected eval cannot leak into or out of an enclosing parse
in any way that causes the Spiffy class of bug.
src/test/resources/unit/source_filter_scope.t reproduces the
exact bug pattern (without depending on any external CPAN module):
- defines an inline
InlineFilterpackage whoseimport()callsfilter_addand thenrequiresCwd(mimicking whatSpiffy::importdoes —filter_addfollowed byExporter::export -> require Exporter::Heavy), - asserts that the filter is correctly applied to the parent file's remaining tokens (test 2),
- asserts that
Cwditself was unaffected by the filter (test 3 —Cwd::cwd()works), - asserts the filter doesn't leak past the eval STRING (test 4).
Confirmed catches the bug: with saveAndResetFilterState /
restoreFilterState neutralised to no-ops, test 2 fails with
got: 'REPLACEME', expected: 'ok_marker' (the filter was consumed
by Cwd's parsing instead of reaching the parent's source).
Two related issues surfaced while bringing
perl5_t/t/op/incfilter.t past the Spiffy regression. Both lived
in ModuleOperators.do_file's source-generator paths.
The do CODEREF generator loop did:
GlobalVariable.getGlobalVariable("main::_").set(""); // clear $_
RuntimeBase result = codeRef.apply(stateArgs, ...); // call generator
String chunk = GlobalVariable.getGlobalVariable("main::_").toString();When the user's generator tied $_ to an object with only
TIESCALAR and FETCH (no STORE) — exactly the pattern in
incfilter.t lines 261-268 — the next iteration's .set("")
invoked the missing STORE and died with
Can't locate object method "STORE" via package "main".
Real Perl handles this with local $_ in pp_require: each
iteration gets its own fresh, untied $_; the caller's tied $_
is restored at end without ever being written.
// Each iteration: install a fresh untied scalar.
GlobalVariable.aliasGlobalVariable("main::_", new RuntimeScalar(""));
...
// At end (in finally): restore the caller's slot WITHOUT calling .set()
GlobalVariable.aliasGlobalVariable("main::_", savedDefaultVar);aliasGlobalVariable swaps the slot's RuntimeScalar reference,
matching local $_'s semantics exactly. No STORE is ever
invoked on the user's scalar.
actualFileName was only set in the do \$scalarref branch. The
filehandle and code-ref branches left parsedArgs.fileName = null,
so __FILE__ produced a StringNode with null value and
crashed downstream with
Cannot invoke "String.length()" because "node.value" is null.
Set actualFileName = fileName (the stringified GLOB(0x…) /
CODE(0x…)) in both branches. Matches the regex assertion in
incfilter.t:
like(__FILE__, qr/(?:GLOB|CODE)\(0x[0-9a-f]+\)/, "__FILE__ is valid");| state | result |
|---|---|
| master (before any of this work) | 143/153 |
| with Phase 6 fix only (Spiffy unblocked) | 143/153 (regressed to 14/153, recovered with skipSaveRestore carve-out) |
| with Phase 6 + Phase 7 fixes | 148/153 |
Five cmp_ok calls expected by the script's hard-coded
plan(tests => 153) never fire on PerlOnJava. All 148 that do
fire pass; there are zero not ok lines.
Most of the test count comes from cmp_ok calls inside two filter
generators that run once per byte read:
# from prepend_block_counting_filter (lines 148-165)
while (--$count) {
$_ = '';
my $status = filter_read($amount); # read 1 byte
cmp_ok (length $_, '<=', $amount, "block mode works?"); # ← per byte
$output .= $_;
if ($status <= 0 or /\n/s) { ...; return $status; }
}So the total ok count =
(bytes through filter 1) + (bytes through filter 2) +
(line count in filter 3) + fixed assertions. Real Perl's byte
stream through these filters is 5 bytes longer than ours →
5 fewer cmp_ok invocations.
Counting label-by-label:
| Label | PerlOnJava | Real Perl 5.42 |
|---|---|---|
block mode works? (43 + 51) |
94 | ~99 |
1 line at most? |
8 | 8 |
You should see this line thrice |
3 | 3 |
Upstream didn't alter existing data |
4 | 4 |
Fixed pass / is / like / etc. |
39 | 39 |
| Total | 148 | 153 |
Two structural mismatches account for the missing 5 bytes in the
second prepend_block_counting_filter invocation (the
s/s/ss/g; s/([\nS])/$1$1$1/g; return; array-form filter chain):
-
Where
preprocessWithBeginFilterssplits the source. PerlOnJava cuts at the closing}ofBEGIN { … }via a literal brace-match. Real Perl's tokenizer position when the BEGIN runs is just past the;terminating the BEGIN statement — Perl's filter sees those few extra characters that PerlOnJava had already consumed before reaching the filter machinery. -
EOF read on the trailing newline. PerlOnJava's block-mode
filter_read(1)returns the trailing newline and then immediately0on the next call; real Perl produces one more 0-length read at end-of-source before returning EOF. That's a +1cmp_okper filter invocation × 2 invocations = +2.
The 1 from #1 plus 2 from #2 plus minor \r\n vs \n framing
in the second invocation accounts for the missing 5.
-
Zero
not ok: everycmp_okthat ran passed. Nothing is incorrect — only the count of bytes reported differs. -
The plan number 153 is a hard-coded count tied to one Perl implementation's filter byte-stream framing. Anything that intercepts
filter_readdifferently (PerlOnJava, miniperl, alternative implementations) will produce a different count. -
Aligning to exactly 153 requires either
- reworking
preprocessWithBeginFiltersto find the tokenizer position the way Perl's lexer does instead of brace-matching (invasive — would re-tokenize the BEGIN prefix to know where the;is), or - emitting a synthetic 0-byte
filter_readcycle at EOF so the user filter can run one finalcmp_okbefore status=0 (changesapplyFilterssemantics for every filter).
Both are big changes for cosmetic test-count parity. Current state — 148/153, 0 failures — is the right place to leave it.
- reworking
If we ever invest in this area further, the meaningful work is:
- Make
filter_readtruly streaming, not a "split on\nthen replay" emulation. Current implementation (FilterUtilCall.filter_read) splits the upstream source on(?<=\n)ahead of time and replays line-by-line; in block mode it concatenates lines until the requested byte count is hit. Filters that depend on partial-line state observe slightly different framing than real Perl. - Drive the filter from the lexer, one chunk at a time, instead
of
applyFilters(entire-remaining-source)followed by re-tokenize. This matches Perl'sPL_rsfp_filtersmodel and removes the "rejoin tokens, filter, re-tokenize" round-trip.
Neither is needed for any currently-failing real-world module — included here as a roadmap, not a TODO.
src/main/java/org/perlonjava/runtime/perlmodule/FilterUtilCall.java(Phase 6FilterStateSnapshot+saveAndResetFilterState/restoreFilterState)src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java(Phase 6 try/finally wrapper around the require/do compile + Phase 7local $_semantics +__FILE__fordo FH/do CODE)src/test/resources/unit/source_filter_scope.t(Phase 6 regression test)src/test/resources/module/YAML/t/(34 upstream YAML-1.31 tests unblocked by Phase 6)