# regex

> Find, capture, replace and split text with regular expressions, validate common formats, and reshape text with ready-made helpers.

This is the Markdown version of https://osl-lang.org/packages/regex. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
import "std:regex"
```

A regular expression (regex) is a small pattern language for text: "one or more digits", "a word followed by an @", "anything between two quotes". `std:regex` runs these patterns for you. Reach for it when plain `contains` and `split` aren't precise enough: pulling numbers out of a log line, checking that input has the right shape, or rewriting text by rule.

The syntax is RE2, the same as Go. It supports groups, classes, repetition and flags, and it runs in time proportional to the text, so a bad pattern can't hang your program. It has no backreferences or lookahead.

This page also covers the ready-made helpers (`isValidEmail`, `slugify`, `extractURLs`) that save you writing the pattern at all.

## Writing patterns in strings

A pattern is a normal string. Because `\` starts an escape in an OSL string, write `\\d` to get the regex `\d` (a digit). Here are the building blocks used in this page:

| Pattern | Matches |
| --- | --- |
| `\\d` `\\w` `\\s` | a digit, a word character, a space |
| `.` | any character |
| `+` `*` `?` | one or more, zero or more, optional |
| `{4}` `{2,3}` | exactly 4, or 2 to 3 |
| `[abc]` `[a-z]` `[^0-9]` | a character from a set, a range, or not in a set |
| `^` `$` | start and end of the text |
| `( )` | a group you can capture |
| `a|b` | a or b |

## Does it match?

`regex.match(pattern, text)` is true when the pattern matches anywhere in the text. Add `^` and `$` to require that it matches the whole text:

```osl
import "std:regex"

log regex.match("\\d+", "order 66")
log regex.match("^\\d+$", "order 66")
log regex.match("^\\d+$", "66")
```

Output:

```text
true
false
true
```

## Finding text

`findFirst` returns the first match, or `null` when there is none, so `??` gives a default. `findAll` returns every match, and `count` says how many:

```osl
import "std:regex"

string text = "abc 123 def 45"
log regex.findFirst("\\d+", text)
log regex.findFirst("\\d+", "no digits") ?? "nothing"
log regex.findAll("\\d+", text)
log regex.count("a", "banana")
```

Output:

```text
123
nothing
["123","45"]
3
```

Matches don't overlap, and they are found left to right.

## Capturing parts

Brackets make a group. `findSubmatch` returns the whole match, then what each group captured. It returns an empty array when nothing matches:

```osl
import "std:regex"

auto parts = regex.findSubmatch("(\\d{4})-(\\d{2})-(\\d{2})", "due on 2024-03-15 sharp")
log parts
log parts[1]
log parts[2] parts[3] parts[4]
log regex.findSubmatch("xyz", "nothing here").len
```

Output:

```text
["2024-03-15","2024","03","15"]
2024-03-15
2024 03 15
0
```

Index 1 is the whole match and the groups follow, because arrays count from 1. Check `parts.len` before reading groups of text that might not match.

## Replacing

`replace(pattern, text, replacement)` replaces every match. In the replacement, `$1`, `$2` and so on insert the captured groups, which lets you reorder text:

```osl
import "std:regex"

log regex.replace("\\s+", "too    many \t spaces", " ")
log regex.replace("(\\w+)@(\\w+)", "ada@home", "$2:$1")
log regex.replace("(\\d{4})-(\\d{2})-(\\d{2})", "2024-03-15", "$3/$2/$1")
```

Output:

```text
too many spaces
home:ada
15/03/2024
```

When a fixed replacement isn't enough, `replaceFunc` calls a function for each match and uses what it returns:

```osl
import "std:regex"

auto doubled = regex.replaceFunc("\\d+", "3 apples and 12 pears", n -> "" ++ (n.as<int> * 2))
log doubled
```

Output:

```text
6 apples and 24 pears
```

## Splitting

`split` cuts text around every match, which handles separators that vary:

```osl
import "std:regex"

