diff --git a/lib/assert.ts b/lib/assert.ts new file mode 100644 index 0000000000000000000000000000000000000000..7def4c49a2e204a609525530c29613d9cb17472e --- /dev/null +++ b/lib/assert.ts @@ -0,0 +1,47 @@ +// This module is intended to be imported as + +/** Assert that `condition` is truthy */ +export function ASSERT(condition: boolean, msg?: Message): asserts condition { + if (!condition) { + throw new Error(msg ? messageToString(msg) : "Assertion Error"); + } +} + +/** Assert that `nullable` is non-null, returning a non-nullable T */ +export function UNWRAP(nullable: T | null | undefined, msg?: Message) { + if (nullable == null) { + throw new Error( + `Unexpected ${nullable}${msg ? `: ${messageToString(msg)}` : ""}`, + ); + } + return nullable; +} + +/** Assert that `number` is not NaN */ +export function FINITE(number: number, msg?: Message) { + if (Number.isNaN(number)) { + throw new Error(`Unexpected NaN${msg ? `: ${messageToString(msg)}` : ""}`); + } + return number; +} + +/** Assert that a `default` case in a switch is never reached. */ +export function unreachableCase(value: never): never { + throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`); +} + +/** message called if function; then string printed as is or util inspectified */ +type Message = string | (() => string | unknown) | unknown; + +function messageToString(msg: Message) { + if (typeof msg === "function") msg = (msg as () => Message)(); + return typeof msg === "string" ? msg : inspect(msg); +} + +function inspect(object: unknown) { + try { + return require("node:util").inspect(object); + } catch { + return typeof object; + } +} diff --git a/lib/deno.json b/lib/deno.json new file mode 100644 index 0000000000000000000000000000000000000000..c476596c5b96e46d7779c742de58cbbb9cb0549e --- /dev/null +++ b/lib/deno.json @@ -0,0 +1,16 @@ +{ + "name": "@clo/lib", + "version": "1.0.0", + "license": "MIT", + "exports": { + "./log": "./log.ts", + "./log/ansi": "./log/ansi.ts", + "./log/headless": "./log/headless.ts", + "./queue": "./queue.ts", + "./string": "./string.ts", + "./ts": "./ts.ts" + }, + "imports": { + "lib/": "./" + } +} diff --git a/lib/jsr.json b/lib/jsr.json deleted file mode 100644 index 94c1a1d3e8dfc9b1a84f1afcdfb0b28d19ddfcdc..0000000000000000000000000000000000000000 --- a/lib/jsr.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@clo/lib", - "version": "1.0.0", - "license": "MIT", - "exports": { - "./log": "./log.ts", - "./log/ansi": "./log/ansi.ts", - "./log/headless": "./log/headless.ts", - "./queue": "./queue.ts", - "./string": "./string.ts", - "./ts": "./ts.ts" - } -} diff --git a/lib/log.ts b/lib/log.ts index 836271096624e18af157bd8da7822abb61cd98b4..f6d8395fe68f73c36ae289e63a7e18f03ffb28c6 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -98,5 +98,5 @@ const globalLog = /* @__PURE__ */ headless.logger({ }); import process from "node:process"; -import * as headless from "./log/headless.ts"; -import * as ts from "./ts.ts"; +import * as headless from "lib/log/headless.ts"; +import type * as ts from "lib/ts.ts"; diff --git a/lib/log/ansi.ts b/lib/log/ansi.ts index 9d89d906272d80d36d4b09a6a0dd6ea010e844be..d20977b4e2a9b970a0eb73f7475bbbb852763f64 100644 --- a/lib/log/ansi.ts +++ b/lib/log/ansi.ts @@ -49,7 +49,7 @@ export function bgRgb(r: number, g: number, b: number): string { } // cursor management -export function cursorUp(n: number) { +export function cursorUp(n: number): string { return n ? `\x1b[${n}A` : ""; } export const startOfLine: string = "\r"; @@ -77,7 +77,7 @@ export function style(code: string, text: string): string { * Given a string of multiple style ansi codes, combines all of them. * Must be used on a string of only ansi style codes. */ -export function mergeStyles(code: string) { +export function mergeStyles(code: string): string { let styles = []; while (code.length) { if (!code.startsWith("\x1b[")) { diff --git a/lib/log/headless.ts b/lib/log/headless.ts index ff30533c15456f31a072519d4aa6403047b81459..cedd5da43ecb1306512d3c4e322ac62be3683523 100644 --- a/lib/log/headless.ts +++ b/lib/log/headless.ts @@ -1,4 +1,6 @@ // This file is under heavy construction. +// - I am happy with the overall API, likely no changes. +// - There are certainly a lot of bugs. // // Rendering for 'lib/term' is done by specifying all system APIs up front in // an interface and creating an instance of the widget host. The main file diff --git a/lib/log/progress.sample.ts b/lib/log/progress.sample.ts index afd45c8d8bcdc49ecdd566005b32694ae94b2eb2..3fa176ceff701421bfca0dd949425f65ae191799 100644 --- a/lib/log/progress.sample.ts +++ b/lib/log/progress.sample.ts @@ -25,6 +25,8 @@ for (let i = 0; i < 10; i += 1) { x = subNodes[2]!.start("other task"); y = x.start("deeply nested"); z = y.start("job"); + z.log.info("with inline logs"); + z.log.warn("with inline logs"); } if (i == 8) z?.end(); if (i === 6) w = y!.start("magic"); diff --git a/lib/log/progress.ts b/lib/log/progress.ts index 349a8e5edea989e16b7145e60c9a3920fc0b5245..b3001a26cce7f9a1262d6ee06919051be3b523f8 100644 --- a/lib/log/progress.ts +++ b/lib/log/progress.ts @@ -1,10 +1,15 @@ // This file is under heavy construction. +// - I am happy with the overall design +// - Will rename things +// - Will be re-working the entire module to have a headless +// core, but still leaving the top level `start` function. +// - There are an overwhelming amount of bugs // // A progress tree based on my past uses with clover console v3 and the Zig // Progress API. Pass `Progress` to functions which should track progress, // calling `.start()` to create child items. Or, multiple top-level progress // bars can be created with the singleton's `start()` function. -import * as self from "./progress.ts"; +import * as self from "lib/log/progress.ts"; self satisfies Ref; // prove you can use the namespace import as a `Progress` /** @@ -12,12 +17,7 @@ self satisfies Ref; // prove you can use the namespace import as a `Progress` * when given an estimate, a progress bar is rendered. */ export function start(text: string, opts?: StartOptions): Node { - const [state, node] = newNode(text, opts); - globalState.push(state); - state.signal = topLevelSignaler; - if (rerender) rerender(); - else log.startWidget(globalWidget); - return node; + return global.start(text, opts); } export interface Ref { @@ -58,10 +58,6 @@ export interface Node extends Ref { /** end this Node or RootNode. ending collapses children */ end(): void; - // /** signal that there will be a failure without ending the node */ - // pendingFail(): void; - // /** end this node with a failure */ - // endFail(): void; [Symbol.dispose](): void; } @@ -70,30 +66,18 @@ export interface StartOptions { estimate?: number | undefined | null; } -/** a node that satisfies the api */ -const nullNode: Node = - (function start(text: string, { estimate }: StartOptions = {}): Node { - return { - start, - text, - value: 0, - estimate: estimate ?? 0, - showEstimate: true, - passive: false, - maxHeight: null, - sortChildren: null, - inc() { - this.value += 1; - }, - log: log.scoped(""), - end() {}, - [Symbol.dispose]() {}, - }; +/** a valid `Node` without any output */ +const nullNode: Node = /* @__PURE__ */ + (function start(text: string, opts?: StartOptions): Node { + const node = newNode(text, opts)[1]; + node.start = start; + node.log = log.scoped(""); + return node; })("root"); export { nullNode as null }; -type SortNode = +export type SortNode = & Pick< Node, | "text" @@ -105,16 +89,23 @@ type SortNode = > & { children: SortNode[] }; -type Signal = "draw" | "end" | "endFail" | "pendingFail"; -interface State extends SortNode { +export interface Internal extends SortNode { logs: string[]; - children: State[]; - parent: State | null; - signal(signal: Signal): void; + children: Internal[]; + parent: Internal | null; + signal(msg: Signal): void; } -function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] { - const state: State = { +type Signal = + | "draw" + | { type: "end"; title: string; logs: string[] } + | { type: "line"; line: string }; + +function newNode( + text: string, + { estimate }: StartOptions = {}, +): [Internal, Node] { + const state: Internal = { text, value: 0, estimate: estimate ?? 0, @@ -128,13 +119,20 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] { throw new Error("no signaler"); }, }; + let detached = false; function end() { + if (detached) return; if (!state.parent) return; const i = state.parent.children.indexOf(state); if (i === -1) return; - state.signal("draw"); + let title = state.text; + let p: Internal | null = state; + while (p = p.parent) title = p.text + " / " + title; + state.signal({ type: "end", title, logs: state.logs }); + state.logs = []; state.parent.children.splice(i, 1); state.parent = null; + detached = true; } function mutate() { const { parent } = state; @@ -144,6 +142,7 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] { const scope = headless.logger({ log: { writeLine(line) { + if (detached) return state.signal({ type: "line", line }); state.logs.push(...line.split("\n")); state.signal("draw"); }, @@ -226,6 +225,27 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] { return [state, binding]; } +function format(now: number, list: Internal[]) { + let out = ""; + for (const top of list) { + if (top.passive && top.children.length === 0) continue; + out += renderMainLine(top, now, 0) + "\n"; + out += renderChildren(top, now, []); + } + return out.trimEnd(); +} + +/** These functions are used to test `lib/log/progress` */ +export const internals = { + node(text: string, props: Partial, children: Internal[]): Internal { + const [node] = newNode(text); + Object.assign(node, props); + node.children.push(...children); + return node; + }, + format, +}; + const spinnerFps = 12.5; const box = { @@ -238,31 +258,7 @@ const fullBar = "█"; const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] .map((frame) => ansi.style(ansi.fgBlue, frame)); -let rerender: (() => void) | null = null; -let globalTimer: ts.Interval | null = null; -const globalWidget: log.Widget = { - format(now) { - let out = ""; - for (const top of globalState) { - if (top.passive && top.children.length === 0) continue; - out += renderMainLine(top, now, 0) + "\n"; - out += renderChildren(top, now, []); - } - return out.trimEnd(); - }, - onChange(cb) { - //const globalTimer = setInterval(cb, 1000 / spinnerFps); - //if ("unref" in globalTimer) (globalTimer.unref as () => void)(); - rerender = cb; - return () => { - globalTimer != null && clearInterval(globalTimer); - rerender = null; - }; - }, -}; -const globalState: State[] = []; - -function renderMainLine(state: State, now: number, depth: number) { +function renderMainLine(state: Internal, now: number, depth: number) { if (state.estimate && state.showEstimate) { return ansi.style( ansi.bgBrightBlack + ansi.fgBlue, @@ -278,14 +274,14 @@ function renderMainLine(state: State, now: number, depth: number) { (state.value > 0 ? `[${state.value}] ` : "") + state.text; } -function renderChildren(state: State, now: number, depth: boolean[]) { +function renderChildren(state: Internal, now: number, depth: boolean[]) { let maxHeight = 25; // TODO: let truncated = 0; let out = ""; const { children } = state; if (children.length === 0) return ""; for (let i = 0, { length } = children; i < length; i += 1) { - const child = children[i]; + const child = UNWRAP(children[i]); if (child.passive && child.children.length === 0) continue; let item = ""; const left = depth.map((x) => x ? box.line : " ").join(""); @@ -333,14 +329,55 @@ export function getUnicodeBar(progress: number, width: number) { return fill + partChar + empty; } -function topLevelSignaler(this: State, signal: Signal) { - if (signal === "draw") { - rerender?.(); +/** Run a progress tree without a dependency on a TTY */ +export class Headless { + constructor( + /** + * `startWidget` is only ever called from `start` + * `writeLine` is used for logs from completed nodes + */ + public env: Pick, + ) {} + + active: Internal[] = []; + + start(text: string, opts?: StartOptions): Node { + const [state, node] = newNode(text, opts); + this.active.push(state); + state.signal = this.#signal; + const rerender = this.#rerender; + if (rerender) rerender(); + else log.startWidget(this.widget); + return node; } + + widget: log.Widget = { + format: (now) => format(now, this.active), + onChange: ( + rerender, + ) => (this.#rerender = rerender, () => this.#rerender = null), + }; + + #rerender?: (() => void) | null; + #signal = (msg: Signal) => { + if (msg === "draw") this.#rerender?.(); + else if (msg.type === "line") this.env.writeLine(msg.line); + else if (msg.type === "end") { + if (msg.logs.length === 0) return; + const { logs } = msg; + const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`; + const header = `[${count} from ${msg.title}]`; + this.env.writeLine(ansi.style(ansi.fgBrightBlack, header)); + this.env.writeLine(msg.logs.join("\n")); + } + }; } -import * as ts from "../ts.ts"; -import * as log from "../log.ts"; -import * as headless from "../log/headless.ts"; -import * as ansi from "./ansi.ts"; -import * as string from "../string.ts"; +const global = /** @__PURE__ */ new Headless(log); + +import * as process from "node:process"; +import * as log from "lib/log.ts"; +import * as headless from "lib/log/headless.ts"; +import * as ansi from "lib/log/ansi.ts"; +import * as string from "lib/string.ts"; +import { UNWRAP } from "lib/assert.ts"; diff --git a/lib/queue.ts b/lib/queue.ts index d90754bc8cf082b7dbfe33296092291503338bd7..c3ec818699c2fd8b0bafb0c240152cd97fa81725 100644 --- a/lib/queue.ts +++ b/lib/queue.ts @@ -136,7 +136,7 @@ export class PriorityQueue { }); } - canRun({ cores }: Internal) { + canRun({ cores }: Internal): boolean { const { coresRemain, concurrency } = this; return coresRemain >= cores || (cores > concurrency && coresRemain === concurrency); @@ -166,3 +166,5 @@ function insertSorted(arr: T[], item: T) { } const global = new PriorityQueue(navigator.hardwareConcurrency); + +import { UNWRAP } from "lib/assert.ts"; diff --git a/lib/string.ts b/lib/string.ts index 6b82a0241ea2bd6c454642d08f5c8ae416684dd3..8905daa419dd1a00b4c69c6481a3a9053666eb4a 100644 --- a/lib/string.ts +++ b/lib/string.ts @@ -1,5 +1,5 @@ // TODO: this function is not benchmarked -export function countNewlines(str: string) { +export function countNewlines(str: string): number { let count = 0; for (let i = 0, { length } = str; i < length; i += 1) { if (str[i] === "\n") count += 1; @@ -7,7 +7,7 @@ export function countNewlines(str: string) { return count; } -export function widthInTerminal(str: string) { +export function widthInTerminal(str: string): number { let width = 0; if (str.includes("\x1b")) str = str.replace(ansiEscapes, ""); for (let i = 0, { length } = str; i < length; i += 1) { @@ -32,7 +32,7 @@ export function widthInTerminal(str: string) { return width; } -export function trimToWidth(str: string, columns: number) { +export function trimToWidth(str: string, columns: number): string { let width = 0; let ansi = false; for (let i = 0, { length } = str; i < length; i += 1) { @@ -57,7 +57,7 @@ export function trimToWidth(str: string, columns: number) { str = str.slice(i); const segments = segmenter.segment(str); { - const { segment } = segments.containing(0); + const { segment } = UNWRAP(segments.containing(0)); width += segmentWidth(segment); if (width > columns) return ansi ? out + "\x1b[0m" : out; out += segment; @@ -73,7 +73,7 @@ export function trimToWidth(str: string, columns: number) { continue; } } - const { segment } = segments.containing(i); + const { segment } = UNWRAP(segments.containing(i)); width += segmentWidth(segment); if (width > columns) return ansi ? out + "\x1b[0m" : out; out += segment; @@ -154,3 +154,5 @@ function segmentWidth(segment: string) { list.includes(x) ); } + +import { UNWRAP } from "lib/assert.ts";