#!/usr/bin/env bash # dfm utility functions for common tasks # Source this file to use the functions in your scripts. # # @author Ismo Vuorinen # @license MIT set -euo pipefail # ANSI escape codes readonly RESET="\033[0m" readonly BOLD="\033[1m" readonly DIM="\033[2m" readonly ITALIC="\033[3m" readonly UNDERLINE="\033[4m" # Colors readonly BLACK="\033[30m" readonly RED="\033[31m" readonly GREEN="\033[32m" readonly YELLOW="\033[33m" readonly BLUE="\033[34m" readonly MAGENTA="\033[35m" readonly CYAN="\033[36m" readonly WHITE="\033[37m" # Function to print formatted line list::print_formatted() { local format=$1 shift printf "${format}%s${RESET}\n" "$@" } # Function to print a header list::print_header() { printf "\n ${BOLD}${BLUE}%s${RESET}\n" "$1" printf "%s\n" " $(printf '%.s─' {1..60})" } # Function to print a group header list::print_group() { local group=$1 printf "\n ${YELLOW}${BOLD}%s${RESET}\n\n" "$group" } # Function to print a command list::print_command() { local cmd=$1 local desc=${2:-""} printf " ${BOLD}${CYAN}%-15s${RESET} ${DIM}%s${RESET}\n" "$cmd" "$desc" } # Function to print a subcommand list::print_subcommand() { local cmd=$1 local desc=${2:-""} printf " ${GREEN}%-13s${RESET} ${desc}\n" "$cmd" } list::loop_functions() { while IFS= read -r func; do # Get the function description from the function definition in the # command file. If no description is found, print only the function name. # The description is printed without the @description prefix. # If the function is not found, print only the function name. # The function name is printed with a bullet point. local doc doc=$(main::get_function_description "$cmd_file" "$func") if [[ -n "$doc" ]]; then list::print_subcommand "$func:" "${doc#*@description}" else list::print_subcommand "$func" "" fi done < <(main::get_command_functions "$cmd_file") } # Get the documentation for a function from a command file. list::get_function_docs() { local cmd_file="$1" local func="$2" awk -v func="$func" ' # Start collecting documentation when a function is found and the line contains @ /^[[:space:]]*#[[:space:]]*@/ { tag = $2 sub(/^[[:space:]]*#[[:space:]]*@[[:space:]]*[a-zA-Z]+[[:space:]]*/, "") docs[tag] = $0 last_tag = tag } # Collect multi-line documentation /^[[:space:]]*#/ && last_tag && !/^[[:space:]]*#[[:space:]]*@/ { sub(/^[[:space:]]*#[[:space:]]*/, "") docs[last_tag] = docs[last_tag] " " $0 } # Empty line or comment line ends documentation !/^[[:space:]]*#/ { last_tag = "" } # When the function is found, print the documentation $0 ~ "^[[:space:]]*(function[[:space:]]+)?" func "\\(\\)" { for (tag in docs) { printf "@%s %s\n", tag, docs[tag] } } ' "$cmd_file" } # Check if a command exists in the current environment and return 0 if it does. # Otherwise, return 1. # # @example # if utils::is_installed curl; then # echo "curl is installed" # else # echo "curl is not installed" # fi # # @description Check if a command exists # @param $1 Command to check # @return 0 if the command exists, 1 otherwise utils::is_installed() { command -v "$1" > /dev/null 2>&1 } # Check if a directory exists in the current env PATH and return 0 if it does. # Otherwise, return 1. # # @example # if utils::in_path /usr/local/bin; then # echo "/usr/local/bin is in PATH" # else # echo "/usr/local/bin is not in PATH" # fi # # @description Check if a directory is in PATH # @param $1 Directory to check # @return 0 if the directory is in PATH, 1 otherwise utils::in_path() { local cmd=$1 local result=1 IFS=: read -ra path <<< "$PATH" for p in "${path[@]}"; do if [[ -x "$p/$cmd" ]]; then result=0 break fi done return $result } # Retry a command until it succeeds or the maximum number of retries is reached. # Logs a warning message if the command fails and is retried after a short delay. # # @example # if utils::retry 3 curl -sSL https://example.com; then # echo "Success" # else # echo "Failed" # fi # # @description Retry a command # @param $1 Maximum number of retries # @param $2.. Command to run # @return 0 if the command succeeds, 1 otherwise # @dependencies logger::warn utils::retry() { local tries=$1 shift local count=1 until "$@"; do [[ $count -gt $tries ]] && return 1 logger::warn "Failed, retry $count/$tries" ((count++)) sleep 1 done return 0 } # Ask for confirmation before proceeding. The default value is used if the user # presses Enter without providing an answer. # # @example # if utils::interactive::confirm "Are you sure?"; then # echo "Confirmed" # else # echo "Not confirmed" # fi # # @description Confirm an action # @param $1 Prompt message # @param $2 Default value # @return 0 if the user confirms, 1 otherwise utils::interactive::confirm() { local prompt=$1 local default=${2:-Y} while true; do read -rp "$prompt [Y/n]: " response case ${response:-$default} in [Yy]*) return 0 ;; [Nn]*) return 1 ;; *) echo "Please answer yes or no" ;; esac done } # Find all command files in the cmd directory and return them # as a space-separated string of filenames (e.g. "cmd1.sh cmd2.sh"). # # The function uses a while loop to read the output of the find command # line by line. The -print0 option is used to separate the filenames with # a null character (\0) instead of a newline. This is necessary to handle # filenames with spaces correctly. # # The read command reads the null-separated filenames and appends them to # the cmd_files array. Finally, the function prints the array elements # separated by a space. # # @return A space-separated string of command files. main::find_commands() { local cmd_files=() while IFS= read -r -d '' file; do cmd_files+=("$file") done < <(find "$CMD_DIR" -type f -name "*.sh" -print0) echo "${cmd_files[@]}" } # Get the function names from a command file. # # The function uses grep to find function definitions (function xxx() or xxx()) # and sed to extract the function names. The function names are printed one per # line. # # @param cmd_file The command file to extract function names from. # @return A list of function names. main::get_command_functions() { local cmd_file="$1" # Etsitään funktiomäärittelyt (function xxx() tai xxx()) grep -E '^[[:space:]]*(function[[:space:]]+)?[a-zA-Z0-9_]+\(\)[[:space:]]*{' "$cmd_file" \ | sed -E 's/^[[:space:]]*(function[[:space:]]+)?([a-zA-Z0-9_]+).*/\2/' } # Get the description of a function from a command file. # # The function uses grep to find the function definition and sed to extract # the description. The description is printed without the @description prefix. # # @param cmd_file The command file to extract the function description from. # @param func The function name. # @return The function description. main::get_function_description() { local cmd_file="$1" local func="$2" grep -B1 "^[[:space:]]*\(function[[:space:]]*\)\{0,1\}$func().*{" "$cmd_file" \ | grep "@description" \ | sed -E 's/^[[:space:]]*#[[:space:]]*@description[[:space:]]*//' } # List all available commands and their functions. # # The function uses main::find_commands to get a list of command files. # It then iterates over the files and prints the command name and # its functions. # # @return None main::list_available_commands() { local cmd_files cmd_files=$(main::find_commands) list::print_header "dfm - dotfiles manager" list::print_group "Available commands" for cmd_file in $cmd_files; do local cmd_name cmd_name=$(basename "$cmd_file" .sh) list::print_command "$cmd_name" list::loop_functions "$cmd_file" done } # Execute a command function. # # The function loads the command file and checks if the function exists. # If the function exists, it executes the function with the provided arguments. # # @param cmd The command name. # @param func The function name. # @param args The function arguments. # @return None main::execute_command() { local cmd="$1" shift local func="$1" shift local cmd_file="${CMD_DIR}/${cmd}.sh" if [[ ! -f "$cmd_file" ]]; then lib::error "Command '$cmd' not found" return 1 fi # Source the command file source "$cmd_file" # Check if the function exists if ! declare -f "$func" > /dev/null; then lib::error "Function '$func' not found in command '$cmd'" return 1 fi # Run the function with the provided arguments "$func" "$@" }