Skip to content

perf: only edit what changed when formatting a document - #182

Merged
dsherret merged 5 commits into
mainfrom
minimal-edits
Oct 2, 2026
Merged

dsherret merged 5 commits into
mainfrom
minimal-edits

Conversation

@dsherret

@dsherret dsherret commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Format Document replaced the whole document with one edit. VS Code reduces an extension's edits to what changed on its own, but only for text up to 100,000 characters. Above that the whole-document edit is applied as-is, which moves the cursor and loses selections and folded regions.

Changes

  • FolderService returns only the edits for what changed instead of a single whole-document edit. This applies to Format Document, notebook cells, and the global config commands, for text of any length so that the same code runs for every file. For text up to 100,000 characters this made no measurable difference to the time of Format Document in VS Code 1.140 (84,000 characters: 7.7 ms before and 6.2 ms after with 1 changed line, 379 ms both ways with every line changed, where the time is VS Code's own diff). Format Selection is unchanged.
  • src/legacy/minimalEdits.ts (new) computes those edits and is written to be fast on large text where a small part changed:
    • Text that's the same is skipped by comparing chunks of the two strings (which get larger while they match) instead of comparing one code unit or one line at a time. This is done from the start, from the end, and again after each change.
    • At a change, only the lines around it are read and diffed (Myers' algorithm on a number per distinct line). The diff stops as soon as 3 lines in a row are the same again, and the skipping continues from there. The edits are therefore not always the fewest possible.
    • Each group of changed lines becomes one edit without the text at its start and end that's the same. An edit never starts or ends in the middle of a \r\n or a surrogate pair.
    • The work of diffing lines is bounded. When it runs out (about 350 changed lines in a row, or many thousands that are spread out), the rest of the changed text is replaced with a single edit.
    • There's also a limit of 5,000 edits, after which the rest of the changed text is one edit.
    • Lines longer than 10,000 characters are never matched with another line while diffing, because looking up many long lines by their text is slow. The same text at the start and end of an edit is still removed.
    • The formatted text is converted to the document's line endings first, so a difference in only the line endings is not a change. normalizeToSourceLineEndings now checks if the text needs converting before creating a new string.

Performance

Median of 20 runs on my machine (Node 24, Windows). "Before" is the first version of this PR, which numbered every line between the first and last change and compared the start and end of the text one code unit at a time.

Text Change Before After
2,000 lines (105 KB) 1 line 0.21 ms 0.02 ms
2,000 lines (105 KB) 100 scattered changes 0.58 ms 0.20 ms
2,000 lines (105 KB) every line (falls back to one edit) 3.5 ms 2.6 ms
20,000 lines (1.1 MB) 1 line 2.1 ms 0.06 ms
20,000 lines (1.1 MB) 100 scattered changes 5.2 ms 0.28 ms
20,000 lines (1.1 MB) 400 scattered changed lines 7.4 ms 0.77 ms
20,000 lines (1.1 MB) every line (falls back to one edit) 8.9 ms 2.7 ms
200,000 lines (11 MB) 100 scattered changes 60 ms 0.85 ms
200,000 lines (11 MB) every line (falls back to one edit) 76 ms 2.8 ms
200,000 lines (11 MB), CRLF document and LF output 1 line 20 ms 6.1 ms
1 line (2.6 MB) formatted to 50,000 lines 8.1 ms 1.7 ms

The CRLF row is the time to convert the line endings of 11 MB of formatted text, which only happens when a plugin's output has different line endings than the document.

Verification

  • Unit tests in minimalEdits.test.ts, including seeded tests of random changes (LF, CRLF and mixed line endings, lone carriage returns and surrogates, with and without a final line break, short and long texts, few distinct lines so that lines match by chance, and groups of changes that exhaust the limits) that apply the edits and compare the result to the formatted text.
  • An integration test formats a file of more than 100,000 characters with the cursor on line 3,000 and asserts the cursor stays there. It fails without the change to FolderService.
  • npm run test:unit and npm test pass locally on Windows.

@dsherret dsherret changed the title feat: only edit what changed when formatting a document feat: only edit what changed when formatting a large document Oct 2, 2026
@dsherret

dsherret commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

A second agent reviewed this PR. It found no input where the edits give the wrong text (5.4 million exhaustive small pairs, 120,000 random pairs with mixed line endings, lone \r and lone surrogates, chunk boundary and budget exhaustion cases) and confirmed the normalizeToSourceLineEndings fast path matches the old regex on all of them. Its findings were addressed in the last commit:

  • M1: fixed. Many changed lines longer than about 16K characters with the same length and the same start took seconds (700 lines of 17,000 characters: 2.4 s, of 100,000 characters: 12.7 s), because the engine doesn't hash all of a long string and the lines were looked up by their text. Lines longer than 10,000 characters are now given their own number instead of being looked up, so they never match another line while diffing (the same text at the start and end of an edit is still removed). Those cases now take 6 ms and 9 ms. The cost is that text made of such lines falls back to a single edit sooner.
  • L1: fixed. The comment on the work limit no longer claims a time bound, and there's now a limit of 5,000 edits after which the rest of the changed text is one edit. 400,000 lines with a line added every 4 lines went from about 125 ms and 83,334 edits to 15 ms and 5,001 edits.
  • L2: fixed. The random test now gives the formatted text its own (sometimes mixed) line endings, includes lone \r and lone surrogates, and checks edit boundaries in the resulting text too. There's a second random test on 5,000 lines with groups of changes that exhaust the work limit, tests for the edit limit and for long lines, and direct tests of normalizeToSourceLineEndings.
  • L3: fixed.
  • L4: not done. Format Selection still returns one edit for its range, which is how it was before this PR.

@dsherret dsherret changed the title feat: only edit what changed when formatting a large document feat: only edit what changed when formatting a document Oct 2, 2026
@dsherret dsherret changed the title feat: only edit what changed when formatting a document perf: only edit what changed when formatting a document Oct 2, 2026
@dsherret
dsherret merged commit 7e12f82 into main Oct 2, 2026
3 checks passed
@dsherret
dsherret deleted the minimal-edits branch October 2, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant