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.
function open
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.
open(path, mode) : FilePath -> Read -> Result<FileHandle<CanRead, CannotWrite>, FileError>
open(path, mode) : FilePath -> Write -> Result<FileHandle<CannotRead, CanWrite>, FileError>
open(path, mode) : FilePath -> Append -> Result<FileHandle<CannotRead, CanWrite>, FileError>
open(path, mode) : FilePath -> ReadWrite -> Result<FileHandle<CanRead, CanWrite>, FileError>
open(path, mode) : FilePath -> Read -> (FileHandle<CanRead, CannotWrite> -> A) -> Result<A, FileError>
open(path, mode) : FilePath -> Write -> (FileHandle<CannotRead, CanWrite> -> A) -> Result<A, FileError>
open(path, mode) : FilePath -> Append -> (FileHandle<CannotRead, CanWrite> -> A) -> Result<A, FileError>
open(path, mode) : FilePath -> ReadWrite -> (FileHandle<CanRead, CanWrite> -> A) -> Result<A, FileError>
Parameters
Returns: Result<FileHandle, FileError> — 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"))
function read
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.
read(path) : FilePath -> Result<String, FileError>
Parameters
pathFilePath- the file to read
Returns: Result<String, FileError> — 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
function readBytes
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.
readBytes(path) : FilePath -> Result<Binary, FileError>
Parameters
pathFilePath- the file to read
Returns: Result<Binary, FileError> — 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 }
function writeBytes
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.
writeBytes(path, content) : FilePath -> Binary -> Bool
Parameters
pathFilePath- the file to write
contentBinary- the bytes to write
Returns: Bool — 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) }
function write
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.
write(path, content) : FilePath -> String -> Bool
Parameters
pathFilePath- the file to write
contentString- the text to write
Returns: Bool — 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")
function append
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.
append(path, content) : FilePath -> String -> Bool
Parameters
pathFilePath- the file to append to
contentString- the text to add
Returns: Bool — 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
function exists?
Returns true when something exists at path: a file, a directory, or anything else. Use file? or directory? when the kind matters.
exists?(path) : FilePath -> Bool
Parameters
pathFilePath- the path to test
Returns: Bool — 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
function file?
Returns true when path exists and is a regular file: not a directory.
file?(path) : FilePath -> Bool
Parameters
pathFilePath- the path to test
Returns: Bool — true for a regular file
Examples
FS.File.file?("README.md") # => true
FS.File.file?("src") # => false
function directory?
Returns true when path exists and is a directory.
directory?(path) : FilePath -> Bool
Parameters
pathFilePath- the path to test
Returns: Bool — 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) }
function delete
Deletes the file at path.
Answers false when the file does not exist or cannot be removed. Use FS.Directory.delete for a directory.
delete(path) : FilePath -> Bool
Parameters
pathFilePath- the file to delete
Returns: Bool — 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)
function copy
Copies the file at src to dst, replacing dst if it exists.
copy(src, dst) : FilePath -> FilePath -> Bool
Parameters
Returns: Bool — 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)
function rename
Renames (or moves) the file at src to dst.
rename(src, dst) : FilePath -> FilePath -> Bool
Parameters
Returns: Bool — 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)
function readLines
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.
readLines(path) : FilePath -> [String]?
Parameters
pathFilePath- the file to read
Returns: [String]? — 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?("#") }
function feed
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"]
feed(path) : FilePath -> Feed<String>?
Parameters
pathFilePath- the file to read
Returns: Feed<String>? — 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([])
function size
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.
size(path) : FilePath -> Integer?
Parameters
pathFilePath- the file to measure
Returns: Integer? — 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 }
function absolute
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.
absolute(path) : FilePath -> String?
Parameters
pathFilePath- the path to resolve
Returns: String? — 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"
constant separator String
The path separator, "/".
function join
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.
join(a, b) : FilePath -> FilePath -> String
join(a, b) : FilePath -> FilePath -> FilePath -> String
Parameters
Returns: String — 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))
function joinAll
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.
joinAll(parts) : [FilePath] -> String
Parameters
parts[FilePath]- the parts to join
Returns: String — 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))
function normalize
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.
normalize(path) : FilePath -> String
Parameters
pathFilePath- the path to normalise
Returns: String — 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)
function segments
Returns the non-empty parts of the normalised path. The root / and the current directory . have none.
segments(path) : FilePath -> [String]
Parameters
pathFilePath- the path to split
Returns: [String] — 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
function absolute?
Returns true when the path starts at the root.
absolute?(path) : FilePath -> Bool
Parameters
pathFilePath- the path to test
Returns: Bool — true for an absolute path
Examples
FS.Path.absolute?("/etc/hosts") # => true
FS.Path.absolute?("src/main") # => false
function relative?
Returns true when the path does not start at the root. The opposite of absolute?.
relative?(path) : FilePath -> Bool
Parameters
pathFilePath- the path to test
Returns: Bool — true for a relative path
Examples
FS.Path.relative?("src/main") # => true
FS.Path.relative?("/etc/hosts") # => false
function dirname
Returns the path's parent directory.
A child of the root has "/" as its parent; a bare name has ".", the current directory.
dirname(path) : FilePath -> String
Parameters
pathFilePath- the path to take the parent of
Returns: String — 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))
function basename
Returns the last segment of the path: the file or directory name.
The root itself answers "/", and an empty path answers ".".
basename(path) : FilePath -> String
Parameters
pathFilePath- the path to take the name of
Returns: String — 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) }
function extension
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.
extension(path) : FilePath -> String
Parameters
pathFilePath- the path to inspect
Returns: String — 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" }
function stem
Returns the basename with its extension removed.
A name that IS its extension (".gitignore") keeps it, because it has none to drop.
stem(path) : FilePath -> String
Parameters
pathFilePath- the path to inspect
Returns: String — 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
function withExtension
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.
withExtension(path, wanted) : FilePath -> String -> String
Parameters
pathFilePath- the path to rewrite
wantedString- the new extension, with or without its dot
Returns: String — 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")
function relativeTo
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 ".".
relativeTo(path, base) : FilePath -> FilePath -> String
Parameters
Returns: String — 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.
function exists?
Returns true when something exists at path.
exists?(path) : FilePath -> Bool
Parameters
pathFilePath- the path to test
Returns: Bool — true when the path exists
Examples
FS.Directory.exists?("src") # => true
function directory?
Returns true when path exists and is a directory.
directory?(path) : FilePath -> Bool
Parameters
pathFilePath- the path to test
Returns: Bool — true for a directory
Examples
FS.Directory.directory?("src") # => true
FS.Directory.directory?("README.md") # => false
function file?
Returns true when path exists and is a regular file.
file?(path) : FilePath -> Bool
Parameters
pathFilePath- the path to test
Returns: Bool — true for a regular file
Examples
FS.Directory.file?("README.md") # => true
function create
Creates the directory at path.
create(path) : FilePath -> Bool
Parameters
pathFilePath- the directory to create
Returns: Bool — 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)
function delete
Removes the directory at path, which must be empty.
Use deleteAll to remove a directory together with its contents.
delete(path) : FilePath -> Bool
Parameters
pathFilePath- the directory to remove
Returns: Bool — true when the directory was removed
Examples
FS.Directory.delete("empty") # => true
function deleteAll
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.
deleteAll(path) : FilePath -> Bool
Parameters
pathFilePath- the directory tree to remove
Returns: Bool — 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)
function list
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.
list(path) : FilePath -> [String]?
Parameters
pathFilePath- the directory to list
Returns: [String]? — 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) }
function files
Lists only the regular files in path, one level deep.
files(path) : FilePath -> [String]?
Parameters
pathFilePath- the directory to list
Returns: [String]? — 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" }
function directories
Lists only the subdirectories of path, one level deep.
directories(path) : FilePath -> [String]?
Parameters
pathFilePath- the directory to list
Returns: [String]? — 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
function current
Returns the process's current working directory, as an absolute path.
current() : String
Returns: String — the current directory
Examples
FS.Directory.current # => "/home/ada/project"
Resolving a relative path by hand
FS.Path.join(FS.Directory.current, "build")
function home
Returns the current user's home directory, or None when it cannot be determined.
home() : String?
Returns: String? — 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")
function temporary
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.
temporary() : String
Returns: String — 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)