Skip to main content

hocon/
lib.rs

1//! # hocon
2//!
3//! Full [Lightbend HOCON specification](https://github.com/lightbend/config/blob/main/HOCON.md)-compliant
4//! parser for Rust.
5//!
6//! ## Quick Example
7//!
8//! ```rust
9//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
10//! let config = hocon::parse(r#"
11//!     server {
12//!         host = "localhost"
13//!         port = 8080
14//!     }
15//! "#)?;
16//!
17//! assert_eq!(config.get_string("server.host")?, "localhost");
18//! assert_eq!(config.get_i64("server.port")?, 8080);
19//! # Ok(())
20//! # }
21//! ```
22//!
23//! ## Parsing
24//!
25//! - [`parse`] -- parse a HOCON string into a [`Config`].
26//! - [`parse_file`] -- parse a HOCON file. Include directives are resolved
27//!   relative to the file's directory.
28//! - [`parse_with_env`] / [`parse_file_with_env`] -- parse with a custom
29//!   environment variable map instead of inheriting the process environment.
30//!
31//! ## Accessing Values
32//!
33//! [`Config`] provides typed getters that accept dot-separated paths:
34//!
35//! | Method | Return type |
36//! |--------|-------------|
37//! | [`Config::get_string`] | `Result<String, ConfigError>` |
38//! | [`Config::get_i64`] | `Result<i64, ConfigError>` |
39//! | [`Config::get_f64`] | `Result<f64, ConfigError>` |
40//! | [`Config::get_bool`] | `Result<bool, ConfigError>` |
41//! | [`Config::get_config`] | `Result<Config, ConfigError>` |
42//! | [`Config::get_list`] | `Result<Vec<HoconValue>, ConfigError>` |
43//! | [`Config::get_duration`] | `Result<Duration, ConfigError>` |
44//! | [`Config::get_bytes`] | `Result<i64, ConfigError>` |
45//!
46//! Each typed getter has an `_option` variant (e.g., [`Config::get_string_option`])
47//! that returns `Option<T>` instead.
48//!
49//! ## Duration and Byte-Size Values
50//!
51//! ```rust
52//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
53//! let config = hocon::parse(r#"
54//!     timeout = 30 seconds
55//!     max-upload = 512 MB
56//! "#)?;
57//!
58//! let timeout = config.get_duration("timeout")?;
59//! let max_upload = config.get_bytes("max-upload")?;
60//! # Ok(())
61//! # }
62//! ```
63//!
64//! Duration units: `ns`, `us`, `ms`, `s`/`seconds`, `m`/`minutes`, `h`/`hours`, `d`/`days`.
65//!
66//! Byte-size units: `B`, `KB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`
67//! (and their long forms like `megabytes`, `mebibytes`).
68//!
69//! ## Serde Deserialization
70//!
71//! With the `serde` feature enabled, deserialize a [`Config`] (or sub-config)
72//! into any type implementing `serde::Deserialize`:
73//!
74//! ```rust,ignore
75//! use serde::Deserialize;
76//!
77//! #[derive(Deserialize)]
78//! struct Server {
79//!     host: String,
80//!     port: u16,
81//! }
82//!
83//! let server: Server = config.get_config("server")?.deserialize()?;
84//! ```
85//!
86//! ## Include Files
87//!
88//! HOCON supports `include` directives to compose configuration from multiple files:
89//!
90//! ```hocon
91//! include "defaults.conf"
92//!
93//! server.port = 9090  # override a value from defaults
94//! ```
95//!
96//! When parsing with [`parse_file`], include paths are resolved relative to the
97//! file being parsed.
98//!
99//! ## Error Types
100//!
101//! - [`HoconError`] -- unified error returned by parse functions. Wraps:
102//!   - [`ParseError`] -- syntax errors during lexing or parsing (includes line/column).
103//!   - [`ResolveError`] -- substitution resolution failures, cycle detection.
104//!   - `std::io::Error` -- file I/O errors (top-level file read; include file errors appear as [`ResolveError`]).
105//! - [`ConfigError`] -- missing keys or type mismatches when accessing values.
106//!
107//! ## HOCON Specification
108//!
109//! For the full specification, see the
110//! [Lightbend HOCON spec](https://github.com/lightbend/config/blob/main/HOCON.md).
111
112#[cfg(any(
113    feature = "adapters-properties",
114    feature = "adapters-env",
115    feature = "adapters-jsonc",
116    feature = "adapters-toml",
117    feature = "adapters-yaml",
118))]
119pub mod adapters;
120pub mod config;
121pub mod error;
122/// Internal lexer module. Not part of the stable public API.
123///
124/// This module is `pub` to allow integration tests to access internal types.
125/// All items are subject to change without notice across minor versions.
126/// Prefer the re-exported items (`tokenize`, `Token`, etc.) over direct module access.
127#[doc(hidden)]
128pub mod lexer;
129pub(crate) mod numeric_array;
130pub mod options;
131/// Internal parser module. Not part of the stable public API.
132///
133/// This module is `pub` to allow integration tests to access internal types.
134/// All items are subject to change without notice across minor versions.
135#[doc(hidden)]
136pub mod parser;
137pub(crate) mod properties;
138/// Internal resolver module. Not part of the stable public API.
139///
140/// This module is `pub` to allow integration tests to access internal types
141/// (`build_tree`, `resolve_tree`, `merge_unresolved`, `ResObj`, etc.).
142/// All items are subject to change without notice across minor versions.
143#[doc(hidden)]
144pub mod resolver;
145pub mod value;
146mod value_factory;
147
148#[cfg(feature = "serde")]
149pub mod serde;
150
151pub use config::{Config, Period};
152pub use error::{ConfigError, HoconError, NotResolvedError, ParseError, ResolveError};
153pub use options::{ParseOptions, ResolveOptions};
154pub use value::{HoconValue, ScalarType, ScalarValue};
155pub use value_factory::empty;
156
157#[cfg(feature = "serde")]
158pub use value_factory::from_map;
159
160// Lexer surface intentionally narrow — only the items integration tests
161// and diagnostic tooling need. The full lexer module is not part of the
162// public API.
163pub use lexer::{tokenize, Segment, SubstPayload, Token, TokenKind};
164
165#[cfg(feature = "serde")]
166pub use serde::{from_value, DeserializeError};
167
168use std::collections::HashMap;
169use std::path::Path;
170
171// ── include-package feature: public Parser builder ───────────────────────────
172
173/// Builder-style parser with a per-instance package registry for
174/// `include package(...)` support (E11).
175///
176/// # Feature flag
177///
178/// This type is only available when the `include-package` Cargo feature is
179/// enabled:
180///
181/// ```toml
182/// [dependencies]
183/// hocon-parser = { version = "...", features = ["include-package"] }
184/// ```
185///
186/// # Usage
187///
188/// ```rust,ignore
189/// # #[cfg(feature = "include-package")]
190/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
191/// let config = hocon::Parser::new()
192///     .register_package("github.com/org/pkg", "reference.conf", include_str!("conf/reference.conf"))
193///     .parse_file("app.conf")?;
194/// # Ok(())
195/// # }
196/// ```
197///
198/// # Cascade convention
199///
200/// For packages that depend on other HOCON-config-providing packages, follow
201/// this convention to cascade registrations:
202///
203/// ```rust,ignore
204/// // In your package (e.g., pkg_a/src/hocon.rs):
205/// pub fn register(parser: hocon::Parser) -> hocon::Parser {
206///     let parser = parser
207///         .register_package("github.com/org/pkg_a", "reference.conf", include_str!("../conf/reference.conf"));
208///     // Cascade to dependencies:
209///     // let parser = pkg_b::hocon::register(parser);
210///     parser
211/// }
212/// ```
213///
214/// Callers:
215/// ```rust,ignore
216/// let config = pkg_a::hocon::register(hocon::Parser::new())
217///     .parse_file("app.conf")?;
218/// ```
219///
220/// # Collision policy
221///
222/// Registering two **different** content strings for the same `(identifier, file)`
223/// key **panics** — this is a programming error (setup-time invariant). Re-registering
224/// **byte-identical** content is idempotent (no panic).
225#[cfg(feature = "include-package")]
226pub struct Parser {
227    registry: HashMap<(String, String), String>,
228}
229
230#[cfg(feature = "include-package")]
231impl Default for Parser {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237#[cfg(feature = "include-package")]
238impl Parser {
239    /// Create a new `Parser` with an empty package registry.
240    pub fn new() -> Self {
241        Parser {
242            registry: HashMap::new(),
243        }
244    }
245
246    /// Register HOCON content for an `include package("identifier", "file")`
247    /// include statement.
248    ///
249    /// # Arguments
250    ///
251    /// * `identifier` — the package identifier (e.g., `"github.com/org/pkg"`).
252    ///   Should follow Go-module-path style for cross-impl portability (E11 decision 1),
253    ///   but any non-empty string is accepted by the parser.
254    /// * `file` — the file path within the package (e.g., `"reference.conf"`).
255    ///   Must satisfy E11 decision 6 constraints.
256    /// * `content` — the HOCON source text. Typically loaded via `include_str!`.
257    ///   Empty content is valid and contributes `{}` to the merge.
258    ///
259    /// # Panics
260    ///
261    /// Panics if different content is registered for the same `(identifier, file)` pair.
262    /// Re-registering byte-identical content is idempotent (no panic).
263    ///
264    /// # Example
265    ///
266    /// ```rust,ignore
267    /// let parser = hocon::Parser::new()
268    ///     .register_package("github.com/org/pkg", "reference.conf", include_str!("conf/reference.conf"))
269    ///     .register_package("github.com/org/pkg", "overrides.conf", include_str!("conf/overrides.conf"));
270    /// ```
271    pub fn register_package(
272        mut self,
273        identifier: impl Into<String>,
274        file: impl Into<String>,
275        content: impl Into<String>,
276    ) -> Self {
277        let id = identifier.into();
278        let f = file.into();
279        let c = content.into();
280        if let Some(existing) = self.registry.get(&(id.clone(), f.clone())) {
281            if existing != &c {
282                panic!(
283                    "hocon: conflicting content registered for package ({:?}, {:?}): \
284                     different content already registered for this (identifier, file) pair",
285                    id, f
286                );
287            }
288            // byte-identical: idempotent, no-op
289        } else {
290            self.registry.insert((id, f), c);
291        }
292        self
293    }
294
295    /// Parse a HOCON string using the registered package registry.
296    pub fn parse(self, input: &str) -> Result<Config, HoconError> {
297        self.parse_with_env(input, &std::env::vars().collect())
298    }
299
300    /// Parse a HOCON file using the registered package registry.
301    pub fn parse_file(self, path: impl AsRef<Path>) -> Result<Config, HoconError> {
302        self.parse_file_with_env(path, &std::env::vars().collect())
303    }
304
305    /// Parse a HOCON string with a custom environment map and the registered registry.
306    pub fn parse_with_env(
307        self,
308        input: &str,
309        env: &HashMap<String, String>,
310    ) -> Result<Config, HoconError> {
311        self.parse_with_options(input, ParseOptions::defaults().with_env(env.clone()))
312    }
313
314    /// Parse a HOCON file with a custom environment map and the registered registry.
315    pub fn parse_file_with_env(
316        self,
317        path: impl AsRef<Path>,
318        env: &HashMap<String, String>,
319    ) -> Result<Config, HoconError> {
320        self.parse_file_with_options(path, ParseOptions::defaults().with_env(env.clone()))
321    }
322
323    /// Parse a HOCON string with explicit [`ParseOptions`] and the registered registry.
324    ///
325    /// Equivalent to the module-level [`parse_string_with_options`] but threads the
326    /// per-`Parser` package registry through phase 1, enabling
327    /// `include package("identifier", "file")` to resolve against `register_package`-supplied
328    /// content. Supports both fused (`resolve_substitutions = true`, default) and deferred
329    /// (`resolve_substitutions = false`) lifecycles. The latter returns an unresolved
330    /// `Config` whose `Config::resolve` call performs phase 2 — includes are already
331    /// inlined at phase 1 so the registry is not needed after this call returns.
332    pub fn parse_with_options(self, input: &str, opts: ParseOptions) -> Result<Config, HoconError> {
333        let tokens = lexer::tokenize(input)?;
334        // S3.1 (corrected, xx.hocon E10): an empty / whitespace-only /
335        // comment-only / BOM-only document parses to the empty object per the
336        // HOCON.md L134-136 brace-omission relaxation — no emptiness guard.
337        let ast = parser::parse_tokens(&tokens)?;
338        reject_array_root(&ast, opts.origin_description.as_deref().unwrap_or("input"))?;
339
340        let env: HashMap<String, String> = opts.env.clone().unwrap_or_else(|| {
341            if opts.resolve_substitutions {
342                std::env::vars().collect()
343            } else {
344                HashMap::new()
345            }
346        });
347
348        let internal_opts = self.into_resolve_opts(env, opts.base_dir.clone());
349
350        if opts.resolve_substitutions {
351            let value = resolver::resolve(ast, &internal_opts)?;
352            match value {
353                HoconValue::Object(fields) => {
354                    let mut cfg = Config::new(fields);
355                    cfg.parse_base_dir = opts.base_dir;
356                    cfg.origin_description = opts.origin_description;
357                    Ok(cfg)
358                }
359                _ => Err(HoconError::Parse(ParseError {
360                    message: "root must be an object".into(),
361                    line: 1,
362                    col: 1,
363                })),
364            }
365        } else {
366            let tree = resolver::build_tree(ast, &internal_opts)?;
367            Ok(Config::new_from_res_obj(
368                tree,
369                opts.base_dir,
370                opts.origin_description,
371            ))
372        }
373    }
374
375    /// Parse a HOCON file with explicit [`ParseOptions`] and the registered registry.
376    /// File's parent directory is used as base_dir (overrides `opts.base_dir`).
377    pub fn parse_file_with_options(
378        self,
379        path: impl AsRef<Path>,
380        opts: ParseOptions,
381    ) -> Result<Config, HoconError> {
382        let path = path.as_ref();
383        let content = std::fs::read_to_string(path)
384            .map_err(|e| std::io::Error::new(e.kind(), format!("{}: {}", path.display(), e)))?;
385        let base_dir = path.parent().map(|p| p.to_path_buf());
386        // Default the origin to the file path so diagnostics (e.g. the S3.5
387        // array-at-file-root error) name the file, matching Lightbend origins.
388        let origin_description = opts
389            .origin_description
390            .or_else(|| Some(path.display().to_string()));
391        let opts = ParseOptions {
392            base_dir,
393            origin_description,
394            ..opts
395        };
396        self.parse_with_options(&content, opts)
397    }
398
399    /// Convert this `Parser` into `ResolveOptions`, threading the registry in.
400    fn into_resolve_opts(
401        self,
402        env: HashMap<String, String>,
403        base_dir: Option<std::path::PathBuf>,
404    ) -> resolver::InternalResolveOptions {
405        let mut opts = resolver::InternalResolveOptions::new(env);
406        if let Some(dir) = base_dir {
407            opts = opts.with_base_dir(dir);
408        }
409        opts.package_registry = std::sync::Arc::new(self.registry);
410        opts
411    }
412}
413
414/// Parse a HOCON string into a Config.
415pub fn parse(input: &str) -> Result<Config, HoconError> {
416    parse_with_env(input, &std::env::vars().collect())
417}
418
419/// Parse a HOCON string with explicit [`ParseOptions`].
420///
421/// `opts.resolve_substitutions = true` (default): fused parse + resolve, same
422/// as [`parse`]. `opts.resolve_substitutions = false`: phase 1 only; returned
423/// `Config` may have `is_resolved() = false`. Use [`Config::resolve`] later.
424///
425/// This module-level function does **not** thread a package registry — for that,
426/// use [`Parser::parse_with_options`] (feature `include-package`).
427pub fn parse_string_with_options(input: &str, opts: ParseOptions) -> Result<Config, HoconError> {
428    let tokens = lexer::tokenize(input)?;
429    let ast = parser::parse_tokens(&tokens)?;
430    reject_array_root(&ast, opts.origin_description.as_deref().unwrap_or("input"))?;
431
432    let env: HashMap<String, String> = opts.env.clone().unwrap_or_else(|| {
433        if opts.resolve_substitutions {
434            std::env::vars().collect()
435        } else {
436            HashMap::new()
437        }
438    });
439
440    let mut internal_opts = resolver::InternalResolveOptions::new(env);
441    if let Some(ref bd) = opts.base_dir {
442        internal_opts = internal_opts.with_base_dir(bd.clone());
443    }
444
445    if opts.resolve_substitutions {
446        // Fused path: phase 1 + phase 2.
447        let value = resolver::resolve(ast, &internal_opts)?;
448        match value {
449            HoconValue::Object(fields) => {
450                let mut cfg = Config::new(fields);
451                cfg.parse_base_dir = opts.base_dir;
452                cfg.origin_description = opts.origin_description;
453                Ok(cfg)
454            }
455            _ => Err(HoconError::Parse(ParseError {
456                message: "root must be an object".into(),
457                line: 1,
458                col: 1,
459            })),
460        }
461    } else {
462        // Deferred path: phase 1 only.
463        let tree = resolver::build_tree(ast, &internal_opts)?;
464        Ok(Config::new_from_res_obj(
465            tree,
466            opts.base_dir,
467            opts.origin_description,
468        ))
469    }
470}
471
472/// Parse a HOCON file with explicit [`ParseOptions`].
473/// File's parent directory is used as base_dir (overrides opts.base_dir).
474pub fn parse_file_with_options<P: AsRef<Path>>(
475    path: P,
476    opts: ParseOptions,
477) -> Result<Config, HoconError> {
478    let path = path.as_ref();
479    let content = std::fs::read_to_string(path)
480        .map_err(|e| std::io::Error::new(e.kind(), format!("{}: {}", path.display(), e)))?;
481    let base_dir = path.parent().map(|p| p.to_path_buf());
482    // Default the origin to the file path so diagnostics (e.g. the S3.5
483    // array-at-file-root error) name the file, matching Lightbend origins.
484    let origin_description = opts
485        .origin_description
486        .or_else(|| Some(path.display().to_string()));
487    let opts = ParseOptions {
488        base_dir,
489        origin_description,
490        ..opts
491    };
492    parse_string_with_options(&content, opts)
493}
494
495/// Parse a HOCON file into a Config.
496pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<Config, HoconError> {
497    parse_file_with_env(path, &std::env::vars().collect())
498}
499
500/// Parse a HOCON file with a custom environment variable map.
501pub fn parse_file_with_env<P: AsRef<Path>>(
502    path: P,
503    env: &HashMap<String, String>,
504) -> Result<Config, HoconError> {
505    let path = path.as_ref();
506    let content = std::fs::read_to_string(path)
507        .map_err(|e| std::io::Error::new(e.kind(), format!("{}: {}", path.display(), e)))?;
508    let tokens = lexer::tokenize(&content)?;
509    let ast = parser::parse_tokens(&tokens)?;
510    reject_array_root(&ast, &path.display().to_string())?;
511    let mut opts = resolver::InternalResolveOptions::new(env.clone());
512    if let Some(dir) = path.parent() {
513        opts = opts.with_base_dir(dir.to_path_buf());
514    }
515    let value = resolver::resolve(ast, &opts)?;
516    match value {
517        HoconValue::Object(fields) => Ok(Config::new(fields)),
518        _ => Err(HoconError::Parse(ParseError {
519            message: "root must be an object".into(),
520            line: 1,
521            col: 1,
522        })),
523    }
524}
525
526/// S3.5 (HOCON.md L989-991): an array-root document is valid syntax, but the
527/// object-rooted Config API rejects it at the Config boundary with a TYPE
528/// error, matching Lightbend's `Parseable.forceParsedToObject`
529/// (`ConfigException.WrongType` "has type LIST rather than object at file
530/// root"). The message carries the origin and the opening bracket's position.
531fn reject_array_root(ast: &parser::AstNode, origin: &str) -> Result<(), HoconError> {
532    if let parser::AstNode::Array { pos, .. } = ast {
533        return Err(HoconError::Config(ConfigError {
534            message: format!(
535                "{}: {}:{}: document has type array rather than object at file root (HOCON.md L989-991); the Config API requires an object at file root",
536                origin, pos.line, pos.col
537            ),
538            path: String::new(),
539        }));
540    }
541    Ok(())
542}
543
544/// Parse a HOCON string with a custom environment variable map.
545pub fn parse_with_env(input: &str, env: &HashMap<String, String>) -> Result<Config, HoconError> {
546    let tokens = lexer::tokenize(input)?;
547    let ast = parser::parse_tokens(&tokens)?;
548    reject_array_root(&ast, "input")?;
549    let opts = resolver::InternalResolveOptions::new(env.clone());
550    let value = resolver::resolve(ast, &opts)?;
551    match value {
552        HoconValue::Object(fields) => Ok(Config::new(fields)),
553        _ => Err(HoconError::Parse(ParseError {
554            message: "root must be an object".into(),
555            line: 1,
556            col: 1,
557        })),
558    }
559}
560
561/// Internal JSON renderer for use by Layer-2 fixture tests.
562///
563/// Emits compact sorted-key JSON. Not semver-stable.
564/// Callers: `tests/deferred_resolution_fixtures.rs`.
565#[doc(hidden)]
566pub fn _render_json_for_test(config: &Config) -> String {
567    use crate::value::HoconValue;
568    use std::fmt::Write;
569
570    fn render_value(val: &HoconValue, out: &mut String) {
571        match val {
572            HoconValue::Scalar(sv) => {
573                use crate::value::ScalarType;
574                match sv.value_type {
575                    ScalarType::Null => out.push_str("null"),
576                    ScalarType::Boolean => out.push_str(&sv.raw),
577                    ScalarType::Number => out.push_str(&sv.raw),
578                    ScalarType::String => {
579                        let escaped = sv
580                            .raw
581                            .replace('\\', "\\\\")
582                            .replace('"', "\\\"")
583                            .replace('\n', "\\n")
584                            .replace('\r', "\\r")
585                            .replace('\t', "\\t");
586                        let _ = write!(out, "\"{}\"", escaped);
587                    }
588                }
589            }
590            HoconValue::Object(map) => {
591                out.push('{');
592                let mut keys: Vec<&str> = map.keys().map(|s| s.as_str()).collect();
593                keys.sort_unstable();
594                for (i, k) in keys.iter().enumerate() {
595                    if i > 0 {
596                        out.push(',');
597                    }
598                    let _ = write!(out, "\"{}\":", k);
599                    render_value(map.get(*k).unwrap(), out);
600                }
601                out.push('}');
602            }
603            HoconValue::Array(arr) => {
604                out.push('[');
605                for (i, v) in arr.iter().enumerate() {
606                    if i > 0 {
607                        out.push(',');
608                    }
609                    render_value(v, out);
610                }
611                out.push(']');
612            }
613            HoconValue::Placeholder(pv) => {
614                let _ = write!(out, "\"<unresolved:{}>\"", pv.path);
615            }
616        }
617    }
618
619    let mut out = String::from("{");
620    let mut keys: Vec<&str> = config.root.keys().map(|s| s.as_str()).collect();
621    keys.sort_unstable();
622    for (i, k) in keys.iter().enumerate() {
623        if i > 0 {
624            out.push(',');
625        }
626        let _ = write!(out, "\"{}\":", k);
627        render_value(config.root.get(*k).unwrap(), &mut out);
628    }
629    out.push('}');
630    out
631}