esc

Externally Scriptable Editor

git clone git://mccd.space/esc

commit 67e1766ec16f4b4d5e90df070aabb528cfcb97b7
parent f45e6f4c4616d2e8503b7ef2c1cc3ac405d465dc
Author: Marc Coquand <marc@coquand.email>
Date:   Thu, 26 Feb 2026 11:35:49 +0100

Add treesitter support for selections

Diffstat:
MMakefile | 37+++++++++++++++++++++++++++++++++----
Meditor.c | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor.h | 1+
Aesc-selections.7 | 21+++++++++++++++++++++
Mmain.c | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/test_editor.c | 27+++++++++++++++++++++++++++
Atreesitter.c | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atreesitter.h | 13+++++++++++++
8 files changed, 420 insertions(+), 8 deletions(-)
diff --git a/Makefile b/Makefile
@@ -5,15 +5,44 @@ SANITIZE = -fsanitize=address,undefined
 SDL3_CFLAGS = $(shell pkg-config --cflags sdl3)
 SDL3_LIBS   = $(shell pkg-config --libs sdl3)
 
+# Optional tree-sitter
+TS_CFLAGS          := $(shell pkg-config --cflags tree-sitter 2>/dev/null)
+TS_LIBS            := $(shell pkg-config --libs   tree-sitter 2>/dev/null)
+TS_C_CFLAGS        := $(shell pkg-config --cflags tree-sitter-c 2>/dev/null)
+TS_C_LIBS          := $(shell pkg-config --libs   tree-sitter-c 2>/dev/null)
+TS_MARKDOWN_CFLAGS := $(shell pkg-config --cflags tree-sitter-markdown 2>/dev/null)
+TS_MARKDOWN_LIBS   := $(shell pkg-config --libs   tree-sitter-markdown 2>/dev/null)
+
+EXTRA_SRCS  :=
+EXTRA_FLAGS :=
+EXTRA_LIBS  :=
+
+ifneq ($(TS_CFLAGS),)
+EXTRA_FLAGS += -DHAVE_TREESITTER $(TS_CFLAGS)
+EXTRA_LIBS  += $(TS_LIBS)
+EXTRA_SRCS  += treesitter.c
+ifneq ($(TS_C_CFLAGS),)
+EXTRA_FLAGS += -DHAVE_TS_C $(TS_C_CFLAGS)
+EXTRA_LIBS  += $(TS_C_LIBS)
+endif
+ifneq ($(TS_MARKDOWN_CFLAGS),)
+EXTRA_FLAGS += -DHAVE_TS_MARKDOWN $(TS_MARKDOWN_CFLAGS)
+EXTRA_LIBS  += $(TS_MARKDOWN_LIBS)
+endif
+endif
+
 .PHONY: all test clean
 
 all: esc
 
 esc: main.c unix_utils.c unix_utils.h editor.c editor.h \
-     strbuf.c strbuf.h fuse_ipc.c fuse_ipc.h renderer.c renderer.h
-	$(CC) $(CFLAGS) $(CFLAGS_OPT) main.c unix_utils.c editor.c strbuf.c \
-	      fuse_ipc.c renderer.c -o esc \
-	      $(shell pkg-config --cflags --libs sdl3 sdl3-ttf fuse)
+     strbuf.c strbuf.h fuse_ipc.c fuse_ipc.h renderer.c renderer.h \
+     treesitter.h $(EXTRA_SRCS)
+	$(CC) $(CFLAGS) $(CFLAGS_OPT) $(EXTRA_FLAGS) \
+	      main.c unix_utils.c editor.c strbuf.c \
+	      fuse_ipc.c renderer.c $(EXTRA_SRCS) -o esc \
+	      $(shell pkg-config --cflags --libs sdl3 sdl3-ttf fuse) \
+	      $(EXTRA_LIBS)
 
 test: tests/test_strbuf tests/test_editor
 	@echo "--- running test_strbuf ---"
diff --git a/editor.c b/editor.c
@@ -556,6 +556,164 @@ void editor_clear_selection(Editor *ed) {
 	editor_unlock(ed);
 }
 
