///|
fn is_ws(c : UInt16) -> Bool {
  c == ' ' || c == '\t' || c == '\r' || c == '\n'
}

///|
fn trim(s : String) -> String {
  let n = s.length()
  let mut a = 0
  let mut b = n
  while a < b && is_ws(s[a]) {
    a = a + 1
  }
  while b > a && is_ws(s[b - 1]) {
    b = b - 1
  }
  s[a:b].to_owned()
}

///|
fn lines(s : String) -> Array[String] {
  let out : Array[String] = []
  let n = s.length()
  let mut start = 0
  for i = 0; i <= n; i = i + 1 {
    if i == n || s[i] == '\n' {
      out.push(s[start:i].to_owned())
      start = i + 1
    }
  }
  out
}

///|
fn starts_with(s : String, prefix : String) -> Bool {
  let pn = prefix.length()
  if s.length() < pn {
    return false
  }
  for i = 0; i < pn; i = i + 1 {
    if s[i] != prefix[i] {
      return false
    }
  }
  true
}

///|
/// Index of the first `ch` in `s` at or after `from`, or `-1` if absent.
fn index_of(s : String, ch : UInt16, from : Int) -> Int {
  let n = s.length()
  for i = from; i < n; i = i + 1 {
    if s[i] == ch {
      return i
    }
  }
  -1
}

///|
/// `line` with any trailing `//` comment cut off. A `//` inside a quoted string or
/// a back-tick struct tag is left alone, so a route summary and a `json:"…"` tag
/// survive a commented line.
fn strip_comment(line : String) -> String {
  let n = line.length()
  let mut in_str = false
  let mut in_tag = false
  for i = 0; i < n; i = i + 1 {
    let c = line[i]
    if c == '"' && in_tag == false {
      in_str = in_str == false
    } else if c == '`' && in_str == false {
      in_tag = in_tag == false
    } else if c == '/' &&
      in_str == false &&
      in_tag == false &&
      i + 1 < n &&
      line[i + 1] == '/' {
      return line[0:i].to_owned()
    }
  }
  line
}

///|
/// `s` with one wrapping pair of double quotes removed.
fn unquote(s : String) -> String {
  let n = s.length()
  if n >= 2 && s[0] == '"' && s[n - 1] == '"' {
    s[1:n - 1].to_owned()
  } else {
    s
  }
}

///|
/// `s` without a trailing `{`, so `service greet{` names the service `greet`.
fn drop_brace(s : String) -> String {
  let n = s.length()
  if n > 0 && s[n - 1] == '{' {
    trim(s[0:n - 1].to_owned())
  } else {
    s
  }
}

///|
/// `s` read as a non-negative decimal, or `None` when it is not one.
fn to_i64(s : String) -> Int64? {
  let n = s.length()
  if n == 0 {
    return None
  }
  let mut v : Int64 = 0
  for i = 0; i < n; i = i + 1 {
    if !is_digit(s[i]) {
      return None
    }
    v = v * 10L + (s[i].to_int() - 48).to_int64()
  }
  Some(v)
}

///|
/// A group prefix joined onto a route path: `("/api/v1", "/ping")` → `/api/v1/ping`.
/// Surplus slashes on either side collapse, and an empty (or all-slash) prefix
/// leaves the path alone.
fn join_path(prefix : String, path : String) -> String {
  let mut end = prefix.length()
  while end > 0 && prefix[end - 1] == '/' {
    end = end - 1
  }
  let mut start = 0
  while start < end && prefix[start] == '/' {
    start = start + 1
  }
  if start >= end {
    return path
  }
  let head = "/" + prefix[start:end].to_owned()
  if path == "" || path == "/" {
    head
  } else if path[0] == '/' {
    head + path
  } else {
    head + "/" + path
  }
}

///|
/// Map a `.api` field type onto its MoonBit spelling (← goctl's Go type set):
/// scalars (`string`→`String`, `int`→`Int`, `int64`→`Int64`, …), slices
/// (`[]T`→`Array[T]`), and maps (`map[K]V`→`Map[K, V]`). An unrecognised name is
/// passed through verbatim, so a spec may already reference a MoonBit type or one
/// of its own `type` blocks.
fn map_type(raw : String) -> String {
  let t = trim(raw)
  if starts_with(t, "*") {
    return map_type(t[1:].to_owned()) + "?"
  }
  if starts_with(t, "[]") {
    return "Array[" + map_type(t[2:].to_owned()) + "]"
  }
  if starts_with(t, "map[") {
    let close = index_of(t, ']', 4)
    if close > 4 {
      let key = t[4:close].to_owned()
      let value = t[close + 1:].to_owned()
      return "Map[" + map_type(key) + ", " + map_type(value) + "]"
    }
  }
  match t {
    "string" => "String"
    "bool" => "Bool"
    "int" | "int8" | "int16" | "int32" | "rune" => "Int"
    "int64" => "Int64"
    "uint" | "uint8" | "uint16" | "uint32" => "UInt"
    "uint64" => "UInt64"
    "byte" => "Byte"
    "bytes" => "Bytes"
    "float32" => "Float"
    "float" | "float64" | "double" => "Double"
    _ => t
  }
}

