2

I use zsh and I want to use a function I wrote to replace cd. This function gives you the ability to move to a parent directory:

$ pwd
/a/b/c/d
$ cl b
$ pwd
/a/b

You can also move into a subdirectory of a parent directory:

$ pwd
/a/b/c/d
$ cl b/e
$ pwd
/a/b/e

If the first part of the path is not a parent directory, it will just function as normal cd would. I hope that makes sense.

In summary, when in /a/b/c/d, I want to be able to move to /a, /a/b, /a/b/c, all subdirectories of /a/b/c/d and any absolute path starting with /, ~/ or ../ (or ./). I hope that makes sense.

This is the function I wrote:

cl () {
    local first=$( echo $1 | cut -d/ -f1 )
    if [ $# -eq 0 ]; then
        # cl without any arguments moves back to the previous directory
        cd - > /dev/null
    elif [ -d $first ]; then
        # If the first argument is an existing normal directory, move there
        cd $1
    else
        # Otherwise, move to a parent directory
        cd ${PWD%/$first/*}/$1
    fi
}

There is probably a better way to this (tips are welcome), but I haven't had any problems with this so far.

Now I want to add autocompletion. This is what I have so far:

_cl() {
    pth=${words[2]}
    opts=""
    new=${pth##*/}
    [[ "$pth" != *"/"*"/"* ]] && middle="" || middle="${${pth%/*}#*/}/"
    if [[ "$pth" != *"/"* ]]; then
        # If this is the start of the path
        # In this case we should also show the parent directories
        opts+="  "
        first=""
        d="${${PWD#/}%/*}/"
        opts+="${d//\/// }"
        dir=$PWD
    else
        first=${pth%%/*}
        if [[ "$first" == "" ]]; then
            # path starts with "/"
            dir="/$middle"
        elif [[ "$first" == "~" ]]; then
            # path starts with "~/"
            dir="$HOME/$middle"
        elif [ -d $first ]; then
            # path starts with a directory in the current directory
            dir="$PWD/$first/$middle"
        else
            # path starts with parent directory
            dir=${PWD%/$first/*}/$first/$middle
        fi
        first=$first/
    fi
    # List al sub directories of the $dir directory
    if [ -d "$dir" ]; then
        for d in $(ls -a $dir); do
            if [ -d $dir/$d ] && [[ "$d" != "." ]] && [[ "$d" != ".." ]]; then
                opts+="$first$middle$d/ "
            fi
        done
    fi
    _multi_parts / "(${opts})"
    return 0
}
compdef _cl cl

Again, probably not the best way to do this, but it works... kinda.

One of the problems is that what I type cl ~/, it replaces it with cl ~/ and does not suggest any directories in my home folder. Is there a way to get this to work?

EDIT

cl () {
    local first=$( echo $1 | cut -d/ -f1 )
    if [ $# -eq 0 ]; then
        # cl without any arguments moves back to the previous directory
        local pwd_bu=$PWD
        [[ $(dirs) == "~" ]] && return 1
        while [[ $PWD == $pwd_bu ]]; do
            popd >/dev/null
        done
        local pwd_nw=$PWD
        [[ $(dirs) != "~" ]] && popd >/dev/null
        pushd $pwd_bu >/dev/null
        pushd $pwd_nw >/dev/null
    elif [ -d $first ]; then
        pushd $1 >/dev/null # If the first argument is an existing normal directory, move there
    else
        pushd ${PWD%/$first/*}/$1 >/dev/null # Otherwise, move to a parent directory or a child of that parent directory
    fi
}
_cl() {
    _cd
    pth=${words[2]}
    opts=""
    new=${pth##*/}
    local expl
    # Generate the visual formatting and store it in `$expl`
    _description -V ancestor-directories expl 'ancestor directories'
    [[ "$pth" != *"/"*"/"* ]] && middle="" || middle="${${pth%/*}#*/}/"
    if [[ "$pth" != *"/"* ]]; then
        # If this is the start of the path
        # In this case we should also show the parent directories
        local ancestor=$PWD:h
        while (( $#ancestor > 1 )); do
            # -f: Treat this as a file (incl. dirs), so you get proper highlighting.
            # -Q: Don't quote (escape) any of the characters.
            # -W: Specify the parent of the dir we're adding.
            # ${ancestor:h}: The parent ("head") of $ancestor.
            # ${ancestor:t}: The short name ("tail") of $ancestor.
            compadd "$expl[@]" -fQ -W "${ancestor:h}/" - "${ancestor:t}"
            # Move on to the next parent.
            ancestor=$ancestor:h
        done
    else
        # $first is the first part of the path the user typed in.
        # it it is part of the current direoctory, we know the user is trying to go back to a directory
        first=${pth%%/*}
        # $middle is the rest of the provided path
        if [ ! -d $first ]; then
            # path starts with parent directory
            dir=${PWD%/$first/*}/$first
            first=$first/
            # List all sub directories of the $dir/$middle directory
            if [ -d "$dir/$middle" ]; then
                for d in $(ls -a $dir/$middle); do
                    if [ -d $dir/$middle/$d ] && [[ "$d" != "." ]] && [[ "$d" != ".." ]]; then
                        compadd "$expl[@]" -fQ -W $dir/ - $first$middle$d
                    fi
                done
            fi
        fi
    fi
}
compdef _cl cl

This is as far as I got on my own. It does works (kinda) but has a couple of problems:

  • When going back to a parent directory, completion mostly works. But when you go to a child of the paretn directory, the suggestions are wrong (they display the full path you have typed, not just the child directory). The result does work
  • I use syntax-hightlighting, but the path I type is just white (when using going to a parent directory. the normal cd functions are colored)
  • In my zshrc, I have the line:
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Za-z}' '+l:|=* r:|=*'

Whith cd this means I can type "load" and it will complete to "Downloads". With cl, this does not work. Not event when using the normal cd functionality.

Is there a way to fix (some of these) problems? I hope you guys understand my questions. I find it hard to explain the problem.

Thanks for your help!

1 Answers1

0

This should do it:

_cl() {
  # Store the number of matches generated so far.
  local -i nmatches=$compstate[nmatches]

  # Call the built-in completion for `cd`. No need to reinvent the wheel.
  _cd

  # ${PWD:h}: The parent ("head") of the present working dir.
  local ancestor=$PWD:h expl

  # Generate the visual formatting and store it in `$expl`
  # -V: Don't sort these items; show them in the order we add them.
  _description -V ancestor-directories expl 'ancestor directories'

  while (( $#ancestor > 1 )); do
    # -f: Treat this as a file (incl. dirs), so you get proper highlighting.
    # -W: Specify the parent of the dir we're adding.
    # ${ancestor:h}: The parent ("head") of $ancestor.
    # ${ancestor:t}: The short name ("tail") of $ancestor.
    compadd "$expl[@]" -f -W ${ancestor:h}/ - $ancestor:t

    # Move on to the next parent.
    ancestor=$ancestor:h
  done

  # Return true if we've added any matches.
  (( compstate[nmatches] > nmatches ))
}

# Define the function above as generating completions for `cl`.
compdef _cl cl

# Alternatively, instead of the line above:
# 1. Create a file `_cl` inside a dir that's in your `$fpath`.
# 2. Paste the _contents_ of the function `_cl` into this file.
# 3. Add `#compdef cl` add the top of the file.
# `_cl` will now get loaded automatically when you run `compinit`.

Also, I would rewrite your cl function like this, so it no longer depends on cut or other external commands:

cl() {
  if (( $# == 0 )); then
    # `cl` without any arguments moves back to the previous directory.
    cd -
  elif [[ -d $1 || -d $PWD/$1 ]]; then
    # If the argument is an existing absolute path or direct child, move there.
    cd $1
  else
    # Get the longest prefix that ends with the argument.
    local ancestor=${(M)${PWD:h}##*$1}
    if [[ -d $ancestor ]]; then
      # Move there, if it's an existing dir.
      cd $ancestor
    else
      # Otherwise, print to stderr and return false.
      print -u2 "$0: no such ancestor '$1'"
      return 1
    fi
  fi
}

Alternative Solution

There is an easier way to do all of this, without the need to write a cd replacement or any completion code:

cdpath() {
  # `$PWD` is always equal to the present working directory.
  local dir=$PWD

  # In addition to searching all children of `$PWD`, `cd` will also search all 
  # children of all of the dirs in the array `$cdpath`.
  cdpath=()

  # Add all ancestors of `$PWD` to `$cdpath`.
  while (( $#dir > 1 )); do
    # `:h` is the direct parent.
    dir=$dir:h
    cdpath+=( $dir )
  done
}

# Run the function above whenever we change directory.
add-zsh-hook chpwd cdpath

Zsh's completion code for cd automatically takes $cdpath into account. No need to even configure that. :)

As an example of how this works, let's say you're in /Users/marlon/.zsh/prezto/modules/history-substring-search/external/.

  • You can now type cd pre and press Tab, and Zsh will complete it to cd prezto. After that, pressing Enter will take you directly to /Users/marlon/.zsh/prezto/.
  • Or let's say that there also exists /Users/marlon/.zsh/prezto/modules/prompt/external/agnoster/. When you're in the former dir, you can do cd prompt/external/agnoster to go directly to the latter, and Zsh will complete this path for you every step of the way.
Marlon Richert
  • 5,250
  • 1
  • 18
  • 27
  • The idea is clever, but if the user already has set his cdpath to a fixed value, this would destroy his settings. – user1934428 Oct 22 '20 at 11:04
  • @MarlonRichet : The problem is that you have to modify this function to apply the change. In a typical example we have set up your function somewhere in .zshrc, and after some time of using it, the user decides to set an explicit `cdpath` on the fly. The function is not aware from this change. Only if the user decides to setup the default cdpath at the time the function is defined, and not change it afterwards, it would work. – user1934428 Oct 22 '20 at 12:31
  • This is the first time I’ve ever seen a a good use for `$cdpath`. I doubt it’s a common occurrence for most users. But feel free to suggest a workaround or improvement to the function. – Marlon Richert Oct 22 '20 at 12:46
  • Wow, I didn't know that was possible. It is not what I meant though. I edited my question. I hope it is more clear now. – user5328080 Oct 22 '20 at 19:23
  • @MarlonRichert : I use cdpath to quickly switch between subdirectories directories, Instead of `cd ~/testdata/testcases/tc1`, I have `CDPATH=~/testdata/testcases` and simply do a `cd tc1`. – user1934428 Oct 23 '20 at 05:54
  • 1
    @user1934428 I use [`cdr`](http://zsh.sourceforge.net/Doc/Release/User-Contributions.html#Recent-Directories) plus [`zsh-autocomplete`](https://github.com/marlonrichert/zsh-autocomplete/blob/master/README.md#features) for that. That way, I don’t have to configure anything. I can just type any part of any dir I’ve recently visited, press Tab and it will complete the entire path for me. – Marlon Richert Oct 23 '20 at 05:59
  • @Tijn I don’t see any difference to your original question. What exactly is it that you want to know? – Marlon Richert Oct 23 '20 at 06:02
  • @MarlonRichert : I'll take a look at cdr. Never heard of it before. From my understanding, it works only if you had been cd'ed there manually before, while with `cdpath`, I can set my directories to a default value which already is useful after login, but of course it is less flexible in that I have to manage the list manually. – user1934428 Oct 23 '20 at 06:02
  • @MarlonRichert, Really. the question is the same, with different examples. the difference between your function and mine is that mine only suggests all parent dirs and the subdirecories of the current dir, whereas yours gives all subdirectories of all parent dirs and all subdirectories of the current dir. It is a really nice solution, and maybe I am going to try it, but I would really like to have the autocomplete work for the function I wrote (which works). That way zsh does not suggest way more directories than I need. – user5328080 Oct 23 '20 at 07:57
  • @Tijn Ah, now I get it. Thanks for explaining. – Marlon Richert Oct 24 '20 at 14:50
  • 1
    @user1934428 `cdr` maintains recent dirs in a text file, so they persist between sessions and you can manually add new dirs to the file (which also possible from the command line). – Marlon Richert Oct 24 '20 at 14:53
  • 1
    @Tijn But it says in the end of `cl()`: "Otherwise, move to a parent directory or a child of that parent directory". So, children of parent dirs are supposed to be valid targets. Then why wouldn't you want to complete them? – Marlon Richert Oct 24 '20 at 15:04
  • @MarlonRichert, no, what I meant by that, is what I described in the second example (cl b/e). just "cl e" should not work. I get the confusion and I will think about a better way of putting it – user5328080 Oct 24 '20 at 19:46
  • After some further testing i noticed that I cant move to a subdirectory of a parent directory (nor does it suggest those directories). See the second example. – user5328080 Dec 15 '20 at 14:43
  • @user5328080 Why on earth do you have `#!/bin/bash` at the top of your file? That's going to lead to all kinds of nonsense. – Marlon Richert Dec 18 '20 at 12:35
  • @MarlonRichert such as? I was told once to use it, but I must confess, I dont know a lot about it and I dont know of any reason not to use it – user5328080 Dec 18 '20 at 22:40
  • That line tells the shell to run your code with Bash. You need to either leave it out (if you’re sourcing the file only from Zsh anyway) or replace it with `#!/bin/zsh` (or whatever is the correct path to `zsh` on your machine). – Marlon Richert Dec 19 '20 at 10:36
  • @MarlonRichert Could you check if you can help me with this problem? you seem to know how zsh completion works. Thanks!! – user5328080 Jan 17 '21 at 18:52
  • @user5328080 Help you with what? Is my answer not a solution to your question? Or if you have another problem, please create a new question for it. – Marlon Richert Jan 17 '21 at 19:48
  • @MarlonRichert, Your solution does help with my problem, but there are still some problems. see my update in the original question (sorry, I forgot to tell you ive updated my question). – user5328080 Jan 18 '21 at 11:33
  • @user5328080 I have no idea what you changed or added to your question. Perhaps it's better to just create a new one anyway. – Marlon Richert Jan 18 '21 at 11:44
  • @MarlonRichert everything below the "edit". Why should i create a new question? I think it is still the same question. I am very new to posting to stackoverflow. – user5328080 Jan 19 '21 at 09:09
  • @user5328080 https://stackoverflow.com/help/minimal-reproducible-example – Marlon Richert Jan 19 '21 at 13:11
  • @MarlonRichert I have posted another question (https://stackoverflow.com/questions/66064158/how-do-i-make-a-zsh-function-have-autocompletion-and-syntax-highlighting-almost). I hope the examples help solve the problem. Let me know if something is unclear – user5328080 Feb 05 '21 at 13:32
  • @MarlonRichert I devided my question up into three questions: https://stackoverflow.com/questions/66258645/how-do-i-make-a-zsh-function-autocomplet-from-the-middle-of-a-word https://stackoverflow.com/questions/66250492/how-do-i-make-the-autosuggestions-of-a-zsh-function-use-syntax-highlighting https://stackoverflow.com/questions/66246724/how-do-i-make-a-zsh-function-autocomplete-as-a-file – user5328080 Feb 18 '21 at 11:07