1use rand::{RngExt, SeedableRng};
2use serde::Serialize;
3use serde_json::error::Category;
4use std::cmp::Ordering;
5use std::collections::HashSet;
6use std::fs::File;
7use std::io;
8use std::io::{BufReader, Cursor, Read, Seek, SeekFrom, Write};
9use std::path::Path;
10use std::sync::OnceLock;
11use thiserror::Error;
12
13use ruff_diagnostics::{SourceMap, SourceMarker};
14use ruff_source_file::{OneIndexed, UniversalNewlineIterator};
15use ruff_text_size::{TextRange, TextSize};
16
17use crate::cell::CellOffsets;
18use crate::index::NotebookIndex;
19use crate::schema::{Cell, RawNotebook, SortAlphabetically, SourceValue};
20use crate::{CellMetadata, CellStart, RawNotebookMetadata, SYNTHETIC_CELL_SEPARATOR, schema};
21
22pub fn round_trip(path: &Path) -> anyhow::Result<String> {
24 let mut notebook = Notebook::from_path(path).map_err(|err| {
25 anyhow::anyhow!(
26 "Failed to read notebook file `{}`: {:?}",
27 path.display(),
28 err
29 )
30 })?;
31 let code = notebook.source_code().to_string();
32 let needs_rebuild = notebook.update_cell_content(&code);
33 debug_assert!(
34 !needs_rebuild,
35 "round-tripping unchanged source cannot remove a synthetic cell separator"
36 );
37 let mut writer = Vec::new();
38 notebook.write(&mut writer)?;
39 Ok(String::from_utf8(writer)?)
40}
41
42#[derive(Error, Debug)]
44pub enum NotebookError {
45 #[error(transparent)]
46 Io(#[from] io::Error),
47 #[error(transparent)]
48 Json(serde_json::Error),
49 #[error(
50 "Expected a Jupyter Notebook, which must be internally stored as JSON, but this file isn't valid JSON: {0}"
51 )]
52 InvalidJson(serde_json::Error),
53 #[error("This file does not match the schema expected of Jupyter Notebooks: {0}")]
54 InvalidSchema(serde_json::Error),
55 #[error("Expected Jupyter Notebook format 4, found: {0}")]
56 InvalidFormat(i64),
57}
58
59#[derive(Clone, Debug)]
60pub struct Notebook {
61 source_code: String,
68 index: OnceLock<NotebookIndex>,
71 raw: RawNotebook,
73 cell_offsets: CellOffsets,
76 valid_code_cells: Vec<u32>,
78 trailing_newline: bool,
80}
81
82impl Notebook {
83 pub fn from_path(path: &Path) -> Result<Self, NotebookError> {
85 Self::from_reader(BufReader::new(File::open(path)?))
86 }
87
88 pub fn from_source_code(source_code: &str) -> Result<Self, NotebookError> {
90 Self::from_reader(Cursor::new(source_code))
91 }
92
93 fn from_reader<R>(mut reader: R) -> Result<Self, NotebookError>
98 where
99 R: Read + Seek,
100 {
101 let trailing_newline = reader.seek(SeekFrom::End(-1)).is_ok_and(|_| {
102 let mut buf = [0; 1];
103 reader.read_exact(&mut buf).is_ok_and(|()| buf[0] == b'\n')
104 });
105 reader.rewind()?;
106 let raw_notebook: RawNotebook = match serde_json::from_reader(reader.by_ref()) {
107 Ok(notebook) => notebook,
108 Err(err) => {
109 return Err(match err.classify() {
111 Category::Io => NotebookError::Json(err),
112 Category::Syntax | Category::Eof => NotebookError::InvalidJson(err),
113 Category::Data => {
114 NotebookError::InvalidSchema(err)
117 }
118 });
119 }
120 };
121 Self::from_raw_notebook(raw_notebook, trailing_newline)
122 }
123
124 pub fn from_raw_notebook(
125 mut raw_notebook: RawNotebook,
126 trailing_newline: bool,
127 ) -> Result<Self, NotebookError> {
128 if raw_notebook.nbformat != 4 {
130 return Err(NotebookError::InvalidFormat(raw_notebook.nbformat));
132 }
133
134 let valid_code_cells = raw_notebook
135 .cells
136 .iter()
137 .enumerate()
138 .filter(|(_, cell)| cell.is_valid_python_code_cell())
139 .map(|(cell_index, _)| u32::try_from(cell_index).unwrap())
140 .collect::<Vec<_>>();
141
142 if raw_notebook.nbformat == 4 && raw_notebook.nbformat_minor >= 5 {
147 let mut rng = rand::rngs::StdRng::seed_from_u64(0);
149 let mut existing_ids = HashSet::new();
150
151 for cell in &raw_notebook.cells {
152 let id = match cell {
153 Cell::Code(cell) => &cell.id,
154 Cell::Markdown(cell) => &cell.id,
155 Cell::Raw(cell) => &cell.id,
156 };
157 if let Some(id) = id {
158 existing_ids.insert(id.clone());
159 }
160 }
161
162 for cell in &mut raw_notebook.cells {
163 let id = match cell {
164 Cell::Code(cell) => &mut cell.id,
165 Cell::Markdown(cell) => &mut cell.id,
166 Cell::Raw(cell) => &mut cell.id,
167 };
168 if id.is_none() {
169 loop {
170 let new_id = uuid::Builder::from_random_bytes(rng.random())
171 .into_uuid()
172 .as_simple()
173 .to_string();
174
175 if existing_ids.insert(new_id.clone()) {
176 *id = Some(new_id);
177 break;
178 }
179 }
180 }
181 }
182 }
183
184 let (source_code, cell_offsets) =
185 Self::source_code_and_cell_offsets(&raw_notebook, &valid_code_cells);
186
187 Ok(Self {
188 raw: raw_notebook,
189 index: OnceLock::new(),
190 source_code,
191 cell_offsets,
192 valid_code_cells,
193 trailing_newline,
194 })
195 }
196
197 pub fn empty() -> Self {
199 Self::from_raw_notebook(
200 RawNotebook {
201 cells: vec![schema::Cell::Code(schema::CodeCell {
202 execution_count: None,
203 id: None,
204 metadata: CellMetadata::default(),
205 outputs: vec![],
206 source: schema::SourceValue::String(String::default()),
207 })],
208 metadata: RawNotebookMetadata::default(),
209 nbformat: 4,
210 nbformat_minor: 5,
211 },
212 false,
213 )
214 .unwrap()
215 }
216
217 fn source_code_and_cell_offsets(
219 raw_notebook: &RawNotebook,
220 valid_code_cells: &[u32],
221 ) -> (String, CellOffsets) {
222 let mut source_code = String::new();
223 let mut cell_offsets = CellOffsets::with_capacity(valid_code_cells.len() + 1);
224 cell_offsets.push(TextSize::from(0));
225
226 for &idx in valid_code_cells {
227 match raw_notebook.cells[idx as usize].source() {
228 SourceValue::String(string) => source_code.push_str(string),
229 SourceValue::StringArray(string_array) => {
230 for string in string_array {
231 source_code.push_str(string);
232 }
233 }
234 }
235 source_code.push(SYNTHETIC_CELL_SEPARATOR);
236 cell_offsets.push(TextSize::of(&source_code));
237 }
238
239 if valid_code_cells.is_empty() {
244 source_code.push(SYNTHETIC_CELL_SEPARATOR);
245 }
246
247 (source_code, cell_offsets)
248 }
249
250 fn update_cell_offsets(&mut self, source_map: &SourceMap) {
252 let mut last_marker: Option<&SourceMarker> = None;
257
258 for (index, offset) in self.cell_offsets.iter_mut().skip(1).rev().enumerate() {
260 let closest_marker = match last_marker {
261 Some(marker) if marker.source() < *offset => marker,
262 _ => {
263 let mut markers = source_map.markers().iter().rev();
264 let Some(marker) = markers.find(|marker| marker.source() <= *offset) else {
265 break;
268 };
269 let marker = if index > 0 && marker.source() == *offset {
272 markers
273 .take_while(|marker| marker.source() == *offset)
274 .last()
275 .unwrap_or(marker)
276 } else {
277 marker
278 };
279 last_marker = Some(marker);
280 marker
281 }
282 };
283
284 match closest_marker.source().cmp(&closest_marker.dest()) {
285 Ordering::Less => *offset += closest_marker.dest() - closest_marker.source(),
286 Ordering::Greater => *offset -= closest_marker.source() - closest_marker.dest(),
287 Ordering::Equal => (),
288 }
289 }
290 }
291
292 fn update_cell_content(&mut self, transformed: &str) -> bool {
303 let mut missing_separator = false;
304
305 for (&idx, &[start, end]) in self
306 .valid_code_cells
307 .iter()
308 .zip(self.cell_offsets.array_windows::<2>())
309 {
310 let cell_content = transformed
311 .get(start.to_usize()..end.to_usize())
312 .unwrap_or_else(|| {
313 panic!(
314 "Transformed content out of bounds ({start:?}..{end:?}) for cell at {idx:?}"
315 );
316 });
317 missing_separator |= !cell_content.ends_with(SYNTHETIC_CELL_SEPARATOR);
318 self.raw.cells[idx as usize].set_source(SourceValue::StringArray(
319 UniversalNewlineIterator::from(
320 cell_content.strip_suffix('\n').unwrap_or(cell_content),
323 )
324 .map(|line| line.as_full_str().to_string())
325 .collect::<Vec<_>>(),
326 ));
327 }
328
329 missing_separator
330 }
331
332 fn build_index(&self) -> NotebookIndex {
349 let mut cell_starts = Vec::with_capacity(self.valid_code_cells.len());
350
351 let mut current_row = OneIndexed::MIN;
352
353 for (&cell_index, range) in self.valid_code_cells.iter().zip(self.cell_offsets.ranges()) {
354 let raw_cell_index = cell_index as usize;
355 cell_starts.push(CellStart {
357 start_row: current_row,
358 raw_cell_index: OneIndexed::from_zero_indexed(raw_cell_index),
359 });
360
361 let line_count = UniversalNewlineIterator::from(&self.source_code[range]).count();
362
363 current_row = current_row.saturating_add(line_count);
364 }
365
366 NotebookIndex { cell_starts }
367 }
368
369 pub fn source_code(&self) -> &str {
373 &self.source_code
374 }
375
376 pub fn index(&self) -> &NotebookIndex {
382 self.index.get_or_init(|| self.build_index())
383 }
384
385 pub fn into_index(mut self) -> NotebookIndex {
391 self.index.take().unwrap_or_else(|| self.build_index())
392 }
393
394 pub fn cell_offsets(&self) -> &CellOffsets {
397 &self.cell_offsets
398 }
399
400 pub fn cell_offset(&self, cell: OneIndexed) -> Option<TextSize> {
403 self.cell_offsets.get(cell.to_zero_indexed()).copied()
404 }
405
406 pub fn cell_range(&self, cell: OneIndexed) -> Option<TextRange> {
409 let start = self.cell_offsets.get(cell.to_zero_indexed()).copied()?;
410 let end = self.cell_offsets.get(cell.to_zero_indexed() + 1).copied()?;
411
412 Some(TextRange::new(start, end))
413 }
414
415 pub fn trailing_newline(&self) -> bool {
417 self.trailing_newline
418 }
419
420 pub fn update(&mut self, source_map: &SourceMap, transformed: String) {
422 self.index.take();
425 self.update_cell_offsets(source_map);
426
427 let needs_rebuild = self.update_cell_content(&transformed);
428
429 if needs_rebuild {
430 (self.source_code, self.cell_offsets) =
435 Self::source_code_and_cell_offsets(&self.raw, &self.valid_code_cells);
436 } else {
437 self.source_code = transformed;
438 }
439 }
440
441 pub fn cells(&self) -> &[Cell] {
443 &self.raw.cells
444 }
445
446 pub fn is_python_notebook(&self) -> bool {
451 if let Some(language_info) = self.raw.metadata.language_info.as_ref() {
452 return language_info.name == "python";
453 }
454 if let Some(kernel_spec) = self.raw.metadata.kernelspec.as_ref() {
455 return kernel_spec.language.as_deref() == Some("python");
456 }
457 true
458 }
459
460 pub fn write(&self, writer: &mut dyn Write) -> Result<(), NotebookError> {
462 let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
464 let mut serializer = serde_json::Serializer::with_formatter(writer, formatter);
465 SortAlphabetically(&self.raw)
466 .serialize(&mut serializer)
467 .map_err(NotebookError::Json)?;
468 if self.trailing_newline {
469 writeln!(serializer.into_inner())?;
470 }
471 Ok(())
472 }
473}
474
475impl PartialEq for Notebook {
476 fn eq(&self, other: &Self) -> bool {
477 self.trailing_newline == other.trailing_newline && self.raw == other.raw
478 }
479}
480
481impl Eq for Notebook {}
482
483#[cfg(test)]
484mod tests {
485 use std::path::Path;
486
487 use anyhow::Result;
488 use test_case::test_case;
489
490 use ruff_diagnostics::SourceMap;
491 use ruff_source_file::OneIndexed;
492 use ruff_text_size::TextSize;
493
494 use crate::{Cell, CellStart, Notebook, NotebookError, NotebookIndex};
495
496 fn notebook_path(path: impl AsRef<Path>) -> std::path::PathBuf {
498 Path::new("./resources/test/fixtures/jupyter").join(path)
499 }
500
501 #[test_case("valid.ipynb", true)]
502 #[test_case("R.ipynb", false)]
503 #[test_case("kernelspec_language.ipynb", true)]
504 fn is_python_notebook(filename: &str, expected: bool) {
505 let notebook = Notebook::from_path(¬ebook_path(filename)).unwrap();
506 assert_eq!(notebook.is_python_notebook(), expected);
507 }
508
509 #[test]
510 fn test_invalid() {
511 assert!(matches!(
512 Notebook::from_path(¬ebook_path("invalid_extension.ipynb")),
513 Err(NotebookError::InvalidJson(_))
514 ));
515 assert!(matches!(
516 Notebook::from_path(¬ebook_path("not_json.ipynb")),
517 Err(NotebookError::InvalidJson(_))
518 ));
519 assert!(matches!(
520 Notebook::from_path(¬ebook_path("wrong_schema.ipynb")),
521 Err(NotebookError::InvalidSchema(_))
522 ));
523 }
524
525 #[test]
526 fn empty_notebook() {
527 let notebook = Notebook::empty();
528
529 assert_eq!(notebook.source_code(), "\n");
530 }
531
532 #[test_case("markdown", false)]
533 #[test_case("only_magic", true)]
534 #[test_case("code_and_magic", true)]
535 #[test_case("only_code", true)]
536 #[test_case("cell_magic", false)]
537 #[test_case("valid_cell_magic", true)]
538 #[test_case("automagic", false)]
539 #[test_case("automagic_assignment", true)]
540 #[test_case("automagics", false)]
541 #[test_case("automagic_before_code", false)]
542 #[test_case("automagic_after_code", true)]
543 #[test_case("unicode_magic_gh9145", true)]
544 #[test_case("vscode_language_id_python", true)]
545 #[test_case("vscode_language_id_javascript", false)]
546 fn test_is_valid_python_code_cell(cell: &str, expected: bool) -> Result<()> {
547 fn read_jupyter_cell(path: impl AsRef<Path>) -> Result<Cell> {
549 let path = notebook_path("cell").join(path);
550 let source_code = std::fs::read_to_string(path)?;
551 Ok(serde_json::from_str(&source_code)?)
552 }
553
554 assert_eq!(
555 read_jupyter_cell(format!("{cell}.json"))?.is_valid_python_code_cell(),
556 expected
557 );
558 Ok(())
559 }
560
561 #[test]
562 fn test_concat_notebook() -> Result<(), NotebookError> {
563 let notebook = Notebook::from_path(¬ebook_path("valid.ipynb"))?;
564 assert_eq!(
565 notebook.source_code,
566 r#"def unused_variable():
567 x = 1
568 y = 2
569 print(f"cell one: {y}")
570
571unused_variable()
572def mutable_argument(z=set()):
573 print(f"cell two: {z}")
574
575mutable_argument()
576
577
578
579
580print("after empty cells")
581"#
582 );
583 assert_eq!(
584 notebook.index(),
585 &NotebookIndex {
586 cell_starts: vec![
587 CellStart {
588 start_row: OneIndexed::MIN,
589 raw_cell_index: OneIndexed::MIN
590 },
591 CellStart {
592 start_row: OneIndexed::from_zero_indexed(6),
593 raw_cell_index: OneIndexed::from_zero_indexed(2)
594 },
595 CellStart {
596 start_row: OneIndexed::from_zero_indexed(11),
597 raw_cell_index: OneIndexed::from_zero_indexed(4)
598 },
599 CellStart {
600 start_row: OneIndexed::from_zero_indexed(12),
601 raw_cell_index: OneIndexed::from_zero_indexed(6)
602 },
603 CellStart {
604 start_row: OneIndexed::from_zero_indexed(14),
605 raw_cell_index: OneIndexed::from_zero_indexed(7)
606 }
607 ],
608 }
609 );
610 assert_eq!(
611 notebook.cell_offsets().as_ref(),
612 &[
613 0.into(),
614 90.into(),
615 168.into(),
616 169.into(),
617 171.into(),
618 198.into()
619 ]
620 );
621 Ok(())
622 }
623
624 #[test]
625 fn index_fragmented_source_array() -> Result<(), NotebookError> {
626 let notebook = Notebook::from_source_code(
627 r##"{
628 "cells": [
629 {
630 "cell_type": "code",
631 "execution_count": null,
632 "metadata": {},
633 "outputs": [],
634 "source": ["p", "a", "s", "s", " ", " ", " "]
635 },
636 {
637 "cell_type": "code",
638 "execution_count": null,
639 "metadata": {},
640 "outputs": [],
641 "source": ["# snapshot\n", "x = 1"]
642 }
643 ],
644 "metadata": {},
645 "nbformat": 4,
646 "nbformat_minor": 4
647}"##,
648 )?;
649
650 assert_eq!(notebook.source_code(), "pass \n# snapshot\nx = 1\n");
651 assert_eq!(
652 notebook.index(),
653 &NotebookIndex {
654 cell_starts: vec![
655 CellStart {
656 start_row: OneIndexed::MIN,
657 raw_cell_index: OneIndexed::MIN,
658 },
659 CellStart {
660 start_row: OneIndexed::from_zero_indexed(1),
661 raw_cell_index: OneIndexed::from_zero_indexed(1),
662 },
663 ],
664 }
665 );
666
667 Ok(())
668 }
669
670 #[test]
671 fn update_restores_separators_for_empty_cells() -> Result<(), NotebookError> {
672 let mut notebook = Notebook::from_source_code(
673 r##"{
674 "cells": [
675 {
676 "cell_type": "code",
677 "execution_count": null,
678 "metadata": {},
679 "outputs": [],
680 "source": ["import os"]
681 },
682 {
683 "cell_type": "code",
684 "execution_count": null,
685 "metadata": {},
686 "outputs": [],
687 "source": ["import sys"]
688 },
689 {
690 "cell_type": "code",
691 "execution_count": null,
692 "metadata": {},
693 "outputs": [],
694 "source": ["x = 1"]
695 }
696 ],
697 "metadata": {},
698 "nbformat": 4,
699 "nbformat_minor": 4
700}"##,
701 )?;
702
703 let mut source_map = SourceMap::default();
704 source_map.push_marker(0.into(), 0.into());
705 source_map.push_marker(10.into(), 0.into());
706 source_map.push_marker(21.into(), 0.into());
707 notebook.update(&source_map, "x = 1\n".to_string());
708
709 assert_eq!(notebook.source_code(), "\n\nx = 1\n");
710 assert_eq!(
711 notebook.cell_offsets().as_ref(),
712 &[0.into(), 1.into(), 2.into(), 8.into()]
713 );
714 assert_eq!(
715 notebook.index(),
716 &NotebookIndex {
717 cell_starts: vec![
718 CellStart {
719 start_row: OneIndexed::MIN,
720 raw_cell_index: OneIndexed::MIN,
721 },
722 CellStart {
723 start_row: OneIndexed::from_zero_indexed(1),
724 raw_cell_index: OneIndexed::from_zero_indexed(1),
725 },
726 CellStart {
727 start_row: OneIndexed::from_zero_indexed(2),
728 raw_cell_index: OneIndexed::from_zero_indexed(2),
729 },
730 ],
731 }
732 );
733
734 Ok(())
735 }
736
737 fn two_cell_notebook() -> Result<Notebook, NotebookError> {
738 Notebook::from_source_code(
739 r##"{
740 "cells": [
741 {
742 "cell_type": "code",
743 "execution_count": null,
744 "metadata": {},
745 "outputs": [],
746 "source": ["x = 1"]
747 },
748 {
749 "cell_type": "code",
750 "execution_count": null,
751 "metadata": {},
752 "outputs": [],
753 "source": ["x.method(inplace=True)"]
754 }
755 ],
756 "metadata": {},
757 "nbformat": 4,
758 "nbformat_minor": 4
759}"##,
760 )
761 }
762
763 #[test]
764 fn update_keeps_insertion_at_cell_start_in_that_cell() -> Result<(), NotebookError> {
765 let mut notebook = two_cell_notebook()?;
766
767 let mut source_map = SourceMap::default();
768 source_map.push_marker(6.into(), 6.into());
769 source_map.push_marker(6.into(), 10.into());
770 notebook.update(
771 &source_map,
772 "x = 1\nx = x.method(inplace=True)\n".to_string(),
773 );
774
775 assert_eq!(
776 notebook.source_code(),
777 "x = 1\nx = x.method(inplace=True)\n"
778 );
779 assert_eq!(
780 notebook.cell_offsets().as_ref(),
781 &[0.into(), 6.into(), 33.into()]
782 );
783
784 Ok(())
785 }
786
787 #[test]
788 fn update_keeps_insertion_at_end_in_non_final_cell() -> Result<(), NotebookError> {
789 let mut notebook = two_cell_notebook()?;
790
791 let mut source_map = SourceMap::default();
792 source_map.push_marker(5.into(), 5.into());
793 source_map.push_marker(5.into(), 16.into());
794 notebook.update(
795 &source_map,
796 "x = 1 # comment\nx.method(inplace=True)\n".to_string(),
797 );
798
799 assert_eq!(
800 notebook.source_code(),
801 "x = 1 # comment\nx.method(inplace=True)\n"
802 );
803 assert_eq!(
804 notebook.cell_offsets().as_ref(),
805 &[0.into(), 17.into(), 40.into()]
806 );
807
808 Ok(())
809 }
810
811 #[test]
812 fn update_keeps_insertion_at_end_in_last_cell() {
813 let mut notebook = Notebook::empty();
814 let end = TextSize::of(notebook.source_code());
815 let insertion = "# comment\n";
816 let transformed = format!("{}{insertion}", notebook.source_code());
817
818 let mut source_map = SourceMap::default();
819 source_map.push_marker(end, end);
820 source_map.push_marker(end, end + TextSize::of(insertion));
821 notebook.update(&source_map, transformed.clone());
822
823 assert_eq!(
824 notebook.cell_offsets().last().copied(),
825 Some(TextSize::of(&transformed))
826 );
827 assert_eq!(notebook.cells()[0].source().to_string(), "\n# comment");
828 }
829
830 #[test_case("vscode_language_id.ipynb")]
831 #[test_case("kernelspec_language.ipynb")]
832 fn round_trip(filename: &str) {
833 let path = notebook_path(filename);
834 let expected = std::fs::read_to_string(&path).unwrap();
835 let actual = super::round_trip(&path).unwrap();
836 assert_eq!(actual, expected);
837 }
838}