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 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,

View File

@ -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<Position> {
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<string>): void {
this.#rows.forEach((row) => {
row.update(searchMatch);
});

View File

@ -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<void> {
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<string | null> {
): Promise<Option<string>> {
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');

View File

@ -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];
}
/**

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 { 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<number> {
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<string>): 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<string>): 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) {

View File

@ -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<Document> = 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<Position> {
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;
}
}