@styx-api/core 0.1.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@styx-api/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Styx compiler core: parses CLI tool descriptors (e.g. Boutiques), optimizes an IR, solves typed parameter bindings, and generates type-safe wrappers (TypeScript, Python, JSON Schema). Part of the Styx/NiWrap ecosystem.",
5
5
  "keywords": [
6
6
  "styx",
@@ -1,4 +1,5 @@
1
1
  export type {
2
+ AppEntrypoint,
2
3
  Backend,
3
4
  EmitError,
4
5
  EmitResult,
@@ -36,4 +37,9 @@ export type { SnippetDialect, SnippetOptions } from "./snippet-core.js";
36
37
  export { camelCase, pascalCase, screamingSnakeCase, snakeCase } from "./string-case.js";
37
38
  export { PYTHON_RUNNER_DEPS, STYXDEFS_COMPAT } from "./styxdefs-compat.js";
38
39
  export { structKey, typeKey, unionKey } from "./type-keys.js";
39
- export { generateTypeScript, renderTypeScriptCall, TypeScriptBackend } from "./typescript/index.js";
40
+ export {
41
+ appEntrypoint,
42
+ generateTypeScript,
43
+ renderTypeScriptCall,
44
+ TypeScriptBackend,
45
+ } from "./typescript/index.js";
@@ -1,6 +1,7 @@
1
1
  export type { PublicNames } from "./typescript.js";
2
2
  export { mapType } from "./typemap.js";
3
3
  export {
4
+ appEntrypoint,
4
5
  appModuleName,
5
6
  computePublicNames,
6
7
  generatePackageIndex,
@@ -1,4 +1,4 @@
1
- export type FormatName = "boutiques" | "argdump" | "workbench";
1
+ export type FormatName = "boutiques" | "argdump" | "workbench" | "mrtrix";
2
2
 
3
3
  /**
4
4
  * Auto-detect the format of a JSON descriptor source string.
@@ -28,6 +28,15 @@ export function detectFormat(source: string): FormatName | null {
28
28
  return "workbench";
29
29
  }
30
30
 
31
+ // MRtrix C++ dump: a "synopsis" string plus "option_groups" and "arguments" arrays
32
+ if (
33
+ typeof obj.synopsis === "string" &&
34
+ Array.isArray(obj.option_groups) &&
35
+ Array.isArray(obj.arguments)
36
+ ) {
37
+ return "mrtrix";
38
+ }
39
+
31
40
  // Boutiques: has "command-line" or "inputs" array
32
41
  if ("command-line" in obj || (Array.isArray(obj.inputs) && "name" in obj)) {
33
42
  return "boutiques";
@@ -0,0 +1 @@
1
+ export { MrtrixParser } from "./parser.js";
@@ -0,0 +1,412 @@
1
+ import { alt, float, int, lit, opt, path, rep, repJoin, seq, str } from "../../ir/builders.js";
2
+ import { nodeRef } from "../../ir/meta.js";
3
+ import type { AppMeta, NodeMeta, Output } from "../../ir/meta.js";
4
+ import type { Documentation } from "../../ir/types.js";
5
+ import type { Expr, Sequence } from "../../ir/node.js";
6
+ import { snakeCase } from "../../backend/string-case.js";
7
+ import type {
8
+ Frontend,
9
+ ParseError,
10
+ ParseResult,
11
+ ParseWarning,
12
+ SourceLocation,
13
+ } from "../frontend.js";
14
+
15
+ // Type guards
16
+
17
+ function isObject(x: unknown): x is Record<string, unknown> {
18
+ return typeof x === "object" && x !== null && !Array.isArray(x);
19
+ }
20
+
21
+ function isString(x: unknown): x is string {
22
+ return typeof x === "string";
23
+ }
24
+
25
+ function isArray(x: unknown): x is unknown[] {
26
+ return Array.isArray(x);
27
+ }
28
+
29
+ /** Coerce a possibly-missing list field to an array. */
30
+ function asArray(x: unknown): unknown[] {
31
+ return isArray(x) ? x : [];
32
+ }
33
+
34
+ /** Input file types: each maps to a plain path terminal. */
35
+ const INPUT_TYPES = new Set<string>(["file in", "image in", "directory in", "tracks in"]);
36
+
37
+ /** Output file types: each maps to a `str` (the user-supplied filename) + an `Output`. */
38
+ const OUTPUT_TYPES = new Set<string>(["file out", "image out", "directory out", "tracks out"]);
39
+
40
+ /** A fresh empty root sequence for error returns (IR passes mutate in place,
41
+ * so callers must not share a single instance). */
42
+ function emptyExpr(): Sequence {
43
+ return { kind: "sequence", attrs: { nodes: [] } };
44
+ }
45
+
46
+ /**
47
+ * Parser for MRtrix3 C++ command definitions (`mrtrix.json`), as dumped by the
48
+ * `__print_usage_json__` hook (see niwrap `extraction/mrtrix/`).
49
+ *
50
+ * The format is flat: positional `arguments`, plus `option_groups[].options`
51
+ * where each option is a single-dash switch (`-id`) carrying 0..N positional
52
+ * `arguments`. There are no unions, conditionals, or nested options. We lower
53
+ * it onto the same `Expr` shapes the Boutiques/Workbench parsers emit:
54
+ *
55
+ * <command> <positionals...> [-option ...] ...
56
+ *
57
+ * - argument (input type) -> typed terminal
58
+ * - argument (`* out`) -> str terminal + an Output entry referencing it
59
+ * - option, 0 args -> opt(lit(-switch)) (bool flag)
60
+ * - option, 1 arg -> opt(seq(lit(-switch), value)) (flat optional)
61
+ * - option, >1 arg / multi -> opt|rep(seq(lit(-switch), ...)) (sub-struct)
62
+ *
63
+ * Type mapping mirrors the v1 `mrt2bt.js` converter's `set_type`. The dump does
64
+ * not carry choice values, so a `choice` argument degrades to a plain string
65
+ * (as it did in v1). Per-command quirks v1 hand-coded that the flat dump cannot
66
+ * express (e.g. dwi2fod/mtnormalise paired in/out args) are intentionally NOT
67
+ * special-cased here - they are patched on the niwrap side post-dump, keeping
68
+ * this frontend format-general.
69
+ */
70
+ export class MrtrixParser implements Frontend {
71
+ readonly name = "mrtrix";
72
+ readonly extensions = ["json"];
73
+
74
+ private errors: ParseError[] = [];
75
+ private warnings: ParseWarning[] = [];
76
+ // Root-level sibling names (positionals + options). MRtrix reuses an id
77
+ // across the positional and option namespaces (e.g. amp2response's `directions`
78
+ // is both), which would collide as struct field names once flattened. Unlike
79
+ // Boutiques/argdump/workbench, this format does not guarantee unique ids, so
80
+ // the frontend disambiguates. The flag token keeps the raw `-id`; only the
81
+ // binding name is suffixed.
82
+ private usedNames = new Set<string>();
83
+
84
+ private reset(): void {
85
+ this.errors = [];
86
+ this.warnings = [];
87
+ this.usedNames = new Set<string>();
88
+ }
89
+
90
+ /** Reserve a unique sibling name, suffixing `_2`, `_3`, ... on collision. */
91
+ private uniqueName(base: string): string {
92
+ if (!this.usedNames.has(base)) {
93
+ this.usedNames.add(base);
94
+ return base;
95
+ }
96
+ let n = 2;
97
+ while (this.usedNames.has(`${base}_${n}`)) n++;
98
+ const name = `${base}_${n}`;
99
+ this.usedNames.add(name);
100
+ this.warn(`Duplicate id '${base}'; renamed a sibling binding to '${name}'`);
101
+ return name;
102
+ }
103
+
104
+ private error(message: string, location?: SourceLocation): void {
105
+ this.errors.push({ message, location });
106
+ }
107
+
108
+ private warn(message: string, location?: SourceLocation): void {
109
+ this.warnings.push({ message, location });
110
+ }
111
+
112
+ private parseJSON(source: string): Record<string, unknown> | null {
113
+ let parsed: unknown;
114
+ try {
115
+ parsed = JSON.parse(source);
116
+ } catch (e) {
117
+ this.error(e instanceof SyntaxError ? e.message : "Invalid JSON");
118
+ return null;
119
+ }
120
+ if (!isObject(parsed)) {
121
+ this.error("JSON source is not an object");
122
+ return null;
123
+ }
124
+ return parsed;
125
+ }
126
+
127
+ // -- Metadata --
128
+
129
+ private docFrom(description: unknown): Documentation | undefined {
130
+ return isString(description) && description.length > 0 ? { description } : undefined;
131
+ }
132
+
133
+ private buildAppMeta(cmd: Record<string, unknown>): AppMeta | undefined {
134
+ const name = cmd.name;
135
+ if (!isString(name) || name.length === 0) {
136
+ this.error("MRtrix descriptor missing required 'name' string");
137
+ return undefined;
138
+ }
139
+
140
+ const synopsis = cmd.synopsis;
141
+ const paragraphs = asArray(cmd.description).filter(isString).join("\n\n");
142
+ const author = cmd.author;
143
+ const references = asArray(cmd.references).filter(isString);
144
+ const version = cmd.version;
145
+
146
+ const doc: Documentation = {
147
+ ...(isString(synopsis) && synopsis.length > 0 && { title: synopsis }),
148
+ ...(paragraphs.length > 0 && { description: paragraphs }),
149
+ ...(isString(author) && author.length > 0 && { authors: [author] }),
150
+ ...(references.length > 0 && { literature: references }),
151
+ urls: [`https://mrtrix.readthedocs.io/en/latest/reference/commands/${name}.html`],
152
+ };
153
+
154
+ return {
155
+ id: name,
156
+ ...(isString(version) && version.length > 0 && { version }),
157
+ doc,
158
+ };
159
+ }
160
+
161
+ // -- Terminals --
162
+
163
+ /**
164
+ * Lower one MRtrix argument to its terminal node (carrying name + doc) and,
165
+ * for output types, an accompanying `Output`.
166
+ */
167
+ private buildArgTerminal(
168
+ arg: Record<string, unknown>,
169
+ meta: NodeMeta,
170
+ ): { node: Expr; output?: Output } | null {
171
+ const argType = arg.type;
172
+ if (!isString(argType)) {
173
+ this.error(`MRtrix argument '${String(arg.id)}' missing 'type'`);
174
+ return null;
175
+ }
176
+
177
+ const name = meta.name!;
178
+
179
+ switch (argType) {
180
+ case "integer":
181
+ return { node: int(meta) };
182
+ case "float":
183
+ return { node: float(meta) };
184
+ case "text":
185
+ case "choice": // choice values are not emitted in the dump; treat as string
186
+ case "undefined":
187
+ return { node: str(meta) };
188
+ // MRtrix sequences are always a single comma-separated token (parse_ints /
189
+ // parse_floats split on `,`), so the list is comma-joined, never spaced.
190
+ case "int seq": {
191
+ const node = repJoin(",", int());
192
+ node.meta = meta;
193
+ return { node };
194
+ }
195
+ case "float seq": {
196
+ const node = repJoin(",", float());
197
+ node.meta = meta;
198
+ return { node };
199
+ }
200
+ case "various": {
201
+ // Anything: a bare string or a file. Mirror v1's `VariousString`/
202
+ // `VariousFile` union so a path can be mounted while a plain literal is
203
+ // still accepted. Union arms must be single-field structs (a bare scalar
204
+ // arm has no data key for the backend to read); the `variantTag` keeps
205
+ // the two `@type`s distinct after each single-field struct collapses.
206
+ const stringArm = seq(str({ name: "obj" }));
207
+ stringArm.meta = { name: "VariousString", variantTag: "VariousString" };
208
+ const fileArm = seq(path({ name: "obj" }));
209
+ fileArm.meta = { name: "VariousFile", variantTag: "VariousFile" };
210
+ const node = alt(stringArm, fileArm);
211
+ node.meta = meta;
212
+ return { node };
213
+ }
214
+ default: {
215
+ if (INPUT_TYPES.has(argType)) {
216
+ return { node: path(meta) };
217
+ }
218
+ if (OUTPUT_TYPES.has(argType)) {
219
+ const node = str(meta);
220
+ const output: Output = {
221
+ name,
222
+ ...(meta.doc && { doc: meta.doc }),
223
+ tokens: [{ kind: "ref", target: nodeRef(name) }],
224
+ mediaTypes: [`mrtrix/${argType}`],
225
+ };
226
+ return { node, output };
227
+ }
228
+ this.error(`Unknown MRtrix type '${argType}' for '${name}'`);
229
+ return null;
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * A positional argument: typed terminal, wrapped for cardinality. Output-type
236
+ * positionals push their `Output` onto the root's `outputs` collector.
237
+ */
238
+ private buildPositional(arg: unknown, rootOutputs: Output[]): Expr | null {
239
+ if (!isObject(arg)) {
240
+ this.warn("Skipping non-object argument");
241
+ return null;
242
+ }
243
+ const id = arg.id;
244
+ if (!isString(id)) {
245
+ this.error("MRtrix argument missing 'id'");
246
+ return null;
247
+ }
248
+ const meta: NodeMeta = {
249
+ name: this.uniqueName(snakeCase(id)),
250
+ ...this.docMeta(arg.description),
251
+ };
252
+ const built = this.buildArgTerminal(arg, meta);
253
+ if (!built) return null;
254
+ if (built.output) rootOutputs.push(built.output);
255
+
256
+ return this.applyCardinality(built.node, arg, built.output !== undefined);
257
+ }
258
+
259
+ /** Wrap a terminal for `allow_multiple` (repeat) and `optional` (optional). */
260
+ private applyCardinality(node: Expr, arg: Record<string, unknown>, isOutput: boolean): Expr {
261
+ let result = node;
262
+ if (arg.allow_multiple === true && !isOutput && result.kind !== "repeat") {
263
+ result = rep(result);
264
+ } else if (arg.allow_multiple === true && isOutput) {
265
+ // Styx cannot rewrite a repeated output filename argument; v1 demoted
266
+ // these to a single output. Keep the single str + warn.
267
+ this.warn(`Output argument '${String(arg.id)}' is allow_multiple; treating as single`);
268
+ }
269
+ if (arg.optional === true) {
270
+ result = opt(result);
271
+ }
272
+ return result;
273
+ }
274
+
275
+ private docMeta(description: unknown): { doc?: Documentation } {
276
+ const doc = this.docFrom(description);
277
+ return doc ? { doc } : {};
278
+ }
279
+
280
+ // -- Options --
281
+
282
+ /**
283
+ * An option `-{id}` with 0..N arguments. Returns the node plus any outputs
284
+ * that must live on the ROOT (collapsing single-value options); sub-struct
285
+ * options carry their own outputs on the struct sequence's meta.
286
+ */
287
+ private buildOption(option: unknown): { node: Expr; rootOutputs: Output[] } | null {
288
+ if (!isObject(option)) {
289
+ this.warn("Skipping non-object option");
290
+ return null;
291
+ }
292
+ const id = option.id;
293
+ if (!isString(id)) {
294
+ this.error("MRtrix option missing 'id'");
295
+ return null;
296
+ }
297
+ const flag = `-${id}`;
298
+ const name = this.uniqueName(snakeCase(id));
299
+ const optDoc = this.docFrom(option.description);
300
+ const args = asArray(option.arguments);
301
+
302
+ const required = option.optional === false;
303
+
304
+ // Flag: no arguments. Repeatable -> a count (rep of the bare switch, like a
305
+ // mrcalc stack operator that may be applied many times); otherwise a bool.
306
+ if (args.length === 0) {
307
+ if (option.allow_multiple === true) {
308
+ return { node: rep(lit(flag), { name, ...(optDoc && { doc: optDoc }) }), rootOutputs: [] };
309
+ }
310
+ const meta: NodeMeta = { name, ...(optDoc && { doc: optDoc }), defaultValue: false };
311
+ return { node: opt(lit(flag), meta), rootOutputs: [] };
312
+ }
313
+
314
+ // Single value, single occurrence: flat scalar named by the option (the
315
+ // argument's own id is usually a generic metavar like "number"/"image", so
316
+ // the binding takes the option's id + description, matching v1). Optional
317
+ // unless the option is explicitly required.
318
+ if (args.length === 1 && option.allow_multiple !== true) {
319
+ const arg = args[0];
320
+ if (!isObject(arg)) {
321
+ this.error(`MRtrix option '${id}' has a non-object argument`);
322
+ return null;
323
+ }
324
+ const meta: NodeMeta = { name, ...(optDoc && { doc: optDoc }) };
325
+ const built = this.buildArgTerminal(arg, meta);
326
+ if (!built) return null;
327
+ const valueNode = this.applyCardinality(built.node, arg, built.output !== undefined);
328
+ const flagSeq = seq(lit(flag), valueNode);
329
+ const node = required ? flagSeq : opt(flagSeq);
330
+ return { node, rootOutputs: built.output ? [built.output] : [] };
331
+ }
332
+
333
+ // Sub-struct: multiple arguments and/or a repeatable option.
334
+ const inner: Expr[] = [lit(flag)];
335
+ const structOutputs: Output[] = [];
336
+ for (const rawArg of args) {
337
+ if (!isObject(rawArg)) {
338
+ this.warn(`Skipping non-object argument of option '${id}'`);
339
+ continue;
340
+ }
341
+ const argId = rawArg.id;
342
+ if (!isString(argId)) {
343
+ this.error(`MRtrix option '${id}' has an argument missing 'id'`);
344
+ continue;
345
+ }
346
+ // Fall back to the option's doc when the argument carries none.
347
+ const argDoc = this.docFrom(rawArg.description) ?? optDoc;
348
+ const meta: NodeMeta = { name: snakeCase(argId), ...(argDoc && { doc: argDoc }) };
349
+ const built = this.buildArgTerminal(rawArg, meta);
350
+ if (!built) continue;
351
+ const valueNode = this.applyCardinality(built.node, rawArg, built.output !== undefined);
352
+ inner.push(valueNode);
353
+ if (built.output) structOutputs.push(built.output);
354
+ }
355
+
356
+ const structSeq = seq(...inner);
357
+ structSeq.meta = { name, ...(structOutputs.length > 0 && { outputs: structOutputs }) };
358
+ const wrapperMeta: NodeMeta | undefined = optDoc ? { doc: optDoc } : undefined;
359
+ let node: Expr;
360
+ if (option.allow_multiple === true) {
361
+ node = rep(structSeq, wrapperMeta);
362
+ } else if (required) {
363
+ // Required, non-repeatable: keep the struct bare; doc lives on its meta.
364
+ if (optDoc) structSeq.meta = { ...structSeq.meta, doc: optDoc };
365
+ node = structSeq;
366
+ } else {
367
+ node = opt(structSeq, wrapperMeta);
368
+ }
369
+ return { node, rootOutputs: [] };
370
+ }
371
+
372
+ // -- Public API --
373
+
374
+ parse(source: string, _filename?: string): ParseResult {
375
+ this.reset();
376
+
377
+ const cmd = this.parseJSON(source);
378
+ if (!cmd) {
379
+ return { expr: emptyExpr(), errors: this.errors, warnings: this.warnings };
380
+ }
381
+
382
+ const meta = this.buildAppMeta(cmd);
383
+ if (!meta || !isString(cmd.name)) {
384
+ return { expr: emptyExpr(), errors: this.errors, warnings: this.warnings };
385
+ }
386
+
387
+ const nodes: Expr[] = [lit(cmd.name)];
388
+ const rootOutputs: Output[] = [];
389
+
390
+ // Positionals first (ergonomic signature; MRtrix parses options anywhere).
391
+ for (const arg of asArray(cmd.arguments)) {
392
+ const node = this.buildPositional(arg, rootOutputs);
393
+ if (node) nodes.push(node);
394
+ }
395
+
396
+ // Then options, in declaration order across all groups (incl. __standard_options).
397
+ for (const group of asArray(cmd.option_groups)) {
398
+ if (!isObject(group)) continue;
399
+ for (const option of asArray(group.options)) {
400
+ const built = this.buildOption(option);
401
+ if (!built) continue;
402
+ nodes.push(built.node);
403
+ rootOutputs.push(...built.rootOutputs);
404
+ }
405
+ }
406
+
407
+ const rootSeq = seq(...nodes);
408
+ rootSeq.meta = { name: meta.id, ...(rootOutputs.length > 0 && { outputs: rootOutputs }) };
409
+
410
+ return { meta, expr: rootSeq, errors: this.errors, warnings: this.warnings };
411
+ }
412
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ArgdumpParser } from "./frontend/argdump/index.js";
2
2
  import { BoutiquesParser } from "./frontend/boutiques/index.js";
3
+ import { MrtrixParser } from "./frontend/mrtrix/index.js";
3
4
  import { WorkbenchParser } from "./frontend/workbench/index.js";
4
5
  import { detectFormat } from "./frontend/detect-format.js";
5
6
  import type { FormatName } from "./frontend/detect-format.js";
@@ -36,6 +37,8 @@ export function compile(
36
37
  ? new ArgdumpParser()
37
38
  : format === "workbench"
38
39
  ? new WorkbenchParser()
39
- : new BoutiquesParser();
40
+ : format === "mrtrix"
41
+ ? new MrtrixParser()
42
+ : new BoutiquesParser();
40
43
  return parser.parse(source, options.filename);
41
44
  }