tower_lsp_server/
uri_ext.rs

1use percent_encoding::AsciiSet;
2use std::borrow::Cow;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6#[cfg(not(windows))]
7pub use std::fs::canonicalize as strict_canonicalize;
8
9/// On Windows, rewrites the wide path prefix `\\?\C:` to `C:`
10/// Source: https://stackoverflow.com/a/70970317
11#[inline]
12#[cfg(windows)]
13fn strict_canonicalize<P: AsRef<Path>>(path: P) -> std::io::Result<PathBuf> {
14    use std::io;
15
16    fn impl_(path: PathBuf) -> std::io::Result<PathBuf> {
17        let head = path
18            .components()
19            .next()
20            .ok_or(io::Error::other("empty path"))?;
21        let disk_;
22        let head = if let std::path::Component::Prefix(prefix) = head {
23            if let std::path::Prefix::VerbatimDisk(disk) = prefix.kind() {
24                disk_ = format!("{}:", disk as char);
25                Path::new(&disk_)
26                    .components()
27                    .next()
28                    .ok_or(io::Error::other("failed to parse disk component"))?
29            } else {
30                head
31            }
32        } else {
33            head
34        };
35        Ok(std::iter::once(head)
36            .chain(path.components().skip(1))
37            .collect())
38    }
39
40    let canon = std::fs::canonicalize(path)?;
41    impl_(canon)
42}
43
44#[cfg(windows)]
45fn capitalize_drive_letter(path: &str) -> String {
46    // Check if it's a Windows path starting with a drive letter like "c:/"
47    if path.len() >= 2 && path.chars().nth(1) == Some(':') {
48        let mut chars = path.chars();
49        let drive_letter = chars.next().unwrap().to_ascii_uppercase();
50        let rest: String = chars.collect();
51        format!("{}{}", drive_letter, rest)
52    } else {
53        path.to_string()
54    }
55}
56
57mod sealed {
58    pub trait Sealed {}
59}
60
61/// Provide methods to [`lsp_types::Uri`] to fill blanks left by
62/// `fluent_uri` (the underlying type) especially when converting to and from file paths.
63pub trait UriExt: Sized + sealed::Sealed {
64    /// Assuming the URL is in the `file` scheme or similar,
65    /// convert its path to an absolute `std::path::Path`.
66    ///
67    /// **Note:** This does not actually check the URL’s `scheme`, and may
68    /// give nonsensical results for other schemes. It is the user’s
69    /// responsibility to check the URL’s scheme before calling this.
70    ///
71    /// e.g. `Uri("file:///etc/passwd")` becomes `PathBuf("/etc/passwd")`
72    fn to_file_path(&self) -> Option<Cow<Path>>;
73
74    /// Convert a file path to a [`lsp_types::Uri`].
75    ///
76    /// Create a [`lsp_types::Uri`] from a file path.
77    ///
78    /// Returns `None` if the file does not exist.
79    fn from_file_path<A: AsRef<Path>>(path: A) -> Option<Self>;
80}
81
82impl sealed::Sealed for lsp_types::Uri {}
83
84const ASCII_SET: AsciiSet =
85    // RFC3986 allows only alphanumeric characters, `-`, `.`, `_`, and `~` in the path.
86    percent_encoding::NON_ALPHANUMERIC
87        .remove(b'-')
88        .remove(b'.')
89        .remove(b'_')
90        .remove(b'~')
91        // we do not want path separators to be percent-encoded
92        .remove(b'/');
93
94impl UriExt for lsp_types::Uri {
95    fn to_file_path(&self) -> Option<Cow<Path>> {
96        let path = match self.path().as_estr().decode().into_string_lossy() {
97            Cow::Borrowed(ref_) => Cow::Borrowed(Path::new(ref_)),
98            Cow::Owned(owned) => Cow::Owned(PathBuf::from(owned)),
99        };
100
101        if cfg!(windows) {
102            let auth_host = self
103                .authority()
104                .map(|auth| auth.host().as_str())
105                .unwrap_or_default();
106
107            if auth_host.is_empty() {
108                // very high chance this is a `file:///c:/...` uri
109                // in which case the path will include a leading slash we
110                // need to remove to get `c:/...`
111                let host = path.to_string_lossy();
112                let host = &host[1..];
113                return Some(Cow::Owned(PathBuf::from(host)));
114            }
115
116            Some(Cow::Owned(
117                // `file://server/...` becomes `server:/`
118                Path::new(&format!("{auth_host}:"))
119                    .components()
120                    .chain(path.components())
121                    .collect(),
122            ))
123        } else {
124            Some(path)
125        }
126    }
127
128    fn from_file_path<A: AsRef<Path>>(path: A) -> Option<Self> {
129        let path = path.as_ref();
130
131        let fragment = if path.is_absolute() {
132            Cow::Borrowed(path)
133        } else {
134            match strict_canonicalize(path) {
135                Ok(path) => Cow::Owned(path),
136                Err(_) => return None,
137            }
138        };
139
140        #[cfg(windows)]
141        let raw_uri = {
142            // we want to parse a triple-slash path for Windows paths
143            // it's a shorthand for `file://localhost/C:/Windows` with the `localhost` omitted.
144            // We encode the driver Letter `C:` as well. LSP Specification allows it.
145            // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#uri
146            format!(
147                "file:///{}",
148                percent_encoding::utf8_percent_encode(
149                    &capitalize_drive_letter(&fragment.to_string_lossy().replace('\\', "/")),
150                    &ASCII_SET
151                )
152            )
153        };
154
155        #[cfg(not(windows))]
156        let raw_uri = {
157            format!(
158                "file://{}",
159                percent_encoding::utf8_percent_encode(&fragment.to_string_lossy(), &ASCII_SET)
160            )
161        };
162
163        Self::from_str(&raw_uri).ok()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::strict_canonicalize;
170    use crate::UriExt;
171    use lsp_types::Uri;
172    use std::path::{Path, PathBuf};
173
174    fn with_schema(path: &str) -> String {
175        const EXPECTED_SCHEMA: &str = if cfg!(windows) { "file:///" } else { "file://" };
176        format!("{EXPECTED_SCHEMA}{path}")
177    }
178
179    #[test]
180    #[cfg(windows)]
181    fn test_idempotent_canonicalization() {
182        let lhs = strict_canonicalize(Path::new(".")).unwrap();
183        let rhs = strict_canonicalize(&lhs).unwrap();
184        assert_eq!(lhs, rhs);
185    }
186
187    #[test]
188    #[cfg(unix)]
189    fn test_path_roundtrip_conversion() {
190        let sources = [
191            strict_canonicalize(Path::new(".")).unwrap(),
192            PathBuf::from("/some/path/to/file.txt"),
193            PathBuf::from("/some/path/to/file with spaces.txt"),
194            PathBuf::from("/some/path/[[...rest]]/file.txt"),
195            PathBuf::from("/some/path/to/файл.txt"),
196            PathBuf::from("/some/path/to/文件.txt"),
197        ];
198
199        for source in sources {
200            let conv = Uri::from_file_path(&source).unwrap();
201            let roundtrip = conv.to_file_path().unwrap();
202            assert_eq!(source, roundtrip, "conv={conv:?}");
203        }
204    }
205
206    #[test]
207    #[cfg(windows)]
208    fn test_path_roundtrip_conversion() {
209        let sources = [
210            strict_canonicalize(Path::new(".")).unwrap(),
211            PathBuf::from("C:\\some\\path\\to\\file.txt"),
212            PathBuf::from("C:\\some\\path\\to\\file with spaces.txt"),
213            PathBuf::from("C:\\some\\path\\[[...rest]]\\file.txt"),
214            PathBuf::from("C:\\some\\path\\to\\файл.txt"),
215            PathBuf::from("C:\\some\\path\\to\\文件.txt"),
216        ];
217
218        for source in sources {
219            let conv = Uri::from_file_path(&source).unwrap();
220            let roundtrip = conv.to_file_path().unwrap();
221            assert_eq!(source, roundtrip, "conv={conv:?}");
222        }
223    }
224
225    #[test]
226    #[cfg(windows)]
227    fn test_windows_uri_roundtrip_conversion() {
228        use std::str::FromStr;
229
230        let uris = [
231            Uri::from_str("file:///C:/some/path/to/file.txt").unwrap(),
232            Uri::from_str("file:///c:/some/path/to/file.txt").unwrap(),
233            Uri::from_str("file:///c%3A/some/path/to/file.txt").unwrap(),
234        ];
235
236        let final_uri = Uri::from_str("file:///C%3A/some/path/to/file.txt").unwrap();
237
238        for uri in uris {
239            let path = uri.to_file_path().unwrap();
240            assert_eq!(
241                &path,
242                Path::new("C:\\some\\path\\to\\file.txt"),
243                "uri={uri:?}"
244            );
245
246            let conv = Uri::from_file_path(&path).unwrap();
247
248            assert_eq!(
249                final_uri,
250                conv,
251                "path={path:?} left={} right={}",
252                final_uri.as_str(),
253                conv.as_str()
254            );
255        }
256    }
257
258    #[test]
259    #[cfg(unix)]
260    fn test_path_to_uri() {
261        let paths = [
262            PathBuf::from("/some/path/to/file.txt"),
263            PathBuf::from("/some/path/to/file with spaces.txt"),
264            PathBuf::from("/some/path/[[...rest]]/file.txt"),
265            PathBuf::from("/some/path/to/файл.txt"),
266            PathBuf::from("/some/path/to/文件.txt"),
267        ];
268
269        let expected = [
270            with_schema("/some/path/to/file.txt"),
271            with_schema("/some/path/to/file%20with%20spaces.txt"),
272            with_schema("/some/path/%5B%5B...rest%5D%5D/file.txt"),
273            with_schema("/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"),
274            with_schema("/some/path/to/%E6%96%87%E4%BB%B6.txt"),
275        ];
276
277        for (path, expected) in paths.iter().zip(expected) {
278            let uri = Uri::from_file_path(path).unwrap();
279            assert_eq!(uri.to_string(), expected);
280        }
281    }
282
283    #[test]
284    #[cfg(windows)]
285    fn test_path_to_uri_windows() {
286        let paths = [
287            PathBuf::from("C:\\some\\path\\to\\file.txt"),
288            PathBuf::from("C:\\some\\path\\to\\file with spaces.txt"),
289            PathBuf::from("C:\\some\\path\\[[...rest]]\\file.txt"),
290            PathBuf::from("C:\\some\\path\\to\\файл.txt"),
291            PathBuf::from("C:\\some\\path\\to\\文件.txt"),
292        ];
293
294        // yes we encode `:` too, LSP allows it
295        // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#uri
296        let expected = [
297            with_schema("C%3A/some/path/to/file.txt"),
298            with_schema("C%3A/some/path/to/file%20with%20spaces.txt"),
299            with_schema("C%3A/some/path/%5B%5B...rest%5D%5D/file.txt"),
300            with_schema("C%3A/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"),
301            with_schema("C%3A/some/path/to/%E6%96%87%E4%BB%B6.txt"),
302        ];
303
304        for (path, expected) in paths.iter().zip(expected) {
305            let uri = Uri::from_file_path(path).unwrap();
306            assert_eq!(uri.to_string(), expected);
307        }
308    }
309}