Skip to content

Instantly share code, notes, and snippets.

@gmlewis
Last active August 3, 2026 12:14
Show Gist options
  • Select an option

  • Save gmlewis/5ff14ce5a945b2832fb0f6fd51b5ddaf to your computer and use it in GitHub Desktop.

Select an option

Save gmlewis/5ff14ce5a945b2832fb0f6fd51b5ddaf to your computer and use it in GitHub Desktop.
find-go-hyphen-ops
#!/usr/bin/env -S gos run
// find-go-hyphen-ops
//
// A stand-alone, shebang-able Gossamer program. Run it directly:
//
// ./find-go-hyphen-ops [dir...]
//
// With no arguments it searches `./...` — the current directory and every
// subdirectory, skipping known non-Go / build-output / vendored directories
// (node_modules, build, vendor, .git, target, dist, ...). Pass directory
// names to search elsewhere; a trailing `/...` (Go wildcard style) is
// accepted and stripped, e.g. `~/go/src/.../go-nomadnet/...`.
//
// For every `.go` file whose filename contains an underscore that can be
// switched to a hyphen WITHOUT changing any Go build suffix, it prints:
//
// git mv <orig> <new>
//
// to stdout. The protected Go filename suffixes are `_GOOS`, `_GOARCH`,
// `_GOOS_GOARCH` (in that order) and the trailing `_test`; only the
// underscores in the free "name" portion before such a suffix are
// candidates. Go-ignored files (names beginning with `_` or `.`) are left
// alone, since renaming them would change whether the Go tool sees them.
//
// THIS PROGRAM MAKES NO CHANGES. It only prints its analysis to stdout
// (a one-line count summary goes to stderr). Review the output, then run
// the `git mv` lines yourself if you want them applied.
//
// Why the two-line shell preamble? Gossamer's parser does not accept a
// `#!` shebang line, so a single self-contained file cannot start with
// `#!/usr/bin/env gos run`. Lines 1-2 are a tiny POSIX shell launcher
// that strips its own preamble (`tail -n +3`) and hands the Gossamer body
// below to `gos run`. Everything from this line on is pure Gossamer.
use std::{env, fs, strings}
// Known GOOS values (from `go tool dist list`, plus legacy ones still
// recognized in filenames).
const GOOS: Vec<String> = Vec::from([
"aix", "android", "darwin", "dragonfly", "freebsd", "hurd", "illumos",
"ios", "js", "linux", "nacl", "netbsd", "openbsd", "plan9", "solaris",
"wasip1", "windows", "zos",
])
// Known GOARCH values.
const GOARCH: Vec<String> = Vec::from([
"386", "amd64", "arm", "arm64", "loong64", "mips", "mips64", "mips64le",
"mipsle", "ppc64", "ppc64le", "riscv64", "s390x", "sparc64", "wasm",
])
// Directory basenames never descended into: non-Go, build output, editor
// caches, or vendored third-party code we should not rewrite.
const SKIP_DIRS: Vec<String> = Vec::from([
"node_modules", "build", "dist", "target", "bin", "obj", "out",
"_build", "release", "debug", "vendor", "deps", "coverage",
"__pycache__", ".next", ".cache", ".gradle", ".m2", ".terraform",
".idea", ".vscode", ".git",
])
fn is_goos(s: &String) -> bool {
GOOS.contains(s)
}
fn is_goarch(s: &String) -> bool {
GOARCH.contains(s)
}
// Directories the Go tool ignores (leading `.` or `_`) plus our skip list.
fn is_skip_dir(name: &String) -> bool {
if strings::starts_with(name, ".") {
return true
}
if strings::starts_with(name, "_") {
return true
}
SKIP_DIRS.contains(name)
}
// Given a `.go` filename stem (extension already removed), return the stem
// with safe underscores turned into hyphens, or the original stem if no
// safe rename applies.
//
// Go filename build suffixes, parsed from the END of the token list after
// splitting on `_`:
// - an optional trailing `_test` (test files);
// - then an optional `_GOARCH`;
// - then an optional `_GOOS` immediately before that GOARCH
// (i.e. `_GOOS_GOARCH`); or a lone `_GOOS`.
// Tokens before that protected run are the free name and may use hyphens.
fn propose_rename(stem: &String) -> String {
let orig = stem.clone()
// Go ignores files whose name begins with `_` or `.`; renaming them
// would newly include them in the build. Leave them alone.
if strings::starts_with(stem, "_") || strings::starts_with(stem, ".") {
return orig
}
let mut name = stem.clone()
let mut is_test = false
if let Some(rest) = strings::strip_suffix(name, "_test") {
is_test = true
name = rest
}
let tokens = strings::split(name, "_")
let n = tokens.len()
// Count trailing tokens that form a GOOS/GOARCH build suffix.
let mut consume = 0
if n > 0 {
let last = &tokens[n - 1]
if is_goarch(last) {
if n >= 2 && is_goos(&tokens[n - 2]) {
consume = 2
} else {
consume = 1
}
} else if is_goos(last) {
consume = 1
}
}
let free_end = n - consume
let mut free_tokens: Vec<String> = Vec::new()
for i in 0..free_end {
free_tokens.push(tokens[i].clone())
}
let mut prot_tokens: Vec<String> = Vec::new()
for i in free_end..n {
prot_tokens.push(tokens[i].clone())
}
let free_name = strings::join(free_tokens, "_")
let prot_arch = strings::join(prot_tokens, "_")
// Nothing before the suffix (e.g. `linux.go`, `amd64_test.go`): skip.
if free_name == "" {
return orig
}
// No underscore to replace in the free name: nothing to do.
if !strings::contains(free_name, "_") {
return orig
}
let hyph = strings::replace(free_name, "_", "-")
// Reject inconvenient results: leading/trailing hyphen or a run of
// hyphens (from leading/double/trailing underscores).
if strings::starts_with(hyph, "-") || strings::ends_with(hyph, "-") || strings::contains(hyph, "--") {
return orig
}
let prot_full = if is_test {
if prot_arch == "" {
"test"
} else {
prot_arch + "_test"
}
} else {
prot_arch
}
let new_stem = if prot_full == "" {
hyph
} else {
hyph + "_" + prot_full
}
if new_stem == orig {
return orig
}
new_stem
}
// Recursively walk `dir`, appending (orig, new) path pairs for `.go` files
// that can be safely hyphen-renamed.
fn walk(dir: String, out: &mut Vec<(String, String)>) {
let entries = match fs::read_dir(dir.clone()) {
Ok(e) => e,
Err(err) => {
eprintln!("find-go-hyphen-ops: cannot read {}: {}", dir, err)
return
}
}
for e in entries {
if e.is_dir {
if is_skip_dir(&e.name) {
continue
}
walk(dir + "/" + e.name, out)
} else if e.is_file {
if !strings::ends_with(e.name, ".go") {
continue
}
if let Some(stem) = strings::strip_suffix(e.name, ".go") {
let new_stem = propose_rename(&stem)
if new_stem != stem {
let orig = dir + "/" + e.name
let new = dir + "/" + new_stem + ".go"
out.push((orig, new))
}
}
}
}
}
// Normalize a CLI argument into a search root: strip a trailing `/...`
// (Go wildcard), treating bare `...` / `./...` as `.`.
fn normalize_root(arg: &String) -> String {
if arg == "..." || arg == "./..." {
return "."
}
if let Some(rest) = strings::strip_suffix(arg, "/...") {
if rest == "" {
return "."
}
return rest
}
arg.clone()
}
// Handle a single root that may be a directory (recurse) or a `.go` file.
fn process_root(root: String, out: &mut Vec<(String, String)>) {
match fs::read_dir(root.clone()) {
Ok(_) => walk(root, out),
Err(_) => {
// Not a readable directory: try treating it as a single file.
if strings::ends_with(root, ".go") {
let (dir, fname) = match strings::rsplit_once(root, "/") {
Some((d, f)) => (d, f),
None => (".", root),
}
if let Some(stem) = strings::strip_suffix(fname, ".go") {
let new_stem = propose_rename(&stem)
if new_stem != stem {
let orig = root.clone()
let new = dir + "/" + new_stem + ".go"
out.push((orig, new))
}
}
} else {
eprintln!("find-go-hyphen-ops: not a directory or .go file: {}", root)
}
}
}
}
fn main() {
let args = env::args()
let mut out: Vec<(String, String)> = Vec::new()
if args.is_empty() {
walk(".".clone(), &mut out)
} else {
for a in &args {
process_root(normalize_root(a), &mut out)
}
}
out.sort()
for (orig, new) in &out {
println!("git mv {} {}", orig, new)
}
eprintln!("find-go-hyphen-ops: {} file(s) could switch to hyphens.", out.len())
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment