///|
pub fn parse(
  source : String,
  dialect? : Dialect = standard(),
  limits? : ParseLimits = default_limits(),
) -> Document {
  match budget_diagnostic(source, limits) {
    Some(d) => return { source, dialect, limits, nodes: [], diagnostics: [d] }
    None => ()
  }
  let lines = scan_lines(source)
  if lines.length() > limits.max_lines {
    let d = diagnostic(
      "LIMIT003",
      "source exceeds physical line budget",
      span(0, source.length(), 1, 1),
    )
    return { source, dialect, limits, nodes: [], diagnostics: [d] }
  }
  let nodes : Array[Node] = []
  let diagnostics : Array[Diagnostic] = []
  let mut section = ""
  let mut consumed_until = 0
  for line in lines {
    if line.start < consumed_until {
      continue
    }
    let (a, b) = trim_bounds(source, line.start, line.content_end)
    let full = line_span(line, line.start, line.end)
    let empty = line_span(line, a, a)
    let mut node : Node = {
      kind: Invalid,
      span: full,
      section,
      key: "",
      value: "",
      key_span: empty,
      value_span: empty,
    }
    if a == b {
      node = { ..node, kind: Blank }
    } else if is_comment(source[a], dialect) {
      node = { ..node, kind: Comment }
    } else if source[a] == '[' {
      let mut close = a + 1
      while close < b && source[close] != ']' {
        close += 1
      }
      if close == b {
        diagnostics.push(
          diagnostic("INI001", "unclosed section header", line_span(line, a, b)),
        )
      } else {
        let (sa, sb) = trim_bounds(source, a + 1, close)
        let (tail, _) = trim_bounds(source, close + 1, b)
        if sa == sb {
          diagnostics.push(
            diagnostic("INI002", "empty section name", line_span(line, sa, sb)),
          )
        } else if tail < b && !is_comment(source[tail], dialect) {
          diagnostics.push(
            diagnostic(
              "INI003",
              "unexpected text after section",
              line_span(line, tail, b),
            ),
          )
        } else {
          section = text(source, sa, sb)
          node = {
            ..node,
            kind: Section,
            section,
            key_span: line_span(line, sa, sb),
          }
        }
      }
    } else {
      let mut sep = a
      while sep < b &&
            source[sep] != '=' &&
            !(dialect.allow_colon && source[sep] == ':') {
        sep += 1
      }
      if sep == b {
        diagnostics.push(
          diagnostic(
            "INI004",
            "expected key/value separator",
            line_span(line, a, b),
          ),
        )
      } else {
        let (ka, kb) = trim_bounds(source, a, sep)
        if ka == kb {
          diagnostics.push(
            diagnostic("INI005", "empty key", line_span(line, ka, kb)),
          )
        } else {
          let (va, vb) = trim_bounds(source, sep + 1, b)
          let key_span = line_span(line, ka, kb)
          let (value, value_span, node_end) = if dialect.multiline &&
            triple_at(source, va, vb) {
            decode_multiline(source, line, va, dialect, diagnostics)
          } else {
            let (v, s) = decode_value(
              source, line, va, vb, dialect, diagnostics,
            )
            (v, s, line.end)
          }
          consumed_until = node_end
          node = {
            ..node,
            kind: Entry,
            span: { ..full, end: node_end },
            key: text(source, ka, kb),
            value,
            key_span,
            value_span,
          }
        }
      }
    }
    nodes.push(node)
  }
  for d in duplicate_diagnostics(nodes, dialect) {
    diagnostics.push(d)
  }
  diagnostics.sort_by((a, b) => a.span.start.compare(b.span.start))
  { source, dialect, limits, nodes, diagnostics }
}

///|
pub fn parse_with_diagnostics(
  source : String,
  dialect? : Dialect = standard(),
  limits? : ParseLimits = default_limits(),
) -> (Document, Array[Diagnostic]) {
  let doc = parse(source, dialect~, limits~)
  (doc, doc.diagnostics())
}

///|
fn is_comment(c : UInt16, dialect : Dialect) -> Bool {
  c == ';' || (dialect.hash_comments && c == '#')
}