@optique/core 0.8.0-dev.164 → 0.8.0-dev.166

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.
@@ -723,6 +723,171 @@ function command(name, parser, options = {}) {
723
723
  }
724
724
  };
725
725
  }
726
+ /**
727
+ * Creates a parser that collects unrecognized options and passes them through.
728
+ * This is useful for building wrapper CLI tools that need to forward unknown
729
+ * options to an underlying tool.
730
+ *
731
+ * **Important**: This parser intentionally weakens Optique's strict parsing
732
+ * philosophy where "all input must be recognized." The benefit is enabling
733
+ * legitimate wrapper/proxy tool patterns, but the trade-off is that typos
734
+ * in pass-through options won't be caught.
735
+ *
736
+ * @param options Configuration for how to capture options.
737
+ * @returns A {@link Parser} that captures unrecognized options as an array
738
+ * of strings.
739
+ *
740
+ * @example
741
+ * ```typescript
742
+ * // Default format: only captures --opt=val
743
+ * const parser = object({
744
+ * debug: option("--debug"),
745
+ * extra: passThrough(),
746
+ * });
747
+ *
748
+ * // mycli --debug --foo=bar --baz=qux
749
+ * // → { debug: true, extra: ["--foo=bar", "--baz=qux"] }
750
+ *
751
+ * // nextToken format: captures --opt val pairs
752
+ * const parser = object({
753
+ * debug: option("--debug"),
754
+ * extra: passThrough({ format: "nextToken" }),
755
+ * });
756
+ *
757
+ * // mycli --debug --foo bar
758
+ * // → { debug: true, extra: ["--foo", "bar"] }
759
+ *
760
+ * // greedy format: captures all remaining tokens
761
+ * const parser = command("exec", object({
762
+ * container: argument(string()),
763
+ * args: passThrough({ format: "greedy" }),
764
+ * }));
765
+ *
766
+ * // myproxy exec mycontainer --verbose -it bash
767
+ * // → { container: "mycontainer", args: ["--verbose", "-it", "bash"] }
768
+ * ```
769
+ *
770
+ * @since 0.8.0
771
+ */
772
+ function passThrough(options = {}) {
773
+ const format = options.format ?? "equalsOnly";
774
+ const optionPattern = /^-[a-z0-9-]|^--[a-z0-9-]+/i;
775
+ const equalsOptionPattern = /^--[a-z0-9-]+=/i;
776
+ return {
777
+ $valueType: [],
778
+ $stateType: [],
779
+ priority: -10,
780
+ usage: [{ type: "passthrough" }],
781
+ initialState: [],
782
+ parse(context) {
783
+ if (context.buffer.length < 1) return {
784
+ success: false,
785
+ consumed: 0,
786
+ error: message`No input to pass through.`
787
+ };
788
+ const token = context.buffer[0];
789
+ if (format === "greedy") {
790
+ const captured = [...context.buffer];
791
+ return {
792
+ success: true,
793
+ next: {
794
+ ...context,
795
+ buffer: [],
796
+ state: [...context.state, ...captured]
797
+ },
798
+ consumed: captured
799
+ };
800
+ }
801
+ if (context.optionsTerminated) return {
802
+ success: false,
803
+ consumed: 0,
804
+ error: message`Options terminated; cannot capture pass-through options.`
805
+ };
806
+ if (format === "equalsOnly") {
807
+ if (!equalsOptionPattern.test(token)) return {
808
+ success: false,
809
+ consumed: 0,
810
+ error: message`Expected --option=value format, but got: ${token}.`
811
+ };
812
+ return {
813
+ success: true,
814
+ next: {
815
+ ...context,
816
+ buffer: context.buffer.slice(1),
817
+ state: [...context.state, token]
818
+ },
819
+ consumed: [token]
820
+ };
821
+ }
822
+ if (format === "nextToken") {
823
+ if (!optionPattern.test(token)) return {
824
+ success: false,
825
+ consumed: 0,
826
+ error: message`Expected option, but got: ${token}.`
827
+ };
828
+ if (token.includes("=")) return {
829
+ success: true,
830
+ next: {
831
+ ...context,
832
+ buffer: context.buffer.slice(1),
833
+ state: [...context.state, token]
834
+ },
835
+ consumed: [token]
836
+ };
837
+ const nextToken = context.buffer[1];
838
+ if (nextToken !== void 0 && !optionPattern.test(nextToken)) return {
839
+ success: true,
840
+ next: {
841
+ ...context,
842
+ buffer: context.buffer.slice(2),
843
+ state: [
844
+ ...context.state,
845
+ token,
846
+ nextToken
847
+ ]
848
+ },
849
+ consumed: [token, nextToken]
850
+ };
851
+ return {
852
+ success: true,
853
+ next: {
854
+ ...context,
855
+ buffer: context.buffer.slice(1),
856
+ state: [...context.state, token]
857
+ },
858
+ consumed: [token]
859
+ };
860
+ }
861
+ return {
862
+ success: false,
863
+ consumed: 0,
864
+ error: message`Unknown passThrough format: ${format}.`
865
+ };
866
+ },
867
+ complete(state) {
868
+ return {
869
+ success: true,
870
+ value: state
871
+ };
872
+ },
873
+ suggest(_context, _prefix) {
874
+ return [];
875
+ },
876
+ getDocFragments(_state, _defaultValue) {
877
+ return {
878
+ fragments: [{
879
+ type: "entry",
880
+ term: { type: "passthrough" },
881
+ description: options.description
882
+ }],
883
+ description: options.description
884
+ };
885
+ },
886
+ [Symbol.for("Deno.customInspect")]() {
887
+ return `passThrough(${format})`;
888
+ }
889
+ };
890
+ }
726
891
 
