All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
653 lines
14 KiB
JavaScript
653 lines
14 KiB
JavaScript
import Ansi from './ansi.ts';
|
|
|
|
import { SCROLL_TAB_SIZE } from './config.ts';
|
|
import {
|
|
arrayInsert,
|
|
isAsciiDigit,
|
|
isSeparator,
|
|
strChars,
|
|
strlen,
|
|
substr,
|
|
} from './fns.ts';
|
|
import { FileType } from './filetype/mod.ts';
|
|
import { highlightToColor, HighlightType } from './highlight.ts';
|
|
import Option, { None, Some } from './option.ts';
|
|
import { SearchDirection } from './types.ts';
|
|
|
|
/**
|
|
* One row of text in the current document. In order to handle
|
|
* multi-byte graphemes, all operations are done on an
|
|
* array of 'character' strings.
|
|
*/
|
|
export class Row {
|
|
/**
|
|
* The actual characters in the current row
|
|
*/
|
|
public chars: string[] = [];
|
|
|
|
/**
|
|
* The characters rendered for the current row
|
|
* (like replacing tabs with spaces)
|
|
*/
|
|
public rchars: string[] = [];
|
|
|
|
/**
|
|
* The syntax highlighting map
|
|
*/
|
|
public hl: HighlightType[] = [];
|
|
|
|
/**
|
|
* Has the current row been highlighted?
|
|
*/
|
|
public isHighlighted: boolean = false;
|
|
|
|
private constructor(s: string | string[] = '') {
|
|
this.chars = Array.isArray(s) ? s : strChars(s);
|
|
this.rchars = [];
|
|
}
|
|
|
|
/**
|
|
* Get the number of 'characters' in this row
|
|
*/
|
|
public get size(): number {
|
|
return this.chars.length;
|
|
}
|
|
|
|
/**
|
|
* Get the number of 'characters' in the 'render' array
|
|
*/
|
|
public get rsize(): number {
|
|
return this.rchars.length;
|
|
}
|
|
|
|
/**
|
|
* Get the 'render' string
|
|
*/
|
|
public rstring(offset: number = 0): string {
|
|
return this.rchars.slice(offset).join('');
|
|
}
|
|
|
|
/**
|
|
* Create a new empty Row
|
|
*/
|
|
public static default(): Row {
|
|
return new Row();
|
|
}
|
|
|
|
/**
|
|
* Create a new Row
|
|
*/
|
|
public static from(s: string | string[] | Row): Row {
|
|
if (s instanceof Row) {
|
|
return s;
|
|
}
|
|
|
|
return new Row(s);
|
|
}
|
|
|
|
/**
|
|
* Add a character to the end of the current row
|
|
*/
|
|
public append(s: string, syntax: FileType): void {
|
|
this.chars = this.chars.concat(strChars(s));
|
|
this.update(None, syntax);
|
|
}
|
|
|
|
/**
|
|
* Add a character to the current row at the specified location
|
|
*/
|
|
public insertChar(at: number, c: string): void {
|
|
const newSlice = strChars(c);
|
|
if (at >= this.size) {
|
|
this.chars = this.chars.concat(newSlice);
|
|
} else {
|
|
this.chars = arrayInsert(this.chars, at + 1, newSlice);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Truncate the current row, and return a new one at the specified index
|
|
*/
|
|
public split(at: number, syntax: FileType): Row {
|
|
const newRow = new Row(this.chars.slice(at));
|
|
this.chars = this.chars.slice(0, at);
|
|
this.update(None, syntax);
|
|
|
|
return newRow;
|
|
}
|
|
|
|
/**
|
|
* Remove a character at the specified index
|
|
*/
|
|
public delete(at: number): void {
|
|
if (at >= this.size) {
|
|
return;
|
|
}
|
|
|
|
this.chars.splice(at, 1);
|
|
}
|
|
|
|
/**
|
|
* Search the current row for the specified string, and return
|
|
* the render 'character' index of the start of that match
|
|
*/
|
|
public find(
|
|
s: string,
|
|
at: number = 0,
|
|
direction: SearchDirection = SearchDirection.Forward,
|
|
): Option<number> {
|
|
if (at > this.size) {
|
|
return None;
|
|
}
|
|
const thisStr = this.chars.join('');
|
|
|
|
// Look for the search query `s`, starting from the 'character' `offset`
|
|
const byteIndex = (direction === SearchDirection.Forward)
|
|
? thisStr.indexOf(s, this.charIndexToByteIndex(at))
|
|
: thisStr.lastIndexOf(s, this.charIndexToByteIndex(at));
|
|
|
|
// No match after the specified offset
|
|
if (byteIndex < 0) {
|
|
return None;
|
|
}
|
|
|
|
// In many cases, the string length will
|
|
// equal the number of characters. So
|
|
// searching is fairly easy
|
|
if (thisStr.length === this.chars.length) {
|
|
return Some(this.cxToRx(byteIndex));
|
|
}
|
|
|
|
// Emoji/Extended Unicode-friendly search
|
|
return Some(this.cxToRx(this.byteIndexToCharIndex(byteIndex)));
|
|
}
|
|
|
|
/**
|
|
* Search the current Row for the given string, returning the index in
|
|
* the 'render' version
|
|
*/
|
|
public rIndexOf(s: string, offset: number = 0): Option<number> {
|
|
const rstring = this.rchars.join('');
|
|
const byteIndex = rstring.indexOf(s, this.charIndexToByteIndex(offset));
|
|
|
|
return (byteIndex >= 0) ? Some(this.byteIndexToCharIndex(byteIndex)) : None;
|
|
}
|
|
|
|
/**
|
|
* Convert the raw row offset to the equivalent offset for screen rendering
|
|
*/
|
|
public cxToRx(cx: number): number {
|
|
let rx = 0;
|
|
let j;
|
|
for (j = 0; j < cx; j++) {
|
|
if (this.chars[j] === '\t') {
|
|
rx += (SCROLL_TAB_SIZE - 1) - (rx % SCROLL_TAB_SIZE);
|
|
}
|
|
rx++;
|
|
}
|
|
|
|
return rx;
|
|
}
|
|
|
|
/**
|
|
* Convert the screen rendering row offset to the file row offset
|
|
*/
|
|
public rxToCx(rx: number): number {
|
|
let curRx = 0;
|
|
let cx = 0;
|
|
for (; cx < this.size; cx++) {
|
|
if (this.chars[cx] === '\t') {
|
|
curRx += (SCROLL_TAB_SIZE - 1) - (curRx % SCROLL_TAB_SIZE);
|
|
}
|
|
curRx++;
|
|
|
|
if (curRx > rx) {
|
|
return cx;
|
|
}
|
|
}
|
|
|
|
return cx;
|
|
}
|
|
|
|
/**
|
|
* Convert the index of a JS string into the equivalent
|
|
* 'unicode character' index
|
|
*/
|
|
public byteIndexToCharIndex(byteIndex: number): number {
|
|
if (this.toString().length === this.chars.length) {
|
|
return byteIndex;
|
|
}
|
|
|
|
let n = 0;
|
|
let byteCount = 0;
|
|
for (; n < this.chars.length; n++) {
|
|
byteCount += this.chars[n].length;
|
|
if (byteCount > byteIndex) {
|
|
return n;
|
|
}
|
|
}
|
|
|
|
return this.chars.length;
|
|
}
|
|
|
|
/**
|
|
* Convert the 'unicode character' index into the equivalent
|
|
* JS string index
|
|
*/
|
|
public charIndexToByteIndex(charIndex: number): number {
|
|
if (charIndex === 0 || this.toString().length === this.chars.length) {
|
|
return charIndex;
|
|
}
|
|
|
|
// The char index will be the same size or smaller than
|
|
// the JS string index, as a 'character' can consist
|
|
// of multiple JS string indicies
|
|
return this.chars.slice(0, charIndex).reduce(
|
|
(prev, current) => prev + current.length,
|
|
0,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Output the contents of the row
|
|
*/
|
|
public toString(): string {
|
|
return this.chars.join('');
|
|
}
|
|
|
|
/**
|
|
* Setup up the row by converting tabs to spaces for rendering,
|
|
* then setup syntax highlighting
|
|
*/
|
|
public update(
|
|
word: Option<string>,
|
|
syntax: FileType,
|
|
startWithComment: boolean = false,
|
|
): boolean {
|
|
const newString = this.chars.join('').replaceAll(
|
|
'\t',
|
|
' '.repeat(SCROLL_TAB_SIZE),
|
|
);
|
|
|
|
this.rchars = strChars(newString);
|
|
return this.highlight(word, syntax, startWithComment);
|
|
}
|
|
|
|
/**
|
|
* Calculate the syntax types of the current Row
|
|
*/
|
|
public highlight(
|
|
word: Option<string>,
|
|
syntax: FileType,
|
|
startWithComment: boolean,
|
|
): boolean {
|
|
// When the highlighting is already up-to-date
|
|
if (this.isHighlighted && word.isNone()) {
|
|
return false;
|
|
}
|
|
|
|
this.hl = [];
|
|
let i = 0;
|
|
|
|
// Handle the case where we are in a multi-line
|
|
// comment from a previous row
|
|
let inMlComment = startWithComment;
|
|
if (inMlComment && syntax.hasMultilineComments()) {
|
|
const maybeEnd = this.rIndexOf(syntax.multiLineCommentEnd.unwrap(), i);
|
|
const closingIndex = (maybeEnd.isSome())
|
|
? maybeEnd.unwrap() + 2
|
|
: this.rsize;
|
|
|
|
for (; i < closingIndex; i++) {
|
|
this.hl.push(HighlightType.MultiLineComment);
|
|
}
|
|
i = closingIndex;
|
|
}
|
|
|
|
for (; i < this.rsize;) {
|
|
const maybeMultiline = this.highlightMultilineComment(i, syntax);
|
|
if (maybeMultiline.isSome()) {
|
|
inMlComment = true;
|
|
i = maybeMultiline.unwrap();
|
|
continue;
|
|
}
|
|
|
|
inMlComment = false;
|
|
|
|
// Go through the syntax highlighting types in order:
|
|
// If there is a match, we end the chain of syntax types
|
|
// and 'consume' the number of characters that matched
|
|
const maybeNext = this.highlightComment(i, syntax)
|
|
.orElse(() => this.highlightPrimaryKeywords(i, syntax))
|
|
.orElse(() => this.highlightSecondaryKeywords(i, syntax))
|
|
.orElse(() => this.highlightString(i, syntax))
|
|
.orElse(() => this.highlightNumber(i, syntax))
|
|
.orElse(() => this.highlightOperators(i, syntax));
|
|
|
|
if (maybeNext.isSome()) {
|
|
const next = maybeNext.unwrap();
|
|
if (next >= this.rsize) {
|
|
break;
|
|
}
|
|
|
|
i = next;
|
|
continue;
|
|
}
|
|
this.hl.push(HighlightType.None);
|
|
i += 1;
|
|
}
|
|
|
|
this.highlightMatch(word);
|
|
if (inMlComment && syntax.hasMultilineComments()) {
|
|
if (
|
|
substr(this.toString(), this.size - 2) !==
|
|
syntax.multiLineCommentEnd.unwrap()
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
this.isHighlighted = true;
|
|
return false;
|
|
}
|
|
|
|
protected highlightMatch(word: Option<string>): void {
|
|
let searchIndex = 0;
|
|
|
|
// Find matches for the current search
|
|
if (word.isSome()) {
|
|
const query = word.unwrap();
|
|
while (true) {
|
|
const match = this.find(
|
|
query,
|
|
searchIndex,
|
|
SearchDirection.Forward,
|
|
);
|
|
if (match.isNone()) {
|
|
break;
|
|
}
|
|
|
|
const index = match.unwrap();
|
|
const matchSize = strlen(query);
|
|
const nextPossible = index + matchSize;
|
|
if (nextPossible < this.rsize) {
|
|
let i = index;
|
|
for (const _ in strChars(word.unwrap())) {
|
|
this.hl[i] = HighlightType.Match;
|
|
i += 1;
|
|
}
|
|
|
|
searchIndex = nextPossible;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protected highlightComment(
|
|
i: number,
|
|
syntax: FileType,
|
|
): Option<number> {
|
|
// Highlight single-line comments
|
|
if (syntax.singleLineComment.isSome()) {
|
|
const commentStart = syntax.singleLineComment.unwrap();
|
|
if (
|
|
this.toString().indexOf(commentStart) === this.charIndexToByteIndex(i)
|
|
) {
|
|
for (; i < this.rsize; i++) {
|
|
this.hl.push(HighlightType.SingleLineComment);
|
|
}
|
|
|
|
return Some(i);
|
|
}
|
|
}
|
|
|
|
return None;
|
|
}
|
|
|
|
private highlightStr(
|
|
i: number,
|
|
substring: string,
|
|
hl_type: HighlightType,
|
|
): Option<number> {
|
|
if (strlen(substring) === 0) {
|
|
return None;
|
|
}
|
|
|
|
const substringChars = strChars(substring);
|
|
for (const [j, ch] of substringChars.entries()) {
|
|
const nextChar = this.rchars[i + j];
|
|
if (nextChar !== ch) {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
for (const _ of substringChars) {
|
|
this.hl.push(hl_type);
|
|
i += 1;
|
|
}
|
|
|
|
return Some(i);
|
|
}
|
|
|
|
private highlightKeywords(
|
|
i: number,
|
|
keywords: string[],
|
|
hl_type: HighlightType,
|
|
): Option<number> {
|
|
if (i > 0) {
|
|
const prevChar = this.rchars[i - 1];
|
|
if (!isSeparator(prevChar)) {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
for (const keyword of keywords) {
|
|
if (i + strlen(keyword) < this.rsize) {
|
|
const nextChar = this.rchars[i + strlen(keyword)];
|
|
if (!isSeparator(nextChar)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const maybeHighlight = this.highlightStr(i, keyword, hl_type);
|
|
if (maybeHighlight.isSome()) {
|
|
return maybeHighlight;
|
|
}
|
|
}
|
|
|
|
return None;
|
|
}
|
|
|
|
protected highlightPrimaryKeywords(
|
|
i: number,
|
|
syntax: FileType,
|
|
): Option<number> {
|
|
return this.highlightKeywords(
|
|
i,
|
|
syntax.primaryKeywords,
|
|
HighlightType.Keyword1,
|
|
);
|
|
}
|
|
|
|
protected highlightSecondaryKeywords(
|
|
i: number,
|
|
syntax: FileType,
|
|
): Option<number> {
|
|
return this.highlightKeywords(
|
|
i,
|
|
syntax.secondaryKeywords,
|
|
HighlightType.Keyword2,
|
|
);
|
|
}
|
|
|
|
protected highlightOperators(
|
|
i: number,
|
|
syntax: FileType,
|
|
): Option<number> {
|
|
// Search the list of operators
|
|
outer: for (const op of syntax.operators) {
|
|
const chars = strChars(op);
|
|
|
|
// See if this operator (chars[j]) exists at this index
|
|
for (const [j, ch] of chars.entries()) {
|
|
// Make sure the next character of this operator matches too
|
|
const nextChar = this.rchars[i + j];
|
|
if (nextChar !== ch) {
|
|
continue outer;
|
|
}
|
|
}
|
|
|
|
// This operator matches, highlight it
|
|
for (const _ of chars) {
|
|
this.hl.push(HighlightType.Operator);
|
|
i += 1;
|
|
}
|
|
|
|
return Some(i);
|
|
}
|
|
|
|
return None;
|
|
}
|
|
|
|
protected highlightString(
|
|
i: number,
|
|
syntax: FileType,
|
|
): Option<number> {
|
|
// Highlight strings
|
|
const ch = this.rchars[i];
|
|
if (syntax.flags.strings && ch === '"' || ch === "'") {
|
|
while (true) {
|
|
this.hl.push(HighlightType.String);
|
|
i += 1;
|
|
if (i === this.rsize) {
|
|
break;
|
|
}
|
|
|
|
const nextChar = this.rchars[i];
|
|
if (nextChar === ch) {
|
|
break;
|
|
}
|
|
}
|
|
this.hl.push(HighlightType.String);
|
|
i += 1;
|
|
return Some(i);
|
|
}
|
|
|
|
return None;
|
|
}
|
|
|
|
protected highlightMultilineComment(
|
|
i: number,
|
|
syntax: FileType,
|
|
): Option<number> {
|
|
if (!syntax.hasMultilineComments()) {
|
|
return None;
|
|
}
|
|
|
|
const ch = this.rchars[i];
|
|
|
|
const startChars = syntax.multiLineCommentStart.unwrap();
|
|
const endChars = syntax.multiLineCommentEnd.unwrap();
|
|
if (ch === startChars[0] && this.rchars[i + 1] == startChars[1]) {
|
|
const maybeEnd = this.rIndexOf(endChars, i);
|
|
const end = (maybeEnd.isSome())
|
|
? maybeEnd.unwrap() + strlen(endChars) + 2
|
|
: this.rsize;
|
|
|
|
for (; i <= end; i++) {
|
|
this.hl.push(HighlightType.MultiLineComment);
|
|
}
|
|
return Some(i);
|
|
}
|
|
|
|
return None;
|
|
}
|
|
|
|
protected highlightNumber(
|
|
i: number,
|
|
syntax: FileType,
|
|
): Option<number> {
|
|
// Exit early
|
|
const ch = this.rchars[i];
|
|
if (!(syntax.flags.numbers && isAsciiDigit(ch))) {
|
|
return None;
|
|
}
|
|
|
|
// Configure which characters are valid
|
|
// for numbers in the current FileType
|
|
let validChars = ['.'];
|
|
if (syntax.flags.binNumbers) {
|
|
validChars = validChars.concat(['b', 'B']);
|
|
}
|
|
if (syntax.flags.octalNumbers) {
|
|
validChars = validChars.concat(['o', 'O']);
|
|
}
|
|
if (syntax.flags.hexNumbers) {
|
|
// deno-fmt-ignore
|
|
validChars = validChars.concat([
|
|
'a','A',
|
|
'b','B',
|
|
'c','C',
|
|
'd','D',
|
|
'e','E',
|
|
'f','F',
|
|
'x','X',
|
|
]);
|
|
}
|
|
if (syntax.flags.jsBigInt) {
|
|
validChars.push('n');
|
|
}
|
|
|
|
// Number literals are not attached to other syntax
|
|
if (i > 0 && !isSeparator(this.rchars[i - 1])) {
|
|
return None;
|
|
}
|
|
|
|
// Match until the end of the number literal
|
|
while (true) {
|
|
this.hl.push(HighlightType.Number);
|
|
i += 1;
|
|
if (i >= this.rsize) {
|
|
break;
|
|
}
|
|
|
|
const nextChar = this.rchars[i];
|
|
if (
|
|
!(validChars.includes(nextChar) || isAsciiDigit(nextChar))
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return Some(i);
|
|
}
|
|
|
|
/**
|
|
* Return a terminal-formatted version of the current row
|
|
*/
|
|
public render(offset: number, len: number): string {
|
|
const end = Math.min(len, this.rsize);
|
|
const start = Math.min(offset, len);
|
|
let result = '';
|
|
let currentHighlight = HighlightType.None;
|
|
|
|
for (let i = start; i < end; i++) {
|
|
const highlightType = this.hl[i];
|
|
|
|
if (highlightType !== currentHighlight) {
|
|
currentHighlight = highlightType;
|
|
result += highlightToColor(highlightType);
|
|
}
|
|
|
|
result += this.rchars[i];
|
|
}
|
|
|
|
result += Ansi.ResetFormatting;
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
export default Row;
|