///|
/// Whitespace-split a line into tokens, keeping a `"quoted string"` as one token
/// (with the quotes stripped).
fn tokens(s : String) -> Array[String] {
  let out : Array[String] = []
  let n = s.length()
  let mut i = 0
  while i < n {
    while i < n && is_ws(s[i]) {
      i = i + 1
    }
    if i >= n {
      break
    }
    if s[i] == '"' {
      let start = i + 1
      let mut j = start
      while j < n && s[j] != '"' {
        j = j + 1
      }
      out.push(s[start:j].to_owned())
      i = j + 1
    } else {
      let start = i
      while i < n && is_ws(s[i]) == false {
        i = i + 1
      }
      out.push(s[start:i].to_owned())
    }
  }
  out
}

///|
/// A spec the parser could not read: `Syntax` is the 1-based `line` and what went
/// wrong there, `Missing` an `import` whose `path` was not among the files handed to
/// `parse_all`.
pub suberror SpecError {
  Syntax(line~ : Int, msg~ : String)
  Missing(file~ : String, path~ : String)
}

///|
impl Show for SpecError with fn output(self, logger) {
  match self {
    Syntax(line~, msg~) =>
      logger.write_string("line " + line.to_string() + ": " + msg)
    Missing(file~, path~) =>
      logger.write_string(file + ": cannot read import \"" + path + "\"")
  }
}

///|
/// The `@server( … )` annotations governing a block of routes (← goctl's route
/// group): the `name` under which its routes are grouped, the path `prefix`
/// already folded into their paths, the `jwt` claim type, the `middleware` chain,
/// the `max_bytes` request cap (`0` when unset), the request `timeout`, and
/// whether the group's requests are signed. `extra` keeps every other annotation
/// the block carried, in order — goctl lets a spec invent its own, and dropping
/// them would lose what the author wrote.
pub(all) struct Group {
  name : String
  prefix : String
  jwt : String
  middleware : Array[String]
  max_bytes : Int64
  timeout : String
  signature : Bool
  extra : Array[(String, String)]
}

///|
/// One route in a service spec: HTTP verb (lower-cased), path pattern (with its
/// group's prefix applied), handler name, an optional summary, and the `@server`
/// group it was declared under. A modern goctl route (`verb /path (Req) returns
/// (Resp)`) also carries its request/response type names (its `@doc` fills
/// `summary`); both are empty for the legacy inline form (`verb /path handler
/// "summary"`).
pub(all) struct Route {
  verb : String
  path : String
  handler : String
  summary : String
  req : String
  resp : String
  group : Group?
}

///|
/// One field of a `type` block: its name, the (already MoonBit-mapped) type, and
/// the raw back-tick struct tag (`json:"…"`/`path`/`form`/`header`, empty when
/// absent) that carries the wire name and binding location.
pub(all) struct Field {
  name : String
  type_ : String
  tag : String
}

///|
/// A named message schema declared by a `type Name { field: Type … }` block,
/// generated into a MoonBit struct (goctl's request/response types). `embeds`
/// names the blocks it inlined — a bare `Base` line — whose fields belong to it as
/// if they had been written out; an anonymous nested block instead becomes a
/// `TypeDef` of its own, named after the two blocks, with a field pointing at it.
pub(all) struct TypeDef {
  name : String
  fields : Array[Field]
  embeds : Array[String]
}

///|
/// A parsed `.api` service specification. `groups` lists the `@server( … )` blocks
/// in the order they were declared; each route also points at the one it belongs
/// to. `imports` holds the `import` paths exactly as the file wrote them — relative
/// to the file itself, so resolving one needs the path it was read from; `deps` and
/// `parse_all` do that.
pub(all) struct Spec {
  service : String
  routes : Array[Route]
  types : Array[TypeDef]
  info : Array[(String, String)]
  groups : Array[Group]
  imports : Array[String]
}

///|
/// goctl only reads a line as a route when it opens with an HTTP verb; anything
/// else at that nesting level is an annotation or a brace.
fn is_http_verb(t : String) -> Bool {
  match t.to_lower() {
    "get"
    | "head"
    | "post"
    | "put"
    | "patch"
    | "delete"
    | "options"
    | "connect"
    | "trace" => true
    _ => false
  }
}

///|
/// What is left of `s` after its first `n` whitespace-separated tokens.
fn rest_after(s : String, n : Int) -> String {
  let len = s.length()
  let mut i = 0
  let mut seen = 0
  while seen < n && i < len {
    while i < len && is_ws(s[i]) {
      i = i + 1
    }
    while i < len && is_ws(s[i]) == false {
      i = i + 1
    }
    seen = seen + 1
  }
  trim(s[i:].to_owned())
}

