edadma / hocon   0.1.1

ISC License Website GitHub

A pure-Scala, cross-platform implementation of HOCON

Scala versions: 3.x
Scala.js versions: 1.x
Scala Native versions: 0.5

hocon

Maven Central Last Commit GitHub Scala Version ScalaJS Version Scala Native Version

A pure-Scala, cross-platform implementation of HOCON (Human-Optimized Config Object Notation) β€” the config format popularized by Lightbend's com.typesafe:config.

Unlike com.typesafe:config, which is JVM-only, hocon is written in pure Scala 3 β€” no java.* imports and no regex engine in its core β€” so the same parser runs on the JVM, in the browser/Node.js via Scala.js, and as a native binary via Scala Native. The identical test suite runs on all three (sbt test).

πŸ“– Full documentation: hocon.edadma.dev

Status: All seven roadmap phases complete β€” the lexer, parser, untyped Config API, object merging, substitutions, value concatenation, durations/sizes, include directives, a typed (case-class) decoder, and HOCON-spec conformance (path expressions, += append, self-referential substitutions) are in and tested on all three platforms. See the roadmap below.

Usage

import io.github.edadma.hocon.*

val config = Hocon.parse("""
  en {
    greeting = "Hello, world"
    nav { home = "Home", about = "About" }
    cart.items = "{count} items"   # path-expression key
  }
""")

config.getString("en.greeting")   // "Hello, world"
config.getString("en.nav.home")   // "Home"
config.getConfig("en").getInt("...")
config.hasPath("en.missing")      // false

// i18n placeholder helper
val msg = Messages(config.getConfig("en"))
msg("cart.items", "count" -> 3)   // "3 items"

Merging and fallback

A partial config falls back to a base β€” perfect for a base locale with per-locale overrides:

