// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// How the bytes of a body turn into the text they carry, and back. Three come
/// with the library; everything else arrives through this trait, so a codebase
/// that needs windows-1252 does not need a fork of this one.
pub(open) trait Charset {
fn decode(Self, BytesView) -> String raise Refused
fn encode(Self, StringView) -> Bytes raise Refused
}
///|
priv struct Utf8 {}
///|
impl Charset for Utf8 with fn decode(_self : Utf8, data : BytesView) -> String raise Refused {
@utf8.decode_lossy(data)
}
///|
impl Charset for Utf8 with fn encode(_self : Utf8, text : StringView) -> Bytes raise Refused {
@utf8.encode(text)
}
///|
priv struct Ascii {}
///|
/// US-ASCII in name, Latin-1 in tolerance: mail that declares ASCII and carries
/// a stray high octet is too common to refuse, so it is read as ISO-8859-1 and
/// never the other way around — encoding refuses what cannot be written.
impl Charset for Ascii with fn decode(_self : Ascii, data : BytesView) -> String raise Refused {
latin1_text(data)
}
///|
impl Charset for Ascii with fn encode(_self : Ascii, text : StringView) -> Bytes raise Refused {
let out = Buffer()
for c in text {
let n = code(c)
if n > 127 {
raise Refused::Charset(name="us-ascii")
}
out.write_byte(n.to_byte())
}
out.to_bytes()
}
///|
priv struct Latin1 {}
///|
impl Charset for Latin1 with fn decode(_self : Latin1, data : BytesView) -> String raise Refused {
latin1_text(data)
}
///|
impl Charset for Latin1 with fn encode(_self : Latin1, text : StringView) -> Bytes raise Refused {
let out = Buffer()
for c in text {
let n = code(c)
if n > 255 {
raise Refused::Charset(name="iso-8859-1")
}
out.write_byte(n.to_byte())
}
out.to_bytes()
}
///|
/// One octet, one character: the identity mapping the ISO-8859 family shares,
/// and what a message is read as when the declared set cannot represent what it
/// carries. Undecodable encoder keeps going, so a partially known body survives.
fn latin1_text(data : BytesView) -> String {
let out = StringBuilder()
for b in data {
out.write_char(b.to_char())
}
out.to_string()
}
///|
/// The sets that come with the library, by every name they answer to.
fn builtin(name : String) -> &Charset? {
match name {
"utf-8" | "utf8" => Some(Utf8::{ })
"us-ascii" | "ascii" => Some(Ascii::{ })
"iso-8859-1" | "latin1" | "l1" => Some(Latin1::{ })
_ => None
}
}
///|
/// The codec for `name`: the caller's own first, so a name can be aliased or
/// a shipped set replaced, then the three that ship. `charsets` is keyed by
/// lower-case names.
fn lookup(
name : StringView,
charsets : Map[String, &Charset]?,
) -> &Charset raise Refused {
let known = lower(name)
if charsets is Some(map) && map.get(known) is Some(codec) {
return codec
}
builtin(known).unwrap_or_error(Refused::Charset(name=known))
}
///|
/// Read `data` as `name` says it was written. Unmapping octets become U+FFFD so
/// that one bad byte costs one character and not the whole part.
///
/// ```mbt check
/// test {
/// let text = @moonmime.decode_charset(name="iso-8859-1", data=b"\xe9") catch {
/// _ => ""
/// }
/// inspect(text, content="é")
/// }
/// ```
pub fn decode_charset(
name~ : StringView,
data~ : BytesView,
charsets? : Map[String, &Charset],
) -> String raise Refused {
lookup(name, charsets).decode(data)
}
///|
/// Write `text` in `name`, refusing a character the set has no room for.
///
/// ```mbt check
/// test {
/// let bytes = @moonmime.encode_charset(name="iso-8859-1", text="é") catch {
/// _ => b""
/// }
/// inspect(bytes == b"\xe9", content="true")
/// }
/// ```
pub fn encode_charset(
name~ : StringView,
text~ : StringView,
charsets? : Map[String, &Charset],
) -> Bytes raise Refused {
lookup(name, charsets).encode(text)
}
///|
/// A tiny codec used only to prove a caller's own charset is dispatched to:
/// Caesar-1 over ASCII, so `a`→`b` and `b`→`c`.
priv struct Shift1 {}
///|
impl Charset for Shift1 with fn decode(_self : Shift1, data : BytesView) -> String raise Refused {
let out = Buffer()
for b in data {
out.write_byte((b.to_int() + 1).to_byte())
}
@utf8.decode_lossy(out.to_bytes()[:])
}
///|
impl Charset for Shift1 with fn encode(_self : Shift1, text : StringView) -> Bytes raise Refused {
let out = Buffer()
for c in text {
out.write_byte((c.to_int() - 1).to_byte())
}
out.to_bytes()
}
///|
/// A caller's codec answers by its lower-cased name, before the shipped sets.
test {
let charsets : Map[String, &Charset] = { "x-shift1": Shift1::{ } }
inspect(
decode_charset(name="X-Shift1", data=@utf8.encode("bcde")[:], charsets~),
content="cdef",
)
let latin : Map[String, &Charset] = { "iso-8859-1": Shift1::{ } }
inspect(
decode_charset(name="iso-8859-1", data=b"bcde", charsets=latin),
content="cdef",
)
}
///|
/// Encoding is allowed to refuse what cannot be written, while decoding is not:
/// US-ASCII has no room for `é`, so the encoder reports `Charset`.
test {
let raised = try {
let _ = encode_charset(name="us-ascii", text="é")
false
} catch {
_ => true
}
inspect(raised, content="true")
}