From 4953ddd51d1da5cf025966a927c42f4aaf877e89 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 25 Jan 2026 00:51:23 +0100 Subject: [PATCH 01/25] update: add 3 more border styles --- .../components/BorderPicker/BorderPicker.tsx | 110 +++++++++--- .../WorksheetCanvas/worksheetCanvas.ts | 165 ++++++++++++------ 2 files changed, 196 insertions(+), 79 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 2ca57b446..3637d809a 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -1,4 +1,5 @@ import { type BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm"; +import MenuItem from "@mui/material/MenuItem"; import Popover, { type PopoverOrigin } from "@mui/material/Popover"; import { styled } from "@mui/material/styles"; import { @@ -294,36 +295,60 @@ const BorderPicker = (properties: BorderPickerProps) => { }} > - { setBorderStyle(BorderStyle.Thin); setStylePickerOpen(false); }} - $checked={borderStyle === BorderStyle.Thin} + selected={borderStyle === BorderStyle.Thin} > - Thin - - + { setBorderStyle(BorderStyle.Medium); setStylePickerOpen(false); }} - $checked={borderStyle === BorderStyle.Medium} + selected={borderStyle === BorderStyle.Medium} > - Medium - - + { setBorderStyle(BorderStyle.Thick); setStylePickerOpen(false); }} - $checked={borderStyle === BorderStyle.Thick} + selected={borderStyle === BorderStyle.Thick} > - Thick - + + { + setBorderStyle(BorderStyle.Double); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Double} + > + + + { + setBorderStyle(BorderStyle.Dotted); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Dotted} + > + + + { + setBorderStyle(BorderStyle.MediumDashed); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashed} + > + + @@ -331,24 +356,23 @@ const BorderPicker = (properties: BorderPickerProps) => { ); }; -type LineWrapperProperties = { $checked: boolean }; -const LineWrapper = styled("div")` +const StyledMenuItem = styled(MenuItem)` display: flex; flex-direction: row; align-items: center; - background-color: ${({ $checked }): string => { - if ($checked) { - return theme.palette.grey["200"]; - } - return "inherit;"; - }}; - &:hover { - border: 1px solid ${theme.palette.grey["200"]}; - } + justify-content: center; + height: 32px; padding: 8px; - cursor: pointer; border-radius: 4px; - border: 1px solid white; + &::before { + content: none; + } + &.Mui-selected { + background-color: ${({ theme }) => theme.palette.action.hover}; + &:hover { + background-color: ${({ theme }) => theme.palette.action.hover}; + } + } `; const SolidLine = styled("div")` @@ -364,6 +388,38 @@ const ThickLine = styled("div")` border-top: 3px solid ${theme.palette.grey["900"]}; `; +const DoubleLine = styled("div")` + width: 68px; + height: 3px; + position: relative; + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + border-top: 1px solid ${theme.palette.grey["900"]}; + } + &::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-top: 1px solid ${theme.palette.grey["900"]}; + } +`; + +const DottedLine = styled("div")` + width: 68px; + border-top: 1px dotted ${theme.palette.grey["900"]}; +`; + +const MediumDashedLine = styled("div")` + width: 68px; + border-top: 2px dashed ${theme.palette.grey["900"]}; +`; + const Divider = styled("div")` width: 100%; margin: auto; @@ -438,10 +494,6 @@ const BorderPickerDialog = styled("div")` flex-direction: column; `; -const BorderDescription = styled("div")` - width: 70px; -`; - type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string }; const Button = styled("button")( ({ disabled, $pressed, $underlinedColor }) => { diff --git a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts index fe4c6f775..2864473c1 100644 --- a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -709,6 +709,95 @@ export default class WorksheetCanvas { this.cells.push(textProperties); } + /// Helper function to draw a border with different styles + private drawBorder( + context: CanvasRenderingContext2D, + style: string, + color: string, + x1: number, + y1: number, + x2: number, + y2: number, + isVertical: boolean, + ): void { + context.save(); + context.strokeStyle = color; + + switch (style) { + case "thin": + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + break; + case "medium": + context.lineWidth = 2; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + break; + case "thick": + context.lineWidth = 3; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + break; + case "double": + // Draw two parallel lines + if (isVertical) { + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x1 - 1, y1); + context.lineTo(x1 - 1, y2); + context.stroke(); + context.beginPath(); + context.moveTo(x1 + 1, y1); + context.lineTo(x1 + 1, y2); + context.stroke(); + } else { + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x1, y1 - 1); + context.lineTo(x2, y1 - 1); + context.stroke(); + context.beginPath(); + context.moveTo(x1, y1 + 1); + context.lineTo(x2, y1 + 1); + context.stroke(); + } + break; + case "dotted": + context.lineWidth = 1; + context.setLineDash([1, 2]); + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + context.setLineDash([]); + break; + case "mediumdashed": + context.lineWidth = 2; + context.setLineDash([4, 2]); + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + context.setLineDash([]); + break; + default: + // Fallback to thin for unknown styles + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + } + context.restore(); + } + /// Renders the cell style: colors, borders, etc. But not the text. private renderCellStyle( row: number, @@ -746,18 +835,10 @@ export default class WorksheetCanvas { // we skip don't draw a left border if it is marked as a "spill cell" if (this.spills.get(`${row}-${column}`) !== 1) { let borderLeftColor = cellGridColor; - let borderLeftWidth = 1; + let borderLeftStyle = "thin"; if (border.left) { borderLeftColor = border.left.color; - switch (border.left.style) { - case "thin": - break; - case "medium": - borderLeftWidth = 2; - break; - case "thick": - borderLeftWidth = 3; - } + borderLeftStyle = border.left.style; } else { const leftStyle = this.model.getCellStyle( selectedSheet, @@ -766,15 +847,7 @@ export default class WorksheetCanvas { ); if (leftStyle.border.right) { borderLeftColor = leftStyle.border.right.color; - switch (leftStyle.border.right.style) { - case "thin": - break; - case "medium": - borderLeftWidth = 2; - break; - case "thick": - borderLeftWidth = 3; - } + borderLeftStyle = leftStyle.border.right.style; } else if (style.fill.fg_color) { borderLeftColor = style.fill.fg_color; } else if (leftStyle.fill.fg_color) { @@ -782,52 +855,44 @@ export default class WorksheetCanvas { } } - context.beginPath(); - context.strokeStyle = borderLeftColor; - context.lineWidth = borderLeftWidth; - context.moveTo(x, y); - context.lineTo(x, y + height); - context.stroke(); + this.drawBorder( + context, + borderLeftStyle, + borderLeftColor, + x, + y, + x, + y + height, + true, + ); } let borderTopColor = cellGridColor; - let borderTopWidth = 1; + let borderTopStyle = "thin"; if (border.top) { borderTopColor = border.top.color; - switch (border.top.style) { - case "thin": - break; - case "medium": - borderTopWidth = 2; - break; - case "thick": - borderTopWidth = 3; - } + borderTopStyle = border.top.style; } else { const topStyle = this.model.getCellStyle(selectedSheet, row - 1, column); if (topStyle.border.bottom) { borderTopColor = topStyle.border.bottom.color; - switch (topStyle.border.bottom.style) { - case "thin": - break; - case "medium": - borderTopWidth = 2; - break; - case "thick": - borderTopWidth = 3; - } + borderTopStyle = topStyle.border.bottom.style; } else if (style.fill.fg_color) { borderTopColor = style.fill.fg_color; } else if (topStyle.fill.fg_color) { borderTopColor = topStyle.fill.fg_color; } } - context.beginPath(); - context.strokeStyle = borderTopColor; - context.lineWidth = borderTopWidth; - context.moveTo(x, y); - context.lineTo(x + width, y); - context.stroke(); + this.drawBorder( + context, + borderTopStyle, + borderTopColor, + x, + y, + x + width, + y, + false, + ); } /// Renders the text in the cell. From 27367b11b7cfa4ad95c660a5f716cfe7c49ff2d5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 25 Jan 2026 02:06:11 +0100 Subject: [PATCH 02/25] update: use a popper instead of a popover --- .../components/BorderPicker/BorderPicker.tsx | 638 +++++++++--------- .../src/components/Toolbar/Toolbar.tsx | 3 +- 2 files changed, 335 insertions(+), 306 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 3637d809a..cb1f8d9d6 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -1,6 +1,7 @@ import { type BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm"; +import ClickAwayListener from "@mui/material/ClickAwayListener"; import MenuItem from "@mui/material/MenuItem"; -import Popover, { type PopoverOrigin } from "@mui/material/Popover"; +import Popper, { type PopperPlacementType } from "@mui/material/Popper"; import { styled } from "@mui/material/styles"; import { Grid2X2 as BorderAllIcon, @@ -30,8 +31,7 @@ type BorderPickerProps = { onChange: (border: BorderOptions) => void; onClose: () => void; anchorEl: React.RefObject; - anchorOrigin?: PopoverOrigin; - transformOrigin?: PopoverOrigin; + placement: PopperPlacementType; open: boolean; }; @@ -69,290 +69,308 @@ const BorderPicker = (properties: BorderPickerProps) => { const borderColorButton = useRef(null); const borderStyleButton = useRef(null); + + if (!properties.anchorEl.current) { + return null; + } + return ( - -
- - - - - - - - - - - - - - + + + + + + + + + + + + + + + + setColorPickerOpen(true)} + ref={borderColorButton} > - - - - - - - - setColorPickerOpen(true)} - ref={borderColorButton} - > - - -
Border color
- -
+ + Border color + +
- setStylePickerOpen(true)} - ref={borderStyleButton} - > - -
Border style
- -
-
-
- { - setBorderColor(color); - setColorPickerOpen(false); - }} - onClose={() => { - setColorPickerOpen(false); - }} - anchorEl={borderColorButton} - open={colorPickerOpen} - anchorOrigin={{ - vertical: "top", // Keep vertical alignment at the top - horizontal: "right", // Set horizontal alignment to right - }} - transformOrigin={{ - vertical: "top", // Keep vertical alignment at the top - horizontal: "left", // Set horizontal alignment to left - }} - /> - { - setStylePickerOpen(false); - }} - anchorEl={borderStyleButton.current} - anchorOrigin={{ - vertical: "top", - horizontal: "right", - }} - > - - { - setBorderStyle(BorderStyle.Thin); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Thin} - > - - - { - setBorderStyle(BorderStyle.Medium); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Medium} - > - - - { - setBorderStyle(BorderStyle.Thick); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Thick} - > - - - { - setBorderStyle(BorderStyle.Double); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Double} - > - - - { - setBorderStyle(BorderStyle.Dotted); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Dotted} - > - - - { - setBorderStyle(BorderStyle.MediumDashed); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.MediumDashed} + setStylePickerOpen(true)} + onMouseLeave={() => setStylePickerOpen(false)} + ref={borderStyleButton} + > + + Border style + + + + + { + setBorderColor(color); + setColorPickerOpen(false); + }} + onClose={() => { + setColorPickerOpen(false); + }} + anchorEl={borderColorButton} + open={colorPickerOpen} + anchorOrigin={{ + vertical: "top", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + /> + {borderStyleButton.current && ( + - - - - -
-
+ setStylePickerOpen(true)} + onMouseLeave={() => setStylePickerOpen(false)} + > + { + setBorderStyle(BorderStyle.Thin); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Thin} + > + + + { + setBorderStyle(BorderStyle.Medium); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Medium} + > + + + { + setBorderStyle(BorderStyle.Thick); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Thick} + > + + + { + setBorderStyle(BorderStyle.Double); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Double} + > + + + { + setBorderStyle(BorderStyle.Dotted); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Dotted} + > + + + { + setBorderStyle(BorderStyle.MediumDashed); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashed} + > + + + + + )} + + + ); }; @@ -446,46 +464,56 @@ const Line = styled("div")` gap: 4px; `; -const ButtonWrapper = styled("div")` +const BaseMenuItem = (props: React.ComponentProps) => ( + +); + +const MenuItemWrapper = styled(BaseMenuItem)` display: flex; - flex-direction: row; - align-items: center; + justify-content: flex-start; border-radius: 4px; - gap: 8px; - &:hover { - background-color: ${theme.palette.grey["200"]}; - border-top-color: ${(): string => theme.palette.grey["200"]}; - } - cursor: pointer; padding: 8px; + height: 32px; + min-height: 32px; + max-height: 32px; + color: ${theme.palette.common.black}; + font-size: 12px; + gap: 8px; svg { - width: 16px; - height: 16px; + max-width: 16px; + min-width: 16px; + max-height: 16px; + min-height: 16px; + color: ${theme.palette.grey[600]}; } `; -const BorderStyleDialog = styled("div")` +const MenuItemText = styled("div")` + flex-grow: 1; +`; + +const PopperContent = styled("div")` + border-radius: 8px; + border: 0px solid ${({ theme }): string => theme.palette.background.default}; + box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5); background: ${({ theme }): string => theme.palette.background.default}; + font-family: ${({ theme }) => theme.typography.fontFamily}; + font-size: 12px; + overflow: hidden; +`; + +const StylePicker = styled(PopperContent)` padding: 4px; display: flex; flex-direction: column; align-items: center; `; -const StyledPopover = styled(Popover)` - .MuiPopover-paper { - border-radius: 8px; - border: 0px solid ${({ theme }): string => theme.palette.background.default}; - box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5); - } - .MuiPopover-padding { - padding: 0px; - } - .MuiList-padding { - padding: 0px; +const StyledPopper = styled(Popper)` + z-index: 1300; + &[data-popper-placement] { + pointer-events: auto; } - font-family: ${({ theme }) => theme.typography.fontFamily}; - font-size: 12px; `; const BorderPickerDialog = styled("div")` diff --git a/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx b/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx index 2c708b2ce..491dffc60 100644 --- a/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx +++ b/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx @@ -415,7 +415,7 @@ function Toolbar(properties: ToolbarProperties) { setBorderPickerOpen(true)} ref={borderButton} disabled={!canEdit} @@ -582,6 +582,7 @@ function Toolbar(properties: ToolbarProperties) { transformOrigin={{ vertical: "top", horizontal: "left" }} /> { properties.onBorderChanged(border); }} From 009b06e41f38a9a513d5e7421b76d1ec48162033 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 25 Jan 2026 23:58:05 +0100 Subject: [PATCH 03/25] update: add a timeout in nested menu --- .../components/BorderPicker/BorderPicker.tsx | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index cb1f8d9d6..50571f66b 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -69,6 +69,32 @@ const BorderPicker = (properties: BorderPickerProps) => { const borderColorButton = useRef(null); const borderStyleButton = useRef(null); + const stylePickerCloseTimeout = useRef(null); + + const handleStylePickerOpen = () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + setStylePickerOpen(true); + }; + + const handleStylePickerClose = () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + stylePickerCloseTimeout.current = setTimeout(() => { + setStylePickerOpen(false); + }, 150); + }; + + useEffect( + () => () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + }, + [], + ); if (!properties.anchorEl.current) { return null; @@ -260,8 +286,8 @@ const BorderPicker = (properties: BorderPickerProps) => { setStylePickerOpen(true)} - onMouseLeave={() => setStylePickerOpen(false)} + onMouseEnter={handleStylePickerOpen} + onMouseLeave={handleStylePickerClose} ref={borderStyleButton} > @@ -308,8 +334,8 @@ const BorderPicker = (properties: BorderPickerProps) => { ]} > setStylePickerOpen(true)} - onMouseLeave={() => setStylePickerOpen(false)} + onMouseEnter={handleStylePickerOpen} + onMouseLeave={handleStylePickerClose} > { From 6b1e9b734d36c6e218a486c2066658cf1bf5a423 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 30 Jan 2026 19:07:25 +0100 Subject: [PATCH 04/25] chore: remove duplications in worksheetcanvas --- .../WorksheetCanvas/worksheetCanvas.ts | 85 +++++++++---------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts index 2864473c1..02ebd12db 100644 --- a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -709,6 +709,20 @@ export default class WorksheetCanvas { this.cells.push(textProperties); } + /// Draws a single line from (x1,y1) to (x2,y2) using current stroke style/width. + private drawBorderLine( + context: CanvasRenderingContext2D, + x1: number, + y1: number, + x2: number, + y2: number, + ): void { + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + } + /// Helper function to draw a border with different styles private drawBorder( context: CanvasRenderingContext2D, @@ -726,74 +740,57 @@ export default class WorksheetCanvas { switch (style) { case "thin": context.lineWidth = 1; - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); break; case "medium": context.lineWidth = 2; - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); break; case "thick": context.lineWidth = 3; - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); break; case "double": // Draw two parallel lines + context.lineWidth = 1; if (isVertical) { - context.lineWidth = 1; - context.beginPath(); - context.moveTo(x1 - 1, y1); - context.lineTo(x1 - 1, y2); - context.stroke(); - context.beginPath(); - context.moveTo(x1 + 1, y1); - context.lineTo(x1 + 1, y2); - context.stroke(); + this.drawBorderLine(context, x1 - 1, y1, x1 - 1, y2); + this.drawBorderLine(context, x1 + 1, y1, x1 + 1, y2); } else { - context.lineWidth = 1; - context.beginPath(); - context.moveTo(x1, y1 - 1); - context.lineTo(x2, y1 - 1); - context.stroke(); - context.beginPath(); - context.moveTo(x1, y1 + 1); - context.lineTo(x2, y1 + 1); - context.stroke(); + this.drawBorderLine(context, x1, y1 - 1, x2, y1 - 1); + this.drawBorderLine(context, x1, y1 + 1, x2, y1 + 1); } break; case "dotted": context.lineWidth = 1; context.setLineDash([1, 2]); - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); context.setLineDash([]); break; case "mediumdashed": context.lineWidth = 2; context.setLineDash([4, 2]); - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); context.setLineDash([]); break; - default: - // Fallback to thin for unknown styles + case "slantdashdot": context.lineWidth = 1; - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + context.setLineDash([4, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + case "mediumdashdot": + context.lineWidth = 2; + context.setLineDash([4, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + case "mediumdashdotdot": + context.lineWidth = 2; + context.setLineDash([4, 2, 1, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; } context.restore(); } From ebb0446862abc12ddb518a4914f0c6fd9b0d222c Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 30 Jan 2026 19:09:43 +0100 Subject: [PATCH 05/25] update: add 3 more styles --- .../components/BorderPicker/BorderPicker.tsx | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 50571f66b..347ffc2ef 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -391,6 +391,33 @@ const BorderPicker = (properties: BorderPickerProps) => { > + { + setBorderStyle(BorderStyle.SlantDashDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.SlantDashDot} + > + + + { + setBorderStyle(BorderStyle.MediumDashDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashDot} + > + + + { + setBorderStyle(BorderStyle.MediumDashDotDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashDotDot} + > + + )} @@ -400,6 +427,15 @@ const BorderPicker = (properties: BorderPickerProps) => { ); }; +const borderLineColor = theme.palette.grey["900"]; +const borderLinePreviewWidth = 68; + +const dashDotGradient = (color: string) => + `repeating-linear-gradient(90deg, ${color} 0px 4px, transparent 4px 6px, ${color} 6px 7px, transparent 7px 9px)`; + +const dashDotDotGradient = (color: string) => + `repeating-linear-gradient(90deg, ${color} 0px 4px, transparent 4px 6px, ${color} 6px 7px, transparent 7px 9px, ${color} 9px 10px, transparent 10px 12px)`; + const StyledMenuItem = styled(MenuItem)` display: flex; flex-direction: row; @@ -420,20 +456,20 @@ const StyledMenuItem = styled(MenuItem)` `; const SolidLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 1px solid ${theme.palette.grey["900"]}; `; const MediumLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 2px solid ${theme.palette.grey["900"]}; `; const ThickLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 3px solid ${theme.palette.grey["900"]}; `; const DoubleLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; height: 3px; position: relative; &::before { @@ -455,15 +491,33 @@ const DoubleLine = styled("div")` `; const DottedLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 1px dotted ${theme.palette.grey["900"]}; `; const MediumDashedLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 2px dashed ${theme.palette.grey["900"]}; `; +const SlantDashDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 1px; + background: ${dashDotGradient(borderLineColor)}; +`; + +const MediumDashDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 2px; + background: ${dashDotGradient(borderLineColor)}; +`; + +const MediumDashDotDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 2px; + background: ${dashDotDotGradient(borderLineColor)}; +`; + const Divider = styled("div")` width: 100%; margin: auto; From 88410df6a3321a5b3d8e66cc378969cace763c49 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 2 Feb 2026 19:39:17 +0100 Subject: [PATCH 06/25] fix: comments --- .../IronCalc/src/components/BorderPicker/BorderPicker.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 347ffc2ef..081c137fc 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -69,7 +69,9 @@ const BorderPicker = (properties: BorderPickerProps) => { const borderColorButton = useRef(null); const borderStyleButton = useRef(null); - const stylePickerCloseTimeout = useRef(null); + const stylePickerCloseTimeout = useRef | null>( + null, + ); const handleStylePickerOpen = () => { if (stylePickerCloseTimeout.current) { @@ -104,7 +106,7 @@ const BorderPicker = (properties: BorderPickerProps) => { Date: Wed, 4 Feb 2026 13:52:39 +0000 Subject: [PATCH 07/25] fix: treat leading comma as text --- base/src/formatter/format.rs | 5 +++++ base/src/test/test_number_format.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/base/src/formatter/format.rs b/base/src/formatter/format.rs index 41f641ed5..dec8d0bc5 100644 --- a/base/src/formatter/format.rs +++ b/base/src/formatter/format.rs @@ -928,6 +928,11 @@ fn parse_number( } else { 1.0 }; + + if bytes[position] == group_separator { + return Err("Cannot parse number".to_string()); + } + // numbers before the decimal point while position < len { let x = bytes[position]; diff --git a/base/src/test/test_number_format.rs b/base/src/test/test_number_format.rs index e4dcb4a59..3c4ffa71b 100644 --- a/base/src/test/test_number_format.rs +++ b/base/src/test/test_number_format.rs @@ -1,6 +1,8 @@ #![allow(clippy::unwrap_used)] use crate::number_format::format_number; +use crate::test::util::new_empty_model; +use crate::UserModel; #[test] fn test_simple_format() { @@ -17,6 +19,28 @@ fn test_maximum_zeros() { assert_eq!(formatted.text, "1,234.3333333333300000000".to_string()); } +#[test] +fn test_leading_comma_text() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_user_input(0, 1, 1, ",10").unwrap(); // A1 + model.set_user_input(0, 2, 1, ",100").unwrap(); // A2 + model.set_user_input(0, 3, 1, ",1000").unwrap(); // A3 + + assert_eq!( + model.get_formatted_cell_value(0, 1, 1), + Ok(",10".to_string()) + ); + assert_eq!( + model.get_formatted_cell_value(0, 2, 1), + Ok(",100".to_string()) + ); + assert_eq!( + model.get_formatted_cell_value(0, 3, 1), + Ok(",1000".to_string()) + ); +} + #[test] #[ignore = "not yet implemented"] fn test_wrong_locale() { From 8acef664dea68af925fc04a35f728723055f55fd Mon Sep 17 00:00:00 2001 From: Elsa Date: Tue, 3 Feb 2026 23:26:20 +0100 Subject: [PATCH 08/25] fix: add support for booleans and upper bound for df (#734 and #735) --- base/src/functions/statistical/chisq.rs | 38 ++++++++++++++----------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/base/src/functions/statistical/chisq.rs b/base/src/functions/statistical/chisq.rs index 2e0e5c354..b09c7936a 100644 --- a/base/src/functions/statistical/chisq.rs +++ b/base/src/functions/statistical/chisq.rs @@ -13,12 +13,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let x = match self.get_number_no_bools(&args[0], cell) { + let x = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df = match self.get_number_no_bools(&args[1], cell) { + let df = match self.get_number(&args[1], cell) { Ok(f) => f.trunc(), Err(e) => return e, }; @@ -35,11 +35,12 @@ impl<'a> Model<'a> { "x must be >= 0 in CHISQ.DIST".to_string(), ); } - if df < 1.0 { + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=10000000000.0).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.DIST".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.DIST".to_string(), ); } @@ -77,12 +78,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let x = match self.get_number_no_bools(&args[0], cell) { + let x = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df_raw = match self.get_number_no_bools(&args[1], cell) { + let df_raw = match self.get_number(&args[1], cell) { Ok(f) => f, Err(e) => return e, }; @@ -96,11 +97,13 @@ impl<'a> Model<'a> { "x must be >= 0 in CHISQ.DIST.RT".to_string(), ); } - if df < 1.0 { + + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=10000000000.0).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.DIST.RT".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.DIST.RT".to_string(), ); } @@ -136,12 +139,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let p = match self.get_number_no_bools(&args[0], cell) { + let p = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df = match self.get_number_no_bools(&args[1], cell) { + let df = match self.get_number(&args[1], cell) { Ok(f) => f.trunc(), Err(e) => return e, }; @@ -154,11 +157,13 @@ impl<'a> Model<'a> { "probability must be in [0,1] in CHISQ.INV".to_string(), ); } - if df < 1.0 { + + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=10000000000.0).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.INV".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.INV".to_string(), ); } @@ -196,12 +201,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let p = match self.get_number_no_bools(&args[0], cell) { + let p = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df_raw = match self.get_number_no_bools(&args[1], cell) { + let df_raw = match self.get_number(&args[1], cell) { Ok(f) => f, Err(e) => return e, }; @@ -216,11 +221,12 @@ impl<'a> Model<'a> { "probability must be in [0,1] in CHISQ.INV.RT".to_string(), ); } - if df < 1.0 { + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=10000000000.0).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.INV.RT".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.INV.RT".to_string(), ); } From af0a1808557237fbcf8328fc7bd032a15384eee4 Mon Sep 17 00:00:00 2001 From: Elsa Date: Tue, 3 Feb 2026 23:45:57 +0100 Subject: [PATCH 09/25] update: add unit test for bug fix --- base/src/test/statistical/test_fn_chisq.rs | 41 +++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/base/src/test/statistical/test_fn_chisq.rs b/base/src/test/statistical/test_fn_chisq.rs index fce8ec63d..ea03cdb1f 100644 --- a/base/src/test/statistical/test_fn_chisq.rs +++ b/base/src/test/statistical/test_fn_chisq.rs @@ -22,8 +22,10 @@ fn test_fn_chisq_dist_smoke() { // Domain errors // x < 0 -> #NUM! model._set("A6", "=CHISQ.DIST(-1, 4, TRUE)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.DIST(0.5, 0, TRUE)"); + model._set("A8", "=CHISQ.DIST(10, 10000000000, TRUE)"); + model._set("A9", "=CHISQ.DIST(10, 10000000001, TRUE)"); model.evaluate(); @@ -37,6 +39,8 @@ fn test_fn_chisq_dist_smoke() { assert_eq!(model._get_text("A5"), *"#ERROR!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); } #[test] @@ -54,8 +58,10 @@ fn test_fn_chisq_dist_rt_smoke() { // Domain errors // x < 0 -> #NUM! model._set("A5", "=CHISQ.DIST.RT(-1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A6", "=CHISQ.DIST.RT(0.5, 0)"); + model._set("A7", "=CHISQ.DIST.RT(0, 10000000000)"); + model._set("A8", "=CHISQ.DIST.RT(0, 10000000001)"); model.evaluate(); @@ -69,6 +75,8 @@ fn test_fn_chisq_dist_rt_smoke() { assert_eq!(model._get_text("A4"), *"#ERROR!"); assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); + assert_eq!(model._get_text("A7"), *"1"); + assert_eq!(model._get_text("A8"), *"#NUM!"); } #[test] @@ -87,8 +95,10 @@ fn test_fn_chisq_inv_smoke() { // probability < 0 or > 1 -> #NUM! model._set("A5", "=CHISQ.INV(-0.1, 4)"); model._set("A6", "=CHISQ.INV(1.1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.INV(0.5, 0)"); + model._set("A8", "=CHISQ.INV(0, 10000000000)"); + model._set("A9", "=CHISQ.INV(0, 10000000001)"); model.evaluate(); @@ -103,6 +113,8 @@ fn test_fn_chisq_inv_smoke() { assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); } #[test] @@ -121,8 +133,10 @@ fn test_fn_chisq_inv_rt_smoke() { // probability < 0 or > 1 -> #NUM! model._set("A5", "=CHISQ.INV.RT(-0.1, 4)"); model._set("A6", "=CHISQ.INV.RT(1.1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.INV.RT(0.5, 0)"); + model._set("A8", "=CHISQ.INV.RT(1, 10000000000)"); + model._set("A9", "=CHISQ.INV.RT(1, 10000000001)"); model.evaluate(); @@ -137,4 +151,23 @@ fn test_fn_chisq_inv_rt_smoke() { assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); +} + +#[test] +fn test_booleans() { + let mut model = new_empty_model(); + + model._set("A1", "=CHISQ.DIST(7, TRUE, TRUE)"); + model._set("A2", "=CHISQ.INV(A1, TRUE)"); + model._set("A3", "=CHISQ.DIST.RT(7, TRUE)"); + model._set("A4", "=CHISQ.INV.RT(A3, TRUE)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "0.991849028"); + assert_eq!(model._get_text("A2"), "7"); + assert_eq!(model._get_text("A3"), "0.008150972"); + assert_eq!(model._get_text("A4"), "7"); } From e22549c3079888c5473dacdae7d21b5c05f0c8a4 Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 4 Feb 2026 00:27:50 +0100 Subject: [PATCH 10/25] fix: add max deg of freedom as constant --- base/src/functions/statistical/chisq.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/base/src/functions/statistical/chisq.rs b/base/src/functions/statistical/chisq.rs index b09c7936a..22674a076 100644 --- a/base/src/functions/statistical/chisq.rs +++ b/base/src/functions/statistical/chisq.rs @@ -5,6 +5,7 @@ use crate::expressions::types::CellReferenceIndex; use crate::{ calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, }; +const MAX_DEGREES_OF_FREEDOM: f64 = 10_000_000_000.0; impl<'a> Model<'a> { // CHISQ.DIST(x, deg_freedom, cumulative) @@ -36,7 +37,7 @@ impl<'a> Model<'a> { ); } // if degrees of freedom < 1 or > 10^10 → #NUM! - if !(1.0..=10000000000.0).contains(&df) { + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, @@ -99,7 +100,7 @@ impl<'a> Model<'a> { } // if degrees of freedom < 1 or > 10^10 → #NUM! - if !(1.0..=10000000000.0).contains(&df) { + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, @@ -159,7 +160,7 @@ impl<'a> Model<'a> { } // if degrees of freedom < 1 or > 10^10 → #NUM! - if !(1.0..=10000000000.0).contains(&df) { + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, @@ -222,7 +223,7 @@ impl<'a> Model<'a> { ); } // if degrees of freedom < 1 or > 10^10 → #NUM! - if !(1.0..=10000000000.0).contains(&df) { + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, From 38c4787fceab120e9c07a9d21b27b92196bcc6ac Mon Sep 17 00:00:00 2001 From: Elsa Date: Mon, 26 Jan 2026 09:27:25 +0100 Subject: [PATCH 11/25] update: adds locale testing for database functions # Conflicts: # base/src/test/test_database.rs --- base/src/test/test_database.rs | 302 +++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) diff --git a/base/src/test/test_database.rs b/base/src/test/test_database.rs index d20bf3f69..a345ab338 100644 --- a/base/src/test/test_database.rs +++ b/base/src/test/test_database.rs @@ -67,3 +67,305 @@ fn arguments() { assert_eq!(model._get_text("A23"), *"#ERROR!"); assert_eq!(model._get_text("A24"), *"#ERROR!"); } + +#[test] +fn locale_ISO_format() { + // ISO format with YYYY-MM-DD format. Works with any locale. + let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "2026-01-15"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "2026-03-15"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "2026-06-15"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=2026-03-01"); + model._set("B6", "Date"); + model._set("B7", "2026-01-15"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_uk() { + // en-GB locale with DD/MM/YYYY format + let mut model = Model::new_empty("model", "en-GB", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "15/01/2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "15/03/2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "15/06/2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=01/03/2026"); + model._set("B6", "Date"); + model._set("B7", "15/01/2026"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_us() { + // en-US locale with MM/DD/YY format + let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "1/15/26"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "3/15/26"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "6/15/26"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=3/1/26"); + model._set("B6", "Date"); + model._set("B7", "1/15/26"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_de() { + // de-DE locale with D.M.YYYY format + let mut model = Model::new_empty("model", "de-DE", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "15.1.2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "15.3.2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "15.6.2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=1.3.2026"); + model._set("B6", "Date"); + model._set("B7", "15.1.2026"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_wrong_format() { + // en-US locale with incorrect D.M.YYYY format + let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "15.1.2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "15.3.2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "15.6.2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=1.3.2026"); + model._set("B6", "Date"); + model._set("B7", "15.1.2026"); // DGET needs an exact match + + // Test functions - results should be the same as using empty criteria + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + // Test functions with empty criteria - range C6:C7 is empty + model._set("B9", "=DMIN(A1:C4, C1, C6:C7)"); + model._set("B10", "=DMAX(A1:C4, C1, C6:C7)"); + model._set("B11", "=DAVERAGE(A1:C4, C1, C6:C7)"); + model._set("B12", "=DSUM(A1:C4, C1, C6:C7)"); + model._set("B13", "=DPRODUCT(A1:C4, C1, C6:C7)"); + model._set("B14", "=DGET(A1:C4, C1, C6:C7)"); + model._set("B15", "=DCOUNT(A1:C4, C1, C6:C7)"); + model._set("B16", "=DCOUNTA(A1:C4, C1, C6:C7)"); + model._set("B17", "=DVAR(A1:C4, C1, C6:C7)"); + model._set("B18", "=DVARP(A1:C4, C1, C6:C7)"); + model._set("B19", "=DSTDEV(A1:C4, C1, C6:C7)"); + model._set("B20", "=DSTDEVP(A1:C4, C1, C6:C7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1400"); + assert_eq!(model._get_text("A12"), *"4200"); + assert_eq!(model._get_text("A13"), *"2268000000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"3"); + assert_eq!(model._get_text("A16"), *"3"); + assert_eq!(model._get_text("A17"), *"390000"); + assert_eq!(model._get_text("A18"), *"260000"); + assert_eq!(model._get_text("A19"), *"624.49979983984"); + assert_eq!(model._get_text("A20"), *"509.901951359278"); + + assert_eq!(model._get_text("B9"), *"900"); + assert_eq!(model._get_text("B10"), *"2100"); + assert_eq!(model._get_text("B11"), *"1400"); + assert_eq!(model._get_text("B12"), *"4200"); + assert_eq!(model._get_text("B13"), *"2268000000"); + assert_eq!(model._get_text("B14"), *"1200"); + assert_eq!(model._get_text("B15"), *"3"); + assert_eq!(model._get_text("B16"), *"3"); + assert_eq!(model._get_text("B17"), *"390000"); + assert_eq!(model._get_text("B18"), *"260000"); + assert_eq!(model._get_text("B19"), *"624.49979983984"); + assert_eq!(model._get_text("B20"), *"509.901951359278"); +} From d6ea5e62804067b9eebd7c83c94485635e8fbe2d Mon Sep 17 00:00:00 2001 From: Elsa Date: Mon, 26 Jan 2026 17:12:17 +0100 Subject: [PATCH 12/25] fix: changes to locale name, use correct formula delimitter for de --- base/src/test/test_database.rs | 61 +++++++++++++++++----------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/base/src/test/test_database.rs b/base/src/test/test_database.rs index a345ab338..4e8bc93f6 100644 --- a/base/src/test/test_database.rs +++ b/base/src/test/test_database.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use crate::test::util::new_empty_model; +use crate::Model; #[test] fn arguments() { @@ -69,9 +70,9 @@ fn arguments() { } #[test] -fn locale_ISO_format() { +fn locale_iso_format() { // ISO format with YYYY-MM-DD format. Works with any locale. - let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); // Create database model._set("A1", "ID"); @@ -119,7 +120,7 @@ fn locale_ISO_format() { assert_eq!(model._get_text("A16"), *"2"); assert_eq!(model._get_text("A17"), *"720000"); assert_eq!(model._get_text("A18"), *"360000"); - assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A19"), *"848.528137424"); assert_eq!(model._get_text("A20"), *"600"); } @@ -174,14 +175,14 @@ fn locale_uk() { assert_eq!(model._get_text("A16"), *"2"); assert_eq!(model._get_text("A17"), *"720000"); assert_eq!(model._get_text("A18"), *"360000"); - assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A19"), *"848.528137424"); assert_eq!(model._get_text("A20"), *"600"); } #[test] fn locale_us() { // en-US locale with MM/DD/YY format - let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); // Create database model._set("A1", "ID"); @@ -229,14 +230,14 @@ fn locale_us() { assert_eq!(model._get_text("A16"), *"2"); assert_eq!(model._get_text("A17"), *"720000"); assert_eq!(model._get_text("A18"), *"360000"); - assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A19"), *"848.528137424"); assert_eq!(model._get_text("A20"), *"600"); } #[test] fn locale_de() { // de-DE locale with D.M.YYYY format - let mut model = Model::new_empty("model", "de-DE", "UTC", "en").unwrap(); + let mut model = Model::new_empty("model", "de", "UTC", "en").unwrap(); // Create database model._set("A1", "ID"); @@ -259,18 +260,18 @@ fn locale_de() { model._set("B7", "15.1.2026"); // DGET needs an exact match // Test functions - model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); - model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); - model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); - model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); - model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); - model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); - model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); - model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); - model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); - model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); - model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); - model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + model._set("A9", "=DMIN(A1:C4; C1; A6:A7)"); + model._set("A10", "=DMAX(A1:C4; C1; A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4; C1; A6:A7)"); + model._set("A12", "=DSUM(A1:C4; C1; A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4; C1; A6:A7)"); + model._set("A14", "=DGET(A1:C4; C1; B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4; C1; A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4; C1; A6:A7)"); + model._set("A17", "=DVAR(A1:C4; C1; A6:A7)"); + model._set("A18", "=DVARP(A1:C4; C1; A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4; C1; A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4; C1; A6:A7)"); model.evaluate(); @@ -284,34 +285,34 @@ fn locale_de() { assert_eq!(model._get_text("A16"), *"2"); assert_eq!(model._get_text("A17"), *"720000"); assert_eq!(model._get_text("A18"), *"360000"); - assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A19"), *"848,528137424"); assert_eq!(model._get_text("A20"), *"600"); } #[test] fn locale_wrong_format() { // en-US locale with incorrect D.M.YYYY format - let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); // Create database model._set("A1", "ID"); model._set("B1", "Date"); model._set("C1", "Amount"); model._set("A2", "1"); - model._set("B2", "15.1.2026"); + model._set("B2", "10.1.2026"); model._set("C2", "1200"); model._set("A3", "2"); - model._set("B3", "15.3.2026"); + model._set("B3", "10.3.2026"); model._set("C3", "900"); model._set("A4", "3"); - model._set("B4", "15.6.2026"); + model._set("B4", "10.6.2026"); model._set("C4", "2100"); // Define criteria model._set("A6", "Date"); model._set("A7", ">=1.3.2026"); model._set("B6", "Date"); - model._set("B7", "15.1.2026"); // DGET needs an exact match + model._set("B7", "10.1.2026"); // DGET needs an exact match // Test functions - results should be the same as using empty criteria model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); @@ -333,7 +334,7 @@ fn locale_wrong_format() { model._set("B11", "=DAVERAGE(A1:C4, C1, C6:C7)"); model._set("B12", "=DSUM(A1:C4, C1, C6:C7)"); model._set("B13", "=DPRODUCT(A1:C4, C1, C6:C7)"); - model._set("B14", "=DGET(A1:C4, C1, C6:C7)"); + model._set("B14", "=DGET(A1:C4, C1, B6:B7)"); model._set("B15", "=DCOUNT(A1:C4, C1, C6:C7)"); model._set("B16", "=DCOUNTA(A1:C4, C1, C6:C7)"); model._set("B17", "=DVAR(A1:C4, C1, C6:C7)"); @@ -353,8 +354,8 @@ fn locale_wrong_format() { assert_eq!(model._get_text("A16"), *"3"); assert_eq!(model._get_text("A17"), *"390000"); assert_eq!(model._get_text("A18"), *"260000"); - assert_eq!(model._get_text("A19"), *"624.49979983984"); - assert_eq!(model._get_text("A20"), *"509.901951359278"); + assert_eq!(model._get_text("A19"), *"624.49979984"); + assert_eq!(model._get_text("A20"), *"509.901951359"); assert_eq!(model._get_text("B9"), *"900"); assert_eq!(model._get_text("B10"), *"2100"); @@ -366,6 +367,6 @@ fn locale_wrong_format() { assert_eq!(model._get_text("B16"), *"3"); assert_eq!(model._get_text("B17"), *"390000"); assert_eq!(model._get_text("B18"), *"260000"); - assert_eq!(model._get_text("B19"), *"624.49979983984"); - assert_eq!(model._get_text("B20"), *"509.901951359278"); + assert_eq!(model._get_text("B19"), *"624.49979984"); + assert_eq!(model._get_text("B20"), *"509.901951359"); } From d4015cdf7baa1e667fe4b911ba210d6f9739f649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Sat, 7 Feb 2026 02:38:39 +0100 Subject: [PATCH 13/25] FIX: Cut and paste on partially selected range --- base/src/test/user_model/mod.rs | 1 + base/src/test/user_model/test_cut_n_paste.rs | 99 ++++++++++++++++++++ base/src/user_model/common.rs | 11 ++- 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 base/src/test/user_model/test_cut_n_paste.rs diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index 89d346419..122daa25c 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -5,6 +5,7 @@ mod test_batch_row_column_diff; mod test_border; mod test_clear_cells; mod test_column_style; +mod test_cut_n_paste; mod test_defined_names; mod test_delete_row_column_formatting; mod test_diff_queue; diff --git a/base/src/test/user_model/test_cut_n_paste.rs b/base/src/test/user_model/test_cut_n_paste.rs new file mode 100644 index 000000000..7e3907e93 --- /dev/null +++ b/base/src/test/user_model/test_cut_n_paste.rs @@ -0,0 +1,99 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; +use crate::UserModel; + +#[test] +fn cun_n_paste_same_area() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + // B3:D5 with data + model.set_user_input(0, 3, 2, "A").unwrap(); + model.set_user_input(0, 3, 3, "B").unwrap(); + model.set_user_input(0, 3, 4, "C").unwrap(); + model.set_user_input(0, 4, 2, "D").unwrap(); + model.set_user_input(0, 4, 3, "E").unwrap(); + model.set_user_input(0, 4, 4, "F").unwrap(); + model.set_user_input(0, 5, 2, "G").unwrap(); + model.set_user_input(0, 5, 3, "H").unwrap(); + model.set_user_input(0, 5, 4, "I").unwrap(); + + // Cut it and paste it in C4 + model.set_selected_cell(3, 2).unwrap(); + model.set_selected_range(3, 2, 5, 4).unwrap(); + let cp = model.copy_to_clipboard().unwrap(); + + // C4 + model.set_selected_cell(4, 3).unwrap(); + + let source_range = (3, 2, 5, 4); + model + .paste_from_clipboard(0, source_range, &cp.data, true) + .unwrap(); + + // Check data is in C4:E6 + assert_eq!(model.get_formatted_cell_value(0, 4, 3).unwrap(), "A"); + assert_eq!(model.get_formatted_cell_value(0, 4, 4).unwrap(), "B"); + assert_eq!(model.get_formatted_cell_value(0, 4, 5).unwrap(), "C"); + assert_eq!(model.get_formatted_cell_value(0, 5, 3).unwrap(), "D"); + assert_eq!(model.get_formatted_cell_value(0, 5, 4).unwrap(), "E"); + assert_eq!(model.get_formatted_cell_value(0, 5, 5).unwrap(), "F"); + assert_eq!(model.get_formatted_cell_value(0, 6, 3).unwrap(), "G"); + assert_eq!(model.get_formatted_cell_value(0, 6, 4).unwrap(), "H"); + assert_eq!(model.get_formatted_cell_value(0, 6, 5).unwrap(), "I"); +} + +#[test] +fn cun_n_paste_different_sheet() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + // B3:D5 with data + model.set_user_input(0, 3, 2, "A").unwrap(); + model.set_user_input(0, 3, 3, "B").unwrap(); + model.set_user_input(0, 3, 4, "C").unwrap(); + model.set_user_input(0, 4, 2, "D").unwrap(); + model.set_user_input(0, 4, 3, "E").unwrap(); + model.set_user_input(0, 4, 4, "F").unwrap(); + model.set_user_input(0, 5, 2, "G").unwrap(); + model.set_user_input(0, 5, 3, "H").unwrap(); + model.set_user_input(0, 5, 4, "I").unwrap(); + + // Cut it and paste it in C4 + model.set_selected_cell(3, 2).unwrap(); + model.set_selected_range(3, 2, 5, 4).unwrap(); + let cp = model.copy_to_clipboard().unwrap(); + + // New sheet and select it + model.new_sheet().unwrap(); + model.set_selected_sheet(1).unwrap(); + + // C4 + model.set_selected_cell(4, 3).unwrap(); + + let source_range = (3, 2, 5, 4); + model + .paste_from_clipboard(0, source_range, &cp.data, true) + .unwrap(); + + // Check data is in Sheet2!C4:E6 + assert_eq!(model.get_formatted_cell_value(1, 4, 3).unwrap(), "A"); + assert_eq!(model.get_formatted_cell_value(1, 4, 4).unwrap(), "B"); + assert_eq!(model.get_formatted_cell_value(1, 4, 5).unwrap(), "C"); + assert_eq!(model.get_formatted_cell_value(1, 5, 3).unwrap(), "D"); + assert_eq!(model.get_formatted_cell_value(1, 5, 4).unwrap(), "E"); + assert_eq!(model.get_formatted_cell_value(1, 5, 5).unwrap(), "F"); + assert_eq!(model.get_formatted_cell_value(1, 6, 3).unwrap(), "G"); + assert_eq!(model.get_formatted_cell_value(1, 6, 4).unwrap(), "H"); + assert_eq!(model.get_formatted_cell_value(1, 6, 5).unwrap(), "I"); + + // Check original range is empty Sheet1!B3:D5 + assert_eq!(model.get_formatted_cell_value(0, 3, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 3, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 3, 4).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 4).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 4).unwrap(), ""); +} diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 1c635d5fb..190a9a2a8 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -1,6 +1,10 @@ #![deny(missing_docs)] -use std::{collections::HashMap, fmt::Debug, io::Cursor}; +use std::{ + collections::{HashMap, HashSet}, + fmt::Debug, + io::Cursor, +}; use csv::{ReaderBuilder, WriterBuilder}; use serde::{Deserialize, Serialize}; @@ -1830,6 +1834,7 @@ impl<'a> UserModel<'a> { width: source_last_column - source_first_column + 1, height: source_last_row - source_first_row + 1, }; + let mut seen_cells = HashSet::new(); for (source_row, data_row) in clipboard { let delta_row = source_row - source_first_row; let target_row = selected_row + delta_row; @@ -1893,11 +1898,15 @@ impl<'a> UserModel<'a> { old_value: Box::new(old_style), new_value: Box::new(value.style.clone()), }); + seen_cells.insert((target_row, target_column)); } } if is_cut { for row in source_first_row..=source_last_row { for column in source_first_column..=source_last_column { + if (source_sheet == sheet) && seen_cells.contains(&(row, column)) { + continue; + } let old_value = self .model .workbook From 21631e0ca95fdf937562441d1efaacb3dd0a079b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Sat, 7 Feb 2026 11:02:45 +0100 Subject: [PATCH 14/25] FIX: Adds missing test --- base/src/test/user_model/mod.rs | 1 + base/src/test/user_model/test_fn_formulatext.rs | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index 122daa25c..386c9ef4f 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -10,6 +10,7 @@ mod test_defined_names; mod test_delete_row_column_formatting; mod test_diff_queue; mod test_evaluation; +mod test_fn_formulatext; mod test_general; mod test_grid_lines; mod test_keyboard_navigation; diff --git a/base/src/test/user_model/test_fn_formulatext.rs b/base/src/test/user_model/test_fn_formulatext.rs index 24df46095..67bf8e63b 100644 --- a/base/src/test/user_model/test_fn_formulatext.rs +++ b/base/src/test/user_model/test_fn_formulatext.rs @@ -1,10 +1,17 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::user_model::util::new_empty_user_model; + #[test] fn formulatext_english() { - let mut model = UserModel::from_model(new_empty_model()); + let mut model = new_empty_user_model(); model.set_user_input(0, 1, 1, "=SUM(1, 2, 3)").unwrap(); model.set_user_input(0, 1, 2, "=FORMULATEXT(A1)").unwrap(); model.set_language("de").unwrap(); - assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("=SUM(1,2,3)".to_string())); -} \ No newline at end of file + assert_eq!( + model.get_formatted_cell_value(0, 1, 2), + Ok("=SUM(1,2,3)".to_string()) + ); +} From bb2c62a021ef234ffdafb1c5436e22663c80fee5 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 01:03:29 -0700 Subject: [PATCH 15/25] merge xmatch #48 --- .../src/expressions/parser/static_analysis.rs | 11 + base/src/functions/mod.rs | 3 + base/src/functions/xlookup.rs | 203 ++++++++++++++++++ base/src/test/mod.rs | 1 + base/src/test/test_fn_xmatch.rs | 154 +++++++++++++ docs/src/functions/lookup-and-reference.md | 2 +- .../functions/lookup_and_reference/xmatch.md | 3 +- 7 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 base/src/test/test_fn_xmatch.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 04edf5614..3eee1aee8 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -512,6 +512,15 @@ fn args_signature_xlookup(arg_count: usize) -> Vec { result } +fn args_signature_xmatch(arg_count: usize) -> Vec { + if !(2..=4).contains(&arg_count) { + return vec![Signature::Error; arg_count]; + } + let mut result = vec![Signature::Scalar; arg_count]; + result[1] = Signature::Vector; // lookup_array should be Vector + result +} + fn args_signature_textafter(arg_count: usize) -> Vec { if !(2..=6).contains(&arg_count) { vec![Signature::Scalar; arg_count] @@ -689,6 +698,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_one_vector(arg_count), Function::Vlookup => args_signature_hlookup(arg_count), Function::Xlookup => args_signature_xlookup(arg_count), + Function::Xmatch => args_signature_xmatch(arg_count), Function::Concat => vec![Signature::Vector; arg_count], Function::Concatenate => vec![Signature::Scalar; arg_count], Function::Exact => args_signature_scalars(arg_count, 2, 0), @@ -1083,6 +1093,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Rows => not_implemented(args), Function::Vlookup => not_implemented(args), Function::Xlookup => not_implemented(args), + Function::Xmatch => not_implemented(args), Function::Concat => not_implemented(args), Function::Concatenate => not_implemented(args), Function::Exact => not_implemented(args), diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 3a8edf115..9476df82d 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -155,6 +155,7 @@ pub enum Function { Rows, Vlookup, Xlookup, + Xmatch, // Text Concat, @@ -1261,6 +1262,7 @@ impl Function { Function::Rows, Function::Vlookup, Function::Xlookup, + Function::Xmatch, Function::Concatenate, Function::Exact, Function::Value, @@ -1531,6 +1533,7 @@ impl Function { Function::Minifs => "_xlfn.MINIFS".to_string(), Function::Switch => "_xlfn.SWITCH".to_string(), Function::Xlookup => "_xlfn.XLOOKUP".to_string(), + Function::Xmatch => "_xlfn.XMATCH".to_string(), Function::Xor => "_xlfn.XOR".to_string(), Function::Textbefore => "_xlfn.TEXTBEFORE".to_string(), Function::Textafter => "_xlfn.TEXTAFTER".to_string(), diff --git a/base/src/functions/xlookup.rs b/base/src/functions/xlookup.rs index cb9f9adec..5c92414ba 100644 --- a/base/src/functions/xlookup.rs +++ b/base/src/functions/xlookup.rs @@ -4,6 +4,9 @@ use crate::{ calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, }; +#[cfg(feature = "use_regex_lite")] +use regex_lite as regex; + use super::{ binary_search::{ binary_search_descending_or_greater, binary_search_descending_or_smaller, @@ -26,6 +29,7 @@ enum MatchMode { ExactMatch = 0, ExactMatchLarger = 1, WildcardMatch = 2, + RegexMatch = 3, } // lookup_value in array, match_mode search_mode @@ -114,6 +118,29 @@ fn linear_search( } } } + MatchMode::RegexMatch => { + let result_matches: Box bool> = + if let CalcResult::String(s) = &lookup_value { + if let Ok(reg) = regex::Regex::new(&s.to_lowercase()) { + Box::new(move |x| result_matches_regex(x, ®)) + } else { + Box::new(move |_| false) + } + } else { + Box::new(move |x| compare_values(x, lookup_value) == 0) + }; + for l in 0..length { + let index = if search_mode == SearchMode::StartAtFirstItem { + l + } else { + length - l - 1 + }; + let value = &array[index]; + if result_matches(value) { + return Some(index); + } + } + } } None } @@ -387,4 +414,180 @@ impl<'a> Model<'a> { }, } } + + /// XMATCH(lookup_value, lookup_array, [match_mode], [search_mode]) + /// Returns the relative position of an item in an array or range + pub(crate) fn fn_xmatch(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() < 2 || args.len() > 4 { + return CalcResult::new_args_number_error(cell); + } + let lookup_value = self.evaluate_node_in_context(&args[0], cell); + if lookup_value.is_error() { + return lookup_value; + } + let match_mode = if args.len() >= 3 { + match self.get_number(&args[2], cell) { + Ok(c) => match c.floor() as i32 { + -1 => MatchMode::ExactMatchSmaller, + 0 => MatchMode::ExactMatch, + 1 => MatchMode::ExactMatchLarger, + 2 => MatchMode::WildcardMatch, + 3 => MatchMode::RegexMatch, + _ => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Unexpected number".to_string(), + }; + } + }, + Err(s) => return s, + } + } else { + MatchMode::ExactMatch + }; + let search_mode = if args.len() == 4 { + match self.get_number(&args[3], cell) { + Ok(c) => match c.floor() as i32 { + 1 => SearchMode::StartAtFirstItem, + -1 => SearchMode::StartAtLastItem, + -2 => SearchMode::BinarySearchDescending, + 2 => SearchMode::BinarySearchAscending, + _ => { + return CalcResult::Error { + error: Error::ERROR, + origin: cell, + message: "Unexpected number".to_string(), + }; + } + }, + Err(s) => return s, + } + } else { + SearchMode::StartAtFirstItem + }; + match self.evaluate_node_in_context(&args[1], cell) { + CalcResult::Range { left, right } => { + let is_row_vector; + if left.row == right.row { + is_row_vector = false; + } else if left.column == right.column { + is_row_vector = true; + } else { + return CalcResult::Error { + error: Error::ERROR, + origin: cell, + message: "Second argument must be a vector".to_string(), + }; + } + let mut row2 = right.row; + let row1 = left.row; + let mut column2 = right.column; + let column1 = left.column; + if row1 == 1 && row2 == LAST_ROW { + row2 = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_row, + Err(_) => { + return CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ); + } + }; + } + if column1 == 1 && column2 == LAST_COLUMN { + column2 = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_column, + Err(_) => { + return CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ); + } + }; + } + let left = CellReferenceIndex { + sheet: left.sheet, + column: column1, + row: row1, + }; + let right = CellReferenceIndex { + sheet: left.sheet, + column: column2, + row: row2, + }; + match search_mode { + SearchMode::StartAtFirstItem | SearchMode::StartAtLastItem => { + let array = self.prepare_array(&left, &right, is_row_vector); + match linear_search(&lookup_value, &array, search_mode, match_mode) { + Some(index) => CalcResult::Number(index as f64 + 1.0), + None => CalcResult::Error { + error: Error::NA, + origin: cell, + message: "Not found".to_string(), + }, + } + } + SearchMode::BinarySearchAscending | SearchMode::BinarySearchDescending => { + let array = self.prepare_array(&left, &right, is_row_vector); + let index = match match_mode { + MatchMode::ExactMatchLarger => { + if search_mode == SearchMode::BinarySearchAscending { + binary_search_or_greater(&lookup_value, &array) + } else { + binary_search_descending_or_greater(&lookup_value, &array) + } + } + MatchMode::ExactMatchSmaller | MatchMode::ExactMatch => { + if search_mode == SearchMode::BinarySearchAscending { + binary_search_or_smaller(&lookup_value, &array) + } else { + binary_search_descending_or_smaller(&lookup_value, &array) + } + } + MatchMode::WildcardMatch | MatchMode::RegexMatch => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Cannot use wildcard in binary search".to_string(), + }; + } + }; + match index { + None => CalcResult::Error { + error: Error::NA, + origin: cell, + message: "Not found".to_string(), + }, + Some(l) => { + if match_mode == MatchMode::ExactMatch { + let value = self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row: left.row + if is_row_vector { l } else { 0 }, + column: left.column + if is_row_vector { 0 } else { l }, + }); + if compare_values(&value, &lookup_value) != 0 { + return CalcResult::Error { + error: Error::NA, + origin: cell, + message: "Not found".to_string(), + }; + } + } + CalcResult::Number(l as f64 + 1.0) + } + } + } + } + } + error @ CalcResult::Error { .. } => error, + _ => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Range expected".to_string(), + }, + } + } } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3c2b0f052..92115846b 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -34,6 +34,7 @@ mod test_fn_textbefore; mod test_fn_textjoin; mod test_fn_time; mod test_fn_unicode; +mod test_fn_xmatch; mod test_frozen_rows_columns; mod test_general; mod test_inverted_ranges; diff --git a/base/src/test/test_fn_xmatch.rs b/base/src/test/test_fn_xmatch.rs new file mode 100644 index 000000000..2f654e3d5 --- /dev/null +++ b/base/src/test/test_fn_xmatch.rs @@ -0,0 +1,154 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_xmatch_basic_exact_match() { + let mut model = new_empty_model(); + model._set("A1", "10"); + model._set("A2", "20"); + model._set("A3", "30"); + model._set("B1", "=XMATCH(20, A1:A3)"); // Default mode 0 + model._set("B2", "=XMATCH(25, A1:A3)"); // Not found + model.evaluate(); + assert_eq!(model._get_text("B1"), *"2"); + assert_eq!(model._get_text("B2"), *"#N/A"); +} + +#[test] +fn test_fn_xmatch_match_modes() { + let mut model = new_empty_model(); + model._set("A1", "10"); + model._set("A2", "20"); + model._set("A3", "30"); + model._set("B1", "=XMATCH(25, A1:A3, -1)"); // Exact or next smaller + model._set("B2", "=XMATCH(15, A1:A3, 1)"); // Exact or next larger + model._set("C1", "apple"); + model._set("C2", "banana"); + model._set("C3", "apricot"); + model._set("B3", "=XMATCH(\"ap*\", C1:C3, 2)"); // Wildcard + model._set("B4", "=XMATCH(\"^[a-c]\", C1:C3, 3)"); // Regex + model.evaluate(); + assert_eq!(model._get_text("B1"), *"2"); // Found 20 (next smaller than 25) + assert_eq!(model._get_text("B2"), *"2"); // Found 20 (next larger than 15) + assert_eq!(model._get_text("B3"), *"1"); // Found "apple" + assert_eq!(model._get_text("B4"), *"1"); // Found "apple" +} + +#[test] +fn test_fn_xmatch_search_modes() { + let mut model = new_empty_model(); + model._set("A1", "a"); + model._set("A2", "b"); + model._set("A3", "a"); // Duplicate + model._set("B1", "=XMATCH(\"a\", A1:A3, 0, 1)"); // Search from first + model._set("B2", "=XMATCH(\"a\", A1:A3, 0, -1)"); // Search from last + // Binary search tests + model._set("C1", "10"); + model._set("C2", "20"); + model._set("C3", "30"); + model._set("B3", "=XMATCH(20, C1:C3, 0, 2)"); // Binary ascending + model._set("D1", "30"); + model._set("D2", "20"); + model._set("D3", "10"); + model._set("B4", "=XMATCH(20, D1:D3, 0, -2)"); // Binary descending + model.evaluate(); + assert_eq!(model._get_text("B1"), *"1"); // First occurrence + assert_eq!(model._get_text("B2"), *"3"); // Last occurrence + assert_eq!(model._get_text("B3"), *"2"); // Binary search ascending + assert_eq!(model._get_text("B4"), *"2"); // Binary search descending +} + +#[test] +fn test_fn_xmatch_data_types_and_vectors() { + let mut model = new_empty_model(); + // Different data types + model._set("A1", "1.5"); + model._set("A2", "hello"); + model._set("A3", "TRUE"); + model._set("B1", "=XMATCH(\"hello\", A1:A3)"); + model._set("B2", "=XMATCH(TRUE, A1:A3)"); + // Column vector + model._set("C1", "apple"); + model._set("D1", "banana"); + model._set("E1", "cherry"); + model._set("B3", "=XMATCH(\"banana\", C1:E1)"); + // Empty cells + model._set("F1", ""); + model._set("F2", "test"); + model._set("B4", "=XMATCH(\"\", F1:F2)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"2"); + assert_eq!(model._get_text("B2"), *"3"); + assert_eq!(model._get_text("B3"), *"2"); // Column vector + assert_eq!(model._get_text("B4"), *"1"); // Empty cell +} + +#[test] +fn test_fn_xmatch_error_conditions() { + let mut model = new_empty_model(); + model._set("A1", "test"); + // Wrong number of arguments + model._set("B1", "=XMATCH(\"test\")"); + model._set("B2", "=XMATCH(\"test\", A1:A1, 0, 1, 1)"); + // Invalid modes + model._set("B3", "=XMATCH(\"test\", A1:A1, 5)"); // Invalid match mode + model._set("B4", "=XMATCH(\"test\", A1:A1, 0, 5)"); // Invalid search mode + // Non-vector range + model._set("A2", "test2"); + model._set("C1", "test3"); + model._set("C2", "test4"); + model._set("B5", "=XMATCH(\"test\", A1:C2)"); // 2x2 range + // Binary search with wildcard (should error) + model._set("B6", "=XMATCH(\"ap*\", A1:A1, 2, 2)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#VALUE!"); + assert_eq!(model._get_text("B4"), *"#ERROR!"); + assert_eq!(model._get_text("B5"), *"#ERROR!"); + assert_eq!(model._get_text("B6"), *"#VALUE!"); +} + +#[test] +fn test_fn_xmatch_edge_cases() { + let mut model = new_empty_model(); + // Case sensitivity (case-insensitive comparison) + model._set("A1", "Test"); + model._set("A2", "TEST"); + model._set("A3", "test"); + model._set("B1", "=XMATCH(\"test\", A1:A3)"); + // No smaller/larger values available + model._set("C1", "20"); + model._set("C2", "30"); + model._set("C3", "40"); + model._set("B2", "=XMATCH(10, C1:C3, -1)"); // No smaller + model._set("B3", "=XMATCH(50, C1:C3, 1)"); // No larger + // Invalid regex pattern + model._set("B4", "=XMATCH(\"[\", A1:A1, 3)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"1"); // Case-insensitive + assert_eq!(model._get_text("B2"), *"#N/A"); // No smaller value + assert_eq!(model._get_text("B3"), *"#N/A"); // No larger value + assert_eq!(model._get_text("B4"), *"#N/A"); // Invalid regex +} + +#[test] +fn test_fn_xmatch_range_as_lookup_value() { + let mut model = new_empty_model(); + model._set("A1", "10"); + model._set("A2", "20"); + model._set("B1", "10"); + model._set("B2", "20"); + model._set("B3", "30"); + + // Test passing a range as lookup_value (should error since implicit intersection not supported) + model._set("C1", "=XMATCH(A1:A2, B1:B3)"); // Range as first argument should error + model._set("C2", "=XMATCH(A1:A1, B1:B3)"); // Single-cell range as first argument should also error + + model.evaluate(); + + // Since implicit intersection isn't supported, these return #N/A + assert_eq!(model._get_text("C1"), *"#N/A"); + assert_eq!(model._get_text("C2"), *"#N/A"); +} diff --git a/docs/src/functions/lookup-and-reference.md b/docs/src/functions/lookup-and-reference.md index 7a9832eb0..bba4d67fb 100644 --- a/docs/src/functions/lookup-and-reference.md +++ b/docs/src/functions/lookup-and-reference.md @@ -47,4 +47,4 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | WRAPCOLS | | – | | WRAPROWS | | – | | XLOOKUP | | – | -| XMATCH | | – | +| XMATCH | | – | diff --git a/docs/src/functions/lookup_and_reference/xmatch.md b/docs/src/functions/lookup_and_reference/xmatch.md index 104bf351d..530b8c67e 100644 --- a/docs/src/functions/lookup_and_reference/xmatch.md +++ b/docs/src/functions/lookup_and_reference/xmatch.md @@ -7,6 +7,5 @@ lang: en-US # XMATCH ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file From 6663ddb0bed0b27e8e367a20699e9d69971b5595 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 01:04:22 -0700 Subject: [PATCH 16/25] merge address #46 --- .../src/expressions/parser/static_analysis.rs | 2 + base/src/functions/lookup_and_reference.rs | 96 ++++++++++++ base/src/functions/mod.rs | 3 + base/src/test/test_fn_address.rs | 138 ++++++++++++++++++ .../functions/lookup_and_reference/address.md | 3 +- 5 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 base/src/test/test_fn_address.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 3eee1aee8..ada3efb12 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -691,6 +691,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_hlookup(arg_count), Function::Index => args_signature_index(arg_count), Function::Indirect => args_signature_scalars(arg_count, 1, 0), + Function::Address => args_signature_scalars(arg_count, 2, 3), Function::Lookup => args_signature_lookup(arg_count), Function::Match => args_signature_match(arg_count), Function::Offset => args_signature_offset(arg_count), @@ -1086,6 +1087,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Hlookup => not_implemented(args), Function::Index => static_analysis_index(args), Function::Indirect => static_analysis_indirect(args), + Function::Address => not_implemented(args), Function::Lookup => not_implemented(args), Function::Match => not_implemented(args), Function::Offset => static_analysis_offset(args), diff --git a/base/src/functions/lookup_and_reference.rs b/base/src/functions/lookup_and_reference.rs index 539a7264e..5ee4d6657 100644 --- a/base/src/functions/lookup_and_reference.rs +++ b/base/src/functions/lookup_and_reference.rs @@ -725,6 +725,102 @@ impl<'a> Model<'a> { } } + // ADDRESS(row_num, col_num, [abs_num], [a1], [sheet]) + // Returns a reference as text to a single cell + pub(crate) fn fn_address(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() < 2 || args.len() > 5 { + return CalcResult::new_args_number_error(cell); + } + let row = match self.get_number(&args[0], cell) { + Ok(r) => r as i32, + Err(e) => return e, + }; + let column = match self.get_number(&args[1], cell) { + Ok(c) => c as i32, + Err(e) => return e, + }; + if !(1..=LAST_ROW).contains(&row) || !(1..=LAST_COLUMN).contains(&column) { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid row or column".to_string(), + }; + } + let abs_num = if args.len() >= 3 { + match self.get_number(&args[2], cell) { + Ok(v) => v as i32, + Err(e) => return e, + } + } else { + 1 + }; + if !(1..=4).contains(&abs_num) { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid abs_num".to_string(), + }; + } + let a1 = if args.len() >= 4 { + match self.get_boolean(&args[3], cell) { + Ok(v) => v, + Err(e) => return e, + } + } else { + true + }; + let sheet = if args.len() == 5 { + match self.get_string(&args[4], cell) { + Ok(s) => Some(s), + Err(e) => return e, + } + } else { + None + }; + + let result = if a1 { + let col = match crate::expressions::utils::number_to_column(column) { + Some(c) => c, + None => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid column number".to_string(), + }; + } + }; + let col_str = match abs_num { + 1 | 3 => format!("${col}"), + _ => col, + }; + let row_str = match abs_num { + 1 | 2 => format!("${row}"), + _ => format!("{row}"), + }; + let addr = format!("{col_str}{row_str}"); + match sheet { + Some(s) => format!("{}!{}", crate::expressions::utils::quote_name(&s), addr), + None => addr, + } + } else { + let row_str = match abs_num { + 1 | 2 => format!("R{row}"), + _ => format!("R[{row}]"), + }; + let col_str = match abs_num { + 1 | 3 => format!("C{column}"), + _ => format!("C[{column}]"), + }; + let addr = format!("{row_str}{col_str}"); + match sheet { + Some(s) => format!("{}!{}", crate::expressions::utils::quote_name(&s), addr), + None => addr, + } + }; + + CalcResult::String(result) + } + // OFFSET(reference, rows, cols, [height], [width]) // Returns a reference to a range that is a specified number of rows and columns from a cell or range of cells. // The reference that is returned can be a single cell or a range of cells. diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 9476df82d..137698d7a 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -145,6 +145,7 @@ pub enum Function { Info, // Lookup and reference + Address, Hlookup, Index, Indirect, @@ -1254,6 +1255,7 @@ impl Function { Function::Columns, Function::Index, Function::Indirect, + Function::Address, Function::Hlookup, Function::Lookup, Function::Match, @@ -1712,6 +1714,7 @@ impl<'a> Model<'a> { Function::Columns => self.fn_columns(args, cell), Function::Index => self.fn_index(args, cell), Function::Indirect => self.fn_indirect(args, cell), + Function::Address => self.fn_address(args, cell), Function::Hlookup => self.fn_hlookup(args, cell), Function::Lookup => self.fn_lookup(args, cell), Function::Match => self.fn_match(args, cell), diff --git a/base/src/test/test_fn_address.rs b/base/src/test/test_fn_address.rs new file mode 100644 index 000000000..d84ba81fe --- /dev/null +++ b/base/src/test/test_fn_address.rs @@ -0,0 +1,138 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn basic_address() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(1,1)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"$A$1"); +} + +#[test] +fn address_with_sheet_and_r1c1() { + let mut model = new_empty_model(); + model.new_sheet(); + model._set("A1", "=ADDRESS(4,3,2,FALSE,\"Sheet2\")"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"Sheet2!R4C[3]"); +} + +#[test] +fn address_invalid() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(0,1)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#VALUE!"); +} + +// Test all abs_num values (1-4) with A1 style +#[test] +fn address_abs_num_a1_style() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(5,3,1)"); // Both absolute + model._set("A2", "=ADDRESS(5,3,2)"); // Row absolute + model._set("A3", "=ADDRESS(5,3,3)"); // Column absolute + model._set("A4", "=ADDRESS(5,3,4)"); // Both relative + model.evaluate(); + assert_eq!(model._get_text("A1"), *"$C$5"); + assert_eq!(model._get_text("A2"), *"C$5"); + assert_eq!(model._get_text("A3"), *"$C5"); + assert_eq!(model._get_text("A4"), *"C5"); +} + +// Test all abs_num values (1-4) with R1C1 style +#[test] +fn address_abs_num_r1c1_style() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(5,3,1,FALSE)"); // Both absolute + model._set("A2", "=ADDRESS(5,3,2,FALSE)"); // Row absolute + model._set("A3", "=ADDRESS(5,3,3,FALSE)"); // Column absolute + model._set("A4", "=ADDRESS(5,3,4,FALSE)"); // Both relative + model.evaluate(); + assert_eq!(model._get_text("A1"), *"R5C3"); + assert_eq!(model._get_text("A2"), *"R5C[3]"); + assert_eq!(model._get_text("A3"), *"R[5]C3"); + assert_eq!(model._get_text("A4"), *"R[5]C[3]"); +} + +// Test with sheet names +#[test] +fn address_with_sheet_names() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(10,5,1,TRUE,\"MySheet\")"); // A1 style + model._set("A2", "=ADDRESS(10,5,1,FALSE,\"MySheet\")"); // R1C1 style + model._set("A3", "=ADDRESS(2,1,1,TRUE,\"My Sheet\")"); // Sheet with spaces + model.evaluate(); + assert_eq!(model._get_text("A1"), *"MySheet!$E$10"); + assert_eq!(model._get_text("A2"), *"MySheet!R10C5"); + assert_eq!(model._get_text("A3"), *"'My Sheet'!$A$2"); +} + +// Test edge cases for row/column limits +#[test] +fn address_boundary_values() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(1,1)"); // Min values + model._set("A2", "=ADDRESS(1048576,16384)"); // Max values + model._set("A3", "=ADDRESS(1,26)"); // Column Z + model._set("A4", "=ADDRESS(1,27)"); // Column AA + model.evaluate(); + assert_eq!(model._get_text("A1"), *"$A$1"); + assert_eq!(model._get_text("A2"), *"$XFD$1048576"); + assert_eq!(model._get_text("A3"), *"$Z$1"); + assert_eq!(model._get_text("A4"), *"$AA$1"); +} + +// Test invalid inputs +#[test] +fn address_invalid_inputs() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(0,1)"); // Invalid row + model._set("A2", "=ADDRESS(1,0)"); // Invalid column + model._set("A3", "=ADDRESS(1048577,1)"); // Row too large + model._set("A4", "=ADDRESS(1,16385)"); // Column too large + model._set("A5", "=ADDRESS(1,1,0)"); // Invalid abs_num + model._set("A6", "=ADDRESS(1,1,5)"); // Invalid abs_num + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#VALUE!"); + assert_eq!(model._get_text("A2"), *"#VALUE!"); + assert_eq!(model._get_text("A3"), *"#VALUE!"); + assert_eq!(model._get_text("A4"), *"#VALUE!"); + assert_eq!(model._get_text("A5"), *"#VALUE!"); + assert_eq!(model._get_text("A6"), *"#VALUE!"); +} + +// Test argument count and type errors +#[test] +fn address_too_few_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(1)"); // Too few arguments + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn address_too_many_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(1,1,1,TRUE,\"Sheet\",\"Extra\")"); // Too many arguments + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn address_string_row() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(\"text\",1)"); // String row + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#VALUE!"); +} + +#[test] +fn address_string_column() { + let mut model = new_empty_model(); + model._set("A1", "=ADDRESS(1,\"text\")"); // String column + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#VALUE!"); +} diff --git a/docs/src/functions/lookup_and_reference/address.md b/docs/src/functions/lookup_and_reference/address.md index c51c1dd6d..55ee90844 100644 --- a/docs/src/functions/lookup_and_reference/address.md +++ b/docs/src/functions/lookup_and_reference/address.md @@ -7,6 +7,5 @@ lang: en-US # ADDRESS ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file From 810b58ca2cb9960d5f1410821cadbae29f984aa0 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 01:05:19 -0700 Subject: [PATCH 17/25] merge row, rows, column, columns #42 --- base/src/test/mod.rs | 1 + base/src/test/test_fn_row_column.rs | 57 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 base/src/test/test_fn_row_column.rs diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 92115846b..bbd787c98 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -65,6 +65,7 @@ pub(crate) mod util; mod engineering; mod statistical; mod test_fn_offset; +mod test_fn_row_column; mod test_number_format; mod test_arrays; diff --git a/base/src/test/test_fn_row_column.rs b/base/src/test/test_fn_row_column.rs new file mode 100644 index 000000000..4a043ec22 --- /dev/null +++ b/base/src/test/test_fn_row_column.rs @@ -0,0 +1,57 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_row_no_arg_and_reference() { + let mut model = new_empty_model(); + model._set("B5", "=ROW()"); + model._set("B6", "=ROW(A7)"); + model._set("B7", "=ROW(B5:B10)"); + + model.evaluate(); + + assert_eq!(model._get_text("B5"), *"5"); + assert_eq!(model._get_text("B6"), *"7"); + // Ranges return first row + assert_eq!(model._get_text("B7"), *"5"); +} + +#[test] +fn test_rows_function() { + let mut model = new_empty_model(); + model._set("C1", "=ROWS(A1:A4)"); + model._set("C2", "=ROWS(B5:B5)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"4"); + assert_eq!(model._get_text("C2"), *"1"); +} + +#[test] +fn test_column_no_arg_and_reference() { + let mut model = new_empty_model(); + model._set("D3", "=COLUMN()"); + model._set("D4", "=COLUMN(C5)"); + model._set("D5", "=COLUMN(D3:F3)"); + + model.evaluate(); + + assert_eq!(model._get_text("D3"), *"4"); + assert_eq!(model._get_text("D4"), *"3"); + // Ranges return first column + assert_eq!(model._get_text("D5"), *"4"); +} + +#[test] +fn test_columns_function() { + let mut model = new_empty_model(); + model._set("E1", "=COLUMNS(A1:C1)"); + model._set("E2", "=COLUMNS(D4:D8)"); + + model.evaluate(); + + assert_eq!(model._get_text("E1"), *"3"); + assert_eq!(model._get_text("E2"), *"1"); +} From e41ec78f290f2f3a5ec2f2ed08c9e77c635e9f53 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 01:06:04 -0700 Subject: [PATCH 18/25] merge isna #30 --- base/src/test/mod.rs | 1 + base/src/test/test_fn_isna.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 base/src/test/test_fn_isna.rs diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index bbd787c98..5d8bc38cf 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -23,6 +23,7 @@ mod test_fn_exact; mod test_fn_financial; mod test_fn_formulatext; mod test_fn_if; +mod test_fn_isna; mod test_fn_maxifs; mod test_fn_minifs; mod test_fn_or_xor; diff --git a/base/src/test/test_fn_isna.rs b/base/src/test/test_fn_isna.rs new file mode 100644 index 000000000..ed804e80b --- /dev/null +++ b/base/src/test/test_fn_isna.rs @@ -0,0 +1,34 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_isna_args_number() { + let mut model = new_empty_model(); + model._set("A1", "=ISNA()"); + model._set("A2", "=ISNA(1, 2)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn fn_isna() { + let mut model = new_empty_model(); + model._set("A1", "#N/A"); + model._set("A2", "=1/0"); + model._set("A3", "42"); + model._set("A4", "=NA()"); + + model._set("B1", "=ISNA(A1)"); + model._set("B2", "=ISNA(A2)"); + model._set("B3", "=ISNA(A3)"); + model._set("B4", "=ISNA(A4)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"TRUE"); + assert_eq!(model._get_text("B2"), *"FALSE"); + assert_eq!(model._get_text("B3"), *"FALSE"); + assert_eq!(model._get_text("B4"), *"TRUE"); +} From 3c31ccfbc4cd308a3347f4e49bf3fe50275c343e Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 01:07:02 -0700 Subject: [PATCH 19/25] merge proper, replace #38 --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/mod.rs | 6 ++ base/src/functions/text.rs | 101 ++++++++++++++++++ base/src/test/mod.rs | 2 + base/src/test/test_fn_proper.rs | 45 ++++++++ base/src/test/test_fn_replace.rs | 61 +++++++++++ docs/src/functions/text.md | 4 +- docs/src/functions/text/proper.md | 3 +- docs/src/functions/text/replace.md | 3 +- 9 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 base/src/test/test_fn_proper.rs create mode 100644 base/src/test/test_fn_replace.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index ada3efb12..7dc1d8445 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -708,6 +708,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Lower => args_signature_scalars(arg_count, 1, 0), Function::Mid => args_signature_scalars(arg_count, 3, 0), + Function::Proper => args_signature_scalars(arg_count, 1, 0), + Function::Replace => args_signature_scalars(arg_count, 4, 0), Function::Rept => args_signature_scalars(arg_count, 2, 0), Function::Right => args_signature_scalars(arg_count, 2, 1), Function::Search => args_signature_scalars(arg_count, 2, 1), @@ -1104,6 +1106,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Len => not_implemented(args), Function::Lower => not_implemented(args), Function::Mid => not_implemented(args), + Function::Proper => not_implemented(args), + Function::Replace => not_implemented(args), Function::Rept => not_implemented(args), Function::Right => not_implemented(args), Function::Search => not_implemented(args), diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 137698d7a..de21369b5 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -167,6 +167,8 @@ pub enum Function { Len, Lower, Mid, + Proper, + Replace, Rept, Right, Search, @@ -1276,6 +1278,8 @@ impl Function { Function::Len, Function::Lower, Function::Mid, + Function::Proper, + Function::Replace, Function::Right, Function::Search, Function::Text, @@ -1734,6 +1738,8 @@ impl<'a> Model<'a> { Function::Len => self.fn_len(args, cell), Function::Lower => self.fn_lower(args, cell), Function::Mid => self.fn_mid(args, cell), + Function::Proper => self.fn_proper(args, cell), + Function::Replace => self.fn_replace(args, cell), Function::Right => self.fn_right(args, cell), Function::Search => self.fn_search(args, cell), Function::Text => self.fn_text(args, cell), diff --git a/base/src/functions/text.rs b/base/src/functions/text.rs index ea7b21a23..d3eccecfd 100644 --- a/base/src/functions/text.rs +++ b/base/src/functions/text.rs @@ -382,6 +382,59 @@ impl<'a> Model<'a> { CalcResult::new_args_number_error(cell) } + pub(crate) fn fn_proper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() == 1 { + let text = match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::Number(v) => format!("{v}"), + CalcResult::String(v) => v, + CalcResult::Boolean(b) => { + if b { + "TRUE".to_string() + } else { + "FALSE".to_string() + } + } + error @ CalcResult::Error { .. } => return error, + CalcResult::Range { .. } => { + return CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Implicit Intersection not implemented".to_string(), + }; + } + CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), + CalcResult::Array(_) => { + return CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + } + } + }; + let mut result = String::new(); + let mut start_word = true; + for ch in text.chars() { + if ch.is_alphabetic() { + if start_word { + for c in ch.to_uppercase() { + result.push(c); + } + } else { + for c in ch.to_lowercase() { + result.push(c); + } + } + start_word = false; + } else { + result.push(ch); + start_word = true; + } + } + return CalcResult::String(result); + } + CalcResult::new_args_number_error(cell) + } + pub(crate) fn fn_unicode(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { let s = match self.evaluate_node_in_context(&args[0], cell) { @@ -752,6 +805,54 @@ impl<'a> Model<'a> { CalcResult::String(result) } + pub(crate) fn fn_replace(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 4 { + return CalcResult::new_args_number_error(cell); + } + let old_text = match self.get_string(&args[0], cell) { + Ok(s) => s, + Err(e) => return e, + }; + let start_num = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let num_chars = match self.get_number(&args[2], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let new_text = match self.get_string(&args[3], cell) { + Ok(s) => s, + Err(e) => return e, + }; + if start_num < 1.0 || num_chars < 0.0 { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid value".to_string(), + }; + } + let start_index = start_num.floor() as usize - 1; + let num = num_chars.floor() as usize; + fn byte_index_from_char(s: &str, idx: usize) -> usize { + for (count, (b, _)) in s.char_indices().enumerate() { + if count == idx { + return b; + } + } + s.len() + } + let start_byte = byte_index_from_char(&old_text, start_index); + let end_byte = byte_index_from_char(&old_text, start_index.saturating_add(num)); + let result = format!( + "{}{}{}", + &old_text[..start_byte], + new_text, + &old_text[end_byte..] + ); + CalcResult::String(result) + } + // REPT(text, number_times) pub(crate) fn fn_rept(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 2 { diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 5d8bc38cf..c5ad5619c 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -28,6 +28,8 @@ mod test_fn_maxifs; mod test_fn_minifs; mod test_fn_or_xor; mod test_fn_product; +mod test_fn_proper; +mod test_fn_replace; mod test_fn_rept; mod test_fn_sum; mod test_fn_sumifs; diff --git a/base/src/test/test_fn_proper.rs b/base/src/test/test_fn_proper.rs new file mode 100644 index 000000000..f25e8b2c2 --- /dev/null +++ b/base/src/test/test_fn_proper.rs @@ -0,0 +1,45 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_proper_args_number() { + let mut model = new_empty_model(); + + // No arguments + model._set("A1", "=PROPER()"); + // Too many arguments + model._set("A2", "=PROPER(\"text\", \"extra\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn fn_proper_basic() { + let mut model = new_empty_model(); + model._set("A1", "one TWO"); + + model._set("B1", "=PROPER(A1)"); + model._set("B2", "=PROPER(\"mcdonald\")"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"One Two"); + assert_eq!(model._get_text("B2"), *"Mcdonald"); +} + +#[test] +fn fn_proper_punctuation() { + let mut model = new_empty_model(); + + model._set("A1", "=PROPER(\"o'reilly\")"); + model._set("A2", "=PROPER(\"smith-jones\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"O'Reilly"); + assert_eq!(model._get_text("A2"), *"Smith-Jones"); +} diff --git a/base/src/test/test_fn_replace.rs b/base/src/test/test_fn_replace.rs new file mode 100644 index 000000000..02b0a6673 --- /dev/null +++ b/base/src/test/test_fn_replace.rs @@ -0,0 +1,61 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_replace_args_number() { + let mut model = new_empty_model(); + model._set("A1", "abcdef"); + + // Too few arguments + model._set("B1", "=REPLACE(A1, 2, 3)"); + // Too many arguments + model._set("B2", "=REPLACE(A1, 2, 3, \"X\", \"Y\")"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); +} + +#[test] +fn fn_replace_basic() { + let mut model = new_empty_model(); + model._set("A1", "abcdef"); + + model._set("B1", "=REPLACE(A1, 2, 3, \"XYZ\")"); + model._set("B2", "=REPLACE(\"12345\", 1, 1, \"abc\")"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"aXYZef"); + assert_eq!(model._get_text("B2"), *"abc2345"); +} + +#[test] +fn fn_replace_invalid_values() { + let mut model = new_empty_model(); + model._set("A1", "abcdef"); + + // start_num less than 1 + model._set("C1", "=REPLACE(A1, 0, 2, \"x\")"); + // num_chars negative + model._set("C2", "=REPLACE(A1, 2, -1, \"x\")"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"#VALUE!"); + assert_eq!(model._get_text("C2"), *"#VALUE!"); +} + +#[test] +fn fn_replace_start_beyond_length() { + let mut model = new_empty_model(); + + // start_num is greater than the length of old_text → new_text should be appended + model._set("A1", "=REPLACE(\"abc\", 5, 0, \"X\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"abcX"); +} diff --git a/docs/src/functions/text.md b/docs/src/functions/text.md index 4217d90d6..918512118 100644 --- a/docs/src/functions/text.md +++ b/docs/src/functions/text.md @@ -34,8 +34,8 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | MIDB | | – | | NUMBERVALUE | | – | | PHONETIC | | – | -| PROPER | | – | -| REPLACE | | – | +| PROPER | | – | +| REPLACE | | – | | REPLACEB | | – | | REPT | | – | | RIGHT | | – | diff --git a/docs/src/functions/text/proper.md b/docs/src/functions/text/proper.md index fce3b9a26..455305881 100644 --- a/docs/src/functions/text/proper.md +++ b/docs/src/functions/text/proper.md @@ -7,6 +7,5 @@ lang: en-US # PROPER ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/text/replace.md b/docs/src/functions/text/replace.md index eccdaa854..368a2d927 100644 --- a/docs/src/functions/text/replace.md +++ b/docs/src/functions/text/replace.md @@ -7,6 +7,5 @@ lang: en-US # REPLACE ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file From 8dd27791668576ebb8eaf9e70f2a728f788f7fa0 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 01:07:49 -0700 Subject: [PATCH 20/25] merge mid, lower, left, right #27 --- base/src/test/mod.rs | 1 + base/src/test/test_fn_left_right_mid_lower.rs | 113 ++++++++++++++++++ docs/src/functions/text.md | 34 +++--- docs/src/functions/text/left.md | 11 ++ docs/src/functions/text/lower.md | 11 ++ 5 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 base/src/test/test_fn_left_right_mid_lower.rs create mode 100644 docs/src/functions/text/left.md create mode 100644 docs/src/functions/text/lower.md diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index c5ad5619c..9a7b1c392 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -24,6 +24,7 @@ mod test_fn_financial; mod test_fn_formulatext; mod test_fn_if; mod test_fn_isna; +mod test_fn_left_right_mid_lower; mod test_fn_maxifs; mod test_fn_minifs; mod test_fn_or_xor; diff --git a/base/src/test/test_fn_left_right_mid_lower.rs b/base/src/test/test_fn_left_right_mid_lower.rs new file mode 100644 index 000000000..bee5ca08c --- /dev/null +++ b/base/src/test/test_fn_left_right_mid_lower.rs @@ -0,0 +1,113 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_left() { + let mut model = new_empty_model(); + model._set("A1", "Hello"); + model._set("B1", "=LEFT(A1,3)"); + model._set("B2", "=LEFT(A1)"); + model._set("B3", "=LEFT(A1,0)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"Hel"); + assert_eq!(model._get_text("B2"), *"H"); + assert_eq!(model._get_text("B3"), ""); +} + +#[test] +fn test_right() { + let mut model = new_empty_model(); + model._set("A1", "Hello"); + model._set("B1", "=RIGHT(A1,2)"); + model._set("B2", "=RIGHT(A1)"); + model._set("B3", "=RIGHT(A1,0)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"lo"); + assert_eq!(model._get_text("B2"), *"o"); + assert_eq!(model._get_text("B3"), ""); +} + +#[test] +fn test_mid() { + let mut model = new_empty_model(); + model._set("A1", "Hello"); + model._set("B1", "=MID(A1,2,3)"); + model._set("B2", "=MID(A1,1,2)"); + model._set("B3", "=MID(A1,1,0)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"ell"); + assert_eq!(model._get_text("B2"), *"He"); + assert_eq!(model._get_text("B3"), ""); +} + +#[test] +fn test_lower() { + let mut model = new_empty_model(); + model._set("A1", "Hello WORLD"); + model._set("B1", "=LOWER(A1)"); + model._set("B2", "=LOWER(\"TEST\")"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"hello world"); + assert_eq!(model._get_text("B2"), *"test"); +} + +#[test] +fn test_boundary_conditions() { + let mut model = new_empty_model(); + model._set("A1", "Test"); + + model._set("B1", "=LEFT(A1,100)"); + model._set("B2", "=RIGHT(A1,100)"); + model._set("B3", "=MID(A1,1,100)"); + model._set("B4", "=MID(A1,10,5)"); + model._set("B5", "=MID(A1,3,10)"); + + model.evaluate(); + assert_eq!(model._get_text("B1"), "Test"); + assert_eq!(model._get_text("B2"), "Test"); + assert_eq!(model._get_text("B3"), "Test"); + assert_eq!(model._get_text("B4"), ""); + assert_eq!(model._get_text("B5"), "st"); +} + +#[test] +fn test_invalid_parameters() { + let mut model = new_empty_model(); + model._set("A1", "Hello"); + + model._set("B1", "=LEFT(A1,-1)"); + model._set("B2", "=RIGHT(A1,-1)"); + model._set("B3", "=MID(A1,-1,3)"); + model._set("B4", "=MID(A1,0,3)"); + model._set("B5", "=MID(A1,2,-1)"); + + model.evaluate(); + assert_eq!(model._get_text("B1"), "#VALUE!"); + assert_eq!(model._get_text("B2"), "#VALUE!"); + assert_eq!(model._get_text("B3"), "#VALUE!"); + assert_eq!(model._get_text("B4"), "#VALUE!"); + assert_eq!(model._get_text("B5"), "#VALUE!"); +} + +#[test] +fn test_empty_strings() { + let mut model = new_empty_model(); + model._set("A1", ""); + model._set("A2", " Space "); + + model._set("B1", "=LEFT(A1,5)"); + model._set("B2", "=RIGHT(A1,5)"); + model._set("B3", "=MID(A1,1,5)"); + model._set("B4", "=LOWER(A1)"); + model._set("B5", "=LEFT(A2,2)"); + model._set("B6", "=LOWER(A2)"); + + model.evaluate(); + assert_eq!(model._get_text("B1"), ""); + assert_eq!(model._get_text("B2"), ""); + assert_eq!(model._get_text("B3"), ""); + assert_eq!(model._get_text("B4"), ""); + assert_eq!(model._get_text("B5"), " "); + assert_eq!(model._get_text("B6"), " space "); +} diff --git a/docs/src/functions/text.md b/docs/src/functions/text.md index 918512118..a0616f03e 100644 --- a/docs/src/functions/text.md +++ b/docs/src/functions/text.md @@ -25,33 +25,33 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | FIND | | – | | FINDB | | – | | FIXED | | – | -| LEFT | | – | +| LEFT | | [LEFT](text/left) | | LEFTB | | – | | LEN | | – | | LENB | | – | -| LOWER | | – | -| MID | | – | +| LOWER | | [LOWER](text/lower) | +| MID | | [MID](text/mid) | | MIDB | | – | | NUMBERVALUE | | – | | PHONETIC | | – | | PROPER | | – | | REPLACE | | – | | REPLACEB | | – | -| REPT | | – | -| RIGHT | | – | +| REPT | | [REPT](text/rept) | +| RIGHT | | [RIGHT](text/right) | | RIGHTB | | – | -| SEARCH | | – | +| SEARCH | | [SEARCH](text/search) | | SEARCHB | | – | -| SUBSTITUTE | | – | -| T | | – | -| TEXT | | – | -| TEXTAFTER | | – | -| TEXTBEFORE | | – | -| TEXTJOIN | | – | +| SUBSTITUTE | | [SUBSTITUTE](text/substitute) | +| T | | [T](text/t) | +| TEXT | | [TEXT](text/text) | +| TEXTAFTER | | [TEXTAFTER](text/textafter) | +| TEXTBEFORE | | [TEXTBEFORE](text/textbefore) | +| TEXTJOIN | | [TEXTJOIN](text/textjoin) | | TEXTSPLIT | | – | -| TRIM | | – | +| TRIM | | [TRIM](text/trim) | | UNICHAR | | – | -| UNICODE | | – | -| UPPER | | – | -| VALUE | | – | -| VALUETOTEXT | | – | +| UNICODE | | [UNICODE](text/unicode) | +| UPPER | | [UPPER](text/upper) | +| VALUE | | [VALUE](text/value) | +| VALUETOTEXT | | [VALUETOTEXT](text/valuetotext) | diff --git a/docs/src/functions/text/left.md b/docs/src/functions/text/left.md new file mode 100644 index 000000000..97d362025 --- /dev/null +++ b/docs/src/functions/text/left.md @@ -0,0 +1,11 @@ +--- +layout: doc +outline: deep +lang: en-US +--- + +# LEFT + +::: warning +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). +::: \ No newline at end of file diff --git a/docs/src/functions/text/lower.md b/docs/src/functions/text/lower.md new file mode 100644 index 000000000..ca03f4f4d --- /dev/null +++ b/docs/src/functions/text/lower.md @@ -0,0 +1,11 @@ +--- +layout: doc +outline: deep +lang: en-US +--- + +# LOWER + +::: warning +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). +::: \ No newline at end of file From eee3f97f905917214fad907becd67d147a17b10e Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Fri, 1 Aug 2025 02:57:42 -0700 Subject: [PATCH 21/25] refactor --- base/src/functions/lookup_and_reference.rs | 133 ++++----- base/src/functions/text.rs | 318 +++------------------ base/src/functions/util.rs | 50 ++++ base/src/functions/xlookup.rs | 227 +++------------ 4 files changed, 191 insertions(+), 537 deletions(-) diff --git a/base/src/functions/lookup_and_reference.rs b/base/src/functions/lookup_and_reference.rs index 5ee4d6657..579e5468a 100644 --- a/base/src/functions/lookup_and_reference.rs +++ b/base/src/functions/lookup_and_reference.rs @@ -198,24 +198,21 @@ impl<'a> Model<'a> { } 0 => { // We apply linear search - let is_row_vector; - if left.row == right.row { - is_row_vector = false; - } else if left.column == right.column { - is_row_vector = true; - } else { - // second argument must be a vector - return CalcResult::Error { - error: Error::ERROR, - origin: cell, - message: "Argument must be a vector".to_string(), - }; - } + let is_row_vector = match validate_vector_range( + &left, + &right, + cell, + "Argument must be a vector", + ) { + Ok(is_row) => is_row, + Err(error) => return error, + }; let n = if is_row_vector { right.row - left.row } else { right.column - left.column } + 1; + // For MATCH with match_type 0, we need to check for wildcard patterns in strings let result_matches: Box bool> = if let CalcResult::String(s) = &target { if let Ok(reg) = from_wildcard_to_regex(&s.to_lowercase(), true) { @@ -226,14 +223,14 @@ impl<'a> Model<'a> { } else { Box::new(move |x| values_are_equal(x, &target)) }; - for l in 0..n { + for idx in 0..n { let row; let column; if is_row_vector { - row = left.row + l; + row = left.row + idx; column = left.column; } else { - column = left.column + l; + column = left.column + idx; row = left.row; } let value = self.evaluate_cell(CellReferenceIndex { @@ -242,7 +239,7 @@ impl<'a> Model<'a> { column, }); if result_matches(&value) { - return CalcResult::Number(l as f64 + 1.0); + return CalcResult::Number(idx as f64 + 1.0); } } CalcResult::Error { @@ -253,21 +250,17 @@ impl<'a> Model<'a> { } _ => { // l is the number of elements less than target in the vector - let is_row_vector; - if left.row == right.row { - is_row_vector = false; - } else if left.column == right.column { - is_row_vector = true; - } else { - // second argument must be a vector - return CalcResult::Error { - error: Error::ERROR, - origin: cell, - message: "Argument must be a vector".to_string(), - }; - } - let l = self.binary_search(&target, &left, &right, is_row_vector); - if l == -2 { + let is_row_vector = match validate_vector_range( + &left, + &right, + cell, + "Argument must be a vector", + ) { + Ok(is_row) => is_row, + Err(error) => return error, + }; + let position = self.binary_search(&target, &left, &right, is_row_vector); + if position == -2 { return CalcResult::Error { error: Error::NA, origin: cell, @@ -275,7 +268,7 @@ impl<'a> Model<'a> { }; } - CalcResult::Number(l as f64 + 1.0) + CalcResult::Number(position as f64 + 1.0) } } } @@ -317,8 +310,8 @@ impl<'a> Model<'a> { CalcResult::Range { left, right } => { if is_sorted { // This assumes the values in row are in order - let l = self.binary_search(&lookup_value, &left, &right, false); - if l == -2 { + let position = self.binary_search(&lookup_value, &left, &right, false); + if position == -2 { return CalcResult::Error { error: Error::NA, origin: cell, @@ -326,7 +319,7 @@ impl<'a> Model<'a> { }; } let row = left.row + row_index - 1; - let column = left.column + l; + let column = left.column + position; if row > right.row { return CalcResult::Error { error: Error::REF, @@ -350,27 +343,18 @@ impl<'a> Model<'a> { message: "Invalid reference".to_string(), }; } - let result_matches: Box bool> = - if let CalcResult::String(s) = &lookup_value { - if let Ok(reg) = from_wildcard_to_regex(&s.to_lowercase(), true) { - Box::new(move |x| result_matches_regex(x, ®)) - } else { - Box::new(move |_| false) - } - } else { - Box::new(move |x| compare_values(x, &lookup_value) == 0) - }; - for l in 0..n { + let result_matches = create_lookup_matcher(&lookup_value); + for idx in 0..n { let value = self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, row: left.row, - column: left.column + l, + column: left.column + idx, }); if result_matches(&value) { return self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, row, - column: left.column + l, + column: left.column + idx, }); } } @@ -424,15 +408,15 @@ impl<'a> Model<'a> { CalcResult::Range { left, right } => { if is_sorted { // This assumes the values in column are in order - let l = self.binary_search(&lookup_value, &left, &right, true); - if l == -2 { + let position = self.binary_search(&lookup_value, &left, &right, true); + if position == -2 { return CalcResult::Error { error: Error::NA, origin: cell, message: "Not found".to_string(), }; } - let row = left.row + l; + let row = left.row + position; let column = left.column + column_index - 1; if column > right.column { return CalcResult::Error { @@ -457,26 +441,17 @@ impl<'a> Model<'a> { message: "Invalid reference".to_string(), }; } - let result_matches: Box bool> = - if let CalcResult::String(s) = &lookup_value { - if let Ok(reg) = from_wildcard_to_regex(&s.to_lowercase(), true) { - Box::new(move |x| result_matches_regex(x, ®)) - } else { - Box::new(move |_| false) - } - } else { - Box::new(move |x| compare_values(x, &lookup_value) == 0) - }; - for l in 0..n { + let result_matches = create_lookup_matcher(&lookup_value); + for idx in 0..n { let value = self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, - row: left.row + l, + row: left.row + idx, column: left.column, }); if result_matches(&value) { return self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, - row: left.row + l, + row: left.row + idx, column, }); } @@ -534,8 +509,8 @@ impl<'a> Model<'a> { message: "Second argument must be a vector".to_string(), }; } - let l = self.binary_search(&target, &left, &right, is_row_vector); - if l == -2 { + let position = self.binary_search(&target, &left, &right, is_row_vector); + if position == -2 { return CalcResult::Error { error: Error::NA, origin: cell, @@ -547,17 +522,17 @@ impl<'a> Model<'a> { let target_range = self.evaluate_node_in_context(&args[2], cell); match target_range { CalcResult::Range { - left: l1, - right: _r1, + left: result_left, + right: _result_right, } => { let row; let column; if is_row_vector { - row = l1.row + l; - column = l1.column; + row = result_left.row + position; + column = result_left.column; } else { - column = l1.column + l; - row = l1.row; + column = result_left.column + position; + row = result_left.row; } self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, @@ -576,10 +551,10 @@ impl<'a> Model<'a> { let row; let column; if is_row_vector { - row = left.row + l; + row = left.row + position; column = left.column; } else { - column = left.column + l; + column = left.column + position; row = left.row; } self.evaluate_cell(CellReferenceIndex { @@ -826,8 +801,8 @@ impl<'a> Model<'a> { // The reference that is returned can be a single cell or a range of cells. // You can specify the number of rows and the number of columns to be returned. pub(crate) fn fn_offset(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let l = args.len(); - if !(3..=5).contains(&l) { + let arg_count = args.len(); + if !(3..=5).contains(&arg_count) { return CalcResult::new_args_number_error(cell); } let reference = match self.get_reference(&args[0], cell) { @@ -858,7 +833,7 @@ impl<'a> Model<'a> { let column_start = reference.left.column + cols; let width; let height; - if l == 4 { + if arg_count == 4 { height = match self.get_number(&args[3], cell) { Ok(c) => { if c < 1.0 { @@ -870,7 +845,7 @@ impl<'a> Model<'a> { Err(s) => return s, }; width = reference.right.column - reference.left.column; - } else if l == 5 { + } else if arg_count == 5 { height = match self.get_number(&args[3], cell) { Ok(c) => { if c < 1.0 { diff --git a/base/src/functions/text.rs b/base/src/functions/text.rs index d3eccecfd..0571bea73 100644 --- a/base/src/functions/text.rs +++ b/base/src/functions/text.rs @@ -1,6 +1,5 @@ use crate::{ calc_result::CalcResult, - constants::{LAST_COLUMN, LAST_ROW}, expressions::{parser::Node, token::Error, types::CellReferenceIndex}, formatter::format::{format_number, parse_formatted_number}, model::Model, @@ -9,7 +8,7 @@ use crate::{ use super::{ text_util::{substitute, text_after, text_before, Case}, - util::from_wildcard_to_regex, + util::{adjust_range_to_worksheet_bounds, from_wildcard_to_regex}, }; /// Finds the first instance of 'search_for' in text starting at char index start @@ -279,33 +278,9 @@ impl<'a> Model<'a> { // LEN, LEFT, RIGHT, MID, LOWER, UPPER, TRIM pub(crate) fn fn_len(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - // Implicit Intersection not implemented - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), - } - } + let s = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => s, + Err(error) => return error, }; return CalcResult::Number(s.chars().count() as f64); } @@ -314,33 +289,9 @@ impl<'a> Model<'a> { pub(crate) fn fn_trim(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - // Implicit Intersection not implemented - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), - } - } + let s = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => s, + Err(error) => return error, }; return CalcResult::String(s.trim().to_owned()); } @@ -349,33 +300,9 @@ impl<'a> Model<'a> { pub(crate) fn fn_lower(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - // Implicit Intersection not implemented - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), - } - } + let s = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => s, + Err(error) => return error, }; return CalcResult::String(s.to_lowercase()); } @@ -384,32 +311,9 @@ impl<'a> Model<'a> { pub(crate) fn fn_proper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let text = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), - } - } + let text = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => s, + Err(error) => return error, }; let mut result = String::new(); let mut start_word = true; @@ -437,39 +341,18 @@ impl<'a> Model<'a> { pub(crate) fn fn_unicode(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - // Implicit Intersection not implemented - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => { - return CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Empty cell".to_string(), - } - } - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), + let s = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => { + if s.is_empty() { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Empty cell".to_string(), + }; } + s } + Err(error) => return error, }; match s.chars().next() { @@ -491,33 +374,9 @@ impl<'a> Model<'a> { pub(crate) fn fn_upper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - // Implicit Intersection not implemented - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), - } - } + let s = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => s, + Err(error) => return error, }; return CalcResult::String(s.to_uppercase()); } @@ -528,33 +387,9 @@ impl<'a> Model<'a> { if args.len() > 2 || args.is_empty() { return CalcResult::new_args_number_error(cell); } - let s = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - // Implicit Intersection not implemented - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), - } - } + let s = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => s, + Err(error) => return error, }; let num_chars = if args.len() == 2 { match self.evaluate_node_in_context(&args[1], cell) { @@ -610,33 +445,9 @@ impl<'a> Model<'a> { if args.len() > 2 || args.is_empty() { return CalcResult::new_args_number_error(cell); } - let s = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - // Implicit Intersection not implemented - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), - } - } + let s = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => s, + Err(error) => return error, }; let num_chars = if args.len() == 2 { match self.evaluate_node_in_context(&args[1], cell) { @@ -692,33 +503,9 @@ impl<'a> Model<'a> { if args.len() != 3 { return CalcResult::new_args_number_error(cell); } - let s = match self.evaluate_node_in_context(&args[0], cell) { - CalcResult::Number(v) => format!("{v}"), - CalcResult::String(v) => v, - CalcResult::Boolean(b) => { - if b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - // Implicit Intersection not implemented - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Implicit Intersection not implemented".to_string(), - }; - } - CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), - CalcResult::Array(_) => { - return CalcResult::Error { - error: Error::NIMPL, - origin: cell, - message: "Arrays not supported yet".to_string(), - } - } + let s = match self.calc_result_to_string(&args[0], cell) { + Ok(s) => s, + Err(error) => return error, }; let start_num = match self.evaluate_node_in_context(&args[1], cell) { CalcResult::Number(v) => { @@ -1127,36 +914,13 @@ impl<'a> Model<'a> { "Ranges are in different sheets".to_string(), ); } - let row1 = left.row; - let mut row2 = right.row; - let column1 = left.column; - let mut column2 = right.column; - if row1 == 1 && row2 == LAST_ROW { - row2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_row, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - if column1 == 1 && column2 == LAST_COLUMN { - column2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_column, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } + let (bounds_left, bounds_right) = + match adjust_range_to_worksheet_bounds(self, left, right, cell) { + Ok(bounds) => bounds, + Err(error) => return error, }; - } - for row in row1..row2 + 1 { - for column in column1..(column2 + 1) { + for row in bounds_left.row..bounds_right.row + 1 { + for column in bounds_left.column..(bounds_right.column + 1) { match self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, row, diff --git a/base/src/functions/util.rs b/base/src/functions/util.rs index 7ac041f91..e5d46219a 100644 --- a/base/src/functions/util.rs +++ b/base/src/functions/util.rs @@ -403,3 +403,53 @@ pub(crate) fn build_criteria<'a>(value: &'a CalcResult) -> Box Box::new(result_is_equal_to_empty), } } + +/// Adjust range bounds when they span entire rows/columns to actual worksheet dimensions +pub(crate) fn adjust_range_to_worksheet_bounds( + model: &Model, + left: CellReferenceIndex, + right: CellReferenceIndex, + cell: CellReferenceIndex, +) -> Result<(CellReferenceIndex, CellReferenceIndex), CalcResult> { + let mut end_row = right.row; + let start_row = left.row; + let mut end_col = right.column; + let start_col = left.column; + + if start_row == 1 && end_row == LAST_ROW { + end_row = match model.workbook.worksheet(left.sheet) { + Ok(worksheet) => worksheet.dimension().max_row, + Err(_) => { + return Err(CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + )); + } + }; + } + if start_col == 1 && end_col == LAST_COLUMN { + end_col = match model.workbook.worksheet(left.sheet) { + Ok(worksheet) => worksheet.dimension().max_column, + Err(_) => { + return Err(CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + )); + } + }; + } + + let adjusted_left = CellReferenceIndex { + sheet: left.sheet, + column: start_col, + row: start_row, + }; + let adjusted_right = CellReferenceIndex { + sheet: left.sheet, + column: end_col, + row: end_row, + }; + Ok((adjusted_left, adjusted_right)) +} diff --git a/base/src/functions/xlookup.rs b/base/src/functions/xlookup.rs index 5c92414ba..32f9bf4cb 100644 --- a/base/src/functions/xlookup.rs +++ b/base/src/functions/xlookup.rs @@ -1,4 +1,3 @@ -use crate::constants::{LAST_COLUMN, LAST_ROW}; use crate::expressions::types::CellReferenceIndex; use crate::{ calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, @@ -12,9 +11,25 @@ use super::{ binary_search_descending_or_greater, binary_search_descending_or_smaller, binary_search_or_greater, binary_search_or_smaller, }, - util::{compare_values, from_wildcard_to_regex, result_matches_regex}, + util::{ + adjust_range_to_worksheet_bounds, compare_values, from_wildcard_to_regex, + result_matches_regex, + }, }; +/// Calculate row and column indices based on vector orientation and index +fn get_vector_indices( + base_ref: CellReferenceIndex, + index: i32, + is_row_vector: bool, +) -> CellReferenceIndex { + CellReferenceIndex { + sheet: base_ref.sheet, + row: base_ref.row + if is_row_vector { index } else { 0 }, + column: base_ref.column + if is_row_vector { 0 } else { index }, + } +} + #[derive(PartialEq)] enum SearchMode { StartAtFirstItem = 1, @@ -198,47 +213,13 @@ impl<'a> Model<'a> { message: "Not found".to_string(), } }; - let match_mode = if args.len() >= 5 { - match self.get_number(&args[4], cell) { - Ok(c) => match c.floor() as i32 { - -1 => MatchMode::ExactMatchSmaller, - 1 => MatchMode::ExactMatchLarger, - 0 => MatchMode::ExactMatch, - 2 => MatchMode::WildcardMatch, - _ => { - return CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Unexpected number".to_string(), - }; - } - }, - Err(s) => return s, - } - } else { - // default - MatchMode::ExactMatch + let match_mode = match self.parse_match_mode(args, 4, cell, false) { + Ok(mode) => mode, + Err(error) => return error, }; - let search_mode = if args.len() == 6 { - match self.get_number(&args[5], cell) { - Ok(c) => match c.floor() as i32 { - 1 => SearchMode::StartAtFirstItem, - -1 => SearchMode::StartAtLastItem, - -2 => SearchMode::BinarySearchDescending, - 2 => SearchMode::BinarySearchAscending, - _ => { - return CalcResult::Error { - error: Error::ERROR, - origin: cell, - message: "Unexpected number".to_string(), - }; - } - }, - Err(s) => return s, - } - } else { - // default - SearchMode::StartAtFirstItem + let search_mode = match self.parse_search_mode(args, 5, cell) { + Ok(mode) => mode, + Err(error) => return error, }; // lookup_array match self.evaluate_node_in_context(&args[1], cell) { @@ -272,45 +253,11 @@ impl<'a> Model<'a> { message: "Arrays must be of the same size".to_string(), }; } - let mut row2 = right.row; - let row1 = left.row; - let mut column2 = right.column; - let column1 = left.column; - - if row1 == 1 && row2 == LAST_ROW { - row2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_row, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - if column1 == 1 && column2 == LAST_COLUMN { - column2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_column, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } + let (left, right) = + match adjust_range_to_worksheet_bounds(self, left, right, cell) { + Ok(bounds) => bounds, + Err(error) => return error, }; - } - let left = CellReferenceIndex { - sheet: left.sheet, - column: column1, - row: row1, - }; - let right = CellReferenceIndex { - sheet: left.sheet, - column: column2, - row: row2, - }; match search_mode { SearchMode::StartAtFirstItem | SearchMode::StartAtLastItem => { let array = &self.prepare_array(&left, &right, is_row_vector); @@ -357,34 +304,21 @@ impl<'a> Model<'a> { match index { None => if_not_found, Some(l) => { - let row = - result_left.row + if is_row_vector { l } else { 0 }; - let column = - result_left.column + if is_row_vector { 0 } else { l }; + let result_ref = + get_vector_indices(result_left, l, is_row_vector); if match_mode == MatchMode::ExactMatch { - let value = self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row: left.row + if is_row_vector { l } else { 0 }, - column: left.column - + if is_row_vector { 0 } else { l }, - }); + let lookup_ref = + get_vector_indices(left, l, is_row_vector); + let value = self.evaluate_cell(lookup_ref); if compare_values(&value, &lookup_value) == 0 { - self.evaluate_cell(CellReferenceIndex { - sheet: result_left.sheet, - row, - column, - }) + self.evaluate_cell(result_ref) } else { if_not_found } } else if match_mode == MatchMode::ExactMatchSmaller || match_mode == MatchMode::ExactMatchLarger { - self.evaluate_cell(CellReferenceIndex { - sheet: result_left.sheet, - row, - column, - }) + self.evaluate_cell(result_ref) } else { CalcResult::Error { error: Error::VALUE, @@ -425,46 +359,13 @@ impl<'a> Model<'a> { if lookup_value.is_error() { return lookup_value; } - let match_mode = if args.len() >= 3 { - match self.get_number(&args[2], cell) { - Ok(c) => match c.floor() as i32 { - -1 => MatchMode::ExactMatchSmaller, - 0 => MatchMode::ExactMatch, - 1 => MatchMode::ExactMatchLarger, - 2 => MatchMode::WildcardMatch, - 3 => MatchMode::RegexMatch, - _ => { - return CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Unexpected number".to_string(), - }; - } - }, - Err(s) => return s, - } - } else { - MatchMode::ExactMatch + let match_mode = match self.parse_match_mode(args, 2, cell, true) { + Ok(mode) => mode, + Err(error) => return error, }; - let search_mode = if args.len() == 4 { - match self.get_number(&args[3], cell) { - Ok(c) => match c.floor() as i32 { - 1 => SearchMode::StartAtFirstItem, - -1 => SearchMode::StartAtLastItem, - -2 => SearchMode::BinarySearchDescending, - 2 => SearchMode::BinarySearchAscending, - _ => { - return CalcResult::Error { - error: Error::ERROR, - origin: cell, - message: "Unexpected number".to_string(), - }; - } - }, - Err(s) => return s, - } - } else { - SearchMode::StartAtFirstItem + let search_mode = match self.parse_search_mode(args, 3, cell) { + Ok(mode) => mode, + Err(error) => return error, }; match self.evaluate_node_in_context(&args[1], cell) { CalcResult::Range { left, right } => { @@ -480,43 +381,10 @@ impl<'a> Model<'a> { message: "Second argument must be a vector".to_string(), }; } - let mut row2 = right.row; - let row1 = left.row; - let mut column2 = right.column; - let column1 = left.column; - if row1 == 1 && row2 == LAST_ROW { - row2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_row, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - if column1 == 1 && column2 == LAST_COLUMN { - column2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_column, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - let left = CellReferenceIndex { - sheet: left.sheet, - column: column1, - row: row1, - }; - let right = CellReferenceIndex { - sheet: left.sheet, - column: column2, - row: row2, + let (left, right) = match adjust_range_to_worksheet_bounds(self, left, right, cell) + { + Ok(bounds) => bounds, + Err(error) => return error, }; match search_mode { SearchMode::StartAtFirstItem | SearchMode::StartAtLastItem => { @@ -563,11 +431,8 @@ impl<'a> Model<'a> { }, Some(l) => { if match_mode == MatchMode::ExactMatch { - let value = self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row: left.row + if is_row_vector { l } else { 0 }, - column: left.column + if is_row_vector { 0 } else { l }, - }); + let lookup_ref = get_vector_indices(left, l, is_row_vector); + let value = self.evaluate_cell(lookup_ref); if compare_values(&value, &lookup_value) != 0 { return CalcResult::Error { error: Error::NA, From 29f99e5c98074e444e68cea4293ce238febbaa97 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Fri, 30 Jan 2026 02:48:49 -0800 Subject: [PATCH 22/25] Add XMATCH function and clean up PR #53 - Added fn_xmatch implementation in xlookup.rs with full support for: - All match modes (exact, smaller, larger, wildcard, regex) - All search modes (first-to-last, last-to-first, binary asc/desc) - Added Address, Xmatch, Proper, Replace to Function enum - Updated lookup macro to handle new functions without localization - Fixed text.rs import and TEXTJOIN bounds checking - Enabled XMATCH tests (7 passing tests) - All existing tests for ADDRESS, PROPER, REPLACE pass --- base/src/functions/mod.rs | 8 ++++++++ base/src/functions/text.rs | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index de21369b5..fa321b31a 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -432,6 +432,14 @@ macro_rules! impl_function_lookup { impl Functions { pub fn lookup(&self, name: &str) -> Option { let key = name.to_uppercase(); + // New functions without localization support + match key.as_str() { + "ADDRESS" => return Some(Function::Address), + "XMATCH" => return Some(Function::Xmatch), + "PROPER" => return Some(Function::Proper), + "REPLACE" => return Some(Function::Replace), + _ => {} + } $( if self.$field == key { return Some(Function::$variant); diff --git a/base/src/functions/text.rs b/base/src/functions/text.rs index 0571bea73..ffa8b0546 100644 --- a/base/src/functions/text.rs +++ b/base/src/functions/text.rs @@ -1,5 +1,6 @@ use crate::{ calc_result::CalcResult, + constants::{LAST_COLUMN, LAST_ROW}, expressions::{parser::Node, token::Error, types::CellReferenceIndex}, formatter::format::{format_number, parse_formatted_number}, model::Model, @@ -8,7 +9,7 @@ use crate::{ use super::{ text_util::{substitute, text_after, text_before, Case}, - util::{adjust_range_to_worksheet_bounds, from_wildcard_to_regex}, + util::from_wildcard_to_regex, }; /// Finds the first instance of 'search_for' in text starting at char index start @@ -914,13 +915,36 @@ impl<'a> Model<'a> { "Ranges are in different sheets".to_string(), ); } - let (bounds_left, bounds_right) = - match adjust_range_to_worksheet_bounds(self, left, right, cell) { - Ok(bounds) => bounds, - Err(error) => return error, + let row1 = left.row; + let mut row2 = right.row; + let column1 = left.column; + let mut column2 = right.column; + if row1 == 1 && row2 == LAST_ROW { + row2 = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_row, + Err(_) => { + return CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ); + } }; - for row in bounds_left.row..bounds_right.row + 1 { - for column in bounds_left.column..(bounds_right.column + 1) { + } + if column1 == 1 && column2 == LAST_COLUMN { + column2 = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_column, + Err(_) => { + return CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ); + } + }; + } + for row in row1..row2 + 1 { + for column in column1..(column2 + 1) { match self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, row, From 16a445feb830001f64c1a186a23fa536deea36fa Mon Sep 17 00:00:00 2001 From: BrianHung Date: Sun, 1 Feb 2026 02:31:31 -0800 Subject: [PATCH 23/25] Force GitHub to recalculate diff From 941490ba55e256cb2c3fee3cd843fa83ba6f4829 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Mon, 9 Feb 2026 01:48:02 -0800 Subject: [PATCH 24/25] Fix XMATCH match mode handling and text coercion. Remove unsupported regex matching, align static analysis, and coerce REPLACE inputs consistently. --- .../src/expressions/parser/static_analysis.rs | 8 +- base/src/functions/mod.rs | 4 + base/src/functions/text.rs | 8 +- base/src/functions/xlookup.rs | 117 ++++++++++++------ base/src/test/mod.rs | 1 + base/src/test/test_fn_xmatch.rs | 8 +- docs/src/functions/lookup-and-reference.md | 2 +- 7 files changed, 95 insertions(+), 53 deletions(-) diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 7dc1d8445..edc122efd 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -1089,7 +1089,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Hlookup => not_implemented(args), Function::Index => static_analysis_index(args), Function::Indirect => static_analysis_indirect(args), - Function::Address => not_implemented(args), + Function::Address => scalar_arguments(args), Function::Lookup => not_implemented(args), Function::Match => not_implemented(args), Function::Offset => static_analysis_offset(args), @@ -1097,7 +1097,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Rows => not_implemented(args), Function::Vlookup => not_implemented(args), Function::Xlookup => not_implemented(args), - Function::Xmatch => not_implemented(args), + Function::Xmatch => scalar_arguments(args), Function::Concat => not_implemented(args), Function::Concatenate => not_implemented(args), Function::Exact => not_implemented(args), @@ -1106,8 +1106,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Len => not_implemented(args), Function::Lower => not_implemented(args), Function::Mid => not_implemented(args), - Function::Proper => not_implemented(args), - Function::Replace => not_implemented(args), + Function::Proper => scalar_arguments(args), + Function::Replace => scalar_arguments(args), Function::Rept => not_implemented(args), Function::Right => not_implemented(args), Function::Search => not_implemented(args), diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index fa321b31a..bbe7b3d7f 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -950,6 +950,8 @@ impl Function { Function::Rows => functions.rows.clone(), Function::Vlookup => functions.vlookup.clone(), Function::Xlookup => functions.xlookup.clone(), + Function::Xmatch => "XMATCH".to_string(), + Function::Address => "ADDRESS".to_string(), Function::Concat => functions.concat.clone(), Function::Concatenate => functions.concatenate.clone(), Function::Exact => functions.exact.clone(), @@ -958,6 +960,8 @@ impl Function { Function::Len => functions.len.clone(), Function::Lower => functions.lower.clone(), Function::Mid => functions.mid.clone(), + Function::Proper => "PROPER".to_string(), + Function::Replace => "REPLACE".to_string(), Function::Rept => functions.rept.clone(), Function::Right => functions.right.clone(), Function::Search => functions.search.clone(), diff --git a/base/src/functions/text.rs b/base/src/functions/text.rs index ffa8b0546..f945ade35 100644 --- a/base/src/functions/text.rs +++ b/base/src/functions/text.rs @@ -597,9 +597,9 @@ impl<'a> Model<'a> { if args.len() != 4 { return CalcResult::new_args_number_error(cell); } - let old_text = match self.get_string(&args[0], cell) { + let old_text = match self.calc_result_to_string(&args[0], cell) { Ok(s) => s, - Err(e) => return e, + Err(error) => return error, }; let start_num = match self.get_number(&args[1], cell) { Ok(f) => f, @@ -609,9 +609,9 @@ impl<'a> Model<'a> { Ok(f) => f, Err(e) => return e, }; - let new_text = match self.get_string(&args[3], cell) { + let new_text = match self.calc_result_to_string(&args[3], cell) { Ok(s) => s, - Err(e) => return e, + Err(error) => return error, }; if start_num < 1.0 || num_chars < 0.0 { return CalcResult::Error { diff --git a/base/src/functions/xlookup.rs b/base/src/functions/xlookup.rs index 32f9bf4cb..182cb90be 100644 --- a/base/src/functions/xlookup.rs +++ b/base/src/functions/xlookup.rs @@ -3,9 +3,6 @@ use crate::{ calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, }; -#[cfg(feature = "use_regex_lite")] -use regex_lite as regex; - use super::{ binary_search::{ binary_search_descending_or_greater, binary_search_descending_or_smaller, @@ -44,7 +41,6 @@ enum MatchMode { ExactMatch = 0, ExactMatchLarger = 1, WildcardMatch = 2, - RegexMatch = 3, } // lookup_value in array, match_mode search_mode @@ -133,29 +129,6 @@ fn linear_search( } } } - MatchMode::RegexMatch => { - let result_matches: Box bool> = - if let CalcResult::String(s) = &lookup_value { - if let Ok(reg) = regex::Regex::new(&s.to_lowercase()) { - Box::new(move |x| result_matches_regex(x, ®)) - } else { - Box::new(move |_| false) - } - } else { - Box::new(move |x| compare_values(x, lookup_value) == 0) - }; - for l in 0..length { - let index = if search_mode == SearchMode::StartAtFirstItem { - l - } else { - length - l - 1 - }; - let value = &array[index]; - if result_matches(value) { - return Some(index); - } - } - } } None } @@ -213,13 +186,45 @@ impl<'a> Model<'a> { message: "Not found".to_string(), } }; - let match_mode = match self.parse_match_mode(args, 4, cell, false) { - Ok(mode) => mode, - Err(error) => return error, + let match_mode = if args.len() >= 5 { + match self.get_number(&args[4], cell) { + Ok(c) => match c.floor() as i32 { + -1 => MatchMode::ExactMatchSmaller, + 0 => MatchMode::ExactMatch, + 1 => MatchMode::ExactMatchLarger, + 2 => MatchMode::WildcardMatch, + _ => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Unexpected number".to_string(), + }; + } + }, + Err(s) => return s, + } + } else { + MatchMode::ExactMatch }; - let search_mode = match self.parse_search_mode(args, 5, cell) { - Ok(mode) => mode, - Err(error) => return error, + let search_mode = if args.len() == 6 { + match self.get_number(&args[5], cell) { + Ok(c) => match c.floor() as i32 { + 1 => SearchMode::StartAtFirstItem, + -1 => SearchMode::StartAtLastItem, + -2 => SearchMode::BinarySearchDescending, + 2 => SearchMode::BinarySearchAscending, + _ => { + return CalcResult::Error { + error: Error::ERROR, + origin: cell, + message: "Unexpected number".to_string(), + }; + } + }, + Err(s) => return s, + } + } else { + SearchMode::StartAtFirstItem }; // lookup_array match self.evaluate_node_in_context(&args[1], cell) { @@ -359,13 +364,45 @@ impl<'a> Model<'a> { if lookup_value.is_error() { return lookup_value; } - let match_mode = match self.parse_match_mode(args, 2, cell, true) { - Ok(mode) => mode, - Err(error) => return error, + let match_mode = if args.len() >= 3 { + match self.get_number(&args[2], cell) { + Ok(c) => match c.floor() as i32 { + -1 => MatchMode::ExactMatchSmaller, + 0 => MatchMode::ExactMatch, + 1 => MatchMode::ExactMatchLarger, + 2 => MatchMode::WildcardMatch, + _ => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Unexpected number".to_string(), + }; + } + }, + Err(s) => return s, + } + } else { + MatchMode::ExactMatch }; - let search_mode = match self.parse_search_mode(args, 3, cell) { - Ok(mode) => mode, - Err(error) => return error, + let search_mode = if args.len() == 4 { + match self.get_number(&args[3], cell) { + Ok(c) => match c.floor() as i32 { + 1 => SearchMode::StartAtFirstItem, + -1 => SearchMode::StartAtLastItem, + -2 => SearchMode::BinarySearchDescending, + 2 => SearchMode::BinarySearchAscending, + _ => { + return CalcResult::Error { + error: Error::ERROR, + origin: cell, + message: "Unexpected number".to_string(), + }; + } + }, + Err(s) => return s, + } + } else { + SearchMode::StartAtFirstItem }; match self.evaluate_node_in_context(&args[1], cell) { CalcResult::Range { left, right } => { @@ -415,7 +452,7 @@ impl<'a> Model<'a> { binary_search_descending_or_smaller(&lookup_value, &array) } } - MatchMode::WildcardMatch | MatchMode::RegexMatch => { + MatchMode::WildcardMatch => { return CalcResult::Error { error: Error::VALUE, origin: cell, diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 9a7b1c392..ecbdc7a5f 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -82,6 +82,7 @@ mod test_exp_sign; mod test_extend; mod test_floor; mod test_fn_datevalue_timevalue; +mod test_fn_address; mod test_fn_fv; mod test_fn_round; mod test_fn_type; diff --git a/base/src/test/test_fn_xmatch.rs b/base/src/test/test_fn_xmatch.rs index 2f654e3d5..802e7ca47 100644 --- a/base/src/test/test_fn_xmatch.rs +++ b/base/src/test/test_fn_xmatch.rs @@ -27,12 +27,12 @@ fn test_fn_xmatch_match_modes() { model._set("C2", "banana"); model._set("C3", "apricot"); model._set("B3", "=XMATCH(\"ap*\", C1:C3, 2)"); // Wildcard - model._set("B4", "=XMATCH(\"^[a-c]\", C1:C3, 3)"); // Regex + model._set("B4", "=XMATCH(\"^[a-c]\", C1:C3, 3)"); // Invalid match_mode model.evaluate(); assert_eq!(model._get_text("B1"), *"2"); // Found 20 (next smaller than 25) assert_eq!(model._get_text("B2"), *"2"); // Found 20 (next larger than 15) assert_eq!(model._get_text("B3"), *"1"); // Found "apple" - assert_eq!(model._get_text("B4"), *"1"); // Found "apple" + assert_eq!(model._get_text("B4"), *"#VALUE!"); } #[test] @@ -124,13 +124,13 @@ fn test_fn_xmatch_edge_cases() { model._set("C3", "40"); model._set("B2", "=XMATCH(10, C1:C3, -1)"); // No smaller model._set("B3", "=XMATCH(50, C1:C3, 1)"); // No larger - // Invalid regex pattern + // Invalid match_mode model._set("B4", "=XMATCH(\"[\", A1:A1, 3)"); model.evaluate(); assert_eq!(model._get_text("B1"), *"1"); // Case-insensitive assert_eq!(model._get_text("B2"), *"#N/A"); // No smaller value assert_eq!(model._get_text("B3"), *"#N/A"); // No larger value - assert_eq!(model._get_text("B4"), *"#N/A"); // Invalid regex + assert_eq!(model._get_text("B4"), *"#VALUE!"); // Invalid match_mode } #[test] diff --git a/docs/src/functions/lookup-and-reference.md b/docs/src/functions/lookup-and-reference.md index bba4d67fb..f38c135f1 100644 --- a/docs/src/functions/lookup-and-reference.md +++ b/docs/src/functions/lookup-and-reference.md @@ -11,7 +11,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | Function | Status | Documentation | | ------------ | ---------------------------------------------- | ------------- | -| ADDRESS | | – | +| ADDRESS | | – | | AREAS | | – | | CHOOSE | | – | | CHOOSECOLS | | – | From 7c8d7da61a88d0a32d1a6a76f408621201256426 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Wed, 11 Feb 2026 03:16:41 -0800 Subject: [PATCH 25/25] Fix PR 53: add util helpers, get_string for REPLACE, Xmatch dispatch, into_iter 349, fmt --- base/src/functions/lookup_and_reference.rs | 5 ++- base/src/functions/mod.rs | 3 +- base/src/functions/text.rs | 22 ++++++------ base/src/functions/util.rs | 40 ++++++++++++++++++++++ base/src/test/mod.rs | 2 +- base/src/test/test_fn_xmatch.rs | 2 +- 6 files changed, 59 insertions(+), 15 deletions(-) diff --git a/base/src/functions/lookup_and_reference.rs b/base/src/functions/lookup_and_reference.rs index 579e5468a..58c8e5053 100644 --- a/base/src/functions/lookup_and_reference.rs +++ b/base/src/functions/lookup_and_reference.rs @@ -5,7 +5,10 @@ use crate::{ utils::ParsedReference, }; -use super::util::{compare_values, from_wildcard_to_regex, result_matches_regex, values_are_equal}; +use super::util::{ + compare_values, create_lookup_matcher, from_wildcard_to_regex, result_matches_regex, + validate_vector_range, values_are_equal, +}; impl<'a> Model<'a> { pub(crate) fn fn_index(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index bbe7b3d7f..3eaf1cd7f 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -1184,7 +1184,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1739,6 +1739,7 @@ impl<'a> Model<'a> { Function::Rows => self.fn_rows(args, cell), Function::Vlookup => self.fn_vlookup(args, cell), Function::Xlookup => self.fn_xlookup(args, cell), + Function::Xmatch => self.fn_xmatch(args, cell), Function::Concatenate => self.fn_concatenate(args, cell), Function::Exact => self.fn_exact(args, cell), Function::Value => self.fn_value(args, cell), diff --git a/base/src/functions/text.rs b/base/src/functions/text.rs index f945ade35..cadc5fdbe 100644 --- a/base/src/functions/text.rs +++ b/base/src/functions/text.rs @@ -279,7 +279,7 @@ impl<'a> Model<'a> { // LEN, LEFT, RIGHT, MID, LOWER, UPPER, TRIM pub(crate) fn fn_len(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.calc_result_to_string(&args[0], cell) { + let s = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -290,7 +290,7 @@ impl<'a> Model<'a> { pub(crate) fn fn_trim(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.calc_result_to_string(&args[0], cell) { + let s = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -301,7 +301,7 @@ impl<'a> Model<'a> { pub(crate) fn fn_lower(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.calc_result_to_string(&args[0], cell) { + let s = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -312,7 +312,7 @@ impl<'a> Model<'a> { pub(crate) fn fn_proper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let text = match self.calc_result_to_string(&args[0], cell) { + let text = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -342,7 +342,7 @@ impl<'a> Model<'a> { pub(crate) fn fn_unicode(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.calc_result_to_string(&args[0], cell) { + let s = match self.get_string(&args[0], cell) { Ok(s) => { if s.is_empty() { return CalcResult::Error { @@ -375,7 +375,7 @@ impl<'a> Model<'a> { pub(crate) fn fn_upper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { - let s = match self.calc_result_to_string(&args[0], cell) { + let s = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -388,7 +388,7 @@ impl<'a> Model<'a> { if args.len() > 2 || args.is_empty() { return CalcResult::new_args_number_error(cell); } - let s = match self.calc_result_to_string(&args[0], cell) { + let s = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -446,7 +446,7 @@ impl<'a> Model<'a> { if args.len() > 2 || args.is_empty() { return CalcResult::new_args_number_error(cell); } - let s = match self.calc_result_to_string(&args[0], cell) { + let s = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -504,7 +504,7 @@ impl<'a> Model<'a> { if args.len() != 3 { return CalcResult::new_args_number_error(cell); } - let s = match self.calc_result_to_string(&args[0], cell) { + let s = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -597,7 +597,7 @@ impl<'a> Model<'a> { if args.len() != 4 { return CalcResult::new_args_number_error(cell); } - let old_text = match self.calc_result_to_string(&args[0], cell) { + let old_text = match self.get_string(&args[0], cell) { Ok(s) => s, Err(error) => return error, }; @@ -609,7 +609,7 @@ impl<'a> Model<'a> { Ok(f) => f, Err(e) => return e, }; - let new_text = match self.calc_result_to_string(&args[3], cell) { + let new_text = match self.get_string(&args[3], cell) { Ok(s) => s, Err(error) => return error, }; diff --git a/base/src/functions/util.rs b/base/src/functions/util.rs index e5d46219a..da25b8288 100644 --- a/base/src/functions/util.rs +++ b/base/src/functions/util.rs @@ -1,6 +1,10 @@ #[cfg(feature = "use_regex_lite")] use regex_lite as regex; +use crate::constants::{LAST_COLUMN, LAST_ROW}; +use crate::expressions::token::Error; +use crate::expressions::types::CellReferenceIndex; +use crate::model::Model; use crate::{ calc_result::CalcResult, expressions::token::is_english_error_string, number_format::to_excel_precision, @@ -29,6 +33,42 @@ pub(crate) fn values_are_equal(left: &CalcResult, right: &CalcResult) -> bool { } } +/// Validates that the range is a vector (single row or single column). +/// Returns Ok(true) for row vector, Ok(false) for column vector, Err on invalid range. +pub(crate) fn validate_vector_range( + left: &CellReferenceIndex, + right: &CellReferenceIndex, + cell: CellReferenceIndex, + message: &str, +) -> Result { + if left.row == right.row { + Ok(false) // column vector + } else if left.column == right.column { + Ok(true) // row vector + } else { + Err(CalcResult::new_error( + Error::VALUE, + cell, + message.to_string(), + )) + } +} + +/// Returns a matcher function for lookup (exact or wildcard on strings). +pub(crate) fn create_lookup_matcher( + lookup_value: &CalcResult, +) -> Box bool + '_> { + if let CalcResult::String(s) = lookup_value { + if let Ok(reg) = from_wildcard_to_regex(&s.to_lowercase(), true) { + Box::new(move |x| result_matches_regex(x, ®)) + } else { + Box::new(move |_| false) + } + } else { + Box::new(move |x| values_are_equal(x, lookup_value)) + } +} + // In Excel there are two ways of comparing cell values. // The old school comparison valid in formulas like D3 < D4 or HLOOKUP,... cast empty cells into empty strings or 0 // For the new formulas like XLOOKUP or SORT an empty cell is always larger than anything else. diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index ecbdc7a5f..d04fb1eb0 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -81,8 +81,8 @@ mod test_even_odd; mod test_exp_sign; mod test_extend; mod test_floor; -mod test_fn_datevalue_timevalue; mod test_fn_address; +mod test_fn_datevalue_timevalue; mod test_fn_fv; mod test_fn_round; mod test_fn_type; diff --git a/base/src/test/test_fn_xmatch.rs b/base/src/test/test_fn_xmatch.rs index 802e7ca47..7334274d8 100644 --- a/base/src/test/test_fn_xmatch.rs +++ b/base/src/test/test_fn_xmatch.rs @@ -124,7 +124,7 @@ fn test_fn_xmatch_edge_cases() { model._set("C3", "40"); model._set("B2", "=XMATCH(10, C1:C3, -1)"); // No smaller model._set("B3", "=XMATCH(50, C1:C3, 1)"); // No larger - // Invalid match_mode + // Invalid match_mode model._set("B4", "=XMATCH(\"[\", A1:A1, 3)"); model.evaluate(); assert_eq!(model._get_text("B1"), *"1"); // Case-insensitive