Skip to main content

aether_lspd/
uri.rs

1//! URI↔path conversion utilities for LSP file URIs.
2
3use lsp_types::Uri;
4use std::path::{Path, PathBuf};
5use url::Url;
6
7#[derive(Debug, thiserror::Error)]
8#[error("Invalid path '{}': {reason}", path.display())]
9pub struct UriError {
10    path: PathBuf,
11    reason: String,
12}
13
14/// Convert a file path to an LSP `file://` URI.
15///
16/// Relative paths are resolved against the current working directory.
17pub fn path_to_uri(path: &Path) -> Result<Uri, UriError> {
18    let absolute =
19        if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
20    // Canonicalize to resolve symlinks (e.g. /var → /private/var on macOS).
21    // This ensures URIs match what language servers like rust-analyzer produce.
22    let absolute = absolute.canonicalize().unwrap_or(absolute);
23
24    let url = Url::from_file_path(&absolute)
25        .map_err(|()| UriError { path: absolute.clone(), reason: "not an absolute file path".to_string() })?;
26
27    url.as_str().parse::<Uri>().map_err(|e| UriError { path: absolute, reason: e.to_string() })
28}
29
30/// Convert an LSP `file://` URI to a file path string.
31///
32/// URIs that do not address a local file are returned unchanged.
33pub fn uri_to_path(uri: &Uri) -> String {
34    let uri_str = uri.as_str();
35    Url::parse(uri_str)
36        .ok()
37        .and_then(|url| url.to_file_path().ok())
38        .map_or_else(|| uri_str.to_string(), |path| path.to_string_lossy().into_owned())
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44    use std::path::Path;
45
46    #[test]
47    fn test_path_to_uri_absolute() {
48        let uri = path_to_uri(Path::new("/src/main.rs")).unwrap();
49        assert_eq!(uri.as_str(), "file:///src/main.rs");
50    }
51
52    #[test]
53    fn test_uri_to_path_basic() {
54        let uri: Uri = "file:///src/main.rs".parse().unwrap();
55        assert_eq!(uri_to_path(&uri), "/src/main.rs");
56    }
57
58    #[test]
59    fn test_roundtrip() {
60        let path = Path::new("/home/user/project/src/lib.rs");
61        let uri = path_to_uri(path).unwrap();
62        let back = uri_to_path(&uri);
63        assert_eq!(back, path.to_str().unwrap());
64    }
65
66    #[test]
67    fn path_to_uri_percent_encodes_reserved_characters() {
68        let cases = [
69            ("/src/my file.rs", "/src/my%20file.rs"),
70            ("/src/a#b.rs", "/src/a%23b.rs"),
71            ("/src/a?b.rs", "/src/a%3Fb.rs"),
72            ("/src/100%.rs", "/src/100%25.rs"),
73            ("/src/café.rs", "/src/caf%C3%A9.rs"),
74        ];
75
76        for (path, expected_uri_path) in cases {
77            let uri = path_to_uri(Path::new(path)).unwrap();
78            assert_eq!(uri.as_str(), format!("file://{expected_uri_path}"), "path: {path}");
79        }
80    }
81
82    #[test]
83    fn uri_to_path_decodes_percent_encoding() {
84        let cases = [
85            ("file:///src/my%20file.rs", "/src/my file.rs"),
86            ("file:///src/a%23b.rs", "/src/a#b.rs"),
87            ("file:///src/caf%C3%A9.rs", "/src/café.rs"),
88        ];
89
90        for (uri_str, expected_path) in cases {
91            let uri: Uri = uri_str.parse().unwrap();
92            assert_eq!(uri_to_path(&uri), expected_path, "uri: {uri_str}");
93        }
94    }
95
96    #[test]
97    fn roundtrips_paths_containing_reserved_characters() {
98        let path = "/home/user/my project/a#b.rs";
99        let uri = path_to_uri(Path::new(path)).unwrap();
100        assert!(uri.as_str().contains('%'), "expected percent-encoding in {}", uri.as_str());
101        assert_eq!(uri_to_path(&uri), path);
102    }
103
104    #[test]
105    fn uri_to_path_passes_non_file_uris_through_unchanged() {
106        let uri: Uri = "https://example.com/file.rs".parse().unwrap();
107        assert_eq!(uri_to_path(&uri), "https://example.com/file.rs");
108    }
109}