///|
/// Core metadata: the `METADATA` file inside a wheel's `.dist-info` directory
/// and the `PKG-INFO` file inside a source distribution.
///
/// A metadata file is RFC 822 style headers, a blank line, then the description
/// body:
///
/// ```text
/// Metadata-Version: 2.4
/// Name: demo
/// Version: 1.0
///
/// Long description.
/// ```
///
/// The rules implemented here are those of the core metadata specification
/// (PEP 566, as amended by PEP 621, 639, 643, 685 and 753). The specification has
/// no machine-readable schema, so the same rules are restated in
/// `packaging.metadata.Metadata`; the semantics of this file were checked against
/// PyPA `packaging` 26.3 on a corpus of documents, and the differential run is
/// reported together with this file.
///
/// The parser is strict on purpose: a document that an unknown field, a version
/// gate or a value rule rejects is not usable metadata, and silently ignoring a
/// broken field is how broken distributions reach an index. Every rejection is a
/// `VersionError::InvalidMetadata(code, offset)`, where `code` is a stable
/// upper-snake-case string and `offset` is the character offset of the offending
/// field (0 when the document as a whole is at fault). The rule to code mapping
/// is:
///
/// | rule | code |
/// | --- | --- |
/// | 1 `Metadata-Version` | `METADATA_VERSION_REQUIRED`, `METADATA_VERSION_INVALID` |
/// | 2 `Name` / `Version` | `NAME_REQUIRED`, `VERSION_REQUIRED` |
/// | 3 `Name` syntax | `NAME_INVALID` |
/// | 4 `Version` syntax | `VERSION_INVALID` |
/// | 5 `Requires-Python` | `REQUIRES_PYTHON_INVALID` |
/// | 6 `Requires-Dist` | `REQUIRES_DIST_INVALID` |
/// | 7 `Provides-Extra` | `PROVIDES_EXTRA_INVALID` |
/// | 8 `License-Expression` | `LICENSE_EXPRESSION_INVALID`, `LICENSE_EXPRESSION_CONFLICT` |
/// | 9 version gate | `FIELD_NOT_IN_METADATA_VERSION`, `UNKNOWN_FIELD`, `EXTRA_MARKER_NOT_IN_METADATA_VERSION` |
/// | 10 repeated single-use fields | `DUPLICATE_HEADER` |
/// | 11 `Author-email` / `Maintainer-email` | parsed, not enforced: see the note below |
/// | 12 `Project-URL` | `PROJECT_URL_INVALID`, `PROJECT_URL_DUPLICATE_LABEL` |
/// | 13 `Description-Content-Type` | `DESCRIPTION_CONTENT_TYPE_INVALID` |
/// | `Summary` | `SUMMARY_MULTILINE` |
/// | `License-File` | the codes of `canonicalize_license_file` |
/// | `Dynamic` | `DYNAMIC_INVALID` |
///
/// Where this file deliberately differs from `packaging` 26.3 or from a literal
/// reading of the PEPs — each difference is also marked at the code that
/// implements it:
///
/// - A folded header is unfolded to a single space (RFC 5322 §2.2.3), so
///   `Summary: a` followed by ` b` is the one-line summary `a b`. `packaging`
///   reads with the `compat32` email policy, which keeps the fold, and so rejects
///   a folded `Summary` outright.
/// - A `Description` header wins over a description carried in the body, where
///   `packaging` puts both into its unparsed pile and rejects the document.
/// - `Dynamic`, `License-Expression` and `License-File` are gated at 2.4; PEP 643
///   introduced `Dynamic` in 2.2 and `packaging` gates it there.
/// - `Import-Name` and `Import-Namespace` exist in `packaging` 26.3 at metadata
///   2.5, and this file accepts at most 2.4, so they can never be used.
/// - `Description-Content-Type` only has to be a well formed `type/subtype` with
///   optional parameters, while `packaging` restricts it to `text/plain`,
///   `text/x-rst` and `text/markdown` with a UTF-8 charset and a known Markdown
///   variant.
/// - `Author-email` and `Maintainer-email` are *parsed* into `(name, address)`
///   pairs here, while `packaging` stores the string untouched. A list that does
///   not parse is reported by `Metadata::diagnostics` instead of failing the
///   document: real `METADATA` files carry `Author-email: UNKNOWN` (setuptools)
///   and empty values, and an optional field must not make a document unusable.
///   An empty value counts as absent, so `Author-email: ` is the same as no
///   `Author-email`. PEP 639's
///   "`License-Expression` forbids `License` and `License ::` classifiers" rule is
///   implemented here but not in `packaging` 26.3 at all.

///|
/// The number of entries of the core metadata version order this reader accepts
/// (rule 1): `1.0 1.1 1.2 2.1 2.2 2.3 2.4`. The later versions stay in the order
/// so a field introduced by one of them can still be ranked, but a document
/// declaring one is rejected.
const ACCEPTED_METADATA_VERSION_RANK : Int = 7

///|
/// The rank of the metadata version that first allowed `Provides-Extra` and the
/// `extra` marker variable (2.1), used by the last clause of rule 9.
const EXTRA_SUPPORT_VERSION_RANK : Int = 3

///|
/// Rank of a `Metadata-Version` in the core metadata ordering: `1.0`→0 `1.1`→1
/// `1.2`→2 `2.1`→3 `2.2`→4 `2.3`→5 `2.4`→6 `2.5`→7 `2.6`→8. `None` for anything
/// else, including the 2.0 that PEP 426 never released.
fn version_rank(version : String) -> Int? {
  match version {
    "1.0" => Some(0)
    "1.1" => Some(1)
    "1.2" => Some(2)
    "2.1" => Some(3)
    "2.2" => Some(4)
    "2.3" => Some(5)
    "2.4" => Some(6)
    "2.5" => Some(7)
    "2.6" => Some(8)
    _ => None
  }
}

