serde-saphyr 2.0.0

YAML (de)serializer for Serde, emphasizing panic-free parsing, fast builds and good error reporting. Supports properties, include, tag and comment capture
Documentation
#![cfg(feature = "deserialize")]

use serde::Deserialize;
use serde_saphyr::{Error, SnippetMode};

fn render(error: &Error, source_name: Option<&str>) -> String {
    error.render_with_options(serde_saphyr::render_options! {
        source_name: source_name,
    })
}

#[test]
fn string_and_reader_source_names_are_deferred_and_combine_with_line_offsets() {
    for error in [
        serde_saphyr::from_str::<u8>("eighty\n").unwrap_err(),
        serde_saphyr::from_reader::<_, u8>("eighty\n".as_bytes()).unwrap_err(),
    ] {
        let baseline = error.to_string();
        let original_location = error.location().unwrap();
        let original_regions = match &error {
            Error::WithSnippet { regions, .. } => regions.clone(),
            _ => panic!("expected snippet"),
        };
        assert_eq!(render(&error, None), baseline);
        let name = String::from("article.md");
        let renamed = render(&error, Some(&name));
        assert!(renamed.contains("article.md:1:1"), "{renamed}");
        assert!(renamed.contains("1 | eighty"), "{renamed}");
        assert_eq!(render(&error, Some(&name)), renamed);

        let offset = error.render_with_options(serde_saphyr::render_options! {
            source_name: Some(&name),
            line_offset: 9_999,
        });
        assert!(offset.contains("article.md:10000:1"), "{offset}");
        assert!(offset.contains("10000 | eighty"), "{offset}");
        assert_eq!(error.location(), Some(original_location));
        assert_eq!(error.location().unwrap().span(), original_location.span());
        let Error::WithSnippet { regions, .. } = &error else {
            unreachable!()
        };
        assert_eq!(*regions, original_regions);
        assert_eq!(error.to_string(), baseline);
    }
}

#[test]
fn source_name_does_not_change_plain_rendering() {
    let error = serde_saphyr::from_str::<u8>("eighty").unwrap_err();
    for error in [&error, error.without_snippet()] {
        let plain = error.render_with_options(serde_saphyr::render_options! {
            snippets: SnippetMode::Off,
        });
        assert_eq!(
            error.render_with_options(serde_saphyr::render_options! {
                source_name: Some("article.md"),
                snippets: SnippetMode::Off,
            }),
            plain
        );
    }
    assert_eq!(
        render(error.without_snippet(), Some("article.md")),
        error.without_snippet().to_string()
    );
}

#[test]
fn names_escape_control_characters_and_an_empty_name_is_an_explicit_override() {
    let error = serde_saphyr::from_str::<u8>("eighty").unwrap_err();
    let rendered = render(&error, Some("part\n\u{1b}\u{2028}.md"));
    assert!(
        rendered.contains(r"part\n\u{1b}\u{2028}.md:1:1"),
        "{rendered}"
    );
    assert!(!rendered.contains('\u{1b}'));
    assert!(!rendered.contains('\u{2028}'));
    let rendered = render(&error, Some(""));
    assert!(rendered.contains("--> :1:1"), "{rendered}");
    assert!(!rendered.contains("input"), "{rendered}");
}

#[derive(Debug, Deserialize)]
struct Port {
    #[serde(rename = "port")]
    _port: u16,
}

#[test]
fn alias_use_and_distinct_failing_value_headers_use_the_override() {
    #[derive(Debug, Deserialize)]
    struct Config {
        #[serde(rename = "base")]
        _base: std::collections::BTreeMap<String, String>,
        #[serde(rename = "copy")]
        _copy: Port,
    }
    let yaml = "base: &b\n  port: eighty\ncopy: *b\n";
    for error in [
        serde_saphyr::from_str::<Config>(yaml).unwrap_err(),
        serde_saphyr::from_reader::<_, Config>(yaml.as_bytes()).unwrap_err(),
    ] {
        let rendered = render(&error, Some("article.md"));
        assert!(rendered.contains("article.md:3:7"), "{rendered}");
        assert!(rendered.contains("article.md:2:9"), "{rendered}");
        assert!(rendered.contains("defined here"), "{rendered}");
        assert!(!rendered.contains("input>"), "{rendered}");
    }
}

