@optique/valibot 1.0.0-dev.921 → 1.0.1

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/README.md CHANGED
@@ -45,7 +45,9 @@ import * as v from "valibot";
45
45
 
46
46
  const cli = run(
47
47
  object({
48
- email: option("--email", valibot(v.pipe(v.string(), v.email()))),
48
+ email: option("--email",
49
+ valibot(v.pipe(v.string(), v.email()), { placeholder: "" }),
50
+ ),
49
51
  }),
50
52
  );
51
53
 
@@ -73,7 +75,9 @@ import { option } from "@optique/core/primitives";
73
75
  import { valibot } from "@optique/valibot";
74
76
  import * as v from "valibot";
75
77
 
76
- const email = option("--email", valibot(v.pipe(v.string(), v.email())));
78
+ const email = option("--email",
79
+ valibot(v.pipe(v.string(), v.email()), { placeholder: "" }),
80
+ );
77
81
  ~~~~
78
82
 
79
83
  ### URL validation
@@ -83,7 +87,9 @@ import { option } from "@optique/core/primitives";
83
87
  import { valibot } from "@optique/valibot";
84
88
  import * as v from "valibot";
85
89
 
86
- const url = option("--url", valibot(v.pipe(v.string(), v.url())));
90
+ const url = option("--url",
91
+ valibot(v.pipe(v.string(), v.url()), { placeholder: "" }),
92
+ );
87
93
  ~~~~
88
94
 
89
95
  ### Port numbers with range validation
@@ -105,7 +111,7 @@ const port = option("-p", "--port",
105
111
  v.integer(),
106
112
  v.minValue(1024),
107
113
  v.maxValue(65535)
108
- ))
114
+ ), { placeholder: 0 }),
109
115
  );
110
116
  ~~~~
111
117
 
@@ -117,7 +123,8 @@ import { valibot } from "@optique/valibot";
117
123
  import * as v from "valibot";
118
124
 
119
125
  const logLevel = option("--log-level",
120
- valibot(v.picklist(["debug", "info", "warn", "error"]))
126
+ valibot(v.picklist(["debug", "info", "warn", "error"]),
127
+ { placeholder: "debug" }),
121
128
  );
122
129
  ~~~~
123
130
 
@@ -129,7 +136,8 @@ import { valibot } from "@optique/valibot";
129
136
  import * as v from "valibot";
130
137
 
131
138
  const startDate = argument(
132
- valibot(v.pipe(v.string(), v.transform((s: string) => new Date(s))))
139
+ valibot(v.pipe(v.string(), v.transform((s: string) => new Date(s))),
140
+ { placeholder: new Date(0) }),
133
141
  );
134
142
  ~~~~
135
143
 
@@ -146,6 +154,7 @@ import { message } from "@optique/core/message";
146
154
  import * as v from "valibot";
147
155
 
