fosscat-site/scripts/spellcheck-interactive.sh

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