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