Pattern matching: match / case
A match/case construct is pure syntactic sugar — it desugars entirely to the same if/jump instructions an ordinary if/elif chain already produces, so every backend (this WASM playground included) gets it for free with zero new instructions. Covers literal, comparison-guard (case > 100), tagged-list destructuring (including nesting, e.g. ["Err", ["Timeout", ms]]), a when guard that can reference its own pattern's binding, and unquoted glob patterns (*.patlang) built on the existing regex engine. There's no dedicated "match by type" pattern kind — a when guard already covers it (and combining criteria across several variables, not just the scrutinee) since it's just an arbitrary boolean expression, free to call type_of()/numeric_kind() and reference any variable in scope. The last case is a deliberate no-match against a pattern with no wildcard arm, so you can see PatLang's real runtime-error behavior fire instead of a silent no-op.
PatLang source
# A small general-purpose regex engine, written in PatLang, for use by the
# Rust-side `syntax_dsl` source preprocessor (and anything else that needs
# pattern matching) without depending on Rust's `regex` crate or any other
# Rust-native pattern-matching feature. PatLang is meant to be fully
# self-hosting, so the actual matching *logic* has to be genuine PatLang
# code, invoked from Rust only as "run this program's function with these
# arguments" (see `Interpreter::call_function`).
#
# Supported syntax subset:
# literals a b c ...
# escapes \\ \. \d \w \s \b (and \n \t \\ \( \) \[ \] \| \* \+ \? \^ \$)
# any char .
# character class [abc] [a-z0-9] [^abc]
# grouping (...)
# alternation a|b|c
# quantifiers a* a+ a?
# anchors ^ $ \b (word boundary)
#
# No capture groups are extracted (whole-match only) — sufficient for
# tokenizing DSL blocks, which only need "does this token pattern match here,
# and how long is the match".
#
# AST nodes are tagged lists: ["lit", ch], ["any"], ["class", negate, items],
# ["seq", nodes], ["alt", branches], ["star", node], ["plus", node],
# ["opt", node], ["bol"], ["eol"], ["wordb"].
fn is_digit_char(ch) {
return ch == "0" or ch == "1" or ch == "2" or ch == "3" or ch == "4" or ch == "5" or ch == "6" or ch == "7" or ch == "8" or ch == "9"
}
fn is_word_char(ch) {
if ch == "_" { return true }
if is_digit_char(ch) { return true }
let code = char_code(ch, 0)
# A-Z: 65-90, a-z: 97-122
if code >= 65 and code <= 90 { return true }
if code >= 97 and code <= 122 { return true }
return false
}
fn is_space_char(ch) {
return ch == " " or ch == "\t" or ch == "\n" or ch == "\r"
}
# ---------------------------------------------------------------------
# Parsing: pattern (string) -> AST node. Returns [node, next_pos]; on
# failure next_pos is -1.
# ---------------------------------------------------------------------
fn regex_parse(pattern) {
let result = regex_parse_alt(pattern, 0)
return list_get(result, 0)
}
fn regex_parse_alt(pattern, pos) {
let plen = to_num(list_len(pattern))
let first = regex_parse_seq(pattern, pos)
let node = list_get(first, 0)
let mut p = list_get(first, 1)
if p < 0 { return [node, p] }
let mut branches = list_push([], node)
while p < plen and substr(pattern, p, 1) == "|" {
let nxt = regex_parse_seq(pattern, p + 1)
branches = list_push(branches, list_get(nxt, 0))
p = list_get(nxt, 1)
if p < 0 { return [["alt", branches], -1] }
}
let n = to_num(list_len(branches))
if n == 1 {
return [list_get(branches, 0), p]
}
return [["alt", branches], p]
}
fn regex_parse_seq(pattern, pos) {
let plen = to_num(list_len(pattern))
let mut nodes = []
let mut p = pos
while p < plen and substr(pattern, p, 1) != "|" and substr(pattern, p, 1) != ")" {
let r = regex_parse_quantified(pattern, p)
let node = list_get(r, 0)
p = list_get(r, 1)
if p < 0 { return [["seq", nodes], -1] }
nodes = list_push(nodes, node)
}
return [["seq", nodes], p]
}
fn regex_parse_quantified(pattern, pos) {
let r = regex_parse_atom(pattern, pos)
let node = list_get(r, 0)
let mut p = list_get(r, 1)
if p < 0 { return [node, -1] }
let plen = to_num(list_len(pattern))
if p < plen {
let c = substr(pattern, p, 1)
if c == "*" { return [["star", node], p + 1] }
if c == "+" { return [["plus", node], p + 1] }
if c == "?" { return [["opt", node], p + 1] }
}
return [node, p]
}
fn regex_atom_for_escape(ch) {
if ch == "d" { return ["class", 0, [["range", "0", "9"]]] }
if ch == "w" { return ["class", 0, [["range", "a", "z"], ["range", "A", "Z"], ["range", "0", "9"], ["char", "_"]]] }
if ch == "s" { return ["class", 0, [["char", " "], ["char", "\t"], ["char", "\n"], ["char", "\r"]]] }
if ch == "b" { return ["wordb"] }
if ch == "n" { return ["lit", "\n"] }
if ch == "t" { return ["lit", "\t"] }
return ["lit", ch]
}
fn regex_parse_atom(pattern, pos) {
let plen = to_num(list_len(pattern))
if pos >= plen { return [["seq", []], -1] }
let c = substr(pattern, pos, 1)
if c == "(" {
let inner = regex_parse_alt(pattern, pos + 1)
let node = list_get(inner, 0)
let mut p = list_get(inner, 1)
if p < 0 { return [node, -1] }
if p >= plen or substr(pattern, p, 1) != ")" { return [node, -1] }
return [["group", node], p + 1]
}
if c == "." { return [["any"], pos + 1] }
if c == "^" { return [["bol"], pos + 1] }
if c == "$" { return [["eol"], pos + 1] }
if c == "\\" {
if pos + 1 >= plen { return [["lit", "\\"], pos + 1] }
let esc = substr(pattern, pos + 1, 1)
return [regex_atom_for_escape(esc), pos + 2]
}
if c == "[" {
return regex_parse_class(pattern, pos + 1)
}
return [["lit", c], pos + 1]
}
fn regex_parse_class(pattern, pos) {
let plen = to_num(list_len(pattern))
let mut negate = 0
let mut p = pos
if p < plen and substr(pattern, p, 1) == "^" {
negate = 1
p = p + 1
}
let mut items = []
while p < plen and substr(pattern, p, 1) != "]" {
let mut c = substr(pattern, p, 1)
if c == "\\" and p + 1 < plen {
c = substr(pattern, p + 1, 1)
p = p + 1
}
if p + 2 < plen and substr(pattern, p + 1, 1) == "-" and substr(pattern, p + 2, 1) != "]" {
let hi = substr(pattern, p + 2, 1)
items = list_push(items, ["range", c, hi])
p = p + 3
} else {
items = list_push(items, ["char", c])
p = p + 1
}
}
if p >= plen { return [["class", negate, items], -1] }
return [["class", negate, items], p + 1]
}
# ---------------------------------------------------------------------
# Matching: attempts to match `node` in `text` starting at `pos`, calling
# `k(new_pos)` (a closure) with every position reachable after a successful
# match, until `k` returns a non-negative number (accepted) or all
# possibilities are exhausted (returns -1). This continuation style is what
# makes backtracking over alternation/quantifiers correct: `k` represents
# "the rest of the overall pattern", so a quantifier can retry with fewer
# repetitions if a later part of the pattern needs the characters back.
# ---------------------------------------------------------------------
fn regex_class_matches(items, ch) {
let n = to_num(list_len(items))
let mut i = 0
while i < n {
let it = list_get(items, i)
let tag = list_get(it, 0)
if tag == "char" {
if ch == list_get(it, 1) { return true }
} else {
if ch >= list_get(it, 1) and ch <= list_get(it, 2) { return true }
}
i = i + 1
}
return false
}
fn regex_match_node(node, text, pos, k) {
let tag = list_get(node, 0)
let tlen = to_num(list_len(text))
if tag == "lit" {
if pos < tlen and substr(text, pos, 1) == list_get(node, 1) {
return k(pos + 1)
}
return -1
}
if tag == "any" {
if pos < tlen and substr(text, pos, 1) != "\n" {
return k(pos + 1)
}
return -1
}
if tag == "class" {
if pos >= tlen { return -1 }
let ch = substr(text, pos, 1)
let mut hit = regex_class_matches(list_get(node, 2), ch)
let negate = list_get(node, 1)
if negate == 1 { hit = not hit }
if hit { return k(pos + 1) }
return -1
}
if tag == "bol" {
if pos == 0 { return k(pos) }
if pos > 0 and substr(text, pos - 1, 1) == "\n" { return k(pos) }
return -1
}
if tag == "eol" {
if pos == tlen { return k(pos) }
if pos < tlen and substr(text, pos, 1) == "\n" { return k(pos) }
return -1
}
if tag == "wordb" {
let mut before = false
let mut after = false
if pos > 0 { before = is_word_char(substr(text, pos - 1, 1)) }
if pos < tlen { after = is_word_char(substr(text, pos, 1)) }
if before != after { return k(pos) }
return -1
}
if tag == "group" {
return regex_match_node(list_get(node, 1), text, pos, k)
}
if tag == "seq" {
return regex_match_seq(list_get(node, 1), 0, text, pos, k)
}
if tag == "alt" {
return regex_match_alt(list_get(node, 1), 0, text, pos, k)
}
if tag == "star" {
return regex_match_repeat(list_get(node, 1), text, pos, k, 0)
}
if tag == "plus" {
return regex_match_repeat(list_get(node, 1), text, pos, k, 1)
}
if tag == "opt" {
let inner = list_get(node, 1)
let r = regex_match_node(inner, text, pos, k)
if r >= 0 { return r }
return k(pos)
}
return -1
}
fn regex_match_seq(nodes, idx, text, pos, k) {
let n = to_num(list_len(nodes))
if idx >= n { return k(pos) }
let node = list_get(nodes, idx)
return regex_match_node(node, text, pos, |p2| { return regex_match_seq(nodes, idx + 1, text, p2, k) })
}
fn regex_match_alt(branches, idx, text, pos, k) {
let n = to_num(list_len(branches))
if idx >= n { return -1 }
let r = regex_match_node(list_get(branches, idx), text, pos, k)
if r >= 0 { return r }
return regex_match_alt(branches, idx + 1, text, pos, k)
}
# Backtracking repetition: tries the greatest number of repetitions first,
# then backs off, so that whatever comes after (represented by `k`) still
# gets a chance to match — the standard greedy-with-backtracking behavior.
fn regex_match_repeat(node, text, pos, k, min_count) {
let r = regex_match_node(node, text, pos, |p2| {
if p2 == pos { return -1 }
return regex_match_repeat(node, text, p2, k, 0)
})
if r >= 0 { return r }
if min_count <= 0 { return k(pos) }
return -1
}
# Attempts to match `pattern_ast` (already parsed via regex_parse) against
# `text` starting exactly at `start`. Returns the end position of the
# longest-preferred (leftmost-greedy) match, or -1 if there is no match
# anchored at `start`.
#
# Note: the "return the position unchanged" continuation is written as an
# inline closure literal (`|p| { return p }`), not a reference to a plain
# top-level function — bare references to named functions lower to a
# local-variable load (and thus to Unit, since no such local exists), only
# closure literals actually produce a callable value in this IR. See
# `regex_match_seq` etc. below for the same reason every "rest of the
# match" continuation passed around is built the same way.
#
# GitHub follow-up (found while investigating 7 pre-existing
# regex_patlang_engine.rs failures, all "match length always 0"): this
# closure's own body used to be the BARE expression `p`, not `return p`
# -- PatLang has no implicit last-expression return (every function/
# closure needs an explicit `return`, unlike Ruby/Rust blocks), so the
# bare form silently evaluated to Unit every time it was called, which
# then propagated through this entire continuation-passing match engine
# to every single caller. Confirmed via a minimal standalone repro
# (`let k = |p| { p }; print(k(6))` prints nothing; `|p| { return p }`
# correctly prints 6) before touching this file.
fn regex_match_at(pattern_ast, text, start) {
return regex_match_node(pattern_ast, text, start, |p| { return p })
}
# Convenience combined entry point for callers that only have the raw
# pattern string (no pre-parsed AST cached) — parses then matches.
fn regex_match_string_at(pattern, text, start) {
let ast = regex_parse(pattern)
return regex_match_at(ast, text, start)
}
# ---------------------------------------------------------------------
# Glob matching (used by `match`'s `case *fred* then` pattern kind, see
# self_hosting/lib/lower.patlang's compile_pattern PGlob arm). A glob is
# translated to a regex (`*` -> `.*`, `?` -> `.`, every other char
# escaped if it's a regex metachar) and matched as a WHOLE-STRING match
# (the translated regex must consume the entire input, not just a
# prefix) -- glob semantics, unlike regex_match_string_at's "match
# anchored at a start position", never mean "found somewhere inside".
# ---------------------------------------------------------------------
fn glob_regex_needs_escape(ch) {
return ch == "\\" or ch == "." or ch == "(" or ch == ")" or ch == "[" or ch == "]" or ch == "^" or ch == "$" or ch == "|" or ch == "+" or ch == "{" or ch == "}"
}
fn glob_to_regex(pattern) {
let plen = to_num(list_len(pattern))
let out = sb_new()
let mut i = 0
while i < plen {
let ch = substr(pattern, i, 1)
if ch == "*" {
sb_push(out, ".*")
} else {
if ch == "?" {
sb_push(out, ".")
} else {
if glob_regex_needs_escape(ch) {
sb_push(out, "\\" + ch)
} else {
sb_push(out, ch)
}
}
}
i = i + 1
}
return sb_str(out)
}
fn glob_match(text, pattern) {
let regex_str = glob_to_regex(pattern)
let ast = regex_parse(regex_str)
let end = regex_match_at(ast, text, 0)
return end == to_num(list_len(text))
}
# Pattern matching (match/case): pure syntactic sugar over ordinary if/
# jump instructions -- see self_hosting/lib/lower.patlang's lower_match.
# Every arm kind below desugars to the exact same instructions an if/elif
# chain already uses, so it runs unmodified on every backend: the tree-
# walking interpreter, native rustc-compiled code, and this very page's
# in-browser WebAssembly playground.
fn classify(x) {
match x do
case 0 then
return "zero"
case "hi" then
return "a greeting"
case > 100 then
return "big (" + x + ")"
case n then
return "just a number: " + n
end
}
print(classify(0))
print(classify("hi"))
print(classify(250))
print(classify(7))
# Tagged-list patterns destructure and bind in one step -- no more
# `if x[0] == "Ok" then let v = x[1] ...` by hand.
fn describe(result) {
match result do
case ["Ok", v] then
return "success: " + v
case ["Err", ["Timeout", ms]] then
return "failed after " + ms + "ms (timeout)"
case ["Err", reason] then
return "failed: " + reason
case _ then
return "not a recognized result shape"
end
}
print(describe(["Ok", 42]))
print(describe(["Err", ["Timeout", 3000]]))
print(describe(["Err", "connection refused"]))
print(describe("garbage"))
# `when` adds an arbitrary boolean guard to any pattern, free to reference
# bindings the pattern just introduced.
fn triage(n) {
match n do
case v when v < 0 then
return "negative: " + v
case v when v > 1000 then
return "way too big: " + v
case v then
return "in range: " + v
end
}
print(triage(-5))
print(triage(5000))
print(triage(42))
# There's no dedicated "match by type" pattern kind -- `when` already
# covers it, since a guard is just an arbitrary boolean expression, free
# to call type_of()/numeric_kind() and reference OTHER variables in
# scope, not only the binding its own pattern just introduced. That also
# covers "select by a combination of criteria across multiple variables"
# for free: a guard can AND together checks on the scrutinee's binding
# and any number of unrelated outer variables in one expression.
fn type_report(v) {
match v do
case x when type_of(x) == "string" then
return "string: \"" + x + "\""
case x when numeric_kind(x) == "bigint" then
return "bigint: " + x
case x when type_of(x) == "list" then
return "list of " + x.length + " item(s)"
case x when type_of(x) == "bool" then
return "boolean: " + x
case x then
return "number: " + x
end
}
fn factorial(n) {
if n <= 1 then
return 1
end
return n * factorial(n - 1)
}
print(type_report("hello"))
print(type_report(factorial(25)))
print(type_report([1, 2, 3]))
print(type_report(true))
print(type_report(7))
fn describe_pair(a, b) {
# Order matters here exactly like an if/elif chain: `and` short-
# circuits left-to-right, so the type check must come FIRST in each
# guard -- `x > 0 and ...` would throw a type error comparing a
# string with `>`, not just evaluate falsy, if x ever isn't numeric.
match a do
case x when type_of(x) == "string" and type_of(b) == "string" then
return "both strings: " + x + ", " + b
case x when numeric_kind(x) != "other" and x > 0 and b > 0 then
return "both positive: " + x + ", " + b
case x when numeric_kind(x) != "other" and x > 0 and b < 0 then
return "mixed signs: " + x + ", " + b
case _ then
return "no combined rule matched"
end
}
print(describe_pair(3, 4))
print(describe_pair(3, -4))
print(describe_pair("a", "b"))
print(describe_pair(-3, -4))
# Unquoted glob patterns reuse the existing regex engine (glob_match, in
# self_hosting/lib/regex.patlang) rather than a bespoke matcher.
fn tag_file(name) {
match name do
case *.patlang then
return "PatLang source"
case *.md then
return "documentation"
case test_* then
return "test artifact"
case _ then
return "unrecognized"
end
}
print(tag_file("lower.patlang"))
print(tag_file("README.md"))
print(tag_file("test_output.log"))
print(tag_file("photo.jpg"))
# PatLang has no static type system to check exhaustiveness against, so a
# scrutinee with no matching arm -- and no wildcard `_` catch-all -- is a
# genuine runtime error, not a silent no-op. Uncomment the line below to
# see it fire (deliberately left commented so the rest of this demo's
# output stays visible):
# print(classify_strict(999))
fn classify_strict(x) {
match x do
case 1 then
return "one"
case 2 then
return "two"
end
}
print("strict(1) = " + classify_strict(1))
print("about to fall through classify_strict's only two cases...")
print(classify_strict(99))
(not run yet)
Native run on the build machine:
Output (click to expand)
zero a greeting big (250) just a number: 7 success: 42 failed after 3000ms (timeout) failed: connection refused not a recognized result shape negative: -5 way too big: 5000 in range: 42 string: "hello" bigint: 15511210043330985984000000 list of 3 item(s) boolean: true number: 7 both positive: 3, 4 mixed signs: 3, -4 both strings: a, b no combined rule matched PatLang source documentation test artifact unrecognized strict(1) = one about to fall through classify_strict's only two cases... IR runtime error: contract violation: assertion failed in classify_strict(): match: no case matched the scrutinee value