llm
Download
1466 lines 53.5 KiBCModified 2026-09-29 raw
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466
// A lang for filesystem paths

#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <fnmatch.h>
#include <ftw.h>
#include <gc.h>
#include <glob.h>
#include <grp.h>
#include <limits.h>
#include <pwd.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/param.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#include "../unistr-fixed.h"
#include "../util.h"
#include "c_string.h"
#include "datatypes/typeinfo.h"
#include "integers.h"
#include "list.h"
#include "path.h"
#include "print.h"
#include "result.h"
#include "text.h"
#include "util.h"

static const Path_t HOME_PATH = (Path_t){"~"}, ROOT_PATH = (Path_t){"/"}, CURDIR_PATH = (Path_t){"."},
                    PARENT_PATH = (Path_t){".."};

typedef enum { PATH_ABSOLUTE, PATH_RELATIVE, PATH_HOME } pathtype_t;

static pathtype_t path_type(Path_t path) {
    if (!path) return PATH_ABSOLUTE;
    if (path[0] == '/') return PATH_ABSOLUTE;
    if (path[0] == '~' && (path[1] == '\0' || path[1] == '/')) return PATH_HOME;
    return PATH_RELATIVE;
}

static void normalize_inplace(char path[PATH_MAX]) {
    if (path[0] == '.' && path[1 + strspn(path + 1, "/")] == '\0') {
        path[1] = '\0';
        return;
    }

    static char buf[PATH_MAX];
    char *src = path, *dest = buf;

    // Leading "/"
    if (*src == '/') {
        *(dest++) = *(src++);
    }
    *dest = '\0';

    for (size_t component_len; *src != '\0' && dest < &buf[PATH_MAX - 1]; src += component_len + 1) {
        component_len = strcspn(src, "/");
        if (component_len == 0) {
            ; // Skip empty "//"s:
        } else if (component_len == 1 && src[0] == '.' && dest > buf) {
            ; // Skip "." components
        } else {
            // Add "/" if there's a previous non-slash
            if (dest > buf && dest[-1] != '/') {
                *(dest++) = '/';
                *dest = '\0';
            }
            // For ".."
            if (component_len == 2 && src[0] == '.' && src[1] == '.' && buf[0] == '/' && dest == buf + 1) {
                ; // Nothing above the root: "/.." is "/", as Path$parent() agrees
            } else if (component_len == 2 && src[0] == '.' && src[1] == '.') {
                // Find previous component:
                char *prev_slash = dest - 2;
                while (prev_slash >= buf && *prev_slash != '/')
                    prev_slash -= 1;

                // If previous component is not "..", then pop it (keeping the
                // root "/" when the popped component was the last one):
                if (prev_slash >= buf && *prev_slash == '/'
                    && strncmp(prev_slash, "/../", (size_t)(dest - prev_slash)) != 0) {
                    dest = (prev_slash == buf) ? buf + 1 : prev_slash;
                } else {
                    // Otherwise we need to keep the ".."
                    *(dest++) = '.';
                    *(dest++) = '.';
                }
            } else {
                // Otherwise copy over the component
                memcpy(dest, src, component_len);
                dest += component_len;
            }
            *dest = '\0';
        }
        if (src[component_len] == '\0') break;
    }

    *dest = '\0';

    if (dest == buf) {
        path[0] = '.';
        path[1] = '\0';
    } else {
        memcpy(path, buf, strlen(buf) + 1);
    }
}

char *path_from_buf(char buf[PATH_MAX]) {
    normalize_inplace(buf);
    char *ret = GC_MALLOC_ATOMIC(strlen(buf) + 1);
    memcpy(ret, buf, strlen(buf) + 1);
    return ret;
}

// normalize_inplace() wants a PATH_MAX buffer, so an over-long path fails here
// rather than being quietly truncated into a path naming some other file.
public
Path_t normalized_path(const char *str) {
    size_t len = strlen(str);
    if (len >= PATH_MAX) fail("Path is too long: ", str);
    static char buf[PATH_MAX];
    memcpy(buf, str, len + 1);
    return path_from_buf(buf);
}

public
PUREFUNC
Path_t Path$from_str(const char *str) {
    if (!str || str[0] == '\0' || streq(str, "/")) return ROOT_PATH;
    else if (streq(str, "~") || streq(str, "~/")) return HOME_PATH;
    else if (streq(str, ".") || streq(str, "./")) return CURDIR_PATH;
    return str;
}

public
Path_t Path$from_text(Text_t text) {
    // Normalized, unlike Path$from_str(): this is a path being built out of
    // arbitrary text, where a literal would have been normalized at compile
    // time and every other constructor normalizes as it goes. Path$from_str()
    // itself must not, being the way results from readdir(2) and glob(3) --
    // already normal, and not to be second-guessed -- become paths.
    return normalized_path(Text$as_c_string(text));
}

static OptionalPath_t Path$_concat2(OptionalPath_t a, OptionalPath_t b) {
    if (a == NULL || b == NULL) return NULL;
    if (path_type(b) != PATH_RELATIVE)
        fail("Cannot concatenate an absolute or home-based path onto another path: (", b, ")");

    if (b[0] == '.' && b[1] == '\0') return a;
    return normalized_path(String(a, "/", b));
}

// $HOME is not guaranteed to be set. Returning NULL here would reach opendir()
// and friends by way of expand_home(), so fall back to the password database
// and fail loudly rather than crash if even that has no answer.
static const char *home_directory(void) {
    const char *home = getenv("HOME");
    if (home && home[0] != '\0') return home;
    struct passwd *pw = getpwuid(getuid());
    if (pw && pw->pw_dir && pw->pw_dir[0] != '\0') return pw->pw_dir;
    fail("Could not determine the home directory: $HOME is unset and the user has no home");
}

public
Path_t Path$expand_home(Path_t path) {
    if (path && path_type(path) == PATH_HOME) {
        const char *home = home_directory();
        if (path[1] == '/') return Path$_concat2(home, path + 2);
        else if (path[1] == '\0') return home;
    }
    return path;
}

// The inverse of expand_home(), for methods that must hand libc a real path but
// should still hand the caller back a path in the form they wrote it.
static Path_t unexpand_home(Path_t path) {
    if (!path) return path;
    const char *home = home_directory();
    size_t home_len = strlen(home);
    while (home_len > 1 && home[home_len - 1] == '/')
        --home_len;
    if (strncmp(path, home, home_len) != 0) return path;
    if (path[home_len] == '\0') return HOME_PATH;
    if (path[home_len] != '/') return path;
    return Path$from_str(String("~", path + home_len));
}

public
OptionalPath_t Path$_concat(int n, Path_t items[n]) {
    assert(n > 0);
    OptionalPath_t result = items[0];
    for (int i = 1; i < n; i++) {
        result = Path$_concat2(result, items[i]);
    }
    return result;
}

public
Path_t Path$resolved(Path_t path, Path_t relative_to) {
    if (!path) return path;
    switch (path_type(path)) {
    case PATH_HOME: return Path$expand_home(path);
    case PATH_ABSOLUTE: return path;
    case PATH_RELATIVE:
        // Path$relative_to() already does this to its own base:
        if (path_type(relative_to) != PATH_ABSOLUTE) relative_to = Path$resolved(relative_to, Path$current_dir());
        return Path$_concat2(relative_to, path);
    default: return path;
    }
}

public
Path_t Path$relative_to(Path_t path, Path_t relative_to) {
    switch (path_type(relative_to)) {
    case PATH_HOME: relative_to = Path$expand_home(relative_to); break;
    case PATH_RELATIVE: relative_to = Path$resolved(relative_to, Path$current_dir()); break;
    default: break;
    }

    path = Path$resolved(path, Path$current_dir());

    int64_t shared = 0;
    for (int64_t i = 0;; i++) {
        if ((path[i] == '/' || path[i] == '\0') && (relative_to[i] == '/' || relative_to[i] == '\0')) {
            shared = i;
        }
        if (path[i] != relative_to[i] || !path[i]) break;
    }

    Path_t path_remainder = path[shared] == '\0' ? "" : &path[shared + 1];
    Path_t relative_remainder = relative_to[shared] == '\0' ? "" : &relative_to[shared + 1];
    if (strlen(path_remainder) > 0 && strlen(relative_remainder) == 0) {
        // "/foo/baz/qux" relative to "/foo/baz" => "qux"
        return path_remainder;
    }

    static char buf[PATH_MAX];
    char *dest = buf;
    for (const char *p = relative_remainder; *p; p += strcspn(p, "/"), p += strspn(p, "/")) {
        *(dest++) = '.';
        *(dest++) = '.';
        *(dest++) = '/';
    }
    memcpy(dest, path_remainder, strlen(path_remainder));
    dest += strlen(path_remainder);
    *dest = '\0';
    return path_from_buf(buf);
}

public
bool Path$exists(Path_t path) {
    path = Path$expand_home(path);
    struct stat sb;
    return (stat(path, &sb) == 0);
}

static INLINE int path_stat(Path_t path, bool follow_symlinks, struct stat *sb) {
    path = Path$expand_home(path);
    return follow_symlinks ? stat(path, sb) : lstat(path, sb);
}

public
bool Path$is_file(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return false;
    return (sb.st_mode & S_IFMT) == S_IFREG;
}

public
bool Path$is_directory(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return false;
    return (sb.st_mode & S_IFMT) == S_IFDIR;
}

public
bool Path$is_pipe(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return false;
    return (sb.st_mode & S_IFMT) == S_IFIFO;
}

public
bool Path$is_socket(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return false;
    return (sb.st_mode & S_IFMT) == S_IFSOCK;
}

public
bool Path$is_symlink(Path_t path) {
    struct stat sb;
    int status = path_stat(path, false, &sb);
    if (status != 0) return false;
    return (sb.st_mode & S_IFMT) == S_IFLNK;
}

public
OptionalPath_t Path$link(Path_t path) {
    static char buf[PATH_MAX];
    ssize_t status = readlink(Path$expand_home(path), buf, sizeof(buf));
    if (status == -1) return NONE_PATH;
    return Path$from_str(GC_strdup(buf));
}

// AT_EACCESS makes these check against the process's *effective* user/group id
// (what would actually govern an attempted access), not the real one.

public
bool Path$can_read(Path_t path) {
    path = Path$expand_home(path);
    return (faccessat(AT_FDCWD, path, R_OK, AT_EACCESS) == 0);
}

public
bool Path$can_write(Path_t path) {
    path = Path$expand_home(path);
    return (faccessat(AT_FDCWD, path, W_OK, AT_EACCESS) == 0);
}

public
bool Path$can_execute(Path_t path) {
    path = Path$expand_home(path);
    return (faccessat(AT_FDCWD, path, X_OK, AT_EACCESS) == 0);
}

public
OptionalInt64_t Path$modified(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return NONE_INT64;
    return (OptionalInt64_t){.value = (int64_t)sb.st_mtime};
}

public
OptionalInt64_t Path$accessed(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return NONE_INT64;
    return (OptionalInt64_t){.value = (int64_t)sb.st_atime};
}

public
OptionalInt64_t Path$changed(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return NONE_INT64;
    return (OptionalInt64_t){.value = (int64_t)sb.st_ctime};
}

static Result_t _write(Path_t path, List_t bytes, int mode, int permissions) {
    path = Path$expand_home(path);
    int fd = open(path, mode, permissions);
    if (fd == -1) {
        if (errno == EMFILE || errno == ENFILE) {
            // If we hit file handle limits, run GC collection to try to clean up any lingering file handles that
            // will be closed by GC finalizers.
            GC_gcollect();
            fd = open(path, mode, permissions);
        }
        if (fd == -1) return FailureResult("Could not write to file: ", path, " (", strerror(errno), ")");
    }

    if (bytes.stride != 1) List$compact(&bytes, 1);
    ssize_t written = write(fd, bytes.data, (size_t)bytes.length);
    if (written != (ssize_t)bytes.length)
        return FailureResult("Could not write to file: ", path, " (", strerror(errno), ")");
    close(fd);
    return SuccessResult;
}

public
Result_t Path$write(Path_t path, Text_t text, int permissions) {
    List_t bytes = Text$utf8(text);
    return _write(path, bytes, O_WRONLY | O_CREAT | O_TRUNC, permissions);
}

public
Result_t Path$write_bytes(Path_t path, List_t bytes, int permissions) {
    return _write(path, bytes, O_WRONLY | O_CREAT | O_TRUNC, permissions);
}

public
Result_t Path$append(Path_t path, Text_t text, int permissions) {
    List_t bytes = Text$utf8(text);
    return _write(path, bytes, O_WRONLY | O_APPEND | O_CREAT, permissions);
}

public
Result_t Path$append_bytes(Path_t path, List_t bytes, int permissions) {
    return _write(path, bytes, O_WRONLY | O_APPEND | O_CREAT, permissions);
}

typedef struct {
    const char *path;
    int fd;
    int mode;
    int permissions;
} writer_data_t;

static Result_t _write_bytes_to_fd(List_t bytes, bool close_file, void *userdata) {
    writer_data_t *data = userdata;
    if (bytes.length > 0) {
        if (data->fd == -1) {
            data->fd = open(data->path, data->mode, data->permissions);
            if (data->fd == -1) {
                if (errno == EMFILE || errno == ENFILE) {
                    // If we hit file handle limits, run GC collection to try to clean up any lingering file handles
                    // that will be closed by GC finalizers.
                    GC_gcollect();
                    data->fd = open(data->path, data->mode, data->permissions);
                }
                if (data->fd == -1)
                    return FailureResult("Could not write to file: ", data->path, " (", strerror(errno), ")");
            }
        }

        if (bytes.stride != 1) List$compact(&bytes, 1);
        ssize_t written = write(data->fd, bytes.data, (size_t)bytes.length);
        if (written != (ssize_t)bytes.length)
            return FailureResult("Could not write to file: ", data->path, " (", strerror(errno), ")");
    }
    // After first successful write, all writes are appends
    data->mode = (O_WRONLY | O_CREAT | O_APPEND);

    if (close_file && data->fd != -1) {
        if (close(data->fd) == -1)
            return FailureResult("Failed to close file: ", data->path, " (", strerror(errno), ")");
        data->fd = -1;
    }
    return SuccessResult;
}

