plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-std/Os.plum
| 141de54 | 1 | module std |
| b7071c9 | 2 | import std/List |
| b7071c9 | 3 | import std/Result |
| b7071c9 | 4 | import std/Str |
| b7071c9 | 5 | import std/Uuid |
| 73b5e55 | 6 | import std/Bool |
| ca5fd6f | 7 | import std/Number |
| afb772e | 8 | |
| 141de54 | 9 | # NOTE: the original file modeled `stdin`/`stdout`/`stderr` as bare top-level |
| 141de54 | 10 | # variable bindings and a `File(...)` constructor, but this language has no |
| 141de54 | 11 | # top-level `let` and `File` is never defined anywhere in the stdlib. Reduced |
| 141de54 | 12 | # to the underlying path so this at least parses; a real `File` type is |
| 141de54 | 13 | # still needed here. |
| 0fe3528 | 14 | fun stdin() -> Str = |
| 141de54 | 15 | "/dev/stdin" |
| 141de54 | 16 | |
| 0fe3528 | 17 | fun stdout() -> Str = |
| 141de54 | 18 | "/dev/stdout" |
| 141de54 | 19 | |
| 0fe3528 | 20 | fun stderr() -> Str = |
| 141de54 | 21 | "/dev/stderr" |
| afb772e | 22 | |
| a271f34 | 23 | # Writes the specified data, followed by the current line terminator, to the |
| a271f34 | 24 | # standard output stream. `extern` means this compiles to a genuine wasm import |
| a271f34 | 25 | # (`plum::printLn`) instead of a Plum body — printing isn't expressible in wasm |
| a271f34 | 26 | # on its own; the embedding host (`plum-cli run`/`plum build`) provides it. |
| a271f34 | 27 | extern fun printLn(s: Str) |
| a271f34 | 28 | |
| a271f34 | 29 | # Raw host-provided filesystem primitives — each maps to a genuine wasm |
| a271f34 | 30 | # import backed by a real OS call in the embedder (`plum-cli`/`plum-runtime`), |
| a271f34 | 31 | # not something expressible in wasm on its own (same idea as `printLn`). |
| a271f34 | 32 | # Returns `Int` (0/1), not `Bool`, for the success/failure ones — the host |
| a271f34 | 33 | # closure that satisfies a wasm import runs BEFORE the module is instantiated |
| a271f34 | 34 | # (it's part of setting up the imports an instance needs), so it has no way |
| a271f34 | 35 | # to reach the instance's own `True`/`False` singleton globals that `Bool` |
| a271f34 | 36 | # values are represented as in this codegen; a plain `i64` has no such |
| a271f34 | 37 | # problem. `rawReadFile` returns `""` for a file that doesn't exist or can't |
| a271f34 | 38 | # be read (there's no way to signal an error through a bare `Str` return); |
| a271f34 | 39 | # callers needing to tell "empty file" apart from "missing file" should check |
| a271f34 | 40 | # `exists` first, which every wrapper below does. |
| a271f34 | 41 | extern fun rawReadFile(path: Str) -> Str |
| a271f34 | 42 | extern fun rawWriteFile(path: Str, data: Str) -> Int |
| a271f34 | 43 | extern fun rawExists(path: Str) -> Int |
| a271f34 | 44 | extern fun rawMkdir(path: Str) -> Int |
| a271f34 | 45 | extern fun rawRemove(path: Str) -> Int |
| 2ec05c8 | 46 | extern fun rawRemoveDir(path: Str) -> Int |
| 2ec05c8 | 47 | extern fun rawRename(old_path: Str, new_path: Str) -> Int |
| 2ec05c8 | 48 | extern fun rawTruncate(path: Str, len: Int) -> Int |
| 2ec05c8 | 49 | extern fun rawLink(existing_path: Str, new_path: Str) -> Int |
| 2ec05c8 | 50 | extern fun rawSymlink(target: Str, path: Str) -> Int |
| 2ec05c8 | 51 | extern fun rawReadlink(path: Str) -> Str |
| 2ec05c8 | 52 | extern fun rawRealpath(path: Str) -> Str |
| 2ec05c8 | 53 | extern fun rawChmod(path: Str, mode: Int) -> Int |
| 2ec05c8 | 54 | # Every directory entry's name (not full path), joined with `\n`; `""` for a |
| 2ec05c8 | 55 | # missing/unreadable directory (same ambiguity `rawReadFile` already has with |
| 2ec05c8 | 56 | # an empty file — `exists`/`readdir` below both check existence first). |
| 2ec05c8 | 57 | extern fun rawReadDir(path: Str) -> Str |
| a271f34 | 58 | |
| a271f34 | 59 | # Reads the whole file at `path` as a `Str` (`Str` is a byte array here — see |
| a271f34 | 60 | # `libs/std/str.plum` — so this works for binary content too, not just text). |
| a271f34 | 61 | fun readFile(path: Str) -> Result[Str, Str] = |
| a271f34 | 62 | if !exists(path) |
| a271f34 | 63 | return Err("no such file: '{path}'") |
| a271f34 | 64 | return Ok(rawReadFile(path)) |
| a271f34 | 65 | |
| a271f34 | 66 | # Writes `data` to `path`, replacing its previous contents (or creating it). |
| a271f34 | 67 | fun writeFile(path: Str, data: Str) -> Result[Bool, Str] = |
| a271f34 | 68 | if rawWriteFile(path, data) == 1 |
| a271f34 | 69 | return Ok(True) |
| a271f34 | 70 | return Err("failed to write file: '{path}'") |
| a271f34 | 71 | |
| a271f34 | 72 | fun exists(path: Str) -> Bool = |
| a271f34 | 73 | return rawExists(path) == 1 |
| a271f34 | 74 | |
| a271f34 | 75 | fun mkdir(path: Str) -> Result[Bool, Str] = |
| a271f34 | 76 | if rawMkdir(path) == 1 |
| a271f34 | 77 | return Ok(True) |
| a271f34 | 78 | return Err("failed to create directory: '{path}'") |
| a271f34 | 79 | |
| a271f34 | 80 | # Removes the file (not directory) at `path`. |
| a271f34 | 81 | fun remove(path: Str) -> Result[Bool, Str] = |
| a271f34 | 82 | if !exists(path) |
| a271f34 | 83 | return Err("no such file: '{path}'") |
| a271f34 | 84 | if rawRemove(path) == 1 |
| a271f34 | 85 | return Ok(True) |
| a271f34 | 86 | return Err("failed to remove: '{path}'") |
| afb772e | 87 | |
| 2ec05c8 | 88 | # Whether `path` exists and is reachable — this VM has no notion of a |
| 2ec05c8 | 89 | # process' effective uid/gid, so (unlike POSIX `access(2)`) there is no mode |
| 2ec05c8 | 90 | # argument to check permission bits against. |
| 2ec05c8 | 91 | fun access(path: Str) -> Bool = |
| 2ec05c8 | 92 | return exists(path) |
| 2ec05c8 | 93 | |
| 2ec05c8 | 94 | # Appends `data` to the file at `path`, creating it (with just `data` as its |
| 2ec05c8 | 95 | # contents) if it doesn't already exist. There's no host-level "open for |
| 2ec05c8 | 96 | # append" primitive, so this reads the existing bytes and writes the |
| 2ec05c8 | 97 | # concatenation back — not atomic, and O(existing file size) per call. |
| 2ec05c8 | 98 | fun appendFile(path: Str, data: Str) -> Result[Bool, Str] = |
| 2ec05c8 | 99 | if exists(path) |
| 2ec05c8 | 100 | match readFile(path) |
| 2ec05c8 | 101 | Ok(existing) => |
| 2ec05c8 | 102 | return writeFile(path, existing + data) |
| 2ec05c8 | 103 | Err(e) => |
| 2ec05c8 | 104 | return Err(e) |
| 2ec05c8 | 105 | return writeFile(path, data) |
| 2ec05c8 | 106 | |
| 2ec05c8 | 107 | # Unix permission bits only (e.g. `0o644`) — this VM has no Windows ACL |
| 2ec05c8 | 108 | # equivalent to fall back to. |
| 2ec05c8 | 109 | fun chmod(path: Str, mode: Int) -> Result[Bool, Str] = |
| 2ec05c8 | 110 | if rawChmod(path, mode) == 1 |
| 2ec05c8 | 111 | return Ok(True) |
| 2ec05c8 | 112 | return Err("failed to chmod: '{path}'") |
| afb772e | 113 | |
| 2ec05c8 | 114 | # There is no host primitive for changing file ownership (it needs a uid/gid |
| 2ec05c8 | 115 | # syscall this VM doesn't expose), unlike the permission-bits-only `chmod`. |
| 0fe3528 | 116 | fun chown(path: Str, uid: Int, gid: Int) = |
| afb772e | 117 | todo |
| afb772e | 118 | |
| 2ec05c8 | 119 | fun copyFile(src: Str, dest: Str) -> Result[Bool, Str] = |
| 2ec05c8 | 120 | match readFile(src) |
| a9a0147 | 121 | Ok(data) => writeFile(dest, data) |
| a9a0147 | 122 | Err(e) => Err(e) |
| afb772e | 123 | |
| 2ec05c8 | 124 | fun cp(src: Str, dest: Str) -> Result[Bool, Str] = |
| 2ec05c8 | 125 | return copyFile(src, dest) |
| afb772e | 126 | |
| 2ec05c8 | 127 | # Symlinks aren't modeled separately from regular files/dirs by this VM's |
| 2ec05c8 | 128 | # host primitives (`rawChmod` always follows them), so this is the same as |
| 2ec05c8 | 129 | # `chmod`. |
| 2ec05c8 | 130 | fun lchmod(path: Str, mode: Int) -> Result[Bool, Str] = |
| 2ec05c8 | 131 | return chmod(path, mode) |
| afb772e | 132 | |
| 2ec05c8 | 133 | # See `chown` — no uid/gid syscall exposed by this VM. |
| 0fe3528 | 134 | fun lchown(path: Str, uid: Int, gid: Int) = |
| afb772e | 135 | todo |
| afb772e | 136 | |
| 2ec05c8 | 137 | # Setting a file's atime/mtime needs a syscall (`utimensat`/`SetFileTime`) |
| 2ec05c8 | 138 | # this VM doesn't expose (`std::fs` itself has no equivalent either). |
| 0fe3528 | 139 | fun lutimes(path: Str, atime: Int, mtime: Int) = |
| afb772e | 140 | todo |
| afb772e | 141 | |
| 2ec05c8 | 142 | fun link(existing_path: Str, new_path: Str) -> Result[Bool, Str] = |
| 2ec05c8 | 143 | if rawLink(existing_path, new_path) == 1 |
| 2ec05c8 | 144 | return Ok(True) |
| 2ec05c8 | 145 | return Err("failed to link '{existing_path}' -> '{new_path}'") |
| afb772e | 146 | |
| 2ec05c8 | 147 | # Would need a real `Stat` struct type (size/mode/mtime/uid/gid/...) to |
| 2ec05c8 | 148 | # return anything useful — no such type exists in the stdlib yet. |
| 0fe3528 | 149 | fun lstat(path: Str) = |
| afb772e | 150 | todo |
| afb772e | 151 | |
| 2ec05c8 | 152 | # Creates a fresh directory named `prefix` followed by a random suffix, |
| 2ec05c8 | 153 | # retrying on the (astronomically unlikely) chance of a collision, and |
| 2ec05c8 | 154 | # returns the path actually created. |
| 2ec05c8 | 155 | fun mkdtemp(prefix: Str) -> Result[Str, Str] = |
| 2ec05c8 | 156 | path := prefix + v4().toStr() |
| 2ec05c8 | 157 | if exists(path) |
| 2ec05c8 | 158 | return mkdtemp(prefix) |
| 2ec05c8 | 159 | match mkdir(path) |
| 2ec05c8 | 160 | Ok(_) => |
| 2ec05c8 | 161 | return Ok(path) |
| 2ec05c8 | 162 | Err(e) => |
| 2ec05c8 | 163 | return Err(e) |
| 2ec05c8 | 164 | |
| 2ec05c8 | 165 | # Would need a host-side open-file-descriptor table (this VM's other file |
| 2ec05c8 | 166 | # ops are all one-shot: open+read/write+close in a single host call) plus a |
| 2ec05c8 | 167 | # `File` handle type — neither exists yet. |
| 0fe3528 | 168 | fun open(path: Str, flags: Int) = |
| afb772e | 169 | todo |
| afb772e | 170 | |
| 2ec05c8 | 171 | # See `open` — same missing handle-table/type problem, for directories. |
| 0fe3528 | 172 | fun opendir(path: Str) = |
| afb772e | 173 | todo |
| afb772e | 174 | |
| 2ec05c8 | 175 | # The names of `path`'s directory entries (not full paths). |
| 2ec05c8 | 176 | fun readdir(path: Str) -> Result[List[Str], Str] = |
| 2ec05c8 | 177 | if !exists(path) |
| 2ec05c8 | 178 | return Err("no such directory: '{path}'") |
| 2ec05c8 | 179 | # `reject`ing empty names handles both a genuinely empty directory (whose |
| 2ec05c8 | 180 | # joined `""` would otherwise split into one bogus `""` entry) and |
| 2ec05c8 | 181 | # `rawReadDir`'s "unreadable" fallback (already ruled out by the `exists` |
| 2ec05c8 | 182 | # check above) the same way — no directory entry is ever named `""`. |
| 2ec05c8 | 183 | return Ok(rawReadDir(path).split("\n", 0).reject(|n| n == "")) |
| 2ec05c8 | 184 | |
| 2ec05c8 | 185 | fun readlink(path: Str) -> Result[Str, Str] = |
| 2ec05c8 | 186 | if !exists(path) |
| 2ec05c8 | 187 | return Err("no such file: '{path}'") |
| 2ec05c8 | 188 | return Ok(rawReadlink(path)) |
| afb772e | 189 | |
| 2ec05c8 | 190 | fun realpath(path: Str) -> Result[Str, Str] = |
| 2ec05c8 | 191 | if !exists(path) |
| 2ec05c8 | 192 | return Err("no such file: '{path}'") |
| 2ec05c8 | 193 | return Ok(rawRealpath(path)) |
| afb772e | 194 | |
| 2ec05c8 | 195 | fun rename(old_path: Str, new_path: Str) -> Result[Bool, Str] = |
| 2ec05c8 | 196 | if !exists(old_path) |
| 2ec05c8 | 197 | return Err("no such file: '{old_path}'") |
| 2ec05c8 | 198 | if rawRename(old_path, new_path) == 1 |
| 2ec05c8 | 199 | return Ok(True) |
| 2ec05c8 | 200 | return Err("failed to rename '{old_path}' -> '{new_path}'") |
| afb772e | 201 | |
| a271f34 | 202 | # Removes an empty directory. (`remove` above covers plain files.) |
| 2ec05c8 | 203 | fun rmdir(path: Str) -> Result[Bool, Str] = |
| 2ec05c8 | 204 | if !exists(path) |
| 2ec05c8 | 205 | return Err("no such directory: '{path}'") |
| 2ec05c8 | 206 | if rawRemoveDir(path) == 1 |
| 2ec05c8 | 207 | return Ok(True) |
| 2ec05c8 | 208 | return Err("failed to remove directory: '{path}'") |
| afb772e | 209 | |
| 2ec05c8 | 210 | # See `lstat` — needs a `Stat` struct type. |
| 0fe3528 | 211 | fun stat(path: Str) = |
| afb772e | 212 | todo |
| afb772e | 213 | |
| 2ec05c8 | 214 | # Needs a filesystem-info struct type, same gap as `stat`. |
| 0fe3528 | 215 | fun statfs(path: Str) = |
| afb772e | 216 | todo |
| afb772e | 217 | |
| 2ec05c8 | 218 | fun symlink(target: Str, path: Str) -> Result[Bool, Str] = |
| 2ec05c8 | 219 | if rawSymlink(target, path) == 1 |
| 2ec05c8 | 220 | return Ok(True) |
| 2ec05c8 | 221 | return Err("failed to symlink '{path}' -> '{target}'") |
| afb772e | 222 | |
| 2ec05c8 | 223 | # Resizes the file at `path` to exactly `len` bytes — truncated if shorter, |
| 2ec05c8 | 224 | # zero-padded if longer. |
| 2ec05c8 | 225 | fun truncate(path: Str, len: Int) -> Result[Bool, Str] = |
| 2ec05c8 | 226 | if !exists(path) |
| 2ec05c8 | 227 | return Err("no such file: '{path}'") |
| 2ec05c8 | 228 | if rawTruncate(path, len) == 1 |
| 2ec05c8 | 229 | return Ok(True) |
| 2ec05c8 | 230 | return Err("failed to truncate: '{path}'") |
| afb772e | 231 | |
| 2ec05c8 | 232 | # See `lutimes` — same missing syscall. |
| 0fe3528 | 233 | fun utimes(path: Str, atime: Int, mtime: Int) = |
| afb772e | 234 | todo |
| afb772e | 235 | |
| 2ec05c8 | 236 | # Would need an async/callback mechanism to deliver filesystem change events |
| 2ec05c8 | 237 | # into a running Plum program — nothing like that exists in this VM (every |
| 2ec05c8 | 238 | # host call here is a synchronous, one-shot request/response). |
| 0fe3528 | 239 | fun watch(filename: Str) = |
| afb772e | 240 | todo |
| 2ec05c8 | 241 | |
| 2ec05c8 | 242 | test "writeFile/readFile/appendFile/remove round-trip on a real file" |
| 2ec05c8 | 243 | match mkdtemp("/tmp/plum_os_test_") |
| 2ec05c8 | 244 | Ok(dir) => |
| 2ec05c8 | 245 | path := dir + "/a.txt" |
| a9a0147 | 246 | assert writeFile(path, "hello").isOk() == True |
| a9a0147 | 247 | assert readFile(path).unwrap() == "hello" |
| a9a0147 | 248 | assert appendFile(path, " world").isOk() == True |
| a9a0147 | 249 | assert readFile(path).unwrap() == "hello world" |
| a9a0147 | 250 | assert exists(path) == True |
| a9a0147 | 251 | assert remove(path).isOk() == True |
| a9a0147 | 252 | assert exists(path) == False |
| a9a0147 | 253 | assert rmdir(dir).isOk() == True |
| 2ec05c8 | 254 | Err(_) => |
| a9a0147 | 255 | assert True == False |
| 2ec05c8 | 256 | |
| 2ec05c8 | 257 | test "copyFile/cp/rename move and duplicate file contents correctly" |
| 2ec05c8 | 258 | match mkdtemp("/tmp/plum_os_test_") |
| 2ec05c8 | 259 | Ok(dir) => |
| 2ec05c8 | 260 | a := dir + "/a.txt" |
| 2ec05c8 | 261 | b := dir + "/b.txt" |
| 2ec05c8 | 262 | c := dir + "/c.txt" |
| 2ec05c8 | 263 | writeFile(a, "data") |
| a9a0147 | 264 | assert copyFile(a, b).isOk() == True |
| a9a0147 | 265 | assert readFile(b).unwrap() == "data" |
| a9a0147 | 266 | assert rename(b, c).isOk() == True |
| a9a0147 | 267 | assert exists(b) == False |
| a9a0147 | 268 | assert readFile(c).unwrap() == "data" |
| 2ec05c8 | 269 | remove(a) |
| 2ec05c8 | 270 | remove(c) |
| 2ec05c8 | 271 | rmdir(dir) |
| 2ec05c8 | 272 | Err(_) => |
| a9a0147 | 273 | assert True == False |
| 2ec05c8 | 274 | |
| 2ec05c8 | 275 | test "readdir lists the entries created in a fresh directory" |
| 2ec05c8 | 276 | match mkdtemp("/tmp/plum_os_test_") |
| 2ec05c8 | 277 | Ok(dir) => |
| a9a0147 | 278 | assert readdir(dir).unwrap().length() == 0 |
| 2ec05c8 | 279 | writeFile(dir + "/only.txt", "x") |
| 2ec05c8 | 280 | names := readdir(dir).unwrap() |
| a9a0147 | 281 | assert names.length() == 1 |
| a9a0147 | 282 | assert names.join(",") == "only.txt" |
| 2ec05c8 | 283 | remove(dir + "/only.txt") |
| 2ec05c8 | 284 | rmdir(dir) |
| 2ec05c8 | 285 | Err(_) => |
| a9a0147 | 286 | assert True == False |
| 2ec05c8 | 287 | |
| 2ec05c8 | 288 | test "symlink/readlink/realpath resolve a linked file correctly" |
| 2ec05c8 | 289 | match mkdtemp("/tmp/plum_os_test_") |
| 2ec05c8 | 290 | Ok(dir) => |
| 2ec05c8 | 291 | target := dir + "/target.txt" |
| 2ec05c8 | 292 | link_path := dir + "/link.txt" |
| 2ec05c8 | 293 | writeFile(target, "linked") |
| a9a0147 | 294 | assert symlink(target, link_path).isOk() == True |
| a9a0147 | 295 | assert readlink(link_path).unwrap() == target |
| a9a0147 | 296 | assert readFile(link_path).unwrap() == "linked" |
| a9a0147 | 297 | assert realpath(link_path).isOk() == True |
| 2ec05c8 | 298 | remove(link_path) |
| 2ec05c8 | 299 | remove(target) |
| 2ec05c8 | 300 | rmdir(dir) |
| 2ec05c8 | 301 | Err(_) => |
| a9a0147 | 302 | assert True == False |
| 2ec05c8 | 303 | |
| 2ec05c8 | 304 | test "truncate resizes a file's contents" |
| 2ec05c8 | 305 | match mkdtemp("/tmp/plum_os_test_") |
| 2ec05c8 | 306 | Ok(dir) => |
| 2ec05c8 | 307 | path := dir + "/a.txt" |
| 2ec05c8 | 308 | writeFile(path, "hello world") |
| a9a0147 | 309 | assert truncate(path, 5).isOk() == True |
| a9a0147 | 310 | assert readFile(path).unwrap() == "hello" |
| 2ec05c8 | 311 | remove(path) |
| 2ec05c8 | 312 | rmdir(dir) |
| 2ec05c8 | 313 | Err(_) => |
| a9a0147 | 314 | assert True == False |
| 2ec05c8 | 315 | |
| 2ec05c8 | 316 | test "chmod changes a file's permission bits" |
| 2ec05c8 | 317 | match mkdtemp("/tmp/plum_os_test_") |
| 2ec05c8 | 318 | Ok(dir) => |
| 2ec05c8 | 319 | path := dir + "/a.txt" |
| 2ec05c8 | 320 | writeFile(path, "x") |
| a9a0147 | 321 | assert chmod(path, 384).isOk() == True # 0o600 |
| 2ec05c8 | 322 | remove(path) |
| 2ec05c8 | 323 | rmdir(dir) |
| 2ec05c8 | 324 | Err(_) => |
| a9a0147 | 325 | assert True == False |
| 2ec05c8 | 326 | |
| 2ec05c8 | 327 | test "link creates a second name for the same file" |
| 2ec05c8 | 328 | match mkdtemp("/tmp/plum_os_test_") |
| 2ec05c8 | 329 | Ok(dir) => |
| 2ec05c8 | 330 | a := dir + "/a.txt" |
| 2ec05c8 | 331 | b := dir + "/b.txt" |
| 2ec05c8 | 332 | writeFile(a, "shared") |
| a9a0147 | 333 | assert link(a, b).isOk() == True |
| a9a0147 | 334 | assert readFile(b).unwrap() == "shared" |
| 2ec05c8 | 335 | remove(a) |
| 2ec05c8 | 336 | remove(b) |
| 2ec05c8 | 337 | rmdir(dir) |
| 2ec05c8 | 338 | Err(_) => |
| a9a0147 | 339 | assert True == False |
| 2ec05c8 | 340 | |
| 2ec05c8 | 341 | test "readFile/remove/rmdir report errors for missing paths" |
| a9a0147 | 342 | assert readFile("/tmp/plum_os_test_does_not_exist.txt").isErr() == True |
| a9a0147 | 343 | assert remove("/tmp/plum_os_test_does_not_exist.txt").isErr() == True |
| a9a0147 | 344 | assert rmdir("/tmp/plum_os_test_does_not_exist_dir").isErr() == True |