authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-09-27 17:27:56-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:47-07:00
logc5236cd3ee2b76999ae6101cd189cdaac23ce0cf
treebf4592314c9bc6f697d0ae25f0a7df3083dc59be
parent293d92bc8ba73bbf125387b927096b2c67a61081
signature Commit is signed but in an unrecognized format.

fix(lib/log/progress): propagate logs for completed nodes


10 files changed, 192 insertions(+), 97 deletions(-)

lib/assert.ts created+47
...@@ -0,0 +1,47 @@
1// This module is intended to be imported as
2
3/** Assert that `condition` is truthy */
4export function ASSERT(condition: boolean, msg?: Message): asserts condition {
5 if (!condition) {
6 throw new Error(msg ? messageToString(msg) : "Assertion Error");
7 }
8}
9
10/** Assert that `nullable` is non-null, returning a non-nullable T */
11export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message) {
12 if (nullable == null) {
13 throw new Error(
14 `Unexpected ${nullable}${msg ? `: ${messageToString(msg)}` : ""}`,
15 );
16 }
17 return nullable;
18}
19
20/** Assert that `number` is not NaN */
21export function FINITE(number: number, msg?: Message) {
22 if (Number.isNaN(number)) {
23 throw new Error(`Unexpected NaN${msg ? `: ${messageToString(msg)}` : ""}`);
24 }
25 return number;
26}
27
28/** Assert that a `default` case in a switch is never reached. */
29export function unreachableCase(value: never): never {
30 throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`);
31}
32
33/** message called if function; then string printed as is or util inspectified */
34type Message = string | (() => string | unknown) | unknown;
35
36function messageToString(msg: Message) {
37 if (typeof msg === "function") msg = (msg as () => Message)();
38 return typeof msg === "string" ? msg : inspect(msg);
39}
40
41function inspect(object: unknown) {
42 try {
43 return require("node:util").inspect(object);
44 } catch {
45 return typeof object;
46 }
47}
lib/deno.json created+16
...@@ -0,0 +1,16 @@
1{
2 "name": "@clo/lib",
3 "version": "1.0.0",
4 "license": "MIT",
5 "exports": {
6 "./log": "./log.ts",
7 "./log/ansi": "./log/ansi.ts",
8 "./log/headless": "./log/headless.ts",
9 "./queue": "./queue.ts",
10 "./string": "./string.ts",
11 "./ts": "./ts.ts"
12 },
13 "imports": {
14 "lib/": "./"
15 }
16}
lib/jsr.json deleted-13
...@@ -1,13 +0,0 @@
1{
2 "name": "@clo/lib",
3 "version": "1.0.0",
4 "license": "MIT",
5 "exports": {
6 "./log": "./log.ts",
7 "./log/ansi": "./log/ansi.ts",
8 "./log/headless": "./log/headless.ts",
9 "./queue": "./queue.ts",
10 "./string": "./string.ts",
11 "./ts": "./ts.ts"
12 }
13}
lib/log.ts+2-2
...@@ -98,5 +98,5 @@ const globalLog = /* @__PURE__ */ headless.logger({...@@ -98,5 +98,5 @@ const globalLog = /* @__PURE__ */ headless.logger({
98});98});
9999
100import process from "node:process";100import process from "node:process";
101import * as headless from "./log/headless.ts";101import * as headless from "lib/log/headless.ts";
102import * as ts from "./ts.ts";102import type * as ts from "lib/ts.ts";
lib/log/ansi.ts+2-2
...@@ -49,7 +49,7 @@ export function bgRgb(r: number, g: number, b: number): string {...@@ -49,7 +49,7 @@ export function bgRgb(r: number, g: number, b: number): string {
49}49}
5050
51// cursor management51// cursor management
52export function cursorUp(n: number) {52export function cursorUp(n: number): string {
53 return n ? `\x1b[${n}A` : "";53 return n ? `\x1b[${n}A` : "";
54}54}
55export const startOfLine: string = "\r";55export const startOfLine: string = "\r";
...@@ -77,7 +77,7 @@ export function style(code: string, text: string): string {...@@ -77,7 +77,7 @@ export function style(code: string, text: string): string {
77 * Given a string of multiple style ansi codes, combines all of them.77 * Given a string of multiple style ansi codes, combines all of them.
78 * Must be used on a string of only ansi style codes.78 * Must be used on a string of only ansi style codes.
79 */79 */
80export function mergeStyles(code: string) {80export function mergeStyles(code: string): string {
81 let styles = [];81 let styles = [];
82 while (code.length) {82 while (code.length) {
83 if (!code.startsWith("\x1b[")) {83 if (!code.startsWith("\x1b[")) {
lib/log/headless.ts+2
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1// This file is under heavy construction.1// This file is under heavy construction.
2// - I am happy with the overall API, likely no changes.
3// - There are certainly a lot of bugs.
2//4//
3// Rendering for 'lib/term' is done by specifying all system APIs up front in5// Rendering for 'lib/term' is done by specifying all system APIs up front in
4// an interface and creating an instance of the widget host. The main file6// an interface and creating an instance of the widget host. The main file
lib/log/progress.sample.ts+2
...@@ -25,6 +25,8 @@ for (let i = 0; i < 10; i += 1) {...@@ -25,6 +25,8 @@ for (let i = 0; i < 10; i += 1) {
25 x = subNodes[2]!.start("other task");25 x = subNodes[2]!.start("other task");
26 y = x.start("deeply nested");26 y = x.start("deeply nested");
27 z = y.start("job");27 z = y.start("job");
28 z.log.info("with inline logs");
29 z.log.warn("with inline logs");
28 }30 }
29 if (i == 8) z?.end();31 if (i == 8) z?.end();
30 if (i === 6) w = y!.start("magic");32 if (i === 6) w = y!.start("magic");
lib/log/progress.ts+111-74
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1// This file is under heavy construction.1// This file is under heavy construction.
2// - I am happy with the overall design
3// - Will rename things
4// - Will be re-working the entire module to have a headless
5// core, but still leaving the top level `start` function.
6// - There are an overwhelming amount of bugs
2//7//
3// A progress tree based on my past uses with clover console v3 and the Zig8// A progress tree based on my past uses with clover console v3 and the Zig
4// Progress API. Pass `Progress` to functions which should track progress,9// Progress API. Pass `Progress` to functions which should track progress,
5// calling `.start()` to create child items. Or, multiple top-level progress10// calling `.start()` to create child items. Or, multiple top-level progress
6// bars can be created with the singleton's `start()` function.11// bars can be created with the singleton's `start()` function.
7import * as self from "./progress.ts";12import * as self from "lib/log/progress.ts";
8self satisfies Ref; // prove you can use the namespace import as a `Progress`13self satisfies Ref; // prove you can use the namespace import as a `Progress`
914
10/**15/**
...@@ -12,12 +17,7 @@ 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`
12 * when given an estimate, a progress bar is rendered.17 * when given an estimate, a progress bar is rendered.
13 */18 */
14export function start(text: string, opts?: StartOptions): Node {19export function start(text: string, opts?: StartOptions): Node {
15 const [state, node] = newNode(text, opts);20 return global.start(text, opts);
16 globalState.push(state);
17 state.signal = topLevelSignaler;
18 if (rerender) rerender();
19 else log.startWidget(globalWidget);
20 return node;
21}21}
2222
23export interface Ref {23export interface Ref {
...@@ -58,10 +58,6 @@ export interface Node extends Ref {...@@ -58,10 +58,6 @@ export interface Node extends Ref {
5858
59 /** end this Node or RootNode. ending collapses children */59 /** end this Node or RootNode. ending collapses children */
60 end(): void;60 end(): void;
61 // /** signal that there will be a failure without ending the node */
62 // pendingFail(): void;
63 // /** end this node with a failure */
64 // endFail(): void;
6561
66 [Symbol.dispose](): void;62 [Symbol.dispose](): void;
67}63}
...@@ -70,30 +66,18 @@ export interface StartOptions {...@@ -70,30 +66,18 @@ export interface StartOptions {
70 estimate?: number | undefined | null;66 estimate?: number | undefined | null;
71}67}
7268
73/** a node that satisfies the api */69/** a valid `Node` without any output */
74const nullNode: Node =70const nullNode: Node = /* @__PURE__ */
75 (function start(text: string, { estimate }: StartOptions = {}): Node {71 (function start(text: string, opts?: StartOptions): Node {
76 return {72 const node = newNode(text, opts)[1];
77 start,73 node.start = start;
78 text,74 node.log = log.scoped("");
79 value: 0,75 return node;
80 estimate: estimate ?? 0,
81 showEstimate: true,
82 passive: false,
83 maxHeight: null,
84 sortChildren: null,
85 inc() {
86 this.value += 1;
87 },
88 log: log.scoped(""),
89 end() {},
90 [Symbol.dispose]() {},
91 };
92 })("root");76 })("root");
9377
94export { nullNode as null };78export { nullNode as null };
9579
96type SortNode =80export type SortNode =
97 & Pick<81 & Pick<
98 Node,82 Node,
99 | "text"83 | "text"
...@@ -105,16 +89,23 @@ type SortNode =...@@ -105,16 +89,23 @@ type SortNode =
105 >89 >
106 & { children: SortNode[] };90 & { children: SortNode[] };
10791
108type Signal = "draw" | "end" | "endFail" | "pendingFail";92export interface Internal extends SortNode {
109interface State extends SortNode {
110 logs: string[];93 logs: string[];
111 children: State[];94 children: Internal[];
112 parent: State | null;95 parent: Internal | null;
113 signal(signal: Signal): void;96 signal(msg: Signal): void;
114}97}
11598
116function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] {99type Signal =
117 const state: State = {100 | "draw"
101 | { type: "end"; title: string; logs: string[] }
102 | { type: "line"; line: string };
103
104function newNode(
105 text: string,
106 { estimate }: StartOptions = {},
107): [Internal, Node] {
108 const state: Internal = {
118 text,109 text,
119 value: 0,110 value: 0,
120 estimate: estimate ?? 0,111 estimate: estimate ?? 0,
...@@ -128,13 +119,20 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] {...@@ -128,13 +119,20 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] {
128 throw new Error("no signaler");119 throw new Error("no signaler");
129 },120 },
130 };121 };
122 let detached = false;
131 function end() {123 function end() {
124 if (detached) return;
132 if (!state.parent) return;125 if (!state.parent) return;
133 const i = state.parent.children.indexOf(state);126 const i = state.parent.children.indexOf(state);
134 if (i === -1) return;127 if (i === -1) return;
135 state.signal("draw");128 let title = state.text;
129 let p: Internal | null = state;
130 while (p = p.parent) title = p.text + " / " + title;
131 state.signal({ type: "end", title, logs: state.logs });
132 state.logs = [];
136 state.parent.children.splice(i, 1);133 state.parent.children.splice(i, 1);
137 state.parent = null;134 state.parent = null;
135 detached = true;
138 }136 }
139 function mutate() {137 function mutate() {
140 const { parent } = state;138 const { parent } = state;
...@@ -144,6 +142,7 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] {...@@ -144,6 +142,7 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] {
144 const scope = headless.logger({142 const scope = headless.logger({
145 log: {143 log: {
146 writeLine(line) {144 writeLine(line) {
145 if (detached) return state.signal({ type: "line", line });
147 state.logs.push(...line.split("\n"));146 state.logs.push(...line.split("\n"));
148 state.signal("draw");147 state.signal("draw");
149 },148 },
...@@ -226,6 +225,27 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] {...@@ -226,6 +225,27 @@ function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] {
226 return [state, binding];225 return [state, binding];
227}226}
228227
228function format(now: number, list: Internal[]) {
229 let out = "";
230 for (const top of list) {
231 if (top.passive && top.children.length === 0) continue;
232 out += renderMainLine(top, now, 0) + "\n";
233 out += renderChildren(top, now, []);
234 }
235 return out.trimEnd();
236}
237
238/** These functions are used to test `lib/log/progress` */
239export const internals = {
240 node(text: string, props: Partial<Internal>, children: Internal[]): Internal {
241 const [node] = newNode(text);
242 Object.assign(node, props);
243 node.children.push(...children);
244 return node;
245 },
246 format,
247};
248
229const spinnerFps = 12.5;249const spinnerFps = 12.5;
230250
231const box = {251const box = {
...@@ -238,31 +258,7 @@ const fullBar = "█";...@@ -238,31 +258,7 @@ const fullBar = "█";
238const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]258const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
239 .map((frame) => ansi.style(ansi.fgBlue, frame));259 .map((frame) => ansi.style(ansi.fgBlue, frame));
240260
241let rerender: (() => void) | null = null;261function renderMainLine(state: Internal, now: number, depth: number) {
242let globalTimer: ts.Interval | null = null;
243const globalWidget: log.Widget = {
244 format(now) {
245 let out = "";
246 for (const top of globalState) {
247 if (top.passive && top.children.length === 0) continue;
248 out += renderMainLine(top, now, 0) + "\n";
249 out += renderChildren(top, now, []);
250 }
251 return out.trimEnd();
252 },
253 onChange(cb) {
254 //const globalTimer = setInterval(cb, 1000 / spinnerFps);
255 //if ("unref" in globalTimer) (globalTimer.unref as () => void)();
256 rerender = cb;
257 return () => {
258 globalTimer != null && clearInterval(globalTimer);
259 rerender = null;
260 };
261 },
262};
263const globalState: State[] = [];
264
265function renderMainLine(state: State, now: number, depth: number) {
266 if (state.estimate && state.showEstimate) {262 if (state.estimate && state.showEstimate) {
267 return ansi.style(263 return ansi.style(
268 ansi.bgBrightBlack + ansi.fgBlue,264 ansi.bgBrightBlack + ansi.fgBlue,
...@@ -278,14 +274,14 @@ function renderMainLine(state: State, now: number, depth: number) {...@@ -278,14 +274,14 @@ function renderMainLine(state: State, now: number, depth: number) {
278 (state.value > 0 ? `[${state.value}] ` : "") + state.text;274 (state.value > 0 ? `[${state.value}] ` : "") + state.text;
279}275}
280276
281function renderChildren(state: State, now: number, depth: boolean[]) {277function renderChildren(state: Internal, now: number, depth: boolean[]) {
282 let maxHeight = 25; // TODO:278 let maxHeight = 25; // TODO:
283 let truncated = 0;279 let truncated = 0;
284 let out = "";280 let out = "";
285 const { children } = state;281 const { children } = state;
286 if (children.length === 0) return "";282 if (children.length === 0) return "";
287 for (let i = 0, { length } = children; i < length; i += 1) {283 for (let i = 0, { length } = children; i < length; i += 1) {
288 const child = children[i];284 const child = UNWRAP(children[i]);
289 if (child.passive && child.children.length === 0) continue;285 if (child.passive && child.children.length === 0) continue;
290 let item = "";286 let item = "";
291 const left = depth.map((x) => x ? box.line : " ").join("");287 const left = depth.map((x) => x ? box.line : " ").join("");
...@@ -333,14 +329,55 @@ export function getUnicodeBar(progress: number, width: number) {...@@ -333,14 +329,55 @@ export function getUnicodeBar(progress: number, width: number) {
333 return fill + partChar + empty;329 return fill + partChar + empty;
334}330}
335331
336function topLevelSignaler(this: State, signal: Signal) {332/** Run a progress tree without a dependency on a TTY */
337 if (signal === "draw") {333export class Headless {
338 rerender?.();334 constructor(
335 /**
336 * `startWidget` is only ever called from `start`
337 * `writeLine` is used for logs from completed nodes
338 */
339 public env: Pick<headless.WidgetHost, "writeLine" | "startWidget">,
340 ) {}
341
342 active: Internal[] = [];
343
344 start(text: string, opts?: StartOptions): Node {
345 const [state, node] = newNode(text, opts);
346 this.active.push(state);
347 state.signal = this.#signal;
348 const rerender = this.#rerender;
349 if (rerender) rerender();
350 else log.startWidget(this.widget);
351 return node;
339 }352 }
353
354 widget: log.Widget = {
355 format: (now) => format(now, this.active),
356 onChange: (
357 rerender,
358 ) => (this.#rerender = rerender, () => this.#rerender = null),
359 };
360
361 #rerender?: (() => void) | null;
362 #signal = (msg: Signal) => {
363 if (msg === "draw") this.#rerender?.();
364 else if (msg.type === "line") this.env.writeLine(msg.line);
365 else if (msg.type === "end") {
366 if (msg.logs.length === 0) return;
367 const { logs } = msg;
368 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;
369 const header = `[${count} from ${msg.title}]`;
370 this.env.writeLine(ansi.style(ansi.fgBrightBlack, header));
371 this.env.writeLine(msg.logs.join("\n"));
372 }
373 };
340}374}
341375
342import * as ts from "../ts.ts";376const global = /** @__PURE__ */ new Headless(log);
343import * as log from "../log.ts";377
344import * as headless from "../log/headless.ts";378import * as process from "node:process";
345import * as ansi from "./ansi.ts";379import * as log from "lib/log.ts";
346import * as string from "../string.ts";380import * as headless from "lib/log/headless.ts";
381import * as ansi from "lib/log/ansi.ts";
382import * as string from "lib/string.ts";
383import { UNWRAP } from "lib/assert.ts";
lib/queue.ts+3-1
...@@ -136,7 +136,7 @@ export class PriorityQueue {...@@ -136,7 +136,7 @@ export class PriorityQueue {
136 });136 });
137 }137 }
138138
139 canRun({ cores }: Internal) {139 canRun({ cores }: Internal): boolean {
140 const { coresRemain, concurrency } = this;140 const { coresRemain, concurrency } = this;
141 return coresRemain >= cores ||141 return coresRemain >= cores ||
142 (cores > concurrency && coresRemain === concurrency);142 (cores > concurrency && coresRemain === concurrency);
...@@ -166,3 +166,5 @@ function insertSorted<T extends { priority: number }>(arr: T[], item: T) {...@@ -166,3 +166,5 @@ function insertSorted<T extends { priority: number }>(arr: T[], item: T) {
166}166}
167167
168const global = new PriorityQueue(navigator.hardwareConcurrency);168const global = new PriorityQueue(navigator.hardwareConcurrency);
169
170import { UNWRAP } from "lib/assert.ts";
lib/string.ts+7-5
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1// TODO: this function is not benchmarked1// TODO: this function is not benchmarked
2export function countNewlines(str: string) {2export function countNewlines(str: string): number {
3 let count = 0;3 let count = 0;
4 for (let i = 0, { length } = str; i < length; i += 1) {4 for (let i = 0, { length } = str; i < length; i += 1) {
5 if (str[i] === "\n") count += 1;5 if (str[i] === "\n") count += 1;
...@@ -7,7 +7,7 @@ export function countNewlines(str: string) {...@@ -7,7 +7,7 @@ export function countNewlines(str: string) {
7 return count;7 return count;
8}8}
99
10export function widthInTerminal(str: string) {10export function widthInTerminal(str: string): number {
11 let width = 0;11 let width = 0;
12 if (str.includes("\x1b")) str = str.replace(ansiEscapes, "");12 if (str.includes("\x1b")) str = str.replace(ansiEscapes, "");
13 for (let i = 0, { length } = str; i < length; i += 1) {13 for (let i = 0, { length } = str; i < length; i += 1) {
...@@ -32,7 +32,7 @@ export function widthInTerminal(str: string) {...@@ -32,7 +32,7 @@ export function widthInTerminal(str: string) {
32 return width;32 return width;
33}33}
3434
35export function trimToWidth(str: string, columns: number) {35export function trimToWidth(str: string, columns: number): string {
36 let width = 0;36 let width = 0;
37 let ansi = false;37 let ansi = false;
38 for (let i = 0, { length } = str; i < length; i += 1) {38 for (let i = 0, { length } = str; i < length; i += 1) {
...@@ -57,7 +57,7 @@ export function trimToWidth(str: string, columns: number) {...@@ -57,7 +57,7 @@ export function trimToWidth(str: string, columns: number) {
57 str = str.slice(i);57 str = str.slice(i);
58 const segments = segmenter.segment(str);58 const segments = segmenter.segment(str);
59 {59 {
60 const { segment } = segments.containing(0);60 const { segment } = UNWRAP(segments.containing(0));
61 width += segmentWidth(segment);61 width += segmentWidth(segment);
62 if (width > columns) return ansi ? out + "\x1b[0m" : out;62 if (width > columns) return ansi ? out + "\x1b[0m" : out;
63 out += segment;63 out += segment;
...@@ -73,7 +73,7 @@ export function trimToWidth(str: string, columns: number) {...@@ -73,7 +73,7 @@ export function trimToWidth(str: string, columns: number) {
73 continue;73 continue;
74 }74 }
75 }75 }
76 const { segment } = segments.containing(i);76 const { segment } = UNWRAP(segments.containing(i));
77 width += segmentWidth(segment);77 width += segmentWidth(segment);
78 if (width > columns) return ansi ? out + "\x1b[0m" : out;78 if (width > columns) return ansi ? out + "\x1b[0m" : out;
79 out += segment;79 out += segment;
...@@ -154,3 +154,5 @@ function segmentWidth(segment: string) {...@@ -154,3 +154,5 @@ function segmentWidth(segment: string) {
154 list.includes(x)154 list.includes(x)
155 );155 );
156}156}
157
158import { UNWRAP } from "lib/assert.ts";