///|
/// A recovered problem that does not prevent the rest of an RSS feed from
/// being used. `line` and `column` are one-indexed source locations. A blank
/// channel field points to its element; an absent one points to ``.
pub struct Diagnostic {
  kind : DiagnosticKind
  line : Int32
  column : Int32
} derive(Debug, Eq)

///|
/// The kind of recoverable feed data problem.
pub enum DiagnosticKind {
  MissingChannelField(String)
  MissingItemIdentifier(Int32)
  MissingItemContent(Int32)
  MissingFeedField(String)
  MissingEntryField(Int32, String)
  UnsupportedNestedMarkup(String)
  UnsupportedTextConstruct(String)
  UnsupportedAtomContent(String)
  MissingAlternateLink(Int32)
  MissingSummaryForExternalContent(Int32)
  MissingSummaryForBinaryContent(Int32)
  DuplicateAtomField(String)
  InvalidAuthor(String)
  InvalidCategory(String)
  InvalidAttachment(String)
  InvalidLanguage(String)
  InvalidFeedImage(String)
  InvalidRssField(String)
  UnnormalizedRssDate(String)
  InvalidAtomLink(String)
  InvalidAtomGeneratorUri(String)
  InvalidAtomId(String)
  InvalidAtomDate(String)
  InvalidAtomOrder(String)
  UnresolvedUri(String)
  InvalidJsonFeedField(String)
  InvalidJsonFeedItem(Int32, String)
  InvalidJsonFeedDate(Int32, String)
} derive(Debug, Eq)

///|
/// The feed syntax accepted by this library.
pub enum FeedFormat {
  Rss2
  Atom1
  JsonFeed1
} derive(Debug, Eq)

///|
/// How a text field was declared by its source format.
pub enum TextKind {
  Unspecified
  Text
  Html
  Xhtml
} derive(Debug, Eq)

///|
/// An attribute declared on the outer Atom XHTML div. Names are expanded
/// into namespace URI and local name so prefixes do not affect callers.
pub struct XhtmlAttribute {
  local_name : String
  namespace_uri : String?
  value : String
} derive(Debug, Eq)

///|
pub extend XhtmlAttribute with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend XhtmlAttribute with Eq::{not_equal, equal}

///|
/// An Atom XHTML fragment. `markup` omits the required outer XHTML div and
/// uses deterministic namespace-qualified XML serialization. `language` is
/// the effective xml:lang at that div; `base_references` retains the ordered
/// xml:base declarations from the feed/entry, text construct, and div. The
/// latter are raw references, not guessed absolute URLs. `container_attributes`
/// retains the discarded div's attributes in source order, including xml:lang
/// and xml:base; markup and attributes are unsanitized.
pub struct XhtmlFragment {
  markup : String
  language : String?
  base_references : Array[String]
  container_attributes : Array[XhtmlAttribute]
} derive(Debug, Eq)

///|
/// An XML media-type Atom body, serialized with stable namespace-qualified
/// names. Unlike XHTML, no wrapper element is removed. This is unsanitized.
pub struct XmlFragment {
  markup : String
  language : String?
  base_references : Array[String]
} derive(Debug, Eq)

///|
/// An Atom out-of-line body reference. `src` is the original IRI reference;
/// `url` is that reference after optional source resolution. `base_uri` is
/// present only when an absolute effective base is known. No fetch occurs.
pub struct AtomExternalContent {
  src : String
  url : String
  media_type : String?
  base_uri : String?
} derive(Debug, Eq)

///|
/// Detailed Atom body representation. `Bytes` contains exact decoded octets
/// on Wasm, JS, and Native. MIME spelling is kept.
pub enum AtomBody {
  InlineText(String, String)
  InlineHtml(String)
  InlineXhtml(XhtmlFragment)
  InlineXml(XmlFragment, String)
  InlineBinary(Bytes, String)
  External(AtomExternalContent)
} derive(Debug, Eq)

///|
pub extend XmlFragment with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend XmlFragment with Eq::{not_equal, equal}

///|
pub extend AtomExternalContent with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend AtomExternalContent with Eq::{not_equal, equal}

///|
pub extend AtomBody with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend AtomBody with Eq::{not_equal, equal}

///|
/// The source syntax that defines an author's fields.
pub enum AuthorSource {
  Rss2
  DublinCore
  Atom1
  JsonFeed
} derive(Debug, Eq)

///|
/// An RSS raw author email, Dublin Core creator name, Atom person construct,
/// or JSON Feed author. RSS uses `email`, Dublin Core uses `name`, and JSON
/// Feed maps its `url` to `uri`.
pub struct Author {
  source : AuthorSource
  name : String?
  uri : String?
  email : String?
  /// JSON Feed author avatar URL; not a feed or entry image.
  avatar : String?
} derive(Debug, Eq)

///|
/// A category whose optional metadata retains its source-specific meaning.
/// `domain` belongs only to RSS; `scheme` and `label` belong only to Atom.
/// JSON Feed tags use only `term`.
pub struct Category {
  source : FeedFormat
  term : String
  domain : String?
  scheme : String?
  label : String?
} derive(Debug, Eq)

///|
/// Metadata for a related resource. `size_in_bytes` is limited to the largest
/// integer exactly representable by all three backends and JSON consumers.
/// `title` is available from Atom links and JSON Feed; duration is supplied
/// by JSON Feed only.
pub struct Attachment {
  source : FeedFormat
  url : String
  media_type : String?
  size_in_bytes : Int64?
  title : String?
  duration_in_seconds : Double?
} derive(Debug, Eq)

///|
/// A feed-level visual identity. Roles are source-specific; in particular an
/// Atom logo and JSON favicon are not interchangeable.
pub enum FeedImageRole {
  RssImage
  AtomIcon
  AtomLogo
  JsonIcon
  JsonFavicon
} derive(Debug, Eq)

///|
/// URL and optional RSS image metadata. Dimensions are declared pixel values,
/// not inferred image dimensions; no image resource is downloaded.
pub struct FeedImage {
  source : FeedFormat
  role : FeedImageRole
  url : String
  title : String?
  link : String?
  description : String?
  width : Int32?
  height : Int32?
} derive(Debug, Eq)

///|
/// JSON Feed item images serve distinct reader presentation roles.
pub enum EntryImageRole {
  JsonFeatured
  JsonBanner
} derive(Debug, Eq)

///|
/// An item image hint, separate from downloadable attachments.
pub struct EntryImage {
  source : FeedFormat
  role : EntryImageRole
  url : String
} derive(Debug, Eq)

///|
/// JSON Feed hub declaration. Parsing does not subscribe or send notifications.
pub struct Hub {
  transport_type : String
  url : String
} derive(Debug, Eq)

///|
/// An Atom link, including relations beyond the convenience alternate link
/// and enclosure attachment fields. RSS channel Atom `self` extensions also
/// use this type. A missing rel is represented as alternate.
pub struct AtomLink {
  relation : String
  href : String
  media_type : String?
  hreflang : String?
  title : String?
  length : Int64?
} derive(Debug, Eq)

///|
/// Metadata retained from an Atom entry's original feed when it is copied
/// into another feed. Missing members remain absent: atom:source may carry
/// only some of its original feed's metadata.
pub struct AtomSource {
  identifier : String?
  title : String?
  title_kind : TextKind
  title_xhtml : XhtmlFragment?
  subtitle : String?
  subtitle_kind : TextKind
  subtitle_xhtml : XhtmlFragment?
  updated_at : String?
  rights : String?
  rights_kind : TextKind
  rights_xhtml : XhtmlFragment?
  generator : String?
  generator_uri : String?
  generator_version : String?
  authors : Array[Author]
  contributors : Array[Author]
  categories : Array[Category]
  link : String?
  links : Array[AtomLink]
  images : Array[FeedImage]
} derive(Debug, Eq)

///|
/// A normalized feed entry. Fields retain decoded source text without HTML
/// rendering or sanitization.
pub struct Entry {
  /// RSS: non-blank `guid`, otherwise non-blank `link`. Atom: entry `id`.
  /// JSON Feed: item `id` (a JSON number is retained as its JSON spelling).
  identifier : String?
  title : String?
  title_kind : TextKind
  /// Present only for a successfully captured Atom XHTML title.
  title_xhtml : XhtmlFragment?
  /// RSS item `link`, or a usable HTTP(S) permalink guid when link is absent.
  /// Other formats retain their own link mapping.
  link : String?
  /// JSON Feed `external_url` names an outside page, not the item permalink.
  external_url : String?
  /// Effective language for this entry when declared or inherited; no
  /// language is guessed from text or the local environment.
  language : String?
  /// RSS `description`, Atom `summary`, or JSON Feed `summary`.
  description : String?
  description_kind : TextKind
  /// Present only for a successfully captured Atom XHTML summary.
  description_xhtml : XhtmlFragment?
  /// RSS Content `encoded`, inline Atom `content`, or JSON Feed
  /// `content_html`/`content_text`. External Atom content is not fetched and
  /// therefore remains `None`.
  content : String?
  /// JSON Feed can supply both representations; `content` remains the
  /// established HTML-preferred convenience field.
  content_text : String?
  content_html : String?
  content_kind : TextKind
  /// Present only for a successfully captured inline Atom XHTML body.
  content_xhtml : XhtmlFragment?
  /// Raw Atom content `src`, or resolved against its scoped xml:base by
  /// `parse_with_source`. No external resource is fetched.
  content_source : String?
  /// Atom content `type` as declared (including media types); `None` when
  /// omitted or for other feed formats.
  content_media_type : String?
  /// Detailed Atom body; `None` for RSS/JSON Feed and failed Atom extraction.
  atom_body : AtomBody?
  atom_source : AtomSource?
  links : Array[AtomLink]
  /// RSS item comments/source and Atom rights retain their distinct roles.
  comments_url : String?
  source_url : String?
  /// RSS source channel's declared title; separate from this item's title.
  source_title : String?
  /// RSS guid: true when isPermaLink is omitted or true, false when false;
  /// None when there is no usable guid or for non-RSS entries.
  guid_is_permalink : Bool?
  rights : String?
  rights_kind : TextKind
  rights_xhtml : XhtmlFragment?
  authors : Array[Author]
  contributors : Array[Author]
  categories : Array[Category]
  attachments : Array[Attachment]
  images : Array[EntryImage]
  /// RSS `pubDate`, Atom `published`, or JSON Feed `date_published`.
  published_at : String?
  /// Atom `updated` or JSON Feed `date_modified`; RSS has no corresponding
  /// core field.
  updated_at : String?
} derive(Debug, Eq)

///|
/// RSS 2.0 cloud registration descriptor. Its values are metadata only.
pub struct RssCloud {
  domain : String
  port : Int32
  path : String
  register_procedure : String
  protocol : String
} derive(Debug, Eq)

///|
/// RSS 2.0 textInput form descriptor. The link is resolved only when a
/// document source URL is supplied.
pub struct RssTextInput {
  title : String
  description : String
  name : String
  link : String
} derive(Debug, Eq)

///|
/// A normalized feed and its entries, in document order.
pub struct Feed {
  format : FeedFormat
  /// Atom feed `id` or JSON Feed `feed_url`; RSS has no corresponding core
  /// field.
  identifier : String?
  title : String?
  title_kind : TextKind
  /// Present only for a successfully captured Atom XHTML feed title.
  title_xhtml : XhtmlFragment?
  link : String?
  /// Declared feed language, if present; spelling is preserved.
  language : String?
  /// RSS channel `description`, Atom `subtitle`, or JSON Feed `description`.
  description : String?
  description_kind : TextKind
  /// Present only for a successfully captured Atom XHTML subtitle.
  description_xhtml : XhtmlFragment?
  /// Atom feed updated, or RSS channel lastBuildDate, preserved verbatim.
  updated_at : String?
  /// RSS channel pubDate and ttl; absent on other formats.
  published_at : String?
  ttl_minutes : Int32?
  /// RSS channel advisory specification URL, retained as a reference.
  docs_url : String?
  /// RSS channel PICS rating, retained without interpretation.
  rating : String?
  /// RSS notification endpoint metadata; no registration is performed.
  cloud : RssCloud?
  /// RSS legacy search form metadata; no form is rendered or submitted.
  text_input : RssTextInput?
  /// RSS 2.0 advisory GMT hours (0–23) and weekday names in source order.
  /// An empty array means no usable hint; these do not schedule fetching.
  skip_hours : Array[Int32]
  skip_days : Array[String]
  /// RSS copyright / Atom rights, and RSS / Atom generator text.
  rights : String?
  rights_kind : TextKind
  rights_xhtml : XhtmlFragment?
  generator : String?
  generator_uri : String?
  generator_version : String?
  /// RSS contact addresses retain their distinct editorial and technical roles.
  managing_editor : String?
  webmaster : String?
  /// JSON Feed pagination is only a reference; the library never follows it.
  next_url : String?
  /// JSON Feed expired is tri-state: absent differs from explicit false.
  expired : Bool?
  hubs : Array[Hub]
  links : Array[AtomLink]
  authors : Array[Author]
  contributors : Array[Author]
  categories : Array[Category]
  images : Array[FeedImage]
  entries : Array[Entry]
  diagnostics : Array[Diagnostic]
} derive(Debug, Eq)

///|
/// A complete feed or a stand-alone Atom Entry Document. An entry document
/// has its own diagnostics and does not invent enclosing feed metadata.
pub enum ParsedDocument {
  FeedDocument(Feed)
  AtomEntryDocument(Entry, Array[Diagnostic])
} derive(Debug, Eq)

///|
fn with_entry_language(entry : Entry, language : String?) -> Entry {
  {
    identifier: entry.identifier,
    title: entry.title,
    title_kind: entry.title_kind,
    title_xhtml: entry.title_xhtml,
    link: entry.link,
    external_url: entry.external_url,
    language,
    description: entry.description,
    description_kind: entry.description_kind,
    description_xhtml: entry.description_xhtml,
    content: entry.content,
    content_text: entry.content_text,
    content_html: entry.content_html,
    content_kind: entry.content_kind,
    content_xhtml: entry.content_xhtml,
    content_source: entry.content_source,
    content_media_type: entry.content_media_type,
    atom_body: entry.atom_body,
    atom_source: entry.atom_source,
    links: entry.links,
    comments_url: entry.comments_url,
    source_url: entry.source_url,
    source_title: entry.source_title,
    guid_is_permalink: entry.guid_is_permalink,
    rights: entry.rights,
    rights_kind: entry.rights_kind,
    rights_xhtml: entry.rights_xhtml,
    authors: entry.authors,
    contributors: entry.contributors,
    categories: entry.categories,
    attachments: entry.attachments,
    images: entry.images,
    published_at: entry.published_at,
    updated_at: entry.updated_at,
  }
}

///|
/// A fatal error that prevents producing a feed.
pub enum ParseError {
  InvalidXml(String, Int32, Int32)
  UnsupportedRoot(String, String?)
  UnsupportedRssVersion(String?)
  MissingChannel
  InvalidRssStructure(String)
  InvalidAtomStructure(String)
  InvalidJson(String, Int32, Int32)
  InvalidJsonFeed(String)
  UnsupportedJsonFeedVersion(String?)
  InvalidSourceUrl(String)
} derive(Debug, Eq)

///|
pub extend Diagnostic with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend Diagnostic with Eq::{not_equal, equal}

///|
pub extend DiagnosticKind with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend DiagnosticKind with Eq::{not_equal, equal}

///|
pub extend FeedFormat with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend FeedFormat with Eq::{not_equal, equal}

///|
pub extend TextKind with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend TextKind with Eq::{not_equal, equal}

///|
pub extend XhtmlFragment with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend XhtmlFragment with Eq::{not_equal, equal}

///|
pub extend AuthorSource with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend AuthorSource with Eq::{not_equal, equal}

///|
pub extend Author with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend Author with Eq::{not_equal, equal}

///|
pub extend Category with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend Category with Eq::{not_equal, equal}

///|
pub extend Attachment with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend Attachment with Eq::{not_equal, equal}

///|
pub extend FeedImageRole with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend FeedImageRole with Eq::{not_equal, equal}

///|
pub extend FeedImage with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend FeedImage with Eq::{not_equal, equal}

///|
pub extend EntryImageRole with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend EntryImageRole with Eq::{not_equal, equal}

///|
pub extend EntryImage with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend EntryImage with Eq::{not_equal, equal}

///|
pub extend Hub with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend Hub with Eq::{not_equal, equal}

///|
pub extend RssCloud with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend RssCloud with Eq::{not_equal, equal}

///|
pub extend RssTextInput with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend RssTextInput with Eq::{not_equal, equal}

///|
pub extend AtomLink with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend AtomLink with Eq::{not_equal, equal}

///|
pub extend AtomSource with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend AtomSource with Eq::{not_equal, equal}

///|
pub extend Entry with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend Entry with Eq::{not_equal, equal}

///|
pub extend Feed with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend Feed with Eq::{not_equal, equal}

///|
pub extend ParsedDocument with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend ParsedDocument with Eq::{not_equal, equal}

///|
pub extend ParseError with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend ParseError with Eq::{not_equal, equal}

///|
fn is_rss_name(name : @xml.XmlName, expected : String) -> Bool {
  name.local_name == expected && name.namespace_uri is None
}

///|
// A guid may be an arbitrary identifier even when isPermaLink defaults to
// true. Project it as a link only when it is a usable web URL.
fn rss_item_link(link : String?, guid : String?, is_permalink : Bool?) -> String? {
  if link is Some(_) {
    return link
  }
  guard is_permalink is Some(true) else { return None }
  guard guid is Some(value) else { return None }
  guard uri_parts(value) is Some(parts) else { return None }
  guard parts.scheme is Some(scheme) else { return None }
  if (scheme.to_lower() == "http" || scheme.to_lower() == "https") &&
    uri_valid_absolute(parts) {
    Some(value)
  } else {
    None
  }
}

///|
fn is_atom_name(name : @xml.XmlName, expected : String) -> Bool {
  name.local_name == expected &&
  name.namespace_uri == Some("http://www.w3.org/2005/Atom")
}

///|
fn is_rss_content_encoded(name : @xml.XmlName) -> Bool {
  name.local_name == "encoded" &&
  name.namespace_uri == Some("http://purl.org/rss/1.0/modules/content/")
}

///|
fn is_rss_dc_creator(name : @xml.XmlName) -> Bool {
  name.local_name == "creator" &&
  name.namespace_uri == Some("http://purl.org/dc/elements/1.1/")
}

///|
fn attribute_value(
  element : @xml.NamespaceElement,
  expected : String,
) -> String? {
  for attribute in element.attributes {
    if attribute.name.local_name == expected &&
      attribute.name.namespace_uri is None {
      return Some(attribute.value)
    }
  }
  None
}

///|
fn xml_language_attribute(element : @xml.NamespaceElement) -> String? {
  for attribute in element.attributes {
    if attribute.name.local_name == "lang" &&
      attribute.name.namespace_uri ==
      Some("http://www.w3.org/XML/1998/namespace") {
      return Some(attribute.value)
    }
  }
  None
}

///|
fn non_blank_text(text : String) -> String? {
  if text.trim().is_empty() {
    None
  } else {
    Some(text)
  }
}

///|
// A failed normalization is diagnostic, not a claim that every legal RFC 822
// spelling is rejected by RSS. Preserve the source text for the caller.
fn rss_date_value(
  raw : String,
  field : String,
  line : Int32,
  column : Int32,
  diagnostics : Array[Diagnostic],
) -> String? {
  match normalize_date(Rss2, Some(raw)) {
    Ok(_) => ()
    Err(_) => diagnostics.push({
      kind: UnnormalizedRssDate(field),
      line,
      column,
    })
  }
  non_blank_text(raw)
}

///|
/// Byte counts are non-negative decimal integers within JS's exact integer
/// range. Validation happens before arithmetic, so malformed inputs cannot
/// wrap or be rounded by the JSON DOM's Double representation.
fn attachment_size(text : String) -> Int64? {
  let text = text.trim()
  if text.is_empty() {
    return None
  }
  let mut value = 0L
  for character in text {
    let digit = character.to_int() - 48
    if digit < 0 ||
      digit > 9 ||
      value > (9007199254740991L - digit.to_int64()) / 10L {
      return None
    }
    value = value * 10L + digit.to_int64()
  }
  Some(value)
}

///|
fn rss_ttl_minutes(raw : String) -> Int32? {
  let raw = raw.trim()
  if raw.is_empty() {
    return None
  }
  let mut minutes = 0
  for character in raw {
    let digit = character.to_int() - 48
    if digit < 0 || digit > 9 || minutes > (2147483647 - digit) / 10 {
      return None
    }
    minutes = minutes * 10 + digit
  }
  Some(minutes)
}

///|
fn rss_skip_hour(
  raw : String,
  count : Int32,
  hours : Array[Int32],
  line : Int32,
  column : Int32,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match rss_ttl_minutes(raw) {
    Some(hour) if hour <= 23 && count <= 24 => hours.push(hour)
    _ =>
      diagnostics.push({
        kind: InvalidRssField("skipHours hour"),
        line,
        column,
      })
  }
}

///|
fn rss_skip_day(
  raw : String,
  count : Int32,
  days : Array[String],
  line : Int32,
  column : Int32,
  diagnostics : Array[Diagnostic],
) -> Unit {
  let day = raw.trim()
  if count <= 7 &&
    (
      day == "Monday" ||
      day == "Tuesday" ||
      day == "Wednesday" ||
      day == "Thursday" ||
      day == "Friday" ||
      day == "Saturday" ||
      day == "Sunday"
    ) {
    days.push(day.to_owned())
  } else {
    diagnostics.push({ kind: InvalidRssField("skipDays day"), line, column, })
  }
}

///|
fn xml_attachment(
  element : @xml.NamespaceElement,
  source : FeedFormat,
  url_name : String,
  line : Int32,
  column : Int32,
  diagnostics : Array[Diagnostic],
) -> Attachment? {
  let url = match attribute_value(element, url_name) {
    Some(value) if source == Atom1 && value.is_empty() => Some(value)
    Some(value) => non_blank_text(value)
    None => None
  }
  let media_type = match attribute_value(element, "type") {
    Some(value) if source == Atom1 => Some(value)
    Some(value) => non_blank_text(value)
    None => None
  }
  if source == Rss2 {
    match media_type {
      Some(raw) if atom_mime_essence(raw) is None =>
        diagnostics.push({
          kind: InvalidAttachment("invalid RSS enclosure type"),
          line,
          column,
        })
      _ => ()
    }
  }
  if source == Atom1 {
    match attribute_value(element, "type") {
      Some(raw) if atom_mime_essence(raw) is None =>
        diagnostics.push({
          kind: InvalidAttachment("invalid Atom enclosure type"),
          line,
          column,
        })
      _ => ()
    }
  }
  let length = attribute_value(element, "length")
  let size = match length {
    Some(raw) =>
      match attachment_size(raw) {
        Some(value) => Some(value)
        None => {
          diagnostics.push({
            kind: InvalidAttachment("invalid length"),
            line,
            column,
          })
          None
        }
      }
    None => None
  }
  if url is None ||
    (source == Rss2 && (media_type is None || length is None || size is None)) {
    diagnostics.push({
      kind: InvalidAttachment(
        if source == Rss2 {
          "missing required RSS enclosure attribute"
        } else {
          "missing Atom enclosure href"
        },
      ),
      line,
      column,
    })
    return None
  }
  Some({
    source,
    url: url.unwrap(),
    media_type,
    size_in_bytes: size,
    title: if source == Atom1 {
      match attribute_value(element, "title") {
        Some(value) => Some(value)
        None => None
      }
    } else {
      None
    },
    duration_in_seconds: None,
  })
}

///|
fn missing_field_location(
  field_location : (Int32, Int32)?,
  channel_line : Int32,
  channel_column : Int32,
) -> (Int32, Int32) {
  field_location.unwrap_or((channel_line, channel_column))
}

///|
fn add_missing_channel_diagnostics(
  title : String?,
  link : String?,
  description : String?,
  title_location : (Int32, Int32)?,
  link_location : (Int32, Int32)?,
  description_location : (Int32, Int32)?,
  diagnostics : Array[Diagnostic],
  channel_line : Int32,
  channel_column : Int32,
) -> Unit {
  if title is None {
    let (line, column) = missing_field_location(
      title_location, channel_line, channel_column,
    )
    diagnostics.push({ kind: MissingChannelField("title"), line, column, })
  }
  if link is None {
    let (line, column) = missing_field_location(
      link_location, channel_line, channel_column,
    )
    diagnostics.push({ kind: MissingChannelField("link"), line, column, })
  }
  if description is None {
    let (line, column) = missing_field_location(
      description_location, channel_line, channel_column,
    )
    diagnostics.push({ kind: MissingChannelField("description"), line, column, })
  }
}

///|
fn rss_core_singleton(
  element : @xml.NamespaceElement,
  owner : String,
  seen : Array[String],
  line : Int32,
  column : Int32,
  diagnostics : Array[Diagnostic],
) -> Unit {
  let field = element.name.local_name
  let unique = if owner == "channel" {
    field is ("title" | "link" | "description" | "language" | "copyright" |
      "generator" | "managingEditor" | "webMaster" | "pubDate" |
      "lastBuildDate" | "ttl")
  } else {
    field is ("guid" | "title" | "link" | "description" | "pubDate" |
      "author" | "comments" | "source")
  }
  if !unique || !is_rss_name(element.name, field) {
    return
  }
  if seen.any(fn(previous) { previous == field }) {
    diagnostics.push({
      kind: InvalidRssField(owner + " " + field + " duplicate"),
      line,
      column,
    })
  }
  seen.push(field)
}

///|
/// Parse one RSS 2.0 XML document.
///
/// XML failures and unsupported document shapes return `Err`. Missing channel
/// fields and incomplete items return `Ok`, with `None` fields and diagnostics.
fn parse_rss(input : String) -> Result[Feed, ParseError] {
  let reader = @xml.NamespaceReader::from_string(input)
  try {
    let mut depth = 0
    let mut root_seen = false
    let mut channel_seen = false
    let mut channel_depth = 0
    let mut channel_line = 1
    let mut channel_column = 1
    let mut channel_title : String? = None
    let mut channel_link : String? = None
    let mut channel_description : String? = None
    let mut channel_language : String? = None
    let mut channel_rights : String? = None
    let mut channel_generator : String? = None
    let mut channel_managing_editor : String? = None
    let mut channel_webmaster : String? = None
    let mut channel_published : String? = None
    let mut channel_updated : String? = None
    let mut channel_ttl : Int32? = None
    let channel_skip_hours : Array[Int32] = []
    let channel_skip_days : Array[String] = []
    let mut skip_kind : String? = None
    let mut skip_depth = 0
    let mut skip_child_count = 0
    let mut skip_ignored = false
    let mut skip_hours_seen = false
    let mut skip_days_seen = false
    let mut channel_title_location : (Int32, Int32)? = None
    let mut channel_link_location : (Int32, Int32)? = None
    let mut channel_description_location : (Int32, Int32)? = None
    let channel_categories : Array[Category] = []
    let channel_links : Array[AtomLink] = []
    let channel_core_seen : Array[String] = []
    let entries : Array[Entry] = []
    let diagnostics : Array[Diagnostic] = []

    let mut item_open = false
    let mut item_depth = 0
    let mut item_line = 1
    let mut item_column = 1
    let mut item_guid : String? = None
    let mut item_guid_is_permalink : Bool? = None
    let mut item_title : String? = None
    let mut item_link : String? = None
    let mut item_description : String? = None
    let mut item_content : String? = None
    let mut item_published_at : String? = None
    let mut item_comments_url : String? = None
    let mut item_source_url : String? = None
    let mut item_source_title : String? = None
    let mut item_authors : Array[Author] = []
    let mut item_categories : Array[Category] = []
    let mut item_attachments : Array[Attachment] = []
    let mut item_core_seen : Array[String] = []

    let mut capture_field : String? = None
    let mut capture_depth = 0
    let mut captured_text = ""
    let mut capture_has_nested_markup = false
    let mut capture_line = 1
    let mut capture_column = 1
    let mut capture_category_domain : String? = None
    let mut capture_guid_is_permalink : Bool? = None

    for ;; {
      let event = reader.read_event()
      match event.kind {
        @xml.Start(element) => {
          if item_open && depth == item_depth {
            rss_core_singleton(
              element, "item", item_core_seen, event.span.start.line,
              event.span.start.column, diagnostics,
            )
          } else if channel_seen && depth == channel_depth {
            rss_core_singleton(
              element, "channel", channel_core_seen, event.span.start.line,
              event.span.start.column, diagnostics,
            )
          }
          if capture_field is Some(_) && depth >= capture_depth {
            capture_has_nested_markup = true
          }
          if !root_seen {
            root_seen = true
            if !is_rss_name(element.name, "rss") {
              return Err(
                UnsupportedRoot(
                  element.name.qualified_name,
                  element.name.namespace_uri,
                ),
              )
            }
            let version = attribute_value(element, "version")
            if version != Some("2.0") {
              return Err(UnsupportedRssVersion(version))
            }
          } else if depth == 1 && is_rss_name(element.name, "channel") {
            if channel_seen {
              return Err(InvalidRssStructure("multiple channel elements"))
            }
            channel_seen = true
            channel_depth = depth + 1
            channel_line = event.span.start.line
            channel_column = event.span.start.column
          } else if channel_seen &&
            depth == channel_depth &&
            atom_element_relation_is(element, "self") &&
            is_atom_name(element.name, "link") {
            match atom_link(
              element, event.span.start.line, event.span.start.column,
              diagnostics,
            ) {
              Some(link) => channel_links.push(link)
              None => ()
            }
          } else if channel_seen &&
            depth == channel_depth &&
            is_rss_name(element.name, "item") {
            item_open = true
            item_depth = depth + 1
            item_line = event.span.start.line
            item_column = event.span.start.column
            item_guid = None
            item_guid_is_permalink = None
            item_title = None
            item_link = None
            item_description = None
            item_content = None
            item_published_at = None
            item_comments_url = None
            item_source_url = None
            item_source_title = None
            item_authors = []
            item_categories = []
            item_attachments = []
            item_core_seen = []
          } else if item_open &&
            depth == item_depth &&
            is_rss_name(element.name, "enclosure") {
            match
              xml_attachment(
                element,
                Rss2,
                "url",
                event.span.start.line,
                event.span.start.column,
                diagnostics,
              ) {
              Some(attachment) => item_attachments.push(attachment)
              None => ()
            }
          } else if channel_seen &&
            depth == channel_depth &&
            !item_open &&
            (
              is_rss_name(element.name, "skipHours") ||
              is_rss_name(element.name, "skipDays")
            ) {
            let kind = element.name.local_name
            skip_ignored = if kind == "skipHours" {
              let seen = skip_hours_seen
              skip_hours_seen = true
              seen
            } else {
              let seen = skip_days_seen
              skip_days_seen = true
              seen
            }
            if skip_ignored {
              diagnostics.push({
                kind: InvalidRssField(kind + " duplicate"),
                line: event.span.start.line,
                column: event.span.start.column,
              })
            }
            skip_kind = Some(kind)
            skip_depth = depth + 1
            skip_child_count = 0
          } else if skip_kind is Some(kind) &&
            !skip_ignored &&
            depth == skip_depth &&
            (
              (kind == "skipHours" && is_rss_name(element.name, "hour")) ||
              (kind == "skipDays" && is_rss_name(element.name, "day"))
            ) {
            skip_child_count = skip_child_count + 1
            capture_field = Some(element.name.local_name)
            capture_depth = depth + 1
            captured_text = ""
            capture_has_nested_markup = false
            capture_line = event.span.start.line
            capture_column = event.span.start.column
          } else if capture_field is None &&
            channel_seen &&
            depth == channel_depth &&
            !item_open &&
            is_rss_name(element.name, "category") {
            capture_field = Some("category")
            capture_depth = depth + 1
            captured_text = ""
            capture_has_nested_markup = false
            capture_line = event.span.start.line
            capture_column = event.span.start.column
            capture_category_domain = attribute_value(element, "domain")
          } else if capture_field is None &&
            channel_seen &&
            depth == channel_depth &&
            !item_open &&
            (
              is_rss_name(element.name, "title") ||
              is_rss_name(element.name, "link") ||
              is_rss_name(element.name, "description") ||
              is_rss_name(element.name, "language") ||
              is_rss_name(element.name, "copyright") ||
              is_rss_name(element.name, "generator") ||
              is_rss_name(element.name, "managingEditor") ||
              is_rss_name(element.name, "webMaster") ||
              is_rss_name(element.name, "pubDate") ||
              is_rss_name(element.name, "lastBuildDate") ||
              is_rss_name(element.name, "ttl")
            ) {
            capture_field = Some(element.name.local_name)
            capture_depth = depth + 1
            captured_text = ""
            capture_has_nested_markup = false
            capture_category_domain = None
            capture_line = event.span.start.line
            capture_column = event.span.start.column
            match element.name.local_name {
              "title" =>
                channel_title_location = Some(
                  (event.span.start.line, event.span.start.column),
                )
              "link" =>
                channel_link_location = Some(
                  (event.span.start.line, event.span.start.column),
                )
              "description" =>
                channel_description_location = Some(
                  (event.span.start.line, event.span.start.column),
                )
              _ => ()
            }
          } else if capture_field is None &&
            item_open &&
            depth == item_depth &&
            (
              is_rss_name(element.name, "guid") ||
              is_rss_name(element.name, "title") ||
              is_rss_name(element.name, "link") ||
              is_rss_name(element.name, "description") ||
              is_rss_name(element.name, "pubDate") ||
              is_rss_name(element.name, "author") ||
              is_rss_name(element.name, "category") ||
              is_rss_name(element.name, "comments") ||
              is_rss_name(element.name, "source") ||
              is_rss_dc_creator(element.name) ||
              is_rss_content_encoded(element.name)
            ) {
            capture_field = Some(
              if is_rss_dc_creator(element.name) {
                "dc:creator"
              } else if is_rss_content_encoded(element.name) {
                "content:encoded"
              } else {
                element.name.local_name
              },
            )
            capture_depth = depth + 1
            captured_text = ""
            capture_has_nested_markup = false
            capture_category_domain = if is_rss_name(element.name, "category") {
              attribute_value(element, "domain")
            } else {
              None
            }
            if is_rss_name(element.name, "guid") {
              capture_guid_is_permalink = match
                attribute_value(element, "isPermaLink") {
                None | Some("true") => Some(true)
                Some("false") => Some(false)
                Some(_) => {
                  diagnostics.push({
                    kind: InvalidRssField("guid isPermaLink"),
                    line: event.span.start.line,
                    column: event.span.start.column,
                  })
                  None
                }
              }
            }
            if is_rss_name(element.name, "source") {
              item_source_url = match attribute_value(element, "url") {
                Some(value) => non_blank_text(value)
                None => None
              }
              if item_source_url is None {
                diagnostics.push({
                  kind: InvalidRssField("source url"),
                  line: event.span.start.line,
                  column: event.span.start.column,
                })
              }
            }
            capture_line = event.span.start.line
            capture_column = event.span.start.column
          }
          depth = depth + 1
        }
        @xml.Empty(element) => {
          if item_open && depth == item_depth {
            rss_core_singleton(
              element, "item", item_core_seen, event.span.start.line,
              event.span.start.column, diagnostics,
            )
          } else if channel_seen && depth == channel_depth {
            rss_core_singleton(
              element, "channel", channel_core_seen, event.span.start.line,
              event.span.start.column, diagnostics,
            )
          }
          if capture_field is Some(_) && depth >= capture_depth {
            capture_has_nested_markup = true
          } else if !root_seen {
            root_seen = true
            if !is_rss_name(element.name, "rss") {
              return Err(
                UnsupportedRoot(
                  element.name.qualified_name,
                  element.name.namespace_uri,
                ),
              )
            }
            let version = attribute_value(element, "version")
            if version != Some("2.0") {
              return Err(UnsupportedRssVersion(version))
            }
          } else if depth == 1 && is_rss_name(element.name, "channel") {
            if channel_seen {
              return Err(InvalidRssStructure("multiple channel elements"))
            }
            channel_seen = true
            channel_depth = depth + 1
            channel_line = event.span.start.line
            channel_column = event.span.start.column
          } else if channel_seen &&
            depth == channel_depth &&
            atom_element_relation_is(element, "self") &&
            is_atom_name(element.name, "link") {
            match atom_link(
              element, event.span.start.line, event.span.start.column,
              diagnostics,
            ) {
              Some(link) => channel_links.push(link)
              None => ()
            }
          } else if channel_seen &&
            depth == channel_depth &&
            is_rss_name(element.name, "item") {
            let item_index = entries.length()
            entries.push({
              identifier: None,
              title: None,
              title_kind: Unspecified,
              title_xhtml: None,
              link: None,
              external_url: None,
              language: channel_language,
              description: None,
              description_kind: Unspecified,
              description_xhtml: None,
              content: None,
              content_text: None,
              content_html: None,
              content_kind: Unspecified,
              content_xhtml: None,
              content_source: None,
              content_media_type: None,
              atom_body: None,
              atom_source: None,
              links: [],
              comments_url: None,
              source_url: None,
              source_title: None,
              guid_is_permalink: None,
              rights: None,
              rights_kind: Unspecified,
              rights_xhtml: None,
              authors: [],
              contributors: [],
              categories: [],
              attachments: [],
              images: [],
              published_at: None,
              updated_at: None,
            })
            diagnostics.push({
              kind: MissingItemContent(item_index),
              line: event.span.start.line,
              column: event.span.start.column,
            })
          } else if channel_seen &&
            depth == channel_depth &&
            !item_open &&
            is_rss_name(element.name, "category") {
            diagnostics.push({
              kind: InvalidCategory("rss category missing term"),
              line: event.span.start.line,
              column: event.span.start.column,
            })
          } else if channel_seen &&
            depth == channel_depth &&
            !item_open &&
            (
              is_rss_name(element.name, "skipHours") ||
              is_rss_name(element.name, "skipDays")
            ) {
            let kind = element.name.local_name
            let seen = if kind == "skipHours" {
              let seen = skip_hours_seen
              skip_hours_seen = true
              seen
            } else {
              let seen = skip_days_seen
              skip_days_seen = true
              seen
            }
            if seen {
              diagnostics.push({
                kind: InvalidRssField(kind + " duplicate"),
                line: event.span.start.line,
                column: event.span.start.column,
              })
            }
          } else if skip_kind is Some(kind) &&
            !skip_ignored &&
            depth == skip_depth &&
            (
              (kind == "skipHours" && is_rss_name(element.name, "hour")) ||
              (kind == "skipDays" && is_rss_name(element.name, "day"))
            ) {
            skip_child_count = skip_child_count + 1
            if kind == "skipHours" {
              rss_skip_hour(
                "",
                skip_child_count,
                channel_skip_hours,
                event.span.start.line,
                event.span.start.column,
                diagnostics,
              )
            } else {
              rss_skip_day(
                "",
                skip_child_count,
                channel_skip_days,
                event.span.start.line,
                event.span.start.column,
                diagnostics,
              )
            }
          } else if channel_seen &&
            depth == channel_depth &&
            !item_open &&
            (
              is_rss_name(element.name, "title") ||
              is_rss_name(element.name, "link") ||
              is_rss_name(element.name, "description") ||
              is_rss_name(element.name, "language") ||
              is_rss_name(element.name, "copyright") ||
              is_rss_name(element.name, "generator") ||
              is_rss_name(element.name, "managingEditor") ||
              is_rss_name(element.name, "webMaster") ||
              is_rss_name(element.name, "pubDate") ||
              is_rss_name(element.name, "lastBuildDate") ||
              is_rss_name(element.name, "ttl")
            ) {
            match element.name.local_name {
              "title" => {
                channel_title = None
                channel_title_location = Some(
                  (event.span.start.line, event.span.start.column),
                )
              }
              "link" => {
                channel_link = None
                channel_link_location = Some(
                  (event.span.start.line, event.span.start.column),
                )
              }
              "description" => {
                channel_description = None
                channel_description_location = Some(
                  (event.span.start.line, event.span.start.column),
                )
              }
              "language" => {
                channel_language = None
                diagnostics.push({
                  kind: InvalidLanguage("rss channel language blank"),
                  line: event.span.start.line,
                  column: event.span.start.column,
                })
              }
              "copyright" => channel_rights = None
              "generator" => channel_generator = None
              "managingEditor" => channel_managing_editor = None
              "webMaster" => channel_webmaster = None
              "pubDate" => channel_published = rss_date_value(
                "", "channel pubDate", event.span.start.line,
                event.span.start.column, diagnostics,
              )
              "lastBuildDate" => channel_updated = rss_date_value(
                "", "channel lastBuildDate", event.span.start.line,
                event.span.start.column, diagnostics,
              )
              _ => {
                channel_ttl = None
                diagnostics.push({
                  kind: InvalidRssField("ttl"),
                  line: event.span.start.line,
                  column: event.span.start.column,
                })
              }
            }
          } else if item_open &&
            depth == item_depth &&
            is_rss_name(element.name, "enclosure") {
            match
              xml_attachment(
                element,
                Rss2,
                "url",
                event.span.start.line,
                event.span.start.column,
                diagnostics,
              ) {
              Some(attachment) => item_attachments.push(attachment)
              None => ()
            }
          } else if item_open &&
            depth == item_depth &&
            (
              is_rss_name(element.name, "guid") ||
              is_rss_name(element.name, "title") ||
              is_rss_name(element.name, "link") ||
              is_rss_name(element.name, "description") ||
              is_rss_name(element.name, "pubDate") ||
              is_rss_name(element.name, "author") ||
              is_rss_name(element.name, "category") ||
              is_rss_name(element.name, "comments") ||
              is_rss_name(element.name, "source") ||
              is_rss_dc_creator(element.name) ||
              is_rss_content_encoded(element.name)
            ) {
            if is_rss_dc_creator(element.name) {
              diagnostics.push({
                kind: InvalidAuthor("dublin core creator missing name"),
                line: event.span.start.line,
                column: event.span.start.column,
              })
            } else if is_rss_content_encoded(element.name) {
              item_content = Some("")
            } else if is_rss_name(element.name, "author") {
              diagnostics.push({
                kind: InvalidAuthor("rss author missing email"),
                line: event.span.start.line,
                column: event.span.start.column,
              })
            } else if is_rss_name(element.name, "category") {
              diagnostics.push({
                kind: InvalidCategory("rss category missing term"),
                line: event.span.start.line,
                column: event.span.start.column,
              })
            } else if is_rss_name(element.name, "source") {
              item_source_url = match attribute_value(element, "url") {
                Some(value) => non_blank_text(value)
                None => None
              }
              diagnostics.push({
                kind: InvalidRssField("source title"),
                line: event.span.start.line,
                column: event.span.start.column,
              })
              if item_source_url is None {
                diagnostics.push({
                  kind: InvalidRssField("source url"),
                  line: event.span.start.line,
                  column: event.span.start.column,
                })
              }
            } else if is_rss_name(element.name, "comments") {
              item_comments_url = None
            } else {
              match element.name.local_name {
                "guid" => {
                  item_guid = None
                  item_guid_is_permalink = None
                }
                "title" => item_title = None
                "link" => item_link = None
                "description" => item_description = None
                "pubDate" => item_published_at = rss_date_value(
                  "", "item pubDate", event.span.start.line,
                  event.span.start.column, diagnostics,
                )
                _ => ()
              }
            }
          }
        }
        @xml.Text(text) | @xml.CData(text) =>
          if capture_field is Some(_) {
            captured_text = captured_text + text
          }
        @xml.End(name) => {
          if capture_field is Some(field) &&
            capture_depth == depth &&
            (
              is_rss_name(name, field) ||
              (field == "content:encoded" && is_rss_content_encoded(name)) ||
              (field == "dc:creator" && is_rss_dc_creator(name))
            ) {
            if capture_has_nested_markup {
              diagnostics.push({
                kind: UnsupportedNestedMarkup("rss " + field),
                line: capture_line,
                column: capture_column,
              })
              if field == "hour" || field == "day" {
                ()
              } else if item_open {
                match field {
                  "guid" => {
                    item_guid = None
                    item_guid_is_permalink = None
                  }
                  "title" => item_title = None
                  "link" => item_link = None
                  "description" => item_description = None
                  "content:encoded" => item_content = None
                  "author" => ()
                  "dc:creator" => ()
                  "category" => ()
                  "comments" => item_comments_url = None
                  "source" => item_source_title = None
                  _ => item_published_at = None
                }
              } else {
                match field {
                  "title" => channel_title = None
                  "link" => channel_link = None
                  "description" => channel_description = None
                  "copyright" => channel_rights = None
                  "generator" => channel_generator = None
                  "managingEditor" => channel_managing_editor = None
                  "webMaster" => channel_webmaster = None
                  "pubDate" => channel_published = None
                  "lastBuildDate" => channel_updated = None
                  "ttl" => channel_ttl = None
                  _ => channel_language = None
                }
              }
            } else if field == "hour" {
              rss_skip_hour(
                captured_text, skip_child_count, channel_skip_hours, capture_line,
                capture_column, diagnostics,
              )
            } else if field == "day" {
              rss_skip_day(
                captured_text, skip_child_count, channel_skip_days, capture_line,
                capture_column, diagnostics,
              )
            } else if item_open {
              match field {
                "guid" => {
                  item_guid = non_blank_text(captured_text)
                  item_guid_is_permalink = if item_guid is Some(_) {
                    capture_guid_is_permalink
                  } else {
                    None
                  }
                }
                "source" => {
                  item_source_title = non_blank_text(captured_text)
                  if item_source_title is None {
                    diagnostics.push({
                      kind: InvalidRssField("source title"),
                      line: capture_line,
                      column: capture_column,
                    })
                  }
                }
                "title" => item_title = non_blank_text(captured_text)
                "link" => item_link = non_blank_text(captured_text)
                "description" =>
                  item_description = non_blank_text(captured_text)
                "content:encoded" => item_content = Some(captured_text)
                "comments" => item_comments_url = non_blank_text(captured_text)
                "author" =>
                  match non_blank_text(captured_text) {
                    Some(email) =>
                      item_authors.push({
                        source: Rss2,
                        name: None,
                        uri: None,
                        email: Some(email),
                        avatar: None,
                      })
                    None =>
                      diagnostics.push({
                        kind: InvalidAuthor("rss author missing email"),
                        line: capture_line,
                        column: capture_column,
                      })
                  }
                "dc:creator" =>
                  match non_blank_text(captured_text) {
                    Some(name) =>
                      item_authors.push({
                        source: DublinCore,
                        name: Some(name),
                        uri: None,
                        email: None,
                        avatar: None,
                      })
                    None =>
                      diagnostics.push({
                        kind: InvalidAuthor("dublin core creator missing name"),
                        line: capture_line,
                        column: capture_column,
                      })
                  }
                "category" =>
                  match non_blank_text(captured_text) {
                    Some(term) =>
                      item_categories.push({
                        source: Rss2,
                        term,
                        domain: capture_category_domain,
                        scheme: None,
                        label: None,
                      })
                    None =>
                      diagnostics.push({
                        kind: InvalidCategory("rss category missing term"),
                        line: capture_line,
                        column: capture_column,
                      })
                  }
                "pubDate" => item_published_at = rss_date_value(
                  captured_text, "item pubDate", capture_line,
                  capture_column, diagnostics,
                )
                _ => ()
              }
            } else {
              match field {
                "title" => channel_title = non_blank_text(captured_text)
                "link" => channel_link = non_blank_text(captured_text)
                "category" =>
                  match non_blank_text(captured_text) {
                    Some(term) =>
                      channel_categories.push({
                        source: Rss2,
                        term,
                        domain: capture_category_domain,
                        scheme: None,
                        label: None,
                      })
                    None =>
                      diagnostics.push({
                        kind: InvalidCategory("rss category missing term"),
                        line: capture_line,
                        column: capture_column,
                      })
                  }
                "description" =>
                  channel_description = non_blank_text(captured_text)
                "copyright" => channel_rights = non_blank_text(captured_text)
                "generator" => channel_generator = non_blank_text(captured_text)
                "managingEditor" =>
                  channel_managing_editor = non_blank_text(captured_text)
                "webMaster" => channel_webmaster = non_blank_text(captured_text)
                "pubDate" => channel_published = rss_date_value(
                  captured_text, "channel pubDate", capture_line,
                  capture_column, diagnostics,
                )
                "lastBuildDate" => channel_updated = rss_date_value(
                  captured_text, "channel lastBuildDate", capture_line,
                  capture_column, diagnostics,
                )
                "ttl" => {
                  channel_ttl = rss_ttl_minutes(captured_text)
                  if channel_ttl is None {
                    diagnostics.push({
                      kind: InvalidRssField("ttl"),
                      line: capture_line,
                      column: capture_column,
                    })
                  }
                }
                _ => {
                  channel_language = non_blank_text(captured_text)
                  if channel_language is None {
                    diagnostics.push({
                      kind: InvalidLanguage("rss channel language blank"),
                      line: capture_line,
                      column: capture_column,
                    })
                  }
                }
              }
            }
            capture_field = None
            captured_text = ""
            capture_has_nested_markup = false
            capture_category_domain = None
            capture_guid_is_permalink = None
          }
          if skip_kind is Some(kind) &&
            depth == skip_depth &&
            is_rss_name(name, kind) {
            skip_kind = None
            skip_ignored = false
          }
          if item_open && depth == item_depth && is_rss_name(name, "item") {
            let item_index = entries.length()
            let identifier = match item_guid {
              Some(guid) => Some(guid)
              None => item_link
            }
            entries.push({
              identifier,
              title: item_title,
              title_kind: Unspecified,
              title_xhtml: None,
              link: rss_item_link(item_link, item_guid, item_guid_is_permalink),
              external_url: None,
              language: None,
              description: item_description,
              description_kind: Unspecified,
              description_xhtml: None,
              content: item_content,
              content_text: None,
              content_html: None,
              content_kind: Unspecified,
              content_xhtml: None,
              content_source: None,
              content_media_type: None,
              atom_body: None,
              atom_source: None,
              links: [],
              comments_url: item_comments_url,
              source_url: item_source_url,
              source_title: item_source_title,
              guid_is_permalink: item_guid_is_permalink,
              rights: None,
              rights_kind: Unspecified,
              rights_xhtml: None,
              authors: item_authors,
              contributors: [],
              categories: item_categories,
              attachments: item_attachments,
              images: [],
              published_at: item_published_at,
              updated_at: None,
            })
            if item_title is None && item_description is None {
              diagnostics.push({
                kind: MissingItemContent(item_index),
                line: item_line,
                column: item_column,
              })
            }
            item_open = false
          }
          depth = depth - 1
        }
        @xml.Eof => {
          if !channel_seen {
            return Err(MissingChannel)
          }
          add_missing_channel_diagnostics(
            channel_title, channel_link, channel_description, channel_title_location,
            channel_link_location, channel_description_location, diagnostics, channel_line,
            channel_column,
          )
          for index = 0; index < entries.length(); index = index + 1 {
            entries[index] = with_entry_language(
              entries[index],
              channel_language,
            )
          }
          return Ok({
            format: Rss2,
            identifier: None,
            title: channel_title,
            title_kind: Unspecified,
            title_xhtml: None,
            link: channel_link,
            language: channel_language,
            description: channel_description,
            description_kind: Unspecified,
            description_xhtml: None,
            updated_at: channel_updated,
            published_at: channel_published,
            ttl_minutes: channel_ttl,
            docs_url: None,
            rating: None,
            cloud: None,
            text_input: None,
            skip_hours: channel_skip_hours,
            skip_days: channel_skip_days,
            rights: channel_rights,
            rights_kind: Unspecified,
            rights_xhtml: None,
            generator: channel_generator,
            generator_uri: None,
            generator_version: None,
            managing_editor: channel_managing_editor,
            webmaster: channel_webmaster,
            next_url: None,
            expired: None,
            hubs: [],
            links: channel_links,
            authors: [],
            contributors: [],
            categories: channel_categories,
            images: [],
            entries,
            diagnostics,
          })
        }
        _ => ()
      }
    }
    abort("unreachable")
  } catch {
    @xml.At(error~, span~) =>
      Err(InvalidXml(error.to_string(), span.start.line, span.start.column))
  }
}

