#!/usr/bin/env bash # --- STANDARD SCRIPT-GLOBAL CONSTANTS kTHIS_NAME=${BASH_SOURCE##*/} kTHIS_HOMEPAGE='https://github.com/mklement0/shall' kTHIS_VERSION='v0.2.8' # NOTE: This assignment is automatically updated by `make version VER=` - DO keep the 'v' prefix. unset CDPATH # To prevent unexpected `cd` behavior. # --- Begin: STANDARD HELPER FUNCTIONS die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2; exit ${2:-1}; } dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2; exit 2; } # SYNOPSIS # openUrl # DESCRIPTION # Opens the specified URL in the system's default browser. openUrl() { local url=$1 platform=$(uname) cmd=() case $platform in 'Darwin') # OSX cmd=( open "$url" ) ;; 'CYGWIN_'*) # Cygwin on Windows; must call cmd.exe with its `start` builtin cmd=( cmd.exe /c start '' "$url " ) # !! Note the required trailing space. ;; 'MINGW32_'*) # MSYS or Git Bash on Windows; they come with a Unix `start` binary cmd=( start '' "$url" ) ;; *) # Otherwise, assume a Freedesktop-compliant OS, which includes many Linux distros, PC-BSD, OpenSolaris, ... cmd=( xdg-open "$url" ) ;; esac "${cmd[@]}" || { echo "Cannot locate or failed to open default browser; please go to '$url' manually." >&2; return 1; } } # Prints the embedded Markdown-formatted man-page source to stdout. printManPageSource() { sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/ { s///; t\np;}' "$BASH_SOURCE" } # Opens the man page, if installed; otherwise, tries to display the embedded Markdown-formatted man-page source; if all else fails: tries to display the man page online. openManPage() { local pager embeddedText if ! man 1 "$kTHIS_NAME" 2>/dev/null; then # 2nd attempt: if present, display the embedded Markdown-formatted man-page source embeddedText=$(printManPageSource) if [[ -n $embeddedText ]]; then pager='more' command -v less &>/dev/null && pager='less' # see if the non-standard `less` is available, because it's preferable to the POSIX utility `more` printf '%s\n' "$embeddedText" | "$pager" else # 3rd attempt: open the the man page on the utility's website openUrl "${kTHIS_HOMEPAGE}/doc/${kTHIS_NAME}.md" fi fi } # Prints the contents of the synopsis chapter of the embedded Markdown-formatted man-page source for quick reference. printUsage() { local embeddedText # Extract usage information from the SYNOPSIS chapter of the embedded Markdown-formatted man-page source. embeddedText=$(sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/!d; /^## SYNOPSIS$/,/^#/{ s///; t\np; }' "$BASH_SOURCE") if [[ -n $embeddedText ]]; then # Print extracted synopsis chapter - remove backticks for uncluttered display. printf '%s\n\n' "$embeddedText" | tr -d '`' else # No SYNOPIS chapter found; fall back to displaying the man page. echo "WARNING: usage information not found; opening man page instead." >&2 openManPage fi } # --- End: STANDARD HELPER FUNCTIONS # --- PROCESS STANDARD, OUTPUT-INFO-THEN-EXIT OPTIONS. case $1 in --version) # Output version number and exit, if requested. echo "$kTHIS_NAME $kTHIS_VERSION"$'\nFor license information and more, visit '"$kTHIS_HOMEPAGE"; exit 0 ;; -h|--help) # Print usage information and exit. printUsage; exit ;; --man) # Display the manual page and exit, falling back to printing the embedded man-page source. openManPage; exit ;; --man-source) # private option, used by `make update-man` # Print raw, embedded Markdown-formatted man-page source and exit printManPageSource; exit ;; --home) # Open the home page and exit. openUrl "$kTHIS_HOMEPAGE"; exit ;; esac # --- Begin: FUNCTIONS unset CDPATH # to prevent unpredictable `cd` behavior # NOTE: # To remain compatible with FreeBSD as well, we AVOID PROCESS SUBSTITUTIONS in this script, # because there they're only supported with an extra configuration step that requires root privileges (`sudo mount -t fdescfs fdescfs /dev/fd`). # Helper function for exiting with error message due to runtime error. # die [errMsg] die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2 exit 127 # Note: exit code was chosen to (a) not overlap with exit codes produced during normal operation, while (b) not overlapping with exit codes stemming from termination by signal. } # Helper function for exiting with error message due to invalid parameters. # dieSyntax [errMsg] dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2 exit 126 # Note: exit code was chosen to (a) not overlap with exit codes produced during normal operation, while (b) not overlapping with exit codes stemming from termination by signal. } # SYNOPSIS # colorOutput colorNum [text ...] # DESCRIPTION # Prints input in the specified color, which must be an ANSI color code (e.g., 31 for red), # Uses stdin, if no arguments are specified. # # If the variable kNO_COLOR is set, coloring is suppressed. # An invoking script may set this in case output is NOT being sent to a terminal. # (e.g., [[ -t 1 ]] || kNO_COLOR=1) colorOutput() { local pre="\033[${1}m" post='\033[0m' (( kNO_COLOR )) && { pre= post=; } shift if (( $# )); then printf "${pre}%s${post}" "$@" else # stdin input printf "$pre" cat printf "$post" fi } # SYNOPSIS # colorCodeOutput exitCode [text ...] # Colors text based on the specified exit code: if 0, green; otherwise, read. colorCodeOutput() { (( $1 == 0 )) && green "${@:2}" || red "${@:2}" } # SYNOPSIS # ... | colorIfFailed exitCode # Prints input red, if EXITCODE != 0; as is, otherwise. colorIfFailed() { (( $1 == 0 )) && { (( $# > 1 )) && echo "$@" || cat; } || red "${@:2}" } green() { colorOutput 32 "$@" } red() { colorOutput 31 "$@" } blue() { colorOutput 34 "$@" } underlineBlue() { colorOutput '4;34' "$@" } statusMark() { local mark=$(red '✗') (( $1 == 0 )) && mark=$(green '✓') printf '%s' "$mark" } # SYNOPSIS # rreadlink # DESCRIPTION # Prints the canonical path of the specified symlink's ultimate target. # If the argument is not a symlink, its own canonical path is output; note # that this means that if a non-symlink argument has symlinks in its # *directory* path, they are resolved to their ultimate target. # A broken symlink causes an error that reports the non-existent target. # NOTES # Attempts to use `readlink`, which is found on most modern platforms # (notable exception: HP-UX). If `readlink` is not available, output from # `ls -l` is parsed, which is the only POSIX-compliant way to determine a # symlink's target. # Caveat: This will break if a filename contains literal ' -> ' or has # embedded newlines. # THANKS # Gratefully adapted from http://stackoverflow.com/a/1116890/45375 rreadlink() ( # execute function in a *subshell* to localize the effect of `cd`, ... local target=$1 fname targetDir readlinkexe=$(command -v readlink) CDPATH= # Since we'll be using `command` below for a predictable execution # environment, we make sure that it has its original meaning. { \unalias command; \unset -f command; } &>/dev/null while :; do # Resolve potential symlinks until the ultimate target is found. [[ -L $target || -e $target ]] || { command printf '%s\n' "$FUNCNAME: ERROR: '$target' does not exist." >&2; return 1; } command cd "$(command dirname -- "$target")" # Change to target dir; necessary for correct resolution of target path. fname=$(command basename -- "$target") # Extract filename. [[ $fname == '/' ]] && fname='' # !! curiously, `basename /` returns '/' if [[ -L $fname ]]; then # Extract [next] target path, which is defined # relative to the symlink's own directory. if [[ -n $readlinkexe ]]; then # Use `readlink`. target=$("$readlinkexe" -- "$fname") else # `readlink` utility not available. # Parse `ls -l` output, which, unfortunately, is the only POSIX-compliant # way to determine a symlink's target. Hypothetically, this can break with # filenames containig literal ' -> ' and embedded newlines. target=$(command ls -l -- "$fname") target=${target#* -> } fi continue # Resolve [next] symlink target. fi break # Ultimate target reached. done targetDir=$(command pwd -P) # Get canonical dir. path # Output the ultimate target's canonical path. # Note that we manually resolve paths ending in /. and /.. to make sure we # have a normalized path. if [[ $fname == '.' ]]; then command printf '%s\n' "${targetDir%/}" elif [[ $fname == '..' ]]; then # Caveat: something like /var/.. will resolve to /private (assuming # /var@ -> /private/var), i.e. the '..' is applied AFTER canonicalization. command printf '%s\n' "$(command dirname -- "${targetDir}")" else command printf '%s\n' "${targetDir%/}/$fname" fi ) # --- MAIN BODY # --------- Constants # The shells to target by default, if installed. kDEFAULT_SHELLS=( sh dash bash zsh ksh ) kREPL_HISTFILE=~/".${kTHIS_NAME}_history" # ---------- # Parse options shellList=$SHELLS isCmdStr=0 cmdStr= fromStdin=0 interactive=0 quietStdout=0 quietAll=0 extraPassThruOpts= while getopts :w:l:p:qQc:si opt; do # $opt will receive the option *letters* one by one; a trailing : means that an arg. is required. [[ $opt == '?' ]] && dieSyntax "ARGUMENT ERROR: Unknown option: -$OPTARG. To pass options through to the target shells, pass them via -p." [[ $opt == ':' ]] && dieSyntax "Option -$OPTARG is missing its argument." case $opt in w|l) # !! -l is for backward compatibility - switched to -w to avoid confusion with native shell option -l (run as login shell) shellList=$OPTARG ;; q) quietStdout=1 ;; Q) quietAll=1 ;; c) # note that, curiously, all shells require the argument to -c be specified as a SEPARATE argument, violating the POSIX requirement that mandatory option-arguments *also* be accepted as directly-attached values. # possible rationale: additional non-option arguments can follow, representing the positional parameters that the command string will see isCmdStr=1 cmdStr=$OPTARG ;; i) interactive=1 ;; s) fromStdin=1 ;; p) extraPassThruOpts=$OPTARG ;; *) dieSyntax "DESIGN ERROR: Unhandled option: $opt" ;; esac done # Determine the options to pass through to the shells invoked. passThruOpts=() if [[ -n $extraPassThruOpts ]]; then # If options to pass through were explicitly specified, parse them into an array now. # Note: We pass them through xargs -n 1 so as to correctly recognize embedded quoted strings. IFS=$'\n' read -d '' -ra passThruOpts <<<"$(xargs -n 1 printf '%s\n' <<<"$extraPassThruOpts")" fi # The -c and -s options are special: # -c: its argument is the command to execute and must be specified *separately* from -c # both -c and -s: any subsequent arguments - even if they look like options! - become the positional arguments for the code invoked (( fromStdin )) && passThruOpts+=( '-s' ) (( isCmdStr )) && passThruOpts+=( '-c' "$cmdStr" ) # Skip past the options. shift $((OPTIND - 1)) # Interactive mode (-i): # Cannot be combined with -c or -s or -q or -Q (( interactive && ( isCmdStr || fromStdin || quietAll || quietStdout ) )) && dieSyntax "Incompatible options specified." # Doesn't support arguments. (( interactive && $# )) && dieSyntax "Unexpected argument(s) specified: '$*'." # For consistency we also prevent specifying *both* -c and -s, as most shells do not support input via both stdin and command string. # (dash is the only exception). (( isCmdStr && fromStdin )) && dieSyntax "Incompatible options specified: Please specify EITHER a command string OR stdin input." # See if input is to come from stdin *implicitly*. if (( ! (interactive || fromStdin || isCmdStr) )); then (( $# == 0 )) && fromStdin=1 fi # ?? Activate for debugging: # pv shellList isCmdStr fromStdin interactive quietStdout quietAll extraPassThruOpts passThruOpts # Note that we ALSO allow stdin input from the terminal with -s (or implicitly, if no operands are specified), not just with -i: # -s, in contrast with -i (REPL-style, line-by-line), allows *multiple* lines to be entered, until ⌃D is pressed to submit all lines at once. # This is like typing a whole script interactively. # With -i, only stdin input from the *terminal* makes sense. (( interactive )) && [[ ! -t 0 ]] && die "-i requires that stdin input be provided interactively." # If stdout wasn't connected to a terminal on entering this script, we suppress colored output. # Note that sending from a terminal through a *pipe* is NOT considered being connected to a terminal. [[ -t 1 ]] || kNO_COLOR=1 # Determine what shells to invoke. if [[ -n $shellList ]]; then # shells were explicitly specified, either with -w or via env. variable $SHELL shellsGiven=1 shellCandidates=( ${shellList//,/ } ) else # use default shells shellsGiven=0 shellCandidates=( ${kDEFAULT_SHELLS[@]} ) fi # Abort in case of non-existent shells, if explicitly specified); otherwise, weed them out. # Note: With the default shells, if 'sh' is found to be a mere symlink to 'dash', it is skipped. # A mere symlink to 'bash' is NOT skipped, however, because bash behaves slightly differently when invoked as 'sh'. # Also: determine shells' display names. shells=() shellDisplayNames=() skipDash=0 for shell in "${shellCandidates[@]}"; do (( skipDash )) && [[ $shell == 'dash' ]] && continue shellDisplayName=$shell # Determine full shell path; if the shell cannot be located: # - if the list of shells was explicitly defined: report an error and exit # - otherwise, simply skip that shell shellFullPath=$(which "$shell" 2>/dev/null) || { (( shellsGiven )) && die "Shell not found: $shell"; } if [[ -n $shellFullPath ]]; then if [[ $shell == 'sh' ]]; then trueShell=$(basename "$(rreadlink "$shellFullPath")") if [[ "$trueShell" != "$shell" ]]; then # 'sh' is symlinked to a different executable shellDisplayName+="@ (-> $trueShell)" # !! If 'sh' symlinks to 'dash', we assume - given that dash has no other purpose than to be POSIX sh-compliant - that dash behaves the same as 'sh', # !! irrespective of how it is invoked, so there's no point in *also* running 'dash' - hence we skip it. [[ "$trueShell" == 'dash' ]] && skipDash=1 elif [[ $(uname) == 'Darwin' ]]; then # !! On OSX, sh is a *variant* of bash: a *separate executable* that implements *additional* behaviors beyond invoking bash via a *symlink* named `sh` that points to `bash`. shellDisplayName+=' (bash variant)' fi fi shells+=( "$shell" ) shellDisplayNames+=( "$shellDisplayName" ) fi done # Initialize temp files. tmpFiles=() tmpFileOutput=$(mktemp -t "$kTHIS_NAME-XXXX") # Works on both OSX and Linux; note: file will have random extension on OSX (e.g., '/var/folders/19/0lxcl7hd63d6fqd813glqppc0000gn/T/XXX.XJViLcM3') and none on Linux (e.g., '/tmp/vXD') tmpFiles+=( "$tmpFileOutput" ) tmpFileTiming=$(mktemp -t "$kTHIS_NAME-XXXX") tmpFiles+=( "$tmpFileTiming" ) if (( fromStdin )); then # Note: We MUST capture the original stdin in a temp. file rather, because we need to provide the same input *multiple* times. # If we relied on the invoked shells to access the original stdin, the *first* shell would *consume* all input. tmpFileStdIn=$(mktemp -t "$kTHIS_NAME-XXXX") tmpFiles+=( "$tmpFileStdIn" ) cat >"$tmpFileStdIn" fi trap '(( interactive )) && history -w "$kREPL_HISTFILE"; rm "${tmpFiles[@]}"' EXIT # Set up exit trap to automatically clean up all temp files, and, if in interactive mode, to persist the history. # Interactive mode: configure the history function and read the history file. (( interactive )) && { HISTCONTROL='ignoredups'; HISTSIZE=100; HISTFILESIZE=$HISTSIZE; history -r "$kREPL_HISTFILE"; } while :; do # loop is for -i only if (( interactive )); then echo "$(blue 'Enter a command') to execute in $(blue "$(sed 's/ /, /g' <<<"${shells[@]}")") ('exit' to exit):" >/dev/tty # Note: read -e turns on readline support for the usual editing and navigation keys, as well as any custom config in ~/.inputrc. # !! Sadly, custom programmable completions are ignored by read -e; the only thing you get is filename completion - see http://stackoverflow.com/q/4726695/45375 # !! Working around that would (a) require bash 4+ and (b) be nontrivial. read -r -e -p '> ' # Ask user for single command. [[ $REPLY == 'exit' ]] && exit 0 [[ $REPLY == 'clear' ]] && { clear; continue; } [[ -z $REPLY ]] && continue history -s "$REPLY" # Add command to history. passThruOpts=() set -- -c -- "$REPLY" # Set the parameters for use in the shell invocations below. fi # Invocation loop over all shells i=0 ecOverall=0 shellsOk=() shellsFailed=() for shell in "${shells[@]}"; do # Make stdin come from the temp. file in which we captured the original stdin. (( fromStdin )) && exec < "$tmpFileStdIn" # Invoke, capture output and timing information, and save exit code. { if (( quietStdout )); then time -p "$shell" "${passThruOpts[@]}" "$@" 1>/dev/null 2>"$tmpFileOutput" else time -p "$shell" "${passThruOpts[@]}" "$@" &>"$tmpFileOutput" fi } 2>"$tmpFileTiming" ec=$? if (( ec )); then (( ++ecOverall )) # We report as the exit code the number of invocations that failed. shellsFailed+=( "$shell" ) else shellsOk+=( "$shell" ) fi # Print header: success/failure, shell name, timing printf '%s %-50s [%s]\n' "$(statusMark $ec)" "$(underlineBlue "${shellDisplayNames[i]}")" "$(colorCodeOutput $ec "$(head -n 1 "$tmpFileTiming" | cut -d' ' -f2)s")" # | column # Print captured output, if requested. if (( ! quietAll )); then if [[ -s "$tmpFileOutput" ]]; then # Was any output captured? # Print output, potentially color-coded: # - If the command succeeded, print all output *normally*. # - If it failed, print all output red. # Note: If stderr wasn't suppressed, this, unfortunately, is an all-or-nothing proposition, since we only have *combined* output without being able to tell which lines came from stdout vs. stderr. cat "$tmpFileOutput" | sed 's/^/ /' | colorIfFailed $ec fi # Unless output was suppressed, print an empty lines between shells - even if nothing happened to be captured in this specific case. The idea is to facilitate paragraph-based parsing. (( quietStdout )) || printf '\n' fi (( ++i )) done # Print overall result. if (( ecOverall )); then # At least 1 shell reported failure. (( ${#shellsFailed[@]} == 1 )) && { pluralSuffix=; conjugationSuffix1=s; } || { pluralSuffix=s; conjugationSuffix1=; } (( ${#shellsOk[@]} > 0 )) && { okSuffix=" ($(green "$(sed 's/ /, /g' <<<"${shellsOk[@]}")"))" && { (( ${#shellsOk[@]} == 1 )) && conjugationSuffix2=s || conjugationSuffix2=;} ; } || okSuffix= conjugationSuffix2= printf '%s - %s shell%s (%s) report%s failure, %s%s report%s success.\n' \ "$(red FAILED)" \ "$(red ${#shellsFailed[@]} | tr -d '\n')" \ "$pluralSuffix" \ "$(sed 's/ /, /g' <<<"${shellsFailed[@]}" | red | tr -d '\n')" \ "$conjugationSuffix1" \ $((( ${#shellsOk[@]} )) && printf $(green ${#shellsOk[@]}) || printf ${#shellsOk[@]}) \ "$okSuffix" \ "$conjugationSuffix2" else # All shells reported success. if (( ${#shellsOk[@]} == 1 )); then printf '%s - Shell %s reports success.\n' "$(green OK)" "$(sed 's/ /, /g' <<<"${shells[@]}")" else printf '%s - All %s shells (%s) report success.\n' "$(green OK)" "$(green ${#shells[@]})" "$(green "$(sed 's/ /, /g' <<<"${shells[@]}")")" fi fi (( interactive )) || break done exit $ecOverall #### # MAN PAGE MARKDOWN SOURCE # - Place a Markdown-formatted version of the man page for this script # inside the here-document below. # The document must be formatted to look good in all 3 viewing scenarios: # - as a man page, after conversion to ROFF with marked-man # - as plain text (raw Markdown source) # - as HTML (rendered Markdown) # Markdown formatting tips: # - GENERAL # To support plain-text rendering in the terminal, limit all lines to 80 chars., # and, for similar rendering as HTML, *end every line with 2 trailing spaces*. # - HEADINGS # - For better plain-text rendering, leave an empty line after a heading # marked-man will remove it from the ROFF version. # - The first heading must be a level-1 heading containing the utility # name and very brief description; append the manual-section number # directly to the CLI name; e.g.: # # foo(1) - does bar # - The 2nd, level-2 heading must be '## SYNOPSIS' and the chapter's body # must render reasonably as plain text, because it is printed to stdout # when `-h`, `--help` is specified: # Use 4-space indentation without markup for both the syntax line and the # block of brief option descriptions; represent option-arguments and operands # in angle brackets; e.g., '' # - All other headings should be level-2 headings in ALL-CAPS. # - TEXT # - Use NO indentation for regular chapter text; if you do, it will # be indented further than list items. # - Use 4-space indentation, as usual, for code blocks. # - Markup character-styling markup translates to ROFF rendering as follows: # `...` and **...** render as bolded (red) text # _..._ and *...* render as word-individually underlined text # - LISTS # - Indent list items by 2 spaces for better plain-text viewing, but note # that the ROFF generated by marked-man still renders them unindented. # - End every list item (bullet point) itself with 2 trailing spaces too so # that it renders on its own line. # - Avoid associating more than 1 paragraph with a list item, if possible, # because it requires the following trick, which hampers plain-text readability: # Use ' ' in lieu of an empty line. #### : <<'EOF_MAN_PAGE' # shall(1) - cross-shell compatibility testing ## SYNOPSIS Cross-POSIX-compatible-shell testing: Run a script file: shall [-w ,...] [-q|-Q] [-p ]