authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-12 18:21:02-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-15 12:49:48-07:00
log333b8a7a5c68ab8ab1917a6192022b0f96c46823
tree1afbc15d9c3a4264106e327aa0b1951d905b3f11
parent6f72c75c17126db8f199d81e8e58650a45ed131e
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat(lib/log): writeError will actually write to stderr

closes #93

3 files changed, 176 insertions(+), 48 deletions(-)

lib/log.test.ts+77
...@@ -758,6 +758,83 @@ describe("log widgets", () => {...@@ -758,6 +758,83 @@ describe("log widgets", () => {
758 host.cancel();758 host.cancel();
759 });759 });
760760
761 test("writeError routes to the interactive stream in order", () => {
762 const host = new testing.MockScreen();
763 host.writeOutput("out 1\n");
764 host.writeError("err 1\n");
765 host.writeOutput("out 2\n");
766 host.expectFrame(0, {
767 stdout: "out 1\nout 2\n",
768 stderr: "err 1\n",
769 merged: "out 1\nerr 1\nout 2\n",
770 });
771 host.cancel();
772 });
773
774 test("error text moves widgets like output", () => {
775 const host = new testing.MockScreen();
776 using _ = host.startWidget({ format: () => "w" });
777 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
778 host.writeError("oops\n");
779 host.expectFrame(0, {
780 stdout: "",
781 stderr: testing.MockScreen.sync([
782 ansi.cursorUp(1),
783 ansi.clearFullLine,
784 "oops\n",
785 "w\n",
786 ]),
787 });
788 host.cancel();
789 });
790
791 test("mixed stream partial lines share cursor math", () => {
792 const host = new testing.MockScreen();
793 using _ = host.startWidget({ format: () => "w" });
794 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
795 host.writeOutput("a");
796 host.writeError("b");
797 host.expectFrame(0, {
798 stdout: "a",
799 merged: testing.MockScreen.sync([
800 ansi.cursorUp(1),
801 ansi.clearFullLine,
802 "a",
803 "b",
804 "\n",
805 "w\n",
806 ]),
807 });
808 // the continuation point is column 2, covering both partial chunks
809 host.writeOutput("c\n");
810 host.expectFrame(0, {
811 merged: testing.MockScreen.sync([
812 ansi.cursorUp(2) + ansi.cursorRight(2),
813 "c\n",
814 "w\n",
815 ]),
816 });
817 host.cancel();
818 });
819
820 test("error text stays on screen when output is redirected", () => {
821 const host = new testing.MockScreen({ outputSharesScreen: false });
822 using _ = host.startWidget({ format: () => "w" });
823 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
824 host.writeOutput("to the file\n");
825 host.writeError("to the screen\n");
826 host.expectFrame(0, {
827 stdout: "to the file\n",
828 stderr: testing.MockScreen.sync([
829 ansi.cursorUp(1),
830 ansi.clearFullLine,
831 "to the screen\n",
832 "w\n",
833 ]),
834 });
835 host.cancel();
836 });
837
761 test("widgets start below a partial line", () => {838 test("widgets start below a partial line", () => {
762 const host = new testing.MockScreen();839 const host = new testing.MockScreen();
763 let text = "";840 let text = "";
lib/log.ts+98-48
...@@ -238,14 +238,14 @@ export function writeOutput(text: string) {...@@ -238,14 +238,14 @@ export function writeOutput(text: string) {
238 globalWidgetHost().writeOutput(text);238 globalWidgetHost().writeOutput(text);
239}239}
240240
241// TODO:241/**
242// /**242 * like {@linkcode writeOutput}, but the text is written to the error stream
243// * no built-in prefix, formatting, or newlline. ensures the text does not interweave.243 * (stderr in node.js). relative ordering between output and error text is
244// * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.244 * preserved through the shared flush buffer.
245// */245 */
246// export function writeError(text: string) {246export function writeError(text: string) {
247// globalWidgetHost().writeError(text);247 globalWidgetHost().writeError(text);
248// }248}
249249
250/** write a {@linkcode Message} object directly. */250/** write a {@linkcode Message} object directly. */
251export function writeMessage(m: Message) {251export function writeMessage(m: Message) {
...@@ -390,7 +390,11 @@ export interface TerminalWidgetHostOptions {...@@ -390,7 +390,11 @@ export interface TerminalWidgetHostOptions {
390 * interface to communicate everything about the terminal state correctly.390 * interface to communicate everything about the terminal state correctly.
391 */391 */
392export interface TerminalLock {392export interface TerminalLock {
393 /** recieves ANSI escape sequences for interactive data (should flush immediately) */393 /**
394 * recieves ANSI escape sequences for interactive data, as well as error log
395 * content from `writeError` (should flush immediately). the interactive
396 * stream and the error stream are the same: stderr.
397 */
394 writeInteractive(text: string): void;398 writeInteractive(text: string): void;
395 /** recieves log content from `write` (pre-buffered; should flush immediately) */399 /** recieves log content from `write` (pre-buffered; should flush immediately) */
396 writeOutput(text: string): void;400 writeOutput(text: string): void;
...@@ -460,7 +464,12 @@ export function createTerminalWidgetHost(...@@ -460,7 +464,12 @@ export function createTerminalWidgetHost(
460 let locks = 0;464 let locks = 0;
461 let redrawTime = 0;465 let redrawTime = 0;
462 let lastFlush = 0;466 let lastFlush = 0;
463 let buffer = "";467 // log text awaiting a flush. error text shares the queue so that relative
468 // write order is preserved, but flushes to the interactive stream (stderr)
469 // and always lands on the widget screen, while plain output rows only
470 // count when `outputSharesScreen`. consecutive same-stream writes merge
471 // into one chunk.
472 let buffer: { text: string; err: boolean }[] = [];
464 // visible width of the trailing partial log line (text since the last "\n"473 // visible width of the trailing partial log line (text since the last "\n"
465 // written to output). the cursor column is derived as `partialWidth %474 // written to output). the cursor column is derived as `partialWidth %
466 // columns` at draw time, so the value survives resizes and wrapped lines.475 // columns` at draw time, so the value survives resizes and wrapped lines.
...@@ -536,7 +545,7 @@ export function createTerminalWidgetHost(...@@ -536,7 +545,7 @@ export function createTerminalWidgetHost(
536 }545 }
537 // cancel() during this render pass (an exit handler unwinding through546 // cancel() during this render pass (an exit handler unwinding through
538 // a crashed format callback) skips its teardown; finish it here.547 // a crashed format callback) skips its teardown; finish it here.
539 if (widgets.length === 0 && terminal && !timer && !buffer) {548 if (widgets.length === 0 && terminal && !timer && !buffer.length) {
540 lines = [];549 lines = [];
541 closeTerminal();550 closeTerminal();
542 }551 }
...@@ -550,21 +559,24 @@ export function createTerminalWidgetHost(...@@ -550,21 +559,24 @@ export function createTerminalWidgetHost(
550 if (!lines.length && !widgets.length) {559 if (!lines.length && !widgets.length) {
551 // a redraw can get scheduled with nothing to write (e.g. widgets torn560 // a redraw can get scheduled with nothing to write (e.g. widgets torn
552 // down before the timer fired); it is a no-op, not an error561 // down before the timer fired); it is a no-op, not an error
553 if (!buffer) return;562 if (!buffer.length) return;
554 needsToRestoreCursor = false;563 needsToRestoreCursor = false;
555 needsToSaveCursor = false;564 needsToSaveCursor = false;
556 if (!terminal && writeOutputTemporaryLock) {565 if (
557 writeOutputTemporaryLock(buffer);566 !terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err)
567 ) {
568 const text = buffer.map((c) => c.text).join("");
569 buffer = [];
570 writeOutputTemporaryLock(text);
571 if (outputOnScreen) trackPartialWidth(text);
558 } else {572 } else {
559 acquireTerminal().writeOutput(buffer);573 trackPartialWidth(flushChunks(acquireTerminal()));
560 }574 }
561 if (hasSyncStart) {575 if (hasSyncStart) {
562 acquireTerminal().writeInteractive(ansi.syncEnd);576 acquireTerminal().writeInteractive(ansi.syncEnd);
563 hasSyncStart = false;577 hasSyncStart = false;
564 }578 }
565 closeTerminal();579 closeTerminal();
566 trackPartialWidth(buffer);
567 buffer = "";
568 return;580 return;
569 }581 }
570582
...@@ -624,10 +636,8 @@ export function createTerminalWidgetHost(...@@ -624,10 +636,8 @@ export function createTerminalWidgetHost(
624 needsToRestoreCursor = false;636 needsToRestoreCursor = false;
625 lines = [];637 lines = [];
626 }638 }
627 if (buffer) {639 if (buffer.length) {
628 term.writeOutput(buffer);640 trackPartialWidth(flushChunks(term));
629 trackPartialWidth(buffer);
630 buffer = "";
631 }641 }
632 if (hasSyncStart) {642 if (hasSyncStart) {
633 term.writeInteractive(ansi.syncEnd);643 term.writeInteractive(ansi.syncEnd);
...@@ -639,15 +649,20 @@ export function createTerminalWidgetHost(...@@ -639,15 +649,20 @@ export function createTerminalWidgetHost(
639 return;649 return;
640 }650 }
641651
642 if (buffer && !outputOnScreen) {652 // concatenation of the buffered text that lands on the widget screen,
653 // which is what the cursor math must account for
654 const screenText = buffer
655 .filter((c) => c.err || outputOnScreen)
656 .map((c) => c.text)
657 .join("");
658 if (buffer.length && screenText === "") {
643 // off-screen output (e.g. stdout redirected to a file) does not659 // off-screen output (e.g. stdout redirected to a file) does not
644 // interact with the widget block; flush it plainly and fall through660 // interact with the widget block; flush it plainly and fall through
645 // to a pure widget redraw661 // to a pure widget redraw
646 term.writeOutput(buffer);662 flushChunks(term);
647 buffer = "";
648 }663 }
649664
650 if (buffer) {665 if (buffer.length) {
651 // when writing a buffer alongside widgets, the screen may look like this666 // when writing a buffer alongside widgets, the screen may look like this
652 // > [existing log]667 // > [existing log]
653 // > [optional partial line]668 // > [optional partial line]
...@@ -659,7 +674,7 @@ export function createTerminalWidgetHost(...@@ -659,7 +674,7 @@ export function createTerminalWidgetHost(
659 // first, clear out the space where new lines are going to intersect.674 // first, clear out the space where new lines are going to intersect.
660 // `span.rows` measures the cursor descent in physical rows, so log675 // `span.rows` measures the cursor descent in physical rows, so log
661 // lines wider than the terminal are accounted for correctly.676 // lines wider than the terminal are accounted for correctly.
662 const span = measureTerminalSpan(buffer, pCol, columns);677 const span = measureTerminalSpan(screenText, pCol, columns);
663 // if more rows are buffered than there are widgets, only some are678 // if more rows are buffered than there are widgets, only some are
664 // needed. when a partial line exists, the buffer starts on its row679 // needed. when a partial line exists, the buffer starts on its row
665 // (one above the widget block), hence the -1.680 // (one above the widget block), hence the -1.
...@@ -689,8 +704,8 @@ export function createTerminalWidgetHost(...@@ -689,8 +704,8 @@ export function createTerminalWidgetHost(
689 : "")704 : "")
690 : ""),705 : ""),
691 );706 );
692 // then write output lines on standard out707 // then write the buffered log content to its streams
693 term.writeOutput(buffer);708 flushChunks(term);
694 term.writeInteractive(709 term.writeInteractive(
695 // if the buffer leaves a partial line, the widgets have to go on the710 // if the buffer leaves a partial line, the widgets have to go on the
696 // next line. to avoid breaking stdout, the newline gets emitted on711 // next line. to avoid breaking stdout, the newline gets emitted on
...@@ -753,17 +768,37 @@ export function createTerminalWidgetHost(...@@ -753,17 +768,37 @@ export function createTerminalWidgetHost(
753 needsToRestoreCursor ||= needsToSaveCursor;768 needsToRestoreCursor ||= needsToSaveCursor;
754 needsToSaveCursor = false;769 needsToSaveCursor = false;
755 lines = newWidgetLines;770 lines = newWidgetLines;
756 buffer = "";
757 }771 }
758772
759 /**773 /**
760 * update `partialWidth` after writing `text` to the output. rows written774 * write every buffered chunk to its stream in order, returning the
761 * off-screen never displace the widget block, so they are not tracked.775 * concatenation of the chunks that landed on the widget screen. error
776 * chunks flush through `writeInteractive` since the interactive stream is
777 * the error stream; plain output rows land on screen only when
778 * `outputSharesScreen`.
762 */779 */
763 function trackPartialWidth(text: string) {780 function flushChunks(term: TerminalLock): string {
764 if (!outputOnScreen) return;781 let screen = "";
765 const i = text.lastIndexOf("\n");782 for (const chunk of buffer) {
766 partialWidth = ansi.widthInTerminal(text.slice(i + 1))783 if (chunk.err) {
784 term.writeInteractive(chunk.text);
785 screen += chunk.text;
786 } else {
787 term.writeOutput(chunk.text);
788 if (outputOnScreen) screen += chunk.text;
789 }
790 }
791 buffer = [];
792 return screen;
793 }
794
795 /**
796 * update `partialWidth` after `screenText` (already filtered to the chunks
797 * that landed on the widget screen) was written.
798 */
799 function trackPartialWidth(screenText: string) {
800 const i = screenText.lastIndexOf("\n");
801 partialWidth = ansi.widthInTerminal(screenText.slice(i + 1))
767 + (i === -1 ? partialWidth : 0);802 + (i === -1 ? partialWidth : 0);
768 }803 }
769804
...@@ -807,24 +842,35 @@ export function createTerminalWidgetHost(...@@ -807,24 +842,35 @@ export function createTerminalWidgetHost(
807 hasSyncStart = shortTermDrawLock;842 hasSyncStart = shortTermDrawLock;
808 }843 }
809 if (buffer.length > 0) {844 if (buffer.length > 0) {
810 if (widgets.length === 0 && !terminal && writeOutputTemporaryLock) {845 if (
811 writeOutputTemporaryLock(buffer);846 widgets.length === 0 && !terminal && writeOutputTemporaryLock
847 && !buffer.some((c) => c.err)
848 ) {
849 const text = buffer.map((c) => c.text).join("");
850 buffer = [];
851 writeOutputTemporaryLock(text);
852 if (outputOnScreen) trackPartialWidth(text);
812 } else {853 } else {
813 acquireTerminal().writeOutput(buffer);854 trackPartialWidth(flushChunks(acquireTerminal()));
814 if (widgets.length === 0) closeTerminal();855 if (widgets.length === 0) closeTerminal();
815 }856 }
816 trackPartialWidth(buffer);
817 buffer = "";
818 }857 }
819 }858 }
820859
860 /** append a write to the flush buffer, scheduling the flush */
861 function bufferChunk(chunk: string, err: boolean) {
862 const last = buffer[buffer.length - 1];
863 if (last && last.err === err) last.text += chunk;
864 else buffer.push({ text: chunk, err });
865 redrawSoon(0);
866 }
867
821 return {868 return {
822 writeOutput(chunk) {869 writeOutput(chunk) {
823 if (chunk) buffer += chunk, redrawSoon(0);870 if (chunk) bufferChunk(chunk, false);
824 },871 },
825 writeError(chunk) {872 writeError(chunk) {
826 // TODO: write to stderr. when this was introduced it was not a regression from v3873 if (chunk) bufferChunk(chunk, true);
827 if (chunk) buffer += chunk, redrawSoon(0);
828 },874 },
829 getDrawLock(mode) {875 getDrawLock(mode) {
830 if (rendering) ASSERT(locks === 0);876 if (rendering) ASSERT(locks === 0);
...@@ -1375,11 +1421,15 @@ const globalLog = /* @__PURE__ */ (() =>...@@ -1375,11 +1421,15 @@ const globalLog = /* @__PURE__ */ (() =>
1375 if (global.writeMessage) {1421 if (global.writeMessage) {
1376 global.writeMessage(m);1422 global.writeMessage(m);
1377 } else if (node.process) {1423 } else if (node.process) {
1378 globalWidgetHost()[1424 // info/debug levels land on stdout, warnings and errors on stderr;
1379 (m.level ?? "info") === "info" ? "writeOutput" : "writeError"1425 // colors are keyed off the destination stream
1380 // colors keyed off stdout, the destination of host log output;1426 const err = (m.level ?? "info") !== "info" && m.level !== "debug";
1381 // `logColors` (used for inspect formatting) matches.1427 globalWidgetHost()[err ? "writeError" : "writeOutput"](
1382 ](formatAnsiMessage(m, node.process.stdout.isTTY));1428 formatAnsiMessage(
1429 m,
1430 (err ? node.process.stderr : node.process.stdout).isTTY,
1431 ),
1432 );
1383 } else {1433 } else {
1384 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;1434 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
1385 if (scope) {1435 if (scope) {
lib/readme.changes.md+1
...@@ -17,6 +17,7 @@...@@ -17,6 +17,7 @@
1717
18- `mime`'s database contains `.eot` for embedded opentype fonts.18- `mime`'s database contains `.eot` for embedded opentype fonts.
19- `async.deferred`19- `async.deferred`
20- `log.writeError` writes to stderr with proper logic
2021
21## v422## v4
2223