Skip to content
Saule
Docs

Os

Time, environment, filesystem and processes. This is the module that touches the world outside the program, so it is also where failure is most ordinary — see Failure conventions for which call reports it which way.

Name Type Value
Os.sep string The path separator: "/", or "\\" on Windows
Os.lineSep string The line ending: "\n", or "\r\n" on Windows
-- Joining a path without hard-coding a separator.
local parts: table<string> = {"src", "main.sau"}
println(String.join(Os.sep, parts)) -- src/main.sau on Unix
println(#Os.lineSep >= 1) -- true
Os.time() -> integer

The current Unix epoch in seconds. Comparable across runs and across machines, which is what makes it the right thing to record.

local now: integer = Os.time()
println(now > 1700000000) -- true, well past 2023
-- A timestamp to store or print.
println(Os.date("%Y-%m-%d", now) != "") -- true
Os.timeMillis() -> integer

The same instant in milliseconds — the resolution needed to time a request, a frame or a test.

local started: integer = Os.timeMillis()
Os.sleep(0.05)
local elapsed: integer = Os.timeMillis() - started
println(elapsed >= 40) -- true, about 50ms
println(elapsed < 5000) -- true
Os.clock() -> float

Seconds from a monotonic clock: it never goes backwards, even if the system clock is adjusted, but its origin is arbitrary — only differences mean anything.

This is not Lua’s os.clock, which reports CPU time. Use Os.time for when something happened and Os.clock for how long it took.

local started: float = Os.clock()
Os.sleep(0.05)
local took: float = Os.clock() - started
println(took >= 0.04) -- true
println(took < 5.0) -- true
Os.difftime(t2: integer, t1: integer) -> integer

t2 - t1. Present for familiarity with Lua; plain subtraction does the same thing here, because both arguments are already integers.

local earlier: integer = Os.time()
Os.sleep(0.05)
local later: integer = Os.time()
println(Os.difftime(later, earlier) >= 0) -- true
println(Os.difftime(100, 40)) -- 60
Os.date(format: string?, time: integer?) -> string

Formats a Unix epoch using strftime-style specifiers. format defaults to "%c" and time to now.

These are the specifiers that are implemented:

Specifier Gives Example
%Y Four-digit year 2021
%y Two-digit year 21
%m Month, zero-padded 01
%d Day of month, zero-padded 01
%j Day of year, zero-padded 001
%H %M %S Hours, minutes, seconds 00
%c Date and time 2021-01-01 00:00:00
%x Date only 2021-01-01
%X Time only 00:00:00

Anything else is passed through literally rather than reported — the name-based specifiers (%A, %B, %a, %b, %p) are not implemented, so Os.date("%B") answers the string "%B".

-- A fixed instant, so the output is the same every run:
-- 2021-01-01 00:00:00 UTC.
local stamp: integer = 1609459200
println(Os.date("%Y-%m-%d", stamp)) -- 2021-01-01
println(Os.date("%Y-%m-%dT%H:%M:%S", stamp)) -- 2021-01-01T00:00:00
println(Os.date("%x", stamp)) -- 2021-01-01
println(Os.date("%B", stamp)) -- %B, not implemented
-- No arguments at all is "now, in the system's preferred format".
println(#Os.date() > 0) -- true
Os.parsedate(text: string, format: string?) -> integer?

The inverse of Os.date: reads text and answers the Unix epoch, or nil when it does not match. format defaults to "%Y-%m-%d".

println(Os.parsedate("2021-01-01") ?? 0) -- 1609459200
println(Os.parsedate("not a date") == nil) -- true
-- A format other than the default.
local at: integer? = Os.parsedate("01/01/2021", "%d/%m/%Y")
println(at ?? 0) -- 1609459200
-- Round trip.
local stamp: integer = 1609459200
println(Os.parsedate(Os.date("%Y-%m-%d", stamp)) == stamp) -- true
Os.sleep(seconds: float) -> nil

Pauses the whole program. Fractions are allowed, so sub-second waits are ordinary.

local started: float = Os.clock()
Os.sleep(0.1)
println(Os.clock() - started >= 0.09) -- true
-- A retry loop that backs off.
for attempt = 1, 3 do
Os.sleep(0.01 * (attempt as float))
end
println("done") -- done
Os.getenv(name: string) -> string?

The value of an environment variable, or nil when it is not set.

Os.setenv("SAULE_DOC_EXAMPLE", "yes")
println(Os.getenv("SAULE_DOC_EXAMPLE") ?? "") -- yes
-- Unset is `nil`, which `??` turns into a default.
println(Os.getenv("SAULE_DEFINITELY_UNSET") ?? "fallback") -- fallback
-- The shape configuration usually wants.
local level: string = Os.getenv("LOG_LEVEL") ?? "info"
println(level != "") -- true
Os.setenv(name: string, value: string) -> nil

Sets a variable for this process and anything it spawns afterwards. It does not reach the shell that started the program.

Os.setenv("SAULE_GREETING", "hello")
println(Os.getenv("SAULE_GREETING") ?? "") -- hello
-- Which a child process then inherits.
local code: integer, out: string, err: string = Os.capture("echo $SAULE_GREETING")
println(String.trim(out)) -- hello
Os.cwd() -> string

The absolute path of the current working directory.

local here: string = Os.cwd()
println(String.len(here) > 0) -- true
println(String.starts(here, Os.sep) or String.contains(here, ":")) -- true
-- Resolving a relative path against it.
println(Os.exists(here)) -- true
Os.chdir(path: string) -> boolean

Changes the working directory, answering false when it cannot — the path does not exist, or is not a directory.

local original: string = Os.cwd()
println(Os.chdir("/")) -- true
println(Os.cwd() != original) -- true
println(Os.chdir("/no/such/dir")) -- false, and nothing moved
Os.chdir(original)
println(Os.cwd() == original) -- true
Os.exists(path: string) -> boolean

Whether anything exists at path — a file, a directory or a symlink.

local path: string = Os.tmpname()
println(Os.exists(path)) -- false, not created yet
Io.open(path, IoMode.Write)!.close()
println(Os.exists(path)) -- true
Os.remove(path)
println(Os.exists(path)) -- false again
Os.list(path: string) -> table<string>

The names of a directory’s children — names only, not full paths.

It is the one call here that fails hard. When the path cannot be read it raises a runtime error that ends the program; try / catch does not intercept it, because this is not a throw of a value. Its neighbours answer false or nil instead, so check with Os.exists or Os.fsInfo first when the path might not be there. That inconsistency is a known rough edge — see Failure conventions.

local dir: string = Os.tmpname()
Os.mkdir(dir)
Io.open(dir .. Os.sep .. "a.txt", IoMode.Write)!.close()
Io.open(dir .. Os.sep .. "b.txt", IoMode.Write)!.close()
local names: table<string> = Os.list(dir)
Table.sort(names, (a, b) => a < b)
println(Table.concat(names, ",")) -- a.txt,b.txt
-- Names, so join them back onto the directory to use them.
for name in names do
println(Os.exists(dir .. Os.sep .. name)) -- true, twice
end
-- Guard rather than catch: an unreadable path ends the program.
if Os.exists("/no/such/dir") then
println(#Os.list("/no/such/dir"))
else
println("not a readable directory") -- not a readable directory
end
Os.remove(dir .. Os.sep .. "a.txt")
Os.remove(dir .. Os.sep .. "b.txt")
Os.remove(dir)
Os.mkdir(path: string, recursive: boolean?) -> boolean

Creates a directory, answering false when it cannot. With recursive set, it creates the missing parents too; without it, a missing parent is a failure.

local base: string = Os.tmpname()
println(Os.mkdir(base)) -- true
println(Os.mkdir(base)) -- false, already there
-- A nested path needs `recursive`.
local nested: string = base .. Os.sep .. "a" .. Os.sep .. "b"
println(Os.mkdir(nested)) -- false, parent missing
println(Os.mkdir(nested, true)) -- true
println(Os.exists(nested)) -- true
Os.remove(nested)
Os.remove(base .. Os.sep .. "a")
Os.remove(base)
Os.remove(path: string) -> boolean

Removes a file, or an empty directory. false when it cannot — the path is missing, or the directory still has something in it.

local path: string = Os.tmpname()
Io.open(path, IoMode.Write)!.close()
println(Os.remove(path)) -- true
println(Os.remove(path)) -- false, already gone
-- A directory has to be empty first.
local dir: string = Os.tmpname()
Os.mkdir(dir)
local inside: string = dir .. Os.sep .. "f.txt"
Io.open(inside, IoMode.Write)!.close()
println(Os.remove(dir)) -- false, not empty
println(Os.remove(inside)) -- true
println(Os.remove(dir)) -- true, now it is
Os.rename(from: string, to: string) -> boolean

Moves or renames, answering false when it cannot. Within one filesystem this is atomic, which is what makes write-then-rename the safe way to replace a file.

local a: string = Os.tmpname()
local b: string = a .. ".renamed"
local f: File = Io.open(a, IoMode.Write)!
f.write("contents")
f.close()
println(Os.rename(a, b)) -- true
println(Os.exists(a)) -- false
println(Io.open(b, IoMode.Read)!.read("a") ?? "") -- contents
println(Os.rename("/no/such/file", b)) -- false
Os.remove(b)
Os.tmpname() -> string

A path under the system temp directory that nothing is using yet. It is a name, not a file: nothing is created until you create it.

local path: string = Os.tmpname()
println(Os.exists(path)) -- false, just a name
local f: File = Io.open(path, IoMode.Write)!
f.write("x")
f.close()
println(Os.exists(path)) -- true
Os.remove(path)
-- Two calls never collide.
println(Os.tmpname() != Os.tmpname()) -- true
Os.fsInfo(path: string?) -> FsInfo?

Metadata for one path, or nil when nothing is there. path defaults to the current directory.

The result is an FsInfo with these fields:

Field Type What it holds
path string The path this describes
kind FsKind File, Dir, Symlink or Other
size integer Size in bytes
modifiedAt integer Last-modified time, as a Unix epoch for Os.date
readOnly boolean Whether the permissions forbid writing
local path: string = Os.tmpname()
local f: File = Io.open(path, IoMode.Write)!
f.write("12345")
f.close()
local info: FsInfo = Os.fsInfo(path)!
println(info.size) -- 5
println(info.kind.value) -- file
println(info.readOnly) -- false
println(info.modifiedAt > 0) -- true
println(info.path == path) -- true
-- A directory reports its own kind.
println(Os.fsInfo(Os.cwd())!.kind.value) -- dir
-- Nothing there is `nil`, not an error.
println(Os.fsInfo("/no/such/path") == nil) -- true
Os.remove(path)

File ("file"), Dir ("dir"), Symlink ("symlink") and Other ("other"). Match on the variant rather than comparing .value when you want the compiler to check you have covered every case.

local info: FsInfo = Os.fsInfo(Os.cwd())!
match info.kind
case FsKind.Dir then
println("a directory") -- a directory
case FsKind.File then
println("a file")
case _ then
println("something else")
end
Os.pid() -> integer

This process’s own process id.

println(Os.pid() > 0) -- true
-- Stable within a run.
println(Os.pid() == Os.pid()) -- true
Os.args() -> table<string>

The command-line arguments passed to the script, without the interpreter or the script path.

local args: table<string> = Os.args()
println(#args >= 0) -- true
-- The shape a CLI usually wants.
local first: string = args[1] ?? "<none>"
println(first != "") -- true
Os.execute(cmd: string) -> integer

Runs cmd through the shell and answers its exit code. The child’s output goes to this process’s own stdout and stderr — nothing is captured.

-1 when the command could not be run at all.

println(Os.execute("true")) -- 0
println(Os.execute("false") != 0) -- true
-- The shell is real, so redirection and variables work.
println(Os.execute("test -d /") == 0) -- true
Os.capture(cmd: string) -> (integer, string, string)

Runs cmd through the shell and captures it: the exit code, everything it wrote to stdout, and everything it wrote to stderr. Nothing reaches the terminal.

-1 with two empty strings when the command could not be run.

local code: integer, out: string, err: string = Os.capture("echo hello")
println(code) -- 0
println(String.trim(out)) -- hello
println(err == "") -- true
-- stderr comes back separately from stdout.
local c2: integer, o2: string, e2: string = Os.capture("echo oops 1>&2")
println(o2 == "") -- true
println(String.trim(e2)) -- oops
-- A failing command reports its code rather than throwing.
local c3: integer, o3: string, e3: string = Os.capture("exit 3")
println(c3) -- 3
Os.spawn(program: string, args: table<string>?, cwd: string?, env: table<string, string>?) -> Process?

Starts program and answers a handle while it runs, so the program can write to it, read what came back, and carry on in between. nil when it cannot be started.

There is no shell: args is passed as a vector, so nothing re-parses quoting and a path with a space in it stays one argument. cwd sets the child’s working directory, and env adds to the environment it inherits rather than replacing it.

See Process handles for what the handle can do.

-- A child that finishes on its own.
local p: Process = Os.spawn("echo", {"hello"})!
p.wait()
println(String.trim(Bytes.toString(p.read()) ?? "")) -- hello
-- No shell: the argument arrives exactly as written.
local literal: Process = Os.spawn("echo", {"$HOME not expanded"})!
literal.wait()
println(String.trim(Bytes.toString(literal.read()) ?? "")) -- $HOME not expanded
-- `cwd` places the child.
local inDir: Process = Os.spawn("pwd", {}, "/")!
inDir.wait()
println(String.trim(Bytes.toString(inDir.read()) ?? "")) -- /
-- A program that is not there is `nil`.
println(Os.spawn("saule-no-such-program") == nil) -- true
Os.exit(code: integer?) -> nil

Ends the process immediately with code, which defaults to 0. Nothing after it runs.

-- Guarded so this example still prints, rather than exiting the page.
local shouldStop: boolean = false
if shouldStop then
Os.exit(1)
end
println("still running") -- still running
Os.platform() -> OsPlatform

Which operating system this is, as an enum: Linux, Macos, Windows or Other, whose .value is "linux", "macos", "windows" or "other".

println(#Os.platform().value > 0) -- true
-- Branching on it, checked for exhaustiveness.
match Os.platform()
case OsPlatform.Windows then
println("backslashes")
case _ then
println("slashes") -- slashes, on Unix
end
-- Which is also what `Os.sep` already answers.
println(Os.sep == "/" or Os.sep == "\\") -- true

Os.execute and Os.capture both block until the child is finished, so neither can be used to talk to one. Os.spawn is for a child with a conversation in it — a language server, a formatter fed on stdin, an editor embedded in a window: it returns while the child runs, and the program writes, reads what came back, and carries on in between.

Two properties shape everything below. Reads never block: each captured stream is drained on a thread of its own into a buffer, and read takes whatever has arrived so far. And reads are bytes, not string: a child’s output is not promised to be text, and a string is always valid UTF-8 — use Bytes.toString where you expect text, which returns string? precisely because that is where it can fail.

local p: Process = Os.spawn("cat")!
p.write(Bytes.fromString("round trip\n"))
p.closeStdin() -- `cat` reads until end of input
println(p.wait()) -- 0, its exit code
println(String.trim(Bytes.toString(p.read()) ?? "")) -- round trip
p.read(max: integer?) -> bytes

Whatever has arrived on stdout, up to max bytes (64 KiB by default). Never blocks: an empty buffer means “nothing yet”, which is not the same as “nothing more” — see p.atEof().

local p: Process = Os.spawn("echo", {"abcdefgh"})!
p.wait()
println(Bytes.toString(p.read(3)) ?? "") -- abc, capped
println(#p.read(0)) -- 0, reads nothing
println(String.trim(Bytes.toString(p.read()) ?? "")) -- defgh, the rest
p.readErr(max: integer?) -> bytes

The same, for stderr. The two streams are separate, so output written to one never appears in the other.

local p: Process = Os.spawn("sh", {"-c", "echo out; echo err 1>&2"})!
p.wait()
println(String.trim(Bytes.toString(p.read()) ?? "")) -- out
println(String.trim(Bytes.toString(p.readErr()) ?? "")) -- err
p.write(data: bytes) -> integer

Writes to the child’s stdin and answers how many bytes went in. An error once stdin has been closed.

local p: Process = Os.spawn("cat")!
println(p.write(Bytes.fromString("hello"))) -- 5, the byte count
p.closeStdin()
p.wait()
println(Bytes.toString(p.read()) ?? "") -- hello
p.closeStdin() -> nil

Drops the child’s stdin so it sees end of input. Idempotent — closing twice has expressed one intention.

Anything that reads until EOF needs this, or both ends wait forever.

local p: Process = Os.spawn("cat")!
p.write(Bytes.fromString("done\n"))
-- Without this, `cat` keeps waiting for more and `wait` never returns.
p.closeStdin()
println(p.wait()) -- 0
println(String.trim(Bytes.toString(p.read()) ?? "")) -- done
p.poll() -> integer?

The exit code if the child has finished, nil while it is still running. Remembered, so asking twice is fine.

local p: Process = Os.spawn("sleep", {"30"})!
println(p.poll() == nil) -- true, still going
p.kill()
p.wait()
println(p.poll() != nil) -- true, and it stays answered
p.wait() -> integer

Blocks until the child exits and its output has arrived, then answers its exit code. Closes stdin first — a child waiting for input and a parent waiting for the child is a deadlock, and this is the end that can tell.

Both halves matter: the exit status alone does not mean the output has been collected, so wait then read sees all of it rather than however much happened to arrive first.

local p: Process = Os.spawn("echo", {"complete"})!
println(p.wait()) -- 0
println(String.trim(Bytes.toString(p.read()) ?? "")) -- complete
-- A non-zero code comes back as itself.
local failed: Process = Os.spawn("sh", {"-c", "exit 3"})!
println(failed.wait()) -- 3
p.kill() -> boolean

Ends the child. true when the signal was delivered, false when it had already exited — which is not an error.

local p: Process = Os.spawn("sleep", {"30"})!
println(p.running()) -- true
println(p.kill()) -- true
-- Signalled rather than exited, which `Os.execute` also reports as -1.
println(p.wait()) -- -1
println(p.running()) -- false
println(p.kill()) -- false, already gone
p.running() -> boolean

Whether the child is still going. Asks the operating system rather than trusting a flag, so it is accurate without a prior poll.

local p: Process = Os.spawn("sleep", {"30"})!
println(p.running()) -- true
p.kill()
p.wait()
println(p.running()) -- false
p.pid() -> integer

The child’s process id.

local p: Process = Os.spawn("true")!
println(p.pid() > 0) -- true
println(p.pid() != Os.pid()) -- true, a different process
p.wait()
p.available() -> integer

How many bytes are waiting on stdout, without taking them — how far behind a reader is.

Each stream buffers at most 8 MiB of unread output. At the limit the reader stops taking from the pipe, so the operating system’s own buffer fills and the child blocks on its next write: nothing is ever dropped, which for a binary protocol would be unrecoverable.

local p: Process = Os.spawn("echo", {"hello"})!
p.wait()
println(p.available()) -- 6, "hello" and a newline
p.read()
println(p.available()) -- 0, taken
p.atEof() -> boolean

Whether stdout has ended and been drained. Both halves matter: a child can exit with output still sitting in the buffer, and reporting end of file then would lose it.

This is what makes a non-blocking read loop terminate correctly.

local p: Process = Os.spawn("sh", {"-c", "for i in 1 2 3; do echo $i; done"})!
local text: string = ""
local deadline: float = Os.clock() + 20.0
while Os.clock() < deadline do
text = text .. (Bytes.toString(p.read()) ?? "")
-- Done only when it has exited *and* the buffer is drained; either
-- alone would lose the tail.
if p.poll() != nil and p.atEof() then
break
end
end
println(String.replace(String.trim(text), "\n", ",")) -- 1,2,3
println(p.atEof()) -- true

The child outlives the handle. Dropping the last reference does not kill it — call kill or wait.

The filesystem calls disagree about how they report trouble, and it is worth knowing which is which before you write the error handling:

  • Os.remove / Os.rename / Os.chdir / Os.mkdir return boolean.
  • Os.fsInfo and Os.getenv return T?.
  • Os.list throws.

None of the first two carry the operating system’s reason, so a failed call can’t tell “not found” from “permission denied”. That is a known rough edge, not a design.