diff --git a/gh-find-code b/gh-find-code index 830ec9b..5a408fe 100755 --- a/gh-find-code +++ b/gh-find-code @@ -1,21 +1,82 @@ #!/usr/bin/env bash -set -o allexport -o errexit -o nounset -o pipefail +set -o allexport -o errexit -o errtrace -o nounset -o pipefail # https://www.gnu.org/software/bash/manual/bash.html#The-Set-Builtin -# ====================== TODO ======================= +# TODO: lookout for Github adding regex support to the REST or GraphQL API +# TODO: add tests - https://github.com/dodie/testing-in-bash +# TODO: replace python with a more typical bash solution -# There is no regex support currently for the REST API, look out if there are changes or -# maybe the GraphQL API gets the ability to search code. Currently only ISSUE, REPOSITORY, -# USER and DISCUSSION are supported. +############################################################################### +# Debugging and Error Handling Configuration +############################################################################### + +GHFC_DEBUG_MODE=${GHFC_DEBUG_MODE:-0} +if ((GHFC_DEBUG_MODE)); then + debug_directory=$(command mktemp -d) + store_all_debug="${debug_directory}/all_debug" + store_gh_api_debug="${debug_directory}/gh_api_debug" + store_gh_search_debug="${debug_directory}/gh_search_debug" + store_grep_extended_debug="${debug_directory}/grep_extended_debug" + + # Redirect stdout and stderr to both the terminal and the debug file. + exec &> >(command tee -a "$store_all_debug") + + # 'GH_DEBUG' is useful for understanding the reasons behind failed GitHub API calls. + export GH_DEBUG=api + + # Redirect xtrace output to a file. + exec 6>>"$store_all_debug" + # Write the trace output to file descriptor 6. + BASH_XTRACEFD=6 + + # Use a more detailed execution trace prompt. + PS4='+$(date +%Y-%m-%d:%H:%M:%S) ${FUNCNAME[0]:-}:L${LINENO:-}: ' + set -o xtrace + # Ensure xtrace is enabled in all child processes started by 'fzf'. + execution_shell="$(which bash) -o xtrace -c" +fi -# Check supported search types in the GraphQL API: gh api graphql --raw-field query=$'{ -# __type(name: "SearchType") { enumValues { name } }}' \ -# --jq '[.data.__type.enumValues[].name] | join(", ")' +die() { + echo ERROR: "$*" >&2 + exit 1 +} -# TODO: add tests - https://github.com/dodie/testing-in-bash -# TODO: replace python with a more typical bash solution +BAT_THEME=${BAT_THEME:-Monokai Extended} +bat_executable="" +# Check for 'bat' early, as it is needed for the error_handler function. +for value in bat batcat; do + if command -v $value >/dev/null; then + bat_executable="$value" + break + fi +done +builtin unset value +[[ -z $bat_executable ]] && die "'bat' was not found." -# ====================== set variables ======================= +# Enable 'errtrace' to ensure the ERR trap is inherited by functions, command substitutions and +# commands executed in a subshell environment. +error_handler() { + local lineno=$1 msg=$2 exit_code="${3:-1}" + { + echo + echo "ERROR TRACE: ${BASH_SOURCE[0]##*/}:$lineno command '$msg' exited with status $exit_code" + command "$bat_executable" \ + --color always \ + --highlight-line "$lineno" \ + --language bash \ + --line-range $((lineno - 3)):+7 \ + --style numbers \ + --terminal-width $((${COLUMNS:-$(tput cols)} - 4)) "${BASH_SOURCE[0]}" \ + --wrap never | + command sed 's/^/ /;4s/ />>/' + } >&2 + exit "$exit_code" +} +trap 'error_handler $LINENO "$BASH_COMMAND" $?' ERR + +############################################################################### +# Set Variables +############################################################################### # define colors COLOR_RESET='\033[0m' @@ -31,22 +92,19 @@ WHITE_NORMAL='\033[0;97m' WHITE_BOLD='\033[1;97m' DARK_GRAY='\033[0;90m' -debug_mode=false +FZF_API_KEY=$(command head -c 32 /dev/urandom | command base64) +GHFC_HISTORY_LIMIT=${GHFC_HISTORY_LIMIT:-500} +GHFC_HISTORY_FILE=${GHFC_HISTORY_FILE:-${BASH_SOURCE%/*}/gh_find_code_history.txt} + open_in_editor=false # Note: Using prompts of the same character length helps maintain user focus by avoiding shifts in # the prompt's position. End the string with a color code, such as '%b', to preserve trailing # whitespace during 'transform' actions. -default_fzf_prompt="$(printf "%b❮❯ Code: %b" "$CYAN_NORMAL" "$COLOR_RESET")" -fzf_prompt_failure="$(printf "%b!! Fail: %b" "$RED_NORMAL" "$COLOR_RESET")" -fzf_prompt_fuzzyAB="$(printf "%b➤ Fuzzy:%b %b" "$CYAN_INVERT" "$CYAN_NORMAL" "$COLOR_RESET")" -fzf_prompt_helpABC="$(printf "%b?? Help: %b" "$CYAN_NORMAL" "$COLOR_RESET")" +default_fzf_prompt=$(printf "%b❮❯ Code: %b" "$CYAN_NORMAL" "$COLOR_RESET") +fzf_prompt_failure=$(printf "%b!! Fail: %b" "$RED_NORMAL" "$COLOR_RESET") +fzf_prompt_fuzzyAB=$(printf "%b➤ Fuzzy:%b %b" "$CYAN_INVERT" "$CYAN_NORMAL" "$COLOR_RESET") +fzf_prompt_helpABC=$(printf "%b?? Help: %b" "$CYAN_NORMAL" "$COLOR_RESET") -FZF_API_KEY="$(command head -c 32 /dev/urandom | command base64)" -BAT_THEME=${BAT_THEME:-Monokai Extended} -MAX_LINES_HISTORY=${MAX_LINES_HISTORY:-500} -# Bash is required to use exported functions when the default shell is not bash. -SHELL=$(which bash) -gh_find_code_history="${BASH_SOURCE%/*}/gh_find_code_history.txt" # A cached version will be used before a new one is pulled. gh_default_cache_time="1h" gh_default_limit=30 @@ -55,12 +113,12 @@ gh_accept_json="Accept: application/vnd.github+json" gh_accept_raw="Accept: application/vnd.github.raw" gh_accept_text_match="Accept: application/vnd.github.text-match+json" gh_rest_api_version="X-GitHub-Api-Version:2022-11-28" + # https://github.com/junegunn/fzf/releases/tag/0.52.0 min_fzf_version="0.52.0" # a bug with 'gh-browse' with relative paths was fixed # https://github.com/cli/cli/issues/7674 min_gh_version="2.37.0" -bat_executable="" # requires 'urllib.parse' # https://docs.python.org/3/library/urllib.parse.html min_python_version="3.0.0" @@ -71,27 +129,24 @@ python_executable="" # Default directory for trivial files scratch_directory=$(command mktemp -d) +store_bat_langs="${scratch_directory}/bat_langs" store_input_list="${scratch_directory}/input_list" store_tee_append="${scratch_directory}/tee_append" store_file_contents="${scratch_directory}/file_contents" store_skip_count="${scratch_directory}/skip_count" -store_fzf_ppids="${scratch_directory}/fzf_ppids" store_query_pids="${scratch_directory}/query_pids" store_fuzzy_search_string="${scratch_directory}/fuzzy_search_string" store_search_string="${scratch_directory}/search_string" -store_gh_find_code_history_tmp="${scratch_directory}/gh_find_code_history_tmp" +store_history_tmp="${scratch_directory}/history_tmp" +store_gh_api_error="${scratch_directory}/gh_api_error" +store_gh_search_error="${scratch_directory}/gh_search_error" store_hold_gh_query_loop="${scratch_directory}/hold_gh_query_loop" store_last_query_signature="${scratch_directory}/last_search_setup" store_current_header="${scratch_directory}/current_header" -# Debug directory -debug_directory=$(command mktemp -d) -store_bat_debug="${debug_directory}/bat_debug" -store_grep_extended_debug="${debug_directory}/grep_extended_debug" -store_gh_search_debug="${debug_directory}/gh_search_debug" -store_gh_content_debug="${debug_directory}/gh_content_debug" - -# =========================== basics ============================ +############################################################################### +# Cleanup Functions +############################################################################### # Terminates processes whose IDs are stored in the given file and empty the file kill_processes() { @@ -117,14 +172,10 @@ kill_processes() { } cleanup() { - kill_processes "$store_fzf_ppids" kill_processes "$store_query_pids" - - $debug_mode && printf "%bDebug mode was ON, the following files weren't deleted.%b\n" "$YELLOW_NORMAL" "$COLOR_RESET" command rm -rf "$scratch_directory" 2>/dev/null - if ! $debug_mode; then - command rm -rf "$debug_directory" 2>/dev/null - else + if ((GHFC_DEBUG_MODE)); then + printf "%bDebug mode was active. The following files have not been deleted:%b\n" "$YELLOW_NORMAL" "$COLOR_RESET" find "$debug_directory" -mindepth 1 2>/dev/null | while read -r matching_file; do if [[ ! -s $matching_file ]]; then command rm -f "$matching_file" @@ -135,13 +186,12 @@ cleanup() { fi } -die() { - echo ERROR: "$*" >&2 - exit 1 -} - trap cleanup EXIT SIGHUP SIGINT +############################################################################### +# Helper Functions +############################################################################### + # This function validates the version of a tool. check_version() { local tool=$1 threshold=$2 on_error=${3:-die} @@ -153,7 +203,6 @@ check_version() { IFS='.' read -ra ver_parts <<<"$user_version" IFS='.' read -ra threshold_parts <<<"$threshold" - for i in "${!threshold_parts[@]}"; do if ((i >= ${#ver_parts[@]})) || ((ver_parts[i] < threshold_parts[i])); then $on_error "Your '$tool' version '$user_version' is insufficient. The minimum required version is '$threshold'." @@ -163,6 +212,82 @@ check_version() { done } +validate_environment() { + local value + if ((GHFC_DEBUG_MODE)); then + default_fzf_prompt="$(printf "%b❮ 𝙳𝚎𝚋𝚞𝚐 𝙼𝚘𝚍𝚎 ❯ Code: %b" "$YELLOW_NORMAL" "$COLOR_RESET")" + fi + + # Collect all 'bat' language extensions once to avoid repetitive calls within the loop. + command "$bat_executable" --list-languages --color=never | + command awk -F ":" '{print $2}' | command tr ',' '\n' >"$store_bat_langs" + + # Rule of Thumb: If it's listed under 'Utilities' in this link, don't check for it + # https://pubs.opengroup.org/onlinepubs/9699919799/utilities/contents.html + for value in column curl fzf gh; do + if ! command -v $value >/dev/null; then + die "'$value' was not found." + fi + done + check_version fzf "$min_fzf_version" + check_version gh "$min_gh_version" + + # Iterate over the possible python versions and assign python_executable + for value in python python3; do + if command -v $value >/dev/null && + [[ -z $(check_version "$value" "$min_python_version" echo) ]]; then + python_executable="$value" + break + fi + done + # If no suitable python version was found, terminate the script + [[ -z $python_executable ]] && die "No suitable 'python' version found." + + # Verify if there are at least two spaces between columns. The delimiter in 'fzf' is set to + # '\t' or '\s\s+' to separate fields. By default, the 'column' command should separate any + # columns with two spaces. If this is not the case, you cannot proceed. It appears that + # older versions, e.g. BSD 1997, of 'column' had a default of two spaces. + # https://github.com/freebsd/freebsd-src/blob/0da30e9aa/usr.bin/column/column.c#L245 + # Newer versions allow the output-separator to be defined. + # https://github.com/util-linux/util-linux/commit/47bd8ddc + # https://github.com/util-linux/util-linux/issues/1699#issuecomment-1140972918 + # https://man7.org/linux/man-pages/man1/column.1.html + if [[ $(command column -t <<<"A Z" 2>/dev/null) != *" "* ]]; then + die "Your 'column' command does not separate columns with at least two spaces. Please report this issue, stating your operating system and 'column' version." + fi + + # Check if GHFC_HISTORY_LIMIT is a number + if ! [[ $GHFC_HISTORY_LIMIT =~ ^[0-9]+$ ]]; then + die "GHFC_HISTORY_LIMIT must be a number." + fi + + # Check if the necessary history file exists and is readable and writable + if ((GHFC_HISTORY_LIMIT)); then + if [[ -d $GHFC_HISTORY_FILE ]]; then + die "$GHFC_HISTORY_FILE is a directory" + fi + + if [[ ! -f $GHFC_HISTORY_FILE ]]; then + command mkdir -p "$(command dirname "${GHFC_HISTORY_FILE}")" + if command touch "$GHFC_HISTORY_FILE"; then + echo "History file successfully created at: $GHFC_HISTORY_FILE" + else + die "Unable to create: $GHFC_HISTORY_FILE" + fi + fi + [[ -r $GHFC_HISTORY_FILE ]] || die "Permission denied: unable to read from: $GHFC_HISTORY_FILE" + [[ -w $GHFC_HISTORY_FILE ]] || die "Permission denied: unable to write to: $GHFC_HISTORY_FILE" + + # Add some examples if the history file is empty. + if [[ ! -s $GHFC_HISTORY_FILE ]]; then + command cat <<'EOF' >"$GHFC_HISTORY_FILE" +repo:junegunn/fzf FZF_PORT +extension:rs "Hello, world!" +EOF + fi + fi +} + # IMPORTANT: Keep it in sync with the readme.md print_help_text() { local help_text @@ -174,7 +299,6 @@ ${WHITE_BOLD}Usage${COLOR_RESET} gh find-code [Flags] [Search query] ${WHITE_BOLD}Flags${COLOR_RESET} - ${GREEN_NORMAL}-d${COLOR_RESET} debug mode, primarily used for identifying and resolving bugs ${GREEN_NORMAL}-l${COLOR_RESET} limit the number of listed results (default ${gh_default_limit}, max 100) ${GREEN_NORMAL}-h${COLOR_RESET} help @@ -204,89 +328,7 @@ EOF echo -e "$help_text" } -# ====================== parse command-line options ======================= - -while getopts ":dhl:" value; do - case $value in - d) - default_fzf_prompt="$(printf "%b❮ 𝙳𝚎𝚋𝚞𝚐 𝙼𝚘𝚍𝚎 ❯ Code: %b" "$YELLOW_NORMAL" "$COLOR_RESET")" - export GH_DEBUG="api" - debug_mode="true" - ;; - l) - if ! [[ $OPTARG =~ ^[0-9]+$ ]]; then - die "Value is not a valid number: '$OPTARG'" - fi - if ((OPTARG < 1 || OPTARG > 100)); then - die "Value for '-l' must be between 1 and 100" - fi - gh_user_limit="${OPTARG}" - ;; - h) - print_help_text - exit 0 - ;; - *) die "Invalid Option: -${OPTARG-}" ;; - esac -done -shift "$((OPTIND - 1))" - -# ====================== check requirements ======================= - -# Rule of Thumb: If it's listed under 'Utilities' in this link, don't check for it -# https://pubs.opengroup.org/onlinepubs/9699919799/utilities/contents.html -for value in column curl fzf gh; do - if ! command -v $value >/dev/null; then - die "'$value' was not found." - fi -done -check_version fzf "$min_fzf_version" -check_version gh "$min_gh_version" - -# Iterate over the possible bat names and assign bat_executable -for value in bat batcat; do - if command -v $value >/dev/null; then - bat_executable="$value" - break - fi -done -[[ -z $bat_executable ]] && die "'bat' was not found." - -# Iterate over the possible python versions and assign python_executable -for value in python python3; do - if command -v $value >/dev/null && - [[ -z $(check_version "$value" "$min_python_version" echo) ]]; then - python_executable="$value" - break - fi -done -# If no suitable python version was found, terminate the script -[[ -z $python_executable ]] && die "No suitable 'python' version found." - -# Verify if there are at least two spaces between columns. The delimiter in 'fzf' is set to -# '\t' or '\s\s+' to separate fields. By default, the 'column' command should separate any -# columns with two spaces. If this is not the case, you cannot proceed. It appears that -# older versions, e.g. BSD 1997, of 'column' had a default of two spaces. -# https://github.com/freebsd/freebsd-src/blob/0da30e9aa/usr.bin/column/column.c#L245 -# Newer versions allow the output-separator to be defined. -# https://github.com/util-linux/util-linux/commit/47bd8ddc -# https://github.com/util-linux/util-linux/issues/1699#issuecomment-1140972918 -# https://man7.org/linux/man-pages/man1/column.1.html -if [[ $(command column -t <<<"A Z" 2>/dev/null) != *" "* ]]; then - die "Your 'column' command does not separate columns with at least two spaces. Please report this issue, stating your operating system and 'column' version." -fi - -# If the history file is empty and MAX_LINES_HISTORY is greater than zero, add some examples. -if [[ ! -s $gh_find_code_history ]] && ((MAX_LINES_HISTORY)); then - command cat <<'EOF' >"$gh_find_code_history" -repo:junegunn/fzf FZF_PORT -extension:rs "Hello, world!" -EOF -fi - -# ===================== helper functions ========================== - -# send a POST request to fzf +# send a POST request to 'fzf' curl_custom() { command curl --header "x-api-key: $FZF_API_KEY" \ --request POST "localhost:$FZF_PORT" \ @@ -320,25 +362,32 @@ open_query_in_browser() { # Adding the current value for 'FZF_QUERY', exported by 'fzf', to the history file. add_history() { - echo "$FZF_QUERY" >>"$gh_find_code_history" - # In the case of duplicates, only the most recent entry is kept. One could use 'tac' instead - # of 'tail -r', but it requires 'coreutils'. Be careful not to read and write the same file - # in the same pipeline - https://shellcheck.net/wiki/SC2094. This could lead to a race - # condition, resulting in the file being erased. - command tail -r "$gh_find_code_history" | command awk '{$1=$1}; NF && !x[$0]++' | - command grep --invert-match '^$' | command tail -r | - command tail -n "$MAX_LINES_HISTORY" >"$store_gh_find_code_history_tmp" - - command mv "$store_gh_find_code_history_tmp" "$gh_find_code_history" + if ((GHFC_HISTORY_LIMIT)); then + echo "$FZF_QUERY" >>"$GHFC_HISTORY_FILE" + # To avoid duplicates, only the most recent entry is retained. Since 'tail -r' does not work + # with the 'coreutils' version and 'tac' requires 'coreutils', 'sed' is used to reverse the + # order of lines. Be cautious not to read from and write to the same file in the same pipeline + # to prevent a race condition that could erase the file. See: https://shellcheck.net/wiki/SC2094 + # for more information. + if command sed '1!G;h;$!d' "$GHFC_HISTORY_FILE" | command awk '{$1=$1}; NF && !x[$0]++' | + command grep --invert-match --regexp='^$' | command sed '1!G;h;$!d' | + command tail -n "$GHFC_HISTORY_LIMIT" >"$store_history_tmp"; then + + command mv "$store_history_tmp" "$GHFC_HISTORY_FILE" + fi + fi } # Removing a specified line from the history file. remove_history() { - # Remove the specified line from the history file - command grep --fixed-strings --line-regexp --invert-match "$*" \ - "$gh_find_code_history" >"$store_gh_find_code_history_tmp" + if ((GHFC_HISTORY_LIMIT)); then + # Attach '--regexp' directly to its argument to handle leading dashes. + if command grep --fixed-strings --line-regexp --invert-match --regexp="$*" \ + "$GHFC_HISTORY_FILE" >"$store_history_tmp"; then - command mv "$store_gh_find_code_history_tmp" "$gh_find_code_history" + command mv "$store_history_tmp" "$GHFC_HISTORY_FILE" + fi + fi } show_api_limits() { @@ -371,7 +420,7 @@ reset_default_prompt() { gh_query() { local trimmed_query data items total_count total_count_si_format skip_count local index owner_repo_name file_name file_path patterns - local file_extension bat_langs sanitized_patterns sanitized_owner_repo_name sanitized_file_path + local file_extension sanitized_patterns sanitized_owner_repo_name sanitized_file_path local matched_line error_encountered update_preview_window_size redirect_location index_color local base_name dir_name declare -a line_numbers @@ -384,12 +433,13 @@ gh_query() { fi # delete leading and trailing whitespace from the query - IFS=$' \t' read -r trimmed_query <<<"$FZF_QUERY" + trimmed_query=$(command awk '{$1=$1;print}' <<<"$FZF_QUERY") + # If the query is the same as before, don't bother running it again, provided that the results # of the last query are still there and there was no error. Useful when switching between fuzzy # mode and search mode. current_query_signature=$(echo -n "${trimmed_query}${gh_user_limit}") - if [[ -s $store_input_list && -s $store_current_header && ! -s $store_gh_search_debug && + if [[ -s $store_input_list && -s $store_current_header && ! -s $store_gh_search_error && $current_query_signature == "$(<"$store_last_query_signature")" ]]; then curl_custom "reload(command cat $store_input_list)+change-header:$(<"$store_current_header")" return @@ -399,7 +449,7 @@ gh_query() { # Ensure all background jobs are terminated before starting new ones kill_processes "$store_query_pids" # empty the files - : >"$store_gh_search_debug" + : >"$store_gh_search_error" : >"$store_current_header" : >"$store_input_list" @@ -424,18 +474,21 @@ gh_query() { patterns: ([.value.text_matches[] | .. | .text? | select(type=="string")] as $patterns_array | if $patterns_array == [] then "__NoPatternFound__" else $patterns_array | unique | join("|") end) } | [.index, .owner_repo_name, .file_name, .file_path, .patterns] | @tsv)' \ - 2>"$store_gh_search_debug") || [[ -z $data ]]; then - if grep --quiet --ignore-case "API rate limit exceeded" "$store_gh_search_debug"; then - show_api_limits >>"$store_gh_search_debug" + 2>"$store_gh_search_error") || [[ -z $data ]]; then + if grep --quiet --ignore-case "API rate limit exceeded" "$store_gh_search_error"; then + show_api_limits >>"$store_gh_search_error" fi - if [[ ! -s $store_gh_search_debug ]]; then - echo "Unknown reason: The query failed, but no error text was written." >>"$store_gh_search_debug" + if [[ ! -s $store_gh_search_error ]]; then + echo "Unknown reason: The query failed, but no error text was written." >>"$store_gh_search_error" fi # Add a line to the beginning of the error file echo "------- GitHub Code Search Failure -------" | - command cat - "$store_gh_search_debug" >"${store_gh_search_debug}_tmp" - command mv "${store_gh_search_debug}_tmp" "$store_gh_search_debug" - curl_custom "unbind(tab,resize)+change-prompt($fzf_prompt_failure)+change-preview-window(99%:nohidden:wrap:~0:+1)+change-preview(command cat $store_gh_search_debug)+transform-header:printf '%bCheck preview window, query syntax, internet connection, ...%b' '$RED_NORMAL' '$COLOR_RESET'" + command cat - "$store_gh_search_error" >"${store_gh_search_error}_tmp" + command mv "${store_gh_search_error}_tmp" "$store_gh_search_error" + curl_custom "unbind(tab,resize)+change-prompt($fzf_prompt_failure)+change-preview-window(99%:nohidden:wrap:~0:+1)+change-preview(command cat $store_gh_search_error)+transform-header:printf '%bCheck preview window, query syntax, internet connection, ...%b' '$RED_NORMAL' '$COLOR_RESET'" + if ((GHFC_DEBUG_MODE)); then + command cp "$store_gh_search_error" "$store_gh_search_debug" + fi return else reset_default_prompt @@ -446,6 +499,8 @@ gh_query() { # first line IFS=' ' read -r items total_count + # Running commands in the background of a script can cause it to hang, especially if the + # command outputs to stdout: https://tldp.org/LDP/abs/html/x9644.html#WAITHANG while IFS=$'\t' read -r index owner_repo_name file_name file_path patterns; do # https://github.com/junegunn/fzf/issues/398 # Tested with 'sudo opensnoop -n bash', without a break check it keeps going through @@ -465,14 +520,14 @@ gh_query() { --header "$gh_accept_raw" \ --header "$gh_rest_api_version" \ >"${store_file_contents}_${index}" \ - 2>"$store_gh_content_debug"; then + 2>"$store_gh_api_error"; then : elif command nice -n 20 command gh api "https://raw.githubusercontent.com/${sanitized_owner_repo_name}/HEAD/${sanitized_file_path}" \ --cache "$gh_default_cache_time" \ --header "$gh_accept_raw" \ --header "$gh_rest_api_version" \ >"${store_file_contents}_${index}" \ - 2>"$store_gh_content_debug"; then + 2>"$store_gh_api_error"; then : fi ) & @@ -517,11 +572,9 @@ gh_query() { fi # This covers special cases where syntax highlighting requires a leading # dot, such as filenames like 'zshrc', '.zshrc' or 'macos.zshrc' - bat_langs=$(command "$bat_executable" --list-languages --color=never | - command awk -F ":" '{print $2}' | command tr ',' '\n') - if command grep --quiet --word-regexp "^\.${file_extension}$" <<<"$bat_langs"; then + if command grep --quiet --max-count=1 --regexp="^\.${file_extension}$" -- "$store_bat_langs"; then file_extension=".${file_extension}" - elif command grep --quiet --word-regexp "^\.${file_name}$" <<<"$bat_langs"; then + elif command grep --quiet --max-count=1 --regexp="^\.${file_name}$" -- "$store_bat_langs"; then file_extension=".${file_name}" fi @@ -540,7 +593,7 @@ gh_query() { # The file is needed now to get the line numbers in the next step. # Therefore, the file will be skipped. echo "$index" >>"$store_skip_count" - if $debug_mode; then + if ((GHFC_DEBUG_MODE)); then error_encountered=true fi index_color="$RED_NORMAL" @@ -548,7 +601,7 @@ gh_query() { break fi done - if command grep --quiet --word-regexp "$index" "$store_skip_count"; then + if command grep --quiet --word-regexp --regexp="$index" -- "$store_skip_count"; then continue fi @@ -560,23 +613,18 @@ gh_query() { curl_custom "transform-header:printf '%b%s/%s of %s collected...%b' '$DARK_GRAY' \ '$index' '$total_listed_results' '$total_count_si_format' '$COLOR_RESET'" - if $debug_mode; then + if ((GHFC_DEBUG_MODE)); then redirect_location="${store_grep_extended_debug}_${index}" fi # Escape special charters before using the string in extended 'grep'. # However, the "|" character should be left unescaped. - sanitized_patterns=$(command sed -e 's/[][?*+.\/$^(){}]/\\&/g' <<<"$patterns") + sanitized_patterns=$(command sed -e 's/[][?*+.$^()]/\\&/g' <<<"$patterns") line_numbers=() [[ $patterns != "__NoPatternFound__" ]] && while IFS='' read -r matched_line; do # Ensure only valid numbers are included if [[ $matched_line =~ ^[0-9]+ ]]; then line_numbers+=("$matched_line") - else - { - echo "The matched line is not a number." - echo "matched_line: '$matched_line'" - } >>"${store_grep_extended_debug}_${index}" fi # Use the '--text' flag, as grep will simply print 'Binary file … matches' if # the file contains binary characters. It won't even throw an error. @@ -588,7 +636,7 @@ gh_query() { --extended-regexp --regexp="$sanitized_patterns" -- \ "${store_file_contents}_${index}_fetched" 2>"${redirect_location}" | cut -d: -f1) # Save additional information only if an error is encountered by grep - if $debug_mode && [[ -s ${store_grep_extended_debug}_${index} ]]; then + if ((GHFC_DEBUG_MODE)) && [[ -s ${store_grep_extended_debug}_${index} ]]; then { for value in "index" "owner_repo_name" "file_path" "patterns" "sanitized_patterns"; do echo "$value = '${!value}'" @@ -631,9 +679,12 @@ gh_query() { skip_count="$(command sed -n '$=' "$store_skip_count")" if $error_encountered; then - show_api_limits >>"$store_gh_content_debug" + show_api_limits >>"$store_gh_api_error" curl_custom "transform-header(printf '%bAPI failed for repos/%s/contents/%s%b' \ - '$RED_NORMAL' '$owner_repo_name' '$file_path' '$COLOR_RESET')+change-preview:command cat '$store_gh_content_debug'" + '$RED_NORMAL' '$owner_repo_name' '$file_path' '$COLOR_RESET')+change-preview:command cat '$store_gh_api_error'" + if ((GHFC_DEBUG_MODE)); then + command cp "$store_gh_api_error" "$store_gh_api_debug" + fi elif ((skip_count > 0)); then printf "%b%s of ∑ %s%b (Skipped: %d %s [%s])%b | ? help · esc quit%b\n" \ "$GREEN_NORMAL" "$items" "$total_count_si_format" "$RED_NORMAL" "$skip_count" \ @@ -704,19 +755,6 @@ view_contents() { return 0 fi - if $debug_mode; then - { - echo -n "bat version: " - command "$bat_executable" --version - echo -n "config file: " - command "$bat_executable" --config-file - command cat "$(command "$bat_executable" --config-file)" - for value in BAT_PAGER BAT_CONFIG_PATH BAT_STYLE BAT_THEME BAT_TABS PAGER; do - echo "$value = '${!value}'" - done - } >"$store_bat_debug" 2>&1 - fi - bat_args+=("--paging=always") # The 'less' pager can move to a specific line. if [[ $(command basename "${PAGER-}") =~ ^(bat|less)$ ]]; then @@ -746,7 +784,6 @@ fzf_basic_style() { : | command fzf -- \ --ansi \ --bind 'scroll-up:offset-up,scroll-down:offset-down' \ - --bind "ctrl-c:execute:kill_processes $store_fzf_ppids" \ --border block \ --color 'bg+:233,bg:235,gutter:235,border:238:dim,scrollbar:235' \ --color 'preview-bg:234,preview-border:236,preview-scrollbar:237' \ @@ -757,7 +794,9 @@ fzf_basic_style() { --height=100% \ --header-lines 0 \ --highlight-line \ + --no-expect \ --no-multi \ + --no-print-query \ --info hidden \ --layout reverse \ --pointer '▶' \ @@ -766,27 +805,31 @@ fzf_basic_style() { --separator '' \ --sync \ --unicode \ + --with-shell "${execution_shell:-"$(which bash) -c"}" \ "$@" } view_history_commands() { local header_string header_color history_command selection - if [[ ! -s $gh_find_code_history ]]; then + if [[ ! -s $GHFC_HISTORY_FILE ]]; then header_color="yellow" header_string="No history entries yet. Check back on your next run. Press 'esc' to exit." fi echo "hold" >"$store_hold_gh_query_loop" - # the extra pipe for 'bat' is to enhance the text visibility - history_command=$'command tail -r "$gh_find_code_history" | command nl -s "\t" -n ln -w 3 | command '$bat_executable' --plain --color=always' + # The additional pipe for 'bat' is used to improve text visibility. + history_command=$'command sed \'1!G;h;$!d\' "$GHFC_HISTORY_FILE" | command nl -s "\t" -n ln -w 3 | command '$bat_executable' --plain --color=always' + # The Ctrl+C keybind instantly closes 'fzf' by terminating both instances. selection=$( fzf_basic_style \ --bind "change:first" \ - --bind "start:execute-silent(echo \${PPID-} >>$store_fzf_ppids)+reload:$history_command" \ + --bind "start:reload:$history_command" \ + --bind "ctrl-c:become:curl_custom 'abort'" \ --bind "ctrl-d:reload:remove_history {2..}; $history_command" \ --bind "ctrl-r:reload:$history_command" \ --bind 'ctrl-space:abort' \ --bind 'esc:abort' \ --color "header:${header_color:--1}" \ + --delimiter '\s+' \ --header "${header_string:-"enter select · ^d delete an entry · esc quit"}" \ --info inline \ --preview-window 'hidden' \ @@ -814,38 +857,72 @@ preview_transformer() { echo "change-preview-window:${lines%.*}" } -# NOTE: The 'change-preview-window' action in 'transform' should precede 'change-preview'. -# NOTE: In the transform action, placeholders and functions using placeholders as arguments must be -# escaped, e.g. '\view_contents \{}', but not 'print_help_text'. -# TODO: The hotkeys 'ctrl-t' and '?' may cause issues if used during certain operations; a solution -# is needed to address this complexity. -fzf_basic_style \ - --bind "change:first+reload:command sleep 0.5; gh_query" \ - --bind "resize:transform:preview_transformer" \ - --bind "start:execute-silent(echo \${PPID-} >>$store_fzf_ppids)+reload:gh_query" \ - --bind 'ctrl-b:execute-silent:command gh browse --repo {4} {5}:{1}' \ - --bind "ctrl-o:transform:[[ \$FZF_MATCH_COUNT -ge 1 && ${EDITOR##*/} =~ ^(code|codium)$ ]] && +main() { + validate_environment + + # CLI Options + while getopts ":hl:" value; do + case $value in + l) + if ! [[ $OPTARG =~ ^[0-9]+$ ]]; then + die "Value is not a valid number: '$OPTARG'" + fi + if ((OPTARG < 1 || OPTARG > 100)); then + die "Value for '-l' must be between 1 and 100" + fi + gh_user_limit="${OPTARG}" + ;; + h) + print_help_text + exit 0 + ;; + *) die "Invalid Option: -${OPTARG-}" ;; + esac + done + shift "$((OPTIND - 1))" + + # Disable the ERR trap to ignore SIGINT from terminating 'fzf' with Ctrl+C. + trap - ERR + + # NOTE: The 'change-preview-window' action in 'transform' should precede 'change-preview'. + # NOTE: In the transform action, placeholders and functions using placeholders as arguments must be + # escaped, e.g. '\view_contents \{}', but not 'print_help_text'. + # TODO: The hotkeys 'ctrl-t' and '?' may cause issues if used during certain operations; a solution + # is needed to address this complexity. + fzf_basic_style \ + --bind "change:first+reload:command sleep 0.5; gh_query" \ + --bind "resize:transform:preview_transformer" \ + --bind "start:reload:gh_query" \ + --bind 'ctrl-b:execute-silent:command gh browse --repo {4} {5}:{1}' \ + --bind "ctrl-o:transform:[[ \$FZF_MATCH_COUNT -ge 1 && ${EDITOR##*/} =~ ^(code|codium)$ ]] && echo 'execute-silent:open_in_editor=true \view_contents \{}' || echo 'execute:open_in_editor=true \view_contents \{}'" \ - --bind "ctrl-p:transform-query:echo repo:{4}" \ - --bind 'ctrl-r:reload:gh_user_limit=100 gh_query' \ - --bind 'ctrl-space:execute:view_history_commands' \ - --bind "ctrl-t:transform:[[ ! \$FZF_PROMPT == \"$fzf_prompt_fuzzyAB\" ]] && + --bind "ctrl-p:transform-query:echo repo:{4}" \ + --bind 'ctrl-r:reload:gh_user_limit=100 gh_query' \ + --bind 'ctrl-space:execute:view_history_commands' \ + --bind "ctrl-t:transform:[[ ! \$FZF_PROMPT == \"$fzf_prompt_fuzzyAB\" ]] && echo 'unbind(change)+change-prompt($fzf_prompt_fuzzyAB)+enable-search+transform-query:echo \{fzf:query} > $store_search_string; command cat $store_fuzzy_search_string' || echo 'rebind(change)+change-prompt($default_fzf_prompt)+disable-search+transform-query:echo \{fzf:query} > $store_fuzzy_search_string; command cat $store_search_string'" \ - --bind 'ctrl-x:execute-silent:open_query_in_browser {fzf:query}' \ - --bind $'enter:execute:[[ $FZF_MATCH_COUNT -ge 1 ]] && view_contents {}' \ - --bind 'esc:become:' \ - --bind "tab:change-prompt($default_fzf_prompt)+change-preview(view_contents {})+change-preview-window:hidden:hidden|+{1}+3/3" \ - --bind "?:transform:[[ ! \$FZF_PROMPT =~ \"$fzf_prompt_helpABC\" ]] && + --bind 'ctrl-x:execute-silent:open_query_in_browser {fzf:query}' \ + --bind $'enter:execute:[[ $FZF_MATCH_COUNT -ge 1 ]] && view_contents {}' \ + --bind 'esc:become:' \ + --bind "tab:change-prompt($default_fzf_prompt)+change-preview(view_contents {})+change-preview-window:hidden:hidden|+{1}+3/3" \ + --bind "?:transform:[[ ! \$FZF_PROMPT =~ \"$fzf_prompt_helpABC\" ]] && echo 'change-prompt($fzf_prompt_helpABC)+change-preview-window(~0:+1)+change-preview:print_help_text' || echo 'change-prompt($default_fzf_prompt)+change-preview-window(+\{1}+3/3)+change-preview:\view_contents \{}'" \ - --delimiter '\t|\s\s+' \ - --disabled \ - --listen \ - --nth=2..,.. \ - --preview 'view_contents {}' \ - --preview-window 'border-block:~3:+{1}+3/3:nohidden:bottom:nowrap:66%' \ - --prompt "$default_fzf_prompt" \ - --query "$*" \ - --with-nth=3.. + --delimiter '\t|\s\s+' \ + --disabled \ + --listen \ + --nth=2..,.. \ + --preview 'view_contents {}' \ + --preview-window 'border-block:~3:+{1}+3/3:nohidden:bottom:nowrap:66%' \ + --prompt "$default_fzf_prompt" \ + --query "$*" \ + --with-nth=3.. +} + +############################################################################### +# Script Execution +############################################################################### + +main "$@" diff --git a/readme.md b/readme.md index 1ac50b6..c6f270d 100644 --- a/readme.md +++ b/readme.md @@ -37,16 +37,15 @@ gh find-code [Flags] [Search query] > [!IMPORTANT] -> The search syntax differs between the WebUI and the REST API, with the latter not -> supporting regex. +> The search syntax differs between the WebUI and the REST API, with the latter +> not supporting regex. --- -| Flags | Description | -| ----- | ------------------------------------------------------------- | -| `-d` | debug mode, primarily used for identifying and resolving bugs | -| `-l` | limit the number of listed results (default 30, max 100) | -| `-h` | help | +| Flags | Description | +| ----- | -------------------------------------------------------- | +| `-l` | limit the number of listed results (default 30, max 100) | +| `-h` | help | | Key Bindings fzf | Description | | ------------------------------- | ------------------------------------ | @@ -69,10 +68,10 @@ gh find-code [Flags] [Search query] - [curl](https://github.com/curl/curl) - sending updates to `fzf` - [Fuzzy Finder (fzf)](https://github.com/junegunn/fzf#installation) - allow for interaction with listed data -- [GitHub command line tool (gh)](https://github.com/cli/cli#installation) - get the data - from Github -- [Python](https://www.python.org) - used to parse and open custom URLs on different - operating systems +- [GitHub command line tool (gh)](https://github.com/cli/cli#installation) - get + the data from Github +- [Python](https://www.python.org) - used to parse and open custom URLs on + different operating systems ```sh # install this extension @@ -88,8 +87,8 @@ gh ext remove LangLangBart/gh-find-code ## 💁 TIPS ### Alias -- The name `gh find-code` was chosen for its descriptive nature. For frequent use, - consider setting up an alias. +- The name `gh find-code` was chosen for its descriptive nature. For frequent + use, consider setting up an alias. ```sh # ~/.bashrc or ~/.zshrc @@ -99,8 +98,9 @@ alias ghfc='BAT_THEME="Dracula" EDITOR="vim" gh find-code' ``` ### Bat -- The color scheme of the preview is determined by the `BAT_THEME` environment variable. - If not explicitly set by the user, the theme defaults to `Monokai Extended`. +- The color scheme of the preview is determined by the `BAT_THEME` environment + variable. If not explicitly set by the user, the theme defaults to `Monokai + Extended`. ```sh # To view all default themes @@ -110,11 +110,21 @@ bat --list-themes --color=never BAT_THEME="Dracula" gh find-code ``` +### Debugging +- To activate debug mode, set `GHFC_DEBUG_MODE=1`. This enables `xtrace` and + logs outputs to a file, with the file's location displayed after script + execution. + +```bash +GHFC_DEBUG_MODE=1 gh find-code +``` + ### Editor -- The extension uses the `EDITOR` environment variable to determine in which editor - the selected file will be opened, works with `nano`, `nvim/vi/vim`, and - `VSCode/VSCodium`. -- The code from opened files is stored temporarily and is removed when the program ends. +- The extension uses the `EDITOR` environment variable to determine in which + editor the selected file will be opened, works with `nano`, `nvim/vi/vim`, + and `VSCode/VSCodium`. +- The code from opened files is stored temporarily and is removed when the + program ends. ```sh # Set the editor to Visual Studio Code @@ -134,33 +144,42 @@ export FZF_DEFAULT_OPTS=" - See `man fzf` for `AVAILABLE KEYS` or [junegunn/fzf](https://github.com/junegunn/fzf#environment-variables) for more details. -- NOTE: [How to use ALT commands in a terminal on - macOS?](https://superuser.com/questions/496090/how-to-use-alt-commands-in-a-terminal-on-os-x) +- **NOTE:** [How to use ALT commands in a terminal on macOS?](https://superuser.com/questions/496090/how-to-use-alt-commands-in-a-terminal-on-os-x) ### History -- The `gh_find_code_history.txt` file stores successfully completed unique commands. All commands - can be viewed with ⌃ Control + Space. In case of duplicates, only the most - recent entry is preserved. The maximum number of command entries is 500 by default, but this can - be overridden by assigning a value to the `MAX_LINES_HISTORY` variable. +- Recent commands can be viewed with ⌃ Control + Space. +- The history file stores successfully completed unique commands, one can + specify a custom location using the `GHFC_HISTORY_FILE` environment variable. + +```bash +# Default location: ${BASH_SOURCE%/*}/gh_find_code_history.txt +# Specify a custom location for the history file +GHFC_HISTORY_FILE="/custom/location/history.txt" gh find-code +``` -```sh +- In case of duplicates, only the most recent entry is preserved. The default + maximum number of command entries is **500**, but this can be adjusted by + setting the `GHFC_HISTORY_LIMIT` variable. + +```bash # Set the maximum number of stored commands to 1000 -MAX_LINES_HISTORY="1000" gh find-code +GHFC_HISTORY_LIMIT="1000" gh find-code ``` ### Pager -- If the `PAGER` environment variable is set to `less` or `bat`, when opening the destination file, - it will automatically scroll to the matching line found. +- If the `PAGER` environment variable is set to `less` or `bat`, when opening + the destination file, it will automatically scroll to the matching line found. --- ## 💪 Contributing > [!NOTE] -> _Pre-commit is a multi-language package manager for pre-commit hooks. You specify a list_ -> _of hooks you want and **pre-commit manages the installation and execution** of any hook_ -> _written in any language before every commit._ **Source:** [pre-commit -> introduction](https://pre-commit.com/#introduction) +> _Pre-commit is a multi-language package manager for pre-commit hooks. You_ +> _specify a list of hooks you want and pre-commit **manages** the installation_ +> _and **execution** of any hook written in any language before every commit._ +> +> **Source:** [pre-commit introduction](https://pre-commit.com/#introduction) ```sh # shellcheck and shfmt are necessary dependencies for one hook