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    // Handle range functions like SUM(A1:B5, ...)
32    let with_shifted_ranges = crate::builtins::range_fn_re()
33        .replace_all(formula, |caps: &regex::Captures| {
34            let func_name = &caps[1];
35            let start_ref = &caps[2];
36            let end_ref = &caps[3];
37            let rest_args = caps.get(4).map(|m| m.as_str()).unwrap_or("");
38
39            let new_start = shift_single_ref(start_ref, op);
40            let new_end = shift_single_ref(end_ref, op);
41
42            // If either ref became #REF!, return #REF!
43            if new_start == "#REF!" || new_end == "#REF!" {
44                return "#REF!".to_string();
45            }
46
47            format!("{}({}:{}{}", func_name, new_start, new_end, rest_args)
48        })
49        .to_string();
50
51    // Now shift individual cell references
52    shift_cell_refs_outside_strings(&with_shifted_ranges, op)
53}
54
55fn shift_single_ref(cell_ref_str: &str, op: ShiftOperation) -> String {
56    let Some(cr) = CellRef::from_str(cell_ref_str) else {
57        return cell_ref_str.to_string();
58    };
59
60    match op {
61        ShiftOperation::InsertRow(at_row) => {
62            if cr.row >= at_row {
63                CellRef::new(cr.row + 1, cr.col).to_string()
64            } else {
65                cr.to_string()
66            }
67        }
68        ShiftOperation::DeleteRow(at_row) => {
69            if cr.row == at_row {
70                "#REF!".to_string()
71            } else if cr.row > at_row {
72                CellRef::new(cr.row - 1, cr.col).to_string()
73            } else {
74                cr.to_string()
75            }
76        }
77        ShiftOperation::InsertColumn(at_col) => {
78            if cr.col >= at_col {
79                CellRef::new(cr.row, cr.col + 1).to_string()
80            } else {
81                cr.to_string()
82            }
83        }
84        ShiftOperation::DeleteColumn(at_col) => {
85            if cr.col == at_col {
86                "#REF!".to_string()
87            } else if cr.col > at_col {
88                CellRef::new(cr.row, cr.col - 1).to_string()
89            } else {
90                cr.to_string()
91            }
92        }
93    }
94}
95
96fn shift_cell_refs_outside_strings(script: &str, op: ShiftOperation) -> String {
97    let cell_re = Regex::new(r"\b([A-Za-z]+)([0-9]+)\b").unwrap();
98    let value_re = Regex::new(r"@([A-Za-z]+)([0-9]+)\b").unwrap();
99
100    let shift_cells = |seg: &str| {
101        // First handle @-prefixed refs (value refs)
102        let seg = value_re
103            .replace_all(seg, |caps: &regex::Captures| {
104                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
105                let shifted = shift_single_ref(&cell_ref, op);
106                if shifted == "#REF!" {
107                    shifted
108                } else {
109                    format!("@{}", shifted)
110                }
111            })
112            .to_string();
113
114        // Then handle regular refs
115        cell_re
116            .replace_all(&seg, |caps: &regex::Captures| {
117                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
118                shift_single_ref(&cell_ref, op)
119            })
120            .to_string()
121    };
122
123    // Process outside of string literals
124    let bytes = script.as_bytes();
125    let mut out = String::new();
126    let mut seg_start = 0;
127    let mut in_string = false;
128    let mut backslashes = 0usize;
129    let mut i = 0usize;
130
131    while i < bytes.len() {
132        let b = bytes[i];
133        if in_string {
134            if b == b'\\' {
135                backslashes += 1;
136                i += 1;
137                continue;
138            }
139            if b == b'"' && backslashes % 2 == 0 {
140                out.push_str(&script[seg_start..=i]);
141                in_string = false;
142                seg_start = i + 1;
143            }
144            backslashes = 0;
145            i += 1;
146            continue;
147        }
148
149        if b == b'"' {
150            out.push_str(&shift_cells(&script[seg_start..i]));
151            in_string = true;
152            seg_start = i;
153            backslashes = 0;
154            i += 1;
155            continue;
156        }
157
158        i += 1;
159    }
160
161    if seg_start < script.len() {
162        if in_string {
163            out.push_str(&script[seg_start..]);
164        } else {
165            out.push_str(&shift_cells(&script[seg_start..]));
166        }
167    }
168
169    out
170}
171
172/// Replace cell references like "A1" with Rhai function calls like "cell(0, 0)".
173/// Typed refs like "@A1" become "value(0, 0)" (returns Dynamic).
174/// Also transforms range functions like SUM(A1:B5, ...) into sum_range(0, 0, 4, 1, ...).
175pub fn preprocess_script(script: &str) -> String {
176    let with_ranges = crate::builtins::range_fn_re()
177        .replace_all(script, |caps: &regex::Captures| {
178            let start_ref = &caps[2];
179            let end_ref = &caps[3];
180            let rest_args = caps.get(4).map(|m| m.as_str()).unwrap_or("");
181
182            let Some(rhai_name) = crate::builtins::range_rhai_name(&caps[1]) else {
183                return caps[0].to_string();
184            };
185
186            if let (Some(start), Some(end)) =
187                (CellRef::from_str(start_ref), CellRef::from_str(end_ref))
188            {
189                format!(
190                    "{}({}, {}, {}, {}{})",
191                    rhai_name, start.row, start.col, end.row, end.col, rest_args
192                )
193            } else {
194                caps[0].to_string()
195            }
196        })
197        .to_string();
198
199    replace_cell_refs_outside_strings(&with_ranges)
200}
201
202fn replace_cell_refs_outside_strings(script: &str) -> String {
203    let cell_re = Regex::new(r"\b([A-Za-z]+)([0-9]+)\b").unwrap();
204    let value_re = Regex::new(r"@([A-Za-z]+)([0-9]+)\b").unwrap();
205
206    let replace_cells = |seg: &str| {
207        let seg = value_re
208            .replace_all(seg, |caps: &regex::Captures| {
209                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
210                if let Some(cr) = CellRef::from_str(&cell_ref) {
211                    format!("value({}, {})", cr.row, cr.col)
212                } else {
213                    caps[0].to_string()
214                }
215            })
216            .to_string();
217
218        cell_re
219            .replace_all(&seg, |caps: &regex::Captures| {
220                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
221                if let Some(cr) = CellRef::from_str(&cell_ref) {
222                    format!("cell({}, {})", cr.row, cr.col)
223                } else {
224                    caps[0].to_string()
225                }
226            })
227            .to_string()
228    };
229
230    let bytes = script.as_bytes();
231    let mut out = String::new();
232    let mut seg_start = 0;
233    let mut in_string = false;
234    let mut backslashes = 0usize;
235    let mut i = 0usize;
236
237    while i < bytes.len() {
238        let b = bytes[i];
239        if in_string {
240            if b == b'\\' {
241                backslashes += 1;
242                i += 1;
243                continue;
244            }
245            if b == b'"' && backslashes.is_multiple_of(2) {
246                out.push_str(&script[seg_start..=i]);
247                in_string = false;
248                seg_start = i + 1;
249            }
250            backslashes = 0;
251            i += 1;
252            continue;
253        }
254
255        if b == b'"' {
256            out.push_str(&replace_cells(&script[seg_start..i]));
257            in_string = true;
258            seg_start = i;
259            backslashes = 0;
260            i += 1;
261            continue;
262        }
263
264        i += 1;
265    }
266
267    if seg_start < script.len() {
268        if in_string {
269            out.push_str(&script[seg_start..]);
270        } else {
271            out.push_str(&replace_cells(&script[seg_start..]));
272        }
273    }
274
275    out
276}