148
156
  const email = option("--email", valibot(v.pipe(v.string(), v.email()), {
157
+ placeholder: "",
149
158
  metavar: "EMAIL",
150
159
  errors: {
151
160
  valibotError: (issues, input) =>
@@ -169,7 +178,9 @@ import { valibot } from "@optique/valibot";
169
178
  import * as v from "valibot";
170
179
 
171
180
  // ✅ Correct
172
- const port = option("-p", valibot(v.pipe(v.string(), v.transform(Number))));
181
+ const port = option("-p",
182
+ valibot(v.pipe(v.string(), v.transform(Number)), { placeholder: 0 }),
183
+ );
173
184
 
174
185
  // ❌ Won't work (CLI arguments are always strings)
175
186
  // const port = option("-p", valibot(v.number()));
@@ -187,7 +198,9 @@ import * as v from "valibot";
187
198
 
188
199
  // ❌ Not supported
189
200
  const email = option("--email",
190
- valibot(v.pipeAsync(v.string(), v.checkAsync(async (val) => await checkDB(val))))
201
+ valibot(v.pipeAsync(v.string(),
202
+ v.checkAsync(async (val) => await checkDB(val))),
203
+ { placeholder: "" }),
191
204
  );
192
205
  ~~~~
193
206
 
package/dist/index.cjs CHANGED
@@ -22,10 +22,153 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
 
23
23
  //#endregion
24
24
  const __optique_core_message = __toESM(require("@optique/core/message"));
25
+ const __optique_core_nonempty = __toESM(require("@optique/core/nonempty"));
25
26
  const valibot = __toESM(require("valibot"));
26
27
 
27
28
  //#region src/index.ts
28
29
  /**
30
+ * Valibot transformation action types that are known to be non-rejecting and
31
+ * type-preserving (they transform a string into a string without ever adding
32
+ * issues). All other transformation types (transform, raw_transform,
33
+ * parse_json, to_number, to_boolean, etc.) may reject input or change the
34
+ * value type.
35
+ */
36
+ const SAFE_TRANSFORMATION_TYPES = new Set([
37
+ "trim",
38
+ "to_lower_case",
39
+ "to_upper_case",
40
+ "normalize",
41
+ "to_min_value",
42
+ "to_max_value",
43
+ "trim_start",
44
+ "trim_end",
45
+ "readonly",
46
+ "brand",
47
+ "flavor"
48
+ ]);
49
+ /**
50
+ * Checks whether a schema synchronously accepts every possible input value.
51
+ * This includes:
52
+ * - `v.unknown()`, `v.any()` (accept everything regardless of input type)
53
+ * - Bare `v.string()` without a pipe (accepts every string)
54
+ * - Any wrapper with a `wrapped` field pointing to a catch-all schema
55
+ * (e.g., `v.optional()`, `v.nullable()`, `v.nonOptional()`, etc.)
56
+ *
57
+ * Piped schemas are considered catch-all only when the base type is
58
+ * `string`/`unknown`/`any` and every pipe action is a non-rejecting
59
+ * transformation (not a validation or nested schema).
60
+ *
61
+ * @param afterTransform When true, only type-agnostic catch-alls
62
+ * (`v.unknown()`, `v.any()`) are recognized. String-based catch-alls
63
+ * are not trusted since the input type may no longer be a string.
64
+ */
65
+ function isCatchAllSchema(schema, afterTransform = false) {
66
+ const s = schema;
67
+ if (s.async) return false;
68
+ if ("fallback" in s) return true;
69
+ if (s.type === "unknown" || s.type === "any") {
70
+ if (!s.pipe) return true;
71
+ return s.pipe.slice(1).every((action) => {
72
+ const a = action;
73
+ if (a.kind === "validation") return false;
74
+ if (a.kind === "schema") return isCatchAllSchema(action, afterTransform);
75
+ return SAFE_TRANSFORMATION_TYPES.has(a.type ?? "");
76
+ });
77
+ }
78
+ if (!afterTransform && s.type === "string") {
79
+ if (!s.pipe) return true;
80
+ return s.pipe.slice(1).every((action) => {
81
+ const a = action;
82
+ if (a.kind === "validation") return false;
83
+ if (a.kind === "schema") return isCatchAllSchema(action, afterTransform);
84
+ return SAFE_TRANSFORMATION_TYPES.has(a.type ?? "");
85
+ });
86
+ }
87
+ if (s.wrapped && !s.pipe) return isCatchAllSchema(s.wrapped, afterTransform);
88
+ return false;
89
+ }
90
+ /**
91
+ * Recursively checks whether a Valibot schema contains any async parts
92
+ * (e.g., `pipeAsync`, `checkAsync`). Wrapper schemas such as `optional()`,
93
+ * `nullable()`, `nullish()`, and `union()` keep `async === false` on the
94
+ * outer layer even when they wrap async inner schemas, so a shallow check
95
+ * on the top-level `async` property is not sufficient.
96
+ *
97
+ * Known limitations:
98
+ * - `v.variant()` arms are treated like union arms, but the catch-all
99
+ * detection does not recognize object-shaped variant arms. Variant
100
+ * schemas with async arms after a broad discriminator will be
101
+ * conservatively rejected.
102
+ * - `v.lazy()` schemas are not inspected because the getter depends on
103
+ * actual parse input, making static analysis unreliable.
104
+ *
105
+ * @param afterTransform When true, a preceding `v.transform()` may have
106
+ * changed the value type. Container members become reachable and
107
+ * string-based union catch-all arms are no longer trusted.
108
+ */
109
+ function containsAsyncSchema(schema, visited = /* @__PURE__ */ new WeakMap(), afterTransform = false) {
110
+ const prev = visited.get(schema);
111
+ if (prev !== void 0 && (prev || !afterTransform)) return false;
112
+ visited.set(schema, (prev ?? false) || afterTransform);
113
+ const s = schema;
114
+ if (s.async) return true;
115
+ if (s.wrapped && !s.pipe) return containsAsyncSchema(s.wrapped, visited, afterTransform);
116
+ if (s.options && Array.isArray(s.options)) {
117
+ if (s.type === "union") for (const option of s.options) {
118
+ if (typeof option !== "object" || option == null) continue;
119
+ if (isCatchAllSchema(option, afterTransform)) break;
120
+ if (containsAsyncSchema(option, visited, afterTransform)) return true;
121
+ }
122
+ else if (s.type === "variant") {
123
+ if (afterTransform) {
124
+ for (const option of s.options) if (typeof option === "object" && option != null) {
125
+ if (containsAsyncSchema(option, visited, true)) return true;
126
+ }
127
+ }
128
+ } else for (const option of s.options) if (typeof option === "object" && option != null) {
129
+ if (containsAsyncSchema(option, visited, afterTransform)) return true;
130
+ }
131
+ }
132
+ if (s.pipe && Array.isArray(s.pipe)) {
133
+ let seenTransform = afterTransform;
134
+ for (const action of s.pipe) {
135
+ if (action.async) return true;
136
+ const a = action;
137
+ if (a.kind === "transformation" && !SAFE_TRANSFORMATION_TYPES.has(a.type ?? "")) seenTransform = true;
138
+ if (a.kind === "schema") {
139
+ if (containsAsyncSchema(action, visited, seenTransform)) return true;
140
+ if (!seenTransform) {
141
+ const innerPipe = action.pipe;
142
+ if (innerPipe && Array.isArray(innerPipe)) for (const innerAction of innerPipe) {
143
+ const ia = innerAction;
144
+ if (ia.kind === "transformation" && !SAFE_TRANSFORMATION_TYPES.has(ia.type ?? "")) {
145
+ seenTransform = true;
146
+ break;
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ if (afterTransform) {
154
+ if (s.entries) {
155
+ for (const entry of Object.values(s.entries)) if (containsAsyncSchema(entry, visited, true)) return true;
156
+ }
157
+ if (s.item && containsAsyncSchema(s.item, visited, true)) return true;
158
+ if (s.items && Array.isArray(s.items)) {
159
+ for (const item of s.items) if (containsAsyncSchema(item, visited, true)) return true;
160
+ }
161
+ if (s.key && typeof s.key === "object" && containsAsyncSchema(s.key, visited, true)) return true;
162
+ if (s.value && containsAsyncSchema(s.value, visited, true)) return true;
163
+ if (s.rest && containsAsyncSchema(s.rest, visited, true)) return true;
164
+ if (s.type === "promise") {
165
+ const promiseInner = schema.message;
166
+ if (typeof promiseInner === "object" && promiseInner != null && "kind" in promiseInner && containsAsyncSchema(promiseInner, visited, true)) return true;
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+ /**
29
172
  * Infers an appropriate metavar string from a Valibot schema.
30
173
  *
31
174
  * This function analyzes the Valibot schema's internal structure to determine
@@ -83,8 +226,15 @@ function inferMetavar(schema) {
83
226
  if (schemaType === "boolean") return "BOOLEAN";
84
227
  if (schemaType === "date") return "DATE";
85
228
  if (schemaType === "picklist") return "CHOICE";
86
- if (schemaType === "literal") return "VALUE";
87
- if (schemaType === "union" || schemaType === "variant") return "VALUE";
229
+ if (schemaType === "literal") {
230
+ if (inferChoices(schema) != null) return "CHOICE";
231
+ return "VALUE";
232
+ }
233
+ if (schemaType === "union") {
234
+ if (inferChoices(schema) != null) return "CHOICE";
235
+ return "VALUE";
236
+ }
237
+ if (schemaType === "variant") return "VALUE";
88
238
  if (schemaType === "optional" || schemaType === "nullable" || schemaType === "nullish") {
89
239
  const wrapped = internalSchema.wrapped;
90
240
  if (wrapped) return inferMetavar(wrapped);
@@ -92,6 +242,50 @@ function inferMetavar(schema) {
92
242
  return "VALUE";
93
243
  }
94
244
  /**
245
+ * Extracts valid choices from a Valibot schema that represents a fixed set of
246
+ * values (picklist, literal, or union of literals).
247
+ *
248
+ * @param schema A Valibot schema to analyze.
249
+ * @returns An array of string representations of valid choices, or `undefined`
250
+ * if the schema does not represent a fixed set of values.
251
+ */
252
+ function inferChoices(schema) {
253
+ const internalSchema = schema;
254
+ const schemaType = internalSchema.type;
255
+ if (!schemaType) return void 0;
256
+ if (schemaType === "picklist") {
257
+ const options = internalSchema.options;
258
+ if (Array.isArray(options)) {
259
+ const result = [];
260
+ for (const opt of options) if (typeof opt === "string") result.push(opt);
261
+ else return void 0;
262
+ return result.length > 0 ? result : void 0;
263
+ }
264
+ return void 0;
265
+ }
266
+ if (schemaType === "literal") {
267
+ const value = internalSchema.literal;
268
+ if (typeof value === "string") return [value];
269
+ return void 0;
270
+ }
271
+ if (schemaType === "union") {
272
+ const options = internalSchema.options;
273
+ if (!Array.isArray(options)) return void 0;
274
+ const allChoices = /* @__PURE__ */ new Set();
275
+ for (const opt of options) if (typeof opt === "object" && opt != null && "type" in opt) {
276
+ const sub = inferChoices(opt);
277
+ if (sub == null) return void 0;
278
+ for (const choice of sub) allChoices.add(choice);
279
+ } else return void 0;
280
+ return allChoices.size > 0 ? [...allChoices] : void 0;
281
+ }
282
+ if (schemaType === "optional" || schemaType === "nullable" || schemaType === "nullish") {
283
+ const wrapped = internalSchema.wrapped;
284
+ if (wrapped) return inferChoices(wrapped);
285
+ }
286
+ return void 0;
287
+ }
288
+ /**
95
289
  * Creates a value parser from a Valibot schema.
96
290
  *
97
291
  * This parser validates CLI argument strings using Valibot schemas, enabling
@@ -108,7 +302,8 @@ function inferMetavar(schema) {
108
302
  *
109
303
  * @template T The output type of the Valibot schema.
110
304
  * @param schema A Valibot schema to validate input against.
111
- * @param options Optional configuration for the parser.
305
+ * @param options Configuration for the parser, including a required
306
+ * `placeholder` value used during deferred prompt resolution.
112
307
  * @returns A value parser that validates inputs using the provided schema.
113
308
  *
114
309
  * @example Basic string validation
@@ -117,7 +312,9 @@ function inferMetavar(schema) {
117
312
  * import { valibot } from "@optique/valibot";
118
313
  * import { option } from "@optique/core/primitives";
119
314
  *
120
- * const email = option("--email", valibot(v.pipe(v.string(), v.email())));
315
+ * const email = option("--email",
316
+ * valibot(v.pipe(v.string(), v.email()), { placeholder: "" }),
317
+ * );
121
318
  * ```
122
319
  *
123
320
  * @example Number validation with pipeline
@@ -134,8 +331,8 @@ function inferMetavar(schema) {
134
331
  * v.number(),
135
332
  * v.integer(),
136
333
  * v.minValue(1024),
137
- * v.maxValue(65535)
138
- * ))
334
+ * v.maxValue(65535),
335
+ * ), { placeholder: 1024 }),
139
336
  * );
140
337
  * ```
141
338
  *
@@ -146,7 +343,9 @@ function inferMetavar(schema) {
146
343
  * import { option } from "@optique/core/primitives";
147
344
  *
148
345
  * const logLevel = option("--log-level",
149
- * valibot(v.picklist(["debug", "info", "warn", "error"]))
346
+ * valibot(v.picklist(["debug", "info", "warn", "error"]), {
347
+ * placeholder: "debug",
348
+ * }),
150
349
  * );
151
350
  * ```
152
351
  *
@@ -159,23 +358,46 @@ function inferMetavar(schema) {
159
358
  *
160
359
  * const email = option("--email",
161
360
  * valibot(v.pipe(v.string(), v.email()), {
361
+ * placeholder: "",
162
362
  * metavar: "EMAIL",
163
363
  * errors: {
164
364
  * valibotError: (issues, input) =>
165
365
  * message`Please provide a valid email address, got ${input}.`
166
- * }
167
- * })
366
+ * },
367
+ * }),
168
368
  * );
169
369
  * ```
170
370
  *
371
+ * @throws {TypeError} If `options` is missing, not an object, or does not
372
+ * include `placeholder`.
373
+ * @throws {TypeError} If the resolved `metavar` is an empty string.
374
+ * @throws {TypeError} If the schema contains async validations that cannot be
375
+ * executed synchronously.
171
376
  * @since 0.7.0
172
377
  */
173
- function valibot$1(schema, options = {}) {
174
- return {
175
- $mode: "sync",
176
- metavar: options.metavar ?? inferMetavar(schema),
378
+ function valibot$1(schema, options) {
379
+ if (options == null || typeof options !== "object") throw new TypeError("valibot() requires an options object with a placeholder property.");
380
+ if (!("placeholder" in options)) throw new TypeError("valibot() options must include a placeholder property.");
381
+ if (containsAsyncSchema(schema)) throw new TypeError("Async Valibot schemas (e.g., async validations) are not supported by valibot(). Use synchronous schemas instead.");
382
+ const choices = inferChoices(schema);
383
+ const metavar = options.metavar ?? inferMetavar(schema);
384
+ (0, __optique_core_nonempty.ensureNonEmptyString)(metavar);
385
+ const parser = {
386
+ mode: "sync",
387
+ metavar,
388
+ placeholder: options.placeholder,
389
+ ...choices != null && choices.length > 0 ? {
390
+ choices: Object.freeze(choices),
391
+ *suggest(prefix) {
392
+ for (const c of choices) if (c.startsWith(prefix)) yield {
393
+ kind: "literal",
394
+ text: c
395
+ };
396
+ }
397
+ } : {},
177
398
  parse(input) {
178
399
  const result = (0, valibot.safeParse)(schema, input);
400
+ if (typeof result.typed !== "boolean") throw new TypeError("Async Valibot schemas (e.g., async validations) are not supported by valibot(). Use synchronous schemas instead.");
179
401
  if (result.success) return {
180
402
  success: true,
181
403
  value: result.output
@@ -191,9 +413,20 @@ function valibot$1(schema, options = {}) {
191
413
  };
192
414
  },
193
415
  format(value) {
194
- return String(value);
416
+ if (options.format) return options.format(value);
417
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? String(value) : value.toISOString();
418
+ if (typeof value !== "object" || value === null) return String(value);
419
+ if (Array.isArray(value)) return String(value);
420
+ const str = String(value);
421
+ if (str !== "[object Object]") return str;
422
+ const proto = Object.getPrototypeOf(value);
423
+ if (proto === Object.prototype || proto === null) try {
424
+ return JSON.stringify(value) ?? str;
425
+ } catch {}
426
+ return str;
195
427
  }
196
428
  };
429
+ return parser;
197
430
  }
198
431
 
199
432
  //#endregion
package/dist/index.d.cts CHANGED
@@ -8,7 +8,7 @@ import * as v from "valibot";
8
8
  * Options for creating a Valibot value parser.
9
9
  * @since 0.7.0
10
10
  */
11
- interface ValibotParserOptions {
11
+ interface ValibotParserOptions<T = unknown> {
12
12
  /**
13
13
  * The metavariable name for this parser. This is used in help messages to
14
14
  * indicate what kind of value this parser expects. Usually a single
@@ -16,6 +16,25 @@ interface ValibotParserOptions {
16
16
  * @default `"VALUE"`
17
17
  */
18
18
  readonly metavar?: NonEmptyString;
19
+ /**
20
+ * A phase-one stand-in value of type `T` used during deferred prompt
21
+ * resolution. Because the output type of a Valibot schema cannot be
22
+ * inferred to a concrete default, callers must provide this explicitly.
23
+ * @since 1.0.0
24
+ */
25
+ readonly placeholder: T;
26
+ /**
27
+ * Custom formatter for displaying parsed values in help messages.
28
+ * When not provided, the default formatter is used: primitives use
29
+ * `String()`, valid `Date` values use `.toISOString()`, and plain
30
+ * objects use `JSON.stringify()`. All other objects (arrays, class
31
+ * instances, etc.) use `String()`.
32
+ *
33
+ * @param value The parsed value to format.
34
+ * @returns A string representation of the value.
35
+ * @since 1.0.0
36
+ */
37
+ readonly format?: (value: T) => string;
19
38
  /**
20
39
  * Custom error messages for Valibot validation failures.
21
40
  */
@@ -46,7 +65,8 @@ interface ValibotParserOptions {
46
65
  *
47
66
  * @template T The output type of the Valibot schema.
48
67
  * @param schema A Valibot schema to validate input against.
49
- * @param options Optional configuration for the parser.
68
+ * @param options Configuration for the parser, including a required
69
+ * `placeholder` value used during deferred prompt resolution.
50
70
  * @returns A value parser that validates inputs using the provided schema.
51
71
  *
52
72
  * @example Basic string validation
@@ -55,7 +75,9 @@ interface ValibotParserOptions {
55
75
  * import { valibot } from "@optique/valibot";
56
76
  * import { option } from "@optique/core/primitives";
57
77
  *
58
- * const email = option("--email", valibot(v.pipe(v.string(), v.email())));
78
+ * const email = option("--email",
79
+ * valibot(v.pipe(v.string(), v.email()), { placeholder: "" }),
80
+ * );
59
81
  * ```
60
82
  *
61
83
  * @example Number validation with pipeline
@@ -72,8 +94,8 @@ interface ValibotParserOptions {
72
94
  * v.number(),
73
95
  * v.integer(),
74
96
  * v.minValue(1024),
75
- * v.maxValue(65535)
76
- * ))
97
+ * v.maxValue(65535),
98
+ * ), { placeholder: 1024 }),
77
99
  * );
78
100
  * ```
79
101
  *
@@ -84,7 +106,9 @@ interface ValibotParserOptions {
84
106
  * import { option } from "@optique/core/primitives";
85
107
  *
86
108
  * const logLevel = option("--log-level",
87
- * valibot(v.picklist(["debug", "info", "warn", "error"]))
109
+ * valibot(v.picklist(["debug", "info", "warn", "error"]), {
110
+ * placeholder: "debug",
111
+ * }),
88
112
  * );
89
113
  * ```
90
114
  *
@@ -97,17 +121,23 @@ interface ValibotParserOptions {
97
121
  *
98
122
  * const email = option("--email",
99
123
  * valibot(v.pipe(v.string(), v.email()), {
124
+ * placeholder: "",
100
125
  * metavar: "EMAIL",
101
126
  * errors: {
102
127
  * valibotError: (issues, input) =>
103
128
  * message`Please provide a valid email address, got ${input}.`
104
- * }
105
- * })
129
+ * },
130
+ * }),
106
131
  * );
107
132
  * ```
108
133
  *
134
+ * @throws {TypeError} If `options` is missing, not an object, or does not
135
+ * include `placeholder`.
136
+ * @throws {TypeError} If the resolved `metavar` is an empty string.
137
+ * @throws {TypeError} If the schema contains async validations that cannot be
138
+ * executed synchronously.
109
139
  * @since 0.7.0
110
140
  */
111
- declare function valibot<T>(schema: v.BaseSchema<unknown, T, v.BaseIssue<unknown>>, options?: ValibotParserOptions): ValueParser<"sync", T>;
141
+ declare function valibot<T>(schema: v.BaseSchema<unknown, T, v.BaseIssue<unknown>>, options: ValibotParserOptions<T>): ValueParser<"sync", T>;
112
142
  //#endregion
113
143
  export { ValibotParserOptions, valibot };
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ import { NonEmptyString, ValueParser } from "@optique/core/valueparser";
8
8
  * Options for creating a Valibot value parser.
9
9
  * @since 0.7.0
10
10
  */
11
- interface ValibotParserOptions {
11
+ interface ValibotParserOptions<T = unknown> {
12
12
  /**
13
13
  * The metavariable name for this parser. This is used in help messages to
14
14
  * indicate what kind of value this parser expects. Usually a single
@@ -16,6 +16,25 @@ interface ValibotParserOptions {
16
16
  * @default `"VALUE"`
17
17
  */
18
18
  readonly metavar?: NonEmptyString;
19
+ /**
20
+ * A phase-one stand-in value of type `T` used during deferred prompt
21
+ * resolution. Because the output type of a Valibot schema cannot be
22
+ * inferred to a concrete default, callers must provide this explicitly.
23
+ * @since 1.0.0
24
+ */
25
+ readonly placeholder: T;
26
+ /**
27
+ * Custom formatter for displaying parsed values in help messages.
28
+ * When not provided, the default formatter is used: primitives use
29
+ * `String()`, valid `Date` values use `.toISOString()`, and plain
30
+ * objects use `JSON.stringify()`. All other objects (arrays, class
31
+ * instances, etc.) use `String()`.
32
+ *
33
+ * @param value The parsed value to format.
34
+ * @returns A string representation of the value.
35
+ * @since 1.0.0
36
+ */
37
+ readonly format?: (value: T) => string;
19
38
  /**
20
39
  * Custom error messages for Valibot validation failures.
21
40
  */
@@ -46,7 +65,8 @@ interface ValibotParserOptions {
46
65
  *
47
66
  * @template T The output type of the Valibot schema.
48
67
  * @param schema A Valibot schema to validate input against.
49
- * @param options Optional configuration for the parser.
68
+ * @param options Configuration for the parser, including a required
69
+ * `placeholder` value used during deferred prompt resolution.
50
70
  * @returns A value parser that validates inputs using the provided schema.
51
71
  *
52
72
  * @example Basic string validation
@@ -55,7 +75,9 @@ interface ValibotParserOptions {
55
75
  * import { valibot } from "@optique/valibot";
56
76
  * import { option } from "@optique/core/primitives";
57
77
  *
58
- * const email = option("--email", valibot(v.pipe(v.string(), v.email())));
78
+ * const email = option("--email",
79
+ * valibot(v.pipe(v.string(), v.email()), { placeholder: "" }),
80
+ * );
59
81
  * ```
60
82
  *
61
83
  * @example Number validation with pipeline
@@ -72,8 +94,8 @@ interface ValibotParserOptions {
72
94
  * v.number(),
73
95
  * v.integer(),
74
96
  * v.minValue(1024),
75
- * v.maxValue(65535)
76
- * ))
97
+ * v.maxValue(65535),
98
+ * ), { placeholder: 1024 }),
77
99
  * );
78
100
  * ```
79
101
  *
@@ -84,7 +106,9 @@ interface ValibotParserOptions {
84
106
  * import { option } from "@optique/core/primitives";
85
107
  *
86
108
  * const logLevel = option("--log-level",
87
- * valibot(v.picklist(["debug", "info", "warn", "error"]))
109
+ * valibot(v.picklist(["debug", "info", "warn", "error"]), {
110
+ * placeholder: "debug",
111
+ * }),
88
112
  * );
89
113
  * ```
90
114
  *
@@ -97,17 +121,23 @@ interface ValibotParserOptions {
97
121
  *
98
122
  * const email = option("--email",
99
123
  * valibot(v.pipe(v.string(), v.email()), {
124
+ * placeholder: "",
100
125
  * metavar: "EMAIL",
101
126
  * errors: {
102
127
  * valibotError: (issues, input) =>
103
128
  * message`Please provide a valid email address, got ${input}.`
104
- * }
105
- * })
129
+ * },
130
+ * }),
106
131
  * );
107
132
  * ```
108
133
  *
134
+ * @throws {TypeError} If `options` is missing, not an object, or does not
135
+ * include `placeholder`.
136
+ * @throws {TypeError} If the resolved `metavar` is an empty string.
137
+ * @throws {TypeError} If the schema contains async validations that cannot be
138
+ * executed synchronously.
109
139
  * @since 0.7.0
110
140
  */
111
- declare function valibot<T>(schema: v.BaseSchema<unknown, T, v.BaseIssue<unknown>>, options?: ValibotParserOptions): ValueParser<"sync", T>;
141
+ declare function valibot<T>(schema: v.BaseSchema<unknown, T, v.BaseIssue<unknown>>, options: ValibotParserOptions<T>): ValueParser<"sync", T>;
112
142
  //#endregion
113
143
  export { ValibotParserOptions, valibot };
package/dist/index.js CHANGED
@@ -1,8 +1,151 @@
1
1
  import { message } from "@optique/core/message";
2
+ import { ensureNonEmptyString } from "@optique/core/nonempty";
2
3
  import { safeParse } from "valibot";
3
4
 
4
5
  //#region src/index.ts
5
6
  /**
7
+ * Valibot transformation action types that are known to be non-rejecting and
8
+ * type-preserving (they transform a string into a string without ever adding
9
+ * issues). All other transformation types (transform, raw_transform,
10
+ * parse_json, to_number, to_boolean, etc.) may reject input or change the
11
+ * value type.
12
+ */
13
+ const SAFE_TRANSFORMATION_TYPES = new Set([
14
+ "trim",
15
+ "to_lower_case",
16
+ "to_upper_case",
17
+ "normalize",
18
+ "to_min_value",
19
+ "to_max_value",
20
+ "trim_start",
21
+ "trim_end",
22
+ "readonly",
23
+ "brand",
24
+ "flavor"
25
+ ]);
26
+ /**
27
+ * Checks whether a schema synchronously accepts every possible input value.
28
+ * This includes:
29
+ * - `v.unknown()`, `v.any()` (accept everything regardless of input type)
30
+ * - Bare `v.string()` without a pipe (accepts every string)
31
+ * - Any wrapper with a `wrapped` field pointing to a catch-all schema
32
+ * (e.g., `v.optional()`, `v.nullable()`, `v.nonOptional()`, etc.)
33
+ *
34
+ * Piped schemas are considered catch-all only when the base type is
35
+ * `string`/`unknown`/`any` and every pipe action is a non-rejecting
36
+ * transformation (not a validation or nested schema).
37
+ *
38
+ * @param afterTransform When true, only type-agnostic catch-alls
39
+ * (`v.unknown()`, `v.any()`) are recognized. String-based catch-alls
40
+ * are not trusted since the input type may no longer be a string.
41
+ */
42
+ function isCatchAllSchema(schema, afterTransform = false) {
43
+ const s = schema;
44
+ if (s.async) return false;
45
+ if ("fallback" in s) return true;
46
+ if (s.type === "unknown" || s.type === "any") {
47
+ if (!s.pipe) return true;
48
+ return s.pipe.slice(1).every((action) => {
49
+ const a = action;
50
+ if (a.kind === "validation") return false;
51
+ if (a.kind === "schema") return isCatchAllSchema(action, afterTransform);
52
+ return SAFE_TRANSFORMATION_TYPES.has(a.type ?? "");
53
+ });
54
+ }
55
+ if (!afterTransform && s.type === "string") {
56
+ if (!s.pipe) return true;
57
+ return s.pipe.slice(1).every((action) => {
58
+ const a = action;
59
+ if (a.kind === "validation") return false;
60
+ if (a.kind === "schema") return isCatchAllSchema(action, afterTransform);
61
+ return SAFE_TRANSFORMATION_TYPES.has(a.type ?? "");
62
+ });
63
+ }
64
+ if (s.wrapped && !s.pipe) return isCatchAllSchema(s.wrapped, afterTransform);
65
+ return false;
66
+ }
67
+ /**
68
+ * Recursively checks whether a Valibot schema contains any async parts
69
+ * (e.g., `pipeAsync`, `checkAsync`). Wrapper schemas such as `optional()`,
70
+ * `nullable()`, `nullish()`, and `union()` keep `async === false` on the
71
+ * outer layer even when they wrap async inner schemas, so a shallow check
72
+ * on the top-level `async` property is not sufficient.
73
+ *
74
+ * Known limitations:
75
+ * - `v.variant()` arms are treated like union arms, but the catch-all
76
+ * detection does not recognize object-shaped variant arms. Variant
77
+ * schemas with async arms after a broad discriminator will be
78
+ * conservatively rejected.
79
+ * - `v.lazy()` schemas are not inspected because the getter depends on
80
+ * actual parse input, making static analysis unreliable.
81
+ *
82
+ * @param afterTransform When true, a preceding `v.transform()` may have
83
+ * changed the value type. Container members become reachable and
84
+ * string-based union catch-all arms are no longer trusted.
85
+ */
86
+ function containsAsyncSchema(schema, visited = /* @__PURE__ */ new WeakMap(), afterTransform = false) {
87
+ const prev = visited.get(schema);
88
+ if (prev !== void 0 && (prev || !afterTransform)) return false;
89
+ visited.set(schema, (prev ?? false) || afterTransform);
90
+ const s = schema;
91
+ if (s.async) return true;
92
+ if (s.wrapped && !s.pipe) return containsAsyncSchema(s.wrapped, visited, afterTransform);
93
+ if (s.options && Array.isArray(s.options)) {
94
+ if (s.type === "union") for (const option of s.options) {
95
+ if (typeof option !== "object" || option == null) continue;
96
+ if (isCatchAllSchema(option, afterTransform)) break;
97
+ if (containsAsyncSchema(option, visited, afterTransform)) return true;
98
+ }
99
+ else if (s.type === "variant") {
100
+ if (afterTransform) {
101
+ for (const option of s.options) if (typeof option === "object" && option != null) {
102
+ if (containsAsyncSchema(option, visited, true)) return true;
103
+ }
104
+ }
105
+ } else for (const option of s.options) if (typeof option === "object" && option != null) {
106
+ if (containsAsyncSchema(option, visited, afterTransform)) return true;
107
+ }
108
+ }
109
+ if (s.pipe && Array.isArray(s.pipe)) {
110
+ let seenTransform = afterTransform;
111
+ for (const action of s.pipe) {
112
+ if (action.async) return true;
113
+ const a = action;
114
+ if (a.kind === "transformation" && !SAFE_TRANSFORMATION_TYPES.has(a.type ?? "")) seenTransform = true;
115
+ if (a.kind === "schema") {
116
+ if (containsAsyncSchema(action, visited, seenTransform)) return true;
117
+ if (!seenTransform) {
118
+ const innerPipe = action.pipe;
119
+ if (innerPipe && Array.isArray(innerPipe)) for (const innerAction of innerPipe) {
120
+ const ia = innerAction;
121
+ if (ia.kind === "transformation" && !SAFE_TRANSFORMATION_TYPES.has(ia.type ?? "")) {
122
+ seenTransform = true;
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ if (afterTransform) {
131
+ if (s.entries) {
132
+ for (const entry of Object.values(s.entries)) if (containsAsyncSchema(entry, visited, true)) return true;
133
+ }
134
+ if (s.item && containsAsyncSchema(s.item, visited, true)) return true;
135
+ if (s.items && Array.isArray(s.items)) {
136
+ for (const item of s.items) if (containsAsyncSchema(item, visited, true)) return true;
137
+ }
138
+ if (s.key && typeof s.key === "object" && containsAsyncSchema(s.key, visited, true)) return true;
139
+ if (s.value && containsAsyncSchema(s.value, visited, true)) return true;
140
+ if (s.rest && containsAsyncSchema(s.rest, visited, true)) return true;
141
+ if (s.type === "promise") {
142
+ const promiseInner = schema.message;
143
+ if (typeof promiseInner === "object" && promiseInner != null && "kind" in promiseInner && containsAsyncSchema(promiseInner, visited, true)) return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+ /**
6
149
  * Infers an appropriate metavar string from a Valibot schema.
7
150
  *
8
151
  * This function analyzes the Valibot schema's internal structure to determine
@@ -60,8 +203,15 @@ function inferMetavar(schema) {
60
203
  if (schemaType === "boolean") return "BOOLEAN";
61
204
  if (schemaType === "date") return "DATE";
62
205
  if (schemaType === "picklist") return "CHOICE";
63
- if (schemaType === "literal") return "VALUE";
64
- if (schemaType === "union" || schemaType === "variant") return "VALUE";
206
+ if (schemaType === "literal") {
207
+ if (inferChoices(schema) != null) return "CHOICE";
208
+ return "VALUE";
209
+ }
210
+ if (schemaType === "union") {
211
+ if (inferChoices(schema) != null) return "CHOICE";
212
+ return "VALUE";
213
+ }
214
+ if (schemaType === "variant") return "VALUE";
65
215
  if (schemaType === "optional" || schemaType === "nullable" || schemaType === "nullish") {
66
216
  const wrapped = internalSchema.wrapped;
67
217
  if (wrapped) return inferMetavar(wrapped);
@@ -69,6 +219,50 @@ function inferMetavar(schema) {
69
219
  return "VALUE";
70
220
  }
71
221
  /**
222
+ * Extracts valid choices from a Valibot schema that represents a fixed set of
223
+ * values (picklist, literal, or union of literals).
224
+ *
225
+ * @param schema A Valibot schema to analyze.
226
+ * @returns An array of string representations of valid choices, or `undefined`
227
+ * if the schema does not represent a fixed set of values.
228
+ */
229
+ function inferChoices(schema) {
230
+ const internalSchema = schema;
231
+ const schemaType = internalSchema.type;
232
+ if (!schemaType) return void 0;
233
+ if (schemaType === "picklist") {
234
+ const options = internalSchema.options;
235
+ if (Array.isArray(options)) {
236
+ const result = [];
237
+ for (const opt of options) if (typeof opt === "string") result.push(opt);
238
+ else return void 0;
239
+ return result.length > 0 ? result : void 0;
240
+ }
241
+ return void 0;
242
+ }
243
+ if (schemaType === "literal") {
244
+ const value = internalSchema.literal;
245
+ if (typeof value === "string") return [value];
246
+ return void 0;
247
+ }
248
+ if (schemaType === "union") {
249
+ const options = internalSchema.options;
250
+ if (!Array.isArray(options)) return void 0;
251
+ const allChoices = /* @__PURE__ */ new Set();
252
+ for (const opt of options) if (typeof opt === "object" && opt != null && "type" in opt) {
253
+ const sub = inferChoices(opt);
254
+ if (sub == null) return void 0;
255
+ for (const choice of sub) allChoices.add(choice);
256
+ } else return void 0;
257
+ return allChoices.size > 0 ? [...allChoices] : void 0;
258
+ }
259
+ if (schemaType === "optional" || schemaType === "nullable" || schemaType === "nullish") {
260
+ const wrapped = internalSchema.wrapped;
261
+ if (wrapped) return inferChoices(wrapped);
262
+ }
263
+ return void 0;
264
+ }
265
+ /**
72
266
  * Creates a value parser from a Valibot schema.
73
267
  *
74
268
  * This parser validates CLI argument strings using Valibot schemas, enabling
@@ -85,7 +279,8 @@ function inferMetavar(schema) {
85
279
  *
86
280
  * @template T The output type of the Valibot schema.
87
281
  * @param schema A Valibot schema to validate input against.
88
- * @param options Optional configuration for the parser.
282
+ * @param options Configuration for the parser, including a required
283
+ * `placeholder` value used during deferred prompt resolution.
89
284
  * @returns A value parser that validates inputs using the provided schema.
90
285
  *
91
286
  * @example Basic string validation
@@ -94,7 +289,9 @@ function inferMetavar(schema) {
94
289
  * import { valibot } from "@optique/valibot";
95
290
  * import { option } from "@optique/core/primitives";
96
291
  *
97
- * const email = option("--email", valibot(v.pipe(v.string(), v.email())));
292
+ * const email = option("--email",
293
+ * valibot(v.pipe(v.string(), v.email()), { placeholder: "" }),
294
+ * );
98
295
  * ```
99
296
  *
100
297
  * @example Number validation with pipeline
@@ -111,8 +308,8 @@ function inferMetavar(schema) {
111
308
  * v.number(),
112
309
  * v.integer(),
113
310
  * v.minValue(1024),
114
- * v.maxValue(65535)
115
- * ))
311
+ * v.maxValue(65535),
312
+ * ), { placeholder: 1024 }),
116
313
  * );
117
314
  * ```
118
315
  *
@@ -123,7 +320,9 @@ function inferMetavar(schema) {
123
320
  * import { option } from "@optique/core/primitives";
124
321
  *
125
322
  * const logLevel = option("--log-level",
126
- * valibot(v.picklist(["debug", "info", "warn", "error"]))
323
+ * valibot(v.picklist(["debug", "info", "warn", "error"]), {
324
+ * placeholder: "debug",
325
+ * }),
127
326
  * );
128
327
  * ```
129
328
  *
@@ -136,23 +335,46 @@ function inferMetavar(schema) {
136
335
  *
137
336
  * const email = option("--email",
138
337
  * valibot(v.pipe(v.string(), v.email()), {
338
+ * placeholder: "",
139
339
  * metavar: "EMAIL",
140
340
  * errors: {
141
341
  * valibotError: (issues, input) =>
142
342
  * message`Please provide a valid email address, got ${input}.`
143
- * }
144
- * })
343
+ * },
344
+ * }),
145
345
  * );
146
346
  * ```
147
347
  *
348
+ * @throws {TypeError} If `options` is missing, not an object, or does not
349
+ * include `placeholder`.
350
+ * @throws {TypeError} If the resolved `metavar` is an empty string.
351
+ * @throws {TypeError} If the schema contains async validations that cannot be
352
+ * executed synchronously.
148
353
  * @since 0.7.0
149
354
  */
150
- function valibot(schema, options = {}) {
151
- return {
152
- $mode: "sync",
153
- metavar: options.metavar ?? inferMetavar(schema),
355
+ function valibot(schema, options) {
356
+ if (options == null || typeof options !== "object") throw new TypeError("valibot() requires an options object with a placeholder property.");
357
+ if (!("placeholder" in options)) throw new TypeError("valibot() options must include a placeholder property.");
358
+ if (containsAsyncSchema(schema)) throw new TypeError("Async Valibot schemas (e.g., async validations) are not supported by valibot(). Use synchronous schemas instead.");
359
+ const choices = inferChoices(schema);
360
+ const metavar = options.metavar ?? inferMetavar(schema);
361
+ ensureNonEmptyString(metavar);
362
+ const parser = {
363
+ mode: "sync",
364
+ metavar,
365
+ placeholder: options.placeholder,
366
+ ...choices != null && choices.length > 0 ? {
367
+ choices: Object.freeze(choices),
368
+ *suggest(prefix) {
369
+ for (const c of choices) if (c.startsWith(prefix)) yield {
370
+ kind: "literal",
371
+ text: c
372
+ };
373
+ }
374
+ } : {},
154
375
  parse(input) {
155
376
  const result = safeParse(schema, input);
377
+ if (typeof result.typed !== "boolean") throw new TypeError("Async Valibot schemas (e.g., async validations) are not supported by valibot(). Use synchronous schemas instead.");
156
378
  if (result.success) return {
157
379
  success: true,
158
380
  value: result.output
@@ -168,9 +390,20 @@ function valibot(schema, options = {}) {
168
390
  };
169
391
  },
170
392
  format(value) {
171
- return String(value);
393
+ if (options.format) return options.format(value);
394
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? String(value) : value.toISOString();
395
+ if (typeof value !== "object" || value === null) return String(value);
396
+ if (Array.isArray(value)) return String(value);
397
+ const str = String(value);
398
+ if (str !== "[object Object]") return str;
399
+ const proto = Object.getPrototypeOf(value);
400
+ if (proto === Object.prototype || proto === null) try {
401
+ return JSON.stringify(value) ?? str;
402
+ } catch {}
403
+ return str;
172
404
  }
173
405
  };
406
+ return parser;
174
407
  }
175
408
 
176
409
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/valibot",
3
- "version": "1.0.0-dev.921+754748bd",
3
+ "version": "1.0.1",
4
4
  "description": "Valibot value parsers for Optique",
5
5
  "keywords": [
6
6
  "CLI",
@@ -57,7 +57,7 @@
57
57
  "valibot": "^1.2.0"
58
58
  },
59
59
  "dependencies": {
60
- "@optique/core": "1.0.0-dev.921+754748bd"
60
+ "@optique/core": "1.0.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^20.19.9",