--!pw caps= -- @pw/util: small deterministic helpers the other stdlib modules share. Nothing here iterates a -- table in hash order: the stdlib lint refuses `pairs`, `next` and `for k in t`. local util = {} --- The keys of `t`, sorted (numbers first, then strings by code point). function util.keys(t: { [any]: any }): { any } local keys = {} for key in pw.sorted(t) do table.insert(keys, key) end return keys end --- The number of entries in `t`, counting every key. function util.count(t: { [any]: any }): number local n = 0 for _ in pw.sorted(t) do n += 1 end return n end --- Bytes in the .NET white-space character (`char.IsWhiteSpace`) at byte `i` of `s`, or nil. local function white_at(s: string, i: number): number? local b = string.byte(s, i) if b == nil then return nil elseif (b >= 0x09 and b <= 0x0D) or b == 0x20 then return 1 end local c, d = string.byte(s, i + 1, i + 2) if b == 0xC2 then return if c == 0x85 or c == 0xA0 then 2 else nil elseif b == 0xE1 then return if c == 0x9A and d == 0x80 then 3 else nil elseif b == 0xE2 and c == 0x80 and d ~= nil then return if (d >= 0x80 and d <= 0x8A) or d == 0xA8 or d == 0xA9 or d == 0xAF then 3 else nil elseif b == 0xE2 then return if c == 0x81 and d == 0x9F then 3 else nil elseif b == 0xE3 then return if c == 0x80 and d == 0x80 then 3 else nil end return nil end --- `s` without the white space it starts with, as .NET's `TrimStart()` (Unicode white space). function util.trim_start(s: string): string local i = 1 while true do local n = white_at(s, i) if n == nil then break end i += n end return string.sub(s, i) end --- `s` without the white space it ends with, as .NET's `TrimEnd()`. function util.trim_end(s: string): string local j = #s while j > 0 do local begin = j while begin > 1 do local b = string.byte(s, begin) :: number if b < 0x80 or b >= 0xC0 then break end begin -= 1 end local n = white_at(s, begin) if n == nil or begin + n - 1 ~= j then break end j = begin - 1 end return string.sub(s, 1, j) end --- `s` without white space at either end, as .NET's `Trim()`. function util.trim(s: string): string return util.trim_end(util.trim_start(s)) end --- Whether `s` starts with `prefix`, compared as bytes. function util.starts_with(s: string, prefix: string): boolean return string.sub(s, 1, #prefix) == prefix end --- Whether `s` ends with `suffix`, compared as bytes. function util.ends_with(s: string, suffix: string): boolean return suffix == "" or string.sub(s, -#suffix) == suffix end --- `bytes` as UTF-8 text the way Zig's `fmtUtf8` (the WHATWG decoder) reads it: valid text is --- unchanged, and each maximal ill-formed part becomes U+FFFD. function util.lossy(bytes: string): string if string.find(bytes, "[\128-\255]") == nil then return bytes end local out = {} local start = 1 local needed, seen = 0, 0 local lower, upper = 0x80, 0xBF local i = 1 local n = #bytes while i <= n do local b = string.byte(bytes, i) :: number if needed == 0 then start = i if b <= 0x7F then table.insert(out, string.char(b)) elseif b >= 0xC2 and b <= 0xDF then needed = 1 elseif b >= 0xE0 and b <= 0xEF then needed = 2 lower = if b == 0xE0 then 0xA0 else 0x80 upper = if b == 0xED then 0x9F else 0xBF elseif b >= 0xF0 and b <= 0xF4 then needed = 3 lower = if b == 0xF0 then 0x90 else 0x80 upper = if b == 0xF4 then 0x8F else 0xBF else table.insert(out, "\u{FFFD}") end i += 1 elseif b < lower or b > upper then -- The byte is not consumed: it starts over as a lead. needed, seen, lower, upper = 0, 0, 0x80, 0xBF table.insert(out, "\u{FFFD}") else lower, upper = 0x80, 0xBF seen += 1 i += 1 if seen == needed then table.insert(out, string.sub(bytes, start, i - 1)) needed, seen = 0, 0 end end end if needed > 0 then table.insert(out, "\u{FFFD}") end return table.concat(out) end --- `s` with every occurrence of `needle` replaced by `replacement`, left to right, as plain text. function util.replace_all(s: string, needle: string, replacement: string): string if needle == "" then return s end local parts = {} local from = 1 while true do local at = string.find(s, needle, from, true) if at == nil then break end table.insert(parts, string.sub(s, from, at - 1)) table.insert(parts, replacement) from = at + #needle end table.insert(parts, string.sub(s, from)) return table.concat(parts) end --- Splits a dotted version ("1.21.10", "0.16.5+build") into its leading numeric parts. local function parts(version: string): { number } local out = {} for digits in string.gmatch(string.match(version, "^[%d%.]+") or "", "%d+") do table.insert(out, tonumber(digits) :: number) end return out end --- Compares two versions by their numeric parts: -1, 0 or 1. "1.21.10" is above "1.21.9"; --- anything after the numeric prefix (a pre-release, build metadata) is ignored. function util.compare_versions(a: string, b: string): number local x, y = parts(a), parts(b) for i = 1, math.max(#x, #y) do local left, right = x[i] or 0, y[i] or 0 if left ~= right then return if left < right then -1 else 1 end end return 0 end --- The version that bounds `~` (next minor) or `^` (next major) above `version`. local function bumped(version: string, index: number): string local p = parts(version) local out = {} for i = 1, index - 1 do out[i] = p[i] or 0 end out[index] = (p[index] or 0) + 1 return table.concat(out, ".") end --- One Fabric/Quilt predicate term (">=1.21", "~1.21.1", "1.21.x", "*"), or nil if unreadable. local function term_holds(version: string, term: string): boolean? if term == "*" or term == "x" or term == "X" then return true end local op, rest = string.match(term, "^([<>=~%^]*)(.+)$") if rest == nil or not string.match(rest, "^%d[%w%.%-%+]*$") then return nil end if string.match(rest, "[xX%*]") then if op ~= "" and op ~= "=" then return nil end local wanted = string.match(rest, "^([%d%.]-)%.?[xX%*]") if wanted == nil or wanted == "" then return true end local have, need = parts(version), parts(wanted) for i = 1, #need do if have[i] ~= need[i] then return false end end return true end local c = util.compare_versions(version, rest) if op == "" or op == "=" then return c == 0 elseif op == ">=" then return c >= 0 elseif op == ">" then return c > 0 elseif op == "<=" then return c <= 0 elseif op == "<" then return c < 0 elseif op == "~" then return c >= 0 and util.compare_versions(version, bumped(rest, 2)) < 0 elseif op == "^" then return c >= 0 and util.compare_versions(version, bumped(rest, 1)) < 0 end return nil end --- A Maven range ("[1.21,1.22)", "[1.20.1]", "[1.20,)", unions joined by commas), or nil. local function maven_holds(version: string, range: string): boolean? local matched = false local any = false for open, inside, close in string.gmatch(range, "([%[%(])([^%]%)]*)([%]%)])") do any = true local lower, upper = string.match(inside, "^%s*(.-)%s*,%s*(.-)%s*$") local ok if lower == nil then ok = util.compare_versions(version, (string.gsub(inside, "%s", ""))) == 0 else ok = true if lower ~= "" then local c = util.compare_versions(version, lower) ok = ok and (if open == "[" then c >= 0 else c > 0) end if upper ~= "" then local c = util.compare_versions(version, upper) ok = ok and (if close == "]" then c <= 0 else c < 0) end end matched = matched or ok end if not any then return nil end return matched end --- Whether `version` meets `range` as a mod declares it. `syntax` is "maven" (Forge, NeoForge: --- bracketed ranges, and a bare version is only a recommendation, so it accepts anything) or --- "semver" (Fabric, Quilt: space-separated terms all hold, `||` between alternatives); without --- it, a bracket means Maven. Nil when the range is unreadable. function util.satisfies(version: string, range: string, syntax: string?): boolean? local text = string.match(range, "^%s*(.-)%s*$") or "" if text == "" or text == "*" then return true end local first = string.sub(text, 1, 1) if first == "[" or first == "(" then return maven_holds(version, text) end if syntax == "maven" then return true end for alternative in string.gmatch(text .. "||", "(.-)||") do local all = true for term in string.gmatch(alternative, "%S+") do local holds = term_holds(version, term) if holds == nil then return nil end all = all and holds end if all then return true end end return false end return util