#!/usr/bin/env bash # ----------------------------------------------------------------------------- # bat-extras | Copyright (C) 2019-2023 eth-p | MIT License # # Repository: https://github.com/eth-p/bat-extras # Issues: https://github.com/eth-p/bat-extras/issues # ----------------------------------------------------------------------------- # ----------------------------------------------------------------------------- # Build-as-a-Library Functions: # ----------------------------------------------------------------------------- # Redefines a function to print a constant string whenever called. # This is used for lazy-loading of some getter functions. # # Arguments: # 1 -- The function name. # 2 -- The constant string to print. # # Output: # The constant string. batextras:lazy_done() { eval "$(printf "%s() { printf \"%%s\n\" %q; }" "$1" "$2")" "$1" } # Checks to see if a function is defined. # Arguments: # 1 -- The function name. # If prefixed with "::", it will use "batextras:" as a namespace. batextras:is_function_defined() { local name="$1" if [[ "${name:0:2}" = "::" ]]; then name="batextras:${name:2}"; fi [[ "$(type -t "$name" || echo 'undefined')" = "function" ]] return $? } # Prints the path to the project directory. if ! batextras:is_function_defined ::get_project_directory; then batextras:get_project_directory() { batextras:lazy_done "${FUNCNAME[0]}" \ "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" } fi # Prints the path to the project source directory. if ! batextras:is_function_defined ::get_source_directory; then batextras:get_source_directory() { batextras:lazy_done "${FUNCNAME[0]}" \ "$(batextras:get_project_directory)/src" } fi # Prints the path to the project output directory for executables. if ! batextras:is_function_defined ::get_output_bin_directory; then batextras:get_output_bin_directory() { batextras:lazy_done "${FUNCNAME[0]}" \ "$(batextras:get_project_directory)/bin" } fi # Prints the path to the project output directory for manuals. if ! batextras:is_function_defined ::get_output_man_directory; then batextras:get_output_man_directory() { batextras:lazy_done "${FUNCNAME[0]}" \ "$(batextras:get_project_directory)/man" } fi # Prints the path to the project directory for documentation. if ! batextras:is_function_defined ::get_docs_directory; then batextras:get_docs_directory() { batextras:lazy_done "${FUNCNAME[0]}" \ "$(batextras:get_project_directory)/doc" } fi # Prints the declared version (in version.txt). if ! batextras:is_function_defined ::get_version; then batextras:get_version() { batextras:lazy_done "${FUNCNAME[0]}" \ "$(cat "$(batextras:get_project_directory)/version.txt")" } fi # Prints the paths for all source scripts in this project. # # Output: # One line for each script with the full path to the script. if ! batextras:is_function_defined ::get_source_paths; then batextras:get_source_paths() { for file in "$(batextras:get_source_directory)"/*.sh; do printf "%s\n" "$file" file_bin="$(basename -- "$file" ".sh")" done } fi # ----------------------------------------------------------------------------- # Main: # Only run everything past this point if the script is not sourced. # ----------------------------------------------------------------------------- (return 0 2>/dev/null) && return 0 HERE="$(batextras:get_project_directory)" SRC="$(batextras:get_source_directory)" BIN="$(batextras:get_output_bin_directory)" MAN="$(batextras:get_output_man_directory)" MAN_SRC="$(batextras:get_docs_directory)" LIB="$HERE/lib" source "${LIB}/print.sh" source "${LIB}/opt.sh" source "${LIB}/constants.sh" source "${HERE}/mdroff.sh" # ----------------------------------------------------------------------------- set -eo pipefail exec 3>&1 # Runs the next build step. # # Arguments: # 1 -- The build step function name. # @ -- The function arguments. # # Input: # The unprocessed file data. # # Output: # The processed file data. next() { "$@" return $? } # Prints a build step message. smsg() { case "$2" in "SKIP") printc_msg " %{YELLOW} %{DIM}%s [skipped]%{CLEAR}\n" "$1" ;; *) printc_msg " %{YELLOW} %s...%{CLEAR}\n" "$1" ;; esac } # Prints a message to STDOUT (via FD 3). # Works the same as printc. printc_msg() { printc "$@" 1>&3 } # Prints a message to STDERR. # Works the same as printc. printc_err() { printc "$@" 1>&2 } # Escapes a sed pattern. # Arguments: # 1 -- The pattern. # # Output: # The escaped string. sed_escape() { sed 's/\([][\\\/\^\$\*\.\-]\)/\\\1/g' <<< "$1" } # Checks if the output scripts will be minified. # Arguments: # 1 "all" -- All scripts will be minified. # "any" -- Any scripts will be minified. # "lib" -- Library scripts will be minified. # "unsafe" -- Unsafe minifications will be applied. will_minify() { case "$1" in all) [[ "$OPT_MINIFY" =~ ^all($|\+.*) ]] return $? ;; unsafe) [[ "$OPT_MINIFY" =~ ^.*+unsafe(\+.*)*$ ]] return $? ;; lib) [[ "$OPT_MINIFY" =~ ^lib($|\+.*) ]] return $? ;; any|"") [[ "$OPT_MINIFY" != "none" ]] return $? ;; none) ! will_minify any return $? ;; esac return 1 } # Generates the banner for the output files. # # Output: # The contents of banner.txt generate_banner() { local step="$1" if ! "$OPT_BANNER"; then return 0 fi # Don't run it unless the comments are removed or hidden. if ! { will_minify all || "$OPT_COMPRESS"; }; then return 0 fi # Only run it in the compression step if both minifying and compressing. if will_minify all && "$OPT_COMPRESS" && [[ "$step" != "step_compress" ]]; then return 0 fi # Write the banner. bat "${HERE}/banner.txt" } # Build step: read # Reads the file from its source. # # Arguments: # 1 -- The source file. # 2 -- A description of what is being read. # # Output: # The file contents. step_read() { local what="" if [[ -n "${2:-}" ]]; then what=" $2" fi cat "$1" smsg "Reading${what}" } # Build step: preprocess # Preprocesses the script. # # This will embed library scripts and inline constants. # # Input: # The original file contents. # # Output: # The processed file contents. step_preprocess() { local line local docvar pp_consolidate | while IFS='' read -r line; do # Skip certain lines. [[ "$line" =~ ^LIB=.*$ ]] && continue [[ "$line" =~ ^[[:space:]]*source[[:space:]]+[\"\']\$\{?LIB\}/(constants\.sh)[\"\'] ]] && continue # Forward data. echo "$line" done | pp_inline_constants smsg "Preprocessing" } # Build step: minify # Minifies the output script. # # Input: # The original file contents. # # Output: # The minified file contents. step_minify() { if ! will_minify all; then cat smsg "Minifying" "SKIP" return 0 fi printf "#!/usr/bin/env bash\n" generate_banner step_minify pp_minify | pp_minify_unsafe smsg "Minifying" } # Build step: compress # Compresses the input into a gzipped self-executable script. # # Input: # The original file contents. # # Output: # The compressed self-executable script. step_compress() { if ! "$OPT_COMPRESS"; then cat smsg "Compressing" "SKIP" return 0 fi local wrapper wrapper="$({ printf '#!/usr/bin/env bash\n' generate_banner step_compress printf "(exec -a \"\$0\" bash -c 'eval \"\$(cat <&3)\"' \"\$0\" \"\$@\" 3< <(dd bs=1 if=\"\$0\" skip=::: 2>/dev/null | gunzip)); exit \$?;\n" })" echo "${wrapper/:::/$(wc -c <<<"$wrapper" | sed 's/^[[:space:]]*//')}" gzip smsg "Compressing" } # Build step: write # Writes the output script to a file. # # Arguments: # 1 -- The file to write to. # # Input: # The file contents. # # Output: # The file contents. step_write() { tee "$1" chmod +x "$1" smsg "Building" } # Build step: write_install # Optionally writes the output script to a file. # # Arguments: # 1 -- The file to write to. # # Input: # The file contents. # # Output: # The file contents. step_write_install() { if [[ "$OPT_INSTALL" != true ]]; then cat smsg "Installing" "SKIP" return 0 fi tee "$1" chmod +x "$1" smsg "Installing" } # Build step: manpage_install # Optionally writes the manpage to a gzipped file. # # Arguments: # 1 -- The file to write to. # # Input: # The file contents. # # Output: # The file contents. step_manpage_install() { if [[ "$OPT_INSTALL" != true ]]; then cat smsg "Installing manual" "SKIP" return 0 fi gzip | tee "$1" smsg "Installing manual" } # Build step: manpage_generate # Generates a manpage document from a markdown document. # # Input: # The markdown doc contents. # # Output: # The roff manpage contents. step_manpage_generate() { if [[ "$OPT_MANUALS" != true ]]; then cat smsg "Generating manual" "SKIP" return 0 fi (mdroff) smsg "Generating manual" } # ----------------------------------------------------------------------------- # Preprocessor: # ----------------------------------------------------------------------------- # Consolidates all scripts into a single file. # This follows all `source "${LIB}/..."` files and embeds them into the script. pp_consolidate() { PP_CONSOLIDATE_PROCESSED=() pp_consolidate__do 0 } pp_consolidate__do() { local depth="$1" local indent="$(printf "%-${depth}s" | tr ' ' $'\t')" local line while IFS='' read -r line; do # Embed library scripts. if [[ "$line" =~ ^[[:space:]]*source[[:space:]]+[\"\']\$\{?LIB\}/([a-z_-]+\.sh)[\"\'] ]]; then local script_name="${BASH_REMATCH[1]}" local script="$LIB/$script_name" # Skip if it's the constants library. [[ "$script_name" = "constants.sh" ]] && continue # Skip if it's already embedded. local other for other in "${PP_CONSOLIDATE_PROCESSED[@]}"; do [[ "$script" = "$other" ]] && continue 2 done PP_CONSOLIDATE_PROCESSED+=("$script") # Embed the script. echo "${indent}# --- BEGIN LIBRARY FILE: ${BASH_REMATCH[1]} ---" { if will_minify lib; then pp_strip_comments | pp_minify | pp_minify_unsafe else pp_strip_copyright | pp_strip_separators fi } < <(pp_consolidate__do "$((depth + 1))" < "$script") | sed "s/^/${indent}/" echo "${indent}# --- END LIBRARY FILE ---" continue fi # Forward data. echo "$line" done } # Inlines constants: # EXECUTABLE_* # PROGRAM_* pp_inline_constants() { local constants=("PROGRAM") # Determine the PROGRAM_ constants. local nf_constants="$( ( set -o posix ; set) | grep '^\(PROGRAM_\|EXECUTABLE_\)' | cut -d'=' -f1)" local line while read -r line; do constants+=("$line") done <<< "$nf_constants" # Generate a sed replace for the constants. local constants_pattern='' local constant_name local constant_value for constant_name in "${constants[@]}"; do constant_value="$(sed_escape "${!constant_name}")" constant_name="$(sed_escape "$constant_name")" constant_sed="s/\\\$${constant_name}\([^A-Za-z0-9_]\)/${constant_value}\1/; s/\\\${${constant_name}}/${constant_value}/g;" constants_pattern="${constants_pattern}${constant_sed}" done sed "${constants_pattern}" } # Strips comments from a Bash source file. pp_strip_comments() { sed '/^[[:space:]]*#.*$/d' } # Strips copyright comments from the start of a Bash source file. pp_strip_copyright() { awk '/^#/ {if(!p){ next }} { p=1; print $0 }' } # Strips separator comments from the start of a Bash source file. pp_strip_separators() { awk '/^#\s*-{5,}/ { next; } {print $0}' } # Minify a Bash source file. # https://github.com/mvdan/sh pp_minify() { if will_minify none; then cat return fi shfmt -mn return $? } # Minifies the output script (unsafely). # Right now, this doesn't do anything. # This should be applied after shfmt minification. pp_minify_unsafe() { if ! will_minify unsafe; then cat return 0 fi cat } # ----------------------------------------------------------------------------- # Options: # ----------------------------------------------------------------------------- OPT_INSTALL=false OPT_COMPRESS=false OPT_VERIFY=true OPT_BANNER=true OPT_MANUALS=true OPT_INLINE=true OPT_MINIFY="lib" OPT_PREFIX="/usr/local" EXECUTABLE_BAT="$(basename -- "$EXECUTABLE_BAT")" ALT_EXECS=() BUILD_FILTER=() DOCS_URL="https://github.com/eth-p/bat-extras/blob/master/doc" DOCS_MAINTAINER="eth-p " # ----------------------------------------------------------------------------- # Use a different default prefix when running on Termux. if [[ "$(uname -o)" = "Android" ]] && [[ -n "${TERMUX_VERSION:-}" ]]; then OPT_PREFIX="/data/data/com.termux/files/usr/" else OPT_PREFIX="/usr/local" fi # ----------------------------------------------------------------------------- # Parse arguments. while shiftopt; do # shellcheck disable=SC2034 case "$OPT" in --install) OPT_INSTALL=true ;; --compress) OPT_COMPRESS=true ;; --manuals) OPT_MANUALS="${OPT_VAL:-true}" ;; --no-manuals) OPT_MANUALS=false ;; --verify) OPT_VERIFY=true ;; --no-verify) OPT_VERIFY=false ;; --banner) OPT_BANNER=true ;; --no-banner) OPT_BANNER=false ;; --inline) OPT_INLINE=true ;; --no-inline) OPT_INLINE=false ;; --prefix) shiftval; OPT_PREFIX="$OPT_VAL" ;; --alternate-executable) shiftval; ALT_EXECS+=("bat"); EXECUTABLE_BAT="$OPT_VAL" ;; --alternate-executable:bat) shiftval; ALT_EXECS+=("bat"); EXECUTABLE_BAT="$OPT_VAL" ;; --alternate-executable:ripgrep) shiftval; ALT_EXECS+=("ripgrep"); EXECUTABLE_RIPGREP="$OPT_VAL" ;; --alternate-executable:delta) shiftval; ALT_EXECS+=("delta"); EXECUTABLE_DELTA="$OPT_VAL" ;; --alternate-executable:fzf) shiftval; ALT_EXECS+=("fzf"); EXECUTABLE_FZF="$OPT_VAL" ;; --alternate-executable:git) shiftval; ALT_EXECS+=("git"); EXECUTABLE_GIT="$OPT_VAL" ;; --minify) shiftval; OPT_MINIFY="$OPT_VAL" ;; # Print scripts. --show:source-paths) get_source_paths; exit 0 ;; # Unknown options. *) if ! [[ -f "${SRC}/${OPT}.sh" ]]; then printc_err "%{RED}%s: unknown option '%s'%{CLEAR}" "$PROGRAM" "$OPT" exit 1 fi BUILD_FILTER+=("$OPT") ;; esac done if [[ "${#ALT_EXECS[@]}" -gt 0 ]]; then printc_msg "%{YELLOW}Building executable scripts with alternate executables for:%{CLEAR}\n" printc_msg "%{YELLOW} - %{CLEAR}%s\n" "${ALT_EXECS[@]}" printc_msg "\n" if ! command -v "$EXECUTABLE_BAT" &>/dev/null; then printc_err "%{YELLOW}WARNING: Bash cannot execute bat's executable file.\n" printc_err "%{YELLOW} The finished scripts may not run properly.%{CLEAR}\n" fi # shellcheck disable=SC2034 printc_msg "\n" fi if [[ "$OPT_INSTALL" = true ]]; then printc_msg "%{YELLOW}Installing to %{MAGENTA}%s%{YELLOW}.%{CLEAR}\n" "$OPT_PREFIX" else printc_msg "%{YELLOW}This will not install the script.%{CLEAR}\n" printc_msg "%{YELLOW}Use %{BLUE}--install%{YELLOW} for a global install.%{CLEAR}\n\n" fi if [[ "$OPT_INLINE" = false ]]; then # Prevent full executable paths from being inlined. while read -r exec; do declare "$exec=$(basename "${!exec}")" done < <(set | grep '^EXECUTABLE' | cut -d'=' -f1) fi # ----------------------------------------------------------------------------- # Check for resources. if ! will_minify none && ! command -v shfmt &>/dev/null; then printc_err "%{RED}Warning: cannot find shfmt. Unable to minify scripts.%{CLEAR}\n" OPT_MINIFY=none fi # ----------------------------------------------------------------------------- # Check target directories exist. [[ -d "$BIN" ]] || mkdir -p "$BIN" if "$OPT_INSTALL"; then [[ -d "${OPT_PREFIX}/bin" ]] || mkdir -p "${OPT_PREFIX}/bin" [[ "$OPT_MANUALS" = "true" && ! -d "${OPT_PREFIX}/share/man/man1" ]] \ && mkdir -p "${OPT_PREFIX}/share/man/man1" fi if [[ "$OPT_MANUALS" = "true" ]]; then [[ -d "$MAN" ]] || mkdir -p "$MAN" fi # ----------------------------------------------------------------------------- # Find files. SOURCES=() printc_msg "%{YELLOW}Preparing scripts...%{CLEAR}\n" while read -r file; do file_bin="$(basename -- "$file" ".sh")" buildable=false if ! "$buildable" && [[ "${#BUILD_FILTER[@]}" -eq 0 ]]; then buildable=true elif ! "$buildable"; then for buildable_file in "${BUILD_FILTER[@]}"; do if [[ "$buildable_file" = "$file_bin" ]]; then buildable=true break fi done fi # If that one is allowed to build, add it to the sources list. if "$buildable"; then SOURCES+=("$file") else printc_msg " %{YELLOW}Skipping %{MAGENTA}%s%{CLEAR}\n" "$(basename "$file_bin")" fi done < <(batextras:get_source_paths) if [[ "${#BUILD_FILTER[@]}" -gt 0 ]]; then printf "\n" fi # ----------------------------------------------------------------------------- # Build files. printc_msg "%{YELLOW}Building scripts...%{CLEAR}\n" file_i=0 file_n="${#SOURCES[@]}" for file in "${SOURCES[@]}"; do ((file_i++)) || true filename="$(basename "$file" .sh)" PROGRAM="$filename" PROGRAM_VERSION="$(<"${HERE}/version.txt")" printc_msg " %{YELLOW}[%s/%s] %{MAGENTA}%s%{CLEAR}\n" "$file_i" "$file_n" "$file" step_read "$file" | next step_preprocess | next step_minify | next step_compress | next step_write "${BIN}/${filename}" | next step_write_install "${OPT_PREFIX}/bin/${filename}" | cat >/dev/null # Build manuals. if [[ -f "${HERE}/doc/${filename}.md" && "$OPT_MANUALS" = "true" ]]; then step_read "${HERE}/doc/${filename}.md" "manual" | next step_manpage_generate | next step_write "${MAN}/${filename}.1" | next step_manpage_install "${OPT_PREFIX}/share/man/man1/${filename}.1.gz" | cat >/dev/null fi done # ----------------------------------------------------------------------------- # Verify files by running the tests. if "$OPT_VERIFY"; then printc_msg "\n%{YELLOW}Verifying scripts...%{CLEAR}\n" # Run the tests. FAIL=0 SKIP=0 while read -r action data1 data2 splat; do [[ "$action" == "result" ]] || continue case "$data2" in fail) printc_err "\x1B[G\x1B[K%s failed.\n" "$data1" ((FAIL++)) || true ;; skip) printc_msg "\x1B[G\x1B[K%s skipped.\n" "$data1" ((SKIP++)) || true ;; *) printc_msg "\x1B[G\x1B[K%s" "$data1" ;; esac done < <("${HERE}/test.sh" --compiled --porcelain --jobs=8) # Print the overall result. printc_msg "\x1B[G\x1B[K" if [[ "$FAIL" -ne 0 ]]; then printc_err "%{RED}%s\n" "One or more tests failed." printc_msg "\x1B[A\x1B[G\x1B[K%{RED}%s\n" "One or more tests failed." printc_err "%{RED}%s%{CLEAR}\n" "Run './test.sh --failed' for more detailed information." exit 1 fi if [[ "$SKIP" -gt 0 ]]; then printc_err "%{CYAN}One or more tests were skipped.\n" printc_err "Run ./test.sh for more detailed information.%{CLEAR}\n" fi printc_msg "%{YELLOW}Verified successfully.%{CLEAR}\n" fi