727
892
  //#endregion
728
- export { argument, command, constant, flag, option };
893
+ export { argument, command, constant, flag, option, passThrough };
@@ -179,8 +179,58 @@ function createErrorWithSuggestions(baseError, invalidInput, usage, type = "both
179
179
  ...suggestionMsg
180
180
  ] : baseError;
181
181
  }
182
+ /**
183
+ * Creates a unique key for a suggestion to enable deduplication.
184
+ *
185
+ * For literal suggestions, the text itself is used as the key.
186
+ * For file suggestions, a composite key is created from the type,
187
+ * extensions, and pattern.
188
+ *
189
+ * @param suggestion The suggestion to create a key for
190
+ * @returns A string key that uniquely identifies this suggestion
191
+ * @internal
192
+ */
193
+ function getSuggestionKey(suggestion) {
194
+ if (suggestion.kind === "literal") return suggestion.text;
195
+ return `__FILE__:${suggestion.type}:${suggestion.extensions?.join(",") ?? ""}:${suggestion.pattern ?? ""}`;
196
+ }
197
+ /**
198
+ * Removes duplicate suggestions from an array while preserving order.
199
+ *
200
+ * Suggestions are considered duplicates if they have the same key:
201
+ * - Literal suggestions: same text
202
+ * - File suggestions: same type, extensions, and pattern
203
+ *
204
+ * The first occurrence of each unique suggestion is kept.
205
+ *
206
+ * @param suggestions Array of suggestions that may contain duplicates
207
+ * @returns A new array with duplicates removed, preserving order of first occurrences
208
+ *
209
+ * @example
210
+ * ```typescript
211
+ * const suggestions = [
212
+ * { kind: "literal", text: "--verbose" },
213
+ * { kind: "literal", text: "--help" },
214
+ * { kind: "literal", text: "--verbose" }, // duplicate
215
+ * ];
216
+ * deduplicateSuggestions(suggestions);
217
+ * // returns [{ kind: "literal", text: "--verbose" }, { kind: "literal", text: "--help" }]
218
+ * ```
219
+ *
220
+ * @since 0.9.0
221
+ */
222
+ function deduplicateSuggestions(suggestions) {
223
+ const seen = /* @__PURE__ */ new Set();
224
+ return suggestions.filter((suggestion) => {
225
+ const key = getSuggestionKey(suggestion);
226
+ if (seen.has(key)) return false;
227
+ seen.add(key);
228
+ return true;
229
+ });
230
+ }
182
231
 
183
232
  //#endregion
184
233
  exports.DEFAULT_FIND_SIMILAR_OPTIONS = DEFAULT_FIND_SIMILAR_OPTIONS;
185
234
  exports.createErrorWithSuggestions = createErrorWithSuggestions;
235
+ exports.deduplicateSuggestions = deduplicateSuggestions;
186
236
  exports.findSimilar = findSimilar;
@@ -179,6 +179,55 @@ function createErrorWithSuggestions(baseError, invalidInput, usage, type = "both
179
179
  ...suggestionMsg
180
180
  ] : baseError;
181
181
  }
182
+ /**
183
+ * Creates a unique key for a suggestion to enable deduplication.
184
+ *
185
+ * For literal suggestions, the text itself is used as the key.
186
+ * For file suggestions, a composite key is created from the type,
187
+ * extensions, and pattern.
188
+ *
189
+ * @param suggestion The suggestion to create a key for
190
+ * @returns A string key that uniquely identifies this suggestion
191
+ * @internal
192
+ */
193
+ function getSuggestionKey(suggestion) {
194
+ if (suggestion.kind === "literal") return suggestion.text;
195
+ return `__FILE__:${suggestion.type}:${suggestion.extensions?.join(",") ?? ""}:${suggestion.pattern ?? ""}`;
196
+ }
197
+ /**
198
+ * Removes duplicate suggestions from an array while preserving order.
199
+ *
200
+ * Suggestions are considered duplicates if they have the same key:
201
+ * - Literal suggestions: same text
202
+ * - File suggestions: same type, extensions, and pattern
203
+ *
204
+ * The first occurrence of each unique suggestion is kept.
205
+ *
206
+ * @param suggestions Array of suggestions that may contain duplicates
207
+ * @returns A new array with duplicates removed, preserving order of first occurrences
208
+ *
209
+ * @example
210
+ * ```typescript
211
+ * const suggestions = [
212
+ * { kind: "literal", text: "--verbose" },
213
+ * { kind: "literal", text: "--help" },
214
+ * { kind: "literal", text: "--verbose" }, // duplicate
215
+ * ];
216
+ * deduplicateSuggestions(suggestions);
217
+ * // returns [{ kind: "literal", text: "--verbose" }, { kind: "literal", text: "--help" }]
218
+ * ```
219
+ *
220
+ * @since 0.9.0
221
+ */
222
+ function deduplicateSuggestions(suggestions) {
223
+ const seen = /* @__PURE__ */ new Set();
224
+ return suggestions.filter((suggestion) => {
225
+ const key = getSuggestionKey(suggestion);
226
+ if (seen.has(key)) return false;
227
+ seen.add(key);
228
+ return true;
229
+ });
230
+ }
182
231
 