val base = Hocon.parse("""greeting = "Hello", farewell = "Goodbye"""")
val fr   = Hocon.parse("""greeting = "Bonjour"""")

val messages = Messages(fr.withFallback(base))
messages("greeting")   // "Bonjour"  (translated)
messages("farewell")   // "Goodbye"  (from base)

// Merge several layers; later arguments win:
val effective = Hocon.load(base, fr, userOverrides)

Objects merge recursively; arrays and scalars replace. A null in the override shadows (unsets) the fallback value at that key.

Substitutions

${path} references another value in the same document; ${?path} is optional and disappears when nothing is found. Substitutions resolve against the merged root, so they are order-independent.

val config = Hocon.parse("""
  host    = localhost
  url     = ${host}              # β†’ "localhost"
  service = ${defaults}          # copies the whole object
  defaults { timeout = 30 }
""")

// A missing required substitution throws; an optional one is simply absent:
Hocon.parse("x = ${?missing}").hasPath("x")   // false

// Fall back to the environment for anything not in the config:
Hocon.parse("home = ${HOME}", EnvSource.fromMap(sys.env))

Circular references throw CircularReferenceException.

Value concatenation

Pieces written side by side with only whitespace between them concatenate. Strings, numbers, and substitutions join into one string (interior whitespace preserved); arrays concatenate element-wise; objects deep-merge left to right.

val config = Hocon.parse("""
  host = example.com
  port = 8080
  url  = "http://"${host}":"${port}    // β†’ "http://example.com:8080"
  xs   = [1, 2] [3, 4]                 // β†’ [1, 2, 3, 4]
""")

Path expressions and array append

A key is a path expression: only an unquoted . separates elements, so a quoted segment may contain a literal dot, and an unquoted key with spaces is one element. A field can refer to its own prior value, and += appends an element to the array already at a key (HOCON's key = ${?key} [value] shorthand), looking back across object blocks:

val config = Hocon.parse("""
  foo."bar.baz" = 1     // path elements: foo, "bar.baz"
  server { ports = [80]  }
  server { ports += 443 }   // β†’ server.ports = [80, 443]
""")
config.getConfig("server").getStringList("ports")   // List("80", "443")

Array and object roots

Per the HOCON spec a document's root may be an object or an array. Hocon.parse returns the path-addressable Config for the common object-rooted document. When the root may be a top-level array, use Hocon.parseValue, which returns the resolved root value (ConfigObject or ConfigArray) directly:

Hocon.parseValue("[1, 2, 3]")        // ConfigArray(List(ConfigNumber("1"), ...))
Hocon.parseValue("a = 1")            // ConfigObject(...) β€” object roots work too
Hocon.parse("[1, 2, 3]")             // throws WrongTypeException β€” Config needs an object root

parseValue takes the same optional EnvSource and ConfigSource seams as parse. Inside an array root there are no keys to look into, so only ${?...} and environment fallbacks resolve.

Durations and sizes

Read unit-suffixed values with getDuration (a cross-platform FiniteDuration) and getBytes (a Long):

val config = Hocon.parse("timeout = 10s, cache = 512K")
config.getDuration("timeout")   // 10.seconds
config.getBytes("cache")        // 524288

Duration units are ns/us/ms/s/m/h/d (bare number = milliseconds); size units distinguish powers of 1024 (K, Ki, KiB) from powers of 1000 (kB, MB).

Includes

include "other.conf" pulls another document in at that point, merging its fields so later fields override them. The qualified forms pin the lookup, and required(...) errors instead of skipping a missing target. Where includes are read from goes through a ConfigSource you pass to parse; the default reads files identically on every platform:

import io.github.edadma.hocon.*

// Read files with the platform default source (identical on every platform):
val config = Hocon.parse("""include "app.conf"""", ConfigSource.default)

// Or resolve includes from memory β€” identical on every platform, ideal for tests:
val src = ConfigSource.fromMap(Map("app.conf" -> """name = "demo""""))
Hocon.parse("""include "app.conf"""", src).getString("name")   // "demo"

ConfigSource.empty (the default for Hocon.parse(text)) does no IO: optional includes are skipped and a required(...) one raises IncludeException. Pass EnvSource.system alongside to let ${VAR} substitutions fall back to the real process environment.

A note on quoting: HOCON allows unquoted strings, but forbids the characters $ " { } [ ] : = , + # ^ ? ! @ * &and` inside them. Most UI strings (Hello, world, Are you sure?, {count} items) hit one of these, so quote your translation strings. This is spec-correct HOCON, not a limitation of this library.

Typed decoding

Map a whole document onto a case class with config.as[A]. A decoder is derived from the case class at compile time (pure Mirror work, so it runs the same on all three platforms), reading each field from the object key of the same name and recursing into nested case classes:

import io.github.edadma.hocon.*
import scala.concurrent.duration.*

case class Server(host: String, port: Int, debug: Boolean)
case class App(name: String, server: Server, tags: List[String], timeout: FiniteDuration)

val config = Hocon.parse("""
  name = demo
  server { host = localhost, port = 8080, debug = true }
  tags    = [http, public]
  timeout = 30s
""")

config.as[App]
// App("demo", Server("localhost", 8080, true), List("http", "public"), 30.seconds)

String, Int/Long/Double, Boolean, FiniteDuration, Option (absent or null β†’ None), List, Map[String, _], Config, and nested case classes are supported. Failures throw MissingPathException / WrongTypeException carrying the dotted field path. Use config.getAs[A](path) to decode a value that isn't at the root.

Why

HOCON is a comfortable format for configuration and, in particular, for internationalization (i18n) message files β€” nested, commented, human-friendly:

# messages.en.conf
en {
  greeting = "Hello, world"
  nav { home = "Home", about = "About" }
  cart.items = "{count} items"      # path-expression key
}

Until now there has been no good way to read these on Scala.js or Scala Native. That's the gap hocon fills.

Roadmap

Phase Scope Status
1 Lexer + parser β†’ untyped Config (comments, quoted/unquoted strings, nested objects, path-expression keys, arrays). i18n-usable. βœ…
2 Object merging + withFallback (base locale + overrides). βœ…
3 Substitutions: ${path}, ${?path}, env fallback, cycle detection. βœ…
4 Value concatenation + durations (10s) and sizes (512K, 10MB). βœ…
5 include directives behind a pluggable, per-platform IO source. βœ…
6 Typed decoder with case-class derivation (config.as[A]). βœ…
7 HOCON-spec conformance: path expressions, += append, self-referential substitutions. βœ…

Documentation

The full documentation site is at hocon.edadma.dev:

  • Getting Started β€” add the dependency and parse your first config
  • Guide β€” the HOCON format, merging, substitutions, includes, typed decoding, and the roadmap
  • Reference β€” the Config API and the Messages i18n helper

Building and Testing

sbt hoconJVM/test
sbt hoconJS/test
sbt hoconNative/test

License

ISC β€” see LICENSE.