// RFC 3966 `tel:` URIs — the form a phone number takes in a web page, a vCard
// or an SMS link.
//
// The grammar has two subscriber shapes and a small set of parameters:
//
// tel:+1-201-555-0123;ext=42
// global number, optionally with an extension or an ISDN subaddress
//
// tel:7042;phone-context=+1-919
// local number, which is *required* to carry a phone-context
//
// Parsing is separated from resolution on purpose. A local number with a domain
// context (`example.com`) is a perfectly valid URI, but it cannot be turned into
// an E.164 number without asking that domain what it means. The old code merged
// the context into the digits and thereby lost both facts; here the parse keeps
// the URI intact and only resolution decides whether it is answerable.
//
// The parse and the serialisation are inverses: for any TelUri produced by
// `parse_tel_uri_full`, `parse_tel_uri_full(to_tel_uri_full(t))` is `t`.
///|
/// The parts of a `tel:` URI, kept apart.
///
/// Exactly one of `global_number` and `local_number` is non-empty. A local
/// number always has a non-empty `phone_context`; a global number never does.
/// `extension` and `isub` are mutually exclusive, as RFC 3966 requires.
/// `global_number` is canonical (`+` and digits). `local_number` keeps digits,
/// `A`–`F`, `*` and `#`, with the visual separators removed.
pub(all) struct TelUri {
global_number : String
local_number : String
phone_context : String
extension : String
isub : String
/// Generic parameters (`;pname` or `;pname=pvalue`) that this library does not
/// interpret. RFC 3966 lets other documents define them, so they are kept
/// and written back in lexicographical order rather than refused.
parameters : Array[(String, String)]
} derive(Eq, Debug)
///|
/// `Eq` promotes both methods; name them so the promotion is stated rather than
/// implied.
pub extend TelUri with Eq::{not_equal, equal}
///|
/// `Debug` promotes `to_repr` the same way; name it too.
pub extend TelUri with @moonbitlang/core/debug.Debug::{to_repr}
///|
/// Render as the canonical URI; an impossible combination of fields renders as
/// `tel:invalid` rather than panicking.
pub impl Show for TelUri with fn to_string(self : TelUri) -> String {
match to_tel_uri_full(self) {
Some(v) => v
None => "tel:invalid"
}
}
///|
/// `Show` promotes both `output` and `to_string`; name them so the promotion is
/// stated rather than implied (the compiler flags the alternative, E0079).
pub extend TelUri with Show::{output, to_string}
///|
/// The visual separators RFC 3966 allows inside a telephone-subscriber: hyphen,
/// dot and the two parentheses. Notably not the space.
fn is_tel_visual_separator(c : Char) -> Bool {
match c {
'-' | '.' | '(' | ')' => true
_ => false
}
}
///|
/// True when `c` is a hexadecimal digit letter (the A–F of local numbers).
fn is_hex_letter(c : Char) -> Bool {
(c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')
}
///|
/// One DNS label: non-empty, alphanumeric or hyphen, and not starting or ending
/// with a hyphen.
fn is_domain_label(s : String) -> Bool {
let cs = chars_of(s)
if cs.length() == 0 {
return false
}
if cs[0] == '-' || cs[cs.length() - 1] == '-' {
return false
}
for c in cs {
let letter = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
if !letter && !is_digit(c) && c != '-' {
return false
}
}
true
}
///|
/// True when `s` is a domain name: one or more non-empty labels joined by dots.
fn is_domain_name(s : String) -> Bool {
for label in split_on(s, '.') {
if !is_domain_label(label) {
return false
}
}
true
}
///|
/// Canonicalise a global number (which starts with `+`): digits and visual
/// separators, with at least one digit. Returns `+` followed by the digits.
fn canonical_global_number(s : String) -> String? {
let cs = chars_of(s)
let buf = StringBuilder()
let mut any = false
for i = 1; i < cs.length(); i = i + 1 {
let c = cs[i]
if is_digit(c) {
buf.write_char(c)
any = true
} else if is_tel_visual_separator(c) {
()
} else {
return None
}
}
if any {
Some("+" + buf.to_string())
} else {
None
}
}
///|
/// Canonicalise a local number: digits, `A`–`F` (uppercased), `*`, `#` and
/// visual separators, with at least one of the former. Separators are dropped.
fn canonical_local_number(s : String) -> String? {
let buf = StringBuilder()
let mut any = false
for c in s {
if is_digit(c) || c == '*' || c == '#' {
buf.write_char(c)
any = true
} else if is_hex_letter(c) {
buf.write_char(upper_ascii(c))
any = true
} else if is_tel_visual_separator(c) {
()
} else {
return None
}
}
if any {
Some(buf.to_string())
} else {
None
}
}
///|
/// Canonicalise an extension: digits and visual separators, at least one digit.
fn canonical_extension(s : String) -> String? {
let buf = StringBuilder()
let mut any = false
for c in s {
if is_digit(c) {
buf.write_char(c)
any = true
} else if is_tel_visual_separator(c) {
()
} else {
return None
}
}
if any {
Some(buf.to_string())
} else {
None
}
}
///|
/// Canonicalise a phone-context descriptor: either `+` and at least one digit
/// (visual separators allowed and dropped), or a domain name in lower case.
fn canonical_phone_context(value : String) -> String? {
if starts_with(value, "+") {
let cs = chars_of(value)
let buf = StringBuilder()
let mut any = false
for i = 1; i < cs.length(); i = i + 1 {
let c = cs[i]
if is_digit(c) {
buf.write_char(c)
any = true
} else if is_tel_visual_separator(c) {
()
} else {
return None
}
}
if any {
Some("+" + buf.to_string())
} else {
None
}
} else if is_domain_name(value) {
let buf = StringBuilder()
for c in value {
buf.write_char(lower_ascii(c))
}
Some(buf.to_string())
} else {
None
}
}
///|
/// True for the characters an `isub` value may contain on its own. RFC 3966
/// makes it a run of URI characters (`uric`: unreserved, reserved and escapes),
/// not a number, so it is preserved rather than parsed.
fn is_isub_char(c : Char) -> Bool {
// `uric` is the reserved set plus the unreserved one; the two members a
// parameter grammar would otherwise swallow are the semicolon and the
// question mark, which are legal inside an isdn-subaddress.
if c == ';' || c == '?' {
return true
}
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || is_digit(c) {
return true
}
// U+0027, the apostrophe: a sub-delimiter of RFC 3986, and one this grammar
// cannot spell as a character literal.
if c.to_int() == 39 {
return true
}
match c {
'-' | '.' | '_' | '~' | '!' | '$' | '&' | '(' | ')' | '*' | '+' | ',' | '=' | ':' | '@' | '/' =>
true
_ => false
}
}
///|
/// True for a hexadecimal digit, in either case.
fn is_hex_digit(c : Char) -> Bool {
is_digit(c) || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')
}
///|
/// Canonicalise an `isub` value: non-empty, every character a URI character, and
/// every `%` the start of a complete `%HH` escape. Hex digits inside an escape
/// are uppercased so the value round-trips to one spelling.
fn canonical_isub(s : String) -> String? {
let cs = chars_of(s)
if cs.length() == 0 {
return None
}
let buf = StringBuilder()
let mut i = 0
while i < cs.length() {
let c = cs[i]
if c == '%' {
if i + 2 >= cs.length() || !is_hex_digit(cs[i + 1]) || !is_hex_digit(cs[i + 2]) {
return None
}
buf.write_char('%')
buf.write_char(upper_ascii(cs[i + 1]))
buf.write_char(upper_ascii(cs[i + 2]))
i = i + 3
} else if is_isub_char(c) {
buf.write_char(c)
i = i + 1
} else {
return None
}
}
Some(buf.to_string())
}
///|
/// True for a parameter name: alphanumerics and hyphens, at least one.
fn is_pname(s : String) -> Bool {
if s == "" {
return false
}
for c in s {
let letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
if !letter && !is_digit(c) && c != '-' {
return false
}
}
true
}
///|
/// True for a generic parameter value: a run of paramchar, where a percent
/// sign must start a complete escape.
fn is_param_value(s : String) -> Bool {
let cs = chars_of(s)
if cs.length() == 0 {
return false
}
let mut i = 0
while i < cs.length() {
let c = cs[i]
if c == '%' {
if i + 2 >= cs.length() || !is_hex_digit(cs[i + 1]) || !is_hex_digit(cs[i + 2]) {
return false
}
i = i + 3
} else if is_param_char(c) {
i = i + 1
} else {
return false
}
}
true
}
///|
/// The characters RFC 3966 allows in a generic parameter value.
fn is_param_char(c : Char) -> Bool {
let letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
if letter || is_digit(c) {
return true
}
// U+0027, a mark character this grammar cannot write as a literal.
if c.to_int() == 39 {
return true
}
match c {
'-' | '_' | '.' | '!' | '~' | '*' | '(' | ')' | '[' | ']' | '/' | ':' | '&' | '+' | '$' => true
_ => false
}
}
///|
/// The parameters in lexicographical order, the order RFC 3966 asks for.
fn sort_parameters(input : Array[(String, String)]) -> Array[(String, String)] {
let out : Array[(String, String)] = []
for p in input {
out.push(p)
}
let mut i = 1
while i < out.length() {
let cur = out[i]
let mut j = i
while j > 0 && out[j - 1].0 > cur.0 {
out[j] = out[j - 1]
j = j - 1
}
out[j] = cur
i = i + 1
}
out
}
///|
/// Parse a `tel:` URI into its parts, without resolving anything.
///
/// The scheme and parameter names are case-insensitive. Unknown parameters,
/// empty parameters, duplicate parameters, empty values and an `ext`/`isub`
/// conflict are rejected rather than dropped: a URI this parser cannot fully
/// represent is better refused than quietly changed. Spaces are not visual
/// separators (RFC 3966 §5.1.1), so only the outside of the URI is trimmed.
pub fn parse_tel_uri_full(uri : String) -> TelUri? {
let s = trim(uri)
if !starts_with_ci(s, "tel:") {
return None
}
let rest = match drop(s, 4) {
Some(r) => r
None => return None
}
let parts = split_on(rest, ';')
let subscriber = parts[0]
if subscriber == "" {
return None
}
let params : Array[(String, String)] = []
let mut context : String? = None
let mut ext : String? = None
let mut isub : String? = None
for i = 1; i < parts.length(); i = i + 1 {
let part = parts[i]
if part == "" {
return None
}
let (name, value) = match split_first(part, '=') {
Some(pair) => pair
None => (part, "")
}
let lower = {
let buf = StringBuilder()
for c in name {
buf.write_char(lower_ascii(c))
}
buf.to_string()
}
if lower == "ext" {
if ext is Some(_) || isub is Some(_) {
return None
}
ext = canonical_extension(value)
if ext is None {
return None
}
} else if lower == "isub" {
if isub is Some(_) || ext is Some(_) {
return None
}
isub = canonical_isub(value)
if isub is None {
return None
}
} else if lower == "phone-context" {
if context is Some(_) {
return None
}
context = canonical_phone_context(value)
if context is None {
return None
}
} else {
// A parameter this library does not define: `;pname` or `;pname=pvalue`.
// RFC 3966 lets other documents define parameters, so they are preserved
// instead of being refused; a name or value outside the grammar still is.
if !is_pname(name) {
return None
}
if index_of_sub(part, "=") is Some(_) {
if value == "" || !is_param_value(value) {
return None
}
}
let mut seen = false
for q in params {
if q.0 == name {
seen = true
}
}
if seen {
return None
}
params.push((name, value))
}
}
let mut global_number = ""
let mut local_number = ""
let mut phone_context = ""
if starts_with(subscriber, "+") {
if context is Some(_) {
return None
}
match canonical_global_number(subscriber) {
Some(v) => global_number = v
None => return None
}
} else {
match context {
Some(v) => phone_context = v
None => return None
}
match canonical_local_number(subscriber) {
Some(v) => local_number = v
None => return None
}
}
Some({
global_number,
local_number,
phone_context,
parameters: sort_parameters(params),
extension: match ext {
Some(v) => v
None => ""
},
isub: match isub {
Some(v) => v
None => ""
},
})
}
///|
/// Serialise the parts back to a canonical `tel:` URI. Parameters are written in
/// RFC 3966's order: `phone-context` (local numbers only), then `ext` or `isub`.
/// None for a combination the grammar cannot represent, such as a local number
/// without a context or both numbers set.
pub fn to_tel_uri_full(t : TelUri) -> String? {
let is_global = t.global_number != ""
let is_local = t.local_number != ""
if is_global == is_local {
return None
}
if is_global && t.phone_context != "" {
return None
}
if is_local && t.phone_context == "" {
return None
}
if t.extension != "" && t.isub != "" {
return None
}
let mut out = if is_global {
"tel:" + t.global_number
} else {
"tel:" + t.local_number
}
if t.extension != "" {
out = out + ";ext=" + t.extension
}
if t.isub != "" {
out = out + ";isub=" + t.isub
}
if is_local {
out = out + ";phone-context=" + t.phone_context
}
for prm in sort_parameters(t.parameters) {
out = out + ";" + prm.0
if prm.1 != "" {
out = out + "=" + prm.1
}
}
Some(out)
}
///|
/// Turn parsed parts into an E.164 number when that is possible without asking
/// an external authority.
///
/// A global number resolves directly. A local number resolves only when its
/// context is a `+` prefix and its own digits are decimal; a domain context, or
/// a local number containing `*`, `#` or hex digits, has no E.164 reading.
/// `isub` is preserved in the TelUri but has no PhoneNumber field, so it does
/// not survive resolution.
pub fn resolve_tel_uri(t : TelUri) -> PhoneNumber? {
let pn = if t.global_number != "" {
parse(t.global_number, "")
} else if t.local_number != "" && starts_with(t.phone_context, "+") {
if digits_of(t.local_number) == t.local_number {
parse(t.phone_context + t.local_number, "")
} else {
None
}
} else {
None
}
match pn {
Some(v) => Some(v.with_extension(t.extension))
None => None
}
}
///|
/// Parse and resolve a `tel:` URI into a PhoneNumber.
///
/// This is the convenience form of `resolve_tel_uri(parse_tel_uri_full(uri))`.
/// Returns None for anything that is not a resolvable tel URI.
pub fn parse_tel_uri(uri : String) -> PhoneNumber? {
match parse_tel_uri_full(uri) {
Some(t) => resolve_tel_uri(t)
None => None
}
}
///|
/// The RFC 3966 URI for a number. Same rendering as `format_rfc3966`, offered
/// here so the two halves of URI handling sit together.
pub fn to_tel_uri(pn : PhoneNumber) -> String {
format_rfc3966(pn)
}