use std::fmt;
use std::path::Path;
use garde::Validate;
use serde::de::DeserializeOwned;
use serde_saphyr::{DuplicateKeyPolicy, MergeKeyPolicy};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Input {
name: String,
text: String,
lines_before: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InputError {
pub name: String,
pub problems: Vec<Problem>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Problem {
pub line: Option<usize>,
pub column: Option<usize>,
pub field: String,
pub message: String,
}
impl Input {
#[must_use]
pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
Self {
name: name.into(),
text: text.into(),
lines_before: 0,
}
}
pub fn read(path: &Path) -> Result<Self, InputError> {
let name = path.display().to_string();
match std::fs::read_to_string(path) {
Ok(text) => Ok(Self::new(name, text)),
Err(error) => Err(InputError::whole(name, error.to_string())),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn frontmatter(&self) -> Option<(Self, &str)> {
let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
let rest = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))?;
let mut offset = 0;
for line in rest.split_inclusive('\n') {
if line.trim_end() == "---" {
let matter = Self {
name: self.name.clone(),
text: rest[..offset].to_owned(),
lines_before: self.lines_before + 1,
};
return Some((matter, &rest[offset + line.len()..]));
}
offset += line.len();
}
None
}
pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
let options = serde_saphyr::options! {
strict_booleans: true,
duplicate_keys: DuplicateKeyPolicy::Error,
merge_keys: MergeKeyPolicy::Error,
with_snippet: false,
};
serde_saphyr::from_str_with_options(&self.text, options).map_err(|error| {
let location = error.location();
InputError {
name: self.name.clone(),
problems: vec![Problem {
line: location
.and_then(|at| usize::try_from(at.line()).ok())
.map(|line| line + self.lines_before),
column: location.and_then(|at| usize::try_from(at.column()).ok()),
field: String::new(),
message: error.without_snippet().to_string(),
}],
}
})
}
pub fn check<T: Validate + ?Sized>(
&self,
value: &T,
context: &T::Context,
) -> Result<(), InputError> {
let Err(report) = value.validate_with(context) else {
return Ok(());
};
let document = yamled::Document::parse(self.text.as_str()).ok();
let problems = report
.iter()
.map(|(path, error)| {
let field = path.to_string();
let location = document
.as_ref()
.and_then(|document| document.locate_nearest(&yaml_path(&field)));
Problem {
line: location.map(|at| at.line + self.lines_before),
column: location.map(|at| at.column),
field,
message: error.message().to_owned(),
}
})
.collect();
Err(InputError {
name: self.name.clone(),
problems,
})
}
pub fn load<T>(&self) -> Result<T, InputError>
where
T: DeserializeOwned + Validate,
T::Context: Default,
{
let value = self.parse::<T>()?;
self.check(&value, &T::Context::default())?;
Ok(value)
}
}
fn yaml_path(field: &str) -> yamled::Path {
let mut path = yamled::Path::root();
for part in field.split('.') {
let mut pieces = part.split('[');
if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
path = path.key(key);
}
for index in pieces {
let index = index.trim_end_matches(']');
path = match index.parse::<usize>() {
Ok(index) => path.index(index),
Err(_) => path.key(index),
};
}
}
path
}
impl InputError {
fn whole(name: String, message: String) -> Self {
Self {
name,
problems: vec![Problem {
line: None,
column: None,
field: String::new(),
message,
}],
}
}
}
impl fmt::Display for InputError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, problem) in self.problems.iter().enumerate() {
if index > 0 {
writeln!(formatter)?;
}
write!(formatter, "{}", self.name)?;
if let Some(line) = problem.line {
write!(formatter, ":{line}")?;
if let Some(column) = problem.column {
write!(formatter, ":{column}")?;
}
}
if problem.field.is_empty() {
write!(formatter, ": {}", problem.message)?;
} else {
write!(formatter, ": {}: {}", problem.field, problem.message)?;
}
}
Ok(())
}
}
impl std::error::Error for InputError {}