Skip to main content

Text

local Regex, Text, Template = Quark.Regex, Quark.Text, Quark.Template

local command = Regex.Match("^/(\\w+)\\s*(.*)$", message)
local player = Text.Closest(name, player_names)
local clean = Text.WordFilter(bad_words):Censor(message)
local motd = Template.Render("Welcome {{ name }}, {{ online }} players online", { name = name, online = count })

Three modules for working with text: Regex for regular expressions, Text for Unicode aware string helpers, fuzzy matching and word filters, and Template for text templates.

Regex​

Regular expressions with the syntax of the Rust regex crate, which is close to Perl and PCRE. Matching runs in linear time, so patterns and text from players can never hang the server. In exchange look-around ((?=...), (?<=...)) and backreferences (\1) are not supported.

Regex.New(pattern, options?) compiles a pattern and returns a regex, or nil, message for invalid patterns. Keep compiled regexes around when they are used often.

Option
CaseInsensitiveignore case, Unicode aware
Multiline^ and $ match at line starts and ends
DotAll. also matches line breaks
Extendedignore whitespace and allow # comments in the pattern
Ascii\w, \d, \s and case folding only cover ASCII instead of Unicode
Method
:IsMatch(text, start?)whether the pattern occurs
:Find(text, start?)start, end, text of the first match like string.find, or nil
:Match(text, start?)the first match, or nil
:MatchAll(text, limit?)list of matches
:Replace(text, replacement, limit?)new text and the number of replacements
:Split(text, limit?)list of the parts between matches, at most limit parts
:Count(text)number of matches
:GetPattern(), :GetGroupCount(), :GetGroupNames()

Positions are byte positions starting at 1 like in the Lua string library, negative start values count from the end. A match is a table:

Field
Textthe matched text
Start, Endposition of the match, End is inclusive
Groupscaptured groups by number, false for groups that did not take part
Namedcaptured named groups (?P<name>...) or (?<name>...)
local date = Regex.New("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})")
local found = date:Match("Released on 2026-10-25")
Console.Log(found.Named.year, found.Groups[2], found.Start)

The replacement is a string with $1, ${1} or ${name} for groups and $$ for a dollar sign, a function that receives the match and returns the new text (nil keeps the match), or a table that maps matched texts to replacements.

Regex.Replace("(\\w+)@(\\w+)", "admin@server", "$2: $1") -- "server: admin"
Regex.Replace("\\d+", "a1b2", function(found) return found.Text * 2 end) -- "a2b4"

The module also has Regex.IsMatch, Find, Match, MatchAll, Replace and Split taking the pattern as first argument. They cache the last compiled patterns and raise an error for invalid ones. Regex.Escape(text) escapes all special characters, Regex.IsValid(pattern) checks a pattern.

Text​

Lua string functions work on bytes and only know ASCII. These helpers understand Unicode: lengths count characters as people see them (grapheme clusters, so "é" and "👨‍👩‍👧" count as 1), and case conversion works for all languages.

Function
Upper(text), Lower(text)Unicode case conversion: Upper("straße") is "STRASSE"
Capitalize(text)first letter of every word upper case, the rest lower case
Length(text)number of grapheme clusters
Sub(text, start, end?)part of the text by grapheme positions, negative positions count from the end
Reverse(text)reversed by graphemes, keeps accents and emoji intact
Truncate(text, length, suffix?)shortens to length graphemes including the suffix ("...")
Graphemes(text), Words(text)list of grapheme clusters / words without punctuation
Split(text, separator, options?)split by a plain separator. Options Trim, RemoveEmpty, Limit. An empty separator splits into graphemes
Trim(text)removes Unicode whitespace at both ends
Pad(text, width, options?)pads to width graphemes. Options Align (Left, Right, Center) and Char (" ")
Wrap(text, width)list of lines of at most width graphemes, breaking at spaces and splitting longer words
Normalize(text, form?)Unicode normalization NFC (default), NFD, NFKC, NFKD
ToAscii(text)transliteration to ASCII: "Crème Brûlée" becomes "Creme Brulee"
Slugify(text, separator?)URL and file name friendly: "Hello, Wörld!" becomes "hello-world"
IsValid(text)whether the text is valid UTF-8

Fuzzy matching​

