Skip to main content

ruff_notebook/
notebook.rs

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
22/// Run round-trip source code generation on a given Jupyter notebook file path.
23pub 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/// An error that can occur while deserializing a Jupyter Notebook.
43#[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    /// Python source code of the notebook.
62    ///
63    /// This is the concatenation of all valid code cells in the notebook
64    /// separated by a newline and a trailing newline. The trailing newline
65    /// is added to make sure that each cell ends with a newline which will
66    /// be removed when updating the cell content.
67    source_code: String,
68    /// The index of the notebook. This is used to map between the concatenated
69    /// source code and the original notebook.
70    index: OnceLock<NotebookIndex>,
71    /// The raw notebook i.e., the deserialized version of JSON string.
72    raw: RawNotebook,
73    /// The offsets of each cell in the concatenated source code. This includes
74    /// the first and last character offsets as well.
75    cell_offsets: CellOffsets,
76    /// The cell index of all valid code cells in the notebook.
77    valid_code_cells: Vec<u32>,
78    /// Flag to indicate if the JSON string of the notebook has a trailing newline.
79    trailing_newline: bool,
80}
81
82impl Notebook {
83    /// Read the Jupyter Notebook from the given [`Path`].
84    pub fn from_path(path: &Path) -> Result<Self, NotebookError> {
85        Self::from_reader(BufReader::new(File::open(path)?))
86    }
87
88    /// Read the Jupyter Notebook from its JSON string.
89    pub fn from_source_code(source_code: &str) -> Result<Self, NotebookError> {
90        Self::from_reader(Cursor::new(source_code))
91    }
92
93    /// Read a Jupyter Notebook from a [`Read`] implementer.
94    ///
95    /// See also the black implementation
96    /// <https://github.com/psf/black/blob/69ca0a4c7a365c5f5eea519a90980bab72cab764/src/black/__init__.py#L1017-L1046>
97    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                // Translate the error into a diagnostic
110                return Err(match err.classify() {
111                    Category::Io => NotebookError::Json(err),
112                    Category::Syntax | Category::Eof => NotebookError::InvalidJson(err),
113                    Category::Data => {
114                        // We could try to read the schema version here but if this fails it's
115                        // a bug anyway.
116                        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        // v4 is what everybody uses
129        if raw_notebook.nbformat != 4 {
130            // bail because we should have already failed at the json schema stage
131            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        // Add cell ids to 4.5+ notebooks if they are missing
143        // https://github.com/astral-sh/ruff/issues/6834
144        // https://github.com/jupyter/enhancement-proposals/blob/master/62-cell-id/cell-id.md#required-field
145        // https://github.com/jupyter/enhancement-proposals/blob/master/62-cell-id/cell-id.md#questions
146        if raw_notebook.nbformat == 4 && raw_notebook.nbformat_minor >= 5 {
147            // We use a insecure random number generator to generate deterministic uuids
148            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    /// Creates an empty notebook with a single code cell.
198    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    /// Build the concatenated source code and cell offsets from the raw notebook.
218    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        // The additional newline maintains a consistent source representation
240        // for notebooks without any valid Python code cells. Synthetic newlines
241        // are removed before updating the raw cell content. Refer
242        // `update_cell_content`.
243        if valid_code_cells.is_empty() {
244            source_code.push(SYNTHETIC_CELL_SEPARATOR);
245        }
246
247        (source_code, cell_offsets)
248    }
249
250    /// Update the cell offsets as per the given [`SourceMap`].
251    fn update_cell_offsets(&mut self, source_map: &SourceMap) {
252        // When there are multiple cells without any edits, the offsets of those
253        // cells will be updated using the same marker. So, we can keep track of
254        // the last marker used to update the offsets and check if it's still
255        // the closest marker to the current offset.
256        let mut last_marker: Option<&SourceMarker> = None;
257
258        // The first offset is always going to be at 0, so skip it.
259        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                        // There are no markers above the current offset, so we can
266                        // stop here.
267                        break;
268                    };
269                    // An internal offset is also the start of the following cell, so prefer the
270                    // first marker at that offset. The final offset is only a cell end.
271                    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    /// Update the cell contents with the transformed content.
293    ///
294    /// Returns `true` if a cell separator was removed and the source code and
295    /// cell offsets need to be rebuilt.
296    ///
297    /// ## Panics
298    ///
299    /// Panics if the transformed content is out of bounds for any cell. This
300    /// can happen only if the cell offsets were not updated before calling
301    /// this method or the offsets were updated incorrectly.
302    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                    // We only need to strip the trailing newline which we added
321                    // while concatenating the cell contents.
322                    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    /// Build and return the [`NotebookIndex`].
333    ///
334    /// ## Notes
335    ///
336    /// Each cell range includes its synthetic newline separator. Counting the
337    /// lines in the concatenated source accounts for empty cells and for cells
338    /// that already end in a newline.
339    ///
340    /// For example, the source array:
341    /// ```text
342    /// ["import os\n", "import sys\n"]
343    /// ```
344    /// is joined with the synthetic separator to form `"import os\nimport sys\n\n"`,
345    /// which occupies three rows. Array entries aren't necessarily lines, though:
346    /// `["p", "a", "s", "s"]` is joined with the separator to form `"pass\n"` and
347    /// occupies one row.
348    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            // Record the starting row of this cell
356            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    /// Return the notebook content.
370    ///
371    /// This is the concatenation of all Python code cells.
372    pub fn source_code(&self) -> &str {
373        &self.source_code
374    }
375
376    /// Return the Jupyter notebook index.
377    ///
378    /// The index is built only once when required. This is only used to
379    /// report diagnostics, so by that time all of the fixes must have
380    /// been applied if `--fix` was passed.
381    pub fn index(&self) -> &NotebookIndex {
382        self.index.get_or_init(|| self.build_index())
383    }
384
385    /// Return the Jupyter notebook index, consuming the notebook.
386    ///
387    /// The index is built only once when required. This is only used to
388    /// report diagnostics, so by that time all of the fixes must have
389    /// been applied if `--fix` was passed.
390    pub fn into_index(mut self) -> NotebookIndex {
391        self.index.take().unwrap_or_else(|| self.build_index())
392    }
393
394    /// Return the [`CellOffsets`] for the concatenated source code corresponding
395    /// the Jupyter notebook.
396    pub fn cell_offsets(&self) -> &CellOffsets {
397        &self.cell_offsets
398    }
399
400    /// Returns the start offset of the cell at index `cell` in the concatenated
401    /// text document.
402    pub fn cell_offset(&self, cell: OneIndexed) -> Option<TextSize> {
403        self.cell_offsets.get(cell.to_zero_indexed()).copied()
404    }
405
406    /// Returns the text range in the concatenated document of the cell
407    /// with index `cell`.
408    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    /// Return `true` if the notebook has a trailing newline, `false` otherwise.
416    pub fn trailing_newline(&self) -> bool {
417        self.trailing_newline
418    }
419
420    /// Update the notebook with the given sourcemap and transformed content.
421    pub fn update(&mut self, source_map: &SourceMap, transformed: String) {
422        // Cell offsets must be updated before updating the cell content as
423        // it depends on the offsets to extract the cell content.
424        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            // A fix that empties a cell can also remove its synthetic newline
431            // separator. For example, deleting `"import os\n"` from the first
432            // cell changes offsets `[0, 10, 16]` to `[0, 0, 6]`. Rebuild the
433            // source and offsets to restore the separator as `[0, 1, 7]`.
434            (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    /// Return a slice of [`Cell`] in the Jupyter notebook.
442    pub fn cells(&self) -> &[Cell] {
443        &self.raw.cells
444    }
445
446    /// Check if it's a Python notebook.
447    ///
448    /// This is determined by checking the `language_info` or `kernelspec` in the notebook
449    /// metadata. If neither is present, it's assumed to be a Python notebook.
450    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    /// Write the notebook back to the given [`Write`] implementer.
461    pub fn write(&self, writer: &mut dyn Write) -> Result<(), NotebookError> {
462        // https://github.com/psf/black/blob/69ca0a4c7a365c5f5eea519a90980bab72cab764/src/black/__init__.py#LL1041
463        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    /// Construct a path to a Jupyter notebook in the `resources/test/fixtures/jupyter` directory.
497    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(&notebook_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(&notebook_path("invalid_extension.ipynb")),
513            Err(NotebookError::InvalidJson(_))
514        ));
515        assert!(matches!(
516            Notebook::from_path(&notebook_path("not_json.ipynb")),
517            Err(NotebookError::InvalidJson(_))
518        ));
519        assert!(matches!(
520            Notebook::from_path(&notebook_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        /// Read a Jupyter cell from the `resources/test/fixtures/jupyter/cell` directory.
548        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(&notebook_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}