Lcl · Core · Packages · Libraries


Regex

namespace Regex

lcl-regex

POSIX extended regular expressions for Lcl.

Requirements

Build

cmake -S . -B build -DLCL_BUILD_REGEX=ON
cmake --build build

Usage

Always brace-quote patterns ({^[0-9]+$}, never "^[0-9]+$"). Inside double quotes Lcl runs [...] through command substitution, so a quoted character class such as [0-9] is executed as the command 0-9 and the call fails with unknown command: 0-9. Braces are Lcl’s literal string syntax; every example below uses them.

All patterns use POSIX extended syntax (REG_EXTENDED); matching is case-sensitive and byte-oriented, and every offset the package reports is a 0-based byte offset with an exclusive end. Every command that takes a pattern argument accepts either a pattern string or a compiled handle from Regex::compile; compile when the same pattern will run repeatedly. An invalid pattern raises invalid regex pattern wherever it is used.

;; One-shot match
if [Regex::match {^[0-9]+$} $input] {
    puts "input is digits"
}

;; Compile once, match many times
let re [Regex::compile {^https?://}]
foreach url $urls {
    if [Regex::match $re $url] {
        puts "web URL: $url"
    }
}
;; No explicit free: the compiled regex is finalized when $re dies.

;; Captures: element 0 is the whole match, 1..n the groups
let caps [Regex::captures {(issue|bug)-([0-9]+)} "see issue-42"]
;; caps = ("issue-42" "issue" "42"), or () when no match

;; Rules as data: a pattern string is already data
let rules (
    #{name "Issue reference" pattern {(issue|bug)-([0-9]+)} action open_issue}
)

;; Replace with group references
Regex::replace {(bug)-([0-9]+)} {\2 (\1)} "bug-42"   ;; -> "42 (bug)"

Iterating over every match

Regex::search returns offset pairs and resumes from an optional from offset, so a loop can walk a string without slicing it. Progress rule: continue from end, or from start + 1 when the match was zero-width.

let re [Regex::compile {[0-9]+}]
var pos 0
while {[<= $pos [len $text]]} {
    let m [Regex::search $re $text $pos]
    if [== [len $m] 0] { break }
    let span [get $m 0]
    puts "match at $span"
    let so [get $span 0]
    let eo [get $span 1]
    if [> $eo $so] { set! pos $eo } else { set! pos [+ $so 1] }
}

proc Regex::compile pattern

Compile pattern into a reusable opaque handle.

The handle prints as <opaque:regex_t> and can be passed wherever a pattern is accepted. It is reference-counted: the underlying regex_t is freed by the finalizer when the last reference drops, so there is no explicit free.

Examples:

>> let re [Regex::compile {^(issue|bug)-[0-9]+$}]
>> type $re
"opaque"
>> Regex::match $re "issue-42"
1
>> Regex::match $re "task-9"
0
>> Regex::compile {[unclosed}
!! invalid regex pattern

proc Regex::regcomp pattern

Alias of Regex::compile (POSIX-flavoured name).

Examples:

>> Regex::regexec [Regex::regcomp {ab+c}] "abbbc"
1

proc Regex::regexec regex string

Match a compiled handle against string; returns 1 or 0.

Unlike the other commands this one accepts only a handle from Regex::compile, never a pattern string. Prefer Regex::match, which accepts both.

Examples:

>> Regex::regexec [Regex::compile {ab+c}] "xabbbcx"
1
>> Regex::regexec [Regex::compile {ab+c}] "ac"
0
>> Regex::regexec {ab+c} "abc"
!! not a compiled regex

proc Regex::match pattern string

Return 1 if pattern matches anywhere in string, else 0.

Anchor with ^ and $ to match the whole string.

Examples:

>> Regex::match {^h.llo$} "hello"
1
>> Regex::match {^h.llo$} "hallo!"
0
>> Regex::match {[0-9]+} "abc123"
1
>> Regex::match {^[[:alpha:]]+$} "hello"
1
>> Regex::match {abc} "ABC"
0
>> Regex::match {(} "x"
!! invalid regex pattern

proc Regex::find pattern string

Return (start end) byte offsets of the first match, or ().

end is exclusive. A zero-width match reports start equal to end.

Examples:

>> Regex::find {[0-9]+} "ab123cd"
(2 5)
>> Regex::find {[0-9]+} "abcdef"
()
>> Regex::find [Regex::compile {b+}] "abbbc"
(1 4)
>> Regex::find {x*} "abc"
(0 0)

proc Regex::captures pattern string

Return the texts of the first match: the whole match, then each capture group; () when nothing matches.

Groups are numbered by their opening parenthesis, left to right. An optional group that did not participate in the match contributes an empty string. Use Regex::search to get offsets instead of copies.

Examples:

>> Regex::captures {(issue|bug)-([0-9]+)} "see issue-42 here"
("issue-42" "issue" "42")
>> Regex::captures {^([0-9]{4})-([0-9]{2})-([0-9]{2})$} "2026-08-29"
("2026-08-29" "2026" "08" "29")
>> Regex::captures {((a)(b))} "ab"
("ab" "ab" "a" "b")
>> Regex::captures {a(x)?b} "ab"
("ab" "")
>> Regex::captures {([0-9]+)} "abc"
()
>> let re [Regex::compile {([a-z]+)://(.+)}]
>> Regex::captures $re "https://example.com"
("https://example.com" "https" "example.com")

proc Regex::search pattern string (from 0)

Return offset pairs for the first match at or after byte from: the whole match, then each capture group, as (start end) pairs; () when nothing matches.

Offsets are absolute (relative to the start of string, not to from) and ends are exclusive; no text is copied. A group that did not participate is (-1 -1). When from is greater than 0 the position is not treated as a line start, so ^ cannot match there; from equal to the string length still lets $ match. from past the end returns (); a negative or non-integer from is an error. See the iteration recipe in the overview for walking every match.

Examples:

>> Regex::search {(issue|bug)-([0-9]+)} "see issue-42 here"
((4 12) (4 9) (10 12))
>> Regex::search {[0-9]+} "a1b22c333" 2
((3 5))
>> Regex::search {[0-9]+} "a1b22c333" 6
((6 9))
>> Regex::search {[0-9]+} "abc"
()
>> Regex::search {a(x)?b} "ab"
((0 2) (-1 -1))
>> Regex::search {^[0-9]+} "1a2b" 2
()
>> Regex::search {$} "abc" 3
((3 3))
>> Regex::search {x*} "ab" 1
((1 1))
>> Regex::search {a} "abc" 10
()
>> Regex::search {a} "abc" -1
!! from must be >= 0

proc Regex::find_all pattern string

Return every non-overlapping whole-match text, left to right.

After the first match, later positions are not line starts, so ^ matches at most once. A zero-width match contributes an empty string and scanning advances one byte.

Examples:

>> Regex::find_all {[0-9]+} "a1b22c333"
("1" "22" "333")
>> Regex::find_all {[a-z]+} "the quick brown"
("the" "quick" "brown")
>> Regex::find_all {[0-9]+} "abc"
()
>> Regex::find_all {^[0-9]+} "1a2b"
("1")
>> Regex::find_all {x*} "ab"
("" "" "")

proc Regex::replace pattern replacement string

Return string with every match replaced by replacement.

In the replacement, \0 expands to the whole match and \1..\9 to the capture groups (a group that did not participate, or does not exist, expands to nothing); \\ is a literal backslash, and any other backslash sequence is kept as is. Brace-quote the replacement so Lcl leaves the backslashes alone. A zero-width match inserts the replacement between bytes.

Examples:

>> Regex::replace {[0-9]+} "N" "a1b22c"
"aNbNc"
>> Regex::replace {(bug)-([0-9]+)} {\2:\1} "bug-42"
"42:bug"
>> Regex::replace {[0-9]+} {<\0>} "a12b"
"a<12>b"
>> Regex::replace {[ ]+} " " "a   b    c"
"a b c"
>> Regex::replace {[0-9]+} "N" "abc"
"abc"
>> Regex::replace {a(x)?b} {<\1>} "ab"
"<>"
>> Regex::replace {a} {\\} "xax"
"x\\x"
>> Regex::replace {x*} "-" "ab"
"-a-b-"

proc Regex::split pattern string

Split string at every match; returns the substrings between matches.

A match at the start or end contributes an empty leading or trailing field, and adjacent matches produce empty fields between them. Zero-width matches are not split points. With no match the result is the whole string as one element.

Examples:

>> Regex::split {,[ ]*} "a, b,c"
("a" "b" "c")
>> Regex::split {[[:space:]]+} "one two   three"
("one" "two" "three")
>> Regex::split {,} "abc"
("abc")
>> Regex::split {,} ",a,"
("" "a" "")
>> Regex::split {,} "a,,b"
("a" "" "b")
>> Regex::split {,} ""
("")