fix(oxfmt/lsp): avoid newlines line ending changes (#23463)

close https://github.com/oxc-project/oxc/issues/23462
Manually tested in VS Code.
A snapshot test will not work, because it does not reflect how editors work, it just replaces position→bytes offset, not interpreting what an editor/ LSP client does.
_https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocuments_

> So you can not specify a position that denotes \r|\n or \n| where | represents the character offset.
This commit is contained in:
Sysix committed 2026-06-15 23:55:24 +00:00
1 parent fef29bdbce
commit 37a34a128f
1 file changed
+40 -4
+40 -4
View File
@@ -437,6 +437,8 @@ fn deserialize_lsp_options(value: serde_json::Value) -> LSPFormatOptions {
}
/// Returns the minimal text edit (start, end, replacement) to transform `source_text` into `formatted_text`
///
/// Respects EOL characters as a whole edit. Appending `\r` before `\n` is invalid and should be treated as a single edit replacing `\n` with `\r\n`.
#[expect(clippy::cast_possible_truncation)]
fn compute_minimal_text_edit<'a>(
source_text: &str,
@@ -461,10 +463,25 @@ fn compute_minimal_text_edit<'a>(
let src_len = src_bytes.len();
let fmt_len = fmt_bytes.len();
while suffix_byte < src_len - prefix_byte
&& suffix_byte < fmt_len - prefix_byte
&& src_bytes[src_len - 1 - suffix_byte] == fmt_bytes[fmt_len - 1 - suffix_byte]
{
while suffix_byte < src_len - prefix_byte && suffix_byte < fmt_len - prefix_byte {
let src_idx = src_len - 1 - suffix_byte;
let fmt_idx = fmt_len - 1 - suffix_byte;
// Prevents appending `\r` to a line ending in `\n`, instead we want to replace `\n` with `\r\n` in this case,
// aligning how LSP text documents are represented (line / column editing, instead of bytes).
if src_bytes[src_idx] == b'\n' && fmt_bytes[fmt_idx] == b'\n' {
let src_has_cr = src_idx > 0 && src_bytes[src_idx - 1] == b'\r';
let fmt_has_cr = fmt_idx > 0 && fmt_bytes[fmt_idx - 1] == b'\r';
if src_has_cr != fmt_has_cr {
break;
}
}
if src_bytes[src_idx] != fmt_bytes[fmt_idx] {
break;
}
suffix_byte += 1;
}
@@ -607,4 +624,23 @@ mod tests {
let (start, end, replacement) = compute_minimal_text_edit(&src, &formatted);
assert_eq!((start, end, replacement), (0, 0, "b"));
}
#[test]
#[expect(clippy::cast_possible_truncation)]
fn test_replacing_line_breaks() {
let src_lf = "line1\nline2\nline3";
let src_crlf = "line1\r\nline2\r\nline3";
// from LF to CRLF
{
let (start, end, replacement) = compute_minimal_text_edit(src_lf, src_crlf);
assert_eq!((start, end, replacement), (5, src_lf.len() as u32 - 5, "\r\nline2\r\n"));
}
// from CRLF to LF
{
let (start, end, replacement) = compute_minimal_text_edit(src_crlf, src_lf);
assert_eq!((start, end, replacement), (5, src_crlf.len() as u32 - 5, "\nline2\n"));
}
}
}