use std::{
ffi::CString,
fmt,
fs::File,
io,
path::{Path, PathBuf},
};
#[cfg(windows)]
use std::io::Read;
use crate::{
Compression, Diagnostic, ParseError, ParseErrorKind, Parser, RawRender, SourceBundle,
compression, diagnostics, ffi, transport::PreparedInput,
};
pub const DEFAULT_RENDER_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_RENDER_WIDTH: usize = 78;
pub const MAX_RENDER_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
pub const MIN_RENDER_WIDTH: usize = 20;
pub const MAX_RENDER_WIDTH: usize = 1_000;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RenderFormat {
Ascii,
Utf8,
Html,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenderReport {
pub output: String,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RenderErrorKind {
InvalidPath,
Read,
Decompression,
Unsupported,
InvalidOptions,
OutputLimit,
Render,
Encoding,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenderError {
pub path: PathBuf,
pub kind: RenderErrorKind,
pub message: String,
}
impl fmt::Display for RenderError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.path.display(), self.message)
}
}
impl std::error::Error for RenderError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Renderer {
parser: Parser,
format: RenderFormat,
width: usize,
max_output_bytes: usize,
html_fragment: bool,
}
impl Renderer {
#[must_use]
pub fn new(format: RenderFormat) -> Self {
Self {
parser: Parser::default(),
format,
width: DEFAULT_RENDER_WIDTH,
max_output_bytes: DEFAULT_RENDER_OUTPUT_BYTES,
html_fragment: false,
}
}
#[must_use]
pub fn with_parser(mut self, parser: Parser) -> Self {
self.parser = parser;
self
}
#[must_use]
pub const fn with_width(mut self, width: usize) -> Self {
self.width = width;
self
}
#[must_use]
pub const fn with_max_output_bytes(mut self, max_output_bytes: usize) -> Self {
self.max_output_bytes = max_output_bytes;
self
}
#[must_use]
pub const fn with_html_fragment(mut self, html_fragment: bool) -> Self {
self.html_fragment = html_fragment;
self
}
#[must_use]
pub const fn parser(&self) -> &Parser {
&self.parser
}
#[must_use]
pub const fn format(&self) -> RenderFormat {
self.format
}
#[must_use]
pub const fn width(&self) -> usize {
self.width
}
#[must_use]
pub const fn max_output_bytes(&self) -> usize {
self.max_output_bytes
}
#[must_use]
pub const fn html_fragment(&self) -> bool {
self.html_fragment
}
pub fn render_file(&self, path: impl AsRef<Path>) -> Result<RenderReport, RenderError> {
let path = path.as_ref();
self.validate(path)?;
match self.parser.options().compression {
Compression::Auto if path.extension().is_some_and(|extension| extension == "zst") => {
self.render_zstd_file(path)
}
Compression::Auto => self.render_auto_file(path),
Compression::Plain => {
let source = std::fs::read(path).map_err(|error| read_error(path, &error))?;
self.render_plain_bytes(path, &source)
}
Compression::Zstd => self.render_zstd_file(path),
}
}
pub fn render_bytes(
&self,
source_path: impl AsRef<Path>,
source: &[u8],
) -> Result<RenderReport, RenderError> {
let path = source_path.as_ref();
self.validate(path)?;
let source = crate::transport::prepare_bytes(source, self.parser.options().compression)
.map_err(|error| decompression_error(path, &error))?;
self.render_plain_bytes(path, &source)
}
pub fn render_bundle(
&self,
root: impl AsRef<Path>,
bundle: &SourceBundle,
) -> Result<RenderReport, RenderError> {
let root = root.as_ref();
self.validate(root)?;
let root_label = root.to_str().ok_or_else(|| RenderError {
path: root.to_path_buf(),
kind: RenderErrorKind::InvalidPath,
message: "source bundle roots must be UTF-8 logical paths".into(),
})?;
if bundle.get(root_label).is_none() {
return Err(RenderError {
path: root.to_path_buf(),
kind: RenderErrorKind::Read,
message: "source bundle does not contain the requested root".into(),
});
}
let c_path = native_path(root)?;
Self::finish(
root,
ffi::render_bundle(
&c_path,
bundle,
self.parser.input_format(),
self.parser.mdoc_operating_system(),
format_code(self.format),
self.width,
self.html_fragment,
self.max_output_bytes,
),
)
}
#[cfg(unix)]
fn render_auto_file(&self, path: &Path) -> Result<RenderReport, RenderError> {
let prepared =
PreparedInput::new(path, &self.parser.options().includes).map_err(map_parse_error)?;
let c_path = &prepared.path;
let includes = &prepared.includes;
Self::finish(
path,
ffi::render_file(
c_path,
includes.root.as_deref(),
includes.allow_includes,
self.parser.input_format(),
self.parser.mdoc_operating_system(),
format_code(self.format),
self.width,
self.html_fragment,
self.max_output_bytes,
),
)
}
#[cfg(windows)]
fn render_auto_file(&self, path: &Path) -> Result<RenderReport, RenderError> {
let (source, gzip) =
compression::open_auto_file(path).map_err(|error| read_error(path, &error))?;
if gzip {
let decoded = compression::decode_gzip(source).map_err(|error| RenderError {
path: path.to_path_buf(),
kind: RenderErrorKind::Decompression,
message: format!("could not decompress gzip manual source: {error}"),
})?;
self.render_plain_bytes(path, &decoded)
} else {
let mut source = source;
let mut bytes = Vec::new();
source
.read_to_end(&mut bytes)
.map_err(|error| read_error(path, &error))?;
self.render_plain_bytes(path, &bytes)
}
}
fn render_zstd_file(&self, path: &Path) -> Result<RenderReport, RenderError> {
let source = File::open(path)
.and_then(compression::decode_zstd)
.map_err(|error| decompression_error(path, &error))?;
self.render_plain_bytes(path, &source)
}
#[cfg(unix)]
fn render_plain_bytes(&self, path: &Path, source: &[u8]) -> Result<RenderReport, RenderError> {
let prepared =
PreparedInput::new(path, &self.parser.options().includes).map_err(map_parse_error)?;
let c_path = &prepared.path;
let includes = &prepared.includes;
Self::finish(
path,
ffi::render_buffer(
c_path,
source,
includes.root.as_deref(),
includes.allow_includes,
self.parser.input_format(),
self.parser.mdoc_operating_system(),
format_code(self.format),
self.width,
self.html_fragment,
self.max_output_bytes,
),
)
}
#[cfg(windows)]
fn render_plain_bytes(&self, path: &Path, source: &[u8]) -> Result<RenderReport, RenderError> {
let prepared =
PreparedInput::new(path, &self.parser.options().includes).map_err(map_parse_error)?;
let c_path = &prepared.path;
let includes = &prepared.includes;
Self::finish(
path,
ffi::render_buffer(
c_path,
source,
includes.root.as_deref(),
includes.allow_includes,
self.parser.input_format(),
self.parser.mdoc_operating_system(),
format_code(self.format),
self.width,
self.html_fragment,
self.max_output_bytes,
),
)
}
fn finish(
path: &Path,
rendered: Result<RawRender, ffi::NativeRenderError>,
) -> Result<RenderReport, RenderError> {
let raw = rendered.map_err(|error| RenderError {
path: path.to_path_buf(),
kind: if error.status == 1 {
RenderErrorKind::OutputLimit
} else {
RenderErrorKind::Render
},
message: error.message,
})?;
let output = String::from_utf8(raw.output).map_err(|error| RenderError {
path: path.to_path_buf(),
kind: RenderErrorKind::Encoding,
message: format!("native renderer returned invalid UTF-8: {error}"),
})?;
Ok(RenderReport {
output,
diagnostics: diagnostics::parse_diagnostics(&raw.diagnostics),
})
}
fn validate(&self, path: &Path) -> Result<(), RenderError> {
if self.format != RenderFormat::Html
&& !(MIN_RENDER_WIDTH..=MAX_RENDER_WIDTH).contains(&self.width)
{
return Err(RenderError {
path: path.to_path_buf(),
kind: RenderErrorKind::InvalidOptions,
message: format!(
"render width must be between {MIN_RENDER_WIDTH} and {MAX_RENDER_WIDTH}"
),
});
}
if !(1..=MAX_RENDER_OUTPUT_BYTES).contains(&self.max_output_bytes) {
return Err(RenderError {
path: path.to_path_buf(),
kind: RenderErrorKind::InvalidOptions,
message: format!(
"render output limit must be between 1 and {MAX_RENDER_OUTPUT_BYTES} bytes"
),
});
}
Ok(())
}
}
fn native_path(path: &Path) -> Result<CString, RenderError> {
crate::transport::native_path(path).map_err(map_parse_error)
}
fn map_parse_error(error: ParseError) -> RenderError {
RenderError {
path: error.path,
kind: match error.kind {
ParseErrorKind::InvalidPath => RenderErrorKind::InvalidPath,
ParseErrorKind::Read => RenderErrorKind::Read,
ParseErrorKind::Decompression => RenderErrorKind::Decompression,
ParseErrorKind::Unsupported => RenderErrorKind::Unsupported,
ParseErrorKind::Parse => RenderErrorKind::Render,
},
message: error.message,
}
}
const fn format_code(format: RenderFormat) -> i32 {
match format {
RenderFormat::Ascii => 1,
RenderFormat::Html => 2,
RenderFormat::Utf8 => 3,
}
}
fn read_error(path: &Path, error: &io::Error) -> RenderError {
RenderError {
path: path.to_path_buf(),
kind: RenderErrorKind::Read,
message: error.to_string(),
}
}
fn decompression_error(path: &Path, error: &io::Error) -> RenderError {
RenderError {
path: path.to_path_buf(),
kind: RenderErrorKind::Decompression,
message: format!("could not decompress zstd manual source: {error}"),
}
}
#[cfg(test)]
mod tests {
use crate::{RenderFormat, Renderer, ffi};
#[test]
fn utf8_rendering_does_not_change_process_ctype_locale() {
let before = ffi::ctype_locale();
let report = Renderer::new(RenderFormat::Utf8)
.render_bytes(
"locale.1",
".TH LOCALE 1\n.SH NAME\ncafé \\(em 日本 😀\n".as_bytes(),
)
.expect("render deterministic UTF-8");
let after = ffi::ctype_locale();
assert_eq!(after, before);
assert!(report.output.contains("café — 日本 😀"));
}
}