9

I'm looking for an elegant one-liner (eg, awk) that will shorten a string of a Unix path using the first character of each parent/intermediate level, but the full basename. Easier to show by examples:

  • /path/to/file/p/t/file
  • /tmp/tmp
  • /foo/bar/.config/wizard_magic/f/b/./wizard_magic
  • /foo/bar/.config/wizard_magic/f/b/.c/wizard_magic
    In light of good points by @MichaelKjörling and @ChrisH below, this example shows how we might show the first two characters when the first character is a dot.
  • A suggestion (I don't know your use case): abbreviate instead to /f/b/.c/wizard_magic. The dot is often so common in a particular directory as to be a very small clue to where you should be looking. – Chris H Dec 3 '15 at 9:13
  • Besides what @ChrisH said, . normally just means "current directory". So /f/b/./wizard_magic is the same as /f/b/wizard_magic because the path element ./ compresses to an empty path element. – a CVn Dec 3 '15 at 15:46
  • Why do you need that? Can't you use some clever autocompletion in your interactive shell (perhaps changing your shell to something adequate) – Basile Starynkevitch Dec 15 '15 at 8:16
7

For this test file:

$ cat path
/path/to/file
/tmp
/foo/bar/.config/wizard_magic

The abbreviations can be generated with this awk code:

$ awk -F/ '{for (i=1;i<NF;i++) $i=substr($i,1,1)} 1' OFS=/ path
/p/t/file
/tmp
/f/b/./wizard_magic

Edit1: Using two characters for dot-names

This version abbreviates directory names to one character except for names that start with . which are abbreviated to two characters:

$ awk -F/ '{for (i=1;i<NF;i++) $i=substr($i,1,1+($i~/^[.]/))} 1' OFS=/ path
/p/t/file
/tmp
/f/b/.c/wizard_magic

How it works

  • -F/

    This tells awk to use a slash as the field separator on input.

  • for (i=1;i<NF;i++) $i=substr($i,1,1)

    This loops over each field, except the last, and replaces it with just its first character.

    EDIT1: In the revised version, we make the length of the substring 2 when the field starts with ..

  • 1

    This tells awk to print the revised line.

  • OFS=/

    This tells awk to use a slash as the field separator on output.

  • Excellent answer, minor modification to use separator: awk -F/ '{for (i=1;i<NF;i++) $i=substr($i,1,1+($i~/^[.]/))(i==1||length($i)<2?"":"‥")} 1' OFS=/ <<<$PWD gives: /foo/bar/.config/wizard_magic/f‥/b‥/.c‥/wizard_magic – ideasman42 Oct 23 '17 at 5:49
12

Pretty easy in sed (assuming there are no newlines in file names):

sed 's!\([^/]\)[^/]*/!\1/!g'

Less easy in awk because it lacks backreferences (except in Gawk, but with a clumsy syntax):

awk -v FS=/ -v OFS=/ '{for (i=1; i<NF; i++) $i=substr($i,1,1)} 1'

In zsh (with the path in $full_path):

echo "${(j:/:)${(@r:1:)${(@s:/:)${full_path:h}}}}/${full_path:t}"
  • 2
    IIRC, "backreferences" are references to capture groups that occur in the pattern, not in the replacement string. – Rhymoid Dec 3 '15 at 16:30
  • @Rhymoid \1 in the replacement string does mean a reference to a capture group in the pattern. A backreference is a backreference no matter where you use it. – Gilles Dec 3 '15 at 17:59
8

you can do it like:

cd /usr///.//share/../share//man/man1 || exit
IFS=/; set -f
printf %.1s/  ${PWD%/*}
printf %s\\n "${PWD##*/}"

/u/s/m/man1

and here's a sed:

printf %s "$file" |
tr /\\n \\n/      | sed -et$ \
    -e '\|^\.\.$|{x;s|\(.*\)\n.*$|\1|;x;}'  \
    -e 's|^\.\{0,2\}$||;\|.|H;$!d;x'        \
-e$ -e '\|\(\.\{0,2\}.\)\(.*\)\(\n\)|!b'    \
    -e 's||\1\3\2\3|;P;s|\n||;D' |
tr /\\n \\n/

that comes pretty close to doing all of the same stuff the function does below. it doesn't abbreviate with tildes or insert the $PWD to the head for a leading non-slash as the function does (and in fact, never prints the leading slash) but that could be handled afterward. it does process out null path components, and single-dots, and weeds out .. cases.

given the same man path as cd above it prints:

u/s/m/man1

it will also print one or two extra leading dots for each path component that begins with such and is not just one or two dots.

you asked about doing more than the one character for a path-component beginning with a .. to do it i figured each component would need individual attention anyway, and because i was curious, i tried my hand at working out a canonical path without the change directory. after some trial-and-error i eventually decided the only way to do it right was to do it twice - backwards and forwards:

pathbytes(){
    local IFS=/   o="$-" p
    set -f${ZSH_VERSION+LFy}
    set -- ${1:-$PWD}
    for p   in      /${1:+$PWD} $*
    do      case    $p in   (.|"")  ;;
            (..)    ${1+shift}      ;;
            (/)     set --          ;;
            (*)     set -- $p $*;   esac
    done
    for p   in      //$* ""
    do      case   ${p:-/$3}        in
            ([!./]*)                ;;
            (..*)   set "..$@"      ;;
            (.*)    set ".$@"       ;;
            (//*) ! set "" $1 $1    ;;
            (~)   ! p=\~            ;;
            (~/*)   p="~/$2";set $HOME
                  ! while "${2+shift}" 2>&3
                    do   p="~/${p#??*/}"
                    done 3>/dev/null;;
            esac&&  set ""  "${p%"${p#$1?}"}/$2" "$p/$3"
    done;   printf %s\\n "${p:-$2}"
    set +f  "-${o:--}"
}

so that never changes the directory or tries to confirm the existence of any path component, but it squeezes repeated / delimiters and drops /./ single-dot components entirely, and processes /../ double-dot components appropriately.

when $IFS is set to some non-whitespace character, a sequence of two or more $IFS characters will result in one or more null fields. so multiple consecutive slashes work out to null-valued arguments. the same is true for a leading $IFS character. and so when set -- $1 splits, if the resulting $1 is null then it began with a slash, else, ${1:+$PWD} if it is not-null, then i insert $PWD. in other words, if the first argument doesn't start with a slash, it will get $PWD prepended. that's as close as this comes to path validation.

otherwise, the first for loop recursively inverts the order of the path components, like:

      1 2 3
1     2 3
2 1   3
3 2 1

...while doing so it ignores any single-dot or null components, and for .. it does...

      1 .. 3
1     .. 3
      3
3

...the second pass reverses this effect, and while doing so it squeezes each component to either 2-dots+char, or 1-dot+char, or char.

so it should work out to a canonical path regardless of existence.

i added/subtracted a little to the second loop. it now sets less often (only once for each [!./]* component), and short-circuits case pattern evaluations most of the time (thanks to the aforementioned pattern), and includes a tail-call match-evaluation against ~. if all or a leading portion (as divided on whole components) of the finally canonical path can match ~, the matching bit will be stripped off and a literal ~ will be substituted. in order to do this, i had to maintain a full copy of the path alongside the abbreviated as well (because matching the abbreviated path to ~ probably wouldn't be very helpful), and so this is kept in $3. the last while loop branch is only run if ~ is matched as a subset of $3.

if you run it with set -xtrace enabled you can watch it work.

$ (set -x;pathbytes ..abc/def/123///././//.././../.xzy/mno)
+ pathbytes ..abc/def/123///././//.././../.xzy/mno
+ local IFS=/ o=xsmi p
+ set -f
+ set -- ..abc def 123   . .   .. . .. .xzy mno
+ set --
+ set -- home
+ set -- mikeserv home
+ set -- ..abc mikeserv home
+ set -- def ..abc mikeserv home
+ set -- 123 def ..abc mikeserv home
+ shift
+ shift
+ set -- .xzy ..abc mikeserv home
+ set -- mno .xzy ..abc mikeserv home
+ set  mno mno
+ set . mno mno
+ set  .x/mno .xzy/mno
+ set .. .x/mno .xzy/mno
+ set  ..a/.x/mno ..abc/.xzy/mno
+ set  m/..a/.x/mno mikeserv/..abc/.xzy/mno
+ set  h/m/..a/.x/mno home/mikeserv/..abc/.xzy/mno
+ p=~/h/m/..a/.x/mno
+ set  home mikeserv
+ shift
+ p=~/m/..a/.x/mno
+ shift
+ p=~/..a/.x/mno
+
+ printf %s\n ~/..a/.x/mno
~/..a/.x/mno
+ set +f -xsmi
  • 4
    Cool, but my eyes hurt. – glenn jackman Dec 3 '15 at 1:41
  • 1
    @don_crissti - yes! – mikeserv Dec 3 '15 at 3:29
2

The "fishy" Zsh theme from Oh My Zsh contains a Perl snippet to do just that that has Unicode support:

perl -pe '
   BEGIN {
      binmode STDIN,  ":encoding(UTF-8)";
      binmode STDOUT, ":encoding(UTF-8)";
   }; s|^$HOME|~|g; s|/([^/.])[^/]*(?=/)|/$1|g; s|/\.([^/])[^/]*(?=/)|/.$1|g;
'
1

Do you want to have s short name or use it for your commandline?
For the commandline I have the following suggestions:
Doesn't file completion in your shell help you ?
Sometimes you are lucky and do not have to do something special:

# /path/to/file -> /p/t/file
ls -l /*/*/file 

# /tmp -> /tmp
cd /tmp

# /foo/bar/.config/wizard_magic -> /f/b/./wizard_magic
ls -l /*/*/*/wizard_magic -> /f/b/./wizard_magic

When you just have some directories you are interested in, you can use aliases:

alias cdto="cd /path/to"
alias cdtmp="cd /tmp"
alias cdcfg="cd /foo/bar/.config"
alias cddeep="cd /home/john/workdir/project1/version3/maven/x/y/z/and/more"

Or you can set up variables for your favorite dirs

export p="/path/to"
export f="/foo/bar/.config"
ls -l $p/file
ls -l $f/wizard_magic

I think these options make more sense than trying to solve this with a function defined in .bashrc (or .profile) like

function x { 
   xxpath=""
   while [ $# -ne 0 ]; do
     xxpath+="${1}*/"
     shift
   done
   cd $(echo "${xxpath}")
}

and calling this function x with spaces between your letters:

 # cd /path/to
 x /p t

 # cd /tmp 
 x /t

 # cd /foo/bar/.config
 x /f b 

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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