@optique/core 1.0.0-dev.1129 → 1.0.0-dev.1155

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.
@@ -40,6 +40,9 @@ function validateProgramName(programName) {
40
40
  function encodePattern(pattern) {
41
41
  return pattern.replace(/%/g, "%25").replace(/:/g, "%3A");
42
42
  }
43
+ function encodeExtensions(extensions) {
44
+ return extensions?.map((ext) => ext.replace(/^\./, "")).join(",") ?? "";
45
+ }
43
46
  /**
44
47
  * Replaces control characters that would corrupt shell completion protocols.
45
48
  * Shell completion formats use tabs as field delimiters and newlines as record
@@ -114,17 +117,25 @@ function _${programName} () {
114
117
  # Generate file completions based on type
115
118
  case "$type" in
116
119
  file)
117
- # Complete files only
120
+ # Complete files and directories (directories for navigation)
118
121
  if [[ -n "$extensions" ]]; then
119
- # Complete with extension filtering
122
+ # Files with extension filtering + directories
120
123
  local ext_pattern="\${extensions//,/|}"
121
- for file in "$__glob_current"*; do
122
- [[ -f "$file" && "$file" =~ \\.($ext_pattern)$ ]] && COMPREPLY+=("$file")
124
+ for item in "$__glob_current"*; do
125
+ if [[ -d "$item" ]]; then
126
+ COMPREPLY+=("$item/")
127
+ elif [[ -f "$item" && "$item" =~ \\.($ext_pattern)$ ]]; then
128
+ COMPREPLY+=("$item")
129
+ fi
123
130
  done
124
131
  else
125
- # Complete files only, exclude directories
132
+ # Complete files and directories for navigation
126
133
  for item in "$__glob_current"*; do
127
- [[ -f "$item" ]] && COMPREPLY+=("$item")
134
+ if [[ -d "$item" ]]; then
135
+ COMPREPLY+=("$item/")
136
+ elif [[ -f "$item" ]]; then
137
+ COMPREPLY+=("$item")
138
+ fi
128
139
  done
129
140
  fi
130
141
  ;;
@@ -203,7 +214,7 @@ complete -F _${programName} -- ${programName}
203
214
  if (i > 0) yield "\n";
204
215
  if (suggestion.kind === "literal") yield `${suggestion.text}`;
205
216
  else {
206
- const extensions = suggestion.extensions?.join(",") || "";
217
+ const extensions = encodeExtensions(suggestion.extensions);
207
218
  const hidden = suggestion.includeHidden ? "1" : "0";
208
219
  const pattern = encodePattern(suggestion.pattern ?? "");
209
220
  yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}`;
@@ -271,11 +282,11 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
271
282
  case "\$type" in
272
283
  file)
273
284
  if [[ -n "\$extensions" ]]; then
274
- # Complete files with extension filtering
285
+ # Complete files with extension filtering + directories for navigation
275
286
  local ext_pattern="*.(\$\{extensions//,/|\})"
276
- _files -g "\$ext_pattern"
287
+ _files -g "\$ext_pattern"; _directories
277
288
  else
278
- _files -g "*"
289
+ _files
279
290
  fi
280
291
  ;;
281
292
  directory)
@@ -285,7 +296,7 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
285
296
  if [[ -n "\$extensions" ]]; then
286
297
  # Complete both files and directories, with extension filtering for files
287
298
  local ext_pattern="*.(\$\{extensions//,/|\})"
288
- _files -g "\$ext_pattern" && _directories
299
+ _files -g "\$ext_pattern"; _directories
289
300
  else
290
301
  _files
291
302
  fi
@@ -328,7 +339,7 @@ compdef _${programName.replace(/[^a-zA-Z0-9]/g, "_")} ${programName}
328
339
  const description = suggestion.description == null ? "" : sanitizeDescription(require_message.formatMessage(suggestion.description, { colors: false }));
329
340
  yield `${suggestion.text}\0${description}\0`;
330
341
  } else {
331
- const extensions = suggestion.extensions?.join(",") || "";
342
+ const extensions = encodeExtensions(suggestion.extensions);
332
343
  const hidden = suggestion.includeHidden ? "1" : "0";
333
344
  const description = suggestion.description == null ? "" : sanitizeDescription(require_message.formatMessage(suggestion.description, { colors: false }));
334
345
  const pattern = encodePattern(suggestion.pattern ?? "");
@@ -375,9 +386,11 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
375
386
  set -l items
376
387
  switch $type
377
388
  case file
378
- # Complete files only
389
+ # Complete files and directories (directories for navigation)
379
390
  for item in $current*
380
- if test -f $item
391
+ if test -d $item
392
+ set -a items $item/
393
+ else if test -f $item
381
394
  set -a items $item
382
395
  end
383
396
  end
@@ -390,7 +403,9 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
390
403
  if test "$hidden" = "1"
391
404
  if test -z "$current"; or string match -q '*/' -- "$current"
392
405
  for item in $current.*
393
- if test -f $item
406
+ if test -d $item
407
+ set -a items $item/
408
+ else if test -f $item
394
409
  set -a items $item
395
410
  end
396
411
  end
@@ -496,7 +511,7 @@ complete -c ${programName} -f -a '(${functionName})'
496
511
  const description = suggestion.description == null ? "" : sanitizeDescription(require_message.formatMessage(suggestion.description, { colors: false }));
497
512
  yield `${suggestion.text}\t${description}`;
498
513
  } else {
499
- const extensions = suggestion.extensions?.join(",") || "";
514
+ const extensions = encodeExtensions(suggestion.extensions);
500
515
  const hidden = suggestion.includeHidden ? "1" : "0";
501
516
  const description = suggestion.description == null ? "" : sanitizeDescription(require_message.formatMessage(suggestion.description, { colors: false }));
502
517
  const pattern = encodePattern(suggestion.pattern ?? "");
@@ -630,13 +645,16 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
630
645
  match $type {
631
646
  "file" => {
632
647
  if ($extensions | is-empty) {
633
- (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file
648
+ (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file or type == dir
634
649
  } else {
635
650
  let ext_list = ($extensions | split row ',')
636
- (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file | where {|f|
651
+ let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
652
+ let dirs = $all_items | where type == dir
653
+ let files = $all_items | where type == file | where {|f|
637
654
  let ext = ($f.name | path parse | get extension)
638
655
  $ext in $ext_list
639
656
  }
657
+ $dirs | append $files
640
658
  }
641
659
  },
642
660
  "directory" => {
@@ -647,8 +665,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
647
665
  if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }
648
666
  } else {
649
667
  let ext_list = ($extensions | split row ',')
650
- let dirs = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == dir
651
- let files = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file | where {|f|
668
+ let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
669
+ let dirs = $all_items | where type == dir
670
+ let files = $all_items | where type == file | where {|f|
652
671
  let ext = ($f.name | path parse | get extension)
653
672
  $ext in $ext_list
654
673
  }
@@ -740,7 +759,7 @@ ${functionName}-external
740
759
  const description = suggestion.description == null ? "" : sanitizeDescription(require_message.formatMessage(suggestion.description, { colors: false }));
741
760
  yield `${suggestion.text}\t${description}`;
742
761
  } else {
743
- const extensions = suggestion.extensions?.join(",") || "";
762
+ const extensions = encodeExtensions(suggestion.extensions);
744
763
  const hidden = suggestion.includeHidden ? "1" : "0";
745
764
  const description = suggestion.description == null ? "" : sanitizeDescription(require_message.formatMessage(suggestion.description, { colors: false }));
746
765
  const pattern = encodePattern(suggestion.pattern ?? "");
@@ -828,15 +847,16 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
828
847
  switch (\$type) {
829
848
  'file' {
830
849
  if (\$extensions) {
831
- # Filter by extensions
850
+ # Filter by extensions, always include directories
832
851
  \$extList = \$extensions -split ','
833
- \$items = Get-ChildItem @forceParam -File -Path "\${prefix}*" -ErrorAction SilentlyContinue |
852
+ \$items = Get-ChildItem @forceParam -Path "\${prefix}*" -ErrorAction SilentlyContinue |
834
853
  Where-Object {
854
+ if (\$_.PSIsContainer) { return \$true }
835
855
  \$ext = \$_.Extension
836
856
  \$extList | ForEach-Object { if (\$ext -eq ".\$_") { return \$true } }
837
857
  }
838
858
  } else {
839
- \$items = Get-ChildItem @forceParam -File -Path "\${prefix}*" -ErrorAction SilentlyContinue
859
+ \$items = Get-ChildItem @forceParam -Path "\${prefix}*" -ErrorAction SilentlyContinue
840
860
  }
841
861
  }
842
862
  'directory' {
@@ -844,15 +864,14 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
844
864
  }
845
865
  'any' {
846
866
  if (\$extensions) {
847
- # Get directories and filtered files
848
- \$dirs = Get-ChildItem @forceParam -Directory -Path "\${prefix}*" -ErrorAction SilentlyContinue
867
+ # Filter by extensions, always include directories
849
868
  \$extList = \$extensions -split ','
850
- \$files = Get-ChildItem @forceParam -File -Path "\${prefix}*" -ErrorAction SilentlyContinue |
869
+ \$items = Get-ChildItem @forceParam -Path "\${prefix}*" -ErrorAction SilentlyContinue |
851
870
  Where-Object {
871
+ if (\$_.PSIsContainer) { return \$true }
852
872
  \$ext = \$_.Extension
853
873
  \$extList | ForEach-Object { if (\$ext -eq ".\$_") { return \$true } }
854
874
  }
855
- \$items = \$dirs + \$files
856
875
  } else {
857
876
  \$items = Get-ChildItem @forceParam -Path "\${prefix}*" -ErrorAction SilentlyContinue
858
877
  }
@@ -910,7 +929,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
910
929
  const description = suggestion.description == null ? "" : sanitizeDescription(require_message.formatMessage(suggestion.description, { colors: false }));
911
930
  yield `${suggestion.text}\t${suggestion.text}\t${description}`;
912
931
  } else {
913
- const extensions = suggestion.extensions?.join(",") || "";
932
+ const extensions = encodeExtensions(suggestion.extensions);
914
933
  const hidden = suggestion.includeHidden ? "1" : "0";
915
934
  const description = suggestion.description == null ? "" : sanitizeDescription(require_message.formatMessage(suggestion.description, { colors: false }));
916
935
  const pattern = encodePattern(suggestion.pattern ?? "");
@@ -40,6 +40,9 @@ function validateProgramName(programName) {
40
40
  function encodePattern(pattern) {
41
41
  return pattern.replace(/%/g, "%25").replace(/:/g, "%3A");
42
42
  }
43
+ function encodeExtensions(extensions) {
44
+ return extensions?.map((ext) => ext.replace(/^\./, "")).join(",") ?? "";
45
+ }
43
46
  /**
44
47
  * Replaces control characters that would corrupt shell completion protocols.
45
48
  * Shell completion formats use tabs as field delimiters and newlines as record
@@ -114,17 +117,25 @@ function _${programName} () {
114
117
  # Generate file completions based on type
115
118
  case "$type" in
116
119
  file)
117
- # Complete files only
120
+ # Complete files and directories (directories for navigation)
118
121
  if [[ -n "$extensions" ]]; then
119
- # Complete with extension filtering
122
+ # Files with extension filtering + directories
120
123
  local ext_pattern="\${extensions//,/|}"
121
- for file in "$__glob_current"*; do
122
- [[ -f "$file" && "$file" =~ \\.($ext_pattern)$ ]] && COMPREPLY+=("$file")
124
+ for item in "$__glob_current"*; do
125
+ if [[ -d "$item" ]]; then
126
+ COMPREPLY+=("$item/")
127
+ elif [[ -f "$item" && "$item" =~ \\.($ext_pattern)$ ]]; then
128
+ COMPREPLY+=("$item")
129
+ fi
123
130
  done
124
131
  else
125
- # Complete files only, exclude directories
132
+ # Complete files and directories for navigation
126
133
  for item in "$__glob_current"*; do
127
- [[ -f "$item" ]] && COMPREPLY+=("$item")
134
+ if [[ -d "$item" ]]; then
135
+ COMPREPLY+=("$item/")
136
+ elif [[ -f "$item" ]]; then
137
+ COMPREPLY+=("$item")
138
+ fi
128
139
  done
129
140
  fi
130
141
  ;;
@@ -203,7 +214,7 @@ complete -F _${programName} -- ${programName}
203
214
  if (i > 0) yield "\n";
204
215
  if (suggestion.kind === "literal") yield `${suggestion.text}`;
205
216
  else {
206
- const extensions = suggestion.extensions?.join(",") || "";
217
+ const extensions = encodeExtensions(suggestion.extensions);
207
218
  const hidden = suggestion.includeHidden ? "1" : "0";
208
219
  const pattern = encodePattern(suggestion.pattern ?? "");
209
220
  yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}`;
@@ -271,11 +282,11 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
271
282
  case "\$type" in
272
283
  file)
273
284
  if [[ -n "\$extensions" ]]; then
274
- # Complete files with extension filtering
285
+ # Complete files with extension filtering + directories for navigation
275
286
  local ext_pattern="*.(\$\{extensions//,/|\})"
276
- _files -g "\$ext_pattern"
287
+ _files -g "\$ext_pattern"; _directories
277
288
  else
278
- _files -g "*"
289
+ _files
279
290
  fi
280
291
  ;;
281
292
  directory)
@@ -285,7 +296,7 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
285
296
  if [[ -n "\$extensions" ]]; then
286
297
  # Complete both files and directories, with extension filtering for files
287
298
  local ext_pattern="*.(\$\{extensions//,/|\})"
288
- _files -g "\$ext_pattern" && _directories
299
+ _files -g "\$ext_pattern"; _directories
289
300
  else
290
301
  _files
291
302
  fi
@@ -328,7 +339,7 @@ compdef _${programName.replace(/[^a-zA-Z0-9]/g, "_")} ${programName}
328
339
  const description = suggestion.description == null ? "" : sanitizeDescription(formatMessage(suggestion.description, { colors: false }));
329
340
  yield `${suggestion.text}\0${description}\0`;
330
341
  } else {
331
- const extensions = suggestion.extensions?.join(",") || "";
342
+ const extensions = encodeExtensions(suggestion.extensions);
332
343
  const hidden = suggestion.includeHidden ? "1" : "0";
333
344
  const description = suggestion.description == null ? "" : sanitizeDescription(formatMessage(suggestion.description, { colors: false }));
334
345
  const pattern = encodePattern(suggestion.pattern ?? "");
@@ -375,9 +386,11 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
375
386
  set -l items
376
387
  switch $type
377
388
  case file
378
- # Complete files only
389
+ # Complete files and directories (directories for navigation)
379
390
  for item in $current*
380
- if test -f $item
391
+ if test -d $item
392
+ set -a items $item/
393
+ else if test -f $item
381
394
  set -a items $item
382
395
  end
383
396
  end
@@ -390,7 +403,9 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
390
403
  if test "$hidden" = "1"
391
404
  if test -z "$current"; or string match -q '*/' -- "$current"
392
405
  for item in $current.*
393
- if test -f $item
406
+ if test -d $item
407
+ set -a items $item/
408
+ else if test -f $item
394
409
  set -a items $item
395
410
  end
396
411
  end
@@ -496,7 +511,7 @@ complete -c ${programName} -f -a '(${functionName})'
496
511
  const description = suggestion.description == null ? "" : sanitizeDescription(formatMessage(suggestion.description, { colors: false }));
497
512
  yield `${suggestion.text}\t${description}`;
498
513
  } else {
499
- const extensions = suggestion.extensions?.join(",") || "";
514
+ const extensions = encodeExtensions(suggestion.extensions);
500
515
  const hidden = suggestion.includeHidden ? "1" : "0";
501
516
  const description = suggestion.description == null ? "" : sanitizeDescription(formatMessage(suggestion.description, { colors: false }));
502
517
  const pattern = encodePattern(suggestion.pattern ?? "");
@@ -630,13 +645,16 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
630
645
  match $type {
631
646
  "file" => {
632
647
  if ($extensions | is-empty) {
633
- (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file
648
+ (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file or type == dir
634
649
  } else {
635
650
  let ext_list = ($extensions | split row ',')
636
- (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file | where {|f|
651
+ let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
652
+ let dirs = $all_items | where type == dir
653
+ let files = $all_items | where type == file | where {|f|
637
654
  let ext = ($f.name | path parse | get extension)
638
655
  $ext in $ext_list
639
656
  }
657
+ $dirs | append $files
640
658
  }
641
659
  },
642
660
  "directory" => {
@@ -647,8 +665,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
647
665
  if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }
648
666
  } else {
649
667
  let ext_list = ($extensions | split row ',')
650
- let dirs = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == dir
651
- let files = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file | where {|f|
668
+ let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
669
+ let dirs = $all_items | where type == dir
670
+ let files = $all_items | where type == file | where {|f|
652
671
  let ext = ($f.name | path parse | get extension)
653
672
  $ext in $ext_list
654
673
  }
@@ -740,7 +759,7 @@ ${functionName}-external
740
759
  const description = suggestion.description == null ? "" : sanitizeDescription(formatMessage(suggestion.description, { colors: false }));
741
760
  yield `${suggestion.text}\t${description}`;
742
761
  } else {
743
- const extensions = suggestion.extensions?.join(",") || "";
762
+ const extensions = encodeExtensions(suggestion.extensions);
744
763
  const hidden = suggestion.includeHidden ? "1" : "0";
745
764
  const description = suggestion.description == null ? "" : sanitizeDescription(formatMessage(suggestion.description, { colors: false }));
746
765
  const pattern = encodePattern(suggestion.pattern ?? "");
@@ -828,15 +847,16 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
828
847
  switch (\$type) {
829
848
  'file' {
830
849
  if (\$extensions) {
831
- # Filter by extensions
850
+ # Filter by extensions, always include directories
832
851
  \$extList = \$extensions -split ','
833
- \$items = Get-ChildItem @forceParam -File -Path "\${prefix}*" -ErrorAction SilentlyContinue |
852
+ \$items = Get-ChildItem @forceParam -Path "\${prefix}*" -ErrorAction SilentlyContinue |
834
853
  Where-Object {
854
+ if (\$_.PSIsContainer) { return \$true }
835
855
  \$ext = \$_.Extension
836
856
  \$extList | ForEach-Object { if (\$ext -eq ".\$_") { return \$true } }
837
857
  }
838
858
  } else {
839
- \$items = Get-ChildItem @forceParam -File -Path "\${prefix}*" -ErrorAction SilentlyContinue
859
+ \$items = Get-ChildItem @forceParam -Path "\${prefix}*" -ErrorAction SilentlyContinue
840
860
  }
841
861
  }
842
862
  'directory' {
@@ -844,15 +864,14 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
844
864
  }
845
865
  'any' {
846
866
  if (\$extensions) {
847
- # Get directories and filtered files
848
- \$dirs = Get-ChildItem @forceParam -Directory -Path "\${prefix}*" -ErrorAction SilentlyContinue
867
+ # Filter by extensions, always include directories
849
868
  \$extList = \$extensions -split ','
850
- \$files = Get-ChildItem @forceParam -File -Path "\${prefix}*" -ErrorAction SilentlyContinue |
869
+ \$items = Get-ChildItem @forceParam -Path "\${prefix}*" -ErrorAction SilentlyContinue |
851
870
  Where-Object {
871
+ if (\$_.PSIsContainer) { return \$true }
852
872
  \$ext = \$_.Extension
853
873
  \$extList | ForEach-Object { if (\$ext -eq ".\$_") { return \$true } }
854
874
  }
855
- \$items = \$dirs + \$files
856
875
  } else {
857
876
  \$items = Get-ChildItem @forceParam -Path "\${prefix}*" -ErrorAction SilentlyContinue
858
877
  }
@@ -910,7 +929,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
910
929
  const description = suggestion.description == null ? "" : sanitizeDescription(formatMessage(suggestion.description, { colors: false }));
911
930
  yield `${suggestion.text}\t${suggestion.text}\t${description}`;
912
931
  } else {
913
- const extensions = suggestion.extensions?.join(",") || "";
932
+ const extensions = encodeExtensions(suggestion.extensions);
914
933
  const hidden = suggestion.includeHidden ? "1" : "0";
915
934
  const description = suggestion.description == null ? "" : sanitizeDescription(formatMessage(suggestion.description, { colors: false }));
916
935
  const pattern = encodePattern(suggestion.pattern ?? "");
package/dist/facade.cjs CHANGED
@@ -727,6 +727,61 @@ function validateVersionValue(value$1) {
727
727
  if (/[\x00-\x1f\x7f]/.test(value$1)) throw new TypeError("Version value must not contain control characters.");
728
728
  return value$1;
729
729
  }
730
+ /**
731
+ * Escapes control characters in a string for display in error messages.
732
+ *
733
+ * @param value The string to escape.
734
+ * @returns The escaped string with control characters replaced by escape
735
+ * sequences.
736
+ */
737
+ function escapeControlChars(value$1) {
738
+ return value$1.replace(/[\x00-\x1f\x7f]/g, (ch) => {
739
+ const code = ch.charCodeAt(0);
740
+ switch (code) {
741
+ case 9: return "\\t";
742
+ case 10: return "\\n";
743
+ case 13: return "\\r";
744
+ default: return `\\x${code.toString(16).padStart(2, "0")}`;
745
+ }
746
+ });
747
+ }
748
+ /**
749
+ * Validates meta option names at runtime.
750
+ *
751
+ * @param names The option names to validate.
752
+ * @param label A human-readable label for error messages (e.g.,
753
+ * `"Help option"`).
754
+ * @throws {TypeError} If the names array is empty, or any name is empty,
755
+ * lacks a valid prefix, or contains whitespace or control characters.
756
+ */
757
+ function validateOptionNames(names, label) {
758
+ if (names.length === 0) throw new TypeError(`Expected at least one ${label.toLowerCase()} name.`);
759
+ for (const name of names) {
760
+ if (name === "") throw new TypeError(`${label} name must not be empty.`);
761
+ if (/^\s+$/.test(name)) throw new TypeError(`${label} name must not be whitespace-only: "${escapeControlChars(name)}".`);
762
+ if (/[\x00-\x1f\x7f]/.test(name)) throw new TypeError(`${label} name must not contain control characters: "${escapeControlChars(name)}".`);
763
+ if (/\s/.test(name)) throw new TypeError(`${label} name must not contain whitespace: "${escapeControlChars(name)}".`);
764
+ if (!/^(--|[-/+])/.test(name)) throw new TypeError(`${label} name must start with "--", "-", "/", or "+": "${name}".`);
765
+ }
766
+ }
767
+ /**
768
+ * Validates meta command names at runtime.
769
+ *
770
+ * @param names The command names to validate.
771
+ * @param label A human-readable label for error messages (e.g.,
772
+ * `"Help command"`).
773
+ * @throws {TypeError} If the names array is empty, or any name is empty,
774
+ * whitespace-only, or contains whitespace or control characters.
775
+ */
776
+ function validateCommandNames(names, label) {
777
+ if (names.length === 0) throw new TypeError(`Expected at least one ${label.toLowerCase()} name.`);
778
+ for (const name of names) {
779
+ if (name === "") throw new TypeError(`${label} name must not be empty.`);
780
+ if (/^\s+$/.test(name)) throw new TypeError(`${label} name must not be whitespace-only: "${escapeControlChars(name)}".`);
781
+ if (/[\x00-\x1f\x7f]/.test(name)) throw new TypeError(`${label} name must not contain control characters: "${escapeControlChars(name)}".`);
782
+ if (/\s/.test(name)) throw new TypeError(`${label} name must not contain whitespace: "${escapeControlChars(name)}".`);
783
+ }
784
+ }
730
785
  function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsParam) {
731
786
  const isProgram = typeof programNameOrArgs !== "string";
732
787
  let parser;
@@ -770,6 +825,12 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
770
825
  const onCompletion = options.completion?.onShow ?? (() => ({}));
771
826
  const onCompletionResult = (code) => onCompletion(code);
772
827
  const onErrorResult = (code) => onError(code);
828
+ if (helpOptionConfig?.names) validateOptionNames(helpOptionConfig.names, "Help option");
829
+ if (helpCommandConfig?.names) validateCommandNames(helpCommandConfig.names, "Help command");
830
+ if (versionOptionConfig?.names) validateOptionNames(versionOptionConfig.names, "Version option");
831
+ if (versionCommandConfig?.names) validateCommandNames(versionCommandConfig.names, "Version command");
832
+ if (completionOptionConfig?.names) validateOptionNames(completionOptionConfig.names, "Completion option");
833
+ if (completionCommandConfig?.names) validateCommandNames(completionCommandConfig.names, "Completion command");
773
834
  const helpOptionNames = helpOptionConfig?.names ?? ["--help"];
774
835
  const helpCommandNames = helpCommandConfig?.names ?? ["help"];
775
836
  const versionOptionNames = versionOptionConfig?.names ?? ["--version"];
package/dist/facade.d.cts CHANGED
@@ -273,7 +273,10 @@ interface RunOptions<THelp, TError> {
273
273
  * @returns The parsed result value, or the return value of `onHelp`/`onError`
274
274
  * callbacks.
275
275
  * @throws {TypeError} If `options.version.value` is not a non-empty string
276
- * without ASCII control characters.
276
+ * without ASCII control characters, or if any meta command/option
277
+ * name is empty, whitespace-only, contains whitespace or control
278
+ * characters, or (for option names) lacks a valid prefix (`--`,
279
+ * `-`, `/`, or `+`).
277
280
  * @throws {RunParserError} When parsing fails and no `onError` callback is
278
281
  * provided.
279
282
  * @since 0.10.0 Added support for {@link Program} objects.
package/dist/facade.d.ts CHANGED
@@ -273,7 +273,10 @@ interface RunOptions<THelp, TError> {
273
273
  * @returns The parsed result value, or the return value of `onHelp`/`onError`
274
274
  * callbacks.
275
275
  * @throws {TypeError} If `options.version.value` is not a non-empty string
276
- * without ASCII control characters.
276
+ * without ASCII control characters, or if any meta command/option
277
+ * name is empty, whitespace-only, contains whitespace or control
278
+ * characters, or (for option names) lacks a valid prefix (`--`,
279
+ * `-`, `/`, or `+`).
277
280
  * @throws {RunParserError} When parsing fails and no `onError` callback is
278
281
  * provided.
279
282
  * @since 0.10.0 Added support for {@link Program} objects.
package/dist/facade.js CHANGED
@@ -727,6 +727,61 @@ function validateVersionValue(value$1) {
727
727
  if (/[\x00-\x1f\x7f]/.test(value$1)) throw new TypeError("Version value must not contain control characters.");
728
728
  return value$1;
729
729
  }
730
+ /**
731
+ * Escapes control characters in a string for display in error messages.
732
+ *
733
+ * @param value The string to escape.
734
+ * @returns The escaped string with control characters replaced by escape
735
+ * sequences.
736
+ */
737
+ function escapeControlChars(value$1) {
738
+ return value$1.replace(/[\x00-\x1f\x7f]/g, (ch) => {
739
+ const code = ch.charCodeAt(0);
740
+ switch (code) {
741
+ case 9: return "\\t";
742
+ case 10: return "\\n";
743
+ case 13: return "\\r";
744
+ default: return `\\x${code.toString(16).padStart(2, "0")}`;
745
+ }
746
+ });
747
+ }
748
+ /**
749
+ * Validates meta option names at runtime.
750
+ *
751
+ * @param names The option names to validate.
752
+ * @param label A human-readable label for error messages (e.g.,
753
+ * `"Help option"`).
754
+ * @throws {TypeError} If the names array is empty, or any name is empty,
755
+ * lacks a valid prefix, or contains whitespace or control characters.
756
+ */
757
+ function validateOptionNames(names, label) {
758
+ if (names.length === 0) throw new TypeError(`Expected at least one ${label.toLowerCase()} name.`);
759
+ for (const name of names) {
760
+ if (name === "") throw new TypeError(`${label} name must not be empty.`);
761
+ if (/^\s+$/.test(name)) throw new TypeError(`${label} name must not be whitespace-only: "${escapeControlChars(name)}".`);
762
+ if (/[\x00-\x1f\x7f]/.test(name)) throw new TypeError(`${label} name must not contain control characters: "${escapeControlChars(name)}".`);
763
+ if (/\s/.test(name)) throw new TypeError(`${label} name must not contain whitespace: "${escapeControlChars(name)}".`);
764
+ if (!/^(--|[-/+])/.test(name)) throw new TypeError(`${label} name must start with "--", "-", "/", or "+": "${name}".`);
765
+ }
766
+ }
767
+ /**
768
+ * Validates meta command names at runtime.
769
+ *
770
+ * @param names The command names to validate.
771
+ * @param label A human-readable label for error messages (e.g.,
772
+ * `"Help command"`).
773
+ * @throws {TypeError} If the names array is empty, or any name is empty,
774
+ * whitespace-only, or contains whitespace or control characters.
775
+ */
776
+ function validateCommandNames(names, label) {
777
+ if (names.length === 0) throw new TypeError(`Expected at least one ${label.toLowerCase()} name.`);
778
+ for (const name of names) {
779
+ if (name === "") throw new TypeError(`${label} name must not be empty.`);
780
+ if (/^\s+$/.test(name)) throw new TypeError(`${label} name must not be whitespace-only: "${escapeControlChars(name)}".`);
781
+ if (/[\x00-\x1f\x7f]/.test(name)) throw new TypeError(`${label} name must not contain control characters: "${escapeControlChars(name)}".`);
782
+ if (/\s/.test(name)) throw new TypeError(`${label} name must not contain whitespace: "${escapeControlChars(name)}".`);
783
+ }
784
+ }
730
785
  function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsParam) {
731
786
  const isProgram = typeof programNameOrArgs !== "string";
732
787
  let parser;
@@ -770,6 +825,12 @@ function runParser(parserOrProgram, programNameOrArgs, argsOrOptions, optionsPar
770
825
  const onCompletion = options.completion?.onShow ?? (() => ({}));
771
826
  const onCompletionResult = (code) => onCompletion(code);
772
827
  const onErrorResult = (code) => onError(code);
828
+ if (helpOptionConfig?.names) validateOptionNames(helpOptionConfig.names, "Help option");
829
+ if (helpCommandConfig?.names) validateCommandNames(helpCommandConfig.names, "Help command");
830
+ if (versionOptionConfig?.names) validateOptionNames(versionOptionConfig.names, "Version option");
831
+ if (versionCommandConfig?.names) validateCommandNames(versionCommandConfig.names, "Version command");
832
+ if (completionOptionConfig?.names) validateOptionNames(completionOptionConfig.names, "Completion option");
833
+ if (completionCommandConfig?.names) validateCommandNames(completionCommandConfig.names, "Completion command");
773
834
  const helpOptionNames = helpOptionConfig?.names ?? ["--help"];
774
835
  const helpCommandNames = helpCommandConfig?.names ?? ["help"];
775
836
  const versionOptionNames = versionOptionConfig?.names ?? ["--version"];
@@ -453,10 +453,28 @@ function float(options = {}) {
453
453
  * object.
454
454
  * @param options Configuration options for the URL parser.
455
455
  * @returns A {@link ValueParser} that converts string input to `URL` objects.
456
+ * @throws {TypeError} If any `allowedProtocols` entry is not a valid protocol
457
+ * string ending with a colon (e.g., `"https:"`).
456
458
  */
457
459
  function url(options = {}) {
458
- const originalProtocols = options.allowedProtocols != null ? Object.freeze([...options.allowedProtocols]) : void 0;
459
- const allowedProtocols = options.allowedProtocols != null ? Object.freeze(options.allowedProtocols.map((p) => p.toLowerCase())) : void 0;
460
+ const originalProtocolsList = [];
461
+ const normalizedProtocolsList = [];
462
+ if (options.allowedProtocols != null) {
463
+ const seen = /* @__PURE__ */ new Set();
464
+ for (const protocol of options.allowedProtocols) {
465
+ if (typeof protocol !== "string" || !/^[a-z][a-z0-9+\-.]*:$/i.test(protocol)) {
466
+ const rendered = typeof protocol === "string" ? JSON.stringify(protocol) : String(protocol);
467
+ throw new TypeError(`Each allowed protocol must be a valid protocol ending with a colon (e.g., "https:"), got: ${rendered}.`);
468
+ }
469
+ const normalized = protocol.toLowerCase();
470
+ if (seen.has(normalized)) continue;
471
+ seen.add(normalized);
472
+ originalProtocolsList.push(protocol);
473
+ normalizedProtocolsList.push(normalized);
474
+ }
475
+ }
476
+ const originalProtocols = options.allowedProtocols != null ? Object.freeze(originalProtocolsList) : void 0;
477
+ const allowedProtocols = options.allowedProtocols != null ? Object.freeze(normalizedProtocolsList) : void 0;
460
478
  const metavar = options.metavar ?? "URL";
461
479
  require_nonempty.ensureNonEmptyString(metavar);
462
480
  const invalidUrl = options.errors?.invalidUrl;
@@ -491,6 +491,8 @@ interface UrlOptions {
491
491
  * object.
492
492
  * @param options Configuration options for the URL parser.
493
493
  * @returns A {@link ValueParser} that converts string input to `URL` objects.
494
+ * @throws {TypeError} If any `allowedProtocols` entry is not a valid protocol
495
+ * string ending with a colon (e.g., `"https:"`).
494
496
  */
495
497
  declare function url(options?: UrlOptions): ValueParser<"sync", URL>;
496
498
  /**
@@ -491,6 +491,8 @@ interface UrlOptions {
491
491
  * object.
492
492
  * @param options Configuration options for the URL parser.
493
493
  * @returns A {@link ValueParser} that converts string input to `URL` objects.
494
+ * @throws {TypeError} If any `allowedProtocols` entry is not a valid protocol
495
+ * string ending with a colon (e.g., `"https:"`).
494
496
  */
495
497
  declare function url(options?: UrlOptions): ValueParser<"sync", URL>;
496
498
  /**
@@ -453,10 +453,28 @@ function float(options = {}) {
453
453
  * object.
454
454
  * @param options Configuration options for the URL parser.
455
455
  * @returns A {@link ValueParser} that converts string input to `URL` objects.
456
+ * @throws {TypeError} If any `allowedProtocols` entry is not a valid protocol
457
+ * string ending with a colon (e.g., `"https:"`).
456
458
  */
457
459
  function url(options = {}) {
458
- const originalProtocols = options.allowedProtocols != null ? Object.freeze([...options.allowedProtocols]) : void 0;
459
- const allowedProtocols = options.allowedProtocols != null ? Object.freeze(options.allowedProtocols.map((p) => p.toLowerCase())) : void 0;
460
+ const originalProtocolsList = [];
461
+ const normalizedProtocolsList = [];
462
+ if (options.allowedProtocols != null) {
463
+ const seen = /* @__PURE__ */ new Set();
464
+ for (const protocol of options.allowedProtocols) {
465
+ if (typeof protocol !== "string" || !/^[a-z][a-z0-9+\-.]*:$/i.test(protocol)) {
466
+ const rendered = typeof protocol === "string" ? JSON.stringify(protocol) : String(protocol);
467
+ throw new TypeError(`Each allowed protocol must be a valid protocol ending with a colon (e.g., "https:"), got: ${rendered}.`);
468
+ }
469
+ const normalized = protocol.toLowerCase();
470
+ if (seen.has(normalized)) continue;
471
+ seen.add(normalized);
472
+ originalProtocolsList.push(protocol);
473
+ normalizedProtocolsList.push(normalized);
474
+ }
475
+ }
476
+ const originalProtocols = options.allowedProtocols != null ? Object.freeze(originalProtocolsList) : void 0;
477
+ const allowedProtocols = options.allowedProtocols != null ? Object.freeze(normalizedProtocolsList) : void 0;
460
478
  const metavar = options.metavar ?? "URL";
461
479
  ensureNonEmptyString(metavar);
462
480
  const invalidUrl = options.errors?.invalidUrl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/core",
3
- "version": "1.0.0-dev.1129+30064af5",
3
+ "version": "1.0.0-dev.1155+7532f28d",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",