kex docs Standard Library

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.

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
mode
Read, Write, Append or ReadWrite

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"

separator constant

separator : String

The path separator, "/".

Examples
FS.Path.separator   # => "/"

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)