#!/bin/bash # Classifies files by type and counts them 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, one row per type, sorted by count descending. # Each row has three columns: type name, count, and a hint indicating # which heuristic(s) matched (currently just the extensions that mapped # to this type). Types with fewer than 3 files are lumped into a # trailing "(other)" line so the output stays bounded. # # Example output: # - markdown: 25 (`.md`) # - typescript: 1241 (`.ts`) # - image: 108 (`.png`, `.jpg`, `.gif`) # - unknown-shell: 7 (`.sh`) # - (other): 9 files across 5 kinds # # Type names are canonical (lowercased) language/format names, chosen # for legibility when scanning. Extensions without a mapping fall # through to using the literal extension as the type name, so unfamiliar # projects stay informative without needing the table to be exhaustive. # # Extensionless files (e.g. `LICENSE`, `Makefile`) and dotfiles whose # name carries no further dot (`.gitignore`, `.editorconfig`) are folded # into `(other)` by default. # 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 # Extension -> canonical type name. # Names roughly follow github-linguist's language list (consulted for # inspiration, not pulled in as a dependency). # `.sh` maps to `unknown-shell` rather than `bash`: the actual interpreter # of a bare `.sh` file depends on the system `/bin/sh`, which varies by # distro, so flagging that ambiguity is more honest than guessing. # Shebang-based detection (in a later commit) will resolve specific shells. declare -A ext_to_type=( [.md]=markdown [.markdown]=markdown [.rs]=rust [.py]=python [.ts]=typescript [.tsx]=typescript [.js]=javascript [.mjs]=javascript [.cjs]=javascript [.jsx]=javascript [.go]=go [.v]=vlang [.c]=c [.h]=c [.cpp]=cpp [.cc]=cpp [.cxx]=cpp [.hpp]=cpp [.hh]=cpp [.java]=java [.rb]=ruby [.php]=php [.cs]=csharp [.swift]=swift [.kt]=kotlin [.kts]=kotlin [.lua]=lua [.zig]=zig [.dart]=dart [.scala]=scala [.ex]=elixir [.exs]=elixir [.erl]=erlang [.hs]=haskell [.clj]=clojure [.cljs]=clojure [.r]=r [.R]=r [.jl]=julia [.pl]=perl [.pm]=perl [.sql]=sql [.sh]=unknown-shell [.bash]=bash [.nu]=nushell [.json]=json [.yaml]=yaml [.yml]=yaml [.toml]=toml [.html]=html [.htm]=html [.css]=css [.xml]=xml [.ini]=ini [.csv]=csv [.tsv]=tsv [.png]=image [.jpg]=image [.jpeg]=image [.gif]=image [.svg]=image [.webp]=image [.txt]=text [.log]=log ) # `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. # # Each found file is awk-classified into either an extension (e.g. `.rs`) # or the special token `(none)`. Bash then tallies and rolls up to types. 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 into per-extension counts and the extensionless bucket. 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 # Roll up extensions into types. # Each type tracks its total count and the set of source extensions that # contributed (rendered later as the hint column). # `type_exts` stores a space-separated list, treated as a set: an extension # only appears once per type, since `ext_counts` is keyed by extension. declare -A type_counts=() declare -A type_exts=() for ext in "${!ext_counts[@]}"; do type=${ext_to_type[$ext]:-$ext} type_counts[$type]=$((${type_counts[$type]:-0} + ${ext_counts[$ext]})) if [ -z "${type_exts[$type]:-}" ]; then type_exts[$type]=$ext else type_exts[$type]+=" $ext" fi done # Sort types by count descending, ties broken alphabetically. mapfile -t sorted_types < <( for t in "${!type_counts[@]}"; do printf '%d\t%s\n' "${type_counts[$t]}" "$t" 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_types=() shown_counts=() other_files=0 other_kinds=0 for line in "${sorted_types[@]}"; do count=${line%%$'\t'*} type=${line#*$'\t'} if [ "$count" -ge 3 ]; then shown_types+=("$type") shown_counts+=("$count") else other_files=$((other_files + count)) other_kinds=$((other_kinds + 1)) fi done # Render the per-type rows. # # Column layout: # - Type name (with trailing colon) left-padded to 16 chars. # Wide enough for `unknown-shell:` (14 chars); types longer than that # simply push the count column rightward on their own row. # - Count right-aligned in 5 chars. # - Two spaces, then the hint in parentheses. # # Per-type extension lists in the hint are sorted by per-extension count # descending (so the dominant variant appears first, e.g. `.yaml` ahead # of `.yml`), then alphabetically. This makes output deterministic # despite bash assoc-array iteration order being unspecified. for i in "${!shown_types[@]}"; do type=${shown_types[$i]} count=${shown_counts[$i]} mapfile -t type_ext_list < <( for ext in ${type_exts[$type]}; do printf '%d\t%s\n' "${ext_counts[$ext]}" "$ext" done | sort -k1,1rn -k2,2 | cut -f2 ) hint="" for ext in "${type_ext_list[@]}"; do if [ -z "$hint" ]; then hint="\`${ext}\`" else hint+=", \`${ext}\`" fi done printf -- "- %-16s %5d (%s)\n" "${type}:" "$count" "$hint" done # Handle extensionless files. # Default: fold into the `(other)` count. # With -x: emit them as their own bullet with nested filename list. 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. 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