Skip to main content

bob/
source.rs

1//! Where a display comes from: a local file or an http(s) URL, and how the
2//! files it names (in actions, embedded displays, tabs) are found from there.
3
4use std::fmt;
5use std::path::PathBuf;
6
7use crate::project::normalize;
8
9/// A display's location.
10#[derive(Clone, Debug, PartialEq, Eq, Hash)]
11pub enum Source {
12    File(PathBuf),
13    /// An absolute `http://` or `https://` URL.
14    Url(String),
15}
16
17impl Source {
18    /// A path or URL as typed by the user; `file://` URLs become paths.
19    pub fn parse(text: &str) -> Self {
20        let text = text.trim();
21        if is_http(text) {
22            Source::Url(encode(text))
23        } else if let Some(path) = text.strip_prefix("file://") {
24            Source::File(PathBuf::from(file_url_path(path)))
25        } else {
26            Source::File(PathBuf::from(text))
27        }
28    }
29
30    pub fn is_url(&self) -> bool {
31        matches!(self, Source::Url(_))
32    }
33
34    /// Resolve a file named by the display at `self`: relative names are
35    /// found next to it, and absolute URLs are used as they are. A path
36    /// loses its `.` and `..` lexically, as in Phoebus and CS-Studio, so
37    /// following links back and forth does not grow it.
38    pub fn resolve(&self, file: &str) -> Source {
39        let file = file.trim();
40        if is_http(file) {
41            return Source::Url(encode(file));
42        }
43        match self {
44            Source::Url(base) => Source::Url(join_url(base, file)),
45            Source::File(path) => {
46                let file = file.strip_prefix("file://").map_or(file, file_url_path);
47                let rel = std::path::Path::new(file);
48                match path.parent() {
49                    Some(dir) if rel.is_relative() => Source::File(normalize(&dir.join(rel))),
50                    _ => Source::File(normalize(rel)),
51                }
52            }
53        }
54    }
55}
56
57impl fmt::Display for Source {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Source::File(p) => write!(f, "{}", p.display()),
61            Source::Url(u) => f.write_str(u),
62        }
63    }
64}
65
66impl From<PathBuf> for Source {
67    fn from(p: PathBuf) -> Self {
68        Source::File(p)
69    }
70}
71
72/// The path of a `file:` URL's path part: as it is, but on Windows
73/// without the `/` before a drive (`/C:/opi/a.bob`).
74pub(crate) fn file_url_path(path: &str) -> &str {
75    let drive = |p: &str| {
76        let b = p.as_bytes();
77        b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
78    };
79    match path.strip_prefix('/') {
80        Some(rest) if cfg!(windows) && drive(rest) => rest,
81        _ => path,
82    }
83}
84
85fn is_http(text: &str) -> bool {
86    let lower = |n: usize| text.get(..n).map(str::to_ascii_lowercase);
87    lower(7).as_deref() == Some("http://") || lower(8).as_deref() == Some("https://")
88}
89
90/// Resolve the reference `rel` against the absolute URL `base` (RFC 3986 ยง5.2).
91fn join_url(base: &str, rel: &str) -> String {
92    let rel = encode(rel);
93    let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
94    let rest = rest.split('#').next().unwrap_or("");
95    let auth_end = rest.find(['/', '?']).unwrap_or(rest.len());
96    let (authority, path_query) = rest.split_at(auth_end);
97    let (base_path, base_query) = match path_query.split_once('?') {
98        Some((p, q)) => (p, Some(q)),
99        None => (path_query, None),
100    };
101    let rel = rel.split('#').next().unwrap_or("");
102    let (rel_path, rel_query) = match rel.split_once('?') {
103        Some((p, q)) => (p, Some(q)),
104        None => (rel, None),
105    };
106
107    if let Some(network) = rel.strip_prefix("//") {
108        return format!("{scheme}://{network}");
109    }
110    let (path, query) = if rel_path.is_empty() {
111        (base_path.to_string(), rel_query.or(base_query))
112    } else if rel_path.starts_with('/') {
113        (remove_dots(rel_path), rel_query)
114    } else {
115        let dir = match base_path.rfind('/') {
116            Some(i) => &base_path[..=i],
117            None => "/",
118        };
119        (remove_dots(&format!("{dir}{rel_path}")), rel_query)
120    };
121    let path = if path.is_empty() { "/".into() } else { path };
122    match query {
123        Some(q) => format!("{scheme}://{authority}{path}?{q}"),
124        None => format!("{scheme}://{authority}{path}"),
125    }
126}
127
128/// Drop `.` and `..` segments from an absolute path.
129fn remove_dots(path: &str) -> String {
130    let segments: Vec<&str> = path.split('/').collect();
131    let mut out: Vec<&str> = Vec::new();
132    for (i, seg) in segments.iter().enumerate() {
133        let last = i + 1 == segments.len();
134        match *seg {
135            "." | ".." => {
136                // Never pop the empty segment before the leading '/'.
137                if *seg == ".." && out.len() > 1 {
138                    out.pop();
139                }
140                if last {
141                    out.push("");
142                }
143            }
144            s => out.push(s),
145        }
146    }
147    out.join("/")
148}
149
150/// Percent-encode the characters a URL may not contain (spaces in file names).
151fn encode(text: &str) -> String {
152    let mut out = String::with_capacity(text.len());
153    for c in text.chars() {
154        let ok = c.is_ascii_alphanumeric() || "-._~:/?#[]@!$&'()*+,;=%".contains(c);
155        if ok {
156            out.push(c);
157        } else {
158            let mut buf = [0; 4];
159            for b in c.encode_utf8(&mut buf).bytes() {
160                out.push_str(&format!("%{b:02X}"));
161            }
162        }
163    }
164    out
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn url(base: &str, rel: &str) -> String {
172        Source::Url(base.into()).resolve(rel).to_string()
173    }
174
175    #[test]
176    fn parses_paths_and_urls() {
177        assert_eq!(
178            Source::parse(" http://h/opi/demo.bob "),
179            Source::Url("http://h/opi/demo.bob".into())
180        );
181        assert!(Source::parse("HTTPS://h/a.bob").is_url());
182        assert_eq!(
183            Source::parse("http://h/My Screen.bob"),
184            Source::Url("http://h/My%20Screen.bob".into())
185        );
186        assert_eq!(
187            Source::parse("file:///tmp/a.bob"),
188            Source::File("/tmp/a.bob".into())
189        );
190        assert_eq!(Source::parse("a/b.bob"), Source::File("a/b.bob".into()));
191        assert_eq!(
192            Source::parse(r"C:\opi\a.bob"),
193            Source::File(r"C:\opi\a.bob".into())
194        );
195        assert_eq!(Source::parse("httpd.bob"), Source::File("httpd.bob".into()));
196    }
197
198    #[cfg(windows)]
199    #[test]
200    fn windows_paths() {
201        assert_eq!(
202            Source::parse("file:///C:/opi/a.bob"),
203            Source::File(r"C:\opi\a.bob".into())
204        );
205        let f = Source::File(r"C:\opi\demo.bob".into());
206        assert_eq!(
207            f.resolve("sub/b.bob"),
208            Source::File(r"C:\opi\sub\b.bob".into())
209        );
210        assert_eq!(f.resolve("../x/b.bob"), Source::File(r"C:\x\b.bob".into()));
211        assert_eq!(
212            f.resolve("file:///D:/abs/b.bob"),
213            Source::File(r"D:\abs\b.bob".into())
214        );
215    }
216
217    #[test]
218    fn resolves_relative_to_a_url() {
219        let base = "http://host:8000/opi/demo.bob";
220        assert_eq!(
221            url(base, "auto-generated/x/y.bob"),
222            "http://host:8000/opi/auto-generated/x/y.bob"
223        );
224        assert_eq!(url(base, "./a.bob"), "http://host:8000/opi/a.bob");
225        assert_eq!(url(base, "../a.bob"), "http://host:8000/a.bob");
226        assert_eq!(url(base, "../../../a.bob"), "http://host:8000/a.bob");
227        assert_eq!(url(base, "x/../a.bob"), "http://host:8000/opi/a.bob");
228        assert_eq!(url(base, "/top/a.bob"), "http://host:8000/top/a.bob");
229        assert_eq!(url(base, "//other/a.bob"), "http://other/a.bob");
230        assert_eq!(url(base, "a.bob?x=1#f"), "http://host:8000/opi/a.bob?x=1");
231        assert_eq!(
232            url(base, "My Screen.bob"),
233            "http://host:8000/opi/My%20Screen.bob"
234        );
235        assert_eq!(url(base, "x/.."), "http://host:8000/opi/");
236        assert_eq!(url("http://host", "a.bob"), "http://host/a.bob");
237        assert_eq!(url("http://host?q", "a.bob"), "http://host/a.bob");
238        assert_eq!(
239            url("https://h/a/b.bob?v=2#top", ""),
240            "https://h/a/b.bob?v=2"
241        );
242    }
243
244    #[test]
245    fn absolute_urls_win() {
246        assert_eq!(url("http://a/x.bob", "https://b/y.bob"), "https://b/y.bob");
247        assert_eq!(
248            Source::File("/opi/x.bob".into()).resolve("http://b/y.bob"),
249            Source::Url("http://b/y.bob".into())
250        );
251    }
252
253    #[test]
254    fn resolves_relative_to_a_file() {
255        let f = Source::File("/opi/demo.bob".into());
256        assert_eq!(f.resolve("a/b.bob"), Source::File("/opi/a/b.bob".into()));
257        assert_eq!(f.resolve("/abs/b.bob"), Source::File("/abs/b.bob".into()));
258        assert_eq!(
259            f.resolve("file:///abs/b.bob"),
260            Source::File("/abs/b.bob".into())
261        );
262    }
263
264    #[test]
265    fn file_paths_lose_dot_segments() {
266        // Going there and back again (`shared/x.opi` -> `../shared/y.opi`
267        // -> `../shared/x.opi`) names the same file by the same path.
268        let f = Source::File("/opi/shared/x.opi".into());
269        let there = f.resolve("../shared/y.opi");
270        assert_eq!(there, Source::File("/opi/shared/y.opi".into()));
271        assert_eq!(there.resolve("../shared/x.opi"), f);
272        assert_eq!(
273            f.resolve("./../../motor/m.opi"),
274            Source::File("/motor/m.opi".into())
275        );
276        assert_eq!(
277            Source::File("x.opi".into()).resolve("../../y.opi"),
278            Source::File("../../y.opi".into())
279        );
280    }
281
282    #[test]
283    fn removes_dot_segments() {
284        assert_eq!(remove_dots("/a/b/c/./../../g"), "/a/g");
285        assert_eq!(remove_dots("/a/b/.."), "/a/");
286        assert_eq!(remove_dots("/.."), "/");
287        assert_eq!(remove_dots("/a//b"), "/a//b");
288    }
289}