diff --git a/src/cli.rs b/src/cli.rs index 404dfdf..82086cc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -50,6 +50,10 @@ pub enum Command { jump: bool, }, WorktreePaths0, + KeyBindingZoxide { + zoxide: PathBuf, + fzf: PathBuf, + }, Init { shell: Shell, setup_key_binding: bool, @@ -105,6 +109,7 @@ impl Cli { let mut relative = false; let mut jump = false; let mut worktree_paths0 = false; + let mut key_binding_zoxide = None; let mut setup_key_binding = false; let mut output = None; let mut preamp = false; @@ -152,6 +157,17 @@ impl Cli { jump = true; } else if options && text == Some("--worktree-paths0") { worktree_paths0 = true; + } else if options && text == Some("--internal-key-binding-zoxide") { + if key_binding_zoxide.is_some() { + return Err("--internal-key-binding-zoxide cannot be repeated".into()); + } + let zoxide = args + .next() + .ok_or("--internal-key-binding-zoxide requires zoxide and fzf paths")?; + let fzf = args + .next() + .ok_or("--internal-key-binding-zoxide requires zoxide and fzf paths")?; + key_binding_zoxide = Some((PathBuf::from(zoxide), PathBuf::from(fzf))); } else if options && text == Some("--setup-key-binding") { setup_key_binding = true; } else if options && matches!(text, Some("-o" | "--output")) { @@ -167,7 +183,26 @@ impl Cli { } } - let command = if worktree_paths0 { + let command = if let Some((zoxide, fzf)) = key_binding_zoxide { + if force_resolve + || !targets.is_empty() + || worktree_paths0 + || worktree + || jump + || format.is_some() + || relative + || resolver != ResolverOverride::Configured + || setup_key_binding + || preamp + || config_path.is_some() + || output.is_some() + { + return Err( + "--internal-key-binding-zoxide cannot be combined with another mode".into(), + ); + } + Command::KeyBindingZoxide { zoxide, fzf } + } else if worktree_paths0 { if force_resolve || !targets.is_empty() || worktree @@ -310,11 +345,6 @@ fn parse_init( ); } let shell = parse_shell(&targets[1])?; - if options.setup_key_binding && shell == Shell::Nu { - return Err( - "--setup-key-binding is currently supported for bash, zsh, and PowerShell".into(), - ); - } Ok(Command::Init { shell, setup_key_binding: options.setup_key_binding, @@ -526,6 +556,72 @@ mod tests { assert!(Cli::parse(["--worktree-paths0", "--"].map(Into::into)).is_err()); } + #[test] + fn parses_hidden_key_binding_zoxide_protocol() { + assert_eq!( + Cli::parse( + [ + "--internal-key-binding-zoxide", + "/tools/zoxide", + "/tools/fzf" + ] + .map(Into::into) + ) + .unwrap() + .command, + Command::KeyBindingZoxide { + zoxide: "/tools/zoxide".into(), + fzf: "/tools/fzf".into() + } + ); + assert!(!HELP.contains("--internal-key-binding-zoxide")); + for extra in [ + "target", + "-w", + "-jw", + "--jump-worktree", + "--worktree-paths0", + "-z", + "--setup-key-binding", + "--preamp", + "--", + ] { + assert!( + Cli::parse( + ["--internal-key-binding-zoxide", "zoxide", "fzf", extra].map(Into::into) + ) + .is_err() + ); + } + assert!(Cli::parse(["--internal-key-binding-zoxide", "zoxide"].map(Into::into)).is_err()); + assert!( + Cli::parse( + [ + "--internal-key-binding-zoxide", + "zoxide", + "fzf", + "-C", + "config.toml" + ] + .map(Into::into) + ) + .is_err() + ); + assert!( + Cli::parse( + [ + "--internal-key-binding-zoxide", + "zoxide", + "fzf", + "-o", + "out" + ] + .map(Into::into) + ) + .is_err() + ); + } + #[test] fn jump_worktree_rejects_ignored_output_flags() { assert!(Cli::parse(["--jump-worktree", "-R"].map(Into::into)).is_err()); @@ -591,7 +687,7 @@ mod tests { #[test] fn rejects_unsupported_shell_setup_combinations() { assert!(Cli::parse(["init", "fish"].map(Into::into)).is_err()); - assert!(Cli::parse(["init", "nu", "--setup-key-binding"].map(Into::into)).is_err()); + assert!(Cli::parse(["init", "nu", "--setup-key-binding"].map(Into::into)).is_ok()); assert_eq!( Cli::parse(["init", "powershell", "--setup-key-binding"].map(Into::into)), Ok(Cli { diff --git a/src/key_bindings.rs b/src/key_bindings.rs new file mode 100644 index 0000000..f98a1ad --- /dev/null +++ b/src/key_bindings.rs @@ -0,0 +1,297 @@ +//! Foreground line-editor widgets for ordered directory completion. +use crate::cli::Shell; +use crate::config::{Config, KeyBinding, KeyBindingBehavior}; + +pub fn render(shell: Shell, chord: Option, config: &Config) -> String { + let cleanup = cleanup(shell); + let Some(chord @ (KeyBinding::CtrlO | KeyBinding::AltO)) = chord else { + return cleanup.into(); + }; + if config.key_binding_behaviors().is_empty() { + return cleanup.into(); + } + if shell == Shell::Nu { + return format!( + "{cleanup}\n{}", + crate::key_bindings_nu::render(chord, config) + ); + } + let history = config + .key_binding_behaviors() + .contains(&KeyBindingBehavior::Cj); + let helper = if history { + crate::key_history::render(shell) + } else { + "" + }; + let widget = match shell { + Shell::Bash | Shell::Zsh => render_posix(shell, chord, config, history), + Shell::Pwsh => render_powershell(chord, config, history), + Shell::Nu => unreachable!(), + }; + format!("{cleanup}\n{helper}\n{widget}") +} + +fn cleanup(shell: Shell) -> &'static str { + match shell { + Shell::Bash => { + r#"if [[ -n "${_cj_bound_key-}" ]]; then + while IFS= read -r _cj_existing_binding; do + if [[ "$_cj_existing_binding" == "\"$_cj_bound_key\" \"${_cj_bound_widget-}\"" || "$_cj_existing_binding" == "\"$_cj_bound_key\": \"${_cj_bound_widget-}\"" ]]; then + \builtin bind -r "$_cj_bound_key" 2>/dev/null || : + fi + done < <(\builtin bind -X 2>/dev/null) +fi +unset _cj_bound_key _cj_bound_widget _cj_existing_binding"# + } + Shell::Zsh => { + r#"if [[ -n "${_cj_bound_key-}" && "$(\builtin bindkey "$_cj_bound_key" 2>/dev/null)" == "\"$_cj_bound_key\" ${_cj_bound_widget-}" ]]; then + \builtin bindkey -r "$_cj_bound_key" +fi +unset _cj_bound_key _cj_bound_widget"# + } + Shell::Pwsh => { + r#"$__cj_previous_chord = Get-Variable -Name __cj_bound_chord -Scope Global -ValueOnly -ErrorAction SilentlyContinue +if ($__cj_previous_chord -and (Get-Command Get-PSReadLineKeyHandler -ErrorAction SilentlyContinue)) { + $__cj_previous_handler = Get-PSReadLineKeyHandler -Chord $__cj_previous_chord + if ($__cj_previous_handler.Function -eq 'cj directory' -and $__cj_previous_handler.Description -eq 'cj managed directory completion') { + Remove-PSReadLineKeyHandler -Chord $__cj_previous_chord + } +} +Remove-Variable -Name __cj_bound_chord -Scope Global -ErrorAction SilentlyContinue +Remove-Variable -Name __cj_previous_chord, __cj_previous_handler -ErrorAction SilentlyContinue"# + } + Shell::Nu => crate::key_bindings_nu::cleanup(), + } +} + +fn quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn render_posix(shell: Shell, chord: KeyBinding, config: &Config, history: bool) -> String { + let behaviors = config + .key_binding_behaviors() + .iter() + .map(|behavior| match behavior { + KeyBindingBehavior::Zoxide => "zoxide", + KeyBindingBehavior::Cj => "cj", + }) + .collect::>() + .join(" "); + let zoxide = quote(&config.programs.zoxide.to_string_lossy()); + let fzf = quote(&config.programs.fzf.to_string_lossy()); + let read_buffer = if shell == Shell::Bash { + "local _cj_buffer=\"${READLINE_LINE-}\" _cj_cursor=\"${READLINE_POINT-0}\"\n if (( BASH_VERSINFO[0] < 5 )); then local LC_ALL=C; fi" + } else { + "emulate -L zsh\n local _cj_buffer=\"$BUFFER\" _cj_cursor=\"$CURSOR\"" + }; + let apply = if shell == Shell::Bash { + "READLINE_LINE=\"$_cj_key_buffer\" READLINE_POINT=\"$_cj_key_cursor\"" + } else { + "BUFFER=\"$_cj_key_buffer\" CURSOR=\"$_cj_key_cursor\"" + }; + let reset = if history { + "_cj_key_history_reset" + } else { + ":" + }; + let resume = if history { + format!( + r#"if [[ -n "${{_cj_key_history_next-}}" && "$_cj_buffer" == "${{_cj_key_history_last-}}" && "$_cj_cursor" == "${{_cj_key_history_last_cursor-}}" ]]; then + _cj_key_history "$_cj_buffer" "$_cj_cursor" + {apply} + return 0 + fi + _cj_key_history_reset"# + ) + } else { + String::new() + }; + // A sentinel prevents command substitution from stripping newlines in paths. + let body = format!( + r#"_cj_key_widget() {{ + {read_buffer} + {resume} + local _cj_behavior _cj_reply _cj_status _cj_target _cj_quoted _cj_quote _cj_separator _cj_missing='' _cj_empty='no matching directories' + for _cj_behavior in {behaviors}; do + case "$_cj_behavior" in + cj) + if _cj_key_history "$_cj_buffer" "$_cj_cursor"; then + {apply} + return 0 + fi + _cj_empty='no directory history' + ;; + zoxide) + if [[ -n "${{ZSH_VERSION-}}" ]]; then \builtin zle -I 2>/dev/null || :; fi + if _cj_reply="$(\command cj --internal-key-binding-zoxide {zoxide} {fzf}; _cj_status=$?; printf '.'; exit "$_cj_status")"; then + _cj_status=0 + else + _cj_status=$? + fi + _cj_reply="${{_cj_reply%.}}" + if (( _cj_status != 0 )); then + {reset} + return 0 + fi + case "$_cj_reply" in + selected$'\n'*) + _cj_target="${{_cj_reply#*$'\n'}}" + {reset} + [[ "$_cj_target" != -* ]] || _cj_target="./$_cj_target" + _cj_quote="'\\''" + _cj_quoted="'${{_cj_target//\'/$_cj_quote}}'" + _cj_separator='' + [[ -z "$_cj_buffer" || "$_cj_buffer" == *[[:space:]] ]] || _cj_separator=' ' + _cj_key_buffer="$_cj_buffer$_cj_separator$_cj_quoted" + _cj_key_cursor=${{#_cj_key_buffer}} + {apply} + return 0 + ;; + unavailable$'\n'*) _cj_missing="${{_cj_reply#*$'\n'}}" ;; + empty$'\n') ;; + cancelled$'\n') + {reset} + return 0 + ;; + *) + printf '%s\n' 'cj: invalid interactive zoxide response' >&2 + {reset} + return 0 + ;; + esac + ;; + esac + done + printf 'cj: %s\n' "${{_cj_missing:-$_cj_empty}}" >&2 + return 0 +}}"# + ); + let binding = match (shell, chord) { + (Shell::Bash, KeyBinding::CtrlO) => r#"\builtin bind -x '"\C-o":_cj_key_widget'"#, + (Shell::Bash, _) => r#"\builtin bind -x '"\eo":_cj_key_widget'"#, + (Shell::Zsh, KeyBinding::CtrlO) => { + "\\builtin zle -N _cj_key_widget\n\\builtin bindkey '^O' _cj_key_widget" + } + (Shell::Zsh, _) => { + "\\builtin zle -N _cj_key_widget\n\\builtin bindkey '^[o' _cj_key_widget" + } + _ => unreachable!(), + }; + let key = match (shell, chord) { + (Shell::Bash, KeyBinding::CtrlO) => r"\C-o", + (Shell::Bash, _) => r"\eo", + (Shell::Zsh, KeyBinding::CtrlO) => "^O", + (Shell::Zsh, _) => "^[o", + _ => unreachable!(), + }; + let binding = format!("{binding}\n_cj_bound_key='{key}'\n_cj_bound_widget=_cj_key_widget"); + if shell == Shell::Bash { + format!("{body}\nif (( BASH_VERSINFO[0] >= 4 )); then\n {binding}\nfi") + } else { + format!("{body}\n{binding}") + } +} + +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 behaviors = config + .key_binding_behaviors() + .iter() + .map(|behavior| match behavior { + KeyBindingBehavior::Zoxide => "'zoxide'", + KeyBindingBehavior::Cj => "'cj'", + }) + .collect::>() + .join(", "); + let reset = if history { "Reset-CjKeyHistory" } else { "" }; + let resume = if history { + r#"$cycle = $global:__cj_key_history_cycle + if ($null -ne $cycle -and $buffer -ceq $cycle.LastBuffer -and $cursor -eq $cycle.LastCursor) { + $result = Invoke-CjKeyHistory -Buffer $buffer -Cursor $cursor + [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $buffer.Length, $result.Buffer) + [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($result.Cursor) + return + } + Reset-CjKeyHistory"# + } else { + "" + }; + let chord = if chord == KeyBinding::CtrlO { + "Ctrl+o" + } else { + "Alt+o" + }; + format!( + r#"function global:Invoke-CjKeyWidget {{ + $buffer = ''; $cursor = 0 + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$buffer, [ref]$cursor) + {resume} + $missing = $null + $empty = 'no matching directories' + foreach ($behavior in @({behaviors})) {{ + if ($behavior -eq 'cj') {{ + $result = Invoke-CjKeyHistory -Buffer $buffer -Cursor $cursor + if ($null -ne $result) {{ + [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $buffer.Length, $result.Buffer) + [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($result.Cursor) + return + }} + $empty = 'no directory history' + continue + }} + # PowerShell's native pipeline captures stderr even inside PSReadLine. + # Redirect only the private stdout protocol; leave the picker on the tty. + $process = [System.Diagnostics.Process]::new() + try {{ + $process.StartInfo.FileName = $script:__cj_executable.Path + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false) + $process.StartInfo.WorkingDirectory = (Get-Location).ProviderPath + foreach ($argument in @('--internal-key-binding-zoxide', {zoxide}, {fzf})) {{ + $process.StartInfo.ArgumentList.Add($argument) + }} + [void]$process.Start() + $raw = $process.StandardOutput.ReadToEnd() + $process.WaitForExit() + $status = $process.ExitCode + }} catch {{ + [Console]::Error.WriteLine("cj: cannot launch interactive zoxide query: $_") + {reset}; return + }} finally {{ + $process.Dispose() + }} + if ($status -ne 0) {{ [Console]::Error.WriteLine("cj: interactive zoxide query failed (exit $status)"); {reset}; return }} + $reply = $raw -split "`n", 2 + if ($reply.Count -ne 2) {{ [Console]::Error.WriteLine('cj: invalid interactive zoxide response'); {reset}; return }} + switch ($reply[0]) {{ + 'selected' {{ + if ($reply[1].Length -eq 0) {{ [Console]::Error.WriteLine('cj: invalid interactive zoxide path'); {reset}; return }} + {reset} + $separator = if ($buffer.Length -eq 0 -or [char]::IsWhiteSpace($buffer[$buffer.Length - 1])) {{ '' }} else {{ ' ' }} + $target = [string]$reply[1] + if ($target.StartsWith('-')) {{ $target = './' + $target }} + $quoted = [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($target) + $updated = $buffer + $separator + "'" + $quoted + "'" + [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $buffer.Length, $updated) + [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($updated.Length) + return + }} + 'unavailable' {{ $missing = $reply[1] }} + 'empty' {{ }} + 'cancelled' {{ {reset}; return }} + default {{ [Console]::Error.WriteLine('cj: invalid interactive zoxide response'); {reset}; return }} + }} + }} + if ($missing) {{ [Console]::Error.WriteLine("cj: $missing") }} else {{ [Console]::Error.WriteLine("cj: $empty") }} +}} +if (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue) {{ + Set-PSReadLineKeyHandler -Chord '{chord}' -BriefDescription 'cj directory' -Description 'cj managed directory completion' -ScriptBlock {{ Invoke-CjKeyWidget }} + $global:__cj_bound_chord = '{chord}' +}}"# + ) +} diff --git a/src/key_bindings_nu.rs b/src/key_bindings_nu.rs new file mode 100644 index 0000000..17febd8 --- /dev/null +++ b/src/key_bindings_nu.rs @@ -0,0 +1,153 @@ +//! Nushell's host-command event runs the picker in the foreground while keeping +//! the pending command in Reedline, without executing or recording that command. +use crate::config::{Config, KeyBinding, KeyBindingBehavior}; + +pub fn cleanup() -> &'static str { + r#"export-env { + $env.__cj_history = [] + $env.__cj_key_cycle = null + $env.__cj_key_resume = false + $env.config.keybindings = ($env.config.keybindings | where name != 'cj-directory') +}"# +} + +pub fn render(chord: KeyBinding, config: &Config) -> String { + let behaviors = config + .key_binding_behaviors() + .iter() + .map(|behavior| match behavior { + KeyBindingBehavior::Zoxide => "'zoxide'", + KeyBindingBehavior::Cj => "'cj'", + }) + .collect::>() + .join(" "); + let zoxide = quote(&config.programs.zoxide.to_string_lossy()); + let fzf = quote(&config.programs.fzf.to_string_lossy()); + // Substitute only template tokens, never tokens inside configured paths. + SOURCE + .split('@') + .map(|part| match part { + "BEHAVIORS" => behaviors.as_str(), + "ZOXIDE" => zoxide.as_str(), + "FZF" => fzf.as_str(), + "MODIFIER" if chord == KeyBinding::CtrlO => "control", + "MODIFIER" => "alt", + text => text, + }) + .collect() +} + +fn quote(value: &str) -> String { + let mut hashes = "#".to_owned(); + while value.contains(&format!("'{hashes}")) { + hashes.push('#'); + } + format!("r{hashes}'{value}'{hashes}") +} + +const SOURCE: &str = r#"def _cj-key-quote [value: string] { + mut hashes = '#' + while ($value | str contains ("'" + $hashes)) { $hashes += '#' } + 'r' + $hashes + "'" + $value + "'" + $hashes +} + +def _cj-key-append [buffer: string, target: string] { + let path = if ($target | str starts-with '-') { './' + $target } else { $target } + let separator = if ($buffer | is-empty) or ($buffer =~ '\s$') { '' } else { ' ' } + $buffer + $separator + (_cj-key-quote $path) +} + +def --env _cj-key-history [buffer: string, cursor: int] { + mut cycle = ($env.__cj_key_cycle? | default null) + if ($cycle == null) or ($buffer != $cycle.last_buffer) or ($cursor != $cycle.last_cursor) { + $env.__cj_key_cycle = null + let items = ($env.__cj_history? | default []) + if ($items | is-empty) { return null } + $cycle = {original_buffer: $buffer, original_cursor: $cursor, + last_buffer: $buffer, last_cursor: $cursor, + items: $items, next: (($items | length) - 1)} + } + if $cycle.next < 0 { + $env.__cj_key_cycle = null + return {buffer: $cycle.original_buffer, cursor: $cycle.original_cursor} + } + let completed = (_cj-key-append $cycle.original_buffer ($cycle.items | get $cycle.next)) + let position = ($completed | str length --grapheme-clusters) + $cycle.next = $cycle.next - 1 + $cycle.last_buffer = $completed + $cycle.last_cursor = $position + $env.__cj_key_cycle = $cycle + {buffer: $completed, cursor: $position} +} + +export def --env _cj-key-widget [] { + let buffer = (commandline) + let cursor = (commandline get-cursor) + # Host commands run pre_prompt too. Retain this cycle for that prompt only. + $env.__cj_key_resume = true + let behaviors = [@BEHAVIORS@] + let cycle = ($env.__cj_key_cycle? | default null) + if ($cycle != null) and ($buffer == $cycle.last_buffer) and ($cursor == $cycle.last_cursor) { + let result = (_cj-key-history $buffer $cursor) + commandline edit --replace $result.buffer + commandline set-cursor $result.cursor + return + } + $env.__cj_key_cycle = null + mut missing = '' + mut empty = 'no matching directories' + for behavior in $behaviors { + if $behavior == 'cj' { + let result = (_cj-key-history $buffer $cursor) + if $result != null { + commandline edit --replace $result.buffer + commandline set-cursor $result.cursor + return + } + $empty = 'no directory history' + continue + } + # Decode explicitly: implicit external string capture trims a final LF. + # Keep stderr attached to the terminal while the picker is foreground. + let reply = (try { + ^cj --internal-key-binding-zoxide @ZOXIDE@ @FZF@ | into binary | decode utf-8 + } catch { null }) + if $reply == null { return } + if ($reply | str starts-with "selected\n") { + let target = ($reply | str substring 9..) + let completed = (_cj-key-append $buffer $target) + commandline edit --replace $completed + commandline set-cursor --end + return + } + if ($reply | str starts-with "unavailable\n") { + $missing = ($reply | str substring 12..) + } else if $reply == "cancelled\n" { + return + } else if $reply != "empty\n" { + print --stderr 'cj: invalid interactive zoxide response' + return + } + } + print --stderr ('cj: ' + (if ($missing | is-empty) { $empty } else { $missing })) +} + +export-env { + $env.__cj_history = [] + $env.__cj_key_cycle = null + $env.__cj_key_resume = false + if not ($env.__cj_key_prompt_hook? | default false) { + $env.config.hooks.pre_prompt ++= [{|| + if ($env.__cj_key_resume? | default false) { + $env.__cj_key_resume = false + } else { + $env.__cj_key_cycle = null + } + }] + $env.__cj_key_prompt_hook = true + } + $env.config.keybindings = ($env.config.keybindings | where name != 'cj-directory') + $env.config.keybindings ++= [{name: cj-directory, modifier: @MODIFIER@, + keycode: char_o, mode: [emacs vi_insert vi_normal], + event: {send: executehostcommand, cmd: '_cj-key-widget'}}] +}"#; diff --git a/src/key_history.rs b/src/key_history.rs new file mode 100644 index 0000000..69ebd83 --- /dev/null +++ b/src/key_history.rs @@ -0,0 +1,276 @@ +use crate::cli::Shell; + +/// Shell-local, read-only completion of the browser history. Cycling restores +/// both the original line and cursor after the oldest remembered directory. +#[allow(dead_code)] // Wired into the ordered binding dispatcher separately. +pub fn render(shell: Shell) -> &'static str { + match shell { + Shell::Bash | Shell::Zsh => POSIX, + Shell::Pwsh => POWERSHELL, + Shell::Nu => "", + } +} + +const POSIX: &str = r#"function _cj_key_history_reset() { + unset _cj_key_history_original _cj_key_history_original_cursor + unset _cj_key_history_last _cj_key_history_last_cursor _cj_key_history_next + unset _cj_key_history_items +} +_cj_key_history_reset + +function _cj_key_history() { + local buffer="$1" cursor="$2" target quoted index remainder + # Bash 5+ and ZLE use character offsets; older Readline uses bytes. + if [[ -n "${BASH_VERSION-}" ]] && (( BASH_VERSINFO[0] < 5 )); then local LC_ALL=C; fi + _cj_key_buffer="$buffer" + _cj_key_cursor="$cursor" + if [[ -z "${_cj_key_history_next-}" || "$buffer" != "${_cj_key_history_last-}" || "$cursor" != "${_cj_key_history_last_cursor-}" ]]; then + _cj_key_history_reset + (( ${#_cj_history[@]} > 0 )) || return 1 + _cj_key_history_original="$buffer" + _cj_key_history_original_cursor="$cursor" + _cj_key_history_items=("${_cj_history[@]}") + _cj_key_history_next=${#_cj_key_history_items[@]} + fi + if (( _cj_key_history_next == 0 )); then + _cj_key_buffer="$_cj_key_history_original" + _cj_key_cursor="$_cj_key_history_original_cursor" + _cj_key_history_reset + return 0 + fi + index=$((_cj_key_history_next - 1)) + [[ -n "${ZSH_VERSION-}" ]] && index=$((index + 1)) + target="${_cj_key_history_items[$index]}" + _cj_key_history_next=$((_cj_key_history_next - 1)) + # Single quotes preserve spaces, newlines, and metacharacters literally. + quoted="'" + remainder="$target" + while [[ "$remainder" == *"'"* ]]; do + quoted="$quoted${remainder%%\'*}'\\''" + remainder="${remainder#*\'}" + done + quoted="$quoted$remainder'" + _cj_key_buffer="$_cj_key_history_original" + if [[ -n "$_cj_key_buffer" && "$_cj_key_buffer" != *[[:space:]] ]]; then + _cj_key_buffer="$_cj_key_buffer " + fi + _cj_key_buffer="$_cj_key_buffer$quoted" + _cj_key_cursor=${#_cj_key_buffer} + _cj_key_history_last="$_cj_key_buffer" + _cj_key_history_last_cursor="$_cj_key_cursor" + return 0 +}"#; + +const POWERSHELL: &str = r#"function global:Reset-CjKeyHistory { + $global:__cj_key_history_cycle = $null +} +Reset-CjKeyHistory + +function global:Invoke-CjKeyHistory { + param([string] $Buffer, [int] $Cursor) + $cycle = $global:__cj_key_history_cycle + if ($null -eq $cycle -or $Buffer -cne $cycle.LastBuffer -or $Cursor -ne $cycle.LastCursor) { + Reset-CjKeyHistory + $history = Get-Variable -Name __cj_history -Scope Global -ErrorAction SilentlyContinue + $items = @() + if ($null -ne $history) { $items = @($history.Value) } + if ($items.Count -eq 0) { return $null } + $cycle = @{ + OriginalBuffer = $Buffer; OriginalCursor = $Cursor + LastBuffer = $Buffer; LastCursor = $Cursor + Items = $items; Next = $items.Count - 1 + } + $global:__cj_key_history_cycle = $cycle + } + if ($cycle.Next -lt 0) { + $result = @{ Buffer = $cycle.OriginalBuffer; Cursor = $cycle.OriginalCursor } + Reset-CjKeyHistory + return $result + } + $target = [string] $cycle.Items[$cycle.Next] + $cycle.Next -= 1 + $completed = $cycle.OriginalBuffer + if ($completed.Length -gt 0 -and -not [char]::IsWhiteSpace($completed[$completed.Length - 1])) { + $completed += ' ' + } + $completed += "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($target) + "'" + $cycle.LastBuffer = $completed + $cycle.LastCursor = $completed.Length + return @{ Buffer = $completed; Cursor = $completed.Length } +}"#; + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::render; + use crate::cli::Shell; + + fn run(shell: Shell, script: &str) { + let (program, args): (&str, &[&str]) = match shell { + Shell::Bash => ("bash", &["--noprofile", "--norc", "-c"]), + Shell::Zsh => ("zsh", &["-f", "-c"]), + Shell::Pwsh => ( + "pwsh", + &["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], + ), + Shell::Nu => unreachable!(), + }; + let source = format!("{}\n{script}", render(shell)); + let output = match Command::new(program).args(args).arg(source).output() { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + assert!( + std::env::var_os("CJ_REQUIRE_SHELLS").is_none() + && !(matches!(shell, Shell::Pwsh) + && std::env::var_os("CJ_REQUIRE_POWERSHELL").is_some()), + "required interpreter unavailable: {program}" + ); + eprintln!("skipping {program} history binding test: interpreter unavailable"); + return; + } + Err(error) => panic!("cannot run {program}: {error}"), + }; + assert!( + output.status.success(), + "{program}: status {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + // Windows may expose a bash.exe WSL launcher without an installed distribution. + #[cfg(unix)] + #[test] + fn posix_history_completion_is_reversible_and_read_only() { + for shell in [Shell::Bash, Shell::Zsh] { + run( + shell, + r#"set -e +_cj_before=$PWD +_cj_down_route='/remembered/down/route' +_cj_history=() +if _cj_key_history 'cd ' 1; then exit 10; fi +[[ $_cj_key_buffer == 'cd ' && $_cj_key_cursor == 1 ]] + +_cj_history=('/single') +_cj_key_history 'cd ' 1 +[[ $_cj_key_buffer == "cd '/single'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == 'cd ' && $_cj_key_cursor == 1 ]] + +_cj_history=('/oldest' '/middle' '/newest') +_cj_key_history 'cd' 0 +[[ $_cj_key_buffer == "cd '/newest'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == "cd '/middle'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == "cd '/oldest'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == cd && $_cj_key_cursor == 0 ]] +[[ ${#_cj_history[@]} == 3 ]] +[[ $PWD == "$_cj_before" && $_cj_down_route == /remembered/down/route ]] +"#, + ); + } + } + + #[cfg(unix)] + #[test] + fn posix_edit_cursor_change_cancel_and_reinit_restart_history_cycle() { + for shell in [Shell::Bash, Shell::Zsh] { + run( + shell, + r#"set -e +_cj_history=('/old' '/new') +_cj_key_history 'cd ' 3 +_cj_key_history 'ls ' 3 +[[ $_cj_key_buffer == "ls '/new'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == "ls '/old'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == 'ls ' && $_cj_key_cursor == 3 ]] + +_cj_key_history 'cd ' 3 +_cj_key_history "$_cj_key_buffer" 0 +[[ $_cj_key_buffer == "cd '/new' '/new'" ]] +_cj_key_history_reset +_cj_key_history 'cd ' 3 +[[ $_cj_key_buffer == "cd '/new'" ]] +# Reinitialization calls this same reset without changing browser history. +_cj_key_history_reset +[[ ${#_cj_history[@]} == 2 ]] +_cj_key_history 'cd ' 3 +[[ $_cj_key_buffer == "cd '/new'" ]] +"#, + ); + } + } + + #[cfg(unix)] + #[test] + fn posix_history_quotes_special_paths_and_preserves_multiline_buffer() { + for shell in [Shell::Bash, Shell::Zsh] { + run( + shell, + r#"set -e +for target in '/space dir' "/apostrophe's" '/literal;$()`"*?[x]\' $'/line\nbreak' '/unicode/日本語‘’'; do + _cj_key_history_reset + _cj_history=("$target") + _cj_key_history 'set --' 6 + # Evaluation is only in this test, modeling normal Enter execution. + eval "$_cj_key_buffer" + [[ $# == 1 && $1 == "$target" ]] +done +original=$'printf first\ncd ' +_cj_key_history_reset +_cj_key_history "$original" 2 +[[ $_cj_key_buffer == "$original"* ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == "$original" && $_cj_key_cursor == 2 ]] +"#, + ); + } + } + + #[test] + fn powershell_history_cycles_resets_and_quotes_literals() { + run( + Shell::Pwsh, + r#"$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$before = (Get-Location).Path +$global:__cj_down_route = '/down/route' +$global:__cj_history = @() +if ($null -ne (Invoke-CjKeyHistory -Buffer 'cd ' -Cursor 1)) { throw 'empty history completed' } +$global:__cj_history = @('/single') +$r = Invoke-CjKeyHistory -Buffer 'cd ' -Cursor 1 +if ($r.Buffer -cne "cd '/single'") { throw 'single history not completed' } +$r = Invoke-CjKeyHistory -Buffer $r.Buffer -Cursor $r.Cursor +if ($r.Buffer -cne 'cd ' -or $r.Cursor -ne 1) { throw 'original cursor not restored' } +$global:__cj_history = @('/old', '/middle', '/new') +$r = Invoke-CjKeyHistory -Buffer 'cd' -Cursor 0 +foreach ($expected in @('/new', '/middle', '/old')) { + if ($r.Buffer -cne "cd '$expected'") { throw "wrong cycle: $($r.Buffer)" } + $r = Invoke-CjKeyHistory -Buffer $r.Buffer -Cursor $r.Cursor +} +if ($r.Buffer -cne 'cd' -or $r.Cursor -ne 0) { throw 'original not restored' } +$r = Invoke-CjKeyHistory -Buffer 'cd ' -Cursor 3 +$r = Invoke-CjKeyHistory -Buffer 'ls ' -Cursor 3 +if ($r.Buffer -cne "ls '/new'") { throw 'editing failed to restart' } +Reset-CjKeyHistory +$r = Invoke-CjKeyHistory -Buffer 'cd ' -Cursor 3 +if ($r.Buffer -cne "cd '/new'") { throw 'cancellation failed to restart' } +if ($global:__cj_history.Count -ne 3 -or (Get-Location).Path -cne $before -or $global:__cj_down_route -cne '/down/route') { throw 'completion mutated navigation state' } +foreach ($target in @('/space dir', "/apostrophe's", '/literal;$()`"*?[x]\', "/line`nbreak", "/unicode/日本語$([char]0x2018)$([char]0x2019)$([char]0x201A)$([char]0x201B)")) { + Reset-CjKeyHistory + $global:__cj_history = @($target) + $r = Invoke-CjKeyHistory -Buffer '' -Cursor 0 + $literal = & ([scriptblock]::Create($r.Buffer)) + if ($literal -cne $target) { throw "path did not round-trip: $target" } +} +"#, + ); + } +} diff --git a/src/key_zoxide.rs b/src/key_zoxide.rs new file mode 100644 index 0000000..f3605af --- /dev/null +++ b/src/key_zoxide.rs @@ -0,0 +1,144 @@ +//! Runtime launcher for the snapshotted interactive zoxide configuration. +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Private stdout protocol separates fallback from cancellation without changing +/// the normal CLI's error handling. Stderr remains attached to the foreground +/// terminal while zoxide owns the interactive picker. +pub fn query_zoxide(zoxide: &Path, fzf: &Path) -> Result, String> { + let Some(zoxide) = executable(zoxide) else { + return Ok(format!( + "unavailable\nzoxide executable not found: {}", + zoxide.display() + ) + .into_bytes()); + }; + let Some(fzf) = executable(fzf) else { + return Ok( + format!("unavailable\nfzf executable not found: {}", fzf.display()).into_bytes(), + ); + }; + let candidates = Command::new(&zoxide) + .args(["query", "--list"]) + .output() + .map_err(|error| format!("cannot query zoxide: {error}"))?; + if !candidates.status.success() { + return Err(format!( + "zoxide query failed ({}): {}", + candidates.status, + String::from_utf8_lossy(&candidates.stderr).trim() + )); + } + if candidates.stdout.is_empty() { + return Ok(b"empty\n".to_vec()); + } + let shim = FzfAlias::new(&fzf)?; + let mut paths = vec![shim.0.clone()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let path = + std::env::join_paths(paths).map_err(|error| format!("cannot set picker PATH: {error}"))?; + let output = Command::new(zoxide) + .args(["query", "--interactive"]) + .env("PATH", path) + .stdin(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .map_err(|error| format!("cannot launch interactive zoxide query: {error}"))?; + if output.status.code() == Some(130) { + return Ok(b"cancelled\n".to_vec()); + } + if !output.status.success() { + return Err(format!( + "interactive zoxide query failed ({})", + output.status + )); + } + let target = crate::path_bytes::without_line_ending(&output.stdout); + if target.is_empty() { + // No selection after opening the picker must not run the next behavior. + return Ok(b"cancelled\n".to_vec()); + } + if target.contains(&0) { + return Err("interactive zoxide returned a path containing NUL".into()); + } + let mut result = b"selected\n".to_vec(); + result.extend_from_slice(target); + Ok(result) +} + +fn executable(program: &Path) -> Option { + let candidates = if program.components().count() > 1 || program.is_absolute() { + vec![program.to_path_buf()] + } else { + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(program)) + .collect() + }; + for candidate in candidates { + #[cfg(windows)] + let candidate = if candidate.extension().is_none() { + candidate.with_extension("exe") + } else { + candidate + }; + let Ok(metadata) = candidate.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + continue; + } + } + if let Ok(path) = dunce::canonicalize(candidate) { + return Some(path); + } + } + None +} + +struct FzfAlias(PathBuf); +impl FzfAlias { + fn new(fzf: &Path) -> Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + for _ in 0..100 { + let id = NEXT.fetch_add(1, Ordering::Relaxed); + let directory = + std::env::temp_dir().join(format!("cj-fzf-{}-{id}", std::process::id())); + let builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + let mut builder = builder; + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(format!("cannot create fzf launcher directory: {error}")), + } + let alias = Self(directory); + #[cfg(unix)] + let result = std::os::unix::fs::symlink(fzf, alias.0.join("fzf")); + #[cfg(not(unix))] + let result = std::fs::hard_link(fzf, alias.0.join("fzf.exe")) + .or_else(|_| std::fs::copy(fzf, alias.0.join("fzf.exe")).map(|_| ())); + result.map_err(|error| format!("cannot create fzf launcher: {error}"))?; + return Ok(alias); + } + Err("cannot allocate fzf launcher directory".into()) + } +} +impl Drop for FzfAlias { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/src/main.rs b/src/main.rs index bd20424..7daf8e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,10 @@ mod cli; mod completions; mod config; mod config_init; +mod key_bindings; +mod key_bindings_nu; +mod key_history; +mod key_zoxide; mod mounts; mod path_bytes; mod resolver; @@ -94,6 +98,9 @@ fn execute(cli: Cli) -> Result { Command::Version => Ok(Execution::stdout( concat!("cj ", env!("CARGO_PKG_VERSION")).into(), )), + Command::KeyBindingZoxide { zoxide, fzf } => { + key_zoxide::query_zoxide(&zoxide, &fzf).map(Execution::raw) + } Command::Resolve { targets, resolver } => { let config = config::Config::load(config_path.as_deref())?; let navigation = resolver::NavigationContext::from_process()?; diff --git a/src/shell.rs b/src/shell.rs index c03ac53..3737a40 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -11,7 +11,12 @@ pub fn render( ) -> Result { match shell { Shell::Bash | Shell::Zsh => render_posix(shell, config_path, binding, config), - Shell::Nu => render_nu(config_path, config), + Shell::Nu => render_nu(config_path, config).map(|wrapper| { + format!( + "{wrapper}\n{}", + crate::key_bindings::render(shell, binding, config) + ) + }), Shell::Pwsh => render_powershell(config_path, binding, config), } } @@ -235,50 +240,8 @@ elif [[ -n "${{ZSH_VERSION-}}" ]]; then _cj_register_cd_completion fi"# ); - Ok(match binding { - Some(KeyBinding::CtrlO) => format!("{wrapper}\n\n{}", render_binding(shell, &config, true)), - Some(KeyBinding::AltO) => format!("{wrapper}\n\n{}", render_binding(shell, &config, false)), - Some(KeyBinding::None) | None => wrapper, - }) -} - -fn render_binding(shell: Shell, config: &str, control: bool) -> String { - let command = format!("\\command cj{config} --jump-worktree"); - match shell { - Shell::Bash => { - let key = if control { r#"\C-o"# } else { r#"\eo"# }; - format!( - r#"_cj_worktree_widget() {{ - local target - target="$({command})" || return - [[ -n "$target" ]] || return - \builtin cd -- "$target" - unset _cj_down_route -}} -\builtin bind -x '"{key}":_cj_worktree_widget'"# - ) - } - Shell::Zsh => { - let key = if control { "^O" } else { "^[o" }; - format!( - r#"_cj_worktree_widget() {{ - local target _cj_status - target="$({command})" - _cj_status=$? - if (( _cj_status == 0 )) && [[ -n "$target" ]]; then - \builtin cd -- "$target" - _cj_status=$? - (( _cj_status == 0 )) && unset _cj_down_route - fi - \builtin zle reset-prompt - return "$_cj_status" -}} -\builtin zle -N _cj_worktree_widget -\builtin bindkey '{key}' _cj_worktree_widget"# - ) - } - Shell::Nu | Shell::Pwsh => unreachable!("bindings are POSIX-shell only"), - } + let widget = crate::key_bindings::render(shell, binding, settings); + Ok(format!("{wrapper}\n{widget}")) } fn render_nu(config_path: Option<&Path>, settings: &Config) -> Result { @@ -300,11 +263,7 @@ fn render_nu(config_path: Option<&Path>, settings: &Config) -> Result>() .join(", "); Ok(format!( - r#"export-env {{ - $env.__cj_history = [] -}} - -def _cj-is-repeated [value: string, ticker: string] {{ + r#"def _cj-is-repeated [value: string, ticker: string] {{ ($value | str length) > 0 and (($value | split chars | all {{ |char| $char == $ticker }})) }} @@ -628,31 +587,8 @@ if (Test-Path Function:TabExpansion2) {{ }} }}"# ); - Ok(match binding { - Some(KeyBinding::CtrlO) => { - format!("{wrapper}\n\n{}", render_powershell_binding("Ctrl+o")) - } - Some(KeyBinding::AltO) => { - format!("{wrapper}\n\n{}", render_powershell_binding("Alt+o")) - } - Some(KeyBinding::None) | None => wrapper, - }) -} - -fn render_powershell_binding(chord: &str) -> String { - format!( - r#"if (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue) {{ - Set-PSReadLineKeyHandler -Chord '{chord}' -BriefDescription 'cj worktree' -ScriptBlock {{ - $configArgs = $script:__cj_config - $executable = $script:__cj_executable.Path - $target = @(& $executable @configArgs --jump-worktree) - if (($LASTEXITCODE -eq 0) -and ($target.Count -eq 1) -and -not [string]::IsNullOrEmpty($target[0])) {{ - Microsoft.PowerShell.Management\Set-Location -LiteralPath $target[0] -ErrorAction Stop - $global:__cj_down_route = $null - }} - }} -}}"# - ) + let widget = crate::key_bindings::render(Shell::Pwsh, binding, settings); + Ok(format!("{wrapper}\n{widget}")) } fn absolute(path: &Path) -> Result { @@ -726,12 +662,11 @@ mod tests { &Config::default(), ) .unwrap(); - assert!(bash.contains(r#"\builtin bind -x '"\C-o":_cj_worktree_widget'"#)); - assert!(bash.contains("\\command cj --jump-worktree")); + assert!(bash.contains(r#"\builtin bind -x '"\C-o":_cj_key_widget'"#)); + assert!(bash.contains("--internal-key-binding-zoxide")); let zsh = render(Shell::Zsh, None, Some(KeyBinding::AltO), &Config::default()).unwrap(); - assert!(zsh.contains("\\builtin bindkey '^[o' _cj_worktree_widget")); - assert!(zsh.contains("\\builtin zle reset-prompt")); + assert!(zsh.contains("\\builtin bindkey '^[o' _cj_key_widget")); let powershell = render( Shell::Pwsh, diff --git a/tests/key_binding_editors.rs b/tests/key_binding_editors.rs new file mode 100644 index 0000000..c8240d0 --- /dev/null +++ b/tests/key_binding_editors.rs @@ -0,0 +1,281 @@ +#![cfg(unix)] + +mod support; + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use support::{TempDir, assert_success, cj, write_executable}; + +#[test] +fn real_line_editors_cycle_history_and_restore_the_original_line() { + if Command::new("zsh").arg("--version").output().is_err() { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + let bash = env::split_paths(&env::var_os("PATH").unwrap_or_default()) + .map(|path| path.join("bash")) + .find(|path| { + Command::new(path) + .args(["-c", "(( BASH_VERSINFO[0] >= 4 ))"]) + .status() + .is_ok_and(|status| status.success()) + }); + for shell in ["bash", "zsh"] { + if shell == "bash" && bash.is_none() { + assert!( + env::var_os("CJ_REQUIRE_SHELLS").is_none(), + "Bash 4+ required for widget tests" + ); + continue; + } + let temp = TempDir::new("key-editors-雪"); + let config = temp.path().join("config.toml"); + fs::write(&config, "[key-bindings]\nmacos = { key = 'ctrl-o', behaviors = ['cj'] }\nlinux = { key = 'ctrl-o', behaviors = ['cj'] }\n").unwrap(); + let generated = cj(temp.path(), temp.path()) + .arg("-C") + .arg(&config) + .args(["init", shell, "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&generated); + let mut script = String::from_utf8(generated.stdout).unwrap(); + script.push_str( + r#" +_cj_history=("$CJ_TEST_OLDER" "$CJ_TEST_NEWER") +_test_record() { + printf '%s\000' "$1" "$2" "$PWD" "${_cj_history[@]}" >> "$CJ_TEST_RESULTS" +} + +"#, + ); + if shell == "bash" { + script.push_str( + r#" +_test_widget() { + _cj_key_widget + _test_record "$READLINE_LINE" "$READLINE_POINT" +} +bind -x '"\C-o":_test_widget' +set -o emacs +PS1='CJ_EDITOR_READY> ' +"#, + ); + } else { + script.push_str( + r#" +zle-line-init() { + BUFFER='code ' + CURSOR=5 + for attempt in 1 2 3; do + zle _cj_key_widget + _test_record "$BUFFER" "$CURSOR" + done + exit +} +zle -N zle-line-init +test_buffer='' +vared test_buffer +"#, + ); + } + let source = temp.path().join("editor.sh"); + fs::write(&source, script).unwrap(); + let results = temp.path().join("results"); + let older = temp.path().join("older 'quoted' 雪"); + let newer = temp.path().join("-newer folder"); + 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())); + let output = Command::new("zsh") + .args([ + "-fc", + r#" +zmodload zsh/zpty || exit 1 +if [[ "$CJ_TEST_SHELL" == bash ]]; then + zpty -b cj_editor "$CJ_TEST_BASH" --noprofile --rcfile "$CJ_TEST_SCRIPT" -i || exit 1 + for attempt in {1..200}; do + if zpty -r cj_editor startup; then + [[ "$startup" == *'CJ_EDITOR_READY> '* ]] && break + fi + sleep 0.02 + done + zpty -w -n cj_editor $'code \x0f\x0f\x0f\x15exit\n' +else + zpty -b cj_editor zsh -fi "$CJ_TEST_SCRIPT" || exit 1 +fi +for attempt in {1..200}; do + while zpty -r cj_editor output; do print -rn -- "$output"; done + zpty -t cj_editor || break + sleep 0.05 +done +timed_out=0 +zpty -t cj_editor && timed_out=1 +while zpty -r cj_editor output; do print -rn -- "$output"; done +zpty -d cj_editor +exit $timed_out +"#, + ]) + .current_dir(temp.path()) + .env("PATH", env::join_paths(paths).unwrap()) + .env("TERM", "xterm") + .env("INPUTRC", "/dev/null") + .env("CJ_TEST_SHELL", shell) + .env( + "CJ_TEST_BASH", + bash.as_deref().unwrap_or(std::path::Path::new("bash")), + ) + .env("CJ_TEST_SCRIPT", source) + .env("CJ_TEST_RESULTS", &results) + .env("CJ_TEST_OLDER", &older) + .env("CJ_TEST_NEWER", &newer) + .output() + .unwrap(); + assert_success(&output); + let bytes = fs::read(&results).unwrap_or_else(|error| { + panic!( + "{shell}: {error}; {}", + String::from_utf8_lossy(&output.stdout) + ) + }); + let fields: Vec<_> = bytes + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + .map(|field| std::str::from_utf8(field).unwrap()) + .collect(); + assert_eq!(fields.len(), 15, "{shell}: {fields:?}"); + for (index, record) in fields.chunks_exact(5).enumerate() { + let expected = match index { + 0 => format!("code '{}'", newer.to_str().unwrap()), + 1 => format!("code '{}'", older.to_str().unwrap().replace('\'', "'\\''")), + _ => "code ".into(), + }; + assert_eq!(record[0], expected, "{shell}"); + let cursor = expected.chars().count(); + assert_eq!(record[1], cursor.to_string(), "{shell}"); + assert_eq!(record[2], temp.path().to_str().unwrap(), "{shell}"); + assert_eq!(record[3], older.to_str().unwrap(), "{shell}"); + assert_eq!(record[4], newer.to_str().unwrap(), "{shell}"); + } + } +} + +#[test] +fn real_zle_picker_preserves_the_line_on_cancellation_and_errors() { + if Command::new("zsh").arg("--version").output().is_err() { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + let temp = TempDir::new("key-zle-picker"); + let zoxide = temp.path().join("zoxide"); + let fzf = temp.path().join("fzf"); + write_executable( + &zoxide, + "if [ \"$2\" = --list ]; then printf '/candidate\\n'; else fzf; fi", + ); + write_executable( + &fzf, + r#" +test -t 0 < /dev/tty +case "$CJ_TEST_PICK_STATUS" in + 0) printf '%s\n' "$CJ_TEST_SELECTION" ;; + 7) printf '%s\n' 'durable picker failure' >&2; exit 7 ;; + *) exit 130 ;; +esac +"#, + ); + let config = temp.path().join("config.toml"); + fs::write(&config, format!("[programs]\nzoxide = {}\nfzf = {}\n[key-bindings]\nmacos = {{ key = 'ctrl-o', behaviors = ['zoxide', 'cj'] }}\nlinux = {{ key = 'ctrl-o', behaviors = ['zoxide', 'cj'] }}\n", serde_json::to_string(zoxide.to_str().unwrap()).unwrap(), serde_json::to_string(fzf.to_str().unwrap()).unwrap())).unwrap(); + let generated = cj(temp.path(), temp.path()) + .arg("-C") + .arg(&config) + .args(["init", "zsh", "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&generated); + let mut source = String::from_utf8(generated.stdout).unwrap(); + source.push_str( + r#" +_cj_history=('/fallback must not be used') +zle-line-init() { + local outcome + for outcome in 0 130 7; do + export CJ_TEST_PICK_STATUS=$outcome + BUFFER='code ' + CURSOR=2 + zle _cj_key_widget + printf '%s\000' "$BUFFER" "$CURSOR" "$PWD" "${_cj_history[@]}" >> "$CJ_TEST_RESULTS" + done + exit +} +zle -N zle-line-init +test_buffer='' +vared test_buffer +"#, + ); + let script = temp.path().join("widget.zsh"); + fs::write(&script, source).unwrap(); + fs::remove_file(&config).unwrap(); + let results = temp.path().join("results"); + let selection = temp.path().join("space's 雪 directory"); + 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())); + let output = Command::new("zsh") + .args([ + "-fc", + r#" +zmodload zsh/zpty || exit 1 +zpty -b cj_editor zsh -fi "$CJ_TEST_SCRIPT" || exit 1 +for attempt in {1..200}; do + while zpty -r cj_editor output; do print -rn -- "$output"; done + zpty -t cj_editor || break + sleep 0.05 +done +timed_out=0 +zpty -t cj_editor && timed_out=1 +while zpty -r cj_editor output; do print -rn -- "$output"; done +zpty -d cj_editor +exit $timed_out +"#, + ]) + .current_dir(temp.path()) + .env("PATH", env::join_paths(paths).unwrap()) + .env("TERM", "xterm") + .env("CJ_TEST_SCRIPT", &script) + .env("CJ_TEST_RESULTS", &results) + .env("CJ_TEST_SELECTION", &selection) + .output() + .unwrap(); + assert_success(&output); + assert!( + String::from_utf8_lossy(&output.stdout).contains("durable picker failure"), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let bytes = fs::read(results).unwrap(); + let fields: Vec<_> = bytes + .split(|byte| *byte == 0) + .filter(|value| !value.is_empty()) + .map(|value| std::str::from_utf8(value).unwrap()) + .collect(); + assert_eq!(fields.len(), 12); + let quoted = format!( + "code '{}'", + selection.to_str().unwrap().replace('\'', "'\\''") + ); + for (index, record) in fields.chunks_exact(4).enumerate() { + assert_eq!(record[0], if index == 0 { "ed } else { "code " }); + assert_eq!( + record[1], + if index == 0 { + quoted.chars().count().to_string() + } else { + "2".into() + } + ); + assert_eq!(record[2], temp.path().to_str().unwrap()); + assert_eq!(record[3], "/fallback must not be used"); + } +} diff --git a/tests/key_binding_nu.rs b/tests/key_binding_nu.rs new file mode 100644 index 0000000..1cc3d27 --- /dev/null +++ b/tests/key_binding_nu.rs @@ -0,0 +1,177 @@ +#![cfg(unix)] + +mod support; + +use std::fs; +use std::path::Path; +use std::process::Command; + +use serde_json::{Value, json}; +use support::{TempDir, assert_success, cj, write_executable}; + +fn available() -> bool { + let available = Command::new("nu").arg("--version").output().is_ok(); + assert!(available || std::env::var_os("CJ_REQUIRE_SHELLS").is_none()); + available +} + +fn run(root: &Path, behaviors: &str, history: Value, actions: Value) -> Vec { + let config = root.join("config.toml"); + let zoxide = toml_path(&root.join("zoxide")); + let fzf = toml_path(&root.join("picker")); + fs::write(&config, format!( + "[programs]\nzoxide = '{zoxide}'\nfzf = '{fzf}'\n[key-bindings]\nmacos = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\nlinux = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\nwindows = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\n" + )).unwrap(); + let generated = cj(root, root) + .arg("-C") + .arg(&config) + .args(["init", "nu", "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&generated); + fs::write(root.join("init.nu"), generated.stdout).unwrap(); + fs::write( + root.join("actions.json"), + json!({"history": history, "actions": actions}).to_string(), + ) + .unwrap(); + let binary = Path::new(env!("CARGO_BIN_EXE_cj")); + let mut paths = vec![binary.parent().unwrap().to_owned()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let output = Command::new("python3") + .arg("-c") + .arg(include_str!("support/nu_key_binding_pty.py")) + .arg(root) + .env("PATH", std::env::join_paths(paths).unwrap()) + .output() + .unwrap(); + assert_success(&output); + serde_json::from_slice(&output.stdout).unwrap() +} + +fn toml_path(path: &Path) -> String { + assert!(!path.to_string_lossy().contains('\'')); + path.to_string_lossy().into_owned() +} + +#[test] +fn nushell_history_widget_cycles_and_cancels_in_reedline() { + if !available() { + return; + } + let temp = TempDir::new("nu-history-widget"); + let history = json!(["/old path's", "/new雪"]); + let rows = run( + temp.path(), + "'cj'", + history.clone(), + json!([ + {"send": "echo é\u{1b}[D\u{f}", "record": true}, + {"send": "\u{f}", "record": true}, + {"send": "\u{f}", "record": true}, + {"send": "\u{f}", "record": true}, + {"send": "\u{3}"}, + {"send": "echo é\u{1b}[D\u{f}", "record": true}, + {"send": "X\u{f}", "record": true} + ]), + ); + assert_eq!(rows[0]["buffer"], "echo é r#'/new雪'#"); + assert_eq!(rows[1]["buffer"], "echo é r#'/old path's'#"); + assert_eq!(rows[2]["buffer"], "echo é"); + assert_eq!(rows[2]["cursor"], 5); + assert_eq!(rows[3]["buffer"], rows[0]["buffer"]); + assert_eq!(rows[4]["buffer"], rows[0]["buffer"]); + assert_eq!(rows[5]["buffer"], "echo é r#'/new雪'#X r#'/new雪'#"); + for row in rows { + assert_eq!(row["history"], history); + assert_eq!(row["cwd"], temp.path().to_string_lossy().as_ref()); + assert_eq!(row["route"], "/sentinel/down/route"); + } +} + +#[test] +fn nushell_widget_picker_owns_foreground_terminal_and_preserves_literal_path() { + if !available() { + return; + } + let temp = TempDir::new("nu-picker-widget"); + write_executable( + &temp.path().join("zoxide"), + r#"if [ "$2" = --list ]; then printf '/candidate\n'; else exec fzf; fi"#, + ); + let target = "/a path's '$()雪\nend\n"; + let picker = format!( + "exec python3 -c {}", + shell_quote(&format!( + "import os,sys; fd=os.open('/dev/tty',os.O_RDWR); assert os.tcgetpgrp(fd)==os.getpgrp(); assert os.isatty(2); sys.stdout.write({target:?}+'\\n')" + )) + ); + write_executable(&temp.path().join("picker"), &picker); + let rows = run( + temp.path(), + "'zoxide', 'cj'", + json!(["/fallback"]), + json!([ + {"send":"echo base\u{f}","record":true} + ]), + ); + assert_eq!(rows[0]["buffer"], format!("echo base r#'{target}'#")); + assert_eq!(rows[0]["cwd"], temp.path().to_string_lossy().as_ref()); + assert_eq!(rows[0]["history"], json!(["/fallback"])); +} + +#[test] +fn nushell_widget_empty_missing_cancel_and_failure_preserve_buffer() { + if !available() { + return; + } + for (label, script, behaviors, expected) in [ + ("empty-history", None, "'cj'", "echo base"), + ( + "missing-fallback", + None, + "'zoxide', 'cj'", + "echo base r#'/fallback'#", + ), + ( + "empty-fallback", + Some("exit 0"), + "'zoxide', 'cj'", + "echo base r#'/fallback'#", + ), + ( + "cancel", + Some("if [ \"$2\" = --list ]; then printf '/candidate\\n'; else exit 130; fi"), + "'zoxide', 'cj'", + "echo base", + ), + ("failure", Some("exit 2"), "'zoxide', 'cj'", "echo base"), + ] { + let temp = TempDir::new(&format!("nu-widget-{label}")); + if let Some(script) = script { + write_executable(&temp.path().join("zoxide"), script); + write_executable(&temp.path().join("picker"), "exit 0"); + } + let history = if label == "empty-history" { + json!([]) + } else { + json!(["/fallback"]) + }; + let rows = run( + temp.path(), + behaviors, + history.clone(), + json!([ + {"send":"echo base\u{f}","record":true} + ]), + ); + assert_eq!(rows[0]["buffer"], expected, "{label}"); + assert_eq!(rows[0]["history"], history, "{label}"); + } +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} diff --git a/tests/key_binding_powershell.rs b/tests/key_binding_powershell.rs new file mode 100644 index 0000000..d22a6ec --- /dev/null +++ b/tests/key_binding_powershell.rs @@ -0,0 +1,202 @@ +#![cfg(unix)] + +mod support; + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use support::{TempDir, assert_success, cj, write_executable}; + +#[test] +fn real_psreadline_keypresses_preserve_buffers_history_and_cwd() { + let pwsh = env::var_os("CJ_TEST_PWSH").unwrap_or_else(|| "pwsh".into()); + if Command::new(&pwsh).arg("-Version").output().is_err() + || Command::new("zsh").arg("--version").output().is_err() + { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + for (behaviors, picker_status, candidates, snapshots) in [ + ("'zoxide', 'cj'", "0", "/candidate", 1), + ("'cj', 'zoxide'", "0", "/candidate", 3), + ("'zoxide', 'cj'", "130", "/candidate", 1), + ("'zoxide', 'cj'", "7", "/candidate", 1), + ("'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"); + write_executable( + &zoxide, + r#" +case "$*" in + 'query --list') printf '%s' "$CJ_TEST_CANDIDATES" ;; + 'query --interactive') printf 'called\n' >> "$CJ_TEST_CALLS"; fzf ;; + *) exit 2 ;; +esac +"#, + ); + write_executable( + &fzf, + r#" +[ -t 0 ] || { echo 'picker requires a foreground terminal' >&2; exit 2; } +[ -t 2 ] || { echo 'picker stderr requires a foreground terminal' >&2; exit 2; } +printf '%s\n' "$CJ_TEST_PICK" +if [ "$CJ_TEST_PICK_STATUS" = 7 ]; then echo 'picker exploded' >&2; fi +exit "$CJ_TEST_PICK_STATUS" +"#, + ); + let config = temp.path().join("config.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(), + serde_json::to_string(fzf.to_str().unwrap()).unwrap(), + )).unwrap(); + let generated = cj(temp.path(), temp.path()) + .arg("-C") + .arg(&config) + .args(["init", "powershell", "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&generated); + let source = temp.path().join("editor.ps1"); + let mut script = String::from( + "Import-Module PSReadLine\n$ErrorActionPreference = 'Stop'\nSet-PSReadLineOption -EditMode Emacs -PredictionSource None -HistorySaveStyle SaveNothing\n", + ); + script.push_str(std::str::from_utf8(&generated.stdout).unwrap()); + script.push_str(r#" +$global:__cj_history = @($env:CJ_TEST_OLDER, $env:CJ_TEST_NEWER) +$global:__cj_down_route = '/unchanged/down/route' +$global:__cj_test_records = 0 +# Only this independent observation chord is test-owned. Ctrl+o stays bound to +# the generated handler and is delivered through the terminal event loop. +Set-PSReadLineKeyHandler -Chord 'Ctrl+g' -ScriptBlock { + $buffer = ''; $cursor = 0 + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$buffer, [ref]$cursor) + $record = @{ Buffer = $buffer; Cursor = $cursor; Cwd = (Get-Location).Path; History = @($global:__cj_history); Down = $global:__cj_down_route } + [IO.File]::AppendAllText($env:CJ_TEST_RESULTS, ($record | ConvertTo-Json -Compress) + "`n") + $global:__cj_test_records++ + if ($global:__cj_test_records -eq [int]$env:CJ_TEST_SNAPSHOTS) { + [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() + [Microsoft.PowerShell.PSConsoleReadLine]::Insert('exit') + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() + } +} +function global:prompt { + [IO.File]::WriteAllText($env:CJ_TEST_READY, 'ready') + 'cj-test> ' +} +"#); + fs::write(&source, script).unwrap(); + let launcher = temp.path().join("launch-pwsh"); + write_executable( + &launcher, + r#" +stty rows 40 cols 200 +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 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())); + let output = Command::new("zsh") + .args([ + "-fc", + r#" +zmodload zsh/zpty || exit 1 +zpty -b cj_editor "$CJ_TEST_LAUNCHER" || exit 1 +sent=0 +for attempt in {1..400}; do + while zpty -r cj_editor output; do + print -rn -- "$output" + # .NET Console requests a cursor report while PSReadLine initializes. + [[ "$output" == *$'\e[6n'* ]] && zpty -w -n cj_editor $'\e[1;1R' + done + if [[ -f "$CJ_TEST_READY" && $sent == 0 ]]; then + sleep 0.1 + zpty -w -n cj_editor 'code ' + for count in {1..$CJ_TEST_SNAPSHOTS}; do zpty -w -n cj_editor $'\x0f\x07'; done + sent=1 + fi + zpty -t cj_editor || break + sleep 0.025 +done +timed_out=0 +zpty -t cj_editor && timed_out=1 +while zpty -r cj_editor output; do print -rn -- "$output"; done +zpty -d cj_editor +exit $timed_out +"#, + ]) + .current_dir(temp.path()) + .env("PATH", env::join_paths(paths).unwrap()) + .env("TERM", "xterm-256color") + .env("CJ_TEST_PWSH", &pwsh) + .env("CJ_TEST_SCRIPT", &source) + .env("CJ_TEST_LAUNCHER", &launcher) + .env("CJ_TEST_RESULTS", &results) + .env("CJ_TEST_READY", temp.path().join("ready")) + .env("CJ_TEST_CALLS", temp.path().join("calls")) + .env("CJ_TEST_SNAPSHOTS", snapshots.to_string()) + .env("CJ_TEST_OLDER", &older) + .env("CJ_TEST_NEWER", &newer) + .env("CJ_TEST_PICK", target) + .env("CJ_TEST_PICK_STATUS", picker_status) + .env("CJ_TEST_CANDIDATES", candidates) + .output() + .unwrap(); + assert_success(&output); + let records: Vec = fs::read_to_string(&results) + .unwrap_or_else(|error| panic!("{error}: {}", String::from_utf8_lossy(&output.stdout))) + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(records.len(), snapshots); + for (index, record) in records.iter().enumerate() { + let chosen = if snapshots == 3 { + match index { + 0 => Some(newer.to_str().unwrap()), + 1 => Some(older.to_str().unwrap()), + _ => None, + } + } else if picker_status == "0" { + Some(target) + } else { + None + }; + let expected = chosen.map_or_else( + || "code ".into(), + |path| { + format!( + "code '{}'", + path.replace('\'', "''") + .replace('‘', "‘‘") + .replace('’', "’’") + ) + }, + ); + assert_eq!(record["Buffer"], expected, "{behaviors}, {picker_status}"); + assert_eq!(record["Cursor"], expected.encode_utf16().count()); + assert_eq!(record["Cwd"], temp.path().to_str().unwrap()); + assert_eq!( + record["History"], + serde_json::json!([older.to_str().unwrap(), newer.to_str().unwrap()]) + ); + assert_eq!(record["Down"], "/unchanged/down/route"); + } + assert_eq!(temp.path().join("calls").exists(), snapshots == 1); + if picker_status == "7" { + assert!(String::from_utf8_lossy(&output.stdout).contains("picker exploded")); + assert!( + String::from_utf8_lossy(&output.stdout).contains("interactive zoxide query failed"), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + } + } +} diff --git a/tests/key_bindings.rs b/tests/key_bindings.rs new file mode 100644 index 0000000..1a63bdf --- /dev/null +++ b/tests/key_bindings.rs @@ -0,0 +1,526 @@ +#![cfg(unix)] +mod support; + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; +use support::{TempDir, assert_success, cj, write_executable}; + +struct Fixture { + temp: TempDir, + zoxide: std::path::PathBuf, + fzf: std::path::PathBuf, +} +impl Fixture { + fn new() -> Self { + let temp = TempDir::new("key-binding"); + let zoxide = temp.path().join("custom zoxide's executable"); + let fzf = temp.path().join("custom fzf's executable"); + write_executable( + &zoxide, + r#" +case "$*" in + 'query --list') printf '%s' "${CJ_TEST_CANDIDATES-/somewhere}"; exit "${CJ_TEST_LIST_STATUS-0}" ;; + 'query --interactive') + printf '%s\n' "$*" >> "$CJ_TEST_LOG" + fzf + ;; + *) echo 'unexpected zoxide arguments' >&2; exit 2 ;; +esac"#, + ); + write_executable( + &fzf, + r#" +printf '%s' "${_ZO_FZF_OPTS-}" > "$CJ_TEST_OPTS" +printf '%s' "${CJ_TEST_PICK-}" +if [ "${CJ_TEST_PICK_STATUS-0}" = 7 ]; then echo 'picker exploded' >&2; fi +exit "${CJ_TEST_PICK_STATUS-0}""#, + ); + Self { temp, zoxide, fzf } + } + fn command(&self) -> Command { + let mut command = cj(self.temp.path(), self.temp.path()); + command + .args(["--internal-key-binding-zoxide"]) + .arg(&self.zoxide) + .arg(&self.fzf); + self.environment(&mut command); + command + } + fn environment(&self, command: &mut Command) { + command + .env("CJ_TEST_LOG", self.temp.path().join("calls")) + .env("CJ_TEST_OPTS", self.temp.path().join("opts")); + } + fn source(&self, shell: &str, behaviors: &str) -> String { + self.source_with_chord(shell, behaviors, "ctrl-o") + } + fn source_with_chord(&self, shell: &str, behaviors: &str, chord: &str) -> String { + let config = self.temp.path().join("config.toml"); + let text = format!( + "[programs]\nzoxide = {}\nfzf = {}\n[key-bindings]\nmacos = {{ key = '{chord}', behaviors = [{behaviors}] }}\nlinux = {{ key = '{chord}', behaviors = [{behaviors}] }}\nwindows = {{ key = '{chord}', behaviors = [{behaviors}] }}\n", + serde_json::to_string(self.zoxide.to_str().unwrap()).unwrap(), + serde_json::to_string(self.fzf.to_str().unwrap()).unwrap() + ); + fs::write(&config, text).unwrap(); + let output = cj(self.temp.path(), self.temp.path()) + .arg("-C") + .arg(&config) + .args(["init", shell, "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&output); + // Keypresses use the initialized settings even when the file disappears. + fs::remove_file(config).unwrap(); + String::from_utf8(output.stdout).unwrap() + } + fn widget( + &self, + shell: &str, + behaviors: &str, + status: &str, + candidates: &str, + prefix: &str, + selection: &str, + ) -> Output { + let source = self.source(shell, behaviors); + let script = self.temp.path().join("widget.sh"); + let (line, cursor) = if shell == "bash" { + ("READLINE_LINE", "READLINE_POINT") + } else { + ("BUFFER", "CURSOR") + }; + fs::write( + &script, + format!( + r#"{source} +_cj_history=('/old history' '/new history') +{line}="$CJ_TEST_PREFIX" +{cursor}=1 +_cj_key_widget +printf '%s\0%s\0%s\0' "${line}" "${cursor}" "$PWD" +printf '%s\0' "${{_cj_history[@]}}" +"# + ), + ) + .unwrap(); + let mut command = Command::new(shell); + command + .arg(&script) + .current_dir(self.temp.path()) + .env( + "PATH", + format!( + "{}:{}", + Path::new(env!("CARGO_BIN_EXE_cj")) + .parent() + .unwrap() + .display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .env("CJ_TEST_PREFIX", prefix) + .env("CJ_TEST_PICK", selection) + .env("CJ_TEST_PICK_STATUS", status) + .env("CJ_TEST_CANDIDATES", candidates); + self.environment(&mut command); + command.output().unwrap() + } +} +fn shells() -> impl Iterator { + ["bash", "zsh"] + .into_iter() + .filter(|shell| Command::new(shell).arg("--version").output().is_ok()) +} +fn legacy_bash() -> bool { + let output = Command::new("bash") + .args(["-c", "printf '%s' \"${BASH_VERSINFO[0]}\""]) + .output() + .unwrap(); + String::from_utf8(output.stdout) + .unwrap() + .parse::() + .unwrap() + < 5 +} +fn fields(output: &Output) -> Vec<&str> { + std::str::from_utf8(&output.stdout) + .unwrap() + .split('\0') + .collect() +} + +#[test] +fn helper_runs_configured_programs_and_preserves_zoxide_options_and_path() { + let fixture = Fixture::new(); + let path = "/space's dir/Unicode 台灣/$literal\n\n"; + let output = fixture + .command() + .env("CJ_TEST_PICK", format!("{path}\n")) + .env("_ZO_FZF_OPTS", "--height=80% --no-sort") + .output() + .unwrap(); + assert_success(&output); + assert_eq!(output.stdout, format!("selected\n{path}").as_bytes()); + assert_eq!( + fs::read_to_string(fixture.temp.path().join("opts")).unwrap(), + "--height=80% --no-sort" + ); + assert_eq!( + fs::read_to_string(fixture.temp.path().join("calls")).unwrap(), + "query --interactive\n" + ); +} + +#[test] +fn helper_distinguishes_missing_empty_cancelled_and_failed_sources() { + let fixture = Fixture::new(); + let empty = fixture + .command() + .env("CJ_TEST_CANDIDATES", "") + .output() + .unwrap(); + assert_success(&empty); + assert_eq!(empty.stdout, b"empty\n"); + assert!(!fixture.temp.path().join("calls").exists()); + let cancelled = fixture + .command() + .env("CJ_TEST_PICK_STATUS", "130") + .output() + .unwrap(); + assert_success(&cancelled); + assert_eq!(cancelled.stdout, b"cancelled\n"); + let failed = fixture + .command() + .env("CJ_TEST_PICK_STATUS", "7") + .output() + .unwrap(); + assert!(!failed.status.success()); + assert!(String::from_utf8_lossy(&failed.stderr).contains("picker exploded")); + let list_failure = fixture + .command() + .env("CJ_TEST_LIST_STATUS", "3") + .output() + .unwrap(); + assert!(!list_failure.status.success()); + assert!(String::from_utf8_lossy(&list_failure.stderr).contains("zoxide query failed")); + fs::remove_file(&fixture.fzf).unwrap(); + let missing = fixture.command().output().unwrap(); + assert_success(&missing); + assert!( + String::from_utf8_lossy(&missing.stdout) + .starts_with("unavailable\nfzf executable not found:") + ); +} + +#[test] +fn widgets_append_quoted_paths_and_preserve_cwd_and_history() { + for shell in shells() { + for (prefix, target) in [ + ("code --wait", "/space's dir/台灣;$()"), + ("", "/selected"), + ("cd ", "-leading-dash"), + ("open café", "/last\n\n"), + ] { + let fixture = Fixture::new(); + let output = fixture.widget( + shell, + "'zoxide', 'cj'", + "0", + "/candidate", + prefix, + &format!("{target}\n"), + ); + assert_success(&output); + let fields = fields(&output); + let target = if target.starts_with('-') { + format!("./{target}") + } else { + target.into() + }; + let separator = if prefix.is_empty() || prefix.ends_with(' ') { + "" + } else { + " " + }; + let expected = format!("{prefix}{separator}'{}'", target.replace('\'', "'\\''")); + assert_eq!(fields[0], expected, "{shell}"); + let cursor = if shell == "bash" && legacy_bash() { + expected.len() + } else { + expected.chars().count() + }; + assert_eq!(fields[1], cursor.to_string()); + assert_eq!(fields[2], fixture.temp.path().to_str().unwrap()); + assert_eq!(&fields[3..5], ["/old history", "/new history"]); + } + } +} + +#[test] +fn cancellation_and_query_failure_stop_the_chain_without_edits() { + for shell in shells() { + for status in ["130", "7"] { + let fixture = Fixture::new(); + let output = fixture.widget( + shell, + "'zoxide', 'cj'", + status, + "/candidate", + "code original", + "", + ); + assert_success(&output); + let fields = fields(&output); + assert_eq!(&fields[..2], ["code original", "1"], "{shell}, {status}"); + assert_eq!(fields[2], fixture.temp.path().to_str().unwrap()); + if status == "7" { + assert!(String::from_utf8_lossy(&output.stderr).contains("picker exploded")); + } + } + } +} + +#[test] +fn unavailable_or_empty_source_falls_through_in_configured_order() { + for shell in shells() { + for missing in [false, true] { + let fixture = Fixture::new(); + if missing { + fs::remove_file(&fixture.fzf).unwrap(); + } + let output = fixture.widget(shell, "'zoxide', 'cj'", "0", "", "code", ""); + assert_success(&output); + assert_eq!(fields(&output)[0], "code '/new history'", "{shell}"); + assert!(!fixture.temp.path().join("calls").exists()); + } + let fixture = Fixture::new(); + let output = fixture.widget( + shell, + "'cj', 'zoxide'", + "0", + "/candidate", + "code", + "/selected\n", + ); + assert_success(&output); + assert_eq!(fields(&output)[0], "code '/new history'"); + assert!(!fixture.temp.path().join("calls").exists()); + } +} + +#[test] +fn missing_dependency_is_diagnostic_when_no_fallback_edits() { + for shell in shells() { + let fixture = Fixture::new(); + fs::remove_file(&fixture.zoxide).unwrap(); + let output = fixture.widget(shell, "'zoxide'", "0", "", "cd", ""); + assert_success(&output); + assert_eq!(&fields(&output)[..2], ["cd", "1"]); + assert!(String::from_utf8_lossy(&output.stderr).contains("zoxide executable not found:")); + } +} + +#[test] +fn installed_zoxide_owns_interactive_selection_and_cancel_status() { + if Command::new("zoxide").arg("--version").output().is_err() { + return; + } + let fixture = Fixture::new(); + let directory = fixture.temp.path().join("real zoxide's 台灣 directory"); + fs::create_dir(&directory).unwrap(); + let data = fixture.temp.path().join("zoxide-data"); + let added = Command::new("zoxide") + .arg("add") + .arg(&directory) + .env("_ZO_DATA_DIR", &data) + .env_remove("_ZO_EXCLUDE_DIRS") + .output() + .unwrap(); + assert_success(&added); + write_executable( + &fixture.fzf, + r#" +printf '%s' "${FZF_DEFAULT_OPTS-}" > "$CJ_TEST_OPTS" +if [ "${CJ_TEST_CANCEL-0}" = 1 ]; then exit 130; fi +tr '\000' '\n' | head -n 1 +"#, + ); + for cancel in [false, true] { + let mut command = cj(fixture.temp.path(), fixture.temp.path()); + command + .arg("--internal-key-binding-zoxide") + .arg("zoxide") + .arg(&fixture.fzf) + .env("_ZO_DATA_DIR", &data) + .env_remove("_ZO_EXCLUDE_DIRS") + .env("_ZO_FZF_OPTS", "--height=80%") + .env("CJ_TEST_CANCEL", if cancel { "1" } else { "0" }); + fixture.environment(&mut command); + let output = command.output().unwrap(); + assert_success(&output); + if cancel { + assert_eq!(output.stdout, b"cancelled\n"); + } else { + assert_eq!( + output.stdout, + format!("selected\n{}", directory.display()).as_bytes() + ); + } + assert_eq!( + fs::read_to_string(fixture.temp.path().join("opts")).unwrap(), + "--height=80%" + ); + } +} + +#[test] +fn powershell_dispatcher_preserves_buffers_and_orders_behaviors() { + if Command::new("pwsh").arg("-Version").output().is_err() { + return; + } + // Exercise PowerShell parsing and dispatch with an in-memory line-editor + // adapter. The native editor lifecycle is covered by the PTY tests. + for (behaviors, status, candidates, expected) in [ + ("'zoxide', 'cj'", "0", "/candidate", "code '/a''s 雪/‘’'"), + ("'zoxide', 'cj'", "130", "/candidate", "code"), + ("'zoxide', 'cj'", "7", "/candidate", "code"), + ("'zoxide', 'cj'", "0", "", "code '/new history'"), + ("'cj', 'zoxide'", "0", "/candidate", "code '/new history'"), + ] { + let fixture = Fixture::new(); + let source = fixture.source("powershell", behaviors); + let script = fixture.temp.path().join("widget.ps1"); + fs::write(&script, format!(r#" +Import-Module Microsoft.PowerShell.Utility +Import-Module Microsoft.PowerShell.Management +$ErrorActionPreference = 'Stop' +$PSModuleAutoLoadingPreference = 'None' +Add-Type -TypeDefinition @' +namespace Microsoft.PowerShell {{ + public static class PSConsoleReadLine {{ + public static string Buffer = "code"; + public static int Cursor = 1; + public static void GetBufferState(ref string buffer, ref int cursor) {{ buffer = Buffer; cursor = Cursor; }} + public static void Replace(int start, int length, string text) {{ Buffer = text; }} + public static void SetCursorPosition(int cursor) {{ Cursor = cursor; }} + }} +}} +'@ +{source} +$global:__cj_history = @('/old history', '/new history') +Invoke-CjKeyWidget +[Console]::WriteLine([Microsoft.PowerShell.PSConsoleReadLine]::Buffer) +[Console]::WriteLine([Microsoft.PowerShell.PSConsoleReadLine]::Cursor) +[Console]::WriteLine((Get-Location).Path) +"#)).unwrap(); + let mut command = Command::new("pwsh"); + command + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"]) + .arg(&script) + .current_dir(fixture.temp.path()) + .env( + "PATH", + format!( + "{}:{}", + Path::new(env!("CARGO_BIN_EXE_cj")) + .parent() + .unwrap() + .display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .env("CJ_TEST_PICK", "/a's 雪/‘’\n") + .env("CJ_TEST_PICK_STATUS", status) + .env("CJ_TEST_CANDIDATES", candidates); + fixture.environment(&mut command); + let output = command.output().unwrap(); + assert_success(&output); + let text = String::from_utf8_lossy(&output.stdout); + let lines: Vec<_> = text.lines().collect(); + let expected = expected.replace('‘', "‘‘").replace('’', "’’"); + assert_eq!( + lines[0], + expected, + "{status}, {behaviors}, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + lines[1], + if status == "130" || status == "7" { + 1 + } else { + expected.encode_utf16().count() + } + .to_string() + ); + assert_eq!(lines[2], fixture.temp.path().to_str().unwrap()); + } +} + +#[test] +fn sourcing_again_updates_only_the_binding_owned_by_cj() { + let bash = if Path::new("/opt/homebrew/bin/bash").exists() { + "/opt/homebrew/bin/bash" + } else { + "bash" + }; + for (shell, executable) in [("bash", bash), ("zsh", "zsh")] { + if Command::new(executable).arg("--version").output().is_err() { + continue; + } + if shell == "bash" + && !Command::new(executable) + .args(["-c", "(( BASH_VERSINFO[0] >= 4 ))"]) + .status() + .unwrap() + .success() + { + continue; + } + let fixture = Fixture::new(); + let first = fixture.source_with_chord(shell, "'cj'", "ctrl-o"); + let second = fixture.source_with_chord(shell, "'cj'", "alt-o"); + let disabled = fixture.source_with_chord(shell, "", "alt-o"); + let query = if shell == "bash" { + "bind -X 2>/dev/null" + } else { + "bindkey '^O'; bindkey '^[o'" + }; + let replace = if shell == "bash" { + r#"bind '"\C-o":beginning-of-line'"# + } else { + "bindkey '^O' beginning-of-line" + }; + let query_user = if shell == "bash" { + "bind -q beginning-of-line" + } else { + "bindkey '^O'" + }; + let script = fixture.temp.path().join("reload.sh"); + fs::write(&script, format!("{first}\n{second}\n{query}\nprintf '\\0'\n{disabled}\n{query}\nprintf '\\0'\n{first}\n{replace}\n{disabled}\n{query_user}\n")).unwrap(); + let output = Command::new(executable).arg(&script).output().unwrap(); + assert_success(&output); + let sections = fields(&output); + assert!( + sections[0].contains("_cj_key_widget"), + "{shell}: {}", + sections[0] + ); + if shell == "bash" { + assert!(!sections[0].contains("\\C-o")); + assert!(sections[0].contains("\\eo")); + } else { + assert!(sections[0].contains("\"^O\" undefined-key")); + } + assert!( + !sections[1].contains("_cj_key_widget"), + "{shell}: {}", + sections[1] + ); + assert!(sections[2].contains("beginning-of-line")); + if shell == "bash" { + assert!(sections[2].contains("\\C-o")); + } + } +} diff --git a/tests/support/nu_key_binding_pty.py b/tests/support/nu_key_binding_pty.py new file mode 100644 index 0000000..8f0cde9 --- /dev/null +++ b/tests/support/nu_key_binding_pty.py @@ -0,0 +1,114 @@ +"""Exercise Nushell's actual Reedline keybinding in an isolated terminal.""" +import fcntl +import json +import os +import pathlib +import pty +import select +import signal +import struct +import sys +import termios +import time + +root = pathlib.Path(sys.argv[1]) +actions = json.loads((root / "actions.json").read_text()) +records = root / "records.jsonl" +records.unlink(missing_ok=True) +config = root / "terminal.nu" + + +def quote(value): + hashes = "#" + while "'" + hashes in str(value): + hashes += "#" + return "r" + hashes + "'" + str(value) + "'" + hashes + + +config.write_text( + f"""use {quote(root / 'init.nu')} * +if '__cj_history' not-in ($env | columns) {{ error make {{msg: 'cj history was not initialized'}} }} +$env.config.show_banner = false +$env.config.shell_integration.osc133 = false +$env.config.edit_mode = 'emacs' +$env.PROMPT_COMMAND = {{|| 'CJPTY> '}} +$env.PROMPT_COMMAND_RIGHT = {{|| ''}} +$env.PROMPT_INDICATOR = '' +$env.__cj_history = {json.dumps(actions['history'], ensure_ascii=False)} +$env.CJ_INTERNAL_DOWN_ROUTE = '/sentinel/down/route' +def --env _cj-test-widget [] {{ + _cj-key-widget + let row = {{buffer: (commandline), cursor: (commandline get-cursor), + history: $env.__cj_history, cwd: $env.PWD, route: $env.CJ_INTERNAL_DOWN_ROUTE}} + (($row | to json -r) + (char nl)) | save --append {quote(records)} +}} +$env.config.keybindings ++= [{{name: cj-test, modifier: control, keycode: char_o, + mode: emacs, event: {{send: executehostcommand, cmd: '_cj-test-widget'}}}}] +""" +) + +pid, fd = pty.fork() +if pid == 0: + os.environ["TERM"] = "xterm-256color" + os.environ["XDG_CONFIG_HOME"] = str(root / "xdg") + os.environ["HOME"] = str(root) + os.chdir(root) + os.execvp("nu", ["nu", "--no-history", "--config", str(config)]) + +fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 150, 0, 0)) +transcript = bytearray() + + +def pump(): + if select.select([fd], [], [], 0.02)[0]: + try: + data = os.read(fd, 65536) + except OSError: + return + transcript.extend(data) + # Reedline requests cursor position while repainting after host commands. + if b"\x1b[6n" in data: + os.write(fd, b"\x1b[1;1R") + + +def wait_until(predicate): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + pump() + if predicate(): + return + raise AssertionError(transcript.decode(errors="replace")) + + +def rows(): + if not records.exists(): + return [] + try: + return [json.loads(line) for line in records.read_text().splitlines()] + except (json.JSONDecodeError, UnicodeDecodeError): + return [] + + +try: + wait_until(lambda: b"CJPTY> " in transcript) + count = 0 + for action in actions["actions"]: + before = len(transcript) + os.write(fd, action["send"].encode()) + if action.get("record", False): + count += 1 + wait_until(lambda: len(rows()) >= count) + # Wait for the prompt hook before delivering the next key sequence. + wait_until(lambda: b"\x1b[?2004h" in transcript[before:]) + else: + # Ctrl+C and Enter create another prompt; wait for its redraw. + wait_until(lambda: b"\x1b[?2004h" in transcript[before:]) + print(json.dumps(rows(), ensure_ascii=False), flush=True) +finally: + (root / "terminal.log").write_bytes(transcript) + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + os.close(fd) + os.waitpid(pid, 0)