# shellcheck disable=SC2155,SC2181,SC2016 shell=bash
# vim: ft=sh ts=4 sw=0 sts=-1 et

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#                              A P P A R I X
#
#        bookmarks for the command line with comprehensive tab completion
#                             works for bash and zsh
#                                                            Authors:
#                                                            Stijn van Dongen
#                                                            Sitaram Chamarty
#                                                            Izaak van Dongen
#                               Quick Guide:
#
#  -  save this file in $HOME/.bourne-apparix
#  -  issue 'source $HOME/.bourne-apparix
#  -  go to a directory and issue 'bm foo'
#  -  you can now go to that directory by issuing 'to foo'
#  -  you can go straight to a subdirectory       'to foo asubdirname'
#  -  you can use tab completion:                 'to foo as<TAB>'
#                                                 'to foo asubdirname/<TAB>'
#  -  try tab completion and command substitution, see the examples below.
#
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#
#  This Apparix is a pure shell implementation of an older system written
#  partly in C. This shell re-implementation is the reason why several of the
#  names here use apparish.  I prefer to think of the abstract system itself as
#  apparix. Never mind!
#
#    An overview of apparix functionality.

function ahoy() {
    cat <<EOH
Apparix functions, grouped and roughly ordered by expected use.
             Below all SUBDIR and FILE can be tab-completed.

  bm   MARK               Bookmark current directory as mark
  to   MARK [SUBDIR]      Jump to mark or a subdirectory of mark
----------------------------------------------
  als  MARK [SUBDIR] [ls-options]  List mark dir or subdir
  ald  MARK [SUBDIR]      List subdirs of mark dir or subdir
                          ignores hidden directories
  aldr MARK [SUBDIR]      Like ald, recursively
  amd  MARK [SUBDIR] [mkdir options] Make dir in mark
  a    MARK [SUBDIR/]FILE Echo the true location of file, useful
                 e.g. in: cp file \$(a mark dir)
---------------------------------
  aget MARK [SUBDIR/]FILE       Copy file to current directory
  aput MARK [SUBDIR] -- FiLE+   Copy files to mark (-- required)
  acat MARK [SUBDIR/]FILE       Cat file
-----------------------------
  ae MARK [SUBDIR/]FILE [editor options] Edit file in mark
  av MARK [SUBDIR/]FILE [editor options] View file in mark
------------------------
  amibm                   See if current directory is a bookmark
  bmgrep PATTERN          List all marks where target matches PATTERN
--------------------
  agather MARK            List all targets for bookmark mark
  whence MARK             Menu selection for mark with multiple targets
----------------
  todo MARK [SUBDIR]      Edit TODO file in mark dir
  rme MARK [SUBDIR]       Edit README file
  portal                  current directory subdirs become mark names
  portal-expand           Re-expand all portals
  aghast MARK [SUBDIR/]FILE [dummy options] testing the apparix muxer
-------
  Where options passing is indicated above:
   - The sequence has to start with a '-' or '+' character.
   - Multiple options with arguments can be passed.
   - -- occurrences are removed but will start a sequence.
   - FWIW Arguments with spaces in them seemed to work under limited
     testing, e.g. ae pl main.nf '+set paste'
EOH
}

#                            Use notes
#
#  - Use apparix with cyclic tab completion. It's what gives the oomph.
#  - Apparix uses by default the most recent MARK if identical marks exist.
#  - I rarely delete bookmarks. They serve as a chronology of my travails.
#  - For deleting bookmarks I find 'via' the quickest.
#  - Bookmark obscure and rarely visited but important locations.
#  - You can forget both mark and location; 'via' and 'bmgrep' will help.
#  - Use 'via' and 'bmgrep'
#  - It can e.g. be useful to use 'now' for the current hotspot of work.
#  - The list of 'now' bookmarks (seen with 'agather now') is a log of activity.
#  - The portal functionality mimics bash CDPATH. I don't use it.
#  - I find it useful to have this alias:
#        alias a=apparish
#  for use in command substitution, e.g.
#        cp myfile $(a bm)
#
#  This is a big decision from a Huffman point of view.  If you want to remove
#  it, go to all the places in the lines below where the name Huffman is
#  mentioned and remove the relevant part.

