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::{HashMap, HashSet};
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12use thiserror::Error;
13use tokio::sync::RwLock;
14use url::Url;
15
16use super::state::{encode_rfc3986_path_chars, file_url, uri_to_path};
17use super::{ClientPath, InvalidClientPath, WorkspaceRoots};
18
19/// URI scheme used for diagnostic resources.
20const SCHEME: &str = "lsp-diagnostics";
21
22/// Full scheme + authority prefix (`scheme://`).
23///
24/// Three-slash form (`lsp-diagnostics:///`) is produced by appending an empty
25/// authority and the absolute path: `{PREFIX}{path}`.
26const PREFIX: &str = "lsp-diagnostics://";
27
28/// Maximum number of resource URIs a single client session may subscribe to.
29///
30/// Guards against memory exhaustion from a misbehaving or adversarial client.
31pub const MAX_SUBSCRIPTIONS: usize = 1_000;
32
33/// Maximum number of concurrent `subscriptions/listen` streams, across all
34/// transports.
35///
36/// Listen streams are bounded by their connection and take no
37/// `max_concurrent_sessions` slot, so they need a cap of their own. A
38/// constant, not a knob: it equals the default `HttpConfig` session cap and
39/// is independent of the configured one.
40pub(crate) const MAX_LISTEN_STREAMS: usize = 100;
41
42/// Errors produced by the resource URI codec.
43#[derive(Debug, Error)]
44pub enum ResourceUriError {
45    /// The path is relative or contains non-UTF-8 components.
46    #[error("path must be absolute and valid UTF-8: {0}")]
47    InvalidPath(String),
48
49    /// The URI has the wrong scheme or malformed structure.
50    #[error("expected '{SCHEME}:///' prefix in URI: {0}")]
51    InvalidScheme(String),
52
53    /// The URI is not parseable as a URL.
54    #[error("malformed resource URI: {0}")]
55    UrlParse(#[source] url::ParseError),
56
57    /// The URI does not name a filesystem path.
58    #[error("URI does not name a filesystem path: {0}")]
59    NotAFilePath(String),
60
61    /// The decoded path is not an acceptable client path.
62    #[error("decoded URI path is not acceptable: {0}")]
63    ClientPath(#[source] InvalidClientPath),
64}
65
66/// Errors produced when adding a URI to a [`ResourceSubscriptions`] set.
67#[derive(Debug, Error, PartialEq, Eq)]
68pub(crate) enum SubscriptionError {
69    /// The session's subscription set has already reached [`MAX_SUBSCRIPTIONS`].
70    #[error("subscription limit of {MAX_SUBSCRIPTIONS} reached")]
71    LimitReached,
72
73    /// [`MAX_LISTEN_STREAMS`] listen streams are already open.
74    #[error("listen stream limit of {MAX_LISTEN_STREAMS} reached")]
75    ListenLimitReached,
76}
77
78impl From<SubscriptionError> for crate::error::Error {
79    fn from(err: SubscriptionError) -> Self {
80        match err {
81            SubscriptionError::LimitReached => Self::SubscriptionLimitReached {
82                max: MAX_SUBSCRIPTIONS,
83            },
84            SubscriptionError::ListenLimitReached => Self::ListenStreamsExhausted {
85                max: MAX_LISTEN_STREAMS,
86            },
87        }
88    }
89}
90
91/// Encode an absolute filesystem path into a `lsp-diagnostics:///…` resource URI.
92///
93/// Percent-encoding is delegated to [`url::Url::from_file_path`], which
94/// handles spaces, unicode, `%`, `?`, `#`, and platform separators correctly,
95/// plus an additional pass for the RFC 3986 §2.2 "other reserved" characters
96/// (`[ ] ^ |`) that `url` otherwise leaves unescaped — the same encoding
97/// applied to `file://` URIs.
98///
99/// # Errors
100///
101/// Returns [`ResourceUriError::InvalidPath`] if the path is relative or
102/// cannot be expressed as a valid file URI.
103///
104/// # Examples
105///
106/// ```
107/// use std::path::Path;
108/// use mcpls_core::bridge::resources::make_uri;
109///
110/// let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
111/// assert!(uri.starts_with("lsp-diagnostics:///"));
112/// ```
113pub fn make_uri(path: &Path) -> Result<String, ResourceUriError> {
114    let file_url =
115        file_url(path).ok_or_else(|| ResourceUriError::InvalidPath(path.display().to_string()))?;
116
117    // Replace the "file" scheme with our custom scheme while keeping the
118    // percent-encoded path and authority (empty) components.
119    let encoded = encode_rfc3986_path_chars(&file_url);
120    let after_scheme = encoded.strip_prefix(file_url.scheme()).unwrap_or(&encoded);
121    let uri = format!("{SCHEME}{after_scheme}");
122    Ok(uri)
123}
124
125/// Decode a `lsp-diagnostics:///…` resource URI back to an absolute filesystem path.
126///
127/// # Errors
128///
129/// Returns an error if the URI does not start with the expected scheme,
130/// or if the percent-encoded path cannot be mapped to a filesystem path
131/// (including a decoded NUL byte).
132///
133/// # Examples
134///
135/// ```
136/// use std::path::Path;
137/// use mcpls_core::bridge::resources::{make_uri, parse_uri};
138///
139/// let path = Path::new("/home/user/main.rs");
140/// let uri = make_uri(path).unwrap();
141/// let recovered = parse_uri(&uri).unwrap();
142/// assert_eq!(recovered.as_path(), path);
143/// ```
144pub fn parse_uri(uri: &str) -> Result<ClientPath, ResourceUriError> {
145    if !uri.starts_with(PREFIX) {
146        return Err(ResourceUriError::InvalidScheme(uri.to_string()));
147    }
148
149    // Require empty authority: the character immediately after `://` must be `/`.
150    // This blocks `lsp-diagnostics://evil-host/path` → UNC path on Windows.
151    let after_prefix = &uri[PREFIX.len()..];
152    if !after_prefix.starts_with('/') {
153        return Err(ResourceUriError::InvalidScheme(format!(
154            "non-empty authority in URI: {uri}"
155        )));
156    }
157
158    let file_uri = format!("file://{after_prefix}");
159    let url = Url::parse(&file_uri).map_err(ResourceUriError::UrlParse)?;
160
161    let path = url
162        .to_file_path()
163        .map_err(|()| ResourceUriError::NotAFilePath(file_uri))?;
164    ClientPath::try_from(path).map_err(ResourceUriError::ClientPath)
165}
166
167/// A `lsp-diagnostics:///` resource URI in the form the diagnostics pump
168/// publishes under -- the only key subscription and delivery state uses.
169///
170/// The field is private, so a value is either the result of
171/// [`Self::resolve`] (a client URI parsed, workspace-checked and re-encoded
172/// from its canonical path) or of [`Self::for_published`] (derived from a
173/// path an LSP server published diagnostics for). Raw client strings
174/// therefore cannot reach the subscription set, the pending set or the
175/// delivery target without going through one of them.
176#[derive(Debug, Clone, PartialEq, Eq, Hash)]
177pub(crate) struct DiagnosticsResourceUri(String);
178
179/// A URI an LSP server published diagnostics for, paired with the canonical
180/// URI of the same file.
181///
182/// A server may publish through a symlinked spelling of a path (rust-analyzer
183/// does, for cargo-metadata paths); keying by that raw spelling would never
184/// match a subscription or read made through the canonical path. The only
185/// constructor is [`Self::from_canonical_path`], fed by the pump's resolver,
186/// so a published URI cannot reach the cache index or a subscription key
187/// without being canonicalized and workspace-checked.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub(crate) struct PublishedDiagnosticsUri {
190    source: lsp_types::Uri,
191    canonical: lsp_types::Uri,
192    /// Whether the server spelled the path canonically, decided on the paths
193    /// rather than the URI text so percent-encoding and drive-letter case
194    /// cannot make a canonical spelling look like an alias.
195    is_canonical: bool,
196}
197
198/// How trustworthy the canonical path handed to
199/// [`PublishedDiagnosticsUri::from_canonical_path`] is.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub(crate) enum CanonicalForm {
202    /// Produced by canonicalizing the filesystem (now or earlier).
203    Resolved,
204    /// The filesystem could not be asked; the published spelling stood in.
205    /// Never reported as canonical, so a stand-in cannot pass for the real
206    /// key in the alias index.
207    Fallback,
208}
209
210impl PublishedDiagnosticsUri {
211    /// Pairs `published` with its already-resolved `canonical_path`, checking
212    /// the result against `roots`.
213    ///
214    /// Returns `None` for a canonical path that cannot be encoded as a URI or
215    /// that lies outside every root (for example a symlink pointing out of
216    /// the workspace). Resolving the path is
217    /// [`PublishedPathResolver`](super::PublishedPathResolver)'s job.
218    pub(super) fn from_canonical_path(
219        published: &lsp_types::Uri,
220        published_path: &Path,
221        canonical_path: &Path,
222        form: CanonicalForm,
223        roots: &WorkspaceRoots,
224    ) -> Option<Self> {
225        let canonical = super::try_path_to_uri(canonical_path)?;
226        roots.contains_canonical(canonical_path).then(|| Self {
227            source: published.clone(),
228            canonical,
229            is_canonical: form == CanonicalForm::Resolved
230                && same_path(published_path, canonical_path),
231        })
232    }
233
234    #[cfg(test)]
235    pub(crate) fn for_test(source: lsp_types::Uri, canonical: lsp_types::Uri) -> Self {
236        let is_canonical = source == canonical;
237        Self {
238            source,
239            canonical,
240            is_canonical,
241        }
242    }
243
244    /// Whether the server published under the canonical spelling of the path.
245    pub(crate) const fn is_canonical(&self) -> bool {
246        self.is_canonical
247    }
248
249    /// The URI exactly as the server published it.
250    pub(crate) const fn source(&self) -> &lsp_types::Uri {
251        &self.source
252    }
253
254    /// The canonical URI of the published file.
255    pub(crate) const fn canonical(&self) -> &lsp_types::Uri {
256        &self.canonical
257    }
258}
259
260/// Path equality as the platform's filesystem sees the spelling: drive
261/// letters and names are case-insensitive on Windows only.
262fn same_path(a: &Path, b: &Path) -> bool {
263    if cfg!(windows) {
264        a.as_os_str()
265            .to_string_lossy()
266            .eq_ignore_ascii_case(&b.as_os_str().to_string_lossy())
267    } else {
268        a == b
269    }
270}
271
272/// A client resource URI resolved against the workspace roots.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub(crate) struct ResolvedResource {
275    /// Canonical filesystem path the URI refers to.
276    pub(crate) path: PathBuf,
277    /// URI re-encoded from [`Self::path`].
278    pub(crate) uri: DiagnosticsResourceUri,
279}
280
281impl DiagnosticsResourceUri {
282    /// Parses `raw`, validates the path against `roots` (canonicalizing it)
283    /// and re-encodes the canonical path.
284    ///
285    /// Touches the filesystem; call from a blocking context when resolving
286    /// many URIs.
287    ///
288    /// # Errors
289    ///
290    /// Returns [`crate::Error::ResourceUri`] when `raw` is not a well-formed
291    /// `lsp-diagnostics:///` URI, the workspace validation error when the
292    /// path is missing or outside `roots`, or [`crate::Error::PathToUri`] when
293    /// the canonical path cannot be encoded again.
294    pub(crate) fn resolve(
295        raw: &str,
296        roots: &WorkspaceRoots,
297    ) -> crate::error::Result<ResolvedResource> {
298        let parsed = parse_uri(raw)?;
299        // `canonicalize` yields a `\\?\` verbatim path on Windows, which LSP
300        // servers never publish; `dunce` strips it where that is safe.
301        let validated = roots.validate_blocking(&parsed)?;
302        let path = dunce::simplified(validated.as_path()).to_path_buf();
303        let uri = Self(make_uri(&path).map_err(|_| crate::Error::PathToUri(path.clone()))?);
304        Ok(ResolvedResource { path, uri })
305    }
306
307    /// The key the pump derives for a file an LSP server published
308    /// diagnostics for: the canonical form, so it matches the key
309    /// [`Self::resolve`] derives from a client URI for the same file.
310    pub(crate) fn for_published(published: &PublishedDiagnosticsUri) -> Option<Self> {
311        Self::for_canonical_file(&published.canonical)
312    }
313
314    /// The key for a file the tracker holds, given its canonical LSP URI.
315    pub(crate) fn for_canonical_file(file: &lsp_types::Uri) -> Option<Self> {
316        make_uri(&uri_to_path(file)?).ok().map(Self)
317    }
318
319    /// The wire form of the URI.
320    pub(crate) fn as_str(&self) -> &str {
321        &self.0
322    }
323
324    #[cfg(test)]
325    pub(crate) fn for_test(uri: &str) -> Self {
326        Self(uri.to_owned())
327    }
328}
329
330impl fmt::Display for DiagnosticsResourceUri {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        f.write_str(&self.0)
333    }
334}
335
336impl From<DiagnosticsResourceUri> for String {
337    fn from(uri: DiagnosticsResourceUri) -> Self {
338        uri.0
339    }
340}
341
342/// Internal state guarded by [`ResourceSubscriptions`]'s `RwLock`.
343#[derive(Debug, Default)]
344struct SubscriptionState {
345    /// Canonical resource URIs currently subscribed -- what the diagnostics
346    /// pump checks against.
347    canonical: HashSet<DiagnosticsResourceUri>,
348    /// Client-supplied ("raw") URI -> canonical URI, recorded at subscribe
349    /// time for entries where the two differ (symlink, macOS `/var` vs
350    /// `/private/var`, ...). Lets a later `unsubscribe` for the same raw URI
351    /// still resolve to the right entry even if canonicalizing it at
352    /// unsubscribe time fails, e.g. because the file was deleted since
353    /// subscribing (#499).
354    aliases: HashMap<String, DiagnosticsResourceUri>,
355}
356
357/// Tracks which MCP resource URIs the client has subscribed to.
358///
359/// The hot read path (pump tasks checking before sending notifications) uses
360/// a `RwLock` so concurrent readers do not block each other.
361#[derive(Debug)]
362pub(crate) struct ResourceSubscriptions(RwLock<SubscriptionState>);
363
364impl Default for ResourceSubscriptions {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370impl ResourceSubscriptions {
371    /// Create an empty subscription set.
372    #[must_use]
373    pub fn new() -> Self {
374        Self(RwLock::new(SubscriptionState::default()))
375    }
376
377    /// Create a set pre-populated with `uris`, for request-scoped sessions
378    /// whose subscriptions are fixed up front (`subscriptions/listen`).
379    pub(crate) fn from_canonical(uris: impl IntoIterator<Item = DiagnosticsResourceUri>) -> Self {
380        Self(RwLock::new(SubscriptionState {
381            canonical: uris.into_iter().collect(),
382            aliases: HashMap::new(),
383        }))
384    }
385
386    /// Add a URI to the subscription set.
387    ///
388    /// Returns `Ok(true)` if newly inserted, `Ok(false)` if already present.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`SubscriptionError::LimitReached`] if the set has already
393    /// reached [`MAX_SUBSCRIPTIONS`] and `uri` is not already a member.
394    pub(crate) async fn subscribe(
395        &self,
396        uri: DiagnosticsResourceUri,
397    ) -> Result<bool, SubscriptionError> {
398        let mut state = self.0.write().await;
399        if !state.canonical.contains(&uri) && state.canonical.len() >= MAX_SUBSCRIPTIONS {
400            return Err(SubscriptionError::LimitReached);
401        }
402        Ok(state.canonical.insert(uri))
403    }
404
405    /// Record that `raw_uri` (the client-supplied URI, before
406    /// canonicalization) currently corresponds to `canonical_uri`, so a
407    /// later [`Self::unsubscribe`] for the same raw URI still resolves even
408    /// if canonicalization fails by then (#499). A no-op when the two are
409    /// equal, or when `canonical_uri` is not (or is no longer) an actual
410    /// subscribed entry -- the latter also closes a race against a
411    /// concurrent [`Self::unsubscribe`] landing between a caller's own
412    /// `subscribe`/`record_alias` pair.
413    ///
414    /// Drops any existing alias that already points at `canonical_uri`
415    /// before inserting the new one, so at most one alias is kept per
416    /// canonical entry. This keeps the alias map's size structurally bounded
417    /// by the canonical set's size (itself capped at [`MAX_SUBSCRIPTIONS`] by
418    /// [`Self::subscribe`]), instead of an independent bound: without this,
419    /// re-subscribing under many distinct raw encodings of the same
420    /// already-subscribed file (each a no-op against the canonical set, so
421    /// never gated by the subscribe cap) could otherwise exhaust an
422    /// independent alias-count bound on a single file, starving aliases for
423    /// every other subscription.
424    ///
425    /// Residual (#499): one-alias-per-canonical narrows but does not fully
426    /// eliminate the leak this exists to close. A file subscribed under two
427    /// distinct raw URIs, then deleted, then unsubscribed via the
428    /// non-latest raw form still leaks one slot -- self-healing the moment
429    /// the client instead unsubscribes via the latest recorded form.
430    pub(crate) async fn record_alias(
431        &self,
432        raw_uri: String,
433        canonical_uri: &DiagnosticsResourceUri,
434    ) {
435        if raw_uri == canonical_uri.as_str() {
436            return;
437        }
438        let mut state = self.0.write().await;
439        if !state.canonical.contains(canonical_uri) {
440            return;
441        }
442        state.aliases.retain(|_, c| c != canonical_uri);
443        state.aliases.insert(raw_uri, canonical_uri.clone());
444    }
445
446    /// Check whether the subscription set is empty.
447    ///
448    /// Used as a fast path in the diagnostics pump to skip URI construction
449    /// when no client has subscribed yet.
450    pub async fn is_empty(&self) -> bool {
451        self.0.read().await.canonical.is_empty()
452    }
453
454    /// Remove a subscription from the set.
455    ///
456    /// A recorded alias for `raw` (see `Self::record_alias`, crate-private)
457    /// wins, so a symlink retargeted since subscribing still removes the entry
458    /// it was subscribed under. Otherwise `canonical` (when the caller could
459    /// canonicalize the client URI) is tried, then `raw` verbatim.
460    ///
461    /// Returns the canonical URI that was removed, or `None` if no
462    /// subscription matched -- the caller needs it to purge the same entry
463    /// from any state keyed by canonical URI when `raw` was an alias.
464    pub(crate) async fn unsubscribe(
465        &self,
466        canonical: Option<&DiagnosticsResourceUri>,
467        raw: &str,
468    ) -> Option<DiagnosticsResourceUri> {
469        let mut state = self.0.write().await;
470        let removed = state
471            .aliases
472            .remove(raw)
473            .and_then(|aliased| state.canonical.take(&aliased))
474            .or_else(|| canonical.and_then(|c| state.canonical.take(c)))
475            // Lookup key only: `take` returns the stored entry, never this value.
476            .or_else(|| {
477                state
478                    .canonical
479                    .take(&DiagnosticsResourceUri(raw.to_owned()))
480            });
481        if let Some(removed) = &removed {
482            state.aliases.retain(|_, c| c != removed);
483        }
484        drop(state);
485        removed
486    }
487
488    /// A copy of the canonical subscription set, so a caller can evaluate a
489    /// predicate on each URI without holding the subscription lock.
490    pub(crate) async fn snapshot(&self) -> Vec<DiagnosticsResourceUri> {
491        self.0.read().await.canonical.iter().cloned().collect()
492    }
493
494    /// Check if a URI is currently subscribed.
495    pub(crate) async fn contains(&self, uri: &DiagnosticsResourceUri) -> bool {
496        self.0.read().await.canonical.contains(uri)
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use std::assert_matches;
503
504    use super::*;
505    #[cfg(unix)]
506    use crate::test_lsp::client_path;
507
508    fn roots_of(root: &Path) -> WorkspaceRoots {
509        WorkspaceRoots::from_paths(&[root.to_path_buf()]).unwrap()
510    }
511
512    // ------------------------------------------------------------------
513    // URI codec
514    // ------------------------------------------------------------------
515
516    #[cfg(windows)]
517    #[test]
518    fn test_make_uri_encodes_a_windows_rooted_path_without_a_drive() {
519        let uri = make_uri(Path::new(r"\ws\main.rs")).unwrap();
520        assert_eq!(uri, "lsp-diagnostics:///ws/main.rs");
521    }
522
523    #[test]
524    fn test_make_uri_rejects_relative_path() {
525        let result = make_uri(Path::new("relative/path.rs"));
526        assert!(result.is_err());
527    }
528
529    #[test]
530    fn test_parse_uri_rejects_wrong_scheme() {
531        let result = parse_uri("file:///home/user/main.rs");
532        assert!(result.is_err());
533    }
534
535    #[test]
536    fn test_parse_uri_rejects_decoded_nul() {
537        let drive = if cfg!(windows) { "C:/" } else { "" };
538        let result = parse_uri(&format!("lsp-diagnostics:///{drive}ws/a%00b.rs"));
539        assert_matches!(result, Err(ResourceUriError::ClientPath(_)));
540    }
541
542    #[test]
543    fn test_parse_uri_rejects_http_scheme() {
544        let result = parse_uri("https://example.com/file.rs");
545        assert!(result.is_err());
546    }
547
548    #[cfg(unix)]
549    #[test]
550    fn test_make_uri_simple_path() {
551        let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
552        assert_eq!(uri, "lsp-diagnostics:///home/user/main.rs");
553    }
554
555    #[cfg(unix)]
556    #[test]
557    fn test_make_uri_scheme_prefix() {
558        let uri = make_uri(Path::new("/tmp/file.rs")).unwrap();
559        assert!(uri.starts_with("lsp-diagnostics:///"));
560    }
561
562    #[cfg(unix)]
563    #[test]
564    fn test_parse_uri_simple() {
565        let path = PathBuf::from("/home/user/main.rs");
566        let uri = make_uri(&path).unwrap();
567        let recovered = parse_uri(&uri).unwrap();
568        assert_eq!(recovered.as_path(), path);
569    }
570
571    /// Round-trip: paths with spaces, unicode, `%`, `?`, `#`.
572    #[cfg(unix)]
573    #[test]
574    fn test_round_trip_special_chars() {
575        let paths = [
576            "/home/user/my file.rs",
577            "/tmp/café/main.rs",
578            "/data/100%/test.rs",
579            "/workspace/query?param/file.rs",
580            "/repo/branch#fragment/src.rs",
581            "/путь/к/файлу.rs",
582        ];
583
584        for raw in &paths {
585            let path = PathBuf::from(raw);
586            let uri = make_uri(&path).expect(raw);
587            assert!(
588                uri.starts_with("lsp-diagnostics:///"),
589                "URI should start with correct scheme: {uri}"
590            );
591            let recovered = parse_uri(&uri).expect(&uri);
592            assert_eq!(recovered.as_path(), path, "Round-trip failed for: {raw}");
593        }
594    }
595
596    /// Snapshot test: verify the on-wire form uses three slashes and percent-encoding.
597    #[cfg(unix)]
598    #[test]
599    fn test_wire_format_percent_encoded() {
600        let path = Path::new("/home/user/my file.rs");
601        let uri = make_uri(path).unwrap();
602        // Space must be percent-encoded as %20
603        assert!(uri.contains("%20"), "Expected %20 in: {uri}");
604        assert!(uri.starts_with("lsp-diagnostics:///"));
605    }
606
607    /// #265 regression: all seven RFC 3986 §2.2 "other reserved" characters
608    /// must be percent-encoded in `lsp-diagnostics://` URIs, same as
609    /// `file://` URIs from `try_path_to_uri` (see
610    /// `test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars`
611    /// in `state.rs`). `{`, `}`, and backtick are already encoded by the
612    /// `url` crate on serialization; `[`, `]`, `^`, `|` are handled
613    /// explicitly by `encode_rfc3986_path_chars`.
614    #[cfg(unix)]
615    #[test]
616    fn test_make_uri_percent_encodes_reserved_chars() {
617        let path = Path::new("/home/user/test[]^|{}`.ts");
618        let uri = make_uri(path).unwrap();
619
620        for (raw, encoded) in [
621            ('[', "%5B"),
622            (']', "%5D"),
623            ('^', "%5E"),
624            ('|', "%7C"),
625            ('{', "%7B"),
626            ('}', "%7D"),
627            ('`', "%60"),
628        ] {
629            assert!(
630                uri.contains(encoded),
631                "expected {raw:?} to be percent-encoded as {encoded} in {uri}"
632            );
633        }
634        assert!(
635            !uri.contains(['[', ']', '^', '|', '{', '}', '`']),
636            "no raw reserved characters should remain in {uri}"
637        );
638        assert_eq!(parse_uri(&uri).unwrap().as_path(), path);
639    }
640
641    // ------------------------------------------------------------------
642    // DiagnosticsResourceUri
643    // ------------------------------------------------------------------
644
645    use crate::bridge::resolve_one;
646    use crate::test_lsp::{diagnostics_uri as u, workspace_with_main_rs as workspace};
647
648    #[test]
649    fn test_resolve_rejects_foreign_scheme() {
650        let (_dir, root, _file) = workspace();
651        let err = DiagnosticsResourceUri::resolve(
652            "file:///tmp/main.rs",
653            &WorkspaceRoots::from_paths(&[root]).unwrap(),
654        )
655        .unwrap_err();
656        assert_matches!(err, crate::Error::ResourceUri(_), "got {err:?}");
657    }
658
659    #[test]
660    fn test_resolve_rejects_non_empty_authority() {
661        let (_dir, root, _file) = workspace();
662        let err = DiagnosticsResourceUri::resolve(
663            "lsp-diagnostics://host/main.rs",
664            &WorkspaceRoots::from_paths(&[root]).unwrap(),
665        )
666        .unwrap_err();
667        assert_matches!(err, crate::Error::ResourceUri(_), "got {err:?}");
668    }
669
670    /// #571: a subscribe URI naming a file through the configured symlinked
671    /// spelling of the root resolves to the canonical file.
672    #[cfg(unix)]
673    #[test]
674    fn test_resolve_admits_configured_symlink_spelling() {
675        let (_dir, root, file) = workspace();
676        let link = root.join("link_to_root");
677        std::os::unix::fs::symlink(&root, &link).unwrap();
678        let roots = roots_of(&link);
679
680        let raw = make_uri(&link.join(file.strip_prefix(&root).unwrap())).unwrap();
681        let resolved = DiagnosticsResourceUri::resolve(&raw, &roots).unwrap();
682
683        assert_eq!(resolved.path, file);
684    }
685
686    /// #575: a subscribe URI whose path decodes to a NUL byte is an invalid
687    /// URI, which classifies as caller-fault.
688    #[test]
689    fn test_resolve_nul_uri_is_invalid_params() {
690        let (_dir, root, _file) = workspace();
691
692        let err =
693            DiagnosticsResourceUri::resolve("lsp-diagnostics:///ws/a%00b.rs", &roots_of(&root))
694                .unwrap_err();
695
696        assert_eq!(
697            err.mcp_error_kind(),
698            crate::error::McpErrorKind::InvalidParams
699        );
700    }
701
702    #[test]
703    fn test_resolve_rejects_path_outside_workspace() {
704        let (_dir, root, _file) = workspace();
705        let (_other_dir, _other_root, other_file) = workspace();
706        let raw = make_uri(&other_file).unwrap();
707        assert!(
708            DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
709                .is_err()
710        );
711    }
712
713    #[test]
714    fn test_resolve_rejects_missing_file() {
715        let (_dir, root, _file) = workspace();
716        let raw = make_uri(&root.join("missing.rs")).unwrap();
717        assert!(
718            DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
719                .is_err()
720        );
721    }
722
723    #[test]
724    fn test_resolve_yields_canonical_path_and_uri() {
725        let (_dir, root, file) = workspace();
726        let raw = make_uri(&file).unwrap();
727        let resolved =
728            DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
729                .unwrap();
730        assert_eq!(resolved.path, file);
731        assert_eq!(resolved.uri.as_str(), raw);
732    }
733
734    #[cfg(unix)]
735    #[test]
736    fn test_resolve_canonicalizes_symlinked_path() {
737        let (_dir, root, file) = workspace();
738        let link = root.join("link.rs");
739        std::os::unix::fs::symlink(&file, &link).unwrap();
740        let raw = make_uri(&link).unwrap();
741        let resolved =
742            DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
743                .unwrap();
744        assert_eq!(resolved.uri.as_str(), make_uri(&file).unwrap());
745    }
746
747    #[tokio::test]
748    async fn test_for_published_matches_resolve_for_same_file() {
749        let (_dir, root, file) = workspace();
750        let roots = WorkspaceRoots::from_paths(&[root]).unwrap();
751        let resolved = DiagnosticsResourceUri::resolve(&make_uri(&file).unwrap(), &roots).unwrap();
752        let published = crate::bridge::path_to_uri(&file).unwrap();
753        let published = resolve_one(&published, &roots).await.unwrap();
754        assert_eq!(
755            DiagnosticsResourceUri::for_published(&published),
756            Some(resolved.uri)
757        );
758    }
759
760    #[tokio::test]
761    async fn test_published_resolve_rejects_non_file_uri() {
762        let (_dir, root, _file) = workspace();
763        let uri = lsp_types::Uri::from("untitled:Untitled-1");
764        assert_eq!(resolve_one(&uri, &roots_of(&root)).await, None);
765    }
766
767    /// Percent-encoding the path must not make a canonical spelling look like
768    /// an alias.
769    #[tokio::test]
770    async fn test_published_resolve_percent_encoded_spelling_is_canonical() {
771        let (_dir, root, file) = workspace();
772        let canonical = crate::bridge::path_to_uri(&file).unwrap();
773        let encoded = lsp_types::Uri::from(canonical.as_ref().replace("main.rs", "main%2Ers"));
774        assert_ne!(encoded, canonical);
775
776        let published = resolve_one(&encoded, &roots_of(&root)).await.unwrap();
777
778        assert!(published.is_canonical());
779        assert_eq!(published.canonical(), &canonical);
780    }
781
782    #[tokio::test]
783    async fn test_published_resolve_deleted_file_keeps_canonical_form() {
784        let (_dir, root, file) = workspace();
785        std::fs::remove_file(&file).unwrap();
786        let uri = crate::bridge::path_to_uri(&file).unwrap();
787
788        let published = resolve_one(&uri, &roots_of(&root)).await.unwrap();
789
790        assert_eq!(published.canonical(), &uri);
791    }
792
793    #[cfg(unix)]
794    #[tokio::test]
795    async fn test_published_resolve_maps_symlink_to_canonical_key() {
796        let (_dir, root, file) = workspace();
797        let link = root.join("link.rs");
798        std::os::unix::fs::symlink(&file, &link).unwrap();
799        let link_uri = crate::bridge::path_to_uri(&link).unwrap();
800
801        let published = resolve_one(&link_uri, &roots_of(&root)).await.unwrap();
802
803        assert_eq!(published.source(), &link_uri);
804        assert_eq!(
805            published.canonical(),
806            &crate::bridge::path_to_uri(&file).unwrap()
807        );
808        let client_side = crate::bridge::Translator::cached_diagnostics_uri(
809            &roots_of(&root),
810            &client_path(&link),
811        )
812        .await
813        .unwrap();
814        assert_eq!(published.canonical(), &client_side);
815    }
816
817    #[cfg(unix)]
818    #[tokio::test]
819    async fn test_published_resolve_drops_symlink_pointing_outside() {
820        let (_dir, root, _file) = workspace();
821        let outside = tempfile::TempDir::new().unwrap();
822        std::fs::write(outside.path().join("x.rs"), "").unwrap();
823        let link = root.join("out.rs");
824        std::os::unix::fs::symlink(outside.path().join("x.rs"), &link).unwrap();
825        let link_uri = crate::bridge::path_to_uri(&link).unwrap();
826
827        assert_eq!(resolve_one(&link_uri, &roots_of(&root)).await, None);
828    }
829
830    // ------------------------------------------------------------------
831    // ResourceSubscriptions
832    // ------------------------------------------------------------------
833
834    #[tokio::test]
835    async fn test_subscribe_and_contains() {
836        let subs = ResourceSubscriptions::new();
837        let uri = u("lsp-diagnostics:///home/user/main.rs");
838
839        assert!(!subs.contains(&uri).await);
840        assert!(subs.subscribe(uri.clone()).await.unwrap());
841        assert!(subs.contains(&uri).await);
842    }
843
844    #[tokio::test]
845    async fn test_from_canonical_prepopulates() {
846        let uri = u("lsp-diagnostics:///tmp/file.rs");
847        let subs = ResourceSubscriptions::from_canonical([uri.clone()]);
848        assert!(subs.contains(&uri).await);
849        assert!(!subs.is_empty().await);
850    }
851
852    #[tokio::test]
853    async fn test_subscribe_duplicate_returns_false() {
854        let subs = ResourceSubscriptions::new();
855        let uri = u("lsp-diagnostics:///tmp/file.rs");
856        assert!(subs.subscribe(uri.clone()).await.unwrap());
857        assert!(!subs.subscribe(uri).await.unwrap());
858    }
859
860    #[tokio::test]
861    async fn test_unsubscribe() {
862        let subs = ResourceSubscriptions::new();
863        let uri = u("lsp-diagnostics:///tmp/file.rs");
864        subs.subscribe(uri.clone()).await.unwrap();
865        assert_eq!(
866            subs.unsubscribe(Some(&uri), uri.as_str()).await,
867            Some(uri.clone())
868        );
869        assert!(!subs.contains(&uri).await);
870    }
871
872    #[tokio::test]
873    async fn test_unsubscribe_nonexistent_returns_none() {
874        let subs = ResourceSubscriptions::new();
875        let missing = u("lsp-diagnostics:///nonexistent.rs");
876        assert!(
877            subs.unsubscribe(Some(&missing), missing.as_str())
878                .await
879                .is_none()
880        );
881    }
882
883    /// Without a canonical URI (canonicalization failed) the raw string still
884    /// removes an entry whose canonical form equals it.
885    #[tokio::test]
886    async fn test_unsubscribe_without_canonical_matches_raw_verbatim() {
887        let subs = ResourceSubscriptions::new();
888        let uri = u("lsp-diagnostics:///tmp/file.rs");
889        subs.subscribe(uri.clone()).await.unwrap();
890        assert_eq!(subs.unsubscribe(None, uri.as_str()).await, Some(uri));
891    }
892
893    /// #499: a stale raw URI recorded via `record_alias` must still resolve
894    /// to the canonical entry `subscribe` created, even though the raw and
895    /// canonical strings differ (e.g. a symlink or macOS `/var` vs
896    /// `/private/var`).
897    #[tokio::test]
898    async fn test_unsubscribe_resolves_recorded_alias() {
899        let subs = ResourceSubscriptions::new();
900        let raw = "lsp-diagnostics:///var/tmp/file.rs";
901        let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
902
903        subs.subscribe(canonical.clone()).await.unwrap();
904        subs.record_alias(raw.to_owned(), &canonical).await;
905        assert!(subs.contains(&canonical).await);
906
907        assert_eq!(subs.unsubscribe(None, raw).await, Some(canonical.clone()));
908        assert!(!subs.contains(&canonical).await);
909    }
910
911    /// A re-canonicalization that now yields a different URI (symlink
912    /// retargeted since subscribing) falls back to the recorded alias.
913    #[tokio::test]
914    async fn test_unsubscribe_alias_wins_over_retargeted_canonical() {
915        let subs = ResourceSubscriptions::new();
916        let raw = "lsp-diagnostics:///link.rs";
917        let (a, b) = (u("lsp-diagnostics:///a.rs"), u("lsp-diagnostics:///b.rs"));
918        subs.subscribe(a.clone()).await.unwrap();
919        subs.subscribe(b.clone()).await.unwrap();
920        subs.record_alias(raw.to_owned(), &a).await;
921
922        assert_eq!(subs.unsubscribe(Some(&b), raw).await, Some(a.clone()));
923        assert!(!subs.contains(&a).await);
924        assert!(subs.contains(&b).await, "the retargeted entry must survive");
925    }
926
927    #[tokio::test]
928    async fn test_unsubscribe_falls_back_to_alias_when_canonical_changed() {
929        let subs = ResourceSubscriptions::new();
930        let raw = "lsp-diagnostics:///link.rs";
931        let original = u("lsp-diagnostics:///old.rs");
932        let retargeted = u("lsp-diagnostics:///new.rs");
933
934        subs.subscribe(original.clone()).await.unwrap();
935        subs.record_alias(raw.to_owned(), &original).await;
936
937        assert_eq!(
938            subs.unsubscribe(Some(&retargeted), raw).await,
939            Some(original.clone())
940        );
941        assert!(!subs.contains(&original).await);
942    }
943
944    /// `record_alias` is a no-op when the raw and canonical URIs are equal
945    /// (the common case), so it never grows the alias map for entries that
946    /// don't need it.
947    #[tokio::test]
948    async fn test_record_alias_noop_when_raw_equals_canonical() {
949        let subs = ResourceSubscriptions::new();
950        let uri = u("lsp-diagnostics:///tmp/file.rs");
951        subs.subscribe(uri.clone()).await.unwrap();
952        subs.record_alias(uri.as_str().to_owned(), &uri).await;
953
954        assert_eq!(subs.unsubscribe(Some(&uri), uri.as_str()).await, Some(uri));
955    }
956
957    /// Unsubscribing under the canonical URI directly must also clear any
958    /// aliases that pointed at it, so the alias map does not accumulate
959    /// stale entries for already-removed subscriptions.
960    #[tokio::test]
961    async fn test_unsubscribe_by_canonical_clears_stale_aliases() {
962        let subs = ResourceSubscriptions::new();
963        let raw = "lsp-diagnostics:///var/tmp/file.rs";
964        let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
965
966        subs.subscribe(canonical.clone()).await.unwrap();
967        subs.record_alias(raw.to_owned(), &canonical).await;
968
969        assert!(subs.unsubscribe(Some(&canonical), "other").await.is_some());
970        assert!(subs.unsubscribe(None, raw).await.is_none());
971    }
972
973    /// #499 site fix (impl-critic C1): only the most recently recorded alias
974    /// for a given canonical URI is kept, so the alias map's size stays
975    /// structurally tied to the (already `MAX_SUBSCRIPTIONS`-capped)
976    /// canonical set's size.
977    #[tokio::test]
978    async fn test_record_alias_keeps_only_latest_alias_per_canonical() {
979        let subs = ResourceSubscriptions::new();
980        let canonical = u("lsp-diagnostics:///file.rs");
981        subs.subscribe(canonical.clone()).await.unwrap();
982
983        let raws = [
984            "lsp-diagnostics:///%66ile.rs",
985            "lsp-diagnostics:///fil%65.rs",
986            "lsp-diagnostics:///file%2Ers",
987        ];
988        for raw in raws {
989            subs.record_alias(raw.to_owned(), &canonical).await;
990        }
991
992        for raw in &raws[..raws.len() - 1] {
993            assert!(subs.unsubscribe(None, raw).await.is_none());
994        }
995        let latest = raws.last().unwrap();
996        assert!(subs.unsubscribe(None, latest).await.is_some());
997        assert!(!subs.contains(&canonical).await);
998    }
999
1000    /// #499 site fix (impl-critic C3): `record_alias` is a no-op when its
1001    /// canonical URI is not (or is no longer) an actual subscribed entry.
1002    /// Subscribing `canonical` *after* the no-op call isolates the guard: had
1003    /// it recorded the alias anyway, `raw` would now resolve to the entry.
1004    #[tokio::test]
1005    async fn test_record_alias_noop_for_unsubscribed_canonical() {
1006        let subs = ResourceSubscriptions::new();
1007        let raw = "lsp-diagnostics:///var/tmp/file.rs";
1008        let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
1009
1010        subs.record_alias(raw.to_owned(), &canonical).await;
1011
1012        subs.subscribe(canonical.clone()).await.unwrap();
1013        assert!(subs.unsubscribe(None, raw).await.is_none());
1014        assert!(subs.unsubscribe(Some(&canonical), raw).await.is_some());
1015    }
1016
1017    #[tokio::test]
1018    async fn test_subscribe_cap_exceeded() {
1019        let subs = ResourceSubscriptions::new();
1020        for i in 0..MAX_SUBSCRIPTIONS {
1021            subs.subscribe(u(&format!("lsp-diagnostics:///file{i}.rs")))
1022                .await
1023                .unwrap();
1024        }
1025        let result = subs.subscribe(u("lsp-diagnostics:///overflow.rs")).await;
1026        assert_eq!(result, Err(SubscriptionError::LimitReached));
1027    }
1028}