#!/bin/bash # Counts files by extension across a project area. # # Usage: orient-fileexts [PATH] # PATH defaults to the git toplevel of cwd, else cwd itself. # # Output: a bullet list of `.ext: N` lines, sorted by count descending. # Extensions with fewer than 3 files are lumped into a trailing # "(other)" line so the output stays bounded on diverse projects. # # Silent (no output, exit 0) when no countable files are found. # # Filtering: # - Dotdirs (`.git`, `.venv`, etc.) are pruned entirely. # - A small list of well-known build/vendor dirs is also pruned # (`node_modules`, `target`, `dist`, `build`, `vendor`). # The bias is conservative: rather miss a real file under a misnamed # `vendor/` than have `node_modules` dominate every count. # - Dotfiles with no further dot (`.gitignore`, `.editorconfig`) are # grouped under `(none)` rather than reported as an `.editorconfig` # extension; they're not really extensions in the language sense. set -euo pipefail # Resolve the search root. if [ $# -ge 1 ]; then root=$(cd "$1" && pwd) elif root=$(git rev-parse --show-toplevel 2>/dev/null); then : else root="$PWD" fi # Walk the tree, pruning noise dirs. # `find` prune semantics: match the prune predicate, then `-o -print` the rest. # We prune by basename so the rules apply at any depth. mapfile -t exts < <( find "$root" \ \( \ -type d \ \( -name '.*' \ -o -name 'node_modules' \ -o -name 'target' \ -o -name 'dist' \ -o -name 'build' \ -o -name 'vendor' \ \) -prune \ \) -o \ \( -type f -printf '%f\n' \) \ 2> >(grep -v 'Permission denied' >&2) \ | awk ' { # Strip leading dot for dotfiles before extension extraction, # so `.gitignore` is treated as having no extension rather than # an extension of "gitignore". name = $0 if (substr(name, 1, 1) == ".") name = substr(name, 2) # Find the last dot; everything after it is the extension. # No dot, or trailing dot, means no extension. n = length(name) idx = 0 for (i = n; i >= 1; i--) { if (substr(name, i, 1) == ".") { idx = i; break } } if (idx == 0 || idx == n) { print "(none)" } else { print "." substr(name, idx + 1) } } ' \ | sort | uniq -c | sort -rn ) # Silent if nothing to report. if [ ${#exts[@]} -eq 0 ]; then exit 0 fi # Split into shown vs. lumped buckets at the count >= 3 threshold. # (Threshold chosen empirically: 1 or 2 files is rarely a meaningful # signal about a project's character; 3 starts to suggest a pattern.) # `uniq -c` emits lines like " 25 .md" with leading whitespace, # so we use `read` to split on whitespace cleanly. shown_counts=() shown_exts=() other_files=0 other_kinds=0 for line in "${exts[@]}"; do read -r count ext <<<"$line" if [ "$count" -ge 3 ]; then shown_counts+=("$count") shown_exts+=("$ext") else other_files=$((other_files + count)) other_kinds=$((other_kinds + 1)) fi done for i in "${!shown_counts[@]}"; do echo "- \`${shown_exts[$i]}\`: ${shown_counts[$i]}" done # Pluralize cleanly: "1 file" / "N files", "1 extension" / "N extensions". if [ "$other_kinds" -gt 0 ]; then file_word="files"; [ "$other_files" -eq 1 ] && file_word="file" ext_word="extensions"; [ "$other_kinds" -eq 1 ] && ext_word="extension" echo "- (other): ${other_files} ${file_word} across ${other_kinds} ${ext_word}" fi