mcpls_core/bridge/
resources.rs1use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11use thiserror::Error;
12use tokio::sync::RwLock;
13use url::Url;
14
15use super::state::encode_rfc3986_path_chars;
16
17const SCHEME: &str = "lsp-diagnostics";
19
20const PREFIX: &str = "lsp-diagnostics://";
25
26pub const MAX_SUBSCRIPTIONS: usize = 1_000;
30
31#[derive(Debug, Error)]
33pub enum ResourceUriError {
34 #[error("path must be absolute and valid UTF-8: {0}")]
36 InvalidPath(String),
37
38 #[error("expected '{SCHEME}:///' prefix in URI: {0}")]
40 InvalidScheme(String),
41
42 #[error("failed to decode URI to filesystem path: {0}")]
44 DecodeFailed(String),
45}
46
47pub fn make_uri(path: &Path) -> Result<String, ResourceUriError> {
70 let file_url = Url::from_file_path(path)
71 .map_err(|()| ResourceUriError::InvalidPath(path.display().to_string()))?;
72
73 let encoded = encode_rfc3986_path_chars(&file_url);
76 let after_scheme = encoded.strip_prefix(file_url.scheme()).unwrap_or(&encoded);
77 let uri = format!("{SCHEME}{after_scheme}");
78 Ok(uri)
79}
80
81pub fn parse_uri(uri: &str) -> Result<PathBuf, ResourceUriError> {
100 if !uri.starts_with(PREFIX) {
101 return Err(ResourceUriError::InvalidScheme(uri.to_string()));
102 }
103
104 let after_prefix = &uri[PREFIX.len()..];
107 if !after_prefix.starts_with('/') {
108 return Err(ResourceUriError::InvalidScheme(format!(
109 "non-empty authority in URI: {uri}"
110 )));
111 }
112
113 let file_uri = format!("file://{after_prefix}");
114 let url = Url::parse(&file_uri).map_err(|e| ResourceUriError::DecodeFailed(e.to_string()))?;
115
116 url.to_file_path()
117 .map_err(|()| ResourceUriError::DecodeFailed(file_uri))
118}
119
120#[derive(Debug)]
125pub struct ResourceSubscriptions(RwLock<HashSet<String>>);
126
127impl Default for ResourceSubscriptions {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133impl ResourceSubscriptions {
134 #[must_use]
136 pub fn new() -> Self {
137 Self(RwLock::new(HashSet::new()))
138 }
139
140 pub async fn subscribe(&self, uri: String) -> Result<bool, String> {
149 let mut set = self.0.write().await;
150 if !set.contains(&uri) && set.len() >= MAX_SUBSCRIPTIONS {
151 return Err(format!("subscription limit of {MAX_SUBSCRIPTIONS} reached"));
152 }
153 Ok(set.insert(uri))
154 }
155
156 pub async fn is_empty(&self) -> bool {
161 self.0.read().await.is_empty()
162 }
163
164 pub async fn unsubscribe(&self, uri: &str) -> bool {
168 self.0.write().await.remove(uri)
169 }
170
171 pub async fn contains(&self, uri: &str) -> bool {
173 self.0.read().await.contains(uri)
174 }
175
176 pub async fn snapshot(&self) -> Vec<String> {
178 self.0.read().await.iter().cloned().collect()
179 }
180}
181
182#[cfg(test)]
183#[allow(clippy::unwrap_used, clippy::expect_used)]
184mod tests {
185 use super::*;
186
187 #[test]
192 fn test_make_uri_rejects_relative_path() {
193 let result = make_uri(Path::new("relative/path.rs"));
194 assert!(result.is_err());
195 }
196
197 #[test]
198 fn test_parse_uri_rejects_wrong_scheme() {
199 let result = parse_uri("file:///home/user/main.rs");
200 assert!(result.is_err());
201 }
202
203 #[test]
204 fn test_parse_uri_rejects_http_scheme() {
205 let result = parse_uri("https://example.com/file.rs");
206 assert!(result.is_err());
207 }
208
209 #[cfg(unix)]
210 #[test]
211 fn test_make_uri_simple_path() {
212 let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
213 assert_eq!(uri, "lsp-diagnostics:///home/user/main.rs");
214 }
215
216 #[cfg(unix)]
217 #[test]
218 fn test_make_uri_scheme_prefix() {
219 let uri = make_uri(Path::new("/tmp/file.rs")).unwrap();
220 assert!(uri.starts_with("lsp-diagnostics:///"));
221 }
222
223 #[cfg(unix)]
224 #[test]
225 fn test_parse_uri_simple() {
226 let path = PathBuf::from("/home/user/main.rs");
227 let uri = make_uri(&path).unwrap();
228 let recovered = parse_uri(&uri).unwrap();
229 assert_eq!(recovered, path);
230 }
231
232 #[cfg(unix)]
234 #[test]
235 fn test_round_trip_special_chars() {
236 let paths = [
237 "/home/user/my file.rs",
238 "/tmp/café/main.rs",
239 "/data/100%/test.rs",
240 "/workspace/query?param/file.rs",
241 "/repo/branch#fragment/src.rs",
242 "/путь/к/файлу.rs",
243 ];
244
245 for raw in &paths {
246 let path = PathBuf::from(raw);
247 let uri = make_uri(&path).expect(raw);
248 assert!(
249 uri.starts_with("lsp-diagnostics:///"),
250 "URI should start with correct scheme: {uri}"
251 );
252 let recovered = parse_uri(&uri).expect(&uri);
253 assert_eq!(recovered, path, "Round-trip failed for: {raw}");
254 }
255 }
256
257 #[cfg(unix)]
259 #[test]
260 fn test_wire_format_percent_encoded() {
261 let path = Path::new("/home/user/my file.rs");
262 let uri = make_uri(path).unwrap();
263 assert!(uri.contains("%20"), "Expected %20 in: {uri}");
265 assert!(uri.starts_with("lsp-diagnostics:///"));
266 }
267
268 #[cfg(unix)]
276 #[test]
277 fn test_make_uri_percent_encodes_reserved_chars() {
278 let path = Path::new("/home/user/test[]^|{}`.ts");
279 let uri = make_uri(path).unwrap();
280
281 for (raw, encoded) in [
282 ('[', "%5B"),
283 (']', "%5D"),
284 ('^', "%5E"),
285 ('|', "%7C"),
286 ('{', "%7B"),
287 ('}', "%7D"),
288 ('`', "%60"),
289 ] {
290 assert!(
291 uri.contains(encoded),
292 "expected {raw:?} to be percent-encoded as {encoded} in {uri}"
293 );
294 }
295 assert!(
296 !uri.contains(['[', ']', '^', '|', '{', '}', '`']),
297 "no raw reserved characters should remain in {uri}"
298 );
299 assert_eq!(parse_uri(&uri).unwrap(), path);
300 }
301
302 #[tokio::test]
307 async fn test_subscribe_and_contains() {
308 let subs = ResourceSubscriptions::new();
309 let uri = "lsp-diagnostics:///home/user/main.rs".to_string();
310
311 assert!(!subs.contains(&uri).await);
312 assert!(subs.subscribe(uri.clone()).await.unwrap());
313 assert!(subs.contains(&uri).await);
314 }
315
316 #[tokio::test]
317 async fn test_subscribe_duplicate_returns_false() {
318 let subs = ResourceSubscriptions::new();
319 let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
320 assert!(subs.subscribe(uri.clone()).await.unwrap());
321 assert!(!subs.subscribe(uri).await.unwrap());
322 }
323
324 #[tokio::test]
325 async fn test_unsubscribe() {
326 let subs = ResourceSubscriptions::new();
327 let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
328 subs.subscribe(uri.clone()).await.unwrap();
329 assert!(subs.unsubscribe(&uri).await);
330 assert!(!subs.contains(&uri).await);
331 }
332
333 #[tokio::test]
334 async fn test_unsubscribe_nonexistent_returns_false() {
335 let subs = ResourceSubscriptions::new();
336 assert!(!subs.unsubscribe("lsp-diagnostics:///nonexistent.rs").await);
337 }
338
339 #[tokio::test]
340 async fn test_subscribe_cap_exceeded() {
341 let subs = ResourceSubscriptions::new();
342 for i in 0..MAX_SUBSCRIPTIONS {
343 subs.subscribe(format!("lsp-diagnostics:///file{i}.rs"))
344 .await
345 .unwrap();
346 }
347 let result = subs
348 .subscribe("lsp-diagnostics:///overflow.rs".to_string())
349 .await;
350 assert!(result.is_err());
351 }
352
353 #[tokio::test]
354 async fn test_snapshot() {
355 let subs = ResourceSubscriptions::new();
356 subs.subscribe("lsp-diagnostics:///a.rs".to_string())
357 .await
358 .unwrap();
359 subs.subscribe("lsp-diagnostics:///b.rs".to_string())
360 .await
361 .unwrap();
362 let mut snap = subs.snapshot().await;
363 snap.sort();
364 assert_eq!(snap, ["lsp-diagnostics:///a.rs", "lsp-diagnostics:///b.rs"]);
365 }
366}