diff --git a/README.md b/README.md index e371d5d..789d4d7 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,9 @@ cd external-ssd # a configured mount cd top # top level of the current Git repository cd origin # main worktree; "og" is also enabled by default cd ^^^ # three directories up +cd ^3 # the same three parent moves cd vvv # go back three directory-history entries +cd v3 # the same three history entries ``` The down ticker uses browser-style directory history: every successful `cd` change @@ -122,6 +124,24 @@ Moving up with `cd ^^^` records each traversed parent so `cd vvv` retraces the m History keeps the latest 100 departures in the current shell session and resets when you reload the integration. Existing directories always win over tickers. +Repeated tickers and counted tickers are equivalent: `^^` means `^2`, and `vvv` +means `v3`. Counts are decimal integers from 1 to 2147483647; leading zeros are +accepted as decimal (`^08` means eight levels). Zero, signed, fractional, and +overflowing counts report an error without changing the directory or history. +The same syntax uses your configured tickers, for example `u5` or `k3`. + +Upward navigation stops at the filesystem root even if the count exceeds the +available parents. It records only the actual parent moves, subject to the history +limit; already at root is a successful no-op. For example, `cd ^5` from `/a/b` +reaches `/`, and `cd v2` returns to `/a/b`. Backward navigation remains strict: +`cd v5` requires five history entries and fails without consuming any if fewer +are available. It goes back through history, not down into child directories. + +A real directory named `^2` or even `^-1` still takes precedence. Otherwise, +numeric-looking targets beginning with a configured ticker are reserved for +counted navigation before aliases, mounts, or Git keywords. Names such as `venv` +remain ordinary targets. Use `-r` to treat a destination as a literal path. + Going back consumes entries without adding the current directory. An empty or insufficient history reports `cj: directory history exhausted`; a missing history destination reports the shell's directory error. Both leave history and the diff --git a/docs/compatibility.md b/docs/compatibility.md index 3657740..5c282dc 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -28,6 +28,12 @@ then supplies zoxide or literal-path fallback. Explicit `-r`, `-z`, and `-Z` mod keep their documented overrides. A configured alias or mount that cannot be reached remains an error rather than silently resolving to another destination. +Navigation supports both repeated tickers and positive decimal counts: `^^` and +`^2` are equivalent, as are `vvv` and `v3`, including with configured tickers. +Upward counts cap at the filesystem root; backward counts exceeding available +history fail without changing the directory or history. Count limits and the +directory-first rule are documented in the README. + ## Generated shell integration The documented `cd` wrapper, directory history, Tab completion, and optional key diff --git a/src/resolver.rs b/src/resolver.rs index f86fc11..d5252fc 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -31,10 +31,15 @@ pub fn resolve( } if let Some(text) = targets[0].to_str() { - if let Some(count) = repeated_count(text, &config.tickers.navigate_up) { - return Ok(parent_path(count)); + if let Some(count) = ticker_count(text, &config.tickers.navigate_up)? { + let logical = std::env::var_os("PWD"); + return Ok(parent_path( + count, + &navigation.cwd, + logical.as_deref().map(std::path::Path::new), + )); } - if let Some(count) = repeated_count(text, &config.tickers.navigate_down) { + if let Some(count) = ticker_count(text, &config.tickers.navigate_down)? { return navigate_down(count, navigation); } if let Some(path) = config.aliases.get(text) { @@ -179,16 +184,46 @@ enum ZoxideFailure { Query(String), } -fn repeated_count(target: &str, ticker: &str) -> Option { - let ticker = ticker.chars().next()?; - let mut characters = target.chars(); - let first = characters.next()?; - (first == ticker && characters.all(|character| character == ticker)) - .then(|| target.chars().count()) +fn ticker_count(target: &str, ticker: &str) -> Result, String> { + let Some(character) = ticker.chars().next() else { + return Ok(None); + }; + let Some(suffix) = target.strip_prefix(ticker) else { + return Ok(None); + }; + if target.chars().all(|value| value == character) { + return Ok(Some(target.chars().count())); + } + // Reserve numeric-looking suffixes while leaving names such as "venv" alone. + if !suffix.starts_with(|character: char| { + character.is_ascii_digit() || matches!(character, '+' | '-' | '.') + }) { + return Ok(None); + } + let count = suffix + .bytes() + .all(|byte| byte.is_ascii_digit()) + .then(|| suffix.parse::().ok()) + .flatten() + .filter(|count| (1..=2_147_483_647).contains(count)); + count.map(|count| Some(count as usize)).ok_or_else(|| { + format!("invalid navigation count in {target:?}: expected a positive decimal integer from 1 to 2147483647") + }) } -fn parent_path(count: usize) -> PathBuf { - std::iter::repeat_n("..", count).collect() +fn parent_path(count: usize, cwd: &std::path::Path, logical: Option<&std::path::Path>) -> PathBuf { + // More parent components than either current path's depth cannot change the + // destination. Keep relative components for native logical/symlink semantics, + // but never allocate a path proportional to an arbitrary numeric count. + let depth = |path: &std::path::Path| { + path.components() + .filter(|component| matches!(component, Component::Normal(_))) + .count() + }; + let limit = depth(cwd) + .max(logical.filter(|path| path.is_absolute()).map_or(0, depth)) + .max(1); + std::iter::repeat_n("..", count.min(limit)).collect() } fn navigate_down(count: usize, navigation: &NavigationContext) -> Result { @@ -277,9 +312,61 @@ mod tests { #[test] fn repeated_ticker_becomes_parent_path() { - assert_eq!(repeated_count("^^^", "^"), Some(3)); - assert_eq!(repeated_count("^x", "^"), None); - assert_eq!(parent_path(3), PathBuf::from("../../..")); + assert_eq!(ticker_count("^^^", "^"), Ok(Some(3))); + assert_eq!(ticker_count("^x", "^"), Ok(None)); + assert_eq!( + parent_path(3, Path::new("a/b/c"), None), + PathBuf::from("../../..") + ); + } + + #[test] + fn counted_tickers_are_positive_decimal_and_bounded() { + for (target, ticker, count) in [ + ("^2", "^", 2), + ("v003", "v", 3), + ("u08", "u", 8), + ("k2147483647", "k", 2_147_483_647), + ] { + assert_eq!(ticker_count(target, ticker), Ok(Some(count)), "{target}"); + } + for target in [ + "^0", + "^000", + "^-1", + "^+1", + "^1.5", + "^.5", + "^1e2", + "^1\n", + "^1\r\n", + "^1 ", + "^2147483648", + "^999999999999999999999999", + ] { + assert!( + ticker_count(target, "^") + .unwrap_err() + .contains("positive decimal integer"), + "{target}" + ); + } + for (target, ticker) in [("venv", "v"), ("code", "^"), ("^^2", "^"), ("", "^")] { + assert_eq!(ticker_count(target, ticker), Ok(None)); + } + assert_eq!( + parent_path(2_147_483_647, Path::new("a/b"), None), + PathBuf::from("../..") + ); + assert_eq!( + parent_path(2_147_483_647, Path::new("/"), None), + PathBuf::from("..") + ); + #[cfg(unix)] + assert_eq!( + parent_path(3, Path::new("/a"), Some(Path::new("/a/logical/link"))), + PathBuf::from("../../..") + ); } #[test] diff --git a/src/shell.rs b/src/shell.rs index 58721e2..1a3d4c1 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -57,6 +57,28 @@ function _cj_is_repeated() {{ return 0 }} +function _cj_ticker_count() {{ + local value="$1" ticker="$2" count=0 + if _cj_is_repeated "$value" "$ticker"; then + printf '%s\n' "${{#value}}" + return + fi + if [[ "$value" == "$ticker"* ]]; then + value="${{value#"$ticker"}}" + case "$value" in + ''|*[!0-9]*) ;; + *) + value="${{value#"${{value%%[!0]*}}"}}" + if [[ -n "$value" && ${{#value}} -le 10 ]]; then + count=$((10#$value)) + (( count <= 2147483647 )) || count=0 + fi + ;; + esac + fi + printf '%s\n' "$count" +}} + # pwd and cj each emit one protocol LF. Append a marker before substitution, # then strip only that LF and marker, preserving any trailing LFs in the path. # Append the marker only on success so a failing command keeps its exit status. @@ -101,7 +123,7 @@ function _cj_builtin_cd() {{ \builtin unalias cd 2>/dev/null || : function cd() {{ local target _cj_status _cj_before _cj_after _cj_nav_arg _cj_nav_mode - local _cj_count _cj_remaining _cj_index _cj_value + local _cj_count _cj_remaining _cj_index _cj_up_count _cj_parent local -a _cj_args if [[ $# -eq 0 ]]; then @@ -154,15 +176,11 @@ function cd() {{ esac _cj_nav_mode=other - if _cj_is_repeated "${{_cj_nav_arg-}}" "$_cj_navigate_up"; then + _cj_up_count="$(_cj_ticker_count "${{_cj_nav_arg-}}" "$_cj_navigate_up")" + _cj_count="$(_cj_ticker_count "${{_cj_nav_arg-}}" "$_cj_navigate_down")" + if (( _cj_up_count > 0 )); then _cj_nav_mode=up - elif _cj_is_repeated "${{_cj_nav_arg-}}" "$_cj_navigate_down"; then - _cj_count=0 - _cj_value="$_cj_nav_arg" - while [[ -n "$_cj_value" ]]; do - _cj_count=$((_cj_count + 1)) - _cj_value="${{_cj_value#"$_cj_navigate_down"}}" - done + elif (( _cj_count > 0 )); then _cj_remaining=$((${{#_cj_history[@]}} - _cj_count)) if (( _cj_remaining < 0 )); then printf '%s\n' 'cj: directory history exhausted' >&2 @@ -192,6 +210,17 @@ function cd() {{ (( _cj_status == 0 )) || return "$_cj_status" target="${{target%$'\n.'}}" [[ -n "$target" ]] || return 1 + if (( _cj_up_count > 0 )); then + # Bound expansion by the shell's logical depth, preserving native .. semantics. + target=. + _cj_parent="$PWD" + while (( _cj_up_count > 0 )) && [[ "$_cj_parent" != / ]]; do + target="$target/.." + _cj_parent="${{_cj_parent%/*}}" + [[ -n "$_cj_parent" ]] || _cj_parent=/ + _cj_up_count=$((_cj_up_count - 1)) + done + fi \{builtin} cd -- "$target" || return _cj_after="$(\builtin pwd -P && printf .)" || return _cj_after="${{_cj_after%$'\n.'}}" @@ -321,6 +350,18 @@ fn render_nu(config_path: Option<&Path>, settings: &Config) -> Result 0 and (($value | split chars | all {{ |char| $char == $ticker }})) }} +def _cj-ticker-count [value: string, ticker: string] {{ + if (_cj-is-repeated $value $ticker) {{ return ($value | str length) }} + if not ($value | str starts-with $ticker) {{ return 0 }} + let digits = ($value | split chars | skip 1 | str join) + if $digits !~ '^[0-9]+\z' {{ return 0 }} + let decimal = ($digits | str replace --regex '^0+' '') + if ($decimal | is-empty) or (($decimal | str length) > 10) {{ return 0 }} + let count = ($decimal | into int) + if $count > 2147483647 {{ return 0 }} + $count +}} + def --env _cj-record-move [before: string, mode: string = 'other', logical: string = ''] {{ let after = ($env.PWD | path expand) if $before == $after {{ return }} @@ -441,8 +482,9 @@ export def --env --wrapped __cj_cd [...args: string] {{ let nav_arg = if (($args | length) == 1) {{ $args.0 }} else if (($args | length) == 2) and (($args.0 == '-Z') or ($args.0 == '--no-zoxide')) {{ $args.1 }} else {{ '' }} let invoke_args = if ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide']) {{ $args }} else {{ ['--'] | append $args }} - if (_cj-is-repeated $nav_arg $navigate_down) {{ - let count = ($nav_arg | str length) + let up_count = (_cj-ticker-count $nav_arg $navigate_up) + let count = (_cj-ticker-count $nav_arg $navigate_down) + if $count > 0 {{ let remaining = (($env.__cj_history | length) - $count) if $remaining < 0 {{ error make {{ msg: 'cj: directory history exhausted' }} }} let target = ($env.__cj_history | get $remaining) @@ -457,10 +499,22 @@ export def --env --wrapped __cj_cd [...args: string] {{ if not ($result.stderr | is-empty) {{ print --stderr --no-newline $result.stderr }} error make {{ msg: $'cj exited with status ($result.exit_code)' }} }} - let target = ($result.stdout | str replace --regex '\r?\n$' '') + mut target = ($result.stdout | str replace --regex '\r?\n$' '') if ($target | is-empty) {{ error make {{ msg: 'cj returned an empty destination' }} }} + if $up_count > 0 {{ + $target = '.' + mut parent = $logical + mut remaining = $up_count + while $remaining > 0 {{ + let next = ($parent | path dirname) + if $next == $parent {{ break }} + $target = $'($target)/..' + $parent = $next + $remaining = $remaining - 1 + }} + }} cd $target - let mode = if (_cj-is-repeated $nav_arg $navigate_up) {{ 'up' }} else {{ 'other' }} + let mode = if $up_count > 0 {{ 'up' }} else {{ 'other' }} _cj-record-move $before $mode $logical }} @@ -500,6 +554,19 @@ function script:Test-CjRepeated {{ return $Value.Replace($Ticker, '').Length -eq 0 }} +function script:Get-CjTickerCount {{ + param([string]$Value, [string]$Ticker) + if (Test-CjRepeated $Value $Ticker) {{ return $Value.Length }} + if (-not $Value.StartsWith($Ticker, [System.StringComparison]::Ordinal)) {{ return 0 }} + $digits = $Value.Substring($Ticker.Length) + if ($digits -cnotmatch '^[0-9]+\z') {{ return 0 }} + $decimal = $digits.TrimStart([char]'0') + if ($decimal.Length -eq 0 -or $decimal.Length -gt 10) {{ return 0 }} + $count = [long]$decimal + if ($count -gt 2147483647) {{ return 0 }} + return [int]$count +}} + function script:Add-CjHistory {{ param([string]$Before, [string]$Mode = 'other') $after = (Microsoft.PowerShell.Management\Get-Location).ProviderPath @@ -615,8 +682,15 @@ function global:cd {{ $navArg = if ($cjArgs.Count -eq 1) {{ $first }} elseif (($cjArgs.Count -eq 2) -and (($first -ceq '-Z') -or ($first -ceq '--no-zoxide'))) {{ [string]$cjArgs[1] }} else {{ '' }} [string[]]$invokeArgs = if (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide')) {{ $cjArgs }} else {{ @('--') + $cjArgs }} - if (Test-CjRepeated $navArg $script:__cj_navigate_down) {{ - $remaining = $global:__cj_history.Count - $navArg.Length + if (($cjArgs.Count -eq 2) -and -not [string]::IsNullOrEmpty($navArg) -and (Test-Path -LiteralPath $navArg -PathType Container)) {{ + Microsoft.PowerShell.Management\Set-Location -LiteralPath $navArg -ErrorAction Stop + Add-CjHistory $before + return + }} + $upCount = Get-CjTickerCount $navArg $script:__cj_navigate_up + $count = Get-CjTickerCount $navArg $script:__cj_navigate_down + if ($count -gt 0) {{ + $remaining = $global:__cj_history.Count - $count if ($remaining -lt 0) {{ throw 'cj: directory history exhausted' }} $target = $global:__cj_history[$remaining] Microsoft.PowerShell.Management\Set-Location -LiteralPath $target -ErrorAction Stop @@ -633,8 +707,21 @@ function global:cd {{ $status = $LASTEXITCODE if ($status -ne 0) {{ throw "cj exited with status $status" }} if ($target.Count -ne 1 -or [string]::IsNullOrEmpty($target[0])) {{ throw 'cj returned an invalid destination' }} - Microsoft.PowerShell.Management\Set-Location -LiteralPath $target[0] -ErrorAction Stop - $mode = if (Test-CjRepeated $navArg $script:__cj_navigate_up) {{ 'up' }} else {{ 'other' }} + $destination = $target[0] + if ($upCount -gt 0) {{ + $destination = '.' + $parent = $before + $remaining = $upCount + while ($remaining -gt 0) {{ + $next = [System.IO.Path]::GetDirectoryName($parent) + if ([string]::IsNullOrEmpty($next)) {{ break }} + $destination += [System.IO.Path]::DirectorySeparatorChar + '..' + $parent = $next + $remaining-- + }} + }} + Microsoft.PowerShell.Management\Set-Location -LiteralPath $destination -ErrorAction Stop + $mode = if ($upCount -gt 0) {{ 'up' }} else {{ 'other' }} Add-CjHistory $before $mode }} diff --git a/tests/counted_tickers.rs b/tests/counted_tickers.rs new file mode 100644 index 0000000..743eca5 --- /dev/null +++ b/tests/counted_tickers.rs @@ -0,0 +1,279 @@ +mod support; + +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +use support::{TempDir, assert_success, cj}; + +fn shells() -> Vec<(&'static str, &'static str)> { + let mut candidates = vec![ + ("bash", "bash"), + ("zsh", "zsh"), + ("nu", "nu"), + ("pwsh", "pwsh"), + ]; + if cfg!(target_os = "macos") { + candidates.push(("/bin/bash", "bash")); + } + candidates.retain(|(executable, shell)| { + if cfg!(windows) && matches!(*shell, "bash" | "zsh") { + return false; + } + let available = Command::new(executable) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()); + assert!( + available + || (env::var_os("CJ_REQUIRE_SHELLS").is_none() + && !(*shell == "pwsh" && env::var_os("CJ_REQUIRE_POWERSHELL").is_some())), + "required shell unavailable: {executable}" + ); + available + }); + assert!(!candidates.is_empty(), "no supported shell available"); + candidates +} + +fn run(temp: &TempDir, executable: &str, shell: &str, config: &Path, body: &str) -> String { + let source = cj(temp.path(), temp.path()) + .arg("-C") + .arg(config) + .args(["init", shell, "--no-setup-key-binding"]) + .output() + .unwrap(); + assert_success(&source); + let file = temp.path().join(match shell { + "nu" => "init.nu", + "pwsh" => "init.ps1", + _ => "init.sh", + }); + fs::write(&file, source.stdout).unwrap(); + let mut paths = vec![ + Path::new(env!("CARGO_BIN_EXE_cj")) + .parent() + .unwrap() + .to_path_buf(), + ]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let mut command = Command::new(executable); + match shell { + "bash" | "zsh" => { + command.args(if shell == "bash" { + &["--noprofile", "--norc", "-c"][..] + } else { + &["-f", "-c"][..] + }); + command.arg(format!( + r#"set -e; . "$CJ_SOURCE" +snapshot() {{ printf '%s\n' "$PWD" "${{#_cj_history[@]}}"; }} +{body}"# + )); + } + "nu" => { + command.args(["--no-config-file", "-c"]).arg(format!( + "use r###'{}'### *; hide cd; def snapshot [] {{ print $env.PWD; print ($env.__cj_history | length) }}; {body}", file.display())); + } + _ => { + command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]).arg(format!( + "$ErrorActionPreference = 'Stop'; . $env:CJ_SOURCE; function snapshot {{ (Get-Location).ProviderPath; $global:__cj_history.Count }}; {body}")); + } + } + let output = command + .current_dir(temp.path().join("a/b/c")) + .env("PATH", env::join_paths(paths).unwrap()) + .env("CJ_SOURCE", file) + .env("CJ_ROOT", temp.path()) + .env("HOME", temp.path().join("home")) + .env("USERPROFILE", temp.path().join("home")) + .output() + .unwrap(); + assert!( + output.status.success(), + "{executable}: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +const POSIX: &str = r#" +for token in '@U@@U@' '@U@2' '@U@0002'; do + cd "$token"; snapshot; cd '@D@2'; snapshot +done +cd -Z '@U@2'; snapshot; cd -Z '@D@2'; snapshot +if cd -r '@U@2' 2>/dev/null; then exit 1; fi +cd '@U@3'; cd '@D@@D@@D@'; snapshot +cd '@U@2147483647'; snapshot +cd '@U@2147483647'; snapshot +if cd '@D@2147483647' 2>/dev/null; then exit 1; fi +snapshot; cd '@D@@DEPTH@'; snapshot +cd '@U@1' +for token in '@U@0' '@U@-1' '@U@1.5' '@U@+1' '@U@1e2' '@U@2147483648' '@U@99999999999999999999999' '@D@0' '@D@-1' '@D@1.5' '@D@+1' '@D@1e2' '@D@2147483648' '@D@99999999999999999999999' '@D@2' $'@U@1\n' $'@U@1\r' $'@D@1\n' $'@D@1\r'; do + before_pwd=$PWD; before_history="${_cj_history[*]}" + if cd "$token" 2>/dev/null; then printf 'accepted invalid token: %s\n' "$token" >&2; exit 1; fi + [[ $PWD == "$before_pwd" && ${_cj_history[*]} == "$before_history" ]] +done +cd '@D@1'; snapshot +for token in '@U@2' '@U@-1' '@D@3' '@D@0'; do + mkdir "$token"; cd "$token"; snapshot; cd '@D@1' +done +snapshot +"#; + +const NU: &str = r#" +for token in ['@U@@U@' '@U@2' '@U@0002'] { + __cj_cd $token; snapshot; __cj_cd '@D@2'; snapshot +} +__cj_cd -Z '@U@2'; snapshot; __cj_cd -Z '@D@2'; snapshot +let raw_failed = (try { __cj_cd -r '@U@2'; false } catch { true }) +if not $raw_failed { error make {msg: 'raw path interpreted as ticker'} } +__cj_cd '@U@3'; __cj_cd '@D@@D@@D@'; snapshot +__cj_cd '@U@2147483647'; snapshot +__cj_cd '@U@2147483647'; snapshot +let exceeded = (try { __cj_cd '@D@2147483647'; false } catch { true }) +if not $exceeded { error make {msg: 'accepted unavailable history'} } +snapshot; __cj_cd '@D@@DEPTH@'; snapshot +__cj_cd '@U@1' +for token in ['@U@0' '@U@-1' '@U@1.5' '@U@+1' '@U@1e2' '@U@2147483648' '@U@99999999999999999999999' '@D@0' '@D@-1' '@D@1.5' '@D@+1' '@D@1e2' '@D@2147483648' '@D@99999999999999999999999' '@D@2' "@U@1\n" "@U@1\r" "@D@1\n" "@D@1\r"] { + let before_pwd = $env.PWD; let before_history = $env.__cj_history + let failed = (try { __cj_cd $token; false } catch { true }) + if not $failed or $env.PWD != $before_pwd or $env.__cj_history != $before_history { error make {msg: $'invalid token changed state: ($token)'} } +} +__cj_cd '@D@1'; snapshot +for token in ['@U@2' '@U@-1' '@D@3' '@D@0'] { + mkdir $token; __cj_cd $token; snapshot; __cj_cd '@D@1' +} +snapshot +"#; + +const POWERSHELL: &str = r#" +foreach ($token in @('@U@@U@', '@U@2', '@U@0002')) { + cd $token; snapshot; cd '@D@2'; snapshot +} +cd -Z '@U@2'; snapshot; cd -Z '@D@2'; snapshot +$rawFailed = $false; try { cd -r '@U@2' } catch { $rawFailed = $true } +if (-not $rawFailed) { throw 'raw path interpreted as ticker' } +cd '@U@3'; cd '@D@@D@@D@'; snapshot +cd '@U@2147483647'; snapshot +cd '@U@2147483647'; snapshot +$exceeded = $false +try { cd '@D@2147483647' } catch { $exceeded = $true } +if (-not $exceeded) { throw 'accepted unavailable history' } +snapshot; cd '@D@@DEPTH@'; snapshot +cd '@U@1' +foreach ($token in @('@U@0', '@U@-1', '@U@1.5', '@U@+1', '@U@1e2', '@U@2147483648', '@U@99999999999999999999999', '@D@0', '@D@-1', '@D@1.5', '@D@+1', '@D@1e2', '@D@2147483648', '@D@99999999999999999999999', '@D@2', "@U@1`n", "@U@1`r", "@D@1`n", "@D@1`r")) { + $beforePwd = (Get-Location).ProviderPath; $beforeHistory = $global:__cj_history | ConvertTo-Json -Compress + $failed = $false; try { cd $token } catch { $failed = $true } + if (-not $failed -or (Get-Location).ProviderPath -ne $beforePwd -or ($global:__cj_history | ConvertTo-Json -Compress) -ne $beforeHistory) { throw "invalid token changed state: $token" } +} +cd '@D@1'; snapshot +foreach ($token in @('@U@2', '@U@-1', '@D@3', '@D@0')) { + [IO.Directory]::CreateDirectory((Join-Path (Get-Location).ProviderPath $token)) > $null + cd $token; snapshot; cd '@D@1' +} +snapshot +"#; + +#[test] +fn counted_tickers_match_repetition_and_preserve_history_boundaries_in_all_shells() { + for (up, down) in [("^", "v"), ("u", "k")] { + for (executable, shell) in shells() { + let temp = TempDir::new("counted-tickers ' spaces"); + let start = temp.path().join("a/b/c"); + fs::create_dir_all(&start).unwrap(); + fs::create_dir(temp.path().join("home")).unwrap(); + let config = temp.path().join("config.toml"); + fs::write(&config, format!("[behavior]\ndefault = \"builtin\"\n[tickers]\nnavigate_up = \"{up}\"\nnavigate_down = \"{down}\"\n")).unwrap(); + let depth = start.ancestors().count() - 1; + let root = start.ancestors().last().unwrap(); + let script = match shell { + "nu" => NU, + "pwsh" => POWERSHELL, + _ => POSIX, + } + .replace("@U@", up) + .replace("@D@", down) + .replace("@DEPTH@", &depth.to_string()); + let output = run(&temp, executable, shell, &config, &script); + let mut expected = Vec::new(); + for _ in 0..4 { + expected.push((temp.path().join("a"), 2)); + expected.push((start.clone(), 0)); + } + expected.push((start.clone(), 0)); + for _ in 0..3 { + expected.push((root.to_path_buf(), depth)); + } + expected.push((start.clone(), 0)); + expected.push((start.clone(), 0)); + for token in [ + format!("{up}2"), + format!("{up}-1"), + format!("{down}3"), + format!("{down}0"), + ] { + expected.push((start.join(token), 1)); + } + expected.push((start.clone(), 0)); + let lines: Vec<_> = output.lines().collect(); + assert_eq!(lines.len(), expected.len() * 2, "{executable}: {output}"); + for (actual, (path, count)) in lines.chunks_exact(2).zip(expected) { + // Shells can spell Windows separators differently; compare actual paths. + assert_eq!( + dunce::canonicalize(actual[0]).unwrap(), + dunce::canonicalize(path).unwrap(), + "{executable}" + ); + assert_eq!(actual[1], count.to_string(), "{executable}"); + } + } + } +} + +#[cfg(unix)] +#[test] +fn counted_parent_history_keeps_logical_symlink_steps() { + for (executable, shell) in shells() + .into_iter() + .filter(|(_, shell)| matches!(*shell, "bash" | "zsh")) + { + let temp = TempDir::new("counted-symlink"); + let physical = temp.path().join("physical/deep/leaf"); + fs::create_dir_all(&physical).unwrap(); + fs::create_dir_all(temp.path().join("a/b/c")).unwrap(); + std::os::unix::fs::symlink(&physical, temp.path().join("a/link")).unwrap(); + let deep = format!("a/b/c/{}link", "d/".repeat(12)); + fs::create_dir(temp.path().join("shallow")).unwrap(); + let deep_link = temp.path().join(&deep); + fs::create_dir_all(deep_link.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(temp.path().join("shallow"), deep_link).unwrap(); + let config = temp.path().join("config.toml"); + fs::write(&config, "[behavior]\ndefault = \"builtin\"\n").unwrap(); + let output = run( + &temp, + executable, + shell, + &config, + &r#" +builtin cd "$CJ_ROOT/a/link" +cd '^2' +[[ $PWD == "$CJ_ROOT" && ${#_cj_history[@]} == 2 ]] +cd v1 +[[ $PWD == "$CJ_ROOT/a" && ${#_cj_history[@]} == 1 ]] +cd v1 +[[ $PWD == "$CJ_ROOT/physical/deep/leaf" && ${#_cj_history[@]} == 0 ]] +builtin cd "$CJ_ROOT/@DEEP@" +if [[ -n ${BASH_VERSION-} ]]; then export -n PWD; else typeset +x PWD; fi +cd '^13' +[[ $PWD == "$CJ_ROOT/a/b/c" && ${#_cj_history[@]} == 13 ]] +cd v13 +[[ $PWD == "$CJ_ROOT/shallow" && ${#_cj_history[@]} == 0 ]] +"# + .replace("@DEEP@", &deep), + ); + assert!(output.is_empty(), "{executable}: {output}"); + } +}