use std::path::Path;
use std::{fmt, io};
#[cfg(feature = "validate")]
use garde::Validate;
use serde::de::DeserializeOwned;
mod entries;
mod field_path;
mod limits;
#[cfg(feature = "input-toml")]
mod toml_entries;
#[cfg(feature = "input-toml")]
mod toml_text;
#[cfg(feature = "input-yaml")]
mod yaml_entries;
#[cfg(feature = "input-yaml")]
mod yaml_text;
pub use entries::{Entry, Written};
pub use field_path::{FieldPath, Place, Step};
use limits::{Exceeded, Raise};
pub use limits::{Limits, set_default_limits};
#[cfg(feature = "input-yaml")]
pub use limits::{Loosened, REPO_FILE, use_repo_limits};
#[derive(Clone, Copy)]
enum At {
Value,
Key,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Format {
#[cfg(feature = "input-yaml")]
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() {
#[cfg(feature = "input-yaml")]
Some("yaml" | "yml") => Ok(Self::Yaml),
#[cfg(not(feature = "input-yaml"))]
Some(yaml @ ("yaml" | "yml")) => Err(InputError::whole(
name,
format!("`.{yaml}` needs ctl-core's input-yaml feature; {ACCEPTED}"),
)),
#[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(all(feature = "input-yaml", feature = "input-toml"))]
const ACCEPTED: &str = "name the file *.toml, *.yaml, or *.yml";
#[cfg(all(feature = "input-yaml", not(feature = "input-toml")))]
const ACCEPTED: &str = "name the file *.yaml or *.yml";
#[cfg(all(feature = "input-toml", not(feature = "input-yaml")))]
const ACCEPTED: &str = "name the file *.toml";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Input {
name: String,
text: String,
lines_before: usize,
limits: Limits,
format: Format,
#[cfg(feature = "validate")]
flattened: Vec<FieldPath>,
#[cfg(feature = "input-yaml")]
yaml_document: yaml_text::Located,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct InputError {
pub name: String,
pub problems: Vec<Problem>,
io_failure: Option<io::ErrorKind>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Problem {
pub line: Option<usize>,
pub column: Option<usize>,
pub field: String,
pub message: String,
}
impl Input {
#[cfg(feature = "input-yaml")]
#[must_use]
pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
Self::new_with_format(name, text, Format::Yaml)
}
#[must_use]
pub fn new_with_format(
name: impl Into<String>,
text: impl Into<String>,
format: Format,
) -> Self {
Self {
name: name.into(),
text: text.into(),
lines_before: 0,
limits: Limits::NONE,
format,
#[cfg(feature = "validate")]
flattened: Vec::new(),
#[cfg(feature = "input-yaml")]
yaml_document: yaml_text::Located::default(),
}
}
#[cfg(feature = "input-yaml")]
pub fn read(path: &Path) -> Result<Self, InputError> {
let (name, text) = read_text(path)?;
Ok(Self::new(name, text))
}
pub fn open(path: &Path) -> Result<Self, InputError> {
let format = Format::of(path)?;
let (name, text) = read_text(path)?;
Ok(Self::new_with_format(name, text, 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
}
#[cfg(feature = "input-yaml")]
#[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,
#[cfg(feature = "validate")]
flattened: self.flattened.clone(),
yaml_document: yaml_text::Located::default(),
};
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
}
#[cfg(feature = "validate")]
#[must_use]
pub fn flattened(mut self, field: FieldPath) -> Self {
self.flattened.push(field);
self
}
#[cfg(feature = "validate")]
fn unflatten(&self, path: &FieldPath) -> Option<FieldPath> {
let positions: Vec<usize> = self
.flattened
.iter()
.filter(|field| path.starts_with(field))
.filter_map(|field| field.steps().len().checked_sub(1))
.collect();
(!positions.is_empty()).then(|| path.without(&positions))
}
pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
self.parse_within(&limits::effective(&self.limits), Raise::FOR_TEXT)
}
fn parse_within<T: DeserializeOwned>(
&self,
limits: &Limits,
raise: Raise,
) -> Result<T, InputError> {
let bounds = limits.bounds();
if self.text.len() > bounds.input_bytes {
return Err(InputError::whole(
self.name.clone(),
format!(
"is {} bytes, which {}",
self.text.len(),
Exceeded::input_bytes(bounds.input_bytes, raise)
),
));
}
match self.format {
#[cfg(feature = "input-yaml")]
Format::Yaml => yaml_text::parse(self, &limits.budget(), raise),
#[cfg(feature = "input-toml")]
Format::Toml => toml_text::parse(self, bounds, raise),
}
}
#[cfg(feature = "validate")]
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)| {
let steps = FieldPath::from(path);
let (field, steps) = match self.unflatten(&steps) {
Some(kept) => (kept.to_string(), kept),
None => (path.to_string(), steps),
};
(field, steps, error.message().to_owned())
})
.collect();
let paths: Vec<_> = reported.iter().map(|(_, path, _)| path.clone()).collect();
let places = self.places(&paths, At::Value);
let problems = reported
.into_iter()
.zip(places)
.map(|((field, _, message), place)| Problem {
line: place.map(|at| at.line),
column: place.map(|at| at.column),
field,
message,
})
.collect();
Err(InputError::of(self.name.clone(), problems))
}
#[must_use]
pub fn place(&self, path: &FieldPath) -> Option<Place> {
self.places(std::slice::from_ref(path), At::Value)
.pop()
.flatten()
}
#[must_use]
pub fn place_key(&self, path: &FieldPath) -> Option<Place> {
self.places(std::slice::from_ref(path), At::Key)
.pop()
.flatten()
}
#[must_use]
pub fn problem_at(&self, path: &FieldPath, message: impl Into<String>) -> Problem {
let place = self.place(path);
Problem {
line: place.map(|at| at.line),
column: place.map(|at| at.column),
field: path.to_string(),
message: message.into(),
}
}
fn places(&self, paths: &[FieldPath], at: At) -> Vec<Option<Place>> {
let limits = limits::effective(&self.limits);
if self.text.len() > limits.bounds().input_bytes {
return vec![None; paths.len()];
}
let found = match self.format {
#[cfg(feature = "input-yaml")]
Format::Yaml => yaml_text::places(self, &limits, paths, at),
#[cfg(feature = "input-toml")]
Format::Toml => toml_text::places(&self.text, paths, at),
};
found
.into_iter()
.map(|at| at.map(|(line, column)| Place::new(line + self.lines_before, column)))
.collect()
}
#[cfg(feature = "validate")]
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 read_text(path: &Path) -> Result<(String, String), InputError> {
let name = path.display().to_string();
match std::fs::read_to_string(path) {
Ok(text) => Ok((name, text)),
Err(error) => Err(InputError::unread(name, &error)),
}
}
impl InputError {
fn whole(name: String, message: String) -> Self {
Self::of(
name,
vec![Problem {
line: None,
column: None,
field: String::new(),
message,
}],
)
}
fn unread(name: String, error: &io::Error) -> Self {
Self {
io_failure: Some(error.kind()),
..Self::whole(name, error.to_string())
}
}
const fn of(name: String, problems: Vec<Problem>) -> Self {
Self {
name,
problems,
io_failure: None,
}
}
#[must_use]
pub const fn io_failure(&self) -> Option<io::ErrorKind> {
self.io_failure
}
}
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 {}