///|
/// The text inside the first `( … )` pair of `s`, or "" when there is none.
fn paren_body(s : String) -> String {
  let lp = index_of(s, '(', 0)
  if lp < 0 {
    return ""
  }
  let rp = index_of(s, ')', lp + 1)
  if rp <= lp + 1 {
    return ""
  }
  trim(s[lp + 1:rp].to_owned())
}

///|
fn index_of_str(hay : String, needle : String) -> Int {
  let hn = hay.length()
  let nn = needle.length()
  if nn == 0 {
    return 0
  }
  for i = 0; i + nn <= hn; i = i + 1 {
    let mut ok = true
    for j = 0; j < nn; j = j + 1 {
      if hay[i + j] != needle[j] {
        ok = false
        break
      }
    }
    if ok {
      return i
    }
  }
  -1
}

///|
/// Whether `s` spells a name: a letter or `_` first, then letters, digits, `_` and
/// the `.` a qualified type carries. What tells goctl's inline `Base` member from a
/// line that is simply missing its type.
fn is_name(s : String) -> Bool {
  let n = s.length()
  if n == 0 {
    return false
  }
  for i = 0; i < n; i = i + 1 {
    let c = s[i]
    let alpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
    let ok = alpha || (i > 0 && ((c >= '0' && c <= '9') || c == '.'))
    if ok == false {
      return false
    }
  }
  true
}

///|
/// What a line inside a `type` block turned out to declare.
priv enum Member {
  /// An ordinary `name: type` / `Name Type` field.
  Plain(Field)
  /// goctl's inline member — a bare type name, whose own fields belong to the
  /// block that embedded it.
  Inline(String)
  /// `Name {`, an anonymous nested struct, with the tag the line carried.
  Nest(String, String)
}

///|
/// Read one `type`-block line. Accepts goctl's Go-struct field `Name Type` and
/// moonctl's colon form `name: type` (each with an optional back-tick tag), a bare
/// type name as an inline embed, and `Name {` as an anonymous nested struct.
/// Returns `None` for a line that is none of those, which the caller reports.
fn read_member(line : String) -> Member? {
  let mut decl = line
  let mut tag = ""
  let bt = index_of(line, '`', 0)
  if bt >= 0 {
    let close = index_of(line, '`', bt + 1)
    if close > bt {
      tag = line[bt + 1:close].to_owned()
      decl = trim(line[0:bt].to_owned() + " " + line[close + 1:].to_owned())
    }
  }
  if decl.length() > 0 && decl[decl.length() - 1] == '{' {
    let head = trim(decl[0:decl.length() - 1].to_owned())
    return if is_name(head) { Some(Nest(head, tag)) } else { None }
  }
  let colon = index_of(decl, ':', 0)
  if colon > 0 {
    let name = trim(decl[0:colon].to_owned())
    let type_ = map_type(decl[colon + 1:].to_owned())
    if type_ == "" {
      return None
    }
    return Some(Plain({ name, type_, tag, }))
  }
  let toks = tokens(decl)
  if toks.length() >= 2 {
    return Some(Plain({ name: toks[0], type_: map_type(toks[1]), tag, }))
  }
  if toks.length() == 1 && is_name(toks[0]) {
    return Some(Inline(toks[0]))
  }
  None
}

///|
/// Split on the commas outside a `"…"`, so a quoted value keeps the commas it
/// contains while `group: user, prefix: /api/v1` still separates.
fn split_commas(s : String) -> Array[String] {
  let out : Array[String] = []
  let n = s.length()
  let mut start = 0
  let mut in_str = false
  for i = 0; i < n; i = i + 1 {
    if s[i] == '"' {
      in_str = in_str == false
    } else if s[i] == ',' && in_str == false {
      out.push(s[start:i].to_owned())
      start = i + 1
    }
  }
  out.push(s[start:n].to_owned())
  out
}

