Normalizing Path Names with Bash

The bash function presented here normalizes path names. By normalize I mean it removes unneeded /./ and ../dir sequences. For example, ../d1/./d2/../f1 normalized would be ../d1/f1.

The first version of the function uses bash regular expressions. The /./ sequences are removed first during variable expansion with substitution by the line:

local   path=${1//\/.\//\/}

The dir/.. sequences are removed by the loop:

while [[ $path =~ ([^/][^/]*/\.\./) ]]
do
    path=${path/${BASH_REMATCH[0]}/}
done

Each time a dir/.. match is found, variable expansion with substitution is used to remove the matched part of the path.

Regular expressions were introduced in bash 3.0. Bash 3.2 changed regular expression handling slightly in that quotes around regular expressions became part of the regular expression. So, if you have a version of bash (with regular expression support) and the code doesn't work, put the regular expression in the while loop in quotes.

The entire function and some test code follows:

#!/bin/bash
#
# Usage: normalize_path PATH
#
# Remove /./ and dir/.. sequences from a pathname and write result to stdout.

function normalize_path()
{
    # Remove all /./ sequences.
    local   path=${1//\/.\//\/}
    
    # Remove dir/.. sequences.
    while [[ $path =~ ([^/][^/]*/\.\./) ]]
    do
        path=${path/${BASH_REMATCH[0]}/}
    done
    echo $path
}

if [[ $(basename $0 .sh) == 'normalize_path' ]]; then
    if [[ "$*" ]]; then
        for p in "$@"
        do
            printf "%-30s => %s\n" $p $(normalize_path $p)
        done
    else
        for p in /test/../test/file test/../test/file .././test/../test/file
        do
            printf "%-30s => %s\n" $p $(normalize_path $p)
        done
    fi
fi


#####################################################################

# vim: tabstop=4: shiftwidth=4: noexpandtab:
# kate: tab-width 4; indent-width 4; replace-tabs false;

Since, older versions of bash don't support regular expressions the second version does the same thing using sed instead:

#!/bin/bash
#
# Usage: normalize_path PATH
#
# Remove /./ and dir/.. sequences from a pathname and write result to stdout.

function normalize_path()
{
    # Remove all /./ sequences.
    local   path=${1//\/.\//\/}
    
    # Remove first dir/.. sequence.
    local   npath=$(echo $path | sed -e 's;[^/][^/]*/\.\./;;')
    
    # Remove remaining dir/.. sequence.
    while [[ $npath != $path ]]
    do
        path=$npath
        npath=$(echo $path | sed -e 's;[^/][^/]*/\.\./;;')
    done
    echo $path
}

if [[ $(basename $(basename $0 .sh) .old) == 'normalize_path' ]]; then
    if [[ "$*" ]]; then
        for p in "$@"
        do
            printf "%-30s => %s\n" $p $(normalize_path $p)
        done
    else
        for p in /test/../test/file test/../test/file .././test/../test/file
        do
            printf "%-30s => %s\n" $p $(normalize_path $p)
        done
    fi
fi


#####################################################################

# vim: tabstop=4: shiftwidth=4: noexpandtab:
# kate: tab-width 4; indent-width 4; replace-tabs false;

You can run the script directly and it runs a few tests:

$ bash normalize_path.sh
/test/../test/file             => /test/file
test/../test/file              => test/file
.././test/../test/file         => ../test/file

You can also pass in test cases on the command line:

$ bash normalize_path.sh ../d1/./d2/../f1 a/b/c/../d/../e
../d1/./d2/../f1               => ../d1/f1
a/b/c/../d/../e                => a/b/e

Normalized path names are never necessary but they're often easier to comprehend at a glance.

Mitch Frazier is an embedded systems programmer at Emerson Electric Co. Mitch has been a contributor to and a friend of Linux Journal since the early 2000s.

Load Disqus comments