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    preprocess_script_with_context(script, None)
177}
178
179/// Preprocess script with optional current cell context for ROW()/COL().
180/// When context is provided, ROW() and COL() are replaced with 1-based row/col values.
181pub fn preprocess_script_with_context(script: &str, context: Option<&CellRef>) -> String {
182    // First, replace ROW() and COL() if context is provided
183    let script = if let Some(cell_ref) = context {
184        let row_re = Regex::new(r"\bROW\(\s*\)").unwrap();
185        let col_re = Regex::new(r"\bCOL\(\s*\)").unwrap();
186        let script = row_re
187            .replace_all(script, (cell_ref.row + 1).to_string())
188            .to_string();
189        col_re
190            .replace_all(&script, (cell_ref.col + 1).to_string())
191            .to_string()
192    } else {
193        script.to_string()
194    };
195
196    preprocess_script_inner(&script)
197}
198
199fn preprocess_script_inner(script: &str) -> String {
200    let with_ranges = crate::builtins::range_fn_re()
201        .replace_all(script, |caps: &regex::Captures| {
202            let start_ref = &caps[2];
203            let end_ref = &caps[3];
204            let rest_args = caps.get(4).map(|m| m.as_str()).unwrap_or("");
205
206            let Some(rhai_name) = crate::builtins::range_rhai_name(&caps[1]) else {
207                return caps[0].to_string();
208            };
209
210            if let (Some(start), Some(end)) =
211                (CellRef::from_str(start_ref), CellRef::from_str(end_ref))
212            {
213                format!(
214                    "{}({}, {}, {}, {}{})",
215                    rhai_name, start.row, start.col, end.row, end.col, rest_args
216                )
217            } else {
218                caps[0].to_string()
219            }
220        })
221        .to_string();
222
223    replace_cell_refs_outside_strings(&with_ranges)
224}
225
226fn replace_cell_refs_outside_strings(script: &str) -> String {
227    let cell_re = Regex::new(r"\b([A-Za-z]+)([0-9]+)\b").unwrap();
228    let value_re = Regex::new(r"@([A-Za-z]+)([0-9]+)\b").unwrap();
229
230    let replace_cells = |seg: &str| {
231        let seg = value_re
232            .replace_all(seg, |caps: &regex::Captures| {
233                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
234                if let Some(cr) = CellRef::from_str(&cell_ref) {
235                    format!("value({}, {})", cr.row, cr.col)
236                } else {
237                    caps[0].to_string()
238                }
239            })
240            .to_string();
241
242        cell_re
243            .replace_all(&seg, |caps: &regex::Captures| {
244                let cell_ref = format!("{}{}", &caps[1], &caps[2]);
245                if let Some(cr) = CellRef::from_str(&cell_ref) {
246                    format!("cell({}, {})", cr.row, cr.col)
247                } else {
248                    caps[0].to_string()
249                }
250            })
251            .to_string()
252    };
253
254    let bytes = script.as_bytes();
255    let mut out = String::new();
256    let mut seg_start = 0;
257    let mut in_string = false;
258    let mut backslashes = 0usize;
259    let mut i = 0usize;
260
261    while i < bytes.len() {
262        let b = bytes[i];
263        if in_string {
264            if b == b'\\' {
265                backslashes += 1;
266                i += 1;
267                continue;
268            }
269            if b == b'"' && backslashes.is_multiple_of(2) {
270                out.push_str(&script[seg_start..=i]);
271                in_string = false;
272                seg_start = i + 1;
273            }
274            backslashes = 0;
275            i += 1;
276            continue;
277        }
278
279        if b == b'"' {
280            out.push_str(&replace_cells(&script[seg_start..i]));
281            in_string = true;
282            seg_start = i;
283            backslashes = 0;
284            i += 1;
285            continue;
286        }
287
288        i += 1;
289    }
290
291    if seg_start < script.len() {
292        if in_string {
293            out.push_str(&script[seg_start..]);
294        } else {
295            out.push_str(&replace_cells(&script[seg_start..]));
296        }
297    }
298
299    out
300}