Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
105fa99
Compile string patterns with automata
chengluyu Jul 3, 2026
592c813
Shrink compiled string automata tables
chengluyu Jul 4, 2026
e2fa0f7
Merge branch 'hkmc2' into compiled-string-patterns
LPTK Jul 4, 2026
a4f702f
Add opt-in size measurement for compiled string pattern tables
chengluyu Jul 11, 2026
eb7a728
Fuse op-carrying single-edge states in string automata
chengluyu Jul 11, 2026
4ab837a
Minimize the reverse DFA in recognition-only string tables
chengluyu Jul 11, 2026
cc60472
Pack string-table integer sections as base64 varints
chengluyu Jul 11, 2026
8aefa77
Support astral code points in character range patterns
chengluyu Jul 11, 2026
f6026af
Warn when string patterns fall back to backtracking matching
chengluyu Jul 11, 2026
10dfb09
Compile negations of pure patterns in string regions
chengluyu Jul 11, 2026
8caf76d
Compile conjunctions with one impure branch in string regions
chengluyu Jul 12, 2026
2967a33
Merge upstream in
chengluyu Jul 13, 2026
df8ba26
Merge branch 'hkmc2' into compiled-string-patterns
LPTK Jul 24, 2026
838ec0d
Restore indented blank lines stripped by the string-pattern commits
claude Jul 24, 2026
71e1715
Add regression tests for compiled string pattern bugs
claude Jul 24, 2026
5cbc7fd
Add review notes for the compiled string pattern PR
claude Jul 24, 2026
42a1829
Fix three miscompilations in compiled string patterns
claude Jul 24, 2026
330a095
Reorganize the string pattern tests
claude Jul 24, 2026
c83320a
Keep constructs the string automaton rejects on the legacy path
claude Jul 24, 2026
3c80103
Drop the empty alphabet class and an unused helper
claude Jul 24, 2026
05920e9
Stop swallowing internal errors when compiling `unapplyStringPrefix`
claude Jul 24, 2026
c58e2b8
Correct the `unapplyStringPrefix` doc: it is always generated
claude Jul 25, 2026
6095982
Collapse empty int ranges to `Never` instead of the wildcard
claude Jul 25, 2026
98995d9
Reject guards and chained patterns within string patterns
claude Jul 25, 2026
994f9a8
Merge branch 'hkmc2' into compiled-string-patterns
LPTK Jul 25, 2026
476705f
Drop the empty-int-range guard, subsumed by the encoding fix
claude Jul 25, 2026
df3eed0
Record the post-review fix status in the PR540 findings
claude Jul 25, 2026
8f66ead
Give range patterns their intended typed, single-character semantics
claude Jul 25, 2026
90e213e
Run transforms under `@compile` even in match-only mode
claude Jul 25, 2026
0173355
Fix four more string-pattern miscompilations from the review
claude Jul 25, 2026
95343ed
Harden diagnostics, invariants, and coverage of the string compiler
claude Jul 25, 2026
07b5987
Pin the poisoned-definition semantics and record the follow-up fix round
claude Jul 25, 2026
21cc53c
Pin the mixed string/class definition the whole-body-region gate prot…
claude Jul 25, 2026
0db6ba6
Merge the PR #540 review round into the string-compiler work
chengluyu Jul 28, 2026
5f092ec
Merge branch 'hkmc2' into compiled-string-patterns
claude Aug 27, 2026
5532a6b
Give the JS test env a bigger stack, and record the merge review round
claude Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions hkmc2/shared/src/main/scala/hkmc2/semantics/Elaborator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ object Elaborator:
loopEnd: ModuleOrObjectSymbol,
tuple: ModuleOrObjectSymbol,
str: ModuleOrObjectSymbol,
strPat: ModuleOrObjectSymbol,
unreachable: TermSymbol,
tupleGet: TermSymbol,
tupleSlice: TermSymbol,
Expand All @@ -340,6 +341,9 @@ object Elaborator:
strGet: TermSymbol,
strTake: TermSymbol,
strLeave: TermSymbol,
strPatMatchWhole: TermSymbol,
strPatParseWhole: TermSymbol,
strPatParsePrefix: TermSymbol,
matchSuccessCls: ClassSymbol,
matchSuccessTrm: TermSymbol,
matchFailureCls: ClassSymbol,
Expand Down Expand Up @@ -375,11 +379,13 @@ object Elaborator:

val tuple = modOrObj("Tuple")
val str = modOrObj("Str")
val strPat = modOrObj("StrPat")
RuntimeSymbols(
unit = modOrObj("Unit"),
loopEnd = modOrObj("LoopEnd"),
tuple = tuple,
str = str,
strPat = strPat,
unreachable = term("unreachable"),
tupleGet = moduleMember(tuple, "get"),
tupleSlice = moduleMember(tuple, "slice"),
Expand All @@ -388,6 +394,9 @@ object Elaborator:
strGet = moduleMember(str, "get"),
strTake = moduleMember(str, "take"),
strLeave = moduleMember(str, "leave"),
strPatMatchWhole = moduleMember(strPat, "matchWhole"),
strPatParseWhole = moduleMember(strPat, "parseWhole"),
strPatParsePrefix = moduleMember(strPat, "parsePrefix"),
matchSuccessCls = cls("MatchSuccess"),
matchSuccessTrm = term("MatchSuccess"),
matchFailureCls = cls("MatchFailure"),
Expand Down Expand Up @@ -441,6 +450,10 @@ object Elaborator:
def strGetSymbol: TermSymbol = runtimeSymbols.strGet
def strTakeSymbol: TermSymbol = runtimeSymbols.strTake
def strLeaveSymbol: TermSymbol = runtimeSymbols.strLeave
def strPatSymbol: ModuleOrObjectSymbol = runtimeSymbols.strPat
def strPatMatchWholeSymbol: TermSymbol = runtimeSymbols.strPatMatchWhole
def strPatParseWholeSymbol: TermSymbol = runtimeSymbols.strPatParseWhole
def strPatParsePrefixSymbol: TermSymbol = runtimeSymbols.strPatParsePrefix
def matchSuccessClsSymbol: ClassSymbol = runtimeSymbols.matchSuccessCls
def matchSuccessTrmSymbol: TermSymbol = runtimeSymbols.matchSuccessTrm
def matchFailureClsSymbol: ClassSymbol = runtimeSymbols.matchFailureCls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ trait TermSynthesizer(using State):
protected lazy val stringGet = sel(sel(runtimeRef, "Str", State.strSymbol), "get", State.strGetSymbol)
protected lazy val stringTake = sel(sel(runtimeRef, "Str", State.strSymbol), "take", State.strTakeSymbol)
protected lazy val stringLeave = sel(sel(runtimeRef, "Str", State.strSymbol), "leave", State.strLeaveSymbol)
protected lazy val strPatMatchWhole = sel(sel(runtimeRef, "StrPat", State.strPatSymbol), "matchWhole", State.strPatMatchWholeSymbol)
protected lazy val strPatParseWhole = sel(sel(runtimeRef, "StrPat", State.strPatSymbol), "parseWhole", State.strPatParseWholeSymbol)
protected lazy val strPatParsePrefix = sel(sel(runtimeRef, "StrPat", State.strPatSymbol), "parsePrefix", State.strPatParsePrefixSymbol)

/** Make a term that looks like `runtime.Tuple.get(t, i)`. */
protected final def callTupleGet(t: Term, i: Int, label: Str): Term =
Expand Down Expand Up @@ -92,6 +95,17 @@ trait TermSynthesizer(using State):
protected final def callStringDrop(t: Term.Ref, n: Int, label: Str) =
app(stringLeave, tup(fld(t), fld(int(n))), label)

/** Aggregate the transform closures of a compiled string pattern into a
* tuple for the matching engine. The closures may originate from different
* source blocks, so the tuple's location must be pinned explicitly:
* deriving it from the children would mix origins and trip the location
* distinctness assertion in `AutoLocated`. */
protected final def actionsTuple(actions: Ls[Term], siteLoc: Opt[Loc]): Term =
val tuple = tup(actions)
siteLoc.orElse(actions.iterator.flatMap(_.toLoc.iterator).nextOption()) match
case loc @ S(_) => tuple.withLoc(loc)
case N => tuple // No sub-locations at all: nothing to mix.

protected final def tempLet(dbgName: Str, term: Term)(inner: TempSymbol => Split): Split =
val s = TempSymbol(N, dbgName)
Split.Let(s, term, inner(s))
Expand Down
92 changes: 89 additions & 3 deletions hkmc2/shared/src/main/scala/hkmc2/semantics/ups/Compiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,20 @@ class Compiler(using Context)(using tl: TL)(using Ctx, State, Raise) extends Ter
.mkString("{", ", ", "}")}"
):
val expandedPatterns = patterns.map(p => (p.label, p.expand(Set.empty)))
val heads = expandedPatterns.flatMap((_, p) => p.heads).toList
// String-shaped patterns (sequences and character classes) cannot take
// part in head-based specialization: how a string scrutinee is split is a
// decision global to the whole sequence. Whenever one is present, all
// string-shaped patterns — including plain string literals, whose heads
// would overlap the `Str` class — are absorbed into a single `Str` head
// whose branch runs one compiled automaton per label, like a lexer
// jointly matching several token rules (see `StringCompiler`).
val absorbStrings = expandedPatterns.exists((_, p) => StringCompiler.containsStringNode(p))
val strSymbol = ctx.builtins.Str
val heads = expandedPatterns.flatMap((_, p) => p.heads).toList.filter: head =>
!absorbStrings || (head match
case _: StrLit => false
case symbol: ClassLikeSymbol if symbol is strSymbol => false
case _ => true)
// This is the parameter of the current multi-matcher.
val scrutinee = VarSymbol(Ident("input"))
// Assemble branches for constructors and literals.
Expand All @@ -170,12 +183,16 @@ class Compiler(using Context)(using tl: TL)(using Ctx, State, Raise) extends Ter
case _: (syntax.Literal | ModuleOrObjectSymbol) => empty
val consequent = Split.Else(multiMatcherBranch(specialized, scrutinee, classFields))
Branch(scrutinee.safeRef, head.toFlatPattern(classFieldArguments), consequent)
val stringBranch = if !absorbStrings then N else
val pattern = FlatPattern.ClassLike(strSymbol.safeRef, strSymbol, N, false)(Tree.Dummy)
val consequent = Split.Else(multiMatcherStringBranch(expandedPatterns, scrutinee))
S(Branch(scrutinee.safeRef, pattern, consequent))
// Assemble the default branch.
val default =
val specialized = expandedPatterns.specializeSet(N)
Split.Else(multiMatcherBranch(specialized, scrutinee, Map.empty))
// Make a split that tries all branches in order.
val topmostSplit = branches.foldRight(default)(_ ~: _)
val topmostSplit = (branches ::: stringBranch.toList).foldRight(default)(_ ~: _)
val bodyTerm = SynthIf(topmostSplit)
log(s"Multi-matcher body:\n${topmostSplit.prettyPrint}")
(paramList(param(scrutinee)), bodyTerm)
Expand Down Expand Up @@ -243,7 +260,76 @@ class Compiler(using Context)(using tl: TL)(using Ctx, State, Raise) extends Ter
// Lastly, we return the matcher result, directly for singleton matchers
// and as a record otherwise.
Blk(bindings ::: tests.reverse, resultTerm)


