diff --git a/README.md b/README.md index 9d6dfc3..3da61c2 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,14 @@ profile gives access to numerous functions, aliases and to an advanced prompt. All functions are organized into modules under the `profile.d/` directory and are loaded automatically at startup. +The internal helpers (functions starting with `_`, not meant to be called by +hand) are kept apart from the modules, in the `helpers/` directory: +`helpers/common.sh` holds the ones shared by several modules (option parsing, +trap restoration, ...), and each module loads its own private helpers from +`helpers/.sh` when that file exists (`profile.d/rain.sh` loads +`helpers/rain.sh`). Both are loaded automatically; `helpers/` must be kept next +to `profile.sh` and `profile.d/`. + ### 3.1. Prompt A bar-style prompt showing current time, execution time of the last command diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 260b293..e80ce7a 100755 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -22,6 +22,14 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). - `utaz` supports bare `.zst` files and `.tzst` archives; `taz -q` is now effective. +### Changed + +- Internal helpers are now delivered apart from the modules, in the new + `helpers/` directory: `helpers/common.sh` (shared helpers, loaded by + `profile.sh` before the modules) and `helpers/.sh` (private helpers + of `profile.d/.sh`, loaded by the module itself when the file exists). + `_fun_is_uint` became the shared `_is_uint`. + ### Fixed - `taz`: a plain file with the `tar` format could be deleted instead of being @@ -174,7 +182,7 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). longer change the shell's directory stack. - All modules: the option parsing no longer uses `xargs`, which broke on arguments or file names containing a newline, nor `eval`: a new - `profile.d/args.sh` provides `_getopt_split` (and `_trap_restore`), pure bash + `helpers/common.sh` provides `_getopt_split` (and `_trap_restore`), pure bash helpers; the `nullglob` and trap restorations no longer use `eval` either. - `profile.sh`: the shell check used `$SHELL` (the login shell: unset under cron or systemd, unrelated to the running shell) and made the profile refuse to diff --git a/doc/CONTRIBUTING.md b/doc/CONTRIBUTING.md index e14162e..21b4dca 100755 --- a/doc/CONTRIBUTING.md +++ b/doc/CONTRIBUTING.md @@ -120,7 +120,7 @@ brew install shellcheck - Use `getopt` (not `getopts`) for option parsing — it handles long options and `--` correctly. Rebuild the arguments from its output with the `_getopt_split` helper - (`profile.d/args.sh`), never with `eval set --` or `xargs`: + (`helpers/common.sh`), never with `eval set --` or `xargs`: ```bash PARSED=$(getopt -o hv --long help,verbose -n 'mycmd' -- "$@") || return 1 @@ -153,6 +153,29 @@ export -f my_function Add the `load_conf` call near the top after any variable declarations. +### Helpers + +Internal functions (prefixed with `_`) do not belong to the module file; they +live in the `helpers/` directory: + +- `helpers/common.sh`: helpers used by several modules (`_getopt_split`, + `_trap_restore`, `_is_uint`, `_profile_load_helpers`). It is loaded by + `profile.sh` before any module. Put a helper there as soon as a second module + needs it, instead of copying it. +- `helpers/.sh`: helpers private to `profile.d/.sh` + (for instance `helpers/rain.sh` for `profile.d/rain.sh`). The file is + optional; the module loads it with the two lines below, which also load + `common.sh` when the module is sourced alone: + + ```bash + declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" + _profile_load_helpers "${BASH_SOURCE[0]}" + ``` + +Helper files use the same copyright block as the modules and are only +definitions (no side effect besides the variables the helpers need). +Document each helper with a comment (purpose, `Usage:` line). + --- ## 7. Configuration keys @@ -176,7 +199,7 @@ See the existing themes and `README.md §4.4` for the allowed syntax. ## 9. Running shellcheck ```bash -shellcheck -x profile.sh profile.d/*.sh +shellcheck -x profile.sh profile.d/*.sh helpers/*.sh ``` All warnings must be resolved before a patch will be accepted. Accepted diff --git a/doc/FAQ.md b/doc/FAQ.md index fc35c03..39ef058 100755 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -72,7 +72,9 @@ taz -p auto -f lz mydir ``` You can also source one module directly (for example -`profile.d/compress.sh`) if you only need a subset of functions. +`profile.d/compress.sh`) if you only need a subset of functions. The module +loads the helpers it needs from the `helpers/` directory, which must stay next +to `profile.d/`. When you source a module directly, profile configuration parsing/loading from `profile.sh` is skipped, so defaults from `profile.conf` are not applied unless diff --git a/profile.d/args.sh b/helpers/common.sh similarity index 75% rename from profile.d/args.sh rename to helpers/common.sh index c4c3b35..f2c59f4 100644 --- a/profile.d/args.sh +++ b/helpers/common.sh @@ -36,8 +36,13 @@ # ------------------------------------------------------------------------------ -# Helpers for the option parsing of the commands. The project rule is to never -# use eval: these functions rebuild argument lists in pure bash. +# Helpers shared by several modules. This file is loaded by profile.sh before +# any module (and by a module sourced alone); the helpers private to one module +# live in helpers/.sh instead, loaded by the module itself through +# _profile_load_helpers. +# +# Option parsing: the project rule is to never use eval, so _getopt_split and +# _trap_restore rebuild argument lists and traps in pure bash. # Split the output of getopt into the array whose name is $1, without eval and # without xargs. getopt prints the normalised arguments as one shell-quoted @@ -113,6 +118,33 @@ _trap_restore() return 0 } export -f _trap_restore + +# True when $1 is a non-negative integer. Every value that ends up in an +# arithmetic context must pass this first (this includes values coming from the +# configuration file): bash evaluates non-numeric strings inside [[ -gt ]] or +# $(( )) as expressions, which can run arbitrary commands. +# Usage: _is_uint +_is_uint() +{ + [[ "$1" =~ ^[0-9]+$ ]] +} +export -f _is_uint + +# Source the helpers private to a module: helpers/.sh, found next +# to the directory holding the module (profile.d/../helpers), the name being the +# module file name without ".sh". A module without helpers file is fine. +# Usage: _profile_load_helpers "${BASH_SOURCE[0]}" +_profile_load_helpers() +{ + local dir=. name="${1##*/}" + [[ "$1" == */* ]] && dir="${1%/*}" + name="$dir/../helpers/${name%.sh}.sh" + [[ -r "$name" ]] || return 0 + # shellcheck source=/dev/null + . "$name" || printf "[ Warning ] Failed to source helpers: %s\n" "$name" >&2 +} +export -f _profile_load_helpers + # ------------------------------------------------------------------------------ diff --git a/helpers/conf.sh b/helpers/conf.sh new file mode 100644 index 0000000..2ca5e5a --- /dev/null +++ b/helpers/conf.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/conf.sh, loaded by the module itself. + +# Print the path of the configuration file the conf_* functions work on: +# the user file ($HOME/.profile.conf) when it exists, otherwise the installation +# file ($PROFILE_CONF, defaulting to $MYPATH/profile.conf). The returned path +# may not exist yet. +# Usage: _conf_path +_conf_path() +{ + if [[ -f "$HOME/.profile.conf" ]]; then + printf '%s\n' "$HOME/.profile.conf" + else + printf '%s\n' "${PROFILE_CONF:-${MYPATH}/profile.conf}" + fi +} +export -f _conf_path diff --git a/helpers/fun.sh b/helpers/fun.sh new file mode 100644 index 0000000..66f5022 --- /dev/null +++ b/helpers/fun.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/fun.sh, loaded by the module itself. + +# Convert a number of milliseconds into a seconds string usable by sleep +# (1500 -> 1.500), in pure bash (no awk fork per call). +# Usage: _fun_ms_to_s +_fun_ms_to_s() +{ + printf '%d.%03d' $(( $1 / 1000 )) $(( $1 % 1000 )) +} +export -f _fun_ms_to_s + +# Fail with a message unless a terminal is available: the demos wait for a key +# read from /dev/tty to stop. +# Usage: _fun_need_tty +_fun_need_tty() +{ + if ! { : /dev/null; then + disp E "$1: a terminal is required (it runs until a key is pressed)." + return 1 + fi +} +export -f _fun_need_tty + +# Wait for a key press, then stop the background job $1 and its children. +# The job runs as "( ... ) &"; with job control (interactive shell) it is the +# leader of its own process group, which is signalled as a whole. Without job +# control that group does not exist, so its direct children (the pipeline) are +# killed first, then the subshell. SIGINT is trapped meanwhile so that Ctrl-C +# stops the job like a key press instead of aborting the caller and leaving it +# running. The wait is a polling loop of short timed reads: a blocking read is +# restarted by bash after a trapped signal and would never notice the Ctrl-C. +# Usage: _fun_stop_on_key +_fun_stop_on_key() +{ + local pid=$1 old_int interrupted=0 + old_int=$(trap -p INT) + trap 'interrupted=1' INT + while (( ! interrupted )); do + IFS= read -r -n 1 -s -t 0.3 _ 128 )) || break + done + kill -- -"$pid" 2>/dev/null || { + pkill -P "$pid" 2>/dev/null + kill "$pid" 2>/dev/null + } + wait "$pid" 2>/dev/null + trap - INT + _trap_restore "$old_int" + return 0 +} +export -f _fun_stop_on_key diff --git a/helpers/git.sh b/helpers/git.sh new file mode 100644 index 0000000..e166c13 --- /dev/null +++ b/helpers/git.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/git.sh, loaded by the module itself. + +# Internal helper: ensure git is available and cwd is a git worktree +_git_require_repo() +{ + if ! command -v git >/dev/null 2>&1; then + disp E "git command not found." + return 1 + fi + + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + disp E "Current directory is not inside a git repository." + return 1 + fi + + return 0 +} +export -f _git_require_repo + +# Internal helper: return default branch from remote HEAD, fallback to config +_git_default_branch() +{ + local remote="${1:-$GIT_DEFAULT_REMOTE}" + local head + + head=$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null) || true + if [[ -n $head ]]; then + printf "%s\n" "${head#"${remote}"/}" + return 0 + fi + + printf "%s\n" "$GIT_MAIN_BRANCH" + return 0 +} +export -f _git_default_branch + +# Internal helper: print the name of the checked-out branch; fail with an error +# message on a detached HEAD (commands that push or rebase need a branch). +_git_current_branch() +{ + local b + b=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || { + disp E "HEAD is detached: check out a branch first." + return 1 + } + printf "%s\n" "$b" +} +export -f _git_current_branch + +# Internal helper: print "" for the remote branch tracked +# by local branch $1, read from its configuration (branch..remote/.merge). +# Unlike parsing the "origin/feature/x" form given by @{u}, this is exact for +# remote and branch names containing slashes. Fails when nothing is tracked or +# when the branch tracks another local branch (remote "."). +_git_tracking() +{ + local branch=$1 remote merge + remote=$(git config --get "branch.${branch}.remote") || return 1 + merge=$(git config --get "branch.${branch}.merge") || return 1 + [[ $remote == "." ]] && return 1 + printf "%s\t%s\n" "$remote" "${merge#refs/heads/}" +} +export -f _git_tracking diff --git a/helpers/packages.sh b/helpers/packages.sh new file mode 100644 index 0000000..ee9ee0e --- /dev/null +++ b/helpers/packages.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/packages.sh, loaded by the module itself. + +# Internal helper: run a package manager command, with root privileges when +# needed (sudo, else doas), or only display it in dry-run mode. +# Usage: _pkg_run [args...] +_pkg_run() +{ + local needs_root="$1" dry="$2" + shift 2 + local -a prefix=() + + if (( needs_root )) && [[ $EUID -ne 0 ]]; then + if command -v sudo >/dev/null 2>&1; then + prefix=(sudo) + elif command -v doas >/dev/null 2>&1; then + prefix=(doas) + elif (( ! dry )); then + disp E "Root privileges are required and neither sudo nor doas is available." + return 1 + fi + fi + + if (( dry )); then + # %q gives a copy-pasteable command line + printf '%q ' "${prefix[@]}" "$@" + printf '\n' + return 0 + fi + + if ! command -v "$1" >/dev/null 2>&1; then + disp E "'$1' is not installed." + return 1 + fi + "${prefix[@]}" "$@" +} +export -f _pkg_run diff --git a/helpers/processes.sh b/helpers/processes.sh new file mode 100644 index 0000000..4405340 --- /dev/null +++ b/helpers/processes.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/processes.sh, loaded by the module itself. + +# Internal helper: take the kill-style signal arguments (-9, -KILL, -SIGHUP) +# out of an argument list. getopt rejects them as unknown options, so they must +# be removed before it runs. They are recognised anywhere before a "--" +# (digits, or upper-case names, which cannot clash with the lower-case options +# of the callers); everything after "--" is left untouched. +# Usage: _proc_split_signals [args...] +_proc_split_signals() +{ + local -n _pss_sigs="$1" _pss_rest="$2" + shift 2 + _pss_sigs=() + _pss_rest=() + local a + while [[ $# -gt 0 ]]; do + a="$1" + shift + if [[ $a == "--" ]]; then + _pss_rest+=(-- "$@") + break + fi + if [[ $a =~ ^-([0-9]+|SIG[A-Z0-9+-]+|[A-Z][A-Z0-9+-]*)$ ]]; then + _pss_sigs+=("$a") + else + _pss_rest+=("$a") + fi + done +} +export -f _proc_split_signals + +# Internal helper of kt: send a signal to a process and, first, to all its +# descendants (depth first, so a parent never outlives the children it could +# respawn from). A failure does not stop the others; the status is non-zero if +# any kill failed. +# Usage: _kt_tree [kill_args...] +_kt_tree() +{ + local dry="$1" pid="$2" child rc=0 + shift 2 + for child in $(pgrep -P "$pid" 2>/dev/null); do + _kt_tree "$dry" "$child" "$@" || rc=1 + done + + if (( dry )); then + printf "DRY-RUN: " + printf "%q " kill "$@" "$pid" + printf "\n" + else + kill "$@" "$pid" || rc=1 + fi + return "$rc" +} +export -f _kt_tree diff --git a/helpers/prompt.sh b/helpers/prompt.sh new file mode 100644 index 0000000..c8f7d67 --- /dev/null +++ b/helpers/prompt.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/prompt.sh, loaded by the module itself. + +# Colour variable names defined by disp.sh. They are the only names a theme or +# the [prompt] configuration may reference (as $Name or ${Name}), and the only +# palette variables a theme may redefine. +_PROMPT_COLOR_NAMES=( + Black Red Green Yellow Blue Purple Cyan White + BBlack BRed BGreen BYellow BBlue BPurple BCyan BWhite + UBlack URed UGreen UYellow UBlue UPurple UCyan UWhite + On_Black On_Red On_Green On_Yellow On_Blue On_Purple On_Cyan On_White + IBlack IRed IGreen IYellow IBlue IPurple ICyan IWhite + BIBlack BIRed BIGreen BIYellow BIBlue BIPurple BICyan BIWhite + On_IBlack On_IRed On_IGreen On_IYellow On_IBlue On_IPurple On_ICyan On_IWhite + DEFAULTFG DEFAULTBG DEFAULTCOL RESETCOL +) +# The same names as an ERE alternation (Black|Red|...) +_PROMPT_COLOR_RE="" +for _pc_name in "${_PROMPT_COLOR_NAMES[@]}"; do + _PROMPT_COLOR_RE+="|${_pc_name}" +done +_PROMPT_COLOR_RE="${_PROMPT_COLOR_RE#|}" +unset _pc_name + +# Check a colour value and put its resolved form in _PROMPT_RESOLVED. +# A value is a sequence of blocks, each one either +# $ColorName or ${ColorName} — colour variable from disp.sh (resolved by +# indirection, never evaluated) +# \e[...m or \033[...m — raw ANSI escape literal +# Anything else is refused (return 1): colours end up inside PS1, so they must +# not be able to carry anything else than colour codes. +# Usage: _prompt_resolve_color +_prompt_resolve_color() +{ + local rest="$1" out="" name + local ref_re='^\$(\{('"$_PROMPT_COLOR_RE"')\}|('"$_PROMPT_COLOR_RE"'))' + local ansi_re='^(\\e|\\033)\[[0-9;]*m' + + [[ -n $rest ]] || return 1 + while [[ -n $rest ]]; do + if [[ $rest =~ $ref_re ]]; then + name="${BASH_REMATCH[2]:-${BASH_REMATCH[3]}}" + out+="${!name:-}" + elif [[ $rest =~ $ansi_re ]]; then + out+="${BASH_REMATCH[0]}" + else + return 1 + fi + rest="${rest:${#BASH_REMATCH[0]}}" + done + _PROMPT_RESOLVED="$out" +} + +# Path of the theme file for a bare name (searched in the theme directory) or an +# explicit path (anything containing a slash). +# Usage: _prompt_theme_file [theme_dir] +_prompt_theme_file() +{ + local name="$1" dir="${2:-${PROMPT_THEME_DIR:-$MYPATH/profile.d/themes}}" + if [[ "$name" == */* ]]; then + printf '%s' "$name" + else + printf '%s' "$dir/${name}.theme" + fi +} + +# Apply the PROMPT_COLOR_* keys of the [prompt] section on top of the current +# colours, so they win over the theme. Their values may use $ColorName, which +# parse_conf leaves untouched while the colour variables do not exist yet. +# A value that is not a valid colour is reported and not applied. +# Usage: _prompt_apply_overrides +_prompt_apply_overrides() +{ + declare -p CONF_prompt >/dev/null 2>&1 || return 0 + + local k + for k in "${!CONF_prompt[@]}"; do + [[ $k == PROMPT_COLOR_* ]] || continue + if _prompt_resolve_color "${CONF_prompt[$k]}"; then + export "$k=$_PROMPT_RESOLVED" + else + printf "[ Warning ] [prompt] %s: invalid colour value '%s', ignoring.\n" \ + "$k" "${CONF_prompt[$k]}" >&2 + # load_conf exported the raw text: do not keep it as a colour + [[ "${!k:-}" == "${CONF_prompt[$k]}" ]] && unset "$k" + fi + done + return 0 +} + +# Switch to a theme from a clean state: default palette, no PROMPT_COLOR_* +# left over from the previous theme (a partial theme must not inherit them), +# the theme, then the [prompt] overrides. The theme file is checked first, so a +# wrong name changes nothing. +# Usage: _prompt_load_theme_clean [theme_dir] +_prompt_load_theme_clean() +{ + local file v + file=$(_prompt_theme_file "$1" "${2:-}") + if [[ ! -f "$file" || ! -r "$file" ]]; then + printf "[ Warning ] set_theme: theme file not found: %s\n" "$file" >&2 + return 1 + fi + + set_colors + for v in ${!PROMPT_COLOR_@}; do + unset "$v" + done + load_theme "$1" "${2:-}" || return 1 + _prompt_apply_overrides +} + +# Put the current time, in microseconds since the epoch, in _timer_us. +# EPOCHREALTIME (bash >= 5) costs no process; its decimal separator follows the +# locale ("." or ","), hence the removal of both. Otherwise "date +%s%N" is +# used (it prints a literal "N" where nanoseconds are not supported, which the +# digit check rejects), then plain seconds. +_timer_now() +{ + if [[ -n "${EPOCHREALTIME:-}" ]]; then + _timer_us="${EPOCHREALTIME//[.,]/}" + else + local t + t=$(date +%s%N 2>/dev/null) + if [[ $t =~ ^[0-9]{16,}$ ]]; then + _timer_us=$(( t / 1000 )) + else + _timer_us=$(( $(date +%s) * 1000000 )) + fi + fi +} + +# Prompt segment: set _PROMPT_SEG to the git branch (or short commit when +# detached) with dirty/ahead/behind markers; empty outside a repository or when +# disabled by PROMPT_SHOW_GIT / PROMPT_SHOW_GIT_STATUS. +_prompt_seg_git() +{ + _PROMPT_SEG="" + # Fast path: skip git lookup when feature is disabled. + [[ "${PROMPT_SHOW_GIT:-1}" == "0" ]] && return 0 + + local branch + branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || \ + branch=$(git rev-parse --short HEAD 2>/dev/null) || return 0 + + local dirty="" sync="" timed_out=0 + + if [[ "${PROMPT_SHOW_GIT_STATUS:-1}" != "0" ]]; then + # Build a timeout wrapper if the 'timeout' command is available; + # fall back to direct execution on systems that lack it. + local -a _tw=() + command -v timeout >/dev/null 2>&1 && _tw=(timeout "${PROMPT_GIT_TIMEOUT:-2}") + + # One call for everything: --branch gives the "# branch.ab +A -B" line + # (only when an upstream exists) and each changed tracked file gives a + # line starting with 1, 2 or u. Untracked files are not counted. + # GIT_OPTIONAL_LOCKS=0 keeps git from taking the index lock to refresh + # it, which would make a concurrent "git commit" fail. + local out ec + out=$(GIT_OPTIONAL_LOCKS=0 "${_tw[@]}" git status --porcelain=v2 --branch \ + --untracked-files=no --ignore-submodules 2>/dev/null) + ec=$? + if [[ $ec -eq 124 ]]; then + timed_out=1 + else + [[ $'\n'"$out" == *$'\n'[12u]\ * ]] && dirty="*" + if [[ "$out" =~ \#\ branch\.ab\ \+([0-9]+)\ -([0-9]+) ]]; then + if [[ "${BASH_REMATCH[1]}" -gt 0 || "${BASH_REMATCH[2]}" -gt 0 ]]; then + sync=" +${BASH_REMATCH[1]}/-${BASH_REMATCH[2]}" + fi + fi + fi + fi + + if [[ $timed_out -eq 1 ]]; then + _PROMPT_SEG="git:${branch}?" + else + _PROMPT_SEG="git:${branch}${dirty}${sync}" + fi +} + +# Prompt segment: set _PROMPT_SEG to the active conda environment, if any. +_prompt_seg_conda() +{ + _PROMPT_SEG="" + [[ "${PROMPT_SHOW_CONDA:-1}" == "0" ]] && return 0 + [[ -z "${CONDA_DEFAULT_ENV:-}" ]] && return 0 + _PROMPT_SEG="conda:${CONDA_DEFAULT_ENV}" +} + +# Prompt segment: set _PROMPT_SEG to the active Python virtualenv name, unless +# a conda environment (which sets its own) is active. +_prompt_seg_venv() +{ + _PROMPT_SEG="" + [[ "${PROMPT_SHOW_VENV:-1}" == "0" ]] && return 0 + [[ -n "${CONDA_DEFAULT_ENV:-}" ]] && return 0 + [[ -z "${VIRTUAL_ENV:-}" ]] && return 0 + _PROMPT_SEG="venv:${VIRTUAL_ENV##*/}" +} + +# Prompt segment: set _PROMPT_SEG to the session kind tags (ssh, tmux, screen) +# joined with '+', empty for a plain local session. +_prompt_seg_session() +{ + _PROMPT_SEG="" + [[ "${PROMPT_SHOW_SESSION:-1}" == "0" ]] && return 0 + + local -a tags=() + [[ -n "${SSH_CONNECTION:-}" ]] && tags+=("ssh") + [[ -n "${TMUX:-}" ]] && tags+=("tmux") + [[ -n "${STY:-}" ]] && tags+=("screen") + [[ ${#tags[@]} -eq 0 ]] && return 0 + + local out="${tags[0]}" i + for ((i=1; i<${#tags[@]}; ++i)); do + out+="+${tags[i]}" + done + _PROMPT_SEG="$out" +} + +# Append the current _PROMPT_SEG to the context text _PROMPT_CTX (" | " between +# segments). An "if" keeps the status at 0 when the segment is empty. +_prompt_ctx_add() +{ + if [[ -n "$_PROMPT_SEG" ]]; then + if [[ -n "$_PROMPT_CTX" ]]; then + _PROMPT_CTX+=" | $_PROMPT_SEG" + else + _PROMPT_CTX="$_PROMPT_SEG" + fi + fi +} + +# Store the exit status of the user's last command (first command of a +# PROMPT_COMMAND that also contains other commands, see profile.sh). +_profile_save_status() +{ + _profile_status=$? +} diff --git a/helpers/pwd.sh b/helpers/pwd.sh new file mode 100644 index 0000000..3498b47 --- /dev/null +++ b/helpers/pwd.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/pwd.sh, loaded by the module itself. + +# Pool of random words read from /dev/urandom, refilled by _pwd_rand. +_PWD_POOL=() + +# Internal helper: put in _PWD_RAND an integer uniformly distributed in +# [0, $1), for 1 <= $1 <= 2^32. $RANDOM is NOT usable for passwords: it only +# has 15 bits, a state seeded from the time and the PID, and is predictable. +# The source is $SRANDOM (bash >= 5.1, from the kernel's random generator), or +# else /dev/urandom read through od, 256 words at a time to limit the number +# of processes. Values above the largest multiple of $1 are rejected, so that +# "% $1" introduces no bias. +# Usage: _pwd_rand +_pwd_rand() +{ + local max="$1" limit r + limit=$(( 4294967296 - 4294967296 % max )) + while :; do + if [[ -n "${SRANDOM+x}" ]]; then + r="$SRANDOM" + else + if (( ${#_PWD_POOL[@]} == 0 )); then + # shellcheck disable=SC2207 # whitespace-separated numbers + _PWD_POOL=( $(od -An -v -N1024 -tu4 /dev/urandom 2>/dev/null) ) + if (( ${#_PWD_POOL[@]} == 0 )); then + disp E "No source of random numbers available (/dev/urandom)." + return 1 + fi + fi + r="${_PWD_POOL[-1]}" + unset '_PWD_POOL[-1]' + fi + if (( r < limit )); then + _PWD_RAND=$(( r % max )) + return 0 + fi + done +} diff --git a/helpers/rain.sh b/helpers/rain.sh new file mode 100644 index 0000000..511e303 --- /dev/null +++ b/helpers/rain.sh @@ -0,0 +1,467 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/rain.sh, loaded by the module itself. + +# Fill RAIN_ENGINE_COLORS with the escape sequences of the drop colours (head to +# tail) of the colour theme $1: a 20-step 24-bit gradient when the terminal +# supports truecolor, a short 256-colour palette otherwise. +# Usage: _rain_build_colors +_rain_build_colors() +{ + # Case-insensitive: the configuration documents capitalised names (Green) + local base_color="${1,,}" + local i + RAIN_ENGINE_COLORS=() + + case "$base_color" in + green|blue|red|yellow|cyan|white) ;; + *) + disp E "Unknown color: ${1} (supported: green, blue, red, yellow, cyan, white)." + return 1 + ;; + esac + + local use_truecolor=0 + # term_set() already sets TERM=*-direct when truecolor is available; + # honour COLORTERM as a belt-and-suspenders fallback. + [[ "$TERM" == *direct* || "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]] && use_truecolor=1 + + if (( use_truecolor )); then + # 24-bit gradient from a near-black shade to a vivid hue. + # 20 steps provide smooth depth variation across simultaneous drops. + local steps=20 + local r1 g1 b1 r2 g2 b2 + case "${base_color}" in + green) r1=0; g1=12; b1=2; r2=50; g2=255; b2=90 ;; + blue) r1=0; g1=5; b1=25; r2=60; g2=140; b2=255 ;; + red) r1=15; g1=0; b1=0; r2=255; g2=40; b2=40 ;; + yellow) r1=25; g1=18; b1=0; r2=255; g2=240; b2=50 ;; + cyan) r1=0; g1=18; b1=18; r2=50; g2=255; b2=240 ;; + *) r1=40; g1=40; b1=45; r2=220; g2=220; b2=255 ;; + esac + + local r g b + for ((i = 0; i < steps; i++)); do + r=$(( r1 + (r2 - r1) * i / (steps - 1) )) + g=$(( g1 + (g2 - g1) * i / (steps - 1) )) + b=$(( b1 + (b2 - b1) * i / (steps - 1) )) + RAIN_ENGINE_COLORS+=("\e[38;2;${r};${g};${b}m") + done + else + # Fallback: 256-colour palettes. + case $base_color in + green) + for i in {22..28} {34..40} {46..48}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; + blue) + for i in {17..21} {27..33} {39..45}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; + red) + for i in {52..52} {88..88} {124..124} {160..160} {196..201}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; + yellow) + for i in {58..58} {100..100} {142..142} {184..184} {226..229}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; + cyan) + for i in {30..31} {37..38} {44..45} {50..51}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; + *) + RAIN_ENGINE_COLORS=("\e[37m" "\e[37;1m") + for i in {244..255}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; + esac + fi +} + +# Fill RAIN_ENGINE_CHARS with the characters a drop may be made of, according to +# the animation ($1: rain or matrix) and the matrix charset name ($2). +# Usage: _rain_build_chars +_rain_build_chars() +{ + local mode="$1" + local charset="$2" + RAIN_ENGINE_CHARS=() + + case "$mode" in + matrix) + case "$charset" in + ""|binary) + RAIN_ENGINE_CHARS=("0" "1") + ;; + kana|kanji) + # Half-width katakana set, generally rendered as single-cell glyphs. + RAIN_ENGINE_CHARS=("ア" "イ" "ウ" "エ" "オ" "カ" "キ" "ク" "ケ" "コ" "サ" "シ" "ス" "セ" "ソ" "タ" "チ" "ツ" "テ" "ト" "ナ" "ニ" "ヌ" "ネ" "ノ" "ハ" "ヒ" "フ" "ヘ" "ホ" "マ" "ミ" "ム" "メ" "モ" "ヤ" "ユ" "ヨ" "ラ" "リ" "ル" "レ" "ロ" "ワ" "ン") + ;; + ascii) + RAIN_ENGINE_CHARS=("0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F") + ;; + *) + disp E "Unknown charset: ${charset} (supported: binary, kana, ascii)." + return 1 + ;; + esac + ;; + *) + RAIN_ENGINE_CHARS=("|" "│" "┃" "┆" "┇" "┊" "┋" "╽" "╿") + ;; + esac + + return 0 +} + +# Validate a speed and print it as a delay in seconds for read -t / sleep: +# values >= 1 are centiseconds (5 -> 0.050), values < 1 are raw seconds. +# Return 1 for a non-numeric or null value. +# Usage: _rain_normalize_speed +_rain_normalize_speed() +{ + local raw_speed="$1" + + # Accept integer/floating values. UI scale is centiseconds by default: + # 5 -> 0.05s, 2.5 -> 0.025s. Values < 1 are treated as direct seconds + # for backward compatibility (e.g. 0.03). + if [[ ! "$raw_speed" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + return 1 + fi + + # A null delay is refused: "read -t 0" does not wait, the animation loop + # would spin and use a whole CPU. + if LC_ALL=C awk -v s="$raw_speed" 'BEGIN { exit !(s == 0) }'; then + return 1 + fi + + if LC_ALL=C awk -v s="$raw_speed" 'BEGIN { exit !(s < 1) }'; then + printf "%s" "$raw_speed" + else + LC_ALL=C awk -v s="$raw_speed" 'BEGIN { printf "%.3f", s / 100 }' + fi +} + +# Validate a density (maximum number of simultaneous drops, an integer >= 1) +# and print it; return 1 otherwise. +# Usage: _rain_normalize_density +_rain_normalize_density() +{ + local raw_density="$1" + + if [[ ! "$raw_density" =~ ^[0-9]+$ || "$raw_density" -lt 1 ]]; then + return 1 + fi + + printf "%s" "$raw_density" +} + +# Fill RAINBOW_BG_PALETTE with background colour escapes sweeping the +# hue wheel: 6 bands of 256 steps in 24-bit colour, or 12 fixed ANSI colours +# spread over the width otherwise. +# Usage: _rainbow_build_palette +_rainbow_build_palette() +{ + local palette_width="$1" + local use_truecolor="$2" + local x=0 wheel_pos=0 band=0 blend=0 + local red=0 green=0 blue=0 + + RAINBOW_BG_PALETTE=() + + if (( palette_width < 1 )); then + RAINBOW_BG_PALETTE+=("\e[41m") + return 0 + fi + + if (( use_truecolor )); then + for ((x = 0; x < palette_width; x++)); do + wheel_pos=$((x * 1536 / palette_width)) + band=$((wheel_pos / 256)) + blend=$((wheel_pos % 256)) + + case "$band" in + 0) + red=255; green=$blend; blue=0 + ;; + 1) + red=$((255 - blend)); green=255; blue=0 + ;; + 2) + red=0; green=255; blue=$blend + ;; + 3) + red=0; green=$((255 - blend)); blue=255 + ;; + 4) + red=$blend; green=0; blue=255 + ;; + *) + red=255; green=0; blue=$((255 - blend)) + ;; + esac + + RAINBOW_BG_PALETTE+=("\e[48;2;${red};${green};${blue}m") + done + else + local ansi_palette=(41 101 43 103 42 102 46 106 44 104 45 105) + local ansi_count=${#ansi_palette[@]} + + for ((x = 0; x < palette_width; x++)); do + RAINBOW_BG_PALETTE+=("\e[${ansi_palette[x * ansi_count / palette_width]}m") + done + fi +} + +# Stop request of the animation loops: sets exit_st, a local of the running +# animation (dynamic scoping). Installed as the TERM/INT trap. +_rain_stop() +{ + exit_st=1 +} + +# The animations read a key from the terminal and draw on it, and need tput. +# Usage: _rain_need_tty +_rain_need_tty() +{ + if [[ ! -t 0 || ! -t 1 ]]; then + disp E "$1: a terminal is required (standard input and output must be a terminal)." + return 1 + fi + command -v tput >/dev/null 2>&1 || { + disp E "The program 'tput' is required but not installed." + return 1 + } +} + +# Prepare the terminal for an animation: save its settings and the TERM, INT +# and WINCH traps of the caller, install the animation traps, disable echo, +# hide the cursor and clear the screen. +# saved_stty and saved_traps are locals of the calling animation. +# Usage: _rain_enter +_rain_enter() +{ + saved_stty=$(stty -g 2>/dev/null) + saved_traps=$(trap -p TERM INT WINCH) + trap _rain_stop TERM INT + trap "$1" WINCH + stty -echo + printf '\e[?25l\e[2J' +} + +# Undo _rain_enter: reset the colours (the last drawn drop colour would +# otherwise stay active), show the cursor, clear the screen, then restore the +# terminal settings and the caller's traps. +_rain_leave() +{ + printf '\e[0m\e[?25h\e[2J\e[H' + [[ -n "$saved_stty" ]] && stty "$saved_stty" + trap - TERM INT WINCH + _trap_restore "$saved_traps" + return 0 +} + +# Shared engine of the rain and matrix animations: draws the falling drops until +# a key is pressed, a signal is received, or the terminal is resized (the +# state is rebuilt). The terminal state is always restored on exit. +# Usage: _rain_engine +_rain_engine() +{ + local step_duration="$1" + local base_color="$2" + local mode="$3" + local charset="$4" + local density_override="$5" + + _rain_need_tty "$mode" || return 1 + + _rain_build_colors "$base_color" || return 1 + _rain_build_chars "$mode" "$charset" || return 1 + + local rain_colors=("${RAIN_ENGINE_COLORS[@]}") + local rain_chars=("${RAIN_ENGINE_CHARS[@]}") + unset RAIN_ENGINE_COLORS RAIN_ENGINE_CHARS + local rain_color_tab=${#rain_colors[@]} + local rain_tab=${#rain_chars[@]} + local matrix_head_color=$'\e[1;97m' + + local exit_st=0 + local num_rain_metadata=5 + local term_height=0 term_width=0 + local X=0 Y=0 drop_length=0 rain_drop=0 + local max_rain_width=0 max_rain_height=0 + local new_rain_odd=0 falling_odd=0 + local term_area=0 + local frame_sleep="$step_duration" + local saved_stty="" saved_traps="" + + # The drops: 5 consecutive entries each (X, Y of the top, character, colour, + # length). + local rains=() + local num_rains=0 + local ch="" + + # WINCH handler (and initial sizing). Called with the animation running, it + # sees its locals through dynamic scoping. + _rain_sigwinch() + { + term_width=$(tput cols) + term_height=$(tput lines) + term_area=$(( term_width * term_height )) + + case "$mode" in + matrix) + max_rain_width=$(( term_area / 3 )) + max_rain_height=$(( term_height < 8 ? 1 : term_height / 6 )) + new_rain_odd=$(( term_height > 50 ? 100 : term_height * 2 )) + new_rain_odd=$(( new_rain_odd * 85 / 100 )) + falling_odd=100 + + # Adapt cadence and density to terminal size for smoother rendering. + if ((term_area < 1200)); then + max_rain_width=$(( term_area / 4 )) + frame_sleep=$(LC_ALL=C awk -v s="$step_duration" 'BEGIN { printf "%.3f", s * 1.15 }') + elif ((term_area > 5000)); then + max_rain_width=$(( term_area / 2 )) + frame_sleep=$(LC_ALL=C awk -v s="$step_duration" 'BEGIN { printf "%.3f", s * 0.85 }') + else + frame_sleep="$step_duration" + fi + ;; + *) + max_rain_width=$(( term_area / 4 )) + max_rain_height=$(( term_height < 10 ? 1 : term_height / 10 )) + new_rain_odd=$(( term_height > 50 ? 100 : term_height * 2 )) + new_rain_odd=$(( new_rain_odd * 75 / 100 )) + falling_odd=$(( term_height > 25 ? 100 : term_height * 4 )) + falling_odd=$(( falling_odd * 90 / 100 )) + frame_sleep="$step_duration" + ;; + esac + + if [[ -n "$density_override" ]]; then + max_rain_width="$density_override" + fi + + # The drops were placed for the previous size and the screen content + # does not fit the new one: start again from an empty screen. + rains=() + num_rains=0 + printf '\e[2J' + } + + # Draw one frame. The whole frame (erasing the previous drop positions, + # then the new ones) is built in a string and written with one printf: a + # write per character made the animation slow and flickering. + _rain_render() + { + local idx=0 y=0 drop_color="" current_char="" render_color="" out="" + local esc=$'\e' + + for ((idx = 0; idx < num_rains * num_rain_metadata; idx += num_rain_metadata)); do + X=${rains[idx]} + Y=${rains[idx + 1]} + drop_length=${rains[idx + 4]} + for ((y = Y; y < Y + drop_length; y++)); do + ((y < 1 || y > term_height)) && continue + out+="${esc}[${y};${X}H " + done + done + + for ((idx = 0; idx < num_rains * num_rain_metadata; idx += num_rain_metadata)); do + if ((100 * RANDOM / 32768 < falling_odd)); then + if ((++rains[idx + 1] > term_height)); then + # The drop left the screen: remove it. The next drop moves + # into this slot, which must be processed again (the loop + # increment would skip it, and it would vanish for a frame). + rains=("${rains[@]:0:idx}" "${rains[@]:idx+num_rain_metadata}") + num_rains=$(( num_rains - 1 )) + idx=$(( idx - num_rain_metadata )) + continue + fi + fi + + X=${rains[idx]} + Y=${rains[idx + 1]} + rain_drop=${rains[idx + 2]} + drop_color=${rains[idx + 3]} + drop_length=${rains[idx + 4]} + + for ((y = Y; y < Y + drop_length; y++)); do + ((y < 1 || y > term_height)) && continue + if [[ "$mode" == "matrix" ]]; then + current_char="${rain_chars[rain_tab * RANDOM / 32768]}" + if ((y == Y + drop_length - 1)); then + render_color="$matrix_head_color" + else + render_color="$drop_color" + fi + else + current_char="$rain_drop" + render_color="$drop_color" + fi + # render_color holds a literal \e[...m: hence %b below + out+="${esc}[${y};${X}H${render_color}${current_char}" + done + done + + [[ -n "$out" ]] && printf '%b' "$out" + return 0 + } + + _rain_enter _rain_sigwinch + _rain_sigwinch + while ((exit_st <= 0)); do + ch="" + # A timeout makes read fail (status > 128): expected, not an error + read -r -n 1 -t "$frame_sleep" ch || true + case "$ch" in + q|Q) + _rain_stop + ;; + esac + + if ((num_rains < max_rain_width)) && ((100 * RANDOM / 32768 < new_rain_odd)); then + rain_drop="${rain_chars[rain_tab * RANDOM / 32768]}" + drop_color="${rain_colors[rain_color_tab * RANDOM / 32768]}" + if [[ "$mode" == "matrix" ]]; then + drop_length=$((max_rain_height * RANDOM / 32768 + 2)) + else + drop_length=$((max_rain_height * RANDOM / 32768 + 1)) + fi + X=$((term_width * RANDOM / 32768 + 1)) + Y=$((1 - drop_length)) + rains+=("$X" "$Y" "$rain_drop" "$drop_color" "$drop_length") + num_rains=$(( num_rains + 1 )) + fi + + _rain_render + done + + _rain_leave + unset -f _rain_sigwinch _rain_render + return 0 +} diff --git a/helpers/ssh.sh b/helpers/ssh.sh new file mode 100644 index 0000000..5c9cfbb --- /dev/null +++ b/helpers/ssh.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/ssh.sh, loaded by the module itself. + +# Internal helper: remove (a name or an IP, in known_hosts notation, so +# also "[host]:port") from the known_hosts file with ssh-keygen -R. +# ssh-keygen writes a new file: run by root on another user's file (--all-users) +# it would leave it owned by root with mode 0644, which keeps that user's ssh +# from adding host keys, or exposes a private file. The owner and mode of the +# file, and of its ".old" backup, are therefore put back. +# Returns ssh-keygen's status (non-zero when the key was not found). +# Usage: _rmhost_remove +_rmhost_remove() +{ + local key="$1" file="$2" owner="" mode="" rc=0 + if [[ ${EUID:-$(id -u)} -eq 0 ]]; then + owner=$(stat -c '%u:%g' -- "$file" 2>/dev/null) + mode=$(stat -c '%a' -- "$file" 2>/dev/null) + fi + + ssh-keygen -R "$key" -f "$file" /dev/null 2>&1 || rc=$? + + if [[ -n $owner && -n $mode ]]; then + chown "$owner" -- "$file" "${file}.old" 2>/dev/null + chmod "$mode" -- "$file" "${file}.old" 2>/dev/null + fi + return "$rc" +} +export -f _rmhost_remove diff --git a/helpers/updates.sh b/helpers/updates.sh new file mode 100644 index 0000000..f48fe34 --- /dev/null +++ b/helpers/updates.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * Redistribution and use in source and binary forms, +# * with or without modification, are permitted provided +# * that the following conditions are met: +# * +# * Redistributions of source code must retain the above +# * copyright notice, this list of conditions and the +# * following disclaimer. +# * +# * Redistributions in binary form must reproduce the above +# * copyright notice, this list of conditions and the following +# * disclaimer in the documentation and/or other materials +# * provided with the distribution. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + +# Private helpers of profile.d/updates.sh, loaded by the module itself. + +# Check a branch name. It ends up in URLs and in git command lines, so it is +# restricted to plain ref characters, without a leading "-" or "..". +# Usage: _updt_valid_branch +_updt_valid_branch() +{ + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ && "$1" != *..* ]] +} + +# Tell whether the published version $2 is an update for the installed one $1. +# Versions look like MAJOR.MINOR.PATCH[-REVISION[_STAGE_N]]. The base version +# (before the first "-" or "_") decides, compared in version order; with the same +# base, any difference (a new revision, a release candidate becoming final) is +# an update. Returns 0 when it is, 1 otherwise (equal, or the installed version +# is the more recent one, as on a development branch). +# Usage: _updt_is_newer +_updt_is_newer() +{ + local cur="$1" new="$2" cur_base new_base + [[ "$cur" == "$new" ]] && return 1 + cur_base="${cur%%[-_]*}" + new_base="${new%%[-_]*}" + [[ "$cur_base" == "$new_base" ]] && return 0 + [[ "$(printf '%s\n%s\n' "$cur_base" "$new_base" | sort -V | tail -n 1)" == "$new_base" ]] +} diff --git a/profile.d/compress.sh b/profile.d/compress.sh index b776487..bd5eac2 100644 --- a/profile.d/compress.sh +++ b/profile.d/compress.sh @@ -34,6 +34,9 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ +# Common helpers (helpers/common.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" + load_conf "compress" # ------------------------------------------------------------------------------ diff --git a/profile.d/conf.sh b/profile.d/conf.sh index c8f44cb..2db60ce 100644 --- a/profile.d/conf.sh +++ b/profile.d/conf.sh @@ -34,23 +34,9 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ -# ------------------------------------------------------------------------------ -# Print the path of the configuration file the conf_* functions work on: -# the user file ($HOME/.profile.conf) when it exists, otherwise the installation -# file ($PROFILE_CONF, defaulting to $MYPATH/profile.conf). The returned path -# may not exist yet. -# Usage: _conf_path -_conf_path() -{ - if [[ -f "$HOME/.profile.conf" ]]; then - printf '%s\n' "$HOME/.profile.conf" - else - printf '%s\n' "${PROFILE_CONF:-${MYPATH}/profile.conf}" - fi -} -export -f _conf_path -# ------------------------------------------------------------------------------ - +# Common helpers, then the ones private to this module (helpers/conf.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # Save or update a key=value pair in a section of the profile configuration file. @@ -213,9 +199,6 @@ conf_save() } } export -f conf_save -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Display the profile configuration file, with optional section and key filters. # The same file resolution as conf_save is used: $HOME/.profile.conf when @@ -401,9 +384,6 @@ conf_dump() ' "$conf_file" "$conf_file" } export -f conf_dump -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Set TERM to the best terminal capability available on this system. # Called automatically at sourcing time. @@ -526,8 +506,6 @@ term_set() export -f term_set # ------------------------------------------------------------------------------ - term_set - # EOF diff --git a/profile.d/debug.sh b/profile.d/debug.sh index 9a45618..87f5fca 100644 --- a/profile.d/debug.sh +++ b/profile.d/debug.sh @@ -34,6 +34,9 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ +# Common helpers (helpers/common.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" + load_conf "debug" # ------------------------------------------------------------------------------ diff --git a/profile.d/disp.sh b/profile.d/disp.sh index 0e2571f..b3dbffa 100644 --- a/profile.d/disp.sh +++ b/profile.d/disp.sh @@ -34,6 +34,9 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ +# Common helpers (helpers/common.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" + load_conf disp # ------------------------------------------------------------------------------ diff --git a/profile.d/filefct.sh b/profile.d/filefct.sh index 8725715..7659c63 100644 --- a/profile.d/filefct.sh +++ b/profile.d/filefct.sh @@ -34,6 +34,9 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ +# Common helpers (helpers/common.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" + load_conf "filefct" # ------------------------------------------------------------------------------ diff --git a/profile.d/fun.sh b/profile.d/fun.sh index 3be7f82..f13e9f9 100644 --- a/profile.d/fun.sh +++ b/profile.d/fun.sh @@ -36,73 +36,9 @@ load_conf "fun" -# ------------------------------------------------------------------------------ -# Shared helpers of the demo functions below (busy, fake_compile, hack). -# They are exported because the public functions are. - -# True when $1 is a non-negative integer. Every value that ends up in an -# arithmetic context must pass this first (this includes values coming from the -# configuration file): bash evaluates non-numeric strings inside [[ -gt ]] or -# $(( )) as expressions, which can run arbitrary commands. -# Usage: _fun_is_uint -_fun_is_uint() -{ - [[ "$1" =~ ^[0-9]+$ ]] -} -export -f _fun_is_uint - -# Convert a number of milliseconds into a seconds string usable by sleep -# (1500 -> 1.500), in pure bash (no awk fork per call). -# Usage: _fun_ms_to_s -_fun_ms_to_s() -{ - printf '%d.%03d' $(( $1 / 1000 )) $(( $1 % 1000 )) -} -export -f _fun_ms_to_s - -# Fail with a message unless a terminal is available: the demos wait for a key -# read from /dev/tty to stop. -# Usage: _fun_need_tty -_fun_need_tty() -{ - if ! { : /dev/null; then - disp E "$1: a terminal is required (it runs until a key is pressed)." - return 1 - fi -} -export -f _fun_need_tty - -# Wait for a key press, then stop the background job $1 and its children. -# The job runs as "( ... ) &"; with job control (interactive shell) it is the -# leader of its own process group, which is signalled as a whole. Without job -# control that group does not exist, so its direct children (the pipeline) are -# killed first, then the subshell. SIGINT is trapped meanwhile so that Ctrl-C -# stops the job like a key press instead of aborting the caller and leaving it -# running. The wait is a polling loop of short timed reads: a blocking read is -# restarted by bash after a trapped signal and would never notice the Ctrl-C. -# Usage: _fun_stop_on_key -_fun_stop_on_key() -{ - local pid=$1 old_int interrupted=0 - old_int=$(trap -p INT) - trap 'interrupted=1' INT - while (( ! interrupted )); do - IFS= read -r -n 1 -s -t 0.3 _ 128 )) || break - done - kill -- -"$pid" 2>/dev/null || { - pkill -P "$pid" 2>/dev/null - kill "$pid" 2>/dev/null - } - wait "$pid" 2>/dev/null - trap - INT - _trap_restore "$old_int" - return 0 -} -export -f _fun_stop_on_key -# ------------------------------------------------------------------------------ +# Common helpers, then the ones private to this module (helpers/fun.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # Make non-IT peoples think you're busy doing something hard: print the lines of @@ -165,7 +101,7 @@ busy() [[ $# -gt 0 ]] && pattern="$*" # Checked here so that the configured default is validated as well as -d - if ! _fun_is_uint "$delay_ms"; then + if ! _is_uint "$delay_ms"; then disp E "Invalid delay '${delay_ms}': must be an integer (milliseconds)." return 1 fi @@ -191,9 +127,6 @@ busy() _fun_stop_on_key "$sub_pid" } export -f busy -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Simulate a long and complex compilation process # Usage: fake_compile [options] @@ -280,7 +213,7 @@ fake_compile() # Validated here (not only in the options) so that the values coming from # the configuration file are checked too. - if ! _fun_is_uint "$min_ms" || ! _fun_is_uint "$max_ms"; then + if ! _is_uint "$min_ms" || ! _is_uint "$max_ms"; then disp E "min-delay and max-delay must be integers (milliseconds)." return 1 fi @@ -396,9 +329,6 @@ fake_compile() _fun_stop_on_key "$sub_pid" } export -f fake_compile -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Simulate a dramatic hacking sequence # Usage: hack [options] @@ -470,7 +400,7 @@ hack() esac done - if ! _fun_is_uint "$min_ms" || ! _fun_is_uint "$max_ms"; then + if ! _is_uint "$min_ms" || ! _is_uint "$max_ms"; then disp E "min-delay and max-delay must be integers (milliseconds)." return 1 fi @@ -598,5 +528,4 @@ hack() export -f hack # ------------------------------------------------------------------------------ - # EOF diff --git a/profile.d/git.sh b/profile.d/git.sh index adcc61d..1b7207f 100644 --- a/profile.d/git.sh +++ b/profile.d/git.sh @@ -36,6 +36,10 @@ load_conf git +# Common helpers, then the ones private to this module (helpers/git.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" + # ------------------------------------------------------------------------------ # Built-in defaults (can be overridden from [git] section in profile.conf) : "${GIT_MAIN_BRANCH:=main}" @@ -43,81 +47,6 @@ load_conf git : "${GIT_WIP_PREFIX:=wip}" : "${GIT_GACP_AUTO_ADD:=1}" - -# ------------------------------------------------------------------------------ -# Internal helper: ensure git is available and cwd is a git worktree -_git_require_repo() -{ - if ! command -v git >/dev/null 2>&1; then - disp E "git command not found." - return 1 - fi - - if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - disp E "Current directory is not inside a git repository." - return 1 - fi - - return 0 -} -export -f _git_require_repo -# ------------------------------------------------------------------------------ - - -# ------------------------------------------------------------------------------ -# Internal helper: return default branch from remote HEAD, fallback to config -_git_default_branch() -{ - local remote="${1:-$GIT_DEFAULT_REMOTE}" - local head - - head=$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null) || true - if [[ -n $head ]]; then - printf "%s\n" "${head#"${remote}"/}" - return 0 - fi - - printf "%s\n" "$GIT_MAIN_BRANCH" - return 0 -} -export -f _git_default_branch -# ------------------------------------------------------------------------------ - - -# ------------------------------------------------------------------------------ -# Internal helper: print the name of the checked-out branch; fail with an error -# message on a detached HEAD (commands that push or rebase need a branch). -_git_current_branch() -{ - local b - b=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || { - disp E "HEAD is detached: check out a branch first." - return 1 - } - printf "%s\n" "$b" -} -export -f _git_current_branch -# ------------------------------------------------------------------------------ - - -# ------------------------------------------------------------------------------ -# Internal helper: print "" for the remote branch tracked -# by local branch $1, read from its configuration (branch..remote/.merge). -# Unlike parsing the "origin/feature/x" form given by @{u}, this is exact for -# remote and branch names containing slashes. Fails when nothing is tracked or -# when the branch tracks another local branch (remote "."). -_git_tracking() -{ - local branch=$1 remote merge - remote=$(git config --get "branch.${branch}.remote") || return 1 - merge=$(git config --get "branch.${branch}.merge") || return 1 - [[ $remote == "." ]] && return 1 - printf "%s\t%s\n" "$remote" "${merge#refs/heads/}" -} -export -f _git_tracking -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Display compact git status + branch tracking information # Usage: gst [path] @@ -165,9 +94,6 @@ gst() git -C "$target" status --short --branch } export -f gst -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Show a readable commit graph # Usage: ggraph [-n limit] @@ -219,9 +145,6 @@ ggraph() git log --graph --decorate --oneline --all --max-count="$limit" } export -f ggraph -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Sync current branch with remote (fetch + rebase) # Usage: gsync [remote] @@ -288,9 +211,6 @@ gsync() git rebase "$upstream" } export -f gsync -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Add, commit, and push changes with automatic pull/rebase if needed # Usage: gacp -m "message" [file1 file2 ...] @@ -414,9 +334,6 @@ gacp() return 0 } export -f gacp -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Reset local branch to exact upstream state (stash local changes first) # Usage: greset [target] @@ -553,9 +470,6 @@ greset() return 0 } export -f greset -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Create a quick WIP commit for local checkpointing # Usage: gwip [message] @@ -604,9 +518,6 @@ gwip() git commit -m "$msg" } export -f gwip -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Delete merged local branches (except protected branches) # Usage: gprune [-D|--deep] [main-branch] @@ -728,9 +639,6 @@ gprune() fi } export -f gprune -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Print repository root path # Usage: groot @@ -790,5 +698,4 @@ groot() export -f groot # ------------------------------------------------------------------------------ - # EOF diff --git a/profile.d/info.sh b/profile.d/info.sh index 5d69643..07f1f59 100644 --- a/profile.d/info.sh +++ b/profile.d/info.sh @@ -34,6 +34,9 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ +# Common helpers (helpers/common.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" + load_conf info # ------------------------------------------------------------------------------ diff --git a/profile.d/lang.sh b/profile.d/lang.sh index 563cbd1..74887e0 100644 --- a/profile.d/lang.sh +++ b/profile.d/lang.sh @@ -34,6 +34,9 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ +# Common helpers (helpers/common.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" + load_conf lang # ------------------------------------------------------------------------------ diff --git a/profile.d/net.sh b/profile.d/net.sh index eb90864..b49096d 100644 --- a/profile.d/net.sh +++ b/profile.d/net.sh @@ -36,6 +36,10 @@ load_conf "net" +# Common helpers, then the ones private to this module (helpers/packages.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" + # ------------------------------------------------------------------------------ # Download a resource using curl, wget, or fetch. # Usage: dwl [-t ] [-r|--resume] [output_file] diff --git a/profile.d/packages.sh b/profile.d/packages.sh index b01ca1e..c66a49b 100644 --- a/profile.d/packages.sh +++ b/profile.d/packages.sh @@ -36,6 +36,9 @@ load_conf "packages" +# Common helpers, then the ones private to this module (helpers/packages.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # Detect the active package manager of the current distribution. @@ -116,9 +119,6 @@ get_pkgmgr() return 1 } export -f get_pkgmgr -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Look for a package within installed one # Usage: pkgs @@ -197,9 +197,6 @@ pkgs() "${list_cmd[@]}" | grep ${grep_opt:+"$grep_opt"} -e "$pkg" } export -f pkgs -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Find which installed package a file belongs to. # Usage: pkgf @@ -286,47 +283,6 @@ pkgf() esac } export -f pkgf -# ------------------------------------------------------------------------------ - - -# ------------------------------------------------------------------------------ -# Internal helper: run a package manager command, with root privileges when -# needed (sudo, else doas), or only display it in dry-run mode. -# Usage: _pkg_run [args...] -_pkg_run() -{ - local needs_root="$1" dry="$2" - shift 2 - local -a prefix=() - - if (( needs_root )) && [[ $EUID -ne 0 ]]; then - if command -v sudo >/dev/null 2>&1; then - prefix=(sudo) - elif command -v doas >/dev/null 2>&1; then - prefix=(doas) - elif (( ! dry )); then - disp E "Root privileges are required and neither sudo nor doas is available." - return 1 - fi - fi - - if (( dry )); then - # %q gives a copy-pasteable command line - printf '%q ' "${prefix[@]}" "$@" - printf '\n' - return 0 - fi - - if ! command -v "$1" >/dev/null 2>&1; then - disp E "'$1' is not installed." - return 1 - fi - "${prefix[@]}" "$@" -} -export -f _pkg_run -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Install packages with the package manager of the distribution. # Usage: pkgi [options] [package...] @@ -441,9 +397,6 @@ pkgi() _pkg_run "$needs_root" "$dry_run" "${cmd[@]}" "$@" } export -f pkgi -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Uninstall packages with the package manager of the distribution. # Usage: pkgr [options] [package...] @@ -597,5 +550,4 @@ pkgr() export -f pkgr # ------------------------------------------------------------------------------ - # EOF diff --git a/profile.d/processes.sh b/profile.d/processes.sh index cda2097..584f0d1 100644 --- a/profile.d/processes.sh +++ b/profile.d/processes.sh @@ -36,6 +36,9 @@ load_conf "processes" +# Common helpers, then the ones private to this module (helpers/processes.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # Search processes matching the given string @@ -106,9 +109,6 @@ ppg() ' } export -f ppg -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # List processes owned by a specific user # Usage: ppu @@ -156,9 +156,6 @@ ppu() ps -u "$1" -o "${PPU_DEFAULT_FORMAT:-pid,user,%cpu,%mem,start,time,command}" } export -f ppu -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # List processes by exact command name (no path/parameters) # Usage: ppn @@ -217,9 +214,6 @@ ppn() ' } export -f ppn -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Get PID list of the given process name # Usage: gpid @@ -300,41 +294,6 @@ gpid() (( found )) || return 1 } export -f gpid -# ------------------------------------------------------------------------------ - - -# ------------------------------------------------------------------------------ -# Internal helper: take the kill-style signal arguments (-9, -KILL, -SIGHUP) -# out of an argument list. getopt rejects them as unknown options, so they must -# be removed before it runs. They are recognised anywhere before a "--" -# (digits, or upper-case names, which cannot clash with the lower-case options -# of the callers); everything after "--" is left untouched. -# Usage: _proc_split_signals [args...] -_proc_split_signals() -{ - local -n _pss_sigs="$1" _pss_rest="$2" - shift 2 - _pss_sigs=() - _pss_rest=() - local a - while [[ $# -gt 0 ]]; do - a="$1" - shift - if [[ $a == "--" ]]; then - _pss_rest+=(-- "$@") - break - fi - if [[ $a =~ ^-([0-9]+|SIG[A-Z0-9+-]+|[A-Z][A-Z0-9+-]*)$ ]]; then - _pss_sigs+=("$a") - else - _pss_rest+=("$a") - fi - done -} -export -f _proc_split_signals -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Kill all processes owned by the given users (kill user) # Usage: ku [options] @@ -460,36 +419,6 @@ ku() return "$rc" } export -f ku -# ------------------------------------------------------------------------------ - - -# ------------------------------------------------------------------------------ -# Internal helper of kt: send a signal to a process and, first, to all its -# descendants (depth first, so a parent never outlives the children it could -# respawn from). A failure does not stop the others; the status is non-zero if -# any kill failed. -# Usage: _kt_tree [kill_args...] -_kt_tree() -{ - local dry="$1" pid="$2" child rc=0 - shift 2 - for child in $(pgrep -P "$pid" 2>/dev/null); do - _kt_tree "$dry" "$child" "$@" || rc=1 - done - - if (( dry )); then - printf "DRY-RUN: " - printf "%q " kill "$@" "$pid" - printf "\n" - else - kill "$@" "$pid" || rc=1 - fi - return "$rc" -} -export -f _kt_tree -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Kill all children of a process then the process (kill tree) # Usage: kt [options] @@ -580,6 +509,4 @@ kt() export -f kt # ------------------------------------------------------------------------------ - # EOF - diff --git a/profile.d/prompt.sh b/profile.d/prompt.sh index a39dcc7..dbdac8e 100644 --- a/profile.d/prompt.sh +++ b/profile.d/prompt.sh @@ -34,74 +34,11 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ - load_conf "prompt" -# ------------------------------------------------------------------------------ -# Colour variable names defined by disp.sh. They are the only names a theme or -# the [prompt] configuration may reference (as $Name or ${Name}), and the only -# palette variables a theme may redefine. -_PROMPT_COLOR_NAMES=( - Black Red Green Yellow Blue Purple Cyan White - BBlack BRed BGreen BYellow BBlue BPurple BCyan BWhite - UBlack URed UGreen UYellow UBlue UPurple UCyan UWhite - On_Black On_Red On_Green On_Yellow On_Blue On_Purple On_Cyan On_White - IBlack IRed IGreen IYellow IBlue IPurple ICyan IWhite - BIBlack BIRed BIGreen BIYellow BIBlue BIPurple BICyan BIWhite - On_IBlack On_IRed On_IGreen On_IYellow On_IBlue On_IPurple On_ICyan On_IWhite - DEFAULTFG DEFAULTBG DEFAULTCOL RESETCOL -) -# The same names as an ERE alternation (Black|Red|...) -_PROMPT_COLOR_RE="" -for _pc_name in "${_PROMPT_COLOR_NAMES[@]}"; do - _PROMPT_COLOR_RE+="|${_pc_name}" -done -_PROMPT_COLOR_RE="${_PROMPT_COLOR_RE#|}" -unset _pc_name - -# ------------------------------------------------------------------------------ -# Check a colour value and put its resolved form in _PROMPT_RESOLVED. -# A value is a sequence of blocks, each one either -# $ColorName or ${ColorName} — colour variable from disp.sh (resolved by -# indirection, never evaluated) -# \e[...m or \033[...m — raw ANSI escape literal -# Anything else is refused (return 1): colours end up inside PS1, so they must -# not be able to carry anything else than colour codes. -# Usage: _prompt_resolve_color -_prompt_resolve_color() -{ - local rest="$1" out="" name - local ref_re='^\$(\{('"$_PROMPT_COLOR_RE"')\}|('"$_PROMPT_COLOR_RE"'))' - local ansi_re='^(\\e|\\033)\[[0-9;]*m' - - [[ -n $rest ]] || return 1 - while [[ -n $rest ]]; do - if [[ $rest =~ $ref_re ]]; then - name="${BASH_REMATCH[2]:-${BASH_REMATCH[3]}}" - out+="${!name:-}" - elif [[ $rest =~ $ansi_re ]]; then - out+="${BASH_REMATCH[0]}" - else - return 1 - fi - rest="${rest:${#BASH_REMATCH[0]}}" - done - _PROMPT_RESOLVED="$out" -} - -# ------------------------------------------------------------------------------ -# Path of the theme file for a bare name (searched in the theme directory) or an -# explicit path (anything containing a slash). -# Usage: _prompt_theme_file [theme_dir] -_prompt_theme_file() -{ - local name="$1" dir="${2:-${PROMPT_THEME_DIR:-$MYPATH/profile.d/themes}}" - if [[ "$name" == */* ]]; then - printf '%s' "$name" - else - printf '%s' "$dir/${name}.theme" - fi -} +# Common helpers, then the ones private to this module (helpers/prompt.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # Parse a prompt theme file safely — it is NEVER sourced or executed. @@ -192,59 +129,6 @@ load_theme() } # Not exported, it remains private (like the other _prompt_* helpers: set_theme # and set_prompt are meant to be used from the interactive shell itself). -# ------------------------------------------------------------------------------ - - -# ------------------------------------------------------------------------------ -# Apply the PROMPT_COLOR_* keys of the [prompt] section on top of the current -# colours, so they win over the theme. Their values may use $ColorName, which -# parse_conf leaves untouched while the colour variables do not exist yet. -# A value that is not a valid colour is reported and not applied. -# Usage: _prompt_apply_overrides -_prompt_apply_overrides() -{ - declare -p CONF_prompt >/dev/null 2>&1 || return 0 - - local k - for k in "${!CONF_prompt[@]}"; do - [[ $k == PROMPT_COLOR_* ]] || continue - if _prompt_resolve_color "${CONF_prompt[$k]}"; then - export "$k=$_PROMPT_RESOLVED" - else - printf "[ Warning ] [prompt] %s: invalid colour value '%s', ignoring.\n" \ - "$k" "${CONF_prompt[$k]}" >&2 - # load_conf exported the raw text: do not keep it as a colour - [[ "${!k:-}" == "${CONF_prompt[$k]}" ]] && unset "$k" - fi - done - return 0 -} - -# ------------------------------------------------------------------------------ -# Switch to a theme from a clean state: default palette, no PROMPT_COLOR_* -# left over from the previous theme (a partial theme must not inherit them), -# the theme, then the [prompt] overrides. The theme file is checked first, so a -# wrong name changes nothing. -# Usage: _prompt_load_theme_clean [theme_dir] -_prompt_load_theme_clean() -{ - local file v - file=$(_prompt_theme_file "$1" "${2:-}") - if [[ ! -f "$file" || ! -r "$file" ]]; then - printf "[ Warning ] set_theme: theme file not found: %s\n" "$file" >&2 - return 1 - fi - - set_colors - for v in ${!PROMPT_COLOR_@}; do - unset "$v" - done - load_theme "$1" "${2:-}" || return 1 - _prompt_apply_overrides -} -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Dynamically switch the prompt theme for the current shell session. # Calls load_theme to apply the new colour values immediately, then updates @@ -419,34 +303,11 @@ set_theme() disp I "Prompt theme set to $theme_name." } export -f set_theme -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # timer_* functions : internal timing functions for the prompt. # The start is recorded by the DEBUG trap (profile.sh) before each command and # the elapsed time is computed by set_prompt, through PROMPT_COMMAND. -# Put the current time, in microseconds since the epoch, in _timer_us. -# EPOCHREALTIME (bash >= 5) costs no process; its decimal separator follows the -# locale ("." or ","), hence the removal of both. Otherwise "date +%s%N" is -# used (it prints a literal "N" where nanoseconds are not supported, which the -# digit check rejects), then plain seconds. -_timer_now() -{ - if [[ -n "${EPOCHREALTIME:-}" ]]; then - _timer_us="${EPOCHREALTIME//[.,]/}" - else - local t - t=$(date +%s%N 2>/dev/null) - if [[ $t =~ ^[0-9]{16,}$ ]]; then - _timer_us=$(( t / 1000 )) - else - _timer_us=$(( $(date +%s) * 1000000 )) - fi - fi -} - # Usage: timer_start # Record the start time of the command about to run, unless already recorded # (the DEBUG trap fires for every simple command of a pipeline or function: only @@ -498,107 +359,6 @@ timer_stop() # definition resets $?, and set_prompt must read the exit status of the user's # last command before anything else runs. -_prompt_seg_git() -{ - _PROMPT_SEG="" - # Fast path: skip git lookup when feature is disabled. - [[ "${PROMPT_SHOW_GIT:-1}" == "0" ]] && return 0 - - local branch - branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || \ - branch=$(git rev-parse --short HEAD 2>/dev/null) || return 0 - - local dirty="" sync="" timed_out=0 - - if [[ "${PROMPT_SHOW_GIT_STATUS:-1}" != "0" ]]; then - # Build a timeout wrapper if the 'timeout' command is available; - # fall back to direct execution on systems that lack it. - local -a _tw=() - command -v timeout >/dev/null 2>&1 && _tw=(timeout "${PROMPT_GIT_TIMEOUT:-2}") - - # One call for everything: --branch gives the "# branch.ab +A -B" line - # (only when an upstream exists) and each changed tracked file gives a - # line starting with 1, 2 or u. Untracked files are not counted. - # GIT_OPTIONAL_LOCKS=0 keeps git from taking the index lock to refresh - # it, which would make a concurrent "git commit" fail. - local out ec - out=$(GIT_OPTIONAL_LOCKS=0 "${_tw[@]}" git status --porcelain=v2 --branch \ - --untracked-files=no --ignore-submodules 2>/dev/null) - ec=$? - if [[ $ec -eq 124 ]]; then - timed_out=1 - else - [[ $'\n'"$out" == *$'\n'[12u]\ * ]] && dirty="*" - if [[ "$out" =~ \#\ branch\.ab\ \+([0-9]+)\ -([0-9]+) ]]; then - if [[ "${BASH_REMATCH[1]}" -gt 0 || "${BASH_REMATCH[2]}" -gt 0 ]]; then - sync=" +${BASH_REMATCH[1]}/-${BASH_REMATCH[2]}" - fi - fi - fi - fi - - if [[ $timed_out -eq 1 ]]; then - _PROMPT_SEG="git:${branch}?" - else - _PROMPT_SEG="git:${branch}${dirty}${sync}" - fi -} - -_prompt_seg_conda() -{ - _PROMPT_SEG="" - [[ "${PROMPT_SHOW_CONDA:-1}" == "0" ]] && return 0 - [[ -z "${CONDA_DEFAULT_ENV:-}" ]] && return 0 - _PROMPT_SEG="conda:${CONDA_DEFAULT_ENV}" -} - -_prompt_seg_venv() -{ - _PROMPT_SEG="" - [[ "${PROMPT_SHOW_VENV:-1}" == "0" ]] && return 0 - [[ -n "${CONDA_DEFAULT_ENV:-}" ]] && return 0 - [[ -z "${VIRTUAL_ENV:-}" ]] && return 0 - _PROMPT_SEG="venv:${VIRTUAL_ENV##*/}" -} - -_prompt_seg_session() -{ - _PROMPT_SEG="" - [[ "${PROMPT_SHOW_SESSION:-1}" == "0" ]] && return 0 - - local -a tags=() - [[ -n "${SSH_CONNECTION:-}" ]] && tags+=("ssh") - [[ -n "${TMUX:-}" ]] && tags+=("tmux") - [[ -n "${STY:-}" ]] && tags+=("screen") - [[ ${#tags[@]} -eq 0 ]] && return 0 - - local out="${tags[0]}" i - for ((i=1; i<${#tags[@]}; ++i)); do - out+="+${tags[i]}" - done - _PROMPT_SEG="$out" -} - -# Append the current _PROMPT_SEG to the context text _PROMPT_CTX (" | " between -# segments). An "if" keeps the status at 0 when the segment is empty. -_prompt_ctx_add() -{ - if [[ -n "$_PROMPT_SEG" ]]; then - if [[ -n "$_PROMPT_CTX" ]]; then - _PROMPT_CTX+=" | $_PROMPT_SEG" - else - _PROMPT_CTX="$_PROMPT_SEG" - fi - fi -} - -# Store the exit status of the user's last command (first command of a -# PROMPT_COMMAND that also contains other commands, see profile.sh). -_profile_save_status() -{ - _profile_status=$? -} - # ------------------------------------------------------------------------------ # Function triggered internally by bash : defining prompt # Usage: set_prompt @@ -680,9 +440,6 @@ set_prompt() # Print the working directory and prompt marker, then reset colour. PS1+="\[${_dir_fg}\]\\w \\\$\[$RESETCOL\] " } -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Theme and configuration loading. # Precedence (lowest → highest): @@ -703,5 +460,4 @@ unset _pt_theme _pt_dir _prompt_apply_overrides # ------------------------------------------------------------------------------ - # EOF diff --git a/profile.d/pwd.sh b/profile.d/pwd.sh index 0e3d2ba..261c5ad 100644 --- a/profile.d/pwd.sh +++ b/profile.d/pwd.sh @@ -36,41 +36,9 @@ load_conf "pwd" -# ------------------------------------------------------------------------------ -# Internal helper: put in _PWD_RAND an integer uniformly distributed in -# [0, $1), for 1 <= $1 <= 2^32. $RANDOM is NOT usable for passwords: it only -# has 15 bits, a state seeded from the time and the PID, and is predictable. -# The source is $SRANDOM (bash >= 5.1, from the kernel's random generator), or -# else /dev/urandom read through od, 256 words at a time to limit the number -# of processes. Values above the largest multiple of $1 are rejected, so that -# "% $1" introduces no bias. -# Usage: _pwd_rand -_pwd_rand() -{ - local max="$1" limit r - limit=$(( 4294967296 - 4294967296 % max )) - while :; do - if [[ -n "${SRANDOM+x}" ]]; then - r="$SRANDOM" - else - if (( ${#_PWD_POOL[@]} == 0 )); then - # shellcheck disable=SC2207 # whitespace-separated numbers - _PWD_POOL=( $(od -An -v -N1024 -tu4 /dev/urandom 2>/dev/null) ) - if (( ${#_PWD_POOL[@]} == 0 )); then - disp E "No source of random numbers available (/dev/urandom)." - return 1 - fi - fi - r="${_PWD_POOL[-1]}" - unset '_PWD_POOL[-1]' - fi - if (( r < limit )); then - _PWD_RAND=$(( r % max )) - return 0 - fi - done -} -_PWD_POOL=() +# Common helpers, then the ones private to this module (helpers/pwd.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # genpwd : generate a password with different criteria @@ -332,9 +300,6 @@ genpwd() done } export -f genpwd -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # pwdscore : score a password quality from 1 to 100 # Usage: pwdscore [options] @@ -541,5 +506,4 @@ pwdscore() export -f pwdscore # ------------------------------------------------------------------------------ - # EOF diff --git a/profile.d/rain.sh b/profile.d/rain.sh index 4fa5f5d..61a69de 100644 --- a/profile.d/rain.sh +++ b/profile.d/rain.sh @@ -36,416 +36,9 @@ load_conf "rain" -# ------------------------------------------------------------------------------ -# Generic rain-like engine and presets - -_rain_build_colors() -{ - # Case-insensitive: the configuration documents capitalised names (Green) - local base_color="${1,,}" - local i - RAIN_ENGINE_COLORS=() - - case "$base_color" in - green|blue|red|yellow|cyan|white) ;; - *) - disp E "Unknown color: ${1} (supported: green, blue, red, yellow, cyan, white)." - return 1 - ;; - esac - - local use_truecolor=0 - # term_set() already sets TERM=*-direct when truecolor is available; - # honour COLORTERM as a belt-and-suspenders fallback. - [[ "$TERM" == *direct* || "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]] && use_truecolor=1 - - if (( use_truecolor )); then - # 24-bit gradient from a near-black shade to a vivid hue. - # 20 steps provide smooth depth variation across simultaneous drops. - local steps=20 - local r1 g1 b1 r2 g2 b2 - case "${base_color}" in - green) r1=0; g1=12; b1=2; r2=50; g2=255; b2=90 ;; - blue) r1=0; g1=5; b1=25; r2=60; g2=140; b2=255 ;; - red) r1=15; g1=0; b1=0; r2=255; g2=40; b2=40 ;; - yellow) r1=25; g1=18; b1=0; r2=255; g2=240; b2=50 ;; - cyan) r1=0; g1=18; b1=18; r2=50; g2=255; b2=240 ;; - *) r1=40; g1=40; b1=45; r2=220; g2=220; b2=255 ;; - esac - - local r g b - for ((i = 0; i < steps; i++)); do - r=$(( r1 + (r2 - r1) * i / (steps - 1) )) - g=$(( g1 + (g2 - g1) * i / (steps - 1) )) - b=$(( b1 + (b2 - b1) * i / (steps - 1) )) - RAIN_ENGINE_COLORS+=("\e[38;2;${r};${g};${b}m") - done - else - # Fallback: 256-colour palettes. - case $base_color in - green) - for i in {22..28} {34..40} {46..48}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; - blue) - for i in {17..21} {27..33} {39..45}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; - red) - for i in {52..52} {88..88} {124..124} {160..160} {196..201}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; - yellow) - for i in {58..58} {100..100} {142..142} {184..184} {226..229}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; - cyan) - for i in {30..31} {37..38} {44..45} {50..51}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; - *) - RAIN_ENGINE_COLORS=("\e[37m" "\e[37;1m") - for i in {244..255}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;; - esac - fi -} - -_rain_build_chars() -{ - local mode="$1" - local charset="$2" - RAIN_ENGINE_CHARS=() - - case "$mode" in - matrix) - case "$charset" in - ""|binary) - RAIN_ENGINE_CHARS=("0" "1") - ;; - kana|kanji) - # Half-width katakana set, generally rendered as single-cell glyphs. - RAIN_ENGINE_CHARS=("ア" "イ" "ウ" "エ" "オ" "カ" "キ" "ク" "ケ" "コ" "サ" "シ" "ス" "セ" "ソ" "タ" "チ" "ツ" "テ" "ト" "ナ" "ニ" "ヌ" "ネ" "ノ" "ハ" "ヒ" "フ" "ヘ" "ホ" "マ" "ミ" "ム" "メ" "モ" "ヤ" "ユ" "ヨ" "ラ" "リ" "ル" "レ" "ロ" "ワ" "ン") - ;; - ascii) - RAIN_ENGINE_CHARS=("0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F") - ;; - *) - disp E "Unknown charset: ${charset} (supported: binary, kana, ascii)." - return 1 - ;; - esac - ;; - *) - RAIN_ENGINE_CHARS=("|" "│" "┃" "┆" "┇" "┊" "┋" "╽" "╿") - ;; - esac - - return 0 -} - -_rain_normalize_speed() -{ - local raw_speed="$1" - - # Accept integer/floating values. UI scale is centiseconds by default: - # 5 -> 0.05s, 2.5 -> 0.025s. Values < 1 are treated as direct seconds - # for backward compatibility (e.g. 0.03). - if [[ ! "$raw_speed" =~ ^[0-9]+([.][0-9]+)?$ ]]; then - return 1 - fi - - # A null delay is refused: "read -t 0" does not wait, the animation loop - # would spin and use a whole CPU. - if LC_ALL=C awk -v s="$raw_speed" 'BEGIN { exit !(s == 0) }'; then - return 1 - fi - - if LC_ALL=C awk -v s="$raw_speed" 'BEGIN { exit !(s < 1) }'; then - printf "%s" "$raw_speed" - else - LC_ALL=C awk -v s="$raw_speed" 'BEGIN { printf "%.3f", s / 100 }' - fi -} - -_rain_normalize_density() -{ - local raw_density="$1" - - if [[ ! "$raw_density" =~ ^[0-9]+$ || "$raw_density" -lt 1 ]]; then - return 1 - fi - - printf "%s" "$raw_density" -} - -_rainbow_build_palette() -{ - local palette_width="$1" - local use_truecolor="$2" - local x=0 wheel_pos=0 band=0 blend=0 - local red=0 green=0 blue=0 - - RAINBOW_BG_PALETTE=() - - if (( palette_width < 1 )); then - RAINBOW_BG_PALETTE+=("\e[41m") - return 0 - fi - - if (( use_truecolor )); then - for ((x = 0; x < palette_width; x++)); do - wheel_pos=$((x * 1536 / palette_width)) - band=$((wheel_pos / 256)) - blend=$((wheel_pos % 256)) - - case "$band" in - 0) - red=255; green=$blend; blue=0 - ;; - 1) - red=$((255 - blend)); green=255; blue=0 - ;; - 2) - red=0; green=255; blue=$blend - ;; - 3) - red=0; green=$((255 - blend)); blue=255 - ;; - 4) - red=$blend; green=0; blue=255 - ;; - *) - red=255; green=0; blue=$((255 - blend)) - ;; - esac - - RAINBOW_BG_PALETTE+=("\e[48;2;${red};${green};${blue}m") - done - else - local ansi_palette=(41 101 43 103 42 102 46 106 44 104 45 105) - local ansi_count=${#ansi_palette[@]} - - for ((x = 0; x < palette_width; x++)); do - RAINBOW_BG_PALETTE+=("\e[${ansi_palette[x * ansi_count / palette_width]}m") - done - fi -} - -# Stop request of the animation loops: sets exit_st, a local of the running -# animation (dynamic scoping). Installed as the TERM/INT trap. -_rain_stop() -{ - exit_st=1 -} - -# The animations read a key from the terminal and draw on it, and need tput. -# Usage: _rain_need_tty -_rain_need_tty() -{ - if [[ ! -t 0 || ! -t 1 ]]; then - disp E "$1: a terminal is required (standard input and output must be a terminal)." - return 1 - fi - command -v tput >/dev/null 2>&1 || { - disp E "The program 'tput' is required but not installed." - return 1 - } -} - -# Prepare the terminal for an animation: save its settings and the TERM, INT -# and WINCH traps of the caller, install the animation traps, disable echo, -# hide the cursor and clear the screen. -# saved_stty and saved_traps are locals of the calling animation. -# Usage: _rain_enter -_rain_enter() -{ - saved_stty=$(stty -g 2>/dev/null) - saved_traps=$(trap -p TERM INT WINCH) - trap _rain_stop TERM INT - trap "$1" WINCH - stty -echo - printf '\e[?25l\e[2J' -} - -# Undo _rain_enter: reset the colours (the last drawn drop colour would -# otherwise stay active), show the cursor, clear the screen, then restore the -# terminal settings and the caller's traps. -_rain_leave() -{ - printf '\e[0m\e[?25h\e[2J\e[H' - [[ -n "$saved_stty" ]] && stty "$saved_stty" - trap - TERM INT WINCH - _trap_restore "$saved_traps" - return 0 -} - -_rain_engine() -{ - local step_duration="$1" - local base_color="$2" - local mode="$3" - local charset="$4" - local density_override="$5" - - _rain_need_tty "$mode" || return 1 - - _rain_build_colors "$base_color" || return 1 - _rain_build_chars "$mode" "$charset" || return 1 - - local rain_colors=("${RAIN_ENGINE_COLORS[@]}") - local rain_chars=("${RAIN_ENGINE_CHARS[@]}") - unset RAIN_ENGINE_COLORS RAIN_ENGINE_CHARS - local rain_color_tab=${#rain_colors[@]} - local rain_tab=${#rain_chars[@]} - local matrix_head_color=$'\e[1;97m' - - local exit_st=0 - local num_rain_metadata=5 - local term_height=0 term_width=0 - local X=0 Y=0 drop_length=0 rain_drop=0 - local max_rain_width=0 max_rain_height=0 - local new_rain_odd=0 falling_odd=0 - local term_area=0 - local frame_sleep="$step_duration" - local saved_stty="" saved_traps="" - - # The drops: 5 consecutive entries each (X, Y of the top, character, colour, - # length). - local rains=() - local num_rains=0 - local ch="" - - # WINCH handler (and initial sizing). Called with the animation running, it - # sees its locals through dynamic scoping. - _rain_sigwinch() - { - term_width=$(tput cols) - term_height=$(tput lines) - term_area=$(( term_width * term_height )) - - case "$mode" in - matrix) - max_rain_width=$(( term_area / 3 )) - max_rain_height=$(( term_height < 8 ? 1 : term_height / 6 )) - new_rain_odd=$(( term_height > 50 ? 100 : term_height * 2 )) - new_rain_odd=$(( new_rain_odd * 85 / 100 )) - falling_odd=100 - - # Adapt cadence and density to terminal size for smoother rendering. - if ((term_area < 1200)); then - max_rain_width=$(( term_area / 4 )) - frame_sleep=$(LC_ALL=C awk -v s="$step_duration" 'BEGIN { printf "%.3f", s * 1.15 }') - elif ((term_area > 5000)); then - max_rain_width=$(( term_area / 2 )) - frame_sleep=$(LC_ALL=C awk -v s="$step_duration" 'BEGIN { printf "%.3f", s * 0.85 }') - else - frame_sleep="$step_duration" - fi - ;; - *) - max_rain_width=$(( term_area / 4 )) - max_rain_height=$(( term_height < 10 ? 1 : term_height / 10 )) - new_rain_odd=$(( term_height > 50 ? 100 : term_height * 2 )) - new_rain_odd=$(( new_rain_odd * 75 / 100 )) - falling_odd=$(( term_height > 25 ? 100 : term_height * 4 )) - falling_odd=$(( falling_odd * 90 / 100 )) - frame_sleep="$step_duration" - ;; - esac - - if [[ -n "$density_override" ]]; then - max_rain_width="$density_override" - fi - - # The drops were placed for the previous size and the screen content - # does not fit the new one: start again from an empty screen. - rains=() - num_rains=0 - printf '\e[2J' - } - - # Draw one frame. The whole frame (erasing the previous drop positions, - # then the new ones) is built in a string and written with one printf: a - # write per character made the animation slow and flickering. - _rain_render() - { - local idx=0 y=0 drop_color="" current_char="" render_color="" out="" - local esc=$'\e' - - for ((idx = 0; idx < num_rains * num_rain_metadata; idx += num_rain_metadata)); do - X=${rains[idx]} - Y=${rains[idx + 1]} - drop_length=${rains[idx + 4]} - for ((y = Y; y < Y + drop_length; y++)); do - ((y < 1 || y > term_height)) && continue - out+="${esc}[${y};${X}H " - done - done - - for ((idx = 0; idx < num_rains * num_rain_metadata; idx += num_rain_metadata)); do - if ((100 * RANDOM / 32768 < falling_odd)); then - if ((++rains[idx + 1] > term_height)); then - # The drop left the screen: remove it. The next drop moves - # into this slot, which must be processed again (the loop - # increment would skip it, and it would vanish for a frame). - rains=("${rains[@]:0:idx}" "${rains[@]:idx+num_rain_metadata}") - num_rains=$(( num_rains - 1 )) - idx=$(( idx - num_rain_metadata )) - continue - fi - fi - - X=${rains[idx]} - Y=${rains[idx + 1]} - rain_drop=${rains[idx + 2]} - drop_color=${rains[idx + 3]} - drop_length=${rains[idx + 4]} - - for ((y = Y; y < Y + drop_length; y++)); do - ((y < 1 || y > term_height)) && continue - if [[ "$mode" == "matrix" ]]; then - current_char="${rain_chars[rain_tab * RANDOM / 32768]}" - if ((y == Y + drop_length - 1)); then - render_color="$matrix_head_color" - else - render_color="$drop_color" - fi - else - current_char="$rain_drop" - render_color="$drop_color" - fi - # render_color holds a literal \e[...m: hence %b below - out+="${esc}[${y};${X}H${render_color}${current_char}" - done - done - - [[ -n "$out" ]] && printf '%b' "$out" - return 0 - } - - _rain_enter _rain_sigwinch - _rain_sigwinch - while ((exit_st <= 0)); do - ch="" - # A timeout makes read fail (status > 128): expected, not an error - read -r -n 1 -t "$frame_sleep" ch || true - case "$ch" in - q|Q) - _rain_stop - ;; - esac - - if ((num_rains < max_rain_width)) && ((100 * RANDOM / 32768 < new_rain_odd)); then - rain_drop="${rain_chars[rain_tab * RANDOM / 32768]}" - drop_color="${rain_colors[rain_color_tab * RANDOM / 32768]}" - if [[ "$mode" == "matrix" ]]; then - drop_length=$((max_rain_height * RANDOM / 32768 + 2)) - else - drop_length=$((max_rain_height * RANDOM / 32768 + 1)) - fi - X=$((term_width * RANDOM / 32768 + 1)) - Y=$((1 - drop_length)) - rains+=("$X" "$Y" "$rain_drop" "$drop_color" "$drop_length") - num_rains=$(( num_rains + 1 )) - fi - - _rain_render - done - - _rain_leave - unset -f _rain_sigwinch _rain_render - return 0 -} +# Common helpers, then the ones private to this module (helpers/rain.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # Let the rain fall (current style) diff --git a/profile.d/ssh.sh b/profile.d/ssh.sh index 4245b8c..34df335 100644 --- a/profile.d/ssh.sh +++ b/profile.d/ssh.sh @@ -36,34 +36,9 @@ load_conf "ssh" -# ------------------------------------------------------------------------------ -# Internal helper: remove (a name or an IP, in known_hosts notation, so -# also "[host]:port") from the known_hosts file with ssh-keygen -R. -# ssh-keygen writes a new file: run by root on another user's file (--all-users) -# it would leave it owned by root with mode 0644, which keeps that user's ssh -# from adding host keys, or exposes a private file. The owner and mode of the -# file, and of its ".old" backup, are therefore put back. -# Returns ssh-keygen's status (non-zero when the key was not found). -# Usage: _rmhost_remove -_rmhost_remove() -{ - local key="$1" file="$2" owner="" mode="" rc=0 - if [[ ${EUID:-$(id -u)} -eq 0 ]]; then - owner=$(stat -c '%u:%g' -- "$file" 2>/dev/null) - mode=$(stat -c '%a' -- "$file" 2>/dev/null) - fi - - ssh-keygen -R "$key" -f "$file" /dev/null 2>&1 || rc=$? - - if [[ -n $owner && -n $mode ]]; then - chown "$owner" -- "$file" "${file}.old" 2>/dev/null - chmod "$mode" -- "$file" "${file}.old" 2>/dev/null - fi - return "$rc" -} -export -f _rmhost_remove -# ------------------------------------------------------------------------------ - +# Common helpers, then the ones private to this module (helpers/ssh.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" # ------------------------------------------------------------------------------ # Remove host entries (name and IP) from ~/.ssh/known_hosts for the active user @@ -252,9 +227,6 @@ rmhost() done } export -f rmhost -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Login root via SSH on the given machine # Usage: ssr @@ -311,5 +283,4 @@ ssr() export -f ssr # ------------------------------------------------------------------------------ - # EOF diff --git a/profile.d/updates.sh b/profile.d/updates.sh index 1b7e84c..09bdc0a 100644 --- a/profile.d/updates.sh +++ b/profile.d/updates.sh @@ -40,14 +40,11 @@ export BASE_URL="https://git.geoffray-levasseur.org/fatalerrors/profile" # UPDT_DEFAULT_BRANCH (if set in profile.conf) is already available. load_conf "updates" +# Common helpers, then the ones private to this module (helpers/updates.sh) +declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" +_profile_load_helpers "${BASH_SOURCE[0]}" + # ------------------------------------------------------------------------------ -# Check a branch name. It ends up in URLs and in git command lines, so it is -# restricted to plain ref characters, without a leading "-" or "..". -# Usage: _updt_valid_branch -_updt_valid_branch() -{ - [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ && "$1" != *..* ]] -} if ! _updt_valid_branch "${UPDT_DEFAULT_BRANCH:-master}"; then disp W "Invalid UPDT_DEFAULT_BRANCH '${UPDT_DEFAULT_BRANCH}', using master." @@ -58,24 +55,6 @@ export UPDT_DEFAULT_BRANCH="${UPDT_DEFAULT_BRANCH:-master}" export UPDT_URL="$BASE_URL/raw/branch/$UPDT_DEFAULT_BRANCH" export ARCH_URL="$BASE_URL/archive/$UPDT_DEFAULT_BRANCH.tar.gz" -# ------------------------------------------------------------------------------ -# Tell whether the published version $2 is an update for the installed one $1. -# Versions look like MAJOR.MINOR.PATCH[-REVISION[_STAGE_N]]. The base version -# (before the first "-" or "_") decides, compared in version order; with the same -# base, any difference (a new revision, a release candidate becoming final) is -# an update. Returns 0 when it is, 1 otherwise (equal, or the installed version -# is the more recent one, as on a development branch). -# Usage: _updt_is_newer -_updt_is_newer() -{ - local cur="$1" new="$2" cur_base new_base - [[ "$cur" == "$new" ]] && return 1 - cur_base="${cur%%[-_]*}" - new_base="${new%%[-_]*}" - [[ "$cur_base" == "$new_base" ]] && return 0 - [[ "$(printf '%s\n%s\n' "$cur_base" "$new_base" | sort -V | tail -n 1)" == "$new_base" ]] -} - # ------------------------------------------------------------------------------ # Check whether a newer profile version is available # Usage: check_updates [-q] @@ -168,9 +147,6 @@ check_updates() return $result } export -f check_updates -# ------------------------------------------------------------------------------ - - # ------------------------------------------------------------------------------ # Apply the available profile upgrade # Usage: profile_upgrade [options] @@ -512,5 +488,4 @@ profile_upgrade() export -f profile_upgrade # ------------------------------------------------------------------------------ - # EOF diff --git a/profile.sh b/profile.sh index 956372d..31939e8 100644 --- a/profile.sh +++ b/profile.sh @@ -455,6 +455,16 @@ fi load_conf system # Load Bash system behavior configuration (history, pager, etc.) load_conf general # General purpose configuration (compilation flags, etc.) +# Helpers shared by several modules (each module also loads the common helpers +# itself when it is sourced alone, see _profile_load_helpers). +if [[ -r "$MYPATH/helpers/common.sh" ]]; then + # shellcheck source=helpers/common.sh + . "$MYPATH/helpers/common.sh" +else + printf "[ Error ] Common helpers not found: %s\n" "$MYPATH/helpers/common.sh" >&2 + return 1 2>/dev/null || exit 1 +fi + # Load module scripts. nullglob is only needed for the expansion: the caller's # setting is put back afterwards instead of being forced off. _profile_ng=0