Instead of saying something boring like initial commit... Behold, evil and darkness unleashed upon the world... That's right, vim in bash!

This commit is contained in:
Storm Dragon
2026-05-14 23:22:15 -04:00
commit 8464427637
2 changed files with 1033 additions and 0 deletions
+44
View File
@@ -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
```
Executable
+989
View File
@@ -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