Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-lsp-char-boundary-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Fixed [#9341](https://github.com/biomejs/biome/issues/9341): Fixed an LSP crash that could corrupt file content when saving with format-on-save enabled.
43 changes: 42 additions & 1 deletion crates/biome_lsp/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ pub(crate) fn apply_document_changes(
// Some clients (e.g. Code) sort the ranges in reverse. As an optimization, we
// remember the last valid line in the index and only rebuild it if needed.
let mut index_valid = u32::MAX;
for change in content_changes {
for change in content_changes.into_iter().skip(start) {
// The None case can't happen as we have handled it above already
if let Some(range) = change.range {
if index_valid <= range.end.line {
Expand Down Expand Up @@ -546,4 +546,45 @@ line 7 new";

assert_eq!(output, expected);
}

/// Regression test for https://github.com/biomejs/biome/issues/9341
///
/// When content_changes contains incremental changes before a full document
/// change, the loop incorrectly applies those stale changes to the new
/// document text. This can cause byte offsets to land on non-char-boundaries
/// in multi-byte UTF-8 text, triggering a panic in `replace_range`.
#[test]
fn test_apply_changes_skips_changes_before_full_doc_replacement() {
let encoding = PositionEncoding::Wide(WideEncoding::Utf16);

let input = "old content\nwith\u{2009}multibyte\n".to_string();

let changes = vec![
// Change 0: incremental change referencing the OLD document
TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 0), Position::new(0, 3))),
range_length: None,
text: String::from("new"),
},
// Change 1: full document replacement (invalidates all previous)
TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: String::from("completely\u{2009}different\ntext\n"),
},
// Change 2: incremental change referencing the NEW document
TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 11), Position::new(0, 20))),
range_length: None,
text: String::from("replaced"),
},
];

let output = apply_document_changes(encoding, input, changes);

// Only the full doc change + subsequent incremental changes should apply.
// Change 0 must be skipped since it references the old document.
let expected = "completely\u{2009}replaced\ntext\n";
assert_eq!(output, expected);
}
}