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}