///|
/// Read the body of an `@server( … )` block into a `Group`. Each body line arrives
/// with its own number, so a complaint names the entry that caused it rather than
/// the line the block opened on. Entries separate on commas as well as newlines, so
/// both goctl's one-per-line layout and the compact `@server(group: user, prefix:
/// /api/v1)` form parse. A comma-separated fragment carrying no `key:` continues the
/// previous entry — that is how `middleware: Log,Trace` reads on a single line.
fn read_group(
  body : Array[(Int, String)],
  issues : Array[(Int, String)],
) -> Group {
  let entries : Array[(Int, String, String)] = []
  for chunk in body {
    for piece in split_commas(chunk.1) {
      let frag = trim(piece)
      if frag == "" {
        continue
      }
      let colon = index_of(frag, ':', 0)
      if colon <= 0 {
        if entries.length() == 0 {
          issues.push(
            (chunk.0, "@server entry \"" + frag + "\" is not `key: value`"),
          )
        } else {
          let last = entries.length() - 1
          let e = entries[last]
          entries[last] = (e.0, e.1, e.2 + "," + frag)
        }
        continue
      }
      entries.push(
        (
          chunk.0,
          trim(frag[0:colon].to_owned()),
          unquote(trim(frag[colon + 1:].to_owned())),
        ),
      )
    }
  }
  let mut name = ""
  let mut prefix = ""
  let mut jwt = ""
  let middleware : Array[String] = []
  let mut max_bytes = 0L
  let mut timeout = ""
  let mut signature = false
  let extra : Array[(String, String)] = []
  for kv in entries {
    match kv.1 {
      "group" => name = kv.2
      "prefix" => prefix = kv.2
      "jwt" => jwt = kv.2
      "middleware" =>
        for m in split_char(kv.2, ',') {
          let one = trim(m)
          if one != "" {
            middleware.push(one)
          }
        }
      "maxBytes" =>
        match to_i64(kv.2) {
          Some(v) => max_bytes = v
          None =>
            issues.push(
              (kv.0, "maxBytes: expected a number, got \"" + kv.2 + "\""),
            )
        }
      "timeout" => timeout = kv.2
      "signature" =>
        match kv.2 {
          "true" => signature = true
          "false" => signature = false
          _ =>
            issues.push(
              (kv.0, "signature: expected true or false, got \"" + kv.2 + "\""),
            )
        }
      _ => extra.push((kv.1, kv.2))
    }
  }
  { name, prefix, jwt, middleware, max_bytes, timeout, signature, extra, }
}

///|
/// Close every `type` block still open, innermost first, complaining about each at
/// the line it opened on. The blocks are already in `Spec.types`, so what they did
/// declare survives a missing brace.
fn unwind(open : Array[(TypeDef, Int)], issues : Array[(Int, String)]) -> Unit {
  while open.length() > 0 {
    match open.pop() {
      Some(f) =>
        issues.push((f.1, "type " + f.0.name + " is missing its closing `}`"))
      None => ()
    }
  }
}

///|
/// Read the body of a `@doc( … )` block into the route summary. goctl documents
/// `summary`, and `description` stands in for it when a block carries only that;
/// any other key is a typo worth naming, since a silently dropped one would leave
/// the route undocumented.
fn read_doc(
  body : Array[(Int, String)],
  issues : Array[(Int, String)],
) -> String {
  let mut summary = ""
  let mut description = ""
  for chunk in body {
    let frag = trim(chunk.1)
    if frag == "" {
      continue
    }
    let colon = index_of(frag, ':', 0)
    if colon <= 0 {
      issues.push((chunk.0, "@doc entry \"" + frag + "\" is not `key: value`"))
      continue
    }
    let key = trim(frag[0:colon].to_owned())
    let value = unquote(trim(frag[colon + 1:].to_owned()))
    match key {
      "summary" => summary = value
      "description" => description = value
      _ =>
        issues.push(
          (chunk.0, "@doc entry \"" + key + "\" is not summary or description"),
        )
    }
  }
  if summary == "" {
    description
  } else {
    summary
  }
}

///|
/// A handler name for a route that named none: the verb and the path's own
/// segments. A spec that forgot several `@handler`s still describes several
/// distinct handlers, so `parse_lenient` generates functions that differ rather
/// than one name declared twice; `parse` reports the omission either way.
fn fallback_handler(verb : String, path : String) -> String {
  let mut out = verb
  for seg in path_segments(path) {
    let mut word = ""
    for i = 0; i < seg.length(); i = i + 1 {
      let c = seg[i]
      if (c >= 'a' && c <= 'z') ||
        (c >= 'A' && c <= 'Z') ||
        (c >= '0' && c <= '9') ||
        c == '_' {
        word = word + seg[i:i + 1].to_owned()
      }
    }
    if word != "" {
      out = out + "_" + word
    }
  }
  to_snake(out + "_handler")
}

///|
/// Whether `s` opens with the annotation `name` itself rather than merely starting
/// with its letters: `@docs` is an annotation of its own, not a misspelt `@doc`, and
/// reading it as one would attach its text to the next route.
fn is_annot(s : String, name : String) -> Bool {
  if starts_with(s, name) == false {
    return false
  }
  let n = name.length()
  s.length() == n || is_ws(s[n]) || s[n] == '(' || s[n] == '"'
}

///|
/// The `"…"`-quoted paths in `s`, in the order they appear.
fn quoted_paths(s : String) -> Array[String] {
  let out : Array[String] = []
  let n = s.length()
  let mut i = 0
  while i < n {
    if s[i] == '"' {
      let start = i + 1
      let mut j = start
      while j < n && s[j] != '"' {
        j = j + 1
      }
      out.push(s[start:j].to_owned())
      i = j + 1
    } else {
      i = i + 1
    }
  }
  out
}