#                       BASH and ZSH functions
#
#  Apparix should work for modern bourne-style shells, not including the
#  bourne shell.  Name this file for example .bourne-apparix in your $HOME
#  directory, and put the line 'source $HOME/.bourne-apparix' (without quotes)
#  in the file $HOME/.bashrc or $HOME/.bash_login if you use bash, in the file
#  $HOME/.zshrc if you use zsh.

#  This code goes a long way to dealing with spaces, tabs and nasty characters
#  in paths thanks to Izaak.  A much simpler parallel completion implementation
#  using bash native completion is triggered if you set APPARIX_BERTRAND_RUSSEL
#  to a nonzero value before sourcing this file, but it will not cope as well
#  with weird characters.

 #
#  Big thanks to Sitaram Chamarty for all the important parts of the initial
#  bash completion code, and big thanks to Izaak van Dongen for figuring out
#  the zsh completion code, subsequently improving and standardising the bash
#  completion code, adding enhancements and rewrites all through the code base
#  and suggesting the name apparish. Although it's now called apparix again.
#  Still appreciate it.
 #

APPARIXHOME="${APPARIXHOME:=$HOME}"
# ensure APPARIXHOME exists
mkdir -p "$APPARIXHOME"
APPARIXRC="${APPARIXRC:=$APPARIXHOME/.apparixrc}"
APPARIXEXPAND="${APPARIXEXPAND:=$APPARIXHOME/.apparixexpand}"
APPARIXLOG="${APPARIXLOG:=$APPARIXHOME/.apparixlog}"

# ensure set up and log files exist
touch "$APPARIXRC"
touch "$APPARIXEXPAND"
touch "$APPARIXLOG"

APPARIX_FILE_FUNCTIONS=( a ae av acat aget toot apparish aghast ) # Huffman (remove a)
APPARIX_DIR_FUNCTIONS=( to als aput ald aldr amd todo rme )

# require one or two
function apx_onetwo() {
  (( $1 == 1 )) || (( $1 == 2 ))
}

# print usage
function apx_usage() {
  echo -n $(ahoy | grep "^  *$1\>")
  echo " (use ahoy for more)"
}

# sanitise $1 so that it becomes suitable for use with your basic grep
function apx_grepsanitise() {
    sed 's/[].*^$]\|\[/\\&/g' <<< "$1"
}

# vim-like: totally silence the given command. does not
# affect return status, so can be used inside if statements.
function apx_silent() {
    "$@" > /dev/null 2> /dev/null
}

# Usage:
#     amux mark [dir] [opt/or/arg+] ++ command [oPt/oR/aRg+]
# This will be translated to
#     command [opt/or/arg+] [oPt/oR/aRg+] TARGET
# where
#     [opt/or/arg+]
# comes from the user commandline invocation
#     command [oRt/oR/aRg+]
# comes from the amux-wrapping function The optional sequence of opt-or-arg is
# recognised/consumed as follows
# - anything starting with - or + and all subsequent arguments up until ++
# - among these, -- is a sentinel and will be discarded.
# This can be used to start FILE+ arguments such as aput does.
# Note that -- is part of the aput usage itself.

