From 6129d9e954d05eeda6fbdc9ac9aa3c8fe82091e2 Mon Sep 17 00:00:00 2001 From: Denis Isidoro Date: Sat, 5 Oct 2019 14:08:00 -0300 Subject: [PATCH] Refactor string escaping code (#114) Fix #111 --- navi | 2 +- src/arg.sh | 35 +++++++++++++-------- src/cheat.sh | 37 +++++++++++------------ src/cmd.sh | 41 +++++++++++++++++++++++++ src/coll.sh | 4 +-- src/dict.sh | 27 ++++++----------- src/main.sh | 77 +++++++++++++++++------------------------------ src/misc.sh | 8 ++++- src/opts.sh | 8 ++--- src/str.sh | 8 +++++ src/ui.sh | 21 ++++--------- test/dict_test.sh | 8 +++-- 12 files changed, 151 insertions(+), 125 deletions(-) create mode 100644 src/cmd.sh diff --git a/navi b/navi index ec77421..f5d8af9 100755 --- a/navi +++ b/navi @@ -37,7 +37,7 @@ source "${SCRIPT_DIR}/src/main.sh" ##? full docs ##? Please refer to the README at https://github.com/denisidoro/navi -VERSION="0.11.1" +VERSION="0.12.0" NAVI_ENV="${NAVI_ENV:-prod}" opts::eval "$@" diff --git a/src/arg.sh b/src/arg.sh index 7c7d513..cebc8f3 100644 --- a/src/arg.sh +++ b/src/arg.sh @@ -1,13 +1,9 @@ #!/usr/bin/env bash -ARG_REGEX_WITHOUT_BRACKETS="[a-zA-Z_]+([- ]?\w+)*" -ARG_REGEX="<${ARG_REGEX_WITHOUT_BRACKETS}>" -ARG_DELIMITER="\f" -ARG_DELIMITER_2="\v" -ARG_DELIMITER_3="\r" +ARG_REGEX="<[a-zA-Z_]+([- ]?\w+)*>" arg::dict() { - local -r input="$(cat | sed 's/\\n/\\f/g')" + local -r input="$(cat)" local -r fn="$(echo "$input" | awk -F'---' '{print $1}')" local -r opts="$(echo "$input" | awk -F'---' '{print $2}')" @@ -15,6 +11,12 @@ arg::dict() { dict::new fn "$fn" opts "$opts" } +arg::escape() { + echo "$*" \ + | tr '-' '_' \ + | tr ' ' '_' +} + arg::interpolate() { local -r arg="$1" local -r value="$2" @@ -40,12 +42,19 @@ arg::deserialize() { arg="${arg:1:${#arg}-2}" echo "$arg" \ - | tr "${ARG_DELIMITER}" " " \ - | tr "${ARG_DELIMITER_2}" "'" \ - | tr "${ARG_DELIMITER_3}" '"' + | tr "${ESCAPE_CHAR}" " " \ + | tr "${ESCAPE_CHAR_2}" "'" \ + | tr "${ESCAPE_CHAR_3}" '"' +} + +arg::serialize_code() { + printf "tr ' ' '${ESCAPE_CHAR}'" + printf " | " + printf "tr \"'\" '${ESCAPE_CHAR_2}'" + printf " | " + printf "tr '\"' '${ESCAPE_CHAR_3}'" } -# TODO: separation of concerns arg::pick() { local -r arg="$1" local -r cheat="$2" @@ -54,7 +63,7 @@ arg::pick() { local -r length="$(echo "$prefix" | str::length)" local -r arg_dict="$(echo "$cheat" | grep "$prefix" | str::sub $((length + 1)) | arg::dict)" - local -r fn="$(dict::get "$arg_dict" fn | sed 's/\\f/\\n/g')" + local -r fn="$(dict::get "$arg_dict" fn)" local -r args_str="$(dict::get "$arg_dict" opts)" local arg_name="" @@ -70,10 +79,10 @@ arg::pick() { if [ -n "$fn" ]; then local suggestions="$(eval "$fn" 2>/dev/null)" if [ -n "$suggestions" ]; then - echo "$suggestions" | ui::pick --prompt "$arg: " --header-lines "${headers:-0}" | str::column "${column:-}" + echo "$suggestions" | ui::fzf --prompt "$arg: " --header-lines "${headers:-0}" | str::column "${column:-}" fi elif ${NAVI_USE_FZF_ALL_INPUTS:-false}; then - echo "" | ui::pick --prompt "$arg: " --print-query --no-select-1 --height 1 + echo "" | ui::fzf --prompt "$arg: " --print-query --no-select-1 --height 1 else printf "\033[0;36m${arg}:\033[0;0m " > /dev/tty read -r value diff --git a/src/cheat.sh b/src/cheat.sh index 53fb17c..f9f1f4d 100755 --- a/src/cheat.sh +++ b/src/cheat.sh @@ -6,25 +6,26 @@ cheat::find() { done } -cheat::_join_multiline_using_sed() { - tr '\n' '\f' \ - | sed -E 's/\\\f *//g' \ - | tr '\f' '\n' +cheat::export_cache() { + if [ -z "${NAVI_CACHE:-}" ]; then + export NAVI_CACHE="$*" + fi } -cheat::_join_multiline() { - if ${NAVI_USE_PERL:-false}; then - perl -0pe 's/\\\n *//g' \ - || cheat::_join_multiline_using_sed +cheat::join_lines() { + if command_exists perl; then + perl -0pe 's/\\\n *//g' else - cheat::_join_multiline_using_sed + tr '\n' "$ESCAPE_CHAR" \ + | sed -E 's/\\'$(printf "$ESCAPE_CHAR")' *//g' \ + | tr "$ESCAPE_CHAR" '\n' fi } cheat::read_all() { for cheat in $(cheat::find); do echo - cat "$cheat" | cheat::_join_multiline + cat "$cheat" echo done } @@ -34,16 +35,13 @@ cheat::memoized_read_all() { echo "$NAVI_CACHE" return fi - if command_exists perl; then - export NAVI_USE_PERL=true - else - export NAVI_USE_PERL=false - fi + local -r cheats="$(cheat::read_all)" - echo "$cheats" + echo "$cheats" \ + | cheat::join_lines } -cheat::pretty() { +cheat::prettify() { awk 'function color(c,s) { printf("\033[%dm%s\033[0m",30+c,s) } @@ -54,7 +52,7 @@ cheat::pretty() { NF { print color(7, $0) color(60, tags); next }' } -cheat::_until_percentage() { +cheat::until_percentage() { awk 'BEGIN { count=0; } /^%/ { if (count >= 1) exit; @@ -70,6 +68,5 @@ cheat::from_selection() { echo "$cheats" \ | grep "% ${tags}" -A99999 \ - | cheat::_until_percentage \ - || (echoerr "No valid cheatsheet!"; exit 67) + | cheat::until_percentage } diff --git a/src/cmd.sh b/src/cmd.sh new file mode 100644 index 0000000..8e74282 --- /dev/null +++ b/src/cmd.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +cmd::loop() { + local -r cmd="$1" + local -r cheat="$2" + + local arg escaped_arg value escaped_cmd + + arg="$(echo "$cmd" | arg::next)" + if [ -z "$arg" ]; then + dict::new cmd "$cmd" + return + fi + + escaped_arg="$(arg::escape "$arg")" + + escaped_cmd="$(echo "$cmd" | sed "s|<${arg}>|<${escaped_arg}>|g")" + arg="$escaped_arg" + + local -r values="$(dict::get "$OPTIONS" values)" + value="$(echo "$values" | coll::get $i)" + [ -z "$value" ] && value="$(arg::pick "$arg" "$cheat")" + + dict::new \ + cmd "${escaped_cmd:-}" \ + value "$value" \ + arg "$arg" +} + +cmd::finish() { + local -r cmd="$1" + + local -r unresolved_arg="$(echo "$cmd" | arg::next)" + + local -r print="$(dict::get "$OPTIONS" print)" + if $print || [ -n "$unresolved_arg" ]; then + echo "$cmd" + else + eval "$cmd" + fi +} \ No newline at end of file diff --git a/src/coll.sh b/src/coll.sh index 58240eb..af2071e 100644 --- a/src/coll.sh +++ b/src/coll.sh @@ -38,7 +38,7 @@ coll::remove() { done } -coll::_without_empty_line() { +coll::without_empty_line() { local -r input="$(cat)" local -r words="$(echo "$input" | wc -w | xargs)" if [[ $words > 0 ]]; then @@ -47,7 +47,7 @@ coll::_without_empty_line() { } coll::add() { - cat | coll::_without_empty_line + cat | coll::without_empty_line for x in "$@"; do echo "$x" done diff --git a/src/dict.sh b/src/dict.sh index 5492504..df5f6ab 100644 --- a/src/dict.sh +++ b/src/dict.sh @@ -7,13 +7,11 @@ # values with non-trivial whitespaces (newlines, subsequent spaces, etc) # aren't handled very well -DICT_DELIMITER='\f' - dict::new() { if [ $# = 0 ]; then echo "" else - echo "" | dict::assoc "$@" | sed '/^$/d' + echo "" | dict::assoc "$@" | str::remove_empty_lines fi } @@ -23,17 +21,17 @@ dict::dissoc() { grep -Ev "^[\s]*${key}[^:]*:" } -dict::_escape_value() { - tr '\n' "$DICT_DELIMITER" | sed "s/\\n/${DICT_DELIMITER}/g" +dict::escape_value() { + tr '\n' "$ESCAPE_CHAR" | sed 's/\\n/'$(printf "$ESCAPE_CHAR")'/g' } -str::_without_trailing_newline() { +str::without_trailing_newline() { printf "%s" "$(cat)" echo } -dict::_unescape_value() { - tr "$DICT_DELIMITER" '\n' | str::_without_trailing_newline +dict::unescape_value() { + tr "$ESCAPE_CHAR" '\n' | str::without_trailing_newline } dict::assoc() { @@ -41,11 +39,11 @@ dict::assoc() { local -r input="$(cat)" if [ -z $key ]; then - printf "$(echo "$input" | tr '%' '\v')" | tr '\v' '%' + printf "$(echo "$input" | tr '%' "$ESCAPE_CHAR_2")" | tr "$ESCAPE_CHAR_2" '%' return fi - local -r value="$(echo "${2:-}" | dict::_escape_value)" + local -r value="$(echo "${2:-}" | dict::escape_value)" shift 2 echo "$(echo "$input" | dict::dissoc "$key")${key}: ${value}\n" | dict::assoc "$@" @@ -65,9 +63,9 @@ dict::get() { local -r matches="$(echo "$result" | wc -l || echo 0)" if [ $matches -gt 1 ]; then - echo "$result" | dict::_unescape_value + echo "$result" | dict::unescape_value else - echo "$result" | sed -E "s/${prefix}//" | dict::_unescape_value + echo "$result" | sed -E "s/${prefix}//" | dict::unescape_value fi } @@ -81,11 +79,6 @@ dict::values() { | cut -c3- } -dict::merge() { - awk -F':' '{$1=""; print $0}' \ - | cut -c3- -} - dict::zipmap() { IFS='\n' diff --git a/src/main.sh b/src/main.sh index 620558a..dfc9576 100644 --- a/src/main.sh +++ b/src/main.sh @@ -6,6 +6,7 @@ fi source "${SCRIPT_DIR}/src/arg.sh" source "${SCRIPT_DIR}/src/cheat.sh" +source "${SCRIPT_DIR}/src/cmd.sh" source "${SCRIPT_DIR}/src/coll.sh" source "${SCRIPT_DIR}/src/dict.sh" source "${SCRIPT_DIR}/src/health.sh" @@ -18,44 +19,25 @@ source "${SCRIPT_DIR}/src/ui.sh" handler::main() { local -r cheats="$(cheat::memoized_read_all)" - - if [ -z "${NAVI_CACHE:-}" ]; then - export NAVI_CACHE="$cheats" - fi - + cheat::export_cache "$cheats" local -r selection="$(ui::select "$cheats")" local -r cheat="$(cheat::from_selection "$cheats" "$selection")" - - [ -z "$cheat" ] && exit 67 + [ -z "$cheat" ] && die "No valid cheatsheet!" local -r interpolation="$(dict::get "$OPTIONS" interpolation)" - local cmd="$(selection::cmd "$selection" "$cheat")" - local arg value - local -r args="$(dict::get "$OPTIONS" args)" + local cmd="$(selection::cmd "$selection" "$cheat")" + local result arg value local i=0 while $interpolation; do - arg="$(echo "$cmd" | arg::next || echo "")" - if [ -z "$arg" ]; then - break - fi + result="$(cmd::loop "$cmd" "$cheat")" + arg="$(dict::get "$result" arg)" + value="$(dict::get "$result" value)" + cmd="$(dict::get "$result" cmd)" - escaped_arg="$(echo "$arg" | tr '-' '_' | tr ' ' '_')" - if ! [[ $escaped_arg =~ $ARG_REGEX_WITHOUT_BRACKETS ]]; then - exit 1 - fi - - cmd="$(echo "$cmd" | sed "s|<${arg}>|<${escaped_arg}>|g")" - arg="$escaped_arg" - - value="$(echo "$args" | coll::get $i)" - [ -z "$value" ] && value="$(arg::pick "$arg" "$cheat")" - - if [ -z "$value" ]; then - echoerr "Unable to fetch suggestions for '$arg'!" - exit 1 - fi + [ -z "$arg" ] && break + [ -z "$value" ] && die "Unable to fetch suggestions for '$arg'!" eval "local $arg"='$value' cmd="$(echo "$cmd" | arg::interpolate "$arg" "$value")" @@ -63,14 +45,7 @@ handler::main() { i=$((i+1)) done - local -r unresolved_arg="$(echo "$cmd" | arg::next || echo "")" - - local -r print="$(dict::get "$OPTIONS" print)" - if $print || [ -n "$unresolved_arg" ]; then - echo "$cmd" - else - eval "$cmd" - fi + cmd::finish "$cmd" } handler::preview() { @@ -82,7 +57,7 @@ handler::preview() { } handler::help() { - echo "$TEXT" + opts::extract_help "$0" } handler::version() { @@ -92,7 +67,8 @@ handler::version() { if $full; then source "${SCRIPT_DIR}/src/version.sh" - version::code 2>/dev/null || echo "unknown code" + version::code 2>/dev/null \ + || die "unknown code" fi } @@ -106,18 +82,23 @@ handler::home() { handler::widget() { local widget + local -r print="$(dict::get "$OPTIONS" print)" case "$SH" in zsh) widget="${SCRIPT_DIR}/navi.plugin.zsh" ;; bash) widget="${SCRIPT_DIR}/navi.plugin.bash" ;; - *) echoerr "Invalid shell: $SH"; exit 1 ;; + *) die "Invalid shell: $SH" ;; esac - if "$(dict::get "$OPTIONS" print)"; then - cat "$widget" - else - echo "$widget" - fi + $print \ + && cat "$widget" \ + || echo "$widget" +} + +handler::search() { + local -r query="$(dict::get "$OPTIONS" query)" + search::save "$query" || true + handler::main } main() { @@ -125,13 +106,11 @@ main() { preview) local -r query="$(dict::get "$OPTIONS" query)" handler::preview "$query" \ - || echo "Unable to find command for '$query'" + || echoerr "Unable to find command for '$query'" ;; search) health::fzf - local -r query="$(dict::get "$OPTIONS" query)" - search::save "$query" || true - handler::main + handler::search ;; version) handler::version false diff --git a/src/misc.sh b/src/misc.sh index c1b8524..4acdce8 100644 --- a/src/misc.sh +++ b/src/misc.sh @@ -10,6 +10,7 @@ command_exists() { } platform::existing_command() { + local cmd for cmd in "$@"; do if command_exists "$cmd"; then echo "$cmd" @@ -25,11 +26,16 @@ echoerr() { url::open() { local -r cmd="$(platform::existing_command "${BROWSER:-}" xdg-open open google-chrome firefox)" - "$cmd" "$@" + "$cmd" "$@" & disown } tap() { local -r input="$(cat)" echoerr "$input" echo "$input" +} + +die() { + echoerr "$@" + exit 42 } \ No newline at end of file diff --git a/src/opts.sh b/src/opts.sh index e1cba28..ab9666c 100644 --- a/src/opts.sh +++ b/src/opts.sh @@ -16,12 +16,12 @@ opts::eval() { local autoselect=true local best=false local query="" - local args="" + local values="" case "${1:-}" in --version|version) entry_point="version"; shift ;; --full-version|full-version) entry_point="full-version"; shift ;; - --help|help) entry_point="help"; TEXT="$(opts::extract_help "$0")"; shift ;; + --help|help) entry_point="help"; shift ;; search) entry_point="search"; wait_for="search"; shift ;; preview) entry_point="preview"; wait_for="preview"; shift ;; query|q) wait_for="query"; shift ;; @@ -46,7 +46,7 @@ opts::eval() { --no-preview) preview=false ;; --path|--dir) wait_for="path" ;; --no-autoselect) autoselect=false ;; - *) args="$(echo "$args" | coll::add "$arg")" ;; + *) values="$(echo "$values" | coll::add "$arg")" ;; esac done @@ -58,7 +58,7 @@ opts::eval() { autoselect "$autoselect" \ query "$query" \ best "$best" \ - args "$args")" + values "$values")" export NAVI_PATH="$path" } diff --git a/src/str.sh b/src/str.sh index e7e9a4f..abd50d1 100644 --- a/src/str.sh +++ b/src/str.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash +ESCAPE_CHAR="\034" +ESCAPE_CHAR_2="\035" +ESCAPE_CHAR_3="\036" + str::length() { awk '{print length}' } @@ -54,4 +58,8 @@ str::not_empty() { else return 1 fi +} + +str::remove_empty_lines() { + sed '/^$/d' } \ No newline at end of file diff --git a/src/ui.sh b/src/ui.sh index 3f7fc48..98f61d6 100644 --- a/src/ui.sh +++ b/src/ui.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -ui::pick() { +ui::fzf() { local -r autoselect="$(dict::get "$OPTIONS" autoselect)" - declare -a args + local args args+=("--height") args+=("100%") if ${autoselect:-false}; then @@ -14,12 +14,11 @@ ui::pick() { "$fzf_cmd" "${args[@]:-}" --inline-info "$@" } -# TODO: separation of concerns ui::select() { local -r cheats="$1" local -r script_path="${SCRIPT_DIR}/navi" - local -r preview_cmd="echo \'{}\' | tr \"'\" '${ARG_DELIMITER_2}' | tr ' ' '${ARG_DELIMITER}' | tr '\"' '${ARG_DELIMITER_3}' | xargs -I% \"${script_path}\" preview %" + local -r preview_cmd="echo \'{}\' | $(arg::serialize_code) | xargs -I% \"${script_path}\" preview %" local -r query="$(dict::get "$OPTIONS" query)" local -r entry_point="$(dict::get "$OPTIONS" entry_point)" @@ -42,18 +41,10 @@ ui::select() { args+=("--header"); args+=("Displaying online results. Please refer to 'navi --help' for details") fi - ui::_select_post() { - if $best; then - head -n1 - else - cat - fi - } - echo "$cheats" \ - | cheat::pretty \ - | ui::pick "${args[@]}" \ - | ui::_select_post \ + | cheat::prettify \ + | ui::fzf "${args[@]}" \ + | ($best && head -n1 || cat) \ | selection::dict } diff --git a/test/dict_test.sh b/test/dict_test.sh index c5a3311..f310bec 100644 --- a/test/dict_test.sh +++ b/test/dict_test.sh @@ -6,8 +6,8 @@ inc() { } test::map_equals() { - local -r actual="$(cat | dict::_unescape_value | sort)" - local -r expected="$(dict::new "$@" | dict::_unescape_value | sort)" + local -r actual="$(cat | dict::unescape_value | sort)" + local -r expected="$(dict::new "$@" | dict::unescape_value | sort)" echo "$actual" | test::equals "$expected" } @@ -15,7 +15,9 @@ test::map_equals() { dict_assoc() { dict::new \ | dict::assoc "foo" "42" \ - | tr -d '\f' \ + | tr -d "$ESCAPE_CHAR" \ + | tr -d "$ESCAPE_CHAR_2" \ + | tr -d "$ESCAPE_CHAR_3" \ | test::equals "foo: 42" }