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>/...`. See
//// `codegen/lexicon` (parsing), `codegen/naming` (names/refs), `codegen/emit`
//// (source emission).
import argv
import atproto_codegen/config.{type Config, Config}
import atproto_codegen/emit
import atproto_codegen/lexicon.{
type Def, type FieldType, type Lexicon, Object, Record, TArray, TRef, TUnion,
Unsupported,
}
import atproto_codegen/naming
import gleam/erlang/application
import gleam/io
import gleam/json
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 usage = "usage: gleam run -- <lexicons_dir> <out_dir> <out_module> <nsid_prefix>[,<nsid_prefix>...]"
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, ","),
))
_ -> io.println(usage)
}
}
fn run(cfg: Config) -> Nil {
let assert Ok(files) = simplifile.get_files(cfg.lexicons_dir)
let lexicons =
files
|> list.filter(string.ends_with(_, ".json"))
|> list.filter_map(fn(path) {
case load_lexicon(path) {
Ok(lex) -> Ok(lex)
Error(reason) -> {
io.println(" WARN skipping " <> path <> ": " <> reason)
Error(Nil)
}
}
})
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,
)
list.each(lexicons, fn(lex) {
list.each(lex.defs, fn(d) {
case d {
Unsupported(nsid, name, reason) ->
io.println(" skip " <> nsid <> "#" <> name <> " (" <> reason <> ")")
_ -> Nil
}
})
case emit.emit_lexicon(cfg, lex) {
Some(source) -> {
let path =
cfg.out_dir
<> "/"
<> naming.module_subpath(cfg.nsid_prefixes, lex.nsid)
<> ".gleam"
let assert Ok(_) = simplifile.create_directory_all(parent_dir(path))
let assert Ok(_) = simplifile.write(to: path, contents: source)
io.println(" gen " <> path)
}
None -> Nil
}
})
io.println("codegen: done")
}
// 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_lexicon(path: String) -> Result(Lexicon, String) {
use contents <- result.try(
simplifile.read(path)
|> result.map_error(fn(e) { "read failed: " <> string.inspect(e) }),
)
json.parse(contents, lexicon.lexicon_decoder())
|> result.map_error(fn(e) { "parse failed: " <> string.inspect(e) })
}
/// Warn about any `ref` that points at a def we never generate (a different
/// lexicon's def, an external NSID, or an unsupported def). Such a ref would
/// otherwise emit Gleam that doesn't compile, with no signal from codegen.
fn report_unresolved_refs(lexicons: List(Lexicon)) -> Nil {
let known =
list.flat_map(lexicons, fn(lex) {
list.filter_map(lex.defs, fn(d) {
case d {
Record(nsid, _) -> Ok(nsid <> "#main")
Object(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(def_properties(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 def_properties(def: Def) -> List(lexicon.Property) {
case def {
Record(_, props) -> props
Object(_, _, props) -> props
_ -> []
}
}
fn field_ref_keys(current: String, ft: FieldType) -> List(String) {
case ft {
TRef(ref) -> ref_key(current, ref)
TUnion(refs) -> list.flat_map(refs, ref_key(current, _))
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("/")
[] -> "."
}
}