static Result_t _write_text_to_fd(Text_t text, bool close_file, void *userdata) {
    return _write_bytes_to_fd(Text$utf8(text), close_file, userdata);
}

static void _writer_cleanup(writer_data_t *data) {
    if (data && data->fd != -1) {
        close(data->fd);
        data->fd = -1;
    }
}

public
Closure_t Path$byte_writer(Path_t path, bool append, int permissions) {
    path = Path$expand_home(path);
    int mode = append ? (O_WRONLY | O_CREAT | O_APPEND) : (O_WRONLY | O_CREAT | O_TRUNC);
    writer_data_t *userdata = new (writer_data_t, .fd = -1, .path = path, .mode = mode, .permissions = permissions);
    GC_register_finalizer(userdata, (void *)_writer_cleanup, NULL, NULL, NULL);
    return (Closure_t){.fn = _write_bytes_to_fd, .userdata = userdata};
}

public
Closure_t Path$writer(Path_t path, bool append, int permissions) {
    path = Path$expand_home(path);
    int mode = append ? (O_WRONLY | O_CREAT | O_APPEND) : (O_WRONLY | O_CREAT | O_TRUNC);
    writer_data_t *userdata = new (writer_data_t, .fd = -1, .path = path, .mode = mode, .permissions = permissions);
    GC_register_finalizer(userdata, (void *)_writer_cleanup, NULL, NULL, NULL);
    return (Closure_t){.fn = _write_text_to_fd, .userdata = userdata};
}

