debugger: Add support for inline value hints (#28656)

This PR uses Tree Sitter to show inline values while a user is in a
debug session.

We went with Tree Sitter over the LSP Inline Values request because the
LSP request isn't widely supported. Tree Sitter is easy for
languages/extensions to add support to. Tree Sitter can compute the
inline values locally, so there's no need to add extra RPC messages for
Collab. Tree Sitter also gives Zed more control over how we want to show
variables.

There's still more work to be done after this PR, namely differentiating
between global/local scoped variables, but it's a great starting point
to start iteratively improving it.

Release Notes:

- N/A

---------

Co-authored-by: Piotr Osiewicz <peterosiewicz@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Cole Miller <m@cole-miller.net>
Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Kirill <kirill@zed.dev>
This commit is contained in:
Remco Smits 2025-04-24 00:27:27 +02:00 committed by GitHub
parent d095bab8ad
commit 218496744c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 709 additions and 54 deletions

View file

@ -4,7 +4,7 @@
use anyhow::{Result, anyhow};
use breakpoints_in_file::BreakpointsInFile;
use collections::BTreeMap;
use dap::client::SessionId;
use dap::{StackFrameId, client::SessionId};
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Subscription, Task};
use itertools::Itertools;
use language::{Buffer, BufferSnapshot, proto::serialize_anchor as serialize_text_anchor};
@ -17,6 +17,8 @@ use text::{Point, PointUtf16};
use crate::{Project, ProjectPath, buffer_store::BufferStore, worktree_store::WorktreeStore};
use super::session::ThreadId;
mod breakpoints_in_file {
use language::{BufferEvent, DiskState};
@ -108,10 +110,20 @@ enum BreakpointStoreMode {
Local(LocalBreakpointStore),
Remote(RemoteBreakpointStore),
}
#[derive(Clone)]
pub struct ActiveStackFrame {
pub session_id: SessionId,
pub thread_id: ThreadId,
pub stack_frame_id: StackFrameId,
pub path: Arc<Path>,
pub position: text::Anchor,
}
pub struct BreakpointStore {
breakpoints: BTreeMap<Arc<Path>, BreakpointsInFile>,
downstream_client: Option<(AnyProtoClient, u64)>,
active_stack_frame: Option<(SessionId, Arc<Path>, text::Anchor)>,
active_stack_frame: Option<ActiveStackFrame>,
// E.g ssh
mode: BreakpointStoreMode,
}
@ -493,7 +505,7 @@ impl BreakpointStore {
})
}
pub fn active_position(&self) -> Option<&(SessionId, Arc<Path>, text::Anchor)> {
pub fn active_position(&self) -> Option<&ActiveStackFrame> {
self.active_stack_frame.as_ref()
}
@ -504,7 +516,7 @@ impl BreakpointStore {
) {
if let Some(session_id) = session_id {
self.active_stack_frame
.take_if(|(id, _, _)| *id == session_id);
.take_if(|active_stack_frame| active_stack_frame.session_id == session_id);
} else {
self.active_stack_frame.take();
}
@ -513,11 +525,7 @@ impl BreakpointStore {
cx.notify();
}
pub fn set_active_position(
&mut self,
position: (SessionId, Arc<Path>, text::Anchor),
cx: &mut Context<Self>,
) {
pub fn set_active_position(&mut self, position: ActiveStackFrame, cx: &mut Context<Self>) {
self.active_stack_frame = Some(position);
cx.emit(BreakpointStoreEvent::ActiveDebugLineChanged);
cx.notify();

View file

@ -4,7 +4,7 @@ use super::{
session::{self, Session, SessionStateEvent},
};
use crate::{
ProjectEnvironment,
InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState,
project_settings::ProjectSettings,
terminals::{SshCommand, wrap_for_ssh},
worktree_store::WorktreeStore,
@ -15,7 +15,7 @@ use collections::HashMap;
use dap::{
Capabilities, CompletionItem, CompletionsArguments, DapRegistry, EvaluateArguments,
EvaluateArgumentsContext, EvaluateResponse, RunInTerminalRequestArguments, Source,
StartDebuggingRequestArguments,
StackFrameId, StartDebuggingRequestArguments,
adapters::{DapStatus, DebugAdapterBinary, DebugAdapterName, TcpArguments},
client::SessionId,
messages::Message,
@ -28,7 +28,10 @@ use futures::{
};
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task};
use http_client::HttpClient;
use language::{BinaryStatus, LanguageRegistry, LanguageToolchainStore};
use language::{
BinaryStatus, Buffer, LanguageRegistry, LanguageToolchainStore,
language_settings::InlayHintKind, range_from_lsp,
};
use lsp::LanguageServerName;
use node_runtime::NodeRuntime;
@ -763,6 +766,103 @@ impl DapStore {
})
}
pub fn resolve_inline_values(
&self,
session: Entity<Session>,
stack_frame_id: StackFrameId,
buffer_handle: Entity<Buffer>,
inline_values: Vec<lsp::InlineValue>,
cx: &mut Context<Self>,
) -> Task<Result<Vec<InlayHint>>> {
let snapshot = buffer_handle.read(cx).snapshot();
let all_variables = session.read(cx).variables_by_stack_frame_id(stack_frame_id);
cx.spawn(async move |_, cx| {
let mut inlay_hints = Vec::with_capacity(inline_values.len());
for inline_value in inline_values.iter() {
match inline_value {
lsp::InlineValue::Text(text) => {
inlay_hints.push(InlayHint {
position: snapshot.anchor_after(range_from_lsp(text.range).end),
label: InlayHintLabel::String(format!(": {}", text.text)),
kind: Some(InlayHintKind::Type),
padding_left: false,
padding_right: false,
tooltip: None,
resolve_state: ResolveState::Resolved,
});
}
lsp::InlineValue::VariableLookup(variable_lookup) => {
let range = range_from_lsp(variable_lookup.range);
let mut variable_name = variable_lookup
.variable_name
.clone()
.unwrap_or_else(|| snapshot.text_for_range(range.clone()).collect());
if !variable_lookup.case_sensitive_lookup {
variable_name = variable_name.to_ascii_lowercase();
}
let Some(variable) = all_variables.iter().find(|variable| {
if variable_lookup.case_sensitive_lookup {
variable.name == variable_name
} else {
variable.name.to_ascii_lowercase() == variable_name
}
}) else {
continue;
};
inlay_hints.push(InlayHint {
position: snapshot.anchor_after(range.end),
label: InlayHintLabel::String(format!(": {}", variable.value)),
kind: Some(InlayHintKind::Type),
padding_left: false,
padding_right: false,
tooltip: None,
resolve_state: ResolveState::Resolved,
});
}
lsp::InlineValue::EvaluatableExpression(expression) => {
let range = range_from_lsp(expression.range);
let expression = expression
.expression
.clone()
.unwrap_or_else(|| snapshot.text_for_range(range.clone()).collect());
let Ok(eval_task) = session.update(cx, |session, cx| {
session.evaluate(
expression,
Some(EvaluateArgumentsContext::Variables),
Some(stack_frame_id),
None,
cx,
)
}) else {
continue;
};
if let Some(response) = eval_task.await {
inlay_hints.push(InlayHint {
position: snapshot.anchor_after(range.end),
label: InlayHintLabel::String(format!(": {}", response.result)),
kind: Some(InlayHintKind::Type),
padding_left: false,
padding_right: false,
tooltip: None,
resolve_state: ResolveState::Resolved,
});
};
}
};
}
Ok(inlay_hints)
})
}
pub fn shutdown_sessions(&mut self, cx: &mut Context<Self>) -> Task<()> {
let mut tasks = vec![];
for session_id in self.sessions.keys().cloned().collect::<Vec<_>>() {

View file

@ -20,7 +20,9 @@ use dap::{
client::{DebugAdapterClient, SessionId},
messages::{Events, Message},
};
use dap::{ExceptionBreakpointsFilter, ExceptionFilterOptions, OutputEventCategory};
use dap::{
EvaluateResponse, ExceptionBreakpointsFilter, ExceptionFilterOptions, OutputEventCategory,
};
use futures::channel::oneshot;
use futures::{FutureExt, future::Shared};
use gpui::{
@ -649,6 +651,7 @@ pub enum SessionEvent {
StackTrace,
Variables,
Threads,
InvalidateInlineValue,
CapabilitiesLoaded,
}
@ -1060,6 +1063,7 @@ impl Session {
.map(Into::into)
.filter(|_| !event.preserve_focus_hint.unwrap_or(false)),
));
cx.emit(SessionEvent::InvalidateInlineValue);
cx.notify();
}
@ -1281,6 +1285,10 @@ impl Session {
});
}
pub fn any_stopped_thread(&self) -> bool {
self.thread_states.any_stopped_thread()
}
pub fn thread_status(&self, thread_id: ThreadId) -> ThreadStatus {
self.thread_states.thread_status(thread_id)
}
@ -1802,6 +1810,20 @@ impl Session {
.unwrap_or_default()
}
pub fn variables_by_stack_frame_id(&self, stack_frame_id: StackFrameId) -> Vec<dap::Variable> {
let Some(stack_frame) = self.stack_frames.get(&stack_frame_id) else {
return Vec::new();
};
stack_frame
.scopes
.iter()
.filter_map(|scope| self.variables.get(&scope.variables_reference))
.flatten()
.cloned()
.collect()
}
pub fn variables(
&mut self,
variables_reference: VariableReference,
@ -1867,7 +1889,7 @@ impl Session {
frame_id: Option<u64>,
source: Option<Source>,
cx: &mut Context<Self>,
) {
) -> Task<Option<EvaluateResponse>> {
self.request(
EvaluateCommand {
expression,
@ -1896,7 +1918,6 @@ impl Session {
},
cx,
)
.detach();
}
pub fn location(
@ -1915,6 +1936,7 @@ impl Session {
);
self.locations.get(&reference).cloned()
}
pub fn disconnect_client(&mut self, cx: &mut Context<Self>) {
let command = DisconnectCommand {
restart: Some(false),

View file

@ -41,13 +41,14 @@ use client::{
};
use clock::ReplicaId;
use dap::client::DebugAdapterClient;
use dap::{DapRegistry, client::DebugAdapterClient};
use collections::{BTreeSet, HashMap, HashSet};
use debounced_delay::DebouncedDelay;
use debugger::{
breakpoint_store::BreakpointStore,
breakpoint_store::{ActiveStackFrame, BreakpointStore},
dap_store::{DapStore, DapStoreEvent},
session::Session,
};
pub use environment::ProjectEnvironment;
#[cfg(test)]
@ -63,7 +64,7 @@ use image_store::{ImageItemEvent, ImageStoreEvent};
use ::git::{blame::Blame, status::FileStatus};
use gpui::{
AnyEntity, App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla,
SharedString, Task, WeakEntity, Window,
SharedString, Task, WeakEntity, Window, prelude::FluentBuilder,
};
use itertools::Itertools;
use language::{
@ -1551,6 +1552,15 @@ impl Project {
self.breakpoint_store.clone()
}
pub fn active_debug_session(&self, cx: &App) -> Option<(Entity<Session>, ActiveStackFrame)> {
let active_position = self.breakpoint_store.read(cx).active_position()?;
let session = self
.dap_store
.read(cx)
.session_by_id(active_position.session_id)?;
Some((session, active_position.clone()))
}
pub fn lsp_store(&self) -> Entity<LspStore> {
self.lsp_store.clone()
}
@ -3484,6 +3494,69 @@ impl Project {
})
}
pub fn inline_values(
&mut self,
session: Entity<Session>,
active_stack_frame: ActiveStackFrame,
buffer_handle: Entity<Buffer>,
range: Range<text::Anchor>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<Vec<InlayHint>>> {
let snapshot = buffer_handle.read(cx).snapshot();
let Some(inline_value_provider) = session
.read(cx)
.adapter_name()
.map(|adapter_name| DapRegistry::global(cx).adapter(&adapter_name))
.and_then(|adapter| adapter.inline_value_provider())
else {
return Task::ready(Err(anyhow::anyhow!("Inline value provider not found")));
};
let mut text_objects =
snapshot.text_object_ranges(range.end..range.end, Default::default());
let text_object_range = text_objects
.find(|(_, obj)| matches!(obj, language::TextObject::AroundFunction))
.map(|(range, _)| snapshot.anchor_before(range.start))
.unwrap_or(range.start);
let variable_ranges = snapshot
.debug_variable_ranges(
text_object_range.to_offset(&snapshot)..range.end.to_offset(&snapshot),
)
.filter_map(|range| {
let lsp_range = language::range_to_lsp(
range.range.start.to_point_utf16(&snapshot)
..range.range.end.to_point_utf16(&snapshot),
)
.ok()?;
Some((
snapshot.text_for_range(range.range).collect::<String>(),
lsp_range,
))
})
.collect::<Vec<_>>();
let inline_values = inline_value_provider.provide(variable_ranges);
let stack_frame_id = active_stack_frame.stack_frame_id;
cx.spawn(async move |this, cx| {
this.update(cx, |project, cx| {
project.dap_store().update(cx, |dap_store, cx| {
dap_store.resolve_inline_values(
session,
stack_frame_id,
buffer_handle,
inline_values,
cx,
)
})
})?
.await
})
}
pub fn inlay_hints<T: ToOffset>(
&mut self,
buffer_handle: Entity<Buffer>,