use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime};
use lsp_types::{
DidChangeTextDocumentParams, DidOpenTextDocumentParams, TextDocumentContentChangeEvent,
TextDocumentItem, Uri, VersionedTextDocumentIdentifier,
};
use tokio::fs;
use tokio::io::AsyncReadExt;
use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
use tokio::time::Instant;
use url::Url;
use super::lock_std;
use crate::config::ServerId;
use crate::error::{Error, Result};
use crate::lsp::LspClient;
const DISK_CHECK_DEBOUNCE: Duration = Duration::from_millis(250);
const MTIME_GRANULARITY: Duration = Duration::from_secs(2);
fn mtime_settled(mtime: Option<SystemTime>, read_at: SystemTime) -> bool {
mtime.is_some_and(|m| {
m.checked_add(MTIME_GRANULARITY)
.is_some_and(|t| t <= read_at)
})
}
#[derive(Debug, Clone, Copy)]
pub struct DiskSync {
pub mtime: Option<SystemTime>,
pub size: u64,
pub mtime_settled: bool,
pub content_checked_at: Instant,
}
impl PartialEq for DiskSync {
fn eq(&self, other: &Self) -> bool {
self.mtime == other.mtime
&& self.size == other.size
&& self.mtime_settled == other.mtime_settled
}
}
impl Eq for DiskSync {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentState {
pub uri: Uri,
pub language_id: String,
pub version: i32,
pub content: String,
pub disk: Option<DiskSync>,
pub synced: HashMap<ServerId, i32>,
}
#[derive(Debug, Clone, Copy)]
pub struct ResourceLimits {
pub max_documents: usize,
pub max_file_size: u64,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
max_documents: 100,
max_file_size: 10 * 1024 * 1024, }
}
}
#[derive(Debug)]
pub struct DocumentTracker {
documents: StdMutex<HashMap<PathBuf, DocumentState>>,
path_locks: StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
limits: ResourceLimits,
extension_map: HashMap<String, String>,
}
impl DocumentTracker {
#[must_use]
pub fn new(limits: ResourceLimits, extension_map: HashMap<String, String>) -> Self {
Self {
documents: StdMutex::new(HashMap::new()),
path_locks: StdMutex::new(HashMap::new()),
limits,
extension_map,
}
}
#[must_use]
pub fn is_open(&self, path: &Path) -> bool {
lock_std(&self.documents).contains_key(path)
}
#[must_use]
pub fn get(&self, path: &Path) -> Option<DocumentState> {
lock_std(&self.documents).get(path).cloned()
}
#[must_use]
pub fn len(&self) -> usize {
lock_std(&self.documents).len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
lock_std(&self.documents).is_empty()
}
pub fn open(&self, path: PathBuf, content: String) -> Result<Uri> {
self.check_file_size(content.len() as u64)?;
let uri = path_to_uri(&path);
let language_id = detect_language(&path, &self.extension_map);
let state = DocumentState {
uri: uri.clone(),
language_id,
version: 1,
content,
disk: None,
synced: HashMap::new(),
};
let mut documents = lock_std(&self.documents);
if self.limits.max_documents > 0 && documents.len() >= self.limits.max_documents {
return Err(Error::DocumentLimitExceeded {
current: documents.len(),
max: self.limits.max_documents,
});
}
documents.insert(path, state);
drop(documents);
Ok(uri)
}
pub fn update(&self, path: &Path, content: String) -> Option<i32> {
let mut documents = lock_std(&self.documents);
if let Some(state) = documents.get_mut(path) {
state.version += 1;
state.content = content;
state.disk = None;
Some(state.version)
} else {
None
}
}
const fn check_file_size(&self, size: u64) -> Result<()> {
if self.limits.max_file_size > 0 && size > self.limits.max_file_size {
return Err(Error::FileSizeLimitExceeded {
size,
max: self.limits.max_file_size,
});
}
Ok(())
}
fn set_disk(&self, path: &Path, snap: DiskSync) {
if let Some(st) = lock_std(&self.documents).get_mut(path) {
st.disk = Some(snap);
}
}
pub fn close(&self, path: &Path) -> Option<DocumentState> {
lock_std(&self.documents).remove(path)
}
pub fn close_all(&self) -> Vec<DocumentState> {
lock_std(&self.documents)
.drain()
.map(|(_, state)| state)
.collect()
}
pub fn open_paths(&self) -> Vec<PathBuf> {
lock_std(&self.documents).keys().cloned().collect()
}
async fn lock_path(&self, path: &Path) -> PathLockGuard<'_> {
let arc = {
let mut locks = lock_std(&self.path_locks);
locks
.entry(path.to_path_buf())
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone()
};
let guard = Arc::clone(&arc).lock_owned().await;
PathLockGuard {
path_locks: &self.path_locks,
path: path.to_path_buf(),
arc,
guard: Some(guard),
}
}
pub async fn ensure_open(
&self,
path: &Path,
server: &ServerId,
lsp_client: &LspClient,
) -> Result<Uri> {
let _path_guard = self.lock_path(path).await;
let decision = self.disk_phase(path).await?;
self.sync_phase(path, server, lsp_client, decision).await
}
async fn disk_phase(&self, path: &Path) -> Result<Decision> {
if !lock_std(&self.documents).contains_key(path) {
return self.disk_phase_new(path).await;
}
let read_at = SystemTime::now();
let meta = fs::metadata(path).await.map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
let mtime = meta.modified().ok();
let size = meta.len();
let Some((uri, current_version, fast_path)) =
lock_std(&self.documents).get(path).map(|st| {
let stat_matches = st.disk.is_some_and(|d| d.mtime == mtime && d.size == size);
let fast_path = match st.disk {
Some(d) if stat_matches && d.mtime_settled => true,
Some(d)
if stat_matches && d.content_checked_at.elapsed() < DISK_CHECK_DEBOUNCE =>
{
true
}
_ => false,
};
(st.uri.clone(), st.version, fast_path)
})
else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
if fast_path {
return Ok(Decision::unchanged(uri, current_version));
}
let (fresh, ..) = self.read_to_string_checked(path).await?;
let snap = DiskSync {
mtime,
size,
mtime_settled: mtime_settled(mtime, read_at),
content_checked_at: Instant::now(),
};
let Some(unchanged) = lock_std(&self.documents)
.get(path)
.map(|st| fresh == st.content)
else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
if unchanged {
self.set_disk(path, snap);
return Ok(Decision::unchanged(uri, current_version));
}
Ok(Decision {
uri,
target_version: current_version.saturating_add(1),
fresh_content: Some(fresh),
snap: Some(snap),
})
}
async fn disk_phase_new(&self, path: &Path) -> Result<Decision> {
let read_at = SystemTime::now();
let (content, mtime, size) = self.read_to_string_checked(path).await?;
let uri = self.open(path.to_path_buf(), content)?;
self.set_disk(
path,
DiskSync {
mtime,
size,
mtime_settled: mtime_settled(mtime, read_at),
content_checked_at: Instant::now(),
},
);
Ok(Decision::unchanged(uri, 1))
}
async fn read_to_string_checked(
&self,
path: &Path,
) -> Result<(String, Option<SystemTime>, u64)> {
let mut file = fs::File::open(path).await.map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
let meta = file.metadata().await.map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
self.check_file_size(meta.len())?;
let mut content = String::new();
file.read_to_string(&mut content)
.await
.map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
Ok((content, meta.modified().ok(), meta.len()))
}
async fn sync_phase(
&self,
path: &Path,
server: &ServerId,
lsp_client: &LspClient,
decision: Decision,
) -> Result<Uri> {
let Decision {
uri,
target_version,
fresh_content,
snap,
} = decision;
let Some(synced_version) = lock_std(&self.documents)
.get(path)
.map(|st| st.synced.get(server).copied())
else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
let up_to_date = synced_version.is_some_and(|v| v >= target_version);
let is_first_open = synced_version.is_none();
if up_to_date {
return Ok(uri);
}
let Some((language_id, text)) = lock_std(&self.documents).get(path).map(|st| {
let text = fresh_content.clone().unwrap_or_else(|| st.content.clone());
(st.language_id.clone(), text)
}) else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
let notify_result = if is_first_open {
lsp_client
.notify(
"textDocument/didOpen",
DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id,
version: target_version,
text,
},
},
)
.await
} else {
lsp_client
.notify(
"textDocument/didChange",
DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: target_version,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text,
}],
},
)
.await
};
if let Err(err) = notify_result {
let first_ever_sync = lock_std(&self.documents)
.get(path)
.is_some_and(|st| st.synced.is_empty());
if is_first_open && first_ever_sync {
lock_std(&self.documents).remove(path);
}
return Err(err);
}
let mut documents = lock_std(&self.documents);
let Some(st) = documents.get_mut(path) else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
if let Some(fresh) = fresh_content {
st.version = target_version;
st.content = fresh;
st.disk = snap;
}
st.synced.insert(server.clone(), target_version);
drop(documents);
Ok(uri)
}
}
struct PathLockGuard<'a> {
path_locks: &'a StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
path: PathBuf,
arc: Arc<AsyncMutex<()>>,
guard: Option<OwnedMutexGuard<()>>,
}
impl Drop for PathLockGuard<'_> {
fn drop(&mut self) {
self.guard.take();
let mut locks = lock_std(self.path_locks);
if Arc::strong_count(&self.arc) <= 2 {
locks.remove(&self.path);
}
}
}
struct Decision {
uri: Uri,
target_version: i32,
fresh_content: Option<String>,
snap: Option<DiskSync>,
}
impl Decision {
const fn unchanged(uri: Uri, target_version: i32) -> Self {
Self {
uri,
target_version,
fresh_content: None,
snap: None,
}
}
}
#[must_use]
pub fn path_to_uri(path: &Path) -> Uri {
let uri_string = file_uri_string(path);
let uri_string = encode_rfc3986_path_chars(&uri_string);
#[allow(clippy::expect_used)]
uri_string.parse().expect("failed to create URI from path")
}
#[cfg(not(windows))]
fn file_uri_string(path: &Path) -> String {
#[allow(clippy::expect_used)]
let file_url = Url::from_file_path(path).expect("failed to create file URI from path");
file_url.into()
}
#[cfg(windows)]
fn file_uri_string(path: &Path) -> String {
match Url::from_file_path(path) {
Ok(file_url) => file_url.into(),
Err(()) if path.has_root() => windows_rooted_path_to_file_uri(path),
Err(()) => panic!("failed to create file URI from path"),
}
}
#[cfg(windows)]
fn windows_rooted_path_to_file_uri(path: &Path) -> String {
let path_str = path.to_string_lossy();
let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
format!("file:///{}", stripped.replace('\\', "/"))
}
fn encode_rfc3986_path_chars(uri: &str) -> String {
#[allow(clippy::expect_used)]
let url = Url::parse(uri).expect("encode called with invalid URI");
let prefix = url[..url::Position::BeforePath].to_owned();
let encoded = url[url::Position::BeforePath..]
.replace('[', "%5B")
.replace(']', "%5D")
.replace('^', "%5E")
.replace('|', "%7C");
format!("{prefix}{encoded}")
}
#[must_use]
pub fn uri_to_path(uri: &Uri) -> Option<PathBuf> {
let url = Url::parse(uri.as_str()).ok()?;
if url.scheme() != "file" {
return None;
}
if !url.host_str().unwrap_or("").is_empty() {
return None;
}
url.to_file_path().ok()
}
#[must_use]
pub fn detect_language(path: &Path, extension_map: &HashMap<String, String>) -> String {
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
extension_map
.get(extension)
.cloned()
.unwrap_or_else(|| "plaintext".to_string())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_detect_language() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
map.insert("py".to_string(), "python".to_string());
map.insert("ts".to_string(), "typescript".to_string());
assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
assert_eq!(detect_language(Path::new("script.py"), &map), "python");
assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
}
#[test]
fn test_document_tracker() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/file.rs");
assert!(!tracker.is_open(&path));
tracker
.open(path.clone(), "fn main() {}".to_string())
.unwrap();
assert!(tracker.is_open(&path));
assert_eq!(tracker.len(), 1);
let state = tracker.get(&path).unwrap();
assert_eq!(state.version, 1);
assert_eq!(state.language_id, "rust");
let new_version = tracker.update(&path, "fn main() { println!() }".to_string());
assert_eq!(new_version, Some(2));
tracker.close(&path);
assert!(!tracker.is_open(&path));
assert!(tracker.is_empty());
}
#[test]
fn test_document_limit() {
let limits = ResourceLimits {
max_documents: 2,
max_file_size: 100,
};
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(limits, map);
tracker
.open(PathBuf::from("/test/file1.rs"), "fn test1() {}".to_string())
.unwrap();
tracker
.open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
.unwrap();
let result = tracker.open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string());
assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
}
#[test]
fn test_file_size_limit() {
let limits = ResourceLimits {
max_documents: 10,
max_file_size: 10,
};
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(limits, map);
tracker
.open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
.unwrap();
let large_content = "x".repeat(100);
let result = tracker.open(PathBuf::from("/test/large.rs"), large_content);
assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
}
#[test]
fn test_resource_limits_default() {
let limits = ResourceLimits::default();
assert_eq!(limits.max_documents, 100);
assert_eq!(limits.max_file_size, 10 * 1024 * 1024);
}
#[test]
fn test_resource_limits_custom() {
let limits = ResourceLimits {
max_documents: 50,
max_file_size: 5 * 1024 * 1024,
};
assert_eq!(limits.max_documents, 50);
assert_eq!(limits.max_file_size, 5 * 1024 * 1024);
}
#[test]
fn test_resource_limits_zero_unlimited() {
let limits = ResourceLimits {
max_documents: 0,
max_file_size: 0,
};
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(limits, map);
for i in 0..200 {
tracker
.open(
PathBuf::from(format!("/test/file{i}.rs")),
"content".to_string(),
)
.unwrap();
}
assert_eq!(tracker.len(), 200);
let huge_content = "x".repeat(100_000_000);
tracker
.open(PathBuf::from("/test/huge.rs"), huge_content)
.unwrap();
}
#[test]
fn test_document_state_clone() {
let state = DocumentState {
uri: "file:///test.rs".parse().unwrap(),
language_id: "rust".to_string(),
version: 5,
content: "fn main() {}".to_string(),
disk: None,
synced: HashMap::new(),
};
#[allow(clippy::redundant_clone)]
let cloned = state.clone();
assert_eq!(cloned.uri, state.uri);
assert_eq!(cloned.language_id, state.language_id);
assert_eq!(cloned.version, 5);
assert_eq!(cloned.content, state.content);
}
#[test]
fn test_update_nonexistent_document() {
let map = HashMap::new();
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/nonexistent.rs");
let version = tracker.update(&path, "new content".to_string());
assert_eq!(
version, None,
"Updating non-existent document should return None"
);
}
#[test]
fn test_close_nonexistent_document() {
let map = HashMap::new();
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/nonexistent.rs");
let state = tracker.close(&path);
assert_eq!(
state, None,
"Closing non-existent document should return None"
);
}
#[test]
fn test_close_all_documents() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
tracker
.open(PathBuf::from("/test/file1.rs"), "content1".to_string())
.unwrap();
tracker
.open(PathBuf::from("/test/file2.rs"), "content2".to_string())
.unwrap();
tracker
.open(PathBuf::from("/test/file3.rs"), "content3".to_string())
.unwrap();
assert_eq!(tracker.len(), 3);
let closed = tracker.close_all();
assert_eq!(closed.len(), 3);
assert!(tracker.is_empty());
}
#[test]
fn test_get_nonexistent_document() {
let map = HashMap::new();
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/nonexistent.rs");
let state = tracker.get(&path);
assert!(
state.is_none(),
"Getting non-existent document should return None"
);
}
#[test]
fn test_document_version_increments() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/versioned.rs");
tracker.open(path.clone(), "v1".to_string()).unwrap();
assert_eq!(tracker.get(&path).unwrap().version, 1);
tracker.update(&path, "v2".to_string());
assert_eq!(tracker.get(&path).unwrap().version, 2);
tracker.update(&path, "v3".to_string());
assert_eq!(tracker.get(&path).unwrap().version, 3);
tracker.update(&path, "v4".to_string());
assert_eq!(tracker.get(&path).unwrap().version, 4);
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_detect_language_all_extensions() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
map.insert("py".to_string(), "python".to_string());
map.insert("pyw".to_string(), "python".to_string());
map.insert("pyi".to_string(), "python".to_string());
map.insert("js".to_string(), "javascript".to_string());
map.insert("mjs".to_string(), "javascript".to_string());
map.insert("cjs".to_string(), "javascript".to_string());
map.insert("ts".to_string(), "typescript".to_string());
map.insert("mts".to_string(), "typescript".to_string());
map.insert("cts".to_string(), "typescript".to_string());
map.insert("tsx".to_string(), "typescriptreact".to_string());
map.insert("jsx".to_string(), "javascriptreact".to_string());
map.insert("go".to_string(), "go".to_string());
map.insert("c".to_string(), "c".to_string());
map.insert("h".to_string(), "c".to_string());
map.insert("cpp".to_string(), "cpp".to_string());
map.insert("cc".to_string(), "cpp".to_string());
map.insert("cxx".to_string(), "cpp".to_string());
map.insert("hpp".to_string(), "cpp".to_string());
map.insert("hh".to_string(), "cpp".to_string());
map.insert("hxx".to_string(), "cpp".to_string());
map.insert("java".to_string(), "java".to_string());
map.insert("rb".to_string(), "ruby".to_string());
map.insert("php".to_string(), "php".to_string());
map.insert("swift".to_string(), "swift".to_string());
map.insert("kt".to_string(), "kotlin".to_string());
map.insert("kts".to_string(), "kotlin".to_string());
map.insert("scala".to_string(), "scala".to_string());
map.insert("sc".to_string(), "scala".to_string());
map.insert("zig".to_string(), "zig".to_string());
map.insert("lua".to_string(), "lua".to_string());
map.insert("sh".to_string(), "shellscript".to_string());
map.insert("bash".to_string(), "shellscript".to_string());
map.insert("zsh".to_string(), "shellscript".to_string());
map.insert("json".to_string(), "json".to_string());
map.insert("toml".to_string(), "toml".to_string());
map.insert("yaml".to_string(), "yaml".to_string());
map.insert("yml".to_string(), "yaml".to_string());
map.insert("xml".to_string(), "xml".to_string());
map.insert("html".to_string(), "html".to_string());
map.insert("htm".to_string(), "html".to_string());
map.insert("css".to_string(), "css".to_string());
map.insert("scss".to_string(), "scss".to_string());
map.insert("less".to_string(), "less".to_string());
map.insert("md".to_string(), "markdown".to_string());
map.insert("markdown".to_string(), "markdown".to_string());
assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
assert_eq!(detect_language(Path::new("script.py"), &map), "python");
assert_eq!(detect_language(Path::new("script.pyw"), &map), "python");
assert_eq!(detect_language(Path::new("script.pyi"), &map), "python");
assert_eq!(detect_language(Path::new("app.js"), &map), "javascript");
assert_eq!(detect_language(Path::new("app.mjs"), &map), "javascript");
assert_eq!(detect_language(Path::new("app.cjs"), &map), "javascript");
assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
assert_eq!(detect_language(Path::new("app.mts"), &map), "typescript");
assert_eq!(detect_language(Path::new("app.cts"), &map), "typescript");
assert_eq!(
detect_language(Path::new("component.tsx"), &map),
"typescriptreact"
);
assert_eq!(
detect_language(Path::new("component.jsx"), &map),
"javascriptreact"
);
assert_eq!(detect_language(Path::new("main.go"), &map), "go");
assert_eq!(detect_language(Path::new("main.c"), &map), "c");
assert_eq!(detect_language(Path::new("header.h"), &map), "c");
assert_eq!(detect_language(Path::new("main.cpp"), &map), "cpp");
assert_eq!(detect_language(Path::new("main.cc"), &map), "cpp");
assert_eq!(detect_language(Path::new("main.cxx"), &map), "cpp");
assert_eq!(detect_language(Path::new("header.hpp"), &map), "cpp");
assert_eq!(detect_language(Path::new("header.hh"), &map), "cpp");
assert_eq!(detect_language(Path::new("header.hxx"), &map), "cpp");
assert_eq!(detect_language(Path::new("Main.java"), &map), "java");
assert_eq!(detect_language(Path::new("script.rb"), &map), "ruby");
assert_eq!(detect_language(Path::new("index.php"), &map), "php");
assert_eq!(detect_language(Path::new("App.swift"), &map), "swift");
assert_eq!(detect_language(Path::new("Main.kt"), &map), "kotlin");
assert_eq!(detect_language(Path::new("script.kts"), &map), "kotlin");
assert_eq!(detect_language(Path::new("Main.scala"), &map), "scala");
assert_eq!(detect_language(Path::new("script.sc"), &map), "scala");
assert_eq!(detect_language(Path::new("main.zig"), &map), "zig");
assert_eq!(detect_language(Path::new("script.lua"), &map), "lua");
assert_eq!(detect_language(Path::new("script.sh"), &map), "shellscript");
assert_eq!(
detect_language(Path::new("script.bash"), &map),
"shellscript"
);
assert_eq!(
detect_language(Path::new("script.zsh"), &map),
"shellscript"
);
assert_eq!(detect_language(Path::new("data.json"), &map), "json");
assert_eq!(detect_language(Path::new("config.toml"), &map), "toml");
assert_eq!(detect_language(Path::new("config.yaml"), &map), "yaml");
assert_eq!(detect_language(Path::new("config.yml"), &map), "yaml");
assert_eq!(detect_language(Path::new("data.xml"), &map), "xml");
assert_eq!(detect_language(Path::new("index.html"), &map), "html");
assert_eq!(detect_language(Path::new("index.htm"), &map), "html");
assert_eq!(detect_language(Path::new("styles.css"), &map), "css");
assert_eq!(detect_language(Path::new("styles.scss"), &map), "scss");
assert_eq!(detect_language(Path::new("styles.less"), &map), "less");
assert_eq!(detect_language(Path::new("README.md"), &map), "markdown");
assert_eq!(
detect_language(Path::new("README.markdown"), &map),
"markdown"
);
assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
assert_eq!(
detect_language(Path::new("no_extension"), &map),
"plaintext"
);
}
#[test]
fn test_path_to_uri_unix() {
#[cfg(not(windows))]
{
let path = Path::new("/home/user/project/main.rs");
let uri = path_to_uri(path);
assert!(
uri.as_str()
.starts_with("file:///home/user/project/main.rs")
);
}
}
#[test]
fn test_path_to_uri_with_special_chars() {
let path = Path::new("/home/user/project-test/main.rs");
let uri = path_to_uri(path);
assert!(uri.as_str().starts_with("file://"));
assert!(uri.as_str().contains("project-test"));
}
#[test]
fn test_path_to_uri_percent_encodes_reserved_chars() {
#[cfg(windows)]
let path = Path::new(r"C:\home\user\routes\api\[...]^|.ts");
#[cfg(not(windows))]
let path = Path::new("/home/user/routes/api/[...]^|.ts");
let uri = path_to_uri(path);
#[cfg(windows)]
let expected = "file:///C:/home/user/routes/api/%5B...%5D%5E%7C.ts";
#[cfg(not(windows))]
let expected = "file:///home/user/routes/api/%5B...%5D%5E%7C.ts";
assert_eq!(uri.as_str(), expected);
assert_eq!(
uri_to_path(&uri).as_deref(),
Some(path),
"encoded file URI should round-trip to the original path"
);
}
#[test]
fn test_path_to_uri_percent_encodes_reserved_chars_in_short_path() {
#[cfg(windows)]
let path = Path::new(r"C:\[a].ts");
#[cfg(not(windows))]
let path = Path::new("/[a].ts");
let uri = path_to_uri(path);
assert!(
uri.as_str().ends_with("%5Ba%5D.ts"),
"short path should percent-encode reserved chars, got {}",
uri.as_str()
);
assert_eq!(uri_to_path(&uri).as_deref(), Some(path));
}
#[test]
fn test_document_tracker_concurrent_operations() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path1 = PathBuf::from("/test/file1.rs");
let path2 = PathBuf::from("/test/file2.rs");
tracker.open(path1.clone(), "content1".to_string()).unwrap();
tracker.open(path2.clone(), "content2".to_string()).unwrap();
assert_eq!(tracker.len(), 2);
assert!(tracker.is_open(&path1));
assert!(tracker.is_open(&path2));
tracker.update(&path1, "new content1".to_string());
assert_eq!(tracker.get(&path1).unwrap().content, "new content1");
assert_eq!(tracker.get(&path2).unwrap().content, "content2");
tracker.close(&path1);
assert_eq!(tracker.len(), 1);
assert!(!tracker.is_open(&path1));
assert!(tracker.is_open(&path2));
}
#[test]
fn test_empty_content() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/empty.rs");
tracker.open(path.clone(), String::new()).unwrap();
assert!(tracker.is_open(&path));
assert_eq!(tracker.get(&path).unwrap().content, "");
}
#[test]
fn test_unicode_content() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/unicode.rs");
let content = "fn テスト() { println!(\"こんにちは\"); }";
tracker.open(path.clone(), content.to_string()).unwrap();
assert_eq!(tracker.get(&path).unwrap().content, content);
}
#[test]
fn test_document_limit_exact_boundary() {
let limits = ResourceLimits {
max_documents: 5,
max_file_size: 1000,
};
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(limits, map);
for i in 0..5 {
tracker
.open(
PathBuf::from(format!("/test/file{i}.rs")),
"content".to_string(),
)
.unwrap();
}
assert_eq!(tracker.len(), 5);
let result = tracker.open(PathBuf::from("/test/file6.rs"), "content".to_string());
assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
}
#[test]
fn test_file_size_exact_boundary() {
let limits = ResourceLimits {
max_documents: 10,
max_file_size: 100,
};
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(limits, map);
let exact_size_content = "x".repeat(100);
tracker
.open(PathBuf::from("/test/exact.rs"), exact_size_content)
.unwrap();
let over_size_content = "x".repeat(101);
let result = tracker.open(PathBuf::from("/test/over.rs"), over_size_content);
assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
}
#[test]
fn test_detect_language_with_custom_extension() {
let mut map = HashMap::new();
map.insert("nu".to_string(), "nushell".to_string());
assert_eq!(detect_language(Path::new("script.nu"), &map), "nushell");
let empty_map = HashMap::new();
assert_eq!(
detect_language(Path::new("script.nu"), &empty_map),
"plaintext"
);
}
#[test]
fn test_detect_language_custom_overrides_default() {
let mut custom_map = HashMap::new();
custom_map.insert("rs".to_string(), "custom-rust".to_string());
assert_eq!(
detect_language(Path::new("main.rs"), &custom_map),
"custom-rust"
);
let mut default_map = HashMap::new();
default_map.insert("rs".to_string(), "rust".to_string());
assert_eq!(detect_language(Path::new("main.rs"), &default_map), "rust");
}
#[test]
fn test_detect_language_fallback_to_plaintext() {
let mut map = HashMap::new();
map.insert("nu".to_string(), "nushell".to_string());
assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
}
#[test]
fn test_detect_language_empty_map() {
let map = HashMap::new();
assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
}
#[test]
fn test_document_tracker_with_extensions() {
let mut map = HashMap::new();
map.insert("nu".to_string(), "nushell".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/script.nu");
tracker
.open(path.clone(), "# nushell script".to_string())
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.language_id, "nushell");
}
#[test]
fn test_document_tracker_uses_provided_map() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/main.rs");
tracker
.open(path.clone(), "fn main() {}".to_string())
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.language_id, "rust");
}
#[test]
fn test_multiple_extensions_same_language() {
let mut map = HashMap::new();
map.insert("cpp".to_string(), "c++".to_string());
map.insert("cc".to_string(), "c++".to_string());
map.insert("cxx".to_string(), "c++".to_string());
assert_eq!(detect_language(Path::new("main.cpp"), &map), "c++");
assert_eq!(detect_language(Path::new("main.cc"), &map), "c++");
assert_eq!(detect_language(Path::new("main.cxx"), &map), "c++");
}
#[test]
fn test_case_sensitive_extensions() {
let mut map = HashMap::new();
map.insert("NU".to_string(), "nushell".to_string());
assert_eq!(detect_language(Path::new("script.nu"), &map), "plaintext");
}
#[cfg(unix)]
#[test]
fn test_uri_to_path_file_scheme() {
let uri: Uri = "file:///home/user/main.rs".parse().unwrap();
let path = uri_to_path(&uri).unwrap();
assert_eq!(path, PathBuf::from("/home/user/main.rs"));
}
#[test]
fn test_uri_to_path_non_file_scheme_returns_none() {
let uri: Uri = "https://example.com/file.rs".parse().unwrap();
assert!(uri_to_path(&uri).is_none());
}
#[test]
fn test_uri_to_path_lsp_diagnostics_scheme_returns_none() {
let uri: Uri = "lsp-diagnostics:///home/user/main.rs".parse().unwrap();
assert!(uri_to_path(&uri).is_none());
}
#[test]
fn test_uri_to_path_with_authority_returns_none() {
let result = "file://server/share/path.rs"
.parse::<Uri>()
.ok()
.and_then(|u| uri_to_path(&u));
assert!(result.is_none());
}
#[test]
fn test_open_paths_empty_tracker() {
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
assert_eq!(tracker.open_paths().len(), 0);
}
#[test]
fn test_open_paths_populated_tracker() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
tracker.open(PathBuf::from("/b.rs"), String::new()).unwrap();
let mut paths = tracker.open_paths();
paths.sort();
assert_eq!(paths, [PathBuf::from("/a.rs"), PathBuf::from("/b.rs")]);
}
#[test]
fn test_open_paths_after_close() {
let mut map = HashMap::new();
map.insert("rs".to_string(), "rust".to_string());
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
tracker.close(Path::new("/a.rs"));
assert_eq!(tracker.open_paths().len(), 0);
}
use std::process::Stdio;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use crate::config::LspServerConfig;
use crate::lsp::LspTransport;
struct FakeServer {
_write_half: Child,
_read_half: Child,
_read_half_stdin: ChildStdin,
write_stdout: ChildStdout,
}
fn fake_lsp_client() -> (LspClient, FakeServer) {
let mut write_half = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let write_stdin = write_half.stdin.take().unwrap();
let write_stdout = write_half.stdout.take().unwrap();
let mut read_half = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let read_stdout = read_half.stdout.take().unwrap();
let read_stdin = read_half.stdin.take().unwrap();
let transport = LspTransport::new(write_stdin, read_stdout);
let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
(
client,
FakeServer {
_write_half: write_half,
_read_half: read_half,
_read_half_stdin: read_stdin,
write_stdout,
},
)
}
fn set_mtime(path: &Path, time: SystemTime) {
let file = std::fs::OpenOptions::new().write(true).open(path).unwrap();
file.set_modified(time).unwrap();
}
fn settled_past() -> SystemTime {
SystemTime::now() - Duration::from_secs(10)
}
async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> serde_json::Value {
let mut content_length = None;
let mut line = String::new();
loop {
line.clear();
reader.read_line(&mut line).await.unwrap();
if line == "\r\n" || line == "\n" {
break;
}
if let Some((key, value)) = line.trim_end().split_once(':')
&& key.trim().eq_ignore_ascii_case("content-length")
{
content_length = Some(value.trim().parse::<usize>().unwrap());
}
}
let mut buf = vec![0u8; content_length.unwrap()];
reader.read_exact(&mut buf).await.unwrap();
serde_json::from_slice(&buf).unwrap()
}
#[test]
fn test_mtime_settled_boundary() {
let read_at = SystemTime::now();
assert!(!mtime_settled(None, read_at), "no mtime is never settled");
assert!(
mtime_settled(Some(read_at - Duration::from_secs(3)), read_at),
"3s older than read_at is past the 2s granularity margin"
);
assert!(
!mtime_settled(Some(read_at - Duration::from_secs(1)), read_at),
"1s older than read_at is within the 2s granularity margin"
);
assert!(
!mtime_settled(Some(read_at + Duration::from_secs(10)), read_at),
"an mtime after read_at is never settled"
);
}
#[tokio::test]
async fn test_ensure_open_unchanged_file_is_fast_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let uri1 = tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
assert_eq!(tracker.get(&path).unwrap().version, 1);
let uri2 = tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
assert_eq!(uri1, uri2);
assert_eq!(tracker.get(&path).unwrap().version, 1);
assert_eq!(tracker.get(&path).unwrap().content, "fn main() {}");
}
#[tokio::test]
async fn test_ensure_open_resyncs_on_size_change() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
set_mtime(&path, settled_past());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.version, 2);
assert_eq!(state.content, "fn main() { println!(\"hi\"); }");
}
#[tokio::test(start_paused = true)]
async fn test_ensure_open_regression_102_103_racy_same_size_rewrite() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "AAAA").unwrap();
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
std::fs::write(&path, "BBBB").unwrap();
set_mtime(&path, original_mtime);
tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(
state.version, 2,
"must resync despite identical (mtime, size)"
);
assert_eq!(state.content, "BBBB");
}
#[tokio::test(start_paused = true)]
async fn test_ensure_open_regression_102_103_settled_mtime_is_the_documented_limit() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "AAAA").unwrap();
set_mtime(&path, settled_past());
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
std::fs::write(&path, "BBBB").unwrap();
set_mtime(&path, original_mtime);
tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.version, 1, "documented limitation: fast path taken");
assert_eq!(state.content, "AAAA");
}
#[tokio::test(start_paused = true)]
async fn test_ensure_open_stat_is_never_debounced() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "AAAA").unwrap();
set_mtime(&path, settled_past());
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
std::fs::write(&path, "BBBBBBBB").unwrap();
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.version, 2);
assert_eq!(state.content, "BBBBBBBB");
}
#[tokio::test(start_paused = true)]
async fn test_ensure_open_debounce_gates_reread_only() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "AAAA").unwrap();
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
std::fs::write(&path, "BBBB").unwrap(); set_mtime(&path, original_mtime);
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
assert_eq!(tracker.get(&path).unwrap().version, 1);
tokio::time::advance(Duration::from_millis(300)).await;
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.version, 2);
assert_eq!(state.content, "BBBB");
}
#[tokio::test]
async fn test_ensure_open_deleted_file_errors_state_untouched() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
std::fs::remove_file(&path).unwrap();
let result = tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await;
assert!(matches!(result, Err(Error::FileIo { .. })));
assert!(tracker.is_open(&path));
assert_eq!(tracker.get(&path).unwrap().version, 1);
assert_eq!(tracker.get(&path).unwrap().content, "fn main() {}");
}
#[tokio::test]
async fn test_ensure_open_grows_past_limit_errors_state_intact() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "small").unwrap();
set_mtime(&path, settled_past());
let limits = ResourceLimits {
max_documents: 10,
max_file_size: 10,
};
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(limits, HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
std::fs::write(&path, "x".repeat(100)).unwrap();
let result = tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await;
assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
assert_eq!(tracker.get(&path).unwrap().content, "small");
assert_eq!(tracker.get(&path).unwrap().version, 1);
}
#[tokio::test]
async fn test_ensure_open_resync_at_document_capacity() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "AAAA").unwrap();
set_mtime(&path, settled_past());
let limits = ResourceLimits {
max_documents: 1,
max_file_size: 0,
};
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(limits, HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
assert_eq!(tracker.len(), 1);
std::fs::write(&path, "BBBBBBBB").unwrap();
let result = tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await;
assert!(
result.is_ok(),
"resync must not re-run the doc-count check on an already-tracked path"
);
assert_eq!(tracker.len(), 1);
assert_eq!(tracker.get(&path).unwrap().version, 2);
}
#[tokio::test]
async fn test_update_clears_disk_provenance() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
assert!(tracker.get(&path).unwrap().disk.is_some());
tracker.update(&path, "fn main() { updated(); }".to_string());
assert!(
tracker.get(&path).unwrap().disk.is_none(),
"update() must clear disk provenance so the next ensure_open re-verifies by content"
);
}
#[tokio::test]
async fn test_first_open_self_heals_when_did_open_notify_fails() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
let (client, _server) = fake_lsp_client();
let notify_will_fail = client.clone();
client.shutdown().await.unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let result = tracker
.ensure_open(&path, &ServerId::from("rust"), ¬ify_will_fail)
.await;
assert!(result.is_err(), "notify failure must propagate as an error");
assert!(
!tracker.is_open(&path),
"a failed didOpen must not leave the document tracked, or the server \
and tracker would stay permanently desynced"
);
}
#[tokio::test]
async fn test_resync_sends_didchange_with_full_replacement_over_the_wire() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client, mut server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
set_mtime(&path, settled_past());
tracker
.ensure_open(&path, &ServerId::from("rust"), &client)
.await
.unwrap();
let changed = read_framed_message(&mut wire).await;
assert_eq!(changed["method"], "textDocument/didChange");
let params = &changed["params"];
assert_eq!(params["textDocument"]["version"], 2);
let change = ¶ms["contentChanges"][0];
assert!(
change.get("range").is_none(),
"range must be omitted, not null, for a full-replacement change"
);
assert!(
change.get("rangeLength").is_none(),
"rangeLength must be omitted, not null, for a full-replacement change"
);
assert_eq!(change["text"], "fn main() { println!(\"hi\"); }");
}
#[tokio::test]
async fn test_ensure_open_second_server_gets_didopen_no_disk_change() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client_a, mut server_a) = fake_lsp_client();
let (client_b, mut server_b) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let id_a = ServerId::from("server-a");
let id_b = ServerId::from("server-b");
tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
let mut wire_a = BufReader::new(&mut server_a.write_stdout);
let opened_a = read_framed_message(&mut wire_a).await;
assert_eq!(opened_a["method"], "textDocument/didOpen");
tracker.ensure_open(&path, &id_b, &client_b).await.unwrap();
let mut wire_b = BufReader::new(&mut server_b.write_stdout);
let opened_b = read_framed_message(&mut wire_b).await;
assert_eq!(opened_b["method"], "textDocument/didOpen");
assert_eq!(opened_b["params"]["textDocument"]["version"], 1);
assert_eq!(opened_b["params"]["textDocument"]["text"], "fn main() {}");
}
#[tokio::test(start_paused = true)]
async fn test_ensure_open_second_server_gets_didopen_unchanged_content_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
let (client_a, _server_a) = fake_lsp_client();
let (client_b, mut server_b) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from("server-a"), &client_a)
.await
.unwrap();
tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
tracker
.ensure_open(&path, &ServerId::from("server-b"), &client_b)
.await
.unwrap();
let mut wire_b = BufReader::new(&mut server_b.write_stdout);
let opened_b = read_framed_message(&mut wire_b).await;
assert_eq!(opened_b["method"], "textDocument/didOpen");
}
#[tokio::test]
async fn test_ensure_open_same_server_twice_sends_nothing_second_time() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client, mut server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let id = ServerId::from("rust");
tracker.ensure_open(&path, &id, &client).await.unwrap();
tracker.ensure_open(&path, &id, &client).await.unwrap();
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
assert_eq!(
tracker.get(&path).unwrap().synced.get(&id),
Some(&1),
"second call for the same server must not re-open or re-change"
);
}
#[tokio::test]
async fn test_sync_phase_failed_didchange_does_not_disturb_other_server() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client_a, _server_a) = fake_lsp_client();
let (client_b, _server_b) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let id_a = ServerId::from("server-a");
let id_b = ServerId::from("server-b");
tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
tracker.ensure_open(&path, &id_b, &client_b).await.unwrap();
let client_b_will_fail = client_b.clone();
client_b.shutdown().await.unwrap();
std::fs::write(&path, "fn main() { updated(); }").unwrap();
set_mtime(&path, settled_past());
let result = tracker.ensure_open(&path, &id_b, &client_b_will_fail).await;
assert!(result.is_err(), "B's didChange must fail and propagate");
assert!(tracker.is_open(&path));
assert_eq!(tracker.get(&path).unwrap().content, "fn main() {}");
assert_eq!(tracker.get(&path).unwrap().version, 1);
assert_eq!(tracker.get(&path).unwrap().synced.get(&id_a), Some(&1));
assert_eq!(tracker.get(&path).unwrap().synced.get(&id_b), Some(&1));
tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
assert_eq!(
tracker.get(&path).unwrap().content,
"fn main() { updated(); }"
);
assert_eq!(tracker.get(&path).unwrap().synced.get(&id_a), Some(&2));
assert_eq!(tracker.get(&path).unwrap().synced.get(&id_b), Some(&1));
}
#[cfg(unix)]
#[tokio::test]
async fn test_ensure_open_different_paths_do_not_serialize() {
let dir = TempDir::new().unwrap();
let path_a = dir.path().join("a.rs");
let path_b = dir.path().join("b.rs");
std::fs::write(&path_b, "fn b() {}").unwrap();
set_mtime(&path_b, settled_past());
let status = std::process::Command::new("mkfifo")
.arg(&path_a)
.status()
.unwrap();
assert!(status.success(), "mkfifo must succeed to set up this test");
let (client_a, _server_a) = fake_lsp_client();
let (client_b, _server_b) = fake_lsp_client();
let tracker = Arc::new(DocumentTracker::new(
ResourceLimits::default(),
HashMap::new(),
));
let tracker_for_a = Arc::clone(&tracker);
let path_a_for_task = path_a.clone();
let handle_a = tokio::spawn(async move {
tracker_for_a
.ensure_open(&path_a_for_task, &ServerId::from("server-a"), &client_a)
.await
});
tokio::time::sleep(Duration::from_millis(200)).await;
tokio::time::timeout(
Duration::from_secs(5),
tracker.ensure_open(&path_b, &ServerId::from("server-b"), &client_b),
)
.await
.unwrap()
.unwrap();
let path_a_writer = path_a.clone();
tokio::task::spawn_blocking(move || {
std::fs::write(path_a_writer, "fn a() {}").unwrap();
})
.await
.unwrap();
handle_a.await.unwrap().unwrap();
assert_eq!(tracker.get(&path_a).unwrap().content, "fn a() {}");
}
#[tokio::test]
async fn test_ensure_open_concurrent_same_path_single_didopen() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client, mut server) = fake_lsp_client();
let tracker = Arc::new(DocumentTracker::new(
ResourceLimits::default(),
HashMap::new(),
));
let id = ServerId::from("rust");
let mut handles = Vec::new();
for _ in 0..8 {
let tracker = Arc::clone(&tracker);
let client = client.clone();
let path = path.clone();
let id = id.clone();
handles.push(tokio::spawn(async move {
tracker.ensure_open(&path, &id, &client).await
}));
}
for handle in handles {
handle.await.unwrap().unwrap();
}
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let extra =
tokio::time::timeout(Duration::from_millis(200), read_framed_message(&mut wire)).await;
assert!(
extra.is_err(),
"expected no additional notification after the single didOpen"
);
assert_eq!(tracker.get(&path).unwrap().synced.get(&id), Some(&1));
assert_eq!(tracker.get(&path).unwrap().version, 1);
}
#[tokio::test]
async fn test_ensure_open_path_locks_evicted_after_completion() {
let dir = TempDir::new().unwrap();
let paths: Vec<_> = ["a.rs", "b.rs", "c.rs"]
.iter()
.map(|name| dir.path().join(name))
.collect();
for path in &paths {
std::fs::write(path, "fn f() {}").unwrap();
set_mtime(path, settled_past());
}
let tracker = Arc::new(DocumentTracker::new(
ResourceLimits::default(),
HashMap::new(),
));
let id = ServerId::from("rust");
let mut handles = Vec::new();
let mut servers = Vec::new();
for path in paths.clone() {
let tracker = Arc::clone(&tracker);
let (client, server) = fake_lsp_client();
servers.push(server);
let id = id.clone();
handles.push(tokio::spawn(async move {
tracker.ensure_open(&path, &id, &client).await
}));
}
for handle in handles {
handle.await.unwrap().unwrap();
}
drop(servers);
assert!(
lock_std(&tracker.path_locks).is_empty(),
"path_locks must be fully evicted once every ensure_open call \
for every path has completed, otherwise the map grows \
unbounded for the lifetime of the process"
);
}
}