///|
/// JSON quoting handles C0, quotes and backslashes; also escape C1 and line
/// separators for a single terminal row. Never feed this back into JSON data.
fn search_terminal_string(text : String) -> String {
  let quoted = Json::string(text).stringify()
  let out = StringBuilder()
  for ch in quoted.iter() {
    let code = ch.to_int()
    if (code >= 0x7f && code <= 0x9f) || code == 0x2028 || code == 0x2029 {
      out.write_string("\\u")
      let hex = "0123456789abcdef"
      for shift in [12, 8, 4, 0] {
        out.write_string(
          hex[(code >> shift) & 15:((code >> shift) & 15) + 1].to_owned(),
        )
      }
    } else {
      out.write_string(ch.to_string())
    }
  }
  out.to_string()
}

///|
pub fn render_function_search_json(report : FunctionSearchReport) -> String {
  let root : Map[String, Json] = Map([])
  root.set("format", Json::string("moonsize-function-search"))
  root.set("schema_version", int_json(1))
  root.set("file_size", int_json(report.file_size))
  root.set("sha256", Json::string(report.sha256))
  root.set("query", Json::string(report.query))
  root.set("matching_rule", Json::string(report.matching_rule))
  root.set("scope", Json::string(report.scope))
  root.set("local_function_count", int_json(report.local_function_count))
  root.set("named_count", int_json(report.named_count))
  root.set("unnamed_count", int_json(report.unnamed_count))
  root.set("match_count", int_json(report.match_count))
  root.set("unmatched_named_count", int_json(report.unmatched_named_count))
  root.set("matched_body_bytes", int_json(report.matched_body_bytes))
  root.set("matched_prefix_bytes", int_json(report.matched_prefix_bytes))
  root.set("matched_encoded_bytes", int_json(report.matched_encoded_bytes))
  root.set(
    "matches",
    Json::array(
      report.matches.map(fn(item) {
        let row : Map[String, Json] = Map([])
        row.set("index", int_json(item.index))
        row.set("name", Json::string(item.name))
        row.set("raw_name", Json::string(item.raw_name))
        row.set("module_name", Json::string(item.module_name))
        row.set("offset", int_json(item.offset))
        row.set("body_size", int_json(item.body_size))
        row.set("total_size", int_json(item.total_size))
        row.set("matched_raw_name", Json::boolean(item.matched_raw_name))
        row.set(
          "matched_display_name",
          Json::boolean(item.matched_display_name),
        )
        Json::object(row)
      }),
    ),
  )
  Json::object(root).stringify(indent=2) + "\n"
}

///|
pub fn render_function_search(
  report : FunctionSearchReport,
  top : Int,
) -> String {
  let out = StringBuilder()
  out.write_string("LOCAL FUNCTION NAME SEARCH\n\n")
  out.write_string("Input: \{report.file_size} B; SHA-256 \{report.sha256}\n")
  out.write_string("Query: " + search_terminal_string(report.query) + "\n")
  out.write_string(report.matching_rule + "\n")
  out.write_string("\nNAME COVERAGE AND MATCH BYTE LEDGER\n")
  out.write_string(
    "Local functions: \{report.local_function_count} = named \{report.named_count} + unnamed \{report.unnamed_count}\n",
  )
  out.write_string(
    "Named functions: \{report.named_count} = matched \{report.match_count} + unmatched \{report.unmatched_named_count}\n",
  )
  out.write_string(
    "Matched encoded bytes: \{report.matched_encoded_bytes} B = body \{report.matched_body_bytes} B + length prefixes \{report.matched_prefix_bytes} B\n",
  )
  let shown = top.max(0).min(report.match_count)
  out.write_string(
    "\nMATCHES: showing \{shown} of \{report.match_count} (total bytes descending, index ascending)\nINDEX  BODY B  TOTAL B  MATCH SOURCE  NAME\n",
  )
  if report.local_function_count == 0 {
    out.write_string("No local function definitions.\n")
  } else if report.named_count == 0 {
    out.write_string("No usable local function names available.\n")
  } else if report.match_count == 0 {
    out.write_string("No matches in the available local function names.\n")
  }
  for i in 0..