1

Context

I have an arduino-cli wrapper script named ino that reads target/build configuration from:

  1. JSON files located in the sketch directory
  2. Command-line flags/arguments

It then constructs and exec's the corresponding arduino-cli command-line.

Problem

As a convenience wrapper script, ino isn't intended to support every feature of arduino-cli. So for those tasks that ino doesn't automate, the user can instead invoke arduino-cli indirectly using the cli subcommand of ino.

For example, if the user types the following commands:

% ino cli update
% ino cli core list --all

The ino script will take everything following cli and simply append them to the arduino-cli executable. So they would be equivalent to the following commands:

% arduino-cli update
% arduino-cli core list --all

Since arduino-cli has nice bash completion for all of its subcommands and flags, I would like to hijack the same completion functionality for my ino cli subcommand.

What I've tried

  1. The accepted answers here:

  2. Completion handler derived from accepted answer here:

    • How do I get bash completion for command aliases?
      • See my ino completion handler based on that answer below (Reference 1).
      • This question/answer isn't quite the same, because they can basically just install a completion handler on their alias. I'm needing to "install" one on an argument to a command/alias.
      • This almost seems to work. Try it with xtrace option enabled (set -x), and you can see the arduino-cli command-line is appearing in the args ... but following ino at position $0.
        • E.g., given ino cli core list --all to the wrapper handler, the arduino-cli handler receives ino arduino-cli core list --all. Not sure how to get rid of $0!

Reference

  • ino completion wrapper derived from alias-based wrappers

joinstr() {
    local d=${1-} f=${2-}
    shift 2 && printf %s "$f" "${@/#/$d}"
}

