osc8 0.1.0

parse or generate terminal hyperlinks
Documentation
use std::io::{Read, self};
use std::prelude::v1::*;
use std::str::from_utf8;

use super::Hyperlink;

fn slice_find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
	haystack.windows(needle.len()).enumerate()
		.find(|(_, x)| x == &needle)
		.map(|(i, _)| i)
}

/// an event generated by a LinkReader.
///
/// note that a span of text with no hyperlink escape codes may be
/// split up into any number of Text events.
#[derive(Debug, PartialEq, Clone)]
pub enum Event<'a> {
	/// represents text that is not part of an escape code.
	///
	/// to strip hyperlink formatting, join all the Text events.
	Text(&'a str),
	/// represents an escape code for the start or end of a hyperlink.
	Link(Hyperlink<'a>),
	/// represents bytes that are not valid utf-8 text.
	Bytes(&'a [u8]),
	/// a hyperlink that could not be parsed,
	/// possibly due to it containing invalid utf8,
	/// or being too big to fit in the maximum configured buffer size.
	BadLink(&'a [u8]),
	/// represents the end of the input stream.
	End,
}

/// processes a UTF-8 stream to extract hyperlinks.
pub struct LinkReader<T: Read>  {
	/// number of bytes at the start of buf that have already been processed.
	processed: usize,
	buf: Vec<u8>,
	stream: T,
	max_buf_len: usize,
}

impl<T: Read> LinkReader<T> {
	/// construct a LinkReader that reads from the given stream.
	pub fn new(stream: T) -> Self {
		Self{
			stream,
			buf: Vec::with_capacity(128),
			processed: 0,
			max_buf_len: 1048576, // one mebibyte.
		}
	}
	
	fn remove_processed(&mut self) {
		// remove a range of elements, somehow not in stdlib
		self.buf.copy_within(self.processed.., 0);
		self.buf.truncate(self.buf.len() - self.processed);
		self.processed = 0;
	}

	/// returns true on end of file.
	fn fill(&mut self) -> io::Result<bool> {
		let l = self.buf.len();
		self.buf.resize(self.buf.capacity(), 0);
		let n = self.stream.read(&mut self.buf[l..])?;
		self.buf.truncate(l+n);
		Ok(self.buf.len() == 0)
	}

	fn text_event<'a>(&'a mut self, l: usize) -> Event<'a> {
		match from_utf8(&self.buf[..l]) {
			Ok(s) => {
				self.processed = l;
				Event::Text(s)
			}
			Err(e) => {
				let n = e.valid_up_to();
				if n > 0 {
					self.processed = n;
					Event::Text(from_utf8(&self.buf[..n]).unwrap())
				} else {
					let errl = e.error_len().unwrap_or(l);
					self.processed = errl;
					Event::Bytes(&self.buf[..errl])
				}
			}
		}
	}

	fn bad_link<'a>(&'a mut self, l: usize) -> Event<'a> {
		self.processed = l;
		Event::BadLink(&self.buf[..l])
	}

	/// get the next event in the input stream.
	pub fn next_event<'a>(&'a mut self) -> io::Result<Event<'a>> {
		static ST: &[u8] = super::ST.as_bytes();
		self.remove_processed();
		if self.buf.len() == 0 {
			if self.fill()? {
				return Ok(Event::End);
			}
		}
		
		let Some(start) = slice_find(&self.buf, super::OSC8.as_bytes())
		else {
			return Ok(self.text_event(self.buf.len()));
		};
		if start != 0 {
			return Ok(self.text_event(start));
		}
		let Some(end) = slice_find(&self.buf, ST)
		else {
			if self.buf.len() == self.buf.capacity() {
				if self.buf.capacity() >= self.max_buf_len {
					return Ok(self.bad_link(self.buf.len()));
				}
				// not enough room, double in size.
				self.buf.reserve(self.buf.capacity());
			}
			let old_len = self.buf.len();
			if self.fill()? ||
				self.buf.len() == old_len
			{
				// end of file with no ST
				return Ok(self.bad_link(self.buf.len()));
			}
			return self.next_event();
		};
		let link_bytes = &self.buf[start..end+ST.len()];
		macro_rules! bad_link {
			() => ({
				self.processed = link_bytes.len();
				return Ok(Event::BadLink(link_bytes));
			});
		}
		let link_str = match from_utf8(link_bytes) {
			Ok(s) => s,
			Err(_) => bad_link!(),
		};
		// tiny inefficency: parse() searches for escape sequences, even though we already found them.
		match Hyperlink::parse(link_str) {
			Ok(Some((link, r))) => {
				self.processed = r.end;
				return Ok(Event::Link(link));
			}
			Ok(None) => unreachable!("hyperlink does not contain hyperlink"),
			Err(err) => {
				// the partial url case should be impossible since we know
				// we have ST.
				debug_assert!(err != super::ParseError::Partial);
				bad_link!();
			}
		}
	}

	/// set the maximum length for the internal buffer.
	///
	/// default value is 1 MiB.
	///
	/// ```rust
	/// use osc8::read::LinkReader;
	///
	/// let mut lrdr = LinkReader::new(std::io::stdin()).with_max_len(1000);
	///
	/// if std::env::args().nth(1) == Some("--long-hyperlinks".to_string()) {
	///  lrdr = lrdr.with_max_len(1000000);
	/// }
	/// ```
	#[must_use = "LinkReader::with_max_len does not modify in place"]
	#[inline]
	pub fn with_max_len(self, max_buf_len: usize) -> Self {
		Self{
			max_buf_len,
			.. self
		}
	}
	
}

#[cfg(test)]
mod tests {
	use std::format;
	use super::*;

	#[test]
	fn non_utf() -> io::Result<()> {
		let mut s1: &[u8] = b"foo\xFFbar";
		let mut r1 = LinkReader::new(&mut s1);
		assert_eq!(r1.next_event()?,
				   Event::Text("foo"));
		assert_eq!(r1.next_event()?,
				   Event::Bytes(b"\xFF"));
		assert_eq!(r1.next_event()?,
				   Event::Text("bar"));
		assert_eq!(r1.next_event()?,
				   Event::End);

		Ok(())
	}

	#[test]
	fn basic_url() -> io::Result<()> {
		let link = Hyperlink::new("gopher://website.example/");
		let text = format!("{link}some gopher site{link:#}");
		let mut textb = text.as_bytes();
		let mut lrdr = LinkReader::new(&mut textb);
		assert_eq!(lrdr.next_event()?,
				   Event::Link(link));
		assert_eq!(lrdr.next_event()?,
				   Event::Text("some gopher site"));
		assert_eq!(lrdr.next_event()?,
				   Event::Link(Hyperlink::END));
		assert_eq!(lrdr.next_event()?,
				   Event::End);
		Ok(())
	}

	#[test]
	fn incomplete() -> io::Result<()> {
		let mut textb = crate::OSC8.as_bytes();
		let mut lrdr = LinkReader::new(&mut textb);
		assert_eq!(lrdr.next_event()?,
				   Event::BadLink(crate::OSC8.as_bytes()));
		assert_eq!(lrdr.next_event()?,
				   Event::End);
		Ok(())
	}
}