function amux() {
   local optorarg=(); local mark=$1; local dir=
   local copy=( "$@" )
   shift
   local get_options=false
   local caller=${FUNCNAME[1]}

   if [[ $mark == '++' ]]; then
     apx_usage "$caller"
     echo "Arguably a lack of argument"
     return 1
   fi 

   while [[ $# -gt 0 ]]; do
      item="$1"; shift
      if [[ $item == '++' ]]; then
        break
      elif $get_options || [[ $item == [+-]* ]]; then
        if [[ $item != '--' ]]; then
          optorarg+=( "$item" )
        fi
        get_options=true
      elif [[ -z "$dir" ]]; then
        dir="$item"
      else
        2>&1 echo "Mixed up mess in amux (caller ${copy[@]})"
        break
      fi
   done

   command=$1
   shift
   [[ -n "$command" ]] && ! apx_silent command -v "$command" && 2>&1 echo "Not a command, $command"

   if ! loc=$(apparish "$mark" "$dir"); then
      return 1
   fi
   $command "${optorarg[@]}" "$@" "$loc"
}


# apparix list
function als() {
    amux "$@" ++ ls --
}

# apparix mkdir in mark
function amd() {
    amux "$@" ++ mkdir --
}

# apparix view of file
function av() {
    amux "$@" ++ view --
}

# apparix cat of file
function acat() {
    amux "$@" ++ cat --
}

# apparix edit of file
function ae() {
    amux "$@" ++ "${EDITOR:-vim}" --
}

# cd to a mark
function to() {
    amux "$@" ++ cd --
}

# intermediate function to swap argument order
function apparix_aget_cp() {
    cp -vi "$@" .
}

# apparix get; copy something from mark
function aget() {
    amux "$@" ++ apparix_aget_cp
}

# apparix put; copy something to mark
function aput() {
    if ! apx_elemOf '--' "${@:2:2}"; then
      apx_usage aput
      return 1
    fi
    amux "$@" ++ cp -vi
}

function aghast_test() {
   for i in "$@"; do echo "[$i]"; done
}

function aghast() {
    amux "$@" ++ aghast_test -- test1 test2
}

# apparix listing of directories (rather than files) directly below mark.
# With find argument/option order requirements not yet really amux material,
# unless placeholder parachuting is added. 🤔 The outcome of apparix
# *is* always just a simple string.
# These versions print paths relative to target and ignore hidden files.
# Future version perhaps pass in + parse flags to control
# (1) prefix behaviour (-P for print prefix)
# (2) hidden behaviour (-H for hidden)
# Then also extract -maxdepth 1.
function ald() {
    local loc
    if ! apx_onetwo $#; then apx_usage ald
    else
      loc="$(apparish "$@")" && \
find -L "$loc" -mindepth 1 -maxdepth 1 -type d -a \( -name ".*" -prune -o -printf '%P\n' \)
    fi
}
# apparix listing of subdirectories of mark, recursively
function aldr() {
    local loc
    if ! apx_onetwo $#; then apx_usage aldr
    else
      loc="$(apparish "$@")" && \
find -L "$loc" -mindepth 1 -type d -a \( -name ".*" -prune -o -printf '%P\n' \)
    fi
}


# Huffman (remove this paragraph, or just alias "a" yourself)
if ! apx_silent command -v a; then
    alias a='apparish'
else
    >&2 echo "Apparix: not aliasing a"
fi

if ! apx_silent command -v via; then
    alias via='"${EDITOR:-vim}" "$APPARIXRC"'
else
    >&2 echo "Apparix: not aliasing via"
fi

if ! apx_silent bind -q menu-complete; then
    cat <<EOH
--> Consider adding the line
bind '"\t":menu-complete'
<-- to e.g. $HOME/.bashrc
This enables cyclic tab completion on directories and files below apparix marks.
We apologise profusely for this interruption.
EOH
fi

function apparish() {
    if [[ 0 == "$#" ]]; then
        cat -- "$APPARIXRC" "$APPARIXEXPAND" | tr ', ' '\t_' | column -t
        return
    fi
    local mark="$1"
    local list="$(command grep -F ",$mark," "$APPARIXRC" "$APPARIXEXPAND")"
    if [[ -z "$list" ]]; then
        >&2 echo "Mark '$mark' not found"
        return 1
    fi
    local target="$( (tail -n 1 | cut -f 3 -d ',') <<< "$list")"
    if [[ 2 == "$#" ]]; then
        echo "$target/$2"
    else
        echo "$target"
    fi
}

function agather() {
    if [[ 0 == "$#" ]]; then
        apx_usage agather
        return 1
    fi
    local mark="$1"
    command grep -F ",$mark," -- "$APPARIXRC" "$APPARIXEXPAND" | cut -f 3 -d ','
}

function bm() {
    if [[ 0 == "$#" ]]; then
        apx_usage bm
        return 1
    fi
    local mark="$1"
    local list="$(agather "$mark")"
    echo "j,$mark,$PWD" | tee -a -- "$APPARIXLOG" >> "$APPARIXRC"
    if [[ -n "$list" ]]; then
        listsize=$((1 + $(wc -l <<< "$list") ))
        echo "$PWD added, $listsize total"
    fi
}

function portal() {
    echo "e,$PWD" >> "$APPARIXRC"
    portal-expand
}

function portal-expand() {
    local parentdir
    rm -f -- "$APPARIXEXPAND"
    true > "$APPARIXEXPAND"
    command grep '^e,' -- "$APPARIXRC" | cut -f 2 -d , | \
        while read -r parentdir; do
            # run in an explicit bash subshell to be able to locally set the
            # right options
            parentdir="$parentdir" APPARIXEXPAND="$APPARIXEXPAND" bash <<EOF
            cd -- "\$parentdir" || return 1
            shopt -s nullglob
            shopt -u dotglob
            shopt -u failglob
            GLOBIGNORE="./:../"
            for _subdir in */ .*/; do
                subdir="\${_subdir%/}"
                echo "j,\$subdir,\$parentdir/\$subdir" >> "\$APPARIXEXPAND"
            done
EOF
        done
}

function whence() {
    if [[ 0 == "$#" ]]; then
        apx_usage whence
        return 1
    fi
    local mark="$1"
    select target in $(agather "$mark"); do
        cd -- "$target" || return 1
        break
    done
}

# This may need some love. But I mainly use the todo function.
function toot() {
    local file
    if [[ 3 == "$#" ]]; then
        file="$(apparish "$1" "$2")/$3"
    elif [[ 2 == "$#" ]]; then
        file="$(apparish "$1")/$2"
    else
        >&2 echo "toot tag dir file OR toot tag file"
        return 1
    fi
    if [[ "$?" == 0 ]]; then
        "${EDITOR:-vim}" "$file"
    fi
}

function todo() {
    toot "$@" TODO
}

function rme() {
    toot "$@" README
}

function apx_amibm () {
    command grep -- "^j,.*,$(apx_grepsanitise "$PWD")$" "$APPARIXRC" "$APPARIXEXPAND" | cut -f 2 -d ','
}

function apx2_amibm () {
    for tag in $(apx_amibm); do
        local path=$(apparish $tag)
        local annot=""
        if [[ "$path" != "$PWD" ]]; then
            annot='-'
        elif (( $(agather "$tag" | wc -l) > 1 )); then
            annot='+'
        fi
        echo "$tag$annot"
    done
}

# apparix search bookmark
function amibm() {
  echo $(apx2_amibm)
}


# apparix search bookmark
function bmgrep() {
    pat="${1?Need a pattern to search}"
    command grep -- "$pat" "$APPARIXRC" | cut -f 2,3 -d ',' | tr ',' '\t' | column -t
}

if [[ -n "$BASH_VERSION" ]]; then
    # bash specific helper functions

    # assert that bash version is at least $1.$2.$3
    version_assert() {
        for i in {1..3}; do
            if ((BASH_VERSINFO[$((i - 1))] > ${!i})); then
                return 0
            elif ((BASH_VERSINFO[$((i - 1))] < ${!i})); then
                # echo "Your bash is older than $1.$2.$3" >&2
                return 1
            fi
        done
        return 0
    }

    # define a function to read lines from a file into an array
    # https://github.com/koalaman/shellcheck/wiki/SC2207
    if apx_silent version_assert 4 0 0; then
        function read_array() {
            mapfile -t goedel_array < "$1"
        }
    elif apx_silent version_assert 3 0 0; then
        function read_array() {
            goedel_array=()
            while IFS='' read -r line; do
                goedel_array+=("$line");
            done < "$1"
        }
    else
        >&2 echo "really, bash 2 is too cool to run apparix"
        function read_array() {
            local IFS=$'\n'
            # this is a bad fallback implementation on purpose
            # shellcheck disable=SC2207
            goedel_array=( $(cat -- "$1") )
        }
    fi

    # https://stackoverflow.com/questions/3685970/check-if-a-bash-array-...
    # contains-a-value
    function apx_elemOf() {
        local e match="$1"
        shift
        for e; do [[ "$e" == "$match" ]] && return 0; done
        return 1
    }

    # a file, used by _apparix_comp
    function _apparix_comp_file() {
        local caller="$1"
        local cur_file="$2"

        if apx_elemOf "$caller" "${APPARIX_DIR_FUNCTIONS[@]}"; then
            if [[ -n "$APPARIX_BERTRAND_RUSSEL" ]]; then
                # # Directories (add -S / for slash separator):
                COMPREPLY=( $(compgen -d -- "$cur_file") )
            else
                goedel_compfile "$cur_file" d
            fi
        elif apx_elemOf "$caller" "${APPARIX_FILE_FUNCTIONS[@]}"; then
            # complete on filenames. this is a little harder to do nicely.
            if [[ -n "$APPARIX_BERTRAND_RUSSEL" ]]; then
                COMPREPLY=( $(compgen -f -- "$cur_file") )
            else
                goedel_compfile "$cur_file" f
            fi
        else
            >&2 echo "Unknown caller: Izaak has probably messed something up"
            return 1
        fi
    }

    # the existence of this function is a counterexample to Gödel's little known
    # incompletion theorem: there's no such thing as good completion on files in
    # Bash
    function goedel_compfile() {
        local part_esc="$1"
        case "$2" in
            f)
                local find_files=true;;
            d) ;;
            *) >&2 echo "Specify file type"; return 1;;
        esac
        local part_unesc="$(bash -c "printf '%s' $part_esc")"
        local part_dir="$(dirname "$part_unesc")"
        COMPREPLY=()
        # Cannot pipe to while as that's a subshell and we modify COMREPLY.
