///|
/// Semantic findings supplement (never replace) parser diagnostics.
/// Findings do not modify source or guess at a repair.
pub fn lint(doc : Document) -> Array[Diagnostic] {
  let out = doc.diagnostics.copy()
  let numbers : Map[String, Int] = Map([])
  let names : Map[String, String] = Map([])
  for e in doc.elements {
    if e.kind == SceneHeading {
      if e.detail != "" {
        match numbers.get(e.detail) {
          Some(line) =>
            out.push({
              code: "FNT101",
              line: e.line,
              message: "Duplicate scene number \{e.detail}; first seen at line \{line}",
            })
          None => numbers.set(e.detail, e.line)
        }
      } else if e.text.has_suffix("#") {
        out.push({
          code: "FNT102",
          line: e.line,
          message: "Malformed scene number retained in heading",
        })
      }
    }
    if e.kind == Character {
      let folded = e.text.to_upper()
      match names.get(folded) {
        Some(name) if name != e.text =>
          out.push({
            code: "FNT103",
            line: e.line,
            message: "Case-distinct role \{e.text} resembles \{name}; identities remain separate",
          })
        None => names.set(folded, e.text)
        _ => ()
      }
    }
    if e.kind == Dialogue && e.text.has_prefix("(") && !e.text.has_suffix(")") {
      out.push({
        code: "FNT104",
        line: e.line,
        message: "Unclosed parenthetical retained as dialogue",
      })
    }
    if e.kind == Action && stripped(e.text).has_prefix("@") {
      out.push({
        code: "FNT105",
        line: e.line,
        message: "Isolated or empty forced character cue retained as action",
      })
    }
  }
  for turn in turns(doc) {
    if turn.dialogue.length() == 0 {
      out.push({
        code: "FNT106",
        line: turn.line,
        message: "Character turn contains directions but no spoken dialogue",
      })
    }
  }
  out
}

///|
pub fn Diagnostic::format(self : Diagnostic) -> String {
  "line \{self.line}: \{self.code}: \{self.message}"
}