log regex.split("[,;]\\s*", "a, b;c,   d")
log regex.split("\\s+", "split   on any   whitespace")
```

Output:

```text
["a","b","c","d"]
["split","on","any","whitespace"]
```

## Compiled patterns

The functions above read the pattern from a string on each call. When you use one pattern many times, or want to check it up front, compile it with `regex.compile`. It returns a [result](/packages/result.md), failing with a `ValueError` if the pattern is invalid:

```osl
import "std:regex"

auto bad = regex.compile("(unclosed")
log bad.isErr()
log bad.unwrapErr().kind

auto date = regex.compile("(?P<year>\\d{4})-(?P<month>\\d{2})").unwrap()
log date.match("due 2024-03")
log date.findSubmatch("due 2024-03")
log date.groupNames()
log date.count("2024-01 and 2025-02")
log date.replace("2024-03", "$month/$year")
log date.source()
```

Output:

```text
true
ValueError
true
["2024-03","2024","03"]
["year","month"]
2
03/2024
(?P<year>\d{4})-(?P<month>\d{2})
```

A `*regex.Pattern` has the same operations as the functions (`match`, `findFirst`, `findAll`, `findSubmatch`, `count`, `replace`, `replaceFunc`, `split`), without the pattern argument. Named groups, written `(?P<name>...)`, can be used in the replacement as `$name`.

`regex.isValid(pattern)` is a quick check when you only want to know if text is a legal pattern, for example one a user typed.

## Flags

Pass options to `compile` for case-insensitive or multi-line matching, or put the flags in the pattern as `(?i)`:

```osl
import "std:regex"

auto ci = regex.compile("hello", {ignoreCase: true}).unwrap()
log ci.replace("Hello HELLO hello", "x")

auto lines = regex.compile("^\\w+", {multiline: true}).unwrap()
log lines.findAll("one two\nthree four")

log regex.replace("(?i)hello", "Hello HELLO", "x")
```

Output:

```text
x x x
["one","three"]
x x
```

The options are `ignoreCase`, `multiline` (`^` and `$` match at each line) and `dotAll` (`.` also matches a new line).

## Escaping user text

If you build a pattern from text you don't control, escape it first, so characters like `.` and `*` mean themselves:

```osl
import "std:regex"

string needle = "a.b*c"
log regex.escape(needle)
log regex.match(regex.escape(needle), "xx a.b*c xx")
log regex.match(needle, "xx aXbbbc xx")
```

Output:

```text
a\.b\*c
true
true
```

The third line shows the danger: unescaped, `a.b*c` is a pattern that matches `aXbbbc`.

## Validating common formats

Common checks come ready-made. They are practical sanity checks, not full standards: `isValidEmail` accepts anything shaped like `name@domain.tld`, and the only real proof an address works is to send mail to it.

```osl
import "std:regex"

log regex.isValidEmail("ada@example.com") regex.isValidEmail("not an email")
log regex.isValidURL("https://osl-lang.org") regex.isValidURL("osl-lang.org")
log regex.isValidIPv4("192.168.0.1") regex.isValidIPv6("::1")
log regex.isUUID("123e4567-e89b-12d3-a456-426614174000")
log regex.isNumeric("123") regex.isNumeric("12.5")
log regex.isAlpha("abc") regex.isAlphanumeric("abc123") regex.isHexadecimal("ff09")
```

Output:

```text
true false
true false
true true
true
true false
true true true
```

The rest of the family: `isValidPhone`, `isBase64`.

## Extracting things from text

The `extract` functions scan free text and return what they find, in order:

```osl
import "std:regex"

