Skip to content

Unbounded quantifiers match the *shortest* run: find/find all/split/replace/capture all return one character for one or more digit #709

Description

@logbie

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:

wfl greedy_repro.wfl

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions