use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex, Weak};
use thiserror::Error;
use tokio::sync::RwLock;
use url::Url;
use super::lock_std;
use super::state::encode_rfc3986_path_chars;
const SCHEME: &str = "lsp-diagnostics";
const PREFIX: &str = "lsp-diagnostics://";
pub const MAX_SUBSCRIPTIONS: usize = 1_000;
#[derive(Debug, Error)]
pub enum ResourceUriError {
#[error("path must be absolute and valid UTF-8: {0}")]
InvalidPath(String),
#[error("expected '{SCHEME}:///' prefix in URI: {0}")]
InvalidScheme(String),
#[error("failed to decode URI to filesystem path: {0}")]
DecodeFailed(String),
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SubscriptionError {
#[error("subscription limit of {MAX_SUBSCRIPTIONS} reached")]
LimitReached,
}
impl From<SubscriptionError> for crate::error::Error {
fn from(err: SubscriptionError) -> Self {
match err {
SubscriptionError::LimitReached => Self::SubscriptionLimitReached {
max: MAX_SUBSCRIPTIONS,
},
}
}
}
pub fn make_uri(path: &Path) -> Result<String, ResourceUriError> {
let file_url = Url::from_file_path(path)
.map_err(|()| ResourceUriError::InvalidPath(path.display().to_string()))?;
let encoded = encode_rfc3986_path_chars(&file_url);
let after_scheme = encoded.strip_prefix(file_url.scheme()).unwrap_or(&encoded);
let uri = format!("{SCHEME}{after_scheme}");
Ok(uri)
}
pub fn parse_uri(uri: &str) -> Result<PathBuf, ResourceUriError> {
if !uri.starts_with(PREFIX) {
return Err(ResourceUriError::InvalidScheme(uri.to_string()));
}
let after_prefix = &uri[PREFIX.len()..];
if !after_prefix.starts_with('/') {
return Err(ResourceUriError::InvalidScheme(format!(
"non-empty authority in URI: {uri}"
)));
}
let file_uri = format!("file://{after_prefix}");
let url = Url::parse(&file_uri).map_err(|e| ResourceUriError::DecodeFailed(e.to_string()))?;
url.to_file_path()
.map_err(|()| ResourceUriError::DecodeFailed(file_uri))
}
#[derive(Debug, Default)]
struct SubscriptionState {
canonical: HashSet<String>,
aliases: HashMap<String, String>,
}
#[derive(Debug)]
pub struct ResourceSubscriptions(RwLock<SubscriptionState>);
impl Default for ResourceSubscriptions {
fn default() -> Self {
Self::new()
}
}
impl ResourceSubscriptions {
#[must_use]
pub fn new() -> Self {
Self(RwLock::new(SubscriptionState::default()))
}
pub async fn subscribe(&self, uri: String) -> Result<bool, SubscriptionError> {
let mut state = self.0.write().await;
if !state.canonical.contains(&uri) && state.canonical.len() >= MAX_SUBSCRIPTIONS {
return Err(SubscriptionError::LimitReached);
}
Ok(state.canonical.insert(uri))
}
pub(crate) async fn record_alias(&self, raw_uri: String, canonical_uri: String) {
if raw_uri == canonical_uri {
return;
}
let mut state = self.0.write().await;
if !state.canonical.contains(&canonical_uri) {
return;
}
state.aliases.retain(|_, c| c != &canonical_uri);
state.aliases.insert(raw_uri, canonical_uri);
}
pub async fn is_empty(&self) -> bool {
self.0.read().await.canonical.is_empty()
}
pub async fn unsubscribe(&self, uri: &str) -> bool {
let mut state = self.0.write().await;
if state.canonical.remove(uri) {
state.aliases.retain(|_, canonical| canonical != uri);
return true;
}
if let Some(canonical) = state.aliases.remove(uri) {
state.aliases.retain(|_, c| c != &canonical);
return state.canonical.remove(&canonical);
}
false
}
pub async fn contains(&self, uri: &str) -> bool {
self.0.read().await.canonical.contains(uri)
}
pub async fn snapshot(&self) -> Vec<String> {
self.0.read().await.canonical.iter().cloned().collect()
}
}
#[derive(Debug, Default, Clone)]
pub struct SubscriptionRegistry(Arc<StdMutex<Vec<Weak<ResourceSubscriptions>>>>);
impl SubscriptionRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn register(&self) -> Arc<ResourceSubscriptions> {
let subs = Arc::new(ResourceSubscriptions::new());
let mut guard = lock_std(&self.0);
guard.retain(|weak| weak.strong_count() > 0);
guard.push(Arc::downgrade(&subs));
subs
}
pub(crate) fn live_sessions(&self) -> Vec<Arc<ResourceSubscriptions>> {
let mut guard = lock_std(&self.0);
guard.retain(|weak| weak.strong_count() > 0);
guard.iter().filter_map(Weak::upgrade).collect()
}
pub async fn any_contains(&self, uri: &str) -> bool {
for subs in self.live_sessions() {
if subs.contains(uri).await {
return true;
}
}
false
}
pub async fn is_all_empty(&self) -> bool {
for subs in self.live_sessions() {
if !subs.is_empty().await {
return false;
}
}
true
}
#[cfg(test)]
pub(crate) fn raw_len(&self) -> usize {
lock_std(&self.0).len()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_make_uri_rejects_relative_path() {
let result = make_uri(Path::new("relative/path.rs"));
assert!(result.is_err());
}
#[test]
fn test_parse_uri_rejects_wrong_scheme() {
let result = parse_uri("file:///home/user/main.rs");
assert!(result.is_err());
}
#[test]
fn test_parse_uri_rejects_http_scheme() {
let result = parse_uri("https://example.com/file.rs");
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn test_make_uri_simple_path() {
let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
assert_eq!(uri, "lsp-diagnostics:///home/user/main.rs");
}
#[cfg(unix)]
#[test]
fn test_make_uri_scheme_prefix() {
let uri = make_uri(Path::new("/tmp/file.rs")).unwrap();
assert!(uri.starts_with("lsp-diagnostics:///"));
}
#[cfg(unix)]
#[test]
fn test_parse_uri_simple() {
let path = PathBuf::from("/home/user/main.rs");
let uri = make_uri(&path).unwrap();
let recovered = parse_uri(&uri).unwrap();
assert_eq!(recovered, path);
}
#[cfg(unix)]
#[test]
fn test_round_trip_special_chars() {
let paths = [
"/home/user/my file.rs",
"/tmp/café/main.rs",
"/data/100%/test.rs",
"/workspace/query?param/file.rs",
"/repo/branch#fragment/src.rs",
"/путь/к/файлу.rs",
];
for raw in &paths {
let path = PathBuf::from(raw);
let uri = make_uri(&path).expect(raw);
assert!(
uri.starts_with("lsp-diagnostics:///"),
"URI should start with correct scheme: {uri}"
);
let recovered = parse_uri(&uri).expect(&uri);
assert_eq!(recovered, path, "Round-trip failed for: {raw}");
}
}
#[cfg(unix)]
#[test]
fn test_wire_format_percent_encoded() {
let path = Path::new("/home/user/my file.rs");
let uri = make_uri(path).unwrap();
assert!(uri.contains("%20"), "Expected %20 in: {uri}");
assert!(uri.starts_with("lsp-diagnostics:///"));
}
#[cfg(unix)]
#[test]
fn test_make_uri_percent_encodes_reserved_chars() {
let path = Path::new("/home/user/test[]^|{}`.ts");
let uri = make_uri(path).unwrap();
for (raw, encoded) in [
('[', "%5B"),
(']', "%5D"),
('^', "%5E"),
('|', "%7C"),
('{', "%7B"),
('}', "%7D"),
('`', "%60"),
] {
assert!(
uri.contains(encoded),
"expected {raw:?} to be percent-encoded as {encoded} in {uri}"
);
}
assert!(
!uri.contains(['[', ']', '^', '|', '{', '}', '`']),
"no raw reserved characters should remain in {uri}"
);
assert_eq!(parse_uri(&uri).unwrap(), path);
}
#[tokio::test]
async fn test_subscribe_and_contains() {
let subs = ResourceSubscriptions::new();
let uri = "lsp-diagnostics:///home/user/main.rs".to_string();
assert!(!subs.contains(&uri).await);
assert!(subs.subscribe(uri.clone()).await.unwrap());
assert!(subs.contains(&uri).await);
}
#[tokio::test]
async fn test_subscribe_duplicate_returns_false() {
let subs = ResourceSubscriptions::new();
let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
assert!(subs.subscribe(uri.clone()).await.unwrap());
assert!(!subs.subscribe(uri).await.unwrap());
}
#[tokio::test]
async fn test_unsubscribe() {
let subs = ResourceSubscriptions::new();
let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
subs.subscribe(uri.clone()).await.unwrap();
assert!(subs.unsubscribe(&uri).await);
assert!(!subs.contains(&uri).await);
}
#[tokio::test]
async fn test_unsubscribe_nonexistent_returns_false() {
let subs = ResourceSubscriptions::new();
assert!(!subs.unsubscribe("lsp-diagnostics:///nonexistent.rs").await);
}
#[tokio::test]
async fn test_unsubscribe_resolves_recorded_alias() {
let subs = ResourceSubscriptions::new();
let raw = "lsp-diagnostics:///var/tmp/file.rs".to_string();
let canonical = "lsp-diagnostics:///private/var/tmp/file.rs".to_string();
subs.subscribe(canonical.clone()).await.unwrap();
subs.record_alias(raw.clone(), canonical.clone()).await;
assert!(subs.contains(&canonical).await);
assert!(subs.unsubscribe(&raw).await);
assert!(!subs.contains(&canonical).await);
}
#[tokio::test]
async fn test_record_alias_noop_when_raw_equals_canonical() {
let subs = ResourceSubscriptions::new();
let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
subs.subscribe(uri.clone()).await.unwrap();
subs.record_alias(uri.clone(), uri.clone()).await;
assert!(subs.unsubscribe(&uri).await);
}
#[tokio::test]
async fn test_unsubscribe_by_canonical_clears_stale_aliases() {
let subs = ResourceSubscriptions::new();
let raw = "lsp-diagnostics:///var/tmp/file.rs".to_string();
let canonical = "lsp-diagnostics:///private/var/tmp/file.rs".to_string();
subs.subscribe(canonical.clone()).await.unwrap();
subs.record_alias(raw.clone(), canonical.clone()).await;
assert!(subs.unsubscribe(&canonical).await);
assert!(!subs.unsubscribe(&raw).await);
}
#[tokio::test]
async fn test_record_alias_keeps_only_latest_alias_per_canonical() {
let subs = ResourceSubscriptions::new();
let canonical = "lsp-diagnostics:///file.rs".to_string();
subs.subscribe(canonical.clone()).await.unwrap();
let raws = [
"lsp-diagnostics:///%66ile.rs".to_string(),
"lsp-diagnostics:///fil%65.rs".to_string(),
"lsp-diagnostics:///file%2Ers".to_string(),
];
for raw in &raws {
subs.record_alias(raw.clone(), canonical.clone()).await;
}
for raw in &raws[..raws.len() - 1] {
assert!(!subs.unsubscribe(raw).await);
}
let latest = raws.last().unwrap();
assert!(subs.unsubscribe(latest).await);
assert!(!subs.contains(&canonical).await);
}
#[tokio::test]
async fn test_record_alias_noop_for_unsubscribed_canonical() {
let subs = ResourceSubscriptions::new();
let raw = "lsp-diagnostics:///var/tmp/file.rs".to_string();
let canonical = "lsp-diagnostics:///private/var/tmp/file.rs".to_string();
subs.record_alias(raw.clone(), canonical.clone()).await;
subs.subscribe(canonical.clone()).await.unwrap();
assert!(!subs.unsubscribe(&raw).await);
assert!(subs.unsubscribe(&canonical).await);
}
#[tokio::test]
async fn test_subscribe_cap_exceeded() {
let subs = ResourceSubscriptions::new();
for i in 0..MAX_SUBSCRIPTIONS {
subs.subscribe(format!("lsp-diagnostics:///file{i}.rs"))
.await
.unwrap();
}
let result = subs
.subscribe("lsp-diagnostics:///overflow.rs".to_string())
.await;
assert_eq!(result, Err(SubscriptionError::LimitReached));
}
#[tokio::test]
async fn test_snapshot() {
let subs = ResourceSubscriptions::new();
subs.subscribe("lsp-diagnostics:///a.rs".to_string())
.await
.unwrap();
subs.subscribe("lsp-diagnostics:///b.rs".to_string())
.await
.unwrap();
let mut snap = subs.snapshot().await;
snap.sort();
assert_eq!(snap, ["lsp-diagnostics:///a.rs", "lsp-diagnostics:///b.rs"]);
}
#[test]
fn test_registry_register_returns_distinct_sets() {
let registry = SubscriptionRegistry::new();
let a = registry.register();
let b = registry.register();
assert!(!Arc::ptr_eq(&a, &b));
}
#[tokio::test]
async fn test_registry_sessions_are_isolated() {
let registry = SubscriptionRegistry::new();
let session_a = registry.register();
let session_b = registry.register();
session_a
.subscribe("lsp-diagnostics:///a.rs".to_string())
.await
.unwrap();
assert!(session_a.contains("lsp-diagnostics:///a.rs").await);
assert!(!session_b.contains("lsp-diagnostics:///a.rs").await);
assert!(!session_b.unsubscribe("lsp-diagnostics:///a.rs").await);
assert!(session_a.contains("lsp-diagnostics:///a.rs").await);
}
#[tokio::test]
async fn test_registry_cap_is_per_session() {
let registry = SubscriptionRegistry::new();
let session_a = registry.register();
let session_b = registry.register();
for i in 0..MAX_SUBSCRIPTIONS {
session_a
.subscribe(format!("lsp-diagnostics:///a{i}.rs"))
.await
.unwrap();
}
assert_eq!(
session_a
.subscribe("lsp-diagnostics:///overflow.rs".to_string())
.await,
Err(SubscriptionError::LimitReached)
);
assert!(
session_b
.subscribe("lsp-diagnostics:///b.rs".to_string())
.await
.unwrap()
);
}
#[tokio::test]
async fn test_registry_any_contains_and_is_all_empty() {
let registry = SubscriptionRegistry::new();
assert!(registry.is_all_empty().await);
assert!(!registry.any_contains("lsp-diagnostics:///a.rs").await);
let session_a = registry.register();
let _session_b = registry.register();
session_a
.subscribe("lsp-diagnostics:///a.rs".to_string())
.await
.unwrap();
assert!(!registry.is_all_empty().await);
assert!(registry.any_contains("lsp-diagnostics:///a.rs").await);
assert!(!registry.any_contains("lsp-diagnostics:///other.rs").await);
}
#[tokio::test]
async fn test_registry_reclaims_dropped_session() {
let registry = SubscriptionRegistry::new();
let session_a = registry.register();
session_a
.subscribe("lsp-diagnostics:///a.rs".to_string())
.await
.unwrap();
assert!(registry.any_contains("lsp-diagnostics:///a.rs").await);
drop(session_a);
assert!(!registry.any_contains("lsp-diagnostics:///a.rs").await);
assert!(registry.is_all_empty().await);
assert_eq!(registry.0.lock().unwrap().len(), 0);
}
}