///|
/// Scan a `.api` source into a `Spec` plus every line the parser could not read.
/// Scanning always runs to the end, so one typo does not hide the rest.
fn read(source : String) -> (Spec, Array[(Int, String)]) {
  let issues : Array[(Int, String)] = []
  let mut service = "app"
  let routes : Array[Route] = []
  let types : Array[TypeDef] = []
  let info : Array[(String, String)] = []
  let groups : Array[Group] = []
  let imports : Array[String] = []
  let mut in_import = false
  let mut import_line = 0
  // The `type` blocks still open, outermost first: a nested block pushes onto it, a
  // `}` pops one, and whatever is left at the end was never closed.
  let open : Array[(TypeDef, Int)] = []
  let mut in_info = false
  let mut info_line = 0
  let mut in_type_group = false
  let mut type_group_line = 0
  let mut in_server = false
  let mut server_line = 0
  let server_body : Array[(Int, String)] = []
  let mut in_service = false
  let mut service_line = 0
  let mut group : Group? = None
  let mut pending_handler = ""
  let mut pending_doc = ""
  let mut in_doc = false
  let mut doc_line = 0
  let doc_body : Array[(Int, String)] = []
  let src = lines(source)
  for i = 0; i < src.length(); i = i + 1 {
    let no = i + 1
    let s = trim(strip_comment(src[i]))
    if s == "" {
      continue
    }
    if in_server {
      let close = index_of(s, ')', 0)
      let part = if close >= 0 { s[0:close].to_owned() } else { s }
      server_body.push((no, part))
      if close >= 0 {
        in_server = false
        let g = read_group(server_body, issues)
        groups.push(g)
        group = Some(g)
      }
      continue
    }
    if in_doc {
      let close = index_of(s, ')', 0)
      let part = if close >= 0 { s[0:close].to_owned() } else { s }
      doc_body.push((no, part))
      if close >= 0 {
        in_doc = false
        pending_doc = read_doc(doc_body, issues)
      }
      continue
    }
    // A grouped `import ( "a.api" "b.api" )` block: every quoted path in it is one
    // import, and the `)` closes the block.
    if in_import {
      let close = index_of(s, ')', 0)
      let part = trim(if close >= 0 { s[0:close].to_owned() } else { s })
      let found = quoted_paths(part)
      for p in found {
        imports.push(p)
      }
      if part != "" && found.length() == 0 {
        issues.push(
          (
            no,
            "expected a quoted path in the import group, got \"" + part + "\"",
          ),
        )
      }
      if close >= 0 {
        in_import = false
      }
      continue
    }
    // An `info ( key: "value" … )` block (← goctl's `info(...)`): its members feed
    // the OpenAPI `info` object. `syntax = "v1"` is recorded and skipped so it is
    // not mistaken for a route.
    if in_info {
      if starts_with(s, ")") {
        in_info = false
      } else {
        let colon = index_of(s, ':', 0)
        if colon > 0 {
          info.push(
            (
              trim(s[0:colon].to_owned()),
              unquote(trim(s[colon + 1:].to_owned())),
            ),
          )
        } else {
          issues.push((no, "info entry \"" + s + "\" is not `key: value`"))
        }
      }
      continue
    }
    if starts_with(s, "syntax") {
      continue
    }
    if starts_with(s, "info") && index_of(s, '(', 0) >= 0 {
      in_info = true
      info_line = no
      continue
    }
    // `Name Type` is a field, so an unterminated block would otherwise swallow the
    // rest of the file. A line that opens a top-level statement cannot be a field:
    // take it as the closing brace that was left out, then read it as the statement
    // it is.
    let statement = s == "service" ||
      starts_with(s, "service ") ||
      starts_with(s, "type ") ||
      starts_with(s, "type(") ||
      starts_with(s, "import") ||
      starts_with(s, "@")
    if open.length() > 0 {
      let td = open[open.length() - 1].0
      if s == "}" {
        open.pop() |> ignore
        continue
      } else if statement {
        unwind(open, issues)
      } else {
        match read_member(s) {
          Some(Plain(f)) => td.fields.push(f)
          Some(Inline(name)) => td.embeds.push(name)
          Some(Nest(name, tag)) => {
            // MoonBit has no anonymous struct, so the nested block becomes a type of
            // its own named after the two, and the field points at it.
            let nested : TypeDef = {
              name: td.name + upper_first(name),
              fields: [],
              embeds: [],
            }
            types.push(nested)
            td.fields.push({ name, type_: nested.name, tag, })
            open.push((nested, no))
          }
          None =>
            issues.push(
              (no, "field \"" + s + "\" in type " + td.name + " has no type"),
            )
        }
        continue
      }
    }
    // A grouped `type ( … )` block declares each member as a bare `Name {` line;
    // the single `type Name {` form is handled below.
    if in_type_group {
      if starts_with(s, ")") {
        in_type_group = false
        continue
      } else if statement {
        in_type_group = false
        issues.push((type_group_line, "type group is missing its closing `)`"))
      } else if index_of(s, '{', 0) < 0 {
        issues.push(
          (no, "expected `Name {` in the type group, got \"" + s + "\""),
        )
        continue
      } else {
        let toks = tokens(s)
        if toks.length() >= 1 && toks[0] != "{" {
          let td : TypeDef = {
            name: drop_brace(toks[0]),
            fields: [],
            embeds: [],
          }
          types.push(td)
          open.push((td, no))
        }
        continue
      }
    }
    if starts_with(s, "service ") || s == "service" {
      let toks = tokens(s)
      let name = if toks.length() >= 2 { drop_brace(toks[1]) } else { "" }
      if name == "" {
        issues.push((no, "service needs a name"))
      } else {
        service = name
      }
      if index_of(s, '{', 0) < 0 {
        issues.push((no, "expected `{` after the service name"))
      } else {
        in_service = true
        service_line = no
      }
    } else if starts_with(s, "type ") || starts_with(s, "type(") {
      let toks = tokens(s)
      if starts_with(trim(s[4:].to_owned()), "(") {
        in_type_group = true
        type_group_line = no
      } else if toks.length() >= 2 && index_of(s, '{', 0) >= 0 {
        let td : TypeDef = {
          name: drop_brace(toks[1]),
          fields: [],
          embeds: [],
        }
        types.push(td)
        open.push((td, no))
      } else {
        issues.push(
          (no, "expected `type Name {` or `type (`, got \"" + s + "\""),
        )
      }
    } else if starts_with(s, "import") {
      // Both `import "a.api"` and the grouped `import ( … )`; a path is resolved
      // against the file that wrote it, which only `deps`/`parse_all` can do.
      let rest = trim(s[6:].to_owned())
      if starts_with(rest, "(") {
        let body = trim(rest[1:].to_owned())
        let close = index_of(body, ')', 0)
        for
          p in quoted_paths(
            if close >= 0 {
              body[0:close].to_owned()
            } else {
              body
            },
          ) {
          imports.push(p)
        }
        if close < 0 {
          in_import = true
          import_line = no
        }
      } else {
        let found = quoted_paths(rest)
        if found.length() == 0 {
          issues.push((no, "import needs a quoted path"))
        }
        for p in found {
          imports.push(p)
        }
      }
    } else if is_annot(s, "@doc") {
      // Three spellings: `@doc "text"`, `@doc("text")`, and goctl's block form
      // `@doc ( summary: "text" )`, which may run over several lines. The text is
      // whatever follows the name — `@doc("…")` carries no space to split on.
      let rest = trim(s[4:].to_owned())
      if starts_with(rest, "(") {
        let body = rest[1:].to_owned()
        let close = index_of(body, ')', 0)
        let inner = if close >= 0 { body[0:close].to_owned() } else { body }
        if starts_with(trim(inner), "\"") {
          pending_doc = unquote(trim(inner))
        } else if close >= 0 {
          pending_doc = read_doc([(no, inner)], issues)
        } else {
          in_doc = true
          doc_line = no
          doc_body.clear()
          doc_body.push((no, inner))
        }
      } else if starts_with(rest, "\"") {
        let close = index_of(rest, '"', 1)
        pending_doc = if close > 0 { rest[1:close].to_owned() } else { rest }
      } else {
        pending_doc = rest
      }
    } else if is_annot(s, "@handler") {
      let toks = tokens(s)
      if toks.length() >= 2 {
        pending_handler = toks[1]
      } else {
        issues.push((no, "@handler needs a name"))
      }
    } else if is_annot(s, "@server") {
      let lp = index_of(s, '(', 0)
      if lp < 0 {
        issues.push((no, "@server needs a `( … )` annotation list"))
      } else {
        let body = s[lp + 1:].to_owned()
        let close = index_of(body, ')', 0)
        if close >= 0 {
          let g = read_group([(no, body[0:close].to_owned())], issues)
          groups.push(g)
          group = Some(g)
        } else {
          in_server = true
          server_line = no
          server_body.clear()
          server_body.push((no, body))
        }
      }
    } else if starts_with(s, "@") {
      issues.push((no, "unknown annotation \"" + tokens(s)[0] + "\""))
    } else if s == "}" {
      if in_service {
        if pending_handler != "" {
          issues.push((no, "@handler " + pending_handler + " has no route"))
        }
        in_service = false
        group = None
        pending_handler = ""
        pending_doc = ""
      } else {
        issues.push((no, "unexpected `}`"))
      }
    } else {
      // A route is `verb /path`, optionally carrying `(Req)`, `returns (Resp)`, or
      // both; the legacy form instead names its handler inline and may add a quoted
      // summary.
      let toks = tokens(s)
      if toks.length() >= 2 && is_http_verb(toks[0]) {
        let rest = rest_after(s, 2)
        let mut req = ""
        let mut resp = ""
        let mut handler = pending_handler
        let mut summary = pending_doc
        if starts_with(rest, "(") {
          let rp = index_of(rest, ')', 1)
          if rp > 1 {
            req = trim(rest[1:rp].to_owned())
          }
          if rp >= 0 {
            let tail = trim(rest[rp + 1:].to_owned())
            if starts_with(tail, "returns") {
              resp = paren_body(tail)
            }
          }
        } else if starts_with(rest, "returns") {
          resp = paren_body(rest)
        } else if rest != "" {
          let lt = tokens(rest)
          if lt.length() >= 1 {
            handler = lt[0]
          }
          if lt.length() >= 2 {
            summary = lt[1]
          }
        }
        let verb = toks[0].to_lower()
        if handler == "" {
          issues.push(
            (
              no,
              "route " +
              verb +
              " " +
              toks[1] +
              " has no handler; name one with `@handler`",
            ),
          )
          handler = fallback_handler(verb, toks[1])
        }
        let prefix = match group {
          Some(g) => g.prefix
          None => ""
        }
        routes.push({
          verb,
          path: join_path(prefix, toks[1]),
          handler,
          summary,
          req,
          resp,
          group,
        })
        pending_handler = ""
        pending_doc = ""
      } else if is_http_verb(toks[0]) {
        issues.push((no, "route " + toks[0] + " needs a path"))
      } else if in_service || (toks.length() >= 2 && starts_with(toks[1], "/")) {
        issues.push((no, "unknown verb \"" + toks[0] + "\""))
      } else {
        issues.push((no, "unknown statement \"" + toks[0] + "\""))
      }
    }
  }
  unwind(open, issues)
  if in_doc {
    issues.push((doc_line, "@doc block is missing its closing `)`"))
  }
  if in_import {
    issues.push((import_line, "import block is missing its closing `)`"))
  }
  if in_info {
    issues.push((info_line, "info block is missing its closing `)`"))
  }
  if in_type_group {
    issues.push((type_group_line, "type group is missing its closing `)`"))
  }
  if in_server {
    issues.push((server_line, "@server block is missing its closing `)`"))
  }
  if in_service {
    issues.push(
      (service_line, "service " + service + " is missing its closing `}`"),
    )
  }
  ({ service, routes, types, info, groups, imports, }, issues)
}

