compass

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