diff --git a/crates/red_knot_ide/src/goto.rs b/crates/red_knot_ide/src/goto.rs index b747d3891f..a3359d8112 100644 --- a/crates/red_knot_ide/src/goto.rs +++ b/crates/red_knot_ide/src/goto.rs @@ -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> { + 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 { - 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 = "test" "#, @@ -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] "#, @@ -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, 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] "#, @@ -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) #[test] fn goto_type_of_expression_with_builtin() { - let test = goto_test( + let test = cursor_test( r#" def foo(a: str): a "#, ); - // 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) #[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) #[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) #[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) #[test] fn goto_type_none() { - let test = goto_test( + let test = cursor_test( r#" def foo(a: str | None, b): a @@ -792,65 +792,7 @@ f(**kwargs) "); } - fn goto_test(source: &str) -> GotoTest { - let mut db = TestDb::new(); - let cursor_offset = source.find("").expect( - "`source`` should contain a `` marker, indicating the position of the cursor.", - ); - - let mut content = source[..cursor_offset].to_string(); - content.push_str(&source[cursor_offset + "".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, - 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) 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) 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( diff --git a/crates/red_knot_ide/src/hover.rs b/crates/red_knot_ide/src/hover.rs new file mode 100644 index 0000000000..967b23d6e7 --- /dev/null +++ b/crates/red_knot_ide/src/hover.rs @@ -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> { + 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>, +} + +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; + + 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 + "#, + ); + + 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.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 + "#, + ); + + 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 == 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= 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 + "#, + ); + + 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 + + lib + "#, + ); + + test.write_file("lib.py", "a = 10").unwrap(); + + assert_snapshot!(test.hover(), @r" + + --------------------------------------------- + ```text + + ``` + --------------------------------------------- + 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] + "#, + ); + + 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, 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] + "#, + ); + + 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: 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) + "#, + ); + + 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 + } + } +} diff --git a/crates/red_knot_ide/src/lib.rs b/crates/red_knot_ide/src/lib.rs index 39e12d64a8..d3162b2079 100644 --- a/crates/red_knot_ide/src/lib.rs +++ b/crates/red_knot_ide/src/lib.rs @@ -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("").expect( + "`source`` should contain a `` marker, indicating the position of the cursor.", + ); + + let mut content = source[..cursor_offset].to_string(); + content.push_str(&source[cursor_offset + "".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, + content: &str, + ) -> std::io::Result<()> { + self.db.write_file(path, content) + } + + pub(super) fn render_diagnostics(&self, diagnostics: I) -> String + where + I: IntoIterator, + 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; + } +} diff --git a/crates/red_knot_ide/src/markup.rs b/crates/red_knot_ide/src/markup.rs new file mode 100644 index 0000000000..129489eb0b --- /dev/null +++ b/crates/red_knot_ide/src/markup.rs @@ -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(self, code: T, language: &str) -> FencedCodeBlock + 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 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") + } + } + } +} diff --git a/crates/red_knot_server/src/document/location.rs b/crates/red_knot_server/src/document/location.rs index c655b45f31..c2e73fcb24 100644 --- a/crates/red_knot_server/src/document/location.rs +++ b/crates/red_knot_server/src/document/location.rs @@ -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 { diff --git a/crates/red_knot_server/src/document/range.rs b/crates/red_knot_server/src/document/range.rs index 30fbc75410..b525ed4aac 100644 --- a/crates/red_knot_server/src/document/range.rs +++ b/crates/red_knot_server/src/document/range.rs @@ -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 }) } } diff --git a/crates/red_knot_server/src/server.rs b/crates/red_knot_server/src/server.rs index 2e0f88ead6..ba0fa12c56 100644 --- a/crates/red_knot_server/src/server.rs +++ b/crates/red_knot_server/src/server.rs @@ -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() } } diff --git a/crates/red_knot_server/src/server/api.rs b/crates/red_knot_server/src/server/api.rs index 7b535729c9..8a659290cc 100644 --- a/crates/red_knot_server/src/server/api.rs +++ b/crates/red_knot_server/src/server/api.rs @@ -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(); diff --git a/crates/red_knot_server/src/server/api/requests.rs b/crates/red_knot_server/src/server/api/requests.rs index 26c45b7ea7..6f9ace2b30 100644 --- a/crates/red_knot_server/src/server/api/requests.rs +++ b/crates/red_knot_server/src/server/api/requests.rs @@ -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; diff --git a/crates/red_knot_server/src/server/api/requests/diagnostic.rs b/crates/red_knot_server/src/server/api/requests/diagnostic.rs index 589a9806f3..7dc95e0af2 100644 --- a/crates/red_knot_server/src/server/api/requests/diagnostic.rs +++ b/crates/red_knot_server/src/server/api/requests/diagnostic.rs @@ -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() diff --git a/crates/red_knot_server/src/server/api/requests/hover.rs b/crates/red_knot_server/src/server/api/requests/hover.rs new file mode 100644 index 0000000000..2677f67c32 --- /dev/null +++ b/crates/red_knot_server/src/server/api/requests/hover.rs @@ -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 { + 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> { + 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(), + )), + })) + } +} diff --git a/crates/red_knot_server/src/session/capabilities.rs b/crates/red_knot_server/src/session/capabilities.rs index ba27153c9b..5ba4c3e0a5 100644 --- a/crates/red_knot_server/src/session/capabilities.rs +++ b/crates/red_knot_server/src/session/capabilities.rs @@ -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, } } } diff --git a/crates/red_knot_wasm/src/lib.rs b/crates/red_knot_wasm/src/lib.rs index b211f5d089..366360a503 100644 --- a/crates/red_knot_wasm/src/lib.rs +++ b/crates/red_knot_wasm/src/lib.rs @@ -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, 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(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, } +#[wasm_bindgen] +pub struct Hover { + #[wasm_bindgen(getter_with_clone)] + pub markdown: String, + + pub range: Range, +} + #[derive(Debug, Clone)] struct WasmSystem { fs: MemoryFileSystem, diff --git a/crates/ruff_python_parser/src/lib.rs b/crates/ruff_python_parser/src/lib.rs index d63d250493..eae7ea75ab 100644 --- a/crates/ruff_python_parser/src/lib.rs +++ b/crates/ruff_python_parser/src/lib.rs @@ -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, diff --git a/playground/knot/src/Editor/Editor.tsx b/playground/knot/src/Editor/Editor.tsx index e03da98a00..6e05467c43 100644 --- a/playground/knot/src/Editor/Editor.tsx +++ b/playground/knot/src/Editor/Editor.tsx @@ -56,6 +56,7 @@ export default function Editor({ const disposable = useRef<{ typeDefinition: IDisposable; editorOpener: IDisposable; + hover: IDisposable; } | null>(null); const playgroundState = useRef({ 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) {} + 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 | undefined, + ): languages.ProviderResult { + 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,