Skip to main content

ruff_notebook/
cell.rs

1use std::fmt;
2use std::ops::{Deref, DerefMut};
3
4use ruff_text_size::{TextRange, TextSize};
5
6use crate::schema::{Cell, SourceValue};
7use crate::{CellMetadata, SYNTHETIC_CELL_SEPARATOR};
8
9impl fmt::Display for SourceValue {
10    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
11        match self {
12            SourceValue::String(string) => f.write_str(string),
13            SourceValue::StringArray(string_array) => {
14                for string in string_array {
15                    f.write_str(string)?;
16                }
17                Ok(())
18            }
19        }
20    }
21}
22
23impl Cell {
24    /// Return the [`SourceValue`] of the cell.
25    pub fn source(&self) -> &SourceValue {
26        match self {
27            Cell::Code(cell) => &cell.source,
28            Cell::Markdown(cell) => &cell.source,
29            Cell::Raw(cell) => &cell.source,
30        }
31    }
32
33    pub fn is_code_cell(&self) -> bool {
34        matches!(self, Cell::Code(_))
35    }
36
37    pub fn metadata(&self) -> &CellMetadata {
38        match self {
39            Cell::Code(cell) => &cell.metadata,
40            Cell::Markdown(cell) => &cell.metadata,
41            Cell::Raw(cell) => &cell.metadata,
42        }
43    }
44
45    /// Update the [`SourceValue`] of the cell.
46    pub(crate) fn set_source(&mut self, source: SourceValue) {
47        match self {
48            Cell::Code(cell) => cell.source = source,
49            Cell::Markdown(cell) => cell.source = source,
50            Cell::Raw(cell) => cell.source = source,
51        }
52    }
53
54    /// Return `true` if it's a valid code cell.
55    ///
56    /// A valid code cell is a cell where:
57    /// 1. The cell type is [`Cell::Code`]
58    /// 2. The source doesn't contain a cell magic
59    /// 3. If the language id is set, it should be `python`
60    pub(crate) fn is_valid_python_code_cell(&self) -> bool {
61        let source = match self {
62            Cell::Code(cell)
63                if cell
64                    .metadata
65                    .vscode
66                    .as_ref()
67                    .is_none_or(|vscode| vscode.language_id == "python") =>
68            {
69                &cell.source
70            }
71            _ => return false,
72        };
73        // Ignore cells containing cell magic as they act on the entire cell
74        // as compared to line magic which acts on a single line.
75        !match source {
76            SourceValue::String(string) => Self::is_magic_cell(string.lines()),
77            SourceValue::StringArray(string_array) => {
78                Self::is_magic_cell(string_array.iter().map(String::as_str))
79            }
80        }
81    }
82
83    /// Returns `true` if a cell should be ignored due to the use of cell magics.
84    fn is_magic_cell<'a>(lines: impl Iterator<Item = &'a str>) -> bool {
85        let mut lines = lines.peekable();
86
87        // Detect automatic line magics (automagic), which aren't supported by the parser. If a line
88        // magic uses automagic, Jupyter doesn't allow following it with non-magic lines anyway, so
89        // we aren't missing out on any valid Python code.
90        //
91        // For example, this is valid:
92        // ```jupyter
93        // cat /path/to/file
94        // cat /path/to/file
95        // ```
96        //
97        // But this is invalid:
98        // ```jupyter
99        // cat /path/to/file
100        // x = 1
101        // ```
102        //
103        // See: https://ipython.readthedocs.io/en/stable/interactive/magics.html
104        if let Some(line) = lines.peek() {
105            let mut tokens = line.split_whitespace();
106
107            // The first token must be an automagic, like `load_exit`.
108            if tokens.next().is_some_and(|token| {
109                matches!(
110                    token,
111                    "alias"
112                        | "alias_magic"
113                        | "autoawait"
114                        | "autocall"
115                        | "automagic"
116                        | "bookmark"
117                        | "cd"
118                        | "code_wrap"
119                        | "colors"
120                        | "conda"
121                        | "config"
122                        | "debug"
123                        | "dhist"
124                        | "dirs"
125                        | "doctest_mode"
126                        | "edit"
127                        | "env"
128                        | "gui"
129                        | "history"
130                        | "killbgscripts"
131                        | "load"
132                        | "load_ext"
133                        | "loadpy"
134                        | "logoff"
135                        | "logon"
136                        | "logstart"
137                        | "logstate"
138                        | "logstop"
139                        | "lsmagic"
140                        | "macro"
141                        | "magic"
142                        | "mamba"
143                        | "matplotlib"
144                        | "micromamba"
145                        | "notebook"
146                        | "page"
147                        | "pastebin"
148                        | "pdb"
149                        | "pdef"
150                        | "pdoc"
151                        | "pfile"
152                        | "pinfo"
153                        | "pinfo2"
154                        | "pip"
155                        | "popd"
156                        | "pprint"
157                        | "precision"
158                        | "prun"
159                        | "psearch"
160                        | "psource"
161                        | "pushd"
162                        | "pwd"
163                        | "pycat"
164                        | "pylab"
165                        | "quickref"
166                        | "recall"
167                        | "rehashx"
168                        | "reload_ext"
169                        | "rerun"
170                        | "reset"
171                        | "reset_selective"
172                        | "run"
173                        | "save"
174                        | "sc"
175                        | "set_env"
176                        | "sx"
177                        | "system"
178                        | "tb"
179                        | "time"
180                        | "timeit"
181                        | "unalias"
182                        | "unload_ext"
183                        | "who"
184                        | "who_ls"
185                        | "whos"
186                        | "xdel"
187                        | "xmode"
188                )
189            }) {
190                // The second token must _not_ be an operator, like `=` (to avoid false positives).
191                // The assignment operators can never follow an automagic. Some binary operators
192                // _can_, though (e.g., `cd -` is valid), so we omit them.
193                if !tokens.next().is_some_and(|token| {
194                    matches!(
195                        token,
196                        "=" | "+=" | "-=" | "*=" | "/=" | "//=" | "%=" | "**=" | "&=" | "|=" | "^="
197                    )
198                }) {
199                    return true;
200                }
201            }
202        }
203
204        // Detect cell magics (which operate on multiple lines).
205        lines.any(|line| {
206            let Some(first) = line.split_whitespace().next() else {
207                return false;
208            };
209            if first.len() < 2 {
210                return false;
211            }
212            let Some(command) = first.strip_prefix("%%") else {
213                return false;
214            };
215            // These cell magics are special in that the lines following them are valid
216            // Python code and the variables defined in that scope are available to the
217            // rest of the notebook.
218            //
219            // For example:
220            //
221            // Cell 1:
222            // ```python
223            // x = 1
224            // ```
225            //
226            // Cell 2:
227            // ```python
228            // %%time
229            // y = x
230            // ```
231            //
232            // Cell 3:
233            // ```python
234            // print(y)  # Here, `y` is available.
235            // ```
236            //
237            // This is to avoid false positives when these variables are referenced
238            // elsewhere in the notebook.
239            //
240            // Refer https://github.com/astral-sh/ruff/issues/13718 for `ipytest`.
241            !matches!(
242                command,
243                "capture"
244                    | "debug"
245                    | "ipytest"
246                    | "prun"
247                    | "pypy"
248                    | "python"
249                    | "python3"
250                    | "time"
251                    | "timeit"
252            )
253        })
254    }
255}
256
257/// Cell offsets are used to keep track of the start and end offsets of each
258/// cell in the concatenated source code. These offsets are in sorted order.
259#[derive(Clone, Debug, Default, PartialEq)]
260pub struct CellOffsets(Vec<TextSize>);
261
262impl CellOffsets {
263    /// Create a new [`CellOffsets`] with the given capacity.
264    pub(crate) fn with_capacity(capacity: usize) -> Self {
265        Self(Vec::with_capacity(capacity))
266    }
267
268    /// Push a new offset to the end of the [`CellOffsets`].
269    ///
270    /// # Panics
271    ///
272    /// Panics if the offset is less than the last offset pushed.
273    pub(crate) fn push(&mut self, offset: TextSize) {
274        if let Some(last_offset) = self.0.last() {
275            assert!(
276                *last_offset <= offset,
277                "Offsets must be pushed in sorted order"
278            );
279        }
280        self.0.push(offset);
281    }
282
283    /// Returns the range of the cell containing the given offset, if any.
284    pub fn containing_range(&self, offset: TextSize) -> Option<TextRange> {
285        self.array_windows::<2>().find_map(|[start, end]| {
286            if *start <= offset && offset < *end {
287                Some(TextRange::new(*start, *end))
288            } else {
289                None
290            }
291        })
292    }
293
294    /// Returns `true` if the given range contains a cell boundary.
295    ///
296    /// A range starting at the cell boundary isn't considered to contain the cell boundary
297    /// as it starts right after it. A range starting before a cell boundary
298    /// and ending exactly at the boundary is considered to contain the cell boundary.
299    ///
300    /// # Examples
301    /// Cell 1:
302    ///
303    /// ```py
304    /// import c
305    /// ```
306    ///
307    /// Cell 2:
308    ///
309    /// ```py
310    /// import os
311    /// ```
312    ///
313    /// The range `import c`..`import os`, contains a cell boundary because it starts before cell 2 and ends in cell 2 (`end=cell2_boundary`).
314    /// The `import os` contains no cell boundary because it starts at the start of cell 2 (at the cell boundary) but doesn't cross into another cell.
315    pub fn has_cell_boundary(&self, range: TextRange) -> bool {
316        let after_range_start = self.partition_point(|offset| *offset <= range.start());
317        let Some(boundary) = self.get(after_range_start).copied() else {
318            return false;
319        };
320
321        range.contains_inclusive(boundary)
322    }
323
324    /// Returns an iterator over [`TextRange`]s covered by each cell.
325    pub fn ranges(&self) -> impl Iterator<Item = TextRange> {
326        self.array_windows::<2>()
327            .map(|[start, end]| TextRange::new(*start, *end))
328    }
329
330    /// Returns an iterator over the concatenated source ranges covered by each cell's actual
331    /// contents, excluding Ruff's synthetic trailing newline separator.
332    pub fn content_ranges(&self) -> impl Iterator<Item = TextRange> {
333        self.ranges().map(|range| {
334            let end = range
335                .end()
336                .checked_sub(TextSize::of(SYNTHETIC_CELL_SEPARATOR))
337                .expect("cell ranges should include the synthetic separator newline");
338            TextRange::new(range.start(), end)
339        })
340    }
341}
342
343impl Deref for CellOffsets {
344    type Target = [TextSize];
345
346    fn deref(&self) -> &Self::Target {
347        &self.0
348    }
349}
350
351impl DerefMut for CellOffsets {
352    fn deref_mut(&mut self) -> &mut Self::Target {
353        &mut self.0
354    }
355}