Skip to content

Conversation

aartoni
Copy link
Contributor

@aartoni aartoni commented Mar 15, 2025

  • changed shebang to /bin/zsh since se() uses zshisms (known since last rework)
  • prevents editing the wrong script with se
    • we were matching file names against a path-less, extension-less file name c ({${(M)s:#*/${c}*}), leading to incorrect matches. For example, if you have a script cron/sdo and choose sd in fzf you'll edit sdo
    • now we trim ~sc/ from the full path to display a partial path and keep the extensions
      • this is especially useful if you have more than one script with the same base name
  • shellcheck ignores to avoid two misleading messages

I've provided a less verbose version of this explanation in the commit messages as well.

Edit: bash -> zsh

@RetroViking
Copy link

They are zsh-isms, not bashisms. The syntax is correct. So technically we could use #!/bin/zsh but the shebang doesn't matter in this case because only zsh loads the file anyway. On my setup I don't have any directories in ~/.local/bin, does se() not work with them?

@emrakyz
Copy link
Contributor

emrakyz commented Mar 15, 2025

The original change belongs to me and this is so unnecessary.

Shellcheck won't work with Zsh by the way. The shebang should have been #!/usr/bin/env zsh

@RetroViking Even if you have directories, the script would work with them.

I sent you a PR with the fix.

Here it is for reference:

se() {
	f=("${HOME}/.local/bin/"**/*(.))
	n=("${f[@]:t:r}")
	c="$(print -lnr "${n[@]}" | fzf)"
	[[ "${c}" ]] && "${EDITOR}" "${f[${n[(i)${c}]}]}"
}

This is more aligned with the Zsh syntax.

aartoni added 3 commits March 15, 2025 16:01
due to zshisms in se()
this happens every time a script is selected and another one matches
before it, this happens if you select a script such as "sd" while
having another one called "cron/sdo"
@aartoni
Copy link
Contributor Author

aartoni commented Mar 15, 2025

They are zsh-isms, not bashisms

Nice catch! I've updated the shebang

The shebang should have been #!/usr/bin/env zsh

Almost, I've changed it to #!/usr/bin/zsh to match 2b5df86.

The syntax is correct

Never said the contrary

the shebang doesn't matter in this case because only zsh loads the file anyway.

It's nice to have the shebang match the actual format, just to avoid throwing off users

shellcheck won't work with Zsh by the way.

I know, this is just to avoid misleading shellcheck results.

I sent you a PR with the fix.

Answered on the PR itself

@RetroViking
Copy link

RetroViking commented Mar 15, 2025

Never said the contrary

You mention some problems like:

we were matching file names against a path-less, extension-less file name c ({${(M)s:#/${c}}), leading to incorrect matches. For example, if you have a script cron/sdo and choose sd in fzf you'll edit sdo

But I can't replicate this on my machine. I created ~/.local/bin/cron/sdo and ~/.local/bin/cron/sd to test and it worked as expected. The syntax also looks fine, so maybe your zsh configuration is causing this to change behaviour?

By the way, if you want to keep the extension you just need to delete the :r modifier, actually that's probably recommended if you have scripts with matching names like script and script.sh.

Funny how something as simple as a script-selector can be implemented in a quintillion possible ways.

Just out of curiosity, I actually benchmarked 5 versions of se(), by running them 1000 times in a loop. (Code is at the end of this comment). Obviously, in order to do this I can't use fzf, because that would require me to manually select the script 1000 thousand times in a row. So instead I pipe the script list to head -n 1, which automatically adds the first found script to the "$choice" (or "$c") variable. Also, I obviously don't want to open 1000 instances of vim, so the last command is merely echoed.

  • Classic version, i.e. the one that used find and was recently changed: 2.15s user 4.41s system 124% cpu 5.257 total
  • Version currently in the repo: 2.17s user 3.65s system 105% cpu 5.525 total
  • The more "correct" version of the previous, mentioned by emrakyz in the present discussion: 2.08s user 3.80s system 105% cpu 5.561 total
  • Shell-agnostic (universal) version, additionally only using shell builtins (therefore it kind of mirrors the zsh version): 2.66s user 4.73s system 110% cpu 6.672 total
  • this PR: 2.38s user 4.79s system 106% cpu 6.761 total

Some thoughts on this:

  • The Classic LARBS version takes the crown, likely because find is highly optimized for this kind of stuff.
  • emrakyz's two versions are close to each other, but the "improved syntax" version tends to be microscopically slower in my repeated benchmarks.
  • Pure shell is slow, because you have to rely on looping. @emrakyz if you have ideas on how to optimize this, please teach me because I have an unhealthy interest in this. (Maybe using grep instead of looping?)

Bottomline: these diferences in speed are so small no human mortal will probably ever notice them, so it ultimately comes down to aesthetics and style. emrakyz's zsh-style script has some serious cool factor because it uses zsh specific features instead of stuff you would find elsewhere.

Classic LARBS is also cool because find is one of the oldest UNIX programs, so extremely portable (however the printf feature is rather new so it's not perfect from that point of view).

The scripts I used, for others to benchmark.

How to test this:

time ( for i in {1..1000}; do ./script; done )

This will run each script 1000 times and show you the "wall clock" time it took to run it under the total section.

If you want to actually use one of these scripts for your aliasrc, replace head -n 1 with fzf and modify the last command so that it is actually run instead of echoed.

Classic LARBS:

#!/bin/zsh
se() {
        choice="$(find ~/.local/bin -mindepth 1 -printf '%P\n' | head -n 1)"
        [ -f "$HOME/.local/bin/$choice" ] && echo $EDITOR "$HOME/.local/bin/$choice"
}

se

Current version (emrakyz)

#!/bin/zsh
se() {
        s=("${HOME}/.local/bin/"**/*(.))
        c="$(print -lnr ${s:t:r} | head -n 1)"
        [[ "${c}" ]] && echo "${EDITOR}" ${${(M)s:#*/${c}*}[1]}
}

se

Current version, but improved by emrakyz for correctness:

#!/bin/zsh
se() {
        f=("${HOME}/.local/bin/"**/*(.))
        n=("${f[@]:t:r}")
        c="$(print -lnr "${n[@]}" | head -n 1)"
        [[ "${c}" ]] && echo "${EDITOR} ${f[${n[(i)${c}]}]}"
}

se

aartoni (present PR)

#!/bin/zsh
se() {
local -r bindir=~sc/
        s=("${bindir}"**/*(.))
        # shellcheck disable=SC2086
        c="$(print -lnr ${s/$bindir/} | head -n 1)"
        [[ "${c}" ]] && echo "${EDITOR} $bindir$c"
}

se

Pure POSIX shell (no external programs other than the obvious). The list function imitates find's recursion.

#!/bin/zsh
list() { for f in "$1"/*; do printf '%s\n' "$f"; [ -d "$f" ] && list "$f"; done ; }

se() {
        set -- $(list ~/.local/bin); choice=$(for f do printf '%s\n' "${f##*/}"; done | head -n 1)
        [ "$choice" ] && for f do [ "${f##*/}" = "$choice" ] && echo "$EDITOR $f" && break; done
}

se

@emrakyz
Copy link
Contributor

emrakyz commented Mar 15, 2025

@RetroViking

Interesting enthusiasm.

I have benchmarked many shells including Dash, Bash, Zsh, Ksh, NuShell, Fish and some other niche ones.

Zsh has the most built-in features while being the fastest (excluding some niche cases where Ksh is miles faster); especially if you use Clang, Ofast, ThinLTO, PGO, and build statically.

Zsh has many internal optimizations around variable, array collection and slicing.

A script with Zsh-isms will almost always work faster than other types of scripts; especially when you otherwise use external commands (if the data is extremely big, external programs can be faster than built-ins though, for some rare cases).

Keep in mind that, speed comparisons need to match with features.

My script removes path, extensions from the files, and matches the file all in the same pass without even using a single command. It also handles all types of filenames. It won't split incorrectly.

On the other hand, Zsh's slicing here is miles better than find's output. It's safer and more correct. Zsh is specifically optimized for file handling (especially with weird filenames) compared to other shells. Also, with head, you also create another pipe and call an external command.

My version is simply less complex and completely minimal, and built-in.

My recommendation is: DO NOT use same names for different scripts. In a Linux path, you shouldn't have two separate binaries with same names anyways. It doesn't matter where it is located. A binary should have a completely unique name no matter where it is. With this way, you don't even need the improved version.

And no, there is no way for the pure shell version. That's why I specifically used Zsh syntax for the PR.

Your current version is also wrong, I don't use head.

	s=("${HOME}/.local/bin/"**/*(.))
	c="$(print -lnr ${s:t:r} | fzf)"
	[[ "${c}" ]] && "${EDITOR}" ${${(M)s:#*/${c}*}[1]}

This is the original implementation.

This implementation can select:
${HOME}/.local/bin/sd
${HOME}/.local/bin/another_folder/sdo
${HOME}/.local/bin/sd.sh
${HOME}/.local/bin/another_folder/sdo.sh

For my own system, I don't even use this implementation (I use even a simpler one) because I name all my scripts with .sh extension, since I can have normal binaries in that directory too and I don't want to see them in the selection menu.

So a better approach is simply better.

You don't even need the upgraded version.

This is just safe, accurate, native, all built-in, minimal, single pass method. There is no need to overcomplicate.

Sometimes, instead of creating fixes for edge-cases, you need to change your methods handling things.

A simple example: Instead of handling filenames with spaces, special characters etc; simply do not name files with these characters.

I have been using the same exact approach for a very long time for both my script and my configuration files with nested directories in /etc/portage (gentoo specific configuration directory) and for my init script directory /etc/init.d; and I have never encountered any problem.

@RetroViking
Copy link

@emrakyz

I don't use head

Yeah, I know. I mentioned why I use head, basically this needs to run 1000 times in a loop.

If you read the comment before the latest changes, kindly re-check it because there's some new important info.

@emrakyz
Copy link
Contributor

emrakyz commented Mar 15, 2025

@RetroViking
Oh, for benchmarking, okay.

You can use Zsh built-in randomizer for that.

An example (this can be used for wallpaper randomization, for example):

p=(${w}/*)
f="$p[RANDOM%${#p}+1]"

Zsh also has features for head or tail or similar stuff.

Example:

duration="${${(f)fileinfo}[1]}"
size="${${(f)fileinfo}[-1]}"

Zsh can even be replaced with awk, cut, cat or similar stuff:

IFS=" "
ll=${${(f)"$(<${xpsnr_log})"}[-1]}
set -- ${=ll}
y_p="${6}" u_p="${8}" v_p="${10}"
xpsnr="$(echo "scale=10; -10 * l((4 * e(-l(10)*$y_p/10) + e(-l(10)*$u_p/10) + e(-l(10)*$v_p/10))/6)/l(10)" | bc -l | xargs printf "%.3f")"

This above loads the file, extracts segments, uses different lines and segments from the file's text. It then uses those for calculation. All without using cat, head, cut, awk, tail, grep, sed or similar stuff.

@RetroViking
Copy link

RetroViking commented Mar 15, 2025

@emrakyz

zsh is extremely powerful. One thing I learned about it, though, is that it doesn't always care about the status quo.

For example, I use something like this for my statusbar dash script:

read -r net < "$wifi"; ! ping -c 1 8.8.8.8 > /dev/null 2>&1 && net="[ ! ] $net"

When I was building the script, I naively tested this command with zsh (because it's my default interactive shell), not knowing that zsh does its own thing and gives ! special significance even if quoted. This is completely different from dash and bash. Now I now better.

I recall you're a Wayland user, which is more minimal and "the future" in a sense, so maybe that's why zsh also appeals to you. bash and dash already have some faster alternatives to those classic utils (see the pure sh bible and the bash version ), but based on your presentation, I take it that this doesn't even scratch the surface of what zsh can do. This does, however, require an adventurous spirit that's willing to learn a shell like zsh, which is not only opinionated but has a full host of "zsh-only" features that aren't portable.

Myself, I'm more of a "classic car" kind of guy that's interested in the old Bourne shell and UNIX days. In my head, even printf for the shell (which originally was an external program only) and find's print0 (newly added to POSIX in 2024) are too "new" from the classics-enthusiast standpoint. This stupidity sometimes extends to wanting my sed and find commands to be as true as possible to the '70's versions 🙃 Fun fact: both zsh and dash support chdir as another name for cd, but bash doesn't.

@aartoni
Copy link
Contributor Author

aartoni commented Mar 16, 2025

Keep in mind that, speed comparisons need to match with features.

I agree with that, @emrakyz version does more string manipulation while mine does some implicit unsets and shadowing and edit checks on the bindir variable due to local -r. I'd be interested in opening an issue if you want to start benchmarking every script to optimize the slowest ones.

I was thinking that we likely want to use local or local -r on s and c as well to avoid them creeping into the client shell.

My recommendation is: DO NOT use same names for different scripts. In a Linux path

Good recommendation for a general use case, however there are applications that have architecture-specific binaries and rely on a run.sh script to select between them, so you may usually see something like this:

├── editing-software
│   ├── run.sh
│   ├── sw.armv7h
│   └── sw.x86_64
├── mykvmtests
│   ├── example.arm64
│   ├── example.x86_64
│   └── run.sh
└── videogame
    ├── run.sh
    ├── videogame.arm64
    └── videogame.x86_64

you shouldn't have two separate binaries with same names anyways. It doesn't matter where it is located. A binary should have a completely unique name no matter where it is. With this way, you don't even need the improved version.

I completely agree with you, at least within .local/bin because at the whole system-level you might need to provide modified versions of the same binaries (e.g., versions compiled with different flags). However there is the above case.

I am now realizing that mine may be a nicher use case than I figured it to be, so I will likely wait a bit and decide whether to change this to a slimmer PR or go through with this version or @emrakyz' version from my branch.

@RetroViking
Copy link

RetroViking commented Mar 16, 2025

I was thinking that we likely want to use local or local -r on s and c as well to avoid them creeping into the client shell.

True. If you set a c or s shortcut in ~/.config/shell/bm-dirs, this will generate $s and $c variables in addition to their matching aliases. So if you run se() in the current shell, you'll overwrite $s and $c with new values until you restart the shell. local does prevent this, although it's not a big deal.

I'm curious, does the current version work for you? It works flawlessly for me, but in the past there was an oddity where $c would be non-empty even if I didn't make a selection in fzf. Anyway, the fault wasn't with the script itself but with some unknown setup, that's why I'm asking.

By the way, no need to stress about the shebang. It makes no difference when sourcing scripts.

~ $ cat alias
#!/bin/batman
alias something='cd /gotham'
~ $ source ./alias
~ $ something
cd: no such file or directory: /gotham

@emrakyz
Copy link
Contributor

emrakyz commented Mar 16, 2025

True. If you set a c or s shortcut in ~/.config/shell/bm-dirs, this will generate $s and $c variables in addition to their matching aliases. So if you run se() in the current shell, you'll overwrite $s and $c with new values until you restart the shell. local does prevent this, although it's not a big deal.

These kinds of edge cases are pointless to trace. Edge-case tracing makes things extremely complex. You know, and you control your system. Create aliases, shortcuts knowingly.

By the way, calling se won't overwrite variables. You can simply use c as a shortcut. Call se, do your job, and then try to go to your shortcut on c, and you can do that.

The script with its current form is very good already for Luke's setup.

@RetroViking
Copy link

RetroViking commented Mar 17, 2025

By the way, calling se won't overwrite variables. You can simply use c as a shortcut. Call se, do your job, and then try to go to your shortcut on c, and you can do that.

@emrakyz, is it different for you?

~ $ cat $XDG_CONFIG_HOME/shell/bm-dirs
# Bookmarked directories
c   /usr/bin
~ $ echo $c
/usr/bin
~ $ se
~ $ echo $c
maimpick

It's up to everybody to decide when the system must be changed, and when one's own habits must be changed. For example, in my case I decided that doing some small tweaks to my scripts, to allow for safe handling of filenames with spaces, is less cumbersome than ensuring that all my filenames are "correct". Set up your scripts once, never think about it again.

For se() however this fix won't impact me at all, so I have no motivation to change it to something slightly slower just for perfection's sake.

@emrakyz
Copy link
Contributor

emrakyz commented Mar 17, 2025

@RetroViking

I guess I use simple cut instead of creating variables. That's why it doesn't break shortcuts for me.

@RetroViking
Copy link

RetroViking commented Mar 17, 2025

@emrakyz and @aartoni , did you know that fzf can display just the basename but send the full path to stdout?

Try this:

se () {
    c="$(print -lnr "$HOME/.local/bin/"**/*(.) | fzf --delimiter / --with-nth -1)"
    [[ "$c" ]] && "$EDITOR" "$c"
}

emrakyz, what is your cut method for shortcuts? I'm curious.

Edit: about shellcheck disable=SCXXXX. Shellcheck does not support zsh, so (as an example) it doesn't know that print is actually a zsh builtin, and not bash compatible. In other words, it thinks print is some kind of regular executable on your system and that's why it gives no warnings about it. So even if the shellcheck report is "clean", it's a faux cleanliness. This confused me into thinking that the script is bash compatible, which it's not. Plus, to even check aliasrc with shellcheck we have to always change the shebang otherwise it'll throw the "unsupported shell" error.

In short, shellcheck can be deceiving if you don't know exactly what you're sending it to check.

Also, #!/bin/prog (i.e. not /usr/bin/prog) aligns better with the style of LARBS scripts, even though as I said, when sourcing scripts the shebang is just for style or for signaling to the reader about zsh-isms.

Since my scripts don't have extensions, I'm considering using fzf to perform the basename extraction. That works for my case. It doesn't work for emrakyz, who names all of his scripts with the sh extension and wants to strip extensions. The bottomline is that creating something that "works for everyone" is tricky.

@emrakyz
Copy link
Contributor

emrakyz commented Mar 18, 2025

@RetroViking

emrakyz, what is your cut method for shortcuts? I'm curious.

Actually I use the Rust-based Zoxide which is much better, faster than any of other methods navigating folders.

You can even write the directory names wrong, and still go there.

Let's say there is a folder like this:
/home/emre/.local/src/ffmpeg/filters

You can even go there with:
z flt

It also offers options to show you most visited directories in fzf. You don't even need to set your own custom jump points.

This can also be used in TUI file managers. I use the Rust-based Yazi but it's possible in lf too. When I press z, I see the most visited directories by order and I can go to those directories immediately.

On the other hand, I also have a config in my .zshrc. Actually it doesn't even have cut :)

fzf-cd-bookmark() {
	cd "${$(fzf < "${XDG_CONFIG_HOME}/shell/bm_dirs")#*[$'\t' ]}"
	zle reset-prompt
}
zle -N fzf-cd-bookmark
bindkey '^J' fzf-cd-bookmark

bm_dirs structure should be like:

hdd	/mnt/harddisk
cac	/home/emre/.cache

If you want to use variables instead, then you should either use eval, or two pass methods, but I don't use variables.

@aartoni
Copy link
Contributor Author

aartoni commented Mar 18, 2025

By the way, no need to stress about the shebang. It makes no difference when sourcing scripts.

I know, I know, this is just a very small change that has already be addressed. For the reason why I wanted to change it, consider this hypothetical scenario: you have a Python script with a bash shebang, that would make no sense, you would change it as soon as you notice it. So this case is even worse because a Linux/LARBS noob may not get the subtle information that it uses a different language (i.e. zsh) instead of plain POSIX scripting.

Of course, some experienced user like us in this thread would get that.

Also, #!/bin/prog (i.e. not /usr/bin/prog) aligns better with the style of LARBS scripts

You're right.

did you know that fzf can display just the basename but send the full path to stdout?

I didn't know! I think this might be both the best and fastest way of using it. I will update the PR in a few days!

These kinds of edge cases are pointless to trace.

Well, your script had a bug and I have fixed it. By talking about it @RetroViking found a way of fixing it by using a built-in feature of fzf that will probably take the same number of lines while also fixing the bug. The code also looks cleaner without this contraption, imho: ${${(M)s:#*/${c}*}[1]}.

Overall I am convinced that opening this bug fixing PR has been useful.

@RetroViking
Copy link

RetroViking commented Mar 18, 2025

@emrakyz
Oh, now I see what you meant by "cut".

If you want to use variables instead, then you should either use eval, or two pass methods, but I don't use variables.

They are useful IMHO. With your bookmarked directories and the vanilla LARBS shortcuts script you could do stuff like

mv somevideo.mkv ~hdd/Videos
find ~cac -name '*.db'

You can also use $ instead of ~ but that's harder to type.

Thanks for sharing your Zoxide setup.

@aartoni

I didn't know! I think this might be both the best and fastest way of using it. I will update the PR in a few days!

You can use -d instead of --delimiter if you want. I'm grateful for emrakyz's print command, which is faster than bash alternatives and find.

se() { local c="$(print -lnr "${HOME}/.local/bin/"**/*(.) | fzf -d / --with-nth -1)"; [[ "$c" ]] && "$EDITOR" "$c"; }

Overall I am convinced that opening this bug fixing PR has been useful.

It is always useful, even if the PR is initially not perfect or a bit confused, because further discussion can lead to discoveries that would have never happened otherwise. Without this PR I wouldn't have learned the things I did, from reading the comments and thinking about this, so I've certainly been enritched by it.

@emrakyz
Copy link
Contributor

emrakyz commented Mar 18, 2025

Well, your script had a bug and I have fixed it.

I don't think the fix approach is good though.

Fixing something by changing the approach or losing functionality is not the best way. I think it must have been an issue instead of a pull request.

PR without building a good solution, covering all the previous functionality is not the most optimal approach:

First of all:

  • I didn't understand this directory structure: ~sc/. I am assuming it's from bm-dirs but it's extremely unnecessary to use it instead of using the standard hardcoded userhome binary location. Another user can change the bm-dirs structure.
  • The first version uses ${s:t:r} to extract basenames without extensions, making selection cleaner. The second only strips the directory prefix with ${s/$bindir/}, leaving extensions that clutter the selection interface. We also see the / prefix.
  • It simply concatenates "$bindir$c" for the final path, which is error-prone. The first version uses pattern matching ${${(M)s:#*/${c}*}[1]} to reliably locate the original file.
  • Uses simple text replacement ${s/$bindir/} rather than proper path manipulation, which can cause issues with special characters or edge cases.
  • The first version's pattern matching approach supports partial matches, while the second version requires exact path matches after the directory prefix is removed.
  • Adds the local -r declaration which adds verbosity without providing significant benefit in this context.
  • If multiple files have similar names, the first version explicitly takes the first match with [1].
  • I still don't understand the actual error. I can access all of below separately:
"${HOME}/.local/bin/sd"
"${HOME}/.local/bin/another_folder/sdo"
"${HOME}/.local/bin/sd.sh"
"${HOME}/.local/bin/another_folder/sdo.sh" 

The only problem can be accessing two different setup.sh scripts for example. Because opening arcitechture-specific binaries with the editor is pointless anyways. So, you can come up with something like this but I still think it's unnecessary:

If you want to also keep the variables intact, then:

se() {
	local d s c
	d="${HOME}/.local/bin/"
	s=("${d}"**/*(.))
	c="$(print -lnr ${s#$d:r} | fzf)"
	[[ "${c}" ]] && "${EDITOR}" "${d}${c}"*
}

This solves the sub-directory, same name script problem by also removing path, extension keeping just the relative path for sub-directories.
It also won't modify d, s, c variables.

Clean and concise.

@LukeSmithxyz
Copy link
Owner

Sorry for the negligent merge on my part.

My idea for this aliasrc file is that it should be shell-independent. That's why it's a separate file called by zsh, so theoretically bash or dash could call it themselves.

So it should remain /bin/sh and zshism/bashisms should be removed. I have reverted the original PR. People are welcome to offer little improvements, but they should be shell-agnostic. Also... let's be real. It is an interactive function. Make it efficient for honor's sake, but don't spill blood benchmarking this thing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants