Skip to content

Commit 933c4bb

Browse files
committed
refactor(fmt): switch LSP server to full document sync
1 parent 3d4f2de commit 933c4bb

5 files changed

Lines changed: 124 additions & 101 deletions

File tree

‎packages/rstack/THIRD_PARTY_NOTICES.md‎

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -499,34 +499,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
499499
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
500500
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
501501
SOFTWARE.
502-
503-
## vscode-languageserver-textdocument
504-
505-
The language server started by `rs fmt --lsp` includes bundled code from
506-
[vscode-languageserver-textdocument](https://github.com/microsoft/vscode-languageserver-node).
507-
508-
License: MIT
509-
510-
Copyright (c) Microsoft Corporation
511-
512-
All rights reserved.
513-
514-
MIT License
515-
516-
Permission is hereby granted, free of charge, to any person obtaining a copy of
517-
this software and associated documentation files (the "Software"), to deal in
518-
the Software without restriction, including without limitation the rights to
519-
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
520-
of the Software, and to permit persons to whom the Software is furnished to do
521-
so, subject to the following conditions:
522-
523-
The above copyright notice and this permission notice shall be included in all
524-
copies or substantial portions of the Software.
525-
526-
THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
527-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
528-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
529-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
530-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
531-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
532-
SOFTWARE.

‎packages/rstack/src/fmt/lsp/minimalEdit.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,50 @@ const computeMinimalEdit = (source: string, formatted: string): MinimalEdit | un
9090
};
9191
};
9292

93-
export { computeMinimalEdit };
94-
export type { MinimalEdit };
93+
/** An LSP position; `character` counts UTF-16 code units, like JS offsets. */
94+
interface Position {
95+
line: number;
96+
character: number;
97+
}
98+
99+
/** A minimal edit expressed as the LSP range/newText the editor applies. */
100+
interface MinimalTextEdit {
101+
range: { start: Position; end: Position };
102+
newText: string;
103+
}
104+
105+
/**
106+
* Reduces a reformat to the single LSP text edit that actually changed.
107+
*
108+
* The offset → position mapping only counts line feeds because LSP characters
109+
* and JS offsets share the UTF-16 unit, and because `computeMinimalEdit` never
110+
* places a boundary inside a surrogate pair or a `\r\n`.
111+
*/
112+
const computeMinimalTextEdit = (source: string, formatted: string): MinimalTextEdit | undefined => {
113+
const edit = computeMinimalEdit(source, formatted);
114+
if (!edit) {
115+
return undefined;
116+
}
117+
118+
let line = 0;
119+
let lineStart = 0;
120+
// Resumes from the previous call's line, so start and end share one scan.
121+
const advanceTo = (offset: number): Position => {
122+
let lineFeed = source.indexOf('\n', lineStart);
123+
while (lineFeed !== -1 && lineFeed < offset) {
124+
line++;
125+
lineStart = lineFeed + 1;
126+
lineFeed = source.indexOf('\n', lineStart);
127+
}
128+
129+
return { line, character: offset - lineStart };
130+
};
131+
132+
const start = advanceTo(edit.start);
133+
const end = advanceTo(edit.end);
134+
135+
return { range: { start, end }, newText: edit.newText };
136+
};
137+
138+
export { computeMinimalEdit, computeMinimalTextEdit };
139+
export type { MinimalEdit, MinimalTextEdit };

‎packages/rstack/src/fmt/lsp/server.ts‎

Lines changed: 44 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { fileURLToPath } from 'node:url';
22
import { inspect } from 'node:util';
3-
import { TextDocument } from 'vscode-languageserver-textdocument';
43
import {
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';
2019
import { createIgnoreMatcher, type IgnorePredicate } from '../ignore.ts';
2120
import type { FmtPluginResolver } from '../plugins.ts';
2221
import type { ResolvedFmtConfig } from '../types.ts';
23-
import { computeMinimalEdit } from './minimalEdit.ts';
22+
import { computeMinimalTextEdit } from './minimalEdit.ts';
2423

2524
interface 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
*/
154152
const 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

186171
const 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

‎packages/rstack/tests/cli/fmt/lsp.test.ts‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ test(
4848
const { capabilities } = await client.initialize();
4949

5050
expect(capabilities.documentFormattingProvider).toBe(true);
51-
// Incremental sync, filled in by the connection for the `TextDocuments`
52-
// listener; without it compliant clients would never send the document.
53-
expect(capabilities.textDocumentSync).toBe(2);
51+
// Full document sync; without a sync capability compliant clients would
52+
// never send the document.
53+
expect(capabilities.textDocumentSync).toBe(1);
5454
expect(capabilities.documentRangeFormattingProvider).toBeUndefined();
5555
expect(capabilities.documentOnTypeFormattingProvider).toBeUndefined();
5656
});
@@ -94,20 +94,15 @@ test(
9494
);
9595

