1use crate::base::{CellError, Value};
6use serde::{Deserialize, Serialize};
7
8pub const MAX_FORMULA_CHARS: usize = 8_192;
13
14pub const MAX_RESULT_CELLS: usize = 1_000_000;
16
17pub const FILE_PREFIX: &str = "_xlfn._xlws.";
19
20const CHUNK_CHARS: usize = 255;
22
23#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
25#[serde(default, rename_all = "camelCase")]
26pub struct PyCode {
27 pub code: String,
28 pub object: bool,
30 pub output: Option<PyOutput>,
32 pub stale: bool,
35}
36
37#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
39#[serde(default, rename_all = "camelCase")]
40pub struct PyOutput {
41 pub value: Value,
43 pub type_name: String,
45 pub preview: String,
47 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 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 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 pub fn to_file_formula(&self) -> String {
78 format!("{FILE_PREFIX}{}", self.to_formula())
79 }
80
81 pub fn fits(&self) -> bool {
83 self.code.chars().nth(MAX_FORMULA_CHARS).is_none() && self.to_file_formula().chars().nth(MAX_FORMULA_CHARS).is_none()
85 }
86
87 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
130fn 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
148pub fn normalize_code(code: &str) -> String {
150 code.replace("\r\n", "\n").replace('\r', "\n")
151}
152
153fn 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
159pub 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#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct XlCall {
169 pub reference: Option<String>,
171 pub span: Option<std::ops::Range<usize>>,
173}
174
175pub 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 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 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
247fn 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
272fn 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
290fn 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
311pub 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 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
340pub 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 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 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 let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
386 assert!(PyCode::new("ed).to_file_formula().chars().count() > MAX_FORMULA_CHARS);
387 assert!(!PyCode::new("ed).fits());
388 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 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 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 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}