#!/usr/bin/env bash
set -o pipefail
set -o errexit
declare -a EXCLUDE_DIRS=(
".git"
"venv"
"__pycache__"
"build"
"dist"
".pytest_cache"
"node_modules"
"bin"
"api/go_proto"
"api/py_proto"
)
declare -a TARGET_SUFFIXES=(
"py" "sh" "yaml" "yml" "md" "txt"
"cfg" "ini" "json" "toml" "go" "proto"
)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
VERBOSE=0
DEBUG_MODE=0
DRY_RUN=0
ENABLE_BACKUP=0
BACKUP_ROOT="${PROJECT_ROOT}/.clean_backup"
EXIT_CODE=0
log_info() {
if [[ $VERBOSE -eq 1 ]]; then
echo -e "\033[32m[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*\033[0m"
fi
}
log_debug() {
if [[ $DEBUG_MODE -eq 1 ]]; then
echo -e "\033[36m[DEBUG] $(date '+%Y-%m-%d %H:%M:%S') $*\033[0m"
fi
}
log_error() {
echo -e "\033[31m[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*\033[0m" >&2
EXIT_CODE=1
}
check_file_permission() {
local file="$1"
if [[ ! -f "$file" ]]; then
log_error "File does not exist: $file"
return 1
fi
if [[ ! -r "$file" ]]; then
log_error "No read permission for file: $file"
return 1
fi
if [[ ! -w "$file" && $DRY_RUN -eq 0 ]]; then
log_error "No write permission for file: $file"
return 1
fi
return 0
}
has_trailing_whitespace() {
local file="$1"
if ! check_file_permission "$file"; then
return 1
fi
if [[ ! -s "$file" ]]; then
return 1
fi
local space_lines=$(sed -n '/[ \t ]$/p' "$file" | wc -l)
if [[ $space_lines -gt 0 ]]; then
return 0
fi
local cr_lines=$(sed -n '/\r$/p' "$file" | wc -l)
if [[ $cr_lines -gt 0 ]]; then
return 0
fi
return 1
}
clean_file() {
local file="$1"
local rel_path="${file#${PROJECT_ROOT}/}"
if ! check_file_permission "$file"; then
return 1
fi
if [[ $DRY_RUN -eq 1 ]]; then
log_info "Dry run: Need cleanup - ${rel_path}"
return 0
fi
log_info "Cleaning - ${rel_path}"
if [[ $ENABLE_BACKUP -eq 1 ]]; then
mkdir -p "$BACKUP_ROOT"
local backup_file="${BACKUP_ROOT}/${rel_path}.bak"
mkdir -p "$(dirname "$backup_file")"
if cp --preserve=all "$file" "$backup_file" 2>/dev/null; then
log_debug "Backed up to: ${backup_file}"
else
log_error "Failed to backup file: $file"
return 1
fi
fi
if [[ "$(uname)" == "Darwin" ]]; then
if ! sed -i '' -e 's/\r$//' -e 's/[ \t ]*$//' "$file" 2>/dev/null; then
log_error "sed processing failed for file: $file"
return 1
fi
else
if ! sed -i -e 's/\r$//' -e 's/[ \t ]*$//' "$file" 2>/dev/null; then
log_error "sed processing failed for file: $file"
return 1
fi
fi
return 0
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--dir)
if [[ -d "$2" ]]; then
PROJECT_ROOT="$(cd "$2" && pwd -P)"
BACKUP_ROOT="${PROJECT_ROOT}/.clean_backup"
else
log_error "Specified directory does not exist: $2"
exit 1
fi
shift 2
;;
--verbose|-v)
VERBOSE=1
shift
;;
--debug)
DEBUG_MODE=1
shift
;;
--dry-run|-n)
DRY_RUN=1
shift
;;
--backup)
ENABLE_BACKUP=1
shift
;;
--no-backup)
ENABLE_BACKUP=0
shift
;;
--help|-h)
cat << HELP
Usage: ${0##*/} [OPTIONS]
EulerOS Optimized: Clean trailing whitespace and standardize line endings
Options:
--dir DIR Specify project root directory (default: parent directory of script)
--verbose|-v Show info-level logging (summary information)
--debug Show debug-level logging (detailed file checking, backup paths)
--dry-run|-n Only detect issues without making changes
--backup Enable file backup (save to ${PROJECT_ROOT}/.clean_backup)
--no-backup Disable file backup (default)
--help|-h Show this help message and exit
Key Features:
1. Compatible with EulerOS/Linux/macOS sed syntax
2. Detect/clean trailing spaces, tabs, full-width spaces and CR
3. Precise file counting (no overcounting/undercounting)
4. Optional backup (--backup) for data safety
HELP
exit 0
;;
*)
log_error "Unknown argument: $1"
exit 1
;;
esac
done
if [[ ! -d "$PROJECT_ROOT" ]]; then
log_error "Project root directory does not exist: $PROJECT_ROOT"
exit 1
fi
}
main() {
parse_args "$@"
log_info "Starting project scan: $PROJECT_ROOT"
if [[ $VERBOSE -eq 1 ]]; then
if [[ $ENABLE_BACKUP -eq 1 ]]; then
log_info "Backup enabled (backup directory: ${BACKUP_ROOT})"
else
log_info "Backup disabled (use --backup to enable data safety)"
fi
if [[ $DEBUG_MODE -eq 1 ]]; then
log_info "Debug mode enabled (detailed logging)"
fi
fi
local find_cmd=(
find "$PROJECT_ROOT"
\(
)
local first_exclude=1
for dir in "${EXCLUDE_DIRS[@]}"; do
local exclude_path="${PROJECT_ROOT}/${dir}"
if [[ $first_exclude -eq 1 ]]; then
find_cmd+=(-path "$exclude_path")
first_exclude=0
else
find_cmd+=(-o -path "$exclude_path")
fi
done
find_cmd+=(
\) -prune -o
\(
)
local first_suffix=1
for suffix in "${TARGET_SUFFIXES[@]}"; do
if [[ $first_suffix -eq 1 ]]; then
find_cmd+=(-name "*.$suffix")
first_suffix=0
else
find_cmd+=(-o -name "*.$suffix")
fi
done
find_cmd+=(
\) -type f -print0
)
local total_files=$("${find_cmd[@]}" | xargs -0 -I {} echo 1 | wc -l)
log_info "Total files scanned: $total_files"
local cleanup_count=0
local cleanup_files=()
while IFS= read -r -d '' file; do
[[ -z "$file" || ! -f "$file" ]] && continue
log_debug "Checking file: $file"
if has_trailing_whitespace "$file"; then
if clean_file "$file"; then
cleanup_files+=("$file")
fi
fi
done < <("${find_cmd[@]}")
cleanup_count=${#cleanup_files[@]}
echo -e "\n==================== Cleanup Summary ===================="
echo "Total files scanned: $total_files"
if [[ $DRY_RUN -eq 1 ]]; then
echo "Files needing cleanup: $cleanup_count"
else
echo "Files cleaned: $cleanup_count"
if [[ $ENABLE_BACKUP -eq 1 ]]; then
echo "Note: All backups stored in ${BACKUP_ROOT}"
else
echo "Note: Backup disabled (use --backup to save original files)"
fi
fi
echo "=========================================================="
if [[ ($VERBOSE -eq 1 || $DEBUG_MODE -eq 1) && $cleanup_count -gt 0 ]]; then
echo -e "\n===== $(if [[ $DRY_RUN -eq 1 ]]; then echo "List of Files Needing Cleanup"; else echo "List of Files Processed"; fi) ====="
for f in "${cleanup_files[@]}"; do
echo "- ${f#${PROJECT_ROOT}/}"
done
fi
log_info "Operation completed successfully"
exit $EXIT_CODE
}
main "$@"