1use 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};
18use crate::util::{MAX_ECHOED_PATH_BYTES, escape_bounded};
19
20const SCHEME: &str = "lsp-diagnostics";
22
23const PREFIX: &str = "lsp-diagnostics://";
28
29pub const MAX_SUBSCRIPTIONS: usize = 1_000;
33
34pub(crate) const MAX_LISTEN_STREAMS: usize = 100;
42
43#[derive(Debug, Error)]
45pub enum ResourceUriError {
46 #[error(
48 "path must be absolute and valid UTF-8: {}",
49 escape_bounded(.0, MAX_ECHOED_PATH_BYTES)
50 )]
51 InvalidPath(String),
52
53 #[error(
55 "expected '{SCHEME}:///' prefix in URI: {}",
56 escape_bounded(.0, MAX_ECHOED_PATH_BYTES)
57 )]
58 InvalidScheme(String),
59
60 #[error("malformed resource URI")]
62 UrlParse(#[source] url::ParseError),
63
64 #[error(
66 "URI does not name a filesystem path: {}",
67 escape_bounded(.0, MAX_ECHOED_PATH_BYTES)
68 )]
69 NotAFilePath(String),
70
71 #[error("decoded URI path is not acceptable")]
73 ClientPath(#[source] InvalidClientPath),
74}
75
76#[derive(Debug, Error, PartialEq, Eq)]
78pub(crate) enum SubscriptionError {
79 #[error("subscription limit of {MAX_SUBSCRIPTIONS} reached")]
81 LimitReached,
82
83 #[error("listen stream limit of {MAX_LISTEN_STREAMS} reached")]
85 ListenLimitReached,
86}
87
88impl From<SubscriptionError> for crate::error::Error {
89 fn from(err: SubscriptionError) -> Self {
90 match err {
91 SubscriptionError::LimitReached => Self::SubscriptionLimitReached {
92 max: MAX_SUBSCRIPTIONS,
93 },
94 SubscriptionError::ListenLimitReached => Self::ListenStreamsExhausted {
95 max: MAX_LISTEN_STREAMS,
96 },
97 }
98 }
99}
100
101pub fn make_uri(path: &Path) -> Result<String, ResourceUriError> {
124 let file_url =
125 file_url(path).ok_or_else(|| ResourceUriError::InvalidPath(path.display().to_string()))?;
126
127 let encoded = encode_rfc3986_path_chars(&file_url);
130 let after_scheme = encoded.strip_prefix(file_url.scheme()).unwrap_or(&encoded);
131 let uri = format!("{SCHEME}{after_scheme}");
132 Ok(uri)
133}
134
135pub fn parse_uri(uri: &str) -> Result<ClientPath, ResourceUriError> {
155 if !uri.starts_with(PREFIX) {
156 return Err(ResourceUriError::InvalidScheme(uri.to_string()));
157 }
158
159 let after_prefix = &uri[PREFIX.len()..];
162 if !after_prefix.starts_with('/') {
163 return Err(ResourceUriError::InvalidScheme(format!(
164 "non-empty authority in URI: {uri}"
165 )));
166 }
167
168 let file_uri = format!("file://{after_prefix}");
169 let url = Url::parse(&file_uri).map_err(ResourceUriError::UrlParse)?;
170
171 let path = url
172 .to_file_path()
173 .map_err(|()| ResourceUriError::NotAFilePath(file_uri))?;
174 ClientPath::try_from(path).map_err(ResourceUriError::ClientPath)
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Hash)]
187pub(crate) struct DiagnosticsResourceUri(String);
188
189#[derive(Debug, Clone, PartialEq, Eq)]
199pub(crate) struct PublishedDiagnosticsUri {
200 source: lsp_types::Uri,
201 canonical: lsp_types::Uri,
202 is_canonical: bool,
206 form: CanonicalForm,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub(crate) enum CanonicalForm {
214 Resolved,
216 Fallback,
220}
221
222impl PublishedDiagnosticsUri {
223 pub(super) fn from_canonical_path(
231 published: &lsp_types::Uri,
232 published_path: &Path,
233 canonical_path: &Path,
234 form: CanonicalForm,
235 roots: &WorkspaceRoots,
236 ) -> Option<Self> {
237 let canonical = super::try_path_to_uri(canonical_path)?;
238 roots.contains_canonical(canonical_path).then(|| Self {
239 source: published.clone(),
240 canonical,
241 is_canonical: form == CanonicalForm::Resolved
242 && same_path(published_path, canonical_path),
243 form,
244 })
245 }
246
247 #[cfg(test)]
248 pub(crate) fn for_test(source: lsp_types::Uri, canonical: lsp_types::Uri) -> Self {
249 Self::for_test_with_form(source, canonical, CanonicalForm::Resolved)
250 }
251
252 #[cfg(test)]
253 pub(crate) fn for_test_with_form(
254 source: lsp_types::Uri,
255 canonical: lsp_types::Uri,
256 form: CanonicalForm,
257 ) -> Self {
258 let is_canonical = source == canonical && form == CanonicalForm::Resolved;
259 Self {
260 source,
261 canonical,
262 is_canonical,
263 form,
264 }
265 }
266
267 pub(crate) const fn form(&self) -> CanonicalForm {
269 self.form
270 }
271
272 pub(crate) const fn is_canonical(&self) -> bool {
274 self.is_canonical
275 }
276
277 pub(crate) const fn source(&self) -> &lsp_types::Uri {
279 &self.source
280 }
281
282 pub(crate) const fn canonical(&self) -> &lsp_types::Uri {
284 &self.canonical
285 }
286}
287
288fn same_path(a: &Path, b: &Path) -> bool {
291 if cfg!(windows) {
292 a.as_os_str()
293 .to_string_lossy()
294 .eq_ignore_ascii_case(&b.as_os_str().to_string_lossy())
295 } else {
296 a == b
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
302pub(crate) struct ResolvedResource {
303 pub(crate) path: PathBuf,
305 pub(crate) uri: DiagnosticsResourceUri,
307}
308
309impl DiagnosticsResourceUri {
310 pub(crate) fn resolve(
323 raw: &str,
324 roots: &WorkspaceRoots,
325 ) -> crate::error::Result<ResolvedResource> {
326 let parsed = parse_uri(raw)?;
327 let validated = roots.validate_blocking(&parsed)?;
330 let path = dunce::simplified(validated.as_path()).to_path_buf();
331 let uri = Self(make_uri(&path).map_err(|_| crate::Error::PathToUri(path.clone()))?);
332 Ok(ResolvedResource { path, uri })
333 }
334
335 pub(crate) fn for_published(published: &PublishedDiagnosticsUri) -> Option<Self> {
339 Self::for_canonical_file(&published.canonical)
340 }
341
342 pub(crate) fn for_canonical_file(file: &lsp_types::Uri) -> Option<Self> {
344 make_uri(&uri_to_path(file)?).ok().map(Self)
345 }
346
347 pub(crate) fn as_str(&self) -> &str {
349 &self.0
350 }
351
352 #[cfg(test)]
353 pub(crate) fn for_test(uri: &str) -> Self {
354 Self(uri.to_owned())
355 }
356}
357
358impl fmt::Display for DiagnosticsResourceUri {
359 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360 f.write_str(&self.0)
361 }
362}
363
364impl From<DiagnosticsResourceUri> for String {
365 fn from(uri: DiagnosticsResourceUri) -> Self {
366 uri.0
367 }
368}
369
370#[derive(Debug, Default)]
372struct SubscriptionState {
373 canonical: HashSet<DiagnosticsResourceUri>,
376 aliases: HashMap<String, DiagnosticsResourceUri>,
383}
384
385#[derive(Debug)]
390pub(crate) struct ResourceSubscriptions(RwLock<SubscriptionState>);
391
392impl Default for ResourceSubscriptions {
393 fn default() -> Self {
394 Self::new()
395 }
396}
397
398impl ResourceSubscriptions {
399 #[must_use]
401 pub fn new() -> Self {
402 Self(RwLock::new(SubscriptionState::default()))
403 }
404
405 pub(crate) fn from_canonical(uris: impl IntoIterator<Item = DiagnosticsResourceUri>) -> Self {
408 Self(RwLock::new(SubscriptionState {
409 canonical: uris.into_iter().collect(),
410 aliases: HashMap::new(),
411 }))
412 }
413
414 pub(crate) async fn subscribe(
423 &self,
424 uri: DiagnosticsResourceUri,
425 ) -> Result<bool, SubscriptionError> {
426 let mut state = self.0.write().await;
427 if !state.canonical.contains(&uri) && state.canonical.len() >= MAX_SUBSCRIPTIONS {
428 return Err(SubscriptionError::LimitReached);
429 }
430 Ok(state.canonical.insert(uri))
431 }
432
433 pub(crate) async fn record_alias(
459 &self,
460 raw_uri: String,
461 canonical_uri: &DiagnosticsResourceUri,
462 ) {
463 if raw_uri == canonical_uri.as_str() {
464 return;
465 }
466 let mut state = self.0.write().await;
467 if !state.canonical.contains(canonical_uri) {
468 return;
469 }
470 state.aliases.retain(|_, c| c != canonical_uri);
471 state.aliases.insert(raw_uri, canonical_uri.clone());
472 }
473
474 pub async fn is_empty(&self) -> bool {
479 self.0.read().await.canonical.is_empty()
480 }
481
482 pub(crate) async fn unsubscribe(
493 &self,
494 canonical: Option<&DiagnosticsResourceUri>,
495 raw: &str,
496 ) -> Option<DiagnosticsResourceUri> {
497 let mut state = self.0.write().await;
498 let removed = state
499 .aliases
500 .remove(raw)
501 .and_then(|aliased| state.canonical.take(&aliased))
502 .or_else(|| canonical.and_then(|c| state.canonical.take(c)))
503 .or_else(|| {
505 state
506 .canonical
507 .take(&DiagnosticsResourceUri(raw.to_owned()))
508 });
509 if let Some(removed) = &removed {
510 state.aliases.retain(|_, c| c != removed);
511 }
512 drop(state);
513 removed
514 }
515
516 pub(crate) async fn snapshot(&self) -> Vec<DiagnosticsResourceUri> {
519 self.0.read().await.canonical.iter().cloned().collect()
520 }
521
522 pub(crate) async fn contains(&self, uri: &DiagnosticsResourceUri) -> bool {
524 self.0.read().await.canonical.contains(uri)
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use std::assert_matches;
531
532 use super::*;
533 #[cfg(unix)]
534 use crate::test_lsp::client_path;
535
536 fn roots_of(root: &Path) -> WorkspaceRoots {
537 WorkspaceRoots::from_paths(&[root.to_path_buf()]).unwrap()
538 }
539
540 #[cfg(windows)]
545 #[test]
546 fn test_make_uri_encodes_a_windows_rooted_path_without_a_drive() {
547 let uri = make_uri(Path::new(r"\ws\main.rs")).unwrap();
548 assert_eq!(uri, "lsp-diagnostics:///ws/main.rs");
549 }
550
551 #[test]
552 fn test_make_uri_rejects_relative_path() {
553 let result = make_uri(Path::new("relative/path.rs"));
554 assert!(result.is_err());
555 }
556
557 #[test]
558 fn test_parse_uri_rejects_wrong_scheme() {
559 let result = parse_uri("file:///home/user/main.rs");
560 assert!(result.is_err());
561 }
562
563 #[test]
564 fn test_parse_uri_rejects_decoded_nul() {
565 let drive = if cfg!(windows) { "C:/" } else { "" };
566 let result = parse_uri(&format!("lsp-diagnostics:///{drive}ws/a%00b.rs"));
567 assert_matches!(result, Err(ResourceUriError::ClientPath(_)));
568 }
569
570 #[test]
571 fn test_parse_uri_rejects_http_scheme() {
572 let result = parse_uri("https://example.com/file.rs");
573 assert!(result.is_err());
574 }
575
576 #[cfg(unix)]
577 #[test]
578 fn test_make_uri_simple_path() {
579 let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
580 assert_eq!(uri, "lsp-diagnostics:///home/user/main.rs");
581 }
582
583 #[cfg(unix)]
584 #[test]
585 fn test_make_uri_scheme_prefix() {
586 let uri = make_uri(Path::new("/tmp/file.rs")).unwrap();
587 assert!(uri.starts_with("lsp-diagnostics:///"));
588 }
589
590 #[cfg(unix)]
591 #[test]
592 fn test_parse_uri_simple() {
593 let path = PathBuf::from("/home/user/main.rs");
594 let uri = make_uri(&path).unwrap();
595 let recovered = parse_uri(&uri).unwrap();
596 assert_eq!(recovered.as_path(), path);
597 }
598
599 #[cfg(unix)]
601 #[test]
602 fn test_round_trip_special_chars() {
603 let paths = [
604 "/home/user/my file.rs",
605 "/tmp/café/main.rs",
606 "/data/100%/test.rs",
607 "/workspace/query?param/file.rs",
608 "/repo/branch#fragment/src.rs",
609 "/путь/к/файлу.rs",
610 ];
611
612 for raw in &paths {
613 let path = PathBuf::from(raw);
614 let uri = make_uri(&path).expect(raw);
615 assert!(
616 uri.starts_with("lsp-diagnostics:///"),
617 "URI should start with correct scheme: {uri}"
618 );
619 let recovered = parse_uri(&uri).expect(&uri);
620 assert_eq!(recovered.as_path(), path, "Round-trip failed for: {raw}");
621 }
622 }
623
624 #[cfg(unix)]
626 #[test]
627 fn test_wire_format_percent_encoded() {
628 let path = Path::new("/home/user/my file.rs");
629 let uri = make_uri(path).unwrap();
630 assert!(uri.contains("%20"), "Expected %20 in: {uri}");
632 assert!(uri.starts_with("lsp-diagnostics:///"));
633 }
634
635 #[cfg(unix)]
643 #[test]
644 fn test_make_uri_percent_encodes_reserved_chars() {
645 let path = Path::new("/home/user/test[]^|{}`.ts");
646 let uri = make_uri(path).unwrap();
647
648 for (raw, encoded) in [
649 ('[', "%5B"),
650 (']', "%5D"),
651 ('^', "%5E"),
652 ('|', "%7C"),
653 ('{', "%7B"),
654 ('}', "%7D"),
655 ('`', "%60"),
656 ] {
657 assert!(
658 uri.contains(encoded),
659 "expected {raw:?} to be percent-encoded as {encoded} in {uri}"
660 );
661 }
662 assert!(
663 !uri.contains(['[', ']', '^', '|', '{', '}', '`']),
664 "no raw reserved characters should remain in {uri}"
665 );
666 assert_eq!(parse_uri(&uri).unwrap().as_path(), path);
667 }
668
669 use crate::bridge::resolve_one;
674 use crate::test_lsp::{diagnostics_uri as u, workspace_with_main_rs as workspace};
675
676 #[test]
677 fn test_resolve_rejects_foreign_scheme() {
678 let (_dir, root, _file) = workspace();
679 let err = DiagnosticsResourceUri::resolve(
680 "file:///tmp/main.rs",
681 &WorkspaceRoots::from_paths(&[root]).unwrap(),
682 )
683 .unwrap_err();
684 assert_matches!(err, crate::Error::ResourceUri(_), "got {err:?}");
685 }
686
687 #[test]
688 fn test_resolve_rejects_non_empty_authority() {
689 let (_dir, root, _file) = workspace();
690 let err = DiagnosticsResourceUri::resolve(
691 "lsp-diagnostics://host/main.rs",
692 &WorkspaceRoots::from_paths(&[root]).unwrap(),
693 )
694 .unwrap_err();
695 assert_matches!(err, crate::Error::ResourceUri(_), "got {err:?}");
696 }
697
698 #[cfg(unix)]
701 #[test]
702 fn test_resolve_admits_configured_symlink_spelling() {
703 let (_dir, root, file) = workspace();
704 let link = root.join("link_to_root");
705 std::os::unix::fs::symlink(&root, &link).unwrap();
706 let roots = roots_of(&link);
707
708 let raw = make_uri(&link.join(file.strip_prefix(&root).unwrap())).unwrap();
709 let resolved = DiagnosticsResourceUri::resolve(&raw, &roots).unwrap();
710
711 assert_eq!(resolved.path, file);
712 }
713
714 #[test]
717 fn test_resolve_nul_uri_is_invalid_params() {
718 let (_dir, root, _file) = workspace();
719
720 let err =
721 DiagnosticsResourceUri::resolve("lsp-diagnostics:///ws/a%00b.rs", &roots_of(&root))
722 .unwrap_err();
723
724 assert_eq!(
725 err.mcp_error_kind(),
726 crate::error::McpErrorKind::InvalidParams
727 );
728 }
729
730 #[test]
731 fn test_resolve_rejects_path_outside_workspace() {
732 let (_dir, root, _file) = workspace();
733 let (_other_dir, _other_root, other_file) = workspace();
734 let raw = make_uri(&other_file).unwrap();
735 assert!(
736 DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
737 .is_err()
738 );
739 }
740
741 #[test]
742 fn test_resolve_rejects_missing_file() {
743 let (_dir, root, _file) = workspace();
744 let raw = make_uri(&root.join("missing.rs")).unwrap();
745 assert!(
746 DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
747 .is_err()
748 );
749 }
750
751 #[test]
752 fn test_resolve_yields_canonical_path_and_uri() {
753 let (_dir, root, file) = workspace();
754 let raw = make_uri(&file).unwrap();
755 let resolved =
756 DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
757 .unwrap();
758 assert_eq!(resolved.path, file);
759 assert_eq!(resolved.uri.as_str(), raw);
760 }
761
762 #[cfg(unix)]
763 #[test]
764 fn test_resolve_canonicalizes_symlinked_path() {
765 let (_dir, root, file) = workspace();
766 let link = root.join("link.rs");
767 std::os::unix::fs::symlink(&file, &link).unwrap();
768 let raw = make_uri(&link).unwrap();
769 let resolved =
770 DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
771 .unwrap();
772 assert_eq!(resolved.uri.as_str(), make_uri(&file).unwrap());
773 }
774
775 #[tokio::test]
776 async fn test_for_published_matches_resolve_for_same_file() {
777 let (_dir, root, file) = workspace();
778 let roots = WorkspaceRoots::from_paths(&[root]).unwrap();
779 let resolved = DiagnosticsResourceUri::resolve(&make_uri(&file).unwrap(), &roots).unwrap();
780 let published = crate::bridge::path_to_uri(&file).unwrap();
781 let published = resolve_one(&published, &roots).await.unwrap();
782 assert_eq!(
783 DiagnosticsResourceUri::for_published(&published),
784 Some(resolved.uri)
785 );
786 }
787
788 #[tokio::test]
789 async fn test_published_resolve_rejects_non_file_uri() {
790 let (_dir, root, _file) = workspace();
791 let uri = lsp_types::Uri::from("untitled:Untitled-1");
792 assert_eq!(resolve_one(&uri, &roots_of(&root)).await, None);
793 }
794
795 #[tokio::test]
798 async fn test_published_resolve_percent_encoded_spelling_is_canonical() {
799 let (_dir, root, file) = workspace();
800 let canonical = crate::bridge::path_to_uri(&file).unwrap();
801 let encoded = lsp_types::Uri::from(canonical.as_ref().replace("main.rs", "main%2Ers"));
802 assert_ne!(encoded, canonical);
803
804 let published = resolve_one(&encoded, &roots_of(&root)).await.unwrap();
805
806 assert!(published.is_canonical());
807 assert_eq!(published.canonical(), &canonical);
808 }
809
810 #[tokio::test]
811 async fn test_published_resolve_deleted_file_keeps_canonical_form() {
812 let (_dir, root, file) = workspace();
813 std::fs::remove_file(&file).unwrap();
814 let uri = crate::bridge::path_to_uri(&file).unwrap();
815
816 let published = resolve_one(&uri, &roots_of(&root)).await.unwrap();
817
818 assert_eq!(published.canonical(), &uri);
819 }
820
821 #[cfg(unix)]
822 #[tokio::test]
823 async fn test_published_resolve_maps_symlink_to_canonical_key() {
824 let (_dir, root, file) = workspace();
825 let link = root.join("link.rs");
826 std::os::unix::fs::symlink(&file, &link).unwrap();
827 let link_uri = crate::bridge::path_to_uri(&link).unwrap();
828
829 let published = resolve_one(&link_uri, &roots_of(&root)).await.unwrap();
830
831 assert_eq!(published.source(), &link_uri);
832 assert_eq!(
833 published.canonical(),
834 &crate::bridge::path_to_uri(&file).unwrap()
835 );
836 let client_side = crate::bridge::Translator::cached_diagnostics_uri(
837 &roots_of(&root),
838 &client_path(&link),
839 )
840 .await
841 .unwrap();
842 assert_eq!(published.canonical(), &client_side);
843 }
844
845 #[cfg(unix)]
846 #[tokio::test]
847 async fn test_published_resolve_drops_symlink_pointing_outside() {
848 let (_dir, root, _file) = workspace();
849 let outside = tempfile::TempDir::new().unwrap();
850 std::fs::write(outside.path().join("x.rs"), "").unwrap();
851 let link = root.join("out.rs");
852 std::os::unix::fs::symlink(outside.path().join("x.rs"), &link).unwrap();
853 let link_uri = crate::bridge::path_to_uri(&link).unwrap();
854
855 assert_eq!(resolve_one(&link_uri, &roots_of(&root)).await, None);
856 }
857
858 #[tokio::test]
863 async fn test_subscribe_and_contains() {
864 let subs = ResourceSubscriptions::new();
865 let uri = u("lsp-diagnostics:///home/user/main.rs");
866
867 assert!(!subs.contains(&uri).await);
868 assert!(subs.subscribe(uri.clone()).await.unwrap());
869 assert!(subs.contains(&uri).await);
870 }
871
872 #[tokio::test]
873 async fn test_from_canonical_prepopulates() {
874 let uri = u("lsp-diagnostics:///tmp/file.rs");
875 let subs = ResourceSubscriptions::from_canonical([uri.clone()]);
876 assert!(subs.contains(&uri).await);
877 assert!(!subs.is_empty().await);
878 }
879
880 #[tokio::test]
881 async fn test_subscribe_duplicate_returns_false() {
882 let subs = ResourceSubscriptions::new();
883 let uri = u("lsp-diagnostics:///tmp/file.rs");
884 assert!(subs.subscribe(uri.clone()).await.unwrap());
885 assert!(!subs.subscribe(uri).await.unwrap());
886 }
887
888 #[tokio::test]
889 async fn test_unsubscribe() {
890 let subs = ResourceSubscriptions::new();
891 let uri = u("lsp-diagnostics:///tmp/file.rs");
892 subs.subscribe(uri.clone()).await.unwrap();
893 assert_eq!(
894 subs.unsubscribe(Some(&uri), uri.as_str()).await,
895 Some(uri.clone())
896 );
897 assert!(!subs.contains(&uri).await);
898 }
899
900 #[tokio::test]
901 async fn test_unsubscribe_nonexistent_returns_none() {
902 let subs = ResourceSubscriptions::new();
903 let missing = u("lsp-diagnostics:///nonexistent.rs");
904 assert!(
905 subs.unsubscribe(Some(&missing), missing.as_str())
906 .await
907 .is_none()
908 );
909 }
910
911 #[tokio::test]
914 async fn test_unsubscribe_without_canonical_matches_raw_verbatim() {
915 let subs = ResourceSubscriptions::new();
916 let uri = u("lsp-diagnostics:///tmp/file.rs");
917 subs.subscribe(uri.clone()).await.unwrap();
918 assert_eq!(subs.unsubscribe(None, uri.as_str()).await, Some(uri));
919 }
920
921 #[tokio::test]
926 async fn test_unsubscribe_resolves_recorded_alias() {
927 let subs = ResourceSubscriptions::new();
928 let raw = "lsp-diagnostics:///var/tmp/file.rs";
929 let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
930
931 subs.subscribe(canonical.clone()).await.unwrap();
932 subs.record_alias(raw.to_owned(), &canonical).await;
933 assert!(subs.contains(&canonical).await);
934
935 assert_eq!(subs.unsubscribe(None, raw).await, Some(canonical.clone()));
936 assert!(!subs.contains(&canonical).await);
937 }
938
939 #[tokio::test]
942 async fn test_unsubscribe_alias_wins_over_retargeted_canonical() {
943 let subs = ResourceSubscriptions::new();
944 let raw = "lsp-diagnostics:///link.rs";
945 let (a, b) = (u("lsp-diagnostics:///a.rs"), u("lsp-diagnostics:///b.rs"));
946 subs.subscribe(a.clone()).await.unwrap();
947 subs.subscribe(b.clone()).await.unwrap();
948 subs.record_alias(raw.to_owned(), &a).await;
949
950 assert_eq!(subs.unsubscribe(Some(&b), raw).await, Some(a.clone()));
951 assert!(!subs.contains(&a).await);
952 assert!(subs.contains(&b).await, "the retargeted entry must survive");
953 }
954
955 #[tokio::test]
956 async fn test_unsubscribe_falls_back_to_alias_when_canonical_changed() {
957 let subs = ResourceSubscriptions::new();
958 let raw = "lsp-diagnostics:///link.rs";
959 let original = u("lsp-diagnostics:///old.rs");
960 let retargeted = u("lsp-diagnostics:///new.rs");
961
962 subs.subscribe(original.clone()).await.unwrap();
963 subs.record_alias(raw.to_owned(), &original).await;
964
965 assert_eq!(
966 subs.unsubscribe(Some(&retargeted), raw).await,
967 Some(original.clone())
968 );
969 assert!(!subs.contains(&original).await);
970 }
971
972 #[tokio::test]
976 async fn test_record_alias_noop_when_raw_equals_canonical() {
977 let subs = ResourceSubscriptions::new();
978 let uri = u("lsp-diagnostics:///tmp/file.rs");
979 subs.subscribe(uri.clone()).await.unwrap();
980 subs.record_alias(uri.as_str().to_owned(), &uri).await;
981
982 assert_eq!(subs.unsubscribe(Some(&uri), uri.as_str()).await, Some(uri));
983 }
984
985 #[tokio::test]
989 async fn test_unsubscribe_by_canonical_clears_stale_aliases() {
990 let subs = ResourceSubscriptions::new();
991 let raw = "lsp-diagnostics:///var/tmp/file.rs";
992 let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
993
994 subs.subscribe(canonical.clone()).await.unwrap();
995 subs.record_alias(raw.to_owned(), &canonical).await;
996
997 assert!(subs.unsubscribe(Some(&canonical), "other").await.is_some());
998 assert!(subs.unsubscribe(None, raw).await.is_none());
999 }
1000
1001 #[tokio::test]
1006 async fn test_record_alias_keeps_only_latest_alias_per_canonical() {
1007 let subs = ResourceSubscriptions::new();
1008 let canonical = u("lsp-diagnostics:///file.rs");
1009 subs.subscribe(canonical.clone()).await.unwrap();
1010
1011 let raws = [
1012 "lsp-diagnostics:///%66ile.rs",
1013 "lsp-diagnostics:///fil%65.rs",
1014 "lsp-diagnostics:///file%2Ers",
1015 ];
1016 for raw in raws {
1017 subs.record_alias(raw.to_owned(), &canonical).await;
1018 }
1019
1020 for raw in &raws[..raws.len() - 1] {
1021 assert!(subs.unsubscribe(None, raw).await.is_none());
1022 }
1023 let latest = raws.last().unwrap();
1024 assert!(subs.unsubscribe(None, latest).await.is_some());
1025 assert!(!subs.contains(&canonical).await);
1026 }
1027
1028 #[tokio::test]
1033 async fn test_record_alias_noop_for_unsubscribed_canonical() {
1034 let subs = ResourceSubscriptions::new();
1035 let raw = "lsp-diagnostics:///var/tmp/file.rs";
1036 let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
1037
1038 subs.record_alias(raw.to_owned(), &canonical).await;
1039
1040 subs.subscribe(canonical.clone()).await.unwrap();
1041 assert!(subs.unsubscribe(None, raw).await.is_none());
1042 assert!(subs.unsubscribe(Some(&canonical), raw).await.is_some());
1043 }
1044
1045 #[tokio::test]
1046 async fn test_subscribe_cap_exceeded() {
1047 let subs = ResourceSubscriptions::new();
1048 for i in 0..MAX_SUBSCRIPTIONS {
1049 subs.subscribe(u(&format!("lsp-diagnostics:///file{i}.rs")))
1050 .await
1051 .unwrap();
1052 }
1053 let result = subs.subscribe(u("lsp-diagnostics:///overflow.rs")).await;
1054 assert_eq!(result, Err(SubscriptionError::LimitReached));
1055 }
1056}