Summary
one or more <class> matches a single character, not the longest run. The
match extent is decided by whichever VM state reaches Match first in a
breadth-first sweep, not by a greedy/lazy rule — so unbounded quantifiers
(one or more, zero or more, at least N) come out shortest, while the
bounded form (N to M) comes out longest. Everything that consumes a match
extent — find, find all, split ... on pattern, replace ... in ..., and
capture {...} as name — returns the wrong text, with no diagnostic and exit 0.
greedy and lazy are lexed (src/lexer/token.rs:284,286) and documented
(Docs/reference/keyword-reference.md:147-148,
Docs/reference/reserved-keywords.md:626,636) but are not accepted anywhere in
pattern syntax, so there is no spelling that asks for the longest match.
Reproduction
create pattern digits:
one or more digit
end pattern
store s as "a1b22c333"
store hit as find digits in s
display "find: [" with hit["matched_text"] with "]"
display "replace: [" with (replace digits with "#" in s) with "]"
Command:
Expected
find: [1]
replace: [a#b#c#]
Docs/04-advanced-features/pattern-matching.md:133-143 documents one or more
as a quantifier over a repeated character class, and
Docs/05-standard-library/pattern-module.md:132-146 ships an example that
presents one or more letter over "The quick brown fox" as word
extraction (display "Found " with length of word_matches with " words:").
Actual
find: [1]
replace: [a#b##c###]
Exit code 0. No warning, no error.
find returns one character, so replace rewrites each digit separately —
22 becomes ##, 333 becomes ###.
Measured extents (one run per row)
| Pattern |
Input |
Matched |
Extent |
one or more digit |
a1b22c333 |
1 |
shortest (1) |
zero or more digit |
12345 |
(empty) |
shortest (0) |
at least 2 digit |
a12345b |
12 |
shortest (2) |
2 to 4 digit |
a12345b |
1234 |
longest (4) |
exactly 3 digit |
a12345b |
123 |
exact (3) |
So the bounded form behaves the opposite way from the unbounded ones. Neither
is documented, and the two contradict each other.
What it breaks downstream
create pattern word:
one or more letter
end pattern
store text as "The quick brown fox"
store word_matches as pattern_find_all of text and word
display "words found: " with length of word_matches
// words found: 16 <- individual letters, not 4 words
// (this is the shipped docs example)
create pattern spaces:
one or more whitespace
end pattern
store parts as split "a b c" on pattern spaces
display "split parts: " with length of parts
// split parts: 6 <- expected 3; the extra parts are empty strings
// between consecutive spaces
create pattern id:
capture {one or more digit} as number
end pattern
store m as find id in "abc12345"
display "captured: [" with (m["captures"])["number"] with "]"
// captured: [1] <- expected 12345
Validation-style patterns are mostly unaffected, which is why this has gone
unnoticed: when a quantifier is followed by more pattern, the surrounding
context forces the longer expansion. The docs' email pattern still both matches
and extracts correctly:
store hit as find email_address in "write to alice42@example.com today"
// extracted: [alice42@example.com] <- correct
The bug bites when the quantifier is unconstrained — which is exactly the
extraction, splitting and replacement cases.
Root cause
find_at_position returns on the first state that reaches Match while
sweeping the state frontier:
// src/pattern/vm.rs:419-455
while !states.is_empty() {
for state in states {
match self.step(program, chars, state)? {
...
StepResult::Match(final_state) => {
...
return Ok(Some(MatchResult::from_chars(
start_pos, final_state.pos, chars, captures,
)));
}
Instruction::Split spawns both branches as peers
(src/pattern/vm.rs:545-554), so the branch that reaches Match in the fewest
steps wins. For OneOrMore the exit branch reaches Match before the
loop-back branch can consume another character:
// src/pattern/compiler.rs:708-735
// <pattern>
// L1: split L2, L3
// L2: <pattern>
// jump L1
// L3: (continue)
Quantifier::Between (src/pattern/compiler.rs:744-773) unrolls its optional
repetitions inline with no Jump, which is why it lands on the opposite
outcome. In other words the extent currently depends on the shape of the
emitted bytecode, not on a stated rule — which is why the table above is
inconsistent.
A fix needs a defined semantic (leftmost-longest for the default quantifiers,
as in POSIX/PCRE-greedy) rather than first-to-Match: e.g. keep sweeping the
frontier and keep the match with the greatest final_state.pos, or order the
alternatives and prefer the loop branch. Wiring the already-lexed greedy /
lazy keywords to select the other behaviour is the natural follow-on, but the
default is the part that is wrong today.
Environment
- wfl --version:
WebFirst Language (WFL) version 26.8.4
- binary:
target/release/wfl built from this branch
- commit: 4e6c413 (branch
claude/issues-698-700-bugs-bnevyy; also reproduces
on c277d8f, so it is not a regression — it is long-standing)
- OS: Linux
- build: release
- No
.wflcfg in scope for the repro.
Context
Found while fixing #698 (replace was a silent no-op). With replace
implemented, replacing with one or more digit still gives a surprising
result — but that is this engine bug, not the replacement: replace faithfully
rewrites whatever find_all reports, and the same wrong extents show up in
find, find all, split ... on pattern and capture. It is filed
separately because it is a pattern-VM semantics question with its own blast
radius, not part of that fix.
Two documentation consequences to fix alongside the engine (both are current
docs describing behaviour the runtime does not have):
Docs/05-standard-library/pattern-module.md:132-146 presents
one or more letter as word extraction; it returns 16 single letters.
Docs/reference/keyword-reference.md:147-148 and
Docs/reference/reserved-keywords.md:626,636 list greedy / lazy as
pattern keywords; neither parses in any position
(Unexpected token in pattern: KeywordGreedy).
The #698 docs were deliberately written to avoid examples that depend on
quantifier extent, so nothing merged there needs revisiting.
Summary
one or more <class>matches a single character, not the longest run. Thematch extent is decided by whichever VM state reaches
Matchfirst in abreadth-first sweep, not by a greedy/lazy rule — so unbounded quantifiers
(
one or more,zero or more,at least N) come out shortest, while thebounded form (
N to M) comes out longest. Everything that consumes a matchextent —
find,find all,split ... on pattern,replace ... in ..., andcapture {...} as name— returns the wrong text, with no diagnostic and exit 0.greedyandlazyare lexed (src/lexer/token.rs:284,286) and documented(
Docs/reference/keyword-reference.md:147-148,Docs/reference/reserved-keywords.md:626,636) but are not accepted anywhere inpattern syntax, so there is no spelling that asks for the longest match.
Reproduction
Command:
Expected
Docs/04-advanced-features/pattern-matching.md:133-143documentsone or moreas a quantifier over a repeated character class, and
Docs/05-standard-library/pattern-module.md:132-146ships an example thatpresents
one or more letterover"The quick brown fox"as wordextraction (
display "Found " with length of word_matches with " words:").Actual
Exit code 0. No warning, no error.
findreturns one character, soreplacerewrites each digit separately —22becomes##,333becomes###.Measured extents (one run per row)
one or more digita1b22c3331zero or more digit12345at least 2 digita12345b122 to 4 digita12345b1234exactly 3 digita12345b123So the bounded form behaves the opposite way from the unbounded ones. Neither
is documented, and the two contradict each other.
What it breaks downstream
Validation-style patterns are mostly unaffected, which is why this has gone
unnoticed: when a quantifier is followed by more pattern, the surrounding
context forces the longer expansion. The docs' email pattern still both matches
and extracts correctly:
The bug bites when the quantifier is unconstrained — which is exactly the
extraction, splitting and replacement cases.
Root cause
find_at_positionreturns on the first state that reachesMatchwhilesweeping the state frontier:
Instruction::Splitspawns both branches as peers(
src/pattern/vm.rs:545-554), so the branch that reachesMatchin the feweststeps wins. For
OneOrMorethe exit branch reachesMatchbefore theloop-back branch can consume another character:
Quantifier::Between(src/pattern/compiler.rs:744-773) unrolls its optionalrepetitions inline with no
Jump, which is why it lands on the oppositeoutcome. In other words the extent currently depends on the shape of the
emitted bytecode, not on a stated rule — which is why the table above is
inconsistent.
A fix needs a defined semantic (leftmost-longest for the default quantifiers,
as in POSIX/PCRE-greedy) rather than first-to-
Match: e.g. keep sweeping thefrontier and keep the match with the greatest
final_state.pos, or order thealternatives and prefer the loop branch. Wiring the already-lexed
greedy/lazykeywords to select the other behaviour is the natural follow-on, but thedefault is the part that is wrong today.
Environment
WebFirst Language (WFL) version 26.8.4target/release/wflbuilt from this branchclaude/issues-698-700-bugs-bnevyy; also reproduceson c277d8f, so it is not a regression — it is long-standing)
.wflcfgin scope for the repro.Context
Found while fixing #698 (
replacewas a silent no-op). Withreplaceimplemented, replacing with
one or more digitstill gives a surprisingresult — but that is this engine bug, not the replacement:
replacefaithfullyrewrites whatever
find_allreports, and the same wrong extents show up infind,find all,split ... on patternandcapture. It is filedseparately because it is a pattern-VM semantics question with its own blast
radius, not part of that fix.
Two documentation consequences to fix alongside the engine (both are current
docs describing behaviour the runtime does not have):
Docs/05-standard-library/pattern-module.md:132-146presentsone or more letteras word extraction; it returns 16 single letters.Docs/reference/keyword-reference.md:147-148andDocs/reference/reserved-keywords.md:626,636listgreedy/lazyaspattern keywords; neither parses in any position
(
Unexpected token in pattern: KeywordGreedy).The #698 docs were deliberately written to avoid examples that depend on
quantifier extent, so nothing merged there needs revisiting.