osc8 0.1.0

parse or generate terminal hyperlinks
Documentation
#![no_std]
#![cfg_attr(feature = "nightly", feature(error_in_core))]
//! parse or generate terminal hyperlinks
//!
//! use [the `supports-hyperlinks` crate][1] to detect
//! if a terminal supports hyperlinks.
//!
//! more info about the OSC8 escape code:
//! * [orignal specification][2]
//! * [OSC8 Adoption][3]
//!
//! [1]: https://docs.rs/supports-hyperlinks
//! [2]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
//! [3]: https://github.com/Alhadis/OSC8-Adoption
use core::ops::Range;
use core::fmt;

#[cfg(feature = "nightly")]
use core::error::Error;
#[cfg(feature = "std")]
extern crate std;
#[cfg(all(feature = "std", not(feature = "nightly")))]
use std::error::Error;
#[cfg(feature = "read")]
pub mod read;
//#[cfg(feature = "read")]
//pub use crate::read::*;


const OSC8: &str = "\x1b]8";

/// string terminator
const ST: &str = "\x1b\\";

/// represents a terminal hyperlink escape code.
///
/// a Hyperlink with an empty url  means "end link",
/// and any text that follows it will not be
/// formatted as a hyperlink.
///
/// note that this only represents a singular escape code,
/// it does not represent a span of hyperlined text.
/// in html terms, this is only the tag,
/// not the whole element.
#[derive(Default, Debug, PartialEq, Clone)]
pub struct Hyperlink<'a>{
	// maybe this should use u8 to support non-utf encodings?
	url: &'a str,
	id: Option<&'a str>,
}

impl fmt::Display for Hyperlink<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		let url = self.url;
		if f.alternate() {
			// based off of the cargo internal hyperlink behavior.
			// if the alternate flag is specified, end the hyperlink.
			write!(f, "{OSC8};;{ST}")
		} else if let Some(id) = self.id {
			write!(f, "{OSC8};id={id};{url}{ST}")
		} else {
			write!(f, "{OSC8};;{url}{ST}")
		}
	}
}

/// represents an error encountered when parsing.
///
/// implements `Error` if either of the `std` or `nightly` features are enabled.
#[derive(Debug, PartialEq)]
pub enum ParseError{
	/// reached end of input with no string terminator.
	///
	/// when recieved from Hyperlink::parse,
	/// this is usually a sign you need to pass a larger buffer.
	Partial,
	/// missing semicolon at start of hyperlink.
	MissingSemi,
	/// missing equal sign in paramater list.
	MissingEq,
}

#[cfg(any(feature = "std", feature = "nightly"))]
impl Error for ParseError {}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		use ParseError::*;
		f.write_str(match self {
			Partial => "incomplete OSC 8 hyperlink",
			MissingSemi => "malformed OSC 8 hyperlink: missing semicolon",
			MissingEq =>
				"malformed OSC 8 hyperlink: missing equals sign in param",			
		})
    }
}

impl<'a> Hyperlink<'a> {
	/// control sequence that ends a span of hyperlinked text.
	///
	/// ```rust
	/// use osc8::Hyperlink;
	///
	/// fn main() {
	///   println!("{}this is a link{}",
	///     Hyperlink::new("https://example.com/"),
	///     Hyperlink::END);
	/// }
	/// ```
	///
	/// note that this is the same as Hyperlink::default(),
	/// and printing this is the same as printing any hyperlink with
	/// the alternate flag set (`#`).
	pub const END: Self = Self::new("");
	/// construct a new hyperlink with the given url.
	pub const fn new(url: &'a str) -> Self {
		Self{ url, id: None }
	}

	/// construct a new hyperlink with the given id param
	///
	/// see the `id()` method for details.
	#[inline]
	pub const fn with_id(&self, id: &'a str) -> Self {
		Self{ url: self.url, id: Some(id) }
	}

	/// parse a buffer for a single hyperlink.
	///
	/// if a hyperlink is found, returns that hyperlink as well as
	/// the range of the string that contains the full hyperlink,
	/// useful if you want to remove the hyperlinks from a piece of text.
	pub fn parse(src: &'a str) -> Result<Option<(Self, Range<usize>)>, ParseError> {
		let Some(start) = src.find(OSC8) else { return Ok(None) };
		let mut i: usize = start+OSC8.len();
		if i >= src.len() { return Err(ParseError::Partial); }
		if src.as_bytes()[i] != b';' { return Err(ParseError::MissingSemi); }
		i += 1;
		let mut id = None;
		if i >= src.len() { return Err(ParseError::Partial); }
		// calling str::split on the empty string will not result in an empty iterator,
		// but instead will result in an iterator containing a single value.
		if src.as_bytes()[i] != b';' {
			let Some(end_params) = src[i..].find(';') else { return Err(ParseError::Partial) };
			for param in src[i..i+end_params].split(':') {
				let Some((key, val)) = param.split_once('=') else { return Err(ParseError::MissingEq) };
				if key == "id" {
					id = Some(val);
				}
				// unknown keys are ignored.  they could be stored in a HashMap to
				// preserve them for a perfect round trip, but that would mean
				// making the core logic no longer zero-allocation.
			}
			i += end_params;
		} else {
			i += 1;
		}
		let start_url = i;
		let Some(end_url) = src[start_url..].find(ST) else { return Err(ParseError::Partial) };
		let end_url = end_url+start_url;
		let end = end_url + ST.len();
		Ok(Some((Hyperlink{ url: &src[start_url..end_url], id }, Range{ start, end })))
	}

	/// get the url of the hyperlink.
	///
	/// ```rust
	/// use osc8::Hyperlink;
	///
	/// assert_eq!(Hyperlink::new("https://example.com/").url(),
	///            "https://example.com/");
	/// ```
	pub fn url(&self) -> &'a str {
		self.url
	}

	/// gets the id paramater of the hyperlink, if present.
	///
	/// this is used by terminals to determine if two hyperlink-formatted cells
	/// are part of the same link, for the purpouse of
	/// highlighting the selected link.
	pub fn id(&self) -> Option<&'a str> {
		self.id
	}
}

#[cfg(test)]
mod tests {
    use super::*;
	extern crate std;
	use std::dbg;//prelude::v1::*;

    #[test]
    fn it_works() {
		let txt = "\x1b]8;;http://example.com\x1b\\This is a link\x1b]8;;\x1b\\\n";
		assert_eq!(ST, "\x1b\\");
		assert!(txt.find(ST).is_some());
		let (link1, r1) = Hyperlink::parse(txt).unwrap().unwrap();
		assert_eq!(link1.url(), "http://example.com");
		assert_eq!(r1, 0..25);
		let (link2, r2) = Hyperlink::parse(&txt[r1.end..]).unwrap().unwrap();
		assert_eq!(link2.url(), "");
		assert_eq!(r2.start, 14);
		assert_eq!(&txt[r1.end..r2.start+r1.end], "This is a link");
		for i in 0..txt.len() {
			for k in 0..i {
				let _ = Hyperlink::parse(&txt[k..i]);
			}
		}
		for r in &[r1.clone(), (r2.start+r1.end..r2.end+r1.end)] {
			for i in r.start+OSC8.len()+1..r.end-1 {
				let txt_slice = &txt[r.start..i];
				dbg!(r, i, txt_slice);
				assert!(txt_slice.contains(OSC8));
				assert_eq!(Hyperlink::parse(txt_slice),
						   Err(ParseError::Partial),
						   "not partial {r:?} {i} {txt_slice:?}");
			}
		}
    }

	// TODO: test if Hyperlink works with Yoke
	// TODO: use fuzzing to make sure it never panics for any input.
}