#!/bin/bash # Classifies files by type and counts them across a project area. # # Usage: orient-files [-x|--list-unclassified] [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. 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`) # - bash: 14 (shebang, `.bash`) # - 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. # # Heuristics: # - Extension: a small handwritten table mapping `.md` -> markdown etc. # - Shebang: for executable regular files, the first 100 bytes are # read and the first line is matched against a known interpreter list # (bash, sh, zsh, dash, ksh, python, node, perl, ruby, lua, awk). # Shebang dominates extension when both produce a verdict: a `.sh` # file with `#!/usr/bin/env python3` counts as python. # Only executable files get a shebang sniff, which keeps the cost # bounded -- almost all source files are non-executable. # The hint column lists which heuristics fired for a given type: it's # a tally of "reasons we looked", not a derivation trace. # # Extensionless files (e.g. `LICENSE`, `Makefile`) and dotfiles whose # name carries no further dot (`.gitignore`, `.editorconfig`) are folded # into `(other)` along with files of rare extensions. # With `-x`/`--list-unclassified`, every file that landed in `(other)` # is itemized as nested bullets, grouped by type, so it's clear what # the lump actually contains. In practice this is a useful peek: the # `(other)` bucket often hides dotfile-shaped config that's central to # a project's identity. # # 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_unclassified=0 while [ $# -gt 0 ]; do case "$1" in -x|--list-unclassified) list_unclassified=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 ) # Interpreter name (as it appears in a shebang) -> canonical type name. # Order in the regex matters only for completeness, not for precedence: # any single file's shebang names one interpreter. declare -A interp_to_type=( [bash]=bash [sh]=sh [zsh]=zsh [dash]=dash [ksh]=ksh [python]=python [python2]=python [python3]=python [node]=javascript [nodejs]=javascript [perl]=perl [ruby]=ruby [lua]=lua [awk]=awk [gawk]=awk [mawk]=awk ) # `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 emitted as a TAB-separated triple: # \t\t # where the trailing flag marks executable regular files (which then get # a shebang sniff in a second pass). We need both filename and path-ish # info to be carried through, so we use -printf with %p (full path) for # the shebang lookup; the basename comes from %f. 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 \( -executable -printf 'x\t%f\t%p\n' \) \) -o \ \( -type f -printf '-\t%f\t\n' \) \ 2> >(grep -v 'Permission denied' >&2) \ | awk -F'\t' ' function classify_ext(name, classname, n, i, idx) { # 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) n = length(classname) idx = 0 for (i = n; i >= 1; i--) { if (substr(classname, i, 1) == ".") { idx = i; break } } if (idx == 0 || idx == n) return "(none)" return "." substr(classname, idx + 1) } { exec_flag = $1; name = $2; path = $3 ext = classify_ext(name) # Output: \t\t\t # The basename is needed for the --list-unclassified listing. # The path is only used for the shebang sniff, and only when # exec_flag == "x"; otherwise it is empty. print ext "\t" name "\t" exec_flag "\t" path } ' ) # Silent if nothing to report. if [ ${#classified[@]} -eq 0 ]; then exit 0 fi # Sniff a file's shebang, cheaply. # Read at most 100 bytes; require `#!` at byte 0; take the first line; # match a known interpreter token via the basename of whatever follows # the bang (handles both `#!/bin/bash` and `#!/usr/bin/env bash`). # Echoes the canonical type name on a match, or nothing on a miss. sniff_shebang() { local path=$1 head line interp # Strip NULs so bash doesn't complain about them in command substitution. # Binaries often start with a NUL run, and a real shebang line never # contains NULs anyway. head=$(head -c 100 -- "$path" 2>/dev/null | tr -d '\000') || return 0 [[ "$head" == '#!'* ]] || return 0 line=${head%%$'\n'*} # Match the rightmost basename-looking token in the shebang. # Examples it should pick up: # #!/bin/bash -> bash # #!/usr/bin/env python3 -> python3 # #!/usr/bin/env -S bash -> bash # Trailing flags (e.g. `python3 -u`) are stripped by taking the first # whitespace-delimited token after the last `/` or after `env`. # Strip leading `#!` and surrounding whitespace. line=${line#'#!'} line=${line## } # Split on whitespace; walk tokens to find the first that's a known # interpreter basename (skipping option words like `-S` and the # literal `env`). local tok base name for tok in $line; do # Skip option flags. [[ "$tok" == -* ]] && continue base=${tok##*/} # Skip the `env` dispatcher; the next token will be the interpreter. [[ "$base" == "env" ]] && continue name=${interp_to_type[$base]:-} if [ -n "$name" ]; then printf '%s' "$name" return 0 fi # First non-flag, non-env token decided the interpreter (even if # we don't recognize it). Bail rather than walking into argv. return 0 done } # Partition into per-extension counts, the extensionless bucket, and # shebang-driven reassignments. # # Per-type tallies are accumulated directly; for the hint column we # track which heuristics fired and (for the extension heuristic) which # extensions contributed. # `type_counts[type]` -- total files for that type. # `type_exts[type]` -- space-separated set of contributing extensions. # `type_shebang[type]` -- count of files whose shebang chose this type. # `ext_counts[ext]` -- per-extension count, used for sorting the hint # list within a type (dominant variant first). # `type_files[type]` -- newline-separated filenames, populated only # when --list-unclassified is set (the listing # needs basenames per type; we skip the bookkeeping # otherwise). declare -A type_counts=() declare -A type_exts=() declare -A type_shebang=() declare -A ext_counts=() declare -A type_files=() for entry in "${classified[@]}"; do IFS=$'\t' read -r ext name exec_flag path <<< "$entry" # Decide the type for this individual file. # Shebang dominates extension when both produce a verdict. shebang_type="" if [ "$exec_flag" = "x" ] && [ -n "$path" ]; then shebang_type=$(sniff_shebang "$path") fi if [ -n "$shebang_type" ]; then type=$shebang_type type_shebang[$type]=$((${type_shebang[$type]:-0} + 1)) # Don't count this file's extension toward the type's ext set. # If the extension agreed with the shebang, we still want the row # to show that extension as a contributing heuristic -- but only # if some *other* file of the same extension landed here too. # That happens naturally below when non-shebang files of that # extension are processed. elif [ "$ext" = "(none)" ]; then # Extensionless files with no shebang verdict get their own type # bucket, so the downstream lumping and listing flow treats them # uniformly with other rare types. Almost always small, almost # always interesting (dotfiles, LICENSE, Makefile, ...). type="extensionless" else type=${ext_to_type[$ext]:-$ext} ext_counts[$ext]=$((${ext_counts[$ext]:-0} + 1)) if [ -z "${type_exts[$type]:-}" ]; then type_exts[$type]=$ext else # Add to set only if not already present. case " ${type_exts[$type]} " in *" $ext "*) ;; *) type_exts[$type]+=" $ext" ;; esac fi fi type_counts[$type]=$((${type_counts[$type]:-0} + 1)) if [ "$list_unclassified" -eq 1 ]; then if [ -z "${type_files[$type]:-}" ]; then type_files[$type]=$name else type_files[$type]+=$'\n'$name fi 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_types=() 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_types+=("$type") 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. # # Hint composition: `shebang` appears first if the shebang heuristic # fired for this type, then the contributing extensions sorted by # per-extension count descending (dominant variant first), then # alphabetically. Determinism is important since bash assoc-array # iteration order is unspecified. for i in "${!shown_types[@]}"; do type=${shown_types[$i]} count=${shown_counts[$i]} hint_parts=() if [ -n "${type_shebang[$type]:-}" ]; then hint_parts+=("shebang") fi if [ -n "${type_exts[$type]:-}" ]; then 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 ) for ext in "${type_ext_list[@]}"; do hint_parts+=("\`${ext}\`") done fi hint="" for part in "${hint_parts[@]}"; do if [ -z "$hint" ]; then hint="$part" else hint+=", $part" fi done printf -- "- %-16s %5d (%s)\n" "${type}:" "$count" "$hint" done # Render the (other) summary line, if anything ended up there. # 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 # With --list-unclassified, expand (other) into a nested listing, # grouped by type so it's easy to see what each rare type is. # Types are listed in the same order as the lumping loop saw them # (count descending, then alphabetical), and filenames within each # type are sorted alphabetically for deterministic output. if [ "$list_unclassified" -eq 1 ] && [ "${#other_types[@]}" -gt 0 ]; then for type in "${other_types[@]}"; do count=${type_counts[$type]} echo " - ${type} (${count}):" # `type_files` entries are newline-separated; sort them. mapfile -t sorted_files < <(printf '%s\n' "${type_files[$type]}" | sort) for f in "${sorted_files[@]}"; do echo " - \`${f}\`" done done fi