scroll/src/common/row.ts
Timothy J. Warren 1a8d9f5469
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
Add operator highlighting, partially fix search
2024-07-17 16:23:06 -04:00

597 lines
13 KiB
JavaScript

import Ansi from './ansi.ts';
import { SCROLL_TAB_SIZE } from './config.ts';
import {
arrayInsert,
isAsciiDigit,
isSeparator,
strChars,
strlen,
} 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[] = [];
private constructor(s: string | string[] = '') {
this.chars = Array.isArray(s) ? s : strChars(s);
this.rchars = [];
}
public get size(): number {
return this.chars.length;
}
public get rsize(): number {
return this.rchars.length;
}
public rstring(offset: number = 0): string {
return this.rchars.slice(offset).join('');
}
public static default(): Row {
return new Row();
}
public static from(s: string | string[] | Row): Row {
if (s instanceof Row) {
return s;
}
return new Row(s);
}
public append(s: string, syntax: FileType): void {
this.chars = this.chars.concat(strChars(s));
this.update(None, syntax);
}
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 '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)));
}
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;
}
public rLastIndexOf(s: string, offset: number = 0): Option<number> {
const rstring = this.rchars.join('');
const byteIndex = rstring.lastIndexOf(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,
);
}
public toString(): string {
return this.chars.join('');
}
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);
}
public highlight(
word: Option<string>,
syntax: FileType,
startWithComment: boolean,
): boolean {
this.hl = [];
let i = 0;
let inMlComment = startWithComment;
if (inMlComment && syntax.hasMultilineComments()) {
const maybEnd = this.rIndexOf(syntax.multiLineCommentEnd.unwrap(), i);
const closingIndex = (maybEnd.isSome())
? maybEnd.unwrap() + 1
: this.rsize;
for (; i < closingIndex; i++) {
this.hl.push(HighlightType.MultiLineComment);
}
}
for (; i < this.rsize;) {
const ch = this.rchars[i];
const maybeMultiline = this.highlightMultilineComment(i, syntax, ch);
if (maybeMultiline.isSome()) {
inMlComment = true;
i = maybeMultiline.unwrap();
continue;
}
inMlComment = false;
const maybeNext = this.highlightComment(i, syntax, ch)
.orElse(() => this.highlightPrimaryKeywords(i, syntax))
.orElse(() => this.highlightSecondaryKeywords(i, syntax))
.orElse(() => this.highlightString(i, syntax, ch))
.orElse(() => this.highlightNumber(i, syntax, ch))
.orElse(() => this.highlightOperators(i, syntax));
if (maybeNext.isSome()) {
const next = maybeNext.unwrap();
if (next < this.rsize) {
i = maybeNext.unwrap();
continue;
}
break;
}
this.hl.push(HighlightType.None);
i += 1;
}
this.highlightMatch(word);
if (inMlComment && syntax.hasMultilineComments()) {
const commentEnd = syntax.multiLineCommentEnd.unwrap();
const maybeIndex = this.rLastIndexOf(commentEnd);
if (maybeIndex.isNone()) {
return true;
}
const lastIndex = maybeIndex.unwrap();
return lastIndex !== this.rsize - 2;
}
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,
_ch: string,
): 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;
}
protected 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);
}
protected 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,
ch: string,
): Option<number> {
// Highlight strings
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,
ch: string,
): Option<number> {
if (!syntax.hasMultilineComments()) {
return None;
}
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,
ch: string,
): Option<number> {
// Highlight numbers
if (syntax.flags.numbers && isAsciiDigit(ch)) {
if (i > 0 && !isSeparator(this.rchars[i - 1])) {
return None;
}
while (true) {
this.hl.push(HighlightType.Number);
i += 1;
if (i >= this.rsize) {
break;
}
const nextChar = this.rchars[i];
// deno-fmt-ignore
const validChars = [
// Decimal
'.',
// Octal Notation
'o','O',
// Hex Notation
'x','X',
// Hex digits
'a','A','c','C','d','D','e','E','f','F',
// Binary Notation/Hex digit
'b','B',
// BigInt
'n',
];
if (
!(validChars.includes(nextChar) || isAsciiDigit(nextChar))
) {
break;
}
}
return Some(i);
}
return None;
}
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;