use std::collections::HashMap;
use std::time::Duration;
use lsp_types::{ProgressParams, ProgressToken};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use crate::config::ServerId;
use crate::lsp::types::ProgressKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexingReset {
Forget,
AwaitReplacement {
within: Duration,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum IndexingState {
#[default]
Unknown,
Loading,
Ready,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexingPolicy {
#[default]
Auto,
Disabled,
}
impl IndexingPolicy {
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde's `skip_serializing_if` requires `fn(&T) -> bool`"
)]
#[must_use]
pub(crate) const fn is_auto(&self) -> bool {
matches!(self, Self::Auto)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IndexingSignalSource {
ServerStatus,
Progress,
}
#[derive(Debug, Clone)]
enum TrackedIndexing {
AwaitingFirstSignal { since: Instant, within: Duration },
Signalled(Signalled),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusReading {
Loading,
Ready,
}
impl From<StatusReading> for IndexingState {
fn from(reading: StatusReading) -> Self {
match reading {
StatusReading::Loading => Self::Loading,
StatusReading::Ready => Self::Ready,
}
}
}
#[derive(Debug, Clone)]
enum Signalled {
ServerStatus {
reading: StatusReading,
last_updated: Instant,
},
Progress(ProgressEntry),
}
#[derive(Debug, Clone)]
struct ProgressEntry {
last_updated: Instant,
open: HashMap<ProgressToken, Instant>,
empty_since: Option<Instant>,
latched: bool,
}
impl ProgressEntry {
fn new() -> Self {
Self {
last_updated: Instant::now(),
open: HashMap::new(),
empty_since: None,
latched: false,
}
}
}
const SERVER_STATUS_METHOD: &str = "experimental/serverStatus";
const QUIESCENT_FIELD: &str = "quiescent";
pub const INDEXING_STALENESS_BOUND: Duration = Duration::from_mins(1);
pub const DEFAULT_INDEXING_READY_TIMEOUT_SECS: u64 = 30;
pub const PROGRESS_SETTLE: Duration = Duration::from_secs(3);
pub const PROGRESS_LATCH_IDLE: Duration = Duration::from_secs(30);
const PROGRESS_OPEN_CAP: usize = 64;
const PROGRESS_TOKEN_MAX_LEN: usize = 256;
#[derive(Debug, Default)]
struct ServerIndexing {
policy: IndexingPolicy,
seen: Option<IndexingSignalSource>,
tracked: Option<TrackedIndexing>,
}
impl ServerIndexing {
fn is_disabled(&self) -> bool {
self.policy == IndexingPolicy::Disabled
}
fn log_wait_ended(&self, server_id: &ServerId) {
if matches!(
self.tracked,
Some(TrackedIndexing::AwaitingFirstSignal { .. })
) {
tracing::debug!(%server_id, "indexing signal ends the pre-signal wait");
}
}
}
#[derive(Debug, Default)]
pub struct IndexingTracker {
servers: HashMap<ServerId, ServerIndexing>,
}
impl IndexingTracker {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn set_policy(&mut self, server_id: ServerId, policy: IndexingPolicy) {
self.servers.entry(server_id).or_default().policy = policy;
}
fn is_disabled(&self, server_id: &ServerId) -> bool {
self.servers
.get(server_id)
.is_some_and(ServerIndexing::is_disabled)
}
pub(crate) fn observe_server_status(
&mut self,
server_id: &ServerId,
method: &str,
params: Option<&serde_json::Value>,
) {
if self.is_disabled(server_id) {
return;
}
if method != SERVER_STATUS_METHOD {
return;
}
let Some(quiescent) = params
.and_then(|p| p.get(QUIESCENT_FIELD))
.and_then(serde_json::Value::as_bool)
else {
return;
};
let server = self.servers.entry(server_id.clone()).or_default();
let sticky_ready = matches!(
server.tracked,
Some(TrackedIndexing::Signalled(Signalled::ServerStatus {
reading: StatusReading::Ready,
..
}))
);
if sticky_ready {
return;
}
server.seen = Some(IndexingSignalSource::ServerStatus);
server.log_wait_ended(server_id);
server.tracked = Some(TrackedIndexing::Signalled(Signalled::ServerStatus {
reading: if quiescent {
StatusReading::Ready
} else {
StatusReading::Loading
},
last_updated: Instant::now(),
}));
}
pub(crate) fn observe_progress(&mut self, server_id: &ServerId, params: &ProgressParams) {
if self.is_disabled(server_id) {
return;
}
let Some(kind) = ProgressKind::from_value(¶ms.value) else {
return;
};
if let Some(server) = self.servers.get(server_id) {
match &server.tracked {
Some(TrackedIndexing::Signalled(Signalled::ServerStatus { .. })) => return,
Some(TrackedIndexing::AwaitingFirstSignal { .. })
if server.seen != Some(IndexingSignalSource::Progress) =>
{
return;
}
_ => {}
}
}
if let ProgressToken::String(token) = ¶ms.token
&& token.len() > PROGRESS_TOKEN_MAX_LEN
{
return;
}
let server = self.servers.entry(server_id.clone()).or_default();
server.seen.get_or_insert(IndexingSignalSource::Progress);
server.log_wait_ended(server_id);
let tracked = server.tracked.get_or_insert_with(|| {
TrackedIndexing::Signalled(Signalled::Progress(ProgressEntry::new()))
});
if matches!(tracked, TrackedIndexing::AwaitingFirstSignal { .. }) {
*tracked = TrackedIndexing::Signalled(Signalled::Progress(ProgressEntry::new()));
}
let TrackedIndexing::Signalled(Signalled::Progress(entry)) = tracked else {
return;
};
if entry.latched {
return;
}
let now = Instant::now();
entry
.open
.retain(|_, began| began.elapsed() < INDEXING_STALENESS_BOUND);
match kind {
ProgressKind::Begin => {
if entry.open.is_empty()
&& entry
.empty_since
.is_some_and(|since| since.elapsed() >= PROGRESS_LATCH_IDLE)
{
entry.latched = true;
entry.open.clear();
return;
}
if entry.open.len() < PROGRESS_OPEN_CAP {
entry.open.insert(params.token.clone(), now);
}
entry.empty_since = None;
}
ProgressKind::End => {
if entry.open.remove(¶ms.token).is_some() && entry.open.is_empty() {
entry.empty_since = Some(now);
}
}
}
entry.last_updated = now;
}
pub(crate) fn state(&self, server_id: &ServerId) -> IndexingState {
let Some(server) = self.servers.get(server_id) else {
return IndexingState::Unknown;
};
if server.is_disabled() {
return IndexingState::Unknown;
}
match &server.tracked {
None => IndexingState::Unknown,
Some(TrackedIndexing::AwaitingFirstSignal { since, within }) => {
if since.elapsed() < *within {
IndexingState::Loading
} else {
IndexingState::Unknown
}
}
Some(TrackedIndexing::Signalled(signalled)) => Self::signalled_state(signalled),
}
}
fn signalled_state(signalled: &Signalled) -> IndexingState {
match signalled {
Signalled::ServerStatus {
reading,
last_updated,
} => {
if *reading == StatusReading::Loading
&& last_updated.elapsed() >= INDEXING_STALENESS_BOUND
{
IndexingState::Unknown
} else {
(*reading).into()
}
}
Signalled::Progress(entry) => Self::progress_state(entry),
}
}
fn progress_state(entry: &ProgressEntry) -> IndexingState {
if entry.latched {
return IndexingState::Ready;
}
if entry.last_updated.elapsed() >= INDEXING_STALENESS_BOUND {
return IndexingState::Unknown;
}
if entry.open.len() >= PROGRESS_OPEN_CAP {
return IndexingState::Unknown;
}
let has_live_open = entry
.open
.values()
.any(|began| began.elapsed() < INDEXING_STALENESS_BOUND);
if has_live_open {
return IndexingState::Loading;
}
if entry.open.is_empty() {
match entry.empty_since {
Some(since) if since.elapsed() < PROGRESS_SETTLE => IndexingState::Loading,
Some(_) => IndexingState::Ready,
None => IndexingState::Unknown,
}
} else {
IndexingState::Unknown
}
}
pub(crate) fn reset(&mut self, server_id: &ServerId, reset: IndexingReset) {
let Some(server) = self.servers.get_mut(server_id) else {
return;
};
server.tracked = None;
let IndexingReset::AwaitReplacement { within } = reset else {
return;
};
if server.is_disabled() || server.seen.is_none() {
return;
}
tracing::debug!(%server_id, ?within, "replacement awaits its first indexing signal");
server.tracked = Some(TrackedIndexing::AwaitingFirstSignal {
since: Instant::now(),
within,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_server() -> ServerId {
ServerId::from_static("test-server")
}
fn progress(kind: &str, token: i32) -> ProgressParams {
ProgressParams {
token: ProgressToken::Int(token),
value: serde_json::json!({ "kind": kind }),
}
}
#[test]
fn test_state_defaults_unknown() {
let tracker = IndexingTracker::new();
assert_eq!(tracker.state(&test_server()), IndexingState::Unknown);
}
#[test]
fn test_observe_server_status_quiescent_false_marks_loading() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
assert_eq!(tracker.state(&server), IndexingState::Loading);
}
#[test]
fn test_observe_server_status_quiescent_true_marks_ready() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": true})),
);
assert_eq!(tracker.state(&server), IndexingState::Ready);
}
#[test]
fn test_observe_server_status_ignores_unrecognized_method() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_server_status(
&server,
"window/logMessage",
Some(&serde_json::json!({"quiescent": false})),
);
assert_eq!(tracker.state(&server), IndexingState::Unknown);
}
#[test]
fn test_malformed_server_status_does_not_mark_source_or_disable_progress() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_server_status(&server, "experimental/serverStatus", None);
assert_eq!(tracker.state(&server), IndexingState::Unknown);
tracker.observe_progress(&server, &progress("begin", 1));
assert_eq!(
tracker.state(&server),
IndexingState::Loading,
"the progress path must still be live after a malformed serverStatus payload"
);
}
#[test]
fn test_observe_server_status_ready_is_sticky() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": true})),
);
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
assert_eq!(tracker.state(&server), IndexingState::Ready);
}
#[test]
fn test_observe_server_status_tracks_servers_independently() {
let mut tracker = IndexingTracker::new();
let rust = ServerId::from_static("rust");
let python = ServerId::from_static("python");
tracker.observe_server_status(
&rust,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
assert_eq!(tracker.state(&rust), IndexingState::Loading);
assert_eq!(tracker.state(&python), IndexingState::Unknown);
}
#[tokio::test(start_paused = true)]
async fn test_state_treats_stale_server_status_loading_as_unknown() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
assert_eq!(tracker.state(&server), IndexingState::Loading);
tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
assert_eq!(tracker.state(&server), IndexingState::Loading);
tokio::time::advance(Duration::from_secs(2)).await;
assert_eq!(tracker.state(&server), IndexingState::Unknown);
}
#[tokio::test(start_paused = true)]
async fn test_observe_server_status_refreshes_staleness_clock() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
assert_eq!(tracker.state(&server), IndexingState::Loading);
}
#[test]
fn test_first_begin_on_fresh_entry_yields_loading_never_ready() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
assert_eq!(tracker.state(&server), IndexingState::Loading);
}
#[tokio::test(start_paused = true)]
async fn test_unmatched_end_does_not_create_empty_since() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tracker.observe_progress(&server, &progress("end", 1));
tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
assert_eq!(
tracker.state(&server),
IndexingState::Ready,
"settled after the real end"
);
tracker.observe_progress(&server, &progress("end", 99));
assert_eq!(
tracker.state(&server),
IndexingState::Ready,
"an unmatched end must not manufacture a fresh settle window"
);
}
#[tokio::test(start_paused = true)]
async fn test_end_as_first_ever_frame_reads_unknown_not_ready() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("end", 1));
assert_eq!(
tracker.state(&server),
IndexingState::Unknown,
"an end with no observed prior begin must not read Ready immediately"
);
tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
assert_eq!(
tracker.state(&server),
IndexingState::Unknown,
"must still read Unknown once the settle window would have expired -- there was \
never a real empty_since to settle from"
);
}
#[tokio::test(start_paused = true)]
async fn test_begin_after_latch_idle_gap_latches_permanently() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tracker.observe_progress(&server, &progress("end", 1));
tokio::time::advance(PROGRESS_LATCH_IDLE + Duration::from_secs(1)).await;
tracker.observe_progress(&server, &progress("begin", 2));
assert_eq!(
tracker.state(&server),
IndexingState::Ready,
"a begin after PROGRESS_LATCH_IDLE of quiet must latch, not re-gate"
);
tracker.observe_progress(&server, &progress("end", 2));
tracker.observe_progress(&server, &progress("begin", 3));
assert_eq!(tracker.state(&server), IndexingState::Ready);
}
#[tokio::test(start_paused = true)]
async fn test_begin_after_mid_gap_regates_and_does_not_latch() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tracker.observe_progress(&server, &progress("end", 1));
tokio::time::advance(Duration::from_secs(10)).await; tracker.observe_progress(&server, &progress("begin", 2));
assert_eq!(
tracker.state(&server),
IndexingState::Loading,
"a mid-gap begin must re-gate the next phase, not latch it away"
);
}
#[tokio::test(start_paused = true)]
async fn test_phase_gap_shorter_than_settle_does_not_read_ready() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tracker.observe_progress(&server, &progress("end", 1));
tokio::time::advance(PROGRESS_SETTLE.checked_sub(Duration::from_secs(1)).unwrap()).await;
assert_eq!(tracker.state(&server), IndexingState::Loading);
}
#[test]
fn test_open_non_empty_reads_loading() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tracker.observe_progress(&server, &progress("begin", 2));
tracker.observe_progress(&server, &progress("end", 1));
assert_eq!(
tracker.state(&server),
IndexingState::Loading,
"token 2 is still open"
);
}
#[test]
fn test_observe_progress_tracks_servers_independently() {
let mut tracker = IndexingTracker::new();
let rust = ServerId::from_static("rust");
let go = ServerId::from_static("go");
tracker.observe_progress(&rust, &progress("begin", 1));
assert_eq!(
tracker.state(&rust),
IndexingState::Loading,
"rust's token is still open"
);
assert_eq!(
tracker.state(&go),
IndexingState::Unknown,
"go has received no progress signal of its own"
);
}
#[tokio::test(start_paused = true)]
async fn test_progress_last_updated_past_staleness_bound_reads_unknown() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tokio::time::advance(INDEXING_STALENESS_BOUND + Duration::from_secs(1)).await;
assert_eq!(
tracker.state(&server),
IndexingState::Unknown,
"a single op with no phase boundary for over a minute must fail open"
);
}
#[tokio::test(start_paused = true)]
async fn test_multiphase_load_past_staleness_bound_with_boundaries_stays_loading() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tokio::time::advance(Duration::from_secs(40)).await;
tracker.observe_progress(&server, &progress("end", 1));
tracker.observe_progress(&server, &progress("begin", 2));
tokio::time::advance(Duration::from_secs(40)).await;
assert_eq!(tracker.state(&server), IndexingState::Loading);
}
#[tokio::test(start_paused = true)]
async fn test_lost_end_self_heals_after_staleness_bound() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tokio::time::advance(Duration::from_secs(30)).await;
tracker.observe_progress(&server, &progress("begin", 2));
tracker.observe_progress(&server, &progress("end", 2));
tokio::time::advance(Duration::from_secs(25)).await; tracker.observe_progress(&server, &progress("begin", 3));
tracker.observe_progress(&server, &progress("end", 3));
assert_eq!(
tracker.state(&server),
IndexingState::Loading,
"token 1 is still within its own staleness window at t=55s"
);
tokio::time::advance(Duration::from_secs(6)).await; assert_eq!(
tracker.state(&server),
IndexingState::Unknown,
"a token whose own begin exceeded INDEXING_STALENESS_BOUND must stop pinning \
Loading even while unrelated later frames keep the entry-wide last_updated clock \
fresh"
);
}
#[test]
fn test_open_token_cap_forces_unknown() {
let mut tracker = IndexingTracker::new();
let server = test_server();
for i in 0..(PROGRESS_OPEN_CAP - 1) {
tracker.observe_progress(&server, &progress("begin", i32::try_from(i).unwrap()));
}
assert_eq!(
tracker.state(&server),
IndexingState::Loading,
"PROGRESS_OPEN_CAP - 1 distinct open tokens must still read Loading"
);
tracker.observe_progress(
&server,
&progress("begin", i32::try_from(PROGRESS_OPEN_CAP - 1).unwrap()),
);
assert_eq!(
tracker.state(&server),
IndexingState::Unknown,
"reaching PROGRESS_OPEN_CAP must fail open instead of staying Loading forever"
);
}
#[test]
fn test_oversized_string_token_is_rejected() {
let mut tracker = IndexingTracker::new();
let server = test_server();
let oversized = ProgressParams {
token: ProgressToken::String("x".repeat(PROGRESS_TOKEN_MAX_LEN + 1)),
value: serde_json::json!({ "kind": "begin" }),
};
tracker.observe_progress(&server, &oversized);
assert_eq!(
tracker.state(&server),
IndexingState::Unknown,
"an oversized token must be dropped outright, not tracked"
);
}
#[test]
fn test_malformed_progress_kind_ignored() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("report", 1));
assert_eq!(tracker.state(&server), IndexingState::Unknown);
let missing_kind = ProgressParams {
token: ProgressToken::Int(1),
value: serde_json::json!({}),
};
tracker.observe_progress(&server, &missing_kind);
assert_eq!(tracker.state(&server), IndexingState::Unknown);
}
#[test]
fn test_disabled_policy_pins_unknown() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.set_policy(server.clone(), IndexingPolicy::Disabled);
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
tracker.observe_progress(&server, &progress("begin", 1));
assert_eq!(tracker.state(&server), IndexingState::Unknown);
}
#[tokio::test(start_paused = true)]
async fn test_progress_settle_then_quiescent_false_yields_loading() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tracker.observe_progress(&server, &progress("end", 1));
tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
assert_eq!(tracker.state(&server), IndexingState::Ready);
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
assert_eq!(tracker.state(&server), IndexingState::Loading);
}
#[test]
fn test_server_status_entry_ignores_later_progress_frames() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_server_status(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": true})),
);
tracker.observe_progress(&server, &progress("begin", 1));
assert_eq!(tracker.state(&server), IndexingState::Ready);
}
#[test]
fn test_reset_clears_entry_but_not_policy() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.set_policy(server.clone(), IndexingPolicy::Disabled);
tracker.observe_progress(&server, &progress("begin", 1));
tracker.reset(&server, IndexingReset::Forget);
assert_eq!(
tracker.state(&server),
IndexingState::Unknown,
"policy stays Disabled, so this reads Unknown regardless of the reset entry"
);
let mut auto_tracker = IndexingTracker::new();
auto_tracker.observe_progress(&server, &progress("begin", 1));
auto_tracker.reset(&server, IndexingReset::Forget);
assert_eq!(auto_tracker.state(&server), IndexingState::Unknown);
}
const WITHIN: Duration = Duration::from_secs(30);
fn server_status(tracker: &mut IndexingTracker, server: &ServerId, quiescent: bool) {
tracker.observe_server_status(
server,
"experimental/serverStatus",
Some(&serde_json::json!({ "quiescent": quiescent })),
);
}
fn await_replacement(tracker: &mut IndexingTracker, server: &ServerId) {
tracker.reset(server, IndexingReset::AwaitReplacement { within: WITHIN });
}
#[test]
fn test_await_replacement_without_history_stays_unknown() {
let mut tracker = IndexingTracker::new();
let server = test_server();
await_replacement(&mut tracker, &server);
assert_eq!(tracker.state(&server), IndexingState::Unknown);
}
#[test]
fn test_await_replacement_after_ready_reads_loading() {
let mut tracker = IndexingTracker::new();
let server = test_server();
server_status(&mut tracker, &server, true);
await_replacement(&mut tracker, &server);
assert_eq!(tracker.state(&server), IndexingState::Loading);
}
#[test]
fn test_await_replacement_after_loading_reads_loading() {
let mut tracker = IndexingTracker::new();
let server = test_server();
server_status(&mut tracker, &server, false);
await_replacement(&mut tracker, &server);
assert_eq!(tracker.state(&server), IndexingState::Loading);
}
#[tokio::test(start_paused = true)]
async fn test_await_replacement_ends_after_within() {
let mut tracker = IndexingTracker::new();
let server = test_server();
server_status(&mut tracker, &server, true);
await_replacement(&mut tracker, &server);
tokio::time::advance(WITHIN.saturating_sub(Duration::from_secs(1))).await;
assert_eq!(tracker.state(&server), IndexingState::Loading);
tokio::time::advance(Duration::from_secs(2)).await;
assert_eq!(tracker.state(&server), IndexingState::Unknown);
}
#[test]
fn test_await_replacement_ready_signal_ends_wait_at_once() {
let mut tracker = IndexingTracker::new();
let server = test_server();
server_status(&mut tracker, &server, true);
await_replacement(&mut tracker, &server);
server_status(&mut tracker, &server, true);
assert_eq!(tracker.state(&server), IndexingState::Ready);
}
#[test]
fn test_await_replacement_loading_signal_is_a_real_loading_entry() {
let mut tracker = IndexingTracker::new();
let server = test_server();
server_status(&mut tracker, &server, true);
await_replacement(&mut tracker, &server);
server_status(&mut tracker, &server, false);
assert_eq!(tracker.state(&server), IndexingState::Loading);
server_status(&mut tracker, &server, true);
assert_eq!(tracker.state(&server), IndexingState::Ready);
}
#[test]
fn test_await_replacement_ignores_progress_of_a_server_status_server() {
let mut tracker = IndexingTracker::new();
let server = test_server();
server_status(&mut tracker, &server, true);
await_replacement(&mut tracker, &server);
tracker.observe_progress(&server, &progress("begin", 1));
tracker.observe_progress(&server, &progress("end", 1));
assert_eq!(
tracker.state(&server),
IndexingState::Loading,
"startup progress must not end the wait for the serverStatus signal"
);
}
#[test]
fn test_await_replacement_progress_server_is_replaced_by_progress_entry() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.observe_progress(&server, &progress("begin", 1));
tracker.observe_progress(&server, &progress("end", 1));
await_replacement(&mut tracker, &server);
assert_eq!(tracker.state(&server), IndexingState::Loading);
tracker.observe_progress(&server, &progress("begin", 2));
assert_eq!(tracker.state(&server), IndexingState::Loading);
tracker.observe_progress(&server, &progress("end", 2));
assert_eq!(
tracker.state(&server),
IndexingState::Loading,
"settle window of the fresh progress entry"
);
}
#[test]
fn test_await_replacement_twice_restarts_the_wait() {
let mut tracker = IndexingTracker::new();
let server = test_server();
server_status(&mut tracker, &server, true);
await_replacement(&mut tracker, &server);
await_replacement(&mut tracker, &server);
assert_eq!(tracker.state(&server), IndexingState::Loading);
tracker.reset(&server, IndexingReset::Forget);
assert_eq!(tracker.state(&server), IndexingState::Unknown);
}
#[test]
fn test_await_replacement_respects_disabled_policy() {
let mut tracker = IndexingTracker::new();
let server = test_server();
tracker.set_policy(server.clone(), IndexingPolicy::Disabled);
await_replacement(&mut tracker, &server);
assert_eq!(tracker.state(&server), IndexingState::Unknown);
}
#[test]
fn test_await_replacement_tracks_servers_independently() {
let mut tracker = IndexingTracker::new();
let rust = ServerId::from_static("rust");
let python = ServerId::from_static("python");
server_status(&mut tracker, &rust, true);
server_status(&mut tracker, &python, true);
await_replacement(&mut tracker, &rust);
assert_eq!(tracker.state(&rust), IndexingState::Loading);
assert_eq!(tracker.state(&python), IndexingState::Ready);
}
mod mock_server {
use std::time::Duration;
use serde_json::json;
use tokio::io::BufReader;
use super::*;
use crate::lsp::LspNotification;
use crate::test_lsp::{
FakeServer, fake_lsp_client_with_lanes, read_framed_message,
spawn_test_pump_over_lanes, write_notification, write_request,
};
async fn assert_create_request_is_acknowledged(server: &mut FakeServer) {
write_request(
&mut server.read_half_stdin,
&json!(7),
"window/workDoneProgress/create",
json!({ "token": "idx" }),
)
.await;
let reply = read_framed_message(&mut BufReader::new(&mut server.write_stdout)).await;
assert_eq!(reply["id"], 7);
assert_eq!(reply.get("result"), Some(&serde_json::Value::Null));
assert!(reply.get("error").is_none(), "unexpected error: {reply}");
}
async fn send_progress(server: &mut FakeServer, kind: &str) {
write_notification(
&mut server.read_half_stdin,
"$/progress",
json!({ "token": "idx", "value": { "kind": kind, "title": "Indexing" } }),
)
.await;
}
fn kind_of(notification: &LspNotification) -> &str {
let LspNotification::Progress(params) = notification else {
panic!("expected a progress notification, got {notification:?}");
};
params.value["kind"].as_str().unwrap()
}
async fn wait_for(
cache: &tokio::sync::Mutex<crate::bridge::NotificationCache>,
id: &ServerId,
expected: IndexingState,
) {
for _ in 0..200 {
if cache.lock().await.indexing_state(id) == expected {
return;
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
panic!("indexing state never became {expected:?}");
}
#[tokio::test]
async fn test_report_frame_never_reaches_the_lifecycle_lane() {
let (_client, mut server, mut lanes) = fake_lsp_client_with_lanes();
assert_create_request_is_acknowledged(&mut server).await;
for kind in ["begin", "report", "end"] {
send_progress(&mut server, kind).await;
}
let first = tokio::time::timeout(Duration::from_secs(5), lanes.lifecycle_rx.recv())
.await
.unwrap()
.unwrap();
let second = tokio::time::timeout(Duration::from_secs(5), lanes.lifecycle_rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!((kind_of(&first), kind_of(&second)), ("begin", "end"));
assert!(lanes.lifecycle_rx.try_recv().is_err());
}
#[tokio::test]
async fn test_progress_sequence_moves_tracker_from_loading_to_ready() {
let (_client, mut server, lanes) = fake_lsp_client_with_lanes();
assert_create_request_is_acknowledged(&mut server).await;
let (cache, _cancel) = spawn_test_pump_over_lanes(lanes);
let id = ServerId::from_static("rust");
tokio::time::pause();
send_progress(&mut server, "begin").await;
wait_for(&cache, &id, IndexingState::Loading).await;
send_progress(&mut server, "report").await;
send_progress(&mut server, "end").await;
tokio::time::sleep(Duration::from_millis(10)).await;
assert_eq!(
cache.lock().await.indexing_state(&id),
IndexingState::Loading,
"still inside the settle window after `end`"
);
tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
assert_eq!(cache.lock().await.indexing_state(&id), IndexingState::Ready);
}
}
}