Current section
Files
Jump to
Current section
Files
src/atproto_codegen.gleam
//// Lexicon -> Gleam codegen. Reads `<lexicons_dir>/**/*.json` and writes
//// typed modules into `<out_dir>`, importable as `<out_module>/...`.
////
//// Pipeline: read lexicon files, decode via `atproto_lexicon/decoding` into
//// `ast.LexiconDoc`, `codegen/lower` flattens those into the view plugins
//// consume, `codegen/plugin` runs the registered plugins and assembles
//// their per-module sections, `codegen/module_header` computes each
//// module's import header, then this module writes the files.
import argv
import atproto_codegen/config.{type Config, Config}
import atproto_codegen/lower.{type FlatLexicon}
import atproto_codegen/module_header
import atproto_codegen/naming
import atproto_codegen/plugin
import atproto_codegen/plugins/decode_json
import atproto_codegen/plugins/types
import atproto_codegen/plugins/xrpc_client
import atproto_lexicon/ast.{type LexiconDoc}
import atproto_lexicon/decoding
import gleam/dict
import gleam/erlang/application
import gleam/io
import gleam/list
import gleam/option.{None, Some}
import gleam/result
import gleam/string
import simplifile
const generated_banner = "//// Generated by codegen from priv/internal.gleam. Do not edit by hand.\n\n"
const module_banner = "//// Generated by codegen from lexicons/. Do not edit by hand.\n\n"
const usage = "usage: gleam run -- <lexicons_dir> <out_dir> <out_module> <nsid_prefix>[,<nsid_prefix>...] [<client_module_subpath>]"
pub fn main() -> Nil {
case argv.load().arguments {
[lexicons_dir, out_dir, out_module, prefixes] ->
run(Config(
lexicons_dir:,
out_dir:,
out_module:,
nsid_prefixes: string.split(prefixes, ","),
client: None,
))
[lexicons_dir, out_dir, out_module, prefixes, client] ->
run(Config(
lexicons_dir:,
out_dir:,
out_module:,
nsid_prefixes: string.split(prefixes, ","),
client: Some(client),
))
_ -> io.println(usage)
}
}
/// `types` then `decode-json` interleave per section; the opt-in `xrpc-client` runs last and owns its own whole file, so it never interleaves.
fn plugin_order(cfg: Config) -> List(String) {
case cfg.client {
Some(_) -> ["types", "decode-json", "xrpc-client"]
None -> ["types", "decode-json"]
}
}
fn plugins(cfg: Config) -> List(plugin.Plugin) {
case cfg.client {
Some(_) -> [types.plugin(), decode_json.plugin(), xrpc_client.plugin()]
None -> [types.plugin(), decode_json.plugin()]
}
}
fn run(cfg: Config) -> Nil {
let assert Ok(files) = simplifile.get_files(cfg.lexicons_dir)
let docs =
files
|> list.filter(string.ends_with(_, ".json"))
|> list.filter_map(fn(path) {
case load_doc(path) {
Ok(doc) -> Ok(doc)
Error(reason) -> {
io.println(" WARN skipping " <> path <> ": " <> reason)
Error(Nil)
}
}
})
let lexicons = lower.lower(docs)
report_unsupported(lexicons)
report_unresolved_refs(lexicons)
let assert Ok(_) = simplifile.create_directory_all(cfg.out_dir)
let assert Ok(internal) = simplifile.read(internal_template())
let assert Ok(_) =
simplifile.write(
to: cfg.out_dir <> "/internal.gleam",
contents: generated_banner <> internal,
)
let assert Ok(outputs) = plugin.run(plugins(cfg), cfg, docs, lexicons)
let assert Ok(assembled) = plugin.assemble(outputs, plugin_order(cfg))
let bodies = dict.from_list(assembled)
list.each(lexicons, fn(lex) {
case list.filter(lex.defs, lower.is_emittable) {
[] -> Nil
defs -> {
let subpath = naming.module_subpath(cfg.nsid_prefixes, lex.nsid)
let path = subpath <> ".gleam"
let assert Ok(body) = dict.get(bodies, path)
let header = module_header.header(cfg, lex.nsid, defs)
let source = module_banner <> header <> "\n" <> body <> "\n"
let full_path = cfg.out_dir <> "/" <> path
let assert Ok(_) =
simplifile.create_directory_all(parent_dir(full_path))
let assert Ok(_) = simplifile.write(to: full_path, contents: source)
io.println(" gen " <> full_path)
}
}
})
write_client(cfg, bodies)
io.println("codegen: done")
}
/// The xrpc-client plugin owns a whole file, so this writes its `WholeFile` content verbatim instead of the per-lexicon banner/header wrapping above.
fn write_client(cfg: Config, bodies: dict.Dict(String, String)) -> Nil {
case cfg.client {
None -> Nil
Some(path) -> {
let full_path = cfg.out_dir <> "/" <> path <> ".gleam"
let assert Ok(content) = dict.get(bodies, path <> ".gleam")
let assert Ok(_) = simplifile.create_directory_all(parent_dir(full_path))
let assert Ok(_) = simplifile.write(to: full_path, contents: content)
io.println(" gen " <> full_path)
}
}
}
// Resolved via the app priv dir so the tool also works installed as a dep.
fn internal_template() -> String {
let priv =
application.priv_directory("atproto_codegen")
|> result.unwrap("priv")
priv <> "/internal.gleam"
}
fn load_doc(path: String) -> Result(LexiconDoc, String) {
use contents <- result.try(
simplifile.read(path)
|> result.map_error(fn(e) { "read failed: " <> string.inspect(e) }),
)
decoding.decode_json(contents)
|> result.map_error(fn(e) { "parse failed: " <> string.inspect(e) })
}
/// Loud on purpose: a dropped def must never be silent.
fn report_unsupported(lexicons: List(FlatLexicon)) -> Nil {
list.each(lexicons, fn(lex) {
list.each(lex.defs, fn(d) {
case d {
lower.FlatUnsupported(nsid, name, reason) ->
io.println(
" WARN skipping " <> nsid <> "#" <> name <> ": " <> reason,
)
_ -> Nil
}
})
})
}
/// Warn about any `ref` pointing at a def we never generate, which would otherwise emit Gleam that doesn't compile with no signal from codegen.
fn report_unresolved_refs(lexicons: List(FlatLexicon)) -> Nil {
let known =
list.flat_map(lexicons, fn(lex) {
list.filter_map(lex.defs, fn(d) {
case d {
lower.FlatRecord(nsid, _) -> Ok(nsid <> "#main")
lower.FlatObject(nsid, name, _) -> Ok(nsid <> "#" <> name)
_ -> Error(Nil)
}
})
})
let refs =
list.flat_map(lexicons, fn(lex) {
list.flat_map(lex.defs, fn(d) {
list.flat_map(lower.properties_of(d), fn(p) {
field_ref_keys(lex.nsid, p.field_type)
})
})
})
refs
|> list.unique
|> list.filter(fn(key) { !list.contains(known, key) })
|> list.each(fn(key) {
io.println(
" WARN unresolved ref " <> key <> " (referencing def won't compile)",
)
})
}
fn field_ref_keys(current: String, ft: lower.FlatType) -> List(String) {
case ft {
lower.TRef(ref) -> ref_key(current, ref)
lower.TUnion(refs, _) -> list.flat_map(refs, ref_key(current, _))
lower.TArray(inner) -> field_ref_keys(current, inner)
_ -> []
}
}
fn ref_key(current: String, ref: String) -> List(String) {
let r = naming.parse_ref(current, ref)
[r.nsid <> "#" <> r.def_name]
}
fn parent_dir(path: String) -> String {
case string.split(path, "/") |> list.reverse {
[_, ..rest] -> rest |> list.reverse |> string.join("/")
[] -> "."
}
}