///|
/// Parses the document (Ruby `Parser.parse`).
fn parse_document(
  reader : Reader,
  document : Node,
  header_only~ : Bool,
) -> Unit {
  let mut block_attributes = parse_document_header(
    reader, document, header_only,
  )
  if !header_only {
    while reader.has_more_lines() {
      let (new_section, attrs) = next_section(
        reader, document, block_attributes,
      )
      block_attributes = attrs
      match new_section {
        Some(s) => {
          document.assign_numeral(s)
          document.blocks.push(s)
        }
        None => ()
      }
    }
  }
}

///|
fn parse_document_header(
  reader : Reader,
  document : Node,
  header_only : Bool,
) -> Attributes {
  let block_attrs = if reader.skip_blank_lines() is Some(_) {
    parse_block_metadata_lines(reader, document, Attributes::new())
  } else {
    Attributes::new()
  }
  let doc_attrs = document.attributes
  let dd = document.doc()
  let implicit_doctitle = is_next_line_doctitle(
    reader,
    block_attrs,
    doc_attrs.str("leveloffset"),
  )
  if implicit_doctitle &&
    (block_attrs.truthy("title") || block_attrs.truthy("style")) {
    doc_attrs.set("authorcount", Int(0))
    return document.finalize_header(block_attrs, header_valid=false)
  }
  let mut doctitle_attr_val : String? = None
  match doc_attrs.str("doctitle") {
    Some(val) if val != "" => {
      document.set_title(Some(val))
      doctitle_attr_val = Some(val)
    }
    _ => ()
  }
  if implicit_doctitle {
    let source_location = if dd.sourcemap {
      Some(reader.cursor())
    } else {
      None
    }
    let (id, _, l0_title, _, atx) = parse_section_title(reader, document, None)
    document.id = id
    let mut l0_section_title : String? = Some(l0_title)
    if doctitle_attr_val is Some(_) {
      l0_section_title = None
    } else {
      document.set_title(Some(l0_title))
      let mut v = sub_specialchars(l0_title)
      doc_attrs.set_str("doctitle", v)
      if v.contains("{") {
        v = document.sub_attributes(v, attribute_missing="skip")
        doc_attrs.set_str("doctitle", v)
      }
      doctitle_attr_val = Some(v)
    }
    match source_location {
      Some(sl) => dd.header.unwrap().source_location = Some(sl)
      None => ()
    }
    if !atx && !document.attribute_locked("compat-mode") {
      doc_attrs.set_str("compat-mode", "")
    }
    match block_attrs.str("separator") {
      Some(sep) =>
        if !document.attribute_locked("title-separator") {
          doc_attrs.set_str("title-separator", sep)
        }
      None => ()
    }
    let doc_id = match block_attrs.str("id") {
      Some(i) => {
        document.id = Some(i)
        Some(i)
      }
      None => document.id
    }
    match block_attrs.str("role") {
      Some(r) => doc_attrs.set_str("role", r)
      None => ()
    }
    match block_attrs.str("reftext") {
      Some(r) => doc_attrs.set_str("reftext", r)
      None => ()
    }
    block_attrs.clear()
    let modified_attrs = dd.attributes_modified
    modified_attrs.remove("doctitle")
    parse_header_metadata(reader, Some(document)) |> ignore
    if modified_attrs.contains("doctitle") {
      match doc_attrs.str("doctitle") {
        Some(val) if val != "" && Some(val) != doctitle_attr_val =>
          document.set_title(Some(val))
        _ =>
          match doctitle_attr_val {
            Some(v) => doc_attrs.set_str("doctitle", v)
            None => ()
          }
      }
    } else if l0_section_title is None {
      modified_attrs["doctitle"] = true
    }
    match doc_id {
      Some(i) => document.register_ref(i, document) |> ignore
      None => ()
    }
  } else if doc_attrs.str("author") is Some(author) {
    let author_metadata = process_authors(
      [author],
      names_only=true,
      multiple=false,
    )
    if doc_attrs.truthy("authorinitials") {
      author_metadata.remove("authorinitials") |> ignore
    }
    doc_attrs.update(author_metadata)
  } else if doc_attrs.str("authors") is Some(author) {
    let author_metadata = process_authors([author], names_only=true)
    doc_attrs.update(author_metadata)
  } else {
    doc_attrs.set("authorcount", Int(0))
  }
  if document.doctype() == "manpage" {
    parse_manpage_header(reader, document, block_attrs, header_only)
  }
  document.finalize_header(block_attrs)
}

///|
fn parse_manpage_header(
  reader : Reader,
  document : Node,
  block_attributes : Attributes,
  header_only : Bool,
) -> Unit {
  let doc_attrs = document.attributes
  let mut manvolnum = "1"
  match manpage_title_volnum_rx.find(doc_attrs.str("doctitle").unwrap_or("")) {
    Some(m) => {
      manvolnum = m.at(2)
      doc_attrs.set_str("manvolnum", manvolnum)
      let mantitle = m.at(1)
      doc_attrs.set_str(
        "mantitle",
        @rb.downcase(
          if mantitle.contains("{") {
            document.sub_attributes(mantitle)
          } else {
            mantitle
          },
        ),
      )
    }
    None => {
      log_error(
        "non-conforming manpage title",
        source_location=reader.cursor_at_line(1),
      )
      doc_attrs.set_str(
        "mantitle",
        match doc_attrs.str("doctitle") {
          Some(t) => t
          None => doc_attrs.str("docname").unwrap_or("command")
        },
      )
      doc_attrs.set_str("manvolnum", "1")
    }
  }
  let set_output = fn(manname : String) {
    if document.backend() == "manpage" {
      doc_attrs.set_str("docname", manname)
      doc_attrs.set_str("outfilesuffix", ".\{manvolnum}")
    }
  }
  match (doc_attrs.str("manname"), doc_attrs.str("manpurpose")) {
    (Some(manname), Some(_)) => {
      doc_attrs.set_default("manname-title", Str("Name"))
      doc_attrs.set("mannames", List([manname]))
      set_output(manname)
    }
    _ =>
      if !header_only {
        reader.skip_blank_lines() |> ignore
        reader.save()
        block_attributes.update(
          parse_block_metadata_lines(reader, document, Attributes::new()),
        )
        let mut error_msg : String? = None
        match is_next_line_section(reader, Attributes::new()) {
          Some(1) => {
            let name_section = initialize_section(
              reader,
              document,
              Attributes::new(),
            )
            let name_section_buffer = reader
              .read_lines_until(
                break_on_blank_lines=true,
                skip_line_comments=true,
              )
              .map(@rb.lstrip)
              .join(" ")
            match manpage_name_purpose_rx.find(name_section_buffer) {
              Some(m) => {
                let mut manname = m.at(1)
                if manname.contains("{") {
                  manname = document.sub_attributes(manname)
                }
                let mannames = if manname.contains(",") {
                  @rb.split(manname, ",").map(@rb.lstrip)
                } else {
                  [manname]
                }
                manname = mannames[0]
                let mut manpurpose = m.at(2)
                if manpurpose.contains("{") {
                  manpurpose = document.sub_attributes(manpurpose)
                }
                doc_attrs.set_default(
                  "manname-title",
                  match name_section.title() {
                    Some(t) => Str(t)
                    None => Nil
                  },
                )
                match name_section.id {
                  Some(i) => doc_attrs.set_str("manname-id", i)
                  None => ()
                }
                doc_attrs.set_str("manname", manname)
                doc_attrs.set("mannames", List(mannames))
                doc_attrs.set_str("manpurpose", manpurpose)
                set_output(manname)
              }
              None => error_msg = Some("non-conforming name section body")
            }
          }
          Some(_) => error_msg = Some("name section must be at level 1")
          None => error_msg = Some("name section expected")
        }
        match error_msg {
          Some(msg) => {
            reader.restore_save()
            log_error(msg, source_location=reader.cursor())
            let manname = doc_attrs.str("docname").unwrap_or("command")
            doc_attrs.set_str("manname", manname)
            doc_attrs.set("mannames", List([manname]))
            set_output(manname)
          }
          None => reader.discard_save()
        }
      }
  }
}

