Skip to main content

mcpls_core/bridge/
resources.rs

1//! MCP resource URI codec and subscription tracking for LSP diagnostics.
2//!
3//! Resources in mcpls use the `lsp-diagnostics:///` scheme (RFC 3986 compliant,
4//! empty authority, percent-encoded path). Each resource corresponds to a single
5//! file whose diagnostics are cached from LSP `textDocument/publishDiagnostics`
6//! notifications.
7
8use 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
17/// URI scheme used for diagnostic resources.
18const SCHEME: &str = "lsp-diagnostics";
19
20/// Full scheme + authority prefix (`scheme://`).
21///
22/// Three-slash form (`lsp-diagnostics:///`) is produced by appending an empty
23/// authority and the absolute path: `{PREFIX}{path}`.
24const PREFIX: &str = "lsp-diagnostics://";
25
26/// Maximum number of resource URIs a single client session may subscribe to.
27///
28/// Guards against memory exhaustion from a misbehaving or adversarial client.
29pub const MAX_SUBSCRIPTIONS: usize = 1_000;
30
31/// Errors produced by the resource URI codec.
32#[derive(Debug, Error)]
33pub enum ResourceUriError {
34    /// The path is relative or contains non-UTF-8 components.
35    #[error("path must be absolute and valid UTF-8: {0}")]
36    InvalidPath(String),
37
38    /// The URI has the wrong scheme or malformed structure.
39    #[error("expected '{SCHEME}:///' prefix in URI: {0}")]
40    InvalidScheme(String),
41
42    /// The URI path could not be decoded to a filesystem path.
43    #[error("failed to decode URI to filesystem path: {0}")]
44    DecodeFailed(String),
45}
46
47/// Encode an absolute filesystem path into a `lsp-diagnostics:///…` resource URI.
48///
49/// Percent-encoding is delegated to [`url::Url::from_file_path`], which
50/// handles spaces, unicode, `%`, `?`, `#`, and platform separators correctly,
51/// plus an additional pass for the RFC 3986 §2.2 "other reserved" characters
52/// (`[ ] ^ |`) that `url` otherwise leaves unescaped — the same encoding
53/// applied to `file://` URIs.
54///
55/// # Errors
56///
57/// Returns [`ResourceUriError::InvalidPath`] if the path is relative or
58/// cannot be expressed as a valid file URI.
59///
60/// # Examples
61///
62/// ```
63/// use std::path::Path;
64/// use mcpls_core::bridge::resources::make_uri;
65///
66/// let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
67/// assert!(uri.starts_with("lsp-diagnostics:///"));
68/// ```
69pub 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    // Replace the "file" scheme with our custom scheme while keeping the
74    // percent-encoded path and authority (empty) components.
75    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
81/// Decode a `lsp-diagnostics:///…` resource URI back to an absolute filesystem path.
82///
83/// # Errors
84///
85/// Returns an error if the URI does not start with the expected scheme,
86/// or if the percent-encoded path cannot be mapped to a filesystem path.
87///
88/// # Examples
89///
90/// ```
91/// use std::path::Path;
92/// use mcpls_core::bridge::resources::{make_uri, parse_uri};
93///
94/// let path = Path::new("/home/user/main.rs");
95/// let uri = make_uri(path).unwrap();
96/// let recovered = parse_uri(&uri).unwrap();
97/// assert_eq!(recovered, path);
98/// ```
99pub 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    // Require empty authority: the character immediately after `://` must be `/`.
105    // This blocks `lsp-diagnostics://evil-host/path` → UNC path on Windows.
106    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/// Tracks which MCP resource URIs the client has subscribed to.
121///
122/// The hot read path (pump tasks checking before sending notifications) uses
123/// a `RwLock` so concurrent readers do not block each other.
124#[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    /// Create an empty subscription set.
135    #[must_use]
136    pub fn new() -> Self {
137        Self(RwLock::new(HashSet::new()))
138    }
139
140    /// Add a URI to the subscription set.
141    ///
142    /// Returns `Ok(true)` if newly inserted, `Ok(false)` if already present.
143    /// Returns `Err` if the subscription set has reached [`MAX_SUBSCRIPTIONS`].
144    ///
145    /// # Errors
146    ///
147    /// Returns an error string when the cap is exceeded.
148    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    /// Check whether the subscription set is empty.
157    ///
158    /// Used as a fast path in the diagnostics pump to skip URI construction
159    /// when no client has subscribed yet.
160    pub async fn is_empty(&self) -> bool {
161        self.0.read().await.is_empty()
162    }
163
164    /// Remove a URI from the subscription set.
165    ///
166    /// Returns `true` if the URI was present and removed.
167    pub async fn unsubscribe(&self, uri: &str) -> bool {
168        self.0.write().await.remove(uri)
169    }
170
171    /// Check if a URI is currently subscribed.
172    pub async fn contains(&self, uri: &str) -> bool {
173        self.0.read().await.contains(uri)
174    }
175
176    /// Return a snapshot of all subscribed URIs (primarily for tests).
177    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    // ------------------------------------------------------------------
188    // URI codec
189    // ------------------------------------------------------------------
190
191    #[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    /// Round-trip: paths with spaces, unicode, `%`, `?`, `#`.
233    #[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    /// Snapshot test: verify the on-wire form uses three slashes and percent-encoding.
258    #[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        // Space must be percent-encoded as %20
264        assert!(uri.contains("%20"), "Expected %20 in: {uri}");
265        assert!(uri.starts_with("lsp-diagnostics:///"));
266    }
267
268    /// #265 regression: all seven RFC 3986 §2.2 "other reserved" characters
269    /// must be percent-encoded in `lsp-diagnostics://` URIs, same as
270    /// `file://` URIs from `try_path_to_uri` (see
271    /// `test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars`
272    /// in `state.rs`). `{`, `}`, and backtick are already encoded by the
273    /// `url` crate on serialization; `[`, `]`, `^`, `|` are handled
274    /// explicitly by `encode_rfc3986_path_chars`.
275    #[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    // ------------------------------------------------------------------
303    // ResourceSubscriptions
304    // ------------------------------------------------------------------
305
306    #[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}