1pub fn canonicalize(path: impl AsRef<std::path::Path>) -> std::io::Result<std::path::PathBuf> {
13 std::fs::canonicalize(path).map(strip_verbatim)
14}
15
16#[cfg(not(windows))]
17fn strip_verbatim(p: std::path::PathBuf) -> std::path::PathBuf {
18 p
19}
20
21#[cfg(windows)]
30fn strip_verbatim(p: std::path::PathBuf) -> std::path::PathBuf {
31 use std::path::{Component, Prefix};
32 let mut comps = p.components();
33 let Some(Component::Prefix(prefix)) = comps.next() else {
34 return p;
35 };
36 let root = match prefix.kind() {
37 Prefix::VerbatimDisk(d) => format!("{}:\\", d as char),
38 Prefix::VerbatimUNC(server, share) => format!(
39 "\\\\{}\\{}",
40 server.to_string_lossy(),
41 share.to_string_lossy()
42 ),
43 _ => return p,
44 };
45 let mut out = std::path::PathBuf::from(root);
46 for c in comps {
47 if !matches!(c, Component::RootDir) {
48 out.push(c.as_os_str());
49 }
50 }
51 if out.symlink_metadata().is_ok() {
54 out
55 } else {
56 p
57 }
58}
59
60pub fn is_absolute_path(path: &str) -> bool {
64 if path.starts_with('/') || path.starts_with('\\') || path.contains("://") {
65 return true;
66 }
67 let b = path.as_bytes();
68 b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
69}
70
71pub fn normalize_path(path: &str) -> String {
74 let path = path.replace('\\', "/");
75 let (absolute, rest) = if path.starts_with("//") && path.len() > 2 {
76 ("//", &path[2..])
77 } else if let Some(r) = path.strip_prefix('/') {
78 ("/", r)
79 } else {
80 ("", path.as_str())
81 };
82 let mut parts: Vec<&str> = Vec::new();
83 let mut up = false;
84 for chunk in rest.split('/') {
85 if chunk == ".." && (!absolute.is_empty() || up) {
86 parts.pop();
87 up = !(parts.is_empty() || parts.last() == Some(&".."));
88 } else if chunk != "." && !chunk.is_empty() {
89 parts.push(chunk);
90 up = chunk != "..";
91 }
92 }
93 format!("{absolute}{}", parts.join("/"))
94}
95
96fn php_dirname(p: &str) -> String {
98 match p.rfind('/') {
99 None => ".".to_owned(),
100 Some(0) => "/".to_owned(),
101 Some(i) => p[..i].to_owned(),
102 }
103}
104
105fn php_basename(p: &str) -> &str {
107 p.rsplit('/').next().unwrap_or(p)
108}
109
110fn common_path(from: &str, to: &str) -> String {
115 let mut common = to.to_owned();
116 while !format!("{from}/").starts_with(&format!("{common}/")) && common != "/" && common != "." {
117 common = php_dirname(&common);
118 }
119 common
120}
121
122pub fn find_shortest_path(from: &str, to: &str, directories: bool) -> String {
125 let mut from = normalize_path(from);
126 let to = normalize_path(to);
127 if directories {
128 from = format!("{}/dummy_file", from.trim_end_matches('/'));
129 }
130 if php_dirname(&from) == php_dirname(&to) {
131 return format!("./{}", php_basename(&to));
132 }
133 let common = common_path(&from, &to);
134 if !from.starts_with(&common) || common == "." {
135 return to;
136 }
137 let common = format!("{}/", common.trim_end_matches('/'));
138 let depth = from[common.len().min(from.len())..].matches('/').count();
139 if common == "/" && depth > 1 {
140 return to;
141 }
142 let result = format!(
143 "{}{}",
144 "../".repeat(depth),
145 &to[common.len().min(to.len())..]
146 );
147 if result.is_empty() {
148 "./".to_owned()
149 } else {
150 result
151 }
152}
153
154pub fn find_shortest_path_code(
157 from: &str,
158 to: &str,
159 directories: bool,
160 static_code: bool,
161) -> String {
162 let from = normalize_path(from);
163 let to = normalize_path(to);
164 if from == to {
165 return if directories { "__DIR__" } else { "__FILE__" }.to_owned();
166 }
167 let common = common_path(&from, &to);
168 if !from.starts_with(&common) || common == "." {
169 return php_str(&to);
170 }
171 let common = format!("{}/", common.trim_end_matches('/'));
172 if to.starts_with(&format!("{from}/")) {
173 return format!("__DIR__ . {}", php_str(&to[from.len()..]));
174 }
175 let depth =
176 from[common.len().min(from.len())..].matches('/').count() + usize::from(directories);
177 if common == "/" && depth > 1 {
178 return php_str(&to);
179 }
180 let code = if static_code {
181 format!("__DIR__ . '{}'", "/..".repeat(depth))
182 } else {
183 format!("{}__DIR__{}", "dirname(".repeat(depth), ")".repeat(depth))
184 };
185 let rel = &to[common.len().min(to.len())..];
186 if rel.is_empty() {
187 code
188 } else {
189 format!("{code}.{}", php_str(&format!("/{rel}")))
190 }
191}
192
193pub fn php_str(s: &str) -> String {
195 let mut out = String::with_capacity(s.len() + 2);
196 out.push('\'');
197 for c in s.chars() {
198 match c {
199 '\'' => out.push_str("\\'"),
200 '\\' => out.push_str("\\\\"),
201 c => out.push(c),
202 }
203 }
204 out.push('\'');
205 out
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn canonicalize_has_no_verbatim_prefix() {
214 let tmp = tempfile::tempdir().expect("tmp");
215 let real = canonicalize(tmp.path()).expect("canonicalize");
216 let s = real.to_string_lossy().into_owned();
217 assert!(!s.starts_with(r"\\?\"), "verbatim prefix not stripped: {s}");
218 assert!(real.is_dir());
220 assert!(!normalize_path(&s).starts_with("//?/"), "{s}");
221 }
222
223 #[cfg(windows)]
227 #[test]
228 fn canonicalize_strips_verbatim_beyond_max_path() {
229 let tmp = tempfile::tempdir().expect("tmp");
230 let mut deep = tmp.path().to_path_buf();
231 while deep.as_os_str().len() < 300 {
232 deep.push("abcdefghijklmnopqrstuvwxyz0123456789");
233 }
234 std::fs::create_dir_all(&deep).expect("mkdir deep");
235 std::fs::write(deep.join("f.txt"), b"x").expect("write");
236 let real = canonicalize(&deep).expect("canonicalize");
237 assert!(real.as_os_str().len() > 260, "{}", real.display());
238 assert!(
239 !real.to_string_lossy().starts_with(r"\\?\"),
240 "verbatim prefix beyond MAX_PATH: {}",
241 real.display()
242 );
243 assert!(std::fs::read(real.join("f.txt")).is_ok(), "unreadable form");
244 }
245
246 #[test]
247 fn normalize() {
248 assert_eq!(normalize_path("/a/b/../c/./d/"), "/a/c/d");
249 assert_eq!(normalize_path("app/"), "app");
250 assert_eq!(normalize_path("/a//b"), "/a/b");
251 assert_eq!(normalize_path("../x"), "../x");
252 assert_eq!(
253 normalize_path("/p/web/app/plugins/x/"),
254 "/p/web/app/plugins/x"
255 );
256 }
257
258 #[test]
259 fn shortest_paths_directories() {
260 assert_eq!(
261 find_shortest_path("/p/vendor/composer", "/p/vendor", true),
262 "../"
263 );
264 assert_eq!(
265 find_shortest_path("/p/vendor/composer", "/p", true),
266 "../../"
267 );
268 assert_eq!(find_shortest_path("/p/vendor", "/p/app", true), "../app");
269 assert_eq!(find_shortest_path("/p", "/p/app/x", true), "app/x");
270 assert_eq!(
271 find_shortest_path("/p/vendor/composer", "/p/vendor/a/b", true),
272 "../a/b"
273 );
274 assert_eq!(
275 find_shortest_path("/p/vendor/composer", "/p/vendor/composer/x", true),
276 "./x"
277 );
278 assert_eq!(
279 find_shortest_path("/p/vendor/composer", "/p/web/app/plugins/x", true),
280 "../../web/app/plugins/x"
281 );
282 assert_eq!(
283 find_shortest_path("/p/vendor/composer", "/q/x", true),
284 "/q/x"
285 );
286 assert_eq!(
287 find_shortest_path("/p/vendor/composer", "/p/vendor/composer", true),
288 "./"
289 );
290 }
291
292 #[test]
293 fn shortest_path_codes() {
294 assert_eq!(
295 find_shortest_path_code("/p/vendor/composer", "/p/vendor", true, true),
296 "__DIR__ . '/..'"
297 );
298 assert_eq!(
299 find_shortest_path_code("/p/vendor/composer", "/p", true, true),
300 "__DIR__ . '/../..'"
301 );
302 assert_eq!(
303 find_shortest_path_code("/p/vendor/composer", "/p/vendor", true, false),
304 "dirname(__DIR__)"
305 );
306 assert_eq!(
307 find_shortest_path_code("/p/vendor", "/p", true, false),
308 "dirname(__DIR__)"
309 );
310 assert_eq!(
311 find_shortest_path_code("/p/vendor", "/p/vendor/composer", true, false),
312 "__DIR__ . '/composer'"
313 );
314 assert_eq!(
315 find_shortest_path_code("/p/vendor/composer", "/p/vendor/composer", true, false),
316 "__DIR__"
317 );
318 assert_eq!(
319 find_shortest_path_code("/p/vendor/composer", "/p/web/x", true, true),
320 "__DIR__ . '/../..'.'/web/x'"
321 );
322 }
323}