summaryrefslogtreecommitdiffstats
path: root/content/posts/bash-incremental-directory-completion/index.org
blob: 6dcb9a403bc3a92fe5241b0d9f42cb7f6f76509c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#+TITLE: Bash Incremental Directory Completion
#+DATE: 2023-11-18T13:59:18-05:00
#+DRAFT:
#+DESCRIPTION:
#+TAGS[]: bash, shell
#+KEYWORDS[]: bash, shell
#+SLUG:
#+SUMMARY:

I was just working on a bash completion for my [[https://github.com/dantecatalfamo/repo2/][repo management tool]]
and came across a completion problem I couldn't find an answer to.

In my tool, all repositories are stored under a root source directory
=~/src=, and are three directories deep under that.

The top directory is the website, the second is the user, and the
third is the repo itself. For example =github.com/zig/ziglang=.

My tool =repo= has a command =repo cd <spec>=, which will try to find a
project matching the path you specify. It will check all paths three
deep under the root and try to find one that matches the spec you give
it. You don't have to specify the full path if you know there's only
one project with the spec as part of its name. For example =repo cd
repo2= would match  =~/src/github.com/dantecatalfamo/repo2=. It will
then move you to that directory.

I've gotten to the point where I have enough repositories from enough
websites that I would like to be able to incrementally tab-complete
the full three-tier path, as I sometimes forget which projects I've
already cloned.

For example I would like to be able to type =repo cd <tab><tab>= and
get a list of all websites.

#+begin_src
$ repo cd <tab><tab>
chromium.googlesource.com/  gitlab.com/            git.meli.delivery/  git.zx2c4.com/      webrtc.googlesource.com/
bitbucket.org/              code.orgmode.org/      gitlab.winehq.org/  git.musl-libc.org/  humungus.tedunangst.com/
c9x.me/                     git.savannah.gnu.org/  github.com/         git.sr.ht/          mumble.net/
#+end_src

Then I'd like to be able to have that complete, and tab complete the
user under that website, and then the project under that user.

#+begin_src
$ repo cd github.com/raysan5/<tab><tab>
github.com/raysan5/physac  github.com/raysan5/raygui  github.com/raysan5/raylib
#+end_src

And then finally add a space once we've reached the third directory deep.

The problem is that there's no obvious way to accomplish that using
the =complete= and =compgen= commands.

My first attempt was to use =compgen -d "~/src/"=, but that came with
some issues.

The first being that it would include the whole path to the directory,
and since I wanted the command to work from anywhere, I had to use the
absolute path to the source root and that full path would show up in
the completions, which wasn't what I wanted.

I then tried using =cd ~/src && compgen -d=. This worked better but I
ran into what ultimately ended up being the issue that made me create
this post.

=compgen= would complete one level of directories, and then add a space
after the completion instead of completing the directories under it.

My next attempt was to use the =find= command.

#+begin_src bash
dirs=$(find ${src_root} -maxdepth 3 -mindepth 3 -type d -printf "%P\n")"
COMPREPLY=($(repo cd && compgen -W "${dirs}" "${COMP_WORDS[2]}"))
#+end_src


This roughly worked but came with its own issues. Since it was listing
all three path components at once, between the hundreds of
repositories I've cloned from several sites, it would produce so many
results that I would always get the =Display all 4967 possibilities?
(y or n)= warning every time. The other issue is that for the first
double tab, I only want a list of sites, where this will list all
repos of one site first, then all of another, pushing the actual list
of sites very far apart.

It's possible to add your completion using the =-o nospace= command
like this =complete -F _repo_completions -o nospace repo=, but that
means it will never append a space to the end of your completions,
which I don't want since I have subcommands other than =cd= that I
want to always place a space after.

The solution I ended up with was this.

#+begin_src bash
function _repo_completions {
    if [ "${#COMP_WORDS[@]}" -eq 2 ]; then
        COMPREPLY=($(compgen -W "cd clone help shell env ls new root reload" "${COMP_WORDS[1]}"))
        return;
    fi

    if [ "${#COMP_WORDS[@]}" -eq 3 ] && [ "${COMP_WORDS[1]}" == "cd" ]; then
        local only_slashes="${COMP_WORDS[2]//[^\/]}"
        local num_slashes="${#only_slashes}"
        if [ $num_slashes -lt 2 ]; then
            compopt -o nospace
            COMPREPLY=($(repo cd && compgen -d -S / "${COMP_WORDS[2]}"))
        else
            COMPREPLY=($(repo cd && compgen -d "${COMP_WORDS[2]}"))
        fi

        return;
    fi
}

complete -F _repo_completions repo
#+end_src

In the above code, =repo cd= changes directory to =~/src= and the =-S
/= option for =compgen= will append a trailing (suffix) slash to the
end of the suggestion.

The key here is that you can use =compopt= to dynamically change
completion options while running the completion, which lets me check
how many directories deep we are into the completion by counting
slashes, and disable appending a space until we're a full three
directories deep into the completion.

Here is a good resource on bash completion for further reading.
https://iridakos.com/programming/2018/03/01/bash-programmable-completion-tutorial