///|
/// The gate table of rule 9: the metadata version that introduced a field, or
/// `None` when the name is not a core metadata field at all (which is
/// `UNKNOWN_FIELD`). Fields are grouped by the version that added them, which is
/// the way PEP 241/314/345/566/639/794 present them.
///
/// `Dynamic` is listed under 2.4 although PEP 643 introduced it in 2.2, because
/// rule 9 groups it with the two PEP 639 fields. `Import-Name` and
/// `Import-Namespace` are 2.5, which no accepted document may declare, so a
/// document that uses them is always rejected.
///
/// The three deprecated 1.1 fields (`Requires`, `Provides`, `Obsoletes`) are part
/// of the vocabulary because the specification recognises them and the reference
/// implementation accepts them; leaving them out would turn a legal 1.1 document
/// into an `UNKNOWN_FIELD` rejection.
fn field_added_version(key : String) -> String? {
  match key {
    "metadata-version"
    | "name"
    | "version"
    | "platform"
    | "summary"
    | "description"
    | "keywords"
    | "home-page"
    | "author"
    | "author-email"
    | "license" => Some("1.0")
    "supported-platform"
    | "download-url"
    | "classifier"
    | "requires"
    | "provides"
    | "obsoletes" => Some("1.1")
    "maintainer"
    | "maintainer-email"
    | "requires-dist"
    | "provides-dist"
    | "obsoletes-dist"
    | "requires-python"
    | "requires-external"
    | "project-url" => Some("1.2")
    "description-content-type" | "provides-extra" => Some("2.1")
    "license-expression" | "license-file" | "dynamic" => Some("2.4")
    "import-name" | "import-namespace" => Some("2.5")
    _ => None
  }
}

///|
/// Rule 10: the multi-use fields — `Classifier`, `Requires-Dist`,
/// `Requires-External`, `Project-URL`, `Provides-Extra`, `Provides-Dist`,
/// `Obsoletes-Dist`, `Dynamic`, `Platform`, `License-File`,
/// `Supported-Platform`, `Import-Name`, `Import-Namespace` and the three
/// deprecated 1.1 list fields — and `false` for every single-use field, whose
/// repetition is `DUPLICATE_HEADER`.
fn is_multi_use(key : String) -> Bool {
  key == "classifier" ||
  key == "requires-dist" ||
  key == "requires-external" ||
  key == "project-url" ||
  key == "provides-extra" ||
  key == "provides-dist" ||
  key == "obsoletes-dist" ||
  key == "dynamic" ||
  key == "platform" ||
  key == "license-file" ||
  key == "supported-platform" ||
  key == "import-name" ||
  key == "import-namespace" ||
  key == "requires" ||
  key == "provides" ||
  key == "obsoletes"
}

///|
/// `text` with its first character upper-cased; field names are ASCII.
fn capitalize(text : String) -> String {
  if text.is_empty() {
    text
  } else {
    text[0:1].to_owned().to_upper() + text[1:].to_owned()
  }
}

///|
/// The canonical spelling of a field name, which is what `Metadata::to_string`
/// emits: header names are compared case-insensitively on input, so a field is
/// stored under its lower-case name and capitalised per `-` separated segment on
/// output. The two URL fields are the only places where that rule needs help
/// (`url` is an initialism in `Download-URL` and `Project-URL`).
fn canonical_field_name(key : String) -> String {
  let out = StringBuilder()
  let mut first = true
  for segment in key.split("-") {
    if !first {
      out.write_string("-")
    }
    first = false
    let text = segment.to_owned()
    out.write_string(if text == "url" { "URL" } else { capitalize(text) })
  }
  out.to_string()
}

///|
/// One logical header: the lower-cased field name, the unfolded value and the
/// character offset of the line it starts on.
priv struct Header {
  key : String
  value : String
  offset : Int
}

///|
/// True for the whitespace RFC 5322 uses to fold a header.
fn is_wsp(c : Char) -> Bool {
  c == ' ' || c == '\t'
}

///|
/// ASCII alphanumeric: the character class the PEP 508 name rule is built from.
fn is_ascii_alphanumeric(c : Char) -> Bool {
  c.is_ascii_alphabetic() || c.is_ascii_digit()
}

///|
/// True when `c` ends a line. Only CRLF, CR and LF do, which is what the RFC 822
/// reader in `packaging` accepts (`email.feedparser`'s `NLCRE`). The exotic
/// boundaries of Python's `str.splitlines` are deliberately not line endings
/// here, because an RFC 822 header value may legitimately contain them.
fn is_line_break(c : Char) -> Bool {
  c == '\n' || c == '\r'
}

///|
/// True when `c` may appear in a header field name: printable ASCII except space
/// and `:`, the `[\041-\071\073-\176]` class of RFC 5322 §2.2.
fn is_header_name_char(c : Char) -> Bool {
  let code = c.to_int()
  (code >= 33 && code <= 57) || (code >= 59 && code <= 126)
}

///|
/// True when every character of `chars[from:to]` may appear in a field name.
fn is_header_name(chars : Array[Char], from : Int, to : Int) -> Bool {
  for i = from; i < to; i = i + 1 {
    if !is_header_name_char(chars[i]) {
      return false
    }
  }
  true
}

///|
/// The first index of `needle` in `chars[from:to]`, or `-1`.
fn find_char(chars : Array[Char], from : Int, to : Int, needle : Char) -> Int {
  for i = from; i < to; i = i + 1 {
    if chars[i] == needle {
      return i
    }
  }
  -1
}

///|
/// `text` with leading and trailing spaces and tabs removed.
fn trim_wsp(text : String) -> String {
  let chars = text.to_array()
  let mut start = 0
  let mut end = chars.length()
  while start < end && is_wsp(chars[start]) {
    start = start + 1
  }
  while end > start && is_wsp(chars[end - 1]) {
    end = end - 1
  }
  String::from_array(chars[start:end].to_owned())
}

