use std::borrow::Cow;
use std::fmt;
use std::path::Path;
use garde::Validate;
use serde::de::DeserializeOwned;
use serde_saphyr::{
Budget, DefaultMessageFormatter, DuplicateKeyPolicy, Error, Localizer, Location, MergeKeyPolicy,
};
mod limits;
#[cfg(feature = "input-toml")]
mod toml_text;
use limits::{Exceeded, Raise};
pub use limits::{Limits, Loosened, REPO_FILE, set_default_limits, use_repo_limits};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Format {
Yaml,
#[cfg(feature = "input-toml")]
Toml,
}
impl Format {
pub fn of(path: &Path) -> Result<Self, InputError> {
let name = path.display().to_string();
let extension = path
.extension()
.map(|extension| extension.to_string_lossy().to_ascii_lowercase());
match extension.as_deref() {
Some("yaml" | "yml") => Ok(Self::Yaml),
#[cfg(feature = "input-toml")]
Some("toml") => Ok(Self::Toml),
#[cfg(not(feature = "input-toml"))]
Some("toml") => Err(InputError::whole(
name,
format!("`.toml` needs ctl-core's input-toml feature; {ACCEPTED}"),
)),
Some(other) => Err(InputError::whole(
name,
format!("unsupported extension .{other}; {ACCEPTED}"),
)),
None => Err(InputError::whole(
name,
format!("no file extension; {ACCEPTED}"),
)),
}
}
}
#[cfg(feature = "input-toml")]
const ACCEPTED: &str = "name the file *.toml, *.yaml, or *.yml";
#[cfg(not(feature = "input-toml"))]
const ACCEPTED: &str = "name the file *.yaml or *.yml";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Input {
name: String,
text: String,
lines_before: usize,
limits: Limits,
format: Format,
}
#[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,
limits: Limits::NONE,
format: Format::Yaml,
}
}
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())),
}
}
pub fn open(path: &Path) -> Result<Self, InputError> {
let format = Format::of(path)?;
Ok(Self::read(path)?.format(format))
}
#[must_use]
pub fn format(mut self, format: Format) -> Self {
self.format = format;
self
}
#[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,
limits: self.limits.clone(),
format: Format::Yaml,
};
return Some((matter, &rest[offset + line.len()..]));
}
offset += line.len();
}
None
}
#[must_use]
pub fn limits(mut self, limits: Limits) -> Self {
self.limits = limits;
self
}
pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
self.parse_within(
&limits::effective(&self.limits).budget(),
Raise::InRepository,
)
}
fn parse_within<T: DeserializeOwned>(
&self,
budget: &Budget,
raise: Raise,
) -> Result<T, InputError> {
if let Some(limit) = budget.max_reader_input_bytes
&& self.text.len() > limit
{
return Err(InputError::whole(
self.name.clone(),
format!(
"is {} bytes, which {}",
self.text.len(),
Exceeded::input_bytes(limit, raise)
),
));
}
match self.format {
Format::Yaml => self.parse_yaml(budget, raise),
#[cfg(feature = "input-toml")]
Format::Toml => toml_text::parse(self, budget, raise),
}
}
fn parse_yaml<T: DeserializeOwned>(
&self,
budget: &Budget,
raise: Raise,
) -> Result<T, InputError> {
let options = serde_saphyr::options! {
budget: Some(budget.clone()),
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: self.message(error.without_snippet(), budget, raise),
}],
}
})
}
fn message(&self, error: &Error, budget: &Budget, raise: Raise) -> String {
if let Error::Budget { breach, .. } = error
&& let Some(exceeded) = Exceeded::breach(breach, budget, raise)
{
return exceeded.to_string();
}
let Error::AliasError { msg, locations } = error else {
return error.render_with_formatter(&DefaultMessageFormatter.with_localizer(&Unplaced));
};
let defined = locations.defined_location;
let (text, innermost) = baked_positions(msg);
let (line, column) = innermost.unwrap_or((
usize::try_from(defined.line()).unwrap_or_default(),
usize::try_from(defined.column()).unwrap_or_default(),
));
let line = line + self.lines_before;
format!("{text} (defined at line {line}, column {column})")
}
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 reported: Vec<_> = report
.iter()
.map(|(path, error)| (path.to_string(), error.message().to_owned()))
.collect();
let fields: Vec<_> = reported.iter().map(|(field, _)| field.as_str()).collect();
let places = self.places(&fields);
let problems = reported
.into_iter()
.zip(places)
.map(|((field, message), place)| Problem {
line: place.map(|(line, _)| line + self.lines_before),
column: place.map(|(_, column)| column),
field,
message,
})
.collect();
Err(InputError {
name: self.name.clone(),
problems,
})
}
fn places(&self, fields: &[&str]) -> Vec<Option<(usize, usize)>> {
match self.format {
Format::Yaml => {
let document = yamled::Document::parse(self.text.as_str()).ok();
fields
.iter()
.map(|field| {
document
.as_ref()
.and_then(|document| {
document.locate_nearest(&yaml_path(&field_steps(field)))
})
.map(|at| (at.line, at.column))
})
.collect()
}
#[cfg(feature = "input-toml")]
Format::Toml => toml_text::places(&self.text, fields),
}
}
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)
}
}
struct Unplaced;
impl Localizer for Unplaced {
fn attach_location<'a>(&self, base: Cow<'a, str>, _: Location) -> Cow<'a, str> {
base
}
}
fn baked_positions(msg: &str) -> (&str, Option<(usize, usize)>) {
let mut text = msg;
let mut innermost = None;
loop {
if let Some((rest, _)) = trailing_position(text, " (defined at line ", ")") {
text = rest;
} else if let Some((rest, at)) = trailing_position(text, " at line ", "") {
text = rest;
innermost = Some(at);
} else {
break;
}
}
(text, innermost)
}
fn trailing_position<'a>(
text: &'a str,
lead: &str,
close: &str,
) -> Option<(&'a str, (usize, usize))> {
let (rest, position) = text.strip_suffix(close)?.rsplit_once(lead)?;
let (line, column) = position.split_once(", column ")?;
Some((rest, (line.parse().ok()?, column.parse().ok()?)))
}
enum Step<'field> {
Key(&'field str),
Index(usize),
}
fn field_steps(field: &str) -> Vec<Step<'_>> {
let mut steps = Vec::new();
for part in field.split('.') {
let mut pieces = part.split('[');
if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
steps.push(Step::Key(key));
}
for index in pieces {
let index = index.trim_end_matches(']');
steps.push(match index.parse::<usize>() {
Ok(index) => Step::Index(index),
Err(_) => Step::Key(index),
});
}
}
steps
}
fn yaml_path(steps: &[Step<'_>]) -> yamled::Path {
steps
.iter()
.fold(yamled::Path::root(), |path, step| match step {
Step::Key(key) => path.key(*key),
Step::Index(index) => path.index(*index),
})
}
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 {}
#[cfg(test)]
mod tests {
use super::baked_positions;
#[test]
fn every_written_position_is_taken_off_the_text() {
assert_eq!(
baked_positions("invalid u16 (defined at line 2, column 3)"),
("invalid u16", None)
);
assert_eq!(
baked_positions(
"invalid u16 at line 2, column 9 (defined at line 2, column 9) at line 3, column 7"
),
("invalid u16", Some((2, 9)))
);
}
}