From 789f45de4ae3a4065a50d94745ad64f99ded027c Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 11 Sep 2026 21:46:44 +0800 Subject: [PATCH 1/2] feat(navigation): support counted navigation tickers Add counted forms such as `^2` and `v3` for configured tickers across Bash, Zsh, Nushell, and PowerShell while preserving native logical path behavior. Validate positive decimal counts from 1 to 2147483647, safely cap upward navigation at the filesystem root, and require sufficient history before atomically navigating back. Preserve real-directory precedence over ticker parsing. Document count bounds, precedence, root behavior, and strict history semantics. Refs #78 --- README.md | 20 +++++++ docs/compatibility.md | 6 +++ src/resolver.rs | 115 ++++++++++++++++++++++++++++++++++----- src/shell.rs | 121 ++++++++++++++++++++++++++++++++++++------ 4 files changed, 231 insertions(+), 31 deletions(-) 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 }} From caa825fd3439991cf25a893a82646360ab2c46bc Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 11 Sep 2026 21:47:21 +0800 Subject: [PATCH 2/2] test(navigation): verify counted ticker behavior across shells Verify counted and repeated ticker equivalence across Bash 3, modern Bash, Zsh, Nu, and PowerShell. Cover root capping, exact parent history, atomic invalid and backward failures, decimal, newline, and overflow validation, directory and resolver precedence, and logical symlinks with an unexported PWD. Closes #78 --- tests/counted_tickers.rs | 279 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 tests/counted_tickers.rs 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}"); + } +}