Skip to content

Commit 0a70e0d

Browse files
authored
Implement TextDocumentSnapshot for delay open notifications (#1773)
* Implement TextDocumentSnapshot and use it in delay open notification code. * Fix regexp code * Fix compile error * Fix range containing line break * Remove double comment
1 parent d4f8eae commit 0a70e0d

6 files changed

Lines changed: 465 additions & 15 deletions

File tree

build/bin/symlink.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const root = path.dirname(path.dirname(__dirname));
3333
await ln.tryLinkJsonRpc(clientFolder);
3434
await ln.tryLinkTypes(clientFolder);
3535
await ln.tryLinkProtocol(clientFolder);
36+
await ln.tryLinkTextDocuments(clientFolder);
3637

3738
// test-extension
3839
let extensionFolder = path.join(root, 'client-node-tests');

client-node-tests/src/integration.test.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2276,6 +2276,11 @@ suite('Server activation', () => {
22762276

22772277
suite('delayOpenNotifications', () => {
22782278
let client: lsclient.LanguageClient;
2279+
let middleware: lsclient.Middleware = {};
2280+
2281+
suiteSetup(() => {
2282+
middleware = {};
2283+
});
22792284

22802285
async function startClient(delayOpen: boolean): Promise<void> {
22812286
const serverModule = path.join(__dirname, './servers/textSyncServer.js');
@@ -2288,7 +2293,7 @@ suite('delayOpenNotifications', () => {
22882293
documentSelector: [{ language: 'plaintext' }],
22892294
synchronize: {},
22902295
initializationOptions: {},
2291-
middleware: {},
2296+
middleware,
22922297
textSynchronization: {
22932298
delayOpenNotifications: delayOpen
22942299
}
@@ -2303,7 +2308,7 @@ suite('delayOpenNotifications', () => {
23032308
uri: 'untitled:test.txt',
23042309
languageId: 'plaintext',
23052310
version: 1,
2306-
getText: () => '',
2311+
getText: () => 'original line1\noriginal line2\noriginal line3',
23072312
} as any as vscode.TextDocument;
23082313

23092314
function sendDidOpen(document: vscode.TextDocument) {
@@ -2348,37 +2353,65 @@ suite('delayOpenNotifications', () => {
23482353
);
23492354
});
23502355

2351-
test.skip('didOpen contains correct version/content for create+edit operation', async () => {
2356+
test('didOpen contains correct version/content for create+edit operation', async () => {
23522357
// Fails due to
23532358
// https://github.com/microsoft/vscode-languageserver-node/issues/1695
23542359
await startClient(true);
23552360

2361+
// Set up middleware to capture the (delayed) text document passed for
2362+
// didOpen.
2363+
let middlewareDidOpenTextDocument: vscode.TextDocument | undefined;
2364+
middleware.didOpen = (document, next) => {
2365+
middlewareDidOpenTextDocument = document;
2366+
return next(document);
2367+
};
23562368
// Simulate did open
23572369
await sendDidOpen(fakeDocument);
23582370

23592371
// Modify the document and trigger change.
2372+
const originalText = fakeDocument.getText();
2373+
const updatedText = 'NEW CONTENT';
23602374
(fakeDocument as any).version = 2;
2361-
fakeDocument.getText = () => 'NEW CONTENT';
2375+
fakeDocument.getText = () => updatedText;
23622376
await sendDidChange({
23632377
document: fakeDocument,
23642378
reason: undefined,
23652379
contentChanges: [{
23662380
range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)),
23672381
rangeOffset: 0,
2368-
rangeLength: 0,
2369-
text: 'NEW CONTENT',
2382+
rangeLength: originalText.length,
2383+
text: updatedText,
23702384
}]
23712385
});
23722386

23732387
// Verify both notifications are as expected.
23742388
const notifications = await client.sendRequest(GetNotificationsRequest.type);
23752389
assert.equal(notifications.length, 2);
23762390
const [openNotification, changeNotification] = notifications;
2391+
23772392
assert.equal(openNotification.method, 'textDocument/didOpen');
2378-
assert.equal(openNotification.params.textDocument.version, 1);
2379-
assert.equal(openNotification.params.textDocument.text, '');
2393+
const openTextDoc = openNotification.params.textDocument as proto.TextDocumentItem;
2394+
assert.equal(openTextDoc.version, 1);
2395+
assert.equal(openTextDoc.text, originalText);
2396+
23802397
assert.equal(changeNotification.method, 'textDocument/didChange');
2381-
assert.equal(changeNotification.params.textDocument.version, 2);
2382-
assert.equal(changeNotification.params.textDocument.text, 'NEW CONTENT');
2398+
const changeTextDoc = changeNotification.params.textDocument as proto.VersionedTextDocumentIdentifier;
2399+
assert.equal(changeTextDoc.version, 2);
2400+
2401+
// Also verify the "VS Code" version of the TextDocument passed to the
2402+
// middleware behaves as the original document would.
2403+
const line3index = 2; // lines are 0-based
2404+
const offsetOfLine3word = originalText.indexOf('line3');
2405+
const lineOffsetOfLine3word = originalText.split('\n')[line3index].indexOf('line3');
2406+
const textDoc = middlewareDidOpenTextDocument!;
2407+
const positionOfLine3word = textDoc.positionAt(offsetOfLine3word);
2408+
const rangeOfLine3word = textDoc.getWordRangeAtPosition(positionOfLine3word);
2409+
assert.equal(positionOfLine3word.line, line3index);
2410+
assert.equal(positionOfLine3word.character, lineOffsetOfLine3word);
2411+
assert.equal(textDoc.lineAt(line3index).text, 'original line3');
2412+
assert.equal(textDoc.getText(rangeOfLine3word), 'line3');
2413+
assert.equal(textDoc.offsetAt(positionOfLine3word), offsetOfLine3word);
2414+
assert.ok(textDoc.validatePosition(positionOfLine3word).isEqual(positionOfLine3word));
2415+
assert.ok(textDoc.validateRange(rangeOfLine3word!).isEqual(rangeOfLine3word!));
23832416
});
23842417
});

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"dependencies": {
4444
"minimatch": "^10.1.2",
4545
"semver": "^7.7.1",
46+
"vscode-languageserver-textdocument": "1.0.12",
4647
"vscode-languageserver-protocol": "3.17.6-next.17"
4748
},
4849
"scripts": {

client/src/common/textSynchronization.ts

Lines changed: 198 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
* ------------------------------------------------------------------------------------------ */
55

66
import {
7-
workspace as Workspace, languages as Languages, TextDocument, TextDocumentChangeEvent, TextDocumentWillSaveEvent, TextEdit as VTextEdit,
8-
DocumentSelector as VDocumentSelector, Event, EventEmitter, Disposable,
9-
workspace
7+
workspace as Workspace, languages as Languages, TextDocument, TextLine, TextDocumentChangeEvent, TextDocumentWillSaveEvent, TextEdit as VTextEdit,
8+
DocumentSelector as VDocumentSelector, Event, EventEmitter, Disposable, Uri as VUri, workspace, type EndOfLine, Position as VPosition, Range as VRange
109
} from 'vscode';
1110

1211
import {
@@ -22,6 +21,7 @@ import {
2221
} from './features';
2322

2423
import * as UUID from './utils/uuid';
24+
import { TextDocument as TextDocumentImpl } from 'vscode-languageserver-textdocument';
2525

2626
export interface TextDocumentSynchronizationMiddleware {
2727
didOpen?: NextSignature<TextDocument, Promise<void>>;
@@ -77,7 +77,14 @@ export class DidOpenTextDocumentFeature extends TextDocumentEventFeature<DidOpen
7777
if (visibleDocuments.isVisible(document)) {
7878
return super.callback(document);
7979
} else {
80-
this._pendingOpenNotifications.set(document.uri.toString(), document);
80+
// Snapshot the text document so that when we send the delayed
81+
// notification it is based on the content/version at the time
82+
// it would've been sent, and not the updated version.
83+
//
84+
// See https://github.com/microsoft/vscode-languageserver-node/issues/1695
85+
86+
const snapshot = new TextDocumentSnapshot(document);
87+
this._pendingOpenNotifications.set(snapshot.uri.toString(), snapshot);
8188
}
8289
}
8390
}
@@ -632,4 +639,191 @@ export class DidSaveTextDocumentFeature extends TextDocumentEventFeature<DidSave
632639
protected getTextDocument(data: TextDocument): TextDocument {
633640
return data;
634641
}
642+
}
643+
644+
// Copied from https://github.com/microsoft/vscode/src/vs/editor/common/core/wordHelper.ts
645+
const USUAL_WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?';
646+
647+
/**
648+
* Create a word definition regular expression based on default word separators.
649+
* Optionally provide allowed separators that should be included in words.
650+
*
651+
* The default would look like this:
652+
* /(-?\d*\.\d\w*)|([^\`\~\!\@\#\$\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g
653+
*/
654+
function createWordRegExp(allowInWords: string = ''): RegExp {
655+
let source = '(-?\\d*\\.\\d\\w*)|([^';
656+
for (const sep of USUAL_WORD_SEPARATORS) {
657+
if (allowInWords.indexOf(sep) >= 0) {
658+
continue;
659+
}
660+
source += '\\' + sep;
661+
}
662+
source += '\\s]+)';
663+
return new RegExp(source, 'g');
664+
}
665+
666+
// catches numbers (including floating numbers) in the first group, and alphanum in the second
667+
const DEFAULT_WORD_REGEXP = createWordRegExp();
668+
669+
class TextDocumentSnapshot implements TextDocument {
670+
671+
private readonly _extTextDocument: TextDocument;
672+
private readonly _capturedTextDocument: TextDocumentImpl;
673+
674+
private readonly _content: string;
675+
private readonly _uri: VUri;
676+
private readonly _fileName: string;
677+
private readonly _languageId: string;
678+
private readonly _version: number;
679+
private readonly _eol: EndOfLine;
680+
private readonly _isUntitled: boolean;
681+
private readonly _encoding: string;
682+
private readonly _isDirty: boolean;
683+
private readonly _isClosed: boolean;
684+
685+
constructor(textDocument: TextDocument) {
686+
this._extTextDocument = textDocument;
687+
this._content = textDocument.getText();
688+
this._uri = textDocument.uri;
689+
this._fileName = textDocument.fileName;
690+
this._languageId = textDocument.languageId;
691+
this._version = textDocument.version;
692+
this._eol = textDocument.eol;
693+
this._isUntitled = textDocument.isUntitled;
694+
this._encoding = textDocument.encoding;
695+
this._isDirty = textDocument.isDirty;
696+
this._isClosed = textDocument.isClosed;
697+
698+
this._capturedTextDocument = TextDocumentImpl.create(this._uri.toString(), this._languageId, this._version, this._content);
699+
}
700+
701+
public get uri(): VUri {
702+
return this._uri;
703+
}
704+
705+
public get languageId(): string {
706+
return this._languageId;
707+
}
708+
709+
public get version(): number {
710+
return this._version;
711+
}
712+
713+
public get eol(): EndOfLine {
714+
return this._eol;
715+
}
716+
717+
public get isUntitled(): boolean {
718+
return this._isUntitled;
719+
}
720+
721+
public get encoding(): string {
722+
return this._encoding;
723+
}
724+
725+
public get fileName(): string {
726+
return this._fileName;
727+
}
728+
729+
public get isDirty(): boolean {
730+
return this._isDirty;
731+
}
732+
733+
public get isClosed(): boolean {
734+
return this._isClosed;
735+
}
736+
737+
public save(): Thenable<boolean> {
738+
return this.version === this._extTextDocument.version
739+
? this._extTextDocument.save()
740+
: Promise.resolve(false);
741+
}
742+
743+
public get lineCount(): number {
744+
return this._capturedTextDocument.lineCount;
745+
}
746+
747+
public offsetAt(position: VPosition): number {
748+
return this._capturedTextDocument.offsetAt(position);
749+
}
750+
751+
public positionAt(offset: number): VPosition {
752+
const position = this._capturedTextDocument.positionAt(offset);
753+
return new VPosition(position.line, position.character);
754+
}
755+
756+
public getText(range?: VRange): string {
757+
return this._capturedTextDocument.getText(range);
758+
}
759+
760+
public lineAt(line: number): TextLine;
761+
public lineAt(position: VPosition): TextLine;
762+
public lineAt(lineOrPosition: VPosition | number): TextLine {
763+
const line = typeof lineOrPosition === 'number' ? lineOrPosition : this.validatePosition(lineOrPosition).line;
764+
if (line < 0 || line >= this.lineCount) {
765+
throw new RangeError(`Illegal value for line: ${line}`);
766+
}
767+
const lineRange = this._capturedTextDocument.getLineRange(line);
768+
const text = this._capturedTextDocument.getText(lineRange);
769+
const firstNonWhitespaceCharacterIndex = text.search(/\S/);
770+
const range = new VRange(lineRange.start.line, lineRange.start.character, lineRange.end.line, lineRange.end.character);
771+
const rangeIncludingLineBreak = line + 1 < this.lineCount
772+
? new VRange(range.start.line, range.start.character, line + 1, 0)
773+
: range;
774+
return {
775+
lineNumber: line,
776+
text,
777+
range,
778+
rangeIncludingLineBreak,
779+
firstNonWhitespaceCharacterIndex: firstNonWhitespaceCharacterIndex === -1 ? text.length : firstNonWhitespaceCharacterIndex,
780+
isEmptyOrWhitespace: firstNonWhitespaceCharacterIndex === -1
781+
};
782+
}
783+
784+
getWordRangeAtPosition(position: VPosition, regex?: RegExp): VRange | undefined {
785+
const lineNumber = this.validatePosition(position).line;
786+
const lineText = this.lineAt(lineNumber).text;
787+
788+
const wordRegex = TextDocumentSnapshot.getWordRegExp(regex);
789+
790+
let match;
791+
wordRegex.lastIndex = 0;
792+
while ((match = wordRegex.exec(lineText)) !== null) {
793+
if (match.index <= position.character && wordRegex.lastIndex >= position.character) {
794+
return new VRange(lineNumber, match.index, lineNumber, wordRegex.lastIndex);
795+
}
796+
}
797+
798+
return undefined;
799+
}
800+
801+
validateRange(range: VRange): VRange {
802+
const start = this.validatePosition(range.start);
803+
const end = this.validatePosition(range.end);
804+
805+
if (start === range.start && end === range.end) {
806+
return range;
807+
}
808+
return new VRange(start.line, start.character, end.line, end.character);
809+
}
810+
811+
validatePosition(position: VPosition): VPosition {
812+
const line = Math.min(Math.max(position.line, 0), this.lineCount - 1);
813+
const lineRange = this._capturedTextDocument.getLineRange(line);
814+
const character = Math.min(Math.max(position.character, 0), lineRange.end.character);
815+
if (line === position.line && character === position.character) {
816+
return position;
817+
}
818+
return new VPosition(line, character);
819+
}
820+
821+
private static getWordRegExp(regex?: RegExp): RegExp {
822+
const result = regex ?? DEFAULT_WORD_REGEXP;
823+
if (result.flags.includes('g')) {
824+
return result;
825+
}
826+
const flags = `${result.flags}g`;
827+
return new RegExp(result.source, flags);
828+
}
635829
}

0 commit comments

Comments
 (0)