mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-03 18:28:56 +00:00
[red-knot] Add basic on-hover to playground and LSP (#17057)
## Summary
Implement a very basic hover in the playground and LSP.
It's basic, because it only shows the type on-hover. Most other LSPs
also show:
* The signature of the symbol beneath the cursor. E.g. `class
Test(a:int, b:int)` (we want something like
54f7da25f9/packages/pyright-internal/src/analyzer/typeEvaluator.ts (L21929-L22129)
)
* The symbols' documentation
* Do more fancy markdown rendering
I decided to defer these features for now because it requires new
semantic APIs (similar to *goto definition*), and investing in fancy
rendering only makes sense once we have the relevant data.
Closes [#16826](https://github.com/astral-sh/ruff/issues/16826)
## Test Plan
https://github.com/user-attachments/assets/044aeee4-58ad-4d4e-9e26-ac2a712026be
https://github.com/user-attachments/assets/4a1f4004-2982-4cf2-9dfd-cb8b84ff2ecb
This commit is contained in:
parent
bf0306887a
commit
a4ba10ff0a
15 changed files with 998 additions and 157 deletions
|
@ -1,5 +1,6 @@
|
|||
use crate::find_node::covering_node;
|
||||
use crate::{Db, HasNavigationTargets, NavigationTargets, RangedValue};
|
||||
use red_knot_python_semantic::types::Type;
|
||||
use red_knot_python_semantic::{HasType, SemanticModel};
|
||||
use ruff_db::files::{File, FileRange};
|
||||
use ruff_db::parsed::{parsed_module, ParsedModule};
|
||||
|
@ -16,31 +17,7 @@ pub fn goto_type_definition(
|
|||
let goto_target = find_goto_target(parsed, offset)?;
|
||||
|
||||
let model = SemanticModel::new(db.upcast(), file);
|
||||
|
||||
let ty = match goto_target {
|
||||
GotoTarget::Expression(expression) => expression.inferred_type(&model),
|
||||
GotoTarget::FunctionDef(function) => function.inferred_type(&model),
|
||||
GotoTarget::ClassDef(class) => class.inferred_type(&model),
|
||||
GotoTarget::Parameter(parameter) => parameter.inferred_type(&model),
|
||||
GotoTarget::Alias(alias) => alias.inferred_type(&model),
|
||||
GotoTarget::ExceptVariable(except) => except.inferred_type(&model),
|
||||
GotoTarget::KeywordArgument(argument) => {
|
||||
// TODO: Pyright resolves the declared type of the matching parameter. This seems more accurate
|
||||
// than using the inferred value.
|
||||
argument.value.inferred_type(&model)
|
||||
}
|
||||
// TODO: Support identifier targets
|
||||
GotoTarget::PatternMatchRest(_)
|
||||
| GotoTarget::PatternKeywordArgument(_)
|
||||
| GotoTarget::PatternMatchStarName(_)
|
||||
| GotoTarget::PatternMatchAsName(_)
|
||||
| GotoTarget::ImportedModule(_)
|
||||
| GotoTarget::TypeParamTypeVarName(_)
|
||||
| GotoTarget::TypeParamParamSpecName(_)
|
||||
| GotoTarget::TypeParamTypeVarTupleName(_)
|
||||
| GotoTarget::NonLocal { .. }
|
||||
| GotoTarget::Globals { .. } => return None,
|
||||
};
|
||||
let ty = goto_target.inferred_type(&model)?;
|
||||
|
||||
tracing::debug!(
|
||||
"Inferred type of covering node is {}",
|
||||
|
@ -149,6 +126,37 @@ pub(crate) enum GotoTarget<'a> {
|
|||
},
|
||||
}
|
||||
|
||||
impl<'db> GotoTarget<'db> {
|
||||
pub(crate) fn inferred_type(self, model: &SemanticModel<'db>) -> Option<Type<'db>> {
|
||||
let ty = match self {
|
||||
GotoTarget::Expression(expression) => expression.inferred_type(model),
|
||||
GotoTarget::FunctionDef(function) => function.inferred_type(model),
|
||||
GotoTarget::ClassDef(class) => class.inferred_type(model),
|
||||
GotoTarget::Parameter(parameter) => parameter.inferred_type(model),
|
||||
GotoTarget::Alias(alias) => alias.inferred_type(model),
|
||||
GotoTarget::ExceptVariable(except) => except.inferred_type(model),
|
||||
GotoTarget::KeywordArgument(argument) => {
|
||||
// TODO: Pyright resolves the declared type of the matching parameter. This seems more accurate
|
||||
// than using the inferred value.
|
||||
argument.value.inferred_type(model)
|
||||
}
|
||||
// TODO: Support identifier targets
|
||||
GotoTarget::PatternMatchRest(_)
|
||||
| GotoTarget::PatternKeywordArgument(_)
|
||||
| GotoTarget::PatternMatchStarName(_)
|
||||
| GotoTarget::PatternMatchAsName(_)
|
||||
| GotoTarget::ImportedModule(_)
|
||||
| GotoTarget::TypeParamTypeVarName(_)
|
||||
| GotoTarget::TypeParamParamSpecName(_)
|
||||
| GotoTarget::TypeParamTypeVarTupleName(_)
|
||||
| GotoTarget::NonLocal { .. }
|
||||
| GotoTarget::Globals { .. } => return None,
|
||||
};
|
||||
|
||||
Some(ty)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranged for GotoTarget<'_> {
|
||||
fn range(&self) -> TextRange {
|
||||
match self {
|
||||
|
@ -174,16 +182,18 @@ impl Ranged for GotoTarget<'_> {
|
|||
}
|
||||
|
||||
pub(crate) fn find_goto_target(parsed: &ParsedModule, offset: TextSize) -> Option<GotoTarget> {
|
||||
let token = parsed.tokens().at_offset(offset).find(|token| {
|
||||
matches!(
|
||||
token.kind(),
|
||||
let token = parsed
|
||||
.tokens()
|
||||
.at_offset(offset)
|
||||
.max_by_key(|token| match token.kind() {
|
||||
TokenKind::Name
|
||||
| TokenKind::String
|
||||
| TokenKind::Complex
|
||||
| TokenKind::Float
|
||||
| TokenKind::Int
|
||||
)
|
||||
| TokenKind::Int => 1,
|
||||
_ => 0,
|
||||
})?;
|
||||
|
||||
let covering_node = covering_node(parsed.syntax().into(), token.range())
|
||||
.find(|node| node.is_identifier() || node.is_expression())
|
||||
.ok()?;
|
||||
|
@ -241,27 +251,18 @@ pub(crate) fn find_goto_target(parsed: &ParsedModule, offset: TextSize) -> Optio
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::db::tests::TestDb;
|
||||
use crate::tests::{cursor_test, CursorTest, IntoDiagnostic};
|
||||
use crate::{goto_type_definition, NavigationTarget};
|
||||
use insta::assert_snapshot;
|
||||
use insta::internals::SettingsBindDropGuard;
|
||||
use red_knot_python_semantic::{
|
||||
Program, ProgramSettings, PythonPath, PythonPlatform, SearchPathSettings,
|
||||
};
|
||||
use ruff_db::diagnostic::{
|
||||
Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, LintName,
|
||||
Severity, Span, SubDiagnostic,
|
||||
Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span, SubDiagnostic,
|
||||
};
|
||||
use ruff_db::files::{system_path_to_file, File, FileRange};
|
||||
use ruff_db::system::{DbWithWritableSystem, SystemPath, SystemPathBuf};
|
||||
use ruff_python_ast::PythonVersion;
|
||||
use ruff_text_size::{Ranged, TextSize};
|
||||
use ruff_db::files::FileRange;
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_class_type() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
class Test: ...
|
||||
|
||||
|
@ -291,7 +292,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_function_type() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def foo(a, b): ...
|
||||
|
||||
|
@ -323,7 +324,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_union_type() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
|
||||
def foo(a, b): ...
|
||||
|
@ -380,7 +381,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_module() {
|
||||
let mut test = goto_test(
|
||||
let mut test = cursor_test(
|
||||
r#"
|
||||
import lib
|
||||
|
||||
|
@ -410,7 +411,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_literal_type() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
a: str = "test"
|
||||
|
||||
|
@ -441,7 +442,7 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_literal_node() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
a: str = "te<CURSOR>st"
|
||||
"#,
|
||||
|
@ -469,7 +470,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_type_var_type() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
type Alias[T: int = bool] = list[T<CURSOR>]
|
||||
"#,
|
||||
|
@ -493,7 +494,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_type_param_spec() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
type Alias[**P = [int, str]] = Callable[P<CURSOR>, int]
|
||||
"#,
|
||||
|
@ -507,7 +508,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_type_var_tuple() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
type Alias[*Ts = ()] = tuple[*Ts<CURSOR>]
|
||||
"#,
|
||||
|
@ -521,7 +522,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_on_keyword_argument() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def test(a: str): ...
|
||||
|
||||
|
@ -553,7 +554,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_on_incorrectly_typed_keyword_argument() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def test(a: str): ...
|
||||
|
||||
|
@ -588,7 +589,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn goto_type_on_kwargs() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def f(name: str): ...
|
||||
|
||||
|
@ -622,14 +623,13 @@ f(**kwargs<CURSOR>)
|
|||
|
||||
#[test]
|
||||
fn goto_type_of_expression_with_builtin() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def foo(a: str):
|
||||
a<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
// FIXME: This should go to `str`
|
||||
assert_snapshot!(test.goto_type_definition(), @r###"
|
||||
info: lint:goto-type-definition: Type definition
|
||||
--> stdlib/builtins.pyi:443:7
|
||||
|
@ -653,7 +653,7 @@ f(**kwargs<CURSOR>)
|
|||
|
||||
#[test]
|
||||
fn goto_type_definition_cursor_between_object_and_attribute() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
class X:
|
||||
def foo(a, b): ...
|
||||
|
@ -685,7 +685,7 @@ f(**kwargs<CURSOR>)
|
|||
|
||||
#[test]
|
||||
fn goto_between_call_arguments() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def foo(a, b): ...
|
||||
|
||||
|
@ -715,7 +715,7 @@ f(**kwargs<CURSOR>)
|
|||
|
||||
#[test]
|
||||
fn goto_type_narrowing() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def foo(a: str | None, b):
|
||||
if a is not None:
|
||||
|
@ -747,7 +747,7 @@ f(**kwargs<CURSOR>)
|
|||
|
||||
#[test]
|
||||
fn goto_type_none() {
|
||||
let test = goto_test(
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def foo(a: str | None, b):
|
||||
a<CURSOR>
|
||||
|
@ -792,65 +792,7 @@ f(**kwargs<CURSOR>)
|
|||
");
|
||||
}
|
||||
|
||||
fn goto_test(source: &str) -> GotoTest {
|
||||
let mut db = TestDb::new();
|
||||
let cursor_offset = source.find("<CURSOR>").expect(
|
||||
"`source`` should contain a `<CURSOR>` marker, indicating the position of the cursor.",
|
||||
);
|
||||
|
||||
let mut content = source[..cursor_offset].to_string();
|
||||
content.push_str(&source[cursor_offset + "<CURSOR>".len()..]);
|
||||
|
||||
db.write_file("main.py", &content)
|
||||
.expect("write to memory file system to be successful");
|
||||
|
||||
let file = system_path_to_file(&db, "main.py").expect("newly written file to existing");
|
||||
|
||||
Program::from_settings(
|
||||
&db,
|
||||
ProgramSettings {
|
||||
python_version: PythonVersion::latest(),
|
||||
python_platform: PythonPlatform::default(),
|
||||
search_paths: SearchPathSettings {
|
||||
extra_paths: vec![],
|
||||
src_roots: vec![SystemPathBuf::from("/")],
|
||||
custom_typeshed: None,
|
||||
python_path: PythonPath::KnownSitePackages(vec![]),
|
||||
},
|
||||
},
|
||||
)
|
||||
.expect("Default settings to be valid");
|
||||
|
||||
let mut insta_settings = insta::Settings::clone_current();
|
||||
insta_settings.add_filter(r#"\\(\w\w|\s|\.|")"#, "/$1");
|
||||
|
||||
let insta_settings_guard = insta_settings.bind_to_scope();
|
||||
|
||||
GotoTest {
|
||||
db,
|
||||
cursor_offset: TextSize::try_from(cursor_offset)
|
||||
.expect("source to be smaller than 4GB"),
|
||||
file,
|
||||
_insta_settings_guard: insta_settings_guard,
|
||||
}
|
||||
}
|
||||
|
||||
struct GotoTest {
|
||||
db: TestDb,
|
||||
cursor_offset: TextSize,
|
||||
file: File,
|
||||
_insta_settings_guard: SettingsBindDropGuard,
|
||||
}
|
||||
|
||||
impl GotoTest {
|
||||
fn write_file(
|
||||
&mut self,
|
||||
path: impl AsRef<SystemPath>,
|
||||
content: &str,
|
||||
) -> std::io::Result<()> {
|
||||
self.db.write_file(path, content)
|
||||
}
|
||||
|
||||
impl CursorTest {
|
||||
fn goto_type_definition(&self) -> String {
|
||||
let Some(targets) = goto_type_definition(&self.db, self.file, self.cursor_offset)
|
||||
else {
|
||||
|
@ -861,19 +803,12 @@ f(**kwargs<CURSOR>)
|
|||
return "No type definitions found".to_string();
|
||||
}
|
||||
|
||||
let mut buf = String::new();
|
||||
|
||||
let source = targets.range;
|
||||
|
||||
let config = DisplayDiagnosticConfig::default()
|
||||
.color(false)
|
||||
.format(DiagnosticFormat::Full);
|
||||
for target in &*targets {
|
||||
let diag = GotoTypeDefinitionDiagnostic::new(source, target).into_diagnostic();
|
||||
write!(buf, "{}", diag.display(&self.db, &config)).unwrap();
|
||||
}
|
||||
|
||||
buf
|
||||
self.render_diagnostics(
|
||||
targets
|
||||
.into_iter()
|
||||
.map(|target| GotoTypeDefinitionDiagnostic::new(source, &target)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -889,7 +824,9 @@ f(**kwargs<CURSOR>)
|
|||
target: FileRange::new(target.file(), target.focus_range()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoDiagnostic for GotoTypeDefinitionDiagnostic {
|
||||
fn into_diagnostic(self) -> Diagnostic {
|
||||
let mut source = SubDiagnostic::new(Severity::Info, "Source");
|
||||
source.annotate(Annotation::primary(
|
||||
|
|
545
crates/red_knot_ide/src/hover.rs
Normal file
545
crates/red_knot_ide/src/hover.rs
Normal file
|
@ -0,0 +1,545 @@
|
|||
use crate::goto::find_goto_target;
|
||||
use crate::{Db, MarkupKind, RangedValue};
|
||||
use red_knot_python_semantic::types::Type;
|
||||
use red_knot_python_semantic::SemanticModel;
|
||||
use ruff_db::files::{File, FileRange};
|
||||
use ruff_db::parsed::parsed_module;
|
||||
use ruff_text_size::{Ranged, TextSize};
|
||||
use std::fmt;
|
||||
use std::fmt::Formatter;
|
||||
|
||||
pub fn hover(db: &dyn Db, file: File, offset: TextSize) -> Option<RangedValue<Hover>> {
|
||||
let parsed = parsed_module(db.upcast(), file);
|
||||
let goto_target = find_goto_target(parsed, offset)?;
|
||||
|
||||
let model = SemanticModel::new(db.upcast(), file);
|
||||
let ty = goto_target.inferred_type(&model)?;
|
||||
|
||||
tracing::debug!(
|
||||
"Inferred type of covering node is {}",
|
||||
ty.display(db.upcast())
|
||||
);
|
||||
|
||||
// TODO: Add documentation of the symbol (not the type's definition).
|
||||
// TODO: Render the symbol's signature instead of just its type.
|
||||
let contents = vec![HoverContent::Type(ty)];
|
||||
|
||||
Some(RangedValue {
|
||||
range: FileRange::new(file, goto_target.range()),
|
||||
value: Hover { contents },
|
||||
})
|
||||
}
|
||||
|
||||
pub struct Hover<'db> {
|
||||
contents: Vec<HoverContent<'db>>,
|
||||
}
|
||||
|
||||
impl<'db> Hover<'db> {
|
||||
/// Renders the hover to a string using the specified markup kind.
|
||||
pub const fn display<'a>(&'a self, db: &'a dyn Db, kind: MarkupKind) -> DisplayHover<'a> {
|
||||
DisplayHover {
|
||||
db,
|
||||
hover: self,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn iter(&self) -> std::slice::Iter<'_, HoverContent<'db>> {
|
||||
self.contents.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'db> IntoIterator for Hover<'db> {
|
||||
type Item = HoverContent<'db>;
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.contents.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'db> IntoIterator for &'a Hover<'db> {
|
||||
type Item = &'a HoverContent<'db>;
|
||||
type IntoIter = std::slice::Iter<'a, HoverContent<'db>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DisplayHover<'a> {
|
||||
db: &'a dyn Db,
|
||||
hover: &'a Hover<'a>,
|
||||
kind: MarkupKind,
|
||||
}
|
||||
|
||||
impl fmt::Display for DisplayHover<'_> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
let mut first = true;
|
||||
for content in &self.hover.contents {
|
||||
if !first {
|
||||
self.kind.horizontal_line().fmt(f)?;
|
||||
}
|
||||
|
||||
content.display(self.db, self.kind).fmt(f)?;
|
||||
first = false;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum HoverContent<'db> {
|
||||
Type(Type<'db>),
|
||||
}
|
||||
|
||||
impl<'db> HoverContent<'db> {
|
||||
fn display(&self, db: &'db dyn Db, kind: MarkupKind) -> DisplayHoverContent<'_, 'db> {
|
||||
DisplayHoverContent {
|
||||
db,
|
||||
content: self,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DisplayHoverContent<'a, 'db> {
|
||||
db: &'db dyn Db,
|
||||
content: &'a HoverContent<'db>,
|
||||
kind: MarkupKind,
|
||||
}
|
||||
|
||||
impl fmt::Display for DisplayHoverContent<'_, '_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.content {
|
||||
HoverContent::Type(ty) => self
|
||||
.kind
|
||||
.fenced_code_block(ty.display(self.db.upcast()), "text")
|
||||
.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tests::{cursor_test, CursorTest};
|
||||
use crate::{hover, MarkupKind};
|
||||
use insta::assert_snapshot;
|
||||
use ruff_db::diagnostic::{
|
||||
Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, LintName,
|
||||
Severity, Span,
|
||||
};
|
||||
use ruff_text_size::{Ranged, TextRange};
|
||||
|
||||
#[test]
|
||||
fn hover_basic() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
a = 10
|
||||
|
||||
a<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
Literal[10]
|
||||
---------------------------------------------
|
||||
```text
|
||||
Literal[10]
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:4:9
|
||||
|
|
||||
2 | a = 10
|
||||
3 |
|
||||
4 | a
|
||||
| ^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_member() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
class Foo:
|
||||
a: int = 10
|
||||
|
||||
def __init__(a: int, b: str):
|
||||
self.a = a
|
||||
self.b: str = b
|
||||
|
||||
foo = Foo()
|
||||
foo.<CURSOR>a
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
int
|
||||
---------------------------------------------
|
||||
```text
|
||||
int
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:10:9
|
||||
|
|
||||
9 | foo = Foo()
|
||||
10 | foo.a
|
||||
| ^^^^-
|
||||
| | |
|
||||
| | Cursor offset
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_function_typed_variable() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def foo(a, b): ...
|
||||
|
||||
foo<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
Literal[foo]
|
||||
---------------------------------------------
|
||||
```text
|
||||
Literal[foo]
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:4:13
|
||||
|
|
||||
2 | def foo(a, b): ...
|
||||
3 |
|
||||
4 | foo
|
||||
| ^^^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_binary_expression() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def foo(a: int, b: int, c: int):
|
||||
a + b ==<CURSOR> c
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
bool
|
||||
---------------------------------------------
|
||||
```text
|
||||
bool
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:3:17
|
||||
|
|
||||
2 | def foo(a: int, b: int, c: int):
|
||||
3 | a + b == c
|
||||
| ^^^^^^^^-^
|
||||
| | |
|
||||
| | Cursor offset
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_keyword_parameter() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def test(a: int): ...
|
||||
|
||||
test(a<CURSOR>= 123)
|
||||
"#,
|
||||
);
|
||||
|
||||
// TODO: This should reveal `int` because the user hovers over the parameter and not the value.
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
Literal[123]
|
||||
---------------------------------------------
|
||||
```text
|
||||
Literal[123]
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:4:18
|
||||
|
|
||||
2 | def test(a: int): ...
|
||||
3 |
|
||||
4 | test(a= 123)
|
||||
| ^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_union() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
|
||||
def foo(a, b): ...
|
||||
|
||||
def bar(a, b): ...
|
||||
|
||||
if random.choice([True, False]):
|
||||
a = foo
|
||||
else:
|
||||
a = bar
|
||||
|
||||
a<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
Literal[foo, bar]
|
||||
---------------------------------------------
|
||||
```text
|
||||
Literal[foo, bar]
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:12:13
|
||||
|
|
||||
10 | a = bar
|
||||
11 |
|
||||
12 | a
|
||||
| ^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_module() {
|
||||
let mut test = cursor_test(
|
||||
r#"
|
||||
import lib
|
||||
|
||||
li<CURSOR>b
|
||||
"#,
|
||||
);
|
||||
|
||||
test.write_file("lib.py", "a = 10").unwrap();
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
<module 'lib'>
|
||||
---------------------------------------------
|
||||
```text
|
||||
<module 'lib'>
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:4:13
|
||||
|
|
||||
2 | import lib
|
||||
3 |
|
||||
4 | lib
|
||||
| ^^-
|
||||
| | |
|
||||
| | Cursor offset
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_type_of_expression_with_type_var_type() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
type Alias[T: int = bool] = list[T<CURSOR>]
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
T
|
||||
---------------------------------------------
|
||||
```text
|
||||
T
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:2:46
|
||||
|
|
||||
2 | type Alias[T: int = bool] = list[T]
|
||||
| ^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_type_of_expression_with_type_param_spec() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
type Alias[**P = [int, str]] = Callable[P<CURSOR>, int]
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
@Todo
|
||||
---------------------------------------------
|
||||
```text
|
||||
@Todo
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:2:53
|
||||
|
|
||||
2 | type Alias[**P = [int, str]] = Callable[P, int]
|
||||
| ^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_type_of_expression_with_type_var_tuple() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
type Alias[*Ts = ()] = tuple[*Ts<CURSOR>]
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
@Todo
|
||||
---------------------------------------------
|
||||
```text
|
||||
@Todo
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:2:43
|
||||
|
|
||||
2 | type Alias[*Ts = ()] = tuple[*Ts]
|
||||
| ^^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_class_member_declaration() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
class Foo:
|
||||
a<CURSOR>: int
|
||||
"#,
|
||||
);
|
||||
|
||||
// TODO: This should be int and not `Never`, https://github.com/astral-sh/ruff/issues/17122
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
Never
|
||||
---------------------------------------------
|
||||
```text
|
||||
Never
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:3:13
|
||||
|
|
||||
2 | class Foo:
|
||||
3 | a: int
|
||||
| ^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_type_narrowing() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def foo(a: str | None, b):
|
||||
if a is not None:
|
||||
print(a<CURSOR>)
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_snapshot!(test.hover(), @r"
|
||||
str
|
||||
---------------------------------------------
|
||||
```text
|
||||
str
|
||||
```
|
||||
---------------------------------------------
|
||||
info: lint:hover: Hovered content is
|
||||
--> /main.py:4:27
|
||||
|
|
||||
2 | def foo(a: str | None, b):
|
||||
3 | if a is not None:
|
||||
4 | print(a)
|
||||
| ^- Cursor offset
|
||||
| |
|
||||
| source
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
impl CursorTest {
|
||||
fn hover(&self) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
let Some(hover) = hover(&self.db, self.file, self.cursor_offset) else {
|
||||
return "Hover provided no content".to_string();
|
||||
};
|
||||
|
||||
let source = hover.range;
|
||||
|
||||
let mut buf = String::new();
|
||||
|
||||
write!(
|
||||
&mut buf,
|
||||
"{plaintext}{line}{markdown}{line}",
|
||||
plaintext = hover.display(&self.db, MarkupKind::PlainText),
|
||||
line = MarkupKind::PlainText.horizontal_line(),
|
||||
markdown = hover.display(&self.db, MarkupKind::Markdown),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = DisplayDiagnosticConfig::default()
|
||||
.color(false)
|
||||
.format(DiagnosticFormat::Full);
|
||||
|
||||
let mut diagnostic = Diagnostic::new(
|
||||
DiagnosticId::Lint(LintName::of("hover")),
|
||||
Severity::Info,
|
||||
"Hovered content is",
|
||||
);
|
||||
diagnostic.annotate(
|
||||
Annotation::primary(Span::from(source.file()).with_range(source.range()))
|
||||
.message("source"),
|
||||
);
|
||||
diagnostic.annotate(
|
||||
Annotation::secondary(
|
||||
Span::from(source.file()).with_range(TextRange::empty(self.cursor_offset)),
|
||||
)
|
||||
.message("Cursor offset"),
|
||||
);
|
||||
|
||||
write!(buf, "{}", diagnostic.display(&self.db, &config)).unwrap();
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,15 @@
|
|||
mod db;
|
||||
mod find_node;
|
||||
mod goto;
|
||||
mod hover;
|
||||
mod markup;
|
||||
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
pub use db::Db;
|
||||
pub use goto::goto_type_definition;
|
||||
pub use hover::hover;
|
||||
pub use markup::MarkupKind;
|
||||
use red_knot_python_semantic::types::{
|
||||
Class, ClassBase, ClassLiteralType, FunctionType, InstanceType, IntersectionType,
|
||||
KnownInstanceType, ModuleLiteralType, Type,
|
||||
|
@ -272,3 +276,103 @@ impl HasNavigationTargets for IntersectionType<'_> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::tests::TestDb;
|
||||
use insta::internals::SettingsBindDropGuard;
|
||||
use red_knot_python_semantic::{
|
||||
Program, ProgramSettings, PythonPath, PythonPlatform, SearchPathSettings,
|
||||
};
|
||||
use ruff_db::diagnostic::{Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig};
|
||||
use ruff_db::files::{system_path_to_file, File};
|
||||
use ruff_db::system::{DbWithWritableSystem, SystemPath, SystemPathBuf};
|
||||
use ruff_python_ast::PythonVersion;
|
||||
use ruff_text_size::TextSize;
|
||||
|
||||
pub(super) fn cursor_test(source: &str) -> CursorTest {
|
||||
let mut db = TestDb::new();
|
||||
let cursor_offset = source.find("<CURSOR>").expect(
|
||||
"`source`` should contain a `<CURSOR>` marker, indicating the position of the cursor.",
|
||||
);
|
||||
|
||||
let mut content = source[..cursor_offset].to_string();
|
||||
content.push_str(&source[cursor_offset + "<CURSOR>".len()..]);
|
||||
|
||||
db.write_file("main.py", &content)
|
||||
.expect("write to memory file system to be successful");
|
||||
|
||||
let file = system_path_to_file(&db, "main.py").expect("newly written file to existing");
|
||||
|
||||
Program::from_settings(
|
||||
&db,
|
||||
ProgramSettings {
|
||||
python_version: PythonVersion::latest(),
|
||||
python_platform: PythonPlatform::default(),
|
||||
search_paths: SearchPathSettings {
|
||||
extra_paths: vec![],
|
||||
src_roots: vec![SystemPathBuf::from("/")],
|
||||
custom_typeshed: None,
|
||||
python_path: PythonPath::KnownSitePackages(vec![]),
|
||||
},
|
||||
},
|
||||
)
|
||||
.expect("Default settings to be valid");
|
||||
|
||||
let mut insta_settings = insta::Settings::clone_current();
|
||||
insta_settings.add_filter(r#"\\(\w\w|\s|\.|")"#, "/$1");
|
||||
// Filter out TODO types because they are different between debug and release builds.
|
||||
insta_settings.add_filter(r"@Todo\(.+\)", "@Todo");
|
||||
|
||||
let insta_settings_guard = insta_settings.bind_to_scope();
|
||||
|
||||
CursorTest {
|
||||
db,
|
||||
cursor_offset: TextSize::try_from(cursor_offset)
|
||||
.expect("source to be smaller than 4GB"),
|
||||
file,
|
||||
_insta_settings_guard: insta_settings_guard,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct CursorTest {
|
||||
pub(super) db: TestDb,
|
||||
pub(super) cursor_offset: TextSize,
|
||||
pub(super) file: File,
|
||||
_insta_settings_guard: SettingsBindDropGuard,
|
||||
}
|
||||
|
||||
impl CursorTest {
|
||||
pub(super) fn write_file(
|
||||
&mut self,
|
||||
path: impl AsRef<SystemPath>,
|
||||
content: &str,
|
||||
) -> std::io::Result<()> {
|
||||
self.db.write_file(path, content)
|
||||
}
|
||||
|
||||
pub(super) fn render_diagnostics<I, D>(&self, diagnostics: I) -> String
|
||||
where
|
||||
I: IntoIterator<Item = D>,
|
||||
D: IntoDiagnostic,
|
||||
{
|
||||
use std::fmt::Write;
|
||||
|
||||
let mut buf = String::new();
|
||||
|
||||
let config = DisplayDiagnosticConfig::default()
|
||||
.color(false)
|
||||
.format(DiagnosticFormat::Full);
|
||||
for diagnostic in diagnostics {
|
||||
let diag = diagnostic.into_diagnostic();
|
||||
write!(buf, "{}", diag.display(&self.db, &config)).unwrap();
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) trait IntoDiagnostic {
|
||||
fn into_diagnostic(self) -> Diagnostic;
|
||||
}
|
||||
}
|
||||
|
|
66
crates/red_knot_ide/src/markup.rs
Normal file
66
crates/red_knot_ide/src/markup.rs
Normal file
|
@ -0,0 +1,66 @@
|
|||
use std::fmt;
|
||||
use std::fmt::Formatter;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum MarkupKind {
|
||||
PlainText,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
impl MarkupKind {
|
||||
pub(crate) const fn fenced_code_block<T>(self, code: T, language: &str) -> FencedCodeBlock<T>
|
||||
where
|
||||
T: fmt::Display,
|
||||
{
|
||||
FencedCodeBlock {
|
||||
language,
|
||||
code,
|
||||
kind: self,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn horizontal_line(self) -> HorizontalLine {
|
||||
HorizontalLine { kind: self }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FencedCodeBlock<'a, T> {
|
||||
language: &'a str,
|
||||
code: T,
|
||||
kind: MarkupKind,
|
||||
}
|
||||
|
||||
impl<T> fmt::Display for FencedCodeBlock<'_, T>
|
||||
where
|
||||
T: fmt::Display,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.kind {
|
||||
MarkupKind::PlainText => self.code.fmt(f),
|
||||
MarkupKind::Markdown => write!(
|
||||
f,
|
||||
"```{language}\n{code}\n```",
|
||||
language = self.language,
|
||||
code = self.code
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(crate) struct HorizontalLine {
|
||||
kind: MarkupKind,
|
||||
}
|
||||
|
||||
impl fmt::Display for HorizontalLine {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self.kind {
|
||||
MarkupKind::PlainText => {
|
||||
f.write_str("\n---------------------------------------------\n")
|
||||
}
|
||||
MarkupKind::Markdown => {
|
||||
write!(f, "\n---\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -38,14 +38,14 @@ impl ToLink for NavigationTarget {
|
|||
let source = source_text(db.upcast(), file);
|
||||
let index = line_index(db.upcast(), file);
|
||||
|
||||
let target_range = self.full_range().to_range(&source, &index, encoding);
|
||||
let selection_range = self.focus_range().to_range(&source, &index, encoding);
|
||||
let target_range = self.full_range().to_lsp_range(&source, &index, encoding);
|
||||
let selection_range = self.focus_range().to_lsp_range(&source, &index, encoding);
|
||||
|
||||
let src = src.map(|src| {
|
||||
let source = source_text(db.upcast(), src.file());
|
||||
let index = line_index(db.upcast(), src.file());
|
||||
|
||||
src.range().to_range(&source, &index, encoding)
|
||||
src.range().to_lsp_range(&source, &index, encoding)
|
||||
});
|
||||
|
||||
Some(lsp_types::LocationLink {
|
||||
|
|
|
@ -28,7 +28,12 @@ pub(crate) trait PositionExt {
|
|||
}
|
||||
|
||||
pub(crate) trait ToRangeExt {
|
||||
fn to_range(&self, text: &str, index: &LineIndex, encoding: PositionEncoding) -> types::Range;
|
||||
fn to_lsp_range(
|
||||
&self,
|
||||
text: &str,
|
||||
index: &LineIndex,
|
||||
encoding: PositionEncoding,
|
||||
) -> types::Range;
|
||||
fn to_notebook_range(
|
||||
&self,
|
||||
text: &str,
|
||||
|
@ -92,7 +97,12 @@ impl RangeExt for lsp_types::Range {
|
|||
}
|
||||
|
||||
impl ToRangeExt for TextRange {
|
||||
fn to_range(&self, text: &str, index: &LineIndex, encoding: PositionEncoding) -> types::Range {
|
||||
fn to_lsp_range(
|
||||
&self,
|
||||
text: &str,
|
||||
index: &LineIndex,
|
||||
encoding: PositionEncoding,
|
||||
) -> types::Range {
|
||||
types::Range {
|
||||
start: source_location_to_position(&offset_to_source_location(
|
||||
self.start(),
|
||||
|
@ -222,7 +232,7 @@ impl FileRangeExt for FileRange {
|
|||
let source = source_text(db.upcast(), file);
|
||||
let line_index = line_index(db.upcast(), file);
|
||||
|
||||
let range = self.range().to_range(&source, &line_index, encoding);
|
||||
let range = self.range().to_lsp_range(&source, &line_index, encoding);
|
||||
Some(Location { uri, range })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,9 +7,9 @@ use std::panic::PanicInfo;
|
|||
|
||||
use lsp_server::Message;
|
||||
use lsp_types::{
|
||||
ClientCapabilities, DiagnosticOptions, DiagnosticServerCapabilities, MessageType,
|
||||
ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
|
||||
TypeDefinitionProviderCapability, Url,
|
||||
ClientCapabilities, DiagnosticOptions, DiagnosticServerCapabilities, HoverProviderCapability,
|
||||
MessageType, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind,
|
||||
TextDocumentSyncOptions, TypeDefinitionProviderCapability, Url,
|
||||
};
|
||||
|
||||
use self::connection::{Connection, ConnectionInitializer};
|
||||
|
@ -221,6 +221,7 @@ impl Server {
|
|||
},
|
||||
)),
|
||||
type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
|
||||
hover_provider: Some(HoverProviderCapability::Simple(true)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,10 @@ pub(super) fn request<'a>(req: server::Request) -> Task<'a> {
|
|||
BackgroundSchedule::LatencySensitive,
|
||||
)
|
||||
}
|
||||
request::HoverRequestHandler::METHOD => background_request_task::<
|
||||
request::HoverRequestHandler,
|
||||
>(req, BackgroundSchedule::LatencySensitive),
|
||||
|
||||
method => {
|
||||
tracing::warn!("Received request {method} which does not have a handler");
|
||||
return Task::nothing();
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
mod diagnostic;
|
||||
mod goto_type_definition;
|
||||
mod hover;
|
||||
|
||||
pub(super) use diagnostic::DocumentDiagnosticRequestHandler;
|
||||
pub(super) use goto_type_definition::GotoTypeDefinitionRequestHandler;
|
||||
pub(super) use hover::HoverRequestHandler;
|
||||
|
|
|
@ -80,7 +80,7 @@ fn to_lsp_diagnostic(
|
|||
let source = source_text(db.upcast(), span.file());
|
||||
|
||||
span.range()
|
||||
.map(|range| range.to_range(&source, &index, encoding))
|
||||
.map(|range| range.to_lsp_range(&source, &index, encoding))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Range::default()
|
||||
|
|
71
crates/red_knot_server/src/server/api/requests/hover.rs
Normal file
71
crates/red_knot_server/src/server/api/requests/hover.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use crate::document::{PositionExt, ToRangeExt};
|
||||
use crate::server::api::traits::{BackgroundDocumentRequestHandler, RequestHandler};
|
||||
use crate::server::client::Notifier;
|
||||
use crate::DocumentSnapshot;
|
||||
use lsp_types::request::HoverRequest;
|
||||
use lsp_types::{HoverContents, HoverParams, MarkupContent, Url};
|
||||
use red_knot_ide::{hover, MarkupKind};
|
||||
use red_knot_project::ProjectDatabase;
|
||||
use ruff_db::source::{line_index, source_text};
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
pub(crate) struct HoverRequestHandler;
|
||||
|
||||
impl RequestHandler for HoverRequestHandler {
|
||||
type RequestType = HoverRequest;
|
||||
}
|
||||
|
||||
impl BackgroundDocumentRequestHandler for HoverRequestHandler {
|
||||
fn document_url(params: &HoverParams) -> Cow<Url> {
|
||||
Cow::Borrowed(¶ms.text_document_position_params.text_document.uri)
|
||||
}
|
||||
|
||||
fn run_with_snapshot(
|
||||
snapshot: DocumentSnapshot,
|
||||
db: ProjectDatabase,
|
||||
_notifier: Notifier,
|
||||
params: HoverParams,
|
||||
) -> crate::server::Result<Option<lsp_types::Hover>> {
|
||||
let Some(file) = snapshot.file(&db) else {
|
||||
tracing::debug!("Failed to resolve file for {:?}", params);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let source = source_text(&db, file);
|
||||
let line_index = line_index(&db, file);
|
||||
let offset = params.text_document_position_params.position.to_text_size(
|
||||
&source,
|
||||
&line_index,
|
||||
snapshot.encoding(),
|
||||
);
|
||||
|
||||
let Some(range_info) = hover(&db, file, offset) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (markup_kind, lsp_markup_kind) = if snapshot
|
||||
.resolved_client_capabilities()
|
||||
.hover_prefer_markdown
|
||||
{
|
||||
(MarkupKind::Markdown, lsp_types::MarkupKind::Markdown)
|
||||
} else {
|
||||
(MarkupKind::PlainText, lsp_types::MarkupKind::PlainText)
|
||||
};
|
||||
|
||||
let contents = range_info.display(&db, markup_kind).to_string();
|
||||
|
||||
Ok(Some(lsp_types::Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: lsp_markup_kind,
|
||||
value: contents,
|
||||
}),
|
||||
range: Some(range_info.file_range().range().to_lsp_range(
|
||||
&source,
|
||||
&line_index,
|
||||
snapshot.encoding(),
|
||||
)),
|
||||
}))
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
use lsp_types::ClientCapabilities;
|
||||
use lsp_types::{ClientCapabilities, MarkupKind};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
|
@ -10,6 +10,9 @@ pub(crate) struct ResolvedClientCapabilities {
|
|||
pub(crate) pull_diagnostics: bool,
|
||||
/// Whether `textDocument.typeDefinition.linkSupport` is `true`
|
||||
pub(crate) type_definition_link_support: bool,
|
||||
|
||||
/// `true`, if the first markup kind in `textDocument.hover.contentFormat` is `Markdown`
|
||||
pub(crate) hover_prefer_markdown: bool,
|
||||
}
|
||||
|
||||
impl ResolvedClientCapabilities {
|
||||
|
@ -63,6 +66,21 @@ impl ResolvedClientCapabilities {
|
|||
.and_then(|text_document| text_document.diagnostic.as_ref())
|
||||
.is_some();
|
||||
|
||||
let hover_prefer_markdown = client_capabilities
|
||||
.text_document
|
||||
.as_ref()
|
||||
.and_then(|text_document| {
|
||||
Some(
|
||||
text_document
|
||||
.hover
|
||||
.as_ref()?
|
||||
.content_format
|
||||
.as_ref()?
|
||||
.contains(&MarkupKind::Markdown),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Self {
|
||||
code_action_deferred_edit_resolution: code_action_data_support
|
||||
&& code_action_edit_resolution,
|
||||
|
@ -71,6 +89,7 @@ impl ResolvedClientCapabilities {
|
|||
workspace_refresh,
|
||||
pull_diagnostics,
|
||||
type_definition_link_support: declaration_link_support,
|
||||
hover_prefer_markdown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use std::any::Any;
|
||||
|
||||
use js_sys::{Error, JsString};
|
||||
use red_knot_ide::goto_type_definition;
|
||||
use red_knot_ide::{goto_type_definition, hover, MarkupKind};
|
||||
use red_knot_project::metadata::options::Options;
|
||||
use red_knot_project::metadata::value::ValueSource;
|
||||
use red_knot_project::watch::{ChangeEvent, ChangedKind, CreatedKind, DeletedKind};
|
||||
|
@ -243,6 +243,37 @@ impl Workspace {
|
|||
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hover(&self, file_id: &FileHandle, position: Position) -> Result<Option<Hover>, Error> {
|
||||
let source = source_text(&self.db, file_id.file);
|
||||
let index = line_index(&self.db, file_id.file);
|
||||
|
||||
let offset = index.offset(
|
||||
OneIndexed::new(position.line).ok_or_else(|| {
|
||||
Error::new("Invalid value `0` for `position.line`. The line index is 1-indexed.")
|
||||
})?,
|
||||
OneIndexed::new(position.column).ok_or_else(|| {
|
||||
Error::new(
|
||||
"Invalid value `0` for `position.column`. The column index is 1-indexed.",
|
||||
)
|
||||
})?,
|
||||
&source,
|
||||
);
|
||||
|
||||
let Some(range_info) = hover(&self.db, file_id.file, offset) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let source_range = Range::from_text_range(range_info.file_range().range(), &index, &source);
|
||||
|
||||
Ok(Some(Hover {
|
||||
markdown: range_info
|
||||
.display(&self.db, MarkupKind::Markdown)
|
||||
.to_string(),
|
||||
range: source_range,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_error<E: std::fmt::Display>(err: E) -> Error {
|
||||
|
@ -330,15 +361,11 @@ pub struct Range {
|
|||
}
|
||||
|
||||
impl Range {
|
||||
fn from_file_range(db: &dyn Db, range: FileRange) -> Self {
|
||||
let index = line_index(db.upcast(), range.file());
|
||||
let source = source_text(db.upcast(), range.file());
|
||||
fn from_file_range(db: &dyn Db, file_range: FileRange) -> Self {
|
||||
let index = line_index(db.upcast(), file_range.file());
|
||||
let source = source_text(db.upcast(), file_range.file());
|
||||
|
||||
let text_range = range.range();
|
||||
|
||||
let start = index.source_location(text_range.start(), &source);
|
||||
let end = index.source_location(text_range.end(), &source);
|
||||
Self::from((start, end))
|
||||
Self::from_text_range(file_range.range(), &index, &source)
|
||||
}
|
||||
|
||||
fn from_text_range(
|
||||
|
@ -437,6 +464,14 @@ pub struct LocationLink {
|
|||
pub origin_selection_range: Option<Range>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Hover {
|
||||
#[wasm_bindgen(getter_with_clone)]
|
||||
pub markdown: String,
|
||||
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WasmSystem {
|
||||
fs: MemoryFileSystem,
|
||||
|
|
|
@ -690,6 +690,7 @@ impl Deref for Tokens {
|
|||
}
|
||||
|
||||
/// A token that encloses a given offset or ends exactly at it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TokenAt {
|
||||
/// There's no token at the given offset
|
||||
None,
|
||||
|
|
|
@ -56,6 +56,7 @@ export default function Editor({
|
|||
const disposable = useRef<{
|
||||
typeDefinition: IDisposable;
|
||||
editorOpener: IDisposable;
|
||||
hover: IDisposable;
|
||||
} | null>(null);
|
||||
const playgroundState = useRef<PlaygroundServerProps>({
|
||||
monaco: null,
|
||||
|
@ -93,6 +94,7 @@ export default function Editor({
|
|||
return () => {
|
||||
disposable.current?.typeDefinition.dispose();
|
||||
disposable.current?.editorOpener.dispose();
|
||||
disposable.current?.hover.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -103,12 +105,17 @@ export default function Editor({
|
|||
const server = new PlaygroundServer(playgroundState);
|
||||
const typeDefinitionDisposable =
|
||||
instance.languages.registerTypeDefinitionProvider("python", server);
|
||||
const hoverDisposable = instance.languages.registerHoverProvider(
|
||||
"python",
|
||||
server,
|
||||
);
|
||||
const editorOpenerDisposable =
|
||||
instance.editor.registerEditorOpener(server);
|
||||
|
||||
disposable.current = {
|
||||
typeDefinition: typeDefinitionDisposable,
|
||||
editorOpener: editorOpenerDisposable,
|
||||
hover: hoverDisposable,
|
||||
};
|
||||
|
||||
playgroundState.current.monaco = instance;
|
||||
|
@ -191,10 +198,49 @@ interface PlaygroundServerProps {
|
|||
}
|
||||
|
||||
class PlaygroundServer
|
||||
implements languages.TypeDefinitionProvider, editor.ICodeEditorOpener
|
||||
implements
|
||||
languages.TypeDefinitionProvider,
|
||||
editor.ICodeEditorOpener,
|
||||
languages.HoverProvider
|
||||
{
|
||||
constructor(private props: RefObject<PlaygroundServerProps>) {}
|
||||
|
||||
provideHover(
|
||||
model: editor.ITextModel,
|
||||
position: Position,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_token: CancellationToken,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
context?: languages.HoverContext<languages.Hover> | undefined,
|
||||
): languages.ProviderResult<languages.Hover> {
|
||||
const workspace = this.props.current.workspace;
|
||||
|
||||
const selectedFile = this.props.current.files.selected;
|
||||
if (selectedFile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedHandle = this.props.current.files.handles[selectedFile];
|
||||
|
||||
if (selectedHandle == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hover = workspace.hover(
|
||||
selectedHandle,
|
||||
new KnotPosition(position.lineNumber, position.column),
|
||||
);
|
||||
|
||||
if (hover == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
range: knotRangeToIRange(hover.range),
|
||||
contents: [{ value: hover.markdown, isTrusted: true }],
|
||||
};
|
||||
}
|
||||
|
||||
provideTypeDefinition(
|
||||
model: editor.ITextModel,
|
||||
position: Position,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue