Last active
August 3, 2026 12:14
-
-
Save gmlewis/5ff14ce5a945b2832fb0f6fd51b5ddaf to your computer and use it in GitHub Desktop.
find-go-hyphen-ops
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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