Skip to main content

ravenrs_sheet/model/
python.rs

1//! Python cells: the code a cell holds, its last result, and the text forms around it (the
2//! `PY("…",0|1)` formula of the function's syntax, and the `xl("…")` references inside it). An
3//! xlsx file keeps the code in its Python part instead (see the xlsx crate).
4
5use crate::base::{CellError, Value};
6use serde::{Deserialize, Serialize};
7
8/// Excel's limit on the characters of one formula. A Python cell's code must fit in it once it is
9/// written as the PY formula of the function's syntax ([`PyCode::to_file_formula`]): quotes are
10/// doubled and every 255 characters add `"&"`, so the limit on the code itself depends on what the
11/// code holds.
12pub const MAX_FORMULA_CHARS: usize = 8_192;
13
14/// The most cells a Python result may have (a crafted file can claim any size).
15pub const MAX_RESULT_CELLS: usize = 1_000_000;
16
17/// The prefix files put before `PY(`, as Excel writes it ([MS-XLSX]: `_xlfn._xlws.PY`).
18pub const FILE_PREFIX: &str = "_xlfn._xlws.";
19
20/// Characters in one string literal of a `PY(…)` formula (Excel's limit for a literal).
21const CHUNK_CHARS: usize = 255;
22
23/// A Python cell.
24#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
25#[serde(default, rename_all = "camelCase")]
26pub struct PyCode {
27    pub code: String,
28    /// Show the result as a Python object (true) or convert it to cell values (false).
29    pub object: bool,
30    /// The last result, when the code ran without an error.
31    pub output: Option<PyOutput>,
32    /// The cell's inputs changed while calculation was manual: its value is out of date until
33    /// the workbook is calculated.
34    pub stale: bool,
35}
36
37/// A Python result kept on the cell.
38#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
39#[serde(default, rename_all = "camelCase")]
40pub struct PyOutput {
41    /// The result as cells (an array spills), or its preview as text when it has no cell form.
42    pub value: Value,
43    /// Python's name for the result's type.
44    pub type_name: String,
45    /// The result's `repr`, shortened.
46    pub preview: String,
47    /// Whether `value` is the result converted to cells.
48    pub convertible: bool,
49}
50
51impl PyCode {
52    pub fn new(code: &str) -> PyCode {
53        PyCode { code: normalize_code(code), ..PyCode::default() }
54    }
55
56    /// What the cell shows for its result: cell values (an array spills from the cell), or, for a
57    /// Python object, its type name. `None` before the code has run.
58    pub fn result_value(&self) -> Option<Value> {
59        let out = self.output.as_ref()?;
60        let scalar = !matches!(out.value, Value::Array(_)) && out.convertible;
61        Some(if self.object && !scalar { Value::text(out.type_name.as_str()) } else { out.value.clone() })
62    }
63
64    /// The cell as a formula (without `=`): `PY("…"&"…",0)`, with the code in string literals of
65    /// at most 255 characters and the return type Excel documents: 0 for an Excel value, 1 for a
66    /// Python object.
67    pub fn to_formula(&self) -> String {
68        let chunks = chunk(&self.code, CHUNK_CHARS);
69        let lits: Vec<String> = chunks.iter().map(|c| format!("\"{}\"", c.replace('"', "\"\""))).collect();
70        let lits = if lits.is_empty() { "\"\"".to_string() } else { lits.join("&") };
71        format!("PY({lits},{})", u8::from(self.object))
72    }
73
74    /// [`PyCode::to_formula`] with Excel's `_xlfn._xlws.` prefix: the string-literal form
75    /// [`PyCode::from_formula`] reads from files that hold it, and what the size limit measures.
76    /// An xlsx file names the code by index instead ([MS-XLSX] 2.3.10.2).
77    pub fn to_file_formula(&self) -> String {
78        format!("{FILE_PREFIX}{}", self.to_formula())
79    }
80
81    /// Whether the code fits in a PY formula of at most [`MAX_FORMULA_CHARS`] characters.
82    pub fn fits(&self) -> bool {
83        // Code longer than the limit can't fit; this also bounds the work for hostile input.
84        self.code.chars().nth(MAX_FORMULA_CHARS).is_none() && self.to_file_formula().chars().nth(MAX_FORMULA_CHARS).is_none()
85    }
86
87    /// Reads a `PY(…)` formula (with or without `=`, and with or without the `_xlfn.` and
88    /// `_xlws.` prefixes): string literals joined by `&`, optionally followed by `,0` or `,1`.
89    /// `None` for anything else.
90    pub fn from_formula(text: &str) -> Option<PyCode> {
91        let body = py_body(text);
92        let rest = body.get(..3).filter(|h| h.eq_ignore_ascii_case("PY(")).and_then(|_| body.get(3..))?;
93        let mut chars = rest.chars().peekable();
94        let mut code = String::new();
95        loop {
96            skip_space(&mut chars);
97            if chars.next()? != '"' {
98                return None;
99            }
100            loop {
101                match chars.next()? {
102                    '"' if chars.peek() == Some(&'"') => {
103                        chars.next();
104                        code.push('"');
105                    }
106                    '"' => break,
107                    c => code.push(c),
108                }
109            }
110            skip_space(&mut chars);
111            match chars.next()? {
112                '&' => continue,
113                ',' => {
114                    skip_space(&mut chars);
115                    let object = match chars.next()? {
116                        '0' => false,
117                        '1' => true,
118                        _ => return None,
119                    };
120                    skip_space(&mut chars);
121                    return (chars.next()? == ')' && chars.all(char::is_whitespace)).then(|| PyCode { object, ..PyCode::new(&code) });
122                }
123                ')' => return chars.all(char::is_whitespace).then(|| PyCode::new(&code)),
124                _ => return None,
125            }
126        }
127    }
128}
129
130/// `text` without a leading `=`, spaces and the `_xlfn.` and `_xlws.` prefixes files put before
131/// newer functions (in any letter case).
132fn py_body(text: &str) -> &str {
133    let mut body = text.trim().trim_start_matches('=').trim_start();
134    for prefix in ["_xlfn.", "_xlws."] {
135        if body.get(..prefix.len()).is_some_and(|h| h.eq_ignore_ascii_case(prefix)) {
136            body = body.get(prefix.len()..).unwrap_or_default();
137        }
138    }
139    body
140}
141
142fn skip_space(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
143    while chars.peek().is_some_and(|c| c.is_whitespace()) {
144        chars.next();
145    }
146}
147
148/// Line breaks as `\n`.
149pub fn normalize_code(code: &str) -> String {
150    code.replace("\r\n", "\n").replace('\r', "\n")
151}
152
153/// `s` in pieces of at most `n` characters.
154fn chunk(s: &str, n: usize) -> Vec<String> {
155    let chars: Vec<char> = s.chars().collect();
156    chars.chunks(n.max(1)).map(|c| c.iter().collect()).collect()
157}
158
159/// Whether `text` (a formula, with or without `=`) is a `PY(` call: such formulas are Python cells,
160/// set with `python.set`.
161pub fn is_py_formula(text: &str) -> bool {
162    let body = py_body(text);
163    body.get(..3).is_some_and(|h| h.eq_ignore_ascii_case("PY(")) || body.eq_ignore_ascii_case("PY")
164}
165
166/// An `xl()` call in Python code.
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct XlCall {
169    /// The reference text, when the first argument is a plain string literal.
170    pub reference: Option<String>,
171    /// Byte range of the literal's contents (between the quotes) in the code.
172    pub span: Option<std::ops::Range<usize>>,
173}
174
175/// The `xl(…)` calls in `code`, skipping comments and other string literals. A call whose first
176/// argument isn't a plain string literal has no reference: the cells it reads are only known when
177/// it runs.
178pub fn xl_calls(code: &str) -> Vec<XlCall> {
179    let bytes = code.as_bytes();
180    let mut out = Vec::new();
181    let mut i = 0usize;
182    while let Some(&b) = bytes.get(i) {
183        match b {
184            b'#' => {
185                // A comment runs to the end of the line.
186                while bytes.get(i).is_some_and(|c| *c != b'\n') {
187                    i += 1;
188                }
189            }
190            b'"' | b'\'' => i = skip_string(bytes, i),
191            b'x' if bytes.get(i + 1) == Some(&b'l') && (i == 0 || bytes.get(i - 1).is_some_and(|p| !is_ident(*p) && *p != b'.')) => {
192                let mut j = i + 2;
193                if bytes.get(j).is_some_and(|c| is_ident(*c)) {
194                    i = j;
195                    continue;
196                }
197                while bytes.get(j).is_some_and(|c| *c == b' ' || *c == b'\t') {
198                    j += 1;
199                }
200                if bytes.get(j) != Some(&b'(') {
201                    i = j;
202                    continue;
203                }
204                j += 1;
205                while bytes.get(j).is_some_and(|c| c.is_ascii_whitespace()) {
206                    j += 1;
207                }
208                let call = match bytes.get(j) {
209                    Some(&q @ (b'"' | b'\'')) => {
210                        let start = j + 1;
211                        let end = skip_string(bytes, j);
212                        // A plain literal: closed on its own line, not part of an expression
213                        // (`xl("A" + n)`), not a triple-quoted string, and escaping nothing but a
214                        // quote or a backslash (`\'`, `\"`, `\\`), which a sheet name may need.
215                        let close = end.saturating_sub(1);
216                        let mut k = end;
217                        while bytes.get(k).is_some_and(|c| *c == b' ' || *c == b'\t') {
218                            k += 1;
219                        }
220                        let plain = close > start
221                            && bytes.get(close) == Some(&q)
222                            && bytes.get(start) != Some(&q)
223                            && matches!(bytes.get(k), Some(b')' | b','));
224                        i = end;
225                        match code.get(start..close).filter(|_| plain).and_then(unescape) {
226                            Some(t) => XlCall { reference: Some(t), span: Some(start..close) },
227                            None => XlCall { reference: None, span: None },
228                        }
229                    }
230                    _ => {
231                        i = j;
232                        XlCall { reference: None, span: None }
233                    }
234                };
235                out.push(call);
236            }
237            _ => i += 1,
238        }
239    }
240    out
241}
242
243fn is_ident(b: u8) -> bool {
244    b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80
245}
246
247/// The index just past the string literal starting at `at` (single, double or triple quoted).
248fn skip_string(bytes: &[u8], at: usize) -> usize {
249    let Some(&q) = bytes.get(at) else { return at.saturating_add(1) };
250    let triple = bytes.get(at + 1) == Some(&q) && bytes.get(at + 2) == Some(&q);
251    let mut i = if triple { at + 3 } else { at + 1 };
252    while let Some(&c) = bytes.get(i) {
253        if c == b'\\' {
254            i += 2;
255            continue;
256        }
257        if c == q {
258            if !triple {
259                return i + 1;
260            }
261            if bytes.get(i + 1) == Some(&q) && bytes.get(i + 2) == Some(&q) {
262                return i + 3;
263            }
264        } else if c == b'\n' && !triple {
265            return i;
266        }
267        i += 1;
268    }
269    i
270}
271
272/// The text of a plain literal's contents: `\'`, `\"` and `\\` stand for the character after the
273/// backslash. `None` for any other escape or a line break, which make the literal not plain.
274fn unescape(raw: &str) -> Option<String> {
275    let mut out = String::with_capacity(raw.len());
276    let mut chars = raw.chars();
277    while let Some(c) = chars.next() {
278        match c {
279            '\n' => return None,
280            '\\' => match chars.next()? {
281                e @ ('\\' | '\'' | '"') => out.push(e),
282                _ => return None,
283            },
284            c => out.push(c),
285        }
286    }
287    Some(out)
288}
289
290/// `text` as a plain literal that was delimited by `quote`: its delimiter (`quote`, or the other
291/// quote when `text` holds `quote` but not the other one, so `xl('Sheet2!B2')` becomes
292/// `xl("'Q4 2026'!B2")` on a rename) and its contents, with backslashes escaped, and the
293/// delimiter too when `text` holds both quotes. `None` for a line break, which no literal on
294/// one line holds.
295fn literal(text: &str, quote: char) -> Option<(char, String)> {
296    if text.contains('\n') {
297        return None;
298    }
299    let other = if quote == '\'' { '"' } else { '\'' };
300    let delim = if text.contains(quote) && !text.contains(other) { other } else { quote };
301    let mut body = String::with_capacity(text.len());
302    for c in text.chars() {
303        if c == '\\' || c == delim {
304            body.push('\\');
305        }
306        body.push(c);
307    }
308    Some((delim, body))
309}
310
311/// Rewrites the reference of every `xl("…")` literal in `code` with `f`; `None` from `f` leaves
312/// it. Returns the new code when anything changed. A new reference holding the literal's quote
313/// (a sheet name that needs quotes, `'Q4 2026'!B2`) gets the other quote as its delimiter, or an
314/// escaped quote when it holds both, so it never ends the string early.
315pub fn rewrite_xl(code: &str, mut f: impl FnMut(&str) -> Option<String>) -> Option<String> {
316    let mut out = String::with_capacity(code.len());
317    let mut last = 0usize;
318    let mut changed = false;
319    for call in xl_calls(code) {
320        let (Some(reference), Some(span)) = (call.reference, call.span) else { continue };
321        let Some(new) = f(&reference).filter(|n| *n != reference) else { continue };
322        let open = span.start.saturating_sub(1);
323        let Some(quote) = code.get(open..span.start).and_then(|q| q.chars().next()) else { continue };
324        let Some((delim, body)) = literal(&new, quote) else { continue };
325        // The literal with its quotes: the delimiter may change.
326        out.push_str(code.get(last..open).unwrap_or_default());
327        out.push(delim);
328        out.push_str(&body);
329        out.push(delim);
330        last = span.end.saturating_add(1);
331        changed = true;
332    }
333    if !changed {
334        return None;
335    }
336    out.push_str(code.get(last..).unwrap_or_default());
337    Some(out)
338}
339
340/// The value a Python cell shows when it can't run: `#BLOCKED!`.
341pub const BLOCKED: Value = Value::Error(CellError::Blocked);
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn formula_round_trip_with_chunks_and_quotes() {
349        let code = format!("s = \"quoted\"\n{}\nlen(s)", "x".repeat(600));
350        let py = PyCode { object: true, ..PyCode::new(&code) };
351        let f = py.to_formula();
352        assert!(f.starts_with("PY(\"s = \"\"quoted\"\"\n"));
353        assert!(f.ends_with(",1)"));
354        assert_eq!(f.matches("\"&\"").count(), 2, "{f}");
355        let back = PyCode::from_formula(&format!("={f}")).unwrap();
356        assert_eq!(back.code, code);
357        assert!(back.object);
358        assert_eq!(PyCode::from_formula("_xlfn.PY(\"1+1\")").unwrap().code, "1+1");
359        // Excel writes `_xlfn._xlws.PY`; the reader takes it in any letter case.
360        assert_eq!(PyCode::from_formula("=_xlfn._xlws.PY(\"1+1\",1)").unwrap().code, "1+1");
361        assert_eq!(PyCode::from_formula("_XLFN._XLWS.py(\"2\",0)").unwrap().code, "2");
362        assert_eq!(PyCode::from_formula("_xlws.PY(\"3\",0)").unwrap().code, "3");
363        assert!(is_py_formula("=_xlfn._xlws.PY(\"1\",0)"));
364        assert_eq!(py.to_file_formula(), format!("_xlfn._xlws.{f}"));
365        assert_eq!(PyCode::from_formula(&py.to_file_formula()).unwrap(), PyCode::from_formula(&f).unwrap());
366        assert_eq!(PyCode::from_formula(" py( \"a\" & \"b\" , 0 ) ").unwrap().code, "ab");
367        assert_eq!(PyCode::new("a\r\nb").code, "a\nb");
368        for bad in ["PY(A1)", "PY(\"a\",2)", "PY(\"a\"", "PY(\"a\")+1", "SUM(1)", "PY(\"a\"&B1)", "PY(", ""] {
369            assert!(PyCode::from_formula(bad).is_none(), "{bad}");
370        }
371        assert!(is_py_formula("=PY(\"1\")") && is_py_formula("=py") && !is_py_formula("=PYTHON(1)"));
372    }
373
374    #[test]
375    fn the_size_limit_counts_the_encoded_formula() {
376        // Plain code: the limit is the formula's, so it is a little under 8,192 characters of code.
377        // `_xlfn._xlws.PY(` + 31 literals of 255 + 30 `&` + `&"…"` + `,0)`: 8,079 characters of
378        // code give a formula of exactly 8,192.
379        let at = PyCode::new(&"y".repeat(8_079));
380        assert_eq!(at.to_file_formula().chars().count(), MAX_FORMULA_CHARS);
381        assert!(at.fits());
382        assert!(!PyCode::new(&"y".repeat(8_080)).fits());
383        assert_eq!(PyCode::from_formula(&at.to_file_formula()).unwrap().code, at.code);
384        // Quotes are doubled in the file, so 7,000 characters with 1,200 of them don't fit.
385        let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
386        assert!(PyCode::new(&quoted).to_file_formula().chars().count() > MAX_FORMULA_CHARS);
387        assert!(!PyCode::new(&quoted).fits());
388        // Hostile sizes are refused without encoding them.
389        assert!(!PyCode::new(&"y".repeat(4_000_000)).fits());
390        assert!(PyCode::new("").fits());
391    }
392
393    #[test]
394    fn result_values() {
395        let arr = crate::base::Array::new(2, 1, vec![1.0.into(), 2.0.into()]).map(Value::from).unwrap();
396        let out = PyOutput { value: arr.clone(), type_name: "list".into(), preview: "[1, 2]".into(), convertible: true };
397        let mut py = PyCode { output: Some(out), ..PyCode::new("[1, 2]") };
398        assert_eq!(py.result_value(), Some(arr));
399        py.object = true;
400        assert_eq!(py.result_value(), Some(Value::text("list")));
401        py.output = Some(PyOutput { value: Value::Number(3.0), type_name: "int".into(), preview: "3".into(), convertible: true });
402        assert_eq!(py.result_value(), Some(Value::Number(3.0)), "scalars show as values in object mode too");
403        py.output = None;
404        assert_eq!(py.result_value(), None);
405    }
406
407    #[test]
408    fn finds_xl_literals() {
409        let code = "a = xl(\"A1:B2\", headers=True)\n# xl('C1') in a comment\ns = \"xl('D1')\"\nb = xl( 'Sheet2!E5' )\nc = xl(name)\nd = df.xl('F1')\nxlx('G1')\ne = xl(\"A\" + \"1\")";
410        let calls = xl_calls(code);
411        let refs: Vec<Option<&str>> = calls.iter().map(|c| c.reference.as_deref()).collect();
412        assert_eq!(refs, vec![Some("A1:B2"), Some("Sheet2!E5"), None, None]);
413        let new = rewrite_xl(code, |r| (r == "A1:B2").then(|| "A2:B3".to_string())).unwrap();
414        assert!(new.starts_with("a = xl(\"A2:B3\", headers=True)"));
415        assert_eq!(new.len(), code.len());
416        assert_eq!(rewrite_xl(code, |_| None), None);
417        // A replacement can't break out of its string: a quote in it changes the delimiter, or is
418        // escaped when it holds both quotes, and the result reads back as the same reference.
419        assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q'!A1".into())), Some("xl(\"'Q'!A1\")".into()));
420        assert_eq!(rewrite_xl("xl('Sheet2!B2', headers=True)", |_| Some("'Q4 2026'!B2".into())), Some("xl(\"'Q4 2026'!B2\", headers=True)".into()));
421        assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q\"4'!A1".into())), Some("xl(\"'Q\\\"4'!A1\")".into()));
422        assert_eq!(rewrite_xl("xl('A1')", |_| Some("'Q\"4'!A1".into())), Some("xl('\\'Q\"4\\'!A1')".into()));
423        for (delim, new) in [("'", "'Q4 2026'!B2"), ("'", "'Q\"4'!A1"), ("\"", "'Q\"4'!A1"), ("\"", "'a\\b'!A1"), ("'", "B2")] {
424            let code = format!("x = xl({delim}A1{delim}) + 1");
425            let out = rewrite_xl(&code, |_| Some(new.to_string())).unwrap();
426            assert_eq!(xl_calls(&out)[0].reference.as_deref(), Some(new), "{out}");
427            assert!(out.ends_with(") + 1"), "{out}");
428        }
429        assert_eq!(rewrite_xl("xl('A1')", |_| Some("A\n1".into())), None, "a line break can't go in a literal");
430        // Escaped quotes and backslashes read as the characters; any other escape is not plain.
431        assert_eq!(xl_calls("xl('Bob\\'s!A1')")[0].reference.as_deref(), Some("Bob's!A1"));
432        assert_eq!(xl_calls("xl(\"A\\\\1\")")[0].reference.as_deref(), Some("A\\1"));
433        assert_eq!(xl_calls("xl(\"A\\n1\")")[0].reference, None);
434        assert_eq!(xl_calls("xl(\"A1\\\")")[0].reference, None, "an escaped closing quote leaves the literal open");
435        // Unclosed and odd input doesn't loop or panic.
436        for odd in ["xl(", "xl(\"", "xl('''A1''')", "x", "xl", "\"unterminated", "xl  (  \"A1\"  )", "é xl('A1')"] {
437            let _ = xl_calls(odd);
438            let _ = rewrite_xl(odd, |_| Some("B2".into()));
439        }
440        assert_eq!(xl_calls("xl  (  \"A1\"  )")[0].reference.as_deref(), Some("A1"));
441        assert_eq!(xl_calls("xl('''A1''')")[0].reference, None);
442    }
443}