diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 651a6fefb9..ba6d989a2f 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -287,320 +287,601 @@ pub fn update_inlay_link_and_hover_points( window: &mut Window, cx: &mut Context, ) { + let mut go_to_definition_updated = false; + let mut hover_updated = false; + // For inlay hints, we need to use the exact position where the mouse is // But we must clip it to valid bounds to avoid panics let clipped_point = snapshot.clip_point(point_for_position.exact_unclipped, Bias::Left); let hovered_offset = snapshot.display_point_to_inlay_offset(clipped_point, Bias::Left); - let mut go_to_definition_updated = false; - let mut hover_updated = false; - // Get all visible inlay hints let visible_hints = editor.visible_inlay_hints(cx); - // Find if we're hovering over an inlay hint - if let Some(hovered_inlay) = visible_hints.into_iter().find(|inlay| { - // Only process hint inlays - if !matches!(inlay.id, InlayId::Hint(_)) { - return false; - } - - // Check if the hovered position falls within this inlay's display range - let inlay_start = snapshot.anchor_to_inlay_offset(inlay.position); - let inlay_end = InlayOffset(inlay_start.0 + inlay.text.len()); - - hovered_offset >= inlay_start && hovered_offset < inlay_end - }) { - let inlay_hint_cache = editor.inlay_hint_cache(); - let excerpt_id = hovered_inlay.position.excerpt_id; - - // Extract the hint ID from the inlay - if let InlayId::Hint(_hint_id) = hovered_inlay.id { - if let Some(cached_hint) = inlay_hint_cache.hint_by_id(excerpt_id, hovered_inlay.id) { - // Check if we should process this hint for hover - let should_process_hint = match cached_hint.resolve_state { + if point_for_position.column_overshoot_after_line_end == 0 { + let hovered_offset = + snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left); + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let previous_valid_anchor = buffer_snapshot.anchor_at( + point_for_position.previous_valid.to_point(snapshot), + Bias::Left, + ); + let next_valid_anchor = buffer_snapshot.anchor_at( + point_for_position.next_valid.to_point(snapshot), + Bias::Right, + ); + if let Some(hovered_hint) = editor + .visible_inlay_hints(cx) + .into_iter() + .skip_while(|hint| { + hint.position + .cmp(&previous_valid_anchor, &buffer_snapshot) + .is_lt() + }) + .take_while(|hint| { + hint.position + .cmp(&next_valid_anchor, &buffer_snapshot) + .is_le() + }) + .max_by_key(|hint| hint.id) + { + let inlay_hint_cache = editor.inlay_hint_cache(); + let excerpt_id = previous_valid_anchor.excerpt_id; + if let Some(cached_hint) = inlay_hint_cache.hint_by_id(excerpt_id, hovered_hint.id) { + match cached_hint.resolve_state { ResolveState::CanResolve(_, _) => { - // For unresolved hints, spawn resolution - if let Some(buffer_id) = hovered_inlay.position.buffer_id { + if let Some(buffer_id) = previous_valid_anchor.buffer_id { inlay_hint_cache.spawn_hint_resolve( buffer_id, excerpt_id, - hovered_inlay.id, + hovered_hint.id, window, cx, ); } - false // Don't process unresolved hints - } - ResolveState::Resolved => true, - ResolveState::Resolving => false, - }; - if should_process_hint { - let mut extra_shift_left = 0; - let mut extra_shift_right = 0; - if cached_hint.padding_left { - extra_shift_left += 1; - extra_shift_right += 1; - } - if cached_hint.padding_right { - extra_shift_right += 1; - } - match cached_hint.label { - project::InlayHintLabel::String(_) => { - if let Some(tooltip) = cached_hint.tooltip { - hover_popover::hover_at_inlay( - editor, - InlayHover { - tooltip: match tooltip { - InlayHintTooltip::String(text) => HoverBlock { - text, - kind: HoverBlockKind::PlainText, - }, - InlayHintTooltip::MarkupContent(content) => { - HoverBlock { - text: content.value, - kind: content.kind, - } - } - }, - range: InlayHighlight { - inlay: hovered_inlay.id, - inlay_position: hovered_inlay.position, - range: extra_shift_left - ..hovered_inlay.text.len() + extra_shift_right, - }, - }, - window, - cx, - ); - hover_updated = true; + if cached_hint.resolve_state.is_resolved() { + let mut extra_shift_left = 0; + let mut extra_shift_right = 0; + if cached_hint.padding_left { + extra_shift_left += 1; + extra_shift_right += 1; } - } - project::InlayHintLabel::LabelParts(label_parts) => { - // Find the first part with actual hover information (tooltip or location) - let _hint_start = - snapshot.anchor_to_inlay_offset(hovered_inlay.position); - let mut part_offset = 0; - - for part in label_parts { - let part_len = part.value.chars().count(); - - if part.tooltip.is_some() || part.location.is_some() { - // Found the meaningful part - show hover for it - let highlight_start = part_offset + extra_shift_left; - let highlight_end = part_offset + part_len + extra_shift_right; - - let highlight = InlayHighlight { - inlay: hovered_inlay.id, - inlay_position: hovered_inlay.position, - range: highlight_start..highlight_end, - }; - - if let Some(tooltip) = part.tooltip { + if cached_hint.padding_right { + extra_shift_right += 1; + } + match cached_hint.label { + project::InlayHintLabel::String(_) => { + if let Some(tooltip) = cached_hint.tooltip { hover_popover::hover_at_inlay( editor, InlayHover { tooltip: match tooltip { - InlayHintLabelPartTooltip::String(text) => { + InlayHintTooltip::String(text) => HoverBlock { + text, + kind: HoverBlockKind::PlainText, + }, + InlayHintTooltip::MarkupContent(content) => { HoverBlock { - text, - kind: HoverBlockKind::PlainText, + text: content.value, + kind: content.kind, } } - InlayHintLabelPartTooltip::MarkupContent( - content, - ) => HoverBlock { - text: content.value, - kind: content.kind, - }, }, - range: highlight.clone(), + range: InlayHighlight { + inlay: hovered_hint.id, + inlay_position: hovered_hint.position, + range: extra_shift_left + ..hovered_hint.text.len() + + extra_shift_right, + }, }, window, cx, ); hover_updated = true; - } else if let Some((_language_server_id, location)) = - part.location.clone() - { - // When there's no tooltip but we have a location, perform a "Go to Definition" style operation - let filename = location - .uri - .path() - .split('/') - .next_back() - .unwrap_or("unknown") - .to_string(); + } + } + project::InlayHintLabel::LabelParts(label_parts) => { + // Find the first part with actual hover information (tooltip or location) + let _hint_start = + snapshot.anchor_to_inlay_offset(hovered_hint.position); + let mut part_offset = 0; - hover_popover::hover_at_inlay( - editor, - InlayHover { - tooltip: HoverBlock { - text: "Loading documentation...".to_string(), - kind: HoverBlockKind::PlainText, - }, - range: highlight.clone(), - }, - window, - cx, - ); - hover_updated = true; + for part in label_parts { + let part_len = part.value.chars().count(); - // Now perform the "Go to Definition" flow to get hover documentation - if let Some(project) = editor.project.clone() { - let highlight = highlight.clone(); - let hint_value = part.value.clone(); - let location_uri = location.uri.clone(); + if part.tooltip.is_some() || part.location.is_some() { + // Found the meaningful part - show hover for it + let highlight_start = part_offset + extra_shift_left; + let highlight_end = + part_offset + part_len + extra_shift_right; - cx.spawn_in(window, async move |editor, cx| { - async move { - // Small delay to show the loading message first - cx.background_executor() - .timer(std::time::Duration::from_millis(50)) - .await; + let highlight = InlayHighlight { + inlay: hovered_hint.id, + inlay_position: hovered_hint.position, + range: highlight_start..highlight_end, + }; - // Convert LSP URL to file path - let file_path = location.uri.to_file_path() - .map_err(|_| anyhow::anyhow!("Invalid file URL"))?; - - // Open the definition file - let definition_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(file_path, cx) - })? - .await?; - - // Extract documentation directly from the source - let documentation = definition_buffer.update(cx, |buffer, _| { - let line_number = location.range.start.line as usize; - - // Get the text of the buffer - let text = buffer.text(); - let lines: Vec<&str> = text.lines().collect(); - - // Look backwards from the definition line to find doc comments - let mut doc_lines = Vec::new(); - let mut current_line = line_number.saturating_sub(1); - - // Skip any attributes like #[derive(...)] - while current_line > 0 && lines.get(current_line).map_or(false, |line| { - let trimmed = line.trim(); - trimmed.starts_with("#[") || trimmed.is_empty() - }) { - current_line = current_line.saturating_sub(1); - } - - // Collect doc comments - while current_line > 0 { - if let Some(line) = lines.get(current_line) { - let trimmed = line.trim(); - if trimmed.starts_with("///") { - // Remove the /// and any leading space - let doc_text = trimmed.strip_prefix("///").unwrap_or("") - .strip_prefix(" ").unwrap_or_else(|| trimmed.strip_prefix("///").unwrap_or("")); - doc_lines.push(doc_text.to_string()); - } else if !trimmed.is_empty() { - // Stop at the first non-doc, non-empty line - break; + if let Some(tooltip) = part.tooltip { + hover_popover::hover_at_inlay( + editor, + InlayHover { + tooltip: match tooltip { + InlayHintLabelPartTooltip::String(text) => { + HoverBlock { + text, + kind: HoverBlockKind::PlainText, } } - current_line = current_line.saturating_sub(1); + InlayHintLabelPartTooltip::MarkupContent( + content, + ) => HoverBlock { + text: content.value, + kind: content.kind, + }, + }, + range: highlight.clone(), + }, + window, + cx, + ); + hover_updated = true; + } else if let Some((_language_server_id, location)) = + part.location.clone() + { + // When there's no tooltip but we have a location, perform a "Go to Definition" style operation + let filename = location + .uri + .path() + .split('/') + .next_back() + .unwrap_or("unknown") + .to_string(); + + hover_popover::hover_at_inlay( + editor, + InlayHover { + tooltip: HoverBlock { + text: "Loading documentation..." + .to_string(), + kind: HoverBlockKind::PlainText, + }, + range: highlight.clone(), + }, + window, + cx, + ); + hover_updated = true; + + // Now perform the "Go to Definition" flow to get hover documentation + if let Some(project) = editor.project.clone() { + let highlight = highlight.clone(); + let hint_value = part.value.clone(); + let location_uri = location.uri.clone(); + + cx.spawn_in(window, async move |editor, cx| { + async move { + // Small delay to show the loading message first + cx.background_executor() + .timer(std::time::Duration::from_millis(50)) + .await; + + // Convert LSP URL to file path + let file_path = location.uri.to_file_path() + .map_err(|_| anyhow::anyhow!("Invalid file URL"))?; + + // Open the definition file + let definition_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(file_path, cx) + })? + .await?; + + // Extract documentation directly from the source + let documentation = definition_buffer.update(cx, |buffer, _| { + let line_number = location.range.start.line as usize; + + // Get the text of the buffer + let text = buffer.text(); + let lines: Vec<&str> = text.lines().collect(); + + // Look backwards from the definition line to find doc comments + let mut doc_lines = Vec::new(); + let mut current_line = line_number.saturating_sub(1); + + // Skip any attributes like #[derive(...)] + while current_line > 0 && lines.get(current_line).map_or(false, |line| { + let trimmed = line.trim(); + trimmed.starts_with("#[") || trimmed.is_empty() + }) { + current_line = current_line.saturating_sub(1); + } + + // Collect doc comments + while current_line > 0 { + if let Some(line) = lines.get(current_line) { + let trimmed = line.trim(); + if trimmed.starts_with("///") { + // Remove the /// and any leading space + let doc_text = trimmed.strip_prefix("///").unwrap_or("") + .strip_prefix(" ").unwrap_or_else(|| trimmed.strip_prefix("///").unwrap_or("")); + doc_lines.push(doc_text.to_string()); + } else if !trimmed.is_empty() { + // Stop at the first non-doc, non-empty line + break; + } + } + current_line = current_line.saturating_sub(1); + } + + // Reverse to get correct order + doc_lines.reverse(); + + // Also get the actual definition line + let definition = lines.get(line_number) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| hint_value.clone()); + + if doc_lines.is_empty() { + None + } else { + let docs = doc_lines.join("\n"); + Some((definition, docs)) + } + })?; + + if let Some((definition, docs)) = documentation { + // Format as markdown with the definition as a code block + let formatted_docs = format!("```rust\n{}\n```\n\n{}", definition, docs); + + editor.update_in(cx, |editor, window, cx| { + hover_popover::hover_at_inlay( + editor, + InlayHover { + tooltip: HoverBlock { + text: formatted_docs, + kind: HoverBlockKind::Markdown, + }, + range: highlight, + }, + window, + cx, + ); + }).log_err(); + } else { + // Fallback to showing just the location info + let fallback_text = format!( + "{}\n\nDefined in {} at line {}", + hint_value.trim(), + filename, + location.range.start.line + 1 + ); + editor.update_in(cx, |editor, window, cx| { + hover_popover::hover_at_inlay( + editor, + InlayHover { + tooltip: HoverBlock { + text: fallback_text, + kind: HoverBlockKind::PlainText, + }, + range: highlight, + }, + window, + cx, + ); + }).log_err(); + } + + anyhow::Ok(()) } - - // Reverse to get correct order - doc_lines.reverse(); - - // Also get the actual definition line - let definition = lines.get(line_number) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| hint_value.clone()); - - if doc_lines.is_empty() { - None - } else { - let docs = doc_lines.join("\n"); - Some((definition, docs)) - } - })?; - - if let Some((definition, docs)) = documentation { - // Format as markdown with the definition as a code block - let formatted_docs = format!("```rust\n{}\n```\n\n{}", definition, docs); - - editor.update_in(cx, |editor, window, cx| { - hover_popover::hover_at_inlay( - editor, - InlayHover { - tooltip: HoverBlock { - text: formatted_docs, - kind: HoverBlockKind::Markdown, - }, - range: highlight, - }, - window, - cx, - ); - }).log_err(); - } else { - // Fallback to showing just the location info - let fallback_text = format!( - "{}\n\nDefined in {} at line {}", - hint_value.trim(), - filename, - location.range.start.line + 1 - ); - editor.update_in(cx, |editor, window, cx| { - hover_popover::hover_at_inlay( - editor, - InlayHover { - tooltip: HoverBlock { - text: fallback_text, - kind: HoverBlockKind::PlainText, - }, - range: highlight, - }, - window, - cx, - ); - }).log_err(); - } - - anyhow::Ok(()) + .log_err() + .await + }).detach(); } - .log_err() - .await - }).detach(); - } - } + } - if let Some((language_server_id, location)) = &part.location { - if secondary_held - && !editor.has_pending_nonempty_selection() - { - go_to_definition_updated = true; - show_link_definition( - shift_held, + if let Some((language_server_id, location)) = + &part.location + { + if secondary_held + && !editor.has_pending_nonempty_selection() + { + go_to_definition_updated = true; + show_link_definition( + shift_held, + editor, + TriggerPoint::InlayHint( + highlight, + location.clone(), + *language_server_id, + ), + snapshot, + window, + cx, + ); + } + } + + break; + } + + part_offset += part_len; + } + } + }; + } + } + ResolveState::Resolved => { + let mut extra_shift_left = 0; + let mut extra_shift_right = 0; + if cached_hint.padding_left { + extra_shift_left += 1; + extra_shift_right += 1; + } + if cached_hint.padding_right { + extra_shift_right += 1; + } + match cached_hint.label { + project::InlayHintLabel::String(_) => { + if let Some(tooltip) = cached_hint.tooltip { + hover_popover::hover_at_inlay( + editor, + InlayHover { + tooltip: match tooltip { + InlayHintTooltip::String(text) => HoverBlock { + text, + kind: HoverBlockKind::PlainText, + }, + InlayHintTooltip::MarkupContent(content) => { + HoverBlock { + text: content.value, + kind: content.kind, + } + } + }, + range: InlayHighlight { + inlay: hovered_hint.id, + inlay_position: hovered_hint.position, + range: extra_shift_left + ..hovered_hint.text.len() + extra_shift_right, + }, + }, + window, + cx, + ); + hover_updated = true; + } + } + project::InlayHintLabel::LabelParts(label_parts) => { + // VS Code shows hover for the meaningful part regardless of where you hover + // Find the first part with actual hover information (tooltip or location) + let _hint_start = + snapshot.anchor_to_inlay_offset(hovered_hint.position); + let mut part_offset = 0; + + for part in label_parts { + let part_len = part.value.chars().count(); + + if part.tooltip.is_some() || part.location.is_some() { + // Found the meaningful part - show hover for it + let highlight_start = part_offset + extra_shift_left; + let highlight_end = + part_offset + part_len + extra_shift_right; + + let highlight = InlayHighlight { + inlay: hovered_hint.id, + inlay_position: hovered_hint.position, + range: highlight_start..highlight_end, + }; + + if let Some(tooltip) = part.tooltip { + hover_popover::hover_at_inlay( editor, - TriggerPoint::InlayHint( - highlight, - location.clone(), - *language_server_id, - ), - snapshot, + InlayHover { + tooltip: match tooltip { + InlayHintLabelPartTooltip::String(text) => { + HoverBlock { + text, + kind: HoverBlockKind::PlainText, + } + } + InlayHintLabelPartTooltip::MarkupContent( + content, + ) => HoverBlock { + text: content.value, + kind: content.kind, + }, + }, + range: highlight.clone(), + }, window, cx, ); + hover_updated = true; + } else if let Some((_language_server_id, location)) = + part.location.clone() + { + // When there's no tooltip but we have a location, perform a "Go to Definition" style operation + let filename = location + .uri + .path() + .split('/') + .next_back() + .unwrap_or("unknown") + .to_string(); + + hover_popover::hover_at_inlay( + editor, + InlayHover { + tooltip: HoverBlock { + text: "Loading documentation..." + .to_string(), + kind: HoverBlockKind::PlainText, + }, + range: highlight.clone(), + }, + window, + cx, + ); + hover_updated = true; + + // Now perform the "Go to Definition" flow to get hover documentation + if let Some(project) = editor.project.clone() { + let highlight = highlight.clone(); + let hint_value = part.value.clone(); + let location_uri = location.uri.clone(); + + cx.spawn_in(window, async move |editor, cx| { + async move { + // Small delay to show the loading message first + cx.background_executor() + .timer(std::time::Duration::from_millis(50)) + .await; + + // Convert LSP URL to file path + let file_path = location.uri.to_file_path() + .map_err(|_| anyhow::anyhow!("Invalid file URL"))?; + + // Open the definition file + let definition_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(file_path, cx) + })? + .await?; + + // Extract documentation directly from the source + let documentation = definition_buffer.update(cx, |buffer, _| { + let line_number = location.range.start.line as usize; + + // Get the text of the buffer + let text = buffer.text(); + let lines: Vec<&str> = text.lines().collect(); + + // Look backwards from the definition line to find doc comments + let mut doc_lines = Vec::new(); + let mut current_line = line_number.saturating_sub(1); + + // Skip any attributes like #[derive(...)] + while current_line > 0 && lines.get(current_line).map_or(false, |line| { + let trimmed = line.trim(); + trimmed.starts_with("#[") || trimmed.is_empty() + }) { + current_line = current_line.saturating_sub(1); + } + + // Collect doc comments + while current_line > 0 { + if let Some(line) = lines.get(current_line) { + let trimmed = line.trim(); + if trimmed.starts_with("///") { + // Remove the /// and any leading space + let doc_text = trimmed.strip_prefix("///").unwrap_or("") + .strip_prefix(" ").unwrap_or_else(|| trimmed.strip_prefix("///").unwrap_or("")); + doc_lines.push(doc_text.to_string()); + } else if !trimmed.is_empty() { + // Stop at the first non-doc, non-empty line + break; + } + } + current_line = current_line.saturating_sub(1); + } + + // Reverse to get correct order + doc_lines.reverse(); + + // Also get the actual definition line + let definition = lines.get(line_number) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| hint_value.clone()); + + if doc_lines.is_empty() { + None + } else { + let docs = doc_lines.join("\n"); + Some((definition, docs)) + } + })?; + + if let Some((definition, docs)) = documentation { + // Format as markdown with the definition as a code block + let formatted_docs = format!("```rust\n{}\n```\n\n{}", definition, docs); + + editor.update_in(cx, |editor, window, cx| { + hover_popover::hover_at_inlay( + editor, + InlayHover { + tooltip: HoverBlock { + text: formatted_docs, + kind: HoverBlockKind::Markdown, + }, + range: highlight, + }, + window, + cx, + ); + }).log_err(); + } else { + // Fallback to showing just the location info + let fallback_text = format!( + "{}\n\nDefined in {} at line {}", + hint_value.trim(), + filename, + location.range.start.line + 1 + ); + editor.update_in(cx, |editor, window, cx| { + hover_popover::hover_at_inlay( + editor, + InlayHover { + tooltip: HoverBlock { + text: fallback_text, + kind: HoverBlockKind::PlainText, + }, + range: highlight, + }, + window, + cx, + ); + }).log_err(); + } + + anyhow::Ok(()) + } + .log_err() + .await + }).detach(); + } } + + if let Some((language_server_id, location)) = &part.location + { + if secondary_held + && !editor.has_pending_nonempty_selection() + { + go_to_definition_updated = true; + show_link_definition( + shift_held, + editor, + TriggerPoint::InlayHint( + highlight, + location.clone(), + *language_server_id, + ), + snapshot, + window, + cx, + ); + } + } + + break; } - break; + part_offset += part_len; } - - part_offset += part_len; } - } - }; + }; + } } } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 8a41a75d68..fd9352445a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -656,6 +656,12 @@ pub enum ResolveState { Resolving, } +impl ResolveState { + pub fn is_resolved(&self) -> bool { + self == &Self::Resolved + } +} + impl InlayHint { pub fn text(&self) -> String { match &self.label {