diff --git a/README.md b/README.md index b8df854..56df313 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ If no file is given, the editor opens an empty, unnamed buffer. | Ctrl-Q | Quit (press again to discard unsaved changes) | | Ctrl-F | Find: all matches are highlighted with a count in the prompt; Ctrl-F again jumps to the next match (wraps around), Enter confirms, Esc cancels | | Ctrl-G | Jump to a line number (Enter jumps, Esc cancels) | +| Ctrl-R | Search & replace: type the search text, Enter, then the replacement text, Enter to replace every match at once (Esc cancels either step) | | Ctrl-Z / Ctrl-Y | Undo / redo | | Ctrl-C / Ctrl-X | Copy / cut the selection (also pushed to the system clipboard) | | Ctrl-V | Paste the internal clipboard | diff --git a/src/editor.c b/src/editor.c index f0844f5..707ca8f 100644 --- a/src/editor.c +++ b/src/editor.c @@ -836,3 +836,40 @@ void editor_paste_text(editor_t *ed, const char *text, size_t len) } insert_text_replacing_selection(ed, text, len); } + +size_t editor_replace_all(editor_t *ed, const char *query, const char *replacement) +{ + size_t count; + search_match_t *matches = search_find_all(&ed->buf, query, &count); + if (count == 0) { + free(matches); + return 0; + } + + size_t query_len = strlen(query); + size_t replacement_len = strlen(replacement); + + size_t last_row = ed->buf.num_lines - 1; + size_t last_col = ed->buf.lines[last_row].len; + + edit_capture_t cap; + capture_before(ed, 0, 0, last_row, last_col, &cap); + + for (size_t i = count; i > 0; i--) { + search_match_t *m = &matches[i - 1]; + buffer_delete_range(&ed->buf, m->row, m->col, m->row, m->col + query_len); + buffer_insert_text(&ed->buf, m->row, m->col, replacement, replacement_len); + } + free(matches); + + last_row = ed->buf.num_lines - 1; + last_col = ed->buf.lines[last_row].len; + commit_edit(ed, &cap, last_row, last_col); + + ed->cy = 0; + ed->cx = 0; + ed->goal_rx = 0; + ed->sel.active = false; + + return count; +} diff --git a/src/editor.h b/src/editor.h index 55abc8d..bfceea6 100644 --- a/src/editor.h +++ b/src/editor.h @@ -16,6 +16,8 @@ typedef enum { MODE_PROMPT_FIND, MODE_PROMPT_GOTO, MODE_PROMPT_SAVE_AS, + MODE_PROMPT_REPLACE_SEARCH, + MODE_PROMPT_REPLACE_WITH, } editor_mode_t; typedef struct { @@ -54,6 +56,8 @@ typedef struct { size_t search_start_cx; search_match_t *search_matches; size_t search_match_count; + char replace_search[256]; + size_t replace_search_len; char status_msg[256]; const char *filetype; @@ -121,4 +125,6 @@ void editor_cut(editor_t *ed); void editor_paste(editor_t *ed); void editor_paste_text(editor_t *ed, const char *text, size_t len); +size_t editor_replace_all(editor_t *ed, const char *query, const char *replacement); + #endif /* DAF_EDITOR_H */ diff --git a/src/input.c b/src/input.c index 91e4a6f..9af1dbf 100644 --- a/src/input.c +++ b/src/input.c @@ -5,6 +5,7 @@ #include "input.h" #include +#include #include "filetype.h" #include "search.h" @@ -52,6 +53,20 @@ static void enter_goto_mode(editor_t *ed) ed->prompt_input[0] = '\0'; } +static void enter_replace_mode(editor_t *ed) +{ + ed->mode = MODE_PROMPT_REPLACE_SEARCH; + ed->prompt_len = 0; + ed->prompt_input[0] = '\0'; + ed->search_start_cy = ed->cy; + ed->search_start_cx = ed->cx; +} + +static bool is_find_like_mode(editor_mode_t mode) +{ + return mode == MODE_PROMPT_FIND || mode == MODE_PROMPT_REPLACE_SEARCH; +} + static void prompt_append_char(editor_t *ed, unsigned int codepoint) { if (codepoint >= 128) { @@ -128,6 +143,28 @@ static void confirm_prompt(editor_t *ed) } break; + case MODE_PROMPT_REPLACE_SEARCH: + if (ed->prompt_len == 0) { + ed->mode = MODE_EDIT; + editor_clear_search_matches(ed); + editor_set_status(ed, "Cancelled"); + break; + } + memcpy(ed->replace_search, ed->prompt_input, ed->prompt_len + 1); + ed->replace_search_len = ed->prompt_len; + ed->prompt_len = 0; + ed->prompt_input[0] = '\0'; + ed->mode = MODE_PROMPT_REPLACE_WITH; + break; + + case MODE_PROMPT_REPLACE_WITH: { + size_t count = editor_replace_all(ed, ed->replace_search, ed->prompt_input); + ed->mode = MODE_EDIT; + editor_clear_search_matches(ed); + editor_set_status(ed, "Replaced %zu occurrence(s)", count); + break; + } + case MODE_EDIT: default: break; @@ -137,7 +174,7 @@ static void confirm_prompt(editor_t *ed) static void process_prompt_key(editor_t *ed, key_event_t key) { if (key.type == KEY_ESCAPE) { - if (ed->mode == MODE_PROMPT_FIND) { + if (is_find_like_mode(ed->mode) || ed->mode == MODE_PROMPT_REPLACE_WITH) { ed->cy = ed->search_start_cy; ed->cx = ed->search_start_cx; editor_clear_search_matches(ed); @@ -149,7 +186,7 @@ static void process_prompt_key(editor_t *ed, key_event_t key) if (key.type == KEY_BACKSPACE) { prompt_backspace(ed); - if (ed->mode == MODE_PROMPT_FIND) { + if (is_find_like_mode(ed->mode)) { do_incremental_search(ed); } return; @@ -172,7 +209,7 @@ static void process_prompt_key(editor_t *ed, key_event_t key) if (key.type == KEY_CHAR && !(key.mods & MOD_CTRL)) { prompt_append_char(ed, key.codepoint); - if (ed->mode == MODE_PROMPT_FIND) { + if (is_find_like_mode(ed->mode)) { do_incremental_search(ed); } } @@ -229,6 +266,8 @@ void input_process_key(editor_t *ed, key_event_t key) enter_find_mode(ed); } else if (key.codepoint == CTRL_KEY('g')) { enter_goto_mode(ed); + } else if (key.codepoint == CTRL_KEY('r')) { + enter_replace_mode(ed); } else if (key.codepoint == CTRL_KEY('z')) { editor_undo(ed); } else if (key.codepoint == CTRL_KEY('y')) { diff --git a/src/render.c b/src/render.c index 23265a4..881daa3 100644 --- a/src/render.c +++ b/src/render.c @@ -68,12 +68,24 @@ static const char *prompt_label(editor_mode_t mode) return "Go to line: "; case MODE_PROMPT_SAVE_AS: return "Save as: "; + case MODE_PROMPT_REPLACE_SEARCH: + return "Replace: "; + case MODE_PROMPT_REPLACE_WITH: + return "With: "; case MODE_EDIT: default: return ""; } } +static size_t active_search_query_len(const editor_t *ed) +{ + if (ed->mode == MODE_PROMPT_REPLACE_WITH) { + return ed->replace_search_len; + } + return ed->prompt_len; +} + static void render_gutter(abuf_t *ab, const editor_t *ed, size_t file_row, bool is_current) { int width = editor_gutter_width(ed); @@ -110,9 +122,10 @@ static void render_empty_gutter(abuf_t *ab, const editor_t *ed) static bool byte_in_search_match(const editor_t *ed, size_t row, size_t byte_col) { + size_t query_len = active_search_query_len(ed); for (size_t i = 0; i < ed->search_match_count; i++) { const search_match_t *m = &ed->search_matches[i]; - if (m->row == row && byte_col >= m->col && byte_col < m->col + ed->prompt_len) { + if (m->row == row && byte_col >= m->col && byte_col < m->col + query_len) { return true; } } @@ -137,7 +150,9 @@ static void render_text_row(abuf_t *ab, const editor_t *ed, size_t file_row) hl_end = (file_row == r2) ? c2 : line->len; } } - bool show_matches = ed->mode == MODE_PROMPT_FIND && ed->search_match_count > 0; + bool show_matches = (ed->mode == MODE_PROMPT_FIND || ed->mode == MODE_PROMPT_REPLACE_SEARCH || + ed->mode == MODE_PROMPT_REPLACE_WITH) && + ed->search_match_count > 0; size_t i = 0; size_t rx = 0; @@ -233,7 +248,10 @@ static void render_message_bar(abuf_t *ab, const editor_t *ed) if (ed->mode != MODE_EDIT) { abuf_append_str(ab, prompt_label(ed->mode)); abuf_append(ab, ed->prompt_input, ed->prompt_len); - if (ed->mode == MODE_PROMPT_FIND && ed->prompt_len > 0) { + + bool has_query = (ed->mode == MODE_PROMPT_FIND || ed->mode == MODE_PROMPT_REPLACE_SEARCH) && + ed->prompt_len > 0; + if (has_query || ed->mode == MODE_PROMPT_REPLACE_WITH) { char count_buf[32]; int n = snprintf(count_buf, sizeof(count_buf), " (%zu found)", ed->search_match_count); if (n > 0) { diff --git a/tests/meson.build b/tests/meson.build index 42db3ee..3e6ee03 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -1,4 +1,4 @@ -test_names = ['buffer', 'search', 'encoding', 'filetype', 'editor', 'undo', 'clipboard'] +test_names = ['buffer', 'search', 'encoding', 'filetype', 'editor', 'undo', 'clipboard', 'replace'] foreach name : test_names test_exe = executable( diff --git a/tests/test_replace.c b/tests/test_replace.c new file mode 100644 index 0000000..30d8439 --- /dev/null +++ b/tests/test_replace.c @@ -0,0 +1,90 @@ +/* SPDX-FileCopyrightText: AnmiTaliDev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include +#include + +#include "editor.h" + +static void set_line(editor_t *ed, size_t row, const char *text) +{ + buffer_insert_bytes(&ed->buf, row, 0, text, strlen(text)); +} + +static void test_replace_all_same_length(void) +{ + editor_t ed; + editor_init(&ed, 24, 80); + set_line(&ed, 0, "foo bar foo"); + buffer_split_line(&ed.buf, 0, 11); + set_line(&ed, 1, "baz foo"); + + size_t count = editor_replace_all(&ed, "foo", "qux"); + assert(count == 3); + assert(memcmp(ed.buf.lines[0].chars, "qux bar qux", 11) == 0); + assert(memcmp(ed.buf.lines[1].chars, "baz qux", 7) == 0); + assert(ed.buf.dirty); + + editor_free(&ed); +} + +static void test_replace_all_different_length_is_undoable(void) +{ + editor_t ed; + editor_init(&ed, 24, 80); + set_line(&ed, 0, "a cat and a cat"); + + size_t count = editor_replace_all(&ed, "cat", "elephant"); + assert(count == 2); + assert(ed.buf.lines[0].len == 25); + assert(memcmp(ed.buf.lines[0].chars, "a elephant and a elephant", 25) == 0); + + editor_undo(&ed); + assert(ed.buf.lines[0].len == 15); + assert(memcmp(ed.buf.lines[0].chars, "a cat and a cat", 15) == 0); + + editor_redo(&ed); + assert(ed.buf.lines[0].len == 25); + assert(memcmp(ed.buf.lines[0].chars, "a elephant and a elephant", 25) == 0); + + editor_free(&ed); +} + +static void test_replace_all_no_matches(void) +{ + editor_t ed; + editor_init(&ed, 24, 80); + set_line(&ed, 0, "hello world"); + + size_t count = editor_replace_all(&ed, "missing", "x"); + assert(count == 0); + assert(ed.buf.lines[0].len == 11); + assert(memcmp(ed.buf.lines[0].chars, "hello world", 11) == 0); + + editor_free(&ed); +} + +static void test_replace_all_multiline_replacement(void) +{ + editor_t ed; + editor_init(&ed, 24, 80); + set_line(&ed, 0, "one,two"); + + size_t count = editor_replace_all(&ed, ",", "\n"); + assert(count == 1); + assert(ed.buf.num_lines == 2); + assert(memcmp(ed.buf.lines[0].chars, "one", 3) == 0); + assert(memcmp(ed.buf.lines[1].chars, "two", 3) == 0); + + editor_free(&ed); +} + +int main(void) +{ + test_replace_all_same_length(); + test_replace_all_different_length_is_undoable(); + test_replace_all_no_matches(); + test_replace_all_multiline_replacement(); + return 0; +}