Skip to main content

gridline_engine/engine/
preprocess.rs

1//! Formula preprocessing and reference transformation.
2//!
3//! Before formulas can be evaluated by Rhai, cell references like `A1` must
4//! be transformed into function calls like `cell(0, 0)`. This module handles:
5//!
6//! - **Preprocessing**: Converting `A1` → `cell(0, 0)` and `@A1` → `value(0, 0)`
7//! - **Range functions**: Converting `SUM(A1:B5)` → `sum_range(0, 0, 4, 1)`
8//! - **Reference shifting**: Adjusting references when rows/columns are inserted/deleted
9
10use regex::Regex;
11
12use super::cell_ref::CellRef;
13
14/// Operation for shifting cell references in formulas.
15#[derive(Clone, Copy, Debug)]
16pub enum ShiftOperation {
17    InsertRow(usize),
18    DeleteRow(usize),
19    InsertColumn(usize),
20    DeleteColumn(usize),
21}
22
23/// Shift cell references in a formula when rows/cols are inserted/deleted.
24/// Returns the updated formula string.
25///
26/// Rules:
27/// - Insert row at R: refs to row >= R become row + 1
28/// - Delete row at R: refs to row > R become row - 1; row == R becomes `#REF!`
29/// - Same logic for columns
30pub fn shift_formula_references(formula: &str, op: ShiftOperation) -> String {
31    let mut replacements: Vec<String> = Vec::new();
32    // Handle range functions like SUM(A1:B5, ...)
33    let with_placeholders = crate::builtins::range_fn_re()
34        .replace_all(formula, |caps: &regex::Captures| {
35            let func_name = &caps[1];
36            let start_ref = &caps[2];
37            let end_ref = &caps[3];
38            let rest_args = caps.get(4).map(|m| m.as_str()).unwrap_or("");
39
40            let new_start = shift_single_ref(start_ref, op);
41            let new_end = shift_single_ref(end_ref, op);
42
43            // If either ref became #REF!, return #REF!
44            if new_start == "#REF!" || new_end == "#REF!" {
45                let idx = replacements.len();
46                replacements.push("#REF!".to_string());
47                return format!("@@@{}@@@", idx);
48            }
49
50            let idx = replacements.len();
51            replacements.push(format!(
52                "{}({}:{}{})",
53                func_name, new_start, new_end, rest_args
54            ));
55            format!("@@@{}@@@", idx)
56        })
57        .to_string();
58
59    // Now shift individual cell references
60    let shifted = shift_cell_refs_outside_strings(&with_placeholders, op);
61    if replacements.is_empty() {
62        return shifted;
63    }
64
65    let mut restored = shifted;
66    for (idx, replacement) in replacements.into_iter().enumerate() {
67        let placeholder = format!("@@@{}@@@", idx);
68        restored = restored.replace(&placeholder, &replacement);
69    }
70    restored
71}
72
73fn shift_single_ref(cell_ref_str: &str, op: ShiftOperation) -> String {
74    let Some(cr) = CellRef::from_str(cell_ref_str) else {
75        return cell_ref_str.to_string();
76    };
77
78    match op {
79        ShiftOperation::InsertRow(at_row) => {
80            if cr.row >= at_row {
81                CellRef::new(cr.row + 1, cr.col).to_string()
82            } else {
83                cr.to_string()
84            }
85        }
86        ShiftOperation::DeleteRow(at_row) => {
87            if cr.row == at_row {
88                "#REF!".to_string()
89            } else if cr.row > at_row {
90                CellRef::new(cr.row - 1, cr.col).to_string()
91            } else {
92                cr.to_string()
93            }
94        }
95        ShiftOperation::InsertColumn(at_col) => {
96            if cr.col >= at_col {
97                CellRef::new(cr.row, cr.col + 1).to_string()
98            } else {
99                cr.to_string()
100            }
101        }
102        ShiftOperation::DeleteColumn(at_col) => {
103            if cr.col == at_col {
104                "#REF!".to_string()
105            } else if cr.col > at_col {
106                CellRef::new(cr.row, cr.col - 1).to_string()
107            } else {
108                cr.to_string()
109            }
110        }
111    }
112}
113
114fn shift_cell_refs_outside_strings(script: &str, op: ShiftOperation) -> String {
115    let cell_re = Regex::new(r"\b([A-Za-z]+)([0-9]+)\b").unwrap();
116    let value_re = Regex::new(r"@([A-Za-z]+)([0-9]+)\b").unwrap();
117
118    let shift_cells = |seg: &str| {
119        // First handle @-prefixed refs (value refs)
120        let seg = value_re
121            .replace_all(seg, |caps: &regex::Captures| {
122                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
123                let shifted = shift_single_ref(&cell_ref, op);
124                if shifted == "#REF!" {
125                    shifted
126                } else {
127                    format!("@{}", shifted)
128                }
129            })
130            .to_string();
131
132        // Then handle regular refs
133        cell_re
134            .replace_all(&seg, |caps: &regex::Captures| {
135                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
136                shift_single_ref(&cell_ref, op)
137            })
138            .to_string()
139    };
140
141    // Process outside of string literals
142    let bytes = script.as_bytes();
143    let mut out = String::new();
144    let mut seg_start = 0;
145    let mut in_string = false;
146    let mut backslashes = 0usize;
147    let mut i = 0usize;
148
149    while i < bytes.len() {
150        let b = bytes[i];
151        if in_string {
152            if b == b'\\' {
153                backslashes += 1;
154                i += 1;
155                continue;
156            }
157            if b == b'"' && backslashes % 2 == 0 {
158                out.push_str(&script[seg_start..=i]);
159                in_string = false;
160                seg_start = i + 1;
161            }
162            backslashes = 0;
163            i += 1;
164            continue;
165        }
166
167        if b == b'"' {
168            out.push_str(&shift_cells(&script[seg_start..i]));
169            in_string = true;
170            seg_start = i;
171            backslashes = 0;
172            i += 1;
173            continue;
174        }
175
176        i += 1;
177    }
178
179    if seg_start < script.len() {
180        if in_string {
181            out.push_str(&script[seg_start..]);
182        } else {
183            out.push_str(&shift_cells(&script[seg_start..]));
184        }
185    }
186
187    out
188}
189
190/// Replace cell references like "A1" with Rhai function calls like "cell(0, 0)".
191/// Typed refs like "@A1" become "value(0, 0)" (returns Dynamic).
192/// Also transforms range functions like SUM(A1:B5, ...) into sum_range(0, 0, 4, 1, ...).
193pub fn preprocess_script(script: &str) -> String {
194    preprocess_script_with_context(script, None)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_shift_formula_references_preserves_paren() {
203        let formula = "VEC(A1:A100)";
204        let shifted = shift_formula_references(formula, ShiftOperation::InsertColumn(0));
205        assert_eq!(shifted, "VEC(B1:B100)");
206    }
207
208    #[test]
209    fn test_shift_formula_references_mixed_range_and_cell() {
210        let formula = "SUM(A1:A3) + B1";
211        let shifted = shift_formula_references(formula, ShiftOperation::InsertColumn(0));
212        assert_eq!(shifted, "SUM(B1:B3) + C1");
213    }
214
215    #[test]
216    fn test_shift_formula_references_vec_and_cell() {
217        let formula = "VEC(A1:A10) + B1";
218        let shifted = shift_formula_references(formula, ShiftOperation::InsertColumn(0));
219        assert_eq!(shifted, "VEC(B1:B10) + C1");
220    }
221}
222
223/// Preprocess script with optional current cell context for ROW()/COL().
224/// When context is provided, ROW() and COL() are replaced with 1-based row/col values.
225pub fn preprocess_script_with_context(script: &str, context: Option<&CellRef>) -> String {
226    // First, replace ROW() and COL() if context is provided
227    let script = if let Some(cell_ref) = context {
228        let row_re = Regex::new(r"\bROW\(\s*\)").unwrap();
229        let col_re = Regex::new(r"\bCOL\(\s*\)").unwrap();
230        let script = row_re
231            .replace_all(script, (cell_ref.row + 1).to_string())
232            .to_string();
233        col_re
234            .replace_all(&script, (cell_ref.col + 1).to_string())
235            .to_string()
236    } else {
237        script.to_string()
238    };
239
240    preprocess_script_inner(&script)
241}
242
243fn preprocess_script_inner(script: &str) -> String {
244    let with_ranges = crate::builtins::range_fn_re()
245        .replace_all(script, |caps: &regex::Captures| {
246            let start_ref = &caps[2];
247            let end_ref = &caps[3];
248            let rest_args = caps.get(4).map(|m| m.as_str()).unwrap_or("");
249
250            let Some(rhai_name) = crate::builtins::range_rhai_name(&caps[1]) else {
251                return caps[0].to_string();
252            };
253
254            if let (Some(start), Some(end)) =
255                (CellRef::from_str(start_ref), CellRef::from_str(end_ref))
256            {
257                format!(
258                    "{}({}, {}, {}, {}{})",
259                    rhai_name, start.row, start.col, end.row, end.col, rest_args
260                )
261            } else {
262                caps[0].to_string()
263            }
264        })
265        .to_string();
266
267    replace_cell_refs_outside_strings(&with_ranges)
268}
269
270fn replace_cell_refs_outside_strings(script: &str) -> String {
271    let cell_re = Regex::new(r"\b([A-Za-z]+)([0-9]+)\b").unwrap();
272    let value_re = Regex::new(r"@([A-Za-z]+)([0-9]+)\b").unwrap();
273
274    let replace_cells = |seg: &str| {
275        let seg = value_re
276            .replace_all(seg, |caps: &regex::Captures| {
277                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
278                if let Some(cr) = CellRef::from_str(&cell_ref) {
279                    format!("value({}, {})", cr.row, cr.col)
280                } else {
281                    caps[0].to_string()
282                }
283            })
284            .to_string();
285
286        cell_re
287            .replace_all(&seg, |caps: &regex::Captures| {
288                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
289                if let Some(cr) = CellRef::from_str(&cell_ref) {
290                    format!("cell({}, {})", cr.row, cr.col)
291                } else {
292                    caps[0].to_string()
293                }
294            })
295            .to_string()
296    };
297
298    let bytes = script.as_bytes();
299    let mut out = String::new();
300    let mut seg_start = 0;
301    let mut in_string = false;
302    let mut backslashes = 0usize;
303    let mut i = 0usize;
304
305    while i < bytes.len() {
306        let b = bytes[i];
307        if in_string {
308            if b == b'\\' {
309                backslashes += 1;
310                i += 1;
311                continue;
312            }
313            if b == b'"' && backslashes.is_multiple_of(2) {
314                out.push_str(&script[seg_start..=i]);
315                in_string = false;
316                seg_start = i + 1;
317            }
318            backslashes = 0;
319            i += 1;
320            continue;
321        }
322
323        if b == b'"' {
324            out.push_str(&replace_cells(&script[seg_start..i]));
325            in_string = true;
326            seg_start = i;
327            backslashes = 0;
328            i += 1;
329            continue;
330        }
331
332        i += 1;
333    }
334
335    if seg_start < script.len() {
336        if in_string {
337            out.push_str(&script[seg_start..]);
338        } else {
339            out.push_str(&replace_cells(&script[seg_start..]));
340        }
341    }
342
343    out
344}