use std::path::{Path, PathBuf};
use anyhow::Result;
use insta::assert_json_snapshot;
use lsp_types::{
DidChangeNotebookDocumentNotification, DidOpenNotebookDocumentNotification,
NotebookDocumentCellContentChanges, TextDocumentContentChangePartial, TextDocumentIdentifier,
};
use lsp_types::{
DidChangeNotebookDocumentParams, DidOpenNotebookDocumentParams, LspObject, NotebookDocument,
NotebookDocumentCellChanges, NotebookDocumentChangeEvent, Position, Range,
TextDocumentContentChangeEvent, TextDocumentItem, VersionedNotebookDocumentIdentifier,
VersionedTextDocumentIdentifier,
};
use ruff_notebook::SourceValue;
use crate::TestServerBuilder;
const NOTEBOOK_FIXTURE_PATH: &str = "resources/test/fixtures/tensorflow_test_notebook.ipynb";
struct NotebookChange {
version: i32,
metadata: Option<LspObject>,
updated_cells: NotebookDocumentCellChanges,
}
#[test]
fn pull_diagnostics_for_notebook_cells() -> Result<()> {
let mut server = TestServerBuilder::new()?
.with_workspace(".")?
.with_file(
"pyproject.toml",
"[tool.ruff.lint]\nselect = [\"F401\", \"F811\"]\n",
)?
.build();
let notebook_path = server.file_path("test.ipynb");
let cell_uris = [
make_cell_uri(¬ebook_path, 0),
make_cell_uri(¬ebook_path, 1),
make_cell_uri(¬ebook_path, 2),
];
let cells = cell_uris
.iter()
.cloned()
.map(|uri| lsp_types::NotebookCell {
kind: lsp_types::NotebookCellKind::Code,
document: uri,
metadata: None,
execution_summary: None,
})
.collect();
let cell_text_documents = cell_uris
.iter()
.cloned()
.zip(["import sys\n", "import os\nimport os\n", "os.getcwd()\n"])
.map(|(uri, source)| {
TextDocumentItem::new(uri, lsp_types::LanguageKind::Python, 0, source.to_string())
})
.collect();
server.send_notification::<DidOpenNotebookDocumentNotification>(
DidOpenNotebookDocumentParams {
notebook_document: NotebookDocument {
uri: server.file_uri("test.ipynb"),
notebook_type: "jupyter-notebook".to_string(),
version: 0,
metadata: None,
cells,
},
cell_text_documents,
},
);
let expected_diagnostics = server.collect_publish_diagnostic_notifications(cell_uris.len());
assert_eq!(expected_diagnostics[&cell_uris[0]].len(), 1);
assert_eq!(expected_diagnostics[&cell_uris[1]].len(), 1);
assert!(expected_diagnostics[&cell_uris[2]].is_empty());
for uri in cell_uris {
let request_id = server.send_request::<lsp_types::DocumentDiagnosticRequest>(
lsp_types::DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
identifier: Some("ruff".to_string()),
previous_result_id: None,
work_done_progress_params: lsp_types::WorkDoneProgressParams::default(),
partial_result_params: lsp_types::PartialResultParams::default(),
},
);
let report = server.await_response::<lsp_types::DocumentDiagnosticRequest>(&request_id);
let lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(report) =
report
else {
panic!("Expected a full diagnostic report for {uri}");
};
assert_eq!(
report.full_document_diagnostic_report.items, expected_diagnostics[&uri],
"Pull diagnostics do not match published diagnostics for {uri}"
);
}
Ok(())
}
#[test]
fn related_information() -> Result<()> {
let mut server = TestServerBuilder::new()?
.with_workspace(".")?
.with_file(
"pyproject.toml",
r#"
[tool.ruff.lint]
select = ["F811"]
"#,
)?
.enable_diagnostic_related_information(true)
.build();
let notebook_path = server.file_path("test.ipynb");
let cell_uris = [
make_cell_uri(¬ebook_path, 0),
make_cell_uri(¬ebook_path, 1),
];
let cells = cell_uris
.iter()
.cloned()
.map(|uri| lsp_types::NotebookCell {
kind: lsp_types::NotebookCellKind::Code,
document: uri,
metadata: None,
execution_summary: None,
})
.collect();
let cell_text_documents = cell_uris
.iter()
.cloned()
.zip(["import os\n", "import os\n"])
.map(|(uri, source)| {
TextDocumentItem::new(uri, lsp_types::LanguageKind::Python, 0, source.to_string())
})
.collect();
server.send_notification::<DidOpenNotebookDocumentNotification>(
DidOpenNotebookDocumentParams {
notebook_document: NotebookDocument {
uri: server.file_uri("test.ipynb"),
notebook_type: "jupyter-notebook".to_string(),
version: 0,
metadata: None,
cells,
},
cell_text_documents,
},
);
let diagnostics = server.collect_publish_diagnostic_notifications(cell_uris.len());
assert!(
diagnostics
.get(&cell_uris[0])
.expect("first cell to have diagnostics published")
.is_empty()
);
let [diagnostic] = diagnostics
.get(&cell_uris[1])
.expect("second cell to have diagnostics published")
.as_slice()
else {
panic!("second cell to have one diagnostic");
};
let [related_information] = diagnostic
.related_information
.as_deref()
.expect("client supports diagnostic related information")
else {
panic!("diagnostic to have one related information entry");
};
assert_eq!(related_information.location.uri, cell_uris[0]);
assert_eq!(
related_information.location.range,
Range {
start: Position {
line: 0,
character: 7,
},
end: Position {
line: 0,
character: 9,
},
}
);
assert_eq!(
related_information.message,
"previous definition of `os` here"
);
Ok(())
}
#[test]
fn super_resolution_overview() -> Result<()> {
let fixture_path = fixture_path(NOTEBOOK_FIXTURE_PATH)?;
let workspace_dir = fixture_path
.parent()
.expect("notebook fixture should have a parent");
let mut server = TestServerBuilder::new()?
.with_workspace(workspace_dir)?
.build();
let (notebook_document, cell_text_documents) =
create_lsp_notebook(&fixture_path, fixture_path.clone())?;
let notebook_uri = notebook_document.uri.clone();
let cell_count = cell_text_documents.len();
server.send_notification::<DidOpenNotebookDocumentNotification>(
DidOpenNotebookDocumentParams {
notebook_document,
cell_text_documents,
},
);
let diagnostics = server.collect_publish_diagnostic_notifications(cell_count);
assert_json_snapshot!("super_resolution_overview_open", diagnostics);
let changes = [
NotebookChange {
version: 0,
metadata: None,
updated_cells: NotebookDocumentCellChanges {
structure: None,
data: None,
text_content: Some(vec![NotebookDocumentCellContentChanges {
document: VersionedTextDocumentIdentifier {
text_document_identifier: TextDocumentIdentifier {
uri: make_cell_uri(&fixture_path, 5),
},
version: 2,
},
changes: vec![
TextDocumentContentChangeEvent::TextDocumentContentChangePartial(
TextDocumentContentChangePartial {
range: Range {
start: Position {
line: 18,
character: 61,
},
end: Position {
line: 18,
character: 62,
},
},
text: "\"".to_string(),
..Default::default()
},
),
TextDocumentContentChangeEvent::TextDocumentContentChangePartial(
TextDocumentContentChangePartial {
range: Range {
start: Position {
line: 18,
character: 55,
},
end: Position {
line: 18,
character: 56,
},
},
text: "\"".to_string(),
..Default::default()
},
),
TextDocumentContentChangeEvent::TextDocumentContentChangePartial(
TextDocumentContentChangePartial {
range: Range {
start: Position {
line: 14,
character: 46,
},
end: Position {
line: 14,
character: 47,
},
},
text: "\"".to_string(),
..Default::default()
},
),
TextDocumentContentChangeEvent::TextDocumentContentChangePartial(
TextDocumentContentChangePartial {
range: Range {
start: Position {
line: 14,
character: 40,
},
end: Position {
line: 14,
character: 41,
},
},
text: "\"".to_string(),
..Default::default()
},
),
],
}]),
},
},
NotebookChange {
version: 1,
metadata: None,
updated_cells: NotebookDocumentCellChanges {
structure: None,
data: None,
text_content: Some(vec![NotebookDocumentCellContentChanges {
document: VersionedTextDocumentIdentifier {
text_document_identifier: TextDocumentIdentifier {
uri: make_cell_uri(&fixture_path, 4),
},
version: 2,
},
changes: vec![TextDocumentContentChangeEvent::TextDocumentContentChangePartial(TextDocumentContentChangePartial {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 181,
},
},
text: "test_img_path = tf.keras.utils.get_file(\n \"lr.jpg\",\n \"https://raw.githubusercontent.com/tensorflow/examples/master/lite/examples/super_resolution/android/app/src/main/assets/lr-1.jpg\",\n)".to_string(),
..Default::default()
})],
}]),
},
},
];
let mut final_diagnostics = None;
for NotebookChange {
version,
metadata,
updated_cells,
} in changes
{
server.send_notification::<DidChangeNotebookDocumentNotification>(
DidChangeNotebookDocumentParams {
notebook_document: VersionedNotebookDocumentIdentifier {
uri: notebook_uri.clone(),
version,
},
change: NotebookDocumentChangeEvent {
metadata,
cells: Some(updated_cells),
},
},
);
final_diagnostics = Some(server.collect_publish_diagnostic_notifications(cell_count));
}
assert_json_snapshot!(
"super_resolution_overview_final",
final_diagnostics.expect("at least one notebook change")
);
Ok(())
}
#[test]
fn notebook_without_ipynb_extension() -> Result<()> {
let fixture_path = fixture_path(NOTEBOOK_FIXTURE_PATH)?;
let workspace_dir = fixture_path
.parent()
.expect("notebook fixture should have a parent");
let mut server = TestServerBuilder::new()?
.with_workspace(workspace_dir)?
.build();
let (notebook_document, cell_text_documents) =
create_lsp_notebook(&fixture_path, workspace_dir.join("notebook.py"))?;
let cell_count = cell_text_documents.len();
server.send_notification::<DidOpenNotebookDocumentNotification>(
DidOpenNotebookDocumentParams {
notebook_document,
cell_text_documents,
},
);
let diagnostics = server.collect_publish_diagnostic_notifications(cell_count);
assert_json_snapshot!("notebook_without_ipynb_extension_open", diagnostics);
Ok(())
}
fn fixture_path(path: &str) -> Result<PathBuf> {
Ok(std::fs::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR")).join(path),
)?)
}
fn create_lsp_notebook(
file_path: &Path,
open_uri_path: PathBuf,
) -> Result<(NotebookDocument, Vec<TextDocumentItem>)> {
let notebook = ruff_notebook::Notebook::from_path(file_path)?;
let notebook_uri = lsp_types::Uri::from_file_path(open_uri_path).unwrap();
let mut cells = Vec::new();
let mut cell_text_documents = Vec::new();
for (index, cell) in notebook
.cells()
.iter()
.filter(|cell| cell.is_code_cell())
.enumerate()
{
let uri = make_cell_uri(file_path, index);
let (lsp_cell, text_document) = cell_to_lsp_cell(cell, uri)?;
cells.push(lsp_cell);
cell_text_documents.push(text_document);
}
Ok((
NotebookDocument {
uri: notebook_uri,
notebook_type: "jupyter-notebook".to_string(),
version: 0,
metadata: None,
cells,
},
cell_text_documents,
))
}
fn make_cell_uri(path: &Path, index: usize) -> lsp_types::Uri {
lsp_types::Uri::parse(&format!(
"notebook-cell:///Users/test/notebooks/{}.ipynb?cell={index}",
path.file_name().unwrap().to_string_lossy()
))
.unwrap()
}
fn cell_to_lsp_cell(
cell: &ruff_notebook::Cell,
cell_uri: lsp_types::Uri,
) -> Result<(lsp_types::NotebookCell, TextDocumentItem)> {
let contents = match cell.source() {
SourceValue::String(string) => string.clone(),
SourceValue::StringArray(array) => array.join(""),
};
let metadata = match serde_json::to_value(cell.metadata())? {
serde_json::Value::Null => None,
metadata @ serde_json::Value::Object(_) => Some(serde_json::from_value(metadata)?),
_ => anyhow::bail!("Notebook cell metadata was not an object"),
};
Ok((
lsp_types::NotebookCell {
kind: match cell {
ruff_notebook::Cell::Code(_) => lsp_types::NotebookCellKind::Code,
ruff_notebook::Cell::Markdown(_) => lsp_types::NotebookCellKind::Markup,
ruff_notebook::Cell::Raw(_) => unreachable!(),
},
document: cell_uri.clone(),
metadata,
execution_summary: None,
},
TextDocumentItem::new(cell_uri, lsp_types::LanguageKind::Python, 0, contents),
))
}