/** The branch body for the absorbed `Str` head: each label's string-shaped
* fragment is compiled to its own whole-match automaton (see the note in
* `buildMultiMatcherBody`). The per-label result terms follow the same
* protocol as `multiMatcherBranch`: a Boolean in match-only mode, and a
* `MatchSuccess`/`MatchFailure` value in full mode.
*/
def multiMatcherStringBranch(
patterns: Set[(Label, ExPat)],
scrutinee: VarSymbol,
)(using ResultMode): Blk =
val z = (Nil: Ls[Statement], Nil: Ls[(Label, Term)])
val (tests, resultTerms) = patterns.iterator.foldLeft(z):
case ((stmts, results), (label, pattern)) =>
val fragment = StringCompiler.stringFragment(pattern).simplify
val resultTerm = fragment match
case And(Nil) =>
// This label has no string-shaped alternative: it cannot match.
emptyMatchResult("not a string pattern")
// Note that each label needs its own compiler: a compiler instance
// accumulates the automaton states (and failure flag) of a single
// region.
case fragment => StringCompiler().compile(fragment, StringCompiler.Mode.Whole) match

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Critical — compiler crash when two labels of one multi-matcher reach the same transform.

Each label gets its own StringCompiler here, and Extract closures are interned per instance (actionSources.indexWhere(_ eq term)). A definition reached from two labels therefore emits its transform lambda twice, and both copies bind the same VarSymbols, because correspondence comes straight off the shared AST node.

