1use std::borrow::Cow;
8use std::fmt;
9use std::path::Path;
10
11use garde::Validate;
12use serde::de::DeserializeOwned;
13use serde_saphyr::{
14 DefaultMessageFormatter, DuplicateKeyPolicy, Error, Localizer, Location, MergeKeyPolicy,
15};
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Input {
20 name: String,
21 text: String,
22 lines_before: usize,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct InputError {
29 pub name: String,
31 pub problems: Vec<Problem>,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct Problem {
38 pub line: Option<usize>,
40 pub column: Option<usize>,
42 pub field: String,
45 pub message: String,
47}
48
49impl Input {
50 #[must_use]
52 pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
53 Self {
54 name: name.into(),
55 text: text.into(),
56 lines_before: 0,
57 }
58 }
59
60 pub fn read(path: &Path) -> Result<Self, InputError> {
66 let name = path.display().to_string();
67 match std::fs::read_to_string(path) {
68 Ok(text) => Ok(Self::new(name, text)),
69 Err(error) => Err(InputError::whole(name, error.to_string())),
70 }
71 }
72
73 #[must_use]
75 pub fn name(&self) -> &str {
76 &self.name
77 }
78
79 #[must_use]
81 pub fn text(&self) -> &str {
82 &self.text
83 }
84
85 #[must_use]
90 pub fn frontmatter(&self) -> Option<(Self, &str)> {
91 let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
92 let rest = text
93 .strip_prefix("---\n")
94 .or_else(|| text.strip_prefix("---\r\n"))?;
95 let mut offset = 0;
96 for line in rest.split_inclusive('\n') {
97 if line.trim_end() == "---" {
98 let matter = Self {
99 name: self.name.clone(),
100 text: rest[..offset].to_owned(),
101 lines_before: self.lines_before + 1,
102 };
103 return Some((matter, &rest[offset + line.len()..]));
104 }
105 offset += line.len();
106 }
107 None
108 }
109
110 pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
117 let options = serde_saphyr::options! {
118 strict_booleans: true,
119 duplicate_keys: DuplicateKeyPolicy::Error,
120 merge_keys: MergeKeyPolicy::Error,
121 with_snippet: false,
122 };
123 serde_saphyr::from_str_with_options(&self.text, options).map_err(|error| {
124 let location = error.location();
125 InputError {
126 name: self.name.clone(),
127 problems: vec![Problem {
128 line: location
129 .and_then(|at| usize::try_from(at.line()).ok())
130 .map(|line| line + self.lines_before),
131 column: location.and_then(|at| usize::try_from(at.column()).ok()),
132 field: String::new(),
133 message: self.message(error.without_snippet()),
134 }],
135 }
136 })
137 }
138
139 fn message(&self, error: &Error) -> String {
142 let Error::AliasError { msg, locations } = error else {
143 return error.render_with_formatter(&DefaultMessageFormatter.with_localizer(&Unplaced));
144 };
145 let defined = locations.defined_location;
146 let (text, innermost) = baked_positions(msg);
147 let (line, column) = innermost.unwrap_or((
148 usize::try_from(defined.line()).unwrap_or_default(),
149 usize::try_from(defined.column()).unwrap_or_default(),
150 ));
151 let line = line + self.lines_before;
152 format!("{text} (defined at line {line}, column {column})")
153 }
154
155 pub fn check<T: Validate + ?Sized>(
163 &self,
164 value: &T,
165 context: &T::Context,
166 ) -> Result<(), InputError> {
167 let Err(report) = value.validate_with(context) else {
168 return Ok(());
169 };
170 let document = yamled::Document::parse(self.text.as_str()).ok();
171 let problems = report
172 .iter()
173 .map(|(path, error)| {
174 let field = path.to_string();
175 let location = document
176 .as_ref()
177 .and_then(|document| document.locate_nearest(&yaml_path(&field)));
178 Problem {
179 line: location.map(|at| at.line + self.lines_before),
180 column: location.map(|at| at.column),
181 field,
182 message: error.message().to_owned(),
183 }
184 })
185 .collect();
186 Err(InputError {
187 name: self.name.clone(),
188 problems,
189 })
190 }
191
192 pub fn load<T>(&self) -> Result<T, InputError>
198 where
199 T: DeserializeOwned + Validate,
200 T::Context: Default,
201 {
202 let value = self.parse::<T>()?;
203 self.check(&value, &T::Context::default())?;
204 Ok(value)
205 }
206}
207
208struct Unplaced;
212
213impl Localizer for Unplaced {
214 fn attach_location<'a>(&self, base: Cow<'a, str>, _: Location) -> Cow<'a, str> {
215 base
216 }
217}
218
219fn baked_positions(msg: &str) -> (&str, Option<(usize, usize)>) {
227 let mut text = msg;
228 let mut innermost = None;
229 loop {
230 if let Some((rest, _)) = trailing_position(text, " (defined at line ", ")") {
231 text = rest;
232 } else if let Some((rest, at)) = trailing_position(text, " at line ", "") {
233 text = rest;
234 innermost = Some(at);
235 } else {
236 break;
237 }
238 }
239 (text, innermost)
240}
241
242fn trailing_position<'a>(
244 text: &'a str,
245 lead: &str,
246 close: &str,
247) -> Option<(&'a str, (usize, usize))> {
248 let (rest, position) = text.strip_suffix(close)?.rsplit_once(lead)?;
249 let (line, column) = position.split_once(", column ")?;
250 Some((rest, (line.parse().ok()?, column.parse().ok()?)))
251}
252
253fn yaml_path(field: &str) -> yamled::Path {
257 let mut path = yamled::Path::root();
258 for part in field.split('.') {
259 let mut pieces = part.split('[');
260 if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
261 path = path.key(key);
262 }
263 for index in pieces {
264 let index = index.trim_end_matches(']');
265 path = match index.parse::<usize>() {
266 Ok(index) => path.index(index),
267 Err(_) => path.key(index),
268 };
269 }
270 }
271 path
272}
273
274impl InputError {
275 fn whole(name: String, message: String) -> Self {
276 Self {
277 name,
278 problems: vec![Problem {
279 line: None,
280 column: None,
281 field: String::new(),
282 message,
283 }],
284 }
285 }
286}
287
288impl fmt::Display for InputError {
291 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
292 for (index, problem) in self.problems.iter().enumerate() {
293 if index > 0 {
294 writeln!(formatter)?;
295 }
296 write!(formatter, "{}", self.name)?;
297 if let Some(line) = problem.line {
298 write!(formatter, ":{line}")?;
299 if let Some(column) = problem.column {
300 write!(formatter, ":{column}")?;
301 }
302 }
303 if problem.field.is_empty() {
304 write!(formatter, ": {}", problem.message)?;
305 } else {
306 write!(formatter, ": {}: {}", problem.field, problem.message)?;
307 }
308 }
309 Ok(())
310 }
311}
312
313impl std::error::Error for InputError {}
314
315#[cfg(test)]
316mod tests {
317 use super::baked_positions;
318
319 #[test]
320 fn every_written_position_is_taken_off_the_text() {
321 assert_eq!(
322 baked_positions("invalid u16 (defined at line 2, column 3)"),
323 ("invalid u16", None)
324 );
325 assert_eq!(
326 baked_positions(
327 "invalid u16 at line 2, column 9 (defined at line 2, column 9) at line 3, column 7"
328 ),
329 ("invalid u16", Some((2, 9)))
330 );
331 }
332}