16
\$\begingroup\$

Adding a directory to the PATH in Unix/Linux is a very common task. However, what if the directory is already in the path? My goal is to write a shell function (preferably portable) that will add a directory to the front or back of the PATH variable, but only if it's not already there.

Here's what I have (syntax is in zsh / bash):

#-------
# DESC: Adds a directory to the PATH if it's not already in the PATH
# ARGS:
#  1 - The directory to add
#  2 - Which end of PATH to add to.  Use "front" to prepend.
#-------
add2path() {
  if ! echo $PATH | egrep "(^|:)$1(:|\$)" > /dev/null ; then
    if [[ $2 = "front" ]]; then
      PATH="$1:$PATH"
    else
      PATH="$PATH:$1"
    fi
    export PATH
  fi
}

This function works. I have tested it on Ubuntu, Solaris, and FreeBSD. And I have tested it in bash, zsh, and ksh. But I want to make sure that it is as portable (first and foremost), readable, and as efficient as possible.

Remarks

  • I know that the =~ operator would be more readable, but I had trouble with it not working on certain OSes, particularly Solaris.
  • I know I could (and arguably should) use grep -Fq instead of egrep routing to /dev/null, but again this didn't work on certain OSes (Solaris)
    • Ironically, the way to fix this on Solaris is to add /usr/xpg4/bin to the PATH. :)
\$\endgroup\$
1
  • 1
    \$\begingroup\$ If you want to write a portable shell function, instead of targeting certain shell interpreters and OSes, you should just write it with pure POSIX syntax and make sure it is executed in a POSIX context (which is the trickiest part). \$\endgroup\$
    – jlliagre
    Apr 28, 2015 at 16:31

5 Answers 5

10
\$\begingroup\$

grep is most likely an overkill. After setting IFS=":", the $PATH is conveniently split into words. Then the presence of the directory can be determined in a simple loop

    IFS=":"
    for pathdir in $PATH; do
        if [ $pathdir == $1]; then return; fi
    done

    # Now restore IFS and modify path as needed.
\$\endgroup\$
5
  • \$\begingroup\$ Won't this be slower, though? In this solution, we're using a script to perform looping whereas the original solution has grep, which runs at the machine level, doing the looping for us. \$\endgroup\$
    – Sildoreth
    Apr 28, 2015 at 18:32
  • 3
    \$\begingroup\$ @Sildoreth No. Looping via builtins is fast (at least comparing to a fork/exec of grep invocation). \$\endgroup\$
    – vnp
    Apr 28, 2015 at 18:53
  • 3
    \$\begingroup\$ I like this. But I'd go with the shorter $pathdir == $1 && return \$\endgroup\$
    – janos
    Apr 29, 2015 at 19:08
  • 1
    \$\begingroup\$ You probably don't want to just return there without also restoring IFS. \$\endgroup\$ Nov 8, 2018 at 14:04
  • \$\begingroup\$ Here is a nice way to restore IFS \$\endgroup\$
    – raratiru
    Nov 10, 2022 at 14:56
5
\$\begingroup\$

Here is one way to make it more portable and robust:

add2path() {
  if ! echo "$PATH" | PATH=$(getconf PATH) grep -Eq '(^|:)'"${1:?missing argument}"'(:|\$)' ; then # Use the POSIX grep implementation
    if [ -d "$1" ]; then # Don't add a non existing directory to the PATH
      if [ "$2" = front ]; then # Use a standard shell test
        PATH="$1:$PATH"
      else
        PATH="$PATH:$1"
      fi
      export PATH
    fi
  fi
}

I have added a test to quit the function with an error should no argument be passed.

PATH=$(getconf PATH) is the portable way to have the POSIX commands first in the PATH. This is solving the issue you had with Solaris where, not to break existing Solaris scripts portability, the first grep command found in the PATH is not POSIX compliant.