///|
/// Split a metadata document into its headers and its body.
///
/// The header section ends at the first blank line or at the first line that is
/// not a header, whichever comes first; everything after that is the body,
/// returned verbatim so a description carried in the body keeps its exact line
/// endings (CRLF included).
///
/// A line that continues a header starts with a space or a tab and is unfolded
/// into the logical value with a single space (RFC 5322 §2.2.3). A continuation
/// before any header, and a line whose colon starts the line, are dropped, which
/// is what the RFC 822 reader used by `packaging` does with them.
fn split_document(text : String) -> (Array[Header], String) {
  let chars = text.to_array()
  let n = chars.length()
  let headers : Array[Header] = []
  let mut last = -1
  let mut i = 0
  let mut body_start = n
  let mut done = false
  while i < n && !done {
    let line_start = i
    let mut e = i
    while e < n && !is_line_break(chars[e]) {
      e = e + 1
    }
    let next = if e < n && chars[e] == '\r' && e + 1 < n && chars[e + 1] == '\n' {
      e + 2
    } else if e < n {
      e + 1
    } else {
      e
    }
    if e == line_start {
      // The blank line that separates the headers from the body.
      body_start = next
      done = true
    } else if is_wsp(chars[line_start]) {
      if last >= 0 {
        let piece = trim_wsp(String::from_array(chars[line_start:e].to_owned()))
        let previous = headers[last]
        headers[last] = {
          ..previous,
          value: if previous.value.is_empty() {
            piece
          } else {
            previous.value + " " + piece
          },
        }
      }
      i = next
    } else {
      let colon = find_char(chars, line_start, e, ':')
      if colon == line_start {
        i = next
      } else if colon > 0 && is_header_name(chars, line_start, colon) {
        let key = String::from_array(chars[line_start:colon].to_owned()).to_lower()
        let value = trim_wsp(String::from_array(chars[colon + 1:e].to_owned()))
        headers.push({ key, value, offset: line_start, })
        last = headers.length() - 1
        i = next
      } else {
        // Not a header, so the body starts here.
        body_start = line_start
        done = true
      }
    }
  }
  (headers, String::from_array(chars[body_start:].to_owned()))
}

///|
/// The character offset of the first header called `key`, or 0 when it is
/// absent. A value level error is reported at the first occurrence of its field.
fn field_offset(headers : Array[Header], key : String) -> Int {
  for header in headers {
    if header.key == key {
      return header.offset
    }
  }
  0
}

///|
/// Raise `code` at the offset of the field `key`. The result type is generic
/// because an error is raised both as a statement and in a position that wants a
/// value.
fn[T] fail(
  code : String,
  headers : Array[Header],
  key : String,
) -> T raise VersionError {
  raise InvalidMetadata(code, field_offset(headers, key))
}

///|
/// The value of the first header called `key`, or `None`.
fn first_value(headers : Array[Header], key : String) -> String? {
  for header in headers {
    if header.key == key {
      return Some(header.value)
    }
  }
  None
}

///|
/// Collect the values of every field, in document order per field, applying rules
/// 9 and 10: the field must exist in the specification (`UNKNOWN_FIELD`), must
/// have been introduced by a metadata version no later than the declared one
/// (`FIELD_NOT_IN_METADATA_VERSION`), and a single-use field may appear once only
/// (`DUPLICATE_HEADER`).
///
/// The structural check runs before any field value is interpreted, so the
/// reported error does not depend on the order the fields appear in.
fn collect_fields(
  headers : Array[Header],
  rank : Int,
) -> Map[String, Array[String]] raise VersionError {
  let fields : Map[String, Array[String]] = Map([])
  for header in headers {
    let added = match field_added_version(header.key) {
      Some(version) => version_rank(version).unwrap()
      None => raise InvalidMetadata("UNKNOWN_FIELD", header.offset)
    }
    if added > rank {
      raise InvalidMetadata("FIELD_NOT_IN_METADATA_VERSION", header.offset)
    }
    match fields.get(header.key) {
      Some(values) => {
        if !is_multi_use(header.key) {
          raise InvalidMetadata("DUPLICATE_HEADER", header.offset)
        }
        values.push(header.value)
      }
      None => fields[header.key] = [header.value]
    }
  }
  fields
}

///|
/// The single value of a single-use field, or `None` when the field is absent.
/// Presence has already been checked, so a list here holds one element at most.
fn one(fields : Map[String, Array[String]], key : String) -> String? {
  match fields.get(key) {
    Some(values) => if values.is_empty() { None } else { Some(values[0]) }
    None => None
  }
}

///|
/// The values of a multi-use field, in document order; empty when absent.
fn many(fields : Map[String, Array[String]], key : String) -> Array[String] {
  match fields.get(key) {
    Some(values) => values
    None => []
  }
}

///|
/// The PEP 508 / PEP 503 project name rule (rule 3): an alphanumeric start, an
/// alphanumeric or `_` end, and alphanumerics, `.`, `_` or `-` in between.
///
/// `requirements.mbt` applies exactly this rule to requirement names but keeps
/// its predicates private, so the rule is repeated here rather than reaching into
/// that module. Reusing it verbatim is the point: a name that `Requires-Dist`
/// accepts must also be accepted as a `Name`, and vice versa.
fn is_valid_name(text : String) -> Bool {
  let chars = text.to_array()
  let n = chars.length()
  if n == 0 || !is_ascii_alphanumeric(chars[0]) {
    return false
  }
  if !is_ascii_alphanumeric(chars[n - 1]) {
    return false
  }
  for i = 1; i < n - 1; i = i + 1 {
    let c = chars[i]
    if !(is_ascii_alphanumeric(c) || c == '.' || c == '_' || c == '-') {
      return false
    }
  }
  true
}