///|
/// Read a goctl-style `.api` description into a `Spec`. Grammar (one statement per
/// line):
///
/// ```
/// syntax = "v1"
///
/// info (
///   title:   "greet"
///   version: "v2"
/// )
///
/// type LoginReq {
///   name: string
/// }
///
/// @server (
///   group:      user
///   prefix:     /api/v1
///   middleware: Log
/// )
/// service greet {
///   @doc "health check"
///   @handler ping
///   get /ping
///
///   @handler login
///   post /login (LoginReq) returns (LoginResp)
///
///   get /legacy legacy_handler "the inline form"
/// }
/// ```
///
/// A `type` block declares a message schema (its fields become a MoonBit struct);
/// field types use goctl's Go spellings (`string`, `int64`, `[]T`, `map[K]V`, …).
/// An `@server( … )` block annotates every route of the service block that follows
/// it: the routes carry its `prefix` in their paths and its `Group` on their
/// `group`. Verbs are lower-cased, blank lines and `//` comments are dropped.
///
/// Any other line raises a `SpecError` naming it — a misspelt verb or a missing
/// brace would otherwise generate a quietly truncated program. Use `parse_lenient`
/// for a best-effort read of a spec that is still being written.
pub fn parse(source : String) -> Spec raise SpecError {
  let (spec, issues) = read(source)
  if issues.length() > 0 {
    let mut first = issues[0]
    for it in issues {
      if it.0 < first.0 {
        first = it
      }
    }
    raise Syntax(line=first.0, msg=first.1)
  }
  spec
}

