@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,487 @@
1
+ /**
2
+ * Recursive-descent parser for the argtype sugar DSL, producing an `AstDocument`
3
+ * (frontmatter + aliases + a single root definition). Lowering to Styx IR is a
4
+ * separate step (`lower.ts`).
5
+ *
6
+ * Precedence, loosest to tightest: `,` (list separator) < `label:` < `|` (alt)
7
+ * < `.method()` (chaining). See the spec's "Sugar DSL" section.
8
+ */
9
+
10
+ import type { AstAlias, AstDocument, AstNode, AstOutput, Combinator, Terminal } from "./ast.js";
11
+ import { lex } from "./lexer.js";
12
+ import type { Token, TokenKind } from "./lexer.js";
13
+ import { splitFrontmatter } from "./frontmatter.js";
14
+ import { parseTemplate } from "./template.js";
15
+ import { splitDocText } from "./doc.js";
16
+
17
+ /** Split a `///` block and attach its title/description to a node. Per the spec,
18
+ * the block "is applied first and wins": for each field the block provides it
19
+ * overrides a value a chained `.title()`/`.description()` already set, while a
20
+ * field the block leaves unset keeps whatever the chain supplied. */
21
+ function attachDocText(target: { title?: string; description?: string }, raw: string): void {
22
+ const { title, description } = splitDocText(raw);
23
+ if (title !== undefined) target.title = title;
24
+ if (description !== undefined) target.description = description;
25
+ }
26
+
27
+ const COMBINATORS = new Set<Combinator>(["seq", "set", "opt", "rep", "alt", "any"]);
28
+ const TERMINALS = new Set<Terminal>(["int", "float", "str", "path"]);
29
+
30
+ export interface AstParseError {
31
+ message: string;
32
+ line?: number;
33
+ column?: number;
34
+ }
35
+
36
+ export interface AstParseResult {
37
+ doc?: AstDocument;
38
+ errors: AstParseError[];
39
+ warnings: AstParseError[];
40
+ }
41
+
42
+ /** Core + supported-extension chaining methods. Anything else is an extension
43
+ * we don't implement and is parsed-and-ignored (the spec's "ignorable" rule). */
44
+ const KNOWN_METHODS = new Set([
45
+ "name",
46
+ "title",
47
+ "description",
48
+ "default",
49
+ "min",
50
+ "max",
51
+ "join",
52
+ "count",
53
+ "countMin",
54
+ "countMax",
55
+ "mediaType",
56
+ "mutable",
57
+ "resolveParent",
58
+ ]);
59
+
60
+ class Parser {
61
+ private tokens: Token[];
62
+ private pos = 0;
63
+ readonly errors: AstParseError[] = [];
64
+ readonly warnings: AstParseError[] = [];
65
+
66
+ constructor(tokens: Token[]) {
67
+ this.tokens = tokens;
68
+ }
69
+
70
+ private peek(o = 0): Token {
71
+ return this.tokens[Math.min(this.pos + o, this.tokens.length - 1)]!;
72
+ }
73
+
74
+ private at(kind: TokenKind): boolean {
75
+ return this.peek().kind === kind;
76
+ }
77
+
78
+ private next(): Token {
79
+ return this.tokens[this.pos++] ?? this.tokens[this.tokens.length - 1]!;
80
+ }
81
+
82
+ private expect(kind: TokenKind, what: string): Token {
83
+ if (this.at(kind)) return this.next();
84
+ const tok = this.peek();
85
+ this.error(`Expected ${what} but found '${tok.value || tok.kind}'`, tok);
86
+ return tok;
87
+ }
88
+
89
+ private error(message: string, tok: Token = this.peek()): void {
90
+ this.errors.push({ message, line: tok.line, column: tok.column });
91
+ }
92
+
93
+ private warn(message: string, tok: Token = this.peek()): void {
94
+ this.warnings.push({ message, line: tok.line, column: tok.column });
95
+ }
96
+
97
+ // -- Top level --
98
+
99
+ parseDocument(frontmatter: Record<string, unknown> | undefined): AstDocument | undefined {
100
+ const aliases: AstAlias[] = [];
101
+ let rootName: string | undefined;
102
+ let root: AstNode | undefined;
103
+
104
+ while (!this.at("eof")) {
105
+ const docs = this.collectDocs();
106
+ if (this.at("eof")) break; // trailing docs before end of file
107
+
108
+ // An alias (`Name = expr`) or a named root (`name: expr`) both begin with
109
+ // an identifier / quoted label followed by `=` / `:`. Anything else at the
110
+ // top level is a bare, anonymous root expression - the spec lets the root
111
+ // be unnamed (its id then falls back to `exe`/`id` frontmatter).
112
+ const labelLike = this.at("ident") || this.at("string");
113
+ const following = this.peek(1).kind;
114
+
115
+ if (labelLike && following === "eq") {
116
+ // Alias: Name = expr. Alias names must be identifiers (they are
117
+ // referenced by bare name at each use site).
118
+ const nameTok = this.next();
119
+ if (nameTok.kind === "string")
120
+ this.error("Alias names must be identifiers, not quoted strings", nameTok);
121
+ this.next(); // '='
122
+ const expr = this.parseElement();
123
+ if (docs) attachDocText(expr, docs);
124
+ aliases.push({ name: nameTok.value, expr });
125
+ continue;
126
+ }
127
+
128
+ if (labelLike && following === "colon") {
129
+ // Named root definition: name: expr
130
+ const nameTok = this.next();
131
+ this.next(); // ':'
132
+ const expr = this.parseElement();
133
+ if (docs) attachDocText(expr, docs);
134
+ if (root) {
135
+ this.error(
136
+ `Multiple root definitions; '${nameTok.value}' ignored (already have '${rootName ?? "<anonymous>"}')`,
137
+ nameTok,
138
+ );
139
+ } else {
140
+ rootName = nameTok.value;
141
+ root = expr;
142
+ if (!root.name) root.name = nameTok.value;
143
+ }
144
+ continue;
145
+ }
146
+
147
+ // Anonymous root: a bare expression (`seq(...)`, `rep(str)`, `path`,
148
+ // `"literal"`, `(...)`, or an alias reference). It leaves `rootName`
149
+ // unset; lowering derives the tool id from frontmatter. Every root
150
+ // expression starts with an identifier, a string, or `(`; a top-level
151
+ // token that cannot start an expression is a syntax error, not an
152
+ // anonymous root (guarding this keeps `parsePrimary`'s empty-literal
153
+ // fallback from being adopted as the root and then reported as a
154
+ // spurious duplicate of the real one).
155
+ if (!this.at("ident") && !this.at("string") && !this.at("lparen")) {
156
+ this.error(
157
+ "Expected a definition (name: expr), an alias (Name = expr), or a root expression",
158
+ );
159
+ break;
160
+ }
161
+ const expr = this.parseElement();
162
+ if (docs) attachDocText(expr, docs);
163
+ if (root) {
164
+ this.error("Multiple root definitions; a second top-level expression is not allowed");
165
+ break;
166
+ }
167
+ root = expr;
168
+ }
169
+
170
+ if (!root) {
171
+ this.error("No root definition (expected `name: expr` or a bare root expression)");
172
+ return undefined;
173
+ }
174
+ return {
175
+ ...(frontmatter && { frontmatter }),
176
+ aliases,
177
+ ...(rootName !== undefined && { rootName }),
178
+ root,
179
+ };
180
+ }
181
+
182
+ /** Collect consecutive `///` doc lines, joined with newlines. */
183
+ private collectDocs(): string | undefined {
184
+ const lines: string[] = [];
185
+ while (this.at("doc")) lines.push(this.next().value);
186
+ return lines.length > 0 ? lines.join("\n") : undefined;
187
+ }
188
+
189
+ // -- Expressions --
190
+
191
+ /**
192
+ * An element is the unit inside a comma list: an optionally-named expression.
193
+ * `label:` is looser than `|`, so a name binds the whole alternative that
194
+ * follows it.
195
+ */
196
+ private parseElement(): AstNode {
197
+ // A label is a bare identifier or a quoted string (for names that are not
198
+ // valid identifiers), followed by `:`. A quoted string NOT followed by `:`
199
+ // is a literal, so the colon lookahead disambiguates.
200
+ if ((this.at("ident") || this.at("string")) && this.peek(1).kind === "colon") {
201
+ const nameTok = this.next();
202
+ this.next(); // colon
203
+ const inner = this.parseElement();
204
+ inner.name = nameTok.value;
205
+ return inner;
206
+ }
207
+ return this.parseAlt();
208
+ }
209
+
210
+ private parseAlt(): AstNode {
211
+ const first = this.parseChain();
212
+ if (!this.at("pipe")) return first;
213
+ const alts: AstNode[] = [first];
214
+ while (this.at("pipe")) {
215
+ this.next();
216
+ alts.push(this.parseChain());
217
+ }
218
+ return { kind: "comb", op: "alt", children: alts };
219
+ }
220
+
221
+ private parseChain(): AstNode {
222
+ const node = this.parsePrimary();
223
+ // Method chain.
224
+ let chained = false;
225
+ while (this.at("dot")) {
226
+ this.next();
227
+ this.applyMethod(node);
228
+ chained = true;
229
+ }
230
+ // `= value` default sugar. The spec restricts it to a bare terminal: once a
231
+ // chain has started, `.default(...)` must be used instead.
232
+ if (this.at("eq")) {
233
+ const eqTok = this.next();
234
+ const value = this.parseValue();
235
+ if (node.kind === "terminal" && !chained) {
236
+ node.default = value;
237
+ } else {
238
+ this.error(
239
+ "`= value` default is only allowed on a bare terminal; use `.default(...)` after a method chain",
240
+ eqTok,
241
+ );
242
+ }
243
+ }
244
+ return node;
245
+ }
246
+
247
+ private parsePrimary(): AstNode {
248
+ const tok = this.peek();
249
+
250
+ if (tok.kind === "string") {
251
+ this.next();
252
+ return { kind: "literal", value: tok.value };
253
+ }
254
+
255
+ if (tok.kind === "lparen") {
256
+ // Anonymous sequence group.
257
+ const children = this.parseParenList();
258
+ return { kind: "comb", op: "seq", children };
259
+ }
260
+
261
+ if (tok.kind === "ident") {
262
+ if (COMBINATORS.has(tok.value as Combinator) && this.peek(1).kind === "lparen") {
263
+ this.next();
264
+ const children = this.parseParenList();
265
+ return { kind: "comb", op: tok.value as Combinator, children };
266
+ }
267
+ if (TERMINALS.has(tok.value as Terminal)) {
268
+ this.next();
269
+ return { kind: "terminal", terminal: tok.value as Terminal };
270
+ }
271
+ // Otherwise an alias reference.
272
+ this.next();
273
+ return { kind: "ref", refName: tok.value };
274
+ }
275
+
276
+ this.error(`Unexpected token '${tok.value || tok.kind}' in expression`, tok);
277
+ this.next();
278
+ return { kind: "literal", value: "" };
279
+ }
280
+
281
+ /** Parse `( elem, elem, ... )` with optional leading docs and trailing comma. */
282
+ private parseParenList(): AstNode[] {
283
+ this.expect("lparen", "'('");
284
+ const items: AstNode[] = [];
285
+ while (!this.at("rparen") && !this.at("eof")) {
286
+ const docs = this.collectDocs();
287
+ if (this.at("rparen")) break; // trailing docs before close
288
+ const elem = this.parseElement();
289
+ if (docs) attachDocText(elem, docs);
290
+ items.push(elem);
291
+ if (this.at("comma")) this.next();
292
+ else break;
293
+ }
294
+ this.expect("rparen", "')'");
295
+ return items;
296
+ }
297
+
298
+ // -- Method calls --
299
+
300
+ private applyMethod(node: AstNode): void {
301
+ const nameTok = this.expect("ident", "a method name");
302
+ const method = nameTok.value;
303
+ if (method === "output") {
304
+ node.outputs = [...(node.outputs ?? []), ...this.parseOutputArgs()];
305
+ return;
306
+ }
307
+ if (!KNOWN_METHODS.has(method)) {
308
+ // An extension method we don't implement (e.g. the draft `constraints`
309
+ // extension's `.requires()`/`.conflicts()`). The spec requires unknown
310
+ // extension annotations to be ignorable, so skip the argument list
311
+ // (whatever its shape) and continue rather than failing the parse.
312
+ this.skipBalancedArgs();
313
+ this.warn(`Ignoring unsupported method '.${method}()'`, nameTok);
314
+ return;
315
+ }
316
+
317
+ const args = this.parseScalarArgs();
318
+ switch (method) {
319
+ case "name":
320
+ if (typeof args[0] === "string") node.name = args[0];
321
+ break;
322
+ case "title":
323
+ if (typeof args[0] === "string") node.title = args[0];
324
+ break;
325
+ case "description":
326
+ // `.description()` sets the description directly; the `# ` heading
327
+ // convention is sugar for the `///` form only, so a leading `#` here is a
328
+ // literal part of the description, not a title.
329
+ if (typeof args[0] === "string") node.description = args[0];
330
+ break;
331
+ case "default":
332
+ if (args[0] !== undefined) node.default = args[0];
333
+ break;
334
+ case "min":
335
+ if (typeof args[0] === "number") node.min = args[0];
336
+ break;
337
+ case "max":
338
+ if (typeof args[0] === "number") node.max = args[0];
339
+ break;
340
+ case "join":
341
+ node.join = typeof args[0] === "string" ? args[0] : "";
342
+ break;
343
+ case "count":
344
+ // `.count(n)` is exactly n (sugar for `.countMin(n).countMax(n)`). For a
345
+ // one-sided or asymmetric bound, use `.countMin` / `.countMax` directly.
346
+ if (typeof args[0] === "number") {
347
+ node.countMin = args[0];
348
+ node.countMax = args[0];
349
+ }
350
+ break;
351
+ case "countMin":
352
+ if (typeof args[0] === "number") node.countMin = args[0];
353
+ break;
354
+ case "countMax":
355
+ if (typeof args[0] === "number") node.countMax = args[0];
356
+ break;
357
+ case "mediaType":
358
+ if (typeof args[0] === "string") (node.mediaTypes ??= []).push(args[0]);
359
+ break;
360
+ case "mutable":
361
+ node.mutable = true;
362
+ break;
363
+ case "resolveParent":
364
+ node.resolveParent = true;
365
+ break;
366
+ }
367
+ }
368
+
369
+ /** Parse a parenthesized list of plain scalar arguments (numbers / strings). */
370
+ private parseScalarArgs(): (string | number)[] {
371
+ this.expect("lparen", "'('");
372
+ const args: (string | number)[] = [];
373
+ while (!this.at("rparen") && !this.at("eof")) {
374
+ args.push(this.parseValue());
375
+ if (this.at("comma")) this.next();
376
+ else break;
377
+ }
378
+ this.expect("rparen", "')'");
379
+ return args;
380
+ }
381
+
382
+ /** Consume a balanced `( ... )` group without interpreting its contents.
383
+ * Used to skip the arguments of an unsupported extension method. */
384
+ private skipBalancedArgs(): void {
385
+ if (!this.at("lparen")) return;
386
+ let depth = 0;
387
+ do {
388
+ const tok = this.next();
389
+ if (tok.kind === "lparen") depth++;
390
+ else if (tok.kind === "rparen") depth--;
391
+ else if (tok.kind === "eof") break;
392
+ } while (depth > 0);
393
+ }
394
+
395
+ /** Parse the argument list of `.output(...)`: one or more template expressions. */
396
+ private parseOutputArgs(): AstOutput[] {
397
+ this.expect("lparen", "'('");
398
+ const outputs: AstOutput[] = [];
399
+ while (!this.at("rparen") && !this.at("eof")) {
400
+ const docs = this.collectDocs();
401
+ if (this.at("rparen")) break; // trailing docs before close
402
+ const out = this.parseOutputTemplate();
403
+ if (docs) attachDocText(out, docs);
404
+ outputs.push(out);
405
+ if (this.at("comma")) this.next();
406
+ else break;
407
+ }
408
+ this.expect("rparen", "')'");
409
+ return outputs;
410
+ }
411
+
412
+ /** `label: \`tpl\`` or `\`tpl\`` followed by optional `.name(...)` / `.or(...)`. */
413
+ private parseOutputTemplate(): AstOutput {
414
+ let name: string | undefined;
415
+ // The output name may be a bare identifier or a quoted label (for
416
+ // non-identifier output names), mirroring `label:` naming.
417
+ if ((this.at("ident") || this.at("string")) && this.peek(1).kind === "colon") {
418
+ name = this.next().value;
419
+ this.next(); // colon
420
+ }
421
+
422
+ const out: AstOutput = { tokens: [] };
423
+ if (this.at("template")) {
424
+ const tmplTok = this.next();
425
+ const { tokens, errors } = parseTemplate(tmplTok.value);
426
+ out.tokens = tokens;
427
+ for (const e of errors) this.error(e, tmplTok);
428
+ } else {
429
+ this.error(`Expected an output template literal`);
430
+ }
431
+
432
+ // Template-level chaining: .name("x") / .or("fallback") / .title("t") /
433
+ // .description("d"). An unrecognized method is an ignorable extension
434
+ // annotation (same rule as node method chains): skip its arguments and warn
435
+ // rather than failing the parse.
436
+ const CHAIN = new Set(["name", "or", "title", "description"]);
437
+ while (this.at("dot")) {
438
+ this.next();
439
+ const m = this.expect("ident", "a template method");
440
+ if (CHAIN.has(m.value)) {
441
+ const arg = this.parseScalarArgs()[0];
442
+ if (typeof arg === "string") {
443
+ if (m.value === "name") name = arg;
444
+ else if (m.value === "or") out.fallback = arg;
445
+ else if (m.value === "title") out.title = arg;
446
+ else out.description = arg; // description
447
+ }
448
+ } else {
449
+ this.skipBalancedArgs();
450
+ this.warn(`Ignoring unsupported output-template method '.${m.value}()'`, m);
451
+ }
452
+ }
453
+
454
+ if (name !== undefined) out.name = name;
455
+ return out;
456
+ }
457
+
458
+ private parseValue(): string | number {
459
+ const tok = this.peek();
460
+ if (tok.kind === "string") {
461
+ this.next();
462
+ return tok.value;
463
+ }
464
+ if (tok.kind === "number") {
465
+ this.next();
466
+ return Number(tok.value);
467
+ }
468
+ this.error(`Expected a value (string or number) but found '${tok.value || tok.kind}'`, tok);
469
+ this.next();
470
+ return "";
471
+ }
472
+ }
473
+
474
+ export function parseArgtype(source: string): AstParseResult {
475
+ const { frontmatter, body, errors: fmErrors } = splitFrontmatter(source);
476
+ const { tokens, errors: lexErrors } = lex(body);
477
+
478
+ const parser = new Parser(tokens);
479
+ const doc = parser.parseDocument(frontmatter);
480
+
481
+ const errors: AstParseError[] = [
482
+ ...fmErrors.map((m) => ({ message: m })),
483
+ ...lexErrors.map((e) => ({ message: e.message, line: e.line, column: e.column })),
484
+ ...parser.errors,
485
+ ];
486
+ return { ...(doc && { doc }), errors, warnings: parser.warnings };
487
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Parse an output template literal body (the text between backticks) into
3
+ * `AstOutputToken[]`. Literal runs become literal tokens; `{...}` interpolations
4
+ * become ref tokens carrying any reference operations (`strip_suffix`,
5
+ * `strip_prefix`, `basename`, `or`).
6
+ *
7
+ * `{}` (empty braces) is a self-reference: the value of the node the output is
8
+ * attached to. Lowering resolves it to that node's name.
9
+ */
10
+
11
+ import type { AstOutputToken } from "./ast.js";
12
+
13
+ export interface TemplateParseResult {
14
+ tokens: AstOutputToken[];
15
+ errors: string[];
16
+ }
17
+
18
+ /**
19
+ * Index of the `}` that closes an interpolation opened at `open` (the position
20
+ * just past the `{`), or -1 if unterminated. A naive `indexOf("}")` would stop
21
+ * at the first `}` even when it sits inside a quoted ref name (`{"a}b"}`) or a
22
+ * quoted operation argument (`{a.or("{b}")}`); this scan skips double-quoted
23
+ * spans (honoring `\"`) and balances nested `{}` so those round-trip.
24
+ */
25
+ function interpolationEnd(body: string, open: number): number {
26
+ let depth = 1;
27
+ let inQuote = false;
28
+ for (let j = open; j < body.length; j++) {
29
+ const c = body[j]!;
30
+ if (inQuote) {
31
+ if (c === "\\")
32
+ j++; // skip the escaped char (e.g. \" or \`)
33
+ else if (c === '"') inQuote = false;
34
+ continue;
35
+ }
36
+ if (c === '"') inQuote = true;
37
+ else if (c === "{") depth++;
38
+ else if (c === "}" && --depth === 0) return j;
39
+ }
40
+ return -1;
41
+ }
42
+
43
+ export function parseTemplate(body: string): TemplateParseResult {
44
+ const tokens: AstOutputToken[] = [];
45
+ const errors: string[] = [];
46
+ let lit = "";
47
+ let i = 0;
48
+
49
+ const flushLit = (): void => {
50
+ if (lit.length > 0) {
51
+ tokens.push({ kind: "literal", value: lit });
52
+ lit = "";
53
+ }
54
+ };
55
+
56
+ while (i < body.length) {
57
+ const ch = body[i]!;
58
+ // A backslash escapes the next character: `\{`, `\}`, `` \` ``, `\\` become
59
+ // that literal char (so `{` need not start an interpolation).
60
+ if (ch === "\\" && i + 1 < body.length) {
61
+ const next = body[i + 1]!;
62
+ lit += next === "{" || next === "}" || next === "`" || next === "\\" ? next : "\\" + next;
63
+ i += 2;
64
+ continue;
65
+ }
66
+ if (ch === "{") {
67
+ const close = interpolationEnd(body, i + 1);
68
+ if (close === -1) {
69
+ errors.push("Unterminated '{' in output template");
70
+ lit += body.slice(i);
71
+ break;
72
+ }
73
+ flushLit();
74
+ const inner = body.slice(i + 1, close).trim();
75
+ const { token, error } = parseRef(inner);
76
+ if (error) errors.push(error);
77
+ tokens.push(token);
78
+ i = close + 1;
79
+ } else {
80
+ lit += ch;
81
+ i++;
82
+ }
83
+ }
84
+ flushLit();
85
+ return { tokens, errors };
86
+ }
87
+
88
+ /** Parse the inside of `{...}`: an optional name followed by `.op(arg)` calls.
89
+ * The name is a bare identifier, a double-quoted string (for a non-identifier
90
+ * target name, e.g. `{"4d_output"}`), or absent for a self-reference (`{}`). */
91
+ function parseRef(inner: string): { token: AstOutputToken; error?: string } {
92
+ const token: AstOutputToken & { kind: "ref" } = { kind: "ref" };
93
+
94
+ let rest = inner;
95
+ const quoted = /^"((?:[^"\\]|\\.)*)"/.exec(inner);
96
+ if (quoted) {
97
+ // A quoted target name carries an arbitrary (non-identifier) name verbatim,
98
+ // mirroring a quoted `label:` (a `}` inside the quotes is fine - the scanner
99
+ // is quote-aware). A literal backtick still cannot appear in a name: the
100
+ // lexer ends the template at the first unescaped backtick.
101
+ token.name = unescapeArg(quoted[1]!);
102
+ rest = inner.slice(quoted[0].length);
103
+ } else {
104
+ // Leading identifier (the referenced name).
105
+ const nameMatch = /^[A-Za-z_][A-Za-z0-9_]*/.exec(inner);
106
+ if (nameMatch) {
107
+ token.name = nameMatch[0];
108
+ rest = inner.slice(nameMatch[0].length);
109
+ }
110
+ }
111
+
112
+ // Method chain: .op("arg") or .op()
113
+ const opRe = /^\.\s*([A-Za-z_]+)\s*\(\s*(?:"((?:[^"\\]|\\.)*)")?\s*\)/;
114
+ let error: string | undefined;
115
+ while (rest.length > 0) {
116
+ const m = opRe.exec(rest);
117
+ if (!m) {
118
+ error = `Unrecognized output-reference operation near '${rest}'`;
119
+ break;
120
+ }
121
+ const op = m[1]!;
122
+ const arg = m[2];
123
+ switch (op) {
124
+ case "strip_suffix":
125
+ if (arg !== undefined) (token.stripSuffix ??= []).push(unescapeArg(arg));
126
+ break;
127
+ case "strip_prefix":
128
+ if (arg !== undefined) (token.stripPrefix ??= []).push(unescapeArg(arg));
129
+ break;
130
+ case "basename":
131
+ token.basename = true;
132
+ break;
133
+ case "or":
134
+ if (arg !== undefined) token.or = unescapeArg(arg);
135
+ break;
136
+ default:
137
+ error = `Unknown output-reference operation '${op}'`;
138
+ }
139
+ rest = rest.slice(m[0].length).trimStart();
140
+ }
141
+
142
+ return { token, ...(error && { error }) };
143
+ }
144
+
145
+ function unescapeArg(s: string): string {
146
+ return s.replace(/\\(.)/g, "$1");
147
+ }
@@ -1,14 +1,39 @@
1
- export type FormatName = "boutiques" | "argdump" | "workbench" | "mrtrix";
1
+ export type FormatName = "boutiques" | "argdump" | "workbench" | "mrtrix" | "argtype";
2
+
3
+ /** The first non-whitespace character of a source ("" if it is all blank). */
4
+ function firstNonBlankChar(source: string): string {
5
+ const match = source.match(/\S/);
6
+ return match ? match[0] : "";
7
+ }
2
8
 
