From 1b3e9d979674d370c3082faf3932ea39e03bbccf Mon Sep 17 00:00:00 2001 From: "Timothy J. Warren" Date: Wed, 3 Jul 2024 16:13:29 -0400 Subject: [PATCH] Add Option type to remove the need to use null/undefined --- src/common/all_test.ts | 53 ++++++------- src/common/document.ts | 27 ++++--- src/common/editor.ts | 21 +++-- src/common/fns.ts | 18 +---- src/common/option.ts | 175 +++++++++++++++++++++++++++++++++++++++++ src/common/row.ts | 44 +++++++---- src/common/search.ts | 28 ++++--- 7 files changed, 269 insertions(+), 97 deletions(-) create mode 100644 src/common/option.ts diff --git a/src/common/all_test.ts b/src/common/all_test.ts index 1b9b5c1..ac3db47 100644 --- a/src/common/all_test.ts +++ b/src/common/all_test.ts @@ -3,6 +3,7 @@ import Buffer from './buffer.ts'; import Document from './document.ts'; import Editor from './editor.ts'; import { highlightToColor, HighlightType } from './highlight.ts'; +import _Option, { None, Some } from './option.ts'; import Position from './position.ts'; import Row from './row.ts'; @@ -12,11 +13,10 @@ import { getTestRunner } from './runtime.ts'; const { assertStrictEquals: assertEquals, - assertEquals: assertLooseEquals, + assertEquals: assertEquivalent, assertExists, assertInstanceOf, assertNotEquals, - assertNull, assertFalse, assertTrue, testSuite, @@ -30,8 +30,6 @@ const THIS_FILE = './src/common/all_test.ts'; const fnTest = () => { const { - some, - none, arrayInsert, noop, posSub, @@ -48,39 +46,25 @@ const fnTest = () => { } = Fn; return { - 'some()': () => { - assertFalse(some(null)); - assertFalse(some(void 0)); - assertFalse(some(undefined)); - assertTrue(some(0)); - assertTrue(some(false)); - }, - 'none()': () => { - assertTrue(none(null)); - assertTrue(none(void 0)); - assertTrue(none(undefined)); - assertFalse(none(0)); - assertFalse(none(false)); - }, 'arrayInsert() strings': () => { const a = ['😺', '😸', '😹']; const b = arrayInsert(a, 1, 'x'); const c = ['😺', 'x', '😸', '😹']; - assertLooseEquals(b, c); + assertEquivalent(b, c); const d = arrayInsert(c, 17, 'y'); const e = ['😺', 'x', '😸', '😹', 'y']; - assertLooseEquals(d, e); + assertEquivalent(d, e); - assertLooseEquals(arrayInsert([], 0, 'foo'), ['foo']); + assertEquivalent(arrayInsert([], 0, 'foo'), ['foo']); }, 'arrayInsert() numbers': () => { const a = [1, 3, 5]; const b = [1, 3, 4, 5]; - assertLooseEquals(arrayInsert(a, 2, 4), b); + assertEquivalent(arrayInsert(a, 2, 4), b); const c = [1, 2, 3, 4, 5]; - assertLooseEquals(arrayInsert(b, 1, 2), c); + assertEquivalent(arrayInsert(b, 1, 2), c); }, 'noop fn': () => { assertExists(noop); @@ -106,7 +90,7 @@ const fnTest = () => { assertEquals(ord('a'), 97); }, 'strChars() properly splits strings into unicode characters': () => { - assertLooseEquals(strChars('😺😸😹'), ['😺', '😸', '😹']); + assertEquivalent(strChars('😺😸😹'), ['😺', '😸', '😹']); }, 'ctrlKey()': () => { const ctrl_a = ctrlKey('a'); @@ -432,6 +416,12 @@ const EditorTest = { // ---------------------------------------------------------------------------- +const OptionTest = { + // @TODO implement Option tests +}; + +// ---------------------------------------------------------------------------- + const PositionTest = { '.default': () => { const p = Position.default(); @@ -498,12 +488,12 @@ const RowTest = { }, '.find': () => { const normalRow = Row.from('For whom the bell tolls'); - assertEquals(normalRow.find('who'), 4); - assertNull(normalRow.find('foo')); + assertEquivalent(normalRow.find('who'), Some(4)); + assertEquals(normalRow.find('foo'), None); const emojiRow = Row.from('😺😸😹'); - assertEquals(emojiRow.find('😹'), 2); - assertNull(emojiRow.find('🤰🏼')); + assertEquivalent(emojiRow.find('😹'), Some(2)); + assertEquals(emojiRow.find('🤰🏼'), None); }, '.byteIndexToCharIndex': () => { // Each 'character' is two bytes @@ -528,7 +518,7 @@ const RowTest = { }, '.cxToRx, .rxToCx': () => { const row = Row.from('foo\tbar\tbaz'); - row.update(); + row.update(None); assertNotEquals(row.chars, row.rchars); assertNotEquals(row.size, row.rsize); assertEquals(row.size, 11); @@ -545,7 +535,9 @@ const RowTest = { // ---------------------------------------------------------------------------- -const SearchTest = {}; +const SearchTest = { + // @TODO implement Search tests +}; // ---------------------------------------------------------------------------- // Test Suite Setup @@ -559,6 +551,7 @@ testSuite({ Buffer: BufferTest, Document: DocumentTest, Editor: EditorTest, + Option: OptionTest, Position: PositionTest, Row: RowTest, Search: SearchTest, diff --git a/src/common/document.ts b/src/common/document.ts index 9d58841..e45fb82 100644 --- a/src/common/document.ts +++ b/src/common/document.ts @@ -1,6 +1,7 @@ import Row from './row.ts'; -import { arrayInsert, some, strlen } from './fns.ts'; +import { arrayInsert, strlen } from './fns.ts'; import { HighlightType } from './highlight.ts'; +import Option, { None, Some } from './option.ts'; import { getRuntime } from './runtime.ts'; import { Position } from './types.ts'; import { Search } from './search.ts'; @@ -26,7 +27,7 @@ export class Document { public static default(): Document { const self = new Document(); - self.#search.parent = self; + self.#search.parent = Some(self); return self; } @@ -68,15 +69,17 @@ export class Document { public resetFind(): void { this.#search = new Search(); - this.#search.parent = this; + this.#search.parent = Some(this); } public find( q: string, key: string, - ): Position | null { - const potential = this.#search.search(q, key); - if (some(potential) && potential instanceof Position) { + ): Option { + const possible = this.#search.search(q, key); + if (possible.isSome()) { + const potential = possible.unwrap(); + // Update highlight of search match const row = this.#rows[potential.y]; @@ -94,7 +97,7 @@ export class Document { } } - return potential; + return possible; } public insert(at: Position, c: string): void { @@ -102,7 +105,7 @@ export class Document { this.insertRow(this.numRows, c); } else { this.#rows[at.y].insertChar(at.x, c); - this.#rows[at.y].update(); + this.#rows[at.y].update(None); } this.dirty = true; @@ -126,7 +129,7 @@ export class Document { // Split the current row, and insert a new // row with the leftovers const newRow = this.#rows[at.y].split(at.x); - newRow.update(); + newRow.update(None); this.#rows = arrayInsert(this.#rows, at.y + 1, newRow); this.dirty = true; @@ -165,7 +168,7 @@ export class Document { row.delete(at.x); } - row.update(); + row.update(None); this.dirty = true; } @@ -176,12 +179,12 @@ export class Document { public insertRow(at: number = this.numRows, s: string = ''): void { this.#rows = arrayInsert(this.#rows, at, Row.from(s)); - this.#rows[at].update(); + this.#rows[at].update(None); this.dirty = true; } - public highlight(searchMatch?: string): void { + public highlight(searchMatch: Option): void { this.#rows.forEach((row) => { row.update(searchMatch); }); diff --git a/src/common/editor.ts b/src/common/editor.ts index 5a5e8f4..c5d6118 100644 --- a/src/common/editor.ts +++ b/src/common/editor.ts @@ -8,12 +8,11 @@ import { ctrlKey, isControl, maxAdd, - none, posSub, readKey, - some, truncate, } from './fns.ts'; +import Option, { None, Some } from './option.ts'; import { getRuntime, log, LogLevel } from './runtime.ts'; import { ITerminalSize, Position } from './types.ts'; @@ -100,12 +99,12 @@ class Editor { public async save(): Promise { if (this.#filename === '') { const filename = await this.prompt('Save as: %s (ESC to cancel)'); - if (filename === null) { + if (filename.isNone()) { this.setStatusMessage('Save aborted'); return; } - this.#filename = filename; + this.#filename = filename.unwrap(); } await this.#document.save(this.#filename); @@ -236,7 +235,7 @@ class Editor { public async prompt( p: string, callback?: (query: string, char: string) => void, - ): Promise { + ): Promise> { const { term } = await getRuntime(); let res = ''; @@ -256,7 +255,7 @@ class Editor { await this.refreshScreen(); for await (const chunk of term.inputLoop()) { const char = readKey(chunk); - if (none(char)) { + if (char.length === 0) { continue; } @@ -273,14 +272,14 @@ class Editor { this.setStatusMessage(''); maybeCallback(res, char); - return null; + return None; // Return the input and end the prompt case KeyCommand.Enter: if (res.length > 0) { this.setStatusMessage(''); maybeCallback(res, char); - return res; + return Some(res); } break; @@ -314,11 +313,11 @@ class Editor { return null; } - if (some(query) && query.length > 0) { + if (query.length > 0) { const pos = this.#document.find(query, key); - if (pos !== null) { + if (pos.isSome()) { // We have a match here - this.#cursor = pos; + this.#cursor = pos.unwrap(); this.scroll(); } else { this.setStatusMessage('Not found'); diff --git a/src/common/fns.ts b/src/common/fns.ts index 6975065..912ccbb 100644 --- a/src/common/fns.ts +++ b/src/common/fns.ts @@ -11,20 +11,6 @@ const decoder = new TextDecoder(); */ export const noop = () => {}; -/** - * Does a value exist? (not null or undefined) - */ -export function some(v: unknown): boolean { - return v !== null && typeof v !== 'undefined'; -} - -/** - * Is the value null or undefined? - */ -export function none(v: unknown): boolean { - return v === null || typeof v === 'undefined'; -} - /** * Convert input from ANSI escape sequences into a form * that can be more easily mapped to editor commands @@ -149,10 +135,10 @@ export function ord(s: string): number { /** * Split a string by graphemes, not just bytes * - * @param s - the string to split into 'characters' + * @param s - the string to split into unicode code points */ export function strChars(s: string): string[] { - return s.split(/(?:)/u); + return [...s]; } /** diff --git a/src/common/option.ts b/src/common/option.ts new file mode 100644 index 0000000..fa4d0ce --- /dev/null +++ b/src/common/option.ts @@ -0,0 +1,175 @@ +/** + * Rust-style optional type + * + * Based on https://gist.github.com/s-panferov/575da5a7131c285c0539 + */ +export default interface Option { + isSome(): boolean; + isNone(): boolean; + isSomeAnd(fn: (a: T) => boolean): boolean; + isNoneAnd(fn: () => boolean): boolean; + unwrap(): T | never; + unwrapOr(def: T): T; + unwrapOrElse(f: () => T): T; + map(f: (a: T) => U): Option; + mapOr(def: U, f: (a: T) => U): U; + mapOrElse(def: () => U, f: (a: T) => U): U; + and(optb: Option): Option; + andThen(f: (a: T) => Option): Option; + or(optb: Option): Option; + orElse(f: () => Option): Option; +} + +class _Some implements Option { + private value: T; + + constructor(v: T) { + this.value = v; + } + + static wrapNull(value: T): Option { + if (value == null) { + return None; + } else { + return new _Some(value); + } + } + + map(fn: (a: T) => U): Option { + return new _Some(fn(this.value)); + } + + mapOr(_def: U, f: (a: T) => U): U { + return f(this.value); + } + + mapOrElse(_def: () => U, f: (a: T) => U): U { + return f(this.value); + } + + isSome(): boolean { + return true; + } + + isNone(): boolean { + return false; + } + + isSomeAnd(fn: (a: T) => boolean): boolean { + return fn(this.value); + } + + isNoneAnd(_fn: () => boolean): boolean { + return false; + } + + unwrap(): T { + return this.value; + } + + unwrapOr(_def: T): T { + return this.value; + } + + unwrapOrElse(_f: () => T): T { + return this.value; + } + + and(optb: Option): Option { + return optb; + } + + andThen(f: (a: T) => Option): Option { + return f(this.value); + } + + or(_optb: Option): Option { + return this; + } + + orElse(_f: () => Option): Option { + return this; + } + + toString(): string { + return 'Some ' + this.value; + } +} + +class _None implements Option { + constructor() { + } + + map(_fn: (a: T) => U): Option { + return > _None._instance; + } + + isSome(): boolean { + return false; + } + + isNone(): boolean { + return true; + } + + isSomeAnd(_fn: (a: T) => boolean): boolean { + return false; + } + + isNoneAnd(fn: () => boolean): boolean { + return fn(); + } + + unwrap(): never { + console.error('None.unwrap()'); + throw 'None.get'; + } + + unwrapOr(def: T): T { + return def; + } + + unwrapOrElse(f: () => T): T { + return f(); + } + + mapOr(def: U, _f: (a: T) => U): U { + return def; + } + + mapOrElse(def: () => U, _f: (a: T) => U): U { + return def(); + } + + and(_optb: Option): Option { + return _None.instance(); + } + + andThen(_f: (a: T) => Option): Option { + return _None.instance(); + } + + or(optb: Option): Option { + return optb; + } + + orElse(f: () => Option): Option { + return f(); + } + + private static _instance: Option = new _None(); + + public static instance(): Option { + return > _None._instance; + } + + public toString(): string { + return 'None'; + } +} + +export const None: Option = _None.instance(); + +export function Some(value: T): Option { + return _Some.wrapNull(value); +} diff --git a/src/common/row.ts b/src/common/row.ts index 173621d..7345b1f 100644 --- a/src/common/row.ts +++ b/src/common/row.ts @@ -1,8 +1,10 @@ -import { SCROLL_TAB_SIZE } from './config.ts'; -import { arrayInsert, isAsciiDigit, some, strChars } from './fns.ts'; -import { highlightToColor, HighlightType } from './highlight.ts'; import Ansi from './ansi.ts'; +import { SCROLL_TAB_SIZE } from './config.ts'; +import { arrayInsert, isAsciiDigit, strChars } from './fns.ts'; +import { highlightToColor, HighlightType } from './highlight.ts'; +import Option, { None, Some } from './option.ts'; + /** * One row of text in the current document. In order to handle * multi-byte graphemes, all operations are done on an @@ -56,7 +58,7 @@ export class Row { public append(s: string): void { this.chars = this.chars.concat(strChars(s)); - this.update(); + this.update(None); } public insertChar(at: number, c: string): void { @@ -74,7 +76,7 @@ export class Row { public split(at: number): Row { const newRow = new Row(this.chars.slice(at)); this.chars = this.chars.slice(0, at); - this.update(); + this.update(None); return newRow; } @@ -92,25 +94,31 @@ export class Row { /** * Search the current row for the specified string, and return - * the index of the start of that match + * the 'character' index of the start of that match */ - public find(s: string, offset: number = 0): number | null { + public find(s: string, offset: number = 0): Option { const thisStr = this.toString(); if (!this.toString().includes(s)) { - return null; + return None; } - const byteCount = thisStr.indexOf(s, this.charIndexToByteIndex(offset)); + // Look for the search query `s`, starting from the 'character' `offset` + const byteIndex = thisStr.indexOf(s, this.charIndexToByteIndex(offset)); + + // 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 byteCount; + return Some(byteIndex); } // Emoji/Extended Unicode-friendly search - return this.byteIndexToCharIndex(byteCount); + return Some(this.byteIndexToCharIndex(byteIndex)); } /** @@ -179,6 +187,9 @@ export class Row { 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, @@ -189,21 +200,22 @@ export class Row { return this.chars.join(''); } - public update(searchMatch?: string): void { + public update(word: Option): void { const newString = this.chars.join('').replaceAll( '\t', ' '.repeat(SCROLL_TAB_SIZE), ); this.rchars = strChars(newString); - this.highlight(searchMatch); + this.highlight(word); } - public highlight(searchMatch?: string): void { + public highlight(word: Option): void { const highlighting = []; + // let searchIndex = 0; - if (some(searchMatch)) { - // TODO: highlight search here + if (word.isSome()) { + // const searchMatch = this.find(word.unwrap(), searchIndex); } for (const ch of this.rchars) { diff --git a/src/common/search.ts b/src/common/search.ts index 9f6c656..12ae87a 100644 --- a/src/common/search.ts +++ b/src/common/search.ts @@ -1,7 +1,9 @@ -import { Position } from './types.ts'; -import { KeyCommand } from './ansi.ts'; import Document from './document.ts'; +import { KeyCommand } from './ansi.ts'; +import Option, { None } from './option.ts'; +import { Position } from './types.ts'; + enum SearchDirection { Forward = 1, Backward = -1, @@ -11,7 +13,7 @@ export class Search { private lastMatch: number = -1; private current: number = -1; private direction: SearchDirection = SearchDirection.Forward; - public parent: Document | null = null; + public parent: Option = None; private parseInput(key: string) { switch (key) { @@ -48,29 +50,31 @@ export class Search { return this.current; } - public search(q: string, key: string): Position | null { - if (this.parent === null) { - return null; + public search(q: string, key: string): Option { + if (this.parent.isNone()) { + return None; } + const parent = this.parent.unwrap(); + this.parseInput(key); let i = 0; - for (; i < this.parent.numRows; i++) { - const current = this.getNextRow(this.parent.numRows); - const row = this.parent.row(current); + for (; i < parent.numRows; i++) { + const current = this.getNextRow(parent.numRows); + const row = parent.row(current); if (row === null) { continue; } const possible = row.find(q); - if (possible !== null) { + if (possible.isSome()) { this.lastMatch = current; - return Position.at(possible, current); + return possible.map((p: number) => Position.at(p, current)); } } - return null; + return None; } }