#[cfg(feature = "include")]
#[test]
fn included_sources_keep_their_names_while_root_and_include_site_use_the_override() {
    use serde_saphyr::{IncludeRequest, IncludeResolveError, InputSource, ResolvedInclude};
    let options = || {
        serde_saphyr::options! {}.with_include_resolver(
            |_: IncludeRequest| -> Result<ResolvedInclude, IncludeResolveError> {
                Ok(ResolvedInclude::new(
                    "port.yaml",
                    "port.yaml",
                    InputSource::from_string("eighty\n".to_owned()),
                ))
            },
        )
    };
    let yaml = "port: !include port.yaml\n";
    for error in [
        serde_saphyr::from_str_with_options::<Port>(yaml, options()).unwrap_err(),
        serde_saphyr::from_reader_with_options::<_, Port>(yaml.as_bytes(), options()).unwrap_err(),
    ] {
        let rendered = render(&error, Some("article.md"));
        assert!(rendered.contains("port.yaml:1:1"), "{rendered}");
        assert!(rendered.contains("article.md:1:16"), "{rendered}");
        assert!(rendered.contains("included from here"), "{rendered}");
    }
    let error =
        serde_saphyr::from_str_with_options::<Port>("port: eighty\n", options()).unwrap_err();
    let rendered = render(&error, Some("article.md"));
    assert!(rendered.contains("article.md:1:7"), "{rendered}");
}

#[cfg(any(feature = "garde", feature = "validator"))]
mod validation {
    use super::*;

    #[derive(Debug, Deserialize)]
    #[cfg_attr(feature = "garde", derive(garde::Validate))]
    #[cfg_attr(feature = "validator", derive(validator::Validate))]
    struct Fields {
        #[cfg_attr(feature = "garde", garde(length(min = 1)))]
        #[cfg_attr(feature = "validator", validate(length(min = 1)))]
        a: String,
        #[cfg_attr(feature = "garde", garde(length(min = 1)))]
        #[cfg_attr(feature = "validator", validate(length(min = 1)))]
        b: String,
    }

    const YAML: &str = "a: \"\"\nb: \"\"\n";

    #[track_caller]
    fn assert_headers(error: &Error, lines: &[u64]) {
        let rendered = render(error, Some("article.md"));
        for line in lines {
            assert!(
                rendered.contains(&format!("article.md:{line}:4")),
                "{rendered}"
            );
        }
        assert!(!rendered.contains("input>"), "{rendered}");
    }

    #[cfg(feature = "garde")]
    #[test]
    fn garde_issue_headers_and_multiple_documents_use_the_override() {
        for error in [
            serde_saphyr::from_str_valid::<Fields>(YAML).unwrap_err(),
            serde_saphyr::from_reader_valid::<_, Fields>(YAML.as_bytes()).unwrap_err(),
        ] {
            assert_headers(&error, &[1, 2]);
        }
        let yaml = format!("---\n{YAML}---\n{YAML}");
        let error = serde_saphyr::from_multiple_valid::<Fields>(&yaml).unwrap_err();
        assert_headers(&error, &[2, 3, 5, 6]);
    }

    #[cfg(feature = "validator")]
    #[test]
    fn validator_issue_headers_and_multiple_documents_use_the_override() {
        for error in [
            serde_saphyr::from_str_validate::<Fields>(YAML).unwrap_err(),
            serde_saphyr::from_reader_validate::<_, Fields>(YAML.as_bytes()).unwrap_err(),
        ] {
            assert_headers(&error, &[1, 2]);
        }
        let yaml = format!("---\n{YAML}---\n{YAML}");
        let error = serde_saphyr::from_multiple_validate::<Fields>(&yaml).unwrap_err();
        assert_headers(&error, &[2, 3, 5, 6]);
    }
}