diff --git a/lib/lua/runtime.sx b/lib/lua/runtime.sx index 13cd6ad1..2e99ebe6 100644 --- a/lib/lua/runtime.sx +++ b/lib/lua/runtime.sx @@ -651,3 +651,230 @@ (dict-set! coroutine "yield" lua-coroutine-yield) (dict-set! coroutine "status" lua-coroutine-status) (dict-set! coroutine "wrap" lua-coroutine-wrap) + +;; ── string library ──────────────────────────────────────────── +(define string {}) + +(define __ascii-32-126 " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~") + +(define + lua-string-len + (fn (s) (len s))) + +(define + lua-string-upper + (fn (s) (upcase s))) + +(define + lua-string-lower + (fn (s) (downcase s))) + +(define + lua-string-rep + (fn (s n) + (cond + ((<= n 0) "") + ((= n 1) s) + (else (str s (lua-string-rep s (- n 1))))))) + +(define + lua-string-sub + (fn (&rest args) + (let ((s (first args)) + (slen (len (first args))) + (i (nth args 1)) + (j (if (> (len args) 2) (nth args 2) -1))) + (let ((ni (cond + ((< i 0) (+ slen i 1)) + ((= i 0) 1) + (else i))) + (nj (cond + ((< j 0) (+ slen j 1)) + ((> j slen) slen) + (else j)))) + (let ((ci (if (< ni 1) 1 ni)) + (cj (if (> nj slen) slen nj))) + (cond + ((> ci cj) "") + (else (substring s (- ci 1) cj)))))))) + +(define + lua-string-byte + (fn (&rest args) + (let ((s (first args)) + (i (if (> (len args) 1) (nth args 1) 1))) + (cond + ((or (< i 1) (> i (len s))) nil) + (else (char-code (char-at s (- i 1)))))))) + +(define + lua-char-one + (fn (n) + (cond + ((= n 9) "\t") + ((= n 10) "\n") + ((= n 13) "\r") + ((and (>= n 32) (<= n 126)) (char-at __ascii-32-126 (- n 32))) + (else (error (str "lua: string.char out of range: " n)))))) + +(define + lua-string-char + (fn (&rest args) + (cond + ((= (len args) 0) "") + (else + (let ((out "")) + (begin + (define + loop + (fn (i) + (when (< i (len args)) + (begin + (set! out (str out (lua-char-one (nth args i)))) + (loop (+ i 1)))))) + (loop 0) + out)))))) + +;; Literal-only string.find: returns (start, end) 1-indexed or nil. +(define + lua-string-find + (fn (&rest args) + (let ((s (first args)) + (pat (nth args 1)) + (init (if (> (len args) 2) (nth args 2) 1))) + (let ((start-i (cond + ((< init 0) (+ (len s) init 1)) + ((= init 0) 1) + (else init)))) + (let ((sub (if (<= start-i 1) s (substring s (- start-i 1) (len s))))) + (let ((idx (index-of sub pat))) + (cond + ((< idx 0) nil) + (else + (list + (quote lua-multi) + (+ start-i idx) + (+ start-i idx (len pat) -1)))))))))) + +;; Literal-only string.match: returns matched substring or nil (no captures since no pattern). +(define + lua-string-match + (fn (&rest args) + (let ((s (first args)) (pat (nth args 1))) + (let ((idx (index-of s pat))) + (cond + ((< idx 0) nil) + (else pat)))))) + +;; Literal-only string.gmatch: iterator producing each literal match of pat. +(define + lua-string-gmatch + (fn (s pat) + (let ((pos 0)) + (fn (&rest __) + (cond + ((> pos (len s)) nil) + (else + (let ((rest-str (if (= pos 0) s (substring s pos (len s))))) + (let ((idx (index-of rest-str pat))) + (cond + ((< idx 0) (begin (set! pos (+ (len s) 1)) nil)) + (else + (begin + (set! pos (+ pos idx (len pat))) + pat))))))))))) + +;; Literal-only string.gsub: replace all occurrences of pat with repl (string only for now). +(define + lua-string-gsub + (fn (&rest args) + (let ((s (first args)) + (pat (nth args 1)) + (repl (nth args 2)) + (max-n (if (> (len args) 3) (nth args 3) -1))) + (cond + ((= (len pat) 0) (list (quote lua-multi) s 0)) + (else + (let ((out "") (pos 0) (count 0) (done false)) + (begin + (define + loop + (fn () + (when (and (not done) (<= pos (len s))) + (let ((rest-str (if (= pos 0) s (substring s pos (len s))))) + (let ((idx (index-of rest-str pat))) + (cond + ((< idx 0) + (begin + (set! out (str out rest-str)) + (set! done true))) + ((and (>= max-n 0) (>= count max-n)) + (begin + (set! out (str out rest-str)) + (set! done true))) + (else + (let ((before (substring rest-str 0 idx))) + (begin + (set! out (str out before (if (= (type-of repl) "string") repl (str repl)))) + (set! pos (+ pos idx (len pat))) + (set! count (+ count 1)) + (loop)))))))))) + (loop) + (list (quote lua-multi) out count)))))))) + +;; Basic string.format: %s %d %f (%%.Nf ignored), %%. +(define + lua-format-int + (fn (n) + (cond + ((= (type-of n) "number") (str (floor n))) + (else (str n))))) + +(define + lua-string-format + (fn (&rest args) + (let ((fmt (first args)) (vals (rest args))) + (let ((out "") (i 0) (vi 0)) + (begin + (define + loop + (fn () + (when (< i (len fmt)) + (let ((c (char-at fmt i))) + (cond + ((and (= c "%") (< (+ i 1) (len fmt))) + (let ((spec (char-at fmt (+ i 1)))) + (cond + ((= spec "%") + (begin (set! out (str out "%")) (set! i (+ i 2)) (loop))) + ((= spec "s") + (begin + (set! out (str out (lua-concat-coerce (nth vals vi)))) + (set! vi (+ vi 1)) (set! i (+ i 2)) (loop))) + ((= spec "d") + (begin + (set! out (str out (lua-format-int (nth vals vi)))) + (set! vi (+ vi 1)) (set! i (+ i 2)) (loop))) + ((= spec "f") + (begin + (set! out (str out (str (nth vals vi)))) + (set! vi (+ vi 1)) (set! i (+ i 2)) (loop))) + (else + (begin (set! out (str out c)) (set! i (+ i 1)) (loop)))))) + (else + (begin (set! out (str out c)) (set! i (+ i 1)) (loop)))))))) + (loop) + out))))) + +(dict-set! string "len" lua-string-len) +(dict-set! string "upper" lua-string-upper) +(dict-set! string "lower" lua-string-lower) +(dict-set! string "rep" lua-string-rep) +(dict-set! string "sub" lua-string-sub) +(dict-set! string "byte" lua-string-byte) +(dict-set! string "char" lua-string-char) +(dict-set! string "find" lua-string-find) +(dict-set! string "match" lua-string-match) +(dict-set! string "gmatch" lua-string-gmatch) +(dict-set! string "gsub" lua-string-gsub) +(dict-set! string "format" lua-string-format) diff --git a/lib/lua/test.sh b/lib/lua/test.sh index faff8f4d..dafd2e78 100755 --- a/lib/lua/test.sh +++ b/lib/lua/test.sh @@ -728,6 +728,46 @@ cat > "$TMPFILE" << 'EPOCHS' (epoch 1040) (eval "(lua-eval-ast \"local function iter() coroutine.yield(10) coroutine.yield(20) coroutine.yield(30) end local co = coroutine.create(iter) local sum = 0 for i = 1, 3 do local ok, v = coroutine.resume(co) sum = sum + v end return sum\")") +;; ── Phase 6: string library ─────────────────────────────────── +(epoch 1100) +(eval "(lua-eval-ast \"return string.len(\\\"hello\\\")\")") +(epoch 1101) +(eval "(lua-eval-ast \"return string.upper(\\\"hi\\\")\")") +(epoch 1102) +(eval "(lua-eval-ast \"return string.lower(\\\"HI\\\")\")") +(epoch 1103) +(eval "(lua-eval-ast \"return string.rep(\\\"ab\\\", 3)\")") +(epoch 1110) +(eval "(lua-eval-ast \"return string.sub(\\\"hello\\\", 2, 4)\")") +(epoch 1111) +(eval "(lua-eval-ast \"return string.sub(\\\"hello\\\", -3)\")") +(epoch 1112) +(eval "(lua-eval-ast \"return string.sub(\\\"hello\\\", 1, -2)\")") +(epoch 1120) +(eval "(lua-eval-ast \"return string.byte(\\\"A\\\")\")") +(epoch 1121) +(eval "(lua-eval-ast \"return string.byte(\\\"ABC\\\", 2)\")") +(epoch 1130) +(eval "(lua-eval-ast \"return string.char(72, 105)\")") +(epoch 1131) +(eval "(lua-eval-ast \"return string.char(97, 98, 99)\")") +(epoch 1140) +(eval "(lua-eval-ast \"local s, e = string.find(\\\"hello world\\\", \\\"wor\\\") return s * 100 + e\")") +(epoch 1141) +(eval "(lua-eval-ast \"if string.find(\\\"abc\\\", \\\"z\\\") == nil then return 1 else return 0 end\")") +(epoch 1150) +(eval "(lua-eval-ast \"return string.match(\\\"hello\\\", \\\"ell\\\")\")") +(epoch 1160) +(eval "(lua-eval-ast \"local r, n = string.gsub(\\\"abcabc\\\", \\\"a\\\", \\\"X\\\") return r .. \\\":\\\" .. n\")") +(epoch 1161) +(eval "(lua-eval-ast \"local r, n = string.gsub(\\\"aaaa\\\", \\\"a\\\", \\\"b\\\", 2) return r .. \\\":\\\" .. n\")") +(epoch 1170) +(eval "(lua-eval-ast \"local c = 0 for w in string.gmatch(\\\"aa aa aa\\\", \\\"aa\\\") do c = c + 1 end return c\")") +(epoch 1180) +(eval "(lua-eval-ast \"return string.format(\\\"%s=%d\\\", \\\"x\\\", 42)\")") +(epoch 1181) +(eval "(lua-eval-ast \"return string.format(\\\"%d%%\\\", 50)\")") + EPOCHS OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) @@ -1090,6 +1130,27 @@ check 1020 "resume dead returns error" '"cannot resume dead coroutine"' check 1030 "coroutine.wrap" '6' check 1040 "iterator via coroutine" '60' +# ── Phase 6: string library ─────────────────────────────────── +check 1100 "string.len" '5' +check 1101 "string.upper" '"HI"' +check 1102 "string.lower" '"hi"' +check 1103 "string.rep" '"ababab"' +check 1110 "string.sub(s,i,j)" '"ell"' +check 1111 "string.sub(s,-3)" '"llo"' +check 1112 "string.sub(s,1,-2)" '"hell"' +check 1120 "string.byte" '65' +check 1121 "string.byte(s,i)" '66' +check 1130 "string.char(72,105)" '"Hi"' +check 1131 "string.char(97,98,99)" '"abc"' +check 1140 "string.find literal hit" '709' +check 1141 "string.find literal miss" '1' +check 1150 "string.match literal" '"ell"' +check 1160 "string.gsub replace all" '"XbcXbc:2"' +check 1161 "string.gsub with limit" '"bbaa:2"' +check 1170 "string.gmatch iterator" '3' +check 1180 "string.format %s=%d" '"x=42"' +check 1181 "string.format %d%%" '"50%"' + TOTAL=$((PASS + FAIL)) if [ $FAIL -eq 0 ]; then echo "ok $PASS/$TOTAL Lua-on-SX tests passed" diff --git a/plans/lua-on-sx.md b/plans/lua-on-sx.md index a59360be..d9dbb099 100644 --- a/plans/lua-on-sx.md +++ b/plans/lua-on-sx.md @@ -68,7 +68,7 @@ Each item: implement → tests → tick box → update progress log. - [x] `coroutine.create`/`.resume`/`.yield`/`.status`/`.wrap` via `perform`/`cek-resume` ### Phase 6 — standard library -- [ ] `string` — `format`, `sub`, `find`, `match`, `gmatch`, `gsub`, `len`, `rep`, `upper`, `lower`, `byte`, `char` +- [x] `string` — `format`, `sub`, `find`, `match`, `gmatch`, `gsub`, `len`, `rep`, `upper`, `lower`, `byte`, `char` - [ ] `math` — full surface - [ ] `table` — `insert`, `remove`, `concat`, `sort`, `unpack` - [ ] `io` — minimal stub (read/write to SX IO surface) @@ -82,6 +82,7 @@ Each item: implement → tests → tick box → update progress log. _Newest first. Agent appends on every commit._ +- 2026-04-24: lua: `string` lib — len/upper/lower/rep/sub (1-idx + neg)/byte/char/find/match/gmatch/gsub/format. Patterns are literal-only (no `%d`/etc.); format is `%s`/`%d`/`%f`/`%%` only. `string.char` uses printable-ASCII lookup + tab/nl/cr. 292 tests. - 2026-04-24: lua: phase 5 — coroutines (create/resume/yield/status/wrap) via `call/cc` (perform/cek-resume not exposed to SX userland). Handles multi-yield + final return + arg passthrough. Fix: body's final return must jump via `caller-k` to the **current** resume's caller, not unwind through the stale first-call continuation. 273 tests. - 2026-04-24: lua: generic `for … in …` — parser split (`=` → num, else `in`), new `lua-for-in` node, transpile to `let`-bound `f,s,var` + recursive `__for_loop`. Added `ipairs`/`pairs`/`next`/`lua-arg` globals. Lua fns now arity-tolerant (`&rest __args` + indexed bind) — needed because generic for always calls iter with 2 args. Noted early-return-in-nested-block as pre-existing limitation. 265 tests. - 2026-04-24: lua: `pcall`/`xpcall`/`error` via SX `guard` + `raise`. Added `lua-apply` (arity-dispatch 0-8, apply fallback) because SX `apply` re-wraps raises as "Unhandled exception". Table payloads preserved (`error({code = 42})`). 256 total tests. @@ -105,4 +106,7 @@ _Shared-file issues that need someone else to fix. Minimal repro only._ ## Known limitations (own code, not shared) +- **`string.find`/`match`/`gmatch`/`gsub` patterns are LITERAL only** — no `%d`/`%a`/`.`/`*`/`+`/etc. Implementing Lua patterns is a separate work item; literal search covers the common case. +- **`string.format`** supports only `%s`, `%d`, `%f`, `%%`. No width/precision flags (`%.2f`, `%5d`). +- **`string.char`** supports printable ASCII 32–126 plus `\t`/`\n`/`\r`; other codes error. - **Early `return` inside nested block** — `if cond then return nil end ...rest` doesn't exit the enclosing function; `rest` runs anyway. Use `if cond then return X else return Y end` instead. Likely needs guard+raise sentinel for proper fix.