use std::{
ffi::CString,
fmt,
fs::File,
io,
os::unix::ffi::OsStrExt,
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
};
use crate::{Diagnostic, Document, RawDocument, diagnostics, ffi};
static PARSER_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum IncludePolicy {
#[default]
Deny,
SourceTree,
Root(PathBuf),
}
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Compression {
#[default]
Auto,
Plain,
Zstd,
}
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ParseOptions {
pub includes: IncludePolicy,
pub compression: Compression,
}
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseReport {
pub document: Document,
pub diagnostics: Vec<Diagnostic>,
}
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ParseErrorKind {
InvalidPath,
Read,
Decompression,
Parse,
}
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseError {
pub path: PathBuf,
pub kind: ParseErrorKind,
pub message: String,
}
impl fmt::Display for ParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.path.display(), self.message)
}
}
impl std::error::Error for ParseError {}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Parser {
options: ParseOptions,
}
impl Parser {
#[must_use]
pub const fn new(options: ParseOptions) -> Self {
Self { options }
}
#[must_use]
pub const fn options(&self) -> &ParseOptions {
&self.options
}
pub fn parse_file(&self, path: impl AsRef<Path>) -> Result<ParseReport, ParseError> {
let path = path.as_ref();
match self.options.compression {
Compression::Auto if path.extension().is_some_and(|extension| extension == "zst") => {
self.parse_zstd_file(path)
}
Compression::Auto => self.parse_native_file(path),
Compression::Plain => {
let source = std::fs::read(path).map_err(|error| read_error(path, &error))?;
self.parse_plain_bytes(path, &source)
}
Compression::Zstd => self.parse_zstd_file(path),
}
}
pub fn parse_bytes(
&self,
source_path: impl AsRef<Path>,
source: &[u8],
) -> Result<ParseReport, ParseError> {
let path = source_path.as_ref();
match self.options.compression {
Compression::Auto if has_zstd_magic(source) => self.parse_zstd_bytes(path, source),
Compression::Auto | Compression::Plain => self.parse_plain_bytes(path, source),
Compression::Zstd => self.parse_zstd_bytes(path, source),
}
}
fn parse_zstd_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
let source = File::open(path)
.and_then(zstd::stream::decode_all)
.map_err(|error| decompression_error(path, &error))?;
self.parse_plain_bytes(path, &source)
}
fn parse_zstd_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
let source =
zstd::stream::decode_all(source).map_err(|error| decompression_error(path, &error))?;
self.parse_plain_bytes(path, &source)
}
fn parse_native_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
self.finish(path, |c_path, include_root, allow_includes| {
ffi::parse_file(c_path, include_root.map(CString::as_c_str), allow_includes)
})
}
fn parse_plain_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
self.finish(path, |c_path, include_root, allow_includes| {
ffi::parse_buffer(
c_path,
source,
include_root.map(CString::as_c_str),
allow_includes,
)
})
}
fn finish(
&self,
path: &Path,
parse: impl FnOnce(&CString, Option<&CString>, bool) -> Result<RawDocument, String>,
) -> Result<ParseReport, ParseError> {
let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| ParseError {
path: path.to_path_buf(),
kind: ParseErrorKind::InvalidPath,
message: "manual source path contains a NUL byte".into(),
})?;
let lock = PARSER_LOCK.get_or_init(|| Mutex::new(()));
let _guard = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (include_root, allow_includes) = self.include_root()?;
let raw = parse(&c_path, include_root.as_ref(), allow_includes).map_err(|message| {
ParseError {
path: path.to_path_buf(),
kind: ParseErrorKind::Parse,
message,
}
})?;
Ok(ParseReport {
document: raw.document,
diagnostics: diagnostics::parse_diagnostics(&raw.diagnostics),
})
}
fn include_root(&self) -> Result<(Option<CString>, bool), ParseError> {
match &self.options.includes {
IncludePolicy::Deny => Ok((None, false)),
IncludePolicy::SourceTree => Ok((None, true)),
IncludePolicy::Root(root) => CString::new(root.as_os_str().as_bytes())
.map(Some)
.map(|root| (root, true))
.map_err(|_| ParseError {
path: root.clone(),
kind: ParseErrorKind::InvalidPath,
message: "manual include root contains a NUL byte".into(),
}),
}
}
}
fn has_zstd_magic(source: &[u8]) -> bool {
source.starts_with(&[0x28, 0xb5, 0x2f, 0xfd])
}
fn read_error(path: &Path, error: &io::Error) -> ParseError {
ParseError {
path: path.to_path_buf(),
kind: ParseErrorKind::Read,
message: error.to_string(),
}
}
fn decompression_error(path: &Path, error: &io::Error) -> ParseError {
ParseError {
path: path.to_path_buf(),
kind: ParseErrorKind::Decompression,
message: format!("could not decompress zstd manual source: {error}"),
}
}