Skip to content

In memory text document #2520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jul 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions packages/common/src/ide/inMemoryTextDocument/InMemoryTextDocument.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
92 changes: 92 additions & 0 deletions packages/common/src/ide/inMemoryTextDocument/performEdits.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading