ruff server: Support Jupyter Notebook (*.ipynb) files (#11206)

## Summary

Closes https://github.com/astral-sh/ruff/issues/10858.

`ruff server` now supports `*.ipynb` (aka Jupyter Notebook) files.
Extensive internal changes have been made to facilitate this, which I've
done some work to contextualize with documentation and an pre-review
that highlights notable sections of the code.

`*.ipynb` cells should behave similarly to `*.py` documents, with one
major exception. The format command `ruff.applyFormat` will only apply
to the currently selected notebook cell - if you want to format an
entire notebook document, use `Format Notebook` from the VS Code context
menu.

## Test Plan

The VS Code extension does not yet have Jupyter Notebook support
enabled, so you'll first need to enable it manually. To do this,
checkout the `pre-release` branch and modify `src/common/server.ts` as
follows:

Before:
![Screenshot 2024-05-13 at 10 59
06 PM](c6a3c604-c405-4968-b8a2-5d670de89172)

After:
![Screenshot 2024-05-13 at 10 58
24 PM](94ab2e3d-0609-448d-9c8c-cd07c69a513b)

I recommend testing this PR with large, complicated notebook files. I
used notebook files from [this popular
repository](https://github.com/jakevdp/PythonDataScienceHandbook/tree/master/notebooks)
in my preliminary testing.

The main thing to test is ensuring that notebook cells behave the same
as Python documents, besides the aforementioned issue with
`ruff.applyFormat`. You should also test adding and deleting cells (in
particular, deleting all the code cells and ensure that doesn't break
anything), changing the kind of a cell (i.e. from markup -> code or vice
versa), and creating a new notebook file from scratch. Finally, you
should also test that source actions work as expected (and across the
entire notebook).

Note: `ruff.applyAutofix` and `ruff.applyOrganizeImports` are currently
broken for notebook files, and I suspect it has something to do with
https://github.com/astral-sh/ruff/issues/11248. Once this is fixed, I
will update the test plan accordingly.

---------

Co-authored-by: nolan <nolan.king90@gmail.com>
This commit is contained in:
Jane Lewis 2024-05-21 15:29:30 -07:00 committed by GitHub
parent 84531d1644
commit b0731ef9cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1584 additions and 622 deletions

View file

@ -2,28 +2,29 @@ use ruff_linter::{
linter::{FixerResult, LinterResult},
packaging::detect_package_root,
settings::{flags, types::UnsafeFixes, LinterSettings},
source_kind::SourceKind,
};
use ruff_python_ast::PySourceType;
use ruff_notebook::SourceValue;
use ruff_source_file::LineIndex;
use rustc_hash::FxHashMap;
use std::borrow::Cow;
use crate::{
edit::{Replacement, ToRangeExt},
session::DocumentQuery,
PositionEncoding,
};
/// A simultaneous fix made across a single text document or among an arbitrary
/// number of notebook cells.
pub(crate) type Fixes = FxHashMap<lsp_types::Url, Vec<lsp_types::TextEdit>>;
pub(crate) fn fix_all(
document: &crate::edit::Document,
document_url: &lsp_types::Url,
query: &DocumentQuery,
linter_settings: &LinterSettings,
encoding: PositionEncoding,
) -> crate::Result<Vec<lsp_types::TextEdit>> {
let source = document.contents();
let document_path = document_url
.to_file_path()
.expect("document URL should be a valid file path");
) -> crate::Result<Fixes> {
let document_path = query.file_path();
let source_kind = query.make_source_kind();
let package = detect_package_root(
document_path
@ -32,10 +33,7 @@ pub(crate) fn fix_all(
&linter_settings.namespace_packages,
);
let source_type = PySourceType::default();
// TODO(jane): Support Jupyter Notebooks
let source_kind = SourceKind::Python(source.to_string());
let source_type = query.source_type();
// We need to iteratively apply all safe fixes onto a single file and then
// create a diff between the modified file and the original source to use as a single workspace
@ -48,7 +46,7 @@ pub(crate) fn fix_all(
result: LinterResult { error, .. },
..
} = ruff_linter::linter::lint_fix(
&document_path,
document_path,
package,
flags::Noqa::Enabled,
UnsafeFixes::Disabled,
@ -66,27 +64,79 @@ pub(crate) fn fix_all(
// fast path: if `transformed` is still borrowed, no changes were made and we can return early
if let Cow::Borrowed(_) = transformed {
return Ok(vec![]);
return Ok(Fixes::default());
}
let modified = transformed.source_code();
if let (Some(source_notebook), Some(modified_notebook)) =
(source_kind.as_ipy_notebook(), transformed.as_ipy_notebook())
{
fn cell_source(cell: &ruff_notebook::Cell) -> String {
match cell.source() {
SourceValue::String(string) => string.clone(),
SourceValue::StringArray(array) => array.join(""),
}
}
let modified_index = LineIndex::from_source_text(modified);
let Some(notebook) = query.as_notebook() else {
anyhow::bail!("Notebook document expected from notebook source kind");
};
let mut fixes = Fixes::default();
for ((source, modified), url) in source_notebook
.cells()
.iter()
.map(cell_source)
.zip(modified_notebook.cells().iter().map(cell_source))
.zip(notebook.urls())
{
let source_index = LineIndex::from_source_text(&source);
let modified_index = LineIndex::from_source_text(&modified);
let source_index = document.index();
let Replacement {
source_range,
modified_range,
} = Replacement::between(
&source,
source_index.line_starts(),
&modified,
modified_index.line_starts(),
);
let Replacement {
source_range,
modified_range,
} = Replacement::between(
source,
source_index.line_starts(),
modified,
modified_index.line_starts(),
);
fixes.insert(
url.clone(),
vec![lsp_types::TextEdit {
range: source_range.to_range(
source_kind.source_code(),
&source_index,
encoding,
),
new_text: modified[modified_range].to_owned(),
}],
);
}
Ok(fixes)
} else {
let source_index = LineIndex::from_source_text(source_kind.source_code());
Ok(vec![lsp_types::TextEdit {
range: source_range.to_range(source, source_index, encoding),
new_text: modified[modified_range].to_owned(),
}])
let modified = transformed.source_code();
let modified_index = LineIndex::from_source_text(modified);
let Replacement {
source_range,
modified_range,
} = Replacement::between(
source_kind.source_code(),
source_index.line_starts(),
modified,
modified_index.line_starts(),
);
Ok([(
query.make_key().into_url(),
vec![lsp_types::TextEdit {
range: source_range.to_range(source_kind.source_code(), &source_index, encoding),
new_text: modified[modified_range].to_owned(),
}],
)]
.into_iter()
.collect())
}
}