///|
/// `parse` without the diagnostics: a line the parser cannot read is skipped and
/// whatever the spec does describe is returned. For a caller that generates from a
/// half-written spec on every keystroke; anything that reports to a user should
/// call `parse`.
pub fn parse_lenient(source : String) -> Spec {
  read(source).0
}

///|
fn quote(s : String) -> String {
  "\"" + s + "\""
}

///|
/// A group's annotations spelled the way the spec wrote them, e.g. `group: user,
/// prefix: /api/v1, middleware: Log`. Empty for a group that carries nothing.
fn group_note(g : Group) -> String {
  let parts : Array[String] = []
  if g.name != "" {
    parts.push("group: " + g.name)
  }
  if g.prefix != "" {
    parts.push("prefix: " + g.prefix)
  }
  if g.jwt != "" {
    parts.push("jwt: " + g.jwt)
  }
  if g.middleware.length() > 0 {
    let mut ms = ""
    for m in g.middleware {
      ms = if ms == "" { m } else { ms + "," + m }
    }
    parts.push("middleware: " + ms)
  }
  if g.max_bytes > 0L {
    parts.push("maxBytes: " + g.max_bytes.to_string())
  }
  if g.timeout != "" {
    parts.push("timeout: " + g.timeout)
  }
  if g.signature {
    parts.push("signature: true")
  }
  for kv in g.extra {
    parts.push(kv.0 + ": " + kv.1)
  }
  let mut out = ""
  for p in parts {
    out = if out == "" { p } else { out + ", " + p }
  }
  out
}