string post = [
  "Thanks @ada! Docs at https://osl-lang.org/docs,",
  "mail bo@example.com #osl #docs, 3 or 12 tips."
].join(" ")
log regex.extractMentions(post)
log regex.extractHashtags(post)
log regex.extractURLs(post)
log regex.extractEmails(post)
log regex.extractNumbers(post)
log regex.extractWords("Hi, there!")
```

Output:

```text
["@ada"]
["#osl","#docs"]
["https://osl-lang.org/docs,"]
["bo@example.com"]
["3","12"]
["Hi","there"]
```

Two things to notice. `extractMentions` skipped the `@` inside `bo@example.com`: a mention has to start the text or follow a character that can't be part of a name. And the URL kept its trailing comma, because the helper takes everything up to the next space. Trim the results yourself if your text has such cases, or write your own pattern with `findAll`.

## Reshaping text

Case and layout helpers cover everyday renaming and cleanup:

```osl
import "std:regex"

log regex.slugify("Hello World_Foo!")
log regex.camelize("hello_world") regex.snakeCase("helloWorld") regex.kebabCase("helloWorld")
log regex.truncate("A long sentence about regex", 11, "...")
log regex.stripTags("<p>Hello <b>there</b></p>")
log regex.stripWhitespace("  tidy    up   this  ")
log regex.maskEmail("ada@example.com") regex.maskPhoneNumber("5551234567")
log regex.wordCount("one two three") regex.sentenceCount("One. Two! Three?")
```

Output:

```text
hello-world-foo
helloWorld hello_world hello-world
A long sent...
Hello there
tidy up this
a***@example.com ******4567
3 3
```

`slugify` is what you want for URLs from titles. `stripTags` is a cleanup aid, not a security boundary: escape or sanitise HTML properly before showing untrusted text in a page.

## Example: parsing log lines

Putting it together: compile once, capture parts, and skip lines that don't fit.

```osl
import "std:regex"

auto line = regex.compile("^(\\w+) (\\d{3}) (\\d+)ms$").unwrap()
string[] logs = ["GET 200 12ms", "POST 500 340ms", "garbage", "GET 404 8ms"]

int slow = 0
for entry in logs (
  auto parts = line.findSubmatch(entry)
  if parts.len == 0 ( continue )
  if parts[4].as<int> > 100 ( slow++ )
  log parts[2] parts[3]
)
log "slow requests:" slow
```

Output:

```text
GET 200
POST 500
GET 404
slow requests: 1
```

**Quiz.** What does this print?

```osl
log regex.match("^\\d+$", "order 66")
```

- [ ] `true`, because the text contains digits
- [x] `false`, because `^` and `$` require the whole text to be digits
- [ ] `66`

Answer: Without the anchors, `regex.match` is true when the pattern matches anywhere in the text.

### Exercise: Reformat phone numbers

Rewrite every number written as `5551234567` into `(555) 123-4567`, leaving the rest of the sentence alone.

Starting code:

```osl
import "std:regex"

string text = "Call 5551234567 or 5559876543 today."

log text
```

It should print:

```text
Call (555) 123-4567 or (555) 987-6543 today.
```

Hint 1: Capture three groups: three digits, three digits, four digits.

Hint 2: Use `$1`, `$2` and `$3` in the replacement.

A solution:

```osl
import "std:regex"

string text = "Call 5551234567 or 5559876543 today."

