commit 84644276378cafb9b663e1d8ad36575c14f74462 Author: Storm Dragon Date: Thu May 14 23:22:15 2026 -0400 Instead of saying something boring like initial commit... Behold, evil and darkness unleashed upon the world... That's right, vim in bash! diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b35b20 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# bim + +`bim` is a tiny Vim-like editor written in Bash. + +This is an early prototype, not a full Vim replacement. It starts in command +mode and supports: + +- `i` insert before the cursor +- `a` append after the cursor +- `I` insert at the beginning of the line +- `A` append at the end of the line +- arrow keys, plus `h`, `j`, `k`, `l` +- `w` move forward by word +- `b` move backward by word +- `e` move to the end of the current or next word +- `x` delete the character under the cursor +- `dd` delete the current line +- `d$` or `D` delete from the cursor to the end of the line +- `dw` delete forward by word +- `db` delete backward by word +- `cw` change word +- `cc` change the current line +- `C` change from the cursor to the end of the line +- `s` substitute the character under the cursor +- `r` replace the character under the cursor +- `yy` yank the current line +- `p` paste the yanked or deleted line below the cursor +- `o` open a new line below and enter insert mode +- `O` open a new line above and enter insert mode +- `u` undo the last change +- numeric prefixes for common commands, such as `2dd`, `20yy`, `3p`, `5j`, + `4x`, `5w`, `5dw`, `3rX`, and `2s` +- `Esc` return to command mode +- `Ctrl+G` show file, modified state, line, and column info +- `: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. + +Run it with: + +```bash +./bim path/to/file +``` diff --git a/bim b/bim new file mode 100755 index 0000000..6024a85 --- /dev/null +++ b/bim @@ -0,0 +1,989 @@ +#!/usr/bin/env bash +set -uo pipefail + +filePath="${1:-}" +mode="command" +statusMessage="" +renderedMessage=$'\001' +pendingInput="" +pendingCommand="" +commandCount="" +hasYank=0 +dirty=0 +cursorRow=0 +cursorCol=0 +topRow=0 +declare -a lines=("") +declare -a yankBuffer=() +declare -a undoLines=() +hasUndo=0 +undoCursorRow=0 +undoCursorCol=0 +undoTopRow=0 +undoDirty=0 +insertUndoSaved=0 + +if [[ -n "$filePath" && -f "$filePath" ]]; then + mapfile -t lines < "$filePath" + ((${#lines[@]} == 0)) && lines=("") +fi + +cleanup() { + local rows + rows="$(tput lines 2>/dev/null || printf '24')" + printf '\033[H\033[J\033[%s;1H\033[?25h\033[?1049l' "$rows" + stty "$savedStty" + printf '\n' +} + +die() { + cleanup + printf 'bim: %s\n' "$*" >&2 + exit 1 +} + +savedStty="$(stty -g)" +trap cleanup EXIT +trap 'exit 130' INT TERM +stty raw -echo +printf '\033[?1049h\033[?25l' + +screen_size() { + screenRows="$(tput lines 2>/dev/null || printf '24')" + screenCols="$(tput cols 2>/dev/null || printf '80')" + editRows=$((screenRows - 1)) + ((editRows < 1)) && editRows=1 +} + +line_length() { + local row="$1" + printf '%s' "${#lines[row]}" +} + +clamp_cursor() { + local maxRow=$(( ${#lines[@]} - 1 )) + ((cursorRow < 0)) && cursorRow=0 + ((cursorRow > maxRow)) && cursorRow="$maxRow" + + local maxCol + maxCol="$(line_length "$cursorRow")" + ((cursorCol < 0)) && cursorCol=0 + ((cursorCol > maxCol)) && cursorCol="$maxCol" + + if ((cursorRow < topRow)); then + topRow="$cursorRow" + elif ((cursorRow >= topRow + editRows)); then + topRow=$((cursorRow - editRows + 1)) + fi +} + +file_info() { + local name="${filePath:-[No Name]}" + local dirtyMark="" + [[ "$dirty" == 1 ]] && dirtyMark=" [+]" + statusMessage="${name}${dirtyMark} line $((cursorRow + 1)) of ${#lines[@]}, col $((cursorCol + 1))" +} + +draw_message() { + if [[ "$statusMessage" != "$renderedMessage" ]]; then + printf '\033[%s;1H%-*.*s' "$screenRows" "$screenCols" "$screenCols" "$statusMessage" + renderedMessage="$statusMessage" + fi +} + +draw_screen() { + screen_size + clamp_cursor + + printf '\033[H' + local row line text + for ((row = 0; row < editRows; row++)); do + line=$((topRow + row)) + printf '\033[%s;1H\033[K' "$((row + 1))" + if ((line < ${#lines[@]})); then + text="${lines[line]}" + printf '%-*.*s' "$screenCols" "$screenCols" "$text" + else + printf '~' + fi + done + + draw_message + printf '\033[%s;%sH' "$((cursorRow - topRow + 1))" "$((cursorCol + 1))" +} + +read_key() { + local char next third rest + if [[ -n "$pendingInput" ]]; then + key="${pendingInput:0:1}" + pendingInput="${pendingInput:1}" + return 0 + fi + + IFS= read -rsN1 char || return 1 + if [[ "$char" == $'\033' ]]; then + if IFS= read -rsN1 -t 0.01 next; then + if [[ "$next" == "[" ]]; then + rest="$next" + while IFS= read -rsN1 -t 0.001 char; do + rest+="$char" + done + key=$'\033'"$rest" + elif [[ "$next" == "O" ]]; then + if IFS= read -rsN1 -t 0.001 third; then + if [[ "$third" =~ [ABCD] ]]; then + key=$'\033'"O$third" + else + pendingInput="O$third" + key=$'\033' + fi + else + pendingInput="O" + key=$'\033' + fi + else + pendingInput="$next" + key=$'\033' + fi + else + key=$'\033' + fi + else + key="$char" + fi +} + +move_left() { + if ((cursorCol > 0)); then + ((cursorCol--)) + fi +} + +move_right() { + local maxCol + maxCol="$(line_length "$cursorRow")" + if ((cursorCol < maxCol)); then + ((cursorCol++)) + fi +} + +move_up() { + ((cursorRow > 0)) && ((cursorRow--)) +} + +move_down() { + ((cursorRow + 1 < ${#lines[@]})) && ((cursorRow++)) +} + +is_blank_char() { + local char="$1" + [[ "$char" =~ [[:space:]] ]] +} + +is_last_buffer_position() { + local row="$1" + local col="$2" + ((row == ${#lines[@]} - 1 && col >= ${#lines[row]})) +} + +step_position_forward() { + local -n rowRef="$1" + local -n colRef="$2" + if ((colRef < ${#lines[rowRef]})); then + ((colRef++)) + elif ((rowRef + 1 < ${#lines[@]})); then + ((rowRef++)) + colRef=0 + fi +} + +step_position_backward() { + local -n rowRef="$1" + local -n colRef="$2" + if ((colRef > 0)); then + ((colRef--)) + elif ((rowRef > 0)); then + ((rowRef--)) + colRef="${#lines[rowRef]}" + fi +} + +move_word_forward() { + local count="$1" + local i row col char + for ((i = 0; i < count; i++)); do + row="$cursorRow" + col="$cursorCol" + if is_last_buffer_position "$row" "$col"; then + break + fi + + if ((col < ${#lines[row]})); then + char="${lines[row]:col:1}" + if ! is_blank_char "$char"; then + while ! is_last_buffer_position "$row" "$col"; do + if ((col >= ${#lines[row]})); then + break + fi + char="${lines[row]:col:1}" + is_blank_char "$char" && break + step_position_forward row col + done + fi + fi + + while ! is_last_buffer_position "$row" "$col"; do + if ((col >= ${#lines[row]})); then + step_position_forward row col + continue + fi + char="${lines[row]:col:1}" + ! is_blank_char "$char" && break + step_position_forward row col + done + + cursorRow="$row" + cursorCol="$col" + done +} + +move_word_backward() { + local count="$1" + local i row col char + for ((i = 0; i < count; i++)); do + row="$cursorRow" + col="$cursorCol" + if ((row == 0 && col == 0)); then + break + fi + + step_position_backward row col + + while ((row > 0 || col > 0)); do + if ((col >= ${#lines[row]})); then + step_position_backward row col + continue + fi + char="${lines[row]:col:1}" + ! is_blank_char "$char" && break + step_position_backward row col + done + + while ((row > 0 || col > 0)); do + if ((col == 0)); then + break + fi + char="${lines[row]:col-1:1}" + is_blank_char "$char" && break + step_position_backward row col + done + + cursorRow="$row" + cursorCol="$col" + done +} + +move_word_end_forward() { + local count="$1" + local i row col nextRow nextCol char nextChar + for ((i = 0; i < count; i++)); do + row="$cursorRow" + col="$cursorCol" + if is_last_buffer_position "$row" "$col"; then + break + fi + + if ((col < ${#lines[row]})); then + char="${lines[row]:col:1}" + nextRow="$row" + nextCol="$col" + step_position_forward nextRow nextCol + if ! is_blank_char "$char"; then + if ((nextCol >= ${#lines[nextRow]})); then + row="$nextRow" + col="$nextCol" + else + nextChar="${lines[nextRow]:nextCol:1}" + if is_blank_char "$nextChar"; then + row="$nextRow" + col="$nextCol" + fi + fi + fi + fi + + while ! is_last_buffer_position "$row" "$col"; do + if ((col >= ${#lines[row]})); then + step_position_forward row col + continue + fi + char="${lines[row]:col:1}" + ! is_blank_char "$char" && break + step_position_forward row col + done + + while ! is_last_buffer_position "$row" "$col"; do + nextRow="$row" + nextCol="$col" + step_position_forward nextRow nextCol + if ((nextCol >= ${#lines[nextRow]})); then + break + fi + nextChar="${lines[nextRow]:nextCol:1}" + is_blank_char "$nextChar" && break + row="$nextRow" + col="$nextCol" + done + + cursorRow="$row" + cursorCol="$col" + done +} + +command_count() { + if [[ -n "$commandCount" ]]; then + printf '%s' "$commandCount" + else + printf '1' + fi +} + +save_undo() { + undoLines=("${lines[@]}") + undoCursorRow="$cursorRow" + undoCursorCol="$cursorCol" + undoTopRow="$topRow" + undoDirty="$dirty" + hasUndo=1 +} + +ensure_insert_undo() { + if ((insertUndoSaved == 0)); then + save_undo + insertUndoSaved=1 + fi +} + +enter_insert_mode() { + local saveSnapshot="${1:-1}" + if ((saveSnapshot == 1)); then + save_undo + fi + insertUndoSaved=1 + mode="insert" + statusMessage="" +} + +undo_last_change() { + if ((hasUndo == 0)); then + statusMessage="Already at oldest change" + return + fi + + lines=("${undoLines[@]}") + cursorRow="$undoCursorRow" + cursorCol="$undoCursorCol" + topRow="$undoTopRow" + dirty="$undoDirty" + hasUndo=0 + insertUndoSaved=0 + statusMessage="Undone" + clamp_cursor +} + +insert_char() { + local char="$1" + local line="${lines[cursorRow]}" + ensure_insert_undo + lines[cursorRow]="${line:0:cursorCol}${char}${line:cursorCol}" + ((cursorCol++)) + dirty=1 +} + +split_line() { + local line="${lines[cursorRow]}" + local before="${line:0:cursorCol}" + local after="${line:cursorCol}" + ensure_insert_undo + lines[cursorRow]="$before" + lines=("${lines[@]:0:cursorRow+1}" "$after" "${lines[@]:cursorRow+1}") + ((cursorRow++)) + cursorCol=0 + dirty=1 +} + +backspace_insert() { + local line previous + if ((cursorCol > 0)); then + ensure_insert_undo + line="${lines[cursorRow]}" + lines[cursorRow]="${line:0:cursorCol-1}${line:cursorCol}" + ((cursorCol--)) + dirty=1 + elif ((cursorRow > 0)); then + ensure_insert_undo + previous="${lines[cursorRow-1]}" + cursorCol="${#previous}" + lines[cursorRow-1]+="${lines[cursorRow]}" + lines=("${lines[@]:0:cursorRow}" "${lines[@]:cursorRow+1}") + ((cursorRow--)) + dirty=1 + fi +} + +delete_under_cursor() { + local line="${lines[cursorRow]}" + if ((cursorCol < ${#line})); then + lines[cursorRow]="${line:0:cursorCol}${line:cursorCol+1}" + dirty=1 + fi +} + +compare_positions() { + local leftRow="$1" + local leftCol="$2" + local rightRow="$3" + local rightCol="$4" + if ((leftRow < rightRow || (leftRow == rightRow && leftCol < rightCol))); then + printf -- '-1' + elif ((leftRow == rightRow && leftCol == rightCol)); then + printf '0' + else + printf '1' + fi +} + +delete_range() { + local startRow="$1" + local startCol="$2" + local endRow="$3" + local endCol="$4" + local comparison line prefix suffix + + comparison="$(compare_positions "$startRow" "$startCol" "$endRow" "$endCol")" + if [[ "$comparison" == "0" ]]; then + return + elif [[ "$comparison" == "1" ]]; then + local tempRow="$startRow" + local tempCol="$startCol" + startRow="$endRow" + startCol="$endCol" + endRow="$tempRow" + endCol="$tempCol" + fi + + if ((startRow == endRow)); then + line="${lines[startRow]}" + lines[startRow]="${line:0:startCol}${line:endCol}" + else + prefix="${lines[startRow]:0:startCol}" + suffix="${lines[endRow]:endCol}" + lines=("${lines[@]:0:startRow}" "${prefix}${suffix}" "${lines[@]:endRow+1}") + fi + + if ((${#lines[@]} == 0)); then + lines=("") + fi + cursorRow="$startRow" + cursorCol="$startCol" + dirty=1 + statusMessage="" + clamp_cursor +} + +delete_to_end_of_line() { + local startRow="$cursorRow" + local startCol="$cursorCol" + local endCol + endCol="${#lines[startRow]}" + if ((startCol >= endCol)); then + return 1 + fi + + delete_range "$startRow" "$startCol" "$startRow" "$endCol" +} + +delete_word_forward() { + local count="$1" + local startRow="$cursorRow" + local startCol="$cursorCol" + save_undo + move_word_forward "$count" + delete_range "$startRow" "$startCol" "$cursorRow" "$cursorCol" +} + +delete_word_backward() { + local count="$1" + local startRow="$cursorRow" + local startCol="$cursorCol" + save_undo + move_word_backward "$count" + delete_range "$startRow" "$startCol" "$cursorRow" "$cursorCol" +} + +change_word() { + local count="$1" + local startRow="$cursorRow" + local startCol="$cursorCol" + local endRow endCol + save_undo + move_word_end_forward "$count" + endRow="$cursorRow" + endCol="$cursorCol" + if ! is_last_buffer_position "$endRow" "$endCol"; then + step_position_forward endRow endCol + elif ((endCol < ${#lines[endRow]})); then + ((endCol++)) + fi + delete_range "$startRow" "$startCol" "$endRow" "$endCol" + enter_insert_mode 0 +} + +change_current_line() { + save_undo + lines[cursorRow]="" + cursorCol=0 + dirty=1 + enter_insert_mode 0 +} + +change_to_end_of_line() { + save_undo + delete_to_end_of_line + enter_insert_mode 0 +} + +substitute_chars() { + local count="$1" + local startCol="$cursorCol" + local endCol=$((cursorCol + count)) + local lineLength + lineLength="${#lines[cursorRow]}" + ((endCol > lineLength)) && endCol="$lineLength" + save_undo + if ((startCol < endCol)); then + delete_range "$cursorRow" "$startCol" "$cursorRow" "$endCol" + fi + enter_insert_mode 0 +} + +replace_chars() { + local count="$1" + local replacement="$2" + local line="${lines[cursorRow]}" + local lineLength="${#line}" + local actual=0 + local i targetCol + + if [[ ! "$replacement" =~ [[:print:]] ]] || ((cursorCol >= lineLength)); then + statusMessage="" + return + fi + + save_undo + for ((i = 0; i < count; i++)); do + targetCol=$((cursorCol + i)) + ((targetCol >= lineLength)) && break + line="${line:0:targetCol}${replacement}${line:targetCol+1}" + ((actual++)) + done + lines[cursorRow]="$line" + if ((actual > 0)); then + cursorCol=$((cursorCol + actual - 1)) + fi + dirty=1 + statusMessage="" +} + +open_line_below() { + lines=("${lines[@]:0:cursorRow+1}" "" "${lines[@]:cursorRow+1}") + ((cursorRow++)) + cursorCol=0 + enter_insert_mode 0 + dirty=1 +} + +open_line_above() { + lines=("${lines[@]:0:cursorRow}" "" "${lines[@]:cursorRow}") + cursorCol=0 + enter_insert_mode 0 + dirty=1 +} + +yank_lines() { + local requested="$1" + local available=$(( ${#lines[@]} - cursorRow )) + local actual="$requested" + ((actual > available)) && actual="$available" + ((actual < 1)) && actual=1 + + yankBuffer=("${lines[@]:cursorRow:actual}") + hasYank=1 + statusMessage="$actual line yanked" + ((actual != 1)) && statusMessage="$actual lines yanked" +} + +delete_lines() { + local requested="$1" + local available=$(( ${#lines[@]} - cursorRow )) + local actual="$requested" + ((actual > available)) && actual="$available" + ((actual < 1)) && actual=1 + + save_undo + yankBuffer=("${lines[@]:cursorRow:actual}") + hasYank=1 + + lines=("${lines[@]:0:cursorRow}" "${lines[@]:cursorRow+actual}") + if ((${#lines[@]} == 0)); then + lines=("") + cursorRow=0 + elif ((cursorRow >= ${#lines[@]})); then + cursorRow=$(( ${#lines[@]} - 1 )) + fi + cursorCol=0 + statusMessage="$actual line deleted" + ((actual != 1)) && statusMessage="$actual lines deleted" + dirty=1 +} + +paste_line_below() { + local count="$1" + local insertAt=$((cursorRow + 1)) + local firstPastedRow="$insertAt" + + if ((hasYank == 0)); then + statusMessage="Nothing in yank buffer" + return + fi + + save_undo + local i + for ((i = 0; i < count; i++)); do + lines=("${lines[@]:0:insertAt}" "${yankBuffer[@]}" "${lines[@]:insertAt}") + insertAt=$((insertAt + ${#yankBuffer[@]})) + done + cursorRow="$firstPastedRow" + cursorCol=0 + statusMessage="" + dirty=1 +} + +save_file() { + local target="$1" + [[ -z "$target" ]] && return 1 + + local line + : > "$target" + for line in "${lines[@]}"; do + printf '%s\n' "$line" >> "$target" + done + filePath="$target" + dirty=0 + statusMessage="Wrote $target" +} + +prompt_command() { + local command key + command=":" + while true; do + statusMessage="$command" + draw_screen + read_key || return + case "$key" in + $'\033') + statusMessage="" + return + ;; + $'\r'|$'\n') + break + ;; + $'\177'|$'\b') + if ((${#command} > 1)); then + command="${command:0:${#command}-1}" + fi + ;; + *) + if [[ "$key" =~ [[:print:]] ]]; then + command+="$key" + fi + ;; + esac + done + + command="${command#:}" + case "$command" in + q) + if [[ "$dirty" == 1 ]]; then + statusMessage="Unsaved changes. Use :q! to quit anyway." + else + exit 0 + fi + ;; + q!) + exit 0 + ;; + w) + if save_file "$filePath"; then + : + else + statusMessage="No file name. Use :w path" + fi + ;; + w\ *) + save_file "${command#w }" || statusMessage="Could not write file" + ;; + wq) + if save_file "$filePath"; then + exit 0 + else + statusMessage="No file name. Use :w path" + fi + ;; + wq\ *) + save_file "${command#wq }" && exit 0 + statusMessage="Could not write file" + ;; + *) + statusMessage="Unknown command: :$command" + ;; + esac +} + +handle_command_key() { + local key="$1" + local count i + if [[ -n "$pendingCommand" ]]; then + count="$(command_count)" + if [[ "$pendingCommand" == "r" ]]; then + replace_chars "$count" "$key" + pendingCommand="" + commandCount="" + return + fi + case "${pendingCommand}${key}" in + dd) + delete_lines "$count" + ;; + d'$') + save_undo + delete_to_end_of_line + ;; + dw) + delete_word_forward "$count" + ;; + db) + delete_word_backward "$count" + ;; + cc) + change_current_line + ;; + c'$') + change_to_end_of_line + ;; + cw) + change_word "$count" + ;; + yy) + yank_lines "$count" + ;; + *) + statusMessage="" + ;; + esac + pendingCommand="" + commandCount="" + return + fi + + case "$key" in + [1-9]) + commandCount+="$key" + statusMessage="" + ;; + 0) + if [[ -n "$commandCount" ]]; then + commandCount+="$key" + statusMessage="" + else + cursorCol=0 + fi + ;; + i) + commandCount="" + enter_insert_mode + ;; + a) + commandCount="" + if ((cursorCol < $(line_length "$cursorRow"))); then + ((cursorCol++)) + fi + enter_insert_mode + ;; + I) + commandCount="" + cursorCol=0 + enter_insert_mode + ;; + A) + commandCount="" + cursorCol="$(line_length "$cursorRow")" + enter_insert_mode + ;; + h|$'\033[D') + count="$(command_count)" + for ((i = 0; i < count; i++)); do move_left; done + commandCount="" + ;; + l|$'\033[C') + count="$(command_count)" + for ((i = 0; i < count; i++)); do move_right; done + commandCount="" + ;; + k|$'\033[A') + count="$(command_count)" + for ((i = 0; i < count; i++)); do move_up; done + commandCount="" + ;; + j|$'\033[B') + count="$(command_count)" + for ((i = 0; i < count; i++)); do move_down; done + commandCount="" + ;; + w) + count="$(command_count)" + move_word_forward "$count" + commandCount="" + ;; + e) + count="$(command_count)" + move_word_end_forward "$count" + commandCount="" + ;; + b) + count="$(command_count)" + move_word_backward "$count" + commandCount="" + ;; + '$') + commandCount="" + cursorCol="$(line_length "$cursorRow")" + ;; + x) + count="$(command_count)" + save_undo + for ((i = 0; i < count; i++)); do delete_under_cursor; done + commandCount="" + ;; + D) + commandCount="" + save_undo + delete_to_end_of_line + ;; + C) + commandCount="" + change_to_end_of_line + ;; + s) + count="$(command_count)" + substitute_chars "$count" + commandCount="" + ;; + r) + pendingCommand="$key" + statusMessage="" + ;; + d|y|c) + pendingCommand="$key" + statusMessage="" + ;; + p) + count="$(command_count)" + paste_line_below "$count" + commandCount="" + ;; + o) + count="$(command_count)" + save_undo + for ((i = 0; i < count; i++)); do open_line_below; done + commandCount="" + ;; + O) + count="$(command_count)" + save_undo + for ((i = 0; i < count; i++)); do open_line_above; done + commandCount="" + ;; + :) + commandCount="" + prompt_command + ;; + $'\007') + commandCount="" + file_info + ;; + u) + commandCount="" + undo_last_change + ;; + $'\003') + commandCount="" + statusMessage="Use :q to quit" + ;; + *) + commandCount="" + ;; + esac +} + +handle_insert_key() { + local key="$1" + case "$key" in + $'\033') + mode="command" + insertUndoSaved=0 + statusMessage="" + ;; + $'\033[D') + move_left + ;; + $'\033[C') + move_right + ;; + $'\033[A') + move_up + ;; + $'\033[B') + move_down + ;; + $'\r'|$'\n') + split_line + ;; + $'\177'|$'\b') + backspace_insert + ;; + $'\007') + file_info + ;; + $'\003') + mode="command" + insertUndoSaved=0 + statusMessage="Use :q to quit" + ;; + *) + if [[ "$key" =~ [[:print:]] ]]; then + insert_char "$key" + fi + ;; + esac +} + +while true; do + draw_screen + read_key || exit 0 + if [[ "$mode" == "insert" ]]; then + handle_insert_key "$key" + else + handle_command_key "$key" + fi +done