diff --git a/packages/common/src/ide/inMemoryTextDocument/InMemoryTextDocument.ts b/packages/common/src/ide/inMemoryTextDocument/InMemoryTextDocument.ts new file mode 100644 index 0000000000..c45390588d --- /dev/null +++ b/packages/common/src/ide/inMemoryTextDocument/InMemoryTextDocument.ts @@ -0,0 +1,137 @@ +import type { URI } from "vscode-uri"; +import type { Edit } from "../../types/Edit"; +import { Position } from "../../types/Position"; +import { Range } from "../../types/Range"; +import type { TextDocument } from "../../types/TextDocument"; +import type { TextLine } from "../../types/TextLine"; +import type { TextDocumentContentChangeEvent } from "../types/Events"; +import type { EndOfLine } from "../types/ide.types"; +import { InMemoryTextLine } from "./InMemoryTextLine"; +import { performEdits } from "./performEdits"; + +export class InMemoryTextDocument implements TextDocument { + private _version: number; + private _eol: EndOfLine; + private _text: string; + private _lines: InMemoryTextLine[]; + readonly filename: string; + + constructor( + public readonly uri: URI, + public readonly languageId: string, + text: string, + ) { + this.filename = uri.path.split(/\\|\//g).at(-1) ?? ""; + this._text = ""; + this._eol = "LF"; + this._version = -1; + this._lines = []; + this.setTextInternal(text); + } + + get version(): number { + return this._version; + } + + get lineCount(): number { + return this._lines.length; + } + + get eol(): EndOfLine { + return this._eol; + } + + get text(): string { + return this._text; + } + + get range(): Range { + return new Range(this._lines[0].range.start, this._lines.at(-1)!.range.end); + } + + private setTextInternal(text: string): void { + this._text = text; + this._eol = text.includes("\r\n") ? "CRLF" : "LF"; + this._version++; + this._lines = createLines(text); + } + + lineAt(lineOrPosition: number | Position): TextLine { + const value = + typeof lineOrPosition === "number" ? lineOrPosition : lineOrPosition.line; + const index = Math.min(Math.max(value, 0), this.lineCount - 1); + return this._lines[index]; + } + + offsetAt(position: Position): number { + if (position.isBefore(this._lines[0].range.start)) { + return 0; + } + if (position.isAfter(this._lines.at(-1)!.range.end)) { + return this._text.length; + } + + let offset = 0; + + for (const line of this._lines) { + if (position.line === line.lineNumber) { + return offset + Math.min(position.character, line.range.end.character); + } + offset += line.text.length + line.eolLength; + } + + throw Error(`Couldn't find offset for position ${position}`); + } + + positionAt(offset: number): Position { + if (offset < 0) { + return this._lines[0].range.start; + } + if (offset >= this._text.length) { + return this._lines.at(-1)!.range.end; + } + + let currentOffset = 0; + + for (const line of this._lines) { + if (currentOffset + line.text.length >= offset) { + return new Position(line.lineNumber, offset - currentOffset); + } + currentOffset += line.text.length + line.eolLength; + } + + throw Error(`Couldn't find position for offset ${offset}`); + } + + getText(range?: Range): string { + if (range == null) { + return this.text; + } + const startOffset = this.offsetAt(range.start); + const endOffset = this.offsetAt(range.end); + return this.text.slice(startOffset, endOffset); + } + + edit(edits: Edit[]): TextDocumentContentChangeEvent[] { + const { text, changes } = performEdits(this, edits); + this.setTextInternal(text); + return changes; + } +} + +function createLines(text: string): InMemoryTextLine[] { + const documentParts = text.split(/(\r?\n)/g); + const result: InMemoryTextLine[] = []; + + for (let i = 0; i < documentParts.length; i += 2) { + result.push( + new InMemoryTextLine( + result.length, + documentParts[i], + documentParts[i + 1], + ), + ); + } + + return result; +} diff --git a/packages/common/src/ide/inMemoryTextDocument/InMemoryTextLine.ts b/packages/common/src/ide/inMemoryTextDocument/InMemoryTextLine.ts new file mode 100644 index 0000000000..084d15bd87 --- /dev/null +++ b/packages/common/src/ide/inMemoryTextDocument/InMemoryTextLine.ts @@ -0,0 +1,31 @@ +import { Position } from "../../types/Position"; +import { Range } from "../../types/Range"; +import type { TextLine } from "../../types/TextLine"; +import { getLeadingWhitespace, getTrailingWhitespace } from "../../util/regex"; + +export class InMemoryTextLine implements TextLine { + readonly range: Range; + readonly rangeIncludingLineBreak: Range; + readonly firstNonWhitespaceCharacterIndex: number; + readonly lastNonWhitespaceCharacterIndex: number; + readonly isEmptyOrWhitespace: boolean; + readonly eolLength: number; + + constructor( + public lineNumber: number, + public text: string, + eol: string | undefined, + ) { + this.isEmptyOrWhitespace = /^\s*$/.test(text); + this.eolLength = eol?.length ?? 0; + const start = new Position(lineNumber, 0); + const end = new Position(lineNumber, text.length); + const endIncludingLineBreak = + eol != null ? new Position(lineNumber + 1, 0) : end; + this.firstNonWhitespaceCharacterIndex = getLeadingWhitespace(text).length; + this.lastNonWhitespaceCharacterIndex = + text.length - getTrailingWhitespace(text).length; + this.range = new Range(start, end); + this.rangeIncludingLineBreak = new Range(start, endIncludingLineBreak); + } +} diff --git a/packages/common/src/ide/inMemoryTextDocument/performEdits.ts b/packages/common/src/ide/inMemoryTextDocument/performEdits.ts new file mode 100644 index 0000000000..df3198a119 --- /dev/null +++ b/packages/common/src/ide/inMemoryTextDocument/performEdits.ts @@ -0,0 +1,92 @@ +import type { Edit } from "../../types/Edit"; +import type { Range } from "../../types/Range"; +import type { TextDocument } from "../../types/TextDocument"; +import type { TextDocumentContentChangeEvent } from "../types/Events"; + +/** + * Apply a series of edits to a document. Note that this function does not + * modify the document itself, but rather returns a new string with the edits + * applied. + * + * @param document The document to apply the edits to. + * @param edits The edits to apply. + * @returns An object containing the new text of the document and the changes + * that were made. + */ +export function performEdits(document: TextDocument, edits: readonly Edit[]) { + const changes = createChangeEvents(document, edits); + + let result = document.getText(); + + for (const change of changes) { + const { text, rangeOffset, rangeLength } = change; + + result = + result.slice(0, rangeOffset) + + text + + result.slice(rangeOffset + rangeLength); + } + + return { text: result, changes }; +} + +function createChangeEvents( + document: TextDocument, + edits: readonly Edit[], +): TextDocumentContentChangeEvent[] { + const changes: TextDocumentContentChangeEvent[] = []; + + /** + * Edits sorted in reverse document order so that edits don't interfere with + * each other. + */ + const sortedEdits = edits + .map((edit, index) => ({ edit, index })) + .sort((a, b) => { + // Edits starting at the same position are sorted in reverse given order. + if (a.edit.range.start.isEqual(b.edit.range.start)) { + return b.index - a.index; + } + return b.edit.range.start.compareTo(a.edit.range.start); + }) + .map(({ edit }) => edit); + + for (const edit of sortedEdits) { + const previousChange = changes[changes.length - 1]; + const intersection = previousChange?.range.intersection(edit.range); + + if (intersection != null && !intersection.isEmpty) { + // Overlapping removal ranges are just merged. + if (!previousChange.text && !edit.text) { + changes[changes.length - 1] = createChangeEvent( + document, + previousChange.range.union(edit.range), + "", + ); + continue; + } + + // Overlapping non-removal ranges are not allowed. + throw Error("Overlapping ranges are not allowed!"); + } + + changes.push(createChangeEvent(document, edit.range, edit.text)); + } + + return changes; +} + +function createChangeEvent( + document: TextDocument, + range: Range, + text: string, +): TextDocumentContentChangeEvent { + const start = document.offsetAt(range.start); + const end = document.offsetAt(range.end); + return { + text, + range, + rangeOffset: start, + rangeLength: end - start, + }; +} diff --git a/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocument.test.ts b/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocument.test.ts new file mode 100644 index 0000000000..b1be134f0a --- /dev/null +++ b/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocument.test.ts @@ -0,0 +1,84 @@ +import * as assert from "node:assert"; +import { URI } from "vscode-uri"; +import { Position } from "../../../types/Position"; +import { Range } from "../../../types/Range"; +import { InMemoryTextDocument } from "../InMemoryTextDocument"; +import { createTestDocument } from "./createTestDocument"; + +suite("InMemoryTextDocument", () => { + test("constructor", () => { + const document = createTestDocument("hello\nworld"); + + assert.equal(document.uri.toString(), "cursorless-dummy://dummy/untitled"); + assert.equal(document.languageId, "plaintext"); + assert.equal(document.filename, "untitled"); + assert.equal(document.text, "hello\nworld"); + assert.equal(document.lineCount, 2); + assert.equal(document.eol, "LF"); + assert.equal(document.version, 0); + }); + + test("filename", () => { + const document1 = new InMemoryTextDocument( + URI.parse("cursorless-dummy://dummy/foo.ts"), + "", + "", + ); + const document2 = new InMemoryTextDocument( + URI.file("dummy\\bar.ts"), + "", + "", + ); + + assert.equal(document1.filename, "foo.ts"); + assert.equal(document2.filename, "bar.ts"); + }); + + test("CRLF", () => { + const document = createTestDocument("foo\nbar\r\nbaz"); + + assert.equal(document.lineCount, 3); + assert.equal(document.eol, "CRLF"); + }); + + test("getText", () => { + const document = createTestDocument("foo\nbar\r\nbaz"); + + assert.equal(document.getText(), document.text); + assert.equal(document.getText(document.range), document.text); + assert.equal(document.getText(new Range(0, 0, 5, 0)), document.text); + assert.equal(document.getText(new Range(0, 0, 0, 0)), ""); + assert.equal(document.getText(new Range(0, 0, 0, 3)), "foo"); + assert.equal(document.getText(new Range(1, 0, 2, 0)), "bar\r\n"); + assert.equal(document.getText(new Range(0, 3, 1, 0)), "\n"); + assert.equal(document.getText(new Range(1, 3, 2, 0)), "\r\n"); + }); + + test("offsetAt", () => { + const document = createTestDocument("hello \n world\r\n"); + + assert.equal(document.offsetAt(new Position(-1, 0)), 0); + assert.equal(document.offsetAt(new Position(0, -1)), 0); + assert.equal(document.offsetAt(new Position(0, 0)), 0); + assert.equal(document.offsetAt(new Position(0, 7)), 7); + assert.equal(document.offsetAt(new Position(0, 100)), 7); + assert.equal(document.offsetAt(new Position(1, 0)), 8); + assert.equal(document.offsetAt(new Position(1, 2)), 10); + assert.equal(document.offsetAt(new Position(2, 0)), 17); + assert.equal(document.offsetAt(new Position(2, 0)), document.text.length); + assert.equal(document.offsetAt(new Position(100, 0)), document.text.length); + }); + + test("positionAt", () => { + const document = createTestDocument("hello \n world\r\n"); + + assert.equal(document.positionAt(-1), "0:0"); + assert.equal(document.positionAt(0).toString(), "0:0"); + assert.equal(document.positionAt(7).toString(), "0:7"); + assert.equal(document.positionAt(8).toString(), "1:0"); + assert.equal(document.positionAt(10).toString(), "1:2"); + assert.equal(document.positionAt(17).toString(), "2:0"); + assert.equal(document.positionAt(document.text.length).toString(), "2:0"); + assert.equal(document.positionAt(100).toString(), "2:0"); + }); +}); diff --git a/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocumentEdit.test.ts b/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocumentEdit.test.ts new file mode 100644 index 0000000000..59b01a0844 --- /dev/null +++ b/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocumentEdit.test.ts @@ -0,0 +1,87 @@ +import * as assert from "node:assert"; +import { Range } from "../../.."; +import { createTestDocument } from "./createTestDocument"; + +const text = "hello\nworld"; + +suite("InMemoryTextDocument.edit", () => { + test("change", () => { + const document = createTestDocument(text); + document.edit([{ range: new Range(0, 0, 0, 5), text: "goodbye" }]); + + assert.equal(document.text, "goodbye\nworld"); + }); + + test("remove", () => { + const document = createTestDocument(text); + document.edit([{ range: new Range(0, 0, 1, 0), text: "" }]); + + assert.equal(document.text, "world"); + }); + + test("insert", () => { + const document = createTestDocument(text); + document.edit([{ range: new Range(0, 5, 0, 5), text: "!" }]); + + assert.equal(document.text, "hello!\nworld"); + }); + + test("multiple inserts", () => { + const document = createTestDocument(""); + + const changes = document.edit([ + { range: new Range(0, 0, 0, 0), text: "aaa" }, + { range: new Range(0, 0, 0, 0), text: "bbb" }, + { range: new Range(0, 0, 0, 0), text: "ccc" }, + ]); + + assert.equal(document.text, "aaabbbccc"); + + assert.equal(changes[0].range.toString(), "0:0-0:0"); + assert.equal(changes[0].text, "ccc"); + assert.equal(changes[1].range.toString(), "0:0-0:0"); + assert.equal(changes[1].text, "bbb"); + assert.equal(changes[2].range.toString(), "0:0-0:0"); + assert.equal(changes[2].text, "aaa"); + }); + + test("multiple", () => { + const document = createTestDocument(text); + document.edit([ + { range: new Range(0, 5, 0, 5), text: "!" }, + { range: new Range(1, 0, 1, 1), text: "" }, + { range: new Range(0, 0, 0, 5), text: "goodbye" }, + ]); + + assert.equal(document.text, "goodbye!\norld"); + }); + + test("remove overlapping", () => { + const document = createTestDocument(text); + const changes = document.edit([ + { range: new Range(0, 0, 0, 4), text: "" }, + { range: new Range(0, 1, 1, 1), text: "" }, + ]); + + assert.equal(document.text, "orld"); + assert.equal(changes.length, 1); + assert.equal(changes[0].range.toString(), "0:0-1:1"); + }); + + test("change overlapping", () => { + const document = createTestDocument(text); + + try { + document.edit([ + { range: new Range(0, 0, 0, 4), text: " " }, + { range: new Range(0, 1, 1, 1), text: "" }, + ]); + assert.fail("Expected an error"); + } catch (error) { + assert.equal( + (error as Error).message, + "Overlapping ranges are not allowed!", + ); + } + }); +}); diff --git a/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocumentLineAt.test.ts b/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocumentLineAt.test.ts new file mode 100644 index 0000000000..e31d668e72 --- /dev/null +++ b/packages/common/src/ide/inMemoryTextDocument/test/InMemoryTextDocumentLineAt.test.ts @@ -0,0 +1,74 @@ +import { range } from "lodash-es"; +import * as assert from "node:assert"; +import { createTestDocument } from "./createTestDocument"; + +interface TestCaseFixture { + input: string; + expectedRanges: string; +} + +const textDocumentRangeFixtures: TestCaseFixture[] = [ + { input: "", expectedRanges: "0:0-0:0" }, + { input: "aaa", expectedRanges: "0:0-0:3" }, + { input: "\naaa", expectedRanges: "0:0-0:0, 1:0-1:3" }, + { input: "aaa\n", expectedRanges: "0:0-0:3, 1:0-1:0" }, + { input: "\naaa\n", expectedRanges: "0:0-0:0, 1:0-1:3, 2:0-2:0" }, + { input: "\n", expectedRanges: "0:0-0:0, 1:0-1:0" }, + { input: "\n\n", expectedRanges: "0:0-0:0, 1:0-1:0, 2:0-2:0" }, + { input: "aaa\n\naaa", expectedRanges: "0:0-0:3, 1:0-1:0, 2:0-2:3" }, + { + input: "aaa\nbbb\r\nccc", + expectedRanges: "0:0-0:3, 1:0-1:3, 2:0-2:3", + }, +]; + +suite("InMemoryTextDocument.lineAt", () => { + for (const fixture of textDocumentRangeFixtures) { + test(JSON.stringify(fixture.input), () => { + const document = createTestDocument(fixture.input); + const documentLineRanges = range(document.lineCount) + .map((i) => document.lineAt(i).range.toString()) + .join(", "); + assert.deepEqual(documentLineRanges, fixture.expectedRanges); + }); + } + + test("basic", () => { + const document = createTestDocument("hello \n world\n "); + + assert.equal(document.lineCount, 3); + + const line0 = document.lineAt(0); + const line1 = document.lineAt(1); + const line2 = document.lineAt(2); + const lineUnderflow = document.lineAt(-1); + const lineOverflow = document.lineAt(100); + + assert.equal(line0.lineNumber, 0); + assert.equal(line0.text, "hello "); + assert.equal(line0.isEmptyOrWhitespace, false); + assert.equal(line0.firstNonWhitespaceCharacterIndex, 0); + assert.equal(line0.lastNonWhitespaceCharacterIndex, 5); + assert.equal(line0.range.toString(), "0:0-0:7"); + assert.equal(line0.rangeIncludingLineBreak.toString(), "0:0-1:0"); + + assert.equal(line1.lineNumber, 1); + assert.equal(line1.text, " world"); + assert.equal(line1.isEmptyOrWhitespace, false); + assert.equal(line1.firstNonWhitespaceCharacterIndex, 2); + assert.equal(line1.lastNonWhitespaceCharacterIndex, 7); + assert.equal(line1.range.toString(), "1:0-1:7"); + assert.equal(line1.rangeIncludingLineBreak.toString(), "1:0-2:0"); + + assert.equal(line2.lineNumber, 2); + assert.equal(line2.text, " "); + assert.equal(line2.isEmptyOrWhitespace, true); + assert.equal(line2.firstNonWhitespaceCharacterIndex, 2); + assert.equal(line2.lastNonWhitespaceCharacterIndex, 0); + assert.equal(line2.range.toString(), "2:0-2:2"); + assert.equal(line2.rangeIncludingLineBreak.toString(), "2:0-2:2"); + + assert.equal(lineUnderflow.lineNumber, 0); + assert.equal(lineOverflow.lineNumber, 2); + }); +}); diff --git a/packages/common/src/ide/inMemoryTextDocument/test/createTestDocument.ts b/packages/common/src/ide/inMemoryTextDocument/test/createTestDocument.ts new file mode 100644 index 0000000000..e1332d91d0 --- /dev/null +++ b/packages/common/src/ide/inMemoryTextDocument/test/createTestDocument.ts @@ -0,0 +1,10 @@ +import { URI } from "vscode-uri"; +import { InMemoryTextDocument } from "../InMemoryTextDocument"; + +export function createTestDocument(text: string): InMemoryTextDocument { + return new InMemoryTextDocument( + URI.parse("cursorless-dummy://dummy/untitled"), + "plaintext", + text, + ); +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index a1711fbc7b..d1142b9e6c 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -30,6 +30,7 @@ export * from "./ide/types/Paths"; export * from "./ide/types/CommandHistoryStorage"; export * from "./ide/types/RawTreeSitterQueryProvider"; export * from "./ide/types/FileSystem.types"; +export * from "./ide/inMemoryTextDocument/InMemoryTextDocument"; export * from "./types/RangeExpansionBehavior"; export * from "./types/InputBoxOptions"; export * from "./types/Position";