///|
/// True when the value carries one of the line boundaries that make a header
/// value multi-line: CR, LF, VT, FF, FS/GS/RS, NEL, LINE SEPARATOR and PARAGRAPH
/// SEPARATOR, the `_LINE_BOUNDARY_RE` of `packaging`. Only `Summary` is checked
/// in the reference implementation, so only `Summary` is checked here.
fn has_line_boundary(text : String) -> Bool {
  for c in text.to_array() {
    let code = c.to_int()
    if code == 10 ||
      code == 13 ||
      code == 11 ||
      code == 12 ||
      (code >= 28 && code <= 30) ||
      code == 133 ||
      code == 8232 ||
      code == 8233 {
      return true
    }
  }
  false
}

///|
/// A character that may appear in a MIME type token (RFC 2045): printable ASCII
/// that is not a `tspecial` and not a separator.
fn is_mime_token_char(c : Char) -> Bool {
  let code = c.to_int()
  if code < 33 || code > 126 {
    return false
  }
  match c {
    '('
    | ')'
    | '<'
    | '>'
    | '@'
    | ','
    | ';'
    | ':'
    | '\\'
    | '"'
    | '/'
    | '['
    | ']'
    | '?'
    | '=' => false
    _ => true
  }
}

///|
/// `chars` advanced past the run of spaces and tabs that starts at `start`.
fn skip_wsp(chars : Array[Char], start : Int) -> Int {
  let mut i = start
  while i < chars.length() && is_wsp(chars[i]) {
    i = i + 1
  }
  i
}

///|
/// `chars` advanced past the MIME token that starts at `start`, if any.
fn scan_token(chars : Array[Char], start : Int) -> Int {
  let mut i = start
  while i < chars.length() && is_mime_token_char(chars[i]) {
    i = i + 1
  }
  i
}

///|
/// Rule 13: a `Description-Content-Type` value is a MIME type — `type/subtype`
/// with optional `; name=value` parameters (RFC 2045, RFC 6838). Any
/// `type/subtype` shape is accepted, which is deliberately more permissive than
/// `packaging`, whose `_process_description_content_type` allows only
/// `text/plain`, `text/x-rst` and `text/markdown`, only a UTF-8 charset and only
/// the `GFM` and `CommonMark` Markdown variants.
fn is_valid_mime_type(text : String) -> Bool {
  let chars = text.to_array()
  let n = chars.length()
  let mut i = 0
  while i < n && chars[i] != ';' {
    i = i + 1
  }
  // The essence may be padded before the ';' but not inside `type/subtype`, and
  // the second '/' of `a/b/c` is not a token character, so it is rejected below.
  let essence = trim_wsp(String::from_array(chars[0:i].to_owned())).to_array()
  let slash = find_char(essence, 0, essence.length(), '/')
  if slash <= 0 || slash + 1 >= essence.length() {
    return false
  }
  for j = 0; j < essence.length(); j = j + 1 {
    if j != slash && !is_mime_token_char(essence[j]) {
      return false
    }
  }
  while i < n {
    // `chars[i]` is the ';' that starts a parameter.
    i = skip_wsp(chars, i + 1)
    if i == n {
      // A trailing ';' is tolerated rather than read as a named parameter.
      break
    }
    let name_end = scan_token(chars, i)
    let equals = skip_wsp(chars, name_end)
    if name_end == i || equals == n || chars[equals] != '=' {
      return false
    }
    i = skip_wsp(chars, equals + 1)
    if i < n && chars[i] == '"' {
      let close = find_char(chars, i + 1, n, '"')
      if close < 0 {
        return false
      }
      i = close + 1
    } else {
      let value_end = scan_token(chars, i)
      if value_end == i {
        return false
      }
      i = value_end
    }
    i = skip_wsp(chars, i)
    if i < n && chars[i] != ';' {
      return false
    }
  }
  true
}

///|
/// Split a comma separated mailbox list into its entries without splitting inside
/// a quoted display name or inside `<...>` (rule 11). An unbalanced quote or
/// angle bracket raises `code`.
fn split_mailboxes(text : String) -> Array[String] raise VersionError {
  let chars = text.to_array()
  let out : Array[String] = []
  let mut start = 0
  let mut depth = 0
  let mut quoted = false
  let mut i = 0
  while i < chars.length() {
    let c = chars[i]
    if quoted {
      if c == '\\' {
        i = i + 1
      } else if c == '"' {
        quoted = false
      }
    } else if c == '"' {
      quoted = true
    } else if c == '<' {
      depth = depth + 1
    } else if c == '>' {
      depth = depth - 1
      if depth < 0 {
        raise InvalidMetadata("MAILBOX_INVALID", 0)
      }
    } else if c == ',' && depth == 0 {
      out.push(String::from_array(chars[start:i].to_owned()))
      start = i + 1
    }
    i = i + 1
  }
  if quoted || depth != 0 {
    raise InvalidMetadata("MAILBOX_INVALID", 0)
  }
  out.push(String::from_array(chars[start:].to_owned()))
  out
}

///|
/// A deliberately shallow `addr-spec` check (rule 11): PEP 621 delegates the
/// grammar to RFC 5322, which this library does not implement, so the check is
/// the shape every usable address has — one `@`, a non-empty local part, a
/// non-empty domain, and no whitespace, angle brackets, quotes or commas.
fn is_valid_address(text : String) -> Bool {
  let chars = text.to_array()
  let mut at = -1
  for i = 0; i < chars.length(); i = i + 1 {
    let c = chars[i]
    if c == '@' {
      if at >= 0 {
        return false
      }
      at = i
    } else if is_wsp(c) || c == '<' || c == '>' || c == ',' || c == '"' {
      return false
    }
  }
  at > 0 && at + 1 < chars.length()
}

