From d20d40c4a33565d1a0bf1ae43e948009e8efa6be Mon Sep 17 00:00:00 2001 From: Kuzin Andrey Date: Mon, 7 Sep 2026 07:41:49 +0300 Subject: [PATCH 1/7] Ticket #1652: editor [1/3]: hide CRLF "\r" in Windows files and detect line break type editor(editbuffer): Add line break helpers to the text buffer Add three text buffer helpers that will be used to treat a Windows ("\r\n") line break as a single unit: - edit_buffer_is_crlf() checks whether the buffer contains a "\r\n" line break starting at the given position. - edit_buffer_detect_line_breaks() detects the line break type of the buffer content: LB_WIN if all line breaks are "\r\n", LB_UNIX if all are "\n", LB_MAC if all are "\r" and LB_ASIS for a mixture. - edit_buffer_trailing_ws_start() returns the start offset of the trailing whitespace (spaces and tabs) run of a line; for a CRLF line the line content ends at the "\r" of its line break, not at the "\n". Signed-off-by: Kuzin Andrey --- src/editor/editbuffer.c | 87 +++++++++++++++++++++++++++++++++++++++++ src/editor/editbuffer.h | 22 +++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/editor/editbuffer.c b/src/editor/editbuffer.c index 451369092a..e88d9c969f 100644 --- a/src/editor/editbuffer.c +++ b/src/editor/editbuffer.c @@ -895,3 +895,90 @@ edit_buffer_calc_percent (const edit_buffer_t *buf, off_t offset) } /* --------------------------------------------------------------------------------------------- */ +/** + * Get the start offset of the trailing whitespace (spaces and tabs) run of the + * line that begins at the given offset. If the line has no trailing whitespace + * the end of the line content is returned, so no character of the line is at or + * after the result. + * + * @param buf editor buffer + * @param bol offset of the first character of the line + * + * @return start offset of the trailing whitespace run (or end of line content) + */ + +off_t +edit_buffer_trailing_ws_start (const edit_buffer_t *buf, off_t bol) +{ + off_t tws, eol; + + eol = edit_buffer_get_eol (buf, bol); + + // a CRLF line break's content ends at the "\r", not at the "\n" + if (eol > bol && edit_buffer_is_crlf (buf, eol - 1)) + eol--; + + for (tws = eol; tws > bol; tws--) + { + int c; + + c = edit_buffer_get_byte (buf, tws - 1); + if (c != ' ' && c != '\t') + break; + } + + return tws; +} + +/* --------------------------------------------------------------------------------------------- */ +/** + * Detect the line break type used in the buffer content. + * + * @param buf editor buffer + * + * @return LB_WIN if all line breaks are "\r\n"; LB_UNIX if all line breaks + * are "\n" (including buffers without any line breaks); LB_MAC if + * all line breaks are "\r"; LB_ASIS if the buffer uses a mixture of + * different line breaks (such a file can only be saved as-is) + */ + +LineBreaks +edit_buffer_detect_line_breaks (const edit_buffer_t *buf) +{ + off_t crlf = 0; // "\r\n" line breaks + off_t cr = 0; // "\r" line breaks not followed by "\n" + off_t lf = 0; // "\n" line breaks not preceded by "\r" + off_t i; + + for (i = 0; i < buf->size; i++) + { + if (edit_buffer_get_byte (buf, i) == '\r') + { + if (edit_buffer_is_crlf (buf, i)) + crlf++; + else + cr++; + } + else if (edit_buffer_get_byte (buf, i) == '\n' + && (i == 0 || edit_buffer_get_byte (buf, i - 1) != '\r')) + lf++; + } + + if (crlf > 0) + { + if (cr > 0 || lf > 0) + return LB_ASIS; // mixture of line breaks + return LB_WIN; + } + + if (cr > 0) + { + if (lf > 0) + return LB_ASIS; // mixture of line breaks + return LB_MAC; + } + + return LB_UNIX; +} + +/* --------------------------------------------------------------------------------------------- */ diff --git a/src/editor/editbuffer.h b/src/editor/editbuffer.h index 686af3e3e5..e1ffff9b8c 100644 --- a/src/editor/editbuffer.h +++ b/src/editor/editbuffer.h @@ -63,6 +63,9 @@ off_t edit_buffer_write_file (edit_buffer_t *buf, int fd); int edit_buffer_calc_percent (const edit_buffer_t *buf, off_t offset); +off_t edit_buffer_trailing_ws_start (const edit_buffer_t *buf, off_t bol); +LineBreaks edit_buffer_detect_line_breaks (const edit_buffer_t *buf); + /*** inline functions ****************************************************************************/ static inline int @@ -109,6 +112,25 @@ edit_buffer_get_current_eol (const edit_buffer_t *buf) return edit_buffer_get_eol (buf, buf->curs1); } +/* --------------------------------------------------------------------------------------------- */ +/** + * Check whether the buffer contains a Windows ("\r\n") line break + * starting at the specified position. + * + * @param buf editor buffer + * @param byte_index position of the potential "\r" character + * + * @return TRUE if the two bytes at byte_index and byte_index + 1 are "\r" and "\n" + */ + +static inline gboolean +edit_buffer_is_crlf (const edit_buffer_t *buf, off_t byte_index) +{ + return (byte_index >= 0 && byte_index + 1 < buf->size + && edit_buffer_get_byte (buf, byte_index) == '\r' + && edit_buffer_get_byte (buf, byte_index + 1) == '\n'); +} + /* --------------------------------------------------------------------------------------------- */ #endif From 26e70300743dc35674715f8d1f613bde0bb18c42 Mon Sep 17 00:00:00 2001 From: Kuzin Andrey Date: Mon, 7 Sep 2026 07:58:36 +0300 Subject: [PATCH 2/7] Ticket #1652: editor [2/3]: hide CRLF "\r" in Windows files and detect line break type editor(edit): Extract edit_insert_line_break() helper Collect all line break insertion into a single edit_insert_line_break() helper and use it instead of direct edit_insert (edit, '\n') calls in edit_double_newline(), check_and_wrap_line() and the CK_Enter/CK_Return key handling. This is a pure refactoring without any behavior change; the helper will be extended with the line break type inheritance in a follow-up commit. Signed-off-by: Kuzin Andrey --- src/editor/edit.c | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/editor/edit.c b/src/editor/edit.c index e4237472b2..b7f9034bef 100644 --- a/src/editor/edit.c +++ b/src/editor/edit.c @@ -1454,16 +1454,27 @@ edit_auto_indent (WEdit *edit) } /* --------------------------------------------------------------------------------------------- */ +/** + * Insert a line break at the cursor. + */ static inline void -edit_double_newline (WEdit *edit) +edit_insert_line_break (WEdit *edit) { edit_insert (edit, '\n'); +} + +/* --------------------------------------------------------------------------------------------- */ + +static inline void +edit_double_newline (WEdit *edit) +{ + edit_insert_line_break (edit); if (edit_buffer_get_current_byte (&edit->buffer) == '\n' || edit_buffer_get_byte (&edit->buffer, edit->buffer.curs1 - 2) == '\n') return; edit->force |= REDRAW_PAGE; - edit_insert (edit, '\n'); + edit_insert_line_break (edit); } /* --------------------------------------------------------------------------------------------- */ @@ -1530,14 +1541,14 @@ check_and_wrap_line (WEdit *edit) c = edit_buffer_get_byte (&edit->buffer, curs); if (c == '\n' || curs <= 0) { - edit_insert (edit, '\n'); + edit_insert_line_break (edit); return; } if (whitespace (c)) { off_t current = edit->buffer.curs1; edit_cursor_move (edit, curs - edit->buffer.curs1 + 1); - edit_insert (edit, '\n'); + edit_insert_line_break (edit); edit_cursor_move (edit, current - edit->buffer.curs1 + 1); return; } @@ -3652,13 +3663,13 @@ edit_execute_cmd (WEdit *edit, long command, int char_for_insertion) } else { - edit_insert (edit, '\n'); + edit_insert_line_break (edit); if (edit_options.return_does_auto_indent && !bracketed_pasting_in_progress) edit_auto_indent (edit); } break; case CK_Return: - edit_insert (edit, '\n'); + edit_insert_line_break (edit); break; case CK_MarkColumnPageUp: From 0200ba946598eafa65992811a95d341d6470cfe9 Mon Sep 17 00:00:00 2001 From: Kuzin Andrey Date: Mon, 7 Sep 2026 09:53:16 +0300 Subject: [PATCH 3/7] Ticket #1652: editor [3/3]: hide CRLF "\r" in Windows files and detect line break type Show the line break type of the file being edited as a word at end of the status line: - all "\n", - all "\r\n", - all "\r" and - a mixture of line breaks or no line breaks. The type is detected by edit_buffer_detect_line_breaks() and cached in the buffer (edit_buffer_refresh_line_breaks()/edit_buffer_get_line_breaks()). Hide the "\r" of a "\r\n" line break in files with pure Windows line breaks. In any other file (a mixture of line breaks, Unix or Mac) the "\r" is shown as "^M", so CRLF lines stay visible, e.g. in a patch containing diffs of files saved with both "\n" and "\r\n". A standalone "\r" (Mac) is rendered as "^M" instead of as a tab. A "\r\n" line break is a single unit in every file: the End key and Delete/Backspace stop at / remove the whole pair, line counting and cursor columns ignore the "\r" part. A line break inserted with Enter inherits the type of the current line ("\r\n" or "\n"); for the last line of a file the previous line's type is used. The "\r" of the pair is not an editable character: pressing Enter at the end of a CRLF line does not duplicate the "\r" and the cursor stays before it. File content is kept raw in the buffer: mixed line breaks are preserved as-is on save (the default LB_ASIS mode), the Save As dialog still offers conversion to a single line break type. Paragraph formatting skips CRLF lines as reformatting would mangle them, and the search end-of-line symbol is always "\n". Fix edit_write_stream() appending an extra "\n" to files that already end with a line break in conversion modes. Add unit tests for line break detection, conversion, inheritance and atomic deletion of CRLF lines in files with mixed line breaks (tests/src/editor/edit_line_breaks.c). Signed-off-by: Kuzin Andrey --- src/editor/edit.c | 200 ++++--- src/editor/editbuffer.c | 53 ++ src/editor/editbuffer.h | 5 + src/editor/editdraw.c | 87 ++- src/editor/editsearch.c | 13 +- src/editor/editwidget.h | 2 +- src/editor/format.c | 7 + tests/src/editor/Makefile.am | 4 + tests/src/editor/edit_line_breaks.c | 876 ++++++++++++++++++++++++++++ 9 files changed, 1147 insertions(+), 100 deletions(-) create mode 100644 tests/src/editor/edit_line_breaks.c diff --git a/src/editor/edit.c b/src/editor/edit.c index b7f9034bef..c9a2f2cf2b 100644 --- a/src/editor/edit.c +++ b/src/editor/edit.c @@ -882,6 +882,9 @@ edit_cursor_to_eol (WEdit *edit) off_t b; b = edit_buffer_get_current_eol (&edit->buffer); + // do not place the cursor after the "\r" of a "\r\n" line break + if (b > 0 && edit_buffer_is_crlf (&edit->buffer, b - 1)) + b--; edit_cursor_move (edit, b - edit->buffer.curs1); edit->search_start = edit->buffer.curs1; edit->prev_col = edit_get_col (edit); @@ -1379,7 +1382,9 @@ edit_group_undo (WEdit *edit) static void edit_delete_to_line_end (WEdit *edit) { - while (edit_buffer_get_current_byte (&edit->buffer) != '\n' && edit->buffer.curs2 != 0) + // delete up to (but not including) the line break, which may be "\n" or "\r\n" + while (edit->buffer.curs2 != 0 && !edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1) + && edit_buffer_get_current_byte (&edit->buffer) != '\n') edit_delete (edit, TRUE); } @@ -1455,12 +1460,52 @@ edit_auto_indent (WEdit *edit) /* --------------------------------------------------------------------------------------------- */ /** - * Insert a line break at the cursor. + * Check whether a line break ("\n" or "\r\n") ends exactly at the specified position, + * i.e. whether the line before that position is empty. + */ + +static inline gboolean +edit_line_break_ends_at (const edit_buffer_t *buf, off_t p) +{ + if (p >= 1 && edit_buffer_get_byte (buf, p - 1) == '\n') + return TRUE; + + return edit_buffer_is_crlf (buf, p - 2); +} + +/* --------------------------------------------------------------------------------------------- */ +/** + * Insert a line break at the cursor inheriting the type of the line break + * of the current line ("\r\n" or "\n"). If the current line has no line + * break (the last line of the file), use the line break of the previous line. */ static inline void edit_insert_line_break (WEdit *edit) { + const off_t eol = edit_buffer_get_current_eol (&edit->buffer); + gboolean crlf; + + if (edit_buffer_is_crlf (&edit->buffer, eol - 1)) + crlf = TRUE; + else if (eol == edit->buffer.size) + { + const off_t bol = edit_buffer_get_current_bol (&edit->buffer); + + crlf = (bol > 0) && edit_buffer_is_crlf (&edit->buffer, bol - 2); + } + else + crlf = FALSE; + + // if the character right before the cursor is already a "\r" (e.g. the + // cursor is between the "\r" and the "\n" of a "\r\n" line break), + // inserting another "\r" would create a duplicate, so insert only "\n" + if (crlf && edit_buffer_get_previous_byte (&edit->buffer) == '\r') + crlf = FALSE; + + if (crlf) + edit_insert (edit, '\r'); + edit_insert (edit, '\n'); } @@ -1469,10 +1514,17 @@ edit_insert_line_break (WEdit *edit) static inline void edit_double_newline (WEdit *edit) { + const off_t pos = edit->buffer.curs1; + edit_insert_line_break (edit); + + // do not add a second line break if the next char or the previous line + // is already a line break, i.e. there is already a blank line if (edit_buffer_get_current_byte (&edit->buffer) == '\n' - || edit_buffer_get_byte (&edit->buffer, edit->buffer.curs1 - 2) == '\n') + || edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1) + || edit_line_break_ends_at (&edit->buffer, pos)) return; + edit->force |= REDRAW_PAGE; edit_insert_line_break (edit); } @@ -1909,99 +1961,63 @@ edit_get_write_filter (const vfs_path_t *write_name_vpath, const vfs_path_t *fil off_t edit_write_stream (WEdit *edit, FILE *f) { - long i; + const off_t size = edit->buffer.size; + off_t i; if (edit->lb == LB_ASIS) { - for (i = 0; i < edit->buffer.size; i++) + for (i = 0; i < size; i++) if (fputc (edit_buffer_get_byte (&edit->buffer, i), f) < 0) break; return i; } // change line breaks - for (i = 0; i < edit->buffer.size; i++) + for (i = 0; i < size; i++) { - unsigned char c; + const unsigned char c = edit_buffer_get_byte (&edit->buffer, i); - c = edit_buffer_get_byte (&edit->buffer, i); - if (!(c == '\n' || c == '\r')) + if (c != '\n' && c != '\r') { // not line break if (fputc (c, f) < 0) return i; + continue; } - else - { // (c == '\n' || c == '\r') - unsigned char c1; - - c1 = edit_buffer_get_byte (&edit->buffer, i + 1); // next char - - switch (edit->lb) - { - case LB_UNIX: // replace "\r\n" or '\r' to '\n' - // put one line break unconditionally - if (fputc ('\n', f) < 0) - return i; - - i++; // 2 chars are processed - - if (c == '\r' && c1 == '\n') - // Windows line break; go to the next char - break; - if (c == '\r' && c1 == '\r') - { - // two Macintosh line breaks; put second line break - if (fputc ('\n', f) < 0) - return i; - break; - } + // c is a line break; c1 is the char after it (-1 if there is none) + const int c1 = (i + 1 < size) ? edit_buffer_get_byte (&edit->buffer, i + 1) : -1; - if (fputc (c1, f) < 0) - return i; - break; - - case LB_WIN: // replace '\n' or '\r' to "\r\n" - // put one line break unconditionally - if (fputc ('\r', f) < 0 || fputc ('\n', f) < 0) - return i; - - if (c == '\r' && c1 == '\n') - // Windows line break; go to the next char - i++; - break; - - case LB_MAC: // replace "\r\n" or '\n' to '\r' - // put one line break unconditionally - if (fputc ('\r', f) < 0) - return i; - - i++; // 2 chars are processed - - if (c == '\r' && c1 == '\n') - // Windows line break; go to the next char - break; + switch (edit->lb) + { + case LB_WIN: // replace "\r\n", "\r" or "\n" to "\r\n" + if (fputc ('\r', f) < 0 || fputc ('\n', f) < 0) + return i; + if (c == '\r' && c1 == '\n') + // Windows line break; skip the second char + i++; + break; - if (c == '\n' && c1 == '\n') - { - // two Windows line breaks; put second line break - if (fputc ('\r', f) < 0) - return i; - break; - } + case LB_MAC: // replace "\r\n", "\r" or "\n" to "\r" + if (fputc ('\r', f) < 0) + return i; + if (c == '\r' && c1 == '\n') + // Windows line break; skip the second char + i++; + break; - if (fputc (c1, f) < 0) - return i; - break; - case LB_ASIS: // default without changes - default: - break; - } + case LB_UNIX: // replace "\r\n", "\r" or "\n" to "\n" + default: + if (fputc ('\n', f) < 0) + return i; + if (c == '\r' && c1 == '\n') + // Windows line break; skip the second char + i++; + break; } } - return edit->buffer.size; + return size; } /* --------------------------------------------------------------------------------------------- */ @@ -2676,6 +2692,10 @@ edit_delete (WEdit *edit, gboolean byte_delete) char_length = 1; } + // a "\r\n" line break is deleted as a single unit + if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1)) + char_length = 2; + if (edit->mark2 != edit->mark1) edit_push_markers (edit); @@ -2721,6 +2741,7 @@ edit_backspace (WEdit *edit, gboolean byte_delete) int p = 0; int char_length = 1; int i; + gboolean deleted_nl = FALSE; if (edit->buffer.curs1 == 0) return 0; @@ -2735,6 +2756,10 @@ edit_backspace (WEdit *edit, gboolean byte_delete) char_length = 1; } + // a "\r\n" line break is deleted as a single unit + if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1 - 2)) + char_length = 2; + for (i = 1; i <= char_length; i++) { if (edit->mark1 >= edit->buffer.curs1) @@ -2749,10 +2774,13 @@ edit_backspace (WEdit *edit, gboolean byte_delete) p = edit_buffer_backspace (&edit->buffer); + if (p == '\n') + deleted_nl = TRUE; + edit_push_undo_action (edit, p); } edit_modification (edit); - if (p == '\n') + if (deleted_nl) { book_mark_dec (edit, edit->buffer.curs_line); edit->buffer.curs_line--; @@ -2763,7 +2791,7 @@ edit_backspace (WEdit *edit, gboolean byte_delete) if (edit->buffer.curs1 < edit->start_display) { edit->start_display--; - if (p == '\n') + if (deleted_nl) edit->start_line--; } @@ -2823,6 +2851,10 @@ edit_move_forward3 (const WEdit *edit, off_t current, long cols, off_t upto) { off_t p, q; long col; + gboolean crlf_hidden; + + // a "\r" of a "\r\n" line break is hidden only in a pure Windows file + crlf_hidden = (edit_buffer_get_line_breaks (&edit->buffer) == LB_WIN); if (upto != 0) { @@ -2866,11 +2898,17 @@ edit_move_forward3 (const WEdit *edit, off_t current, long cols, off_t upto) c = convert_to_display_c (c); if (c == '\n') - return (upto != 0 ? (off_t) col : p); + { + // a CRLF line break ends the line before the "\r", not after it + return (upto != 0) ? (off_t) col + : (edit_buffer_is_crlf (&edit->buffer, p - 1) ? p - 1 : p); + } + // a hidden "\r" of a "\r\n" line break (a pure Windows file) occupies no column + if (c == '\r' && crlf_hidden && edit_buffer_is_crlf (&edit->buffer, p)) + continue; if (c == '\t') col += TAB_SIZE - col % TAB_SIZE; else if ((c < 32 || c == 127) && (orig_c == c || (!mc_global.utf8_display && !edit->utf8))) - // '\r' is shown as ^M, so we must advance 2 characters // Caret notation for control characters col += 2; else @@ -3272,15 +3310,17 @@ void edit_delete_line (WEdit *edit) { /* - * Delete right part of the line. + * Delete right part of the line, up to (but not including) the line + * break. The line break may be "\n" or "\r\n". * Note that edit_buffer_get_byte() returns '\n' when byte position is * beyond EOF. */ - while (edit_buffer_get_current_byte (&edit->buffer) != '\n') + while (!edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1) + && edit_buffer_get_current_byte (&edit->buffer) != '\n') (void) edit_delete (edit, TRUE); /* - * Delete '\n' char. + * Delete the line break ("\n" or "\r\n"). * Note that edit_delete() will not corrupt anything if called while * cursor position is EOF. */ diff --git a/src/editor/editbuffer.c b/src/editor/editbuffer.c index e88d9c969f..8dff699c0f 100644 --- a/src/editor/editbuffer.c +++ b/src/editor/editbuffer.c @@ -154,6 +154,9 @@ edit_buffer_init (edit_buffer_t *buf, off_t size) buf->size = size; buf->lines = 0; + + buf->lb_detected = LB_ASIS; + buf->lb_dirty = TRUE; } /* --------------------------------------------------------------------------------------------- */ @@ -506,6 +509,9 @@ edit_buffer_insert (edit_buffer_t *buf, int c) // update file length buf->size++; + + if (c == '\r' || c == '\n') + buf->lb_dirty = TRUE; } /* --------------------------------------------------------------------------------------------- */ @@ -538,6 +544,9 @@ edit_buffer_insert_ahead (edit_buffer_t *buf, int c) // update file length buf->size++; + + if (c == '\r' || c == '\n') + buf->lb_dirty = TRUE; } /* --------------------------------------------------------------------------------------------- */ @@ -577,6 +586,9 @@ edit_buffer_delete (edit_buffer_t *buf) // update file length buf->size--; + if (c == '\r' || c == '\n') + buf->lb_dirty = TRUE; + return c; } @@ -617,6 +629,9 @@ edit_buffer_backspace (edit_buffer_t *buf) // update file length buf->size--; + if (c == '\r' || c == '\n') + buf->lb_dirty = TRUE; + return c; } @@ -790,6 +805,9 @@ edit_buffer_read_file (edit_buffer_t *buf, int fd, off_t size, } } + // the loaded content may contain line breaks + buf->lb_dirty = TRUE; + return ret; } @@ -982,3 +1000,38 @@ edit_buffer_detect_line_breaks (const edit_buffer_t *buf) } /* --------------------------------------------------------------------------------------------- */ +/** + * Recompute the cached line break type if the line breaks have changed. + * Cheap (O(1)) unless a line break was inserted or deleted since the last + * recomputation. + * + * @param buf pointer to editor buffer + */ + +void +edit_buffer_refresh_line_breaks (edit_buffer_t *buf) +{ + if (buf->lb_dirty) + { + buf->lb_detected = edit_buffer_detect_line_breaks (buf); + buf->lb_dirty = FALSE; + } +} + +/* --------------------------------------------------------------------------------------------- */ +/** + * Get the cached line break type of the buffer content. + * Call edit_buffer_refresh_line_breaks() first if the result must be current. + * + * @param buf pointer to editor buffer + * + * @return cached detect_line_breaks() result + */ + +LineBreaks +edit_buffer_get_line_breaks (const edit_buffer_t *buf) +{ + return buf->lb_detected; +} + +/* --------------------------------------------------------------------------------------------- */ diff --git a/src/editor/editbuffer.h b/src/editor/editbuffer.h index e1ffff9b8c..be1f8e93c5 100644 --- a/src/editor/editbuffer.h +++ b/src/editor/editbuffer.h @@ -20,6 +20,9 @@ typedef struct edit_buffer_struct off_t size; // file size long lines; // total lines in the file long curs_line; // line number of the cursor. + + LineBreaks lb_detected; // cached detect_line_breaks() result + gboolean lb_dirty; // line breaks have changed, need detect_line_breaks() } edit_buffer_t; typedef struct edit_buffer_read_file_status_msg_struct @@ -65,6 +68,8 @@ int edit_buffer_calc_percent (const edit_buffer_t *buf, off_t offset); off_t edit_buffer_trailing_ws_start (const edit_buffer_t *buf, off_t bol); LineBreaks edit_buffer_detect_line_breaks (const edit_buffer_t *buf); +void edit_buffer_refresh_line_breaks (edit_buffer_t *buf); +LineBreaks edit_buffer_get_line_breaks (const edit_buffer_t *buf); /*** inline functions ****************************************************************************/ diff --git a/src/editor/editdraw.c b/src/editor/editdraw.c index bde7e18047..38c338e24b 100644 --- a/src/editor/editdraw.c +++ b/src/editor/editdraw.c @@ -142,18 +142,59 @@ format_character_code (WEdit *edit) #undef CHAR_CODE_BUF_SIZE +/* --------------------------------------------------------------------------------------------- */ +/** + * Get the cached line break type of the buffer content. + * The result is recomputed only when line breaks have changed. + */ + +static inline LineBreaks +edit_get_detected_line_breaks (edit_buffer_t *buf) +{ + edit_buffer_refresh_line_breaks (buf); + return edit_buffer_get_line_breaks (buf); +} + +/* --------------------------------------------------------------------------------------------- */ +/** + * Get the status line sign describing the line break type + * of the buffer content: + * - Windows ("\r\n") + * - Macintosh ("\r") + * - Unix ("\n") + * - mixture of line breaks or no line breaks + */ + +static inline const char * +edit_line_breaks_status_sign (edit_buffer_t *buf) +{ + switch (edit_get_detected_line_breaks (buf)) + { + case LB_WIN: + return ""; + case LB_MAC: + return ""; + case LB_UNIX: + return ""; + case LB_ASIS: + default: + return ""; + } +} + /* --------------------------------------------------------------------------------------------- */ static inline void status_string (WEdit *edit, char *s, int w) { char *character_code; + const char *lb = edit_line_breaks_status_sign (&edit->buffer); character_code = format_character_code (edit); // The field lengths just prevent the status line from shortening too much if (edit_options.simple_statusbar) - g_snprintf (s, w, "%c%c%c%c %3ld %5ld/%ld %6ld/%ld [%s] %s", + g_snprintf (s, w, "%c%c%c%c %3ld %5ld/%ld %6ld/%ld [%s] %s %s", edit->mark1 != edit->mark2 ? (edit->column_highlight ? 'C' : 'B') : '-', // edit->modified != 0 ? 'M' : '-', // macro_index < 0 ? '-' : 'R', // @@ -165,9 +206,11 @@ status_string (WEdit *edit, char *s, int w) (long) edit->buffer.size, // character_code, mc_global.source_codepage >= 0 ? get_codepage_id (mc_global.source_codepage) - : ""); + : "", + lb // + ); else - g_snprintf (s, w, "[%c%c%c%c] %2ld L:[%3ld+%2ld %3ld/%3ld] *(%-4ld/%4ldb) [%s] %s", + g_snprintf (s, w, "[%c%c%c%c] %2ld L:[%3ld+%2ld %3ld/%3ld] *(%-4ld/%4ldb) [%s] %s %s", edit->mark1 != edit->mark2 ? (edit->column_highlight ? 'C' : 'B') : '-', // edit->modified != 0 ? 'M' : '-', // macro_index < 0 ? '-' : 'R', // @@ -181,7 +224,9 @@ status_string (WEdit *edit, char *s, int w) (long) edit->buffer.size, // character_code, mc_global.source_codepage >= 0 ? get_codepage_id (mc_global.source_codepage) - : ""); + : "", + lb // + ); g_free (character_code); } @@ -297,6 +342,15 @@ edit_status_window (WEdit *edit) edit->overwrite == 0 ? '-' : 'O'); } + tty_getyx (&y, &x); + x -= w->rect.x; + x += 4; + if (x + 6 <= cols - 2 - 6) + { + edit_move (x, 0); + tty_printf ("%s", edit_line_breaks_status_sign (&edit->buffer)); + } + if (cols > 30) { edit_move (2, w->rect.lines - 1); @@ -497,11 +551,16 @@ edit_draw_this_line (WEdit *edit, off_t b, long row, long start_col, long end_co int col, start_col_real; int abn_style; int book_mark = 0; + gboolean crlf_hidden; char line_stat[LINE_STATE_WIDTH + 1] = "\0"; if (row > w->rect.lines - 1 - EDIT_TEXT_VERTICAL_OFFSET - 2 * (edit->fullscreen != 0 ? 0 : 1)) return; + // in a pure Windows file the "\r" of a "\r\n" line break is a line break + // (hidden); in any other file it is shown as "^M" + crlf_hidden = (edit_buffer_get_line_breaks (&edit->buffer) == LB_WIN); + if (book_mark_query_color (edit, edit->start_line + row, EDITOR_BOOKMARK_COLOR)) book_mark = EDITOR_BOOKMARK_COLOR; else if (book_mark_query_color (edit, edit->start_line + row, EDITOR_BOOKMARK_FOUND_COLOR)) @@ -554,14 +613,7 @@ edit_draw_this_line (WEdit *edit, off_t b, long row, long start_col, long end_co off_t tws = 0; if (edit_options.visible_tws && tty_use_colors ()) - for (tws = edit_buffer_get_eol (&edit->buffer, b); tws > b; tws--) - { - unsigned int c; - - c = edit_buffer_get_byte (&edit->buffer, tws - 1); - if (!whitespace (c)) - break; - } + tws = edit_buffer_trailing_ws_start (&edit->buffer, b); while (col <= end_col - edit->start_col) { @@ -603,6 +655,13 @@ edit_draw_this_line (WEdit *edit, off_t b, long row, long start_col, long end_co else c = edit_buffer_get_byte (&edit->buffer, q); + // the "\r" of a hidden "\r\n" line break is not shown + if (c == '\r' && crlf_hidden && edit_buffer_is_crlf (&edit->buffer, q)) + { + q++; + continue; + } + // we don't use bg for mc - fg contains both if (book_mark != 0) p->style |= book_mark << 16; @@ -817,6 +876,10 @@ render_edit_text (WEdit *edit, long start_row, long start_column, long end_row, int y1, x1, y2, x2; int last_line, last_column; + // make sure the cached line break type (used to decide whether a "\r" is + // shown as "^M" or hidden) is up to date before drawing + edit_buffer_refresh_line_breaks (&edit->buffer); + // draw only visible region last_line = wh->rect.y + wh->rect.lines - 1; diff --git a/src/editor/editsearch.c b/src/editor/editsearch.c index 63b4b376c4..4aad6b0dc6 100644 --- a/src/editor/editsearch.c +++ b/src/editor/editsearch.c @@ -155,6 +155,10 @@ edit_dialog_search_show (WEdit *edit) /** * Get EOL symbol for searching. * + * Line breaks in the buffer are either "\n" or "\r\n"; in both cases the + * line is terminated by "\n" (a "\r" only precedes it, it never terminates a + * line by itself). Therefore the search EOL symbol is always "\n". + * * @param edit editor object * @return EOL symbol */ @@ -162,13 +166,8 @@ edit_dialog_search_show (WEdit *edit) static inline char edit_search_get_current_end_line_char (const WEdit *edit) { - switch (edit->lb) - { - case LB_MAC: - return '\r'; - default: - return '\n'; - } + (void) edit; + return '\n'; } /* --------------------------------------------------------------------------------------------- */ diff --git a/src/editor/editwidget.h b/src/editor/editwidget.h index 38c948aee5..913566e43f 100644 --- a/src/editor/editwidget.h +++ b/src/editor/editwidget.h @@ -154,7 +154,7 @@ struct WEdit gboolean is_case_insensitive; // selects language case sensitivity // line break - LineBreaks lb; + LineBreaks lb; // line break conversion mode on save (set in Save As dialog) }; /*** global variables defined in .c file *********************************************************/ diff --git a/src/editor/format.c b/src/editor/format.c index b012f6163c..892089ca4b 100644 --- a/src/editor/format.c +++ b/src/editor/format.c @@ -482,6 +482,13 @@ format_paragraph (WEdit *edit, gboolean force) p = begin_paragraph (edit, force, &lines); q = end_paragraph (edit, force); + + // Formatting assumes "\n" line breaks: a "\r\n" line break would be mangled + // (the "\r" leaks into the reformatted text), so leave CRLF paragraphs as is. + for (off_t i = p; i + 1 <= q; i++) + if (edit_buffer_is_crlf (&edit->buffer, i)) + return; + indent = test_indent (edit, p, q); t = get_paragraph (&edit->buffer, p, q, indent != 0); diff --git a/tests/src/editor/Makefile.am b/tests/src/editor/Makefile.am index 81f3d9ae37..2bf3d3b4a2 100644 --- a/tests/src/editor/Makefile.am +++ b/tests/src/editor/Makefile.am @@ -19,6 +19,7 @@ EXTRA_DIST = edit_complete_word_cmd_test_data.txt.in TESTS = \ edit_complete_word_cmd \ edit_insert_column_of_text \ + edit_line_breaks \ edit_replace_cmd check_PROGRAMS = $(TESTS) @@ -29,6 +30,9 @@ edit_complete_word_cmd_SOURCES = \ edit_insert_column_of_text_SOURCES = \ edit_insert_column_of_text.c +edit_line_breaks_SOURCES = \ + edit_line_breaks.c + edit_replace_cmd_SOURCES = \ edit_replace_cmd.c diff --git a/tests/src/editor/edit_line_breaks.c b/tests/src/editor/edit_line_breaks.c new file mode 100644 index 0000000000..9b5e27d275 --- /dev/null +++ b/tests/src/editor/edit_line_breaks.c @@ -0,0 +1,876 @@ +/* + src/editor - tests for line break handling: CRLF/LF/CR detection, + atomic "\r\n" editing, line break inheritance and write conversion + + Copyright (C) 2026 + Free Software Foundation, Inc. + + This file is part of the Midnight Commander. + + The Midnight Commander is free software: you can redistribute it + and/or modify it under the terms of the GNU General Public License as + published by the Free Software Foundation, either version 3 of the License, + or (at your option) any later version. + + The Midnight Commander is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +#define TEST_SUITE_NAME "/src/editor" + +#include "tests/mctest.h" + +#include + +#include "lib/charsets.h" +#include "lib/keybind.h" +#include "lib/vfs/path.h" +#include "lib/vfs/vfs.h" +#include "src/selcodepage.h" +#include "src/vfs/local/local.c" + +#include "src/editor/edit-impl.h" +#include "src/editor/editmacros.h" // edit_load_macro_cmd() +#include "src/editor/editsearch.h" // edit_search_update_callback() +#include "src/editor/editwidget.h" + +static WGroup owner; +static WEdit *test_edit; + +/* --------------------------------------------------------------------------------------------- */ + +/* @Mock */ +void +message (int flags, const char *title, const char *text, ...) +{ + (void) flags; + (void) title; + (void) text; +} + +/* --------------------------------------------------------------------------------------------- */ + +/* @Mock */ +void +status_msg_init (status_msg_t *sm, const char *title, double delay, status_msg_cb init_cb, + status_msg_update_cb update_cb, status_msg_cb deinit_cb) +{ + (void) sm; + (void) title; + (void) delay; + (void) init_cb; + (void) update_cb; + (void) deinit_cb; +} + +/* --------------------------------------------------------------------------------------------- */ + +/* @Mock */ +void +status_msg_deinit (status_msg_t *sm) +{ + (void) sm; +} + +/* --------------------------------------------------------------------------------------------- */ + +/* @Mock */ +mc_search_cbret_t +edit_search_update_callback (const void *user_data, off_t char_offset) +{ + (void) user_data; + (void) char_offset; + + return MC_SEARCH_CB_OK; +} + +/* --------------------------------------------------------------------------------------------- */ + +/* @Mock */ +void +edit_load_syntax (WEdit *edit, GPtrArray *pnames, const char *type) +{ + (void) edit; + (void) pnames; + (void) type; +} + +/* --------------------------------------------------------------------------------------------- */ + +/* @Mock */ +int +edit_get_syntax_color (WEdit *edit, off_t byte_index) +{ + (void) edit; + (void) byte_index; + + return 0; +} + +/* --------------------------------------------------------------------------------------------- */ + +/* @Mock */ +gboolean +edit_load_macro_cmd (WEdit *edit) +{ + (void) edit; + + return FALSE; +} + +/* --------------------------------------------------------------------------------------------- */ + +/* @Before */ +static void +setup (void) +{ + WRect r; + + str_init_strings (NULL); + + vfs_init (); + vfs_init_localfs (); + vfs_setup_work_dir (); + + mc_global.sysconfig_dir = (char *) TEST_SHARE_DIR; + load_codepages_list (); + + edit_options.filesize_threshold = (char *) "64M"; + edit_options.return_does_auto_indent = FALSE; + edit_options.save_position = FALSE; + + rect_init (&r, 0, 0, 24, 80); + test_edit = edit_init (NULL, &r, NULL); + memset (&owner, 0, sizeof (owner)); + group_add_widget (&owner, WIDGET (test_edit)); + + mc_global.source_codepage = 0; + mc_global.display_codepage = 0; + cp_source = "ASCII"; + cp_display = "ASCII"; + + do_set_codepage (0); + edit_set_codeset (test_edit); +} + +/* --------------------------------------------------------------------------------------------- */ + +/* @After */ +static void +teardown (void) +{ + edit_clean (test_edit); + group_remove_widget (test_edit); + g_free (test_edit); + + free_codepages_list (); + vfs_shut (); + str_uninit_strings (); +} + +/* --------------------------------------------------------------------------------------------- */ + +// the line counters are maintained by edit_insert()/edit_delete() in the real +// session; refresh them after the raw buffer manipulations +static void +test_refresh_lines (void) +{ + long lines = 1; + long curs_line = 0; + + for (off_t i = 0; i < test_edit->buffer.size; i++) + { + if (edit_buffer_get_byte (&test_edit->buffer, i) == '\n') + { + lines++; + if (i < test_edit->buffer.curs1) + curs_line++; + } + } + + test_edit->buffer.lines = lines; + test_edit->buffer.curs_line = curs_line; +} + +/* --------------------------------------------------------------------------------------------- */ + +static void +test_load_text (const char *text) +{ + for (; *text != '\0'; text++) + edit_buffer_insert (&test_edit->buffer, (unsigned char) *text); + test_refresh_lines (); +} + +/* --------------------------------------------------------------------------------------------- */ + +// move the cursor to the absolute buffer position +static void +test_cursor_to (off_t pos) +{ + edit_cursor_move (test_edit, pos - test_edit->buffer.curs1); + test_refresh_lines (); +} + +/* --------------------------------------------------------------------------------------------- */ + +static void +test_check (const char *expected) +{ + GString *actual; + + actual = g_string_new (""); + + for (off_t i = 0; i < test_edit->buffer.size; i++) + g_string_append_c (actual, edit_buffer_get_byte (&test_edit->buffer, i)); + + mctest_assert_str_eq (actual->str, expected); + g_string_free (actual, TRUE); +} + +/* --------------------------------------------------------------------------------------------- */ +/* edit_buffer_detect_line_breaks() */ + +static const struct test_detect_ds +{ + const char *in; + LineBreaks expected; +} test_detect_ds[] = { + // empty buffer + { "", LB_UNIX }, + { "abc", LB_UNIX }, + // all "\n" + { "a\nb\n", LB_UNIX }, + { "a\nb", LB_UNIX }, + // all "\r\n" + { "a\r\nb\r\n", LB_WIN }, + { "a\r\nb", LB_WIN }, + // all "\r" + { "a\rb\r", LB_MAC }, + { "a\rb", LB_MAC }, + // mixture of line breaks + { "a\r\nb\n", LB_ASIS }, + { "a\nb\r\n", LB_ASIS }, + { "a\rb\n", LB_ASIS }, + { "a\nb\rc", LB_ASIS }, + { "a\r\nb\rc", LB_ASIS }, +}; + +/* @Test(dataSource = "test_detect_ds") */ +START_PARAMETRIZED_TEST (test_detect, test_detect_ds) +{ + // given + test_load_text (data->in); + + // when + const LineBreaks lb = edit_buffer_detect_line_breaks (&test_edit->buffer); + + // then + ck_assert_int_eq (lb, data->expected); +} +END_PARAMETRIZED_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* edit_buffer_is_crlf() */ + +static const struct test_is_crlf_ds +{ + const char *in; + off_t index; + gboolean expected; +} test_is_crlf_ds[] = { + { "a\r\nb", 1, TRUE }, // "\r" of the "\r\n" pair + { "a\r\nb", 0, FALSE }, // plain char + { "a\r\nb", 2, FALSE }, // "\n" of the "\r\n" pair + { "a\r\nb", 3, FALSE }, // out of range + { "a\r\nb", -1, FALSE }, // negative index + { "a\r", 1, FALSE }, // trailing "\r", out of range + { "\r\n", 0, TRUE }, // + { "a\rb\n", 1, FALSE }, // standalone "\r" + { "a\rb\n", 3, FALSE }, // standalone "\n" +}; + +/* @Test(dataSource = "test_is_crlf_ds") */ +START_PARAMETRIZED_TEST (test_is_crlf, test_is_crlf_ds) +{ + // given + test_load_text (data->in); + + // when + const gboolean is_crlf = edit_buffer_is_crlf (&test_edit->buffer, data->index); + + // then + ck_assert_int_eq (is_crlf, data->expected); +} +END_PARAMETRIZED_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* edit_buffer_trailing_ws_start() */ + +static const struct test_trailing_ws_ds +{ + const char *in; + off_t bol; + off_t expected; +} test_trailing_ws_ds[] = { + // LF lines + { "ab \n", 0, 2 }, // trailing spaces + { "ab\n", 0, 2 }, // no trailing spaces + { " \n", 0, 0 }, // the line is all spaces + // CRLF lines: the "\r" is part of the line break, so the content ends at the + // "\r" (the same in a pure Windows file and in a mixed file) + { "ab \r\n", 0, 2 }, // trailing spaces + { "ab\r\n", 0, 2 }, // no trailing spaces + { " \r\n", 0, 0 }, // the line is all spaces + { "a\t \r\n", 0, 1 }, // trailing tab + space + { "xx\nab \r\n", 3, 5 }, // second line, trailing spaces + // last line without a line break + { "ab ", 0, 2 }, +}; + +/* @Test(dataSource = "test_trailing_ws_ds") */ +START_PARAMETRIZED_TEST (test_trailing_ws_start, test_trailing_ws_ds) +{ + // given + test_load_text (data->in); + + // when + const off_t tws = edit_buffer_trailing_ws_start (&test_edit->buffer, data->bol); + + // then + ck_assert_int_eq (tws, data->expected); +} +END_PARAMETRIZED_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* edit_write_stream() */ + +static const struct test_write_ds +{ + const char *in; + LineBreaks lb; + const char *out; +} test_write_ds[] = { + { "a\r\nb\r\n", LB_ASIS, "a\r\nb\r\n" }, // as-is + { "a\r\nb\r\n", LB_UNIX, "a\nb\n" }, // "\r\n" -> "\n" + { "a\r\nb\r\n", LB_WIN, "a\r\nb\r\n" }, // "\r\n" -> "\r\n" + { "a\r\nb\r\n", LB_MAC, "a\rb\r" }, // "\r\n" -> "\r" + { "a\nb\n", LB_WIN, "a\r\nb\r\n" }, // "\n" -> "\r\n" + { "a\nb\n", LB_MAC, "a\rb\r" }, // "\n" -> "\r" + { "a\nb", LB_WIN, "a\r\nb" }, // no trailing line break + { "a\r\nb", LB_UNIX, "a\nb" }, // no trailing line break + { "abc\n", LB_UNIX, "abc\n" }, // no extra "\n" is appended (regression) + { "abc\r\n", LB_UNIX, "abc\n" }, + { "abc\r\n", LB_WIN, "abc\r\n" }, + { "\n", LB_WIN, "\r\n" }, // line break at the beginning + { "a\r\n\r\nb", LB_UNIX, "a\n\nb" }, // blank line + { "a\r\r\nb", LB_UNIX, "a\n\nb" }, // standalone "\r" before "\r\n" + { "a\r\nb\r\nc", LB_MAC, "a\rb\rc" }, // last line without line break + { "a\rb", LB_UNIX, "a\nb" }, // standalone "\r" is a line break too + { "abc", LB_WIN, "abc" }, // no line breaks +}; + +/* @Test(dataSource = "test_write_ds") */ +START_PARAMETRIZED_TEST (test_write_stream, test_write_ds) +{ + char *mem = NULL; + size_t mem_size = 0; + FILE *f; + off_t written; + + // given + test_load_text (data->in); + test_edit->lb = data->lb; + + // when + f = open_memstream (&mem, &mem_size); + written = edit_write_stream (test_edit, f); + fclose (f); + + // then + ck_assert_int_eq (written, (off_t) strlen (data->in)); + mctest_assert_str_eq (mem, data->out); + free (mem); +} +END_PARAMETRIZED_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* CK_Enter: a new line break inherits the type of the current line break */ + +START_TEST (test_enter_inherits_crlf) +{ + // given: cursor is at the end of the first line + test_load_text ("line1\r\nline2\r\n"); + test_cursor_to (5); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("line1\r\n\r\nline2\r\n"); +} +END_TEST + +START_TEST (test_enter_inherits_lf) +{ + // given: cursor is at the end of the first line + test_load_text ("line1\nline2\n"); + test_cursor_to (5); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("line1\n\nline2\n"); +} +END_TEST + +// the last line has no line break of its own: use the previous line's line break +START_TEST (test_enter_last_line_inherits_crlf) +{ + // given: cursor is at the end of the last line + test_load_text ("a\r\nb\r\nc"); + test_cursor_to (7); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("a\r\nb\r\nc\r\n"); +} +END_TEST + +START_TEST (test_enter_last_line_inherits_lf) +{ + // given: cursor is at the end of the last line + test_load_text ("a\nb\nc"); + test_cursor_to (5); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("a\nb\nc\n"); +} +END_TEST + +START_TEST (test_enter_empty_buffer) +{ + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("\n"); +} +END_TEST + +/* with auto indent enabled the line break must stay of the file's type */ +START_TEST (test_enter_auto_indent_crlf) +{ + // given: indented line, cursor at the end of the second line + edit_options.return_does_auto_indent = TRUE; + test_load_text (" a\r\n b\r\n"); + test_cursor_to (8); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then: the new line gets the indent of the previous line, line break stays CRLF + test_check (" a\r\n b\r\n \r\n"); +} +END_TEST + +/* with auto paragraph formatting enabled pressing Enter must keep the file's line breaks */ +START_TEST (test_enter_auto_para_format_crlf) +{ + // given: cursor at the end of the second line + edit_options.auto_para_formatting = TRUE; + test_load_text ("aaaa\r\nbbbb\r\n"); + test_cursor_to (10); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("aaaa\r\nbbbb\r\n\r\n"); +} +END_TEST + +/* formatting a CRLF paragraph must not leak "\r" into the text or change line breaks */ +START_TEST (test_format_paragraph_crlf) +{ + // given: a CRLF paragraph, cursor on a non-blank line + test_load_text ("aaaa bbbb\r\ncccc dddd\r\n"); + test_cursor_to (11); + + // when + format_paragraph (test_edit, FALSE); + + // then: the paragraph is left unchanged (line breaks intact, no stray "\r") + test_check ("aaaa bbbb\r\ncccc dddd\r\n"); +} +END_TEST + +START_TEST (test_enter_auto_indent_crlf_empty_prev_line) +{ + // given: cursor is on an empty line + edit_options.return_does_auto_indent = TRUE; + test_load_text ("line1\r\n\r\nline3\r\n"); + test_cursor_to (7); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("line1\r\n\r\n\r\nline3\r\n"); +} +END_TEST + +START_TEST (test_enter_auto_indent_crlf_last_line) +{ + // given: cursor is at the end of the last (indented) line + edit_options.return_does_auto_indent = TRUE; + test_load_text (" a\r\n b\r\n c"); + test_cursor_to (13); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then: the new line gets the indent of the previous line, line break stays CRLF + test_check (" a\r\n b\r\n c\r\n "); +} +END_TEST + +/* moving down onto a shorter CRLF line must not leave the cursor inside the "\r\n" pair */ +START_TEST (test_down_then_enter_crlf) +{ + // given: auto-indent on; a non-empty line, an empty line, a non-empty line; all CRLF + edit_options.return_does_auto_indent = TRUE; + test_load_text ("xxxx\r\n\r\nyyyy\r\n"); + test_cursor_to (0); + + // when: go to the end of the first line, move down onto the empty line, press Enter + edit_execute_cmd (test_edit, CK_End, -1); + edit_execute_cmd (test_edit, CK_Down, -1); + + // then: the cursor is at the end of the empty line, before its "\r" (not after it) + ck_assert_int_eq (test_edit->buffer.curs1, 6); + + edit_execute_cmd (test_edit, CK_Enter, -1); + + // a new CRLF line is added; no LF line break is introduced + test_check ("xxxx\r\n\r\n\r\nyyyy\r\n"); +} +END_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* CK_End: the cursor stops before the "\r" of a "\r\n" line break */ + +START_TEST (test_end_stops_before_cr) +{ + // given: cursor is at the begin of the first line + test_load_text ("ab\r\ncd"); + test_cursor_to (0); + + // when + edit_execute_cmd (test_edit, CK_End, -1); + + // then + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +START_TEST (test_end_stops_at_lf) +{ + // given: cursor is at the begin of the first line + test_load_text ("ab\ncd"); + test_cursor_to (0); + + // when + edit_execute_cmd (test_edit, CK_End, -1); + + // then + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* edit_delete()/edit_backspace(): a "\r\n" line break is an atomic unit */ + +START_TEST (test_delete_crlf_atomic) +{ + // given: cursor is on the "\r" of a "\r\n" line break + test_load_text ("ab\r\ncd"); + test_cursor_to (2); + + // when + edit_delete (test_edit, TRUE); + + // then + test_check ("abcd"); + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +START_TEST (test_backspace_crlf_atomic) +{ + // given: cursor is after the "\n" of a "\r\n" line break + test_load_text ("ab\r\ncd"); + test_cursor_to (4); + + // when + edit_backspace (test_edit, TRUE); + + // then + test_check ("abcd"); + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +// a standalone "\r" (not followed by "\n") is not a line break: delete one byte only +START_TEST (test_delete_standalone_cr) +{ + // given: cursor is on the standalone "\r" + test_load_text ("ab\rcd"); + test_cursor_to (2); + + // when + edit_delete (test_edit, TRUE); + + // then + test_check ("abcd"); + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +START_TEST (test_backspace_standalone_cr) +{ + // given: cursor is after the standalone "\r" + test_load_text ("ab\rcd"); + test_cursor_to (3); + + // when + edit_backspace (test_edit, TRUE); + + // then + test_check ("abcd"); + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* CK_DeleteToEnd: stop before the "\r" of a "\r\n" line break */ + +START_TEST (test_delete_to_line_end_crlf) +{ + // given: cursor is at the begin of the first line + test_load_text ("ab\r\ncd"); + test_cursor_to (0); + + // when + edit_execute_cmd (test_edit, CK_DeleteToEnd, -1); + + // then: the "\r\n" line break itself is preserved + test_check ("\r\ncd"); +} +END_TEST + +START_TEST (test_delete_to_line_end_lf) +{ + // given: cursor is at the begin of the first line + test_load_text ("ab\ncd"); + test_cursor_to (0); + + // when + edit_execute_cmd (test_edit, CK_DeleteToEnd, -1); + + // then + test_check ("\ncd"); +} +END_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* In a file with a mixture of line breaks the "\r" of a "\r\n" pair is NOT hidden: it is shown + * as "^M". Nevertheless a "\r\n" pair is always a single (atomic) line break, exactly as in a pure + * Windows file: the cursor stops before the "\r", Delete/Backspace remove the pair as a unit, and + * the content of the line ends at the "\r". The test buffers below are mixed (contain both "\r\n" + * and "\n"), so they are LB_ASIS; they verify the atomic behaviour on a non-pure-Windows file. */ + +// CK_End: the cursor stops before the "\r" (the line break), even in a mixed file +START_TEST (test_end_stops_before_cr_mixed) +{ + // given: a mixed file, cursor is at the begin of the CRLF line + test_load_text ("ab\r\ncd\n"); + test_cursor_to (0); + + // when + edit_execute_cmd (test_edit, CK_End, -1); + + // then: the cursor is before the "\r" (offset 2), not after it + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +// edit_delete(): the "\r\n" pair is deleted as a single unit, even in a mixed file +START_TEST (test_delete_crlf_atomic_mixed) +{ + // given: a mixed file, cursor is before the "\r" of a "\r\n" line break + test_load_text ("ab\r\ncd\n"); + test_cursor_to (2); + + // when + edit_delete (test_edit, TRUE); + + // then: the whole "\r\n" pair is gone, the lines are joined + test_check ("abcd\n"); + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +// edit_backspace(): the "\r\n" pair is deleted as a single unit, even in a mixed file +START_TEST (test_backspace_crlf_atomic_mixed) +{ + // given: a mixed file, cursor is at the begin of the line after a "\r\n" line break + test_load_text ("ab\r\ncd\n"); + test_cursor_to (4); + + // when + edit_backspace (test_edit, TRUE); + + // then: the whole "\r\n" pair is gone, the lines are joined + test_check ("abcd\n"); + ck_assert_int_eq (test_edit->buffer.curs1, 2); +} +END_TEST + +// CK_DeleteToEnd: stops before the "\r\n" line break, which is preserved +START_TEST (test_delete_to_line_end_crlf_mixed) +{ + // given: a mixed file, cursor is at the begin of the CRLF line + test_load_text ("ab\r\ncd\n"); + test_cursor_to (0); + + // when + edit_execute_cmd (test_edit, CK_DeleteToEnd, -1); + + // then: "ab" is deleted, the "\r\n" line break is preserved + test_check ("\r\ncd\n"); +} +END_TEST + +// CK_Enter after CK_End on a CRLF line: a blank CRLF line is added, with no +// duplicate "\r" (no "^M^M"), the cursor is at the begin of the blank line +START_TEST (test_enter_after_end_crlf_mixed) +{ + // given: a mixed file, cursor is at the begin of the CRLF line + test_load_text ("ab\r\ncd\n"); + test_cursor_to (0); + + // when: move to the end of the line, then press Enter + edit_execute_cmd (test_edit, CK_End, -1); + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then: a blank CRLF line is added (the line break is inherited), no duplicate + // "\r" is introduced, the cursor is at the begin of the blank line + test_check ("ab\r\n\r\ncd\n"); + ck_assert_int_eq (test_edit->buffer.curs1, 4); +} +END_TEST + +/* --------------------------------------------------------------------------------------------- */ +/* loading a file: the buffer keeps the raw content, detection works on the loaded text */ + +START_TEST (test_load_crlf_file) +{ + const char *path = "/tmp/mc-test-line-breaks.crlf"; + WEdit *edit; + WRect r; + edit_arg_t arg; + vfs_path_t *vpath; + FILE *f; + GString *actual; + + // given: a file with "\r\n" line breaks + f = fopen (path, "wb"); + mctest_assert_not_null (f); + fputs ("line1\r\nline2\r\nline3\r\n", f); + fclose (f); + + vpath = vfs_path_from_str (path); + arg.file_vpath = vpath; + arg.line_number = 0; + rect_init (&r, 0, 0, 24, 80); + edit = edit_init (NULL, &r, &arg); + + // then + mctest_assert_not_null (edit); + + actual = g_string_new (""); + for (off_t i = 0; i < edit->buffer.size; i++) + g_string_append_c (actual, edit_buffer_get_byte (&edit->buffer, i)); + mctest_assert_str_eq (actual->str, "line1\r\nline2\r\nline3\r\n"); + g_string_free (actual, TRUE); + + ck_assert_int_eq (edit->lb, LB_ASIS); + ck_assert_int_eq (edit_buffer_detect_line_breaks (&edit->buffer), LB_WIN); + + // cleanup + edit_clean (edit); + g_free (edit); + vfs_path_free (vpath, TRUE); + unlink (path); +} +END_TEST + +/* --------------------------------------------------------------------------------------------- */ + +int +main (void) +{ + TCase *tc_core; + + tc_core = tcase_create ("Core"); + + tcase_add_checked_fixture (tc_core, setup, teardown); + + // Add new tests here: *************** + mctest_add_parameterized_test (tc_core, test_detect, test_detect_ds); + mctest_add_parameterized_test (tc_core, test_is_crlf, test_is_crlf_ds); + mctest_add_parameterized_test (tc_core, test_trailing_ws_start, test_trailing_ws_ds); + mctest_add_parameterized_test (tc_core, test_write_stream, test_write_ds); + tcase_add_test (tc_core, test_enter_inherits_crlf); + tcase_add_test (tc_core, test_enter_inherits_lf); + tcase_add_test (tc_core, test_enter_last_line_inherits_crlf); + tcase_add_test (tc_core, test_enter_last_line_inherits_lf); + tcase_add_test (tc_core, test_enter_empty_buffer); + tcase_add_test (tc_core, test_enter_auto_indent_crlf); + tcase_add_test (tc_core, test_enter_auto_para_format_crlf); + tcase_add_test (tc_core, test_format_paragraph_crlf); + tcase_add_test (tc_core, test_enter_auto_indent_crlf_empty_prev_line); + tcase_add_test (tc_core, test_enter_auto_indent_crlf_last_line); + tcase_add_test (tc_core, test_down_then_enter_crlf); + tcase_add_test (tc_core, test_end_stops_before_cr); + tcase_add_test (tc_core, test_end_stops_at_lf); + tcase_add_test (tc_core, test_delete_crlf_atomic); + tcase_add_test (tc_core, test_backspace_crlf_atomic); + tcase_add_test (tc_core, test_delete_standalone_cr); + tcase_add_test (tc_core, test_backspace_standalone_cr); + tcase_add_test (tc_core, test_delete_to_line_end_crlf); + tcase_add_test (tc_core, test_delete_to_line_end_lf); + tcase_add_test (tc_core, test_end_stops_before_cr_mixed); + tcase_add_test (tc_core, test_delete_crlf_atomic_mixed); + tcase_add_test (tc_core, test_backspace_crlf_atomic_mixed); + tcase_add_test (tc_core, test_delete_to_line_end_crlf_mixed); + tcase_add_test (tc_core, test_enter_after_end_crlf_mixed); + tcase_add_test (tc_core, test_load_crlf_file); + // *********************************** + + return mctest_run_all (tc_core); +} + +/* --------------------------------------------------------------------------------------------- */ \ No newline at end of file From 8cf9615c073e0f6394309d15ef812e47c0180833 Mon Sep 17 00:00:00 2001 From: "Yury V. Zaytsev" Date: Sun, 4 Oct 2026 08:37:45 +0000 Subject: [PATCH 4/7] tests: cover block, undo and replace operations on "\r\n" line breaks Block delete, block move, undo of Enter and Delete, delete word left, overwrite mode and regex replace of "\r" must remove exactly the bytes they are meant to remove in a text with "\r\n" line breaks. Assisted-By: Claude Opus 5.5 Signed-off-by: Yury V. Zaytsev --- tests/src/editor/edit_line_breaks.c | 109 ++++++++++++++++++++++++++++ tests/src/editor/edit_replace_cmd.c | 27 +++++++ 2 files changed, 136 insertions(+) diff --git a/tests/src/editor/edit_line_breaks.c b/tests/src/editor/edit_line_breaks.c index 9b5e27d275..c1e53392a5 100644 --- a/tests/src/editor/edit_line_breaks.c +++ b/tests/src/editor/edit_line_breaks.c @@ -782,6 +782,109 @@ START_TEST (test_enter_after_end_crlf_mixed) } END_TEST +/* --------------------------------------------------------------------------------------------- */ +/* Block and undo operations work byte by byte: they must not remove more than they intend to, + * even when the text contains "\r\n" line breaks. */ + +// block delete removes the selected bytes only +START_TEST (test_block_delete_crlf) +{ + // given: the first two lines of a CRLF file are selected + test_load_text ("a\r\nb\r\nXYZ\r\n"); + test_cursor_to (0); + edit_set_markers (test_edit, 0, 6, 0, 0); + + // when + edit_block_delete_cmd (test_edit); + + // then: the third line is intact + test_check ("XYZ\r\n"); +} +END_TEST + +// block move keeps all bytes of the moved block, including the "\r" +START_TEST (test_block_move_crlf) +{ + // given: the first two lines of a CRLF file are selected, cursor is at the end of the file + test_load_text ("a\r\nb\r\nXYZ\r\n"); + test_cursor_to (0); + edit_set_markers (test_edit, 0, 6, 0, 0); + test_cursor_to (11); + + // when + edit_block_move_cmd (test_edit); + + // then + test_check ("XYZ\r\na\r\nb\r\n"); +} +END_TEST + +// undo of Enter removes the inserted "\r\n" only +START_TEST (test_undo_enter_crlf) +{ + // given: cursor is at the end of the first line + test_load_text ("abc\r\ndef\r\n"); + test_cursor_to (3); + edit_execute_key_command (test_edit, CK_Enter, -1); + test_check ("abc\r\n\r\ndef\r\n"); + + // when + edit_execute_key_command (test_edit, CK_Undo, -1); + + // then: the "c" before the line break is not deleted + test_check ("abc\r\ndef\r\n"); +} +END_TEST + +// undo of Delete restores the whole "\r\n" line break +START_TEST (test_undo_delete_crlf) +{ + // given: cursor is before the "\r" of a "\r\n" line break + test_load_text ("abc\r\ndef\r\n"); + test_cursor_to (3); + edit_execute_key_command (test_edit, CK_Delete, -1); + test_check ("abcdef\r\n"); + + // when + edit_execute_key_command (test_edit, CK_Undo, -1); + + // then + test_check ("abc\r\ndef\r\n"); +} +END_TEST + +// delete word left stops at a "\r\n" line break like at a "\n" one +START_TEST (test_left_delete_word_crlf) +{ + // given: cursor is at the beginning of the second line + test_load_text ("foo \r\nbar"); + test_cursor_to (8); + + // when + edit_execute_cmd (test_edit, CK_DeleteToWordBegin, -1); + + // then: only the line break is deleted, the trailing spaces are kept + test_check ("foo bar"); +} +END_TEST + +// typing in overwrite mode at the end of a CRLF line does not join it with the next line +START_TEST (test_overwrite_at_eol_crlf) +{ + // given: overwrite mode, cursor is at the end of the first line + test_load_text ("abc\r\ndef\r\n"); + test_cursor_to (0); + test_edit->overwrite = 1; + edit_execute_cmd (test_edit, CK_End, -1); + + // when + edit_execute_cmd (test_edit, -1, 'X'); + + // then + test_check ("abcX\r\ndef\r\n"); +} +END_TEST + /* --------------------------------------------------------------------------------------------- */ /* loading a file: the buffer keeps the raw content, detection works on the loaded text */ @@ -867,6 +970,12 @@ main (void) tcase_add_test (tc_core, test_backspace_crlf_atomic_mixed); tcase_add_test (tc_core, test_delete_to_line_end_crlf_mixed); tcase_add_test (tc_core, test_enter_after_end_crlf_mixed); + tcase_add_test (tc_core, test_block_delete_crlf); + tcase_add_test (tc_core, test_block_move_crlf); + tcase_add_test (tc_core, test_undo_enter_crlf); + tcase_add_test (tc_core, test_undo_delete_crlf); + tcase_add_test (tc_core, test_left_delete_word_crlf); + tcase_add_test (tc_core, test_overwrite_at_eol_crlf); tcase_add_test (tc_core, test_load_crlf_file); // *********************************** diff --git a/tests/src/editor/edit_replace_cmd.c b/tests/src/editor/edit_replace_cmd.c index 206b10723a..9af898dc8d 100644 --- a/tests/src/editor/edit_replace_cmd.c +++ b/tests/src/editor/edit_replace_cmd.c @@ -118,6 +118,10 @@ static const char *replace_regex_insert_char_at_begin_of_string__from = "^"; static const char *replace_regex_replace_first_char_of_string__from = "^."; static const char *replace_regex_begin_of_string__to = "X"; +// replace "\r" with nothing: convert "\r\n" line breaks to "\n" +static const char *replace_regex_cr__from = "\\r"; +static const char *replace_regex_cr__to = ""; + /* --------------------------------------------------------------------------------------------- */ void edit_dialog_replace_show (WEdit *edit, const char *search_default, const char *replace_default, @@ -465,6 +469,28 @@ END_TEST /* --------------------------------------------------------------------------------------------- */ +// every replacement deletes exactly the matched bytes, also when they are a part of "\r\n" +START_TEST (test_replace_regex__crlf_to_lf) +{ + // given + only_in_selection = FALSE; + replace_regex__from = &replace_regex_cr__from; + replace_regex__to = &replace_regex_cr__to; + + for (const char *ti = "qwe\r\nqwe\r\nqwe\r\n"; *ti != '\0'; ti++) + edit_buffer_insert (&test_edit->buffer, *ti); + + // when + edit_cursor_move (test_edit, 0); + edit_replace_cmd (test_edit, FALSE); + + // then + test_replace_check ("qwe\nqwe\nqwe\n"); +} +END_TEST + +/* --------------------------------------------------------------------------------------------- */ + int main (void) { @@ -482,6 +508,7 @@ main (void) tcase_add_test (tc_core, test_replace_regex__in_selection_top_down_2); tcase_add_test (tc_core, test_replace_regex__in_selection_bottom_up_1); tcase_add_test (tc_core, test_replace_regex__in_selection_bottom_up_2); + tcase_add_test (tc_core, test_replace_regex__crlf_to_lf); // *********************************** return mctest_run_all (tc_core); From 30105d3ec410808450ff5a9130567b0799ff35ca Mon Sep 17 00:00:00 2001 From: "Yury V. Zaytsev" Date: Sun, 4 Oct 2026 08:43:02 +0000 Subject: [PATCH 5/7] tests: use a unique temporary file in test_load_crlf_file A fixed /tmp path collides when the test suite runs concurrently, e.g. from two build directories, and ignores TMPDIR. Assisted-By: Claude Opus 5.5 Signed-off-by: Yury V. Zaytsev --- tests/src/editor/edit_line_breaks.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/src/editor/edit_line_breaks.c b/tests/src/editor/edit_line_breaks.c index c1e53392a5..dafb62f13c 100644 --- a/tests/src/editor/edit_line_breaks.c +++ b/tests/src/editor/edit_line_breaks.c @@ -890,7 +890,8 @@ END_TEST START_TEST (test_load_crlf_file) { - const char *path = "/tmp/mc-test-line-breaks.crlf"; + char *path = NULL; + int fd; WEdit *edit; WRect r; edit_arg_t arg; @@ -899,7 +900,9 @@ START_TEST (test_load_crlf_file) GString *actual; // given: a file with "\r\n" line breaks - f = fopen (path, "wb"); + fd = g_file_open_tmp ("mc-test-line-breaks-XXXXXX", &path, NULL); + ck_assert_int_ge (fd, 0); + f = fdopen (fd, "wb"); mctest_assert_not_null (f); fputs ("line1\r\nline2\r\nline3\r\n", f); fclose (f); @@ -927,6 +930,7 @@ START_TEST (test_load_crlf_file) g_free (edit); vfs_path_free (vpath, TRUE); unlink (path); + g_free (path); } END_TEST From 8730cb092d4b1cc440be0a855a31783ad380c2a6 Mon Sep 17 00:00:00 2001 From: "Yury V. Zaytsev" Date: Sun, 4 Oct 2026 08:37:08 +0000 Subject: [PATCH 6/7] editor: delete a "\r\n" line break as a unit only in user commands edit_delete() and edit_backspace() are byte primitives: block delete, block move, search and replace, undo and redo call them once per byte and expect exactly one byte to be removed. Making them remove both bytes of a "\r\n" pair deleted unselected text after a block, lost the "\r" in block move, joined lines in "replace \r with nothing", and made undo of Enter delete the character before the line break. Keep the primitives byte-exact and handle the "\r\n" unit in the user-facing commands instead: Delete, Backspace, delete word left and right, and delete line. In overwrite mode do not overwrite the "\r" of a "\r\n" line break, which joined the line with the next one. Assisted-By: Claude Opus 5.5 Signed-off-by: Yury V. Zaytsev --- src/editor/edit.c | 71 +++++++++++++++++++++-------- tests/src/editor/edit_line_breaks.c | 18 ++++---- 2 files changed, 60 insertions(+), 29 deletions(-) diff --git a/src/editor/edit.c b/src/editor/edit.c index c9a2f2cf2b..d3ee19f992 100644 --- a/src/editor/edit.c +++ b/src/editor/edit.c @@ -1142,6 +1142,48 @@ edit_move_updown (WEdit *edit, long lines, gboolean do_scroll, gboolean directio /* --------------------------------------------------------------------------------------------- */ +/** + * Delete the character under the cursor as a result of a user command. + * Unlike edit_delete(), a "\r\n" line break is deleted as a single unit. + * + * @return the deleted character, '\n' for a "\r\n" line break + */ + +static int +edit_delete_char (WEdit *edit, gboolean byte_delete) +{ + if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1)) + { + (void) edit_delete (edit, TRUE); + return edit_delete (edit, TRUE); + } + + return edit_delete (edit, byte_delete); +} + +/* --------------------------------------------------------------------------------------------- */ +/** + * Delete the character before the cursor as a result of a user command. + * Unlike edit_backspace(), a "\r\n" line break is deleted as a single unit. + * + * @return the deleted character, '\n' for a "\r\n" line break + */ + +static int +edit_backspace_char (WEdit *edit, gboolean byte_delete) +{ + if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1 - 2)) + { + (void) edit_backspace (edit, TRUE); + (void) edit_backspace (edit, TRUE); + return '\n'; + } + + return edit_backspace (edit, byte_delete); +} + +/* --------------------------------------------------------------------------------------------- */ + static void edit_right_delete_word (WEdit *edit) { @@ -1149,7 +1191,7 @@ edit_right_delete_word (WEdit *edit) { int c1, c2; - c1 = edit_delete (edit, TRUE); + c1 = edit_delete_char (edit, TRUE); if (c1 == '\n') break; c2 = edit_buffer_get_current_byte (&edit->buffer); @@ -1171,7 +1213,7 @@ edit_left_delete_word (WEdit *edit) { int c1, c2; - c1 = edit_backspace (edit, TRUE); + c1 = edit_backspace_char (edit, TRUE); if (c1 == '\n') break; c2 = edit_buffer_get_previous_byte (&edit->buffer); @@ -2692,10 +2734,6 @@ edit_delete (WEdit *edit, gboolean byte_delete) char_length = 1; } - // a "\r\n" line break is deleted as a single unit - if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1)) - char_length = 2; - if (edit->mark2 != edit->mark1) edit_push_markers (edit); @@ -2741,7 +2779,6 @@ edit_backspace (WEdit *edit, gboolean byte_delete) int p = 0; int char_length = 1; int i; - gboolean deleted_nl = FALSE; if (edit->buffer.curs1 == 0) return 0; @@ -2756,10 +2793,6 @@ edit_backspace (WEdit *edit, gboolean byte_delete) char_length = 1; } - // a "\r\n" line break is deleted as a single unit - if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1 - 2)) - char_length = 2; - for (i = 1; i <= char_length; i++) { if (edit->mark1 >= edit->buffer.curs1) @@ -2774,13 +2807,10 @@ edit_backspace (WEdit *edit, gboolean byte_delete) p = edit_buffer_backspace (&edit->buffer); - if (p == '\n') - deleted_nl = TRUE; - edit_push_undo_action (edit, p); } edit_modification (edit); - if (deleted_nl) + if (p == '\n') { book_mark_dec (edit, edit->buffer.curs_line); edit->buffer.curs_line--; @@ -2791,7 +2821,7 @@ edit_backspace (WEdit *edit, gboolean byte_delete) if (edit->buffer.curs1 < edit->start_display) { edit->start_display--; - if (deleted_nl) + if (p == '\n') edit->start_line--; } @@ -3324,7 +3354,7 @@ edit_delete_line (WEdit *edit) * Note that edit_delete() will not corrupt anything if called while * cursor position is EOF. */ - (void) edit_delete (edit, TRUE); + (void) edit_delete_char (edit, TRUE); /* * Delete left part of the line. @@ -3522,7 +3552,8 @@ edit_execute_cmd (WEdit *edit, long command, int char_for_insertion) { // remove char only one time, after input first byte, multibyte chars if (!mc_global.utf8_display || edit->charpoint == 0) - if (edit_buffer_get_current_byte (&edit->buffer) != '\n') + if (edit_buffer_get_current_byte (&edit->buffer) != '\n' + && !edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1)) edit_delete (edit, FALSE); } if (edit_options.cursor_beyond_eol && edit->over_col > 0) @@ -3650,7 +3681,7 @@ edit_execute_cmd (WEdit *edit, long command, int char_for_insertion) edit_backspace (edit, TRUE); } else - edit_backspace (edit, FALSE); + edit_backspace_char (edit, FALSE); break; case CK_Delete: // if non persistent selection and text selected @@ -3670,7 +3701,7 @@ edit_execute_cmd (WEdit *edit, long command, int char_for_insertion) edit_delete (edit, TRUE); } else - edit_delete (edit, FALSE); + edit_delete_char (edit, FALSE); } break; case CK_DeleteToWordBegin: diff --git a/tests/src/editor/edit_line_breaks.c b/tests/src/editor/edit_line_breaks.c index dafb62f13c..845a2d4595 100644 --- a/tests/src/editor/edit_line_breaks.c +++ b/tests/src/editor/edit_line_breaks.c @@ -600,7 +600,7 @@ START_TEST (test_end_stops_at_lf) END_TEST /* --------------------------------------------------------------------------------------------- */ -/* edit_delete()/edit_backspace(): a "\r\n" line break is an atomic unit */ +/* CK_Delete/CK_BackSpace: a "\r\n" line break is an atomic unit */ START_TEST (test_delete_crlf_atomic) { @@ -609,7 +609,7 @@ START_TEST (test_delete_crlf_atomic) test_cursor_to (2); // when - edit_delete (test_edit, TRUE); + edit_execute_cmd (test_edit, CK_Delete, -1); // then test_check ("abcd"); @@ -624,7 +624,7 @@ START_TEST (test_backspace_crlf_atomic) test_cursor_to (4); // when - edit_backspace (test_edit, TRUE); + edit_execute_cmd (test_edit, CK_BackSpace, -1); // then test_check ("abcd"); @@ -640,7 +640,7 @@ START_TEST (test_delete_standalone_cr) test_cursor_to (2); // when - edit_delete (test_edit, TRUE); + edit_execute_cmd (test_edit, CK_Delete, -1); // then test_check ("abcd"); @@ -655,7 +655,7 @@ START_TEST (test_backspace_standalone_cr) test_cursor_to (3); // when - edit_backspace (test_edit, TRUE); + edit_execute_cmd (test_edit, CK_BackSpace, -1); // then test_check ("abcd"); @@ -716,7 +716,7 @@ START_TEST (test_end_stops_before_cr_mixed) } END_TEST -// edit_delete(): the "\r\n" pair is deleted as a single unit, even in a mixed file +// CK_Delete: the "\r\n" pair is deleted as a single unit, even in a mixed file START_TEST (test_delete_crlf_atomic_mixed) { // given: a mixed file, cursor is before the "\r" of a "\r\n" line break @@ -724,7 +724,7 @@ START_TEST (test_delete_crlf_atomic_mixed) test_cursor_to (2); // when - edit_delete (test_edit, TRUE); + edit_execute_cmd (test_edit, CK_Delete, -1); // then: the whole "\r\n" pair is gone, the lines are joined test_check ("abcd\n"); @@ -732,7 +732,7 @@ START_TEST (test_delete_crlf_atomic_mixed) } END_TEST -// edit_backspace(): the "\r\n" pair is deleted as a single unit, even in a mixed file +// CK_BackSpace: the "\r\n" pair is deleted as a single unit, even in a mixed file START_TEST (test_backspace_crlf_atomic_mixed) { // given: a mixed file, cursor is at the begin of the line after a "\r\n" line break @@ -740,7 +740,7 @@ START_TEST (test_backspace_crlf_atomic_mixed) test_cursor_to (4); // when - edit_backspace (test_edit, TRUE); + edit_execute_cmd (test_edit, CK_BackSpace, -1); // then: the whole "\r\n" pair is gone, the lines are joined test_check ("abcd\n"); From f146d536fa24298cf448f7612d12d2d2b1cc74a3 Mon Sep 17 00:00:00 2001 From: "Yury V. Zaytsev" Date: Sun, 4 Oct 2026 08:38:38 +0000 Subject: [PATCH 7/7] editor: do not place the cursor between "\r" and "\n" Left and Right moved the cursor by one byte, so the cursor could stop between the "\r" and the "\n" of a "\r\n" line break, at the same screen position as before the hidden "\r". Enter pressed there added a bare "\n", and a typed character split the line break. Both turned a pure CRLF file into a file with mixed line breaks, where every "\r" is shown as "^M" again. Move over a "\r\n" line break as a unit. If the cursor still ends up inside the pair (e.g. after a search), insert a new line break before the "\r". Assisted-By: Claude Opus 5.5 Signed-off-by: Yury V. Zaytsev --- src/editor/edit.c | 20 ++++++++- tests/src/editor/edit_line_breaks.c | 68 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/editor/edit.c b/src/editor/edit.c index d3ee19f992..87233c0133 100644 --- a/src/editor/edit.c +++ b/src/editor/edit.c @@ -1065,6 +1065,13 @@ edit_right_char_move_cmd (WEdit *edit) else c = edit_buffer_get_current_byte (&edit->buffer); + // a "\r\n" line break is a single unit: do not stop between "\r" and "\n" + if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1)) + { + c = '\n'; + char_length = 2; + } + if (edit_options.cursor_beyond_eol && c == '\n') edit->over_col++; else @@ -1089,6 +1096,10 @@ edit_left_char_move_cmd (WEdit *edit) char_length = 1; } + // a "\r\n" line break is a single unit: do not stop between "\r" and "\n" + if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1 - 2)) + char_length = 2; + if (edit_options.cursor_beyond_eol && edit->over_col > 0) edit->over_col--; else @@ -1525,9 +1536,16 @@ edit_line_break_ends_at (const edit_buffer_t *buf, off_t p) static inline void edit_insert_line_break (WEdit *edit) { - const off_t eol = edit_buffer_get_current_eol (&edit->buffer); + off_t eol; gboolean crlf; + // never split a "\r\n" line break: if the cursor is between its "\r" and "\n" + // (e.g. placed there by a search), insert the new line break before the "\r" + if (edit_buffer_is_crlf (&edit->buffer, edit->buffer.curs1 - 1)) + edit_cursor_move (edit, -1); + + eol = edit_buffer_get_current_eol (&edit->buffer); + if (edit_buffer_is_crlf (&edit->buffer, eol - 1)) crlf = TRUE; else if (eol == edit->buffer.size) diff --git a/tests/src/editor/edit_line_breaks.c b/tests/src/editor/edit_line_breaks.c index 845a2d4595..1e86c9aa0a 100644 --- a/tests/src/editor/edit_line_breaks.c +++ b/tests/src/editor/edit_line_breaks.c @@ -663,6 +663,70 @@ START_TEST (test_backspace_standalone_cr) } END_TEST +/* --------------------------------------------------------------------------------------------- */ +/* CK_Right/CK_Left: the cursor never stops between the "\r" and the "\n" of a line break */ + +START_TEST (test_right_skips_crlf) +{ + // given: cursor is at the end of the first line + test_load_text ("abc\r\ndef\r\n"); + test_cursor_to (0); + edit_execute_cmd (test_edit, CK_End, -1); + + // when + edit_execute_cmd (test_edit, CK_Right, -1); + + // then: the cursor is at the beginning of the second line + ck_assert_int_eq (test_edit->buffer.curs1, 5); +} +END_TEST + +START_TEST (test_left_skips_crlf) +{ + // given: cursor is at the beginning of the second line + test_load_text ("abc\r\ndef\r\n"); + test_cursor_to (5); + + // when + edit_execute_cmd (test_edit, CK_Left, -1); + + // then: the cursor is at the end of the first line, before the "\r" + ck_assert_int_eq (test_edit->buffer.curs1, 3); +} +END_TEST + +// Enter after Right at the end of a CRLF line adds a CRLF line, not an LF one +START_TEST (test_right_then_enter_crlf) +{ + // given: cursor is at the end of the first line + test_load_text ("abc\r\ndef\r\n"); + test_cursor_to (0); + edit_execute_cmd (test_edit, CK_End, -1); + + // when + edit_execute_cmd (test_edit, CK_Right, -1); + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("abc\r\n\r\ndef\r\n"); +} +END_TEST + +// Enter with the cursor between "\r" and "\n" does not split the line break +START_TEST (test_enter_inside_crlf) +{ + // given: cursor is between the "\r" and the "\n" + test_load_text ("abc\r\ndef\r\n"); + test_cursor_to (4); + + // when + edit_execute_cmd (test_edit, CK_Enter, -1); + + // then + test_check ("abc\r\n\r\ndef\r\n"); +} +END_TEST + /* --------------------------------------------------------------------------------------------- */ /* CK_DeleteToEnd: stop before the "\r" of a "\r\n" line break */ @@ -967,6 +1031,10 @@ main (void) tcase_add_test (tc_core, test_backspace_crlf_atomic); tcase_add_test (tc_core, test_delete_standalone_cr); tcase_add_test (tc_core, test_backspace_standalone_cr); + tcase_add_test (tc_core, test_right_skips_crlf); + tcase_add_test (tc_core, test_left_skips_crlf); + tcase_add_test (tc_core, test_right_then_enter_crlf); + tcase_add_test (tc_core, test_enter_inside_crlf); tcase_add_test (tc_core, test_delete_to_line_end_crlf); tcase_add_test (tc_core, test_delete_to_line_end_lf); tcase_add_test (tc_core, test_end_stops_before_cr_mixed);