Skip to main content

perl_workspace_folder/
lib.rs

1//! Workspace folder URI/path parsing.
2//!
3//! This crate has one narrow responsibility: convert workspace folder entries into
4//! local filesystem paths with deterministic behavior for both plain paths and
5//! `file://` URIs.
6
7#![deny(unsafe_code)]
8#![warn(rust_2018_idioms)]
9#![warn(missing_docs)]
10#![warn(clippy::all)]
11
12use std::path::PathBuf;
13
14#[cfg(not(target_arch = "wasm32"))]
15use perl_uri::uri_to_fs_path;
16use serde_json::Value;
17
18/// URI lists extracted from an LSP workspace folder change event.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct WorkspaceFolderChange {
21    /// Added workspace folder URIs.
22    pub added: Vec<String>,
23    /// Removed workspace folder URIs.
24    pub removed: Vec<String>,
25}
26
27/// Parse a workspace folder declaration into a filesystem path.
28///
29/// Workspace folders can be passed as absolute paths or `file://` URIs. For
30/// `file://` URIs this attempts to resolve through `perl_uri::uri_to_fs_path`.
31/// If URI resolution fails, the scheme prefix is trimmed and the remainder is
32/// interpreted as a path fallback.
33#[must_use]
34pub fn workspace_folder_to_path(workspace_folder: &str) -> PathBuf {
35    if workspace_folder.starts_with("file://") {
36        #[cfg(not(target_arch = "wasm32"))]
37        if let Some(path) = uri_to_fs_path(workspace_folder) {
38            return path;
39        }
40
41        return PathBuf::from(workspace_folder.trim_start_matches("file://"));
42    }
43
44    PathBuf::from(workspace_folder)
45}
46
47/// Extract workspace folder URIs from an LSP `workspaceFolders` array.
48///
49/// Invalid entries are ignored.
50#[must_use]
51pub fn extract_workspace_folder_uris(workspace_folders: &[Value]) -> Vec<String> {
52    workspace_folders
53        .iter()
54        .filter_map(|folder| {
55            folder.get("uri").and_then(Value::as_str).map(std::string::ToString::to_string)
56        })
57        .collect()
58}
59
60/// Extract URI changes from an LSP `workspace/didChangeWorkspaceFolders` event payload.
61///
62/// Missing/invalid sections are treated as empty.
63#[must_use]
64pub fn extract_workspace_folder_change(event: &Value) -> WorkspaceFolderChange {
65    let added = event
66        .get("added")
67        .and_then(Value::as_array)
68        .map_or_else(Vec::new, |entries| extract_workspace_folder_uris(entries));
69
70    let removed = event
71        .get("removed")
72        .and_then(Value::as_array)
73        .map_or_else(Vec::new, |entries| extract_workspace_folder_uris(entries));
74
75    WorkspaceFolderChange { added, removed }
76}
77
78/// Convert a legacy LSP `rootPath` string to a `file://` URI.
79///
80/// This keeps behavior deterministic across absolute POSIX and Windows-style paths.
81#[must_use]
82pub fn root_path_to_file_uri(root_path: &str) -> String {
83    let path = std::path::Path::new(root_path);
84    url::Url::from_file_path(path).map_or_else(
85        |_| {
86            if root_path.starts_with('/') {
87                format!("file://{}", root_path)
88            } else {
89                format!("file:///{}", root_path.replace('\\', "/"))
90            }
91        },
92        |uri| uri.to_string(),
93    )
94}
95
96#[cfg(test)]
97mod tests {
98    use super::{
99        extract_workspace_folder_change, extract_workspace_folder_uris, root_path_to_file_uri,
100        workspace_folder_to_path,
101    };
102    use serde_json::json;
103    use std::path::PathBuf;
104
105    #[test]
106    fn parses_plain_folder_path() {
107        assert_eq!(workspace_folder_to_path("/tmp/project"), PathBuf::from("/tmp/project"));
108    }
109
110    #[cfg(not(target_arch = "wasm32"))]
111    #[test]
112    fn parses_file_uri_when_possible() {
113        let parsed = workspace_folder_to_path("file:///tmp/project");
114        assert!(parsed.to_string_lossy().contains("tmp"));
115        assert!(parsed.to_string_lossy().contains("project"));
116    }
117
118    #[test]
119    fn extracts_workspace_uris() {
120        let entries = vec![
121            json!({"uri": "file:///one"}),
122            json!({"uri": "file:///two"}),
123            json!({"name": "invalid"}),
124        ];
125        let uris = extract_workspace_folder_uris(&entries);
126        assert_eq!(uris, vec!["file:///one", "file:///two"]);
127    }
128
129    #[test]
130    fn extracts_workspace_change_entries() {
131        let change = extract_workspace_folder_change(&json!({
132            "added": [{"uri": "file:///add"}],
133            "removed": [{"uri": "file:///remove"}],
134        }));
135
136        assert_eq!(change.added, vec!["file:///add"]);
137        assert_eq!(change.removed, vec!["file:///remove"]);
138    }
139
140    #[test]
141    fn converts_legacy_root_path_to_file_uri() {
142        let uri = root_path_to_file_uri("/legacy/workspace");
143        assert_eq!(uri, "file:///legacy/workspace");
144    }
145}