11import { fileURLToPath } from 'node:url' ;
22import { inspect } from 'node:util' ;
3- import { TextDocument } from 'vscode-languageserver-textdocument' ;
43import {
54 createConnection ,
65 MessageType ,
76 ShowMessageNotification ,
8- TextDocuments ,
7+ TextDocumentSyncKind ,
98 type Connection ,
109 type InitializeParams ,
1110 type TextEdit ,
@@ -20,7 +19,7 @@ import { formatFmtSource } from '../format.ts';
2019import { createIgnoreMatcher , type IgnorePredicate } from '../ignore.ts' ;
2120import type { FmtPluginResolver } from '../plugins.ts' ;
2221import type { ResolvedFmtConfig } from '../types.ts' ;
23- import { computeMinimalEdit } from './minimalEdit.ts' ;
22+ import { computeMinimalTextEdit } from './minimalEdit.ts' ;
2423
2524interface RunFmtLspOptions {
2625 /** Base for relative CLI paths, and the workspace root when the client reports none. */
@@ -146,41 +145,27 @@ const formatDocumentSource = async (
146145/**
147146 * Turns a reformat of an open buffer into the edit the editor applies.
148147 *
149- * `TextDocuments` mutates the same document instance on every change, so the
150- * buffer can move on while the formatter runs. The text is snapshotted up
151- * front and the version is re-checked afterwards, which keeps the returned
152- * positions describing the text the edit was computed from.
148+ * The client can replace the buffer while the formatter runs, so the text is
149+ * re-read afterwards: the edit stays valid exactly as long as the text it was
150+ * computed from is still the text the client holds.
153151 */
154152const createDocumentEdits = async (
155- document : TextDocument ,
153+ getText : ( ) => string | undefined ,
156154 format : ( source : string ) => Promise < string | undefined > ,
157155) : Promise < TextEdit [ ] > => {
158- const { version } = document ;
159- const source = document . getText ( ) ;
160- const formatted = await format ( source ) ;
161- // Edits for a buffer the client has already changed would be applied to text
162- // they were never computed for; the editor formats again after the change.
163- if ( document . version !== version ) {
156+ const source = getText ( ) ;
157+ if ( source === undefined ) {
164158 return [ ] ;
165159 }
166160
167- const edit = formatted === undefined ? undefined : computeMinimalEdit ( source , formatted ) ;
168- if ( ! edit ) {
161+ const formatted = await format ( source ) ;
162+ if ( getText ( ) !== source ) {
169163 return [ ] ;
170164 }
171165
172- // Nothing awaits between the version check and this mapping, so the document
173- // still holds `source` and its incrementally maintained line table maps the
174- // offsets without rebuilding one from scratch.
175- return [
176- {
177- range : {
178- start : document . positionAt ( edit . start ) ,
179- end : document . positionAt ( edit . end ) ,
180- } ,
181- newText : edit . newText ,
182- } ,
183- ] ;
166+ const edit = formatted === undefined ? undefined : computeMinimalTextEdit ( source , formatted ) ;
167+
168+ return edit ? [ edit ] : [ ] ;
184169} ;
185170
186171const startFmtLsp = ( options : RunFmtLspOptions , onExit : ( ) => void ) : void => {
@@ -193,8 +178,27 @@ const startFmtLsp = (options: RunFmtLspOptions, onExit: () => void): void => {
193178 // raw bytes into the JSON-RPC stream and break the client's framing parser.
194179 // This runs before any user code can load.
195180 redirectConsoleToConnection ( connection ) ;
196- // Document sync also maps offsets to UTF-16 positions for the returned edits.
197- const documents = new TextDocuments ( TextDocument ) ;
181+ // Full document sync: every change carries the whole buffer, so tracking a
182+ // document is replacing one string, and a dropped or reordered change heals
183+ // on the next one.
184+ const documents = new Map < string , string > ( ) ;
185+
186+ connection . onDidOpenTextDocument ( ( { textDocument } ) => {
187+ documents . set ( textDocument . uri , textDocument . text ) ;
188+ } ) ;
189+ connection . onDidChangeTextDocument ( ( { textDocument, contentChanges } ) => {
190+ const change = contentChanges [ 0 ] ;
191+ if ( change ) {
192+ documents . set ( textDocument . uri , change . text ) ;
193+ } else {
194+ // An empty change list is a protocol violation; dropping the entry keeps
195+ // stale text from standing in for the buffer until the next change.
196+ documents . delete ( textDocument . uri ) ;
197+ }
198+ } ) ;
199+ connection . onDidCloseTextDocument ( ( { textDocument } ) => {
200+ documents . delete ( textDocument . uri ) ;
201+ } ) ;
198202
199203 let root = options . cwd ;
200204 let sessionPromise : Promise < FmtLspSession > | undefined ;
@@ -229,17 +233,18 @@ const startFmtLsp = (options: RunFmtLspOptions, onExit: () => void): void => {
229233
230234 return {
231235 // The project config is the single source of truth, so client formatting
232- // options are ignored. The connection fills in incremental
233- // `textDocumentSync` for the `TextDocuments` listener; nothing else is
234- // advertised.
235- capabilities : { documentFormattingProvider : true } ,
236+ // options are ignored and nothing beyond formatting is advertised. Full
237+ // sync spares the client from computing deltas the server never uses.
238+ capabilities : {
239+ documentFormattingProvider : true ,
240+ textDocumentSync : TextDocumentSyncKind . Full ,
241+ } ,
236242 } ;
237243 } ) ;
238244
239245 connection . onDocumentFormatting ( async ( { textDocument } ) : Promise < TextEdit [ ] > => {
240- const document = documents . get ( textDocument . uri ) ;
241246 const filePath = toFilePath ( textDocument . uri ) ;
242- if ( ! document || ! filePath ) {
247+ if ( ! filePath ) {
243248 return [ ] ;
244249 }
245250
@@ -248,16 +253,16 @@ const startFmtLsp = (options: RunFmtLspOptions, onExit: () => void): void => {
248253 try {
249254 const session = await getSession ( ) ;
250255
251- return await createDocumentEdits ( document , ( source ) =>
252- formatDocumentSource ( session , filePath , source ) ,
256+ return await createDocumentEdits (
257+ ( ) => documents . get ( textDocument . uri ) ,
258+ ( source ) => formatDocumentSource ( session , filePath , source ) ,
253259 ) ;
254260 } catch ( error ) {
255261 connection . console . error ( `Failed to format "${ filePath } ": ${ String ( error ) } ` ) ;
256262 return [ ] ;
257263 }
258264 } ) ;
259265
260- documents . listen ( connection ) ;
261266 connection . listen ( ) ;
262267} ;
263268
0 commit comments