script source

← 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.

ItemDefinedWorked exampleCall sites
walk fn22—31 41 46 51 52 60 … (8 total)
walk_sorted fn29—24 27 29 30 31 34 … (7 total)
copy_to fn50—73 75
glob fn60—82 83 84 85 86 91 … (7 total)
glob_match fn86—73 87 88 89 93
Word struct124—127 128 129 134 139 140 … (9 total)
set fn127—106 113 114 118 119 124 … (7 total)
has fn134—109 112
put fn139—110 111 112 127
minus fn144—120 121 122 124
words fn151—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");
}