#!/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 [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 [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/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