use super::Expander;
use super::env::Env;
use super::origins;
use crate::cel::Value;
use crate::diagnostic::{Code, Location};
use crate::expanded::{self, Content};
use crate::tree::{self, Image, Interpolation, Link, Node, StringPart};
use std::fmt::Write as _;
const SCHEMES: [&str; 3] = ["https", "http", "mailto"];
impl<'r> Expander<'r> {
pub(super) fn interpolation(
&mut self,
interpolation: &'r Interpolation,
env: &Env<'r>,
out: &mut Vec<expanded::Node>,
) {
let path = interpolation.path.as_str();
if let Some(slot) = slot_name(path)
&& env.scope.knows(path)
{
return self.slot(slot, interpolation.block, env, out);
}
let start = interpolation
.position
.map(|position| position.start)
.unwrap_or_default();
let at = self.lines[&env.document].path_at(start);
let Some(text) = self.path_text(path, at, env) else {
return;
};
let text = self.made(Content::Text(super::literal(&text)), None);
if interpolation.block {
let paragraph = Content::Paragraph(tree::Parent {
children: vec![text],
position: None,
});
let origin = env
.origin
.or_else(|| origins::range(&Node::Interpolation(interpolation.clone())));
let made = self.made(paragraph, origin);
out.push(made);
} else {
out.push(text);
}
}
pub(super) fn path_text(&mut self, path: &str, at: Location, env: &Env<'r>) -> Option<String> {
let written = format!("{{{{{path}}}}}");
if !env.scope.knows(path) {
return Some(written);
}
match self.evaluate(path, env) {
Ok(value) => match value.written() {
Ok(text) => text,
Err(why) => {
self.report(
Code::F024,
env,
at,
format!("`{path}` can't be written: {why}"),
);
Some(written)
}
},
Err(error) => {
self.report(
Code::F024,
env,
at,
format!("`{path}`: {}", error.message()),
);
Some(written)
}
}
}
fn slot(&mut self, name: &str, block: bool, env: &Env<'r>, out: &mut Vec<expanded::Node>) {
let Some(slots) = env.slots.clone() else {
return;
};
let content = if name == "default" {
slots.default.clone()
} else {
slots
.named
.iter()
.find(|slot| names_slot(name, &slot.name))
.map(|slot| vec![slot.children.as_slice()])
.unwrap_or_default()
};
let mut placed = Vec::new();
match slots
.templates
.iter()
.find(|template| names_slot(name, &template.slot))
{
Some(template) => {
for index in 0..template.items.len() {
if self.over_limit() {
break;
}
let inner = slots.env.iteration(&template.name, &template.items, index);
placed.extend(self.parts(&content, &inner));
}
}
None => placed = self.parts(&content, &slots.env),
}
match placed.as_mut_slice() {
[single] if !block && matches!(single.content, Content::Paragraph(_)) => {
out.extend(
single
.content
.children_mut()
.map(std::mem::take)
.unwrap_or_default(),
);
}
_ => out.extend(placed),
}
}
pub(super) fn link(
&mut self,
link: &'r Link,
node: &'r Node,
env: &Env<'r>,
out: &mut Vec<expanded::Node>,
) {
let children = self.list(&link.children, env);
match self.checked_url(&link.url, link.url_parts.as_deref(), node, env) {
Some(url) => {
let content = Content::Link(Link {
children,
url,
title: link.title.clone(),
url_parts: None,
position: None,
});
let made = self.made(content, origins::of(node, env));
out.push(made);
}
None => out.extend(children),
}
}
pub(super) fn image(
&mut self,
image: &'r Image,
node: &'r Node,
env: &Env<'r>,
out: &mut Vec<expanded::Node>,
) {
let content = match self.checked_url(&image.url, image.url_parts.as_deref(), node, env) {
Some(url) => Content::Image(Image {
url,
url_parts: None,
position: None,
..image.clone()
}),
None => Content::Text(super::literal(&image.alt)),
};
let made = self.made(content, origins::of(node, env));
out.push(made);
}
pub(super) fn checked_url(
&mut self,
written: &str,
parts: Option<&[StringPart]>,
node: &Node,
env: &Env<'r>,
) -> Option<String> {
let url = self.url(written, parts, env);
let interpolated_start = matches!(parts, Some([StringPart::Interpolation(_), ..]));
let scheme = scheme(&url);
if interpolated_start && scheme.is_some_and(|scheme| !SCHEMES.contains(&scheme.as_str())) {
let at = node
.position()
.map_or(Location::new(1, 1), |p| p.start.into());
self.report(
Code::W109,
env,
at,
"an interpolated URL may only be relative or `https:`, `http:` or `mailto:`; the link's text is shown without the link",
);
return None;
}
Some(url)
}
fn url(&mut self, written: &str, parts: Option<&[StringPart]>, env: &Env<'r>) -> String {
let Some(parts) = parts else {
return written.to_string();
};
let mut url = String::new();
for part in parts {
match part {
StringPart::Text(text) => url.push_str(&text.value),
StringPart::Interpolation(interpolation) => {
let path = &interpolation.path;
let value = if env.scope.knows(path) {
self.evaluate(path, env).ok()
} else {
None
};
match value.as_ref().map(Value::written) {
Some(Ok(text)) => url.push_str(&url_safe(&text.unwrap_or_default())),
_ => {
let _ = write!(url, "{{{{{path}}}}}");
}
}
}
}
}
url
}
}
fn slot_name(path: &str) -> Option<&str> {
match path {
"md.slot" => Some("default"),
_ => path.strip_prefix("md.slot."),
}
}
fn names_slot(name: &str, slot: &str) -> bool {
slot.replace('-', "_") == name
}
fn scheme(url: &str) -> Option<String> {
let (scheme, _) = url.split_once(':')?;
let mut chars = scheme.chars();
let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
valid.then(|| scheme.to_ascii_lowercase())
}
fn url_safe(text: &str) -> String {
let mut out = String::new();
for c in text.chars() {
if c.is_ascii_alphanumeric() || "-._~:/?#[]@!$&'()*+,;=%".contains(c) {
out.push(c);
} else {
let mut bytes = [0; 4];
for byte in c.encode_utf8(&mut bytes).bytes() {
let _ = write!(out, "%{byte:02X}");
}
}
}
out
}
pub(super) fn joined(nodes: Vec<expanded::Node>) -> Vec<expanded::Node> {
let mut out: Vec<expanded::Node> = Vec::with_capacity(nodes.len());
for node in nodes {
if let (Some(last), Content::Text(text)) = (out.last_mut(), &node.content)
&& let Content::Text(joined) = &mut last.content
{
joined.value.push_str(&text.value);
if last.origin != node.origin {
last.origin = None;
}
continue;
}
out.push(node);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_keep_their_structure() {
assert_eq!(url_safe("acme/ray play?q=1#x"), "acme/ray%20play?q=1#x");
assert_eq!(url_safe("é\"<>"), "%C3%A9%22%3C%3E");
}
#[test]
fn schemes_are_letters_before_a_colon() {
assert_eq!(scheme("JavaScript:alert(1)"), Some("javascript".into()));
assert_eq!(scheme("https://a.org"), Some("https".into()));
assert_eq!(scheme("./a:b"), None);
assert_eq!(scheme("1a:b"), None);
assert_eq!(scheme("docs/page"), None);
}
#[test]
fn slots_are_named_by_their_path() {
assert_eq!(slot_name("md.slot"), Some("default"));
assert_eq!(slot_name("md.slot.footer"), Some("footer"));
assert_eq!(slot_name("md.doc"), None);
}
#[test]
fn a_path_names_a_slot_with_underscores_for_its_hyphens() {
assert!(names_slot("side_panel", "side-panel"));
assert!(names_slot("footer", "footer"));
assert!(!names_slot("side_panel", "side"));
}
}