public
OptionalList_t Path$read_bytes(Path_t path, OptionalInt_t count) {
    path = Path$expand_home(path);
    int fd = open(path, O_RDONLY);
    if (fd == -1) {
        if (errno == EMFILE || errno == ENFILE) {
            // If we hit file handle limits, run GC collection to try to clean up any lingering file handles that
            // will be closed by GC finalizers.
            GC_gcollect();
            fd = open(path, O_RDONLY);
        }
    }

    if (fd == -1) return NONE_LIST;

    struct stat sb;
    if (fstat(fd, &sb) != 0) return NONE_LIST;

    int64_t const target_count = count.small ? Int64$from_int(count, false) : INT64_MAX;
    if (target_count < 0) fail("Cannot read a negative number of bytes!");

    if ((sb.st_mode & S_IFMT) == S_IFREG) { // Use memory mapping if it's a real file:
        const char *mem = mmap(NULL, (size_t)sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
        char *content = GC_MALLOC_ATOMIC((size_t)sb.st_size + 1);
        memcpy(content, mem, (size_t)sb.st_size);
        content[sb.st_size] = '\0';
        close(fd);
        if (count.small && (int64_t)sb.st_size < target_count) return NONE_LIST;
        int64_t len = count.small ? target_count : (int64_t)sb.st_size;
        return (List_t){.data = content, .atomic = 1, .stride = 1, .length = (uint64_t)len};
    } else {
        size_t capacity = 256, len = 0;
        char *content = GC_MALLOC_ATOMIC(capacity);
        int64_t count_remaining = target_count;
        for (;;) {
            char chunk[256];
            size_t to_read = count_remaining < (int64_t)sizeof(chunk) ? (size_t)count_remaining : sizeof(chunk);
            ssize_t just_read = read(fd, chunk, to_read);
            if (just_read < 0) {
                close(fd);
                return NONE_LIST;
            } else if (just_read == 0) {
                if (errno == EAGAIN || errno == EINTR) continue;
                break;
            }
            count_remaining -= (int64_t)just_read;

            if (len + (size_t)just_read >= capacity) {
                content = GC_REALLOC(content, (capacity *= 2));
            }

            memcpy(&content[len], chunk, (size_t)just_read);
            len += (size_t)just_read;
        }
        close(fd);
        if (count.small != 0 && (int64_t)len < target_count) return NONE_LIST;
        return (List_t){.data = content, .atomic = 1, .stride = 1, .length = (uint64_t)len};
    }
}

public
OptionalText_t Path$read(Path_t path) {
    List_t bytes = Path$read_bytes(path, NONE_INT);
    if (bytes.data == NULL) return NONE_TEXT;
    return Text$from_utf8(bytes);
}

public
OptionalText_t Path$owner(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return NONE_TEXT;
    struct passwd *pw = getpwuid(sb.st_uid);
    return pw ? Text$from_str(pw->pw_name) : NONE_TEXT;
}

public
OptionalText_t Path$group(Path_t path, bool follow_symlinks) {
    struct stat sb;
    int status = path_stat(path, follow_symlinks, &sb);
    if (status != 0) return NONE_TEXT;
    struct group *gr = getgrgid(sb.st_uid);
    return gr ? Text$from_str(gr->gr_name) : NONE_TEXT;
}

public
Result_t Path$set_owner(Path_t path, OptionalText_t owner, OptionalText_t group, bool follow_symlinks) {
    // (uid_t)-1 tells chown(2) to leave that half alone, which is what a none
    // owner or group means here.
    uid_t owner_id = (uid_t)-1;
    if (owner.tag != TEXT_NONE) {
        struct passwd *pwd = getpwnam(Text$as_c_string(owner));
        if (pwd == NULL) return FailureResult("Not a valid user: ", owner);
        owner_id = pwd->pw_uid;
    }

    gid_t group_id = (gid_t)-1;
    if (group.tag != TEXT_NONE) {
        struct group *grp = getgrnam(Text$as_c_string(group));
        if (grp == NULL) return FailureResult("Not a valid group: ", group);
        group_id = grp->gr_gid;
    }
    path = Path$expand_home(path);
    int result = follow_symlinks ? chown(path, owner_id, group_id) : lchown(path, owner_id, group_id);
    if (result < 0) return FailureResult("Could not set owner!");
    return SuccessResult;
}

// nftw(3) hands its callback no user data, so a failure has to be left
// somewhere for Path$remove() to collect. _Thread_local rather than a plain
// static: the walk is not reentrant either way, but this keeps two of them from
// overwriting each other's reason. Returning non-zero stops the walk, so at
// most one failure is ever recorded -- the first one.
static _Thread_local Result_t _remove_failure = Result$Success;

static int _remove_files(const char *path, const struct stat *sbuf, int type, struct FTW *ftwb) {
    (void)sbuf, (void)ftwb;
    switch (type) {
    case FTW_F:
    case FTW_SL:
    case FTW_SLN:
        if (remove(path) < 0) {
            _remove_failure = FailureResult("Could not remove file: ", path, " (", strerror(errno), ")");
            return -1;
        }
        return 0;
    case FTW_DP:
        if (rmdir(path) != 0) {
            _remove_failure = FailureResult("Could not remove directory: ", path, " (", strerror(errno), ")");
            return -1;
        }
        return 0;
    default:
        // FTW_DNR (a directory that can't be read) and FTW_NS (a stat that
        // failed) land here. Reporting them the same way as the cases above
        // keeps a tree Path$remove() can't fully walk a Failure the caller
        // sees, rather than an abort that takes the whole program down.
        _remove_failure = FailureResult("Could not remove path: ", path, " (it could not be read)");
        return -1;
    }
}

public
Result_t Path$remove(Path_t path, bool ignore_missing) {
    path = Path$expand_home(path);

    // Attempt the removal and let the kernel report what is there, rather than
    // asking with lstat() first: between the question and the answer the entry
    // can be replaced, and unlink(2) does not follow symlinks, so a link that
    // appears in that window is removed rather than followed.
    if (unlink(path) == 0) return SuccessResult;

    // ENOTDIR means a component of the path isn't a directory, so nothing can
    // be at that path either:
    if (errno == ENOENT || errno == ENOTDIR) {
        if (ignore_missing) return SuccessResult;
        return FailureResult("Could not remove file: ", path, " (", strerror(errno), ")");
    }

    // Linux reports EISDIR for a directory, the BSDs and macOS report EPERM:
    if (errno == EISDIR || errno == EPERM) {
        const int num_open_fd = 10;
        _remove_failure = SuccessResult;
        if (nftw(path, _remove_files, num_open_fd, FTW_DEPTH | FTW_MOUNT | FTW_PHYS) == 0) return SuccessResult;
        // A file the walk could not remove, reported by the callback:
        if (_remove_failure.$tag == Result$tag$Failure) return _remove_failure;
        if (errno == ENOENT && ignore_missing) return SuccessResult;
        return FailureResult("Could not remove directory: ", path, " (", strerror(errno), ")");
    }

    // `ignore_missing` is about a path that isn't there, which is settled
    // above. Everything left (EACCES, EROFS, EBUSY, ...) is a file that is
    // still there and has to be reported, or the caller is told the removal
    // succeeded while the file remains.
    return FailureResult("Could not remove file: ", path, " (", strerror(errno), ")");
}

#ifndef RENAME_NOREPLACE
#define RENAME_NOREPLACE (1 << 0)
#endif

// rename(2) replaces an existing destination without complaint. Checking with
// lstat() first only narrows the window -- another process can create the
// destination in between -- so the refusal has to happen inside one operation
// the kernel performs indivisibly.
static int rename_without_replacing(const char *src, const char *dest) {
#if defined(__linux__) && defined(SYS_renameat2)
    int status = (int)syscall(SYS_renameat2, AT_FDCWD, src, AT_FDCWD, dest, RENAME_NOREPLACE);
    // ENOSYS is a kernel too old for it; EINVAL is a filesystem that does not
    // implement the flag. Anything else is the real answer, EEXIST included.
    if (status == 0 || (errno != ENOSYS && errno != EINVAL)) return status;
#elif defined(__APPLE__)
    int status = renameatx_np(AT_FDCWD, src, AT_FDCWD, dest, RENAME_EXCL);
    if (status == 0 || errno != ENOTSUP) return status;
#endif
    // link(2) fails with EEXIST rather than replacing anything, so it gives the
    // same guarantee for everything it can handle:
    if (link(src, dest) == 0) {
        if (unlink(src) != 0) {
            // The move didn't happen, so don't leave the extra link behind as
            // a second name for the same file:
            int unlink_errno = errno;
            unlink(dest);
            errno = unlink_errno;
            return -1;
        }
        return 0;
    }
    // It cannot handle directories (EPERM) or a move across filesystems
    // (EXDEV). Those fall back to looking first, which races; every other
    // errno, EEXIST included, is the answer.
    if (errno != EPERM && errno != EXDEV) return -1;

    struct stat sb;
    if (lstat(dest, &sb) == 0) {
        errno = EEXIST;
        return -1;
    }
    return rename(src, dest);
}

public
Result_t Path$move(Path_t src, Path_t dest, bool allow_overwriting) {
    src = Path$expand_home(src);
    dest = Path$expand_home(dest);
    int status = allow_overwriting ? rename(src, dest) : rename_without_replacing(src, dest);
    if (status != 0) {
        // Replacing a non-empty directory needs it gone first; rename(2) will
        // not do that for us:
        if (allow_overwriting && (errno == EEXIST || errno == ENOTEMPTY || errno == EISDIR)) {
            Result_t result = Path$remove(dest, true);
            if (result.Failure.reason.tag != TEXT_NONE) return result;
            if (rename(src, dest) == 0) return SuccessResult;
        } else if (errno == EEXIST || errno == ENOTEMPTY) {
            return FailureResult("Could not move file ", src, " to ", dest, " (the destination already exists)");
        }
        return FailureResult("Could not move file ", src, " to ", dest, " (", strerror(errno), ")");
    }
    return SuccessResult;
}

Result_t Path$copy_to(Path_t src, Path_t dest, bool allow_overwriting) {
    // There is no shell in between to expand a "~" for us:
    src = Path$expand_home(src);
    dest = Path$expand_home(dest);

    // Copying a tree is many operations, so unlike Path$move() it cannot be one
    // indivisible act. What protects the destination is "cp -n" below, which
    // declines to clobber; this look is only so the caller is told that it
    // declined, since cp exits 0 either way. If the destination appears after
    // this and before the copy, "cp -n" still refuses and the caller is told
    // Success -- an inaccurate report, never a lost file.
    struct stat sb;
    if (!allow_overwriting && lstat(dest, &sb) == 0)
        return FailureResult("Could not copy ", src, " to ", dest, " (the destination already exists)");

    pid_t child = fork();
    if (child == 0) {
        // "-r" alone still overwrites: "-f" only forces past a destination
        // that cannot be opened. "-n" is the flag that declines to clobber.
        const char *args[] = {"cp", allow_overwriting ? "-rf" : "-rn", "-T", src, dest, NULL};
        execvp("cp", (char **)args);
        exit(0);
    }
    int status;
    while (waitpid(child, &status, 0) < 0 && errno == EINTR) {
        if (WIFEXITED(status) || WIFSIGNALED(status)) break;
        else if (WIFSTOPPED(status)) kill(child, SIGCONT);
    }

    if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
        return FailureResult("Failed to copy ", src, " to ", dest);
    }
    return SuccessResult;
}

