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 { 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 { 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, 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, 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): 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 { // 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 { 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 { 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 { return this.highlightKeywords( i, syntax.primaryKeywords, HighlightType.Keyword1, ); } protected highlightSecondaryKeywords( i: number, syntax: FileType, ): Option { return this.highlightKeywords( i, syntax.secondaryKeywords, HighlightType.Keyword2, ); } protected highlightOperators( i: number, syntax: FileType, ): Option { // 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 { // 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 { 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 { // 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;