plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


plum-std/Os.plum
module std
import std/List
import std/Result
import std/Str
import std/Uuid
import std/Bool
import std/Number

# NOTE: the original file modeled `stdin`/`stdout`/`stderr` as bare top-level
# variable bindings and a `File(...)` constructor, but this language has no
# top-level `let` and `File` is never defined anywhere in the stdlib. Reduced
# to the underlying path so this at least parses; a real `File` type is
# still needed here.
fun stdin() -> Str =
  "/dev/stdin"

fun stdout() -> Str =
  "/dev/stdout"

fun stderr() -> Str =
  "/dev/stderr"

# Writes the specified data, followed by the current line terminator, to the
# standard output stream. `extern` means this compiles to a genuine wasm import
# (`plum::printLn`) instead of a Plum body — printing isn't expressible in wasm
# on its own; the embedding host (`plum-cli run`/`plum build`) provides it.
extern fun printLn(s: Str)

# Raw host-provided filesystem primitives — each maps to a genuine wasm
# import backed by a real OS call in the embedder (`plum-cli`/`plum-runtime`),
# not something expressible in wasm on its own (same idea as `printLn`).
# Returns `Int` (0/1), not `Bool`, for the success/failure ones — the host
# closure that satisfies a wasm import runs BEFORE the module is instantiated
# (it's part of setting up the imports an instance needs), so it has no way
# to reach the instance's own `True`/`False` singleton globals that `Bool`
# values are represented as in this codegen; a plain `i64` has no such
# problem. `rawReadFile` returns `""` for a file that doesn't exist or can't
# be read (there's no way to signal an error through a bare `Str` return);
# callers needing to tell "empty file" apart from "missing file" should check
# `exists` first, which every wrapper below does.
extern fun rawReadFile(path: Str) -> Str
extern fun rawWriteFile(path: Str, data: Str) -> Int
extern fun rawExists(path: Str) -> Int
extern fun rawMkdir(path: Str) -> Int
extern fun rawRemove(path: Str) -> Int
extern fun rawRemoveDir(path: Str) -> Int
extern fun rawRename(old_path: Str, new_path: Str) -> Int
extern fun rawTruncate(path: Str, len: Int) -> Int
extern fun rawLink(existing_path: Str, new_path: Str) -> Int
extern fun rawSymlink(target: Str, path: Str) -> Int
extern fun rawReadlink(path: Str) -> Str
extern fun rawRealpath(path: Str) -> Str
extern fun rawChmod(path: Str, mode: Int) -> Int
# Every directory entry's name (not full path), joined with `\n`; `""` for a
# missing/unreadable directory (same ambiguity `rawReadFile` already has with
# an empty file — `exists`/`readdir` below both check existence first).
extern fun rawReadDir(path: Str) -> Str

# Reads the whole file at `path` as a `Str` (`Str` is a byte array here — see
# `libs/std/str.plum` — so this works for binary content too, not just text).
fun readFile(path: Str) -> Result[Str, Str] =
  if !exists(path)
    return Err("no such file: '{path}'")
  return Ok(rawReadFile(path))

# Writes `data` to `path`, replacing its previous contents (or creating it).
fun writeFile(path: Str, data: Str) -> Result[Bool, Str] =
  if rawWriteFile(path, data) == 1
    return Ok(True)
  return Err("failed to write file: '{path}'")

fun exists(path: Str) -> Bool =
  return rawExists(path) == 1

fun mkdir(path: Str) -> Result[Bool, Str] =
  if rawMkdir(path) == 1
    return Ok(True)
  return Err("failed to create directory: '{path}'")

# Removes the file (not directory) at `path`.
fun remove(path: Str) -> Result[Bool, Str] =
  if !exists(path)
    return Err("no such file: '{path}'")
  if rawRemove(path) == 1
    return Ok(True)
  return Err("failed to remove: '{path}'")

# Whether `path` exists and is reachable — this VM has no notion of a
# process' effective uid/gid, so (unlike POSIX `access(2)`) there is no mode
# argument to check permission bits against.
fun access(path: Str) -> Bool =
  return exists(path)

# Appends `data` to the file at `path`, creating it (with just `data` as its
# contents) if it doesn't already exist. There's no host-level "open for
# append" primitive, so this reads the existing bytes and writes the
# concatenation back — not atomic, and O(existing file size) per call.
fun appendFile(path: Str, data: Str) -> Result[Bool, Str] =
  if exists(path)
    match readFile(path)
      Ok(existing) =>
        return writeFile(path, existing + data)
      Err(e) =>
        return Err(e)
  return writeFile(path, data)

