Skip to main content

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}