Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions src/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::collections::BTreeSet;

use crate::cli::Shell;
use crate::config::Config;
use crate::powershell::quote as quote_ps;

const FLAGS: &[&str] = &[
"-C",
Expand Down Expand Up @@ -401,7 +402,7 @@ fn powershell(destinations: &[String]) -> String {
}}

$candidates | Where-Object {{ $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) }} | ForEach-Object {{
$completionText = if ($_ -match '[^\p{{L}}\p{{N}}_./:\\-]') {{ "'" + $_.Replace("'", "''") + "'" }} else {{ $_ }}
$completionText = if ($_ -match '[^\p{{L}}\p{{N}}_./:\\-]') {{ "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($_) + "'" }} else {{ $_ }}
[System.Management.Automation.CompletionResult]::new($completionText, $_, 'ParameterValue', $_)
}}
}}"#
Expand Down Expand Up @@ -451,10 +452,6 @@ fn quote_nu(value: &str) -> String {
unreachable!()
}

fn quote_ps(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
6 changes: 3 additions & 3 deletions src/key_bindings.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Foreground line-editor widgets for ordered directory completion.
use crate::cli::Shell;
use crate::config::{Config, KeyBinding, KeyBindingBehavior};
use crate::powershell::quote as quote_powershell;

pub fn render(shell: Shell, chord: Option<KeyBinding>, config: &Config) -> String {
let cleanup = cleanup(shell);
Expand Down Expand Up @@ -197,9 +198,8 @@ fn render_posix(shell: Shell, chord: KeyBinding, config: &Config, history: bool)
}

fn render_powershell(chord: KeyBinding, config: &Config, history: bool) -> String {
let quote = |value: &str| format!("'{}'", value.replace('\'', "''"));
let zoxide = quote(&config.programs.zoxide.to_string_lossy());
let fzf = quote(&config.programs.fzf.to_string_lossy());
let zoxide = quote_powershell(&config.programs.zoxide.to_string_lossy());
let fzf = quote_powershell(&config.programs.fzf.to_string_lossy());
let behaviors = config
.key_binding_behaviors()
.iter()
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod key_history;
mod key_zoxide;
mod mounts;
mod path_bytes;
mod powershell;
mod resolver;
mod shell;
#[cfg(windows)]
Expand Down
17 changes: 17 additions & 0 deletions src/powershell.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//! String literals embedded in generated PowerShell source.

/// Match PowerShell's `CodeGeneration.EscapeSingleQuotedStringContent`:
/// ASCII apostrophes and U+2018–U+201B all delimit single-quoted strings.
/// Duplicate the original character so the literal preserves its exact value.
pub fn quote(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('\'');
for character in value.chars() {
quoted.push(character);
if matches!(character, '\'' | '\u{2018}'..='\u{201b}') {
quoted.push(character);
}
}
quoted.push('\'');
quoted
}
7 changes: 2 additions & 5 deletions src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::path::Path;

use crate::cli::Shell;
use crate::config::{Config, KeyBinding};
use crate::powershell::quote as quote_powershell;

pub fn render(
shell: Shell,
Expand Down Expand Up @@ -488,7 +489,7 @@ function script:Add-CjHistory {{

function script:ConvertTo-CjCompletionText {{
param([string]$Value)
return "'" + $Value.Replace("'", "''") + "'"
return "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($Value) + "'"
}}

function script:Complete-CjCdArgument {{
Expand Down Expand Up @@ -664,10 +665,6 @@ fn quote_nu(value: &str) -> String {
unreachable!()
}

fn quote_powershell(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
10 changes: 6 additions & 4 deletions tests/key_binding_powershell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ fn real_psreadline_keypresses_preserve_buffers_history_and_cwd() {
("'zoxide', 'cj'", "0", "", 3),
] {
let temp = TempDir::new("psreadline-雪");
let zoxide = temp.path().join("custom zoxide's executable");
let fzf = temp.path().join("custom fzf's executable");
let zoxide = temp.path().join("custom zoxide's ‘left’ ‚low‛ executable");
let fzf = temp.path().join("custom fzf's ‘left’ ‚low‛ executable");
write_executable(
&zoxide,
r#"
Expand All @@ -47,7 +47,7 @@ if [ "$CJ_TEST_PICK_STATUS" = 7 ]; then echo 'picker exploded' >&2; fi
exit "$CJ_TEST_PICK_STATUS"
"#,
);
let config = temp.path().join("config.toml");
let config = temp.path().join("config's ‘left’ ‚low‛.toml");
fs::write(&config, format!(
"[programs]\nzoxide = {}\nfzf = {}\n[key-bindings]\nmacos = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\nlinux = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\n",
serde_json::to_string(zoxide.to_str().unwrap()).unwrap(),
Expand Down Expand Up @@ -100,7 +100,7 @@ exec "$CJ_TEST_PWSH" -NoLogo -NoProfile -NoExit -File "$CJ_TEST_SCRIPT"
let results = temp.path().join("results.jsonl");
let older = temp.path().join("older 'quoted' 雪");
let newer = temp.path().join("-newer folder");
let target = "/selected 'quote' 雪/‘’/$literal;$(no-execution)\nlast\n\n";
let target = "/selected 'quote' 雪/‘’‚‛/$literal;$(no-execution)\nlast\n\n";
let binary = PathBuf::from(env!("CARGO_BIN_EXE_cj"));
let mut paths = vec![binary.parent().unwrap().to_path_buf()];
paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default()));
Expand Down Expand Up @@ -177,6 +177,8 @@ exit $timed_out
path.replace('\'', "''")
.replace('‘', "‘‘")
.replace('’', "’’")
.replace('‚', "‚‚")
.replace('‛', "‛‛")
)
},
);
Expand Down
125 changes: 124 additions & 1 deletion tests/shell_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use support::{TempDir, assert_success, cj};
use support::{GitFixture, TempDir, assert_success, cj};

#[test]
fn generated_sources_parse_in_available_shells() {
Expand Down Expand Up @@ -316,6 +316,129 @@ if (Get-Command Get-PSReadLineKeyHandler -ErrorAction SilentlyContinue) {
);
}

#[test]
fn powershell_smart_quotes_round_trip_in_source_and_completions() {
if !available("pwsh") {
eprintln!("skipping PowerShell smart quote test: pwsh is unavailable");
return;
}
let fixture = GitFixture::new("powershell-smart-quotes");
let temp = &fixture.temp;
let worktree = temp.path().join("worktree's ‘left’ ‚low‛");
assert_success(
&Command::new("git")
.current_dir(&fixture.main)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_INDEX_FILE")
.args(["worktree", "move"])
.arg(&fixture.linked)
.arg(&worktree)
.output()
.unwrap(),
);
let destination = temp.path().join("target's ‘left’ ‚low‛ 雪");
let config = temp.path().join("config's ‘left’ ‚low‛.toml");
fs::create_dir_all(&destination).unwrap();
let mut aliases = vec!["it’s-here".to_string()];
for (index, quote) in ['\'', '‘', '’', '‚', '‛'].into_iter().enumerate() {
aliases.push(format!(
"quote{index}{quote}; $global:CjInjected=1; #{quote}"
));
}
let mut contents = String::from("[aliases]\n");
for name in &aliases {
contents.push_str(&format!(
"{} = \"{}\"\n",
serde_json::to_string(name).unwrap(),
toml_string(&destination)
));
}
fs::write(&config, contents).unwrap();
let integration = temp.path().join("init.ps1");
let completions = temp.path().join("completions.ps1");
fs::write(
&integration,
generate(temp.path(), &config, "init", "powershell"),
)
.unwrap();
fs::write(
&completions,
generate(temp.path(), &config, "completions", "powershell"),
)
.unwrap();

let script = r#"$ErrorActionPreference = 'Stop'
$global:CjInjected = 0
function Test-SameDirectory([string]$Actual, [string]$Expected) {
# Git uses '/', while Windows PathBuf and Get-Location use '\'.
$comparison = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
return [System.IO.Path]::GetFullPath($Actual).Equals([System.IO.Path]::GetFullPath($Expected), $comparison)
}
. $env:CJ_INIT_SOURCE
. $env:CJ_COMPLETION_SOURCE
if ($script:__cj_config[1] -cne $env:CJ_TEST_CONFIG) { throw 'config path changed' }
$line = 'cd -jw'
$before = (Get-Location).ProviderPath
$result = TabExpansion2 $line $line.Length
$match = @($result.CompletionMatches | Where-Object { Test-SameDirectory $_.ListItemText $env:CJ_TEST_WORKTREE })
if ($match.Count -ne 1) { throw "missing smart-quote worktree completion: expected $env:CJ_TEST_WORKTREE; got $($result.CompletionMatches.ListItemText -join ', ')" }
if ((Get-Location).ProviderPath -cne $before) { throw 'Tab changed directory' }
$literalPath = & ([scriptblock]::Create($match[0].CompletionText))
if ($literalPath -isnot [string] -or $literalPath -cne $match[0].ListItemText) { throw 'completion changed literal worktree path' }
$completed = $line.Remove($result.ReplacementIndex, $result.ReplacementLength).Insert($result.ReplacementIndex, $match[0].CompletionText)
& ([scriptblock]::Create($completed))
if (-not (Test-SameDirectory (Get-Location).ProviderPath $env:CJ_TEST_WORKTREE)) { throw 'completed worktree path changed' }
$aliases = @($env:CJ_TEST_ALIASES | ConvertFrom-Json)
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)
} else {
[System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null).CompletionMatches
}
$matches = @($candidates | Where-Object ListItemText -CEQ $expected)
if ($matches.Count -ne 1) { throw "missing $command completion: $expected" }
$literal = $matches[0].CompletionText
$value = & ([scriptblock]::Create($literal))
if ($value -isnot [string] -or $value -cne $expected) { throw "completion changed: $expected" }
if ($command -ceq 'cd') {
& ([scriptblock]::Create('cd ' + $literal))
if (-not (Test-SameDirectory (Get-Location).ProviderPath $env:CJ_TEST_DESTINATION)) { throw 'completed alias failed to resolve' }
}
if ($global:CjInjected -ne 0) { throw 'completion executed embedded code' }
}
}
if ($global:CjInjected -ne 0) { throw 'generated source executed embedded code' }
"#;
let output = Command::new("pwsh")
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
script,
])
.current_dir(&fixture.main)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_INDEX_FILE")
.env("PATH", path_with_cj())
.env("HOME", temp.path())
.env("CJ_INIT_SOURCE", &integration)
.env("CJ_COMPLETION_SOURCE", &completions)
.env("CJ_TEST_CONFIG", &config)
.env("CJ_TEST_WORKTREE", &worktree)
.env("CJ_TEST_ALIASES", serde_json::to_string(&aliases).unwrap())
.env("CJ_TEST_DESTINATION", &destination)
.output()
.unwrap();
assert_success(&output);
}

#[cfg(unix)]
#[test]
fn bash_completion_handles_nested_commands_and_spaced_names() {
Expand Down
Loading