# Unix permission bits only (e.g. `0o644`) — this VM has no Windows ACL
# equivalent to fall back to.
fun chmod(path: Str, mode: Int) -> Result[Bool, Str] =
  if rawChmod(path, mode) == 1
    return Ok(True)
  return Err("failed to chmod: '{path}'")

# There is no host primitive for changing file ownership (it needs a uid/gid
# syscall this VM doesn't expose), unlike the permission-bits-only `chmod`.
fun chown(path: Str, uid: Int, gid: Int) =
  todo

fun copyFile(src: Str, dest: Str) -> Result[Bool, Str] =
  match readFile(src)
    Ok(data) => writeFile(dest, data)
    Err(e) => Err(e)

fun cp(src: Str, dest: Str) -> Result[Bool, Str] =
  return copyFile(src, dest)

# Symlinks aren't modeled separately from regular files/dirs by this VM's
# host primitives (`rawChmod` always follows them), so this is the same as
# `chmod`.
fun lchmod(path: Str, mode: Int) -> Result[Bool, Str] =
  return chmod(path, mode)

# See `chown` — no uid/gid syscall exposed by this VM.
fun lchown(path: Str, uid: Int, gid: Int) =
  todo

# Setting a file's atime/mtime needs a syscall (`utimensat`/`SetFileTime`)
# this VM doesn't expose (`std::fs` itself has no equivalent either).
fun lutimes(path: Str, atime: Int, mtime: Int) =
  todo

fun link(existing_path: Str, new_path: Str) -> Result[Bool, Str] =
  if rawLink(existing_path, new_path) == 1
    return Ok(True)
  return Err("failed to link '{existing_path}' -> '{new_path}'")

# Would need a real `Stat` struct type (size/mode/mtime/uid/gid/...) to
# return anything useful — no such type exists in the stdlib yet.
fun lstat(path: Str) =
  todo

# Creates a fresh directory named `prefix` followed by a random suffix,
# retrying on the (astronomically unlikely) chance of a collision, and
# returns the path actually created.
fun mkdtemp(prefix: Str) -> Result[Str, Str] =
  path := prefix + v4().toStr()
  if exists(path)
    return mkdtemp(prefix)
  match mkdir(path)
    Ok(_) =>
      return Ok(path)
    Err(e) =>
      return Err(e)

# Would need a host-side open-file-descriptor table (this VM's other file
# ops are all one-shot: open+read/write+close in a single host call) plus a
# `File` handle type — neither exists yet.
fun open(path: Str, flags: Int) =
  todo

# See `open` — same missing handle-table/type problem, for directories.
fun opendir(path: Str) =
  todo

# The names of `path`'s directory entries (not full paths).
fun readdir(path: Str) -> Result[List[Str], Str] =
  if !exists(path)
    return Err("no such directory: '{path}'")
  # `reject`ing empty names handles both a genuinely empty directory (whose
  # joined `""` would otherwise split into one bogus `""` entry) and
  # `rawReadDir`'s "unreadable" fallback (already ruled out by the `exists`
  # check above) the same way — no directory entry is ever named `""`.
  return Ok(rawReadDir(path).split("\n", 0).reject(|n| n == ""))

fun readlink(path: Str) -> Result[Str, Str] =
  if !exists(path)
    return Err("no such file: '{path}'")
  return Ok(rawReadlink(path))

fun realpath(path: Str) -> Result[Str, Str] =
  if !exists(path)
    return Err("no such file: '{path}'")
  return Ok(rawRealpath(path))

fun rename(old_path: Str, new_path: Str) -> Result[Bool, Str] =
  if !exists(old_path)
    return Err("no such file: '{old_path}'")
  if rawRename(old_path, new_path) == 1
    return Ok(True)
  return Err("failed to rename '{old_path}' -> '{new_path}'")

# Removes an empty directory. (`remove` above covers plain files.)
fun rmdir(path: Str) -> Result[Bool, Str] =
  if !exists(path)
    return Err("no such directory: '{path}'")
  if rawRemoveDir(path) == 1
    return Ok(True)
  return Err("failed to remove directory: '{path}'")

# See `lstat` — needs a `Stat` struct type.
fun stat(path: Str) =
  todo

# Needs a filesystem-info struct type, same gap as `stat`.
fun statfs(path: Str) =
  todo

