aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore7
-rw-r--r--LICENSE24
-rw-r--r--Makefile67
-rw-r--r--README.md82
-rw-r--r--guix.scm75
-rw-r--r--include/ecex.h2
-rw-r--r--include/types.h3
-rw-r--r--src/buffers.c18
-rw-r--r--src/config.c13
-rw-r--r--src/ecex.c106
-rw-r--r--src/log.c22
-rw-r--r--src/path.c22
-rw-r--r--tests/test_buffers.c87
-rw-r--r--tests/test_completion_path.c59
-rw-r--r--tests/test_core.h13
-rw-r--r--tests/test_main.c13
-rw-r--r--tests/test_plugin.c (renamed from tests/test_core.c)15
17 files changed, 589 insertions, 39 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..87e1836
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+bin/
+crash.dump
+compile_flags.txt
+*.o
+*.so
+*.a
+*.dSYM/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..efd398a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,24 @@
+BSD 2-Clause License
+
+Copyright (c) 2026, Ecex contributors
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Makefile b/Makefile
index 3a23f4f..79aecf7 100644
--- a/Makefile
+++ b/Makefile
@@ -1,28 +1,62 @@
CC = clang
+PREFIX ?= /usr/local
+BINDIR ?= $(PREFIX)/bin
+DATADIR ?= $(PREFIX)/share
+DOCDIR ?= $(DATADIR)/doc/ecex
+ECEX_DATADIR ?= $(DATADIR)/ecex
+ECEX_INCLUDEDIR ?= $(PREFIX)/include/ecex
STATIC_LIB = include/libccdjit.a
SHARED_LIB = include/libccdjit.so
-SRC = src/main.c src/app.c src/completion.c src/path.c src/media.c src/render.c src/font.c src/util.c src/log.c src/buffers.c src/ecex.c src/plugin.c src/config.c src/eval.c
+SRC = \
+ src/main.c \
+ src/app.c \
+ src/completion.c \
+ src/path.c \
+ src/media.c \
+ src/render.c \
+ src/font.c \
+ src/util.c \
+ src/log.c \
+ src/buffers.c \
+ src/ecex.c \
+ src/plugin.c \
+ src/config.c \
+ src/eval.c
+
+TEST_SRC = \
+ tests/test_main.c \
+ tests/test_plugin.c \
+ tests/test_buffers.c \
+ tests/test_completion_path.c \
+ src/buffers.c \
+ src/completion.c \
+ src/path.c \
+ src/util.c \
+ src/log.c \
+ src/plugin.c
+
BIN = bin/ecex
PKG_CFLAGS = $(shell pkg-config --cflags glfw3 2>/dev/null)
PKG_LIBS = $(shell pkg-config --libs glfw3 2>/dev/null || echo -lglfw)
-CFLAGS ?= -std=c11 -Wall -Wextra -pedantic -Iinclude $(PKG_CFLAGS)
+CPPFLAGS ?= -Iinclude $(PKG_CFLAGS) -DECEX_SYSTEM_INCLUDE_DIR=\"$(ECEX_INCLUDEDIR)\"
+CFLAGS ?= -std=c11 -Wall -Wextra -pedantic
LDLIBS = $(PKG_LIBS) -lGL -lm
-.PHONY: all static shared clean run check sanitize debug release
+.PHONY: all static shared clean run check sanitize debug release install uninstall
all: static
static: $(SRC) $(STATIC_LIB)
@mkdir -p bin
- $(CC) $(CFLAGS) $(SRC) $(STATIC_LIB) -o $(BIN) $(LDLIBS)
+ $(CC) $(CPPFLAGS) $(CFLAGS) $(SRC) $(STATIC_LIB) -o $(BIN) $(LDLIBS)
shared: $(SRC) $(SHARED_LIB)
@mkdir -p bin
- $(CC) $(CFLAGS) $(SRC) -Linclude -lccdjit -o $(BIN) $(LDLIBS)
+ $(CC) $(CPPFLAGS) $(CFLAGS) $(SRC) -Linclude -lccdjit -o $(BIN) $(LDLIBS)
run: static
LD_LIBRARY_PATH=include ./$(BIN)
@@ -35,12 +69,29 @@ release: static
sanitize: CFLAGS += -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer
sanitize: LDLIBS += -fsanitize=address,undefined
-sanitize: static
+sanitize: SANITIZER_TEST_ENV = ASAN_OPTIONS=detect_leaks=0
+sanitize: static check
check:
@mkdir -p bin
- $(CC) -std=c11 -Wall -Wextra -pedantic -Iinclude tests/test_core.c src/buffers.c src/completion.c src/path.c src/util.c src/log.c src/plugin.c -o bin/ecex-tests
- ./bin/ecex-tests
+ $(CC) $(CPPFLAGS) $(CFLAGS) $(TEST_SRC) -o bin/ecex-tests
+ $(SANITIZER_TEST_ENV) ./bin/ecex-tests
+
+install: static
+ install -d "$(DESTDIR)$(BINDIR)"
+ install -d "$(DESTDIR)$(ECEX_INCLUDEDIR)"
+ install -d "$(DESTDIR)$(ECEX_DATADIR)/config"
+ install -d "$(DESTDIR)$(DOCDIR)"
+ install -m 755 "$(BIN)" "$(DESTDIR)$(BINDIR)/ecex"
+ install -m 644 include/*.h "$(DESTDIR)$(ECEX_INCLUDEDIR)/"
+ install -m 644 config/*.c "$(DESTDIR)$(ECEX_DATADIR)/config/"
+ install -m 644 README.md LICENSE docs/*.md "$(DESTDIR)$(DOCDIR)/"
+
+uninstall:
+ rm -f "$(DESTDIR)$(BINDIR)/ecex"
+ rm -rf "$(DESTDIR)$(ECEX_INCLUDEDIR)"
+ rm -rf "$(DESTDIR)$(ECEX_DATADIR)"
+ rm -rf "$(DESTDIR)$(DOCDIR)"
clean:
rm -f $(BIN) bin/ecex-tests
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7454862
--- /dev/null
+++ b/README.md
@@ -0,0 +1,82 @@
+# Ecex
+
+Ecex is a small C editor with GLFW/OpenGL rendering and C config/plugins loaded
+through CCDJIT.
+
+## Build
+
+Dependencies:
+
+- `clang`
+- `make`
+- `pkg-config`
+- GLFW 3
+- OpenGL
+
+Build and test:
+
+```sh
+make
+make check
+```
+
+With Guix:
+
+```sh
+guix shell -m manifest.scm -- make check
+guix build -f guix.scm
+```
+
+Run:
+
+```sh
+make run
+```
+
+Use a config or font explicitly:
+
+```sh
+./bin/ecex --config config/ecexrc.c
+./bin/ecex --font /path/to/font.ttf
+```
+
+Install locally:
+
+```sh
+make install PREFIX=$HOME/.local
+```
+
+`ECEX_FONT` can also point at a TTF font. `ECEX_INCLUDE` can point at the Ecex
+include directory when loading configs from another working directory.
+
+## Useful Keys
+
+- `M-x`: command prompt
+- `C-x C-f`: find file
+- `C-x C-s`: save buffer
+- `C-x b`: switch buffer
+- `C-x k`: kill buffer
+- `C-x d`: file browser
+- `M-x messages`: open the read-only `*Messages*` buffer
+- `C-s` / `C-r`: incremental search
+- `C-/`: undo
+- `C-S-z`: redo
+- `C-x C-r`: reload config
+- `C-x C-c`: quit
+
+The sample config in `config/ecexrc.c` enables demo plugins including Markdown,
+which-key, C mode, API completion, render demo, and Tetris.
+
+## Development
+
+`make check` runs the core C tests. `make sanitize` builds the app and runs the
+tests with AddressSanitizer/UBSan flags.
+
+Plugin API conventions are documented in `docs/plugin-api.md`. Current CCDJIT
+follow-up work is tracked in `docs/ccdjit-improvements.md`.
+
+`guix.scm` defines a local package. It installs the binary, public headers,
+sample configs, and docs, and wraps `ecex` with `ECEX_INCLUDE` and a DejaVu font
+path.
+
+Ecex is distributed under the BSD 2-Clause License. See `LICENSE`.
diff --git a/guix.scm b/guix.scm
new file mode 100644
index 0000000..bd2d244
--- /dev/null
+++ b/guix.scm
@@ -0,0 +1,75 @@
+(use-modules (guix packages)
+ (guix build-system gnu)
+ (guix gexp)
+ ((guix licenses) #:prefix license:)
+ (gnu packages fonts)
+ (gnu packages gl)
+ (gnu packages llvm)
+ (gnu packages pkg-config)
+ (ice-9 regex))
+
+(define (ecex-source-file? file stat)
+ (or (string-match "/src($|/)" file)
+ (string-match "/include($|/)" file)
+ (string-match "/config($|/)" file)
+ (string-match "/docs($|/)" file)
+ (string-match "/tests($|/)" file)
+ (string-match "/Makefile$" file)
+ (string-match "/LICENSE$" file)
+ (string-match "/README\\.md$" file)
+ (string-match "/manifest\\.scm$" file)
+ (string-match "/guix\\.scm$" file)
+ (string-match "/\\.gitignore$" file)))
+
+(define-public ecex
+ (package
+ (name "ecex")
+ (version "0.1.0")
+ (source
+ (local-file "." "ecex-checkout"
+ #:recursive? #t
+ #:select? ecex-source-file?))
+ (build-system gnu-build-system)
+ (arguments
+ (list
+ #:make-flags
+ #~(list "CC=clang"
+ (string-append "PREFIX=" #$output))
+ #:phases
+ #~(modify-phases %standard-phases
+ (delete 'configure)
+ (replace 'build
+ (lambda* (#:key make-flags #:allow-other-keys)
+ (apply invoke "make" "static" make-flags)))
+ (replace 'check
+ (lambda* (#:key tests? make-flags #:allow-other-keys)
+ (when tests?
+ (apply invoke "make" "check" make-flags))))
+ (replace 'install
+ (lambda* (#:key make-flags #:allow-other-keys)
+ (apply invoke "make" "install" make-flags)))
+ (add-after 'install 'wrap-ecex
+ (lambda* (#:key inputs outputs #:allow-other-keys)
+ (let* ((out (assoc-ref outputs "out"))
+ (font (assoc-ref inputs "font-dejavu"))
+ (font-path
+ (string-append font "/share/fonts/truetype/DejaVuSansMono.ttf")))
+ (wrap-program (string-append out "/bin/ecex")
+ `("ECEX_INCLUDE" ":" prefix
+ (,(string-append out "/include/ecex")))
+ `("ECEX_FONT" ":" = (,font-path)))))))))
+ (native-inputs
+ `(("clang-toolchain" ,clang-toolchain)
+ ("pkg-config" ,pkg-config)))
+ (inputs
+ `(("font-dejavu" ,font-dejavu)
+ ("glfw" ,glfw)
+ ("mesa" ,mesa)))
+ (home-page "git.cdatgoose.org/ecex.git")
+ (synopsis "Small C editor with CCDJIT-loaded C config and plugins")
+ (description
+ "Ecex is a small editor written in C with GLFW/OpenGL rendering and a
+C-based config/plugin system loaded through CCDJIT.")
+ (license license:bsd-2)))
+
+ecex
diff --git a/include/ecex.h b/include/ecex.h
index 3e0048e..a7d4b24 100644
--- a/include/ecex.h
+++ b/include/ecex.h
@@ -46,6 +46,8 @@ void ecex_log_int(const char *message, int value);
void ecex_log_double(const char *message, double value);
void ecex_log_ptr(const char *message, const void *ptr);
void ecex_log_flush(void);
+void ecex_log_set_sink(ecex_log_sink_fn fn, void *userdata);
+void ecex_log_clear_sink(void *userdata);
void ecex_mem_zero(void *ptr, size_t size);
int ecex_i32_get(const int *items, size_t index);
void ecex_i32_set(int *items, size_t index, int value);
diff --git a/include/types.h b/include/types.h
index 008bd98..c950bbb 100644
--- a/include/types.h
+++ b/include/types.h
@@ -23,6 +23,7 @@ typedef void (*ecex_hook_free_fn)(void *userdata);
typedef void (*ecex_command_hook_fn)(ecex_t *ed, const char *command, int event, int result, void *userdata);
typedef void (*ecex_prefix_hook_fn)(ecex_t *ed, const char *prefix, int event, void *userdata);
typedef void (*ecex_buffer_hook_fn)(ecex_t *ed, buffer_t *buffer, int event, void *userdata);
+typedef void (*ecex_log_sink_fn)(const char *line, int depth, void *userdata);
typedef int (*ecex_completion_provider_fn)(ecex_t *ed,
buffer_t *buffer,
const char *prefix,
@@ -336,6 +337,8 @@ struct ecex {
int next_major_mode_id;
ecex_plugin_runtime_t *plugins;
+ buffer_t *messages_buffer;
+ int messages_append_active;
char *last_eval_source;
char *last_eval_filename;
diff --git a/src/buffers.c b/src/buffers.c
index 1e42bfc..c2e94e2 100644
--- a/src/buffers.c
+++ b/src/buffers.c
@@ -302,15 +302,25 @@ int buffer_delete_selection(buffer_t *buffer) {
int buffer_replace_selection(buffer_t *buffer, const char *text) {
if (!buffer || !text) return ECEX_ERR;
if (!buffer_has_selection(buffer)) return buffer_insert(buffer, text);
+ if (buffer_record_undo(buffer) != ECEX_OK) return ECEX_ERR;
size_t start = 0;
size_t end = 0;
buffer_selection_range(buffer, &start, &end);
- if (buffer_delete_range(buffer, start, end) != ECEX_OK) return ECEX_ERR;
- buffer->point = start;
- buffer_clear_mark(buffer);
- return buffer_insert(buffer, text);
+ int undo_disabled = buffer->undo_disabled;
+ int result = ECEX_OK;
+ buffer->undo_disabled = 1;
+
+ result = buffer_delete_range(buffer, start, end);
+ if (result == ECEX_OK) {
+ buffer->point = start;
+ buffer_clear_mark(buffer);
+ result = buffer_insert(buffer, text);
+ }
+
+ buffer->undo_disabled = undo_disabled;
+ return result;
}
int buffer_backspace(buffer_t *buffer) {
diff --git a/src/config.c b/src/config.c
index 9c885ff..6e4b84f 100644
--- a/src/config.c
+++ b/src/config.c
@@ -339,9 +339,18 @@ static int add_include_path(ccdjit_context *ctx, const char *path) {
return ECEX_ERR;
}
+static int add_existing_include_path(ccdjit_context *ctx, const char *path) {
+ if (!path || !path[0] || !ecex_path_is_dir(path)) return ECEX_OK;
+ return add_include_path(ctx, path);
+}
+
int ecex_add_ccdjit_include_paths(ccdjit_context *ctx) {
- if (add_include_path(ctx, "include") != ECEX_OK) return ECEX_ERR;
- if (add_include_path(ctx, "../include") != ECEX_OK) return ECEX_ERR;
+ if (add_existing_include_path(ctx, "include") != ECEX_OK) return ECEX_ERR;
+ if (add_existing_include_path(ctx, "../include") != ECEX_OK) return ECEX_ERR;
+
+#ifdef ECEX_SYSTEM_INCLUDE_DIR
+ if (add_existing_include_path(ctx, ECEX_SYSTEM_INCLUDE_DIR) != ECEX_OK) return ECEX_ERR;
+#endif
const char *env_include = getenv("ECEX_INCLUDE");
if (env_include && env_include[0]) {
diff --git a/src/ecex.c b/src/ecex.c
index a40a792..f3b4980 100644
--- a/src/ecex.c
+++ b/src/ecex.c
@@ -33,6 +33,9 @@ extern int kill(pid_t pid, int sig);
#define ECEX_INITIAL_MODE_CAP 8
#define ECEX_INITIAL_MODE_KEYBIND_CAP 16
#define ECEX_INITIAL_HOOK_CAP 8
+#define ECEX_MESSAGES_BUFFER_NAME "*Messages*"
+#define ECEX_MESSAGES_MAX_BYTES (1024u * 1024u)
+#define ECEX_MESSAGES_TRIM_BYTES (256u * 1024u)
ecex_window_t *ecex_current_window(ecex_t *ed);
static void ecex_clear_command_hooks(ecex_t *ed);
@@ -41,6 +44,10 @@ static void ecex_clear_buffer_hooks(ecex_t *ed);
static void ecex_clear_completion_providers(ecex_t *ed);
static int ecex_complete_at_point_direction(ecex_t *ed, int direction);
static int ecex_indent_for_tab(ecex_t *ed);
+static int ecex_buffer_index_of(ecex_t *ed, buffer_t *buffer, size_t *out_index);
+static buffer_t *ecex_ensure_messages_buffer(ecex_t *ed);
+static int ecex_messages_append(ecex_t *ed, const char *prefix, const char *message);
+static void ecex_log_messages_sink(const char *line, int depth, void *userdata);
void *ecex_config_alloc(size_t size) {
@@ -769,10 +776,16 @@ static int cmd_file_browser_history_forward(ecex_t *ed) {
}
static int cmd_media_play_pause(ecex_t *ed) { return ecex_media_toggle_playback(ed); }
+static int cmd_messages(ecex_t *ed) {
+ buffer_t *buffer = ecex_ensure_messages_buffer(ed);
+ return buffer ? ecex_switch_buffer(ed, buffer->name) : ECEX_ERR;
+}
static int ecex_register_builtins(ecex_t *ed) {
ECEX_COMMAND("quit", cmd_quit);
ECEX_COMMAND("force-quit", cmd_force_quit);
+ ECEX_COMMAND("messages", cmd_messages);
+ ECEX_COMMAND("view-messages", cmd_messages);
ECEX_COMMAND("find-file", cmd_find_file);
ECEX_COMMAND("file-browser", cmd_file_browser);
ECEX_COMMAND("file-browser-here", cmd_file_browser_here);
@@ -1011,12 +1024,18 @@ ecex_t *ecex_new(void) {
}
ecex_buffer_set_major_mode_by_name(ed, scratch, "fundamental-mode");
+ if (!ecex_ensure_messages_buffer(ed)) {
+ ecex_free(ed);
+ return NULL;
+ }
+ ecex_log_set_sink(ecex_log_messages_sink, ed);
return ed;
}
void ecex_free(ecex_t *ed) {
if (!ed) return;
+ ecex_log_clear_sink(ed);
/* Buffers may hold renderer/animation callbacks and userdata destructors
* compiled by CCDJIT config modules. Run those destructors while their JIT
@@ -1109,6 +1128,91 @@ int ecex_add_buffer(ecex_t *ed, buffer_t *buffer) {
return ECEX_OK;
}
+static void ecex_messages_trim(buffer_t *buffer) {
+ if (!buffer || buffer->len <= ECEX_MESSAGES_MAX_BYTES) return;
+
+ size_t trim = buffer->len - ECEX_MESSAGES_MAX_BYTES + ECEX_MESSAGES_TRIM_BYTES;
+ if (trim > buffer->len) trim = buffer->len;
+ while (trim < buffer->len && buffer->data[trim] != '\n') trim++;
+ if (trim < buffer->len) trim++;
+
+ memmove(buffer->data, buffer->data + trim, buffer->len - trim + 1);
+ buffer->len -= trim;
+ buffer->point = buffer->len;
+ buffer->mark = 0;
+ buffer->mark_active = 0;
+ buffer->scroll_line = 0;
+ buffer->scroll_col = 0;
+}
+
+static buffer_t *ecex_ensure_messages_buffer(ecex_t *ed) {
+ if (!ed) return NULL;
+
+ buffer_t *buffer = ed->messages_buffer;
+ if (!buffer || ecex_buffer_index_of(ed, buffer, NULL) != ECEX_OK) {
+ buffer = ecex_find_buffer(ed, ECEX_MESSAGES_BUFFER_NAME);
+ }
+
+ if (!buffer) {
+ buffer = ecex_create_buffer(ed, ECEX_MESSAGES_BUFFER_NAME, NULL, 0);
+ if (!buffer) return NULL;
+ }
+
+ ed->messages_buffer = buffer;
+ buffer_set_interactive(buffer, 1);
+ if (buffer->major_mode == 0) {
+ ecex_buffer_set_major_mode_by_name(ed, buffer, "special-mode");
+ }
+ buffer->read_only = 1;
+ buffer->modified = 0;
+ return buffer;
+}
+
+static int ecex_messages_append(ecex_t *ed, const char *prefix, const char *message) {
+ if (!ed || ed->messages_append_active) return ECEX_ERR;
+
+ buffer_t *buffer = ecex_ensure_messages_buffer(ed);
+ if (!buffer) return ECEX_ERR;
+
+ int old_read_only = buffer->read_only;
+ int old_undo_disabled = buffer->undo_disabled;
+ ed->messages_append_active = 1;
+ buffer->read_only = 0;
+ buffer->undo_disabled = 1;
+
+ int result = ECEX_OK;
+ if (prefix && prefix[0]) result = buffer_append(buffer, prefix);
+ if (result == ECEX_OK) result = buffer_append(buffer, message ? message : "");
+ if (result == ECEX_OK) result = buffer_append(buffer, "\n");
+ if (result == ECEX_OK) ecex_messages_trim(buffer);
+
+ buffer->read_only = old_read_only;
+ buffer->undo_disabled = old_undo_disabled;
+ buffer->modified = 0;
+ buffer_clear_undo(buffer);
+ ed->messages_append_active = 0;
+ ecex_mark_ui_changed(ed);
+ return result;
+}
+
+static void ecex_log_messages_sink(const char *line, int depth, void *userdata) {
+ ecex_t *ed = (ecex_t *)userdata;
+ char prefix[64];
+ size_t pos = 0;
+
+ if (!ed) return;
+ int written = snprintf(prefix, sizeof(prefix), "ecex-log: ");
+ if (written < 0) return;
+ pos = (size_t)written;
+ if (pos >= sizeof(prefix)) pos = sizeof(prefix) - 1;
+ for (int i = 0; i < depth && pos + 2 < sizeof(prefix); i++) {
+ prefix[pos++] = ' ';
+ prefix[pos++] = ' ';
+ }
+ prefix[pos < sizeof(prefix) ? pos : sizeof(prefix) - 1] = '\0';
+ ecex_messages_append(ed, prefix, line);
+}
+
buffer_t *ecex_create_buffer(ecex_t *ed,
const char *name,
const char *path,
@@ -1382,6 +1486,7 @@ static int ecex_kill_buffer_impl(ecex_t *ed, const char *name, int force) {
buffer_t *victim = ed->buffers[index];
if (ed->previous_buffer == victim) ed->previous_buffer = NULL;
+ if (ed->messages_buffer == victim) ed->messages_buffer = NULL;
if (!force && victim->modified && !victim->read_only) {
fprintf(stderr,
"ecex: refusing to kill modified buffer '%s'; save it or use force-kill-buffer.\n",
@@ -2100,6 +2205,7 @@ void ecex_message(ecex_t *ed, const char *message) {
if (!ed) return;
snprintf(ed->message, sizeof(ed->message), "%s", message ? message : "");
ed->message_revision++;
+ ecex_messages_append(ed, "message: ", message ? message : "");
ecex_mark_ui_changed(ed);
}
diff --git a/src/log.c b/src/log.c
index 7dec534..78c384a 100644
--- a/src/log.c
+++ b/src/log.c
@@ -31,6 +31,22 @@ static char ecex_frame_group_start[1024];
static char ecex_frame_repeat_start[1024];
static char ecex_frame_repeat_end[1024];
static unsigned long ecex_frame_repeat_count = 0;
+static ecex_log_sink_fn ecex_log_sink = NULL;
+static void *ecex_log_sink_userdata = NULL;
+static int ecex_log_sink_active = 0;
+
+void ecex_log_set_sink(ecex_log_sink_fn fn, void *userdata) {
+ ecex_log_sink = fn;
+ ecex_log_sink_userdata = userdata;
+}
+
+void ecex_log_clear_sink(void *userdata) {
+ if (!userdata || userdata == ecex_log_sink_userdata) {
+ ecex_log_sink = NULL;
+ ecex_log_sink_userdata = NULL;
+ ecex_log_sink_active = 0;
+ }
+}
static size_t ecex_strn_copy(char *out, size_t out_cap, const char *in) {
size_t i = 0;
@@ -157,6 +173,12 @@ static void ecex_log_emit_raw_depth(const char *line, int depth) {
}
ecex_write_all(line ? line : "(null)", line ? len : 6);
ecex_write_all("\n", 1);
+
+ if (ecex_log_sink && !ecex_log_sink_active) {
+ ecex_log_sink_active = 1;
+ ecex_log_sink(line ? line : "(null)", depth, ecex_log_sink_userdata);
+ ecex_log_sink_active = 0;
+ }
}
static void ecex_log_emit_raw_counted(const char *line, int depth, unsigned long count) {
diff --git a/src/path.c b/src/path.c
index 1e002d7..be2b347 100644
--- a/src/path.c
+++ b/src/path.c
@@ -1,5 +1,7 @@
#include "path.h"
+#include "util.h"
+
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
@@ -25,16 +27,12 @@ int ecex_path_copy(char *out, size_t out_size, const char *text) {
char *ecex_path_expand_user(const char *path) {
if (!path) return NULL;
if (path[0] != '~' || (path[1] != '\0' && path[1] != '/')) {
- char *copy = malloc(strlen(path) + 1);
- if (copy) strcpy(copy, path);
- return copy;
+ return ecex_strdup(path);
}
const char *home = getenv("HOME");
if (!home || !home[0]) {
- char *copy = malloc(strlen(path) + 1);
- if (copy) strcpy(copy, path);
- return copy;
+ return ecex_strdup(path);
}
size_t home_len = strlen(home);
@@ -49,9 +47,7 @@ char *ecex_path_expand_user(const char *path) {
char *ecex_path_join(const char *dir, const char *name) {
if (!name) return NULL;
if (name[0] == '/') {
- char *copy = malloc(strlen(name) + 1);
- if (copy) strcpy(copy, name);
- return copy;
+ return ecex_strdup(name);
}
if (!dir || !dir[0]) dir = ".";
@@ -68,9 +64,7 @@ char *ecex_path_join(const char *dir, const char *name) {
char *ecex_path_dirname(const char *path) {
if (!path || !path[0]) {
- char *dot = malloc(2);
- if (dot) strcpy(dot, ".");
- return dot;
+ return ecex_strdup(".");
}
char *expanded = ecex_path_expand_user(path);
@@ -98,9 +92,7 @@ char *ecex_path_basename_dup(const char *path) {
else if (slash && slash == path) base = "/";
else base = path;
}
- char *out = malloc(strlen(base) + 1);
- if (out) strcpy(out, base);
- return out;
+ return ecex_strdup(base);
}
char *ecex_path_normalize(const char *path) {
diff --git a/tests/test_buffers.c b/tests/test_buffers.c
new file mode 100644
index 0000000..7b073f4
--- /dev/null
+++ b/tests/test_buffers.c
@@ -0,0 +1,87 @@
+#include "test_core.h"
+
+#include "ecex.h"
+
+#include <assert.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+void test_buffer_editing_and_undo(void) {
+ buffer_t *buffer = buffer_new("edit", NULL, 0);
+ assert(buffer);
+
+ assert(buffer_insert(buffer, "hello") == 0);
+ assert(buffer_insert_char(buffer, '\n') == 0);
+ assert(buffer_insert(buffer, "world") == 0);
+ assert(strcmp(buffer->data, "hello\nworld") == 0);
+ assert(buffer->len == strlen("hello\nworld"));
+ assert(buffer_line_count(buffer) == 2);
+
+ buffer_set_point(buffer, 5);
+ assert(buffer_insert(buffer, ",") == 0);
+ assert(strcmp(buffer->data, "hello,\nworld") == 0);
+
+ assert(buffer_undo(buffer) == 0);
+ assert(strcmp(buffer->data, "hello\nworld") == 0);
+ assert(buffer_redo(buffer) == 0);
+ assert(strcmp(buffer->data, "hello,\nworld") == 0);
+
+ buffer_free(buffer);
+}
+
+void test_buffer_selection_and_search(void) {
+ buffer_t *buffer = buffer_new("select", NULL, 0);
+ assert(buffer);
+
+ assert(buffer_set_text(buffer, "alpha beta gamma beta") == 0);
+ buffer_set_mark(buffer, 6);
+ buffer_set_point(buffer, 10);
+ assert(buffer_has_selection(buffer) == 1);
+
+ size_t start = 0;
+ size_t end = 0;
+ buffer_selection_range(buffer, &start, &end);
+ assert(start == 6);
+ assert(end == 10);
+
+ char *selection = buffer_substring(buffer, start, end);
+ assert(selection);
+ assert(strcmp(selection, "beta") == 0);
+ free(selection);
+
+ assert(buffer_replace_selection(buffer, "delta") == 0);
+ assert(strcmp(buffer->data, "alpha delta gamma beta") == 0);
+
+ size_t pos = 0;
+ assert(buffer_search_forward(buffer, "gamma", 0, &pos) == 0);
+ assert(pos == 12);
+ assert(buffer_search_backward(buffer, "alpha", buffer->len, &pos) == 0);
+ assert(pos == 0);
+ assert(buffer_search_forward(buffer, "missing", 0, &pos) != 0);
+ assert(buffer_undo(buffer) == 0);
+ assert(strcmp(buffer->data, "alpha beta gamma beta") == 0);
+
+ buffer_free(buffer);
+}
+
+void test_buffer_file_round_trip(void) {
+ const char *path = "/tmp/ecex-test-core-buffer.txt";
+ remove(path);
+
+ buffer_t *writer = buffer_new("writer", NULL, 0);
+ assert(writer);
+ assert(buffer_set_text(writer, "one\ntwo\n") == 0);
+ assert(buffer_save_as(writer, path) == 0);
+ assert(writer->modified == 0);
+ buffer_free(writer);
+
+ buffer_t *reader = buffer_new("reader", path, 0);
+ assert(reader);
+ assert(buffer_load_file(reader, path) == 0);
+ assert(strcmp(reader->data, "one\ntwo\n") == 0);
+ assert(reader->modified == 0);
+ buffer_free(reader);
+
+ remove(path);
+}
diff --git a/tests/test_completion_path.c b/tests/test_completion_path.c
new file mode 100644
index 0000000..8170ee9
--- /dev/null
+++ b/tests/test_completion_path.c
@@ -0,0 +1,59 @@
+#include "test_core.h"
+
+#include "completion.h"
+#include "ecex.h"
+
+#include <assert.h>
+#include <stdlib.h>
+#include <string.h>
+
+void test_completion_helpers(void) {
+ assert(ecex_ascii_strncasecmp("Alpha", "alpha", 5) == 0);
+ assert(ecex_ascii_strncasecmp("Alpha", "Alpine", 4) != 0);
+ assert(ecex_ascii_contains_ci("compile-buffer", "PILE") == 1);
+ assert(ecex_ascii_contains_ci("compile-buffer", "missing") == 0);
+
+ assert(ecex_fuzzy_score("find-file", "ff") > 0);
+ assert(ecex_fuzzy_score("find-file", "zz") < 0);
+ assert(ecex_fuzzy_score("find-file", "find") > ecex_fuzzy_score("other-find", "find"));
+
+ ecex_completion_item_t items[] = {
+ {"z-file", 10, 0, 0},
+ {"a-dir", 10, 1, 1},
+ {"b-file", 30, 0, 2},
+ };
+ qsort(items, 3, sizeof(items[0]), ecex_completion_item_compare);
+ assert(strcmp(items[0].value, "b-file") == 0);
+ assert(strcmp(items[1].value, "a-dir") == 0);
+ assert(strcmp(items[2].value, "z-file") == 0);
+}
+
+void test_path_helpers(void) {
+ char small[4];
+ assert(ecex_path_copy(small, sizeof(small), "abcd") != 0);
+ assert(strcmp(small, "abc") == 0);
+
+ char *joined = ecex_path_join("/tmp", "ecex-path-test.txt");
+ assert(joined);
+ assert(strcmp(joined, "/tmp/ecex-path-test.txt") == 0);
+ free(joined);
+
+ char *absolute = ecex_path_join("/tmp", "/var/log");
+ assert(absolute);
+ assert(strcmp(absolute, "/var/log") == 0);
+ free(absolute);
+
+ char *dir = ecex_path_dirname("/tmp/ecex/file.txt");
+ assert(dir);
+ assert(strcmp(dir, "/tmp/ecex") == 0);
+ free(dir);
+
+ char *base = ecex_path_basename_dup("/tmp/ecex/file.txt");
+ assert(base);
+ assert(strcmp(base, "file.txt") == 0);
+ free(base);
+
+ assert(ecex_path_is_image("photo.PNG") == 1);
+ assert(ecex_path_is_video("clip.MKV") == 1);
+ assert(ecex_path_is_media("notes.txt") == 0);
+}
diff --git a/tests/test_core.h b/tests/test_core.h
new file mode 100644
index 0000000..a9dd52f
--- /dev/null
+++ b/tests/test_core.h
@@ -0,0 +1,13 @@
+#ifndef ECEX_TEST_CORE_H
+#define ECEX_TEST_CORE_H
+
+void test_plugin_identity(void);
+void test_plugin_slots_and_exports(void);
+void test_plugin_objects_and_text(void);
+void test_buffer_editing_and_undo(void);
+void test_buffer_selection_and_search(void);
+void test_buffer_file_round_trip(void);
+void test_completion_helpers(void);
+void test_path_helpers(void);
+
+#endif
diff --git a/tests/test_main.c b/tests/test_main.c
new file mode 100644
index 0000000..6a8dc5f
--- /dev/null
+++ b/tests/test_main.c
@@ -0,0 +1,13 @@
+#include "test_core.h"
+
+int main(void) {
+ test_plugin_identity();
+ test_plugin_slots_and_exports();
+ test_plugin_objects_and_text();
+ test_buffer_editing_and_undo();
+ test_buffer_selection_and_search();
+ test_buffer_file_round_trip();
+ test_completion_helpers();
+ test_path_helpers();
+ return 0;
+}
diff --git a/tests/test_core.c b/tests/test_plugin.c
index 42dfd99..505d363 100644
--- a/tests/test_core.c
+++ b/tests/test_plugin.c
@@ -1,10 +1,12 @@
+#include "test_core.h"
+
#include "ecex.h"
#include <assert.h>
#include <stddef.h>
#include <string.h>
-static void test_plugin_identity(void) {
+void test_plugin_identity(void) {
ecex_t ed;
memset(&ed, 0, sizeof(ed));
ed.plugins = ecex_plugin_runtime_new();
@@ -21,7 +23,7 @@ static void test_plugin_identity(void) {
ecex_plugin_runtime_free(ed.plugins);
}
-static void test_plugin_slots_and_exports(void) {
+void test_plugin_slots_and_exports(void) {
ecex_t ed;
memset(&ed, 0, sizeof(ed));
ed.plugins = ecex_plugin_runtime_new();
@@ -54,7 +56,7 @@ static void test_plugin_slots_and_exports(void) {
ecex_plugin_runtime_free(ed.plugins);
}
-static void test_plugin_objects_and_text(void) {
+void test_plugin_objects_and_text(void) {
ecex_t ed;
memset(&ed, 0, sizeof(ed));
ed.plugins = ecex_plugin_runtime_new();
@@ -79,10 +81,3 @@ static void test_plugin_objects_and_text(void) {
ecex_plugin_runtime_free(ed.plugins);
}
-
-int main(void) {
- test_plugin_identity();
- test_plugin_slots_and_exports();
- test_plugin_objects_and_text();
- return 0;
-}