public
Result_t Path$create_directory(Path_t path, int permissions, bool recursive) {
retry:
    path = Path$expand_home(path);
    const char *c_path = Path$as_c_string(path);
    int status = mkdir(c_path, (mode_t)permissions);
    if (status != 0) {
        if (recursive && errno == ENOENT) {
            Path$create_directory(Path$parent(path), permissions, recursive);
            goto retry;
        } else if (errno == EEXIST) {
            // mkdir(2) is the check and the creation in one, which is why
            // nothing looks first. But EEXIST only says something is there,
            // so confirm it is the directory the caller asked for:
            struct stat sb;
            if (stat(c_path, &sb) != 0 || (sb.st_mode & S_IFMT) != S_IFDIR)
                return FailureResult("Could not create directory: ", c_path,
                                     " (something that is not a directory is already there)");
        } else {
            return FailureResult("Could not create directory: ", c_path, " (", strerror(errno), ")");
        }
    }
    return SuccessResult;
}

static OptionalList_t _filtered_children(Path_t path, bool include_hidden, mode_t filter) {
    List_t children = EMPTY_LIST;
    size_t path_len = strlen(path);
    DIR *d = opendir(Path$expand_home(path));
    if (!d) return NONE_LIST;

    if (path_len > 0 && path[path_len - 1] == '/') --path_len;

    struct dirent *ent;
    while ((ent = readdir(d)) != NULL) {
        if (!include_hidden && ent->d_name[0] == '.') continue;
        if (streq(ent->d_name, ".") || streq(ent->d_name, "..")) continue;

        Path_t child = Path$from_str(String(string_slice(path, path_len), "/", ent->d_name));
        struct stat sb;
        if (stat(Path$expand_home(child), &sb) != 0) continue;
        if (!((sb.st_mode & S_IFMT) & filter)) continue;

        List$insert(&children, &child, I(0), sizeof(Path_t));
    }
    closedir(d);
    return children;
}

public
OptionalList_t Path$children(Path_t path, bool include_hidden) {
    return _filtered_children(path, include_hidden, (mode_t)-1);
}

public
OptionalList_t Path$files(Path_t path, bool include_hidden) {
    return _filtered_children(path, include_hidden, S_IFREG);
}

public
OptionalList_t Path$subdirectories(Path_t path, bool include_hidden) {
    return _filtered_children(path, include_hidden, S_IFDIR);
}

typedef struct {
    Path_t path;
    DIR *dir;
    bool include_hidden : 1;
} child_info_t;

static OptionalPath_t _next_child(child_info_t *info) {
    if (!info->dir) return NONE_PATH;
    for (struct dirent *ent; (ent = readdir(info->dir)) != NULL;) {
        if (!info->include_hidden && ent->d_name[0] == '.') continue;
        if (streq(ent->d_name, ".") || streq(ent->d_name, "..")) continue;

        Path_t child = Path$_concat2(info->path, ent->d_name);
        return child;
    }
    closedir(info->dir);
    info->dir = NULL;
    return NONE_PATH;
}

public
OptionalClosure_t Path$each_child(Path_t path, bool include_hidden) {
    DIR *d = opendir(Path$expand_home(path));
    if (!d) return NONE_CLOSURE;

    child_info_t *info = GC_malloc(sizeof(child_info_t));
    info->path = path;
    info->dir = d;
    info->include_hidden = include_hidden;
    return (Closure_t){.fn = (void *)_next_child, .userdata = info};
}

public
OptionalPath_t Path$unique_directory(Path_t path) {
    bool home_based = (path_type(path) == PATH_HOME);
    path = Path$expand_home(path);

    size_t len = strlen(path);
    // A trailing slash would leave mkdtemp(3) with a template that names a
    // directory inside one that doesn't exist yet, so trim it first:
    while (len > 1 && path[len - 1] == '/')
        --len;

    // mkdtemp(3) replaces a trailing "XXXXXX" with the random characters, so
    // a path that doesn't already end in one gets "-XXXXXX" appended:
    const char *placeholder = "XXXXXX";
    const size_t plen = strlen(placeholder);
    bool has_placeholder = (len >= plen && strncmp(&path[len - plen], placeholder, plen) == 0);
    size_t result_len = has_placeholder ? len : len + 1 + plen;
    if (result_len >= PATH_MAX) fail("Path is too long: ", path);

    char *result = GC_MALLOC_ATOMIC(result_len + 1);
    memcpy(result, path, len);
    if (!has_placeholder) {
        result[len] = '-';
        memcpy(&result[len + 1], placeholder, plen);
    }
    result[result_len] = '\0';

    if (mkdtemp(result) == NULL) return NULL;
    return home_based ? unexpand_home((Path_t)result) : (Path_t)result;
}

public
OptionalPath_t Path$write_unique_bytes(Path_t path, List_t bytes) {
    bool home_based = (path_type(path) == PATH_HOME);
    path = Path$expand_home(path);

    const char *placeholder = "XXXXXX";
    const char *placeholder_pos = strstr(path, placeholder);
    size_t suffixlen = placeholder_pos ? strlen(placeholder_pos + strlen(placeholder)) : 0;
    // If there is no "XXXXXX" then append "-XXXXXX"
    if (!placeholder_pos) {
        path = String(path, "-", placeholder);
    }

    size_t len = strlen(path);
    if (len >= PATH_MAX) fail("Path is too long: ", path);

    char *result = GC_MALLOC_ATOMIC(len + 1);
    memcpy(result, path, len);
    result[len] = '\0';

    int fd = mkstemps(result, suffixlen);
    if (fd == -1) return NULL;

    if (bytes.stride != 1) List$compact(&bytes, 1);

    ssize_t written = write(fd, bytes.data, (size_t)bytes.length);
    if (written != (ssize_t)bytes.length) fail("Could not write to file: ", result, " (", strerror(errno), ")");
    close(fd);
    return home_based ? unexpand_home((Path_t)result) : (Path_t)result;
}

public
OptionalPath_t Path$write_unique(Path_t path, Text_t text) {
    return Path$write_unique_bytes(path, Text$utf8(text));
}

public
OptionalPath_t Path$parent(Path_t path) {
    if (!path || path[0] == '\0' || strspn(path, "/") == strlen(path)) {
        // root dir has no parent
        return NULL;
    }
    if (streq(path, ".")) return PARENT_PATH;
    return normalized_path(String(path, "/.."));
}

static const char *base_name_start(Path_t path) {
    if (!path || path[0] == '\0') return "";

    const char *end = path + strlen(path);
    // Strip trailing slashes. `end` starts on the terminator, so this looks at
    // the byte behind it; testing end[0] only ever found the '\0'.
    while (end > path && end[-1] == '/')
        end -= 1;

    // Get component up to end, excluding trailing slash
    while (end > path && end[-1] != '/')
        end -= 1;

    return end;
}

public
PUREFUNC OptionalText_t Path$base_name(Path_t path) {
    // A POSIX filename is any byte sequence, so this is none when the name is
    // not valid UTF-8. Text$from_strn() already reports that; it used to be
    // discarded by a non-optional return type.
    const char *base = base_name_start(path);
    return Text$from_strn(base, strcspn(base, "/"));
}

public
OptionalText_t Path$extension(Path_t path, bool full) {
    const char *base = base_name_start(path);
    if (!base || base[0] == '\0') return NONE_TEXT;
    if (base[0] == '.') base += 1;
    // Nothing after the leading ".", as in (.) or (..): no extension. Without
    // this, the searches below would start one byte past the terminator.
    if (base[0] == '\0') return NONE_TEXT;
    const char *dot = full ? strchr(base + 1, '.') : strrchr(base + 1, '.');
    if (!dot) return NONE_TEXT; // No "." in the name at all
    const char *extension = dot + 1;
    size_t len = strcspn(extension, "/");
    if (len == 0) return NONE_TEXT; // A trailing "." is not an extension
    return Text$from_strn(extension, len);
}

public
bool Path$has_extension(Path_t path, Text_t extension) {
    const char *base = base_name_start(path);
    // A path with no base name, such as the root, has no extension, so it
    // answers yes only to the "does it lack one?" question:
    if (!base || base[0] == '\0') return extension.length == 0;
    if (base[0] == '.') base += 1;
    const char *end = base;
    while (*end && *end != '/')
        end += 1;
    int64_t base_len = (int64_t)(end - base);
    // Nothing after the leading ".", as in (.) itself: no extension.
    if (base_len <= 0) return extension.length == 0;
    if (extension.length == 0) {
        const char *dot = strrchr(base, '.');
        return dot == NULL || dot[1] == '\0' || dot == base;
    }
    const char *ext = Text$as_c_string(extension);
    if (ext[0] == '.') {
        if (1 + (int64_t)extension.length > base_len) return false;
        return strncmp(base + base_len - extension.length, ext, extension.length) == 0;
    } else {
        if (1 + 1 + (int64_t)extension.length > base_len) return false;
        return base[base_len - 1 - extension.length] == '.'
               && strncmp(base + base_len - extension.length, ext, extension.length) == 0;
    }
}

public
OptionalList_t Path$components(Path_t path) {
    char buf[PATH_MAX + 1] = {};
    size_t len = MIN(strlen(path), PATH_MAX);
    memcpy(buf, path, len);
    buf[len] = '\0';
    List_t components = EMPTY_LIST;
    if (path[0] == '/') {
        Text_t root = Text("/");
        List$insert(&components, &root, I(0), sizeof(root));
    }
    for (char *comp = buf, *next = buf; (comp = strsep(&next, "/"));) {
        if (comp[0] != '\0') {
            OptionalText_t comp_text = Text$from_str(comp);
            // One undecodable component makes the whole split meaningless,
            // rather than leaving a silent "" in the middle of the list:
            if (comp_text.tag == TEXT_NONE) return NONE_LIST;
            List$insert(&components, &comp_text, I(0), sizeof(comp_text));
        }
    }
    return components;
}

public
Path_t Path$child(Path_t path, Text_t name) {
    return normalized_path(String(path, "/", Text$as_c_string(name)));
}

public
OptionalPath_t Path$sibling(Path_t path, Text_t name) {
    // Written as the ".parent().child(name)" it is documented to be, rather
    // than by appending "/../name": appending gave the root a sibling, while
    // Path$parent() says the root has no parent to put one beside.
    OptionalPath_t parent = Path$parent(path);
    if (parent == NULL) return NONE_PATH;
    return Path$child(parent, name);
}

public
Path_t Path$with_extension(Path_t path, Text_t extension, bool replace) {
    // Path$from_str() maps "" to "/", so a well-typed Path is never empty and
    // this never fires; the declared type stays non-optional rather than making
    // every caller unwrap a failure that cannot happen.
    if (!path || path[0] == '\0') fail("Cannot set the extension of an empty path");

    const char *ext = Text$as_c_string(extension);
    const char *dot_or_empty = (ext[0] == '.' || ext[0] == '\0') ? "" : ".";
    if (replace) {
        const char *base = base_name_start(path);
        const char *dot = base;
        while (*dot && *dot != '.')
            dot += 1;
        return normalized_path(String(string_slice(path, (size_t)(dot - path)), dot_or_empty, ext));
    } else {
        return normalized_path(String(path, dot_or_empty, ext));
    }
}

static void _line_reader_cleanup(FILE **f) {
    if (f && *f) {
        fclose(*f);
        *f = NULL;
    }
}

static Text_t _next_line(FILE **f) {
    if (!f || !*f) return NONE_TEXT;

    char *line = NULL;
    size_t size = 0;
next_line:;
    ssize_t len = getline(&line, &size, *f);
    if (len <= 0) {
        if (line != NULL) free(line);
        _line_reader_cleanup(f);
        return NONE_TEXT;
    }

    while (len > 0 && (line[len - 1] == '\r' || line[len - 1] == '\n'))
        --len;

    if (u8_check((uint8_t *)line, (size_t)len) != NULL) {
        // If there's invalid UTF8, skip this line and move to the next
        goto next_line;
    }

    Text_t line_text = Text$from_strn(line, (size_t)len);
    free(line);
    return line_text;
}

public
OptionalClosure_t Path$by_line(Path_t path) {
    path = Path$expand_home(path);

    FILE *f = fopen(path, "r");
    if (f == NULL) {
        if (errno == EMFILE || errno == ENFILE) {
            // If we hit file handle limits, run GC collection to try to clean up any lingering file handles that
            // will be closed by GC finalizers.
            GC_gcollect();
            f = fopen(path, "r");
        }
    }

    if (f == NULL) return NONE_CLOSURE;

    FILE **wrapper = GC_MALLOC(sizeof(FILE *));
    *wrapper = f;
    GC_register_finalizer(wrapper, (void *)_line_reader_cleanup, NULL, NULL, NULL);
    return (Closure_t){.fn = (void *)_next_line, .userdata = wrapper};
}

public
OptionalList_t Path$lines(Path_t path) {
    path = Path$expand_home(path);
    FILE *f = fopen(path, "r");
    if (f == NULL) {
        if (errno == EMFILE || errno == ENFILE) {
            // If we hit file handle limits, run GC collection to try to clean up any lingering file handles that
            // will be closed by GC finalizers.
            GC_gcollect();
            f = fopen(path, "r");
        }
    }

    if (f == NULL) return NONE_LIST;

    List_t lines = EMPTY_LIST;
    for (OptionalText_t line; (line = _next_line(&f)).tag != TEXT_NONE;) {
        List$insert(&lines, &line, I(0), sizeof(line));
    }
    return lines;
}

// Split a path or a glob pattern into its "/"-separated pieces. Nothing here
// decodes: a filename that is not valid UTF-8 still has to be matchable, for
// the same reason Path$has_extension works on bytes.
//
// A leading "/" becomes a piece of its own. That is what anchors absolute
// patterns: no other piece can ever equal "/", so a pattern beginning with one
// can only line up at the front of the path.
static size_t split_components(const char *str, string_slice_t **out) {
    size_t count = (str[0] == '/') ? 1 : 0;
    for (const char *p = str; *p;) {
        p += strspn(p, "/"); // Skip separators, including a run of them
        if (!*p) break;
        count += 1;
        p += strcspn(p, "/");
    }

    string_slice_t *comps = GC_MALLOC(count * sizeof(string_slice_t));
    size_t i = 0;
    if (str[0] == '/') comps[i++] = string_slice("/", 1);
    for (const char *p = str; *p && i < count;) {
        p += strspn(p, "/");
        if (!*p) break;
        size_t len = strcspn(p, "/");
        comps[i++] = string_slice(p, len);
        p += len;
    }
    *out = comps;
    return count;
}

static bool component_matches(string_slice_t path, string_slice_t glob) {
    // fnmatch() needs NUL-terminated strings, and a slice is not one:
    return fnmatch(String(glob), String(path), FNM_PATHNAME | FNM_PERIOD) == 0;
}

static bool is_double_star(string_slice_t comp) {
    return comp.length == 2 && comp.str[0] == '*' && comp.str[1] == '*';
}

// The pattern has to consume every component it is given. "**" stands for zero
// or more components, so it tries each split point.
static bool match_components(string_slice_t *path, size_t n_path, string_slice_t *glob, size_t n_glob) {
    if (n_glob == 0) return n_path == 0;
    if (is_double_star(glob[0])) {
        for (size_t skip = 0; skip <= n_path; skip++)
            if (match_components(path + skip, n_path - skip, glob + 1, n_glob - 1)) return true;
        return false;
    }
    if (n_path == 0) return false;
    if (!component_matches(path[0], glob[0])) return false;
    return match_components(path + 1, n_path - 1, glob + 1, n_glob - 1);
}

// Path$matches_glob() lets a pattern match any trailing run of components. A
// glob is anchored instead: the pattern is relative to the directory being
// globbed, so it has to account for every component below it.
//
// The walk compares one pattern against every path it reaches, so it splits
// that pattern a single time, up front, into the NUL-terminated pieces that
// fnmatch() takes.
static size_t split_cstring_components(const char *str, const char ***out) {
    string_slice_t *slices;
    size_t n = split_components(str, &slices);
    const char **comps = GC_MALLOC(n * sizeof(const char *));
    for (size_t i = 0; i < n; i++)
        comps[i] = String(slices[i]);
    *out = comps;
    return n;
}

// "**" is special only as a component of its own. Anything else containing two
// stars -- "**.c", "**b" -- is an ordinary one-component pattern, where the
// adjacent stars collapse and fnmatch() reads it the same as "*.c" or "*b".
static bool walk_matches(const char **path, size_t n_path, const char **glob, size_t n_glob) {
    if (n_glob == 0) return n_path == 0;
    if (streq(glob[0], "**")) {
        for (size_t skip = 0; skip <= n_path; skip++)
            if (walk_matches(path + skip, n_path - skip, glob + 1, n_glob - 1)) return true;
        return false;
    }
    if (n_path == 0) return false;
    if (fnmatch(glob[0], path[0], FNM_PATHNAME | FNM_PERIOD) != 0) return false;
    return walk_matches(path + 1, n_path - 1, glob + 1, n_glob - 1);
}

// Whether anything *below* these components could still match, which is what
// decides if a directory is worth opening. This is what keeps the walk to the
// part of the tree a pattern can reach: a pattern anchored to one subdirectory
// only ever descends towards it.
static bool walk_can_descend(const char **path, size_t n_path, const char **glob, size_t n_glob) {
    if (n_glob == 0) return false; // The pattern is spent; nothing deeper fits
    if (n_path == 0) return true;  // Pattern left over, so deeper paths may fit
    if (streq(glob[0], "**")) return true; // "**" covers any depth from here
    if (fnmatch(glob[0], path[0], FNM_PATHNAME | FNM_PERIOD) != 0) return false;
    return walk_can_descend(path + 1, n_path - 1, glob + 1, n_glob - 1);
}

// Backslash-escape every glob(3) metacharacter in `str`, so that the bytes are
// matched literally. glob(3) honours these escapes unless GLOB_NOESCAPE is set.
static const char *glob_escaped(const char *str, size_t len) {
    char *escaped = GC_MALLOC_ATOMIC(2 * len + 1); // Worst case: every byte escaped
    char *dest = escaped;
    for (const char *src = str, *end = str + len; src < end; src++) {
        if (*src == '*' || *src == '?' || *src == '[' || *src == ']' || *src == '\\') *(dest++) = '\\';
        *(dest++) = *src;
    }
    *dest = '\0';
    return escaped;
}

// The path components walked so far, relative to the directory being globbed.
// One buffer is reused for the whole walk: each level pushes a name before
// recursing and pops it after.
typedef struct {
    const char **comps;
    size_t length, capacity;
} walk_stack_t;

// glob(3) has no way to say "any number of directories", so a pattern with a
// "**" in it is answered by walking the tree and testing each path instead. A
// directory is opened when the pattern could still match something inside it,
// and every entry it holds is tested, hidden ones included: the matcher is what
// decides, and there "**" spans any component while "*" still will not match a
// leading ".".
static void glob_walk(Path_t dir, walk_stack_t *stack, const char **glob, size_t n_glob, List_t *out) {
    DIR *d = opendir(Path$expand_home(dir));
    if (!d) return;

    size_t dir_len = strlen(dir);
    if (dir_len > 0 && dir[dir_len - 1] == '/') --dir_len;

    for (struct dirent *ent; (ent = readdir(d)) != NULL;) {
        if (streq(ent->d_name, ".") || streq(ent->d_name, "..")) continue;

        if (stack->length >= stack->capacity) {
            stack->capacity = stack->capacity ? 2 * stack->capacity : 16;
            stack->comps = GC_REALLOC(stack->comps, stack->capacity * sizeof(const char *));
        }
        stack->comps[stack->length++] = ent->d_name;

        bool matched = walk_matches(stack->comps, stack->length, glob, n_glob);
        bool descend = walk_can_descend(stack->comps, stack->length, glob, n_glob);
        // Building the path costs an allocation, so only pay for it if the
        // entry is either an answer or a directory worth looking inside:
        if (matched || descend) {
            Path_t child = Path$from_str(String(string_slice(dir, dir_len), "/", ent->d_name));
            if (matched) List$insert(out, &child, I(0), sizeof(Path_t));
            if (descend && Path$is_directory(child, false)) glob_walk(child, stack, glob, n_glob, out);
        }

        stack->length -= 1;
    }
    closedir(d);
}

static bool is_readable_directory(Path_t path) {
    DIR *d = opendir(Path$expand_home(path));
    if (d == NULL) return false;
    closedir(d);
    return true;
}