183
232
  //#endregion
184
- export { DEFAULT_FIND_SIMILAR_OPTIONS, createErrorWithSuggestions, findSimilar };
233
+ export { DEFAULT_FIND_SIMILAR_OPTIONS, createErrorWithSuggestions, deduplicateSuggestions, findSimilar };
package/dist/usage.cjs CHANGED
@@ -331,7 +331,13 @@ function* formatUsageTermInternal(term, options) {
331
331
  text: term.value,
332
332
  width: term.value.length
333
333
  };
334
- else throw new TypeError(`Unknown usage term type: ${term["type"]}.`);
334
+ else if (term.type === "passthrough") {
335
+ const text = "[...]";
336
+ yield {
337
+ text: options?.colors ? `\x1b[2m${text}\x1b[0m` : text,
338
+ width: 5
339
+ };
340
+ } else throw new TypeError(`Unknown usage term type: ${term["type"]}.`);
335
341
  }
336
342
 
337
343
  //#endregion
package/dist/usage.d.cts CHANGED
@@ -123,6 +123,16 @@ type UsageTerm =
123
123
  * The literal value that must be provided exactly as written.
124
124
  */
125
125
  readonly value: string;
126
+ }
127
+ /**
128
+ * A pass-through term, which represents unrecognized options that are
129
+ * collected and passed through to an underlying tool or command.
130
+ * @since 0.8.0
131
+ */ | {
132
+ /**
133
+ * The type of the term, which is always `"passthrough"` for this term.
134
+ */
135
+ readonly type: "passthrough";
126
136
  };
127
137
  /**
128
138
  * Represents a command-line usage description, which is a sequence of
package/dist/usage.d.ts CHANGED
@@ -123,6 +123,16 @@ type UsageTerm =
123
123
  * The literal value that must be provided exactly as written.
124
124
  */
125
125
  readonly value: string;
126
+ }
127
+ /**
128
+ * A pass-through term, which represents unrecognized options that are
129
+ * collected and passed through to an underlying tool or command.
130
+ * @since 0.8.0
131
+ */ | {
132
+ /**
133
+ * The type of the term, which is always `"passthrough"` for this term.
134
+ */
135
+ readonly type: "passthrough";
126
136
  };
127
137
  /**
128
138
  * Represents a command-line usage description, which is a sequence of
package/dist/usage.js CHANGED
@@ -330,7 +330,13 @@ function* formatUsageTermInternal(term, options) {
330
330
  text: term.value,
331
331
  width: term.value.length
332
332
  };
333
- else throw new TypeError(`Unknown usage term type: ${term["type"]}.`);
333
+ else if (term.type === "passthrough") {
334
+ const text = "[...]";
335
+ yield {
336
+ text: options?.colors ? `\x1b[2m${text}\x1b[0m` : text,
337
+ width: 5
338
+ };
339
+ } else throw new TypeError(`Unknown usage term type: ${term["type"]}.`);
334
340
  }
335
341
 
336
342
  //#endregion
@@ -153,7 +153,7 @@ function integer(options) {
153
153
  return {
154
154
  metavar: options?.metavar ?? "INTEGER",
155
155
  parse(input) {
156
- if (!input.match(/^\d+$/)) return {
156
+ if (!input.match(/^-?\d+$/)) return {
157
157
  success: false,
158
158
  error: options?.errors?.invalidInteger ? typeof options.errors.invalidInteger === "function" ? options.errors.invalidInteger(input) : options.errors.invalidInteger : require_message.message`Expected a valid integer, but got ${input}.`
159
159
  };
@@ -153,7 +153,7 @@ function integer(options) {
153
153
  return {
154
154
  metavar: options?.metavar ?? "INTEGER",
155
155
  parse(input) {
156
- if (!input.match(/^\d+$/)) return {
156
+ if (!input.match(/^-?\d+$/)) return {
157
157
  success: false,
158
158
  error: options?.errors?.invalidInteger ? typeof options.errors.invalidInteger === "function" ? options.errors.invalidInteger(input) : options.errors.invalidInteger : message`Expected a valid integer, but got ${input}.`
159
159
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/core",
3
- "version": "0.8.0-dev.164+250237ef",
3
+ "version": "0.8.0-dev.166+141d0c79",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",