@optique/core 1.0.15-dev.2816 → 1.0.15-dev.2824

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.
@@ -902,15 +902,13 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
902
902
  # Normalize leading ./ so that ./src/ma matches pattern src/
903
903
  set -l __norm_current (string replace -r '^\\./' '' -- "$current")
904
904
  set -l __norm_pattern (string replace -r '^\\./' '' -- "$pattern")
905
- # For wildcard patterns, compare only the directory prefix
905
+ # For wildcard patterns, compare only the directory prefix.
906
+ # Use a regex: fish wildcards have no character classes.
907
+ # Only the basename can hold wildcards; a ? or * in a
908
+ # directory name such as What?/ is literal.
906
909
  set -l __compare_pattern "$__norm_pattern"
907
- if string match -q '*[*?]*' -- "$__compare_pattern"
908
- set __compare_pattern (string replace -r '[*?].*' '' -- "$__compare_pattern")
909
- if string match -q '*/*' -- "$__compare_pattern"
910
- set __compare_pattern (string replace -r '/[^/]*$' '/' -- "$__compare_pattern")
911
- else
912
- set __compare_pattern ""
913
- end
910
+ if string match -qr '[*?][^/]*$' -- "$__compare_pattern"
911
+ set __compare_pattern (string replace -r '[^/]*$' '' -- "$__compare_pattern")
914
912
  end
915
913
  set -l __cp_len (string length -- "$__compare_pattern")
916
914
  set -l __nc_len (string length -- "$__norm_current")
@@ -945,54 +943,82 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
945
943
  end
946
944
 
947
945
  # Pre-expand file candidates. When the glob base came from
948
- # the program's pattern and contains * or ?, use it as the
949
- # complete glob expression. Otherwise append * as a prefix.
946
+ # the program's pattern and its basename contains * or ?, use
947
+ # it as the complete glob expression. Otherwise append * as a
948
+ # prefix, so a literal ? in a directory name such as What?/
949
+ # still lists that directory. A pattern equal to the current
950
+ # word is the typed text echoed back (as path() does), so its
951
+ # ? and * are literal characters, not wildcards.
950
952
  # Note: [ is NOT treated as a glob because it commonly
951
953
  # appears in literal filenames like [draft] or foo[1].txt.
952
954
  set -l __has_glob 0
953
955
  if test $__from_pattern -eq 1
954
- and string match -q '*[*?]*' -- "$glob_base"
956
+ and string match -qr '[*?][^/]*$' -- "$glob_base"
957
+ and test "$pattern" != "$current"
955
958
  set __has_glob 1
956
959
  end
957
960
  set -l __candidates
958
961
  if test $__has_glob -eq 1
959
962
  # Safe glob expansion without eval: split the pattern into
960
- # directory and filter parts, list the directory, then use
961
- # string match for wildcard filtering
962
- set -l __glob_dir (string replace -r '/[^/]*$' '' -- "$glob_base")
963
+ # a directory prefix and a basename filter, list the
964
+ # directory, then match basenames against a regex built
965
+ # from the filter. The prefix keeps one empty element for
966
+ # bare patterns so that $__glob_prefix* still expands.
967
+ set -l __glob_prefix ""
968
+ if string match -q '*/*' -- "$glob_base"
969
+ set __glob_prefix (string replace -r '[^/]*$' '' -- "$glob_base")
970
+ end
963
971
  set -l __glob_filter (string replace -r '.*/' '' -- "$glob_base")
964
- if test -z "$__glob_dir"; or test "$__glob_dir" = "$glob_base"
965
- set __glob_dir "."
972
+ # Translate the filter into an anchored regex: * and ? are
973
+ # wildcards and every other character is literal. Fish's
974
+ # own wildcards cannot be used here because ? is literal
975
+ # when the qmark-noglob feature is enabled (fish 4 default).
976
+ set -l __glob_regex '^'
977
+ for __ch in (string split '' -- "$__glob_filter")
978
+ if test "$__ch" = '*'
979
+ set __glob_regex "$__glob_regex.*"
980
+ else if test "$__ch" = '?'
981
+ set __glob_regex "$__glob_regex."
982
+ else
983
+ set __glob_regex "$__glob_regex"(string escape --style=regex -- "$__ch")
984
+ end
966
985
  end