9696
test(
97-
'formats incremental changes sent by the client',
97+
'formats changes sent by the client',
9898
async () => {
9999
await withLspServer(async (client) => {
100100
await client.initialize();
101101
const uri = openDocument(client, 'src/index.ts', 'const x = 1;\n');
102-
// Replaces ` = 1` with `=2`, leaving `const x=2;` in the buffer.
102+
// Full document sync: the change carries the whole new buffer.
103103
client.notify('textDocument/didChange', {
104104
textDocument: { uri, version: 2 },
105-
contentChanges: [
106-
{
107-
range: { start: { line: 0, character: 7 }, end: { line: 0, character: 11 } },
108-
text: '=2',
109-
},
110-
],
105+
contentChanges: [{ text: 'const x=2;\n' }],
111106
});
112107

113108
const edits = await client.formatDocument(uri);
Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import { expect, test } from 'rstack/test';
2-
import { TextDocument } from 'vscode-languageserver-textdocument';
32
import { createDocumentEdits } from '../../../src/fmt/lsp/server.ts';
43

5-
const createDocument = (text: string): TextDocument =>
6-
TextDocument.create('file:///src/index.ts', 'typescript', 1, text);
7-
84
test('maps the edit onto the formatted document', async () => {
9-
const document = createDocument('const a = 1;\nconst b=2;\n');
10-
11-
const edits = await createDocumentEdits(document, async () => 'const a = 1;\nconst b = 2;\n');
5+
const edits = await createDocumentEdits(
6+
() => 'const a = 1;\nconst b=2;\n',
7+
async () => 'const a = 1;\nconst b = 2;\n',
8+
);
129

1310
expect(edits).toEqual([
1411
{
@@ -19,24 +16,36 @@ test('maps the edit onto the formatted document', async () => {
1916
});
2017

2118
test('returns no edits for an already formatted document', async () => {
22-
const document = createDocument('const a = 1;\n');
19+
const getText = () => 'const a = 1;\n';
2320

24-
expect(await createDocumentEdits(document, async () => 'const a = 1;\n')).toEqual([]);
25-
expect(await createDocumentEdits(document, async () => undefined)).toEqual([]);
21+
expect(await createDocumentEdits(getText, async () => 'const a = 1;\n')).toEqual([]);
22+
expect(await createDocumentEdits(getText, async () => undefined)).toEqual([]);
2623
});
2724

28-
// `TextDocuments` mutates the document in place, so a change arriving while the
29-
// formatter runs would otherwise be mapped through the new line table.
25+
test('returns no edits for a document that is not open', async () => {
26+
expect(
27+
await createDocumentEdits(
28+
() => undefined,
29+
async () => '',
30+
),
31+
).toEqual([]);
32+
});
33+
34+
// The client can replace the buffer while the formatter runs; an edit computed
35+
// from the old text must not reach the new one.
3036
test('returns no edits when the document changes while it is formatted', async () => {
31-
const document = createDocument('const a = 1;\nconst b=2;\n');
37+
let text = 'const a = 1;\nconst b=2;\n';
3238

33-
const edits = await createDocumentEdits(document, async (source) => {
34-
TextDocument.update(document, [{ text: 'const b=2;\n' }], 2);
39+
const edits = await createDocumentEdits(
40+
() => text,
41+
async (source) => {
42+
text = 'const b=2;\n';
3543

36-
return source.replace('const b=2;', 'const b = 2;');
37-
});
44+
return source.replace('const b=2;', 'const b = 2;');
45+
},
46+
);
3847

39-
// Without the version check this returns an edit for line 1, which the
48+
// Without the staleness check this returns an edit for line 1, which the
4049
// shortened buffer no longer holds.
4150
expect(edits).toEqual([]);
4251
});

0 commit comments

Comments
 (0)