Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build/bin/symlink.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const root = path.dirname(path.dirname(__dirname));
await ln.tryLinkJsonRpc(clientFolder);
await ln.tryLinkTypes(clientFolder);
await ln.tryLinkProtocol(clientFolder);
await ln.tryLinkTextDocuments(clientFolder);

// test-extension
let extensionFolder = path.join(root, 'client-node-tests');
Expand Down
57 changes: 45 additions & 12 deletions client-node-tests/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as lsclient from 'vscode-languageclient/node';
import * as proto from 'vscode-languageserver-protocol';
import { MemoryFileSystemProvider } from './memoryFileSystemProvider';
import { vsdiag, DiagnosticProviderMiddleware } from 'vscode-languageclient';
import { TextDocument } from 'vscode';

namespace GotNotifiedRequest {
export const method: 'testing/gotNotified' = 'testing/gotNotified';
Expand Down Expand Up @@ -2242,6 +2243,11 @@ suite('Server activation', () => {

suite('delayOpenNotifications', () => {
let client: lsclient.LanguageClient;
let middleware: lsclient.Middleware = {};

suiteSetup(() => {
middleware = {};
});

async function startClient(delayOpen: boolean): Promise<void> {
const serverModule = path.join(__dirname, './servers/textSyncServer.js');
Expand All @@ -2254,7 +2260,7 @@ suite('delayOpenNotifications', () => {
documentSelector: [{ language: 'plaintext' }],
synchronize: {},
initializationOptions: {},
middleware: {},
middleware,
textSynchronization: {
delayOpenNotifications: delayOpen
}
Expand All @@ -2269,7 +2275,7 @@ suite('delayOpenNotifications', () => {
uri: 'untitled:test.txt',
languageId: 'plaintext',
version: 1,
getText: () => '',
getText: () => 'original line1\noriginal line2\noriginal line3',
} as any as vscode.TextDocument;

function sendDidOpen(document: vscode.TextDocument) {
Expand Down Expand Up @@ -2314,37 +2320,64 @@ suite('delayOpenNotifications', () => {
);
});

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

// Set up middleware to capture the (delayed) text document passed for
// didOpen.
let middlewareDidOpenTextDocument: TextDocument | undefined;
middleware.didOpen = (document, next) => {
middlewareDidOpenTextDocument = document;
return next(document);
};

// Simulate did open
await sendDidOpen(fakeDocument);

// Modify the document and trigger change.
const originalText = fakeDocument.getText();
const updatedText = 'NEW CONTENT';
(fakeDocument as any).version = 2;
fakeDocument.getText = () => 'NEW CONTENT';
fakeDocument.getText = () => updatedText;
await sendDidChange({
document: fakeDocument,
reason: undefined,
contentChanges: [{
range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)),
rangeOffset: 0,
rangeLength: 0,
text: 'NEW CONTENT',
rangeLength: originalText.length,
text: updatedText,
}]
});

// Verify both notifications are as expected.
const notifications = await client.sendRequest(GetNotificationsRequest.type);
assert.equal(notifications.length, 2);
const [openNotification, changeNotification] = notifications;

assert.equal(openNotification.method, 'textDocument/didOpen');
assert.equal(openNotification.params.textDocument.version, 1);
assert.equal(openNotification.params.textDocument.text, '');
const openTextDoc = openNotification.params.textDocument as proto.TextDocumentItem;
assert.equal(openTextDoc.version, 1);
assert.equal(openTextDoc.text, originalText);

assert.equal(changeNotification.method, 'textDocument/didChange');
assert.equal(changeNotification.params.textDocument.version, 2);
assert.equal(changeNotification.params.textDocument.text, 'NEW CONTENT');
const changeTextDoc = changeNotification.params.textDocument as proto.VersionedTextDocumentIdentifier;
assert.equal(changeTextDoc.version, 2);

// Also verify the "VS Code" version of the TextDocument passed to the
// middleware behaves as the original document would.
const line3index = 2; // lines are 0-based
const offsetOfLine3word = originalText.indexOf('line3');
const lineOffsetOfLine3word = originalText.split('\n')[line3index].indexOf('line3');
const textDoc = middlewareDidOpenTextDocument!;
const positionOfLine3word = textDoc.positionAt(offsetOfLine3word);
const rangeOfLine3word = textDoc.getWordRangeAtPosition(positionOfLine3word);
assert.equal(positionOfLine3word.line, line3index);
assert.equal(positionOfLine3word.character, lineOffsetOfLine3word);
assert.equal(textDoc.lineAt(line3index).text, 'original line3');
assert.equal(textDoc.getText(rangeOfLine3word), 'line3');
assert.equal(textDoc.offsetAt(positionOfLine3word), offsetOfLine3word);
assert.ok(textDoc.validatePosition(positionOfLine3word).isEqual(positionOfLine3word));
assert.ok(textDoc.validateRange(rangeOfLine3word!).isEqual(rangeOfLine3word!));
});
});
8 changes: 7 additions & 1 deletion client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"dependencies": {
"minimatch": "^10.1.2",
"semver": "^7.7.1",
"vscode-languageserver-textdocument": "1.0.12",
"vscode-languageserver-protocol": "3.17.6-next.17"
},
"scripts": {
Expand Down
186 changes: 183 additions & 3 deletions client/src/common/textSynchronization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import {
workspace as Workspace, languages as Languages, TextDocument, TextDocumentChangeEvent, TextDocumentWillSaveEvent, TextEdit as VTextEdit,
DocumentSelector as VDocumentSelector, Event, EventEmitter, Disposable,
DocumentSelector as VDocumentSelector, EndOfLine, Event, EventEmitter, Disposable, Position, Range, TextLine,
workspace
} from 'vscode';

Expand All @@ -22,6 +22,7 @@ import {
} from './features';

import * as UUID from './utils/uuid';
import { TextDocument as nodeTextDocument } from 'vscode-languageserver-textdocument';

export interface TextDocumentSynchronizationMiddleware {
didOpen?: NextSignature<TextDocument, Promise<void>>;
Expand Down Expand Up @@ -77,6 +78,14 @@ export class DidOpenTextDocumentFeature extends TextDocumentEventFeature<DidOpen
if (visibleDocuments.isVisible(document)) {
return super.callback(document);
} else {
// Snapshot the text document so that when we send the delayed
// notification it is based on the content/version at the time
// it would've been sent, and not the updated version.
//
// See https://github.com/microsoft/vscode-languageserver-node/issues/1695

document = new TextDocumentSnapshot(document);

this._pendingOpenNotifications.set(document.uri.toString(), document);
}
}
Expand Down Expand Up @@ -124,7 +133,7 @@ export class DidOpenTextDocumentFeature extends TextDocumentEventFeature<DidOpen
});
this._syncedDocuments.set(uri, textDocument);
} else {
this._pendingOpenNotifications.set(uri, textDocument);
this._pendingOpenNotifications.set(uri, new TextDocumentSnapshot(textDocument));
}
}
});
Expand Down Expand Up @@ -632,4 +641,175 @@ export class DidSaveTextDocumentFeature extends TextDocumentEventFeature<DidSave
protected getTextDocument(data: TextDocument): TextDocument {
return data;
}
}
}