# echo "[$part_dir]"
# echo "[$part_unesc]"
# echo "[${FUNCNAME[@]}]"
        while IFS='' read -r -d '' result; do
            # This is a bit of a weird hack because printf "%q\n" with no
            # arguments prints ''. It should be robust, because any actual
            # single quotes will have been escaped by printf.
            if [[ "$result" != "''" ]]; then
                COMPREPLY+=("${result%/}")
            fi
        # Use an explicit bash subshell to set some glob flags.
        done < <(part_dir="$part_dir" part_unesc="$part_unesc" \
                 find_files="$find_files" bash -c '
            shopt -s nullglob
            shopt -s extglob
            shopt -u dotglob
            shopt -u failglob
            GLOBIGNORE="./:../"
            if [[ "\$part_dir" == "." ]]; then
                find_name_prefix="./"               # what is this.
            fi
            # here we delay the %q escaping because I want to strip trailing /s
            if [ -d "$part_unesc" ]; then
                if [[ ! "$part_unesc" =~ '"'"'^/+$'"'"' ]]; then
                    part_unesc="${part_unesc%%+(/)}"
                fi
                if [ "$find_files" = "true" ]; then
                    printf "%q\0" "$part_unesc"/* "$part_unesc"/*/
                else
                    printf "%q\0" "$part_unesc"/*/
                fi
            else
                if [ "$find_files" = "true" ]; then
                    printf "%q\0" "$part_unesc"*/ "$part_unesc"*
                else
                    printf "%q\0" "$part_unesc"*/
                fi
            fi'
        )
    }

    # generate completions for a bookmark
    # this is currently case sensitive. Good? Bad? Who knows!
    function _apparix_compgen_bm() {
        cut -f2 -d, -- "$APPARIXRC" "$APPARIXEXPAND" | sort |\
            command grep -i -- "^$(apx_grepsanitise "$1")"
        if [[ -n "$1" ]]; then
            cut -f2 -d, -- "$APPARIXRC" "$APPARIXEXPAND" | sort |\
                command grep -i -- "^..*$(apx_grepsanitise "$1")"
        fi
    }

    # complete an apparix tag followed by a file inside that tag's
    # directory
    function _apparix_comp() {
        local tag="${COMP_WORDS[1]}"
        COMPREPLY=()
        if [[ "$COMP_CWORD" == 1 ]]; then
            read_array <(_apparix_compgen_bm "$tag" | \
                xargs -d $'\n' printf "%q\n")
            COMPREPLY=( "${goedel_array[@]}" )
        else
            local cur_file="${COMP_WORDS[2]}"
            local app_dir="$(apparish "$tag" 2>/dev/null)"
            if [[ -d "$app_dir" ]]; then
                  # can't run in subshell as _apparix_comp_file modifies
                  # COMREPLY.  Just hope that nothing goes wrong, basically
                apx_silent pushd -- "$app_dir"
                  # below, just using "$cur_file", bash -c blows up down the
                  # line in the case that user types a quote (that is present
                  # in directory name).  However to MARK <TAB> and to MARK
                  # xyz<TAB> still work on subdirectories containing a quote as
                  # longs as the user does not meddle with the quote(s)
                  # themself.
                _apparix_comp_file "$1" $(printf %q "$cur_file")
                apx_silent popd
            else
                COMPREPLY=()
            fi
        fi
        return 0
    }

    # Register completions
    #   'nospace' prevents bash putting a space after partially completed paths
    #   'nosort' prevents bash from messing up the bespoke order in which bookmarks
    #   are completed
    apparix_o_nosort="-o nosort"
    if ! version_assert 4 4 0; then
        # >&2 echo "(Apparix: Can't disable alphabetic sorting of completions)"
        apparix_o_nosort=""
    fi
    complete -o nospace $apparix_o_nosort -F _apparix_comp \
            "${APPARIX_FILE_FUNCTIONS[@]}" "${APPARIX_DIR_FUNCTIONS[@]}"
    unset apparix_o_nosort

elif [[ -n "$ZSH_VERSION" ]]; then
    # Use zsh's completion system, as this seems a lot more robust, rather
    # than using bashcompinit to reuse the bash code but really this wasn't
    # a hassle to write
    autoload -Uz compinit
    compinit

    function _apparix_file() {
        IFS=$'\n'
        _arguments \
            '1:mark:($(cut -d, -f2 "$APPARIXRC" "$APPARIXEXPAND"))' \
            '2:file:_path_files -W "$(apparish "$words[2]" 2>/dev/null)"'
    }

    function _apparix_directory() {
        IFS=$'\n'
        _arguments \
            '1:mark:($(cut -d, -f2 "$APPARIXRC" "$APPARIXEXPAND"))' \
            '2:file:_path_files -/W "$(apparish "$words[2]" 2>/dev/null)"'
    }

    compdef _apparix_file "${APPARIX_FILE_FUNCTIONS[@]}"
    compdef _apparix_directory "${APPARIX_DIR_FUNCTIONS[@]}"
else
    >&2 echo "Aparix: I do not know how to generate completions"
fi

# shellcheck: Ignore errors about
# - testing $?, because that's useful when you have branches
# - declaring and assigning at the same time just because
# - unexpanded substitutions in single quotes for similar reasons