+void editor_set_selection(Editor *ed, int anchor, int cursor) {
+	editor_lock(ed);
+	int tlen = (int)ed->text.len;
+	ed->selection_anchor = anchor > tlen ? tlen : anchor;
+	ed->cursor_idx       = cursor > tlen ? tlen : cursor;
+	editor_unlock(ed);
+}
+
+/* Return start of line containing pos (byte offset). */
+static int find_sol(const char *data, int pos) {
+	while (pos > 0 && data[pos - 1] != '\n')
+		pos--;
+	return pos;
+}
+
+/* True if the line at byte pos is blank (whitespace-only or just \n). */
+static bool line_is_blank(const char *data, int len, int pos) {
+	pos = find_sol(data, pos);
+	while (pos < len && data[pos] != '\n') {
+		if (data[pos] != ' ' && data[pos] != '\t' && data[pos] != '\r')
+			return false;
+		pos++;
+	}
+	return true;
+}
+
+void editor_expand_selection(Editor *ed, int level) {
+	if (level == 0) {
+		editor_select_word(ed);
+		return;
+	}
+	editor_lock(ed);
+	const char *data = ed->text.data;
+	int len = (int)ed->text.len;
+	int lo = ed->selection_anchor < ed->cursor_idx
+	             ? ed->selection_anchor : ed->cursor_idx;
+	int hi = ed->selection_anchor > ed->cursor_idx
+	             ? ed->selection_anchor : ed->cursor_idx;
+
+	if (level == 1) {
+		/* Sentence: backward to start of line */
+		lo = find_sol(data, lo);
+		/* Forward: to [.?!] + space/\n, or paragraph boundary */
+		bool found = false;
+		for (int p = hi; p < len; p++) {
+			if (data[p] == '\n') {
+				int next = p + 1;
+				if (next >= len || line_is_blank(data, len, next)) {
+					hi = p;
+					found = true;
+					break;
+				}
+			}
+			if ((data[p] == '.' || data[p] == '?' || data[p] == '!') &&
+			    (p + 1 >= len || data[p + 1] == ' ' || data[p + 1] == '\n')) {
+				hi = p + 1;
+				found = true;
+				break;
+			}
+		}
+		if (!found)
+			hi = len;
+	} else {
+		/* level >= 2: paragraph — blank-line boundaries */
+
+		/* Backward: walk lines upward until blank line or start */
+		{
+			int p = find_sol(data, lo);
+			while (p > 0) {
+				int prev_start = find_sol(data, p - 1);
+				if (line_is_blank(data, len, prev_start))
+					break;
+				p = prev_start;
+			}
+			lo = p;
+		}
+
+		/* Forward: walk lines downward until blank line or end */
+		{
+			int p = hi;
+			while (p < len) {
+				while (p < len && data[p] != '\n')
+					p++;
+				if (p >= len) {
+					hi = len;
+					break;
+				}
+				int next = p + 1;
+				if (next >= len) {
+					hi = len;
+					break;
+				}
+				if (line_is_blank(data, len, next)) {
+					hi = next; /* blank line's \n */
+					break;
+				}
+				p = next;
+			}
+			if (p >= len)
+				hi = len;
+		}
+
+		/* level >= 3: extend by one more paragraph per extra level */
+		for (int extra = 0; extra < level - 2; extra++) {
+			/* Extend lo backward past blank separator + prev paragraph */
+			if (lo > 0) {
+				int q = find_sol(data, lo - 1);
+				/* skip blank lines above */
+				while (q > 0 && line_is_blank(data, len, q))
+					q = find_sol(data, q - 1);
+				/* walk back through the non-blank paragraph above */
+				while (q > 0) {
+					int prev = find_sol(data, q - 1);
+					if (line_is_blank(data, len, prev))
+						break;
+					q = prev;
+				}
+				lo = q;
+			}
+			/* Extend hi forward past blank separator + next paragraph */
+			if (hi < len) {
+				int q = hi + 1;
+				/* skip blank lines below */
+				while (q < len && line_is_blank(data, len, q)) {
+					while (q < len && data[q] != '\n')
+						q++;
+					if (q < len) q++;
+				}
+				/* walk forward through the non-blank paragraph below */
+				while (q < len) {
+					while (q < len && data[q] != '\n')
+						q++;
+					if (q >= len) {
+						hi = len;
+						break;
+					}
+					int next = q + 1;
+					if (next >= len) {
+						hi = len;
+						break;
+					}
+					if (line_is_blank(data, len, next)) {
+						hi = next;
+						break;
+					}
+					q = next;
+				}
+				if (q >= len)
+					hi = len;
+			}
+		}
+	}
+
+	ed->selection_anchor = lo;
+	ed->cursor_idx = hi;
+	editor_unlock(ed);
+}
+
 void editor_cursor_up(Editor *ed) {
 
 	editor_lock(ed);
diff --git a/editor.h b/editor.h
@@ -101,6 +101,7 @@ void editor_goto_pos(Editor *ed, int pos);
 
 char *editor_get_selection(Editor *ed);
 void editor_select_word(Editor *ed);
+void editor_expand_selection(Editor *ed, int level);
 bool editor_has_selection(Editor *ed);
 EditorRange *editor_get_range_at(Editor *ed, int byte_idx);
 
diff --git a/esc-selections.7 b/esc-selections.7
@@ -0,0 +1,21 @@
+.TH esc-selections 7
+
+.SH NAME
+
+esc-selections - Esc editor selection algorithm
+
+.SH DESCRIPTION
+
+.PP
+\fIesc\fR(1) uses treesitter to enable semantic selections of content.
+These are triggered with left click by default to expand the region, 
+and right click by default to deselect. First click moves the cursor,
+and subsequent clicks expands the region or makes the region smaller. 
+Those familiar with expand-region from \fIemacs\fR(1) will be familiar 
+with this functionality.
+
+.PP
+In case there is no treesitter support for the file, \fIesc\fR(1) falls
+back to using the logic of word followed by sentence followed by paragraph,
+and then expands with paragraph above and below.
+
diff --git a/main.c b/main.c
@@ -1,7 +1,18 @@
 #include "editor.h"
 #include "fuse_ipc.h"
 #include "renderer.h"
+#include "treesitter.h"
 #include "unix_utils.h"
+
+#define SEL_STACK_MAX 32
+typedef struct {
+#ifdef HAVE_TREESITTER
+	TsState *ts;
+#endif
+	int last_click_byte;
+	struct { int anchor; int cursor; } stack[SEL_STACK_MAX];
+	int stack_len;
+} SelectionState;
 #include <SDL3/SDL.h>
 #include <SDL3/SDL_clipboard.h>
 #include <SDL3/SDL_events.h>
@@ -19,7 +30,7 @@
 
 void handle_events(Editor *ed, SDL_Renderer *renderer, float *scroll_x,
 		   float *scroll_y, float line_height, bool *running,
-		   bool *is_dragging) {
+		   bool *is_dragging, SelectionState *sel) {
 	SDL_Event event;
 	char *cmd;
 
@@ -196,10 +207,73 @@ void handle_events(Editor *ed, SDL_Renderer *renderer, float *scroll_x,
 					SDL_RenderCoordinatesFromWindow(
 					    renderer, event.button.x,
 					    event.button.y, &mx, &my);
+
+					int prev_cursor = ed->cursor_idx;
+					int prev_anchor = ed->selection_anchor;
 					editor_set_cursor_from_coords(
 					    ed, mx, my, *scroll_x, *scroll_y);
-					ed->selection_anchor = ed->cursor_idx;
-					*is_dragging = true;
+					int new_pos = ed->cursor_idx;
+
+					int sel_min = prev_cursor < prev_anchor
+					                 ? prev_cursor : prev_anchor;
+					int sel_max = prev_cursor > prev_anchor
+					                 ? prev_cursor : prev_anchor;
+					bool same_pos   = (new_pos == sel->last_click_byte);
+					bool within_sel = (prev_cursor != prev_anchor) &&
+					                  (new_pos >= sel_min &&
+					                   new_pos <= sel_max);
+
+					if (same_pos || within_sel) {
+						/* Expansion click */
+						ed->cursor_idx       = prev_cursor;
+						ed->selection_anchor = prev_anchor;
+
+						if (sel->stack_len < SEL_STACK_MAX) {
+							sel->stack[sel->stack_len].anchor =
+							    prev_anchor;
+							sel->stack[sel->stack_len].cursor =
+							    prev_cursor;
+							sel->stack_len++;
+						}
+
+						bool expanded = false;
+#ifdef HAVE_TREESITTER
+						int new_start, new_end;
+						if (sel->ts) {
+							editor_lock(ed);
+							ts_state_parse(sel->ts,
+							    ed->text.data,
+							    ed->text.len);
+							editor_unlock(ed);
+							expanded = ts_state_expand(
+							    sel->ts, sel_min, sel_max,
+							    &new_start, &new_end);
+							if (expanded)
+								editor_set_selection(ed,
+								    new_start, new_end);
+						}
+#endif
+						if (!expanded) {
+							int lvl = sel->stack_len - 1;
+							editor_expand_selection(ed, lvl);
+						}
+						/* No drag after expansion click */
+					} else {
+						/* Fresh click — move cursor, reset */
+						ed->selection_anchor = new_pos;
+						sel->last_click_byte = new_pos;
+						sel->stack_len = 0;
+						*is_dragging = true;
+					}
+				} else if (event.button.button == SDL_BUTTON_RIGHT) {
+					if (sel->stack_len > 0) {
+						sel->stack_len--;
+						editor_set_selection(ed,
+						    sel->stack[sel->stack_len].anchor,
+						    sel->stack[sel->stack_len].cursor);
+					} else {
+						editor_clear_selection(ed);
+					}
 				} else if (event.button.button ==
 					   SDL_BUTTON_MIDDLE) {
 					SDL_Keymod mod = SDL_GetModState();
@@ -359,11 +433,16 @@ int main(int argc, char *argv[]) {
 	bool running = true;
 	bool is_dragging = false;
 
+	SelectionState sel = { .last_click_byte = -1, .stack_len = 0 };
+#ifdef HAVE_TREESITTER
+	sel.ts = ed->filename ? ts_state_create(ed->filename) : NULL;
+#endif
+
 	SDL_StartTextInput(window);
 
 	while (running) {
 		handle_events(ed, renderer, &scroll_x, &scroll_y, line_height,
-			      &running, &is_dragging);
+			      &running, &is_dragging, &sel);
 
 		ctx.scroll_x = scroll_x;
 		ctx.scroll_y = scroll_y;
@@ -372,6 +451,9 @@ int main(int argc, char *argv[]) {
 		editor_unlock(ed);
 	}
 
+#ifdef HAVE_TREESITTER
+	ts_state_destroy(sel.ts);
+#endif
 	if (ipc)
 		fuse_ipc_stop(ipc);
 	editor_destroy(ed);
diff --git a/tests/test_editor.c b/tests/test_editor.c
@@ -495,6 +495,32 @@ static void suite_range_shifts_on_insert(void) {
 	editor_destroy(ed);
 }
 
+/* ---- suite_expand_selection --------------------------------------------- */
+
+static void suite_expand_selection(void) {
+	RUN_SUITE(suite_expand_selection);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "foo bar. baz qux.", false);
+
+	editor_goto_pos(ed, 1);
+	editor_clear_selection(ed);
+
+	/* level 0 → word */
+	editor_expand_selection(ed, 0);
+	char *sel = editor_get_selection(ed);
+	ASSERT_NOT_NULL(sel);
+	ASSERT_EQ_STR(sel, "foo");
+	free(sel);
+
+	/* level 2 → paragraph (single paragraph → whole buffer) */
+	editor_expand_selection(ed, 2);
+	ASSERT_EQ_INT(ed->selection_anchor, 0);
+	ASSERT_EQ_INT(ed->cursor_idx, (int)ed->text.len);
+
+	editor_destroy(ed);
+}
+
 /* ---- main --------------------------------------------------------------- */
 
 int main(void) {
@@ -518,6 +544,7 @@ int main(void) {
 	suite_tab_expansion();
 	suite_select_word();
 	suite_range_shifts_on_insert();
+	suite_expand_selection();
 
 	SDL_Quit();
 	REPORT_AND_EXIT();
diff --git a/treesitter.c b/treesitter.c
@@ -0,0 +1,81 @@
+#ifdef HAVE_TREESITTER
+#include "treesitter.h"
+#include <tree_sitter/api.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef HAVE_TS_C
+extern const TSLanguage *tree_sitter_c(void);
+#endif
+#ifdef HAVE_TS_MARKDOWN
+extern const TSLanguage *tree_sitter_markdown(void);
+#endif
+
+struct TsState {
+	TSParser *parser;
+	TSTree   *tree;
+};
+
+static const TSLanguage *detect_language(const char *filename) {
+	if (!filename) return NULL;
+	const char *ext = strrchr(filename, '.');
+	if (!ext) return NULL;
+	ext++;
+#ifdef HAVE_TS_C
+	if (strcmp(ext, "c") == 0 || strcmp(ext, "h") == 0)
+		return tree_sitter_c();
+#endif
+#ifdef HAVE_TS_MARKDOWN
+	if (strcmp(ext, "md") == 0 || strcmp(ext, "markdown") == 0)
+		return tree_sitter_markdown();
+#endif
+	return NULL;
+}
+
+TsState *ts_state_create(const char *filename) {
+	const TSLanguage *lang = detect_language(filename);
+	if (!lang) return NULL;
+	TsState *s = malloc(sizeof *s);
+	s->parser = ts_parser_new();
+	ts_parser_set_language(s->parser, lang);
+	s->tree = NULL;
+	return s;
+}
+
+void ts_state_destroy(TsState *s) {
+	if (!s) return;
+	if (s->tree) ts_tree_delete(s->tree);
+	ts_parser_delete(s->parser);
+	free(s);
+}
+
+void ts_state_parse(TsState *s, const char *text, size_t len) {
+	TSTree *new_tree = ts_parser_parse_string(s->parser, s->tree,
+	                                          text, (uint32_t)len);
+	if (s->tree) ts_tree_delete(s->tree);
+	s->tree = new_tree;
+}
+
+bool ts_state_expand(TsState *s, int sel_start, int sel_end,
+                     int *out_start, int *out_end) {
+	if (!s || !s->tree) return false;
+	TSNode root = ts_tree_root_node(s->tree);
+	uint32_t a = (uint32_t)sel_start;
+	uint32_t b = sel_end > sel_start ? (uint32_t)(sel_end - 1) : a;
+	TSNode node = ts_node_descendant_for_byte_range(root, a, b);
+	uint32_t ns = ts_node_start_byte(node);
+	uint32_t ne = ts_node_end_byte(node);
+	/* If tightest node == current selection, climb to parent */
+	while ((int)ns == sel_start && (int)ne == sel_end) {
+		TSNode parent = ts_node_parent(node);
+		if (ts_node_is_null(parent)) return false;
+		node = parent;
+		ns = ts_node_start_byte(node);
+		ne = ts_node_end_byte(node);
+	}
+	if ((int)ns == sel_start && (int)ne == sel_end) return false;
+	*out_start = (int)ns;
+	*out_end   = (int)ne;
+	return true;
+}
+#endif /* HAVE_TREESITTER */
diff --git a/treesitter.h b/treesitter.h
@@ -0,0 +1,13 @@
+#ifndef TREESITTER_H
+#define TREESITTER_H
+#include <stddef.h>
+#include <stdbool.h>
+
+typedef struct TsState TsState;
+
+TsState *ts_state_create(const char *filename);
+void     ts_state_destroy(TsState *state);
+void     ts_state_parse(TsState *state, const char *text, size_t len);
+bool     ts_state_expand(TsState *state, int sel_start, int sel_end,
+                         int *out_start, int *out_end);
+#endif