compass

orient-files

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

orient-files · 8.6 KiB · 9b576736e6