//// Lexicon -> Gleam codegen. Reads `/**/*.json` and writes //// typed modules into ``, importable as `/...`. //// //// 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 -- [,...] []" 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(config: Config) -> List(String) { case config.client { Some(_) -> ["types", "decode-json", "xrpc-client"] None -> ["types", "decode-json"] } } fn plugins(config: Config) -> List(plugin.Plugin) { case config.client { Some(_) -> [types.plugin(), decode_json.plugin(), xrpc_client.plugin()] None -> [types.plugin(), decode_json.plugin()] } } fn run(config: Config) -> Nil { let assert Ok(files) = simplifile.get_files(config.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(config.out_dir) let assert Ok(internal) = simplifile.read(internal_template()) let assert Ok(_) = simplifile.write( to: config.out_dir <> "/internal.gleam", contents: generated_banner <> internal, ) let assert Ok(outputs) = plugin.run(plugins(config), config, docs, lexicons) let assert Ok(assembled) = plugin.assemble(outputs, plugin_order(config)) 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(config.nsid_prefixes, lex.nsid) let path = subpath <> ".gleam" let assert Ok(body) = dict.get(bodies, path) let header = module_header.header(config, lex.nsid, defs) let source = module_banner <> header <> "\n" <> body <> "\n" let full_path = config.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(config, 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(config: Config, bodies: dict.Dict(String, String)) -> Nil { case config.client { None -> Nil Some(path) -> { let full_path = config.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. Exhaustive over /// `FlatDef` so a future variant forces a decision here instead of falling /// through a wildcard. 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, ) lower.FlatRecord(..) -> Nil lower.FlatObject(..) -> Nil lower.FlatMethod(_) -> Nil lower.FlatIgnored -> 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("/") [] -> "." } }