fun symlink(target: Str, path: Str) -> Result[Bool, Str] =
  if rawSymlink(target, path) == 1
    return Ok(True)
  return Err("failed to symlink '{path}' -> '{target}'")

# Resizes the file at `path` to exactly `len` bytes — truncated if shorter,
# zero-padded if longer.
fun truncate(path: Str, len: Int) -> Result[Bool, Str] =
  if !exists(path)
    return Err("no such file: '{path}'")
  if rawTruncate(path, len) == 1
    return Ok(True)
  return Err("failed to truncate: '{path}'")

# See `lutimes` — same missing syscall.
fun utimes(path: Str, atime: Int, mtime: Int) =
  todo

# Would need an async/callback mechanism to deliver filesystem change events
# into a running Plum program — nothing like that exists in this VM (every
# host call here is a synchronous, one-shot request/response).
fun watch(filename: Str) =
  todo

test "writeFile/readFile/appendFile/remove round-trip on a real file"
  match mkdtemp("/tmp/plum_os_test_")
    Ok(dir) =>
      path := dir + "/a.txt"
      assert writeFile(path, "hello").isOk() == True
      assert readFile(path).unwrap() == "hello"
      assert appendFile(path, " world").isOk() == True
      assert readFile(path).unwrap() == "hello world"
      assert exists(path) == True
      assert remove(path).isOk() == True
      assert exists(path) == False
      assert rmdir(dir).isOk() == True
    Err(_) =>
      assert True == False

test "copyFile/cp/rename move and duplicate file contents correctly"
  match mkdtemp("/tmp/plum_os_test_")
    Ok(dir) =>
      a := dir + "/a.txt"
      b := dir + "/b.txt"
      c := dir + "/c.txt"
      writeFile(a, "data")
      assert copyFile(a, b).isOk() == True
      assert readFile(b).unwrap() == "data"
      assert rename(b, c).isOk() == True
      assert exists(b) == False
      assert readFile(c).unwrap() == "data"
      remove(a)
      remove(c)
      rmdir(dir)
    Err(_) =>
      assert True == False

test "readdir lists the entries created in a fresh directory"
  match mkdtemp("/tmp/plum_os_test_")
    Ok(dir) =>
      assert readdir(dir).unwrap().length() == 0
      writeFile(dir + "/only.txt", "x")
      names := readdir(dir).unwrap()
      assert names.length() == 1
      assert names.join(",") == "only.txt"
      remove(dir + "/only.txt")
      rmdir(dir)
    Err(_) =>
      assert True == False

test "symlink/readlink/realpath resolve a linked file correctly"
  match mkdtemp("/tmp/plum_os_test_")
    Ok(dir) =>
      target := dir + "/target.txt"
      link_path := dir + "/link.txt"
      writeFile(target, "linked")
      assert symlink(target, link_path).isOk() == True
      assert readlink(link_path).unwrap() == target
      assert readFile(link_path).unwrap() == "linked"
      assert realpath(link_path).isOk() == True
      remove(link_path)
      remove(target)
      rmdir(dir)
    Err(_) =>
      assert True == False

test "truncate resizes a file's contents"
  match mkdtemp("/tmp/plum_os_test_")
    Ok(dir) =>
      path := dir + "/a.txt"
      writeFile(path, "hello world")
      assert truncate(path, 5).isOk() == True
      assert readFile(path).unwrap() == "hello"
      remove(path)
      rmdir(dir)
    Err(_) =>
      assert True == False

test "chmod changes a file's permission bits"
  match mkdtemp("/tmp/plum_os_test_")
    Ok(dir) =>
      path := dir + "/a.txt"
      writeFile(path, "x")
      assert chmod(path, 384).isOk() == True # 0o600
      remove(path)
      rmdir(dir)
    Err(_) =>
      assert True == False

test "link creates a second name for the same file"
  match mkdtemp("/tmp/plum_os_test_")
    Ok(dir) =>
      a := dir + "/a.txt"
      b := dir + "/b.txt"
      writeFile(a, "shared")
      assert link(a, b).isOk() == True
      assert readFile(b).unwrap() == "shared"
      remove(a)
      remove(b)
      rmdir(dir)
    Err(_) =>
      assert True == False

test "readFile/remove/rmdir report errors for missing paths"
  assert readFile("/tmp/plum_os_test_does_not_exist.txt").isErr() == True
  assert remove("/tmp/plum_os_test_does_not_exist.txt").isErr() == True
  assert rmdir("/tmp/plum_os_test_does_not_exist_dir").isErr() == True