use std::collections::{HashMap, HashSet};
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime};
use lsp_types::{
DidChangeTextDocumentNotification, DidChangeTextDocumentParams,
DidCloseTextDocumentNotification, DidCloseTextDocumentParams, DidOpenTextDocumentNotification,
DidOpenTextDocumentParams, TextDocumentContentChangeEvent, TextDocumentItem, Uri,
VersionedTextDocumentIdentifier,
};
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::io::{AsyncBufReadExt, AsyncReadExt};
use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
use tokio::time::Instant;
use url::Url;
use super::notifications::DocumentStamp;
use super::{WorkspacePath, WorkspaceRoots};
use crate::config::{DocumentLimit, LanguageId, LanguageMap, ServerId, SizeLimit};
use crate::error::{BackgroundTask, Error, Result};
use crate::lsp::LspClient;
use crate::util::{BoundedUtf8Error, RegularFile, SizeExceeded, check_bounded_utf8, lock_std};
const DISK_CHECK_DEBOUNCE: Duration = Duration::from_millis(250);
const MTIME_GRANULARITY: Duration = Duration::from_secs(2);
const INLINE_INDEX_MAX_BYTES: usize = 1024 * 1024;
const LINE_CHECKPOINT_STRIDE: usize = 64;
const fn is_line_terminator(byte: u8) -> bool {
matches!(byte, b'\n' | b'\r')
}
fn line_bounds(content: &str, start: usize) -> (usize, Option<usize>) {
let bytes = content.as_bytes();
let Some(offset) = bytes
.get(start..)
.and_then(|rest| rest.iter().position(|&b| is_line_terminator(b)))
else {
return (content.len(), None);
};
let end = start.saturating_add(offset);
let next = end.saturating_add(1);
let crlf = bytes.get(end) == Some(&b'\r') && bytes.get(next) == Some(&b'\n');
(end, Some(next.saturating_add(usize::from(crlf))))
}
#[derive(Clone)]
pub(super) struct DocumentText {
content: String,
digest: ContentDigest,
stride: usize,
checkpoints: Box<[usize]>,
line_count: NonZeroU32,
}
impl std::fmt::Debug for DocumentText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DocumentText")
.field("len", &self.content.len())
.field("checkpoints", &self.checkpoints.len())
.finish_non_exhaustive()
}
}
impl DocumentText {
pub(super) fn new(content: String) -> Self {
Self::with_stride(content, LINE_CHECKPOINT_STRIDE)
}
fn with_stride(content: String, stride: usize) -> Self {
let stride = stride.max(1);
let mut checkpoints = vec![0];
let mut start = 0;
let mut line = 0usize;
while let (_, Some(next)) = line_bounds(&content, start) {
start = next;
line = line.saturating_add(1);
if line.is_multiple_of(stride) {
checkpoints.push(start);
}
}
let line_count = u32::try_from(line)
.ok()
.and_then(|last| last.checked_add(1))
.and_then(NonZeroU32::new)
.unwrap_or(NonZeroU32::MAX);
Self {
digest: ContentDigest::of(&content),
content,
stride,
checkpoints: checkpoints.into_boxed_slice(),
line_count,
}
}
pub(super) const fn digest(&self) -> ContentDigest {
self.digest
}
async fn build(content: String) -> Result<Self> {
if content.len() <= INLINE_INDEX_MAX_BYTES {
return Ok(Self::new(content));
}
tokio::task::spawn_blocking(move || Self::new(content))
.await
.map_err(|e| Error::Io(std::io::Error::other(e)))
}
pub(super) fn as_str(&self) -> &str {
&self.content
}
pub(super) const fn last_line(&self) -> NonZeroU32 {
self.line_count
}
pub(super) fn line(&self, n: u32) -> Option<&str> {
let start = self.line_start(n)?;
let (end, _) = line_bounds(&self.content, start);
Some(&self.content[start..end])
}
fn line_start(&self, n: u32) -> Option<usize> {
let n = n as usize;
let mut start = *self.checkpoints.get(n.checked_div(self.stride)?)?;
for _ in 0..n.checked_rem(self.stride)? {
start = line_bounds(&self.content, start).1?;
}
Some(start)
}
pub(super) fn lines_from(&self, n: u32) -> impl Iterator<Item = &str> {
let mut next = self.line_start(n);
std::iter::from_fn(move || {
let start = next?;
let (end, after) = line_bounds(&self.content, start);
next = after;
self.content.get(start..end)
})
}
}
impl PartialEq for DocumentText {
fn eq(&self, other: &Self) -> bool {
self.content == other.content
}
}
impl Eq for DocumentText {}
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, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DocumentVersion(i32);
impl DocumentVersion {
pub const FIRST: Self = Self(1);
#[must_use]
pub const fn new(version: i32) -> Self {
Self(version)
}
#[must_use]
pub const fn get(self) -> i32 {
self.0
}
#[must_use]
pub(crate) const fn next(self) -> Self {
Self(self.0.saturating_add(1))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContentDigest(u64);
impl ContentDigest {
pub(crate) fn of(text: &str) -> Self {
use std::hash::{DefaultHasher, Hash as _, Hasher as _};
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
Self(hasher.finish())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SweepState {
Current {
stamp: DocumentStamp,
digest: ContentDigest,
},
Lagging {
stamp: DocumentStamp,
},
}
impl SweepState {
pub(crate) const fn stamp(self) -> DocumentStamp {
match self {
Self::Current { stamp, .. } | Self::Lagging { stamp } => stamp,
}
}
pub(crate) fn same_text(self, other: Self) -> bool {
matches!(
(self, other),
(Self::Current { digest: a, .. }, Self::Current { digest: b, .. }) if a == b
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Opening(u64);
#[derive(Debug, Clone)]
pub(super) struct DocumentState {
uri: Uri,
language_id: LanguageId,
version: DocumentVersion,
text: DocumentText,
disk: Option<DiskSync>,
synced: HashMap<ServerId, DocumentVersion>,
opened: Opening,
last_accessed: Instant,
}
impl PartialEq for DocumentState {
fn eq(&self, other: &Self) -> bool {
let Self {
uri,
language_id,
version,
text,
disk,
synced,
opened: _,
last_accessed: _,
} = self;
*uri == other.uri
&& *language_id == other.language_id
&& *version == other.version
&& *text == other.text
&& *disk == other.disk
&& *synced == other.synced
}
}
impl Eq for DocumentState {}
impl DocumentState {
fn new(uri: Uri, language_id: LanguageId, text: DocumentText, opened: Opening) -> Self {
Self {
uri,
language_id,
version: DocumentVersion::FIRST,
text,
disk: None,
synced: HashMap::new(),
opened,
last_accessed: Instant::now(),
}
}
fn touch(&mut self) {
self.last_accessed = Instant::now();
}
#[must_use]
#[cfg(test)]
pub(crate) const fn uri(&self) -> &Uri {
&self.uri
}
#[must_use]
#[cfg(test)]
pub(crate) const fn language_id(&self) -> &LanguageId {
&self.language_id
}
#[must_use]
#[cfg(test)]
pub(crate) const fn version(&self) -> DocumentVersion {
self.version
}
#[must_use]
#[cfg(test)]
pub(crate) fn content(&self) -> &str {
self.text.as_str()
}
const fn disk(&self) -> Option<DiskSync> {
self.disk
}
#[must_use]
pub(crate) fn synced_version(&self, server: &ServerId) -> Option<DocumentVersion> {
self.synced.get(server).copied()
}
fn has_never_synced(&self) -> bool {
self.synced.is_empty()
}
fn commit_reload(
&mut self,
version: DocumentVersion,
text: DocumentText,
snap: Option<DiskSync>,
) {
debug_assert!(
version >= self.version,
"document version must be monotonically increasing"
);
self.version = version;
self.text = text;
self.disk = snap;
}
const fn set_disk(&mut self, snap: DiskSync) {
self.disk = Some(snap);
}
fn mark_synced(&mut self, server: ServerId, version: DocumentVersion) {
self.synced.insert(server, version);
}
fn forget_server(&mut self, server: &ServerId) {
self.synced.remove(server);
}
}
struct OpenedDocument {
uri: Uri,
language_id: LanguageId,
text: String,
version: DocumentVersion,
}
#[derive(Debug, Clone, Copy)]
pub struct ResourceLimits {
pub max_documents: DocumentLimit,
pub max_file_size: SizeLimit,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
}
}
}
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)]
struct PendingClose {
uri: Uri,
servers: HashSet<ServerId>,
}
#[derive(Debug)]
#[must_use = "dropping the claim releases the path lock; send the closes first"]
pub struct PendingCloseClaim<'a> {
pub(crate) path: PathBuf,
pub(crate) uri: Uri,
servers: Vec<ServerId>,
tracker: &'a DocumentTracker,
unsettled: Vec<(ServerId, SyncGeneration)>,
_path_guard: PathLockGuard<'a>,
}
impl PendingCloseClaim<'_> {
pub(crate) fn servers(&self) -> impl Iterator<Item = &ServerId> {
self.servers.iter()
}
pub(crate) fn settle(&mut self, server: &ServerId) {
self.unsettled.retain(|(unsettled, _)| unsettled != server);
}
}
impl Drop for PendingCloseClaim<'_> {
fn drop(&mut self) {
for (server, generation) in self.unsettled.drain(..) {
self.tracker
.restore_pending_close(&self.path, &self.uri, &server, generation);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct SyncGeneration(u64);
impl SyncGeneration {
const fn next(self) -> Self {
Self(self.0.saturating_add(1))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinePresence {
Untracked,
Present,
Beyond {
last_line: NonZeroU32,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentOpenState {
Open,
NotOpen,
}
#[derive(Debug)]
pub struct DocumentTracker {
documents: StdMutex<HashMap<PathBuf, DocumentState>>,
path_locks: StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
in_flight: InFlightMap,
generations: StdMutex<HashMap<ServerId, SyncGeneration>>,
limits: ResourceLimits,
language_map: LanguageMap,
pending_closes: StdMutex<HashMap<PathBuf, PendingClose>>,
next_opening: AtomicU64,
}
impl DocumentTracker {
#[must_use]
pub fn new(limits: ResourceLimits, language_map: impl Into<LanguageMap>) -> Self {
Self {
documents: StdMutex::new(HashMap::new()),
path_locks: StdMutex::new(HashMap::new()),
in_flight: Arc::default(),
generations: StdMutex::new(HashMap::new()),
limits,
language_map: language_map.into(),
pending_closes: StdMutex::new(HashMap::new()),
next_opening: AtomicU64::new(0),
}
}
#[must_use]
pub(crate) const fn limits(&self) -> ResourceLimits {
self.limits
}
pub(crate) fn mark_in_flight(&self, path: &Path) -> InFlightGuard {
let mut in_flight = lock_std(&self.in_flight);
let count = in_flight.entry(path.to_path_buf()).or_insert(0);
*count = count.saturating_add(1);
drop(in_flight);
InFlightGuard {
in_flight: Arc::clone(&self.in_flight),
path: path.to_path_buf(),
}
}
#[cfg(test)]
pub(crate) fn in_flight_count(&self, path: &Path) -> usize {
lock_std(&self.in_flight).get(path).copied().unwrap_or(0)
}
#[must_use]
pub(crate) fn synced_version(&self, path: &Path, server: &ServerId) -> Option<DocumentVersion> {
lock_std(&self.documents).get(path)?.synced_version(server)
}
#[must_use]
pub(crate) fn stamp(&self, path: &Path, server: &ServerId) -> Option<DocumentStamp> {
let documents = lock_std(&self.documents);
let stamp = documents.get(path).and_then(|state| {
Some(DocumentStamp::new(
Some(state.opened),
state.synced_version(server)?,
))
});
drop(documents);
stamp
}
#[must_use]
pub(crate) fn sweep_state(&self, path: &Path, server: &ServerId) -> Option<SweepState> {
let documents = lock_std(&self.documents);
let sweep_state = documents.get(path).and_then(|state| {
let synced = state.synced_version(server)?;
let stamp = DocumentStamp::new(Some(state.opened), synced);
Some(if synced == state.version {
SweepState::Current {
stamp,
digest: state.text.digest(),
}
} else {
SweepState::Lagging { stamp }
})
});
drop(documents);
sweep_state
}
#[must_use]
pub(crate) fn opening(&self, path: &Path) -> Option<Opening> {
lock_std(&self.documents)
.get(path)
.map(|state| state.opened)
}
#[cfg(test)]
pub(crate) fn set_synced_version_for_test(&self, path: &Path, server: &ServerId, version: i32) {
if let Some(state) = lock_std(&self.documents).get_mut(path) {
state.mark_synced(server.clone(), DocumentVersion::new(version));
}
}
#[must_use]
pub fn open_state(&self, path: &Path) -> DocumentOpenState {
if lock_std(&self.documents).contains_key(path) {
DocumentOpenState::Open
} else {
DocumentOpenState::NotOpen
}
}
#[must_use]
#[cfg(test)]
pub(super) 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> {
let documents = lock_std(&self.documents);
documents.get(path)?.text.line(line).map(str::to_string)
}
#[must_use]
pub(crate) fn text(&self, path: &Path) -> Option<String> {
lock_std(&self.documents)
.get(path)
.map(|state| state.text.as_str().to_owned())
}
#[must_use]
pub(crate) fn line_presence(&self, path: &Path, line: u32) -> LinePresence {
match lock_std(&self.documents).get(path) {
None => LinePresence::Untracked,
Some(document) if document.text.line(line).is_some() => LinePresence::Present,
Some(document) => LinePresence::Beyond {
last_line: document.text.last_line(),
},
}
}
#[must_use]
pub(crate) fn line_window(
&self,
path: &Path,
start_line: u32,
max_lines: usize,
) -> Option<String> {
let documents = lock_std(&self.documents);
let mut lines = documents
.get(path)?
.text
.lines_from(start_line)
.take(max_lines);
let mut window = lines.next()?.to_string();
for line in lines {
window.push('\n');
window.push_str(line);
}
drop(documents);
Some(window)
}
#[must_use]
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
lock_std(&self.documents).len()
}
#[must_use]
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
lock_std(&self.documents).is_empty()
}
#[cfg(test)]
pub(crate) fn open(&self, path: PathBuf, content: String) -> Result<Uri> {
self.open_text(path, DocumentText::new(content))
}
fn open_text(&self, path: PathBuf, text: DocumentText) -> Result<Uri> {
self.check_file_size(text.as_str().len() as u64)?;
let uri = path_to_uri(&path)?;
let language_id = self.language_map.detect(&path);
let opened = Opening(self.next_opening.fetch_add(1, Ordering::Relaxed));
let state = DocumentState::new(uri.clone(), language_id, text, opened);
let mut documents = lock_std(&self.documents);
if let Some(max) = self.limits.max_documents.get()
&& documents.len() >= max.get()
&& !documents.contains_key(&path)
{
let Some((evicted_path, evicted_state)) =
Self::evict_lru(&mut documents, &self.path_locks, &self.in_flight)
else {
return Err(Error::DocumentLimitExceeded {
current: documents.len(),
max,
});
};
self.record_pending_close(evicted_path, evicted_state);
}
documents.insert(path, state);
drop(documents);
Ok(uri)
}
fn record_pending_close(&self, path: PathBuf, evicted: DocumentState) {
if evicted.synced.is_empty() {
return;
}
lock_std(&self.pending_closes)
.entry(path)
.or_insert_with(|| PendingClose {
uri: evicted.uri,
servers: HashSet::new(),
})
.servers
.extend(evicted.synced.into_keys());
}
fn evict_lru(
documents: &mut HashMap<PathBuf, DocumentState>,
path_locks: &StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
in_flight: &InFlightMap,
) -> Option<(PathBuf, DocumentState)> {
let mut busy = lock_std(path_locks)
.keys()
.cloned()
.collect::<std::collections::HashSet<_>>();
busy.extend(lock_std(in_flight).keys().cloned());
let lru_path = documents
.iter()
.filter(|(path, state)| !busy.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))
}
const fn check_file_size(&self, size: u64) -> Result<()> {
match SizeExceeded::check_limit(size, self.limits.max_file_size) {
Ok(()) => Ok(()),
Err(exceeded) => Err(Error::FileSizeLimitExceeded(exceeded)),
}
}
fn set_disk(&self, path: &Path, snap: DiskSync) {
if let Some(st) = lock_std(&self.documents).get_mut(path) {
st.set_disk(snap);
}
}
#[cfg(test)]
pub(super) fn close(&self, path: &Path) -> Option<DocumentState> {
lock_std(&self.documents).remove(path)
}
pub fn open_paths(&self) -> Vec<PathBuf> {
lock_std(&self.documents).keys().cloned().collect()
}
pub(crate) fn open_paths_by_recency(&self) -> Vec<PathBuf> {
let mut paths: Vec<(PathBuf, Instant)> = lock_std(&self.documents)
.iter()
.map(|(path, state)| (path.clone(), state.last_accessed))
.collect();
paths.sort_unstable_by(|(_, a), (_, b)| b.cmp(a));
paths.into_iter().map(|(path, _)| path).collect()
}
pub fn forget_server(&self, server: &ServerId) {
let mut generations = lock_std(&self.generations);
let generation = generations.entry(server.clone()).or_default();
*generation = generation.next();
drop(generations);
for state in lock_std(&self.documents).values_mut() {
state.forget_server(server);
}
lock_std(&self.pending_closes).retain(|_, pending| {
pending.servers.remove(server);
!pending.servers.is_empty()
});
}
pub(crate) fn synced_document_count(&self, server: &ServerId) -> usize {
lock_std(&self.documents)
.values()
.filter(|state| state.synced_version(server).is_some())
.count()
}
pub(crate) fn sync_history_forgotten(&self, server: &ServerId) -> bool {
self.generation(server) != SyncGeneration::default()
}
fn generation(&self, server: &ServerId) -> SyncGeneration {
lock_std(&self.generations)
.get(server)
.copied()
.unwrap_or_default()
}
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),
}
}
fn try_lock_path(&self, path: &Path) -> Option<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()
};
if let Ok(guard) = Arc::clone(&arc).try_lock_owned() {
return Some(PathLockGuard {
path_locks: &self.path_locks,
path: path.to_path_buf(),
arc,
guard: Some(guard),
});
}
let mut locks = lock_std(&self.path_locks);
if Arc::strong_count(&arc) <= 2 {
locks.remove(path);
}
None
}
pub(crate) fn pending_close_paths(&self) -> Vec<PathBuf> {
lock_std(&self.pending_closes).keys().cloned().collect()
}
pub(crate) fn try_claim_pending_close(&self, path: &Path) -> Option<PendingCloseClaim<'_>> {
let path_guard = self.try_lock_path(path)?;
let synced: HashSet<ServerId> = lock_std(&self.documents)
.get(path)
.map_or_default(|st| st.synced.keys().cloned().collect());
let pending = lock_std(&self.pending_closes).remove(path)?;
let servers: Vec<ServerId> = pending.servers.difference(&synced).cloned().collect();
if servers.is_empty() {
return None;
}
let unsettled = servers
.iter()
.map(|server| (server.clone(), self.generation(server)))
.collect();
Some(PendingCloseClaim {
path: path.to_path_buf(),
uri: pending.uri,
servers,
tracker: self,
unsettled,
_path_guard: path_guard,
})
}
fn owes_pending_close(&self, path: &Path, server: &ServerId) -> bool {
lock_std(&self.pending_closes)
.get(path)
.is_some_and(|pending| pending.servers.contains(server))
}
fn take_pending_close(&self, path: &Path, server: &ServerId) -> bool {
let mut pending_closes = lock_std(&self.pending_closes);
let Some(pending) = pending_closes.get_mut(path) else {
return false;
};
let owed = pending.servers.remove(server);
if pending.servers.is_empty() {
pending_closes.remove(path);
}
owed
}
fn restore_pending_close(
&self,
path: &Path,
uri: &Uri,
server: &ServerId,
generation: SyncGeneration,
) {
let mut pending_closes = lock_std(&self.pending_closes);
if self.generation(server) != generation {
return;
}
pending_closes
.entry(path.to_path_buf())
.or_insert_with(|| PendingClose {
uri: uri.clone(),
servers: HashSet::new(),
})
.servers
.insert(server.clone());
}
pub async fn ensure_open(&self, request: OpenRequest<'_>) -> Result<Uri> {
let path = request.path.as_path();
let _path_guard = self.lock_path(path).await;
let generation = self.generation(request.server);
let decision = self.disk_phase(request.gate(), request.access).await?;
self.sync_phase(path, request.server, request.client, decision, generation)
.await
}
async fn disk_phase(&self, gate: OpenGate<'_>, access: Access) -> Result<Decision> {
let path = gate.path();
if !lock_std(&self.documents).contains_key(path) {
if access == Access::Probe {
return Err(Error::DocumentNotFound(path.to_path_buf()));
}
return self.disk_phase_new(gate).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| {
if access == Access::Use {
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(gate).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.text.as_str())
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.next(),
fresh_content: Some(DocumentText::build(fresh).await?),
snap: Some(snap),
})
}
async fn disk_phase_new(&self, gate: OpenGate<'_>) -> Result<Decision> {
let path = gate.path();
let read_at = SystemTime::now();
let (content, mtime, size) = self.read_to_string_checked(gate).await?;
let uri = self.open_text(path.to_path_buf(), DocumentText::build(content).await?)?;
self.set_disk(
path,
DiskSync {
mtime,
size,
mtime_settled: mtime_settled(mtime, read_at),
content_checked_at: Instant::now(),
},
);
Ok(Decision::unchanged(uri, DocumentVersion::FIRST))
}
async fn open_checked(&self, gate: OpenGate<'_>) -> Result<(fs::File, std::fs::Metadata)> {
let opened = match gate {
OpenGate::Contained { roots, path } => {
let (roots, path) = (roots.clone(), path.clone());
tokio::task::spawn_blocking(move || roots.open_contained(&path))
}
OpenGate::Ungated(path) => {
let owned = path.to_path_buf();
tokio::task::spawn_blocking(move || {
RegularFile::open(&owned).map_err(|error| error.into_error(&owned))
})
}
}
.await
.map_err(|source| Error::TaskFailed {
task: BackgroundTask::FileOpen,
source,
})?;
let (file, meta) = opened?.into_parts();
self.check_file_size(meta.len())?;
Ok((fs::File::from_std(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 = max.read_cap();
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)?;
check_bounded_utf8(buf, max).map_err(|e| match e {
BoundedUtf8Error::TooLarge(exceeded) => Error::FileSizeLimitExceeded(exceeded),
BoundedUtf8Error::InvalidUtf8(e) => {
io_err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
})
}
async fn read_to_string_checked(
&self,
gate: OpenGate<'_>,
) -> Result<(String, Option<SystemTime>, u64)> {
let path = gate.path();
let (file, meta) = self.open_checked(gate).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(OpenGate::Ungated(path)).await else {
return Ok(LineRead {
text: None,
bytes_read: OPEN_FAILURE_CHARGE_BYTES,
});
};
let max = self.limits.max_file_size;
let cap = max.read_cap().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,
};
read_nth_line(&mut reader, line, cap).await.map_err(io_err)
}
async fn send_open(
&self,
path: &Path,
server: &ServerId,
lsp_client: &LspClient,
document: OpenedDocument,
) -> Result<()> {
let OpenedDocument {
uri,
language_id,
text,
version,
} = document;
let open = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: String::from(language_id).into(),
version: version.get(),
text,
},
};
if !self.owes_pending_close(path, server) {
return lsp_client
.notify_typed::<DidOpenTextDocumentNotification>(open)
.await;
}
let open = LspClient::outbound_notification::<DidOpenTextDocumentNotification>(open)?;
let close = LspClient::outbound_notification::<DidCloseTextDocumentNotification>(
DidCloseTextDocumentParams {
text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
},
)?;
let (close_permit, open_permit) = lsp_client.reserve_notification_pair().await?;
if self.take_pending_close(path, server) {
close_permit.send(close);
}
open_permit.send(open);
Ok(())
}
async fn send_change(
lsp_client: &LspClient,
uri: &Uri,
text: String,
version: DocumentVersion,
) -> Result<()> {
lsp_client
.notify_typed::<DidChangeTextDocumentNotification>(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
version: version.get(),
text_document_identifier: lsp_types::TextDocumentIdentifier {
uri: uri.clone(),
},
},
content_changes: vec![
TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
lsp_types::TextDocumentContentChangeWholeDocument { text },
),
],
})
.await
}
async fn sync_phase(
&self,
path: &Path,
server: &ServerId,
lsp_client: &LspClient,
decision: Decision,
generation: SyncGeneration,
) -> 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
.as_ref()
.unwrap_or(&st.text)
.as_str()
.to_owned();
(st.language_id.clone(), text)
}) else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
let notify_result = if is_first_open {
let document = OpenedDocument {
uri: uri.clone(),
language_id,
text,
version: target_version,
};
self.send_open(path, server, lsp_client, document).await
} else {
Self::send_change(lsp_client, &uri, text, target_version).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)
}
}
#[allow(
clippy::arithmetic_side_effects,
clippy::indexing_slicing,
reason = "every index derives from `position()` over the same chunk and stays within `chunk.len()`"
)]
async fn read_nth_line<R>(reader: &mut R, line: u32, cap: u64) -> std::io::Result<LineRead>
where
R: tokio::io::AsyncBufRead + Unpin,
{
let mut buf = Vec::new();
let mut bytes_read: u64 = 0;
let mut current_line = 0u32;
let mut after_cr = false;
loop {
let chunk = reader.fill_buf().await?;
if chunk.is_empty() {
let reached_line = current_line == line && bytes_read < cap;
return Ok(LineRead {
text: reached_line.then(|| String::from_utf8(buf).ok()).flatten(),
bytes_read,
});
}
let mut i = usize::from(after_cr && chunk[0] == b'\n');
after_cr = false;
let mut finished = false;
while i < chunk.len() {
let rest = &chunk[i..];
let Some(offset) = rest.iter().position(|&b| is_line_terminator(b)) else {
if current_line == line {
buf.extend_from_slice(rest);
}
i = chunk.len();
break;
};
let terminator = i + offset;
if current_line == line {
buf.extend_from_slice(&chunk[i..terminator]);
i = terminator + 1;
finished = true;
break;
}
current_line += 1;
i = terminator + 1;
if chunk[terminator] == b'\r' {
match chunk.get(i) {
Some(b'\n') => i += 1,
Some(_) => {}
None => after_cr = true,
}
}
}
bytes_read += i as u64;
reader.consume(i);
if finished {
return Ok(LineRead {
text: String::from_utf8(buf).ok(),
bytes_read,
});
}
}
}
type InFlightMap = Arc<StdMutex<HashMap<PathBuf, usize>>>;
#[derive(Debug)]
#[must_use = "dropping the guard immediately makes the document evictable again"]
pub struct InFlightGuard {
in_flight: InFlightMap,
path: PathBuf,
}
impl Drop for InFlightGuard {
fn drop(&mut self) {
let mut in_flight = lock_std(&self.in_flight);
if let Some(count) = in_flight.get_mut(&self.path) {
*count = count.saturating_sub(1);
if *count == 0 {
in_flight.remove(&self.path);
}
}
}
}
#[derive(Debug)]
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);
}
}
}
#[cfg(test)]
impl DocumentTracker {
pub(crate) async fn ensure_open_for_test(
&self,
path: &Path,
server: &ServerId,
client: &LspClient,
) -> Result<Uri> {
self.ensure_open_as_for_test(path, server, client, Access::Use)
.await
}
pub(crate) async fn ensure_open_as_for_test(
&self,
path: &Path,
server: &ServerId,
client: &LspClient,
access: Access,
) -> Result<Uri> {
let parent = path.parent().unwrap_or(path);
let mut roots = vec![parent.to_path_buf()];
if let Ok(canonical) = dunce::canonicalize(parent)
&& canonical != parent
{
roots.push(canonical);
}
self.ensure_open(OpenRequest::new(
&WorkspacePath::for_test(path.to_path_buf()),
&WorkspaceRoots::for_test(roots, Vec::new()),
server,
client,
access,
))
.await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
Use,
Probe,
}
#[derive(Debug, Clone, Copy)]
pub struct OpenRequest<'a> {
path: &'a WorkspacePath,
roots: &'a WorkspaceRoots,
server: &'a ServerId,
client: &'a LspClient,
access: Access,
}
impl<'a> OpenRequest<'a> {
#[must_use]
pub const fn new(
path: &'a WorkspacePath,
roots: &'a WorkspaceRoots,
server: &'a ServerId,
client: &'a LspClient,
access: Access,
) -> Self {
Self {
path,
roots,
server,
client,
access,
}
}
const fn gate(&self) -> OpenGate<'a> {
OpenGate::Contained {
roots: self.roots,
path: self.path,
}
}
}
#[derive(Clone, Copy)]
enum OpenGate<'a> {
Contained {
roots: &'a WorkspaceRoots,
path: &'a WorkspacePath,
},
Ungated(&'a Path),
}
impl<'a> OpenGate<'a> {
fn path(self) -> &'a Path {
match self {
Self::Contained { path, .. } => path.as_path(),
Self::Ungated(path) => path,
}
}
}
struct Decision {
uri: Uri,
target_version: DocumentVersion,
fresh_content: Option<DocumentText>,
snap: Option<DiskSync>,
}
impl Decision {
const fn unchanged(uri: Uri, target_version: DocumentVersion) -> 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::PathToUri(path.to_path_buf()))
}
#[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))]
pub(super) fn file_url(path: &Path) -> Option<Url> {
Url::from_file_path(path).ok()
}
#[cfg(windows)]
pub(super) 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()
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::*;
use crate::config::FileExtension;
fn detect_language(
path: &Path,
extension_map: &HashMap<FileExtension, LanguageId>,
) -> LanguageId {
LanguageMap::from(extension_map.clone()).detect(path)
}
#[test]
fn test_detect_language() {
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
map.insert(
FileExtension::from_static("py"),
LanguageId::from_static("python"),
);
map.insert(
FileExtension::from_static("ts"),
LanguageId::from_static("typescript"),
);
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(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/file.rs");
assert_eq!(tracker.open_state(&path), DocumentOpenState::NotOpen);
tracker
.open(path.clone(), "fn main() {}".to_string())
.unwrap();
assert_eq!(tracker.open_state(&path), DocumentOpenState::Open);
assert_eq!(tracker.len(), 1);
let state = tracker.get(&path).unwrap();
assert_eq!(state.version().get(), 1);
assert_eq!(state.language_id(), "rust");
tracker.close(&path);
assert_eq!(tracker.open_state(&path), DocumentOpenState::NotOpen);
assert!(tracker.is_empty());
}
#[test]
fn test_stamp_and_sweep_state_read_one_opening_and_version() {
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let server = ServerId::from_static("rust");
let path = PathBuf::from("/test/stamp.rs");
tracker.open(path.clone(), "fn a() {}".to_string()).unwrap();
assert_eq!(tracker.stamp(&path, &server), None);
assert_eq!(tracker.sweep_state(&path, &server), None);
tracker.set_synced_version_for_test(&path, &server, 1);
let first = tracker.stamp(&path, &server).unwrap();
assert_eq!(first.opening(), tracker.opening(&path));
assert_eq!(first.version(), DocumentVersion::new(1));
assert_matches!(
tracker.sweep_state(&path, &server),
Some(SweepState::Current { stamp, .. }) if stamp == first
);
tracker.set_synced_version_for_test(&path, &server, 0);
assert_matches!(
tracker.sweep_state(&path, &server),
Some(SweepState::Lagging { .. })
);
tracker.close(&path);
tracker.open(path.clone(), "fn a() {}".to_string()).unwrap();
tracker.set_synced_version_for_test(&path, &server, 1);
let second = tracker.stamp(&path, &server).unwrap();
assert_ne!(second.opening(), first.opening());
assert_eq!(second.opening(), tracker.opening(&path));
}
#[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_static("rust-respawned");
let untouched = ServerId::from_static("rust-diagnostics");
lock_std(&tracker.documents)
.get_mut(&path)
.unwrap()
.synced
.insert(respawned.clone(), DocumentVersion::FIRST);
lock_std(&tracker.documents)
.get_mut(&path)
.unwrap()
.synced
.insert(untouched.clone(), DocumentVersion::FIRST);
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_static("rust");
let generation_before_respawn = SyncGeneration::default();
tracker.forget_server(&server);
let (stale_client, _guard) = fake_lsp_client();
let decision = tracker
.disk_phase(OpenGate::Ungated(&path), Access::Use)
.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_static("rust");
let (client, _guard) = fake_lsp_client();
tracker
.ensure_open_for_test(&path, &server, &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(
state.synced_version(&server).map(DocumentVersion::get),
Some(1)
);
}
#[tokio::test]
async fn test_synced_document_count_and_forgotten_history() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("cover.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_static("rust");
let other = ServerId::from_static("other");
let (client, _guard) = fake_lsp_client();
assert_eq!(tracker.synced_document_count(&server), 0);
assert!(!tracker.sync_history_forgotten(&server));
tracker
.ensure_open_for_test(&path, &server, &client)
.await
.unwrap();
assert_eq!(tracker.synced_document_count(&server), 1);
assert_eq!(tracker.synced_document_count(&other), 0);
tracker.forget_server(&server);
assert_eq!(tracker.synced_document_count(&server), 0);
assert!(tracker.sync_history_forgotten(&server));
assert!(!tracker.sync_history_forgotten(&other));
}
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: DocumentLimit::new(2),
max_file_size: SizeLimit::from_static(100),
};
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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_eq!(
tracker.open_state(Path::new("/test/file1.rs")),
DocumentOpenState::NotOpen
);
assert_eq!(
tracker.open_state(Path::new("/test/file2.rs")),
DocumentOpenState::Open
);
assert_eq!(
tracker.open_state(Path::new("/test/file3.rs")),
DocumentOpenState::Open
);
assert!(
tracker.pending_close_paths().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: DocumentLimit::new(1),
max_file_size: SizeLimit::from_static(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.open_state(&locked_path) == DocumentOpenState::Open,
"the locked document must not be evicted"
);
assert_eq!(tracker.pending_close_paths().len(), 0);
}
fn pending_servers(tracker: &DocumentTracker, path: &Path) -> HashSet<ServerId> {
lock_std(&tracker.pending_closes)
.get(path)
.map_or_default(|pending| pending.servers.clone())
}
async fn ensure_open_disk_verified(
tracker: &DocumentTracker,
dir: &TempDir,
name: &str,
) -> PathBuf {
let path = dir.path().join(name);
std::fs::write(&path, name).unwrap();
set_mtime(&path, settled_past());
let (client, _server) = fake_lsp_client();
tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
path
}
#[cfg(unix)]
#[tokio::test]
async fn test_ensure_open_refuses_a_path_swapped_out_of_the_roots_after_validation() {
let dir = TempDir::new().unwrap();
let base = dunce::canonicalize(dir.path()).unwrap();
let root = base.join("ws");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::create_dir_all(base.join("out")).unwrap();
let roots = WorkspaceRoots::from_paths(&[&root]).unwrap();
let file = root.join("sub/a.rs");
std::fs::write(&file, "inside").unwrap();
let validated = roots
.validate_blocking(&crate::bridge::ClientPath::try_from(file).unwrap())
.unwrap();
std::fs::write(base.join("out/a.rs"), "secret").unwrap();
std::fs::remove_dir_all(root.join("sub")).unwrap();
std::os::unix::fs::symlink(base.join("out"), root.join("sub")).unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let server = ServerId::from_static("rust");
let (client, _guard) = fake_lsp_client();
let result = tracker
.ensure_open(OpenRequest::new(
&validated,
&roots,
&server,
&client,
Access::Use,
))
.await;
assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
assert_eq!(tracker.open_paths().len(), 0);
}
#[tokio::test]
async fn test_probe_of_an_untracked_path_opens_nothing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn a() {}").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let (client, _guard) = fake_lsp_client();
let result = tracker
.ensure_open_as_for_test(
&path,
&ServerId::from_static("rust"),
&client,
Access::Probe,
)
.await;
assert_matches!(result, Err(Error::DocumentNotFound(_)));
assert_eq!(tracker.open_paths().len(), 0);
}
#[tokio::test]
async fn test_probe_leaves_the_recency_order_and_use_changes_it() {
let dir = TempDir::new().unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let server = ServerId::from_static("rust");
let (client, _guard) = fake_lsp_client();
let (a, b) = (dir.path().join("a.rs"), dir.path().join("b.rs"));
for path in [&a, &b] {
std::fs::write(path, "fn f() {}").unwrap();
tracker
.ensure_open_for_test(path, &server, &client)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(tracker.open_paths_by_recency(), [b.clone(), a.clone()]);
tracker
.ensure_open_as_for_test(&a, &server, &client, Access::Probe)
.await
.unwrap();
assert_eq!(tracker.open_paths_by_recency(), [b.clone(), a.clone()]);
tracker
.ensure_open_for_test(&a, &server, &client)
.await
.unwrap();
assert_eq!(tracker.open_paths_by_recency(), [a, b]);
}
#[tokio::test]
async fn test_open_does_not_evict_in_flight_document_until_guard_drops() {
let dir = TempDir::new().unwrap();
let limits = ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::UNLIMITED,
};
let tracker = DocumentTracker::new(limits, HashMap::new());
let path_a = ensure_open_disk_verified(&tracker, &dir, "a.rs").await;
let guard = tracker.mark_in_flight(&path_a);
let path_b = dir.path().join("b.rs");
std::fs::write(&path_b, "BBBB").unwrap();
let result = tracker.open(path_b.clone(), "BBBB".to_string());
assert_matches!(result, Err(Error::DocumentLimitExceeded { .. }));
assert_eq!(tracker.open_state(&path_a), DocumentOpenState::Open);
assert_eq!(tracker.pending_close_paths().len(), 0);
drop(guard);
tracker.open(path_b.clone(), "BBBB".to_string()).unwrap();
assert_eq!(tracker.open_state(&path_a), DocumentOpenState::NotOpen);
assert_eq!(tracker.open_state(&path_b), DocumentOpenState::Open);
assert_eq!(tracker.pending_close_paths(), vec![path_a.clone()]);
assert_eq!(
pending_servers(&tracker, &path_a),
HashSet::from([ServerId::from_static("rust")])
);
}
#[tokio::test]
async fn test_in_flight_guards_are_refcounted_per_path() {
let dir = TempDir::new().unwrap();
let limits = ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::UNLIMITED,
};
let tracker = DocumentTracker::new(limits, HashMap::new());
let path_a = ensure_open_disk_verified(&tracker, &dir, "a.rs").await;
let first = tracker.mark_in_flight(&path_a);
let second = tracker.mark_in_flight(&path_a);
drop(first);
let path_b = dir.path().join("b.rs");
let result = tracker.open(path_b.clone(), "BBBB".to_string());
assert_matches!(
result,
Err(Error::DocumentLimitExceeded { .. }),
"one guard still outstanding"
);
drop(second);
assert!(lock_std(&tracker.in_flight).is_empty());
tracker.open(path_b, "BBBB".to_string()).unwrap();
assert_eq!(tracker.open_state(&path_a), DocumentOpenState::NotOpen);
}
#[tokio::test]
async fn test_in_flight_guard_released_when_holding_future_is_cancelled() {
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let path = PathBuf::from("/test/cancelled.rs");
let held = async {
let _guard = tracker.mark_in_flight(&path);
std::future::pending::<()>().await;
};
let timed_out = tokio::time::timeout(Duration::from_millis(10), held).await;
assert!(timed_out.is_err());
assert_eq!(tracker.in_flight_count(&path), 0);
}
#[test]
fn test_evict_lru_skips_document_without_disk_snapshot() {
let limits = ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::UNLIMITED,
};
let tracker = DocumentTracker::new(limits, HashMap::new());
let first = PathBuf::from("/test/first.rs");
tracker
.open(first.clone(), "fn first() {}".to_string())
.unwrap();
let result = tracker.open(
PathBuf::from("/test/second.rs"),
"fn second() {}".to_string(),
);
assert_matches!(result, Err(Error::DocumentLimitExceeded { .. }));
assert!(
tracker.open_state(&first) == DocumentOpenState::Open,
"the not-disk-verified document must not be evicted"
);
assert_eq!(tracker.pending_close_paths().len(), 0);
}
#[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: DocumentLimit::new(2),
max_file_size: SizeLimit::UNLIMITED,
};
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(limits, HashMap::new());
let server_id = ServerId::from_static("rust");
tracker
.ensure_open_for_test(&path_a, &server_id, &client)
.await
.unwrap();
tracker
.ensure_open_for_test(&path_b, &server_id, &client)
.await
.unwrap();
tracker
.ensure_open_for_test(&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_for_test(&path_c, &server_id, &client)
.await
.unwrap();
assert!(
tracker.open_state(&path_a) == DocumentOpenState::Open,
"recently re-accessed, must survive"
);
assert!(
tracker.open_state(&path_b) == DocumentOpenState::NotOpen,
"least-recently-used, must be evicted"
);
assert_eq!(tracker.open_state(&path_c), DocumentOpenState::Open);
assert_eq!(tracker.pending_close_paths(), vec![path_b.clone()]);
assert_eq!(
pending_servers(&tracker, &path_b),
HashSet::from([server_id])
);
}
#[test]
fn test_file_size_limit() {
let limits = ResourceLimits {
max_documents: DocumentLimit::new(10),
max_file_size: SizeLimit::from_static(10),
};
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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, DocumentLimit::new(100));
assert_eq!(
limits.max_file_size,
SizeLimit::from_static(10 * 1024 * 1024)
);
}
#[test]
fn test_resource_limits_custom() {
let limits = ResourceLimits {
max_documents: DocumentLimit::new(50),
max_file_size: SizeLimit::from_static(5 * 1024 * 1024),
};
assert_eq!(limits.max_documents, DocumentLimit::new(50));
assert_eq!(
limits.max_file_size,
SizeLimit::from_static(5 * 1024 * 1024)
);
}
#[test]
fn test_resource_limits_zero_unlimited() {
let limits = ResourceLimits {
max_documents: DocumentLimit::UNLIMITED,
max_file_size: SizeLimit::UNLIMITED,
};
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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: LanguageId::from_static("rust"),
version: DocumentVersion::new(5),
text: DocumentText::new("fn main() {}".to_string()),
disk: None,
synced: HashMap::new(),
opened: Opening(0),
last_accessed: Instant::now(),
};
#[allow(clippy::redundant_clone, reason = "the test exercises `Clone` itself")]
let cloned = state.clone();
assert_eq!(cloned.uri(), state.uri());
assert_eq!(cloned.language_id(), state.language_id());
assert_eq!(cloned.version().get(), 5);
assert_eq!(cloned.content(), state.content());
}
#[test]
fn test_line_presence_distinguishes_untracked_present_and_beyond() {
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let path = PathBuf::from("/test/lines.rs");
assert_eq!(tracker.line_presence(&path, 0), LinePresence::Untracked);
tracker.open(path.clone(), "a\r\nb\n".to_string()).unwrap();
assert_eq!(tracker.line_presence(&path, 0), LinePresence::Present);
assert_eq!(tracker.line_presence(&path, 1), LinePresence::Present);
assert_eq!(tracker.line_presence(&path, 2), LinePresence::Present);
let beyond = LinePresence::Beyond {
last_line: NonZeroU32::new(3).unwrap(),
};
assert_eq!(tracker.line_presence(&path, 3), beyond);
assert_eq!(tracker.line_presence(&path, u32::MAX), beyond);
let empty = PathBuf::from("/test/empty.rs");
tracker.open(empty.clone(), String::new()).unwrap();
assert_eq!(tracker.line_presence(&empty, 0), LinePresence::Present);
assert_eq!(
tracker.line_presence(&empty, 1),
LinePresence::Beyond {
last_line: NonZeroU32::MIN
}
);
}
#[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_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]
#[allow(
clippy::too_many_lines,
reason = "a table of extension mappings; splitting would scatter it"
)]
fn test_detect_language_all_extensions() {
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
map.insert(
FileExtension::from_static("py"),
LanguageId::from_static("python"),
);
map.insert(
FileExtension::from_static("pyw"),
LanguageId::from_static("python"),
);
map.insert(
FileExtension::from_static("pyi"),
LanguageId::from_static("python"),
);
map.insert(
FileExtension::from_static("js"),
LanguageId::from_static("javascript"),
);
map.insert(
FileExtension::from_static("mjs"),
LanguageId::from_static("javascript"),
);
map.insert(
FileExtension::from_static("cjs"),
LanguageId::from_static("javascript"),
);
map.insert(
FileExtension::from_static("ts"),
LanguageId::from_static("typescript"),
);
map.insert(
FileExtension::from_static("mts"),
LanguageId::from_static("typescript"),
);
map.insert(
FileExtension::from_static("cts"),
LanguageId::from_static("typescript"),
);
map.insert(
FileExtension::from_static("tsx"),
LanguageId::from_static("typescriptreact"),
);
map.insert(
FileExtension::from_static("jsx"),
LanguageId::from_static("javascriptreact"),
);
map.insert(
FileExtension::from_static("go"),
LanguageId::from_static("go"),
);
map.insert(
FileExtension::from_static("c"),
LanguageId::from_static("c"),
);
map.insert(
FileExtension::from_static("h"),
LanguageId::from_static("c"),
);
map.insert(
FileExtension::from_static("cpp"),
LanguageId::from_static("cpp"),
);
map.insert(
FileExtension::from_static("cc"),
LanguageId::from_static("cpp"),
);
map.insert(
FileExtension::from_static("cxx"),
LanguageId::from_static("cpp"),
);
map.insert(
FileExtension::from_static("hpp"),
LanguageId::from_static("cpp"),
);
map.insert(
FileExtension::from_static("hh"),
LanguageId::from_static("cpp"),
);
map.insert(
FileExtension::from_static("hxx"),
LanguageId::from_static("cpp"),
);
map.insert(
FileExtension::from_static("java"),
LanguageId::from_static("java"),
);
map.insert(
FileExtension::from_static("rb"),
LanguageId::from_static("ruby"),
);
map.insert(
FileExtension::from_static("php"),
LanguageId::from_static("php"),
);
map.insert(
FileExtension::from_static("swift"),
LanguageId::from_static("swift"),
);
map.insert(
FileExtension::from_static("kt"),
LanguageId::from_static("kotlin"),
);
map.insert(
FileExtension::from_static("kts"),
LanguageId::from_static("kotlin"),
);
map.insert(
FileExtension::from_static("scala"),
LanguageId::from_static("scala"),
);
map.insert(
FileExtension::from_static("sc"),
LanguageId::from_static("scala"),
);
map.insert(
FileExtension::from_static("zig"),
LanguageId::from_static("zig"),
);
map.insert(
FileExtension::from_static("lua"),
LanguageId::from_static("lua"),
);
map.insert(
FileExtension::from_static("sh"),
LanguageId::from_static("shellscript"),
);
map.insert(
FileExtension::from_static("bash"),
LanguageId::from_static("shellscript"),
);
map.insert(
FileExtension::from_static("zsh"),
LanguageId::from_static("shellscript"),
);
map.insert(
FileExtension::from_static("json"),
LanguageId::from_static("json"),
);
map.insert(
FileExtension::from_static("toml"),
LanguageId::from_static("toml"),
);
map.insert(
FileExtension::from_static("yaml"),
LanguageId::from_static("yaml"),
);
map.insert(
FileExtension::from_static("yml"),
LanguageId::from_static("yaml"),
);
map.insert(
FileExtension::from_static("xml"),
LanguageId::from_static("xml"),
);
map.insert(
FileExtension::from_static("html"),
LanguageId::from_static("html"),
);
map.insert(
FileExtension::from_static("htm"),
LanguageId::from_static("html"),
);
map.insert(
FileExtension::from_static("css"),
LanguageId::from_static("css"),
);
map.insert(
FileExtension::from_static("scss"),
LanguageId::from_static("scss"),
);
map.insert(
FileExtension::from_static("less"),
LanguageId::from_static("less"),
);
map.insert(
FileExtension::from_static("md"),
LanguageId::from_static("markdown"),
);
map.insert(
FileExtension::from_static("markdown"),
LanguageId::from_static("markdown"),
);
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::PathToUri(_));
}
#[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(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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_eq!(tracker.open_state(&path1), DocumentOpenState::Open);
assert_eq!(tracker.open_state(&path2), DocumentOpenState::Open);
assert_eq!(tracker.get(&path1).unwrap().content(), "content1");
assert_eq!(tracker.get(&path2).unwrap().content(), "content2");
tracker.close(&path1);
assert_eq!(tracker.len(), 1);
assert_eq!(tracker.open_state(&path1), DocumentOpenState::NotOpen);
assert_eq!(tracker.open_state(&path2), DocumentOpenState::Open);
}
#[test]
fn test_empty_content() {
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
let tracker = DocumentTracker::new(ResourceLimits::default(), map);
let path = PathBuf::from("/test/empty.rs");
tracker.open(path.clone(), String::new()).unwrap();
assert_eq!(tracker.open_state(&path), DocumentOpenState::Open);
assert_eq!(tracker.get(&path).unwrap().content(), "");
}
#[test]
fn test_unicode_content() {
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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: DocumentLimit::new(5),
max_file_size: SizeLimit::from_static(1000),
};
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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_eq!(
tracker.open_state(Path::new("/test/file0.rs")),
DocumentOpenState::NotOpen
);
assert_eq!(
tracker.open_state(Path::new("/test/file6.rs")),
DocumentOpenState::Open
);
}
#[test]
fn test_file_size_exact_boundary() {
let limits = ResourceLimits {
max_documents: DocumentLimit::new(10),
max_file_size: SizeLimit::from_static(100),
};
let mut map = HashMap::new();
map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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(
FileExtension::from_static("nu"),
LanguageId::from_static("nushell"),
);
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(
FileExtension::from_static("rs"),
LanguageId::from_static("custom-rust"),
);
assert_eq!(
detect_language(Path::new("main.rs"), &custom_map),
"custom-rust"
);
let mut default_map = HashMap::new();
default_map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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(
FileExtension::from_static("nu"),
LanguageId::from_static("nushell"),
);
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(
FileExtension::from_static("nu"),
LanguageId::from_static("nushell"),
);
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(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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(
FileExtension::from_static("cpp"),
LanguageId::from_static("c++"),
);
map.insert(
FileExtension::from_static("cc"),
LanguageId::from_static("c++"),
);
map.insert(
FileExtension::from_static("cxx"),
LanguageId::from_static("c++"),
);
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(
FileExtension::from_static("NU"),
LanguageId::from_static("nushell"),
);
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(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
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_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
let uri2 = tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
assert_eq!(uri1, uri2);
assert_eq!(tracker.get(&path).unwrap().version().get(), 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_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
set_mtime(&path, settled_past());
tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.version().get(), 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_for_test(&path, &ServerId::from_static("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_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(
state.version().get(),
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_for_test(&path, &ServerId::from_static("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_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(
state.version().get(),
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_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
std::fs::write(&path, "BBBBBBBB").unwrap();
tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.version().get(), 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_for_test(&path, &ServerId::from_static("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_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
tokio::time::advance(Duration::from_millis(300)).await;
tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(state.version().get(), 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_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
std::fs::remove_file(&path).unwrap();
let result = tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await;
assert_matches!(result, Err(Error::FileIo { .. }));
assert_eq!(tracker.open_state(&path), DocumentOpenState::Open);
assert_eq!(tracker.get(&path).unwrap().version().get(), 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: DocumentLimit::new(10),
max_file_size: SizeLimit::from_static(10),
};
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(limits, HashMap::new());
tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
std::fs::write(&path, "x".repeat(100)).unwrap();
let result = tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await;
assert_matches!(result, Err(Error::FileSizeLimitExceeded { .. }));
assert_eq!(tracker.get(&path).unwrap().content(), "small");
assert_eq!(tracker.get(&path).unwrap().version().get(), 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: DocumentLimit::new(1),
max_file_size: SizeLimit::UNLIMITED,
};
let (client, _server) = fake_lsp_client();
let tracker = DocumentTracker::new(limits, HashMap::new());
tracker
.ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
assert_eq!(tracker.len(), 1);
std::fs::write(&path, "BBBBBBBB").unwrap();
let result = tracker
.ensure_open_for_test(&path, &ServerId::from_static("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().get(), 2);
}
#[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_for_test(&path, &ServerId::from_static("rust"), ¬ify_will_fail)
.await;
assert!(result.is_err(), "notify failure must propagate as an error");
assert!(
tracker.open_state(&path) == DocumentOpenState::NotOpen,
"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_for_test(&path, &ServerId::from_static("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_for_test(&path, &ServerId::from_static("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_static("server-a");
let id_b = ServerId::from_static("server-b");
tracker
.ensure_open_for_test(&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_for_test(&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_for_test(&path, &ServerId::from_static("server-a"), &client_a)
.await
.unwrap();
tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
tracker
.ensure_open_for_test(&path, &ServerId::from_static("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_static("rust");
tracker
.ensure_open_for_test(&path, &id, &client)
.await
.unwrap();
tracker
.ensure_open_for_test(&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)
.map(DocumentVersion::get),
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_static("server-a");
let id_b = ServerId::from_static("server-b");
tracker
.ensure_open_for_test(&path, &id_a, &client_a)
.await
.unwrap();
tracker
.ensure_open_for_test(&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_for_test(&path, &id_b, &client_b_will_fail)
.await;
assert!(result.is_err(), "B's didChange must fail and propagate");
assert_eq!(tracker.open_state(&path), DocumentOpenState::Open);
assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
assert_eq!(
tracker
.get(&path)
.unwrap()
.synced_version(&id_a)
.map(DocumentVersion::get),
Some(1)
);
assert_eq!(
tracker
.get(&path)
.unwrap()
.synced_version(&id_b)
.map(DocumentVersion::get),
Some(1)
);
tracker
.ensure_open_for_test(&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)
.map(DocumentVersion::get),
Some(2)
);
assert_eq!(
tracker
.get(&path)
.unwrap()
.synced_version(&id_b)
.map(DocumentVersion::get),
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_for_test(
&path_a_for_task,
&ServerId::from_static("server-a"),
&client_a,
)
.await
});
tokio::time::sleep(Duration::from_millis(200)).await;
tokio::time::timeout(
Duration::from_secs(5),
tracker.ensure_open_for_test(&path_b, &ServerId::from_static("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_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_static("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_for_test(&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)
.map(DocumentVersion::get),
Some(1)
);
assert_eq!(tracker.get(&path).unwrap().version().get(), 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_static("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_for_test(&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");
crate::test_lsp::make_fifo(&path);
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let result = tokio::time::timeout(
Duration::from_secs(5),
tracker.read_to_string_checked(OpenGate::Ungated(&path)),
)
.await
.unwrap();
assert_matches!(result, 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(OpenGate::Ungated(&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: DocumentLimit::new(100),
max_file_size: SizeLimit::from_static(10),
},
HashMap::new(),
);
std::fs::write(&path, "a".repeat(10)).unwrap();
let (content, ..) = tracker
.read_to_string_checked(OpenGate::Ungated(&path))
.await
.unwrap();
assert_eq!(content.len(), 10);
std::fs::write(&path, "a".repeat(11)).unwrap();
let result = tracker
.read_to_string_checked(OpenGate::Ungated(&path))
.await;
assert_matches!(
result,
Err(Error::FileSizeLimitExceeded(SizeExceeded { size: 11, max })) if max.get() == 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_line_zero_is_empty_line() {
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
.as_deref(),
Some("")
);
assert_eq!(
tracker
.read_line_checked(&path, 1, u64::MAX)
.await
.unwrap()
.text,
None
);
}
#[tokio::test]
async fn test_read_line_checked_trailing_empty_line_after_final_newline() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("trailing.rs");
std::fs::write(&path, "a\n").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let read = |line| tracker.read_line_checked(&path, line, u64::MAX);
assert_eq!(read(1).await.unwrap().text.as_deref(), Some(""));
assert_eq!(read(2).await.unwrap().text, None);
}
#[tokio::test]
async fn test_read_line_checked_cap_at_line_boundary_is_not_empty_line() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("capped.rs");
std::fs::write(&path, "abc\nrest\n").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
assert_eq!(
tracker.read_line_checked(&path, 1, 3).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_line_text_and_read_line_checked_agree_on_line_rule() {
let dir = TempDir::new().unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let contents = [
"",
"abc",
"abc\n",
"abc\r\n",
"abc\r\r\n",
"abc\r",
"a\n\nb",
"a\r\nb\r\n",
"a\rb",
"a\r\r\nb",
"\n\r",
"\r\n\r\n",
];
for (i, content) in contents.iter().enumerate() {
let path = dir.path().join(format!("parity_{i}.rs"));
std::fs::write(&path, content).unwrap();
tracker.open(path.clone(), (*content).to_string()).unwrap();
for line in 0..6 {
let disk = tracker
.read_line_checked(&path, line, u64::MAX)
.await
.unwrap()
.text;
assert_eq!(
tracker.line_text(&path, line),
disk,
"content {content:?}, line {line}"
);
}
}
}
#[test]
fn test_line_text_trailing_empty_line_and_lone_cr_terminator() {
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let path = PathBuf::from("/test/lines.rs");
tracker
.open(path.clone(), "ab\r\ncd\r\r\n".to_string())
.unwrap();
assert_eq!(tracker.line_text(&path, 0).as_deref(), Some("ab"));
assert_eq!(tracker.line_text(&path, 1).as_deref(), Some("cd"));
assert_eq!(tracker.line_text(&path, 2).as_deref(), Some(""));
assert_eq!(tracker.line_text(&path, 3).as_deref(), Some(""));
assert_eq!(tracker.line_text(&path, 4), None);
let empty = PathBuf::from("/test/empty.rs");
tracker.open(empty.clone(), String::new()).unwrap();
assert_eq!(tracker.line_text(&empty, 0).as_deref(), Some(""));
assert_eq!(tracker.line_text(&empty, 1), None);
}
#[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: DocumentLimit::new(100),
max_file_size: SizeLimit::from_static(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: DocumentLimit::new(100),
max_file_size: SizeLimit::from_static(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);
}
fn naive_lines(content: &str) -> Vec<&str> {
let bytes = content.as_bytes();
let mut lines = Vec::new();
let (mut start, mut i) = (0, 0);
while i < bytes.len() {
match bytes[i] {
b'\n' => {
lines.push(&content[start..i]);
i += 1;
start = i;
}
b'\r' => {
lines.push(&content[start..i]);
i += 1;
if bytes.get(i) == Some(&b'\n') {
i += 1;
}
start = i;
}
_ => i += 1,
}
}
lines.push(&content[start..]);
lines
}
fn all_strings(max_len: usize) -> Vec<String> {
let alphabet = ['a', 'é', '\r', '\n'];
let mut all = vec![String::new()];
let mut frontier = vec![String::new()];
for _ in 0..max_len {
frontier = frontier
.iter()
.flat_map(|s| {
alphabet.iter().map(move |c| {
let mut next = s.clone();
next.push(*c);
next
})
})
.collect();
all.extend(frontier.iter().cloned());
}
all
}
#[test]
fn test_document_text_line_matches_naive_splitter_exhaustively() {
for content in all_strings(8) {
let expected = naive_lines(&content);
for stride in 1..=3 {
let text = DocumentText::with_stride(content.clone(), stride);
for (n, want) in expected.iter().enumerate() {
assert_eq!(
text.line(u32::try_from(n).unwrap()),
Some(*want),
"{content:?} stride {stride} line {n}"
);
}
assert_eq!(
text.line(u32::try_from(expected.len()).unwrap()),
None,
"{content:?} stride {stride} past the last line"
);
}
}
}
#[test]
fn test_document_text_lines_from_matches_line_exhaustively() {
for content in all_strings(7) {
let expected = naive_lines(&content);
for stride in 1..=3 {
let text = DocumentText::with_stride(content.clone(), stride);
for start in 0..=expected.len() {
let got: Vec<&str> = text.lines_from(u32::try_from(start).unwrap()).collect();
assert_eq!(
got,
expected[start.min(expected.len())..],
"{content:?} {stride} {start}"
);
}
}
}
}
#[tokio::test]
async fn test_read_nth_line_matches_naive_splitter_exhaustively() {
for content in all_strings(7) {
let expected = naive_lines(&content);
for capacity in [1, 2, 8 * 1024] {
for n in 0..=expected.len() {
let mut reader =
tokio::io::BufReader::with_capacity(capacity, content.as_bytes());
let read = read_nth_line(&mut reader, u32::try_from(n).unwrap(), u64::MAX)
.await
.unwrap();
assert_eq!(
read.text.as_deref(),
expected.get(n).copied(),
"{content:?} capacity {capacity} line {n}"
);
}
}
}
}
#[test]
fn test_document_text_checkpoint_at_eof_after_full_stride() {
for terminator in ["\n", "\r\n", "\r"] {
let content = format!("x{terminator}").repeat(LINE_CHECKPOINT_STRIDE);
let text = DocumentText::new(content);
let stride = u32::try_from(LINE_CHECKPOINT_STRIDE).unwrap();
assert_eq!(text.line(stride - 1), Some("x"));
assert_eq!(text.line(stride), Some(""), "terminator {terminator:?}");
assert_eq!(text.line(stride + 1), None);
}
}
#[test]
fn test_document_text_lines_across_default_stride_boundaries() {
let content: String = (0..200)
.map(|i| format!("line{i}\r\n"))
.collect::<Vec<_>>()
.concat();
let text = DocumentText::new(content);
for i in [0u32, 1, 63, 64, 65, 127, 128, 199] {
assert_eq!(text.line(i), Some(format!("line{i}").as_str()), "line {i}");
}
assert_eq!(text.line(200), Some(""));
assert_eq!(text.line(201), None);
}
#[tokio::test]
async fn test_document_text_build_above_inline_threshold_matches_inline() {
let content = "ab\r\n".repeat(INLINE_INDEX_MAX_BYTES / 4 + 10);
assert!(content.len() > INLINE_INDEX_MAX_BYTES);
let built = DocumentText::build(content.clone()).await.unwrap();
let inline = DocumentText::new(content);
assert_eq!(built.checkpoints, inline.checkpoints);
assert_eq!(built.line(1000), Some("ab"));
}
#[test]
fn test_document_text_checkpoint_memory_is_sparse() {
let text = DocumentText::new("\n".repeat(6400));
assert_eq!(text.checkpoints.len(), 6400 / LINE_CHECKPOINT_STRIDE + 1);
}
#[tokio::test]
async fn test_read_nth_line_crlf_split_across_fills_is_one_terminator() {
let mut reader = tokio::io::BufReader::with_capacity(1, b"a\r\nb".as_slice());
let read = read_nth_line(&mut reader, 1, u64::MAX).await.unwrap();
assert_eq!(read.text.as_deref(), Some("b"));
}
#[tokio::test]
async fn test_read_nth_line_cap_exhausted_mid_line_is_none() {
let mut reader =
tokio::io::BufReader::new(tokio::io::AsyncReadExt::take(b"abcdef\n".as_slice(), 3));
let read = read_nth_line(&mut reader, 0, 3).await.unwrap();
assert_eq!(read.text, None);
assert_eq!(read.bytes_read, 3);
}
async fn open_for_servers(
tracker: &DocumentTracker,
dir: &TempDir,
name: &str,
servers: &[&str],
) -> (
PathBuf,
Vec<(ServerId, LspClient, crate::test_lsp::FakeServer)>,
) {
let path = dir.path().join(name);
std::fs::write(&path, name).unwrap();
set_mtime(&path, settled_past());
let mut out = Vec::new();
for server in servers {
let (client, fake) = fake_lsp_client();
let id = ServerId::new(*server).unwrap();
tracker
.ensure_open_for_test(&path, &id, &client)
.await
.unwrap();
out.push((id, client, fake));
}
(path, out)
}
async fn evict_by_opening(tracker: &DocumentTracker, dir: &TempDir, other: &str) {
let path = dir.path().join(other);
std::fs::write(&path, other).unwrap();
set_mtime(&path, settled_past());
let (client, _fake) = fake_lsp_client();
tracker
.ensure_open_for_test(&path, &ServerId::from_static("other"), &client)
.await
.unwrap();
}
fn one_document_tracker() -> DocumentTracker {
DocumentTracker::new(
ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::UNLIMITED,
},
HashMap::new(),
)
}
#[tokio::test]
async fn test_reopen_through_one_server_settles_only_that_servers_close() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, mut servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([ServerId::from_static("a"), ServerId::from_static("d")])
);
let (id_a, client_a, mut fake_a) = servers.remove(0);
tracker
.ensure_open_for_test(&path, &id_a, &client_a)
.await
.unwrap();
let mut wire = BufReader::new(&mut fake_a.write_stdout);
assert_eq!(
read_framed_message(&mut wire).await["method"],
"textDocument/didOpen"
);
assert_eq!(
read_framed_message(&mut wire).await["method"],
"textDocument/didClose"
);
assert_eq!(
read_framed_message(&mut wire).await["method"],
"textDocument/didOpen"
);
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([ServerId::from_static("d")])
);
let claim = tracker.try_claim_pending_close(&path).unwrap();
assert_eq!(claim.servers, vec![ServerId::from_static("d")]);
}
#[tokio::test]
async fn test_claim_returns_every_owed_server_once() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
assert_eq!(tracker.pending_close_paths(), vec![path.clone()]);
let mut claim = tracker.try_claim_pending_close(&path).unwrap();
assert_eq!(claim.path, path);
let mut claimed = claim.servers.clone();
claimed.sort_by_key(ToString::to_string);
assert_eq!(
claimed,
vec![ServerId::from_static("a"), ServerId::from_static("d")]
);
for server in claimed {
claim.settle(&server);
}
drop(claim);
assert_eq!(tracker.pending_close_paths().len(), 0);
assert!(tracker.try_claim_pending_close(&path).is_none());
}
#[tokio::test]
async fn test_claim_defers_busy_path_without_waiting() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
let busy = tracker.lock_path(&path).await;
assert!(tracker.try_claim_pending_close(&path).is_none());
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([ServerId::from_static("a")]),
"a deferred claim must leave the debt pending"
);
drop(busy);
assert!(tracker.try_claim_pending_close(&path).is_some());
assert!(
lock_std(&tracker.path_locks).is_empty(),
"failed and successful claims must not leak path-lock entries"
);
}
#[tokio::test]
async fn test_claim_holds_path_lock_until_dropped() {
let dir = TempDir::new().unwrap();
let tracker = Arc::new(one_document_tracker());
let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
let claim = tracker.try_claim_pending_close(&path).unwrap();
let (client, _fake) = fake_lsp_client();
let reopen = {
let tracker = Arc::clone(&tracker);
let path = path.clone();
tokio::spawn(async move {
tracker
.ensure_open_for_test(&path, &ServerId::from_static("b"), &client)
.await
})
};
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!reopen.is_finished(), "ensure_open must wait for the claim");
drop(claim);
reopen.await.unwrap().unwrap();
}
#[tokio::test]
async fn test_claim_dropped_unsettled_restores_the_owed_closes() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
let mut claim = tracker.try_claim_pending_close(&path).unwrap();
claim.settle(&ServerId::from_static("a"));
drop(claim);
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([ServerId::from_static("d")]),
"only the close that was never settled stays owed"
);
}
#[tokio::test]
async fn test_claim_dropped_after_the_server_was_forgotten_restores_nothing() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
let claim = tracker.try_claim_pending_close(&path).unwrap();
tracker.forget_server(&ServerId::from_static("a"));
drop(claim);
assert!(pending_servers(&tracker, &path).is_empty());
}
#[tokio::test]
async fn test_sync_phase_dropped_while_waiting_for_queue_room_keeps_the_debt() {
use crate::lsp::LspClient;
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
let server = ServerId::from_static("a");
let (client, mut commands) =
LspClient::with_command_channel(crate::config::LspServerConfig::rust_analyzer(), 2);
client
.notify("test/fill", serde_json::json!({}))
.await
.unwrap();
client
.notify("test/fill", serde_json::json!({}))
.await
.unwrap();
let generation = tracker.generation(&server);
let decision = tracker
.disk_phase(OpenGate::Ungated(&path), Access::Use)
.await
.unwrap();
{
let sync = tracker.sync_phase(&path, &server, &client, decision, generation);
let mut sync = std::pin::pin!(sync);
assert!(futures::poll!(sync.as_mut()).is_pending());
}
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([server.clone()]),
"the abandoned sync must leave the close owed"
);
assert!(
tracker
.get(&path)
.unwrap()
.synced_version(&server)
.is_none()
);
assert_eq!(commands.next_method().as_deref(), Some("test/fill"));
assert_eq!(commands.next_method().as_deref(), Some("test/fill"));
assert_eq!(
commands.next_method(),
None,
"the abandoned sync sent something"
);
let generation = tracker.generation(&server);
let decision = tracker
.disk_phase(OpenGate::Ungated(&path), Access::Use)
.await
.unwrap();
tracker
.sync_phase(&path, &server, &client, decision, generation)
.await
.unwrap();
let methods: Vec<_> = std::iter::from_fn(|| commands.next_method()).collect();
assert_eq!(
methods,
["textDocument/didClose", "textDocument/didOpen"],
"the debt is settled before the open, in one step"
);
assert!(pending_servers(&tracker, &path).is_empty());
assert!(
tracker
.get(&path)
.unwrap()
.synced_version(&server)
.is_some()
);
}
#[tokio::test]
async fn test_repeated_eviction_merges_pending_closes() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, mut servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
let (id_d, client_d, _fake_d) = servers.remove(1);
tracker
.ensure_open_for_test(&path, &id_d, &client_d)
.await
.unwrap();
evict_by_opening(&tracker, &dir, "r.rs").await;
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([ServerId::from_static("a"), ServerId::from_static("d")])
);
assert_eq!(tracker.pending_close_paths().len(), 2);
}
#[tokio::test]
async fn test_restore_pending_close_skips_forgotten_server() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
let server = ServerId::from_static("a");
let uri = path_to_uri(&path).unwrap();
let generation = tracker.generation(&server);
assert!(tracker.take_pending_close(&path, &server));
tracker.forget_server(&server);
tracker.restore_pending_close(&path, &uri, &server, generation);
assert!(pending_servers(&tracker, &path).is_empty());
tracker.restore_pending_close(&path, &uri, &server, tracker.generation(&server));
assert_eq!(pending_servers(&tracker, &path), HashSet::from([server]));
}
#[tokio::test]
async fn test_forget_server_purges_its_pending_closes() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
tracker.forget_server(&ServerId::from_static("a"));
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([ServerId::from_static("d")])
);
tracker.forget_server(&ServerId::from_static("d"));
assert!(!lock_std(&tracker.pending_closes).contains_key(&path));
}
#[tokio::test]
async fn test_failed_close_before_reopen_restores_the_debt() {
let dir = TempDir::new().unwrap();
let tracker = one_document_tracker();
let (path, mut servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
evict_by_opening(&tracker, &dir, "q.rs").await;
let (id_a, client_a, _fake_a) = servers.remove(0);
let will_fail = client_a.clone();
client_a.shutdown().await.unwrap();
assert!(
tracker
.ensure_open_for_test(&path, &id_a, &will_fail)
.await
.is_err()
);
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([ServerId::from_static("a")])
);
}
}