complete-subcmd() {
    [[ ${#} -gt 2 ]] || {
        printf "usage:\n\tcomplete-subcmd src-command... -- comp-func dst-command...\n"
        return 1
    }
    # parse the command-line by splitting it into two command-lines
    # of variable length, src-command and dst-command:
    #   1. src-command is the trigger that invokes the real completion
    #      handler, comp-func.
    #   2. dst-command is the leading args of the command-line passed
    #      to the real completion handler, comp-func, to produce the
    #      resulting completion choices.
    unset -v dstparse
    local -a srccmd dstcmd
    local func
    while [[ ${#} -gt 0 ]]; do
        case "${1}" in
        --) 
            # when we reach the delimiter, also shift in comp-func as 
            # the next argument (the real completion handler).
            dstparse=1
            shift
            func=${1:-}
            ;;
        *)
            # if we aren't processing the delimiter, then all other
            # args are appended to either src-command or dst-command.
            if [[ -z ${dstparse} ]]; then
                srccmd+=( "${1}" )
            else
                dstcmd+=( "${1}" )
            fi
            ;;
        esac
        shift
    done
    # if the completer is dynamic and not yet loaded, try to load it
    # automatically using the given command
    if [[ $( type -t "${func}" ) != function ]]; then
        type -p _completion_loader &> /dev/null &&
            _completion_loader "${dstcmd[@]}"
    fi
    local wrap=$( joinstr _ "${srccmd[@]}" | tr -d -c '[A-Za-z_]' )
    # replace our args with dst-command followed by whatever remains
    # from the invoking command-line.
    eval "
        function _${wrap} {
            (( COMP_CWORD+=$(( ${#dstcmd[@]} )) ))
            COMP_WORDS=( "${dstcmd[@]}" \${COMP_WORDS[@]:1} )
            "${func}"
            return 0
        }
    "
    # install this wrapper handler on the first word in src-command
    complete -F "_${wrap}" "${srccmd[0]}"
}

complete-subcmd ino cli -- __start_arduino-cli arduino-cli
  • completion.bash from arduino-cli

# bash completion V2 for arduino-cli                          -*- shell-script -*-

__arduino-cli_debug()
{
    if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then
        echo "$*" >> "${BASH_COMP_DEBUG_FILE}"
    fi
}

# Macs have bash3 for which the bash-completion package doesn't include
# _init_completion. This is a minimal version of that function.
__arduino-cli_init_completion()
{
    COMPREPLY=()
    _get_comp_words_by_ref "$@" cur prev words cword
}

# This function calls the arduino-cli program to obtain the completion
# results and the directive.  It fills the 'out' and 'directive' vars.
__arduino-cli_get_completion_results() {
    local requestComp lastParam lastChar args

    # Prepare the command to request completions for the program.
    # Calling ${words[0]} instead of directly arduino-cli allows to handle aliases
    args=("${words[@]:1}")
    requestComp="${words[0]} __completeNoDesc ${args[*]}"

    lastParam=${words[$((${#words[@]}-1))]}
    lastChar=${lastParam:$((${#lastParam}-1)):1}
    __arduino-cli_debug "lastParam ${lastParam}, lastChar ${lastChar}"

    if [ -z "${cur}" ] && [ "${lastChar}" != "=" ]; then
        # If the last parameter is complete (there is a space following it)
        # We add an extra empty parameter so we can indicate this to the go method.
        __arduino-cli_debug "Adding extra empty parameter"
        requestComp="${requestComp} ''"
    fi

    # When completing a flag with an = (e.g., arduino-cli -n=<TAB>)
    # bash focuses on the part after the =, so we need to remove
    # the flag part from $cur
    if [[ "${cur}" == -*=* ]]; then
        cur="${cur#*=}"
    fi

    __arduino-cli_debug "Calling ${requestComp}"
    # Use eval to handle any environment variables and such
    out=$(eval "${requestComp}" 2>/dev/null)

    # Extract the directive integer at the very end of the output following a colon (:)
    directive=${out##*:}
    # Remove the directive
    out=${out%:*}
    if [ "${directive}" = "${out}" ]; then
        # There is not directive specified
        directive=0
    fi
    __arduino-cli_debug "The completion directive is: ${directive}"
    __arduino-cli_debug "The completions are: ${out[*]}"
}

__arduino-cli_process_completion_results() {
    local shellCompDirectiveError=1
    local shellCompDirectiveNoSpace=2
    local shellCompDirectiveNoFileComp=4
    local shellCompDirectiveFilterFileExt=8
    local shellCompDirectiveFilterDirs=16

    if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then
        # Error code.  No completion.
        __arduino-cli_debug "Received error from custom completion go code"
        return
    else
        if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then
            if [[ $(type -t compopt) = "builtin" ]]; then
                __arduino-cli_debug "Activating no space"
                compopt -o nospace
            else
                __arduino-cli_debug "No space directive not supported in this version of bash"
            fi
        fi
        if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then
            if [[ $(type -t compopt) = "builtin" ]]; then
                __arduino-cli_debug "Activating no file completion"
                compopt +o default
            else
                __arduino-cli_debug "No file completion directive not supported in this version of bash"
            fi
        fi
    fi

    if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then
        # File extension filtering
        local fullFilter filter filteringCmd

        # Do not use quotes around the $out variable or else newline
        # characters will be kept.
        for filter in ${out[*]}; do
            fullFilter+="$filter|"
        done

        filteringCmd="_filedir $fullFilter"
        __arduino-cli_debug "File filtering command: $filteringCmd"
        $filteringCmd
    elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then
        # File completion for directories only

        # Use printf to strip any trailing newline
        local subdir
        subdir=$(printf "%s" "${out[0]}")
        if [ -n "$subdir" ]; then
            __arduino-cli_debug "Listing directories in $subdir"
            pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return
        else
            __arduino-cli_debug "Listing directories in ."
            _filedir -d
        fi
    else
        __arduino-cli_handle_standard_completion_case
    fi

    __arduino-cli_handle_special_char "$cur" :
    __arduino-cli_handle_special_char "$cur" =
}

__arduino-cli_handle_standard_completion_case() {
    local tab comp
    tab=$(printf '\t')

    local longest=0
    # Look for the longest completion so that we can format things nicely
    while IFS='' read -r comp; do
        # Strip any description before checking the length
        comp=${comp%%$tab*}
        # Only consider the completions that match
        comp=$(compgen -W "$comp" -- "$cur")
        if ((${#comp}>longest)); then
            longest=${#comp}
        fi
    done < <(printf "%s\n" "${out[@]}")

    local completions=()
    while IFS='' read -r comp; do
        if [ -z "$comp" ]; then
            continue
        fi

        __arduino-cli_debug "Original comp: $comp"
        comp="$(__arduino-cli_format_comp_descriptions "$comp" "$longest")"
        __arduino-cli_debug "Final comp: $comp"
        completions+=("$comp")
    done < <(printf "%s\n" "${out[@]}")

    while IFS='' read -r comp; do
        COMPREPLY+=("$comp")
    done < <(compgen -W "${completions[*]}" -- "$cur")

    # If there is a single completion left, remove the description text
    if [ ${#COMPREPLY[*]} -eq 1 ]; then
        __arduino-cli_debug "COMPREPLY[0]: ${COMPREPLY[0]}"
        comp="${COMPREPLY[0]%% *}"
        __arduino-cli_debug "Removed description from single completion, which is now: ${comp}"
        COMPREPLY=()
        COMPREPLY+=("$comp")
    fi
}

__arduino-cli_handle_special_char()
{
    local comp="$1"
    local char=$2
    if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then
        local word=${comp%"${comp##*${char}}"}
        local idx=${#COMPREPLY[*]}
        while [[ $((--idx)) -ge 0 ]]; do
            COMPREPLY[$idx]=${COMPREPLY[$idx]#"$word"}
        done
    fi
}

__arduino-cli_format_comp_descriptions()
{
    local tab
    tab=$(printf '\t')
    local comp="$1"
    local longest=$2

    # Properly format the description string which follows a tab character if there is one
    if [[ "$comp" == *$tab* ]]; then
        desc=${comp#*$tab}
        comp=${comp%%$tab*}

        # $COLUMNS stores the current shell width.
        # Remove an extra 4 because we add 2 spaces and 2 parentheses.
        maxdesclength=$(( COLUMNS - longest - 4 ))

        # Make sure we can fit a description of at least 8 characters
        # if we are to align the descriptions.
        if [[ $maxdesclength -gt 8 ]]; then
            # Add the proper number of spaces to align the descriptions
            for ((i = ${#comp} ; i < longest ; i++)); do
                comp+=" "
            done
        else
            # Don't pad the descriptions so we can fit more text after the completion
            maxdesclength=$(( COLUMNS - ${#comp} - 4 ))
        fi

        # If there is enough space for any description text,
        # truncate the descriptions that are too long for the shell width
        if [ $maxdesclength -gt 0 ]; then
            if [ ${#desc} -gt $maxdesclength ]; then
                desc=${desc:0:$(( maxdesclength - 1 ))}
                desc+="…"
            fi
            comp+="  ($desc)"
        fi
    fi

    # Must use printf to escape all special characters
    printf "%q" "${comp}"
}

__start_arduino-cli()
{
    local cur prev words cword split

    COMPREPLY=()

    # Call _init_completion from the bash-completion package
    # to prepare the arguments properly
    if declare -F _init_completion >/dev/null 2>&1; then
        _init_completion -n "=:" || return
    else
        __arduino-cli_init_completion -n "=:" || return
    fi

    __arduino-cli_debug
    __arduino-cli_debug "========= starting completion logic =========="
    __arduino-cli_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword"

    # The user could have moved the cursor backwards on the command-line.
    # We need to trigger completion from the $cword location, so we need
    # to truncate the command-line ($words) up to the $cword location.
    words=("${words[@]:0:$cword+1}")
    __arduino-cli_debug "Truncated words[*]: ${words[*]},"

    local out directive
    __arduino-cli_get_completion_results
    __arduino-cli_process_completion_results
}

if [[ $(type -t compopt) = "builtin" ]]; then
    complete -o default -F __start_arduino-cli arduino-cli
else
    complete -o default -o nospace -F __start_arduino-cli arduino-cli
fi

# ex: ts=4 sw=4 et filetype=sh

1 Answer 1

3

UPDATE2:

After I posted this I checked your links and after seeing the accepted answer here, I was thinking I am just old and forget that I just copied this code from that link, and it wasn't me who wrote it. Even the example used there is the same, but investigating the code further, it looks I did write this and used a different approach, and maybe this will help you understand what's going on. As I mentioned at the bottom UPDATE1 section: you need to tune the COMP variables then call the original function


Original:

I wrote an 'alias wrapper' script a couple of years ago. The idea is to use the original bash completion with aliases even with parameters. For example:

    alias apti='apt-get install'
    source alias-completion-wrapper _apt_get apti apt-get install
    #here _apt_get is the original completion function

Now you can use tab to complete the package name after apti just like after apt-get install

#alias-completion-wrapper
#Example: . alias-completion-wrapper _apt_get apti apt-get install

comp_function_name="$1"
ali="$2"
shift 2
x="$@"
function_name=`echo _$@ |tr ' ' _`
function="
    function $function_name {
       _completion_loader $1
       (( COMP_CWORD += $# - 1 ))
       COMP_WORDS=( $@ \"\${COMP_WORDS[@]:1}\")
       COMP_LINE=\"\${COMP_WORDS[@]}\"
       let COMP_POINT=\${COMP_POINT}-${#ali}+${#x}
       $comp_function_name
       return 0
    }"
eval "$function"
complete -F $function_name $ali
unset function function_name ali x

To be honest, I can't remember how it works and I didn't commented the script :) But I think you will be able to tune this for your needs.


UPDATE1:

As I investigated the code a bit, it looks like the idea is to tune the COMP variables, then call the original function :)


UPDATE3:

I had some time, so the modification you need are:

This ${COMP_WORDS[@]} contains the current command line. ${COMP_WORDS[@]:1} cuts off the first word, which is originally the alias/command. As you want to use it after a parameter you have to cut off the parameter too.

COMP_WORDS=( $@ \"\${COMP_WORDS[@]:2}\")

I don't see COMP_LINE and COMP_POINT in the other solution, but as I can recall without those, it doesn't worked well in certain circumstances. So I suppose you need:

COMP_LINE=\"\${COMP_WORDS[@]:1}\"

And here ${#ali} is the length of the command. You need to replace this with the length of your command with the parameter. eg,:"xcmd prm" -> 8 (count the space too)

let COMP_POINT=\${COMP_POINT}-${#ali}+${#x}

Not sure about (( COMP_CWORD += $# - 1 )) either remove the -1 or use -2 or leave it as it is :)

After the modifications, just change the eval to echo and remove the complete -F line. And source the script as described. This way it will echo the function what you can insert into your completion script.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.