// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A media type: `text/plain; charset=utf-8`.
///
/// `essence` is the lowercased `type/subtype`; `params` keeps the parameters in
/// order, names lowercased and quoted values unquoted. A malformed parameter is
/// dropped rather than guessed at.
#derive(Eq, Debug)
pub(all) struct MediaType {
essence : String
params : Array[(String, String)]
}
///|
pub extend MediaType with Eq::{equal, not_equal}
///|
pub extend MediaType with Debug::{to_repr}
///|
/// Parse a header value into a media type. Lenient: anything that is not a
/// parameter is left out, so an empty or foreign value yields an empty essence
/// rather than an error.
pub fn media_type(s : StringView) -> MediaType {
let semi = index_char(s, 0x3B, 0)
let head = if semi < 0 { s } else { s[0:semi] }
let params : Array[(String, String)] = if semi < 0 {
[]
} else {
parse_params(s[semi + 1:])
}
{ essence: trim(head).to_lower(), params, }
}
///|
/// Parse a header value the way the MIME Sniffing standard reads one, and
/// refuse the rest: `None` when the value is not a `type/subtype` of tokens.
///
/// Two readers, two jobs. `media_type` above takes whatever a server sent and
/// keeps what it can; this one answers "is this a media type at all", which is
/// the question worth asking when a value decides what a file is. A parameter
/// whose name is not a token, or whose value cannot be written into a quoted
/// string, is dropped; the first of two with the same name wins. Both are the
/// standard's readings.
///
/// ```mbt check
/// test {
/// let mt = @moonmime.MediaType::parse("Text/HTML; charset=\"utf-8\"").unwrap()
/// inspect(mt.essence, content="text/html")
/// inspect(mt.param("charset").unwrap_or(""), content="utf-8")
/// inspect(
/// @moonmime.MediaType::parse("text/html, text/plain") is None,
/// content="true",
/// )
/// }
/// ```
pub fn MediaType::parse(s : StringView) -> MediaType? {
let text = trim(s)
let n = text.length()
let mut i = 0
let type_start = i
while i < n && text.at(i).to_int() != 0x2F {
i += 1
}
let kind = text[type_start:i]
guard i < n && is_token(kind) else { return None }
i += 1
let subtype_start = i
while i < n && text.at(i).to_int() != 0x3B {
i += 1
}
let subtype = trim_end(text[subtype_start:i])
guard is_token(subtype) else { return None }
let params : Array[(String, String)] = []
while i < n {
i += 1
while i < n && is_space(text.at(i).to_int()) {
i += 1
}
let name_start = i
while i < n && text.at(i).to_int() != 0x3B && text.at(i).to_int() != 0x3D {
i += 1
}
let name = text[name_start:i].to_lower().to_owned()
if i < n {
// A `;` where the `=` should be: this parameter has no value at all.
if text.at(i).to_int() == 0x3B {
continue
}
i += 1
}
// The name ends the value: `text/html;charset` is still a media type.
if i >= n {
break
}
let value : String? = if text.at(i).to_int() == 0x22 {
let (quoted, after) = collect_quoted(text, i)
i = after
// Junk between the closing quote and the next `;` is not a parameter.
while i < n && text.at(i).to_int() != 0x3B {
i += 1
}
Some(quoted)
} else {
let value_start = i
while i < n && text.at(i).to_int() != 0x3B {
i += 1
}
let bare = trim_end(text[value_start:i])
if bare.is_empty() {
None
} else {
Some(bare.to_owned())
}
}
match value {
Some(v) =>
if !name.is_empty() &&
is_token(name[:]) &&
is_quoted_string_token(v[:]) &&
!has_name(params, name) {
params.push((name, v))
}
None => ()
}
}
let out = StringBuilder()
out.write_string(kind.to_lower().to_owned())
out.write_char('/')
out.write_string(subtype.to_lower().to_owned())
Some({ essence: out.to_string(), params, })
}
///|
/// The type half: `text` in `text/plain`.
pub fn MediaType::type_(self : MediaType) -> String {
match index_char(self.essence[:], 0x2F, 0) {
-1 => self.essence
at => self.essence[0:at].to_owned()
}
}
///|
/// The subtype half: `plain` in `text/plain`, empty when there is no slash.
pub fn MediaType::subtype(self : MediaType) -> String {
match index_char(self.essence[:], 0x2F, 0) {
-1 => ""
at => self.essence[at + 1:].to_owned()
}
}
///|
/// A parameter by lowercased name, quoted or bare.
pub fn MediaType::param(self : MediaType, name : StringView) -> String? {
for pair in self.params {
if pair.0[:] == name {
return Some(pair.1)
}
}
None
}
///|
/// Back to a header value. A value that is not a token goes out quoted, which
/// keeps a boundary with spaces or quotes in one piece.
pub impl Show for MediaType with fn to_string(self) {
let out = StringBuilder()
out.write_string(self.essence)
for pair in self.params {
out.write_char(';')
out.write_char(' ')
out.write_string(pair.0)
out.write_char('=')
if is_token(pair.1[:]) {
out.write_string(pair.1)
} else {
out.write_char('"')
for i in 0.. Array[(String, String)] {
let out : Array[(String, String)] = []
let n = s.length()
let mut i = 0
while i < n {
while i < n && (is_space(s.at(i).to_int()) || s.at(i).to_int() == 0x3B) {
i += 1
}
let name_start = i
while i < n &&
s.at(i).to_int() != 0x3D &&
s.at(i).to_int() != 0x3B &&
!is_space(s.at(i).to_int()) {
i += 1
}
let mut name_end = i
while name_end > name_start && is_space(s.at(name_end - 1).to_int()) {
name_end -= 1
}
while i < n && is_space(s.at(i).to_int()) {
i += 1
}
guard i < n && s.at(i).to_int() == 0x3D else {
while i < n && s.at(i).to_int() != 0x3B {
i += 1
}
continue
}
let name = s[name_start:name_end].to_owned()
i += 1
while i < n && is_space(s.at(i).to_int()) {
i += 1
}
if i < n && s.at(i).to_int() == 0x22 {
i += 1
let value = StringBuilder()
while i < n && s.at(i).to_int() != 0x22 {
if s.at(i).to_int() == 0x5C && i + 1 < n {
i += 1
}
value.write_view(s[i:i + 1])
i += 1
}
if i < n {
i += 1
}
out.push((name.to_lower(), value.to_string()))
} else {
let value_start = i
while i < n && s.at(i).to_int() != 0x3B {
i += 1
}
out.push((name.to_lower(), trim(s[value_start:i])))
}
}
out
}
///|
/// Whether a value can stand bare in a parameter.
fn is_token(s : StringView) -> Bool {
if s.is_empty() {
return false
}
for i in 0.. Bool {
match c {
'!'
| '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '.'
| '^'
| '_'
| '`'
| '|'
| '~'
| '0'..<='9'
| 'A'..<='Z'
| 'a'..<='z' => true
_ => false
}
}
///|
fn trim(s : StringView) -> String {
let mut from = 0
let mut to = s.length()
while from < to && is_space(s.at(from).to_int()) {
from += 1
}
while to > from && is_space(s.at(to - 1).to_int()) {
to -= 1
}
s[from:to].to_owned()
}
///|
fn trim_end(s : StringView) -> StringView {
let mut to = s.length()
while to > 0 && is_space(s.at(to - 1).to_int()) {
to -= 1
}
s[0:to]
}
///|
/// A quoted string, read from the opening quote at `from`, with its escapes
/// resolved and the quotes gone. Also answers where reading stopped, so the
/// caller can skip whatever followed the closing quote.
fn collect_quoted(s : StringView, from : Int32) -> (String, Int32) {
let n = s.length()
let out = StringBuilder()
let mut i = from + 1
while i < n {
let c = s.at(i).to_int()
if c == 0x22 {
return (out.to_string(), i + 1)
}
if c == 0x5C {
// A backslash with nothing behind it is part of the value, not an escape.
if i + 1 >= n {
out.write_char('\\')
return (out.to_string(), n)
}
out.write_view(s[i + 1:i + 2])
i += 2
continue
}
out.write_view(s[i:i + 1])
i += 1
}
(out.to_string(), n)
}
///|
fn has_name(params : Array[(String, String)], name : String) -> Bool {
for pair in params {
if pair.0 == name {
return true
}
}
false
}
///|
/// The characters a quoted-string may carry: TAB, printable ASCII, and the
/// upper half of Latin-1. A value outside them cannot be written back out.
fn is_quoted_string_token(s : StringView) -> Bool {
for i in 0..= 0x20 && c <= 0x7E) && !(c >= 0x80 && c <= 0xFF) {
return false
}
}
true
}
///|
fn is_space(c : Int32) -> Bool {
c == 0x20 || c == 0x09 || c == 0x0D || c == 0x0A
}
///|
fn index_char(s : StringView, needle : Int32, from : Int32) -> Int32 {
for i = from; i < s.length(); i = i + 1 {
if s.at(i).to_int() == needle {
return i
}
}
-1
}