Skip to content

Commit 5c4cb0f

Browse files
committed
support jsonc format
1 parent b7f967c commit 5c4cb0f

File tree

9 files changed

+311
-3
lines changed

9 files changed

+311
-3
lines changed

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ edition = "2018"
1515
maintenance = { status = "actively-developed" }
1616

1717
[features]
18-
default = ["toml", "json", "yaml", "ini", "ron", "json5", "convert-case", "async"]
18+
default = ["toml", "json", "yaml", "ini", "ron", "json5", "jsonc", "convert-case", "async"]
1919
json = ["serde_json"]
2020
yaml = ["yaml-rust"]
2121
ini = ["rust-ini"]
2222
json5 = ["json5_rs", "serde/derive"]
23+
jsonc = ["jsonc-parser"]
2324
convert-case = ["convert_case"]
2425
preserve_order = ["indexmap", "toml/preserve_order", "serde_json/preserve_order", "ron/indexmap"]
2526
async = ["async-trait"]
@@ -36,6 +37,7 @@ yaml-rust = { version = "0.4", optional = true }
3637
rust-ini = { version = "0.19", optional = true }
3738
ron = { version = "0.8", optional = true }
3839
json5_rs = { version = "0.4", optional = true, package = "json5" }
40+
jsonc-parser = { version = "0.22.1", optional = true }
3941
indexmap = { version = "2.0.0", features = ["serde"], optional = true }
4042
convert_case = { version = "0.6", optional = true }
4143
pathdiff = "0.2"

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
- Set defaults
1313
- Set explicit values (to programmatically override)
14-
- Read from [JSON], [TOML], [YAML], [INI], [RON], [JSON5] files
14+
- Read from [JSON], [TOML], [YAML], [INI], [RON], [JSON5], [JSONC] files
1515
- Read from environment
1616
- Loosely typed — Configuration values may be read in any supported type, as long as there exists a reasonable conversion
1717
- 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 @@
2222
[INI]: https://github.com/zonyitoo/rust-ini
2323
[RON]: https://github.com/ron-rs/ron
2424
[JSON5]: https://github.com/callum-oakley/json5-rs
25+
[JSONC]: https://github.com/dprint/jsonc-parser
2526

2627
Please note this library
2728

@@ -43,6 +44,7 @@ config = "0.13.1"
4344
- `toml` - Adds support for reading TOML files
4445
- `ron` - Adds support for reading RON files
4546
- `json5` - Adds support for reading JSON5 files
47+
- `jsonc` - Adds support for reading JSONC files
4648

4749
### Support for custom formats
4850

src/file/format/jsonc.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
use std::error::Error;
2+
3+
use crate::error::{ConfigError, Unexpected};
4+
use crate::map::Map;
5+
use crate::value::{Value, ValueKind};
6+
7+
use jsonc_parser::JsonValue;
8+
9+
pub fn parse(
10+
uri: Option<&String>,
11+
text: &str,
12+
) -> Result<Map<String, Value>, Box<dyn Error + Send + Sync>> {
13+
match jsonc_parser::parse_to_value(text, &Default::default())? {
14+
Some(r) => match r {
15+
JsonValue::String(ref value) => Err(Unexpected::Str(value.to_string())),
16+
JsonValue::Number(value) => Err(Unexpected::Float(value.parse::<f64>().unwrap())),
17+
JsonValue::Boolean(value) => Err(Unexpected::Bool(value)),
18+
JsonValue::Object(o) => match from_jsonc_value(uri, JsonValue::Object(o)).kind {
19+
ValueKind::Table(map) => Ok(map),
20+
_ => unreachable!(),
21+
},
22+
JsonValue::Array(_) => Err(Unexpected::Seq),
23+
JsonValue::Null => Err(Unexpected::Unit),
24+
},
25+
None => Err(Unexpected::Unit),
26+
}
27+
.map_err(|err| ConfigError::invalid_root(uri, err))
28+
.map_err(|err| Box::new(err) as Box<dyn Error + Send + Sync>)
29+
}
30+
31+
fn from_jsonc_value(uri: Option<&String>, value: JsonValue) -> Value {
32+
let vk = match value {
33+
JsonValue::Null => ValueKind::Nil,
34+
JsonValue::String(v) => ValueKind::String(v.to_string()),
35+
JsonValue::Number(ref value) => {
36+
if let Ok(value) = value.parse::<i64>() {
37+
ValueKind::I64(value)
38+
} else if let Ok(value) = value.parse::<f64>() {
39+
ValueKind::Float(value)
40+
} else {
41+
unreachable!();
42+
}
43+
},
44+
JsonValue::Boolean(v) => ValueKind::Boolean(v),
45+
JsonValue::Object(table) => {
46+
let m = table
47+
.into_iter()
48+
.map(|(k, v)| (k, from_jsonc_value(uri, v)))
49+
.collect();
50+
ValueKind::Table(m)
51+
}
52+
JsonValue::Array(array) => {
53+
let l = array
54+
.into_iter()
55+
.map(|v| from_jsonc_value(uri, v))
56+
.collect();
57+
ValueKind::Array(l)
58+
}
59+
};
60+
Value::new(uri, vk)
61+
}

src/file/format/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ mod ron;
2727
#[cfg(feature = "json5")]
2828
mod json5;
2929

30+
#[cfg(feature = "jsonc")]
31+
mod jsonc;
32+
3033
/// File formats provided by the library.
3134
///
3235
/// Although it is possible to define custom formats using [`Format`] trait it is recommended to use FileFormat if possible.
@@ -55,6 +58,10 @@ pub enum FileFormat {
5558
/// JSON5 (parsed with json5)
5659
#[cfg(feature = "json5")]
5760
Json5,
61+
62+
/// JSONC (parsed with jsonc)
63+
#[cfg(feature = "jsonc")]
64+
Jsonc,
5865
}
5966

6067
lazy_static! {
@@ -81,6 +88,12 @@ lazy_static! {
8188
#[cfg(feature = "json5")]
8289
formats.insert(FileFormat::Json5, vec!["json5"]);
8390

91+
#[cfg(all(feature = "jsonc", feature = "json"))]
92+
formats.insert(FileFormat::Jsonc, vec!["jsonc"]);
93+
94+
#[cfg(all(feature = "jsonc", not(feature = "json")))]
95+
formats.insert(FileFormat::Jsonc, vec!["jsonc", "json"]);
96+
8497
formats
8598
};
8699
}
@@ -117,13 +130,17 @@ impl FileFormat {
117130
#[cfg(feature = "json5")]
118131
FileFormat::Json5 => json5::parse(uri, text),
119132

133+
#[cfg(feature = "jsonc")]
134+
FileFormat::Jsonc => jsonc::parse(uri, text),
135+
120136
#[cfg(all(
121137
not(feature = "toml"),
122138
not(feature = "json"),
123139
not(feature = "yaml"),
124140
not(feature = "ini"),
125141
not(feature = "ron"),
126142
not(feature = "json5"),
143+
not(feature = "jsonc"),
127144
))]
128145
_ => unreachable!("No features are enabled, this library won't work without features"),
129146
}

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//! - Environment variables
77
//! - String literals in well-known formats
88
//! - Another Config instance
9-
//! - Files: TOML, JSON, YAML, INI, RON, JSON5 and custom ones defined with Format trait
9+
//! - Files: TOML, JSON, YAML, INI, RON, JSON5, JSONC and custom ones defined with Format trait
1010
//! - Manual, programmatic override (via a `.set` method on the Config instance)
1111
//!
1212
//! Additionally, Config supports:

tests/Settings-enum-test.jsonc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
// foo
3+
"bar": "bar is a lowercase param",
4+
}

tests/Settings-invalid.jsonc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"ok": true,
3+
"error"
4+
}

tests/Settings.jsonc

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
// c
3+
/* c */
4+
"debug": true,
5+
"debug_json": true,
6+
"production": false,
7+
"arr": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
8+
"place": {
9+
"name": "Torre di Pisa",
10+
"longitude": 43.7224985,
11+
"latitude": 10.3970522,
12+
"favorite": false,
13+
"reviews": 3866,
14+
"rating": 4.5,
15+
"creator": {
16+
"name": "John Smith",
17+
"username": "jsmith",
18+
"email": "jsmith@localhost"
19+
},
20+
},
21+
"FOO": "FOO should be overridden",
22+
"bar": "I am bar",
23+
}

