esc
Externally Scriptable Editor
git clone git://mccd.space/esc
| Log | Files | Refs | README |
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:
| M | Makefile | | | 37 | +++++++++++++++++++++++++++++++++---- |
| M | editor.c | | | 158 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | editor.h | | | 1 | + |
| A | esc-selections.7 | | | 21 | +++++++++++++++++++++ |
| M | main.c | | | 90 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
| M | tests/test_editor.c | | | 27 | +++++++++++++++++++++++++++ |
| A | treesitter.c | | | 81 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | treesitter.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