kex docs Standard Library 0.4.0-alpha kex.run ↗

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

  • Read
  • Write
  • Append
  • ReadWrite

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

  • CanRead
  • CannotRead

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

  • CanWrite
  • CannotWrite

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
path FilePath
the file to open
mode FileModes
Read, Write, Append or ReadWrite

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
path FilePath
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
path FilePath
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
path FilePath
the file to write
content Binary
the bytes to write

Returns: Booltrue 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
path FilePath
the file to write
content String
the text to write

Returns: Booltrue 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
path FilePath
the file to append to
content String
the text to add

Returns: Booltrue 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
path FilePath
the path to test

Returns: Booltrue 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
path FilePath
the path to test

Returns: Booltrue 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
path FilePath
the path to test

Returns: Booltrue 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
path FilePath
the file to delete

Returns: Booltrue 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
src FilePath
the file to copy
dst FilePath
where to copy it

Returns: Booltrue 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
src FilePath
the current path
dst FilePath
the new path

Returns: Booltrue 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
path FilePath
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
path FilePath
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
path FilePath
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
path FilePath
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
a FilePath
the first part
b FilePath
the second part

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
path FilePath
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
path FilePath
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
path FilePath
the path to test

Returns: Booltrue 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
path FilePath
the path to test

Returns: Booltrue 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
path FilePath
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
path FilePath
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
path FilePath
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
path FilePath
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
path FilePath
the path to rewrite
wanted String
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
path FilePath
the path to re-express
base FilePath
the directory to express it against

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
path FilePath
the path to test

Returns: Booltrue 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
path FilePath
the path to test

Returns: Booltrue 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
path FilePath
the path to test

Returns: Booltrue for a regular file

Examples
FS.Directory.file?("README.md")   # => true

function create

Creates the directory at path.

create(path) : FilePath -> Bool
Parameters
path FilePath
the directory to create

Returns: Booltrue 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
path FilePath
the directory to remove

Returns: Booltrue 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
path FilePath
the directory tree to remove

Returns: Booltrue 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
path FilePath
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
path FilePath
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
path FilePath
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)