diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert-arguments.ps1 b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert-arguments.ps1 new file mode 100644 index 0000000000..cbf5a0cac0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert-arguments.ps1 @@ -0,0 +1,75 @@ +# The PowerShell script binder consumes a bare -- before populating $args. +# Exercise the generated wrapper with a real native child, without a registry. +function Assert-Arguments($actual, $expected) { + if ($LASTEXITCODE -ne 0 -or ($actual -join "`n") -ne $expected) { + throw "Expected arguments $expected, got $actual (exit $LASTEXITCODE)" + } +} + +Assert-Arguments (vp env exec node print-args.cjs -- --template react-ts) '["--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs '--' --template react-ts) '["--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs "--" --template react-ts) '["--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs '--' -- --template react-ts) '["--","--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs -- -- --template react-ts) '["--","--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs --) '["--"]' +Assert-Arguments (vp env exec node print-args.cjs 'value with spaces' -- --template 'react ts') '["value with spaces","--","--template","react ts"]' + +# The stop-parsing token preserves its literal remainder without consuming --. +# Capture on a separate line because --% makes the rest of the line literal. +$literalArguments = vp env exec node print-args.cjs --% -- +Assert-Arguments $literalArguments '["--"]' +$literalArguments = vp env exec node print-args.cjs --% -- -- +Assert-Arguments $literalArguments '["--","--"]' +$literalArguments = vp env exec node print-args.cjs -- --% -- +Assert-Arguments $literalArguments '["--","--"]' + +# A quoted --% allows recovery; escaping its first dash still stops parsing. +$literalArguments = vp env exec node print-args.cjs '--%' -- after +Assert-Arguments $literalArguments '["--","after"]' +$literalArguments = vp env exec node print-args.cjs `--% -- +Assert-Arguments $literalArguments '["--"]' + +$prefix = @('env', 'exec', 'node', 'print-args.cjs') +$suffix = @('--template', 'react-ts') +$allArguments = $prefix + @('--') + $suffix +Assert-Arguments (vp env exec node print-args.cjs -- @suffix) '["--","--template","react-ts"]' +Assert-Arguments (vp @allArguments) '["--","--template","react-ts"]' +Assert-Arguments (vp @prefix '--' @suffix) '["--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs @('before', 'separator') -- @('after', 'separator')) '["before","separator","--","after","separator"]' + +# A splat before -- makes its position unknowable from syntax alone. Preserve +# PowerShell's bound arguments in that case instead of guessing an insertion point. +Assert-Arguments (vp @prefix -- --template react-ts) '["--template","react-ts"]' +Assert-Arguments (vp @prefix -- @suffix) '["--template","react-ts"]' +$emptyArguments = @() +Assert-Arguments (vp @prefix @emptyArguments -- @emptyArguments @suffix) '["--template","react-ts"]' +$script:scopedPrefix = $prefix +$script:scopedSuffix = $suffix +Assert-Arguments (vp @script:scopedPrefix -- @script:scopedSuffix) '["--template","react-ts"]' +Assert-Arguments (vp @script:scopedPrefix '--' @script:scopedSuffix) '["--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs -- @script:scopedSuffix) '["--","--template","react-ts"]' + +# Argument expressions can change a splatted variable after it has been expanded. +Assert-Arguments (vp @prefix -- @script:scopedSuffix $($script:scopedSuffix = @(); 'last')) '["--template","react-ts","last"]' + +# The caller AST must identify the invocation inside a function or on a later line. +function Invoke-ArgumentCheck { + Assert-Arguments (vp env exec node print-args.cjs -- @suffix) '["--","--template","react-ts"]' +} +Invoke-ArgumentCheck +Assert-Arguments (& vp env exec node print-args.cjs ` + -- ` + --template react-ts) '["--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs first -- second | ForEach-Object { $_ }) '["first","--","second"]' +$first = vp env exec node print-args.cjs one -- two; $second = vp env exec node print-args.cjs three -- four +Assert-Arguments $first '["one","--","two"]' +Assert-Arguments $second '["three","--","four"]' + +# Recovering the separator must not evaluate argument expressions a second time. +$script:argumentEvaluations = 0 +Assert-Arguments (vp env exec node print-args.cjs -- $($script:argumentEvaluations++; 'value with spaces')) '["--","value with spaces"]' +if ($script:argumentEvaluations -ne 1) { throw 'An argument expression ran more than once' } + +Assert-Arguments (vp env exec node print-args.cjs 'text -- with spaces' --template react-ts) '["text -- with spaces","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs `-- --template react-ts) '["--","--template","react-ts"]' +Assert-Arguments (vp env exec node print-args.cjs --template react-ts) '["--template","react-ts"]' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 index af730148d3..f8c5b1cdff 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 @@ -117,6 +117,7 @@ if ($env:VP_NODE_VERSION -ne "22.18.0") { throw "file-based VP_NODE_VERSION mismatch: expected 22.18.0, got $env:VP_NODE_VERSION" } +. (Join-Path $PSScriptRoot "assert-arguments.ps1") . (Join-Path $PSScriptRoot "assert-status.ps1") Write-Output "PowerShell environment checks passed" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/print-args.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/print-args.cjs new file mode 100644 index 0000000000..74e2668db0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/print-args.cjs @@ -0,0 +1 @@ +console.log(JSON.stringify(process.argv.slice(2))); 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 0365e9964b..390ff6fe9f 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 @@ -344,6 +344,48 @@ PowerShell wrapper keeps global -C before env use and propagates the native exit ``` # Vite+ PowerShell wrapper (https://viteplus.dev) $__vp_bin = '/home/bin' +# PowerShell consumes the first bare -- when calling a script. Recover its +# position from the caller's AST, without evaluating argument expressions again. +# The call stack also works on Windows PowerShell 5.1, which has no Statement. +$__vp_stack = @(Get-PSCallStack) +$__vp_command = $null +if ($__vp_stack.Count -gt 1) { + $__vp_ast = $__vp_stack[1].InvocationInfo.MyCommand.ScriptBlock.Ast + $__vp_line = $MyInvocation.ScriptLineNumber + $__vp_column = $MyInvocation.OffsetInLine + if ($__vp_ast) { + $__vp_command = $__vp_ast.Find({ param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.Extent.StartLineNumber -eq $__vp_line -and $node.Extent.StartColumnNumber -eq $__vp_column + }, $true) + } +} +if ($__vp_command) { + $__vp_index = 0 + $__vp_elements = $__vp_command.CommandElements + for ($__vp_i = 1; $__vp_i -lt $__vp_elements.Count; $__vp_i++) { + $__vp_element = $__vp_elements[$__vp_i] + # A preceding splat has an unknown length. Leave $args unchanged rather + # than reading caller variables, which may have changed or belong to another scope. + if ($__vp_element -is [System.Management.Automation.Language.VariableExpressionAst] -and $__vp_element.Splatted) { + break + } + # Only a parameter AST can be consumed. After --%, the separator is literal. + if ($__vp_element -is [System.Management.Automation.Language.CommandParameterAst] -and $__vp_element.Extent.Text -ceq '--') { + if ($__vp_index -le $args.Count) { + $__vp_arguments = [System.Collections.Generic.List[object]]::new() + $__vp_arguments.AddRange($args) + $__vp_arguments.Insert($__vp_index, '--') + $args = $__vp_arguments.ToArray() + } + break + } + $__vp_index++ + if ($__vp_element -is [System.Management.Automation.Language.CommandParameterAst] -and $__vp_element.Argument) { + $__vp_index++ + } + } +} $__vp_command_index = 0 if ($args.Count -ge 1) { if ($args[0] -eq "-C") { diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index c271d0de3a..0c21031342 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1095,8 +1095,52 @@ Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp // Keep the wrapper outside bin so it cannot shadow the native executable. // Unlike an advanced function, this script forwards all arguments without // PowerShell consuming common parameters such as -Verbose or its -V alias. +// Bare -- recovery is inspired by mise's __mise_restore_double_dash: +// https://github.com/jdx/mise/blob/34ec0439c868379be87a28591f50d41f84fb8d02/src/shell/pwsh.rs#L45-L103 const VP_WRAPPER_PS1: &str = r#"# Vite+ PowerShell wrapper (https://viteplus.dev) $__vp_bin = '__VP_BIN_WIN__' +# PowerShell consumes the first bare -- when calling a script. Recover its +# position from the caller's AST, without evaluating argument expressions again. +# The call stack also works on Windows PowerShell 5.1, which has no Statement. +$__vp_stack = @(Get-PSCallStack) +$__vp_command = $null +if ($__vp_stack.Count -gt 1) { + $__vp_ast = $__vp_stack[1].InvocationInfo.MyCommand.ScriptBlock.Ast + $__vp_line = $MyInvocation.ScriptLineNumber + $__vp_column = $MyInvocation.OffsetInLine + if ($__vp_ast) { + $__vp_command = $__vp_ast.Find({ param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.Extent.StartLineNumber -eq $__vp_line -and $node.Extent.StartColumnNumber -eq $__vp_column + }, $true) + } +} +if ($__vp_command) { + $__vp_index = 0 + $__vp_elements = $__vp_command.CommandElements + for ($__vp_i = 1; $__vp_i -lt $__vp_elements.Count; $__vp_i++) { + $__vp_element = $__vp_elements[$__vp_i] + # A preceding splat has an unknown length. Leave $args unchanged rather + # than reading caller variables, which may have changed or belong to another scope. + if ($__vp_element -is [System.Management.Automation.Language.VariableExpressionAst] -and $__vp_element.Splatted) { + break + } + # Only a parameter AST can be consumed. After --%, the separator is literal. + if ($__vp_element -is [System.Management.Automation.Language.CommandParameterAst] -and $__vp_element.Extent.Text -ceq '--') { + if ($__vp_index -le $args.Count) { + $__vp_arguments = [System.Collections.Generic.List[object]]::new() + $__vp_arguments.AddRange($args) + $__vp_arguments.Insert($__vp_index, '--') + $args = $__vp_arguments.ToArray() + } + break + } + $__vp_index++ + if ($__vp_element -is [System.Management.Automation.Language.CommandParameterAst] -and $__vp_element.Argument) { + $__vp_index++ + } + } +} $__vp_command_index = 0 if ($args.Count -ge 1) { if ($args[0] -eq "-C") {