// URI -- the `format: uri` of the Lexicon, used for external embed links,
// avatar URLs and the `uri` on a label.
//
// Ported from @atproto/syntax packages/syntax/src/uri.ts, which is all of four
// lines: `/^\w+:(?:\/\/)?[^\s/][^\s]*$/`.
//
// Deliberately loose, and worth saying why rather than tightening it. This
// format covers every scheme anyone might link to, and the values are shown to
// a person or handed to an HTTP client -- neither of which this library does.
// A validator strict enough to be useful here would have to be a full RFC 3986
// parser, and would then reject real links that browsers accept, which is a
// worse failure than passing one through.
//
// If you need "is this a link I should fetch", check the scheme yourself. This
// answers only "is this shaped like a URI at all".
///|
/// A string shaped like a URI.
pub struct Uri(String) derive(Eq, Debug)
///|
pub impl Show for Uri with fn output(self, logger) {
logger.write_string(self.0)
}
///|
pub fn Uri::to_string(self : Self) -> String {
self.0
}
///|
pub fn Uri::unchecked(uri : String) -> Uri {
Uri(uri)
}
///|
pub fn Uri::is_valid(uri : String) -> Bool {
try {
Uri::parse(uri) |> ignore
true
} catch {
_ => false
}
}
///|
/// The scheme, lower-cased -- `https` in `https://example.com`. This is the
/// part worth branching on, and the reason to have a type here at all.
pub fn Uri::scheme(self : Self) -> String {
match self.0.split_once(":") {
Some((scheme, _)) => scheme.to_owned().to_lower()
// Unreachable: `parse` requires a colon.
None => ""
}
}
///|
pub fn Uri::parse(uri : String) -> Uri raise SyntaxError {
fn bad(reason : String) -> SyntaxError {
SyntaxError(kind=Uri, input=uri, reason~)
}
guard uri.split_once(":") is Some((scheme, rest)) else {
raise bad("URI must have a scheme")
}
// `\w+` -- and note it must be non-empty, so `://x` has no scheme.
guard scheme.length() > 0 && all_chars(scheme.to_owned(), is_word_char) else {
raise bad("URI scheme must be letters, digits or underscore")
}
// The authority marker is optional: `mailto:a@b` and `did:plc:x` are URIs.
let after = if rest.to_owned().has_prefix("//") {
rest.to_owned()[2:].to_owned()
} else {
rest.to_owned()
}
// `[^\s/][^\s]*` -- non-empty, and not starting with a slash, which is what
// rejects `https:///path` and a bare `at://`.
guard after.length() > 0 else { raise bad("URI must have a path or host") }
guard !after.has_prefix("/") else {
raise bad("URI must have a path or host")
}
guard all_chars(after, c => !is_whitespace(c)) else {
raise bad("URI can not contain whitespace")
}
Uri(uri)
}
///|
/// `\w` -- ASCII letters, digits and underscore.
fn is_word_char(c : Char) -> Bool {
is_ascii_alnum(c) || c == '_'
}
///|
fn is_whitespace(c : Char) -> Bool {
c == ' ' || c == '\t' || c == '\n' || c == '\r'
}