Skip to content

Commit 10e4412

Browse files
[red-knot] Add inlay type hints (#17214)
Co-authored-by: Micha Reiser <[email protected]>
1 parent 9f6913c commit 10e4412

File tree

11 files changed

+529
-50
lines changed

11 files changed

+529
-50
lines changed
+279
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
use crate::Db;
2+
use red_knot_python_semantic::types::Type;
3+
use red_knot_python_semantic::{HasType, SemanticModel};
4+
use ruff_db::files::File;
5+
use ruff_db::parsed::parsed_module;
6+
use ruff_python_ast::visitor::source_order::{self, SourceOrderVisitor, TraversalSignal};
7+
use ruff_python_ast::{AnyNodeRef, Expr, Stmt};
8+
use ruff_text_size::{Ranged, TextRange, TextSize};
9+
use std::fmt;
10+
use std::fmt::Formatter;
11+
12+
#[derive(Debug, Clone, Eq, PartialEq)]
13+
pub struct InlayHint<'db> {
14+
pub position: TextSize,
15+
pub content: InlayHintContent<'db>,
16+
}
17+
18+
impl<'db> InlayHint<'db> {
19+
pub const fn display(&self, db: &'db dyn Db) -> DisplayInlayHint<'_, 'db> {
20+
self.content.display(db)
21+
}
22+
}
23+
24+
#[derive(Debug, Clone, Eq, PartialEq)]
25+
pub enum InlayHintContent<'db> {
26+
Type(Type<'db>),
27+
ReturnType(Type<'db>),
28+
}
29+
30+
impl<'db> InlayHintContent<'db> {
31+
pub const fn display(&self, db: &'db dyn Db) -> DisplayInlayHint<'_, 'db> {
32+
DisplayInlayHint { db, hint: self }
33+
}
34+
}
35+
36+
pub struct DisplayInlayHint<'a, 'db> {
37+
db: &'db dyn Db,
38+
hint: &'a InlayHintContent<'db>,
39+
}
40+
41+
impl fmt::Display for DisplayInlayHint<'_, '_> {
42+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
43+
match self.hint {
44+
InlayHintContent::Type(ty) => {
45+
write!(f, ": {}", ty.display(self.db.upcast()))
46+
}
47+
InlayHintContent::ReturnType(ty) => {
48+
write!(f, " -> {}", ty.display(self.db.upcast()))
49+
}
50+
}
51+
}
52+
}
53+
54+
pub fn inlay_hints(db: &dyn Db, file: File, range: TextRange) -> Vec<InlayHint<'_>> {
55+
let mut visitor = InlayHintVisitor::new(db, file, range);
56+
57+
let ast = parsed_module(db.upcast(), file);
58+
59+
visitor.visit_body(ast.suite());
60+
61+
visitor.hints
62+
}
63+
64+
struct InlayHintVisitor<'db> {
65+
model: SemanticModel<'db>,
66+
hints: Vec<InlayHint<'db>>,
67+
in_assignment: bool,
68+
range: TextRange,
69+
}
70+
71+
impl<'db> InlayHintVisitor<'db> {
72+
fn new(db: &'db dyn Db, file: File, range: TextRange) -> Self {
73+
Self {
74+
model: SemanticModel::new(db.upcast(), file),
75+
hints: Vec::new(),
76+
in_assignment: false,
77+
range,
78+
}
79+
}
80+
81+
fn add_type_hint(&mut self, position: TextSize, ty: Type<'db>) {
82+
self.hints.push(InlayHint {
83+
position,
84+
content: InlayHintContent::Type(ty),
85+
});
86+
}
87+
}
88+
89+
impl SourceOrderVisitor<'_> for InlayHintVisitor<'_> {
90+
fn enter_node(&mut self, node: AnyNodeRef<'_>) -> TraversalSignal {
91+
if self.range.intersect(node.range()).is_some() {
92+
TraversalSignal::Traverse
93+
} else {
94+
TraversalSignal::Skip
95+
}
96+
}
97+
98+
fn visit_stmt(&mut self, stmt: &Stmt) {
99+
let node = AnyNodeRef::from(stmt);
100+
101+
if !self.enter_node(node).is_traverse() {
102+
return;
103+
}
104+
105+
match stmt {
106+
Stmt::Assign(assign) => {
107+
self.in_assignment = true;
108+
for target in &assign.targets {
109+
self.visit_expr(target);
110+
}
111+
self.in_assignment = false;
112+
113+
return;
114+
}
115+
// TODO
116+
Stmt::FunctionDef(_) => {}
117+
Stmt::For(_) => {}
118+
Stmt::Expr(_) => {
119+
// Don't traverse into expression statements because we don't show any hints.
120+
return;
121+
}
122+
_ => {}
123+
}
124+
125+
source_order::walk_stmt(self, stmt);
126+
}
127+
128+
fn visit_expr(&mut self, expr: &'_ Expr) {
129+
if !self.in_assignment {
130+
return;
131+
}
132+
133+
match expr {
134+
Expr::Name(name) => {
135+
if name.ctx.is_store() {
136+
let ty = expr.inferred_type(&self.model);
137+
self.add_type_hint(expr.range().end(), ty);
138+
}
139+
}
140+
_ => {
141+
source_order::walk_expr(self, expr);
142+
}
143+
}
144+
}
145+
}
146+
147+
#[cfg(test)]
148+
mod tests {
149+
use super::*;
150+
151+
use insta::assert_snapshot;
152+
use ruff_db::{
153+
files::{system_path_to_file, File},
154+
source::source_text,
155+
};
156+
use ruff_text_size::TextSize;
157+
158+
use crate::db::tests::TestDb;
159+
160+
use red_knot_python_semantic::{
161+
Program, ProgramSettings, PythonPath, PythonPlatform, SearchPathSettings,
162+
};
163+
use ruff_db::system::{DbWithWritableSystem, SystemPathBuf};
164+
use ruff_python_ast::PythonVersion;
165+
166+
pub(super) fn inlay_hint_test(source: &str) -> InlayHintTest {
167+
const START: &str = "<START>";
168+
const END: &str = "<END>";
169+
170+
let mut db = TestDb::new();
171+
172+
let start = source.find(START);
173+
let end = source
174+
.find(END)
175+
.map(|x| if start.is_some() { x - START.len() } else { x })
176+
.unwrap_or(source.len());
177+
178+
let range = TextRange::new(
179+
TextSize::try_from(start.unwrap_or_default()).unwrap(),
180+
TextSize::try_from(end).unwrap(),
181+
);
182+
183+
let source = source.replace(START, "");
184+
let source = source.replace(END, "");
185+
186+
db.write_file("main.py", source)
187+
.expect("write to memory file system to be successful");
188+
189+
let file = system_path_to_file(&db, "main.py").expect("newly written file to existing");
190+
191+
Program::from_settings(
192+
&db,
193+
ProgramSettings {
194+
python_version: PythonVersion::latest(),
195+
python_platform: PythonPlatform::default(),
196+
search_paths: SearchPathSettings {
197+
extra_paths: vec![],
198+
src_roots: vec![SystemPathBuf::from("/")],
199+
custom_typeshed: None,
200+
python_path: PythonPath::KnownSitePackages(vec![]),
201+
},
202+
},
203+
)
204+
.expect("Default settings to be valid");
205+
206+
InlayHintTest { db, file, range }
207+
}
208+
209+
pub(super) struct InlayHintTest {
210+
pub(super) db: TestDb,
211+
pub(super) file: File,
212+
pub(super) range: TextRange,
213+
}
214+
215+
impl InlayHintTest {
216+
fn inlay_hints(&self) -> String {
217+
let hints = inlay_hints(&self.db, self.file, self.range);
218+
219+
let mut buf = source_text(&self.db, self.file).as_str().to_string();
220+
221+
let mut offset = 0;
222+
223+
for hint in hints {
224+
let end_position = (hint.position.to_u32() as usize) + offset;
225+
let hint_str = format!("[{}]", hint.display(&self.db));
226+
buf.insert_str(end_position, &hint_str);
227+
offset += hint_str.len();
228+
}
229+
230+
buf
231+
}
232+
}
233+
234+
#[test]
235+
fn test_assign_statement() {
236+
let test = inlay_hint_test("x = 1");
237+
238+
assert_snapshot!(test.inlay_hints(), @r"
239+
x[: Literal[1]] = 1
240+
");
241+
}
242+
243+
#[test]
244+
fn test_tuple_assignment() {
245+
let test = inlay_hint_test("x, y = (1, 'abc')");
246+
247+
assert_snapshot!(test.inlay_hints(), @r#"
248+
x[: Literal[1]], y[: Literal["abc"]] = (1, 'abc')
249+
"#);
250+
}
251+
252+
#[test]
253+
fn test_nested_tuple_assignment() {
254+
let test = inlay_hint_test("x, (y, z) = (1, ('abc', 2))");
255+
256+
assert_snapshot!(test.inlay_hints(), @r#"
257+
x[: Literal[1]], (y[: Literal["abc"]], z[: Literal[2]]) = (1, ('abc', 2))
258+
"#);
259+
}
260+
261+
#[test]
262+
fn test_assign_statement_with_type_annotation() {
263+
let test = inlay_hint_test("x: int = 1");
264+
265+
assert_snapshot!(test.inlay_hints(), @r"
266+
x: int = 1
267+
");
268+
}
269+
270+
#[test]
271+
fn test_assign_statement_out_of_range() {
272+
let test = inlay_hint_test("<START>x = 1<END>\ny = 2");
273+
274+
assert_snapshot!(test.inlay_hints(), @r"
275+
x[: Literal[1]] = 1
276+
y = 2
277+
");
278+
}
279+
}

crates/red_knot_ide/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ mod db;
22
mod find_node;
33
mod goto;
44
mod hover;
5+
mod inlay_hints;
56
mod markup;
67

78
pub use db::Db;
89
pub use goto::goto_type_definition;
910
pub use hover::hover;
11+
pub use inlay_hints::inlay_hints;
1012
pub use markup::MarkupKind;
1113

1214
use rustc_hash::FxHashSet;

crates/red_knot_server/src/document.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ mod text_document;
88
pub(crate) use location::ToLink;
99
use lsp_types::{PositionEncodingKind, Url};
1010
pub use notebook::NotebookDocument;
11-
pub(crate) use range::{FileRangeExt, PositionExt, RangeExt, ToRangeExt};
11+
pub(crate) use range::{FileRangeExt, PositionExt, RangeExt, TextSizeExt, ToRangeExt};
1212
pub(crate) use text_document::DocumentVersion;
1313
pub use text_document::TextDocument;
1414

crates/red_knot_server/src/document/range.rs

+25-12
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,29 @@ pub(crate) trait PositionExt {
2828
fn to_text_size(&self, text: &str, index: &LineIndex, encoding: PositionEncoding) -> TextSize;
2929
}
3030

31+
pub(crate) trait TextSizeExt {
32+
fn to_position(
33+
self,
34+
text: &str,
35+
index: &LineIndex,
36+
encoding: PositionEncoding,
37+
) -> types::Position
38+
where
39+
Self: Sized;
40+
}
41+
42+
impl TextSizeExt for TextSize {
43+
fn to_position(
44+
self,
45+
text: &str,
46+
index: &LineIndex,
47+
encoding: PositionEncoding,
48+
) -> types::Position {
49+
let source_location = offset_to_source_location(self, text, index, encoding);
50+
source_location_to_position(&source_location)
51+
}
52+
}
53+
3154
pub(crate) trait ToRangeExt {
3255
fn to_lsp_range(
3356
&self,
@@ -107,18 +130,8 @@ impl ToRangeExt for TextRange {
107130
encoding: PositionEncoding,
108131
) -> types::Range {
109132
types::Range {
110-
start: source_location_to_position(&offset_to_source_location(
111-
self.start(),
112-
text,
113-
index,
114-
encoding,
115-
)),
116-
end: source_location_to_position(&offset_to_source_location(
117-
self.end(),
118-
text,
119-
index,
120-
encoding,
121-
)),
133+
start: self.start().to_position(text, index, encoding),
134+
end: self.end().to_position(text, index, encoding),
122135
}
123136
}
124137

crates/red_knot_server/src/server.rs

+6-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ use std::panic::PanicInfo;
88
use lsp_server::Message;
99
use lsp_types::{
1010
ClientCapabilities, DiagnosticOptions, DiagnosticServerCapabilities, HoverProviderCapability,
11-
MessageType, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind,
12-
TextDocumentSyncOptions, TypeDefinitionProviderCapability, Url,
11+
InlayHintOptions, InlayHintServerCapabilities, MessageType, ServerCapabilities,
12+
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
13+
TypeDefinitionProviderCapability, Url,
1314
};
1415

1516
use self::connection::{Connection, ConnectionInitializer};
@@ -222,6 +223,9 @@ impl Server {
222223
)),
223224
type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
224225
hover_provider: Some(HoverProviderCapability::Simple(true)),
226+
inlay_hint_provider: Some(lsp_types::OneOf::Right(
227+
InlayHintServerCapabilities::Options(InlayHintOptions::default()),
228+
)),
225229
..Default::default()
226230
}
227231
}

crates/red_knot_server/src/server/api.rs

+3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ pub(super) fn request<'a>(req: server::Request) -> Task<'a> {
3333
request::HoverRequestHandler::METHOD => {
3434
background_request_task::<request::HoverRequestHandler>(req, BackgroundSchedule::Worker)
3535
}
36+
request::InlayHintRequestHandler::METHOD => background_request_task::<
37+
request::InlayHintRequestHandler,
38+
>(req, BackgroundSchedule::Worker),
3639

3740
method => {
3841
tracing::warn!("Received request {method} which does not have a handler");
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
mod diagnostic;
22
mod goto_type_definition;
33
mod hover;
4+
mod inlay_hints;
45

56
pub(super) use diagnostic::DocumentDiagnosticRequestHandler;
67
pub(super) use goto_type_definition::GotoTypeDefinitionRequestHandler;
78
pub(super) use hover::HoverRequestHandler;
9+
pub(super) use inlay_hints::InlayHintRequestHandler;

0 commit comments

Comments
 (0)