orient-files
#!/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:
# <ext_or_(none)>\t<basename>\t<x_or_->
# 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: <ext>\t<basename>\t<exec_flag>\t<path>
# 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
orient-files · 14.1 KiB · 2ab2c888ab