///|
/// Parses the next section (Ruby `Parser.next_section`).
fn next_section(
  reader : Reader,
  parent : Node,
  attributes : Attributes,
) -> (Node?, Attributes) {
  let mut attributes = attributes
  let mut preamble : Node? = None
  let mut intro : Node? = None
  let mut part = false
  let mut section = parent
  let mut current_level = 0
  let mut expected_next_level : Int? = None
  let mut expected_next_level_alt : Int? = None
  let mut sectname = ""
  let document = parent.document()
  let book = document.doctype() == "book"
  let parent_is_doc_start = parent.context == Document &&
    parent.blocks.is_empty() &&
    ({
      let has_header = parent.has_header()
      has_header ||
      attributes.remove("invalid-header") is Some(_) ||
      is_next_line_section(reader, attributes) is None
    })
  if parent_is_doc_start {
    let has_header = parent.has_header()
    if has_header || (book && attributes.pos_str(1) != Some("abstract")) {
      let p = Node::new_block(parent, Preamble, content_model=Compound)
      if book && parent.has_attr("preface-title") {
        p.set_title(parent.attr("preface-title"))
      }
      parent.blocks.push(p)
      preamble = Some(p)
      intro = Some(p)
    }
    section = parent
    current_level = 0
    if parent.attributes.contains("fragment") {
      expected_next_level = Some(-1)
    } else if book {
      expected_next_level = Some(1)
      expected_next_level_alt = Some(0)
    } else {
      expected_next_level = Some(1)
    }
  } else {
    section = initialize_section(reader, parent, attributes)
    attributes = match attributes.get("title") {
      Some(t) if t.truthy() => Attributes::from_array([("title", t)])
      _ => Attributes::new()
    }
    current_level = section.level
    expected_next_level = Some(current_level + 1)
    if current_level == 0 {
      part = book
    } else if current_level == 1 && section.special {
      sectname = section.sectname.unwrap_or("")
      if !(sectname == "appendix" ||
        sectname == "preface" ||
        sectname == "abstract") {
        expected_next_level = None
      }
    }
  }
  reader.skip_blank_lines() |> ignore
  while reader.has_more_lines() {
    parse_block_metadata_lines(reader, document, attributes) |> ignore
    match is_next_line_section(reader, attributes) {
      Some(nl) => {
        let mut next_level = nl
        if document.has_attr("leveloffset") {
          next_level += @rb.to_i(document.attr("leveloffset").unwrap_or("0"))
          if next_level < 0 {
            next_level = 0
          }
        }
        if next_level > current_level {
          match expected_next_level {
            Some(enl) =>
              if !(next_level == enl ||
                expected_next_level_alt == Some(next_level) ||
                enl < 0) {
                let expected_condition = match expected_next_level_alt {
                  Some(alt) => "expected levels \{alt} or \{enl}"
                  None => "expected level \{enl}"
                }
                log_warn(
                  "section title out of sequence: \{expected_condition}, got level \{next_level}",
                  source_location=reader.cursor(),
                )
              }
            None =>
              log_error(
                "\{sectname} sections do not support nested sections",
                source_location=reader.cursor(),
              )
          }
          let (new_section, attrs2) = next_section(reader, section, attributes)
          attributes = attrs2
          section.assign_numeral(new_section.unwrap())
          section.blocks.push(new_section.unwrap())
        } else if next_level == 0 && physical_equal(section, document) {
          if !book {
            log_error(
              "level 0 sections can only be used when doctype is book",
              source_location=reader.cursor(),
            )
          }
          let (new_section, attrs2) = next_section(reader, section, attributes)
          attributes = attrs2
          section.assign_numeral(new_section.unwrap())
          section.blocks.push(new_section.unwrap())
        } else {
          break
        }
      }
      None => {
        let block_cursor = reader.cursor()
        let target_parent = match intro {
          Some(i) => i
          None => section
        }
        match
          next_block(reader, target_parent, attributes, parse_metadata=false) {
          Some(new_block) => {
            if part {
              if !section.has_blocks() {
                if new_block.style != Some("partintro") {
                  if new_block.style == Some("open") &&
                    new_block.context == Open {
                    new_block.style = Some("partintro")
                  } else {
                    let i = Node::new_block(
                      section,
                      Open,
                      content_model=Compound,
                    )
                    new_block.set_parent(i)
                    i.style = Some("partintro")
                    section.blocks.push(i)
                    intro = Some(i)
                  }
                } else if new_block.content_model == Simple {
                  new_block.content_model = Compound
                  new_block.append(
                    Node::new_block(
                      new_block,
                      Paragraph,
                      source=new_block.lines,
                      subs=ExplicitSubs(new_block.subs),
                    ),
                  )
                  new_block.lines.clear()
                  new_block.subs.clear()
                }
              } else if section.blocks.length() == 1 {
                let first_block = section.blocks[0]
                if intro is None && first_block.content_model == Compound {
                  log_error(
                    "illegal block content outside of partintro block",
                    source_location=block_cursor,
                  )
                } else if first_block.content_model != Compound {
                  let i = Node::new_block(section, Open, content_model=Compound)
                  new_block.set_parent(i)
                  i.style = Some("partintro")
                  if first_block.style == Some("partintro") {
                    first_block.set_context(Paragraph)
                    first_block.style = None
                  }
                  section.blocks.remove(0) |> ignore
                  i.append(first_block)
                  section.blocks.push(i)
                  intro = Some(i)
                }
              }
            }
            match intro {
              Some(i) => i.blocks.push(new_block)
              None => section.blocks.push(new_block)
            }
            attributes.clear()
          }
          None => ()
        }
      }
    }
    if reader.skip_blank_lines() is None {
      break
    }
  }
  if part {
    if !(section.has_blocks() &&
      section.blocks[section.blocks.length() - 1].context == Section) {
      log_error(
        "invalid part, must have at least one section (e.g., chapter, appendix, etc.)",
        source_location=reader.cursor(),
      )
    }
  } else {
    match preamble {
      Some(p) =>
        if p.has_blocks() {
          if book ||
            document.blocks.length() > 1 ||
            !compliance.unwrap_standalone_preamble {
            if document.sourcemap() {
              p.source_location = p.blocks[0].source_location
            }
          } else {
            document.blocks.remove(0) |> ignore
            while !p.blocks.is_empty() {
              let child = p.blocks.remove(0)
              document.append(child)
            }
          }
        } else {
          document.blocks.remove(0) |> ignore
        }
      None => ()
    }
  }
  (
    if physical_equal(section, parent) {
      None
    } else {
      Some(section)
    },
    attributes.copy(),
  )
}

///|
/// Creates a section from the title at the reader position (Ruby `initialize_section`).
fn initialize_section(
  reader : Reader,
  parent : Node,
  attributes : Attributes,
) -> Node {
  let document = parent.document()
  let doctype = document.doctype()
  let book = doctype == "book"
  let source_location = if document.sourcemap() {
    Some(reader.cursor())
  } else {
    None
  }
  let sect_style = attributes.pos_str(1)
  let (sect_id, sect_reftext, sect_title, sect_level0, sect_atx) = parse_section_title(
    reader,
    document,
    attributes.str("id"),
  )
  let mut sect_level = sect_level0
  let mut sect_name = "section"
  let mut sect_special = false
  let mut sect_numbered = false
  match sect_style {
    Some(style) =>
      if book && style == "abstract" {
        sect_name = "chapter"
        sect_level = 1
      } else if style.has_prefix("sect") &&
        section_level_style_rx.matches(style) {
        sect_name = "section"
      } else {
        sect_name = style
        sect_special = true
        if sect_level == 0 {
          sect_level = 1
        }
        sect_numbered = sect_name == "appendix"
      }
    None =>
      if book {
        sect_name = if sect_level == 0 {
          "part"
        } else if sect_level > 1 {
          "section"
        } else {
          "chapter"
        }
      } else if doctype == "manpage" &&
        @rb.downcase_ascii(sect_title) == "synopsis" {
        sect_name = "synopsis"
        sect_special = true
      } else {
        sect_name = "section"
      }
  }
  match sect_reftext {
    Some(r) => attributes.set_str("reftext", r)
    None => ()
  }
  let section = Node::new_section(Some(parent), level=sect_level)
  section.id = sect_id
  section.set_title(Some(sect_title))
  section.sectname = Some(sect_name)
  section.source_location = source_location
  if sect_special {
    section.special = true
    if sect_numbered {
      section.numbered = Numbered
    } else if document.attributes.str("sectnums") == Some("all") {
      section.numbered = if book && sect_level == 1 {
        NumberedChapter
      } else {
        Numbered
      }
    }
  } else if document.attributes.truthy("sectnums") && sect_level > 0 {
    section.numbered = if section.special {
      if parent.numbered != NotNumbered {
        Numbered
      } else {
        NotNumbered
      }
    } else {
      Numbered
    }
  } else if book && sect_level == 0 && document.attributes.truthy("partnums") {
    section.numbered = Numbered
  }
  let mut id = section.id
  match id {
    Some(i) =>
      if i == "" {
        section.id = None
        id = None
      } else if sect_title.contains("{") {
        section.title() |> ignore
      }
    None =>
      if document.attributes.contains("sectids") {
        let gid = generate_section_id(section.title().unwrap_or(""), document)
        section.id = Some(gid)
        id = Some(gid)
      }
  }
  match id {
    Some(i) =>
      if !document.register_ref(i, section) {
        log_warn(
          "id assigned to section already in use: \{i}",
          source_location=reader.cursor_at_line(
            reader.lineno() - (if sect_atx { 1 } else { 2 }),
          ),
        )
      }
    None => ()
  }
  section.update_attributes(attributes)
  reader.skip_blank_lines() |> ignore
  section
}

