diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_completion.nu b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_completion.nu new file mode 100644 index 0000000000..3bb22b34f6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_completion.nu @@ -0,0 +1,59 @@ +# Nushell 0.116 deprecates a positional completer input such as `context` (#3127). +# Nushell writes the warning to stderr, so complete in a child process. +let lines = [ + "vp en", + "ls | vp en", + "echo \"it's okay\" | vp en", + "vpr ", + "ls | vpr ", + "do {\n let x = 1\n vpr ", +] +let script = $"source env.nu +($lines | to nuon) | each {|line| $line | commandline complete } | to json --raw" +let result = ^$nu.current-exe -n -c $script | complete +if $result.exit_code != 0 { + error make { msg: $"completion failed: ($result.stderr)" } +} +if ($result.stderr | str contains "deprecated") { + error make { msg: $"completion printed a deprecation warning: ($result.stderr)" } +} + +let completions = $result.stdout | from json +for index in [0 1 2] { + let values = $completions | get $index | each {|value| $value | str trim } + if "env" not-in $values { + error make { msg: $"`($lines | get $index)` did not complete `env`, got: ($values)" } + } +} + +# `buffer` includes the earlier commands. Without the `vpr` translation, fish completes file names. +for index in [4 5] { + if ($completions | get $index) != ($completions | get 3) { + error make { msg: $"`($lines | get $index)` completed differently from `vpr `, got: ($completions | get $index)" } + } +} + +# Nushell 0.115 and earlier pass the current command and the cursor position by position. +# CI has only a newer Nushell, so call the completers in that form directly. +# An empty command makes fish list every command, which also includes `env`, so compare +# each result with the completion of the same line above. +let legacy_script = "source env.nu +[ + (nu-complete vp 'vp en' 5 | get value) + (nu-complete vpr 'vpr ' 4 | get value) +] | to json --raw" +let legacy_result = ^$nu.current-exe -n -c $legacy_script | complete +if $legacy_result.exit_code != 0 { + error make { msg: $"legacy completion failed: ($legacy_result.stderr)" } +} + +let legacy = $legacy_result.stdout | from json +for pair in [[0 0] [1 3]] { + let expected = $completions | get $pair.1 | each {|value| $value | str trim } | sort + let got = $legacy | get $pair.0 | each {|value| $value | str trim } | sort + if $got != $expected { + error make { msg: $"legacy `($lines | get $pair.1)` completed differently, got: ($got)" } + } +} + +print "Nushell completer checks passed" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_shadowed.nu b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_shadowed.nu index ecba751f54..bafb52d94d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_shadowed.nu +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_shadowed.nu @@ -18,6 +18,8 @@ def prepend [] { } def append [] { } def "str starts-with" [] { } def "str replace" [] { } +def "str join" [] { } +def describe [] { } def "path expand" [] { } def "path exists" [] { } def "from tsv" [] { } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots.toml index 60ce3e035b..babf9401fe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots.toml @@ -31,3 +31,14 @@ steps = [ { argv = ["vpt", "cp", "assert_shadowed.nu", "home/assert_shadowed.nu"], snapshot = false }, { argv = ["nu", "assert_shadowed.nu"], cwd = "home", comment = "env.nu loads and runs `vp env use` when user commands shadow the built-ins it calls (#3092)", envs = [["VP_HOME", "${workspace}/home"]] }, ] + +[[case]] +name = "command_env_nushell_completer_input" +vp = "global" +skip-platforms = ["windows"] +requires = ["nu", "fish"] +steps = [ + { argv = ["vp", "env", "setup", "--refresh"], envs = [["VP_HOME", "${workspace}/home"]], snapshot = false }, + { argv = ["vpt", "cp", "assert_completion.nu", "home/assert_completion.nu"], snapshot = false }, + { argv = ["nu", "assert_completion.nu"], cwd = "home", comment = "env.nu completers complete `vp` after a pipeline and do not use the positional input that Nushell 0.116 deprecates (#3127)", envs = [["VP_HOME", "${workspace}/home"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell_completer_input.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell_completer_input.md new file mode 100644 index 0000000000..05923e5da8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell_completer_input.md @@ -0,0 +1,15 @@ +# command_env_nushell_completer_input + +## `VP_HOME=${workspace}/home vp env setup --refresh` + + +## `vpt cp assert_completion.nu home/assert_completion.nu` + + +## `cd home && VP_HOME=${workspace}/home nu assert_completion.nu` + +env.nu completers complete `vp` after a pipeline and do not use the positional input that Nushell 0.116 deprecates (#3127) + +``` +Nushell completer checks passed +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md index 390ff6fe9f..4ffc1c657a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md @@ -248,9 +248,19 @@ def --env --wrapped vp [...args: string@"nu-complete vp"] { } # Shell completion for nushell (delegates to fish completions dynamically) -def "nu-complete vp" [context: string] { - let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($context)'" - ^fish --command $fish_cmd | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| +# Nushell 0.116+ binds `buffer` and `place` by name: `buffer` is the whole line up to the cursor, +# and `place.command` is the words of the current command up to the cursor. Older releases pass +# the current command in `buffer` and the cursor position in `place`. See "Unified custom +# completions" in https://www.nushell.sh/blog/2026-09-26-nushell_v0_116_0.html#unified-custom-completions +# `place` is `any` because it is an int or a record. `oneof` needs Nushell 0.105+, +# and every Nushell release must parse the legacy env.nu. +def "nu-complete vp current-command" [buffer: string, place: any] { + if (($place | %describe) starts-with "record") { $place.command | %str join ' ' } else { $buffer } +} +# Fish gets the command as an argument, so quotes in it cannot end a fish string. +def "nu-complete vp fish" [command: string] { + let fish_script = 'VP_COMPLETE=fish command vp | source; complete --do-complete=$argv[1]' + ^fish --command $fish_script $command | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| let value = $row.value let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | %any {$in in $value} if ($need_quote and ($value | %path exists)) { @@ -259,8 +269,12 @@ def "nu-complete vp" [context: string] { } else {$value} } } +def "nu-complete vp" [buffer: string, place: any] { + nu-complete vp fish (nu-complete vp current-command $buffer $place) +} # Completion logic for vpr (translates context to 'vp run ...') -def "nu-complete vpr" [context: string] { +def "nu-complete vpr" [buffer: string, place: any] { + let context = nu-complete vp current-command $buffer $place let modified_context = if ($context =~ '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { $context | %str replace -r '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' } else if ($context =~ '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { @@ -270,15 +284,7 @@ def "nu-complete vpr" [context: string] { } else { $context | %str replace -r '^vpr' 'vp run' } - let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($modified_context)'" - ^fish --command $fish_cmd | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| - let value = $row.value - let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | %any {$in in $value} - if ($need_quote and ($value | %path exists)) { - let expanded_path = if ($value starts-with ~) {$value | %path expand --no-symlink} else {$value} - $'"($expanded_path | %str replace --all "\"" "\\\"")"' - } else {$value} - } + nu-complete vp fish $modified_context } export extern "vpr" [...args: string@"nu-complete vpr"] ``` diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 176ee3691d..90339f898f 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1009,9 +1009,19 @@ def --env --wrapped vp [...args: string@"nu-complete vp"] { } # Shell completion for nushell (delegates to fish completions dynamically) -def "nu-complete vp" [context: string] { - let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($context)'" - ^fish --command $fish_cmd | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| +# Nushell 0.116+ binds `buffer` and `place` by name: `buffer` is the whole line up to the cursor, +# and `place.command` is the words of the current command up to the cursor. Older releases pass +# the current command in `buffer` and the cursor position in `place`. See "Unified custom +# completions" in https://www.nushell.sh/blog/2026-09-26-nushell_v0_116_0.html#unified-custom-completions +# `place` is `any` because it is an int or a record. `oneof` needs Nushell 0.105+, +# and every Nushell release must parse the legacy env.nu. +def "nu-complete vp current-command" [buffer: string, place: any] { + if (($place | %describe) starts-with "record") { $place.command | %str join ' ' } else { $buffer } +} +# Fish gets the command as an argument, so quotes in it cannot end a fish string. +def "nu-complete vp fish" [command: string] { + let fish_script = 'VP_COMPLETE=fish command vp | source; complete --do-complete=$argv[1]' + ^fish --command $fish_script $command | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| let value = $row.value let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | %any {$in in $value} if ($need_quote and ($value | %path exists)) { @@ -1020,8 +1030,12 @@ def "nu-complete vp" [context: string] { } else {$value} } } +def "nu-complete vp" [buffer: string, place: any] { + nu-complete vp fish (nu-complete vp current-command $buffer $place) +} # Completion logic for vpr (translates context to 'vp run ...') -def "nu-complete vpr" [context: string] { +def "nu-complete vpr" [buffer: string, place: any] { + let context = nu-complete vp current-command $buffer $place let modified_context = if ($context =~ '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { $context | %str replace -r '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' } else if ($context =~ '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { @@ -1031,15 +1045,7 @@ def "nu-complete vpr" [context: string] { } else { $context | %str replace -r '^vpr' 'vp run' } - let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($modified_context)'" - ^fish --command $fish_cmd | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| - let value = $row.value - let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | %any {$in in $value} - if ($need_quote and ($value | %path exists)) { - let expanded_path = if ($value starts-with ~) {$value | %path expand --no-symlink} else {$value} - $'"($expanded_path | %str replace --all "\"" "\\\"")"' - } else {$value} - } + nu-complete vp fish $modified_context } export extern "vpr" [...args: string@"nu-complete vpr"] "#; @@ -1968,6 +1974,10 @@ mod tests { assert!(sigil.contains("| %update value {|row|"), "got: {sigil}"); assert!(sigil.contains(r#"("/tmp/vp%home" | %path expand"#), "got: {sigil}"); assert!(sigil.contains("^fish --command"), "got: {sigil}"); + // Completers must not cause the Nushell 0.116 positional-input warning (#3127). + assert!(sigil.contains(r#"def "nu-complete vp" [buffer: string, place: any]"#)); + assert!(sigil.contains(r#"def "nu-complete vpr" [buffer: string, place: any]"#)); + assert!(!sigil.contains("[context"), "got: {sigil}"); let legacy = render_env_content(EnvShell::Nu, &config, NuSyntax::Legacy); assert!(legacy.contains("| update value {|row|"), "got: {legacy}");