Skip to content

Instantly share code, notes, and snippets.

@karlb
Created July 23, 2025 14:37
Show Gist options
  • Select an option

  • Save karlb/7b9b9c51bbcf253b27dd27e3405e26d1 to your computer and use it in GitHub Desktop.

Select an option

Save karlb/7b9b9c51bbcf253b27dd27e3405e26d1 to your computer and use it in GitHub Desktop.
Run shellcheck on just recipes
#!/bin/bash
# justfile-shellcheck.sh - Extract and lint bash code from justfile recipes
# Set DEFAULT_OPTIONS to your liking
set -euo pipefail
JUSTFILE="${1:-justfile}"
TEMP_DIR=$(mktemp -d)
SHELL_TYPE="${2:-bash}" # Default to bash, can be overridden
DEFAULT_OPTIONS="--exclude=SC2086,SC2046"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
cleanup() {
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT
usage() {
echo "Usage: $0 [justfile] [shell_type]"
echo " justfile - Path to justfile (default: ./justfile)"
echo " shell_type - Shell type for shebang (default: bash)"
echo ""
echo "Examples:"
echo " $0"
echo " $0 my.justfile"
echo " $0 justfile sh"
exit 1
}
log() {
echo -e "${BLUE}[INFO]${NC} $1"
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
# Check if justfile exists
if [[ ! -f "$JUSTFILE" ]]; then
error "Justfile not found: $JUSTFILE"
usage
fi
# Check if shellcheck is installed
if ! command -v shellcheck &> /dev/null; then
error "shellcheck is not installed. Please install it first."
echo " - Ubuntu/Debian: apt install shellcheck"
echo " - macOS: brew install shellcheck"
echo " - Fedora: dnf install ShellCheck"
exit 1
fi
log "Parsing justfile: $JUSTFILE"
log "Using shell type: $SHELL_TYPE"
log "Temporary directory: $TEMP_DIR"
# Parse justfile and extract recipes
extract_recipes() {
local justfile="$1"
local current_recipe=""
local in_recipe=false
local recipe_content=""
local recipes_found=0
local recipe_shebang=""
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip settings and top-level comments when not in recipe
if [[ "$in_recipe" == false ]]; then
if [[ "$line" =~ ^[[:space:]]*set[[:space:]] ]] ||
[[ "$line" =~ ^[[:space:]]*# ]] ||
[[ "$line" =~ ^[[:space:]]*$ ]]; then
continue
fi
fi
# Check if this is a recipe definition
# Pattern: recipe_name [parameters...]:
if [[ "$line" =~ ^[a-zA-Z_][a-zA-Z0-9_-]*([[:space:]]+[^:]*)?:[[:space:]]*$ ]]; then
# Save previous recipe if we were in one
if [[ "$in_recipe" == true && -n "$current_recipe" ]]; then
save_recipe "$current_recipe" "$recipe_content" "$recipe_shebang"
((recipes_found++))
fi
# Start new recipe
current_recipe=$(echo "$line" | sed 's/\([^[:space:]]*\).*/\1/')
recipe_content=""
recipe_shebang=""
in_recipe=true
log "Found recipe: $current_recipe"
continue
fi
# If we're in a recipe, collect the content
if [[ "$in_recipe" == true ]]; then
# Check if this line starts a new recipe (not indented, contains colon)
if [[ ! "$line" =~ ^[[:space:]] ]] && [[ "$line" =~ : ]] && [[ ! "$line" =~ ^[[:space:]]*# ]]; then
# This is a new recipe, save current and start over
if [[ -n "$current_recipe" ]]; then
save_recipe "$current_recipe" "$recipe_content" "$recipe_shebang"
((recipes_found++))
fi
# Start the new recipe
current_recipe=$(echo "$line" | sed 's/\([^[:space:]]*\).*/\1/')
recipe_content=""
recipe_shebang=""
log "Found recipe: $current_recipe"
continue
fi
# Handle indented content (recipe body)
if [[ "$line" =~ ^[[:space:]]+.* ]] || [[ "$line" =~ ^[[:space:]]*$ ]]; then
# Check if this is a shebang line within the recipe
if [[ "$line" =~ ^[[:space:]]+#!/ ]]; then
# Extract the shebang and store it separately
recipe_shebang=$(echo "$line" | sed 's/^[[:space:]]*//')
else
# Regular recipe content - remove one level of indentation
cleaned_line=$(echo "$line" | sed 's/^[[:space:]]\{1,4\}//')
recipe_content+="$cleaned_line"$'\n'
fi
else
# Non-indented, non-recipe line - could be end of recipe
if [[ -n "$current_recipe" ]]; then
save_recipe "$current_recipe" "$recipe_content" "$recipe_shebang"
((recipes_found++))
in_recipe=false
current_recipe=""
recipe_content=""
recipe_shebang=""
fi
fi
fi
done < "$justfile"
# Handle the last recipe if file doesn't end with a non-indented line
if [[ "$in_recipe" == true && -n "$current_recipe" ]]; then
save_recipe "$current_recipe" "$recipe_content" "$recipe_shebang"
((recipes_found++))
fi
if [[ $recipes_found -eq 0 ]]; then
warn "No recipes found in $justfile"
return 1
fi
log "Extracted $recipes_found recipes"
}
save_recipe() {
local recipe_name="$1"
local content="$2"
local recipe_shebang="$3"
# Skip recipes with no shell content (e.g., just @just --list)
local shell_content=$(echo "$content" | grep -v '^[[:space:]]*@' | tr -d '[:space:]')
if [[ -z "$shell_content" ]]; then
log "Skipping recipe '$recipe_name' (no shell code to lint)"
return
fi
local script_file="$TEMP_DIR/${recipe_name}.sh"
# Use recipe's shebang if present, otherwise use default
if [[ -n "$recipe_shebang" ]]; then
echo "$recipe_shebang" > "$script_file"
else
echo "#!/bin/$SHELL_TYPE" > "$script_file"
fi
echo "# Extracted from justfile recipe: $recipe_name" >> "$script_file"
echo "" >> "$script_file"
# Filter out just-specific syntax while preserving shell code
echo "$content" | while IFS= read -r line; do
# Skip lines that start with @ (just commands)
if [[ "$line" =~ ^[[:space:]]*@ ]]; then
continue
fi
# Replace just template variables {{var}} with placeholder
# This prevents shellcheck from complaining about invalid syntax
processed_line=$(echo "$line" | sed 's/{{[^}]*}}/\$JUST_VAR/g')
echo "$processed_line" >> "$script_file"
done
chmod +x "$script_file"
}
run_shellcheck() {
local total_files=0
local passed_files=0
local failed_files=0
log "Running ShellCheck on extracted recipes..."
echo ""
for script_file in "$TEMP_DIR"/*.sh; do
if [[ ! -f "$script_file" ]]; then
continue
fi
((total_files++))
local recipe_name=$(basename "$script_file" .sh)
echo -e "${BLUE}Checking recipe: $recipe_name${NC}"
echo "----------------------------------------"
# Add shellcheck options to ignore some just-specific issues
if shellcheck -e SC2154 $DEFAULT_OPTIONS "$script_file"; then
success "✓ $recipe_name passed ShellCheck"
((passed_files++))
else
error "✗ $recipe_name failed ShellCheck"
((failed_files++))
fi
echo ""
done
if [[ $total_files -eq 0 ]]; then
warn "No shell scripts found to check"
return 0
fi
# Summary
echo "========================================"
log "ShellCheck Results Summary:"
echo " Total recipes: $total_files"
echo " Passed: $passed_files"
echo " Failed: $failed_files"
if [[ $failed_files -gt 0 ]]; then
echo ""
warn "Some recipes failed ShellCheck. Review the output above for details."
return 1
else
echo ""
success "All recipes passed ShellCheck!"
return 0
fi
}
# Show help if requested
if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]]; then
usage
fi
# Main execution
main() {
log "Starting justfile ShellCheck analysis..."
if extract_recipes "$JUSTFILE"; then
run_shellcheck
else
error "Failed to extract recipes from justfile"
exit 1
fi
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment