Skip to main content

formualizer_eval/
args.rs

1use crate::traits::ArgumentHandle;
2// Note: Validator no longer depends on EvaluationContext; keep it engine-agnostic.
3use formualizer_common::{ArgKind, ExcelError, ExcelErrorKind, LiteralValue};
4use smallvec::{SmallVec, smallvec};
5use std::borrow::Cow;
6
7#[derive(Copy, Clone, Debug, Eq, PartialEq)]
8pub enum ShapeKind {
9    Scalar,
10    Range,
11    Array,
12}
13
14pub use formualizer_common::CoercionPolicy;
15
16#[derive(Clone, Debug)]
17pub struct ArgSchema {
18    pub kinds: SmallVec<[ArgKind; 2]>,
19    pub required: bool,
20    pub by_ref: bool,
21    pub shape: ShapeKind,
22    pub coercion: CoercionPolicy,
23    pub max: Option<usize>,
24    pub repeating: Option<usize>,
25    pub default: Option<LiteralValue>,
26}
27
28impl ArgSchema {
29    pub fn any() -> Self {
30        Self {
31            kinds: smallvec![ArgKind::Any],
32            required: true,
33            by_ref: false,
34            shape: ShapeKind::Scalar,
35            coercion: CoercionPolicy::None,
36            max: None,
37            repeating: None,
38            default: None,
39        }
40    }
41
42    pub fn number_lenient_scalar() -> Self {
43        Self {
44            kinds: smallvec![ArgKind::Number],
45            required: true,
46            by_ref: false,
47            shape: ShapeKind::Scalar,
48            coercion: CoercionPolicy::NumberLenientText,
49            max: None,
50            repeating: None,
51            default: None,
52        }
53    }
54}
55
56#[derive(Clone, Debug)]
57pub enum CriteriaPredicate {
58    Eq(LiteralValue),
59    Ne(LiteralValue),
60    Gt(f64),
61    Ge(f64),
62    Lt(f64),
63    Le(f64),
64    TextLike {
65        pattern: String,
66        case_insensitive: bool,
67    },
68    /// `"<>a*"`: the complement of [`CriteriaPredicate::TextLike`]; numbers,
69    /// booleans, errors and blank cells match.
70    NotTextLike {
71        pattern: String,
72        case_insensitive: bool,
73    },
74    /// `">b"` and friends with a non-numeric operand: compares text cells
75    /// only (case-insensitive); other cells never match.
76    TextGt(String),
77    TextGe(String),
78    TextLt(String),
79    TextLe(String),
80    /// `"="`: a truly blank cell (not empty text).
81    IsBlank,
82    /// `"<>"`: any cell that is not truly blank (empty text matches).
83    NotBlank,
84    IsNumber,
85    IsText,
86    IsLogical,
87}
88
89#[derive(Debug)]
90pub enum PreparedArg<'a> {
91    Value(Cow<'a, LiteralValue>),
92    Range(crate::engine::range_view::RangeView<'a>),
93    Reference(formualizer_parse::parser::ReferenceType),
94    Predicate(CriteriaPredicate),
95}
96
97pub struct PreparedArgs<'a> {
98    pub items: Vec<PreparedArg<'a>>,
99}
100
101#[derive(Default)]
102pub struct ValidationOptions {
103    pub warn_only: bool,
104    /// Minimum number of arguments the function requires.  When non-zero,
105    /// `validate_and_prepare` rejects calls with fewer arguments before any
106    /// per-argument validation runs, preventing out-of-bounds panics in
107    /// `eval` implementations.
108    pub min_args: usize,
109}
110
111// Legacy adapter removed in clean break.
112
113/// Parse a criteria argument (COUNTIF/SUMIF/...IFS, MAXIFS/MINIFS and the
114/// database functions) into a predicate, following Excel's rules:
115/// - a number, boolean or error criterion matches that value;
116/// - a reference to an empty cell is the number 0 (blank cells do not match);
117/// - `""` matches blank cells and empty text, `"="` only blank cells, and
118///   `"<>"` every cell that is not blank;
119/// - `?`, `*` and `~` are wildcards, matched against text cells only, with or
120///   without a leading `=` (`"<>a*"` is the complement);
121/// - `>`, `>=`, `<`, `<=` compare numbers with a numeric operand and text
122///   with a text operand.
123pub fn parse_criteria(v: &LiteralValue) -> Result<CriteriaPredicate, ExcelError> {
124    match v {
125        LiteralValue::Text(s) => {
126            // Leading spaces before an operator are tolerated; the text operand
127            // itself is matched exactly, so trailing spaces are significant
128            // (`"Ltd "` does not match `"Ltd"`).
129            let s_trim = s.trim_start();
130
131            let unquote = |t: &str| -> String {
132                let q = t.trim();
133                if let Some(inner) = q.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
134                    inner.replace("\"\"", "\"")
135                } else {
136                    t.to_string()
137                }
138            };
139            let is_pattern = |t: &str| t.contains('*') || t.contains('?') || t.contains("~~");
140
141            // Operators: >=, <=, <>, >, <, =
142            let ops = [">=", "<=", "<>", ">", "<", "="];
143            for op in ops.iter() {
144                if let Some(rhs) = s_trim.strip_prefix(op) {
145                    // Numbers, then date/time text (`">=2/1/02"`), as Excel
146                    // reads the operand the way it reads typed input.
147                    if let Some(n) = parse_criteria_number(rhs.trim())
148                        .or_else(|| parse_criteria_datetime(rhs.trim()))
149                    {
150                        return Ok(match *op {
151                            ">=" => CriteriaPredicate::Ge(n),
152                            "<=" => CriteriaPredicate::Le(n),
153                            ">" => CriteriaPredicate::Gt(n),
154                            "<" => CriteriaPredicate::Lt(n),
155                            "=" => CriteriaPredicate::Eq(LiteralValue::Number(n)),
156                            "<>" => CriteriaPredicate::Ne(LiteralValue::Number(n)),
157                            _ => unreachable!(),
158                        });
159                    }
160                    // Fallback: non-numeric operand (support Excel-style quoted strings: ="aa")
161                    let text = unquote(rhs);
162                    let boolean = match text.to_ascii_lowercase().as_str() {
163                        "true" => Some(true),
164                        "false" => Some(false),
165                        _ => None,
166                    };
167                    return Ok(match *op {
168                        "=" if text.is_empty() => CriteriaPredicate::IsBlank,
169                        "<>" if text.is_empty() => CriteriaPredicate::NotBlank,
170                        "=" if is_pattern(&text) => CriteriaPredicate::TextLike {
171                            pattern: text,
172                            case_insensitive: true,
173                        },
174                        "<>" if is_pattern(&text) => CriteriaPredicate::NotTextLike {
175                            pattern: text,
176                            case_insensitive: true,
177                        },
178                        "=" => match boolean {
179                            Some(b) => CriteriaPredicate::Eq(LiteralValue::Boolean(b)),
180                            None => CriteriaPredicate::Eq(LiteralValue::Text(text)),
181                        },
182                        "<>" => match boolean {
183                            Some(b) => CriteriaPredicate::Ne(LiteralValue::Boolean(b)),
184                            None => CriteriaPredicate::Ne(LiteralValue::Text(text)),
185                        },
186                        ">=" => CriteriaPredicate::TextGe(text),
187                        "<=" => CriteriaPredicate::TextLe(text),
188                        ">" => CriteriaPredicate::TextGt(text),
189                        "<" => CriteriaPredicate::TextLt(text),
190                        _ => unreachable!(),
191                    });
192                }
193            }
194
195            let plain = unquote(s);
196
197            // Wildcards or escaped tilde => TextLike (including literal ~* and ~?).
198            if is_pattern(&plain) {
199                return Ok(CriteriaPredicate::TextLike {
200                    pattern: plain,
201                    case_insensitive: true,
202                });
203            }
204            // Booleans TRUE/FALSE
205            let lower = plain.to_ascii_lowercase();
206            if lower == "true" {
207                return Ok(CriteriaPredicate::Eq(LiteralValue::Boolean(true)));
208            } else if lower == "false" {
209                return Ok(CriteriaPredicate::Eq(LiteralValue::Boolean(false)));
210            }
211            // Date or time text (`"2/1/2002"`) matches the date's serial.
212            if parse_criteria_number(plain.trim()).is_none()
213                && let Some(serial) = parse_criteria_datetime(plain.trim())
214            {
215                return Ok(CriteriaPredicate::Eq(LiteralValue::Number(serial)));
216            }
217            // Plain text equality
218            Ok(CriteriaPredicate::Eq(LiteralValue::Text(plain)))
219        }
220        // Excel: a criteria argument that references an empty cell is 0.
221        LiteralValue::Empty => Ok(CriteriaPredicate::Eq(LiteralValue::Number(0.0))),
222        LiteralValue::Number(n) => Ok(CriteriaPredicate::Eq(LiteralValue::Number(*n))),
223        // Normalize integer criteria to Number for Excel-style numeric coercions
224        // (numeric text == number, etc.)
225        LiteralValue::Int(i) => Ok(CriteriaPredicate::Eq(LiteralValue::Number(*i as f64))),
226        LiteralValue::Boolean(b) => Ok(CriteriaPredicate::Eq(LiteralValue::Boolean(*b))),
227        LiteralValue::Error(e) => Err(e.clone()),
228        LiteralValue::Array(arr) => {
229            // Treat 1x1 array literals as scalars for criteria parsing
230            if arr.len() == 1 && arr.first().map(|r| r.len()).unwrap_or(0) == 1 {
231                parse_criteria(&arr[0][0])
232            } else {
233                Ok(CriteriaPredicate::Eq(LiteralValue::Array(arr.clone())))
234            }
235        }
236        other => Ok(CriteriaPredicate::Eq(other.clone())),
237    }
238}
239
240/// A date or time operand (`"2/1/02"`, `"1-Feb-2002"`, `"13:30"`) as a 1900-system
241/// serial. Criteria parsing has no workbook context, so 1904-system workbooks
242/// would see the 1900 serial.
243fn parse_criteria_datetime(text: &str) -> Option<f64> {
244    if text.is_empty() || !text.bytes().any(|b| b.is_ascii_digit()) {
245        return None;
246    }
247    formualizer_common::parse_excel_datetime_text_to_serial_for(
248        formualizer_common::DateSystem::Excel1900,
249        text,
250    )
251}
252
253/// A finite number operand of a criteria operator (`">=1e3"`, `"<>-2"`).
254fn parse_criteria_number(text: &str) -> Option<f64> {
255    text.parse::<f64>().ok().filter(|n| n.is_finite())
256}
257
258pub fn validate_and_prepare<'a, 'b>(
259    args: &'a [ArgumentHandle<'a, 'b>],
260    schema: &[ArgSchema],
261    options: ValidationOptions,
262) -> Result<PreparedArgs<'a>, ExcelError> {
263    // Minimum arity — reject too-few arguments before per-arg validation so
264    // that individual `eval` implementations cannot panic on indexing.
265    if options.min_args > 0 && args.len() < options.min_args {
266        if options.warn_only {
267            return Ok(PreparedArgs { items: Vec::new() });
268        }
269        return Err(ExcelError::new(ExcelErrorKind::Value).with_message(format!(
270            "Too few arguments: expected at least {}, got {}",
271            options.min_args,
272            args.len()
273        )));
274    }
275
276    // Arity: simple rule – if schema.len() == 1, allow variadic repetition; else match up to schema.len()
277    if schema.is_empty() {
278        return Ok(PreparedArgs { items: Vec::new() });
279    }
280
281    let mut items: Vec<PreparedArg<'a>> = Vec::with_capacity(args.len());
282    for (idx, arg) in args.iter().enumerate() {
283        let spec = if schema.len() == 1 {
284            &schema[0]
285        } else if idx < schema.len() {
286            &schema[idx]
287        } else {
288            // Attempt to find a repeating spec (e.g., variadic tail like CHOOSE, SUM, etc.)
289            if let Some(rep_spec) = schema.iter().find(|s| s.repeating.is_some()) {
290                rep_spec
291            } else if options.warn_only {
292                continue;
293            } else {
294                return Err(
295                    ExcelError::new(ExcelErrorKind::Value).with_message("Too many arguments")
296                );
297            }
298        };
299
300        // By-ref argument: require a reference (AST literal or function-returned)
301        if spec.by_ref {
302            match arg.as_reference_or_eval() {
303                Ok(r) => {
304                    items.push(PreparedArg::Reference(r));
305                    continue;
306                }
307                Err(e) => {
308                    if options.warn_only {
309                        continue;
310                    } else {
311                        return Err(e);
312                    }
313                }
314            }
315        }
316
317        // Criteria policy: parse into predicate
318        if matches!(spec.coercion, CoercionPolicy::Criteria) {
319            let v = arg.value()?.into_literal();
320            match parse_criteria(&v) {
321                Ok(pred) => {
322                    items.push(PreparedArg::Predicate(pred));
323                    continue;
324                }
325                Err(e) => {
326                    if options.warn_only {
327                        continue;
328                    } else {
329                        return Err(e);
330                    }
331                }
332            }
333        }
334
335        // Shape handling
336        match spec.shape {
337            ShapeKind::Scalar => {
338                // Collapse to scalar if needed (top-left for arrays)
339                match arg.value() {
340                    Ok(cv) => {
341                        let v: Cow<'_, LiteralValue> = match cv {
342                            crate::traits::CalcValue::Scalar(LiteralValue::Array(arr)) => {
343                                let tl = arr
344                                    .first()
345                                    .and_then(|row| row.first())
346                                    .cloned()
347                                    .unwrap_or(LiteralValue::Empty);
348                                Cow::Owned(tl)
349                            }
350                            crate::traits::CalcValue::Range(rv) => Cow::Owned(rv.get_cell(0, 0)),
351                            crate::traits::CalcValue::Scalar(s)
352                            | crate::traits::CalcValue::AnnotatedScalar(s, _) => Cow::Owned(s),
353                            crate::traits::CalcValue::Callable(_) => {
354                                Cow::Owned(LiteralValue::Error(
355                                    ExcelError::new(ExcelErrorKind::Calc)
356                                        .with_message("LAMBDA value must be invoked"),
357                                ))
358                            }
359                        };
360                        // Apply coercion policy to Value shapes when applicable
361                        let coerced = match spec.coercion {
362                            CoercionPolicy::None => v,
363                            CoercionPolicy::NumberStrict => {
364                                match crate::coercion::to_number_strict(v.as_ref()) {
365                                    Ok(n) => Cow::Owned(LiteralValue::Number(n)),
366                                    Err(e) => {
367                                        if options.warn_only {
368                                            v
369                                        } else {
370                                            return Err(e);
371                                        }
372                                    }
373                                }
374                            }
375                            CoercionPolicy::NumberLenientText => {
376                                match crate::coercion::to_number_lenient(v.as_ref()) {
377                                    Ok(n) => Cow::Owned(LiteralValue::Number(n)),
378                                    Err(e) => {
379                                        if options.warn_only {
380                                            v
381                                        } else {
382                                            return Err(e);
383                                        }
384                                    }
385                                }
386                            }
387                            CoercionPolicy::Logical => {
388                                match crate::coercion::to_logical(v.as_ref()) {
389                                    Ok(b) => Cow::Owned(LiteralValue::Boolean(b)),
390                                    Err(e) => {
391                                        if options.warn_only {
392                                            v
393                                        } else {
394                                            return Err(e);
395                                        }
396                                    }
397                                }
398                            }
399                            CoercionPolicy::Criteria => v, // handled per-function currently
400                            CoercionPolicy::DateTimeSerial => {
401                                match crate::coercion::to_datetime_serial(v.as_ref()) {
402                                    Ok(n) => Cow::Owned(LiteralValue::Number(n)),
403                                    Err(e) => {
404                                        if options.warn_only {
405                                            v
406                                        } else {
407                                            return Err(e);
408                                        }
409                                    }
410                                }
411                            }
412                        };
413                        items.push(PreparedArg::Value(coerced))
414                    }
415                    Err(e) => items.push(PreparedArg::Value(Cow::Owned(LiteralValue::Error(e)))),
416                }
417            }
418            ShapeKind::Range | ShapeKind::Array => match arg.resolve_once() {
419                Ok(crate::traits::ResolvedArgument::Range(range))
420                | Ok(crate::traits::ResolvedArgument::Value(crate::traits::CalcValue::Range(
421                    range,
422                ))) => items.push(PreparedArg::Range(range)),
423                Ok(crate::traits::ResolvedArgument::Value(value)) => {
424                    // Excel-compatible: range-accepting functions also accept scalars.
425                    items.push(PreparedArg::Value(Cow::Owned(value.into_literal())))
426                }
427                Ok(crate::traits::ResolvedArgument::ReferenceError(error)) => {
428                    items.push(PreparedArg::Value(Cow::Owned(LiteralValue::Error(error))))
429                }
430                Err(error) => {
431                    items.push(PreparedArg::Value(Cow::Owned(LiteralValue::Error(error))))
432                }
433            },
434        }
435    }
436
437    Ok(PreparedArgs { items })
438}