Skip to content

Commit 4fb4f0f

Browse files
committed
Implement support for Jupyter Notebooks in ruff server
1 parent a347a1b commit 4fb4f0f

39 files changed

+1345
-620
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ruff_notebook/src/cell.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ impl fmt::Display for SourceValue {
2323

2424
impl Cell {
2525
/// Return the [`SourceValue`] of the cell.
26-
pub(crate) fn source(&self) -> &SourceValue {
26+
pub fn source(&self) -> &SourceValue {
2727
match self {
2828
Cell::Code(cell) => &cell.source,
2929
Cell::Markdown(cell) => &cell.source,

crates/ruff_notebook/src/notebook.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,23 @@ impl Notebook {
8585
Self::from_reader(Cursor::new(source_code))
8686
}
8787

88+
/// Generate a pseudo-representation of a notebook that can be used
89+
/// for linting by the language server. As this is not generated directly from the raw JSON
90+
/// of a notebook file, writing this back into the file system is a bad idea.
91+
pub fn from_cells(
92+
cells: Vec<Cell>,
93+
metadata: crate::RawNotebookMetadata,
94+
) -> Result<Self, NotebookError> {
95+
let raw_notebook = RawNotebook {
96+
cells,
97+
metadata,
98+
nbformat: 4,
99+
nbformat_minor: 5,
100+
};
101+
102+
Self::from_raw(raw_notebook, false)
103+
}
104+
88105
/// Read a Jupyter Notebook from a [`Read`] implementer.
89106
///
90107
/// See also the black implementation
@@ -98,7 +115,7 @@ impl Notebook {
98115
reader.read_exact(&mut buf).is_ok_and(|()| buf[0] == b'\n')
99116
});
100117
reader.rewind()?;
101-
let mut raw_notebook: RawNotebook = match serde_json::from_reader(reader.by_ref()) {
118+
let raw_notebook: RawNotebook = match serde_json::from_reader(reader.by_ref()) {
102119
Ok(notebook) => notebook,
103120
Err(err) => {
104121
// Translate the error into a diagnostic
@@ -113,7 +130,13 @@ impl Notebook {
113130
});
114131
}
115132
};
133+
Self::from_raw(raw_notebook, trailing_newline)
134+
}
116135

136+
fn from_raw(
137+
mut raw_notebook: RawNotebook,
138+
trailing_newline: bool,
139+
) -> Result<Self, NotebookError> {
117140
// v4 is what everybody uses
118141
if raw_notebook.nbformat != 4 {
119142
// bail because we should have already failed at the json schema stage

crates/ruff_server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ruff_python_codegen = { workspace = true }
2121
ruff_python_formatter = { workspace = true }
2222
ruff_python_index = { workspace = true }
2323
ruff_python_parser = { workspace = true }
24+
ruff_notebook = { path = "../ruff_notebook" }
2425
ruff_source_file = { workspace = true }
2526
ruff_text_size = { workspace = true }
2627
ruff_workspace = { workspace = true }

crates/ruff_server/src/edit.rs

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
//! Types and utilities for working with text, modifying source files, and `Ruff <-> LSP` type conversion.
22
33
mod document;
4+
mod notebook;
45
mod range;
56
mod replacement;
67

7-
use std::collections::HashMap;
8+
use std::{collections::HashMap, ffi::OsStr, path::PathBuf};
89

9-
pub use document::Document;
1010
pub(crate) use document::DocumentVersion;
11+
pub use document::TextDocument;
1112
use lsp_types::PositionEncodingKind;
13+
pub(crate) use notebook::NotebookDocument;
1214
pub(crate) use range::{RangeExt, ToRangeExt};
1315
pub(crate) use replacement::Replacement;
1416

15-
use crate::session::ResolvedClientCapabilities;
17+
use crate::{fix::Fixes, session::ResolvedClientCapabilities};
1618

1719
/// A convenient enumeration for supported text encodings. Can be converted to [`lsp_types::PositionEncodingKind`].
1820
// Please maintain the order from least to greatest priority for the derived `Ord` impl.
@@ -29,6 +31,57 @@ pub enum PositionEncoding {
2931
UTF8,
3032
}
3133

34+
/// A unique document ID, derived from a URL passed as part of an LSP request.
35+
/// This document ID can point to either be a standalone Python file, a full notebook, or a cell within a notebook.
36+
#[derive(Clone, Debug)]
37+
pub(crate) enum DocumentKey {
38+
Notebook(PathBuf),
39+
NotebookCell(lsp_types::Url),
40+
Text(PathBuf),
41+
}
42+
43+
impl DocumentKey {
44+
/// Creates a document key from a URL provided in an LSP request.
45+
pub(crate) fn from_url(url: &lsp_types::Url) -> Self {
46+
if url.scheme() != "file" {
47+
return Self::NotebookCell(url.clone());
48+
}
49+
let Some(path) = url.to_file_path().ok() else {
50+
return Self::NotebookCell(url.clone());
51+
};
52+
53+
// figure out whether this is a notebook or a text document
54+
if path.extension() == Some(OsStr::new("ipynb")) {
55+
Self::Notebook(path)
56+
} else {
57+
// Until we support additional document types, we need to confirm
58+
// that any non-notebook file is a Python file
59+
debug_assert_eq!(path.extension(), Some(OsStr::new("py")));
60+
Self::Text(path)
61+
}
62+
}
63+
64+
/// Converts the key back into its original URL.
65+
pub(crate) fn into_url(self) -> lsp_types::Url {
66+
match self {
67+
DocumentKey::NotebookCell(url) => url,
68+
DocumentKey::Notebook(path) | DocumentKey::Text(path) => {
69+
lsp_types::Url::from_file_path(path)
70+
.expect("file path originally from URL should convert back to URL")
71+
}
72+
}
73+
}
74+
}
75+
76+
impl std::fmt::Display for DocumentKey {
77+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78+
match self {
79+
Self::NotebookCell(url) => url.fmt(f),
80+
Self::Notebook(path) | Self::Text(path) => path.display().fmt(f),
81+
}
82+
}
83+
}
84+
3285
/// Tracks multi-document edits to eventually merge into a `WorkspaceEdit`.
3386
/// Compatible with clients that don't support `workspace.workspaceEdit.documentChanges`.
3487
#[derive(Debug)]
@@ -72,6 +125,18 @@ impl WorkspaceEditTracker {
72125
}
73126
}
74127

128+
/// Sets a series of [`Fixes`] for a text or notebook document.
129+
pub(crate) fn set_fixes_for_document(
130+
&mut self,
131+
fixes: Fixes,
132+
version: DocumentVersion,
133+
) -> crate::Result<()> {
134+
for (uri, edits) in fixes {
135+
self.set_edits_for_document(uri, version, edits)?;
136+
}
137+
Ok(())
138+
}
139+
75140
/// Sets the edits made to a specific document. This should only be called
76141
/// once for each document `uri`, and will fail if this is called for the same `uri`
77142
/// multiple times.

crates/ruff_server/src/edit/document.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub(crate) type DocumentVersion = i32;
1010
/// The state for an individual document in the server. Stays up-to-date
1111
/// with changes made by the user, including unsaved changes.
1212
#[derive(Debug, Clone)]
13-
pub struct Document {
13+
pub struct TextDocument {
1414
/// The string contents of the document.
1515
contents: String,
1616
/// A computed line index for the document. This should always reflect
@@ -22,7 +22,7 @@ pub struct Document {
2222
version: DocumentVersion,
2323
}
2424

25-
impl Document {
25+
impl TextDocument {
2626
pub fn new(contents: String, version: DocumentVersion) -> Self {
2727
let index = LineIndex::from_source_text(&contents);
2828
Self {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
use std::{collections::HashMap, hash::BuildHasherDefault};
2+
3+
use anyhow::Ok;
4+
use lsp_types::{NotebookCellKind, Url};
5+
use rustc_hash::FxHashMap;
6+
7+
use crate::{PositionEncoding, TextDocument};
8+
9+
use super::DocumentVersion;
10+
11+
#[derive(Clone, Debug)]
12+
pub(crate) struct NotebookDocument {
13+
cells: Vec<NotebookCell>,
14+
metadata: ruff_notebook::RawNotebookMetadata,
15+
version: DocumentVersion,
16+
cell_index: FxHashMap<lsp_types::Url, usize>,
17+
}
18+
19+
#[derive(Clone, Debug)]
20+
struct NotebookCell {
21+
url: Url,
22+
kind: NotebookCellKind,
23+
document: TextDocument,
24+
}
25+
26+
impl NotebookDocument {
27+
pub(crate) fn new(
28+
version: DocumentVersion,
29+
cells: Vec<lsp_types::NotebookCell>,
30+
metadata: serde_json::Map<String, serde_json::Value>,
31+
cell_documents: Vec<lsp_types::TextDocumentItem>,
32+
) -> crate::Result<Self> {
33+
let mut cell_contents: FxHashMap<_, _> = cell_documents
34+
.into_iter()
35+
.map(|document| (document.uri, document.text))
36+
.collect();
37+
38+
let cells: Vec<_> = cells
39+
.into_iter()
40+
.map(|cell| {
41+
let contents = cell_contents.remove(&cell.document).unwrap_or_default();
42+
NotebookCell::new(cell, contents, version)
43+
})
44+
.collect();
45+
46+
Ok(Self {
47+
version,
48+
cell_index: Self::make_cell_index(cells.as_slice()),
49+
metadata: serde_json::from_value(serde_json::Value::Object(metadata))?,
50+
cells,
51+
})
52+
}
53+
54+
pub(crate) fn make_ruff_notebook(&self) -> ruff_notebook::Notebook {
55+
let cells = self
56+
.cells
57+
.iter()
58+
.map(|cell| match cell.kind {
59+
NotebookCellKind::Code => ruff_notebook::Cell::Code(ruff_notebook::CodeCell {
60+
execution_count: None,
61+
id: None,
62+
metadata: serde_json::Value::Null,
63+
outputs: vec![],
64+
source: ruff_notebook::SourceValue::String(
65+
cell.document.contents().to_string(),
66+
),
67+
}),
68+
NotebookCellKind::Markup => {
69+
ruff_notebook::Cell::Markdown(ruff_notebook::MarkdownCell {
70+
attachments: None,
71+
id: None,
72+
metadata: serde_json::Value::Null,
73+
source: ruff_notebook::SourceValue::String(
74+
cell.document.contents().to_string(),
75+
),
76+
})
77+
}
78+
})
79+
.collect();
80+
81+
ruff_notebook::Notebook::from_cells(cells, self.metadata.clone())
82+
.expect("notebook should convert successfully")
83+
}
84+
85+
pub(crate) fn update(
86+
&mut self,
87+
cells: Option<lsp_types::NotebookDocumentCellChange>,
88+
metadata_change: Option<serde_json::Map<String, serde_json::Value>>,
89+
version: DocumentVersion,
90+
encoding: PositionEncoding,
91+
) -> crate::Result<()> {
92+
self.version = version;
93+
94+
if let Some(lsp_types::NotebookDocumentCellChange {
95+
structure,
96+
data,
97+
text_content,
98+
}) = cells
99+
{
100+
if let Some(structure) = structure {
101+
let start = usize::try_from(structure.array.start).unwrap();
102+
let delete = usize::try_from(structure.array.delete_count).unwrap();
103+
if delete > 0 {
104+
self.cells.drain(start..start + delete);
105+
}
106+
for cell in structure.array.cells.into_iter().flatten().rev() {
107+
self.cells
108+
.insert(start, NotebookCell::new(cell, String::new(), version));
109+
}
110+
111+
// the array has been updated - rebuild the cell index
112+
self.rebuild_cell_index();
113+
}
114+
if let Some(cell_data) = data {
115+
for cell in cell_data {
116+
if let Some(existing_cell) = self.cell_by_uri_mut(&cell.document) {
117+
existing_cell.kind = cell.kind;
118+
}
119+
}
120+
}
121+
if let Some(content_changes) = text_content {
122+
for content_change in content_changes {
123+
if let Some(cell) = self.cell_by_uri_mut(&content_change.document.uri) {
124+
cell.document
125+
.apply_changes(content_change.changes, version, encoding);
126+
}
127+
}
128+
}
129+
}
130+
if let Some(metadata_change) = metadata_change {
131+
self.metadata = serde_json::from_value(serde_json::Value::Object(metadata_change))?;
132+
}
133+
Ok(())
134+
}
135+
136+
pub(crate) fn version(&self) -> DocumentVersion {
137+
self.version
138+
}
139+
140+
pub(crate) fn cell_uri_by_index(&self, index: usize) -> Option<&lsp_types::Url> {
141+
self.cells.get(index).map(|cell| &cell.url)
142+
}
143+
144+
pub(crate) fn cell_document_by_uri(&self, uri: &lsp_types::Url) -> Option<&TextDocument> {
145+
self.cells
146+
.get(*self.cell_index.get(uri)?)
147+
.map(|cell| &cell.document)
148+
}
149+
150+
pub(crate) fn urls(&self) -> impl Iterator<Item = &lsp_types::Url> {
151+
self.cells.iter().map(|cell| &cell.url)
152+
}
153+
154+
fn cell_by_uri_mut(&mut self, uri: &lsp_types::Url) -> Option<&mut NotebookCell> {
155+
self.cells.get_mut(*self.cell_index.get(uri)?)
156+
}
157+
158+
fn rebuild_cell_index(&mut self) {
159+
self.cell_index = Self::make_cell_index(&self.cells);
160+
}
161+
162+
fn make_cell_index(cells: &[NotebookCell]) -> FxHashMap<lsp_types::Url, usize> {
163+
let mut index =
164+
HashMap::with_capacity_and_hasher(cells.len(), BuildHasherDefault::default());
165+
for (i, cell) in cells.iter().enumerate() {
166+
index.insert(cell.url.clone(), i);
167+
}
168+
index
169+
}
170+
}
171+
172+
impl NotebookCell {
173+
pub(crate) fn new(
174+
cell: lsp_types::NotebookCell,
175+
contents: String,
176+
version: DocumentVersion,
177+
) -> Self {
178+
Self {
179+
url: cell.document,
180+
kind: cell.kind,
181+
document: TextDocument::new(contents, version),
182+
}
183+
}
184+
}

0 commit comments

Comments
 (0)