orient-fileexts
#!/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
orient-fileexts · 3.3 KiB · 37decbe129