@styx-api/core 0.6.1 → 0.7.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.
@@ -0,0 +1,725 @@
1
+ /**
2
+ * argtype backend: emit argtype sugar-DSL source text from Styx IR + `AppMeta`.
3
+ *
4
+ * This is the inverse of the argtype frontend's lowering (`frontend/argtype/
5
+ * lower.ts`): it walks the `Expr` tree and prints surface syntax, mirroring each
6
+ * lowering decision in reverse. Unlike the typed-language backends it needs only
7
+ * the IR and `AppMeta`, never the solver, so it is a pure pretty-printer.
8
+ *
9
+ * Intentional lossiness (the frontend already collapses these, so the IR holds
10
+ * no record of them and emitting the collapsed form is correct):
11
+ * - `set` -> `sequence` and `any` -> first branch: the IR has no such nodes, so
12
+ * we emit `seq` / `alt`.
13
+ * - Output ops `strip_prefix` / `basename` and output-level `.or()` are not in
14
+ * the IR's `OutputToken`, so there is nothing to emit.
15
+ *
16
+ * `path.attrs.mutable` / `resolveParent` emit as the `paths` extension's
17
+ * `.mutable()` / `.resolveParent()`. A `doc.title` + `doc.description` emit as a
18
+ * `///` block using the summary-line convention (title, blank `///`, description).
19
+ *
20
+ * IR features with no argtype surface are handled explicitly (always a backend
21
+ * warning): output media types, stream `doc.title`,
22
+ * `AppMeta.doc.literature/comment`, and a union arm's `variantTag` when it
23
+ * differs from the arm name (the frontend re-derives the `@type` discriminator
24
+ * from the name on re-parse).
25
+ */
26
+
27
+ import type { AppMeta, Output, StreamOutput } from "../../ir/meta.js";
28
+ import type { Documentation } from "../../ir/types.js";
29
+ import type { Expr, Optional, Repeat } from "../../ir/node.js";
30
+ import type { EmitWarning } from "../backend.js";
31
+
32
+ const INDENT = " ";
33
+
34
+ /** Target content width (excluding the `/// ` prefix and indentation) for a
35
+ * wrapped `///` description line. */
36
+ const DOC_WIDTH = 80;
37
+
38
+ function pad(level: number): string {
39
+ return INDENT.repeat(level);
40
+ }
41
+
42
+ /**
43
+ * Wrap a description into `///`-ready content lines. Paragraphs (separated by a
44
+ * blank line) are word-wrapped to `width`; an empty string marks a paragraph
45
+ * break (the caller emits it as a bare `///`).
46
+ *
47
+ * The argtype frontend reflows a `///` block as prose - a single line break
48
+ * rejoins with a space, a blank line separates paragraphs - so wrapping here is
49
+ * the inverse: it round-trips paragraph text exactly (whitespace within a
50
+ * paragraph is normalized to single spaces, matching the frontend's reflow), and
51
+ * keeps a long description from emitting as one giant physical line.
52
+ */
53
+ function wrapDoc(text: string, width: number): string[] {
54
+ const lines: string[] = [];
55
+ for (const paragraph of text.split(/\n{2,}/)) {
56
+ const words = paragraph.split(/\s+/).filter((w) => w.length > 0);
57
+ if (words.length === 0) continue; // leading/trailing blank paragraph
58
+ if (lines.length > 0) lines.push(""); // blank `///` between paragraphs
59
+ let cur = "";
60
+ for (const word of words) {
61
+ if (cur === "") cur = word;
62
+ else if (cur.length + 1 + word.length <= width) cur += ` ${word}`;
63
+ else {
64
+ lines.push(cur);
65
+ cur = word;
66
+ }
67
+ }
68
+ if (cur !== "") lines.push(cur);
69
+ }
70
+ return lines;
71
+ }
72
+
73
+ function num(n: number): string {
74
+ return String(n);
75
+ }
76
+
77
+ /** Escape a string for a double-quoted argtype literal (matches the lexer). */
78
+ function escapeString(s: string): string {
79
+ return s
80
+ .replace(/\\/g, "\\\\")
81
+ .replace(/"/g, '\\"')
82
+ .replace(/\n/g, "\\n")
83
+ .replace(/\t/g, "\\t")
84
+ .replace(/\r/g, "\\r");
85
+ }
86
+
87
+ function quote(s: string): string {
88
+ return `"${escapeString(s)}"`;
89
+ }
90
+
91
+ /** A value terminal kind whose `meta.defaultValue` argtype can express. */
92
+ function isValueTerminal(
93
+ node: Expr,
94
+ ): node is Extract<Expr, { kind: "int" | "float" | "str" | "path" }> {
95
+ return (
96
+ node.kind === "int" || node.kind === "float" || node.kind === "str" || node.kind === "path"
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Find the single value terminal a wrapper's default should attach to. Descends
102
+ * through optional/repeat and a sequence with exactly one non-literal child
103
+ * (the flag-value pattern `seq(lit, value)`). Returns undefined when the value
104
+ * slot is ambiguous (multiple non-literal children) or absent (a bare literal
105
+ * flag), in which case the wrapper default has no terminal to sink onto.
106
+ */
107
+ function findValueTerminal(node: Expr): Expr | undefined {
108
+ switch (node.kind) {
109
+ case "int":
110
+ case "float":
111
+ case "str":
112
+ case "path":
113
+ return node;
114
+ case "optional":
115
+ return findValueTerminal(node.attrs.node);
116
+ case "repeat":
117
+ return findValueTerminal(node.attrs.node);
118
+ case "sequence": {
119
+ const nonLiteral = node.attrs.nodes.filter((n) => n.kind !== "literal");
120
+ return nonLiteral.length === 1 ? findValueTerminal(nonLiteral[0]!) : undefined;
121
+ }
122
+ default:
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Push a wrapper's `meta.defaultValue` down onto its inner value terminal so it
129
+ * can be emitted as `int = 5` / `.default(...)`. Boutiques (and argparse) hoist
130
+ * an input's default onto the outermost wrapper (`optional` / `sequence`); the
131
+ * argtype surface only carries `.default()` on a terminal, so we relocate it.
132
+ * Boolean defaults are left in place: those are the `opt(literal)` flag-false
133
+ * convention, which the frontend regenerates for free and which has no terminal.
134
+ */
135
+ function sinkDefaults(expr: Expr): Expr {
136
+ const clone = structuredClone(expr);
137
+ const visit = (node: Expr): void => {
138
+ switch (node.kind) {
139
+ case "optional":
140
+ visit(node.attrs.node);
141
+ sinkInto(node);
142
+ break;
143
+ case "repeat":
144
+ visit(node.attrs.node);
145
+ sinkInto(node);
146
+ break;
147
+ case "sequence":
148
+ for (const c of node.attrs.nodes) visit(c);
149
+ sinkInto(node);
150
+ break;
151
+ case "alternative":
152
+ for (const c of node.attrs.alts) visit(c);
153
+ break;
154
+ default:
155
+ break;
156
+ }
157
+ };
158
+ visit(clone);
159
+ return clone;
160
+ }
161
+
162
+ function sinkInto(wrapper: Expr): void {
163
+ const dv = wrapper.meta?.defaultValue;
164
+ if (dv === undefined || typeof dv === "boolean") return;
165
+ const terminal = findValueTerminal(wrapper);
166
+ if (!terminal || !isValueTerminal(terminal) || terminal.meta?.defaultValue !== undefined) return;
167
+ terminal.meta = { ...terminal.meta, defaultValue: dv };
168
+ const meta = { ...wrapper.meta };
169
+ delete meta.defaultValue;
170
+ wrapper.meta = Object.keys(meta).length > 0 ? meta : undefined;
171
+ }
172
+
173
+ /**
174
+ * Detect the synthetic single-child sequence the frontend wraps a non-sequence
175
+ * root in. Such a wrapper carries only `name` (matching the child's name) plus
176
+ * any collected `outputs`; the doc/default live on the inner node. Returns the
177
+ * inner node and the wrapper's outputs so the caller can emit the inner node as
178
+ * the definition body (the frontend re-wraps it identically on re-parse).
179
+ */
180
+ function syntheticWrap(root: Expr): { child: Expr; outputs?: Output[] } | undefined {
181
+ if (root.kind !== "sequence" || root.attrs.nodes.length !== 1 || root.attrs.join !== undefined) {
182
+ return undefined;
183
+ }
184
+ const meta = root.meta;
185
+ if (meta?.doc || meta?.defaultValue !== undefined || meta?.variantTag !== undefined) {
186
+ return undefined;
187
+ }
188
+ const child = root.attrs.nodes[0]!;
189
+ if (child.kind === "sequence") return undefined; // the frontend only wraps non-sequences
190
+ if ((child.meta?.name ?? undefined) !== (meta?.name ?? undefined)) return undefined;
191
+ return { child, ...(meta?.outputs && { outputs: meta.outputs }) };
192
+ }
193
+
194
+ function hasOutputs(expr: Expr): boolean {
195
+ return walkSome(expr, (n) => (n.meta?.outputs?.length ?? 0) > 0);
196
+ }
197
+
198
+ function hasMediaTypes(expr: Expr): boolean {
199
+ return walkSome(expr, (n) => n.kind === "path" && (n.attrs.mediaTypes?.length ?? 0) > 0);
200
+ }
201
+
202
+ function hasPaths(expr: Expr): boolean {
203
+ return walkSome(expr, (n) => n.kind === "path" && (!!n.attrs.mutable || !!n.attrs.resolveParent));
204
+ }
205
+
206
+ function walkSome(node: Expr, pred: (n: Expr) => boolean): boolean {
207
+ if (pred(node)) return true;
208
+ switch (node.kind) {
209
+ case "sequence":
210
+ return node.attrs.nodes.some((n) => walkSome(n, pred));
211
+ case "alternative":
212
+ return node.attrs.alts.some((n) => walkSome(n, pred));
213
+ case "optional":
214
+ return walkSome(node.attrs.node, pred);
215
+ case "repeat":
216
+ return walkSome(node.attrs.node, pred);
217
+ default:
218
+ return false;
219
+ }
220
+ }
221
+
222
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
223
+
224
+ /** A name rendered where an identifier is expected: bare when it is a valid
225
+ * identifier, otherwise a double-quoted form preserving it exactly. Used for
226
+ * both `label:` prefixes and output-template `{ref}` targets (templates accept a
227
+ * quoted name, `{"4d_output"}`), so the two always agree and no lossy
228
+ * sanitization is needed. */
229
+ function identOrQuoted(name: string): string {
230
+ return IDENT_RE.test(name) ? name : quote(name);
231
+ }
232
+
233
+ class ArgtypeEmitter {
234
+ readonly warnings: EmitWarning[] = [];
235
+
236
+ private warn(message: string): void {
237
+ this.warnings.push({ message });
238
+ }
239
+
240
+ /** Render a name as it appears before a `:` label (bare identifier or quoted
241
+ * label, e.g. `"1deval":`, `"1D":`). */
242
+ private labelFor(name: string): string {
243
+ return identOrQuoted(name);
244
+ }
245
+
246
+ /**
247
+ * `///` doc lines for a `Documentation`. A title is written as a leading
248
+ * Markdown H1 (`# Title`); the description follows after a blank `///` line.
249
+ * Either alone emits just that part.
250
+ */
251
+ private docLines(doc: Documentation | undefined): string[] {
252
+ if (!doc) return [];
253
+ const out: string[] = [];
254
+ // The title is a single H1 line (only the first line survives the title
255
+ // convention, so a multi-line title routes through `docNeedsChain` instead).
256
+ if (doc.title) out.push(`/// # ${doc.title}`);
257
+ if (doc.title && doc.description) out.push("///");
258
+ if (doc.description) {
259
+ for (const line of wrapDoc(doc.description, DOC_WIDTH)) {
260
+ out.push(line === "" ? "///" : `/// ${line}`);
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /**
267
+ * Whether a `///` block would not round-trip, so the doc must instead be
268
+ * emitted as `.title()` / `.description()` chaining (which sets the fields
269
+ * verbatim). Three cases:
270
+ * - a multi-line title (only its first line would survive `splitDocText`);
271
+ * - a description with a lone line break (a single `\n`, not a blank-line
272
+ * paragraph break), which a `///` block reflows to a space - chaining keeps
273
+ * the hard break intact;
274
+ * - a title-less description whose first line looks like an H1 heading
275
+ * (`# ...`), which would be promoted to a spurious title.
276
+ * A blank-line paragraph break is safe: the frontend reflows a `///` block back
277
+ * into the same paragraphs, and a description under a title is safe too
278
+ * (`splitDocText` consumes only the very first line as the title).
279
+ */
280
+ private docNeedsChain(doc: Documentation | undefined): boolean {
281
+ if (!doc) return false;
282
+ if (doc.title?.includes("\n")) return true;
283
+ const desc = doc.description;
284
+ if (desc) {
285
+ if (desc.split(/\n{2,}/).some((para) => para.includes("\n"))) return true;
286
+ if (!doc.title && (desc.split("\n")[0] ?? "").trim().startsWith("# ")) return true;
287
+ }
288
+ return false;
289
+ }
290
+
291
+ /** `.title(...)` / `.description(...)` chaining for a doc that cannot round-trip
292
+ * as a `///` block (see `docNeedsChain`). */
293
+ private docChain(doc: Documentation | undefined): string {
294
+ if (!doc) return "";
295
+ let chain = "";
296
+ if (doc.title) chain += `.title(${quote(doc.title)})`;
297
+ if (doc.description) chain += `.description(${quote(doc.description)})`;
298
+ return chain;
299
+ }
300
+
301
+ /**
302
+ * Warn for `Documentation` fields a node's `///` comment cannot carry. `title`
303
+ * and `description` are emitted (see `docLines`); literature / comment /
304
+ * authors / urls attached to an inner node have no surface, so surface them
305
+ * rather than dropping silently.
306
+ */
307
+ private warnUnrepresentableDoc(
308
+ doc:
309
+ | {
310
+ literature?: string[];
311
+ comment?: string;
312
+ authors?: string[];
313
+ urls?: string[];
314
+ }
315
+ | undefined,
316
+ where: string,
317
+ ): void {
318
+ if (!doc) return;
319
+ const lost: string[] = [];
320
+ if (doc.literature?.length) lost.push("literature");
321
+ if (doc.comment) lost.push("comment");
322
+ if (doc.authors?.length) lost.push("authors");
323
+ if (doc.urls?.length) lost.push("urls");
324
+ if (lost.length > 0) {
325
+ this.warn(`Documentation ${lost.join("/")} on ${where} has no argtype node surface; ignored`);
326
+ }
327
+ }
328
+
329
+ emit(expr: Expr, app?: AppMeta): string {
330
+ const raw = sinkDefaults(expr);
331
+ const lines: string[] = [];
332
+
333
+ // argtype describes arguments only; the executable (argv[0]) belongs in
334
+ // frontmatter. Strip a leading command literal into `exe` so the body reads
335
+ // as arguments (the frontend re-prepends it on parse). The tool id stays the
336
+ // root label, which for a `wb_command <sub>` tool differs from the exe.
337
+ let exe: string | undefined;
338
+ let root = raw;
339
+ if (raw.kind === "sequence" && raw.attrs.nodes[0]?.kind === "literal") {
340
+ exe = raw.attrs.nodes[0].attrs.str;
341
+ root = { kind: "sequence", attrs: { ...raw.attrs, nodes: raw.attrs.nodes.slice(1) } };
342
+ if (raw.meta) root.meta = raw.meta;
343
+ }
344
+
345
+ const frontmatter = this.emitFrontmatter(
346
+ app,
347
+ exe,
348
+ hasOutputs(root),
349
+ hasMediaTypes(root),
350
+ hasPaths(root),
351
+ );
352
+ if (frontmatter) {
353
+ lines.push(frontmatter, "");
354
+ }
355
+
356
+ // The frontend wraps a non-sequence root in a synthetic single-child
357
+ // sequence (carrying only name + collected outputs). Emit the logical inner
358
+ // node as the definition body so the frontend re-wraps it identically;
359
+ // emitting the wrapper instead would re-attach the root doc to the wrapper.
360
+ const synthetic = syntheticWrap(root);
361
+ const defNode = synthetic ? synthetic.child : root;
362
+ const wrapperOutputs = synthetic ? synthetic.outputs : undefined;
363
+
364
+ // The tool's title + description live in the root `///` block (from the
365
+ // root node's doc, or the app metadata for a converted descriptor) - unless
366
+ // the title convention would misread that block, in which case it round-trips
367
+ // as `.title()` / `.description()` chaining on the body instead.
368
+ const rootDoc = defNode.meta?.doc ?? app?.doc;
369
+ const rootChain = this.docNeedsChain(rootDoc);
370
+ if (!rootChain) lines.push(...this.docLines(rootDoc));
371
+ // Only the node's own doc is checked here; an app doc's authors/urls are
372
+ // representable (frontmatter) and its literature/comment are warned there.
373
+ this.warnUnrepresentableDoc(defNode.meta?.doc, "the root node");
374
+
375
+ const rootName = this.labelFor(defNode.meta?.name || app?.id || "tool");
376
+ let body = this.emitNode(defNode, 0);
377
+ if (rootChain) body += this.docChain(rootDoc);
378
+ if (wrapperOutputs?.length) body += this.emitOutputs(wrapperOutputs, 0);
379
+ lines.push(`${rootName}: ${body}`);
380
+
381
+ return lines.join("\n") + "\n";
382
+ }
383
+
384
+ // -- Expression emission --
385
+
386
+ /**
387
+ * Emit a node's core expression plus its node-local chains (value
388
+ * constraints, default, join, count, media types) and any `.output(...)`.
389
+ * The first line is not indented (the caller places it after a label or pad);
390
+ * continuation lines are indented relative to `level`.
391
+ */
392
+ private emitNode(expr: Expr, level: number): string {
393
+ let core: string;
394
+ switch (expr.kind) {
395
+ case "literal":
396
+ core = quote(expr.attrs.str);
397
+ break;
398
+ case "str":
399
+ core = "str" + this.defaultSuffix(expr, false);
400
+ break;
401
+ case "int":
402
+ case "float": {
403
+ core = expr.kind;
404
+ let chained = false;
405
+ const min = this.finiteNum(expr.attrs.minValue, "min");
406
+ if (min !== undefined) {
407
+ core += `.min(${min})`;
408
+ chained = true;
409
+ }
410
+ const max = this.finiteNum(expr.attrs.maxValue, "max");
411
+ if (max !== undefined) {
412
+ core += `.max(${max})`;
413
+ chained = true;
414
+ }
415
+ core += this.defaultSuffix(expr, chained);
416
+ break;
417
+ }
418
+ case "path": {
419
+ core = "path";
420
+ const media = expr.attrs.mediaTypes ?? [];
421
+ for (const m of media) core += `.mediaType(${quote(m)})`;
422
+ // `.mutable()` / `.resolveParent()` are the `paths` extension.
423
+ if (expr.attrs.mutable) core += ".mutable()";
424
+ if (expr.attrs.resolveParent) core += ".resolveParent()";
425
+ const chained = media.length > 0 || !!expr.attrs.mutable || !!expr.attrs.resolveParent;
426
+ core += this.defaultSuffix(expr, chained);
427
+ break;
428
+ }
429
+ case "sequence":
430
+ core = this.emitCombinator("seq", expr.attrs.nodes, level, expr.attrs.join);
431
+ core += this.structuralDefaultSuffix(expr);
432
+ break;
433
+ case "optional":
434
+ core = this.emitOptional(expr, level) + this.structuralDefaultSuffix(expr);
435
+ break;
436
+ case "repeat":
437
+ core = this.emitRepeat(expr, level) + this.structuralDefaultSuffix(expr);
438
+ break;
439
+ case "alternative":
440
+ // argtype has no surface for an explicit union discriminator: the
441
+ // frontend re-derives an arm's `@type` tag from its label (name). That
442
+ // reproduces the discriminant whenever `variantTag` equals the emitted
443
+ // name (the common case), but a `variantTag` that was set to something
444
+ // else (to disambiguate a collapsed subcommand) would change on
445
+ // re-parse, so warn.
446
+ for (const arm of expr.attrs.alts) {
447
+ const tag = arm.meta?.variantTag;
448
+ if (tag !== undefined && tag !== arm.meta?.name) {
449
+ this.warn(
450
+ `Union arm discriminator '${tag}' has no argtype surface and will be re-derived ` +
451
+ `from the arm name '${arm.meta?.name ?? "<unnamed>"}' on re-parse`,
452
+ );
453
+ }
454
+ }
455
+ core =
456
+ this.emitCombinator("alt", expr.attrs.alts, level) + this.structuralDefaultSuffix(expr);
457
+ break;
458
+ default: {
459
+ const _exhaustive: never = expr;
460
+ core = "";
461
+ }
462
+ }
463
+
464
+ if (expr.meta?.outputs?.length) {
465
+ core += this.emitOutputs(expr.meta.outputs, level);
466
+ }
467
+ return core;
468
+ }
469
+
470
+ /** `String(n)` when `n` is finite; otherwise undefined with a warning, since
471
+ * `Infinity`/`NaN` have no argtype number literal (emitting them would produce
472
+ * an identifier that fails to re-parse). */
473
+ private finiteNum(n: number | undefined, where: string): string | undefined {
474
+ if (n === undefined) return undefined;
475
+ if (!Number.isFinite(n)) {
476
+ this.warn(`Non-finite number (${String(n)}) on ${where} has no argtype literal; ignored`);
477
+ return undefined;
478
+ }
479
+ return num(n);
480
+ }
481
+
482
+ /** `= value` on a bare terminal, else `.default(value)` once a chain started. */
483
+ private defaultSuffix(expr: Expr, chained: boolean): string {
484
+ const dv = expr.meta?.defaultValue;
485
+ if (dv === undefined || typeof dv === "boolean") return "";
486
+ let value: string;
487
+ if (typeof dv === "number") {
488
+ const n = this.finiteNum(dv, "default");
489
+ if (n === undefined) return "";
490
+ value = n;
491
+ } else {
492
+ value = quote(dv);
493
+ }
494
+ return chained ? `.default(${value})` : ` = ${value}`;
495
+ }
496
+
497
+ /**
498
+ * A `.default(value)` chained onto a structural node (e.g. an enum
499
+ * `alternative`, or a wrapper whose default could not sink onto an inner
500
+ * terminal). Booleans have no argtype value literal: the only legitimate one
501
+ * is the `opt(literal)` flag-false convention, which the frontend regenerates,
502
+ * so it is silently dropped; any other boolean default is warned and dropped.
503
+ */
504
+ private structuralDefaultSuffix(expr: Expr): string {
505
+ const dv = expr.meta?.defaultValue;
506
+ if (dv === undefined) return "";
507
+ if (typeof dv === "boolean") {
508
+ const isFlagFalse =
509
+ expr.kind === "optional" && expr.attrs.node.kind === "literal" && dv === false;
510
+ if (!isFlagFalse) {
511
+ this.warn(`Boolean default on a ${expr.kind} cannot be expressed in argtype; ignored`);
512
+ }
513
+ return "";
514
+ }
515
+ if (typeof dv === "number") {
516
+ const n = this.finiteNum(dv, "default");
517
+ return n === undefined ? "" : `.default(${n})`;
518
+ }
519
+ return `.default(${quote(dv)})`;
520
+ }
521
+
522
+ private emitOptional(expr: Optional, level: number): string {
523
+ const inner = expr.attrs.node;
524
+ if (inner.kind === "sequence" && !inner.meta && inner.attrs.join === undefined) {
525
+ return this.emitCombinator("opt", inner.attrs.nodes, level);
526
+ }
527
+ return this.emitCombinator("opt", [inner], level);
528
+ }
529
+
530
+ private emitRepeat(expr: Repeat, level: number): string {
531
+ const node = expr.attrs.node;
532
+ let core: string;
533
+ if (node.kind === "sequence" && !node.meta && node.attrs.join === undefined) {
534
+ core = this.emitCombinator("rep", node.attrs.nodes, level);
535
+ } else {
536
+ core = this.emitCombinator("rep", [node], level);
537
+ }
538
+ if (expr.attrs.join !== undefined) {
539
+ core += `.join(${expr.attrs.join === "" ? "" : quote(expr.attrs.join)})`;
540
+ }
541
+ // `.count(n)` for an exact count; otherwise the composable `.countMin()` /
542
+ // `.countMax()` primitives, which express one-sided bounds too.
543
+ const { countMin, countMax } = expr.attrs;
544
+ if (countMin !== undefined && countMin === countMax) {
545
+ core += `.count(${countMin})`;
546
+ } else {
547
+ if (countMin !== undefined) core += `.countMin(${countMin})`;
548
+ if (countMax !== undefined) core += `.countMax(${countMax})`;
549
+ }
550
+ return core;
551
+ }
552
+
553
+ private emitCombinator(keyword: string, children: Expr[], level: number, join?: string): string {
554
+ let core: string;
555
+ if (children.length === 0) {
556
+ core = `${keyword}()`;
557
+ } else {
558
+ const items = children.map((c) => this.emitDecorated(c, level + 1));
559
+ // Stay on one line when no child carries a doc or spans lines and the
560
+ // result is short, so simple groups read like hand-written argtype.
561
+ const inline = `${keyword}(${items.join(", ")})`;
562
+ if (!items.some((i) => i.includes("\n")) && inline.length + level * INDENT.length <= 80) {
563
+ core = inline;
564
+ } else {
565
+ const padded = items.map((i) => pad(level + 1) + i);
566
+ core = `${keyword}(\n${padded.join(",\n")},\n${pad(level)})`;
567
+ }
568
+ }
569
+ if (join !== undefined) {
570
+ core += `.join(${join === "" ? "" : quote(join)})`;
571
+ }
572
+ return core;
573
+ }
574
+
575
+ /**
576
+ * A list item: leading `///` doc lines, an optional `label:` prefix, then the
577
+ * node. The first line is unindented (the caller pads it); continuation lines
578
+ * are indented to `level`.
579
+ */
580
+ private emitDecorated(expr: Expr, level: number): string {
581
+ const doc = expr.meta?.doc;
582
+ // A doc the title convention would misread is emitted as chaining on the node
583
+ // instead of a leading `///` block (see `docNeedsChain`).
584
+ const useChain = this.docNeedsChain(doc);
585
+ const parts: string[] = useChain ? [] : [...this.docLines(doc)];
586
+ this.warnUnrepresentableDoc(doc, `node '${expr.meta?.name ?? expr.kind}'`);
587
+ // An empty name is dropped by the frontend on re-parse, so emit no label.
588
+ const label = expr.meta?.name ? `${this.labelFor(expr.meta.name)}: ` : "";
589
+ let node = this.emitNode(expr, level);
590
+ if (useChain) node += this.docChain(doc);
591
+ parts.push(label + node);
592
+ return parts.join("\n" + pad(level));
593
+ }
594
+
595
+ // -- Outputs --
596
+
597
+ private emitOutputs(outputs: Output[], level: number): string {
598
+ const items = outputs.map((o) => {
599
+ // A `///` doc block precedes the output entry (title-convention split),
600
+ // unless that block would be misread - then it round-trips as `.title()` /
601
+ // `.description()` chaining on the template, as node docs do.
602
+ const useChain = this.docNeedsChain(o.doc);
603
+ const docs = useChain ? [] : this.docLines(o.doc).map((l) => pad(level + 1) + l);
604
+ let template = pad(level + 1) + this.emitOutputTemplate(o);
605
+ if (useChain) template += this.docChain(o.doc);
606
+ return [...docs, template].join("\n");
607
+ });
608
+ return `.output(\n${items.join(",\n")},\n${pad(level)})`;
609
+ }
610
+
611
+ private emitOutputTemplate(output: Output): string {
612
+ if (output.mediaTypes?.length) {
613
+ this.warn(
614
+ `Output '${output.name ?? "<anon>"}' has media types, which have no argtype surface; ignored`,
615
+ );
616
+ }
617
+ const label = output.name ? `${this.labelFor(output.name)}: ` : "";
618
+ let body = "";
619
+ for (const token of output.tokens) {
620
+ if (token.kind === "literal") {
621
+ // Escape template-significant characters so a literal `{`/`}`/backtick
622
+ // in the path round-trips (the frontend unescapes them).
623
+ body += token.value.replace(/[\\{}`]/g, (c) => `\\${c}`);
624
+ continue;
625
+ }
626
+ // Emit the ref name the same way as the target node's `label:` (bare
627
+ // identifier or quoted), so the `{ref}` and its `label:` stay in agreement.
628
+ let ref = identOrQuoted(token.target.name);
629
+ for (const ext of token.stripExtensions ?? []) ref += `.strip_suffix(${quote(ext)})`;
630
+ if (token.fallback !== undefined) ref += `.or(${quote(token.fallback)})`;
631
+ body += `{${ref}}`;
632
+ }
633
+ return `${label}\`${body}\``;
634
+ }
635
+
636
+ // -- Frontmatter --
637
+
638
+ /** Quote a frontmatter scalar (double-quoted, backslash-escaped). The
639
+ * frontmatter parser unescapes symmetrically, so quotes/newlines round-trip. */
640
+ private fmScalar(value: string): string {
641
+ return quote(value);
642
+ }
643
+
644
+ private emitFrontmatter(
645
+ app: AppMeta | undefined,
646
+ exe: string | undefined,
647
+ outputs: boolean,
648
+ mediaTypes: boolean,
649
+ paths: boolean,
650
+ ): string | undefined {
651
+ const lines: string[] = [];
652
+
653
+ // `exe` is the executable stripped from the command (argv[0]); emit it only
654
+ // when one was present, so the frontend's re-prepend stays symmetric.
655
+ if (exe !== undefined) lines.push(`exe: ${this.fmScalar(exe)}`);
656
+
657
+ if (app) {
658
+ if (app.version) lines.push(`version: ${this.fmScalar(app.version)}`);
659
+
660
+ const authors = app.doc?.authors ?? [];
661
+ if (authors.length > 0) {
662
+ lines.push("authors:");
663
+ for (const a of authors) lines.push(` - ${this.fmScalar(a)}`);
664
+ }
665
+
666
+ const urls = app.doc?.urls ?? [];
667
+ if (urls.length > 0) {
668
+ lines.push("urls:");
669
+ for (const u of urls) lines.push(` - ${this.fmScalar(u)}`);
670
+ }
671
+
672
+ const references = app.doc?.literature ?? [];
673
+ if (references.length > 0) {
674
+ lines.push("references:");
675
+ for (const rf of references) lines.push(` - ${this.fmScalar(rf)}`);
676
+ }
677
+
678
+ if (app.container) {
679
+ lines.push("container:");
680
+ lines.push(` image: ${this.fmScalar(app.container.image)}`);
681
+ if (app.container.type) lines.push(` type: ${this.fmScalar(app.container.type)}`);
682
+ }
683
+
684
+ if (app.stdout) this.emitStream(lines, "stdout", app.stdout);
685
+ if (app.stderr) this.emitStream(lines, "stderr", app.stderr);
686
+
687
+ // app.doc.title is emitted in the root `///` block, not frontmatter.
688
+ if (app.doc?.comment) {
689
+ this.warn("AppMeta.doc.comment has no argtype surface; ignored");
690
+ }
691
+ }
692
+
693
+ const extensions: string[] = [];
694
+ if (outputs) extensions.push("outputs");
695
+ if (mediaTypes) extensions.push("mediatypes");
696
+ if (paths) extensions.push("paths");
697
+ if (extensions.length > 0) {
698
+ lines.push("extensions:");
699
+ for (const e of extensions) lines.push(` - ${e}`);
700
+ }
701
+
702
+ if (lines.length === 0) return undefined;
703
+ return ["---", ...lines, "---"].join("\n");
704
+ }
705
+
706
+ private emitStream(lines: string[], key: string, stream: StreamOutput): void {
707
+ lines.push(`${key}:`);
708
+ lines.push(` name: ${this.fmScalar(stream.name)}`);
709
+ if (stream.doc?.description)
710
+ lines.push(` description: ${this.fmScalar(stream.doc.description)}`);
711
+ if (stream.doc?.title) {
712
+ this.warn(`${key} stream title has no argtype surface; ignored`);
713
+ }
714
+ }
715
+ }
716
+
717
+ /** Emit argtype sugar-DSL source from an IR expression tree and optional `AppMeta`. */
718
+ export function generateArgtype(
719
+ expr: Expr,
720
+ app?: AppMeta,
721
+ ): { source: string; warnings: EmitWarning[] } {
722
+ const emitter = new ArgtypeEmitter();
723
+ const source = emitter.emit(expr, app);
724
+ return { source, warnings: emitter.warnings };
725
+ }