From cb8a162b9443f72bdfdd5b0a5eb5967ce7d32b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 14 Mar 2026 08:36:21 +0100 Subject: [PATCH] refactor(lsp): replace dissimilar with imara-diff for text diffing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `dissimilar` crate with `imara-diff` (already used in `deno_resolver`) for computing LSP formatting text edits. This removes a dependency from the project and unifies on a single diffing library. The diff granularity changes from character-level to line-level, which is the standard approach used by most LSP implementations. The edits are semantically equivalent β€” applying them produces the same formatted result. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 8 +- Cargo.toml | 1 - cli/Cargo.toml | 2 +- cli/lsp/text.rs | 189 ++++++++++++++++---------------- tests/integration/lsp_tests.rs | 191 ++++++++------------------------- 5 files changed, 142 insertions(+), 249 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c551a2497d909..65a34492591aca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1854,7 +1854,6 @@ dependencies = [ "deno_tower_lsp", "deno_typescript_go_client_rust", "dhat", - "dissimilar", "dprint-core", "dprint-plugin-json", "dprint-plugin-jupyter", @@ -1869,6 +1868,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.0", "http-body-util", + "imara-diff", "import_map", "indexmap 2.12.0", "jsonc-parser 0.28.0", @@ -3905,12 +3905,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dissimilar" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59f8e79d1fbf76bdfbde321e902714bf6c49df88a7dda6fc682fc2979226962d" - [[package]] name = "divan" version = "0.1.21" diff --git a/Cargo.toml b/Cargo.toml index 6e4c847163ce06..270d45b570b446 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -354,7 +354,6 @@ clap_complete_fig = "=4.5.2" console_static_text = "=0.8.3" crossterm = "0.28.1" dhat = "0.3.3" -dissimilar = "=1.0.9" dprint-core = "=0.67.4" dprint-plugin-json = "=0.21.1" dprint-plugin-jupyter = "=0.2.1" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 3b16912d9897f0..551e0e9728c4f1 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -110,7 +110,6 @@ crossterm.workspace = true dashmap.workspace = true deno_dotenv.workspace = true dhat = { workspace = true, optional = true } -dissimilar.workspace = true dprint-core.workspace = true dprint-plugin-json.workspace = true dprint-plugin-jupyter.workspace = true @@ -125,6 +124,7 @@ fluent-uri.workspace = true http.workspace = true http-body.workspace = true http-body-util.workspace = true +imara-diff.workspace = true import_map.workspace = true indexmap.workspace = true jsonc-parser = { workspace = true, features = ["cst", "serde"] } diff --git a/cli/lsp/text.rs b/cli/lsp/text.rs index 442c159b63ed86..fd7dd394550905 100644 --- a/cli/lsp/text.rs +++ b/cli/lsp/text.rs @@ -1,8 +1,9 @@ // Copyright 2018-2026 the Deno authors. MIT license. use deno_core::error::AnyError; -use dissimilar::Chunk; -use dissimilar::diff; +use imara_diff::Algorithm; +use imara_diff::Diff; +use imara_diff::InternedInput; use text_size::TextRange; use text_size::TextSize; use tower_lsp::jsonrpc; @@ -77,8 +78,8 @@ pub fn get_edits(a: &str, b: &str, line_index: &LineIndex) -> Vec { if a == b { return vec![]; } - // Heuristic to detect things like large JSON or minified files. `diff()` is - // expensive. + // Heuristic to detect things like large JSON or minified files. Diffing is + // expensive on very large inputs. let b_lines = b.chars().filter(|c| *c == '\n').count(); if b_lines > 10000 || b_lines > line_index.inner.utf8_offsets_len() * 3 { return vec![TextEdit { @@ -89,48 +90,44 @@ pub fn get_edits(a: &str, b: &str, line_index: &LineIndex) -> Vec { new_text: b.to_string(), }]; } - let chunks = diff(a, b); + + let input = InternedInput::new(a, b); + let mut diff = Diff::compute(Algorithm::Histogram, &input); + diff.postprocess_lines(&input); + + // Build a mapping from line index to utf16 offset for the original text. + // Line i starts at a_line_offsets[i] (in utf16 units). + let a_line_offsets: Vec = { + let mut offsets = vec![0u32]; + let mut pos = 0u32; + for line_token in input.before.iter() { + let line_str = input.interner[*line_token]; + pos += line_str.encode_utf16().count() as u32; + offsets.push(pos); + } + offsets + }; + let mut text_edits = Vec::::new(); - let mut iter = chunks.iter().peekable(); - let mut a_pos = TextSize::from(0); - loop { - let chunk = iter.next(); - match chunk { - None => break, - Some(Chunk::Equal(e)) => { - a_pos += TextSize::from(e.encode_utf16().count() as u32); - } - Some(Chunk::Delete(d)) => { - let start = line_index.position_utf16(a_pos); - a_pos += TextSize::from(d.encode_utf16().count() as u32); - let end = line_index.position_utf16(a_pos); - let range = lsp::Range { start, end }; - match iter.peek() { - Some(Chunk::Insert(i)) => { - iter.next(); - text_edits.push(TextEdit { - range, - new_text: i.to_string(), - }); - } - _ => text_edits.push(TextEdit { - range, - new_text: "".to_string(), - }), - } - } - Some(Chunk::Insert(i)) => { - let pos = line_index.position_utf16(a_pos); - let range = lsp::Range { - start: pos, - end: pos, - }; - text_edits.push(TextEdit { - range, - new_text: i.to_string(), - }); - } + + for hunk in diff.hunks() { + let del_start_line = hunk.before.start as usize; + let del_end_line = hunk.before.end as usize; + + // Range in the original document being replaced + let start_offset = TextSize::from(a_line_offsets[del_start_line]); + let end_offset = TextSize::from(a_line_offsets[del_end_line]); + let start = line_index.position_utf16(start_offset); + let end = line_index.position_utf16(end_offset); + let range = lsp::Range { start, end }; + + // Build the replacement text from the "after" lines + let mut new_text = String::new(); + for ins_idx in hunk.after.start..hunk.after.end { + new_text.push_str(input.interner[input.after[ins_idx as usize]]); } + + text_edits.push(TextEdit { range, new_text }); } text_edits @@ -145,36 +142,22 @@ mod tests { let a = "abcdefg"; let b = "a\nb\nchije\nfg\n"; let actual = get_edits(a, b, &LineIndex::new(a)); + // Line-level diff replaces the entire single-line input assert_eq!( actual, - vec![ - TextEdit { - range: lsp::Range { - start: lsp::Position { - line: 0, - character: 1 - }, - end: lsp::Position { - line: 0, - character: 5 - } + vec![TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 0 }, - new_text: "\nb\nchije\n".to_string() - }, - TextEdit { - range: lsp::Range { - start: lsp::Position { - line: 0, - character: 7 - }, - end: lsp::Position { - line: 0, - character: 7 - } - }, - new_text: "\n".to_string() + end: lsp::Position { + line: 0, + character: 7 + } }, - ] + new_text: "a\nb\nchije\nfg\n".to_string() + }] ); } @@ -183,36 +166,52 @@ mod tests { let a = "const bar = \"πŸ‘πŸ‡ΊπŸ‡ΈπŸ˜ƒ\";\nconsole.log('hello deno')\n"; let b = "const bar = \"πŸ‘πŸ‡ΊπŸ‡ΈπŸ˜ƒ\";\nconsole.log(\"hello deno\");\n"; let actual = get_edits(a, b, &LineIndex::new(a)); + // Line-level diff replaces only the changed line assert_eq!( actual, - vec![ - TextEdit { - range: lsp::Range { - start: lsp::Position { - line: 1, - character: 12 - }, - end: lsp::Position { - line: 1, - character: 13 - } + vec![TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 1, + character: 0 }, - new_text: "\"".to_string() + end: lsp::Position { + line: 2, + character: 0 + } }, - TextEdit { - range: lsp::Range { - start: lsp::Position { - line: 1, - character: 23 - }, - end: lsp::Position { - line: 1, - character: 25 - } + new_text: "console.log(\"hello deno\");\n".to_string() + }] + ) + } + + #[test] + fn test_get_edits_no_changes() { + let a = "hello world\n"; + let actual = get_edits(a, a, &LineIndex::new(a)); + assert_eq!(actual, vec![]); + } + + #[test] + fn test_get_edits_insert_line() { + let a = "line1\nline3\n"; + let b = "line1\nline2\nline3\n"; + let actual = get_edits(a, b, &LineIndex::new(a)); + assert_eq!( + actual, + vec![TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 1, + character: 0 }, - new_text: "\");".to_string() + end: lsp::Position { + line: 1, + character: 0 + } }, - ] - ) + new_text: "line2\n".to_string() + }] + ); } } diff --git a/tests/integration/lsp_tests.rs b/tests/integration/lsp_tests.rs index 515a01a7086558..ab75d685124c5c 100644 --- a/tests/integration/lsp_tests.rs +++ b/tests/integration/lsp_tests.rs @@ -12450,28 +12450,10 @@ fn lsp_format_mbc(use_tsgo: bool) { res, json!([{ "range": { - "start": { "line": 0, "character": 12 }, - "end": { "line": 0, "character": 13 } - }, - "newText": "\"" - }, { - "range": { - "start": { "line": 0, "character": 21 }, - "end": { "line": 0, "character": 22 } - }, - "newText": "\";" - }, { - "range": { - "start": { "line": 1, "character": 12 }, - "end": { "line": 1, "character": 13 } - }, - "newText": "\"" - }, { - "range": { - "start": { "line": 1, "character": 23 }, - "end": { "line": 1, "character": 25 } + "start": { "line": 0, "character": 0 }, + "end": { "line": 2, "character": 0 } }, - "newText": "\");" + "newText": "const bar = \"πŸ‘πŸ‡ΊπŸ‡ΈπŸ˜ƒ\";\nconsole.log(\"hello deno\");\n" }]) ); client.shutdown(); @@ -12612,9 +12594,9 @@ fn lsp_format_untitled(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, + "end": { "line": 1, "character": 0 }, }, - "newText": "", + "newText": "console.log();\n", }, ]) ); @@ -12648,22 +12630,10 @@ fn lsp_format_json(use_tsgo: bool) { json!([ { "range": { - "start": { "line": 0, "character": 1 }, - "end": { "line": 0, "character": 1 } - }, - "newText": " " - }, { - "range": { - "start": { "line": 0, "character": 7 }, - "end": { "line": 0, "character": 7 } - }, - "newText": " " - }, { - "range": { - "start": { "line": 0, "character": 14 }, + "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 15 } }, - "newText": " }\n" + "newText": "{ \"key\": \"value\" }\n" } ]) ); @@ -12701,9 +12671,9 @@ fn lsp_format_vscode_userdata(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, + "end": { "line": 1, "character": 0 }, }, - "newText": "", + "newText": "// foo\n", }, ]), ); @@ -12738,9 +12708,9 @@ fn lsp_format_editor_options(use_tsgo: bool) { { "range": { "start": { "line": 1, "character": 0 }, - "end": { "line": 1, "character": 0 }, + "end": { "line": 2, "character": 0 }, }, - "newText": " ", + "newText": " console.log();\n", }, ]) ); @@ -12762,9 +12732,9 @@ fn lsp_format_editor_options(use_tsgo: bool) { { "range": { "start": { "line": 1, "character": 0 }, - "end": { "line": 1, "character": 2 }, + "end": { "line": 2, "character": 0 }, }, - "newText": "\t", + "newText": "\tconsole.log();\n", }, ]) ); @@ -12869,16 +12839,10 @@ fn lsp_format_markdown(use_tsgo: bool) { json!([ { "range": { - "start": { "line": 0, "character": 1 }, - "end": { "line": 0, "character": 3 } - }, - "newText": "" - }, { - "range": { - "start": { "line": 0, "character": 15 }, + "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 15 } }, - "newText": "\n" + "newText": "# Hello World\n" } ]) ); @@ -12908,16 +12872,9 @@ fn lsp_format_html(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, - }, - "newText": "", - }, - { - "range": { - "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 15 }, }, - "newText": "\n", + "newText": "\n", }, ]), ); @@ -12959,9 +12916,9 @@ fn lsp_format_css(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, + "end": { "line": 1, "character": 0 }, }, - "newText": "", + "newText": "foo {}\n", }, ]), ); @@ -12981,9 +12938,9 @@ fn lsp_format_css(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, + "end": { "line": 1, "character": 0 }, }, - "newText": "", + "newText": "$font-stack: Helvetica, sans-serif;\n", }, ]), ); @@ -13003,9 +12960,9 @@ fn lsp_format_css(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, + "end": { "line": 1, "character": 0 }, }, - "newText": "", + "newText": "$font-stack: Helvetica, sans-serif\n", }, ]), ); @@ -13025,9 +12982,9 @@ fn lsp_format_css(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, + "end": { "line": 1, "character": 0 }, }, - "newText": "", + "newText": "@width: 10px;\n", }, ]), ); @@ -13057,16 +13014,9 @@ fn lsp_format_yaml(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, - }, - "newText": "", - }, - { - "range": { - "start": { "line": 0, "character": 8 }, "end": { "line": 0, "character": 8 }, }, - "newText": "\n", + "newText": "foo: 1\n", }, ]), ); @@ -13106,16 +13056,9 @@ fn lsp_format_sql(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, - }, - "newText": "", - }, - { - "range": { - "start": { "line": 0, "character": 52 }, "end": { "line": 0, "character": 52 }, }, - "newText": "\n", + "newText": "CREATE TABLE item (id int NOT NULL IDENTITY(1, 1))\n", }, ]), ); @@ -13171,9 +13114,9 @@ fn lsp_format_component(use_tsgo: bool) { { "range": { "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 2 }, + "end": { "line": 1, "character": 0 }, }, - "newText": "", + "newText": "