986
+ set __glob_regex "$__glob_regex"'$'
967
987
  # Match files by the glob filter. Fish's * does not match
968
- # dotfiles, so also scan .* when the filter targets them.
969
- for __item in $__glob_dir/*
988
+ # dotfiles, so also scan .* when the filter targets them or
989
+ # hidden files are requested.
990
+ set -l __entries $__glob_prefix*
991
+ if test "$hidden" = "1"; or string match -q '.*' -- "$__glob_filter"
992
+ set -a __entries $__glob_prefix.*
993
+ end
994
+ for __item in $__entries
970
995
  if test -e "$__item"
971
- set -l __bn (basename -- "$__item")
972
- if string match -q -- "$__glob_filter" "$__bn"
973
- set -a __candidates "$__item"
996
+ set -l __bn (string replace -r '.*/' '' -- "$__item")
997
+ if contains -- "$__bn" . ..
998
+ continue
974
999
  end
975
- end
976
- end
977
- if string match -q '.*' -- "$__glob_filter"
978
- for __item in $__glob_dir/.*
979
- if test -e "$__item"
980
- set -l __bn (basename -- "$__item")
981
- if test "$__bn" = "." -o "$__bn" = ".."
982
- continue
983
- end
984
- if string match -q -- "$__glob_filter" "$__bn"
985
- set -a __candidates "$__item"
986
- end
1000
+ if string match -qr -- "$__glob_regex" "$__bn"
1001
+ set -a __candidates "$__item"
987
1002
  end
988
1003
  end
989
1004
  end
990
1005
  # For file/any modes, also include directories for navigation.
991
1006
  # Skip for directory mode so the pattern filter is respected.
1007
+ # Directories the filter already matched are not repeated.
992
1008
  if test "$type" != "directory"
993
- for __item in $__glob_dir/*/
1009
+ set -l __nav_dirs $__glob_prefix*/
1010
+ if test "$hidden" = "1"
1011
+ set -a __nav_dirs $__glob_prefix.*/
1012
+ end
1013
+ for __item in $__nav_dirs
1014
+ set __item (string replace -r '/$' '' -- "$__item")
1015
+ set -l __bn (string replace -r '.*/' '' -- "$__item")
1016
+ if contains -- "$__bn" . ..
1017
+ continue
1018
+ end
994
1019
  if test -d "$__item"
995
- set -a __candidates (string replace -r '/$' '' -- "$__item")
1020
+ and not string match -qr -- "$__glob_regex" "$__bn"
1021
+ set -a __candidates "$__item"
996
1022
  end
997
1023
  end
998
1024
  end
