Skip to main content

lspkit_server/
uri.rs

1//! URI ↔ `PathBuf` conversion.
2//!
3//! Handles `file://`, percent-decoding, Windows drive letters, and UNC paths.
4
5use std::path::{Path, PathBuf};
6
7/// Errors from URI conversion.
8#[non_exhaustive]
9#[derive(Debug, thiserror::Error)]
10pub enum UriError {
11    /// URI scheme is not `file://`.
12    #[error("unsupported URI scheme: {0}")]
13    UnsupportedScheme(String),
14    /// Percent-decoded URI is not valid UTF-8.
15    #[error("URI is not valid UTF-8 after percent decoding")]
16    InvalidUtf8,
17    /// Path could not be converted to a URI.
18    #[error("path cannot be expressed as a file URI: {0}")]
19    InvalidPath(PathBuf),
20}
21
22/// Convert a `file://` URI to a `PathBuf`.
23///
24/// # Errors
25/// Returns [`UriError`] for non-`file` schemes or invalid percent encoding.
26pub fn uri_to_path(uri: &str) -> Result<PathBuf, UriError> {
27    let rest = uri
28        .strip_prefix("file://")
29        .ok_or_else(|| UriError::UnsupportedScheme(uri.to_owned()))?;
30    let trimmed = rest.strip_prefix('/').unwrap_or(rest);
31    let decoded = percent_decode(trimmed)?;
32    if cfg!(windows) {
33        let normalized = decoded.replace('/', "\\");
34        Ok(PathBuf::from(normalized))
35    } else {
36        Ok(PathBuf::from(format!("/{decoded}")))
37    }
38}
39
40/// Convert a filesystem path to a `file://` URI.
41///
42/// # Errors
43/// Returns [`UriError::InvalidPath`] if the path is not absolute or contains
44/// components that cannot be expressed in a URI.
45pub fn path_to_uri(path: &Path) -> Result<String, UriError> {
46    if !path.is_absolute() {
47        return Err(UriError::InvalidPath(path.to_path_buf()));
48    }
49    let as_str = path
50        .to_str()
51        .ok_or_else(|| UriError::InvalidPath(path.to_path_buf()))?;
52    let normalized = as_str.replace('\\', "/");
53    let encoded = percent_encode(&normalized);
54    if normalized.starts_with('/') {
55        Ok(format!("file://{encoded}"))
56    } else {
57        Ok(format!("file:///{encoded}"))
58    }
59}
60
61fn percent_decode(input: &str) -> Result<String, UriError> {
62    let bytes = input.as_bytes();
63    let mut out = Vec::with_capacity(bytes.len());
64    let mut idx = 0;
65    while idx < bytes.len() {
66        if bytes[idx] == b'%' && idx + 2 < bytes.len() {
67            let hi = hex_digit(bytes[idx + 1])?;
68            let lo = hex_digit(bytes[idx + 2])?;
69            out.push((hi << 4) | lo);
70            idx += 3;
71        } else {
72            out.push(bytes[idx]);
73            idx += 1;
74        }
75    }
76    String::from_utf8(out).map_err(|_| UriError::InvalidUtf8)
77}
78
79fn hex_digit(byte: u8) -> Result<u8, UriError> {
80    match byte {
81        b'0'..=b'9' => Ok(byte - b'0'),
82        b'a'..=b'f' => Ok(byte - b'a' + 10),
83        b'A'..=b'F' => Ok(byte - b'A' + 10),
84        _ => Err(UriError::InvalidUtf8),
85    }
86}
87
88fn percent_encode(input: &str) -> String {
89    use std::fmt::Write as _;
90    let mut out = String::with_capacity(input.len());
91    for byte in input.bytes() {
92        match byte {
93            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {
94                out.push(byte as char);
95            }
96            _ => {
97                let _ = write!(out, "%{byte:02X}");
98            }
99        }
100    }
101    out
102}