From aa6f6c38f4420066b19687d191a616b6d1f518e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=B8=A1?= Date: Mon, 27 Apr 2020 16:15:03 +0800 Subject: [PATCH] Text Document implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 迷渡 --- textdocument/README.md | 22 + textdocument/mod.ts | 514 ++++++++++++++++++++++ textdocument/test/edits_test.ts | 147 +++++++ textdocument/test/helper.ts | 63 +++ textdocument/test/textdocument_test.ts | 565 +++++++++++++++++++++++++ types/{ => test}/edits_test.ts | 2 +- types/{ => test}/textdocument_test.ts | 6 +- types/{ => test}/typeguards_test.ts | 2 +- 8 files changed, 1317 insertions(+), 4 deletions(-) create mode 100644 textdocument/README.md create mode 100644 textdocument/mod.ts create mode 100644 textdocument/test/edits_test.ts create mode 100644 textdocument/test/helper.ts create mode 100644 textdocument/test/textdocument_test.ts rename types/{ => test}/edits_test.ts (98%) rename types/{ => test}/textdocument_test.ts (96%) rename types/{ => test}/typeguards_test.ts (99%) diff --git a/textdocument/README.md b/textdocument/README.md new file mode 100644 index 0000000..3fab745 --- /dev/null +++ b/textdocument/README.md @@ -0,0 +1,22 @@ +# Text Document implementation for a LSP Deno server + +[![tag](https://img.shields.io/github/release/denodev/deno_vscode_languageserver)](https://github.com/denodev/deno_vscode_languageserver/releases) +[![Build Status](https://github.com/denodev/deno_vscode_languageserver/workflows/ci/badge.svg?branch=master)](https://github.com/denodev/deno_vscode_languageserver/actions) + +Deno module containing a simple text document implementation for [Deno](https://deno.land) language server. + +**Modified from [microsoft/vscode-languageserver-node's textDocument @f81b7da](https://github.com/microsoft/vscode-languageserver-node/blob/f81b7dade216829869e5cc03e594660ddd4a810a/textDocument/src/main.ts)**. + +-------------- + +> Click [here](https://code.visualstudio.com/docs/extensions/example-language-server) for a detailed document on how +> to implement language servers for [VSCode](https://code.visualstudio.com/). +> +> ## History +> +> ### 1.0.0 +> +> Initial version. +> +> ## License +> [MIT](https://github.com/Microsoft/vscode-languageserver-node/blob/master/License.txt) diff --git a/textdocument/mod.ts b/textdocument/mod.ts new file mode 100644 index 0000000..6bcd759 --- /dev/null +++ b/textdocument/mod.ts @@ -0,0 +1,514 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ +"use strict"; + +/** + * A tagging type for string properties that are actually URIs. + */ +export type DocumentUri = string; + +/** + * Position in a text document expressed as zero-based line and character offset. + * The offsets are based on a UTF-16 string representation. So a string of the form + * `a𐐀b` the character offset of the character `a` is 0, the character offset of `𐐀` + * is 1 and the character offset of b is 3 since `𐐀` is represented using two code + * units in UTF-16. + * + * Positions are line end character agnostic. So you can not specify a position that + * denotes `\r|\n` or `\n|` where `|` represents the character offset. + */ +export interface Position { + /** + * Line position in a document (zero-based). + * If a line number is greater than the number of lines in a document, it defaults back to the number of lines in the document. + * If a line number is negative, it defaults to 0. + */ + line: number; + + /** + * Character offset on a line in a document (zero-based). Assuming that the line is + * represented as a string, the `character` value represents the gap between the + * `character` and `character + 1`. + * + * If the character value is greater than the line length it defaults back to the + * line length. + * If a line number is negative, it defaults to 0. + */ + character: number; +} + +/** + * A range in a text document expressed as (zero-based) start and end positions. + * + * If you want to specify a range that contains a line including the line ending + * character(s) then use an end position denoting the start of the next line. + * For example: + * ```ts + * { + * start: { line: 5, character: 23 } + * end : { line 6, character : 0 } + * } + * ``` + */ +export interface Range { + /** + * The range's start position + */ + start: Position; + + /** + * The range's end position. + */ + end: Position; +} + +/** + * A text edit applicable to a text document. + */ +export interface TextEdit { + /** + * The range of the text document to be manipulated. To insert + * text into a document create a range where start === end. + */ + range: Range; + + /** + * The string to be inserted. For delete operations use an + * empty string. + */ + newText: string; +} + +/** + * An event describing a change to a text document. If range and rangeLength are omitted + * the new text is considered to be the full content of the document. + */ +export type TextDocumentContentChangeEvent = { + /** + * The range of the document that changed. + */ + range: Range; + + /** + * The optional length of the range that got replaced. + * + * @deprecated use range instead. + */ + rangeLength?: number; + + /** + * The new text for the provided range. + */ + text: string; +} | { + /** + * The new text of the whole document. + */ + text: string; +}; + +/** + * A simple text document. Not to be implemented. The document keeps the content + * as string. + */ +export interface TextDocument { + /** + * 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. + * + * @readonly + */ + readonly uri: DocumentUri; + + /** + * The identifier of the language associated with this document. + * + * @readonly + */ + readonly languageId: string; + + /** + * The version number of this document (it will increase after each + * change, including undo/redo). + * + * @readonly + */ + readonly version: number; + + /** + * Get the text of this document. A substring can be retrieved by + * providing a range. + * + * @param range (optional) An range within the document to return. + * If no range is passed, the full content is returned. + * Invalid range positions are adjusted as described in [Position.line](#Position.line) + * and [Position.character](#Position.character). + * If the start range position is greater than the end range position, + * then the effect of getText is as if the two positions were swapped. + * @return The text of this document or a substring of the text if a + * range is provided. + */ + getText(range?: Range): string; + + /** + * Converts a zero-based offset to a position. + * + * @param offset A zero-based offset. + * @return A valid [position](#Position). + */ + positionAt(offset: number): Position; + + /** + * Converts the position to a zero-based offset. + * Invalid positions are adjusted as described in [Position.line](#Position.line) + * and [Position.character](#Position.character). + * + * @param position A position. + * @return A valid zero-based offset. + */ + offsetAt(position: Position): number; + + /** + * The number of lines in this document. + * + * @readonly + */ + readonly lineCount: number; +} + +class FullTextDocument implements TextDocument { + private _uri: DocumentUri; + private _languageId: string; + private _version: number; + private _content: string; + private _lineOffsets: number[] | undefined; + + public constructor( + uri: DocumentUri, + languageId: string, + version: number, + content: string, + ) { + this._uri = uri; + this._languageId = languageId; + this._version = version; + this._content = content; + this._lineOffsets = undefined; + } + + public get uri(): string { + return this._uri; + } + + public get languageId(): string { + return this._languageId; + } + + public get version(): number { + return this._version; + } + + public getText(range?: Range): string { + if (range) { + const start = this.offsetAt(range.start); + const end = this.offsetAt(range.end); + return this._content.substring(start, end); + } + return this._content; + } + + public update( + changes: TextDocumentContentChangeEvent[], + version: number, + ): void { + for (let change of changes) { + if (FullTextDocument.isIncremental(change)) { + // makes sure start is before end + const range = getWellformedRange(change.range); + + // update content + const startOffset = this.offsetAt(range.start); + const endOffset = this.offsetAt(range.end); + this._content = this._content.substring(0, startOffset) + change.text + + this._content.substring(endOffset, this._content.length); + + // update the offsets + const startLine = Math.max(range.start.line, 0); + const endLine = Math.max(range.end.line, 0); + let lineOffsets = this._lineOffsets!; + const addedLineOffsets = computeLineOffsets( + change.text, + false, + startOffset, + ); + if (endLine - startLine === addedLineOffsets.length) { + for (let i = 0, len = addedLineOffsets.length; i < len; i++) { + lineOffsets[i + startLine + 1] = addedLineOffsets[i]; + } + } else { + if (addedLineOffsets.length < 10000) { + lineOffsets.splice( + startLine + 1, + endLine - startLine, + ...addedLineOffsets, + ); + } else { // avoid too many arguments for splice + this._lineOffsets = lineOffsets = lineOffsets.slice( + 0, + startLine + 1, + ).concat(addedLineOffsets, lineOffsets.slice(endLine + 1)); + } + } + const diff = change.text.length - (endOffset - startOffset); + if (diff !== 0) { + for ( + let i = startLine + 1 + addedLineOffsets.length, + len = lineOffsets.length; + i < len; + i++ + ) { + lineOffsets[i] = lineOffsets[i] + diff; + } + } + } else if (FullTextDocument.isFull(change)) { + this._content = change.text; + this._lineOffsets = undefined; + } else { + throw new Error("Unknown change event received"); + } + } + this._version = version; + } + + private getLineOffsets(): number[] { + if (this._lineOffsets === undefined) { + this._lineOffsets = computeLineOffsets(this._content, true); + } + return this._lineOffsets; + } + + public positionAt(offset: number): Position { + offset = Math.max(Math.min(offset, this._content.length), 0); + + let lineOffsets = this.getLineOffsets(); + let low = 0, high = lineOffsets.length; + if (high === 0) { + return { line: 0, character: offset }; + } + while (low < high) { + let mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + let line = low - 1; + return { line, character: offset - lineOffsets[line] }; + } + + public offsetAt(position: Position) { + let lineOffsets = this.getLineOffsets(); + if (position.line >= lineOffsets.length) { + return this._content.length; + } else if (position.line < 0) { + return 0; + } + let lineOffset = lineOffsets[position.line]; + let nextLineOffset = (position.line + 1 < lineOffsets.length) + ? lineOffsets[position.line + 1] + : this._content.length; + return Math.max( + Math.min(lineOffset + position.character, nextLineOffset), + lineOffset, + ); + } + + public get lineCount() { + return this.getLineOffsets().length; + } + + private static isIncremental( + event: TextDocumentContentChangeEvent, + ): event is { range: Range; rangeLength?: number; text: string } { + let candidate: { range: Range; rangeLength?: number; text: string } = + event as any; + return candidate !== undefined && candidate !== null && + typeof candidate.text === "string" && candidate.range !== undefined && + (candidate.rangeLength === undefined || + typeof candidate.rangeLength === "number"); + } + + private static isFull( + event: TextDocumentContentChangeEvent, + ): event is { text: string } { + let candidate: { range?: Range; rangeLength?: number; text: string } = + event as any; + return candidate !== undefined && candidate !== null && + typeof candidate.text === "string" && candidate.range === undefined && + candidate.rangeLength === undefined; + } +} + +export namespace TextDocument { + /** + * Creates a new text document. + * + * @param uri The document's uri. + * @param languageId The document's language Id. + * @param version The document's initial version number. + * @param content The document's content. + */ + export function create( + uri: DocumentUri, + languageId: string, + version: number, + content: string, + ): TextDocument { + return new FullTextDocument(uri, languageId, version, content); + } + + /** + * Updates a TextDocument by modifing its content. + * + * @param document the document to update. Only documents created by TextDocument.create are valid inputs. + * @param changes the changes to apply to the document. + * @returns The updated TextDocument. Note: That's the same document instance passed in as first parameter. + * + */ + export function update( + document: TextDocument, + changes: TextDocumentContentChangeEvent[], + version: number, + ): TextDocument { + if (document instanceof FullTextDocument) { + document.update(changes, version); + return document; + } else { + throw new Error( + "TextDocument.update: document must be created by TextDocument.create", + ); + } + } + + export function applyEdits( + document: TextDocument, + edits: TextEdit[], + ): string { + let text = document.getText(); + let sortedEdits = mergeSort(edits.map(getWellformedEdit), (a, b) => { + let diff = a.range.start.line - b.range.start.line; + if (diff === 0) { + return a.range.start.character - b.range.start.character; + } + return diff; + }); + let lastModifiedOffset = 0; + const spans = []; + for (const e of sortedEdits) { + let startOffset = document.offsetAt(e.range.start); + if (startOffset < lastModifiedOffset) { + throw new Error("Overlapping edit"); + } else if (startOffset > lastModifiedOffset) { + spans.push(text.substring(lastModifiedOffset, startOffset)); + } + if (e.newText.length) { + spans.push(e.newText); + } + lastModifiedOffset = document.offsetAt(e.range.end); + } + spans.push(text.substr(lastModifiedOffset)); + return spans.join(""); + } +} + +function mergeSort(data: T[], compare: (a: T, b: T) => number): T[] { + if (data.length <= 1) { + // sorted + return data; + } + const p = (data.length / 2) | 0; + const left = data.slice(0, p); + const right = data.slice(p); + + mergeSort(left, compare); + mergeSort(right, compare); + + let leftIdx = 0; + let rightIdx = 0; + let i = 0; + while (leftIdx < left.length && rightIdx < right.length) { + let ret = compare(left[leftIdx], right[rightIdx]); + if (ret <= 0) { + // smaller_equal -> take left to preserve order + data[i++] = left[leftIdx++]; + } else { + // greater -> take right + data[i++] = right[rightIdx++]; + } + } + while (leftIdx < left.length) { + data[i++] = left[leftIdx++]; + } + while (rightIdx < right.length) { + data[i++] = right[rightIdx++]; + } + return data; +} + +const enum CharCode { + /** + * The `\n` character. + */ + LineFeed = 10, + /** + * The `\r` character. + */ + CarriageReturn = 13, +} + +function computeLineOffsets( + text: string, + isAtLineStart: boolean, + textOffset = 0, +): number[] { + const result: number[] = isAtLineStart ? [textOffset] : []; + for (let i = 0; i < text.length; i++) { + let ch = text.charCodeAt(i); + if (ch === CharCode.CarriageReturn || ch === CharCode.LineFeed) { + if ( + ch === CharCode.CarriageReturn && i + 1 < text.length && + text.charCodeAt(i + 1) === CharCode.LineFeed + ) { + i++; + } + result.push(textOffset + i + 1); + } + } + return result; +} + +function getWellformedRange(range: Range): Range { + const start = range.start; + const end = range.end; + if ( + start.line > end.line || + (start.line === end.line && start.character > end.character) + ) { + return { start: end, end: start }; + } + return range; +} + +function getWellformedEdit(textEdit: TextEdit): TextEdit { + const range = getWellformedRange(textEdit.range); + if (range !== textEdit.range) { + return { newText: textEdit.newText, range }; + } + return textEdit; +} diff --git a/textdocument/test/edits_test.ts b/textdocument/test/edits_test.ts new file mode 100644 index 0000000..a6c36e7 --- /dev/null +++ b/textdocument/test/edits_test.ts @@ -0,0 +1,147 @@ +import { + assertEquals, + assertThrows, +} from "https://deno.land/std/testing/asserts.ts"; + +import { TextDocument } from "../mod.ts"; +import { + Positions as Position, + Ranges as Range, + TextEdits as TextEdit, +} from "./helper.ts"; + +const applyEdits = TextDocument.applyEdits; + +Deno.test("Edits - inserts", function (): any { + let input = TextDocument.create( + "foo://bar/f", + "html", + 0, + "012345678901234567890123456789", + ); + assertEquals( + applyEdits(input, [TextEdit.insert(Position.create(0, 0), "Hello")]), + "Hello012345678901234567890123456789", + ); + assertEquals( + applyEdits(input, [TextEdit.insert(Position.create(0, 1), "Hello")]), + "0Hello12345678901234567890123456789", + ); + assertEquals( + applyEdits( + input, + [ + TextEdit.insert(Position.create(0, 1), "Hello"), + TextEdit.insert(Position.create(0, 1), "World"), + ], + ), + "0HelloWorld12345678901234567890123456789", + ); + assertEquals( + applyEdits( + input, + [ + TextEdit.insert(Position.create(0, 2), "One"), + TextEdit.insert(Position.create(0, 1), "Hello"), + TextEdit.insert(Position.create(0, 1), "World"), + TextEdit.insert(Position.create(0, 2), "Two"), + TextEdit.insert(Position.create(0, 2), "Three"), + ], + ), + "0HelloWorld1OneTwoThree2345678901234567890123456789", + ); +}); + +Deno.test("Edits - replace", function (): any { + let input = TextDocument.create( + "foo://bar/f", + "html", + 0, + "012345678901234567890123456789", + ); + assertEquals( + applyEdits(input, [TextEdit.replace(Range.create(0, 3, 0, 6), "Hello")]), + "012Hello678901234567890123456789", + ); + assertEquals( + applyEdits( + input, + [ + TextEdit.replace(Range.create(0, 3, 0, 6), "Hello"), + TextEdit.replace(Range.create(0, 6, 0, 9), "World"), + ], + ), + "012HelloWorld901234567890123456789", + ); + assertEquals( + applyEdits( + input, + [ + TextEdit.replace(Range.create(0, 3, 0, 6), "Hello"), + TextEdit.insert(Position.create(0, 6), "World"), + ], + ), + "012HelloWorld678901234567890123456789", + ); + assertEquals( + applyEdits( + input, + [ + TextEdit.insert(Position.create(0, 6), "World"), + TextEdit.replace(Range.create(0, 3, 0, 6), "Hello"), + ], + ), + "012HelloWorld678901234567890123456789", + ); + assertEquals( + applyEdits( + input, + [ + TextEdit.insert(Position.create(0, 3), "World"), + TextEdit.replace(Range.create(0, 3, 0, 6), "Hello"), + ], + ), + "012WorldHello678901234567890123456789", + ); +}); + +Deno.test("Edits - overlap", function (): any { + let input = TextDocument.create( + "foo://bar/f", + "html", + 0, + "012345678901234567890123456789", + ); + assertThrows(() => + applyEdits( + input, + [ + TextEdit.replace(Range.create(0, 3, 0, 6), "Hello"), + TextEdit.insert(Position.create(0, 3), "World"), + ], + ) + ); + assertThrows(() => + applyEdits( + input, + [ + TextEdit.replace(Range.create(0, 3, 0, 6), "Hello"), + TextEdit.insert(Position.create(0, 4), "World"), + ], + ) + ); +}); + +Deno.test("Edits - multiline", function (): any { + let input = TextDocument.create("foo://bar/f", "html", 0, "0\n1\n2\n3\n4"); + assertEquals( + applyEdits( + input, + [ + TextEdit.replace(Range.create(2, 0, 3, 0), "Hello"), + TextEdit.insert(Position.create(1, 1), "World"), + ], + ), + "0\n1World\nHello3\n4", + ); +}); diff --git a/textdocument/test/helper.ts b/textdocument/test/helper.ts new file mode 100644 index 0000000..37d5ae0 --- /dev/null +++ b/textdocument/test/helper.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +"use strict"; + +import { Position, Range, TextEdit, TextDocument } from "../mod.ts"; + +export namespace Positions { + export function create(line: number, character: number): Position { + return { line, character }; + } + + export function afterSubstring( + document: TextDocument, + subText: string, + ): Position { + const index = document.getText().indexOf(subText); + return document.positionAt(index + subText.length); + } +} + +export namespace Ranges { + export function create( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + ): Range { + return { + start: Positions.create(startLine, startCharacter), + end: Positions.create(endLine, endCharacter), + }; + } + + export function forSubstring(document: TextDocument, subText: string): Range { + const index = document.getText().indexOf(subText); + return { + start: document.positionAt(index), + end: document.positionAt(index + subText.length), + }; + } + + export function afterSubstring( + document: TextDocument, + subText: string, + ): Range { + const pos = Positions.afterSubstring(document, subText); + return { start: pos, end: pos }; + } +} + +export namespace TextEdits { + export function replace(range: Range, newText: string): TextEdit { + return { range, newText }; + } + export function insert(position: Position, newText: string): TextEdit { + return { range: { start: position, end: position }, newText }; + } + export function del(range: Range): TextEdit { + return { range, newText: "" }; + } +} diff --git a/textdocument/test/textdocument_test.ts b/textdocument/test/textdocument_test.ts new file mode 100644 index 0000000..052d191 --- /dev/null +++ b/textdocument/test/textdocument_test.ts @@ -0,0 +1,565 @@ +import { + assertEquals, + assertThrows, + assertStrictEq, +} from "https://deno.land/std/testing/asserts.ts"; + +import { TextDocument } from "../mod.ts"; +import { + Positions, + Ranges, +} from "./helper.ts"; + +function newDocument(str: string) { + return TextDocument.create("file://foo/bar", "text", 0, str); +} + +Deno.test("Text Document Lines Model Validator - Empty content", () => { + const str = ""; + const document = newDocument(str); + assertEquals(document.lineCount, 1); + assertEquals(document.offsetAt(Positions.create(0, 0)), 0); + assertEquals(document.positionAt(0), Positions.create(0, 0)); +}); + +Deno.test("Text Document Lines Model Validator - Single line", () => { + const str = "Hello World"; + const document = newDocument(str); + assertEquals(document.lineCount, 1); + + for (let i = 0; i < str.length; i++) { + assertEquals(document.offsetAt(Positions.create(0, i)), i); + assertEquals(document.positionAt(i), Positions.create(0, i)); + } +}); + +Deno.test("Text Document Lines Model Validator - Multiple lines", () => { + const str = "ABCDE\nFGHIJ\nKLMNO\n"; + const document = newDocument(str); + assertEquals(document.lineCount, 4); + + for (let i = 0; i < str.length; i++) { + const line = Math.floor(i / 6); + const column = i % 6; + + assertEquals(document.offsetAt(Positions.create(line, column)), i); + assertEquals(document.positionAt(i), Positions.create(line, column)); + } + + assertEquals(document.offsetAt(Positions.create(3, 0)), 18); + assertEquals(document.offsetAt(Positions.create(3, 1)), 18); + assertEquals(document.positionAt(18), Positions.create(3, 0)); + assertEquals(document.positionAt(19), Positions.create(3, 0)); +}); + +Deno.test("Text Document Lines Model Validator - Starts with new-line", () => { + const document = newDocument("\nABCDE"); + assertEquals(document.lineCount, 2); + assertEquals(document.positionAt(0), Positions.create(0, 0)); + assertEquals(document.positionAt(1), Positions.create(1, 0)); + assertEquals(document.positionAt(6), Positions.create(1, 5)); +}); + +Deno.test("Text Document Lines Model Validator - New line characters", () => { + let str = "ABCDE\rFGHIJ"; + assertEquals(newDocument(str).lineCount, 2); + + str = "ABCDE\nFGHIJ"; + assertEquals(newDocument(str).lineCount, 2); + + str = "ABCDE\r\nFGHIJ"; + assertEquals(newDocument(str).lineCount, 2); + + str = "ABCDE\n\nFGHIJ"; + assertEquals(newDocument(str).lineCount, 3); + + str = "ABCDE\r\rFGHIJ"; + assertEquals(newDocument(str).lineCount, 3); + + str = "ABCDE\n\rFGHIJ"; + assertEquals(newDocument(str).lineCount, 3); +}); + +Deno.test("Text Document Lines Model Validator - getText(Range)", () => { + const str = "12345\n12345\n12345"; + const document = newDocument(str); + assertEquals(document.getText(), str); + assertEquals(document.getText(Ranges.create(-1, 0, 0, 5)), "12345"); + assertEquals(document.getText(Ranges.create(0, 0, 0, 5)), "12345"); + assertEquals(document.getText(Ranges.create(0, 4, 1, 1)), "5\n1"); + assertEquals(document.getText(Ranges.create(0, 4, 2, 1)), "5\n12345\n1"); + assertEquals(document.getText(Ranges.create(0, 4, 3, 1)), "5\n12345\n12345"); + assertEquals(document.getText(Ranges.create(0, 0, 3, 5)), str); +}); + +Deno.test("Text Document Lines Model Validator - Invalid inputs", () => { + const str = "Hello World"; + const document = newDocument(str); + + // invalid position + assertEquals(document.offsetAt(Positions.create(0, str.length)), str.length); + assertEquals( + document.offsetAt(Positions.create(0, str.length + 3)), + str.length, + ); + assertEquals(document.offsetAt(Positions.create(2, 3)), str.length); + assertEquals(document.offsetAt(Positions.create(-1, 3)), 0); + assertEquals(document.offsetAt(Positions.create(0, -3)), 0); + assertEquals(document.offsetAt(Positions.create(1, -3)), str.length); + + // invalid offsets + assertEquals(document.positionAt(-1), Positions.create(0, 0)); + assertEquals( + document.positionAt(str.length), + Positions.create(0, str.length), + ); + assertEquals( + document.positionAt(str.length + 3), + Positions.create(0, str.length), + ); +}); + +Deno.test("Text Document Full Updates - One full update", () => { + const document = newDocument("abc123"); + TextDocument.update(document, [{ text: "efg456" }], 1); + assertStrictEq(document.version, 1); + assertStrictEq(document.getText(), "efg456"); +}); + +Deno.test("Text Document Full Updates - Several full content updates", () => { + const document = newDocument("abc123"); + TextDocument.update(document, [{ text: "hello" }, { text: "world" }], 2); + assertStrictEq(document.version, 2); + assertStrictEq(document.getText(), "world"); +}); + +// assumes that only '\n' is used +function assertValidLineNumbers(doc: TextDocument) { + const text = doc.getText(); + let expectedLineNumber = 0; + for (let i = 0; i < text.length; i++) { + assertEquals(doc.positionAt(i).line, expectedLineNumber); + const ch = text[i]; + if (ch === "\n") { + expectedLineNumber++; + } + } + assertEquals(doc.positionAt(text.length).line, expectedLineNumber); +} + +Deno.test("Text Document Incremental Updates - Incrementally removing content", () => { + const document = newDocument( + 'function abc() {\n console.log("hello, world!");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ text: "", range: Ranges.forSubstring(document, "hello, world!") }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq(document.getText(), 'function abc() {\n console.log("");\n}'); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally removing multi-line content", () => { + const document = newDocument("function abc() {\n foo();\n bar();\n \n}"); + assertEquals(document.lineCount, 5); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ + text: "", + range: Ranges.forSubstring(document, " foo();\n bar();\n"), + }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq(document.getText(), "function abc() {\n \n}"); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally removing multi-line content 2", () => { + const document = newDocument("function abc() {\n foo();\n bar();\n \n}"); + assertEquals(document.lineCount, 5); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ text: "", range: Ranges.forSubstring(document, "foo();\n bar();") }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq(document.getText(), "function abc() {\n \n \n}"); + assertEquals(document.lineCount, 4); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally adding content", () => { + const document = newDocument('function abc() {\n console.log("hello");\n}'); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ text: ", world!", range: Ranges.afterSubstring(document, "hello") }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq( + document.getText(), + 'function abc() {\n console.log("hello, world!");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally adding multi-line content", () => { + const document = newDocument( + "function abc() {\n while (true) {\n foo();\n };\n}", + ); + assertEquals(document.lineCount, 5); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ + text: "\n bar();", + range: Ranges.afterSubstring(document, "foo();"), + }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq( + document.getText(), + "function abc() {\n while (true) {\n foo();\n bar();\n };\n}", + ); + assertEquals(document.lineCount, 6); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally replacing single-line content, more chars", () => { + const document = newDocument( + 'function abc() {\n console.log("hello, world!");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ + text: "hello, test case!!!", + range: Ranges.forSubstring(document, "hello, world!"), + }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq( + document.getText(), + 'function abc() {\n console.log("hello, test case!!!");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally replacing single-line content, less chars", () => { + const document = newDocument( + 'function abc() {\n console.log("hello, world!");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ text: "hey", range: Ranges.forSubstring(document, "hello, world!") }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq( + document.getText(), + 'function abc() {\n console.log("hey");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally replacing single-line content, same num of chars", () => { + const document = newDocument( + 'function abc() {\n console.log("hello, world!");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ + text: "world, hello!", + range: Ranges.forSubstring(document, "hello, world!"), + }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq( + document.getText(), + 'function abc() {\n console.log("world, hello!");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally replacing multi-line content, more lines", () => { + const document = newDocument( + 'function abc() {\n console.log("hello, world!");\n}', + ); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ + text: "\n//hello\nfunction d(){", + range: Ranges.forSubstring(document, "function abc() {"), + }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq( + document.getText(), + '\n//hello\nfunction d(){\n console.log("hello, world!");\n}', + ); + assertEquals(document.lineCount, 5); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally replacing multi-line content, less lines", () => { + const document = newDocument("a1\nb1\na2\nb2\na3\nb3\na4\nb4\n"); + assertEquals(document.lineCount, 9); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ + text: "xx\nyy", + range: Ranges.forSubstring(document, "\na3\nb3\na4\nb4\n"), + }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq(document.getText(), "a1\nb1\na2\nb2xx\nyy"); + assertEquals(document.lineCount, 5); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally replacing multi-line content, same num of lines and chars", () => { + const document = newDocument("a1\nb1\na2\nb2\na3\nb3\na4\nb4\n"); + assertEquals(document.lineCount, 9); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ + text: "\nxx1\nxx2", + range: Ranges.forSubstring(document, "a2\nb2\na3"), + }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq(document.getText(), "a1\nb1\n\nxx1\nxx2\nb3\na4\nb4\n"); + assertEquals(document.lineCount, 9); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally replacing multi-line content, same num of lines but diff chars", () => { + const document = newDocument("a1\nb1\na2\nb2\na3\nb3\na4\nb4\n"); + assertEquals(document.lineCount, 9); + assertValidLineNumbers(document); + TextDocument.update( + document, + [{ text: "\ny\n", range: Ranges.forSubstring(document, "a2\nb2\na3") }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq(document.getText(), "a1\nb1\n\ny\n\nb3\na4\nb4\n"); + assertEquals(document.lineCount, 9); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Incrementally replacing multi-line content, huge number of lines", () => { + const document = newDocument("a1\ncc\nb1"); + assertEquals(document.lineCount, 3); + assertValidLineNumbers(document); + const text = new Array(20000).join("\ndd"); // a string with 19999 `\n` + TextDocument.update( + document, + [{ text, range: Ranges.forSubstring(document, "\ncc") }], + 1, + ); + assertStrictEq(document.version, 1); + assertStrictEq(document.getText(), "a1" + text + "\nb1"); + assertEquals(document.lineCount, 20001); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Several incremental content changes", () => { + const document = newDocument( + 'function abc() {\n console.log("hello, world!");\n}', + ); + TextDocument.update(document, [ + { text: "defg", range: Ranges.create(0, 12, 0, 12) }, + { text: "hello, test case!!!", range: Ranges.create(1, 15, 1, 28) }, + { text: "hij", range: Ranges.create(0, 16, 0, 16) }, + ], 1); + assertStrictEq(document.version, 1); + assertStrictEq( + document.getText(), + 'function abcdefghij() {\n console.log("hello, test case!!!");\n}', + ); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Basic append", () => { + let document = newDocument("foooo\nbar\nbaz"); + + assertEquals(document.offsetAt(Positions.create(2, 0)), 10); + + TextDocument.update( + document, + [{ text: " some extra content", range: Ranges.create(1, 3, 1, 3) }], + 1, + ); + assertEquals(document.getText(), "foooo\nbar some extra content\nbaz"); + assertEquals(document.version, 1); + assertEquals(document.offsetAt(Positions.create(2, 0)), 29); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Multi-line append", () => { + let document = newDocument("foooo\nbar\nbaz"); + + assertEquals(document.offsetAt(Positions.create(2, 0)), 10); + + TextDocument.update( + document, + [{ text: " some extra\ncontent", range: Ranges.create(1, 3, 1, 3) }], + 1, + ); + assertEquals(document.getText(), "foooo\nbar some extra\ncontent\nbaz"); + assertEquals(document.version, 1); + assertEquals(document.offsetAt(Positions.create(3, 0)), 29); + assertEquals(document.lineCount, 4); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Basic delete", () => { + let document = newDocument("foooo\nbar\nbaz"); + + assertEquals(document.offsetAt(Positions.create(2, 0)), 10); + + TextDocument.update( + document, + [{ text: "", range: Ranges.create(1, 0, 1, 3) }], + 1, + ); + assertEquals(document.getText(), "foooo\n\nbaz"); + assertEquals(document.version, 1); + assertEquals(document.offsetAt(Positions.create(2, 0)), 7); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Multi-line delete", () => { + let lm = newDocument("foooo\nbar\nbaz"); + + assertEquals(lm.offsetAt(Positions.create(2, 0)), 10); + + TextDocument.update(lm, [{ text: "", range: Ranges.create(0, 5, 1, 3) }], 1); + assertEquals(lm.getText(), "foooo\nbaz"); + assertEquals(lm.version, 1); + assertEquals(lm.offsetAt(Positions.create(1, 0)), 6); + assertValidLineNumbers(lm); +}); + +Deno.test("Text Document Incremental Updates - Single character replace", () => { + let document = newDocument("foooo\nbar\nbaz"); + + assertEquals(document.offsetAt(Positions.create(2, 0)), 10); + + TextDocument.update( + document, + [{ text: "z", range: Ranges.create(1, 2, 1, 3) }], + 2, + ); + assertEquals(document.getText(), "foooo\nbaz\nbaz"); + assertEquals(document.version, 2); + assertEquals(document.offsetAt(Positions.create(2, 0)), 10); + assertValidLineNumbers(document); +}); + +Deno.test("Text Document Incremental Updates - Multi-character replace", () => { + let lm = newDocument("foo\nbar"); + + assertEquals(lm.offsetAt(Positions.create(1, 0)), 4); + + TextDocument.update( + lm, + [{ text: "foobar", range: Ranges.create(1, 0, 1, 3) }], + 1, + ); + assertEquals(lm.getText(), "foo\nfoobar"); + assertEquals(lm.version, 1); + assertEquals(lm.offsetAt(Positions.create(1, 0)), 4); + assertValidLineNumbers(lm); +}); + +Deno.test("Text Document Incremental Updates - Invalid update ranges", () => { + // Before the document starts -> before the document starts + let document = newDocument("foo\nbar"); + TextDocument.update( + document, + [{ text: "abc123", range: Ranges.create(-2, 0, -1, 3) }], + 2, + ); + assertEquals(document.getText(), "abc123foo\nbar"); + assertEquals(document.version, 2); + assertValidLineNumbers(document); + + // Before the document starts -> the middle of document + document = newDocument("foo\nbar"); + TextDocument.update( + document, + [{ text: "foobar", range: Ranges.create(-1, 0, 0, 3) }], + 2, + ); + assertEquals(document.getText(), "foobar\nbar"); + assertEquals(document.version, 2); + assertEquals(document.offsetAt(Positions.create(1, 0)), 7); + assertValidLineNumbers(document); + + // The middle of document -> after the document ends + document = newDocument("foo\nbar"); + TextDocument.update( + document, + [{ text: "foobar", range: Ranges.create(1, 0, 1, 10) }], + 2, + ); + assertEquals(document.getText(), "foo\nfoobar"); + assertEquals(document.version, 2); + assertEquals(document.offsetAt(Positions.create(1, 1000)), 10); + assertValidLineNumbers(document); + + // After the document ends -> after the document ends + document = newDocument("foo\nbar"); + TextDocument.update( + document, + [{ text: "abc123", range: Ranges.create(3, 0, 6, 10) }], + 2, + ); + assertEquals(document.getText(), "foo\nbarabc123"); + assertEquals(document.version, 2); + assertValidLineNumbers(document); + + // Before the document starts -> after the document ends + document = newDocument("foo\nbar"); + TextDocument.update( + document, + [{ text: "entirely new content", range: Ranges.create(-1, 1, 2, 10000) }], + 2, + ); + assertEquals(document.getText(), "entirely new content"); + assertEquals(document.version, 2); + assertEquals(document.lineCount, 1); + assertValidLineNumbers(document); +}); diff --git a/types/edits_test.ts b/types/test/edits_test.ts similarity index 98% rename from types/edits_test.ts rename to types/test/edits_test.ts index e6df4a3..4392107 100644 --- a/types/edits_test.ts +++ b/types/test/edits_test.ts @@ -2,7 +2,7 @@ import { assertEquals, assertThrows, } from "https://deno.land/std/testing/asserts.ts"; -import { TextDocument, TextEdit, Position, Range } from "./mod.ts"; +import { TextDocument, TextEdit, Position, Range } from "../mod.ts"; const applyEdits = TextDocument.applyEdits; diff --git a/types/textdocument_test.ts b/types/test/textdocument_test.ts similarity index 96% rename from types/textdocument_test.ts rename to types/test/textdocument_test.ts index 2134742..0a408ba 100644 --- a/types/textdocument_test.ts +++ b/types/test/textdocument_test.ts @@ -1,7 +1,9 @@ import { - assertEquals, assertStrictEq, assertThrows, + assertEquals, + assertStrictEq, + assertThrows, } from "https://deno.land/std/testing/asserts.ts"; -import { TextDocument, Range, Position } from "./mod.ts"; +import { TextDocument, Range, Position } from "../mod.ts"; function newDocument(str: string): TextDocument { return TextDocument.create("file://foo/bar", "text", 0, str); diff --git a/types/typeguards_test.ts b/types/test/typeguards_test.ts similarity index 99% rename from types/typeguards_test.ts rename to types/test/typeguards_test.ts index b9569d5..6f0448d 100644 --- a/types/typeguards_test.ts +++ b/types/test/typeguards_test.ts @@ -1,5 +1,5 @@ import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; -import { Range, Position, Hover, MarkedString, TextEdit } from "./mod.ts"; +import { Range, Position, Hover, MarkedString, TextEdit } from "../mod.ts"; const { test } = Deno;