1 # POSIX paths plus directory and metadata effects. Source: https://github.com/paymog/bend-kit/tree/main/files 2 import Base 3 import bend-kit-bytes@0.3.3.0/bytes.bend as Bytes 4 5 # The path.* defs are pure and lexical: they never touch the disk. 6 # The effects are thin OS calls; each failure is (errno, text). 7 # import ./files/files.bend as Fs 8 9 def path.is_absolute(p: String) -> Bool: 10 String.starts_with(p, "/") 11 12 def path.join.rel(bare: Bool, a: String, b: String) -> String: 13 match bare: 14 case True{}: 15 a ++ b 16 case False{}: 17 a ++ "/" ++ b 18 19 def path.join.if(abs: Bool, +a: String, b: String) -> String: 20 match abs: 21 case True{}: 22 b 23 case False{}: 24 path.join.rel(String.is_empty(a) || String.ends_with(a, "/"), a, b) 25 26 # b when b is absolute; otherwise b under a. 27 def path.join(a: String, +b: String) -> String: 28 path.join.if(path.is_absolute(b), a, b) 29 30 def path.norm.pop.if(up: Bool, h: String, t: List<&2, String>) -> 31 List<&2, String>: 32 match up: 33 case True{}: 34 ".." <> (h <> t) 35 case False{}: 36 t 37 38 # A ".." above the root is dropped; above a relative start it is kept. 39 def path.norm.pop(stack: List<&2, String>, abs: Bool) -> List<&2, String>: 40 match stack: 41 case Nil{}: 42 Bool.pick(List<&2, String>, abs, Nil{}, [".."]) 43 case Con{+h, t}: 44 path.norm.pop.if(String.eq(h, ".."), h, t) 45 46 def path.norm.step( 47 skip: Bool, up: Bool, s: String, stack: List<&2, String>, abs: Bool 48 ) -> List<&2, String>: 49 match skip: 50 case True{}: 51 stack 52 case False{}: 53 match up: 54 case True{}: 55 path.norm.pop(stack, abs) 56 case False{}: 57 s <> stack 58 59 # The kept segments, last first. 60 def path.norm.go(segs: List<&2, String>, stack: List<&2, String>, +abs: Bool) -> 61 List<&2, String>: 62 match segs: 63 case Nil{}: 64 stack 65 case Con{+s, t}: 66 path.norm.go(t, 67 path.norm.step(String.eq(s, "") || String.eq(s, "."), String.eq(s, ".."), 68 s, stack, abs), 69 abs) 70 71 def path.norm.fin(abs: Bool, body: String) -> String: 72 match abs: 73 case True{}: 74 "/" ++ body 75 case False{}: 76 match body: 77 case SNil{}: 78 "." 79 case SCon{h, t}: 80 SCon{h, t} 81 82 # Drops empty and "." segments and resolves "..", as POSIX normpath does 83 # (RFC 3986 ยง5.2.4 for paths), but always to one leading "/". 84 def path.normalize(+p: String) -> String: 85 +abs = path.is_absolute(p) 86 path.norm.fin(abs, 87 String.join(List.reverse(&2, String, 88 path.norm.go(String.split(p, '/'), Nil{}, abs)), "/")) 89 90 def path.parent(p: String) -> String: 91 +n = path.normalize(p) 92 path.norm.fin(path.is_absolute(n), 93 String.join(List.reverse(&2, String, 94 List.tail(&2, String, List.reverse(&2, String, String.split(n, '/')))), "/")) 95 96 def path.file_name.fin(m: Maybe<&2, String>) -> String: 97 match m: 98 case None{}: 99 "" 100 case Some{+s}: 101 Bool.pick(String, String.eq(s, "."), "", s) 102 103 # The last segment of the normalized path; "" for "/" and ".". 104 def path.file_name(p: String) -> String: 105 path.file_name.fin(List.last(&2, String, String.split(path.normalize(p), '/'))) 106 107 def path.extension.fin(bare: Bool, ext: String) -> String: 108 match bare: 109 case True{}: 110 "" 111 case False{}: 112 ext 113 114 def path.extension.go(rev: List<&2, String>) -> String: 115 match rev: 116 case Nil{}: 117 "" 118 case Con{ext, Nil{}}: 119 "" 120 case Con{ext, Con{prev, rest}}: 121 path.extension.fin(String.is_empty(prev) && List.is_empty(&2, String, rest), 122 ext) 123 124 # The text after the file name's last ".", without the dot. A leading dot 125 # does not start one: ".bashrc" has none. 126 def path.extension(p: String) -> String: 127 path.extension.go(List.reverse(&2, String, String.split(path.file_name(p), '.'))) 128 129 type FileType is Data: 130 Regular{} 131 Directory{} 132 Other{} 133 134 # mtime is whole seconds since the Unix epoch. size and mtime wrap at 2^32. 135 type Info is Data: 136 Info{kind: FileType, size: U32, mtime: U32} 137 138 # Entry names, NUL-separated, in the order the OS gives them. 139 def list_dir.raw(path: String) -> IO(Result<&1, &1, U32 & String, String>): 140 import "./effs/files.c" 141 import "./effs/files.js" 142 143 # (kind, size, mtime); kind 0 is a regular file, 1 a directory, 2 other. 144 # Symbolic links are followed. 145 def stat.raw(path: String) -> 146 IO(Result<&1, &1, U32 & String, U32 & U32 & U32>): 147 import "./effs/files.c" 148 import "./effs/files.js" 149 150 def mkdir(path: String) -> IO(Result<&1, &1, U32 & String, Unit>): 151 import "./effs/files.c" 152 import "./effs/files.js" 153 154 # Removes a file or an empty directory. 155 def remove(path: String) -> IO(Result<&1, &1, U32 & String, Unit>): 156 import "./effs/files.c" 157 import "./effs/files.js" 158 159 def rename(from: String, to: String) -> 160 IO(Result<&1, &1, U32 & String, Unit>): 161 import "./effs/files.c" 162 import "./effs/files.js" 163 164 # Creates a new, empty, private directory under the system temp dir. 165 def temp_dir() -> IO(Result<&1, &1, U32 & String, String>): 166 import "./effs/files.c" 167 import "./effs/files.js" 168 169 # A successful zero-length read is EOF (or max = 0). Reads at most 1 MiB 170 # per call; a short read is not necessarily EOF. Errors retain the File. 171 def read.words(file: File, max: U32) -> 172 IO(File & Result<&1, &1, U32 & String, U32 & Array>): 173 import "./effs/files.c" 174 import "./effs/files.js" 175 176 # The size of an open file as (hi, lo): hi * 2^32 + lo bytes. Errors retain the File. 177 def size64(file: File) -> 178 IO(File & Result<&1, &1, U32 & String, U32 & U32>): 179 import "./effs/files.c" 180 import "./effs/files.js" 181 182 # read.words at byte offset hi * 2^32 + lo. The file position does not move. 183 def read.words.at(file: File, hi: U32, lo: U32, max: U32) -> 184 IO(File & Result<&1, &1, U32 & String, U32 & Array>): 185 import "./effs/files.c" 186 import "./effs/files.js" 187 188 def read_bytes.wrap(result: Result<&1, &1, U32 & String, U32 & Array>) -> 189 Result<&1, &1, U32 & String, Bytes.Bytes>: 190 match result: 191 case Fail{err}: 192 Fail{err} 193 case Done{(len, words)}: 194 Done{Bytes.Bytes{len, words}} 195 196 def read_bytes.pair(pair: File & Result<&1, &1, U32 & String, U32 & Array>) -> 197 File & Result<&1, &1, U32 & String, Bytes.Bytes>: 198 match pair: 199 case (handle, result): 200 (handle, read_bytes.wrap(result)) 201 202 # The existing Bytes layout, without an octet List or String. 203 def read_bytes(file: File, max: U32) -> 204 IO(File & Result<&1, &1, U32 & String, Bytes.Bytes>): 205 do IO>: 206 pair : File & Result<&1, &1, U32 & String, U32 & Array> <- read.words(file, max) 207 return read_bytes.pair(pair) 208 209 # Writes the entire buffer or fails. Errors retain the File; writes before 210 # an error are not rolled back. A len beyond the word array fails EINVAL. 211 def write.words(file: File, len: U32, words: Array) -> 212 IO(File & Result<&1, &1, U32 & String, Unit>): 213 import "./effs/files.c" 214 import "./effs/files.js" 215 216 def write_bytes(file: File, data: Bytes.Bytes) -> 217 IO(File & Result<&1, &1, U32 & String, Unit>): 218 Bytes.Bytes{len, buf} = data 219 write.words(file, len, buf) 220 221 def list_dir.fin(r: Result<&1, &1, U32 & String, String>) -> 222 Result<&1, &1, U32 & String, List<&2, String>>: 223 match r: 224 case Fail{e}: 225 Fail{e} 226 case Done{s}: 227 Done{List.sort(~String, ~String.is_le, 228 List.filter(~String, ~(n => Bool.not(String.is_empty(n))), 229 String.split(s, Chr{0})))} 230 231 # Entry names, without "." and "..", sorted. 232 def list_dir(path: String) -> 233 IO(Result<&1, &1, U32 & String, List<&2, String>>): 234 do IO>>: 235 r : Result<&1, &1, U32 & String, String> <- list_dir.raw(path) 236 return list_dir.fin(r) 237 238 def stat.kind(+k: U32) -> FileType: 239 Bool.pick(FileType, U32.is_eq(k, 0), Regular{}, 240 Bool.pick(FileType, U32.is_eq(k, 1), Directory{}, Other{})) 241 242 def stat.fin(r: Result<&1, &1, U32 & String, U32 & U32 & U32>) -> 243 Result<&1, &1, U32 & String, Info>: 244 match r: 245 case Fail{e}: 246 Fail{e} 247 case Done{(k, size, mtime)}: 248 Done{Info{stat.kind(k), size, mtime}} 249 250 def stat(path: String) -> IO(Result<&1, &1, U32 & String, Info>): 251 do IO>: 252 r : Result<&1, &1, U32 & String, U32 & U32 & U32> <- stat.raw(path) 253 return stat.fin(r) 254 255 def exists(path: String) -> IO(Bool): 256 do IO: 257 r : Result<&1, &1, U32 & String, U32 & U32 & U32> <- stat.raw(path) 258 return Result.is_done(&1, &1, U32 & String, U32 & U32 & U32, r) 259 260 def mkdir_all.dir(r: Result<&1, &1, U32 & String, Info>) -> 261 Result<&1, &1, U32 & String, Unit>: 262 match r: 263 case Fail{e}: 264 Fail{e} 265 case Done{Info{Directory{}, size, mtime}}: 266 Done{Unit{}} 267 case Done{i}: 268 Fail{(17, "File exists")} 269 270 def mkdir_all.fail(exists: Bool, e: U32 & String, 271 k: IO(Result<&1, &1, U32 & String, Unit>)) -> 272 IO(Result<&1, &1, U32 & String, Unit>): 273 match exists: 274 case True{}: 275 k 276 case False{}: 277 IO.pure(Result<&1, &1, U32 & String, Unit>, Fail{e}) 278 279 def mkdir_all.step(r: Result<&1, &1, U32 & String, Unit>, 280 k: IO(Result<&1, &1, U32 & String, Unit>)) -> 281 IO(Result<&1, &1, U32 & String, Unit>): 282 match r: 283 case Fail{(+code, text)}: 284 mkdir_all.fail(U32.is_eq(code, 17), (code, text), k) 285 case Done{u}: 286 k 287 288 def mkdir_all.go(segs: List<&2, String>, +at: String) -> 289 IO(Result<&1, &1, U32 & String, Unit>): 290 match segs: 291 case Nil{}: 292 do IO>: 293 r : Result<&1, &1, U32 & String, Info> <- stat(at) 294 return mkdir_all.dir(r) 295 case Con{s, t}: 296 +next = path.join(at, s) 297 do IO>: 298 r : Result<&1, &1, U32 & String, Unit> <- mkdir(next) 299 mkdir_all.step(r, mkdir_all.go(t, next)) 300 301 # Creates the directory and any missing parents. Succeeds when it is 302 # already a directory; fails if a component is not a directory. 303 def mkdir_all(+path: String) -> IO(Result<&1, &1, U32 & String, Unit>): 304 mkdir_all.go( 305 List.filter(~String, ~(n => Bool.not(String.eq(n, ""))), 306 String.split(path, '/')), 307 Bool.pick(String, path.is_absolute(path), "/", "."))