#!/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