diff options
Diffstat (limited to 'plugins/scd')
| -rw-r--r-- | plugins/scd/README.md | 122 | ||||
| -rwxr-xr-x | plugins/scd/scd | 353 | ||||
| -rw-r--r-- | plugins/scd/scd.plugin.zsh | 19 | 
3 files changed, 494 insertions, 0 deletions
| diff --git a/plugins/scd/README.md b/plugins/scd/README.md new file mode 100644 index 000000000..197cea50a --- /dev/null +++ b/plugins/scd/README.md @@ -0,0 +1,122 @@ +# scd - smart change of directory + +Define `scd` shell function for changing to any directory with +a few keystrokes. + +`scd` keeps history of the visited directories, which serves as an index of +the known paths.  The directory index is updated after every `cd` command in +the shell and can be also filled manually by running `scd -a`.  To switch to +some directory, `scd` needs few fragments of the desired path to match with +the index.  A selection menu is displayed in case of several matches, with a +preference given to recently visited paths.  `scd` can create permanent +directory aliases, which appear as named directories in zsh session. + +## INSTALLATION + +For oh-my-zsh, add `scd` to the `plugins` array in the ~/.zshrc file as in the +[template file](../../templates/zshrc.zsh-template#L45). + +Besides zsh, `scd` can be used with *bash*, *dash* or *tcsh* +shells and is also available as [Vim](http://www.vim.org/) plugin and +[IPython](http://ipython.org/) extension.  For installation details, see +https://github.com/pavoljuhas/smart-change-directory. + +## SYNOPSIS + +```sh +scd [options] [pattern1 pattern2 ...] +``` + +## OPTIONS + +<dl><dt> +-a, --add</dt><dd> +  add specified directories to the directory index.</dd><dt> + +--unindex</dt><dd> +  remove specified directories from the index.</dd><dt> + +-r, --recursive</dt><dd> +  apply options <em>--add</em> or <em>--unindex</em> recursively.</dd><dt> + +--alias=ALIAS</dt><dd> +  create alias for the current or specified directory and save it to +  <em>~/.scdalias.zsh</em>.</dd><dt> + +--unalias</dt><dd> +  remove ALIAS definition for the current or specified directory from +  <em>~/.scdalias.zsh</em>.</dd><dt> + +--list</dt><dd> +  show matching directories and exit.</dd><dt> + +-v, --verbose</dt><dd> +  display directory rank in the selection menu.</dd><dt> + +-h, --help</dt><dd> +  display this options summary and exit.</dd> +</dl> + +## Examples + +```sh +# Index recursively some paths for the very first run +scd -ar ~/Documents/ + +# Change to a directory path matching "doc" +scd doc + +# Change to a path matching all of "a", "b" and "c" +scd a b c + +# Change to a directory path that ends with "ts" +scd "ts(#e)" + +# Show selection menu and ranking of 20 most likely directories +scd -v + +# Alias current directory as "xray" +scd --alias=xray + +# Jump to a previously defined aliased directory +scd xray +``` + +# FILES + +<dl><dt> +~/.scdhistory</dt><dd> +    time-stamped index of visited directories.</dd><dt> + +~/.scdalias.zsh</dt><dd> +    scd-generated definitions of directory aliases.</dd> +</dl> + +# ENVIRONMENT + +<dl><dt> +SCD_HISTFILE</dt><dd> +    path to the scd index file (by default ~/.scdhistory).</dd><dt> + +SCD_HISTSIZE</dt><dd> +    maximum number of entries in the index (5000).  Index is trimmed when it +    exceeds <em>SCD_HISTSIZE</em> by more than 20%.</dd><dt> + +SCD_MENUSIZE</dt><dd> +    maximum number of items for directory selection menu (20).</dd><dt> + +SCD_MEANLIFE</dt><dd> +    mean lifetime in seconds for exponential decay of directory +    likelihood (86400).</dd><dt> + +SCD_THRESHOLD</dt><dd> +    threshold for cumulative directory likelihood.  Directories with +    a lower likelihood compared to the best match are excluded (0.005). +    </dd><dt> + +SCD_SCRIPT</dt><dd> +    command script file where scd writes the final <code>cd</code> +    command.  This variable must be defined when scd runs in its own +    process rather than as a shell function.  It is up to the +    scd caller to use the output in <em>SCD_SCRIPT</em>.</dd> +</dl> diff --git a/plugins/scd/scd b/plugins/scd/scd new file mode 100755 index 000000000..1567d2736 --- /dev/null +++ b/plugins/scd/scd @@ -0,0 +1,353 @@ +#!/bin/zsh -f + +emulate -L zsh +local EXIT=return +if [[ $(whence -w $0) == *:' 'command ]]; then +    emulate -R zsh +    local RUNNING_AS_COMMAND=1 +    EXIT=exit +fi + +local DOC='scd -- smart change to a recently used directory +usage: scd [options] [pattern1 pattern2 ...] +Go to a directory path that contains all fixed string patterns.  Prefer +recently visited directories and directories with patterns in their tail +component.  Display a selection menu in case of multiple matches. + +Options: +  -a, --add         add specified directories to the directory index +  --unindex         remove specified directories from the index +  -r, --recursive   apply options --add or --unindex recursively +  --alias=ALIAS     create alias for the current or specified directory and +                    store it in ~/.scdalias.zsh +  --unalias         remove ALIAS definition for the current or specified +                    directory from ~/.scdalias.zsh +  --list            show matching directories and exit +  -v, --verbose     display directory rank in the selection menu +  -h, --help        display this message and exit +' + +local SCD_HISTFILE=${SCD_HISTFILE:-${HOME}/.scdhistory} +local SCD_HISTSIZE=${SCD_HISTSIZE:-5000} +local SCD_MENUSIZE=${SCD_MENUSIZE:-20} +local SCD_MEANLIFE=${SCD_MEANLIFE:-86400} +local SCD_THRESHOLD=${SCD_THRESHOLD:-0.005} +local SCD_SCRIPT=${RUNNING_AS_COMMAND:+$SCD_SCRIPT} +local SCD_ALIAS=~/.scdalias.zsh + +local ICASE a d m p i tdir maxrank threshold +local opt_help opt_add opt_unindex opt_recursive opt_verbose +local opt_alias opt_unalias opt_list +local -A drank dalias +local dmatching +local last_directory + +setopt extendedhistory extendedglob noautonamedirs brace_ccl + +# If SCD_SCRIPT is defined make sure the file exists and is empty. +# This removes any previous old commands. +[[ -n "$SCD_SCRIPT" ]] && [[ -s $SCD_SCRIPT || ! -f $SCD_SCRIPT ]] && ( +    umask 077 +    : >| $SCD_SCRIPT +) + +# process command line options +zmodload -i zsh/zutil +zmodload -i zsh/datetime +zparseopts -D -- a=opt_add -add=opt_add -unindex=opt_unindex \ +    r=opt_recursive -recursive=opt_recursive \ +    -alias:=opt_alias -unalias=opt_unalias -list=opt_list \ +    v=opt_verbose -verbose=opt_verbose h=opt_help -help=opt_help \ +    || $EXIT $? + +if [[ -n $opt_help ]]; then +    print $DOC +    $EXIT +fi + +# load directory aliases if they exist +[[ -r $SCD_ALIAS ]] && source $SCD_ALIAS + +# works faster than the (:a) modifier and is compatible with zsh 4.2.6 +_scd_Y19oug_abspath() { +    set -A $1 ${(ps:\0:)"$( +        unfunction -m "*"; shift +        for d; do +            cd $d && print -Nr -- $PWD && cd $OLDPWD +        done +        )"} +} + +# define directory alias +if [[ -n $opt_alias ]]; then +    if [[ -n $1 && ! -d $1 ]]; then +        print -u2 "'$1' is not a directory." +        $EXIT 1 +    fi +    a=${opt_alias[-1]#=} +    _scd_Y19oug_abspath d ${1:-$PWD} +    # alias in the current shell, update alias file if successful +    hash -d -- $a=$d && +    ( +        umask 077 +        hash -dr +        [[ -r $SCD_ALIAS ]] && source $SCD_ALIAS +        hash -d -- $a=$d +        hash -dL >| $SCD_ALIAS +    ) +    $EXIT $? +fi + +# undefine directory alias +if [[ -n $opt_unalias ]]; then +    if [[ -n $1 && ! -d $1 ]]; then +        print -u2 "'$1' is not a directory." +        $EXIT 1 +    fi +    _scd_Y19oug_abspath a ${1:-$PWD} +    a=$(print -rD ${a}) +    if [[ $a != [~][^/]## ]]; then +        $EXIT +    fi +    a=${a#[~]} +    # unalias in the current shell, update alias file if successful +    if unhash -d -- $a 2>/dev/null && [[ -r $SCD_ALIAS ]]; then +        ( +            umask 077 +            hash -dr +            source $SCD_ALIAS +            unhash -d -- $a 2>/dev/null && +            hash -dL >| $SCD_ALIAS +        ) +    fi +    $EXIT $? +fi + +# Rewrite directory index if it is at least 20% oversized +if [[ -s $SCD_HISTFILE ]] && \ +(( $(wc -l <$SCD_HISTFILE) > 1.2 * $SCD_HISTSIZE )); then +    m=( ${(f)"$(<$SCD_HISTFILE)"} ) +    print -lr -- ${m[-$SCD_HISTSIZE,-1]} >| ${SCD_HISTFILE} +fi + +# Determine the last recorded directory +if [[ -s ${SCD_HISTFILE} ]]; then +    last_directory=${"$(tail -1 ${SCD_HISTFILE})"#*;} +fi + +# Internal functions are prefixed with "_scd_Y19oug_". +# The "record" function adds its arguments to the directory index. +_scd_Y19oug_record() { +    while [[ -n $last_directory && $1 == $last_directory ]]; do +        shift +    done +    if [[ $# -gt 0 ]]; then +        ( umask 077 +          p=": ${EPOCHSECONDS}:0;" +          print -lr -- ${p}${^*} >>| $SCD_HISTFILE ) +    fi +} + +if [[ -n $opt_add ]]; then +    for d; do +        if [[ ! -d $d ]]; then +            print -u2 "Directory '$d' does not exist." +            $EXIT 2 +        fi +    done +    _scd_Y19oug_abspath m ${*:-$PWD} +    _scd_Y19oug_record $m +    if [[ -n $opt_recursive ]]; then +        for d in $m; do +            print -n "scanning ${d} ... " +            _scd_Y19oug_record ${d}/**/*(-/N) +            print "[done]" +        done +    fi +    $EXIT +fi + +# take care of removing entries from the directory index +if [[ -n $opt_unindex ]]; then +    if [[ ! -s $SCD_HISTFILE ]]; then +        $EXIT +    fi +    # expand existing directories in the argument list +    for i in {1..$#}; do +        if [[ -d ${argv[i]} ]]; then +            _scd_Y19oug_abspath d ${argv[i]} +            argv[i]=${d} +        fi +    done +    m="$(awk -v recursive=${opt_recursive} ' +        BEGIN { +            for (i = 2; i < ARGC; ++i) { +                argset[ARGV[i]] = 1; +                delete ARGV[i]; +            } +        } +        1 { +            d = $0;  sub(/^[^;]*;/, "", d); +            if (d in argset)  next; +        } +        recursive { +            for (a in argset) { +                if (substr(d, 1, length(a) + 1) == a"/")  next; +            } +        } +        { print $0 } +        ' $SCD_HISTFILE ${*:-$PWD} )" || $EXIT $? +    : >| ${SCD_HISTFILE} +    [[ ${#m} == 0 ]] || print -r -- $m >> ${SCD_HISTFILE} +    $EXIT +fi + +# The "action" function is called when there is just one target directory. +_scd_Y19oug_action() { +    cd $1 || return $? +    if [[ -z $SCD_SCRIPT && -n $RUNNING_AS_COMMAND ]]; then +        print -u2 "Warning: running as command with SCD_SCRIPT undefined." +    fi +    if [[ -n $SCD_SCRIPT ]]; then +        print -r "cd ${(q)1}" >| $SCD_SCRIPT +    fi +} + +# Match and rank patterns to the index file +# set global arrays dmatching and drank +_scd_Y19oug_match() { +    ## single argument that is an existing directory or directory alias +    if [[ $# == 1 ]] && \ +        [[ -d ${d::=$1} || -d ${d::=${nameddirs[$1]}} ]] && [[ -x $d ]]; +    then +        _scd_Y19oug_abspath dmatching $d +        drank[${dmatching[1]}]=1 +        return +    fi + +    # ignore case unless there is an argument with an uppercase letter +    [[ "$*" == *[[:upper:]]* ]] || ICASE='(#i)' + +    # calculate rank of all directories in the SCD_HISTFILE and keep it as drank +    # include a dummy entry for splitting of an empty string is buggy +    [[ -s $SCD_HISTFILE ]] && drank=( ${(f)"$( +        print -l /dev/null -10 +        <$SCD_HISTFILE \ +        awk -v epochseconds=$EPOCHSECONDS -v meanlife=$SCD_MEANLIFE ' +            BEGIN { FS = "[:;]"; } +            length($0) < 4096 && $2 > 0 { +                tau = 1.0 * ($2 - epochseconds) / meanlife; +                if (tau < -4.61)  tau = -4.61; +                prec = exp(tau); +                sub(/^[^;]*;/, ""); +                if (NF)  ptot[$0] += prec; +            } +            END { for (di in ptot)  { print di; print ptot[di]; } }' +        )"} +    ) +    unset "drank[/dev/null]" + +    # filter drank to the entries that match all arguments +    for a; do +        p=${ICASE}"*${a}*" +        drank=( ${(kv)drank[(I)${~p}]} ) +    done + +    # build a list of matching directories reverse-sorted by their probabilities +    dmatching=( ${(f)"$( +        for d p in ${(kv)drank}; do +            print -r -- "$p $d"; +        done | sort -grk1 | cut -d ' ' -f 2- +        )"} +    ) + +    # if some directory paths match all patterns in order, discard all others +    p=${ICASE}"*${(j:*:)argv}*" +    m=( ${(M)dmatching:#${~p}} ) +    [[ -d ${m[1]} ]] && dmatching=( $m ) +    # if some directory names match last pattern, discard all others +    p=${ICASE}"*${(j:*:)argv}[^/]#" +    m=( ${(M)dmatching:#${~p}} ) +    [[ -d ${m[1]} ]] && dmatching=( $m ) +    # if some directory names match all patterns, discard all others +    m=( $dmatching ) +    for a; do +        p=${ICASE}"*/[^/]#${a}[^/]#" +        m=( ${(M)m:#${~p}} ) +    done +    [[ -d ${m[1]} ]] && dmatching=( $m ) +    # if some directory names match all patterns in order, discard all others +    p=${ICASE}"/*${(j:[^/]#:)argv}[^/]#" +    m=( ${(M)dmatching:#${~p}} ) +    [[ -d ${m[1]} ]] && dmatching=( $m ) + +    # do not match $HOME or $PWD when run without arguments +    if [[ $# == 0 ]]; then +        dmatching=( ${dmatching:#(${HOME}|${PWD})} ) +    fi + +    # keep at most SCD_MENUSIZE of matching and valid directories +    m=( ) +    for d in $dmatching; do +        [[ ${#m} == $SCD_MENUSIZE ]] && break +        [[ -d $d && -x $d ]] && m+=$d +    done +    dmatching=( $m ) + +    # find the maximum rank +    maxrank=0.0 +    for d in $dmatching; do +        [[ ${drank[$d]} -lt maxrank ]] || maxrank=${drank[$d]} +    done + +    # discard all directories below the rank threshold +    threshold=$(( maxrank * SCD_THRESHOLD )) +    dmatching=( ${^dmatching}(Ne:'(( ${drank[$REPLY]} >= threshold ))':) ) +} + +_scd_Y19oug_match $* + +## process whatever directories that remained +if [[ ${#dmatching} == 0 ]]; then +    print -u2 "No matching directory." +    $EXIT 1 +fi + +## build formatted directory aliases for selection menu or list display +for d in $dmatching; do +    if [[ -n ${opt_verbose} ]]; then +        dalias[$d]=$(printf "%.3g %s" ${drank[$d]} $d) +    else +        dalias[$d]=$(print -Dr -- $d) +    fi +done + +## process the --list option +if [[ -n $opt_list ]]; then +    for d in $dmatching; do +        print -r -- "# ${dalias[$d]}" +        print -r -- $d +    done +    $EXIT +fi + +## process single directory match +if [[ ${#dmatching} == 1 ]]; then +    _scd_Y19oug_action $dmatching +    $EXIT $? +fi + +## here we have multiple matches - display selection menu +a=( {a-z} {A-Z} ) +p=( ) +for i in {1..${#dmatching}}; do +    [[ -n ${a[i]} ]] || break +    p+="${a[i]}) ${dalias[${dmatching[i]}]}" +done + +print -c -r -- $p + +if read -s -k 1 d && [[ ${i::=${a[(I)$d]}} -gt 0 ]]; then +    _scd_Y19oug_action ${dmatching[i]} +    $EXIT $? +fi diff --git a/plugins/scd/scd.plugin.zsh b/plugins/scd/scd.plugin.zsh new file mode 100644 index 000000000..0197c53a1 --- /dev/null +++ b/plugins/scd/scd.plugin.zsh @@ -0,0 +1,19 @@ +## The scd script should autoload as a shell function. +autoload scd + + +## If the scd function exists, define a change-directory-hook function +## to record visited directories in the scd index. +if [[ ${+functions[scd]} == 1 ]]; then +    scd_chpwd_hook() { scd --add $PWD } +    autoload add-zsh-hook +    add-zsh-hook chpwd scd_chpwd_hook +fi + + +## Allow scd usage with unquoted wildcard characters such as "*" or "?". +alias scd='noglob scd' + + +## Load the directory aliases created by scd if any. +if [[ -s ~/.scdalias.zsh ]]; then source ~/.scdalias.zsh; fi | 
