@mmutils/logger 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +901 -2
  3. package/dist/cjs/index.cjs +72 -0
  4. package/dist/cjs/index.cjs.map +1 -0
  5. package/dist/cjs/levels.cjs +82 -0
  6. package/dist/cjs/levels.cjs.map +1 -0
  7. package/dist/cjs/logger.cjs +576 -0
  8. package/dist/cjs/logger.cjs.map +1 -0
  9. package/dist/cjs/outputs.cjs +24 -0
  10. package/dist/cjs/outputs.cjs.map +1 -0
  11. package/dist/cjs/plugins/columns.cjs +782 -0
  12. package/dist/cjs/plugins/columns.cjs.map +1 -0
  13. package/dist/cjs/plugins/graph.cjs +578 -0
  14. package/dist/cjs/plugins/graph.cjs.map +1 -0
  15. package/dist/cjs/plugins/json.cjs +644 -0
  16. package/dist/cjs/plugins/json.cjs.map +1 -0
  17. package/dist/cjs/plugins/message.cjs +108 -0
  18. package/dist/cjs/plugins/message.cjs.map +1 -0
  19. package/dist/cjs/plugins/shared.cjs +194 -0
  20. package/dist/cjs/plugins/shared.cjs.map +1 -0
  21. package/dist/cjs/time.cjs +234 -0
  22. package/dist/cjs/time.cjs.map +1 -0
  23. package/dist/cjs/utils/text.cjs +646 -0
  24. package/dist/cjs/utils/text.cjs.map +1 -0
  25. package/dist/esm/index.js +11 -0
  26. package/dist/esm/index.js.map +1 -0
  27. package/dist/esm/levels.js +72 -0
  28. package/dist/esm/levels.js.map +1 -0
  29. package/dist/esm/logger.js +572 -0
  30. package/dist/esm/logger.js.map +1 -0
  31. package/dist/esm/outputs.js +21 -0
  32. package/dist/esm/outputs.js.map +1 -0
  33. package/dist/esm/plugins/columns.js +778 -0
  34. package/dist/esm/plugins/columns.js.map +1 -0
  35. package/dist/esm/plugins/graph.js +572 -0
  36. package/dist/esm/plugins/graph.js.map +1 -0
  37. package/dist/esm/plugins/json.js +641 -0
  38. package/dist/esm/plugins/json.js.map +1 -0
  39. package/dist/esm/plugins/message.js +105 -0
  40. package/dist/esm/plugins/message.js.map +1 -0
  41. package/dist/esm/plugins/shared.js +187 -0
  42. package/dist/esm/plugins/shared.js.map +1 -0
  43. package/dist/esm/time.js +231 -0
  44. package/dist/esm/time.js.map +1 -0
  45. package/dist/esm/utils/text.js +621 -0
  46. package/dist/esm/utils/text.js.map +1 -0
  47. package/dist/types/index.d.ts +11 -0
  48. package/dist/types/levels.d.ts +50 -0
  49. package/dist/types/logger.d.ts +245 -0
  50. package/dist/types/outputs.d.ts +12 -0
  51. package/dist/types/plugins/columns.d.ts +151 -0
  52. package/dist/types/plugins/graph.d.ts +243 -0
  53. package/dist/types/plugins/json.d.ts +169 -0
  54. package/dist/types/plugins/message.d.ts +181 -0
  55. package/dist/types/plugins/shared.d.ts +74 -0
  56. package/dist/types/time.d.ts +48 -0
  57. package/dist/types/types.d.ts +176 -0
  58. package/dist/types/utils/text.d.ts +231 -0
  59. package/package.json +85 -4
