moonconf

    Configuration files, their expansion, their layering and their binding to a typed model.

    config
    ini
    properties
    dotenv
    hocon
    hcl
    moonbit
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    20
    moonconf

    Configuration for MoonBit: the formats a deployment actually ships — INI, Java properties, .env, HOCON and HCL — read into one tree, layered in the order a program wants them, checked against a model, and bound to typed values. Every value keeps the source and line it came from, so a refusal can say where the value was written.

    This package reads bytes and builds mappings. It does not open files or fetch a URL: a caller reads its sources and hands them in, which is what makes every part of it testable on every target. The one exception is the process environment, which a HOCON document consults for a name it does not define — that is what the format means by a substitution, and it is a lookup rather than a read of a stream.

    #The tree

    Every reader produces the same tree, so a value from an INI file and a value from the environment can be compared, merged and bound the same way.

    test {
    let node = @moonconf.Node::map([
    ("db", @moonconf.Node::map([("host", @moonconf.Node::str("localhost", "app.ini"))], "app.ini")),
    ], "app.ini")
    assert_eq(node.need_text("db.host"), "localhost")
    }

    • A value is null, boolean, integer, real, text, a list, or a mapping. An integer stays an integer, so 8080 is not printed back as 8080.0.
    • A mapping remembers the order its members were written in, so a file written back is the file that was read.
    • Every value carries the source it came from and the position in it, which is what an error message needs to be useful.

    Lookups use a dotted path; a segment of digits indexes a list, and \. is a literal dot.

    test {
    let node = @moonconf.Node::map([
    ("servers", @moonconf.Node::seq([
    @moonconf.Node::str("one", "app.conf"),
    @moonconf.Node::str("two", "app.conf"),
    ], "app.conf")),
    ], "app.conf")
    assert_eq(node.need_text("servers.1"), "two")
    }

    #Layers

    A configuration is usually four answers to the same question, and the last one said is the one meant. merge takes them in any order and settles it by tier.

    test {
    let defaults = @moonconf.Node::map([
    ("port", @moonconf.Node::num(5432, "defaults")),
    ], "defaults")
    let file = @moonconf.Node::map([
    ("port", @moonconf.Node::num(6432, "app.ini")),
    ], "app.ini")
    let merged = @moonconf.merge([
    @moonconf.Source::new(@moonconf.File, "app.ini", file),
    @moonconf.Source::new(@moonconf.Default, "defaults", defaults),
    ])
    assert_eq(merged.need_int("port"), 6432L)
    }

    • The tiers run Default, File, Env, Override. The wins field of Knobs lists them weakest first, so the stack can be reversed — a patch read under a document — without touching a parser.
    • Mappings merge member by member: a later file that sets one key under db keeps the rest of the earlier file's db. Lists replace, because a list is one decision.
    • A disagreement is Clash::Last (the default), Clash::First, or Clash::Refuse, which reports both positions.

    #Formats

    PackageReadsWritesKnobs
    moonconf/inisections, = or :, continuations, defaults, two interpolationsyesstandard, extended, literal
    moonconf/propsJava properties, ISO-8859-1 and escapesyes—
    moonconf/dotenv.env with quotes, escapes, continuation and referencesyesinterpolated, verbatim
    moonconf/expandPOSIX parameter expansion—strict, lenient, deferred
    moonconf/hoconJSON with relaxed punctuation, merging, references, includes—ambient, plain, with_includes, with_fallback
    moonconf/hclHCL 2 native and JSON syntax, evaluated against a context—Context, standard

    #INI

    [DEFAULT] host = localhost [db] port = 5432 url = postgres://%(host)s/db

    test {
    let doc = @ini.parse("[db]\nport = 5432\nurl = postgres://localhost/db\n")
    assert_eq(doc.get("db", "url"), "postgres://localhost/db")
    assert_eq(doc.sections(), ["db"])
    }

    doc.tree() turns a document into the shared tree: one mapping per section, with the defaults folded in and the references resolved.

    #Properties

    # a note db.host=localhost greeting=\u4f60\u597d

    test {
    let doc = @props.parse("db.host=localhost\n")
    assert_eq(doc.get("db.host"), Some("localhost"))
    }

    Names are names, not paths: db.host is one key. load takes bytes as ISO-8859-1, one byte to one character; parse takes text.

    #.env

    export HOST=localhost DSN="postgres://${HOST}/app" GREETING='it\'s here'

    test {
    let env = @dotenv.load("HOST=localhost\nDSN=\"postgres://${HOST}/app\"\n")
    assert_eq(env.get("DSN"), Some("postgres://localhost/app"))
    }

    A reference sees the entries above it, so the order in the file is the order of evaluation. A later assignment wins. The two kinds of quote differ in their escapes, not in whether a reference is expanded: inside single quotes only \' and \\ mean anything, while double quotes also take \n, \t, \r and the rest. A # starts a comment at the beginning of a line or after whitespace, and is part of the value when a character comes before it, so a#b is a value and a # b is a.

    A line that is not an assignment — a stray word, a quote that never closes — is kept in Doc::skipped rather than failing the whole file: one bad line should not cost a deployment its configuration.

    #POSIX expansion

    test {
    let vars = @expand.Vars::of_map(Map([("PORT", "8080")]))
    assert_eq(@expand.expand(":${PORT:-80}", vars), ":8080")
    assert_eq(@expand.expand("${MISSING:-fallback}", vars), "fallback")
    }

    ${A}, $A, ${A:-b}, ${A-b}, ${A:=b}, ${A=b}, ${A:?msg}, ${A?msg}, ${A:+b}, ${A+b} and ${#A}, with \ escaping $, \, " and a backtick.

    A name with no value is an error by default — a missing setting is nearly always the bug. lenient reads it as empty, deferred leaves ${A} for a later pass, and { unset: Keep } does the same field by field.

    #HOCON

    include "base.conf" db.name = app url = "postgres://${db.host}:${db.port}/${db.name}" hosts = [a, b] hosts += c cache = ${?optional}

    test {
    let doc = @hocon.parse("db { host = localhost }\nurl = \"pg://${db.host}/x\"\n")
    assert_eq(doc.need_text("url"), "pg://localhost/x")
    }

    • The same name twice merges: objects member by member, arrays end to end, and anything else keeps the later one.
    • ${a.b} is the value at that path; ${?a.b} is that value when it is there and nothing at all when it is not, so a field that depends on an unset name simply is not there.
    • include "x" asks the caller's read for the text. include url("…") and include classpath("…") ask the same function, told which wrapper was written, so a caller that can fetch a URL can answer all three. Nothing is fetched here: reading is I/O, and this package does none.
    • A value written twice may refer to itself: a = 1 then a = ${a} 2 is 1 2, and db = ${db} { port = 2 } overrides one member of db and keeps the rest.
    • A name the document does not define is looked up in the process environment, so path = ${HOME}/bin works without the caller arranging anything. plain reads a document on its own, for a test or a machine-independent build.
    • A cycle that is not a definition building on itself is reported as one, rather than resolved into nonsense.

    #HCL

    region = var.region name = upper("web") server "web" { port = 80 + 1 }

    test {
    let names = @moonconf.Node::map([("region", @moonconf.Node::str("eu-west-1", "vars"))], "vars")
    let body = @hcl.parse("region = var.region\n", context=@hcl.Context::new().bind("var", names))
    assert_eq(body.get("region").unwrap().text(), "eu-west-1")
    }

    Expressions are evaluated as they are read, against a Context: names are values, functions are functions, and a string interpolates what an expression inside it evaluates to. @hcl.standard() provides upper, lower, trim, length, join, concat and coalesce; a caller adds its own with Context::function.

    parse_json reads the JSON syntax, where an object whose members are all objects is a block and the member names are its labels.

    Body::tree() turns a body into the shared tree: attributes under their names, and blocks under theirs, so a document of blocks merges like any other.

    A string is a template: ${ … } inserts an expression, %{ if } … %{ else } …%{ endif } chooses, and %{ for x in xs } … %{ endfor } repeats, with ~ on either side of a marker to take the whitespace around it away. <<EOF and <<-EOF are heredocs, interpolated the same way. A list or a mapping can be built by a for expression, with an optional if filter, and xs[*].name asks the same question of every member.

    A for over a list or a mapping builds either; {for s in xs : s.team => s.name...} gathers the values that share a key into a list, and without the ... the last one written wins.

    #Binding

    A model says what a valid configuration is. Binding checks the tree against it, fills in the defaults, and hands back JSON in the shape the model describes, which @json.from_json turns into a struct of the caller's own.

    test {
    let model = @moonmodel.Model::new("Db", [
    @moonmodel.Field::new("host", @moonmodel.Type::Text, required=true),
    @moonmodel.Field::new("port", @moonmodel.Type::Int, required=true),
    @moonmodel.Field::new("pool", @moonmodel.Type::Int, default=Json::number(4.0)),
    ])
    let tree = @moonconf.Node::map([
    ("host", @moonconf.Node::str("localhost", "app.ini")),
    ("port", @moonconf.Node::str("5432", "env")),
    ], "app.ini")
    assert_eq(
    @moonconf.bind(tree, model),
    @json.parse("{\"host\":\"localhost\",\"port\":5432,\"pool\":4}"),
    )
    }

    Text is read as the number or the boolean it spells, because an environment variable is always text. Text that does not spell the type is refused, not guessed at.

    validate reports every mistake at once instead of stopping at the first, for a caller that wants to show a person everything wrong with their file.

    #Errors

    One error type per package, and one shape: what was wanted, what was there, and where.

    db.port (env@1:1): want integer, got text

    • path names the field, as a dotted path.
    • want and got name the shapes, in the words a person would use.
    • at is the position in the source, and source is the name the caller gave it.

    #API

    #moonconf

    FunctionMeaning
    merge(sources, knobs?)Merge layers into one tree
    Node::merge(base, over, clash?=Last)Merge two trees
    Node::find(path) / need(path)The value at a dotted path
    Node::need_text/int/bool/real/list/table(path)The value, as a type
    bind(node, model, known?, knobs?)Check against a model, as JSON
    validate(node, model, known?, knobs?)Every refusal, not just the first
    native(node)The tree as JSON, without provenance
    of_json/of_yaml/of_toml(text, source?)Read a borrowed format into the tree

    #moonconf/expand

    FunctionMeaning
    expand(text, vars, knobs?)Expand a text in the shell's parameter syntax
    Vars::of(get) / Vars::writing(get, set) / Vars::of_map(map)Where names come from

    #moonconf/ini

    FunctionMeaning
    parse(text, knobs?)Read a document
    Doc::get(section, option) / raw / get_orThe value, interpolated or not
    Doc::sections() / options(section) / has_optionWhat is in it
    Doc::tree(source?)The document as the shared tree
    dumps(doc)Write it back

    #moonconf/props

    FunctionMeaning
    parse(text) / load(bytes)Read text, or bytes as ISO-8859-1
    Doc::get(key) / keys() / items()The pairs
    Doc::tree(source?)The file as the shared tree
    dumps(doc, comment?)Write it back

    #moonconf/dotenv

    FunctionMeaning
    parse(text, source?)The entries, and the lines that were not ones
    load(text, vars?, knobs?, source?)The variables, references expanded
    tree(text, vars?, knobs?, source?)The file as the shared tree
    dumps(entries)Write it back

    #moonconf/hocon

    FunctionMeaning
    parse(text, knobs?, source?)Read a document into the shared tree
    Knobs::with_read(read) / with_includes(from)Where an include comes from, with or without its wrapper; the environment stays the fallback
    Knobs::with_fallback(lookup)Replace where an unresolved name is looked up

    #moonconf/hcl

    FunctionMeaning
    parse(text, context?, source?)Read the native syntax
    parse_json(text, source?)Read the JSON syntax
    Body::get(name) / blocks_of(kind) / tree(source)What is in a body
    Context::new() / bind(name, value) / function(name, body)Names and functions in scope
    standard()The functions a configuration file usually needs

    #License

    Apache-2.0.

    Refused

    pub(all) suberror Refused {
    Type(want~ : String, got~ : String, path~ : String, source~ : String, at~ : At)
    Missing(path~ : String, want~ : String)
    Unknown(path~ : String, got~ : String)
    Clash(path~ : String, first~ : String, second~ : String)
    Path(got~ : String, at~ : Int)
    Format(why~ : String, at~ : At?)
    TooDeep(at~ : At, limit~ : Int)
    }

    Why a configuration was refused, and where.

    One error type for the whole repository's root package: reading a borrowed format, merging layers, looking a path up, binding to a model. A caller catches one thing and reads one message. The message names the source and the position when the value came from text, and names the field path — db.pool.size — whenever the refusal is about a place in the tree, because a message that says "expected an integer" without saying where is a message that sends the reader looking through the whole file.
    impl Show for Refused

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

    pub(all) struct At {
    at : Int
    line : Int
    column : Int
    } derive(Eq,
    Debug
    )

    Where in a document a value was written.

    The offset is in UTF-16 code units from the start of the source, which is what a [StringView] indexes by. Line and column are one-based, because that is how an editor counts, and an error a reader cannot find in their file is half an error.

    A source that does not report positions — the JSON, YAML and TOML readers this package borrows keep the tree but not the offsets — fills in { at: 0, line: 1, column: 1 }, and says so where it matters.

    At::equal

    fn At::equal(At, At) -> Bool

    At::not_equal

    fn At::not_equal(x : At, y : At) -> Bool

    At::of

    fn At::of(text : StringView, offset : Int) -> At

    The position of offset in text, counted only when something has already gone wrong.

    At::to_repr

    Bind

    pub(all) struct Bind {
    extra : Extra
    } derive(Eq,
    Debug
    )

    How to bind.

    Bind::equal

    fn Bind::equal(Bind, Bind) -> Bool

    Bind::not_equal

    fn Bind::not_equal(x : Bind, y : Bind) -> Bool

    Bind::to_repr

    Clash

    pub(all) enum Clash {
    Last
    First
    Refuse
    } derive(Eq,
    Debug
    )

    What to do when two layers name the same value and disagree.

    Clash::equal

    fn Clash::equal(Clash, Clash) -> Bool

    Clash::not_equal

    fn Clash::not_equal(x : Clash, y : Clash) -> Bool

    Clash::to_repr

    Extra

    pub(all) enum Extra {
    Allow
    Refuse
    } derive(Eq,
    Debug
    )

    What to do with a member no field of the model describes.

    Extra::equal

    fn Extra::equal(Extra, Extra) -> Bool

    Extra::not_equal

    fn Extra::not_equal(x : Extra, y : Extra) -> Bool

    Extra::to_repr

    Knobs

    pub(all) struct Knobs {
    wins : Array[Tier]
    clash : Clash
    } derive(Eq,
    Debug
    )

    How to merge: which tier is stronger, and what a disagreement means.

    wins runs from the weakest tier to the strongest, and a tier left out of it sorts below every tier in it, in the order the sources were given. Putting the tiers in a different order is how a caller reverses the direction of the whole stack — an overlay read after the base file, say — without touching any parser.

    Knobs::equal

    fn Knobs::equal(Knobs, Knobs) -> Bool

    Knobs::not_equal

    fn Knobs::not_equal(x : Knobs, y : Knobs) -> Bool

    Knobs::to_repr

    Node

    pub(all) struct Node {
    value : Value
    source : String
    at : At
    } derive(Eq,
    Debug
    )

    A value and the provenance that makes an error message usable: which source it came from, and where in that source it was written.

    Node::bool

    fn Node::bool(self : Node) -> Bool raise Refused

    The boolean.

    Node::copy

    fn Node::copy(self : Node) -> Node

    A deep copy: the tree a caller gets back is theirs to change without disturbing the one it came from.

    Node::equal

    fn Node::equal(Node, Node) -> Bool

    Node::find

    fn Node::find(self : Node, path : String) -> Node?

    The value at path, or nothing.

    A segment of digits indexes into a list, so servers.0.host names the first server's host without a second notation to learn.

    Node::flag

    fn Node::flag(flag : Bool, source : String, at? : At) -> Node

    A boolean node.

    Node::from

    fn Node::from(self : Node, source : String, at? : At) -> Node

    The same value, said to come from somewhere else. A merge uses this to remember which layer won.

    Node::has

    fn Node::has(self : Node, path : String) -> Bool

    Whether a path is present.

    Node::int

    fn Node::int(self : Node) -> Int64 raise Refused

    The integer, or a [Type] refusal. A real with no fractional part is accepted, because 8080.0 and 8080 are the same port.

    Node::is_list

    fn Node::is_list(self : Node) -> Bool

    Whether this is a list.

    Node::is_map

    fn Node::is_map(self : Node) -> Bool

    Whether this is a mapping.

    Node::kind

    fn Node::kind(self : Node) -> String

    The name of the shape a value has, for error messages: text, integer, real, boolean, null, list, mapping.

    Node::list

    fn Node::list(self : Node) -> Array[Node] raise Refused

    The members of a list.

    Node::map

    fn Node::map(pairs : Array[(String, Node)], source : String, at? : At) -> Node

    A mapping node built from pairs, in the order given.

    Node::merge

    fn Node::merge(self : Node, over : Node, clash? : Clash, path? : String) -> Node raise Refused

    Merge two trees. over is the stronger one; the policy decides what happens where they disagree.

    Mappings merge member by member, so a later file that sets one key under db keeps the rest of the earlier file's db. Lists replace rather than concatenate: a list in a configuration is nearly always one decision — which hosts, which plugins — and appending the two decisions together gives a value nobody wrote.

    Node::need

    fn Node::need(self : Node, path : String) -> Node raise Refused

    The value at path, or a [Missing] refusal naming the path.

    Node::need_bool

    fn Node::need_bool(self : Node, path : String) -> Bool raise Refused

    The boolean at path.

    Node::need_int

    fn Node::need_int(self : Node, path : String) -> Int64 raise Refused

    The integer at path.

    Node::need_text

    fn Node::need_text(self : Node, path : String) -> String raise Refused

    The text at path.

    A value of another shape is refused with the path in the sentence, because a caller who asked for db.port wants to be told about db.port rather than about a position in a file.

    Node::new

    fn Node::new(value : Value, source : String, at : At) -> Node

    A node of any value, with the source it came from and where in it.

    Node::not_equal

    fn Node::not_equal(x : Node, y : Node) -> Bool

    Node::num

    fn Node::num(n : Int64, source : String, at? : At) -> Node

    An integer node.

    Node::of_real

    fn Node::of_real(value : Double, source : String, at? : At) -> Node

    A real-number node.

    Node::real

    fn Node::real(self : Node) -> Double raise Refused

    The number as a real.

    Node::seq

    fn Node::seq(items : Array[Node], source : String, at? : At) -> Node

    A list node.

    Node::str

    fn Node::str(text : String, source : String, at? : At) -> Node

    A text node.

    Node::table

    fn Node::table(self : Node) -> Table raise Refused

    The members of a mapping.

    Node::text

    fn Node::text(self : Node) -> String raise Refused

    The text, or a [Type] refusal naming what was there instead.

    Node::to_repr

    Source

    pub(all) struct Source {
    tier : Tier
    name : String
    node : Node
    } derive(Eq,
    Debug
    )

    One layer of a configuration: a tree, the tier it belongs to, and the name that goes into error messages.

    Source::equal

    fn Source::equal(Source, Source) -> Bool

    Source::new

    fn Source::new(tier : Tier, name : String, node : Node) -> Source

    A source whose name is taken from the tree it carries.

    Source::not_equal

    fn Source::not_equal(x : Source, y : Source) -> Bool

    Source::to_repr

    Table

    pub(all) struct Table {
    entries : Array[(String, Node)]
    } derive(Eq,
    Debug
    )

    A mapping that remembers the order its members arrived in, because a configuration file does: an INI file written back with its sections sorted alphabetically is a different file, and a person diffing it sees noise instead of their change.

    Table::copy

    fn Table::copy(self : Table) -> Table

    A copy, one level deep: the members are shared, the mapping is not.

    Table::equal

    fn Table::equal(Table, Table) -> Bool

    Table::get

    fn Table::get(self : Table, key : String) -> Node?

    The member called key, or nothing.

    Table::has

    fn Table::has(self : Table, key : String) -> Bool

    Whether key has a member.

    Table::is_empty

    fn Table::is_empty(self : Table) -> Bool

    Whether the mapping has no members.

    Table::items

    fn Table::items(self : Table) -> ArrayView[(String, Node)]

    The members in the order they were written.

    Table::keys

    fn Table::keys(self : Table) -> Array[String]

    The names, in the order they were written.

    Table::length

    fn Table::length(self : Table) -> Int

    How many members the mapping has.

    Table::new

    fn Table::new() -> Table

    An empty mapping.

    Table::not_equal

    fn Table::not_equal(x : Table, y : Table) -> Bool

    Table::remove

    fn Table::remove(self : Table, key : String) -> Node?

    Drop key, giving back what it held.

    Table::set

    fn Table::set(self : Table, key : String, value : Node) -> Unit

    Put value under key, in the place key already holds if it holds one.

    Keeping the position is what makes a merge readable: the winner of a clash stays where the losing value was, so the merged document still reads in the order a person wrote it.

    Table::to_repr

    Tier

    pub(all) enum Tier {
    Default
    File
    Env
    Override
    } derive(Eq,
    Debug
    )

    Which layer of a configuration a source belongs to.

    The tiers are here rather than as a number the caller assigns because the order almost everybody wants — defaults, then files, then the environment, then whatever the command line said — is a fact about deployment, not about this repository, and naming it means a caller who wants the usual thing does not write it out.

    Tier::equal

    fn Tier::equal(Tier, Tier) -> Bool

    Tier::not_equal

    fn Tier::not_equal(x : Tier, y : Tier) -> Bool

    Tier::to_repr

    Value

    pub(all) enum Value {
    Null
    Bool(Bool)
    Int(Int64)
    Real(Double)
    Text(String)
    List(Array[Node])
    Map(Table)
    } derive(Eq,
    Debug
    )

    A value in a configuration tree.

    Numbers keep their kind: an integer stays an integer so that a port number written as 8080 is not printed back as 8080.0, and so that a binding to an Int field can accept it without guessing.

    Value::equal

    fn Value::equal(Value, Value) -> Bool

    Value::not_equal

    fn Value::not_equal(x : Value, y : Value) -> Bool

    Value::to_repr

    bind

    Check a tree against a model and hand back the bound values as JSON, with defaults filled in and text read as the type it spells.

    The JSON is the shape the model describes, so @json.from_json turns it into a struct of the caller's own. known resolves Ref fields by model name; a reference nothing resolves is carried through unchecked rather than guessed at.

    closed

    let closed : Bind

    Bind the members the model describes and refuse the rest.

    decimal

    fn decimal(text : StringView) -> Double?

    The real number text spells, or nothing.

    layered

    let layered : Knobs

    Defaults below files below the environment below explicit overrides, with the stronger layer winning.

    merge

    fn merge(sources : Array[Source], knobs? : Knobs) -> Node raise Refused

    Merge a whole stack, weakest first, in the order knobs asks for.

    Sources of the same tier keep the order they were given, so Source order is the tie-break and a caller never has to number anything.

    native

    fn native(node : Node) -> Json

    The tree as JSON, keeping the shape and dropping the provenance.

    of_json

    fn of_json(text : StringView, source? : String) -> Node raise Refused

    Read a JSON document into the tree.

    Positions are not carried: the reader this borrows reports where it refused, not where each value sat, so a value carries the source name and the first position. A refusal keeps the reader's own position.

    of_toml

    fn of_toml(text : StringView, source? : String) -> Node raise Refused

    Read a TOML document into the tree.

    of_yaml

    fn of_yaml(text : StringView, source? : String) -> Node raise Refused

    Read a YAML document into the tree.

    One document, in YAML 1.2 core schema. Anchors, aliases and merge keys are resolved by the reader; what arrives here is the value.

    open

    let open : Bind

    Bind the members the model describes and carry the rest through.

    segments

    fn segments(path : String) -> Array[String] raise Refused

    Split a dotted path into its segments.

    A dot separates members; \. is a literal dot, so a key that contains one can still be named. An empty segment — a..b, .a, a. — is refused rather than skipped, because a path with a hole in it is a typo.

    spot

    fn spot(source : String, at : At) -> String

    The source and position part of a refusal, rendered as name@line:column.

    start

    let start : At

    The first position in a source: what to use when nothing better is known.

    validate

    Every way node fails to be what model describes, in tree order.

    Binding stops at the first refusal, because a caller who is going to fail wants one clear message. A caller who wants to show a person everything wrong with a file at once uses this: it walks the whole tree and collects.

    whole

    fn whole(text : StringView) -> Int64?

    The whole number text spells, or nothing.

    Configuration text is mostly digits that a program has to read as a number, and the readers in this repository refuse rather than guess. Nothing is trimmed here: a value with a space in it is not a number, and saying so is better than quietly accepting it.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io