ctl_core/input.rs
1//! Declared input: read a file, parse it, validate it once, and report every
2//! problem on its file and line.
3//!
4//! Each format is its own feature. `input-yaml` parses YAML with
5//! `serde-saphyr`, and `input-toml` parses TOML with the `toml` crate; neither
6//! needs the other. `validate` adds `Input::check` and `Input::load`, which
7//! validate with garde, and `input` is `input-yaml` with `validate`. This is
8//! the one boundary where a CLI's config enters. Downstream code keeps the
9//! validated type and never checks it again.
10//!
11//! A check that runs after the read, such as one that needs a file the config
12//! names, places its problem with a [`FieldPath`] and [`Input::problem_at`],
13//! the way the loader places garde's.
14//!
15//! A text's [`Format`] is chosen, never guessed. [`Input::open`] reads it from
16//! the file's extension, and [`Input::new_with_format`] takes it for text in
17//! memory. With `input-yaml`, `Input::new` and `Input::read` are YAML.
18//!
19//! ```
20//! # #[cfg(feature = "input")]
21//! # {
22//! use ctl_core::input::Input;
23//! use serde::Deserialize;
24//!
25//! #[derive(Debug, Deserialize)]
26//! struct Config {
27//! name: String,
28//! }
29//!
30//! let config: Config = Input::new("toy.yaml", "name: release\n").parse()?;
31//! assert_eq!(config.name, "release");
32//!
33//! let error = Input::new("toy.yaml", "name: a\nname: b\n")
34//! .parse::<Config>()
35//! .unwrap_err();
36//! assert!(error.to_string().starts_with("toy.yaml"));
37//! # }
38//! # Ok::<(), ctl_core::input::InputError>(())
39//! ```
40
41use std::path::Path;
42use std::{fmt, io};
43
44#[cfg(feature = "validate")]
45use garde::Validate;
46use serde::de::DeserializeOwned;
47
48mod entries;
49mod field_path;
50mod limits;
51#[cfg(feature = "input-toml")]
52mod toml_entries;
53#[cfg(feature = "input-toml")]
54mod toml_text;
55#[cfg(feature = "input-yaml")]
56mod yaml_entries;
57#[cfg(feature = "input-yaml")]
58mod yaml_text;
59
60pub use entries::{Entry, Written};
61pub use field_path::{FieldPath, Place, Step};
62use limits::{Exceeded, Raise};
63pub use limits::{Limits, set_default_limits};
64#[cfg(feature = "input-yaml")]
65pub use limits::{Loosened, REPO_FILE, use_repo_limits};
66/// A value and the YAML tag written on its node, such as `!override` in
67/// `code_span_size: !override 0`: `Tagged(0, Some("!override".into()))`.
68/// A node with no tag reads as `Tagged(value, None)`, and an untagged enum
69/// inside reads the plain value of a tagged node.
70#[cfg(feature = "input-yaml")]
71pub use serde_saphyr::Tagged;
72
73/// What a placing answers with for a key of the path: where its value starts,
74/// or where the key itself is written.
75#[derive(Clone, Copy)]
76enum At {
77 Value,
78 Key,
79}
80
81/// How a text is written.
82///
83/// Non-exhaustive: each variant exists only with its format's feature.
84/// Cargo unifies features across a build, so a caller's `match` needs a
85/// wildcard arm.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum Format {
89 /// YAML, read by serde-saphyr and placed by yamled.
90 #[cfg(feature = "input-yaml")]
91 Yaml,
92 /// TOML, read and placed by the `toml` crate.
93 #[cfg(feature = "input-toml")]
94 Toml,
95}
96
97impl Format {
98 /// The format a file's extension names, compared without regard to case:
99 /// `.toml` is TOML, and `.yaml` and `.yml` are YAML.
100 ///
101 /// # Errors
102 ///
103 /// An [`InputError`] with no line for any other extension or none, naming
104 /// the extension and the accepted ones. One naming the feature for an
105 /// extension whose format's feature is off.
106 pub fn of(path: &Path) -> Result<Self, InputError> {
107 let name = path.display().to_string();
108 let extension = path
109 .extension()
110 .map(|extension| extension.to_string_lossy().to_ascii_lowercase());
111 match extension.as_deref() {
112 #[cfg(feature = "input-yaml")]
113 Some("yaml" | "yml") => Ok(Self::Yaml),
114 #[cfg(not(feature = "input-yaml"))]
115 Some(yaml @ ("yaml" | "yml")) => Err(InputError::whole(
116 name,
117 format!("`.{yaml}` needs ctl-core's input-yaml feature; {ACCEPTED}"),
118 )),
119 #[cfg(feature = "input-toml")]
120 Some("toml") => Ok(Self::Toml),
121 #[cfg(not(feature = "input-toml"))]
122 Some("toml") => Err(InputError::whole(
123 name,
124 format!("`.toml` needs ctl-core's input-toml feature; {ACCEPTED}"),
125 )),
126 Some(other) => Err(InputError::whole(
127 name,
128 format!("unsupported extension .{other}; {ACCEPTED}"),
129 )),
130 None => Err(InputError::whole(
131 name,
132 format!("no file extension; {ACCEPTED}"),
133 )),
134 }
135 }
136}
137
138/// What a refusal tells a person to name the file: the extensions this build
139/// reads.
140#[cfg(all(feature = "input-yaml", feature = "input-toml"))]
141const ACCEPTED: &str = "name the file *.toml, *.yaml, or *.yml";
142#[cfg(all(feature = "input-yaml", not(feature = "input-toml")))]
143const ACCEPTED: &str = "name the file *.yaml or *.yml";
144#[cfg(all(feature = "input-toml", not(feature = "input-yaml")))]
145const ACCEPTED: &str = "name the file *.toml";
146
147/// A named text, ready to parse and validate.
148///
149/// The name is usually the file's path, and appears in every problem. Build
150/// one with [`Input::new_with_format`] for text in memory, or [`Input::open`]
151/// for a file. With `input-yaml`, `Input::new` and `Input::read` build a YAML
152/// one.
153#[derive(Clone, Debug, PartialEq, Eq)]
154pub struct Input {
155 name: String,
156 text: String,
157 lines_before: usize,
158 limits: Limits,
159 format: Format,
160 /// The schema fields serde flattens into their parents, which
161 /// [`Input::flattened`] declares.
162 #[cfg(feature = "validate")]
163 flattened: Vec<FieldPath>,
164 /// The YAML document that placing reads, built on first use.
165 #[cfg(feature = "input-yaml")]
166 yaml_document: yaml_text::Located,
167}
168
169/// Why an input was refused: every problem found, each placed on a line when
170/// the text allows it.
171///
172/// `Display` prints one problem per line as `name:line:column: field: message`,
173/// leaving out what is not known.
174///
175/// A file that could not be read keeps its [`io::ErrorKind`], which
176/// [`Self::io_failure`] returns. So a caller can treat a missing file apart
177/// from a refused one. A field can join this type in a minor release.
178#[derive(Clone, Debug, PartialEq, Eq)]
179#[non_exhaustive]
180pub struct InputError {
181 /// The input's name, usually its path.
182 pub name: String,
183 /// What is wrong, in the order found.
184 pub problems: Vec<Problem>,
185 /// The I/O failure, for an input that could not be read.
186 io_failure: Option<io::ErrorKind>,
187}
188
189/// One thing wrong with an input.
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub struct Problem {
192 /// 1-based line, when known.
193 pub line: Option<usize>,
194 /// 1-based column, when known.
195 pub column: Option<usize>,
196 /// The field it concerns, as garde names it (`packages[1].name`); empty
197 /// for a problem with the whole text.
198 pub field: String,
199 /// What a person changes to fix it.
200 pub message: String,
201}
202
203impl Input {
204 /// A YAML input from text already in memory, whatever the extension of
205 /// `name`. Set another format with [`Input::format`].
206 #[cfg(feature = "input-yaml")]
207 #[must_use]
208 pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
209 Self::new_with_format(name, text, Format::Yaml)
210 }
211
212 /// An input of `format` from text already in memory, whatever the
213 /// extension of `name`. It needs only `format`'s feature, so a build
214 /// without YAML builds text in memory with it.
215 #[must_use]
216 pub fn new_with_format(
217 name: impl Into<String>,
218 text: impl Into<String>,
219 format: Format,
220 ) -> Self {
221 Self {
222 name: name.into(),
223 text: text.into(),
224 lines_before: 0,
225 limits: Limits::NONE,
226 format,
227 #[cfg(feature = "validate")]
228 flattened: Vec::new(),
229 #[cfg(feature = "input-yaml")]
230 yaml_document: yaml_text::Located::default(),
231 }
232 }
233
234 /// Read a YAML file, named by its path, whatever its extension.
235 ///
236 /// # Errors
237 ///
238 /// An [`InputError`] with the I/O failure.
239 #[cfg(feature = "input-yaml")]
240 pub fn read(path: &Path) -> Result<Self, InputError> {
241 let (name, text) = read_text(path)?;
242 Ok(Self::new(name, text))
243 }
244
245 /// Read a file and take its [`Format`] from its extension, as
246 /// [`Format::of`] does. The extension is checked before the file is read.
247 ///
248 /// # Errors
249 ///
250 /// An [`InputError`] for an extension [`Format::of`] refuses, or with the
251 /// I/O failure.
252 pub fn open(path: &Path) -> Result<Self, InputError> {
253 let format = Format::of(path)?;
254 let (name, text) = read_text(path)?;
255 Ok(Self::new_with_format(name, text, format))
256 }
257
258 /// Set how the text is written, over what the constructor took.
259 #[must_use]
260 pub fn format(mut self, format: Format) -> Self {
261 self.format = format;
262 self
263 }
264
265 /// The name given at construction, usually the file's path.
266 #[must_use]
267 pub fn name(&self) -> &str {
268 &self.name
269 }
270
271 /// The text as read, unchanged.
272 #[must_use]
273 pub fn text(&self) -> &str {
274 &self.text
275 }
276
277 /// Split YAML frontmatter from a Markdown body.
278 ///
279 /// The frontmatter is the text between a first line of `---` and the next
280 /// line of `---`. It is YAML whatever this input's format, and problems in
281 /// it keep the line numbers of the whole file. The second item is the body
282 /// after the closing line. Returns `None` when the text does not start with
283 /// a `---` line, or never closes it.
284 #[cfg(feature = "input-yaml")]
285 #[must_use]
286 pub fn frontmatter(&self) -> Option<(Self, &str)> {
287 let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
288 let rest = text
289 .strip_prefix("---\n")
290 .or_else(|| text.strip_prefix("---\r\n"))?;
291 let mut offset = 0;
292 for line in rest.split_inclusive('\n') {
293 if line.trim_end() == "---" {
294 let matter = Self {
295 name: self.name.clone(),
296 text: rest[..offset].to_owned(),
297 lines_before: self.lines_before + 1,
298 limits: self.limits.clone(),
299 format: Format::Yaml,
300 #[cfg(feature = "validate")]
301 flattened: self.flattened.clone(),
302 yaml_document: yaml_text::Located::default(),
303 };
304 return Some((matter, &rest[offset + line.len()..]));
305 }
306 offset += line.len();
307 }
308 None
309 }
310
311 /// Override limits for this input alone, above every other tier. See
312 /// [`Limits`] for the order.
313 #[must_use]
314 pub fn limits(mut self, limits: Limits) -> Self {
315 self.limits = limits;
316 self
317 }
318
319 /// Declare that the schema field at `field` is flattened into its parent
320 /// with `#[serde(flatten)]`, so the file writes its entries directly under
321 /// the parent.
322 ///
323 /// garde names the field in each problem's path all the same, and serde
324 /// gives no sign of the flattening at run time, so the schema declares it.
325 /// [`Input::check`] then drops the field's step from each garde path whose
326 /// leading steps are `field`, before it places the problem and prints its
327 /// field. `field` is written in the schema's terms, the way garde names it,
328 /// and each call adds one field. A declaration that matches no garde path
329 /// changes nothing. [`Input::place`], [`Input::place_key`], and
330 /// [`Input::problem_at`] take paths in the file's terms, so a declaration
331 /// does not touch them.
332 ///
333 /// # Examples
334 ///
335 /// verctl's `assets` holds its targets in a flattened map field `targets`.
336 /// Without the declaration, the problem prints as
337 /// `assets.targets.linux-x64.files` and sits on the line below `assets:`.
338 ///
339 /// ```
340 /// # #[cfg(feature = "input")]
341 /// # {
342 /// use std::collections::BTreeMap;
343 ///
344 /// use ctl_core::input::{FieldPath, Input};
345 /// use ctl_core::validate::at_least_one;
346 /// use garde::Validate;
347 /// use serde::Deserialize;
348 ///
349 /// #[derive(Debug, Deserialize, Validate)]
350 /// struct Config {
351 /// #[garde(dive)]
352 /// assets: Assets,
353 /// }
354 ///
355 /// #[derive(Debug, Deserialize, Validate)]
356 /// struct Assets {
357 /// #[serde(flatten)]
358 /// #[garde(dive)]
359 /// targets: BTreeMap<String, Target>,
360 /// }
361 ///
362 /// #[derive(Debug, Deserialize, Validate)]
363 /// struct Target {
364 /// #[garde(custom(at_least_one("file")))]
365 /// files: Vec<String>,
366 /// }
367 ///
368 /// let error = Input::new("ver.yaml", "assets:\n linux-x64:\n files: []\n")
369 /// .flattened(FieldPath::root().key("assets").key("targets"))
370 /// .load::<Config>()
371 /// .unwrap_err();
372 /// assert_eq!(
373 /// error.to_string(),
374 /// "ver.yaml:3:12: assets.linux-x64.files: must declare at least one file"
375 /// );
376 /// # }
377 /// ```
378 #[cfg(feature = "validate")]
379 #[must_use]
380 pub fn flattened(mut self, field: FieldPath) -> Self {
381 self.flattened.push(field);
382 self
383 }
384
385 /// `path` without the step of each declared flattened field it passes
386 /// through, or `None` when it passes through none.
387 #[cfg(feature = "validate")]
388 fn unflatten(&self, path: &FieldPath) -> Option<FieldPath> {
389 let positions: Vec<usize> = self
390 .flattened
391 .iter()
392 .filter(|field| path.starts_with(field))
393 .filter_map(|field| field.steps().len().checked_sub(1))
394 .collect();
395 (!positions.is_empty()).then(|| path.without(&positions))
396 }
397
398 /// Parse the text into `T`, without validating it.
399 ///
400 /// In YAML only `true` and `false` are booleans, and a duplicate key or a
401 /// merge key (`<<`) is refused. In TOML the format itself refuses a
402 /// duplicate key.
403 ///
404 /// The text must fit the limits in force, decided field by field through
405 /// the tiers [`Limits`] lists. TOML reads only `max_reader_input_bytes`,
406 /// `max_depth`, and `max_nodes`, because the other fields name YAML
407 /// syntax.
408 ///
409 /// # Errors
410 ///
411 /// An [`InputError`] with the parser's problem and its line. A limit
412 /// problem names the limit, its value, and where to raise it, or, without
413 /// `input-yaml`, that the tool sets it.
414 pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
415 self.parse_within(&limits::effective(&self.limits), Raise::FOR_TEXT)
416 }
417
418 /// [`Self::parse`] under exactly `limits`, whatever the tiers say.
419 fn parse_within<T: DeserializeOwned>(
420 &self,
421 limits: &Limits,
422 raise: Raise,
423 ) -> Result<T, InputError> {
424 self.fits(limits, raise)?;
425 match self.format {
426 #[cfg(feature = "input-yaml")]
427 Format::Yaml => yaml_text::parse(self, &limits.budget(), raise),
428 #[cfg(feature = "input-toml")]
429 Format::Toml => toml_text::parse(self, limits.bounds(), raise),
430 }
431 }
432
433 /// Whether the text is within the size `limits` allow.
434 fn fits(&self, limits: &Limits, raise: Raise) -> Result<(), InputError> {
435 let bounds = limits.bounds();
436 if self.text.len() > bounds.input_bytes {
437 return Err(InputError::whole(
438 self.name.clone(),
439 format!(
440 "is {} bytes, which {}",
441 self.text.len(),
442 Exceeded::input_bytes(bounds.input_bytes, raise)
443 ),
444 ));
445 }
446 Ok(())
447 }
448
449 /// Validate a parsed `value` against `context`.
450 ///
451 /// Each of garde's problems lands on the line of the field it names, or
452 /// of its nearest written ancestor. The place comes from the parts of
453 /// garde's path through [`Input::place`], so a key that holds a `.` is
454 /// found as one key. `Problem::field` is garde's printed path, for display
455 /// only. A path through a field [`Input::flattened`] declares loses that
456 /// field's step, and its `Problem::field` is the shorter path as
457 /// [`FieldPath`] prints it.
458 ///
459 /// # Errors
460 ///
461 /// An [`InputError`] with every problem garde reports.
462 #[cfg(feature = "validate")]
463 pub fn check<T: Validate + ?Sized>(
464 &self,
465 value: &T,
466 context: &T::Context,
467 ) -> Result<(), InputError> {
468 let Err(report) = value.validate_with(context) else {
469 return Ok(());
470 };
471 let reported: Vec<_> = report
472 .iter()
473 .map(|(path, error)| {
474 let steps = FieldPath::from(path);
475 let (field, steps) = match self.unflatten(&steps) {
476 Some(kept) => (kept.to_string(), kept),
477 None => (path.to_string(), steps),
478 };
479 (field, steps, error.message().to_owned())
480 })
481 .collect();
482 let paths: Vec<_> = reported.iter().map(|(_, path, _)| path.clone()).collect();
483 let places = self.places(&paths, At::Value);
484 let problems = reported
485 .into_iter()
486 .zip(places)
487 .map(|((field, _, message), place)| Problem {
488 line: place.map(|at| at.line),
489 column: place.map(|at| at.column),
490 field,
491 message,
492 })
493 .collect();
494 Err(InputError::of(self.name.clone(), problems))
495 }
496
497 /// Where `path` is written in the text, or where its nearest written
498 /// ancestor is.
499 ///
500 /// The line counts the whole file, so a frontmatter input adds the lines
501 /// before it. A path the text lacks gives the place of the last step that
502 /// exists, and a caller that needs an exact hit compares the result with
503 /// the place of the path's parent. `None` when the text does not parse.
504 ///
505 /// `None` too when the text goes past the limits in force, as
506 /// [`Input::parse`] would refuse it, so placing never parses a text the
507 /// limits keep out. A YAML input checks `max_reader_input_bytes`,
508 /// `max_depth` and `max_nodes` before it hands the text to yamled, which
509 /// has no budget. A TOML input checks `max_reader_input_bytes`, and
510 /// the parse of its tree is the one [`Input::parse`] does first.
511 ///
512 /// A YAML input parses its text for the first placing and keeps the
513 /// result, so [`Input::check`] and later calls read one document. A TOML
514 /// input parses its text on each call.
515 ///
516 /// ```
517 /// # #[cfg(feature = "input-yaml")]
518 /// # {
519 /// use ctl_core::input::{FieldPath, Input};
520 ///
521 /// let input = Input::new("app.yaml", "files:\n 'docs/**/*.md':\n label: ''\n");
522 /// let path = FieldPath::root()
523 /// .key("files")
524 /// .key("docs/**/*.md")
525 /// .key("label");
526 /// let at = input.place(&path).map(|at| (at.line, at.column));
527 /// assert_eq!(at, Some((3, 12)));
528 /// # }
529 /// ```
530 #[must_use]
531 pub fn place(&self, path: &FieldPath) -> Option<Place> {
532 self.places(std::slice::from_ref(path), At::Value)
533 .pop()
534 .flatten()
535 }
536
537 /// Where the last step of `path` is written as a key, or where its nearest
538 /// written ancestor's key is.
539 ///
540 /// [`Input::place`] gives where a value starts. For a scalar or a flow
541 /// value that is the line of the key, and for a block mapping or a block
542 /// list it is the first line below it. A fault about the key itself, such
543 /// as a key that names nothing, belongs on the key, and this finds it.
544 ///
545 /// A step that is an index has no key, and neither has the root, so they
546 /// give the place of their value, as `place` does. A path the text lacks
547 /// gives the key of the last step that exists. The line counts the whole
548 /// file, `None` is for a text that does not parse or goes past the limits,
549 /// and a YAML document is built once and shared with [`Input::place`].
550 ///
551 /// ```
552 /// # #[cfg(feature = "input-yaml")]
553 /// # {
554 /// use ctl_core::input::{FieldPath, Input};
555 ///
556 /// let input = Input::new("app.yaml", "files:\n 'docs/**/*.md':\n label: ''\n");
557 /// let glob = FieldPath::root().key("files").key("docs/**/*.md");
558 /// let value = input.place(&glob).map(|at| (at.line, at.column));
559 /// let key = input.place_key(&glob).map(|at| (at.line, at.column));
560 /// assert_eq!(value, Some((3, 5)));
561 /// assert_eq!(key, Some((2, 3)));
562 /// # }
563 /// ```
564 #[must_use]
565 pub fn place_key(&self, path: &FieldPath) -> Option<Place> {
566 self.places(std::slice::from_ref(path), At::Key)
567 .pop()
568 .flatten()
569 }
570
571 /// A [`Problem`] for `path` with `message`, placed as [`Input::place`]
572 /// places it, with the path printed as garde prints a field.
573 ///
574 /// A CLI uses it for a check that runs after the read, such as one that
575 /// needs a file the config names.
576 #[must_use]
577 pub fn problem_at(&self, path: &FieldPath, message: impl Into<String>) -> Problem {
578 let place = self.place(path);
579 Problem {
580 line: place.map(|at| at.line),
581 column: place.map(|at| at.column),
582 field: path.to_string(),
583 message: message.into(),
584 }
585 }
586
587 /// The place of each path, with the frontmatter lines added.
588 fn places(&self, paths: &[FieldPath], at: At) -> Vec<Option<Place>> {
589 let limits = limits::effective(&self.limits);
590 if self.text.len() > limits.bounds().input_bytes {
591 return vec![None; paths.len()];
592 }
593 let found = match self.format {
594 #[cfg(feature = "input-yaml")]
595 Format::Yaml => yaml_text::places(self, &limits, paths, at),
596 #[cfg(feature = "input-toml")]
597 Format::Toml => toml_text::places(&self.text, paths, at),
598 };
599 found
600 .into_iter()
601 .map(|at| at.map(|(line, column)| Place::new(line + self.lines_before, column)))
602 .collect()
603 }
604
605 /// Parse the text into `T`, then validate it with `T`'s default context.
606 ///
607 /// # Errors
608 ///
609 /// As [`Input::parse`] and [`Input::check`].
610 #[cfg(feature = "validate")]
611 pub fn load<T>(&self) -> Result<T, InputError>
612 where
613 T: DeserializeOwned + Validate,
614 T::Context: Default,
615 {
616 let value = self.parse::<T>()?;
617 self.check(&value, &T::Context::default())?;
618 Ok(value)
619 }
620}
621
622/// The file at `path`, named by its path, or the I/O failure.
623fn read_text(path: &Path) -> Result<(String, String), InputError> {
624 let name = path.display().to_string();
625 match std::fs::read_to_string(path) {
626 Ok(text) => Ok((name, text)),
627 Err(error) => Err(InputError::unread(name, &error)),
628 }
629}
630
631impl InputError {
632 /// A refusal of the whole input, not placed on a line.
633 fn whole(name: String, message: String) -> Self {
634 Self::of(
635 name,
636 vec![Problem {
637 line: None,
638 column: None,
639 field: String::new(),
640 message,
641 }],
642 )
643 }
644
645 /// The input `name` could not be read: the failure's text is the one
646 /// problem, and its `io::ErrorKind` is kept.
647 fn unread(name: String, error: &io::Error) -> Self {
648 Self {
649 io_failure: Some(error.kind()),
650 ..Self::whole(name, error.to_string())
651 }
652 }
653
654 /// The problems found in the input `name`, with no I/O failure.
655 const fn of(name: String, problems: Vec<Problem>) -> Self {
656 Self {
657 name,
658 problems,
659 io_failure: None,
660 }
661 }
662
663 /// The I/O failure when the file could not be read, such as
664 /// [`io::ErrorKind::NotFound`]. `None` when the text was read and refused.
665 #[must_use]
666 pub const fn io_failure(&self) -> Option<io::ErrorKind> {
667 self.io_failure
668 }
669}
670
671/// One problem per line: `name:line:column: field: message`, leaving out
672/// what is not known.
673impl fmt::Display for InputError {
674 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
675 for (index, problem) in self.problems.iter().enumerate() {
676 if index > 0 {
677 writeln!(formatter)?;
678 }
679 write!(formatter, "{}", self.name)?;
680 if let Some(line) = problem.line {
681 write!(formatter, ":{line}")?;
682 if let Some(column) = problem.column {
683 write!(formatter, ":{column}")?;
684 }
685 }
686 if problem.field.is_empty() {
687 write!(formatter, ": {}", problem.message)?;
688 } else {
689 write!(formatter, ": {}: {}", problem.field, problem.message)?;
690 }
691 }
692 Ok(())
693 }
694}
695
696impl std::error::Error for InputError {}