use std::path::{Path, PathBuf};
use tower_lsp_server::ls_types::Uri;
pub(super) fn file_uri_for_path(path: &Path) -> Result<Uri, String> {
let normalized = path.to_string_lossy().replace('\\', "/");
let encoded = encode_uri_path(&normalized);
let uri = if encoded.starts_with('/') {
format!("file://{encoded}")
} else {
format!("file:///{encoded}")
};
uri.parse()
.map_err(|err| format!("failed to build LSP file URI for {}: {err}", path.display()))
}
pub(super) fn path_from_file_uri(uri: &Uri) -> Option<PathBuf> {
normalized_file_uri_path(uri).map(PathBuf::from)
}
pub(super) fn file_uris_match(left: &Uri, right: &Uri) -> bool {
if left == right {
return true;
}
let Some(left_path) = normalized_file_uri_path(left) else {
return false;
};
let Some(right_path) = normalized_file_uri_path(right) else {
return false;
};
if is_windows_drive_path(&left_path) && is_windows_drive_path(&right_path) {
return left_path.eq_ignore_ascii_case(&right_path);
}
left_path == right_path
}
fn normalized_file_uri_path(uri: &Uri) -> Option<String> {
let raw = uri.as_str();
let path = raw.strip_prefix("file://")?;
let decoded = percent_decode_uri_path(path)?;
let path = if is_windows_drive_uri_path(&decoded) {
decoded[1..].to_string()
} else {
decoded
};
Some(path.replace('\\', "/"))
}
fn is_windows_drive_uri_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3 && bytes[0] == b'/' && bytes[2] == b':' && bytes[1].is_ascii_alphabetic()
}
fn is_windows_drive_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}
fn percent_decode_uri_path(path: &str) -> Option<String> {
let bytes = path.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let high = hex_value(*bytes.get(index + 1)?)?;
let low = hex_value(*bytes.get(index + 2)?)?;
decoded.push((high << 4) | low);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
pub(super) fn encode_uri_path(path: &str) -> String {
let mut encoded = String::new();
for byte in path.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
encoded.push(byte as char)
}
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_uri(value: &str) -> Result<Uri, String> {
value
.parse()
.map_err(|err| format!("failed to parse test URI {value}: {err}"))
}
#[test]
fn file_uri_for_path_percent_encodes_spaces_and_symbols() -> Result<(), String> {
let uri = file_uri_for_path(Path::new("/tmp/ripr fixtures/a#b?.rs"))?;
assert_eq!(uri.as_str(), "file:///tmp/ripr%20fixtures/a%23b%3F.rs");
assert_eq!(
path_from_file_uri(&uri).ok_or("expected decoded path")?,
PathBuf::from("/tmp/ripr fixtures/a#b?.rs")
);
Ok(())
}
#[test]
fn file_uri_for_path_percent_encodes_unicode_relative_paths() -> Result<(), String> {
let uri = file_uri_for_path(Path::new("workspace/ripr/src/cafe_menu.rs"))?;
assert_eq!(uri.as_str(), "file:///workspace/ripr/src/cafe_menu.rs");
let uri = file_uri_for_path(Path::new("workspace/ripr/src/café.rs"))?;
assert_eq!(uri.as_str(), "file:///workspace/ripr/src/caf%C3%A9.rs");
Ok(())
}
#[test]
fn invalid_percent_encoding_is_not_a_file_path() -> Result<(), String> {
let uri = parse_uri("file:///tmp/%FF.rs")?;
assert_eq!(path_from_file_uri(&uri), None);
Ok(())
}
#[test]
fn path_from_file_uri_rejects_non_file_scheme() -> Result<(), String> {
let uri = parse_uri("https://example.test/src.rs")?;
assert_eq!(path_from_file_uri(&uri), None);
Ok(())
}
#[test]
fn file_uris_match_normalizes_percent_encoded_separators() -> Result<(), String> {
let encoded_separator = parse_uri("file:///workspace/ripr/src%2Flib.rs")?;
let literal_separator = parse_uri("file:///workspace/ripr/src/lib.rs")?;
assert!(file_uris_match(&encoded_separator, &literal_separator));
Ok(())
}
#[test]
fn windows_drive_file_uris_match_case_insensitively() -> Result<(), String> {
let upper = parse_uri("file:///C:/Work/Ripr/src/lib.rs")?;
let lower = parse_uri("file:///c:/Work/Ripr/src/lib.rs")?;
assert!(file_uris_match(&upper, &lower));
Ok(())
}
#[test]
fn file_uris_match_keeps_non_windows_paths_case_sensitive() -> Result<(), String> {
let upper = parse_uri("file:///workspace/ripr/src/Lib.rs")?;
let lower = parse_uri("file:///workspace/ripr/src/lib.rs")?;
assert!(!file_uris_match(&upper, &lower));
Ok(())
}
}