Function
Levenshtein(a, b), DamerauLevenshtein(a, b)number of edits between two texts
Similarity(a, b, algorithm?)similarity from 0 to 1 with JaroWinkler (default), Jaro, Levenshtein, DamerauLevenshtein or SorensenDice
FuzzyFind(query, list, options?)matching entries of the list, best first
Closest(query, list, options?)the best matching entry and its score, or nil

FuzzyFind returns { Item, Score, Index } entries. Exact matches score 1, then texts starting with the query, then texts containing it, then similar texts, so typing the start of a player name is enough. It ignores case unless CaseSensitive is set.

Option
Thresholdminimum score from 0 to 1 (0.5)
Limitmaximum number of results
Keyfield to compare when the list contains tables
CaseSensitivefalse
local commands = { { Name = "teleport" }, { Name = "kick" }, { Name = "ban" } }
local command = Text.Closest("telport", commands, { Key = "Name" })
if command then
Chat.SendMessage(player, "Did you mean /" .. command.Name .. "?")
end

Word filters​

Text.WordFilter(words, options?) finds words from a list in text, for chat filters and name checks. Matching ignores case, accents ("ä" matches "a") and common character substitutions ("4" for "a", "3" for "e", "0" for "o", "$" for "s" and more), and treats repeated letters as one ("baaad" matches "bad"). Entries with spaces match across any whitespace or punctuation between the words.

Option
WholeWordsonly match complete words, so "ass" does not match "class" (true)
Leetspeakmatch character substitutions (true)
Repeatstreat repeated letters as one (true)
Method
:Contains(text)whether any word occurs
:Find(text)list of { Word, Text, Start, End }, Word is the list entry, Text how it was written
:Censor(text, replacement?)text with every match replaced and the number of matches. A single character ("*") replaces every character of the match, longer replacements the whole match
:Add(words), :Remove(words), :GetWords()change the list
local filter = Text.WordFilter({ "darn", "heck" })
local censored = filter:Censor("D4rn it, what the h3ck") -- "**** it, what the ****"

Template​

Text templates with the Jinja syntax, powered by MiniJinja: {{ value }} outputs values, {% if %} and {% for %} control the output, filters like {{ name | upper }} transform values and templates can extend each other.

Template.Render(source, context?, options?) renders a template once and returns the text, or nil, message for syntax and render errors. The context is a table, functions and other values that cannot be represented are left out.

local text = Template.Render([[
Top players:
{% for player in players %}
{{ loop.index }}. {{ player.Name }} - {{ player.Kills }} kills
{% endfor %}
]], { players = top }, { TrimBlocks = true })
Option
AutoEscapeNone (default), Html or Json escaping of output values, use Html for web pages
Undefinedhow missing values behave: Lenient renders them empty (default), Chainable also allows missing.field, Strict makes them an error
TrimBlocks, LstripBlocks, KeepTrailingNewlinewhitespace handling around blocks
Globalsvalues available to every template
Fuellimit for the work one render may do, protects against runaway templates (10000000)
Filters, Functions, TestsLua functions usable in templates, see below

Template.New(options?) keeps named templates for {% extends %}, {% include %} and {% import %}. It takes the same options and Templates, a table of names and sources:

local pages = Template.New({
AutoEscape = "Html",
Templates = {
layout = "<html><body>{% block body %}{% endblock %}</body></html>",
status = "{% extends 'layout' %}{% block body %}{{ online }} players online{% endblock %}",
},
})

local html = pages:Render("status", { online = #Player.GetAll() })
Method
:Render(name, context?)renders a named template
:RenderString(source, context?)renders a source that can use the named templates
:Add(name, source)adds or replaces a template, returns true or nil, message
:Remove(name), :Has(name), :GetNames()
:AddFilter(name, function), :AddFunction(name, function), :AddTest(name, function)add Lua functions

Lua functions in templates​

Filters transform a value ({{ price | money("EUR") }}), functions are called by name ({{ player(7).Name }}) and tests check a value ({% if level is max %}). They receive their arguments as Lua values, the filtered value first, and their results go back into the template. Errors raised in them end the render with nil, message.

local templates = Quark.Template.New({
Filters = {
money = function(value, currency) return string.format("%.2f %s", value, currency or "$") end,
},
Functions = {
online = function() return #Player.GetAll() end,
},
Tests = {
admin = function(name) return admins[name] == true end,
},
})
templates:Add("motd", "{{ online() }} players online{% if name is admin %}, welcome back admin{% endif %}")

Lua functions run on the game thread while the template renders, keep them fast.