class Box(val v)
pattern T = (("a" ~ "b") as w) => [w]
pattern P2 = Box(T ~ "c") | Box(T ~ "d")

fun f2(x) = if x is @compile P2 as y then y else "no"
//| /!!!\ Uncaught error: java.lang.AssertionError: assertion failed: already defined: w
//| 	at: hkmc2.codegen.SymbolRefresherWalker.assertUpdate(SymbolRefresher.scala:15)

The whole definition is lost and every later use fails with ReferenceError: f2 is not defined. Minimal pair: one label (Box(T ~ "c") alone) compiles and runs; the same P2 without @compile compiles and runs; two labels in one multi-matcher crashes.

This makes the TODO above the Extract case in StringCompiler optimistic — the hazard is not only "when a simplifier pass duplicates a subtree containing both", it fires on ordinary two-label input. Threading one interning table through all labels of a multi-matcher body would fix it; the TODO's own suggestion — hosting each definition's transforms as methods on the pattern object and referencing them by selection — fixes it properly.

case N => emptyMatchResult("rejected string pattern")
case S(compiled) =>
val matchTableTerm = str(compiled.matchTable)
if isMatchOnly then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Critical — @compile silently drops a transform's effects.

This branch calls matchWhole regardless of compiled.actions, while makeStringRegionSplit documents the opposite rule and follows it: "Transforms … run exactly once on the committed parse, even when the match is only used as a condition". ups/regex/CompiledSemantics.mls pins that rule — for the inline path only.

fun probe(x)  = if x is ("a" => print("ran")) ~ "b" then "yes" else "no"
probe("ab")   //| > ran
              //| = "yes"

fun probe2(x) = if x is @compile (("a" => print("ran2")) ~ "b") then "yes" else "no"
probe2("ab")  //| = "yes"     <- "ran2" is never printed

Whether the transform runs depends on whether the caller happens to bind the output (MatchOnly vs Full), so adding @compile — documented as an optimisation switch — changes observable behaviour.

Suggest factoring the decision into one predicate on Compiled that both call sites consult, e.g. def needsParsing(outputNeeded: Bool, bindingsNeeded: Bool): Bool = !pure && (outputNeeded || bindingsNeeded || actions.nonEmpty || visibleSlots.nonEmpty). At minimum, if isMatchOnly && compiled.actions.isEmpty then here, plus a softAssert that the matchWhole path is only taken when actions.isEmpty.

In fairness: dropping Extract in ResultMode.MatchOnly is pre-existing base behaviour for every pattern shape, so the underlying policy question is broader than this PR. What is new is that two paths now disagree for the same pattern, silently.

