Skip to main content

leviath_core/mime/
inline_refs.rs

1//! `@path` references inside a paragraph of text.
2//!
3//! A user writes `edit sprite image @hero.png so the arm is longer` and
4//! expects the file to travel with the sentence. This module finds those
5//! tokens. It does not read files: the caller supplies a resolver that says
6//! whether a token names something, so the CLI resolves against the current
7//! directory, the daemon against the run's workdir, and a test against
8//! nothing at all. The text keeps every token exactly as written, so the
9//! model and the stand-in agree on the name; only `\@` is rewritten, to `@`.
10
11use super::MimeType;
12
13/// One `@path` token found in a text.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct InlineRef {
16    /// The token as written, with its `@` and any `:type` suffix, for a caller
17    /// that highlights or echoes it back exactly as the user typed it.
18    pub token: String,
19    /// The path part, without `@` or the suffix.
20    pub path: String,
21    /// A `:type/subtype` suffix, when one was written.
22    pub mime_type: Option<MimeType>,
23    /// Byte offset of the token's `@` in the cleaned text, for the same
24    /// highlighting caller.
25    pub start: usize,
26}
27
28/// The cleaned text and the references found in it.
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub struct Extracted {
31    /// The text with `\@` unescaped and nothing else changed.
32    pub text: String,
33    /// The references the resolver accepted, in text order.
34    pub refs: Vec<InlineRef>,
35    /// Tokens that looked like a file path but the resolver refused, so a
36    /// caller can warn about a typo without treating an email as a file.
37    pub unresolved: Vec<String>,
38}
39
40/// Every `@path` reference in `text` as a part bound for `region`: a token
41/// `exists` accepts is read through `read`, and a `:type` suffix declares the
42/// part's type. Returns the text as [`extract`] cleans it, the parts in text
43/// order, and the tokens that looked like files but `exists` refused.
44///
45/// The two policies a caller has (where a path may point, how large a file
46/// may be) live in its `exists` and `read`, so the command line and the HTTP
47/// API share the walk and differ only there.
48pub 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
67/// Characters that end a token.
68fn ends_token(c: char) -> bool {
69    c.is_whitespace()
70        || matches!(
71            c,
72            ',' | ';' | ')' | ']' | '}' | '"' | '\'' | '<' | '>' | '|'
73        )
74}
75
76/// Trailing punctuation that belongs to the sentence, not the path.
77fn trim_trailing(path: &str) -> &str {
78    path.trim_end_matches(['.', ':', '!', '?'])
79}
80
81/// Whether a token is worth warning about when it does not resolve: it has a
82/// slash or a dot-extension, which an `@handle` or an email does not.
83fn 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
92/// Find every `@path` token in `text`, keeping those `resolve` accepts.
93///
94/// `resolve` receives the path as written and answers whether it names a
95/// file the caller is willing to attach. A token may end in `:type/subtype`
96/// to name the type; the suffix is split off before resolution.
97pub 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
147/// `photo.png:image/png` into `("photo.png", Some(image/png))`. A colon that
148/// is not followed by a mime type stays part of the path (Windows drives,
149/// `file:` URLs, a stray colon).
150fn 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    /// The walk is shared; the caller decides what exists and how a file is
165    /// read, and its first read error is the answer.
166    #[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}