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};
18
19const SCHEME: &str = "lsp-diagnostics";
21
22const PREFIX: &str = "lsp-diagnostics://";
27
28pub const MAX_SUBSCRIPTIONS: usize = 1_000;
32
33pub(crate) const MAX_LISTEN_STREAMS: usize = 100;
41
42#[derive(Debug, Error)]
44pub enum ResourceUriError {
45 #[error("path must be absolute and valid UTF-8: {0}")]
47 InvalidPath(String),
48
49 #[error("expected '{SCHEME}:///' prefix in URI: {0}")]
51 InvalidScheme(String),
52
53 #[error("malformed resource URI: {0}")]
55 UrlParse(#[source] url::ParseError),
56
57 #[error("URI does not name a filesystem path: {0}")]
59 NotAFilePath(String),
60
61 #[error("decoded URI path is not acceptable: {0}")]
63 ClientPath(#[source] InvalidClientPath),
64}
65
66#[derive(Debug, Error, PartialEq, Eq)]
68pub(crate) enum SubscriptionError {
69 #[error("subscription limit of {MAX_SUBSCRIPTIONS} reached")]
71 LimitReached,
72
73 #[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
91pub 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 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
125pub 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
177pub(crate) struct DiagnosticsResourceUri(String);
178
179#[derive(Debug, Clone, PartialEq, Eq)]
189pub(crate) struct PublishedDiagnosticsUri {
190 source: lsp_types::Uri,
191 canonical: lsp_types::Uri,
192 is_canonical: bool,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub(crate) enum CanonicalForm {
202 Resolved,
204 Fallback,
208}
209
210impl PublishedDiagnosticsUri {
211 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 pub(crate) const fn is_canonical(&self) -> bool {
246 self.is_canonical
247 }
248
249 pub(crate) const fn source(&self) -> &lsp_types::Uri {
251 &self.source
252 }
253
254 pub(crate) const fn canonical(&self) -> &lsp_types::Uri {
256 &self.canonical
257 }
258}
259
260fn 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#[derive(Debug, Clone, PartialEq, Eq)]
274pub(crate) struct ResolvedResource {
275 pub(crate) path: PathBuf,
277 pub(crate) uri: DiagnosticsResourceUri,
279}
280
281impl DiagnosticsResourceUri {
282 pub(crate) fn resolve(
295 raw: &str,
296 roots: &WorkspaceRoots,
297 ) -> crate::error::Result<ResolvedResource> {
298 let parsed = parse_uri(raw)?;
299 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 pub(crate) fn for_published(published: &PublishedDiagnosticsUri) -> Option<Self> {
311 Self::for_canonical_file(&published.canonical)
312 }
313
314 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 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#[derive(Debug, Default)]
344struct SubscriptionState {
345 canonical: HashSet<DiagnosticsResourceUri>,
348 aliases: HashMap<String, DiagnosticsResourceUri>,
355}
356
357#[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 #[must_use]
373 pub fn new() -> Self {
374 Self(RwLock::new(SubscriptionState::default()))
375 }
376
377 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 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 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 pub async fn is_empty(&self) -> bool {
451 self.0.read().await.canonical.is_empty()
452 }
453
454 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 .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 pub(crate) async fn snapshot(&self) -> Vec<DiagnosticsResourceUri> {
491 self.0.read().await.canonical.iter().cloned().collect()
492 }
493
494 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 #[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 #[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 #[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 assert!(uri.contains("%20"), "Expected %20 in: {uri}");
604 assert!(uri.starts_with("lsp-diagnostics:///"));
605 }
606
607 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}