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