Skip to content

Instantly share code, notes, and snippets.

@cheater
Last active February 25, 2024 01:10
Show Gist options
  • Save cheater/3304da6741186f623635a575a51c1cc8 to your computer and use it in GitHub Desktop.
Save cheater/3304da6741186f623635a575a51c1cc8 to your computer and use it in GitHub Desktop.
turn any command into a prompt
#!/bin/bash
# USAGE
#
# If you have to run the same program over and over with the last few arguments
# always changing, you can use this command like this:
#
# prompt myprog --arg1 -arg2 arg3 "this is arg 4"
#
# A prompt will show up which you can exit using Ctrl-D.
#
# You can add multiple arguments per each prompt line. Spaces always separate
# arguments unless you surround them by single or double quotes.
#
# Lines entered into the prompt will be stored into a history name derived from
# the supplied command complete with its arguments, and ending with the
# .history file extension. Lines that result in errors (non-zero exit codes)
# from the command will also be added to a file named the same, but which ends
# in .errors instead of ending in .history. The .errors file logs all errors,
# even for prompts that start with a space.
#
# You can call the program multiple times with one prompt line by using the
# semicolon (;). For example, this prompt:
#
# myprog -a> foo; bar
#
# will invoke myprog -a foo, then myprog -a bar, and then log "foo; bar" into
# the .history file. If either invokation of myprog returns an error, the whole
# prompt "foo; bar" will be logged to the .errors file. It does not matter that
# the part that contained "bar" starts with a space (located right after the
# semicolon). Whether or not a line gets logged to .history only depends on
# whether the whole prompt starts with a space.
#
# If you wish to use a literal semicolon, you have to escape it using a
# backslash, like so: foo\; bar.
#
# Semicolons are not observed within quotes (both single and double quotes).
#
# You can start comments with # and they will continue either until the end of
# line or until the next semicolon which is not escaped with a backslash and
# not within quotes.
#
# Please set the disallowed variable depending on whether you're on Linux,
# Windows, or some other operating system.
# Set characters not allowed within a file name on your platform. If you want
# to use a backslash (\), make sure to escape it (\\), because it'll be used by
# the tr command, which needs the backslash escaped.
disallowed='<>:"/\\|?' # for Windows.
if [[ "$(uname -s)" == "Linux" ]]; then
disallowed='/' # for Linux
fi
# If you're on another platform, set the variable here:
# disallowed='/'
# The file name will have disallowed characters replaced with underscores and
# will end in .history. Prompt lines that result in an error will also be added
# to a file that is named the same, but ends in .errors instead of .history.
this_histfile_base="$(printf '%s' "$*" | tr "$disallowed" _)"
this_histfile="$this_histfile_base"".history"
this_errorfile="$this_histfile_base"".errors"
# Is debugging turned on? This will print some additional testing information.
# Set to 1 to turn on, set to 0 to turn off.
dbg=0
# Save the args passed to this program in a new array that is globally
# accessible, also from within functions, as $@ is used not only for holding
# the arguments to the program but it also holds the arguments to a function:
declare -a prompt_args=("$@")
# This variable holds the information about whether the last command line has
# spawned a command that returned with an error (return value > 0)
error=0
# Helper function. I think this one isn't being used any more.
# Usage: join joinchar [arg ...]
join() {
local IFS="$1"
shift
printf '%s\n' "$*"
}
# The command line parser: split on separator but not within quotes or when
# escaped; allow comments that end on separator.
#
# The escape character is \, the separator is ;.
#
#
#
# States:
#
# normal
# comment (starts with # and goes on until separator (;) or EOL)
# after-separator (for skipping whitespace after ;)
# single-quoted (with ')
# double-quoted (with ")
# escape-on-first-character (for commands)
# escape-normal
# escape-single-quoted
# escape-double-quoted
#
#
#
# Allowed actions within states - transitions are not mentioned, they're
# documented further below - here we only mention actions that don't change the
# current state:
#
# normal:
# - encounter a normal non-space character and add it to current entry in the
# args array being built (the args array is the accumulator for the parser)
# - encounter a space and finish current entry in args array and start a new,
# empty one
#
# comment:
# - encounter any non-special character and skip it
#
# after-separator:
# - encounter a whitespace character and skip it
#
# single-quoted:
# - encounter any character, space or non-space, and add it to the current
# argument being built
#
# double-quoted:
# - encounter any character, space or non-space, and add it to the current
# argument being built
#
#
#
# Allowed state transitions:
#
# Trigger Transition
#
# # normal -> comment
# ; normal -> after-separator
# \ normal -> escape-on-first-character
# \ normal -> escape-normal
# ' normal -> single-quoted (set continuation to "normal")
# " normal -> double-quoted (set continuation to "normal")
#
# ; comment -> after-separator
# \ comment -> escape-comment
# ' comment -> single-quoted (set continuation to "comment")
# " comment -> double-quoted (set continuation to "comment")
#
# # after-separator -> comment
# ; after-separator -> after-separator
# \ after-separator -> escape-on-first-character
# ' after-separator -> singe-quoted (set continuation to "normal")
# " after-separator -> double-quoted (set continuation to "normal")
# any other non-whitespace after-separator -> normal
#
# \ single-quoted -> escape-single-quoted
# ' single-quoted -> normal/comment (depending on continuation)
#
# \ double-quoted -> escape-double-quoted
# " double-quoted -> normal/comment (depending on continuation)
#
# any escape-normal -> normal
#
# any escape-comment -> comment
#
# any escape-single-quoted -> single-quoted
#
# any escape-double-quoted -> double-quoted
#
# (at end of input) EOL - note this is not represented within the
# state loop, it's performed after the loop.
separator=';'
parser() {
local line="$1"
line_len=${#line}
state="normal"
declare -a args=()
current_arg=""
quoted_str=""
continuation=""
# Iterate over every character of the input line
for ((i = 0; i < line_len; i++)); do
char="${line:i:1}"
if [ "$dbg" -gt 0 ]; then
echo "------"
echo "i: $i"
echo "char: $char"
echo "state: $state"
echo "continuation: $continuation"
echo "current_arg: $current_arg"
echo "quoted_str: $quoted_str"
echo "args: ${args[@]}"
fi
case "$state" in
normal)
case "$char" in
'#') # -> comment
# We've encountered the start of a comment. Finish the current
# argument in the argument list (accumulator) and switch to comment
# state.
if [ -n "$current_arg" ]; then # if $current_arg is not an empty string
args+=("$current_arg")
current_arg=""
fi
state="comment"
;;
';') # -> after-separator
# We've found a separator in normal mode; finish off the current
# argument, run the command with the current args, empty the
# accumulator, and continue parsing the next subline.
if [ -n "$current_arg" ]; then # if $current_arg is not an empty string
args+=("$current_arg")
current_arg=""
fi
[ "$dbg" -gt 0 ] && echo "about to execute 1"
# Execute the command
if [ ${#args[@]} -gt 0 ]; then # if the args array is non-empty
"${prompt_args[@]}" "${args[@]}"
if [ "$?" -gt 0 ]; then
error=1
fi
printf '\n'
fi
args=()
state="after-separator"
;;
'\') # -> escape-normal
# We've found an escape character. The next character will not
# change modes.
state="escape-normal"
;;
"'") # -> single-quoted
# We've found a single quote
continuation="normal"
state="single-quoted"
;;
'"') # -> double-quoted
# We've found a double quote
continuation="normal"
state="double-quoted"
;;
' ')
# We've encountered a space. Finish the current arg and start a new
# one.
if [ -n "$current_arg" ]; then # if $current_arg is not an empty string
args+=("$current_arg")
current_arg=""
fi
;;
*)
# Any other normal char - just add it to the end of the
# accumulator.
current_arg="$current_arg""$char"
esac
;;
comment)
case "$char" in
';') # -> after-separator
# We've found a separator in comment mode; run the command with the
# current args, empty the accumulator, and continue parsing the
# next subline.
[ "$dbg" -gt 0 ] && echo "about to execute 2"
# Execute the command
if [ ${#args[@]} -gt 0 ]; then # if the args array is non-empty
"${prompt_args[@]}" "${args[@]}"
if [ "$?" -gt 0 ]; then
error=1
fi
printf '\n'
fi
args=()
state="after-separator"
;;
'\') # -> escape-comment
# We've found an escape character. The next character will not
# change modes.
state="escape-comment"
;;
"'") # -> single-quoted
# We've found a single quote
continuation="comment"
state="single-quoted"
;;
'"') # -> double-quoted
# We've found a double quote
continuation="comment"
state="double-quoted"
;;
*)
# Do nothing, just continue parsing.
esac
;;
after-separator)
case "$char" in
' ')
# We're skipping leading whitespace of a sub-line after the
# separator (;) has been parsed.
# Do nothing, just continue parsing.
;;
'#') # -> comment
current_arg=""
state="comment"
;;
';') # -> after-separator
# We've encountered another separator.
# Do nothing, just continue parsing.
;;
"'") # -> single-quoted
# We've found a single quote
continuation="normal"
state="single-quoted"
;;
'"') # -> double-quoted
# We've found a double quote
continuation="normal"
state="double-quoted"
;;
*) # -> normal
# We've found a non-space, non-special character. Change to normal
# state and start building an argument for the args array.
state="normal"
current_arg="$char" # Note there may not be any other input into
# the argument before the first normal
# character that's after a separator.
esac
;;
single-quoted)
# We're in single-quoted mode. We don't recognize the separator, only
# quotes and escaped quotes
case "$char" in
'\') # -> escape-single-quoted
state="escape-single-quoted"
;;
"'") # -> normal / comment (depending on continuation)
# We've found a single quote
case "$continuation" in
normal)
continuation=""
current_arg="$current_arg""$quoted_str"
quoted_str=""
state="normal"
;;
comment)
continuation=""
quoted_str=""
state="comment"
;;
*)
echo "Erroneous continuation at end of single-quoted string: $continuation; exiting." >&2
exit 128
esac
;;
*)
# Any other normal char - just add it to the end of the
# accumulator.
quoted_str="$quoted_str""$char"
esac
;;
double-quoted)
# We're in double-quoted mode. We don't recognize the separator, only
# quotes and escaped quotes
case "$char" in
'\') # -> escape-double-quoted
state="escape-double-quoted"
;;
'"') # -> normal / comment (depending on continuation)
# We've found a double quote
case "$continuation" in
normal)
continuation=""
current_arg="$current_arg""$quoted_str"
quoted_str=""
state="normal"
;;
comment)
continuation=""
quoted_str=""
state="comment"
;;
*)
echo "Erroneous continuation at end of double-quoted string: $continuation; exiting." >&2
exit 128
esac
;;
*)
# Any other normal char - just add it to the end of the
# accumulator.
quoted_str="$quoted_str""$char"
esac
;;
escape-normal)
case "$char" in
'#'|';'|'\'|"'"|'"') # -> normal
current_arg="$current_arg""$char"
state="normal"
;;
*) # -> normal
# Failed escape - the escape character wasn't followed by an
# escapable character, so treat them both literally.
current_arg="$current_arg"'\'"$char"
state="normal"
esac
;;
escape-comment)
# We're in comment mode, and the last char was an escape character.
case "$char" in
# We don't need to special case on escapable characters and
# non-escapable characters since it doens't matter - in either case,
# the characters aren't making it into any sort of string we're
# building up:
#
# '#'|';'|'\'|"'"|'"') # -> comment
# # Do nothing, just go back to the comment state.
# state="comment"
# ;;
*) # -> comment
# Do nothing, just go back to the comment state.
# In fact, we don't even need this case block, but we just included
# it so it loks the same as all the other code around it.
state="comment"
esac
;;
escape-single-quoted)
# We're in single-quoted mode, and the last char was an escape
# character (the backslash \).
case "$char" in
"'") # -> single-quoted
# We've found an escaped single quote - add it to the end of the
# accumulator.
quoted_str="$quoted_str""$char"
state="single-quoted"
;;
*) # -> single-quoted
# Failed escape - the escape character wasn't followed by an
# escapable character, so treat them both literally.
quoted_str="$quoted_str"'\'"$char"
state="single-quoted"
esac
;;
escape-double-quoted)
# We're in double-quoted mode, and the last char was an escape
# character (the backslash \).
case "$char" in
'"') # -> double-quoted
# We've found an escaped double quote - add it to the end of the
# accumulator.
quoted_str="$quoted_str""$char"
state="double-quoted"
;;
*) # -> double-quoted
# Failed escape - the escape character wasn't followed by an
# escapable character, so treat them both literally.
quoted_str="$quoted_str"'\'"$char"
state="double-quoted"
esac
;;
*)
echo "Erroneous Stage 1 Parser state: $state; exiting." >&2
exit 128
esac
done # We've finished parsing the whole line
args+=("$current_arg")
current_arg=""
[ "$dbg" -gt 0 ] && echo "about to execute 3"
# Execute the command
if [ ${#args[@]} -gt 0 ]; then # if the args array is non-empty
"${prompt_args[@]}" "${args[@]}"
if [ "$?" -gt 0 ]; then
error=1
fi
printf '\n'
fi
args=()
}
# Note: Starting bash 5.3, you will be able to do read -E, which is like
# read -e, but has history. However, bash 5.3 has not been released yet.
while IFS=';' read -r -e -p "$*> " line; do
# line="$(join ";" "${linearr[@]}")"
# echo "line: $line"
if [ -z "$line" ]; then
continue
fi
if ! [[ "${line:0:1}" == " " ]]; then # do not log lines that start with a space
printf '%s\n' "$line" >> "$this_histfile"
fi
error=0
parser "$line"
# Log prompts that cause errors
if [ "$error" -gt 0 ]; then
printf '%s\n' "$line" >> "$this_errorfile"
fi
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment