Skip to main content

formualizer_eval/builtins/lookup/
stack.rs

1//! Stack / concatenation dynamic array functions: HSTACK, VSTACK
2//!
3//! Excel semantics (baseline subset):
4//! - Each function accepts 1..N arrays/ranges; scalars treated as 1x1.
5//! - HSTACK: concatenate arrays horizontally (columns) aligning rows; differing row counts -> #VALUE!.
6//! - VSTACK: concatenate arrays vertically (rows) aligning columns; differing column counts -> #VALUE!.
7//! - Empty arguments (zero-sized ranges) are skipped; if all skipped -> empty spill.
8//! - Result collapses to scalar if 1x1 after stacking (consistent with existing dynamic functions here).
9//!
10//! TODO(excel-nuance): Propagate first error cell wise; currently a whole argument that is an Error scalar becomes a 1x1 error block.
11//! TODO(perf): Avoid intermediate full materialization by streaming row-wise/col-wise (later optimization).
12
13use super::super::utils::collapse_if_scalar;
14use crate::args::{ArgSchema, CoercionPolicy, ShapeKind};
15use crate::function::Function;
16use crate::traits::{ArgumentHandle, FunctionContext};
17use formualizer_common::{ArgKind, ExcelError, ExcelErrorKind, LiteralValue};
18use formualizer_macros::func_caps;
19
20#[derive(Debug)]
21pub struct HStackFn;
22#[derive(Debug)]
23pub struct VStackFn;
24
25fn materialize_arg<'b>(
26    arg: &ArgumentHandle<'_, 'b>,
27    ctx: &dyn FunctionContext<'b>,
28) -> Result<Vec<Vec<LiteralValue>>, ExcelError> {
29    // Similar helper to dynamic.rs (avoid cyclic import). Minimal duplication; consider refactor later.
30    if let Ok(r) = arg.as_reference_or_eval() {
31        let mut rows: Vec<Vec<LiteralValue>> = Vec::new();
32        let sheet = ctx.current_sheet();
33        let rv = ctx.resolve_range_view(&r, sheet)?;
34        rv.for_each_row(&mut |row| {
35            rows.push(row.to_vec());
36            Ok(())
37        })?;
38        Ok(rows)
39    } else {
40        let cv = arg.value()?;
41        match cv.into_literal() {
42            LiteralValue::Array(a) => Ok(a),
43            v => Ok(vec![vec![v]]),
44        }
45    }
46}
47
48/// Concatenates arrays horizontally into a single spilled array.
49///
50/// `HSTACK` appends columns from each argument left-to-right.
51///
52/// # Remarks
53/// - All non-empty range arguments must have the same row count.
54/// - Scalar arguments are treated as 1x1 values.
55/// - Mismatched row counts return `#VALUE!`.
56/// - Empty inputs are skipped; if all inputs are empty, result is an empty spill.
57///
58/// # Examples
59/// ```yaml,sandbox
60/// title: "Join two 2-row ranges side by side"
61/// grid:
62///   A1: 1
63///   A2: 2
64///   B1: 10
65///   B2: 20
66/// formula: '=HSTACK(A1:A2,B1:B2)'
67/// expected: [[1,10],[2,20]]
68/// ```
69///
70/// ```yaml,sandbox
71/// title: "Append a scalar column"
72/// grid:
73///   A1: "Item"
74/// formula: '=HSTACK(A1,"OK")'
75/// expected: [["Item","OK"]]
76/// ```
77///
78/// ```yaml,docs
79/// related:
80///   - VSTACK
81///   - CHOOSECOLS
82///   - TAKE
83/// faq:
84///   - q: "Why does HSTACK return #VALUE! when combining ranges?"
85///     a: "All non-empty inputs must have identical row counts; mismatched heights produce #VALUE!."
86///   - q: "How are scalar arguments treated in HSTACK?"
87///     a: "Each scalar is treated as a 1x1 block, so it only aligns with other arguments when the target row count is 1."
88/// ```
89/// [formualizer-docgen:schema:start]
90/// Name: HSTACK
91/// Type: HStackFn
92/// Min args: 1
93/// Max args: variadic
94/// Variadic: true
95/// Signature: HSTACK(arg1...: range|any@range)
96/// Arg schema: arg1{kinds=range|any,required=true,shape=range,by_ref=false,coercion=None,max=None,repeating=Some(1),default=false}
97/// Caps: PURE
98/// [formualizer-docgen:schema:end]
99impl Function for HStackFn {
100    func_caps!(PURE);
101    fn name(&self) -> &'static str {
102        "HSTACK"
103    }
104    fn min_args(&self) -> usize {
105        1
106    }
107    fn variadic(&self) -> bool {
108        true
109    }
110    fn arg_schema(&self) -> &'static [ArgSchema] {
111        use once_cell::sync::Lazy;
112        static SCHEMA: Lazy<Vec<ArgSchema>> = Lazy::new(|| {
113            vec![ArgSchema {
114                kinds: smallvec::smallvec![ArgKind::Range, ArgKind::Any],
115                required: true,
116                by_ref: false,
117                shape: ShapeKind::Range,
118                coercion: CoercionPolicy::None,
119                max: None,
120                repeating: Some(1),
121                default: None,
122            }]
123        });
124        &SCHEMA
125    }
126    fn eval<'a, 'b, 'c>(
127        &self,
128        args: &'c [ArgumentHandle<'a, 'b>],
129        ctx: &dyn FunctionContext<'b>,
130    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
131        if args.is_empty() {
132            return Ok(crate::traits::CalcValue::Range(
133                crate::engine::range_view::RangeView::from_owned_rows(vec![], ctx.date_system()),
134            ));
135        }
136
137        let mut entries = Vec::with_capacity(args.len());
138        let mut target_rows: Option<usize> = None;
139        let mut total_cols = 0;
140
141        for a in args {
142            if let Ok(v) = a.range_view() {
143                let (rows, cols) = v.dims();
144                if rows == 0 || cols == 0 {
145                    continue;
146                }
147                if let Some(tr) = target_rows {
148                    if rows != tr {
149                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
150                            ExcelError::new(ExcelErrorKind::Value),
151                        )));
152                    }
153                } else {
154                    target_rows = Some(rows);
155                }
156                total_cols += cols;
157                entries.push(HStackEntry::View(v));
158            } else {
159                let v = a.value()?.into_literal();
160                if let Some(tr) = target_rows {
161                    if tr != 1 {
162                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
163                            ExcelError::new(ExcelErrorKind::Value),
164                        )));
165                    }
166                } else {
167                    target_rows = Some(1);
168                }
169                total_cols += 1;
170                entries.push(HStackEntry::Scalar(v));
171            }
172        }
173
174        if entries.is_empty() {
175            return Ok(crate::traits::CalcValue::Range(
176                crate::engine::range_view::RangeView::from_owned_rows(vec![], ctx.date_system()),
177            ));
178        }
179
180        let row_count = target_rows.unwrap();
181        let mut result: Vec<Vec<LiteralValue>> = Vec::with_capacity(row_count);
182        for _ in 0..row_count {
183            result.push(Vec::with_capacity(total_cols));
184        }
185
186        for entry in entries {
187            match entry {
188                HStackEntry::View(v) => {
189                    let (v_rows, v_cols) = v.dims();
190                    for (r, row) in result.iter_mut().enumerate().take(v_rows) {
191                        for c in 0..v_cols {
192                            row.push(v.get_cell(r, c));
193                        }
194                    }
195                }
196                HStackEntry::Scalar(s) => {
197                    result[0].push(s);
198                }
199            }
200        }
201
202        Ok(collapse_if_scalar(result, ctx.date_system()))
203    }
204}
205
206enum HStackEntry<'a> {
207    View(crate::engine::range_view::RangeView<'a>),
208    Scalar(LiteralValue),
209}
210
211/// Concatenates arrays vertically into a single spilled array.
212///
213/// `VSTACK` appends rows from each argument top-to-bottom.
214///
215/// # Remarks
216/// - All non-empty range arguments must have the same column count.
217/// - Scalar arguments are treated as 1x1 values.
218/// - Mismatched column counts return `#VALUE!`.
219/// - Empty inputs are skipped; if all inputs are empty, result is an empty spill.
220///
221/// # Examples
222/// ```yaml,sandbox
223/// title: "Join two 1-row ranges vertically"
224/// grid:
225///   A1: 1
226///   B1: 10
227///   A2: 2
228///   B2: 20
229/// formula: '=VSTACK(A1:B1,A2:B2)'
230/// expected: [[1,10],[2,20]]
231/// ```
232///
233/// ```yaml,sandbox
234/// title: "Stack scalar values"
235/// formula: '=VSTACK(5,9)'
236/// expected: [[5],[9]]
237/// ```
238///
239/// ```yaml,docs
240/// related:
241///   - HSTACK
242///   - CHOOSEROWS
243///   - DROP
244/// faq:
245///   - q: "When does VSTACK return #VALUE!?"
246///     a: "VSTACK requires matching column counts across non-empty range arguments; differing widths return #VALUE!."
247///   - q: "What happens if all VSTACK inputs are empty ranges?"
248///     a: "Empty inputs are skipped, and if every argument is empty the function returns an empty spill."
249/// ```
250/// [formualizer-docgen:schema:start]
251/// Name: VSTACK
252/// Type: VStackFn
253/// Min args: 1
254/// Max args: variadic
255/// Variadic: true
256/// Signature: VSTACK(arg1...: range|any@range)
257/// Arg schema: arg1{kinds=range|any,required=true,shape=range,by_ref=false,coercion=None,max=None,repeating=Some(1),default=false}
258/// Caps: PURE
259/// [formualizer-docgen:schema:end]
260impl Function for VStackFn {
261    func_caps!(PURE);
262    fn name(&self) -> &'static str {
263        "VSTACK"
264    }
265    fn min_args(&self) -> usize {
266        1
267    }
268    fn variadic(&self) -> bool {
269        true
270    }
271    fn arg_schema(&self) -> &'static [ArgSchema] {
272        use once_cell::sync::Lazy;
273        static SCHEMA: Lazy<Vec<ArgSchema>> = Lazy::new(|| {
274            vec![ArgSchema {
275                kinds: smallvec::smallvec![ArgKind::Range, ArgKind::Any],
276                required: true,
277                by_ref: false,
278                shape: ShapeKind::Range,
279                coercion: CoercionPolicy::None,
280                max: None,
281                repeating: Some(1),
282                default: None,
283            }]
284        });
285        &SCHEMA
286    }
287    fn eval<'a, 'b, 'c>(
288        &self,
289        args: &'c [ArgumentHandle<'a, 'b>],
290        ctx: &dyn FunctionContext<'b>,
291    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
292        if args.is_empty() {
293            return Ok(crate::traits::CalcValue::Range(
294                crate::engine::range_view::RangeView::from_owned_rows(vec![], ctx.date_system()),
295            ));
296        }
297
298        let mut target_width: Option<usize> = None;
299        let mut total_rows = 0;
300        let mut entries = Vec::with_capacity(args.len());
301
302        for a in args {
303            if let Ok(v) = a.range_view() {
304                let (rows, cols) = v.dims();
305                if rows == 0 || cols == 0 {
306                    continue;
307                }
308                if let Some(tw) = target_width {
309                    if cols != tw {
310                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
311                            ExcelError::new(ExcelErrorKind::Value),
312                        )));
313                    }
314                } else {
315                    target_width = Some(cols);
316                }
317                total_rows += rows;
318                entries.push(VStackEntry::View(v));
319            } else {
320                let v = a.value()?.into_literal();
321                if let Some(tw) = target_width {
322                    if tw != 1 {
323                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
324                            ExcelError::new(ExcelErrorKind::Value),
325                        )));
326                    }
327                } else {
328                    target_width = Some(1);
329                }
330                total_rows += 1;
331                entries.push(VStackEntry::Scalar(v));
332            }
333        }
334
335        if entries.is_empty() {
336            return Ok(crate::traits::CalcValue::Range(
337                crate::engine::range_view::RangeView::from_owned_rows(vec![], ctx.date_system()),
338            ));
339        }
340
341        let mut result: Vec<Vec<LiteralValue>> = Vec::with_capacity(total_rows);
342        for entry in entries {
343            match entry {
344                VStackEntry::View(v) => {
345                    let _ = v.for_each_row(&mut |row| {
346                        result.push(row.to_vec());
347                        Ok(())
348                    });
349                }
350                VStackEntry::Scalar(s) => {
351                    result.push(vec![s]);
352                }
353            }
354        }
355
356        Ok(collapse_if_scalar(result, ctx.date_system()))
357    }
358}
359
360enum VStackEntry<'a> {
361    View(crate::engine::range_view::RangeView<'a>),
362    Scalar(LiteralValue),
363}
364
365pub fn register_builtins() {
366    use crate::function_registry::register_function;
367    use std::sync::Arc;
368    register_function(Arc::new(HStackFn));
369    register_function(Arc::new(VStackFn));
370}
371
372/* ───────────────────────── tests ───────────────────────── */
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::test_workbook::TestWorkbook;
377    use crate::traits::ArgumentHandle;
378    use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
379    use std::sync::Arc;
380
381    fn ref_range(r: &str, sr: i32, sc: i32, er: i32, ec: i32) -> ASTNode {
382        ASTNode::new(
383            ASTNodeType::Reference {
384                original: r.into(),
385                reference: ReferenceType::range(
386                    None,
387                    Some(sr as u32),
388                    Some(sc as u32),
389                    Some(er as u32),
390                    Some(ec as u32),
391                ),
392            },
393            None,
394        )
395    }
396
397    fn lit(v: LiteralValue) -> ASTNode {
398        ASTNode::new(ASTNodeType::Literal(v), None)
399    }
400
401    #[test]
402    fn hstack_basic_and_mismatched_rows() {
403        let wb = TestWorkbook::new().with_function(Arc::new(HStackFn));
404        let wb = wb
405            .with_cell_a1("Sheet1", "A1", LiteralValue::Int(1))
406            .with_cell_a1("Sheet1", "A2", LiteralValue::Int(2))
407            .with_cell_a1("Sheet1", "B1", LiteralValue::Int(10))
408            .with_cell_a1("Sheet1", "B2", LiteralValue::Int(20))
409            .with_cell_a1("Sheet1", "C1", LiteralValue::Int(100)); // single row range for mismatch
410        let ctx = wb.interpreter();
411        let left = ref_range("A1:A2", 1, 1, 2, 1);
412        let right = ref_range("B1:B2", 1, 2, 2, 2);
413        let f = ctx.context.get_function("", "HSTACK").unwrap();
414        let args = vec![
415            ArgumentHandle::new(&left, &ctx),
416            ArgumentHandle::new(&right, &ctx),
417        ];
418        let v = f
419            .dispatch(&args, &ctx.function_context(None))
420            .unwrap()
421            .into_literal();
422        match v {
423            LiteralValue::Array(a) => {
424                assert_eq!(a.len(), 2);
425                assert_eq!(
426                    a[0],
427                    vec![LiteralValue::Number(1.0), LiteralValue::Number(10.0)]
428                );
429            }
430            other => panic!("expected array got {other:?}"),
431        }
432        // mismatch rows
433        let mism = ref_range("C1:C1", 1, 3, 1, 3);
434        let args_bad = vec![
435            ArgumentHandle::new(&left, &ctx),
436            ArgumentHandle::new(&mism, &ctx),
437        ];
438        let v_bad = f
439            .dispatch(&args_bad, &ctx.function_context(None))
440            .unwrap()
441            .into_literal();
442        match v_bad {
443            LiteralValue::Error(e) => assert_eq!(e.kind, ExcelErrorKind::Value),
444            other => panic!("expected #VALUE! got {other:?}"),
445        }
446    }
447
448    #[test]
449    fn vstack_basic_and_mismatched_cols() {
450        let wb = TestWorkbook::new().with_function(Arc::new(VStackFn));
451        let wb = wb
452            .with_cell_a1("Sheet1", "A1", LiteralValue::Int(1))
453            .with_cell_a1("Sheet1", "B1", LiteralValue::Int(10))
454            .with_cell_a1("Sheet1", "A2", LiteralValue::Int(2))
455            .with_cell_a1("Sheet1", "B2", LiteralValue::Int(20))
456            .with_cell_a1("Sheet1", "C1", LiteralValue::Int(100))
457            .with_cell_a1("Sheet1", "C2", LiteralValue::Int(200));
458        let ctx = wb.interpreter();
459        let top = ref_range("A1:B1", 1, 1, 1, 2);
460        let bottom = ref_range("A2:B2", 2, 1, 2, 2);
461        let f = ctx.context.get_function("", "VSTACK").unwrap();
462        let args = vec![
463            ArgumentHandle::new(&top, &ctx),
464            ArgumentHandle::new(&bottom, &ctx),
465        ];
466        let v = f
467            .dispatch(&args, &ctx.function_context(None))
468            .unwrap()
469            .into_literal();
470        match v {
471            LiteralValue::Array(a) => {
472                assert_eq!(a.len(), 2);
473                assert_eq!(
474                    a[0],
475                    vec![LiteralValue::Number(1.0), LiteralValue::Number(10.0)]
476                );
477            }
478            other => panic!("expected array got {other:?}"),
479        }
480        // mismatched width (add 3rd column row)
481        let extra = ref_range("A1:C1", 1, 1, 1, 3);
482        let args_bad = vec![
483            ArgumentHandle::new(&top, &ctx),
484            ArgumentHandle::new(&extra, &ctx),
485        ];
486        let v_bad = f
487            .dispatch(&args_bad, &ctx.function_context(None))
488            .unwrap()
489            .into_literal();
490        match v_bad {
491            LiteralValue::Error(e) => assert_eq!(e.kind, ExcelErrorKind::Value),
492            other => panic!("expected #VALUE! got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn hstack_scalar_and_array_collapse() {
498        let wb = TestWorkbook::new().with_function(Arc::new(HStackFn));
499        let ctx = wb.interpreter();
500        let f = ctx.context.get_function("", "HSTACK").unwrap();
501        let s1 = lit(LiteralValue::Int(5));
502        let s2 = lit(LiteralValue::Int(6));
503        let args = vec![
504            ArgumentHandle::new(&s1, &ctx),
505            ArgumentHandle::new(&s2, &ctx),
506        ];
507        let v = f
508            .dispatch(&args, &ctx.function_context(None))
509            .unwrap()
510            .into_literal();
511        // 1 row x 2 cols stays as array (not scalar collapse)
512        match v {
513            LiteralValue::Array(a) => {
514                assert_eq!(a.len(), 1);
515                assert_eq!(
516                    a[0],
517                    vec![LiteralValue::Number(5.0), LiteralValue::Number(6.0)]
518                );
519            }
520            other => panic!("expected array got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn vstack_scalar_collapse_single_result() {
526        let wb = TestWorkbook::new().with_function(Arc::new(VStackFn));
527        let ctx = wb.interpreter();
528        let f = ctx.context.get_function("", "VSTACK").unwrap();
529        let lone = lit(LiteralValue::Int(9));
530        let args = vec![ArgumentHandle::new(&lone, &ctx)];
531        let v = f
532            .dispatch(&args, &ctx.function_context(None))
533            .unwrap()
534            .into_literal();
535        assert_eq!(v, LiteralValue::Int(9));
536    }
537}