tidier 0.5.6

Format HTML, XHTML and XML documents
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright 2023 Taylan Gökkaya

use std::{
	ffi::c_int,
	fmt::{
		self,
		Display,
		Formatter,
	},
	slice,
};

use tidy_sys::TidyBuffer;

/// An error generated during parsing or formatting.
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub enum Error {
	/// Input document contains a null byte in the middle or at the start.
	ContainsNullByte(usize),
	/// Failed to parse Tidy output as UTF-8. This error is unlikely to be
	/// produced as this crate sets Tidy char encoding to UTF-8.
	ParseUtf8,
	/// The formatted document's length exceeds `u32::MAX`. This error exists
	/// due to Tidy using `u32` for lengths.
	MaxSizeReached,
	/// One or more errors Tidy produced during parsing or formatting.
	///
	/// When constructed by this crate, it's guaranteed that the Vec will contain at least one diagnostic with the level being [DiagnosticLevel::Error].
	Doc(Vec<Diagnostic>),
	/// Tidy produced a severe error.
	///
	/// The error code represents a POSIX errno code on all platforms.
	Errno(i32),
}

impl std::error::Error for Error {}

/// A diagnostic generated by Tidy.
#[derive(Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub struct Diagnostic {
	/// The severity of the diagnostic.
	pub level: DiagnosticLevel,
	/// The 1-based line number in the document where this diagnostic is
	/// produced.
	pub line: usize,
	/// The 1-based column number in the line where this diagnostic is produced.
	pub column: usize,
	/// The human readable message.
	pub message: String,
}

/// The severity of a diagnostic.
#[derive(Debug, Hash, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)]
pub enum DiagnosticLevel {
	Warning,
	Error,
}

fn errno_msg(no: c_int) -> String {
	#[cfg(windows)]
	{
		extern "C" {
			fn strerror_s(buf: *mut u8, bufsz: usize, errnum: c_int) -> c_int;
		}
		let mut buf = vec![0; 128];
		let res = unsafe { strerror_s(buf.as_mut_ptr(), buf.capacity(), no) };

		debug_assert_eq!(res, 0);
		let nil = memchr::memchr(0, &buf)
			.expect("buffer was supposed to contain a null byte but it doesn't");
		buf.truncate(nil);
		String::from_utf8(buf).expect("strerror returned a non-utf8 string")
	}
	#[cfg(not(windows))]
	errno::Errno(no).to_string()
}

impl Diagnostic {
	pub(crate) fn parse(s: &str) -> Option<Self> {
		// warnings and errors look like
		// line 2 column 1 - Warning: missing </a>
		let s = s.trim();
		let s = s.strip_prefix("line ")?;
		let (ln, s) = s.split_once(' ')?;

		let s = s.strip_prefix("column ")?;
		let (col, s) = s.split_once(' ')?;

		let s = s.strip_prefix("- ")?;
		let (level, msg) = s.split_once(": ")?;

		let level = match level {
			"Error" => DiagnosticLevel::Error,
			"Warning" => DiagnosticLevel::Warning,
			_ => return None,
		};

		let line = ln.parse::<usize>().ok()?;
		let column = col.parse::<usize>().ok()?;
		Some(Diagnostic {
			line,
			column,
			level,
			message: msg.to_string(),
		})
	}

	pub(crate) fn parse_all(err_buf: *const TidyBuffer) -> Vec<Self> {
		let sink = unsafe {
			let buf = *err_buf;
			if buf.bp.is_null() {
				return Vec::new();
			}
			slice::from_raw_parts::<u8>(buf.bp, buf.size as _)
		};
		let mut list = Vec::with_capacity(memchr::memchr_iter(b'\n', sink).count() + 1);
		let mut last = 0;
		for i in memchr::memchr_iter(b'\n', sink) {
			let s = &sink[last..i];
			last = i + 1;
			if !s.is_empty() {
				list.extend(Self::parse(&String::from_utf8_lossy(s)));
			}
		}

		if last < sink.len() {
			list.extend(Self::parse(&String::from_utf8_lossy(&sink[last..])));
		}
		list
	}

	/// Returns true if `self.level == DiagnosticLevel::Error`.
	pub fn is_error(&self) -> bool {
		self.level == DiagnosticLevel::Error
	}

	/// Returns true if `self.level == DiagnosticLevel::Warning`.
	pub fn is_warning(&self) -> bool {
		self.level == DiagnosticLevel::Warning
	}
}

impl Display for Diagnostic {
	fn fmt(&self, f: &mut Formatter) -> fmt::Result {
		write!(
			f,
			"{} at line {} column {}: {}",
			self.level, self.line, self.column, self.message
		)
	}
}

impl Display for DiagnosticLevel {
	fn fmt(&self, f: &mut Formatter) -> fmt::Result {
		let s = match self {
			Self::Error => "error",
			Self::Warning => "warning",
		};

		f.write_str(s)
	}
}

impl Display for Error {
	fn fmt(&self, f: &mut Formatter) -> fmt::Result {
		match self {
			Self::ContainsNullByte(index) => {
				write!(f, "the input contains a null byte at index {index}")
			}
			Self::ParseUtf8 => f.write_str("failed to parse the formatted output as UTF8"),
			Self::MaxSizeReached => {
				f.write_str("formatted document exceeded the maximum size possible with tidy")
			}
			&Self::Errno(no) => {
				write!(f, "Tidy error errno {}: {}", no, errno_msg(no))
			}
			Self::Doc(errs) => {
				let n_err = errs.iter().filter(|d| d.is_error()).count();
				if n_err == 0 {
					return f.write_str("unknown document error");
				} else if n_err > 1 {
					writeln!(f, "encountered {n_err} errors while parsing/formatting:")?;
				}
				for (i, e) in errs.iter().filter(|d| d.is_error()).enumerate() {
					if i > 0 {
						f.write_str("\n")?;
					}
					write!(f, "{e}")?;
				}

				Ok(())
			}
		}
	}
}