// The generator's command line.
//
// moonx moonbit-community/serde.mbt/derive [path ...]
//
// Each path is a file or a directory to walk. For every source file holding at
// least one `#serde.derive(...)` type, a sibling `*_derive*.mbt` is written; if
// a file stops having any, its stale companion is removed.
//
// A package can run this before every `moon check` and `moon test` by naming it
// in a `dev_build` step of its `moon.pkg`; see the README. Generated files are
// committed alongside the sources they came from either way, since pre-build
// steps never run when a package is used as a dependency.
//
// File access goes through `moonbitlang/async/fs`, which works on both the
// native and wasm backends. That matters because `moonx` runs the published
// package as wasm under `moonrun`, so a native-only IO layer would break the
// documented way of invoking this tool.
///|
const USAGE : String =
#|Generate Serialize / Deserialize implementations for types marked
#|`#serde.derive(...)`.
#|
#| moonx moonbit-community/serde.mbt/derive [path ...]
#|
#|Paths may be files or directories; directories are walked recursively.
#|Defaults to the current directory.
#|
#|Attributes:
#| #serde.derive(Serialize, Deserialize) on a struct or enum
#| #serde.rename("name") on a field or variant
#| #serde.skip on a field
#|
#|To run on every `moon check` and `moon test`, add to the package's moon.pkg:
#| rule(name: "serde-derive", command: "moonx moonbit-community/serde.mbt@/derive $input")
#| dev_build(rule: "serde-derive", input: "types.mbt", output: "types_derive.mbt")
///|
/// Directories that never contain hand-written sources.
fn skip_dir(name : String) -> Bool {
name
is ("target"
| "_build"
| ".mooncakes"
| ".git"
| ".moonagent"
| ".github"
| ".githooks")
}
///|
/// Generated files are recognised by name so a re-run never reads its own
/// output back in.
fn is_generated(name : String) -> Bool {
name.has_suffix("_derive.mbt") ||
name.has_suffix("_derive_test.mbt") ||
name.has_suffix("_derive_wbtest.mbt")
}
///|
/// Where a source file's generated companion goes.
///
/// The test suffix has to survive: `moon` decides which package a file belongs
/// to from its name, so `foo_test.mbt` must generate `foo_derive_test.mbt` and
/// not `foo_test_derive.mbt`, which would land in the wrong package.
fn output_path(path : String) -> String {
for suffix in ["_wbtest.mbt", "_test.mbt"] {
if path.has_suffix(suffix) {
let stem = path[:path.length() - suffix.length()].to_owned()
return stem + "_derive" + suffix
}
}
path[:path.length() - ".mbt".length()].to_owned() + "_derive.mbt"
}
///|
/// The last segment of a path, for matching a directory against `skip_dir`.
fn base_name(path : String) -> String {
match path.rev_find("/") {
Some(at) => path[at + 1:].to_owned()
None => path
}
}
///|
async fn collect(root : String, out : Array[String]) -> Unit {
if @fs.kind(root) is Regular {
if root.has_suffix(".mbt") && !is_generated(root) {
out.push(root)
}
return
}
@fs.walk(
root,
(dir, entries) => {
entries.sort()
for entry in entries {
if entry.has_suffix(".mbt") && !is_generated(entry) {
out.push(if dir == "." { entry } else { dir + "/" + entry })
}
}
},
exclude=path => skip_dir(base_name(path)),
)
}
///|
/// A layout-insensitive fingerprint of generated source.
///
/// The two formatters in play disagree: they break long calls in different
/// places, add trailing commas when they wrap, and the one available inside
/// this program wraps a match arm's body in braces where `moon fmt` strips
/// them. Comparing raw text would make the generator and `moon fmt` rewrite
/// each other forever, dirtying the tree on every run. Dropping whitespace,
/// commas and braces — the only things they disagree about — leaves a
/// fingerprint that changes when the code does and not before.
fn fingerprint(source : String) -> String {
let out = StringBuilder()
for c in source {
let noise = c == ' ' ||
c == '\t' ||
c == '\n' ||
c == '\r' ||
c == ',' ||
c == '{' ||
c == '}'
if !noise {
out.write_char(c)
}
}
out.to_string()
}
///|
/// Generates for one file. Returns a one-line report, or `None` if nothing
/// changed.
async fn process(path : String) -> String? {
let source = @fs.read_file(path).text()
let (impls, reports) = @parser.parse_string(source, name=path)
if reports.length() > 0 {
// A file that does not parse is the compiler's problem to report, not
// ours; skipping keeps the generator quiet during an edit-compile loop.
return None
}
let targets = scan(impls, path)
let target_path = output_path(path)
match emit(targets) {
Some(code) => {
let formatted = @fmt.format(code)
let existing = if @fs.exists(target_path) {
@fs.read_file(target_path).text() catch {
_ => ""
}
} else {
""
}
if fingerprint(existing) == fingerprint(formatted) {
return None
}
@fs.write_file(target_path, formatted, create_mode=CreateOrTruncate)
Some("wrote \{target_path}")
}
None =>
if @fs.exists(target_path) {
@fs.remove(target_path)
Some("removed \{target_path}")
} else {
None
}
}
}
///|
async fn main {
let argv = @env.args()
let roots = []
for i in 1.. {
println("derive: cannot read `\{root}`: \{err}")
return
}
}
}
let mut changed = 0
let mut failed = 0
for path in sources {
try {
if process(path) is Some(report) {
println(report)
changed = changed + 1
}
} catch {
err => {
println("derive: \{err}")
failed = failed + 1
}
}
}
if failed > 0 {
println("derive: \{failed} file(s) failed, \{changed} updated")
} else if changed == 0 {
println(
"derive: \{sources.length()} file(s) scanned, everything up to date",
)
} else {
println("derive: \{sources.length()} file(s) scanned, \{changed} updated")
}
}