esc

Externally Scriptable Editor

git clone git://mccd.space/esc

commit f9ff11d6c910dc2551fb92ab0e66656b7a782a00
parent 46f5c673e7eba7f5bec9a4f7c55ef753607282dc
Author: Marc Coquand <marc@coquand.email>
Date:   Mon, 23 Feb 2026 16:29:50 +0100

*

Diffstat:
Mfuse_ipc.c | 251++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mmain.c | 13+++++++++++++
2 files changed, 225 insertions(+), 39 deletions(-)
diff --git a/fuse_ipc.c b/fuse_ipc.c
@@ -20,9 +20,22 @@ typedef struct {
 	size_t cap;
 } WriteBuffer;
 
+/*
+ * In-memory temp file slot.  Used for the create→write→rename workflow
+ * that tools like `sed -i` perform.  Slots are identified by FUSE path;
+ * an empty path[0] means the slot is free.
+ */
+#define MAX_TEMP_FILES 8
+typedef struct {
+	char   path[256];
+	char  *data;
+	size_t len;
+} TempFile;
+
 /* FUSE private data passed as private_data to fuse_new */
 typedef struct {
-	Editor *ed;
+	Editor   *ed;
+	TempFile  temps[MAX_TEMP_FILES];
 } FuseCtx;
 
 struct FuseIPC {
@@ -38,6 +51,49 @@ static FuseCtx *get_ctx(void)
 	return (FuseCtx *)fuse_get_context()->private_data;
 }
 
+/* ---- temp-file helpers ------------------------------------------------ */
+
+static TempFile *find_temp(FuseCtx *ctx, const char *path)
+{
+	for (int i = 0; i < MAX_TEMP_FILES; i++) {
+		if (ctx->temps[i].path[0] &&
+		    strcmp(ctx->temps[i].path, path) == 0)
+			return &ctx->temps[i];
+	}
+	return NULL;
+}
+
+static TempFile *alloc_temp(FuseCtx *ctx, const char *path)
+{
+	for (int i = 0; i < MAX_TEMP_FILES; i++) {
+		if (!ctx->temps[i].path[0]) {
+			strncpy(ctx->temps[i].path, path,
+				sizeof(ctx->temps[i].path) - 1);
+			ctx->temps[i].data = NULL;
+			ctx->temps[i].len  = 0;
+			return &ctx->temps[i];
+		}
+	}
+	return NULL;
+}
+
+static void free_temp(TempFile *tf)
+{
+	free(tf->data);
+	tf->data   = NULL;
+	tf->len    = 0;
+	tf->path[0] = '\0';
+}
+
+/* Push a wakeup event so SDL_WaitEvent unblocks and the frame is redrawn. */
+static void wake_render(void)
+{
+	SDL_Event ev;
+	memset(&ev, 0, sizeof(ev));
+	ev.type = SDL_EVENT_USER;
+	SDL_PushEvent(&ev);
+}
+
 /* ---- path helpers ---------------------------------------------------- */
 
 static int is_dir(const char *path)
@@ -121,31 +177,42 @@ static int op_getattr(const char *path, struct stat *st)
 		return 0;
 	}
 
-	if (!is_file(path))
-		return -ENOENT;
+	if (is_file(path)) {
+		st->st_mode  = S_IFREG | 0644;
+		st->st_nlink = 1;
 
-	st->st_mode  = S_IFREG | 0644;
-	st->st_nlink = 1;
+		editor_lock(ed);
 
-	editor_lock(ed);
+		if (strcmp(path, "/cursor") == 0) {
+			char tmp[64];
+			st->st_size = snprintf(tmp, sizeof(tmp), "%d %d\n",
+				ed->cursor_idx, ed->selection_anchor);
+		} else if (strcmp(path, "/buffer/0/body") == 0) {
+			st->st_size = (off_t)ed->text.len;
+		} else if (strcmp(path, "/buffer/0/path") == 0) {
+			st->st_size = ed->filename
+				? (off_t)strlen(ed->filename) : 0;
+		} else if (strcmp(path, "/buffer/0/ranges") == 0) {
+			size_t sz;
+			char  *tmp = build_ranges(ed, &sz);
+			st->st_size = (off_t)sz;
+			free(tmp);
+		}
 
-	if (strcmp(path, "/cursor") == 0) {
-		char tmp[64];
-		st->st_size = snprintf(tmp, sizeof(tmp), "%d %d\n",
-			ed->cursor_idx, ed->selection_anchor);
-	} else if (strcmp(path, "/buffer/0/body") == 0) {
-		st->st_size = (off_t)ed->text.len;
-	} else if (strcmp(path, "/buffer/0/path") == 0) {
-		st->st_size = ed->filename ? (off_t)strlen(ed->filename) : 0;
-	} else if (strcmp(path, "/buffer/0/ranges") == 0) {
-		size_t sz;
-		char  *tmp = build_ranges(ed, &sz);
-		st->st_size = (off_t)sz;
-		free(tmp);
+		editor_unlock(ed);
+		return 0;
 	}
 
-	editor_unlock(ed);
-	return 0;
+	/* In-flight or committed temp files */
+	TempFile *tf = find_temp(ctx, path);
+	if (tf) {
+		st->st_mode  = S_IFREG | 0644;
+		st->st_nlink = 1;
+		st->st_size  = (off_t)tf->len;
+		return 0;
+	}
+
+	return -ENOENT;
 }
 
 static int op_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
