Relative paths based on file location instead of current working directory [duplicate]
Asked Answered
C

3

181

Given:

some.txt
dir
 |-cat.sh

With cat.sh having the content:

cat ../some.txt

Then running ./cat.sh inside dir works fine while running ./dir/cat.sh on the same level as dir does not. I expect this to be due to the different working directories. Is there an easy way to make the path ../some.txt relative to the location of cat.sh?

Cottbus answered 9/6, 2014 at 1:52 Comment(3)
This is BashFAQ #28: mywiki.wooledge.org/BashFAQ/028Chipmunk
This is a variant of a very frequently asked question (that being how to determine the location of a script being run). I'm pondering whether it is in fact different enough to not be a duplicate.Chipmunk
...so, given the location the script is stored in (per the answer this is marked duplicate of), one need only cd to that directory before continuing to have the effect requested here.Chipmunk
C
280

What you want to do is get the absolute path of the script (available via ${BASH_SOURCE[0]}) and then use this to get the parent directory and cd to it at the beginning of the script.

#!/bin/bash
parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )

cd "$parent_path"
cat ../some.text

This will make your shell script work independent of where you invoke it from. Each time you run it, it will be as if you were running ./cat.sh inside dir.

Note that this script only works if you're invoking the script directly (i.e. not via a symlink), otherwise the finding the current location of the script gets a little more tricky)

