FS
module FS
The filesystem: reading and writing files, walking directories, and manipulating paths.
FS is not in the prelude — start with using FS.
using FS
main do
match FS.File.read("config.txt") do
Just(text) => IO.printLine(text.lines.count)
None => IO.printError("config.txt is missing")
end
end
It is organised in three parts:
FS.File reading, writing, copying and deleting files
FS.Directory creating, listing and removing directories
FS.Path pure path arithmetic, with no filesystem access at all
The first two are capabilities: everything in them touches the real filesystem, so they can only be called from foul code, and a test can replace the whole of FS.File for a lexical region rather than mutating global state. FS.Path is ordinary pure code.
Most read operations answer with an Optional and most write operations with a Bool, so a missing file or a failed write is an ordinary value you handle rather than an exception you catch.
type FilePath
A filesystem path. An alias for String, so every String method applies to one; FS.Path adds the path-aware operations.
Variants
String
type FileModes
How a file should be opened: Read to read it, Write to replace it, Append to add to its end, ReadWrite for both. The mode chosen decides what type the resulting FileHandle has, and therefore which operations the compiler will let you call on it.
Variants
ReadWriteAppendReadWrite
type ReadPermission
Whether a FileHandle may be read from. Part of the handle's type rather than a runtime flag, so reading from a write-only handle is a compile error.
Variants
CanReadCannotRead
type WritePermission
Whether a FileHandle may be written to. Part of the handle's type, so writing to a read-only handle is a compile error.
Variants
CanWriteCannotWrite
type FileError
A file operation that failed, carrying the path it failed on.
OpenFailed and ReadFailed mean the filesystem refused the operation. InvalidUtf8 means the bytes were read but are not valid UTF-8, and carries the byte offset of the first malformed sequence.
Variants
OpenFailed(FilePath)ReadFailed(FilePath)InvalidUtf8(FilePath, Integer)
module FS.File
Reading and writing files.
A capability: every member reaches the real filesystem, so a test can replace the whole thing for a lexical region with with FS.File = ... instead of mutating global mock state (kexhq/kex#143). The boundary is here rather than on FS, because FS.Path below is pure string work with no implementation to substitute.
open
open(path: FilePath, mode: Read) -> Result<FileHandle<CanRead, CannotWrite>, FileError>
open(path: FilePath, mode: Write) -> Result<FileHandle<CannotRead, CanWrite>, FileError>
open(path: FilePath, mode: Append) -> Result<FileHandle<CannotRead, CanWrite>, FileError>
open(path: FilePath, mode: ReadWrite) -> Result<FileHandle<CanRead, CanWrite>, FileError>
open(path: FilePath, mode: Read) -> (FileHandle<CanRead, CannotWrite> -> A) -> Result<A, FileError>
open(path: FilePath, mode: Write) -> (FileHandle<CannotRead, CanWrite> -> A) -> Result<A, FileError>
open(path: FilePath, mode: Append) -> (FileHandle<CannotRead, CanWrite> -> A) -> Result<A, FileError>
open(path: FilePath, mode: ReadWrite) -> (FileHandle<CanRead, CanWrite> -> A) -> Result<A, FileError>
Opens path and returns a FileHandle for it.
The mode decides the handle's type, and the type decides what you may do with it: a handle opened Read has no write, a handle opened Write has no read. That is checked at compile time, not at run time.
Use the whole-file read and write below when a file fits in memory and you have no reason to hold it open; reach for a handle when you want to stream through a large file or make many small writes.
Close the handle when you are done with it.
Parameters
path- the file to open
modeRead,Write,AppendorReadWrite
Returns the handle, or OpenFailed
Examples
Reading a file line by line
match FS.File.open("log.txt", Read) do
Ok(handle) => do
IO.printLine(handle.readLine.or(""))
handle.close
end
Error(e) => IO.printError("cannot open: ${e}")
end
Writing a report
match FS.File.open("report.txt", Write) do
Ok(handle) => do
rows.each { |row| handle.printLine(row) }
handle.close
end
Error(_) => IO.printError("cannot write report")
end
A failure names the path
FS.File.open("missing/x.txt", Read) # => Error(OpenFailed("missing/x.txt"))
read
read(path: FilePath) -> Result<String, FileError>
Reads the whole file and decodes it as UTF-8 text.
Answers Error(ReadFailed(path)) when the file does not exist or cannot be read, and Error(InvalidUtf8(path, offset)) when the bytes are not valid UTF-8 — so a missing or non-text file is something you handle rather than something that stops the program. Reach for FS.File.readBytes when the contents are not text.
Parameters
path- the file to read
Returns the contents, or the failure
Examples
FS.File.read("hello.txt") # => Ok("hello\n")
FS.File.read("nowhere.txt") # => Error(ReadFailed("nowhere.txt"))
Reading with a default
let config = FS.File.read("app.conf").or("")
Counting words in a file
FS.File.read("essay.txt").or("").split(" ").reject(~empty?).count
readBytes
readBytes(path: FilePath) -> Result<Binary, FileError>
Reads the whole file as raw bytes, without decoding it as text.
The byte counterpart of FS.File.read: it never validates UTF-8, so it round trips images, archives, and any other non-text payload losslessly. Answers Error(ReadFailed(path)) when the file cannot be read.
Parameters
path- the file to read
Returns the contents, or the failure
Examples
FS.File.readBytes("logo.png") # => Ok(#Binary<2048 bytes>)
Hashing a file
FS.File.readBytes(path).map { |data| Digest.sha256(data).hex }
writeBytes
writeBytes(path: FilePath, content: Binary) -> Bool
Writes content to path as raw bytes, replacing whatever was there.
The byte counterpart of FS.File.write: the payload lands on disk exactly as given, with no encoding step. Answers false when the write fails.
Parameters
path- the file to write
content- the bytes to write
Returns true when the write succeeded
Examples
FS.File.writeBytes("out.bin", Binary.fromBytes([0, 255])) # => true
Copying a file without decoding it
FS.File.readBytes(source).map { |data| FS.File.writeBytes(target, data) }
write
write(path: FilePath, content: String) -> Bool
Writes content to path, replacing whatever was there.
Creates the file if it does not exist. The containing directory must already exist — see FS.Directory.create. Answers false when the write fails.
Parameters
path- the file to write
content- the text to write
Returns true when the write succeeded
Examples
FS.File.write("out.txt", "hello\n") # => true
Reporting a failed write
if !FS.File.write(target, rendered)
IO.printError("could not write ${target}")
end
Writing a list of lines
FS.File.write("names.txt", names.join("\n") + "\n")
append
append(path: FilePath, content: String) -> Bool
Adds content to the end of path, keeping what is already there.
Creates the file if it does not exist, so it is safe to append to a log that has not been started yet.
Parameters
path- the file to append to
content- the text to add
Returns true when the write succeeded
Examples
FS.File.append("app.log", "started\n") # => true
A simple logger
foul log(message: String) -> Void do
FS.File.append("app.log", "${Time.now.to(String).or("")} ${message}\n")
end
exists?
exists?(path: FilePath) -> Bool
Returns true when something exists at path — a file, a directory, or anything else. Use file? or directory? when the kind matters.
Parameters
path- the path to test
Returns true when the path exists
Examples
FS.File.exists?("README.md") # => true
FS.File.exists?("nowhere") # => false
Not overwriting an existing file
if FS.File.exists?(target)
IO.printError("${target} already exists")
else
FS.File.write(target, content)
end
file?
file?(path: FilePath) -> Bool
Returns true when path exists and is a regular file — not a directory.
Parameters
path- the path to test
Returns true for a regular file
Examples
FS.File.file?("README.md") # => true
FS.File.file?("src") # => false
directory?
directory?(path: FilePath) -> Bool
Returns true when path exists and is a directory.
Parameters
path- the path to test
Returns true for a directory
Examples
FS.File.directory?("src") # => true
FS.File.directory?("README.md") # => false
Separating files from directories in a listing
entries.partition { |e| FS.File.directory?(e) }
delete
delete(path: FilePath) -> Bool
Deletes the file at path.
Answers false when the file does not exist or cannot be removed. Use FS.Directory.delete for a directory.
Parameters
path- the file to delete
Returns true when the file was deleted
Examples
FS.File.delete("tmp.txt") # => true
Cleaning up after a temporary file
FS.File.write(tmp, data)
process(tmp)
FS.File.delete(tmp)
copy
copy(src: FilePath, dst: FilePath) -> Bool
Copies the file at src to dst, replacing dst if it exists.
Parameters
src- the file to copy
dst- where to copy it
Returns true when the copy succeeded
Examples
FS.File.copy("app.conf", "app.conf.bak") # => true
Backing a file up before rewriting it
FS.File.copy(path, "${path}.bak")
FS.File.write(path, updated)
rename
rename(src: FilePath, dst: FilePath) -> Bool
Renames — or moves — the file at src to dst.
Parameters
src- the current path
dst- the new path
Returns true when the rename succeeded
Examples
FS.File.rename("draft.txt", "final.txt") # => true
Writing to a temporary file, then swapping it in
FS.File.write("${path}.tmp", content)
FS.File.rename("${path}.tmp", path)
readLines
readLines(path: FilePath) -> [String]?
Reads the file and returns its lines, without their newlines.
Answers None when the file cannot be read. A trailing newline does not produce a final empty line, so the count is the number of lines you would see in an editor.
NOT lines: FilePath is an alias for String, so a receiver function named lines here is indistinguishable from String's own lines at every call site, and merely saying using FS made text.lines ambiguous. readLines also says what it does — it reads the file.
Parameters
path- the file to read
Returns the lines, or None
Examples
FS.File.readLines("names.txt") # => Just(["ada", "grace"])
FS.File.readLines("nowhere") # => None
Ignoring blank lines and comments
FS.File.readLines("hosts")
.or([])
.map(~trim)
.reject { |line| line.empty? || line.startsWith?("#") }
feed
feed(path: FilePath) -> Feed<String>?
Returns a lazy Feed of the file's lines, or None when it cannot be read.
Unlike readLines, the file is read a line at a time off a handle held open for the feed's lifetime, so this is the way to walk a file too large to hold in memory — nothing but the current line is retained.
A Feed rather than a Stream because that is what a file honestly is: reading consumes, and there is no rewinding. Taking twice walks forward rather than answering the same lines again. On a file small enough to replay, toStream buys that back.
The feed ends at the last line, so asking for more lines than the file has answers just the lines there are — unlike Stream.Sequence, which is deliberately infinite.
FS.File.feed("two-lines.txt").map { |lines| lines.take(5) }.or([])
# => ["one", "two"]
Parameters
path- the file to read
Returns the lines as a feed, or None
Examples
Peeking at the head of a large file
match FS.File.feed("huge.log") do
Just(lines) => lines.take(10).each { |line| IO.printLine(line) }
None => IO.printError("cannot read huge.log")
end
The first ten errors in a long log, in one pass
FS.File.feed("app.log")
.map { |lines| lines.filter { |line| line.contains?("ERROR") }.take(10) }
.or([])
size
size(path: FilePath) -> Integer?
Returns the file's size in bytes, or None when it cannot be read.
Bytes, not characters — a file of non-ASCII text has more bytes than it has characters.
Parameters
path- the file to measure
Returns the size in bytes, or None
Examples
FS.File.size("hello.txt") # => Just(6)
FS.File.size("nowhere") # => None
Skipping files that are too large
paths.filter { |p| FS.File.size(p).or(0) < 1048576 }
absolute
absolute(path: FilePath) -> String?
Resolves path against the process's current directory and returns the absolute form, or None when it cannot be resolved.
This is the one path operation that is not in FS.Path, because it is not lexical: it asks the process where it is.
Path manipulation lives in FS.Path — basename, dirname, extension and join used to be here too, but a name cannot sit in both modules: FilePath IS String, so two same-named receiver functions on it are indistinguishable at every call site. absolute stays because it is not lexical — it asks the process where it is.
Parameters
path- the path to resolve
Returns the absolute path, or None
Examples
FS.File.absolute("src/main.kex") # => Just("/home/ada/proj/src/main.kex")
Reporting a file unambiguously
IO.printError("failed: ${FS.File.absolute(path).or(path)}")
module FS.Path
Path arithmetic: joining, splitting, normalising and comparing paths.
Everything here is pure string manipulation with no filesystem access, so the answer is the same whether or not the path exists — which also means these functions can be called from ordinary pure code, unlike FS.File. POSIX separators only for now.
FS.Path.join("src", "main.kex") # => "src/main.kex"
FS.Path.extension("src/main.kex") # => ".kex"
FS.Path.withExtension("src/main.kex", "beam") # => "src/main.beam"
join
join(a: FilePath, b: FilePath) -> String
join(a: FilePath, b: FilePath) -> FilePath -> String
Joins two path parts with a single separator and normalises the result.
Repeated separators collapse, so join("a/", "/b") is "a/b" and not "a//b". An absolute second part does NOT restart the path, the way Ruby's Pathname#join would — use that part on its own if that is what you mean.
Parameters
a- the first part
b- the second part
Returns the joined, normalised path
Examples
FS.Path.join("src", "main.kex") # => "src/main.kex"
FS.Path.join("a/", "/b") # => "a/b"
FS.Path.join("a/b", "../c") # => "a/c"
Building a path under a base directory
FS.Path.join(outputDir, FS.Path.basename(source))
joinAll
joinAll(parts: [FilePath]) -> String
Joins any number of path parts, skipping empty ones, and normalises the result. An empty list gives ".".
The list form is joinAll, not another join overload: a list receiver already has List.join, and a second one-argument join on the same receiver would be indistinguishable from it.
Parameters
parts- the parts to join
Returns the joined, normalised path
Examples
FS.Path.joinAll(["a", "b", "c.txt"]) # => "a/b/c.txt"
FS.Path.joinAll(["a", "", "b"]) # => "a/b"
FS.Path.joinAll([]) # => "."
Rebuilding a path from its segments
FS.Path.joinAll(FS.Path.segments(path).drop(1))
normalize
normalize(path: FilePath) -> String
Resolves . and .. in a path, lexically.
Because it is lexical it never follows a symlink and never touches the disk. A leading .. in a RELATIVE path is kept — there is no way to know what it escapes to — while one in an absolute path is dropped, since / has no parent.
Parameters
path- the path to normalise
Returns the normalised path
Examples
FS.Path.normalize("a/./b/../c") # => "a/c"
FS.Path.normalize("../a") # => "../a"
FS.Path.normalize("/a/../../b") # => "/b"
Comparing two paths for equality
FS.Path.normalize(a) == FS.Path.normalize(b)
segments
segments(path: FilePath) -> [String]
Returns the non-empty parts of the normalised path. The root / and the current directory . have none.
Parameters
path- the path to split
Returns the segments, outermost first
Examples
FS.Path.segments("/a/b/c") # => ["a", "b", "c"]
FS.Path.segments("a/./b") # => ["a", "b"]
FS.Path.segments("/") # => []
How deep a path is
FS.Path.segments(path).count
absolute?
absolute?(path: FilePath) -> Bool
Returns true when the path starts at the root.
Parameters
path- the path to test
Returns true for an absolute path
Examples
FS.Path.absolute?("/etc/hosts") # => true
FS.Path.absolute?("src/main") # => false
relative?
relative?(path: FilePath) -> Bool
Returns true when the path does not start at the root. The opposite of absolute?.
Parameters
path- the path to test
Returns true for a relative path
Examples
FS.Path.relative?("src/main") # => true
FS.Path.relative?("/etc/hosts") # => false
dirname
dirname(path: FilePath) -> String
Returns the path's parent directory.
A child of the root has "/" as its parent; a bare name has ".", the current directory.
Parameters
path- the path to take the parent of
Returns the parent directory
Examples
FS.Path.dirname("/a/b/c.txt") # => "/a/b"
FS.Path.dirname("/c.txt") # => "/"
FS.Path.dirname("c.txt") # => "."
Making sure a file's directory exists before writing
FS.Directory.create(FS.Path.dirname(target))
basename
basename(path: FilePath) -> String
Returns the last segment of the path — the file or directory name.
The root itself answers "/", and an empty path answers ".".
Parameters
path- the path to take the name of
Returns the last segment
Examples
FS.Path.basename("/a/b/c.txt") # => "c.txt"
FS.Path.basename("/a/b") # => "b"
FS.Path.basename("/") # => "/"
Listing names rather than paths
paths.map { |p| FS.Path.basename(p) }
extension
extension(path: FilePath) -> String
Returns the file extension, including its leading dot, or "" when there is none.
Only the last extension counts, so "a.tar.gz" has ".gz". A leading dot is part of the NAME rather than an extension, so ".gitignore" has none.
Parameters
path- the path to inspect
Returns the extension, or ""
Examples
FS.Path.extension("src/main.kex") # => ".kex"
FS.Path.extension("a/b.tar.gz") # => ".gz"
FS.Path.extension("README") # => ""
FS.Path.extension(".gitignore") # => ""
Selecting sources by extension
files.filter { |f| FS.Path.extension(f) == ".kex" }
stem
stem(path: FilePath) -> String
Returns the basename with its extension removed.
A name that IS its extension — ".gitignore" — keeps it, because it has none to drop.
Parameters
path- the path to inspect
Returns the name without its extension
Examples
FS.Path.stem("src/main.kex") # => "main"
FS.Path.stem("a/b.tar.gz") # => "b.tar"
FS.Path.stem(".gitignore") # => ".gitignore"
Deriving a module name from a filename
FS.Path.stem(source).capitalize
withExtension
withExtension(path: FilePath, wanted: String) -> String
Returns the path with its extension replaced by wanted.
The new extension may be written with or without its leading dot; an empty one removes the extension entirely.
Parameters
path- the path to rewrite
wanted- the new extension, with or without its dot
Returns the rewritten path
Examples
FS.Path.withExtension("src/main.kex", "beam") # => "src/main.beam"
FS.Path.withExtension("src/main.kex", ".beam") # => "src/main.beam"
FS.Path.withExtension("src/main.kex", "") # => "src/main"
Deriving an output path from a source path
FS.Path.withExtension(FS.Path.join(outDir, FS.Path.basename(src)), "o")
relativeTo
relativeTo(path: FilePath, base: FilePath) -> String
Expresses path relative to base, walking up with .. as needed.
Purely lexical, so it answers only when both sides are anchored the same way; a relative path against an absolute base, or the reverse, comes back unchanged. A path that IS the base answers ".".
Parameters
path- the path to re-express
base- the directory to express it against
Returns the relative path
Examples
FS.Path.relativeTo("/a/b/c.txt", "/a/d") # => "../b/c.txt"
FS.Path.relativeTo("/a/b/c.txt", "/a") # => "b/c.txt"
FS.Path.relativeTo("/a/b", "/a/b") # => "."
Printing project-relative paths in a report
matches.each { |p| IO.printLine(FS.Path.relativeTo(p, projectRoot)) }
module FS.Directory
Creating, listing and removing directories.
Like FS.File, everything here reaches the real filesystem and so is foul. Listings answer with an Optional, so a directory that cannot be read is a value you handle rather than an exception.
exists?
exists?(path: FilePath) -> Bool
Returns true when something exists at path.
Parameters
path- the path to test
Returns true when the path exists
Examples
FS.Directory.exists?("src") # => true
directory?
directory?(path: FilePath) -> Bool
Returns true when path exists and is a directory.
Parameters
path- the path to test
Returns true for a directory
Examples
FS.Directory.directory?("src") # => true
FS.Directory.directory?("README.md") # => false
file?
file?(path: FilePath) -> Bool
Returns true when path exists and is a regular file.
Parameters
path- the path to test
Returns true for a regular file
Examples
FS.Directory.file?("README.md") # => true
create
create(path: FilePath) -> Bool
Creates the directory at path.
Parameters
path- the directory to create
Returns true when the directory was created
Examples
FS.Directory.create("build") # => true
Making sure an output directory is there
FS.Directory.create(outDir) if !FS.Directory.exists?(outDir)
delete
delete(path: FilePath) -> Bool
Removes the directory at path, which must be empty.
Use deleteAll to remove a directory together with its contents.
Parameters
path- the directory to remove
Returns true when the directory was removed
Examples
FS.Directory.delete("empty") # => true
deleteAll
deleteAll(path: FilePath) -> Bool
Removes the directory at path and everything inside it, recursively.
This deletes data and cannot be undone — check the path before calling it, particularly when it was computed or came from user input.
Parameters
path- the directory tree to remove
Returns true when the tree was removed
Examples
FS.Directory.deleteAll("build") # => true
Rebuilding a scratch directory from clean
FS.Directory.deleteAll(tmpDir)
FS.Directory.create(tmpDir)
list
list(path: FilePath) -> [String]?
Lists the names in path — both files and directories, one level deep.
The results are bare names, not paths; join them with path to get something you can open. Answers None when the directory cannot be read.
Parameters
path- the directory to list
Returns the entry names, or None
Examples
FS.Directory.list("src") # => Just(["main.kex", "lexer"])
Turning names into usable paths
FS.Directory.list(dir).or([]).map { |name| FS.Path.join(dir, name) }
files
files(path: FilePath) -> [String]?
Lists only the regular files in path, one level deep.
Parameters
path- the directory to list
Returns the file names, or None
Examples
FS.Directory.files("src") # => Just(["main.kex"])
Every Kex source in a directory
FS.Directory.files(dir)
.or([])
.filter { |f| FS.Path.extension(f) == ".kex" }
directories
directories(path: FilePath) -> [String]?
Lists only the subdirectories of path, one level deep.
Parameters
path- the directory to list
Returns the directory names, or None
Examples
FS.Directory.directories("src") # => Just(["lexer", "parser"])
Walking one level down
FS.Directory.directories(root).or([]).each do |name|
IO.printLine(FS.Path.join(root, name))
end
current
current : String
Returns the process's current working directory, as an absolute path.
Returns the current directory
Examples
FS.Directory.current # => "/home/ada/project"
Resolving a relative path by hand
FS.Path.join(FS.Directory.current, "build")
home
home : String?
Returns the current user's home directory, or None when it cannot be determined.
Returns the home directory, or None
Examples
FS.Directory.home # => Just("/home/ada")
A per-user config path
FS.Path.join(FS.Directory.home.or("."), ".apprc")
temporary
temporary : String
Returns the directory this system puts temporary files in.
Total, unlike home: it answers TMPDIR when the environment sets one (TEMP or TMP on Windows) and falls back to /tmp, so there is always somewhere to write. The path never ends in a separator, so it composes with FS.Path.join directly.
The directory is shared with every other process on the machine, so pick a name unlikely to collide and delete it when you are done.
Returns the temporary directory
Examples
FS.Directory.temporary # => "/tmp"
A scratch file that cleans up after itself
let scratch = FS.Path.join(FS.Directory.temporary, "report.bin")
FS.File.writeBytes(scratch, payload)
FS.File.delete(scratch)