sourcejig::Builder.fan

//
// Copyright (c) 2026, Novant LLC
// Licensed under the MIT License
//
// History:
//   27 Oct 2026  Andy Frank  Creation
//

using build
using concurrent

*************************************************************************
** Builder
*************************************************************************

** Builder spawns 'fan <build.fan> compile', parses its output, and
** renders one line per pod with the same live status bar as Runner.
** The running pod stays pinned above the bar with its current build
** step to the right in dim text.
**
** Build output is line based: a top level '<target> [<pod>]' line
** starts a pod, indented lines are progress steps, and any other
** top level line is an error or warning for the current pod, until
** 'BUILD SUCCESS' or 'BUILD FAILED'.  Each row is tagged with its
** target; with several targets a group runs each target across all
** pods in turn, so each pod gets one row per target.
class Builder
{
  ** Entry point for 'jig build'; each arg is a pod under the root
  ** script or a build script path; with no args build the root
  ** script.  Runs 'compile' unless one of these picks the target:
  ** 'clean' only cleans; 'full' cleans, compiles, and tests the
  ** built pods once all compile; 'target' runs that build target.
  static Int main(Str[] args, Bool quiet, Bool clean, Bool full, Str? target := null)
  {
    scripts := BuildScript[,]
    try
    {
      BuildScript? root
      args.each |arg|
      {
        if (arg.endsWith(".fan")) { scripts.add(load(File.os(arg).normalize)); return }
        if (root == null) root = load(findRoot)
        scripts.add(findPod(root, arg) ?: throw ArgErr("Unknown pod: $arg"))
      }
      if (scripts.isEmpty) scripts.add(load(findRoot))

      // check target up front so a typo fails before anything runs
      if (target != null)
        scripts.each |s|
        {
          if (s.target(target, false) == null)
            throw ArgErr("Unknown target '$target' for ${s.scriptFile.osPath}; " +
              "targets: " + s.targets.map |m| { m.name }.join(", "))
        }
    }
    catch (ArgErr e) { echo("ERR: $e.msg"); return 1 }

    // rewriting the pod file jig is running from breaks class loading
    pods := scripts.map |s| { JigBuild.podNames(s) }.flatten
    self := Builder#.pod.name
    if (pods.contains(self) && Env.cur.findPodFile(self)?.normalize == (Env.cur.workDir + `lib/fan/${self}.pod`).normalize)
    {
      echo("ERR: cannot rebuild running ${self}.pod; use 'fan build.fan' or an installed jig")
      return 1
    }

    targets := ["compile"]
    if (full) targets = ["clean", "compile"]
    else if (clean) targets = ["clean"]
    else if (target != null) targets = [target]
    code := Builder(scripts.map |s->File| { s.scriptFile }, pods, targets, quiet).run
    if (code != 0 || !full) return code
    return test(pods, quiet)
  }

  ** Run tests for each of 'pods' that has any, after a full build.
  private static Int test(Str[] pods, Bool quiet)
  {
    tests := Str[,]
    targets := Str[,]
    pods.each |name|
    {
      pod := Pod.find(name, false)
      if (pod == null) return
      t := Resolver.podTests(pod)
      if (t.isEmpty) return
      targets.add(name)
      tests.addAll(t)
    }
    if (tests.isEmpty) { echo("No tests found"); return 0 }
    return Runner(targets, tests, quiet).run
  }

  ** Root build script: './build.fan', else '<workDir>/src/build.fan'.
  static File findRoot()
  {
    cwd := File.os(".").normalize
    f := cwd + `build.fan`
    if (f.exists) return f
    f = Env.cur.workDir + `src/build.fan`
    if (f.exists) return f
    throw ArgErr("no build.fan in ${cwd.osPath} or ${f.parent.osPath}")
  }

  ** Compile and construct build script, as 'build::Main' does.
  static BuildScript load(File file)
  {
    if (!file.exists) throw ArgErr("File not found: $file.osPath")
    Obj? obj
    try obj = Env.cur.compileScript(file).make
    catch (Err e) throw ArgErr("Cannot load $file.osPath: $e.msg")
    return obj as BuildScript ?: throw ArgErr("Not a BuildScript: $file.osPath")
  }

  ** Find BuildPod for 'name' anywhere under 'script', or null.
  static BuildPod? findPod(BuildScript script, Str name)
  {
    if (script is BuildPod) return ((BuildPod)script).podName == name ? script : null
    if (script is BuildGroup)
      return ((BuildGroup)script).children.eachWhile |c| { findPod(c, name) }
    return null
  }

  ** Create builder to run 'targets' on each of 'scripts', which
  ** together are expected to build 'pods'.
  new make(File[] scripts, Str[] pods, Str[] targets := ["compile"], Bool quiet := false,
           Bool? tty := null, OutStream out := Env.cur.out, File? cwd := null)
  {
    this.cwd     = cwd ?: File.os(".").normalize
    this.scripts = scripts
    this.targets = targets
    this.podCount = pods.size
    this.total    = pods.size * targets.size
    pods.each |p| { nameWidth = nameWidth.max(p.size) }
    targets.each |t| { targetWidth = targetWidth.max(t.size) }
    this.quiet  = quiet
    this.tty    = tty ?: Term.isTty
    this.width  = this.tty ? Term.width : 80
    this.out    = out
  }