///|
/// `text` without one pair of surrounding double quotes, when it has one.
fn strip_quotes(text : String) -> String {
  let chars = text.to_array()
  let n = chars.length()
  if n >= 2 && chars[0] == '"' && chars[n - 1] == '"' {
    String::from_array(chars[1:n - 1].to_owned())
  } else {
    text
  }
}

///|
/// Rule 11: parse an `Author-email`/`Maintainer-email` value into `(name,
/// address)` pairs, the shape PEP 621 gives the field (`Name 
` or a bare /// address, comma separated), raising `code` on anything else. A bare address has /// an empty name; the raw text stays on the metadata either way. fn parse_mailboxes(text : String) -> Array[(String, String)] raise VersionError { let out : Array[(String, String)] = [] for entry in split_mailboxes(text) { let cleaned = trim_wsp(entry) let chars = cleaned.to_array() let open = find_char(chars, 0, chars.length(), '<') if open < 0 { if !is_valid_address(cleaned) { raise InvalidMetadata("MAILBOX_INVALID", 0) } out.push(("", cleaned)) continue } if chars[chars.length() - 1] != '>' || find_char(chars, 0, open, '>') >= 0 { raise InvalidMetadata("MAILBOX_INVALID", 0) } let name = strip_quotes( trim_wsp(String::from_array(chars[0:open].to_owned())), ) let address = trim_wsp( String::from_array(chars[open + 1:chars.length() - 1].to_owned()), ) if name.is_empty() || !is_valid_address(address) { raise InvalidMetadata("MAILBOX_INVALID", 0) } out.push((name, address)) } out } ///| /// A value that is present but empty counts as absent: `Author-email: ` and /// `Author-email` are the same thing, and so are an empty `Name` and no `Name`. fn present(value : String?) -> String? { match value { Some(text) => if text.is_empty() { None } else { Some(text) } None => None } } ///| /// The mailboxes an address list holds, or none when the list does not parse. /// The failure is not an error: see the note at rule 11 in `Metadata::parse`. fn mailboxes_or_empty(text : String) -> Array[(String, String)] { parse_mailboxes(text) catch { _ => [] } } ///| /// Whether an address list parses. `diagnostics` re-asks instead of storing a /// flag, so the struct stays a plain record of the document. fn mailboxes_parsable(text : String) -> Bool { try { ignore(parse_mailboxes(text)) true } catch { _ => false } } ///| /// Rule 12: a `Project-URL` value is `label, url`, the label first. A value /// without a comma is rejected, and a label may not repeat, which is what the /// reference implementation does with duplicate labels (it drops the field). Both /// halves are stripped, as `packaging` does, and the URL keeps any further commas. fn parse_project_urls( values : Array[String], headers : Array[Header], ) -> Array[(String, String)] raise VersionError { let out : Array[(String, String)] = [] for value in values { let chars = value.to_array() let comma = find_char(chars, 0, chars.length(), ',') if comma < 0 { fail("PROJECT_URL_INVALID", headers, "project-url") } let label = trim_wsp(String::from_array(chars[0:comma].to_owned())) let url = trim_wsp(String::from_array(chars[comma + 1:].to_owned())) for pair in out { if pair.0 == label { fail("PROJECT_URL_DUPLICATE_LABEL", headers, "project-url") } } out.push((label, url)) } out } ///| /// True when `c` is whitespace to Python's `str.strip()`, which is a wider set /// than RFC 5322's WSP: besides space, tab, CR, LF, VT and FF it also removes the /// four C0 information separators and the Unicode spaces. `packaging` strips /// `Keywords` parts with `str.strip()`, so matching it means matching this set and /// not `is_wsp`. fn is_python_space(c : Char) -> Bool { let code = c.to_int() if code == 32 || (code >= 9 && code <= 13) || (code >= 28 && code <= 31) { return true } // NEL, NO-BREAK SPACE, OGHAM SPACE MARK, the EN/EM QUAD..HAIR SPACE run, // LINE/PARAGRAPH SEPARATOR, NARROW NO-BREAK SPACE, MEDIUM MATHEMATICAL SPACE // and IDEOGRAPHIC SPACE. code == 133 || code == 160 || code == 5760 || (code >= 8192 && code <= 8202) || code == 8232 || code == 8233 || code == 8239 || code == 8287 || code == 12288 } ///| /// `text` with Python's whitespace removed from both ends. fn strip_python_space(text : String) -> String { let chars = text.to_array() let mut start = 0 let mut end = chars.length() while start < end && is_python_space(chars[start]) { start = start + 1 } while end > start && is_python_space(chars[end - 1]) { end = end - 1 } String::from_array(chars[start:end].to_owned()) } ///| /// PEP 566: `Keywords` is a comma separated string, exposed here as the list of /// stripped parts, which is the shape `packaging` uses too -- including *which* /// whitespace it strips: `str.strip()` removes the Unicode spaces as well, so /// `a,\u{0b}b` is `["a", "b"]` and not `["a", "\u{0b}b"]`. fn split_keywords(text : String) -> Array[String] { let out : Array[String] = [] for part in text.split(",") { out.push(strip_python_space(part.to_owned())) } out } ///| /// True when a `Requires-Dist` marker mentions `extra` (or the PEP 685 /// `extras`), which is what attaches a dependency to a `Provides-Extra` name. A /// marker that does not parse cannot mention anything, and it cannot reach here /// anyway: `Requirement::parse` has already validated it. fn marker_mentions_extra(text : String) -> Bool { try { let marker = Marker::parse(text) marker.variables().contains(MarkerVar::Extra) || marker.variables().contains(MarkerVar::Extras) } catch { _ => false } } ///| /// A parsed and validated core metadata document. /// /// Everything is stored the way the document spelled it — names, extras and /// classifiers keep their case, an absent optional field is `None`, and multi-use /// fields keep their input order — except for the fields the specification gives /// a canonical form to: `Version` is a parsed PEP 440 version, /// `License-Expression` holds the canonical SPDX expression, and `Dynamic` is /// lower-cased because PEP 643 compares it case insensitively. /// /// `raw` is the text handed to `Metadata::parse`. It is the caller's document, /// not a canonical form, so it is not part of the `to_string` fixed point. pub(all) struct Metadata { /// `Metadata-Version` (rule 1), one of `1.0 1.1 1.2 2.1 2.2 2.3 2.4`. metadata_version : String /// `Name` (rule 3), as written; `canonicalize_name` makes spellings comparable. name : String /// `Version` (rule 4), parsed as PEP 440. version : Version /// `Summary`. summary : String? /// `Description`, from the header or from the body after the blank line. description : String? /// `Description-Content-Type` (rule 13), a MIME type. description_content_type : String? /// `Keywords`, split on commas and stripped. keywords : Array[String] /// `Author`. author : String? /// `Author-email`, the raw value. author_email : String? /// `Author-email` as `(name, address)` pairs (rule 11). author_email_addresses : Array[(String, String)] /// `Maintainer`. maintainer : String? /// `Maintainer-email`, the raw value. maintainer_email : String? /// `Maintainer-email` as `(name, address)` pairs (rule 11). maintainer_email_addresses : Array[(String, String)] /// `License`, the free text form; forbidden next to `License-Expression`. license : String? /// `License-Expression` (rule 8), the canonical SPDX expression. license_expression : String? /// `Home-page`. home_page : String? /// `Download-URL`. download_url : String? /// `Requires-Python`, the raw set; `Metadata::requires_python` parses it. requires_python : String? /// `Classifier` (PEP 301 trove classifiers). classifiers : Array[String] /// `Requires-Dist`, the raw PEP 508 lines; `Metadata::requirements` parses them. requires_dist : Array[String] /// `Requires-External`. requires_external : Array[String] /// `Project-URL` as `(label, url)` pairs (rule 12). project_urls : Array[(String, String)] /// `Provides-Extra` as written; `Metadata::extras` canonicalizes. provides_extra : Array[String] /// `Provides-Dist`. provides_dist : Array[String] /// `Obsoletes-Dist`. obsoletes_dist : Array[String] /// `Dynamic` (PEP 643), lower-cased, in input order. dynamic : Array[String] /// `Platform`. platforms : Array[String] /// `Supported-Platform`. supported_platforms : Array[String] /// `License-File` (PEP 639), each path checked by `canonicalize_license_file`. license_files : Array[String] /// `Import-Name` (metadata 2.5). Always empty: rule 1 rejects such a document. import_names : Array[String] /// `Import-Namespace` (metadata 2.5). Always empty, for the same reason. import_namespaces : Array[String] /// `Requires` (deprecated in metadata 1.2). requires : Array[String] /// `Provides` (deprecated in metadata 1.2). provides : Array[String] /// `Obsoletes` (deprecated in metadata 1.2). obsoletes : Array[String] /// The document as handed to `Metadata::parse`. raw : String } derive(Eq, @debug.Debug) ///| pub extend Metadata with Show::{output} ///| pub extend Metadata with Eq::{equal, not_equal} ///| pub extend Metadata with @debug.Debug::{to_repr} ///| /// Parse and validate a `METADATA`/`PKG-INFO` document (PEP 566). /// /// The checks run in a fixed order, so the error a broken document reports is /// deterministic: the version fields first (rules 1 and 2), then the structural /// pass over every header (rules 9 and 10), then each field's own rule in the /// canonical order of the specification's field list. /// /// ```moonbit nocheck /// let metadata = Metadata::parse( /// "Metadata-Version: 2.1\nName: demo\nVersion: 1.0\n\nHello.\n", /// ) /// metadata.name // "demo" /// metadata.version.to_string() // "1.0" /// metadata.description // Some("Hello.\n") /// ``` pub fn Metadata::parse(text : String) -> Metadata raise VersionError { let (headers, body) = split_document(text) // Rule 1: Metadata-Version is required, and gates every other field. let metadata_version = match first_value(headers, "metadata-version") { Some(value) => value None => raise InvalidMetadata("METADATA_VERSION_REQUIRED", 0) } let rank = match version_rank(metadata_version) { Some(rank) if rank < ACCEPTED_METADATA_VERSION_RANK => rank _ => fail("METADATA_VERSION_INVALID", headers, "metadata-version") } // Rules 9 and 10, in document order, before any value is interpreted. let fields = collect_fields(headers, rank) // Rule 2: Name and Version are required by every accepted version, and an empty // value is a missing value, which is what the reference implementation says too. let name = match one(fields, "name") { Some(value) if !value.is_empty() => value _ => fail("NAME_REQUIRED", headers, "name") } let version_text = match one(fields, "version") { Some(value) if !value.is_empty() => value _ => fail("VERSION_REQUIRED", headers, "version") } // Rule 3. if !is_valid_name(name) { fail("NAME_INVALID", headers, "name") } // Rule 4. let version = Version::parse(version_text) catch { _ => fail("VERSION_INVALID", headers, "version") } // `Summary` is the one field that must not carry a line break. let summary = one(fields, "summary") match summary { Some(value) => if has_line_boundary(value) { fail("SUMMARY_MULTILINE", headers, "summary") } None => () } // Rule 5. let requires_python = one(fields, "requires-python") match requires_python { Some(value) => ignore(SpecifierSet::parse(value)) catch { _ => fail("REQUIRES_PYTHON_INVALID", headers, "requires-python") } None => () } // Rule 6, plus the last clause of rule 9: a marker that mentions `extra` is // only meaningful from 2.1, where `Provides-Extra` exists. let requires_dist = many(fields, "requires-dist") for value in requires_dist { let requirement = Requirement::parse(value) catch { _ => fail("REQUIRES_DIST_INVALID", headers, "requires-dist") } match requirement.marker_text { Some(marker_text) => if rank < EXTRA_SUPPORT_VERSION_RANK && marker_mentions_extra(marker_text) { fail("EXTRA_MARKER_NOT_IN_METADATA_VERSION", headers, "requires-dist") } None => () } } // Rule 7. let provides_extra = many(fields, "provides-extra") for value in provides_extra { if !is_valid_name(value) { fail("PROVIDES_EXTRA_INVALID", headers, "provides-extra") } } // Rule 8: the SPDX expression is canonicalized on the way in, and PEP 639 // forbids the two legacy license spellings next to it. let license = one(fields, "license") let classifiers = many(fields, "classifier") let license_expression = match one(fields, "license-expression") { None => None Some(value) => { let canonical = canonicalize_license_expression(value) catch { _ => fail("LICENSE_EXPRESSION_INVALID", headers, "license-expression") } match license { Some(_) => fail("LICENSE_EXPRESSION_CONFLICT", headers, "license") None => () } for classifier in classifiers { if classifier.has_prefix("License ::") { fail("LICENSE_EXPRESSION_CONFLICT", headers, "classifier") } } Some(canonical) } } // Rule 13. let description_content_type = one(fields, "description-content-type") match description_content_type { Some(value) => if !is_valid_mime_type(value) { fail( "DESCRIPTION_CONTENT_TYPE_INVALID", headers, "description-content-type", ) } None => () } // PEP 639 license file paths. The code is the one // `canonicalize_license_file` reports: PARENT_DIRECTORY, UNRESOLVED_GLOB, // ABSOLUTE_PATH or NOT_POSIX_PATH. let license_files : Array[String] = [] for path in many(fields, "license-file") { let checked = canonicalize_license_file(path) catch { InvalidLicense(code, _) => fail(code, headers, "license-file") _ => fail("LICENSE_FILE_INVALID", headers, "license-file") } license_files.push(checked) } // PEP 643: `Dynamic` names a field. A field that does not exist cannot be // dynamic, and the three fields that define the distribution cannot be either. let dynamic : Array[String] = [] for value in many(fields, "dynamic") { let lowered = value.to_lower() if field_added_version(lowered) is None || lowered == "name" || lowered == "version" || lowered == "metadata-version" { fail("DYNAMIC_INVALID", headers, "dynamic") } dynamic.push(lowered) } // Rule 12. let project_urls = parse_project_urls(many(fields, "project-url"), headers) // Rule 11. An address list is *parsed*, not enforced: the field is optional, a // real `METADATA` in the wild carries a placeholder (`Author-email: UNKNOWN`) // or an empty value, and refusing the whole document over an optional field // would make those documents unreadable. What parses becomes the address list; // what does not is reported by `diagnostics`, where a caller can act on it // without losing the rest of the document. An empty value counts as absent, // the same way an empty `Name` does. let author_email = present(one(fields, "author-email")) let author_email_addresses = match author_email { Some(value) => mailboxes_or_empty(value) None => [] } let maintainer_email = present(one(fields, "maintainer-email")) let maintainer_email_addresses = match maintainer_email { Some(value) => mailboxes_or_empty(value) None => [] } let keywords = match one(fields, "keywords") { Some(value) => split_keywords(value) None => [] } // The `Description` header wins over a body description. An empty body means // "no description", which is how the reference implementation reads it too. let description = match one(fields, "description") { Some(value) => Some(value) None => if body.is_empty() { None } else { Some(body) } } { metadata_version, name, version, summary, description, description_content_type, keywords, author: one(fields, "author"), author_email, author_email_addresses, maintainer: one(fields, "maintainer"), maintainer_email, maintainer_email_addresses, license, license_expression, home_page: one(fields, "home-page"), download_url: one(fields, "download-url"), requires_python, classifiers, requires_dist, requires_external: many(fields, "requires-external"), project_urls, provides_extra, provides_dist: many(fields, "provides-dist"), obsoletes_dist: many(fields, "obsoletes-dist"), dynamic, platforms: many(fields, "platform"), supported_platforms: many(fields, "supported-platform"), license_files, import_names: many(fields, "import-name"), import_namespaces: many(fields, "import-namespace"), requires: many(fields, "requires"), provides: many(fields, "provides"), obsoletes: many(fields, "obsoletes"), raw: text, } } ///| /// `Requires-Dist` parsed into PEP 508 requirements, in input order. /// /// Every value was validated by `Metadata::parse`, so this cannot fail for a value /// that came from it; a value a caller wrote into the struct by hand and that does /// not parse is skipped rather than panicking. pub fn Metadata::requirements(self : Metadata) -> Array[Requirement] { let out : Array[Requirement] = [] for value in self.requires_dist { out.push(Requirement::parse(value)) catch { _ => () } } out } ///| /// `Requires-Python` parsed as a PEP 440 specifier set, or `None` when the field /// is absent. As with `requirements`, an unparsable hand-written value becomes /// `None` instead of a panic. pub fn Metadata::requires_python(self : Metadata) -> SpecifierSet? { match self.requires_python { None => None Some(value) => Some(SpecifierSet::parse(value)) catch { _ => None } } } ///| /// `Provides-Extra` in the canonical PEP 685 form, in input order: extras are /// compared after `canonicalize_name`, so `Foo_Bar` and `foo-bar` are one extra. /// The field itself keeps the spelling of the document. pub fn Metadata::extras(self : Metadata) -> Array[String] { let out : Array[String] = [] for value in self.provides_extra { out.push(canonicalize_name(value)) } out } ///| /// True when this metadata describes `name` at `version` and can be installed on /// `python`: /// /// - the names are compared after `canonicalize_name` (PEP 503), so `Foo_Bar` /// matches `foo-bar`; /// - the versions are compared with PEP 440 equality, so `1.0` matches `1.0.0`; /// - the `Requires-Python` set, when there is one and `python` was given, must /// contain `python`. An unknown side is not a mismatch: no `Requires-Python` /// means every interpreter, and `python = None` means the caller does not know /// the interpreter it will install on. pub fn Metadata::is_compatible( self : Metadata, name : String, version : Version, python : Version?, ) -> Bool { if canonicalize_name(name) != canonicalize_name(self.name) { return false } if Version::compare(self.version, version) != 0 { return false } match python { None => true Some(interpreter) => match self.requires_python() { None => true Some(specifiers) => specifiers.contains(interpreter) } } } ///| /// Warnings about a document that parsed successfully. A warning is never an /// error: the document is usable, it just says something the current /// specifications discourage. The list is stable and ordered — the deprecated /// field first, then one entry per deprecated classifier, then the /// description-related warning — and the exact strings are: /// /// - `deprecated: Home-page is superseded by Project-URL` when `Home-page` is /// present (PEP 753 deprecates it in favour of a `Project-URL`); /// - `classifier-not-current: ` for every `Classifier` that starts /// with `License ::`, because PEP 639 replaces those classifiers with /// `License-Expression`; /// - `description-content-type-missing` when a description is present but /// `Description-Content-Type` is not, which leaves the format of the body to the /// reader (PEP 566). pub fn Metadata::diagnostics(self : Metadata) -> Array[String] { let out : Array[String] = [] match self.home_page { Some(_) => out.push("deprecated: Home-page is superseded by Project-URL") None => () } for classifier in self.classifiers { if classifier.has_prefix("License ::") { out.push("classifier-not-current: " + classifier) } } let has_description = match self.description { Some(text) => !text.is_empty() None => false } if has_description && self.description_content_type is None { out.push("description-content-type-missing") } match self.author_email { Some(value) if !mailboxes_parsable(value) => out.push("author-email-unparsable") _ => () } match self.maintainer_email { Some(value) if !mailboxes_parsable(value) => out.push("maintainer-email-unparsable") _ => () } out } ///| /// Join `values` with `separator`; the empty list becomes the empty string. fn join_with(values : Array[String], separator : String) -> String { let out = StringBuilder() for i = 0; i < values.length(); i = i + 1 { if i > 0 { out.write_string(separator) } out.write_string(values[i]) } out.to_string() } ///| /// Emit one field, spelled the canonical way `canonical_field_name` spells it: /// header names are case-insensitive on input and canonical on output. fn write_field( out : StringBuilder, key : String, values : Array[String], ) -> Unit { let name = canonical_field_name(key) for value in values { out.write_string(name + ": " + value + "\n") } } ///| /// Emit a single-use field when it is present. fn write_single(out : StringBuilder, key : String, value : String?) -> Unit { match value { Some(text) => write_field(out, key, [text]) None => () } } ///| /// Emit a `(first, second)` field, which for `Project-URL` is `label, url`. fn write_pairs( out : StringBuilder, key : String, pairs : Array[(String, String)], ) -> Unit { for pair in pairs { let (first, second) = pair write_field(out, key, [first + ", " + second]) } } ///| /// Render the metadata as a `METADATA`/`PKG-INFO` document. /// /// The rendering is canonical, so it re-parses to the same metadata; the unit /// tests assert both the fixed point /// `to_string(parse(to_string(m))) == to_string(m)` and that every field survives /// a round trip: /// /// - the headers come in the field order of the specification rather than the /// order of the input document, and multi-use fields keep their input order; /// - every value sits on a single line, because an unfolded logical value has no /// line breaks; /// - `Version` is the normalized PEP 440 spelling and `License-Expression` is the /// canonical SPDX expression, the two fields whose value is normalized on the /// way in; /// - a non-empty `Description` is rendered as the body after the blank line, /// which is the canonical 2.x location (PEP 566). An *empty* `Description` is /// rendered as a header instead, because an empty body means "no description" /// and would not survive the round trip. pub fn Metadata::to_string(self : Metadata) -> String { let out = StringBuilder() write_field(out, "metadata-version", [self.metadata_version]) write_field(out, "name", [self.name]) write_field(out, "version", [self.version.to_string()]) write_single(out, "summary", self.summary) write_single(out, "description-content-type", self.description_content_type) if !self.keywords.is_empty() { write_field(out, "keywords", [join_with(self.keywords, ", ")]) } write_single(out, "author", self.author) write_single(out, "author-email", self.author_email) write_single(out, "maintainer", self.maintainer) write_single(out, "maintainer-email", self.maintainer_email) write_single(out, "license", self.license) write_single(out, "license-expression", self.license_expression) write_single(out, "home-page", self.home_page) write_single(out, "download-url", self.download_url) write_single(out, "requires-python", self.requires_python) write_field(out, "requires", self.requires) write_field(out, "provides", self.provides) write_field(out, "obsoletes", self.obsoletes) write_field(out, "classifier", self.classifiers) write_field(out, "requires-dist", self.requires_dist) write_field(out, "requires-external", self.requires_external) write_pairs(out, "project-url", self.project_urls) write_field(out, "provides-extra", self.provides_extra) write_field(out, "provides-dist", self.provides_dist) write_field(out, "obsoletes-dist", self.obsoletes_dist) write_field(out, "dynamic", self.dynamic) write_field(out, "platform", self.platforms) write_field(out, "supported-platform", self.supported_platforms) write_field(out, "license-file", self.license_files) write_field(out, "import-name", self.import_names) write_field(out, "import-namespace", self.import_namespaces) match self.description { Some(text) => if text.is_empty() { // An empty body is indistinguishable from no body, so the header form is // the only one that round trips. write_field(out, "description", [text]) } else { out.write_string("\n" + text) } None => () } out.to_string() } ///| pub impl Show for Metadata with fn to_string(self : Metadata) -> String { Metadata::to_string(self) }