app(strPatMatchWhole, tup(fld(matchTableTerm), fld(scrutinee.safeRef)), "string match")
else if compiled.pure then
// An operation-free whole match preserves the scrutinee.
val matchedSymbol = TempSymbol(N, "stringMatched")
val call = app(strPatMatchWhole, tup(fld(matchTableTerm), fld(scrutinee.safeRef)), "string match")
SynthIf(Split.Let(matchedSymbol, call,
Branch(matchedSymbol.safeRef,
Split.Else(makeMatchSuccess(scrutinee.safeRef))
) ~: Split.Else(emptyMatchResult("string mismatch"))))
else
// Note: expanded patterns may aggregate sub-patterns from
// several source blocks, so their auto-computed location is
// not usable here; the helper pins the first action's own
// location instead (the surrounding terms are location-free).
val call = app(strPatParseWhole,
tup(fld(str(compiled.table)), fld(actionsTuple(compiled.actions, N)), fld(scrutinee.safeRef)),
"string parse")
val resultSymbol = TempSymbol(N, "parseResult")
val outputSymbol = TempSymbol(N, "stringOutput")
val slotSymbols = compiled.visibleSlots.map: (symbol, slot) =>
(symbol, slot, TempSymbol(N, s"${symbol.name}$$"))
val bindingsTerm = makeBindings(slotSymbols.map:
(symbol, _, local) => RcdField(str(symbol.name), local.safeRef))
val success = slotSymbols.foldRight(
Split.Else(makeMatchSuccess(outputSymbol.safeRef, bindingsTerm)): Split
):
case ((_, slot, local), inner) =>
Split.Let(local, callTupleGet(resultSymbol.safeRef, 1 + slot, "string binding"), inner)
SynthIf(Split.Let(resultSymbol, call,
Branch(
resultSymbol.safeRef,
// The engine returns null on failure and an array on success.
FlatPattern.Tuple(1, true),
Split.Let(outputSymbol, callTupleGet(resultSymbol.safeRef, 0, "string output"), success)
) ~: Split.Else(emptyMatchResult("string mismatch"))))
val symbol = TempSymbol(N, label.asFieldName + "$")
(DefineVar(symbol, resultTerm) :: LetDecl(symbol, Nil) :: stmts, (label, symbol.safeRef) :: results)
val resultTerm = resultTerms.reverse match
case (_, term) :: Nil => term
case terms => Rcd(false, terms.map: (label, term) =>
RcdField(str(label.asFieldName), term))
Blk(tests.reverse, resultTerm)

import Pattern.*

