hocon/value.rs
1use indexmap::IndexMap;
2
3/// Payload for an unresolved substitution placeholder. Used internally by the
4/// deferred-resolution path (E12). Not part of the stable public API — marked
5/// `#[doc(hidden)]` to exclude from rustdoc and signal non-stable visibility.
6/// The type is `pub` for technical reasons (it is a field of the public `HoconValue`
7/// enum), but no semver guarantees are made for it.
8#[doc(hidden)]
9#[derive(Debug, Clone, PartialEq)]
10pub struct PlaceholderValue {
11 /// The dot-separated substitution path (e.g. `"db.host"`).
12 pub(crate) path: String,
13 /// Whether this was an optional substitution (`${?x}` vs `${x}`).
14 pub(crate) optional: bool,
15}
16
17/// A resolved HOCON value.
18///
19/// This is the tree that [`Config`](crate::Config) wraps. You normally interact
20/// with it through the typed getters on `Config`, but it is also returned
21/// directly by [`Config::get`](crate::Config::get) and
22/// [`Config::get_list`](crate::Config::get_list).
23#[non_exhaustive]
24#[derive(Debug, Clone, PartialEq)]
25pub enum HoconValue {
26 /// An ordered map of key-value pairs (HOCON object / JSON object).
27 Object(IndexMap<String, HoconValue>),
28 /// An ordered list of values (HOCON array / JSON array).
29 Array(Vec<HoconValue>),
30 /// A leaf value (string, number, boolean, or null).
31 Scalar(ScalarValue),
32 /// An unresolved substitution placeholder. Not part of the stable public API.
33 /// Marked `#[doc(hidden)]`; callers using the fused parse path will never see
34 /// this variant. May be encountered when `allow_unresolved=true` is passed to
35 /// [`Config::resolve`](crate::Config::resolve) — check `Config::is_resolved()`
36 /// instead of matching on this variant.
37 #[doc(hidden)]
38 Placeholder(PlaceholderValue),
39}
40
41impl HoconValue {
42 /// The string, if this is a **string** scalar.
43 ///
44 /// Strict: numbers/booleans/null return `None` (mirrors `serde_json::Value::as_str`).
45 /// For coercing any scalar to text, use [`Config::get_string`](crate::Config::get_string).
46 pub fn as_str(&self) -> Option<&str> {
47 match self {
48 HoconValue::Scalar(sv) if sv.value_type == ScalarType::String => Some(&sv.raw),
49 _ => None,
50 }
51 }
52
53 /// This value as `i64`, if it is a scalar coercible to one.
54 ///
55 /// HOCON-aware coercion matching [`Config::get_i64`](crate::Config::get_i64):
56 /// a quoted `"8080"` and a bare `8080` both yield `Some(8080)`, and a
57 /// whole-number numeric scalar (`1.0`, `1e3`) is truncated to its integer
58 /// value. A non-whole number (`1.5`) yields `None`.
59 pub fn as_i64(&self) -> Option<i64> {
60 match self {
61 HoconValue::Scalar(sv) => scalar_as_i64(sv),
62 _ => None,
63 }
64 }
65
66 /// This value as `f64`, if it is a scalar whose raw text parses as one.
67 pub fn as_f64(&self) -> Option<f64> {
68 match self {
69 HoconValue::Scalar(sv) => sv.raw.parse::<f64>().ok(),
70 _ => None,
71 }
72 }
73
74 /// This value as `bool`, if it is a scalar with a recognised boolean spelling.
75 ///
76 /// Accepts `true`/`yes`/`on` and `false`/`no`/`off` (case-insensitive),
77 /// matching the serde boolean coercion.
78 pub fn as_bool(&self) -> Option<bool> {
79 match self {
80 HoconValue::Scalar(sv) => match sv.raw.to_lowercase().as_str() {
81 "true" | "yes" | "on" => Some(true),
82 "false" | "no" | "off" => Some(false),
83 _ => None,
84 },
85 _ => None,
86 }
87 }
88
89 /// The underlying ordered map, if this is an object.
90 pub fn as_object(&self) -> Option<&IndexMap<String, HoconValue>> {
91 match self {
92 HoconValue::Object(map) => Some(map),
93 _ => None,
94 }
95 }
96
97 /// The underlying slice, if this is an array.
98 ///
99 /// Structural: a numeric-keyed object is **not** coerced to an array here
100 /// (unlike [`Config::get_list`](crate::Config::get_list) / serde sequence
101 /// deserialization). Use `get_as::<Vec<_>>` / `from_value::<Vec<_>>` for that.
102 pub fn as_array(&self) -> Option<&[HoconValue]> {
103 match self {
104 HoconValue::Array(items) => Some(items),
105 _ => None,
106 }
107 }
108
109 /// Whether this value is an object.
110 pub fn is_object(&self) -> bool {
111 matches!(self, HoconValue::Object(_))
112 }
113
114 /// Whether this value is an array.
115 pub fn is_array(&self) -> bool {
116 matches!(self, HoconValue::Array(_))
117 }
118
119 /// Whether this value is a scalar (string, number, boolean, or null).
120 pub fn is_scalar(&self) -> bool {
121 matches!(self, HoconValue::Scalar(_))
122 }
123
124 /// Whether this value is an explicit null scalar.
125 pub fn is_null(&self) -> bool {
126 matches!(
127 self,
128 HoconValue::Scalar(sv) if sv.value_type == ScalarType::Null
129 )
130 }
131}
132
133/// Coerce a scalar to `i64` with the same rules as [`Config::get_i64`] and the
134/// serde integer path (`parse_int_from_scalar`): direct parse, else whole-number
135/// float/exponent coercion for any float-like raw (quoted or not). Kept in sync
136/// so `HoconValue::as_i64`, `Config::get_i64`, and `get_as::<i64>` all agree.
137fn scalar_as_i64(sv: &ScalarValue) -> Option<i64> {
138 sv.raw
139 .parse::<i64>()
140 .ok()
141 .or_else(|| whole_float_to_i64(&sv.raw))
142}
143
144/// Coerce a whole-number float/exponent **raw string** to an exact `i64`.
145///
146/// This is the float-like fallback shared by [`HoconValue::as_i64`],
147/// [`Config::get_i64`](crate::Config::get_i64), and the serde integer path; it is
148/// used only after a direct `parse::<i64>()` of the raw string fails. Returns
149/// `None` unless the text is float-like (`.`/`e`/`E`), denotes a whole number, and
150/// fits `i64`.
151///
152/// Wholeness and the integer value are derived from the decimal digits, never
153/// from an intermediate `f64` (xx.hocon#56). Above 2^52 an `f64` can no longer
154/// represent fractional parts, so `"4503599627370496.5".parse::<f64>().fract()`
155/// is `0.0` (false-accept) and `"9007199254740993.0"` rounds to the wrong integer
156/// (silent off-by-one). Parsing the digits directly avoids both, while still
157/// accepting genuinely whole large literals such as `1e16`.
158pub(crate) fn whole_float_to_i64(raw: &str) -> Option<i64> {
159 // Plain integers are handled by the caller's direct `parse::<i64>()`.
160 if !raw.contains(['.', 'e', 'E']) {
161 return None;
162 }
163 let s = raw.trim();
164 let (neg, body) = match s.strip_prefix('-') {
165 Some(rest) => (true, rest),
166 None => (false, s.strip_prefix('+').unwrap_or(s)),
167 };
168 // Mantissa and base-10 exponent (default 0).
169 let (mantissa, exp) = match body.split_once(['e', 'E']) {
170 Some((m, e)) => (m, e.parse::<i32>().ok()?),
171 None => (body, 0),
172 };
173 // Integer and fractional digit runs.
174 let (int_part, frac_part) = match mantissa.split_once('.') {
175 Some((i, f)) => (i, f),
176 None => (mantissa, ""),
177 };
178 if int_part.is_empty() && frac_part.is_empty() {
179 return None;
180 }
181 if !int_part.bytes().all(|b| b.is_ascii_digit())
182 || !frac_part.bytes().all(|b| b.is_ascii_digit())
183 {
184 return None;
185 }
186 // Significant digits as one run; leading zeros never affect the value, so
187 // drop them (keeps the overflow guard and u64 parse below significant-only).
188 // The decimal point sits `r` places from the right after applying exponent.
189 let combined = format!("{int_part}{frac_part}");
190 let digits = combined.trim_start_matches('0');
191 // An all-zero mantissa is exactly 0 regardless of the exponent; handle it
192 // before the append-zeros guard below (which is keyed off the exponent and
193 // would otherwise reject e.g. "0e2147483647"). After this, `digits` is
194 // non-empty, so its leading byte is always a non-zero digit.
195 if digits.is_empty() {
196 return Some(0);
197 }
198 let r = frac_part.len() as i64 - exp as i64;
199 // i64 has at most 19 decimal digits; any longer magnitude cannot fit, so it
200 // is reported as out-of-range below — but for the right-shift (append-zeros)
201 // branch we must bound BEFORE materializing the zeros, or a huge exponent
202 // such as "1e2147483647" would allocate gigabytes before failing.
203 let mag_str: String = if r <= 0 {
204 let zeros = (-r) as usize;
205 if digits.len().saturating_add(zeros) > 19 {
206 return None;
207 }
208 let mut t = String::with_capacity(digits.len() + zeros);
209 t.push_str(digits);
210 t.extend(std::iter::repeat_n('0', zeros));
211 t
212 } else {
213 let r = r as usize;
214 // `digits` is non-empty with a non-zero leading byte, so if every
215 // significant digit is fractional the value is < 1 and not whole.
216 if r >= digits.len() {
217 return None;
218 }
219 let (head, tail) = digits.split_at(digits.len() - r);
220 // Whole only if the fractional digits are all zero.
221 if !tail.bytes().all(|b| b == b'0') {
222 return None;
223 }
224 head.to_string()
225 };
226 // Parse the unsigned magnitude, then apply the sign with an explicit range
227 // check so that i64::MIN (magnitude 2^63, which does not fit i64) is
228 // preserved for float-like spellings like "-9223372036854775808.0".
229 let mag: u64 = mag_str.parse().ok()?; // overflow -> None
230 if neg {
231 match mag.cmp(&((i64::MAX as u64) + 1)) {
232 std::cmp::Ordering::Less => Some(-(mag as i64)),
233 std::cmp::Ordering::Equal => Some(i64::MIN),
234 std::cmp::Ordering::Greater => None,
235 }
236 } else {
237 i64::try_from(mag).ok()
238 }
239}
240
241/// The type tag for a scalar value.
242#[non_exhaustive]
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum ScalarType {
245 /// A string value.
246 String,
247 /// A numeric value (integer or floating-point).
248 Number,
249 /// A boolean value.
250 Boolean,
251 /// An explicit null.
252 Null,
253}
254
255/// A scalar (leaf) value inside a HOCON document.
256///
257/// Stores the raw string representation alongside a type tag.
258/// Typed access (i64, f64, bool) is done by parsing `raw` on demand.
259#[non_exhaustive]
260#[derive(Debug, Clone, PartialEq)]
261pub struct ScalarValue {
262 /// The raw string as it appeared in the source (or was produced by resolution).
263 pub raw: String,
264 /// The semantic type of this scalar.
265 pub value_type: ScalarType,
266}
267
268impl ScalarValue {
269 /// Create a new scalar value with explicit type.
270 pub fn new(raw: String, value_type: ScalarType) -> Self {
271 Self { raw, value_type }
272 }
273
274 /// Create a string-typed scalar.
275 pub fn string(raw: String) -> Self {
276 Self {
277 raw,
278 value_type: ScalarType::String,
279 }
280 }
281
282 /// Create a null scalar.
283 pub fn null() -> Self {
284 Self {
285 raw: "null".to_string(),
286 value_type: ScalarType::Null,
287 }
288 }
289
290 /// Create a boolean scalar.
291 pub fn boolean(value: bool) -> Self {
292 Self {
293 raw: if value { "true" } else { "false" }.to_string(),
294 value_type: ScalarType::Boolean,
295 }
296 }
297
298 /// Create a number scalar from a raw string.
299 pub fn number(raw: String) -> Self {
300 Self {
301 raw,
302 value_type: ScalarType::Number,
303 }
304 }
305}