// 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")
  }
}