3
9
  /**
4
- * Auto-detect the format of a JSON descriptor source string.
5
- * Returns null if the format cannot be determined.
10
+ * Auto-detect the format of a descriptor source string.
11
+ *
12
+ * Every JSON frontend (boutiques, argdump, workbench, mrtrix) is a top-level
13
+ * object, so the first non-blank character being `{` is the signal for "some
14
+ * JSON format"; we then inspect its keys to pick which one. Anything else
15
+ * non-blank is treated as the argtype DSL, whose sources open with a terminal
16
+ * (`int`), a literal (`"hello"`), a combinator (`seq(...)`), a `name: expr`
17
+ * definition, or a `---` frontmatter fence - never with `{`.
18
+ *
19
+ * Deciding on the leading character (rather than trying `JSON.parse` first) is
20
+ * what lets standalone argtype snippets like `"hello"` or `42` be recognized:
21
+ * those are *also* valid JSON scalars, so a parse-first approach would swallow
22
+ * them and then reject them as "not an object".
23
+ *
24
+ * Returns null only when the source is blank, or opens with `{` but matches no
25
+ * known JSON format (ambiguous, or still being typed).
6
26
  */
7
27
  export function detectFormat(source: string): FormatName | null {
28
+ const first = firstNonBlankChar(source);
29
+ if (first === "") return null;
30
+ if (first !== "{") return "argtype";
31
+
8
32
  let parsed: unknown;
9
33
  try {
10
34
  parsed = JSON.parse(source);
11
35
  } catch {
36
+ // Opens like a JSON object but is not valid (yet) - e.g. mid-edit.
12
37
  return null;
13
38
  }
14
39