diff --git a/example/lsp_server_example_incremental_sync.dart b/example/lsp_server_example_incremental_sync.dart new file mode 100644 index 0000000..c16b063 --- /dev/null +++ b/example/lsp_server_example_incremental_sync.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:lsp_server/lsp_server.dart'; + +void main() async { + // Create a connection that can read and write data to the LSP client. + // Supply a readable and writable stream. In this case we are using stdio. + // But you could use a socket connection or any other stream. + var connection = Connection(stdin, stdout); + + // Create a TextDocuments handler. This class gives support for both full + // and incremental sync. The document returned by this handler is the + // TextDocument class, which has an API that matches + // vscode-languageserver-textdocument. + var documents = TextDocuments(connection, onDidChangeContent: (params) async { + // onDidChangeContent is called both when a document is opened + // and when it changes. It's a great place to run diagnostics. + var diagnostics = _validateTextDocument( + params.document.getText(), + params.document.uri.toString(), + ); + + // Send back an event notifying the client of issues we want them to render. + // To clear issues the server is responsible for sending an empty list. + connection.sendDiagnostics( + PublishDiagnosticsParams( + diagnostics: diagnostics, + uri: params.document.uri, + ), + ); + }); + + // Register a listener for when the client initialzes the server. + // You are suppose to respond with the capabilities of the server. + // Some capabilities must be enabled by the client, you can see what the client + // supports by inspecting the ClientCapabilities object, inside InitializeParams. + connection.onInitialize((params) async { + return InitializeResult( + capabilities: ServerCapabilities( + // In this example we are using the Incremental sync mode. This means + // only the content that has changed is sent, and it's up to the server + // to update its state accordingly. TextDocuments and TextDocument + // handle this for you. + textDocumentSync: const Either2.t1(TextDocumentSyncKind.Incremental), + // Tell the client what we can do + diagnosticProvider: Either2.t1(DiagnosticOptions( + interFileDependencies: true, workspaceDiagnostics: false)), + hoverProvider: Either2.t1(true), + ), + ); + }); + + // Your other listeners likely want to get the synced TextDocument based + // on the params' TextDocumentIdentifier. + connection.onHover((params) async { + var textDocument = documents.get(params.textDocument.uri); + var lines = textDocument?.lineCount ?? 0; + return Hover(contents: Either2.t2('Document has $lines lines')); + }); + + await connection.listen(); +} + +// Validate the text document and return a list of diagnostics. +// Will find each occurence of more than two uppercase letters in a row. +// Each reported value will come with the indexed location in the file, +// by line and column. +List _validateTextDocument(String text, String sourcePath) { + RegExp pattern = RegExp(r'\b[A-Z]{2,}\b'); + + final lines = text.split('\n'); + + final matches = lines.map((line) => pattern.allMatches(line)); + + final diagnostics = matches + .mapIndexed( + (line, lineMatches) => _convertPatternToDiagnostic(lineMatches, line), + ) + .reduce((aggregate, diagnostics) => [...aggregate, ...diagnostics]) + .toList(); + + return diagnostics; +} + +// Convert each line that has uppercase strings into a list of diagnostics. +// The line "AAA bbb CCC" would be converted into two diagnostics: +// One for "AAA". +// One for "CCC". +Iterable _convertPatternToDiagnostic( + Iterable matches, int line) { + return matches.map( + (match) => Diagnostic( + message: + '${match.input.substring(match.start, match.end)} is all uppercase.', + range: Range( + start: Position(character: match.start, line: line), + end: Position(character: match.end, line: line), + ), + ), + ); +} diff --git a/lib/lsp_server.dart b/lib/lsp_server.dart index 3837d91..b597de4 100644 --- a/lib/lsp_server.dart +++ b/lib/lsp_server.dart @@ -1,3 +1,5 @@ export 'src/lsp_server_base.dart'; +export 'src/text_document.dart'; +export 'src/text_documents.dart'; export 'src/protocol/lsp_protocol/protocol_generated.dart'; export 'src/protocol/lsp_protocol/protocol_special.dart'; diff --git a/lib/src/text_document.dart b/lib/src/text_document.dart new file mode 100644 index 0000000..ff3bdb6 --- /dev/null +++ b/lib/src/text_document.dart @@ -0,0 +1,236 @@ +import 'dart:math'; + +import 'package:lsp_server/lsp_server.dart'; + +// \n +const lineFeed = 10; +// \r +const carriageReturn = 13; + +/// Mimics vscode-languageserver-node's +/// [TextDocument](https://github.com/microsoft/vscode-languageserver-node/blob/main/textDocument/src/main.ts) +class TextDocument { + final Uri _uri; + final String _languageId; + int _version; + String _content; + List? _lineOffsets; + + TextDocument(this._uri, this._languageId, this._version, this._content); + + /// The associated URI for this document. Most documents have the file scheme, indicating that they + /// represent files on disk. However, some documents may have other schemes indicating that they + /// are not available on disk. + Uri get uri => _uri; + + /// The identifier of the language associated with this document. + String get languageId => _languageId; + + /// The version number of this document (it will increase after each change, + /// including undo/redo). + int get version => _version; + + /// The number of lines in this document. + int get lineCount => _getLineOffsets().length; + + String applyEdits(List edits) { + var sortedEdits = edits.map(_getWellformedTextEdit).toList(); + sortedEdits.sort((a, b) { + var diff = a.range.start.line - b.range.start.line; + if (diff == 0) { + return a.range.start.character - b.range.start.character; + } + return diff; + }); + + var text = getText(); + var lastModifiedOffset = 0; + List spans = []; + + for (var edit in sortedEdits) { + var startOffset = offsetAt(edit.range.start); + if (startOffset < lastModifiedOffset) { + throw 'Overlapping edit'; + } else if (startOffset > lastModifiedOffset) { + spans.add(text.substring(lastModifiedOffset, startOffset)); + } + if (edit.newText.isNotEmpty) { + spans.add(edit.newText); + } + lastModifiedOffset = offsetAt(edit.range.end); + } + spans.add(text.substring(lastModifiedOffset)); + return spans.join(); + } + + /// Get the text of this document. Provide a [Range] to get a substring. + String getText({Range? range}) { + if (range != null) { + var start = offsetAt(range.start); + var end = offsetAt(range.end); + return _content.substring(start, end); + } + return _content; + } + + /// Convert a [Position] to a zero-based offset. + int offsetAt(Position position) { + var lineOffsets = _getLineOffsets(); + if (position.line >= lineOffsets.length) { + return _content.length; + } else if (position.line < 0) { + return 0; + } + + var lineOffset = lineOffsets[position.line]; + if (position.character <= 0) { + return lineOffset; + } + + var nextLineOffset = (position.line + 1 < lineOffsets.length) + ? lineOffsets[position.line + 1] + : _content.length; + var offset = min(lineOffset + position.character, nextLineOffset); + + return _ensureBeforeEndOfLine(offset: offset, lineOffset: lineOffset); + } + + /// Converts a zero-based offset to a [Position]. + Position positionAt(int offset) { + offset = max(min(offset, _content.length), 0); + var lineOffsets = _getLineOffsets(); + var low = 0; + var high = lineOffsets.length; + if (high == 0) { + return Position(character: offset, line: 0); + } + + while (low < high) { + var mid = ((low + high) / 2).floor(); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + var line = low - 1; + offset = _ensureBeforeEndOfLine( + offset: offset, + lineOffset: lineOffsets[line], + ); + + return Position(character: offset - lineOffsets[line], line: line); + } + + /// Updates this text document by modifying its content. + void update(List changes, int version) { + _version = version; + for (var c in changes) { + var change = c.map((v) => v, (v) => v); + if (change is TextDocumentContentChangeEvent1) { + // Incremental sync. + var range = _getWellformedRange(change.range); + var text = change.text; + + var startOffset = offsetAt(range.start); + var endOffset = offsetAt(range.end); + + // Update content. + _content = _content.substring(0, startOffset) + + text + + _content.substring(endOffset, _content.length); + + // Update offsets without recomputing for the whole document. + var startLine = max(range.start.line, 0); + var endLine = max(range.end.line, 0); + var lineOffsets = _lineOffsets!; + var addedLineOffsets = _computeLineOffsets(text, + isAtLineStart: false, textOffset: startOffset); + + if (endLine - startLine == addedLineOffsets.length) { + for (var i = 0, len = addedLineOffsets.length; i < len; i++) { + lineOffsets[i + startLine + 1] = addedLineOffsets[i]; + } + } else { + // Avoid going outside the range on weird range inputs. + lineOffsets.replaceRange( + min(startLine + 1, lineOffsets.length), + min(endLine + 1, lineOffsets.length), + addedLineOffsets, + ); + } + + var diff = text.length - (endOffset - startOffset); + if (diff != 0) { + for (var i = startLine + 1 + addedLineOffsets.length, + len = lineOffsets.length; + i < len; + i++) { + lineOffsets[i] = lineOffsets[i] + diff; + } + } + } else if (change is TextDocumentContentChangeEvent2) { + // Full sync. + _content = change.text; + _lineOffsets = null; + } + } + } + + List _getLineOffsets() { + _lineOffsets ??= _computeLineOffsets(_content, isAtLineStart: true); + return _lineOffsets!; + } + + List _computeLineOffsets(String content, + {required bool isAtLineStart, int textOffset = 0}) { + List result = isAtLineStart ? [textOffset] : []; + + for (var i = 0; i < content.length; i++) { + var char = content.codeUnitAt(i); + if (_isEndOfLine(char)) { + if (char == carriageReturn) { + var nextCharIsLineFeed = + i + 1 < content.length && content.codeUnitAt(i + 1) == lineFeed; + if (nextCharIsLineFeed) { + i++; + } + } + result.add(textOffset + i + 1); + } + } + + return result; + } + + bool _isEndOfLine(int char) { + return char == lineFeed || char == carriageReturn; + } + + int _ensureBeforeEndOfLine({required int offset, required int lineOffset}) { + while ( + offset > lineOffset && _isEndOfLine(_content.codeUnitAt(offset - 1))) { + offset--; + } + return offset; + } + + Range _getWellformedRange(Range range) { + var start = range.start; + var end = range.end; + if (start.line > end.line || + (start.line == end.line && start.character > end.character)) { + return Range(start: end, end: start); + } + return range; + } + + TextEdit _getWellformedTextEdit(TextEdit textEdit) { + var range = _getWellformedRange(textEdit.range); + if (range != textEdit.range) { + return TextEdit(newText: textEdit.newText, range: range); + } + return textEdit; + } +} diff --git a/lib/src/text_documents.dart b/lib/src/text_documents.dart new file mode 100644 index 0000000..fb1ae7c --- /dev/null +++ b/lib/src/text_documents.dart @@ -0,0 +1,116 @@ +import 'package:lsp_server/lsp_server.dart'; + +class TextDocumentChangeEvent { + final TextDocument document; + + TextDocumentChangeEvent(this.document); +} + +class TextDocumentWillSaveEvent { + final TextDocument document; + final TextDocumentSaveReason reason; + + TextDocumentWillSaveEvent(this.document, this.reason); +} + +/// Helper class handling the low-level methods to sync document +/// contents from the client. Fulfills a similar role to vscode-languageserver-node's +/// [TextDocuments](https://github.com/microsoft/vscode-languageserver-node/blob/main/server/src/common/textDocuments.ts), though +/// differs slightly in the API surface. +/// +/// Pass in to the constuctor your wanted event handlers, to run code when a document is opened, closed, changed or saved. +/// +/// The events will run for the same LSP messages as the Node implementation. +/// The parameters are also the same as the Node implementation [TextDocument]. +class TextDocuments { + final Map _syncedDocuments = {}; + + TextDocuments( + Connection connection, { + Future Function(TextDocumentChangeEvent)? onDidOpen, + Future Function(TextDocumentChangeEvent)? onDidChangeContent, + Future Function(TextDocumentChangeEvent)? onDidClose, + Future Function(TextDocumentChangeEvent)? onWillSave, + Future> Function(TextDocumentWillSaveEvent)? + onWillSaveWaitUntil, + Future Function(TextDocumentChangeEvent)? onDidSave, + }) { + connection.onDidOpenTextDocument((event) async { + var td = event.textDocument; + var document = TextDocument(td.uri, td.languageId, td.version, td.text); + _syncedDocuments[document.uri] = document; + + if (onDidOpen != null) { + onDidOpen(TextDocumentChangeEvent(document)); + } + if (onDidChangeContent != null) { + onDidChangeContent(TextDocumentChangeEvent(document)); + } + }); + + connection.onDidChangeTextDocument((event) async { + var td = event.textDocument; + var changes = event.contentChanges; + if (changes.isEmpty) return; + + var version = td.version; + var syncedDocument = get(td.uri); + if (syncedDocument == null) return; + + syncedDocument.update(changes, version); + + if (onDidChangeContent != null) { + onDidChangeContent(TextDocumentChangeEvent(syncedDocument)); + } + }); + + connection.onDidCloseTextDocument((event) async { + var key = event.textDocument.uri; + var document = _syncedDocuments.remove(key); + if (document != null && onDidClose != null) { + onDidClose(TextDocumentChangeEvent(document)); + } + }); + + connection.onWillSaveTextDocument((event) async { + var document = _syncedDocuments[event.textDocument.uri]; + if (document != null && onWillSave != null) { + onWillSave(TextDocumentChangeEvent(document)); + } + }); + + if (onWillSaveWaitUntil != null) { + connection.onWillSaveWaitUntilTextDocument((event) async { + var document = _syncedDocuments[event.textDocument.uri]; + if (document != null) { + return onWillSaveWaitUntil( + TextDocumentWillSaveEvent(document, event.reason)); + } else { + return []; + } + }); + } + + connection.onDidSaveTextDocument((event) async { + var document = _syncedDocuments[event.textDocument.uri]; + if (document != null && onDidSave != null) { + onDidSave(TextDocumentChangeEvent(document)); + } + }); + } + + /// Get a synced [TextDocument] for [uri], if there is one. + TextDocument? get(Uri uri) { + return _syncedDocuments[uri]; + } + + /// Get all synced [TextDocument]s + Iterable all() { + return _syncedDocuments.values; + } + + /// Get an [Iterable] of [Uri]s we have synced. + Iterable keys() { + return _syncedDocuments.keys; + } +} diff --git a/test/text_document_test.dart b/test/text_document_test.dart new file mode 100644 index 0000000..ff33187 --- /dev/null +++ b/test/text_document_test.dart @@ -0,0 +1,607 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:lsp_server/lsp_server.dart'; +import 'package:test/test.dart'; + +lsp.Position at({required int line, required int char}) { + return lsp.Position(character: char, line: line); +} + +lsp.Position position(int line, int char) { + return at(line: line, char: char); +} + +lsp.Range range(int startLine, int startChar, int endLine, int endChar) { + return lsp.Range( + start: at(line: startLine, char: startChar), + end: at(line: endLine, char: endChar), + ); +} + +TextDocument createDocument(String content) { + return TextDocument(Uri.parse('test://hello/world'), 'text', 0, content); +} + +lsp.Either2 updateFull(String text) { + return lsp.Either2.t2(lsp.TextDocumentContentChangeEvent2(text: text)); +} + +lsp.Either2 + updateIncremental(String text, lsp.Range range) { + return lsp.Either2.t1( + lsp.TextDocumentContentChangeEvent1( + text: text, + range: range, + ), + ); +} + +lsp.Range forSubstring(TextDocument document, String substring) { + var i = document.getText().indexOf(substring); + var start = document.positionAt(i); + var end = document.positionAt(i + substring.length); + var range = lsp.Range(start: start, end: end); + return range; +} + +lsp.Range afterSubstring(TextDocument document, String substring) { + var i = document.getText().indexOf(substring); + var pos = document.positionAt(i + substring.length); + return lsp.Range(start: pos, end: pos); +} + +lsp.TextEdit insert(String text, lsp.Position at) { + return lsp.TextEdit(newText: text, range: lsp.Range(start: at, end: at)); +} + +lsp.TextEdit replace(String text, lsp.Range range) { + return lsp.TextEdit(newText: text, range: range); +} + +lsp.TextEdit delete(lsp.Range range) { + return lsp.TextEdit(newText: '', range: range); +} + +void main() { + group('lines, offsets and positions', () { + test('empty content', () { + var document = createDocument(''); + + expect(document.lineCount, equals(1)); + expect(document.offsetAt(position(0, 0)), equals(0)); + + var pos = document.positionAt(0); + expect(pos.line, equals(0)); + expect(pos.character, equals(0)); + }); + + test('single line', () { + var content = 'Hello World'; + var document = createDocument(content); + expect(document.lineCount, equals(1)); + + for (var i = 0; i < content.length; i++) { + expect(document.offsetAt(position(0, i)), equals(i)); + var pos = document.positionAt(i); + expect(pos.line, equals(0)); + expect(pos.character, equals(i)); + } + }); + + test('multiple lines', () { + var content = 'abcde\nfghij\nklmno\n'; + var document = createDocument(content); + expect(document.lineCount, equals(4)); + + for (var i = 0; i < content.length; i++) { + var line = (i / 6).floor(); + var char = i % 6; + expect(document.offsetAt(position(line, char)), equals(i)); + + var pos = document.positionAt(i); + expect(pos.line, equals(line)); + expect(pos.character, equals(char)); + } + + // Out of bounds. + expect(document.offsetAt(position(3, 0)), content.length); + expect(document.offsetAt(position(3, 1)), content.length); + + var pos = document.positionAt(18); + expect(pos.line, equals(3)); + expect(pos.character, equals(0)); + + pos = document.positionAt(19); + expect(pos.line, equals(3)); + expect(pos.character, equals(0)); + }); + + test('starts with newline', () { + var content = '\nABCDE'; + var document = createDocument(content); + expect(document.lineCount, equals(2)); + }); + + test('newline characters', () { + var document = createDocument('\rABCDE'); + expect(document.lineCount, equals(2)); + document = createDocument('\nABCDE'); + expect(document.lineCount, equals(2)); + + document = createDocument('\r\nABCDE'); + expect(document.lineCount, equals(2)); + + document = createDocument('\n\nABCDE'); + expect(document.lineCount, equals(3)); + + document = createDocument('\r\rABCDE'); + expect(document.lineCount, equals(3)); + + document = createDocument('\n\rABCDE'); + expect(document.lineCount, equals(3)); + }); + + test('getText', () { + var content = 'abcde\nfghij\nklmno'; + var document = createDocument(content); + expect(document.getText(), equals(content)); + + expect( + document.getText(range: range(0, 0, 0, 5)), + equals('abcde'), + ); + expect( + document.getText(range: range(0, 4, 1, 1)), + equals('e\nf'), + ); + }); + + test('invalid input at beginning of file', () { + var document = createDocument('asdf'); + expect(document.offsetAt(position(-1, 0)), 0); + expect(document.offsetAt(position(0, -1)), 0); + + var pos = document.positionAt(-1); + expect(pos.line, equals(0)); + expect(pos.character, equals(0)); + }); + + test('invalid input at end of file', () { + var document = createDocument('asdf'); + expect(document.offsetAt(position(1, 1)), 4); + + var pos = document.positionAt(8); + expect(pos.line, equals(0)); + expect(pos.character, equals(4)); + }); + + test('invalid input at beginning of line', () { + var document = createDocument('a\ns\nd\r\nf'); + expect(document.offsetAt(position(0, -1)), 0); + expect(document.offsetAt(position(1, -1)), 2); + expect(document.offsetAt(position(2, -1)), 4); + expect(document.offsetAt(position(3, -1)), 7); + }); + + test('invalid input at end of line', () { + var document = createDocument('a\ns\nd\r\nf'); + expect(document.offsetAt(position(0, 10)), 1); + expect(document.offsetAt(position(1, 10)), 3); + expect(document.offsetAt(position(2, 2)), 5); + expect(document.offsetAt(position(2, 3)), 5); + expect(document.offsetAt(position(2, 10)), 5); + expect(document.offsetAt(position(3, 10)), 8); + + var pos = document.positionAt(6); + expect(pos.line, equals(2)); + expect(pos.character, equals(1)); + }); + }); + + group('full updates', () { + test('one full update', () { + var document = createDocument('asdfqwer'); + document.update([updateFull('hjklyuio')], 1); + expect(document.version, equals(1)); + expect(document.getText(), equals('hjklyuio')); + }); + + test('several full updates', () { + var document = createDocument('asdfqwer'); + document.update([updateFull('hjklyuio'), updateFull('12345')], 2); + expect(document.version, equals(2)); + expect(document.getText(), equals('12345')); + }); + }); + + group('incremental updates', () { + void expectLineAtOffsets(TextDocument document) { + // Assuming \n. + var text = document.getText(); + var characters = text.split(''); + var expected = 0; + for (var i = 0; i < text.length; i++) { + expect(document.positionAt(i).line, expected); + if (characters[i] == '\n') { + expected += 1; + } + } + expect(document.positionAt(text.length).line, equals(expected)); + } + + test('removing content', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [updateIncremental('', forSubstring(document, 'abcde'))], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('\nfghij\nklmno')); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + }); + + test('removing content over multiple lines', () { + var document = createDocument('abcde\nfghij\nklmno\npqrst'); + expect(document.version, equals(0)); + expect(document.lineCount, equals(4)); + expectLineAtOffsets(document); + document.update( + [updateIncremental('', forSubstring(document, 'fghij\nklmno'))], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), 'abcde\n\npqrst'); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + }); + + test('adding content', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [updateIncremental('12345', afterSubstring(document, 'abcde\n'))], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('abcde\n12345fghij\nklmno')); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + }); + + test('adding content over multiple lines', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental( + '12345\n67890\n', + afterSubstring(document, 'abcde\n'), + ) + ], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('abcde\n12345\n67890\nfghij\nklmno')); + expect(document.lineCount, equals(5)); + expectLineAtOffsets(document); + }); + + test('replacing single-line content with more content', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental( + '1234567890', + forSubstring(document, 'abcde'), + ) + ], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('1234567890\nfghij\nklmno')); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + }); + + test('replacing single-line content with less content', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental( + '1', + forSubstring(document, 'abcde'), + ) + ], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('1\nfghij\nklmno')); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + }); + + test('replacing single-line content with same amount of characters', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental( + '12345', + forSubstring(document, 'abcde'), + ) + ], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('12345\nfghij\nklmno')); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + }); + + test('replacing multi-line content with more lines', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental( + '12345\n67890\nABCDE\n', + forSubstring(document, 'abcde\n'), + ) + ], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('12345\n67890\nABCDE\nfghij\nklmno')); + expect(document.lineCount, equals(5)); + expectLineAtOffsets(document); + }); + + test('replacing multi-line content with fewer lines', () { + var document = createDocument('12345\n67890\nABCDE\nfghij\nklmno'); + expect(document.lineCount, equals(5)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental( + 'abcde\n', + forSubstring(document, '12345\n67890\nABCDE\n'), + ) + ], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('abcde\nfghij\nklmno')); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + }); + + test('replacing multi-line content with same amounts', () { + var document = createDocument('12345\n67890\nABCDE\nfghij\nklmno'); + expect(document.lineCount, equals(5)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental( + 'abcde\nFGHJI', + forSubstring(document, 'ABCDE\nfghij'), + ) + ], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('12345\n67890\nabcde\nFGHJI\nklmno')); + expect(document.lineCount, equals(5)); + expectLineAtOffsets(document); + }); + + test('replace large number of lines', () { + var document = createDocument('12345\n67890\nasdf'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + var text = ''; + for (var i = 0; i < 20000; i++) { + text += 'asdf\n'; + } + document.update( + [updateIncremental(text, forSubstring(document, '67890\n'))], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), '12345\n${text}asdf'); + expect(document.lineCount, 20002); + expectLineAtOffsets(document); + }); + + test('several incremental changes', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + expect(document.version, equals(0)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental('BCD', forSubstring(document, 'bcd')), + updateIncremental('LMN', forSubstring(document, 'lmn')), + updateIncremental('GHI', forSubstring(document, 'ghi')), + ], + 1, + ); + expect(document.version, equals(1)); + expect(document.getText(), equals('aBCDe\nfGHIj\nkLMNo')); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + }); + + test('append', () { + var document = createDocument('abcde'); + expect(document.lineCount, equals(1)); + document.update( + [ + updateIncremental( + '\nfghij\nklmno', + afterSubstring(document, 'abcde'), + ), + ], + 1, + ); + expect(document.getText(), equals('abcde\nfghij\nklmno')); + expect(document.lineCount, equals(3)); + }); + + test('delete', () { + var document = createDocument('abcde\nfghij\nklmno'); + expect(document.lineCount, equals(3)); + document.update( + [ + updateIncremental( + '', + forSubstring(document, 'o'), + ), + ], + 1, + ); + expect(document.getText(), equals('abcde\nfghij\nklmn')); + expect(document.version, equals(1)); + expect(document.lineCount, equals(3)); + expectLineAtOffsets(document); + document.update( + [ + updateIncremental( + '', + forSubstring(document, 'fghij\nklmn'), + ), + ], + 2, + ); + expect(document.getText(), equals('abcde\n')); + expect(document.version, equals(2)); + expect(document.lineCount, equals(2)); + expectLineAtOffsets(document); + }); + + test('handles weird update ranges', () { + var document = createDocument('abcde\nfghij'); + document.update( + [ + updateIncremental('1234', range(-4, 0, -2, 3)), + ], + 1, + ); + expect(document.getText(), equals('1234abcde\nfghij')); + + document = createDocument('abcde\nfghij'); + document.update( + [ + updateIncremental('1234', range(-1, 0, 0, 5)), + ], + 1, + ); + expect(document.getText(), equals('1234\nfghij')); + + document = createDocument('abcde\nfghij'); + document.update( + [ + updateIncremental('1234', range(1, 0, 13, 14)), + ], + 1, + ); + expect(document.getText(), equals('abcde\n1234')); + + document = createDocument('abcde\nfghij'); + document.update( + [ + updateIncremental('1234', range(13, 0, 35, 14)), + ], + 1, + ); + expect(document.getText(), equals('abcde\nfghij1234')); + + document = createDocument('abcde\nfghij'); + document.update( + [ + updateIncremental('1234', range(-13, 0, 35, 14)), + ], + 1, + ); + expect(document.getText(), equals('1234')); + }); + }); + + group('applyEdits', () { + test('inserts', () { + var input = createDocument('asdfasdfasdf'); + expect( + input.applyEdits([insert('Hello', position(0, 0))]), + equals('Helloasdfasdfasdf'), + ); + expect( + input.applyEdits([insert('Hello', position(0, 1))]), + equals('aHellosdfasdfasdf'), + ); + expect( + input.applyEdits([ + insert('Hello', position(0, 1)), + insert('World', position(0, 1)), + ]), + equals('aHelloWorldsdfasdfasdf'), + ); + expect( + input.applyEdits([ + insert('Mint', position(0, 2)), + insert('Hello', position(0, 1)), + insert('World', position(0, 1)), + insert('Jams', position(0, 2)), + insert('Casiopea', position(0, 2)), + ]), + equals('aHelloWorldsMintJamsCasiopeadfasdfasdf'), + ); + }); + + test('replace', () { + var input = createDocument('0123456789'); + expect( + input.applyEdits([replace('Hello', range(0, 3, 0, 5))]), + equals('012Hello56789'), + ); + expect( + input.applyEdits([ + replace('Hello', range(0, 3, 0, 5)), + replace('World', range(0, 5, 0, 7)), + ]), + equals('012HelloWorld789'), + ); + }); + + test('mix', () { + var input = createDocument('0123456789'); + expect( + input.applyEdits([ + insert('Jams', position(0, 6)), + replace('Mint', range(0, 3, 0, 6)), + ]), + equals('012MintJams6789'), + ); + }); + }); +}