compass

orient-files: classify by type, three-column output

commit 9b576736e6f5cc1714e59d8d82114cda99bf98fb · parent 3172031fb8 · browse this tree
Ximianth, 2026-06-24 16:47:25 UTC

Output is now grouped by canonical type name (markdown, rust, typescript,
...) rather than raw extension.  Each row shows type, count, and a hint
listing the extensions that rolled up into it.

The extension-to-type table is a bash assoc array seeded with a couple
dozen common languages and formats, names cribbed from github-linguist's
list (consulted for reference, not pulled in).  Unmapped extensions fall
through to using the literal extension as the type name, so unfamiliar
projects stay informative without needing the table to be exhaustive.

The (other) lumping threshold (count >= 3) is unchanged but now applies
per-type rather than per-extension; this keeps small siblings together
(.yml + .yaml count as four yaml files, not two of each lumped).

-x/--list-extensionless still works the same way; shebang-based
detection in the next commit will reduce how often that bucket matters
in practice.
diff --git a/orient-files b/orient-files
index 53bed72..8879078 100755
--- a/orient-files
+++ b/orient-files
@@ -1,19 +1,32 @@
 #!/bin/bash
-# Counts files by extension across a project area.
+# 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 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.
+# 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.
 #
-# 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.
+# 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.
 #
@@ -48,12 +61,64 @@ 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.
+# 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.
 #
-# We awk-classify each file into either an extension (e.g. `.rs`) or the
-# special token `(none)`.  Bash then tallies and decides on layout.
+# 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" \
 		\( \
@@ -97,8 +162,7 @@ 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.
+# Partition into per-extension counts and the extensionless bucket.
 declare -A ext_counts=()
 extensionless_files=()
 for entry in "${classified[@]}"; do
@@ -109,39 +173,84 @@ for entry in "${classified[@]}"; do
 	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"
+# 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=()
-shown_exts=()
 other_files=0
 other_kinds=0
-for line in "${sorted_exts[@]}"; do
+for line in "${sorted_types[@]}"; do
 	count=${line%%$'\t'*}
-	ext=${line#*$'\t'}
+	type=${line#*$'\t'}
 	if [ "$count" -ge 3 ]; then
+		shown_types+=("$type")
 		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]}"
+# 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 (or nested under "extensionless" line).
+# 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}):"
@@ -152,8 +261,8 @@ if [ "$list_extensionless" -eq 1 ] && [ "$n_extensionless" -gt 0 ]; then
 	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.
+	# 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