use serde_fjson::syntax::{self as config, Member, Value, ValueKind}; fn members(value: &Value) -> &[Member] { let ValueKind::Object(members) = &value.kind else { panic!("Expected an object: {value:?}"); }; members } fn field<'a>(value: &'a Value, key: &str) -> &'a Value { &members(value) .iter() .find(|member| member.key == key) .unwrap() .value } #[test] fn parses_a_package_config_with_or_without_root_braces() { let source = r#" // Dependencies are private to this package. imports: { assert: 'jsr:@std/assert@^1', "@my/library": "../library/mod.ts", }, options: { enabled: true, fallback: null, targets: ["linux", /* another platform */ "macos",], }, "#; for source in [source.to_owned(), format!("{{{source}}}")] { let value = config::parse_object(&source).unwrap(); let imports = field(&value, "imports"); assert_eq!( field(imports, "assert").kind, ValueKind::String("jsr:@std/assert@^1".into()) ); assert_eq!( field(imports, "@my/library").kind, ValueKind::String("../library/mod.ts".into()) ); let options = field(&value, "options"); assert_eq!(field(options, "enabled").kind, ValueKind::Bool(true)); assert_eq!(field(options, "fallback").kind, ValueKind::Null); let ValueKind::Array(targets) = &field(options, "targets").kind else { panic!() }; assert_eq!( targets.iter().map(|value| &value.kind).collect::>(), vec![ &ValueKind::String("linux".into()), &ValueKind::String("macos".into()), ] ); } } #[test] fn handles_empty_containers_and_comments_at_token_boundaries() { for source in [ "", " \t\r\n", "// only a comment", "/* comment */", "{}", "{ /* comment */ }", ] { assert!( members(&config::parse_object(source).unwrap()).is_empty(), "{source}" ); } let value = config::parse_object("a/*1*/:/*2*/[/*3*/]/*4*/,/*5*/b: {/*6*/},// eof").unwrap(); assert_eq!(field(&value, "a").kind, ValueKind::Array(vec![])); assert!(members(field(&value, "b")).is_empty()); assert_eq!( field(&config::parse_object("a: false // eof").unwrap(), "a").kind, ValueKind::Bool(false) ); } #[test] fn implicit_commas_separate_map_entries_and_array_items() { for newline in ["\n", "\r\n", "\r"] { for separator in [ newline.to_owned(), format!(" // comment{newline}"), format!(" /* comment */{newline}/* next */ "), format!(" /* multiline{newline}comment */{newline}"), format!("{newline}/* multiline{newline}comment */ "), format!("{newline}// blank{newline}{newline}"), format!(",{newline}"), format!("{newline},{newline}"), ] { let source = format!( "a: 1{separator}b: [true{separator}false{separator}null{newline}]{newline}" ); for source in [source.clone(), format!("{{{newline}{source}}}")] { let value = config::parse_object(&source).unwrap(); assert_eq!(members(&value).len(), 2, "{source:?}"); assert_eq!(field(&value, "a").kind, ValueKind::Number("1".into())); assert_eq!(&source[field(&value, "a").span.clone()], "1"); let ValueKind::Array(items) = &field(&value, "b").kind else { panic!() }; assert_eq!( items.iter().map(|item| &item.kind).collect::>(), vec![ &ValueKind::Bool(true), &ValueKind::Bool(false), &ValueKind::Null ] ); for (item, spelling) in items.iter().zip(["true", "false", "null"]) { assert_eq!(&source[item.span.clone()], spelling); } } } } } #[test] fn newlines_only_separate_completed_entries_at_the_current_level() { let source = "a\n:\n[\n{\nx\n:\n1\ny: 2\n}\n[\n3\n4\n]\n]\nb:\nfalse"; let value = config::parse_object(source).unwrap(); assert_eq!(members(&value).len(), 2); assert_eq!(field(&value, "b").kind, ValueKind::Bool(false)); let ValueKind::Array(items) = &field(&value, "a").kind else { panic!() }; assert_eq!(items.len(), 2); assert_eq!(field(&items[0], "x").kind, ValueKind::Number("1".into())); assert_eq!(field(&items[0], "y").kind, ValueKind::Number("2".into())); assert_eq!(&source[items[0].span.clone()], "{\nx\n:\n1\ny: 2\n}"); let ValueKind::Array(numbers) = &items[1].kind else { panic!() }; assert_eq!( numbers .iter() .map(|number| &number.kind) .collect::>(), vec![ &ValueKind::Number("3".into()), &ValueKind::Number("4".into()) ] ); } #[test] fn requires_explicit_commas_without_a_separator_newline() { for source in [ "a: 1 b: 2", "{a: true b: false}", "a: [1 2]", "a: ['one' 'two']", "a: 1 /* comment */ b: 2", "a: [1 /* comment */ 2]", "a: 1 /* multiline\ncomment */ b: 2", "a: [1 /* multiline\r\ncomment */ 2]", "a: [\n1\n] b: 2", "a: [{\nx: 1\n} {}]", "a\n:\n1 b: 2", "a: ['one\\ntwo' 'three']", ] { let error = config::parse_object(source).unwrap_err(); assert!( error.message.contains("Expected `,` or newline"), "{source}: {error}" ); } for source in [ "a: 1,", "{a: 1,}", "a: [1,],", "a: 1,\nb: 2,", "a: 1\n,", "{a: 1\n,}", "a: [1\n,]", ] { config::parse_object(source).unwrap(); } } #[test] fn strings_decode_escapes_without_interpreting_comment_markers() { let value = config::parse_object( r#" url: "https://example.com/*path*/", text: '\"\'\\\/\b\f\n\r\t', unicode: "α\u0061\uD83D\uDE00", "quoted\u0020key": 'value', "#, ) .unwrap(); assert_eq!( field(&value, "url").kind, ValueKind::String("https://example.com/*path*/".into()) ); assert_eq!( field(&value, "text").kind, ValueKind::String("\"'\\/\u{0008}\u{000c}\n\r\t".into()) ); assert_eq!( field(&value, "unicode").kind, ValueKind::String("αa😀".into()) ); assert_eq!( field(&value, "quoted key").kind, ValueKind::String("value".into()) ); } #[test] fn accepts_identifier_keys_but_not_bare_string_values() { let value = config::parse_object("_key: 1, $key: 2, key123: 3, true: false, '日本語': 'ok'").unwrap(); assert_eq!( members(&value) .iter() .map(|member| member.key.as_str()) .collect::>(), vec!["_key", "$key", "key123", "true", "日本語"] ); for source in ["a: bare", "a: undefined", "a: trueish", "a: Null"] { assert!( config::parse_object(source) .unwrap_err() .message .contains("strings must be quoted") ); } for source in ["123: true", "日本語: 1", "@scope/pkg: 1", "some-key: 1"] { assert!(config::parse_object(source).is_err(), "{source}"); } } #[test] fn decimal_numbers_keep_their_precision_and_spelling() { for spelling in [ "0", "-0", "42", "-12.34", "1e3", "2.5E-10", "6e+2", "9007199254740993", "1e9999", ] { let source = format!("value: {spelling}"); assert_eq!( field(&config::parse_object(&source).unwrap(), "value").kind, ValueKind::Number(spelling.into()) ); } for spelling in [ "-", "01", "-01", "1.", ".5", "+1", "1e", "1e+", "0x10", "1_000", "1n", "NaN", "Infinity", "-Infinity", "1.2.3", ] { assert!( config::parse_object(&format!("value: {spelling}")).is_err(), "{spelling}" ); } } #[test] fn rejects_duplicate_decoded_keys_and_identifies_both_definitions() { let source = "imports: {\n assert: 'first'\n '\\u0061ssert': 'second'\n}"; let error = config::parse_object(source).unwrap_err(); assert!( error.message.contains("Duplicate key \"assert\""), "{error}" ); assert_eq!(&source[error.span.clone()], "'\\u0061ssert'"); assert_eq!(&source[error.previous.clone().unwrap()], "assert"); assert_eq!( error.render("avril.conf", source), "avril.conf:3:3: Duplicate key \"assert\"\navril.conf:2:3: First defined here" ); config::parse_object("a: {key: 1}, b: {key: 2}").unwrap(); } #[test] fn retains_original_source_spans_for_keys_and_values() { let source = "/* lead */ imports: { '日本語': ['😀', -12.5], }, // tail"; let value = config::parse_object(source).unwrap(); let imports = field(&value, "imports"); assert_eq!( &source[imports.span.clone()], "{ '日本語': ['😀', -12.5], }" ); let entry = &members(imports)[0]; assert_eq!(&source[entry.key_span.clone()], "'日本語'"); assert_eq!(&source[entry.value.span.clone()], "['😀', -12.5]"); let ValueKind::Array(elements) = &entry.value.kind else { panic!() }; assert_eq!(&source[elements[0].span.clone()], "'😀'"); assert_eq!(&source[elements[1].span.clone()], "-12.5"); } #[test] fn diagnostics_use_original_lines_and_unicode_character_columns() { let source = "label: '😀',\n imports: {\n x: 'α', y 'bad',\n },\n"; let error = config::parse_object(source).unwrap_err(); assert_eq!( error.render("avril.conf", source), "avril.conf:3:15: Expected `:`" ); assert_eq!(&source[error.span], "'"); for newline in ["\r", "\r\n", "\n"] { let source = format!("a: true,{newline}// heading{newline}b:"); let error = config::parse_object(&source).unwrap_err(); assert_eq!( error.render("avril.conf", &source), "avril.conf:3:3: Expected a value" ); } let source = "a: 1,\nb:"; let error = config::parse_object(source).unwrap_err(); assert_eq!(error.span, source.len()..source.len()); assert_eq!( error.render("avril.conf", source), "avril.conf:2:3: Expected a value" ); } #[test] fn rejects_malformed_or_executable_input() { for (source, message) in [ ("[]", "object key"), ("42", "object key"), ("{a: 1} b: 2", "after the root object"), ("{a: 1},", "after the root object"), ("a: {", "Expected `}`"), ("a: [", "Expected `]`"), ("a: [1", "Expected `]`"), ("a: {b: 1", "Expected `}`"), ("a: 1,,", "object key"), ("a: [,]", "Expected a value"), ("a: [1,,]", "Expected a value"), ("a: 1;", "Expected `,` or newline"), ("a = 1", "Expected `:`"), ("a: () => 1", "Expected a value"), ("a: `template`", "Expected a value"), ("a: 'unterminated", "Unterminated string"), ("a: 'new\nline'", "Control characters"), ("a: '\0'", "Control characters"), (r#"a: '\q'"#, "Invalid string escape"), (r#"a: '\u12x4'"#, "hexadecimal digits"), (r#"a: '\uDC00'"#, "Unexpected low Unicode surrogate"), (r#"a: '\uD800\u0041'"#, "Expected a low Unicode surrogate"), (r#"a: '\uD800'"#, "Unpaired Unicode surrogate"), ("/* never closed", "Unterminated block comment"), ("a: 1 / nope", "Expected `,` or newline"), ] { let error = config::parse_object(source).unwrap_err(); assert!(error.message.contains(message), "{source:?}: {error}"); assert!(source.get(error.span.clone()).is_some(), "{error:?}"); } } #[test] fn bounds_nesting_without_rejecting_large_flat_configs() { let nested = format!("value: {}0{}", "[".repeat(129), "]".repeat(129)); assert!( config::parse_object(&nested) .unwrap_err() .message .contains("nesting") ); let nested = format!("value: {}0{}", "[".repeat(128), "]".repeat(128)); config::parse_object(&nested).unwrap(); let objects = format!("value: {}0{}", "{a:".repeat(129), "}".repeat(129)); assert!( config::parse_object(&objects) .unwrap_err() .message .contains("nesting") ); let flat = format!("value: [{}]", "0,".repeat(10_000)); let value = config::parse_object(&flat).unwrap(); let ValueKind::Array(elements) = &field(&value, "value").kind else { panic!() }; assert_eq!(elements.len(), 10_000); } #[test] fn truncated_input_always_produces_valid_diagnostic_spans() { let source = r#"/* lead */ imports: {'日本語': "😀\uD83D\uDE00",}, flags: [true, false, null, -1e+2],"#; for offset in source .char_indices() .map(|(offset, _)| offset) .chain([source.len()]) { let prefix = &source[..offset]; if let Err(error) = config::parse_object(prefix) { assert!( prefix.get(error.span.clone()).is_some(), "{prefix:?}: {error:?}" ); error.render("avril.conf", prefix); } } }