1use std::borrow::Cow;
12use std::fmt;
13use std::path::Path;
14
15use garde::Validate;
16use serde::de::DeserializeOwned;
17use serde_saphyr::{
18 Budget, DefaultMessageFormatter, DuplicateKeyPolicy, Error, Localizer, Location, MergeKeyPolicy,
19};
20
21mod limits;
22#[cfg(feature = "input-toml")]
23mod toml_text;
24
25use limits::{Exceeded, Raise};
26pub use limits::{Limits, Loosened, REPO_FILE, set_default_limits, use_repo_limits};
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum Format {
35 Yaml,
37 #[cfg(feature = "input-toml")]
39 Toml,
40}
41
42impl Format {
43 pub fn of(path: &Path) -> Result<Self, InputError> {
52 let name = path.display().to_string();
53 let extension = path
54 .extension()
55 .map(|extension| extension.to_string_lossy().to_ascii_lowercase());
56 match extension.as_deref() {
57 Some("yaml" | "yml") => Ok(Self::Yaml),
58 #[cfg(feature = "input-toml")]
59 Some("toml") => Ok(Self::Toml),
60 #[cfg(not(feature = "input-toml"))]
61 Some("toml") => Err(InputError::whole(
62 name,
63 format!("`.toml` needs ctl-core's input-toml feature; {ACCEPTED}"),
64 )),
65 Some(other) => Err(InputError::whole(
66 name,
67 format!("unsupported extension .{other}; {ACCEPTED}"),
68 )),
69 None => Err(InputError::whole(
70 name,
71 format!("no file extension; {ACCEPTED}"),
72 )),
73 }
74 }
75}
76
77#[cfg(feature = "input-toml")]
79const ACCEPTED: &str = "name the file *.toml, *.yaml, or *.yml";
80#[cfg(not(feature = "input-toml"))]
81const ACCEPTED: &str = "name the file *.yaml or *.yml";
82
83#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct Input {
86 name: String,
87 text: String,
88 lines_before: usize,
89 limits: Limits,
90 format: Format,
91}
92
93#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct InputError {
97 pub name: String,
99 pub problems: Vec<Problem>,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq)]
105pub struct Problem {
106 pub line: Option<usize>,
108 pub column: Option<usize>,
110 pub field: String,
113 pub message: String,
115}
116
117impl Input {
118 #[must_use]
121 pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
122 Self {
123 name: name.into(),
124 text: text.into(),
125 lines_before: 0,
126 limits: Limits::NONE,
127 format: Format::Yaml,
128 }
129 }
130
131 pub fn read(path: &Path) -> Result<Self, InputError> {
137 let name = path.display().to_string();
138 match std::fs::read_to_string(path) {
139 Ok(text) => Ok(Self::new(name, text)),
140 Err(error) => Err(InputError::whole(name, error.to_string())),
141 }
142 }
143
144 pub fn open(path: &Path) -> Result<Self, InputError> {
152 let format = Format::of(path)?;
153 Ok(Self::read(path)?.format(format))
154 }
155
156 #[must_use]
158 pub fn format(mut self, format: Format) -> Self {
159 self.format = format;
160 self
161 }
162
163 #[must_use]
165 pub fn name(&self) -> &str {
166 &self.name
167 }
168
169 #[must_use]
171 pub fn text(&self) -> &str {
172 &self.text
173 }
174
175 #[must_use]
181 pub fn frontmatter(&self) -> Option<(Self, &str)> {
182 let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
183 let rest = text
184 .strip_prefix("---\n")
185 .or_else(|| text.strip_prefix("---\r\n"))?;
186 let mut offset = 0;
187 for line in rest.split_inclusive('\n') {
188 if line.trim_end() == "---" {
189 let matter = Self {
190 name: self.name.clone(),
191 text: rest[..offset].to_owned(),
192 lines_before: self.lines_before + 1,
193 limits: self.limits.clone(),
194 format: Format::Yaml,
195 };
196 return Some((matter, &rest[offset + line.len()..]));
197 }
198 offset += line.len();
199 }
200 None
201 }
202
203 #[must_use]
206 pub fn limits(mut self, limits: Limits) -> Self {
207 self.limits = limits;
208 self
209 }
210
211 pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
224 self.parse_within(
225 &limits::effective(&self.limits).budget(),
226 Raise::InRepository,
227 )
228 }
229
230 fn parse_within<T: DeserializeOwned>(
232 &self,
233 budget: &Budget,
234 raise: Raise,
235 ) -> Result<T, InputError> {
236 if let Some(limit) = budget.max_reader_input_bytes
237 && self.text.len() > limit
238 {
239 return Err(InputError::whole(
240 self.name.clone(),
241 format!(
242 "is {} bytes, which {}",
243 self.text.len(),
244 Exceeded::input_bytes(limit, raise)
245 ),
246 ));
247 }
248 match self.format {
249 Format::Yaml => self.parse_yaml(budget, raise),
250 #[cfg(feature = "input-toml")]
251 Format::Toml => toml_text::parse(self, budget, raise),
252 }
253 }
254
255 fn parse_yaml<T: DeserializeOwned>(
257 &self,
258 budget: &Budget,
259 raise: Raise,
260 ) -> Result<T, InputError> {
261 let options = serde_saphyr::options! {
262 budget: Some(budget.clone()),
263 strict_booleans: true,
264 duplicate_keys: DuplicateKeyPolicy::Error,
265 merge_keys: MergeKeyPolicy::Error,
266 with_snippet: false,
267 };
268 serde_saphyr::from_str_with_options(&self.text, options).map_err(|error| {
269 let location = error.location();
270 InputError {
271 name: self.name.clone(),
272 problems: vec![Problem {
273 line: location
274 .and_then(|at| usize::try_from(at.line()).ok())
275 .map(|line| line + self.lines_before),
276 column: location.and_then(|at| usize::try_from(at.column()).ok()),
277 field: String::new(),
278 message: self.message(error.without_snippet(), budget, raise),
279 }],
280 }
281 })
282 }
283
284 fn message(&self, error: &Error, budget: &Budget, raise: Raise) -> String {
288 if let Error::Budget { breach, .. } = error
289 && let Some(exceeded) = Exceeded::breach(breach, budget, raise)
290 {
291 return exceeded.to_string();
292 }
293 let Error::AliasError { msg, locations } = error else {
294 return error.render_with_formatter(&DefaultMessageFormatter.with_localizer(&Unplaced));
295 };
296 let defined = locations.defined_location;
297 let (text, innermost) = baked_positions(msg);
298 let (line, column) = innermost.unwrap_or((
299 usize::try_from(defined.line()).unwrap_or_default(),
300 usize::try_from(defined.column()).unwrap_or_default(),
301 ));
302 let line = line + self.lines_before;
303 format!("{text} (defined at line {line}, column {column})")
304 }
305
306 pub fn check<T: Validate + ?Sized>(
314 &self,
315 value: &T,
316 context: &T::Context,
317 ) -> Result<(), InputError> {
318 let Err(report) = value.validate_with(context) else {
319 return Ok(());
320 };
321 let reported: Vec<_> = report
322 .iter()
323 .map(|(path, error)| (path.to_string(), error.message().to_owned()))
324 .collect();
325 let fields: Vec<_> = reported.iter().map(|(field, _)| field.as_str()).collect();
326 let places = self.places(&fields);
327 let problems = reported
328 .into_iter()
329 .zip(places)
330 .map(|((field, message), place)| Problem {
331 line: place.map(|(line, _)| line + self.lines_before),
332 column: place.map(|(_, column)| column),
333 field,
334 message,
335 })
336 .collect();
337 Err(InputError {
338 name: self.name.clone(),
339 problems,
340 })
341 }
342
343 fn places(&self, fields: &[&str]) -> Vec<Option<(usize, usize)>> {
346 match self.format {
347 Format::Yaml => {
348 let document = yamled::Document::parse(self.text.as_str()).ok();
349 fields
350 .iter()
351 .map(|field| {
352 document
353 .as_ref()
354 .and_then(|document| {
355 document.locate_nearest(&yaml_path(&field_steps(field)))
356 })
357 .map(|at| (at.line, at.column))
358 })
359 .collect()
360 }
361 #[cfg(feature = "input-toml")]
362 Format::Toml => toml_text::places(&self.text, fields),
363 }
364 }
365
366 pub fn load<T>(&self) -> Result<T, InputError>
372 where
373 T: DeserializeOwned + Validate,
374 T::Context: Default,
375 {
376 let value = self.parse::<T>()?;
377 self.check(&value, &T::Context::default())?;
378 Ok(value)
379 }
380}
381
382struct Unplaced;
386
387impl Localizer for Unplaced {
388 fn attach_location<'a>(&self, base: Cow<'a, str>, _: Location) -> Cow<'a, str> {
389 base
390 }
391}
392
393fn baked_positions(msg: &str) -> (&str, Option<(usize, usize)>) {
401 let mut text = msg;
402 let mut innermost = None;
403 loop {
404 if let Some((rest, _)) = trailing_position(text, " (defined at line ", ")") {
405 text = rest;
406 } else if let Some((rest, at)) = trailing_position(text, " at line ", "") {
407 text = rest;
408 innermost = Some(at);
409 } else {
410 break;
411 }
412 }
413 (text, innermost)
414}
415
416fn trailing_position<'a>(
418 text: &'a str,
419 lead: &str,
420 close: &str,
421) -> Option<(&'a str, (usize, usize))> {
422 let (rest, position) = text.strip_suffix(close)?.rsplit_once(lead)?;
423 let (line, column) = position.split_once(", column ")?;
424 Some((rest, (line.parse().ok()?, column.parse().ok()?)))
425}
426
427enum Step<'field> {
429 Key(&'field str),
431 Index(usize),
433}
434
435fn field_steps(field: &str) -> Vec<Step<'_>> {
439 let mut steps = Vec::new();
440 for part in field.split('.') {
441 let mut pieces = part.split('[');
442 if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
443 steps.push(Step::Key(key));
444 }
445 for index in pieces {
446 let index = index.trim_end_matches(']');
447 steps.push(match index.parse::<usize>() {
448 Ok(index) => Step::Index(index),
449 Err(_) => Step::Key(index),
450 });
451 }
452 }
453 steps
454}
455
456fn yaml_path(steps: &[Step<'_>]) -> yamled::Path {
458 steps
459 .iter()
460 .fold(yamled::Path::root(), |path, step| match step {
461 Step::Key(key) => path.key(*key),
462 Step::Index(index) => path.index(*index),
463 })
464}
465
466impl InputError {
467 fn whole(name: String, message: String) -> Self {
468 Self {
469 name,
470 problems: vec![Problem {
471 line: None,
472 column: None,
473 field: String::new(),
474 message,
475 }],
476 }
477 }
478}
479
480impl fmt::Display for InputError {
483 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
484 for (index, problem) in self.problems.iter().enumerate() {
485 if index > 0 {
486 writeln!(formatter)?;
487 }
488 write!(formatter, "{}", self.name)?;
489 if let Some(line) = problem.line {
490 write!(formatter, ":{line}")?;
491 if let Some(column) = problem.column {
492 write!(formatter, ":{column}")?;
493 }
494 }
495 if problem.field.is_empty() {
496 write!(formatter, ": {}", problem.message)?;
497 } else {
498 write!(formatter, ": {}: {}", problem.field, problem.message)?;
499 }
500 }
501 Ok(())
502 }
503}
504
505impl std::error::Error for InputError {}
506
507#[cfg(test)]
508mod tests {
509 use super::baked_positions;
510
511 #[test]
512 fn every_written_position_is_taken_off_the_text() {
513 assert_eq!(
514 baked_positions("invalid u16 (defined at line 2, column 3)"),
515 ("invalid u16", None)
516 );
517 assert_eq!(
518 baked_positions(
519 "invalid u16 at line 2, column 9 (defined at line 2, column 9) at line 3, column 7"
520 ),
521 ("invalid u16", Some((2, 9)))
522 );
523 }
524}