///|
/// The variables a configuration reads: the process environment, or a
/// map (for tests and for callers that bring their own).
pub struct Environment((String) -> String?)
///|
/// The process environment (`@env.get_env_var`), on every target.
pub fn Environment::system() -> Environment {
Environment(name => @env.get_env_var(name))
}
///|
/// A fixed set of variables.
pub fn Environment::of(vars : Map[String, String]) -> Environment {
Environment(name => vars.get(name))
}
///|
/// The value of the variable `name`, or `None` when it is not set.
pub fn Environment::get(self : Environment, name : String) -> String? {
(self.0)(name)
}
///|
/// The keys a configuration knows: the single source of their TOML and
/// environment names and of their value parsers, so a value means the
/// same thing from both sources.
priv enum Key {
Gleam
Fuel
}
///|
fn Key::all() -> Array[Key] {
[Gleam, Fuel]
}
///|
fn Key::env_name(self : Key) -> String {
match self {
Gleam => "GLEAM_PARSER_GLEAM"
Fuel => "GLEAM_PARSER_FUEL"
}
}
///|
fn Key::of_toml(name : String) -> Key? {
match name {
"gleam" => Some(Gleam)
"fuel" => Some(Fuel)
_ => None
}
}
///|
fn[T] bad(source : Source, message : String) -> T raise ConfigError {
raise ConfigError(source~, message~)
}
///|
fn parse_gleam(
text : String,
source : Source,
) -> @dialect.Dialect raise ConfigError {
match @dialect.Dialect::from_version(text) {
Some(d) => d
None =>
bad(
source,
"unknown Gleam version \"\{text}\"; known: \{@dialect.Dialect::known_versions().join(", ")}",
)
}
}
///|
fn parse_fuel(units : Int64, source : Source) -> @fuel.Budget raise ConfigError {
// Both bounds before `to_int`: a value outside the Int range would wrap.
if units < 1L {
bad(source, "must be at least 1")
}
if units > 2147483647L {
bad(source, "must be at most 2147483647")
}
match @fuel.Budget::new(units.to_int()) {
Some(b) => b
None => bad(source, "must be at least 1")
}
}
///|
/// The TOML parser's message without its internal location: the text
/// after `FAILED: ` and before ` at {`.
fn toml_message(raw : String) -> String {
let text = match raw.find("FAILED: ") {
Some(i) => raw[i + 8:].to_owned()
None => raw
}
match text.find(" at {") {
Some(i) => text[:i].to_owned()
None => text
}
}
///|
/// "an integer", "a string": the TOML type name with its article.
fn article(type_name : String) -> String {
match type_name {
"integer" | "array" => "an \{type_name}"
_ => "a \{type_name}"
}
}
///|
/// The `[parser]` table of a `gleam-parser.toml` text: `gleam = "1.19"`,
/// `fuel = 100`. Every key is optional. A syntax error, an unknown table
/// or key, a value of the wrong type, a `fuel` below 1 or an unknown Gleam
/// version is a `ConfigError` that names its source.
///
/// ```mbt check
/// test {
/// let p = @config.Config::from_toml("[parser]\nfuel = 550\n")
/// debug_inspect(p.fuel.map(b => b.units()), content="Some(550)")
/// inspect(p.dialect is None, content="true")
/// }
/// ```
pub fn Config::from_toml(text : String) -> Partial raise ConfigError {
let root = @toml.parse(text) catch {
e => bad(TomlSyntax, toml_message("\{e}"))
}
let tables = match root {
TomlTable(m) => m
other =>
bad(TomlSyntax, "expected a table, found \{article(other.type_name())}")
}
let mut partial = Partial::empty()
for name, value in tables {
if name != "parser" {
bad(TomlKey(name), "unknown table")
}
let parser = match value {
TomlTable(m) => m
other =>
bad(
TomlKey(name),
"expected a table, found \{article(other.type_name())}",
)
}
for key_name, v in parser {
let source = TomlKey("parser.\{key_name}")
match Key::of_toml(key_name) {
None => bad(source, "unknown key")
Some(Gleam) =>
match v {
TomlString(s) =>
partial = { ..partial, dialect: Some(parse_gleam(s, source)), }
other =>
bad(
source,
"expected a string, found \{article(other.type_name())}",
)
}
Some(Fuel) =>
match v {
TomlInteger(n) =>
partial = { ..partial, fuel: Some(parse_fuel(n, source)), }
other =>
bad(
source,
"expected an integer, found \{article(other.type_name())}",
)
}
}
}
}
partial
}
///|
/// `GLEAM_PARSER_GLEAM` and `GLEAM_PARSER_FUEL` from `env`. An unset
/// variable leaves the field unset; an empty or bad value is a
/// `ConfigError` that names the variable.
pub fn Config::from_env(env : Environment) -> Partial raise ConfigError {
let mut partial = Partial::empty()
for key in Key::all() {
let name = key.env_name()
let source = EnvVar(name)
match env.get(name) {
None => ()
Some("") => bad(source, "empty value")
Some(text) =>
match key {
Gleam =>
partial = { ..partial, dialect: Some(parse_gleam(text, source)), }
Fuel => {
let n = @string.parse_int64(text) catch {
_ => bad(source, "expected an integer, found \"\{text}\"")
}
partial = { ..partial, fuel: Some(parse_fuel(n, source)), }
}
}
}
}
partial
}
///|
/// The configuration from the environment over the TOML file over the
/// defaults. `path` is `gleam-parser.toml` by default; a missing file
/// contributes nothing, a file that cannot be read is a `ConfigError`
/// with `File(path)` and the system's text, and a TOML error from the file
/// names the file in front of its message. The only function of this
/// package with I/O.
///
/// ```mbt nocheck
/// fn parse_with_config(text : String) -> @gleam.ParseResult raise {
/// let config = @gleam.Config::load()
/// @gleam.parse_text(text, config~)
/// }
/// ```
pub fn Config::load(
path~ : String = "gleam-parser.toml",
env? : Environment,
) -> Config raise ConfigError {
let env = match env {
Some(e) => e
None => Environment::system()
}
let file = if @fs.path_exists(path) {
let text = @fs.read_file_to_string(path) catch {
@fs.IOError(detail) => bad(File(path), "cannot read the file: \{detail}")
}
// A TOML error from the file names the file in front of its message.
Config::from_toml(text) catch {
ConfigError(source~, message~) =>
raise ConfigError(source~, message="\{path}: \{message}")
}
} else {
Partial::empty()
}
Config::from_env(env).with_fallback(file).to_config(Config::default())
}