Skip to content

When the engine has no lookaround

One line: Rust's regex and Go's regexp have no lookahead, no lookbehind and no backreferences — and the three jobs you used them for have replacements that are shorter to read, tell you which rule failed, and in one case are simply more correct than the lookahead was.

Level: 201 · you write Rust or Go and just discovered the hole

Keywords on this page: (?=…) · (?<=…) · (…) · | — Topics: Choosing an engine · Extracting fields

First, the refusal

Verified output of lookaround_rs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

regex:       refused
regex parse error:
    foo(?=bar)
       ^^^
error: look-around, including look-ahead and look-behind, is not supported
fancy-regex: fixed-width lookbehind -> Some("250")
fancy-regex: variable-width lookbehind -> Error compiling regex: Look-behind assertion without constant size

That is not a missing feature anyone forgot. regex and RE2 compile a pattern to a finite automaton and match in time linear in the subject, with no backtracking — and lookaround cannot be expressed that way. The trade is stated plainly on what a backreference costs: you give up two constructs, and in exchange no pattern can ever hang your service.

So the question is not "how do I get lookaround back" but "what was I using it for".

The three jobs, without it

Verified output of without_lookaround_rs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

Sunshine1  rules matched [0, 1, 2]  accepted true
sunshine1  rules matched [0, 2]  accepted false
Sun1       rules matched [0, 1, 2]  accepted false
sunshine1 is missing: ["an uppercase letter"]
not in EUR: 90
after EUR: "250" at byte 4

1. "All of these rules at once"

The lookahead idiom is ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$ — three assertions stacked at position zero. In Rust that becomes a RegexSet ↗, which matches many patterns against the subject in a single pass.

It is worth noticing what the replacement gains. rules.matches("sunshine1") returns [0, 2]: the set does not just say no, it says which rule failed, so the error message writes itself — missing: ["an uppercase letter"]. The one-big-pattern version can only fail. Anyone who has tried to explain a rejected password from a single (?=…)(?=…)(?=…) pattern has met this limitation already.

2. "X not followed by Y" — and the version that is more correct

The lookahead spelling is \d+(?! EUR), and it is a trap in every engine that has it. From the lookaround lesson, Python's answer on 250 EUR and 90 USD:

['25', '90']

25 is there because \d+ is free to stop one digit early, at which point the next character is 0, which is not EUR, so the assertion holds. The Rust version — match the optional tail and then look at whether you captured it — returns 90 and nothing else. It is longer and it is right.

let amount = Regex::new(r"(\d+)( EUR)?").unwrap();
// then: if caps.get(2).is_none() { … }

This is the general shape: make the thing you are asserting about part of the match, then test it in code. A negative assertion is a question about one position; a capture is a question about the text, which is usually what you meant.

3. "X preceded by Y"

Lookbehind exists so the prefix does not end up in the match. Capturing it costs nothing, because the capture knows where it starts: caps.get(1) gives both the text and the byte offset. after EUR: "250" at byte 4 is the same information (?<=EUR )\d+ would have given you.

The same three in Go

Verified output of without_lookaround_go.go — regenerated by tools/run_examples.py, never hand-typed.

Sunshine1  accepted true
sunshine1  accepted false
           missing an uppercase letter
not in EUR: 90
after EUR: "250" at byte 4

Go has no RegexSet, so the set is an ordinary slice of compiled patterns — the same idea with more typing. FindAllStringSubmatchIndex is the offsets version of job 3.

One trap in the Go version

The rules live in a map, and Go randomises map iteration on purpose, so the list of missing rules had to be sorted before printing. Without the sort this example would print a different order on different runs, and its answer key would fail CI at random — the kind of flake that takes an afternoon to attribute.

When you actually do need it

Sometimes the assertion is irreducible — a pattern supplied by a user, a rule ported from a PCRE codebase that must behave identically, a tokenizer that genuinely needs to peek. Then reach for fancy-regex ↗, deliberately:

fancy-regex = "0.16"

It gives you lookahead, fixed-width lookbehind and backreferences, delegates any pattern that needs none of them straight to regex, and replaces the linear-time guarantee with a backtrack limit that returns Err instead of hanging. Two things to know before you do:

  • Its lookbehind must be a known width — (?<=EUR\s{1,4}) is a compile error, Look-behind assertion without constant size. That is Python's rule, not PCRE2's, so a pattern moved from ABAP or Perl may not survive the trip.
  • is_match now returns a Result. Handle the error case; it is the backtrack limit telling you a pattern is pathological.

Decision table

What you were doing with lookaround In regex / Go regexp
Several independent rules at once RegexSet (Rust) or a slice of patterns (Go) — and you learn which one failed
"not followed by Y" Capture (Y)? and test the capture
"preceded by Y" Capture Y (x) and use the submatch offset
Trim a prefix from the reported match The capture group's start(), or strip_prefix before matching
A user-supplied PCRE pattern fancy-regex, and handle its Err

See also