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
112pub mod config;
113pub mod error;
114/// Internal lexer module. Not part of the stable public API.
115///
116/// This module is `pub` to allow integration tests to access internal types.
117/// All items are subject to change without notice across minor versions.
118/// Prefer the re-exported items (`tokenize`, `Token`, etc.) over direct module access.
119#[doc(hidden)]
120pub mod lexer;
121pub(crate) mod numeric_array;
122pub mod options;
123/// Internal parser module. Not part of the stable public API.
124///
125/// This module is `pub` to allow integration tests to access internal types.
126/// All items are subject to change without notice across minor versions.
127#[doc(hidden)]
128pub mod parser;
129pub(crate) mod properties;
130/// Internal resolver module. Not part of the stable public API.
131///
132/// This module is `pub` to allow integration tests to access internal types
133/// (`build_tree`, `resolve_tree`, `merge_unresolved`, `ResObj`, etc.).
134/// All items are subject to change without notice across minor versions.
135#[doc(hidden)]
136pub mod resolver;
137pub mod value;
138mod value_factory;
139
140#[cfg(feature = "serde")]
141pub mod serde;
142
143pub use config::{Config, Period};
144pub use error::{ConfigError, HoconError, NotResolvedError, ParseError, ResolveError};
145pub use options::{ParseOptions, ResolveOptions};
146pub use value::{HoconValue, ScalarType, ScalarValue};
147pub use value_factory::empty;
148
149#[cfg(feature = "serde")]
150pub use value_factory::from_map;
151
152// Lexer surface intentionally narrow — only the items integration tests
153// and diagnostic tooling need. The full lexer module is not part of the
154// public API.
155pub use lexer::{tokenize, Segment, SubstPayload, Token, TokenKind};
156
157#[cfg(feature = "serde")]
158pub use serde::DeserializeError;
159
160use std::collections::HashMap;
161use std::path::Path;
162
163// ── include-package feature: public Parser builder ───────────────────────────
164
165/// Builder-style parser with a per-instance package registry for
166/// `include package(...)` support (E11).
167///
168/// # Feature flag
169///
170/// This type is only available when the `include-package` Cargo feature is
171/// enabled:
172///
173/// ```toml
174/// [dependencies]
175/// hocon-parser = { version = "...", features = ["include-package"] }
176/// ```
177///
178/// # Usage
179///
180/// ```rust,ignore
181/// # #[cfg(feature = "include-package")]
182/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
183/// let config = hocon::Parser::new()
184///     .register_package("github.com/org/pkg", "reference.conf", include_str!("conf/reference.conf"))
185///     .parse_file("app.conf")?;
186/// # Ok(())
187/// # }
188/// ```
189///
190/// # Cascade convention
191///
192/// For packages that depend on other HOCON-config-providing packages, follow
193/// this convention to cascade registrations:
194///
195/// ```rust,ignore
196/// // In your package (e.g., pkg_a/src/hocon.rs):
197/// pub fn register(parser: hocon::Parser) -> hocon::Parser {
198///     let parser = parser
199///         .register_package("github.com/org/pkg_a", "reference.conf", include_str!("../conf/reference.conf"));
200///     // Cascade to dependencies:
201///     // let parser = pkg_b::hocon::register(parser);
202///     parser
203/// }
204/// ```
205///
206/// Callers:
207/// ```rust,ignore
208/// let config = pkg_a::hocon::register(hocon::Parser::new())
209///     .parse_file("app.conf")?;
210/// ```
211///
212/// # Collision policy
213///
214/// Registering two **different** content strings for the same `(identifier, file)`
215/// key **panics** — this is a programming error (setup-time invariant). Re-registering
216/// **byte-identical** content is idempotent (no panic).
217#[cfg(feature = "include-package")]
218pub struct Parser {
219    registry: HashMap<(String, String), String>,
220}
221
222#[cfg(feature = "include-package")]
223impl Default for Parser {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229#[cfg(feature = "include-package")]
230impl Parser {
231    /// Create a new `Parser` with an empty package registry.
232    pub fn new() -> Self {
233        Parser {
234            registry: HashMap::new(),
235        }
236    }
237
238    /// Register HOCON content for an `include package("identifier", "file")`
239    /// include statement.
240    ///
241    /// # Arguments
242    ///
243    /// * `identifier` — the package identifier (e.g., `"github.com/org/pkg"`).
244    ///   Should follow Go-module-path style for cross-impl portability (E11 decision 1),
245    ///   but any non-empty string is accepted by the parser.
246    /// * `file` — the file path within the package (e.g., `"reference.conf"`).
247    ///   Must satisfy E11 decision 6 constraints.
248    /// * `content` — the HOCON source text. Typically loaded via `include_str!`.
249    ///   Empty content is valid and contributes `{}` to the merge.
250    ///
251    /// # Panics
252    ///
253    /// Panics if different content is registered for the same `(identifier, file)` pair.
254    /// Re-registering byte-identical content is idempotent (no panic).
255    ///
256    /// # Example
257    ///
258    /// ```rust,ignore
259    /// let parser = hocon::Parser::new()
260    ///     .register_package("github.com/org/pkg", "reference.conf", include_str!("conf/reference.conf"))
261    ///     .register_package("github.com/org/pkg", "overrides.conf", include_str!("conf/overrides.conf"));
262    /// ```
263    pub fn register_package(
264        mut self,
265        identifier: impl Into<String>,
266        file: impl Into<String>,
267        content: impl Into<String>,
268    ) -> Self {
269        let id = identifier.into();
270        let f = file.into();
271        let c = content.into();
272        if let Some(existing) = self.registry.get(&(id.clone(), f.clone())) {
273            if existing != &c {
274                panic!(
275                    "hocon: conflicting content registered for package ({:?}, {:?}): \
276                     different content already registered for this (identifier, file) pair",
277                    id, f
278                );
279            }
280            // byte-identical: idempotent, no-op
281        } else {
282            self.registry.insert((id, f), c);
283        }
284        self
285    }
286
287    /// Parse a HOCON string using the registered package registry.
288    pub fn parse(self, input: &str) -> Result<Config, HoconError> {
289        self.parse_with_env(input, &std::env::vars().collect())
290    }
291
292    /// Parse a HOCON file using the registered package registry.
293    pub fn parse_file(self, path: impl AsRef<Path>) -> Result<Config, HoconError> {
294        self.parse_file_with_env(path, &std::env::vars().collect())
295    }
296
297    /// Parse a HOCON string with a custom environment map and the registered registry.
298    pub fn parse_with_env(
299        self,
300        input: &str,
301        env: &HashMap<String, String>,
302    ) -> Result<Config, HoconError> {
303        self.parse_with_options(input, ParseOptions::defaults().with_env(env.clone()))
304    }
305
306    /// Parse a HOCON file with a custom environment map and the registered registry.
307    pub fn parse_file_with_env(
308        self,
309        path: impl AsRef<Path>,
310        env: &HashMap<String, String>,
311    ) -> Result<Config, HoconError> {
312        self.parse_file_with_options(path, ParseOptions::defaults().with_env(env.clone()))
313    }
314
315    /// Parse a HOCON string with explicit [`ParseOptions`] and the registered registry.
316    ///
317    /// Equivalent to the module-level [`parse_string_with_options`] but threads the
318    /// per-`Parser` package registry through phase 1, enabling
319    /// `include package("identifier", "file")` to resolve against `register_package`-supplied
320    /// content. Supports both fused (`resolve_substitutions = true`, default) and deferred
321    /// (`resolve_substitutions = false`) lifecycles. The latter returns an unresolved
322    /// `Config` whose `Config::resolve` call performs phase 2 — includes are already
323    /// inlined at phase 1 so the registry is not needed after this call returns.
324    pub fn parse_with_options(self, input: &str, opts: ParseOptions) -> Result<Config, HoconError> {
325        let tokens = lexer::tokenize(input)?;
326        assert_non_empty_document(&tokens)?;
327        let ast = parser::parse_tokens(&tokens)?;
328
329        let env: HashMap<String, String> = opts.env.clone().unwrap_or_else(|| {
330            if opts.resolve_substitutions {
331                std::env::vars().collect()
332            } else {
333                HashMap::new()
334            }
335        });
336
337        let internal_opts = self.into_resolve_opts(env, opts.base_dir.clone());
338
339        if opts.resolve_substitutions {
340            let value = resolver::resolve(ast, &internal_opts)?;
341            match value {
342                HoconValue::Object(fields) => {
343                    let mut cfg = Config::new(fields);
344                    cfg.parse_base_dir = opts.base_dir;
345                    cfg.origin_description = opts.origin_description;
346                    Ok(cfg)
347                }
348                _ => Err(HoconError::Parse(ParseError {
349                    message: "root must be an object".into(),
350                    line: 1,
351                    col: 1,
352                })),
353            }
354        } else {
355            let tree = resolver::build_tree(ast, &internal_opts)?;
356            Ok(Config::new_from_res_obj(
357                tree,
358                opts.base_dir,
359                opts.origin_description,
360            ))
361        }
362    }
363
364    /// Parse a HOCON file with explicit [`ParseOptions`] and the registered registry.
365    /// File's parent directory is used as base_dir (overrides `opts.base_dir`).
366    pub fn parse_file_with_options(
367        self,
368        path: impl AsRef<Path>,
369        opts: ParseOptions,
370    ) -> Result<Config, HoconError> {
371        let path = path.as_ref();
372        let content = std::fs::read_to_string(path)
373            .map_err(|e| std::io::Error::new(e.kind(), format!("{}: {}", path.display(), e)))?;
374        let base_dir = path.parent().map(|p| p.to_path_buf());
375        let opts = ParseOptions { base_dir, ..opts };
376        self.parse_with_options(&content, opts)
377    }
378
379    /// Convert this `Parser` into `ResolveOptions`, threading the registry in.
380    fn into_resolve_opts(
381        self,
382        env: HashMap<String, String>,
383        base_dir: Option<std::path::PathBuf>,
384    ) -> resolver::InternalResolveOptions {
385        let mut opts = resolver::InternalResolveOptions::new(env);
386        if let Some(dir) = base_dir {
387            opts = opts.with_base_dir(dir);
388        }
389        opts.package_registry = std::sync::Arc::new(self.registry);
390        opts
391    }
392}
393
394/// Parse a HOCON string into a Config.
395pub fn parse(input: &str) -> Result<Config, HoconError> {
396    parse_with_env(input, &std::env::vars().collect())
397}
398
399/// Parse a HOCON string with explicit [`ParseOptions`].
400///
401/// `opts.resolve_substitutions = true` (default): fused parse + resolve, same
402/// as [`parse`]. `opts.resolve_substitutions = false`: phase 1 only; returned
403/// `Config` may have `is_resolved() = false`. Use [`Config::resolve`] later.
404///
405/// This module-level function does **not** thread a package registry — for that,
406/// use [`Parser::parse_with_options`] (feature `include-package`).
407pub fn parse_string_with_options(input: &str, opts: ParseOptions) -> Result<Config, HoconError> {
408    let tokens = lexer::tokenize(input)?;
409    assert_non_empty_document(&tokens)?;
410    let ast = parser::parse_tokens(&tokens)?;
411
412    let env: HashMap<String, String> = opts.env.clone().unwrap_or_else(|| {
413        if opts.resolve_substitutions {
414            std::env::vars().collect()
415        } else {
416            HashMap::new()
417        }
418    });
419
420    let mut internal_opts = resolver::InternalResolveOptions::new(env);
421    if let Some(ref bd) = opts.base_dir {
422        internal_opts = internal_opts.with_base_dir(bd.clone());
423    }
424
425    if opts.resolve_substitutions {
426        // Fused path: phase 1 + phase 2.
427        let value = resolver::resolve(ast, &internal_opts)?;
428        match value {
429            HoconValue::Object(fields) => {
430                let mut cfg = Config::new(fields);
431                cfg.parse_base_dir = opts.base_dir;
432                cfg.origin_description = opts.origin_description;
433                Ok(cfg)
434            }
435            _ => Err(HoconError::Parse(ParseError {
436                message: "root must be an object".into(),
437                line: 1,
438                col: 1,
439            })),
440        }
441    } else {
442        // Deferred path: phase 1 only.
443        let tree = resolver::build_tree(ast, &internal_opts)?;
444        Ok(Config::new_from_res_obj(
445            tree,
446            opts.base_dir,
447            opts.origin_description,
448        ))
449    }
450}
451
452/// Parse a HOCON file with explicit [`ParseOptions`].
453/// File's parent directory is used as base_dir (overrides opts.base_dir).
454pub fn parse_file_with_options<P: AsRef<Path>>(
455    path: P,
456    opts: ParseOptions,
457) -> Result<Config, HoconError> {
458    let path = path.as_ref();
459    let content = std::fs::read_to_string(path)
460        .map_err(|e| std::io::Error::new(e.kind(), format!("{}: {}", path.display(), e)))?;
461    let base_dir = path.parent().map(|p| p.to_path_buf());
462    let opts = ParseOptions { base_dir, ..opts };
463    parse_string_with_options(&content, opts)
464}
465
466/// Parse a HOCON file into a Config.
467pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<Config, HoconError> {
468    parse_file_with_env(path, &std::env::vars().collect())
469}
470
471/// Parse a HOCON file with a custom environment variable map.
472pub fn parse_file_with_env<P: AsRef<Path>>(
473    path: P,
474    env: &HashMap<String, String>,
475) -> Result<Config, HoconError> {
476    let path = path.as_ref();
477    let content = std::fs::read_to_string(path)
478        .map_err(|e| std::io::Error::new(e.kind(), format!("{}: {}", path.display(), e)))?;
479    let tokens = lexer::tokenize(&content)?;
480    assert_non_empty_document(&tokens)?;
481    let ast = parser::parse_tokens(&tokens)?;
482    let mut opts = resolver::InternalResolveOptions::new(env.clone());
483    if let Some(dir) = path.parent() {
484        opts = opts.with_base_dir(dir.to_path_buf());
485    }
486    let value = resolver::resolve(ast, &opts)?;
487    match value {
488        HoconValue::Object(fields) => Ok(Config::new(fields)),
489        _ => Err(HoconError::Parse(ParseError {
490            message: "root must be an object".into(),
491            line: 1,
492            col: 1,
493        })),
494    }
495}
496
497/// Parse a HOCON string with a custom environment variable map.
498pub fn parse_with_env(input: &str, env: &HashMap<String, String>) -> Result<Config, HoconError> {
499    let tokens = lexer::tokenize(input)?;
500    assert_non_empty_document(&tokens)?;
501    let ast = parser::parse_tokens(&tokens)?;
502    let opts = resolver::InternalResolveOptions::new(env.clone());
503    let value = resolver::resolve(ast, &opts)?;
504    match value {
505        HoconValue::Object(fields) => Ok(Config::new(fields)),
506        _ => Err(HoconError::Parse(ParseError {
507            message: "root must be an object".into(),
508            line: 1,
509            col: 1,
510        })),
511    }
512}
513
514/// Internal JSON renderer for use by Layer-2 fixture tests.
515///
516/// Emits compact sorted-key JSON. Not semver-stable.
517/// Callers: `tests/deferred_resolution_fixtures.rs`.
518#[doc(hidden)]
519pub fn _render_json_for_test(config: &Config) -> String {
520    use crate::value::HoconValue;
521    use std::fmt::Write;
522
523    fn render_value(val: &HoconValue, out: &mut String) {
524        match val {
525            HoconValue::Scalar(sv) => {
526                use crate::value::ScalarType;
527                match sv.value_type {
528                    ScalarType::Null => out.push_str("null"),
529                    ScalarType::Boolean => out.push_str(&sv.raw),
530                    ScalarType::Number => out.push_str(&sv.raw),
531                    ScalarType::String => {
532                        let escaped = sv
533                            .raw
534                            .replace('\\', "\\\\")
535                            .replace('"', "\\\"")
536                            .replace('\n', "\\n")
537                            .replace('\r', "\\r")
538                            .replace('\t', "\\t");
539                        let _ = write!(out, "\"{}\"", escaped);
540                    }
541                }
542            }
543            HoconValue::Object(map) => {
544                out.push('{');
545                let mut keys: Vec<&str> = map.keys().map(|s| s.as_str()).collect();
546                keys.sort_unstable();
547                for (i, k) in keys.iter().enumerate() {
548                    if i > 0 {
549                        out.push(',');
550                    }
551                    let _ = write!(out, "\"{}\":", k);
552                    render_value(map.get(*k).unwrap(), out);
553                }
554                out.push('}');
555            }
556            HoconValue::Array(arr) => {
557                out.push('[');
558                for (i, v) in arr.iter().enumerate() {
559                    if i > 0 {
560                        out.push(',');
561                    }
562                    render_value(v, out);
563                }
564                out.push(']');
565            }
566            HoconValue::Placeholder(pv) => {
567                let _ = write!(out, "\"<unresolved:{}>\"", pv.path);
568            }
569        }
570    }
571
572    let mut out = String::from("{");
573    let mut keys: Vec<&str> = config.root.keys().map(|s| s.as_str()).collect();
574    keys.sort_unstable();
575    for (i, k) in keys.iter().enumerate() {
576        if i > 0 {
577            out.push(',');
578        }
579        let _ = write!(out, "\"{}\":", k);
580        render_value(config.root.get(*k).unwrap(), &mut out);
581    }
582    out.push('}');
583    out
584}
585
586/// Guard: reject token streams that carry no semantic content (HOCON.md L130).
587///
588/// An empty document is one whose token stream contains only `Newline` and `Eof`
589/// tokens after the lexer has already stripped whitespace, BOM, and comments.
590/// A document with at least one structural or value token (including `{`, `}`,
591/// unquoted/quoted text, substitutions, …) is not empty even if it resolves to
592/// an empty object.
593fn assert_non_empty_document(tokens: &[lexer::Token]) -> Result<(), HoconError> {
594    let has_content = tokens
595        .iter()
596        .any(|t| !matches!(t.kind, lexer::TokenKind::Newline | lexer::TokenKind::Eof));
597    if !has_content {
598        return Err(HoconError::Parse(ParseError {
599            message: "empty file is not a valid HOCON document (HOCON.md L130)".into(),
600            line: 1,
601            col: 1,
602        }));
603    }
604    Ok(())
605}