/** Represent things that can be used as expressions in consequents. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ class FixedPointCompiler(using tl: TL)(using State, Ctx, Raise) extends TermSynt
case Not(pattern) => mentions(pattern, target)
case Rename(pattern, _) => mentions(pattern, target)
case Extract(pattern, _, _) => mentions(pattern, target)
case Literal(_) => false
case Concat(patterns) => patterns.exists(mentions(_, target))
case Literal(_) | CharClass(_, _) => false

/** Instantiate the pattern groups with a shared `Instantiator`,
* monomorphizing higher-order patterns such as `Ctx(Redex)` into
Expand Down
29 changes: 21 additions & 8 deletions hkmc2/shared/src/main/scala/hkmc2/semantics/ups/Instantiator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,28 @@ class Instantiator(using tl: TL)(using Ctx, State, Raise):
case SP.Wildcard() => Wildcard
case SP.Literal(literal) => Literal(literal)
case SP.Range(lower, upper, rightInclusive) =>
// Currently, we expand the range pattern into a list of literals. After
// the `where` clause or chain patterns are implemented, we could directly
// expand the range pattern into a range test.
(lower, upper) match
case (StrLit(lower), StrLit(upper)) =>
Or((lower.head to upper.head).map(c => Literal(StrLit(c.toString))).toList)
case (StrLit(lower), StrLit(upper)) if lower.nonEmpty && upper.nonEmpty =>
// String ranges compare the first UTF-16 code unit, mirroring the
// previous expansion `(lower.head to upper.head)`. Keeping the range
// symbolic lets the string pattern compiler emit compact
// character-class transitions instead of wide disjunctions.
CharClass(lower.head.toInt, upper.head.toInt).withLocOf(pattern)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Critical — ..< silently becomes ..= inside a ~ sequence.

rightInclusive is bound in the enclosing match and never used here, so the emitted CharClass is always inclusive. The comment is right that this mirrors the previous (lower.head to upper.head) expansion — but that expansion was only reachable from the @compile path, whereas routing Concatenation through Instantiator now makes it the default for ordinary code.

pattern Half = ("a" ..< "z") ~ "!"
"z!" is Half        //| = true     <- base: false

pattern Plain = "a" ..< "z"
"z" is Plain        //| = false    <- still correct, via makeRangeTest

So the same range means two different things depending on whether it sits inside a sequence. Nothing caught it because no test in the tree uses ..< on strings — only ..=.

Suggest CharClass(lo, if rightInclusive then hi else hi - 1), collapsing to Never when the class becomes empty, or an explicit inclusivity field on CharClass. Worth noting the IntLit branch a few lines below has the same bug (lower to upper regardless of the flag) — pre-existing rather than introduced here, but it is right there.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Addressed — fix written and verified locally (not pushed here yet).

The upper bound is now lowered by one for an exclusive range, collapsing to Never when that empties the class:

pattern Half = ("a" ..< "z") ~ "!"
"z!" is Half        //| = false    (was: true)
"y!" is Half        //| = true

pattern Plain = "a" ..< "z"
"z" is Plain        //| = false    (unchanged — this path always honoured the flag)

I also fixed the integer branch immediately below, which had the same bug (lower to upper regardless of rightInclusive). That one is pre-existing rather than introduced by this PR, but it is literally the adjacent case and the same two lines, so it seemed worse to leave it. Shout if you would rather it were split out.

I did not touch the lower.nonEmpty && upper.nonEmpty guard on this case. It is unreachable — Elaborator.isInvalidStringBounds already rejects any bound whose length is not exactly 1, which also rules out astral characters, since a surrogate pair has length 2 — so a bound reaching here is always a single code unit. Removing the guard would send those to the "Range patterns are not supported in pattern compilation" arm, whose message would be misleading. Left as is, but it is dead defensive code either way.

Leaving this thread open for you.

case (IntLit(lower), IntLit(upper)) =>
// Integer ranges are still expanded into a list of literals. After
// the `where` clause or chain patterns are implemented, we could
// directly expand the range pattern into a range test.
Or((lower to upper).map(i => Literal(IntLit(i))).toList)
case _ =>
error(msg"Range patterns are not supported in pattern compilation." -> pattern.toLoc)
Never
case SP.Concatenation(left, right) =>
error(msg"String concatenation is not supported in pattern compilation." -> pattern.toLoc)
Never
// Flatten nested concatenations into one sequence so that the string
// pattern compiler sees the whole `~`-spine at once.
def parts(pattern: Pat): Ls[Pat] = pattern match
case Concat(patterns) => patterns
case pattern => pattern :: Nil
Concat(parts(instantiate(left)) ::: parts(instantiate(right))).withLocOf(pattern)
case SP.Tuple(leading, spread) =>
val instantiatedSpread = spread.map:
case (spreadKind, middle, trailing) =>
Expand Down Expand Up @@ -171,4 +179,9 @@ class Instantiator(using tl: TL)(using Ctx, State, Raise):
acc
case N => true
instantiate(pattern)
case _: SP.Guarded => TODO("instantiate for Guarded")
case _: SP.Guarded =>
// Guards may fail after consumption based on information the automaton
// cannot track, which would reintroduce backtracking; they are excluded
// from compiled patterns for now.
error(msg"Guarded patterns are not supported in pattern compilation." -> pattern.toLoc)
Never
Loading
Loading