///|
/// Generate compilable moonapi scaffolding from a spec: a MoonBit struct for
/// every `type` block, a `build_app` that wires every route to its handler, plus
/// a stub for each handler. The routing part depends only on `Lfan-ke/moonapi`
/// and `Lfan-ke/moonasgi`; the emitted schemas are dependency-free.
///
/// Pass `template` to render the spec through the runtime template engine (see
/// `generate_with`) instead of the built-in generator; omit it for the default
/// scaffold.
pub fn generate(spec : Spec, template? : String) -> String raise TemplateError {
  match template {
    Some(src) => generate_with(spec, src)
    None => generate_builtin(spec)
  }
}

///|
/// The `TypeDef` named `name`, or `None` when the spec declared no such block.
fn find_type(types : Array[TypeDef], name : String) -> TypeDef? {
  for t in types {
    if t.name == name {
      return Some(t)
    }
  }
  None
}

///|
/// Every field a `type` block carries with its inline embeds folded in first,
/// which is both the shape an embedded Go struct serialises to and the only one
/// MoonBit can spell — a MoonBit struct has no embedding. An embed naming a block
/// that is not in `types`, and one that leads back to a block already folded in,
/// contribute nothing.
fn flat_fields(t : TypeDef, types : Array[TypeDef]) -> Array[Field] {
  let out : Array[Field] = []
  let seen : Array[String] = [t.name]
  fn walk(td : TypeDef) -> Unit {
    for name in td.embeds {
      if seen.contains(name) {
        continue
      }
      seen.push(name)
      match find_type(types, name) {
        Some(base) => walk(base)
        None => ()
      }
    }
    for f in td.fields {
      out.push(f)
    }
  }

  walk(t)
  out
}

///|
/// A MoonBit struct for every `type` block, in the order the spec declared them,
/// each followed by whatever its tags asked to be checked (see `render_checks`).
fn render_structs(types : Array[TypeDef]) -> String {
  let mut out = ""
  for t in types {
    let fields = flat_fields(t, types)
    out = out +
      "///|\n/// `" +
      t.name +
      "` schema (generated from the `.api` `type` block).\npub(all) struct " +
      t.name +
      " {\n"
    for f in fields {
      out = out + "  " + f.mbt_name() + " : " + f.type_ + "\n"
    }
    out = out + "}\n\n" + render_checks(t.name, fields)
  }
  out
}

///|
fn generate_builtin(spec : Spec) -> String {
  let mut out = "// Code generated by moonctl. DO NOT EDIT.\n\n" +
    render_structs(spec.types)
  out = out +
    "///|\n/// Build the " +
    spec.service +
    " application with its routes wired to handlers.\n"
  out = out + "pub fn build_app() -> @moonapi.App {\n"
  out = out + "  let app = @moonapi.App::new()\n"
  // A group's jwt / middleware / limits have no moonapi call to emit them into, so
  // the block they govern is marked with the annotations the spec asked for.
  let mut note = ""
  for r in spec.routes {
    let mark = match r.group {
      Some(g) => group_note(g)
      None => ""
    }
    if mark != note {
      out = out +
        (if mark == "" {
          "  // (ungrouped)\n"
        } else {
          "  // @server " + mark + "\n"
        })
      note = mark
    }
    // moonapi's `ApiHandler` is a *raising* fn-type, and under moonc 0.10.5 a pure
    // named fn no longer coerces to one. Register each handler through an arrow
    // closure — a closure infers the raise effect, so a stub that never raises and a
    // filled-in handler that raises an `HttpException` are both accepted unchanged.
    let handler = "ctx => " + r.handler + "(ctx)"
    let head = "  app." + r.verb + "(" + quote(r.path) + ", " + handler
    out = out +
      (if r.summary != "" {
        head + ", summary=" + quote(r.summary) + ")\n"
      } else {
        head + ")\n"
      })
  }
  out = out + "  app\n}\n\n"
  for r in spec.routes {
    let where_ = match r.group {
      Some(g) if g.name != "" => " of group " + g.name
      _ => ""
    }
    out = out +
      "///|\n/// `" +
      r.verb +
      " " +
      r.path +
      "`" +
      where_ +
      ".\npub fn " +
      r.handler +
      "(_ctx : @moonapi.Context) -> @moonasgi.Response {\n  @moonapi.text(200, " +
      quote("TODO: " + r.handler) +
      ")\n}\n\n"
  }
  out
}