//! Formula text conversion between the model (canonical text, no `=`) and files (`_xlfn.` //! prefixes for newer functions). use gridcraft_formula::Expr; use gridcraft_model::Formula; /// Formula text for a file. pub fn to_file(f: &Formula) -> String { match f.expr() { Some(e) => expr_to_file(e), None => f.text.clone(), } } /// Arbitrary formula text (names, CF, validation) for a file. Unparseable text is kept. pub fn text_to_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { Ok(e) => expr_to_file(e), Err(_) => body.to_string(), } } const PARAM_PREFIX: &str = "_xlpm."; /// Adds Excel's `_xlpm.` prefix to LET/LAMBDA parameter names, at the declaration and at every use in scope. fn prefix_params(e: Expr, scope: &[String]) -> Expr { let sub = |x: Box, sc: &[String]| Box::new(prefix_params(*x, sc)); match e { Expr::Name(n) if scope.iter().any(|s| s.eq_ignore_ascii_case(&n)) => Expr::Name(format!("{PARAM_PREFIX}{n}")), Expr::Unary(op, x) => Expr::Unary(op, sub(x, scope)), Expr::Paren(x) => Expr::Paren(sub(x, scope)), Expr::Binary(op, a, b) => Expr::Binary(op, sub(a, scope), sub(b, scope)), Expr::Invoke(c, args) => Expr::Invoke(sub(c, scope), args.into_iter().map(|a| prefix_params(a, scope)).collect()), Expr::Call(name, args) if name == "LET" || name == "LAMBDA" => { let last = args.len().saturating_sub(1); let is_let = name == "LET"; let mut sc = scope.to_vec(); let mut pending: Option = None; let mut out = Vec::with_capacity(args.len()); for (i, a) in args.into_iter().enumerate() { let declares = i < last && (!is_let || i % 2 == 0); match a { Expr::Name(n) if declares => { out.push(Expr::Name(format!("{PARAM_PREFIX}{n}"))); if is_let { pending = Some(n); } else { sc.push(n); } } other => { out.push(prefix_params(other, &sc)); // A LET name is in scope only after its value expression. if let Some(n) = pending.take() { sc.push(n); } } } } Expr::Call(name, out) } // A LET-bound LAMBDA called by name, e.g. `f(3)`. Expr::Call(name, args) if scope.iter().any(|s| s.eq_ignore_ascii_case(&name)) => { Expr::Call(format!("{PARAM_PREFIX}{name}"), args.into_iter().map(|a| prefix_params(a, scope)).collect()) } Expr::Call(name, args) => Expr::Call(name, args.into_iter().map(|a| prefix_params(a, scope)).collect()), other => other, } } fn expr_to_file(e: Expr) -> String { let e = prefix_params(e, &[]); let e = e.map(&mut |x| match x { Expr::Call(name, args) => match crate::tables::file_function_name(&name) { Some(n) => Expr::Call(n, args), None => Expr::Call(name, args), }, // Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`. Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), // Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`. Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]), other => other, }); gridcraft_formula::print(&e) } /// `_xlpm.x` → `x` (any case, as the parser upper-cases function names). fn strip_param_prefix(n: String) -> String { match (n.get(..PARAM_PREFIX.len()), n.get(PARAM_PREFIX.len()..)) { (Some(p), Some(rest)) if p.eq_ignore_ascii_case(PARAM_PREFIX) => rest.to_string(), _ => n, } } /// Formula text read from a file → model text (strips prefixes when it parses). pub fn from_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { Ok(e) => gridcraft_formula::print(&e.map(&mut |x| match x { Expr::Name(n) => Expr::Name(strip_param_prefix(n)), Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), other => other, })), Err(_) => body.to_string(), } } #[cfg(test)] mod tests { use super::*; #[test] fn spill_references() { assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"); assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)"); assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#"); } #[test] fn implicit_intersection() { assert_eq!(to_file(&Formula::new("SUM(@A1:A3*B1:B3)")), "SUM(_xlfn.SINGLE(A1:A3)*B1:B3)"); assert_eq!(to_file(&Formula::new("@A1:A3*2")), "_xlfn.SINGLE(A1:A3)*2"); assert_eq!(text_to_file("=@Sheet1!$A$1:$A$3"), "_xlfn.SINGLE(Sheet1!$A$1:$A$3)"); assert_eq!(from_file("_xlfn.SINGLE(A1:A3)*2"), "@A1:A3*2"); assert_eq!(from_file("SUM(_xlfn.SINGLE(A1:A3)*B1:B3)"), "SUM(@A1:A3*B1:B3)"); } #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); assert_eq!(to_file(&Formula::new("FILTER(A1:A3,B1:B3)")), "_xlfn._xlws.FILTER(A1:A3,B1:B3)"); assert_eq!(from_file("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); assert_eq!(text_to_file("=IFS(A1>0,1)"), "_xlfn.IFS(A1>0,1)"); assert_eq!(to_file(&Formula::new("REGEXTEST(A1,\"x\")&ENCODEURL(B1)")), "_xlfn.REGEXTEST(A1,\"x\")&_xlfn.ENCODEURL(B1)"); assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")"); } #[test] fn let_lambda_params() { assert_eq!(to_file(&Formula::new("LET(x,1,x+1)")), "_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"); assert_eq!(to_file(&Formula::new("LET(x,A1*2,y,x+1,x*y)")), "_xlfn.LET(_xlpm.x,A1*2,_xlpm.y,_xlpm.x+1,_xlpm.x*_xlpm.y)"); assert_eq!(to_file(&Formula::new("LAMBDA(a,b,a^2+b)(3,4)")), "_xlfn.LAMBDA(_xlpm.a,_xlpm.b,_xlpm.a^2+_xlpm.b)(3,4)"); // A name outside the scope stays a plain defined name. assert_eq!(to_file(&Formula::new("LET(x,1,x)+x")), "_xlfn.LET(_xlpm.x,1,_xlpm.x)+x"); assert_eq!(from_file("_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"), "LET(x,1,x+1)"); assert_eq!(from_file("_xlfn.LAMBDA(_xlpm.a,_xlpm.a*2)(3)"), "LAMBDA(a,a*2)(3)"); // A LET-bound LAMBDA called by name. assert_eq!(to_file(&Formula::new("LET(f,LAMBDA(x,x*2),f(3))")), "_xlfn.LET(_xlpm.f,_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2),_xlpm.F(3))"); assert_eq!(from_file("_xlfn.LET(_xlpm.f,_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2),_xlpm.f(3))"), "LET(f,LAMBDA(x,x*2),F(3))"); } }