From 620d56f46fdcc3b7d5c5310cfe92be5a359f39f9 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 11 Sep 2026 20:50:56 +0800 Subject: [PATCH] fix(shell): connect PowerShell cd targets to TabExpansion2 Merge configured destinations into ordinary `cd` completion while preserving native matches and replacement spans. Delegate unrelated and AST-based completion unchanged, and retain jump-worktree completion. Closes #74 --- src/shell.rs | 42 ++++++++++++++++- tests/shell_generation.rs | 98 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/shell.rs b/src/shell.rs index 42e9603..58721e2 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -522,7 +522,7 @@ function script:ConvertTo-CjCompletionText {{ }} function script:Complete-CjCdArgument {{ - param([string]$WordToComplete) + param([string]$WordToComplete, [switch]$DestinationsOnly) if (($WordToComplete -ceq '-jw') -or ($WordToComplete -ceq '--jump-worktree')) {{ $configArgs = $script:__cj_config @@ -550,6 +550,7 @@ function script:Complete-CjCdArgument {{ [System.Management.Automation.CompletionResult]::new($completion, $destination, 'ParameterValue', 'cj destination') }} }} + if ($DestinationsOnly) {{ return }} [System.Management.Automation.CompletionCompleters]::CompleteFilename($WordToComplete) | Where-Object ResultType -eq ([System.Management.Automation.CompletionResultType]::ProviderContainer) }} @@ -674,7 +675,44 @@ if (Test-Path Function:TabExpansion2) {{ ) }} }} - return & $script:__cj_tab_expansion2 @PSBoundParameters + $native = & $script:__cj_tab_expansion2 @PSBoundParameters + if ($PSCmdlet.ParameterSetName -cne 'ScriptInputSet') {{ return $native }} + + # The wrapper has no formal Path parameter, so PowerShell does not invoke an + # argument completer for ordinary cd targets. Keep native completion and add + # configured destinations only at the first literal argument's replacement span. + $parsedTokens = $null + $parseErrors = $null + $parsed = [System.Management.Automation.Language.Parser]::ParseInput($beforeCursor, [ref]$parsedTokens, [ref]$parseErrors) + $commands = $parsed.FindAll({{ param($node) $node -is [System.Management.Automation.Language.CommandAst] }}, $true) + foreach ($command in $commands) {{ + if ($command.GetCommandName() -ine 'cd') {{ continue }} + $elements = $command.CommandElements + $word = '' + if ($elements.Count -eq 1) {{ + $tail = $beforeCursor.Substring($command.Extent.EndOffset) + if ($tail -notmatch '^[ \t]+$' -or $native.ReplacementIndex -ne $cursorColumn) {{ continue }} + }} elseif ($elements.Count -eq 2) {{ + $argument = $elements[1] + if ($argument -isnot [System.Management.Automation.Language.StringConstantExpressionAst] -or + $argument.Extent.EndOffset -ne $cursorColumn -or + $argument.Extent.StartOffset -ne $native.ReplacementIndex) {{ continue }} + $word = $argument.Value + if ($word.StartsWith('-')) {{ continue }} + }} else {{ continue }} + + $extra = @(Complete-CjCdArgument $word -DestinationsOnly) + if ($extra.Count -eq 0) {{ return $native }} + $results = [System.Collections.ObjectModel.Collection[System.Management.Automation.CompletionResult]]::new() + foreach ($match in $native.CompletionMatches) {{ [void]$results.Add($match) }} + foreach ($match in $extra) {{ + if ($match.CompletionText -cnotin $results.CompletionText) {{ [void]$results.Add($match) }} + }} + return [System.Management.Automation.CommandCompletion]::new( + $results, $native.CurrentMatchIndex, $native.ReplacementIndex, $native.ReplacementLength + ) + }} + return $native }} }}"# ); diff --git a/tests/shell_generation.rs b/tests/shell_generation.rs index 509b23a..6c756e7 100644 --- a/tests/shell_generation.rs +++ b/tests/shell_generation.rs @@ -316,6 +316,102 @@ if (Get-Command Get-PSReadLineKeyHandler -ErrorAction SilentlyContinue) { ); } +#[test] +fn powershell_cd_tab_completion_merges_configured_and_native_targets() { + if !available("pwsh") { + eprintln!("skipping PowerShell cd completion test: pwsh is unavailable"); + return; + } + let temp = TempDir::new("powershell-cd-completion"); + let destination = temp.path().join("qa native directory"); + fs::create_dir_all(&destination).unwrap(); + let config = temp.path().join("config.toml"); + fs::write( + &config, + format!( + "[keywords]\ntop = [\"qa-top\"]\nmain-worktree = [\"qa-main\"]\n\ + [tickers]\nnavigate_up = \"k\"\nnavigate_down = \"j\"\n\ + [aliases]\n\"qa team\" = \"{}\"\n\ + [mounts.qa-mount]\npath = \"{}\"\n", + toml_string(&destination), + toml_string(&destination), + ), + ) + .unwrap(); + let integration = temp.path().join("init.ps1"); + fs::write( + &integration, + generate(temp.path(), &config, "init", "powershell"), + ) + .unwrap(); + let script = r#"$ErrorActionPreference = 'Stop' +. $env:CJ_INIT_SOURCE +. $env:CJ_INIT_SOURCE +$before = (Get-Location).ProviderPath +function Assert-NativePreserved($line, $result) { + $native = & $script:__cj_tab_expansion2 $line $line.Length + if ($result.ReplacementIndex -ne $native.ReplacementIndex -or $result.ReplacementLength -ne $native.ReplacementLength) { throw "native replacement span changed: $line" } + foreach ($match in $native.CompletionMatches) { + if ($match.CompletionText -cnotin $result.CompletionMatches.CompletionText) { throw "native completion lost: $line" } + } +} +foreach ($line in @('cd ', 'cd qa', 'CD qa', "cd 'qa t", 'cd "qa t', 'Write-Output ok; cd qa')) { + $result = TabExpansion2 $line $line.Length + Assert-NativePreserved $line $result + $names = @($result.CompletionMatches.ListItemText) + $expected = if ($line -eq 'cd ') { @('qa-top', 'qa-main', 'qa team', 'qa-mount', 'k', 'j') } elseif ($line -like '*qa t') { @('qa team') } else { @('qa-top', 'qa-main', 'qa team', 'qa-mount') } + foreach ($name in $expected) { + if (@($names | Where-Object { $_ -ceq $name }).Count -ne 1) { throw "missing or duplicated destination $name in $line" } + } +} +foreach ($ticker in @('k', 'j')) { + $line = 'cd ' + $ticker + if ($ticker -cnotin (TabExpansion2 $line $line.Length).CompletionMatches.ListItemText) { throw "missing ticker $ticker" } +} +$line = 'cd qa' +$result = TabExpansion2 $line $line.Length +if (@($result.CompletionMatches | Where-Object ResultType -EQ ProviderContainer).Count -eq 0) { throw 'native directory completion missing' } +# Complete before a suffix and execute exactly the replacement PowerShell provides. +$line = 'cd qaZZ; $global:CjSuffixRan = 1' +$result = TabExpansion2 $line 5 +$match = @($result.CompletionMatches | Where-Object ListItemText -CEQ 'qa team') +if ($match.Count -ne 1) { throw 'missing completion at middle cursor' } +$completed = $line.Remove($result.ReplacementIndex, $result.ReplacementLength).Insert($result.ReplacementIndex, $match[0].CompletionText) +if ($completed -cne "cd 'qa team'; `$global:CjSuffixRan = 1") { throw "incorrect replacement: $completed" } +if ((Get-Location).ProviderPath -cne $before -or $global:__cj_history.Count -ne 0) { throw 'completion changed directory or history' } +& ([scriptblock]::Create($completed)) +$comparison = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } +if (-not [System.IO.Path]::GetFullPath((Get-Location).ProviderPath).Equals([System.IO.Path]::GetFullPath($env:CJ_TEST_DESTINATION), $comparison) -or $global:CjSuffixRan -ne 1) { throw 'completed alias failed to execute' } +foreach ($line in @('Write-Output qa', 'cd qa qa', 'cd -Path qa', 'cd -j', 'cd qa ', 'cd qa # comment', '"cd qa', 'cd $HOME', 'cd @(qa)', 'cd qa | Write-Output qa')) { + $result = TabExpansion2 $line $line.Length -options @{ IgnoreHidden = $true } + $native = & $script:__cj_tab_expansion2 $line $line.Length -options @{ IgnoreHidden = $true } + if (($result | ConvertTo-Json -Depth 5 -Compress) -cne ($native | ConvertTo-Json -Depth 5 -Compress)) { throw "completion was not delegated: $line" } +} +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseInput('cd qa', [ref]$tokens, [ref]$errors) +$params = @{ ast = $ast; tokens = $tokens; positionOfCursor = $ast.Extent.EndScriptPosition; options = @{} } +$result = TabExpansion2 @params +$native = & $script:__cj_tab_expansion2 @params +if (($result | ConvertTo-Json -Depth 5 -Compress) -cne ($native | ConvertTo-Json -Depth 5 -Compress)) { throw 'AST completion was not delegated' } +"#; + let output = Command::new("pwsh") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]) + .current_dir(temp.path()) + .env("PATH", path_with_cj()) + .env("HOME", temp.path()) + .env("CJ_INIT_SOURCE", &integration) + .env("CJ_TEST_DESTINATION", &destination) + .output() + .unwrap(); + assert_success(&output); +} + #[test] fn powershell_smart_quotes_round_trip_in_source_and_completions() { if !available("pwsh") { @@ -395,7 +491,7 @@ foreach ($command in @('cj', 'cd')) { foreach ($expected in $aliases) { $line = $command + ' ' + $expected.Substring(0, 2) $candidates = if ($command -ceq 'cd') { - Complete-CjCdArgument $expected.Substring(0, 2) + (TabExpansion2 $line $line.Length).CompletionMatches } else { [System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null).CompletionMatches }