#[expect(
clippy::string_slice,
reason = "t_pos only reaches slice sites on ASCII '{' bytes (char boundaries), u_pos is a \
boundary because it advances past find() matches or byte runs identical to \
whole-char template literals, and find() results are boundaries"
)]
pub(super) fn match_uri_template(
template: &str,
uri: &str,
) -> Option<std::collections::HashMap<String, String>> {
let mut vars = std::collections::HashMap::new();
let mut t_pos = 0;
let mut u_pos = 0;
let t_bytes = template.as_bytes();
let u_bytes = uri.as_bytes();
while t_pos < t_bytes.len() {
if t_bytes[t_pos] == b'{' {
let close = template[t_pos..].find('}')? + t_pos;
let var_name = &template[t_pos + 1..close];
t_pos = close + 1;
let next_literal = if t_pos < t_bytes.len() {
let lit_start = t_pos;
let lit_end = template[t_pos..]
.find('{')
.map(|i| t_pos + i)
.unwrap_or(t_bytes.len());
Some(&template[lit_start..lit_end])
} else {
None
};
let value_end = match next_literal {
Some(lit) if !lit.is_empty() => uri[u_pos..].find(lit).map(|i| u_pos + i)?,
_ => u_bytes.len(),
};
vars.insert(var_name.to_string(), uri[u_pos..value_end].to_string());
u_pos = value_end;
} else {
if u_pos >= u_bytes.len() || t_bytes[t_pos] != u_bytes[u_pos] {
return None;
}
t_pos += 1;
u_pos += 1;
}
}
if u_pos == u_bytes.len() {
Some(vars)
} else {
None
}
}
#[expect(
clippy::string_slice,
reason = "offsets come from find() of the ASCII braces on the sliced string"
)]
pub(super) fn uri_template_variables(template: &str) -> Vec<String> {
let mut variables = Vec::new();
let mut cursor = template;
while let Some(open) = cursor.find('{') {
cursor = &cursor[open + 1..];
let Some(close) = cursor.find('}') else {
break;
};
let name = cursor[..close].trim();
if !name.is_empty() && !variables.iter().any(|variable| variable == name) {
variables.push(name.to_string());
}
cursor = &cursor[close + 1..];
}
variables
}
pub(super) fn validated_uri_template_variables(template: &str) -> Result<Vec<String>, String> {
let mut variables = Vec::new();
let mut sample = String::with_capacity(template.len());
let mut chars = template.chars();
while let Some(ch) = chars.next() {
match ch {
'}' => return Err("unmatched closing brace".into()),
'{' => {
let mut name = String::new();
loop {
match chars.next() {
Some('}') => break,
Some('{') | None => return Err("unclosed or nested expression".into()),
Some(ch) => name.push(ch),
}
}
if name.is_empty()
|| name.starts_with('.')
|| name.ends_with('.')
|| name.contains("..")
|| !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.')
{
return Err(format!("unsupported template variable '{name}'"));
}
if variables.contains(&name) {
return Err(format!("duplicate template variable '{name}'"));
}
variables.push(name);
sample.push('x');
}
_ => sample.push(ch),
}
}
url::Url::parse(&sample).map_err(|error| format!("invalid URI: {error}"))?;
Ok(variables)
}