@mmutils/logger 0.0.0-stage → 0.1.1

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