Fs / MemoryOverlay.flix
MemoryOverlay.flix
/*
* Copyright 2026 Flix Authors
*
* Use of this source code is governed by the Apache 2.0 license
* that can be found in the LICENSE.md file.
*/
/// Type of in-memory overlays that record changes on top of a lower file system layer, and its functions.
mod Fs.MemoryOverlay {
import java.nio.file.FileSystems
import java.nio.file.PathMatcher
import java.nio.file.Paths
use IoError.IoError
use IoError.ErrorKind
/// In-memory overlay state, parameterized by region.
///
/// - `files`: map from normalized absolute paths to in-memory content (text or bytes).
/// - `dirs`: set of normalized absolute paths registered as directories.
/// - `whiteouts`: set of normalized absolute paths deleted in the overlay (hides lower-layer entries).
/// - `counter`: monotonically increasing counter for generating unique temp directory names.
struct MemoryOverlay[r: Region] {
mut files: Map[String, OverlayEntry],
mut dirs: Set[String],
mut whiteouts: Set[String],
mut counter: Int32
}
/// Type of overlay file contents, either text or bytes.
pub enum OverlayEntry with ToString {
case BinaryEntry(Vector[Int8])
case TextEntry(String)
}
/// Creates a fresh, empty overlay in region `rc`.
pub def fresh(rc: Region[r]): Fs.MemoryOverlay[r] \ r =
new Fs.MemoryOverlay @ rc {files = Map.empty(), dirs = Set.empty(), whiteouts = Set.empty(), counter = 0}
// ─── Path helpers (private) ───────────────────────────────────────
def normalize(path: String): String =
unsafe IO { Paths.get(path).toAbsolutePath().normalize().toString() }
def parentDir(path: String): Option[String] =
unsafe IO {
let p = Paths.get(path).getParent();
if (Object.isNull(p)) None else Some(p.toString())
}
def fileName(path: String): String =
unsafe IO {
let p = Paths.get(path).getFileName();
if (Object.isNull(p)) path else p.toString()
}
def notFound(path: String): Result[IoError, a] =
Err(IoError(ErrorKind.NotFound, "overlay: '${path}' does not exist"))
// ─── Low-level state queries (private) ────────────────────────────
def hasFile(path: String, o: Fs.MemoryOverlay[r]): Bool \ r =
Map.memberOf(normalize(path), o->files)
def hasDir(path: String, o: Fs.MemoryOverlay[r]): Bool \ r =
Set.memberOf(normalize(path), o->dirs)
def hasEntry(path: String, o: Fs.MemoryOverlay[r]): Bool \ r =
hasFile(path, o) or hasDir(path, o)
def isWhiteout(path: String, o: Fs.MemoryOverlay[r]): Bool \ r =
Set.memberOf(normalize(path), o->whiteouts)
// ─── Handler-level methods — reads return Option[Result] ──────────
/// Overlay-aware exists check.
pub def tryExists(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Bool]] \ r =
if (hasEntry(path, o))
Some(Ok(true))
else if (isWhiteout(path, o))
Some(Ok(false))
else
None
/// Overlay-aware isDirectory check.
pub def tryIsDirectory(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Bool]] \ r =
if (hasDir(path, o))
Some(Ok(true))
else if (hasFile(path, o))
Some(Ok(false))
else if (isWhiteout(path, o))
Some(Ok(false))
else
None
/// Overlay-aware isRegularFile check.
pub def tryIsRegularFile(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Bool]] \ r =
if (hasFile(path, o))
Some(Ok(true))
else if (hasDir(path, o))
Some(Ok(false))
else if (isWhiteout(path, o))
Some(Ok(false))
else
None
/// Overlay-aware isSymbolicLink check. In-memory entries are never symlinks.
pub def tryIsSymbolicLink(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Bool]] \ r =
if (hasEntry(path, o))
Some(Ok(false))
else if (isWhiteout(path, o))
Some(Ok(false))
else
None
/// Overlay-aware isReadable. In-memory entries are always readable.
pub def tryIsReadable(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Bool]] \ r =
if (hasEntry(path, o))
Some(Ok(true))
else if (isWhiteout(path, o))
Some(notFound(path))
else
None
/// Overlay-aware isWritable. In-memory entries are always writable.
pub def tryIsWritable(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Bool]] \ r =
if (hasEntry(path, o))
Some(Ok(true))
else if (isWhiteout(path, o))
Some(notFound(path))
else
None
/// Overlay-aware isExecutable. In-memory entries are never executable.
pub def tryIsExecutable(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Bool]] \ r =
if (hasEntry(path, o))
Some(Ok(false))
else if (isWhiteout(path, o))
Some(notFound(path))
else
None
/// Overlay-aware time query. In-memory entries return 0.
pub def tryTime(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Int64]] \ r =
if (hasEntry(path, o))
Some(Ok(0i64))
else if (isWhiteout(path, o))
Some(notFound(path))
else
None
/// Overlay-aware size query. Computes from content length.
pub def trySize(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Fs.Size]] \ r =
use Fs.Size.bytes;
let norm = normalize(path);
match Map.get(norm, o->files) {
case Some(OverlayEntry.TextEntry(s)) => Some(Ok(Fs.FsLayer.estimateStringBytes(s)))
case Some(OverlayEntry.BinaryEntry(b)) => Some(Ok(bytes(Int32.toInt64(Vector.length(b)))))
case None =>
if (isWhiteout(path, o))
Some(notFound(path))
else
None
}
/// Overlay-aware read (text).
pub def tryRead(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, String]] \ r =
let norm = normalize(path);
match Map.get(norm, o->files) {
case Some(OverlayEntry.TextEntry(s)) => Some(Ok(s))
case Some(OverlayEntry.BinaryEntry(b)) => Some(Ok(String.fromBytes(b)))
case None =>
if (isWhiteout(path, o))
Some(notFound(path))
else
None
}
/// Overlay-aware readLines.
pub def tryReadLines(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, List[String]]] \ r =
match tryRead(path, o) {
case Some(Ok(s)) => Some(Ok(String.lines(s)))
case Some(Err(e)) => Some(Err(e))
case None => None
}
/// Overlay-aware readBytes.
pub def tryReadBytes(path: String, o: Fs.MemoryOverlay[r]): Option[Result[IoError, Vector[Int8]]] \ r =
let norm = normalize(path);
match Map.get(norm, o->files) {
case Some(OverlayEntry.BinaryEntry(b)) => Some(Ok(b))
case Some(OverlayEntry.TextEntry(s)) => Some(Ok(String.toBytes(s)))
case None =>
if (isWhiteout(path, o))
Some(notFound(path))
else
None
}
// ─── Handler-level methods — writes return Result directly ────────
/// Stores text content.
pub def write(path: String, data: String, o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let norm = normalize(path);
o->files = Map.insert(norm, OverlayEntry.TextEntry(data), o->files);
o->whiteouts = Set.remove(norm, o->whiteouts);
Ok(())
/// Stores lines as text content.
pub def writeLines(path: String, lines: List[String], o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
write(path, String.intercalate(String.lineSeparator(), lines), o)
/// Stores byte content.
pub def writeBytes(path: String, data: Vector[Int8], o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let norm = normalize(path);
o->files = Map.insert(norm, OverlayEntry.BinaryEntry(data), o->files);
o->whiteouts = Set.remove(norm, o->whiteouts);
Ok(())
/// Appends text. `lower` is the current content from the lower layer (if not in overlay/whiteout).
pub def append(path: String, data: String, lower: Option[String], o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let norm = normalize(path);
let current = match Map.get(norm, o->files) {
case Some(OverlayEntry.TextEntry(s)) => s
case Some(OverlayEntry.BinaryEntry(b)) => String.fromBytes(b)
case None => Option.getWithDefault("", lower)
};
write(path, current + data, o)
/// Appends lines. `lower` is the current content from the lower layer.
pub def appendLines(path: String, lines: List[String], lower: Option[String], o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let suffix = String.intercalate(String.lineSeparator(), lines);
append(path, suffix, lower, o)
/// Appends bytes. `lower` is the current content from the lower layer.
pub def appendBytes(path: String, data: Vector[Int8], lower: Option[Vector[Int8]], o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let norm = normalize(path);
let current = match Map.get(norm, o->files) {
case Some(OverlayEntry.BinaryEntry(b)) => b
case Some(OverlayEntry.TextEntry(s)) => String.toBytes(s)
case None => Option.getWithDefault(Vector.empty(), lower)
};
writeBytes(path, Vector.append(current, data), o)
/// Truncates a file (stores empty content).
pub def truncate(path: String, o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
write(path, "", o)
/// Moves an entry. `lower` is the source bytes from the lower layer if not in overlay.
pub def move(src: String, dst: String, lower: Option[Vector[Int8]], o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let normSrc = normalize(src);
let entry = match Map.get(normSrc, o->files) {
case Some(e) => Some(e)
case None => Option.map(b -> OverlayEntry.BinaryEntry(b), lower)
};
match entry {
case Some(e) =>
let normDst = normalize(dst);
o->files = Map.insert(normDst, e, o->files);
o->whiteouts = Set.remove(normDst, o->whiteouts);
o->files = Map.remove(normSrc, o->files);
o->dirs = Set.remove(normSrc, o->dirs);
o->whiteouts = Set.insert(normSrc, o->whiteouts);
Ok(())
case None => notFound(src)
}
/// Copies an entry. `lower` is the source bytes from the lower layer if not in overlay.
pub def copy(src: String, dst: String, lower: Option[Vector[Int8]], o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let normSrc = normalize(src);
let entry = match Map.get(normSrc, o->files) {
case Some(e) => Some(e)
case None => Option.map(b -> OverlayEntry.BinaryEntry(b), lower)
};
match entry {
case Some(e) =>
let normDst = normalize(dst);
o->files = Map.insert(normDst, e, o->files);
o->whiteouts = Set.remove(normDst, o->whiteouts);
Ok(())
case None => notFound(src)
}
/// Deletes an entry (adds whiteout).
pub def delete(path: String, o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let norm = normalize(path);
o->files = Map.remove(norm, o->files);
o->dirs = Set.remove(norm, o->dirs);
o->whiteouts = Set.insert(norm, o->whiteouts);
Ok(())
/// Creates a directory.
pub def mkDir(path: String, o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let norm = normalize(path);
o->dirs = Set.insert(norm, o->dirs);
o->whiteouts = Set.remove(norm, o->whiteouts);
Ok(())
/// Creates a directory and all parents.
pub def mkDirs(path: String, o: Fs.MemoryOverlay[r]): Result[IoError, Unit] \ r =
let norm = normalize(path);
o->dirs = Set.insert(norm, o->dirs);
o->whiteouts = Set.remove(norm, o->whiteouts);
match parentDir(norm) {
case Some(parent) =>
if (not Set.memberOf(parent, o->dirs)) mkDirs(parent, o) else Ok(())
case None => Ok(())
}
/// Generates a unique temp directory and registers it.
pub def mkTempDir(prefix: String, o: Fs.MemoryOverlay[r]): Result[IoError, String] \ r =
let n = o->counter;
o->counter = n + 1;
let name = "${prefix}overlay-${n}";
o->dirs = Set.insert(name, o->dirs);
Ok(name)
/// Merges overlay listing with lower-layer listing.
/// `lower` is the result of listing from the real filesystem.
pub def list(dir: String, lower: Result[IoError, List[String]], o: Fs.MemoryOverlay[r]): Result[IoError, List[String]] \ r =
let norm = normalize(dir);
if (isWhiteout(dir, o))
notFound(dir)
else {
let whiteoutNames = o->whiteouts
|> Set.filter(p -> parentDir(p) == Some(norm))
|> Set.map(p -> fileName(p));
let overlayFileNames = Map.keysOf(o->files)
|> Set.filter(p -> parentDir(p) == Some(norm))
|> Set.map(p -> fileName(p));
let overlayDirNames = o->dirs
|> Set.filter(p -> parentDir(p) == Some(norm))
|> Set.map(p -> fileName(p));
let overlayNames = Set.union(overlayFileNames, overlayDirNames);
let lowerNames = match lower {
case Ok(names) => List.filter(n -> not Set.memberOf(n, whiteoutNames), names)
case Err(_) => Nil
};
let merged = List.append(Set.toList(overlayNames), lowerNames) |> List.distinct;
Ok(merged)
}
/// Merges overlay entries with lower-layer glob results.
/// `lower` is the result of globbing from the real filesystem.
pub def glob(base: String, pattern: String, lower: Result[IoError, List[String]], o: Fs.MemoryOverlay[r]): Result[IoError, List[String]] \ r =
if (isWhiteout(base, o))
notFound(base)
else
unsafe IO {
let basePath = Paths.get(normalize(base));
let matcher: PathMatcher = FileSystems.getDefault().getPathMatcher("glob:${pattern}");
let matchesPattern = path -> {
let p = Paths.get(path);
let starts = p.startsWith(basePath);
if (starts) {
let rel = basePath.relativize(p);
matcher.matches(rel)
} else
false
};
let overlayPaths = Set.union(
Map.keysOf(o->files) |> Set.filter(matchesPattern),
o->dirs |> Set.filter(matchesPattern)
);
let whiteouts = o->whiteouts;
let lowerPaths = match lower {
case Ok(paths) => List.filter(p -> not Set.memberOf(normalize(p), whiteouts), paths)
case Err(_) => Nil
};
let merged = List.append(Set.toList(overlayPaths), lowerPaths) |> List.distinct;
Ok(merged)
}
// ─── Helper for handler ───────────────────────────────────────────
/// Returns true if the path needs lower-layer content (not in overlay, not whiteout'd).
pub def needsLower(path: String, o: Fs.MemoryOverlay[r]): Bool \ r =
let norm = normalize(path);
not Map.memberOf(norm, o->files) and not Set.memberOf(norm, o->whiteouts)
}