@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
|
@@ -20,29 +20,36 @@ export function destructTemplate<T>(template: string, lookup: Record<string, T>)
|
|
|
20
20
|
continue;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Pick the leftmost match; on a tie in position, the longest alias wins
|
|
24
|
+
// (maximal munch), so a value-key that is a prefix of another (e.g. `foo`
|
|
25
|
+
// vs `foobar`) does not shadow it by mere iteration order.
|
|
26
|
+
let best: { idx: number; alias: string; replacement: T } | null = null;
|
|
25
27
|
for (const [alias, replacement] of Object.entries(lookup)) {
|
|
28
|
+
if (alias.length === 0) continue;
|
|
26
29
|
const idx = x.indexOf(alias);
|
|
27
|
-
if (idx
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
stack.unshift(replacement);
|
|
35
|
-
if (left.length > 0) {
|
|
36
|
-
stack.unshift(left);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
didSplit = true;
|
|
40
|
-
break;
|
|
30
|
+
if (idx === -1) continue;
|
|
31
|
+
if (
|
|
32
|
+
best === null ||
|
|
33
|
+
idx < best.idx ||
|
|
34
|
+
(idx === best.idx && alias.length > best.alias.length)
|
|
35
|
+
) {
|
|
36
|
+
best = { idx, alias, replacement };
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
if (
|
|
40
|
+
if (best === null) {
|
|
45
41
|
destructed.push(x);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const left = x.slice(0, best.idx);
|
|
46
|
+
const right = x.slice(best.idx + best.alias.length);
|
|
47
|
+
if (right.length > 0) {
|
|
48
|
+
stack.unshift(right);
|
|
49
|
+
}
|
|
50
|
+
stack.unshift(best.replacement);
|
|
51
|
+
if (left.length > 0) {
|
|
52
|
+
stack.unshift(left);
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -372,12 +372,21 @@ export class BoutiquesParser implements Frontend {
|
|
|
372
372
|
case InputTypePrimitive.Integer: {
|
|
373
373
|
const node: Int = { kind: "int", attrs: {} };
|
|
374
374
|
if (isNumber(btInput.minimum)) {
|
|
375
|
-
|
|
376
|
-
|
|
375
|
+
// Smallest valid integer: for a fractional bound, `>= 5.7` allows 6
|
|
376
|
+
// (ceil) and `> 5.7` allows 6 (floor + 1); both differ from a plain
|
|
377
|
+
// floor.
|
|
378
|
+
node.attrs.minValue =
|
|
379
|
+
btInput["exclusive-minimum"] === true
|
|
380
|
+
? Math.floor(btInput.minimum) + 1
|
|
381
|
+
: Math.ceil(btInput.minimum);
|
|
377
382
|
}
|
|
378
383
|
if (isNumber(btInput.maximum)) {
|
|
379
|
-
|
|
380
|
-
|
|
384
|
+
// Largest valid integer: `<= 5.7` allows 5 (floor) and `< 5.7` allows
|
|
385
|
+
// 5 (ceil - 1).
|
|
386
|
+
node.attrs.maxValue =
|
|
387
|
+
btInput["exclusive-maximum"] === true
|
|
388
|
+
? Math.ceil(btInput.maximum) - 1
|
|
389
|
+
: Math.floor(btInput.maximum);
|
|
381
390
|
}
|
|
382
391
|
if (meta) node.meta = meta;
|
|
383
392
|
return node;
|
|
@@ -424,7 +433,10 @@ export class BoutiquesParser implements Frontend {
|
|
|
424
433
|
return null;
|
|
425
434
|
}
|
|
426
435
|
const node = this.parseDescriptor(nested);
|
|
427
|
-
|
|
436
|
+
// Merge (not overwrite) so the subcommand's own meta - notably its
|
|
437
|
+
// `output-files`, attached by `parseDescriptor` - survives; the input's
|
|
438
|
+
// meta (name/doc) layers on top.
|
|
439
|
+
if (node && meta) node.meta = { ...node.meta, ...meta };
|
|
428
440
|
return node;
|
|
429
441
|
}
|
|
430
442
|
|
|
@@ -553,12 +565,19 @@ export class BoutiquesParser implements Frontend {
|
|
|
553
565
|
}
|
|
554
566
|
|
|
555
567
|
// Hoist metadata (doc, default) to outermost wrapper so backends find it
|
|
556
|
-
// on the binding node. Keep name on inner for solver's findDeepName
|
|
568
|
+
// on the binding node. Keep `name` on inner for the solver's findDeepName,
|
|
569
|
+
// and keep `outputs` there too: outputs are a scope concept declared on the
|
|
570
|
+
// subcommand struct, not a property of the list/flag wrapper - hoisting them
|
|
571
|
+
// onto a repeat would move the output scope off the struct and make backends
|
|
572
|
+
// (which resolve outputs against the struct) silently drop them.
|
|
557
573
|
if (node !== inner && inner.meta) {
|
|
558
|
-
const { name, ...rest } = inner.meta;
|
|
574
|
+
const { name, outputs, ...rest } = inner.meta;
|
|
559
575
|
if (Object.keys(rest).length > 0) {
|
|
560
576
|
node.meta = { ...node.meta, ...rest };
|
|
561
|
-
|
|
577
|
+
const kept: NodeMeta = {};
|
|
578
|
+
if (name) kept.name = name;
|
|
579
|
+
if (outputs) kept.outputs = outputs;
|
|
580
|
+
inner.meta = Object.keys(kept).length > 0 ? kept : undefined;
|
|
562
581
|
}
|
|
563
582
|
}
|
|
564
583
|
|
|
@@ -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
|
|
5
|
-
*
|
|
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
|
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ArgdumpParser } from "./frontend/argdump/index.js";
|
|
2
|
+
import { ArgtypeParser } from "./frontend/argtype/index.js";
|
|
2
3
|
import { BoutiquesParser } from "./frontend/boutiques/index.js";
|
|
3
4
|
import { MrtrixParser } from "./frontend/mrtrix/index.js";
|
|
4
5
|
import { WorkbenchParser } from "./frontend/workbench/index.js";
|
|
@@ -22,7 +23,10 @@ export function compile(
|
|
|
22
23
|
? { filename: filenameOrOptions }
|
|
23
24
|
: (filenameOrOptions ?? {});
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
// An explicit `.argtype` filename selects the DSL frontend even though the
|
|
27
|
+
// source is not JSON (detection by content is a fallback).
|
|
28
|
+
const byExtension = options.filename?.endsWith(".argtype") ? "argtype" : undefined;
|
|
29
|
+
const format = options.format ?? byExtension ?? detectFormat(source);
|
|
26
30
|
|
|
27
31
|
if (!format) {
|
|
28
32
|
return {
|
|
@@ -32,13 +36,23 @@ export function compile(
|
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
const parser =
|
|
36
|
-
format
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
const parser = ((): { parse: (s: string, f?: string) => ParseResult } => {
|
|
40
|
+
switch (format) {
|
|
41
|
+
case "argdump":
|
|
42
|
+
return new ArgdumpParser();
|
|
43
|
+
case "argtype":
|
|
44
|
+
return new ArgtypeParser();
|
|
45
|
+
case "workbench":
|
|
46
|
+
return new WorkbenchParser();
|
|
47
|
+
case "mrtrix":
|
|
48
|
+
return new MrtrixParser();
|
|
49
|
+
case "boutiques":
|
|
50
|
+
return new BoutiquesParser();
|
|
51
|
+
default: {
|
|
52
|
+
const _exhaustive: never = format;
|
|
53
|
+
return new BoutiquesParser();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
43
57
|
return parser.parse(source, options.filename);
|
|
44
58
|
}
|
|
@@ -38,6 +38,19 @@ export const canonicalize: Pass = {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Identity for dedup: structure plus the meta that makes two structurally
|
|
42
|
+
// identical union arms semantically distinct - the variant tag (@type
|
|
43
|
+
// discriminant), name, and attached outputs. Deduping on structure alone
|
|
44
|
+
// would silently drop a distinct arm (e.g. two sub-commands with the same
|
|
45
|
+
// inner shape but different @type), making its variant unreachable.
|
|
46
|
+
function identityKey(node: Expr): string {
|
|
47
|
+
const m = node.meta;
|
|
48
|
+
const metaKey = m
|
|
49
|
+
? [m.name ?? "", m.variantTag ?? "", m.outputs ? JSON.stringify(m.outputs) : ""].join("|")
|
|
50
|
+
: "";
|
|
51
|
+
return `${structuralHash(node)}#${metaKey}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
function sortKey(node: Expr): string {
|
|
42
55
|
const name = node.meta?.name ?? "";
|
|
43
56
|
return `${node.kind}:${name}:${structuralHash(node)}`;
|
|
@@ -51,13 +64,13 @@ export const canonicalize: Pass = {
|
|
|
51
64
|
// Sort alternatives
|
|
52
65
|
const sorted = [...children].sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
|
|
53
66
|
|
|
54
|
-
// Deduplicate
|
|
67
|
+
// Deduplicate identical arms (structure + discriminating meta).
|
|
55
68
|
const seen = new Set<string>();
|
|
56
69
|
const alts: Expr[] = [];
|
|
57
70
|
for (const child of sorted) {
|
|
58
|
-
const
|
|
59
|
-
if (!seen.has(
|
|
60
|
-
seen.add(
|
|
71
|
+
const key = identityKey(child);
|
|
72
|
+
if (!seen.has(key)) {
|
|
73
|
+
seen.add(key);
|
|
61
74
|
alts.push(child);
|
|
62
75
|
} else {
|
|
63
76
|
changed = true;
|
|
@@ -67,7 +80,7 @@ export const canonicalize: Pass = {
|
|
|
67
80
|
// Check if order changed
|
|
68
81
|
if (
|
|
69
82
|
alts.length !== children.length ||
|
|
70
|
-
alts.some((alt, i) =>
|
|
83
|
+
alts.some((alt, i) => identityKey(alt) !== identityKey(children[i]!))
|
|
71
84
|
) {
|
|
72
85
|
changed = true;
|
|
73
86
|
}
|
|
@@ -127,15 +127,30 @@ export const simplify: Pass = {
|
|
|
127
127
|
node.attrs.join === ""
|
|
128
128
|
) {
|
|
129
129
|
changed = true;
|
|
130
|
-
prev
|
|
130
|
+
// Replace with a fresh node - `prev` is the original literal object
|
|
131
|
+
// (the `literal` case returns `node` unchanged), so mutating it in
|
|
132
|
+
// place would corrupt the caller's IR and double-append on rerun.
|
|
133
|
+
nodes[nodes.length - 1] = {
|
|
134
|
+
...prev,
|
|
135
|
+
attrs: { ...prev.attrs, str: prev.attrs.str + child.attrs.str },
|
|
136
|
+
};
|
|
131
137
|
} else {
|
|
132
138
|
nodes.push(child);
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
// seq(T) -> T, carrying the seq's meta down onto the child.
|
|
142
|
+
// seq(T) -> T, carrying the seq's meta down onto the child. Skip the
|
|
143
|
+
// collapse when the sequence declares outputs and the sole child is a
|
|
144
|
+
// literal: outputs need a scope binding, which the solver only forces
|
|
145
|
+
// on sequences (not on literals, which are never bound). Collapsing a
|
|
146
|
+
// parameterless output-bearing command (e.g. `seq(lit("run"))` with an
|
|
147
|
+
// output-file) onto its literal would orphan the output. A field child
|
|
148
|
+
// (str/path) does get bound, so those still collapse.
|
|
137
149
|
if (nodes.length === 1) {
|
|
138
150
|
const child = nodes[0]!;
|
|
151
|
+
if (node.meta?.outputs?.length && child.kind === "literal") {
|
|
152
|
+
return { ...node, attrs: { ...node.attrs, nodes } };
|
|
153
|
+
}
|
|
139
154
|
changed = true;
|
|
140
155
|
const mergedMeta = mergeMeta(node.meta, child.meta);
|
|
141
156
|
return mergedMeta ? { ...child, meta: mergedMeta } : child;
|
package/src/solver/solver.ts
CHANGED
|
@@ -45,11 +45,11 @@ function isBooleanLiteralPair(variants: Array<{ type: BoundType }>): boolean {
|
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
47
|
const [a, b] = variants.map((v) => (v.type.kind === "literal" ? v.type.value : null));
|
|
48
|
+
// `literalFromNode` canonicalizes clean-int literals, so "0"/"1" arrive as the
|
|
49
|
+
// numbers 0/1 (never strings); only "false"/"true" survive as strings.
|
|
48
50
|
return (
|
|
49
51
|
(a === 0 && b === 1) ||
|
|
50
52
|
(a === 1 && b === 0) ||
|
|
51
|
-
(a === "0" && b === "1") ||
|
|
52
|
-
(a === "1" && b === "0") ||
|
|
53
53
|
(a === "false" && b === "true") ||
|
|
54
54
|
(a === "true" && b === "false")
|
|
55
55
|
);
|