use std::path::{Path, PathBuf};
use lsp_types::Uri;
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
pub fn uri_to_file_path(uri: &Uri) -> Option<PathBuf> {
let text = uri.as_str();
let rest = text.strip_prefix("file://")?;
let path_text = match rest.strip_prefix('/') {
Some(path) => format!("/{path}"),
None => match rest.split_once('/') {
Some((host, path)) => format!("//{host}/{path}"),
None => return None,
},
};
let decoded = percent_decode_str(&path_text).decode_utf8().ok()?;
let trimmed = match decoded.strip_prefix('/') {
Some(rest) if looks_like_a_windows_drive(rest) => rest,
_ => decoded.as_ref(),
};
Some(PathBuf::from(
trimmed.replace('/', std::path::MAIN_SEPARATOR_STR),
))
}
pub fn path_to_uri(path: &Path) -> Option<Uri> {
let text = path.to_str()?;
let text = text.replace(std::path::MAIN_SEPARATOR, "/");
let (authority, body) = match text.strip_prefix("//") {
Some(rest) => match rest.split_once('/') {
Some((host, path)) => (host.to_string(), format!("/{path}")),
None => (rest.to_string(), String::new()),
},
None if looks_like_a_windows_drive(&text) => (String::new(), format!("/{text}")),
None => (String::new(), text),
};
let encoded = utf8_percent_encode(&body, PATH_ESCAPE).to_string();
format!("file://{authority}{encoded}").parse::<Uri>().ok()
}
const PATH_ESCAPE: &percent_encoding::AsciiSet = &NON_ALPHANUMERIC
.remove(b'/')
.remove(b':')
.remove(b'.')
.remove(b'-')
.remove(b'_')
.remove(b'~')
.remove(b'!')
.remove(b'$')
.remove(b'&')
.remove(b'\'')
.remove(b'(')
.remove(b')')
.remove(b'*')
.remove(b'+')
.remove(b',')
.remove(b';')
.remove(b'=')
.remove(b'@');
fn looks_like_a_windows_drive(text: &str) -> bool {
let mut characters = text.chars();
matches!(
(characters.next(), characters.next()),
(Some(letter), Some(':')) if letter.is_ascii_alphabetic()
)
}
#[cfg(test)]
mod tests {
use super::*;
fn written(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[test]
fn a_windows_path_survives_the_round_trip() {
let uri: Uri = "file:///C:/work/a%20b/x.cpp".parse().unwrap();
let path = uri_to_file_path(&uri).unwrap();
assert!(path.ends_with("x.cpp"));
assert!(path.to_str().unwrap().contains("a b"));
let back = path_to_uri(&path).unwrap();
assert_eq!(back.as_str(), "file:///C:/work/a%20b/x.cpp");
}
#[test]
fn a_rooted_path_keeps_its_three_slashes() {
let uri: Uri = "file:///home/dev/main.cpp".parse().unwrap();
let path = uri_to_file_path(&uri).unwrap();
assert!(
written(&path).ends_with("/home/dev/main.cpp"),
"{}",
written(&path)
);
assert_eq!(
path_to_uri(&path).unwrap().as_str(),
"file:///home/dev/main.cpp"
);
}
#[test]
fn what_a_uri_cannot_hold_is_escaped() {
let path = Path::new("/p/a#b?c d/é.cpp");
let uri = path_to_uri(path).expect("the path is valid UTF-8");
assert!(uri.as_str().contains("a%23b%3Fc%20d"), "{}", uri.as_str());
assert!(uri.as_str().contains("%C3%A9"), "{}", uri.as_str());
assert_eq!(
written(&uri_to_file_path(&uri).unwrap()),
written(path),
"and the path comes back"
);
}
#[test]
fn a_uri_without_a_path_has_no_answer() {
let uri: Uri = "untitled:Untitled-1".parse().unwrap();
assert!(uri_to_file_path(&uri).is_none());
}
#[test]
fn a_uri_with_a_host_is_a_unc_path() {
let uri: Uri = "file://server/share/x.cpp".parse().unwrap();
let path = uri_to_file_path(&uri).unwrap();
assert_eq!(written(&path), "//server/share/x.cpp");
assert_eq!(path_to_uri(&path).unwrap().as_str(), uri.as_str());
}
}