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 crate::config::{DocumentLimit, LanguageId, LanguageMap, ServerId, SizeLimit};
use crate::error::{BackgroundTask, Error, Result};
use crate::lsp::LspClient;
use crate::util::{
BoundedUtf8Error, OpenRegularFileError, 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,
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 {
content,
stride,
checkpoints: checkpoints.into_boxed_slice(),
line_count,
}
}
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 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);
}
}
#[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,
pub(crate) servers: Vec<ServerId>,
_path_guard: PathLockGuard<'a>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinePresence {
Untracked,
Present,
Beyond {
last_line: NonZeroU32,
},
}
#[derive(Debug)]
pub struct DocumentTracker {
documents: StdMutex<HashMap<PathBuf, DocumentState>>,
path_locks: StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
in_flight: InFlightMap,
generations: StdMutex<HashMap<ServerId, u64>>,
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 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 is_open(&self, path: &Path) -> bool {
lock_std(&self.documents).contains_key(path)
}
#[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 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 fn forget_server(&self, server: &ServerId) {
let mut generations = lock_std(&self.generations);
let generation = generations.entry(server.clone()).or_insert(0);
*generation = generation.saturating_add(1);
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()
});
}
fn generation(&self, server: &ServerId) -> u64 {
lock_std(&self.generations)
.get(server)
.copied()
.unwrap_or(0)
}
async fn lock_path(&self, path: &Path) -> PathLockGuard<'_> {
let arc = {
let mut locks = lock_std(&self.path_locks);
locks
.entry(path.to_path_buf())
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone()
};
let guard = Arc::clone(&arc).lock_owned().await;
PathLockGuard {
path_locks: &self.path_locks,
path: path.to_path_buf(),
arc,
guard: Some(guard),
}
}
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;
}
Some(PendingCloseClaim {
path: path.to_path_buf(),
uri: pending.uri,
servers,
_path_guard: path_guard,
})
}
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: u64) {
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,
path: &Path,
server: &ServerId,
lsp_client: &LspClient,
) -> Result<Uri> {
let _path_guard = self.lock_path(path).await;
let generation = self.generation(server);
let decision = self.disk_phase(path).await?;
self.sync_phase(path, server, lsp_client, decision, generation)
.await
}
async fn disk_phase(&self, path: &Path) -> Result<Decision> {
if !lock_std(&self.documents).contains_key(path) {
return self.disk_phase_new(path).await;
}
let read_at = SystemTime::now();
let meta = fs::metadata(path).await.map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
let mtime = meta.modified().ok();
let size = meta.len();
let Some((uri, current_version, fast_path)) =
lock_std(&self.documents).get_mut(path).map(|st| {
st.touch();
let stat_matches = st
.disk()
.is_some_and(|d| d.mtime == mtime && d.size == size);
let fast_path = match st.disk() {
Some(d) if stat_matches && d.mtime_settled => true,
Some(d)
if stat_matches && d.content_checked_at.elapsed() < DISK_CHECK_DEBOUNCE =>
{
true
}
_ => false,
};
(st.uri.clone(), st.version, fast_path)
})
else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
if fast_path {
return Ok(Decision::unchanged(uri, current_version));
}
let (fresh, ..) = self.read_to_string_checked(path).await?;
let snap = DiskSync {
mtime,
size,
mtime_settled: mtime_settled(mtime, read_at),
content_checked_at: Instant::now(),
};
let Some(unchanged) = lock_std(&self.documents)
.get(path)
.map(|st| fresh == st.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, path: &Path) -> Result<Decision> {
let read_at = SystemTime::now();
let (content, mtime, size) = self.read_to_string_checked(path).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, path: &Path) -> Result<(fs::File, std::fs::Metadata)> {
let owned = path.to_path_buf();
let opened = tokio::task::spawn_blocking(move || RegularFile::open(&owned))
.await
.map_err(|source| Error::TaskFailed {
task: BackgroundTask::FileOpen,
source,
})?;
let (file, meta) = opened
.map_err(|e| match e {
OpenRegularFileError::Io(source) => Error::FileIo {
path: path.to_path_buf(),
source,
},
OpenRegularFileError::NotRegular => Error::NotARegularFile(path.to_path_buf()),
})?
.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,
path: &Path,
) -> Result<(String, Option<SystemTime>, u64)> {
let (file, meta) = self.open_checked(path).await?;
let mtime = meta.modified().ok();
let size = meta.len();
let content = self.read_string_bounded(path, file, size).await?;
Ok((content, mtime, size))
}
pub(crate) async fn read_line_checked(
&self,
path: &Path,
line: u32,
budget: u64,
) -> Result<LineRead> {
let Ok((file, _meta)) = self.open_checked(path).await else {
return Ok(LineRead {
text: None,
bytes_read: OPEN_FAILURE_CHARGE_BYTES,
});
};
let max = self.limits.max_file_size;
let cap = 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 sync_phase(
&self,
path: &Path,
server: &ServerId,
lsp_client: &LspClient,
decision: Decision,
generation: u64,
) -> Result<Uri> {
let Decision {
uri,
target_version,
fresh_content,
snap,
} = decision;
let Some(synced_version) = lock_std(&self.documents)
.get(path)
.map(|st| st.synced_version(server))
else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
let up_to_date = synced_version.is_some_and(|v| v >= target_version);
let is_first_open = synced_version.is_none();
if up_to_date {
return Ok(uri);
}
let Some((language_id, text)) = lock_std(&self.documents).get(path).map(|st| {
let text = fresh_content
.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 closed = if self.take_pending_close(path, server) {
lsp_client
.notify_typed::<DidCloseTextDocumentNotification>(DidCloseTextDocumentParams {
text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
})
.await
.inspect_err(|_| self.restore_pending_close(path, &uri, server, generation))
} else {
Ok(())
};
match closed {
Ok(()) => {
lsp_client
.notify_typed::<DidOpenTextDocumentNotification>(
DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: String::from(language_id).into(),
version: target_version.get(),
text,
},
},
)
.await
}
Err(err) => Err(err),
}
} else {
lsp_client
.notify_typed::<DidChangeTextDocumentNotification>(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
version: target_version.get(),
text_document_identifier: lsp_types::TextDocumentIdentifier {
uri: uri.clone(),
},
},
content_changes: vec![
TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
lsp_types::TextDocumentContentChangeWholeDocument { text },
),
],
})
.await
};
if let Err(err) = notify_result {
let first_ever_sync = lock_std(&self.documents)
.get(path)
.is_some_and(DocumentState::has_never_synced);
if is_first_open && first_ever_sync {
lock_std(&self.documents).remove(path);
}
return Err(err);
}
let mut documents = lock_std(&self.documents);
let Some(st) = documents.get_mut(path) else {
return Err(Error::DocumentNotFound(path.to_path_buf()));
};
if let Some(fresh) = fresh_content {
st.commit_reload(target_version, fresh, snap);
}
if self.generation(server) == generation {
st.mark_synced(server.clone(), target_version);
}
drop(documents);
Ok(uri)
}
}
#[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);
}
}
}
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!(!tracker.is_open(&path));
tracker
.open(path.clone(), "fn main() {}".to_string())
.unwrap();
assert!(tracker.is_open(&path));
assert_eq!(tracker.len(), 1);
let state = tracker.get(&path).unwrap();
assert_eq!(state.version().get(), 1);
assert_eq!(state.language_id(), "rust");
tracker.close(&path);
assert!(!tracker.is_open(&path));
assert!(tracker.is_empty());
}
#[test]
fn test_forget_server_clears_only_that_servers_synced_version() {
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let path = PathBuf::from("/test/file.rs");
tracker
.open(path.clone(), "fn main() {}".to_string())
.unwrap();
let respawned = ServerId::from_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 = 0;
tracker.forget_server(&server);
let (stale_client, _guard) = fake_lsp_client();
let decision = tracker.disk_phase(&path).await.unwrap();
tracker
.sync_phase(
&path,
&server,
&stale_client,
decision,
generation_before_respawn,
)
.await
.unwrap();
let state = tracker.get(&path).unwrap();
assert!(
state.synced_version(&server).is_none(),
"a sync_phase call that captured a stale generation must not \
commit `synced`, even though its notify against the \
superseded connection succeeded"
);
}
#[tokio::test]
async fn test_ensure_open_commits_when_generation_is_current() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("no_race.rs");
std::fs::write(&path, "fn main() {}").unwrap();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
let server = ServerId::from_static("rust");
let (client, _guard) = fake_lsp_client();
tracker.ensure_open(&path, &server, &client).await.unwrap();
let state = tracker.get(&path).unwrap();
assert_eq!(
state.synced_version(&server).map(DocumentVersion::get),
Some(1)
);
}
fn mark_disk_verified(tracker: &DocumentTracker, path: &Path) {
tracker.set_disk(
path,
DiskSync {
mtime: None,
size: 0,
mtime_settled: false,
content_checked_at: Instant::now(),
},
);
}
#[test]
fn test_document_limit_evicts_lru_instead_of_failing() {
let limits = ResourceLimits {
max_documents: 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!(!tracker.is_open(Path::new("/test/file1.rs")));
assert!(tracker.is_open(Path::new("/test/file2.rs")));
assert!(tracker.is_open(Path::new("/test/file3.rs")));
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.is_open(&locked_path),
"the locked document must not be evicted"
);
assert!(tracker.pending_close_paths().is_empty());
}
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(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
path
}
#[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!(tracker.is_open(&path_a));
assert!(tracker.pending_close_paths().is_empty());
drop(guard);
tracker.open(path_b.clone(), "BBBB".to_string()).unwrap();
assert!(!tracker.is_open(&path_a));
assert!(tracker.is_open(&path_b));
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!(!tracker.is_open(&path_a));
}
#[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.is_open(&first),
"the not-disk-verified document must not be evicted"
);
assert!(tracker.pending_close_paths().is_empty());
}
#[tokio::test]
async fn test_ensure_open_touch_changes_lru_eviction_order() {
let dir = TempDir::new().unwrap();
let path_a = dir.path().join("a.rs");
let path_b = dir.path().join("b.rs");
std::fs::write(&path_a, "AAAA").unwrap();
std::fs::write(&path_b, "BBBB").unwrap();
set_mtime(&path_a, settled_past());
set_mtime(&path_b, settled_past());
let limits = ResourceLimits {
max_documents: 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(&path_a, &server_id, &client)
.await
.unwrap();
tracker
.ensure_open(&path_b, &server_id, &client)
.await
.unwrap();
tracker
.ensure_open(&path_a, &server_id, &client)
.await
.unwrap();
let path_c = dir.path().join("c.rs");
std::fs::write(&path_c, "CCCC").unwrap();
set_mtime(&path_c, settled_past());
tracker
.ensure_open(&path_c, &server_id, &client)
.await
.unwrap();
assert!(
tracker.is_open(&path_a),
"recently re-accessed, must survive"
);
assert!(
!tracker.is_open(&path_b),
"least-recently-used, must be evicted"
);
assert!(tracker.is_open(&path_c));
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!(tracker.is_open(&path1));
assert!(tracker.is_open(&path2));
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!(!tracker.is_open(&path1));
assert!(tracker.is_open(&path2));
}
#[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!(tracker.is_open(&path));
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!(!tracker.is_open(Path::new("/test/file0.rs")));
assert!(tracker.is_open(Path::new("/test/file6.rs")));
}
#[test]
fn test_file_size_exact_boundary() {
let limits = ResourceLimits {
max_documents: 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(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
let uri2 = tracker
.ensure_open(&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(&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(&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(&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(&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(&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(&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(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
std::fs::write(&path, "BBBBBBBB").unwrap();
tracker
.ensure_open(&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(&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(&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(&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(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
std::fs::remove_file(&path).unwrap();
let result = tracker
.ensure_open(&path, &ServerId::from_static("rust"), &client)
.await;
assert_matches!(result, Err(Error::FileIo { .. }));
assert!(tracker.is_open(&path));
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(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
std::fs::write(&path, "x".repeat(100)).unwrap();
let result = tracker
.ensure_open(&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(&path, &ServerId::from_static("rust"), &client)
.await
.unwrap();
assert_eq!(tracker.len(), 1);
std::fs::write(&path, "BBBBBBBB").unwrap();
let result = tracker
.ensure_open(&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(&path, &ServerId::from_static("rust"), ¬ify_will_fail)
.await;
assert!(result.is_err(), "notify failure must propagate as an error");
assert!(
!tracker.is_open(&path),
"a failed didOpen must not leave the document tracked, or the server \
and tracker would stay permanently desynced"
);
}
#[tokio::test]
async fn test_resync_sends_didchange_with_full_replacement_over_the_wire() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
set_mtime(&path, settled_past());
let (client, mut server) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from_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(&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(&path, &id_a, &client_a).await.unwrap();
let mut wire_a = BufReader::new(&mut server_a.write_stdout);
let opened_a = read_framed_message(&mut wire_a).await;
assert_eq!(opened_a["method"], "textDocument/didOpen");
tracker.ensure_open(&path, &id_b, &client_b).await.unwrap();
let mut wire_b = BufReader::new(&mut server_b.write_stdout);
let opened_b = read_framed_message(&mut wire_b).await;
assert_eq!(opened_b["method"], "textDocument/didOpen");
assert_eq!(opened_b["params"]["textDocument"]["version"], 1);
assert_eq!(opened_b["params"]["textDocument"]["text"], "fn main() {}");
}
#[tokio::test(start_paused = true)]
async fn test_ensure_open_second_server_gets_didopen_unchanged_content_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a.rs");
std::fs::write(&path, "fn main() {}").unwrap();
let (client_a, _server_a) = fake_lsp_client();
let (client_b, mut server_b) = fake_lsp_client();
let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
tracker
.ensure_open(&path, &ServerId::from_static("server-a"), &client_a)
.await
.unwrap();
tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
tracker
.ensure_open(&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(&path, &id, &client).await.unwrap();
tracker.ensure_open(&path, &id, &client).await.unwrap();
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
assert_eq!(
tracker
.get(&path)
.unwrap()
.synced_version(&id)
.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(&path, &id_a, &client_a).await.unwrap();
tracker.ensure_open(&path, &id_b, &client_b).await.unwrap();
let client_b_will_fail = client_b.clone();
client_b.shutdown().await.unwrap();
std::fs::write(&path, "fn main() { updated(); }").unwrap();
set_mtime(&path, settled_past());
let result = tracker.ensure_open(&path, &id_b, &client_b_will_fail).await;
assert!(result.is_err(), "B's didChange must fail and propagate");
assert!(tracker.is_open(&path));
assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
assert_eq!(tracker.get(&path).unwrap().version().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(&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(
&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(&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(&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(&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(&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(&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(&path).await.unwrap();
assert_eq!(content.len(), 10);
std::fs::write(&path, "a".repeat(11)).unwrap();
let result = tracker.read_to_string_checked(&path).await;
assert_matches!(
result,
Err(Error::FileSizeLimitExceeded(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(&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(&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(&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 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")]
);
drop(claim);
assert!(tracker.pending_close_paths().is_empty());
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(&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_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(&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(&path, &id_a, &will_fail).await.is_err());
assert_eq!(
pending_servers(&tracker, &path),
HashSet::from([ServerId::from_static("a")])
);
}
}