@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.
- package/LICENSE +21 -0
- package/README.md +901 -2
- package/dist/cjs/index.cjs +72 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/levels.cjs +82 -0
- package/dist/cjs/levels.cjs.map +1 -0
- package/dist/cjs/logger.cjs +576 -0
- package/dist/cjs/logger.cjs.map +1 -0
- package/dist/cjs/outputs.cjs +24 -0
- package/dist/cjs/outputs.cjs.map +1 -0
- package/dist/cjs/plugins/columns.cjs +782 -0
- package/dist/cjs/plugins/columns.cjs.map +1 -0
- package/dist/cjs/plugins/graph.cjs +578 -0
- package/dist/cjs/plugins/graph.cjs.map +1 -0
- package/dist/cjs/plugins/json.cjs +644 -0
- package/dist/cjs/plugins/json.cjs.map +1 -0
- package/dist/cjs/plugins/message.cjs +108 -0
- package/dist/cjs/plugins/message.cjs.map +1 -0
- package/dist/cjs/plugins/shared.cjs +194 -0
- package/dist/cjs/plugins/shared.cjs.map +1 -0
- package/dist/cjs/time.cjs +234 -0
- package/dist/cjs/time.cjs.map +1 -0
- package/dist/cjs/utils/text.cjs +646 -0
- package/dist/cjs/utils/text.cjs.map +1 -0
- package/dist/esm/index.js +11 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/levels.js +72 -0
- package/dist/esm/levels.js.map +1 -0
- package/dist/esm/logger.js +572 -0
- package/dist/esm/logger.js.map +1 -0
- package/dist/esm/outputs.js +21 -0
- package/dist/esm/outputs.js.map +1 -0
- package/dist/esm/plugins/columns.js +778 -0
- package/dist/esm/plugins/columns.js.map +1 -0
- package/dist/esm/plugins/graph.js +572 -0
- package/dist/esm/plugins/graph.js.map +1 -0
- package/dist/esm/plugins/json.js +641 -0
- package/dist/esm/plugins/json.js.map +1 -0
- package/dist/esm/plugins/message.js +105 -0
- package/dist/esm/plugins/message.js.map +1 -0
- package/dist/esm/plugins/shared.js +187 -0
- package/dist/esm/plugins/shared.js.map +1 -0
- package/dist/esm/time.js +231 -0
- package/dist/esm/time.js.map +1 -0
- package/dist/esm/utils/text.js +621 -0
- package/dist/esm/utils/text.js.map +1 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/levels.d.ts +50 -0
- package/dist/types/logger.d.ts +245 -0
- package/dist/types/outputs.d.ts +12 -0
- package/dist/types/plugins/columns.d.ts +151 -0
- package/dist/types/plugins/graph.d.ts +243 -0
- package/dist/types/plugins/json.d.ts +169 -0
- package/dist/types/plugins/message.d.ts +181 -0
- package/dist/types/plugins/shared.d.ts +74 -0
- package/dist/types/time.d.ts +48 -0
- package/dist/types/types.d.ts +176 -0
- package/dist/types/utils/text.d.ts +231 -0
- 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
|