orient-files
#!/bin/bash
# Counts files by extension across a project area.
#
# Usage: orient-files [-x|--list-extensionless] [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.
#
# Extensionless files (e.g. `LICENSE`, `Makefile`) and dotfiles whose name
# carries no further dot (`.gitignore`, `.editorconfig`) don't really have
# an extension in the language-classification sense, so by default they're
# rolled into the `(other)` bucket along with rare extensions.
# With `-x`/`--list-extensionless`, those filenames are itemized as nested
# bullets so it's clear what they are.
#
# 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.
set -euo pipefail
list_extensionless=0
while [ $# -gt 0 ]; do
case "$1" in
-x|--list-extensionless) list_extensionless=1; shift ;;
-h|--help)
sed -n '2,/^$/p' "$0" | sed 's/^# \?//'
exit 0 ;;
--) shift; break ;;
-*) echo "orient-files: unknown option: $1" >&2; exit 2 ;;
*) break ;;
esac
done
# 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
# `find` permission-denied messages on unrelated subdirs are expected noise
# in shared filesystems (e.g. running under /tmp). Filter those out, but
# pass any other stderr through unchanged so real problems still surface.
#
# We awk-classify each file into either an extension (e.g. `.rs`) or the
# special token `(none)`. Bash then tallies and decides on layout.
mapfile -t classified < <(
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 '
{
name = $0
# For classification purposes, drop a leading dot so dotfiles
# like `.gitignore` are treated as having no extension.
classname = name
if (substr(classname, 1, 1) == ".") classname = substr(classname, 2)
# Find the last dot; everything after it is the extension.
n = length(classname)
idx = 0
for (i = n; i >= 1; i--) {
if (substr(classname, i, 1) == ".") { idx = i; break }
}
if (idx == 0 || idx == n) {
# Emit the original filename so we can list it later if requested.
print "(none)\t" name
} else {
print "." substr(classname, idx + 1)
}
}
'
)
# Silent if nothing to report.
if [ ${#classified[@]} -eq 0 ]; then
exit 0
fi
# Partition: real extensions go to `ext_counts`, extensionless filenames
# are kept aside for either folding into `(other)` or listing verbatim.
declare -A ext_counts=()
extensionless_files=()
for entry in "${classified[@]}"; do
if [[ "$entry" == '(none) '* ]]; then
extensionless_files+=("${entry#'(none) '}")
else
ext_counts["$entry"]=$((${ext_counts["$entry"]:-0} + 1))
fi
done
# Sort extensions by count descending, ties broken alphabetically.
mapfile -t sorted_exts < <(
for ext in "${!ext_counts[@]}"; do
printf '%d\t%s\n' "${ext_counts[$ext]}" "$ext"
done | sort -k1,1rn -k2,2
)
# 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.)
shown_counts=()
shown_exts=()
other_files=0
other_kinds=0
for line in "${sorted_exts[@]}"; do
count=${line%%$'\t'*}
ext=${line#*$'\t'}
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
# Handle extensionless files.
# Default: fold into the `(other)` count.
# With -x: emit them as their own bullet (or nested under "extensionless" line).
n_extensionless=${#extensionless_files[@]}
if [ "$list_extensionless" -eq 1 ] && [ "$n_extensionless" -gt 0 ]; then
echo "- extensionless files (${n_extensionless}):"
# Sort for deterministic output.
mapfile -t sorted_files < <(printf '%s\n' "${extensionless_files[@]}" | sort)
for f in "${sorted_files[@]}"; do
echo " - \`${f}\`"
done
else
other_files=$((other_files + n_extensionless))
# Count extensionless as a single "kind" if there were any,
# so the "(other)" line acknowledges them. This is approximate but honest.
if [ "$n_extensionless" -gt 0 ]; then
other_kinds=$((other_kinds + 1))
fi
fi
# Pluralize cleanly.
if [ "$other_kinds" -gt 0 ]; then
file_word="files"; [ "$other_files" -eq 1 ] && file_word="file"
kind_word="kinds"; [ "$other_kinds" -eq 1 ] && kind_word="kind"
echo "- (other): ${other_files} ${file_word} across ${other_kinds} ${kind_word}"
fi
orient-files · 5.2 KiB · 3172031fb8