Reference
Metaconfig is a library to read HOCON configuration into Scala case classes. Key features of Metaconfig include
- helpful error messages on common mistakes like typos or type mismatch (expected string, obtained int)
- configurable, semi-automatic derivation of decoders, with support for deprecating setting options
- cross-platform, supports JS/JVM. Native support is on the roadmap
The target use-case for metaconfig is tool maintainers who support HOCON
configuration in their tool. Metaconfig is used by scalafmt to read
.scalafmt.conf
and scalafix to read .scalafix.conf
. With metaconfig, tool
maintainers should be able to safely evolve their configuration (deprecate old
fields, add new fields) without breaking existing configuration files. Users
should get helpful error messages when they mistype a setting name.
There are alternatives to metaconfig that you might want to give a try first
Getting started
libraryDependencies += "org.scalameta" %% "metaconfig-core" % "0.14.0"
// Use https://github.com/lightbend/config to parse HOCON
libraryDependencies += "org.scalameta" %% "metaconfig-typesafe-config" % "0.14.0"
Use this import to access the metaconfig API
import metaconfig._
All of the following code examples assume that you have import metaconfig._
in
scope.
Conf
Conf
is a JSON-like data structure that is the foundation of metaconfig.
val string = Conf.fromString("string")
// string: Conf = Str(value = "string")
val int = Conf.fromInt(42)
// int: Conf = Num(value = 42)
Conf.fromList(int :: string :: Nil)
// res0: Conf = Lst(values = List(Num(value = 42), Str(value = "string")))
Conf.fromMap(Map("a" -> string, "b" -> int))
// res1: Conf = Obj(
// values = List(("a", Str(value = "string")), ("b", Num(value = 42)))
// )
Conf.parse
You need an implicit MetaconfigParser
to convert HOCON into Conf
. Assuming
you depend on the metaconfig-typesafe-config
module,
import metaconfig.typesafeconfig._
Conf.parseString("""
a.b.c = 2
a.d = [ 1, 2, 3 ]
reference = ${a}
""")
// res2: Configured[Conf] = Ok(
// value = Obj(
// values = List(
// (
// "a",
// Obj(
// values = List(
// (
// "d",
// Lst(values = List(Num(value = 1), Num(value = 2), Num(value = 3)))
// ),
// ("b", Obj(values = List(("c", Num(value = 2)))))
// )
// )
// ),
// (
// "reference",
// Obj(
// values = List(
// (
// "d",
// Lst(values = List(Num(value = 1), Num(value = 2), Num(value = 3)))
// ),
// ("b", Obj(values = List(("c", Num(value = 2)))))
// )
// )
// )
// )
// )
// )
Conf.parseFile(new java.io.File(".scalafmt.conf"))
// res3: Configured[Conf] = Ok(
// value = Obj(
// values = List(
// (
// "fileOverride",
// Obj(
// values = List(
// (
// "glob:**/scala-3*/**",
// Obj(
// values = List(
// (
// "runner",
// Obj(values = List(("dialect", Str(value = "scala3"))))
// )
// )
// )
// )
// )
// )
// ),
// ("assumeStandardLibraryStripMargin", Bool(value = true)),
// (
// "rewrite",
// Obj(
// values = List(
// (
// "redundantParens",
// Obj(values = List(("preset", Str(value = "all"))))
// ),
// (
// "trailingCommas",
// Obj(values = List(("style", Str(value = "always"))))
// ),
// (
// "redundantBraces",
// Obj(values = List(("preset", Str(value = "all"))))
// ),
// (
// "sortModifiers",
// Obj(values = List(("preset", Str(value = "styleGuide"))))
// ),
// (
// "imports",
// Obj(
// values = List(
// ("sort", Str(value = "ascii")),
// ("expand", Bool(value = true)),
// (
// ...
Note. The example above is JVM-only. For a Scala.js alternative, depend on the
metaconfig-sconfig
module and replace metaconfig.typesafeconfig
with
import metaconfig.sconfig._
Conf.printHocon
It's possible to print Conf
as
HOCON.
Conf.printHocon(Conf.Obj(
"a" -> Conf.Obj(
"b" -> Conf.Str("3"),
"c" -> Conf.Num(1),
"d" -> Conf.Lst(
Conf.Null(),
Conf.Bool(true)
))))
// res4: String = """a.b = "3"
// a.c = 1
// a.d = [
// null
// true
// ]"""
The printer is tested against the roundtrip property
parse(print(conf)) == conf
so it should be safe to parse the output from the printer.
Conf.patch
Imagine the scenario
- your application has many configuration options with default values,
- you have a custom configuration object that overrides only a few specific fields.
- you want to pretty-print the minimal HOCON configuration to obtain that custom configuration
Use Conf.patch
compute a minimal Conf
to go from an original Conf
to a
revised Conf
.
val original = Conf.Obj(
"a" -> Conf.Obj(
"b" -> Conf.Str("c"),
"d" -> Conf.Str("e")
),
"f" -> Conf.Bool(true)
)
// original: Conf.Obj = Obj(
// values = List(
// ("a", Obj(values = List(("b", Str(value = "c")), ("d", Str(value = "e"))))),
// ("f", Bool(value = true))
// )
// )
val revised = Conf.Obj(
"a" -> Conf.Obj(
"b" -> Conf.Str("c"),
"d" -> Conf.Str("ee") // <-- only overridden setting
),
"f" -> Conf.Bool(true)
)
// revised: Conf.Obj = Obj(
// values = List(
// ("a", Obj(values = List(("b", Str(value = "c")), ("d", Str(value = "ee"))))),
// ("f", Bool(value = true))
// )
// )
val patch = Conf.patch(original, revised)
// patch: Conf = Obj(
// values = List(
// ("a", Obj(values = List(("b", Str(value = "c")), ("d", Str(value = "ee"))))),
// ("f", Bool(value = true))
// )
// )
Conf.printHocon(patch)
// res5: String = """a.b = c
// a.d = ee
// f = true"""
val revised2 = Conf.applyPatch(original, patch)
// revised2: Conf = Obj(
// values = List(
// ("f", Bool(value = true)),
// ("a", Obj(values = List(("d", Str(value = "ee")), ("b", Str(value = "c")))))
// )
// )
assert(revised == revised2)
The patch
operation is tested against the property
applyPatch(original, revised) == applyPatch(original, patch(original, revised))
ConfDecoder
To convert Conf
into higher-level data structures you need a ConfDecoder[T]
instance. Convert a partial function from Conf
to your target type using
ConfDecoder.fromPartial[T]
.
val number2 = ConfDecoder.fromPartial[Int]("String") {
case Conf.Str("2") => Configured.Ok(2)
}
number2.read(Conf.fromString("2"))
// res7: Configured[Int] = Ok(value = 2)
number2.read(Conf.fromInt(2))
// res8: Configured[Int] = NotOk(
// error = Type mismatch;
// found : Number (value: 2)
// expected : String
// )
Convert a regular function from Conf
to your target type using
ConfDecoder.from[T]
.
case class User(name: String, age: Int)
val decoder = ConfDecoder.from[User] { conf =>
conf.get[String]("name").product(conf.get[Int]("age")).map {
case (name, age) => User(name, age)
}
}
decoder.read(Conf.parseString("""
name = "Susan"
age = 29
"""))
// res9: Configured[User] = Ok(value = User(name = "Susan", age = 29))
decoder.read(Conf.parseString("""
name = 42
age = "Susan"
"""))
// res10: Configured[User] = NotOk(
// error = 2 errors
// [E0] <input>:2:0 error: Type mismatch;
// found : Number (value: 42)
// expected : String
// name = 42
// ^
//
// [E1] <input>:3:0 error: Type mismatch;
// found : String (value: "Susan")
// expected : Number
// age = "Susan"
// ^
//
//
// )
You can also use existing decoders to build more complex decoders
val fileDecoder = ConfDecoder.stringConfDecoder.flatMap { string =>
val file = new java.io.File(string)
if (file.exists()) Configured.ok(file)
else ConfError.fileDoesNotExist(file).notOk
}
// fileDecoder: ConfDecoder[java.io.File] = metaconfig.ConfDecoder$$anonfun$flatMap$2@24987570
fileDecoder.read(Conf.fromString(".scalafmt.conf"))
// res11: Configured[java.io.File] = Ok(value = .scalafmt.conf)
fileDecoder.read(Conf.fromString(".foobar"))
// res12: Configured[java.io.File] = NotOk(
// error = File /home/runner/work/metaconfig/metaconfig/.foobar does not exist.
// )
ConfDecoderEx and ConfDecoderExT
Similar to ConfDecoder
but its read
method takes an initial state as a parameter
rather than as part of the decoder instance definition. ConfDecoderEx[A]
is an alias
for ConfDecoderExT[A, A]
.
Decoding collections
If a decoder for type T
is defined, the package defines implicits to derive
decoders for Option[T]
, Seq[T]
and Map[String, T]
.
There's also a special for extending collections rather than redefining them
(works only for ConfDecoderEx
, not the original ConfDecoder
):
// sets list
a = [ ... ]
// sets map
a = {
b { ... }
c { ... }
}
// extends list
a = {
// must be the only key
"+" = [ ... ]
}
// extends map
a = {
// must be the only key
"+" = {
d { ... }
}
}
ConfEncoder
To convert a class instance into Conf
use ConfEncoder[T]
. It's possible to
automatically derive a ConfEncoder[T]
instance for any case class with
generic.deriveEncoder
.
implicit val encoder: ConfEncoder[User] = generic.deriveEncoder[User]
// encoder: ConfEncoder[User] = repl.MdocSession$MdocApp$$anon$1@2415a350
ConfEncoder[User].write(User("John", 42))
// res13: Conf = Obj(
// values = List(("name", Str(value = "John")), ("age", Num(value = 42)))
// )
It's possible to compose ConfEncoder
instances with contramap
val ageEncoder = ConfEncoder.IntEncoder.contramap[User](user => user.age)
ageEncoder.write(User("Ignored", 88))
ConfCodec
It's common to have a class that has both a ConfDecoder[T]
and
ConfEncoder[T]
instance. For convenience, it's possible to use the
ConfCodec[T]
typeclass to wrap an encoder and decoder in one instance.
case class Bijective(name: String)
implicit val surface: generic.Surface[Bijective] = generic.deriveSurface[Bijective]
implicit val codec: ConfCodec[Bijective] = generic.deriveCodec[Bijective](new Bijective("default"))
ConfEncoder[Bijective].write(Bijective("John"))
// res14: Conf = Obj(values = List(("name", Str(value = "John"))))
ConfDecoder[Bijective].read(Conf.Obj("name" -> Conf.Str("Susan")))
// res15: Configured[Bijective] = Ok(value = Bijective(name = "Susan"))
It's possible to compose ConfCodec
instances with bimap
val bijectiveString = ConfCodec.StringCodec.bimap[Bijective](_.name, Bijective(_))
bijectiveString.write(Bijective("write"))
// res16: Conf = Str(value = "write")
bijectiveString.read(Conf.Str("write"))
// res17: Configured[Bijective] = Ok(value = Bijective(name = "write"))
ConfCodecEx and ConfCodecExT
Similar to ConfCodec
but derives from ConfDecoderExT
instead of ConfDecoder
.
ConfError
ConfError
is a helper to produce readable and potentially aggregated error
messages.
ConfError.message("Not good!")
// res18: ConfError = Not good!
ConfError.exception(new IllegalArgumentException("Expected String!"), stackSize = 2)
// res19: ConfError = java.lang.IllegalArgumentException: Expected String!
// at repl.MdocSession$MdocApp.<init>(reference.md:206)
// at repl.MdocSession$.app(reference.md:3)
//
ConfError.typeMismatch("Int", "String", "field")
// res20: ConfError = Type mismatch at 'field';
// found : String
// expected : Int
ConfError.message("Failure 1").combine(ConfError.message("Failure 2"))
// res21: ConfError = 2 errors
// [E0] Failure 1
// [E1] Failure 2
//
Metaconfig uses Input
to represent a source that can be parsed and Position
to represent range positions in a given Input
val input = Input.VirtualFile(
"foo.scala",
"""
|object A {
| var x
|}
""".stripMargin
)
val i = input.text.indexOf('v')
val pos = Position.Range(input, i, i)
ConfError.parseError(pos, "No var")
// res22: ConfError = foo.scala:3:2 error: No var
// var x
// ^
//
Configured
Configured[T]
is like an Either[metaconfig.ConfError, T]
which is used
throughout the metaconfig API to either represent a successfully parsed/decoded
value or a failure.
Configured.ok("Hello world!")
// res23: Configured[String] = Ok(value = "Hello world!")
Configured.ok(List(1, 2))
// res24: Configured[List[Int]] = Ok(value = List(1, 2))
val error = ConfError.message("Boom!")
// error: ConfError = Boom!
val configured = error.notOk
// configured: Configured[Nothing] = NotOk(error = Boom!)
configured.toEither
// res25: Either[ConfError, Nothing] = Left(value = Boom!)
To skip error handling, use the nuclear .get
configured.get
// java.util.NoSuchElementException: Boom!
// at metaconfig.Configured.get(Configured.scala:15)
// at repl.MdocSession$MdocApp$$anonfun$7.apply(reference.md:262)
// at repl.MdocSession$MdocApp$$anonfun$7.apply(reference.md:262)
Configured.ok(42).get
// res26: Int = 42
generic.deriveSurface
To use automatic derivation, you first need a Surface[T]
typeclass instance
import metaconfig.generic._
implicit val userSurface: Surface[User] =
generic.deriveSurface[User]
// userSurface: Surface[User] = Surface(List(List(Field(name="name",tpe="String",annotations=List(),underlying=List()), Field(name="age",tpe="Int",annotations=List(),underlying=List()))))
The surface is used by metaconfig to support configurable decoding such as
alternative fields names. In the future, the plan is to use Surface[T]
to
automatically generate html/markdown documentation for configuration settings.
For now, you can ignore Surface[T]
and just consider it as an annoying
requirement from metaconfig.
generic.deriveDecoder
Writing manual decoder by hand grows tiring quickly. This becomes especially true when you have documentation to keep up-to-date as well.
implicit val decoder: ConfDecoder[User] =
generic.deriveDecoder[User](User("John", 42)).noTypos
implicit val decoderEx: ConfDecoderEx[User] =
generic.deriveDecoderEx[User](User("Jane", 24)).noTypos
ConfDecoder[User].read(Conf.parseString("""
name = Susan
age = 34
"""))
// res27: Configured[User] = Ok(value = User(name = "Susan", age = 34))
ConfDecoder[User].read(Conf.parseString("""
nam = John
age = 23
"""))
// res28: Configured[User] = NotOk(
// error = <input>:2:0 error: found option 'nam' which wasn't expected, or isn't valid in this context.
// Did you mean 'name'?
// nam = John
// ^
//
// )
ConfDecoder[User].read(Conf.parseString("""
name = John
age = Old
"""))
// res29: Configured[User] = NotOk(
// error = <input>:3:0 error: Type mismatch;
// found : String (value: "Old")
// expected : Number
// age = Old
// ^
//
// )
ConfDecoderEx[User].read(
Some(User(name = "Jack", age = 33)),
Conf.parseString("name = John")
)
// res30: Configured[User] = Ok(value = User(name = "John", age = 33))
ConfDecoderEx[User].read(
None,
Conf.parseString("name = John")
)
// res31: Configured[User] = Ok(value = User(name = "John", age = 24))
Sometimes automatic derivation fails, for example if your class contains fields
that have no ConfDecoder
instance
import java.io.File
case class Funky(file: File)
implicit val surface = generic.deriveSurface[Funky]
// surface: Surface[Funky] = Surface(List(List(Field(name="file",tpe="File",annotations=List(@TabCompleteAsPath()),underlying=List()))))
This will fail with a fail cryptic compile error
implicit val decoder = generic.deriveDecoder[Funky](Funky(new File("")))
// error: decoder is already defined as value decoder
// error: could not find implicit value for parameter ev: metaconfig.ConfDecoder[repl.MdocSession.MdocApp.User]
// error: could not find implicit value for parameter ev: metaconfig.ConfDecoder[repl.MdocSession.MdocApp.User]
// error: could not find implicit value for parameter ev: metaconfig.ConfDecoder[repl.MdocSession.MdocApp.User]
// error: could not find implicit value for evidence parameter of type metaconfig.ConfDecoder[java.io.File]
// implicit val decoder = generic.deriveDecoder[Funky](Funky(new File("")))
// ^
Observe that the error message is complaining about a missing
metaconfig.ConfDecoder[java.io.File]
implicit.
Limitations
The following features are not supported by generic derivation
- derivation for objects, sealed traits or non-case classes, only case classes are supported
- parameterized types, it's possible to derive decoders for a concrete
parameterized type like
Option[Foo]
but note that the type field (Field.tpe
) will be pretty-printed to the generic representation of that field:Option[T].value: T
.
Renaming settings
As your configuration evolves, you may want to rename or completely redefine some settings but you have existing users who are using the old name. Below are some ways to manage these transitions.
@metaconfig.annotation.SectionRename
This functionality provides a rich way to move or transform settings across the configuration.
Since v0.14.0.
These transformations are applied to the
Conf
object before passing it to the decoder, in the order in which they are specified.Do not use @DeprecatedName or @ExtraName on the same section; instead, define an additional
SectionRename
for the other aliases.
This can be accomplished in one of two ways:
- via a call to
.withSectionRenames(...)
with explicit rename arguments, with each argument either:- a tuple with
(old section name, new section name)
, or - an explicit
metaconfig.annotation.SectionRename
instance which also adds a partial function re-mappingmetaconfig.Conf
value
- a tuple with
- via a call to
.detectSectionRenames
when the target type is provided with one or more@SectionRename(...)
annotations- using the partial-function argument in this case requires first defining it as a static variable (say, in a companion object); as of this writing, inlining the function in the annotation leads to typechecker errors in the compiler
import metaconfig.annotation._
case class Human(
name: String = "",
family: Option[Family] = None
)
@SectionRename("wife", "spouse")
case class Family(
spouse: String = "",
children: List[String] = Nil
)
object Human {
/** will parse correctly:
* {{{
* name = "John Doe"
* spouse = "Jane Doe" # maps to `family.spouse = ...`
* }}}
*/
implicit val surface: generic.Surface[Human] =
generic.deriveSurface[Human]
implicit val decoder: ConfDecoder[Human] =
generic.deriveDecoder(Human()).noTypos.withSectionRenames(
"wife" -> "family.spouse",
"spouse" -> "family.spouse",
SectionRename {
case Conf.Lst(kids) => Conf.Lst(
kids.zipWithIndex.map {
case (Conf.Str(kid), idx) =>
Conf.Str(s"#$idx $kid")
case (kid, _) => kid
}
)
} ("kids", "family.children")
)
}
object Family {
implicit val surface: generic.Surface[Family] =
generic.deriveSurface[Family]
implicit val decoder: ConfDecoder[Family] =
generic.deriveDecoder[Family](Family()).noTypos.detectSectionRenames
}
Human.decoder.read(Conf.Obj(
// this rename takes priority as it comes first
"spouse" -> Conf.Str("Jane Doe (spouse)"),
"family" -> Conf.Obj(
// this rename is ignored as it is applied later in the processing
"wife" -> Conf.Str("Jane Doe (wife)")
)
))
// res33: Configured[Human] = Ok(
// value = Human(
// name = "",
// family = Some(
// value = Family(spouse = "Jane Doe (spouse)", children = List())
// )
// )
// )
Human.decoder.read(Conf.Obj(
// this rename is ignored as there's a primary parameter defined below
"spouse" -> Conf.Str("Jane Doe 1"),
"family" -> Conf.Obj(
// primary parameter, takes precedence over any renames
"spouse" -> Conf.Str("Jane Doe 2")
)
))
// res34: Configured[Human] = Ok(
// value = Human(
// name = "",
// family = Some(value = Family(spouse = "Jane Doe 2", children = List()))
// )
// )
Human.decoder.read(Conf.Obj(
"name" -> Conf.Str("Bob Parr"),
"wife" -> Conf.Str("Elastigirl"),
"kids" -> Conf.Lst(
Conf.Str("Violet"),
Conf.Str("Dash"),
Conf.Str("Jack-Jack")
)
))
// res35: Configured[Human] = Ok(
// value = Human(
// name = "Bob Parr",
// family = Some(
// value = Family(
// spouse = "Elastigirl",
// children = List("#0 Violet", "#1 Dash", "#2 Jack-Jack")
// )
// )
// )
// )
@metaconfig.annotation.ExtraName
This provides a simple way to define aliases for the same configuration section or parameter.
These alternative aliases are used by the decoder when looking up a field value in the provided
Conf
object when there's no value defined for the primary field.Therefore, do not use
@ExtraName
in combination with @SectionRename above if you wish it to take priority over other conflicting renames, as you might end up with part of the configuration undetected and unused.Instead, use an equivalent
SectionRename(alias, primary)
but make sure to prepend it to otherSectionRename(oldFieldName, primary.newFieldName)
definitions.
import metaconfig.annotation._
case class EvolvingConfig(
@ExtraName("isValidName")
isGoodName: Boolean
)
implicit val surface: generic.Surface[EvolvingConfig] =
generic.deriveSurface[EvolvingConfig]
implicit val decoder: ConfDecoder[EvolvingConfig] =
generic.deriveDecoder[EvolvingConfig](EvolvingConfig(true)).noTypos
decoder.read(Conf.Obj("isGoodName" -> Conf.fromBoolean(false)))
// res36: Configured[EvolvingConfig] = Ok(
// value = EvolvingConfig(isGoodName = false)
// )
decoder.read(Conf.Obj("isValidName" -> Conf.fromBoolean(false)))
// res37: Configured[EvolvingConfig] = Ok(
// value = EvolvingConfig(isGoodName = false)
// )
@metaconfig.annotation.DeprecatedName
This annotation behaves similarly to ExtraName but also documents the change for the developers, to prepare them for migration. The same caveat with respect to SectionRename applies.
import metaconfig.annotation._
case class EvolvingConfig(
@DeprecatedName("goodName", "Use isGoodName instead", "1.0")
isGoodName: Boolean
)
implicit val surface: generic.Surface[EvolvingConfig] =
generic.deriveSurface[EvolvingConfig]
implicit val decoder: ConfDecoder[EvolvingConfig] =
generic.deriveDecoder[EvolvingConfig](EvolvingConfig(true)).noTypos
decoder.read(Conf.Obj("goodName" -> Conf.fromBoolean(false)))
// res38: Configured[EvolvingConfig] = Ok(
// value = EvolvingConfig(isGoodName = false)
// )
decoder.read(Conf.Obj("isGoodName" -> Conf.fromBoolean(false)))
// res39: Configured[EvolvingConfig] = Ok(
// value = EvolvingConfig(isGoodName = false)
// )
decoder.read(Conf.Obj("gooodName" -> Conf.fromBoolean(false)))
// res40: Configured[EvolvingConfig] = NotOk(
// error = found option 'gooodName' which wasn't expected, or isn't valid in this context.
// Did you mean 'isGoodName'?
// )
Conf.parseCliArgs
Metaconfig can parse command line arguments into a Conf
.
case class App(
@Description("The directory to output files")
target: String = "out",
@Description("Print out debugging diagnostics")
@ExtraName("v")
verbose: Boolean = false,
@Description("The input files for app")
@ExtraName("remainingArgs")
files: List[String] = Nil
)
implicit val surface = generic.deriveSurface[App]
implicit val codec = generic.deriveCodec[App](App())
val conf = Conf.parseCliArgs[App](List(
"--verbose",
"--target", "/tmp",
"input.txt"
))
// conf: Configured[Conf] = Ok(
// value = Obj(
// values = List(
// ("remainingArgs", Lst(values = List(Str(value = "input.txt")))),
// ("target", Str(value = "/tmp")),
// ("verbose", Bool(value = true))
// )
// )
// )
Decode the cli args into App
like normal
val app = decoder.read(conf.get)
// app: Configured[EvolvingConfig] = NotOk(
// error = 3 errors
// [E0] found option 'remainingArgs' which wasn't expected, or isn't valid in this context.
// [E1] found option 'target' which wasn't expected, or isn't valid in this context.
// [E2] found option 'verbose' which wasn't expected, or isn't valid in this context.
//
// )
Settings.toCliHelp
Generate a --help message with a Settings[T]
.
Settings[App].toCliHelp(default = App())
// res41: String = """--target: String = "out" The directory to output files
// --verbose: Boolean = false Print out debugging diagnostics
// --files: List[String] = [] The input files for app"""
@Inline
If you have multiple cli apps that all share a base set of fields you can use
@Inline
.
case class Common(
@Description("The working directory")
cwd: String = "",
@Description("The output directory")
out: String = ""
)
implicit val surface = generic.deriveSurface[Common]
implicit val codec = generic.deriveCodec[Common](Common())
case class AgeApp(
@Description("The user's age")
age: Int = 0,
@Inline
common: Common = Common()
)
implicit val ageSurface = generic.deriveSurface[AgeApp]
implicit val ageCodec = generic.deriveCodec[AgeApp](AgeApp())
case class NameApp(
@Description("The user's name")
name: String = "John",
@Inline
common: Common = Common()
)
implicit val nameSurface = generic.deriveSurface[NameApp]
implicit val nameCodec = generic.deriveCodec[NameApp](NameApp())
Observe that NameApp
and AgeApp
both have an @Inline common: Common
field.
It is not necessary to prefix cli args with the name of @Inline
fields. In the
example above, it's possible to pass in --out target
instead of
--common.out target
to override the common output directory.
Conf.parseCliArgs[NameApp](List("--out", "/tmp", "--cwd", "working-dir"))
// res42: Configured[Conf] = Ok(
// value = Obj(
// values = List(
// (
// "common",
// Obj(
// values = List(
// ("cwd", Str(value = "working-dir")),
// ("out", Str(value = "/tmp"))
// )
// )
// )
// )
// )
// )
val conf = Conf.parseCliArgs[AgeApp](List("--out", "target", "--cwd", "working-dir"))
// conf: Configured[Conf] = Ok(
// value = Obj(
// values = List(
// (
// "common",
// Obj(
// values = List(
// ("cwd", Str(value = "working-dir")),
// ("out", Str(value = "target"))
// )
// )
// )
// )
// )
// )
conf.get.as[AgeApp].get
// res43: AgeApp = AgeApp(
// age = 0,
// common = Common(cwd = "working-dir", out = "target")
// )
The generated --help message does not display @Inline
fields. Instead, the
nested fields inside the type of the @Inline
field are shown in the --help
message.
Settings[NameApp].toCliHelp(default = NameApp())
// res44: String = """--name: String = "John" The user's name
// --cwd: String = "" The working directory
// --out: String = "" The output directory"""
Docs
To generate documentation for you configuration, add a dependency to the following module
libraryDependencies += "org.scalameta" %% "metaconfig-docs" % "0.14.0"
First define your configuration
import metaconfig._
import metaconfig.annotation._
import metaconfig.generic._
case class Home(
@Description("Address description")
address: String = "Lakelands 2",
@Description("Country description")
country: String = "Iceland"
)
implicit val homeSurface: Surface[Home] = generic.deriveSurface[Home]
implicit val homeEncoder: ConfEncoder[Home] = generic.deriveEncoder[Home]
case class User(
@Description("Name description")
name: String = "John",
@Description("Age description")
age: Int = 42,
home: Home = Home()
)
implicit val userSurface: Surface[User] = generic.deriveSurface[User]
implicit val userEncoder: ConfEncoder[User] = generic.deriveEncoder[User]
To generate html documentation, pass in a default value
docs.Docs.html(User())
// res46: String = "<table><thead><tr><th>Name</th><th>Type</th><th>Description</th><th>Default value</th></tr></thead><tbody><tr><td><code>name</code></td><td><code>String</code></td><td>Name description</td><td>"John"</td></tr><tr><td><code>age</code></td><td><code>Int</code></td><td>Age description</td><td>42</td></tr><tr><td><code>home.address</code></td><td><code>String</code></td><td>Address description</td><td>"Lakelands 2"</td></tr><tr><td><code>home.country</code></td><td><code>String</code></td><td>Country description</td><td>"Iceland"</td></tr></tbody></table>"
The output will look like this when rendered in a markdown or html document
Name | Type | Description | Default value |
---|---|---|---|
name | String | Name description | "John" |
age | Int | Age description | 42 |
home.address | String | Address description | "Lakelands 2" |
home.country | String | Country description | "Iceland" |
The Docs.html
method does nothing magical, it's possible to implement custom
renderings by inspecting Settings[T]
directly.
Settings[User].settings
// res48: List[Setting] = List(
// Setting(Field(name="name",tpe="String",annotations=List(@Description(Name description)),underlying=List())),
// Setting(Field(name="age",tpe="Int",annotations=List(@Description(Age description)),underlying=List())),
// Setting(Field(name="home",tpe="Home",annotations=List(),underlying=List(List(Field(name="address",tpe="String",annotations=List(@Description(Address description)),underlying=List()), Field(name="country",tpe="String",annotations=List(@Description(Country description)),underlying=List())))))
// )
val flat = Settings[User].flat(ConfEncoder[User].writeObj(User()))
// flat: List[(Setting, Conf)] = List(
// (
// Setting(Field(name="name",tpe="String",annotations=List(@Description(Name description)),underlying=List())),
// Str(value = "John")
// ),
// (
// Setting(Field(name="age",tpe="Int",annotations=List(@Description(Age description)),underlying=List())),
// Num(value = 42)
// ),
// (
// Setting(Field(name="home.address",tpe="String",annotations=List(@Description(Address description)),underlying=List())),
// Str(value = "Lakelands 2")
// ),
// (
// Setting(Field(name="home.country",tpe="String",annotations=List(@Description(Country description)),underlying=List())),
// Str(value = "Iceland")
// )
// )
flat.map { case (setting, defaultValue) =>
s"Setting ${setting.name} of type ${setting.tpe} has default value $defaultValue"
}.mkString("\n==============\n")
// res49: String = """Setting name of type String has default value "John"
// ==============
// Setting age of type Int has default value 42
// ==============
// Setting home.address of type String has default value "Lakelands 2"
// ==============
// Setting home.country of type String has default value "Iceland""""