class TextDocumentSnapshot implements TextDocument {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbaeumer the implementation here was written by GPT-5.4 with some tidying up by me. I don't like that so much is repeated here from VS Code (and I haven't tried to verify the behaviour matches perfectly, because I'm hoping you might have a better idea :-))

I did add 'vscode-languageserver-textdocument' but it only provided a small number of the methods we needed.

I also noticed there is a very similar class here, but it uses some additional types from VS Code so I couldn't just lift it:

https://github.com/microsoft/vscode/blob/a14d0306921f937f5644bb89bfbb66c699e66409/extensions/copilot/src/platform/editing/common/textDocumentSnapshot.ts

I wonder if it'd be better adding a good implementation of this to vscode-languageserver-textdocument and then using it both in Copilot and here?


private static readonly DefaultWordRegExp: RegExp = /(-?\d*\.\d\w*)|([^\s`~!@#%^&*()=+[\]{}\\|;:'",.<>/?-]+)/g;

private readonly liveVsCodeDocument: TextDocument;
private readonly snapshotDocument: nodeTextDocument;
private readonly content: string;
public readonly fileName: string;
public readonly isUntitled: boolean;
public readonly encoding: string;
public readonly isDirty: boolean;
public readonly isClosed: boolean;
public readonly eol: EndOfLine;
private _lineOffsets: number[] | undefined;

constructor(document: TextDocument) {
// Keep the document to handle operations like save().
this.liveVsCodeDocument = document;

// Snapshot all the data.
this.content = document.getText();
this.snapshotDocument = nodeTextDocument.create(
document.uri.toString(),
document.languageId,
document.version,
this.content,
);
this.fileName = document.fileName;
this.isUntitled = document.isUntitled;
this.encoding = document.encoding;
this.isDirty = document.isDirty;
this.isClosed = document.isClosed;
this.eol = document.eol;
}

public get uri(): TextDocument['uri'] {
return this.liveVsCodeDocument.uri;
}

public get languageId(): string {
return this.snapshotDocument.languageId;
}

public get version(): number {
return this.snapshotDocument.version;
}

public save(): Thenable<boolean> {
return this.liveVsCodeDocument.save();
}

public get lineCount(): number {
return this.snapshotDocument.lineCount;
}

public lineAt(line: number): TextLine;
public lineAt(position: Position): TextLine;
public lineAt(lineOrPosition: number | Position): TextLine {
const line = typeof lineOrPosition === 'number' ? lineOrPosition : this.validatePosition(lineOrPosition).line;
if (line < 0 || line >= this.lineCount) {
throw new RangeError(`Illegal value for line: ${line}`);
}
const startOffset = this.getLineOffsets()[line];
const endOffset = this.getLineEndOffset(line);
const text = this.content.substring(startOffset, endOffset);
const firstNonWhitespaceCharacterIndex = text.search(/\S/);
const range = new Range(new Position(line, 0), new Position(line, text.length));
const rangeIncludingLineBreak = line + 1 < this.lineCount
? new Range(range.start, new Position(line + 1, 0))
: range;
return {
lineNumber: line,
text,
range,
rangeIncludingLineBreak,
firstNonWhitespaceCharacterIndex: firstNonWhitespaceCharacterIndex === -1 ? text.length : firstNonWhitespaceCharacterIndex,
isEmptyOrWhitespace: firstNonWhitespaceCharacterIndex === -1,
};
}

public offsetAt(position: Position): number {
return this.snapshotDocument.offsetAt(position);
}

public positionAt(offset: number): Position {
const position = this.snapshotDocument.positionAt(offset);
return new Position(position.line, position.character);
}

public getText(range?: Range): string {
return this.snapshotDocument.getText(range);
}

public getWordRangeAtPosition(position: Position, regex?: RegExp): Range | undefined {
const validatedPosition = this.validatePosition(position);
const line = this.lineAt(validatedPosition);
if (line.text.length === 0) {
return undefined;
}
const wordDefinition = TextDocumentSnapshot.createWordRegExp(regex);
if (''.match(wordDefinition)?.[0]?.length === 0) {
return undefined;
}
let match: RegExpExecArray | null;
while ((match = wordDefinition.exec(line.text)) !== null) {
if (match[0].length === 0) {
break;
}
const start = match.index;
const end = start + match[0].length;
if (start <= validatedPosition.character && validatedPosition.character <= end) {
return new Range(new Position(line.lineNumber, start), new Position(line.lineNumber, end));
}
}
return undefined;
}

public validateRange(range: Range): Range {
return new Range(this.validatePosition(range.start), this.validatePosition(range.end));
}

public validatePosition(position: Position): Position {
const line = Math.min(Math.max(position.line, 0), this.lineCount - 1);
const character = Math.min(Math.max(position.character, 0), this.getLineLength(line));
if (line === position.line && character === position.character) {
return position;
}
return new Position(line, character);
}

private getLineOffsets(): number[] {
if (this._lineOffsets === undefined) {
this._lineOffsets = [0];
for (let index = 0; index < this.content.length; index++) {
const charCode = this.content.charCodeAt(index);
if (TextDocumentSnapshot.isEol(charCode)) {
if (charCode === 13 && index + 1 < this.content.length && this.content.charCodeAt(index + 1) === 10) {
index++;
}
this._lineOffsets.push(index + 1);
}
}
}
return this._lineOffsets;
}

private getLineEndOffset(line: number): number {
const lineOffsets = this.getLineOffsets();
const startOffset = lineOffsets[line];
let endOffset = line + 1 < lineOffsets.length ? lineOffsets[line + 1] : this.content.length;
while (endOffset > startOffset && TextDocumentSnapshot.isEol(this.content.charCodeAt(endOffset - 1))) {
endOffset--;
}
return endOffset;
}

private getLineLength(line: number): number {
return this.getLineEndOffset(line) - this.getLineOffsets()[line];
}

private static createWordRegExp(regex?: RegExp): RegExp {
const wordDefinition = regex ?? TextDocumentSnapshot.DefaultWordRegExp;
const flags = wordDefinition.flags.includes('g') ? wordDefinition.flags : `${wordDefinition.flags}g`;
return new RegExp(wordDefinition.source, flags);
}

private static isEol(charCode: number): boolean {
return charCode === 10 || charCode === 13;
}
}