[ty] Respect notebook cell boundaries when adding an auto import (#21322)

This commit is contained in:
Micha Reiser 2025-11-13 18:58:08 +01:00 committed by GitHub
parent d49c326309
commit f9cc26aa12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 637 additions and 23 deletions

View file

@ -7,6 +7,10 @@ serial = { max-threads = 1 }
filter = 'binary(file_watching)' filter = 'binary(file_watching)'
test-group = 'serial' test-group = 'serial'
[[profile.default.overrides]]
filter = 'binary(e2e)'
test-group = 'serial'
[profile.ci] [profile.ci]
# Print out output for failing tests as soon as they fail, and also at the end # Print out output for failing tests as soon as they fail, and also at the end
# of the run (for easy scrollability). # of the run (for easy scrollability).

View file

@ -83,7 +83,7 @@ impl<'a> Importer<'a> {
.into_edit(&required_import) .into_edit(&required_import)
} else { } else {
// Insert at the start of the file. // Insert at the start of the file.
Insertion::start_of_file(self.python_ast, self.source, self.stylist) Insertion::start_of_file(self.python_ast, self.source, self.stylist, None)
.into_edit(&required_import) .into_edit(&required_import)
} }
} }
@ -113,7 +113,7 @@ impl<'a> Importer<'a> {
Insertion::end_of_statement(stmt, self.source, self.stylist) Insertion::end_of_statement(stmt, self.source, self.stylist)
} else { } else {
// Insert at the start of the file. // Insert at the start of the file.
Insertion::start_of_file(self.python_ast, self.source, self.stylist) Insertion::start_of_file(self.python_ast, self.source, self.stylist, None)
}; };
let add_import_edit = insertion.into_edit(&content); let add_import_edit = insertion.into_edit(&content);
@ -498,7 +498,7 @@ impl<'a> Importer<'a> {
Insertion::end_of_statement(stmt, self.source, self.stylist) Insertion::end_of_statement(stmt, self.source, self.stylist)
} else { } else {
// Insert at the start of the file. // Insert at the start of the file.
Insertion::start_of_file(self.python_ast, self.source, self.stylist) Insertion::start_of_file(self.python_ast, self.source, self.stylist, None)
}; };
if insertion.is_inline() { if insertion.is_inline() {
Err(anyhow::anyhow!( Err(anyhow::anyhow!(

View file

@ -294,19 +294,33 @@ impl CellOffsets {
} }
/// Returns `true` if the given range contains a cell boundary. /// Returns `true` if the given range contains a cell boundary.
///
/// A range starting at the cell boundary isn't considered to contain the cell boundary
/// as it starts right after it. A range starting before a cell boundary
/// and ending exactly at the boundary is considered to contain the cell boundary.
///
/// # Examples
/// Cell 1:
///
/// ```py
/// import c
/// ```
///
/// Cell 2:
///
/// ```py
/// import os
/// ```
///
/// The range `import c`..`import os`, contains a cell boundary because it starts before cell 2 and ends in cell 2 (`end=cell2_boundary`).
/// The `import os` contains no cell boundary because it starts at the start of cell 2 (at the cell boundary) but doesn't cross into another cell.
pub fn has_cell_boundary(&self, range: TextRange) -> bool { pub fn has_cell_boundary(&self, range: TextRange) -> bool {
self.binary_search_by(|offset| { let after_range_start = self.partition_point(|offset| *offset <= range.start());
if range.start() <= *offset { let Some(boundary) = self.get(after_range_start).copied() else {
if range.end() < *offset { return false;
std::cmp::Ordering::Greater };
} else {
std::cmp::Ordering::Equal range.contains_inclusive(boundary)
}
} else {
std::cmp::Ordering::Less
}
})
.is_ok()
} }
/// Returns an iterator over [`TextRange`]s covered by each cell. /// Returns an iterator over [`TextRange`]s covered by each cell.

View file

@ -10,7 +10,7 @@ use ruff_python_parser::{TokenKind, Tokens};
use ruff_python_trivia::is_python_whitespace; use ruff_python_trivia::is_python_whitespace;
use ruff_python_trivia::{PythonWhitespace, textwrap::indent}; use ruff_python_trivia::{PythonWhitespace, textwrap::indent};
use ruff_source_file::{LineRanges, UniversalNewlineIterator}; use ruff_source_file::{LineRanges, UniversalNewlineIterator};
use ruff_text_size::{Ranged, TextSize}; use ruff_text_size::{Ranged, TextRange, TextSize};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Placement<'a> { pub(super) enum Placement<'a> {
@ -37,7 +37,7 @@ pub struct Insertion<'a> {
impl<'a> Insertion<'a> { impl<'a> Insertion<'a> {
/// Create an [`Insertion`] to insert (e.g.) an import statement at the start of a given /// Create an [`Insertion`] to insert (e.g.) an import statement at the start of a given
/// file, along with a prefix and suffix to use for the insertion. /// file or cell, along with a prefix and suffix to use for the insertion.
/// ///
/// For example, given the following code: /// For example, given the following code:
/// ///
@ -49,7 +49,26 @@ impl<'a> Insertion<'a> {
/// ///
/// The insertion returned will begin at the start of the `import os` statement, and will /// The insertion returned will begin at the start of the `import os` statement, and will
/// include a trailing newline. /// include a trailing newline.
pub fn start_of_file(body: &[Stmt], contents: &str, stylist: &Stylist) -> Insertion<'static> { ///
/// If `within_range` is set, the insertion will be limited to the specified range. That is,
/// the insertion is constrained to the given range rather than the start of the file.
/// This is used for insertions in notebook cells where the source code and AST are for
/// the entire notebook but the insertion should be constrained to a specific cell.
pub fn start_of_file(
body: &[Stmt],
contents: &str,
stylist: &Stylist,
within_range: Option<TextRange>,
) -> Insertion<'static> {
let body = within_range
.map(|range| {
let start = body.partition_point(|stmt| stmt.start() < range.start());
let end = body.partition_point(|stmt| stmt.end() <= range.end());
&body[start..end]
})
.unwrap_or(body);
// Skip over any docstrings. // Skip over any docstrings.
let mut location = if let Some(mut location) = match_docstring_end(body) { let mut location = if let Some(mut location) = match_docstring_end(body) {
// If the first token after the docstring is a semicolon, insert after the semicolon as // If the first token after the docstring is a semicolon, insert after the semicolon as
@ -66,6 +85,10 @@ impl<'a> Insertion<'a> {
// Otherwise, advance to the next row. // Otherwise, advance to the next row.
contents.full_line_end(location) contents.full_line_end(location)
} else if let Some(range) = within_range
&& range.start() != TextSize::ZERO
{
range.start()
} else { } else {
contents.bom_start_offset() contents.bom_start_offset()
}; };
@ -374,7 +397,12 @@ mod tests {
fn insert(contents: &str) -> Result<Insertion<'_>> { fn insert(contents: &str) -> Result<Insertion<'_>> {
let parsed = parse_module(contents)?; let parsed = parse_module(contents)?;
let stylist = Stylist::from_tokens(parsed.tokens(), contents); let stylist = Stylist::from_tokens(parsed.tokens(), contents);
Ok(Insertion::start_of_file(parsed.suite(), contents, &stylist)) Ok(Insertion::start_of_file(
parsed.suite(),
contents,
&stylist,
None,
))
} }
let contents = ""; let contents = "";

View file

@ -20,6 +20,7 @@ use rustc_hash::FxHashMap;
use ruff_db::files::File; use ruff_db::files::File;
use ruff_db::parsed::ParsedModuleRef; use ruff_db::parsed::ParsedModuleRef;
use ruff_db::source::source_text;
use ruff_diagnostics::Edit; use ruff_diagnostics::Edit;
use ruff_python_ast as ast; use ruff_python_ast as ast;
use ruff_python_ast::name::Name; use ruff_python_ast::name::Name;
@ -76,6 +77,7 @@ impl<'a> Importer<'a> {
parsed: &'a ParsedModuleRef, parsed: &'a ParsedModuleRef,
) -> Self { ) -> Self {
let imports = TopLevelImports::find(parsed); let imports = TopLevelImports::find(parsed);
Self { Self {
db, db,
file, file,
@ -145,10 +147,14 @@ impl<'a> Importer<'a> {
let request = request.avoid_conflicts(self.db, self.file, members); let request = request.avoid_conflicts(self.db, self.file, members);
let mut symbol_text: Box<str> = request.member.into(); let mut symbol_text: Box<str> = request.member.into();
let Some(response) = self.find(&request, members.at) else { let Some(response) = self.find(&request, members.at) else {
let insertion = if let Some(future) = self.find_last_future_import() { let insertion = if let Some(future) = self.find_last_future_import(members.at) {
Insertion::end_of_statement(future.stmt, self.source, self.stylist) Insertion::end_of_statement(future.stmt, self.source, self.stylist)
} else { } else {
Insertion::start_of_file(self.parsed.suite(), self.source, self.stylist) let range = source_text(self.db, self.file)
.as_notebook()
.and_then(|notebook| notebook.cell_offsets().containing_range(members.at));
Insertion::start_of_file(self.parsed.suite(), self.source, self.stylist, range)
}; };
let import = insertion.into_edit(&request.to_string()); let import = insertion.into_edit(&request.to_string());
if matches!(request.style, ImportStyle::Import) { if matches!(request.style, ImportStyle::Import) {
@ -209,6 +215,9 @@ impl<'a> Importer<'a> {
available_at: TextSize, available_at: TextSize,
) -> Option<ImportResponse<'importer, 'a>> { ) -> Option<ImportResponse<'importer, 'a>> {
let mut choice = None; let mut choice = None;
let source = source_text(self.db, self.file);
let notebook = source.as_notebook();
for import in &self.imports { for import in &self.imports {
// If the import statement comes after the spot where we // If the import statement comes after the spot where we
// need the symbol, then we conservatively assume that // need the symbol, then we conservatively assume that
@ -226,7 +235,22 @@ impl<'a> Importer<'a> {
if import.stmt.start() >= available_at { if import.stmt.start() >= available_at {
return choice; return choice;
} }
if let Some(response) = import.satisfies(self.db, self.file, request) { if let Some(response) = import.satisfies(self.db, self.file, request) {
let partial = matches!(response.kind, ImportResponseKind::Partial { .. });
// The LSP doesn't support edits across cell boundaries.
// Skip over imports that only partially satisfy the import
// because they would require changes to the import (across cell boundaries).
if partial
&& let Some(notebook) = notebook
&& notebook
.cell_offsets()
.has_cell_boundary(TextRange::new(import.stmt.start(), available_at))
{
continue;
}
if choice if choice
.as_ref() .as_ref()
.is_none_or(|c| !c.kind.is_prioritized_over(&response.kind)) .is_none_or(|c| !c.kind.is_prioritized_over(&response.kind))
@ -247,9 +271,21 @@ impl<'a> Importer<'a> {
} }
/// Find the last `from __future__` import statement in the AST. /// Find the last `from __future__` import statement in the AST.
fn find_last_future_import(&self) -> Option<&'a AstImport> { fn find_last_future_import(&self, at: TextSize) -> Option<&'a AstImport> {
let source = source_text(self.db, self.file);
let notebook = source.as_notebook();
self.imports self.imports
.iter() .iter()
.take_while(|import| import.stmt.start() <= at)
// Skip over imports from other cells.
.skip_while(|import| {
notebook.is_some_and(|notebook| {
notebook
.cell_offsets()
.has_cell_boundary(TextRange::new(import.stmt.start(), at))
})
})
.take_while(|import| { .take_while(|import| {
import import
.stmt .stmt

View file

@ -1,5 +1,7 @@
use insta::assert_json_snapshot; use insta::assert_json_snapshot;
use lsp_types::{NotebookCellKind, Position, Range}; use lsp_types::{CompletionResponse, CompletionTriggerKind, NotebookCellKind, Position, Range};
use ruff_db::system::SystemPath;
use ty_server::ClientOptions;
use crate::{TestServer, TestServerBuilder}; use crate::{TestServer, TestServerBuilder};
@ -276,6 +278,142 @@ fn swap_cells() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[test]
fn auto_import() -> anyhow::Result<()> {
let mut server = TestServerBuilder::new()?
.with_workspace(
SystemPath::new("src"),
Some(ClientOptions::default().with_experimental_auto_import(true)),
)?
.build()?
.wait_until_workspaces_are_initialized()?;
server.initialization_result().unwrap();
let mut builder = NotebookBuilder::virtual_file("src/test.ipynb");
builder.add_python_cell(
r#"from typing import TYPE_CHECKING
"#,
);
let second_cell = builder.add_python_cell(
r#"# leading comment
b: Litera
"#,
);
builder.open(&mut server);
server.collect_publish_diagnostic_notifications(2)?;
let completions = literal_completions(&mut server, &second_cell, Position::new(1, 9))?;
assert_json_snapshot!(completions);
Ok(())
}
#[test]
fn auto_import_same_cell() -> anyhow::Result<()> {
let mut server = TestServerBuilder::new()?
.with_workspace(
SystemPath::new("src"),
Some(ClientOptions::default().with_experimental_auto_import(true)),
)?
.build()?
.wait_until_workspaces_are_initialized()?;
server.initialization_result().unwrap();
let mut builder = NotebookBuilder::virtual_file("src/test.ipynb");
let first_cell = builder.add_python_cell(
r#"from typing import TYPE_CHECKING
b: Litera
"#,
);
builder.open(&mut server);
server.collect_publish_diagnostic_notifications(1)?;
let completions = literal_completions(&mut server, &first_cell, Position::new(1, 9))?;
assert_json_snapshot!(completions);
Ok(())
}
#[test]
fn auto_import_from_future() -> anyhow::Result<()> {
let mut server = TestServerBuilder::new()?
.with_workspace(
SystemPath::new("src"),
Some(ClientOptions::default().with_experimental_auto_import(true)),
)?
.build()?
.wait_until_workspaces_are_initialized()?;
server.initialization_result().unwrap();
let mut builder = NotebookBuilder::virtual_file("src/test.ipynb");
builder.add_python_cell(r#"from typing import TYPE_CHECKING"#);
let second_cell = builder.add_python_cell(
r#"from __future__ import annotations
b: Litera
"#,
);
builder.open(&mut server);
server.collect_publish_diagnostic_notifications(2)?;
let completions = literal_completions(&mut server, &second_cell, Position::new(1, 9))?;
assert_json_snapshot!(completions);
Ok(())
}
#[test]
fn auto_import_docstring() -> anyhow::Result<()> {
let mut server = TestServerBuilder::new()?
.with_workspace(
SystemPath::new("src"),
Some(ClientOptions::default().with_experimental_auto_import(true)),
)?
.build()?
.wait_until_workspaces_are_initialized()?;
server.initialization_result().unwrap();
let mut builder = NotebookBuilder::virtual_file("src/test.ipynb");
builder.add_python_cell(
r#"from typing import TYPE_CHECKING
"#,
);
let second_cell = builder.add_python_cell(
r#""""A cell level docstring"""
b: Litera
"#,
);
builder.open(&mut server);
server.collect_publish_diagnostic_notifications(2)?;
let completions = literal_completions(&mut server, &second_cell, Position::new(1, 9))?;
assert_json_snapshot!(completions);
Ok(())
}
fn semantic_tokens_full_for_cell( fn semantic_tokens_full_for_cell(
server: &mut TestServer, server: &mut TestServer,
cell_uri: &lsp_types::Url, cell_uri: &lsp_types::Url,
@ -359,3 +497,37 @@ impl NotebookBuilder {
self.notebook_url self.notebook_url
} }
} }
fn literal_completions(
server: &mut TestServer,
cell: &lsp_types::Url,
position: Position,
) -> crate::Result<Vec<lsp_types::CompletionItem>> {
let completions_id =
server.send_request::<lsp_types::request::Completion>(lsp_types::CompletionParams {
text_document_position: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri: cell.clone() },
position,
},
work_done_progress_params: lsp_types::WorkDoneProgressParams::default(),
partial_result_params: lsp_types::PartialResultParams::default(),
context: Some(lsp_types::CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS,
trigger_character: None,
}),
});
// There are a ton of imports we don't care about in here...
// The import bit is that an edit is always restricted to the current cell. That means,
// we can't add `Literal` to the `from typing import TYPE_CHECKING` import in cell 1
let completions = server.await_response::<lsp_types::request::Completion>(&completions_id)?;
let mut items = match completions {
Some(CompletionResponse::Array(array)) => array,
Some(CompletionResponse::List(lsp_types::CompletionList { items, .. })) => items,
None => return Ok(vec![]),
};
items.retain(|item| item.label.starts_with("Litera"));
Ok(items)
}

View file

@ -0,0 +1,90 @@
---
source: crates/ty_server/tests/e2e/notebook.rs
expression: completions
---
[
{
"label": "Literal (import typing)",
"kind": 6,
"sortText": " 43",
"insertText": "Literal",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing import Literal\n"
}
]
},
{
"label": "Literal (import typing_extensions)",
"kind": 6,
"sortText": " 44",
"insertText": "Literal",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing_extensions import Literal\n"
}
]
},
{
"label": "LiteralString (import typing)",
"kind": 6,
"sortText": " 45",
"insertText": "LiteralString",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing import LiteralString\n"
}
]
},
{
"label": "LiteralString (import typing_extensions)",
"kind": 6,
"sortText": " 46",
"insertText": "LiteralString",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing_extensions import LiteralString\n"
}
]
}
]

View file

@ -0,0 +1,90 @@
---
source: crates/ty_server/tests/e2e/notebook.rs
expression: completions
---
[
{
"label": "Literal (import typing)",
"kind": 6,
"sortText": " 43",
"insertText": "Literal",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing import Literal\n"
}
]
},
{
"label": "Literal (import typing_extensions)",
"kind": 6,
"sortText": " 44",
"insertText": "Literal",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing_extensions import Literal\n"
}
]
},
{
"label": "LiteralString (import typing)",
"kind": 6,
"sortText": " 45",
"insertText": "LiteralString",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing import LiteralString\n"
}
]
},
{
"label": "LiteralString (import typing_extensions)",
"kind": 6,
"sortText": " 46",
"insertText": "LiteralString",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing_extensions import LiteralString\n"
}
]
}
]

View file

@ -0,0 +1,90 @@
---
source: crates/ty_server/tests/e2e/notebook.rs
expression: completions
---
[
{
"label": "Literal (import typing)",
"kind": 6,
"sortText": " 43",
"insertText": "Literal",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing import Literal\n"
}
]
},
{
"label": "Literal (import typing_extensions)",
"kind": 6,
"sortText": " 44",
"insertText": "Literal",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing_extensions import Literal\n"
}
]
},
{
"label": "LiteralString (import typing)",
"kind": 6,
"sortText": " 45",
"insertText": "LiteralString",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing import LiteralString\n"
}
]
},
{
"label": "LiteralString (import typing_extensions)",
"kind": 6,
"sortText": " 46",
"insertText": "LiteralString",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 1,
"character": 0
},
"end": {
"line": 1,
"character": 0
}
},
"newText": "from typing_extensions import LiteralString\n"
}
]
}
]

View file

@ -0,0 +1,90 @@
---
source: crates/ty_server/tests/e2e/notebook.rs
expression: completions
---
[
{
"label": "Literal (import typing)",
"kind": 6,
"sortText": " 43",
"insertText": "Literal",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 0,
"character": 32
},
"end": {
"line": 0,
"character": 32
}
},
"newText": ", Literal"
}
]
},
{
"label": "Literal (import typing_extensions)",
"kind": 6,
"sortText": " 44",
"insertText": "Literal",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 0,
"character": 0
}
},
"newText": "from typing_extensions import Literal\n"
}
]
},
{
"label": "LiteralString (import typing)",
"kind": 6,
"sortText": " 45",
"insertText": "LiteralString",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 0,
"character": 32
},
"end": {
"line": 0,
"character": 32
}
},
"newText": ", LiteralString"
}
]
},
{
"label": "LiteralString (import typing_extensions)",
"kind": 6,
"sortText": " 46",
"insertText": "LiteralString",
"additionalTextEdits": [
{
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 0,
"character": 0
}
},
"newText": "from typing_extensions import LiteralString\n"
}
]
}
]