magdalena

Log | Files | Refs | README | LICENSE

commit 39a38fc7fa002b6abe4dfd4a8b1a94c0af2ec2ab
Author: mtmn <miro@haravara.org>
Date:   Sat, 25 Apr 2026 17:10:53 +0200

init

Diffstat:
ABUILD | 37+++++++++++++++++++++++++++++++++++++
AREADME.md | 4++++
Abuild.zig | 34++++++++++++++++++++++++++++++++++
Abuild.zig.zon | 19+++++++++++++++++++
Aconfig.json | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aextension.bzl | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajustfile | 5+++++
Asrc/cfg.zig | 36++++++++++++++++++++++++++++++++++++
Asrc/cli.zig | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/db.zig | 347+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/fzf.zig | 557+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/io.zig | 48++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main.zig | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/memcached.zig | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Athemes/bat/magdalena.tmTheme | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 1791 insertions(+), 0 deletions(-)

diff --git a/BUILD b/BUILD @@ -0,0 +1,37 @@ +load("@rules_zig//zig:defs.bzl", "zig_binary", "zig_library") +load("//bazel:local-deploy.bzl", "local_deploy") + +package(default_visibility = ["//visibility:public"]) + +zig_binary( + name = "magdalena", + main = "src/main.zig", + srcs = glob(["src/*.zig"]), + zigopts = [ + "-O", + "ReleaseFast", + "-Doptimize=ReleaseFast", + ], + deps = [ + "@zul//:zul", + ], +) + +zig_binary( + name = "magdalena_debug", + main = "src/main.zig", + srcs = glob(["src/*.zig"]), + zigopts = [ + "-O", + "Debug", + "-Doptimize=Debug", + ], + deps = [ + "@zul//:zul", + ], +) + +local_deploy( + name = "deploy", + srcs = [":magdalena"], +) diff --git a/README.md b/README.md @@ -0,0 +1,4 @@ +# magdalena +Interactive shell navigation and history in zig. + +[![Sandra - Maria Magdalena](https://i.discogs.com/90fMDrWx3ZBUyp3-54G3H4k6XL13lsFzzpF4TMp52sA/rs:fit/g:sm/q:90/h:524/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTI1NTM5/OC0xNDc3NjY3OTEw/LTI5NjcucG5n.jpeg)](https://youtu.be/qwrrpBc7xWA/) diff --git a/build.zig b/build.zig @@ -0,0 +1,34 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const zul = b.dependency("zul", .{ + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "magdalena", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zul", .module = zul.module("zul") }, + }, + }), + }); + exe.linkLibC(); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); +} diff --git a/build.zig.zon b/build.zig.zon @@ -0,0 +1,19 @@ +.{ + .name = .magdalena, + .version = "0.1.4", + .fingerprint = 0xd065e535ca3c8b31, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .zul = .{ + .url = "https://github.com/karlseguin/zul/archive/e770047f208cf4538fcd9fc64550c84fd5492fc4.tar.gz", + .hash = "1220c0fe8bdde860b533f5625b72daaf2c0c686ac7eaf72e123aaf6998d785439b93", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "LICENSE", + "README.md", + }, +} diff --git a/config.json b/config.json @@ -0,0 +1,51 @@ +{ + "editor": "vim", + "max_depth": 5, + "ignored_patterns": [ + ".git", + ".jj", + ".zig-cache", + "node_modules", + "target" + ], + "fzf_opts": [ + "--highlight-line", + "--ansi", + "--layout=reverse", + "--border=rounded", + "--color=bg+:#2a1f2e,bg:-1,spinner:#c084b8,hl:#e08060", + "--color=fg:#c8b8bf,header:#c4607a,info:#7a6e7a,pointer:#c084b8", + "--color=marker:#e06fad,fg+:#f0dfe5,prompt:#7a9fd4,hl+:#f0a070", + "--color=border:#3d2a42" + ], + "fav_dirs": [ + "/home/miro/src" + ], + "openers": [ + { + "extensions": ["mp4", "mkv", "avi", "webm"], + "action": "media_player", + "command": "mpv" + }, + { + "extensions": ["flac", "aiff", "aif", "mp3", "wav"], + "action": "audio_player", + "command": "mpv" + }, + { + "extensions": ["jpg", "jpeg", "png", "webp", "svg"], + "action": "image_viewer", + "command": "nsxiv-rifle" + }, + { + "extensions": ["pdf", "epub", "mobi"], + "action": "document_reader", + "command": "sioyek" + }, + { + "extensions": ["html"], + "action": "web_browser", + "command": "firefox" + } + ] +} diff --git a/extension.bzl b/extension.bzl @@ -0,0 +1,75 @@ +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +def _magdalena_deps_impl(_ctx): + http_archive( + name = "zul", + sha256 = "1dee366aaab6257e9d2b7d8b54dfb470bc2c0404be5975d76365b8ef6fd51ab7", + strip_prefix = "zul-e770047f208cf4538fcd9fc64550c84fd5492fc4", + url = "https://github.com/karlseguin/zul/archive/e770047f208cf4538fcd9fc64550c84fd5492fc4.tar.gz", + build_file_content = """ +load("@rules_zig//zig:defs.bzl", "zig_library") +zig_library( + name = "zul", + main = "src/zul.zig", + srcs = glob(["src/**/*.zig"]), + visibility = ["//visibility:public"], +) +""", + ) + + http_archive( + name = "zio", + sha256 = "bcda5af0639c1392f55f909536759bb0df254c9966d1f9bdd9f828212c221816", + strip_prefix = "zio-3ac234eae165177cd75742f922851dc5d373fd12", + url = "https://github.com/lalinsky/zio/archive/3ac234eae165177cd75742f922851dc5d373fd12.tar.gz", + build_file_content = """ +load("@rules_zig//zig:defs.bzl", "zig_library") + +genrule( + name = "zio_options_gen", + outs = ["zio_options.zig"], + cmd = 'echo \\'pub const backend: ?[]const u8 = "epoll";\\' > $@', +) + +zig_library( + name = "zio_options", + main = "zio_options.zig", + visibility = ["//visibility:public"], +) + +zig_library( + name = "zio", + main = "src/zio.zig", + srcs = glob(["src/**/*.zig"]), + visibility = ["//visibility:public"], + deps = [ + ":zio_options", + "@rules_zig//zig/lib:libc", + ], +) +""", + ) + + http_archive( + name = "memcached", + sha256 = "702822a8d4c41b8549c49f42df48e703e279644f3a5e523b7e221b7c57b1c175", + strip_prefix = "memcached.zig-d4e93b1663074db714ec4d6ade513d023ab92ee4", + url = "https://github.com/lalinsky/memcached.zig/archive/d4e93b1663074db714ec4d6ade513d023ab92ee4.tar.gz", + build_file_content = """ +load("@rules_zig//zig:defs.bzl", "zig_library") +zig_library( + name = "memcached", + main = "src/root.zig", + srcs = glob(["src/**/*.zig"]), + visibility = ["//visibility:public"], + deps = [ + "@zio//:zio", + "@rules_zig//zig/lib:libc", + ], +) +""", + ) + +magdalena_deps = module_extension( + implementation = _magdalena_deps_impl, +) diff --git a/justfile b/justfile @@ -0,0 +1,5 @@ +run opt="Debug": + zig build run -Doptimize={{opt}} + +build opt="Debug": + zig build -Doptimize={{opt}} -p build/{{opt}} diff --git a/src/cfg.zig b/src/cfg.zig @@ -0,0 +1,36 @@ +const std = @import("std"); + +pub const Config = struct { + openers: []Opener = &.{}, + ignored_patterns: []const []const u8 = &.{}, + fzf_opts: []const []const u8 = &.{}, + fav_dirs: []const []const u8 = &.{}, + editor: ?[]const u8 = null, + max_depth: usize = 3, + + pub const Opener = struct { + extensions: []const []const u8, + action: []const u8, + command: []const u8, + }; +}; + +pub fn loadConfig(allocator: std.mem.Allocator) ?std.json.Parsed(Config) { + const home = std.posix.getenv("HOME") orelse return null; + const path = std.fs.path.join(allocator, &.{ home, ".config", "magdalena", "config.json" }) catch return null; + defer allocator.free(path); + + const file = std.fs.cwd().openFile(path, .{}) catch return null; + defer file.close(); + + const content = file.readToEndAlloc(allocator, std.math.maxInt(usize)) catch return null; + defer allocator.free(content); + + return std.json.parseFromSlice(Config, allocator, content, .{ + .ignore_unknown_fields = true, + .allocate = .alloc_always, + }) catch |err| { + std.log.err("failed to parse config: {any}", .{err}); + return null; + }; +} diff --git a/src/cli.zig b/src/cli.zig @@ -0,0 +1,144 @@ +const std = @import("std"); +const zul = @import("zul"); +const io = @import("io.zig"); + +pub const Action = enum { + recent_dirs, + recent_files, + fav_dirs, + search, + goto_dir, + goto_file, + look_file, + look_dir, + grep, + cleanup, + log_dir, + log_file, + help, +}; + +pub const Args = struct { + action: Action, + query: ?[]const u8 = null, + log_path: ?[]const u8 = null, + file_type: ?[]const u8 = null, + file_action: ?[]const u8 = null, + depth: ?usize = null, + + _cla: zul.CommandLineArgs, + + pub fn deinit(self: Args) void { + self._cla.deinit(); + } +}; + +pub fn parseArgs(allocator: std.mem.Allocator) !Args { + const cla = try zul.CommandLineArgs.parse(allocator); + + var res = Args{ + .action = .help, + ._cla = cla, + }; + + if (cla.contains("help") or cla.contains("-h") or cla.contains("--help")) { + res.action = .help; + return res; + } + + if (cla.contains("clean") or cla.contains("c")) { + res.action = .cleanup; + return res; + } + + if (cla.get("depth")) |d| { + res.depth = try std.fmt.parseInt(usize, d, 10); + } else if (cla.get("d")) |d| { + res.depth = try std.fmt.parseInt(usize, d, 10); + } + + if (res.depth == null) { + for (cla.tail, 0..) |arg, i| { + if (std.mem.startsWith(u8, arg, "--depth=")) { + res.depth = try std.fmt.parseInt(usize, arg["--depth=".len..], 10); + } else if (std.mem.eql(u8, arg, "--depth") or std.mem.eql(u8, arg, "-d")) { + if (i + 1 < cla.tail.len) { + res.depth = try std.fmt.parseInt(usize, cla.tail[i + 1], 10); + } + } + if (res.depth != null) break; + } + } + + if (cla.tail.len == 0) { + return res; + } + + const cmd_str = cla.tail[0]; + if (std.mem.eql(u8, cmd_str, "recent-dirs")) { + res.action = .recent_dirs; + } else if (std.mem.eql(u8, cmd_str, "recent-files")) { + res.action = .recent_files; + } else if (std.mem.eql(u8, cmd_str, "fav-dirs")) { + res.action = .fav_dirs; + } else if (std.mem.eql(u8, cmd_str, "search")) { + res.action = .search; + } else if (std.mem.eql(u8, cmd_str, "goto-dir")) { + res.action = .goto_dir; + } else if (std.mem.eql(u8, cmd_str, "goto-file")) { + res.action = .goto_file; + } else if (std.mem.eql(u8, cmd_str, "look-file")) { + res.action = .look_file; + } else if (std.mem.eql(u8, cmd_str, "look-dir")) { + res.action = .look_dir; + } else if (std.mem.eql(u8, cmd_str, "grep")) { + res.action = .grep; + } else if (std.mem.eql(u8, cmd_str, "log-dir")) { + res.action = .log_dir; + } else if (std.mem.eql(u8, cmd_str, "log-file")) { + res.action = .log_file; + } else { + res.action = .help; + return res; + } + + if (cla.tail.len > 1) { + const arg = cla.tail[1]; + if (res.action == .search) { + res.query = arg; + } else if (res.action == .log_dir) { + res.log_path = arg; + } else if (res.action == .log_file) { + res.log_path = arg; + if (cla.tail.len > 2) res.file_type = cla.tail[2]; + if (cla.tail.len > 3) res.file_action = cla.tail[3]; + } + } + + return res; +} + +pub fn printUsage() !void { + var buf: [4096]u8 = undefined; + var writer = io.getStdout(&buf); + try writer.print( + \\Usage: + \\ magdalena recent-dirs + \\ magdalena recent-files + \\ magdalena fav-dirs + \\ magdalena goto-dir + \\ magdalena goto-file + \\ magdalena log-dir <path> + \\ magdalena log-file <path> [type] [action] + \\ magdalena search <query> + \\ magdalena look-file [--depth <n>] + \\ magdalena look-dir [--depth <n>] + \\ magdalena grep + \\ + \\Options: + \\ -c, --clean Perform cleanup + \\ -h, --help Show this help + \\ + , .{}); + try writer.end(); +} diff --git a/src/db.zig b/src/db.zig @@ -0,0 +1,347 @@ +const std = @import("std"); +const zul = @import("zul"); +const io = @import("io.zig"); + +pub const DirectoryEntry = struct { + path: []const u8, + timestamp: ?[]const u8 = null, +}; + +pub const FileEntry = struct { + path: []const u8, + file_type: []const u8, + action: []const u8, + timestamp: ?[]const u8 = null, +}; + +pub const SearchResult = struct { + directories: []DirectoryEntry, + files: []FileEntry, +}; + +pub const Db = struct { + allocator: std.mem.Allocator, + base_path: []const u8, + dirs_path: []const u8, + files_path: []const u8, + + pub fn init(allocator: std.mem.Allocator, path: []const u8) !Db { + const base_path = try allocator.dupe(u8, path); + errdefer allocator.free(base_path); + + const dirs_path = try std.fs.path.join(allocator, &.{ base_path, "dirs.log" }); + errdefer allocator.free(dirs_path); + + const files_path = try std.fs.path.join(allocator, &.{ base_path, "files.log" }); + errdefer allocator.free(files_path); + + const res = Db{ + .allocator = allocator, + .base_path = base_path, + .dirs_path = dirs_path, + .files_path = files_path, + }; + + // Ensure directory exists + std.fs.cwd().makePath(base_path) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + + // Ensure files exist + for (&[_][]const u8{ dirs_path, files_path }) |p| { + var f = std.fs.cwd().openFile(p, .{ .mode = .read_write }) catch |err| { + if (err == error.FileNotFound) { + _ = try std.fs.cwd().createFile(p, .{ .truncate = false }); + continue; + } + return err; + }; + f.close(); + } + + return res; + } + + pub fn deinit(self: *Db) void { + self.allocator.free(self.base_path); + self.allocator.free(self.dirs_path); + self.allocator.free(self.files_path); + } + + pub fn recentDirs(self: *Db) !zul.Managed([]DirectoryEntry) { + const file = std.fs.cwd().openFile(self.dirs_path, .{}) catch |err| { + if (err == error.FileNotFound) return zul.Managed([]DirectoryEntry).fromJson(try std.json.parseFromSlice([]DirectoryEntry, self.allocator, "[]", .{})); + return err; + }; + defer file.close(); + + const content = try file.readToEndAlloc(self.allocator, std.math.maxInt(usize)); + errdefer self.allocator.free(content); + + var arena = try self.allocator.create(std.heap.ArenaAllocator); + errdefer { + arena.deinit(); + self.allocator.destroy(arena); + } + arena.* = std.heap.ArenaAllocator.init(self.allocator); + const allocator = arena.allocator(); + + var list = std.ArrayListUnmanaged(DirectoryEntry){}; + + var seen = std.StringHashMap(void).init(allocator); + + // Use splitBackwardsScalar to read the newest entries first + var it = std.mem.splitBackwardsScalar(u8, content, '\n'); + while (it.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (trimmed.len == 0) continue; + + var line_it = std.mem.splitScalar(u8, trimmed, '|'); + const ts = line_it.next() orelse continue; + const dir_path = line_it.next() orelse continue; + + if (seen.contains(dir_path)) continue; + try seen.put(dir_path, {}); + + try list.append(allocator, .{ + .path = try allocator.dupe(u8, dir_path), + .timestamp = try allocator.dupe(u8, ts), + }); + } + + self.allocator.free(content); + return .{ + .arena = arena, + .value = try list.toOwnedSlice(allocator), + }; + } + + pub fn recentFiles(self: *Db) !zul.Managed([]FileEntry) { + const file = std.fs.cwd().openFile(self.files_path, .{}) catch |err| { + if (err == error.FileNotFound) return zul.Managed([]FileEntry).fromJson(try std.json.parseFromSlice([]FileEntry, self.allocator, "[]", .{})); + return err; + }; + defer file.close(); + + const content = try file.readToEndAlloc(self.allocator, std.math.maxInt(usize)); + errdefer self.allocator.free(content); + + var arena = try self.allocator.create(std.heap.ArenaAllocator); + errdefer { + arena.deinit(); + self.allocator.destroy(arena); + } + arena.* = std.heap.ArenaAllocator.init(self.allocator); + const allocator = arena.allocator(); + + var list = std.ArrayListUnmanaged(FileEntry){}; + + var seen = std.StringHashMap(void).init(allocator); + + var it = std.mem.splitBackwardsScalar(u8, content, '\n'); + while (it.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (trimmed.len == 0) continue; + + var line_it = std.mem.splitScalar(u8, trimmed, '|'); + const ts = line_it.next() orelse continue; + const ftype = line_it.next() orelse continue; + const action = line_it.next() orelse continue; + const fpath = line_it.next() orelse continue; + + if (seen.contains(fpath)) continue; + try seen.put(fpath, {}); + + try list.append(allocator, .{ + .path = try allocator.dupe(u8, fpath), + .file_type = try allocator.dupe(u8, ftype), + .action = try allocator.dupe(u8, action), + .timestamp = try allocator.dupe(u8, ts), + }); + } + + self.allocator.free(content); + return .{ + .arena = arena, + .value = try list.toOwnedSlice(allocator), + }; + } + + pub fn searchHistory(self: *Db, query_str: []const u8) !zul.Managed(SearchResult) { + const managed_dirs = try self.recentDirs(); + defer managed_dirs.deinit(); + const managed_files = try self.recentFiles(); + defer managed_files.deinit(); + + var arena = try self.allocator.create(std.heap.ArenaAllocator); + errdefer { + arena.deinit(); + self.allocator.destroy(arena); + } + arena.* = std.heap.ArenaAllocator.init(self.allocator); + const allocator = arena.allocator(); + + var dir_list = std.ArrayListUnmanaged(DirectoryEntry){}; + for (managed_dirs.value) |d| { + if (std.mem.indexOf(u8, d.path, query_str) != null) { + try dir_list.append(allocator, .{ + .path = try allocator.dupe(u8, d.path), + .timestamp = if (d.timestamp) |ts| try allocator.dupe(u8, ts) else null, + }); + } + } + + var file_list = std.ArrayListUnmanaged(FileEntry){}; + for (managed_files.value) |f| { + if (std.mem.indexOf(u8, f.path, query_str) != null) { + try file_list.append(allocator, .{ + .path = try allocator.dupe(u8, f.path), + .file_type = try allocator.dupe(u8, f.file_type), + .action = try allocator.dupe(u8, f.action), + .timestamp = if (f.timestamp) |ts| try allocator.dupe(u8, ts) else null, + }); + } + } + + return .{ + .arena = arena, + .value = .{ + .directories = try dir_list.toOwnedSlice(allocator), + .files = try file_list.toOwnedSlice(allocator), + }, + }; + } + + fn resolvePath(self: *Db, path: []const u8) ![]const u8 { + return std.fs.cwd().realpathAlloc(self.allocator, path) catch { + return try std.fs.path.resolve(self.allocator, &[_][]const u8{path}); + }; + } + + pub fn logDir(self: *Db, dir_path: []const u8) !void { + const abs_path = try self.resolvePath(dir_path); + defer self.allocator.free(abs_path); + + const file = std.fs.cwd().openFile(self.dirs_path, .{ .mode = .write_only }) catch |err| { + if (err == error.FileNotFound) { + _ = try std.fs.cwd().createFile(self.dirs_path, .{ .truncate = false }); + return try self.logDir(dir_path); + } + return err; + }; + defer file.close(); + try file.seekFromEnd(0); + + const now = zul.DateTime.now(); + var ts_buf: [64]u8 = undefined; + var ts_writer = std.io.Writer.fixed(&ts_buf); + try now.format(&ts_writer); + const ts = ts_buf[0..ts_writer.end]; + + var line_buf: [4096]u8 = undefined; + const line = try std.fmt.bufPrint(&line_buf, "{s}|{s}\n", .{ ts, abs_path }); + try file.writeAll(line); + } + + pub fn logFile(self: *Db, file_path: []const u8, file_type: []const u8, action: []const u8) !void { + const abs_path = try self.resolvePath(file_path); + defer self.allocator.free(abs_path); + + const file = std.fs.cwd().openFile(self.files_path, .{ .mode = .write_only }) catch |err| { + if (err == error.FileNotFound) { + _ = try std.fs.cwd().createFile(self.files_path, .{ .truncate = false }); + return try self.logFile(file_path, file_type, action); + } + return err; + }; + defer file.close(); + try file.seekFromEnd(0); + + const now = zul.DateTime.now(); + var ts_buf: [64]u8 = undefined; + var ts_writer = std.io.Writer.fixed(&ts_buf); + try now.format(&ts_writer); + const ts = ts_buf[0..ts_writer.end]; + + var line_buf: [4096]u8 = undefined; + const line = try std.fmt.bufPrint(&line_buf, "{s}|{s}|{s}|{s}\n", .{ ts, file_type, action, abs_path }); + try file.writeAll(line); + } + + pub fn cleanup(self: *Db) !void { + try self.cleanupFile(self.dirs_path, 1); + try self.cleanupFile(self.files_path, 3); + } + + fn cleanupFile(self: *Db, path: []const u8, path_col_idx: usize) !void { + io.warn("Cleaning up {s}...\n", .{path}); + + const file = try std.fs.cwd().openFile(path, .{}); + const content = try file.readToEndAlloc(self.allocator, std.math.maxInt(usize)); + file.close(); + defer self.allocator.free(content); + + var seen = std.StringHashMap(void).init(self.allocator); + defer seen.deinit(); + + var lines = std.ArrayListUnmanaged([]const u8){}; + defer lines.deinit(self.allocator); + + var total_entries: usize = 0; + var missing_entries: usize = 0; + var it = std.mem.splitBackwardsScalar(u8, content, '\n'); + while (it.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (trimmed.len == 0) continue; + total_entries += 1; + + var line_it = std.mem.splitScalar(u8, trimmed, '|'); + var item_path: ?[]const u8 = null; + var i: usize = 0; + while (line_it.next()) |col| : (i += 1) { + if (i == path_col_idx) { + item_path = col; + break; + } + } + + const p = item_path orelse continue; + + if (seen.contains(p)) continue; + + std.fs.cwd().access(p, .{}) catch |err| { + if (err == error.FileNotFound) { + missing_entries += 1; + continue; + } + return err; + }; + + try seen.put(p, {}); + try lines.append(self.allocator, trimmed); + } + + const duplicates = total_entries - lines.items.len - missing_entries; + io.warn(" Entries: {d} total, {d} unique, {d} missing, {d} duplicates removed\n", .{ total_entries, lines.items.len, missing_entries, duplicates }); + + const write_file = try std.fs.cwd().createFile(path, .{ .truncate = true }); + defer write_file.close(); + + var write_buf: [4096]u8 = undefined; + var writer = write_file.writer(&write_buf); + + var i: usize = lines.items.len; + while (i > 0) { + i -= 1; + try writer.interface.print("{s}\n", .{lines.items[i]}); + } + try writer.end(); + } +}; + +pub fn getDefaultDbPath(allocator: std.mem.Allocator) ![]const u8 { + if (std.posix.getenv("HOME")) |home| { + return std.fs.path.join(allocator, &.{ home, ".magdalena" }); + } + return error.HomeNotFound; +} diff --git a/src/fzf.zig b/src/fzf.zig @@ -0,0 +1,557 @@ +const std = @import("std"); + +const cache = @import("memcached.zig"); +const cfg = @import("cfg.zig"); +const hist = @import("db.zig"); +const io = @import("io.zig"); + +fn gotoFile(allocator: std.mem.Allocator, db: ?*hist.Db, file_path: []const u8, line: ?[]const u8, config: *const cfg.Config) !void { + const ext = if (std.mem.lastIndexOfScalar(u8, file_path, '.')) |idx| file_path[idx + 1 ..] else ""; + + var action: []const u8 = "text_editor"; + var command: ?[]const u8 = null; + + for (config.openers) |opener| { + for (opener.extensions) |e| { + if (std.mem.eql(u8, e, ext)) { + action = opener.action; + command = opener.command; + break; + } + } + if (command != null) break; + } + + const term = if (command) |cmd| blk: { + var child = std.process.Child.init(&.{ cmd, file_path }, allocator); + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + break :blk try child.spawnAndWait(); + } else blk: { + const editor = config.editor orelse + std.process.getEnvVarOwned(allocator, "EDITOR") catch |err| switch (err) { + error.EnvironmentVariableNotFound => return error.EditorNotSet, + else => return err, + }; + defer if (config.editor == null) allocator.free(editor); + + if (line) |l| { + const line_arg = try std.fmt.allocPrint(allocator, "+{s}", .{l}); + defer allocator.free(line_arg); + var child = std.process.Child.init(&.{ editor, line_arg, file_path }, allocator); + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + break :blk try child.spawnAndWait(); + } + + var child = std.process.Child.init(&.{ editor, file_path }, allocator); + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + break :blk try child.spawnAndWait(); + }; + + if (term == .Exited and term.Exited == 0) { + if (db) |d| { + try d.logFile(file_path, ext, action); + } + } +} + +const Fzf = struct { + child: std.process.Child, + argv: [][]const u8, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, fzf_opts: []const []const u8, extra_opts: []const []const u8) !Fzf { + var argv = try allocator.alloc([]const u8, 1 + fzf_opts.len + extra_opts.len); + errdefer allocator.free(argv); + + var i: usize = 0; + argv[i] = "fzf"; + i += 1; + for (fzf_opts) |opt| { + argv[i] = opt; + i += 1; + } + for (extra_opts) |opt| { + argv[i] = opt; + i += 1; + } + + var child = std.process.Child.init(argv, allocator); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Inherit; + + return .{ + .child = child, + .argv = argv, + .allocator = allocator, + }; + } + + pub fn deinit(self: *Fzf) void { + self.allocator.free(self.argv); + } +}; + +fn shouldSkip(path: []const u8, ignored_patterns: []const []const u8) bool { + var it = std.mem.splitScalar(u8, path, std.fs.path.sep); + while (it.next()) |component| { + for (ignored_patterns) |pattern| { + if (std.mem.eql(u8, component, pattern)) { + return true; + } + } + } + return false; +} + +fn runFzfPicker(allocator: std.mem.Allocator, fzf_opts: []const []const u8, items: []const []const u8) ![]u8 { + var fzf = try Fzf.init(allocator, fzf_opts, &[_][]const u8{}); + defer fzf.deinit(); + const child = &fzf.child; + try child.spawn(); + + if (child.stdin) |*stdin| { + // Write items in batches to prevent excessive buffer usage + const batch_size = 1000; + for (0..((items.len + batch_size - 1) / batch_size)) |batch_idx| { + const start = batch_idx * batch_size; + const end = @min(start + batch_size, items.len); + + for (start..end) |i| { + stdin.writeAll(items[i]) catch |err| { + if (err == error.BrokenPipe) break; + return err; + }; + stdin.writeAll("\n") catch |err| { + if (err == error.BrokenPipe) break; + return err; + }; + } + } + stdin.close(); + child.stdin = null; + } + + var stdout_buf = std.ArrayListUnmanaged(u8){}; + defer stdout_buf.deinit(allocator); + + if (child.stdout) |stdout| { + var buffer: [1024]u8 = undefined; + while (true) { + const bytes_read = try stdout.read(&buffer); + if (bytes_read == 0) break; + try stdout_buf.appendSlice(allocator, buffer[0..bytes_read]); + } + } + + const term = try child.wait(); + if (term != .Exited or term.Exited != 0) return error.UserAbort; + const selected = std.mem.trim(u8, stdout_buf.items, " \n\r\t"); + if (selected.len == 0) return error.UserAbort; + + return allocator.dupe(u8, selected); +} + +pub fn recentDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { + const managed_dirs = try db.recentDirs(); + defer managed_dirs.deinit(); + const dirs = managed_dirs.value; + + if (dirs.len == 0) { + io.warn("No recent directories found.\n", .{}); + return; + } + + var items = std.ArrayListUnmanaged([]const u8){}; + defer items.deinit(allocator); + + for (dirs) |dir| { + if (shouldSkip(dir.path, config.ignored_patterns)) continue; + try items.append(allocator, dir.path); + } + + const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); + defer allocator.free(selected); + + std.fs.accessAbsolute(selected, .{}) catch |err| { + io.warn("Selected directory no longer exists: {s} ({})\n", .{ selected, err }); + return error.UserAbort; + }; + + var out_buf: [4096]u8 = undefined; + var out = io.getStdout(&out_buf); + try out.print("{s}\n", .{selected}); + try out.end(); +} + +pub fn favDirs(allocator: std.mem.Allocator, config: *const cfg.Config) !void { + if (config.fav_dirs.len == 0) { + io.warn("No favorite directories found in config.\n", .{}); + return; + } + + var items = std.ArrayListUnmanaged([]const u8){}; + defer { + for (items.items) |item| allocator.free(item); + items.deinit(allocator); + } + + for (config.fav_dirs) |dir| { + const expanded = try expandPath(allocator, dir); + try items.append(allocator, expanded); + } + + const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); + defer allocator.free(selected); + + var out_buf: [4096]u8 = undefined; + var out = io.getStdout(&out_buf); + try out.print("{s}\n", .{selected}); + try out.end(); +} + +fn expandPath(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { + var res = std.ArrayListUnmanaged(u8){}; + errdefer res.deinit(allocator); + + var i: usize = 0; + while (i < path.len) { + if (std.mem.startsWith(u8, path[i..], "$HOME")) { + const home = std.posix.getenv("HOME") orelse "/home/user"; + try res.appendSlice(allocator, home); + i += 5; + } else { + try res.append(allocator, path[i]); + i += 1; + } + } + + return res.toOwnedSlice(allocator); +} + +pub fn recentFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { + const managed_files = try db.recentFiles(); + defer managed_files.deinit(); + const files = managed_files.value; + + if (files.len == 0) { + io.warn("No recent files found.\n", .{}); + return; + } + + var items = std.ArrayListUnmanaged([]const u8){}; + defer items.deinit(allocator); + + for (files) |file| { + if (shouldSkip(file.path, config.ignored_patterns)) continue; + try items.append(allocator, file.path); + } + + const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); + defer allocator.free(selected); + + try std.fs.accessAbsolute(selected, .{}); + + try gotoFile(allocator, db, selected, null, config); +} + +pub fn grep(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { + const initial_query = ""; + + var rg_cmd = std.ArrayListUnmanaged(u8){}; + defer rg_cmd.deinit(allocator); + try rg_cmd.appendSlice(allocator, "change:reload:rg --column --line-number --no-heading --color=always --smart-case"); + for (config.ignored_patterns) |pattern| { + try rg_cmd.appendSlice(allocator, " --glob '!"); + try rg_cmd.appendSlice(allocator, pattern); + try rg_cmd.appendSlice(allocator, "/*'"); + try rg_cmd.appendSlice(allocator, " --glob '!"); + try rg_cmd.appendSlice(allocator, pattern); + try rg_cmd.appendSlice(allocator, "'"); + } + try rg_cmd.appendSlice(allocator, " {q} || true"); + + var fzf = try Fzf.init(allocator, config.fzf_opts, &[_][]const u8{ + "--disabled", + "--query", + initial_query, + "--bind", + rg_cmd.items, + "--delimiter", + ":", + "--preview", + "bat --highlight-line {2} {1}", + "--preview-window", + "right,60%,border-left,+{2}+3/3,~3", + }); + defer fzf.deinit(); + const child = &fzf.child; + + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Inherit; + + try child.spawn(); + + var stdout_buf = std.ArrayListUnmanaged(u8){}; + defer stdout_buf.deinit(allocator); + + if (child.stdout) |stdout| { + var buffer: [1024]u8 = undefined; + while (true) { + const bytes_read = try stdout.read(&buffer); + if (bytes_read == 0) break; + try stdout_buf.appendSlice(allocator, buffer[0..bytes_read]); + } + } + + const term = try child.wait(); + if (term != .Exited or term.Exited != 0) return error.UserAbort; + const selected = std.mem.trim(u8, stdout_buf.items, " \n\r\t"); + if (selected.len == 0) return error.UserAbort; + + var it = std.mem.splitScalar(u8, selected, ':'); + if (it.next()) |file| { + const line = it.next(); + try gotoFile(allocator, db, file, line, config); + } +} + +pub fn lookFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void { + try runFzfWithWalker(allocator, db, .file, config, depth); +} + +pub fn lookDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void { + try runFzfWithWalker(allocator, db, .directory, config, depth); +} + +const Entry = struct { + path: []const u8, + mtime: i128, +}; + +fn getMaxDirMtime(allocator: std.mem.Allocator, ignored_patterns: []const []const u8, max_depth: usize) !i128 { + var max_mtime: i128 = 0; + + if (std.fs.cwd().statFile(".")) |stat| { + max_mtime = stat.mtime; + } else |_| {} + + const StackEntry = struct { + dir: std.fs.Dir, + depth: usize, + }; + var stack = std.ArrayListUnmanaged(StackEntry){}; + defer { + for (stack.items) |*se| se.dir.close(); + stack.deinit(allocator); + } + + try stack.append(allocator, .{ .dir = try std.fs.cwd().openDir(".", .{ .iterate = true }), .depth = 0 }); + + while (stack.items.len > 0) { + var current = stack.pop() orelse unreachable; + defer current.dir.close(); + + var it = current.dir.iterate(); + while (try it.next()) |entry| { + var skip = false; + for (ignored_patterns) |pattern| { + if (std.mem.eql(u8, entry.name, pattern)) { + skip = true; + break; + } + } + if (skip) continue; + + const is_dir = entry.kind == .directory; + if (is_dir or entry.kind == .sym_link) { + const stat = current.dir.statFile(entry.name) catch |err| { + if (err != error.FileNotFound and err != error.AccessDenied) { + io.warn("failed to stat {s}: {}\n", .{ entry.name, err }); + } + continue; + }; + if (stat.kind == .directory) { + if (stat.mtime > max_mtime) max_mtime = stat.mtime; + + if (is_dir and current.depth + 1 < max_depth) { + const sub_dir = current.dir.openDir(entry.name, .{ .iterate = true }) catch |err| { + io.warn("failed to open directory {s}: {}\n", .{ entry.name, err }); + continue; + }; + try stack.append(allocator, .{ .dir = sub_dir, .depth = current.depth + 1 }); + } + } + } + } + } + + return max_mtime; +} + +fn runFzfWithWalker(allocator: std.mem.Allocator, db: *hist.Db, kind: std.fs.File.Kind, config: *const cfg.Config, depth: ?usize) !void { + const is_dir = kind == .directory; + const max_depth = depth orelse config.max_depth; + + var entries = std.ArrayListUnmanaged(Entry){}; + defer { + for (entries.items) |e| allocator.free(e.path); + entries.deinit(allocator); + } + + var mc = cache.Memcached.init(allocator, "127.0.0.1", 11211) catch |err| blk: { + if (err != error.ConnectionRefused) { + io.warn("failed to initialize memcached: {}\n", .{err}); + } + break :blk null; + }; + defer if (mc) |*m| m.deinit(); + + var cache_key: ?[]const u8 = null; + defer if (cache_key) |k| allocator.free(k); + + var cache_hit = false; + if (mc) |*m| { + const cwd = blk: { + break :blk std.process.getCwdAlloc(allocator) catch |err| { + io.warn("failed to get cwd for cache: {}\n", .{err}); + break :blk null; + }; + }; + if (cwd) |c| { + defer allocator.free(c); + + const hash = std.hash.Crc32.hash(c); + const max_dir_mtime = getMaxDirMtime(allocator, config.ignored_patterns, max_depth) catch blk: { + break :blk @as(i128, 0); + }; + const mtime = @divTrunc(max_dir_mtime, std.time.ns_per_s); + cache_key = try std.fmt.allocPrint(allocator, "magdalena:look:{s}:{x}:{d}:{d}", .{ if (is_dir) "dir" else "file", hash, mtime, max_depth }); + if (cache_key) |key| { + const cached_data = try m.get(key); + if (cached_data) |data| { + defer allocator.free(data); + const parsed = try std.json.parseFromSlice([]Entry, allocator, data, .{}); + defer parsed.deinit(); + for (parsed.value) |e| { + try entries.append(allocator, .{ + .path = try allocator.dupe(u8, e.path), + .mtime = e.mtime, + }); + } + cache_hit = true; + } + } + } + } + + if (!cache_hit) { + const StackEntry = struct { + dir: std.fs.Dir, + path: ?[]const u8, + depth: usize, + }; + var stack = std.ArrayListUnmanaged(StackEntry){}; + defer { + for (stack.items) |*se| se.dir.close(); + stack.deinit(allocator); + } + + try stack.append(allocator, .{ .dir = try std.fs.cwd().openDir(".", .{ .iterate = true }), .path = null, .depth = 0 }); + + while (stack.items.len > 0) { + var current = stack.pop() orelse unreachable; + defer { + current.dir.close(); + if (current.path) |p| allocator.free(p); + } + + var it = current.dir.iterate(); + while (try it.next()) |entry| { + const entry_path = if (current.path) |p| + try std.fs.path.join(allocator, &.{ p, entry.name }) + else + try allocator.dupe(u8, entry.name); + errdefer allocator.free(entry_path); + + if (shouldSkip(entry_path, config.ignored_patterns)) { + allocator.free(entry_path); + continue; + } + + if (entry.kind == kind or entry.kind == .sym_link) { + if (current.dir.statFile(entry.name)) |stat| { + if (stat.kind == kind) { + try entries.append(allocator, .{ + .path = try allocator.dupe(u8, entry_path), + .mtime = stat.mtime, + }); + } + } else |err| { + if (err != error.FileNotFound and err != error.AccessDenied) { + io.warn("failed to stat {s}: {}\n", .{ entry.name, err }); + } + } + } + + if (entry.kind == .directory and current.depth + 1 < max_depth) { + const sub_dir = current.dir.openDir(entry.name, .{ .iterate = true }) catch |err| { + io.warn("failed to open directory {s}: {}\n", .{ entry.name, err }); + allocator.free(entry_path); + continue; + }; + try stack.append(allocator, .{ .dir = sub_dir, .path = entry_path, .depth = current.depth + 1 }); + } else { + allocator.free(entry_path); + } + } + } + + if (mc) |*m| { + if (cache_key) |key| { + const json_data = try std.json.Stringify.valueAlloc(allocator, entries.items, .{}); + defer allocator.free(json_data); + try m.set(key, json_data, 3600); + } + } + } + + std.mem.sortUnstable(Entry, entries.items, {}, struct { + fn lessThan(_: void, a: Entry, b: Entry) bool { + return a.mtime > b.mtime; + } + }.lessThan); + + var items = std.ArrayListUnmanaged([]const u8){}; + defer items.deinit(allocator); + for (entries.items) |entry| { + try items.append(allocator, entry.path); + } + + const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); + defer allocator.free(selected); + + const clean_path = if (std.mem.startsWith(u8, selected, "./")) selected[2..] else selected; + + if (clean_path.len > 0) { + std.fs.cwd().access(clean_path, .{}) catch |err| { + io.warn("Selected item no longer exists or is inaccessible: {s} ({})\n", .{ clean_path, err }); + return error.UserAbort; + }; + if (is_dir) { + var out_buf: [4096]u8 = undefined; + var out = io.getStdout(&out_buf); + try out.print("{s}\n", .{clean_path}); + try out.end(); + } else { + try gotoFile(allocator, db, clean_path, null, config); + } + } +} diff --git a/src/io.zig b/src/io.zig @@ -0,0 +1,48 @@ +const std = @import("std"); + +pub const Writer = struct { + inner: std.fs.File.Writer, + failed: bool = false, + + pub fn print(self: *Writer, comptime fmt: []const u8, args: anytype) !void { + if (self.failed) return; + self.inner.interface.print(fmt, args) catch |err| { + if (err == error.WriteFailed) { + self.failed = true; + return; + } + return err; + }; + } + + pub fn end(self: *Writer) !void { + if (self.failed) return; + self.inner.end() catch |err| { + if (err == error.WriteFailed) { + self.failed = true; + return; + } + return err; + }; + } +}; + +pub fn getStdout(buf: []u8) Writer { + return .{ .inner = std.fs.File.stdout().writer(buf) }; +} + +pub fn warn(comptime fmt: []const u8, args: anytype) void { + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stderr().writer(&buf); + writer.interface.print(fmt, args) catch |err| { + std.log.warn("failed to write to stderr: {}", .{err}); + }; + writer.end() catch |err| { + std.log.warn("failed to end stderr writer: {}", .{err}); + }; +} + +pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + warn(fmt ++ "\n", args); + std.process.exit(1); +} diff --git a/src/main.zig b/src/main.zig @@ -0,0 +1,117 @@ +const std = @import("std"); +const cli = @import("cli.zig"); +const cfg = @import("cfg.zig"); +const fzf = @import("fzf.zig"); +const hist = @import("db.zig"); +const io = @import("io.zig"); + +pub fn main() void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const args = cli.parseArgs(allocator) catch |err| { + io.fatal("failed to parse arguments: {}", .{err}); + }; + defer args.deinit(); + + if (args.action == .help) { + cli.printUsage() catch |err| { + io.fatal("failed to print usage: {}", .{err}); + }; + return; + } + + const parsed_config = cfg.loadConfig(allocator); + defer if (parsed_config) |c| c.deinit(); + const default_config = cfg.Config{}; + const config = if (parsed_config) |c| c.value else default_config; + + const db_path = hist.getDefaultDbPath(allocator) catch |err| { + io.fatal("failed to get database path: {}", .{err}); + }; + defer allocator.free(db_path); + + var db = hist.Db.init(allocator, db_path) catch |err| { + io.fatal("failed to initialize database: {}", .{err}); + }; + defer db.deinit(); + + runAction(allocator, &db, args, &config) catch |err| { + if (err == error.UserAbort) { + std.process.exit(1); + } + io.fatal("action failed: {}", .{err}); + }; +} + +fn runAction(allocator: std.mem.Allocator, db: *hist.Db, args: cli.Args, config: *const cfg.Config) !void { + var buf: [4096]u8 = undefined; + var writer = io.getStdout(&buf); + + switch (args.action) { + .recent_dirs => { + const managed = try db.recentDirs(); + defer managed.deinit(); + for (managed.value) |d| { + try writer.print("{s}|{s}\n", .{ d.timestamp orelse "-", d.path }); + } + }, + .recent_files => { + const managed = try db.recentFiles(); + defer managed.deinit(); + for (managed.value) |f| { + try writer.print("{s}|{s}|{s}|{s}\n", .{ f.timestamp orelse "-", f.file_type, f.action, f.path }); + } + }, + .fav_dirs => { + try fzf.favDirs(allocator, config); + }, + .search => { + if (args.query) |query| { + const managed = try db.searchHistory(query); + defer managed.deinit(); + const results = managed.value; + + for (results.directories) |d| { + try writer.print("D|{s}\n", .{d.path}); + } + for (results.files) |f| { + try writer.print("F|{s}|{s}|{s}\n", .{ f.file_type, f.action, f.path }); + } + } + }, + .goto_dir => { + try fzf.recentDir(allocator, db, config); + }, + .goto_file => { + try fzf.recentFile(allocator, db, config); + }, + .look_file => { + try fzf.lookFile(allocator, db, config, args.depth); + }, + .look_dir => { + try fzf.lookDir(allocator, db, config, args.depth); + }, + .grep => { + try fzf.grep(allocator, db, config); + }, + .cleanup => { + try db.cleanup(); + }, + .log_dir => { + if (args.log_path) |path| { + try db.logDir(path); + } + }, + .log_file => { + if (args.log_path) |path| { + try db.logFile(path, args.file_type orelse "other", args.file_action orelse "open"); + } + }, + .help => { + try cli.printUsage(); + }, + } + try writer.end(); +} diff --git a/src/memcached.zig b/src/memcached.zig @@ -0,0 +1,230 @@ +const std = @import("std"); +const io = @import("io.zig"); + +pub const Memcached = struct { + allocator: std.mem.Allocator, + stream: std.net.Stream, + write_buf: [4096]u8 = undefined, + write_end: usize = 0, + read_buf: [65536]u8 = undefined, + read_start: usize = 0, + read_end: usize = 0, + + pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16) !Memcached { + const address = try std.net.Address.parseIp4(host, port); + const stream = try std.net.tcpConnectToAddress(address); + return .{ + .allocator = allocator, + .stream = stream, + }; + } + + pub fn deinit(self: *Memcached) void { + self.flushWrite() catch |err| { + io.warn("failed to flush memcached on deinit: {}\n", .{err}); + }; + self.stream.close(); + } + + fn flushWrite(self: *Memcached) !void { + if (self.write_end == 0) return; + try self.stream.writeAll(self.write_buf[0..self.write_end]); + self.write_end = 0; + } + + fn bufferedWrite(self: *Memcached, data: []const u8) !void { + if (data.len >= self.write_buf.len) { + try self.flushWrite(); + try self.stream.writeAll(data); + return; + } + const available = self.write_buf.len - self.write_end; + if (data.len > available) { + try self.flushWrite(); + } + @memcpy(self.write_buf[self.write_end..][0..data.len], data); + self.write_end += data.len; + } + + const chunk_size = 900 * 1024; + const chunk_prefix = "\x00chunked:"; + + pub fn set(self: *Memcached, key: []const u8, value: []const u8, exptime: u32) !void { + if (value.len <= chunk_size) { + try self.sendSet(key, value, exptime); + return; + } + + const num_chunks = (value.len + chunk_size - 1) / chunk_size; + var meta_buf: [128]u8 = undefined; + const meta = try std.fmt.bufPrint(&meta_buf, chunk_prefix ++ "{d}:{d}", .{ num_chunks, value.len }); + try self.sendSet(key, meta, exptime); + + var i: usize = 0; + while (i < num_chunks) : (i += 1) { + const start = i * chunk_size; + const end = @min((i + 1) * chunk_size, value.len); + var chunk_key_buf: [512]u8 = undefined; + const chunk_key = try std.fmt.bufPrint(&chunk_key_buf, "{s}:{d}", .{ key, i }); + try self.sendSet(chunk_key, value[start..end], exptime); + } + } + + fn sendSet(self: *Memcached, key: []const u8, value: []const u8, exptime: u32) !void { + var header_buf: [512]u8 = undefined; + const header = try std.fmt.bufPrint(&header_buf, "ms {s} {d} T{d} q\r\n", .{ key, value.len, exptime }); + + try self.bufferedWrite(header); + if (value.len > 0) { + try self.bufferedWrite(value); + } + try self.bufferedWrite("\r\n"); + } + + fn readLine(self: *Memcached, buf: []u8) !?[]const u8 { + while (true) { + if (self.read_start < self.read_end) { + if (std.mem.indexOfScalar(u8, self.read_buf[self.read_start..self.read_end], '\n')) |newline_offset| { + const line_start = self.read_start; + const line_end = self.read_start + newline_offset; + self.read_start = line_end + 1; + const line = self.read_buf[line_start..line_end]; + const trimmed = std.mem.trim(u8, line, "\r"); + if (buf.len < trimmed.len) return error.BufferTooSmall; + @memcpy(buf[0..trimmed.len], trimmed); + return buf[0..trimmed.len]; + } + } + if (self.read_start > 0) { + const remaining = self.read_end - self.read_start; + @memcpy(self.read_buf[0..remaining], self.read_buf[self.read_start..self.read_end]); + self.read_end = remaining; + self.read_start = 0; + } + if (self.read_end >= self.read_buf.len) return error.LineTooLong; + const n = try self.stream.read(self.read_buf[self.read_end..]); + if (n == 0) return null; + self.read_end += n; + } + } + + fn readExact(self: *Memcached, buf: []u8) !void { + var offset: usize = 0; + while (offset < buf.len) { + if (self.read_start < self.read_end) { + const available = self.read_end - self.read_start; + const needed = buf.len - offset; + const to_copy = @min(available, needed); + @memcpy(buf[offset..][0..to_copy], self.read_buf[self.read_start..][0..to_copy]); + self.read_start += to_copy; + offset += to_copy; + continue; + } + self.read_start = 0; + self.read_end = 0; + const remaining = buf.len - offset; + if (remaining >= self.read_buf.len) { + const n = try self.stream.read(buf[offset..]); + if (n == 0) return error.IncompleteRead; + offset += n; + } else { + const n = try self.stream.read(self.read_buf[0..]); + if (n == 0) return error.IncompleteRead; + self.read_end = n; + } + } + } + + fn consumeCrlf(self: *Memcached) void { + if (self.read_start < self.read_end) { + const available = self.read_end - self.read_start; + if (available >= 2 and self.read_buf[self.read_start] == '\r' and self.read_buf[self.read_start + 1] == '\n') { + self.read_start += 2; + return; + } + if (available >= 1 and self.read_buf[self.read_start] == '\n') { + self.read_start += 1; + return; + } + } + var discard: [2]u8 = undefined; + _ = self.stream.read(&discard) catch return; + } + + pub fn get(self: *Memcached, key: []const u8) !?[]u8 { + try self.flushWrite(); + var cmd_buf: [512]u8 = undefined; + const cmd = try std.fmt.bufPrint(&cmd_buf, "mg {s} v\r\n", .{key}); + try self.stream.writeAll(cmd); + + var line_buf: [512]u8 = undefined; + const line = (try self.readLine(&line_buf)) orelse return null; + + if (std.mem.startsWith(u8, line, "VA ")) { + const rest = line[3..]; + const space_idx = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len; + const size = try std.fmt.parseInt(usize, rest[0..space_idx], 10); + + const data = try self.allocator.alloc(u8, size); + errdefer self.allocator.free(data); + + if (size > 0) { + try self.readExact(data); + } + + self.consumeCrlf(); + + if (std.mem.startsWith(u8, data, chunk_prefix)) { + defer self.allocator.free(data); + return try self.reassembleChunks(key, data); + } + + return data; + } + + return null; + } + + fn reassembleChunks(self: *Memcached, key: []const u8, meta: []const u8) ![]u8 { + var it = std.mem.splitScalar(u8, meta[chunk_prefix.len..], ':'); + const num_chunks_str = it.next() orelse return error.InvalidCacheData; + const total_size_str = it.next() orelse return error.InvalidCacheData; + + const num_chunks = try std.fmt.parseInt(usize, num_chunks_str, 10); + const total_size = try std.fmt.parseInt(usize, total_size_str, 10); + + const result = try self.allocator.alloc(u8, total_size); + errdefer self.allocator.free(result); + + var i: usize = 0; + while (i < num_chunks) : (i += 1) { + var chunk_key_buf: [512]u8 = undefined; + const chunk_key = try std.fmt.bufPrint(&chunk_key_buf, "{s}:{d}", .{ key, i }); + + var cmd_buf: [512]u8 = undefined; + const cmd = try std.fmt.bufPrint(&cmd_buf, "mg {s} v\r\n", .{chunk_key}); + try self.stream.writeAll(cmd); + + var line_buf: [512]u8 = undefined; + const line = (try self.readLine(&line_buf)) orelse return error.CacheChunkMissing; + + if (!std.mem.startsWith(u8, line, "VA ")) return error.CacheChunkMissing; + + const rest = line[3..]; + const space_idx = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len; + const size = try std.fmt.parseInt(usize, rest[0..space_idx], 10); + + const chunk_start = i * chunk_size; + const chunk_end = @min((i + 1) * chunk_size, total_size); + if (size != chunk_end - chunk_start) return error.InvalidChunkSize; + + if (size > 0) { + try self.readExact(result[chunk_start..chunk_end]); + } + + self.consumeCrlf(); + } + + return result; + } +}; diff --git a/themes/bat/magdalena.tmTheme b/themes/bat/magdalena.tmTheme @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>name</key> + <string>magdalena</string> + <key>settings</key> + <array> + <dict> + <key>settings</key> + <dict> + <key>background</key> + <string>#2a1f2e</string> + <key>divider</key> + <string>#3d2a42</string> + <key>foreground</key> + <string>#c8b8bf</string> + <key>caret</key> + <string>#f0dfe5</string> + <key>invisibles</key> + <string>#3d2a42</string> + <key>lineHighlight</key> + <string>#3d2a42</string> + <key>selection</key> + <string>#c084b8</string> + <key>selectionForeground</key> + <string>#f0dfe5</string> + </dict> + </dict> + <dict> + <key>name</key> + <string>Comments</string> + <key>scope</key> + <string>comment</string> + <key>settings</key> + <dict> + <key>foreground</key> + <string>#7a6e7a</string> + </dict> + </dict> + <dict> + <key>name</key> + <string>Keywords</string> + <key>scope</key> + <string>keyword, storage.type, storage.modifier</string> + <key>settings</key> + <dict> + <key>foreground</key> + <string>#c084b8</string> + </dict> + </dict> + <dict> + <key>name</key> + <string>Strings</string> + <key>scope</key> + <string>string</string> + <key>settings</key> + <dict> + <key>foreground</key> + <string>#e08060</string> + </dict> + </dict> + <dict> + <key>name</key> + <string>Functions</string> + <key>scope</key> + <string>entity.name.function, support.function</string> + <key>settings</key> + <dict> + <key>foreground</key> + <string>#7a9fd4</string> + </dict> + </dict> + <dict> + <key>name</key> + <string>Types</string> + <key>scope</key> + <string>entity.name.type, entity.other.inherited-class, support.type, support.class</string> + <key>settings</key> + <dict> + <key>foreground</key> + <string>#c4607a</string> + </dict> + </dict> + </array> +</dict> +</plist>