///|
fn feed_input_without_bom(input : String) -> String {
  if input.has_prefix("\uFEFF") {
    let without = uri_drop(input, 1)
    if without.has_prefix("\uFEFF") {
      input
    } else {
      without
    }
  } else {
    input
  }
}

///|
/// Parse one RSS 2.0, Atom 1.0, or JSON Feed 1.0/1.1 document.
///
/// Inputs whose first non-whitespace character is `<` are treated as XML; all
/// other inputs are treated as JSON. XML failures and JSON syntax/structural
/// failures return `Err`. Recoverable field problems return `Ok` with
/// diagnostics. An optional leading U+FEFF byte order mark is ignored for
/// both formats; U+FEFF elsewhere remains part of the input.
pub fn parse(input : String) -> Result[Feed, ParseError] {
  let input = feed_input_without_bom(input)
  if !input.trim().has_prefix("<") {
    return parse_json_feed(input)
  }
  let reader = @xml.NamespaceReader::from_string(input)
  try {
    for ;; {
      let event = reader.read_event()
      match event.kind {
        @xml.Start(element) | @xml.Empty(element) => {
          if is_rss_name(element.name, "rss") {
            return match parse_rss(input) {
              Ok(feed) =>
                match add_xml_feed_images(input, feed, None, true) {
                  Ok(imaged) =>
                    add_rss_optional_channel(input, imaged, None, true)
                  Err(error) => Err(error)
                }
              Err(error) => Err(error)
            }
          }
          if is_atom_name(element.name, "feed") {
            return match parse_atom(input, false) {
              Ok(feed) => add_xml_feed_images(input, feed, None, true)
              Err(error) => Err(error)
            }
          }
          return Err(
            UnsupportedRoot(
              element.name.qualified_name,
              element.name.namespace_uri,
            ),
          )
        }
        @xml.Eof => return Err(InvalidRssStructure("missing root element"))
        _ => ()
      }
    }
    abort("unreachable")
  } catch {
    @xml.At(error~, span~) =>
      Err(InvalidXml(error.to_string(), span.start.line, span.start.column))
  }
}

///|
/// Parse a feed or an independent Atom Entry Document. The established
/// `parse` function remains feed-only and keeps its Result[Feed, ParseError]
/// signature. Stand-alone entries use the same Atom field, content, and
/// diagnostic rules as entries inside feeds.
pub fn parse_document(input : String) -> Result[ParsedDocument, ParseError] {
  let input = feed_input_without_bom(input)
  match parse(input) {
    Ok(feed) => Ok(FeedDocument(feed))
    Err(UnsupportedRoot(_, Some("http://www.w3.org/2005/Atom"))) =>
      match parse_atom(input, true) {
        Ok(feed) =>
          if feed.entries.length() == 1 {
            Ok(AtomEntryDocument(feed.entries[0], feed.diagnostics))
          } else {
            Err(InvalidAtomStructure("entry document has no entry"))
          }
        Err(error) => Err(error)
      }
    Err(error) => Err(error)
  }
}