diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d548bd..ad1ecd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Short option aliases**: every CLI option now has a short equivalent (for + example `-f`/`--from`, `-r`/`--relative-to`, `-v`/`--vcs`, + `-c`/`--completions`). Generated Bash/Zsh/Fish/Elvish/PowerShell completions + expose both the short and long forms. + ## [0.1.0] - 2026-07-06 ### Added diff --git a/README.md b/README.md index bf227da..472cd77 100644 --- a/README.md +++ b/README.md @@ -24,57 +24,111 @@ yank-path [OPTIONS] [PATH...] - Multiple operands are rendered **one per line**, in the order given. - All options apply to **every** operand in the invocation. -## Installation +## Examples -### From crates.io (recommended) +All examples assume the current working directory is +`~/projects/example-repo` (i.e. `/Users/example/projects/example-repo`). -```sh -cargo install yank-path +> **Note:** By default `yank-path` copies the rendered text to the system +> clipboard and writes nothing to stdout. The sessions below add +> `--print --no-copy` so the rendered result is visible in the terminal; +> in normal use you would omit those flags and just let it copy. + +### Copy the current directory's path + +```console +~/projects/example-repo $ yank-path --print --no-copy +~/projects/example-repo ``` -### From source (latest `main`) +### Copy a path in a different form -Install the latest unreleased revision directly from GitHub: +Pick the anchor that matches how you want the path to read. `--from`, +`--absolute`, and `--relative-to` are mutually exclusive — see +[Anchor modes](#anchor-modes) for the full reference. -```sh -cargo install --git https://github.com/neumachen/yank-path +```console +~/projects/example-repo $ yank-path --from home --print --no-copy . +~/projects/example-repo + +~/projects/example-repo $ yank-path --from base --print --no-copy . +example-repo + +~/projects/example-repo $ yank-path --from parent --print --no-copy . +projects/example-repo + +~/projects/example-repo $ yank-path --from git --print --no-copy . +. + +~/projects/example-repo $ yank-path --absolute --print --no-copy . +/Users/example/projects/example-repo + +~/projects/example-repo $ yank-path --relative-to ~/projects --print --no-copy . +example-repo ``` -Or clone and build manually: +### Copy the path of a specific file -```sh -git clone https://github.com/neumachen/yank-path -cd yank-path -cargo build --release -# binary at target/release/yank-path +```console +# Repo-relative path for a source file (errors if not in a Git repo). +~/projects/example-repo $ yank-path --from git --print --no-copy src/main.rs +src/main.rs + +# Canonical absolute path of a file. +~/projects/example-repo $ yank-path --absolute --print --no-copy README.md +/Users/example/projects/example-repo/README.md + +# Render a path relative to an explicit base directory. +~/projects/example-repo $ yank-path --relative-to ~ --print --no-copy ~/projects/example-repo/README.md +projects/example-repo/README.md ``` -### Runtime notes +### Share a link to a file (VCS permalink) -The binary is self-contained — once built, it has no runtime -dependencies beyond a system clipboard backend, and even that is -optional. On **headless systems** with no X11 or Wayland display (CI -runners, SSH sessions, the Docker image shipped with this repo) -`yank-path` automatically falls back to printing the rendered text to -stdout instead of copying it to the clipboard, so the command remains -useful everywhere. +Turn a file path into a stable remote URL for pull requests, issues, or +docs. See [VCS URL anchor](#vcs-url-anchor) for all `--vcs*` options. -If you would rather not install a toolchain locally, the provided -Docker image works as a drop-in alternative — see -[Container usage](#container-usage) for the full workflow: +```console +# GitHub permalink to a file at the current commit +~/projects/example-repo $ yank-path --vcs --print --no-copy src/main.rs +https://github.com/owner/repo/blob/abc1234.../src/main.rs -```sh -docker build -t yank-path . -docker run --rm yank-path . +# Use a branch instead of a SHA when no commit is resolvable +~/projects/example-repo $ yank-path --vcs --vcs-branch-fallback --print --no-copy src/main.rs +# -> https://github.com/owner/repo/blob/main/src/main.rs + +# Point at a non-default remote +~/projects/example-repo $ yank-path --vcs --vcs-remote upstream --print --no-copy README.md +# -> https://github.com/upstream-owner/repo/blob/abc1234.../README.md ``` -### Requirements +### Copy several files at once (glob) -Building or installing from source requires a stable Rust toolchain. -The minimum supported Rust version (MSRV) is **1.88**, which CI -enforces. Linux and macOS are tested in CI; Windows is currently -untested (path rendering assumes POSIX separators — Windows support is -future work). +`--glob` matches files in the current directory only. See +[Glob expansion](#glob-expansion) for the full rules. + +```console +~/projects/example-repo $ ls *.rs +build.rs + +~/projects/example-repo $ yank-path --glob '*.rs' --print --no-copy +~/projects/example-repo/build.rs +``` + +### Use it in a script (print, don't copy) + +```console +# Print to stdout AND copy to the clipboard. +~/projects/example-repo $ yank-path --print +~/projects/example-repo + +# Pure stdout renderer — never touches the clipboard. +~/projects/example-repo $ yank-path --print --no-copy +~/projects/example-repo + +# Validate operands only (silent success, no output, no clipboard write). +~/projects/example-repo $ yank-path --no-copy README.md +``` ## Anchor modes @@ -96,6 +150,27 @@ and the command is `yank-path .`. | relative-to | — | `--relative-to ` | e.g. `--relative-to ~/projects` → `example-repo` | | vcs | VCS | `--vcs` | VCS remote URL permalink — see [VCS URL anchor](#vcs-url-anchor) | +### Short flag aliases + +Every long-form option has a short equivalent: + +| Short | Long | +| ----- | ------------------------ | +| `-f` | `--from` | +| `-r` | `--relative-to` | +| `-a` | `--absolute` | +| `-g` | `--glob` | +| `-p` | `--print` | +| `-n` | `--no-copy` | +| `-v` | `--vcs` | +| `-R` | `--vcs-remote` | +| `-d` | `--vcs-default-branch` | +| `-b` | `--vcs-branch-fallback` | +| `-x` | `--vcs-verify` | +| `-c` | `--completions` | + +The existing long-form `--VCS` alias remains supported. + Notes: - **No anchor ever emits a bare filename.** Every rendered output @@ -175,29 +250,12 @@ In all cases the URL is still produced and exit code is unchanged (0). `--vcs-verify` requires `git` on PATH; without it you receive a neutral note. -### Examples - -```sh -# GitHub permalink to a file at the current commit -yank-path --vcs --print --no-copy src/main.rs -# -> https://github.com/owner/repo/blob/abc1234.../src/main.rs - -# Use a branch instead of a SHA when no commit is resolvable -yank-path --vcs --vcs-branch-fallback --print --no-copy src/main.rs - -# Point at a non-default remote -yank-path --vcs --vcs-remote upstream --print --no-copy README.md - -# Opt-in: verify the ref exists on the remote (requires git on PATH) -yank-path --vcs --vcs-verify --print --no-copy src/main.rs -``` - ## Glob expansion `--glob PATTERN` expands a single-level glob in the current working directory. The flag is **repeatable** and the results are concatenated. -Rules: +### Rules - **Single-level only.** A pattern may not contain `/`; multi-segment patterns are rejected. @@ -241,6 +299,10 @@ for the given shell; redirect it to the appropriate location for your shell. This is a standalone action — it ignores all other arguments, does not touch the clipboard or filesystem, and exits 0. +Generated completion scripts include both the long options and their short +aliases, so completion works for either form. If you already have a completion +script installed, regenerate it after upgrading to pick up newly added aliases. + Supported shells: `bash`, `zsh`, `fish`, `elvish`, `powershell`. ### Bash (user-local) @@ -283,26 +345,6 @@ This means a successful `yank-path` invocation always corresponds to a clipboard that contains the full, valid result — there is no partial state to reason about. -## Examples - -```sh -# Default: yank `~/projects/example-repo` (home-anchored) for the cwd. -yank-path - -# Repo-relative path for a source file (errors if not in a Git repo). -yank-path --from git src/main.rs - -# Canonical absolute path of a file. -yank-path --absolute README.md - -# Print all *.rs files in the cwd to stdout without touching the clipboard. -yank-path --glob '*.rs' --print --no-copy - -# Render a path relative to an explicit base directory. -yank-path --relative-to ~ ~/projects/example-repo/README.md -# → projects/example-repo/README.md -``` - ## Container usage `yank-path` ships with a multi-stage `Dockerfile`, a `docker-compose.yml` @@ -366,6 +408,58 @@ clear that the current surface is minimal by design. - Man pages. - OS-level packaging (Homebrew, deb/rpm, etc.). +## Installation + +### From crates.io (recommended) + +```sh +cargo install --locked yank-path +``` + +### From source (latest `main`) + +Install the latest unreleased revision directly from GitHub: + +```sh +cargo install --locked --git https://github.com/neumachen/yank-path +``` + +Or clone and build manually: + +```sh +git clone https://github.com/neumachen/yank-path +cd yank-path +cargo build --release +# binary at target/release/yank-path +``` + +### Runtime notes + +The binary is self-contained — once built, it has no runtime +dependencies beyond a system clipboard backend, and even that is +optional. On **headless systems** with no X11 or Wayland display (CI +runners, SSH sessions, the Docker image shipped with this repo) +`yank-path` automatically falls back to printing the rendered text to +stdout instead of copying it to the clipboard, so the command remains +useful everywhere. + +If you would rather not install a toolchain locally, the provided +Docker image works as a drop-in alternative — see +[Container usage](#container-usage) for the full workflow: + +```sh +docker build -t yank-path . +docker run --rm yank-path . +``` + +### Requirements + +Building or installing from source requires a stable Rust toolchain. +The minimum supported Rust version (MSRV) is **1.88**, which CI +enforces. Linux and macOS are tested in CI; Windows is currently +untested (path rendering assumes POSIX separators — Windows support is +future work). + ## License Dual-licensed under MIT or Apache-2.0, at your option. diff --git a/src/cli.rs b/src/cli.rs index 85a049f..19fc8d2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -35,44 +35,54 @@ pub struct Cli { /// Anchor selection: `home`, `base`, `parent`, or `git`. Aliases: /// `basename` → `base`, `dirname` → `parent`. - #[arg(long = "from", value_name = "ANCHOR", value_enum)] + #[arg(short = 'f', long = "from", value_name = "ANCHOR", value_enum)] pub from: Option, /// Render paths relative to this base directory. - #[arg(long = "relative-to", value_name = "PATH")] + #[arg(short = 'r', long = "relative-to", value_name = "PATH")] pub relative_to: Option, /// Render paths in canonical absolute form. - #[arg(long = "absolute")] + #[arg(short = 'a', long = "absolute")] pub absolute: bool, /// Single-level glob pattern(s). Repeatable. Patterns containing `/` /// or `**` are rejected. - #[arg(long = "glob", value_name = "PATTERN")] + #[arg(short = 'g', long = "glob", value_name = "PATTERN")] pub glob: Vec, /// Also print rendered paths to stdout (does not imply `--no-copy`). - #[arg(long = "print")] + #[arg(short = 'p', long = "print")] pub print: bool, /// Do not touch the system clipboard. - #[arg(long = "no-copy")] + #[arg(short = 'n', long = "no-copy")] pub no_copy: bool, /// Render paths as VCS remote URLs (e.g. GitHub permalink). - #[arg(long = "vcs", visible_alias = "VCS")] + #[arg(short = 'v', long = "vcs", visible_alias = "VCS")] pub vcs: bool, /// Remote name for `--vcs` (defaults to `origin`). - #[arg(long = "vcs-remote", value_name = "REMOTE", requires = "vcs")] + #[arg( + short = 'R', + long = "vcs-remote", + value_name = "REMOTE", + requires = "vcs" + )] pub vcs_remote: Option, /// Default branch for `--vcs` (defaults to `main`). - #[arg(long = "vcs-default-branch", value_name = "BRANCH", requires = "vcs")] + #[arg( + short = 'd', + long = "vcs-default-branch", + value_name = "BRANCH", + requires = "vcs" + )] pub vcs_default_branch: Option, /// Fall back to branch name when SHA is unavailable (for `--vcs`). - #[arg(long = "vcs-branch-fallback", requires = "vcs")] + #[arg(short = 'b', long = "vcs-branch-fallback", requires = "vcs")] pub vcs_branch_fallback: bool, /// Verify the ref exists on the remote via `git ls-remote` (for `--vcs`). @@ -80,7 +90,7 @@ pub struct Cli { /// This spawns a subprocess with a timeout. If the remote is unreachable, /// private, or verification times out, a note is emitted but the URL is /// still produced. Only adds a warning when the ref is definitively absent. - #[arg(long = "vcs-verify", requires = "vcs")] + #[arg(short = 'x', long = "vcs-verify", requires = "vcs")] pub vcs_verify: bool, /// Generate a shell completion script for the given shell and exit. @@ -90,6 +100,7 @@ pub struct Cli { /// writes the completion script to stdout without touching the /// filesystem or clipboard. #[arg( + short = 'c', long = "completions", value_name = "SHELL", value_enum, @@ -443,4 +454,84 @@ mod tests { "expected ArgumentConflict, got: {err}" ); } + + // --- Short flag alias tests --- + + #[test] + fn short_from_takes_value() { + let cli = parse(&["-f", "git"]); + assert_eq!(cli.from, Some(FromAnchor::Git)); + assert_eq!(cli.anchor().unwrap(), Anchor::Git); + } + + #[test] + fn short_relative_to_takes_value() { + let cli = parse(&["-r", "/tmp/base"]); + assert_eq!( + cli.anchor().unwrap(), + Anchor::RelativeTo(PathBuf::from("/tmp/base")) + ); + } + + #[test] + fn short_absolute_boolean() { + let cli = parse(&["-a"]); + assert!(cli.absolute); + assert_eq!(cli.anchor().unwrap(), Anchor::Absolute); + } + + #[test] + fn short_glob_is_repeatable() { + let cli = parse(&["-g", "*.rs", "-g", "*.md"]); + assert_eq!(cli.glob, vec!["*.rs".to_string(), "*.md".to_string()]); + } + + #[test] + fn short_print_and_no_copy_booleans() { + let cli = parse(&["-p", "-n"]); + assert!(cli.print); + assert!(cli.no_copy); + } + + #[test] + fn short_vcs_boolean_and_anchor() { + let cli = parse(&["-v"]); + assert!(cli.vcs); + assert_eq!(cli.anchor().unwrap(), Anchor::Vcs); + } + + #[test] + fn short_vcs_dependent_flags_parse_with_short_vcs() { + let cli = parse(&["-v", "-R", "upstream", "-d", "develop", "-b", "-x"]); + assert!(cli.vcs); + assert_eq!(cli.vcs_remote.as_deref(), Some("upstream")); + assert_eq!(cli.vcs_default_branch.as_deref(), Some("develop")); + assert!(cli.vcs_branch_fallback); + assert!(cli.vcs_verify); + } + + #[test] + fn short_completions_takes_value() { + let cli = Cli::try_parse_from(["yank-path", "-c", "zsh"]).expect("-c zsh should parse"); + assert_eq!(cli.completions, Some(Shell::Zsh)); + } + + #[test] + fn short_anchor_flags_conflict() { + let err = Cli::try_parse_from(["yank-path", "-a", "-f", "home"]).unwrap_err(); + assert_eq!( + err.kind(), + clap::error::ErrorKind::ArgumentConflict, + "expected ArgumentConflict, got: {err}" + ); + } + + #[test] + fn short_vcs_remote_requires_vcs() { + let err = Cli::try_parse_from(["yank-path", "-R", "origin"]).unwrap_err(); + assert!( + matches!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument), + "expected MissingRequiredArgument, got: {err}" + ); + } } diff --git a/tests/completions.rs b/tests/completions.rs index fe97603..3a35d50 100644 --- a/tests/completions.rs +++ b/tests/completions.rs @@ -42,3 +42,106 @@ fn completions_bash_emits_script() { .success() .stdout(predicate::str::contains("yank-path")); } + +// --------------------------------------------------------------------------- +// Short-alias exposure in generated completions. +// +// TDD exception: the short aliases already exist in `src/cli.rs`; clap_complete +// derives scripts from `Cli::command()`, so these verification tests pass on +// first run without a fabricated RED failure. +// --------------------------------------------------------------------------- + +/// Approved short/long alias mapping under verification. +const ALIAS_PAIRS: &[(&str, &str)] = &[ + ("-f", "--from"), + ("-r", "--relative-to"), + ("-a", "--absolute"), + ("-g", "--glob"), + ("-p", "--print"), + ("-n", "--no-copy"), + ("-v", "--vcs"), + ("-R", "--vcs-remote"), + ("-d", "--vcs-default-branch"), + ("-b", "--vcs-branch-fallback"), + ("-x", "--vcs-verify"), + ("-c", "--completions"), +]; + +/// Run `--completions ` and return the generated script. +fn generate(shell: &str) -> String { + let dir = tempdir().unwrap(); + let cwd = canonical(&dir); + let out = yp(&cwd) + .args(["--completions", shell]) + .output() + .expect("spawn yank-path"); + assert!( + out.status.success(), + "--completions {shell} exited non-zero" + ); + String::from_utf8(out.stdout).expect("completion script is valid UTF-8") +} + +#[test] +fn completions_bash_exposes_short_aliases() { + let script = generate("bash"); + let opts: std::collections::BTreeSet<&str> = script + .lines() + .find(|l| l.contains("opts=\"-")) + .and_then(|l| { + l.split_once('"') + .map(|(_, rest)| rest.trim_end_matches('"')) + }) + .expect("bash script contains an opts=\"…\" line") + .split_whitespace() + .collect(); + + for (short, long) in ALIAS_PAIRS { + assert!( + opts.contains(short), + "bash opts missing short flag `{short}`" + ); + assert!(opts.contains(long), "bash opts missing long flag `{long}`"); + } + assert!( + opts.contains("--VCS"), + "bash opts dropped visible `--VCS` alias" + ); +} + +#[test] +fn completions_zsh_exposes_short_aliases() { + let script = generate("zsh"); + + // clap_complete zsh specifiers: value flags use `-x+[` (repeatable `*-x+[`), + // booleans use `-x[`; long forms use `--long=[` / `--long[`. + let specs: &[(&str, &str)] = &[ + ("'-f+[", "'--from=["), + ("'-r+[", "'--relative-to=["), + ("'-a[", "'--absolute["), + ("'*-g+[", "'*--glob=["), + ("'-p[", "'--print["), + ("'-n[", "'--no-copy["), + ("'-v[", "'--vcs["), + ("'-R+[", "'--vcs-remote=["), + ("'-d+[", "'--vcs-default-branch=["), + ("'-b[", "'--vcs-branch-fallback["), + ("'-x[", "'--vcs-verify["), + ("'-c+[", "'--completions=["), + ]; + + for (short, long) in specs { + assert!( + script.contains(short), + "zsh script missing short specifier `{short}…`" + ); + assert!( + script.contains(long), + "zsh script missing long specifier `{long}…`" + ); + } + assert!( + script.contains("'--VCS["), + "zsh script dropped visible `--VCS` alias" + ); +}