public
OptionalList_t Path$glob(Path_t path, Text_t pattern) {
    const char *pat = Text$as_c_string(pattern);
    if (pat[0] == '\0') return EMPTY_LIST; // An empty pattern matches nothing

    // The directory has to be readable, which is a failure rather than an empty
    // result, the same as Path$children. Finding matches proves it was, so the
    // question is only asked when there were none -- checking first would be a
    // second look at a directory that can change between the two.

    // Whether to walk is decided by the same split components the walk itself
    // would match with, so the two can never disagree about what a "**" is.
    const char **glob_comps;
    size_t n_glob = split_cstring_components(pat, &glob_comps);
    bool has_double_star = false;
    for (size_t i = 0; i < n_glob; i++)
        if (streq(glob_comps[i], "**")) {
            has_double_star = true;
            break;
        }

    List_t glob_files = EMPTY_LIST;
    if (has_double_star) {
        walk_stack_t stack = {.comps = NULL, .length = 0, .capacity = 0};
        glob_walk(path, &stack, glob_comps, n_glob, &glob_files);
        if (glob_files.length == 0 && !is_readable_directory(path)) return NONE_LIST;
        Closure_t comparison = {.fn = (void *)CString$compare, .userdata = (void *)&Path$info};
        List$sort(&glob_files, comparison, sizeof(Path_t));
        return glob_files;
    }

    // No "**", so let glob(3) prune as it descends instead of walking it all.
    bool home_based = (path_type(path) == PATH_HOME);
    Path_t expanded = Path$expand_home(path);
    size_t dir_len = strlen(expanded);
    if (dir_len > 0 && expanded[dir_len - 1] == '/') --dir_len;
    // Only `pattern` is a pattern: the directory is a literal path, so a
    // directory named "foo[1]" has to be escaped or glob(3) would read the
    // "[1]" as a character class and match nothing inside it.
    const char *joined = String(glob_escaped(expanded, dir_len), "/", pat);

    glob_t glob_result;
    int status = glob(joined, 0, NULL, &glob_result);
    if (status != 0 && status != GLOB_NOMATCH) fail("Failed to perform globbing: ", joined);

    for (size_t i = 0; i < glob_result.gl_pathc; i++) {
        size_t len = strlen(glob_result.gl_pathv[i]);
        if ((len >= 2 && glob_result.gl_pathv[i][len - 1] == '.' && glob_result.gl_pathv[i][len - 2] == '/')
            || (len >= 2 && glob_result.gl_pathv[i][len - 1] == '.' && glob_result.gl_pathv[i][len - 2] == '.'
                && glob_result.gl_pathv[i][len - 3] == '/'))
            continue;
        // Copy: Path$from_str() does not, and globfree() frees gl_pathv.
        Path_t p = Path$from_str(GC_strdup(glob_result.gl_pathv[i]));
        if (home_based) p = unexpand_home(p);
        List$insert(&glob_files, &p, I(0), sizeof(Path_t));
    }
    globfree(&glob_result);
    if (glob_files.length == 0 && !is_readable_directory(path)) return NONE_LIST;
    // glob(3) sorts with strcoll() and the walk above does not sort at all, so
    // both are put in the same order here rather than the answer depending on
    // which branch produced it.
    Closure_t comparison = {.fn = (void *)CString$compare, .userdata = (void *)&Path$info};
    List$sort(&glob_files, comparison, sizeof(Path_t));
    return glob_files;
}

public
bool Path$matches_glob(Path_t path, Text_t glob) {
    string_slice_t *path_comps, *glob_comps;
    size_t n_path = split_components(path, &path_comps);
    size_t n_glob = split_components(Text$as_c_string(glob), &glob_comps);

    if (n_glob == 0) return false; // An empty pattern matches nothing

    // A pattern matches any trailing run of components, so "*.txt" asks about
    // the file's name wherever it sits, while "/tmp/*.txt" pins the whole path.
    for (size_t start = 0; start <= n_path; start++)
        if (match_components(path_comps + start, n_path - start, glob_comps, n_glob)) return true;
    return false;
}

public
Path_t Path$current_dir(void) {
    static char cwd[PATH_MAX];
    if (getcwd(cwd, sizeof(cwd)) == NULL) fail("Could not get current working directory");
    return Path$from_str(GC_strdup(cwd)); // Copy out of the static buffer
}

typedef struct {
    List_t dir_stack;
    OptionalPath_t current;
    DIR *dir;
    bool include_hidden : 1, follow_symlinks : 1;
} walk_info_t;

static OptionalPath_t _walk_next_path(walk_info_t *info) {
    while (info->dir == NULL) {
        if (info->dir_stack.length == 0) return NONE_PATH;

        Path_t p = *(Path_t *)info->dir_stack.data;
        List$remove_at(&info->dir_stack, I(1), I(1), sizeof(Path_t));
        info->dir = opendir(Path$expand_home(p));
        info->current = p;
        return p;
    }

    for (struct dirent *ent; (ent = readdir(info->dir)) != NULL;) {
        if (!info->include_hidden && ent->d_name[0] == '.') continue;
        if (streq(ent->d_name, ".") || streq(ent->d_name, "..")) continue;

        Path_t path = Path$_concat2(info->current, Path$from_str(ent->d_name));
        if (Path$is_directory(path, info->follow_symlinks)) {
            List$insert(&info->dir_stack, &path, I(0), sizeof(Path_t));
            continue;
        }
        return path;
    }

    closedir(info->dir);
    info->dir = NULL;
    return _walk_next_path(info);
}

public
OptionalClosure_t Path$walk(Path_t dir, bool include_hidden, bool follow_symlinks) {
    // There is nothing to walk if the path is not there. lstat(), not stat():
    // a dangling symlink is still an entry, and walk() yields the path itself
    // before descending into it.
    struct stat sb;
    if (lstat(Path$expand_home(dir), &sb) != 0) return NONE_CLOSURE;

    walk_info_t *info = GC_malloc(sizeof(walk_info_t));
    info->dir_stack = List(dir);
    info->current = dir;
    info->dir = NULL;
    info->include_hidden = include_hidden;
    info->follow_symlinks = follow_symlinks;
    return (Closure_t){.fn = (void *)_walk_next_path, .userdata = info};
}

public
CONSTFUNC
const char *Path$as_c_string(Path_t path) {
    return path;
}

public
List_t Path$bytes(Path_t path) {
    return CString$bytes(path);
}

public
Text_t Path$as_text(const void *obj, bool color, const TypeInfo_t *type) {
    (void)type;
    if (!obj) return Text("Path");
    Path_t *path = (Path_t *)obj;
    Text_t text = Text$from_str(*path);
    if (color) text = Text$concat(Text("\033[92;1m"), text, Text("\033[m"));
    return text;
}

public
const TypeInfo_t Path$info = {
    .size = sizeof(Path_t),
    .align = __alignof__(Path_t),
    .tag = OpaqueInfo,
    .metamethods =
        {
            .as_text = Path$as_text,
            .compare = CString$compare,
            .equal = CString$equal,
            .hash = CString$hash,
            .is_none = CString$is_none,
            .serialize = CString$serialize,
            .deserialize = CString$deserialize,
        },
};