Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 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
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
2,185 changes: 2,185 additions & 0 deletions PR540-review-findings.md

Large diffs are not rendered by default.

21 changes: 19 additions & 2 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 Expand Up @@ -2347,9 +2360,13 @@ extends Importer:
/** String range bounds must be single characters. */
def isInvalidStringBounds(lo: StrLit, hi: StrLit)(using Raise): Bool =
val ds = collection.mutable.Buffer.empty[(Message, Option[Loc])]
if lo.value.length =/= 1 then
// A bound is a single character when it is one code point; astral
// characters (two UTF-16 code units) are accepted.
def isSingleCharacter(s: Str): Bool =
s.nonEmpty && s.codePointCount(0, s.length) == 1
if !isSingleCharacter(lo.value) then
ds += msg"The lower bound of character ranges must be a single character." -> lo.toLoc
if hi.value.length =/= 1 then
if !isSingleCharacter(hi.value) then
ds += msg"The upper bound of character ranges must be a single character." -> hi.toLoc
if ds.nonEmpty then error(ds.toSeq*)
ds.nonEmpty
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
125 changes: 121 additions & 4 deletions hkmc2/shared/src/main/scala/hkmc2/semantics/ups/Compiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ class Compiler(using Context)(using tl: TL)(using Ctx, State, Raise) extends Ter
import Compiler.*, tl.*
import Pattern.*

/** The location of the match site this compiler serves, used only to label
* `StringCompiler.TableStats` measurement records. Expanded patterns
* aggregate sub-trees from several blocks, so their own locations are not
* usable for this purpose. */
var statsSiteLoc: Opt[Loc] = N

/** A previously-computed matcher result for one field of the current
* multi-matcher. In full mode the value also carries the original field
* input, which is needed when a successful field pattern preserves its
Expand Down Expand Up @@ -150,7 +156,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 +189,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 @@ -244,6 +267,98 @@ class Compiler(using Context)(using tl: TL)(using Ctx, State, Raise) extends Ter
// 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 Or(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) =>
// Mirrors the branch chosen below: a match-only region still
// needs the parse table when it carries transforms or bindings.
val embedsMatchTable =
if isMatchOnly then compiled.recognitionSuffices(false) else compiled.pure
StringCompiler.TableStats.record(statsSiteLoc,
if embedsMatchTable then "mm-match" else "mm-parse",
if embedsMatchTable then compiled.matchTable else compiled.table)
val matchTableTerm = str(compiled.matchTable)
if isMatchOnly && compiled.recognitionSuffices(false) then
app(strPatMatchWhole, tup(fld(matchTableTerm), fld(scrutinee.safeRef)), "string match")
else if isMatchOnly then
// The region carries transforms (or bindings): even a
// condition-position match must run them, exactly once, on
// the committed parse. Only the success of the parse is
// observed.
val call = app(strPatParseWhole,
tup(fld(str(compiled.table)), fld(actionsTuple(compiled.actions, N)), fld(scrutinee.safeRef)),
"string parse")
val resultSymbol = TempSymbol(N, "parseResult")
SynthIf(Split.Let(resultSymbol, call,
Branch(
resultSymbol.safeRef,
// The engine returns null on failure and an array on success.
FlatPattern.Tuple(1, true),
Split.Else(bool(true))
) ~: Split.Else(bool(false))))
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 Expand Up @@ -525,8 +640,10 @@ class Compiler(using Context)(using tl: TL)(using Ctx, State, Raise) extends Ter
val params = paramList(param(bindingsSymbol))
// Because we pass the extracted values using recoreds. We need to bind
// each property to its corresponding variable which is accessible from
// then `term`.
val letBindings = pattern.symbols.flatMap: symbol =>
// then `term`. Only the symbols the definition itself binds are
// mapped by `correspondence` (and referenced by `term`); symbols
// bound inside substituted pattern arguments are not.
val letBindings = pattern.symbols.filter(correspondence.contains).flatMap: symbol =>
val termSymbol = correspondence(symbol)
LetDecl(termSymbol, Nil) ::
DefineVar(termSymbol, sel(bindingsSymbol.safeRef, termSymbol.name)) :: Nil
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
Loading
Loading