///|
/// Level of the section title at the reader position, if any.
fn is_next_line_section(reader : Reader, attributes : Attributes) -> Int? {
  let style = attributes.pos_str(1)
  if style == Some("discrete") || style == Some("float") {
    return None
  }
  if compliance.underline_style_section_titles {
    let next_lines = reader.peek_lines(num=2, direct=style == Some("comment"))
    is_section_title(next_lines.get(0).unwrap_or(""), next_lines.get(1))
  } else {
    atx_section_title(reader.peek_line().unwrap_or(""))
  }
}

///|
fn is_next_line_doctitle(
  reader : Reader,
  attributes : Attributes,
  leveloffset : String?,
) -> Bool {
  match leveloffset {
    Some(lo) =>
      match is_next_line_section(reader, attributes) {
        Some(sl) => sl + @rb.to_i(lo) == 0
        None => false
      }
    None => is_next_line_section(reader, attributes) == Some(0)
  }
}

///|
fn is_section_title(line1 : String, line2 : String?) -> Int? {
  match atx_section_title(line1) {
    Some(l) => Some(l)
    None =>
      match line2 {
        Some(l2) if l2 != "" => setext_section_title(line1, l2)
        _ => None
      }
  }
}

///|
fn atx_section_title(line : String) -> Int? {
  let m = if compliance.markdown_syntax {
    if line.has_prefix("=") || line.has_prefix("#") {
      ext_atx_section_title_rx.find(line)
    } else {
      None
    }
  } else if line.has_prefix("=") {
    atx_section_title_rx.find(line)
  } else {
    None
  }
  match m {
    Some(m) => Some(m.at(1).length() - 1)
    None => None
  }
}

///|
fn setext_level(ch : String) -> Int? {
  match ch {
    "=" => Some(0)
    "-" => Some(1)
    "~" => Some(2)
    "^" => Some(3)
    "+" => Some(4)
    _ => None
  }
}

///|
fn first_char(s : String) -> String {
  if s == "" {
    ""
  } else {
    let w = if s[0] >= 0xD800 && s[0] <= 0xDBFF && s.length() > 1 {
      2
    } else {
      1
    }
    s.unsafe_substring(start=0, end=w)
  }
}

///|
fn setext_section_title(line1 : String, line2 : String) -> Int? {
  let ch0 = first_char(line2)
  match setext_level(ch0) {
    Some(level) => {
      let line2_len = line2.length()
      if is_uniform(line2, ch0, line2_len) &&
        setext_section_title_rx.matches(line1) &&
        (@rb.char_length(line1) - @rb.char_length(line2)).abs() < 2 {
        Some(level)
      } else {
        None
      }
    }
    None => None
  }
}

///|
/// Parses a section title (Ruby `parse_section_title`): (id, reftext, title, level, atx).
fn parse_section_title(
  reader : Reader,
  document : Node,
  sect_id : String?,
) -> (String?, String?, String, Int, Bool) {
  let mut sect_id = sect_id
  let mut sect_reftext : String? = None
  let line1 = reader.read_line().unwrap_or("")
  let mut sect_level = 0
  let mut sect_title = ""
  let mut atx = false
  let atx_m = if compliance.markdown_syntax {
    if line1.has_prefix("=") || line1.has_prefix("#") {
      ext_atx_section_title_rx.find(line1)
    } else {
      None
    }
  } else if line1.has_prefix("=") {
    atx_section_title_rx.find(line1)
  } else {
    None
  }
  match atx_m {
    Some(m) => {
      sect_level = m.at(1).length() - 1
      sect_title = m.at(2)
      atx = true
      if sect_id is None && sect_title.has_suffix("]]") {
        match inline_section_anchor_rx.find(sect_title) {
          Some(am) if !am.has(1) => {
            sect_title = @rb.slice(
              sect_title,
              0,
              sect_title.length() - am.matched().length(),
            )
            sect_id = am.group(2)
            sect_reftext = am.group(3)
          }
          _ => ()
        }
      }
    }
    None => {
      let line2 = if compliance.underline_style_section_titles {
        reader.peek_line(direct=true)
      } else {
        None
      }
      let setext = match line2 {
        Some(l2) if l2 != "" =>
          match setext_level(first_char(l2)) {
            Some(level) =>
              if is_uniform(l2, first_char(l2), l2.length()) &&
                (@rb.char_length(line1) - @rb.char_length(l2)).abs() < 2 {
                match setext_section_title_rx.find(line1) {
                  Some(m) => Some((level, m.at(1)))
                  None => None
                }
              } else {
                None
              }
            None => None
          }
        _ => None
      }
      match setext {
        Some((level, title)) => {
          sect_level = level
          sect_title = title
          atx = false
          if sect_id is None && sect_title.has_suffix("]]") {
            match inline_section_anchor_rx.find(sect_title) {
              Some(am) if !am.has(1) => {
                sect_title = @rb.slice(
                  sect_title,
                  0,
                  sect_title.length() - am.matched().length(),
                )
                sect_id = am.group(2)
                sect_reftext = am.group(3)
              }
              _ => ()
            }
          }
          reader.shift() |> ignore
        }
        None =>
          abort(
            "Unrecognized section at \{reader.cursor_at_prev_line().line_info()}",
          )
      }
    }
  }
  if document.has_attr("leveloffset") {
    sect_level += @rb.to_i(document.attr("leveloffset").unwrap_or("0"))
    if sect_level < 0 {
      sect_level = 0
    }
  }
  (sect_id, sect_reftext, sect_title, sect_level, atx)
}

///|
/// Parses the author and revision lines (Ruby `parse_header_metadata`).
fn parse_header_metadata(reader : Reader, document : Node?) -> Attributes {
  let doc_attrs = document.map(d => d.attributes)
  process_attribute_entries(reader, document, None)
  let mut implicit_author_metadata = Attributes::new()
  let rev_metadata = Attributes::new()
  let mut author_metadata : Attributes? = None
  let mut implicit_author : String? = None
  let mut implicit_authorinitials : String? = None
  let mut implicit_authors : String? = None
  let mut authorcount : Int? = None
  if reader.has_more_lines() && !reader.next_line_empty() {
    implicit_author_metadata = process_authors([reader.read_line().unwrap()])
    let ac = implicit_author_metadata
      .remove("authorcount")
      .map(v => v.to_i())
      .unwrap_or(0)
    authorcount = Some(ac)
    match (document, doc_attrs) {
      (Some(doc), Some(da)) => {
        da.set("authorcount", Int(ac))
        if ac > 0 {
          for k, v in implicit_author_metadata.iter() {
            if k is Name(key) && !da.contains(key) {
              da.set_str(key, doc.apply_header_subs(v.to_s()))
            }
          }
          implicit_author = da.str("author")
          implicit_authorinitials = da.str("authorinitials")
          implicit_authors = da.str("authors")
        }
      }
      _ => ()
    }
    implicit_author_metadata.set("authorcount", Int(ac))
    process_attribute_entries(reader, document, None)
    if reader.has_more_lines() && !reader.next_line_empty() {
      let rev_line = reader.read_line().unwrap()
      match revision_info_line_rx.find(rev_line) {
        Some(m) => {
          match m.group(1) {
            Some(g1) => rev_metadata.set_str("revnumber", @rb.rstrip(g1))
            None => ()
          }
          let component = @rb.strip(m.at(2))
          if component != "" {
            if !m.has(1) && component.has_prefix("v") {
              rev_metadata.set_str("revnumber", @rb.from(component, 1))
            } else {
              rev_metadata.set_str("revdate", component)
            }
          }
          match m.group(3) {
            Some(g3) => rev_metadata.set_str("revremark", @rb.rstrip(g3))
            None => ()
          }
          match (document, doc_attrs) {
            (Some(doc), Some(da)) if !rev_metadata.is_empty() =>
              for k, v in rev_metadata.iter() {
                if k is Name(key) && !da.contains(key) {
                  da.set_str(key, doc.apply_header_subs(v.to_s()))
                }
              }
            _ => ()
          }
        }
        None => reader.unshift_line(rev_line)
      }
    }
    process_attribute_entries(reader, document, None)
    reader.skip_blank_lines() |> ignore
  }
  match doc_attrs {
    Some(da) => {
      let mut am : Attributes = Attributes::new()
      if da.contains("author") && da.str("author") != implicit_author {
        am = process_authors(
          [da.str("author").unwrap_or("")],
          names_only=true,
          multiple=false,
        )
        if da.str("authorinitials") != implicit_authorinitials {
          am.remove("authorinitials") |> ignore
        }
      } else if da.contains("authors") && da.str("authors") != implicit_authors {
        am = process_authors([da.str("authors").unwrap_or("")], names_only=true)
      } else {
        let authors : Array[String?] = []
        let mut author_idx = 1
        let mut author_key = "author_1"
        let mut explicit = false
        let mut sparse = false
        while da.contains(author_key) {
          let author_override = da.str(author_key)
          if author_override == implicit_author_metadata.str(author_key) {
            authors.push(None)
            sparse = true
          } else {
            authors.push(author_override)
            explicit = true
          }
          author_idx += 1
          author_key = "author_\{author_idx}"
        }
        if explicit {
          if sparse {
            for idx, author in authors {
              if author is None {
                let name_idx = idx + 1
                let parts = [
                  implicit_author_metadata.str("firstname_\{name_idx}"),
                  implicit_author_metadata.str("middlename_\{name_idx}"),
                  implicit_author_metadata.str("lastname_\{name_idx}"),
                ]
                authors[idx] = Some(
                  parts
                  .filter(p => p is Some(_))
                  .map(p => p.unwrap().replace_all(old=" ", new="_"))
                  .join(" "),
                )
              }
            }
          }
          am = process_authors(
            authors.map(a => a.unwrap_or("")),
            names_only=true,
            multiple=false,
          )
        } else {
          am = Attributes::from_array([("authorcount", Int(0))])
        }
      }
      if am.get("authorcount").map(v => v.to_i()) == Some(0) {
        if authorcount is Some(_) {
          author_metadata = None
        } else {
          da.set("authorcount", Int(0))
          // Ruby keeps author_metadata ({ 'authorcount' => 0 }) in the result
          author_metadata = Some(am)
        }
      } else {
        da.update(am)
        author_metadata = Some(am)
        if !da.contains("email") && da.contains("email_1") {
          da.set("email", da.get("email_1").unwrap())
        }
      }
    }
    None => ()
  }
  let result = implicit_author_metadata.copy()
  result.update(rev_metadata)
  match author_metadata {
    Some(am) => result.update(am)
    None => ()
  }
  result
}

///|
let author_keys : Array[String] = [
  "author", "authorinitials", "firstname", "middlename", "lastname", "email",
]

///|
/// Parses author lines (Ruby `process_authors`).
fn process_authors(
  author_lines : Array[String],
  names_only? : Bool = false,
  multiple? : Bool = true,
) -> Attributes {
  let author_metadata = Attributes::new()
  let mut author_idx = 0
  let entries = if multiple &&
    author_lines.length() == 1 &&
    author_lines[0].contains(";") {
    author_delimiter_rx.split(author_lines[0])
  } else {
    author_lines
  }
  for author_entry0 in entries {
    let mut author_entry = author_entry0
    if author_entry == "" {
      continue
    }
    author_idx += 1
    let key = fn(k : String) {
      if author_idx == 1 {
        k
      } else {
        "\{k}_\{author_idx}"
      }
    }
    let mut segments : Array[String]? = None
    if names_only {
      if author_entry.contains("<") {
        author_metadata.set_str(
          key("author"),
          author_entry.replace_all(old="_", new=" "),
        )
        author_entry = xml_sanitize_rx.gsub(author_entry, "")
      }
      let segs = split_ws_limit(author_entry, 3)
      if segs.length() == 3 {
        segs[2] = @rb.squeeze(segs[2], chars=" ")
      }
      segments = Some(segs)
    } else {
      match author_info_line_rx.find(author_entry) {
        Some(m) => {
          let segs = []
          for i in 1.. segs.push(s)
              None => segs.push("\u{0}")
            }
          }
          segments = Some(segs)
        }
        None => ()
      }
    }
    let seg = fn(segs : Array[String], i : Int) -> String? {
      match segs.get(i) {
        Some(s) => if s == "\u{0}" { None } else { Some(s) }
        None => None
      }
    }
    match segments {
      Some(segs) => {
        let fname = seg(segs, 0).unwrap_or("").replace_all(old="_", new=" ")
        let mut author = fname
        author_metadata.set_str(key("firstname"), fname)
        author_metadata.set_str(key("authorinitials"), first_char(fname))
        match seg(segs, 1) {
          Some(s1) =>
            match seg(segs, 2) {
              Some(s2) => {
                let mname = s1.replace_all(old="_", new=" ")
                let lname = s2.replace_all(old="_", new=" ")
                author_metadata.set_str(key("middlename"), mname)
                author_metadata.set_str(key("lastname"), lname)
                author = fname + " " + mname + " " + lname
                author_metadata.set_str(
                  key("authorinitials"),
                  "\{first_char(fname)}\{first_char(mname)}\{first_char(lname)}",
                )
              }
              None => {
                let lname = s1.replace_all(old="_", new=" ")
                author_metadata.set_str(key("lastname"), lname)
                author = fname + " " + lname
                author_metadata.set_str(
                  key("authorinitials"),
                  "\{first_char(fname)}\{first_char(lname)}",
                )
              }
            }
          None => ()
        }
        author_metadata.set_default(key("author"), Str(author))
        if !names_only {
          match seg(segs, 3) {
            Some(email) => author_metadata.set_str(key("email"), email)
            None => ()
          }
        }
      }
      None => {
        let fname = @rb.strip(@rb.squeeze(author_entry, chars=" "))
        author_metadata.set_str(key("author"), fname)
        author_metadata.set_str(key("firstname"), fname)
        author_metadata.set_str(key("authorinitials"), first_char(fname))
      }
    }
    if author_idx == 1 {
      author_metadata.set(
        "authors",
        author_metadata.get(key("author")).unwrap_or(Nil),
      )
    } else {
      if author_idx == 2 {
        for k in author_keys {
          if author_metadata.contains(k) {
            author_metadata.set("\{k}_1", author_metadata.get(k).unwrap())
          }
        }
      }
      author_metadata.set_str(
        "authors",
        "\{author_metadata.str("authors").unwrap_or("")}, \{author_metadata.str(key("author")).unwrap_or("")}",
      )
    }
  }
  author_metadata.set("authorcount", Int(author_idx))
  author_metadata
}

///|
/// Ruby `str.split(nil, limit)`: whitespace split with a field limit.
fn split_ws_limit(s : String, limit : Int) -> Array[String] {
  let out = []
  let n = s.length()
  let mut i = 0
  while i < n && @rb.is_space(s[i]) {
    i += 1
  }
  while i < n {
    if out.length() == limit - 1 {
      out.push(s.unsafe_substring(start=i, end=n))
      return out
    }
    let start = i
    while i < n && !@rb.is_space(s[i]) {
      i += 1
    }
    out.push(s.unsafe_substring(start~, end=i))
    while i < n && @rb.is_space(s[i]) {
      i += 1
    }
  }
  out
}

///|
/// Parses consecutive block metadata lines.
fn parse_block_metadata_lines(
  reader : Reader,
  document : Node,
  attributes : Attributes,
  text_only? : Bool = false,
) -> Attributes {
  while parse_block_metadata_line(reader, document, attributes, text_only~) {
    reader.shift() |> ignore
    if reader.skip_blank_lines() is None {
      break
    }
  }
  attributes
}

