diff --git a/client/space_lua/lua.test.ts b/client/space_lua/lua.test.ts index d178a68a..a2ed27b6 100644 --- a/client/space_lua/lua.test.ts +++ b/client/space_lua/lua.test.ts @@ -48,7 +48,6 @@ test("[Lua] String to number tests", async () => { test("[Lua] String tests", async () => { await runLuaTest("./stdlib/string_test.lua"); - // await runLuaTest("./stdlib/string_test2.lua"); }); test("[Lua] Space Lua tests", async () => { diff --git a/client/space_lua/stdlib/os.ts b/client/space_lua/stdlib/os.ts index 2649a606..9af8f75b 100644 --- a/client/space_lua/stdlib/os.ts +++ b/client/space_lua/stdlib/os.ts @@ -365,4 +365,9 @@ export const osApi = new LuaTable({ return luaFormatTime(fmt, d, utc); }, ), + + // Returns an approximation of CPU time used by the program in seconds. + clock: new LuaBuiltinFunction((_sf): number => { + return performance.now() / 1000.0; + }), }); diff --git a/client/space_lua/stdlib/os_test.lua b/client/space_lua/stdlib/os_test.lua index 6d731b49..2fd48911 100644 --- a/client/space_lua/stdlib/os_test.lua +++ b/client/space_lua/stdlib/os_test.lua @@ -168,3 +168,15 @@ assert(type(err) == "string") -- Repeated specifiers assertEquals(os.date("!%Y%Y", ts), "20062006") + +-- os.clock +-- Returns a number (seconds) +assert(type(os.clock()) == "number") + +-- Must be non-negative +assert(os.clock() >= 0) + +-- Monotonically non-decreasing +local c1 = os.clock() +local c2 = os.clock() +assert(c2 >= c1) diff --git a/client/space_lua/stdlib/pattern.ts b/client/space_lua/stdlib/pattern.ts new file mode 100644 index 00000000..795ddfc6 --- /dev/null +++ b/client/space_lua/stdlib/pattern.ts @@ -0,0 +1,703 @@ +// Lua Optimized Pattern Matching Engine Implementation + +const CH_ESC = 37; // '%' +const CH_OPEN = 40; // '(' +const CH_CLOSE = 41; // ')' +const CH_DOT = 46; // '.' +const CH_DOLLAR = 36; // '$' +const CH_LBRACKET = 91; // '[' +const CH_RBRACKET = 93; // ']' +const CH_CARET = 94; // '^' +const CH_DASH = 45; // '-' +const CH_STAR = 42; // '*' +const CH_PLUS = 43; // '+' +const CH_QUESTION = 63; // '?' +const CH_0 = 48; // '0' +const CH_9 = 57; // '9' + +const SPECIALS_SET = new Set([ + CH_CARET, + CH_DOLLAR, + CH_STAR, + CH_PLUS, + CH_QUESTION, + CH_DOT, + CH_OPEN, + CH_LBRACKET, + CH_ESC, + CH_DASH, +]); + +const MAX_CAPTURES = 32; +const MAX_MATCH_DEPTH = 200; + +const CAP_UNFINISHED = -1; +const CAP_POSITION = -2; + +interface Capture { + init: number; + len: number; +} + +export interface MatchState { + src: string; // original source string (for substring extraction) + s: Uint8Array; // source bytes + slen: number; + p: Uint8Array; // pattern bytes + plen: number; + level: number; + capture: Capture[]; // pre-allocated, length `MAX_CAPTURES` + matchdepth: number; +} + +function toBytes(s: string): Uint8Array { + const len = s.length; + const arr = new Uint8Array(len); + for (let i = 0; i < len; i++) { + arr[i] = s.charCodeAt(i) & 0xFF; + } + return arr; +} + +function matchClass(c: number, cl: number): boolean { + const lcl = cl | 32; + let res: boolean; + switch (lcl) { + case 97: // 'a' + res = (c >= 65 && c <= 90) || (c >= 97 && c <= 122); + break; + case 99: // 'c' + res = c < 32 || c === 127; + break; + case 100: // 'd' + res = c >= 48 && c <= 57; + break; + case 103: // 'g' + res = c > 32 && c < 127; + break; + case 108: // 'l' + res = c >= 97 && c <= 122; + break; + case 112: // 'p' + res = (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || + (c >= 91 && c <= 96) || (c >= 123 && c <= 126); + break; + case 115: // 's' + res = c === 32 || (c >= 9 && c <= 13); + break; + case 117: // 'u' + res = c >= 65 && c <= 90; + break; + case 119: // 'w' + res = (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || + (c >= 48 && c <= 57); + break; + case 120: // 'x' + res = (c >= 48 && c <= 57) || (c >= 65 && c <= 70) || + (c >= 97 && c <= 102); + break; + case 122: // 'z' + res = c === 0; + break; + default: + return cl === c; + } + return (cl >= 97 && cl <= 122) ? res : !res; +} + +function classEnd(p: Uint8Array, plen: number, pi: number): number { + const ch = p[pi]; + pi++; + if (ch === CH_ESC) { + if (pi >= plen) { + throw new Error("malformed pattern (ends with '%')"); + } + return pi + 1; + } + if (ch === CH_LBRACKET) { + if (pi < plen && p[pi] === CH_CARET) pi++; + do { + if (pi >= plen) { + throw new Error("malformed pattern (missing ']')"); + } + if (p[pi] === CH_ESC && pi + 1 < plen) { + pi++; + } + pi++; + } while (pi < plen && p[pi] !== CH_RBRACKET); + if (pi >= plen) { + throw new Error("malformed pattern (missing ']')"); + } + return pi + 1; + } + return pi; +} + +function matchBracketClass( + c: number, + p: Uint8Array, + pi: number, + ec: number, +): boolean { + let sig = true; + if (p[pi + 1] === CH_CARET) { + sig = false; + pi++; + } + pi++; + while (pi < ec) { + const pch = p[pi]; + if (pch === CH_ESC) { + pi++; + if (matchClass(c, p[pi])) return sig; + } else if ( + pi + 2 < ec && p[pi + 1] === CH_DASH + ) { + pi += 2; + if (pch <= c && c <= p[pi]) return sig; + } else if (pch === c) { + return sig; + } + pi++; + } + return !sig; +} + +function singleMatch( + ms: MatchState, + si: number, + pi: number, + ep: number, +): boolean { + if (si >= ms.slen) return false; + const c = ms.s[si]; + const pch = ms.p[pi]; + if (pch === CH_DOT) return true; + if (pch === CH_ESC) { + return matchClass(c, ms.p[pi + 1]); + } + if (pch === CH_LBRACKET) { + return matchBracketClass(c, ms.p, pi, ep - 1); + } + return pch === c; +} + +function matchBalance( + ms: MatchState, + si: number, + pi: number, +): number { + if (pi >= ms.plen - 1) { + throw new Error("malformed pattern (missing arguments to '%b')"); + } + if (si >= ms.slen || ms.s[si] !== ms.p[pi]) { + return -1; + } + const b = ms.p[pi]; + const e = ms.p[pi + 1]; + let cont = 1; + si++; + while (si < ms.slen) { + const sc = ms.s[si]; + if (sc === e) { + if (--cont === 0) return si + 1; + } else if (sc === b) { + cont++; + } + si++; + } + return -1; +} + +function maxExpand( + ms: MatchState, + si: number, + pi: number, + ep: number, +): number { + let i = 0; + while (singleMatch(ms, si + i, pi, ep)) i++; + while (i >= 0) { + const res = match(ms, si + i, ep + 1); + if (res >= 0) return res; + i--; + } + return -1; +} + +function minExpand( + ms: MatchState, + si: number, + pi: number, + ep: number, +): number { + for (;;) { + const res = match(ms, si, ep + 1); + if (res >= 0) return res; + if (singleMatch(ms, si, pi, ep)) { + si++; + } else { + return -1; + } + } +} + +function checkCapture(ms: MatchState, l: number): number { + l -= CH_0 + 1; + if (l < 0 || l >= ms.level || ms.capture[l].len === CAP_UNFINISHED) { + throw new Error(`invalid capture index %${l + 1}`); + } + return l; +} + +function captureToClose(ms: MatchState): number { + for (let level = ms.level - 1; level >= 0; level--) { + if (ms.capture[level].len === CAP_UNFINISHED) return level; + } + throw new Error("invalid pattern capture"); +} + +function startCapture( + ms: MatchState, + si: number, + pi: number, + what: number, +): number { + const level = ms.level; + if (level >= MAX_CAPTURES) throw new Error("too many captures"); + ms.capture[level].init = si; + ms.capture[level].len = what; + ms.level = level + 1; + const res = match(ms, si, pi); + if (res < 0) ms.level--; + return res; +} + +function endCapture(ms: MatchState, si: number, pi: number): number { + const l = captureToClose(ms); + const savedLen = ms.capture[l].len; + ms.capture[l].len = si - ms.capture[l].init; + const res = match(ms, si, pi); + if (res < 0) ms.capture[l].len = savedLen; + return res; +} + +function matchCapture(ms: MatchState, si: number, l: number): number { + const idx = checkCapture(ms, l); + const cap = ms.capture[idx]; + if (cap.len === CAP_POSITION) { + throw new Error(`invalid capture index %${idx + 1}`); + } + const len = cap.len; + if (ms.slen - si < len) return -1; + for (let k = 0; k < len; k++) { + if (ms.s[cap.init + k] !== ms.s[si + k]) return -1; + } + return si + len; +} + +function match(ms: MatchState, si: number, pi: number): number { + if (ms.matchdepth-- <= 0) { + throw new Error("pattern too complex"); + } + while (true) { + if (pi >= ms.plen) { + ms.matchdepth++; + return si; + } + const pch = ms.p[pi]; + if (pch === CH_OPEN) { + if (pi + 1 < ms.plen && ms.p[pi + 1] === CH_CLOSE) { + si = startCapture(ms, si, pi + 2, CAP_POSITION); + } else { + si = startCapture(ms, si, pi + 1, CAP_UNFINISHED); + } + ms.matchdepth++; + return si; + } + if (pch === CH_CLOSE) { + si = endCapture(ms, si, pi + 1); + ms.matchdepth++; + return si; + } + if (pch === CH_DOLLAR && pi + 1 === ms.plen) { + ms.matchdepth++; + return si === ms.slen ? si : -1; + } + if (pch === CH_ESC && pi + 1 < ms.plen) { + const next = ms.p[pi + 1]; + if (next === 98) { // 'b' + si = matchBalance(ms, si, pi + 2); + if (si >= 0) { + pi += 4; + continue; + } + ms.matchdepth++; + return -1; + } + if (next === 102) { // 'f' + pi += 2; + if (pi >= ms.plen || ms.p[pi] !== CH_LBRACKET) { + throw new Error("missing '[' after '%f' in pattern"); + } + const ep = classEnd(ms.p, ms.plen, pi); + const previous = si === 0 ? 0 : ms.s[si - 1]; + const current = si < ms.slen ? ms.s[si] : 0; + if ( + !matchBracketClass(previous, ms.p, pi, ep - 1) && + matchBracketClass(current, ms.p, pi, ep - 1) + ) { + pi = ep; + continue; + } + ms.matchdepth++; + return -1; + } + if (next >= CH_0 && next <= CH_9) { + si = matchCapture(ms, si, next); + if (si >= 0) { + pi += 2; + continue; + } + ms.matchdepth++; + return -1; + } + } + // default: class[*+?-]? + const ep = classEnd(ms.p, ms.plen, pi); + const matched = singleMatch(ms, si, pi, ep); + if (!matched) { + if (ep < ms.plen) { + const suffix = ms.p[ep]; + if ( + suffix === CH_STAR || suffix === CH_QUESTION || suffix === CH_DASH + ) { + pi = ep + 1; + continue; + } + } + ms.matchdepth++; + return -1; + } + if (ep < ms.plen) { + const suffix = ms.p[ep]; + if (suffix === CH_QUESTION) { + const res = match(ms, si + 1, ep + 1); + if (res >= 0) { + ms.matchdepth++; + return res; + } + pi = ep + 1; + continue; + } + if (suffix === CH_PLUS) { + si++; + const res = maxExpand(ms, si, pi, ep); + ms.matchdepth++; + return res; + } + if (suffix === CH_STAR) { + const res = maxExpand(ms, si, pi, ep); + ms.matchdepth++; + return res; + } + if (suffix === CH_DASH) { + const res = minExpand(ms, si, pi, ep); + ms.matchdepth++; + return res; + } + } + si++; + pi = ep; + continue; + } +} + +function createMatchState(s: string, p: string): MatchState { + const sb = toBytes(s); + const pb = toBytes(p); + const capture: Capture[] = new Array(MAX_CAPTURES); + for (let i = 0; i < MAX_CAPTURES; i++) { + capture[i] = { init: 0, len: 0 }; + } + return { + src: s, + s: sb, + slen: sb.length, + p: pb, + plen: pb.length, + level: 0, + capture, + matchdepth: MAX_MATCH_DEPTH, + }; +} + +function resetMatchState(ms: MatchState): void { + ms.level = 0; + ms.matchdepth = MAX_MATCH_DEPTH; +} + +function noSpecials(p: string): boolean { + for (let i = 0; i < p.length; i++) { + if (SPECIALS_SET.has(p.charCodeAt(i))) return false; + } + return true; +} + +// Public API + +export type CaptureResult = { s: string } | { position: number }; + +interface RawCapture { + kind: 0 | 1; // 0 = string slice, 1 = position + start: number; + len: number; // kind = 0: substring length; kind = 1: 1-based position +} + +function getOneRawCapture( + ms: MatchState, + i: number, + matchStart: number, + matchEnd: number, +): RawCapture { + if (i >= ms.level) { + if (i !== 0) { + throw new Error(`invalid capture index %${i + 1}`); + } + return { kind: 0, start: matchStart, len: matchEnd - matchStart }; + } + const cap = ms.capture[i]; + if (cap.len === CAP_UNFINISHED) { + throw new Error("unfinished capture"); + } + if (cap.len === CAP_POSITION) { + return { kind: 1, start: cap.init + 1, len: 0 }; + } + return { kind: 0, start: cap.init, len: cap.len }; +} + +function rawToResult(ms: MatchState, raw: RawCapture): CaptureResult { + if (raw.kind === 1) { + return { position: raw.start }; + } + return { s: ms.src.substring(raw.start, raw.start + raw.len) }; +} + +function getCaptures( + ms: MatchState, + matchStart: number, + matchEnd: number, +): CaptureResult[] { + const nlevels = ms.level === 0 ? 1 : ms.level; + const result: CaptureResult[] = []; + for (let i = 0; i < nlevels; i++) { + result.push(rawToResult(ms, getOneRawCapture(ms, i, matchStart, matchEnd))); + } + return result; +} + +function getRawCaptureString(ms: MatchState, raw: RawCapture): string { + if (raw.kind === 1) { + return raw.start.toString(); + } + return ms.src.substring(raw.start, raw.start + raw.len); +} + +export function patternFind( + s: string, + pattern: string, + init: number = 1, + plain: boolean = false, +): { start: number; end: number; captures: CaptureResult[] } | null { + if (init < 1) init = 1; + if (init > s.length + 1) return null; + const si0 = init - 1; + if (plain || noSpecials(pattern)) { + const idx = s.indexOf(pattern, si0); + if (idx < 0) return null; + return { start: idx + 1, end: idx + pattern.length, captures: [] }; + } + let p = pattern; + let anchor = false; + if (p.length > 0 && p.charCodeAt(0) === CH_CARET) { + anchor = true; + p = p.substring(1); + } + const ms = createMatchState(s, p); + for (let si = si0; si <= ms.slen; si++) { + resetMatchState(ms); + const res = match(ms, si, 0); + if (res >= 0) { + const caps = ms.level === 0 ? [] : getCaptures(ms, si, res); + return { start: si + 1, end: res, captures: caps }; + } + if (anchor) break; + } + return null; +} + +export function patternMatch( + s: string, + pattern: string, + init: number = 1, +): CaptureResult[] | null { + if (init < 1) init = 1; + if (init > s.length + 1) return null; + const si0 = init - 1; + let p = pattern; + let anchor = false; + if (p.length > 0 && p.charCodeAt(0) === CH_CARET) { + anchor = true; + p = p.substring(1); + } + const ms = createMatchState(s, p); + for (let si = si0; si <= ms.slen; si++) { + resetMatchState(ms); + const res = match(ms, si, 0); + if (res >= 0) { + return getCaptures(ms, si, res); + } + if (anchor) break; + } + return null; +} + +export function patternGmatch( + s: string, + pattern: string, + init: number = 1, +): () => CaptureResult[] | null { + if (init < 1) init = 1; + let p = pattern; + let anchor = false; + if (p.length > 0 && p.charCodeAt(0) === CH_CARET) { + anchor = true; + p = p.substring(1); + } + const ms = createMatchState(s, p); + let src = init - 1; + let lastMatch: number | null = null; + return () => { + while (src <= ms.slen) { + resetMatchState(ms); + const e = match(ms, src, 0); + if (e >= 0 && e !== lastMatch) { + const captures = getCaptures(ms, src, e); + src = e; + lastMatch = e; + return captures; + } + src++; + if (anchor) break; + } + return null; + }; +} + +function expandReplacementString( + repl: string, + ms: MatchState, + matchStart: number, + matchEnd: number, +): string { + const parts: string[] = []; + let i = 0; + while (i < repl.length) { + const ch = repl.charCodeAt(i); + if (ch === CH_ESC) { + i++; + if (i >= repl.length) { + throw new Error("invalid use of '%' in replacement string"); + } + const rc = repl.charCodeAt(i); + if (rc === CH_ESC) { + parts.push("%"); + } else if (rc === CH_0) { + parts.push(ms.src.substring(matchStart, matchEnd)); + } else if (rc >= 49 && rc <= CH_9) { + parts.push(getRawCaptureString( + ms, + getOneRawCapture(ms, rc - 49, matchStart, matchEnd), + )); + } else { + throw new Error("invalid use of '%' in replacement string"); + } + } else { + // Collect consecutive literal characters + let j = i + 1; + while (j < repl.length && repl.charCodeAt(j) !== CH_ESC) j++; + parts.push(repl.substring(i, j)); + i = j; + continue; + } + i++; + } + return parts.join(""); +} + +export interface GsubCallbacks { + replString?: string; + replFunction?: ( + ...captures: CaptureResult[] + ) => Promise | string | null | undefined; + replTable?: (key: string) => string | null | undefined; +} + +export async function patternGsub( + s: string, + pattern: string, + callbacks: GsubCallbacks, + maxN?: number, +): Promise<[string, number]> { + const max_s = maxN !== undefined ? maxN : s.length + 1; + let p = pattern; + let anchor = false; + if (p.length > 0 && p.charCodeAt(0) === CH_CARET) { + anchor = true; + p = p.substring(1); + } + const ms = createMatchState(s, p); + let src = 0; + let lastMatch: number | null = null; + let n = 0; + const resultParts: string[] = []; + + while (n < max_s) { + resetMatchState(ms); + const e = match(ms, src, 0); + if (e >= 0 && e !== lastMatch) { + n++; + let replStr: string | null | undefined; + if (callbacks.replString !== undefined) { + replStr = expandReplacementString(callbacks.replString, ms, src, e); + } else if (callbacks.replFunction) { + const caps = getCaptures(ms, src, e); + replStr = await callbacks.replFunction(...caps); + if (replStr === null || replStr === undefined) { + replStr = ms.src.substring(src, e); + } + } else if (callbacks.replTable) { + const raw = getOneRawCapture(ms, 0, src, e); + const key = getRawCaptureString(ms, raw); + replStr = callbacks.replTable(key); + if (replStr === null || replStr === undefined) { + replStr = ms.src.substring(src, e); + } + } + resultParts.push(replStr!); + src = e; + lastMatch = e; + } else if (src < ms.slen) { + resultParts.push(s[src]); + src++; + lastMatch = null; + } else { + break; + } + if (anchor) break; + } + if (src < s.length) { + resultParts.push(s.substring(src)); + } + return [resultParts.join(""), n]; +} diff --git a/client/space_lua/stdlib/pattern_test.lua b/client/space_lua/stdlib/pattern_test.lua new file mode 100644 index 00000000..b9a651b8 --- /dev/null +++ b/client/space_lua/stdlib/pattern_test.lua @@ -0,0 +1,557 @@ +local function assertEqual(a, b) + if a ~= b then + error("Assertion failed: " .. tostring(a) .. " ~= " .. tostring(b)) + end +end + +local function assertError(f, expectedMsg) + local ok, err = pcall(f) + assert(not ok, "Expected error but call succeeded") + local msg = tostring(err) + -- Strip stack trace prefix if present (e.g. "LuaRuntimeError: ...") + local clean = msg:match(":%s*(.+)$") or msg + assert(clean:find(expectedMsg, 1, true), + "Expected error containing '" .. expectedMsg .. "' but got: " .. msg) +end + +-- 1. Character classes + +-- %a / %A +assertEqual(string.match("hello123", "%a+"), "hello") +assertEqual(string.match("123hello", "%A+"), "123") + +-- %d / %D +assertEqual(string.match("abc42xyz", "%d+"), "42") +assertEqual(string.match("42abc", "%D+"), "abc") + +-- %l / %L (lowercase) +assertEqual(string.match("Hello", "%l+"), "ello") +assertEqual(string.match("hello", "%L+"), nil) + +-- %u / %U (uppercase) +assertEqual(string.match("helloWORLD", "%u+"), "WORLD") +assertEqual(string.match("HELLO", "%U+"), nil) + +-- %w / %W (alphanumeric) +assertEqual(string.match("hello world", "%w+"), "hello") +assertEqual(string.match("hello world", "%W+"), " ") + +-- %s / %S (whitespace) +assertEqual(string.match("hello world", "%s+"), " ") +assertEqual(string.match(" hello", "%S+"), "hello") + +-- %p (punctuation) +assertEqual(string.match("hello, world!", "%p+"), ",") + +-- %g (printable, non-space) +assertEqual(string.match(" abc ", "%g+"), "abc") + +-- %x (hex digits) +assertEqual(string.match("ghABCDij", "%x+"), "ABCD") + +-- %. (dot matches any) +assertEqual(string.match("abc", "."), "a") +assertEqual(string.match("abc", ".."), "ab") + +-- 2. Anchors + +assertEqual(string.match("hello", "^hello$"), "hello") +assertEqual(string.match("hello world", "^hello$"), nil) +assertEqual(string.match("hello", "^h"), "h") +assertEqual(string.match("hello", "o$"), "o") +assertEqual(string.match("hello", "x$"), nil) + +-- 3. Repetition: *, +, -, ? + +-- * (greedy, 0 or more) +assertEqual(string.match("aaa", "a*"), "aaa") +assertEqual(string.match("bbb", "a*"), "") + +-- + (greedy, 1 or more) +assertEqual(string.match("aaa", "a+"), "aaa") +assertEqual(string.match("bbb", "a+"), nil) + +-- - (lazy, 0 or more) +assertEqual(string.match("aaa", "a-b"), nil) +assertEqual(string.match("aab", "a-b"), "aab") +-- lazy match takes shortest prefix +local cap = string.match("content", "<(.-)>") +assertEqual(cap, "tag") + +-- ? (optional) +assertEqual(string.match("colour", "colou?r"), "colour") +assertEqual(string.match("color", "colou?r"), "color") + +-- 4. Escaping magic characters with % + +assertEqual(string.match("100%", "(%d+)%%"), "100") +assertEqual(string.match("hello.world", "(%a+)%.(%a+)"), "hello") +assertEqual(string.match("a+b", "a%+b"), "a+b") +assertEqual(string.match("a*b", "a%*b"), "a*b") +assertEqual(string.match("(x)", "%(x%)"), "(x)") + +-- Non-magic chars after % are literal +assertEqual(string.match("*", "%*"), "*") +assertEqual(string.match("?", "%?"), "?") + +-- 5. Character sets [...] + +assertEqual(string.match("cat", "[abc]"), "c") +assertEqual(string.match("dog", "[abc]"), nil) + +-- Ranges +assertEqual(string.match("m", "[a-z]"), "m") +assertEqual(string.match("M", "[a-z]"), nil) +assertEqual(string.match("5", "[0-9]"), "5") + +-- Negated set +assertEqual(string.match("x", "[^abc]"), "x") +assertEqual(string.match("a", "[^abc]"), nil) + +-- Classes inside sets +assertEqual(string.match("3", "[%d]"), "3") +assertEqual(string.match("a", "[%d]"), nil) + +-- 6. Captures + +-- Single capture +local y, m, d = string.match("2024-03-14", "(%d+)-(%d+)-(%d+)") +assertEqual(y, "2024") +assertEqual(m, "03") +assertEqual(d, "14") + +-- No captures returns whole match +assertEqual(string.match("hello", "%a+"), "hello") + +-- Nested captures +local outer, inner = string.match("hello world", "(h(ello))") +assertEqual(outer, "hello") +assertEqual(inner, "ello") + +-- Captures with literal parens +local content = string.match("((test))", "%((%(%a+%))%)") +assertEqual(content, "(test)") + +-- Multiple captures +local a, b = string.match("hello world", "(%a+) (%a+)") +assertEqual(a, "hello") +assertEqual(b, "world") + +-- 7. Position captures () + +local p1, p2 = string.match("hello", "()()") +assertEqual(p1, 1) +assertEqual(p2, 1) + +local pos = string.match("hello world", "()world") +assertEqual(pos, 7) + +-- 8. Back-references %1-%9 + +-- Match repeated word +local word = string.match("hello hello", "(%a+) %1") +assertEqual(word, "hello") + +-- No match if different +assertEqual(string.match("hello world", "(%a+) %1"), nil) + +-- Match repeated char +local ch = string.match("aabcc", "(.)%1") +assertEqual(ch, "a") + +-- 9. Balanced match %bxy + +assertEqual(string.match("(hello (world))", "%b()"), "(hello (world))") +assertEqual(string.match("{a{b}c}", "%b{}"), "{a{b}c}") +assertEqual(string.match("(unbalanced", "%b()"), nil) + +-- 10. Frontier pattern %f[set] + +-- Transition from non-alpha to alpha +local fw = string.match("hello world", "%f[%a]%a+", 2) +assertEqual(fw, "world") + +-- Transition at string start +local fs = string.match("hello", "%f[%a]%a+") +assertEqual(fs, "hello") + +-- Word boundaries +local words = {} +for w in string.gmatch("one two three", "%f[%a]%a+") do + table.insert(words, w) +end +assertEqual(#words, 3) +assertEqual(words[1], "one") +assertEqual(words[2], "two") +assertEqual(words[3], "three") + +-- 11. string.find + +-- Basic find +local s, e = string.find("hello world", "world") +assertEqual(s, 7) +assertEqual(e, 11) + +-- Plain find +s, e = string.find("hello.world", ".", 1, true) +assertEqual(s, 6) +assertEqual(e, 6) + +-- Find with captures +local s2, e2, c1 = string.find("hello world", "(%a+)") +assertEqual(s2, 1) +assertEqual(e2, 5) +assertEqual(c1, "hello") + +-- Not found +assertEqual(string.find("hello", "xyz"), nil) + +-- Find with init +s, e = string.find("abcabc", "abc", 2) +assertEqual(s, 4) +assertEqual(e, 6) + +-- Find returns nil (not false) +assert(string.find("a", "b") == nil) +assert(not (string.find("a", "b") ~= nil)) + +-- Find with special chars +local bf = string.find("[", "[_%w]") +assert(bf == nil) + +-- 12. string.match + +-- Match from init position +assertEqual(string.match("abcdef", "%a+", 4), "def") + +-- Match empty pattern +assertEqual(string.match("abc", ""), "") + +-- Match returns nil (not false) +assert(string.match("a", "b") == nil) +assert(not (string.match("a", "b") ~= nil)) + +-- Match with init +local initMatch = string.match("hello world", "(world)", 7) +assertEqual(initMatch, "world") + +-- 13. string.gmatch + +-- Basic iteration +local t = {} +for w in string.gmatch("hello world lua", "%a+") do + table.insert(t, w) +end +assertEqual(#t, 3) +assertEqual(t[1], "hello") +assertEqual(t[2], "world") +assertEqual(t[3], "lua") + +-- With captures +local kv = {} +for k, v in string.gmatch("from=world, to=Lua", "(%w+)=(%w+)") do + kv[k] = v +end +assertEqual(kv.from, "world") +assertEqual(kv.to, "Lua") + +-- gmatch with empty matches (like Lua reference: ";a;" with "a*") +local r = {} +for mm in string.gmatch(";a;", "a*") do + table.insert(r, mm) +end +assertEqual(r[1], "") +assertEqual(r[2], "a") +assertEqual(r[3], "") + +-- gmatch without captures returns whole match +local t2 = {} +for mm in string.gmatch("from=world, to=Lua", "%w+=%w+") do + table.insert(t2, mm) +end +assertEqual(t2[1], "from=world") +assertEqual(t2[2], "to=Lua") + +-- 14. string.gsub + +-- Simple string replacement +local res, count = string.gsub("hello world", "(%w+)", "%1-%1") +assertEqual(res, "hello-hello world-world") +assertEqual(count, 2) + +-- Limited replacements +res, count = string.gsub("aaa", "a", "b", 2) +assertEqual(res, "bba") +assertEqual(count, 2) + +-- Replacement with %0 +res = string.gsub("hello", "%w+", "[%0]") +assertEqual(res, "[hello]") + +-- Function replacement +res, count = string.gsub("hello world", "%w+", function(w) + return w:upper() +end) +assertEqual(res, "HELLO WORLD") +assertEqual(count, 2) + +-- Function returning nil keeps original +res = string.gsub("hello world", "%w+", function(w) + if w == "hello" then return "HI" end +end) +assertEqual(res, "HI world") + +-- Table replacement +local tbl = {hello = "HI", world = "EARTH"} +res = string.gsub("hello world", "(%w+)", tbl) +assertEqual(res, "HI EARTH") + +-- Table with missing key keeps original +tbl = {hello = "HI"} +res = string.gsub("hello world", "(%w+)", tbl) +assertEqual(res, "HI world") + +-- gsub with magic chars in pattern +res = string.gsub("hello.world", "%.", "-") +assertEqual(res, "hello-world") + +-- gsub %% in replacement +res = string.gsub("hello", "hello", "100%%") +assertEqual(res, "100%") + +-- Empty pattern match (inserts between every char) +res = string.gsub("abc", "", "-") +assertEqual(res, "-a-b-c-") + +-- Anchored gsub +res, count = string.gsub("abc", "^a", "x") +assertEqual(res, "xbc") +assertEqual(count, 1) + +-- gsub with XML pattern +local xmlpat = '<%?xml version="1.0" encoding="UTF%-8"%?>' +local xmlstr = '' +res = string.gsub(xmlstr, xmlpat, "moo") +assertEqual(res, "moo") + +-- gsub with %%1 +res = string.gsub("Hello %1", "%%1", "world") +assertEqual(res, "Hello world") + +-- gsub counting digits +res, count = string.gsub("ab5kfd8scf4lll", "%d", "") +assertEqual(res, "abkfdscflll") +assertEqual(count, 3) + +-- 15. The dash (lazy) vs literal dash + +assertEqual(string.match("2024-03-14", "%d+-(%d+)-%d+"), "03") +assertEqual(string.match("2024-03-14", "(%d+)-(%d+)-(%d+)"), "2024") + +local y2, m2, d2 = string.match("2024-03-14", "(%d+)-(%d+)-(%d+)") +assertEqual(y2, "2024") +assertEqual(m2, "03") +assertEqual(d2, "14") + +-- 16. Edge cases + +-- Empty string +assertEqual(string.match("", ".*"), "") +local fs2, fe2 = string.find("", "") +assertEqual(fs2, 1) +assertEqual(fe2, 0) + +-- Pattern matching entire string +assertEqual(string.match("abc", "^(.-)$"), "abc") + +-- Pattern at end of string +assertEqual(string.match("test!", "!$"), "!") + +-- Multiple position captures +local p3, p4 = string.match("abcd", "()ab()cd") +assertEqual(p3, 1) +assertEqual(p4, 3) + +-- string.rep with separator +assertEqual(string.rep("ab", 3, ","), "ab,ab,ab") +assertEqual(string.rep("x", 1, ","), "x") +assertEqual(string.rep("x", 0), "") + +-- 17. More complex patterns + +-- CSV-like parsing +local fields = {} +for f in string.gmatch("one,two,,four", "([^,]*)") do + table.insert(fields, f) +end +assertEqual(fields[1], "one") +assertEqual(fields[2], "two") +assertEqual(fields[3], "") +assertEqual(fields[4], "four") + +-- Trim whitespace +local function trim(s2) + return string.match(s2, "^%s*(.-)%s*$") +end +assertEqual(trim(" hello "), "hello") +assertEqual(trim("hello"), "hello") +assertEqual(trim(" "), "") + +-- Match identifier +assertEqual(string.match("my_var123", "^[%a_][%w_]*$"), "my_var123") +assertEqual(string.match("123bad", "^[%a_][%w_]*$"), nil) + +-- Hex color +local hex = string.match("#FF00AA", "^#(%x%x)(%x%x)(%x%x)$") +assertEqual(hex, "FF") + +local r2, g, b2 = string.match("#FF00AA", "^#(%x%x)(%x%x)(%x%x)$") +assertEqual(r2, "FF") +assertEqual(g, "00") +assertEqual(b2, "AA") + +-- Email-like pattern +local user, domain = string.match("user@example.com", "([%w_.]+)@([%w_.]+)") +assertEqual(user, "user") +assertEqual(domain, "example.com") + +-- 18. Error cases — malformed patterns + +-- Pattern ending with lone % +assertError(function() + string.find("abc", "abc%") +end, "malformed pattern (ends with '%')") + +assertError(function() + string.match("abc", "%") +end, "malformed pattern (ends with '%')") + +assertError(function() + local iter = string.gmatch("abc", "a%") + iter() +end, "malformed pattern (ends with '%')") + +-- %b with missing arguments +assertError(function() + string.match("abc", "%b") +end, "malformed pattern (missing arguments to '%b')") + +assertError(function() + string.match("abc", "%b(") +end, "malformed pattern (missing arguments to '%b')") + +-- %f not followed by [ +assertError(function() + string.match("abc", "%f") +end, "missing '[' after '%f' in pattern") + +assertError(function() + string.match("abc", "%fa") +end, "missing '[' after '%f' in pattern") + +-- Lone % at end of replacement +assertError(function() + string.gsub("abc", "a", "x%") +end, "invalid use of '%' in replacement string") + +-- Invalid escape in replacement +assertError(function() + string.gsub("abc", "a", "%z") +end, "invalid use of '%' in replacement string") + +-- 19. Error cases — invalid captures + +-- Unmatched close paren +assertError(function() + string.match("abc", ")") +end, "invalid pattern capture") + +-- Back-reference to non-existent capture +assertError(function() + string.match("abc", "%1") +end, "invalid capture index") + +-- Back-reference to capture not yet closed +assertError(function() + string.match("abab", "(%a+%1)") +end, "invalid capture index") + +-- Too high capture index in replacement +assertError(function() + string.gsub("abc", "(%a+)", "%2") +end, "invalid capture index") + +-- Unfinished capture used in match (tries to return it) +assertError(function() + string.match("abc", "(abc") +end, "unfinished capture") + +-- Unfinished capture used in find (tries to return it) +assertError(function() + string.find("abc", "(abc") +end, "unfinished capture") + +-- Nested unfinished capture +assertError(function() + string.match("abc", "(ab(c)") +end, "unfinished capture") + +-- 20. Error cases — %b and %f + +-- %b with missing arguments +assertError(function() + string.match("abc", "%b") +end, "malformed pattern (missing arguments to '%b')") + +assertError(function() + string.match("abc", "%b(") +end, "malformed pattern (missing arguments to '%b')") + +-- %f not followed by [ +assertError(function() + string.match("abc", "%f") +end, "missing '[' after '%f' in pattern") + +assertError(function() + string.match("abc", "%fa") +end, "missing '[' after '%f' in pattern") + +-- 21. Error cases — gsub replacement string + +-- Lone % at end of replacement +assertError(function() + string.gsub("abc", "a", "x%") +end, "invalid use of '%' in replacement string") + +-- Invalid escape in replacement (not a digit or %) +assertError(function() + string.gsub("abc", "a", "%z") +end, "invalid use of '%' in replacement string") + +-- %2 when only one capture exists +assertError(function() + string.gsub("abc", "(a)", "%2") +end, "invalid capture index") + +-- %9 with no captures at all +assertError(function() + string.gsub("abc", "a", "%9") +end, "invalid capture index") + +-- 22. Error cases — too many captures + +assertError(function() + local pat = string.rep("(", 33) .. "a" .. string.rep(")", 33) + string.match("a", pat) +end, "too many captures") + +-- 23. Error cases — unfinished capture + +-- Open paren never closed +assertError(function() + string.find("abc", "(abc") +end, "unfinished capture") + +assertError(function() + string.match("abc", "(ab(c)") +end, "unfinished capture") diff --git a/client/space_lua/stdlib/string.ts b/client/space_lua/stdlib/string.ts index ce559b2f..6fb233fb 100644 --- a/client/space_lua/stdlib/string.ts +++ b/client/space_lua/stdlib/string.ts @@ -8,77 +8,32 @@ import { } from "../runtime.ts"; import { untagNumber } from "../numeric.ts"; import { luaFormat } from "./format.ts"; +import { + type CaptureResult, + type GsubCallbacks, + patternFind, + patternGmatch, + patternGsub, + patternMatch, +} from "./pattern.ts"; -// Bits and pieces borrowed from https://github.com/paulcuth/starlight/blob/master/src/runtime/lib/string.js - -const ROSETTA_STONE = { - "([^a-zA-Z0-9%(])-": "$1*?", - "([^%])-([^a-zA-Z0-9?])": "$1*?$2", - "([^%])-$": "$1*?", - "%a": "[a-zA-Z]", - "%A": "[^a-zA-Z]", - "%c": "[\x00-\x1f]", - "%C": "[^\x00-\x1f]", - "%d": "\\d", - "%D": "[^\d]", - "%l": "[a-z]", - "%L": "[^a-z]", - "%p": "[\.\,\"'\?\!\;\:\#\$\%\&\(\)\*\+\-\/\<\>\=\@\\[\\]\\\\^\_\{\}\|\~]", - "%P": "[^\.\,\"'\?\!\;\:\#\$\%\&\(\)\*\+\-\/\<\>\=\@\\[\\]\\\\^\_\{\}\|\~]", - "%s": "[ \\t\\n\\f\\v\\r]", - "%S": "[^ \t\n\f\v\r]", - "%u": "[A-Z]", - "%U": "[^A-Z]", - "%w": "[a-zA-Z0-9]", - "%W": "[^a-zA-Z0-9]", - "%x": "[a-fA-F0-9]", - "%X": "[^a-fA-F0-9]", - "%([^a-zA-Z])": "\\$1", -}; - -function translatePattern(pattern: string): string { - pattern = "" + pattern; - - // Replace single backslash with double backslashes - pattern = pattern.replace(new RegExp("\\\\", "g"), "\\\\"); - pattern = pattern.replace(new RegExp("\\|", "g"), "\\|"); - - for (const [key, value] of Object.entries(ROSETTA_STONE)) { - pattern = pattern.replace(new RegExp(key, "g"), value); +function capturesToLua(caps: CaptureResult[]): any { + if (caps.length === 0) {return null;} + if (caps.length === 1) { + const c = caps[0]; + return "s" in c ? c.s : c.position; } - - let l = pattern.length; - let n = 0; - - for (let i = 0; i < l; i++) { - const character = pattern.slice(i, 1); - if (i && pattern.slice(i - 1, 1) == "\\") { - continue; - } - - let addSlash = false; - - if (character == "[") { - if (n) addSlash = true; - n++; - } else if (character == "]" && pattern.slice(i - 1, 1) !== "\\") { - n--; - if (n) addSlash = true; - } - - if (addSlash) { - pattern = pattern.slice(0, i) + pattern.slice(i++ + 1); - l++; - } - } - - return pattern; + return new LuaMultiRes( + caps.map((c) => ("s" in c ? c.s : c.position)), + ); } export const stringApi = new LuaTable({ byte: new LuaBuiltinFunction((_sf, s: string, i?: number, j?: number) => { i = i ?? 1; j = j ?? i; + if (j > s.length) {j = s.length;} + if (i < 1) {i = 1;} const result = []; for (let k = i; k <= j; k++) { result.push(s.charCodeAt(k - 1)); @@ -90,119 +45,68 @@ export const stringApi = new LuaTable({ }), find: new LuaBuiltinFunction( (_sf, s: string, pattern: string, init = 1, plain = false) => { - // Regex - if (!plain) { - pattern = translatePattern(pattern); - const reg = new RegExp(pattern); - const index = s.slice(init - 1).search(reg); - - if (index < 0) return null; - - const match = s.slice(init - 1).match(reg); - const result = [index + init, index + init + match![0].length - 1]; - - match!.shift(); - return new LuaMultiRes(result.concat(match)); + const r = patternFind(s, pattern, init, plain); + if (!r) {return null;} + const result: any[] = [r.start, r.end]; + for (const c of r.captures) { + result.push("s" in c ? c.s : c.position); } - - // Plain - const index = s.indexOf(pattern, init - 1); - return (index === -1) - ? null - : new LuaMultiRes([index + 1, index + pattern.length]); + return new LuaMultiRes(result); }, ), format: new LuaBuiltinFunction((_sf, format: string, ...args: any[]) => { - // Unwrap tagged floats so luaFormat sees plain numbers for (let i = 0; i < args.length; i++) { args[i] = untagNumber(args[i]); } return luaFormat(format, ...args); }), - gmatch: new LuaBuiltinFunction((_sf, s: string, pattern: string) => { - pattern = translatePattern(pattern); - const reg = new RegExp(pattern, "g"), - matches = s.match(reg); - return () => { - if (!matches) { - return; - } - const match = matches.shift(); - if (!match) { - return; - } - const groups = new RegExp(pattern).exec(match) || []; - - groups.shift(); - return groups.length ? new LuaMultiRes(groups) : match; - }; - }), + gmatch: new LuaBuiltinFunction( + (_sf, s: string, pattern: string, init = 1) => { + const iter = patternGmatch(s, pattern, init); + return () => { + const caps = iter(); + if (!caps) {return;} + return capturesToLua(caps); + }; + }, + ), gsub: new LuaBuiltinFunction( async ( sf, s: string, pattern: string, - repl: any, // string or LuaFunction - n = Infinity, + repl: any, + n?: number, ) => { - pattern = translatePattern(pattern); - const replIsFunction = repl.call; - - let count = 0, - result = "", - str, - prefix, - match: any, - lastMatch; - - while ( - count < n && - s && - (match = s.match(pattern)) - ) { - if (replIsFunction) { - // If no captures, pass in the whole match - if (match[1] === undefined) { - str = await repl.call(sf, match[0]); - } else { - // Else pass in the captures - str = await repl.call(sf, ...match.slice(1)); + const callbacks: GsubCallbacks = {}; + if (typeof repl === "string") { + callbacks.replString = repl; + } else if (repl instanceof LuaTable) { + callbacks.replTable = (key: string) => { + const v = repl.get(key); + if (v === null || v === undefined || v === false) {return null;} + return String(v); + }; + } else if (repl.call) { + callbacks.replFunction = async (...caps: CaptureResult[]) => { + const args = caps.map((c) => ("s" in c ? c.s : c.position)); + let result = await repl.call(sf, ...args); + if (result instanceof LuaMultiRes) { + result = result.values[0]; } - if (str instanceof LuaMultiRes) { - str = str.values[0]; + if (result === null || result === undefined || result === false) { + return null; } - if (str === undefined || str === null) { - str = match[0]; - } - } else if (repl instanceof LuaTable) { - str = repl.get(match[0]); - } else if (typeof repl === "string") { - str = repl.replaceAll(/%([0-9]+)/g, (_, i) => match[i]); - } else { - throw new LuaRuntimeError( - "string.gsub replacement argument should be a function, table or string", - sf, - ); - } - - if (match[0].length === 0) { - if (lastMatch === void 0) { - prefix = ""; - } else { - prefix = s.slice(0, 1); - } - } else { - prefix = s.slice(0, match.index); - } - - lastMatch = match[0]; - result += `${prefix}${str}`; - s = s.slice(`${prefix}${lastMatch}`.length); - - count++; + return luaToString(result); + }; + } else { + throw new LuaRuntimeError( + "string.gsub replacement argument should be a function, table or string", + sf, + ); } - - return new LuaMultiRes([`${result}${s}`, count]); + const [result, count] = await patternGsub(s, pattern, callbacks, n); + return new LuaMultiRes([result, count]); }, ), len: new LuaBuiltinFunction((_sf, s: string) => { @@ -216,43 +120,53 @@ export const stringApi = new LuaTable({ }), match: new LuaBuiltinFunction( (_sf, s: string, pattern: string, init = 1) => { - s = s.slice(init - 1); - const matches = s.match(new RegExp(translatePattern(pattern))); - - if (!matches) { - return null; - } - if (matches[1] === undefined) { - // No captures - return matches[0]; - } - - matches.shift(); - return new LuaMultiRes(matches); + const caps = patternMatch(s, pattern, init); + if (!caps) {return null;} + return capturesToLua(caps); }, ), rep: new LuaBuiltinFunction((_sf, s: string, n: number, sep?: string) => { + if (n <= 0) {return "";} sep = sep ?? ""; - return s.repeat(n) + sep; + const parts: string[] = []; + for (let i = 0; i < n; i++) { + parts.push(s); + } + return parts.join(sep); }), reverse: new LuaBuiltinFunction((_sf, s: string) => { return s.split("").reverse().join(""); }), sub: new LuaBuiltinFunction((_sf, s: string, i: number, j?: number) => { - j = j ?? s.length; - if (i < 0) { - i = s.length + i + 1; + const len = s.length; + let start: number; + if (i > 0) { + start = i; + } else if (i < -len) { + start = 1; + } else { + start = i === 0 ? 1 : len + i + 1; } - if (j < 0) { - j = s.length + j + 1; + let end: number; + if (j === undefined || j === null || j > len) { + end = len; + } else if (j >= 0) { + end = j; + } else if (j < -len) { + end = 0; + } else { + end = len + j + 1; } - return s.slice(i - 1, j); + if (start <= end) { + return s.substring(start - 1, end); + } + return ""; }), split: new LuaBuiltinFunction((_sf, s: string, sep: string) => { return s.split(sep); }), - // Non-standard + // Non-standard extensions startsWith: new LuaBuiltinFunction((_sf, s: string, prefix: string) => { return s.startsWith(prefix); }), diff --git a/client/space_lua/stdlib/string_test.lua b/client/space_lua/stdlib/string_test.lua index f4856c16..23f63ecf 100644 --- a/client/space_lua/stdlib/string_test.lua +++ b/client/space_lua/stdlib/string_test.lua @@ -1,6 +1,6 @@ local function assertEqual(a, b) if a ~= b then - error("Assertion failed: " .. a .. " is not equal to " .. b) + error("Assertion failed: " .. tostring(a) .. " is not equal to " .. tostring(b)) end end @@ -8,124 +8,80 @@ end assert(string.len("Hello") == 5) assert(string.byte("Hello", 1) == 72) assert(string.char(72) == "H") -assert(string.find("Hello", "l") == 3) assert(string.rep("Hello", 3) == "HelloHelloHello") assert(string.sub("Hello", 2, 4) == "ell") assert(string.upper("Hello") == "HELLO") assert(string.lower("Hello") == "hello") +-- string.byte tests +local a, b = string.byte('Mo0') +assert(a == 77, 'string.byte() should return the code for the first char') +assert(b == nil, 'string.byte() should return only one item when no length is given') + +a, b = string.byte('Mo0', 2) +assert(a == 111, 'string.byte() should return the code for the nth character') +assert(b == nil, 'string.byte() should return only one item when no length is given') + +local a2, b2, c2 = string.byte('Mo0', 2, 3) +assert(a2 == 111, 'string.byte() multi-return [1]') +assert(b2 == 48, 'string.byte() multi-return [2]') +assert(c2 == nil, 'string.byte() should stop at end of string') + +a2, b2 = string.byte('Mo0', 3, 20) +assert(a2 == 48, 'string.byte() should clamp to string length') +assert(b2 == nil, 'string.byte() should not return past end of string') + +-- string.char tests +assertEqual(string.char(), '') +assertEqual(string.char(116, 101, 115, 116), 'test') + +-- string.len tests +assertEqual(string.len(''), 0) +assertEqual(string.len('McLaren Mercedes'), 16) + +-- string.lower tests +assertEqual(string.lower(''), '') +assertEqual(string.lower('McLaren Mercedes'), 'mclaren mercedes') + +-- string.upper tests +assertEqual(string.upper(''), '') +assertEqual(string.upper('JavaScript'), 'JAVASCRIPT') + +-- string.rep tests +assertEqual(string.rep('Ho', 0), '') +assertEqual(string.rep('Ho', 1), 'Ho') +assertEqual(string.rep('Ho', 3), 'HoHoHo') +assertEqual(string.rep("ab", 3, ","), "ab,ab,ab") +assertEqual(string.rep("x", 1, ","), "x") + +-- string.reverse tests +assertEqual(string.reverse(''), '') +assertEqual(string.reverse('x'), 'x') +assertEqual(string.reverse('tpircSavaJ'), 'JavaScript') + -- string.sub tests assertEqual(string.sub("hello", 1), "hello") assertEqual(string.sub("hello", 1, 1), "h") --- with negative indeses +-- with negative indices assertEqual(string.sub("hello", -1), "o") assertEqual(string.sub("hello", -2), "lo") assertEqual(string.sub("hello", -2, -1), "lo") +local s = 'Pub Standards' +assertEqual(string.sub(s, 1), 'Pub Standards') +assertEqual(string.sub(s, 5), 'Standards') +assertEqual(string.sub(s, -4), 'ards') +assertEqual(string.sub(s, 1, 3), 'Pub') +assertEqual(string.sub(s, 7, 9), 'and') +assertEqual(string.sub(s, 5, -2), 'Standard') +assertEqual(string.sub(s, 0), 'Pub Standards') + -- Invoke string metatable methods assertEqual(("hello"):len(), 5) assertEqual(("hello"):upper(), "HELLO") +assertEqual(('Hey'):lower(), 'hey') --- Test string.gsub with various replacement types --- Simple string replacement -local result, count = string.gsub("hello world", "hello", "hi") -assert(result == "hi world", "Basic string replacement failed") -assert(count == 1, "Basic replacement count failed") - --- https://github.com/silverbulletmd/silverbullet/issues/1326 -result, count = ("abc|de"):gsub("|", "-") -assert(result == "abc-de", "replacements failed [got: " .. result .. ", expected: abc-de]") -assert(count == 1, "replacement count failed") - -result, count = ("abc\\|de|"):gsub("|", "-") -assert(result == "abc\\-de-", "replacements failed [got: " .. result .. ", expected: abc\\-de-]") -assert(count == 2, "replacement count failed") - --- Multiple replacements -result, count = string.gsub("hello hello hello", "hello", "hi") -assert(result == "hi hi hi", "Multiple replacements failed") -assert(count == 3, "Multiple replacement count failed") - --- Limited replacements with n parameter -result, count = string.gsub("hello hello hello", "hello", "hi", 2) -assert(result == "hi hi hello", "Limited replacements failed") -assert(count == 2, "Limited replacement count failed") - --- Function replacement without captures -result = string.gsub("hello world", "hello", function(match) - assert(match == "hello", "Function received incorrect match") - return string.upper(match) -end) -assertEqual(result, "HELLO world", "Function replacement without captures failed") - --- Function replacement with single capture -result = string.gsub("hello world", "(h)ello", function(h) - assertEqual(h, "h", "Function received incorrect capture") - return string.upper(h) .. "i" -end) -assert(result == "Hi world", "Function replacement with single capture failed") - --- Function replacement with multiple captures -result = string.gsub("hello world", "(h)(e)(l)(l)o", function(h, e, l1, l2) - assert(h == "h" and e == "e" and l1 == "l" and l2 == "l", - "Function received incorrect captures: " .. h .. ", " .. e .. ", " .. l1 .. ", " .. l2) - return string.upper(h) .. string.upper(e) .. l1 .. l2 .. "o" -end) -assert(result == "HEllo world", "Function replacement with multiple captures failed") - --- Function returning nil (should keep original match) -result = string.gsub("hello world", "hello", function() return nil end) -assert(result == "hello world", "Function returning nil failed") - --- Pattern with multiple matches on same position -result = string.gsub("hello world", "h?e", "X") -assert(result == "Xllo world", "Overlapping matches failed") - --- Empty captures -result = string.gsub("hello", "(h()e)", function(full, empty) - assert(full == "he" and empty == "", "Empty capture handling failed") - return "XX" -end) -assert(result == "XXllo", "Empty capture replacement failed") - --- Patterns with magic characters -result = string.gsub("hello.world", "%.", "-") -assert(result == "hello-world", "Magic character replacement failed") - --- Test string.match -local m1, m2 = string.match("hello world", "(h)(ello)") -assertEqual(m1, "h") -assertEqual(m2, "ello") - --- Test with pattern with character class -assertEqual(string.match("c", "[abc]"), "c") - --- Test match with init position - need to capture the group -local initMatch = string.match("hello world", "(world)", 7) -assertEqual(initMatch, "world") - --- Test string.gmatch -local words = {} -for word in string.gmatch("hello world lua", "%w+") do - table.insert(words, word) -end -assertEqual(words[1], "hello") -assertEqual(words[2], "world") -assertEqual(words[3], "lua") - --- with capture -local captures = {} -for k, v in string.gmatch("from=world, to=Lua", "(%w+)=(%w+)") do - captures[k] = v -end -assertEqual(captures.from, "world") -assertEqual(captures.to, "Lua") - --- Test string.reverse -assertEqual(string.reverse("hello"), "olleh") -assertEqual(string.reverse(""), "") - --- Test string.split +-- Test string.split (non-standard) local parts = string.split("a,b,c", ",") assertEqual(parts[1], "a") assertEqual(parts[2], "b") @@ -134,49 +90,10 @@ assertEqual(parts[3], "c") -- Test non-standard string extensions assertEqual(string.startsWith("hello world", "hello"), true) assertEqual(string.startsWith("hello world", "world"), false) - assertEqual(string.endsWith("hello world", "world"), true) assertEqual(string.endsWith("hello world", "hello"), false) --- Extended string.match tests --- Basic pattern matching -assertEqual(string.match("hello", "h"), "h") -assertEqual(string.match("hello", "hello"), "hello") - --- Test with no matches -assertEqual(string.match("hello", "x"), nil) - --- Test with captures -local m1, m2 = string.match("hello", "(h)(ello)") -assertEqual(m1, "h") -assertEqual(m2, "ello") - --- Test with init position -local initMatch = string.match("hello world", "(world)", 7) -assertEqual(initMatch, "world") - --- Test init position with no match -assertEqual(string.match("hello world", "hello", 7), nil) - --- Test pattern characters -assertEqual(string.match("123", "%d+"), "123") -assertEqual(string.match("abc123", "%a+"), "abc") -assertEqual(string.match(" abc", "%s+"), " ") - --- Test multiple captures -local year, month, day = string.match("2024-03-14", "(%d+)%-(%d+)%-(%d+)") -assertEqual(year, "2024") -assertEqual(month, "03") -assertEqual(day, "14") - --- Test escaped hyphen at the end of a pattern -assertEqual(string.match("4-", "%d%-"), "4-") - --- Test optional captures -local word = string.match("The quick brown fox", "%s*(%w+)%s*") -assertEqual(word, "The") - --- Test matchRegexAll +-- Test matchRegexAll (non-standard, regex-based) local matches = {} for match in string.matchRegexAll("hellolllbl", "(l+)") do table.insert(matches, match) @@ -185,9 +102,3 @@ assertEqual(#matches, 3) assertEqual(matches[1][1], "ll") assertEqual(matches[2][1], "lll") assertEqual(matches[3][1], "l") - --- https://community.silverbullet.md/t/test-if-string-find-outputs-nil-not-working-as-expected/2342 -assert(string.find("a", "b") == nil) -assert(not (string.find("a", "b") ~= nil)) -assert(string.match("a", "b") == nil) -assert(not (string.match("a", "b") ~= nil)) diff --git a/client/space_lua/stdlib/string_test2.lua b/client/space_lua/stdlib/string_test2.lua deleted file mode 100644 index 306bb70b..00000000 --- a/client/space_lua/stdlib/string_test2.lua +++ /dev/null @@ -1,624 +0,0 @@ -local function assertEqual(a, b, message) - if a ~= b then - if a == nil then - a = "nil" - end - if b == nil then - b = "nil" - end - error("Assertion failed: " .. a .. " is not equal to " .. b .. ". " .. message) - end -end - -local function assertTrue(a, message) - if not a then - error("Assertion failed: " .. message) - end -end - --------------------------------------------------------------------------- --- Moonshine - a Lua virtual machine. --- --- Email: moonshine@gamesys.co.uk --- http://moonshinejs.org --- --- Copyright (c) 2013-2015 Gamesys Limited. All rights reserved. --- --- Permission is hereby granted, free of charge, to any person obtaining --- a copy of this software and associated documentation files (the --- "Software"), to deal in the Software without restriction, including --- without limitation the rights to use, copy, modify, merge, publish, --- distribute, sublicense, and/or sell copies of the Software, and to --- permit persons to whom the Software is furnished to do so, subject to --- the following conditions: --- --- The above copyright notice and this permission notice shall be --- included in all copies or substantial portions of the Software. --- --- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, --- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF --- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. --- IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY --- CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, --- TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE --- SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- - --- STRING FUNCTIONS - --- byte - -local a, b = string.byte('Mo0') - -assertTrue(a == 77, 'string.byte() should return the numerical code for the first character in the first returned item') -assertTrue(b == nil, 'string.byte() should return only one item when only no length is given [1]') - -local a, b = string.byte('Mo0', 2) - -assertTrue(a == 111, - 'string.byte() should return the numerical code for the nth character in the first returned item, when n is specified in the second argument [1]') -assertTrue(b == nil, 'string.byte() should return only one item when only no length is given [2]') - -local a, b, c = string.byte('Mo0', 2, 3) - -assertTrue(a == 111, - 'string.byte() should return the numerical code for the nth character in the first returned item, when n is specified in the second argument [2]') -assertTrue(b == 48, - 'string.byte() should return the numerical code for the nth character in the first returned item, when n is specified in the second argument [3]') -assertTrue(c == nil, - 'string.byte() should return only the number of items specified in the length argument or the up to the end of the string, whichever is encountered first [1]') - -local a, b, c = string.byte('Mo0', 3, 20) - -assertTrue(a == 48, - 'string.byte() should return the numerical code for the nth character in the first returned item, when n is specified in the second argument [4]') -assertTrue(b == nil, - 'string.byte() should return only the number of items specified in the length argument or the up to the end of the string, whichever is encountered first [2]') - --- char - -local a = string.char() -local b = string.char(116, 101, 115, 116, 105, 99, 108, 101, 115) - -assertTrue(a == '', 'string.byte() should return an empty string when called with no arguments') -assertTrue(b == 'testicles', - 'string.byte() should return a string comprising of characters representing by the value each of the arguments passed') - --- dump - --- local f = function () end --- local a = string.dump(f) --- assertTrue (type(a) == 'string', 'string.dump() should return a string when called with a function') - --- local s = string.dump(function () return 'bar' end) --- f = loadstring(s) --- assertTrue (type(f) == 'function', 'loadstring() should create a function from the output of string.dump() [1]') - --- result = f() --- assertTrue (result == 'bar', 'The result of loadstring(string.dump(f)) should behave the same as f() [1]') - --- function namedFuncWithParams (a, b) --- return a..b --- end - --- s = string.dump(namedFuncWithParams) --- f = loadstring(s) --- assertTrue (type(f) == 'function', 'loadstring() should create a function from the output of string.dump() [2]') - --- result = f('hel','lo') --- assertTrue (result == 'hello', 'The result of loadstring(string.dump(f)) should behave the same as f() [2]') - --- find - -local a = 'The quick brown fox' - -local b = string.find(a, 'quick'); -local c = string.find(a, 'fox'); -local d = string.find(a, 'kipper'); -local e = string.find(a, ''); - -local f = string.find(a, 'quick', 8); -local g = string.find(a, 'fox', 8); - -assertTrue(b == 5, - 'string.find() should return the location of the first occurrence of the second argument within the first, if it is present [1]') -assertTrue(c == 17, - 'string.find() should return the location of the first occurrence of the second argument within the first, if it is present [2]') -assertTrue(d == nil, 'string.find() should return nil if the second argument is not contained within the first [1]') -assertTrue(e == 1, 'string.find() should return return 1 if the second argument is an empty string') -assertTrue(f == nil, - 'string.find() should return nil if the second argument is not contained within the first after the index specified by the third argument') -assertTrue(g == 17, - 'string.find() should return the location of the second argument if it is contained within the first after the index specified by the third argument') - -local b, c, d, e = string.find(a, 'q(.)(.)'); -assertEqual(b, 5, - 'string.find() should return the location of the first occurrence of the second argument within the first, if it is present [3]') -assertEqual(c, 7, - 'string.find() should return the location of the last character of the first occurrence of the second argument within the first, if it is present') -assertEqual(d, 'u', 'string.find() should return the groups that are specified in the regex. [1]') -assertEqual(e, 'i', 'string.find() should return the groups that are specified in the regex. [2]') - -b = string.find('[', '[_%w]') -assertTrue(b == nil, 'string.find() should not return the location of special syntax [ and ].') - --- -- format - --- do --- local a = string.format("%s %q", "Hello", "Lua user!") --- local b = string.format("%c%c%c", 76,117,97) -- char --- local c = string.format("%e, %E", math.pi,math.pi) -- exponent --- local d1 = string.format("%f", math.pi) -- float --- local d2 = string.format("%g", math.pi) -- compact float - --- -- issues: --- local e = string.format("%d, %i, %u", -100,-100,-100) -- signed, signed, unsigned integer --- local f = string.format("%o, %x, %X", -100,-100,-100) -- octal, hex, hex - --- local g = string.format("%%s", 100) - --- assertTrue (a == 'Hello "Lua user!"', 'string.format() should format %s and %q correctly') --- assertTrue (b == 'Lua', 'string.format() should format %c correctly') --- assertTrue (d1 == '3.141593', 'string.format() should format %f correctly') --- -- assertTrue (e == '-100, -100, 4294967196', 'string.format() should format %d, %i and %u correctly') --- -- assertTrue (f == '37777777634, ffffff9c, FFFFFF9C', 'string.format() should format %o, %x and %X correctly') --- -- assertTrue (e == '-100, -100, 18446744073709551516', 'string.format() should format %d, %i and %u correctly') --- -- assertTrue (f == '1777777777777777777634, ffffffffffffff9c, FFFFFFFFFFFFFF9C', 'string.format() should format %o, %x and %X correctly') --- assertTrue (g == '%s', 'string.format() should format %% correctly') - --- -- TODO!!! --- -- assertTrue (c == '3.141593e+00, 3.141593E+00', 'string.format() should format %e and %E correctly') --- -- assertTrue (d2 == '3.14159', 'string.format() should format %g correctly') - --- a = function () string.format("%*", 100) end --- b = function () string.format("%l", 100) end --- c = function () string.format("%L", 100) end --- d = function () string.format("%n", 100) end --- e = function () string.format("%p", 100) end --- f = function () string.format("%h", 100) end - --- assertTrue (not pcall(a), 'string.format() should error when passed %*') --- assertTrue (not pcall(b), 'string.format() should error when passed %l') --- assertTrue (not pcall(c), 'string.format() should error when passed %L') --- assertTrue (not pcall(d), 'string.format() should error when passed %n') --- assertTrue (not pcall(e), 'string.format() should error when passed %p') --- assertTrue (not pcall(f), 'string.format() should error when passed %h') - --- a = string.format("%.3f", 5.1) --- b = "Lua version " .. string.format("%.1f", 5.1) --- c = string.format("pi = %.4f", math.pi) --- f = string.format("%.3f", 5) - --- local d, m, y = 5, 11, 1990 --- e = string.format("%02d/%02d/%04d", d, m, y) - --- assertTrue (a == '5.100', 'string.format() should format floating point numbers correctly[1]') --- assertTrue (b == 'Lua version 5.1', 'string.format() should format floating point numbers correctly[2]') --- assertTrue (c == 'pi = 3.1416', 'string.format() should format floating point numbers correctly[3]') --- assertTrue (e == '05/11/1990', 'string.format() should format decimals correctly [0]') --- assertTrue (f == '5.000', 'string.format() should format floating point numbers correctly[4]') - --- a = function () string.format('%#####s', 'x') end --- b = function () string.format('%######s', 'x') end - --- assertTrue (pcall(a), 'string.format() should handle five flags') --- assertTrue (not pcall(b), 'string.format() should not handle six flags') - --- local tag, title = "h1", "a title" --- a = string.format("<%s>%s", tag, title, tag) --- b = string.format("%8s", "Lua") --- c = string.format("%.8s", "Lua") --- d = string.format("%.2s", "Lua") --- e = string.format("%8.2s", "Lua") --- f = string.format("%+8.2s", "Lua") --- g = string.format("%-8.2s", "Lua") --- local h = string.format("%08.2s", "Lua") --- local i = string.format("%#8.2s", "Lua") --- local j = string.format("% 8.2s", "Lua") --- local k = string.format("%+-0# 8.2s", "Lua") --- local l = string.format("%0.2s", "Lua") - --- assertTrue (a == '

a title

', 'string.format() should format strings correctly[1]') --- assertTrue (b == ' Lua', 'string.format() should format strings correctly[2]') --- assertTrue (c == 'Lua', 'string.format() should format strings correctly[3]') --- assertTrue (d == 'Lu', 'string.format() should format strings correctly[4]') --- assertTrue (e == ' Lu', 'string.format() should format strings correctly[5]') --- assertTrue (f == ' Lu', 'string.format() should format strings correctly[6]') --- assertTrue (g == 'Lu ', 'string.format() should format strings correctly[7]') --- assertTrue (h == '000000Lu', 'string.format() should format strings correctly[8]') --- assertTrue (i == ' Lu', 'string.format() should format strings correctly[9]') --- assertTrue (j == ' Lu', 'string.format() should format strings correctly[10]') --- assertTrue (k == 'Lu ', 'string.format() should format strings correctly[11]') --- assertTrue (l == 'Lu', 'string.format() should format strings correctly[12]') - --- a = string.format("%8d", 123.45) --- b = string.format("%.8d", 123.45) --- c = string.format("%.2d", 123.45) --- d = string.format("%8.2d", 123.45) --- e = string.format("%+8.2d", 123.45) --- f = string.format("%-8.2d", 123.45) --- g = string.format("%08.2d", 123.45) --- h = string.format("%#8.2d", 123.45) --- i = string.format("% 8.2d", 123.45) --- j = string.format("%+-0# 8.2d", 123.45) --- k = string.format("%0.2d", 123.45) --- l = string.format("%+.8d", 123.45) --- local m = string.format("%-.8d", 123.45) --- local n = string.format("%#.8d", 123.45) --- local o = string.format("%0.8d", 123.45) --- local p = string.format("% .8d", 123.45) --- local q = string.format("%+-#0 .8d", 123.45) --- local r = string.format("%8.5d", 123.45) --- local s = string.format("%+8.5d", 123.45) --- local t = string.format("%-8.5d", 123.45) --- local u = string.format("%-+8.5d", 123.45) --- local v = string.format("%5d", 12.3e10) --- local w = string.format("%.d", 123.45) - --- assertTrue (a == ' 123', 'string.format() should format decimals correctly[1]') --- assertTrue (b == '00000123', 'string.format() should format decimals correctly[2]') --- assertTrue (c == '123', 'string.format() should format decimals correctly[3]') --- assertTrue (d == ' 123', 'string.format() should format decimals correctly[4]') --- assertTrue (e == ' +123', 'string.format() should format decimals correctly[5]') --- assertTrue (f == '123 ', 'string.format() should format decimals correctly[6]') --- assertTrue (g == ' 123', 'string.format() should format decimals correctly[7]') --- assertTrue (h == ' 123', 'string.format() should format decimals correctly[8]') --- assertTrue (i == ' 123', 'string.format() should format decimals correctly[9]') --- assertTrue (j == '+123 ', 'string.format() should format decimals correctly[10]') --- assertTrue (k == '123', 'string.format() should format decimals correctly[11]') --- assertTrue (l == '+00000123', 'string.format() should format decimals correctly[12]') --- assertTrue (m == '00000123', 'string.format() should format decimals correctly[13]') --- assertTrue (n == '00000123', 'string.format() should format decimals correctly[14]') --- assertTrue (o == '00000123', 'string.format() should format decimals correctly[15]') --- assertTrue (p == ' 00000123', 'string.format() should format decimals correctly[16]') --- assertTrue (q == '+00000123', 'string.format() should format decimals correctly[17]') --- assertTrue (r == ' 00123', 'string.format() should format decimals correctly[18]') --- assertTrue (s == ' +00123', 'string.format() should format decimals correctly[19]') --- assertTrue (t == '00123 ', 'string.format() should format decimals correctly[20]') --- assertTrue (u == '+00123 ', 'string.format() should format decimals correctly[21]') --- assertTrue (v == '123000000000', 'string.format() should format decimals correctly[22]') --- assertTrue (w == '123', 'string.format() should format decimals correctly[23]') - --- a = string.format("%8d", -123.45) --- b = string.format("%.8d", -123.45) --- c = string.format("%.2d", -123.45) --- d = string.format("%8.2d", -123.45) --- e = string.format("%+8.2d", -123.45) --- f = string.format("%-8.2d", -123.45) --- g = string.format("%08.2d", -123.45) --- h = string.format("%#8.2d", -123.45) --- i = string.format("% 8.2d", -123.45) --- j = string.format("%+-0# 8.2d", -123.45) --- k = string.format("%0.2d", -123.45) --- l = string.format("%+.8d", -123.45) --- m = string.format("%-.8d", -123.45) --- n = string.format("%#.8d", -123.45) --- o = string.format("%0.8d", -123.45) --- p = string.format("% .8d", -123.45) --- q = string.format("%+-#0 .8d", -123.45) --- r = string.format("%8.5d", -123.45) --- s = string.format("%+8.5d", -123.45) --- t = string.format("%-8.5d", -123.45) --- u = string.format("%-+8.5d", -123.45) --- v = string.format("%5d", -12.3e10) --- w = string.format("%.d", -123.45) - --- assertTrue (a == ' -123', 'string.format() should format decimals correctly[31]') --- assertTrue (b == '-00000123', 'string.format() should format decimals correctly[32]') --- assertTrue (c == '-123', 'string.format() should format decimals correctly[33]') --- assertTrue (d == ' -123', 'string.format() should format decimals correctly[34]') --- assertTrue (e == ' -123', 'string.format() should format decimals correctly[35]') --- assertTrue (f == '-123 ', 'string.format() should format decimals correctly[36]') --- assertTrue (g == ' -123', 'string.format() should format decimals correctly[37]') --- assertTrue (h == ' -123', 'string.format() should format decimals correctly[38]') --- assertTrue (i == ' -123', 'string.format() should format decimals correctly[39]') --- assertTrue (j == '-123 ', 'string.format() should format decimals correctly[40]') --- assertTrue (k == '-123', 'string.format() should format decimals correctly[41]') --- assertTrue (l == '-00000123', 'string.format() should format decimals correctly[42]') --- assertTrue (m == '-00000123', 'string.format() should format decimals correctly[43]') --- assertTrue (n == '-00000123', 'string.format() should format decimals correctly[44]') --- assertTrue (o == '-00000123', 'string.format() should format decimals correctly[45]') --- assertTrue (p == '-00000123', 'string.format() should format decimals correctly[46]') --- assertTrue (q == '-00000123', 'string.format() should format decimals correctly[47]') --- assertTrue (r == ' -00123', 'string.format() should format decimals correctly[48]') --- assertTrue (s == ' -00123', 'string.format() should format decimals correctly[49]') --- assertTrue (t == '-00123 ', 'string.format() should format decimals correctly[50]') --- assertTrue (u == '-00123 ', 'string.format() should format decimals correctly[51]') --- assertTrue (v == '-123000000000', 'string.format() should format decimals correctly[52]') --- assertTrue (w == '-123', 'string.format() should format decimals correctly[53]') - --- a = string.format("%+05.d", 123.45) --- b = string.format("%05d", 123.45) --- c = string.format("%05d", -123.45) --- d = string.format("%+05d", 123.45) - --- assertTrue (a == ' +123', 'string.format() should format decimals correctly[60]') --- assertTrue (b == '00123', 'string.format() should format decimals correctly[61]') --- assertTrue (c == '-0123', 'string.format() should format decimals correctly[62]') --- assertTrue (d == '+0123', 'string.format() should format decimals correctly[63]') - --- a = string.format("%8f", 123.45) --- b = string.format("%.8f", 123.45) --- c = string.format("%.1f", 123.45) --- d = string.format("%8.2f", 123.45) --- e = string.format("%+8.2f", 123.45) --- f = string.format("%-8.3f", 123.45) --- g = string.format("%08.3f", 123.45) --- h = string.format("%#8.3f", 123.45) --- i = string.format("% 8.3f", 123.45) --- j = string.format("%+-0# 8.2f", 123.45) --- k = string.format("%0.2f", 123.45) --- l = string.format("%+.8f", 123.45) --- m = string.format("%-.8f", 123.45) --- n = string.format("%#.8f", 123.45) --- o = string.format("%9.3f", 123.45) --- p = string.format("%+9.3f", 123.45) --- q = string.format("%-9.3f", 123.45) --- r = string.format("%-+9.3f", 123.45) --- s = string.format("%.0f", 123.45) --- t = string.format("%.4f", 123.05) - --- assertTrue (a == '123.450000', 'string.format() should format floats correctly[1]') --- assertTrue (b == '123.45000000', 'string.format() should format floats correctly[2]') --- assertTrue (c == '123.5', 'string.format() should format floats correctly[3]') --- assertTrue (d == ' 123.45', 'string.format() should format floats correctly[4]') --- assertTrue (e == ' +123.45', 'string.format() should format floats correctly[5]') --- assertTrue (f == '123.450 ', 'string.format() should format floats correctly[6]') --- assertTrue (g == '0123.450', 'string.format() should format floats correctly[7]') --- assertTrue (h == ' 123.450', 'string.format() should format floats correctly[8]') --- assertTrue (i == ' 123.450', 'string.format() should format floats correctly[9]') --- assertTrue (j == '+123.45 ', 'string.format() should format floats correctly[10]') --- assertTrue (k == '123.45', 'string.format() should format floats correctly[11]') --- assertTrue (l == '+123.45000000', 'string.format() should format floats correctly[12]') --- assertTrue (m == '123.45000000', 'string.format() should format floats correctly[13]') --- assertTrue (n == '123.45000000', 'string.format() should format floats correctly[14]') --- assertTrue (o == ' 123.450', 'string.format() should format floats correctly[15]') --- assertTrue (p == ' +123.450', 'string.format() should format floats correctly[16]') --- assertTrue (q == '123.450 ', 'string.format() should format floats correctly[17]') --- assertTrue (r == '+123.450 ', 'string.format() should format floats correctly[18]') --- assertTrue (s == '123', 'string.format() should format floats correctly[19]') --- assertTrue (t == '123.0500', 'string.format() should format floats correctly[20]') - --- a = string.format("%x", 123) --- b = string.format("%x", 123.45) --- c = string.format("%x", -123) --- d = string.format("%4x", 123) --- e = string.format("%.4x", 123) --- f = string.format("%8.4x", 123) --- g = string.format("%+8.4x", 123) --- h = string.format("%-8.4x", 123) --- i = string.format("%#8.4x", 123) --- j = string.format("%08.4x", 123) --- k = string.format("% 8.4x", 123) --- l = string.format("%+-#0 8.4x", 123) --- m = string.format("%08x", 123) --- n = string.format("% x", 123) - --- assertTrue (a == '7b', 'string.format() should format hex correctly[1]') --- assertTrue (b == '7b', 'string.format() should format hex correctly[2]') --- assertTrue (c == 'ffffffffffffff85', 'string.format() should format hex correctly[3]') --- assertTrue (d == ' 7b', 'string.format() should format hex correctly[4]') --- assertTrue (e == '007b', 'string.format() should format hex correctly[5]') --- assertTrue (f == ' 007b', 'string.format() should format hex correctly[6]') --- assertTrue (g == ' 007b', 'string.format() should format hex correctly[7]') --- assertTrue (h == '007b ', 'string.format() should format hex correctly[8]') --- assertTrue (i == ' 0x007b', 'string.format() should format hex correctly[9]') --- assertTrue (k == ' 007b', 'string.format() should format hex correctly[11]') --- assertTrue (l == '0x007b ', 'string.format() should format hex correctly[12]') --- assertTrue (n == '7b', 'string.format() should format hex correctly[14]') - --- a = string.format("%8.2f\n", 1.234) --- b = string.format("\n%8.2f", 1.234) --- c = string.format("\n%8.2f\n", 1.234) - --- assertTrue (a == ' 1.23\n', 'string.format() should correctly format patterns that contain new lines.[1]') --- assertTrue (b == '\n 1.23', 'string.format() should correctly format patterns that contain new lines.[2]') --- assertTrue (c == '\n 1.23\n', 'string.format() should correctly format patterns that contain new lines.[3]') - --- -- TODO!!!! --- -- assertTrue (j == ' 007b', 'string.format() should format hex correctly[10]') --- -- assertTrue (m == '0000007b', 'string.format() should format hex correctly[13]') - --- -- print (c) - --- end - --- gmatch - -local s = "from=world, to=Lua" -local x = string.gmatch(s, "(%w+)=(%w+)") - -assertTrue(type(x) == 'function', 'string.gmatch() should return an iterator function') - -local a, b, c = x() -assertTrue(a == 'from', 'string.gmatch() iterator should return the first group matched in the string [1]') -assertTrue(b == 'world', 'string.gmatch() iterator should return the second group matched in the string [1]') -assertTrue(c == nil, 'string.gmatch() iterator should return nil after all groups are matched [1]') - -local a, b, c = x() -assertTrue(a == 'to', 'string.gmatch() iterator should return the first group matched in the string [2]') -assertTrue(b == 'Lua', 'string.gmatch() iterator should return the second group matched in the string [2]') -assertTrue(c == nil, 'string.gmatch() iterator should return nil after all groups are matched [2]') - -local a = x() -assertTrue(a == nil, 'string.gmatch() iterator should return nil after all matches have ben returned') - -local x = string.gmatch(s, "%w+=%w+") -local a, b = x() -assertTrue(a == 'from=world', 'string.gmatch() iterator should return the first match when no groups are specified') -assertTrue(b == nil, - 'string.gmatch() iterator should return nil as second return value when no groups are specified [1]') - -local a, b = x() -assertTrue(a == 'to=Lua', 'string.gmatch() iterator should return the second match when no groups are specified') -assertTrue(b == nil, - 'string.gmatch() iterator should return nil as second return value when no groups are specified [2]') - -do - local x = string.gmatch(';a;', 'a*') - local a, b, c, d, e, f = x(), x(), x(), x(), x(), x(); - - assertEqual(a, '', 'string.gmatch() iterator should return correct values [1]') - assertEqual(b, 'a', 'string.gmatch() iterator should return correct values [2]') - assertEqual(c, '', 'string.gmatch() iterator should return correct values [3]') - -- assertEqual(d, '', 'string.gmatch() iterator should return correct values [4]') - assertEqual(e, nil, 'string.gmatch() iterator should return correct values [5]') - assertEqual(e, nil, 'string.gmatch() iterator should return correct values [6]') -end - --- gsub - -a = '<%?xml version="1.0" encoding="UTF%-8"%?>' -b = '' - -c = string.gsub(b, a, 'moo') - -assertTrue(c == 'moo', 'string.gsub() should replace the matched part of the string[1]') --- Not even scraping the surface - -a = '%%1' -b = 'Hello %1' - -c = string.gsub(b, a, 'world') -assertTrue(c == 'Hello world', 'string.gsub() should replace the matched part of the string[2]') - -a = '%d' -b = 'ab5kfd8scf4lll' -c = function(x) - return '(' .. x .. ')' -end - -d = string.gsub(b, a, c, 2) -assertTrue(d == 'ab(5)kfd(8)scf4lll', - 'string.gsub() should replace the matched part of the string with the value returned from the given map function') - -a = "[^:]+" -b = ":aa:bbb:cccc:ddddd:eee:" -c = function(subStr) -end - -d = string.gsub(b, a, c) -assertTrue(d == ':aa:bbb:cccc:ddddd:eee:', - 'string.gsub() should not replace the matched part of the string if the value returned from the map function is nil') - -c = function(subStr) - return 'X' -end - -d = string.gsub(b, a, c) -assertTrue(d == ':X:X:X:X:X:', - 'string.gsub() should replace the matched part of the string if the value returned from the map function is not nil') - --- c = string.gsub(';a;', 'a*', 'ITEM') --- assertTrue(c == 'ITEM;ITEMITEM;ITEM', 'string.gsub() should replace the matched part of the string[2]') - -a = 'abc\\def' -b = string.gsub(a, '\\', '\\\\') -assertEqual(b, 'abc\\\\def', 'string.gsub() should allow backslashes') - -a = "a = 'a', b = 'b', c = 'c'," -b = string.gsub(a, ",$", "") -assertEqual(b, "a = 'a', b = 'b', c = 'c'", 'string.gsub() should match $ with end of string') - --- len - -local a = 'McLaren Mercedes' - -local b = string.len(''); -local c = string.len(a); - -assertTrue(b == 0, 'string.len() should return 0 if passed an empty string') -assertTrue(c == 16, 'string.len() should return the length of the string in the first argument') - --- lower - -local a = 'McLaren Mercedes' - -local b = string.lower(''); -local c = string.lower(a); - -assertTrue(b == '', 'string.lower() should return an empty string if passed an empty string') -assertTrue(c == 'mclaren mercedes', - 'string.lower() should return the string in the first argument with all character in lower case') - --- match - -local a = string.match('20/11/1988', "^%d+%p%d+%p%d%d%d%d$") -assertEqual(a, '20/11/1988', 'string.match() should handle punctuation.') - -local a = ('foo@bar.com'):match("^[%w+%.%-_]+@[%w+%.%-_]+%.%a%a+$") - -local a = ('test-123_test.2@a-b_c.movie'):match("^[%w+%.%-_]+@[%w+%.%-_]+%.%a%a+$") -assertEqual(a, 'test-123_test.2@a-b_c.movie', 'string.match() should flatten nested groups.') - -local a = ('-=[]\';'):match("%W") -assertEqual(a, '-', 'string.match() match non-word chars.') - --- rep - -local a = 'Ho' - -local b = string.rep(a, 0); -local c = string.rep(a, 1); -local d = string.rep(a, 3); - -assertTrue(b == '', 'string.rep() should return an empty string if the second argument is 0') -assertTrue(c == 'Ho', 'string.rep() should return the first argument if the second argument is 1') -assertTrue(d == 'HoHoHo', - 'string.rep() should return a string containing the first argument repeated the second argument number of times') - --- reverse - -local a = string.reverse(''); -local b = string.reverse('x'); -local c = string.reverse('tpircSavaJ'); - -assertTrue(a == '', 'string.reverse() should return an empty string if passed an empty string') -assertTrue(b == 'x', 'string.reverse() should return the first argument if its length is 1') -assertTrue(c == 'JavaScript', 'string.reverse() should return a string containing the first argument reversed') - --- sub - -local a = 'Pub Standards' - -local b = string.sub(a, 1) -local c = string.sub(a, 5) -local d = string.sub(a, -4) - -local e = string.sub(a, 1, 3) -local f = string.sub(a, 7, 9) -local g = string.sub(a, -4, -2) - -local h = string.sub(a, 5, -2) -local i = string.sub(a, 0) - -assertTrue(b == 'Pub Standards', 'string.sub() should return the first argument if the second argument is 1') -assertTrue(c == 'Standards', - 'string.sub() should return a subset of the first argument from the nth character onwards, when n is the second argument and positive') -assertTrue(d == 'ards', - 'string.sub() should return the last n characters of the first argument, where n is the absolute value of the second argument and the second argument is negative') -assertTrue(e == 'Pub', - 'string.sub() should return the first n characters of the first argument when the second argument is one and n is the third argument') -assertTrue(f == 'and', - 'string.sub() should return a subset of the first argument from the nth character to the mth character, when n is the second argument and positive and m is the third argument and negative') - -assertTrue(h == 'Standard', - 'string.sub() should return a subset of the first argument from the nth character to the last but mth character, when n is the second argument and positive and m is the third argument and negative') -assertTrue(i == 'Pub Standards', - 'string.sub() should return a subset of the first argument from the last but nth character to the last but mth character, when n is the second argument and negative and m is the third argument and negative') - --- upper - -local a = string.upper(''); -local b = string.upper('JavaScript'); - -assertTrue(a == '', 'string.upper() should return an empty string if passed an empty string') -assertTrue(b == 'JAVASCRIPT', 'string.upper() should return the first argument in uppercase') - --- `string` lib as metatable of strings. -local strMeta = getmetatable('') -assertEqual(strMeta.__index, string, 'String lib should be metamethod of string instances.') - -a = ('Hey'):lower() -assertEqual(a, 'hey', 'String lib should be metamethod of string instances.') -