Packages

Generate Gleam modules based on static assets and environment variables.

Current section

Files

Jump to
embeds src embeds env.gleam
Raw

src/embeds/env.gleam

//// `embeds/env` generates a Gleam module containing constants and zero-argument
//// functions based on environment variables.
////
//// This module is intended to be run as a CLI tool during your build process:
//// ```sh
//// gleam run -m embeds/env -- --help
//// ```
////
//// The functions exposed in this module can also be used directly to support
//// advanced use cases inside custom pre-build scripts.
import argv
import embeds/generate.{type Code, type Definition, type Error}
import embeds/internal
import envoy
import gleam/dict
import gleam/float
import gleam/int
import gleam/io
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/regex.{type Regex, Match}
import gleam/result
import gleam/string
import glint.{type Command}
pub type Options {
Options(
/// Name of the module to generate.
///
/// Note that the module name is not sanitized, and should represent a valid
/// Gleam module name.
/// You can use `generate.to_gleam_module_name` to turn a path or arbitrary
/// into a valid value.
module: String,
/// The module header
header: String,
// A parse function, preprocessing the environment variables and producing Gleam source code.
//
// Check out `default_parse` or `typed_parse` for built-in ways to construct
// a parse function.
parse: fn(String, String) -> Code,
/// Only environment variables matching this prefix will be included,
/// and the prefix will be stripped from the variable name.
prefix: String,
/// Optional regex that needs to match the variable name after the `prefix`
/// has been stripped.
/// If a capture group exists, the first match will be used for the environment variable.
regex: Option(Regex),
)
}
/// A default parse function, converting environment variables into
/// string constants.
pub fn default_parse(_key: String, value: String) -> Code {
generate.Constant(string.inspect(value))
}
/// Generate Gleam modules, containing constants for all found variables.
/// Exported for easier integration with other build tools.
///
/// ## Example
///
/// ```gleam
/// import embeds/env
/// import embeds/generate
/// import gleam/option
/// import gleam/io
///
/// pub fn main() {
/// let result = env.generate(env.Options(
/// module: "build_env",
/// header: generate.default_header,
/// parse: env.default_parse,
/// prefix: "BUILD_",
/// regex: option.None,
/// ))
///
/// case result {
/// Ok(Nil) -> Nil
/// Error(errs) -> io.println_error(generate.describe_errors(errs))
/// }
/// }
/// ```
pub fn generate(options: Options) -> Result(Nil, List(Error)) {
use <- generate.guard_gleam_project()
let Options(
module: module,
header: header,
parse: parse,
prefix: prefix,
regex: regex,
) = options
let defs = get_variables(module, prefix, regex)
let wrapped_parse = fn(name, code) {
case code {
generate.Text(text) -> parse(name, text)
generate.Binary(_) -> generate.Skip
}
}
generate.generate(defs, header, wrapped_parse, True)
}
//
// -- COMMAND LINE INTERFACE
//
/// runs generate using command line arguments to fill out the arguments.
pub fn main() {
glint.new()
|> glint.pretty_help(glint.default_pretty_help())
|> glint.add(at: [], do: generate_cmd())
|> glint.run(argv.load().arguments)
}
fn generate_cmd() -> Command(Nil) {
use <- glint.command_help(
"Generate Gleam modules containing String constants of whitelisted environment variables.\n\n\n"
<> "By default, environment variables starting with `BUILD_' will be included, and a module called build_env will be generated.\n\n"
<> "The generted constants have this prefix stripped.\n\n\n"
<> "You can optionally provide a list of NAME:TYPE pairs to generate constants of other types instead.\n\n"
<> "Valid types names are: int, float, bool, string",
)
use module_name <- glint.flag(
glint.string_flag("module")
|> glint.flag_default("build_env")
|> glint.flag_help(
"Name of the generated Gleam module (default: build_env)",
),
)
use prefix <- glint.flag(
glint.string_flag("prefix")
|> glint.flag_default("BUILD_")
|> glint.flag_help(
"Only environment variables matching this prefix will be included, and the prefix will be stripped from their name. (default: BUILD_)",
),
)
use filter <- glint.flag(
glint.string_flag("filter")
|> glint.flag_help(
"Use a regular expression to match environment variables. If a capture group exists, it will be used as the variable name. If both prefix and regex are given, the prefix will be stripped first.",
),
)
use _, args, flags <- glint.command()
let assert Ok(module) = module_name(flags)
let assert Ok(prefix) = prefix(flags)
let filter = filter(flags) |> option.from_result
let result = {
use regex <- result.try(case filter {
Some(filter) ->
case regex.from_string(filter) {
Ok(regex) -> Ok(Some(regex))
Error(regex.CompileError(error: error, ..)) ->
Error([generate.InvalidArgument("filter", filter, error)])
}
None -> Ok(None)
})
use definitions <- result.try(parse_definitions(args))
let options =
Options(
module: generate.to_gleam_module_name(module),
parse: typed_parse_fallback(definitions, default_parse),
prefix: prefix,
regex: regex,
header: generate.default_header,
)
generate(options)
}
case result {
Ok(Nil) -> Nil
Error(errs) -> io.println_error(generate.describe_errors(errs))
}
Nil
}
fn parse_definitions(
args: List(String),
) -> Result(List(#(String, Parser)), List(Error)) {
use arg <- internal.traverse_map(args)
case string.split(arg, on: ":") {
[name, type_name] -> {
let name = string.trim(name)
case string.trim(string.lowercase(type_name)) {
"bool" -> Ok(#(name, bool))
"int" -> Ok(#(name, int))
"float" -> Ok(#(name, float))
"string" -> Ok(#(name, string))
_ ->
Error([
generate.InvalidArgument(
"",
arg,
"Invalid type name: " <> type_name,
),
])
}
}
_ -> Error([generate.InvalidArgument("", arg, "Invalid type definition")])
}
}
//
// -- TYPED PARSE
//
/// A parser is just a function turning an environment variable into Code.
///
/// ## Custom parsers
///
/// ```gleam
/// type Environment {
/// Development
/// Testing
/// Production
/// }
///
/// fn environemnt(value: String) -> Code {
/// // you need to make sure the type constructors are available,
/// // for example by adding them to the header!
/// case value {
/// "development" -> generate.Constant(string.inspect(Development))
/// "production" -> generate.Constant(string.inspect(Production))
/// "testing" -> generate.Constant(string.inspect(Testing))
/// _ -> generate.GenerateError("Invalid Environment: " <> value)
/// }
/// }
/// ```
pub type Parser =
fn(String) -> Code
/// A function taking a list of `(key, parser)` pairs, and turning them
/// into a parse function to be used in `Options`.
///
/// This allows you to generate other types of variables, instead of just strings.
///
/// ## Example
///
/// ```gleam
/// let options = env.Options(
/// module: "build_env",
/// header: generate.default_header,
/// prefix: "BUILD_",
/// regex: option.None,
/// parse: env.typed_parse([
/// #("BUILD_ENV", string),
/// #("BUILD_REVISION", int),
/// #("BUILD_ENABLE_AWESOMENESS", bool)
/// ]),
/// )
/// ```
pub fn typed_parse(
definitions: List(#(String, Parser)),
) -> fn(String, String) -> Code {
fn(key, value) {
case list.key_find(definitions, key) {
Ok(parser) -> parser(string.trim(value))
Error(Nil) -> generate.Skip
}
}
}
/// A variant of `typed_parse` that takes an additional fallback function
/// used when the variable is not found inside of `definitions`.
///
/// Using `default_parse` as the fallback is useful to include all
/// variables not found in `definitions` as simple string constants.
pub fn typed_parse_fallback(
definitions: List(#(String, Parser)),
fallback: fn(String, String) -> Code,
) -> fn(String, String) -> Code {
fn(key, value) {
case list.key_find(definitions, key) {
Ok(parser) -> parser(string.trim(value))
Error(Nil) -> fallback(key, value)
}
}
}
/// A string parser, just passing through the value
pub fn string(value: String) -> Code {
generate.Constant(string.inspect(value))
}
/// A parser for integers literals
pub fn int(value: String) -> Code {
generate.result_to_const({
use n <- result.map(
int.parse(value)
|> result.replace_error("Invalid integer: " <> value),
)
string.inspect(n)
})
}
/// A parser for floating-point literals
pub fn float(value: String) -> Code {
generate.result_to_const({
use n <- result.map(
float.parse(value)
|> result.replace_error("Invalid number: " <> value),
)
string.inspect(n)
})
}
/// A parser for boolean flags
pub fn bool(value: String) -> Code {
let sanitized = string.trim(string.lowercase(value))
let bool = case sanitized {
"true" | "yes" | "on" | "1" -> True
_ -> False
}
generate.Constant(string.inspect(bool))
}
/// A parser for lists.
/// Common separators might be `:` or `,`
pub fn list(item_parser: Parser, separator: String) -> Parser {
fn(value) {
let list = string.split(value, on: separator)
let result = {
use #(parts, is_const), value <- list.try_fold(list, #([], True))
case item_parser(string.trim(value)) {
generate.Skip -> Ok(#(parts, is_const))
generate.GenerateError(error) -> Error(error)
generate.Constant(code) -> Ok(#([code, ..parts], is_const))
generate.FunctionBody(code) -> Ok(#([code, ..parts], False))
}
}
case result {
Ok(#(parts, is_const)) -> {
let code = string.inspect(list.reverse(parts))
case is_const {
True -> generate.Constant(code)
False -> generate.FunctionBody(code)
}
}
Error(err) -> generate.GenerateError(err)
}
}
}
//
// -- ADVANCED
//
/// Get all variables from the environment matching a prefix and a regex,
/// and convert them into definitions.
pub fn get_variables(
module: String,
prefix: String,
regex: Option(Regex),
) -> List(Definition) {
use #(key, value) <- list.filter_map(dict.to_list(envoy.all()))
use var_name <- result.try(chop_prefix(key, prefix: prefix))
use var_name <- result.try(case regex {
Some(regex) ->
case regex.scan(regex, var_name) {
[Match(submatches: [Some(matched), ..], ..), ..] -> Ok(matched)
[_, ..] -> Ok(var_name)
[] -> Error(Nil)
}
None -> Ok(var_name)
})
let var_name = generate.to_gleam_identifier(var_name)
Ok(generate.Definition(
name: key,
read: fn() { Ok(generate.Text(value)) },
last_modified_seconds: 0,
module: module,
var_name: var_name,
))
}
//
// -- HELPERS
//
fn chop_prefix(string: String, prefix prefix: String) -> Result(String, Nil) {
case string.pop_grapheme(string), string.pop_grapheme(prefix) {
Ok(#(ch1, string)), Ok(#(ch2, prefix)) if ch1 == ch2 ->
chop_prefix(string, prefix)
_, Error(Nil) -> Ok(string)
_, _ -> Error(Nil)
}
}