diff --git a/Cargo.lock b/Cargo.lock index 7c3e3d77..b45f9ec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,6 +227,7 @@ dependencies = [ "glob", "indexmap 2.10.0", "json5", + "jsonc-parser", "log", "notify", "pathdiff", @@ -955,6 +956,16 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonc-parser" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6d80e6d70e7911a29f3cf3f44f452df85d06f73572b494ca99a2cad3fcf8f4" +dependencies = [ + "indexmap 2.10.0", + "serde_json", +] + [[package]] name = "kqueue" version = "1.0.8" diff --git a/Cargo.toml b/Cargo.toml index f1e17600..f05c1c5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,8 +126,9 @@ json = ["serde_json"] yaml = ["yaml-rust2"] ini = ["rust-ini"] json5 = ["json5_rs", "dep:serde-untagged"] +jsonc = ["dep:jsonc-parser"] convert-case = ["convert_case"] -preserve_order = ["indexmap", "toml?/preserve_order", "serde_json?/preserve_order", "ron?/indexmap"] +preserve_order = ["indexmap", "toml?/preserve_order", "serde_json?/preserve_order", "jsonc-parser?/preserve_order", "ron?/indexmap"] async = ["async-trait"] toml = ["dep:toml"] @@ -141,6 +142,7 @@ yaml-rust2 = { version = "0.10", optional = true } rust-ini = { version = "0.21", optional = true } ron = { version = "0.8", optional = true } json5_rs = { version = "0.4", optional = true, package = "json5" } +jsonc-parser = { version = "0.26.3", optional = true } indexmap = { version = "2.10.0", features = ["serde"], optional = true } convert_case = { version = "0.6", optional = true } pathdiff = "0.2" diff --git a/README.md b/README.md index 8bcd7fff..d32c7b09 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - Set defaults - Set explicit values (to programmatically override) - - Read from [JSON], [TOML], [YAML], [INI], [RON], [JSON5] files + - Read from [JSON], [TOML], [YAML], [INI], [RON], [JSON5], [JSONC] files - Read from environment - Loosely typed — Configuration values may be read in any supported type, as long as there exists a reasonable conversion - Access nested fields using a formatted path — Uses a subset of JSONPath; currently supports the child ( `redis.port` ) and subscript operators ( `databases[0].name` ) @@ -22,6 +22,7 @@ [INI]: https://github.com/zonyitoo/rust-ini [RON]: https://github.com/ron-rs/ron [JSON5]: https://github.com/callum-oakley/json5-rs +[JSONC]: https://github.com/dprint/jsonc-parser Please note that this library can not be used to write changed configuration values back to the configuration file(s)! @@ -36,6 +37,7 @@ values back to the configuration file(s)! - `toml` - Adds support for reading TOML files - `ron` - Adds support for reading RON files - `json5` - Adds support for reading JSON5 files + - `jsonc` - Adds support for reading JSONC files ### Support for custom formats diff --git a/src/file/format/jsonc.rs b/src/file/format/jsonc.rs new file mode 100644 index 00000000..8b481a60 --- /dev/null +++ b/src/file/format/jsonc.rs @@ -0,0 +1,49 @@ +use std::error::Error; + +use crate::format; +use crate::map::Map; +use crate::value::{Value, ValueKind}; +use jsonc_parser::JsonValue; + +pub(crate) fn parse( + uri: Option<&String>, + text: &str, +) -> Result, Box> { + let value = match jsonc_parser::parse_to_value(text, &Default::default())? { + Some(json_value) => from_jsonc_value(uri, json_value), + None => Value::new(uri, ValueKind::Nil), + }; + format::extract_root_table(uri, value) +} + +fn from_jsonc_value(uri: Option<&String>, value: JsonValue<'_>) -> Value { + let vk = match value { + JsonValue::Null => ValueKind::Nil, + JsonValue::String(v) => ValueKind::String(v.to_string()), + JsonValue::Number(number) => { + if let Ok(v) = number.parse::() { + ValueKind::I64(v) + } else if let Ok(v) = number.parse::() { + ValueKind::Float(v) + } else { + unreachable!(); + } + } + JsonValue::Boolean(v) => ValueKind::Boolean(v), + JsonValue::Object(table) => { + let m = table + .into_iter() + .map(|(k, v)| (k, from_jsonc_value(uri, v))) + .collect(); + ValueKind::Table(m) + } + JsonValue::Array(array) => { + let l = array + .into_iter() + .map(|v| from_jsonc_value(uri, v)) + .collect(); + ValueKind::Array(l) + } + }; + Value::new(uri, vk) +} diff --git a/src/file/format/mod.rs b/src/file/format/mod.rs index 857813ad..1ea6cd6d 100644 --- a/src/file/format/mod.rs +++ b/src/file/format/mod.rs @@ -23,6 +23,9 @@ mod ron; #[cfg(feature = "json5")] mod json5; +#[cfg(feature = "jsonc")] +mod jsonc; + /// File formats provided by the library. /// /// Although it is possible to define custom formats using [`Format`] trait it is recommended to use `FileFormat` if possible. @@ -52,6 +55,10 @@ pub enum FileFormat { /// JSON5 (parsed with json5) #[cfg(feature = "json5")] Json5, + + /// JSONC (parsed with jsonc) + #[cfg(feature = "jsonc")] + Jsonc, } pub(crate) fn all_extensions() -> &'static HashMap> { @@ -79,6 +86,9 @@ pub(crate) fn all_extensions() -> &'static HashMap #[cfg(feature = "json5")] formats.insert(FileFormat::Json5, vec!["json5"]); + #[cfg(feature = "jsonc")] + formats.insert(FileFormat::Jsonc, vec!["jsonc"]); + formats }) } @@ -115,6 +125,9 @@ impl FileFormat { #[cfg(feature = "json5")] FileFormat::Json5 => json5::parse(uri, text), + #[cfg(feature = "jsonc")] + FileFormat::Jsonc => jsonc::parse(uri, text), + #[cfg(all( not(feature = "toml"), not(feature = "json"), @@ -122,6 +135,7 @@ impl FileFormat { not(feature = "ini"), not(feature = "ron"), not(feature = "json5"), + not(feature = "jsonc"), ))] _ => unreachable!("No features are enabled, this library won't work without features"), } diff --git a/tests/testsuite/file_jsonc.enum.jsonc b/tests/testsuite/file_jsonc.enum.jsonc new file mode 100644 index 00000000..ab6bdb1f --- /dev/null +++ b/tests/testsuite/file_jsonc.enum.jsonc @@ -0,0 +1,4 @@ +{ + // foo + "bar": "bar is a lowercase param", +} diff --git a/tests/testsuite/file_jsonc.error.jsonc b/tests/testsuite/file_jsonc.error.jsonc new file mode 100644 index 00000000..ba2d7cba --- /dev/null +++ b/tests/testsuite/file_jsonc.error.jsonc @@ -0,0 +1,4 @@ +{ + "ok": true, + "error" +} diff --git a/tests/testsuite/file_jsonc.jsonc b/tests/testsuite/file_jsonc.jsonc new file mode 100644 index 00000000..1461aa0e --- /dev/null +++ b/tests/testsuite/file_jsonc.jsonc @@ -0,0 +1,23 @@ +{ + // c + /* c */ + "debug": true, + "debug_json": true, + "production": false, + "arr": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "place": { + "name": "Torre di Pisa", + "longitude": 43.7224985, + "latitude": 10.3970522, + "favorite": false, + "reviews": 3866, + "rating": 4.5, + "creator": { + "name": "John Smith", + "username": "jsmith", + "email": "jsmith@localhost" + }, + }, + "FOO": "FOO should be overridden", + "bar": "I am bar", +} diff --git a/tests/testsuite/file_jsonc.rs b/tests/testsuite/file_jsonc.rs new file mode 100644 index 00000000..0f9348c0 --- /dev/null +++ b/tests/testsuite/file_jsonc.rs @@ -0,0 +1,205 @@ +#![cfg(feature = "jsonc")] + +use serde_derive::Deserialize; + +use config::{Config, File, FileFormat, Map, Value}; +use float_cmp::ApproxEqUlps; +use snapbox::{assert_data_eq, str}; + +/// Returns the path to a test config file with an optional suffix. +/// +/// # Example +/// If the current file path is `/workspace/config-rs/tests/testsuite/file_jsonc.rs`: +/// ``` +/// let path = get_config_file_path(""); +/// assert_eq!(path, "/workspace/config-rs/tests/testsuite/file_jsonc.jsonc"); +/// +/// let path = get_config_file_path(".extra"); +/// assert_eq!(path, "/workspace/config-rs/tests/testsuite/file_jsonc.extra.jsonc"); +/// ``` +fn get_config_file_path(suffix: &str) -> String { + let path = std::path::Path::new(file!()); + format!( + "{}/{}/{}{}.jsonc", + env!("CARGO_MANIFEST_DIR"), + path.parent().unwrap().to_str().unwrap(), + path.file_stem().unwrap().to_str().unwrap(), + suffix + ) +} + +#[test] +fn test_file() { + #[derive(Debug, Deserialize)] + struct Place { + name: String, + longitude: f64, + latitude: f64, + favorite: bool, + telephone: Option, + reviews: u64, + creator: Map, + rating: Option, + } + + #[derive(Debug, Deserialize)] + struct Settings { + debug: f64, + production: Option, + place: Place, + #[serde(rename = "arr")] + elements: Vec, + } + + let c = Config::builder() + .add_source(File::new(&get_config_file_path(""), FileFormat::Jsonc)) + .build() + .unwrap(); + + // Deserialize the entire file as single struct + let s: Settings = c.try_deserialize().unwrap(); + + assert!(s.debug.approx_eq_ulps(&1.0, 2)); + assert_eq!(s.production, Some("false".to_owned())); + assert_eq!(s.place.name, "Torre di Pisa"); + assert!(s.place.longitude.approx_eq_ulps(&43.722_498_5, 2)); + assert!(s.place.latitude.approx_eq_ulps(&10.397_052_2, 2)); + assert!(!s.place.favorite); + assert_eq!(s.place.reviews, 3866); + assert_eq!(s.place.rating, Some(4.5)); + assert_eq!(s.place.telephone, None); + assert_eq!(s.elements.len(), 10); + assert_eq!(s.elements[3], "4".to_owned()); + if cfg!(feature = "preserve_order") { + assert_eq!( + s.place + .creator + .into_iter() + .collect::>(), + vec![ + ("name".to_owned(), "John Smith".into()), + ("username".into(), "jsmith".into()), + ("email".into(), "jsmith@localhost".into()), + ] + ); + } else { + assert_eq!( + s.place.creator["name"].clone().into_string().unwrap(), + "John Smith".to_owned() + ); + } +} + +#[test] +fn test_error_parse() { + let f = get_config_file_path(".error"); + let res = Config::builder() + .add_source(File::new(&f, FileFormat::Jsonc)) + .build(); + + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + let expected_prefix = + "Expected colon after the string or word in object property on line 4 column 1 in "; + assert!( + err.starts_with(expected_prefix), + "Error message does not start with expected prefix. Got: {}", + err + ); +} + +#[derive(Debug, Deserialize, PartialEq)] +#[allow(non_snake_case)] +struct OverrideSettings { + FOO: String, + foo: String, +} + +#[test] +fn test_override_uppercase_value_for_struct() { + std::env::set_var("APP_FOO", "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE"); + + let cfg = Config::builder() + .add_source(File::new(&get_config_file_path(""), FileFormat::Jsonc)) + .add_source(config::Environment::with_prefix("APP").separator("_")) + .build() + .unwrap(); + + let settings: OverrideSettings = cfg.try_deserialize().unwrap(); + assert_eq!(settings.FOO, "FOO should be overridden"); + assert_eq!( + settings.foo, + "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE".to_owned() + ); +} + +#[test] +fn test_override_lowercase_value_for_struct() { + std::env::set_var("config_foo", "I have been overridden_with_lower_case"); + + let cfg = Config::builder() + .add_source(File::new(&get_config_file_path(""), FileFormat::Jsonc)) + .add_source(config::Environment::with_prefix("config").separator("_")) + .build() + .unwrap(); + + let settings: OverrideSettings = cfg.try_deserialize().unwrap(); + assert_eq!(settings.FOO, "FOO should be overridden"); + assert_eq!( + settings.foo, + "I have been overridden_with_lower_case".to_owned() + ); +} + +#[derive(Debug, Deserialize, PartialEq)] +enum EnumSettings { + Bar(String), +} + +#[test] +fn test_override_uppercase_value_for_enums() { + std::env::set_var("APPS_BAR", "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE"); + + let cfg = Config::builder() + .add_source(File::new(&get_config_file_path(".enum"), FileFormat::Jsonc)) + .add_source(config::Environment::with_prefix("APPS").separator("_")) + .build() + .unwrap(); + + let param = cfg.try_deserialize::(); + assert!(param.is_err()); + assert_data_eq!( + param.unwrap_err().to_string(), + str!["enum EnumSettings does not have variant constructor bar"] + ); +} + +#[test] +fn test_override_lowercase_value_for_enums() { + std::env::set_var("test_bar", "I have been overridden_with_lower_case"); + + let cfg = Config::builder() + .add_source(File::new(&get_config_file_path(".enum"), FileFormat::Jsonc)) + .add_source(config::Environment::with_prefix("test").separator("_")) + .build() + .unwrap(); + + let param = cfg.try_deserialize::(); + assert!(param.is_err()); + assert_data_eq!( + param.unwrap_err().to_string(), + str!["enum EnumSettings does not have variant constructor bar"] + ); +} + +#[test] +fn test_nothing() { + let res = Config::builder() + .add_source(File::from_str("", FileFormat::Jsonc)) + .build(); + assert!(res.is_err()); + assert_data_eq!( + res.unwrap_err().to_string(), + format!("invalid type: unit value, expected a map") + ); +} diff --git a/tests/testsuite/main.rs b/tests/testsuite/main.rs index 1b1bcc20..32a512a1 100644 --- a/tests/testsuite/main.rs +++ b/tests/testsuite/main.rs @@ -11,6 +11,7 @@ pub mod file; pub mod file_ini; pub mod file_json; pub mod file_json5; +pub mod file_jsonc; pub mod file_ron; pub mod file_toml; pub mod file_yaml;