#!/usr/bin/env bash # Begin profile # ------------------------------------------------------------------------------ # 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. # ------------------------------------------------------------------------------ # This first test is POSIX on purpose: it runs before anything bash-specific, so # that sourcing the file from dash, zsh, ... ends with a message instead of # cascading errors. It checks the running shell: $SHELL is the user's login # shell, which says nothing about it (and is unset under cron or systemd). if [ -z "${BASH_VERSION:-}" ]; then echo "That environment script is designed to be used with bash." >&2 echo "Please consider using bash to enjoy our features!" >&2 return 1 2>/dev/null || exit 1 fi _profile_is_sourced() { [[ "${BASH_SOURCE[0]}" != "$0" ]] } # Installation helper: write $2 (the source line) in the file $1, unless that # file already sources $3 (the profile) in some form. _profile_install_in_file() { local rc_file="$1" local source_line="$2" local target="$3" [[ -f "$rc_file" ]] || touch "$rc_file" || { printf "[ Error ] Cannot create %s\n" "$rc_file" >&2 return 1 } # The exact line, or any other sourcing statement naming the same file # (". ~/p/profile.sh", with other quotes, ...): adding another one would # load the profile twice. if grep -Fqx "$source_line" "$rc_file" || _profile_file_sources "$rc_file" "$target"; then printf "[ Info ] Already configured in %s\n" "$rc_file" return 0 fi printf "\n%s\n" "$source_line" >> "$rc_file" || { printf "[ Error ] Cannot write to %s\n" "$rc_file" >&2 return 1 } printf "[ Info ] Added profile source line to %s\n" "$rc_file" return 0 } # Detect whether $rc_file sources the file whose basename is $target_name. # Returns 0 (true) if a sourcing statement referencing $target_name is found, # ignoring commented-out lines. _profile_file_sources() { local rc_file="$1" local target_name="$2" local line [[ -f "$rc_file" ]] || return 1 while IFS= read -r line || [[ -n "$line" ]]; do # Strip comments so commented-out source lines are not matched. line="${line%%#*}" # Keep only lines whose first word is a source command (`source` or `.`). [[ "$line" =~ ^[[:space:]]*(source|\.)[[:space:]] ]] || continue # Match if the sourcing statement references the target file. [[ "$line" == *"$target_name"* ]] && return 0 done < "$rc_file" return 1 } _profile_install() { local install_bashrc=0 local install_profile=0 local target_selected=0 local script_dir source_line rc=0 while [[ $# -gt 0 ]]; do case "$1" in --bashrc) install_bashrc=1 target_selected=1 ;; --profile) install_profile=1 target_selected=1 ;; -h|--help) printf "Usage: %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}" printf "If no target is specified, both ~/.bashrc and ~/.profile are configured.\n" return 0 ;; *) printf "[ Error ] Unknown install option: %s\n" "$1" >&2 return 2 ;; esac shift done if (( target_selected == 0 )); then install_bashrc=1 install_profile=1 fi # Avoid double loading when both targets are selected and one already # sources the other: only configure the called file so profile.sh is # loaded exactly once. if (( install_bashrc == 1 && install_profile == 1 )); then if _profile_file_sources "$HOME/.bashrc" ".profile"; then printf "[ Info ] ~/.bashrc sources ~/.profile; configuring only ~/.profile to avoid double loading.\n" install_bashrc=0 elif _profile_file_sources "$HOME/.profile" ".bashrc"; then printf "[ Info ] ~/.profile sources ~/.bashrc; configuring only ~/.bashrc to avoid double loading.\n" install_profile=0 fi fi script_dir=$(dirname "$(realpath -s "${BASH_SOURCE[0]}")") # The path goes between double quotes in the rc file: escape what stays # special there (a plain path is left unchanged, so existing lines keep # matching) local escaped_dir="$script_dir" escaped_dir="${escaped_dir//\\/\\\\}" escaped_dir="${escaped_dir//\"/\\\"}" escaped_dir="${escaped_dir//\$/\\\$}" escaped_dir="${escaped_dir//\`/\\\`}" source_line="source \"$escaped_dir/profile.sh\"" if (( install_bashrc == 1 )); then _profile_install_in_file "$HOME/.bashrc" "$source_line" "$script_dir/profile.sh" || rc=$? fi if (( install_profile == 1 )); then _profile_install_in_file "$HOME/.profile" "$source_line" "$script_dir/profile.sh" || rc=$? fi return "$rc" } # Options are only handled when the file is executed: a sourced file receives # the positional parameters of whoever sources it ("./script --install" sourcing # the profile must not install anything, nor fail on an option that is not for # us). if ! _profile_is_sourced; then case "${1:-}" in --install) shift _profile_install "$@" exit $? ;; -h|--help) printf "Usage: source %s\n" "${BASH_SOURCE[0]}" printf " %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}" exit 0 ;; "") ;; *) printf "[ Error ] Unknown option: %s\n" "$1" >&2 exit 2 ;; esac printf "[ Warning ] profile.sh is designed to be sourced, not executed directly.\n" >&2 printf "Use: source \"%s\"\n" "$(realpath -s "${BASH_SOURCE[0]}")" >&2 printf "Or run: %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}" >&2 exit 1 fi # Required for associative arrays (4.0+) and namerefs (4.3+) if ((BASH_VERSINFO[0] < 4)) || [[ ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 3 ]]; then echo "[ Error ] This profile requires Bash 4.3 or higher." >&2 echo "Current version: $BASH_VERSION" >&2 return 1 fi # ------------------------------------------------------------------------------ # path* : private functions for PATH variable management pathremove() { [[ -z "$1" ]] && return 0 local IFS=':' local newpath dir local pathvar="${2:-PATH}" [[ "$pathvar" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { printf "pathremove: unsafe variable name '%s'\n" "$pathvar" >&2 return 1 } for dir in ${!pathvar}; do [[ "$dir" != "$1" ]] && newpath="${newpath:+$newpath:}$dir" done export "$pathvar=$newpath" } #pathprepend() # Unused for now, but might be useful in the future #{ # [[ -z "$1" ]] && return 0 # local pathvar="${2:-PATH}" # [[ "$pathvar" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { # printf "pathprepend: unsafe variable name '%s'\n" "$pathvar" >&2 # return 1 # } # pathremove "$1" "$pathvar" # export "$pathvar=$1${!pathvar:+:${!pathvar}}" #} # Append one directory to a path variable (PATH by default), unless it is # already in it: an entry that exists keeps its place. Moving it to the end # would, for instance, demote ~/.local/bin that Debian's ~/.profile puts first, # so that system programs shadow the user's own ones. pathappend() { [[ -z "$1" ]] && return 0 local pathvar="${2:-PATH}" [[ "$pathvar" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { printf "pathappend: unsafe variable name '%s'\n" "$pathvar" >&2 return 1 } case ":${!pathvar}:" in *":$1:"*) return 0 ;; esac export "$pathvar=${!pathvar:+${!pathvar}:}$1" } # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Configuration file parser parse_conf() { local config_file="$1" local current_section="" local key value [[ ! -f "$config_file" ]] && return 1 while IFS='=' read -r key value || [[ -n "$key" ]]; do # Internal trimming (removes leading/trailing whitespace & CR) key="${key%"${key##*[![:space:]]}"}" key="${key#"${key%%[![:space:]]*}"}" key="${key%$'\r'}" # Strip potential Windows line endings # Skip comments and empty lines [[ -z "$key" || "$key" =~ ^[#\;] ]] && continue # Section Detection: [section_name], optionally followed by a comment if [[ "$key" =~ ^\[([a-zA-Z0-9_]+)\][[:space:]]*([#\;].*)?$ ]]; then current_section="${BASH_REMATCH[1]}" declare -g -A "CONF_$current_section" continue fi # A key is a variable name: it ends up in "export" (load_conf) and as an # array subscript. Anything else (a stray line, a typo) is reported. if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then printf "[ Warning ] %s: invalid configuration line ignored: '%s'\n" \ "$config_file" "$key" >&2 continue fi # Secure Assignment (if inside a section) if [[ -n "$current_section" ]]; then # Clean the value value="${value%"${value##*[![:space:]]}"}" value="${value#"${value%%[![:space:]]*}"}" value="${value%$'\r'}" # A quoted value is what is between the quotes (a trailing comment # is dropped); an unquoted one ends at an inline comment, which is # a "#" preceded by a blank ("dark # my theme"). if [[ "$value" == \"* ]]; then value="${value#\"}" value="${value%%\"*}" elif [[ "$value" == \'* ]]; then value="${value#\'}" value="${value%%\'*}" else value="${value%%[[:space:]]#*}" value="${value%"${value##*[![:space:]]}"}" fi # Protect against command injection by disallowing certain characters in keys value="${value//\`/}" value="${value//\$\(/}" # Correctly interpretet internal variables (e.g. $HOME). In the # [prompt] section only the variables that exist are substituted: # envsubst would otherwise replace an unknown one by nothing, # wiping out the $Blue-style colour references that are resolved # later, when the colour variables exist (see prompt.sh). if [[ "$value" == *\$* ]]; then if command -v envsubst >/dev/null 2>&1; then if [[ "$current_section" == "prompt" ]]; then local _envnames="" _envname for _envname in $(compgen -e); do [[ "$_envname" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] && _envnames+="\${${_envname}}" done value=$(envsubst "$_envnames" <<< "$value") else value=$(envsubst <<< "$value") fi fi # If envsubst is unavailable, $VAR references are left as-is. fi # Use a nameref for safe, eval-free assignment local -n current_array="CONF_$current_section" # shellcheck disable=SC2034 # Dynamic var creation current_array["$key"]="$value" fi done < "$config_file" } # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Load command aliases from configuration load_alias() { local section_name="CONF_$1" # Check if the associative array exists using declare -p [[ "$(declare -p "$section_name" 2>/dev/null)" != "declare -A"* ]] && return 1 # Create a nameref to the section array local -n current_aliases="$section_name" local key # Iterate safely over the keys of the associative array for key in "${!current_aliases[@]}"; do local cmd="${current_aliases[$key]}" # Extract the base command (first word) safely without awk local base_cmd="${cmd%% *}" # Only alias if the base command is executable if command -v "$base_cmd" >/dev/null 2>&1; then # shellcheck disable=SC2139 # Dynamic alias creation alias "$key"="$cmd" fi done } # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Load configuration values as environment variables load_conf() { local section_name="CONF_$1" # Missing section is not an error: modules can rely on built-in defaults. [[ "$(declare -p "$section_name" 2>/dev/null)" != "declare -A"* ]] && return 0 local -n current_vars="$section_name" local key for key in "${!current_vars[@]}"; do # Export the key/value pair as a standard shell variable # We use 'export' directly; Bash handles the assignment safely here export "$key"="${current_vars[$key]}" done } # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # ********************************** MAIN PROGRAM ****************************** # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Store the script's path. Because we're more likely to be sourced, we use # BASH_SOURCE to get the path of the sourced file instead of $0. If profile.sh # itself is a symbolic link, it is followed to find the real installation # (profile.d, version, ...); a symlinked directory is kept as the user sees it # (realpath -s). if [[ -z "${PROFILE_PATH:-}" ]]; then _profile_src="${BASH_SOURCE[0]}" [[ -L "$_profile_src" ]] && _profile_src=$(readlink -f -- "$_profile_src") MYPATH=$(dirname "$(realpath -s -- "$_profile_src")") unset _profile_src else MYPATH="$PROFILE_PATH" fi export MYPATH if [[ ! -e "$MYPATH/profile.sh" ]]; then echo "[ Warning ] Path detection failed, trying to use pwd..." >&2 MYPATH=$(pwd) if [[ ! -e "$MYPATH/profile.sh" ]]; then echo "[ Error ] Unable to determine installation path, pretty much nothing will work." >&2 fi fi PROFVERSION="" if [[ -s "$MYPATH/version" ]]; then IFS= read -r PROFVERSION < "$MYPATH/version" PROFVERSION="${PROFVERSION%$'\r'}" else echo "[ Warning ] Unable to determine running profile version; your installation might be broken." >&2 fi export PROFVERSION # Build PATH environment variable if [[ $EUID -eq 0 ]]; then pathappend /sbin pathappend /usr/sbin fi [[ -d ~/bin ]] && pathappend ~/bin [[ -d ~/.local/bin ]] && pathappend ~/.local/bin # Parse and load general configuration export PROFILE_CONF="$MYPATH/profile.conf" parse_conf "$PROFILE_CONF" # Overload with user configuration if it exists if [[ -f "$HOME/.profile.conf" ]]; then parse_conf "$HOME/.profile.conf" 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 shopt -q nullglob && _profile_ng=1 shopt -s nullglob for _profile_script in "$MYPATH/profile.d/"*.sh; do if [[ -f "$_profile_script" && -r "$_profile_script" ]]; then # shellcheck source=/dev/null . "$_profile_script" || printf "[ Warning ] Failed to source module: %s\n" "$_profile_script" >&2 fi done unset _profile_script (( _profile_ng )) || shopt -u nullglob # Interactive shell detection. INTERACTIVE is exported for the modules, so it # is reset when the shell is not interactive: a non-interactive child of an # interactive shell would otherwise inherit INTERACTIVE=1 and run the whole # interactive part (banner, prompt, update check) inside scripts. if [[ $- == *i* ]]; then export INTERACTIVE=1 else unset INTERACTIVE fi if [[ -n "${INTERACTIVE:-}" ]]; then # Load custom bash completions shopt -s nullglob for _compl in "$MYPATH/profile.d/bash-completion/"*.sh; do # shellcheck disable=SC1090 # Dynamic sourcing of completion scripts [[ -f "$_compl" && -r "$_compl" ]] && . "$_compl" done unset _compl (( _profile_ng )) || shopt -u nullglob # Aliases load_alias aliases # Define PS1. The timer hook is a DEBUG trap: it replaces any DEBUG trap set # before (it cannot even be read from a sourced file, bash hides it there), # so load this profile before tools such as bash-preexec. trap 'timer_start' DEBUG # set_prompt is added to PROMPT_COMMAND instead of replacing it, so that # what exists (VTE's directory reporting, direnv, "history -a", ...) keeps # working. It has to read the exit status of the user's last command, which # any command running before it resets: _profile_save_status stores it # first, set_prompt (which uses it) runs last. It must also run last for the # timer: the DEBUG trap fires for every command of PROMPT_COMMAND, and the # elapsed time is measured from the first one of the user's command. # Nothing changes when PROMPT_COMMAND is empty or already has set_prompt. case "$(declare -p PROMPT_COMMAND 2>/dev/null)" in "declare -a"*) # Array form (bash 5.1+) if [[ " ${PROMPT_COMMAND[*]} " != *" set_prompt "* ]]; then if (( ${#PROMPT_COMMAND[@]} )); then PROMPT_COMMAND=(_profile_save_status "${PROMPT_COMMAND[@]}" set_prompt) else PROMPT_COMMAND=(set_prompt) fi fi ;; *) if [[ -z "${PROMPT_COMMAND:-}" ]]; then PROMPT_COMMAND='set_prompt' elif [[ "$PROMPT_COMMAND" != *set_prompt* ]]; then PROMPT_COMMAND="_profile_save_status;${PROMPT_COMMAND};set_prompt" fi ;; esac # Set default language from DEFAULT_LANG config key (set in [general]). # The value must match one of the alias names defined in SET_LOCALE so that # the corresponding set function exists after build_locale_shortcuts. if [[ -n "${DEFAULT_LANG:-}" ]]; then _lang_fn="set${DEFAULT_LANG}" if declare -F "$_lang_fn" >/dev/null 2>&1; then "$_lang_fn" else disp W "DEFAULT_LANG '$DEFAULT_LANG' has no matching locale shortcut (check SET_LOCALE in profile.conf)." fi unset _lang_fn fi showinfo && printf "\n" check_updates -q disp I "Profile version $PROFVERSION loaded..." fi unset _profile_ng # Cleanup unset -f _profile_is_sourced _profile_install_in_file _profile_file_sources _profile_install unset -f parse_conf load_alias load_conf unset -f pathremove pathprepend pathappend #return 0 # End profile.sh