← script · API reference · version 0.4.0
2 file(s), 284 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
walk fn | 22 | — | 31 41 46 51 52 60 … (8 total) |
walk_sorted fn | 29 | — | 24 27 29 30 31 34 … (7 total) |
copy_to fn | 50 | — | 73 75 |
glob fn | 60 | — | 82 83 84 85 86 91 … (7 total) |
glob_match fn | 86 | — | 73 87 88 89 93 |
Word struct | 124 | — | 127 128 129 134 139 140 … (9 total) |
set fn | 127 | — | 106 113 114 118 119 124 … (7 total) |
has fn | 134 | — | 109 112 |
put fn | 139 | — | 110 111 112 127 |
minus fn | 144 | — | 120 121 122 124 |
words fn | 151 | — | 127 129 108 112 128 |
src/script.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// What a script reaches for and the standard library does not carry, because each is loft
// over primitives the runtime already answers (`files`, `is_symlink`, `read_bytes`,
// `write_bytes`): the recursive walk every port wrote by hand, the copy that was two calls
// and a null discharge, the glob that was a listing and a filter. Each hangs off `text` so
// it fires as a trigger: a script writes `dir.walk(".loft")` and names no library.
/// The files under the directory `self`, in every subdirectory, one at a time as a `for`
/// loop asks for them: `for p in "src".walk(".rs") { … }`. Nothing is listed before the loop
/// reaches it and nothing is sorted: each directory gives its files in name order, then its
/// subdirectories are walked the same way, depth first. `suffix` keeps only the names that
/// end with it (`".loft"`); `""` keeps every file. A path that is not a directory gives no
/// files. Use [`walk_sorted`] when the order matters.
///
/// A symbolic link to a DIRECTORY is never entered, so a loop in the tree is not a loop
/// here. A link to a FILE is given like any file, as `find` and Python's `os.walk` do;
/// `links: false` leaves it out, at the cost of one more check per file given. A library
/// placed out of process should hand its callers `walk_sorted`, the whole answer in one
/// value, rather than one crossing per file.
pub fn walk(self: text, suffix: text = "", links: boolean = true) -> iterator<text> {
if is_dir(self) { yield from walk_from(self, suffix, links); }
}
/// The files [`walk`] gives, as one vector in path order — the same files, at the cost of
/// listing them all before the first is used, and of the sort. `walk_sorted(suffix,
/// links: false)` is the walk of 0.2 and before, which never gave a linked file.
pub fn walk_sorted(self: text, suffix: text = "", links: boolean = true) -> vector<text> {
found: vector<text> = [];
for p in self.walk(suffix, links: links) { found += [p]; }
sort(found);
found
}
fn walk_from(dir: text, suffix: text, links: boolean) -> iterator<text> {
below: vector<text> = [];
for f in file(dir).files() {
if f.format == Format.Directory {
if !is_symlink(f.path) { below += [f.path]; }
} else if f.format != Format.NotExists && (suffix == "" || f.path.ends_with(suffix)) {
if links || !is_symlink(f.path) { yield f.path; }
}
}
for d in below { yield from walk_from(d, suffix, links); }
}
/// Copy the file at `self` to `dst`, bytes as they are: true when `dst` now holds them. A
/// source that cannot be read writes nothing and answers false, as does a write that fails.
pub fn copy_to(self: text, dst: text) -> boolean {
bytes = read_bytes(self);
if bytes == null { return false; }
write_bytes(dst, bytes ?? [])
}
/// The paths that match the pattern `self`, in name order: `*` stands for any run of
/// characters inside one name, `?` for one character, and everything before the last `/` is
/// a directory taken as written (`"default/*.loft"`, `"out/??.txt"`). A pattern without a
/// `/` is matched against the current directory. An absent directory answers no paths.
pub fn glob(self: text) -> vector<text> {
dir = ".";
pattern = self;
cut = -1;
for c in self { if c == '/' { cut = c#index; } }
if cut >= 0 {
dir = self[0..cut];
pattern = self[cut + 1..];
if dir == "" { dir = "/"; }
}
found: vector<text> = [];
if !is_dir(dir) { return found; }
for name in list_dir(dir) ?? [] {
if glob_match(pattern, name) {
if dir == "." && cut < 0 { found += [name]; }
else if dir == "/" { found += ["/{name}"]; }
else { found += ["{dir}/{name}"]; }
}
}
sort(found);
found
}
/// Does `name` match `pattern`, with `*` any run and `?` one character? A plain character
/// matches itself only. A name that starts with `.` matches only a pattern that starts with
/// `.`, as a shell's and Python's glob have it: `*.loft` is not `.loft`, a hidden entry.
pub fn glob_match(pattern: text, name: text) -> boolean {
if name.starts_with(".") && !pattern.starts_with(".") { return false; }
pc: vector<character> = [];
for c in pattern { pc += [c]; }
nc: vector<character> = [];
for c in name { nc += [c]; }
glob_at(pc, 0, nc, 0)
}
fn glob_at(p: vector<character>, pi: integer, n: vector<character>, ni: integer) -> boolean {
if pi == len(p) { return ni == len(n); }
c = p[pi];
if c == '*' {
// The star takes zero or more characters: try every length until one matches.
k = ni;
while k <= len(n) {
if glob_at(p, pi + 1, n, k) { return true; }
k += 1;
}
return false;
}
if ni == len(n) { return false; }
if c == '?' || c == n[ni] { return glob_at(p, pi + 1, n, ni + 1); }
false
}
// --- A set of texts ---
//
// A set is a keyed collection over a one-field record, and that is what these are: a
// `sorted<Word[key]>` holds each text once, in key order, with the lookup, the removal and
// the prefix range every keyed collection has. What a script could not write in one line
// was the building and the algebra: `set(words)` BUILDS one (the library's builder, bare
// once builder triggers load a library, `script::set(words)` always), and the rest are
// methods on the set — `seen.put(w)`, `seen.has(w)`, `a.minus(b)`, `seen.words()` — so a
// script names no type. The element is `Word`, for a field that declares the set
// (`names: sorted<Word[key]>`), read as `x.key` in a walk.
/// One element of a set of texts: the text is the key.
pub struct Word { pub key: text }
/// A set of the texts in `words`: each once, in key order. `set([])` is the empty set.
pub fn set(words: vector<text>) -> sorted<Word[key]> {
out: sorted<Word[key]> = [];
for w in words { out += [Word { key: w }]; }
out
}
/// Is `w` in the set?
pub fn has(self: sorted<Word[key]>, w: text) -> boolean {
self[w] != null
}
/// Put `w` in the set; a text already there stays once.
pub fn put(self: sorted<Word[key]>, w: text) {
self += [Word { key: w }];
}
/// The texts of `self` that are not in `other`, in key order.
pub fn minus(self: sorted<Word[key]>, other: sorted<Word[key]>) -> vector<text> {
out: vector<text> = [];
for x in self { if other[x.key] == null { out += [x.key]; } }
out
}
/// The texts of the set as a vector, in key order.
pub fn words(self: sorted<Word[key]>) -> vector<text> {
out: vector<text> = [];
for x in self { out += [x.key]; }
out
}
tests/script.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The three helpers on a tree built here: a walk that takes every file below a directory
// and never enters a linked one, a copy that moves bytes as they are, a glob over one directory.
// Every expected value by hand.
use script::*;
fn root() -> text { "{temp_dir()}/loft-script-lib-{now()}" }
fn build(r: text) {
mkdir_all("{r}/a/b");
mkdir_all("{r}/c");
file("{r}/one.loft").write("fn main() {{}}\n");
file("{r}/a/two.loft").write("// two\n");
file("{r}/a/b/three.txt").write("three");
file("{r}/a/b/four.loft").write("");
file("{r}/c/five.md").write("# five\n");
}
fn test_walk_sorted_takes_every_file_below_in_path_order() {
r = root();
build(r);
all = r.walk_sorted();
assert(len(all) == 5, "five files: {all}");
assert(all[0] == "{r}/a/b/four.loft" && all[4] == "{r}/one.loft", "path order: {all}");
lofts = r.walk_sorted(".loft");
assert(lofts == ["{r}/a/b/four.loft", "{r}/a/two.loft", "{r}/one.loft"], "by suffix: {lofts}");
assert(len("{r}/c".walk_sorted()) == 1, "a subdirectory alone");
assert(len("{r}/one.loft".walk_sorted()) == 0, "a file is not a directory");
assert(len("{r}/absent".walk_sorted()) == 0, "an absent directory");
// No link in this tree, so leaving the linked files out changes nothing; a link needs a
// tool loft lacks, and is probed by hand.
assert(r.walk_sorted(".loft", links: false) == lofts, "links: false on a tree without one");
}
fn test_walk_gives_each_file_once_directory_by_directory() {
r = root();
build(r);
got: vector<text> = [];
for p in r.walk() { got += [p]; }
// The root's own file first, then `a` (its file, then `a/b` by name), then `c`.
want = ["{r}/one.loft", "{r}/a/two.loft", "{r}/a/b/four.loft", "{r}/a/b/three.txt", "{r}/c/five.md"];
assert(got == want, "directory by directory, names in order: {got}");
lofts: vector<text> = [];
for p in r.walk(".loft") { lofts += [p]; }
assert(lofts == ["{r}/one.loft", "{r}/a/two.loft", "{r}/a/b/four.loft"], "by suffix: {lofts}");
sort(lofts);
assert(lofts == r.walk_sorted(".loft"), "the same files as walk_sorted");
n = 0;
for _p in "{r}/one.loft".walk() { n += 1; }
for _p in "{r}/absent".walk() { n += 1; }
assert(n == 0, "a file and an absent path give no files: {n}");
}
fn test_walk_stops_where_the_loop_stops() {
r = root();
build(r);
first = "";
for p in r.walk() { first = p; break; }
assert(first == "{r}/one.loft", "the first file: {first}");
w = "{r}/a".walk();
for _p in w { break; }
rest: vector<text> = [];
for p in w { rest += [p]; }
// A walk is a generator: a second loop over it goes on where the first stopped.
assert(rest == ["{r}/a/b/four.loft", "{r}/a/b/three.txt"], "the rest of the walk: {rest}");
}
fn test_copy_to_moves_the_bytes_as_they_are() {
r = root();
build(r);
assert("{r}/c/five.md".copy_to("{r}/five-copy.md"), "copied");
assert(file("{r}/five-copy.md").content() == "# five\n", "same bytes");
assert(!"{r}/absent.md".copy_to("{r}/never.md"), "no source, no copy");
assert(!is_file("{r}/never.md"), "nothing written for an absent source");
}
fn test_glob_matches_one_directory() {
r = root();
build(r);
assert("{r}/a/*.loft".glob() == ["{r}/a/two.loft"], "star with a suffix");
assert("{r}/a/b/*".glob() == ["{r}/a/b/four.loft", "{r}/a/b/three.txt"], "star alone, name order");
assert("{r}/a/b/f???.loft".glob() == ["{r}/a/b/four.loft"], "one ? per character");
assert(len("{r}/a/b/*.md".glob()) == 0, "no match");
assert(len("{r}/nowhere/*".glob()) == 0, "absent directory");
assert(glob_match("*.loft", "x.loft") && !glob_match("*.loft", "x.loft.bak"), "the star ends where the pattern does");
assert(glob_match("a*b*c", "abc") && glob_match("a*b*c", "axxbyyc") && !glob_match("a*b*c", "ac"), "several stars");
assert(glob_match("", "") && !glob_match("", "a") && !glob_match("?", ""), "the empty cases");
file("{r}/a/.hidden.loft").write("");
assert("{r}/a/*.loft".glob() == ["{r}/a/two.loft"], "a star does not reach a hidden name");
assert("{r}/a/.*".glob() == ["{r}/a/.hidden.loft"], "a pattern that starts with a dot does");
assert(!glob_match("*", ".loft") && !glob_match("?loft", ".loft") && glob_match(".l*", ".loft"), "the dot rule");
}
fn main() {
test_walk_sorted_takes_every_file_below_in_path_order();
test_walk_gives_each_file_once_directory_by_directory();
test_walk_stops_where_the_loop_stops();
test_copy_to_moves_the_bytes_as_they_are();
test_glob_matches_one_directory();
println("ok");
}
fn test_a_set_holds_each_text_once_in_key_order() {
seen = set(["c", "a", "b", "a"]);
assert(len(seen) == 3, "three distinct texts, got {len(seen)}");
assert(seen.words() == ["a", "b", "c"], "key order: {seen.words()}");
assert(seen.has("a") && !seen.has("z"), "membership");
seen.put("z");
seen.put("a");
assert(len(seen) == 4 && seen.has("z"), "put adds once: {seen.words()}");
assert(len(set([])) == 0, "set([]) is the empty set");
assert(len(script::set(["q"])) == 1, "the qualified spelling always reaches the builder");
}
fn test_minus_is_the_texts_not_in_the_other() {
a = set(["x", "y", "z"]);
b = set(["y"]);
assert(a.minus(b) == ["x", "z"], "a minus b: {a.minus(b)}");
assert(len(b.minus(a)) == 0, "b minus a is empty");
assert(a.minus(a).len() == 0, "a minus itself");
// the symmetric difference of two sets whose sizes differ by one is one text or none
d = a.minus(set(["x", "y"]));
assert(d == ["z"], "one apart: {d}");
decl: sorted<Word[key]> = [];
decl.put("q");
assert(decl.words() == ["q"], "a set declared by its element type");
}