ruff/crates/ruff_server/src/edit/notebook.rs

356 lines
14 KiB
Rust

use anyhow::Ok;
use lsp_types::NotebookCellKind;
use ruff_notebook::CellMetadata;
use rustc_hash::{FxBuildHasher, FxHashMap};
use crate::{PositionEncoding, TextDocument};
use super::DocumentVersion;
pub(super) type CellId = usize;
/// The state of a notebook document in the server. Contains an array of cells whose
/// contents are internally represented by [`TextDocument`]s.
#[derive(Clone, Debug)]
pub struct NotebookDocument {
cells: Vec<NotebookCell>,
metadata: ruff_notebook::RawNotebookMetadata,
version: DocumentVersion,
// Used to quickly find the index of a cell for a given URL.
cell_index: FxHashMap<lsp_types::Url, CellId>,
}
/// A single cell within a notebook, which has text contents represented as a `TextDocument`.
#[derive(Clone, Debug)]
struct NotebookCell {
url: lsp_types::Url,
kind: NotebookCellKind,
document: TextDocument,
}
impl NotebookDocument {
pub fn new(
version: DocumentVersion,
cells: Vec<lsp_types::NotebookCell>,
metadata: serde_json::Map<String, serde_json::Value>,
cell_documents: Vec<lsp_types::TextDocumentItem>,
) -> crate::Result<Self> {
let mut cell_contents: FxHashMap<_, _> = cell_documents
.into_iter()
.map(|document| (document.uri, document.text))
.collect();
let cells: Vec<_> = cells
.into_iter()
.map(|cell| {
let contents = cell_contents.remove(&cell.document).unwrap_or_default();
NotebookCell::new(cell, contents, version)
})
.collect();
Ok(Self {
version,
cell_index: Self::make_cell_index(cells.as_slice()),
metadata: serde_json::from_value(serde_json::Value::Object(metadata))?,
cells,
})
}
/// Generates a pseudo-representation of a notebook that lacks per-cell metadata and contextual information
/// but should still work with Ruff's linter.
pub fn make_ruff_notebook(&self) -> ruff_notebook::Notebook {
let cells = self
.cells
.iter()
.map(|cell| match cell.kind {
NotebookCellKind::Code => ruff_notebook::Cell::Code(ruff_notebook::CodeCell {
execution_count: None,
id: None,
metadata: CellMetadata::default(),
outputs: vec![],
source: ruff_notebook::SourceValue::String(
cell.document.contents().to_string(),
),
}),
NotebookCellKind::Markup => {
ruff_notebook::Cell::Markdown(ruff_notebook::MarkdownCell {
attachments: None,
id: None,
metadata: CellMetadata::default(),
source: ruff_notebook::SourceValue::String(
cell.document.contents().to_string(),
),
})
}
})
.collect();
let raw_notebook = ruff_notebook::RawNotebook {
cells,
metadata: self.metadata.clone(),
nbformat: 4,
nbformat_minor: 5,
};
ruff_notebook::Notebook::from_raw_notebook(raw_notebook, false)
.unwrap_or_else(|err| panic!("Server notebook document could not be converted to Ruff's notebook document format: {err}"))
}
pub(crate) fn update(
&mut self,
cells: Option<lsp_types::NotebookDocumentCellChange>,
metadata_change: Option<serde_json::Map<String, serde_json::Value>>,
version: DocumentVersion,
encoding: PositionEncoding,
) -> crate::Result<()> {
self.version = version;
if let Some(lsp_types::NotebookDocumentCellChange {
structure,
data,
text_content,
}) = cells
{
// The structural changes should be done first, as they may affect the cell index.
if let Some(structure) = structure {
let start = structure.array.start as usize;
let delete = structure.array.delete_count as usize;
// This is required because of the way the `NotebookCell` is modelled. We include
// the `TextDocument` within the `NotebookCell` so when it's deleted, the
// corresponding `TextDocument` is removed as well. But, when cells are
// re-ordered, the change request doesn't provide the actual contents of the cell.
// Instead, it only provides that (a) these cell URIs were removed, and (b) these
// cell URIs were added.
// https://github.com/astral-sh/ruff/issues/12573
let mut deleted_cells = FxHashMap::default();
// First, delete the cells and remove them from the index.
if delete > 0 {
for cell in self.cells.drain(start..start + delete) {
self.cell_index.remove(&cell.url);
deleted_cells.insert(cell.url, cell.document);
}
}
// Second, insert the new cells with the available information. This array does not
// provide the actual contents of the cells, so we'll initialize them with empty
// contents.
for cell in structure.array.cells.into_iter().flatten().rev() {
let (content, version) =
if let Some(text_document) = deleted_cells.remove(&cell.document) {
let version = text_document.version();
(text_document.into_contents(), version)
} else {
(String::new(), 0)
};
self.cells
.insert(start, NotebookCell::new(cell, content, version));
}
// Third, register the new cells in the index and update existing ones that came
// after the insertion.
for (index, cell) in self.cells.iter().enumerate().skip(start) {
self.cell_index.insert(cell.url.clone(), index);
}
// Finally, update the text document that represents the cell with the actual
// contents. This should be done at the end so that both the `cells` and
// `cell_index` are updated before we start applying the changes to the cells.
if let Some(did_open) = structure.did_open {
for cell_text_document in did_open {
if let Some(cell) = self.cell_by_uri_mut(&cell_text_document.uri) {
cell.document = TextDocument::new(
cell_text_document.text,
cell_text_document.version,
);
}
}
}
}
if let Some(cell_data) = data {
for cell in cell_data {
if let Some(existing_cell) = self.cell_by_uri_mut(&cell.document) {
existing_cell.kind = cell.kind;
}
}
}
if let Some(content_changes) = text_content {
for content_change in content_changes {
if let Some(cell) = self.cell_by_uri_mut(&content_change.document.uri) {
cell.document
.apply_changes(content_change.changes, version, encoding);
}
}
}
}
if let Some(metadata_change) = metadata_change {
self.metadata = serde_json::from_value(serde_json::Value::Object(metadata_change))?;
}
Ok(())
}
/// Get the current version of the notebook document.
pub(crate) fn version(&self) -> DocumentVersion {
self.version
}
/// Get the URI for a cell by its index within the cell array.
pub(crate) fn cell_uri_by_index(&self, index: CellId) -> Option<&lsp_types::Url> {
self.cells.get(index).map(|cell| &cell.url)
}
/// Get the text document representing the contents of a cell by the cell URI.
pub(crate) fn cell_document_by_uri(&self, uri: &lsp_types::Url) -> Option<&TextDocument> {
self.cells
.get(*self.cell_index.get(uri)?)
.map(|cell| &cell.document)
}
/// Returns a list of cell URIs in the order they appear in the array.
pub(crate) fn urls(&self) -> impl Iterator<Item = &lsp_types::Url> {
self.cells.iter().map(|cell| &cell.url)
}
fn cell_by_uri_mut(&mut self, uri: &lsp_types::Url) -> Option<&mut NotebookCell> {
self.cells.get_mut(*self.cell_index.get(uri)?)
}
fn make_cell_index(cells: &[NotebookCell]) -> FxHashMap<lsp_types::Url, CellId> {
let mut index = FxHashMap::with_capacity_and_hasher(cells.len(), FxBuildHasher);
for (i, cell) in cells.iter().enumerate() {
index.insert(cell.url.clone(), i);
}
index
}
}
impl NotebookCell {
pub(crate) fn new(
cell: lsp_types::NotebookCell,
contents: String,
version: DocumentVersion,
) -> Self {
Self {
url: cell.document,
kind: cell.kind,
document: TextDocument::new(contents, version),
}
}
}
#[cfg(test)]
mod tests {
use super::NotebookDocument;
enum TestCellContent {
#[allow(dead_code)]
Markup(String),
Code(String),
}
fn create_test_url(index: usize) -> lsp_types::Url {
lsp_types::Url::parse(&format!("cell:/test.ipynb#{index}")).unwrap()
}
fn create_test_notebook(test_cells: Vec<TestCellContent>) -> NotebookDocument {
let mut cells = Vec::with_capacity(test_cells.len());
let mut cell_documents = Vec::with_capacity(test_cells.len());
for (index, test_cell) in test_cells.into_iter().enumerate() {
let url = create_test_url(index);
match test_cell {
TestCellContent::Markup(content) => {
cells.push(lsp_types::NotebookCell {
kind: lsp_types::NotebookCellKind::Markup,
document: url.clone(),
metadata: None,
execution_summary: None,
});
cell_documents.push(lsp_types::TextDocumentItem {
uri: url,
language_id: "markdown".to_owned(),
version: 0,
text: content,
});
}
TestCellContent::Code(content) => {
cells.push(lsp_types::NotebookCell {
kind: lsp_types::NotebookCellKind::Code,
document: url.clone(),
metadata: None,
execution_summary: None,
});
cell_documents.push(lsp_types::TextDocumentItem {
uri: url,
language_id: "python".to_owned(),
version: 0,
text: content,
});
}
}
}
NotebookDocument::new(0, cells, serde_json::Map::default(), cell_documents).unwrap()
}
/// This test case checks that for a notebook with three code cells, when the client sends a
/// change request to swap the first two cells, the notebook document is updated correctly.
///
/// The swap operation as a change request is represented as deleting the first two cells and
/// adding them back in the reverse order.
#[test]
fn swap_cells() {
let mut notebook = create_test_notebook(vec![
TestCellContent::Code("cell = 0".to_owned()),
TestCellContent::Code("cell = 1".to_owned()),
TestCellContent::Code("cell = 2".to_owned()),
]);
notebook
.update(
Some(lsp_types::NotebookDocumentCellChange {
structure: Some(lsp_types::NotebookDocumentCellChangeStructure {
array: lsp_types::NotebookCellArrayChange {
start: 0,
delete_count: 2,
cells: Some(vec![
lsp_types::NotebookCell {
kind: lsp_types::NotebookCellKind::Code,
document: create_test_url(1),
metadata: None,
execution_summary: None,
},
lsp_types::NotebookCell {
kind: lsp_types::NotebookCellKind::Code,
document: create_test_url(0),
metadata: None,
execution_summary: None,
},
]),
},
did_open: None,
did_close: None,
}),
data: None,
text_content: None,
}),
None,
1,
crate::PositionEncoding::default(),
)
.unwrap();
assert_eq!(
notebook.make_ruff_notebook().source_code(),
"cell = 1
cell = 0
cell = 2
"
);
}
}