1use super::MimeType;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct InlineRef {
16 pub token: String,
19 pub path: String,
21 pub mime_type: Option<MimeType>,
23 pub start: usize,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub struct Extracted {
31 pub text: String,
33 pub refs: Vec<InlineRef>,
35 pub unresolved: Vec<String>,
38}
39
40pub fn parts_from_text<E>(
49 text: &str,
50 region: Option<&str>,
51 exists: &mut dyn FnMut(&str) -> bool,
52 read: &mut dyn FnMut(&str) -> Result<super::InboundPart, E>,
53) -> Result<(String, Vec<super::InboundPart>, Vec<String>), E> {
54 let extracted = extract(text, exists);
55 let mut parts = Vec::with_capacity(extracted.refs.len());
56 for r in &extracted.refs {
57 let mut part = read(&r.path)?;
58 if let Some(t) = &r.mime_type {
59 part.mime_type = Some(t.clone());
60 }
61 part.region = region.map(str::to_string);
62 parts.push(part);
63 }
64 Ok((extracted.text, parts, extracted.unresolved))
65}
66
67fn ends_token(c: char) -> bool {
69 c.is_whitespace()
70 || matches!(
71 c,
72 ',' | ';' | ')' | ']' | '}' | '"' | '\'' | '<' | '>' | '|'
73 )
74}
75
76fn trim_trailing(path: &str) -> &str {
78 path.trim_end_matches(['.', ':', '!', '?'])
79}
80
81fn looks_like_path(path: &str) -> bool {
84 !path.contains('@')
85 && (path.contains('/')
86 || path.contains('\\')
87 || std::path::Path::new(path)
88 .extension()
89 .is_some_and(|e| !e.is_empty()))
90}
91
92pub fn extract(text: &str, resolve: &mut dyn FnMut(&str) -> bool) -> Extracted {
98 let mut out = Extracted::default();
99 let mut cleaned = String::with_capacity(text.len());
100 let chars: Vec<char> = text.chars().collect();
101 let mut i = 0;
102 while i < chars.len() {
103 let c = chars[i];
104 if c == '\\' && chars.get(i + 1) == Some(&'@') {
105 cleaned.push('@');
106 i += 2;
107 continue;
108 }
109 let at_boundary = i == 0
110 || chars[i - 1].is_whitespace()
111 || matches!(chars[i - 1], '(' | '[' | '"' | '\'' | '<');
112 if c == '@'
113 && at_boundary
114 && chars
115 .get(i + 1)
116 .is_some_and(|n| !n.is_whitespace() && *n != '@')
117 {
118 let mut j = i + 1;
119 while j < chars.len() && !ends_token(chars[j]) {
120 j += 1;
121 }
122 let raw: String = chars[i + 1..j].iter().collect();
123 let trimmed = trim_trailing(&raw);
124 let (path, mime_type) = split_type(trimmed);
125 if !path.is_empty() && !path.contains('@') && resolve(&path) {
126 let token: String = chars[i..i + 1 + trimmed.chars().count()].iter().collect();
127 out.refs.push(InlineRef {
128 token,
129 path,
130 mime_type,
131 start: cleaned.len(),
132 });
133 } else if looks_like_path(&path) {
134 out.unresolved.push(path);
135 }
136 cleaned.extend(&chars[i..j]);
137 i = j;
138 continue;
139 }
140 cleaned.push(c);
141 i += 1;
142 }
143 out.text = cleaned;
144 out
145}
146
147fn split_type(raw: &str) -> (String, Option<MimeType>) {
151 if let Some((path, suffix)) = raw.rsplit_once(':')
152 && suffix.contains('/')
153 && let Ok(t) = MimeType::parse(suffix)
154 {
155 return (path.to_string(), Some(t));
156 }
157 (raw.to_string(), None)
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
167 fn parts_from_text_reads_each_reference_through_the_caller() {
168 let (text, parts, unresolved) = parts_from_text(
169 "see @a.png then @missing.txt and @b.txt:text/markdown",
170 Some("art"),
171 &mut |path| path != "missing.txt",
172 &mut |path| Ok::<_, String>(super::super::InboundPart::from_bytes(path, b"x".to_vec())),
173 )
174 .unwrap();
175 assert_eq!(parts.len(), 2);
176 assert_eq!(parts[0].name, "a.png");
177 assert_eq!(parts[0].region.as_deref(), Some("art"));
178 assert_eq!(parts[0].mime_type, None);
179 assert_eq!(
180 parts[1].mime_type.as_ref().map(MimeType::as_str),
181 Some("text/markdown")
182 );
183 assert_eq!(unresolved, ["missing.txt"]);
184 assert!(text.contains("@a.png"), "{text}");
185 let err = parts_from_text("@a.png", None, &mut |_| true, &mut |path| {
186 Err::<super::super::InboundPart, _>(format!("no {path}"))
187 })
188 .unwrap_err();
189 assert_eq!(err, "no a.png");
190 }
191
192 fn all(text: &str) -> Extracted {
193 extract(text, &mut |_| true)
194 }
195
196 #[test]
197 fn finds_tokens_at_start_middle_and_end() {
198 let e = all("@a.png then @dir/b.wav and finally @c.mp4");
199 let paths: Vec<_> = e.refs.iter().map(|r| r.path.as_str()).collect();
200 assert_eq!(paths, ["a.png", "dir/b.wav", "c.mp4"]);
201 assert_eq!(e.text, "@a.png then @dir/b.wav and finally @c.mp4");
202 assert_eq!(e.refs[0].start, 0);
203 assert_eq!(e.refs[1].start, 12);
204 assert_eq!(e.refs[1].token, "@dir/b.wav");
205 assert!(e.unresolved.is_empty());
206 }
207
208 #[test]
209 fn sentence_punctuation_is_not_part_of_the_path() {
210 let e = all("look at @hero.png. Then (@b.png), \"@c.png\" and [@d.png]; ok?");
211 let paths: Vec<_> = e.refs.iter().map(|r| r.path.as_str()).collect();
212 assert_eq!(paths, ["hero.png", "b.png", "c.png", "d.png"]);
213 assert_eq!(e.refs[0].token, "@hero.png");
214 assert!(e.text.contains("@hero.png. Then"));
215 }
216
217 #[test]
218 fn type_suffix_is_split_off() {
219 let e = all("use @scene.bin:model/gltf-binary here and @c:\\x\\y.obj");
220 assert_eq!(e.refs[0].path, "scene.bin");
221 assert_eq!(
222 e.refs[0].mime_type.as_ref().unwrap().as_str(),
223 "model/gltf-binary"
224 );
225 assert_eq!(e.refs[0].token, "@scene.bin:model/gltf-binary");
226 assert_eq!(e.refs[1].path, "c:\\x\\y.obj");
227 assert!(e.refs[1].mime_type.is_none());
228 let e = all("@a.txt:notatype");
229 assert_eq!(e.refs[0].path, "a.txt:notatype");
230 let e = all("@a.txt:not/a/type");
231 assert_eq!(e.refs[0].path, "a.txt:not/a/type");
232 assert!(e.refs[0].mime_type.is_none());
233 }
234
235 #[test]
236 fn escapes_and_non_tokens() {
237 let e = all(r"mail me\@example.com, ping @@twice, a@b.c, \@literal and email me@x.io");
238 assert!(e.refs.is_empty());
239 assert_eq!(
240 e.text,
241 "mail me@example.com, ping @@twice, a@b.c, @literal and email me@x.io"
242 );
243 assert!(e.unresolved.is_empty());
244 let e = all("@");
245 assert!(e.refs.is_empty());
246 assert_eq!(e.text, "@");
247 let e = all("@ space");
248 assert!(e.refs.is_empty());
249 let e = all("end with \\");
250 assert_eq!(e.text, "end with \\");
251 let e = all("a \\x b @a@b @. c");
252 assert!(e.refs.is_empty(), "{:?}", e.refs);
253 assert!(e.unresolved.is_empty(), "{:?}", e.unresolved);
254 assert_eq!(e.text, "a \\x b @a@b @. c");
255 }
256
257 #[test]
258 fn resolver_decides_and_unresolved_paths_are_reported() {
259 let e = extract("see @real.png and @missing.png and @channel", &mut |p| {
260 p == "real.png"
261 });
262 assert_eq!(e.refs.len(), 1);
263 assert_eq!(e.refs[0].path, "real.png");
264 assert_eq!(e.unresolved, vec!["missing.png"]);
265 assert_eq!(e.text, "see @real.png and @missing.png and @channel");
266 let e = extract("@dir/thing and @a\\b", &mut |_| false);
267 assert_eq!(e.unresolved, vec!["dir/thing", "a\\b"]);
268 assert!(!looks_like_path("name."));
269 assert!(!looks_like_path("a@b.c"));
270 }
271
272 #[test]
273 fn unicode_text_keeps_offsets_in_bytes() {
274 let e = all("héllo @ü.png done");
275 assert_eq!(e.refs[0].start, "héllo ".len());
276 assert_eq!(e.refs[0].path, "ü.png");
277 assert_eq!(e.text, "héllo @ü.png done");
278 }
279}