Add Option type to remove the need to use null/undefined
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good

This commit is contained in:
Timothy Warren 2024-07-03 16:13:29 -04:00
parent 76eacd835f
commit 1b3e9d9796
7 changed files with 269 additions and 97 deletions

View File

@ -3,6 +3,7 @@ import Buffer from './buffer.ts';
import Document from './document.ts'; import Document from './document.ts';
import Editor from './editor.ts'; import Editor from './editor.ts';
import { highlightToColor, HighlightType } from './highlight.ts'; import { highlightToColor, HighlightType } from './highlight.ts';
import _Option, { None, Some } from './option.ts';
import Position from './position.ts'; import Position from './position.ts';
import Row from './row.ts'; import Row from './row.ts';
@ -12,11 +13,10 @@ import { getTestRunner } from './runtime.ts';
const { const {
assertStrictEquals: assertEquals, assertStrictEquals: assertEquals,
assertEquals: assertLooseEquals, assertEquals: assertEquivalent,
assertExists, assertExists,
assertInstanceOf, assertInstanceOf,
assertNotEquals, assertNotEquals,
assertNull,
assertFalse, assertFalse,
assertTrue, assertTrue,
testSuite, testSuite,
@ -30,8 +30,6 @@ const THIS_FILE = './src/common/all_test.ts';
const fnTest = () => { const fnTest = () => {
const { const {
some,
none,
arrayInsert, arrayInsert,
noop, noop,
posSub, posSub,
@ -48,39 +46,25 @@ const fnTest = () => {
} = Fn; } = Fn;
return { 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': () => { 'arrayInsert() strings': () => {
const a = ['😺', '😸', '😹']; const a = ['😺', '😸', '😹'];
const b = arrayInsert(a, 1, 'x'); const b = arrayInsert(a, 1, 'x');
const c = ['😺', 'x', '😸', '😹']; const c = ['😺', 'x', '😸', '😹'];
assertLooseEquals(b, c); assertEquivalent(b, c);
const d = arrayInsert(c, 17, 'y'); const d = arrayInsert(c, 17, 'y');
const e = ['😺', 'x', '😸', '😹', 'y']; const e = ['😺', 'x', '😸', '😹', 'y'];
assertLooseEquals(d, e); assertEquivalent(d, e);
assertLooseEquals(arrayInsert([], 0, 'foo'), ['foo']); assertEquivalent(arrayInsert([], 0, 'foo'), ['foo']);
}, },
'arrayInsert() numbers': () => { 'arrayInsert() numbers': () => {
const a = [1, 3, 5]; const a = [1, 3, 5];
const b = [1, 3, 4, 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]; const c = [1, 2, 3, 4, 5];
assertLooseEquals(arrayInsert(b, 1, 2), c); assertEquivalent(arrayInsert(b, 1, 2), c);
}, },
'noop fn': () => { 'noop fn': () => {
assertExists(noop); assertExists(noop);
@ -106,7 +90,7 @@ const fnTest = () => {
assertEquals(ord('a'), 97); assertEquals(ord('a'), 97);
}, },
'strChars() properly splits strings into unicode characters': () => { 'strChars() properly splits strings into unicode characters': () => {
assertLooseEquals(strChars('😺😸😹'), ['😺', '😸', '😹']); assertEquivalent(strChars('😺😸😹'), ['😺', '😸', '😹']);
}, },
'ctrlKey()': () => { 'ctrlKey()': () => {
const ctrl_a = ctrlKey('a'); const ctrl_a = ctrlKey('a');
@ -432,6 +416,12 @@ const EditorTest = {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
const OptionTest = {
// @TODO implement Option tests
};
// ----------------------------------------------------------------------------
const PositionTest = { const PositionTest = {
'.default': () => { '.default': () => {
const p = Position.default(); const p = Position.default();
@ -498,12 +488,12 @@ const RowTest = {
}, },
'.find': () => { '.find': () => {
const normalRow = Row.from('For whom the bell tolls'); const normalRow = Row.from('For whom the bell tolls');
assertEquals(normalRow.find('who'), 4); assertEquivalent(normalRow.find('who'), Some(4));
assertNull(normalRow.find('foo')); assertEquals(normalRow.find('foo'), None);
const emojiRow = Row.from('😺😸😹'); const emojiRow = Row.from('😺😸😹');
assertEquals(emojiRow.find('😹'), 2); assertEquivalent(emojiRow.find('😹'), Some(2));
assertNull(emojiRow.find('🤰🏼')); assertEquals(emojiRow.find('🤰🏼'), None);
}, },
'.byteIndexToCharIndex': () => { '.byteIndexToCharIndex': () => {
// Each 'character' is two bytes // Each 'character' is two bytes
@ -528,7 +518,7 @@ const RowTest = {
}, },
'.cxToRx, .rxToCx': () => { '.cxToRx, .rxToCx': () => {
const row = Row.from('foo\tbar\tbaz'); const row = Row.from('foo\tbar\tbaz');
row.update(); row.update(None);
assertNotEquals(row.chars, row.rchars); assertNotEquals(row.chars, row.rchars);
assertNotEquals(row.size, row.rsize); assertNotEquals(row.size, row.rsize);
assertEquals(row.size, 11); assertEquals(row.size, 11);
@ -545,7 +535,9 @@ const RowTest = {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
const SearchTest = {}; const SearchTest = {
// @TODO implement Search tests
};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Test Suite Setup // Test Suite Setup
@ -559,6 +551,7 @@ testSuite({
Buffer: BufferTest, Buffer: BufferTest,
Document: DocumentTest, Document: DocumentTest,
Editor: EditorTest, Editor: EditorTest,
Option: OptionTest,
Position: PositionTest, Position: PositionTest,
Row: RowTest, Row: RowTest,
Search: SearchTest, Search: SearchTest,

View File

@ -1,6 +1,7 @@
import Row from './row.ts'; import Row from './row.ts';
import { arrayInsert, some, strlen } from './fns.ts'; import { arrayInsert, strlen } from './fns.ts';
import { HighlightType } from './highlight.ts'; import { HighlightType } from './highlight.ts';
import Option, { None, Some } from './option.ts';
import { getRuntime } from './runtime.ts'; import { getRuntime } from './runtime.ts';
import { Position } from './types.ts'; import { Position } from './types.ts';
import { Search } from './search.ts'; import { Search } from './search.ts';
@ -26,7 +27,7 @@ export class Document {
public static default(): Document { public static default(): Document {
const self = new Document(); const self = new Document();
self.#search.parent = self; self.#search.parent = Some(self);
return self; return self;
} }
@ -68,15 +69,17 @@ export class Document {
public resetFind(): void { public resetFind(): void {
this.#search = new Search(); this.#search = new Search();
this.#search.parent = this; this.#search.parent = Some(this);
} }
public find( public find(
q: string, q: string,
key: string, key: string,
): Position | null { ): Option<Position> {
const potential = this.#search.search(q, key); const possible = this.#search.search(q, key);
if (some(potential) && potential instanceof Position) { if (possible.isSome()) {
const potential = possible.unwrap();
// Update highlight of search match // Update highlight of search match
const row = this.#rows[potential.y]; const row = this.#rows[potential.y];
@ -94,7 +97,7 @@ export class Document {
} }
} }
return potential; return possible;
} }
public insert(at: Position, c: string): void { public insert(at: Position, c: string): void {
@ -102,7 +105,7 @@ export class Document {
this.insertRow(this.numRows, c); this.insertRow(this.numRows, c);
} else { } else {
this.#rows[at.y].insertChar(at.x, c); this.#rows[at.y].insertChar(at.x, c);
this.#rows[at.y].update(); this.#rows[at.y].update(None);
} }
this.dirty = true; this.dirty = true;
@ -126,7 +129,7 @@ export class Document {
// Split the current row, and insert a new // Split the current row, and insert a new
// row with the leftovers // row with the leftovers
const newRow = this.#rows[at.y].split(at.x); const newRow = this.#rows[at.y].split(at.x);
newRow.update(); newRow.update(None);
this.#rows = arrayInsert(this.#rows, at.y + 1, newRow); this.#rows = arrayInsert(this.#rows, at.y + 1, newRow);
this.dirty = true; this.dirty = true;
@ -165,7 +168,7 @@ export class Document {
row.delete(at.x); row.delete(at.x);
} }
row.update(); row.update(None);
this.dirty = true; this.dirty = true;
} }
@ -176,12 +179,12 @@ export class Document {
public insertRow(at: number = this.numRows, s: string = ''): void { public insertRow(at: number = this.numRows, s: string = ''): void {
this.#rows = arrayInsert(this.#rows, at, Row.from(s)); this.#rows = arrayInsert(this.#rows, at, Row.from(s));
this.#rows[at].update(); this.#rows[at].update(None);
this.dirty = true; this.dirty = true;
} }
public highlight(searchMatch?: string): void { public highlight(searchMatch: Option<string>): void {
this.#rows.forEach((row) => { this.#rows.forEach((row) => {
row.update(searchMatch); row.update(searchMatch);
}); });

View File

@ -8,12 +8,11 @@ import {
ctrlKey, ctrlKey,
isControl, isControl,
maxAdd, maxAdd,
none,
posSub, posSub,
readKey, readKey,
some,
truncate, truncate,
} from './fns.ts'; } from './fns.ts';
import Option, { None, Some } from './option.ts';
import { getRuntime, log, LogLevel } from './runtime.ts'; import { getRuntime, log, LogLevel } from './runtime.ts';
import { ITerminalSize, Position } from './types.ts'; import { ITerminalSize, Position } from './types.ts';
@ -100,12 +99,12 @@ class Editor {
public async save(): Promise<void> { public async save(): Promise<void> {
if (this.#filename === '') { if (this.#filename === '') {
const filename = await this.prompt('Save as: %s (ESC to cancel)'); const filename = await this.prompt('Save as: %s (ESC to cancel)');
if (filename === null) { if (filename.isNone()) {
this.setStatusMessage('Save aborted'); this.setStatusMessage('Save aborted');
return; return;
} }
this.#filename = filename; this.#filename = filename.unwrap();
} }
await this.#document.save(this.#filename); await this.#document.save(this.#filename);
@ -236,7 +235,7 @@ class Editor {
public async prompt( public async prompt(
p: string, p: string,
callback?: (query: string, char: string) => void, callback?: (query: string, char: string) => void,
): Promise<string | null> { ): Promise<Option<string>> {
const { term } = await getRuntime(); const { term } = await getRuntime();
let res = ''; let res = '';
@ -256,7 +255,7 @@ class Editor {
await this.refreshScreen(); await this.refreshScreen();
for await (const chunk of term.inputLoop()) { for await (const chunk of term.inputLoop()) {
const char = readKey(chunk); const char = readKey(chunk);
if (none(char)) { if (char.length === 0) {
continue; continue;
} }
@ -273,14 +272,14 @@ class Editor {
this.setStatusMessage(''); this.setStatusMessage('');
maybeCallback(res, char); maybeCallback(res, char);
return null; return None;
// Return the input and end the prompt // Return the input and end the prompt
case KeyCommand.Enter: case KeyCommand.Enter:
if (res.length > 0) { if (res.length > 0) {
this.setStatusMessage(''); this.setStatusMessage('');
maybeCallback(res, char); maybeCallback(res, char);
return res; return Some(res);
} }
break; break;
@ -314,11 +313,11 @@ class Editor {
return null; return null;
} }
if (some(query) && query.length > 0) { if (query.length > 0) {
const pos = this.#document.find(query, key); const pos = this.#document.find(query, key);
if (pos !== null) { if (pos.isSome()) {
// We have a match here // We have a match here
this.#cursor = pos; this.#cursor = pos.unwrap();
this.scroll(); this.scroll();
} else { } else {
this.setStatusMessage('Not found'); this.setStatusMessage('Not found');

View File

@ -11,20 +11,6 @@ const decoder = new TextDecoder();
*/ */
export const noop = () => {}; 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 * Convert input from ANSI escape sequences into a form
* that can be more easily mapped to editor commands * 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 * 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[] { export function strChars(s: string): string[] {
return s.split(/(?:)/u); return [...s];
} }
/** /**

175
src/common/option.ts Normal file
View File

@ -0,0 +1,175 @@
/**
* Rust-style optional type
*
* Based on https://gist.github.com/s-panferov/575da5a7131c285c0539
*/
export default interface Option<T> {
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<U>(f: (a: T) => U): Option<U>;
mapOr<U>(def: U, f: (a: T) => U): U;
mapOrElse<U>(def: () => U, f: (a: T) => U): U;
and<U>(optb: Option<U>): Option<U>;
andThen<U>(f: (a: T) => Option<U>): Option<U>;
or(optb: Option<T>): Option<T>;
orElse(f: () => Option<T>): Option<T>;
}
class _Some<T> implements Option<T> {
private value: T;
constructor(v: T) {
this.value = v;
}
static wrapNull<T>(value: T): Option<T> {
if (value == null) {
return None;
} else {
return new _Some<T>(value);
}
}
map<U>(fn: (a: T) => U): Option<U> {
return new _Some(fn(this.value));
}
mapOr<U>(_def: U, f: (a: T) => U): U {
return f(this.value);
}
mapOrElse<U>(_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<U>(optb: Option<U>): Option<U> {
return optb;
}
andThen<U>(f: (a: T) => Option<U>): Option<U> {
return f(this.value);
}
or(_optb: Option<T>): Option<T> {
return this;
}
orElse(_f: () => Option<T>): Option<T> {
return this;
}
toString(): string {
return 'Some ' + this.value;
}
}
class _None<T> implements Option<T> {
constructor() {
}
map<U>(_fn: (a: T) => U): Option<U> {
return <Option<U>> _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<U>(def: U, _f: (a: T) => U): U {
return def;
}
mapOrElse<U>(def: () => U, _f: (a: T) => U): U {
return def();
}
and<U>(_optb: Option<U>): Option<U> {
return _None.instance<U>();
}
andThen<U>(_f: (a: T) => Option<U>): Option<U> {
return _None.instance<U>();
}
or(optb: Option<T>): Option<T> {
return optb;
}
orElse(f: () => Option<T>): Option<T> {
return f();
}
private static _instance: Option<any> = new _None();
public static instance<X>(): Option<X> {
return <Option<X>> _None._instance;
}
public toString(): string {
return 'None';
}
}
export const None: Option<any> = _None.instance();
export function Some<T>(value: T): Option<T> {
return _Some.wrapNull(value);
}

View File

@ -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 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 * One row of text in the current document. In order to handle
* multi-byte graphemes, all operations are done on an * multi-byte graphemes, all operations are done on an
@ -56,7 +58,7 @@ export class Row {
public append(s: string): void { public append(s: string): void {
this.chars = this.chars.concat(strChars(s)); this.chars = this.chars.concat(strChars(s));
this.update(); this.update(None);
} }
public insertChar(at: number, c: string): void { public insertChar(at: number, c: string): void {
@ -74,7 +76,7 @@ export class Row {
public split(at: number): Row { public split(at: number): Row {
const newRow = new Row(this.chars.slice(at)); const newRow = new Row(this.chars.slice(at));
this.chars = this.chars.slice(0, at); this.chars = this.chars.slice(0, at);
this.update(); this.update(None);
return newRow; return newRow;
} }
@ -92,25 +94,31 @@ export class Row {
/** /**
* Search the current row for the specified string, and return * 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<number> {
const thisStr = this.toString(); const thisStr = this.toString();
if (!this.toString().includes(s)) { 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 // In many cases, the string length will
// equal the number of characters. So // equal the number of characters. So
// searching is fairly easy // searching is fairly easy
if (thisStr.length === this.chars.length) { if (thisStr.length === this.chars.length) {
return byteCount; return Some(byteIndex);
} }
// Emoji/Extended Unicode-friendly search // Emoji/Extended Unicode-friendly search
return this.byteIndexToCharIndex(byteCount); return Some(this.byteIndexToCharIndex(byteIndex));
} }
/** /**
@ -179,6 +187,9 @@ export class Row {
return charIndex; 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( return this.chars.slice(0, charIndex).reduce(
(prev, current) => prev += current.length, (prev, current) => prev += current.length,
0, 0,
@ -189,21 +200,22 @@ export class Row {
return this.chars.join(''); return this.chars.join('');
} }
public update(searchMatch?: string): void { public update(word: Option<string>): void {
const newString = this.chars.join('').replaceAll( const newString = this.chars.join('').replaceAll(
'\t', '\t',
' '.repeat(SCROLL_TAB_SIZE), ' '.repeat(SCROLL_TAB_SIZE),
); );
this.rchars = strChars(newString); this.rchars = strChars(newString);
this.highlight(searchMatch); this.highlight(word);
} }
public highlight(searchMatch?: string): void { public highlight(word: Option<string>): void {
const highlighting = []; const highlighting = [];
// let searchIndex = 0;
if (some(searchMatch)) { if (word.isSome()) {
// TODO: highlight search here // const searchMatch = this.find(word.unwrap(), searchIndex);
} }
for (const ch of this.rchars) { for (const ch of this.rchars) {

View File

@ -1,7 +1,9 @@
import { Position } from './types.ts';
import { KeyCommand } from './ansi.ts';
import Document from './document.ts'; import Document from './document.ts';
import { KeyCommand } from './ansi.ts';
import Option, { None } from './option.ts';
import { Position } from './types.ts';
enum SearchDirection { enum SearchDirection {
Forward = 1, Forward = 1,
Backward = -1, Backward = -1,
@ -11,7 +13,7 @@ export class Search {
private lastMatch: number = -1; private lastMatch: number = -1;
private current: number = -1; private current: number = -1;
private direction: SearchDirection = SearchDirection.Forward; private direction: SearchDirection = SearchDirection.Forward;
public parent: Document | null = null; public parent: Option<Document> = None;
private parseInput(key: string) { private parseInput(key: string) {
switch (key) { switch (key) {
@ -48,29 +50,31 @@ export class Search {
return this.current; return this.current;
} }
public search(q: string, key: string): Position | null { public search(q: string, key: string): Option<Position> {
if (this.parent === null) { if (this.parent.isNone()) {
return null; return None;
} }
const parent = this.parent.unwrap();
this.parseInput(key); this.parseInput(key);
let i = 0; let i = 0;
for (; i < this.parent.numRows; i++) { for (; i < parent.numRows; i++) {
const current = this.getNextRow(this.parent.numRows); const current = this.getNextRow(parent.numRows);
const row = this.parent.row(current); const row = parent.row(current);
if (row === null) { if (row === null) {
continue; continue;
} }
const possible = row.find(q); const possible = row.find(q);
if (possible !== null) { if (possible.isSome()) {
this.lastMatch = current; this.lastMatch = current;
return Position.at(possible, current); return possible.map((p: number) => Position.at(p, current));
} }
} }
return null; return None;
} }
} }