log regex.replace("(\\d{3})(\\d{3})(\\d{4})", text, "($1) $2-$3")
```

## API reference

## API reference

Every member of `std:regex`, generated from the source of OSL.

### Functions

#### `regex.camelize(string text) string`

camelize turns snake_case text into camelCase, as in "hello_world" to
"helloWorld".

#### `regex.charCount(string text, boolean includeSpaces) int`

charCount returns how many characters text has, leaving out spaces
unless includeSpaces is true.

#### `regex.compile(string pattern, object? options) result<*regex.Pattern, error>`

compile compiles a pattern into a *regex.Pattern for repeated use. Use
it for patterns that come from outside the program: an invalid pattern
fails with ValueError instead of throwing. The syntax is RE2, as in Go:
no backreferences or lookaround, and matching always takes linear time.
Options: ignoreCase, multiline (^ and $ match at line breaks) and dotAll
(. matches a line break), all false by default.

#### `regex.count(string pattern, string text) int`

count returns how many non-overlapping matches of pattern text has.
Throws ValueError for an invalid pattern.

#### `regex.escape(string text) string`

escape returns text with every regular expression metacharacter
escaped, so it matches itself literally inside a pattern.

#### `regex.extractEmail(any text) string`

**Deprecated.** Deprecated: use regex.extractEmails(text), which returns every address.

extractEmail returns the first email address in text, or "".

#### `regex.extractEmails(string text) string[]`

extractEmails returns every email address in text, in order.

#### `regex.extractHashtags(string text) string[]`

extractHashtags returns every #hashtag in text, including the #. A # in
the middle of a word, as in a URL's page#section, starts no hashtag.

#### `regex.extractMentions(string text) string[]`

extractMentions returns every @mention in text, including the @. An @ in
the middle of a word, as in the email address a@example.com, starts no
mention.

#### `regex.extractNumbers(string text) string[]`

extractNumbers returns every run of digits in text, as strings.

#### `regex.extractURLs(string text) string[]`

extractURLs returns every http and https URL in text, in order.

#### `regex.extractWords(string text) string[]`

extractWords returns every word in text: a run of letters in any
language, which may contain an apostrophe, as in "don't" or "café".

#### `regex.find(any pattern, any text) string`

**Deprecated.** Deprecated: use regex.findFirst(pattern, text) instead, which returns null when nothing matches and throws ValueError for an invalid pattern.

find returns the first match of pattern in text, or "" when there is
none or the pattern is invalid.

#### `regex.findAll(string pattern, string text) string[]`

findAll returns every non-overlapping match of pattern in text, in
order, or an empty array when there is none. Throws ValueError for an
invalid pattern.

#### `regex.findFirst(string pattern, string text) string?`

findFirst returns the first match of pattern in text, or null when
there is none. Throws ValueError for an invalid pattern.

#### `regex.findSubmatch(string pattern, string text) string[]`

findSubmatch returns the first match of pattern in text followed by the
text of each capture group, "" for a group that didn't take part, or an
empty array when nothing matches. Throws ValueError for an invalid
pattern.

#### `regex.highlight(any text, any pattern, any color) string`

**Deprecated.** Deprecated: use regex.replace(pattern, text, "\x1b[31m$0\x1b[0m") with the color code you want.

highlight wraps each match of pattern in text with an ANSI color code,
returning text unchanged for an unknown color or invalid pattern.

#### `regex.isAlpha(string text) boolean`

isAlpha reports whether text is one or more ASCII letters.

#### `regex.isAlphanumeric(string text) boolean`

isAlphanumeric reports whether text is one or more ASCII letters and
digits.

#### `regex.isBase64(string text) boolean`

isBase64 reports whether text is standard padded base64, ignoring line
breaks. "" counts as valid.

#### `regex.isHexadecimal(string text) boolean`

isHexadecimal reports whether text is one or more hex digits, with an
optional 0x prefix.

#### `regex.isNumeric(string text) boolean`

isNumeric reports whether text is one or more digits 0 to 9, with no
sign or decimal point.

#### `regex.isUUID(string text) boolean`

isUUID reports whether text is a UUID such as
3b241101-e2bb-4255-8caf-4136c566a962, in either case.

#### `regex.isValid(string pattern) boolean`

isValid reports whether pattern is a valid regular expression.

#### `regex.isValidEmail(string email) boolean`

isValidEmail reports whether email looks like name@domain.tld. It is a
shape check, not proof that the address exists.

#### `regex.isValidIPv4(string ip) boolean`

isValidIPv4 reports whether ip is an IPv4 address such as 192.168.0.1.

#### `regex.isValidIPv6(string ip) boolean`

isValidIPv6 reports whether ip is an IPv6 address such as ::1.

#### `regex.isValidPhone(string phone) boolean`

isValidPhone reports whether phone is 10 to 14 digits with an optional
leading +, ignoring spaces, dashes and parentheses.

#### `regex.isValidURL(string url) boolean`

isValidURL reports whether url is an http or https URL with a domain
name, such as https://example.com/path.

#### `regex.kebabCase(string text) string`

kebabCase turns camelCase or snake_case text into kebab-case, as in
"helloWorld" to "hello-world".

#### `regex.maskEmail(string email) string`

maskEmail hides an email address's name after its first character, as
in "john@example.com" to "j***@example.com".

#### `regex.maskPhoneNumber(string phone) string`

maskPhoneNumber replaces every character but the last four with *.

#### `regex.match(string pattern, string text) boolean`

match reports whether pattern matches anywhere in text. Throws
ValueError for an invalid pattern; use regex.compile for patterns from
outside the program.

#### `regex.replace(string pattern, string text, string replacement) string`

replace returns text with every match of pattern replaced by
replacement, in which $1 or ${name} stands for a capture group and $$
for a literal $. Throws ValueError for an invalid pattern.

#### `regex.replaceFunc(string pattern, string text, any fn) string`

replaceFunc returns text with every match of pattern replaced by what fn
returns when called with the matched text. Throws ValueError for an
invalid pattern.

#### `regex.sentenceCount(string text) int`

sentenceCount returns how many sentences text has: the pieces between
., ! and ? that contain more than whitespace.

#### `regex.slugify(string text) string`

slugify lowercases text, turns spaces and underscores into dashes, and
drops everything but ASCII letters, digits and single dashes, as in
"Hello World!" to "hello-world".

#### `regex.snakeCase(string text) string`

snakeCase turns camelCase or kebab_case text into snake_case, as in
"helloWorld" to "hello_world".

#### `regex.split(string pattern, string text) string[]`

split splits text around every match of pattern; text without a match
comes back whole as the only item. Throws ValueError for an invalid
pattern.

#### `regex.stripTags(string text) string`

stripTags removes everything that looks like an HTML tag from text. It
is not an HTML sanitizer: don't rely on it to make input safe.

#### `regex.stripWhitespace(string text) string`

stripWhitespace trims text and collapses each run of whitespace inside
it to a single space.

#### `regex.test(any pattern) boolean`

**Deprecated.** Deprecated: use regex.isValid(pattern) instead.

test reports whether pattern is a valid regular expression.

#### `regex.truncate(string text, int length, string suffix) string`

truncate shortens text to its first length characters and appends
suffix, or returns text unchanged when it is no longer than length.

#### `regex.wordCount(string text) int`

wordCount returns how many words regex.extractWords finds in text.

### `*regex.Pattern`

#### `pattern.count(string text) int`

count returns how many non-overlapping matches text has.

#### `pattern.findAll(string text) string[]`

findAll returns every non-overlapping match in text, in order.

#### `pattern.findFirst(string text) string?`

findFirst returns the first match in text, or null when there is none.

#### `pattern.findSubmatch(string text) string[]`

findSubmatch returns the first match in text followed by the text of
each capture group, or an empty array when nothing matches.

#### `pattern.groupNames() string[]`

groupNames returns the names of the pattern's capture groups in order,
"" for an unnamed group.

#### `pattern.match(string text) boolean`

match reports whether the pattern matches anywhere in text.

#### `pattern.replace(string text, string replacement) string`

replace returns text with every match replaced by replacement, in which
$1 or ${name} stands for a capture group and $$ for a literal $.

#### `pattern.replaceFunc(string text, any fn) string`

replaceFunc returns text with every match replaced by what fn returns
when called with the matched text.

#### `pattern.source() string`

source returns the pattern's source text, including any (?flags) the
options added.

#### `pattern.split(string text) string[]`

split splits text around every match; text without a match comes back
whole as the only item.
