@styx-api/core 0.6.0 → 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,2 @@
1
+ export { ArgtypeBackend } from "./argtype.js";
2
+ export { generateArgtype } from "./emit.js";
@@ -297,7 +297,10 @@ class BoutiquesEmitter {
297
297
  }
298
298
  if (peeled.flag) {
299
299
  input["command-line-flag"] = peeled.flag;
300
- if (peeled.flagSeparator) input["command-line-flag-separator"] = peeled.flagSeparator;
300
+ // Emit the separator whenever the flag is fused with its value, including
301
+ // the empty (direct-concatenation) case; absent means the default space.
302
+ if (peeled.flagSeparator !== undefined)
303
+ input["command-line-flag-separator"] = peeled.flagSeparator;
301
304
  }
302
305
  if (mapped.integer) input.integer = true;
303
306
  if (mapped.minimum !== undefined) input.minimum = mapped.minimum;
@@ -547,7 +550,10 @@ class BoutiquesEmitter {
547
550
  // Flag
548
551
  if (peeled.flag) {
549
552
  input["command-line-flag"] = peeled.flag;
550
- if (peeled.flagSeparator) input["command-line-flag-separator"] = peeled.flagSeparator;
553
+ // Emit the separator whenever the flag is fused with its value, including
554
+ // the empty (direct-concatenation) case; absent means the default space.
555
+ if (peeled.flagSeparator !== undefined)
556
+ input["command-line-flag-separator"] = peeled.flagSeparator;
551
557
  }
552
558
 
553
559
  // Constraints from mapped type
@@ -650,9 +656,17 @@ class BoutiquesEmitter {
650
656
  const nodes = node.attrs.nodes;
651
657
  if (nodes.length === 2 && nodes[0]!.kind === "literal") {
652
658
  const flagLit = nodes[0]!.attrs.str;
653
- const { flag, separator } = this.splitFlagLiteral(flagLit);
654
- result.flag = flag;
655
- if (separator) result.flagSeparator = separator;
659
+ if (node.attrs.join !== undefined) {
660
+ // Joined: flag + value form one argv token, so a trailing `=` (or an
661
+ // empty separator for direct concatenation) is the flag-separator.
662
+ const { flag, separator } = this.splitFlagLiteral(flagLit);
663
+ result.flag = flag;
664
+ result.flagSeparator = separator; // may be "" (direct concatenation)
665
+ } else {
666
+ // Not joined: flag and value are separate argv tokens, so the whole
667
+ // literal is the flag with no separator.
668
+ result.flag = flagLit;
669
+ }
656
670
  this.peelNodeInner(nodes[1]!, type, result);
657
671
  }
658
672
  // Otherwise don't peel further (it's a struct sequence or similar)
@@ -8,6 +8,7 @@ export type {
8
8
  EmitWarning,
9
9
  TypeMap,
10
10
  } from "./backend.js";
11
+ export { ArgtypeBackend, generateArgtype } from "./argtype/index.js";
11
12
  export { BoutiquesBackend, generateBoutiques } from "./boutiques/index.js";
12
13
  export { CodeBuilder } from "./code-builder.js";
13
14
  export type { FieldInfo } from "./collect-field-info.js";
@@ -25,6 +25,14 @@ export function resultToStmt(r: ArgResult): string {
25
25
  }
26
26
 
27
27
  function appendLines(cb: CodeBuilder, code: string): void {
28
+ // Every caller emits `code` as an indented branch body (an `if`/`elif`/`else`
29
+ // or optional block). A union variant or optional can legitimately contribute
30
+ // no command-line arguments (e.g. an "off"/"none" mode), leaving `code` empty -
31
+ // emit `pass` so the block is valid Python instead of an empty, un-indented one.
32
+ if (code.trim() === "") {
33
+ cb.line("pass");
34
+ return;
35
+ }
28
36
  for (const line of code.split("\n")) cb.line(line);
29
37
  }
30
38
 
@@ -0,0 +1,103 @@
1
+ ---
2
+ exe: "bet"
3
+ version: "6.0.4"
4
+ authors:
5
+ - "FMRIB Analysis Group, University of Oxford"
6
+ urls:
7
+ - "https://fsl.fmrib.ox.ac.uk/fsl/fslwiki"
8
+ extensions:
9
+ - outputs
10
+ ---
11
+
12
+ /// Automated brain extraction tool for FSL
13
+ bet: seq(
14
+ /// Input image (e.g. img.nii.gz).
15
+ infile: path,
16
+
17
+ /// Output brain mask prefix (e.g. img_bet).
18
+ maskfile: str = "img_bet",
19
+
20
+ set(
21
+ /// Fractional intensity threshold (0->1). Smaller values give larger
22
+ /// brain outline estimates.
23
+ opt("-f", fractional_intensity: float.min(0).max(1).default(0.5)),
24
+
25
+ /// Vertical gradient in fractional intensity threshold (-1->1).
26
+ /// Positive values give larger brain outline at bottom, smaller at top.
27
+ opt("-g", vg_fractional_intensity: float.min(-1).max(1).default(0)),
28
+
29
+ /// XYZ coordinates (voxels, not mm) of the center of gravity of the
30
+ /// initial mesh surface.
31
+ opt("-c", center_of_gravity: rep(float).count(3)),
32
+
33
+ /// Head radius (mm, not voxels); initial surface sphere is set to half
34
+ /// of this.
35
+ opt("-r", head_radius: float),
36
+
37
+ /// Generate brain surface outline overlaid onto original image.
38
+ overlay: opt("-o").output(overlay_file: `{maskfile}_overlay.nii.gz`),
39
+
40
+ /// Generate binary brain mask.
41
+ binary_mask_flag: opt("-m").output(binary_mask: `{maskfile}_mask.nii.gz`),
42
+
43
+ /// Generate rough skull image (not as clean as betsurf).
44
+ approx_skull: opt("-s").output(
45
+ approx_skull_img: `{maskfile}_skull.nii.gz`,
46
+ skull_mask: `{maskfile}_skull_mask.nii.gz`,
47
+ ),
48
+
49
+ /// Don't generate segmented brain image output.
50
+ no_seg_output: opt("-n"),
51
+
52
+ /// Generate brain surface as mesh in .vtk format.
53
+ vtk_mesh: opt("-e").output(output_vtk_mesh: `{maskfile}_mesh.vtk`),
54
+
55
+ /// Apply thresholding to segmented brain image and mask.
56
+ thresholding: opt("-t"),
57
+
58
+ /// More robust brain center estimation by iterating BET with a
59
+ /// changing center-of-gravity.
60
+ robust_iters: opt("-R"),
61
+
62
+ /// Cleanup residual eye and optic nerve voxels (useful before
63
+ /// SIENA/SIENAX).
64
+ residual_optic_cleanup: opt("-S"),
65
+
66
+ /// Reduce image bias and residual neck voxels (useful before
67
+ /// SIENA/SIENAX).
68
+ reduce_bias: opt("-B"),
69
+
70
+ /// Pad end slices in both directions; useful when only a few slices
71
+ /// are present in the Z direction.
72
+ slice_padding: opt("-Z"),
73
+
74
+ /// Determine brain mask from the first volume of a 4D dataset and
75
+ /// apply it to the whole set. Intended for FMRI; the -f threshold
76
+ /// is reduced to 0.3.
77
+ whole_set_mask: opt("-F"),
78
+
79
+ /// Run bet2 + betsurf so skull and scalp surfaces are also produced
80
+ /// (requires registration to standard space).
81
+ additional_surfaces: opt("-A").output(
82
+ out_inskull_mask: `{maskfile}_inskull_mask.nii.gz`,
83
+ out_inskull_mesh: `{maskfile}_inskull_mesh.nii.gz`,
84
+ out_inskull_off: `{maskfile}_inskull_mesh.off`,
85
+ out_outskin_mask: `{maskfile}_outskin_mask.nii.gz`,
86
+ out_outskin_mesh: `{maskfile}_outskin_mesh.nii.gz`,
87
+ out_outskin_off: `{maskfile}_outskin_mesh.off`,
88
+ out_outskull_mask: `{maskfile}_outskull_mask.nii.gz`,
89
+ out_outskull_mesh: `{maskfile}_outskull_mesh.nii.gz`,
90
+ out_outskull_off: `{maskfile}_outskull_mesh.off`,
91
+ ),
92
+
93
+ /// Same as -A, but also takes a T2 image as input to refine the
94
+ /// skull/scalp surfaces.
95
+ opt("-A2", additional_surfaces_t2: path),
96
+
97
+ /// Switch on diagnostic messages.
98
+ verbose: opt("-v"),
99
+
100
+ /// Don't delete temporary intermediate images.
101
+ debug: opt("-d"),
102
+ ),
103
+ ).output(outfile: `{maskfile}.nii.gz`)
@@ -0,0 +1,101 @@
1
+ /**
2
+ * AST for the argtype sugar DSL. Mirrors the grammar in the argtype spec
3
+ * (combinators, terminals, literals, naming, aliases, method chains) and is the
4
+ * intermediate the parser produces before `lower.ts` translates it to Styx IR.
5
+ *
6
+ * Decorations (`name`, `doc`, `default`, value constraints, `.join`/`.count`,
7
+ * outputs, media types) attach to any node, so they live as optional fields on
8
+ * the single `AstNode` shape rather than as wrapper nodes - lowering folds them
9
+ * onto the corresponding `NodeMeta` / terminal attrs.
10
+ */
11
+
12
+ /** A combinator keyword. `set`/`any` have no direct IR node (see `lower.ts`). */
13
+ export type Combinator = "seq" | "set" | "opt" | "rep" | "alt" | "any";
14
+
15
+ /** A terminal keyword. */
16
+ export type Terminal = "int" | "float" | "str" | "path";
17
+
18
+ export interface AstNode {
19
+ kind: "literal" | "terminal" | "comb" | "ref";
20
+
21
+ /** kind === "literal": the fixed token string. */
22
+ value?: string;
23
+ /** kind === "terminal": which terminal. */
24
+ terminal?: Terminal;
25
+ /** kind === "comb": which combinator + its children. */
26
+ op?: Combinator;
27
+ children?: AstNode[];
28
+ /** kind === "ref": an alias reference (resolved by substitution). */
29
+ refName?: string;
30
+
31
+ // -- Decorations (any node) --
32
+
33
+ /** `label:` sugar / `.name("...")`. */
34
+ name?: string;
35
+ /** Title: a `# ` heading in a `///` block, or `.title("...")`. */
36
+ title?: string;
37
+ /** Description: a `///` block (minus the title), or `.description("...")`. */
38
+ description?: string;
39
+ /** `= value` sugar / `.default(...)` (terminal only). */
40
+ default?: string | number;
41
+ /** `.min(n)` (int/float only). */
42
+ min?: number;
43
+ /** `.max(n)` (int/float only). */
44
+ max?: number;
45
+ /** `.join(sep?)` - present means joined; separator defaults to "". */
46
+ join?: string;
47
+ /** `.count(n)` (= `.countMin(n).countMax(n)`) / `.countMin(n)` / `.countMax(n)`
48
+ * (rep only). */
49
+ countMin?: number;
50
+ countMax?: number;
51
+ /** `.mediaType(mime)` (path only; `mediatypes` extension). */
52
+ mediaTypes?: string[];
53
+ /** `.mutable()` (path only; `paths` extension). */
54
+ mutable?: boolean;
55
+ /** `.resolveParent()` (path only; `paths` extension). */
56
+ resolveParent?: boolean;
57
+ /** `.output(...)` (`outputs` extension). */
58
+ outputs?: AstOutput[];
59
+ }
60
+
61
+ /** One output template declared via `.output(...)`. */
62
+ export interface AstOutput {
63
+ /** `label:` on the template / `.name(...)`. */
64
+ name?: string;
65
+ /** Title of the produced file (from a `# ` heading in the entry's `///`). */
66
+ title?: string;
67
+ /** Description of the produced file (from the entry's `///`). */
68
+ description?: string;
69
+ /** Output-level `.or(fallback)` on the template. */
70
+ fallback?: string;
71
+ tokens: AstOutputToken[];
72
+ }
73
+
74
+ /** A piece of an output path template: literal text or a `{ref}` interpolation. */
75
+ export type AstOutputToken =
76
+ | { kind: "literal"; value: string }
77
+ | {
78
+ kind: "ref";
79
+ /** Target node name; undefined for a self-reference (`{}`). */
80
+ name?: string;
81
+ stripSuffix?: string[];
82
+ stripPrefix?: string[];
83
+ basename?: boolean;
84
+ or?: string;
85
+ };
86
+
87
+ /** A top-level alias definition (`Name = expr`). */
88
+ export interface AstAlias {
89
+ name: string;
90
+ expr: AstNode;
91
+ }
92
+
93
+ /** The result of parsing a full argtype document. */
94
+ export interface AstDocument {
95
+ frontmatter?: Record<string, unknown>;
96
+ aliases: AstAlias[];
97
+ /** The root definition's name (`name: expr`). Undefined for an anonymous root
98
+ * (a bare top-level expression); the tool id then falls back to frontmatter. */
99
+ rootName?: string;
100
+ root: AstNode;
101
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Doc-comment title/description handling.
3
+ *
4
+ * The `# Title` heading convention is *sugar*: it applies only when splitting a
5
+ * `///` block (`splitDocText`). The chaining methods `.title()` / `.description()`
6
+ * set the title / description directly and are not re-parsed for headings.
7
+ */
8
+
9
+ export interface DocParts {
10
+ title?: string;
11
+ description?: string;
12
+ }
13
+
14
+ /**
15
+ * Reflow a `///` description as Markdown-style prose: a single line break is a
16
+ * soft wrap (the lines join with a space) and a blank line starts a new
17
+ * paragraph (kept as `\n\n`). This lets an author wrap a long description across
18
+ * several `///` lines for readability without forcing hard breaks, while real
19
+ * paragraph structure survives. (It does not preserve Markdown lists or indented
20
+ * code blocks - their single line breaks are joined like ordinary prose.)
21
+ */
22
+ function reflowProse(text: string): string {
23
+ return text
24
+ .split(/\n{2,}/) // blank line(s) -> paragraph boundary
25
+ .map((para) =>
26
+ para
27
+ .split("\n")
28
+ .map((line) => line.trim())
29
+ .filter((line) => line.length > 0)
30
+ .join(" "),
31
+ )
32
+ .filter((para) => para.length > 0)
33
+ .join("\n\n");
34
+ }
35
+
36
+ /**
37
+ * Split a `///` doc block into title + description. A leading Markdown H1
38
+ * (`# Title` on the first line) is the title; everything after it is the
39
+ * description. Without a leading `# `, the whole block is the description (no
40
+ * title). The description is reflowed as prose (see `reflowProse`): single line
41
+ * breaks soft-wrap, blank lines separate paragraphs.
42
+ */
43
+ export function splitDocText(raw: string): DocParts {
44
+ const newline = raw.indexOf("\n");
45
+ const firstLine = (newline === -1 ? raw : raw.slice(0, newline)).trim();
46
+ if (firstLine.startsWith("# ")) {
47
+ const title = firstLine.slice(2).trim();
48
+ const description = reflowProse(newline === -1 ? "" : raw.slice(newline + 1));
49
+ return {
50
+ ...(title && { title }),
51
+ ...(description && { description }),
52
+ };
53
+ }
54
+ const description = reflowProse(raw);
55
+ return description ? { description } : {};
56
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Frontmatter handling for argtype documents.
3
+ *
4
+ * A document may begin with a `---` fenced block of YAML-ish metadata (`exe`,
5
+ * `version`, `extensions`, `container`, `authors`, `stdout`, ...). Rather than
6
+ * pull in a full YAML dependency, this parses the small subset the format uses:
7
+ * scalar `key: value`, block sequences (`- item`), and one level of nested
8
+ * mappings. That covers every key the lowering step consumes; anything more
9
+ * exotic is read as a raw string and ignored downstream.
10
+ */
11
+
12
+ export interface SplitResult {
13
+ frontmatter?: Record<string, unknown>;
14
+ /** The document body. Frontmatter lines are blanked (not removed) so token
15
+ * line numbers still line up with the original source. */
16
+ body: string;
17
+ errors: string[];
18
+ }
19
+
20
+ export function splitFrontmatter(source: string): SplitResult {
21
+ // Tolerate a leading BOM / blank lines before the opening fence.
22
+ const lines = source.split("\n");
23
+ let start = 0;
24
+ while (start < lines.length && lines[start]!.trim() === "") start++;
25
+
26
+ if (lines[start]?.trim() !== "---") {
27
+ return { body: source, errors: [] };
28
+ }
29
+
30
+ // Find the closing fence.
31
+ let end = -1;
32
+ for (let i = start + 1; i < lines.length; i++) {
33
+ if (lines[i]!.trim() === "---") {
34
+ end = i;
35
+ break;
36
+ }
37
+ }
38
+ if (end === -1) {
39
+ return { body: source, errors: ["Unterminated frontmatter block (missing closing '---')"] };
40
+ }
41
+
42
+ const fmLines = lines.slice(start + 1, end);
43
+ const { value, errors } = parseYamlish(fmLines);
44
+
45
+ // Blank out the frontmatter region (fences + content) to preserve line numbers.
46
+ const blanked = lines.map((line, i) => (i >= start && i <= end ? "" : line));
47
+ return { frontmatter: value, body: blanked.join("\n"), errors };
48
+ }
49
+
50
+ interface Line {
51
+ indent: number;
52
+ content: string;
53
+ }
54
+
55
+ function tokenizeLines(raw: string[]): Line[] {
56
+ const out: Line[] = [];
57
+ for (const line of raw) {
58
+ const noComment = stripComment(line);
59
+ if (noComment.trim() === "") continue;
60
+ const indent = noComment.length - noComment.trimStart().length;
61
+ out.push({ indent, content: noComment.trim() });
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Strip a trailing `#` comment that is not inside quotes. Following YAML, a
67
+ * `#` only starts a comment at the start of the line or after whitespace, so an
68
+ * unquoted value containing `#` (e.g. a URL fragment `https://x/#frag`) is kept
69
+ * intact. */
70
+ function stripComment(line: string): string {
71
+ let inQuote: string | null = null;
72
+ for (let i = 0; i < line.length; i++) {
73
+ const ch = line[i]!;
74
+ // A backslash escape inside a double-quoted value hides the next char (so
75
+ // `\"` does not close the string).
76
+ if (inQuote === '"' && ch === "\\") {
77
+ i++;
78
+ continue;
79
+ }
80
+ if (inQuote) {
81
+ if (ch === inQuote) inQuote = null;
82
+ } else if (ch === '"' || ch === "'") {
83
+ inQuote = ch;
84
+ } else if (ch === "#" && (i === 0 || line[i - 1] === " " || line[i - 1] === "\t")) {
85
+ return line.slice(0, i);
86
+ }
87
+ }
88
+ return line;
89
+ }
90
+
91
+ function parseYamlish(raw: string[]): { value: Record<string, unknown>; errors: string[] } {
92
+ const lines = tokenizeLines(raw);
93
+ const errors: string[] = [];
94
+ let pos = 0;
95
+
96
+ function parseBlock(minIndent: number): Record<string, unknown> | unknown[] {
97
+ // Sequence block?
98
+ if (
99
+ pos < lines.length &&
100
+ lines[pos]!.indent >= minIndent &&
101
+ lines[pos]!.content.startsWith("- ")
102
+ ) {
103
+ const seq: unknown[] = [];
104
+ const indent = lines[pos]!.indent;
105
+ while (
106
+ pos < lines.length &&
107
+ lines[pos]!.indent === indent &&
108
+ lines[pos]!.content.startsWith("- ")
109
+ ) {
110
+ seq.push(parseScalar(lines[pos]!.content.slice(2).trim()));
111
+ pos++;
112
+ }
113
+ return seq;
114
+ }
115
+
116
+ // Mapping block.
117
+ const map: Record<string, unknown> = {};
118
+ if (pos >= lines.length) return map;
119
+ const indent = lines[pos]!.indent;
120
+ while (
121
+ pos < lines.length &&
122
+ lines[pos]!.indent === indent &&
123
+ !lines[pos]!.content.startsWith("- ")
124
+ ) {
125
+ const { content } = lines[pos]!;
126
+ const colon = findColon(content);
127
+ if (colon === -1) {
128
+ errors.push(`Malformed frontmatter line: '${content}'`);
129
+ pos++;
130
+ continue;
131
+ }
132
+ const key = content.slice(0, colon).trim();
133
+ const valueText = content.slice(colon + 1).trim();
134
+ pos++;
135
+ if (valueText !== "") {
136
+ map[key] = parseScalar(valueText);
137
+ } else if (pos < lines.length && lines[pos]!.indent > indent) {
138
+ map[key] = parseBlock(lines[pos]!.indent);
139
+ } else {
140
+ map[key] = null;
141
+ }
142
+ }
143
+ return map;
144
+ }
145
+
146
+ const value = parseBlock(0);
147
+ return { value: isRecord(value) ? value : { _root: value }, errors };
148
+ }
149
+
150
+ function findColon(s: string): number {
151
+ let inQuote: string | null = null;
152
+ for (let i = 0; i < s.length; i++) {
153
+ const ch = s[i]!;
154
+ if (inQuote === '"' && ch === "\\") {
155
+ i++;
156
+ continue;
157
+ }
158
+ if (inQuote) {
159
+ if (ch === inQuote) inQuote = null;
160
+ } else if (ch === '"' || ch === "'") {
161
+ inQuote = ch;
162
+ } else if (ch === ":") {
163
+ return i;
164
+ }
165
+ }
166
+ return -1;
167
+ }
168
+
169
+ /** Reverse the backslash escapes an emitter applies to a quoted scalar
170
+ * (`\n`, `\t`, `\r`, `\"`, `\\`), so values with newlines/quotes round-trip. */
171
+ function unescapeScalar(s: string): string {
172
+ return s.replace(/\\(.)/g, (_, c) =>
173
+ c === "n" ? "\n" : c === "t" ? "\t" : c === "r" ? "\r" : c,
174
+ );
175
+ }
176
+
177
+ function parseScalar(text: string): unknown {
178
+ if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) {
179
+ return unescapeScalar(text.slice(1, -1));
180
+ }
181
+ if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) {
182
+ return text.slice(1, -1);
183
+ }
184
+ if (text === "true") return true;
185
+ if (text === "false") return false;
186
+ if (text === "null" || text === "~") return null;
187
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
188
+ return text;
189
+ }
190
+
191
+ function isRecord(x: unknown): x is Record<string, unknown> {
192
+ return typeof x === "object" && x !== null && !Array.isArray(x);
193
+ }
@@ -0,0 +1 @@
1
+ export { ArgtypeParser } from "./parser-frontend.js";