diff --git a/README.md b/README.md index 6d0c68a..ae6885c 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,34 @@ mode and supports: `4x`, `5w`, `5dw`, `3rX`, and `2s` - `Esc` return to command mode - `Ctrl+G` show file, modified state, line, and column info +- `,name` then `Enter` run a command defined in `~/.bimrc` and insert its output - `:w`, `:w path`, `:q`, `:q!`, `:wq`, and `:wq path` There is no persistent ruler/status line. The bottom line is used only for commands, messages, and explicit `Ctrl+G` file info. +## `~/.bimrc` commands + +`bim` can load simple command shortcuts from `~/.bimrc`. Each non-empty, +non-comment line is a command name, an equals sign, and the shell command to run: + +```text +date=date +'%A, %B %d, %Y' +uuid=uuidgen +``` + +In command mode, type `,date` then `Enter` to run the configured `date` command +and insert its standard output at the cursor. `Esc` cancels a pending command, +and Backspace edits it before it runs. `bim` appends one space after inserted +command output so short snippets are ready for continued typing. + +Commands are not run when `bim` starts. They only run when invoked explicitly +with `,name`. `bim` does not source `~/.bimrc`; it parses command definitions as +data. For safety, `~/.bimrc` must be a regular file owned by the current user, +must not be a symlink, and must not be writable by group or other users. +Command names may contain letters, digits, underscores, and hyphens, and must +start with a letter or underscore. + Run it with: ```bash diff --git a/bim b/bim index 8a48851..ef98576 100755 --- a/bim +++ b/bim @@ -15,6 +15,8 @@ renderedMessage=$'\001' pendingInput="" pendingCommand="" commandCount="" +bimCommandActive=0 +bimCommandName="" lastChangeKeys="" insertChangeKeys="" replayKeysRemaining=0 @@ -33,6 +35,58 @@ undoCursorCol=0 undoTopRow=0 undoDirty=0 insertUndoSaved=0 +startupMessage="" +declare -A bimCommands=() + +trim_whitespace() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +load_bimrc() { + local bimrcPath="${HOME:-}/.bimrc" + local permissions line trimmed name commandText invalidCount=0 + + [[ -n "${HOME:-}" && -e "$bimrcPath" ]] || return + + if [[ -L "$bimrcPath" || ! -f "$bimrcPath" || ! -O "$bimrcPath" ]]; then + startupMessage="Ignoring unsafe ~/.bimrc" + return + fi + + permissions="$(stat -c %A "$bimrcPath" 2>/dev/null)" || { + startupMessage="Could not inspect ~/.bimrc" + return + } + if [[ "${permissions:5:1}" == "w" || "${permissions:8:1}" == "w" ]]; then + startupMessage="Ignoring group/world-writable ~/.bimrc" + return + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + trimmed="$(trim_whitespace "$line")" + [[ -z "$trimmed" || "${trimmed:0:1}" == "#" ]] && continue + if [[ "$line" != *"="* ]]; then + ((invalidCount++)) + continue + fi + name="$(trim_whitespace "${line%%=*}")" + commandText="$(trim_whitespace "${line#*=}")" + if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ && -n "$commandText" ]]; then + bimCommands["$name"]="$commandText" + else + ((invalidCount++)) + fi + done < "$bimrcPath" + + if ((invalidCount > 0)); then + startupMessage="Ignored $invalidCount invalid ~/.bimrc line(s)" + fi +} + +load_bimrc if [[ -n "$filePath" && -f "$filePath" ]]; then mapfile -t lines < "$filePath" @@ -58,6 +112,7 @@ trap cleanup EXIT trap 'exit 130' INT TERM stty raw -echo printf '\033[?1049h\033[?25l' +statusMessage="$startupMessage" screen_size() { screenRows="$(tput lines 2>/dev/null || printf '24')" @@ -97,7 +152,9 @@ file_info() { draw_message() { local message="$statusMessage" - if [[ -n "$pendingCommand" || -n "$commandCount" ]]; then + if ((bimCommandActive)); then + message=",$bimCommandName" + elif [[ -n "$pendingCommand" || -n "$commandCount" ]]; then message="${commandCount}${pendingCommand}" fi @@ -453,6 +510,82 @@ repeat_last_change() { statusMessage="" } +bim_command_exists() { + local name="$1" + [[ -n "${bimCommands[$name]+set}" ]] +} + +bim_command_has_prefix() { + local prefix="$1" + local name + for name in "${!bimCommands[@]}"; do + [[ "$name" == "$prefix"* ]] && return 0 + done + return 1 +} + +run_bim_command() { + local name="$1" + local commandText="${bimCommands[$name]}" + local output exitStatus + + output="$(bash -c "$commandText" /dev/null)" + exitStatus=$? + if ((exitStatus != 0)); then + statusMessage=",$name failed with status $exitStatus" + return + fi + if [[ -z "$output" ]]; then + statusMessage=",$name produced no output" + return + fi + + insert_text_at_cursor "${output} " + statusMessage="Inserted ,$name" +} + +handle_bim_command_key() { + local key="$1" + + case "$key" in + $'\033') + bimCommandActive=0 + bimCommandName="" + statusMessage="" + return + ;; + $'\r'|$'\n') + if bim_command_exists "$bimCommandName"; then + run_bim_command "$bimCommandName" + else + statusMessage="Unknown command: ,$bimCommandName" + fi + bimCommandActive=0 + bimCommandName="" + return + ;; + $'\177'|$'\b') + if ((${#bimCommandName} > 0)); then + bimCommandName="${bimCommandName:0:${#bimCommandName}-1}" + fi + return + ;; + esac + + if [[ "$key" =~ ^[A-Za-z0-9_-]$ ]]; then + bimCommandName+="$key" + if ! bim_command_has_prefix "$bimCommandName"; then + statusMessage="Unknown command: ,$bimCommandName" + bimCommandActive=0 + bimCommandName="" + fi + else + statusMessage="Invalid command name: ,$bimCommandName" + bimCommandActive=0 + bimCommandName="" + fi +} + save_undo() { undoLines=("${lines[@]}") undoCursorRow="$cursorRow" @@ -536,6 +669,42 @@ backspace_insert() { fi } +insert_text_at_cursor() { + local text="$1" + local line="${lines[cursorRow]}" + local prefix="${line:0:cursorCol}" + local suffix="${line:cursorCol}" + local rest="$text" + local segment + local -a parts=() + local i lastIndex + + [[ -n "$text" ]] || return 1 + + while [[ "$rest" == *$'\n'* ]]; do + segment="${rest%%$'\n'*}" + parts+=("$segment") + rest="${rest#*$'\n'}" + done + parts+=("$rest") + + save_undo + if ((${#parts[@]} == 1)); then + lines[cursorRow]="${prefix}${parts[0]}${suffix}" + cursorCol=$((cursorCol + ${#parts[0]})) + else + lastIndex=$((${#parts[@]} - 1)) + parts[0]="${prefix}${parts[0]}" + parts[lastIndex]="${parts[lastIndex]}${suffix}" + lines=("${lines[@]:0:cursorRow}" "${parts[@]}" "${lines[@]:cursorRow+1}") + cursorRow=$((cursorRow + lastIndex)) + cursorCol=$((${#parts[lastIndex]} - ${#suffix})) + fi + dirty=1 + insertUndoSaved=0 + clamp_cursor +} + delete_under_cursor() { local line="${lines[cursorRow]}" if ((cursorCol < ${#line})); then @@ -1132,6 +1301,13 @@ prompt_command() { handle_command_key() { local key="$1" local count i + if ((bimCommandActive)); then + handle_bim_command_key "$key" + commandCount="" + pendingCommand="" + return + fi + if [[ -n "$pendingCommand" ]]; then count="$(command_count)" if [[ "$pendingCommand" == "r" ]]; then @@ -1382,6 +1558,16 @@ handle_command_key() { commandCount="" prompt_command ;; + ,) + commandCount="" + if ((${#bimCommands[@]} == 0)); then + statusMessage="No ~/.bimrc commands" + else + bimCommandActive=1 + bimCommandName="" + statusMessage="" + fi + ;; $'\007') commandCount="" file_info