#include "input.h" #include "util.h" #include #include #include #include static int failures = 0; static int checks = 0; #define CHECK(cond, msg) \ do { \ checks++; \ if (!(cond)) { \ failures++; \ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ } \ } while (0) #define CHECK_CONT(hay, needle, msg) \ do { \ checks++; \ if (!hay || !strstr(hay, needle)) { \ failures++; \ fprintf(stderr, \ "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \ (needle)); \ } \ } while (0) #define CHECK_NOT_CONT(hay, needle, msg) \ do { \ checks++; \ if (hay && strstr(hay, needle)) { \ failures++; \ fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \ __FILE__, __LINE__, (msg), (needle), hay); \ } \ } while (0) #define CHECK_STR(got, want, msg) \ do { \ checks++; \ if (!got || strcmp(got, want) != 0) { \ failures++; \ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \ __FILE__, __LINE__, (msg), got ? got : "(null)", (want));\ } \ } while (0) /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ static input_key kkind(int kind) { input_key k; k.kind = kind; k.cp = 0; return k; } static evlist feed(const unsigned char *bytes, size_t n) { key_parser p; evlist ev; key_parser_init(&p); evlist_init(&ev); key_parser_feed(&p, bytes, n, &ev); key_parser_flush(&p, &ev); key_parser_free(&p); return ev; } /* Apply a run of parsed keys; every action must be ACT_NONE. */ static void type_str(editor *e, const char *s) { evlist ev = feed((const unsigned char *)s, strlen(s)); for (size_t i = 0; i < ev.len; i++) { input_action a; editor_apply(e, ev.items[i], &a); CHECK(a.kind == ACT_NONE, "type_str: unexpected action while typing"); input_action_free(&a); } evlist_free(&ev); } /* Apply a single named key and release its action. */ static void apply_key(editor *e, int kind) { input_action a; editor_apply(e, kkind(kind), &a); input_action_free(&a); } /* ================================================================== */ /* Parser tests */ /* ================================================================== */ static void test_parser_plain_chars(void) { evlist ev = feed((const unsigned char *)"abc", 3); CHECK(ev.len == 3, "three chars"); if (ev.len == 3) { CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a"); CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 'b', "b"); CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 'c', "c"); CHECK(ev.raws[0] && ev.lens[0] == 1 && ev.raws[0][0] == 'a', "raw a"); CHECK(ev.raws[2] && ev.lens[2] == 1 && ev.raws[2][0] == 'c', "raw c"); } evlist_free(&ev); } static void test_parser_enter_variants(void) { evlist ev = feed((const unsigned char *)"\r", 1); CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CR -> Enter"); evlist_free(&ev); /* LF (Ctrl+J) is a dead key outside of paste: no event. */ ev = feed((const unsigned char *)"\n", 1); CHECK(ev.len == 0, "LF -> no event (dead key)"); evlist_free(&ev); } /* Shift-modified Enter and Esc carry no special binding: they behave as the * plain key. */ static void test_parser_shift_enter_and_esc(void) { evlist ev = feed((const unsigned char *)"\x1b[13;2u", 7); CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CSI 13;2 -> Enter"); evlist_free(&ev); ev = feed((const unsigned char *)"\x1b[27;2u", 7); CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "CSI 27;2 -> Esc"); evlist_free(&ev); } static void test_parser_lone_escape_is_esc(void) { /* a lone ESC is held until flush */ key_parser p; evlist ev; key_parser_init(&p); evlist_init(&ev); key_parser_feed(&p, (const unsigned char *)"\x1b", 1, &ev); CHECK(ev.len == 0, "no event before flush"); CHECK(key_parser_flush(&p, &ev) == 1, "flush emits one"); CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "flush -> Esc"); evlist_free(&ev); key_parser_free(&p); } static void test_parser_alt_letter_is_the_letter(void) { evlist ev = feed((const unsigned char *)"\x1b" "a", 2); CHECK(ev.len == 1 && ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "alt+a -> Char('a')"); if (ev.len == 1) { CHECK(ev.raws[0] && ev.lens[0] == 2 && ev.raws[0][0] == '\x1b' && ev.raws[0][1] == 'a', "raw = ESC a"); } evlist_free(&ev); } static void test_parser_raw_bytes_preserved(void) { evlist ev = feed((const unsigned char *)"\x1b[1;5A", 6); CHECK(ev.len == 1 && ev.items[0].kind == KEY_UP, "UP"); if (ev.len == 1) { CHECK(ev.raws[0] && ev.lens[0] == 6, "raw len 6"); if (ev.raws[0] && ev.lens[0] == 6) CHECK(memcmp(ev.raws[0], "\x1b[1;5A", 6) == 0, "raw bytes"); } evlist_free(&ev); } static void test_parser_control_keys(void) { struct { unsigned char b; int want; const char *m; } cases[] = { {0x01, KEY_CTRL_A, "Ctrl-A"}, {0x03, KEY_CTRL_C, "Ctrl-C"}, {0x04, KEY_CTRL_D, "Ctrl-D"}, {0x05, KEY_CTRL_E, "Ctrl-E"}, {0x0B, KEY_CTRL_K, "Ctrl-K"}, {0x0C, KEY_CTRL_L, "Ctrl-L"}, {0x15, KEY_CTRL_U, "Ctrl-U"}, {0x17, KEY_CTRL_W, "Ctrl-W"}, {0x19, KEY_CTRL_Y, "Ctrl-Y"}, {0x1F, KEY_UNDO, "Ctrl-_ (undo)"}, }; for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { evlist ev = feed(&cases[i].b, 1); CHECK(ev.len == 1, cases[i].m); if (ev.len == 1) { checks++; if (ev.items[0].kind != cases[i].want) { failures++; fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__, __LINE__, cases[i].m, ev.items[0].kind, cases[i].want); } } evlist_free(&ev); } unsigned char bs[2] = {0x7F, 0x08}; for (int i = 0; i < 2; i++) { evlist ev = feed(&bs[i], 1); CHECK(ev.len == 1 && ev.items[0].kind == KEY_BACKSPACE, "backspace"); evlist_free(&ev); } } static void test_parser_unmapped_control_bytes(void) { int mapped[256] = {0}; mapped[0x09] = mapped[0x0A] = mapped[0x0D] = mapped[0x1B] = 1; for (int b = 0; b < 0x20; b++) { if (mapped[b]) continue; unsigned char c = (unsigned char)b; evlist ev = feed(&c, 1); CHECK(ev.len == 1, "exactly one control event"); if (ev.len == 1) CHECK(ev.items[0].kind != KEY_CHAR, "not a char"); evlist_free(&ev); } } static void test_parser_unknown_sequence_consumed(void) { // Unmapped CSI tilde: empty or a single Unknown (oracle allows either). evlist ev = feed((const unsigned char *)"\x1b[999~", 6); CHECK(ev.len == 0 || (ev.len == 1 && ev.items[0].kind == KEY_UNKNOWN), "unknown CSI ~"); evlist_free(&ev); // Empty bracketed paste: markers consumed, no events. ev = feed((const unsigned char *)"\x1b[200~\x1b[201~", 12); CHECK(ev.len == 0, "empty paste -> no events"); evlist_free(&ev); // Mouse reports must not surface as editor keys. ev = feed((const unsigned char *)"\x1b[M", 3); CHECK(ev.len == 0, "mouse SGR -> no events"); evlist_free(&ev); ev = feed((const unsigned char *)"\x1b[64;10;5M", 10); CHECK(ev.len == 0, "mouse CSI -> no events"); evlist_free(&ev); // Cursor position reports (DSR responses) are consumed. ev = feed((const unsigned char *)"\x1b[12;40R", 8); CHECK(ev.len == 0, "DSR report -> no events"); evlist_free(&ev); } static void test_parser_bracketed_paste(void) { // Paste start marker: no event on its own. evlist ev = feed((const unsigned char *)"\x1b[200~", 6); CHECK(ev.len == 0, "paste start -> no event"); evlist_free(&ev); // Pasted content is individual char events; a pasted CR is a Newline. ev = feed((const unsigned char *)"\x1b[200~hello\rworld\x1b[201~", 23); CHECK(ev.len == 11, "paste content = 11 events"); if (ev.len == 11) { CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h"); CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o"); CHECK(ev.items[5].kind == KEY_NEWLINE, "pasted CR -> Newline"); CHECK(ev.items[10].kind == KEY_CHAR && ev.items[10].cp == 'd', "d"); } evlist_free(&ev); } static void test_parser_utf8(void) { evlist ev = feed((const unsigned char *)"h\xc3\xa9llo", 6); CHECK(ev.len == 5, "five codepoints"); if (ev.len == 5) { CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h"); CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 0xE9, "e-acute"); CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o"); } evlist_free(&ev); /* split across feed() calls */ key_parser p; evlist ev2; key_parser_init(&p); evlist_init(&ev2); key_parser_feed(&p, (const unsigned char *)"h\xc3", 2, &ev2); CHECK(ev2.len == 1 && ev2.items[0].kind == KEY_CHAR && ev2.items[0].cp == 'h', "h flushed, partial UTF-8 held"); key_parser_feed(&p, (const unsigned char *)"\xa9llo", 4, &ev2); key_parser_flush(&p, &ev2); CHECK(ev2.len == 5, "recombined to five codepoints"); evlist_free(&ev2); key_parser_free(&p); evlist ev3 = feed((const unsigned char *)"\xff", 1); CHECK(ev3.len == 1 && ev3.items[0].kind == KEY_UNKNOWN, "invalid 0xFF"); evlist_free(&ev3); } static void test_parser_mixed_stream(void) { unsigned char seq[] = "a\x1b[1;5A\xc3\xa9\x1b[13u"; evlist ev = feed(seq, sizeof(seq) - 1); CHECK(ev.len == 4, "four events"); if (ev.len == 4) { CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a"); CHECK(ev.items[1].kind == KEY_UP, "UP"); CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 0xE9, "e-acute"); CHECK(ev.items[3].kind == KEY_ENTER, "Enter (CSI 13)"); } evlist_free(&ev); } static void test_parser_kitty_and_ss3(void) { struct { const char *seq; int want; const char *m; } cases[] = { {"\x1b[8593;1u", KEY_UP, "kitty up"}, {"\x1b[8594;1u", KEY_RIGHT, "kitty right"}, {"\x1b[9u", KEY_TAB, "kitty tab"}, {"\x1b[9;2u", KEY_BACKTAB, "kitty backtab"}, {"\x1b[12u", KEY_BACKSPACE, "kitty backspace"}, {"\x1b[127u", KEY_DELETE, "kitty delete"}, {"\x1b[13u", KEY_ENTER, "kitty enter"}, {"\x1b[27u", KEY_ESC, "kitty esc"}, {"\x1bOA", KEY_UP, "ss3 up"}, {"\x1bOD", KEY_LEFT, "ss3 left"}, {"\x1b[H", KEY_HOME, "csi home"}, {"\x1b[3~", KEY_DELETE, "csi delete"}, {"\x1b[4~", KEY_END, "csi end"}, {"\x1b[5~", KEY_PAGEUP, "csi pageup"}, {"\x1b[6~", KEY_PAGEDOWN, "csi pagedown"}, }; for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { const char *seq = cases[i].seq; evlist ev = feed((const unsigned char *)seq, strlen(seq)); CHECK(ev.len == 1, cases[i].m); if (ev.len == 1) { checks++; if (ev.items[0].kind != cases[i].want) { failures++; fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__, __LINE__, cases[i].m, ev.items[0].kind, cases[i].want); } } evlist_free(&ev); } } /* ================================================================== */ /* Editor tests */ /* ================================================================== */ static void test_editor_typing_and_submit(void) { editor e; editor_init(&e); type_str(&e, "hello"); char *t = editor_text(&e); CHECK_STR(t, "hello", "text after typing"); free(t); input_action a; editor_apply(&e, kkind(KEY_ENTER), &a); CHECK(a.kind == ACT_SUBMIT, "Enter submits"); if (a.kind == ACT_SUBMIT) CHECK_STR(a.text, "hello", "submitted text"); input_action_free(&a); editor_free(&e); } static void test_editor_shift_enter_inserts_newline(void) { editor e; editor_init(&e); type_str(&e, "he"); apply_key(&e, KEY_NEWLINE); type_str(&e, "llo"); char *t = editor_text(&e); CHECK_STR(t, "he\nllo", "NEWLINE key inserts a newline"); free(t); CHECK(editor_line_count(&e) == 2, "two lines"); input_action a; editor_apply(&e, kkind(KEY_ENTER), &a); CHECK(a.kind == ACT_SUBMIT, "Enter submits"); if (a.kind == ACT_SUBMIT) CHECK_STR(a.text, "he\nllo", "submitted multi-line text"); input_action_free(&a); editor_free(&e); } /* Backslash + Enter at the end of the buffer is a line continuation (a * universal newline trigger for terminals where Shift+Enter is not * distinguishable from Enter); any other Enter still submits. */ static void test_editor_backslash_enter_continues(void) { editor e; editor_init(&e); type_str(&e, "ls \\"); input_action a; editor_apply(&e, kkind(KEY_ENTER), &a); CHECK(a.kind == ACT_NEWLINE, "trailing backslash + Enter -> continuation"); input_action_free(&a); char *t = editor_text(&e); CHECK_STR(t, "ls \n", "backslash became a newline"); free(t); CHECK(e.caret == 4, "caret after the newline"); type_str(&e, "-la"); editor_apply(&e, kkind(KEY_ENTER), &a); CHECK(a.kind == ACT_SUBMIT, "second line's Enter submits"); if (a.kind == ACT_SUBMIT) CHECK_STR(a.text, "ls \n-la", "multi-line text submitted"); input_action_free(&a); editor_free(&e); /* No backslash before the caret -> Enter still submits. */ editor f; editor_init(&f); type_str(&f, "a\\b"); input_action b; editor_apply(&f, kkind(KEY_ENTER), &b); CHECK(b.kind == ACT_SUBMIT, "no backslash before caret submits"); if (b.kind == ACT_SUBMIT) CHECK_STR(b.text, "a\\b", "text submitted unchanged"); input_action_free(&b); editor_free(&f); /* A backslash before the caret continues even with text after it. */ editor g; editor_init(&g); type_str(&g, "a\\b"); apply_key(&g, KEY_LEFT); /* caret sits right after the backslash */ input_action c; editor_apply(&g, kkind(KEY_ENTER), &c); CHECK(c.kind == ACT_NEWLINE, "backslash before caret continues"); input_action_free(&c); char *gt = editor_text(&g); CHECK_STR(gt, "a\nb", "newline inserted after the backslash"); free(gt); editor_free(&g); } /* Regression: after building a multi-line buffer and moving the caret UP to * a middle line, backslash+Enter on that line must split it (insert a * newline), not submit. Previously the continuation only fired when the * caret was at the very end of the whole buffer. */ static void test_editor_middle_line_continuation(void) { editor e; editor_init(&e); /* Build "alpha \nbeta": line 1 "alpha ", line 2 "beta". */ type_str(&e, "alpha "); type_str(&e, "\\"); apply_key(&e, KEY_ENTER); /* "alpha \" -> "alpha \n" */ type_str(&e, "beta"); /* -> "alpha \nbeta", caret at end */ CHECK(editor_line_count(&e) == 2, "two lines before the split"); /* Move the caret UP to the middle (first) line. */ apply_key(&e, KEY_UP); /* Walk to the end of that line (just before the newline). */ while (e.caret < strlen(e.text) && e.text[e.caret] != '\n') apply_key(&e, KEY_RIGHT); CHECK(e.caret == 6, "caret at end of the middle line"); /* Type a backslash on the middle line, then Enter -> split it. */ type_str(&e, "\\"); input_action a; editor_apply(&e, kkind(KEY_ENTER), &a); CHECK(a.kind == ACT_NEWLINE, "middle-line backslash+Enter continues"); input_action_free(&a); char *t = editor_text(&e); CHECK_STR(t, "alpha \n\nbeta", "middle line was split"); free(t); CHECK(editor_line_count(&e) == 3, "three lines after the split"); /* And the buffer still submits as a whole from its last line. */ apply_key(&e, KEY_DOWN); while (e.caret < strlen(e.text) && e.text[e.caret] != '\n') apply_key(&e, KEY_RIGHT); editor_apply(&e, kkind(KEY_ENTER), &a); CHECK(a.kind == ACT_SUBMIT, "last-line Enter submits"); if (a.kind == ACT_SUBMIT) CHECK_STR(a.text, "alpha \n\nbeta", "multi-line submit intact"); input_action_free(&a); editor_free(&e); } /* End-to-end regression: Ctrl+J is LF (0x0A). Outside of bracketed paste it * is a dead key — it neither submits nor inserts a newline. Multi-line input * is via backslash+Enter or bracketed paste. */ static void test_ctrl_j_is_dead_key(void) { key_parser p; evlist ev; key_parser_init(&p); evlist_init(&ev); key_parser_feed(&p, (const unsigned char *)"hi\n", 3, &ev); key_parser_free(&p); CHECK(ev.len == 2, "only the two chars, LF dropped"); editor e; editor_init(&e); input_action a = {ACT_NONE, NULL}; int submitted = 0; for (size_t i = 0; i < ev.len; i++) { editor_apply(&e, ev.items[i], &a); if (a.kind == ACT_SUBMIT) submitted = 1; input_action_free(&a); } CHECK(!submitted, "Ctrl+J (LF) does not submit"); char *t = editor_text(&e); CHECK_STR(t, "hi", "LF left no newline in the buffer"); free(t); evlist_free(&ev); editor_free(&e); } static void test_editor_backspace_and_delete(void) { editor e; editor_init(&e); type_str(&e, "he"); apply_key(&e, KEY_NEWLINE); type_str(&e, "llo"); apply_key(&e, KEY_LEFT); apply_key(&e, KEY_LEFT); apply_key(&e, KEY_LEFT); apply_key(&e, KEY_BACKSPACE); char *t = editor_text(&e); CHECK_STR(t, "hello", "backspace removed the newline"); CHECK(e.caret == 2, "caret at the join"); free(t); editor_free(&e); /* A forward-delete needs the caret at the end of the first line: Up gets * there (Left is now clamped to a line), then Delete removes the newline. */ editor f; editor_init(&f); type_str(&f, "ab"); apply_key(&f, KEY_NEWLINE); type_str(&f, "cd"); apply_key(&f, KEY_UP); apply_key(&f, KEY_DELETE); char *u = editor_text(&f); CHECK_STR(u, "abcd", "delete removed the newline"); CHECK(f.caret == 2, "caret at the join"); free(u); editor_free(&f); } static void test_editor_multibyte_backspace(void) { editor e; editor_init(&e); type_str(&e, "h\xc3\xa9llo"); apply_key(&e, KEY_END); apply_key(&e, KEY_BACKSPACE); apply_key(&e, KEY_BACKSPACE); apply_key(&e, KEY_BACKSPACE); apply_key(&e, KEY_BACKSPACE); char *t = editor_text(&e); CHECK_STR(t, "h", "removed whole codepoints"); free(t); editor_free(&e); } static void test_editor_horizontal_movement(void) { editor e; editor_init(&e); type_str(&e, "one"); apply_key(&e, KEY_NEWLINE); type_str(&e, "two"); CHECK(e.caret == 7, "caret at the end"); /* Left stays on the current line: three presses reach its start, and a * fourth is a no-op (it no longer crosses into the previous line). */ apply_key(&e, KEY_LEFT); apply_key(&e, KEY_LEFT); apply_key(&e, KEY_LEFT); CHECK(e.caret == 4, "left moves the caret to the line start"); apply_key(&e, KEY_LEFT); CHECK(e.caret == 4, "left is a no-op at the line start"); apply_key(&e, KEY_RIGHT); CHECK(e.caret == 5, "right moves the caret"); /* HOME/END (and their Ctrl aliases) act on the current line, not the * whole buffer. Move to the first line and exercise them there. */ apply_key(&e, KEY_UP); CHECK(editor_caret_line(&e) == 0, "on the first line"); apply_key(&e, KEY_HOME); CHECK(e.caret == 0, "home is the line start"); apply_key(&e, KEY_END); CHECK(e.caret == 3, "end is the line end"); apply_key(&e, KEY_CTRL_A); CHECK(e.caret == 0, "ctrl-a is the line start"); apply_key(&e, KEY_CTRL_E); CHECK(e.caret == 3, "ctrl-e is the line end"); editor_free(&e); } /* Vertical movement walks the lines, keeps the column, clamps it to short * lines on a UTF-8 boundary, and stops at the top/bottom edges. */ static void test_editor_vertical_movement(void) { editor e; editor_init(&e); editor_set(&e, "one\ntwo\nthree"); CHECK(editor_line_count(&e) == 3, "three lines"); CHECK(editor_caret_line(&e) == 2, "caret on the last line"); CHECK(e.caret == 13, "caret at the end"); apply_key(&e, KEY_UP); CHECK(e.caret == 7, "up to the end of line 2"); CHECK(editor_caret_line(&e) == 1, "caret on line 2"); apply_key(&e, KEY_UP); CHECK(e.caret == 3, "up to the end of line 1"); CHECK(editor_caret_line(&e) == 0, "caret on line 1"); apply_key(&e, KEY_UP); CHECK(e.caret == 3, "up is a no-op on the first line"); apply_key(&e, KEY_DOWN); CHECK(e.caret == 7, "down back to line 2"); apply_key(&e, KEY_DOWN); CHECK(e.caret == 11, "down into line 3, column preserved"); CHECK(editor_caret_line(&e) == 2, "caret on line 3"); apply_key(&e, KEY_DOWN); CHECK(e.caret == 11, "down is a no-op on the last line"); editor_free(&e); /* Column clamping: a column longer than the target line clamps to its * end, and the clamped column is what comes back. */ editor f; editor_init(&f); editor_set(&f, "abcdef\ngh"); CHECK(f.caret == 9, "set caret at the end"); apply_key(&f, KEY_UP); CHECK(f.caret == 2, "up keeps the column"); apply_key(&f, KEY_RIGHT); apply_key(&f, KEY_RIGHT); apply_key(&f, KEY_RIGHT); apply_key(&f, KEY_RIGHT); CHECK(f.caret == 6, "end of the long line"); apply_key(&f, KEY_DOWN); CHECK(f.caret == 9, "down clamps the column to the short line"); apply_key(&f, KEY_UP); CHECK(f.caret == 2, "up keeps the clamped column"); editor_free(&f); /* A single-line buffer: UP/DOWN never move the caret. */ editor h; editor_init(&h); editor_set(&h, "hello"); apply_key(&h, KEY_HOME); apply_key(&h, KEY_UP); CHECK(h.caret == 0, "UP is a no-op on one line"); apply_key(&h, KEY_DOWN); CHECK(h.caret == 0, "DOWN is a no-op on one line"); editor_free(&h); /* UTF-8: a column landing mid-codepoint snaps to the boundary. */ editor u; editor_init(&u); editor_set(&u, "abc\n\xc3\xa9\xc3\xa9"); /* "abc", then two e-acute */ CHECK(u.caret == 8, "caret at the end"); apply_key(&u, KEY_UP); CHECK(u.caret == 3, "up to the end of line 1"); apply_key(&u, KEY_DOWN); CHECK(u.caret == 6, "down clamps between the two codepoints"); apply_key(&u, KEY_UP); CHECK(u.caret == 2, "up keeps the clamped column"); editor_free(&u); } static void test_editor_insert_at_caret(void) { editor e; editor_init(&e); type_str(&e, "helo"); apply_key(&e, KEY_HOME); apply_key(&e, KEY_RIGHT); type_str(&e, "i"); char *t = editor_text(&e); CHECK_STR(t, "hielo", "inserted before the caret"); CHECK(e.caret == 2, "caret after the inserted char"); free(t); editor_free(&e); editor f; editor_init(&f); apply_key(&f, KEY_BACKSPACE); CHECK(editor_is_empty(&f), "backspace at 0 is a no-op"); apply_key(&f, KEY_DELETE); CHECK(editor_is_empty(&f), "delete at 0 is a no-op"); editor_free(&f); } static void test_editor_kill_and_word_kills(void) { editor e; editor_init(&e); type_str(&e, "foo bar"); apply_key(&e, KEY_CTRL_K); char *t = editor_text(&e); CHECK_STR(t, "foo bar", "Ctrl-K at end is a no-op"); free(t); editor_free(&e); editor f; editor_init(&f); type_str(&f, "foo bar"); apply_key(&f, KEY_HOME); apply_key(&f, KEY_RIGHT); apply_key(&f, KEY_RIGHT); apply_key(&f, KEY_RIGHT); apply_key(&f, KEY_CTRL_K); char *u = editor_text(&f); CHECK_STR(u, "foo", "Ctrl-K cut the rest of the line"); free(u); editor_free(&f); editor g; editor_init(&g); type_str(&g, "foo bar"); apply_key(&g, KEY_END); apply_key(&g, KEY_LEFT); apply_key(&g, KEY_LEFT); apply_key(&g, KEY_LEFT); apply_key(&g, KEY_CTRL_U); char *v = editor_text(&g); CHECK_STR(v, "bar", "Ctrl-U cut the front of the line"); free(v); editor_free(&g); editor h; editor_init(&h); type_str(&h, "hello bar_baz"); apply_key(&h, KEY_END); apply_key(&h, KEY_CTRL_W); char *w = editor_text(&h); CHECK_STR(w, "hello", "Ctrl-W removed one word"); free(w); editor_free(&h); } static void test_editor_undo(void) { editor e; editor_init(&e); type_str(&e, "hello"); char *t = editor_text(&e); CHECK_STR(t, "hello", "typed"); free(t); apply_key(&e, KEY_UNDO); t = editor_text(&e); CHECK_STR(t, "hell", "undo removed the last char"); free(t); apply_key(&e, KEY_UNDO); t = editor_text(&e); CHECK_STR(t, "hel", "undo again"); free(t); apply_key(&e, KEY_UNDO); apply_key(&e, KEY_UNDO); apply_key(&e, KEY_UNDO); t = editor_text(&e); CHECK_STR(t, "", "undo back to empty"); free(t); apply_key(&e, KEY_UNDO); t = editor_text(&e); CHECK_STR(t, "", "undo past empty is a no-op"); free(t); editor_free(&e); } static void test_editor_yank(void) { /* Ctrl-W kill, then Ctrl-Y yank it back. */ editor e; editor_init(&e); type_str(&e, "foo bar"); apply_key(&e, KEY_END); apply_key(&e, KEY_CTRL_W); char *t = editor_text(&e); CHECK_STR(t, "foo", "Ctrl-W killed the last word"); free(t); apply_key(&e, KEY_CTRL_Y); t = editor_text(&e); CHECK_STR(t, "foo bar", "Ctrl-Y yanked the killed word"); free(t); editor_free(&e); /* A backspaced char is also yankable. */ editor f; editor_init(&f); type_str(&f, "ab"); apply_key(&f, KEY_BACKSPACE); char *u = editor_text(&f); CHECK_STR(u, "a", "backspace removed b"); free(u); apply_key(&f, KEY_CTRL_Y); u = editor_text(&f); CHECK_STR(u, "ab", "Ctrl-Y yanked the backspaced char"); free(u); editor_free(&f); } static void test_editor_ctrl_c_and_ctrl_d(void) { editor e; editor_init(&e); type_str(&e, "hello"); input_action a; editor_apply(&e, kkind(KEY_CTRL_C), &a); CHECK(a.kind == ACT_NONE, "Ctrl-C on non-empty is an edit op"); CHECK(editor_is_empty(&e), "Ctrl-C clears the buffer"); input_action_free(&a); input_action q; editor_apply(&e, kkind(KEY_CTRL_C), &q); CHECK(q.kind == ACT_QUIT, "Ctrl-C on empty -> quit"); input_action_free(&q); editor_free(&e); editor f; editor_init(&f); input_action d; editor_apply(&f, kkind(KEY_CTRL_D), &d); CHECK(d.kind == ACT_QUIT, "Ctrl-D on empty -> quit"); input_action_free(&d); type_str(&f, "hello"); input_action d2; editor_apply(&f, kkind(KEY_CTRL_D), &d2); CHECK(d2.kind == ACT_NONE, "Ctrl-D on non-empty is an edit op"); char *t = editor_text(&f); CHECK_STR(t, "hello", "no-op at end of a single line"); free(t); input_action_free(&d2); editor_free(&f); } static void test_editor_backtab_tab_and_esc(void) { editor e; editor_init(&e); input_action a; editor_apply(&e, kkind(KEY_BACKTAB), &a); CHECK(a.kind == ACT_TOGGLE_MODE, "backtab toggles mode"); input_action_free(&a); editor_free(&e); editor f; editor_init(&f); input_action t; editor_apply(&f, kkind(KEY_TAB), &t); CHECK(t.kind == ACT_NONE, "tab is an insert op"); char *ts = editor_text(&f); CHECK_STR(ts, "\t", "tab inserted"); free(ts); input_action_free(&t); editor_free(&f); editor g; editor_init(&g); input_action s; editor_apply(&g, kkind(KEY_ESC), &s); CHECK(s.kind == ACT_STOP, "esc -> stop"); input_action_free(&s); editor_free(&g); } /* ================================================================== */ /* Display tests */ /* ================================================================== */ static void test_display_len(void) { CHECK(input_display_len("\x1b[1;36m›\x1b[0m ") == 2, "SGR ignored"); CHECK(input_display_len("plain") == 5, "plain ascii"); CHECK(input_display_len("") == 0, "empty"); } static void test_display_len_wide(void) { CHECK(input_display_len("\xe4\xb8\xad\xe6\x96\x87") == 4, "two CJK = 4"); CHECK(input_display_len("a\xe4\xb8\xad" "b") == 4, "a CJK b = 4"); CHECK(input_display_len("h\xc3\xa9llo") == 5, "accented = 5"); } static void test_display_len_escape_families(void) { CHECK(input_display_len("\x1b]0;llsh\x1b\\hi") == 2, "OSC st"); CHECK(input_display_len("\x1b]0;llsh\x07hi") == 2, "OSC BEL"); CHECK(input_display_len("\x1bOA") == 0, "SS3"); CHECK(input_display_len("\x1b(Bx") == 1, "charset"); CHECK(input_display_len("\x1b[c") == 0, "CSI cursor"); CHECK(input_display_len("a\x1b") == 1, "trailing lone ESC"); } /* History tests */ /* ================================================================== */ static char *hist_dir(void) { static char buf[512]; snprintf(buf, sizeof(buf), "/tmp/llshc_hist_%d", (int)getpid()); return buf; } static char *hist_path(const char *name) { static char path[640]; snprintf(path, sizeof(path), "%s/%s", hist_dir(), name); return path; } static void hist_cleanup(void) { const char *names[] = {"a.json", "b.json"}; for (size_t i = 0; i < 2; i++) { char p[640]; snprintf(p, sizeof(p), "%s/%s", hist_dir(), names[i]); remove(p); } rmdir(hist_dir()); } static void test_history_capture_submit_cycle(void) { hist_cleanup(); char *path = hist_path("a.json"); input_history *h = input_history_open(path); CHECK(h != NULL, "open history"); input_history_push(h, "echo hi"); input_history_push(h, "ls -la"); /* While composing, Up engages only from the first line (caret_line 0); * a mid-buffer Up moves the caret, not history. */ CHECK(input_history_walk(h, -1, 1, "") == NULL, "Up mid-buffer -> no recall"); char *line = input_history_walk(h, -1, 0, ""); CHECK_STR(line, "ls -la", "newest first"); free(line); /* Mid-walk: Up advances toward the oldest regardless of the caret line. */ line = input_history_walk(h, -1, 2, ""); CHECK_STR(line, "echo hi", "oldest second"); free(line); /* Oldest already shown: Up hands back to the caret. */ CHECK(input_history_walk(h, -1, 0, "") == NULL, "Up at oldest -> NULL"); /* Down walks back toward the newest entry. */ line = input_history_walk(h, +1, 0, ""); CHECK_STR(line, "ls -la", "Down back to newest"); free(line); /* Down past the newest restores the pre-recall line and ends the walk. */ line = input_history_walk(h, +1, 0, ""); CHECK_STR(line, "", "Down past newest restores pre-recall line"); free(line); CHECK(h->active == false, "deactivated after restoring"); input_history_free(h); input_history *h2 = input_history_open(path); CHECK(h2 && h2->entries.len == 2, "reloaded 2 entries"); input_history_free(h2); hist_cleanup(); } static void test_history_restores_pre_recall_line(void) { hist_cleanup(); char *path = hist_path("a.json"); input_history *h = input_history_open(path); input_history_push(h, "first"); input_history_push(h, "second"); /* Down while composing never recalls — it moves the caret. */ CHECK(input_history_walk(h, +1, 0, "typed but not sent") == NULL, "Down while composing -> no recall"); char *line = input_history_walk(h, -1, 0, "typed but not sent"); CHECK_STR(line, "second", "newest first"); free(line); line = input_history_walk(h, +1, 0, "typed but not sent"); CHECK_STR(line, "typed but not sent", "restored the pre-recall line"); free(line); CHECK(h->active == false, "deactivated after restoring"); CHECK(h->snapshot == NULL, "snapshot consumed"); input_history_free(h); hist_cleanup(); } static void test_history_skips_duplicates_and_empty(void) { hist_cleanup(); char *path = hist_path("a.json"); input_history *h = input_history_open(path); input_history_push(h, "same"); input_history_push(h, "same"); input_history_push(h, " "); input_history_push(h, " spaced "); input_history_push(h, "spaced"); CHECK(h->entries.len == 2, "two unique entries"); if (h->entries.len == 2) { CHECK_STR(h->entries.items[0], "same", "entry 0"); CHECK_STR(h->entries.items[1], "spaced", "entry 1 trimmed"); } input_history_free(h); hist_cleanup(); } static void test_history_persistence_and_limit(void) { hist_cleanup(); char *path = hist_path("b.json"); input_history *h = input_history_open(path); for (int i = 1; i <= LLSH_HISTORY_LIMIT + 1; i++) { char buf[32]; snprintf(buf, sizeof(buf), "cmd%03d", i); input_history_push(h, buf); } size_t live = h->entries.len - h->head; CHECK(live == LLSH_HISTORY_LIMIT, "limit enforced"); if (live == LLSH_HISTORY_LIMIT) { CHECK_STR(h->entries.items[h->head], "cmd002", "oldest evicted"); CHECK_STR(h->entries.items[h->entries.len - 1], "cmd101", "newest preserved"); } input_history_free(h); input_history *h2 = input_history_open(path); CHECK(h2 && h2->entries.len - h2->head == LLSH_HISTORY_LIMIT, "reloaded at limit"); input_history_free(h2); hist_cleanup(); } /* ================================================================== */ /* Shell shadow tests */ /* ================================================================== */ static void test_shell_shadow(void) { hist_cleanup(); editor shadow; editor_init(&shadow); type_str(&shadow, "echo hi"); char *out = shell_shadow_apply(&shadow, kkind(KEY_ENTER)); CHECK_STR(out, "echo hi", "submitted line returned"); free(out); CHECK(editor_is_empty(&shadow), "shadow cleared after submit"); editor_free(&shadow); editor s2; editor_init(&s2); type_str(&s2, "ls"); char *o2 = shell_shadow_apply(&s2, kkind(KEY_LEFT)); CHECK(o2 == NULL, "intermediate key -> NULL"); CHECK(!editor_is_empty(&s2), "shadow NOT cleared on intermediate key"); editor_free(&s2); editor s3; editor_init(&s3); type_str(&s3, " "); char *o3 = shell_shadow_apply(&s3, kkind(KEY_ENTER)); CHECK(o3 == NULL, "blank submit -> NULL"); CHECK(!editor_is_empty(&s3), "shadow NOT cleared on blank"); editor_free(&s3); hist_cleanup(); } /* ================================================================== */ /* Reader queue */ /* ================================================================== */ /* The reader's event queue must be FIFO: keys typed quickly must reach the * editor in order (regression: a swap-pop scrambled the sequence, dropping * and reordering characters). */ static void test_reader_queue_fifo(void) { reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx); memset(ctx, 0, sizeof *ctx); input_key_reader *r = input_key_reader_new(ctx); const unsigned int cps[] = {'a', 'b', 'c', 'd', 'e', 'f'}; for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) { input_key k = {KEY_CHAR, cps[i]}; input_key_reader_send(r, &k); } for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) { input_key out; const unsigned char *raw; size_t raw_len; CHECK(input_key_reader_recv(r, &out, &raw, &raw_len), "recv returns queued event"); CHECK(out.kind == KEY_CHAR, "queued key kind preserved"); CHECK(out.cp == cps[i], "queue is FIFO (order preserved)"); input_raw_free(raw); } input_key out; CHECK(!input_key_reader_recv(r, &out, NULL, NULL), "queue drained"); input_key_reader_free(r); } /* The raw buffer handed out by recv is owned by the caller: it must stay * valid no matter what the queue does afterwards (regression: recv used to * pop-and-free the very buffer it returned, so callers read freed memory and * shell-mode keystrokes arrived scrambled). */ static void test_recv_raw_owned(void) { reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx); memset(ctx, 0, sizeof *ctx); input_key_reader *r = input_key_reader_new(ctx); input_key k = {KEY_CHAR, 'x'}; input_key_reader_send_raw(r, &k, "payload"); input_key out; const unsigned char *raw1 = NULL; size_t raw_len = 0; CHECK(input_key_reader_recv(r, &out, &raw1, &raw_len), "recv raw event"); CHECK(raw_len == 7, "raw length preserved"); /* Fill the ring past one full lap so the queue reuses the first slot; * with a borrowed (freed-on-pop) pointer, `raw1` would now be garbage. */ for (int i = 0; i < 16; i++) input_key_reader_send_raw(r, &k, "scratch"); for (int i = 0; i < 16; i++) { input_key o2; const unsigned char *r2 = NULL; if (!input_key_reader_recv(r, &o2, &r2, &raw_len)) break; input_raw_free(r2); } CHECK(raw1 && raw1[0] == 'p' && memcmp(raw1, "payload", 7) == 0, "recv'd raw stays valid after the queue laps it"); input_raw_free(raw1); input_key_reader_free(r); } /* ================================================================== */ /* The renderer and these tests share input_wrap_total / input_wrap_caret to * agree on how a buffer breaks into physical rows and where the caret lands on * a terminal of a given width, using the delayed-wrap model real terminals use. * Line 0 carries the 2-column prompt. */ static void test_wrap_geometry(void) { size_t r, c; /* A short first line: the prompt takes the first two columns, nothing * wraps. */ CHECK(input_wrap_total("hi", 10) == 1, "short line is one row"); input_wrap_caret("hi", 0, 10, &r, &c); CHECK(r == 0 && c == 2, "caret at text start sits after the prompt"); input_wrap_caret("hi", 2, 10, &r, &c); CHECK(r == 0 && c == 4, "caret at the text end"); /* A first line long enough to wrap: prompt + text flow across rows. The * cursor sits on the last typed column, not a spurious next row. */ CHECK(input_wrap_total("abcdef", 5) == 2, "wrapped line is two rows"); input_wrap_caret("abcdef", 0, 5, &r, &c); CHECK(r == 0 && c == 2, "caret before the first char"); input_wrap_caret("abcdef", 3, 5, &r, &c); CHECK(r == 0 && c == 4, "caret after 'abc' sits on the last column"); input_wrap_caret("abcdef", 6, 5, &r, &c); CHECK(r == 1 && c == 3, "caret at the wrapped end"); /* A multi-line buffer: only line 0 carries the prompt. */ CHECK(input_wrap_total("ab\ncdef", 5) == 2, "two short lines are two rows"); input_wrap_caret("ab\ncdef", 7, 5, &r, &c); CHECK(r == 1 && c == 4, "caret at the buffer end"); input_wrap_caret("ab\ncdef", 5, 5, &r, &c); CHECK(r == 1 && c == 2, "caret mid second line"); /* Regression: a first line that ends exactly at the margin, followed by a * newline, must NOT gain a spurious empty row. '» ab' is exactly 4 * columns, so the 'cdefgh' line starts immediately on row 1. */ CHECK(input_wrap_total("ab\ncdefgh", 4) == 3, "full first line + newline is three rows, not four"); input_wrap_caret("ab\ncdefgh", 9, 4, &r, &c); CHECK(r == 2 && c == 2, "caret at the end of the third row"); input_wrap_caret("ab\ncdefgh", 2, 4, &r, &c); CHECK(r == 0 && c == 3, "caret at the end of the full first row"); input_wrap_caret("ab\ncdefgh", 3, 4, &r, &c); CHECK(r == 1 && c == 0, "caret at the start of the second line"); /* A trailing full line keeps the cursor on its last column (no empty row). */ CHECK(input_wrap_total("abcdef", 4) == 2, "two full rows is two rows"); input_wrap_caret("abcdef", 6, 4, &r, &c); CHECK(r == 1 && c == 3, "caret at the end of a full row sits on it"); /* The renderer needs the pending flag to tell a cursor sitting on the last * column (delayed wrap) from one merely after the second-to-last column. */ CHECK(input_wrap_pending("hi", 2, 10) == false, "short end is not pending"); CHECK(input_wrap_pending("abcdef", 3, 5) == true, "caret after the last-column char is pending"); CHECK(input_wrap_pending("abcdef", 4, 5) == false, "caret on the wrapped row is not pending"); CHECK(input_wrap_pending("abcdefg", 7, 10) == false, "second-to-last column end is not pending"); CHECK(input_wrap_pending("abcdefgh", 8, 10) == true, "last-column end is pending"); CHECK(input_wrap_pending("a\xE4\xB8\xAD", 4, 10) == false, "three-byte char advances one column"); CHECK(input_wrap_pending("a\xF0\x9F\x98\x80", 5, 10) == false, "four-byte char advances one column"); CHECK(input_wrap_pending("a", 1, 1) == true, "one-column terminal stays pending"); CHECK(input_wrap_pending("a\n", 2, 1) == false, "newline clears pending wrap"); CHECK(input_wrap_pending("ab\ncdef\n", 8, 5) == false, "a trailing newline is not pending"); /* A blank buffer still renders the prompt as one row. */ CHECK(input_wrap_total("", 10) == 1, "blank buffer is one row"); input_wrap_caret("", 0, 10, &r, &c); CHECK(r == 0 && c == 2, "caret sits after the prompt"); } int main(void) { /* parser */ test_parser_plain_chars(); test_parser_enter_variants(); test_parser_shift_enter_and_esc(); test_parser_lone_escape_is_esc(); test_parser_alt_letter_is_the_letter(); test_parser_raw_bytes_preserved(); test_parser_control_keys(); test_parser_unmapped_control_bytes(); test_parser_unknown_sequence_consumed(); test_parser_bracketed_paste(); test_parser_utf8(); test_parser_mixed_stream(); test_parser_kitty_and_ss3(); /* editor */ test_editor_typing_and_submit(); test_editor_shift_enter_inserts_newline(); test_editor_backslash_enter_continues(); test_editor_middle_line_continuation(); test_ctrl_j_is_dead_key(); test_editor_backspace_and_delete(); test_editor_multibyte_backspace(); test_editor_horizontal_movement(); test_editor_vertical_movement(); test_editor_insert_at_caret(); test_editor_kill_and_word_kills(); test_editor_undo(); test_editor_yank(); test_editor_ctrl_c_and_ctrl_d(); test_editor_backtab_tab_and_esc(); /* display */ test_display_len(); test_display_len_wide(); test_display_len_escape_families(); /* wrap geometry */ test_wrap_geometry(); /* history */ test_history_capture_submit_cycle(); test_history_restores_pre_recall_line(); test_history_skips_duplicates_and_empty(); test_history_persistence_and_limit(); /* shell shadow */ test_shell_shadow(); /* reader queue */ test_reader_queue_fifo(); test_recv_raw_owned(); printf("%d checks, %d failures\n", checks, failures); return failures ? 1 : 0; }