// The generator's command line.
//
//   moonx Yu-zh/data/derive [path ...]
//
// Each path is a file or a directory to walk. For every source file holding at
// least one `#data.derive(...)` type, a sibling `*_derive*.mbt` is written; if
// a file stops having any, its stale companion is removed.
//
// There is no way to hook this into `moon build` — the current `moon.pkg`
// format has no pre-build step — so generated files are committed alongside
// the sources they came from and the generator is re-run by hand.
//
// 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
  #|`#data.derive(...)`.
  #|
  #|    moonx Yu-zh/data/derive [path ...]
  #|
  #|Paths may be files or directories; directories are walked recursively.
  #|Defaults to the current directory.
  #|
  #|Attributes:
  #|    #data.derive(Serialize, Deserialize)   on a struct or enum
  #|    #data.rename("name")                   on a field or variant
  #|    #data.skip                             on a field

///|
/// 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: `moon fmt` breaks long calls where the
/// one available inside this program does not, and adds a trailing comma when
/// it wraps. Comparing raw text would make the generator and `moon fmt`
/// rewrite each other forever, dirtying the tree on every run. Dropping
/// whitespace and commas — 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::new()
  for c in source {
    let noise = c == ' ' || c == '\t' || c == '\n' || c == '\r' || 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")
  }
}