Skip to content

Commit f9cc26a

Browse files
authored
[ty] Respect notebook cell boundaries when adding an auto import (#21322)
1 parent d49c326 commit f9cc26a

File tree

10 files changed

+637
-23
lines changed

10 files changed

+637
-23
lines changed

.config/nextest.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ serial = { max-threads = 1 }
77
filter = 'binary(file_watching)'
88
test-group = 'serial'
99

10+
[[profile.default.overrides]]
11+
filter = 'binary(e2e)'
12+
test-group = 'serial'
13+
1014
[profile.ci]
1115
# Print out output for failing tests as soon as they fail, and also at the end
1216
# of the run (for easy scrollability).

crates/ruff_linter/src/importer/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ impl<'a> Importer<'a> {
8383
.into_edit(&required_import)
8484
} else {
8585
// Insert at the start of the file.
86-
Insertion::start_of_file(self.python_ast, self.source, self.stylist)
86+
Insertion::start_of_file(self.python_ast, self.source, self.stylist, None)
8787
.into_edit(&required_import)
8888
}
8989
}
@@ -113,7 +113,7 @@ impl<'a> Importer<'a> {
113113
Insertion::end_of_statement(stmt, self.source, self.stylist)
114114
} else {
115115
// Insert at the start of the file.
116-
Insertion::start_of_file(self.python_ast, self.source, self.stylist)
116+
Insertion::start_of_file(self.python_ast, self.source, self.stylist, None)
117117
};
118118
let add_import_edit = insertion.into_edit(&content);
119119

