#![allow(clippy::result_large_err)]
#![allow(clippy::cast_possible_truncation)]
use std::sync::Arc;
use {
reovim_protocol::v2::{
CaptureRequestPayload, DebugCaptureRequest, DebugCaptureResponse, DebugExtensionInfo,
DebugGetCursorRequest, DebugGetCursorResponse, DebugGetExtensionStateRequest,
DebugGetExtensionStateResponse, DebugGetModeRequest, DebugGetModeResponse,
DebugListClientsRequest, DebugListClientsResponse, DebugListExtensionsRequest,
DebugListExtensionsResponse, DebugSendKeysRequest, DebugSendKeysResponse, LogEntry,
LogLevelRequest, LogLevelResponse, LogTailRequest, LogTailResponse, Notification, Position,
SendKeysRequest, debug_service_server::DebugService, input_service_server::InputService,
notification::Payload,
},
tonic::{Request, Response, Status},
};
use crate::{
debug::try_debug_ring,
grpc::{InputServiceImpl, presence::to_proto_client_info},
session::{
ClientId, Session, SessionId, SessionRegistry,
capture::{CaptureError, wait_for_capture},
},
};
use reovim_driver_session::bridges::{BridgeRegistry, ExtensionScope};
#[cfg_attr(coverage_nightly, coverage(off))]
fn require_debug_ring() -> Result<&'static crate::debug::DebugRingBuffer, Status> {
try_debug_ring().ok_or_else(|| Status::unavailable("Debug ring buffer not initialized"))
}
fn capture_error_to_status(err: CaptureError) -> Status {
match err {
CaptureError::NoTuiClient => Status::failed_precondition(err.to_string()),
CaptureError::Timeout => Status::deadline_exceeded(err.to_string()),
CaptureError::Disconnected => Status::unavailable(err.to_string()),
CaptureError::InvalidResponse(msg) => Status::internal(msg),
}
}
pub struct DebugServiceImpl {
sessions: Option<Arc<SessionRegistry>>,
default_session_id: Option<SessionId>,
bridges: Option<Arc<BridgeRegistry>>,
}
impl DebugServiceImpl {
#[must_use]
pub const fn new() -> Self {
Self {
sessions: None,
default_session_id: None,
bridges: None,
}
}
#[must_use]
pub const fn with_sessions(
sessions: Arc<SessionRegistry>,
default_session_id: SessionId,
bridges: Arc<BridgeRegistry>,
) -> Self {
Self {
sessions: Some(sessions),
default_session_id: Some(default_session_id),
bridges: Some(bridges),
}
}
fn get_session(&self) -> Result<Arc<Session>, Status> {
let sessions = self
.sessions
.as_ref()
.ok_or_else(|| Status::unavailable("DebugService not configured with sessions"))?;
let session_id = self
.default_session_id
.as_ref()
.expect("session_id set with sessions");
sessions
.get(session_id)
.ok_or_else(|| Status::not_found("No active session"))
}
fn resolve_target(target_client_id: u64) -> Result<ClientId, Status> {
if target_client_id == 0 {
return Err(Status::invalid_argument(
"target_client_id=0 is reserved; specify the target client ID",
));
}
Ok(ClientId::new(target_client_id as usize))
}
}
impl Default for DebugServiceImpl {
fn default() -> Self {
Self::new()
}
}
#[tonic::async_trait]
impl DebugService for DebugServiceImpl {
async fn log_tail(
&self,
request: Request<LogTailRequest>,
) -> Result<Response<LogTailResponse>, Status> {
let req = request.into_inner();
let count = if req.count == 0 {
50
} else {
req.count as usize
};
let ring = require_debug_ring()?;
let entries = ring.tail(count);
let proto_entries: Vec<LogEntry> = entries
.into_iter()
.filter_map(|e| {
if let Some(ref level) = req.level
&& !e.level.to_string().eq_ignore_ascii_case(level)
{
return None;
}
if let Some(ref target) = req.target
&& !e.target.contains(target)
{
return None;
}
if let Some(ref grep) = req.grep
&& !e.message.to_lowercase().contains(&grep.to_lowercase())
{
return None;
}
Some(LogEntry {
seq: e.seq,
timestamp_us: e.timestamp_us,
level: e.level.to_string(),
target: e.target,
message: e.message,
})
})
.collect();
Ok(Response::new(LogTailResponse {
entries: proto_entries,
}))
}
async fn log_level(
&self,
_request: Request<LogLevelRequest>,
) -> Result<Response<LogLevelResponse>, Status> {
Ok(Response::new(LogLevelResponse {
level: "info".to_string(),
}))
}
#[cfg_attr(coverage_nightly, coverage(off))]
async fn debug_send_keys(
&self,
request: Request<DebugSendKeysRequest>,
) -> Result<Response<DebugSendKeysResponse>, Status> {
let req = request.into_inner();
let client_id = Self::resolve_target(req.target_client_id)?;
let sessions = self
.sessions
.as_ref()
.ok_or_else(|| Status::unavailable("DebugService not configured with sessions"))?;
let session_id = self
.default_session_id
.as_ref()
.expect("session_id set with sessions");
let bridges = self
.bridges
.as_ref()
.ok_or_else(|| Status::unavailable("DebugService not configured with bridges"))?;
let input_service =
InputServiceImpl::new(Arc::clone(sessions), session_id.clone(), Arc::clone(bridges));
let mut send_request = Request::new(SendKeysRequest { keys: req.keys });
send_request.extensions_mut().insert(client_id);
let response = InputService::send_keys(&input_service, send_request).await?;
let inner = response.into_inner();
Ok(Response::new(DebugSendKeysResponse {
ok: inner.ok,
status: inner.status,
}))
}
#[cfg_attr(coverage_nightly, coverage(off))]
async fn debug_capture(
&self,
request: Request<DebugCaptureRequest>,
) -> Result<Response<DebugCaptureResponse>, Status> {
let req = request.into_inner();
let session = self.get_session()?;
let format = if req.format.is_empty() {
"raw_ansi".to_string()
} else {
match req.format.as_str() {
"plain_text" | "raw_ansi" | "cell_grid" => req.format,
_ => {
return Err(Status::invalid_argument(format!(
"Invalid format '{}'. Use 'plain_text', 'raw_ansi', or 'cell_grid'",
req.format
)));
}
}
};
let target_client_id = req.target_client_id;
let (request_id, rx) = session.capture_tracker().create_pending();
let notification = Notification {
event_type: "capture_request".to_string(),
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time before UNIX_EPOCH")
.as_millis() as u64,
payload: Some(Payload::CaptureRequest(CaptureRequestPayload {
request_id,
format: format.clone(),
target_client_id,
})),
};
session.emit_notification(notification);
tracing::debug!(request_id, target_client_id, format, "Sent debug capture_request");
let result = wait_for_capture(rx).await.map_err(|e: CaptureError| {
session.capture_tracker().cancel(request_id);
capture_error_to_status(e)
})?;
Ok(Response::new(DebugCaptureResponse {
width: result.width as u32,
height: result.height as u32,
format: result.format,
content: result.content,
}))
}
async fn debug_get_mode(
&self,
request: Request<DebugGetModeRequest>,
) -> Result<Response<DebugGetModeResponse>, Status> {
let req = request.into_inner();
let client_id = Self::resolve_target(req.target_client_id)?;
let session = self.get_session()?;
let mode = session.client_current_mode(client_id).ok_or_else(|| {
Status::not_found(format!("Client {} not found", req.target_client_id))
})?;
let name = mode.name().to_string();
let display = name.to_uppercase();
let is_insert = name.contains("insert") || name.contains("cmdline");
Ok(Response::new(DebugGetModeResponse {
name,
display,
is_insert,
}))
}
async fn debug_get_cursor(
&self,
request: Request<DebugGetCursorRequest>,
) -> Result<Response<DebugGetCursorResponse>, Status> {
let req = request.into_inner();
let client_id = Self::resolve_target(req.target_client_id)?;
let session = self.get_session()?;
let state = session.client_state(client_id).ok_or_else(|| {
Status::not_found(format!("Client {} not found", req.target_client_id))
})?;
let window = state
.windows
.active()
.ok_or_else(|| Status::not_found("No active window"))?;
let cursor = &window.cursor;
Ok(Response::new(DebugGetCursorResponse {
window_id: window.id.as_usize() as u64,
position: Some(Position {
line: cursor.line as u64,
column: cursor.column as u64,
}),
}))
}
async fn debug_list_clients(
&self,
_request: Request<DebugListClientsRequest>,
) -> Result<Response<DebugListClientsResponse>, Status> {
let session = self.get_session()?;
let clients = session.with_clients(|c| c.values().map(to_proto_client_info).collect());
Ok(Response::new(DebugListClientsResponse { clients }))
}
async fn debug_get_extension_state(
&self,
request: Request<DebugGetExtensionStateRequest>,
) -> Result<Response<DebugGetExtensionStateResponse>, Status> {
let req = request.into_inner();
let client_id = Self::resolve_target(req.target_client_id)?;
let session = self.get_session()?;
let bridges = self
.bridges
.as_ref()
.ok_or_else(|| Status::unavailable("DebugService not configured with bridges"))?;
let bridge = bridges
.get(&req.kind)
.ok_or_else(|| Status::not_found(format!("Unknown extension kind: {}", req.kind)))?;
let (active, snapshot) = match bridge.scope() {
ExtensionScope::Client => session
.with_client_extensions(client_id, |extensions| {
let active = bridge.is_active(extensions);
let snap = bridge.snapshot(extensions);
(active, snap)
})
.unwrap_or((false, None)),
ExtensionScope::Shared => {
session
.with_state(|state| {
let extensions = &state.app.extensions;
let active = bridge.is_active(extensions);
let snap = bridge.snapshot(extensions);
(active, snap)
})
.await
}
};
Ok(Response::new(DebugGetExtensionStateResponse {
active,
data: snapshot.map_or_else(String::new, |v| v.to_string()),
}))
}
async fn debug_list_extensions(
&self,
_request: Request<DebugListExtensionsRequest>,
) -> Result<Response<DebugListExtensionsResponse>, Status> {
let bridges = self
.bridges
.as_ref()
.ok_or_else(|| Status::unavailable("DebugService not configured with bridges"))?;
let extensions = bridges
.kinds()
.into_iter()
.map(|kind| {
let scope = bridges.get(kind).map_or("unknown", |b| match b.scope() {
ExtensionScope::Client => "client",
ExtensionScope::Shared => "shared",
});
DebugExtensionInfo {
kind: kind.to_string(),
scope: scope.to_string(),
}
})
.collect();
Ok(Response::new(DebugListExtensionsResponse { extensions }))
}
}
#[cfg(test)]
#[path = "debug_tests.rs"]
mod tests;