Using [[ is not specified by POSIX so it is better to stick with [ to do tests.

$2 should then be quoted to avoid clashes with [ operands.

front, being a constant string, need not be quoted.

Excluding non directories to be added looks to me a reasonable approach but is of course an optional extension which you might remove.

\$\endgroup\$
6
  • 1
    \$\begingroup\$ It's not necessarily bad to add a non-existent directory to the PATH. For instance, say I always want to be able to run from ~/bin, and I have the exact same rc files on many machines (which is the case for me). There might be cases where the ~/bin directory doesn't exist yet. Then if I add it on one machine, I'd have to re-run my rc file to place that directory in the PATH. \$\endgroup\$
    – Sildoreth
    Apr 28, 2015 at 16:51
  • \$\begingroup\$ @Sildoreth Indeed, I have added comments about it in my reply. \$\endgroup\$
    – jlliagre
    Apr 28, 2015 at 17:02
  • \$\begingroup\$ @TobySpeight Not just a matter of style. Quotes are definitely required here to allow adding a path with an embedded space, or not to fail when passed an empty argument. \$\endgroup\$
    – jlliagre
    Nov 8, 2018 at 20:53
  • \$\begingroup\$ @TobySpeight Okay, I misunderstood your comment. I just meant that the quotes were superfluous around front but were required around $2. You might indeed keep them around the former without breaking the script. \$\endgroup\$
    – jlliagre
    Nov 8, 2018 at 21:05
  • 1
    \$\begingroup\$ Yes, that's what I meant. But it looked like you were saying that it shouldn't be quoted. I think that's actually just a typo, and I've made an edit accordingly. (Sometimes in English, a single letter makes all the difference - e.g. "a few people" is very much more than "few people"!) \$\endgroup\$ Nov 8, 2018 at 21:38
3
\$\begingroup\$

I don't think you need to export PATH. By the time this script of yours is sourced, PATH should be exported already by startup scripts of the OS. See also this post on Unix SE.

If you don't mind dropping ksh support, then you can replace echo with a here-string:

egrep "(^|:)$1(:|\$)" <<< "$PATH" > /dev/null
\$\endgroup\$
3
\$\begingroup\$

vnp's solution is pretty good because it uses only builtin. Here's another way, which doesn't modify the IFS:

add2path() {
  case :$PATH: in
    *:$1:*) ;;
    *)
      if [ "$2" = 'front' ]; then
        PATH="$1:$PATH"
      else
        PATH="$PATH:$1"
      fi
      export PATH
      ;;
  esac
}
\$\endgroup\$
4
  • 4
    \$\begingroup\$ This is terrible. There is no way to uniquely match pathnames in :-delimited strings in raw bash without either manually iterating the ${IFS} or matching via the regex-based =~ operator. Without profiling, it's unclear which is more performant. What is clear, however, is that this answer fails in fairly obviously common edge cases (e.g., when the passed dirname is itself a substring of an existing dirname on the current ${PATH}). Notably, PATH=/usr/bin && add2path /bin/ front && echo $PATH prints /usr/bin rather than the expected /bin:/usr/bin. (Woops.) \$\endgroup\$ Nov 8, 2018 at 5:41
  • \$\begingroup\$ @CecilCurry Thanks for spotting the bug. I've fixed it and now the function works just fine. \$\endgroup\$ Nov 8, 2018 at 13:52
  • \$\begingroup\$ Simpler method: case ":$PATH:" in *:$1:*) - if we add : onto each end, we no longer have to special-case the first and last members. I'm not 100% sure, but I think $1 might need quotes there, too. (Oh, and we can use [ instead of [[, to regain portability). \$\endgroup\$ Nov 8, 2018 at 14:07
  • \$\begingroup\$ @TobySpeight Good idea. It does in fact simplifies this example a little bit. \$\endgroup\$ Nov 8, 2018 at 14:20
-1
\$\begingroup\$

I liked the idea presented by @vpn but it's only a snippet so I coded a working function using just bash builtins and separating the functionality into two different calls (append/prepend) which IMHO makes it more robust.

@vpns's answer doesn't include the whole function and also has the IFS problem. This working code doesn't use grep and IFS is untouched.

not_in_path(){
    IFS=":" read -r -a path_array <<< "$PATH"
    for pathdir in ${path_array[@]}; do
        if [ "$pathdir" == "$1" ]; then
            return 1
        fi
    done
    return 0
}
append2path(){
    not_in_path "$1" && export PATH="$PATH:$1"
}
prepend2path(){
    not_in_path "$1" && export PATH="$1:$PATH"
}

Usage is pretty simple, just:

  • append2path /my/path to append
  • prepend2path /my/path to prepend

From what I understand I can rely on PATH being already present so it is not necessary to handle the case of PATH being empty or not set and colon should be omitted.

Also there's no need to export the variable. It doesn't harm, though and for me it is clearer what the function intends to achieve. Maybe there are other issues. Happy to see some feedback.

\$\endgroup\$
8
  • 1
    \$\begingroup\$ If you have a new question, please ask it by clicking the Ask Question button. Include a link to this question if it helps provide context. - From Review \$\endgroup\$
    – rolfl
    Feb 11, 2022 at 19:24
  • \$\begingroup\$ You have presented an alternative solution, but haven't reviewed the code. Please edit to show what aspects of the question code prompted you to write this version, and in what ways it's an improvement over the original. It may be worth (re-)reading How to Answer. \$\endgroup\$ Feb 12, 2022 at 15:13
  • 1
    \$\begingroup\$ @TobySpeight did you find any specific issues in the code which made you suggest that the code is not reviewed? I added a reasoning. It is more a continuation of an idea than an alternative solution. \$\endgroup\$
    – badc0de
    Feb 12, 2022 at 23:14
  • 1
    \$\begingroup\$ @TobySpeight Sorry, should I link the answer in question. Didn't know how to do that. The sentence would be: "@vpns's answer doesn't include the whole function and also has the IFS problem. This working code doesn't use grep and IFS is untouched." \$\endgroup\$
    – badc0de
    Feb 12, 2022 at 23:45
  • 1
    \$\begingroup\$ It looks like you have some pretty good suggestions in there - separate functions to append and prepend is certainly more user-friendly and robust than relying on a second argument to switch behaviours, for example. That's something that could be a worthwhile review and good intro to your alternative solution! \$\endgroup\$ Feb 12, 2022 at 23:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.