#!/usr/bin/env bash ############################################################################### # run is a utility for organizing your project’s CLI commands. # https://run.jotaen.net ############################################################################### # MIT License # Copyright (c) 2022 Jan Heuermann, https://www.jotaen.net # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ############################################################################### # Runtime configuration. # These variables can be overriden while parsing the flags. TASK_FILE='./run.sh' ACTION=(run::action-list) # Regex patterns for parsing the task file. COMMENT_PATTERN='^#{1,}[[:blank:]]?(.*)$' TASK_NAME_PATTERN='[a-zA-Z]+[a-zA-Z0-9:_-]*' TASK_DEF_PATTERN_1='^[[:blank:]]*run::('"${TASK_NAME_PATTERN}"')[[:blank:]]*\([[:blank:]]*\)' TASK_DEF_PATTERN_2='^[[:blank:]]*function[[:blank:]]*run::('"${TASK_NAME_PATTERN}"')' # The --help procedure. run::action-help() { echo 'Run tasks from a run.sh file.' echo echo 'Usage: run [OPTION] TASK [TASK_ARGS...]' echo echo 'Options:' echo ' -f, --file Specify the task file (default: ./run.sh)' echo ' -i, --info task Show task description' echo ' -l, --list List all available tasks' echo ' -h, --help Print this help' echo ' --version Print version of this command' } # The --version procedure. run::action-version() { echo 'Version 2.0' echo 'https://run.jotaen.net' } # The --list procedure. run::action-list() { local title_candidate="" local tasks=() # All encountered task names. local titles=() # All encountered titles. (Same arity as $tasks.) local longest_task_name=0 # Needed for aligning the output. # Parse the task file. while IFS= read -r line; do # Process comment block. if [[ "${line}" =~ $COMMENT_PATTERN ]]; then if [[ -z "${title_candidate}" ]]; then local comment_text="${BASH_REMATCH[1]}" title_candidate="${comment_text}" fi continue fi # After a comment block, there must be the task definition. # Allow both definition styles: `foo() {...` and `function foo {`. if [[ "${line}" =~ $TASK_DEF_PATTERN_1 || "${line}" =~ $TASK_DEF_PATTERN_2 ]]; then local task="${BASH_REMATCH[1]}" tasks+=("${task}") titles+=("${title_candidate}") if [[ ${#task} -gt $longest_task_name ]]; then longest_task_name=${#task} fi fi # Reset parser for next iteration. title_candidate="" done < "${TASK_FILE}" # Print out the gathered information. local column_offset=3 for (( i=0; i<${#tasks[@]}; i++ )); do local padding=0 if [[ "${titles[$i]}" != "" ]]; then padding=$((longest_task_name+column_offset)) fi printf "%-${padding}s%s\n" "${tasks[$i]}" "${titles[$i]}" done } # The --info procedure. run::action-info() { local requested_task="$1" local comment_block_candidate=() # All comment lines for the task. # Abort if no task name was specified. if [[ "${requested_task}" == '' ]]; then echo 'No task specified' >&2 exit 1 fi # Parse the task file. while IFS= read -r line; do if [[ "${line}" =~ $COMMENT_PATTERN ]]; then comment_block_candidate+=("${BASH_REMATCH[1]}") continue fi if [[ "${line}" =~ $TASK_DEF_PATTERN_1 || "${line}" =~ $TASK_DEF_PATTERN_2 ]]; then if [[ "${BASH_REMATCH[1]}" == "${requested_task}" ]]; then # Print out the gathered comment lines. for i in "${comment_block_candidate[@]}"; do echo "${i}" done exit fi fi comment_block_candidate=() done < "${TASK_FILE}" echo "No such task: ${requested_task}" >&2 exit 1 } # The procedure when invoking a task. run::action-execute() { readonly task_name="$1" readonly task_identifier="run::${task_name}" readonly input=( "${@:2}" ) readonly task_file="${TASK_FILE}" # Check shebang of task file. shebang=$(head -n 1 "${task_file}") if [[ "${shebang}" == '#!'* && "${shebang}" != *'bash'* ]]; then echo "Unsupported shebang." >&2 exit 1 fi # Assemble final script. run_sh='' # Include task file. run_sh+=' source '"${task_file}"' ' # Add check, whether subcommand is defined. # shellcheck disable=SC2016 run_sh+=' if [[ "$(type -t '"${task_identifier}"')" != "function" ]]; then echo "No such task: '"${task_name}"'" >&2 exit 1 fi ' # Add task invocation. run_sh+=' '"${task_identifier} ${input[*]}"' ' # Run the final script. bash -c "$run_sh" } # Parse CLI arguments. while [[ $# -gt 0 ]]; do case $1 in --help|-h) run::action-help exit ;; --version) run::action-version exit ;; --list|--ls|-l) ACTION=( run::action-list ) shift continue ;; --info=*|-i=*) ACTION=( run::action-info "${1#*=}" ) shift continue ;; --info|-i) ACTION=( run::action-info "$2" ) shift shift continue ;; --file=*|-f=*) TASK_FILE="${1#*=}" shift continue ;; --file|-f) TASK_FILE="$2" shift shift continue ;; -*) echo "Unknown option $1" >&2 exit 1 ;; *) ACTION=( run::action-execute "$1" "${@:2}" ) break ;; esac done # If task file is given, check it. if [[ -n "${TASK_FILE}" ]]; then # Check that it’s a regular file. if [[ -d "${TASK_FILE}" ]]; then echo "Not a file: ${TASK_FILE}" >&2 exit 2 fi # Check that it exists. if [[ ! -f "${TASK_FILE}" ]]; then echo "No such file: ${TASK_FILE}" >&2 exit 2 fi fi # Execute action. "${ACTION[@]}"