1use std::fmt;
5use std::path::PathBuf;
6
7use crate::project::normalize;
8
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
11pub enum Source {
12 File(PathBuf),
13 Url(String),
15}
16
17impl Source {
18 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 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
72pub(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
90fn 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
128fn 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 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
150fn 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 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}