387 lines
12 KiB
Bash
Executable File
387 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# spellcheck-interactive.sh — Interactive fzf-based spell checker for markdown
|
|
#
|
|
# Checks markdown files with aspell, then presents each misspelled word
|
|
# interactively with fzf, showing context and offering actions:
|
|
# - Skip (ignore this word)
|
|
# - Add to dictionary (.aspell.en.pws)
|
|
# - Replace with a suggestion
|
|
# - Type a custom replacement
|
|
#
|
|
# If no TTY is available (non-interactive), falls back to batch output.
|
|
#
|
|
# Usage: spellcheck-interactive.sh <file1.md> [file2.md ...]
|
|
|
|
set -euo pipefail
|
|
|
|
RED='\033[0;31m'
|
|
YELLOW='\033[0;33m'
|
|
GREEN='\033[0;32m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
DIM='\033[2m'
|
|
NC='\033[0m'
|
|
|
|
if [ $# -eq 0 ]; then
|
|
echo "Usage: spellcheck-interactive.sh <file1.md> [file2.md ...]"
|
|
exit 1
|
|
fi
|
|
|
|
FILES=("$@")
|
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
DICT_FILE="$REPO_ROOT/.aspell.en.pws"
|
|
|
|
# Check aspell availability
|
|
if ! command -v aspell &> /dev/null; then
|
|
echo -e "${YELLOW}aspell not found, skipping spell check${NC}"
|
|
exit 0
|
|
fi
|
|
|
|
if ! aspell dump dicts 2>/dev/null | grep -q "en"; then
|
|
echo -e "${YELLOW}aspell found but no English dictionaries available${NC}"
|
|
echo " Make sure ASPELL_CONF is set correctly in your direnv"
|
|
exit 0
|
|
fi
|
|
|
|
# Detect interactive mode
|
|
INTERACTIVE=0
|
|
if [ -t 0 ]; then
|
|
INTERACTIVE=1
|
|
elif ( exec 0</dev/tty ) 2>/dev/null; then
|
|
# Pre-commit hook: stdin is redirected, but /dev/tty is available
|
|
INTERACTIVE=1
|
|
fi
|
|
|
|
if [ "$INTERACTIVE" -eq 1 ] && ! command -v fzf &> /dev/null; then
|
|
echo -e "${YELLOW}fzf not found, falling back to non-interactive mode${NC}"
|
|
INTERACTIVE=0
|
|
fi
|
|
|
|
# --- Strip front matter and code blocks from markdown ---
|
|
# Returns cleaned text suitable for spell checking.
|
|
# We keep line numbers aligned by replacing stripped content with blank lines,
|
|
# so aspell line numbers match the original file.
|
|
strip_for_spellcheck() {
|
|
local file="$1"
|
|
local in_frontmatter=0
|
|
local frontmatter_count=0
|
|
local in_codeblock=0
|
|
local linenum=0
|
|
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
linenum=$((linenum + 1))
|
|
|
|
# Track YAML front matter (between --- delimiters)
|
|
if [[ "$line" =~ ^---[[:space:]]*$ ]]; then
|
|
frontmatter_count=$((frontmatter_count + 1))
|
|
if [ "$frontmatter_count" -eq 1 ]; then
|
|
in_frontmatter=1
|
|
echo ""
|
|
continue
|
|
elif [ "$frontmatter_count" -eq 2 ]; then
|
|
in_frontmatter=0
|
|
echo ""
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
if [ "$in_frontmatter" -eq 1 ]; then
|
|
echo ""
|
|
continue
|
|
fi
|
|
|
|
# Track fenced code blocks
|
|
if [[ "$line" =~ ^\`\`\` ]]; then
|
|
if [ "$in_codeblock" -eq 0 ]; then
|
|
in_codeblock=1
|
|
else
|
|
in_codeblock=0
|
|
fi
|
|
echo ""
|
|
continue
|
|
fi
|
|
|
|
if [ "$in_codeblock" -eq 1 ]; then
|
|
echo ""
|
|
continue
|
|
fi
|
|
|
|
# Strip inline code (`...`) to avoid checking code snippets
|
|
echo "$line" | sed 's/`[^`]*`//g'
|
|
done < "$file"
|
|
}
|
|
|
|
# --- Get aspell suggestions for a word ---
|
|
get_suggestions() {
|
|
local word="$1"
|
|
# aspell pipe mode: & means misspelled with suggestions, # means no suggestions
|
|
local result
|
|
result=$(echo "$word" | aspell pipe --mode=markdown --lang=en --personal="$DICT_FILE" 2>/dev/null | tail -n +2)
|
|
|
|
if [[ "$result" =~ ^'&' ]]; then
|
|
# Format: & word count offset: suggestion1, suggestion2, ...
|
|
echo "$result" | sed 's/^& [^ ]* [0-9]* [0-9]*: //' | tr ',' '\n' | sed 's/^[[:space:]]*//' | head -8
|
|
fi
|
|
}
|
|
|
|
# --- Get context lines around a word occurrence in a file ---
|
|
get_context() {
|
|
local file="$1"
|
|
local word="$2"
|
|
local max_contexts="${3:-3}"
|
|
|
|
# Find line numbers containing the word (case-insensitive, word boundary)
|
|
local lines
|
|
lines=$(grep -n -i -w "$word" "$file" 2>/dev/null | head -"$max_contexts") || true
|
|
echo "$lines"
|
|
}
|
|
|
|
# --- Add a word to the personal dictionary ---
|
|
add_to_dictionary() {
|
|
local word="$1"
|
|
echo "$word" >> "$DICT_FILE"
|
|
# Sort and deduplicate (preserving the header line)
|
|
local header
|
|
header=$(head -1 "$DICT_FILE")
|
|
local body
|
|
body=$(tail -n +2 "$DICT_FILE" | sort -u)
|
|
printf '%s\n%s\n' "$header" "$body" > "$DICT_FILE"
|
|
}
|
|
|
|
# --- Replace a word in a file ---
|
|
replace_word_in_file() {
|
|
local file="$1"
|
|
local old_word="$2"
|
|
local new_word="$3"
|
|
|
|
# Use word-boundary-aware sed replacement (case-sensitive, first occurrence per line)
|
|
# We use perl for proper word boundary support
|
|
if command -v perl &> /dev/null; then
|
|
perl -pi -e "s/\\b\Q${old_word}\E\\b/${new_word}/g" "$file"
|
|
else
|
|
sed -i "s/\b${old_word}\b/${new_word}/g" "$file" 2>/dev/null || \
|
|
sed -i '' "s/[[:<:]]${old_word}[[:>:]]/${new_word}/g" "$file"
|
|
fi
|
|
}
|
|
|
|
# --- Non-interactive fallback (matches old pre-commit behavior) ---
|
|
run_batch_check() {
|
|
local overall_fail=0
|
|
|
|
for file in "${FILES[@]}"; do
|
|
if [ ! -f "$file" ]; then continue; fi
|
|
|
|
local errors
|
|
errors=$(strip_for_spellcheck "$file" | aspell list --mode=markdown --lang=en --personal="$DICT_FILE" 2>/dev/null | sort -u)
|
|
|
|
if [ -n "$errors" ]; then
|
|
echo -e "${YELLOW}Possible misspellings in ${BOLD}$file${NC}${YELLOW}:${NC}"
|
|
while IFS= read -r word; do
|
|
if [ -z "$word" ]; then continue; fi
|
|
local suggestion
|
|
suggestion=$(get_suggestions "$word" | head -1)
|
|
echo -e " ${RED}'$word'${NC} ${DIM}→${NC} ${GREEN}$suggestion${NC}"
|
|
get_context "$file" "$word" 2 | while IFS= read -r ctx; do
|
|
echo -e " ${DIM}$ctx${NC}"
|
|
done
|
|
done <<< "$errors"
|
|
echo ""
|
|
overall_fail=1
|
|
fi
|
|
done
|
|
|
|
if [ "$overall_fail" -eq 1 ]; then
|
|
echo -e "${RED}${BOLD}Spell check failed.${NC}"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# --- Interactive spell check with fzf ---
|
|
run_interactive_check() {
|
|
# Grab the terminal for input (needed in pre-commit hook context)
|
|
exec < /dev/tty
|
|
|
|
local files_modified=0
|
|
local overall_skipped=0
|
|
local words_added=0
|
|
local words_replaced=0
|
|
local words_skipped=0
|
|
local user_quit=0
|
|
|
|
for file in "${FILES[@]}"; do
|
|
if [ ! -f "$file" ]; then continue; fi
|
|
|
|
# Get misspelled words from stripped content
|
|
local errors
|
|
errors=$(strip_for_spellcheck "$file" | aspell list --mode=markdown --lang=en --personal="$DICT_FILE" 2>/dev/null | sort -u)
|
|
|
|
if [ -z "$errors" ]; then
|
|
continue
|
|
fi
|
|
|
|
local word_count
|
|
word_count=$(echo "$errors" | wc -l | tr -d ' ')
|
|
local current=0
|
|
|
|
echo -e "\n${BOLD}${CYAN}Spell checking: $file${NC} ${DIM}($word_count issues)${NC}"
|
|
|
|
while IFS= read -r word; do
|
|
if [ -z "$word" ]; then continue; fi
|
|
if [ "$user_quit" -eq 1 ]; then
|
|
words_skipped=$((words_skipped + 1))
|
|
continue
|
|
fi
|
|
|
|
current=$((current + 1))
|
|
|
|
# Check if this word was already added to dictionary in this session
|
|
if grep -qx "$word" "$DICT_FILE" 2>/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
# Get suggestions
|
|
local suggestions
|
|
suggestions=$(get_suggestions "$word")
|
|
|
|
# Get context
|
|
local context
|
|
context=$(get_context "$file" "$word" 3)
|
|
|
|
# Build the context header for fzf
|
|
local header=""
|
|
header+="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"$'\n'
|
|
header+=" Misspelled: '$word' [$current/$word_count] in $file"$'\n'
|
|
header+="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"$'\n'
|
|
if [ -n "$context" ]; then
|
|
header+=$'\n'" Context:"$'\n'
|
|
while IFS= read -r ctx_line; do
|
|
header+=" $ctx_line"$'\n'
|
|
done <<< "$context"
|
|
fi
|
|
header+=$'\n'" Choose an action:"
|
|
|
|
# Build fzf options
|
|
local options=""
|
|
options+="skip (ignore this word)"$'\n'
|
|
options+="add (add '$word' to dictionary)"$'\n'
|
|
|
|
if [ -n "$suggestions" ]; then
|
|
while IFS= read -r sug; do
|
|
if [ -n "$sug" ]; then
|
|
options+="use '$sug'"$'\n'
|
|
fi
|
|
done <<< "$suggestions"
|
|
fi
|
|
|
|
options+="type (enter custom replacement)"$'\n'
|
|
options+="quit (skip remaining words)"
|
|
|
|
# Run fzf
|
|
local choice
|
|
choice=$(echo -e "$options" | fzf \
|
|
--header="$header" \
|
|
--prompt="Action: " \
|
|
--no-sort \
|
|
--no-multi \
|
|
--height=~30 \
|
|
--reverse \
|
|
--no-info \
|
|
--pointer="▶" \
|
|
--color="header:cyan,pointer:yellow,prompt:yellow" \
|
|
2>/dev/tty) || {
|
|
# fzf returns 130 on Ctrl-C/Esc
|
|
echo -e "${YELLOW}Spell check cancelled.${NC}"
|
|
user_quit=1
|
|
words_skipped=$((words_skipped + 1))
|
|
continue
|
|
}
|
|
|
|
# Parse the action
|
|
local action
|
|
action=$(echo "$choice" | awk '{print $1}')
|
|
|
|
case "$action" in
|
|
skip)
|
|
words_skipped=$((words_skipped + 1))
|
|
;;
|
|
add)
|
|
add_to_dictionary "$word"
|
|
words_added=$((words_added + 1))
|
|
echo -e " ${GREEN}+ Added '$word' to dictionary${NC}"
|
|
;;
|
|
use)
|
|
local replacement
|
|
replacement=$(echo "$choice" | sed "s/^use[[:space:]]*'//;s/'$//" )
|
|
replace_word_in_file "$file" "$word" "$replacement"
|
|
files_modified=1
|
|
words_replaced=$((words_replaced + 1))
|
|
echo -e " ${GREEN}~ Replaced '$word' → '$replacement'${NC}"
|
|
;;
|
|
type)
|
|
echo -n " Enter replacement for '$word': "
|
|
local custom
|
|
read -r custom < /dev/tty
|
|
if [ -n "$custom" ]; then
|
|
replace_word_in_file "$file" "$word" "$custom"
|
|
files_modified=1
|
|
words_replaced=$((words_replaced + 1))
|
|
echo -e " ${GREEN}~ Replaced '$word' → '$custom'${NC}"
|
|
else
|
|
echo -e " ${DIM}(empty input, skipping)${NC}"
|
|
words_skipped=$((words_skipped + 1))
|
|
fi
|
|
;;
|
|
quit)
|
|
user_quit=1
|
|
words_skipped=$((words_skipped + 1))
|
|
;;
|
|
*)
|
|
words_skipped=$((words_skipped + 1))
|
|
;;
|
|
esac
|
|
done <<< "$errors"
|
|
|
|
# Re-stage the file if we modified it
|
|
if [ "$files_modified" -eq 1 ]; then
|
|
git add "$file" 2>/dev/null || true
|
|
files_modified=0
|
|
fi
|
|
done
|
|
|
|
# Re-stage dictionary if words were added
|
|
if [ "$words_added" -gt 0 ]; then
|
|
git add "$DICT_FILE" 2>/dev/null || true
|
|
fi
|
|
|
|
# Summary
|
|
echo ""
|
|
echo -e "${BOLD}Spell check summary:${NC}"
|
|
if [ "$words_replaced" -gt 0 ]; then echo -e " ${GREEN}Replaced: $words_replaced${NC}"; fi
|
|
if [ "$words_added" -gt 0 ]; then echo -e " ${GREEN}Added to dictionary: $words_added${NC}"; fi
|
|
if [ "$words_skipped" -gt 0 ]; then echo -e " ${YELLOW}Skipped: $words_skipped${NC}"; fi
|
|
|
|
# If any words were skipped (not fixed), that's still a pass —
|
|
# the user explicitly chose to skip them. Only fail if user quit early.
|
|
if [ "$user_quit" -eq 1 ] && [ "$words_skipped" -gt 0 ]; then
|
|
echo -e "\n${YELLOW}Some words were skipped due to early quit.${NC}"
|
|
echo -e "${YELLOW}Commit will proceed — re-run to address remaining words.${NC}"
|
|
fi
|
|
|
|
echo -e "${GREEN}${BOLD}Spell check complete.${NC}"
|
|
return 0
|
|
}
|
|
|
|
# --- Main ---
|
|
echo "Running spell check..."
|
|
|
|
if [ "$INTERACTIVE" -eq 1 ]; then
|
|
run_interactive_check
|
|
exit $?
|
|
else
|
|
run_batch_check
|
|
exit $?
|
|
fi
|