ledit

Text editor (WIP)
git clone git://lumidify.org/ledit.git (fast, but not encrypted)
git clone https://lumidify.org/git/ledit.git (encrypted, but very slow)
Log | Files | Refs | README | LICENSE

commit 225b064c19ce96a38c5472b3d73367922f671dc7
parent 155a081f559a9ecfddd8b086c7d91187c2af4bc2
Author: lumidify <nobody@lumidify.org>
Date:   Sat,  4 Nov 2023 19:11:19 +0100

Add basic infrastructure for sort of automated tests

Now that I have the infrastructure, I can forget about
writing the actual tests. That's how it works, right?

Diffstat:
MMakefile | 3++-
Mkeys_basic.c | 9+--------
Mkeys_basic.h | 2+-
Mkeys_command.c | 38+++++++++++++++++++++++++++-----------
Mkeys_command.h | 2+-
Mledit.c | 365++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Atests/README | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mundo.c | 27+++++++++++++++------------
Mundo.h | 4++++
Mview.h | 2+-
10 files changed, 466 insertions(+), 45 deletions(-)

diff --git a/Makefile b/Makefile @@ -12,6 +12,7 @@ MAN5 = leditrc.5 MISCFILES = Makefile README LICENSE IDEAS NOTES TODO DEBUG=0 +TEST=0 SANITIZE=0 ENABLE_UTF8PROC=1 @@ -78,7 +79,7 @@ EXTRA_LDFLAGS_UTF8PROC1 = `pkg-config --libs libutf8proc` # Xcursor isn't actually needed right now since I'm not using the drag 'n drop functionality # of ctrlsel yet, but since it's moderately likely that I will use that in the future, I # decided to just leave it in. -CFLAGS_LEDIT = ${EXTRA_FLAGS_SANITIZE${SANITIZE}} ${EXTRA_CFLAGS_DEBUG${DEBUG}} ${EXTRA_CFLAGS_UTF8PROC${ENABLE_UTF8PROC}} -Wall -Wextra -pedantic -D_POSIX_C_SOURCE=200809L -std=c99 `pkg-config --cflags x11 xkbfile pangoxft xext xcursor` +CFLAGS_LEDIT = -DTEST=${TEST} ${EXTRA_FLAGS_SANITIZE${SANITIZE}} ${EXTRA_CFLAGS_DEBUG${DEBUG}} ${EXTRA_CFLAGS_UTF8PROC${ENABLE_UTF8PROC}} -Wall -Wextra -pedantic -D_POSIX_C_SOURCE=200809L -std=c99 `pkg-config --cflags x11 xkbfile pangoxft xext xcursor` LDFLAGS_LEDIT = ${EXTRA_FLAGS_SANITIZE${SANITIZE}} ${EXTRA_LDFLAGS_DEBUG${DEBUG}} ${EXTRA_LDFLAGS_UTF8PROC${ENABLE_UTF8PROC}} `pkg-config --libs x11 xkbfile pangoxft xext xcursor` -lm all: ${BIN} diff --git a/keys_basic.c b/keys_basic.c @@ -2709,14 +2709,7 @@ repeat_command(ledit_view *view, char *text, size_t len) { } struct action -basic_key_handler(ledit_view *view, XEvent *event, int lang_index) { - char *buf = NULL; - KeySym sym = NoSymbol; - int n; - - unsigned int key_state = event->xkey.state; - preprocess_key(view->window, &event->xkey, &sym, &buf, &n); - +basic_key_handler(ledit_view *view, unsigned int key_state, KeySym sym, char *buf, int n, int lang_index) { struct repetition_stack_elem *re = push_repetition_stack(); re->key_text = ledit_strndup(buf, (size_t)n); re->len = (size_t)n; diff --git a/keys_basic.h b/keys_basic.h @@ -11,6 +11,6 @@ int basic_key_cb_modemask_is_valid(basic_key_cb *cb, ledit_mode modes); /* perform cleanup of global data */ void basic_key_cleanup(void); -struct action basic_key_handler(ledit_view *view, XEvent *event, int lang_index); +struct action basic_key_handler(ledit_view *view, unsigned int key_state, KeySym sym, char *buf, int n, int lang_index); #endif diff --git a/keys_command.c b/keys_command.c @@ -215,11 +215,16 @@ static int parse_range( static int handle_cmd(ledit_view *view, char *cmd, size_t len, size_t lang_index); /* FIXME: USE LEN EVERYWHERE INSTEAD OF RELYING ON cmd BEING NUL-TERMINATED */ -/* FIXME: return error so write_quit knows when to quit */ +/* returns 1 on error, 0 otherwise */ static int -handle_write(ledit_view *view, char *cmd, size_t l1, size_t l2) { - (void)l1; - (void)l2; +handle_write_base(ledit_view *view, char *cmd) { + #if TEST + /* disallow normal file writing in test mode so no + file can accidentally be destroyed by fuzz testing */ + (void)view; + (void)cmd; + return 0; + #else /* FIXME: allow writing only part of file */ char *filename = view->buffer->filename; int stored = 1; @@ -248,6 +253,7 @@ handle_write(ledit_view *view, char *cmd, size_t l1, size_t l2) { "%s: file modification time changed; use ! to override", filename ); + return 1; /* FIXME: I guess the file can still exist if stat returns an error, but the writing itself will probably fail then as well. */ } else if (!ret && !force && !stored) { @@ -256,8 +262,10 @@ handle_write(ledit_view *view, char *cmd, size_t l1, size_t l2) { "%s: file exists; use ! to override", filename ); + return 1; } else if (buffer_write_to_filename(view->buffer, filename, &errstr)) { window_show_message_fmt(view->window, "Error writing %s: %s", filename, errstr); + return 1; } else { /* FIXME: better message */ window_show_message_fmt(view->window, "Wrote file %s", filename); @@ -270,8 +278,18 @@ handle_write(ledit_view *view, char *cmd, size_t l1, size_t l2) { } } else { window_show_message(view->window, "No file name", -1); + return 1; } return 0; + #endif +} + +static int +handle_write(ledit_view *view, char *cmd, size_t l1, size_t l2) { + (void)l1; + (void)l2; + handle_write_base(view, cmd); + return 0; } static int @@ -320,7 +338,10 @@ close_view(ledit_view *view, char *cmd, size_t l1, size_t l2) { static int handle_write_quit(ledit_view *view, char *cmd, size_t l1, size_t l2) { - handle_write(view, cmd, l1, l2); + (void)l1; + (void)l2; + if (handle_write_base(view, cmd)) + return 0; ledit_cleanup(); exit(0); return 0; @@ -984,14 +1005,9 @@ edit_discard(ledit_view *view, char *key_text, size_t len, size_t lang_index) { } struct action -command_key_handler(ledit_view *view, XEvent *event, int lang_index) { - char *buf = NULL; - KeySym sym = NoSymbol; - int n; +command_key_handler(ledit_view *view, unsigned int key_state, KeySym sym, char *buf, int n, int lang_index) { command_key_array *cur_keys = config_get_command_keys(lang_index); size_t num_keys = cur_keys->num_keys; - unsigned int key_state = event->xkey.state; - preprocess_key(view->window, &event->xkey, &sym, &buf, &n); int grabkey = 1, found = 0; command_key_cb_flags flags = KEY_FLAG_NONE; for (size_t i = 0; i < num_keys; i++) { diff --git a/keys_command.h b/keys_command.h @@ -17,6 +17,6 @@ void search_next(ledit_view *view); void search_prev(ledit_view *view); void command_key_cleanup(void); -struct action command_key_handler(ledit_view *view, XEvent *event, int lang_index); +struct action command_key_handler(ledit_view *view, unsigned int key_state, KeySym sym, char *buf, int n, int lang_index); #endif diff --git a/ledit.c b/ledit.c @@ -12,6 +12,9 @@ #include <pwd.h> #include <time.h> +#if TEST +#include <fcntl.h> +#endif #include <errno.h> #include <stdio.h> #include <stdlib.h> @@ -45,15 +48,322 @@ static void setup(int argc, char *argv[]); static void redraw(void); static void change_keyboard(char *lang); -static void key_press(ledit_view *view, XEvent *event); +static void key_press_event(ledit_view *view, XEvent *event); +static void key_press(ledit_view *view, unsigned int key_state, KeySym sym, char *buf, int n); ledit_common common; ledit_clipboard *clipboard = NULL; ledit_buffer *buffer = NULL; size_t cur_lang = 0; +#if TEST +static struct { + char *read; /* text read from stdin */ + size_t read_len; /* length of text in read buffer */ + size_t read_alloc; /* size of read buffer */ + size_t line_start; /* start of current line */ + size_t read_cur; /* length of text already read */ +} test_status = {NULL, 0, 0, 0, 0}; + +#define READ_BLK_SIZE 128 + +/* Read up to READ_BLK_SIZE bytes from stdin. + Returns 1 if an error occurred, -1 if not new data available, 0 otherwise. */ +static int +read_input(void) { + if (test_status.read_cur > 0) { + memmove(test_status.read, test_status.read + test_status.read_cur, test_status.read_len - test_status.read_cur); + test_status.read_len -= test_status.read_cur; + test_status.read_cur = 0; + } + int nread; + test_status.read_alloc = ideal_array_size(test_status.read_alloc, test_status.read_len + READ_BLK_SIZE); + test_status.read = ledit_realloc(test_status.read, test_status.read_alloc); + nread = read(fileno(stdin), test_status.read + test_status.read_len, READ_BLK_SIZE); + if (nread == -1 && errno == EAGAIN) + return -1; + else if (nread == -1 || nread == 0) + return 1; + test_status.read_len += nread; + + return 0; +} + +/* based partially on OpenBSD's strtonum */ +int +read_rangeint(long long *ret, int end, long long min, long long max) { + if (test_status.read_cur >= test_status.read_len || test_status.read[test_status.read_cur] != ' ') + return 1; + char end_char = end ? '\n' : ' '; + size_t len = 0; + test_status.read_cur++; + char *str = test_status.read + test_status.read_cur; + int found = 0; + for (; test_status.read_cur < test_status.read_len; test_status.read_cur++) { + if (test_status.read[test_status.read_cur] == end_char) { + found = 1; + break; + } + len++; + } + if (!found || len == 0) + return 1; + /* the string needs to be nul-terminated + if it contains more than 11 characters (10 digits + sign), + it's illegal anyways (at least for these testing purposes...) */ + if (len > 11) + return 1; + char nstr[12]; + strncpy(nstr, str, len); + nstr[len] = '\0'; + char *num_end; + long long ll = strtoll(nstr, &num_end, 10); + if (nstr == num_end || *num_end != '\0' || + ll < min || ll > max || ((ll == LLONG_MIN || + ll == LLONG_MAX) && errno == ERANGE)) { + return 1; + } + *ret = ll; + if (end) + test_status.read_cur++; + return 0; +} + +int +read_uint(unsigned int *ret, int end) { + long long l; + int err = read_rangeint(&l, end, 0, UINT_MAX); + *ret = (unsigned int)l; + return err; +} + +int +read_int(int *ret, int end) { + long long l; + int err = read_rangeint(&l, end, INT_MIN, INT_MAX); + *ret = (int)l; + return err; +} + +int +read_text(char **text, size_t *text_len) { + if (test_status.read_cur >= test_status.read_len || test_status.read[test_status.read_cur] != ' ') + return 1; + int bs = 0; + int offset = 0; + test_status.read_cur++; + size_t start = test_status.read_cur; + *text = test_status.read + test_status.read_cur; + int found = 0; + for (; test_status.read_cur < test_status.read_len; test_status.read_cur++) { + if (test_status.read[test_status.read_cur] == '\\') { + bs++; + if (bs / 2) + offset++; + bs %= 2; + test_status.read[test_status.read_cur - offset] = '\\'; + } else if (test_status.read[test_status.read_cur] == '\n') { + if (!bs) { + found = 1; + break; + } else { + bs = 0; + offset++; + test_status.read[test_status.read_cur - offset] = '\n'; + } + } else { + test_status.read[test_status.read_cur - offset] = test_status.read[test_status.read_cur]; + bs = 0; + } + } + if (!found) + return 1; + *text_len = test_status.read_cur - start - offset; + test_status.read_cur++; + return 0; +} + +int +read_filename(char **text, size_t *text_len) { + if (read_text(text, text_len)) + return 1; + for (size_t i = 0; i < *text_len; i++) { + if ((*text)[i] == '/' || (*text)[i] == '\0') + return 1; + } + return 0; +} + +static unsigned int view_num = 0; +/* Process commands in test_status. + Returns 0 if no complete commands are contained in read buffer, 1 otherwise. */ +static int +process_commands(void) { + int bs = 0; + int found = 0; + size_t nl_index = 0; + for (size_t i = test_status.read_cur; i < test_status.read_len; i++) { + if (test_status.read[i] == '\\') { + bs++; + bs %= 2; + } else if (test_status.read[i] == '\n' && bs == 0) { + found = 1; + nl_index = i; + break; + } else { + bs = 0; + } + } + if (!found) + return 0; + unsigned int key_state, button_num, keysym, new_view; + char *text, *term, *errstr; + size_t text_len; + int x, y; + XEvent e; + FILE *file; + test_status.read_cur += 1; + ledit_view *view = buffer->views[view_num]; + switch (test_status.read[test_status.read_cur-1]) { + case 'k': + /* key press */ + /* k key_state keysym text */ + if (read_uint(&key_state, 0)) + goto error; + if (read_uint(&keysym, 0)) + goto error; + if (read_text(&text, &text_len)) + goto error; + key_press(view, key_state, keysym, text, (int)text_len); + break; + case 'p': + /* mouse button press */ + /* p button_num x y */ + if (read_uint(&button_num, 0)) + goto error; + if (read_int(&x, 0)) + goto error; + if (read_int(&y, 1)) + goto error; + e = (XEvent){.xbutton = {.type = ButtonPress, .button = button_num, .x = x, .y = y}}; + window_register_button_press(view->window, &e); + break; + case 'r': + /* mouse button release */ + /* r button_num x y */ + if (read_uint(&button_num, 0)) + goto error; + if (read_int(&x, 0)) + goto error; + if (read_int(&y, 1)) + goto error; + e = (XEvent){.xbutton = {.type = ButtonRelease, .button = button_num, .x = x, .y = y}}; + window_button_release(view->window, &e); + break; + case 'm': + /* mouse motion */ + /* m x y */ + if (read_int(&x, 0)) + goto error; + if (read_int(&y, 1)) + goto error; + e = (XEvent){.xmotion = {.type = MotionNotify, .x = x, .y = y}}; + window_register_motion(view->window, &e); + break; + case 'l': + /* language switch */ + /* l lang_name */ + if (read_text(&text, &text_len)) + goto error; + term = ledit_strndup(text, text_len); + change_keyboard(term); + free(term); + break; + case 's': + /* switch view */ + /* s view_num */ + if (read_uint(&new_view, 1)) + goto error; + if (new_view >= buffer->views_num) + fprintf(stderr, "Invalid view number %u\n", new_view); + else + view_num = new_view; + break; + case 'w': + /* write contents of buffer */ + /* w file_name */ + if (read_filename(&text, &text_len)) + goto error; + term = ledit_strndup(text, text_len); + if (buffer_write_to_filename(buffer, term, &errstr)) + fprintf(stderr, "Error writing %s: %s\n", term, errstr); + free(term); + break; + case 'd': + /* dump other info to file */ + /* d file_name */ + if (read_filename(&text, &text_len)) + goto error; + term = ledit_strndup(text, text_len); + file = fopen(term, "w"); + if (!file) { + fprintf(stderr, "Unable to open file %s\n", term); + } else { + fprintf( + file, + "cursor_line: %zu, cursor_byte: %zu, sel_valid: %d, " + "sel_line1: %zu, sel_byte1: %zu, " + "sel_line2: %zu, sel_byte2: %zu\n", + view->cur_line, view->cur_index, view->sel_valid, + view->sel.line1, view->sel.byte1, + view->sel.line2, view->sel.byte2 + ); + fclose(file); + } + free(term); + break; + case 'u': + /* dump undo stack to file */ + if (read_filename(&text, &text_len)) + goto error; + /* u file_name */ + term = ledit_strndup(text, text_len); + file = fopen(term, "w"); + if (!file) { + fprintf(stderr, "Unable to open file %s\n", term); + } else { + dump_undo_stack(file, buffer->undo); + fclose(file); + } + free(term); + break; + default: + goto error; + } + return 1; +error: + fprintf(stderr, "Error parsing command.\n"); + test_status.read_cur = nl_index + 1; + return 1; +} +#endif + +/* can only be set to 1 when compiled with TEST */ +static int test_extra = 0; + static void mainloop(void) { + #if TEST + int flags = fcntl(fileno(stdin), F_GETFL, 0); + if (flags == -1) { + fprintf(stderr, "Unable to set non-blocking mode on stdin.\n"); + return; + } + if (fcntl(fileno(stdin), F_SETFL, flags | O_NONBLOCK)) { + fprintf(stderr, "Unable to set non-blocking mode on stdin.\n"); + return; + } + #endif XEvent event; int xkb_event_type; int major, minor; @@ -138,16 +448,20 @@ mainloop(void) { window_register_resize(view->window, &event); break; case ButtonPress: - window_register_button_press(view->window, &event); + if (!test_extra) + window_register_button_press(view->window, &event); break; case ButtonRelease: - window_button_release(view->window, &event); + if (!test_extra) + window_button_release(view->window, &event); break; case MotionNotify: - window_register_motion(window, &event); + if (!test_extra) + window_register_motion(window, &event); break; case KeyPress: - key_press(view, &event); + if (!test_extra) + key_press_event(view, &event); break; case ClientMessage: if ((Atom)event.xclient.data.l[0] == view->window->wm_delete_msg) { @@ -161,11 +475,22 @@ mainloop(void) { } }; + #if TEST + int ret; + if ((ret = read_input()) == 1) { + fprintf(stderr, "Unable to read text from stdin.\n"); + } else if (ret == 0) { + while (process_commands()) { + /* NOP */ + } + } + #endif + for (size_t i = 0; i < buffer->views_num; i++) { window_handle_filtered_events(buffer->views[i]->window); } - if (change_kbd) { + if (!test_extra && change_kbd) { change_kbd = 0; XkbStateRec s; XkbGetState(common.dpy, XkbUseCoreKbd, &s); @@ -201,11 +526,21 @@ setup(int argc, char *argv[]) { char c; char *opt_filename = NULL; - while ((c = getopt(argc, argv, "c:")) != -1) { + #if TEST + char *opts = "tc:"; + #else + char *opts = "c:"; + #endif + while ((c = getopt(argc, argv, opts)) != -1) { switch (c) { case 'c': opt_filename = optarg; break; + #if TEST + case 't': + test_extra = 1; + break; + #endif default: fprintf(stderr, "USAGE: ledit [-c config] [file]\n"); exit(1); @@ -517,16 +852,26 @@ change_keyboard(char *lang) { } static void -key_press(ledit_view *view, XEvent *event) { +key_press(ledit_view *view, unsigned int key_state, KeySym sym, char *buf, int n) { /* FIXME: just let view handle this since the action is part of it anyways now */ if (view->cur_action.type == ACTION_GRABKEY && view->cur_action.callback) { - view->cur_action = view->cur_action.callback(view, event, cur_lang); + view->cur_action = view->cur_action.callback(view, key_state, sym, buf, n, cur_lang); } else { - view->cur_action = basic_key_handler(view, event, cur_lang); + view->cur_action = basic_key_handler(view, key_state, sym, buf, n, cur_lang); } } +static void +key_press_event(ledit_view *view, XEvent *event) { + char *buf = NULL; + KeySym sym = NoSymbol; + int n; + unsigned int key_state = event->xkey.state; + preprocess_key(view->window, &event->xkey, &sym, &buf, &n); + key_press(view, key_state, sym, buf, n); +} + int main(int argc, char *argv[]) { setup(argc, argv); diff --git a/tests/README b/tests/README @@ -0,0 +1,59 @@ +There aren't any proper tests currently, but some infrastructure is in place to support them. + +When compiled with TEST=1, ledit accepts commands on standard input to generate fake events. +Each command ends in newline. If the last argument is text, it may also contain newlines if +they are escaped with backslash. Single backslashes that are not in front of a newline are +just taken verbatim, but two backslashes are collapsed into one. Filenames are a special +case because they are not allowed to contain '/' or '\0'. + +The commands to generate events take raw integers instead of symbolic names for keysyms +and other parameters. These need to be given using the definitions from Xlib. + +The commands currently supported are the following: + +k <key_state> <keysym> <text> + +Generate a keypress event. <key_state> and <keysym> are the modifier state and keysym. + +p <button_num> <x> <y> + +Generate a mouse button press event. + +r <button_num> <x> <y> + +Generate a mouse button release event. + +m <x> <y> + +Generate a mouse motion event. + +l <lang> + +Switch to keyboard layout <lang>. + +s <view_num> + +Switch to view <view_num>, if it exists. + +w <filename> + +Write the contents of the buffer to <filename>. + +d <filename> + +Dump various information to <filename>. Currently, the cursor position and +information about the selection is given. See ledit.c for the exact format. + +u <filename> + +Dump the undo stack to <filename>. See undo.c for the exact format. + +TODO: Add more commands, e.g. for dumping the repetition stack. + + +When compiled with TEST=1, ledit supports an additional command-line argument '-t'. +This disables handling of the regular key press, mouse, and language switch events +in order to avoid messing with the results. + +Note that regular file writing using :w is disabled when compiled with TEST=1 +in order to avoid overwriting anything important if fuzz testing is done. diff --git a/undo.c b/undo.c @@ -101,22 +101,27 @@ undo_change_mode_group(undo_stack *undo) { undo->change_mode_group = 1; } -/* -static void -dump_undo(undo_stack *undo) { - printf("START UNDO STACK\n"); - printf("cur: %zu\n", undo->cur); +#if TEST +void +dump_undo_stack(FILE *file, undo_stack *undo) { + fprintf( + file, + "cur: %zu, cur_valid: %d, change_mode_group: %d, len: %zu, cap: %zu\n", + undo->cur, undo->cur_valid, undo->change_mode_group, undo->len, undo->cap + ); for (size_t i = 0; i < undo->len; i++) { undo_elem *e = &undo->stack[i]; - printf( - "type %d, mode %d, group %d, mode_group %d, text '%.*s', range (%zu, %zu)\n", + fprintf( + file, + "type %d, mode %d, group %d, mode_group %d, text '%.*s', " + "op_range (%zu,%zu;%zu,%zu), cursor_range (%zu,%zu;%zu,%zu)\n", e->type, e->mode, e->group, e->mode_group, (int)e->text->len, e->text->text, - e->op_range.byte1, e->op_range.byte2 + e->op_range.line1, e->op_range.byte1, e->op_range.line2, e->op_range.byte2, + e->cursor_range.line1, e->cursor_range.byte1, e->cursor_range.line2, e->cursor_range.byte2 ); } - printf("END UNDO STACK\n"); } -*/ +#endif static void push_undo( @@ -140,7 +145,6 @@ push_undo( txtbuf_copy(e->text, text); else e->text = txtbuf_dup(text); - /* dump_undo(undo); */ } void @@ -243,7 +247,6 @@ ledit_undo(undo_stack *undo, ledit_mode mode, void *callback_data, *min_line_ret = min_line; if (mode == NORMAL || mode == VISUAL) undo_change_mode_group(undo); - /* dump_undo(undo); */ return UNDO_NORMAL; } diff --git a/undo.h b/undo.h @@ -133,4 +133,8 @@ void undo_change_last_cur_range(undo_stack *undo, ledit_range cur_range); */ char *undo_state_to_str(undo_status s); +#if TEST +void dump_undo_stack(FILE *file, undo_stack *undo); +#endif + #endif diff --git a/view.h b/view.h @@ -26,7 +26,7 @@ enum action_type { main event manager what key handler to call next */ struct action { enum action_type type; - struct action (*callback)(ledit_view *view, XEvent *event, int lang_index); + struct action (*callback)(ledit_view *view, unsigned int key_state, KeySym sym, char *buf, int n, int lang_index); }; typedef struct {