1use std::borrow::Cow;
8use std::fmt;
9use std::path::Path;
10
11use garde::Validate;
12use serde::de::DeserializeOwned;
13use serde_saphyr::{
14 Budget, DefaultMessageFormatter, DuplicateKeyPolicy, Error, Localizer, Location, MergeKeyPolicy,
15};
16
17mod limits;
18
19use limits::{Exceeded, Raise};
20pub use limits::{Limits, Loosened, REPO_FILE, set_default_limits, use_repo_limits};
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct Input {
25 name: String,
26 text: String,
27 lines_before: usize,
28 limits: Limits,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct InputError {
35 pub name: String,
37 pub problems: Vec<Problem>,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct Problem {
44 pub line: Option<usize>,
46 pub column: Option<usize>,
48 pub field: String,
51 pub message: String,
53}
54
55impl Input {
56 #[must_use]
58 pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
59 Self {
60 name: name.into(),
61 text: text.into(),
62 lines_before: 0,
63 limits: Limits::NONE,
64 }
65 }
66
67 pub fn read(path: &Path) -> Result<Self, InputError> {
73 let name = path.display().to_string();
74 match std::fs::read_to_string(path) {
75 Ok(text) => Ok(Self::new(name, text)),
76 Err(error) => Err(InputError::whole(name, error.to_string())),
77 }
78 }
79
80 #[must_use]
82 pub fn name(&self) -> &str {
83 &self.name
84 }
85
86 #[must_use]
88 pub fn text(&self) -> &str {
89 &self.text
90 }
91
92 #[must_use]
97 pub fn frontmatter(&self) -> Option<(Self, &str)> {
98 let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
99 let rest = text
100 .strip_prefix("---\n")
101 .or_else(|| text.strip_prefix("---\r\n"))?;
102 let mut offset = 0;
103 for line in rest.split_inclusive('\n') {
104 if line.trim_end() == "---" {
105 let matter = Self {
106 name: self.name.clone(),
107 text: rest[..offset].to_owned(),
108 lines_before: self.lines_before + 1,
109 limits: self.limits.clone(),
110 };
111 return Some((matter, &rest[offset + line.len()..]));
112 }
113 offset += line.len();
114 }
115 None
116 }
117
118 #[must_use]
121 pub fn limits(mut self, limits: Limits) -> Self {
122 self.limits = limits;
123 self
124 }
125
126 pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
136 self.parse_within(
137 &limits::effective(&self.limits).budget(),
138 Raise::InRepository,
139 )
140 }
141
142 fn parse_within<T: DeserializeOwned>(
144 &self,
145 budget: &Budget,
146 raise: Raise,
147 ) -> Result<T, InputError> {
148 if let Some(limit) = budget.max_reader_input_bytes
149 && self.text.len() > limit
150 {
151 return Err(InputError::whole(
152 self.name.clone(),
153 format!(
154 "is {} bytes, which {}",
155 self.text.len(),
156 Exceeded::input_bytes(limit, raise)
157 ),
158 ));
159 }
160 let options = serde_saphyr::options! {
161 budget: Some(budget.clone()),
162 strict_booleans: true,
163 duplicate_keys: DuplicateKeyPolicy::Error,
164 merge_keys: MergeKeyPolicy::Error,
165 with_snippet: false,
166 };
167 serde_saphyr::from_str_with_options(&self.text, options).map_err(|error| {
168 let location = error.location();
169 InputError {
170 name: self.name.clone(),
171 problems: vec![Problem {
172 line: location
173 .and_then(|at| usize::try_from(at.line()).ok())
174 .map(|line| line + self.lines_before),
175 column: location.and_then(|at| usize::try_from(at.column()).ok()),
176 field: String::new(),
177 message: self.message(error.without_snippet(), budget, raise),
178 }],
179 }
180 })
181 }
182
183 fn message(&self, error: &Error, budget: &Budget, raise: Raise) -> String {
187 if let Error::Budget { breach, .. } = error
188 && let Some(exceeded) = Exceeded::breach(breach, budget, raise)
189 {
190 return exceeded.to_string();
191 }
192 let Error::AliasError { msg, locations } = error else {
193 return error.render_with_formatter(&DefaultMessageFormatter.with_localizer(&Unplaced));
194 };
195 let defined = locations.defined_location;
196 let (text, innermost) = baked_positions(msg);
197 let (line, column) = innermost.unwrap_or((
198 usize::try_from(defined.line()).unwrap_or_default(),
199 usize::try_from(defined.column()).unwrap_or_default(),
200 ));
201 let line = line + self.lines_before;
202 format!("{text} (defined at line {line}, column {column})")
203 }
204
205 pub fn check<T: Validate + ?Sized>(
213 &self,
214 value: &T,
215 context: &T::Context,
216 ) -> Result<(), InputError> {
217 let Err(report) = value.validate_with(context) else {
218 return Ok(());
219 };
220 let document = yamled::Document::parse(self.text.as_str()).ok();
221 let problems = report
222 .iter()
223 .map(|(path, error)| {
224 let field = path.to_string();
225 let location = document
226 .as_ref()
227 .and_then(|document| document.locate_nearest(&yaml_path(&field)));
228 Problem {
229 line: location.map(|at| at.line + self.lines_before),
230 column: location.map(|at| at.column),
231 field,
232 message: error.message().to_owned(),
233 }
234 })
235 .collect();
236 Err(InputError {
237 name: self.name.clone(),
238 problems,
239 })
240 }
241
242 pub fn load<T>(&self) -> Result<T, InputError>
248 where
249 T: DeserializeOwned + Validate,
250 T::Context: Default,
251 {
252 let value = self.parse::<T>()?;
253 self.check(&value, &T::Context::default())?;
254 Ok(value)
255 }
256}
257
258struct Unplaced;
262
263impl Localizer for Unplaced {
264 fn attach_location<'a>(&self, base: Cow<'a, str>, _: Location) -> Cow<'a, str> {
265 base
266 }
267}
268
269fn baked_positions(msg: &str) -> (&str, Option<(usize, usize)>) {
277 let mut text = msg;
278 let mut innermost = None;
279 loop {
280 if let Some((rest, _)) = trailing_position(text, " (defined at line ", ")") {
281 text = rest;
282 } else if let Some((rest, at)) = trailing_position(text, " at line ", "") {
283 text = rest;
284 innermost = Some(at);
285 } else {
286 break;
287 }
288 }
289 (text, innermost)
290}
291
292fn trailing_position<'a>(
294 text: &'a str,
295 lead: &str,
296 close: &str,
297) -> Option<(&'a str, (usize, usize))> {
298 let (rest, position) = text.strip_suffix(close)?.rsplit_once(lead)?;
299 let (line, column) = position.split_once(", column ")?;
300 Some((rest, (line.parse().ok()?, column.parse().ok()?)))
301}
302
303fn yaml_path(field: &str) -> yamled::Path {
307 let mut path = yamled::Path::root();
308 for part in field.split('.') {
309 let mut pieces = part.split('[');
310 if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
311 path = path.key(key);
312 }
313 for index in pieces {
314 let index = index.trim_end_matches(']');
315 path = match index.parse::<usize>() {
316 Ok(index) => path.index(index),
317 Err(_) => path.key(index),
318 };
319 }
320 }
321 path
322}
323
324impl InputError {
325 fn whole(name: String, message: String) -> Self {
326 Self {
327 name,
328 problems: vec![Problem {
329 line: None,
330 column: None,
331 field: String::new(),
332 message,
333 }],
334 }
335 }
336}
337
338impl fmt::Display for InputError {
341 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
342 for (index, problem) in self.problems.iter().enumerate() {
343 if index > 0 {
344 writeln!(formatter)?;
345 }
346 write!(formatter, "{}", self.name)?;
347 if let Some(line) = problem.line {
348 write!(formatter, ":{line}")?;
349 if let Some(column) = problem.column {
350 write!(formatter, ":{column}")?;
351 }
352 }
353 if problem.field.is_empty() {
354 write!(formatter, ": {}", problem.message)?;
355 } else {
356 write!(formatter, ": {}: {}", problem.field, problem.message)?;
357 }
358 }
359 Ok(())
360 }
361}
362
363impl std::error::Error for InputError {}
364
365#[cfg(test)]
366mod tests {
367 use super::baked_positions;
368
369 #[test]
370 fn every_written_position_is_taken_off_the_text() {
371 assert_eq!(
372 baked_positions("invalid u16 (defined at line 2, column 3)"),
373 ("invalid u16", None)
374 );
375 assert_eq!(
376 baked_positions(
377 "invalid u16 at line 2, column 9 (defined at line 2, column 9) at line 3, column 7"
378 ),
379 ("invalid u16", Some((2, 9)))
380 );
381 }
382}