Skip to main content

schema_index_yaml/
parser.rs

1use schema_core::ParseFrom;
2
3use crate::SUPPORTED_VERSIONS;
4
5#[derive(thiserror::Error, Debug)]
6pub enum ParseError {
7    /// A YAML syntax or shape error, rendered with the offending source line and
8    /// a caret pointing at it (see the crate-private `render_yaml_error`).
9    #[error("{0}")]
10    Syntax(String),
11    #[error("unsupported schema version {got}; supported versions: {supported}")]
12    UnsupportedVersion { got: u8, supported: &'static str },
13}
14
15impl<T: AsRef<str>> ParseFrom<T> for super::SchemaYaml {
16    type Error = ParseError;
17
18    fn try_parse(value: T) -> Result<Self, Self::Error> {
19        let source = value.as_ref();
20        let result: super::SchemaYaml = serde_yaml::from_str(source)
21            .map_err(|error| ParseError::Syntax(render_yaml_error(source, &error)))?;
22
23        if !SUPPORTED_VERSIONS.contains(&result.version) {
24            return Err(ParseError::UnsupportedVersion {
25                got: result.version,
26                supported: "1",
27            });
28        }
29
30        Ok(result)
31    }
32}
33
34/// Turn a `serde_yaml` error into a human-readable message: a cleaned-up
35/// description plus, when the error carries a *trustworthy* location, the
36/// offending source line with a caret — the same shape the `toml` parser prints.
37///
38/// Field-level errors are the exception: a field parses through a `serde_yaml`
39/// `Value` round-trip (to find its type tag, see [`field`]), which discards
40/// source positions, so `serde_yaml` stamps such an error at the *start of the
41/// `fields` sequence* rather than the offending field. A caret there would point
42/// at the wrong field, so we omit it — the message already names the field by
43/// its type tag and document key, a more reliable locator than a wrong line.
44///
45/// [`field`]: crate::Field
46fn render_yaml_error(source: &str, error: &serde_yaml::Error) -> String {
47    let message = clean_message(&error.to_string());
48    match error.location() {
49        Some(location) if !is_field_scoped(&message) => {
50            render_snippet(source, location.line(), location.column(), &message)
51        }
52        _ => message,
53    }
54}
55
56/// Whether a (cleaned) message came from parsing a single field, and so carries
57/// an unreliable location. Field messages either name the field — `` `keyword`
58/// field `email`: … `` — or are the tag diagnostics that open with `field `.
59fn is_field_scoped(message: &str) -> bool {
60    message.starts_with('`') || message.starts_with("field ")
61}
62
63/// Tidy a raw `serde_yaml` message into our phrasing:
64/// - drop the trailing ` at line L column C` (the snippet shows it instead),
65/// - hide the internal `field` key we inject while parsing (see [`field`]) so it
66///   never appears in serde's "expected one of …" lists,
67/// - say "key" rather than serde's "field" (a schema field is a different thing).
68///
69/// [`field`]: crate::Field
70fn clean_message(raw: &str) -> String {
71    let without_location = match raw.rfind(" at line ") {
72        Some(idx) => raw.get(..idx).unwrap_or(raw),
73        None => raw,
74    };
75
76    // Drop serde's leading `fields:` path breadcrumb(s); the snippet already
77    // points at the exact line, so the breadcrumb only stutters.
78    let mut trimmed = without_location;
79    while let Some(rest) = trimmed.strip_prefix("fields: ") {
80        trimmed = rest;
81    }
82
83    trimmed
84        .replace("`field`, ", "")
85        .replace(", `field`", "")
86        .replace("unknown field", "unknown key")
87        .replace("missing field", "missing key")
88}
89
90/// Render `message` above the offending source line, with a caret under the
91/// reported column. `line`/`column` are 1-based, as `serde_yaml` reports them.
92fn render_snippet(source: &str, line: usize, column: usize, message: &str) -> String {
93    let text = line.checked_sub(1).and_then(|idx| source.lines().nth(idx));
94    let Some(text) = text else {
95        return format!("{message} (line {line}, column {column})");
96    };
97
98    let number = line.to_string();
99    let gutter = " ".repeat(number.len());
100    let caret_indent = " ".repeat(column.saturating_sub(1));
101    format!(
102        "{message}\n{gutter}--> line {line}, column {column}\n\
103         {gutter} |\n\
104         {number} | {text}\n\
105         {gutter} | {caret_indent}^"
106    )
107}