@@ -498,7 +498,7 @@ impl<'a> Importer<'a> {
498498
Insertion::end_of_statement(stmt, self.source, self.stylist)
499499
} else {
500500
// Insert at the start of the file.
501-
Insertion::start_of_file(self.python_ast, self.source, self.stylist)
501+
Insertion::start_of_file(self.python_ast, self.source, self.stylist, None)
502502
};
503503
if insertion.is_inline() {
504504
Err(anyhow::anyhow!(

crates/ruff_notebook/src/cell.rs

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -294,19 +294,33 @@ impl CellOffsets {
294294
}
295295

296296
/// Returns `true` if the given range contains a cell boundary.
297+
///
298+
/// A range starting at the cell boundary isn't considered to contain the cell boundary
299+
/// as it starts right after it. A range starting before a cell boundary
300+
/// and ending exactly at the boundary is considered to contain the cell boundary.
301+
///
302+
/// # Examples
303+
/// Cell 1:
304+
///
305+
/// ```py
306+
/// import c
307+
/// ```
308+
///
309+
/// Cell 2:
310+
///
311+
/// ```py
312+
/// import os
313+
/// ```
314+
///
315+
/// The range `import c`..`import os`, contains a cell boundary because it starts before cell 2 and ends in cell 2 (`end=cell2_boundary`).
316+
/// 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.
297317
pub fn has_cell_boundary(&self, range: TextRange) -> bool {
298-
self.binary_search_by(|offset| {
299-
if range.start() <= *offset {
300-
if range.end() < *offset {
301-
std::cmp::Ordering::Greater
302-
} else {
303-
std::cmp::Ordering::Equal
304-
}
305-
} else {
306-
std::cmp::Ordering::Less
307-
}
308-
})
309-
.is_ok()
318+
let after_range_start = self.partition_point(|offset| *offset <= range.start());
319+
let Some(boundary) = self.get(after_range_start).copied() else {
320+
return false;
321+
};
322+
323+
range.contains_inclusive(boundary)
310324
}
311325

312326
/// Returns an iterator over [`TextRange`]s covered by each cell.

crates/ruff_python_importer/src/insertion.rs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use ruff_python_parser::{TokenKind, Tokens};
1010
use ruff_python_trivia::is_python_whitespace;
1111
use ruff_python_trivia::{PythonWhitespace, textwrap::indent};
1212
use ruff_source_file::{LineRanges, UniversalNewlineIterator};
13-
use ruff_text_size::{Ranged, TextSize};
13+
use ruff_text_size::{Ranged, TextRange, TextSize};
1414

1515
#[derive(Debug, Clone, PartialEq, Eq)]
1616
pub(super) enum Placement<'a> {
@@ -37,7 +37,7 @@ pub struct Insertion<'a> {
3737

3838
impl<'a> Insertion<'a> {
3939
/// Create an [`Insertion`] to insert (e.g.) an import statement at the start of a given
40-
/// file, along with a prefix and suffix to use for the insertion.
40+
/// file or cell, along with a prefix and suffix to use for the insertion.
4141
///
4242
/// For example, given the following code:
4343
///
@@ -49,7 +49,26 @@ impl<'a> Insertion<'a> {
4949
///
5050
/// The insertion returned will begin at the start of the `import os` statement, and will
5151
/// include a trailing newline.
52-
pub fn start_of_file(body: &[Stmt], contents: &str, stylist: &Stylist) -> Insertion<'static> {
52+
///
53+
/// If `within_range` is set, the insertion will be limited to the specified range. That is,
54+
/// the insertion is constrained to the given range rather than the start of the file.
55+
/// This is used for insertions in notebook cells where the source code and AST are for
56+
/// the entire notebook but the insertion should be constrained to a specific cell.
57+
pub fn start_of_file(
58+
body: &[Stmt],
59+
contents: &str,
60+
stylist: &Stylist,
61+
within_range: Option<TextRange>,
62+
) -> Insertion<'static> {
63+
let body = within_range
64+
.map(|range| {
65+
let start = body.partition_point(|stmt| stmt.start() < range.start());
66+
let end = body.partition_point(|stmt| stmt.end() <= range.end());
67+
68+
&body[start..end]
69+
})
70+
.unwrap_or(body);
71+
5372
// Skip over any docstrings.
5473
let mut location = if let Some(mut location) = match_docstring_end(body) {
5574
// If the first token after the docstring is a semicolon, insert after the semicolon as
@@ -66,6 +85,10 @@ impl<'a> Insertion<'a> {
6685

6786
// Otherwise, advance to the next row.
6887
contents.full_line_end(location)
88+
} else if let Some(range) = within_range
89+
&& range.start() != TextSize::ZERO
90+
{
91+
range.start()
6992
} else {
7093
contents.bom_start_offset()
7194
};
@@ -374,7 +397,12 @@ mod tests {
374397
fn insert(contents: &str) -> Result<Insertion<'_>> {
375398
let parsed = parse_module(contents)?;
376399
let stylist = Stylist::from_tokens(parsed.tokens(), contents);
377-
Ok(Insertion::start_of_file(parsed.suite(), contents, &stylist))
400+
Ok(Insertion::start_of_file(
401+
parsed.suite(),
402+
contents,
403+
&stylist,
404+
None,
405+
))
378406
}
379407

380408
let contents = "";

crates/ty_ide/src/importer.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use rustc_hash::FxHashMap;
2020

2121
use ruff_db::files::File;
2222
use ruff_db::parsed::ParsedModuleRef;
23+
use ruff_db::source::source_text;
2324
use ruff_diagnostics::Edit;
2425
use ruff_python_ast as ast;
2526
use ruff_python_ast::name::Name;
@@ -76,6 +77,7 @@ impl<'a> Importer<'a> {
7677
parsed: &'a ParsedModuleRef,
7778
) -> Self {
7879
let imports = TopLevelImports::find(parsed);
80+
7981
Self {
8082
db,
8183
file,
@@ -145,10 +147,14 @@ impl<'a> Importer<'a> {
145147
let request = request.avoid_conflicts(self.db, self.file, members);
146148
let mut symbol_text: Box<str> = request.member.into();
147149
let Some(response) = self.find(&request, members.at) else {
148-
let insertion = if let Some(future) = self.find_last_future_import() {
150+
let insertion = if let Some(future) = self.find_last_future_import(members.at) {
149151
Insertion::end_of_statement(future.stmt, self.source, self.stylist)
150152
} else {
151-
Insertion::start_of_file(self.parsed.suite(), self.source, self.stylist)
153+
let range = source_text(self.db, self.file)
154+
.as_notebook()
155+
.and_then(|notebook| notebook.cell_offsets().containing_range(members.at));
156+
157+
Insertion::start_of_file(self.parsed.suite(), self.source, self.stylist, range)
152158
};
153159
let import = insertion.into_edit(&request.to_string());
154160
if matches!(request.style, ImportStyle::Import) {
@@ -209,6 +215,9 @@ impl<'a> Importer<'a> {
209215
available_at: TextSize,
210216
) -> Option<ImportResponse<'importer, 'a>> {
211217
let mut choice = None;
218+
let source = source_text(self.db, self.file);
219+
let notebook = source.as_notebook();
220+
212221
for import in &self.imports {
213222
// If the import statement comes after the spot where we
214223
// need the symbol, then we conservatively assume that
@@ -226,7 +235,22 @@ impl<'a> Importer<'a> {
226235
if import.stmt.start() >= available_at {
227236
return choice;
228237
}
238+
229239
if let Some(response) = import.satisfies(self.db, self.file, request) {
240+
let partial = matches!(response.kind, ImportResponseKind::Partial { .. });
241+
242+
// The LSP doesn't support edits across cell boundaries.
243+
// Skip over imports that only partially satisfy the import
244+
// because they would require changes to the import (across cell boundaries).
245+
if partial
246+
&& let Some(notebook) = notebook
247+
&& notebook
248+
.cell_offsets()
249+
.has_cell_boundary(TextRange::new(import.stmt.start(), available_at))
250+
{
251+
continue;
252+
}
253+
230254
if choice
231255
.as_ref()
232256
.is_none_or(|c| !c.kind.is_prioritized_over(&response.kind))
@@ -247,9 +271,21 @@ impl<'a> Importer<'a> {
247271
}
248272

249273
/// Find the last `from __future__` import statement in the AST.
250-
fn find_last_future_import(&self) -> Option<&'a AstImport> {
274+
fn find_last_future_import(&self, at: TextSize) -> Option<&'a AstImport> {
275+
let source = source_text(self.db, self.file);
276+
let notebook = source.as_notebook();
277+
251278
self.imports
252279
.iter()
280+
.take_while(|import| import.stmt.start() <= at)
281+
// Skip over imports from other cells.
282+
.skip_while(|import| {
283+
notebook.is_some_and(|notebook| {
284+
notebook
285+
.cell_offsets()
286+
.has_cell_boundary(TextRange::new(import.stmt.start(), at))
287+
})
288+
})
253289
.take_while(|import| {
254290
import
255291
.stmt

0 commit comments

Comments
 (0)