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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 101 additions & 14 deletions src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -179,16 +184,46 @@ enum ZoxideFailure {
Query(String),
}

fn repeated_count(target: &str, ticker: &str) -> Option<usize> {
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<Option<usize>, 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::<u32>().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<PathBuf, String> {
Expand Down Expand Up @@ -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]
Expand Down
121 changes: 104 additions & 17 deletions src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.'}}"
Expand Down Expand Up @@ -321,6 +350,18 @@ fn render_nu(config_path: Option<&Path>, settings: &Config) -> Result<String, St
($value | str length) > 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 }}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}}

Expand Down
Loading
Loading