///|
// Renders warnings first and match hits second so parse failures never hide
// successful findings from other files.
fn render_directory_scan_result(result : DirectoryScanResult) -> String {
  let sections : Array[String] = []
  if result.scan_trace.length() > 0 {
    sections.push(result.scan_trace.join("\n"))
  }
  let warnings : Array[String] = []
  for skipped in result.skipped_files {
    warnings.push("warning: skipping \{skipped.file}: \{skipped.reason}")
  }
  if warnings.length() > 0 {
    sections.push(warnings.join("\n"))
  }
  if result.hits.length() == 0 {
    sections.push("no match hits")
  } else {
    let rendered_hits : Array[String] = []
    for hit in result.hits {
      rendered_hits.push(render_match_hit(hit))
    }
    sections.push(rendered_hits.join("\n\n"))
  }
  sections.join("\n\n")
}

///|
// Keep hit output stable and line-oriented for downstream agents. The first
// line is always a location, followed by rule metadata and indented
// description text.
fn render_match_hit(hit : @rules.MatchHit) -> String {
  let lines : Array[String] = [
    format_location(hit.file, hit.loc),
    "rule: \{hit.rule_id}",
  ]
  match hit.outer_loc {
    Some(loc) => lines.push("outer_loc: \{format_location(hit.file, loc)}")
    None => ()
  }
  lines.push("description:")
  let description = trim_trailing_newlines(hit.description)
  if description == "" {
    lines.push("  ")
  } else {
    for line in description.split("\n") {
      lines.push("  \{line}")
    }
  }
  lines.join("\n")
}

///|
fn format_location(file : String, loc : @basic.Location) -> String {
  "\{file}:\{loc.start.lnum}:\{loc.start.column()}-\{loc.end.lnum}:\{loc.end.column()}"
}

///|
fn trim_trailing_newlines(input : String) -> String {
  let mut end = input.length()
  while end > 0 && input[end - 1:end].to_owned() == "\n" {
    end = end - 1
  }
  input[:end].to_owned()
}