tests/file_jsonc.rs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#![cfg(feature = "jsonc")]
2+
3+
use serde_derive::Deserialize;
4+
5+
use config::{Config, File, FileFormat, Map, Value};
6+
use float_cmp::ApproxEqUlps;
7+
use std::path::PathBuf;
8+
9+
#[derive(Debug, Deserialize)]
10+
struct Place {
11+
name: String,
12+
longitude: f64,
13+
latitude: f64,
14+
favorite: bool,
15+
telephone: Option<String>,
16+
reviews: u64,
17+
creator: Map<String, Value>,
18+
rating: Option<f32>,
19+
}
20+
21+
#[derive(Debug, Deserialize)]
22+
struct Settings {
23+
debug: f64,
24+
production: Option<String>,
25+
place: Place,
26+
#[serde(rename = "arr")]
27+
elements: Vec<String>,
28+
}
29+
30+
fn make() -> Config {
31+
Config::builder()
32+
.add_source(File::new("tests/Settings", FileFormat::Jsonc))
33+
.build()
34+
.unwrap()
35+
}
36+
37+
#[test]
38+
fn test_file() {
39+
let c = make();
40+
41+
// Deserialize the entire file as single struct
42+
let s: Settings = c.try_deserialize().unwrap();
43+
44+
assert!(s.debug.approx_eq_ulps(&1.0, 2));
45+
assert_eq!(s.production, Some("false".to_string()));
46+
assert_eq!(s.place.name, "Torre di Pisa");
47+
assert!(s.place.longitude.approx_eq_ulps(&43.722_498_5, 2));
48+
assert!(s.place.latitude.approx_eq_ulps(&10.397_052_2, 2));
49+
assert!(!s.place.favorite);
50+
assert_eq!(s.place.reviews, 3866);
51+
assert_eq!(s.place.rating, Some(4.5));
52+
assert_eq!(s.place.telephone, None);
53+
assert_eq!(s.elements.len(), 10);
54+
assert_eq!(s.elements[3], "4".to_string());
55+
if cfg!(feature = "preserve_order") {
56+
assert_eq!(
57+
s.place
58+
.creator
59+
.into_iter()
60+
.collect::<Vec<(String, config::Value)>>(),
61+
vec![
62+
("name".to_string(), "John Smith".into()),
63+
("username".into(), "jsmith".into()),
64+
("email".into(), "jsmith@localhost".into()),
65+
]
66+
);
67+
} else {
68+
assert_eq!(
69+
s.place.creator["name"].clone().into_string().unwrap(),
70+
"John Smith".to_string()
71+
);
72+
}
73+
}
74+
75+
#[test]
76+
fn test_error_parse() {
77+
let res = Config::builder()
78+
.add_source(File::new("tests/Settings-invalid", FileFormat::Jsonc))
79+
.build();
80+
81+
let path_with_extension: PathBuf = ["tests", "Settings-invalid.jsonc"].iter().collect();
82+
83+
assert!(res.is_err());
84+
assert_eq!(
85+
res.unwrap_err().to_string(),
86+
format!(
87+
"Expected a colon after the string or word in an object property on line 4 column 1. in {}",
88+
path_with_extension.display()
89+
)
90+
);
91+
}
92+
93+
#[derive(Debug, Deserialize, PartialEq)]
94+
enum EnumSettings {
95+
Bar(String),
96+
}
97+
98+
#[derive(Debug, Deserialize, PartialEq)]
99+
struct StructSettings {
100+
foo: String,
101+
bar: String,
102+
}
103+
#[derive(Debug, Deserialize, PartialEq)]
104+
#[allow(non_snake_case)]
105+
struct CapSettings {
106+
FOO: String,
107+
}
108+
109+
#[test]
110+
fn test_override_uppercase_value_for_struct() {
111+
std::env::set_var("APP_FOO", "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE");
112+
113+
let cfg = Config::builder()
114+
.add_source(File::new("tests/Settings", FileFormat::Jsonc))
115+
.add_source(config::Environment::with_prefix("APP").separator("_"))
116+
.build()
117+
.unwrap();
118+
119+
let cap_settings = cfg.clone().try_deserialize::<CapSettings>();
120+
let lower_settings = cfg.try_deserialize::<StructSettings>().unwrap();
121+
122+
match cap_settings {
123+
Ok(v) => {
124+
// this assertion will ensure that the map has only lowercase keys
125+
assert_ne!(v.FOO, "FOO should be overridden");
126+
assert_eq!(
127+
lower_settings.foo,
128+
"I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE".to_string()
129+
);
130+
}
131+
Err(e) => {
132+
if e.to_string().contains("missing field `FOO`") {
133+
assert_eq!(
134+
lower_settings.foo,
135+
"I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE".to_string()
136+
);
137+
} else {
138+
panic!("{}", e);
139+
}
140+
}
141+
}
142+
}
143+
144+
#[test]
145+
fn test_override_lowercase_value_for_struct() {
146+
std::env::set_var("config_foo", "I have been overridden_with_lower_case");
147+
148+
let cfg = Config::builder()
149+
.add_source(File::new("tests/Settings", FileFormat::Jsonc))
150+
.add_source(config::Environment::with_prefix("config").separator("_"))
151+
.build()
152+
.unwrap();
153+
154+
let values: StructSettings = cfg.try_deserialize().unwrap();
155+
assert_eq!(
156+
values.foo,
157+
"I have been overridden_with_lower_case".to_string()
158+
);
159+
assert_ne!(values.foo, "I am bar".to_string());
160+
}
161+
162+
#[test]
163+
fn test_override_uppercase_value_for_enums() {
164+
std::env::set_var("APPS_BAR", "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE");
165+
166+
let cfg = Config::builder()
167+
.add_source(File::new("tests/Settings-enum-test", FileFormat::Jsonc))
168+
.add_source(config::Environment::with_prefix("APPS").separator("_"))
169+
.build()
170+
.unwrap();
171+
let val: EnumSettings = cfg.try_deserialize().unwrap();
172+
173+
assert_eq!(
174+
val,
175+
EnumSettings::Bar("I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE".to_string())
176+
);
177+
}
178+
179+
#[test]
180+
fn test_override_lowercase_value_for_enums() {
181+
std::env::set_var("test_bar", "I have been overridden_with_lower_case");
182+
183+
let cfg = Config::builder()
184+
.add_source(File::new("tests/Settings-enum-test", FileFormat::Jsonc))
185+
.add_source(config::Environment::with_prefix("test").separator("_"))
186+
.build()
187+
.unwrap();
188+
189+
let param: EnumSettings = cfg.try_deserialize().unwrap();
190+
191+
assert_eq!(
192+
param,
193+
EnumSettings::Bar("I have been overridden_with_lower_case".to_string())
194+
);
195+
}

0 commit comments

Comments
 (0)