use super::MimeType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineRef {
pub token: String,
pub path: String,
pub mime_type: Option<MimeType>,
pub start: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Extracted {
pub text: String,
pub refs: Vec<InlineRef>,
pub unresolved: Vec<String>,
}
pub fn parts_from_text<E>(
text: &str,
region: Option<&str>,
exists: &mut dyn FnMut(&str) -> bool,
read: &mut dyn FnMut(&str) -> Result<super::InboundPart, E>,
) -> Result<(String, Vec<super::InboundPart>, Vec<String>), E> {
let extracted = extract(text, exists);
let mut parts = Vec::with_capacity(extracted.refs.len());
for r in &extracted.refs {
let mut part = read(&r.path)?;
if let Some(t) = &r.mime_type {
part.mime_type = Some(t.clone());
}
part.region = region.map(str::to_string);
parts.push(part);
}
Ok((extracted.text, parts, extracted.unresolved))
}
fn ends_token(c: char) -> bool {
c.is_whitespace()
|| matches!(
c,
',' | ';' | ')' | ']' | '}' | '"' | '\'' | '<' | '>' | '|'
)
}
fn trim_trailing(path: &str) -> &str {
path.trim_end_matches(['.', ':', '!', '?'])
}
fn looks_like_path(path: &str) -> bool {
!path.contains('@')
&& (path.contains('/')
|| path.contains('\\')
|| std::path::Path::new(path)
.extension()
.is_some_and(|e| !e.is_empty()))
}
pub fn extract(text: &str, resolve: &mut dyn FnMut(&str) -> bool) -> Extracted {
let mut out = Extracted::default();
let mut cleaned = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\\' && chars.get(i + 1) == Some(&'@') {
cleaned.push('@');
i += 2;
continue;
}
let at_boundary = i == 0
|| chars[i - 1].is_whitespace()
|| matches!(chars[i - 1], '(' | '[' | '"' | '\'' | '<');
if c == '@'
&& at_boundary
&& chars
.get(i + 1)
.is_some_and(|n| !n.is_whitespace() && *n != '@')
{
let mut j = i + 1;
while j < chars.len() && !ends_token(chars[j]) {
j += 1;
}
let raw: String = chars[i + 1..j].iter().collect();
let trimmed = trim_trailing(&raw);
let (path, mime_type) = split_type(trimmed);
if !path.is_empty() && !path.contains('@') && resolve(&path) {
let token: String = chars[i..i + 1 + trimmed.chars().count()].iter().collect();
out.refs.push(InlineRef {
token,
path,
mime_type,
start: cleaned.len(),
});
} else if looks_like_path(&path) {
out.unresolved.push(path);
}
cleaned.extend(&chars[i..j]);
i = j;
continue;
}
cleaned.push(c);
i += 1;
}
out.text = cleaned;
out
}
fn split_type(raw: &str) -> (String, Option<MimeType>) {
if let Some((path, suffix)) = raw.rsplit_once(':')
&& suffix.contains('/')
&& let Ok(t) = MimeType::parse(suffix)
{
return (path.to_string(), Some(t));
}
(raw.to_string(), None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parts_from_text_reads_each_reference_through_the_caller() {
let (text, parts, unresolved) = parts_from_text(
"see @a.png then @missing.txt and @b.txt:text/markdown",
Some("art"),
&mut |path| path != "missing.txt",
&mut |path| Ok::<_, String>(super::super::InboundPart::from_bytes(path, b"x".to_vec())),
)
.unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].name, "a.png");
assert_eq!(parts[0].region.as_deref(), Some("art"));
assert_eq!(parts[0].mime_type, None);
assert_eq!(
parts[1].mime_type.as_ref().map(MimeType::as_str),
Some("text/markdown")
);
assert_eq!(unresolved, ["missing.txt"]);
assert!(text.contains("@a.png"), "{text}");
let err = parts_from_text("@a.png", None, &mut |_| true, &mut |path| {
Err::<super::super::InboundPart, _>(format!("no {path}"))
})
.unwrap_err();
assert_eq!(err, "no a.png");
}
fn all(text: &str) -> Extracted {
extract(text, &mut |_| true)
}
#[test]
fn finds_tokens_at_start_middle_and_end() {
let e = all("@a.png then @dir/b.wav and finally @c.mp4");
let paths: Vec<_> = e.refs.iter().map(|r| r.path.as_str()).collect();
assert_eq!(paths, ["a.png", "dir/b.wav", "c.mp4"]);
assert_eq!(e.text, "@a.png then @dir/b.wav and finally @c.mp4");
assert_eq!(e.refs[0].start, 0);
assert_eq!(e.refs[1].start, 12);
assert_eq!(e.refs[1].token, "@dir/b.wav");
assert!(e.unresolved.is_empty());
}
#[test]
fn sentence_punctuation_is_not_part_of_the_path() {
let e = all("look at @hero.png. Then (@b.png), \"@c.png\" and [@d.png]; ok?");
let paths: Vec<_> = e.refs.iter().map(|r| r.path.as_str()).collect();
assert_eq!(paths, ["hero.png", "b.png", "c.png", "d.png"]);
assert_eq!(e.refs[0].token, "@hero.png");
assert!(e.text.contains("@hero.png. Then"));
}
#[test]
fn type_suffix_is_split_off() {
let e = all("use @scene.bin:model/gltf-binary here and @c:\\x\\y.obj");
assert_eq!(e.refs[0].path, "scene.bin");
assert_eq!(
e.refs[0].mime_type.as_ref().unwrap().as_str(),
"model/gltf-binary"
);
assert_eq!(e.refs[0].token, "@scene.bin:model/gltf-binary");
assert_eq!(e.refs[1].path, "c:\\x\\y.obj");
assert!(e.refs[1].mime_type.is_none());
let e = all("@a.txt:notatype");
assert_eq!(e.refs[0].path, "a.txt:notatype");
let e = all("@a.txt:not/a/type");
assert_eq!(e.refs[0].path, "a.txt:not/a/type");
assert!(e.refs[0].mime_type.is_none());
}
#[test]
fn escapes_and_non_tokens() {
let e = all(r"mail me\@example.com, ping @@twice, a@b.c, \@literal and email me@x.io");
assert!(e.refs.is_empty());
assert_eq!(
e.text,
"mail me@example.com, ping @@twice, a@b.c, @literal and email me@x.io"
);
assert!(e.unresolved.is_empty());
let e = all("@");
assert!(e.refs.is_empty());
assert_eq!(e.text, "@");
let e = all("@ space");
assert!(e.refs.is_empty());
let e = all("end with \\");
assert_eq!(e.text, "end with \\");
let e = all("a \\x b @a@b @. c");
assert!(e.refs.is_empty(), "{:?}", e.refs);
assert!(e.unresolved.is_empty(), "{:?}", e.unresolved);
assert_eq!(e.text, "a \\x b @a@b @. c");
}
#[test]
fn resolver_decides_and_unresolved_paths_are_reported() {
let e = extract("see @real.png and @missing.png and @channel", &mut |p| {
p == "real.png"
});
assert_eq!(e.refs.len(), 1);
assert_eq!(e.refs[0].path, "real.png");
assert_eq!(e.unresolved, vec!["missing.png"]);
assert_eq!(e.text, "see @real.png and @missing.png and @channel");
let e = extract("@dir/thing and @a\\b", &mut |_| false);
assert_eq!(e.unresolved, vec!["dir/thing", "a\\b"]);
assert!(!looks_like_path("name."));
assert!(!looks_like_path("a@b.c"));
}
#[test]
fn unicode_text_keeps_offsets_in_bytes() {
let e = all("héllo @ü.png done");
assert_eq!(e.refs[0].start, "héllo ".len());
assert_eq!(e.refs[0].path, "ü.png");
assert_eq!(e.text, "héllo @ü.png done");
}
}