use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime};
use lsp_types::{
DidChangeTextDocumentNotification, DidChangeTextDocumentParams,
DidOpenTextDocumentNotification, DidOpenTextDocumentParams, TextDocumentContentChangeEvent,
TextDocumentItem, Uri, VersionedTextDocumentIdentifier,
};
use tokio::fs;
use tokio::io::{AsyncBufReadExt, 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;
use crate::util::{BoundedReadOutcome, bounded_read_cap, check_bounded_utf8};
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)
})
}
#[cfg(windows)]
fn check_disk_file_type(file: &fs::File, path: &Path) -> Result<()> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, GetFileType};
#[allow(unsafe_code)]
let file_type = unsafe { GetFileType(file.as_raw_handle().cast()) };
if file_type != FILE_TYPE_DISK {
return Err(Error::NotARegularFile(path.to_path_buf()));
}
Ok(())
}
#[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)]
pub struct DocumentState {
uri: Uri,
language_id: String,
version: i32,
content: String,
disk: Option<DiskSync>,
synced: HashMap<ServerId, i32>,
last_accessed: Instant,
}
impl PartialEq for DocumentState {
fn eq(&self, other: &Self) -> bool {
let Self {
uri,
language_id,
version,
content,
disk,
synced,
last_accessed: _,
} = self;
*uri == other.uri
&& *language_id == other.language_id
&& *version == other.version
&& *content == other.content
&& *disk == other.disk
&& *synced == other.synced
}
}
impl Eq for DocumentState {}
impl DocumentState {
fn new(uri: Uri, language_id: String, content: String) -> Self {
Self {
uri,
language_id,
version: 1,
content,
disk: None,
synced: HashMap::new(),
last_accessed: Instant::now(),
}
}
fn touch(&mut self) {
self.last_accessed = Instant::now();
}
#[must_use]
pub const fn uri(&self) -> &Uri {
&self.uri
}
#[must_use]
pub fn language_id(&self) -> &str {
&self.language_id
}
#[must_use]
pub const fn version(&self) -> i32 {
self.version
}
#[must_use]
pub fn content(&self) -> &str {
&self.content
}
const fn disk(&self) -> Option<DiskSync> {
self.disk
}
#[must_use]
pub fn synced_version(&self, server: &ServerId) -> Option<i32> {
self.synced.get(server).copied()
}
fn has_never_synced(&self) -> bool {
self.synced.is_empty()
}
fn apply_local_edit(&mut self, content: String) -> i32 {
self.version += 1;
self.content = content;
self.disk = None;
self.version
}
fn commit_reload(&mut self, version: i32, content: String, snap: Option<DiskSync>) {
debug_assert!(
version >= self.version,
"document version must be monotonically increasing"
);
self.version = version;
self.content = content;
self.disk = snap;
}
const fn set_disk(&mut self, snap: DiskSync) {
self.disk = Some(snap);
}
fn mark_synced(&mut self, server: ServerId, version: i32) {
self.synced.insert(server, version);
}
fn forget_server(&mut self, server: &ServerId) {
self.synced.remove(server);
}
}
pub const DEFAULT_MAX_DOCUMENTS: usize = 100;
pub const DEFAULT_MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
#[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: DEFAULT_MAX_DOCUMENTS,
max_file_size: DEFAULT_MAX_FILE_SIZE,
}
}
}
pub const OPEN_FAILURE_CHARGE_BYTES: u64 = 4096;
#[derive(Debug, Clone)]
pub struct LineRead {
pub(crate) text: Option<String>,
pub(crate) bytes_read: u64,
}
#[derive(Debug, Clone)]
pub struct EvictedDocument {
pub path: PathBuf,
pub uri: Uri,
pub synced_servers: Vec<ServerId>,
}
#[derive(Debug)]
pub struct DocumentTracker {
documents: StdMutex<HashMap<PathBuf, DocumentState>>,
path_locks: StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
generations: StdMutex<HashMap<ServerId, u64>>,
limits: ResourceLimits,
extension_map: HashMap<String, String>,
evicted: StdMutex<Vec<EvictedDocument>>,
}
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()),
generations: StdMutex::new(HashMap::new()),
limits,
extension_map,
evicted: StdMutex::new(Vec::new()),
}
}
pub fn take_evicted(&self) -> Vec<EvictedDocument> {
std::mem::take(&mut lock_std(&self.evicted))
}
#[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 line_text(&self, path: &Path, line: u32) -> Option<String> {
lock_std(&self.documents)
.get(path)?
.content
.lines()
.nth(line as usize)
.map(str::to_string)
}
#[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::new(uri.clone(), language_id, content);
let mut documents = lock_std(&self.documents);
if self.limits.max_documents > 0
&& documents.len() >= self.limits.max_documents
&& !documents.contains_key(&path)
{
let Some((evicted_path, evicted_state)) =
Self::evict_lru(&mut documents, &self.path_locks)
else {
return Err(Error::DocumentLimitExceeded {
current: documents.len(),
max: self.limits.max_documents,
});
};
lock_std(&self.evicted).push(EvictedDocument {
path: evicted_path,
uri: evicted_state.uri,
synced_servers: evicted_state.synced.into_keys().collect(),
});
}
documents.insert(path, state);
drop(documents);
Ok(uri)
}
fn evict_lru(
documents: &mut HashMap<PathBuf, DocumentState>,
path_locks: &StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
) -> Option<(PathBuf, DocumentState)> {
let locked = lock_std(path_locks)
.keys()
.cloned()
.collect::<std::collections::HashSet<_>>();
let lru_path = documents
.iter()
.filter(|(path, state)| !locked.contains(path.as_path()) && state.disk().is_some())
.min_by_key(|(_, state)| state.last_accessed)
.map(|(path, _)| path.clone())?;
documents.remove(&lru_path).map(|state| (lru_path, state))
}
pub async fn update(&self, path: &Path, content: String) -> Option<i32> {
let _path_guard = self.lock_path(path).await;
lock_std(&self.documents).get_mut(path).map(|state| {
state.touch();
state.apply_local_edit(content)
})
}
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.set_disk(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()
}
pub fn forget_server(&self, server: &ServerId) {
*lock_std(&self.generations)
.entry(server.clone())
.or_insert(0) += 1;
for state in lock_std(&self.documents).values_mut() {
state.forget_server(server);
}
}
fn generation(&self, server: &ServerId) -> u64 {
lock_std(&self.generations)
.get(server)
.copied()
.unwrap_or(0)
}
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 generation = self.generation(server);
let decision = self.disk_phase(path).await?;
self.sync_phase(path, server, lsp_client, decision, generation)
.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_mut(path).map(|st| {
st.touch();
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 open_checked(&self, path: &Path) -> Result<(fs::File, std::fs::Metadata)> {
#[cfg(unix)]
let opened = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
.await;
#[cfg(not(unix))]
let opened = fs::File::open(path).await;
let file = opened.map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
#[cfg(windows)]
check_disk_file_type(&file, path)?;
let meta = file.metadata().await.map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
if !meta.is_file() {
return Err(Error::NotARegularFile(path.to_path_buf()));
}
self.check_file_size(meta.len())?;
Ok((file, meta))
}
async fn read_string_bounded(
&self,
path: &Path,
mut file: fs::File,
size_hint: u64,
) -> Result<String> {
let max = self.limits.max_file_size;
let cap = bounded_read_cap(max);
let mut buf = Vec::with_capacity(usize::try_from(size_hint.min(cap)).unwrap_or(0));
let io_err = |e: std::io::Error| Error::FileIo {
path: path.to_path_buf(),
source: e,
};
(&mut file)
.take(cap)
.read_to_end(&mut buf)
.await
.map_err(io_err)?;
match check_bounded_utf8(buf, max) {
BoundedReadOutcome::Ok(s) => Ok(s),
BoundedReadOutcome::TooLarge { size } => {
Err(Error::FileSizeLimitExceeded { size, max })
}
BoundedReadOutcome::InvalidUtf8(e) => Err(io_err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e,
))),
}
}
async fn read_to_string_checked(
&self,
path: &Path,
) -> Result<(String, Option<SystemTime>, u64)> {
let (file, meta) = self.open_checked(path).await?;
let mtime = meta.modified().ok();
let size = meta.len();
let content = self.read_string_bounded(path, file, size).await?;
Ok((content, mtime, size))
}
pub(crate) async fn read_line_checked(
&self,
path: &Path,
line: u32,
budget: u64,
) -> Result<LineRead> {
let Ok((file, _meta)) = self.open_checked(path).await else {
return Ok(LineRead {
text: None,
bytes_read: OPEN_FAILURE_CHARGE_BYTES,
});
};
let max = self.limits.max_file_size;
let cap = bounded_read_cap(max).min(budget.saturating_add(1));
let mut reader = tokio::io::BufReader::new(file.take(cap));
let io_err = |e: std::io::Error| Error::FileIo {
path: path.to_path_buf(),
source: e,
};
let mut buf = Vec::new();
let mut bytes_read: u64 = 0;
let mut current_line = 0u32;
loop {
buf.clear();
let n = reader.read_until(b'\n', &mut buf).await.map_err(io_err)?;
bytes_read += n as u64;
if n == 0 {
return Ok(LineRead {
text: None,
bytes_read,
});
}
if current_line == line {
let truncated_by_cap = bytes_read >= cap && buf.last() != Some(&b'\n');
if truncated_by_cap {
return Ok(LineRead {
text: None,
bytes_read,
});
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
}
return Ok(LineRead {
text: String::from_utf8(buf).ok(),
bytes_read,
});
}
current_line += 1;
}
}
async fn sync_phase(
&self,
path: &Path,
server: &ServerId,
lsp_client: &LspClient,
decision: Decision,
generation: u64,
) -> 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_version(server))
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_typed::<DidOpenTextDocumentNotification>(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: language_id.into(),
version: target_version,
text,
},
})
.await
} else {
lsp_client
.notify_typed::<DidChangeTextDocumentNotification>(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
version: target_version,
text_document_identifier: lsp_types::TextDocumentIdentifier {
uri: uri.clone(),
},
},
content_changes: vec![
TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
lsp_types::TextDocumentContentChangeWholeDocument { text },
),
],
})
.await
};
if let Err(err) = notify_result {
let first_ever_sync = lock_std(&self.documents)
.get(path)
.is_some_and(DocumentState::has_never_synced);
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.commit_reload(target_version, fresh, snap);
}
if self.generation(server) == generation {
st.mark_synced(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,
}
}
}
pub fn path_to_uri(path: &Path) -> Result<Uri> {
try_path_to_uri(path)
.ok_or_else(|| Error::InvalidUri(format!("cannot convert path to URI: {}", path.display())))
}
#[must_use]
pub fn try_path_to_uri(path: &Path) -> Option<Uri> {
let uri_string = encode_rfc3986_path_chars(&file_url(path)?);
Some(Uri::from(uri_string))
}
#[cfg(not(windows))]
fn file_url(path: &Path) -> Option<Url> {
Url::from_file_path(path).ok()
}
#[cfg(windows)]
fn file_url(path: &Path) -> Option<Url> {
match Url::from_file_path(path) {
Ok(file_url) => Some(file_url),
Err(()) if path.has_root() => windows_rooted_path_to_file_url(path),
Err(()) => None,
}
}
#[cfg(windows)]
fn windows_rooted_path_to_file_url(path: &Path) -> Option<Url> {
let path_str = path.to_string_lossy();
let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
let mut file_url = Url::parse("file:///").ok()?;
file_url.path_segments_mut().ok()?.clear().extend(
stripped
.split(['\\', '/'])
.filter(|segment| !segment.is_empty()),
);
Some(file_url)
}
pub(super) fn encode_rfc3986_path_chars(url: &Url) -> String {
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_ref()).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");
}
#[tokio::test]
async 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())
.await;
assert_eq!(new_version, Some(2));
tracker.close(&path);
assert!(!tracker.is_open(&path));
assert!(tracker.is_empty());
}
#[test]
fn test_forget_server_clears_only_that_servers_synced_version() {
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let path = PathBuf::from("/test/file.rs");
tracker
.open(path.clone(), "fn main() {}".to_string())
.unwrap();
let respawned = ServerId::from("rust-respawned");
let untouched = ServerId::from("rust-diagnostics");
lock_std(&tracker.documents)
.get_mut(&path)
.unwrap()
.synced
.insert(respawned.clone(), 1);
lock_std(&tracker.documents)
.get_mut(&path)
.unwrap()
.synced
.insert(untouched.clone(), 1);
tracker.forget_server(&respawned);
let state = tracker.get(&path).unwrap();
assert!(state.synced_version(&respawned).is_none());
assert!(state.synced_version(&untouched).is_some());
}
#[tokio::test]
async fn test_sync_phase_skips_commit_when_generation_is_stale() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("race.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let server = ServerId::from("rust");
let generation_before_respawn = 0;
tracker.forget_server(&server);
let (stale_client, _guard) = fake_lsp_client();
let decision = tracker.disk_phase(&path).await.unwrap();
tracker
.sync_phase(
&path,
&server,
&stale_client,
decision,
generation_before_respawn,
)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert!(
state.synced_version(&server).is_none(),
"a sync_phase call that captured a stale generation must not \
commit `synced`, even though its notify against the \
superseded connection succeeded"
);
}
#[tokio::test]
async fn test_ensure_open_commits_when_generation_is_current() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("no_race.rs");
std::fs::write(&path, "fn main() {}").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let server = ServerId::from("rust");
let (client, _guard) = fake_lsp_client();
tracker.ensure_open(&path, &server, &client).await.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.synced_version(&server), Some(1));
}
fn mark_disk_verified(tracker: &DocumentTracker, path: &Path) {
tracker.set_disk(
path,
DiskSync {
mtime: None,
size: 0,
mtime_settled: false,
content_checked_at: Instant::now(),
},
);
}
#[test]
fn test_document_limit_evicts_lru_instead_of_failing() {
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();
mark_disk_verified(&tracker, Path::new("/test/file1.rs"));
tracker
.open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
.unwrap();
mark_disk_verified(&tracker, Path::new("/test/file2.rs"));
tracker
.open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string())
.unwrap();
assert_eq!(tracker.len(), 2);
assert!(!tracker.is_open(Path::new("/test/file1.rs")));
assert!(tracker.is_open(Path::new("/test/file2.rs")));
assert!(tracker.is_open(Path::new("/test/file3.rs")));
let evicted = tracker.take_evicted();
assert_eq!(evicted.len(), 1);
assert_eq!(evicted[0].path, PathBuf::from("/test/file1.rs"));
assert!(
evicted[0].synced_servers.is_empty(),
"opened directly via `open`, never synced to any server"
);
}
#[test]
fn test_document_limit_falls_back_to_error_when_only_candidate_is_locked() {
let limits = ResourceLimits {
max_documents: 1,
max_file_size: 100,
};
let tracker = DocumentTracker::new(limits, HashMap::new());
let locked_path = PathBuf::from("/test/locked.rs");
tracker
.open(locked_path.clone(), "fn locked() {}".to_string())
.unwrap();
lock_std(&tracker.path_locks).insert(locked_path.clone(), Arc::new(AsyncMutex::new(())));
let result = tracker.open(PathBuf::from("/test/other.rs"), "fn other() {}".to_string());
assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
assert!(
tracker.is_open(&locked_path),
"the locked document must not be evicted"
);
assert!(tracker.take_evicted().is_empty());
}
#[tokio::test]
async fn test_evict_lru_skips_document_with_diverged_unsaved_content() {
let dir = TempDir::new().unwrap();
let path_a = dir.path().join("a.rs");
std::fs::write(&path_a, "AAAA").unwrap();
set_mtime(&path_a, 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());
let server_id = ServerId::from("rust");
tracker
.ensure_open(&path_a, &server_id, &client)
.await
.unwrap();
tracker
.update(&path_a, "AAAA-edited".to_string())
.await
.unwrap();
let path_b = dir.path().join("b.rs");
std::fs::write(&path_b, "BBBB").unwrap();
let result = tracker.open(path_b, "BBBB".to_string());
assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
assert!(
tracker.is_open(&path_a),
"the diverged, not-disk-verified document must not be evicted"
);
assert_eq!(tracker.get(&path_a).unwrap().content(), "AAAA-edited");
assert!(tracker.take_evicted().is_empty());
}
#[tokio::test]
async fn test_ensure_open_touch_changes_lru_eviction_order() {
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_a, "AAAA").unwrap();
std::fs::write(&path_b, "BBBB").unwrap();
set_mtime(&path_a, settled_past());
set_mtime(&path_b, settled_past());
let limits = ResourceLimits {
max_documents: 2,
max_file_size: 0,
};
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(limits, HashMap::new());
let server_id = ServerId::from("rust");
tracker
.ensure_open(&path_a, &server_id, &client)
.await
.unwrap();
tracker
.ensure_open(&path_b, &server_id, &client)
.await
.unwrap();
tracker
.ensure_open(&path_a, &server_id, &client)
.await
.unwrap();
let path_c = dir.path().join("c.rs");
std::fs::write(&path_c, "CCCC").unwrap();
set_mtime(&path_c, settled_past());
tracker
.ensure_open(&path_c, &server_id, &client)
.await
.unwrap();
assert!(
tracker.is_open(&path_a),
"recently re-accessed, must survive"
);
assert!(
!tracker.is_open(&path_b),
"least-recently-used, must be evicted"
);
assert!(tracker.is_open(&path_c));
let evicted = tracker.take_evicted();
assert_eq!(evicted.len(), 1);
assert_eq!(evicted[0].path, path_b);
assert_eq!(evicted[0].synced_servers, vec![server_id]);
}
#[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: Uri::from("file:///test.rs"),
language_id: "rust".to_string(),
version: 5,
content: "fn main() {}".to_string(),
disk: None,
synced: HashMap::new(),
last_accessed: Instant::now(),
};
#[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());
}
#[tokio::test]
async 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()).await;
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"
);
}
#[tokio::test]
async 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()).await;
assert_eq!(tracker.get(&path).unwrap().version(), 2);
tracker.update(&path, "v3".to_string()).await;
assert_eq!(tracker.get(&path).unwrap().version(), 3);
tracker.update(&path, "v4".to_string()).await;
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).unwrap();
assert!(
uri.as_ref()
.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).unwrap();
assert!(uri.as_ref().starts_with("file://"));
assert!(uri.as_ref().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).unwrap();
#[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_ref(), expected);
assert_eq!(
uri_to_path(&uri).as_deref(),
Some(path),
"encoded file URI should round-trip to the original path"
);
}
#[test]
fn test_try_path_to_uri_returns_none_for_relative_path() {
assert_eq!(try_path_to_uri(Path::new("relative/file.ts")), None);
}
#[test]
fn test_path_to_uri_returns_err_for_relative_path() {
let err = path_to_uri(Path::new("relative/file.ts")).unwrap_err();
assert!(matches!(err, Error::InvalidUri(_)));
}
#[cfg(windows)]
#[test]
fn test_try_path_to_uri_encodes_synthetic_windows_root() {
let uri = try_path_to_uri(Path::new("/home/user/#work %23")).unwrap();
assert_eq!(uri.as_ref(), "file:///home/user/%23work%20%2523");
}
#[cfg(windows)]
#[test]
fn test_try_path_to_uri_accepts_rooted_but_not_absolute_windows_path() {
let path = Path::new(r"\foo");
assert!(path.has_root());
assert!(!path.is_absolute());
let uri = try_path_to_uri(path).unwrap();
assert_eq!(uri.as_ref(), "file:///foo");
}
#[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).unwrap();
assert!(
uri.as_ref().ends_with("%5Ba%5D.ts"),
"short path should percent-encode reserved chars, got {}",
uri.as_ref()
);
assert_eq!(uri_to_path(&uri).as_deref(), Some(path));
}
#[test]
fn test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars() {
#[cfg(windows)]
let path = Path::new(r"C:\home\user\test[]^|{}`.ts");
#[cfg(not(windows))]
let path = Path::new("/home/user/test[]^|{}`.ts");
let uri = try_path_to_uri(path).unwrap();
let uri_str = uri.as_ref();
for (raw, encoded) in [
('[', "%5B"),
(']', "%5D"),
('^', "%5E"),
('|', "%7C"),
('{', "%7B"),
('}', "%7D"),
('`', "%60"),
] {
assert!(
uri_str.contains(encoded),
"expected {raw:?} to be percent-encoded as {encoded} in {uri_str}"
);
}
assert!(
!uri_str.contains(['[', ']', '^', '|', '{', '}', '`']),
"no raw reserved characters should remain in {uri_str}"
);
}
#[tokio::test]
async 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()).await;
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 {
let path = PathBuf::from(format!("/test/file{i}.rs"));
tracker.open(path.clone(), "content".to_string()).unwrap();
mark_disk_verified(&tracker, &path);
}
assert_eq!(tracker.len(), 5);
tracker
.open(PathBuf::from("/test/file6.rs"), "content".to_string())
.unwrap();
assert_eq!(tracker.len(), 5);
assert!(!tracker.is_open(Path::new("/test/file0.rs")));
assert!(tracker.is_open(Path::new("/test/file6.rs")));
}
#[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 = Uri::from("file:///home/user/main.rs");
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 = Uri::from("https://example.com/file.rs");
assert!(uri_to_path(&uri).is_none());
}
#[test]
fn test_uri_to_path_lsp_diagnostics_scheme_returns_none() {
let uri: Uri = Uri::from("lsp-diagnostics:///home/user/main.rs");
assert!(uri_to_path(&uri).is_none());
}
#[test]
fn test_uri_to_path_with_authority_returns_none() {
let result = uri_to_path(&Uri::from("file://server/share/path.rs"));
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 tempfile::TempDir;
use tokio::io::BufReader;
use crate::test_lsp::{fake_lsp_client, read_framed_message};
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)
}
#[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())
.await;
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_version(&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_version(&id_a), Some(1));
assert_eq!(tracker.get(&path).unwrap().synced_version(&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_version(&id_a), Some(2));
assert_eq!(tracker.get(&path).unwrap().synced_version(&id_b), Some(1));
}
#[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_a, "fn a() {}").unwrap();
std::fs::write(&path_b, "fn b() {}").unwrap();
set_mtime(&path_a, settled_past());
set_mtime(&path_b, settled_past());
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 path_a_guard = tracker.lock_path(&path_a).await;
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();
drop(path_a_guard);
handle_a.await.unwrap().unwrap();
assert_eq!(tracker.get(&path_a).unwrap().content(), "fn a() {}");
}
#[tokio::test]
async fn test_update_serializes_with_concurrent_ensure_open_same_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn a() {}").unwrap();
set_mtime(&path, settled_past());
let (client, _server) = fake_lsp_client();
let tracker = Arc::new(DocumentTracker::new(
ResourceLimits::default(),
HashMap::new(),
));
let path_guard = tracker.lock_path(&path).await;
let tracker_for_open = Arc::clone(&tracker);
let path_for_task = path.clone();
let handle_open = tokio::spawn(async move {
tracker_for_open
.ensure_open(&path_for_task, &ServerId::from("rust"), &client)
.await
});
tokio::time::sleep(Duration::from_millis(200)).await;
let update_while_blocked = tokio::time::timeout(
Duration::from_millis(300),
tracker.update(&path, "raced content".to_string()),
)
.await;
assert!(
update_while_blocked.is_err(),
"update() must block while ensure_open holds the per-path lock for the same path"
);
drop(path_guard);
handle_open.await.unwrap().unwrap();
assert_eq!(tracker.get(&path).unwrap().content(), "fn a() {}");
assert_eq!(tracker.get(&path).unwrap().version(), 1);
let new_version = tracker
.update(&path, "fn a() { updated(); }".to_string())
.await;
assert_eq!(new_version, Some(2));
assert_eq!(
tracker.get(&path).unwrap().content(),
"fn a() { updated(); }"
);
}
#[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_version(&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"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_read_to_string_checked_rejects_fifo() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("fifo");
let status = std::process::Command::new("mkfifo")
.arg(&path)
.status()
.unwrap();
assert!(status.success(), "mkfifo must succeed to set up this test");
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let result = tokio::time::timeout(
Duration::from_secs(5),
tracker.read_to_string_checked(&path),
)
.await
.unwrap();
assert!(matches!(result, Err(Error::NotARegularFile(_))));
}
#[cfg(windows)]
#[tokio::test]
async fn test_check_disk_file_type_accepts_regular_rejects_nul() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("regular.txt");
std::fs::write(&path, "hello").unwrap();
let regular = fs::File::open(&path).await.unwrap();
assert!(check_disk_file_type(®ular, &path).is_ok());
let nul_path = PathBuf::from("NUL");
let nul = fs::File::open(&nul_path).await.unwrap();
assert!(matches!(
check_disk_file_type(&nul, &nul_path),
Err(Error::NotARegularFile(_))
));
}
#[cfg(windows)]
#[tokio::test]
async fn test_read_to_string_checked_rejects_nul_device() {
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let path = PathBuf::from("NUL");
let result = tokio::time::timeout(
Duration::from_secs(5),
tracker.read_to_string_checked(&path),
)
.await
.unwrap();
assert!(matches!(result, Err(Error::NotARegularFile(_))));
}
#[tokio::test]
async fn test_read_to_string_checked_size_boundary() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("boundary.rs");
let tracker = DocumentTracker::new(
ResourceLimits {
max_documents: 100,
max_file_size: 10,
},
HashMap::new(),
);
std::fs::write(&path, "a".repeat(10)).unwrap();
let (content, ..) = tracker.read_to_string_checked(&path).await.unwrap();
assert_eq!(content.len(), 10);
std::fs::write(&path, "a".repeat(11)).unwrap();
let result = tracker.read_to_string_checked(&path).await;
assert!(matches!(
result,
Err(Error::FileSizeLimitExceeded { size: 11, max: 10 })
));
}
#[tokio::test]
async fn test_read_line_checked_does_not_read_past_target_line() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("partial.rs");
let mut content = b"hello\n".to_vec();
content.extend_from_slice(&[0xFF, 0xFE]);
content.push(b'\n');
std::fs::write(&path, &content).unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let line = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
assert_eq!(line.text.as_deref(), Some("hello"));
}
#[tokio::test]
async fn test_read_line_checked_returns_requested_non_zero_line() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("multi.rs");
std::fs::write(&path, "first\nsecond\nthird\nfourth\n").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
assert_eq!(
tracker
.read_line_checked(&path, 2, u64::MAX)
.await
.unwrap()
.text
.as_deref(),
Some("third")
);
}
#[tokio::test]
async fn test_read_line_checked_returns_none_past_last_line() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("short.rs");
std::fs::write(&path, "only one line").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
assert_eq!(
tracker
.read_line_checked(&path, 5, u64::MAX)
.await
.unwrap()
.text,
None
);
}
#[tokio::test]
async fn test_read_line_checked_reads_last_line_without_trailing_newline() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("no_newline.rs");
std::fs::write(&path, "only one line").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
assert_eq!(
tracker
.read_line_checked(&path, 0, u64::MAX)
.await
.unwrap()
.text
.as_deref(),
Some("only one line")
);
}
#[tokio::test]
async fn test_read_line_checked_empty_file_returns_none() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("empty.rs");
std::fs::write(&path, "").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
assert_eq!(
tracker
.read_line_checked(&path, 0, u64::MAX)
.await
.unwrap()
.text,
None
);
}
#[tokio::test]
async fn test_read_line_checked_strips_crlf_line_ending() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("crlf.rs");
std::fs::write(&path, "first\r\nsecond\r\n").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
assert_eq!(
tracker
.read_line_checked(&path, 0, u64::MAX)
.await
.unwrap()
.text
.as_deref(),
Some("first")
);
assert_eq!(
tracker
.read_line_checked(&path, 1, u64::MAX)
.await
.unwrap()
.text
.as_deref(),
Some("second")
);
}
#[tokio::test]
async fn test_read_line_checked_matches_str_lines_crlf_semantics() {
let dir = TempDir::new().unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let double_cr = "abc\r\r\n";
let path_a = dir.path().join("double_cr.rs");
std::fs::write(&path_a, double_cr).unwrap();
assert_eq!(
tracker
.read_line_checked(&path_a, 0, u64::MAX)
.await
.unwrap()
.text
.as_deref(),
double_cr.lines().next()
);
let trailing_cr_no_newline = "abc\r";
let path_b = dir.path().join("trailing_cr_no_newline.rs");
std::fs::write(&path_b, trailing_cr_no_newline).unwrap();
assert_eq!(
tracker
.read_line_checked(&path_b, 0, u64::MAX)
.await
.unwrap()
.text
.as_deref(),
trailing_cr_no_newline.lines().next()
);
}
#[tokio::test]
async fn test_read_line_checked_exact_max_file_size_reads_to_eof_without_error() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("exact.rs");
let content = "a".repeat(20);
std::fs::write(&path, &content).unwrap();
let limits = ResourceLimits {
max_documents: 100,
max_file_size: 20,
};
let tracker = DocumentTracker::new(limits, HashMap::new());
assert_eq!(
tracker
.read_line_checked(&path, 0, u64::MAX)
.await
.unwrap()
.text
.as_deref(),
Some(content.as_str())
);
assert_eq!(
tracker
.read_line_checked(&path, 1, u64::MAX)
.await
.unwrap()
.text,
None,
"a line past an exact-max_file_size file's only line must read to EOF cleanly, not \
be misreported as truncated"
);
}
#[tokio::test]
async fn test_read_line_checked_bounds_read_by_budget_not_just_max_file_size() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("budget.rs");
std::fs::write(&path, "a".repeat(1000)).unwrap();
let limits = ResourceLimits {
max_documents: 100,
max_file_size: 1000,
};
let tracker = DocumentTracker::new(limits, HashMap::new());
let read = tracker.read_line_checked(&path, 0, 10).await.unwrap();
assert_eq!(
read.text, None,
"a single line far longer than the budget must not be returned as if complete"
);
assert_eq!(
read.bytes_read, 11,
"the read must stop at exactly the budget's +1 slack (see the correctness-gate fix \
below), not at max_file_size"
);
}
#[tokio::test]
async fn test_read_line_checked_exact_budget_match_on_unterminated_line_not_truncated() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("exact_budget.rs");
let content = "twelve chars";
std::fs::write(&path, content).unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let read = tracker
.read_line_checked(&path, 0, content.len() as u64)
.await
.unwrap();
assert_eq!(
read.text.as_deref(),
Some(content),
"budget exactly matching the line's byte length must not be misreported as truncated"
);
assert_eq!(read.bytes_read, content.len() as u64);
}
#[tokio::test]
async fn test_read_line_checked_reports_bytes_read_for_invalid_utf8_line() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("invalid_utf8.rs");
let mut content = vec![0xFFu8, 0xFE, 0xFD];
content.push(b'\n');
std::fs::write(&path, &content).unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let read = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
assert_eq!(read.text, None);
assert_eq!(
read.bytes_read,
content.len() as u64,
"bytes scanned must be reported even though the line wasn't valid UTF-8"
);
}
#[tokio::test]
async fn test_read_line_checked_charges_nominal_amount_for_nonexistent_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("does_not_exist.rs");
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let read = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
assert_eq!(read.text, None);
assert_eq!(read.bytes_read, OPEN_FAILURE_CHARGE_BYTES);
}
}