@@ -1174,66 +1200,149 @@ const nu = {
1174
1200
  const safeName = programName.replace(/[^a-zA-Z0-9]+/g, "-");
1175
1201
  const functionName = `nu-complete-${safeName}`;
1176
1202
  return `
1177
- # Helper to split args respecting quotes and whitespace
1203
+ # Helper to split a command line into the arguments Nushell would pass to
1204
+ # an external command. Words are found the way Nushell's lexer finds them
1205
+ # (quotes and raw strings may contain whitespace), then each word is decoded
1206
+ # like an external argument. A final empty argument is added when the line
1207
+ # ends in unquoted whitespace, for the word about to be completed.
1178
1208
  def args-split []: string -> list<string> {
1179
- let STATE_NORMAL = 0
1180
- let STATE_IN_SINGLE_QUOTE = 1
1181
- let STATE_IN_DOUBLE_QUOTE = 2
1182
- let STATE_ESCAPE = 3
1183
1209
  let WHITESPACES = [" " "\\t" "\\n" "\\r"]
1210
+ let QUOTES = ["'" '"' "\`"]
1184
1211
 
1185
- mut state = $STATE_NORMAL
1186
- mut current_token = ""
1187
- mut result: list<string> = []
1188
- mut prev_state = $STATE_NORMAL
1189
- mut ends_with_delimiter = false
1212
+ # Decodes one word. An unterminated word is the one still being typed,
1213
+ # so its open quote is taken as if it were closed.
1214
+ let decode = {|word: string, unterminated: bool|
1215
+ let hashes = if ($word | str starts-with "r#") {
1216
+ $word | str replace -r '(?s)^r(#+).*$' '$1'
1217
+ } else {
1218
+ ""
1219
+ }
1220
+ if ($hashes | is-not-empty) {
1221
+ let prefix = "r" + $hashes + "'"
1222
+ let suffix = "'" + $hashes
1223
+ let size = ($hashes | str length)
1224
+ if ($word | str starts-with $prefix) and ($word | str ends-with $suffix) and (($word | str length) >= ($size * 2 + 3)) {
1225
+ $word | str replace $prefix "" | str replace -r ($suffix + '$') ""
1226
+ } else if $unterminated and ($word | str starts-with $prefix) {
1227
+ $word | str replace $prefix ""
1228
+ } else {
1229
+ $word
1230
+ }
1231
+ } else {
1232
+ mut result = ""
1233
+ mut quote = ""
1234
+ mut quoted = ""
1235
+ mut escaped = false
1236
+ for char in ($word | split chars) {
1237
+ if $quote == "" {
1238
+ if ($char in $QUOTES) {
1239
+ $quote = $char
1240
+ $quoted = ""
1241
+ } else {
1242
+ $result = $result + $char
1243
+ }
1244
+ } else if $escaped {
1245
+ $quoted = $quoted + $char
1246
+ $escaped = false
1247
+ } else if $char == $quote {
1248
+ let body = $quoted
1249
+ $result = $result + (if $quote == '"' {
1250
+ try { '"' + $body + '"' | from nuon } catch { $body }
1251
+ } else {
1252
+ $body
1253
+ })
1254
+ $quote = ""
1255
+ } else {
1256
+ $quoted = $quoted + $char
1257
+ $escaped = ($quote == '"' and $char == '\\')
1258
+ }
1259
+ }
1260
+ if $quote != "" {
1261
+ let body = $quoted
1262
+ $result = $result + (if not $unterminated {
1263
+ $quote + $body
1264
+ } else if $quote == '"' {
1265
+ try { '"' + $body + '"' | from nuon } catch { $body }
1266
+ } else {
1267
+ $body
1268
+ })
1269
+ }
1270
+ $result
1271
+ }
1272
+ }
1273
+
1274
+ mut words: list<string> = []
1275
+ mut word = ""
1276
+ mut in_word = false
1277
+ mut quote = ""
1278
+ mut escaped = false
1279
+ mut raw_hashes = ""
1280
+ mut raw_pending = ""
1281
+ mut prev = ""
1282
+ mut trailing_space = false
1190
1283
 
1191
1284
  for char in ($in | split chars) {
1192
- $ends_with_delimiter = false
1193
- if $state == $STATE_ESCAPE {
1194
- $current_token = $current_token + $char
1195
- $state = $prev_state
1196
- } else if $char == '\\\\' {
1197
- $prev_state = $state
1198
- $state = $STATE_ESCAPE
1199
- } else if $state == $STATE_NORMAL {
1200
- if $char == "'" {
1201
- $state = $STATE_IN_SINGLE_QUOTE
1202
- } else if $char == '"' {
1203
- $state = $STATE_IN_DOUBLE_QUOTE
1204
- } else if ($char in $WHITESPACES) {
1205
- $ends_with_delimiter = true
1206
- if ($current_token | is-not-empty) {
1207
- $result = $result | append $current_token
1208
- $current_token = ""
1209
- }
1285
+ $trailing_space = false
1286
+ if $quote == "#" and $char != "#" and $char != "'" {
1287
+ # Not a raw string after all; lex this character normally
1288
+ $quote = ""
1289
+ }
1290
+ if $quote == "r" {
1291
+ # Inside a raw string, which ends at ' followed by the same number of #
1292
+ if ($raw_pending | is-not-empty) and $char == "#" {
1293
+ $raw_pending = $raw_pending + $char
1294
+ } else if $char == "'" {
1295
+ $raw_pending = $char
1210
1296
  } else {
1211
- $current_token = $current_token + $char
1297
+ $raw_pending = ""
1298
+ }
1299
+ if $raw_pending == ("'" + $raw_hashes) {
1300
+ $quote = ""
1301
+ $raw_pending = ""
1212
1302
  }
1213
- } else if $state == $STATE_IN_SINGLE_QUOTE {
1214
- if $char == "'" {
1215
- $state = $STATE_NORMAL
1303
+ $word = $word + $char
1304
+ } else if $quote == "#" {
1305
+ # Between r# and the opening ' of a raw string
1306
+ $word = $word + $char
1307
+ if $char == "#" {
1308
+ $raw_hashes = $raw_hashes + $char
1216
1309
  } else {
1217
- $current_token = $current_token + $char
1310
+ $quote = "r"
1218
1311
  }
1219
- } else if $state == $STATE_IN_DOUBLE_QUOTE {
1220
- if $char == '"' {
1221
- $state = $STATE_NORMAL
1312
+ } else if $quote != "" {
1313
+ $word = $word + $char
1314
+ if $escaped {
1315
+ $escaped = false
1316
+ } else if $char == $quote {
1317
+ $quote = ""
1222
1318
  } else {
1223
- $current_token = $current_token + $char
1319
+ $escaped = ($quote == '"' and $char == '\\')
1224
1320
  }
1321
+ } else if ($char in $WHITESPACES) {
1322
+ if $in_word {
1323
+ $words = $words | append (do $decode $word false)
1324
+ $word = ""
1325
+ $in_word = false
1326
+ }
1327
+ $trailing_space = true
1328
+ } else {
1329
+ if ($char in $QUOTES) {
1330
+ $quote = $char
1331
+ } else if $char == "#" and $prev == "r" {
1332
+ $quote = "#"
1333
+ $raw_hashes = "#"
1334
+ }
1335
+ $word = $word + $char
1336
+ $in_word = true
1225
1337
  }
1338
+ $prev = $char
1226
1339
  }
1227
- # A quote left open at the end starts the argument being completed, even
1228
- # when nothing follows it yet
1229
- if ($current_token | is-not-empty) or ($state in [$STATE_IN_SINGLE_QUOTE $STATE_IN_DOUBLE_QUOTE]) {
1230
- $result = $result | append $current_token
1231
- }
1232
- # Whitespace outside quotes at the end starts a new, still empty argument
1233
- if $ends_with_delimiter {
1234
- $result = $result | append ''
1340
+ if $in_word {
1341
+ $words = $words | append (do $decode $word ($quote != ""))
1342
+ } else if $trailing_space {
1343
+ $words = $words | append ""
1235
1344
  }
1236
- $result
1345
+ $words
1237
1346
  }
1238
1347
 
1239
1348
  # Helper to tell whether Nushell would not read a value back verbatim as a
@@ -1272,7 +1381,8 @@ def quote-arg []: string -> string {
1272
1381
 
1273
1382
  # Completion command that calls back to the CLI
1274
1383
  export def "${functionName}" [context: string] {
1275
- # Split context into tokens, handling quotes properly
1384
+ # Split context into the arguments Nushell would pass, including an empty
1385
+ # one for a new word when the context ends in unquoted whitespace
1276
1386
  let tokens = $context | args-split
1277
1387
 
1278
1388
  # Remove the command name (first token) to get arguments
@@ -902,15 +902,13 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
902
902
  # Normalize leading ./ so that ./src/ma matches pattern src/
903
903
  set -l __norm_current (string replace -r '^\\./' '' -- "$current")
904
904
  set -l __norm_pattern (string replace -r '^\\./' '' -- "$pattern")
905
- # For wildcard patterns, compare only the directory prefix
905
+ # For wildcard patterns, compare only the directory prefix.
906
+ # Use a regex: fish wildcards have no character classes.
907
+ # Only the basename can hold wildcards; a ? or * in a
908
+ # directory name such as What?/ is literal.
906
909
  set -l __compare_pattern "$__norm_pattern"
907
- if string match -q '*[*?]*' -- "$__compare_pattern"
908
- set __compare_pattern (string replace -r '[*?].*' '' -- "$__compare_pattern")
909
- if string match -q '*/*' -- "$__compare_pattern"
910
- set __compare_pattern (string replace -r '/[^/]*$' '/' -- "$__compare_pattern")
911
- else
912
- set __compare_pattern ""
913
- end
910
+ if string match -qr '[*?][^/]*$' -- "$__compare_pattern"
911
+ set __compare_pattern (string replace -r '[^/]*$' '' -- "$__compare_pattern")
914
912
  end
915
913
  set -l __cp_len (string length -- "$__compare_pattern")
916
914
  set -l __nc_len (string length -- "$__norm_current")
@@ -945,54 +943,82 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
945
943
  end
946
944
 
947
945
  # Pre-expand file candidates. When the glob base came from
948
- # the program's pattern and contains * or ?, use it as the
949
- # complete glob expression. Otherwise append * as a prefix.
946
+ # the program's pattern and its basename contains * or ?, use
947
+ # it as the complete glob expression. Otherwise append * as a
948
+ # prefix, so a literal ? in a directory name such as What?/
949
+ # still lists that directory. A pattern equal to the current
950
+ # word is the typed text echoed back (as path() does), so its
951
+ # ? and * are literal characters, not wildcards.
950
952
  # Note: [ is NOT treated as a glob because it commonly
951
953
  # appears in literal filenames like [draft] or foo[1].txt.
952
954
  set -l __has_glob 0
953
955
  if test $__from_pattern -eq 1
954
- and string match -q '*[*?]*' -- "$glob_base"
956
+ and string match -qr '[*?][^/]*$' -- "$glob_base"
957
+ and test "$pattern" != "$current"
955
958
  set __has_glob 1
956
959
  end
957
960
  set -l __candidates
958
961
  if test $__has_glob -eq 1
959
962
  # Safe glob expansion without eval: split the pattern into
960
- # directory and filter parts, list the directory, then use
961
- # string match for wildcard filtering
962
- set -l __glob_dir (string replace -r '/[^/]*$' '' -- "$glob_base")
963
+ # a directory prefix and a basename filter, list the
964
+ # directory, then match basenames against a regex built
965
+ # from the filter. The prefix keeps one empty element for
966
+ # bare patterns so that $__glob_prefix* still expands.
967
+ set -l __glob_prefix ""
968
+ if string match -q '*/*' -- "$glob_base"
969
+ set __glob_prefix (string replace -r '[^/]*$' '' -- "$glob_base")
970
+ end
963
971
  set -l __glob_filter (string replace -r '.*/' '' -- "$glob_base")
964
- if test -z "$__glob_dir"; or test "$__glob_dir" = "$glob_base"
965
- set __glob_dir "."
972
+ # Translate the filter into an anchored regex: * and ? are
973
+ # wildcards and every other character is literal. Fish's
974
+ # own wildcards cannot be used here because ? is literal
975
+ # when the qmark-noglob feature is enabled (fish 4 default).
976
+ set -l __glob_regex '^'
977
+ for __ch in (string split '' -- "$__glob_filter")
978
+ if test "$__ch" = '*'
979
+ set __glob_regex "$__glob_regex.*"
980
+ else if test "$__ch" = '?'
981
+ set __glob_regex "$__glob_regex."
982
+ else
983
+ set __glob_regex "$__glob_regex"(string escape --style=regex -- "$__ch")
984
+ end
966
985
  end
986
+ set __glob_regex "$__glob_regex"'$'
967
987
  # Match files by the glob filter. Fish's * does not match
968
- # dotfiles, so also scan .* when the filter targets them.
969
- for __item in $__glob_dir/*
988
+ # dotfiles, so also scan .* when the filter targets them or
989
+ # hidden files are requested.
990
+ set -l __entries $__glob_prefix*
991
+ if test "$hidden" = "1"; or string match -q '.*' -- "$__glob_filter"
992
+ set -a __entries $__glob_prefix.*
993
+ end
994
+ for __item in $__entries
970
995
  if test -e "$__item"
971
- set -l __bn (basename -- "$__item")
972
- if string match -q -- "$__glob_filter" "$__bn"
973
- set -a __candidates "$__item"
996
+ set -l __bn (string replace -r '.*/' '' -- "$__item")
997
+ if contains -- "$__bn" . ..
998
+ continue
974
999
  end
975
- end
976
- end
977
- if string match -q '.*' -- "$__glob_filter"
978
- for __item in $__glob_dir/.*
979
- if test -e "$__item"
980
- set -l __bn (basename -- "$__item")
981
- if test "$__bn" = "." -o "$__bn" = ".."
982
- continue
983
- end
984
- if string match -q -- "$__glob_filter" "$__bn"
985
- set -a __candidates "$__item"
986
- end
1000
+ if string match -qr -- "$__glob_regex" "$__bn"
1001
+ set -a __candidates "$__item"
987
1002
  end
988
1003
  end
989
1004
  end
990
1005
  # For file/any modes, also include directories for navigation.
991
1006
  # Skip for directory mode so the pattern filter is respected.
1007
+ # Directories the filter already matched are not repeated.
992
1008
  if test "$type" != "directory"
993
- for __item in $__glob_dir/*/
1009
+ set -l __nav_dirs $__glob_prefix*/
1010
+ if test "$hidden" = "1"
1011
+ set -a __nav_dirs $__glob_prefix.*/
1012
+ end
1013
+ for __item in $__nav_dirs
1014
+ set __item (string replace -r '/$' '' -- "$__item")
1015
+ set -l __bn (string replace -r '.*/' '' -- "$__item")
1016
+ if contains -- "$__bn" . ..
1017
+ continue
1018
+ end
994
1019
  if test -d "$__item"
995
- set -a __candidates (string replace -r '/$' '' -- "$__item")
1020
+ and not string match -qr -- "$__glob_regex" "$__bn"
1021
+ set -a __candidates "$__item"
996
1022
  end
997
1023
  end
998
1024
  end
@@ -1174,66 +1200,149 @@ const nu = {
1174
1200
  const safeName = programName.replace(/[^a-zA-Z0-9]+/g, "-");
1175
1201
  const functionName = `nu-complete-${safeName}`;
1176
1202
  return `
1177
- # Helper to split args respecting quotes and whitespace
1203
+ # Helper to split a command line into the arguments Nushell would pass to
1204
+ # an external command. Words are found the way Nushell's lexer finds them
1205
+ # (quotes and raw strings may contain whitespace), then each word is decoded
1206
+ # like an external argument. A final empty argument is added when the line
1207
+ # ends in unquoted whitespace, for the word about to be completed.
1178
1208
  def args-split []: string -> list<string> {
1179
- let STATE_NORMAL = 0
1180
- let STATE_IN_SINGLE_QUOTE = 1
1181
- let STATE_IN_DOUBLE_QUOTE = 2
1182
- let STATE_ESCAPE = 3
1183
1209
  let WHITESPACES = [" " "\\t" "\\n" "\\r"]
1210
+ let QUOTES = ["'" '"' "\`"]
1184
1211
 
1185
- mut state = $STATE_NORMAL
1186
- mut current_token = ""
1187
- mut result: list<string> = []
1188
- mut prev_state = $STATE_NORMAL
1189
- mut ends_with_delimiter = false
1212
+ # Decodes one word. An unterminated word is the one still being typed,
1213
+ # so its open quote is taken as if it were closed.
1214
+ let decode = {|word: string, unterminated: bool|
1215
+ let hashes = if ($word | str starts-with "r#") {
1216
+ $word | str replace -r '(?s)^r(#+).*$' '$1'
1217
+ } else {
1218
+ ""
1219
+ }
1220
+ if ($hashes | is-not-empty) {
1221
+ let prefix = "r" + $hashes + "'"
1222
+ let suffix = "'" + $hashes
1223
+ let size = ($hashes | str length)
1224
+ if ($word | str starts-with $prefix) and ($word | str ends-with $suffix) and (($word | str length) >= ($size * 2 + 3)) {
1225
+ $word | str replace $prefix "" | str replace -r ($suffix + '$') ""
1226
+ } else if $unterminated and ($word | str starts-with $prefix) {
1227
+ $word | str replace $prefix ""
1228
+ } else {
1229
+ $word
1230
+ }
1231
+ } else {
1232
+ mut result = ""
1233
+ mut quote = ""
1234
+ mut quoted = ""
1235
+ mut escaped = false
1236
+ for char in ($word | split chars) {
1237
+ if $quote == "" {
1238
+ if ($char in $QUOTES) {
1239
+ $quote = $char
1240
+ $quoted = ""
1241
+ } else {
1242
+ $result = $result + $char
1243
+ }
1244
+ } else if $escaped {
1245
+ $quoted = $quoted + $char
1246
+ $escaped = false
1247
+ } else if $char == $quote {
1248
+ let body = $quoted
1249
+ $result = $result + (if $quote == '"' {
1250
+ try { '"' + $body + '"' | from nuon } catch { $body }
1251
+ } else {
1252
+ $body
1253
+ })
1254
+ $quote = ""
1255
+ } else {
1256
+ $quoted = $quoted + $char
1257
+ $escaped = ($quote == '"' and $char == '\\')
1258
+ }
1259
+ }
1260
+ if $quote != "" {
1261
+ let body = $quoted
1262
+ $result = $result + (if not $unterminated {
1263
+ $quote + $body
1264
+ } else if $quote == '"' {
1265
+ try { '"' + $body + '"' | from nuon } catch { $body }
1266
+ } else {
1267
+ $body
1268
+ })
1269
+ }
1270
+ $result
1271
+ }
1272
+ }
1273
+
1274
+ mut words: list<string> = []
1275
+ mut word = ""
1276
+ mut in_word = false
1277
+ mut quote = ""
1278
+ mut escaped = false
1279
+ mut raw_hashes = ""
1280
+ mut raw_pending = ""
1281
+ mut prev = ""
1282
+ mut trailing_space = false
1190
1283
 
1191
1284
  for char in ($in | split chars) {
1192
- $ends_with_delimiter = false
1193
- if $state == $STATE_ESCAPE {
1194
- $current_token = $current_token + $char
1195
- $state = $prev_state
1196
- } else if $char == '\\\\' {
1197
- $prev_state = $state
1198
- $state = $STATE_ESCAPE
1199
- } else if $state == $STATE_NORMAL {
1200
- if $char == "'" {
1201
- $state = $STATE_IN_SINGLE_QUOTE
1202
- } else if $char == '"' {
1203
- $state = $STATE_IN_DOUBLE_QUOTE
1204
- } else if ($char in $WHITESPACES) {
1205
- $ends_with_delimiter = true
1206
- if ($current_token | is-not-empty) {
1207
- $result = $result | append $current_token
1208
- $current_token = ""
1209
- }
1285
+ $trailing_space = false
1286
+ if $quote == "#" and $char != "#" and $char != "'" {
1287
+ # Not a raw string after all; lex this character normally
1288
+ $quote = ""
1289
+ }
1290
+ if $quote == "r" {
1291
+ # Inside a raw string, which ends at ' followed by the same number of #
1292
+ if ($raw_pending | is-not-empty) and $char == "#" {
1293
+ $raw_pending = $raw_pending + $char
1294
+ } else if $char == "'" {
1295
+ $raw_pending = $char
1210
1296
  } else {
1211
- $current_token = $current_token + $char
1297
+ $raw_pending = ""
1298
+ }
1299
+ if $raw_pending == ("'" + $raw_hashes) {
1300
+ $quote = ""
1301
+ $raw_pending = ""
1212
1302
  }
1213
- } else if $state == $STATE_IN_SINGLE_QUOTE {
1214
- if $char == "'" {
1215
- $state = $STATE_NORMAL
1303
+ $word = $word + $char
1304
+ } else if $quote == "#" {
1305
+ # Between r# and the opening ' of a raw string
1306
+ $word = $word + $char
1307
+ if $char == "#" {
1308
+ $raw_hashes = $raw_hashes + $char
1216
1309
  } else {
1217
- $current_token = $current_token + $char
1310
+ $quote = "r"
1218
1311
  }
1219
- } else if $state == $STATE_IN_DOUBLE_QUOTE {
1220
- if $char == '"' {
1221
- $state = $STATE_NORMAL
1312
+ } else if $quote != "" {
1313
+ $word = $word + $char
1314
+ if $escaped {
1315
+ $escaped = false
1316
+ } else if $char == $quote {
1317
+ $quote = ""
1222
1318
  } else {
1223
- $current_token = $current_token + $char
1319
+ $escaped = ($quote == '"' and $char == '\\')
1224
1320
  }
1321
+ } else if ($char in $WHITESPACES) {
1322
+ if $in_word {
1323
+ $words = $words | append (do $decode $word false)
1324
+ $word = ""
1325
+ $in_word = false
1326
+ }
1327
+ $trailing_space = true
1328
+ } else {
1329
+ if ($char in $QUOTES) {
1330
+ $quote = $char
1331
+ } else if $char == "#" and $prev == "r" {
1332
+ $quote = "#"
1333
+ $raw_hashes = "#"
1334
+ }
1335
+ $word = $word + $char
1336
+ $in_word = true
1225
1337
  }
1338
+ $prev = $char
1226
1339
  }
1227
- # A quote left open at the end starts the argument being completed, even
1228
- # when nothing follows it yet
1229
- if ($current_token | is-not-empty) or ($state in [$STATE_IN_SINGLE_QUOTE $STATE_IN_DOUBLE_QUOTE]) {
1230
- $result = $result | append $current_token
1231
- }
1232
- # Whitespace outside quotes at the end starts a new, still empty argument
1233
- if $ends_with_delimiter {
1234
- $result = $result | append ''
1340
+ if $in_word {
1341
+ $words = $words | append (do $decode $word ($quote != ""))
1342
+ } else if $trailing_space {
1343
+ $words = $words | append ""
1235
1344
  }
1236
- $result
1345
+ $words
1237
1346
  }
1238
1347
 
1239
1348
  # Helper to tell whether Nushell would not read a value back verbatim as a
@@ -1272,7 +1381,8 @@ def quote-arg []: string -> string {
1272
1381
 
1273
1382
  # Completion command that calls back to the CLI
1274
1383
  export def "${functionName}" [context: string] {
1275
- # Split context into tokens, handling quotes properly
1384
+ # Split context into the arguments Nushell would pass, including an empty
1385
+ # one for a new word when the context ends in unquoted whitespace
1276
1386
  let tokens = $context | args-split
1277
1387
 
1278
1388
  # Remove the command name (first token) to get arguments
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/core",
3
- "version": "1.0.15-dev.2816",
3
+ "version": "1.0.15-dev.2824",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",