///|
/// Percent-encoded string slices and buffers.
///|
/// A percent-encoded string.
///
/// # Type parameter
///
/// `EStr[E]` is parameterized over a type `E` that implements [`@enc.Encoder`].
/// The table `E::table()` specifies the byte patterns allowed in the string:
/// an `EStr[E]` is formed by joining any number of
///
/// - characters `ch` such that `E::table().allows(ch)`, and
/// - percent-encoded octets `"%XX"`, if `E::table().allows_pct_encoded()`.
///
/// # Comparison
///
/// `EStr`s are compared lexicographically by their code units.
/// Normalization is **not** performed prior to comparison.
///
/// # Examples
///
/// Parse key-value pairs from a query string into a map:
///
/// ```mbt check
/// test {
/// let s = "?name=%E5%BC%A0%E4%B8%89&speech=%C2%A1Ol%C3%A9%21"
/// let query = @uri.UriRef::parse(s).query.unwrap()
/// let map : Map[String, String] = Map([])
/// for pair in query.split('&') {
/// let (k, v) = pair.split_once('=').unwrap()
/// map[k.decode_to_string_lossy()] = v.decode_to_string_lossy()
/// }
/// assert_eq(map["name"], "张三")
/// assert_eq(map["speech"], "¡Olé!")
/// }
/// ```
#warnings("-unused_type_variable")
pub struct EStr[E] {
inner : String
}
///|
/// Wraps a string that is known to be properly encoded with `E`.
fn[E] estr(s : String) -> EStr[E] {
{ inner: s }
}
///|
/// An empty `EStr`.
pub fn[E] EStr::empty() -> EStr[E] {
{ inner: "" }
}
///|
/// Converts a string to an `EStr`, returning `None` if it is not
/// properly encoded with `E`.
///
/// ```mbt check
/// test {
/// let s : @uri.EStr[@enc.Path]? = @uri.EStr::new("a%20b")
/// assert_true(s is Some(_))
/// let bad : @uri.EStr[@enc.Path]? = @uri.EStr::new("a%2")
/// assert_true(bad is None)
/// }
/// ```
pub fn[E : @enc.Encoder] EStr::new(s : String) -> EStr[E]? {
if table_validate(E::table(), s) {
Some({ inner: s })
} else {
None
}
}
///|
/// Converts a string to an `EStr`.
///
/// # Panics
///
/// Panics if the string is not properly encoded with `E`.
/// For a non-panicking variant, use [`EStr::new`].
pub fn[E : @enc.Encoder] EStr::new_or_panic(s : String) -> EStr[E] {
match EStr::new(s) {
Some(v) => v
None => abort("improperly encoded string: \{s}")
}
}
///|
/// Forcefully percent-encodes the given octet to an `EStr`.
///
/// # Panics
///
/// Panics if `E::table()` does not allow percent-encoded octets.
///
/// ```mbt check
/// test {
/// let s : @uri.EStr[@enc.Path] = @uri.EStr::force_encode_byte(b'/')
/// inspect(s, content="%2F")
/// }
/// ```
pub fn[E : @enc.Encoder] EStr::force_encode_byte(x : Byte) -> EStr[E] {
guard E::table().allows_pct_encoded() else {
abort("table does not allow percent-encoded octets")
}
{ inner: encode_byte(x.to_int()) }
}
///|
/// Reinterprets the `EStr` with another encoder, assuming validity.
fn[E, F] EStr::cast(self : EStr[E]) -> EStr[F] {
{ inner: self.inner }
}
///|
/// Returns the `EStr` as a string.
pub fn[E] EStr::as_str(self : EStr[E]) -> String {
self.inner
}
///|
/// Returns the length of the `EStr` in code units.
pub fn[E] EStr::length(self : EStr[E]) -> Int {
self.inner.length()
}
///|
/// Checks whether the `EStr` is empty.
pub fn[E] EStr::is_empty(self : EStr[E]) -> Bool {
self.inner.is_empty()
}
///|
pub impl[E] Eq for EStr[E] with fn equal(self, other) {
self.inner == other.inner
}
///|
pub impl[E] Compare for EStr[E] with fn compare(self, other) {
self.inner.compare(other.inner)
}
///|
pub impl[E] Hash for EStr[E] with fn hash_combine(self, hasher) {
hasher.combine_string(self.inner)
}
///|
pub impl[E] Show for EStr[E] with fn output(self, logger) {
logger.write_string(self.inner)
}
///|
pub impl[E] Debug for EStr[E] with fn to_repr(self) {
Repr(self.inner)
}
///|
/// An error raised when percent-decoded octets are not valid UTF-8.
pub(all) suberror DecodeError {
/// The octets that failed to decode.
NotUtf8(bytes~ : Bytes)
} derive(Eq, Debug)
///|
pub impl Show for DecodeError with fn output(_self, logger) {
logger.write_string("percent-decoded octets are not valid UTF-8")
}
///|
/// Percent-decodes the `EStr` into octets.
///
/// Always **split before decoding**, as otherwise the data may be
/// mistaken for component delimiters.
///
/// Note that `U+002B` (+) is **not** decoded as `0x20` (space).
///
/// # Panics
///
/// Panics if `E::table()` does not allow percent-encoded octets.
///
/// ```mbt check
/// test {
/// let s : @uri.EStr[@enc.Path] = @uri.EStr::new_or_panic("%C2%A1Hola%21")
/// inspect(
/// s.decode_to_bytes(),
/// content=(
/// #|b"\xc2\xa1Hola!"
/// ),
/// )
/// }
/// ```
pub fn[E : @enc.Encoder] EStr::decode_to_bytes(self : EStr[E]) -> Bytes {
self.assert_decodable()
Bytes::from_array(decode_to_byte_array(self.inner))
}
///|
/// Percent-decodes the `EStr` into a string.
///
/// # Errors
///
/// Raises [`DecodeError`] if the decoded octets are not valid UTF-8.
///
/// # Panics
///
/// Panics if `E::table()` does not allow percent-encoded octets.
///
/// ```mbt check
/// test {
/// let s : @uri.EStr[@enc.Path] = @uri.EStr::new_or_panic("%C2%A1Hola%21")
/// inspect(s.decode_to_string(), content="¡Hola!")
/// }
/// ```
pub fn[E : @enc.Encoder] EStr::decode_to_string(
self : EStr[E],
) -> String raise DecodeError {
self.assert_decodable()
let sb = StringBuilder::new(size_hint=self.inner.length())
let mut ok = true
decode_walk(self.inner, s => sb.write_string(s), (valid, invalid) => {
sb.write_string(valid)
if !invalid.is_empty() {
ok = false
}
})
if !ok {
raise NotUtf8(bytes=Bytes::from_array(decode_to_byte_array(self.inner)))
}
sb.to_string()
}
///|
/// Percent-decodes the `EStr` into a string, replacing any octets that are not
/// valid UTF-8 with `U+FFFD`.
///
/// # Panics
///
/// Panics if `E::table()` does not allow percent-encoded octets.
///
/// ```mbt check
/// test {
/// let s : @uri.EStr[@enc.Path] = @uri.EStr::new_or_panic("%C2Hi%FF")
/// inspect(s.decode_to_string_lossy(), content="�Hi�")
/// }
/// ```
pub fn[E : @enc.Encoder] EStr::decode_to_string_lossy(self : EStr[E]) -> String {
self.assert_decodable()
let sb = StringBuilder::new(size_hint=self.inner.length())
decode_walk(self.inner, s => sb.write_string(s), (valid, invalid) => {
sb.write_string(valid)
if !invalid.is_empty() {
sb.write_char('\u{FFFD}')
}
})
sb.to_string()
}
///|
fn[E : @enc.Encoder] EStr::assert_decodable(_self : EStr[E]) -> Unit {
guard E::table().allows_pct_encoded() else {
abort("table does not allow percent-encoded octets")
}
}
///|
/// Returns an iterator over the subslices of the `EStr` separated by `delim`.
///
/// # Panics
///
/// Panics if `delim` is not a [reserved] character.
///
/// [reserved]: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
///
/// ```mbt check
/// test {
/// let s : @uri.EStr[@enc.Path] = @uri.EStr::new_or_panic("a,b,c")
/// assert_eq(s.split(',').map(v => v.as_str()).to_array(), ["a", "b", "c"])
/// }
/// ```
pub fn[E] EStr::split(self : EStr[E], delim : Char) -> Iter[EStr[E]] {
assert_reserved(delim)
self.inner.split(delim.to_string()).map(v => estr(v.to_owned()))
}
///|
/// Splits the `EStr` on the first occurrence of `delim`, returning the prefix
/// before it and the suffix after it, or `None` if it is not found.
///
/// # Panics
///
/// Panics if `delim` is not a [reserved] character.
///
/// [reserved]: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
pub fn[E] EStr::split_once(self : EStr[E], delim : Char) -> (EStr[E], EStr[E])? {
assert_reserved(delim)
match self.inner.split_once(delim.to_string()) {
Some((a, b)) => Some((estr(a.to_owned()), estr(b.to_owned())))
None => None
}
}
///|
/// Splits the `EStr` on the last occurrence of `delim`, returning the prefix
/// before it and the suffix after it, or `None` if it is not found.
///
/// # Panics
///
/// Panics if `delim` is not a [reserved] character.
///
/// [reserved]: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
pub fn[E] EStr::rsplit_once(
self : EStr[E],
delim : Char,
) -> (EStr[E], EStr[E])? {
assert_reserved(delim)
match self.inner.rev_find(delim.to_string()) {
Some(i) =>
Some(
(
estr(slice(self.inner, 0, i)),
estr(slice(self.inner, i + 1, self.inner.length())),
),
)
None => None
}
}
///|
fn assert_reserved(delim : Char) -> Unit {
guard @enc.table_reserved.allows(delim) else {
abort("splitting with non-reserved character")
}
}
///|
/// Checks whether the path is absolute, i.e., starting with `'/'`.
pub fn[E : @enc.PathEncoder] EStr::is_absolute(self : EStr[E]) -> Bool {
self.inner.has_prefix("/")
}
///|
/// Checks whether the path is rootless, i.e., not starting with `'/'`.
pub fn[E : @enc.PathEncoder] EStr::is_rootless(self : EStr[E]) -> Bool {
!self.inner.has_prefix("/")
}
///|
/// Returns an iterator over the path segments, separated by `'/'`,
/// or `None` if the path is [rootless](EStr::is_rootless).
///
/// Note that the path can be empty when an authority is present,
/// in which case this method returns `None`.
///
/// ```mbt check
/// test {
/// let path = @uri.Uri::parse("file:///path/to//dir/").path
/// assert_eq(
/// path.segments_if_absolute().unwrap().map(v => v.as_str()).to_array(),
/// ["path", "to", "", "dir", ""],
/// )
/// let path = @uri.Uri::parse("foo:bar/baz").path
/// assert_true(path.segments_if_absolute() is None)
/// }
/// ```
pub fn[E : @enc.PathEncoder] EStr::segments_if_absolute(
self : EStr[E],
) -> Iter[EStr[E]]? {
if self.inner.has_prefix("/") {
let rest : EStr[E] = estr(slice(self.inner, 1, self.inner.length()))
Some(rest.split('/'))
} else {
None
}
}
///|
/// A percent-encoded, growable string; the owned counterpart of [`EStr`].
///
/// # Examples
///
/// Encode key-value pairs into a query string and use it to build a
/// URI reference:
///
/// ```mbt check
/// test {
/// let pairs = [("name", "张三"), ("speech", "¡Olé!")]
/// let buf : @uri.EString[@enc.Query] = @uri.EString::new()
/// for pair in pairs {
/// let (k, v) = pair
/// if !buf.is_empty() {
/// buf.push('&')
/// }
/// // WARNING: Absolutely do not confuse data with delimiters!
/// // Use `@enc.Data` (or `@enc.IData`) to encode data contained in a URI
/// // (or an IRI) unless you know what you're doing!
/// buf.encode(k, table=@enc.table_data)
/// buf.push('=')
/// buf.encode(v, table=@enc.table_data)
/// }
/// inspect(buf, content="name=%E5%BC%A0%E4%B8%89&speech=%C2%A1Ol%C3%A9%21")
/// }
/// ```
pub struct EString[E] {
mut buf : String
}
///|
/// Creates a new empty `EString`.
pub fn[E] EString::new() -> EString[E] {
{ buf: "" }
}
///|
/// Returns the `EString` as an [`EStr`].
pub fn[E] EString::to_estr(self : EString[E]) -> EStr[E] {
{ inner: self.buf }
}
///|
/// Checks whether the `EString` is empty.
pub fn[E] EString::is_empty(self : EString[E]) -> Bool {
self.buf.is_empty()
}
///|
/// Truncates the `EString`, removing all contents.
pub fn[E] EString::clear(self : EString[E]) -> Unit {
self.buf = ""
}
///|
/// Percent-encodes `s` with `table` and appends the result.
///
/// `table` should be the table of a sub-encoder of `E`, typically
/// [`@enc.table_data`] or [`@enc.table_idata`]: data contained in a URI/IRI must be
/// encoded more strictly than the component that holds it, or it would be
/// mistaken for delimiters.
///
/// # Panics
///
/// Panics if `table` is not a subset of `E::table()`, or if `table`
/// does not allow percent-encoded octets.
pub fn[E : @enc.Encoder] EString::encode(
self : EString[E],
s : String,
table~ : @enc.Table,
) -> Unit {
guard table.is_subset(E::table()) else { abort("not a sub-encoder") }
guard table.allows_pct_encoded() else {
abort("table does not allow percent-encoded octets")
}
let sb = StringBuilder::new(size_hint=s.length() * 3)
encode_str(sb, table, s)
self.buf = self.buf + sb.to_string()
}
///|
/// Appends a character to the `EString`.
///
/// # Panics
///
/// Panics if `E::table()` does not allow the character.
pub fn[E : @enc.Encoder] EString::push(self : EString[E], ch : Char) -> Unit {
guard E::table().allows(ch) else { abort("table does not allow the char") }
self.buf = self.buf + ch.to_string()
}
///|
/// Appends an [`EStr`] to the `EString`.
pub fn[E] EString::push_estr(self : EString[E], s : EStr[E]) -> Unit {
self.buf = self.buf + s.inner
}
///|
pub impl[E] Show for EString[E] with fn output(self, logger) {
logger.write_string(self.buf)
}
///|
pub impl[E] Debug for EString[E] with fn to_repr(self) {
Repr(self.buf)
}
///|
pub impl[E] Eq for EString[E] with fn equal(self, other) {
self.buf == other.buf
}
// The encoding and decoding routines.
///|
/// Percent-encodes `s` with `table`, appending the result to `sb`.
///
/// Characters that the table allows are kept as they are, and lowercased
/// beforehand if `to_ascii_lowercase` is set.
fn encode_str(
sb : StringBuilder,
table : @enc.Table,
s : String,
to_ascii_lowercase? : Bool = false,
) -> Unit {
let len = s.length()
let mut i = 0
let scratch : Array[Byte] = []
while i < len {
let (cp, w) = next_code_point(s, i)
if table.allows_code_point(cp) {
if to_ascii_lowercase && cp >= 'A'.to_int() && cp <= 'Z'.to_int() {
sb.write_char((cp + 32).to_char().unwrap())
} else {
sb.write_substring(s, i, w)
}
} else {
scratch.clear()
utf8_encode_code_point(scratch, cp)
for x in scratch {
push_encoded_byte(sb, x.to_int())
}
}
i += w
}
}
///|
/// Percent-decodes `s` into octets.
fn decode_to_byte_array(s : String) -> Array[Byte] {
let out : Array[Byte] = []
let len = s.length()
let mut i = 0
while i < len {
let x = code_at(s, i)
if x == '%' {
out.push(decode_octet(code_at(s, i + 1), code_at(s, i + 2)).to_byte())
i += 3
} else {
let (cp, w) = next_code_point(s, i)
utf8_encode_code_point(out, cp)
i += w
}
}
out
}
///|
/// Walks the decoded contents of `s`.
///
/// `on_unencoded` is called with each maximal run of characters that are not
/// percent-encoded, and `on_decoded` with the UTF-8 chunks (see
/// [`utf8_chunks`]) of each maximal run of percent-decoded octets. Runs are
/// visited in order of appearance.
fn decode_walk(
s : String,
on_unencoded : (String) -> Unit,
on_decoded : (String, ArrayView[Byte]) -> Unit,
) -> Unit {
let len = s.length()
let buf : Array[Byte] = []
let mut i = 0
while i < len {
let start = i
while i < len && code_at(s, i) != '%' {
i += 1
}
if i > start {
if !buf.is_empty() {
utf8_chunks(buf[:], on_decoded)
buf.clear()
}
on_unencoded(slice(s, start, i))
}
while i < len && code_at(s, i) == '%' {
buf.push(decode_octet(code_at(s, i + 1), code_at(s, i + 2)).to_byte())
i += 3
}
}
if !buf.is_empty() {
utf8_chunks(buf[:], on_decoded)
}
}
///|
/// Walks the decoded contents of `s`, one percent-decoded octet at a time.
///
/// `on_unencoded` is called with each maximal run of characters that are not
/// percent-encoded, and `on_octet` with each percent-decoded octet.
fn decode_walk_octets(
s : String,
on_unencoded : (String) -> Unit,
on_octet : (Int) -> Unit,
) -> Unit {
let len = s.length()
let mut i = 0
while i < len {
let start = i
while i < len && code_at(s, i) != '%' {
i += 1
}
if i > start {
on_unencoded(slice(s, start, i))
}
while i < len && code_at(s, i) == '%' {
on_octet(decode_octet(code_at(s, i + 1), code_at(s, i + 2)))
i += 3
}
}
}