@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.
Files changed (52) hide show
  1. package/dist/index.cjs +2559 -553
  2. package/dist/index.d.cts +41 -37
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +41 -37
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2558 -549
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +2 -1
  9. package/src/backend/argtype/__fixtures__/microsyntax.argtype +13 -0
  10. package/src/backend/argtype/__fixtures__/subcommands.argtype +25 -0
  11. package/src/backend/argtype/argtype.ts +45 -0
  12. package/src/backend/argtype/emit.ts +676 -0
  13. package/src/backend/argtype/index.ts +2 -0
  14. package/src/backend/boutiques/boutiques.ts +55 -47
  15. package/src/backend/field-defaults.ts +40 -0
  16. package/src/backend/find-struct-node.ts +7 -0
  17. package/src/backend/index.ts +2 -9
  18. package/src/backend/nipype/emit.ts +11 -3
  19. package/src/backend/python/arg-builder.ts +2 -27
  20. package/src/backend/python/emit.ts +18 -6
  21. package/src/backend/python/outputs-emit.ts +27 -42
  22. package/src/backend/python/python.ts +46 -22
  23. package/src/backend/python/validate-emit.ts +4 -1
  24. package/src/backend/resolve-output-tokens.ts +0 -76
  25. package/src/backend/typescript/arg-builder.ts +2 -24
  26. package/src/backend/typescript/emit.ts +7 -4
  27. package/src/backend/typescript/outputs-emit.ts +15 -39
  28. package/src/backend/typescript/typemap.ts +9 -1
  29. package/src/backend/typescript/typescript.ts +41 -18
  30. package/src/backend/typescript/validate-emit.ts +4 -1
  31. package/src/backend/union-variants.ts +41 -1
  32. package/src/frontend/argdump/parser.ts +20 -19
  33. package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
  34. package/src/frontend/argtype/__fixtures__/bet.argtype +101 -0
  35. package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
  36. package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
  37. package/src/frontend/argtype/ast.ts +114 -0
  38. package/src/frontend/argtype/doc.ts +56 -0
  39. package/src/frontend/argtype/frontmatter.ts +237 -0
  40. package/src/frontend/argtype/index.ts +1 -0
  41. package/src/frontend/argtype/lexer.ts +264 -0
  42. package/src/frontend/argtype/lower.ts +601 -0
  43. package/src/frontend/argtype/parser-frontend.ts +51 -0
  44. package/src/frontend/argtype/parser.ts +533 -0
  45. package/src/frontend/argtype/template.ts +147 -0
  46. package/src/frontend/boutiques/destruct-template.ts +24 -17
  47. package/src/frontend/boutiques/parser.ts +27 -8
  48. package/src/frontend/detect-format.ts +28 -3
  49. package/src/index.ts +23 -9
  50. package/src/ir/passes/canonicalize.ts +18 -5
  51. package/src/ir/passes/simplify.ts +17 -2
  52. package/src/solver/solver.ts +2 -2