@@ -179,8 +246,13 @@ static int op_open(const char *path, struct fuse_file_info *fi)
 {
 	fi->fh = 0;
 
-	if (!is_file(path))
+	if (!is_file(path)) {
+		/* Allow opening an existing temp file for reading */
+		FuseCtx *ctx = get_ctx();
+		if (find_temp(ctx, path))
+			return 0;
 		return -ENOENT;
+	}
 
 	if (!is_writable(path)) {
 		if ((fi->flags & O_ACCMODE) != O_RDONLY)
@@ -199,6 +271,37 @@ static int op_open(const char *path, struct fuse_file_info *fi)
 	return 0;
 }
 
+/*
+ * op_create — called when a new file is created (e.g. sed -i's temp file).
+ * We only allow creation inside /buffer/0/ and reject known permanent names.
+ */
+static int op_create(const char *path, mode_t mode,
+		     struct fuse_file_info *fi)
+{
+	(void)mode;
+	FuseCtx *ctx = get_ctx();
+
+	if (is_file(path) || is_dir(path))
+		return -EEXIST;
+
+	if (strncmp(path, "/buffer/0/", 10) != 0)
+		return -EACCES;
+
+	TempFile *tf = find_temp(ctx, path);
+	if (!tf) {
+		tf = alloc_temp(ctx, path);
+		if (!tf)
+			return -ENOSPC;
+	}
+
+	WriteBuffer *wb = calloc(1, sizeof(WriteBuffer));
+	if (!wb)
+		return -ENOMEM;
+
+	fi->fh = (uint64_t)(uintptr_t)wb;
+	return 0;
+}
+
 static int op_read(const char *path, char *buf, size_t size, off_t offset,
 		   struct fuse_file_info *fi)
 {
@@ -305,6 +408,10 @@ static int op_truncate(const char *path, off_t size)
 	/* Accept truncate on writable files; we reset on open anyway */
 	if (is_writable(path))
 		return 0;
+	/* Also accept on temp files */
+	FuseCtx *ctx = get_ctx();
+	if (find_temp(ctx, path))
+		return 0;
 	return -EACCES;
 }
 
@@ -320,26 +427,47 @@ static int op_ftruncate(const char *path, off_t size,
 	return 0;
 }
 
+/* Apply content to the editor and wake the render loop. Caller holds no lock. */
+static void apply_body(Editor *ed, const char *data, size_t len)
+{
+	editor_lock(ed);
+	ed->ranges_count     = 0;
+	strbuf_delete(&ed->text, 0, ed->text.len);
+	strbuf_insert(&ed->text, 0, data ? data : "", len);
+	ed->cursor_idx       = 0;
+	ed->selection_anchor = 0;
+	editor_unlock(ed);
+	editor_parse_ansi_codes(ed);
+	wake_render();
+}
+
 static int op_release(const char *path, struct fuse_file_info *fi)
 {
 	FuseCtx *ctx = get_ctx();
 	Editor  *ed  = ctx->ed;
 
 	if (!fi->fh)
-		return 0; /* read-only open — nothing to apply */
+		return 0;
 
 	WriteBuffer *wb = (WriteBuffer *)(uintptr_t)fi->fh;
+	fi->fh = 0;
+
+	/*
+	 * Temp file: save the accumulated data into the TempFile slot so
+	 * op_rename can pick it up.  Do NOT apply to the editor yet.
+	 */
+	TempFile *tf = find_temp(ctx, path);
+	if (tf) {
+		free(tf->data);
+		tf->data = wb->data; /* steal */
+		tf->len  = wb->len;
+		wb->data = NULL;
+		free(wb);
+		return 0;
+	}
 
 	if (strcmp(path, "/buffer/0/body") == 0) {
-		editor_lock(ed);
-		ed->ranges_count    = 0;
-		strbuf_delete(&ed->text, 0, ed->text.len);
-		strbuf_insert(&ed->text, 0,
-			wb->data ? wb->data : "", wb->len);
-		ed->cursor_idx      = 0;
-		ed->selection_anchor = 0;
-		editor_unlock(ed);
-		editor_parse_ansi_codes(ed);
+		apply_body(ed, wb->data, wb->len);
 
 	} else if (strcmp(path, "/cursor") == 0 &&
 		   wb->data && wb->len > 0) {
@@ -355,11 +483,40 @@ static int op_release(const char *path, struct fuse_file_info *fi)
 		ed->selection_anchor = anchor;
 		ed->cursor_idx       = cursor_pos;
 		editor_unlock(ed);
+		wake_render();
 	}
 
 	free(wb->data);
 	free(wb);
-	fi->fh = 0;
+	return 0;
+}
+
+/*
+ * op_rename — the final step of `sed -i`: rename the temp file over body.
+ */
+static int op_rename(const char *from, const char *to)
+{
+	FuseCtx *ctx = get_ctx();
+	Editor  *ed  = ctx->ed;
+
+	TempFile *tf = find_temp(ctx, from);
+	if (!tf)
+		return -ENOENT;
+
+	if (strcmp(to, "/buffer/0/body") == 0)
+		apply_body(ed, tf->data, tf->len);
+
+	free_temp(tf);
+	return 0;
+}
+
+static int op_unlink(const char *path)
+{
+	FuseCtx *ctx = get_ctx();
+	TempFile *tf = find_temp(ctx, path);
+	if (!tf)
+		return -ENOENT;
+	free_temp(tf);
 	return 0;
 }
 
@@ -377,11 +534,14 @@ static const struct fuse_operations ops = {
 	.getattr   = op_getattr,
 	.readdir   = op_readdir,
 	.open      = op_open,
+	.create    = op_create,
 	.read      = op_read,
 	.write     = op_write,
 	.truncate  = op_truncate,
 	.ftruncate = op_ftruncate,
 	.release   = op_release,
+	.rename    = op_rename,
+	.unlink    = op_unlink,
 };
 
 /* ---- Public API ------------------------------------------------------ */
@@ -409,12 +569,25 @@ FuseIPC *fuse_ipc_start(Editor *ed)
 
 	snprintf(ipc->mountpoint, sizeof(ipc->mountpoint),
 		"%s/esc/%d", base, (int)getpid());
-	if (mkdir(ipc->mountpoint, 0700) != 0 && errno != EEXIST) {
-		SDL_Log("fuse_ipc: mkdir %s failed: %s",
-			ipc->mountpoint, strerror(errno));
-		free(ctx);
-		free(ipc);
-		return NULL;
+
+	if (mkdir(ipc->mountpoint, 0700) != 0) {
+		if (errno == EEXIST) {
+			/*
+			 * Stale mount from a previous crash — detach it so
+			 * we can reuse the directory.
+			 */
+			char cmd[512];
+			snprintf(cmd, sizeof(cmd),
+				"fusermount -u -- %s 2>/dev/null",
+				ipc->mountpoint);
+			system(cmd); /* ignore errors */
+		} else {
+			SDL_Log("fuse_ipc: mkdir %s failed: %s",
+				ipc->mountpoint, strerror(errno));
+			free(ctx);
+			free(ipc);
+			return NULL;
+		}
 	}
 
 	struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
diff --git a/main.c b/main.c
@@ -12,6 +12,7 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <signal.h>
 #include <unistd.h>
 
 void handle_events(Editor *ed, SDL_Renderer *renderer, float *scroll_x,
@@ -508,11 +509,23 @@ void render_editor(SDL_Renderer *renderer, Editor *ed, TTF_Font *font,
 	SDL_RenderPresent(renderer);
 }
 
+static void sig_quit(int sig)
+{
+	(void)sig;
+	SDL_Event ev;
+	memset(&ev, 0, sizeof(ev));
+	ev.type = SDL_EVENT_QUIT;
+	SDL_PushEvent(&ev);
+}
+
 int main(int argc, char *argv[]) {
 	// Otherwise wayland just wouldn't load.
 	SDL_SetHintWithPriority(SDL_HINT_VIDEO_DRIVER, "wayland",
 				SDL_HINT_OVERRIDE);
 
+	signal(SIGTERM, sig_quit);
+	signal(SIGINT,  sig_quit);
+
 	SDL_Init(SDL_INIT_VIDEO);
 	TTF_Init();