@@ -0,0 +1,778 @@
1
+ import { SLOW_CHARS, textWidth, charWidth } from '../utils/text.js';
2
+ import { paint } from './shared.js';
3
+
4
+ /**
5
+ * Wrap modes used unless `wrap` overrides them: graphs are cut (a wrapped
6
+ * graph is unreadable, a cut one shows the width is off), everything else
7
+ * wraps hard.
8
+ */
9
+ const DEFAULT_COLUMNS_WRAP = {
10
+ '*': 'hard',
11
+ graph: 'truncate',
12
+ };
13
+ const RESET = '\x1b[0m';
14
+ const MAX_COLUMNS = 100;
15
+ const MAX_WIDTH = 10_000;
16
+ const MIN_WIDTH = 2;
17
+ /**
18
+ * Width of a whole grid, frames included, at most. Output grows with the
19
+ * grid's width times the lines of a row.
20
+ */
21
+ const MAX_TOTAL_WIDTH = 10_000;
22
+ /** Lines of one cell at most; the last one becomes `CELL_CUT_NOTE`. */
23
+ const MAX_CELL_LINES = 1000;
24
+ /**
25
+ * Rows a streaming grid holds at most while waiting for a column: older
26
+ * rows are written as they are.
27
+ */
28
+ const MAX_BUFFERED_ROWS = 1000;
29
+ const CELL_CUT_NOTE = `… cut at ${String(MAX_CELL_LINES)} lines`;
30
+ const TAB_SIZE = 8;
31
+ /** Terminal height assumed when it is unknown. */
32
+ const DEFAULT_ROWS = 24;
33
+ /* eslint-disable no-control-regex -- matching control characters is the point */
34
+ const CONTROL = /[\x00-\x1f\x7f-\x9f]/;
35
+ const SGR_AT = /\x1B\[[0-9;:]*m/y;
36
+ /* eslint-enable no-control-regex */
37
+ /**
38
+ * SGR sequences in effect after `sgr`, replayed at the start of a wrapped
39
+ * line. A reset clears them.
40
+ */
41
+ const applySgr = (active, sgr) => {
42
+ const params = sgr.slice(2, -1);
43
+ if (params === '' || params === '0')
44
+ return '';
45
+ return params.startsWith('0;') ? sgr : active + sgr;
46
+ };
47
+ /**
48
+ * Ends a wrapped line with a reset if styles are in effect, so they never
49
+ * reach the padding, the frame or the next column.
50
+ */
51
+ const closeLine = (text, active) => active === '' ? text : `${text}${RESET}`;
52
+ const SGR_GLOBAL = new RegExp(SGR_AT.source, 'g');
53
+ /**
54
+ * Builds the wrapped lines of one cell. A single module-level instance:
55
+ * wrapping is synchronous and never re-entered, so nothing is allocated per
56
+ * log but the strings.
57
+ */
58
+ class LineBuilder {
59
+ out = [];
60
+ /** Terminal columns of each line in `out`. */
61
+ widths = [];
62
+ width = 0;
63
+ word = false;
64
+ /** Spaces at the start of lines created by wrapping. */
65
+ indent = '';
66
+ /** Lines to produce at most; the rest of the line is skipped. */
67
+ limit = Infinity;
68
+ /** SGR sequences in effect, carried from line to line of a cell. */
69
+ active = '';
70
+ /** A line was left out because of `limit`. */
71
+ dropped = false;
72
+ text = '';
73
+ used = 0;
74
+ /** Columns taken before any text: the indent on wrapped lines. */
75
+ lineStart = 0;
76
+ /** Last space a word wrap can break at: index in `text`, or -1. */
77
+ breakAt = -1;
78
+ breakUsed = 0;
79
+ breakActive = '';
80
+ /**
81
+ * A word wrap broke the line and nothing visible followed yet: spaces
82
+ * are dropped with the break, also in the next runs.
83
+ */
84
+ atBreak = false;
85
+ resetDropped() {
86
+ this.dropped = false;
87
+ }
88
+ get full() {
89
+ return this.out.length >= this.limit;
90
+ }
91
+ start(out, widths, width, word, indent, limit) {
92
+ this.out = out;
93
+ this.widths = widths;
94
+ this.width = width;
95
+ this.word = word;
96
+ this.indent = ' '.repeat(indent);
97
+ this.limit = limit;
98
+ this.startLine(false);
99
+ }
100
+ /**
101
+ * @param wrapped A line created by wrapping: starts with the indent,
102
+ * before the styles in effect, so a background never fills it.
103
+ */
104
+ startLine(wrapped) {
105
+ const indent = wrapped ? this.indent : '';
106
+ this.text = indent + this.active;
107
+ this.used = indent.length;
108
+ this.lineStart = indent.length;
109
+ this.breakAt = -1;
110
+ this.atBreak = false;
111
+ }
112
+ /**
113
+ * Ends the line; `wrapped` when the same text continues. A line holding
114
+ * only what followed a word wrap break (spaces, styles) is left out.
115
+ */
116
+ endLine(wrapped = false) {
117
+ if (this.atBreak) ;
118
+ else if (this.full)
119
+ this.dropped = true;
120
+ else {
121
+ this.out.push(closeLine(this.text, this.active));
122
+ this.widths.push(this.used);
123
+ }
124
+ this.startLine(wrapped);
125
+ }
126
+ /** Plain text: every code unit is one column wide. */
127
+ addRun(run) {
128
+ let start = 0;
129
+ while (!this.full) {
130
+ if (this.atBreak) {
131
+ while (run.charCodeAt(start) === 0x20)
132
+ start++;
133
+ if (start === run.length)
134
+ return;
135
+ this.atBreak = false;
136
+ }
137
+ const rest = run.length - start;
138
+ const room = this.width - this.used;
139
+ if (rest <= room) {
140
+ if (this.word)
141
+ this.markBreak(run, start);
142
+ this.text += start === 0 ? run : run.slice(start);
143
+ this.used += rest;
144
+ return;
145
+ }
146
+ if (this.word) {
147
+ const space = run.lastIndexOf(' ', start + room);
148
+ // a break that leaves an empty line is no break
149
+ if (space >= start && (space > start || this.used > this.lineStart)) {
150
+ this.text += run.slice(start, space);
151
+ this.used += space - start;
152
+ this.endLine(true);
153
+ this.atBreak = true;
154
+ start = space + 1;
155
+ continue;
156
+ }
157
+ if (this.breakAt >= 0) {
158
+ this.splitAtBreak();
159
+ continue;
160
+ }
161
+ }
162
+ this.text += run.slice(start, start + room);
163
+ this.used += room;
164
+ start += room;
165
+ this.endLine(true);
166
+ }
167
+ }
168
+ /**
169
+ * One code point that is not one column wide, or a control. A wide
170
+ * character that doesn't fit even on a fresh line is placed anyway.
171
+ */
172
+ addChar(char, columns) {
173
+ while (this.used + columns > this.width &&
174
+ this.used > this.lineStart &&
175
+ !this.full)
176
+ if (this.word && this.breakAt >= 0)
177
+ this.splitAtBreak();
178
+ else
179
+ this.endLine(true);
180
+ // no room next to the indent: this line goes without it
181
+ if (this.used === this.lineStart && this.used + columns > this.width) {
182
+ this.text = this.active;
183
+ this.used = 0;
184
+ this.lineStart = 0;
185
+ }
186
+ this.atBreak = false;
187
+ this.text += char;
188
+ this.used += columns;
189
+ }
190
+ addTab() {
191
+ this.atBreak = false;
192
+ if (this.used === this.width)
193
+ this.endLine(true);
194
+ const spaces = Math.min(TAB_SIZE - (this.used % TAB_SIZE), this.width - this.used);
195
+ this.text += ' '.repeat(spaces);
196
+ this.used += spaces;
197
+ }
198
+ addSgr(sgr) {
199
+ this.active = applySgr(this.active, sgr);
200
+ this.text += sgr;
201
+ }
202
+ /** Remembers the last space of a run appended whole. */
203
+ markBreak(run, start) {
204
+ const space = run.lastIndexOf(' ');
205
+ if (space < start)
206
+ return;
207
+ const used = this.used + space - start;
208
+ if (used === this.lineStart)
209
+ return;
210
+ this.breakAt = this.text.length + space - start;
211
+ this.breakUsed = used;
212
+ this.breakActive = this.active;
213
+ }
214
+ /** Ends the line at the last space; the text after it starts a new line. */
215
+ splitAtBreak() {
216
+ const { text, breakAt, breakActive, indent } = this;
217
+ if (this.full)
218
+ this.dropped = true;
219
+ else {
220
+ this.out.push(closeLine(text.slice(0, breakAt), breakActive));
221
+ this.widths.push(this.breakUsed);
222
+ }
223
+ this.used = indent.length + this.used - this.breakUsed - 1;
224
+ this.lineStart = indent.length;
225
+ this.text = indent + breakActive + text.slice(breakAt + 1);
226
+ this.breakAt = -1;
227
+ this.atBreak = this.used === this.lineStart;
228
+ }
229
+ }
230
+ const builder = new LineBuilder();
231
+ const SPECIAL = new RegExp(SLOW_CHARS.source, 'g');
232
+ /**
233
+ * A control character as `\xHH`, like the `'safe'` sanitizer.
234
+ */
235
+ const hexEscape = (code) => `\\x${code.toString(16).toUpperCase().padStart(2, '0')}`;
236
+ /**
237
+ * Feeds one line to the builder: runs of plain text are sliced, only escape
238
+ * codes, tabs and characters that are not one column wide are handled one
239
+ * by one. Stops when the builder is full.
240
+ *
241
+ * Control characters other than SGR sequences and tabs are shown as `\xHH`,
242
+ * even in text logged with `sanitize: 'none'`: their effect on the terminal
243
+ * (carriage returns, cursor moves, screen clears) can't be laid out, and in
244
+ * live mode they would break the redraw.
245
+ */
246
+ const feedLine = (line) => {
247
+ // one scan: a line without special characters is a single run
248
+ let start = 0;
249
+ SPECIAL.lastIndex = 0;
250
+ for (let match = SPECIAL.exec(line); match !== null && !builder.full; match = SPECIAL.exec(line)) {
251
+ const { index } = match;
252
+ if (index > start)
253
+ builder.addRun(line.slice(start, index));
254
+ const code = line.charCodeAt(index);
255
+ start = index + 1;
256
+ if (code === 0x1b) {
257
+ SGR_AT.lastIndex = index;
258
+ const sgr = SGR_AT.exec(line);
259
+ if (sgr === null)
260
+ builder.addRun('\\x1B');
261
+ else {
262
+ builder.addSgr(sgr[0]);
263
+ start = index + sgr[0].length;
264
+ }
265
+ }
266
+ else if (code === 0x09)
267
+ builder.addTab();
268
+ else if (code < 0x20 || (code >= 0x7f && code <= 0x9f))
269
+ builder.addRun(hexEscape(code));
270
+ else {
271
+ const codePoint = line.codePointAt(index) ?? 0;
272
+ if (codePoint > 0xffff)
273
+ start = index + 2;
274
+ builder.addChar(line.slice(index, start), charWidth(codePoint));
275
+ }
276
+ SPECIAL.lastIndex = start;
277
+ }
278
+ if (start < line.length && !builder.full)
279
+ builder.addRun(line.slice(start));
280
+ };
281
+ /**
282
+ * Cuts one line at `width` columns with `marker` (one column) in the last
283
+ * one. Lines that fit are added as they are.
284
+ */
285
+ const truncateLine = (line, width, out, widths, marker) => {
286
+ const { active } = builder;
287
+ const fitted = [];
288
+ const fittedWidths = [];
289
+ builder.start(fitted, fittedWidths, width, false, 0, 2);
290
+ feedLine(line);
291
+ builder.endLine();
292
+ if (fitted.length === 1) {
293
+ out.push(fitted[0] ?? '');
294
+ widths.push(fittedWidths[0] ?? 0);
295
+ return;
296
+ }
297
+ // too long: again, one column narrower, and the marker
298
+ builder.active = active;
299
+ const cut = [];
300
+ const cutWidths = [];
301
+ builder.start(cut, cutWidths, width - 1, false, 0, 1);
302
+ feedLine(line);
303
+ builder.endLine();
304
+ const used = cutWidths[0] ?? 0;
305
+ // the marker in the last column
306
+ out.push(`${cut[0] ?? ''}${' '.repeat(Math.max(0, width - 1 - used))}${marker}`);
307
+ widths.push(width);
308
+ // styles in effect after the whole line, for the cell's next line
309
+ let after = active;
310
+ if (line.includes('\x1b'))
311
+ for (const [sgr] of line.matchAll(SGR_GLOBAL))
312
+ after = applySgr(after, sgr);
313
+ builder.active = after;
314
+ };
315
+ /**
316
+ * Wraps text into lines of at most `width` terminal columns, not padded,
317
+ * with the columns of each line in `widths`.
318
+ *
319
+ * @param maxLines Lines to produce at most.
320
+ * @returns `true` if lines were left out because of `maxLines`.
321
+ */
322
+ const wrapLines = (text, width, { mode = 'hard', marker = '…', indent = 0 }, out, widths, maxLines) => {
323
+ builder.active = '';
324
+ builder.resetDropped();
325
+ const truncate = mode === 'truncate';
326
+ for (const line of text.split('\n')) {
327
+ if (out.length >= maxLines)
328
+ return true;
329
+ builder.start(out, widths, width, mode === 'word', indent, maxLines);
330
+ if (truncate)
331
+ truncateLine(line, width, out, widths, marker);
332
+ else {
333
+ feedLine(line);
334
+ builder.endLine();
335
+ if (builder.dropped)
336
+ return true;
337
+ }
338
+ }
339
+ return false;
340
+ };
341
+ /**
342
+ * Splits rendered text into lines of exactly `width` terminal columns, each
343
+ * padded with spaces. Lines longer than the width are wrapped or cut, see
344
+ * `WrapMode`. Never splits an SGR sequence, a surrogate pair or a wide
345
+ * character; styles in effect are closed at the end of each line and
346
+ * reopened on the next one. Tabs become spaces up to the next multiple of 8.
347
+ */
348
+ const wrapText = (text, width, options = {}) => {
349
+ const out = [];
350
+ const widths = [];
351
+ wrapLines(text, width, options, out, widths, Infinity);
352
+ return out.map((line, index) => `${line}${' '.repeat(Math.max(0, width - (widths[index] ?? 0)))}`);
353
+ };
354
+ /**
355
+ * Text without trailing spaces. A backwards scan: `/ +$/` is quadratic on
356
+ * lines with many spaces (graph rows), it took 40% of a graph in a column.
357
+ */
358
+ const trimSpaces = (text) => {
359
+ let end = text.length;
360
+ while (end > 0 && text.charCodeAt(end - 1) === 0x20)
361
+ end--;
362
+ return end === text.length ? text : text.slice(0, end);
363
+ };
364
+ const rowHeight = (row) => {
365
+ let height = 1;
366
+ for (const cell of row)
367
+ if (cell !== undefined)
368
+ height = Math.max(height, cell.lines.length);
369
+ return height;
370
+ };
371
+ /**
372
+ * Joins rows of cells into terminal lines: each row as tall as its tallest
373
+ * cell (at least one line), empty cells blank, the frame painted with the
374
+ * styles of the row's first cell. The last column is not padded.
375
+ */
376
+ const renderRows = (rows, settings) => {
377
+ const { columns, width, frame, frameEnd, frameStyle } = settings;
378
+ const blank = ' '.repeat(width);
379
+ const lines = [];
380
+ for (const row of rows) {
381
+ const { styles } = row.find((cell) => cell !== undefined) ?? {};
382
+ const style = styles?.[frameStyle] ?? '';
383
+ const reset = styles?.reset ?? '';
384
+ const painted = paint(style, frame, reset);
385
+ const paintedEnd = paint(style, frameEnd, reset);
386
+ const height = rowHeight(row);
387
+ for (let index = 0; index < height; index++) {
388
+ let line = '';
389
+ for (let column = 0; column < columns; column++) {
390
+ const cell = row[column];
391
+ let text = cell?.lines[index];
392
+ if (column === columns - 1)
393
+ text = text === undefined ? '' : trimSpaces(text);
394
+ else if (text === undefined)
395
+ text = blank;
396
+ else {
397
+ const used = cell?.widths[index] ?? width;
398
+ if (used < width)
399
+ text += blank.slice(0, width - used);
400
+ }
401
+ if (column > 0)
402
+ line += text === '' ? paintedEnd : painted;
403
+ line += text;
404
+ }
405
+ // a frame of spaces before an empty last column
406
+ lines.push(trimSpaces(line));
407
+ }
408
+ }
409
+ return lines;
410
+ };
411
+ /**
412
+ * Cells of one grid. Rows that can no longer change are written and
413
+ * dropped: `rows[0]` is row `written`.
414
+ */
415
+ class Grid {
416
+ settings;
417
+ live;
418
+ /** Active column. */
419
+ active = 0;
420
+ /** Number of rows written, which can't change any more. */
421
+ written = 0;
422
+ /** Rows that can still change. */
423
+ rows = [];
424
+ /** Per column, the row the next log goes to. */
425
+ cursors;
426
+ /** Live mode: lines drawn for `rows`, redrawn on the next change. */
427
+ drawn = 0;
428
+ /** Live mode: `write` is drawing the rows. */
429
+ drawing = false;
430
+ constructor(settings,
431
+ /** Redraw on every log instead of writing finished rows. */
432
+ live) {
433
+ this.settings = settings;
434
+ this.live = live;
435
+ this.cursors = Array.from({ length: settings.columns }, () => 0);
436
+ }
437
+ /** Number of rows, written or not. */
438
+ get rowCount() {
439
+ return this.written + this.rows.length;
440
+ }
441
+ /** Puts rendered text in the active column's cell at its cursor. */
442
+ put(text, styles, kind) {
443
+ this.assertNotDrawing();
444
+ const { active, settings } = this;
445
+ let row = this.cursors[active] ?? 0;
446
+ if (row < this.written)
447
+ row = this.firstFree(active);
448
+ while (this.rowCount <= row)
449
+ this.rows.push([]);
450
+ const cells = this.rows[row - this.written] ?? [];
451
+ const mode = settings.wrap[kind] ?? settings.wrap['*'] ?? 'hard';
452
+ const marker = mode === 'truncate' ?
453
+ paint(styles.textDim ?? '', '…', styles.reset ?? '')
454
+ : '…';
455
+ const lines = [];
456
+ const widths = [];
457
+ const cut = wrapLines(text, settings.width, { mode, marker, indent: settings.indent }, lines, widths, MAX_CELL_LINES);
458
+ if (cut) {
459
+ const note = CELL_CUT_NOTE.slice(0, settings.width);
460
+ lines[MAX_CELL_LINES - 1] = paint(styles.textDim ?? '', note, styles.reset ?? '');
461
+ widths[MAX_CELL_LINES - 1] = note.length;
462
+ }
463
+ cells[active] = { lines, widths, styles };
464
+ this.cursors[active] = row + 1;
465
+ if (this.live)
466
+ this.redraw();
467
+ else
468
+ this.writeReady();
469
+ }
470
+ /** Writes everything left. Live mode has it on screen already. */
471
+ end() {
472
+ if (!this.live && this.rows.length > 0)
473
+ this.settings.write(renderRows(this.rows, this.settings).join('\n'));
474
+ this.freeze(this.rows.length);
475
+ }
476
+ /** Live mode: leaves what is drawn on screen, before other output. */
477
+ freezeAll() {
478
+ if (!this.live)
479
+ return;
480
+ this.assertNotDrawing();
481
+ this.freeze(this.rows.length);
482
+ }
483
+ /**
484
+ * A live redraw can't be interleaved: a `write` that logs to the same
485
+ * logger would draw over the lines being drawn.
486
+ */
487
+ assertNotDrawing() {
488
+ if (this.drawing)
489
+ throw new Error('columnsPlugin: write logged to the same logger during a live redraw. Write to another logger or destination.');
490
+ }
491
+ firstFree(column) {
492
+ let index = 0;
493
+ while (index < this.rows.length && this.rows[index]?.[column] !== undefined)
494
+ index++;
495
+ return this.written + index;
496
+ }
497
+ freeze(count) {
498
+ this.rows.splice(0, count);
499
+ this.written += count;
500
+ this.drawn = 0;
501
+ }
502
+ /** Streaming: writes the rows every column's cursor has passed. */
503
+ writeReady() {
504
+ // rows beyond the buffer limit are written with their empty cells blank:
505
+ // a column that stopped logging can't hold back the others forever
506
+ const ready = Math.max(Math.min(...this.cursors) - this.written, this.rows.length - MAX_BUFFERED_ROWS);
507
+ if (ready <= 0)
508
+ return;
509
+ const rows = this.rows.splice(0, ready);
510
+ this.written += rows.length;
511
+ if (rows.length > 0)
512
+ this.settings.write(renderRows(rows, this.settings).join('\n'));
513
+ }
514
+ /**
515
+ * Live: moves up over the lines drawn last time, clears to the end of the
516
+ * screen and draws the rows again. Rows that would not fit on the screen
517
+ * stay written: the cursor can't reach lines that scrolled off.
518
+ */
519
+ redraw() {
520
+ const lines = renderRows(this.rows, this.settings);
521
+ const up = this.drawn > 0 ? `\x1b[${String(this.drawn)}F\x1b[J` : '';
522
+ this.drawing = true;
523
+ try {
524
+ this.settings.write(up + lines.join('\n'));
525
+ }
526
+ finally {
527
+ this.drawing = false;
528
+ }
529
+ const { rows = DEFAULT_ROWS } = process.stdout;
530
+ const room = Math.max(1, rows - 1);
531
+ let drawn = lines.length;
532
+ let frozen = 0;
533
+ while (drawn > room && frozen < this.rows.length)
534
+ drawn -= rowHeight(this.rows[frozen++] ?? []);
535
+ this.freeze(frozen);
536
+ this.drawn = drawn;
537
+ }
538
+ }
539
+ /**
540
+ * Column mode of one logger: swaps the outputs of the laid out kinds for
541
+ * one that fills the grid, and puts them back on `endColumns`. Outside
542
+ * column mode the logger's pipeline is untouched.
543
+ */
544
+ class Columns {
545
+ logger;
546
+ settings;
547
+ grid;
548
+ saved = [];
549
+ /** Live mode: freezes the grid before logs of other kinds. */
550
+ freezer;
551
+ constructor(logger, settings) {
552
+ this.logger = logger;
553
+ this.settings = settings;
554
+ }
555
+ column(index) {
556
+ const { columns } = this.settings;
557
+ if (index !== undefined &&
558
+ !(Number.isInteger(index) && index >= 0 && index < columns))
559
+ throw new RangeError(`column: index must be an integer from 0 to ${String(columns - 1)}, got ${String(index)}.`);
560
+ const { grid } = this;
561
+ if (grid === undefined)
562
+ this.start().active = index ?? 0;
563
+ else if (index !== undefined)
564
+ grid.active = index;
565
+ else if (grid.active + 1 < columns)
566
+ grid.active++;
567
+ else {
568
+ grid.end();
569
+ this.grid = new Grid(this.settings, grid.live);
570
+ }
571
+ }
572
+ row(index) {
573
+ const grid = this.grid ?? this.start();
574
+ const { active } = grid;
575
+ if (index === undefined) {
576
+ grid.cursors[active] = (grid.cursors[active] ?? 0) + 1;
577
+ return;
578
+ }
579
+ if (!(Number.isInteger(index) && index >= 0 && index <= grid.rowCount))
580
+ throw new RangeError(`row: index must be an integer from 0 to ${String(grid.rowCount)} (the next new row), got ${String(index)}.`);
581
+ grid.cursors[active] = index;
582
+ }
583
+ endColumns() {
584
+ const { grid } = this;
585
+ if (grid === undefined)
586
+ return;
587
+ try {
588
+ grid.end();
589
+ }
590
+ finally {
591
+ this.grid = undefined;
592
+ for (const [kind, output] of this.saved)
593
+ this.logger.setOutput(kind, output);
594
+ this.saved = [];
595
+ if (this.freezer !== undefined) {
596
+ this.logger.removeTransformer(this.freezer);
597
+ this.freezer = undefined;
598
+ }
599
+ }
600
+ }
601
+ start() {
602
+ const { logger, settings } = this;
603
+ const { templateMaps } = logger;
604
+ const kinds = settings.kinds.filter((kind) => templateMaps[kind] !== undefined);
605
+ if (kinds.length === 0)
606
+ throw new Error(`columnsPlugin: no plugin renders the kinds ${settings.kinds.map((kind) => `"${kind}"`).join(', ')}. Use messagePlugin or graphPlugin before columnsPlugin, or set kinds.`);
607
+ const grid = new Grid(settings, this.isLive());
608
+ this.grid = grid;
609
+ const cellOutput = (entry, { styles, kind }) => {
610
+ let text = '';
611
+ for (const key in entry)
612
+ text += String(entry[key]);
613
+ this.grid?.put(text, styles, kind);
614
+ };
615
+ for (const kind of kinds) {
616
+ this.saved.push([kind, logger.getOutput(kind)]);
617
+ logger.setOutput(kind, cellOutput);
618
+ }
619
+ // logs of other kinds, whatever their output, would move the lines a
620
+ // live redraw moves over: the grid leaves its rows on screen first
621
+ if (grid.live) {
622
+ const laidOut = new Set(kinds);
623
+ this.freezer = (entry, { kind }) => {
624
+ if (!laidOut.has(kind))
625
+ this.grid?.freezeAll();
626
+ return entry;
627
+ };
628
+ logger.addTransformer(this.freezer, 0);
629
+ }
630
+ return grid;
631
+ }
632
+ /**
633
+ * Live mode needs a terminal at least as wide as the grid; checked when
634
+ * column mode starts.
635
+ */
636
+ isLive() {
637
+ const { live, customWrite, columns, width, frame } = this.settings;
638
+ if (!live)
639
+ return false;
640
+ const stdout = process.stdout;
641
+ if (!customWrite && stdout.isTTY !== true)
642
+ return false;
643
+ const total = columns * width + (columns - 1) * textWidth(frame);
644
+ return total <= (stdout.columns ?? Infinity);
645
+ }
646
+ }
647
+ const assertInteger = (value, name, min, max) => {
648
+ if (!(typeof value === 'number' &&
649
+ Number.isInteger(value) &&
650
+ value >= min &&
651
+ value <= max))
652
+ throw new RangeError(`columnsPlugin: ${name} must be an integer from ${String(min)} to ${String(max)}, got ${typeof value === 'number' ? String(value) : typeof value}.`);
653
+ };
654
+ const WRAP_MODES = ['hard', 'word', 'truncate'];
655
+ const assertWrapMode = (mode, name) => {
656
+ if (!WRAP_MODES.includes(mode))
657
+ throw new RangeError(`columnsPlugin: ${name} must be 'hard', 'word' or 'truncate', got ${typeof mode === 'string' ? `'${mode}'` : typeof mode}.`);
658
+ };
659
+ /**
660
+ * Wrap modes by kind: the defaults, then the option. Own keys only, into an
661
+ * object without prototype, so kinds like `toString` or `__proto__` are
662
+ * plain names.
663
+ */
664
+ const resolveWrap = (wrap) => {
665
+ const modes = Object.assign(Object.create(null), DEFAULT_COLUMNS_WRAP);
666
+ if (wrap === undefined)
667
+ return modes;
668
+ if (typeof wrap === 'string') {
669
+ assertWrapMode(wrap, 'wrap');
670
+ modes['*'] = wrap;
671
+ return modes;
672
+ }
673
+ if (typeof wrap !== 'object' || wrap === null || Array.isArray(wrap))
674
+ throw new TypeError(`columnsPlugin: wrap must be a mode or an object of modes by kind, got ${wrap === null ? 'null'
675
+ : Array.isArray(wrap) ? 'array'
676
+ : typeof wrap}.`);
677
+ for (const kind of Object.keys(wrap)) {
678
+ const mode = wrap[kind];
679
+ assertWrapMode(mode, `wrap["${kind}"]`);
680
+ modes[kind] = mode;
681
+ }
682
+ return modes;
683
+ };
684
+ const resolveSettings = (options) => {
685
+ const { columns = 2, frame = ' | ', frameStyle = 'textDim', kinds = ['message', 'graph'], live = false, write, } = options;
686
+ assertInteger(columns, 'columns', 1, MAX_COLUMNS);
687
+ if (typeof frame !== 'string')
688
+ throw new TypeError(`columnsPlugin: frame must be a string, got ${typeof frame}.`);
689
+ if (CONTROL.test(frame))
690
+ throw new RangeError('columnsPlugin: frame must not contain control characters.');
691
+ if (typeof frameStyle !== 'string')
692
+ throw new TypeError(`columnsPlugin: frameStyle must be a style name, got ${typeof frameStyle}.`);
693
+ if (!Array.isArray(kinds) ||
694
+ kinds.length === 0 ||
695
+ !kinds.every((kind) => typeof kind === 'string'))
696
+ throw new TypeError('columnsPlugin: kinds must be a non-empty array of kind names.');
697
+ if (typeof live !== 'boolean')
698
+ throw new TypeError(`columnsPlugin: live must be a boolean, got ${typeof live}.`);
699
+ if (write !== undefined && typeof write !== 'function')
700
+ throw new TypeError(`columnsPlugin: write must be a function, got ${typeof write}.`);
701
+ let { width } = options;
702
+ if (width === undefined) {
703
+ const { columns: terminal = 80 } = process.stdout;
704
+ const frames = (columns - 1) * textWidth(frame);
705
+ const room = Math.min(terminal, MAX_TOTAL_WIDTH) - frames;
706
+ width = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.floor(room / columns)));
707
+ }
708
+ assertInteger(width, 'width', MIN_WIDTH, MAX_WIDTH);
709
+ const total = columns * width + (columns - 1) * textWidth(frame);
710
+ if (total > MAX_TOTAL_WIDTH)
711
+ throw new RangeError(`columnsPlugin: the grid must be at most ${String(MAX_TOTAL_WIDTH)} terminal columns wide (columns × width + frames), got ${String(total)}.`);
712
+ const { indent = 0 } = options;
713
+ assertInteger(indent, 'indent', 0, width - 1);
714
+ return {
715
+ columns,
716
+ width,
717
+ frame,
718
+ frameEnd: frame.trimEnd(),
719
+ frameStyle,
720
+ kinds: [...kinds],
721
+ wrap: resolveWrap(options.wrap),
722
+ indent,
723
+ live,
724
+ customWrite: write !== undefined,
725
+ write: write ?? console.log,
726
+ };
727
+ };
728
+ /**
729
+ * Lays out logs in columns, side by side. Each log of the laid out kinds
730
+ * (by default messages and graphs) is a cell, and `column()` / `row()` pick
731
+ * the cell. Calls go call for call: the n-th log of each column shares a
732
+ * row, and a row is as tall as its tallest cell. Lines are wrapped at
733
+ * exactly `width` terminal columns (wide characters take two) and padded,
734
+ * except in the last column.
735
+ *
736
+ * ```ts
737
+ * const logger = new Logger()
738
+ * .use(messagePlugin())
739
+ * .use(graphPlugin({ width: 64 }))
740
+ * .use(columnsPlugin({ columns: 2, width: 64 }));
741
+ * for (const column of [0, 1]) {
742
+ * logger.column(column);
743
+ * logger.info('styled by default');
744
+ * logger.infoGraph(values);
745
+ * }
746
+ * logger.endColumns();
747
+ * ```
748
+ *
749
+ * Rows are written as soon as every column has moved past them or, with
750
+ * `live`, redrawn on every log. Outside `column()` … `endColumns()` the
751
+ * logger is not slowed down: the outputs are only swapped while a grid is
752
+ * open. Use after the plugins that render the kinds.
753
+ *
754
+ * @throws RangeError or TypeError for invalid options.
755
+ */
756
+ const columnsPlugin = (options = {}) => {
757
+ const settings = resolveSettings(options);
758
+ return {
759
+ name: 'columns',
760
+ methods: (logger) => {
761
+ const columns = new Columns(logger, settings);
762
+ return {
763
+ column: (index) => {
764
+ columns.column(index);
765
+ },
766
+ row: (index) => {
767
+ columns.row(index);
768
+ },
769
+ endColumns: () => {
770
+ columns.endColumns();
771
+ },
772
+ };
773
+ },
774
+ };
775
+ };
776
+
777
+ export { DEFAULT_COLUMNS_WRAP, columnsPlugin, wrapText };
778
+ //# sourceMappingURL=columns.js.map