@@ -0,0 +1,101 @@
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
+ ---
9
+
10
+ /// Automated brain extraction tool for FSL
11
+ bet: seq(
12
+ /// Input image (e.g. img.nii.gz).
13
+ infile: path,
14
+
15
+ /// Output brain mask prefix (e.g. img_bet).
16
+ maskfile: str = "img_bet",
17
+
18
+ set(
19
+ /// Fractional intensity threshold (0->1). Smaller values give larger
20
+ /// brain outline estimates.
21
+ opt("-f", fractional_intensity: float.min(0).max(1).default(0.5)),
22
+
23
+ /// Vertical gradient in fractional intensity threshold (-1->1).
24
+ /// Positive values give larger brain outline at bottom, smaller at top.
25
+ opt("-g", vg_fractional_intensity: float.min(-1).max(1).default(0)),
26
+
27
+ /// XYZ coordinates (voxels, not mm) of the center of gravity of the
28
+ /// initial mesh surface.
29
+ opt("-c", center_of_gravity: rep(float).count(3)),
30
+
31
+ /// Head radius (mm, not voxels); initial surface sphere is set to half
32
+ /// of this.
33
+ opt("-r", head_radius: float),
34
+
35
+ /// Generate brain surface outline overlaid onto original image.
36
+ overlay: opt("-o").output(overlay_file: `{maskfile}_overlay.nii.gz`),
37
+
38
+ /// Generate binary brain mask.
39
+ binary_mask_flag: opt("-m").output(binary_mask: `{maskfile}_mask.nii.gz`),
40
+
41
+ /// Generate rough skull image (not as clean as betsurf).
42
+ approx_skull: opt("-s").output(
43
+ approx_skull_img: `{maskfile}_skull.nii.gz`,
44
+ skull_mask: `{maskfile}_skull_mask.nii.gz`,
45
+ ),
46
+
47
+ /// Don't generate segmented brain image output.
48
+ no_seg_output: opt("-n"),
49
+
50
+ /// Generate brain surface as mesh in .vtk format.
51
+ vtk_mesh: opt("-e").output(output_vtk_mesh: `{maskfile}_mesh.vtk`),
52
+
53
+ /// Apply thresholding to segmented brain image and mask.
54
+ thresholding: opt("-t"),
55
+
56
+ /// More robust brain center estimation by iterating BET with a
57
+ /// changing center-of-gravity.
58
+ robust_iters: opt("-R"),
59
+
60
+ /// Cleanup residual eye and optic nerve voxels (useful before
61
+ /// SIENA/SIENAX).
62
+ residual_optic_cleanup: opt("-S"),
63
+
64
+ /// Reduce image bias and residual neck voxels (useful before
65
+ /// SIENA/SIENAX).
66
+ reduce_bias: opt("-B"),
67
+
68
+ /// Pad end slices in both directions; useful when only a few slices
69
+ /// are present in the Z direction.
70
+ slice_padding: opt("-Z"),
71
+
72
+ /// Determine brain mask from the first volume of a 4D dataset and
73
+ /// apply it to the whole set. Intended for FMRI; the -f threshold
74
+ /// is reduced to 0.3.
75
+ whole_set_mask: opt("-F"),
76
+
77
+ /// Run bet2 + betsurf so skull and scalp surfaces are also produced
78
+ /// (requires registration to standard space).
79
+ additional_surfaces: opt("-A").output(
80
+ out_inskull_mask: `{maskfile}_inskull_mask.nii.gz`,
81
+ out_inskull_mesh: `{maskfile}_inskull_mesh.nii.gz`,
82
+ out_inskull_off: `{maskfile}_inskull_mesh.off`,
83
+ out_outskin_mask: `{maskfile}_outskin_mask.nii.gz`,
84
+ out_outskin_mesh: `{maskfile}_outskin_mesh.nii.gz`,
85
+ out_outskin_off: `{maskfile}_outskin_mesh.off`,
86
+ out_outskull_mask: `{maskfile}_outskull_mask.nii.gz`,
87
+ out_outskull_mesh: `{maskfile}_outskull_mesh.nii.gz`,
88
+ out_outskull_off: `{maskfile}_outskull_mesh.off`,
89
+ ),
90
+
91
+ /// Same as -A, but also takes a T2 image as input to refine the
92
+ /// skull/scalp surfaces.
93
+ opt("-A2", additional_surfaces_t2: path),
94
+
95
+ /// Switch on diagnostic messages.
96
+ verbose: opt("-v"),
97
+
98
+ /// Don't delete temporary intermediate images.
99
+ debug: opt("-d"),
100
+ ),
101
+ ).output(outfile: `{maskfile}.nii.gz`)
@@ -0,0 +1,50 @@
1
+ ---
2
+ exe: "flirt"
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/FLIRT"
8
+ ---
9
+
10
+ /// FMRIB's Linear Image Registration Tool (affine intra-modal registration).
11
+ flirt: seq(
12
+ /// Input volume to register.
13
+ opt("-in", infile: path),
14
+
15
+ /// Reference volume that defines the target space.
16
+ opt("-ref", reference: path),
17
+
18
+ set(
19
+ /// Registered output volume.
20
+ opt("-out", outbase: str).output(outvol: `{outbase}.nii.gz`),
21
+
22
+ /// Text file the estimated affine transform is written to.
23
+ opt("-omat", outmatrix: str),
24
+
25
+ /// Number of histogram bins used for the cost function.
26
+ opt("-bins", bins: int.min(1) = 256),
27
+
28
+ /// Degrees of freedom for the transform (6 rigid, 12 full affine).
29
+ opt("-dof", dof: int.min(3).max(12) = 12),
30
+
31
+ /// Cost function to optimize.
32
+ opt("-cost", cost: alt(
33
+ "mutualinfo",
34
+ "corratio",
35
+ "normcorr",
36
+ "normmi",
37
+ "leastsq",
38
+ ) = "corratio"),
39
+
40
+ /// Final interpolation applied when writing `-out`. The union carries a
41
+ /// default variant (`trilinear`), which the frontend keeps as the node
42
+ /// default.
43
+ opt("-interp", interp: alt(
44
+ "trilinear",
45
+ "nearestneighbour",
46
+ "sinc",
47
+ "spline",
48
+ ) = "trilinear"),
49
+ ),
50
+ )
@@ -0,0 +1,39 @@
1
+ ---
2
+ exe: "wb_command"
3
+ version: "1.5.0"
4
+ authors:
5
+ - "Washington University School of Medicine"
6
+ urls:
7
+ - "https://www.humanconnectome.org/software/connectome-workbench"
8
+ ---
9
+
10
+ /// Reduce a volume file along the map axis to a single output map.
11
+ ///
12
+ /// The executable is `wb_command` and the subcommand is `-volume-reduce`, so
13
+ /// the tool id (`volume_reduce`) deliberately differs from the exe.
14
+ volume_reduce: seq(
15
+ "-volume-reduce",
16
+
17
+ /// The input volume.
18
+ volume_in: path,
19
+
20
+ /// The reduction operation applied across the maps.
21
+ operation: alt(
22
+ "MAX",
23
+ "MIN",
24
+ "MEAN",
25
+ "SUM",
26
+ "STDEV",
27
+ "VARIANCE",
28
+ "TSNR",
29
+ ),
30
+
31
+ /// The output volume.
32
+ volume_out: str,
33
+
34
+ /// Exclude outliers by standard deviation before reducing.
35
+ opt("-exclude-outliers", sigma_below: float.min(0), sigma_above: float.min(0)),
36
+ ).output(
37
+ /// The reduced output volume.
38
+ reduced: `{volume_out}`,
39
+ )
@@ -0,0 +1,114 @@
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
+ /** 1-based source position of a construct, for diagnostics. Points at the node's
19
+ * primary token (a terminal/combinator keyword, a literal token, or the opening
20
+ * paren of a group), which is where a misplaced modifier or bad decoration is
21
+ * most usefully reported. */
22
+ export interface SourceSpan {
23
+ line: number;
24
+ column: number;
25
+ }
26
+
27
+ export interface AstNode {
28
+ kind: "literal" | "terminal" | "comb" | "ref";
29
+
30
+ /** Source position of the node's primary token, if known. */
31
+ span?: SourceSpan;
32
+
33
+ /** kind === "literal": the fixed token string. */
34
+ value?: string;
35
+ /** kind === "terminal": which terminal. */
36
+ terminal?: Terminal;
37
+ /** kind === "comb": which combinator + its children. */
38
+ op?: Combinator;
39
+ children?: AstNode[];
40
+ /** kind === "ref": an alias reference (resolved by substitution). */
41
+ refName?: string;
42
+
43
+ // -- Decorations (any node) --
44
+
45
+ /** `label:` sugar / `.name("...")`. */
46
+ name?: string;
47
+ /** Title: a `# ` heading in a `///` block, or `.title("...")`. */
48
+ title?: string;
49
+ /** Description: a `///` block (minus the title), or `.description("...")`. */
50
+ description?: string;
51
+ /** `= value` sugar / `.default(...)`. Meaningful on a terminal and on
52
+ * `opt`/`alt`/`rep`; dropped with a warning on a `seq`/`set` struct. */
53
+ default?: string | number;
54
+ /** `.min(n)` (int/float only). */
55
+ min?: number;
56
+ /** `.max(n)` (int/float only). */
57
+ max?: number;
58
+ /** `.join(sep?)` - present means joined; separator defaults to "". */
59
+ join?: string;
60
+ /** `.count(n)` (= `.countMin(n).countMax(n)`) / `.countMin(n)` / `.countMax(n)`
61
+ * (rep only). */
62
+ countMin?: number;
63
+ countMax?: number;
64
+ /** `.mediaType(mime)` (path only; `mediatypes` extension). */
65
+ mediaTypes?: string[];
66
+ /** `.mutable()` (path only; `paths` extension). */
67
+ mutable?: boolean;
68
+ /** `.resolveParent()` (path only; `paths` extension). */
69
+ resolveParent?: boolean;
70
+ /** `.output(...)` (`outputs` extension). */
71
+ outputs?: AstOutput[];
72
+ }
73
+
74
+ /** One output template declared via `.output(...)`. */
75
+ export interface AstOutput {
76
+ /** `label:` on the template / `.name(...)`. */
77
+ name?: string;
78
+ /** Title of the produced file (from a `# ` heading in the entry's `///`). */
79
+ title?: string;
80
+ /** Description of the produced file (from the entry's `///`). */
81
+ description?: string;
82
+ /** Output-level `.or(fallback)` on the template. */
83
+ fallback?: string;
84
+ tokens: AstOutputToken[];
85
+ }
86
+
87
+ /** A piece of an output path template: literal text or a `{ref}` interpolation. */
88
+ export type AstOutputToken =
89
+ | { kind: "literal"; value: string }
90
+ | {
91
+ kind: "ref";
92
+ /** Target node name; undefined for a self-reference (`{}`). */
93
+ name?: string;
94
+ stripSuffix?: string[];
95
+ stripPrefix?: string[];
96
+ basename?: boolean;
97
+ or?: string;
98
+ };
99
+
100
+ /** A top-level alias definition (`Name = expr`). */
101
+ export interface AstAlias {
102
+ name: string;
103
+ expr: AstNode;
104
+ }
105
+
106
+ /** The result of parsing a full argtype document. */
107
+ export interface AstDocument {
108
+ frontmatter?: Record<string, unknown>;
109
+ aliases: AstAlias[];
110
+ /** The root definition's name (`name: expr`). Undefined for an anonymous root
111
+ * (a bare top-level expression); the tool id then falls back to frontmatter. */
112
+ rootName?: string;
113
+ root: AstNode;
114
+ }
@@ -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,237 @@
1
+ /**
2
+ * Frontmatter handling for argtype documents.
3
+ *
4
+ * A document may begin with a `---` fenced block of YAML-ish metadata (`exe`,
5
+ * `version`, `container`, `authors`, `stdout`, ...). Rather than pull in a full
6
+ * YAML dependency, this parses the small subset the format uses: scalar
7
+ * `key: value`, block sequences (`- item`), inline flow sequences (`[a, b]`),
8
+ * and one level of nested mappings. That covers every key the lowering step
9
+ * consumes; anything more 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
+ /** Split a flow-sequence body (`a, "b, c", [d, e]`) on top-level commas, keeping
178
+ * commas inside quotes or a nested `[...]` intact. Empty segments (from a leading,
179
+ * trailing, or doubled comma, or an empty `[]` body) are dropped, matching YAML;
180
+ * a quoted empty string keeps its quotes and so survives. */
181
+ function splitFlow(body: string): string[] {
182
+ const parts: string[] = [];
183
+ let depth = 0;
184
+ let inQuote: string | null = null;
185
+ let cur = "";
186
+ for (let i = 0; i < body.length; i++) {
187
+ const ch = body[i]!;
188
+ if (inQuote === '"' && ch === "\\") {
189
+ cur += ch + (body[i + 1] ?? "");
190
+ i++;
191
+ continue;
192
+ }
193
+ if (inQuote) {
194
+ if (ch === inQuote) inQuote = null;
195
+ cur += ch;
196
+ } else if (ch === '"' || ch === "'") {
197
+ inQuote = ch;
198
+ cur += ch;
199
+ } else if (ch === "[") {
200
+ depth++;
201
+ cur += ch;
202
+ } else if (ch === "]") {
203
+ depth--;
204
+ cur += ch;
205
+ } else if (ch === "," && depth === 0) {
206
+ parts.push(cur);
207
+ cur = "";
208
+ } else {
209
+ cur += ch;
210
+ }
211
+ }
212
+ parts.push(cur);
213
+ return parts.map((p) => p.trim()).filter((p) => p !== "");
214
+ }
215
+
216
+ function parseScalar(text: string): unknown {
217
+ // Inline flow sequence: `[a, b, c]` (valid YAML the block-list parser would
218
+ // otherwise read as a bare string). `[]` is the empty list.
219
+ if (text.startsWith("[") && text.endsWith("]") && text.length >= 2) {
220
+ return splitFlow(text.slice(1, -1)).map(parseScalar);
221
+ }
222
+ if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) {
223
+ return unescapeScalar(text.slice(1, -1));
224
+ }
225
+ if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) {
226
+ return text.slice(1, -1);
227
+ }
228
+ if (text === "true") return true;
229
+ if (text === "false") return false;
230
+ if (text === "null" || text === "~") return null;
231
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
232
+ return text;
233
+ }
234
+
235
+ function isRecord(x: unknown): x is Record<string, unknown> {
236
+ return typeof x === "object" && x !== null && !Array.isArray(x);
237
+ }
@@ -0,0 +1 @@
1
+ export { ArgtypeParser } from "./parser-frontend.js";