[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:
Micha Reiser 2025-04-04 08:13:43 +02:00 committed by GitHub
parent bf0306887a
commit a4ba10ff0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 998 additions and 157 deletions

View file

@ -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::String
| TokenKind::Complex
| TokenKind::Float
| 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(

View 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
}
}
}

View file

@ -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;
}
}

View 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")
}
}
}
}

View file

@ -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 {

View file

@ -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 })
}
}

View file

@ -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()
}
}

View file

@ -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();

View file

@ -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;

View file

@ -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()

View 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(&params.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(),
)),
}))
}
}

View file

@ -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,
}
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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,