Skip to main content

formualizer_eval/builtins/lookup/
core.rs

1//! Classic lookup & reference essentials: MATCH, VLOOKUP, HLOOKUP (Sprint 4 subset)
2//!
3//! Implementation notes:
4//! - MATCH supports match_type: 0 exact, 1 approximate (largest <= lookup), -1 approximate (smallest >= lookup)
5//! - Approximate modes assume data sorted ascending (1) or descending (-1); unsorted leads to #N/A like Excel (we don't yet detect unsorted reliably, TODO)
6//! - Binary search used for approximate modes for efficiency; linear scan for exact or when data small (<8 elements) to avoid overhead.
7//! - VLOOKUP/HLOOKUP wrap MATCH logic; VLOOKUP: vertical first column; HLOOKUP: horizontal first row.
8//! - Error propagation: if lookup_value is error -> propagate. If table/range contains errors in non-deciding positions, they don't matter unless selected.
9//! - Type coercion: current simple: numbers vs numeric text coerced; text comparison case-insensitive? Excel is case-insensitive for MATCH (without wildcards). We implement case-insensitive for now.
10//!   TODO(excel-nuance): refine boolean/text/number coercion differences.
11
12use super::lookup_utils::{cmp_for_lookup, find_exact_index, is_sorted_ascending};
13use crate::args::{ArgSchema, CoercionPolicy, ShapeKind};
14use crate::function::Function;
15use crate::traits::{ArgumentHandle, FunctionContext};
16use formualizer_common::ArgKind;
17use formualizer_common::{ExcelError, ExcelErrorKind, LiteralValue};
18use formualizer_macros::func_caps;
19
20fn binary_search_match(slice: &[LiteralValue], needle: &LiteralValue, mode: i32) -> Option<usize> {
21    if mode == 0 || slice.is_empty() {
22        return None;
23    }
24    // Only ascending binary search currently (mode 1); descending path kept linear for now.
25    if mode == 1 {
26        // largest <= needle
27        let mut lo = 0usize;
28        let mut hi = slice.len();
29        while lo < hi {
30            let mid = (lo + hi) / 2;
31            match cmp_for_lookup(&slice[mid], needle) {
32                Some(c) => {
33                    if c > 0 {
34                        hi = mid;
35                    } else {
36                        lo = mid + 1;
37                    }
38                }
39                None => {
40                    hi = mid;
41                }
42            }
43        }
44        if lo == 0 { None } else { Some(lo - 1) }
45    } else {
46        // -1 mode handled via linear fallback since semantics differ (smallest >=)
47        let mut best: Option<usize> = None;
48        for (i, v) in slice.iter().enumerate() {
49            if let Some(c) = cmp_for_lookup(v, needle) {
50                if c == 0 {
51                    return Some(i);
52                }
53                if c >= 0 && best.is_none_or(|b| i < b) {
54                    best = Some(i);
55                }
56            }
57        }
58        best
59    }
60}
61
62#[derive(Debug)]
63pub struct MatchFn;
64/// Returns the relative position of a lookup value in a one-dimensional array.
65///
66/// `MATCH` supports exact and approximate modes and returns a 1-based position.
67///
68/// # Remarks
69/// - `match_type` defaults to `1` (approximate, ascending).
70/// - `match_type=0` performs exact matching and supports `*`, `?`, and `~` wildcards for text.
71/// - `match_type=1` looks for the largest value less than or equal to the lookup value.
72/// - `match_type=-1` looks for the smallest value greater than or equal to the lookup value.
73/// - Approximate modes require sorted data; unsorted data returns `#N/A`.
74/// - If no match is found, returns `#N/A`.
75///
76/// # Examples
77/// ```yaml,sandbox
78/// title: "Exact text match"
79/// grid:
80///   A1: "A"
81///   A2: "B"
82///   A3: "C"
83/// formula: '=MATCH("B",A1:A3,0)'
84/// expected: 2
85/// ```
86///
87/// ```yaml,sandbox
88/// title: "Approximate numeric match"
89/// grid:
90///   A1: 10
91///   A2: 20
92///   A3: 30
93///   A4: 40
94/// formula: '=MATCH(27,A1:A4,1)'
95/// expected: 2
96/// ```
97///
98/// ```yaml,docs
99/// related:
100///   - XMATCH
101///   - XLOOKUP
102///   - VLOOKUP
103/// faq:
104///   - q: "Why does MATCH with match_type 1 or -1 return #N/A on unsorted data?"
105///     a: "Approximate modes assume ordered lookup data; this implementation treats detected unsorted inputs as no valid match and returns #N/A."
106///   - q: "When are wildcards interpreted in MATCH?"
107///     a: "Wildcard patterns (*, ?, ~ escapes) are only applied in exact mode (match_type=0) for text lookup values."
108/// ```
109/// [formualizer-docgen:schema:start]
110/// Name: MATCH
111/// Type: MatchFn
112/// Min args: 2
113/// Max args: 3
114/// Variadic: false
115/// Signature: MATCH(arg1: any@scalar, arg2: any@range, arg3?: number@scalar)
116/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg2{kinds=any,required=true,shape=range,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg3{kinds=number,required=false,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=true}
117/// Caps: PURE, LOOKUP
118/// [formualizer-docgen:schema:end]
119impl Function for MatchFn {
120    fn name(&self) -> &'static str {
121        "MATCH"
122    }
123    fn min_args(&self) -> usize {
124        2
125    }
126    func_caps!(PURE, LOOKUP);
127    fn arg_schema(&self) -> &'static [ArgSchema] {
128        use once_cell::sync::Lazy;
129        static SCHEMA: Lazy<Vec<ArgSchema>> = Lazy::new(|| {
130            vec![
131                // lookup_value (any scalar)
132                ArgSchema {
133                    kinds: smallvec::smallvec![ArgKind::Any],
134                    required: true,
135                    by_ref: false,
136                    shape: ShapeKind::Scalar,
137                    coercion: CoercionPolicy::None,
138                    max: None,
139                    repeating: None,
140                    default: None,
141                },
142                // lookup_array (accepts both references and array literals)
143                ArgSchema {
144                    kinds: smallvec::smallvec![ArgKind::Any],
145                    required: true,
146                    by_ref: false,
147                    shape: ShapeKind::Range,
148                    coercion: CoercionPolicy::None,
149                    max: None,
150                    repeating: None,
151                    default: None,
152                },
153                // match_type (optional numeric, default 1)
154                ArgSchema {
155                    kinds: smallvec::smallvec![ArgKind::Number],
156                    required: false,
157                    by_ref: false,
158                    shape: ShapeKind::Scalar,
159                    coercion: CoercionPolicy::NumberLenientText,
160                    max: None,
161                    repeating: None,
162                    default: Some(LiteralValue::Number(1.0)),
163                },
164            ]
165        });
166        &SCHEMA
167    }
168    fn eval<'a, 'b, 'c>(
169        &self,
170        args: &'c [ArgumentHandle<'a, 'b>],
171        ctx: &dyn FunctionContext<'b>,
172    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
173        if args.len() < 2 {
174            return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
175                ExcelError::new(ExcelErrorKind::Na),
176            )));
177        }
178        let cv = args[0].value()?;
179        let lookup_value = cv.into_literal();
180        if let LiteralValue::Error(e) = lookup_value {
181            return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(e)));
182        }
183        let mut match_type = 1.0; // default
184        if args.len() >= 3 {
185            let mt_val = args[2].value()?.into_literal();
186            if let LiteralValue::Error(e) = mt_val {
187                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(e)));
188            }
189            match mt_val {
190                LiteralValue::Number(n) => match_type = n,
191                LiteralValue::Int(i) => match_type = i as f64,
192                LiteralValue::Text(s) => {
193                    if let Ok(n) = s.parse::<f64>() {
194                        match_type = n;
195                    }
196                }
197                _ => {}
198            }
199        }
200        let mt = if match_type > 0.0 {
201            1
202        } else if match_type < 0.0 {
203            -1
204        } else {
205            0
206        };
207        let arr_ref = args[1].as_reference_or_eval().ok();
208        if let Some(r) = arr_ref {
209            let current_sheet = ctx.current_sheet();
210            match ctx.resolve_range_view(&r, current_sheet) {
211                Ok(rv) => {
212                    if mt == 0 {
213                        let wildcard_mode = matches!(lookup_value, LiteralValue::Text(ref s) if s.contains('*') || s.contains('?') || s.contains('~'));
214                        if let Some(idx) = super::lookup_utils::find_exact_index_in_view(
215                            &rv,
216                            &lookup_value,
217                            wildcard_mode,
218                        )? {
219                            return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Int(
220                                (idx + 1) as i64,
221                            )));
222                        }
223                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
224                            ExcelError::new(ExcelErrorKind::Na),
225                        )));
226                    }
227
228                    // Fallback for approximate match modes (handled via materialization for now)
229                    let mut values: Vec<LiteralValue> = Vec::new();
230                    if let Err(e) = rv.for_each_cell(&mut |v| {
231                        values.push(v.clone());
232                        Ok(())
233                    }) {
234                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(e)));
235                    }
236
237                    // Lightweight unsorted detection for approximate modes
238                    let is_sorted = if mt == 1 {
239                        is_sorted_ascending(&values)
240                    } else if mt == -1 {
241                        values
242                            .windows(2)
243                            .all(|w| cmp_for_lookup(&w[0], &w[1]).is_some_and(|c| c >= 0))
244                    } else {
245                        true
246                    };
247                    if !is_sorted {
248                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
249                            ExcelError::new(ExcelErrorKind::Na),
250                        )));
251                    }
252                    let idx = if values.len() < 8 {
253                        // linear small
254                        let mut best: Option<(usize, &LiteralValue)> = None;
255                        for (i, v) in values.iter().enumerate() {
256                            if let Some(c) = cmp_for_lookup(v, &lookup_value) {
257                                // compare candidate to needle
258                                if mt == 1 {
259                                    // v <= needle
260                                    if (c == 0 || c == -1)
261                                        && (best.is_none() || i > best.unwrap().0)
262                                    {
263                                        best = Some((i, v));
264                                    }
265                                } else {
266                                    // -1, v >= needle
267                                    if (c == 0 || c == 1) && (best.is_none() || i > best.unwrap().0)
268                                    {
269                                        best = Some((i, v));
270                                    }
271                                }
272                            }
273                        }
274                        best.map(|(i, _)| i)
275                    } else {
276                        binary_search_match(&values, &lookup_value, mt)
277                    };
278                    match idx {
279                        Some(i) => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Int(
280                            (i + 1) as i64,
281                        ))),
282                        None => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
283                            ExcelError::new(ExcelErrorKind::Na),
284                        ))),
285                    }
286                }
287                Err(e) => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(e))),
288            }
289        } else {
290            // Handle array literals and other non-reference values
291            let v = args[1].value()?.into_literal();
292            let values: Vec<LiteralValue> = match v {
293                LiteralValue::Array(rows) => {
294                    // Flatten the array (MATCH works on 1D, so take first row or column)
295                    if rows.len() == 1 {
296                        // Single row - use as-is
297                        rows.into_iter().next().unwrap_or_default()
298                    } else if rows.iter().all(|r| r.len() == 1) {
299                        // Column vector - extract first element of each row
300                        rows.into_iter()
301                            .filter_map(|r| r.into_iter().next())
302                            .collect()
303                    } else {
304                        // 2D array - flatten row by row
305                        rows.into_iter().flatten().collect()
306                    }
307                }
308                other => vec![other],
309            };
310            let idx = if mt == 0 {
311                let wildcard_mode = matches!(lookup_value, LiteralValue::Text(ref s) if s.contains('*') || s.contains('?') || s.contains('~'));
312                find_exact_index(&values, &lookup_value, wildcard_mode)
313            } else {
314                binary_search_match(&values, &lookup_value, mt)
315            };
316            match idx {
317                Some(i) => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Int(
318                    (i + 1) as i64,
319                ))),
320                None => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
321                    ExcelError::new(ExcelErrorKind::Na),
322                ))),
323            }
324        }
325    }
326}
327
328#[derive(Debug)]
329pub struct VLookupFn;
330/// Looks up a value in the first column of a table and returns a value from another column.
331///
332/// `VLOOKUP` searches vertically and returns the matching row's value from `col_index_num`.
333///
334/// # Remarks
335/// - `col_index_num` is 1-based and must be within the table width.
336/// - `range_lookup` defaults to `FALSE` in this engine (exact match by default).
337/// - When `range_lookup=TRUE`, approximate match logic is used against the first column.
338/// - If the lookup value is not found, returns `#N/A`.
339/// - If `col_index_num` is invalid, returns `#REF!` (or `#VALUE!` if non-numeric).
340/// - A matched empty target cell is materialized as numeric `0`.
341///
342/// # Examples
343/// ```yaml,sandbox
344/// title: "Exact match in a key/value table"
345/// grid:
346///   A1: "SKU-1"
347///   B1: 12.5
348///   A2: "SKU-2"
349///   B2: 18
350/// formula: '=VLOOKUP("SKU-2",A1:B2,2,FALSE)'
351/// expected: 18
352/// ```
353///
354/// ```yaml,sandbox
355/// title: "Approximate tier lookup"
356/// grid:
357///   A1: 0
358///   B1: "Bronze"
359///   A2: 1000
360///   B2: "Silver"
361///   A3: 5000
362///   B3: "Gold"
363/// formula: '=VLOOKUP(3200,A1:B3,2,TRUE)'
364/// expected: "Silver"
365/// ```
366///
367/// ```yaml,docs
368/// related:
369///   - HLOOKUP
370///   - XLOOKUP
371///   - MATCH
372/// faq:
373///   - q: "What is the default behavior when range_lookup is omitted?"
374///     a: "This engine defaults range_lookup to FALSE, so VLOOKUP performs exact matching unless TRUE is explicitly provided."
375///   - q: "What happens if col_index_num points outside the table?"
376///     a: "A numeric out-of-range column index returns #REF!, while a non-numeric col_index_num returns #VALUE!."
377/// ```
378/// [formualizer-docgen:schema:start]
379/// Name: VLOOKUP
380/// Type: VLookupFn
381/// Min args: 3
382/// Max args: 4
383/// Variadic: false
384/// Signature: VLOOKUP(arg1: any@scalar, arg2: any@range, arg3: number@scalar, arg4?: logical@scalar)
385/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg2{kinds=any,required=true,shape=range,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberStrict,max=None,repeating=None,default=false}; arg4{kinds=logical,required=false,shape=scalar,by_ref=false,coercion=Logical,max=None,repeating=None,default=true}
386/// Caps: PURE, LOOKUP
387/// [formualizer-docgen:schema:end]
388impl Function for VLookupFn {
389    fn name(&self) -> &'static str {
390        "VLOOKUP"
391    }
392    fn min_args(&self) -> usize {
393        3
394    }
395    func_caps!(PURE, LOOKUP);
396    fn arg_schema(&self) -> &'static [ArgSchema] {
397        use once_cell::sync::Lazy;
398        static SCHEMA: Lazy<Vec<ArgSchema>> = Lazy::new(|| {
399            vec![
400                // lookup_value
401                ArgSchema {
402                    kinds: smallvec::smallvec![ArgKind::Any],
403                    required: true,
404                    by_ref: false,
405                    shape: ShapeKind::Scalar,
406                    coercion: CoercionPolicy::None,
407                    max: None,
408                    repeating: None,
409                    default: None,
410                },
411                // table_array (accepts both references and array literals)
412                ArgSchema {
413                    kinds: smallvec::smallvec![ArgKind::Any],
414                    required: true,
415                    by_ref: false,
416                    shape: ShapeKind::Range,
417                    coercion: CoercionPolicy::None,
418                    max: None,
419                    repeating: None,
420                    default: None,
421                },
422                // col_index_num (strict number)
423                ArgSchema {
424                    kinds: smallvec::smallvec![ArgKind::Number],
425                    required: true,
426                    by_ref: false,
427                    shape: ShapeKind::Scalar,
428                    coercion: CoercionPolicy::NumberStrict,
429                    max: None,
430                    repeating: None,
431                    default: None,
432                },
433                // range_lookup (optional logical, default FALSE for safer exact default)
434                ArgSchema {
435                    kinds: smallvec::smallvec![ArgKind::Logical],
436                    required: false,
437                    by_ref: false,
438                    shape: ShapeKind::Scalar,
439                    coercion: CoercionPolicy::Logical,
440                    max: None,
441                    repeating: None,
442                    default: Some(LiteralValue::Boolean(false)),
443                },
444            ]
445        });
446        &SCHEMA
447    }
448    fn eval<'a, 'b, 'c>(
449        &self,
450        args: &'c [ArgumentHandle<'a, 'b>],
451        ctx: &dyn FunctionContext<'b>,
452    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
453        if args.len() < 3 {
454            return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
455                ExcelError::new(ExcelErrorKind::Na),
456            )));
457        }
458        let lookup_value = args[0].value()?.into_literal();
459
460        // Try to get table as reference, fall back to array literal
461        let table_ref_opt = args[1].as_reference_or_eval().ok();
462        let col_index = match args[2].value()?.into_literal() {
463            LiteralValue::Int(i) => i,
464            LiteralValue::Number(n) => n as i64,
465            _ => {
466                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
467                    ExcelError::new(ExcelErrorKind::Value),
468                )));
469            }
470        };
471        if col_index < 1 {
472            return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
473                ExcelError::new(ExcelErrorKind::Value),
474            )));
475        }
476        let approximate = if args.len() >= 4 {
477            match args[3].value()?.into_literal() {
478                LiteralValue::Boolean(b) => b,
479                _ => true,
480            }
481        } else {
482            false // engine chooses FALSE default (exact) rather than Excel's historical TRUE to avoid silent approximate matches
483        };
484        // Handle both cell references and array literals
485        if let Some(table_ref) = table_ref_opt {
486            let current_sheet = ctx.current_sheet();
487            let rv = ctx.resolve_range_view(&table_ref, current_sheet)?;
488            let (rows, cols) = rv.dims();
489            if col_index as usize > cols {
490                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
491                    ExcelError::new(ExcelErrorKind::Ref),
492                )));
493            }
494
495            let first_col_view = rv.sub_view(0, 0, rows, 1);
496            let row_idx_opt = if !approximate {
497                let wildcard_mode = matches!(lookup_value, LiteralValue::Text(ref s) if s.contains('*') || s.contains('?') || s.contains('~'));
498                super::lookup_utils::find_exact_index_in_view(
499                    &first_col_view,
500                    &lookup_value,
501                    wildcard_mode,
502                )?
503            } else {
504                // Fallback for approximate mode (requires materializing first column for now)
505                let mut first_col: Vec<LiteralValue> = Vec::new();
506                first_col_view.for_each_row(&mut |row| {
507                    first_col.push(row[0].clone());
508                    Ok(())
509                })?;
510                if first_col.is_empty() {
511                    None
512                } else {
513                    binary_search_match(&first_col, &lookup_value, 1)
514                }
515            };
516
517            match row_idx_opt {
518                Some(i) => {
519                    let target_col_idx = (col_index - 1) as usize;
520                    let v = rv.get_cell(i, target_col_idx);
521                    // Excel treats a direct reference to an empty cell as 0.
522                    // VLOOKUP/HLOOKUP return the referenced cell value, so match Excel by
523                    // materializing Empty as numeric 0. (Empty text "" remains Text(""))
524                    let v = match v {
525                        LiteralValue::Empty => LiteralValue::Number(0.0),
526                        other => other,
527                    };
528                    Ok(crate::traits::CalcValue::Scalar(v))
529                }
530                None => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
531                    ExcelError::new(ExcelErrorKind::Na),
532                ))),
533            }
534        } else {
535            // Handle array literal
536            let v = args[1].value()?.into_literal();
537            let table: Vec<Vec<LiteralValue>> = match v {
538                LiteralValue::Array(rows) => rows,
539                other => vec![vec![other]],
540            };
541            if table.is_empty() {
542                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
543                    ExcelError::new(ExcelErrorKind::Na),
544                )));
545            }
546            let width = table.first().map(|r| r.len()).unwrap_or(0);
547            if col_index as usize > width {
548                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
549                    ExcelError::new(ExcelErrorKind::Ref),
550                )));
551            }
552
553            // First column values for lookup
554            let first_col: Vec<LiteralValue> =
555                table.iter().filter_map(|r| r.first().cloned()).collect();
556            let row_idx_opt = if !approximate {
557                let wildcard_mode = matches!(lookup_value, LiteralValue::Text(ref s) if s.contains('*') || s.contains('?') || s.contains('~'));
558                find_exact_index(&first_col, &lookup_value, wildcard_mode)
559            } else {
560                binary_search_match(&first_col, &lookup_value, 1)
561            };
562
563            match row_idx_opt {
564                Some(i) => {
565                    let target_col_idx = (col_index - 1) as usize;
566                    let val = table
567                        .get(i)
568                        .and_then(|r| r.get(target_col_idx))
569                        .cloned()
570                        .unwrap_or(LiteralValue::Empty);
571                    let val = match val {
572                        LiteralValue::Empty => LiteralValue::Number(0.0),
573                        other => other,
574                    };
575                    Ok(crate::traits::CalcValue::Scalar(val))
576                }
577                None => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
578                    ExcelError::new(ExcelErrorKind::Na),
579                ))),
580            }
581        }
582    }
583}
584
585#[derive(Debug)]
586pub struct HLookupFn;
587/// Looks up a value in the first row of a table and returns a value from another row.
588///
589/// `HLOOKUP` searches horizontally and returns the matching column's value from `row_index_num`.
590///
591/// # Remarks
592/// - `row_index_num` is 1-based and must be within the table height.
593/// - `range_lookup` defaults to `FALSE` in this engine (exact match by default).
594/// - When `range_lookup=TRUE`, approximate match logic is used against the first row.
595/// - If the lookup value is not found, returns `#N/A`.
596/// - If `row_index_num` is invalid, returns `#REF!` (or `#VALUE!` if non-numeric).
597/// - A matched empty target cell is materialized as numeric `0`.
598///
599/// # Examples
600/// ```yaml,sandbox
601/// title: "Exact match across header row"
602/// grid:
603///   A1: "Jan"
604///   B1: "Feb"
605///   A2: 120
606///   B2: 150
607/// formula: '=HLOOKUP("Feb",A1:B2,2,FALSE)'
608/// expected: 150
609/// ```
610///
611/// ```yaml,sandbox
612/// title: "Approximate threshold lookup"
613/// grid:
614///   A1: 0
615///   B1: 50
616///   C1: 80
617///   A2: "F"
618///   B2: "C"
619///   C2: "A"
620/// formula: '=HLOOKUP(72,A1:C2,2,TRUE)'
621/// expected: "C"
622/// ```
623///
624/// ```yaml,docs
625/// related:
626///   - VLOOKUP
627///   - XLOOKUP
628///   - MATCH
629/// faq:
630///   - q: "Does HLOOKUP default to exact or approximate matching?"
631///     a: "It defaults to exact matching in this engine because range_lookup defaults to FALSE."
632///   - q: "How are invalid row_index_num values reported?"
633///     a: "If row_index_num is outside table height HLOOKUP returns #REF!; if it is non-numeric it returns #VALUE!."
634/// ```
635/// [formualizer-docgen:schema:start]
636/// Name: HLOOKUP
637/// Type: HLookupFn
638/// Min args: 3
639/// Max args: 4
640/// Variadic: false
641/// Signature: HLOOKUP(arg1: any@scalar, arg2: any@range, arg3: number@scalar, arg4?: logical@scalar)
642/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg2{kinds=any,required=true,shape=range,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberStrict,max=None,repeating=None,default=false}; arg4{kinds=logical,required=false,shape=scalar,by_ref=false,coercion=Logical,max=None,repeating=None,default=true}
643/// Caps: PURE, LOOKUP
644/// [formualizer-docgen:schema:end]
645impl Function for HLookupFn {
646    fn name(&self) -> &'static str {
647        "HLOOKUP"
648    }
649    fn min_args(&self) -> usize {
650        3
651    }
652    func_caps!(PURE, LOOKUP);
653    fn arg_schema(&self) -> &'static [ArgSchema] {
654        use once_cell::sync::Lazy;
655        static SCHEMA: Lazy<Vec<ArgSchema>> = Lazy::new(|| {
656            vec![
657                // lookup_value
658                ArgSchema {
659                    kinds: smallvec::smallvec![ArgKind::Any],
660                    required: true,
661                    by_ref: false,
662                    shape: ShapeKind::Scalar,
663                    coercion: CoercionPolicy::None,
664                    max: None,
665                    repeating: None,
666                    default: None,
667                },
668                // table_array (accepts both references and array literals)
669                ArgSchema {
670                    kinds: smallvec::smallvec![ArgKind::Any],
671                    required: true,
672                    by_ref: false,
673                    shape: ShapeKind::Range,
674                    coercion: CoercionPolicy::None,
675                    max: None,
676                    repeating: None,
677                    default: None,
678                },
679                // row_index_num (strict number)
680                ArgSchema {
681                    kinds: smallvec::smallvec![ArgKind::Number],
682                    required: true,
683                    by_ref: false,
684                    shape: ShapeKind::Scalar,
685                    coercion: CoercionPolicy::NumberStrict,
686                    max: None,
687                    repeating: None,
688                    default: None,
689                },
690                // range_lookup (optional logical, default FALSE for safer exact default)
691                ArgSchema {
692                    kinds: smallvec::smallvec![ArgKind::Logical],
693                    required: false,
694                    by_ref: false,
695                    shape: ShapeKind::Scalar,
696                    coercion: CoercionPolicy::Logical,
697                    max: None,
698                    repeating: None,
699                    default: Some(LiteralValue::Boolean(false)),
700                },
701            ]
702        });
703        &SCHEMA
704    }
705    fn eval<'a, 'b, 'c>(
706        &self,
707        args: &'c [ArgumentHandle<'a, 'b>],
708        ctx: &dyn FunctionContext<'b>,
709    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
710        if args.len() < 3 {
711            return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
712                ExcelError::new(ExcelErrorKind::Na),
713            )));
714        }
715        let lookup_value = args[0].value()?.into_literal();
716
717        // Try to get table as reference, fall back to array literal
718        let table_ref_opt = args[1].as_reference_or_eval().ok();
719        let row_index = match args[2].value()?.into_literal() {
720            LiteralValue::Int(i) => i,
721            LiteralValue::Number(n) => n as i64,
722            _ => {
723                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
724                    ExcelError::new(ExcelErrorKind::Value),
725                )));
726            }
727        };
728        if row_index < 1 {
729            return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
730                ExcelError::new(ExcelErrorKind::Value),
731            )));
732        }
733        let approximate = if args.len() >= 4 {
734            match args[3].value()?.into_literal() {
735                LiteralValue::Boolean(b) => b,
736                _ => true,
737            }
738        } else {
739            false
740        };
741        // Handle both cell references and array literals
742        if let Some(table_ref) = table_ref_opt {
743            let current_sheet = ctx.current_sheet();
744            let rv = ctx.resolve_range_view(&table_ref, current_sheet)?;
745            let (rows, cols) = rv.dims();
746            if row_index as usize > rows {
747                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
748                    ExcelError::new(ExcelErrorKind::Ref),
749                )));
750            }
751            let first_row_view = rv.sub_view(0, 0, 1, cols);
752            let col_idx_opt = if approximate {
753                let mut first_row: Vec<LiteralValue> = Vec::with_capacity(cols);
754                first_row_view.for_each_row(&mut |row| {
755                    if first_row.is_empty() {
756                        first_row.extend_from_slice(row);
757                    }
758                    Ok(())
759                })?;
760                binary_search_match(&first_row, &lookup_value, 1)
761            } else {
762                let wildcard_mode = matches!(lookup_value, LiteralValue::Text(ref s) if s.contains('*') || s.contains('?') || s.contains('~'));
763                super::lookup_utils::find_exact_index_in_view(
764                    &first_row_view,
765                    &lookup_value,
766                    wildcard_mode,
767                )?
768            };
769
770            match col_idx_opt {
771                Some(i) => {
772                    let target_row_idx = (row_index - 1) as usize;
773                    let v = rv.get_cell(target_row_idx, i);
774                    let v = match v {
775                        LiteralValue::Empty => LiteralValue::Number(0.0),
776                        other => other,
777                    };
778                    Ok(crate::traits::CalcValue::Scalar(v))
779                }
780                None => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
781                    ExcelError::new(ExcelErrorKind::Na),
782                ))),
783            }
784        } else {
785            // Handle array literal
786            let v = args[1].value()?.into_literal();
787            let table: Vec<Vec<LiteralValue>> = match v {
788                LiteralValue::Array(rows) => rows,
789                other => vec![vec![other]],
790            };
791            if table.is_empty() {
792                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
793                    ExcelError::new(ExcelErrorKind::Na),
794                )));
795            }
796            let height = table.len();
797            if row_index as usize > height {
798                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
799                    ExcelError::new(ExcelErrorKind::Ref),
800                )));
801            }
802
803            // First row values for lookup
804            let first_row: Vec<LiteralValue> = table.first().cloned().unwrap_or_default();
805            let col_idx_opt = if approximate {
806                binary_search_match(&first_row, &lookup_value, 1)
807            } else {
808                let wildcard_mode = matches!(lookup_value, LiteralValue::Text(ref s) if s.contains('*') || s.contains('?') || s.contains('~'));
809                find_exact_index(&first_row, &lookup_value, wildcard_mode)
810            };
811
812            match col_idx_opt {
813                Some(i) => {
814                    let target_row_idx = (row_index - 1) as usize;
815                    let val = table
816                        .get(target_row_idx)
817                        .and_then(|r| r.get(i))
818                        .cloned()
819                        .unwrap_or(LiteralValue::Empty);
820                    let val = match val {
821                        LiteralValue::Empty => LiteralValue::Number(0.0),
822                        other => other,
823                    };
824                    Ok(crate::traits::CalcValue::Scalar(val))
825                }
826                None => Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
827                    ExcelError::new(ExcelErrorKind::Na),
828                ))),
829            }
830        }
831    }
832}
833
834pub fn register_builtins() {
835    use crate::function_registry::register_function;
836    use std::sync::Arc;
837    register_function(Arc::new(MatchFn));
838    register_function(Arc::new(VLookupFn));
839    register_function(Arc::new(HLookupFn));
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use crate::test_workbook::TestWorkbook;
846    use crate::traits::ArgumentHandle;
847    use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
848    use std::sync::Arc;
849    fn lit(v: LiteralValue) -> ASTNode {
850        ASTNode::new(ASTNodeType::Literal(v), None)
851    }
852
853    #[test]
854    fn match_wildcard_and_descending_and_unsorted() {
855        // Wildcard: A1:A4 = "foo", "fob", "bar", "baz"
856        let wb = TestWorkbook::new().with_function(Arc::new(MatchFn));
857        let wb = wb
858            .with_cell_a1("Sheet1", "A1", LiteralValue::Text("foo".into()))
859            .with_cell_a1("Sheet1", "A2", LiteralValue::Text("fob".into()))
860            .with_cell_a1("Sheet1", "A3", LiteralValue::Text("bar".into()))
861            .with_cell_a1("Sheet1", "A4", LiteralValue::Text("baz".into()));
862        let ctx = wb.interpreter();
863        let range = ASTNode::new(
864            ASTNodeType::Reference {
865                original: "A1:A4".into(),
866                reference: ReferenceType::range(None, Some(1), Some(1), Some(4), Some(1)),
867            },
868            None,
869        );
870        let f = ctx.context.get_function("", "MATCH").unwrap();
871        // Wildcard *o* matches "foo" (1) and "fob" (2), should return first match (1)
872        let pat = lit(LiteralValue::Text("*o*".into()));
873        let zero = lit(LiteralValue::Int(0));
874        let args = vec![
875            ArgumentHandle::new(&pat, &ctx),
876            ArgumentHandle::new(&range, &ctx),
877            ArgumentHandle::new(&zero, &ctx),
878        ];
879        let v = f
880            .dispatch(&args, &ctx.function_context(None))
881            .unwrap()
882            .into_literal();
883        assert_eq!(v, LiteralValue::Int(1));
884        // Wildcard b?z matches "baz" (4)
885        let pat2 = lit(LiteralValue::Text("b?z".into()));
886        let args2 = vec![
887            ArgumentHandle::new(&pat2, &ctx),
888            ArgumentHandle::new(&range, &ctx),
889            ArgumentHandle::new(&zero, &ctx),
890        ];
891        let v2 = f
892            .dispatch(&args2, &ctx.function_context(None))
893            .unwrap()
894            .into_literal();
895        assert_eq!(v2, LiteralValue::Int(4));
896        // No match
897        let pat3 = lit(LiteralValue::Text("z*".into()));
898        let args3 = vec![
899            ArgumentHandle::new(&pat3, &ctx),
900            ArgumentHandle::new(&range, &ctx),
901            ArgumentHandle::new(&zero, &ctx),
902        ];
903        let v3 = f
904            .dispatch(&args3, &ctx.function_context(None))
905            .unwrap()
906            .into_literal();
907        assert!(matches!(v3, LiteralValue::Error(e) if e.kind == ExcelErrorKind::Na));
908
909        // Descending approximate: 50,40,30,20,10; match_type = -1
910        let wb2 = TestWorkbook::new()
911            .with_function(Arc::new(MatchFn))
912            .with_cell_a1("Sheet1", "A1", LiteralValue::Int(50))
913            .with_cell_a1("Sheet1", "A2", LiteralValue::Int(40))
914            .with_cell_a1("Sheet1", "A3", LiteralValue::Int(30))
915            .with_cell_a1("Sheet1", "A4", LiteralValue::Int(20))
916            .with_cell_a1("Sheet1", "A5", LiteralValue::Int(10));
917        let ctx2 = wb2.interpreter();
918        let range2 = ASTNode::new(
919            ASTNodeType::Reference {
920                original: "A1:A5".into(),
921                reference: ReferenceType::range(None, Some(1), Some(1), Some(5), Some(1)),
922            },
923            None,
924        );
925        let minus1 = lit(LiteralValue::Int(-1));
926        let thirty = lit(LiteralValue::Int(30));
927        let args_desc = vec![
928            ArgumentHandle::new(&thirty, &ctx2),
929            ArgumentHandle::new(&range2, &ctx2),
930            ArgumentHandle::new(&minus1, &ctx2),
931        ];
932        let v_desc = f
933            .dispatch(&args_desc, &ctx2.function_context(None))
934            .unwrap()
935            .into_literal();
936        assert_eq!(v_desc, LiteralValue::Int(3));
937        // Descending, not found (needle > max)
938        let sixty = lit(LiteralValue::Int(60));
939        let args_desc2 = vec![
940            ArgumentHandle::new(&sixty, &ctx2),
941            ArgumentHandle::new(&range2, &ctx2),
942            ArgumentHandle::new(&minus1, &ctx2),
943        ];
944        let v_desc2 = f
945            .dispatch(&args_desc2, &ctx2.function_context(None))
946            .unwrap()
947            .into_literal();
948        assert!(matches!(v_desc2, LiteralValue::Error(e) if e.kind == ExcelErrorKind::Na));
949
950        // Unsorted detection: 10, 30, 20, 40, 50 (not sorted ascending)
951        let wb3 = TestWorkbook::new()
952            .with_function(Arc::new(MatchFn))
953            .with_cell_a1("Sheet1", "A1", LiteralValue::Int(10))
954            .with_cell_a1("Sheet1", "A2", LiteralValue::Int(30))
955            .with_cell_a1("Sheet1", "A3", LiteralValue::Int(20))
956            .with_cell_a1("Sheet1", "A4", LiteralValue::Int(40))
957            .with_cell_a1("Sheet1", "A5", LiteralValue::Int(50));
958        let ctx3 = wb3.interpreter();
959        let range3 = ASTNode::new(
960            ASTNodeType::Reference {
961                original: "A1:A5".into(),
962                reference: ReferenceType::range(None, Some(1), Some(1), Some(5), Some(1)),
963            },
964            None,
965        );
966        let args_unsorted = vec![
967            ArgumentHandle::new(&thirty, &ctx3),
968            ArgumentHandle::new(&range3, &ctx3),
969        ];
970        let v_unsorted = f
971            .dispatch(&args_unsorted, &ctx3.function_context(None))
972            .unwrap()
973            .into_literal();
974        assert!(matches!(v_unsorted, LiteralValue::Error(e) if e.kind == ExcelErrorKind::Na));
975        // Unsorted detection descending: 50, 30, 40, 20, 10
976        let wb4 = TestWorkbook::new()
977            .with_function(Arc::new(MatchFn))
978            .with_cell_a1("Sheet1", "A1", LiteralValue::Int(50))
979            .with_cell_a1("Sheet1", "A2", LiteralValue::Int(30))
980            .with_cell_a1("Sheet1", "A3", LiteralValue::Int(40))
981            .with_cell_a1("Sheet1", "A4", LiteralValue::Int(20))
982            .with_cell_a1("Sheet1", "A5", LiteralValue::Int(10));
983        let ctx4 = wb4.interpreter();
984        let range4 = ASTNode::new(
985            ASTNodeType::Reference {
986                original: "A1:A5".into(),
987                reference: ReferenceType::range(None, Some(1), Some(1), Some(5), Some(1)),
988            },
989            None,
990        );
991        let args_unsorted_desc = vec![
992            ArgumentHandle::new(&thirty, &ctx4),
993            ArgumentHandle::new(&range4, &ctx4),
994            ArgumentHandle::new(&minus1, &ctx4),
995        ];
996        let v_unsorted_desc = f
997            .dispatch(&args_unsorted_desc, &ctx4.function_context(None))
998            .unwrap()
999            .into_literal();
1000        assert!(matches!(v_unsorted_desc, LiteralValue::Error(e) if e.kind == ExcelErrorKind::Na));
1001    }
1002
1003    #[test]
1004    fn match_exact_and_approx() {
1005        let wb = TestWorkbook::new().with_function(Arc::new(MatchFn));
1006        let wb = wb
1007            .with_cell_a1("Sheet1", "A1", LiteralValue::Int(10))
1008            .with_cell_a1("Sheet1", "A2", LiteralValue::Int(20))
1009            .with_cell_a1("Sheet1", "A3", LiteralValue::Int(30))
1010            .with_cell_a1("Sheet1", "A4", LiteralValue::Int(40))
1011            .with_cell_a1("Sheet1", "A5", LiteralValue::Int(50));
1012        let ctx = wb.interpreter();
1013        let range = ASTNode::new(
1014            ASTNodeType::Reference {
1015                original: "A1:A5".into(),
1016                reference: ReferenceType::range(None, Some(1), Some(1), Some(5), Some(1)),
1017            },
1018            None,
1019        );
1020        let f = ctx.context.get_function("", "MATCH").unwrap();
1021        let thirty = lit(LiteralValue::Int(30));
1022        let zero = lit(LiteralValue::Int(0));
1023        let args = vec![
1024            ArgumentHandle::new(&thirty, &ctx),
1025            ArgumentHandle::new(&range, &ctx),
1026            ArgumentHandle::new(&zero, &ctx),
1027        ];
1028        let v = f
1029            .dispatch(&args, &ctx.function_context(None))
1030            .unwrap()
1031            .into_literal();
1032        assert_eq!(v, LiteralValue::Int(3));
1033        let thirty_seven = lit(LiteralValue::Int(37));
1034        let args = vec![
1035            ArgumentHandle::new(&thirty_seven, &ctx),
1036            ArgumentHandle::new(&range, &ctx),
1037        ];
1038        let v = f
1039            .dispatch(&args, &ctx.function_context(None))
1040            .unwrap()
1041            .into_literal();
1042        assert_eq!(v, LiteralValue::Int(3));
1043    }
1044
1045    #[test]
1046    fn vlookup_basic() {
1047        let wb = TestWorkbook::new()
1048            .with_function(Arc::new(VLookupFn))
1049            .with_cell_a1("Sheet1", "A1", LiteralValue::Text("Key1".into()))
1050            .with_cell_a1("Sheet1", "A2", LiteralValue::Text("Key2".into()))
1051            .with_cell_a1("Sheet1", "B1", LiteralValue::Int(100))
1052            .with_cell_a1("Sheet1", "B2", LiteralValue::Int(200));
1053        let ctx = wb.interpreter();
1054        let table = ASTNode::new(
1055            ASTNodeType::Reference {
1056                original: "A1:B2".into(),
1057                reference: ReferenceType::range(None, Some(1), Some(1), Some(2), Some(2)),
1058            },
1059            None,
1060        );
1061        let f = ctx.context.get_function("", "VLOOKUP").unwrap();
1062        let key2 = lit(LiteralValue::Text("Key2".into()));
1063        let two = lit(LiteralValue::Int(2));
1064        let false_lit = lit(LiteralValue::Boolean(false));
1065        let args = vec![
1066            ArgumentHandle::new(&key2, &ctx),
1067            ArgumentHandle::new(&table, &ctx),
1068            ArgumentHandle::new(&two, &ctx),
1069            ArgumentHandle::new(&false_lit, &ctx),
1070        ];
1071        let v = f
1072            .dispatch(&args, &ctx.function_context(None))
1073            .unwrap()
1074            .into_literal();
1075        assert_eq!(v, LiteralValue::Number(200.0));
1076    }
1077
1078    #[test]
1079    fn vlookup_named_range_reference() {
1080        let wb = TestWorkbook::new()
1081            .with_function(Arc::new(VLookupFn))
1082            .with_named_range(
1083                "Split",
1084                vec![
1085                    vec![
1086                        LiteralValue::Text("Professional".into()),
1087                        LiteralValue::Int(123),
1088                    ],
1089                    vec![LiteralValue::Text("Support".into()), LiteralValue::Int(77)],
1090                ],
1091            );
1092        let ctx = wb.interpreter();
1093        let table = ASTNode::new(
1094            ASTNodeType::Reference {
1095                original: "Split".into(),
1096                reference: ReferenceType::NamedRange("Split".into()),
1097            },
1098            None,
1099        );
1100        let f = ctx.context.get_function("", "VLOOKUP").unwrap();
1101        let key = lit(LiteralValue::Text("Professional".into()));
1102        let two = lit(LiteralValue::Int(2));
1103        let false_lit = lit(LiteralValue::Boolean(false));
1104        let args = vec![
1105            ArgumentHandle::new(&key, &ctx),
1106            ArgumentHandle::new(&table, &ctx),
1107            ArgumentHandle::new(&two, &ctx),
1108            ArgumentHandle::new(&false_lit, &ctx),
1109        ];
1110        let v = f
1111            .dispatch(&args, &ctx.function_context(None))
1112            .unwrap()
1113            .into_literal();
1114        assert_eq!(v, LiteralValue::Number(123.0));
1115    }
1116
1117    #[test]
1118    fn vlookup_blank_target_cell_returns_zero() {
1119        // Excel treats a direct reference to an empty cell as 0.
1120        // VLOOKUP should therefore return 0 (not Empty) when the found cell is empty.
1121        let wb = TestWorkbook::new()
1122            .with_function(Arc::new(VLookupFn))
1123            .with_cell_a1("Sheet1", "A1", LiteralValue::Int(1));
1124
1125        let ctx = wb.interpreter();
1126        let table = ASTNode::new(
1127            ASTNodeType::Reference {
1128                original: "A1:B1".into(),
1129                reference: ReferenceType::range(None, Some(1), Some(1), Some(1), Some(2)),
1130            },
1131            None,
1132        );
1133        let f = ctx.context.get_function("", "VLOOKUP").unwrap();
1134        let key1 = lit(LiteralValue::Int(1));
1135        let two = lit(LiteralValue::Int(2));
1136        let false_lit = lit(LiteralValue::Boolean(false));
1137        let args = vec![
1138            ArgumentHandle::new(&key1, &ctx),
1139            ArgumentHandle::new(&table, &ctx),
1140            ArgumentHandle::new(&two, &ctx),
1141            ArgumentHandle::new(&false_lit, &ctx),
1142        ];
1143        let v = f
1144            .dispatch(&args, &ctx.function_context(None))
1145            .unwrap()
1146            .into_literal();
1147        assert_eq!(v, LiteralValue::Number(0.0));
1148    }
1149
1150    #[test]
1151    fn hlookup_basic() {
1152        let wb = TestWorkbook::new()
1153            .with_function(Arc::new(HLookupFn))
1154            .with_cell_a1("Sheet1", "A1", LiteralValue::Text("Key1".into()))
1155            .with_cell_a1("Sheet1", "B1", LiteralValue::Text("Key2".into()))
1156            .with_cell_a1("Sheet1", "A2", LiteralValue::Int(100))
1157            .with_cell_a1("Sheet1", "B2", LiteralValue::Int(200));
1158        let ctx = wb.interpreter();
1159        let table = ASTNode::new(
1160            ASTNodeType::Reference {
1161                original: "A1:B2".into(),
1162                reference: ReferenceType::range(None, Some(1), Some(1), Some(2), Some(2)),
1163            },
1164            None,
1165        );
1166        let f = ctx.context.get_function("", "HLOOKUP").unwrap();
1167        let key1 = lit(LiteralValue::Text("Key1".into()));
1168        let two = lit(LiteralValue::Int(2));
1169        let false_lit = lit(LiteralValue::Boolean(false));
1170        let args = vec![
1171            ArgumentHandle::new(&key1, &ctx),
1172            ArgumentHandle::new(&table, &ctx),
1173            ArgumentHandle::new(&two, &ctx),
1174            ArgumentHandle::new(&false_lit, &ctx),
1175        ];
1176        let v = f
1177            .dispatch(&args, &ctx.function_context(None))
1178            .unwrap()
1179            .into_literal();
1180        assert_eq!(v, LiteralValue::Number(100.0));
1181    }
1182
1183    #[test]
1184    fn hlookup_blank_target_cell_returns_zero() {
1185        let wb = TestWorkbook::new()
1186            .with_function(Arc::new(HLookupFn))
1187            .with_cell_a1("Sheet1", "A1", LiteralValue::Int(1));
1188
1189        let ctx = wb.interpreter();
1190        let table = ASTNode::new(
1191            ASTNodeType::Reference {
1192                original: "A1:B2".into(),
1193                reference: ReferenceType::range(None, Some(1), Some(1), Some(2), Some(2)),
1194            },
1195            None,
1196        );
1197        let f = ctx.context.get_function("", "HLOOKUP").unwrap();
1198        let key1 = lit(LiteralValue::Int(1));
1199        let two = lit(LiteralValue::Int(2));
1200        let false_lit = lit(LiteralValue::Boolean(false));
1201        let args = vec![
1202            ArgumentHandle::new(&key1, &ctx),
1203            ArgumentHandle::new(&table, &ctx),
1204            ArgumentHandle::new(&two, &ctx),
1205            ArgumentHandle::new(&false_lit, &ctx),
1206        ];
1207        let v = f
1208            .dispatch(&args, &ctx.function_context(None))
1209            .unwrap()
1210            .into_literal();
1211        assert_eq!(v, LiteralValue::Number(0.0));
1212    }
1213}