From b700d181e17cedf7085fef38f926d092af1099d8 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Fri, 13 Mar 2026 09:41:01 -0500 Subject: [PATCH 1/5] feat(hyperlink): Add skeleton --- Cargo.lock | 7 + crates/anstyle-hyperlink/CHANGELOG.md | 11 ++ crates/anstyle-hyperlink/Cargo.toml | 37 +++++ crates/anstyle-hyperlink/LICENSE-APACHE | 202 ++++++++++++++++++++++++ crates/anstyle-hyperlink/LICENSE-MIT | 19 +++ crates/anstyle-hyperlink/README.md | 26 +++ crates/anstyle-hyperlink/src/lib.rs | 13 ++ 7 files changed, 315 insertions(+) create mode 100644 crates/anstyle-hyperlink/CHANGELOG.md create mode 100644 crates/anstyle-hyperlink/Cargo.toml create mode 100644 crates/anstyle-hyperlink/LICENSE-APACHE create mode 100644 crates/anstyle-hyperlink/LICENSE-MIT create mode 100644 crates/anstyle-hyperlink/README.md create mode 100644 crates/anstyle-hyperlink/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 8d867be1..c4ddd3ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,13 @@ dependencies = [ "anstyle 1.0.13", ] +[[package]] +name = "anstyle-hyperlink" +version = "1.0.0" +dependencies = [ + "snapbox", +] + [[package]] name = "anstyle-lossy" version = "1.1.4" diff --git a/crates/anstyle-hyperlink/CHANGELOG.md b/crates/anstyle-hyperlink/CHANGELOG.md new file mode 100644 index 00000000..3d5ab46e --- /dev/null +++ b/crates/anstyle-hyperlink/CHANGELOG.md @@ -0,0 +1,11 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/) +and this project adheres to [Semantic Versioning](https://semver.org/). + + +## [Unreleased] - ReleaseDate + + +[Unreleased]: https://github.com/rust-cli/anstyle/compare/22205da0833d0c597801a0ee73b86dd89bdb94c6...HEAD diff --git a/crates/anstyle-hyperlink/Cargo.toml b/crates/anstyle-hyperlink/Cargo.toml new file mode 100644 index 00000000..e0b94810 --- /dev/null +++ b/crates/anstyle-hyperlink/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "anstyle-hyperlink" +version = "1.0.0" +description = "ANSI escape code hyperlinks" +categories = ["command-line-interface"] +keywords = ["ansi", "terminal", "color", "no_std"] +repository.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true +include.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--generate-link-to-definition"] + +[package.metadata.release] +tag-prefix = "" +pre-release-replacements = [ + {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, + {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, + {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, + {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, + {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/rust-cli/anstyle/compare/{{tag_name}}...HEAD", exactly=1}, +] + +[features] +default = ["std"] +std = [] + +[dependencies] + +[dev-dependencies] +snapbox = "0.6.23" + +[lints] +workspace = true diff --git a/crates/anstyle-hyperlink/LICENSE-APACHE b/crates/anstyle-hyperlink/LICENSE-APACHE new file mode 100644 index 00000000..8f71f43f --- /dev/null +++ b/crates/anstyle-hyperlink/LICENSE-APACHE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/crates/anstyle-hyperlink/LICENSE-MIT b/crates/anstyle-hyperlink/LICENSE-MIT new file mode 100644 index 00000000..a2d01088 --- /dev/null +++ b/crates/anstyle-hyperlink/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/anstyle-hyperlink/README.md b/crates/anstyle-hyperlink/README.md new file mode 100644 index 00000000..8717d818 --- /dev/null +++ b/crates/anstyle-hyperlink/README.md @@ -0,0 +1,26 @@ +# anstyle-hyperlink + +> ANSI escape code hyperlinks + +[![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] +![License](https://img.shields.io/crates/l/anstyle-hyperlink.svg) +[![Crates Status](https://img.shields.io/crates/v/anstyle-hyperlink.svg)](https://crates.io/crates/anstyle-hyperlink) + +## License + +Licensed under either of + +* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) +* MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +## [Contribute](../../CONTRIBUTING.md) + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual-licensed as above, without any additional terms or +conditions. + +[Crates.io]: https://crates.io/crates/anstyle-hyperlink +[Documentation]: https://docs.rs/anstyle-hyperlink diff --git a/crates/anstyle-hyperlink/src/lib.rs b/crates/anstyle-hyperlink/src/lib.rs new file mode 100644 index 00000000..d5e8db9f --- /dev/null +++ b/crates/anstyle-hyperlink/src/lib.rs @@ -0,0 +1,13 @@ +//! ANSI escape code hyperlink + +#![cfg_attr(not(feature = "std"), no_std)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![warn(missing_docs)] +#![warn(clippy::std_instead_of_core)] +#![warn(clippy::std_instead_of_alloc)] +#![warn(clippy::print_stderr)] +#![warn(clippy::print_stdout)] + +#[doc = include_str!("../README.md")] +#[cfg(doctest)] +pub struct ReadmeDoctests; From e5196436fdbb1a7f3c1d1af808da94c45ffe1fca Mon Sep 17 00:00:00 2001 From: Ed Page Date: Fri, 13 Mar 2026 09:46:28 -0500 Subject: [PATCH 2/5] feat(hyperlink): Add Hyperlink type --- crates/anstyle-hyperlink/src/hyperlink.rs | 44 +++++++++++++++++++++++ crates/anstyle-hyperlink/src/lib.rs | 4 +++ 2 files changed, 48 insertions(+) create mode 100644 crates/anstyle-hyperlink/src/hyperlink.rs diff --git a/crates/anstyle-hyperlink/src/hyperlink.rs b/crates/anstyle-hyperlink/src/hyperlink.rs new file mode 100644 index 00000000..0c6f7140 --- /dev/null +++ b/crates/anstyle-hyperlink/src/hyperlink.rs @@ -0,0 +1,44 @@ +/// Hyperlink formatter +/// +/// # Example +/// +/// ``` +/// let link = anstyle_hyperlink::Hyperlink::with_url("https://docs.rs/anstyle/latest/anstyle/"); +/// format!("Go to {link}anstyle's documentation{link:#}!"); +/// ``` +pub struct Hyperlink { + url: Option, +} + +impl Hyperlink { + /// Directly create a hyperlink for a URL + /// + /// # Example + /// + /// ``` + /// let link = anstyle_hyperlink::Hyperlink::with_url("https://docs.rs/anstyle/latest/anstyle/"); + /// format!("Go to {link}anstyle's documentation{link:#}!"); + /// ``` + pub fn with_url(url: D) -> Self { + Self { url: Some(url) } + } +} + +impl Default for Hyperlink { + fn default() -> Self { + Self { url: None } + } +} + +impl core::fmt::Display for Hyperlink { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let Some(url) = self.url.as_ref() else { + return Ok(()); + }; + if f.alternate() { + write!(f, "\x1B]8;;\x1B\\") + } else { + write!(f, "\x1B]8;;{url}\x1B\\") + } + } +} diff --git a/crates/anstyle-hyperlink/src/lib.rs b/crates/anstyle-hyperlink/src/lib.rs index d5e8db9f..5d6cebfc 100644 --- a/crates/anstyle-hyperlink/src/lib.rs +++ b/crates/anstyle-hyperlink/src/lib.rs @@ -8,6 +8,10 @@ #![warn(clippy::print_stderr)] #![warn(clippy::print_stdout)] +mod hyperlink; + +pub use hyperlink::Hyperlink; + #[doc = include_str!("../README.md")] #[cfg(doctest)] pub struct ReadmeDoctests; From 33f781a21277441ef957f9bfd71f1be208147e47 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Fri, 13 Mar 2026 09:47:09 -0500 Subject: [PATCH 3/5] docs(hyperlink): Link out to supports-hyperlinks --- crates/anstyle-hyperlink/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/anstyle-hyperlink/src/lib.rs b/crates/anstyle-hyperlink/src/lib.rs index 5d6cebfc..c51ba274 100644 --- a/crates/anstyle-hyperlink/src/lib.rs +++ b/crates/anstyle-hyperlink/src/lib.rs @@ -1,4 +1,6 @@ //! ANSI escape code hyperlink +//! +//! To detect support, see [supports-hyperlinks](https://crates.io/crates/supports-hyperlinks) #![cfg_attr(not(feature = "std"), no_std)] #![cfg_attr(docsrs, feature(doc_cfg))] From a8be67634d6e0039cfd98bfebbc2bf97fbb9cc18 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Fri, 13 Mar 2026 10:23:45 -0500 Subject: [PATCH 4/5] feat(hyperlink): Help people create file URLs --- Cargo.lock | 14 +++- crates/anstyle-hyperlink/Cargo.toml | 3 + crates/anstyle-hyperlink/src/file.rs | 79 +++++++++++++++++++++++ crates/anstyle-hyperlink/src/hostname.rs | 77 ++++++++++++++++++++++ crates/anstyle-hyperlink/src/hyperlink.rs | 18 ++++++ crates/anstyle-hyperlink/src/lib.rs | 12 ++++ 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 crates/anstyle-hyperlink/src/file.rs create mode 100644 crates/anstyle-hyperlink/src/hostname.rs diff --git a/Cargo.lock b/Cargo.lock index c4ddd3ae..b33944a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,8 @@ dependencies = [ name = "anstyle-hyperlink" version = "1.0.0" dependencies = [ + "libc", + "percent-encoding", "snapbox", ] @@ -568,9 +570,9 @@ checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "linux-raw-sys" @@ -665,6 +667,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1132,7 +1140,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/crates/anstyle-hyperlink/Cargo.toml b/crates/anstyle-hyperlink/Cargo.toml index e0b94810..150e4308 100644 --- a/crates/anstyle-hyperlink/Cargo.toml +++ b/crates/anstyle-hyperlink/Cargo.toml @@ -27,8 +27,11 @@ pre-release-replacements = [ [features] default = ["std"] std = [] +file = ["std", "dep:percent-encoding", "dep:libc"] [dependencies] +libc = { version = "0.2.183", optional = true } +percent-encoding = { version = "2.3.2", optional = true } [dev-dependencies] snapbox = "0.6.23" diff --git a/crates/anstyle-hyperlink/src/file.rs b/crates/anstyle-hyperlink/src/file.rs new file mode 100644 index 00000000..660a3c67 --- /dev/null +++ b/crates/anstyle-hyperlink/src/file.rs @@ -0,0 +1,79 @@ +/// Create a URL from a given path +pub fn path_to_url(path: &std::path::Path) -> Option { + // Do a best-effort for getting the host in the URL + let hostname = if cfg!(windows) { + // Not supported correctly on windows + None + } else { + crate::hostname().ok().and_then(|os| os.into_string().ok()) + }; + if path.is_dir() { + dir_to_url(hostname.as_deref(), path) + } else { + file_to_url(hostname.as_deref(), path) + } +} + +/// Create a URL from a given hostname and file path +/// +/// For hyperlink escape codes, the hostname is used to avoid issues with opening a link scoped to +/// the computer you've SSH'ed into +pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option { + if !path.is_absolute() { + return None; + } + + let mut url = "file://".to_owned(); + if let Some(hostname) = hostname { + url.push_str(hostname); + } + + // skip the root component + let mut is_path_empty = true; + for component in path.components().skip(1) { + is_path_empty = false; + url.push_str(URL_PATH_SEP); + let component = component.as_os_str().to_str()?; + url.extend(percent_encoding::percent_encode( + component.as_bytes(), + SPECIAL_PATH_SEGMENT, + )); + } + if is_path_empty { + // An URL's path must not be empty + url.push_str(URL_PATH_SEP); + } + + Some(url) +} + +/// Create a URL from a given hostname and directory path +/// +/// For hyperlink escape codes, the hostname is used to avoid issues with opening a link scoped to +/// the computer you've SSH'ed into +pub fn dir_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option { + let mut url = file_to_url(hostname, path)?; + if !url.ends_with(URL_PATH_SEP) { + url.push_str(URL_PATH_SEP); + } + Some(url) +} + +const URL_PATH_SEP: &str = "/"; + +/// +const FRAGMENT: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS + .add(b' ') + .add(b'"') + .add(b'<') + .add(b'>') + .add(b'`'); + +/// +const PATH: &percent_encoding::AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}'); + +const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%'); + +// The backslash (\) character is treated as a path separator in special URLs +// so it needs to be additionally escaped in that case. +const SPECIAL_PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH_SEGMENT.add(b'\\'); diff --git a/crates/anstyle-hyperlink/src/hostname.rs b/crates/anstyle-hyperlink/src/hostname.rs new file mode 100644 index 00000000..a619ebbc --- /dev/null +++ b/crates/anstyle-hyperlink/src/hostname.rs @@ -0,0 +1,77 @@ +// Copied from https://github.com/BurntSushi/ripgrep/blob/7099e174acbcbd940f57e4ab4913fee4040c826e/crates/cli/src/hostname.rs + +use std::{ffi::OsString, io}; + +/// Returns the hostname of the current system. +/// +/// It is unusual, although technically possible, for this routine to return +/// an error. It is difficult to list out the error conditions, but one such +/// possibility is platform support. +/// +/// # Platform specific behavior +/// +/// On Unix, this returns the result of the `gethostname` function from the +/// `libc` linked into the program. +pub fn hostname() -> io::Result { + #[cfg(unix)] + { + gethostname() + } + #[cfg(not(unix))] + { + Err(io::Error::new( + io::ErrorKind::Other, + "hostname could not be found on unsupported platform", + )) + } +} + +#[cfg(unix)] +fn gethostname() -> io::Result { + use std::os::unix::ffi::OsStringExt; + + // SAFETY: There don't appear to be any safety requirements for calling + // sysconf. + let limit = unsafe { libc::sysconf(libc::_SC_HOST_NAME_MAX) }; + if limit == -1 { + // It is in theory possible for sysconf to return -1 for a limit but + // *not* set errno, in which case, io::Error::last_os_error is + // indeterminate. But untangling that is super annoying because std + // doesn't expose any unix-specific APIs for inspecting the errno. (We + // could do it ourselves, but it just doesn't seem worth doing?) + return Err(io::Error::last_os_error()); + } + let Ok(maxlen) = usize::try_from(limit) else { + let msg = format!("host name max limit ({limit}) overflowed usize"); + return Err(io::Error::new(io::ErrorKind::Other, msg)); + }; + // maxlen here includes the NUL terminator. + let mut buf = vec![0; maxlen]; + // SAFETY: The pointer we give is valid as it is derived directly from a + // Vec. Similarly, `maxlen` is the length of our Vec, and is thus valid + // to write to. + let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast::(), maxlen) }; + if rc == -1 { + return Err(io::Error::last_os_error()); + } + // POSIX says that if the hostname is bigger than `maxlen`, then it may + // write a truncate name back that is not necessarily NUL terminated (wtf, + // lol). So if we can't find a NUL terminator, then just give up. + let Some(zeropos) = buf.iter().position(|&b| b == 0) else { + let msg = "could not find NUL terminator in hostname"; + return Err(io::Error::new(io::ErrorKind::Other, msg)); + }; + buf.truncate(zeropos); + buf.shrink_to_fit(); + Ok(OsString::from_vec(buf)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn print_hostname() { + println!("{:?}", hostname()); + } +} diff --git a/crates/anstyle-hyperlink/src/hyperlink.rs b/crates/anstyle-hyperlink/src/hyperlink.rs index 0c6f7140..3051b1d8 100644 --- a/crates/anstyle-hyperlink/src/hyperlink.rs +++ b/crates/anstyle-hyperlink/src/hyperlink.rs @@ -24,6 +24,24 @@ impl Hyperlink { } } +#[cfg(feature = "std")] +impl Hyperlink { + /// Create a hyperlink for a path + /// + /// # Example + /// + /// ``` + /// let path = std::env::current_dir().unwrap(); + /// let link = anstyle_hyperlink::Hyperlink::with_path(&path); + /// format!("Go to {link}CWD{link:#}!"); + /// ``` + #[cfg(feature = "file")] + pub fn with_path(path: &std::path::Path) -> Self { + let url = crate::path_to_url(path); + Self { url } + } +} + impl Default for Hyperlink { fn default() -> Self { Self { url: None } diff --git a/crates/anstyle-hyperlink/src/lib.rs b/crates/anstyle-hyperlink/src/lib.rs index c51ba274..6bb2ae6e 100644 --- a/crates/anstyle-hyperlink/src/lib.rs +++ b/crates/anstyle-hyperlink/src/lib.rs @@ -10,8 +10,20 @@ #![warn(clippy::print_stderr)] #![warn(clippy::print_stdout)] +#[cfg(feature = "file")] +mod file; +#[cfg(feature = "file")] +mod hostname; mod hyperlink; +#[cfg(feature = "file")] +pub use file::dir_to_url; +#[cfg(feature = "file")] +pub use file::file_to_url; +#[cfg(feature = "file")] +pub use file::path_to_url; +#[cfg(feature = "file")] +pub use hostname::hostname; pub use hyperlink::Hyperlink; #[doc = include_str!("../README.md")] From 0c296241e361a03b6ad7a88c9fec9d13a6c701ab Mon Sep 17 00:00:00 2001 From: Ed Page Date: Fri, 13 Mar 2026 10:26:18 -0500 Subject: [PATCH 5/5] docs: Link out to specifics --- crates/anstyle-hyperlink/Cargo.toml | 2 +- crates/anstyle-hyperlink/README.md | 2 +- crates/anstyle-hyperlink/src/file.rs | 2 ++ crates/anstyle-hyperlink/src/lib.rs | 5 ++++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/anstyle-hyperlink/Cargo.toml b/crates/anstyle-hyperlink/Cargo.toml index 150e4308..764131e3 100644 --- a/crates/anstyle-hyperlink/Cargo.toml +++ b/crates/anstyle-hyperlink/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "anstyle-hyperlink" version = "1.0.0" -description = "ANSI escape code hyperlinks" +description = "ANSI escape code hyperlinks (OSC 8)" categories = ["command-line-interface"] keywords = ["ansi", "terminal", "color", "no_std"] repository.workspace = true diff --git a/crates/anstyle-hyperlink/README.md b/crates/anstyle-hyperlink/README.md index 8717d818..3907f2c4 100644 --- a/crates/anstyle-hyperlink/README.md +++ b/crates/anstyle-hyperlink/README.md @@ -1,6 +1,6 @@ # anstyle-hyperlink -> ANSI escape code hyperlinks +> ANSI escape code hyperlinks (OSC 8) [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] ![License](https://img.shields.io/crates/l/anstyle-hyperlink.svg) diff --git a/crates/anstyle-hyperlink/src/file.rs b/crates/anstyle-hyperlink/src/file.rs index 660a3c67..624eafee 100644 --- a/crates/anstyle-hyperlink/src/file.rs +++ b/crates/anstyle-hyperlink/src/file.rs @@ -18,6 +18,7 @@ pub fn path_to_url(path: &std::path::Path) -> Option { /// /// For hyperlink escape codes, the hostname is used to avoid issues with opening a link scoped to /// the computer you've SSH'ed into +/// ([reference](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#file-uris-and-the-hostname)) pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option { if !path.is_absolute() { return None; @@ -51,6 +52,7 @@ pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option, path: &std::path::Path) -> Option { let mut url = file_to_url(hostname, path)?; if !url.ends_with(URL_PATH_SEP) { diff --git a/crates/anstyle-hyperlink/src/lib.rs b/crates/anstyle-hyperlink/src/lib.rs index 6bb2ae6e..2147fe09 100644 --- a/crates/anstyle-hyperlink/src/lib.rs +++ b/crates/anstyle-hyperlink/src/lib.rs @@ -1,4 +1,7 @@ -//! ANSI escape code hyperlink +//! ANSI escape code hyperlink (OSC 8) +//! +//! For details on the protocol, see +//! [Hyperlinks (a.k.a. HTML-like anchors) in terminal emulators](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) //! //! To detect support, see [supports-hyperlinks](https://crates.io/crates/supports-hyperlinks)