///|
/// Parses one block metadata line (anchor, attribute list, title, comment,
/// attribute entry). Returns true if the line was metadata.
fn parse_block_metadata_line(
  reader : Reader,
  document : Node,
  attributes : Attributes,
  text_only? : Bool = false,
) -> Bool {
  guard reader.peek_line() is Some(next_line) else { return false }
  let normal = !text_only &&
    (
      next_line.has_prefix("[") ||
      next_line.has_prefix(".") ||
      next_line.has_prefix("/") ||
      next_line.has_prefix(":")
    )
  if !(if text_only {
      next_line.has_prefix("[") || next_line.has_prefix("/")
    } else {
      normal
    }) {
    return false
  }
  if next_line.has_prefix("[") {
    if next_line.has_prefix("[[") {
      if next_line.has_suffix("]]") &&
        block_anchor_rx.find(next_line) is Some(m) {
        match m.group(1) {
          Some(id) => attributes.set_str("id", id)
          None => attributes.set("id", Nil)
        }
        match m.group(2) {
          Some(reftext) =>
            attributes.set_str(
              "reftext",
              if reftext.contains("{") {
                document.sub_attributes(reftext)
              } else {
                reftext
              },
            )
          None => ()
        }
        return true
      }
    } else if next_line.has_suffix("]") &&
      block_attribute_list_rx.find(next_line) is Some(m) {
      let current_style = attributes.get_pos(1)
      let parsed = document.parse_attributes(
        m.at(1),
        [],
        sub_input=true,
        sub_result=true,
        into=attributes,
      )
      if parsed.get_pos(1) is Some(v) && v.truthy() {
        match parse_style_attribute(attributes, Some(reader)) {
          Some(s) => attributes.set_pos(1, Str(s))
          None => attributes.set_pos(1, current_style.unwrap_or(Nil))
        }
      }
      return true
    }
  } else if normal && next_line.has_prefix(".") {
    match block_title_rx.find(next_line) {
      Some(m) => {
        attributes.set_str("title", m.at(1))
        return true
      }
      None => ()
    }
  } else if !normal || next_line.has_prefix("/") {
    if next_line.has_prefix("//") {
      if next_line == "//" {
        return true
      } else if normal && is_uniform(next_line, "/", next_line.length()) {
        if next_line.length() != 3 {
          reader.read_lines_until(
            terminator=next_line,
            skip_first_line=true,
            preserve_last_line=true,
            skip_processing=true,
            context=Some("comment"),
          )
          |> ignore
          return true
        }
      } else if !next_line.has_prefix("///") {
        return true
      }
    }
  } else if normal &&
    next_line.has_prefix(":") &&
    attribute_entry_rx.find(next_line) is Some(m) {
    process_attribute_entry(reader, Some(document), Some(attributes), Some(m))
    |> ignore
    return true
  }
  false
}

///|
fn process_attribute_entries(
  reader : Reader,
  document : Node?,
  attributes : Attributes?,
) -> Unit {
  reader.skip_comment_lines()
  while process_attribute_entry(reader, document, attributes, None) {
    reader.shift() |> ignore
    reader.skip_comment_lines()
  }
}

///|
fn process_attribute_entry(
  reader : Reader,
  document : Node?,
  attributes : Attributes?,
  m : @regex.MatchData?,
) -> Bool {
  let m = match m {
    Some(m) => m
    None =>
      if reader.has_more_lines() {
        match attribute_entry_rx.find(reader.peek_line().unwrap_or("")) {
          Some(m) => m
          None => return false
        }
      } else {
        return false
      }
  }
  let mut value = match m.group(2) {
    Some(v) => v
    None => ""
  }
  if value != "" && (value.has_suffix(" \\") || value.has_suffix(" +")) {
    let con = @rb.from(value, value.length() - 2)
    value = @rb.rstrip(@rb.slice(value, 0, value.length() - 2))
    while reader.advance() {
      let next_line0 = reader.peek_line().unwrap_or("")
      if next_line0 == "" {
        break
      }
      let mut next_line = @rb.lstrip(next_line0)
      let keep_open = next_line.has_suffix(con)
      if keep_open {
        next_line = @rb.rstrip(@rb.slice(next_line, 0, next_line.length() - 2))
      }
      value = "\{value}\{if value.has_suffix(" +") { "\n" } else { " " }}\{next_line}"
      if !keep_open {
        break
      }
    }
  }
  store_attribute(m.at(1), Some(value), doc=document, attrs=attributes)
  |> ignore
  true
}

///|
/// Stores an attribute entry (Ruby `Parser.store_attribute`); returns (name, value).
fn store_attribute(
  name : String,
  value : String?,
  doc~ : Node?,
  attrs~ : Attributes?,
) -> (String, String?) {
  let mut name = name
  let mut value = value
  if name.has_suffix("!") {
    name = @rb.chop(name)
    value = None
  } else if name.has_prefix("!") {
    name = @rb.from(name, 1)
    value = None
  }
  name = sanitize_attribute_name(name)
  if name == "numbered" {
    name = "sectnums"
  } else if name == "hardbreaks" {
    name = "hardbreaks-option"
  } else if name == "showtitle" {
    store_attribute(
      "notitle",
      if value is Some(_) {
        None
      } else {
        Some("")
      },
      doc~,
      attrs~,
    )
    |> ignore
  }
  match doc {
    Some(d) =>
      match value {
        Some(v0) => {
          let mut v = v0
          if name == "leveloffset" {
            if v.has_prefix("+") {
              v = (@rb.to_i(d.attr("leveloffset", default="0").unwrap_or("0")) +
              @rb.to_i(@rb.from(v, 1))).to_string()
            } else if v.has_prefix("-") {
              v = (@rb.to_i(d.attr("leveloffset", default="0").unwrap_or("0")) -
              @rb.to_i(@rb.from(v, 1))).to_string()
            }
          }
          match d.set_attribute(name, value=v) {
            Some(resolved) => {
              value = Some(resolved)
              match attrs {
                Some(a) =>
                  a.save_entry(AttributeEntry::new(name, Some(resolved)))
                None => ()
              }
            }
            None => value = Some(v)
          }
        }
        None =>
          if d.delete_attribute(name) {
            match attrs {
              Some(a) => a.save_entry(AttributeEntry::new(name, None))
              None => ()
            }
          }
      }
    None =>
      match attrs {
        Some(a) => a.save_entry(AttributeEntry::new(name, value))
        None => ()
      }
  }
  (name, value)
}

