// The JSON Lines renderer.
//
// Built as TEXT rather than through a JSON value, because the field order is
// part of the contract the harness compares against and a keyed map does not
// promise one.

///|
/// Escape a string as a JSON scalar.
///
/// Matches the reference's encoder: quote and backslash escaped, the five
/// named control escapes, everything else below 0x20 as `\u00xx`, and non-ASCII
/// passed through as raw UTF-8 rather than escaped.
fn json_string(s : String) -> String {
  let out = StringBuilder::new()
  out.write_char('"')
  for c in s {
    match c {
      '"' => out.write_string("\\\"")
      '\\' => out.write_string("\\\\")
      '\n' => out.write_string("\\n")
      '\r' => out.write_string("\\r")
      '\t' => out.write_string("\\t")
      '\u{08}' => out.write_string("\\b")
      '\u{0C}' => out.write_string("\\f")
      _ =>
        if c.to_int() < 0x20 {
          let hex = "0123456789abcdef"
          let n = c.to_int()
          out.write_string("\\u00")
          out.write_char(
            hex.unsafe_get((n >> 4) & 0xF).to_int().unsafe_to_char(),
          )
          out.write_char(hex.unsafe_get(n & 0xF).to_int().unsafe_to_char())
        } else {
          out.write_char(c)
        }
    }
  }
  out.write_char('"')
  out.to_string()
}

///|
/// The six span fields, in the order the contract fixes.
///
/// Columns are 0-based here (against the human format's 1-based one), and the
/// offsets are UTF-8 BYTE offsets into the source, so an agent can rewrite the
/// raw bytes.
fn span_fields(loc : @basic.Location) -> Array[(String, String)] {
  [
    ("startLine", loc.start.lnum.to_string()),
    ("startColumn", loc.start.column0().to_string()),
    ("endLine", loc.end.lnum.to_string()),
    ("endColumn", loc.end.column0().to_string()),
    ("startOffset", loc.start.cnum.to_string()),
    ("endOffset", loc.end.cnum.to_string()),
  ]
}

///|
fn obj(fields : Array[(String, String)]) -> String {
  let parts = fields.map(f => json_string(f.0) + ":" + f.1)
  "{" + parts.join(",") + "}"
}

///|
/// Emit one diagnostic as a single-line JSON object.
///
/// `warning` and `hint` are null when absent; `related` is always an array;
/// `edit` is OMITTED entirely when absent rather than being null.
fn output_json(sink : Sink, d : Diagnostic) -> Unit {
  let fields : Array[(String, String)] = [
    ("severity", json_string(d.severity.to_str())),
    ("file", json_string(d.loc.start.fname)),
  ]
  fields.append(span_fields(d.loc))
  fields.push(("message", json_string(@message.to_plain_string(d.message))))
  fields.push(
    (
      "warning",
      match d.warning {
        Some(w) => json_string(w.name())
        None => "null"
      },
    ),
  )
  fields.push(
    (
      "hint",
      match d.hint {
        Some(m) => json_string(@message.to_plain_string(m))
        None => "null"
      },
    ),
  )
  let related = d.related.map(l => {
    let f = span_fields(l.loc)
    f.push(("message", json_string(@message.to_plain_string(l.message))))
    obj(f)
  })
  fields.push(("related", "[" + related.join(",") + "]"))
  match d.edit {
    Some(e) => {
      let f = span_fields(e.loc)
      f.push(("newText", json_string(e.new_text)))
      fields.push(("edit", obj(f)))
    }
    None => ()
  }
  line(sink, obj(fields))
}