@styx-api/core 0.6.1 → 0.8.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/dist/index.cjs +2559 -553
- package/dist/index.d.cts +41 -37
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +41 -37
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2558 -549
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/backend/argtype/__fixtures__/microsyntax.argtype +13 -0
- package/src/backend/argtype/__fixtures__/subcommands.argtype +25 -0
- package/src/backend/argtype/argtype.ts +45 -0
- package/src/backend/argtype/emit.ts +676 -0
- package/src/backend/argtype/index.ts +2 -0
- package/src/backend/boutiques/boutiques.ts +55 -47
- package/src/backend/field-defaults.ts +40 -0
- package/src/backend/find-struct-node.ts +7 -0
- package/src/backend/index.ts +2 -9
- package/src/backend/nipype/emit.ts +11 -3
- package/src/backend/python/arg-builder.ts +2 -27
- package/src/backend/python/emit.ts +18 -6
- package/src/backend/python/outputs-emit.ts +27 -42
- package/src/backend/python/python.ts +46 -22
- package/src/backend/python/validate-emit.ts +4 -1
- package/src/backend/resolve-output-tokens.ts +0 -76
- package/src/backend/typescript/arg-builder.ts +2 -24
- package/src/backend/typescript/emit.ts +7 -4
- package/src/backend/typescript/outputs-emit.ts +15 -39
- package/src/backend/typescript/typemap.ts +9 -1
- package/src/backend/typescript/typescript.ts +41 -18
- package/src/backend/typescript/validate-emit.ts +4 -1
- package/src/backend/union-variants.ts +41 -1
- package/src/frontend/argdump/parser.ts +20 -19
- package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
- package/src/frontend/argtype/__fixtures__/bet.argtype +101 -0
- package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
- package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
- package/src/frontend/argtype/ast.ts +114 -0
- package/src/frontend/argtype/doc.ts +56 -0
- package/src/frontend/argtype/frontmatter.ts +237 -0
- package/src/frontend/argtype/index.ts +1 -0
- package/src/frontend/argtype/lexer.ts +264 -0
- package/src/frontend/argtype/lower.ts +601 -0
- package/src/frontend/argtype/parser-frontend.ts +51 -0
- package/src/frontend/argtype/parser.ts +533 -0
- package/src/frontend/argtype/template.ts +147 -0
- package/src/frontend/boutiques/destruct-template.ts +24 -17
- package/src/frontend/boutiques/parser.ts +27 -8
- package/src/frontend/detect-format.ts +28 -3
- package/src/index.ts +23 -9
- package/src/ir/passes/canonicalize.ts +18 -5
- package/src/ir/passes/simplify.ts +17 -2
- package/src/solver/solver.ts +2 -2
|
@@ -0,0 +1,676 @@
|
|
|
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
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
195
|
+
|
|
196
|
+
/** A name rendered where an identifier is expected: bare when it is a valid
|
|
197
|
+
* identifier, otherwise a double-quoted form preserving it exactly. Used for
|
|
198
|
+
* both `label:` prefixes and output-template `{ref}` targets (templates accept a
|
|
199
|
+
* quoted name, `{"4d_output"}`), so the two always agree and no lossy
|
|
200
|
+
* sanitization is needed. */
|
|
201
|
+
function identOrQuoted(name: string): string {
|
|
202
|
+
return IDENT_RE.test(name) ? name : quote(name);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
class ArgtypeEmitter {
|
|
206
|
+
readonly warnings: EmitWarning[] = [];
|
|
207
|
+
|
|
208
|
+
private warn(message: string): void {
|
|
209
|
+
this.warnings.push({ message });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Render a name as it appears before a `:` label (bare identifier or quoted
|
|
213
|
+
* label, e.g. `"1deval":`, `"1D":`). */
|
|
214
|
+
private labelFor(name: string): string {
|
|
215
|
+
return identOrQuoted(name);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `///` doc lines for a `Documentation`. A title is written as a leading
|
|
220
|
+
* Markdown H1 (`# Title`); the description follows after a blank `///` line.
|
|
221
|
+
* Either alone emits just that part.
|
|
222
|
+
*/
|
|
223
|
+
private docLines(doc: Documentation | undefined): string[] {
|
|
224
|
+
if (!doc) return [];
|
|
225
|
+
const out: string[] = [];
|
|
226
|
+
// The title is a single H1 line (only the first line survives the title
|
|
227
|
+
// convention, so a multi-line title routes through `docNeedsChain` instead).
|
|
228
|
+
if (doc.title) out.push(`/// # ${doc.title}`);
|
|
229
|
+
if (doc.title && doc.description) out.push("///");
|
|
230
|
+
if (doc.description) {
|
|
231
|
+
for (const line of wrapDoc(doc.description, DOC_WIDTH)) {
|
|
232
|
+
out.push(line === "" ? "///" : `/// ${line}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Whether a `///` block would not round-trip, so the doc must instead be
|
|
240
|
+
* emitted as `.title()` / `.description()` chaining (which sets the fields
|
|
241
|
+
* verbatim). Three cases:
|
|
242
|
+
* - a multi-line title (only its first line would survive `splitDocText`);
|
|
243
|
+
* - a description with a lone line break (a single `\n`, not a blank-line
|
|
244
|
+
* paragraph break), which a `///` block reflows to a space - chaining keeps
|
|
245
|
+
* the hard break intact;
|
|
246
|
+
* - a title-less description whose first line looks like an H1 heading
|
|
247
|
+
* (`# ...`), which would be promoted to a spurious title.
|
|
248
|
+
* A blank-line paragraph break is safe: the frontend reflows a `///` block back
|
|
249
|
+
* into the same paragraphs, and a description under a title is safe too
|
|
250
|
+
* (`splitDocText` consumes only the very first line as the title).
|
|
251
|
+
*/
|
|
252
|
+
private docNeedsChain(doc: Documentation | undefined): boolean {
|
|
253
|
+
if (!doc) return false;
|
|
254
|
+
if (doc.title?.includes("\n")) return true;
|
|
255
|
+
const desc = doc.description;
|
|
256
|
+
if (desc) {
|
|
257
|
+
if (desc.split(/\n{2,}/).some((para) => para.includes("\n"))) return true;
|
|
258
|
+
if (!doc.title && (desc.split("\n")[0] ?? "").trim().startsWith("# ")) return true;
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `.title(...)` / `.description(...)` chaining for a doc that cannot round-trip
|
|
264
|
+
* as a `///` block (see `docNeedsChain`). */
|
|
265
|
+
private docChain(doc: Documentation | undefined): string {
|
|
266
|
+
if (!doc) return "";
|
|
267
|
+
let chain = "";
|
|
268
|
+
if (doc.title) chain += `.title(${quote(doc.title)})`;
|
|
269
|
+
if (doc.description) chain += `.description(${quote(doc.description)})`;
|
|
270
|
+
return chain;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Warn for `Documentation` fields a node's `///` comment cannot carry. `title`
|
|
275
|
+
* and `description` are emitted (see `docLines`); literature / comment /
|
|
276
|
+
* authors / urls attached to an inner node have no surface, so surface them
|
|
277
|
+
* rather than dropping silently.
|
|
278
|
+
*/
|
|
279
|
+
private warnUnrepresentableDoc(
|
|
280
|
+
doc:
|
|
281
|
+
| {
|
|
282
|
+
literature?: string[];
|
|
283
|
+
comment?: string;
|
|
284
|
+
authors?: string[];
|
|
285
|
+
urls?: string[];
|
|
286
|
+
}
|
|
287
|
+
| undefined,
|
|
288
|
+
where: string,
|
|
289
|
+
): void {
|
|
290
|
+
if (!doc) return;
|
|
291
|
+
const lost: string[] = [];
|
|
292
|
+
if (doc.literature?.length) lost.push("literature");
|
|
293
|
+
if (doc.comment) lost.push("comment");
|
|
294
|
+
if (doc.authors?.length) lost.push("authors");
|
|
295
|
+
if (doc.urls?.length) lost.push("urls");
|
|
296
|
+
if (lost.length > 0) {
|
|
297
|
+
this.warn(`Documentation ${lost.join("/")} on ${where} has no argtype node surface; ignored`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
emit(expr: Expr, app?: AppMeta): string {
|
|
302
|
+
const raw = sinkDefaults(expr);
|
|
303
|
+
const lines: string[] = [];
|
|
304
|
+
|
|
305
|
+
// argtype describes arguments only; the executable (argv[0]) belongs in
|
|
306
|
+
// frontmatter. Strip a leading command literal into `exe` so the body reads
|
|
307
|
+
// as arguments (the frontend re-prepends it on parse). The tool id stays the
|
|
308
|
+
// root label, which for a `wb_command <sub>` tool differs from the exe.
|
|
309
|
+
let exe: string | undefined;
|
|
310
|
+
let root = raw;
|
|
311
|
+
if (raw.kind === "sequence" && raw.attrs.nodes[0]?.kind === "literal") {
|
|
312
|
+
exe = raw.attrs.nodes[0].attrs.str;
|
|
313
|
+
root = { kind: "sequence", attrs: { ...raw.attrs, nodes: raw.attrs.nodes.slice(1) } };
|
|
314
|
+
if (raw.meta) root.meta = raw.meta;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const frontmatter = this.emitFrontmatter(app, exe);
|
|
318
|
+
if (frontmatter) {
|
|
319
|
+
lines.push(frontmatter, "");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The frontend wraps a non-sequence root in a synthetic single-child
|
|
323
|
+
// sequence (carrying only name + collected outputs). Emit the logical inner
|
|
324
|
+
// node as the definition body so the frontend re-wraps it identically;
|
|
325
|
+
// emitting the wrapper instead would re-attach the root doc to the wrapper.
|
|
326
|
+
const synthetic = syntheticWrap(root);
|
|
327
|
+
const defNode = synthetic ? synthetic.child : root;
|
|
328
|
+
const wrapperOutputs = synthetic ? synthetic.outputs : undefined;
|
|
329
|
+
|
|
330
|
+
// The tool's title + description live in the root `///` block (from the
|
|
331
|
+
// root node's doc, or the app metadata for a converted descriptor) - unless
|
|
332
|
+
// the title convention would misread that block, in which case it round-trips
|
|
333
|
+
// as `.title()` / `.description()` chaining on the body instead.
|
|
334
|
+
const rootDoc = defNode.meta?.doc ?? app?.doc;
|
|
335
|
+
const rootChain = this.docNeedsChain(rootDoc);
|
|
336
|
+
if (!rootChain) lines.push(...this.docLines(rootDoc));
|
|
337
|
+
// Only the node's own doc is checked here; an app doc's authors/urls are
|
|
338
|
+
// representable (frontmatter) and its literature/comment are warned there.
|
|
339
|
+
this.warnUnrepresentableDoc(defNode.meta?.doc, "the root node");
|
|
340
|
+
|
|
341
|
+
const rootName = this.labelFor(defNode.meta?.name || app?.id || "tool");
|
|
342
|
+
let body = this.emitNode(defNode, 0);
|
|
343
|
+
if (rootChain) body += this.docChain(rootDoc);
|
|
344
|
+
if (wrapperOutputs?.length) body += this.emitOutputs(wrapperOutputs, 0);
|
|
345
|
+
lines.push(`${rootName}: ${body}`);
|
|
346
|
+
|
|
347
|
+
return lines.join("\n") + "\n";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// -- Expression emission --
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Emit a node's core expression plus its node-local chains (value
|
|
354
|
+
* constraints, default, join, count, media types) and any `.output(...)`.
|
|
355
|
+
* The first line is not indented (the caller places it after a label or pad);
|
|
356
|
+
* continuation lines are indented relative to `level`.
|
|
357
|
+
*/
|
|
358
|
+
private emitNode(expr: Expr, level: number): string {
|
|
359
|
+
let core: string;
|
|
360
|
+
switch (expr.kind) {
|
|
361
|
+
case "literal":
|
|
362
|
+
core = quote(expr.attrs.str);
|
|
363
|
+
break;
|
|
364
|
+
case "str":
|
|
365
|
+
core = "str" + this.defaultSuffix(expr, false);
|
|
366
|
+
break;
|
|
367
|
+
case "int":
|
|
368
|
+
case "float": {
|
|
369
|
+
core = expr.kind;
|
|
370
|
+
let chained = false;
|
|
371
|
+
const min = this.finiteNum(expr.attrs.minValue, "min");
|
|
372
|
+
if (min !== undefined) {
|
|
373
|
+
core += `.min(${min})`;
|
|
374
|
+
chained = true;
|
|
375
|
+
}
|
|
376
|
+
const max = this.finiteNum(expr.attrs.maxValue, "max");
|
|
377
|
+
if (max !== undefined) {
|
|
378
|
+
core += `.max(${max})`;
|
|
379
|
+
chained = true;
|
|
380
|
+
}
|
|
381
|
+
core += this.defaultSuffix(expr, chained);
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case "path": {
|
|
385
|
+
core = "path";
|
|
386
|
+
const media = expr.attrs.mediaTypes ?? [];
|
|
387
|
+
for (const m of media) core += `.mediaType(${quote(m)})`;
|
|
388
|
+
// `.mutable()` / `.resolveParent()` are the `paths` extension.
|
|
389
|
+
if (expr.attrs.mutable) core += ".mutable()";
|
|
390
|
+
if (expr.attrs.resolveParent) core += ".resolveParent()";
|
|
391
|
+
const chained = media.length > 0 || !!expr.attrs.mutable || !!expr.attrs.resolveParent;
|
|
392
|
+
core += this.defaultSuffix(expr, chained);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case "sequence":
|
|
396
|
+
core = this.emitCombinator("seq", expr.attrs.nodes, level, expr.attrs.join);
|
|
397
|
+
core += this.structuralDefaultSuffix(expr);
|
|
398
|
+
break;
|
|
399
|
+
case "optional":
|
|
400
|
+
core = this.emitOptional(expr, level) + this.structuralDefaultSuffix(expr);
|
|
401
|
+
break;
|
|
402
|
+
case "repeat":
|
|
403
|
+
core = this.emitRepeat(expr, level) + this.structuralDefaultSuffix(expr);
|
|
404
|
+
break;
|
|
405
|
+
case "alternative":
|
|
406
|
+
// argtype has no surface for an explicit union discriminator: the
|
|
407
|
+
// frontend re-derives an arm's `@type` tag from its label (name). That
|
|
408
|
+
// reproduces the discriminant whenever `variantTag` equals the emitted
|
|
409
|
+
// name (the common case), but a `variantTag` that was set to something
|
|
410
|
+
// else (to disambiguate a collapsed subcommand) would change on
|
|
411
|
+
// re-parse, so warn.
|
|
412
|
+
for (const arm of expr.attrs.alts) {
|
|
413
|
+
const tag = arm.meta?.variantTag;
|
|
414
|
+
if (tag !== undefined && tag !== arm.meta?.name) {
|
|
415
|
+
this.warn(
|
|
416
|
+
`Union arm discriminator '${tag}' has no argtype surface and will be re-derived ` +
|
|
417
|
+
`from the arm name '${arm.meta?.name ?? "<unnamed>"}' on re-parse`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
core =
|
|
422
|
+
this.emitCombinator("alt", expr.attrs.alts, level) + this.structuralDefaultSuffix(expr);
|
|
423
|
+
break;
|
|
424
|
+
default: {
|
|
425
|
+
const _exhaustive: never = expr;
|
|
426
|
+
core = "";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (expr.meta?.outputs?.length) {
|
|
431
|
+
core += this.emitOutputs(expr.meta.outputs, level);
|
|
432
|
+
}
|
|
433
|
+
return core;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** `String(n)` when `n` is finite; otherwise undefined with a warning, since
|
|
437
|
+
* `Infinity`/`NaN` have no argtype number literal (emitting them would produce
|
|
438
|
+
* an identifier that fails to re-parse). */
|
|
439
|
+
private finiteNum(n: number | undefined, where: string): string | undefined {
|
|
440
|
+
if (n === undefined) return undefined;
|
|
441
|
+
if (!Number.isFinite(n)) {
|
|
442
|
+
this.warn(`Non-finite number (${String(n)}) on ${where} has no argtype literal; ignored`);
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
return num(n);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** `= value` on a bare terminal, else `.default(value)` once a chain started. */
|
|
449
|
+
private defaultSuffix(expr: Expr, chained: boolean): string {
|
|
450
|
+
const dv = expr.meta?.defaultValue;
|
|
451
|
+
if (dv === undefined || typeof dv === "boolean") return "";
|
|
452
|
+
let value: string;
|
|
453
|
+
if (typeof dv === "number") {
|
|
454
|
+
const n = this.finiteNum(dv, "default");
|
|
455
|
+
if (n === undefined) return "";
|
|
456
|
+
value = n;
|
|
457
|
+
} else {
|
|
458
|
+
value = quote(dv);
|
|
459
|
+
}
|
|
460
|
+
return chained ? `.default(${value})` : ` = ${value}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A `.default(value)` chained onto a structural node (e.g. an enum
|
|
465
|
+
* `alternative`, or a wrapper whose default could not sink onto an inner
|
|
466
|
+
* terminal). Booleans have no argtype value literal: the only legitimate one
|
|
467
|
+
* is the `opt(literal)` flag-false convention, which the frontend regenerates,
|
|
468
|
+
* so it is silently dropped; any other boolean default is warned and dropped.
|
|
469
|
+
*/
|
|
470
|
+
private structuralDefaultSuffix(expr: Expr): string {
|
|
471
|
+
const dv = expr.meta?.defaultValue;
|
|
472
|
+
if (dv === undefined) return "";
|
|
473
|
+
if (typeof dv === "boolean") {
|
|
474
|
+
const isFlagFalse =
|
|
475
|
+
expr.kind === "optional" && expr.attrs.node.kind === "literal" && dv === false;
|
|
476
|
+
if (!isFlagFalse) {
|
|
477
|
+
this.warn(`Boolean default on a ${expr.kind} cannot be expressed in argtype; ignored`);
|
|
478
|
+
}
|
|
479
|
+
return "";
|
|
480
|
+
}
|
|
481
|
+
if (typeof dv === "number") {
|
|
482
|
+
const n = this.finiteNum(dv, "default");
|
|
483
|
+
return n === undefined ? "" : `.default(${n})`;
|
|
484
|
+
}
|
|
485
|
+
return `.default(${quote(dv)})`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private emitOptional(expr: Optional, level: number): string {
|
|
489
|
+
const inner = expr.attrs.node;
|
|
490
|
+
if (inner.kind === "sequence" && !inner.meta && inner.attrs.join === undefined) {
|
|
491
|
+
return this.emitCombinator("opt", inner.attrs.nodes, level);
|
|
492
|
+
}
|
|
493
|
+
return this.emitCombinator("opt", [inner], level);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private emitRepeat(expr: Repeat, level: number): string {
|
|
497
|
+
const node = expr.attrs.node;
|
|
498
|
+
let core: string;
|
|
499
|
+
if (node.kind === "sequence" && !node.meta && node.attrs.join === undefined) {
|
|
500
|
+
core = this.emitCombinator("rep", node.attrs.nodes, level);
|
|
501
|
+
} else {
|
|
502
|
+
core = this.emitCombinator("rep", [node], level);
|
|
503
|
+
}
|
|
504
|
+
if (expr.attrs.join !== undefined) {
|
|
505
|
+
core += `.join(${expr.attrs.join === "" ? "" : quote(expr.attrs.join)})`;
|
|
506
|
+
}
|
|
507
|
+
// `.count(n)` for an exact count; otherwise the composable `.countMin()` /
|
|
508
|
+
// `.countMax()` primitives, which express one-sided bounds too.
|
|
509
|
+
const { countMin, countMax } = expr.attrs;
|
|
510
|
+
if (countMin !== undefined && countMin === countMax) {
|
|
511
|
+
core += `.count(${countMin})`;
|
|
512
|
+
} else {
|
|
513
|
+
if (countMin !== undefined) core += `.countMin(${countMin})`;
|
|
514
|
+
if (countMax !== undefined) core += `.countMax(${countMax})`;
|
|
515
|
+
}
|
|
516
|
+
return core;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private emitCombinator(keyword: string, children: Expr[], level: number, join?: string): string {
|
|
520
|
+
let core: string;
|
|
521
|
+
if (children.length === 0) {
|
|
522
|
+
core = `${keyword}()`;
|
|
523
|
+
} else {
|
|
524
|
+
const items = children.map((c) => this.emitDecorated(c, level + 1));
|
|
525
|
+
// Stay on one line when no child carries a doc or spans lines and the
|
|
526
|
+
// result is short, so simple groups read like hand-written argtype.
|
|
527
|
+
const inline = `${keyword}(${items.join(", ")})`;
|
|
528
|
+
if (!items.some((i) => i.includes("\n")) && inline.length + level * INDENT.length <= 80) {
|
|
529
|
+
core = inline;
|
|
530
|
+
} else {
|
|
531
|
+
const padded = items.map((i) => pad(level + 1) + i);
|
|
532
|
+
core = `${keyword}(\n${padded.join(",\n")},\n${pad(level)})`;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (join !== undefined) {
|
|
536
|
+
core += `.join(${join === "" ? "" : quote(join)})`;
|
|
537
|
+
}
|
|
538
|
+
return core;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* A list item: leading `///` doc lines, an optional `label:` prefix, then the
|
|
543
|
+
* node. The first line is unindented (the caller pads it); continuation lines
|
|
544
|
+
* are indented to `level`.
|
|
545
|
+
*/
|
|
546
|
+
private emitDecorated(expr: Expr, level: number): string {
|
|
547
|
+
const doc = expr.meta?.doc;
|
|
548
|
+
// A doc the title convention would misread is emitted as chaining on the node
|
|
549
|
+
// instead of a leading `///` block (see `docNeedsChain`).
|
|
550
|
+
const useChain = this.docNeedsChain(doc);
|
|
551
|
+
const parts: string[] = useChain ? [] : [...this.docLines(doc)];
|
|
552
|
+
this.warnUnrepresentableDoc(doc, `node '${expr.meta?.name ?? expr.kind}'`);
|
|
553
|
+
// An empty name is dropped by the frontend on re-parse, so emit no label.
|
|
554
|
+
const label = expr.meta?.name ? `${this.labelFor(expr.meta.name)}: ` : "";
|
|
555
|
+
let node = this.emitNode(expr, level);
|
|
556
|
+
if (useChain) node += this.docChain(doc);
|
|
557
|
+
parts.push(label + node);
|
|
558
|
+
return parts.join("\n" + pad(level));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// -- Outputs --
|
|
562
|
+
|
|
563
|
+
private emitOutputs(outputs: Output[], level: number): string {
|
|
564
|
+
const items = outputs.map((o) => {
|
|
565
|
+
// A `///` doc block precedes the output entry (title-convention split),
|
|
566
|
+
// unless that block would be misread - then it round-trips as `.title()` /
|
|
567
|
+
// `.description()` chaining on the template, as node docs do.
|
|
568
|
+
const useChain = this.docNeedsChain(o.doc);
|
|
569
|
+
const docs = useChain ? [] : this.docLines(o.doc).map((l) => pad(level + 1) + l);
|
|
570
|
+
let template = pad(level + 1) + this.emitOutputTemplate(o);
|
|
571
|
+
if (useChain) template += this.docChain(o.doc);
|
|
572
|
+
return [...docs, template].join("\n");
|
|
573
|
+
});
|
|
574
|
+
return `.output(\n${items.join(",\n")},\n${pad(level)})`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private emitOutputTemplate(output: Output): string {
|
|
578
|
+
if (output.mediaTypes?.length) {
|
|
579
|
+
this.warn(
|
|
580
|
+
`Output '${output.name ?? "<anon>"}' has media types, which have no argtype surface; ignored`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const label = output.name ? `${this.labelFor(output.name)}: ` : "";
|
|
584
|
+
let body = "";
|
|
585
|
+
for (const token of output.tokens) {
|
|
586
|
+
if (token.kind === "literal") {
|
|
587
|
+
// Escape template-significant characters so a literal `{`/`}`/backtick
|
|
588
|
+
// in the path round-trips (the frontend unescapes them).
|
|
589
|
+
body += token.value.replace(/[\\{}`]/g, (c) => `\\${c}`);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
// Emit the ref name the same way as the target node's `label:` (bare
|
|
593
|
+
// identifier or quoted), so the `{ref}` and its `label:` stay in agreement.
|
|
594
|
+
let ref = identOrQuoted(token.target.name);
|
|
595
|
+
for (const ext of token.stripExtensions ?? []) ref += `.strip_suffix(${quote(ext)})`;
|
|
596
|
+
if (token.fallback !== undefined) ref += `.or(${quote(token.fallback)})`;
|
|
597
|
+
body += `{${ref}}`;
|
|
598
|
+
}
|
|
599
|
+
return `${label}\`${body}\``;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// -- Frontmatter --
|
|
603
|
+
|
|
604
|
+
/** Quote a frontmatter scalar (double-quoted, backslash-escaped). The
|
|
605
|
+
* frontmatter parser unescapes symmetrically, so quotes/newlines round-trip. */
|
|
606
|
+
private fmScalar(value: string): string {
|
|
607
|
+
return quote(value);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private emitFrontmatter(app: AppMeta | undefined, exe: string | undefined): string | undefined {
|
|
611
|
+
const lines: string[] = [];
|
|
612
|
+
|
|
613
|
+
// `exe` is the executable stripped from the command (argv[0]); emit it only
|
|
614
|
+
// when one was present, so the frontend's re-prepend stays symmetric.
|
|
615
|
+
if (exe !== undefined) lines.push(`exe: ${this.fmScalar(exe)}`);
|
|
616
|
+
|
|
617
|
+
if (app) {
|
|
618
|
+
if (app.version) lines.push(`version: ${this.fmScalar(app.version)}`);
|
|
619
|
+
|
|
620
|
+
const authors = app.doc?.authors ?? [];
|
|
621
|
+
if (authors.length > 0) {
|
|
622
|
+
lines.push("authors:");
|
|
623
|
+
for (const a of authors) lines.push(` - ${this.fmScalar(a)}`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const urls = app.doc?.urls ?? [];
|
|
627
|
+
if (urls.length > 0) {
|
|
628
|
+
lines.push("urls:");
|
|
629
|
+
for (const u of urls) lines.push(` - ${this.fmScalar(u)}`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const references = app.doc?.literature ?? [];
|
|
633
|
+
if (references.length > 0) {
|
|
634
|
+
lines.push("references:");
|
|
635
|
+
for (const rf of references) lines.push(` - ${this.fmScalar(rf)}`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (app.container) {
|
|
639
|
+
lines.push("container:");
|
|
640
|
+
lines.push(` image: ${this.fmScalar(app.container.image)}`);
|
|
641
|
+
if (app.container.type) lines.push(` type: ${this.fmScalar(app.container.type)}`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (app.stdout) this.emitStream(lines, "stdout", app.stdout);
|
|
645
|
+
if (app.stderr) this.emitStream(lines, "stderr", app.stderr);
|
|
646
|
+
|
|
647
|
+
// app.doc.title is emitted in the root `///` block, not frontmatter.
|
|
648
|
+
if (app.doc?.comment) {
|
|
649
|
+
this.warn("AppMeta.doc.comment has no argtype surface; ignored");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (lines.length === 0) return undefined;
|
|
654
|
+
return ["---", ...lines, "---"].join("\n");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private emitStream(lines: string[], key: string, stream: StreamOutput): void {
|
|
658
|
+
lines.push(`${key}:`);
|
|
659
|
+
lines.push(` name: ${this.fmScalar(stream.name)}`);
|
|
660
|
+
if (stream.doc?.description)
|
|
661
|
+
lines.push(` description: ${this.fmScalar(stream.doc.description)}`);
|
|
662
|
+
if (stream.doc?.title) {
|
|
663
|
+
this.warn(`${key} stream title has no argtype surface; ignored`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Emit argtype sugar-DSL source from an IR expression tree and optional `AppMeta`. */
|
|
669
|
+
export function generateArgtype(
|
|
670
|
+
expr: Expr,
|
|
671
|
+
app?: AppMeta,
|
|
672
|
+
): { source: string; warnings: EmitWarning[] } {
|
|
673
|
+
const emitter = new ArgtypeEmitter();
|
|
674
|
+
const source = emitter.emit(expr, app);
|
|
675
|
+
return { source, warnings: emitter.warnings };
|
|
676
|
+
}
|