santoku
santoku

santoku

A Lua framework for building applications end to end

Lua 5.1 from the build system to the server to the browser. Every example on this site runs in your browser. Edit one and run it.


santoku targets Lua 5.1, because 5.1 is the version its platforms actually share. LuaJIT implements 5.1 and OpenResty embeds LuaJIT, so server code, client code and the build harness all run the same language. Compiled to WebAssembly, the 5.1 core is smaller to embed, and its single number type sidesteps the integer and float split introduced in 5.3, which keeps numeric values crossing the JavaScript boundary simple. Support for newer versions is meant to come from compatibility shims layered over the 5.1 core rather than version branches in the tree; contributions and design ideas for that layer are welcome.

santoku-fs

View on LuaRocks

filesystem library: whole-file and streamed I/O, path string helpers, traversal iterators, and a POSIX C extension, all raising structured errors

santoku-fs is the framework's filesystem library: whole-file and streamed I/O, pure-string path helpers, directory traversal iterators, and a thin POSIX C extension (santoku.fs.posix) whose primitives are merged, along with stock io, into the santoku.fs table. Failures raise structured errors through santoku.error (the OS message plus errno), so call sites use pcall rather than checking nil, and errno constants like fs.ENOENT and fs.EEXIST are exported for matching. It sits under every native part of the stack, from toku build scripts to CLI tools. This page ships the library in its wasm bundle, so most examples below run live against MEMFS, emscripten's ephemeral in-memory filesystem: real files, directories, and errors, gone when you leave the page. Hardlinks and symlinks are compiled out under emscripten, and host-path idioms are meaningless in a browser, so those are shown for reading.

path joins and names

Pure string path work, no disk involved. join inserts separators only where needed (a trailing slash on the left or a leading slash on the right suppresses the inserted one). dirname of a bare name is the current directory, and basename of a path ending in a slash is nil.

local fs = require("santoku.fs") print("join:", fs.join("a/", "b")) print("join:", fs.join("srv", "web", "assets")) print("join:", fs.join("srv", "/web")) print("dirname:", fs.dirname("/opt/bin/sort")) print("dirname:", fs.dirname("stdio.h")) print("dirname:", fs.dirname("../../test")) print("basename:", fs.basename("/opt/bin/sort")) print("basename:", fs.basename("stdio.h")) print("basename:", fs.basename("/home/user/")) return fs.join("a", "b", "c")

extensions

extension takes the last dot segment, extensions takes everything from the first dot, and the strip variants remove them. splitexts iterates the extension parts, bare or with keep_dots. A name with no dot gives nil.

local fs = require("santoku.fs") local arr = require("santoku.array") print("extension:", fs.extension("release.tar.gz")) print("extensions:", fs.extensions("release.tar.gz")) print("stripextension:", fs.stripextension("release.tar.gz")) print("stripextensions:", fs.stripextensions("release.tar.gz")) print("no dot:", fs.extension("Makefile")) local exts = {} for e in fs.splitexts("/this/test.tar.gz", true) do exts[#exts + 1] = e end print("splitexts:", arr.concat(exts, " ")) return fs.stripextensions("release.tar.gz")

splitting and stripping parts

splitparts iterates path components, collapsing repeated slashes; a delim of right or left keeps the separators attached to one side. stripparts drops n leading components, returns the path unchanged for 0, and nil when n consumes everything.

local fs = require("santoku.fs") local arr = require("santoku.array") local plain = {} for p in fs.splitparts("/deploy//web/assets/") do plain[#plain + 1] = p end print("parts:", arr.concat(plain, " ")) local right = {} for p in fs.splitparts("/deploy//web/assets/", "right") do right[#right + 1] = p end print("right:", arr.concat(right, " ")) print("strip 2:", fs.stripparts("/home/user/a/b/c.txt", 2)) print("strip 4:", fs.stripparts("/home/user/a/b/c.txt", 4)) print("strip 0:", fs.stripparts("/home/user/a/b/c.txt", 0)) print("strip 5:", fs.stripparts("/home/user/a/b/c.txt", 5)) return fs.stripparts("/home/user/a/b/c.txt", 2)

whole-file round trips

writefile truncates by default and appends with the a flag, readfile slurps, mv renames, touch creates an empty file, and exists returns two values: found, and the entry kind when found.

local fs = require("santoku.fs") fs.writefile("greeting.txt", "hello") fs.writefile("greeting.txt", " world", "a") print("contents:", fs.readfile("greeting.txt")) fs.mv("greeting.txt", "renamed.txt") fs.touch("empty.txt") local found, kind = fs.exists("renamed.txt") print("exists:", found, kind) print("empty:", fs.readfile("empty.txt") == "") fs.rm("renamed.txt") fs.rm("empty.txt") return fs.exists("greeting.txt")

probes: exists, isdir, isfile

The predicates stat the path and answer with plain booleans, swallowing only ENOENT: a missing path is false, not an error, while any other failure still raises. mode gives the raw entry kind string that exists returns as its second value.

local fs = require("santoku.fs") fs.mkdirp("probe/dir") fs.writefile("probe/f.txt", "x") print("isdir dir:", fs.isdir("probe/dir")) print("isdir file:", fs.isdir("probe/f.txt")) print("isfile file:", fs.isfile("probe/f.txt")) print("isfile missing:", fs.isfile("probe/ghost")) print("mode:", fs.mode("probe")) local found, kind = fs.exists("probe/f.txt") print("exists:", found, kind) fs.rm("probe/f.txt") fs.rmdirs("probe") return kind

structured errors

Failures raise through santoku.error carrying the OS message and errno, so santoku.error's pcall spreads them back as values you can match against the exported constants. rm raises on a missing path unless allow_noexist is passed.

local fs = require("santoku.fs") local err = require("santoku.error") local ok, msg, code = err.pcall(fs.mode, "ghost") print("ok:", ok) print("message:", msg) print("is ENOENT:", code == fs.ENOENT) local ok2 = err.pcall(fs.rm, "ghost") print("rm missing raises:", not ok2) fs.rm("ghost", true) print("rm allow_noexist:", "no raise") return code == fs.ENOENT

with: scoped handles

with opens a handle, runs your function with it, and always closes it, then re-raises anything the function threw. Pass an already-open handle instead of a path and with leaves closing it to you.

local fs = require("santoku.fs") local err = require("santoku.error") fs.writefile("data.txt", "line 1\nline 2\n") local first = fs.with("data.txt", "r", function (fh) return fs.read(fh, "*l") end) print("first line:", first) local ok, msg = err.pcall(fs.with, "data.txt", "r", function () return err.error("boom") end) print("re-raised:", not ok, msg) fs.rm("data.txt") return first

handle primitives

open, read, write, seek, flush, and close wrap the io methods with raising semantics. open returns the handle plus a was_open flag: given an existing handle it passes it through with true, which is how with and chunks accept either a path or a handle.

local fs = require("santoku.fs") local fh = fs.open("notes.txt", "w") fs.write(fh, "alpha\nbeta\n") fs.flush(fh) fs.close(fh) fh = fs.open("notes.txt", "r") print("first:", fs.read(fh, "*l")) print("seek:", fs.seek(fh, "set", 6)) print("second:", fs.read(fh, "*l")) local same, was_open = fs.open(fh) print("passthrough:", same == fh, was_open) fs.close(fh) fs.rm("notes.txt") return was_open

stdout, stderr and stdin

fs.stdout, fs.stderr and fs.stdin are the process's standard handles, io.stdout, io.stderr and io.stdin. Pass one to fs.write, fs.read or fs.flush like any other handle. fs.write always takes a handle first, so it has no default stream. A bin script reports a failure on fs.stderr and exits nonzero with sys.exit.

local fs = require("santoku.fs") local sys = require("santoku.system") local err = require("santoku.error") local path = arg[1] local ok, text = err.pcall(fs.readfile, path) if not ok then fs.write(fs.stderr, "my-tool: can't read ", tostring(path), "\n") sys.exit(1) end fs.write(fs.stdout, #text, " bytes\n")
Illustrative: runs outside the browser.

mkdirp and rmdirs

mkdirp creates every missing component and is idempotent, while the raw mkdir raises EEXIST on a second call. rmdirs removes a directory tree bottom-up, which works because dirs with leaves true yields children before their parents.

local fs = require("santoku.fs") local err = require("santoku.error") fs.mkdirp("deep/x/y") print("created:", fs.isdir("deep/x/y")) fs.mkdirp("deep/x/y") print("mkdirp again:", "no raise") local ok, _, code = err.pcall(fs.mkdir, "deep") print("mkdir again:", ok, code == fs.EEXIST) for d in fs.dirs("deep", true, true) do print("leaves-first:", d) end fs.rmdirs("deep") return fs.exists("deep")

files and dirs

files iterates regular files, dirs iterates directories, each with a recurse flag; non-recursive files stays in the top level, non-recursive dirs lists immediate subdirectories without descending. Directory order comes from readdir, so collect and sort when order matters.

local fs = require("santoku.fs") local arr = require("santoku.array") fs.mkdirp("proj/src/util") fs.writefile("proj/readme.md", "# proj") fs.writefile("proj/src/main.lua", "return true") fs.writefile("proj/src/util/str.lua", "return true") local all = {} for fp in fs.files("proj", true) do all[#all + 1] = fp end arr.sort(all) print("recursive:", arr.concat(all, " ")) local top = {} for fp in fs.files("proj") do top[#top + 1] = fp end print("top only:", arr.concat(top, " ")) local ds = {} for d in fs.dirs("proj", true) do ds[#ds + 1] = d end arr.sort(ds) print("dirs:", arr.concat(ds, " ")) for i = 1, #all do fs.rm(all[i]) end fs.rmdirs("proj") return #all

walk with prune

walk yields every entry as name, kind. The prune callback runs for each directory: true skips the subtree entirely, the string keep yields the directory itself without descending (how non-recursive dirs is built), and false descends normally.

local fs = require("santoku.fs") local arr = require("santoku.array") fs.mkdirp("site/src") fs.mkdirp("site/build") fs.writefile("site/src/main.lua", "return true") fs.writefile("site/build/cache.bin", "x") fs.writefile("site/doc.md", "# doc") local seen = {} for name, kind in fs.walk("site", function (dir) if fs.basename(dir) == "build" then return true end end) do seen[#seen + 1] = name .. " (" .. kind .. ")" end arr.sort(seen) print("pruned:", arr.concat(seen, " ")) local kept = {} for name, kind in fs.walk("site", function () return "keep" end) do kept[#kept + 1] = name .. " (" .. kind .. ")" end arr.sort(kept) print("keep:", arr.concat(kept, " ")) fs.rm("site/src/main.lua") fs.rm("site/build/cache.bin") fs.rm("site/doc.md") fs.rmdirs("site") return #seen

low-level directory stream

dir wraps the raw diropen, dirent, and dirclose primitives into an iterator over entry name and kind, including the dot entries, which callers filter themselves. The handle closes itself at end of stream and carries a __gc closer for early exits.

local fs = require("santoku.fs") local arr = require("santoku.array") fs.mkdirp("box") fs.writefile("box/a.txt", "a") fs.writefile("box/b.txt", "b") local ents = {} for name, kind in fs.dir("box") do if name ~= "." and name ~= ".." then ents[#ents + 1] = name .. " (" .. kind .. ")" end end arr.sort(ents) print(arr.concat(ents, ", ")) fs.rm("box/a.txt") fs.rm("box/b.txt") fs.rmdirs("box") return #ents

chunks: fixed-size blocks

With no delimiters, chunks streams a file in blocks of at most the given size, each yielded as a fresh string, so a 28-byte file read 8 bytes at a time gives three full blocks and a 4-byte tail. Buffer size defaults to BUFSIZ when omitted.

local fs = require("santoku.fs") fs.writefile("blob.txt", "line 1\nline 2\nline 3\nline 4\n") local blocks = {} for chunk in fs.chunks("blob.txt", nil, 8) do blocks[#blocks + 1] = chunk end print("count:", #blocks) print("first:", (blocks[1]:gsub("\n", "\\n"))) print("last:", (blocks[#blocks]:gsub("\n", "\\n"))) fs.rm("blob.txt") return #blocks

chunks: delimited records

With delimiter bytes, chunks yields the buffer plus start and end positions of each segment, slicing without copying; omit true excludes the delimiter run from the end position. Records spanning a buffer boundary are re-read, and a record larger than the buffer raises chunk doesn't fit with the byte offsets.

local fs = require("santoku.fs") local err = require("santoku.error") local arr = require("santoku.array") fs.writefile("log.txt", "alpha\nbeta\ngamma\n") local lines = {} for chunk, s, e in fs.chunks("log.txt", "\n", 8, true) do lines[#lines + 1] = chunk:sub(s, e) end print("lines:", arr.concat(lines, ", ")) local ok, msg = err.pcall(function () for chunk in fs.chunks("log.txt", "\n", 3) do local _ = chunk end end) print("too small:", not ok, msg) fs.rm("log.txt") return #lines

pushd

pushd changes into a directory, calls your function with any extra arguments, and restores the previous cwd on every exit path: on error it still restores first, then re-raises. This is how the make engine runs each dependency build inside its own checkout.

local fs = require("santoku.fs") local err = require("santoku.error") fs.mkdirp("scratch") local before = fs.cwd() local inside = fs.pushd("scratch", function (tag) fs.writefile("note.txt", tag) return fs.cwd() end, "written inside") print("changed:", inside ~= before) print("restored:", fs.cwd() == before) print("note:", fs.readfile("scratch/note.txt")) local ok, msg = err.pcall(fs.pushd, "scratch", function () return err.error("inner failure") end) print("re-raised:", not ok, msg) print("still restored:", fs.cwd() == before) fs.rm("scratch/note.txt") fs.rmdirs("scratch") return fs.basename(inside)

loadfile and runfile

loadfile compiles a Lua file to a function under an optional environment; runfile loads and calls it. runfile's environment falls through to _G by default so scripts can still reach globals, and the nog flag cuts that link for full isolation.

local fs = require("santoku.fs") fs.writefile("script.lua", "return x + y") print("loadfile:", fs.loadfile("script.lua", { x = 1, y = 2 })()) local result = fs.runfile("script.lua", { x = 10, y = 20 }) print("runfile:", result) fs.writefile("script.lua", "return print ~= nil") print("sees _G:", fs.runfile("script.lua")) print("isolated:", fs.runfile("script.lua", {}, true)) fs.rm("script.lua") return result

absolute paths

absolute resolves a path against the real filesystem with realpath, walking back to the deepest existing prefix so a not-yet-created leaf still gets an absolute answer. The empty string gives nil.

local fs = require("santoku.fs") fs.mkdirp("abs/demo") local a = fs.absolute("abs/demo") print("anchored:", a == fs.join(fs.cwd(), "abs/demo")) local leaf = fs.absolute("abs/demo/new.txt") print("missing leaf:", leaf == fs.join(fs.cwd(), "abs/demo/new.txt")) print("empty:", fs.absolute("") == nil) fs.rmdirs("abs") return a ~= nil

the copy idiom

The pattern the make engine uses everywhere: mkdirp the destination's dirname, then writefile a readfile, and drive it from a files iteration to mirror a whole tree.

local fs = require("santoku.fs") local arr = require("santoku.array") local function copy (src, dest) fs.mkdirp(fs.dirname(dest)) fs.writefile(dest, fs.readfile(src)) end fs.mkdirp("src/icons") fs.writefile("src/app.lua", "return 1") fs.writefile("src/icons/logo.svg", "<svg/>") for fp in fs.files("src", true) do copy(fp, fs.join("dist", fp)) end local out = {} for fp in fs.files("dist", true) do out[#out + 1] = fp end arr.sort(out) print("mirrored:", arr.concat(out, " ")) local body = fs.readfile("dist/src/app.lua") local function nuke (dir) for fp in fs.files(dir, true) do fs.rm(fp) end fs.rmdirs(dir) end nuke("src") nuke("dist") return body

stat, chmod, chown and umask

stat follows symlinks and returns one table: type (the same string mode gives), perms (the permission bits as an integer), size, mtime, atime, ctime, uid, gid, nlink, ino and dev. chmod and umask take the mode as an integer or as an octal string, since Lua has no octal literal; chown takes a uid and gid and leaves either unchanged when it is -1 or nil. umask returns the previous mask, and with no argument only reads it. A server whose master process creates a file that its workers write, a query log under nginx for one, calls chmod or chown right after creating it.

local fs = require("santoku.fs") local str = require("santoku.string") fs.writefile("perm.txt", "abc") fs.chmod("perm.txt", "600") local st = fs.stat("perm.txt") print("type:", st.type, "size:", st.size) print("perms:", str.format("%o", st.perms)) fs.chmod("perm.txt", 420) print("perms:", str.format("%o", fs.stat("perm.txt").perms)) fs.chown("perm.txt", -1, -1) local old = fs.umask("022") print("umask was:", str.format("%o", old), "now:", str.format("%o", fs.umask())) fs.umask(old) fs.rm("perm.txt") return st.type

host tooling idioms

Patterns from the CLI tools and build scripts that only make sense on a real machine: pcall a readfile to treat a missing config as defaults, absolute to canonicalize a user path before recording it, exists as a cheap guard, and tmpname for compiler scratch files.

local fs = require("santoku.fs") local err = require("santoku.error") local ok, raw = err.pcall(fs.readfile, "ll.conf.lua") if not ok then raw = "return {}" end local canonical = fs.absolute("notes/sync.md") if fs.exists("notes/sync.md") then print("syncing:", canonical) end local tmp = fs.tmpname() .. ".c" fs.writefile(tmp, "int main () { return 0; }") print("scratch:", tmp) fs.rm(tmp) return canonical
Illustrative: runs outside the browser.