@effected/cli 0.10.0 → 0.12.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 (92) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +302 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +346 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +80 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +110 -54
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +141 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +536 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +145 -131
  23. package/Render.js +255 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +166 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +3089 -169
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +84 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +195 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +320 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +368 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +40 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +432 -0
  56. package/ui/CliUiLive.js +446 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +230 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +290 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lazyView.js +74 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +760 -0
  85. package/ui/testing/fakeStreams.js +79 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing-serializer.d.ts +14 -0
  88. package/ui-testing-serializer.js +33 -0
  89. package/ui-testing.d.ts +527 -0
  90. package/ui-testing.js +3 -0
  91. package/ui.d.ts +1790 -0
  92. package/ui.js +17 -0
package/Doc.js ADDED
@@ -0,0 +1,536 @@
1
+ import { autoFormat } from "./internal/autoFormat.js";
2
+ import { totalOf, visibleCountersOf } from "./internal/counts.js";
3
+ import { Render } from "./Render.js";
4
+ import { Console, Effect } from "effect";
5
+
6
+ //#region src/Doc.ts
7
+ const isList = (input) => Array.isArray(input);
8
+ const freeze = (value) => Object.freeze(value);
9
+ const frozenArray = (items) => Object.freeze([...items]);
10
+ const text = (value, token) => freeze({
11
+ _tag: "Text",
12
+ value,
13
+ ...token === void 0 ? {} : { token }
14
+ });
15
+ const inlineOne = (part) => typeof part === "string" ? text(part) : part;
16
+ const inlines = (input) => frozenArray(isList(input) ? input.map(inlineOne) : [inlineOne(input)]);
17
+ const overflowOf = (overflow) => (hidden) => inlines(overflow(hidden));
18
+ const overflowFields = (options) => ({
19
+ ...options?.cap === void 0 ? {} : { cap: options.cap },
20
+ ...options?.overflow === void 0 ? {} : { overflow: overflowOf(options.overflow) }
21
+ });
22
+ const treeNode = (input) => freeze({
23
+ label: inlines(input.label),
24
+ children: frozenArray((input.children ?? []).map(treeNode))
25
+ });
26
+ const counterOf = (counter) => freeze({
27
+ ...counter,
28
+ label: typeof counter.label === "string" ? counter.label : freeze({
29
+ one: counter.label.one,
30
+ other: counter.label.other
31
+ }),
32
+ status: freeze({
33
+ name: counter.status.name,
34
+ def: freeze({ ...counter.status.def })
35
+ })
36
+ });
37
+ /**
38
+ * Constructors for the document IR, and two helpers a renderer shares.
39
+ *
40
+ * @remarks
41
+ * Every constructor returns a frozen node and copies the arrays it is given, so editing an input afterwards
42
+ * cannot change a document. An optional field that is not given is absent from the node, not `undefined`.
43
+ * Content arguments accept a string, an `Inline` or an array of either.
44
+ *
45
+ * A node is plain data: nothing decodes or encodes one, so a function field such as `overflow` or `total` is fine
46
+ * and a document is not meant to be serialised.
47
+ *
48
+ * Freezing covers what a `Doc` constructor builds. A literal you write by hand is not frozen, and a `Style` object
49
+ * given as a token is shared by reference (the freeze of a status definition is shallow for the same reason).
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { Doc, Status } from "@effected/cli"
54
+ *
55
+ * const report = [
56
+ * Doc.heading(2, "Results"),
57
+ * Doc.paragraph(Doc.status(Status.core, "success"), " ", "3 checks passed"),
58
+ * Doc.table([{ header: "Check" }, { header: "Time", align: "right" }], [["lint", "1.2s"]]),
59
+ * ]
60
+ * // Written for whoever is reading: `yield* Doc.print(report)`
61
+ * ```
62
+ *
63
+ * @public
64
+ */
65
+ var Doc = class {
66
+ constructor() {}
67
+ /**
68
+ * A run of text.
69
+ *
70
+ * @param value - the text
71
+ * @param token - a semantic token or a style to paint it with
72
+ */
73
+ static text(value, token) {
74
+ return text(value, token);
75
+ }
76
+ /**
77
+ * Code in a monospace span.
78
+ *
79
+ * @param value - the code
80
+ */
81
+ static code(value) {
82
+ return freeze({
83
+ _tag: "Code",
84
+ value
85
+ });
86
+ }
87
+ static link(target, label, options) {
88
+ if (target === void 0) return typeof label === "string" ? text(label) : label;
89
+ const fallback = "url" in target ? target.url : target.file;
90
+ return freeze({
91
+ _tag: "Link",
92
+ target: freeze({ ...target }),
93
+ label: inlines(label ?? fallback),
94
+ ...options?.suffix === void 0 ? {} : { suffix: options.suffix }
95
+ });
96
+ }
97
+ /**
98
+ * A status glyph, holding the resolved definition.
99
+ *
100
+ * @remarks
101
+ * A name the vocabulary does not have is a compile error.
102
+ *
103
+ * @param vocab - the vocabulary the name belongs to
104
+ * @param name - a status name in it
105
+ */
106
+ static status(vocab, name) {
107
+ return freeze({
108
+ _tag: "StatusMark",
109
+ name,
110
+ def: vocab.resolve(name)
111
+ });
112
+ }
113
+ /**
114
+ * Content in bold: markdown `**…**`, bold in `ansi`, and the content as is in plain and `githubLog`.
115
+ *
116
+ * @param content - any number of strings, inlines or arrays of them, in order
117
+ */
118
+ static strong(...content) {
119
+ return freeze({
120
+ _tag: "Strong",
121
+ content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
122
+ });
123
+ }
124
+ /**
125
+ * Content in italic: markdown `*…*` (which GFM reads inside a word too), italic in `ansi`, and the content as is in
126
+ * plain and `githubLog`.
127
+ *
128
+ * @param content - any number of strings, inlines or arrays of them, in order
129
+ */
130
+ static em(...content) {
131
+ return freeze({
132
+ _tag: "Emphasis",
133
+ content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
134
+ });
135
+ }
136
+ /**
137
+ * A file path, shown through the context's `displayPath` and never linked.
138
+ *
139
+ * @param path - the path, usually absolute
140
+ */
141
+ static file(path) {
142
+ return freeze({
143
+ _tag: "File",
144
+ path
145
+ });
146
+ }
147
+ /**
148
+ * A path or breadcrumb; a renderer joins the segments with the audience's separator.
149
+ *
150
+ * @param segments - the segments, in order
151
+ */
152
+ static path(...segments) {
153
+ return freeze({
154
+ _tag: "Path",
155
+ segments: frozenArray(segments)
156
+ });
157
+ }
158
+ /**
159
+ * A heading.
160
+ *
161
+ * @param level - 1 to 4
162
+ * @param content - the heading text
163
+ */
164
+ static heading(level, content) {
165
+ return freeze({
166
+ _tag: "Heading",
167
+ level,
168
+ content: inlines(content)
169
+ });
170
+ }
171
+ /**
172
+ * One logical line of content.
173
+ *
174
+ * @param content - any number of strings, inlines or arrays of them, in order
175
+ */
176
+ static paragraph(...content) {
177
+ return freeze({
178
+ _tag: "Paragraph",
179
+ content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
180
+ });
181
+ }
182
+ /**
183
+ * A list of blocks.
184
+ *
185
+ * @param items - the items
186
+ * @param options - `cap`, `overflow`, and `compact` for no blank lines inside an item
187
+ */
188
+ static list(items, options) {
189
+ return freeze({
190
+ _tag: "List",
191
+ items: frozenArray(items),
192
+ ...overflowFields(options),
193
+ ...options?.compact === void 0 ? {} : { compact: options.compact }
194
+ });
195
+ }
196
+ /**
197
+ * A table.
198
+ *
199
+ * @param columns - the columns: a header and an optional alignment each
200
+ * @param rows - the rows; each cell takes a string, an inline or an array of either
201
+ * @param options - `cap`, `overflow`, and `style: "pipe"` for istanbul's shape in plain and `ansi`
202
+ */
203
+ static table(columns, rows, options) {
204
+ return freeze({
205
+ _tag: "Table",
206
+ columns: frozenArray(columns.map((column) => freeze({
207
+ header: inlines(column.header),
208
+ ...column.align === void 0 ? {} : { align: column.align }
209
+ }))),
210
+ rows: frozenArray(rows.map((row) => frozenArray(row.map(inlines)))),
211
+ ...overflowFields(options),
212
+ ...options?.style === void 0 ? {} : { style: options.style }
213
+ });
214
+ }
215
+ /**
216
+ * A tree of labels.
217
+ *
218
+ * @param root - the root; a node's `children` may be left out
219
+ */
220
+ static tree(root) {
221
+ return freeze({
222
+ _tag: "Tree",
223
+ root: treeNode(root)
224
+ });
225
+ }
226
+ /**
227
+ * A titled body a renderer may fold.
228
+ *
229
+ * @param title - the title
230
+ * @param body - the body
231
+ * @param options - `open` asks for it to start unfolded
232
+ */
233
+ static collapsible(title, body, options) {
234
+ return freeze({
235
+ _tag: "Collapsible",
236
+ title: inlines(title),
237
+ body: frozenArray(body),
238
+ ...options?.open === void 0 ? {} : { open: options.open }
239
+ });
240
+ }
241
+ /**
242
+ * A callout.
243
+ *
244
+ * @param kind - `note`, `tip`, `important`, `warning` or `caution`
245
+ * @param body - the body
246
+ */
247
+ static callout(kind, body) {
248
+ return freeze({
249
+ _tag: "Callout",
250
+ kind,
251
+ body: frozenArray(body)
252
+ });
253
+ }
254
+ /**
255
+ * Preformatted text.
256
+ *
257
+ * @param text - the text
258
+ * @param lang - its language, for a renderer that fences it
259
+ */
260
+ static codeBlock(text, lang) {
261
+ return freeze({
262
+ _tag: "CodeBlock",
263
+ ...lang === void 0 ? {} : { lang },
264
+ text
265
+ });
266
+ }
267
+ /**
268
+ * Expected against received text.
269
+ *
270
+ * @param expected - the expected text
271
+ * @param received - the received text
272
+ * @param options - `cap` limits the lines shown
273
+ */
274
+ static diff(expected, received, options) {
275
+ return freeze({
276
+ _tag: "Diff",
277
+ expected,
278
+ received,
279
+ ...options?.cap === void 0 ? {} : { cap: options.cap }
280
+ });
281
+ }
282
+ /**
283
+ * Children under an optional title.
284
+ *
285
+ * @remarks
286
+ * The children are separated by blank lines (unless the document is compact); a title sits directly above the first.
287
+ * `Doc.section(undefined, blocks)` is the way to space a document's top-level blocks, which are otherwise joined with
288
+ * no blank line.
289
+ *
290
+ * @param title - the title, or `undefined` for none
291
+ * @param children - the blocks
292
+ */
293
+ static section(title, children) {
294
+ return freeze({
295
+ _tag: "Section",
296
+ ...title === void 0 ? {} : { title: inlines(title) },
297
+ children: frozenArray(children)
298
+ });
299
+ }
300
+ /**
301
+ * One counter of a `Counts` block, with its status definition resolved.
302
+ *
303
+ * @remarks
304
+ * A name the vocabulary does not have is a compile error.
305
+ *
306
+ * The label is one string, or `{ one, other }` to pluralise by count: `one` when the count is exactly 1 and `other`
307
+ * for every other count, 0 included. A count standing alone reads by its own `n` (`1 change`, `2 changes`); a
308
+ * headline shown as a share of the total reads by that total, the noun it counts (`1/1 repo`, `1/3 repos`,
309
+ * `2/3 repos`).
310
+ *
311
+ * @param vocab - the vocabulary the status belongs to
312
+ * @param name - a status name in it
313
+ * @param options - the counter's `key`, its `label` (one string, or `{ one, other }`), its count `n`, and `showZero`
314
+ * to keep it when `n` is zero
315
+ */
316
+ static counter(vocab, name, options) {
317
+ return counterOf({
318
+ key: options.key,
319
+ label: options.label,
320
+ n: options.n,
321
+ status: {
322
+ name,
323
+ def: vocab.resolve(name)
324
+ },
325
+ ...options.showZero === void 0 ? {} : { showZero: options.showZero }
326
+ });
327
+ }
328
+ /**
329
+ * Counters in one of three layouts.
330
+ *
331
+ * @param options - the counters, the layout and the optional label, total rule, qualifier and duration
332
+ */
333
+ static counts(options) {
334
+ return freeze({
335
+ _tag: "Counts",
336
+ ...options.label === void 0 ? {} : { label: inlines(options.label) },
337
+ counters: frozenArray(options.counters.map(counterOf)),
338
+ ...options.total === void 0 ? {} : { total: options.total },
339
+ ...options.qualifier === void 0 ? {} : { qualifier: inlines(options.qualifier) },
340
+ ...options.durationMs === void 0 ? {} : { durationMs: options.durationMs },
341
+ layout: options.layout,
342
+ ...options.share === void 0 ? {} : { share: options.share },
343
+ ...options.paint === void 0 ? {} : { paint: options.paint },
344
+ ...options.suffix === void 0 ? {} : { suffix: inlines(options.suffix) }
345
+ });
346
+ }
347
+ /**
348
+ * Counters as a table: a row per entry, a column per counter key (in the order the keys first appear, headed by
349
+ * the counter's label), and an optional total row summing each column.
350
+ *
351
+ * @remarks
352
+ * A row without a counter for some key leaves that cell empty, and it counts as zero in the total. A counter whose
353
+ * `n` is zero shows `0`, as a `Doc.table` cell would: a counter's `showZero` has no effect in a table, only in a
354
+ * `Counts` block, so there is no need to set it. `totalRow`
355
+ * labels the total row with a plain `Total` when `true`, or with the content given: for a bold one, pass
356
+ * `totalRow: Doc.strong("Total")`. A column is headed by its counter's `label`; a counter's status paints its cells
357
+ * in `ansi` and is ignored in markdown, so a plain numbers table may pass any status. `labelHeader` heads the label column, which
358
+ * is otherwise empty. When some row has a `durationMs`, a last column shows it with `Fmt.duration`, headed
359
+ * `durationHeader` (`duration` by default); a row without one has an empty cell there and counts as zero in the
360
+ * total row's summed duration.
361
+ *
362
+ * @param rows - each row's label, counters and optional duration
363
+ * @param options - `totalRow`, to add the summed row; the label and duration column headers
364
+ */
365
+ static countsTable(rows, options) {
366
+ const totalRow = options?.totalRow;
367
+ return freeze({
368
+ _tag: "CountsTable",
369
+ rows: frozenArray(rows.map((row) => freeze({
370
+ label: inlines(row.label),
371
+ counters: frozenArray(row.counters.map(counterOf)),
372
+ ...row.durationMs === void 0 ? {} : { durationMs: row.durationMs }
373
+ }))),
374
+ ...totalRow === void 0 ? {} : { totalRow: typeof totalRow === "boolean" ? totalRow : inlines(totalRow) },
375
+ ...options?.labelHeader === void 0 ? {} : { labelHeader: inlines(options.labelHeader) },
376
+ ...options?.durationHeader === void 0 ? {} : { durationHeader: inlines(options.durationHeader) }
377
+ });
378
+ }
379
+ /**
380
+ * Lines, one per entry, in every renderer: markdown joins them with hard breaks so they never collapse into one.
381
+ *
382
+ * @param lines - the entries; each takes a string, an inline or an array of either
383
+ */
384
+ static lines(lines) {
385
+ return freeze({
386
+ _tag: "Lines",
387
+ lines: frozenArray(lines.map(inlines))
388
+ });
389
+ }
390
+ /**
391
+ * One line of content; with `truncate`, it is cut to the width with the glyph set's ellipsis instead of wrapping,
392
+ * and with `wrap: false` it is kept whole on one line whatever the width.
393
+ *
394
+ * @remarks
395
+ * By default a line longer than the width wraps. `wrap: false` keeps it atomic in every audience and renderer, still
396
+ * carrying its status glyphs, theme tokens and links, which {@link Doc.verbatim} (a plain string) cannot: the tool for
397
+ * a finding such as `✗ path:line:col rule message` that a reader greps or reads line by line, while the prose around
398
+ * it still wraps. A line break inside it is still a space. With both `truncate` and `wrap: false`, `truncate` wins:
399
+ * the line is cut to the width.
400
+ *
401
+ * @param content - the line
402
+ * @param options - `truncate`, to cut it to the width; `wrap: false`, to keep it whole
403
+ */
404
+ static line(content, options) {
405
+ return freeze({
406
+ _tag: "Line",
407
+ content: inlines(content),
408
+ ...options?.truncate === void 0 ? {} : { truncate: options.truncate },
409
+ ...options?.wrap === void 0 ? {} : { wrap: options.wrap }
410
+ });
411
+ }
412
+ /**
413
+ * A unified diff as given, such as a test runner's: sanitized, its `+` and `-` lines painted `success` and
414
+ * `failure` in `ansi`, and a `diff` fence in markdown.
415
+ *
416
+ * @remarks
417
+ * With `truncate`, plain and `ansi` cut each line to the width with the glyph set's ellipsis instead of wrapping
418
+ * it; an agent's or a CI's width is unbounded, so nothing is cut for them unless the context gives a finite width.
419
+ * Markdown keeps every line whole. Inside a compact list item a blank line of the diff keeps the item's indent.
420
+ *
421
+ * A trailing line break ends the last line, as in a unified diff file, and adds no blank line after it: `"a\n"` is
422
+ * one line. To end on a blank line, end the text with two line breaks.
423
+ *
424
+ * @param unified - the diff
425
+ * @param options - `cap`, the most lines shown; `truncate`, to cut each line to the width
426
+ */
427
+ static diffText(unified, options) {
428
+ return freeze({
429
+ _tag: "DiffText",
430
+ text: unified,
431
+ ...options?.cap === void 0 ? {} : { cap: options.cap },
432
+ ...options?.truncate === void 0 ? {} : { truncate: options.truncate }
433
+ });
434
+ }
435
+ /**
436
+ * Lines kept exactly: each indented by `indent` spaces, sanitized, and never wrapped.
437
+ *
438
+ * @remarks
439
+ * Plain, `ansi` and `githubLog` write the lines as they are; markdown fences them, so the indentation survives.
440
+ *
441
+ * It is the tool for a single line that must never wrap nor be cut, whatever the width: {@link Doc.line} wraps at
442
+ * the width, or cuts with `truncate`, and `verbatim` does neither.
443
+ *
444
+ * @param text - the lines
445
+ * @param options - `indent`, the spaces in front of every line; none by default
446
+ */
447
+ static verbatim(text, options) {
448
+ return freeze({
449
+ _tag: "Verbatim",
450
+ text,
451
+ ...options?.indent === void 0 ? {} : { indent: options.indent }
452
+ });
453
+ }
454
+ /**
455
+ * A GitHub Actions annotation: `Render.githubLog` writes it as one workflow command (`::error file=…::message`),
456
+ * and every other renderer writes nothing.
457
+ *
458
+ * @remarks
459
+ * It is the kit's own command, so `githubLog` does not neutralize it; its message and properties are escaped, so
460
+ * no text in them can end the command or start another. It is a command where a line starts: at the top level, as a
461
+ * top-level section's child, or as a direct child of a group's body. Nested deeper, it is dropped.
462
+ *
463
+ * @param options - the level, and the optional file, position and title
464
+ * @param message - what it says
465
+ */
466
+ static annotation(options, message) {
467
+ return freeze({
468
+ _tag: "Annotation",
469
+ level: options.level,
470
+ ...options.file === void 0 ? {} : { file: options.file },
471
+ ...options.line === void 0 ? {} : { line: options.line },
472
+ ...options.col === void 0 ? {} : { col: options.col },
473
+ ...options.endLine === void 0 ? {} : { endLine: options.endLine },
474
+ ...options.endColumn === void 0 ? {} : { endColumn: options.endColumn },
475
+ ...options.title === void 0 ? {} : { title: options.title },
476
+ message
477
+ });
478
+ }
479
+ /**
480
+ * The total of a `Counts` block: the caller's rule when it has one, otherwise the sum of `n` over every counter.
481
+ *
482
+ * @remarks
483
+ * The rule sees every counter, including the ones a renderer hides, so hiding never changes the total.
484
+ *
485
+ * @param block - the `Counts` block
486
+ */
487
+ static total(block) {
488
+ return totalOf(block);
489
+ }
490
+ /**
491
+ * The counters a renderer shows: every one except a zero counter that does not ask for `showZero`.
492
+ *
493
+ * @param block - the `Counts` block
494
+ */
495
+ static visibleCounters(block) {
496
+ return visibleCountersOf(block);
497
+ }
498
+ /**
499
+ * Render a document for whoever is running the program and write it to a stream.
500
+ *
501
+ * @remarks
502
+ * The context is {@link Render.context} for the stream, so the width, the colour, the links and the audience
503
+ * come from the services the program already has, and the text is written with `Console.log` or
504
+ * `Console.error`: a test captures it by swapping the `Console`. With `format: "auto"` the renderer follows
505
+ * the audience, and the width is unbounded for an agent, a CI, and a human whose stream is not a terminal.
506
+ *
507
+ * An agent is never written an escape of any kind, even with an explicit `format: "ansi"`: its context is
508
+ * colourless and its links are off. A document that renders to nothing prints nothing.
509
+ *
510
+ * The whole document is written as one `Console.log` (or `Console.error`) call, with its line breaks embedded, so a
511
+ * captured `Console` holds one entry per document, not one per line. Top-level blocks are joined with no blank
512
+ * line between them; wrap them in `Doc.section(undefined, [...])` to space them.
513
+ *
514
+ * `CurrentRuntimeEnv` is read if the environment has one and is not required: a `ci` audience prints
515
+ * GitHub's log format only when it says GitHub Actions, and plain text otherwise, including when it is
516
+ * absent. An explicit `format` is honoured whatever the audience.
517
+ *
518
+ * @param doc - the document
519
+ * @param options - the stream and the format
520
+ */
521
+ static print = (doc, options) => Effect.gen(function* () {
522
+ const stream = options?.stream ?? "stdout";
523
+ const ctx = yield* Render.context(stream, {
524
+ ...options?.displayPath === void 0 ? {} : { displayPath: options.displayPath },
525
+ ...options?.width === void 0 ? {} : { width: options.width }
526
+ });
527
+ const requested = options?.format ?? "auto";
528
+ const format = requested === "auto" ? yield* autoFormat(ctx.audience) : requested;
529
+ const text = Render[format](doc, ctx);
530
+ if (text === "") return;
531
+ yield* stream === "stderr" ? Console.error(text) : Console.log(text);
532
+ });
533
+ };
534
+
535
+ //#endregion
536
+ export { Doc };
package/Fmt.js ADDED
@@ -0,0 +1,133 @@
1
+ import { displayWidth, graphemes, stripAnsi } from "./internal/displayWidth.js";
2
+
3
+ //#region src/Fmt.ts
4
+ const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
5
+ /**
6
+ * Text made safe to lay out and print: complete ANSI and OSC sequences removed, every remaining control character
7
+ * removed (a stray ESC, BEL, BS, DEL, the C1 range) except line feed and carriage return, and a tab turned into a
8
+ * space.
9
+ *
10
+ * @remarks
11
+ * Stripping complete sequences is not enough on its own: a lone ESC survives it, and so does whatever would
12
+ * complete a sequence once two adjacent pieces of text are joined (`ESC` in one node, `[31m` in the next). With
13
+ * every ESC and C0 or C1 control gone after the strip, nothing can reassemble, so the width of the text equals what
14
+ * a terminal shows. A tab counts as no columns but draws up to eight, hence the space.
15
+ *
16
+ * @internal
17
+ */
18
+ const sanitize = (input) => stripAnsi(input).replace(/\t/g, " ").replace(CONTROL, "");
19
+ /**
20
+ * Small, pure formatting primitives for terminal output.
21
+ *
22
+ * @public
23
+ */
24
+ var Fmt = class {
25
+ constructor() {}
26
+ /**
27
+ * Text made safe to lay out and print, exactly as the renderers make it: complete ANSI and OSC sequences removed,
28
+ * every other control character removed (a stray ESC, BEL, BS, DEL, the C1 range) except line feed and carriage
29
+ * return, and a tab turned into a space.
30
+ *
31
+ * @remarks
32
+ * For a `render` or any other text a program builds by hand from data it does not control: an error message, a
33
+ * file name, a value from the environment. Line breaks are kept, so split on them if the text must be one line.
34
+ *
35
+ * @param text - the text
36
+ */
37
+ static sanitize = sanitize;
38
+ /**
39
+ * The display width of `text` in terminal columns: wide East Asian characters and emoji count two,
40
+ * combining marks and ANSI escapes count none.
41
+ *
42
+ * @param text - the text to measure
43
+ */
44
+ static width = (text) => displayWidth(text);
45
+ /**
46
+ * Cut `text` to at most `width` columns, ending in `ellipsis` when it was cut.
47
+ *
48
+ * @remarks
49
+ * Grapheme-safe: a grapheme, such as a wide character or an emoji ZWJ family, is kept whole or dropped
50
+ * whole, so the result is never wider than `width`. Text that already fits is returned unchanged, colour
51
+ * escapes included. Text that must be cut is cut as plain text: its ANSI escapes are dropped rather than cut
52
+ * in half. When the ellipsis cannot fit, it is omitted and the text is cut to `width`; a `width` of 0 or
53
+ * less gives `""`.
54
+ *
55
+ * @param text - the text to truncate
56
+ * @param width - the most columns the result may take
57
+ * @param options - the ellipsis
58
+ */
59
+ static truncate = (text, width, options) => {
60
+ const limit = Number.isFinite(width) ? Math.floor(width) : width === Number.POSITIVE_INFINITY ? Infinity : 0;
61
+ if (limit <= 0) return "";
62
+ if (displayWidth(text) <= limit) return text;
63
+ const plain = stripAnsi(text);
64
+ const ellipsis = options?.ellipsis ?? "…";
65
+ const ellipsisWidth = displayWidth(ellipsis);
66
+ const useEllipsis = ellipsisWidth <= limit && ellipsis !== "";
67
+ const budget = useEllipsis ? limit - ellipsisWidth : limit;
68
+ let out = "";
69
+ let used = 0;
70
+ for (const grapheme of graphemes(plain)) {
71
+ const w = displayWidth(grapheme);
72
+ if (used + w > budget) break;
73
+ out += grapheme;
74
+ used += w;
75
+ }
76
+ return useEllipsis ? `${out}${ellipsis}` : out;
77
+ };
78
+ /**
79
+ * A duration in milliseconds as short human text: `250ms`, `1.2s`, `2s`, `1m 3s`, `2m`, `1h 2m`, `2h`.
80
+ *
81
+ * @remarks
82
+ * Under a second it is whole milliseconds; under a minute, seconds to one decimal with a trailing `.0`
83
+ * dropped; under an hour, minutes and whole seconds, with the seconds dropped when zero; from an hour, hours and
84
+ * whole minutes, the seconds dropped. A value that rounds up to the next unit (`999.6` to a second, `59999` to a
85
+ * minute, `3599999` to an hour) is written in that unit, so `1000ms`, `60s` and `60m` never appear. There is no
86
+ * days unit. A negative or non-finite input is `0ms`.
87
+ *
88
+ * @param ms - the duration in milliseconds
89
+ */
90
+ static duration = (ms) => {
91
+ const value = Number.isFinite(ms) ? Math.max(0, ms) : 0;
92
+ const whole = Math.round(value);
93
+ if (whole < 1e3) return `${whole}ms`;
94
+ const tenths = Math.round(value / 100);
95
+ if (tenths < 600) return `${Number((tenths / 10).toFixed(1))}s`;
96
+ const seconds = Math.round(value / 1e3);
97
+ if (seconds >= 3600) {
98
+ const hours = Math.floor(seconds / 3600);
99
+ const remaining = Math.floor(seconds % 3600 / 60);
100
+ return remaining === 0 ? `${hours}h` : `${hours}h ${remaining}m`;
101
+ }
102
+ const minutes = Math.floor(seconds / 60);
103
+ const rest = seconds % 60;
104
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
105
+ };
106
+ /**
107
+ * A ratio from 0 to 1 as a percentage, or a number already from 0 to 100 with `scale: 100`: `83.3%`, `50%`,
108
+ * `100%`.
109
+ *
110
+ * @remarks
111
+ * Rounded to `digits` decimal places (one by default) with trailing zeros dropped, so whole values print
112
+ * without a decimal. A value that rounds to zero prints `0%`, never `-0%`. The input is not validated.
113
+ *
114
+ * @param n - the ratio, 0 to 1 (0 to 100 with `scale: 100`)
115
+ * @param options - the decimal places and the scale
116
+ */
117
+ static percent = (n, options) => {
118
+ const digits = Math.min(10, Math.max(0, Math.floor(options?.digits ?? 1)));
119
+ const percent = options?.scale === 100 ? n : n * 100;
120
+ return `${Number(percent.toFixed(digits))}%`;
121
+ };
122
+ /**
123
+ * A count with its noun: `1 test`, `2 tests`, `0 tests`.
124
+ *
125
+ * @param n - the count
126
+ * @param singular - the noun for exactly one
127
+ * @param plural - the noun otherwise; `singular` with an `s` appended by default
128
+ */
129
+ static plural = (n, singular, plural) => `${n} ${n === 1 ? singular : plural ?? `${singular}s`}`;
130
+ };
131
+
132
+ //#endregion
133
+ export { Fmt, sanitize };