tracker
All repositories: gitoria
44.6 KB
// hl:fs plugin — native shared library// Compiled to fs.so, loaded by runtime via dlopen//// Exports:// hl_fs_file(path) → file descriptor with metadata + line iterator// hl_fs_read_file(path) → entire file as string (raw bytes, unchecked)// hl_fs_write_file(path, text, mode?) / hl_fs_write_file_hex(path, hex, mode?)// hl_fs_append_file(path, text, mode?) / hl_fs_append_file_hex(path, hex, mode?)// hl_fs_list_dir(path) → streaming directory iteratorconst std = @import("std");const api = @import("plugin_api");const linux = std.os.linux;const HlValue = api.HlValue;const HlObject = api.HlObject;const HlField = api.HlField;const HlIterator = api.HlIterator;const HlString = api.HlString;// the plugins' allocator (plugin_api.zig)const allocator = api.allocator;// =========================================================================// Path resolution (mission 068)//// CONTRACT: relative paths passed to hl:fs resolve against the MAIN SCRIPT's// directory, not the process CWD — Hybriel path resolution is file-relative// everywhere (import, inherit, plugin discovery), and fs follows suit.// Absolute paths are used as-is. The runtime injects the script dir via the// optional plugin hook hl_fs_set_script_dir right after dlopen.// =========================================================================var script_dir: ?[]u8 = null;export fn hl_fs_set_script_dir(ptr: [*]const u8, len: usize) callconv(.c) void {if (script_dir) |old| allocator.free(old);script_dir = allocator.dupe(u8, ptr[0..len]) catch null;}/// Resolve a (possibly relative) path against the script dir. Returned slice/// is allocated with the plugin allocator; caller frees.fn resolvePath(path: []const u8) ?[]u8 {if (path.len == 0) return allocator.dupe(u8, path) catch null;if (path[0] == '/') return allocator.dupe(u8, path) catch null;if (script_dir) |sd| {return std.fmt.allocPrint(allocator, "{s}/{s}", .{ sd, path }) catch null;}return allocator.dupe(u8, path) catch null;}// =========================================================================// Error values (mission 068): open failures are LOUD — api.makeError with// the operation, the path as the caller wrote it, and the OS reason. The// runtime turns a top-level error value into a located runtime error.// =========================================================================fn errnoText(errno: usize) []const u8 {return switch (errno) {2 => "no such file or directory",13 => "permission denied",20 => "not a directory",21 => "is a directory",else => "I/O error",};}fn errDeinit(val: *api.HlValue) callconv(.c) void {if (val.type == .hl_error) {const s = val.data.string;allocator.free(@constCast(s.ptr[0..s.len]));}}fn makeOpenError(op: []const u8, path: []const u8, errno: usize) api.HlValue {const msg = std.fmt.allocPrint(allocator, "{s}: cannot open '{s}' — {s} (errno {d}); relative paths resolve against the script's directory", .{ op, path, errnoText(errno), errno }) catchreturn api.makeError("fs: cannot open file");var v = api.makeError(msg);v.deinit_fn = &errDeinit;return v;}fn makeReadError(op: []const u8, path: []const u8, errno: usize) api.HlValue {const msg = std.fmt.allocPrint(allocator, "{s}: cannot read '{s}' — {s} (errno {d})", .{ op, path, errnoText(errno), errno }) catchreturn api.makeError("fs: cannot read file");var v = api.makeError(msg);v.deinit_fn = &errDeinit;return v;}// =========================================================================// hl_fs_file — file descriptor with metadata + line iterator// =========================================================================const FileIterState = struct {fd: i32,allocated_strings: std.array_list.Managed([]u8),done: bool,buf: [4096]u8,buf_pos: usize,buf_len: usize,};export fn hl_fs_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.file expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];// Resolve relative paths against the script dir; keep the caller's path// for the descriptor's name/path fields.const resolved = resolvePath(path) orelse return api.makeNull();defer allocator.free(resolved);// We need a null-terminated path for the OSconst path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();defer allocator.free(path_z);// Open fileconst rc = linuxOpenRc(path_z, .{}, 0);if (rc < 0) return makeOpenError("fs.file", path, @intCast(-rc));const fd: i32 = @intCast(rc);// Stat for size, chmod, uid, gidvar size: f64 = 0;var chmod: f64 = 0;var uid: f64 = 0;var gid: f64 = 0;var stat_ok = false;var statx_buf: std.os.linux.Statx = undefined;const statx_rc = std.os.linux.statx(fd,"",std.os.linux.AT.EMPTY_PATH,std.os.linux.STATX.BASIC_STATS,&statx_buf,);if (statx_rc == 0) {size = @floatFromInt(statx_buf.size);chmod = @floatFromInt(statx_buf.mode & 0o7777);uid = @floatFromInt(statx_buf.uid);gid = @floatFromInt(statx_buf.gid);stat_ok = true;}// Extract name from pathconst name = if (std.mem.lastIndexOfScalar(u8, path, '/')) |idx|path[idx + 1 ..]elsepath;// Mime from extensionconst mime = mimeFromName(name);// Build fields: name, path, size, mime, chmod, user, group = 7 fieldsconst field_count: usize = 7;const fields = allocator.alloc(HlField, field_count) catch return api.makeNull();fields[0] = .{ .key = hlStr("name"), .value = api.makeString(name) };fields[1] = .{ .key = hlStr("path"), .value = api.makeString(path) };fields[2] = .{ .key = hlStr("size"), .value = api.makeNumber(size) };fields[3] = .{ .key = hlStr("mime"), .value = api.makeString(mime) };fields[4] = .{ .key = hlStr("chmod"), .value = api.makeNumber(chmod) };if (stat_ok) {fields[5] = .{ .key = hlStr("user"), .value = api.makeNumber(uid) };fields[6] = .{ .key = hlStr("group"), .value = api.makeNumber(gid) };} else {fields[5] = .{ .key = hlStr("user"), .value = api.makeNull() };fields[6] = .{ .key = hlStr("group"), .value = api.makeNull() };}const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{.fields = fields.ptr,.field_count = field_count,.deinit_fn = &fileObjDeinit,};// Create line iteratorconst iter_state = allocator.create(FileIterState) catch return api.makeNull();iter_state.* = .{.fd = fd,.allocated_strings = std.array_list.Managed([]u8).init(allocator),.done = false,.buf = undefined,.buf_pos = 0,.buf_len = 0,};const iter = allocator.create(HlIterator) catch return api.makeNull();iter.* = .{.context = @ptrCast(iter_state),.next_fn = &fileIterNext,.deinit_fn = &fileIterDeinit,};// Return object — runtime will see it has both object fields and an iterator// We pack the iterator pointer into the object by adding one more fieldconst full_fields = allocator.alloc(HlField, field_count + 1) catch return api.makeNull();@memcpy(full_fields[0..field_count], fields);full_fields[field_count] = .{ .key = hlStr("__iter"), .value = api.makeIterator(iter) };// Free the original fields, use full_fieldsallocator.free(fields);obj.fields = full_fields.ptr;obj.field_count = field_count + 1;return api.makeObject(obj);}fn fileIterNext(ctx: ?*anyopaque) callconv(.c) HlValue {const state: *FileIterState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));if (state.done) return api.makeNull();var line_buf = std.array_list.Managed(u8).init(allocator);while (true) {if (state.buf_pos < state.buf_len) {const remaining = state.buf[state.buf_pos..state.buf_len];if (std.mem.indexOfScalar(u8, remaining, '\n')) |nl_pos| {line_buf.appendSlice(remaining[0..nl_pos]) catch return api.makeNull();state.buf_pos += nl_pos + 1;break;} else {line_buf.appendSlice(remaining) catch return api.makeNull();state.buf_pos = 0;state.buf_len = 0;}}// Read from file using posix readconst n = linuxRead(state.fd, &state.buf) orelse {state.done = true;break;};if (n == 0) {state.done = true;break;}state.buf_pos = 0;state.buf_len = n;}if (line_buf.items.len == 0 and state.done) {line_buf.deinit();return api.makeNull();}const line = line_buf.toOwnedSlice() catch return api.makeNull();state.allocated_strings.append(line) catch return api.makeNull();return api.makeString(line);}fn fileIterDeinit(ctx: ?*anyopaque) callconv(.c) void {const state: *FileIterState = @ptrCast(@alignCast(ctx orelse return));_ = linux.close(state.fd);for (state.allocated_strings.items) |s| {allocator.free(s);}state.allocated_strings.deinit();allocator.destroy(state);}fn fileObjDeinit(obj: *HlObject) callconv(.c) void {const fields = obj.fields[0..obj.field_count];// Check for iterator field and clean it up (only if not already nulled out by runtime)for (fields) |field| {if (field.value.type == .hl_iterator) {const iter = field.value.data.iterator;if (iter.deinit_fn) |deinit_fn| {deinit_fn(iter.context);}allocator.destroy(iter);}}allocator.free(fields);allocator.destroy(obj);}// =========================================================================// hl_fs_read_file — read entire file as string// =========================================================================fn readFileDeinit(val: *HlValue) callconv(.c) void {if (val.type == .hl_string) {const s = val.data.string;allocator.free(@constCast(s.ptr[0..s.len]));}}/// exists(path) → Boolean (mission 077, 074 GAP 8). The ONLY hl:fs entry point/// that never errors: it answers a question, and "no" is an answer, not a/// failure. Same script-relative resolution as everything else. Without it the/// only way to test a path was to open it and survive the error — which is why/// the retired live_server used to index `static/` blind instead of probing it.export fn hl_fs_exists(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeBool(false);const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];const resolved = resolvePath(path) orelse return api.makeBool(false);defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeBool(false);defer allocator.free(path_z);// statx, not open: a directory, a symlink target, a device — anything the// OS can name — counts, and nothing is opened (no fd, no side effect).var statx_buf: std.os.linux.Statx = undefined;const rc = std.os.linux.statx(std.os.linux.AT.FDCWD,path_z,0,std.os.linux.STATX.BASIC_STATS,&statx_buf,);return api.makeBool(@as(isize, @bitCast(rc)) == 0);}export fn hl_fs_read_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.readFile expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];const resolved = resolvePath(path) orelse return api.makeNull();defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();defer allocator.free(path_z);const rc = linuxOpenRc(path_z, .{}, 0);if (rc < 0) return makeOpenError("fs.readFile", path, @intCast(-rc));const fd: i32 = @intCast(rc);defer _ = linux.close(fd);// The whole file: no size cap (a 10 MiB cap once truncated silently, ticket #112).// A failed read is an error naming the path, never a short string.var content = std.array_list.Managed(u8).init(allocator);defer content.deinit();var buf: [65536]u8 = undefined;while (true) {const n_read: isize = @bitCast(linux.read(fd, &buf, buf.len));if (n_read == 0) break;if (n_read < 0) {const errno: usize = @intCast(-n_read);if (errno == @intFromEnum(linux.E.INTR)) continue;return makeReadError("fs.readFile", path, errno);}content.appendSlice(buf[0..@intCast(n_read)]) catch return api.makeError("fs.readFile: out of memory reading the file");}const result_slice = content.toOwnedSlice() catch return api.makeNull();var result = api.makeString(result_slice);result.deinit_fn = &readFileDeinit;return result;}// =========================================================================// hl_fs_list_dir — streaming directory iterator with metadata// =========================================================================const DirIterState = struct {fd: i32,base_path: []const u8,allocated_strings: std.array_list.Managed([]u8),// Linux getdents64 bufferdents_buf: [4096]u8,dents_pos: usize,dents_len: usize,done: bool,};export fn hl_fs_list_dir(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.listDir expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];const resolved = resolvePath(path) orelse return api.makeNull();defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeNull();defer allocator.free(path_z);const rc = linuxOpenRc(path_z, .{ .DIRECTORY = true }, 0);if (rc < 0) return makeOpenError("fs.listDir", path, @intCast(-rc));const fd: i32 = @intCast(rc);const path_copy = allocator.dupe(u8, path) catch return api.makeNull();const state = allocator.create(DirIterState) catch return api.makeNull();state.* = .{.fd = fd,.base_path = path_copy,.allocated_strings = std.array_list.Managed([]u8).init(allocator),.dents_buf = undefined,.dents_pos = 0,.dents_len = 0,.done = false,};const iter = allocator.create(HlIterator) catch return api.makeNull();iter.* = .{.context = @ptrCast(state),.next_fn = &dirIterNext,.deinit_fn = &dirIterDeinit,};return api.makeIterator(iter);}fn dirIterNext(ctx: ?*anyopaque) callconv(.c) HlValue {const state: *DirIterState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));if (state.done) return api.makeNull();while (true) {// Need more directory entries?if (state.dents_pos >= state.dents_len) {const rc = std.os.linux.getdents64(state.fd, &state.dents_buf, state.dents_buf.len);if (@as(isize, @bitCast(rc)) <= 0) {state.done = true;return api.makeNull();}state.dents_len = rc;state.dents_pos = 0;}// Parse next dirent64const entry_ptr: *align(1) std.os.linux.dirent64 = @ptrCast(&state.dents_buf[state.dents_pos]);state.dents_pos += entry_ptr.reclen;// Get nameconst name_ptr: [*]const u8 = @ptrCast(&entry_ptr.name);const name_len = std.mem.indexOfScalar(u8, name_ptr[0..256], 0) orelse continue;const name = name_ptr[0..name_len];// Skip . and ..if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue;const name_copy = allocator.dupe(u8, name) catch return api.makeNull();state.allocated_strings.append(name_copy) catch return api.makeNull();// Build full pathconst full_path = std.fmt.allocPrint(allocator, "{s}/{s}", .{ state.base_path, name }) catch return api.makeNull();state.allocated_strings.append(full_path) catch return api.makeNull();// Entry type from d_typeconst kind_str: []const u8 = switch (entry_ptr.type) {std.os.linux.DT.DIR => "directory",std.os.linux.DT.REG => "file",std.os.linux.DT.LNK => "symlink",else => "other",};// Stat for size, chmod, uid, gidvar size: f64 = 0;var chmod: f64 = 0;var e_uid: f64 = 0;var e_gid: f64 = 0;// null-terminate name for statxvar name_z_buf: [256]u8 = undefined;if (name.len < name_z_buf.len) {@memcpy(name_z_buf[0..name.len], name);name_z_buf[name.len] = 0;var statx_buf: std.os.linux.Statx = undefined;const src = std.os.linux.statx(state.fd,@ptrCast(name_z_buf[0 .. name.len + 1]),0,std.os.linux.STATX.BASIC_STATS,&statx_buf,);if (src == 0) {size = @floatFromInt(statx_buf.size);chmod = @floatFromInt(statx_buf.mode & 0o7777);e_uid = @floatFromInt(statx_buf.uid);e_gid = @floatFromInt(statx_buf.gid);}}const mime = mimeFromName(name_copy);// Build object: name, path, type, size, mime, chmod, user, group// The runtime's hlObjectToValue will call entryObjDeinit after conversion,// so these objects are freed immediately upon consumption.const field_count: usize = 8;const fields = allocator.alloc(HlField, field_count) catch return api.makeNull();fields[0] = .{ .key = hlStr("name"), .value = api.makeString(name_copy) };fields[1] = .{ .key = hlStr("path"), .value = api.makeString(full_path) };fields[2] = .{ .key = hlStr("type"), .value = api.makeString(kind_str) };fields[3] = .{ .key = hlStr("size"), .value = api.makeNumber(size) };fields[4] = .{ .key = hlStr("mime"), .value = api.makeString(mime) };fields[5] = .{ .key = hlStr("chmod"), .value = api.makeNumber(chmod) };fields[6] = .{ .key = hlStr("user"), .value = api.makeNumber(e_uid) };fields[7] = .{ .key = hlStr("group"), .value = api.makeNumber(e_gid) };const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{.fields = fields.ptr,.field_count = field_count,.deinit_fn = &entryObjDeinit,};return api.makeObject(obj);}}fn entryObjDeinit(obj: *HlObject) callconv(.c) void {allocator.free(obj.fields[0..obj.field_count]);allocator.destroy(obj);}fn dirIterDeinit(ctx: ?*anyopaque) callconv(.c) void {const state: *DirIterState = @ptrCast(@alignCast(ctx orelse return));_ = linux.close(state.fd);for (state.allocated_strings.items) |s| {allocator.free(s);}state.allocated_strings.deinit();allocator.free(@constCast(state.base_path));allocator.destroy(state);}// =========================================================================// Helpers// =========================================================================/// Raw open: >= 0 is the fd, < 0 is -errno.fn linuxOpenRc(path_z: [*:0]const u8, flags: linux.O, mode: u32) isize {const rc = linux.open(path_z, flags, mode);return @bitCast(rc);}fn linuxRead(fd: i32, buf: []u8) ?usize {const rc = linux.read(fd, buf.ptr, buf.len);if (@as(isize, @bitCast(rc)) <= 0) return null;return rc;}fn hlStr(comptime s: []const u8) HlString {return .{ .ptr = s.ptr, .len = s.len };}fn mimeFromName(name: []const u8) []const u8 {const ext = if (std.mem.lastIndexOfScalar(u8, name, '.')) |dot|name[dot..]elsereturn "application/octet-stream";if (std.mem.eql(u8, ext, ".txt")) return "text/plain";if (std.mem.eql(u8, ext, ".html") or std.mem.eql(u8, ext, ".htm")) return "text/html";if (std.mem.eql(u8, ext, ".css")) return "text/css";if (std.mem.eql(u8, ext, ".js")) return "text/javascript";if (std.mem.eql(u8, ext, ".json")) return "application/json";if (std.mem.eql(u8, ext, ".xml")) return "application/xml";if (std.mem.eql(u8, ext, ".png")) return "image/png";if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) return "image/jpeg";if (std.mem.eql(u8, ext, ".gif")) return "image/gif";if (std.mem.eql(u8, ext, ".svg")) return "image/svg+xml";if (std.mem.eql(u8, ext, ".pdf")) return "application/pdf";if (std.mem.eql(u8, ext, ".zip")) return "application/zip";if (std.mem.eql(u8, ext, ".gz")) return "application/gzip";if (std.mem.eql(u8, ext, ".mp3")) return "audio/mpeg";if (std.mem.eql(u8, ext, ".mp4")) return "video/mp4";if (std.mem.eql(u8, ext, ".wasm")) return "application/wasm";if (std.mem.eql(u8, ext, ".hl")) return "text/x-hybriel";if (std.mem.eql(u8, ext, ".md")) return "text/markdown";if (std.mem.eql(u8, ext, ".yml") or std.mem.eql(u8, ext, ".yaml")) return "text/yaml";if (std.mem.eql(u8, ext, ".toml")) return "application/toml";if (std.mem.eql(u8, ext, ".csv")) return "text/csv";if (std.mem.eql(u8, ext, ".ts")) return "text/typescript";if (std.mem.eql(u8, ext, ".c") or std.mem.eql(u8, ext, ".h")) return "text/x-c";if (std.mem.eql(u8, ext, ".zig")) return "text/x-zig";if (std.mem.eql(u8, ext, ".rs")) return "text/x-rust";if (std.mem.eql(u8, ext, ".py")) return "text/x-python";if (std.mem.eql(u8, ext, ".sh")) return "text/x-shellscript";if (std.mem.eql(u8, ext, ".sql")) return "application/sql";if (std.mem.eql(u8, ext, ".webp")) return "image/webp";if (std.mem.eql(u8, ext, ".ico")) return "image/x-icon";if (std.mem.eql(u8, ext, ".woff2")) return "font/woff2";if (std.mem.eql(u8, ext, ".woff")) return "font/woff";if (std.mem.eql(u8, ext, ".ttf")) return "font/ttf";return "application/octet-stream";}// ── fs.watch: an inotify LOOP SOURCE (the dev-reload bell) ───────────────────// `watch(path)` returns an event-loop source whose wake_fd IS the inotify fd,// so the loop's own epoll_wait wakes on a save — no polling anywhere. Watches// the directory RECURSIVELY at registration (dot-dirs skipped); directories// created afterwards are picked up the next time the app is restarted, which// is the honest v1 for a dev tool.//// EXCLUSIONS (mission 266). `watch(path, exclude)` takes a second argument —// a path STRING or a LIST of them, the same either-shape the plugin surface// already uses in `hl_proc_spawn` — naming directories the descent must not// enter. One argument still means "watch everything": the argument is optional// and a null is the same as absent.//// This exists because a program that knows, at startup, about a directory it// will write to constantly (hl:web's session store is the first) had NO way to// say so. The only skip the walker possessed was "the name starts with a dot",// so the framework's advice was to RENAME the directory — a lecture standing// in for a fix. The dot-dir skip STAYS; `.git` is its reason and it is a good// one. This is a second, explicit reason to skip, not a replacement.const WatchState = struct {ifd: i32,buf: [4096]u8 align(4) = undefined,len: usize = 0,pos: usize = 0,};/// LEXICAL path normalisation — collapses `//` and resolves `.` / `..`/// without touching the filesystem. It must be lexical: an excluded directory/// need not exist yet at the moment the watch is armed (hl:web resolves its/// session dir before it creates it), and realpath(2) on a missing path fails./// Both the watch root and every exclusion go through this, so the comparison/// below is between two paths written the same way.fn normalizePath(path: []const u8) ?[]u8 {const absolute = path.len > 0 and path[0] == '/';var parts = std.ArrayListUnmanaged([]const u8).empty;defer parts.deinit(allocator);var it = std.mem.tokenizeScalar(u8, path, '/');while (it.next()) |seg| {if (std.mem.eql(u8, seg, ".")) continue;if (std.mem.eql(u8, seg, "..")) {if (parts.items.len > 0 and !std.mem.eql(u8, parts.items[parts.items.len - 1], "..")) {_ = parts.pop();continue;}if (absolute) continue; // ".." above "/" is "/"}parts.append(allocator, seg) catch return null;}var out = std.ArrayListUnmanaged(u8).empty;defer out.deinit(allocator);for (parts.items, 0..) |seg, i| {if (absolute or i > 0) out.append(allocator, '/') catch return null;out.appendSlice(allocator, seg) catch return null;}if (out.items.len == 0) out.appendSlice(allocator, if (absolute) "/" else ".") catch return null;return allocator.dupe(u8, out.items) catch null;}/// Is `child` the directory `root`, or somewhere under it? Both must already/// be normalised. A bare `startsWith` would call `/a/sessions-old` a child of/// `/a/sessions`, which is the whole reason this is a function: the byte after/// the prefix has to BE a separator.fn pathIsInside(child: []const u8, root: []const u8) bool {if (root.len == 0) return false;if (std.mem.eql(u8, child, root)) return true;if (child.len <= root.len) return false;if (!std.mem.startsWith(u8, child, root)) return false;if (root[root.len - 1] == '/') return true; // root is "/" itselfreturn child[root.len] == '/';}fn addWatchesRecursive(ifd: i32, dir_path: []const u8, depth: u32, excludes: []const []const u8) void {if (depth > 8) return;// The SECOND reason to skip a directory (mission 266). `dir_path` is// normalised: the root was normalised before the first call and every// child below is that root plus one plain name.for (excludes) |ex| if (pathIsInside(dir_path, ex)) return;const path_z = allocator.dupeZ(u8, dir_path) catch return;defer allocator.free(path_z);const mask: u32 = linux.IN.CLOSE_WRITE | linux.IN.CREATE | linux.IN.DELETE | linux.IN.MOVED_TO | linux.IN.MOVED_FROM;_ = linux.inotify_add_watch(ifd, path_z, mask);// The descent walks getdents64 directly, like hl_fs_list_dir does — this// plugin speaks raw syscalls throughout and never pulls in std.fs.const rc = linuxOpenRc(path_z, .{ .DIRECTORY = true }, 0);if (rc < 0) return;const fd: i32 = @intCast(rc);defer _ = linux.close(fd);var dents: [4096]u8 align(8) = undefined;while (true) {const got = linux.getdents64(fd, &dents, dents.len);if (@as(isize, @bitCast(got)) <= 0) break;var pos: usize = 0;while (pos < got) {const entry: *align(1) linux.dirent64 = @ptrCast(&dents[pos]);pos += entry.reclen;if (entry.type != linux.DT.DIR) continue;const name_ptr: [*]const u8 = @ptrCast(&entry.name);const name_len = std.mem.indexOfScalar(u8, name_ptr[0..256], 0) orelse continue;const name = name_ptr[0..name_len];// "." and ".." would recurse forever; every other dot-dir is noise// for a dev watcher (.git alone would cost thousands of watches).if (name.len == 0 or name[0] == '.') continue;const child = std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir_path, name }) catch continue;defer allocator.free(child);addWatchesRecursive(ifd, child, depth + 1, excludes);}}}/// One exclusion, resolved against the script dir like every other hl:fs path/// and then normalised. Appends nothing on an empty string.fn appendExclusion(list: *std.ArrayListUnmanaged([]u8), raw: []const u8) bool {if (raw.len == 0) return true;const resolved = resolvePath(raw) orelse return false;defer allocator.free(resolved);const norm = normalizePath(resolved) orelse return false;list.append(allocator, norm) catch {allocator.free(norm);return false;};return true;}export fn hl_fs_watch(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("hl:fs: watch(path) needs a path string");const raw = argv[0].data.string.ptr[0..argv[0].data.string.len];const resolved = resolvePath(raw) orelse return api.makeError("hl:fs: watch: could not resolve the path");defer allocator.free(resolved);const path = normalizePath(resolved) orelse return api.makeError("hl:fs: watch: could not resolve the path");defer allocator.free(path);// THE OPTIONAL SECOND ARGUMENT: absent or null → watch everything, exactly// as before. A string is one directory; an object with numeric keys is the// loader's list shape (`hl_proc_spawn` reads its argv list the same way).var excludes = std.ArrayListUnmanaged([]u8).empty;defer {for (excludes.items) |e| allocator.free(e);excludes.deinit(allocator);}if (argc >= 2) {switch (argv[1].type) {.hl_null => {},.hl_string => {const sv = argv[1].data.string;if (!appendExclusion(&excludes, sv.ptr[0..sv.len])) return api.makeError("hl:fs: out of memory");},.hl_object => {const obj_in = argv[1].data.object;for (obj_in.fields[0..obj_in.field_count]) |field| {if (field.value.type == .hl_null) continue;if (field.value.type != .hl_string) return api.makeError("hl:fs: watch(path, exclude): every exclusion must be a path string");const sv = field.value.data.string;if (!appendExclusion(&excludes, sv.ptr[0..sv.len])) return api.makeError("hl:fs: out of memory");}},else => return api.makeError("hl:fs: watch(path, exclude): exclude is a path string or a list of path strings"),}}const rc = linux.inotify_init1(linux.IN.NONBLOCK);const ifd: i32 = @intCast(@as(isize, @bitCast(rc)));if (ifd < 0) return api.makeError("hl:fs: watch: inotify unavailable");addWatchesRecursive(ifd, path, 0, excludes.items);const state = allocator.create(WatchState) catch return api.makeError("hl:fs: out of memory");state.* = .{ .ifd = ifd };const iter = allocator.create(HlIterator) catch return api.makeError("hl:fs: out of memory");iter.* = .{.context = @ptrCast(state),.next_fn = &watchTryNext, // non-blocking either way — event loop only.try_next_fn = &watchTryNext,.deinit_fn = &watchDeinit,.wake_fd = ifd,};return api.makeIterator(iter);}fn watchTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {const st: *WatchState = @ptrCast(@alignCast(ctx orelse return api.makeNull()));if (st.pos >= st.len) {const r = linux.read(st.ifd, &st.buf, st.buf.len);const n: isize = @bitCast(r);if (n <= 0) return api.makeNull(); // EAGAIN: drainedst.len = @intCast(n);st.pos = 0;}// one inotify_event: wd(i32) mask(u32) cookie(u32) len(u32) name[len]const base = st.pos;if (st.len - base < 16) {st.pos = st.len;return api.makeNull();}const name_len = std.mem.readInt(u32, st.buf[base + 12 ..][0..4], .little);const name_start = base + 16;var name: []const u8 = "";if (name_len > 0 and name_start + name_len <= st.len) {const raw_name = st.buf[name_start .. name_start + name_len];name = std.mem.sliceTo(raw_name, 0);}st.pos = name_start + name_len;const fields = allocator.alloc(api.HlField, 1) catch return api.makeNull();const name_copy = allocator.dupe(u8, name) catch {allocator.free(fields);return api.makeNull();};fields[0] = .{ .key = hlStr("name"), .value = api.makeString(name_copy) };const obj = allocator.create(api.HlObject) catch {allocator.free(name_copy);allocator.free(fields);return api.makeNull();};obj.* = .{ .fields = fields.ptr, .field_count = 1, .deinit_fn = &watchEventDeinit };return api.makeObject(obj);}/// An event is the loader's to copy and ours to free (the emit soak, 2026-10-02): with/// no deinit, every inotify event of the dev watcher — each write a db makes under the/// watched root — kept its name, its field and its object for good.fn watchEventDeinit(obj: *api.HlObject) callconv(.c) void {const sv = obj.fields[0].value.data.string;allocator.free(@constCast(sv.ptr)[0..sv.len]);allocator.free(obj.fields[0..obj.field_count]);allocator.destroy(obj);}fn watchDeinit(ctx: ?*anyopaque) callconv(.c) void {const st: *WatchState = @ptrCast(@alignCast(ctx orelse return));_ = linux.close(st.ifd);allocator.destroy(st);}// =========================================================================// THE WRITE SURFACE (2026-08-28)//// hl:fs was READ-ONLY until this block: file, readFile, listDir, exists,// watch. It could report a file's `chmod` and never set one. Session// persistence is the first thing in this tree that has to put bytes on a// disk, and the rule when the framework can do something the language// cannot is that the LANGUAGE is missing it — so the capability lands here,// as an ordinary hl:fs surface any program can use, not as a private hook// the session store reaches through.//// Three calls, and the shape of each is a decision://// writeFile(path, contents, mode?) ATOMIC. Writes a sibling temp file,// fsyncs it, then rename()s it over the target — so a reader sees// either the whole previous file or the whole new one, never a torn// one. A half-written session file that revives as garbage is a// silent logout, and the crash that produces it is exactly the crash// nobody can reproduce. rename(2) within one directory is atomic on// every filesystem Linux ships; that is the whole reason the temp// file is a SIBLING rather than in /tmp (a cross-device rename fails).// mkDir(path, mode?) recursive, and succeeds when the// directory is already there — the caller wants it to exist, not to// be the one who created it.// remove(path) IDEMPOTENT: true when the path is// gone on return, including when it was already gone. A sweep// deleting an expired file must not fail because something else// deleted it first.//// MODES ARE EXPLICIT AND DEFAULT TIGHT. hybrilior passes no mode anywhere,// so its session files land at whatever the umask says — 0644 on a shared// box, i.e. every session on the machine readable by every other tenant.// Here the default is 0600 for a file and 0700 for a directory, and a// caller who wants them looser has to say so. This is not the kind of bug// that shows up in a test; it shows up in someone else's logs.// =========================================================================fn makeWriteError(op: []const u8, path: []const u8, errno: usize) api.HlValue {const msg = std.fmt.allocPrint(allocator, "{s}: cannot write '{s}' — {s} (errno {d}); relative paths resolve against the script's directory", .{ op, path, errnoText(errno), errno }) catchreturn api.makeError("fs: write failed");var v = api.makeError(msg);v.deinit_fn = &errDeinit;return v;}/// mkdir every missing component of `path`. Returns 0, or the errno of the/// first component that could not be created for a reason other than "it is/// already there".fn mkdirRecursive(path: []const u8, mode: u32) usize {if (path.len == 0) return 0;const buf = allocator.dupeZ(u8, path) catch return 12; // ENOMEMdefer allocator.free(buf);// Walk the separators left to right, creating each prefix in turn. Index// 0 is skipped so a leading '/' is never itself a component to create.var i: usize = 1;while (i <= buf.len) : (i += 1) {const at_end = i == buf.len;if (!at_end and buf[i] != '/') continue;if (!at_end) buf[i] = 0;const rc: isize = @bitCast(linux.mkdir(buf.ptr, mode));if (!at_end) buf[i] = '/';if (rc < 0) {const errno: usize = @intCast(-rc);// EEXIST is the success case: the caller wants it to exist.if (errno != 17) return errno;}}return 0;}export fn hl_fs_mk_dir(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.mkDir expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];var mode: u32 = 0o700;if (argc >= 2 and argv[1].type == .hl_number) mode = @intFromFloat(argv[1].data.number);const resolved = resolvePath(path) orelse return api.makeError("fs.mkDir: out of memory");defer allocator.free(resolved);const errno = mkdirRecursive(resolved, mode);if (errno != 0) return makeWriteError("fs.mkDir", path, errno);return api.makeBool(true);}const write_usage = "fs.writeFile expects (String path, String or Bytes contents, Number mode?)";export fn hl_fs_write_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {return api.makeError(write_usage);}const path_str = argv[0].data.string;const body_str = argv[1].data.string;return writeWhole(path_str.ptr[0..path_str.len], body_str.ptr[0..body_str.len], modeArg(argc, argv));}/// hl_fs_write_file_hex(path, hex, mode?) — writeFile with a Bytes (ticket/// #88): a Bytes crosses the plugin boundary as its hex text (the ABI has no/// Bytes, as hl:proc's write_hex says), is decoded here and written raw.export fn hl_fs_write_file_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {return api.makeError(write_usage);}const path_str = argv[0].data.string;const hex = argv[1].data.string.ptr[0..argv[1].data.string.len];const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("fs.writeFile: out of memory");defer allocator.free(raw);_ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("fs.writeFile: not a Bytes");return writeWhole(path_str.ptr[0..path_str.len], raw, modeArg(argc, argv));}fn modeArg(argc: u32, argv: [*]const HlValue) u32 {if (argc >= 3 and argv[2].type == .hl_number) return @intFromFloat(argv[2].data.number);return 0o600;}fn writeWhole(path: []const u8, body: []const u8, mode: u32) HlValue {const resolved = resolvePath(path) orelse return api.makeError("fs.writeFile: out of memory");defer allocator.free(resolved);// The temp name is a SIBLING (see the block comment): rename(2) is only// atomic within one filesystem, and /tmp is routinely a different one.// The pid keeps two processes writing the same path off each other's temp// file; the rename itself settles who wins the target.const pid = linux.getpid();const tmp = std.fmt.allocPrintSentinel(allocator, "{s}.hltmp{d}", .{ resolved, pid }, 0) catchreturn api.makeError("fs.writeFile: out of memory");defer allocator.free(tmp);const rc = linuxOpenRc(tmp.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, mode);if (rc < 0) return makeWriteError("fs.writeFile", path, @intCast(-rc));const fd: i32 = @intCast(rc);var wrote: usize = 0;while (wrote < body.len) {const n: isize = @bitCast(linux.write(fd, body.ptr + wrote, body.len - wrote));if (n <= 0) {_ = linux.close(fd);_ = linux.unlink(tmp.ptr);return makeWriteError("fs.writeFile", path, if (n < 0) @intCast(-n) else 5);}wrote += @intCast(n);}// fsync BEFORE the rename, or the rename can land while the bytes are// still only in the page cache — a power cut then leaves an EMPTY file// where a complete one used to be, which is worse than either outcome._ = linux.fsync(fd);_ = linux.close(fd);// O_CREAT honours the mode only through the umask; say it outright so a// 0600 request is 0600 whatever the process umask happens to be._ = linux.chmod(tmp.ptr, mode);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeError("fs.writeFile: out of memory");defer allocator.free(path_z);const ren: isize = @bitCast(linux.rename(tmp.ptr, path_z.ptr));if (ren < 0) {_ = linux.unlink(tmp.ptr);return makeWriteError("fs.writeFile", path, @intCast(-ren));}return api.makeBool(true);}const append_usage = "fs.appendFile expects (String path, String or Bytes contents, Number mode?)";export fn hl_fs_append_file(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {return api.makeError(append_usage);}const path_str = argv[0].data.string;const body_str = argv[1].data.string;return appendWhole(path_str.ptr[0..path_str.len], body_str.ptr[0..body_str.len], modeArg(argc, argv));}/// hl_fs_append_file_hex(path, hex, mode?) — appendFile with a Bytes (ticket/// #88), the same hex-text crossing writeFile's hex sibling uses.export fn hl_fs_append_file_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_string) {return api.makeError(append_usage);}const path_str = argv[0].data.string;const hex = argv[1].data.string.ptr[0..argv[1].data.string.len];const raw = allocator.alloc(u8, hex.len / 2) catch return api.makeError("fs.appendFile: out of memory");defer allocator.free(raw);_ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("fs.appendFile: not a Bytes");return appendWhole(path_str.ptr[0..path_str.len], raw, modeArg(argc, argv));}/// APPEND, NOT ATOMIC BY RENAME (writeFile's block comment above): an append/// MUTATES the file that is already there, so there is no whole "old" or/// "new" version to swap in — O_APPEND gives a narrower guarantee instead./// The kernel positions a single write() at the file's CURRENT end and/// performs it as one operation, so two processes each appending a line never/// interleave their bytes mid-line and a reader never sees a torn append —/// which is why this is ONE write() of the whole body where writeFile's is/// (short-write retries aside). The file is created at `mode` (default 0600,/// same default as writeFile) if it is not there yet; O_APPEND on an existing/// file leaves its mode untouched, same as any other open of it.fn appendWhole(path: []const u8, body: []const u8, mode: u32) HlValue {const resolved = resolvePath(path) orelse return api.makeError("fs.appendFile: out of memory");defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeError("fs.appendFile: out of memory");defer allocator.free(path_z);const rc = linuxOpenRc(path_z.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .APPEND = true }, mode);if (rc < 0) return makeWriteError("fs.appendFile", path, @intCast(-rc));const fd: i32 = @intCast(rc);defer _ = linux.close(fd);var wrote: usize = 0;while (wrote < body.len) {const n: isize = @bitCast(linux.write(fd, body.ptr + wrote, body.len - wrote));if (n <= 0) {return makeWriteError("fs.appendFile", path, if (n < 0) @intCast(-n) else 5);}wrote += @intCast(n);}return api.makeBool(true);}export fn hl_fs_remove(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_string) return api.makeError("fs.remove expects a string path");const path_str = argv[0].data.string;const path = path_str.ptr[0..path_str.len];const resolved = resolvePath(path) orelse return api.makeError("fs.remove: out of memory");defer allocator.free(resolved);const path_z = allocator.dupeZ(u8, resolved) catch return api.makeError("fs.remove: out of memory");defer allocator.free(path_z);const rc: isize = @bitCast(linux.unlink(path_z.ptr));if (rc < 0) {const errno: usize = @intCast(-rc);// ENOENT is success: the caller asked for the path to be gone.if (errno == 2) return api.makeBool(true);return makeWriteError("fs.remove", path, errno);}return api.makeBool(true);}
Branches
- mainmain branch
Latest commits
- 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
- 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
- eb3b9205tracker: report 031mre
- 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
- 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
- a386dc92tracker: reports 029 + 030mre
- 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
- d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
- 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
- 661c2592tracker: report 028mre
- 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
- d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
- 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
- 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
- 4f47f181tracker: report 027mre
- dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
- 84e1b3e1tracker: reports 025 + 026mre