compass

orient-fileexts: hide `(none)` by default; add -x flag to list extensionless files

commit d1183e1e6ed1b847072cae5129ee66efaf7acce3 · parent 37decbe129 · browse this tree
Ximianth, 2026-06-24 15:27:09 UTC

`(none)` in code markers reads awkwardly and the bucket is mostly noise.
Roll extensionless files into `(other)` by default; expose them as a
nested bullet list under \`-x\`/\`--list-extensionless\` when explicitly
requested (`LICENSE`, `.gitignore`, `Makefile`, etc.).

Also renamed the trailing summary from "extensions" to "kinds" -- since
extensionless files now fold into the same bucket, the older noun would
have been a lie.
diff --git a/orient-fileexts b/orient-fileexts
index a600f8b..43593b9 100755
--- a/orient-fileexts
+++ b/orient-fileexts
@@ -1,13 +1,20 @@
 #!/bin/bash
 # Counts files by extension across a project area.
 #
-# Usage: orient-fileexts [PATH]
+# Usage: orient-fileexts [-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.
 #
+# 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.
+#
 # Silent (no output, exit 0) when no countable files are found.
 #
 # Filtering:
@@ -16,12 +23,22 @@
 #   (`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.
-# - Dotfiles with no further dot (`.gitignore`, `.editorconfig`) are
-#   grouped under `(none)` rather than reported as an `.editorconfig`
-#   extension; they're not really extensions in the language sense.
 
 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-fileexts: unknown option: $1" >&2; exit 2 ;;
+		*) break ;;
+	esac
+done
+
 # Resolve the search root.
 if [ $# -ge 1 ]; then
 	root=$(cd "$1" && pwd)
@@ -31,10 +48,13 @@ else
 	root="$PWD"
 fi
 
-# Walk the tree, pruning noise dirs.
-# `find` prune semantics: match the prune predicate, then `-o -print` the rest.
-# We prune by basename so the rules apply at any depth.
-mapfile -t exts < <(
+# `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.
+mapfile -t classified < <(
 	find "$root" \
 		\( \
 			-type d \
@@ -50,45 +70,62 @@ mapfile -t exts < <(
 		2> >(grep -v 'Permission denied' >&2) \
 	| awk '
 		{
-			# Strip leading dot for dotfiles before extension extraction,
-			# so `.gitignore` is treated as having no extension rather than
-			# an extension of "gitignore".
 			name = $0
-			if (substr(name, 1, 1) == ".") name = substr(name, 2)
+			# 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.
-			# No dot, or trailing dot, means no extension.
-			n = length(name)
+			n = length(classname)
 			idx = 0
 			for (i = n; i >= 1; i--) {
-				if (substr(name, i, 1) == ".") { idx = i; break }
+				if (substr(classname, i, 1) == ".") { idx = i; break }
 			}
 			if (idx == 0 || idx == n) {
-				print "(none)"
+				# Emit the original filename so we can list it later if requested.
+				print "(none)\t" name
 			} else {
-				print "." substr(name, idx + 1)
+				print "." substr(classname, idx + 1)
 			}
 		}
-	' \
-	| sort | uniq -c | sort -rn
+	'
 )
 
 # Silent if nothing to report.
-if [ ${#exts[@]} -eq 0 ]; then
+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.
+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
+
+# 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"
+	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.)
-# `uniq -c` emits lines like "   25 .md" with leading whitespace,
-# so we use `read` to split on whitespace cleanly.
 shown_counts=()
 shown_exts=()
 other_files=0
 other_kinds=0
-for line in "${exts[@]}"; do
-	read -r count ext <<<"$line"
+for line in "${sorted_exts[@]}"; do
+	count=${line%%$'\t'*}
+	ext=${line#*$'\t'}
 	if [ "$count" -ge 3 ]; then
 		shown_counts+=("$count")
 		shown_exts+=("$ext")
@@ -101,9 +138,30 @@ done
 for i in "${!shown_counts[@]}"; do
 	echo "- \`${shown_exts[$i]}\`: ${shown_counts[$i]}"
 done
-# Pluralize cleanly: "1 file" / "N files", "1 extension" / "N extensions".
+
+# Handle extensionless files.
+# Default: fold into the `(other)` count.
+# With -x: emit them as their own bullet (or nested under "extensionless" line).
+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.  This is 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"
-	ext_word="extensions"; [ "$other_kinds" -eq 1 ] && ext_word="extension"
-	echo "- (other): ${other_files} ${file_word} across ${other_kinds} ${ext_word}"
+	kind_word="kinds"; [ "$other_kinds" -eq 1 ] && kind_word="kind"
+	echo "- (other): ${other_files} ${file_word} across ${other_kinds} ${kind_word}"
 fi