Contaminate answered 9/6, 2014 at 1:55 Comment(6)
Is there a reason why you cd to the script directory and then call pwd to store it instead of just storing the result of the dirname directly into parent_path?Busra
@Busra The pwd -P resolves any symlinks in the path, so the statement returns the canonical path.Barbicel
If you get a warning that says cat.sh: 2: cat.sh: Bad substitution, then see here: #29832537Blankly
Tip: If one doesn't want to change the cwd, they could also use pushd $parent_path instead of cd and call popd after cat.Disunite
Can you elaborate on the Bad substitution @CameronHudson gives a link to another post, but the answer there is so long and complex :-( Thanks!Nickles
@Nickles "The POSIX-shell (sh) counterpart of $BASH_SOURCE is $0. Caveat: The crucial difference is that if your script is being sourced (loaded into the current shell with .), the snippets below will not work properly."Blankly
E
57

@Martin Konecny's answer provides an effective solution, but - as he mentions - it only works if the actual script is not invoked through a symlink residing in a different directory.

This answer covers that case: a solution that also works when the script is invoked through a symlink or even a chain of symlinks:


Linux / GNU readlink solution:

If your script needs to run on Linux only or you know that GNU readlink is in the $PATH, use readlink -f, which conveniently resolves a symlink to its ultimate target:

 scriptDir=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")

Note that GNU readlink has 3 related options for resolving a symlink to its ultimate target's full path: -f (--canonicalize), -e (--canonicalize-existing), and -m (--canonicalize-missing) - see man readlink.
Since the target by definition exists in this scenario, any of the 3 options can be used; I've chosen -f here, because it is the most well-known one.


Multi-(Unix-like-)platform solution (including platforms with a POSIX-only set of utilities):

If your script must run on any platform that:

  • has a readlink utility, but lacks the -f option (in the GNU sense of resolving a symlink to its ultimate target) - e.g., macOS.

    • macOS uses an older version of the BSD implementation of readlink; note that recent versions of FreeBSD/PC-BSD do support -f.
  • does not even have readlink, but has POSIX-compatible utilities - e.g., HP-UX (thanks, @Charles Duffy).

The following solution, inspired by https://mcmap.net/q/13456/-how-can-i-get-the-behavior-of-gnu-39-s-readlink-f-on-a-mac-closed, defines helper shell function, rreadlink(), which resolves a given symlink to its ultimate target in a loop - this function is in effect a POSIX-compliant implementation of GNU readlink's -e option, which is similar to the -f option, except that the ultimate target must exist.

Note: The function is a bash function, and is POSIX-compliant only in the sense that only POSIX utilities with POSIX-compliant options are used. For a version of this function that is itself written in POSIX-compliant shell code (for /bin/sh), see here.

  • If readlink is available, it is used (without options) - true on most modern platforms.

  • Otherwise, the output from ls -l is parsed, which is the only POSIX-compliant way to determine a symlink's target.
    Caveat: this will break if a filename or path contains the literal substring -> - which is unlikely, however.
    (Note that platforms that lack readlink may still provide other, non-POSIX methods for resolving a symlink; e.g., @Charles Duffy mentions HP-UX's find utility supporting the %l format char. with its -printf primary; in the interest of brevity the function does NOT try to detect such cases.)

  • An installable utility (script) form of the function below (with additional functionality) can be found as rreadlink in the npm registry; on Linux and macOS, install it with [sudo] npm install -g rreadlink; on other platforms (assuming they have bash), follow the manual installation instructions.

If the argument is a symlink, the ultimate target's canonical path is returned; otherwise, the argument's own canonical path is returned.

#!/usr/bin/env bash

# Helper function.
rreadlink() ( # execute function in a *subshell* to localize the effect of `cd`, ...

  local target=$1 fname targetDir readlinkexe=$(command -v readlink) CDPATH= 

  # Since we'll be using `command` below for a predictable execution
  # environment, we make sure that it has its original meaning.
  { \unalias command; \unset -f command; } &>/dev/null

  while :; do # Resolve potential symlinks until the ultimate target is found.
      [[ -L $target || -e $target ]] || { command printf '%s\n' "$FUNCNAME: ERROR: '$target' does not exist." >&2; return 1; }
      command cd "$(command dirname -- "$target")" # Change to target dir; necessary for correct resolution of target path.
      fname=$(command basename -- "$target") # Extract filename.
      [[ $fname == '/' ]] && fname='' # !! curiously, `basename /` returns '/'
      if [[ -L $fname ]]; then
        # Extract [next] target path, which is defined
        # relative to the symlink's own directory.
        if [[ -n $readlinkexe ]]; then # Use `readlink`.
          target=$("$readlinkexe" -- "$fname")
        else # `readlink` utility not available.
          # Parse `ls -l` output, which, unfortunately, is the only POSIX-compliant 
          # way to determine a symlink's target. Hypothetically, this can break with
          # filenames containig literal ' -> ' and embedded newlines.
          target=$(command ls -l -- "$fname")
          target=${target#* -> }
        fi
        continue # Resolve [next] symlink target.
      fi
      break # Ultimate target reached.
  done
  targetDir=$(command pwd -P) # Get canonical dir. path
  # Output the ultimate target's canonical path.
  # Note that we manually resolve paths ending in /. and /.. to make sure we
  # have a normalized path.
  if [[ $fname == '.' ]]; then
    command printf '%s\n' "${targetDir%/}"
  elif  [[ $fname == '..' ]]; then
    # Caveat: something like /var/.. will resolve to /private (assuming
    # /var@ -> /private/var), i.e. the '..' is applied AFTER canonicalization.
    command printf '%s\n' "$(command dirname -- "${targetDir}")"
  else
    command printf '%s\n' "${targetDir%/}/$fname"
  fi
)
                
# Determine ultimate script dir. using the helper function.
# Note that the helper function returns a canonical path.
scriptDir=$(dirname -- "$(rreadlink "$BASH_SOURCE")")
Ensanguine answered 9/6, 2014 at 5:16 Comment(2)
scriptDir=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")") to make shellcheck happy.Mallissa
@iElectric: (I originally misread your comment as relating to the rreadlink function). Yes, shellcheck.net issues a warning when referencing a variable that is technically an array without also specifying an index, in which case the 1st element is returned (here, $BASH_SOURCE is equivalent to ${BASH_SOURCE[0]}). While doing so can be a pitfall, it is also a convenient shortcut, especially with variables such as BASH_SOURCE, where it is almost always the 1st element that is of interest.Ensanguine
A
27

Just one line will be OK.

cat "`dirname $0`"/../some.txt
Abijah answered 9/6, 2014 at 3:19 Comment(5)
Same issue as Martin's answer -- needs more quotes, or this will behave very badly when $0 contains whitespace, glob characters, etc.Chipmunk
@CharlesDuffy Yes, absolutely. more quotes will be better.Abijah
The robust version of this answer: cat "$(dirname "$0")/../some.txt". Note that it will still fail in 2 cases: (a) if the script itself is invoked through a symlink to the script located in a different directory; and (b) if the script is invoked through a path containing a symlink to the script's directory. In both cases, .. will NOT refer to the script directory's actual parent directory. Aside from that, when a script is sourced, $0 won't work as intended; $BASH_SOURCE is the better choice.Ensanguine
Thanks, but the unquoted use of $0 is still a problem.Ensanguine
In my case I had to change it to cd "`dirname "$0"`" to handle path with spacesKlee

© 2022 - 2024 — McMap. All rights reserved.