// ***************************************************************************** // Copyright (C) 2018 Red Hat, Inc. and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import * as theia from '@theia/plugin'; import { ModelChangedEvent, DocumentsMain } from '../common/plugin-api-rpc'; import { Range as ARange } from '../common/plugin-api-rpc-model'; import { EndOfLine, Position, Range, URI } from './types-impl'; import { ok } from '../common/assert'; import { PrefixSumComputer } from './prefix-sum-computer'; import { getWordAtText, ensureValidWordDefinition } from './word-helper'; const _modeId2WordDefinition = new Map(); export function setWordDefinitionFor(modeId: string, wordDefinition: RegExp | null): void { _modeId2WordDefinition.set(modeId, wordDefinition); } export function getWordDefinitionFor(modeId: string): RegExp { return _modeId2WordDefinition.get(modeId)!; } export class DocumentDataExt { private disposed = false; private dirty: boolean; private encoding: string; private _document: theia.TextDocument; private textLines = new Array(); private lineStarts: PrefixSumComputer | undefined; constructor(private proxy: DocumentsMain, private uri: URI, private lines: string[], private eol: string, private languageId: string, private versionId: number, isDirty: boolean, encoding: string) { this.dirty = isDirty; this.encoding = encoding; } dispose(): void { ok(!this.disposed); this.dirty = false; this.disposed = true; } onEvents(e: ModelChangedEvent): void { if (e.eol && e.eol !== this.eol) { this.eol = e.eol; this.lineStarts = undefined; } // Update my lines const changes = e.changes; // tslint:disable-next-line:one-variable-per-declaration for (let i = 0, len = changes.length; i < len; i++) { const change = changes[i]; this.acceptDeleteRange(change.range); this.acceptInsertText(new Position(change.range.startLineNumber, change.range.startColumn), change.text); } this.versionId = e.versionId; } acceptIsDirty(isDirty: boolean): void { ok(!this.disposed); this.dirty = isDirty; } acceptLanguageId(langId: string): void { ok(!this.disposed); this.languageId = langId; } acceptEncoding(encoding: string): void { ok(!this.disposed); this.encoding = encoding; } get document(): theia.TextDocument { if (!this._document) { const that = this; this._document = { get uri(): theia.Uri { return that.uri; }, get fileName(): string { return that.uri.fsPath; }, get isUntitled(): boolean { return that.uri.scheme === 'untitled'; }, get languageId(): string { return that.languageId; }, get version(): number { return that.versionId; }, get isClosed(): boolean { return that.disposed; }, get isDirty(): boolean { return that.dirty; }, get encoding(): string { return that.encoding; }, save(): Promise { return that.save(); }, getText(range?): string { return range ? that.getTextInRange(range) : that.getText(); }, get eol(): theia.EndOfLine { return that.eol === '\n' ? EndOfLine.LF : EndOfLine.CRLF; }, get lineCount(): number { return that.lines.length; }, lineAt(lineOrPos: number | theia.Position): theia.TextLine { return that.lineAt(lineOrPos); }, offsetAt(pos): number { return that.offsetAt(pos); }, positionAt(offset): theia.Position { return that.positionAt(offset); }, validateRange(ran): theia.Range { return that.validateRange(ran); }, validatePosition(pos): theia.Position { return that.validatePosition(pos); }, getWordRangeAtPosition(pos, regexp?): theia.Range | undefined { return that.getWordRangeAtPosition(pos, regexp); } }; } return Object.freeze(this._document); } private acceptInsertText(position: Position, insertText: string): void { if (insertText.length === 0) { // Nothing to insert return; } const insertLines = insertText.split(/\r\n|\r|\n/); if (insertLines.length === 1) { // Inserting text on one line this.setLineText(position.line - 1, this.lines[position.line - 1].substring(0, position.character - 1) + insertLines[0] + this.lines[position.line - 1].substring(position.character - 1) ); return; } // Append overflowing text from first line to the end of text to insert insertLines[insertLines.length - 1] += this.lines[position.line - 1].substring(position.character - 1); // Delete overflowing text from first line and insert text on first line this.setLineText(position.line - 1, this.lines[position.line - 1].substring(0, position.character - 1) + insertLines[0] ); // Insert new lines & store lengths const newLengths = new Uint32Array(insertLines.length - 1); for (let i = 1; i < insertLines.length; i++) { this.lines.splice(position.line + i - 1, 0, insertLines[i]); newLengths[i - 1] = insertLines[i].length + this.eol.length; } if (this.lineStarts) { // update prefix sum this.lineStarts.insertValues(position.line, newLengths); } } private acceptDeleteRange(range: ARange): void { if (range.startLineNumber === range.endLineNumber) { if (range.startColumn === range.endColumn) { // Nothing to delete return; } // Delete text on the affected line this.setLineText(range.startLineNumber - 1, this.lines[range.startLineNumber - 1].substring(0, range.startColumn - 1) + this.lines[range.startLineNumber - 1].substring(range.endColumn - 1) ); return; } // Take remaining text on last line and append it to remaining text on first line this.setLineText(range.startLineNumber - 1, this.lines[range.startLineNumber - 1].substring(0, range.startColumn - 1) + this.lines[range.endLineNumber - 1].substring(range.endColumn - 1) ); // Delete middle lines this.lines.splice(range.startLineNumber, range.endLineNumber - range.startLineNumber); if (this.lineStarts) { this.lineStarts.removeValues(range.startLineNumber, range.endLineNumber - range.startLineNumber); } } private setLineText(lineIndex: number, newValue: string): void { this.lines[lineIndex] = newValue; if (this.lineStarts) { this.lineStarts.changeValue(lineIndex, this.lines[lineIndex].length + this.eol.length); } } private save(): Promise { if (this.disposed) { return Promise.reject(new Error('Document is closed')); } return this.proxy.$trySaveDocument(this.uri); } private getTextInRange(_range: theia.Range): string { const range = this.validateRange(_range); if (range.isEmpty) { return ''; } if (range.isSingleLine) { return this.lines[range.start.line].substring(range.start.character, range.end.character); } const lineEnding = this.eol; const startLineIndex = range.start.line; const endLineIndex = range.end.line; const resultLines: string[] = []; resultLines.push(this.lines[startLineIndex].substring(range.start.character)); for (let i = startLineIndex + 1; i < endLineIndex; i++) { resultLines.push(this.lines[i]); } resultLines.push(this.lines[endLineIndex].substring(0, range.end.character)); return resultLines.join(lineEnding); } private validateRange(range: theia.Range): theia.Range { if (!(range instanceof Range)) { throw new Error('Invalid argument'); } const start = this.validatePosition(range.start); const end = this.validatePosition(range.end); if (start === range.start && end === range.end) { return range; } return new Range(start.line, start.character, end.line, end.character); } private getText(): string { return this.lines.join(this.eol); } private validatePosition(position: theia.Position): theia.Position { if (!(position instanceof Position)) { throw new Error('Invalid argument'); } let { line, character } = position; let hasChanged = false; if (line < 0) { line = 0; character = 0; hasChanged = true; } else if (line >= this.lines.length) { line = this.lines.length - 1; character = this.lines[line].length; hasChanged = true; } else { const maxCharacter = this.lines[line].length; if (character < 0) { character = 0; hasChanged = true; } else if (character > maxCharacter) { character = maxCharacter; hasChanged = true; } } if (!hasChanged) { return position; } return new Position(line, character); } private lineAt(lineOrPosition: number | theia.Position): theia.TextLine { let line: number = -1; if (lineOrPosition instanceof Position) { line = lineOrPosition.line; } else if (typeof lineOrPosition === 'number') { line = lineOrPosition; } if (line < 0 || line >= this.lines.length) { throw new Error('Illegal value for `line`'); } let result = this.textLines[line]; if (!result || result.lineNumber !== line || result.text !== this.lines[line]) { const text = this.lines[line]; const firstNonWhitespaceCharacterIndex = /^(\s*)/.exec(text)![1].length; const range = new Range(line, 0, line, text.length); const rangeIncludingLineBreak = line < this.lines.length - 1 ? new Range(line, 0, line + 1, 0) : range; result = Object.freeze({ lineNumber: line, range, rangeIncludingLineBreak, text, firstNonWhitespaceCharacterIndex, isEmptyOrWhitespace: firstNonWhitespaceCharacterIndex === text.length }); this.textLines[line] = result; } return result; } private offsetAt(position: theia.Position): number { position = this.validatePosition(position); this.ensureLineStarts(); return this.lineStarts!.getAccumulatedValue(position.line - 1) + position.character; } private ensureLineStarts(): void { if (!this.lineStarts) { const eolLength = this.eol.length; const linesLength = this.lines.length; const lineStartValues = new Uint32Array(linesLength); for (let i = 0; i < linesLength; i++) { lineStartValues[i] = this.lines[i].length + eolLength; } this.lineStarts = new PrefixSumComputer(lineStartValues); } } private positionAt(offset: number): theia.Position { offset = Math.floor(offset); offset = Math.max(0, offset); this.ensureLineStarts(); const out = this.lineStarts!.getIndexOf(offset); const lineLength = this.lines[out.index].length; return new Position(out.index, Math.min(out.remainder, lineLength)); } private getWordRangeAtPosition(_position: theia.Position, regexp?: RegExp): theia.Range | undefined { const position = this.validatePosition(_position); if (!regexp) { // use default when custom-regexp isn't provided regexp = getWordDefinitionFor(this.languageId); } else if (regExpLeadsToEndlessLoop(regexp)) { // use default when custom-regexp is bad console.warn(`[getWordRangeAtPosition]: ignoring custom regexp '${regexp.source}' because it matches the empty string.`); regexp = getWordDefinitionFor(this.languageId); } const wordAtText = getWordAtText( position.character + 1, ensureValidWordDefinition(regexp), this.lines[position.line], 0 ); if (wordAtText) { return new Range(position.line, wordAtText.startColumn - 1, position.line, wordAtText.endColumn - 1); } return undefined; } } export function regExpLeadsToEndlessLoop(regexp: RegExp): boolean { // Exit early if it's one of these special cases which are meant to match // against an empty string if (regexp.source === '^' || regexp.source === '^$' || regexp.source === '$' || regexp.source === '^\\s*$') { return false; } // We check against an empty string. If the regular expression doesn't advance // (e.g. ends in an endless loop) it will match an empty string. const match = regexp.exec(''); // eslint-disable-next-line @typescript-eslint/no-explicit-any return (match && regexp.lastIndex === 0)!; }