From 196ca1f162a4f1c27ecaf4e6ca79631ffa0a1ee6 Mon Sep 17 00:00:00 2001 From: Fotis Gimian Date: Mon, 6 Jan 2025 23:24:36 +1100 Subject: [PATCH] Switch to generating completions at runtime (#393) * Allow for completion and man page generation at runtime and support for Elvish and Nshull * Refine runtime generation based on feedback provided --- CHANGELOG.md | 5 + Cargo.lock | 11 ++ Cargo.toml | 6 +- RELEASE-CHECKLIST.md | 10 +- completions/_xh | 4 +- completions/_xh.ps1 | 2 + completions/xh.bash | 6 +- completions/xh.elv | 148 ++++++++++++++++++++++ completions/xh.fish | 2 + completions/xh.nu | 138 ++++++++++++++++++++ doc/xh.1 | 7 +- src/cli.rs | 296 +++++++++++-------------------------------- src/generation.rs | 195 ++++++++++++++++++++++++++++ src/main.rs | 6 + 14 files changed, 603 insertions(+), 233 deletions(-) create mode 100644 completions/xh.elv create mode 100644 completions/xh.nu create mode 100644 src/generation.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d184942..47ded2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased +### Features +- Add `--generate` option to generate the man page and shell completions at runtime +- Add support for Elvish and Nushell shell completions + ## [0.23.1] - 2025-01-02 ### Security fixes - Upgrade to ruzstd v0.7.3 to fix RUSTSEC-2024-0400, see #396 (@zuisong) diff --git a/Cargo.lock b/Cargo.lock index d501ee6..e1cd1d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,16 @@ dependencies = [ "clap", ] +[[package]] +name = "clap_complete_nushell" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "315902e790cc6e5ddd20cbd313c1d0d49db77f191e149f96397230fb82a17677" +dependencies = [ + "clap", + "clap_complete", +] + [[package]] name = "clap_derive" version = "4.5.18" @@ -2798,6 +2808,7 @@ dependencies = [ "chardetng", "clap", "clap_complete", + "clap_complete_nushell", "cookie_store 0.20.0", "digest_auth", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 5a552b6..346afdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,8 @@ anyhow = "1.0.38" brotli = { version = "3.3.0", default-features = false, features = ["std"] } chardetng = "0.1.15" clap = { version = "4.4", features = ["derive", "wrap_help", "string"] } -clap_complete = { version = "4.4", optional = true } +clap_complete = "4.4" +clap_complete_nushell = "4.4" cookie_store = { version = "0.20.0", features = ["preserve_order"] } digest_auth = "0.3.0" dirs = "5.0" @@ -38,7 +39,7 @@ once_cell = "1.8.0" os_display = "0.1.3" pem = "3.0" regex-lite = "0.1.5" -roff = { version = "0.2.1", optional = true } +roff = "0.2.1" rpassword = "7.2.0" serde = { version = "1.0", features = ["derive"] } serde-transcode = "1.1.1" @@ -101,7 +102,6 @@ network-interface = ["dep:network-interface"] online-tests = [] ipv6-tests = [] -man-completion-gen = ["clap_complete", "roff"] [package.metadata.cross.build.env] passthrough = ["CARGO_PROFILE_RELEASE_LTO"] diff --git a/RELEASE-CHECKLIST.md b/RELEASE-CHECKLIST.md index d8b215e..fcb0030 100644 --- a/RELEASE-CHECKLIST.md +++ b/RELEASE-CHECKLIST.md @@ -4,9 +4,15 @@ - Update `CHANGELOG.md` (rename unreleased header to the current date, add any missing changes). - Run `cargo update` to update dependencies. - Bump up the version in `Cargo.toml` and run `cargo check` to update `Cargo.lock`. -- Run the following to update man pages and shell-completion files. +- Run the following to update shell-completion files and man pages. ```sh - cargo run --all-features -- generate-completions completions && cargo run --all-features -- generate-manpages doc + cargo run --all-features -- --generate complete-bash > completions/xh.bash + cargo run --all-features -- --generate complete-elvish > completions/xh.elv + cargo run --all-features -- --generate complete-fish > completions/xh.fish + cargo run --all-features -- --generate complete-nushell > completions/xh.nu + cargo run --all-features -- --generate complete-powershell > completions/_xh.ps1 + cargo run --all-features -- --generate complete-zsh > completions/_xh + cargo run --all-features -- --generate man > doc/xh.1 ``` - Commit changes and push them to remote. - Add git tag e.g `git tag v0.9.0`. diff --git a/completions/_xh b/completions/_xh index 10a9b32..258f21d 100644 --- a/completions/_xh +++ b/completions/_xh @@ -49,6 +49,7 @@ none\:"Disable both coloring and formatting"))' \ '--http-version=[HTTP version to use]:VERSION:(1.0 1.1 2 2-prior-knowledge)' \ '*--resolve=[Override DNS resolution for specific domain to a custom IP]:HOST:ADDRESS:_default' \ '--interface=[Bind to a network interface or local IP address]:NAME:_default' \ +'--generate=[Generate shell completions or man pages]:KIND:(complete-bash complete-elvish complete-fish complete-nushell complete-powershell complete-zsh man)' \ '-j[(default) Serialize data items from the command line as a JSON object]' \ '--json[(default) Serialize data items from the command line as a JSON object]' \ '-f[Serialize data items from the command line as form fields]' \ @@ -137,10 +138,11 @@ none\:"Disable both coloring and formatting"))' \ '--no-ignore-stdin[]' \ '--no-curl[]' \ '--no-curl-long[]' \ +'--no-generate[]' \ '--no-help[]' \ '-V[Print version]' \ '--version[Print version]' \ -':raw_method_or_url -- The request URL, preceded by an optional HTTP method:_default' \ +'::raw_method_or_url -- The request URL, preceded by an optional HTTP method:_default' \ '*::raw_rest_args -- Optional key-value pairs to be included in the request.:_default' \ && ret=0 } diff --git a/completions/_xh.ps1 b/completions/_xh.ps1 index 6172f0e..27ed4af 100644 --- a/completions/_xh.ps1 +++ b/completions/_xh.ps1 @@ -52,6 +52,7 @@ Register-ArgumentCompleter -Native -CommandName 'xh' -ScriptBlock { [CompletionResult]::new('--http-version', '--http-version', [CompletionResultType]::ParameterName, 'HTTP version to use') [CompletionResult]::new('--resolve', '--resolve', [CompletionResultType]::ParameterName, 'Override DNS resolution for specific domain to a custom IP') [CompletionResult]::new('--interface', '--interface', [CompletionResultType]::ParameterName, 'Bind to a network interface or local IP address') + [CompletionResult]::new('--generate', '--generate', [CompletionResultType]::ParameterName, 'Generate shell completions or man pages') [CompletionResult]::new('-j', '-j', [CompletionResultType]::ParameterName, '(default) Serialize data items from the command line as a JSON object') [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, '(default) Serialize data items from the command line as a JSON object') [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'Serialize data items from the command line as form fields') @@ -140,6 +141,7 @@ Register-ArgumentCompleter -Native -CommandName 'xh' -ScriptBlock { [CompletionResult]::new('--no-ignore-stdin', '--no-ignore-stdin', [CompletionResultType]::ParameterName, 'no-ignore-stdin') [CompletionResult]::new('--no-curl', '--no-curl', [CompletionResultType]::ParameterName, 'no-curl') [CompletionResult]::new('--no-curl-long', '--no-curl-long', [CompletionResultType]::ParameterName, 'no-curl-long') + [CompletionResult]::new('--no-generate', '--no-generate', [CompletionResultType]::ParameterName, 'no-generate') [CompletionResult]::new('--no-help', '--no-help', [CompletionResultType]::ParameterName, 'no-help') [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') diff --git a/completions/xh.bash b/completions/xh.bash index cf26ec2..0637dc9 100644 --- a/completions/xh.bash +++ b/completions/xh.bash @@ -19,7 +19,7 @@ _xh() { case "${cmd}" in xh) - opts="-j -f -s -p -h -b -m -v -P -q -S -o -d -c -A -a -F -4 -6 -I -V --json --form --multipart --raw --pretty --format-options --style --response-charset --response-mime --print --headers --body --meta --verbose --debug --all --history-print --quiet --stream --output --download --continue --session --session-read-only --auth-type --auth --bearer --ignore-netrc --offline --check-status --follow --max-redirects --timeout --proxy --verify --cert --cert-key --ssl --native-tls --default-scheme --https --http-version --resolve --interface --ipv4 --ipv6 --ignore-stdin --curl --curl-long --help --no-json --no-form --no-multipart --no-raw --no-pretty --no-format-options --no-style --no-response-charset --no-response-mime --no-print --no-headers --no-body --no-meta --no-verbose --no-debug --no-all --no-history-print --no-quiet --no-stream --no-output --no-download --no-continue --no-session --no-session-read-only --no-auth-type --no-auth --no-bearer --no-ignore-netrc --no-offline --no-check-status --no-follow --no-max-redirects --no-timeout --no-proxy --no-verify --no-cert --no-cert-key --no-ssl --no-native-tls --no-default-scheme --no-https --no-http-version --no-resolve --no-interface --no-ipv4 --no-ipv6 --no-ignore-stdin --no-curl --no-curl-long --no-help --version <[METHOD] URL> [REQUEST_ITEM]..." + opts="-j -f -s -p -h -b -m -v -P -q -S -o -d -c -A -a -F -4 -6 -I -V --json --form --multipart --raw --pretty --format-options --style --response-charset --response-mime --print --headers --body --meta --verbose --debug --all --history-print --quiet --stream --output --download --continue --session --session-read-only --auth-type --auth --bearer --ignore-netrc --offline --check-status --follow --max-redirects --timeout --proxy --verify --cert --cert-key --ssl --native-tls --default-scheme --https --http-version --resolve --interface --ipv4 --ipv6 --ignore-stdin --curl --curl-long --generate --help --no-json --no-form --no-multipart --no-raw --no-pretty --no-format-options --no-style --no-response-charset --no-response-mime --no-print --no-headers --no-body --no-meta --no-verbose --no-debug --no-all --no-history-print --no-quiet --no-stream --no-output --no-download --no-continue --no-session --no-session-read-only --no-auth-type --no-auth --no-bearer --no-ignore-netrc --no-offline --no-check-status --no-follow --no-max-redirects --no-timeout --no-proxy --no-verify --no-cert --no-cert-key --no-ssl --no-native-tls --no-default-scheme --no-https --no-http-version --no-resolve --no-interface --no-ipv4 --no-ipv6 --no-ignore-stdin --no-curl --no-curl-long --no-generate --no-help --version [[METHOD] URL] [REQUEST_ITEM]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -149,6 +149,10 @@ _xh() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --generate) + COMPREPLY=($(compgen -W "complete-bash complete-elvish complete-fish complete-nushell complete-powershell complete-zsh man" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; diff --git a/completions/xh.elv b/completions/xh.elv new file mode 100644 index 0000000..028561d --- /dev/null +++ b/completions/xh.elv @@ -0,0 +1,148 @@ + +use builtin; +use str; + +set edit:completion:arg-completer[xh] = {|@words| + fn spaces {|n| + builtin:repeat $n ' ' | str:join '' + } + fn cand {|text desc| + edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc + } + var command = 'xh' + for word $words[1..-1] { + if (str:has-prefix $word '-') { + break + } + set command = $command';'$word + } + var completions = [ + &'xh'= { + cand --raw 'Pass raw request data without extra processing' + cand --pretty 'Controls output processing' + cand --format-options 'Set output formatting options' + cand -s 'Output coloring style' + cand --style 'Output coloring style' + cand --response-charset 'Override the response encoding for terminal display purposes' + cand --response-mime 'Override the response mime type for coloring and formatting for the terminal' + cand -p 'String specifying what the output should contain' + cand --print 'String specifying what the output should contain' + cand -P 'The same as --print but applies only to intermediary requests/responses' + cand --history-print 'The same as --print but applies only to intermediary requests/responses' + cand -o 'Save output to FILE instead of stdout' + cand --output 'Save output to FILE instead of stdout' + cand --session 'Create, or reuse and update a session' + cand --session-read-only 'Create or read a session without updating it form the request/response exchange' + cand -A 'Specify the auth mechanism' + cand --auth-type 'Specify the auth mechanism' + cand -a 'Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer)' + cand --auth 'Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer)' + cand --bearer 'Authenticate with a bearer token' + cand --max-redirects 'Number of redirects to follow. Only respected if --follow is used' + cand --timeout 'Connection timeout of the request' + cand --proxy 'Use a proxy for a protocol. For example: --proxy https:http://proxy.host:8080' + cand --verify 'If "no", skip SSL verification. If a file path, use it as a CA bundle' + cand --cert 'Use a client side certificate for SSL' + cand --cert-key 'A private key file to use with --cert' + cand --ssl 'Force a particular TLS version' + cand --default-scheme 'The default scheme to use if not specified in the URL' + cand --http-version 'HTTP version to use' + cand --resolve 'Override DNS resolution for specific domain to a custom IP' + cand --interface 'Bind to a network interface or local IP address' + cand --generate 'Generate shell completions or man pages' + cand -j '(default) Serialize data items from the command line as a JSON object' + cand --json '(default) Serialize data items from the command line as a JSON object' + cand -f 'Serialize data items from the command line as form fields' + cand --form 'Serialize data items from the command line as form fields' + cand --multipart 'Like --form, but force a multipart/form-data request even without files' + cand -h 'Print only the response headers. Shortcut for --print=h' + cand --headers 'Print only the response headers. Shortcut for --print=h' + cand -b 'Print only the response body. Shortcut for --print=b' + cand --body 'Print only the response body. Shortcut for --print=b' + cand -m 'Print only the response metadata. Shortcut for --print=m' + cand --meta 'Print only the response metadata. Shortcut for --print=m' + cand -v 'Print the whole request as well as the response' + cand --verbose 'Print the whole request as well as the response' + cand --debug 'Print full error stack traces and debug log messages' + cand --all 'Show any intermediary requests/responses while following redirects with --follow' + cand -q 'Do not print to stdout or stderr' + cand --quiet 'Do not print to stdout or stderr' + cand -S 'Always stream the response body' + cand --stream 'Always stream the response body' + cand -d 'Download the body to a file instead of printing it' + cand --download 'Download the body to a file instead of printing it' + cand -c 'Resume an interrupted download. Requires --download and --output' + cand --continue 'Resume an interrupted download. Requires --download and --output' + cand --ignore-netrc 'Do not use credentials from .netrc' + cand --offline 'Construct HTTP requests without sending them anywhere' + cand --check-status '(default) Exit with an error status code if the server replies with an error' + cand -F 'Do follow redirects' + cand --follow 'Do follow redirects' + cand --native-tls 'Use the system TLS library instead of rustls (if enabled at compile time)' + cand --https 'Make HTTPS requests if not specified in the URL' + cand -4 'Resolve hostname to ipv4 addresses only' + cand --ipv4 'Resolve hostname to ipv4 addresses only' + cand -6 'Resolve hostname to ipv6 addresses only' + cand --ipv6 'Resolve hostname to ipv6 addresses only' + cand -I 'Do not attempt to read stdin' + cand --ignore-stdin 'Do not attempt to read stdin' + cand --curl 'Print a translation to a curl command' + cand --curl-long 'Use the long versions of curl''s flags' + cand --help 'Print help' + cand --no-json 'no-json' + cand --no-form 'no-form' + cand --no-multipart 'no-multipart' + cand --no-raw 'no-raw' + cand --no-pretty 'no-pretty' + cand --no-format-options 'no-format-options' + cand --no-style 'no-style' + cand --no-response-charset 'no-response-charset' + cand --no-response-mime 'no-response-mime' + cand --no-print 'no-print' + cand --no-headers 'no-headers' + cand --no-body 'no-body' + cand --no-meta 'no-meta' + cand --no-verbose 'no-verbose' + cand --no-debug 'no-debug' + cand --no-all 'no-all' + cand --no-history-print 'no-history-print' + cand --no-quiet 'no-quiet' + cand --no-stream 'no-stream' + cand --no-output 'no-output' + cand --no-download 'no-download' + cand --no-continue 'no-continue' + cand --no-session 'no-session' + cand --no-session-read-only 'no-session-read-only' + cand --no-auth-type 'no-auth-type' + cand --no-auth 'no-auth' + cand --no-bearer 'no-bearer' + cand --no-ignore-netrc 'no-ignore-netrc' + cand --no-offline 'no-offline' + cand --no-check-status 'no-check-status' + cand --no-follow 'no-follow' + cand --no-max-redirects 'no-max-redirects' + cand --no-timeout 'no-timeout' + cand --no-proxy 'no-proxy' + cand --no-verify 'no-verify' + cand --no-cert 'no-cert' + cand --no-cert-key 'no-cert-key' + cand --no-ssl 'no-ssl' + cand --no-native-tls 'no-native-tls' + cand --no-default-scheme 'no-default-scheme' + cand --no-https 'no-https' + cand --no-http-version 'no-http-version' + cand --no-resolve 'no-resolve' + cand --no-interface 'no-interface' + cand --no-ipv4 'no-ipv4' + cand --no-ipv6 'no-ipv6' + cand --no-ignore-stdin 'no-ignore-stdin' + cand --no-curl 'no-curl' + cand --no-curl-long 'no-curl-long' + cand --no-generate 'no-generate' + cand --no-help 'no-help' + cand -V 'Print version' + cand --version 'Print version' + } + ] + $completions[$command] +} diff --git a/completions/xh.fish b/completions/xh.fish index 882b707..2f876cf 100644 --- a/completions/xh.fish +++ b/completions/xh.fish @@ -23,6 +23,7 @@ complete -c xh -l default-scheme -d 'The default scheme to use if not specified complete -c xh -l http-version -d 'HTTP version to use' -r -f -a "{1.0\t'',1.1\t'',2\t'',2-prior-knowledge\t''}" complete -c xh -l resolve -d 'Override DNS resolution for specific domain to a custom IP' -r complete -c xh -l interface -d 'Bind to a network interface or local IP address' -r +complete -c xh -l generate -d 'Generate shell completions or man pages' -r -f -a "{complete-bash\t'',complete-elvish\t'',complete-fish\t'',complete-nushell\t'',complete-powershell\t'',complete-zsh\t'',man\t''}" complete -c xh -s j -l json -d '(default) Serialize data items from the command line as a JSON object' complete -c xh -s f -l form -d 'Serialize data items from the command line as form fields' complete -c xh -l multipart -d 'Like --form, but force a multipart/form-data request even without files' @@ -97,5 +98,6 @@ complete -c xh -l no-ipv6 complete -c xh -l no-ignore-stdin complete -c xh -l no-curl complete -c xh -l no-curl-long +complete -c xh -l no-generate complete -c xh -l no-help complete -c xh -s V -l version -d 'Print version' diff --git a/completions/xh.nu b/completions/xh.nu new file mode 100644 index 0000000..33e2767 --- /dev/null +++ b/completions/xh.nu @@ -0,0 +1,138 @@ +module completions { + + def "nu-complete xh pretty" [] { + [ "all" "colors" "format" "none" ] + } + + def "nu-complete xh style" [] { + [ "auto" "solarized" "monokai" "fruity" ] + } + + def "nu-complete xh auth_type" [] { + [ "basic" "bearer" "digest" ] + } + + def "nu-complete xh ssl" [] { + [ "auto" "tls1" "tls1.1" "tls1.2" "tls1.3" ] + } + + def "nu-complete xh http_version" [] { + [ "1.0" "1.1" "2" "2-prior-knowledge" ] + } + + def "nu-complete xh generate" [] { + [ "complete-bash" "complete-elvish" "complete-fish" "complete-nushell" "complete-powershell" "complete-zsh" "man" ] + } + + # xh is a friendly and fast tool for sending HTTP requests + export extern xh [ + --json(-j) # (default) Serialize data items from the command line as a JSON object + --form(-f) # Serialize data items from the command line as form fields + --multipart # Like --form, but force a multipart/form-data request even without files + --raw: string # Pass raw request data without extra processing + --pretty: string@"nu-complete xh pretty" # Controls output processing + --format-options: string # Set output formatting options + --style(-s): string@"nu-complete xh style" # Output coloring style + --response-charset: string # Override the response encoding for terminal display purposes + --response-mime: string # Override the response mime type for coloring and formatting for the terminal + --print(-p): string # String specifying what the output should contain + --headers(-h) # Print only the response headers. Shortcut for --print=h + --body(-b) # Print only the response body. Shortcut for --print=b + --meta(-m) # Print only the response metadata. Shortcut for --print=m + --verbose(-v) # Print the whole request as well as the response + --debug # Print full error stack traces and debug log messages + --all # Show any intermediary requests/responses while following redirects with --follow + --history-print(-P): string # The same as --print but applies only to intermediary requests/responses + --quiet(-q) # Do not print to stdout or stderr + --stream(-S) # Always stream the response body + --output(-o): string # Save output to FILE instead of stdout + --download(-d) # Download the body to a file instead of printing it + --continue(-c) # Resume an interrupted download. Requires --download and --output + --session: string # Create, or reuse and update a session + --session-read-only: string # Create or read a session without updating it form the request/response exchange + --auth-type(-A): string@"nu-complete xh auth_type" # Specify the auth mechanism + --auth(-a): string # Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer) + --bearer: string # Authenticate with a bearer token + --ignore-netrc # Do not use credentials from .netrc + --offline # Construct HTTP requests without sending them anywhere + --check-status # (default) Exit with an error status code if the server replies with an error + --follow(-F) # Do follow redirects + --max-redirects: string # Number of redirects to follow. Only respected if --follow is used + --timeout: string # Connection timeout of the request + --proxy: string # Use a proxy for a protocol. For example: --proxy https:http://proxy.host:8080 + --verify: string # If "no", skip SSL verification. If a file path, use it as a CA bundle + --cert: string # Use a client side certificate for SSL + --cert-key: string # A private key file to use with --cert + --ssl: string@"nu-complete xh ssl" # Force a particular TLS version + --native-tls # Use the system TLS library instead of rustls (if enabled at compile time) + --default-scheme: string # The default scheme to use if not specified in the URL + --https # Make HTTPS requests if not specified in the URL + --http-version: string@"nu-complete xh http_version" # HTTP version to use + --resolve: string # Override DNS resolution for specific domain to a custom IP + --interface: string # Bind to a network interface or local IP address + --ipv4(-4) # Resolve hostname to ipv4 addresses only + --ipv6(-6) # Resolve hostname to ipv6 addresses only + --ignore-stdin(-I) # Do not attempt to read stdin + --curl # Print a translation to a curl command + --curl-long # Use the long versions of curl's flags + --generate: string@"nu-complete xh generate" # Generate shell completions or man pages + --help # Print help + raw_method_or_url?: string # The request URL, preceded by an optional HTTP method + ...raw_rest_args: string # Optional key-value pairs to be included in the request. + --no-json + --no-form + --no-multipart + --no-raw + --no-pretty + --no-format-options + --no-style + --no-response-charset + --no-response-mime + --no-print + --no-headers + --no-body + --no-meta + --no-verbose + --no-debug + --no-all + --no-history-print + --no-quiet + --no-stream + --no-output + --no-download + --no-continue + --no-session + --no-session-read-only + --no-auth-type + --no-auth + --no-bearer + --no-ignore-netrc + --no-offline + --no-check-status + --no-follow + --no-max-redirects + --no-timeout + --no-proxy + --no-verify + --no-cert + --no-cert-key + --no-ssl + --no-native-tls + --no-default-scheme + --no-https + --no-http-version + --no-resolve + --no-interface + --no-ipv4 + --no-ipv6 + --no-ignore-stdin + --no-curl + --no-curl-long + --no-generate + --no-help + --version(-V) # Print version + ] + +} + +export use completions * diff --git a/doc/xh.1 b/doc/xh.1 index c9c5c30..e4f44c1 100644 --- a/doc/xh.1 +++ b/doc/xh.1 @@ -1,4 +1,4 @@ -.TH XH 1 2025-01-02 0.23.1 "User Commands" +.TH XH 1 2025-01-04 0.23.1 "User Commands" .SH NAME xh \- Friendly and fast tool for sending HTTP requests @@ -320,6 +320,11 @@ For translating the other way, try https://curl2httpie.online/. \fB\-\-curl\-long\fR Use the long versions of curl's flags. .TP 4 +\fB\-\-generate\fR=\fIKIND\fR +Generate shell completions or man pages. + +[possible values: complete\-bash, complete\-elvish, complete\-fish, complete\-nushell, complete\-powershell, complete\-zsh, man] +.TP 4 \fB\-\-help\fR Print help. .TP 4 diff --git a/src/cli.rs b/src/cli.rs index a6b37d5..8d227a1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -367,6 +367,27 @@ Example: --print=Hb" #[clap(long)] pub curl_long: bool, + /// Generate shell completions or man pages. + #[arg( + long, + value_name = "KIND", + hide_possible_values = true, + long_help = "\ +Generate shell completions or man pages. Possible values are: + + complete-bash + complete-elvish + complete-fish + complete-nushell + complete-powershell + complete-zsh + man + +Example: xh --generate=complete-bash > xh.bash", + conflicts_with = "raw_method_or_url" + )] + pub generate: Option, + /// Print help. #[clap(long, action = ArgAction::HelpShort)] pub help: Option, @@ -381,8 +402,8 @@ Example: --print=Hb" /// /// A leading colon works as shorthand for localhost. ":8000" is equivalent /// to "localhost:8000", and ":/path" is equivalent to "localhost/path". - #[clap(value_name = "[METHOD] URL")] - raw_method_or_url: String, + #[clap(value_name = "[METHOD] URL", required = true)] + raw_method_or_url: Option, /// Optional key-value pairs to be included in the request. /// @@ -480,21 +501,28 @@ impl Cli { let matches = app.try_get_matches_from_mut(iter)?; let mut cli = Self::from_arg_matches(&matches)?; - match cli.raw_method_or_url.as_str() { - "help" => { - // opt-out of clap's auto-generated possible values help for --pretty - // as we already list them in the long_help - app = app.mut_arg("pretty", |a| a.hide_possible_values(true)); + app.get_bin_name() + .and_then(|name| name.split('.').next()) + .unwrap_or("xh") + .clone_into(&mut cli.bin_name); - app.print_long_help().unwrap(); - safe_exit(); - } - "generate-completions" => return Err(generate_completions(app, cli.raw_rest_args)), - "generate-manpages" => return Err(generate_manpages(app, cli.raw_rest_args)), - _ => {} + if cli.generate.is_some() { + return Ok(cli); } + + let mut raw_method_or_url = cli.raw_method_or_url.clone().unwrap(); + + if raw_method_or_url == "help" { + // opt-out of clap's auto-generated possible values help for --pretty + // as we already list them in the long_help + app = app.mut_arg("pretty", |a| a.hide_possible_values(true)); + + app.print_long_help().unwrap(); + safe_exit(); + } + let mut rest_args = mem::take(&mut cli.raw_rest_args).into_iter(); - let raw_url = match parse_method(&cli.raw_method_or_url) { + let raw_url = match parse_method(&raw_method_or_url) { Some(method) => { cli.method = Some(method); rest_args.next().ok_or_else(|| { @@ -506,7 +534,7 @@ impl Cli { } None => { cli.method = None; - mem::take(&mut cli.raw_method_or_url) + mem::take(&mut raw_method_or_url) } }; for request_item in rest_args { @@ -517,11 +545,6 @@ impl Cli { ); } - app.get_bin_name() - .and_then(|name| name.split('.').next()) - .unwrap_or("xh") - .clone_into(&mut cli.bin_name); - if matches!(cli.bin_name.as_str(), "https" | "xhs" | "xhttps") { cli.https = true; } @@ -625,9 +648,12 @@ impl Cli { }) .collect(); - app.args(negations) + let mut app = app.args(negations) .after_help(format!("Each option can be reset with a --no-OPTION argument.\n\nRun \"{} help\" for more complete documentation.", env!("CARGO_PKG_NAME"))) - .after_long_help("Each option can be reset with a --no-OPTION argument.") + .after_long_help("Each option can be reset with a --no-OPTION argument."); + + app.build(); + app } pub fn logger_config(&self) -> env_logger::Builder { @@ -744,209 +770,6 @@ fn construct_url( Ok(url) } -#[cfg(feature = "man-completion-gen")] -// This signature is a little weird: we either return an error or don't return at all -fn generate_completions(mut app: clap::Command, rest_args: Vec) -> clap::error::Error { - let bin_name = app.get_bin_name().unwrap().to_string(); - if rest_args.len() != 1 { - return app.error( - clap::error::ErrorKind::WrongNumberOfValues, - "Usage: xh generate-completions ", - ); - } - - for &shell in clap_complete::Shell::value_variants() { - // Elvish complains about multiple deprecations and these don't seem to work - if shell != clap_complete::Shell::Elvish { - clap_complete::generate_to(shell, &mut app, &bin_name, &rest_args[0]).unwrap(); - } - } - safe_exit(); -} - -#[cfg(feature = "man-completion-gen")] -fn generate_manpages(mut app: clap::Command, rest_args: Vec) -> clap::error::Error { - use roff::{bold, italic, roman, Roff}; - use time::OffsetDateTime as DateTime; - - if rest_args.len() != 1 { - return app.error( - clap::error::ErrorKind::WrongNumberOfValues, - "Usage: xh generate-manpages ", - ); - } - - let items: Vec<_> = app.get_arguments().filter(|i| !i.is_hide_set()).collect(); - - let mut request_items_roff = Roff::new(); - let request_items = items - .iter() - .find(|opt| opt.get_id() == "raw_rest_args") - .unwrap(); - let request_items_help = request_items - .get_long_help() - .or_else(|| request_items.get_help()) - .expect("request_items is missing help") - .to_string(); - - // replace the indents in request_item help with proper roff controls - // For example: - // - // ``` - // normal help normal help - // normal help normal help - // - // request-item-1 - // help help - // - // request-item-2 - // help help - // - // normal help normal help - // ``` - // - // Should look like this with roff controls - // - // ``` - // normal help normal help - // normal help normal help - // .RS 12 - // .TP - // request-item-1 - // help help - // .TP - // request-item-2 - // help help - // .RE - // - // .RS - // normal help normal help - // .RE - // ``` - let lines: Vec<&str> = request_items_help.lines().collect(); - let mut rs = false; - for i in 0..lines.len() { - if lines[i].is_empty() { - let prev = lines[i - 1].chars().take_while(|&x| x == ' ').count(); - let next = lines[i + 1].chars().take_while(|&x| x == ' ').count(); - if prev != next && next > 0 { - if !rs { - request_items_roff.control("RS", ["8"]); - rs = true; - } - request_items_roff.control("TP", ["4"]); - } else if prev != next && next == 0 { - request_items_roff.control("RE", []); - request_items_roff.text(vec![roman("")]); - request_items_roff.control("RS", []); - } else { - request_items_roff.text(vec![roman(lines[i])]); - } - } else { - request_items_roff.text(vec![roman(lines[i].trim())]); - } - } - request_items_roff.control("RE", []); - - let mut options_roff = Roff::new(); - let non_pos_items = items - .iter() - .filter(|a| !a.is_positional()) - .collect::>(); - - for opt in non_pos_items { - let mut header = vec![]; - if let Some(short) = opt.get_short() { - header.push(bold(format!("-{}", short))); - } - if let Some(long) = opt.get_long() { - if !header.is_empty() { - header.push(roman(", ")); - } - header.push(bold(format!("--{}", long))); - } - if opt.get_action().takes_values() { - let value_name = &opt.get_value_names().unwrap(); - if opt.get_long().is_some() { - header.push(roman("=")); - } else { - header.push(roman(" ")); - } - - if opt.get_id() == "auth" { - header.push(italic("USER")); - header.push(roman("[")); - header.push(italic(":PASS")); - header.push(roman("] | ")); - header.push(italic("TOKEN")); - } else { - header.push(italic(value_name.join(" "))); - } - } - let mut body = vec![]; - - let mut help = opt - .get_long_help() - .or_else(|| opt.get_help()) - .expect("option is missing help") - .to_string(); - if !help.ends_with('.') { - help.push('.') - } - body.push(roman(help)); - - let possible_values = opt.get_possible_values(); - if !possible_values.is_empty() - && !opt.is_hide_possible_values_set() - && opt.get_id() != "pretty" - { - let possible_values_text = format!( - "\n\n[possible values: {}]", - possible_values - .iter() - .map(|v| v.get_name()) - .collect::>() - .join(", ") - ); - body.push(roman(possible_values_text)); - } - options_roff.control("TP", ["4"]); - options_roff.text(header); - options_roff.text(body); - } - - let mut manpage = fs::read_to_string(format!("{}/man-template.roff", rest_args[0])).unwrap(); - - let current_date = { - let (year, month, day) = DateTime::now_utc().date().to_calendar_date(); - format!("{}-{:02}-{:02}", year, u8::from(month), day) - }; - - manpage = manpage.replace("{{date}}", ¤t_date); - manpage = manpage.replace("{{version}}", app.get_version().unwrap()); - manpage = manpage.replace("{{request_items}}", request_items_roff.to_roff().trim()); - manpage = manpage.replace("{{options}}", options_roff.to_roff().trim()); - - fs::write(format!("{}/xh.1", rest_args[0]), manpage).unwrap(); - safe_exit(); -} - -#[cfg(not(feature = "man-completion-gen"))] -fn generate_completions(mut _app: clap::Command, _rest_args: Vec) -> clap::error::Error { - clap::Error::raw( - clap::error::ErrorKind::InvalidSubcommand, - "generate-completions requires enabling man-completion-gen feature\n", - ) -} - -#[cfg(not(feature = "man-completion-gen"))] -fn generate_manpages(mut _app: clap::Command, _rest_args: Vec) -> clap::error::Error { - clap::Error::raw( - clap::error::ErrorKind::InvalidSubcommand, - "generate-manpages requires enabling man-completion-gen feature\n", - ) -} - #[derive(Default, ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] pub enum AuthType { #[default] @@ -1353,6 +1176,17 @@ pub enum HttpVersion { Http2PriorKnowledge, } +#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] +pub enum Generate { + CompleteBash, + CompleteElvish, + CompleteFish, + CompleteNushell, + CompletePowershell, + CompleteZsh, + Man, +} + /// HTTPie uses Python's str.decode(). That one's very accepting of different spellings. /// encoding_rs is not. /// @@ -1870,4 +1704,16 @@ mod tests { assert!(Resolve::from_str("example.com:::1").is_ok()); assert!(Resolve::from_str("example.com:[::1]").is_ok()); } + + #[test] + fn generate() { + let cli = parse(["--generate", "complete-bash"]).unwrap(); + assert_eq!(cli.generate, Some(Generate::CompleteBash)); + assert_eq!(cli.raw_method_or_url, None); + } + + #[test] + fn generate_with_url() { + parse(["--generate", "complete-zsh", "example.org"]).unwrap_err(); + } } diff --git a/src/generation.rs b/src/generation.rs new file mode 100644 index 0000000..317306b --- /dev/null +++ b/src/generation.rs @@ -0,0 +1,195 @@ +use std::io; + +use clap_complete::Shell; +use clap_complete_nushell::Nushell; + +use crate::cli::Cli; +use crate::cli::Generate; + +const MAN_TEMPLATE: &str = include_str!("../doc/man-template.roff"); + +pub fn generate(bin_name: &str, generate: Generate) { + let mut app = Cli::into_app(); + + match generate { + Generate::CompleteBash => { + clap_complete::generate(Shell::Bash, &mut app, bin_name, &mut io::stdout()); + } + Generate::CompleteElvish => { + clap_complete::generate(Shell::Elvish, &mut app, bin_name, &mut io::stdout()); + } + Generate::CompleteFish => { + clap_complete::generate(Shell::Fish, &mut app, bin_name, &mut io::stdout()); + } + Generate::CompleteNushell => { + clap_complete::generate(Nushell, &mut app, bin_name, &mut io::stdout()); + } + Generate::CompletePowershell => { + clap_complete::generate(Shell::PowerShell, &mut app, bin_name, &mut io::stdout()); + } + Generate::CompleteZsh => { + clap_complete::generate(Shell::Zsh, &mut app, bin_name, &mut io::stdout()); + } + Generate::Man => { + generate_manpages(&mut app); + } + } +} + +fn generate_manpages(app: &mut clap::Command) { + use roff::{bold, italic, roman, Roff}; + use time::OffsetDateTime as DateTime; + + let items: Vec<_> = app.get_arguments().filter(|i| !i.is_hide_set()).collect(); + + let mut request_items_roff = Roff::new(); + let request_items = items + .iter() + .find(|opt| opt.get_id() == "raw_rest_args") + .unwrap(); + let request_items_help = request_items + .get_long_help() + .or_else(|| request_items.get_help()) + .expect("request_items is missing help") + .to_string(); + + // replace the indents in request_item help with proper roff controls + // For example: + // + // ``` + // normal help normal help + // normal help normal help + // + // request-item-1 + // help help + // + // request-item-2 + // help help + // + // normal help normal help + // ``` + // + // Should look like this with roff controls + // + // ``` + // normal help normal help + // normal help normal help + // .RS 12 + // .TP + // request-item-1 + // help help + // .TP + // request-item-2 + // help help + // .RE + // + // .RS + // normal help normal help + // .RE + // ``` + let lines: Vec<&str> = request_items_help.lines().collect(); + let mut rs = false; + for i in 0..lines.len() { + if lines[i].is_empty() { + let prev = lines[i - 1].chars().take_while(|&x| x == ' ').count(); + let next = lines[i + 1].chars().take_while(|&x| x == ' ').count(); + if prev != next && next > 0 { + if !rs { + request_items_roff.control("RS", ["8"]); + rs = true; + } + request_items_roff.control("TP", ["4"]); + } else if prev != next && next == 0 { + request_items_roff.control("RE", []); + request_items_roff.text(vec![roman("")]); + request_items_roff.control("RS", []); + } else { + request_items_roff.text(vec![roman(lines[i])]); + } + } else { + request_items_roff.text(vec![roman(lines[i].trim())]); + } + } + request_items_roff.control("RE", []); + + let mut options_roff = Roff::new(); + let non_pos_items = items + .iter() + .filter(|a| !a.is_positional()) + .collect::>(); + + for opt in non_pos_items { + let mut header = vec![]; + if let Some(short) = opt.get_short() { + header.push(bold(format!("-{}", short))); + } + if let Some(long) = opt.get_long() { + if !header.is_empty() { + header.push(roman(", ")); + } + header.push(bold(format!("--{}", long))); + } + if opt.get_action().takes_values() { + let value_name = &opt.get_value_names().unwrap(); + if opt.get_long().is_some() { + header.push(roman("=")); + } else { + header.push(roman(" ")); + } + + if opt.get_id() == "auth" { + header.push(italic("USER")); + header.push(roman("[")); + header.push(italic(":PASS")); + header.push(roman("] | ")); + header.push(italic("TOKEN")); + } else { + header.push(italic(value_name.join(" "))); + } + } + let mut body = vec![]; + + let mut help = opt + .get_long_help() + .or_else(|| opt.get_help()) + .expect("option is missing help") + .to_string(); + if !help.ends_with('.') { + help.push('.') + } + body.push(roman(help)); + + let possible_values = opt.get_possible_values(); + if !possible_values.is_empty() + && !opt.is_hide_possible_values_set() + && opt.get_id() != "pretty" + { + let possible_values_text = format!( + "\n\n[possible values: {}]", + possible_values + .iter() + .map(|v| v.get_name()) + .collect::>() + .join(", ") + ); + body.push(roman(possible_values_text)); + } + options_roff.control("TP", ["4"]); + options_roff.text(header); + options_roff.text(body); + } + + let mut manpage = MAN_TEMPLATE.to_string(); + + let current_date = { + let (year, month, day) = DateTime::now_utc().date().to_calendar_date(); + format!("{}-{:02}-{:02}", year, u8::from(month), day) + }; + + manpage = manpage.replace("{{date}}", ¤t_date); + manpage = manpage.replace("{{version}}", app.get_version().unwrap()); + manpage = manpage.replace("{{request_items}}", request_items_roff.to_roff().trim()); + manpage = manpage.replace("{{options}}", options_roff.to_roff().trim()); + + print!("{manpage}"); +} diff --git a/src/main.rs b/src/main.rs index 006a5ee..c8f95cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod cli; mod decoder; mod download; mod formatting; +mod generation; mod middleware; mod nested_json; mod netrc; @@ -101,6 +102,11 @@ fn main() { } fn run(args: Cli) -> Result { + if let Some(generate) = args.generate { + generation::generate(&args.bin_name, generate); + return Ok(0); + } + if args.curl { to_curl::print_curl_translation(args)?; return Ok(0);