///|
fn sanitize_attribute_name(name : String) -> String {
  @rb.downcase(invalid_attribute_name_chars_rx.gsub(name, ""))
}

///|
/// Whether `str` consists of `len` copies of `chr` (Ruby `uniform?`).
fn is_uniform(str : String, chr : String, len : Int) -> Bool {
  @rb.count(str, chr) * chr.length() == len && str.length() == len
}

///|
/// Parses the style attribute shorthand (`#id.role%option`), Ruby `parse_style_attribute`.
fn parse_style_attribute(attributes : Attributes, reader : Reader?) -> String? {
  match attributes.pos_str(1) {
    Some(raw_style) if !raw_style.contains(" ") &&
      compliance.shorthand_property_syntax => {
      let mut name : String? = None
      let accum = StringBuilder()
      let mut parsed_style : String? = None
      let mut parsed_id : String? = None
      let mut has_id = false
      let roles : Array[String] = []
      let mut has_role = false
      let options : Array[String] = []
      let mut has_option = false
      let flush = fn(nm : String?, value : String) {
        match nm {
          Some(n) =>
            if value == "" {
              match reader {
                Some(r) =>
                  log_warn(
                    "invalid empty \{n} detected in style attribute",
                    source_location=r.cursor_at_prev_line(),
                  )
                None =>
                  log_warn("invalid empty \{n} detected in style attribute")
              }
            } else if n == "id" {
              if has_id {
                match reader {
                  Some(r) =>
                    log_warn(
                      "multiple ids detected in style attribute",
                      source_location=r.cursor_at_prev_line(),
                    )
                  None => log_warn("multiple ids detected in style attribute")
                }
              }
              has_id = true
              parsed_id = Some(value)
            } else if n == "role" {
              has_role = true
              roles.push(value)
            } else {
              has_option = true
              options.push(value)
            }
          None => if value != "" { parsed_style = Some(value) }
        }
      }
      for c in raw_style {
        match c {
          '.' => {
            flush(name, accum.to_string())
            accum.reset()
            name = Some("role")
          }
          '#' => {
            flush(name, accum.to_string())
            accum.reset()
            name = Some("id")
          }
          '%' => {
            flush(name, accum.to_string())
            accum.reset()
            name = Some("option")
          }
          _ => accum.write_char(c)
        }
      }
      match name {
        Some(_) => {
          flush(name, accum.to_string())
          match parsed_style {
            Some(s) => attributes.set_str("style", s)
            None => ()
          }
          if has_id {
            match parsed_id {
              Some(i) => attributes.set_str("id", i)
              None => ()
            }
          }
          if has_role {
            let existing = attributes.str("role")
            attributes.set_str(
              "role",
              match existing {
                Some(e) if e != "" => "\{e} \{roles.join(" ")}"
                _ => roles.join(" ")
              },
            )
          }
          if has_option {
            for opt in options {
              attributes.set_str("\{opt}-option", "")
            }
          }
          parsed_style
        }
        None => {
          attributes.set_str("style", raw_style)
          Some(raw_style)
        }
      }
    }
    raw => {
      match raw {
        Some(s) => attributes.set_str("style", s)
        None => attributes.set("style", Nil)
      }
      raw
    }
  }
}

///|
/// Adjusts indentation of lines (Ruby `adjust_indentation!`).
fn adjust_indentation(
  lines : Array[String],
  indent_size? : Int = 0,
  tab_size? : Int = 0,
) -> Unit {
  if lines.is_empty() {
    return
  }
  if tab_size > 0 && lines.iter().any(l => l.contains("\t")) {
    let full_tab_space = @rb.repeat(" ", tab_size)
    for i, line0 in lines {
      if line0 == "" || !line0.contains("\t") {
        continue
      }
      let mut line = line0
      if line.has_prefix("\t") {
        let mut leading_tabs = 0
        while leading_tabs < line.length() && line[leading_tabs] == '\t' {
          leading_tabs += 1
        }
        line = @rb.repeat(full_tab_space, leading_tabs) +
          @rb.from(line, leading_tabs)
        if !line.contains("\t") {
          lines[i] = line
          continue
        }
      }
      let mut spaces_added = 0
      let mut idx = 0
      let result = StringBuilder()
      for c in line {
        if c == '\t' {
          let offset = idx + spaces_added
          if offset % tab_size == 0 {
            spaces_added += tab_size - 1
            result.write_string(full_tab_space)
          } else {
            let spaces = tab_size - offset % tab_size
            if spaces != 1 {
              spaces_added += spaces - 1
            }
            result.write_string(@rb.repeat(" ", spaces))
          }
        } else {
          result.write_char(c)
        }
        idx += 1
      }
      lines[i] = result.to_string()
    }
  }
  if indent_size < 0 {
    return
  }
  let mut block_indent : Int? = None
  for line in lines {
    if line == "" {
      continue
    }
    let line_indent = line.length() - @rb.lstrip(line).length()
    if line_indent == 0 {
      block_indent = None
      break
    }
    match block_indent {
      Some(bi) if bi < line_indent => ()
      _ => block_indent = Some(line_indent)
    }
  }
  if indent_size == 0 {
    match block_indent {
      Some(bi) =>
        for i, line in lines {
          if line != "" {
            lines[i] = @rb.from(line, bi)
          }
        }
      None => ()
    }
  } else {
    let new_block_indent = @rb.repeat(" ", indent_size)
    for i, line in lines {
      if line != "" {
        lines[i] = match block_indent {
          Some(bi) => new_block_indent + @rb.from(line, bi)
          None => new_block_indent + line
        }
      }
    }
  }
}