  ** Run build and return exit code.
  Int run()
  {
    // blank line spaces results off from the command line
    out.printLine
    pool := startTicker
    exit := 0
    scripts.eachWhile |script|
    {
      done.val = false
      exit = Term.exec(Term.fan.add(script.osPath).addAll(targets), |line| { parseLine(line) }) |->Bool| { done.val }
      if (!done.val) endPod(exit == 0)
      return exit != 0 || failedPod != null ? true : null
    }

    // stop ticker before writing summary
    running.val = false
    pool?.stop?.join(1sec)
    if (tty) out.print(erase).flush
    return summary(exit)
  }

  ** Redraw every 100ms so elapsed time moves during long steps.
  private ActorPool? startTicker()
  {
    if (!tty) return null
    pool := ActorPool { it.name = "jigTicker"; it.maxThreads = 1 }
    self := Unsafe(this)
    flag := this.running
    Actor(pool) |->Obj?|
    {
      while (flag.val) { Actor.sleep(100ms); ((Builder)self.val).redraw }
      return null
    }.send(null)
    return pool
  }

//////////////////////////////////////////////////////////////////////////
// Parsing
//////////////////////////////////////////////////////////////////////////

  ** Parse next line of build output, without trailing newline.
  internal Void parseLine(Str line)
  {
    if (done.val) return

    if (line.startsWith("BUILD SUCCESS")) { endPod(true);  done.val = true; return }
    if (line.startsWith("BUILD FAILED"))  { endPod(false); done.val = true; return }

    // '<target> [<pod>]' starts a row for that pod and target
    m := headerRegex.matcher(line)
    if (m.matches)
    {
      pod := m.group(2)
      target := m.group(1)
      if (pod != curPod || target != curTarget) { endPod(true); startPod(pod, target) }
      step = null
      redraw
      return
    }

    if (line.trim.isEmpty) return

    // top level lines are errors or warnings; once one is seen keep
    // indented lines too so traces and multi-line messages stay whole
    indented := line.startsWith(" ")
    if (!indented || !msgs.isEmpty) msgs.add(line)
    if (!indented && line.contains(": WARN ")) warns++
    if (indented) { step = line.trim; checkWritePod(step) }
    redraw
  }

  ** Record output dir from 'WritePod [file:/.../lib/fan/acme.pod]'
  ** for the summary.
  private Void checkWritePod(Str step)
  {
    if (!step.startsWith("WritePod [file:") || !step.endsWith("]")) return
    try
    {
      // keep the 'file:' scheme so 'relPath' can match it to cwd
      dir := File(step["WritePod [".size..-2].toUri).parent
      if (dir != null && !outDirs.contains(dir)) outDirs.add(dir)
    }
    catch (Err e) {}
  }

  private Void startPod(Str pod, Str target)
  {
    curPod    = pod
    curTarget = target
    podStart  = now
    step     = null
  }

  ** Finish current pod; if no pod started yet only failures and
  ** stray output are kept.
  private Void endPod(Bool ok)
  {
    pod := curPod
    if (pod == null)
    {
      if (!ok) failedPod = failedPod ?: ""
      if (!ok || !msgs.isEmpty) details.add(msgs.dup)
      msgs.clear
      return
    }

    target := curTarget ?: ""
    curPod    = null
    curTarget = null
    step      = null
    if (ok) { built++; if (target == targets.last) podsDone++ }
    else { failed++; failedPod = pod }

    lines := Str[,]
    if (!quiet)
    {
      lines.add("  " + nameCol(pod) + targetCol(target) +
        (ok ? color(Term.green, "ok") : color(Term.red, "fail")) + " " +
        color(Term.dim, "(${StatusBar.fmtDurMs(now - podStart)})"))

      // warnings on a good pod are shown inline without the build
      // steps after them; errors wait for the summary so they sit
      // right above the final line
      if (ok) msgs.each |s| { if (!s.startsWith(" ")) lines.add(color(Term.dim, "    $s")) }
    }
    if (!ok) details.add([color(Term.red, "FAIL: $pod [$target]")].addAll(msgs))
    msgs.clear
    printAbove(lines)
  }

  private static const Regex headerRegex := Regex<|^(\w+) \[([^\]\s]+)\]$|>

