use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use url::Url;
pub fn path_to_uri(path: &Path) -> Result<String> {
if path.to_str().is_none() {
return Err(anyhow!(
"{} is not valid UTF-8; rust-analyzer only understands UTF-8 paths",
path.display()
));
}
if !path.is_absolute() {
return Err(anyhow!("{} is not an absolute path", path.display()));
}
Url::from_file_path(path)
.map(|url| url.to_string())
.map_err(|()| anyhow!("{} cannot be named by a file URI", path.display()))
}
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
let url = Url::parse(uri).ok()?;
if url.scheme() != "file" {
return None;
}
url.to_file_path().ok()
}
pub fn absolute(path: &Path) -> PathBuf {
if let Ok(canonical) = dunce::canonicalize(path) {
return canonical;
}
if path.is_absolute() {
return path.to_path_buf();
}
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
pub fn normalize(uri: &str) -> String {
let Ok(url) = Url::parse(uri) else {
return uri.to_string();
};
if url.scheme() != "file" {
return uri.to_string();
}
let Some((drive, drive_len)) = drive_letter(url.path()) else {
return url.to_string();
};
let mut url = url;
let normalized = format!(
"/{}:{}",
drive.to_ascii_uppercase(),
&url.path()[drive_len..]
);
url.set_path(&normalized);
url.to_string()
}
fn drive_letter(path: &str) -> Option<(char, usize)> {
let letter = path.strip_prefix('/')?.chars().next()?;
if !letter.is_ascii_alphabetic() {
return None;
}
let rest = &path[2..];
let colon_len = if rest.starts_with(':') || rest.starts_with('|') {
1
} else if rest.starts_with("%3A") || rest.starts_with("%3a") {
3
} else {
return None;
};
let drive_len = 2 + colon_len;
match path[drive_len..].chars().next() {
Some('/') | None => Some((letter, drive_len)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_settles_on_one_spelling_of_the_drive() {
for uri in [
"file:///c:/Users/zeenix/src/lib.rs",
"file:///C:/Users/zeenix/src/lib.rs",
"file:///c%3A/Users/zeenix/src/lib.rs",
"file:///C%3a/Users/zeenix/src/lib.rs",
"file:///c|/Users/zeenix/src/lib.rs",
] {
assert_eq!(
normalize(uri),
"file:///C:/Users/zeenix/src/lib.rs",
"{uri}"
);
}
assert_eq!(normalize("file:///c:"), "file:///C:");
assert_eq!(normalize("file:///c:/"), "file:///C:/");
}
#[test]
fn normalize_leaves_everything_else_alone() {
for uri in [
"file:///home/zeenix/src/lib.rs",
"file:///home/zeenix/my%20crate/src/lib.rs",
"file:///c:x/lib.rs",
"file:///cc:/lib.rs",
"file:///1:/lib.rs",
"file://server/share/lib.rs",
"untitled:Untitled-1",
"not a uri",
"",
] {
assert_eq!(normalize(uri), uri, "{uri}");
}
}
#[test]
fn normalize_is_idempotent() {
for uri in ["file:///c%3A/a.rs", "file:///home/zeenix/a.rs", "nonsense"] {
assert_eq!(normalize(&normalize(uri)), normalize(uri), "{uri}");
}
}
#[test]
fn relative_paths_have_no_uri() {
let error = path_to_uri(Path::new("src/lib.rs"))
.unwrap_err()
.to_string();
assert!(error.contains("absolute"), "{error}");
}
#[test]
fn non_file_uris_have_no_path() {
assert!(uri_to_path("http://example.com/lib.rs").is_none());
assert!(uri_to_path("not a uri").is_none());
assert!(uri_to_path("src/lib.rs").is_none());
}
#[test]
fn absolute_leaves_nothing_relative() {
assert!(absolute(Path::new("src/lib.rs")).is_absolute());
assert!(absolute(Path::new("no/such/file.rs")).is_absolute());
}
#[cfg(unix)]
#[test]
fn unix_paths_round_trip() {
for path in [
"/home/zeenix/src/lib.rs",
"/home/zeenix/my crate/src/lib.rs",
"/home/zeenix/#1?/src/lib.rs",
"/home/zeenix/ünïcøde/src/lib.rs",
] {
let uri = path_to_uri(Path::new(path)).unwrap();
assert!(uri.starts_with("file:///"), "{uri}");
assert_eq!(uri_to_path(&uri).unwrap(), Path::new(path), "{uri}");
assert_eq!(normalize(&uri), uri, "{uri}");
}
}
#[cfg(unix)]
#[test]
fn paths_that_are_not_utf8_are_refused() {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
let path = PathBuf::from(OsString::from_vec(b"/home/zeenix/\xff.rs".to_vec()));
let error = path_to_uri(&path).unwrap_err().to_string();
assert!(error.contains("UTF-8"), "{error}");
}
#[cfg(windows)]
#[test]
fn windows_paths_round_trip() {
for path in [
r"C:\Users\zeenix\src\lib.rs",
r"C:\Users\zeenix\my crate\src\lib.rs",
] {
let uri = path_to_uri(Path::new(path)).unwrap();
assert!(uri.starts_with("file:///C:/"), "{path} became {uri}");
assert!(!uri.contains('\\'), "{path} became {uri}");
assert_eq!(normalize(&uri), uri, "{uri}");
assert_eq!(uri_to_path(&uri).unwrap(), Path::new(path), "{uri}");
}
}
#[cfg(windows)]
#[test]
fn windows_shares_round_trip() {
let path = r"\\server\share\src\lib.rs";
let uri = path_to_uri(Path::new(path)).unwrap();
assert_eq!(uri, "file://server/share/src/lib.rs");
assert_eq!(uri_to_path(&uri).unwrap(), Path::new(path));
}
#[cfg(windows)]
#[test]
fn absolute_keeps_the_plain_spelling_of_a_path() {
let absolute = absolute(&std::env::temp_dir());
let spelling = absolute.to_str().unwrap();
assert!(!spelling.starts_with(r"\\?\"), "{spelling}");
assert!(absolute.join("a/b").parent().unwrap().ends_with("a"));
}
}