compass

orient-fileexts

#!/bin/bash
# Counts files by extension across a project area.
#
# Usage: orient-fileexts [-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-fileexts: 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-fileexts · 5.2 KiB · d1183e1e6e