//////////////////////////////////////////////////////////////////////////
// Rendering
//////////////////////////////////////////////////////////////////////////

  ** Line for the pod currently building with its latest build step
  ** in dim to the right, or null if none.
  internal Str? liveLine()
  {
    if (quiet) return null
    pod := curPod
    if (pod == null && step == null) return null

    s := step ?: ""
    timer := " (${StatusBar.fmtDur(now - podStart)})"

    // keep on one line so cursor math in 'erase' holds
    col   := pod == null ? "" : nameCol(pod, false) + targetCol(curTarget ?: "", false)
    avail := width - 1 - ("  " + col + timer).size
    if (avail < 4) s = ""
    else if (s.size > avail) s = s[0..<avail-1] + "…"

    head := pod == null ? "" : nameCol(pod) + targetCol(curTarget ?: "")
    return "  " + head + color(Term.dim, s.isEmpty ? timer.trimStart : s + timer)
  }

  ** Target in brackets padded so status words line up.
  private Str targetCol(Str target, Bool colored := true)
  {
    s := "[$target]".padr(targetWidth + 2) + " "
    return colored ? color(Term.magenta, s) : s
  }

  ** Pod name plus dim dot leaders out to the status column; the
  ** longest name still gets 'Runner.minDots'.
  private Str nameCol(Str pod, Bool colored := true)
  {
    dots := "·" * (nameWidth - pod.size + Runner.minDots)
    return "$pod " + (colored ? color(Term.dim, dots) : dots) + " "
  }

  ** Print lines above the status bar.
  private Void printAbove(Str[] lines)
  {
    lock.lock
    try
    {
      if (tty) out.print(erase)
      lines.each |s| { out.printLine(s) }
      redraw
    }
    finally lock.unlock
  }

  ** Redraw live pod line and status bar in place; called from
  ** both the output pump thread and the ticker.
  private Void redraw()
  {
    if (!tty) return
    lock.lock
    try
    {
      if (!running.val) return
      live := liveLine
      buf  := StrBuf().add(erase)
      drawn := 0
      if (live != null) { buf.add(live).add("\n"); drawn++ }
      if (!quiet) { buf.add("\n"); drawn++ }
      label := quiet ? curPod : null
      buf.add(StatusBar.render(total, built, failed, label, elapsed, width, true, "BUILD"))
      liveDrawn = drawn
      out.print(buf.toStr).flush
    }
    finally lock.unlock
  }

  ** Escape sequence to clear the status bar and any live
  ** lines above it, leaving the cursor where they started.
  private Str erase()
  {
    s := Term.clearLine + Term.upClearLine * liveDrawn
    liveDrawn = 0
    return s
  }

  ** Print errors and final summary and return exit code.
  internal Int summary(Int exit)
  {
    dur := StatusBar.fmtDur(elapsed)
    out.printLine

    details.each |lines|
    {
      lines.each |s| { out.printLine(s) }
      out.printLine
    }

    // output dirs confirm where pods landed; warnings get a count
    // since the lines themselves are dim and easy to scroll past
    to := outDirs.isEmpty ? "" : " to " + outDirs.map |d| { relPath(d) }.join(", ")
    ws := warns == 0 ? "" : " " + color(Term.yellow, "[" + (warns == 1 ? "1 warning" : "$warns warnings") + "]")

    // name what ran; counts are pods through the last target
    verb := "ran ${targets.last} on"
    if (targets == ["clean"]) verb = "cleaned"
    else if (targets.last == "compile") verb = targets.contains("clean") ? "cleaned and built" : "built"
    if (failedPod == null && exit == 0)
    {
      out.printLine(color(Term.green, "✓ ${verb.capitalize} ${pods(podsDone)}$to") + ws + color(Term.green, " in $dur"))
      return 0
    }

    what := failedPod == null || failedPod.isEmpty ? "Build failed (exit $exit)" : "$failedPod failed"
    out.printLine(color(Term.red, "✗ $what; $verb $podsDone of ${pods(podCount)}$to") + ws + color(Term.red, " in $dur"))
    return 1
  }

  ** Path relative to cwd when under it, else the full OS path.
  private Str relPath(File f)
  {
    rel := f.uri.relTo(cwd.uri).toStr
    if (!rel.startsWith("..") && !rel.startsWith("/")) return rel
    return f.isDir ? f.osPath + File.sep : f.osPath
  }

  private static Str pods(Int n) { n == 1 ? "1 pod" : "$n pods" }

  private Str color(Str c, Str s) { tty ? "$c$s$Term.reset" : s }

  private Duration elapsed() { now - start }

  ** Current time; tests may freeze it with 'fixedNow'.
  private Duration now() { fixedNow ?: Duration.now }

  ** Fixed clock for tests.
  internal Duration? fixedNow

//////////////////////////////////////////////////////////////////////////
// Fields
//////////////////////////////////////////////////////////////////////////

  private OutStream out
  private const File[] scripts
  private const Str[] targets
  private const Bool quiet
  private const Int total
  private const Int podCount
  private Int targetWidth
  private Int podsDone
  private Str? curTarget
  private const Bool tty
  private const Int width
  private const Duration start := Duration.now
  private const File cwd
  private File[] outDirs := [,]
  private Int warns
  private Int nameWidth
  private Int built
  private Int failed
  private Str? failedPod
  private Str? curPod
  private Str? step
  private Duration podStart := Duration.now
  private Str[] msgs := [,]
  private Str[][] details := [,]
  private Int liveDrawn
  private const Lock lock := Lock.makeReentrant
  private const AtomicBool running := AtomicBool(true)
  private const AtomicBool done := AtomicBool(false)
}