From 7df7896e85a072d4d8c71c7c0c7efab3f3e46afd Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:03:27 +0900 Subject: [PATCH 01/93] refactor(oracle): share cell observation and sheet helpers between Excel drivers - extract cell_result.py (xlwings cell / spill observation into CaseResult) and case_sheet.py (sheet naming, merges, error triggers, print-block helpers) from macos_excel.py and windows_excel.py - split windows_excel.py into windows_com.py (COM primitives), windows_pivot.py (PivotTable build and readout) and windows_page_setup.py (PageSetup readers) - keep the old private names reachable from the driver modules as aliases - list the new modules in the tools/oracle README layout tree - behavior-preserving move --- tools/oracle/README.md | 5 + tools/oracle/drivers/case_sheet.py | 118 +++ tools/oracle/drivers/cell_result.py | 237 ++++++ tools/oracle/drivers/macos_excel.py | 320 +------ tools/oracle/drivers/windows_com.py | 133 +++ tools/oracle/drivers/windows_excel.py | 932 ++------------------- tools/oracle/drivers/windows_page_setup.py | 255 ++++++ tools/oracle/drivers/windows_pivot.py | 244 ++++++ 8 files changed, 1087 insertions(+), 1157 deletions(-) create mode 100644 tools/oracle/drivers/case_sheet.py create mode 100644 tools/oracle/drivers/cell_result.py create mode 100644 tools/oracle/drivers/windows_com.py create mode 100644 tools/oracle/drivers/windows_page_setup.py create mode 100644 tools/oracle/drivers/windows_pivot.py diff --git a/tools/oracle/README.md b/tools/oracle/README.md index e04674dee..2fa974269 100644 --- a/tools/oracle/README.md +++ b/tools/oracle/README.md @@ -433,6 +433,11 @@ tools/oracle/ ├── base.py OracleDriver ABC + CaseResult / EnvironmentInfo ├── macos_excel.py Mac driver (xlwings via AppleEvents) ├── windows_excel.py Windows driver (xlwings via COM) + wire entrypoint + ├── cell_result.py xlwings cell / spill observation -> CaseResult (both drivers) + ├── case_sheet.py sheet naming, merges, error triggers, print-block helpers (both drivers) + ├── windows_com.py COM primitives: displayed text, raw-cell adapter, error text + ├── windows_pivot.py PivotTable build, grid readout, formula probes + ├── windows_page_setup.py PageSetup readers and the print round-trip read ├── wsl_bridge.py WSL2 wrapper that subprocess-invokes windows_excel └── __init__.py select_driver(target) factory ``` diff --git a/tools/oracle/drivers/case_sheet.py b/tools/oracle/drivers/case_sheet.py new file mode 100644 index 000000000..5ce83cf58 --- /dev/null +++ b/tools/oracle/drivers/case_sheet.py @@ -0,0 +1,118 @@ +"""Case-to-worksheet primitives shared by the Mac and Windows drivers. + +Sheet naming and addressing, merge application, error-cell triggers, and the +print-block helpers that do not touch a platform bridge. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def split_sheet_qualified_addr(key: str) -> "tuple[Optional[str], str]": + """Splits a setup-key like ``"Sheet2!A1"`` into ``(sheet, a1)``. + + Returns ``(None, key)`` when the key is a bare A1 address (no ``!``). + Single-quoted sheet names are unquoted: ``"'Sheet One'!A1"`` -> + ``("Sheet One", "A1")``. Escaped quotes (``''`` inside a quoted + name) collapse to a single quote per Excel's convention. The split + is on the LAST ``!`` so a future stray ``!`` inside a quoted sheet + name does not confuse it. + """ + + if "!" not in key: + return None, key + bang = key.rfind("!") + sheet_part = key[:bang] + addr_part = key[bang + 1 :] + if sheet_part.startswith("'") and sheet_part.endswith("'") and len(sheet_part) >= 2: + sheet_part = sheet_part[1:-1].replace("''", "'") + return sheet_part, addr_part + + +def get_or_add_sheet(wb, name: str): + """Returns the sheet whose display name matches ``name`` (case-insensitive), + adding it at the end if absent. + """ + + target = name.casefold() + for sht in wb.sheets: + if sht.name.casefold() == target: + return sht + return wb.sheets.add(name=name, after=wb.sheets[len(wb.sheets) - 1]) + + +def apply_merges(sht, merges: List[str]) -> None: + """Apply case-declared inclusive A1 merge ranges on ``sht``.""" + + for ref in merges: + sht.range(ref).merge() + + +SHEET_FORBIDDEN = set("\\/?*[]:") + + +def sanitize_sheet_name(name: str) -> str: + """Strips characters Excel disallows in sheet names and trims length.""" + + cleaned = "".join("_" if c in SHEET_FORBIDDEN else c for c in name) + return cleaned[:24] or "case" + + +ERROR_TRIGGERS = { + "#DIV/0!": "=1/0", + "#NAME?": "=NONEXISTENT_FUNC()", + "#VALUE!": '=VALUE("x")', + "#NUM!": "=SQRT(-1)", + "#N/A": "=NA()", + "#REF!": "=OFFSET(A1,-1,-1)", + "#NULL!": "=A1 B1", +} + + +def error_trigger(code: str) -> str: + return ERROR_TRIGGERS.get(code, '=VALUE("x")') + + +# Excel `XlPageOrientation` constants. +_XL_PORTRAIT = 1 +_XL_LANDSCAPE = 2 + +PRINT_ORIENTATIONS = { + "portrait": _XL_PORTRAIT, + "landscape": _XL_LANDSCAPE, +} + + +def resolve_print_sheet(wb, print_spec: Dict[str, Any]): + """Returns the worksheet the `print` block names, or raises.""" + + sheet_name = print_spec.get("sheet") + if not isinstance(sheet_name, str) or not sheet_name: + raise RuntimeError("print block missing string 'sheet'") + target = sheet_name.casefold() + for sht in wb.sheets: + if sht.name.casefold() == target: + return sht + raise RuntimeError(f"print 'sheet' names an unknown sheet {sheet_name!r}") + + +def normalise_print_area(area: str) -> str: + """Strips ``$`` anchors and ``Sheet!`` qualifiers from a print area. + + Excel reports ``PrintArea`` fully qualified and anchored + (``Sheet1!$A$1:$H$80``); the C++ engine compares against a bare + ``A1:H80`` form, so both sides normalise the same way. + """ + + out_parts: List[str] = [] + for part in area.split(","): + token = part.strip() + if not token: + continue + bang = token.rfind("!") + if bang != -1: + token = token[bang + 1 :] + token = token.replace("$", "") + out_parts.append(token) + return ",".join(out_parts) diff --git a/tools/oracle/drivers/cell_result.py b/tools/oracle/drivers/cell_result.py new file mode 100644 index 000000000..627c1a2a1 --- /dev/null +++ b/tools/oracle/drivers/cell_result.py @@ -0,0 +1,237 @@ +"""Turns an xlwings cell or spill observation into a :class:`CaseResult`. + +The Mac and Windows drivers read results the same way and differ only in two +platform primitives, which every entry point takes as callables: + + - ``displayed_text(cell)`` -- the cell's rendered string via the bridge. + - ``app_evaluate(app)`` / ``spill_shape(app, anchor, *, max_cells)`` -- the + ``Application.Evaluate`` adapter and the spill-shape probe built on it. +""" + +from __future__ import annotations + +import datetime as _dt +from typing import Any, Callable, Dict, List, Optional + +from ._locale import normalise_error_token +from .base import ( + _ERR_DISPLAY_NAMES, + CaseResult, + _datetime_to_serial, + case_formula_cell, + case_shape_samples, + is_empty_text_result, +) + +DisplayedText = Callable[[Any], Optional[str]] + + +def error_display_from_cell(cell, displayed_text: DisplayedText) -> Optional[str]: + """Returns the tokenised Excel error name for `cell`, or None. + + Walks four progressively weaker signals: + 1. ``xlwings.utils.CVErr`` -- ideal, but only surfaces on some + Excel / xlwings build pairs. + 2. ``cell.value`` already a ``'#DIV/0!'``-style string. + 3. The displayed text (COM ``.Text``) matches a known error. + 4. ``cell.value`` is ``None`` AND the displayed text nonetheless + starts with ``#``. This is the fallback path where the Python + layer has coerced the error into ``None``. + """ + + raw = cell.value + try: + from xlwings.utils import CVErr # type: ignore + + if isinstance(raw, CVErr): + s = str(raw) + if s in _ERR_DISPLAY_NAMES: + return s + except Exception: # pragma: no cover - older xlwings without CVErr + pass + + if isinstance(raw, str): + if raw in _ERR_DISPLAY_NAMES: + return raw + # ja-JP / de-DE / fr-FR builds occasionally surface the localized + # token directly through cell.value (when the bridge has already + # decoded the CVErr to a string). Normalise here so the golden + # JSON always carries the canonical English form. + canon = normalise_error_token(raw) + if canon is not None: + return canon + + text = displayed_text(cell) + if text in _ERR_DISPLAY_NAMES: + return text + # Range.Text is locale-bound: de-DE returns "#WERT!" for #VALUE!, + # fr-FR returns "#VALEUR!", and so on. Normalise through the shared + # localisation map before falling back to the prefix heuristic. + if text: + canon = normalise_error_token(text) + if canon is not None: + return canon + if text and text.startswith("#") and (text.endswith("!") or text.endswith("?") or text == "#N/A"): + for name in _ERR_DISPLAY_NAMES: + if text == name: + return name + return None + + +def classify_value(cell, evaluate, displayed_text: DisplayedText) -> CaseResult: + """Converts an xlwings cell observation into a CaseResult. + + The ``cell.value`` read happens once up front; subsequent checks may + consult the COM ``.Text`` fallback for edge cases (error cells, + pre-1900 serials) where the Python-side value is lossy. ``evaluate`` is + the Application.Evaluate adapter that resolves an empty read-back into + ``""`` or blank; without it (``None``) an empty read-back stays blank. + """ + + err = error_display_from_cell(cell, displayed_text) + if err is not None: + return CaseResult(id="", kind="error", error_code=err) + + v = cell.value + # The classifier has no workbook epoch metadata, so datetimes use the + # shared 1900-system conversion; 1904 cases should expose date values + # through a numeric/boolean formula such as N(DATE()). + if isinstance(v, _dt.datetime): + return CaseResult(id="", kind="number", value=_datetime_to_serial(v)) + if isinstance(v, _dt.date): # pragma: no cover - xlwings mostly returns datetime + combined = _dt.datetime(v.year, v.month, v.day) + return CaseResult(id="", kind="number", value=_datetime_to_serial(combined)) + + if isinstance(v, bool): + return CaseResult(id="", kind="bool", value=bool(v)) + if isinstance(v, (int, float)): + return CaseResult(id="", kind="number", value=float(v)) + + if v is None or v == "": + # Pre-1900 serials come back as None (no datetime representation); + # a displayed-text parse was never implemented, so they stay blank. + text = displayed_text(cell) + if text and text.strip(): + pass + if evaluate is not None and is_empty_text_result(evaluate, cell): + return CaseResult(id="", kind="text", value="") + return CaseResult(id="", kind="blank") + + if isinstance(v, str): + return CaseResult(id="", kind="text", value=v) + + if isinstance(v, list): + rows = len(v) + cols = 0 + if rows > 0 and isinstance(v[0], list): + cols = len(v[0]) + flat = [array_cell_from_scalar(classify_python_scalar(item)) for row in v for item in row] + else: + cols = rows + rows = 1 + flat = [array_cell_from_scalar(classify_python_scalar(item)) for item in v] + return CaseResult( + id="", + kind="array", + value=flat, + array_shape=[rows, cols], + ) + return CaseResult(id="", kind="text", value=str(v)) + + +def classify_python_scalar(v: Any) -> CaseResult: + """Classifies a scalar value already extracted from an xlwings array.""" + + if isinstance(v, _dt.datetime): + return CaseResult(id="", kind="number", value=_datetime_to_serial(v)) + if isinstance(v, _dt.date): # pragma: no cover - xlwings mostly returns datetime + combined = _dt.datetime(v.year, v.month, v.day) + return CaseResult(id="", kind="number", value=_datetime_to_serial(combined)) + if isinstance(v, bool): + return CaseResult(id="", kind="bool", value=bool(v)) + if isinstance(v, (int, float)): + return CaseResult(id="", kind="number", value=float(v)) + if v is None or v == "": + return CaseResult(id="", kind="blank") + if isinstance(v, str): + canon = normalise_error_token(v) + if canon is not None: + return CaseResult(id="", kind="error", error_code=canon) + return CaseResult(id="", kind="text", value=v) + return CaseResult(id="", kind="text", value=str(v)) + + +def array_cell_from_scalar(result: CaseResult) -> Any: + if result.kind == "blank": + return None + if result.kind in {"number", "bool", "text"}: + return result.value + if result.kind == "error": + return {"kind": "error", "code": result.error_code or "#UNKNOWN!"} + return {"kind": result.kind, "value": result.value} + + +def classify_shape_result( + app, + sht, + anchor_addr: str, + samples: List[str], + *, + displayed_text: DisplayedText, + app_evaluate: Callable[[Any], Callable[[str], Any]], + spill_shape: Callable[..., tuple[int, int]], +) -> CaseResult: + """Records a spill as its shape plus the case's declared sample cells. + + For results too large to materialise cell by cell. The shape comes from + the same non-invasive probe the cell walk uses; only the listed cells + are read. + """ + + rows, cols = spill_shape(app, sht.range(anchor_addr), max_cells=None) + evaluate = app_evaluate(app) + values = { + addr: array_cell_from_scalar(classify_value(sht.range(addr), evaluate, displayed_text)) for addr in samples + } + return CaseResult(id="", kind="array_shape", value=values, array_shape=[rows, cols]) + + +def classify_case_result( + app, + sht, + case: Dict[str, Any], + *, + shape_result: Callable[[Any, Any, str, List[str]], CaseResult], + result_cell: Callable[[Any, Any, str], CaseResult], +) -> CaseResult: + """Reads one case's result in whichever capture mode it declares.""" + + anchor_addr = case_formula_cell(case) + samples = case_shape_samples(case) + if samples is not None: + return shape_result(app, sht, anchor_addr, samples) + return result_cell(app, sht, anchor_addr) + + +def classify_result_cell( + app, + sht, + anchor_addr: str, + *, + displayed_text: DisplayedText, + app_evaluate: Callable[[Any], Callable[[str], Any]], + spill_shape: Callable[..., tuple[int, int]], +) -> CaseResult: + """Classifies the anchor scalar or the full dynamic spill if present.""" + + shape = spill_shape(app, sht.range(anchor_addr)) + rows, cols = shape + evaluate = app_evaluate(app) + if rows == 1 and cols == 1: + return classify_value(sht.range(anchor_addr), evaluate, displayed_text) + anchor = sht.range(anchor_addr) + flat: List[Any] = [] + for r in range(rows): + for c in range(cols): + flat.append(array_cell_from_scalar(classify_value(anchor.offset(r, c), evaluate, displayed_text))) + return CaseResult(id="", kind="array", value=flat, array_shape=[rows, cols]) diff --git a/tools/oracle/drivers/macos_excel.py b/tools/oracle/drivers/macos_excel.py index 7e1c6968e..0aa090229 100644 --- a/tools/oracle/drivers/macos_excel.py +++ b/tools/oracle/drivers/macos_excel.py @@ -37,22 +37,18 @@ from __future__ import annotations -import datetime as _dt import platform from typing import Any, Dict, List, Optional -from ._locale import detect_locale_from_app, normalise_error_token +from . import case_sheet, cell_result +from ._locale import detect_locale_from_app from .base import ( - _ERR_DISPLAY_NAMES, DEFAULT_FORMULA_CELL, MAX_CAPTURE_CELLS, CaseResult, EnvironmentInfo, OracleDriver, - _datetime_to_serial, case_formula_cell, - case_shape_samples, - is_empty_text_result, probe_spill_shape, ) @@ -208,157 +204,27 @@ def _cell_displayed_text(cell) -> Optional[str]: return None -def _error_display_from_cell(cell) -> Optional[str]: - """Returns the tokenised Excel error name for `cell`, or None. - - Walks four progressively weaker signals: - 1. `xlwings.utils.CVErr` — ideal, but only surfaces on some Excel / - xlwings build pairs. - 2. `cell.value` already a '#DIV/0!'-style string. - 3. The displayed text (AppleScript `.text`) matches a known error. - 4. `cell.value` is `None` AND the displayed text nonetheless starts - with `#`. This is the fallback Mac path where the Python layer - has coerced the error into `None`. - """ +# Shared helpers re-exported under the names this driver has always carried. +_split_sheet_qualified_addr = case_sheet.split_sheet_qualified_addr +_get_or_add_sheet = case_sheet.get_or_add_sheet +_apply_merges = case_sheet.apply_merges +_sanitize_sheet_name = case_sheet.sanitize_sheet_name +_error_trigger = case_sheet.error_trigger +_resolve_print_sheet = case_sheet.resolve_print_sheet +_normalise_print_area = case_sheet.normalise_print_area +_PRINT_ORIENTATIONS = case_sheet.PRINT_ORIENTATIONS +_classify_python_scalar = cell_result.classify_python_scalar +_array_cell_from_scalar = cell_result.array_cell_from_scalar - raw = cell.value - try: - from xlwings.utils import CVErr # type: ignore - - if isinstance(raw, CVErr): - s = str(raw) - if s in _ERR_DISPLAY_NAMES: - return s - except Exception: # pragma: no cover - older xlwings without CVErr - pass - - if isinstance(raw, str): - if raw in _ERR_DISPLAY_NAMES: - return raw - canon = normalise_error_token(raw) - if canon is not None: - return canon - - text = _cell_displayed_text(cell) - if text in _ERR_DISPLAY_NAMES: - return text - # AppleScript `string_value` is locale-bound: de-DE returns "#WERT!", - # fr-FR "#VALEUR!", and so on. Normalise through the shared - # localisation map before falling back to the prefix heuristic. - if text: - canon = normalise_error_token(text) - if canon is not None: - return canon - # Last-ditch: match by prefix (`#DIV/0!...` in an ex-format-localised - # build, for example). All Excel errors start with `#` and end with - # `!` or `?`; we don't want to catch text that happens to start with - # '#'. - if text and text.startswith("#") and (text.endswith("!") or text.endswith("?") or text == "#N/A"): - for name in _ERR_DISPLAY_NAMES: - if text == name: - return name - return None +def _error_display_from_cell(cell) -> Optional[str]: + return cell_result.error_display_from_cell(cell, _cell_displayed_text) -def _classify_value(cell, evaluate) -> CaseResult: - """Converts an xlwings cell observation into a CaseResult. - The `cell.value` read happens once up front; subsequent checks may - consult the AppleScript `.text` fallback for Mac edge cases (error - cells, pre-1900 serials) where the Python-side value is lossy. - ``evaluate`` is the Application.Evaluate adapter used to resolve an - empty read-back into ``""`` or blank. - """ +def _classify_value(cell, evaluate) -> CaseResult: + """Classifies one cell; ``evaluate`` resolves an empty read-back to ``""`` or blank.""" - err = _error_display_from_cell(cell) - if err is not None: - return CaseResult(id="", kind="error", error_code=err) - - v = cell.value - # Datetime must come before bool/int because `datetime` is distinct - # from `int` in Python, but xlwings may return a `date` (no time) for - # pure DATE() results. Both use the shared 1900-system conversion so the - # golden carries a number kind (matching Formulon's Value::Number). The - # classifier has no workbook epoch metadata; 1904 cases should therefore - # expose date values through a numeric/boolean formula such as N(DATE()). - if isinstance(v, _dt.datetime): - return CaseResult(id="", kind="number", value=_datetime_to_serial(v)) - if isinstance(v, _dt.date): # pragma: no cover - xlwings mostly returns datetime - combined = _dt.datetime(v.year, v.month, v.day) - return CaseResult(id="", kind="number", value=_datetime_to_serial(combined)) - - if isinstance(v, bool): - return CaseResult(id="", kind="bool", value=bool(v)) - if isinstance(v, (int, float)): - return CaseResult(id="", kind="number", value=float(v)) - - if v is None or v == "": - # Mac Excel can return None for pre-1900 serials (Python can't - # represent them as datetime). Inspect the displayed text and try - # to parse it as an integer serial before falling back to blank. - text = _cell_displayed_text(cell) - if text and text.strip(): - # Trivial numeric fallback: some ja-JP date formats render - # as "1900/2/29" even though cell.value is None. Bail to - # blank here; the YAML case should be rewritten to force a - # non-date display if a serial is needed. - pass - if is_empty_text_result(evaluate, cell): - return CaseResult(id="", kind="text", value="") - return CaseResult(id="", kind="blank") - - if isinstance(v, str): - return CaseResult(id="", kind="text", value=v) - - if isinstance(v, list): - rows = len(v) - cols = 0 - if rows > 0 and isinstance(v[0], list): - cols = len(v[0]) - flat = [_array_cell_from_scalar(_classify_python_scalar(item)) for row in v for item in row] - else: - cols = rows - rows = 1 - flat = [_array_cell_from_scalar(_classify_python_scalar(item)) for item in v] - return CaseResult( - id="", - kind="array", - value=flat, - array_shape=[rows, cols], - ) - return CaseResult(id="", kind="text", value=str(v)) - - -def _classify_python_scalar(v: Any) -> CaseResult: - """Classifies a scalar value already extracted from an xlwings array.""" - - if isinstance(v, _dt.datetime): - return CaseResult(id="", kind="number", value=_datetime_to_serial(v)) - if isinstance(v, _dt.date): # pragma: no cover - xlwings mostly returns datetime - combined = _dt.datetime(v.year, v.month, v.day) - return CaseResult(id="", kind="number", value=_datetime_to_serial(combined)) - if isinstance(v, bool): - return CaseResult(id="", kind="bool", value=bool(v)) - if isinstance(v, (int, float)): - return CaseResult(id="", kind="number", value=float(v)) - if v is None or v == "": - return CaseResult(id="", kind="blank") - if isinstance(v, str): - canon = normalise_error_token(v) - if canon is not None: - return CaseResult(id="", kind="error", error_code=canon) - return CaseResult(id="", kind="text", value=v) - return CaseResult(id="", kind="text", value=str(v)) - - -def _array_cell_from_scalar(result: CaseResult) -> Any: - if result.kind == "blank": - return None - if result.kind in {"number", "bool", "text"}: - return result.value - if result.kind == "error": - return {"kind": "error", "code": result.error_code or "#UNKNOWN!"} - return {"kind": result.kind, "value": result.value} + return cell_result.classify_value(cell, evaluate, _cell_displayed_text) def _app_evaluate(app): @@ -374,43 +240,38 @@ def _evaluate_spill_shape(app, anchor, *, max_cells: Optional[int] = MAX_CAPTURE def _classify_shape_result(app, sht, anchor_addr: str, samples: List[str]) -> CaseResult: - """Records a spill as its shape plus the case's declared sample cells. + """Records a spill as its shape plus the case's declared sample cells.""" - For results too large to materialise cell by cell. The shape comes from - the same non-invasive probe the cell walk uses; only the listed cells - are read. - """ - - rows, cols = _evaluate_spill_shape(app, sht.range(anchor_addr), max_cells=None) - evaluate = _app_evaluate(app) - values = {addr: _array_cell_from_scalar(_classify_value(sht.range(addr), evaluate)) for addr in samples} - return CaseResult(id="", kind="array_shape", value=values, array_shape=[rows, cols]) + return cell_result.classify_shape_result( + app, + sht, + anchor_addr, + samples, + displayed_text=_cell_displayed_text, + app_evaluate=_app_evaluate, + spill_shape=_evaluate_spill_shape, + ) def _classify_case_result(app, sht, case: Dict[str, Any]) -> CaseResult: """Reads one case's result in whichever capture mode it declares.""" - anchor_addr = case_formula_cell(case) - samples = case_shape_samples(case) - if samples is not None: - return _classify_shape_result(app, sht, anchor_addr, samples) - return _classify_result_cell(app, sht, anchor_addr) + return cell_result.classify_case_result( + app, sht, case, shape_result=_classify_shape_result, result_cell=_classify_result_cell + ) def _classify_result_cell(app, sht, anchor_addr: str = DEFAULT_FORMULA_CELL) -> CaseResult: """Classifies the anchor scalar or the full dynamic spill if present.""" - shape = _evaluate_spill_shape(app, sht.range(anchor_addr)) - rows, cols = shape - evaluate = _app_evaluate(app) - if rows == 1 and cols == 1: - return _classify_value(sht.range(anchor_addr), evaluate) - anchor = sht.range(anchor_addr) - flat: List[Any] = [] - for r in range(rows): - for c in range(cols): - flat.append(_array_cell_from_scalar(_classify_value(anchor.offset(r, c), evaluate))) - return CaseResult(id="", kind="array", value=flat, array_shape=[rows, cols]) + return cell_result.classify_result_cell( + app, + sht, + anchor_addr, + displayed_text=_cell_displayed_text, + app_evaluate=_app_evaluate, + spill_shape=_evaluate_spill_shape, + ) class ExcelOracle(OracleDriver): @@ -777,77 +638,6 @@ def _build_workbook_sheets(self, wb, case: Dict[str, Any]) -> None: pass -def _split_sheet_qualified_addr(key: str) -> "tuple[Optional[str], str]": - """Splits ``"Sheet2!A1"`` into ``("Sheet2", "A1")``; returns ``(None, key)`` - for bare A1 keys. Mirrors the Windows driver helper -- see - ``windows_excel._split_sheet_qualified_addr`` for the quoting rules. - """ - - if "!" not in key: - return None, key - bang = key.rfind("!") - sheet_part = key[:bang] - addr_part = key[bang + 1 :] - if sheet_part.startswith("'") and sheet_part.endswith("'") and len(sheet_part) >= 2: - sheet_part = sheet_part[1:-1].replace("''", "'") - return sheet_part, addr_part - - -def _get_or_add_sheet(wb, name: str): - """Returns the sheet whose display name matches ``name`` (case-insensitive), - adding it at the end if absent. - """ - - target = name.casefold() - for sht in wb.sheets: - if sht.name.casefold() == target: - return sht - return wb.sheets.add(name=name, after=wb.sheets[len(wb.sheets) - 1]) - - -# Excel `XlPageOrientation` constants. -_XL_PORTRAIT = 1 -_XL_LANDSCAPE = 2 - -_PRINT_ORIENTATIONS = { - "portrait": _XL_PORTRAIT, - "landscape": _XL_LANDSCAPE, -} - - -def _resolve_print_sheet(wb, print_spec: Dict[str, Any]): - """Returns the worksheet the `print` block names, or raises.""" - - sheet_name = print_spec.get("sheet") - if not isinstance(sheet_name, str) or not sheet_name: - raise RuntimeError("print block missing string 'sheet'") - target = sheet_name.casefold() - for sht in wb.sheets: - if sht.name.casefold() == target: - return sht - raise RuntimeError(f"print 'sheet' names an unknown sheet {sheet_name!r}") - - -def _normalise_print_area(area: str) -> str: - """Strips ``$`` anchors and ``Sheet!`` qualifiers from a print area. - - Mirrors the Windows driver helper -- the C++ engine compares against - a bare ``A1:H80`` form, so both drivers normalise identically. - """ - - out_parts: List[str] = [] - for part in area.split(","): - token = part.strip() - if not token: - continue - bang = token.rfind("!") - if bang != -1: - token = token[bang + 1 :] - token = token.replace("$", "") - out_parts.append(token) - return ",".join(out_parts) - - def _apply_and_read_print(wb, print_spec: Dict[str, Any]) -> Dict[str, Any]: """Applies the declarative `print` block to ``wb`` and reads it back. @@ -985,37 +775,5 @@ def _write_cell(sht, addr: str, rec: Dict[str, Any], *, context: str = "setup ce raise ValueError(f"unknown cell kind: {kind}") -def _apply_merges(sht, merges: List[str]) -> None: - """Apply case-declared inclusive A1 merge ranges on ``sht``.""" - - for ref in merges: - sht.range(ref).merge() - - def _format_mac_error(exc: BaseException) -> str: return f"{type(exc).__name__}: {exc}"[:200] - - -_SHEET_FORBIDDEN = set("\\/?*[]:") - - -def _sanitize_sheet_name(name: str) -> str: - """Strips characters Excel disallows in sheet names and trims length.""" - - cleaned = "".join("_" if c in _SHEET_FORBIDDEN else c for c in name) - return cleaned[:24] or "case" - - -_ERROR_TRIGGERS = { - "#DIV/0!": "=1/0", - "#NAME?": "=NONEXISTENT_FUNC()", - "#VALUE!": '=VALUE("x")', - "#NUM!": "=SQRT(-1)", - "#N/A": "=NA()", - "#REF!": "=OFFSET(A1,-1,-1)", # relative moves out of range - "#NULL!": "=A1 B1", # intersection with no overlap -} - - -def _error_trigger(code: str) -> str: - return _ERROR_TRIGGERS.get(code, '=VALUE("x")') diff --git a/tools/oracle/drivers/windows_com.py b/tools/oracle/drivers/windows_com.py new file mode 100644 index 000000000..738946978 --- /dev/null +++ b/tools/oracle/drivers/windows_com.py @@ -0,0 +1,133 @@ +"""Windows COM access primitives: displayed text, raw-cell adapter, error text. + +``xlwings`` on Windows is a thin layer over pywin32; these helpers cover the +parts of the object model it does not wrap and the way COM failures surface. +""" + +from __future__ import annotations + +from typing import Any, List, Optional + + +def cell_displayed_text(cell) -> Optional[str]: + """Returns the rendered string of a cell via the COM bridge. + + On Windows, ``cell.api.Text`` is the canonical property that yields + the displayed string (including ``'#DIV/0!'`` for errors whose + Python-side ``.value`` has been coerced to ``None``). + + Why we don't try fallback property names here: + Excel's IDispatch is case-insensitive, so ``text`` resolves to the + same DISPID as ``Text`` and adds no coverage; ``DisplayValue`` is + a Chart.Axis property and is not present on Range; ``string_value`` + is an xlwings *high-level* Range attribute, not a COM property. + Worse, an earlier defensive version of this function iterated over + a list that included the lowercase ``text``, and on Excel 16.0 / + M365 a getattr for the lowercase form triggered an apparent + infinite retry inside ``win32com``'s ``COMRetryObjectWrapper`` -- + cells whose ``Text`` legitimately resolved to ``""`` (e.g. the + result of ``=ARRAYTOTEXT("")``) made the loop fall through to the + lowercase try, which never returned and burned Excel CPU at ~60% + indefinitely. We saw the run hang for 30+ minutes before + diagnosis. Sticking to the canonical ``Text`` property avoids the + whole pitfall. + """ + + try: + api = cell.api + except Exception: + return None + try: + val = api.Text + except Exception: + return None + if isinstance(val, str): + return val or None + return None + + +class CellAdapter: + """Adapts a raw COM ``Range`` to the small surface ``classify_value`` + expects (a ``.value`` attribute plus the COM passthrough used by the + error / displayed-text fallbacks). + """ + + def __init__(self, com_cell) -> None: + self.api = com_cell + + @property + def value(self) -> Any: + return self.api.Value + + +def format_com_error(exc: BaseException) -> str: + """Returns a one-line summary of a pywin32 / xlwings exception. + + Includes the COM HRESULT and Excel's localised description when + present, so per-target divergence triage can match on the actual + failure (e.g. ``COM -2147352567: 例外が発生しました。``) rather + than a generic Python traceback. Falls back to ``repr(exc)`` for + non-COM exceptions. + """ + + try: + args = getattr(exc, "args", ()) or () + if args and isinstance(args[0], int): + hresult = args[0] + descr = "" + if len(args) >= 2 and isinstance(args[1], str): + descr = args[1] + # pywin32 com_error.args = (hresult, source, excepinfo, argerr) + # excepinfo = (wcode, source, description, helpfile, helpcontext, scode) + # When the HRESULT is the generic DISP_E_EXCEPTION the real + # Excel error message lives in excepinfo[2], so pull it out. + extras: List[str] = [] + if len(args) >= 3 and args[2] is not None: + info = args[2] + if isinstance(info, tuple) and len(info) >= 3: + src = info[1] if isinstance(info[1], str) else "" + info_descr = info[2] if isinstance(info[2], str) else "" + scode = info[5] if len(info) >= 6 else None + if info_descr.strip(): + extras.append(info_descr.strip()) + if src.strip(): + extras.append(f"source={src.strip()}") + if isinstance(scode, int): + extras.append(f"scode=0x{scode & 0xFFFFFFFF:08X}") + if extras: + descr = (descr + " -- " if descr else "") + "; ".join(extras) + return f"COM {hresult}: {descr}".strip().rstrip(":") + except Exception: + pass + return f"{type(exc).__name__}: {exc}"[:200] + + +def com_scalar(owner, attr: str) -> Any: + """Reads one COM property, mapping an unavailable one to ``None``. + + A property Excel does not expose on this host must not abort the + capture: the golden records `null` and the comparison skips that + field, which is honest about what was observed. + """ + + try: + return getattr(owner, attr) + except Exception: + return None + + +def com_bool(owner, attr: str) -> Optional[bool]: + value = com_scalar(owner, attr) + return bool(value) if isinstance(value, bool) else None + + +def com_int(owner, attr: str) -> Optional[int]: + value = com_scalar(owner, attr) + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return int(value) + + +def com_text(owner, attr: str) -> Optional[str]: + value = com_scalar(owner, attr) + return value if isinstance(value, str) else None diff --git a/tools/oracle/drivers/windows_excel.py b/tools/oracle/drivers/windows_excel.py index 82921aa14..edc240dfc 100644 --- a/tools/oracle/drivers/windows_excel.py +++ b/tools/oracle/drivers/windows_excel.py @@ -58,7 +58,6 @@ from __future__ import annotations import base64 -import datetime as _dt import os import platform import shutil @@ -68,18 +67,15 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from ._locale import detect_locale_from_app, normalise_error_token +from . import case_sheet, cell_result, windows_com, windows_page_setup, windows_pivot +from ._locale import detect_locale_from_app from .base import ( - _ERR_DISPLAY_NAMES, DEFAULT_FORMULA_CELL, MAX_CAPTURE_CELLS, CaseResult, EnvironmentInfo, OracleDriver, - _datetime_to_serial, case_formula_cell, - case_shape_samples, - is_empty_text_result, probe_spill_shape, ) @@ -107,182 +103,45 @@ def _ensure_windows() -> None: ) -def _cell_displayed_text(cell) -> Optional[str]: - """Returns the rendered string of a cell via the COM bridge. - - On Windows, ``cell.api.Text`` is the canonical property that yields - the displayed string (including ``'#DIV/0!'`` for errors whose - Python-side ``.value`` has been coerced to ``None``). - - Why we don't try fallback property names here: - Excel's IDispatch is case-insensitive, so ``text`` resolves to the - same DISPID as ``Text`` and adds no coverage; ``DisplayValue`` is - a Chart.Axis property and is not present on Range; ``string_value`` - is an xlwings *high-level* Range attribute, not a COM property. - Worse, an earlier defensive version of this function iterated over - a list that included the lowercase ``text``, and on Excel 16.0 / - M365 a getattr for the lowercase form triggered an apparent - infinite retry inside ``win32com``'s ``COMRetryObjectWrapper`` -- - cells whose ``Text`` legitimately resolved to ``""`` (e.g. the - result of ``=ARRAYTOTEXT("")``) made the loop fall through to the - lowercase try, which never returned and burned Excel CPU at ~60% - indefinitely. We saw the run hang for 30+ minutes before - diagnosis. Sticking to the canonical ``Text`` property avoids the - whole pitfall. - """ - - try: - api = cell.api - except Exception: - return None - try: - val = api.Text - except Exception: - return None - if isinstance(val, str): - return val or None - return None +# Shared helpers re-exported under the names this driver has always carried. +_cell_displayed_text = windows_com.cell_displayed_text +_CellAdapter = windows_com.CellAdapter +_format_com_error = windows_com.format_com_error +_com_scalar = windows_com.com_scalar +_com_bool = windows_com.com_bool +_com_int = windows_com.com_int +_com_text = windows_com.com_text +_split_sheet_qualified_addr = case_sheet.split_sheet_qualified_addr +_get_or_add_sheet = case_sheet.get_or_add_sheet +_apply_merges = case_sheet.apply_merges +_sanitize_sheet_name = case_sheet.sanitize_sheet_name +_error_trigger = case_sheet.error_trigger +_resolve_print_sheet = case_sheet.resolve_print_sheet +_normalise_print_area = case_sheet.normalise_print_area +_PRINT_ORIENTATIONS = case_sheet.PRINT_ORIENTATIONS +_classify_python_scalar = cell_result.classify_python_scalar +_array_cell_from_scalar = cell_result.array_cell_from_scalar +_build_pivot_table = windows_pivot.build_pivot_table +_run_formula_probes = windows_pivot.run_formula_probes +_read_pivot_grid = windows_pivot.read_pivot_grid +_split_a1 = windows_page_setup.split_a1 +_GEOMETRY_TRACK_CAP = windows_page_setup.GEOMETRY_TRACK_CAP +_read_orientation_value = windows_page_setup.read_orientation_value +_read_paper_value = windows_page_setup.read_paper_value +_read_zoom_value = windows_page_setup.read_zoom_value +_read_fit_value = windows_page_setup.read_fit_value +_read_margins = windows_page_setup.read_margins +_read_roundtrip = windows_page_setup.read_roundtrip def _error_display_from_cell(cell) -> Optional[str]: - """Returns the tokenised Excel error name for `cell`, or None. - - Walks four progressively weaker signals: - 1. ``xlwings.utils.CVErr`` -- ideal, but only surfaces on some - Excel / xlwings build pairs. - 2. ``cell.value`` already a ``'#DIV/0!'``-style string. - 3. The displayed text (COM ``.Text``) matches a known error. - 4. ``cell.value`` is ``None`` AND the displayed text nonetheless - starts with ``#``. This is the fallback path where the Python - layer has coerced the error into ``None``. - """ - - raw = cell.value - try: - from xlwings.utils import CVErr # type: ignore - - if isinstance(raw, CVErr): - s = str(raw) - if s in _ERR_DISPLAY_NAMES: - return s - except Exception: # pragma: no cover - older xlwings without CVErr - pass - - if isinstance(raw, str): - if raw in _ERR_DISPLAY_NAMES: - return raw - # ja-JP / de-DE / fr-FR builds occasionally surface the localized - # token directly through cell.value (when the bridge has already - # decoded the CVErr to a string). Normalise here so the golden - # JSON always carries the canonical English form. - canon = normalise_error_token(raw) - if canon is not None: - return canon - - text = _cell_displayed_text(cell) - if text in _ERR_DISPLAY_NAMES: - return text - # Range.Text is locale-bound: de-DE returns "#WERT!" for #VALUE!, - # fr-FR returns "#VALEUR!", and so on. Normalise through the shared - # localisation map before falling back to the prefix heuristic. - if text: - canon = normalise_error_token(text) - if canon is not None: - return canon - if text and text.startswith("#") and (text.endswith("!") or text.endswith("?") or text == "#N/A"): - for name in _ERR_DISPLAY_NAMES: - if text == name: - return name - return None + return cell_result.error_display_from_cell(cell, _cell_displayed_text) def _classify_value(cell, evaluate=None) -> CaseResult: - """Converts an xlwings cell observation into a CaseResult. - - The ``cell.value`` read happens once up front; subsequent checks may - consult the COM ``.Text`` fallback for edge cases (error cells, - pre-1900 serials) where the Python-side value is lossy. ``evaluate`` is - the Application.Evaluate adapter that resolves an empty read-back into - ``""`` or blank; without it (raw COM cells in the workbook track) an - empty read-back stays blank. - """ + """Classifies one cell; without ``evaluate`` an empty read-back stays blank.""" - err = _error_display_from_cell(cell) - if err is not None: - return CaseResult(id="", kind="error", error_code=err) - - v = cell.value - if isinstance(v, _dt.datetime): - return CaseResult(id="", kind="number", value=_datetime_to_serial(v)) - if isinstance(v, _dt.date): # pragma: no cover - xlwings mostly returns datetime - combined = _dt.datetime(v.year, v.month, v.day) - return CaseResult(id="", kind="number", value=_datetime_to_serial(combined)) - - if isinstance(v, bool): - return CaseResult(id="", kind="bool", value=bool(v)) - if isinstance(v, (int, float)): - return CaseResult(id="", kind="number", value=float(v)) - - if v is None or v == "": - text = _cell_displayed_text(cell) - if text and text.strip(): - pass - if evaluate is not None and is_empty_text_result(evaluate, cell): - return CaseResult(id="", kind="text", value="") - return CaseResult(id="", kind="blank") - - if isinstance(v, str): - return CaseResult(id="", kind="text", value=v) - - if isinstance(v, list): - rows = len(v) - cols = 0 - if rows > 0 and isinstance(v[0], list): - cols = len(v[0]) - flat = [_array_cell_from_scalar(_classify_python_scalar(item)) for row in v for item in row] - else: - cols = rows - rows = 1 - flat = [_array_cell_from_scalar(_classify_python_scalar(item)) for item in v] - return CaseResult( - id="", - kind="array", - value=flat, - array_shape=[rows, cols], - ) - return CaseResult(id="", kind="text", value=str(v)) - - -def _classify_python_scalar(v: Any) -> CaseResult: - """Classifies a scalar value already extracted from an xlwings array.""" - - if isinstance(v, _dt.datetime): - return CaseResult(id="", kind="number", value=_datetime_to_serial(v)) - if isinstance(v, _dt.date): # pragma: no cover - xlwings mostly returns datetime - combined = _dt.datetime(v.year, v.month, v.day) - return CaseResult(id="", kind="number", value=_datetime_to_serial(combined)) - if isinstance(v, bool): - return CaseResult(id="", kind="bool", value=bool(v)) - if isinstance(v, (int, float)): - return CaseResult(id="", kind="number", value=float(v)) - if v is None or v == "": - return CaseResult(id="", kind="blank") - if isinstance(v, str): - canon = normalise_error_token(v) - if canon is not None: - return CaseResult(id="", kind="error", error_code=canon) - return CaseResult(id="", kind="text", value=v) - return CaseResult(id="", kind="text", value=str(v)) - - -def _array_cell_from_scalar(result: CaseResult) -> Any: - if result.kind == "blank": - return None - if result.kind in {"number", "bool", "text"}: - return result.value - if result.kind == "error": - return {"kind": "error", "code": result.error_code or "#UNKNOWN!"} - return {"kind": result.kind, "value": result.value} + return cell_result.classify_value(cell, evaluate, _cell_displayed_text) def _app_evaluate(app): @@ -298,41 +157,38 @@ def _evaluate_spill_shape(app, anchor, *, max_cells: Optional[int] = MAX_CAPTURE def _classify_shape_result(app, sht, anchor_addr: str, samples: List[str]) -> CaseResult: - """Records a spill as its shape plus the case's declared sample cells. - - Mirrors :func:`tools.oracle.drivers.macos_excel._classify_shape_result`. - """ - - rows, cols = _evaluate_spill_shape(app, sht.range(anchor_addr), max_cells=None) - evaluate = _app_evaluate(app) - values = {addr: _array_cell_from_scalar(_classify_value(sht.range(addr), evaluate)) for addr in samples} - return CaseResult(id="", kind="array_shape", value=values, array_shape=[rows, cols]) + """Records a spill as its shape plus the case's declared sample cells.""" + + return cell_result.classify_shape_result( + app, + sht, + anchor_addr, + samples, + displayed_text=_cell_displayed_text, + app_evaluate=_app_evaluate, + spill_shape=_evaluate_spill_shape, + ) def _classify_case_result(app, sht, case: Dict[str, Any]) -> CaseResult: """Reads one case's result in whichever capture mode it declares.""" - anchor_addr = case_formula_cell(case) - samples = case_shape_samples(case) - if samples is not None: - return _classify_shape_result(app, sht, anchor_addr, samples) - return _classify_result_cell(app, sht, anchor_addr) + return cell_result.classify_case_result( + app, sht, case, shape_result=_classify_shape_result, result_cell=_classify_result_cell + ) def _classify_result_cell(app, sht, anchor_addr: str = DEFAULT_FORMULA_CELL) -> CaseResult: """Classifies the anchor scalar or the full dynamic spill if present.""" - shape = _evaluate_spill_shape(app, sht.range(anchor_addr)) - rows, cols = shape - evaluate = _app_evaluate(app) - if rows == 1 and cols == 1: - return _classify_value(sht.range(anchor_addr), evaluate) - anchor = sht.range(anchor_addr) - flat: List[Any] = [] - for r in range(rows): - for c in range(cols): - flat.append(_array_cell_from_scalar(_classify_value(anchor.offset(r, c), evaluate))) - return CaseResult(id="", kind="array", value=flat, array_shape=[rows, cols]) + return cell_result.classify_result_cell( + app, + sht, + anchor_addr, + displayed_text=_cell_displayed_text, + app_evaluate=_app_evaluate, + spill_shape=_evaluate_spill_shape, + ) # Names the printer whose driver metrics Excel paginates against; see @@ -992,41 +848,6 @@ def _apply_axis_size(sheet, axis: str, key: str, size: float) -> None: ) -def _split_sheet_qualified_addr(key: str) -> "tuple[Optional[str], str]": - """Splits a setup-key like ``"Sheet2!A1"`` into ``(sheet, a1)``. - - Returns ``(None, key)`` when the key is a bare A1 address (no ``!``). - Single-quoted sheet names are unquoted: ``"'Sheet One'!A1"`` -> - ``("Sheet One", "A1")``. Escaped quotes (``''`` inside a quoted - name) collapse to a single quote per Excel's convention. The split - is on the LAST ``!`` so a future stray ``!`` inside a quoted sheet - name does not confuse it. - """ - - if "!" not in key: - return None, key - bang = key.rfind("!") - sheet_part = key[:bang] - addr_part = key[bang + 1 :] - if sheet_part.startswith("'") and sheet_part.endswith("'") and len(sheet_part) >= 2: - sheet_part = sheet_part[1:-1].replace("''", "'") - return sheet_part, addr_part - - -def _get_or_add_sheet(wb, name: str): - """Returns the sheet whose display name matches ``name`` (case-insensitive), - adding it at the end if absent. The xlwings ``books.Sheets`` collection - is itself case-insensitive on lookup, but we surface that behavior - explicitly here for clarity. - """ - - target = name.casefold() - for sht in wb.sheets: - if sht.name.casefold() == target: - return sht - return wb.sheets.add(name=name, after=wb.sheets[len(wb.sheets) - 1]) - - def _write_cell(sht, addr: str, rec: Dict[str, Any]) -> None: """Writes one normalised {kind, value} record to ``sht!addr``.""" @@ -1070,219 +891,6 @@ def _write_cell(sht, addr: str, rec: Dict[str, Any]) -> None: raise ValueError(f"unknown cell kind: {kind}") -def _apply_merges(sht, merges: List[str]) -> None: - """Apply case-declared inclusive A1 merge ranges on ``sht``.""" - - for ref in merges: - sht.range(ref).merge() - - -# Excel `XlConsolidationFunction` constants, keyed by the declarative -# `agg` name. Mirrors the `Aggregation` enum the C++ builder accepts. -# Numeric values are the documented Office automation constants so the -# driver does not depend on the `win32com` constant cache being warm. -_XL_CONSOLIDATION = { - "Sum": -4157, - "Count": -4112, - "Average": -4106, - "Max": -4136, - "Min": -4139, - "Product": -4149, - "CountNumbers": -4113, - "StdDev": -4155, - "StdDevP": -4156, - "Var": -4164, - "VarP": -4165, -} - -# Excel `XlPivotFieldOrientation` constants. -_XL_ORIENT_ROW = 1 -_XL_ORIENT_COLUMN = 2 -_XL_ORIENT_PAGE = 3 -_XL_ORIENT_DATA = 4 - -# Excel `XlPivotTableSourceType.xlDatabase`. -_XL_DATABASE = 1 - -# Excel `XlLayoutRowType` values for `RowAxisLayout`. -_XL_COMPACT_ROW = 0 -_XL_TABULAR_ROW = 1 -_XL_OUTLINE_ROW = 2 - -_PIVOT_LAYOUT_MODES = { - "Compact": _XL_COMPACT_ROW, - "Tabular": _XL_TABULAR_ROW, - "Outline": _XL_OUTLINE_ROW, -} - - -def _build_pivot_table(wb, pivot_spec: Dict[str, Any]): - """Creates a PivotTable in ``wb`` from a declarative ``pivot`` block. - - Returns the materialised ``PivotTable`` COM object. The caller reads - ``TableRange2`` for the rendered grid and may then run post-build - formula probes. The ``pivot`` block shape is documented on - ``tests/oracle/workbook_builder.h``. - """ - - source = pivot_spec.get("source") - anchor = pivot_spec.get("anchor") - if not isinstance(source, str) or not source: - raise RuntimeError("pivot block missing string 'source'") - if not isinstance(anchor, str) or not anchor: - raise RuntimeError("pivot block missing string 'anchor'") - - # Resolve the declarative anchor "Report!A1" to the destination - # cell's COM Range, since CreatePivotTable accepts a Range here and - # the string form has the same fully-qualified requirement as - # SourceData. We also drive the source resolution through a Range - # object so PivotCaches.Create does not have to parse the address - # itself. - src_sheet_name, src_addr = _split_sheet_qualified_addr(source) - anchor_sheet_name, anchor_addr = _split_sheet_qualified_addr(anchor) - if src_sheet_name is None: - raise RuntimeError(f"pivot source must be sheet-qualified (e.g. 'Data!A1:C13'); got {source!r}") - if anchor_sheet_name is None: - raise RuntimeError(f"pivot anchor must be sheet-qualified (e.g. 'Report!A1'); got {anchor!r}") - - def _find_sheet(name: str): - for sht in wb.sheets: - if sht.name.casefold() == name.casefold(): - return sht - return None - - src_sheet = _find_sheet(src_sheet_name) - anchor_sheet = _find_sheet(anchor_sheet_name) - if src_sheet is None: - raise RuntimeError(f"pivot source references unknown sheet {src_sheet_name!r}") - if anchor_sheet is None: - raise RuntimeError(f"pivot anchor references unknown sheet {anchor_sheet_name!r}") - - source_range_api = src_sheet.range(src_addr).api - anchor_range_api = anchor_sheet.range(anchor_addr).api - - # Activate the anchor sheet so CreatePivotTable's destination is on - # the active sheet -- some Excel builds reject creating a pivot - # whose destination is on an inactive sheet with E_INVALIDARG. - try: - anchor_sheet.activate() - except Exception: - pass - - api = wb.api - cache = api.PivotCaches().Create(_XL_DATABASE, source_range_api) - pivot = cache.CreatePivotTable(anchor_range_api, "FormulonPivot") - - for field_name in pivot_spec.get("row_fields") or []: - pivot.PivotFields(field_name).Orientation = _XL_ORIENT_ROW - for field_name in pivot_spec.get("col_fields") or []: - pivot.PivotFields(field_name).Orientation = _XL_ORIENT_COLUMN - for field_name in pivot_spec.get("page_fields") or []: - pivot.PivotFields(field_name).Orientation = _XL_ORIENT_PAGE - - for data_field in pivot_spec.get("data_fields") or []: - field_name = data_field.get("field") - agg = data_field.get("agg", "Sum") - consolidation = _XL_CONSOLIDATION.get(agg) - if consolidation is None: - raise RuntimeError(f"unknown aggregation {agg!r}") - field = pivot.PivotFields(field_name) - field.Orientation = _XL_ORIENT_DATA - field.Function = consolidation - - # Manual item filters: hide the named items on their field. - for filter_spec in pivot_spec.get("filters") or []: - field_name = filter_spec.get("field") - for item_name in filter_spec.get("hide") or []: - try: - pivot.PivotFields(field_name).PivotItems(item_name).Visible = False - except Exception: - pass - - layout = pivot_spec.get("layout") - if isinstance(layout, str) and layout in _PIVOT_LAYOUT_MODES: - try: - pivot.RowAxisLayout(_PIVOT_LAYOUT_MODES[layout]) - except Exception: - pass - - grand = pivot_spec.get("grand_totals") or {} - if isinstance(grand, dict): - if "rows" in grand: - pivot.RowGrand = bool(grand["rows"]) - if "cols" in grand: - pivot.ColumnGrand = bool(grand["cols"]) - - pivot.RefreshTable() - return pivot - - -def _run_formula_probes(wb, probes: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Write and read post-build formulas against a materialised PivotTable. - - Formula probes are intentionally evaluated only after ``RefreshTable``. - A rendered PivotTable grid cannot exercise GETPIVOTDATA's page/data-axis - routing, whereas a formula cell can. The result shape matches the - scalar grid records: ``{"kind": ..., "value": ...}`` or - ``{"kind": "error", "code": "#REF!"}``. - """ - - if not isinstance(probes, list) or not probes: - return [] - out: List[Dict[str, Any]] = [] - for probe in probes: - if not isinstance(probe, dict): - raise RuntimeError("pivot formula_probes entries must be objects") - probe_id = probe.get("id") - cell_ref = probe.get("cell") - formula = probe.get("formula") - if not isinstance(probe_id, str) or not probe_id: - raise RuntimeError("pivot formula probe missing string 'id'") - if not isinstance(cell_ref, str) or "!" not in cell_ref: - raise RuntimeError(f"formula probe {probe_id!r} needs sheet-qualified cell") - if not isinstance(formula, str) or not formula: - raise RuntimeError(f"formula probe {probe_id!r} missing string 'formula'") - sheet_name, bare_addr = _split_sheet_qualified_addr(cell_ref) - target = None - for sht in wb.sheets: - if sht.name.casefold() == sheet_name.casefold(): - target = sht - break - if target is None: - raise RuntimeError(f"formula probe {probe_id!r} references unknown sheet {sheet_name!r}") - result_cell = target.range(bare_addr) - try: - result_cell.formula2 = formula - except Exception: - result_cell.formula = formula - - try: - wb.app.calculate() - except Exception as exc: - # Do not silently emit stale values from a prior probe. A formula - # result such as #REF! is a normal cell value and does not raise here; - # a COM calculation failure is a driver failure and must be visible. - raise RuntimeError(f"Excel failed to calculate formula probes: {_format_com_error(exc)}") from exc - - for probe in probes: - sheet_name, bare_addr = _split_sheet_qualified_addr(probe["cell"]) - target = next(sht for sht in wb.sheets if sht.name.casefold() == sheet_name.casefold()) - result = _classify_value(_CellAdapter(target.range(bare_addr).api)) - out.append({"id": probe["id"], "cell": probe["cell"], "result": _grid_value_record(result)}) - return out - - -# Excel `XlPageOrientation` constants. -_XL_PORTRAIT = 1 -_XL_LANDSCAPE = 2 - -_PRINT_ORIENTATIONS = { - "portrait": _XL_PORTRAIT, - "landscape": _XL_LANDSCAPE, -} - -_PRINT_ORIENTATION_NAMES = {value: name for name, value in _PRINT_ORIENTATIONS.items()} - # `GET.DOCUMENT(50)` is the Excel-4 macro that returns the page count; # used as a fallback when `PageSetup.Pages.Count` is unavailable. _GET_DOCUMENT_PAGE_COUNT = 50 @@ -1298,11 +906,6 @@ def _run_formula_probes(wb, probes: List[Dict[str, Any]]) -> List[Dict[str, Any] # the break read independent of which case came before. _XL_PAGE_BREAK_PREVIEW_ZOOM = 60 -# Excel COM reports `PageSetup.LeftMargin` etc. in points (72 pt = 1 -# inch). The Formulon `PageMargins` struct works in inches, so we -# divide on read. -_POINTS_PER_INCH = 72.0 - # How many times a case's pagination is read before the capture gives up # on it settling. Two consecutive agreeing reads end the loop, so a stable @@ -1406,19 +1009,6 @@ def _read_pagination_settled(ws, page_setup, wb) -> Tuple[List[int], List[int], ) -def _resolve_print_sheet(wb, print_spec: Dict[str, Any]): - """Returns the worksheet the `print` block names, or raises.""" - - sheet_name = print_spec.get("sheet") - if not isinstance(sheet_name, str) or not sheet_name: - raise RuntimeError("print block missing string 'sheet'") - target = sheet_name.casefold() - for sht in wb.sheets: - if sht.name.casefold() == target: - return sht - raise RuntimeError(f"print 'sheet' names an unknown sheet {sheet_name!r}") - - def _apply_and_read_print(wb, print_spec: Dict[str, Any]) -> Dict[str, Any]: """Applies the declarative `print` block to ``wb`` and reads the result. @@ -1680,416 +1270,6 @@ def points(getter) -> Any: return out -# A whole-column print area would otherwise issue 16,384 COM round trips -# for a diagnostic nobody asserts. -_GEOMETRY_TRACK_CAP = 64 - - -def _split_a1(addr: str) -> Tuple[int, int]: - """Splits a bare A1 address into 1-based (row, column) COM indices.""" - - addr = addr.replace("$", "") - i = 0 - while i < len(addr) and addr[i].isalpha(): - i += 1 - col = 0 - for ch in addr[:i]: - col = col * 26 + (ord(ch.upper()) - ord("A") + 1) - return int(addr[i:]), col - - -def _read_orientation_value(page_setup) -> Any: - """Returns the post-apply Orientation as `"portrait"` / `"landscape"`. - - Load-bearing rather than cosmetic: the pagination read can answer from - a page geometry the case never asked for (a portrait case coming back - with the preceding landscape case's breaks), and without the applied - orientation beside the breaks that is indistinguishable from Excel - genuinely disagreeing with the C++ paginator. An unreadable or - unrecognised value is reported as-is rather than guessed at. - """ - - try: - value = int(page_setup.Orientation) - except Exception: - return None - return _PRINT_ORIENTATION_NAMES.get(value, value) - - -def _read_paper_value(page_setup) -> Any: - """Returns the post-apply `PaperSize` as the `XlPaperSize` int. - - The companion to the orientation read: paper and orientation together - are what fix the page the breaks were measured on. - """ - - try: - return int(page_setup.PaperSize) - except Exception: - return None - - -def _read_zoom_value(page_setup) -> Any: - """Returns the post-apply Zoom: int percent, or `False` when Fit-active. - - Excel's COM ``PageSetup.Zoom`` is ``False`` when "Fit to" pagination - is engaged, else an integer percent (10..400). Boolean is checked - before numeric because Python's ``isinstance(False, int)`` is True. - """ - - try: - val = page_setup.Zoom - except Exception: - return None - if isinstance(val, bool): - return False if val is False else True - if isinstance(val, (int, float)): - return int(val) - return None - - -def _read_fit_value(page_setup, name: str) -> Any: - """Returns the post-apply ``FitToPagesWide/Tall``: int or False (auto). - - Excel returns ``False`` for the "auto" / unset axis and an integer - (1..32767) when constrained. Same bool-before-int ordering applies - as for Zoom. - """ - - try: - val = getattr(page_setup, name) - except Exception: - return None - if isinstance(val, bool): - return False - if isinstance(val, (int, float)): - return int(val) - return None - - -_MARGIN_ATTRS = ( - ("left", "LeftMargin"), - ("right", "RightMargin"), - ("top", "TopMargin"), - ("bottom", "BottomMargin"), - ("header", "HeaderMargin"), - ("footer", "FooterMargin"), -) - - -def _read_margins(page_setup) -> Dict[str, Any]: - """Returns the post-apply page margins in inches. - - Excel's COM `PageSetup.{Left,Right,Top,Bottom,Header,Footer}Margin` - are points; we divide by 72 so the golden surfaces inches (matching - Formulon's `PageMargins` struct and OOXML's tag, which - are both inch-denominated). The body-height calibration hunt in the - print_matrix follow-up needs these values to distinguish "Excel - applied the OOXML defaults" from "Excel applied a workbook-template - preset that we never asked for". - """ - - out: Dict[str, Any] = {} - for key, attr in _MARGIN_ATTRS: - try: - val = getattr(page_setup, attr) - except Exception: - out[key] = None - continue - if isinstance(val, (int, float)) and not isinstance(val, bool): - out[key] = round(float(val) / _POINTS_PER_INCH, 6) - else: - out[key] = None - return out - - -# `XlPageBreak`. A manual break authored into the file must read back as -# manual; one that degraded to automatic means Excel discarded the -# `` we wrote and re-derived the break itself. -_XL_PAGE_BREAK_MANUAL = -4135 - -# The header/footer sections Excel exposes per page class, in the order -# OOXML concatenates them into one string (`&L...&C...&R...`). -_HEADER_FOOTER_POSITIONS = ("Left", "Center", "Right") - -# `PageSetup.` for the odd/primary pages, and the `Page` object -# carrying the same three positions for the even and first-page classes. -_HEADER_FOOTER_CLASSES = ( - ("odd", None), - ("even", "EvenPage"), - ("first", "FirstPage"), -) - - -def _com_scalar(owner, attr: str) -> Any: - """Reads one COM property, mapping an unavailable one to ``None``. - - A property Excel does not expose on this host must not abort the - capture: the golden records `null` and the comparison skips that - field, which is honest about what was observed. - """ - - try: - return getattr(owner, attr) - except Exception: - return None - - -def _com_bool(owner, attr: str) -> Optional[bool]: - value = _com_scalar(owner, attr) - return bool(value) if isinstance(value, bool) else None - - -def _com_int(owner, attr: str) -> Optional[int]: - value = _com_scalar(owner, attr) - if isinstance(value, bool) or not isinstance(value, (int, float)): - return None - return int(value) - - -def _com_text(owner, attr: str) -> Optional[str]: - value = _com_scalar(owner, attr) - return value if isinstance(value, str) else None - - -def _read_header_footer(page_setup) -> Dict[str, Any]: - """Reads the six header/footer sections as Excel reports them. - - OOXML stores one string per section with `&L` / `&C` / `&R` markers - inside it; COM splits the same content across three properties. The - golden records the split form, because that is what Excel actually - parsed the string into -- collapsing it back would hide a case where - Excel put our text in the wrong third. - """ - - out: Dict[str, Any] = { - "different_odd_even": _com_bool(page_setup, "OddAndEvenPagesHeaderFooter"), - "different_first": _com_bool(page_setup, "DifferentFirstPageHeaderFooter"), - "scale_with_doc": _com_bool(page_setup, "ScaleWithDocHeaderFooter"), - "align_with_margins": _com_bool(page_setup, "AlignMarginsHeaderFooter"), - } - for class_name, page_attr in _HEADER_FOOTER_CLASSES: - owner = page_setup if page_attr is None else _com_scalar(page_setup, page_attr) - for band in ("Header", "Footer"): - for position in _HEADER_FOOTER_POSITIONS: - key = f"{class_name}_{band.lower()}_{position.lower()}" - if owner is None: - out[key] = None - continue - if page_attr is None: - # The odd/primary sections are plain strings on - # `PageSetup` itself. - out[key] = _com_text(owner, f"{position}{band}") - continue - section = _com_scalar(owner, f"{position}{band}") - out[key] = None if section is None else _com_text(section, "Text") - return out - - -def _read_manual_breaks(ws) -> Dict[str, List[int]]: - """Returns the manual row / column breaks as zero-based indices. - - Automatic breaks are filtered out: an authored break that Excel - re-derived rather than honoured is the failure this case exists to - catch, and keeping both kinds in one list would let a coincidental - automatic break at the same position mask it. - """ - - rows: List[int] = [] - try: - for i in range(1, int(ws.api.HPageBreaks.Count) + 1): - brk = ws.api.HPageBreaks(i) - if _com_int(brk, "Type") == _XL_PAGE_BREAK_MANUAL: - rows.append(int(brk.Location.Row) - 1) - except Exception: - pass - cols: List[int] = [] - try: - for i in range(1, int(ws.api.VPageBreaks.Count) + 1): - brk = ws.api.VPageBreaks(i) - if _com_int(brk, "Type") == _XL_PAGE_BREAK_MANUAL: - cols.append(int(brk.Location.Column) - 1) - except Exception: - pass - rows.sort() - cols.sort() - return {"manual_row_breaks": rows, "manual_col_breaks": cols} - - -def _read_roundtrip(sht) -> Dict[str, Any]: - """Reads every print setting Excel resolved from an opened workbook.""" - - page_setup = sht.api.PageSetup - print_area = _com_text(page_setup, "PrintArea") or "" - title_rows = _com_text(page_setup, "PrintTitleRows") or "" - title_cols = _com_text(page_setup, "PrintTitleColumns") or "" - - observed: Dict[str, Any] = { - "page_setup": { - "paper_size": _com_int(page_setup, "PaperSize"), - "orientation": _com_int(page_setup, "Orientation"), - "zoom": _read_zoom_value(page_setup), - "fit_to_pages_wide": _read_fit_value(page_setup, "FitToPagesWide"), - "fit_to_pages_tall": _read_fit_value(page_setup, "FitToPagesTall"), - }, - "page_margins": _read_margins(page_setup), - "print_options": { - "grid_lines": _com_bool(page_setup, "PrintGridlines"), - "headings": _com_bool(page_setup, "PrintHeadings"), - "horizontal_centered": _com_bool(page_setup, "CenterHorizontally"), - "vertical_centered": _com_bool(page_setup, "CenterVertically"), - }, - "header_footer": _read_header_footer(page_setup), - "print_area": _normalise_print_area(print_area), - "print_title_rows": _normalise_print_area(title_rows), - "print_title_cols": _normalise_print_area(title_cols), - } - observed.update(_read_manual_breaks(sht)) - return observed - - -def _normalise_print_area(area: str) -> str: - """Strips ``$`` anchors and ``Sheet!`` qualifiers from a print area. - - Excel reports ``PrintArea`` fully qualified and anchored - (``Sheet1!$A$1:$H$80``); the C++ engine compares against a bare - ``A1:H80`` form, so both sides normalise the same way. - """ - - out_parts: List[str] = [] - for part in area.split(","): - token = part.strip() - if not token: - continue - bang = token.rfind("!") - if bang != -1: - token = token[bang + 1 :] - token = token.replace("$", "") - out_parts.append(token) - return ",".join(out_parts) - - -def _read_pivot_grid(wb, table_range) -> "tuple[List[Dict[str, Any]], int, int]": - """Reads every cell of ``table_range`` into an anchor-relative grid. - - Returns ``(grid, rows, cols)`` where ``grid`` is a list of - ``{"r", "c", "value"}`` records (``r`` / ``c`` are 0-based offsets - from the pivot anchor) and ``value`` is the normalised ``{kind, - value}`` record produced by ``_classify_value``. - """ - - rows = int(table_range.Rows.Count) - cols = int(table_range.Columns.Count) - top = int(table_range.Row) - left = int(table_range.Column) - sht = table_range.Worksheet - - grid: List[Dict[str, Any]] = [] - for r in range(rows): - for c in range(cols): - com_cell = sht.Cells(top + r, left + c) - result = _classify_value(_CellAdapter(com_cell)) - grid.append( - { - "r": r, - "c": c, - "value": _grid_value_record(result), - } - ) - return grid, rows, cols - - -def _grid_value_record(result: CaseResult) -> Dict[str, Any]: - """Shapes a ``CaseResult`` into the golden grid's ``{kind, value}``.""" - - if result.kind == "blank": - return {"kind": "blank"} - if result.kind == "error": - return {"kind": "error", "code": result.error_code or "#UNKNOWN!"} - return {"kind": result.kind, "value": result.value} - - -class _CellAdapter: - """Adapts a raw COM ``Range`` to the small surface ``_classify_value`` - expects (a ``.value`` attribute plus the COM passthrough used by the - error / displayed-text fallbacks). - """ - - def __init__(self, com_cell) -> None: - self.api = com_cell - - @property - def value(self) -> Any: - return self.api.Value - - -def _format_com_error(exc: BaseException) -> str: - """Returns a one-line summary of a pywin32 / xlwings exception. - - Includes the COM HRESULT and Excel's localised description when - present, so per-target divergence triage can match on the actual - failure (e.g. ``COM -2147352567: 例外が発生しました。``) rather - than a generic Python traceback. Falls back to ``repr(exc)`` for - non-COM exceptions. - """ - - try: - args = getattr(exc, "args", ()) or () - if args and isinstance(args[0], int): - hresult = args[0] - descr = "" - if len(args) >= 2 and isinstance(args[1], str): - descr = args[1] - # pywin32 com_error.args = (hresult, source, excepinfo, argerr) - # excepinfo = (wcode, source, description, helpfile, helpcontext, scode) - # When the HRESULT is the generic DISP_E_EXCEPTION the real - # Excel error message lives in excepinfo[2], so pull it out. - extras: List[str] = [] - if len(args) >= 3 and args[2] is not None: - info = args[2] - if isinstance(info, tuple) and len(info) >= 3: - src = info[1] if isinstance(info[1], str) else "" - info_descr = info[2] if isinstance(info[2], str) else "" - scode = info[5] if len(info) >= 6 else None - if info_descr.strip(): - extras.append(info_descr.strip()) - if src.strip(): - extras.append(f"source={src.strip()}") - if isinstance(scode, int): - extras.append(f"scode=0x{scode & 0xFFFFFFFF:08X}") - if extras: - descr = (descr + " -- " if descr else "") + "; ".join(extras) - return f"COM {hresult}: {descr}".strip().rstrip(":") - except Exception: - pass - return f"{type(exc).__name__}: {exc}"[:200] - - -_SHEET_FORBIDDEN = set("\\/?*[]:") - - -def _sanitize_sheet_name(name: str) -> str: - """Strips characters Excel disallows in sheet names and trims length.""" - - cleaned = "".join("_" if c in _SHEET_FORBIDDEN else c for c in name) - return cleaned[:24] or "case" - - -_ERROR_TRIGGERS = { - "#DIV/0!": "=1/0", - "#NAME?": "=NONEXISTENT_FUNC()", - "#VALUE!": '=VALUE("x")', - "#NUM!": "=SQRT(-1)", - "#N/A": "=NA()", - "#REF!": "=OFFSET(A1,-1,-1)", - "#NULL!": "=A1 B1", -} - - -def _error_trigger(code: str) -> str: - return _ERROR_TRIGGERS.get(code, '=VALUE("x")') - - def _env_to_json(env: EnvironmentInfo) -> Dict[str, Any]: return { "excel_version": env.excel_version, diff --git a/tools/oracle/drivers/windows_page_setup.py b/tools/oracle/drivers/windows_page_setup.py new file mode 100644 index 000000000..992f49471 --- /dev/null +++ b/tools/oracle/drivers/windows_page_setup.py @@ -0,0 +1,255 @@ +"""PageSetup readers: what Excel resolved for a sheet's print settings. + +Each reader maps an unavailable COM property to ``None`` so the golden records +what was observed rather than aborting the capture. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Tuple + +from .case_sheet import PRINT_ORIENTATIONS, normalise_print_area +from .windows_com import com_bool, com_int, com_scalar, com_text + +_PRINT_ORIENTATION_NAMES = {value: name for name, value in PRINT_ORIENTATIONS.items()} + +# Excel COM reports `PageSetup.LeftMargin` etc. in points (72 pt = 1 +# inch). The Formulon `PageMargins` struct works in inches, so we +# divide on read. +_POINTS_PER_INCH = 72.0 + +# A whole-column print area would otherwise issue 16,384 COM round trips +# for a diagnostic nobody asserts. +GEOMETRY_TRACK_CAP = 64 + + +def split_a1(addr: str) -> Tuple[int, int]: + """Splits a bare A1 address into 1-based (row, column) COM indices.""" + + addr = addr.replace("$", "") + i = 0 + while i < len(addr) and addr[i].isalpha(): + i += 1 + col = 0 + for ch in addr[:i]: + col = col * 26 + (ord(ch.upper()) - ord("A") + 1) + return int(addr[i:]), col + + +def read_orientation_value(page_setup) -> Any: + """Returns the post-apply Orientation as `"portrait"` / `"landscape"`. + + Load-bearing rather than cosmetic: the pagination read can answer from + a page geometry the case never asked for (a portrait case coming back + with the preceding landscape case's breaks), and without the applied + orientation beside the breaks that is indistinguishable from Excel + genuinely disagreeing with the C++ paginator. An unreadable or + unrecognised value is reported as-is rather than guessed at. + """ + + try: + value = int(page_setup.Orientation) + except Exception: + return None + return _PRINT_ORIENTATION_NAMES.get(value, value) + + +def read_paper_value(page_setup) -> Any: + """Returns the post-apply `PaperSize` as the `XlPaperSize` int. + + The companion to the orientation read: paper and orientation together + are what fix the page the breaks were measured on. + """ + + try: + return int(page_setup.PaperSize) + except Exception: + return None + + +def read_zoom_value(page_setup) -> Any: + """Returns the post-apply Zoom: int percent, or `False` when Fit-active. + + Excel's COM ``PageSetup.Zoom`` is ``False`` when "Fit to" pagination + is engaged, else an integer percent (10..400). Boolean is checked + before numeric because Python's ``isinstance(False, int)`` is True. + """ + + try: + val = page_setup.Zoom + except Exception: + return None + if isinstance(val, bool): + return False if val is False else True + if isinstance(val, (int, float)): + return int(val) + return None + + +def read_fit_value(page_setup, name: str) -> Any: + """Returns the post-apply ``FitToPagesWide/Tall``: int or False (auto). + + Excel returns ``False`` for the "auto" / unset axis and an integer + (1..32767) when constrained. Same bool-before-int ordering applies + as for Zoom. + """ + + try: + val = getattr(page_setup, name) + except Exception: + return None + if isinstance(val, bool): + return False + if isinstance(val, (int, float)): + return int(val) + return None + + +_MARGIN_ATTRS = ( + ("left", "LeftMargin"), + ("right", "RightMargin"), + ("top", "TopMargin"), + ("bottom", "BottomMargin"), + ("header", "HeaderMargin"), + ("footer", "FooterMargin"), +) + + +def read_margins(page_setup) -> Dict[str, Any]: + """Returns the post-apply page margins in inches. + + Excel's COM `PageSetup.{Left,Right,Top,Bottom,Header,Footer}Margin` + are points; we divide by 72 so the golden surfaces inches (matching + Formulon's `PageMargins` struct and OOXML's tag, which + are both inch-denominated). The body-height calibration hunt in the + print_matrix follow-up needs these values to distinguish "Excel + applied the OOXML defaults" from "Excel applied a workbook-template + preset that we never asked for". + """ + + out: Dict[str, Any] = {} + for key, attr in _MARGIN_ATTRS: + try: + val = getattr(page_setup, attr) + except Exception: + out[key] = None + continue + if isinstance(val, (int, float)) and not isinstance(val, bool): + out[key] = round(float(val) / _POINTS_PER_INCH, 6) + else: + out[key] = None + return out + + +# `XlPageBreak`. A manual break authored into the file must read back as +# manual; one that degraded to automatic means Excel discarded the +# `` we wrote and re-derived the break itself. +_XL_PAGE_BREAK_MANUAL = -4135 + +# The header/footer sections Excel exposes per page class, in the order +# OOXML concatenates them into one string (`&L...&C...&R...`). +_HEADER_FOOTER_POSITIONS = ("Left", "Center", "Right") + +# `PageSetup.` for the odd/primary pages, and the `Page` object +# carrying the same three positions for the even and first-page classes. +_HEADER_FOOTER_CLASSES = ( + ("odd", None), + ("even", "EvenPage"), + ("first", "FirstPage"), +) + + +def _read_header_footer(page_setup) -> Dict[str, Any]: + """Reads the six header/footer sections as Excel reports them. + + OOXML stores one string per section with `&L` / `&C` / `&R` markers + inside it; COM splits the same content across three properties. The + golden records the split form, because that is what Excel actually + parsed the string into -- collapsing it back would hide a case where + Excel put our text in the wrong third. + """ + + out: Dict[str, Any] = { + "different_odd_even": com_bool(page_setup, "OddAndEvenPagesHeaderFooter"), + "different_first": com_bool(page_setup, "DifferentFirstPageHeaderFooter"), + "scale_with_doc": com_bool(page_setup, "ScaleWithDocHeaderFooter"), + "align_with_margins": com_bool(page_setup, "AlignMarginsHeaderFooter"), + } + for class_name, page_attr in _HEADER_FOOTER_CLASSES: + owner = page_setup if page_attr is None else com_scalar(page_setup, page_attr) + for band in ("Header", "Footer"): + for position in _HEADER_FOOTER_POSITIONS: + key = f"{class_name}_{band.lower()}_{position.lower()}" + if owner is None: + out[key] = None + continue + if page_attr is None: + # The odd/primary sections are plain strings on + # `PageSetup` itself. + out[key] = com_text(owner, f"{position}{band}") + continue + section = com_scalar(owner, f"{position}{band}") + out[key] = None if section is None else com_text(section, "Text") + return out + + +def _read_manual_breaks(ws) -> Dict[str, List[int]]: + """Returns the manual row / column breaks as zero-based indices. + + Automatic breaks are filtered out: an authored break that Excel + re-derived rather than honoured is the failure this case exists to + catch, and keeping both kinds in one list would let a coincidental + automatic break at the same position mask it. + """ + + rows: List[int] = [] + try: + for i in range(1, int(ws.api.HPageBreaks.Count) + 1): + brk = ws.api.HPageBreaks(i) + if com_int(brk, "Type") == _XL_PAGE_BREAK_MANUAL: + rows.append(int(brk.Location.Row) - 1) + except Exception: + pass + cols: List[int] = [] + try: + for i in range(1, int(ws.api.VPageBreaks.Count) + 1): + brk = ws.api.VPageBreaks(i) + if com_int(brk, "Type") == _XL_PAGE_BREAK_MANUAL: + cols.append(int(brk.Location.Column) - 1) + except Exception: + pass + rows.sort() + cols.sort() + return {"manual_row_breaks": rows, "manual_col_breaks": cols} + + +def read_roundtrip(sht) -> Dict[str, Any]: + """Reads every print setting Excel resolved from an opened workbook.""" + + page_setup = sht.api.PageSetup + print_area = com_text(page_setup, "PrintArea") or "" + title_rows = com_text(page_setup, "PrintTitleRows") or "" + title_cols = com_text(page_setup, "PrintTitleColumns") or "" + + observed: Dict[str, Any] = { + "page_setup": { + "paper_size": com_int(page_setup, "PaperSize"), + "orientation": com_int(page_setup, "Orientation"), + "zoom": read_zoom_value(page_setup), + "fit_to_pages_wide": read_fit_value(page_setup, "FitToPagesWide"), + "fit_to_pages_tall": read_fit_value(page_setup, "FitToPagesTall"), + }, + "page_margins": read_margins(page_setup), + "print_options": { + "grid_lines": com_bool(page_setup, "PrintGridlines"), + "headings": com_bool(page_setup, "PrintHeadings"), + "horizontal_centered": com_bool(page_setup, "CenterHorizontally"), + "vertical_centered": com_bool(page_setup, "CenterVertically"), + }, + "header_footer": _read_header_footer(page_setup), + "print_area": normalise_print_area(print_area), + "print_title_rows": normalise_print_area(title_rows), + "print_title_cols": normalise_print_area(title_cols), + } + observed.update(_read_manual_breaks(sht)) + return observed diff --git a/tools/oracle/drivers/windows_pivot.py b/tools/oracle/drivers/windows_pivot.py new file mode 100644 index 000000000..18d0783b9 --- /dev/null +++ b/tools/oracle/drivers/windows_pivot.py @@ -0,0 +1,244 @@ +"""PivotTable construction and grid readout through the Excel COM object model.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from .base import CaseResult +from .case_sheet import split_sheet_qualified_addr +from .cell_result import classify_value +from .windows_com import CellAdapter, cell_displayed_text, format_com_error + +# Excel `XlConsolidationFunction` constants, keyed by the declarative +# `agg` name. Mirrors the `Aggregation` enum the C++ builder accepts. +# Numeric values are the documented Office automation constants so the +# driver does not depend on the `win32com` constant cache being warm. +_XL_CONSOLIDATION = { + "Sum": -4157, + "Count": -4112, + "Average": -4106, + "Max": -4136, + "Min": -4139, + "Product": -4149, + "CountNumbers": -4113, + "StdDev": -4155, + "StdDevP": -4156, + "Var": -4164, + "VarP": -4165, +} + +# Excel `XlPivotFieldOrientation` constants. +_XL_ORIENT_ROW = 1 +_XL_ORIENT_COLUMN = 2 +_XL_ORIENT_PAGE = 3 +_XL_ORIENT_DATA = 4 + +# Excel `XlPivotTableSourceType.xlDatabase`. +_XL_DATABASE = 1 + +# Excel `XlLayoutRowType` values for `RowAxisLayout`. +_XL_COMPACT_ROW = 0 +_XL_TABULAR_ROW = 1 +_XL_OUTLINE_ROW = 2 + +_PIVOT_LAYOUT_MODES = { + "Compact": _XL_COMPACT_ROW, + "Tabular": _XL_TABULAR_ROW, + "Outline": _XL_OUTLINE_ROW, +} + + +def build_pivot_table(wb, pivot_spec: Dict[str, Any]): + """Creates a PivotTable in ``wb`` from a declarative ``pivot`` block. + + Returns the materialised ``PivotTable`` COM object. The caller reads + ``TableRange2`` for the rendered grid and may then run post-build + formula probes. The ``pivot`` block shape is documented on + ``tests/oracle/workbook_builder.h``. + """ + + source = pivot_spec.get("source") + anchor = pivot_spec.get("anchor") + if not isinstance(source, str) or not source: + raise RuntimeError("pivot block missing string 'source'") + if not isinstance(anchor, str) or not anchor: + raise RuntimeError("pivot block missing string 'anchor'") + + # Resolve the declarative anchor "Report!A1" to the destination + # cell's COM Range, since CreatePivotTable accepts a Range here and + # the string form has the same fully-qualified requirement as + # SourceData. We also drive the source resolution through a Range + # object so PivotCaches.Create does not have to parse the address + # itself. + src_sheet_name, src_addr = split_sheet_qualified_addr(source) + anchor_sheet_name, anchor_addr = split_sheet_qualified_addr(anchor) + if src_sheet_name is None: + raise RuntimeError(f"pivot source must be sheet-qualified (e.g. 'Data!A1:C13'); got {source!r}") + if anchor_sheet_name is None: + raise RuntimeError(f"pivot anchor must be sheet-qualified (e.g. 'Report!A1'); got {anchor!r}") + + def _find_sheet(name: str): + for sht in wb.sheets: + if sht.name.casefold() == name.casefold(): + return sht + return None + + src_sheet = _find_sheet(src_sheet_name) + anchor_sheet = _find_sheet(anchor_sheet_name) + if src_sheet is None: + raise RuntimeError(f"pivot source references unknown sheet {src_sheet_name!r}") + if anchor_sheet is None: + raise RuntimeError(f"pivot anchor references unknown sheet {anchor_sheet_name!r}") + + source_range_api = src_sheet.range(src_addr).api + anchor_range_api = anchor_sheet.range(anchor_addr).api + + # Activate the anchor sheet so CreatePivotTable's destination is on + # the active sheet -- some Excel builds reject creating a pivot + # whose destination is on an inactive sheet with E_INVALIDARG. + try: + anchor_sheet.activate() + except Exception: + pass + + api = wb.api + cache = api.PivotCaches().Create(_XL_DATABASE, source_range_api) + pivot = cache.CreatePivotTable(anchor_range_api, "FormulonPivot") + + for field_name in pivot_spec.get("row_fields") or []: + pivot.PivotFields(field_name).Orientation = _XL_ORIENT_ROW + for field_name in pivot_spec.get("col_fields") or []: + pivot.PivotFields(field_name).Orientation = _XL_ORIENT_COLUMN + for field_name in pivot_spec.get("page_fields") or []: + pivot.PivotFields(field_name).Orientation = _XL_ORIENT_PAGE + + for data_field in pivot_spec.get("data_fields") or []: + field_name = data_field.get("field") + agg = data_field.get("agg", "Sum") + consolidation = _XL_CONSOLIDATION.get(agg) + if consolidation is None: + raise RuntimeError(f"unknown aggregation {agg!r}") + field = pivot.PivotFields(field_name) + field.Orientation = _XL_ORIENT_DATA + field.Function = consolidation + + # Manual item filters: hide the named items on their field. + for filter_spec in pivot_spec.get("filters") or []: + field_name = filter_spec.get("field") + for item_name in filter_spec.get("hide") or []: + try: + pivot.PivotFields(field_name).PivotItems(item_name).Visible = False + except Exception: + pass + + layout = pivot_spec.get("layout") + if isinstance(layout, str) and layout in _PIVOT_LAYOUT_MODES: + try: + pivot.RowAxisLayout(_PIVOT_LAYOUT_MODES[layout]) + except Exception: + pass + + grand = pivot_spec.get("grand_totals") or {} + if isinstance(grand, dict): + if "rows" in grand: + pivot.RowGrand = bool(grand["rows"]) + if "cols" in grand: + pivot.ColumnGrand = bool(grand["cols"]) + + pivot.RefreshTable() + return pivot + + +def run_formula_probes(wb, probes: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Write and read post-build formulas against a materialised PivotTable. + + Formula probes are intentionally evaluated only after ``RefreshTable``. + A rendered PivotTable grid cannot exercise GETPIVOTDATA's page/data-axis + routing, whereas a formula cell can. The result shape matches the + scalar grid records: ``{"kind": ..., "value": ...}`` or + ``{"kind": "error", "code": "#REF!"}``. + """ + + if not isinstance(probes, list) or not probes: + return [] + out: List[Dict[str, Any]] = [] + for probe in probes: + if not isinstance(probe, dict): + raise RuntimeError("pivot formula_probes entries must be objects") + probe_id = probe.get("id") + cell_ref = probe.get("cell") + formula = probe.get("formula") + if not isinstance(probe_id, str) or not probe_id: + raise RuntimeError("pivot formula probe missing string 'id'") + if not isinstance(cell_ref, str) or "!" not in cell_ref: + raise RuntimeError(f"formula probe {probe_id!r} needs sheet-qualified cell") + if not isinstance(formula, str) or not formula: + raise RuntimeError(f"formula probe {probe_id!r} missing string 'formula'") + sheet_name, bare_addr = split_sheet_qualified_addr(cell_ref) + target = None + for sht in wb.sheets: + if sht.name.casefold() == sheet_name.casefold(): + target = sht + break + if target is None: + raise RuntimeError(f"formula probe {probe_id!r} references unknown sheet {sheet_name!r}") + result_cell = target.range(bare_addr) + try: + result_cell.formula2 = formula + except Exception: + result_cell.formula = formula + + try: + wb.app.calculate() + except Exception as exc: + # Do not silently emit stale values from a prior probe. A formula + # result such as #REF! is a normal cell value and does not raise here; + # a COM calculation failure is a driver failure and must be visible. + raise RuntimeError(f"Excel failed to calculate formula probes: {format_com_error(exc)}") from exc + + for probe in probes: + sheet_name, bare_addr = split_sheet_qualified_addr(probe["cell"]) + target = next(sht for sht in wb.sheets if sht.name.casefold() == sheet_name.casefold()) + result = classify_value(CellAdapter(target.range(bare_addr).api), None, cell_displayed_text) + out.append({"id": probe["id"], "cell": probe["cell"], "result": _grid_value_record(result)}) + return out + + +def read_pivot_grid(wb, table_range) -> "tuple[List[Dict[str, Any]], int, int]": + """Reads every cell of ``table_range`` into an anchor-relative grid. + + Returns ``(grid, rows, cols)`` where ``grid`` is a list of + ``{"r", "c", "value"}`` records (``r`` / ``c`` are 0-based offsets + from the pivot anchor) and ``value`` is the normalised ``{kind, + value}`` record produced by ``classify_value``. + """ + + rows = int(table_range.Rows.Count) + cols = int(table_range.Columns.Count) + top = int(table_range.Row) + left = int(table_range.Column) + sht = table_range.Worksheet + + grid: List[Dict[str, Any]] = [] + for r in range(rows): + for c in range(cols): + com_cell = sht.Cells(top + r, left + c) + result = classify_value(CellAdapter(com_cell), None, cell_displayed_text) + grid.append( + { + "r": r, + "c": c, + "value": _grid_value_record(result), + } + ) + return grid, rows, cols + + +def _grid_value_record(result: CaseResult) -> Dict[str, Any]: + """Shapes a ``CaseResult`` into the golden grid's ``{kind, value}``.""" + + if result.kind == "blank": + return {"kind": "blank"} + if result.kind == "error": + return {"kind": "error", "code": result.error_code or "#UNKNOWN!"} + return {"kind": result.kind, "value": result.value} From 42ad31a5eb27341d7a7962c43a0b9d76a8226ffe Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:03:35 +0900 Subject: [PATCH 02/93] refactor(dev): split check_binding_drift into a binding_drift package - move the C header parser, ABI baseline, declaration, Python and shape checks from check_binding_drift.py into tools/dev/binding_drift/ modules - keep check_binding_drift.py as the entrypoint that imports and runs the checks, still re-exporting c_abi_declarations for gen_c_abi_baseline.py - behavior-preserving move --- tools/dev/binding_drift/__init__.py | 1 + tools/dev/binding_drift/abi_checks.py | 151 ++ tools/dev/binding_drift/c_header.py | 232 ++ tools/dev/binding_drift/declaration_checks.py | 439 ++++ tools/dev/binding_drift/enum_decl.py | 135 ++ tools/dev/binding_drift/python_checks.py | 742 ++++++ tools/dev/binding_drift/shape_checks.py | 236 ++ tools/dev/binding_drift/surface_files.py | 84 + tools/dev/binding_drift/ts_decl.py | 159 ++ tools/dev/check_binding_drift.py | 2073 +---------------- 10 files changed, 2197 insertions(+), 2055 deletions(-) create mode 100644 tools/dev/binding_drift/__init__.py create mode 100644 tools/dev/binding_drift/abi_checks.py create mode 100644 tools/dev/binding_drift/c_header.py create mode 100644 tools/dev/binding_drift/declaration_checks.py create mode 100644 tools/dev/binding_drift/enum_decl.py create mode 100644 tools/dev/binding_drift/python_checks.py create mode 100644 tools/dev/binding_drift/shape_checks.py create mode 100644 tools/dev/binding_drift/surface_files.py create mode 100644 tools/dev/binding_drift/ts_decl.py diff --git a/tools/dev/binding_drift/__init__.py b/tools/dev/binding_drift/__init__.py new file mode 100644 index 000000000..0585db1e5 --- /dev/null +++ b/tools/dev/binding_drift/__init__.py @@ -0,0 +1 @@ +"""Shared parsers and drift checks behind tools/dev/check_binding_drift.py.""" diff --git a/tools/dev/binding_drift/abi_checks.py b/tools/dev/binding_drift/abi_checks.py new file mode 100644 index 000000000..28c560d6a --- /dev/null +++ b/tools/dev/binding_drift/abi_checks.py @@ -0,0 +1,151 @@ +"""Drift checks on the released C ABI baseline and the header error-code documentation.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import List + +from .c_header import c_abi_declarations +from .enum_decl import _cpp_enum_class_values +from .surface_files import C_ABI_BASELINE, C_ABI_BREAKS, CAPI_HEADER, ERROR_H, REPO_ROOT, _read + + +def _parse_abi_manifest(path: Path) -> List[str]: + """Non-comment, non-blank lines of a baseline / ledger file.""" + lines = [] + for raw in _read(path).splitlines(): + line = raw.strip() + if line and not line.startswith("#"): + lines.append(line) + return lines + + +_ABI_BREAK_RE = re.compile(r"^(removed|signature|retyped)\s+(fm_[A-Za-z0-9_]+)\s*::\s*(\S.*)$") +_ABI_BASELINE_RE = re.compile(r"^(.+?)\s+(fm_[A-Za-z0-9_]+)\((.*)\)$") + + +def check_abi_baseline() -> List[str]: + """The released C ABI surface is still declared, or its loss is recorded. + + A deletion or rename of a base entry point is invisible to every other + check here: five of the eight base/`_ex` families have no in-repo caller + at all, so the C API tests recompile against the new header and pass. The + only party that notices is a third-party consumer compiled against the + released header, which no test can stand in for -- hence a pinned copy of + that header's surface plus an explicit ledger of what was broken on + purpose. + + Signature comparison is by declared parameter *type*, so it catches an + added, dropped or retyped parameter. It does NOT catch a by-value struct + that keeps its name and changes size; that is a calling convention change + the `static_assert(sizeof(...))` tripwires in the C ABI tests pin instead. + """ + problems: List[str] = [] + + baseline: dict[str, tuple[str, List[str]]] = {} + for line in _parse_abi_manifest(C_ABI_BASELINE): + match = _ABI_BASELINE_RE.match(line) + if match is None: + problems.append(f"abi-baseline: unparsable baseline line: {line!r}") + continue + ret, name, params = match.groups() + parsed = [] if params.strip() in ("", "void") else [p.strip() for p in params.split(",")] + baseline[name] = (ret.strip(), parsed) + + ledger: dict[str, str] = {} + for line in _parse_abi_manifest(C_ABI_BREAKS): + match = _ABI_BREAK_RE.match(line) + if match is None: + problems.append(f"abi-baseline: unparsable ledger line: {line!r}") + continue + kind, name, _reason = match.groups() + if name in ledger: + problems.append(f"abi-baseline: {name} is listed twice in {C_ABI_BREAKS.name}") + ledger[name] = kind + + current = c_abi_declarations(_read(CAPI_HEADER)) + + for name, declared in sorted(baseline.items()): + recorded = ledger.get(name) + if name not in current: + if recorded != "removed": + problems.append( + f"abi-baseline: {name} shipped in the released header but is gone from " + f"{CAPI_HEADER.relative_to(REPO_ROOT)}. A consumer built against the release " + f"loses it at link time. Record the break as `removed {name} :: ` in " + f"{C_ABI_BREAKS.relative_to(REPO_ROOT)} and carry it into the CHANGELOG's " + "Removed section, or restore the entry point." + ) + continue + if current[name] != declared: + if recorded not in ("signature", "retyped"): + problems.append( + f"abi-baseline: {name} changed signature since the release.\n" + f" released: {declared[0]} {name}({', '.join(declared[1]) or 'void'})\n" + f" current: {current[name][0]} {name}({', '.join(current[name][1]) or 'void'})\n" + f" Record it in {C_ABI_BREAKS.relative_to(REPO_ROOT)} as `signature` (breaks a " + "stale caller) or `retyped` (invisible to a C caller and to the calling " + "convention), with the reason." + ) + continue + # Declared and unchanged: any ledger entry naming it is stale. + if recorded is not None: + problems.append( + f"abi-baseline: {C_ABI_BREAKS.relative_to(REPO_ROOT)} records {name} as " + f"`{recorded}`, but it is declared unchanged from the release. Drop the line -- a " + "ledger that outlives its break stops meaning anything." + ) + + for name in sorted(set(ledger) - set(baseline)): + problems.append( + f"abi-baseline: {C_ABI_BREAKS.relative_to(REPO_ROOT)} records {name}, which was never " + f"in the released surface. Only an entry point a consumer could have compiled against " + "can be broken; drop the line." + ) + + return problems + + +# A doc comment quotes a status code as `` `kName` (N) ``, and the number may +# wrap onto the next comment line, so the separator spans a `*` continuation +# marker as well as plain spaces. +_HEADER_ERROR_CODE_RE = re.compile(r"`?(k[A-Z][A-Za-z0-9_]*)`?(?:[ \t]|\n[ \t]*\*)*\((\d+)\)") + + +def check_header_error_codes() -> List[str]: + """Every numeric status code quoted in the C header matches its enumerator. + + `fm_status_t` is a bare `int32_t`, so the numbers the header's doc + comments print next to an enumerator name are the whole contract a + binding author reading only the header can act on. A comparison written + against a stale number never matches, and mis-classifies whatever code + actually holds that value. + """ + problems: List[str] = [] + + codes = _cpp_enum_class_values(_read(ERROR_H), "FormulonErrorCode") + if codes is None: + return [ + f"header-error-codes: could not parse `FormulonErrorCode` out of {ERROR_H.relative_to(REPO_ROOT)}; " + "the enum moved or gained a non-literal initializer" + ] + + header = _read(CAPI_HEADER) + for match in _HEADER_ERROR_CODE_RE.finditer(header): + name, quoted = match.group(1), match.group(2) + line_number = header.count("\n", 0, match.start()) + 1 + site = f"{CAPI_HEADER.name}:{line_number}" + if name not in codes: + problems.append( + f"header-error-codes: {site} documents `{name}` ({quoted}), which names no enumerator of " + f"`FormulonErrorCode` in {ERROR_H.relative_to(REPO_ROOT)}" + ) + continue + if int(quoted) != codes[name]: + problems.append( + f"header-error-codes: {site} documents `{name}` as ({quoted}), but " + f"{ERROR_H.relative_to(REPO_ROOT)} defines it as {codes[name]}" + ) + + return problems diff --git a/tools/dev/binding_drift/c_header.py b/tools/dev/binding_drift/c_header.py new file mode 100644 index 000000000..39adf44e2 --- /dev/null +++ b/tools/dev/binding_drift/c_header.py @@ -0,0 +1,232 @@ +"""Parsing of the C ABI header: wasm32 struct layouts, function declarations and struct fields.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import List, NamedTuple, Set + +# --------------------------------------------------------------------------- +# Check 1b: Python WASM32 POD layouts <-> C ABI header. +# --------------------------------------------------------------------------- + + +def _wasm32_layout(fields: List[tuple[str, int, int]]) -> tuple[dict[str, int], int, int]: + """Lays `fields` out in sequence, returning `(offsets, size, alignment)`.""" + offsets: dict[str, int] = {} + offset = 0 + max_align = 1 + for name, size, align in fields: + offset = (offset + align - 1) // align * align + offsets[name] = offset + offset += size + max_align = max(max_align, align) + return offsets, (offset + max_align - 1) // max_align * max_align, max_align + + +# wasm32 (ILP32) size and alignment for the leaf types the C ABI structs are +# built from. These are properties of the target ABI rather than anything the +# header states, which is why they are the only layout facts tabulated here: +# every type that has a declaration to read -- struct, union, array -- is +# measured from that declaration instead of quoted. A literal for an embedded +# struct would keep asserting its old size after the struct changed, which is +# the one thing this check exists to catch. +_WASM32_SCALARS = { + "uint8_t": (1, 1), + "uint16_t": (2, 2), + "uint32_t": (4, 4), + "int32_t": (4, 4), + "double": (8, 8), + "size_t": (4, 4), +} +_WASM32_POINTER = (4, 4) +# A C enum takes `int` representation. The header pins each enumerator's +# ordinal but says nothing about the storage width, so this is not derivable +# either; *which* names are enums is read off the header. +_WASM32_ENUM = (4, 4) + +_C_STRUCT_RE = re.compile(r"typedef\s+struct\s*\{(.*?)\}\s*(fm_[A-Za-z0-9_]+)\s*;", re.S) +_C_ENUM_RE = re.compile(r"typedef\s+enum\s*\{[^}]*\}\s*([A-Za-z_][A-Za-z0-9_]*)\s*;") +# An anonymous `union { ... } name;` member, matched before the enclosing body +# is split on `;` so the union's own members are not mistaken for the parent's. +_C_UNION_MEMBER_RE = re.compile(r"union\s*\{(.*?)\}\s*([A-Za-z_][A-Za-z0-9_]*)\s*;", re.S) +_C_DECLARATION_RE = re.compile(r"(.+?)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\[(\d+)\])?") + + +class _LayoutError(Exception): + """A C declaration the wasm32 layout resolver cannot measure.""" + + +class _Member(NamedTuple): + """One measured struct member. `ctype` is empty for an anonymous union.""" + + name: str + ctype: str + size: int + align: int + + +class _Wasm32Layouts: + """Measures the C ABI's structs against the wasm32 ABI, from the header. + + Struct members are resolved recursively, so an embedded POD is measured + rather than quoted: changing a field of `fm_color_spec` moves every struct + that embeds it, and the Python side is checked against the new offsets. + """ + + def __init__(self, header: str) -> None: + self._structs = {name: body for body, name in _C_STRUCT_RE.findall(header)} + self._enums = set(_C_ENUM_RE.findall(header)) + self._members: dict[str, List[_Member]] = {} + self._pending: Set[str] = set() + + def is_struct(self, ctype: str) -> bool: + return ctype in self._structs + + def members(self, struct_name: str) -> List[_Member]: + """Members of `struct_name` in declaration order, each measured.""" + cached = self._members.get(struct_name) + if cached is not None: + return cached + body = self._structs.get(struct_name) + if body is None: + raise _LayoutError(f"{struct_name} missing from C header") + if struct_name in self._pending: + raise _LayoutError(f"{struct_name} embeds itself") + self._pending.add(struct_name) + try: + measured = self._parse_body(struct_name, body) + finally: + self._pending.discard(struct_name) + self._members[struct_name] = measured + return measured + + def extent(self, ctype: str) -> tuple[int, int]: + """`(size, alignment)` of one member's declared type.""" + if "*" in ctype: + return _WASM32_POINTER + ctype = ctype.replace("const ", "").strip() + if ctype in self._structs: + _, size, align = _wasm32_layout([(m.name, m.size, m.align) for m in self.members(ctype)]) + return size, align + if ctype in _WASM32_SCALARS: + return _WASM32_SCALARS[ctype] + if ctype in self._enums: + return _WASM32_ENUM + raise _LayoutError(f"unknown field type {ctype!r}") + + def _parse_body(self, owner: str, body: str) -> List[_Member]: + measured: List[_Member] = [] + position = 0 + for union in _C_UNION_MEMBER_RE.finditer(body): + measured.extend(self._parse_declarations(owner, body[position : union.start()])) + size, align = self._union_extent(owner, union.group(1)) + measured.append(_Member(union.group(2), "", size, align)) + position = union.end() + measured.extend(self._parse_declarations(owner, body[position:])) + return measured + + def _parse_declarations(self, owner: str, text: str) -> List[_Member]: + measured: List[_Member] = [] + for declaration in text.split(";"): + declaration = " ".join(declaration.split()) + if not declaration: + continue + match = _C_DECLARATION_RE.fullmatch(declaration) + if not match: + raise _LayoutError(f"cannot parse {owner}: {declaration!r}") + ctype, name, count_text = match.groups() + size, align = self.extent(ctype) + measured.append(_Member(name, ctype.replace("const ", "").strip(), size * int(count_text or "1"), align)) + return measured + + def _union_extent(self, owner: str, body: str) -> tuple[int, int]: + """A union overlays its members: widest size, strictest alignment.""" + size = 0 + align = 1 + for member in self._parse_declarations(owner, body): + size = max(size, member.size) + align = max(align, member.align) + return (size + align - 1) // align * align, align + + +# --------------------------------------------------------------------------- +# Check 1c: Python `LIB.fm_*` call sites <-> the C header's declarations. +# +# `python-exports` matches call *names* only. Nothing compares what a call +# passes against what the entry point declares, so a parameter added to an +# existing `fm_*` function -- the shape a 1.0-frozen ABI is most likely to +# grow -- leaves the Python side calling the old signature and is caught only +# if a runtime binding test happens to exercise that path. +# --------------------------------------------------------------------------- + +_C_FUNCTION_DECL_RE = re.compile( + r"FM_API\s+([A-Za-z_][A-Za-z0-9_ *]*?)\b(fm_[A-Za-z0-9_]+)\s*\(([^;]*?)\)\s*;", + re.S, +) + + +def _split_c_params(text: str) -> List[str]: + """Splits a parameter list on its top-level commas.""" + parts: List[str] = [] + depth = 0 + current = "" + for char in text: + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if char == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += char + parts.append(current.strip()) + return parts + + +def _c_function_params(header: str) -> dict[str, List[str]]: + """Maps each `FM_API` entry point to its declared parameter types. + + The parameter *name* is stripped, so the values are types in declaration + order (`["fm_workbook_t*", "size_t", "fm_value_t*"]`). Every parameter in + this header is named, so dropping the last whitespace-delimited token is + unambiguous. + """ + return {name: params for name, (_, params) in c_abi_declarations(header).items()} + + +def c_abi_declarations(header: str) -> dict[str, tuple[str, List[str]]]: + """Maps each `FM_API` entry point to its `(return type, parameter types)`. + + Public because `gen_c_abi_baseline.py` writes the released-surface file + with it: generating the baseline through the same parser the check reads + it back with keeps a formatting difference from masquerading as a break. + """ + declarations: dict[str, tuple[str, List[str]]] = {} + for ret, name, params in _C_FUNCTION_DECL_RE.findall(header): + ret = " ".join(ret.split()) + params = " ".join(params.split()) + if params in ("", "void"): + declarations[name] = (ret, []) + continue + declarations[name] = (ret, [" ".join(param.split()[:-1]) for param in _split_c_params(params)]) + return declarations + + +def _c_struct_fields(header: str, struct_name: str, source: Path) -> List[str]: + blocks = {name: body for body, name in _C_STRUCT_RE.findall(header)} + body = blocks.get(struct_name) + if body is None: + print(f"check_binding_drift: struct {struct_name!r} not found in {source}", file=sys.stderr) + sys.exit(2) + fields: List[str] = [] + for declaration in body.split(";"): + declaration = " ".join(declaration.split()) + if not declaration: + continue + match = re.fullmatch(r"(.+?)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\[(\d+)\])?", declaration) + if match and not match.group(2).startswith("_pad"): + fields.append(match.group(2)) + return fields diff --git a/tools/dev/binding_drift/declaration_checks.py b/tools/dev/binding_drift/declaration_checks.py new file mode 100644 index 000000000..32fb8bd4c --- /dev/null +++ b/tools/dev/binding_drift/declaration_checks.py @@ -0,0 +1,439 @@ +"""Drift checks across the WASM and N-API `.d.ts` surfaces, the npm helpers and the staged package.""" + +from __future__ import annotations + +import re +from typing import List + +from .enum_decl import ( + _extract_c_typedef_enum, + _extract_cpp_enum_class, + _extract_ts_enum, + _parse_ts_named_enum, +) +from .surface_files import ( + _SKIPPED, + CALC_MODE_H, + CAPI_HEADER, + CF_MATCH_H, + EXTERNAL_LINKS_H, + NODE_ADDON_CC, + NODE_DIST_DIR, + NODE_DTS, + NODE_INDEX_MJS, + NODE_README, + NODE_WORKBOOK_CLASS_CC, + NPM_COMMON_MJS, + NPM_ENTRY_MJS, + REPO_ROOT, + VALUE_H, + WASM_BINDINGS_CPP, + WASM_DTS, + _format_diff, + _read, + _read_bytes, +) +from .ts_decl import ( + _extract_ts_methods, + _find_interface_body, + _js_exported_function_source, + _parse_js_constants, +) + +# embind auto-adds a `delete()` finaliser to every `class_` -- it has no +# corresponding `.function(...)` registration, so it must be excluded before +# comparing the WASM Workbook interface against bindings_register.cpp. +WASM_AUTO_METHODS = {"delete"} + +# Intentional binding differences are kept exact. This makes a newly added +# method or a removed method fail drift checking instead of becoming a stale +# exception in one binding. +WASM_ONLY_METHODS = { + "addCellStyleXf", + "createTable", + "getSheetAutoFilterXml", + "removeTable", + "setCellStyle", + "setSheetAutoFilterXml", + "updateTable", +} +NODE_ONLY_METHODS = {"dispose", "memoryUsage"} + +# Pure-JS free functions declared in packages/npm-native/index.d.ts that are +# intentionally NOT backed by a native `exports.Set(...)` registration -- they +# are host-side helpers implemented in index.mjs (e.g. the function-metadata +# provider merge helper). Excluded before comparing the d.ts free-function +# surface against the addon's native exports. +NODE_PURE_JS_FREE_FUNCTIONS = {"mergeFunctionMetadata"} + + +# --------------------------------------------------------------------------- +# Check 2a: WASM embind registration <-> src/wasm/formulon.d.ts. +# --------------------------------------------------------------------------- + + +def check_dts_wasm() -> List[str]: + problems: List[str] = [] + cpp_text = _read(WASM_BINDINGS_CPP) + + instance_cpp = set(re.findall(r'\.function\("([^"]+)"', cpp_text)) + static_cpp = set(re.findall(r'\.class_function\("([^"]+)"', cpp_text)) + free_cpp = set(re.findall(r'(? packages/npm-native/index.d.ts. +# --------------------------------------------------------------------------- + + +def check_dts_node() -> List[str]: + problems: List[str] = [] + cc_text = _read(NODE_WORKBOOK_CLASS_CC) + + instance_cc = set(re.findall(r'InstanceMethod<[^>]+>\("([^"]+)"\)', cc_text)) + static_cc = set(re.findall(r'StaticMethod<[^>]+>\("([^"]+)"\)', cc_text)) + + addon_text = _read(NODE_ADDON_CC) + free_cc = set(re.findall(r'exports\.Set\("([^"]+)"', addon_text)) - {"Workbook"} + + dts_text = _read(NODE_DTS) + instance_dts = _extract_ts_methods(_find_interface_body(dts_text, "Workbook", NODE_DTS)) + static_dts = _extract_ts_methods(_find_interface_body(dts_text, "WorkbookCtor", NODE_DTS)) + free_dts = set(re.findall(r"^export function ([A-Za-z0-9_]+)\(", dts_text, re.MULTILINE)) + free_dts -= NODE_PURE_JS_FREE_FUNCTIONS + + wasm_cpp_text = _read(WASM_BINDINGS_CPP) + wasm_instance = set(re.findall(r'\.function\("([^"]+)"', wasm_cpp_text)) + actual_wasm_only = wasm_instance - instance_cc + actual_node_only = instance_cc - wasm_instance + if actual_wasm_only != WASM_ONLY_METHODS: + problems.append( + "dts-node: WASM-only instance-method allowlist mismatch; " + f"actual={sorted(actual_wasm_only)}, allowlist={sorted(WASM_ONLY_METHODS)}" + ) + if actual_node_only != NODE_ONLY_METHODS: + problems.append( + "dts-node: Node-only instance-method allowlist mismatch; " + f"actual={sorted(actual_node_only)}, allowlist={sorted(NODE_ONLY_METHODS)}" + ) + stale_wasm_only = WASM_ONLY_METHODS - actual_wasm_only + stale_node_only = NODE_ONLY_METHODS - actual_node_only + if stale_wasm_only: + problems.append(f"dts-node: stale WASM-only allowlist entries: {sorted(stale_wasm_only)}") + if stale_node_only: + problems.append(f"dts-node: stale Node-only allowlist entries: {sorted(stale_node_only)}") + + for label, cc_set, dts_set in ( + ("Workbook instance methods", instance_cc, instance_dts), + ("Workbook static factories", static_cc, static_dts), + ("free functions", free_cc, free_dts), + ): + diff = _format_diff( + f"{NODE_WORKBOOK_CLASS_CC.relative_to(REPO_ROOT)} / {NODE_ADDON_CC.relative_to(REPO_ROOT)}", + cc_set - dts_set, + f"{NODE_DTS.relative_to(REPO_ROOT)}", + dts_set - cc_set, + ) + if diff: + problems.append(f"dts-node: {label} mismatch:\n" + "\n".join(diff)) + + return problems + + +def _npm_entries_reexport_common(check: str) -> List[str]: + """Every npm entry point must re-export common.mjs, or it ships without the shared surface.""" + return [ + f"{check}: {entry.relative_to(REPO_ROOT)} does not `export * from './common.js'`" + for entry in NPM_ENTRY_MJS + if not re.search(r"^export \* from '\./common\.js';", _read(entry), re.MULTILINE) + ] + + +def check_pure_js_helpers() -> List[str]: + problems: List[str] = _npm_entries_reexport_common("pure-js-helpers") + sources = { + "npm": (NPM_COMMON_MJS, WASM_DTS), + "npm-native": (NODE_INDEX_MJS, NODE_DTS), + } + + for name in sorted(NODE_PURE_JS_FREE_FUNCTIONS): + bodies: dict[str, str] = {} + for package, (mjs_path, dts_path) in sources.items(): + body = _js_exported_function_source(_read(mjs_path), name) + if body is None: + problems.append( + f"pure-js-helpers: {mjs_path.relative_to(REPO_ROOT)} does not export a " + f"`{name}` function. A helper only one package ships cannot be named in a " + "declaration file both packages publish." + ) + else: + bodies[package] = body + if not re.search(r"^export function %s\s*\(" % re.escape(name), _read(dts_path), re.MULTILINE): + problems.append( + f"pure-js-helpers: {dts_path.relative_to(REPO_ROOT)} does not declare `export function {name}`" + ) + if len(bodies) == len(sources) and len(set(bodies.values())) != 1: + problems.append( + f"pure-js-helpers: the `{name}` implementations have diverged between " + f"{NPM_COMMON_MJS.relative_to(REPO_ROOT)} and {NODE_INDEX_MJS.relative_to(REPO_ROOT)}. " + "The two packages share no module, so the copies are held together here or not " + "at all." + ) + + return problems + + +# --------------------------------------------------------------------------- +# Check 3: README instance-method count <-> actual registration count. +# --------------------------------------------------------------------------- + +_NUMBER_WORDS = { + "zero": 0, + "one": 1, + "two": 2, + "three": 3, + "four": 4, + "five": 5, + "six": 6, + "seven": 7, + "eight": 8, + "nine": 9, + "ten": 10, +} + +_README_COUNT_RE = re.compile(r"register (\d+) instance methods plus the\s+(\w+) static factories") +_README_SHARED_COUNT_RE = re.compile(r"instance methods, (\d+) are shared with WASM; (\w+) remain WASM-only") + + +def check_readme_counts() -> List[str]: + problems: List[str] = [] + cc_text = _read(NODE_WORKBOOK_CLASS_CC) + actual_instance = len(set(re.findall(r'InstanceMethod<[^>]+>\("([^"]+)"\)', cc_text))) + actual_static = len(set(re.findall(r'StaticMethod<[^>]+>\("([^"]+)"\)', cc_text))) + + readme_text = _read(NODE_README) + match = _README_COUNT_RE.search(readme_text) + if not match: + problems.append( + f"readme-counts: could not find the instance/static method-count sentence in " + f"{NODE_README.relative_to(REPO_ROOT)} (pattern: {_README_COUNT_RE.pattern!r})" + ) + return problems + + quoted_instance = int(match.group(1)) + quoted_static_word = match.group(2).lower() + quoted_static = _NUMBER_WORDS.get(quoted_static_word) + + if quoted_instance != actual_instance: + problems.append( + f"readme-counts: {NODE_README.relative_to(REPO_ROOT)} claims {quoted_instance} instance methods, " + f"actual count in {NODE_WORKBOOK_CLASS_CC.relative_to(REPO_ROOT)} is {actual_instance}" + ) + if quoted_static is None or quoted_static != actual_static: + problems.append( + f"readme-counts: {NODE_README.relative_to(REPO_ROOT)} claims {match.group(2)!r} static factories, " + f"actual count in {NODE_WORKBOOK_CLASS_CC.relative_to(REPO_ROOT)} is {actual_static}" + ) + + wasm_cpp_text = _read(WASM_BINDINGS_CPP) + wasm_instance = set(re.findall(r'\.function\("([^"]+)"', wasm_cpp_text)) + actual_node_methods = set(re.findall(r'InstanceMethod<[^>]+>\("([^"]+)"\)', cc_text)) + actual_shared = len(wasm_instance & actual_node_methods) + actual_wasm_only = len(wasm_instance - actual_node_methods) + shared_match = _README_SHARED_COUNT_RE.search(readme_text) + if not shared_match: + problems.append( + f"readme-counts: could not find the shared/WASM-only method-count sentence in " + f"{NODE_README.relative_to(REPO_ROOT)}" + ) + else: + quoted_shared = int(shared_match.group(1)) + quoted_wasm_only = _NUMBER_WORDS.get(shared_match.group(2).lower()) + if quoted_shared != actual_shared: + problems.append( + f"readme-counts: {NODE_README.relative_to(REPO_ROOT)} claims {quoted_shared} shared methods, " + f"actual shared count is {actual_shared}" + ) + if quoted_wasm_only is None or quoted_wasm_only != actual_wasm_only: + problems.append( + f"readme-counts: {NODE_README.relative_to(REPO_ROOT)} claims {shared_match.group(2)!r} WASM-only methods, " + f"actual count is {actual_wasm_only}" + ) + + return problems + + +# Maps each `.d.ts` enum name to the (source description, extractor) pair +# that finds its mirrored C/C++ enum's ordinal sequence. `fm_*_t` names +# resolve against formulon_c.h; the remaining few are plain C++ `enum +# class` types in their own small headers (comparing against the giant +# formulon_c.h text for those would just add false-positive risk from an +# unrelated same-named enum elsewhere). +_DTS_ENUM_SOURCES = { + "ValueKind": (CAPI_HEADER, "fm_value_kind_t", _extract_c_typedef_enum), + "WorkbookFormat": (CAPI_HEADER, "fm_workbook_format_t", _extract_c_typedef_enum), + "PivotCellKind": (CAPI_HEADER, "fm_pivot_cell_kind_t", _extract_c_typedef_enum), + "PivotAxis": (CAPI_HEADER, "fm_pivot_axis_t", _extract_c_typedef_enum), + "PivotAggregation": (CAPI_HEADER, "fm_pivot_aggregation_t", _extract_c_typedef_enum), + "PivotShowValuesAs": (CAPI_HEADER, "fm_pivot_show_as_t", _extract_c_typedef_enum), + "PivotFilterType": (CAPI_HEADER, "fm_pivot_filter_type_t", _extract_c_typedef_enum), + "PivotDateGrouping": (CAPI_HEADER, "fm_pivot_date_grouping_t", _extract_c_typedef_enum), + "PivotCalendar": (CAPI_HEADER, "fm_pivot_calendar_t", _extract_c_typedef_enum), + "PivotReportLayout": (CAPI_HEADER, "fm_pivot_layout_t", _extract_c_typedef_enum), + "PivotFilterValueKind": (CAPI_HEADER, "fm_pivot_filter_value_kind_t", _extract_c_typedef_enum), + "SheetVisibility": (CAPI_HEADER, "fm_sheet_visibility_t", _extract_c_typedef_enum), + "CfMatchKind": (CF_MATCH_H, "CFMatchKind", _extract_cpp_enum_class), + "CalcMode": (CALC_MODE_H, "CalcMode", _extract_cpp_enum_class), + "ErrorCode": (VALUE_H, "ErrorCode", _extract_cpp_enum_class), + "ExternalLinkKind": (EXTERNAL_LINKS_H, "Kind", _extract_cpp_enum_class), +} + + +def check_dts_enums() -> List[str]: + problems: List[str] = [] + dts_text = _read(WASM_DTS) + + for ts_name, (source_path, source_enum, extractor) in sorted(_DTS_ENUM_SOURCES.items()): + ts_values = _extract_ts_enum(dts_text, ts_name) + if ts_values is None: + problems.append( + f"dts-enums: could not find/parse `export enum {ts_name}` in {WASM_DTS.relative_to(REPO_ROOT)}" + ) + continue + + source_text = _read(source_path) + source_values = extractor(source_text, source_enum) + if source_values is None: + problems.append( + f"dts-enums: could not find/parse `{source_enum}` in {source_path.relative_to(REPO_ROOT)} " + f"(needed to check {WASM_DTS.relative_to(REPO_ROOT)}'s {ts_name})" + ) + continue + + if ts_values != source_values: + problems.append( + f"dts-enums: {ts_name} in {WASM_DTS.relative_to(REPO_ROOT)} has ordinals {ts_values}, " + f"but its source {source_enum} in {source_path.relative_to(REPO_ROOT)} has {source_values}" + ) + + problems.extend(_check_js_constant_tables(dts_text)) + return problems + + +def _check_js_constant_tables(wasm_dts_text: str) -> List[str]: + problems: List[str] = _npm_entries_reexport_common("dts-enums") + npm = _parse_js_constants(NPM_COMMON_MJS) + native = _parse_js_constants(NODE_INDEX_MJS) + npm_label = NPM_COMMON_MJS.relative_to(REPO_ROOT) + native_label = NODE_INDEX_MJS.relative_to(REPO_ROOT) + + only_npm = sorted(set(npm) - set(native)) + only_native = sorted(set(native) - set(npm)) + if only_npm: + problems.append(f"dts-enums: exported by {npm_label} but not {native_label}: {only_npm}") + if only_native: + problems.append(f"dts-enums: exported by {native_label} but not {npm_label}: {only_native}") + + for name in sorted(set(npm) & set(native)): + if npm[name] != native[name]: + problems.append(f"dts-enums: {name} is {npm[name]} in {npm_label} but {native[name]} in {native_label}") + + # Each runtime table must also match the declaration file its own + # package ships, and the tables (not the bare scalars) must match the + # canonical WASM `.d.ts`. + node_dts_text = _read(NODE_DTS) + for label, constants, dts_text, dts_path in ( + (npm_label, npm, wasm_dts_text, WASM_DTS), + (native_label, native, node_dts_text, NODE_DTS), + ): + for name, value in sorted(constants.items()): + if not isinstance(value, dict): + if not re.search(r"export const\s+" + re.escape(name) + r"\s*=\s*" + str(value) + r"\b", dts_text): + problems.append( + f"dts-enums: {label} exports {name} = {value}, " + f"not declared with that value in {dts_path.relative_to(REPO_ROOT)}" + ) + continue + declared = _parse_ts_named_enum(dts_text, name) + if declared is None: + problems.append( + f"dts-enums: {label} exports the table {name}, " + f"which {dts_path.relative_to(REPO_ROOT)} does not declare as a value" + ) + elif declared != value: + problems.append( + f"dts-enums: {name} is {value} in {label} but {declared} in {dts_path.relative_to(REPO_ROOT)}" + ) + + canonical_only = sorted( + name for name in _DTS_ENUM_SOURCES if name not in npm or not isinstance(npm.get(name), dict) + ) + if canonical_only: + problems.append( + f"dts-enums: {WASM_DTS.relative_to(REPO_ROOT)} declares these enums but {npm_label} " + f"does not export them as runtime tables: {canonical_only}" + ) + return problems + + +# --------------------------------------------------------------------------- +# Check 7: the npm-native package's staged copies <-> their sources. +# +# `stage.mjs` publishes `packages/npm-native/dist/` by copying `index.d.ts` +# and `index.mjs` verbatim out of the package root. Every other check in this +# file reads the source side only, so an edit that lands in the source but is +# never re-staged publishes a declaration file that no longer describes the +# runtime, or an ordinal table that no longer matches the one `dts-enums` +# verified. The WASM package already guards its own staged `.d.ts` +# (packages/npm/scripts/check-dts.mjs); npm-native had no equivalent, which +# matters most for a mechanical rewrite that touches one side of the pair. +# --------------------------------------------------------------------------- + +# (source, staged copy) pairs `stage.mjs` produces with a verbatim copyFile. +_NODE_STAGED_COPIES = ( + (NODE_DTS, NODE_DIST_DIR / "index.d.ts"), + (NODE_INDEX_MJS, NODE_DIST_DIR / "index.mjs"), +) + + +def check_staged_dist() -> List[str]: + problems: List[str] = [] + for source, staged in _NODE_STAGED_COPIES: + if not staged.is_file(): + # `dist/` is gitignored, so a fresh clone has nothing to compare + # until the package is staged. Record that as not-checked instead + # of passing: this check exists because a silent pass is + # indistinguishable from a real one. + _SKIPPED.append( + f"staged-dist: {staged.relative_to(REPO_ROOT)} is absent; the npm-native " + "package has not been staged in this tree (`make node-package`)" + ) + continue + if _read_bytes(source) != _read_bytes(staged): + problems.append( + f"staged-dist: {staged.relative_to(REPO_ROOT)} differs from " + f"{source.relative_to(REPO_ROOT)}; the published copy is stale -- " + "re-stage the package with `make node-package`" + ) + return problems diff --git a/tools/dev/binding_drift/enum_decl.py b/tools/dev/binding_drift/enum_decl.py new file mode 100644 index 000000000..46a8e4019 --- /dev/null +++ b/tools/dev/binding_drift/enum_decl.py @@ -0,0 +1,135 @@ +"""Enumerator extraction from C, C++ and TypeScript enum bodies.""" + +from __future__ import annotations + +import re +from typing import List, Optional + +# --------------------------------------------------------------------------- +# Check 5: src/wasm/formulon.d.ts `export enum` ordinals <-> their C/C++ +# source enum. embind never registers these as a real `enum_` +# (that would cost WASM size for values only ever crossing the +# boundary as plain numbers); the `.d.ts` copy is therefore the +# sole place the JS-visible ordinals live, and nothing enforces +# that it still matches the source enum after a reorder / insert. +# --------------------------------------------------------------------------- + +_ENUM_BODY_COMMENT_RE = re.compile(r"//[^\n]*|/\*.*?\*/", re.DOTALL) +_ENUM_MEMBER_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\s*(?:=\s*(-?(?:0[xX][0-9a-fA-F]+|\d+)))?\s*$") + + +def _parse_enum_body(body: str) -> Optional[List[int]]: + """Parses a brace-delimited enumerator list into its ordinal values. + + Handles both explicit `Name = N` members and implicit sequential + values (each unset member is one more than the previous value, C/C++/TS + enum semantics). Returns `None` if a member's initializer is not a + literal integer (e.g. it references another constant) -- the caller + should skip comparison in that case rather than mis-flag a drift. + """ + body = _ENUM_BODY_COMMENT_RE.sub("", body) + values: List[int] = [] + next_value = 0 + for part in body.split(","): + part = part.strip() + if not part: + continue + match = _ENUM_MEMBER_RE.match(part) + if not match: + return None + literal = match.group(2) + if literal is not None: + next_value = int(literal, 0) + values.append(next_value) + next_value += 1 + return values + + +def _extract_c_typedef_enum(text: str, type_name: str) -> Optional[List[int]]: + # `[^}]*` (rather than a non-greedy `.*?`) deliberately cannot cross a + # `}` boundary, so a search starting at an *earlier*, unrelated + # `typedef enum { ... }` block cannot skip past its own close brace + # and accidentally splice in a later block that happens to close with + # this `type_name`. + match = re.search(r"typedef enum\s*\{([^}]*)\}\s*" + re.escape(type_name) + r"\s*;", text, re.DOTALL) + if not match: + return None + return _parse_enum_body(match.group(1)) + + +def _cpp_enum_class_body(text: str, enum_name: str) -> Optional[str]: + """The brace-delimited enumerator list of a scoped enum, or `None`.""" + match = re.search( + r"enum class\s+" + re.escape(enum_name) + r"\s*(?::\s*[\w:]+)?\s*\{([^}]*)\}\s*;", text, re.DOTALL + ) + return match.group(1) if match else None + + +def _extract_cpp_enum_class(text: str, enum_name: str) -> Optional[List[int]]: + body = _cpp_enum_class_body(text, enum_name) + if body is None: + return None + return _parse_enum_body(body) + + +def _extract_ts_enum(text: str, enum_name: str) -> Optional[List[int]]: + match = re.search(r"export (?:const )?enum\s+" + re.escape(enum_name) + r"\s*\{([^}]*)\}", text, re.DOTALL) + if not match: + return None + return _parse_enum_body(match.group(1)) + + +def _parse_ts_named_enum(text: str, name: str) -> Optional[dict]: + """Reads a `.d.ts` ordinal table in either idiom used across the two + declaration files: a TS `export enum` or an `export const` whose type + is a `Readonly<{...}>` literal.""" + match = re.search(r"export enum\s+" + re.escape(name) + r"\s*\{([^}]*)\}", text, re.DOTALL) + if match is None: + match = re.search(r"export const\s+" + re.escape(name) + r"\s*:\s*Readonly<\{([^}]*)\}>", text, re.DOTALL) + if match is None: + return None + body = _ENUM_BODY_COMMENT_RE.sub("", match.group(1)) + members: dict = {} + next_value = 0 + for part in body.replace(";", ",").split(","): + part = part.strip() + if not part: + continue + member = re.match(r"([A-Za-z_]\w*)\s*(?:[:=]\s*(-?\d+))?$", part) + if member is None: + return None + if member.group(2) is not None: + next_value = int(member.group(2)) + members[member.group(1)] = next_value + next_value += 1 + return members + + +def _cpp_enum_class_values(text: str, enum_name: str) -> Optional[dict[str, int]]: + """Parses a scoped enum into a name -> value map. + + The value-only sibling `_extract_cpp_enum_class` compares ordinal + sequences; this one is for the checks that need to look a member up by + name. Implicit members take one more than the previous value, as in C++. + Returns `None` when the enum is absent or a member's initializer is not a + literal integer, so the caller reports the parse failure rather than a + false drift. + """ + raw_body = _cpp_enum_class_body(text, enum_name) + if raw_body is None: + return None + values: dict[str, int] = {} + next_value = 0 + for part in _ENUM_BODY_COMMENT_RE.sub("", raw_body).split(","): + part = part.strip() + if not part: + continue + member = _ENUM_MEMBER_RE.match(part) + if not member: + return None + literal = member.group(2) + if literal is not None: + next_value = int(literal, 0) + values[member.group(1)] = next_value + next_value += 1 + return values diff --git a/tools/dev/binding_drift/python_checks.py b/tools/dev/binding_drift/python_checks.py new file mode 100644 index 000000000..f20d2976e --- /dev/null +++ b/tools/dev/binding_drift/python_checks.py @@ -0,0 +1,742 @@ +"""Drift checks between the C ABI header and the Python binding.""" + +from __future__ import annotations + +import ast +import importlib.util +import re +from pathlib import Path +from typing import List, Optional, Set + +from .c_header import _c_function_params, _LayoutError, _wasm32_layout, _Wasm32Layouts +from .surface_files import ( + _SKIPPED, + CAPI_EXPORTS, + CAPI_HEADER, + PYTHON_C_BINDING, + PYTHON_PKG_DIR, + PYTHON_STRUCTS, + PYTHON_WORKBOOK, + REPO_ROOT, + _read, +) + + +def _parse_literal_status_exports() -> tuple[Set[str], Optional[str]]: + """Read `_STATUS_RETURNING_EXPORT_NAMES` without importing `_c.py`. + + Importing the binding would load wasmtime and, depending on the + environment, initialize a real WASM instance. The drift check is a + source-level consistency check, so an AST walk keeps it stdlib-only and + side-effect-free. + """ + try: + tree = ast.parse(_read(PYTHON_C_BINDING), filename=str(PYTHON_C_BINDING)) + except SyntaxError as exc: + return set(), f"python-exports: cannot parse {PYTHON_C_BINDING.relative_to(REPO_ROOT)}: {exc}" + + for statement in tree.body: + if isinstance(statement, ast.Assign): + targets = statement.targets + elif isinstance(statement, ast.AnnAssign): + targets = [statement.target] + else: + continue + if not any( + isinstance(target, ast.Name) and target.id == "_STATUS_RETURNING_EXPORT_NAMES" for target in targets + ): + continue + value = statement.value + if not isinstance(value, ast.Tuple): + return set(), "python-exports: _STATUS_RETURNING_EXPORT_NAMES must be a literal tuple" + names: Set[str] = set() + for element in value.elts: + if not isinstance(element, ast.Constant) or not isinstance(element.value, str): + return set(), "python-exports: _STATUS_RETURNING_EXPORT_NAMES must contain only string literals" + names.add(element.value) + return names, None + + return ( + set(), + "python-exports: _STATUS_RETURNING_EXPORT_NAMES literal tuple not found in packages/python/formulon/_c.py", + ) + + +# --------------------------------------------------------------------------- +# Check 1: Python `LIB.fm_*` calls <-> tools/wasm/capi_exports.txt <-> +# src/c_api/formulon_c.h declarations. +# --------------------------------------------------------------------------- + + +def check_python_exports() -> List[str]: + problems: List[str] = [] + + python_calls: Set[str] = set() + for py_file in sorted(PYTHON_PKG_DIR.glob("*.py")): + python_calls |= set(re.findall(r"\bLIB\.(fm_[A-Za-z0-9_]+)", _read(py_file))) + + exports_text = _read(CAPI_EXPORTS) + exports: Set[str] = set() + for line in exports_text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + exports.add(stripped) + + header_text = _read(CAPI_HEADER) + header_fns = set(re.findall(r"\bfm_[A-Za-z0-9_]+(?=\s*\()", header_text)) + + missing_from_exports = python_calls - exports + if missing_from_exports: + problems.append( + "python-exports: Python calls a symbol not staged in " + f"{CAPI_EXPORTS.relative_to(REPO_ROOT)}: {sorted(missing_from_exports)}" + ) + + # capi_exports.txt also lists a handful of non-`fm_` allocator symbols + # (`malloc`/`free`) needed for host-side scratch-buffer plumbing; those + # are libc, not part of the `fm_*` C ABI, and are never declared in + # formulon_c.h. Only the `fm_*` subset is checked against the header. + fm_exports = {symbol for symbol in exports if symbol.startswith("fm_")} + exports_not_in_header = fm_exports - header_fns + if exports_not_in_header: + problems.append( + "python-exports: symbol staged in " + f"{CAPI_EXPORTS.relative_to(REPO_ROOT)} but not declared in " + f"{CAPI_HEADER.relative_to(REPO_ROOT)}: {sorted(exports_not_in_header)}" + ) + + # The WASM result type is not enough to identify status calls: counts, + # indices, and pointers are also i32. Keep the binding's capture list as + # a literal and compare it to the authoritative header/manifest + # intersection without importing `_c.py` or wasmtime. + header_status_fns = set(re.findall(r"\bFM_API\s+fm_status_t\s+(fm_[A-Za-z0-9_]+)\s*\(", header_text)) + expected_status_exports = header_status_fns & fm_exports + binding_status_exports, parse_problem = _parse_literal_status_exports() + if parse_problem: + problems.append(parse_problem) + + missing_status_exports = expected_status_exports - binding_status_exports + if missing_status_exports: + problems.append( + "python-exports: _STATUS_RETURNING_EXPORT_NAMES is missing status-returning exports: " + f"{sorted(missing_status_exports)}" + ) + + extra_status_exports = binding_status_exports - expected_status_exports + if extra_status_exports: + problems.append( + f"python-exports: _STATUS_RETURNING_EXPORT_NAMES has extra exports: {sorted(extra_status_exports)}" + ) + + return problems + + +def _flatten_for_python( + layouts: _Wasm32Layouts, struct_name: str, py_field_names: Set[str] +) -> List[tuple[str, int, int]]: + """C members of `struct_name` in the shape the Python layout models them. + + Python keeps one flat field map per struct, so an embedded POD appears in + it one of two ways and both have to be accepted: as a single opaque blob + under the C member's own name (`fm_pivot_cell_t.value`), or spread into + the parent's map (`fm_cf_color_t color` contributing `color_r`). Which one + applies is read off the Python layout, so a struct that switches + representation needs no edit here -- only the nesting depth Python + actually flattens is followed. + """ + fields: List[tuple[str, int, int]] = [] + for member in layouts.members(struct_name): + if member.name in py_field_names or not layouts.is_struct(member.ctype): + fields.append((member.name, member.size, member.align)) + continue + for inner in layouts.members(member.ctype): + name = inner.name if inner.name in py_field_names else f"{member.name}_{inner.name}" + fields.append((name, inner.size, inner.align)) + return fields + + +def check_python_struct_layouts() -> List[str]: + """Verify Python's hand-written WASM32 structs against the C header. + + Every size and offset is measured from the authoritative C declarations + rather than repeated in a Python table -- including the structs embedded + inside other structs, which are laid out recursively. Only the wasm32 + scalar, pointer and enum widths are tabulated, because the header does not + state those. It covers every ``Struct`` exported by ``_structs.py`` and + reports field order, offsets, and final size. + """ + layouts = _Wasm32Layouts(re.sub(r"/\*.*?\*/", "", _read(CAPI_HEADER), flags=re.S)) + spec = importlib.util.spec_from_file_location("formulon_struct_layouts", PYTHON_STRUCTS) + if spec is None or spec.loader is None: + return ["python-struct-layout: could not load packages/python/formulon/_structs.py"] + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + problems: List[str] = [] + for layout in (value for value in vars(module).values() if isinstance(value, module.Struct)): + py_field_names = {name for name, _ in layout.fields} + try: + c_fields = _flatten_for_python(layouts, layout.name, py_field_names) + except _LayoutError as exc: + problems.append(f"python-struct-layout: {exc}") + continue + # Explicit C padding need not be represented in Struct: its alignment + # effect is reproduced by the following semantic field. It is still + # laid out here so the offsets after it are right. + c_offsets, c_size, _ = _wasm32_layout(c_fields) + c_sizes = {name: size for name, size, _ in c_fields} + semantic_names = [name for name, _, _ in c_fields if not name.startswith("_pad")] + py_names = [name for name, _ in layout.fields if not name.startswith("_pad")] + py_sizes = {name: spec[1] for name, spec in layout.fields} + if py_names != semantic_names: + problems.append(f"python-struct-layout: {layout.name} fields differ: C={semantic_names}, Python={py_names}") + for name in py_names: + if name in c_offsets and layout.offsets[name][1] != c_offsets[name]: + problems.append( + f"python-struct-layout: {layout.name}.{name} offset C={c_offsets[name]} Python={layout.offsets[name][1]}" + ) + # Field width is checked separately from offset: narrowing a field + # whose successor is more strictly aligned leaves every offset and + # the total size intact, so the offset comparison alone reads the + # wrong number of bytes without ever disagreeing. + if name in c_sizes and py_sizes[name] != c_sizes[name]: + problems.append( + f"python-struct-layout: {layout.name}.{name} size C={c_sizes[name]} Python={py_sizes[name]}" + ) + if layout.size != c_size: + problems.append(f"python-struct-layout: {layout.name} size C={c_size} Python={layout.size}") + return problems + + +# Scratch readers whose name pins how many bytes come back out of the slot. +# `read_cstr` is deliberately absent: it walks to a NUL rather than reading a +# fixed width, so it is checked for the deref mistake only (below). +_SCRATCH_READER_WIDTHS = {"read_u32": 4, "read_i32": 4, "read_f64": 8} + + +def _module_int_constants(paths: List[Path]) -> dict[str, int]: + """Module-level `NAME = ` bindings across the binding package. + + Collected across files because the constant a scratch allocation is sized + with may be defined in one module and imported into another + (`fm_value_t_size` lives in `_c.py` and is used from `workbook.py`). + """ + constants: dict[str, int] = {} + for path in paths: + for statement in ast.parse(_read(path), filename=str(path)).body: + if not isinstance(statement, ast.Assign) or len(statement.targets) != 1: + continue + target = statement.targets[0] + if isinstance(target, ast.Name) and isinstance(statement.value, ast.Constant): + if isinstance(statement.value.value, int) and not isinstance(statement.value.value, bool): + constants[target.id] = statement.value.value + return constants + + +def _is_lib_call(node: ast.AST, attribute: Optional[str] = None) -> bool: + """True for `LIB.(...)` (any `LIB.*` call when unspecified).""" + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + return False + if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "LIB"): + return False + return attribute is None or node.func.attr == attribute + + +def _scratch_slot_widths( + function: ast.AST, constants: dict[str, int], struct_module: object +) -> dict[str, Optional[int]]: + """Local name -> byte width of the WASM scratch block bound to it. + + A width of `None` marks a slot whose size this resolver cannot pin (an + array allocation sized from a runtime length, or a name rebound to two + different widths). Those are reported rather than skipped when the C side + says the parameter is a struct pointer, because an unmeasurable buffer + behind a by-pointer struct is exactly the case a size guard exists for. + """ + widths: dict[str, Optional[int]] = {} + + def record(name: str, width: Optional[int]) -> None: + if name in widths and widths[name] != width: + widths[name] = None + else: + widths[name] = width + + for node in ast.walk(function): + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if not isinstance(target, ast.Name) or not isinstance(node.value, ast.Call): + continue + call = node.value + callee = call.func + # `x = _alloc_out_ptr()` -- the package's 4-byte out-i32 / out-ptr slot. + if isinstance(callee, ast.Name) and callee.id == "_alloc_out_ptr": + record(target.id, 4) + # `x = S.alloc_struct(LIB, S.LAYOUT)` -- sized from the layout table + # `python-struct-layouts` already pins against the header. + elif (isinstance(callee, ast.Attribute) and callee.attr == "alloc_struct") or ( + isinstance(callee, ast.Name) and callee.id == "alloc_struct" + ): + layout = call.args[1] if len(call.args) > 1 else None + layout_name = layout.attr if isinstance(layout, ast.Attribute) else None + struct = getattr(struct_module, layout_name, None) if layout_name else None + record(target.id, struct.size if isinstance(struct, struct_module.Struct) else None) + # `x = LIB.alloc(N)` / `LIB.alloc(CONSTANT)` -- a hand-sized block. + elif _is_lib_call(call, "alloc") and call.args: + size = call.args[0] + if isinstance(size, ast.Constant) and isinstance(size.value, int): + record(target.id, size.value) + elif isinstance(size, ast.Name) and size.id in constants: + record(target.id, constants[size.id]) + else: + record(target.id, None) + return widths + + +def _scratch_slot_readers(function: ast.AST) -> dict[str, Set[object]]: + """Local name -> the reads taken against that scratch slot. + + An entry is either a `_SCRATCH_READER_WIDTHS` key, `("bytes", N)` for a + constant-length `LIB.read_bytes`, or `"read_cstr"`. + """ + readers: dict[str, Set[object]] = {} + for node in ast.walk(function): + if not isinstance(node, ast.Call) or not _is_lib_call(node) or not node.args: + continue + first = node.args[0] + if not isinstance(first, ast.Name): + continue + attribute = node.func.attr if isinstance(node.func, ast.Attribute) else "" + if attribute in _SCRATCH_READER_WIDTHS or attribute == "read_cstr": + readers.setdefault(first.id, set()).add(attribute) + elif attribute == "read_bytes" and len(node.args) > 1 and isinstance(node.args[1], ast.Constant): + readers.setdefault(first.id, set()).add(("bytes", node.args[1].value)) + return readers + + +def _pointee(ctype: str) -> Optional[str]: + """The type a parameter points at, or `None` if it is not a pointer.""" + ctype = ctype.strip() + if not ctype.endswith("*"): + return None + return ctype[:-1].strip() + + +def _check_out_param_width( + layouts: _Wasm32Layouts, + site: str, + fn_name: str, + position: int, + ctype: str, + width: Optional[int], + reads: Set[object], +) -> List[str]: + """One scratch slot against the parameter it is passed to.""" + problems: List[str] = [] + pointee = _pointee(ctype) + if pointee is None: + # Emscripten lowers a by-value struct parameter to a pointer into + # linear memory, so a scratch block is the correct thing to pass for + # one. Any other non-pointer parameter takes a scalar, and handing it + # a scratch address means the callee reads the pointer as the value. + bare = ctype.replace("const ", "").strip() + if not layouts.is_struct(bare): + return [ + f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r}, " + "which takes a scalar by value, but the binding passes a scratch-block pointer" + ] + pointee_size, _ = layouts.extent(bare) + is_struct = True + else: + bare = pointee.replace("const ", "").strip() + if bare.endswith("*"): + pointee_size, is_struct = 4, False # wasm32 pointer-to-pointer + else: + try: + pointee_size, _ = layouts.extent(bare) + except _LayoutError as exc: + return [f"python-call-signatures: {site}: {fn_name} parameter {position} ({ctype}): {exc}"] + is_struct = layouts.is_struct(bare) + + if width is None: + if is_struct: + problems.append( + f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r}, " + "but the size of the scratch block passed to it cannot be resolved; give the " + "struct a `_structs.Struct` layout or size the allocation from a module constant" + ) + return problems + + if width < pointee_size: + problems.append( + f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r} " + f"(wasm32 pointee {pointee_size} bytes) but the binding allocates {width} bytes" + ) + elif is_struct and width != pointee_size: + problems.append( + f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r} " + f"(wasm32 sizeof {pointee_size}) but the binding allocates {width} bytes; a struct " + "out-parameter block must match the C size exactly" + ) + + for read in sorted(reads, key=repr): + read_width = _SCRATCH_READER_WIDTHS.get(read) if isinstance(read, str) else read[1] + if read == "read_cstr": + if bare.endswith("*"): + problems.append( + f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r}, " + "so its slot holds a pointer; `read_cstr` on the slot itself decodes the " + "pointer's bytes as text instead of dereferencing it" + ) + continue + if read_width is None: + continue + if read_width > width: + problems.append( + f"python-call-signatures: {site}: {fn_name} parameter {position}'s slot is " + f"{width} bytes but is read with {read!r} ({read_width} bytes)" + ) + elif read_width > pointee_size: + problems.append( + f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r} " + f"(wasm32 pointee {pointee_size} bytes) but its slot is read with {read!r} " + f"({read_width} bytes)" + ) + return problems + + +def check_python_call_signatures() -> List[str]: + """Verify Python's `LIB.fm_*` calls against the C header's declarations. + + Covered, for every `LIB.fm_*(...)` call in `packages/python/formulon`: + + * **Arity.** The number of positional arguments equals the header's + parameter count. A call that splats (`*pointers`) is checked as a lower + bound only and named in the run's SKIPPED list. + * **Scratch-block width.** When an argument is a local bound to a + recognised scratch allocation (``_alloc_out_ptr()``, + ``S.alloc_struct(LIB, S.LAYOUT)``, or ``LIB.alloc(...)`` with a constant + or module-constant size), the block is compared against the wasm32 size + of what the parameter addresses: never smaller, and exactly equal when + that is a struct. This covers both pointer parameters and the by-value + struct parameters Emscripten lowers to a pointer. A struct whose block + size cannot be resolved is reported rather than skipped. + * **Read width.** Reads taken against such a slot (``read_u32`` / + ``read_i32`` / ``read_f64`` / constant-length ``read_bytes``) must not + exceed either the block or the pointee, and ``read_cstr`` must not be + applied to a slot that holds a pointer. + + NOT covered: argument *types* at non-pointer positions (nothing on the + Python side records whether a value was meant to be a `uint32_t` or a + `double`; the wasmtime layer passes plain Python ints and floats), return + types beyond what `python-exports` already pins, and both non-Python + bindings -- embind and N-API bind C++ entry points directly rather than + the `fm_*` C ABI, so this header has no arity relationship to them. + """ + header = re.sub(r"/\*.*?\*/", "", _read(CAPI_HEADER), flags=re.S) + declarations = _c_function_params(header) + layouts = _Wasm32Layouts(header) + + spec = importlib.util.spec_from_file_location("formulon_struct_layouts_sig", PYTHON_STRUCTS) + if spec is None or spec.loader is None: + return ["python-call-signatures: could not load packages/python/formulon/_structs.py"] + struct_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(struct_module) + + sources = sorted(PYTHON_PKG_DIR.glob("*.py")) + constants = _module_int_constants(sources) + + problems: List[str] = [] + checked_calls = 0 + for path in sources: + label = path.relative_to(REPO_ROOT) + tree = ast.parse(_read(path), filename=str(path)) + # Analysis is per enclosing function: a scratch local is only + # meaningfully tied to the calls in the scope that allocated it. + scopes = [node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))] + for scope in scopes: + widths = _scratch_slot_widths(scope, constants, struct_module) + readers = _scratch_slot_readers(scope) + for node in ast.walk(scope): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if not _is_lib_call(node): + continue + fn_name = node.func.attr + if not fn_name.startswith("fm_"): + continue + site = f"{label}:{node.lineno}" + params = declarations.get(fn_name) + if params is None: + problems.append(f"python-call-signatures: {site}: {fn_name} is not declared in the C header") + continue + checked_calls += 1 + if node.keywords: + problems.append( + f"python-call-signatures: {site}: {fn_name} is called with keyword arguments; " + "the wasmtime export takes positional arguments only" + ) + continue + starred = any(isinstance(arg, ast.Starred) for arg in node.args) + if starred: + fixed = len(node.args) - 1 + if fixed > len(params): + problems.append( + f"python-call-signatures: {site}: {fn_name} passes {fixed} arguments before its " + f"splat but the C header declares only {len(params)} parameters" + ) + _SKIPPED.append( + f"python-call-signatures: {site}: {fn_name} splats its trailing arguments, so only " + f"the {fixed}-argument lower bound is checked against the header's {len(params)}" + ) + continue + if len(node.args) != len(params): + problems.append( + f"python-call-signatures: {site}: {fn_name} is called with {len(node.args)} " + f"arguments but the C header declares {len(params)}: {params}" + ) + continue + for position, argument in enumerate(node.args): + if not isinstance(argument, ast.Name) or argument.id not in widths: + continue + problems.extend( + _check_out_param_width( + layouts, + site, + fn_name, + position, + params[position], + widths[argument.id], + readers.get(argument.id, set()), + ) + ) + + if not checked_calls: + problems.append( + "python-call-signatures: no `LIB.fm_*` call sites found in " + f"{PYTHON_PKG_DIR.relative_to(REPO_ROOT)}; the check has stopped looking at anything" + ) + # A call inside a nested `def` is reached both as part of the enclosing + # scope's walk and as its own scope, so the same finding can be produced + # twice. Deduplicate in place rather than restricting the walk: an inner + # closure that uses a scratch slot its parent allocated still has to be + # checked against that slot's width. + return list(dict.fromkeys(problems)) + + +# --------------------------------------------------------------------------- +# Check 1d: C structs the Python binding decodes inline <-> the C header. +# +# `python-struct-layouts` only sees structs that have a `_structs.Struct` +# entry. A struct small enough to decode with a bare `struct.unpack` -- an +# `fm_value_t`, an `fm_print_range_t` -- has no such entry, so its field +# offsets and widths live as literals in the decoding function and nothing +# compares them to the header. +# --------------------------------------------------------------------------- + +# (source file, decoder qualname, C struct). Each decoder is expected to +# consume the whole struct, so its `struct` calls are read as a description +# of the C layout and compared field for field. +_PYTHON_INLINE_DECODERS = ( + ("Value._from_wasm", "fm_value_t"), + ("Workbook.paginate", "fm_print_range_t"), +) + +# Python-side size literals for a struct with no `_structs.Struct` entry, as +# (module, binding name, C struct). A `Struct`-shaped tuple binding is +# compared on both size and alignment; a bare int on size alone. +_PYTHON_SIZE_CONSTANTS = ( + (PYTHON_C_BINDING, "fm_value_t_size", "fm_value_t"), + (PYTHON_STRUCTS, "VALUE_BLOB", "fm_value_t"), +) + +# C structs that cross the ABI but that no binding marshals, so there is no +# second side to compare against. Each is pinned by a native/wasm32 +# `static_assert` tripwire in tests/c_api instead; listing them here keeps the +# absence deliberate, and `python-call-signatures` fails if a binding starts +# passing one an unmeasurable block. +_UNMODELLED_C_STRUCTS = {"fm_styles_batch"} + +_STRUCT_FORMAT_WIDTHS = {"b": 1, "B": 1, "h": 2, "H": 2, "i": 4, "I": 4, "q": 8, "Q": 8, "f": 4, "d": 8} + + +def _struct_format_widths(fmt: str) -> Optional[List[int]]: + """Per-item byte widths of a little-endian `struct` format string.""" + if not fmt.startswith("<"): + return None + widths: List[int] = [] + for char in fmt[1:]: + width = _STRUCT_FORMAT_WIDTHS.get(char) + if width is None: + return None + widths.append(width) + return widths + + +def _find_qualified_function(tree: ast.AST, qualname: str) -> Optional[ast.AST]: + class_name, _, function_name = qualname.rpartition(".") + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef) or node.name != class_name: + continue + for member in node.body: + if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)) and member.name == function_name: + return member + return None + + +def check_python_inline_structs() -> List[str]: + """Verify Python's inline `struct.unpack` decoders against the C header. + + Two things are compared, both measured from the C declarations rather + than tabulated here: + + * A size literal the binding carries for a struct with no + `_structs.Struct` entry (`fm_value_t_size`, `VALUE_BLOB`) equals the + struct's wasm32 size, and its alignment where the binding records one. + * Inside each decoder in `_PYTHON_INLINE_DECODERS`, every + `struct.unpack_from(fmt, buf, offset)` lands on a C member offset and + reads no wider than that member, every whole-struct `struct.unpack(fmt, + ...)` describes the C members' widths in order, and every + constant-length `LIB.read_bytes` spans exactly the struct. + + NOT covered: the *meaning* of a field (a decoder that reads the right + width from the right offset into the wrong attribute still passes), and + any struct in `_UNMODELLED_C_STRUCTS`, which no binding marshals and + which therefore has only a `static_assert` tripwire. + """ + header = re.sub(r"/\*.*?\*/", "", _read(CAPI_HEADER), flags=re.S) + layouts = _Wasm32Layouts(header) + constants = _module_int_constants(sorted(PYTHON_PKG_DIR.glob("*.py"))) + problems: List[str] = [] + + def measure(struct_name: str) -> Optional[tuple[dict[str, int], int, int, dict[str, int]]]: + try: + members = layouts.members(struct_name) + except _LayoutError as exc: + problems.append(f"python-inline-structs: {exc}") + return None + offsets, size, align = _wasm32_layout([(m.name, m.size, m.align) for m in members]) + return offsets, size, align, {m.name: m.size for m in members} + + for struct_name in sorted(_UNMODELLED_C_STRUCTS): + if not layouts.is_struct(struct_name): + problems.append( + f"python-inline-structs: {struct_name} is listed as unmodelled by every binding " + f"but no longer exists in {CAPI_HEADER.relative_to(REPO_ROOT)}" + ) + + for module_path, binding_name, struct_name in _PYTHON_SIZE_CONSTANTS: + measured = measure(struct_name) + if measured is None: + continue + _, c_size, c_align, _ = measured + label = module_path.relative_to(REPO_ROOT) + found = False + for statement in ast.parse(_read(module_path), filename=str(module_path)).body: + if not isinstance(statement, ast.Assign) or len(statement.targets) != 1: + continue + target = statement.targets[0] + if not isinstance(target, ast.Name) or target.id != binding_name: + continue + found = True + value = statement.value + if isinstance(value, ast.Constant) and isinstance(value.value, int): + if value.value != c_size: + problems.append( + f"python-inline-structs: {label}: {binding_name} is {value.value}, " + f"but wasm32 sizeof({struct_name}) is {c_size}" + ) + elif isinstance(value, ast.Tuple) and len(value.elts) == 3: + elements = [element.value if isinstance(element, ast.Constant) else None for element in value.elts] + if elements[1] != c_size: + problems.append( + f"python-inline-structs: {label}: {binding_name} declares size {elements[1]}, " + f"but wasm32 sizeof({struct_name}) is {c_size}" + ) + if elements[2] != c_align: + problems.append( + f"python-inline-structs: {label}: {binding_name} declares alignment {elements[2]}, " + f"but wasm32 alignof({struct_name}) is {c_align}" + ) + else: + problems.append( + f"python-inline-structs: {label}: {binding_name} is neither an int literal nor a " + "(kind, size, alignment) literal tuple, so its layout claim cannot be read" + ) + if not found: + problems.append( + f"python-inline-structs: {label} no longer defines {binding_name}, " + f"which is where the binding's {struct_name} size lives" + ) + + tree = ast.parse(_read(PYTHON_WORKBOOK), filename=str(PYTHON_WORKBOOK)) + label = PYTHON_WORKBOOK.relative_to(REPO_ROOT) + for qualname, struct_name in _PYTHON_INLINE_DECODERS: + function = _find_qualified_function(tree, qualname) + if function is None: + problems.append( + f"python-inline-structs: {label} no longer defines {qualname}, " + f"which is where the inline {struct_name} decoding lives" + ) + continue + measured = measure(struct_name) + if measured is None: + continue + c_offsets, c_size, _, c_sizes = measured + member_widths = [c_sizes[name] for name in c_offsets] + + for node in ast.walk(function): + if isinstance(node, ast.Call) and _is_lib_call(node, "read_bytes") and len(node.args) > 1: + length = node.args[1] + span = None + if isinstance(length, ast.Constant) and isinstance(length.value, int): + span = length.value + elif isinstance(length, ast.Name): + span = constants.get(length.id) + if span is not None and span != c_size: + problems.append( + f"python-inline-structs: {label}:{node.lineno}: {qualname} reads {span} bytes " + f"for a {struct_name}, whose wasm32 size is {c_size}" + ) + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "struct" + ): + continue + if not node.args or not isinstance(node.args[0], ast.Constant): + continue + fmt = node.args[0].value + if not isinstance(fmt, str): + continue + widths = _struct_format_widths(fmt) + if widths is None: + problems.append( + f"python-inline-structs: {label}:{node.lineno}: {qualname} uses the format {fmt!r}, " + "which is not a little-endian fixed-width layout this check can measure" + ) + continue + if node.func.attr == "unpack_from": + if len(node.args) < 3 or not isinstance(node.args[2], ast.Constant): + continue + offset = node.args[2].value + owner = next((name for name, at in c_offsets.items() if at == offset), None) + if owner is None: + problems.append( + f"python-inline-structs: {label}:{node.lineno}: {qualname} decodes at offset " + f"{offset}, which is not a member offset of {struct_name} " + f"({sorted(c_offsets.items(), key=lambda item: item[1])})" + ) + elif sum(widths) > c_sizes[owner]: + problems.append( + f"python-inline-structs: {label}:{node.lineno}: {qualname} reads {sum(widths)} bytes " + f"with {fmt!r} at offset {offset}, but {struct_name}.{owner} is {c_sizes[owner]} bytes" + ) + elif node.func.attr == "unpack" and widths != member_widths: + problems.append( + f"python-inline-structs: {label}:{node.lineno}: {qualname} decodes a whole " + f"{struct_name} with {fmt!r} (field widths {widths}), but the C members are " + f"{list(zip(c_offsets, member_widths))}" + ) + return problems diff --git a/tools/dev/binding_drift/shape_checks.py b/tools/dev/binding_drift/shape_checks.py new file mode 100644 index 000000000..c091844bb --- /dev/null +++ b/tools/dev/binding_drift/shape_checks.py @@ -0,0 +1,236 @@ +"""Drift checks on the shape of shared declarations: return types and record field sets.""" + +from __future__ import annotations + +import re +from typing import List, Optional, Set + +from .c_header import _c_struct_fields +from .surface_files import ( + CAPI_HEADER, + NODE_DTS, + PYTHON_WORKBOOK, + REPO_ROOT, + WASM_DTS, + _format_diff, + _read, +) +from .ts_decl import ( + _find_interface_body, + _ts_field_set, + _ts_interface_declaration, + _ts_return_types, +) + +# --------------------------------------------------------------------------- +# Check 2c: shared `.d.ts` shapes -- return types and record field sets. +# +# `dts-wasm` and `dts-node` compare method *names* only. That is enough to +# notice a method that exists on one surface and not the other, and nothing +# else: a shared method whose two declarations disagree about what comes back, +# or a record type that grew a field on one side, passes both of them. The +# README's parity claim is about the shapes, not the names, so this check +# compares the shapes. +# +# Types declared in only one of the two published declaration files. Each has +# to be reachable only from that surface's own method allowlist -- a type that +# becomes one-sided for any other reason is a missing declaration, and listing +# it here is the deliberate act that says otherwise. +# --------------------------------------------------------------------------- + +_DTS_SURFACE_ONLY_TYPES = { + # The embind module factory and its options: there is no equivalent on a + # native addon, which is `require`d rather than instantiated. + ("wasm", "FormulonModule"), + ("wasm", "FormulonModuleOptions"), + # Reachable only from `getSheetAutoFilterXml` / `createTable` / + # `updateTable`, all of which are in WASM_ONLY_METHODS. + ("wasm", "SheetAutoFilterXmlResult"), + ("wasm", "TableInput"), + ("wasm", "TableUpdateInput"), +} + +# Shared methods whose two declarations are allowed to differ, with the +# reason. Empty by design: a difference that is not worth an entry here is a +# difference that should not exist. +_DTS_RETURN_TYPE_EXEMPT_METHODS: dict[str, str] = {} + + +def check_dts_shared_shapes() -> List[str]: + problems: List[str] = [] + wasm_dts = _read(WASM_DTS) + node_dts = _read(NODE_DTS) + wasm_label = str(WASM_DTS.relative_to(REPO_ROOT)) + node_label = str(NODE_DTS.relative_to(REPO_ROOT)) + + for interface in ("Workbook", "WorkbookCtor"): + wasm_returns = _ts_return_types(_find_interface_body(wasm_dts, interface, WASM_DTS)) + node_returns = _ts_return_types(_find_interface_body(node_dts, interface, NODE_DTS)) + for name in sorted(set(wasm_returns) & set(node_returns)): + if wasm_returns[name] == node_returns[name]: + if name in _DTS_RETURN_TYPE_EXEMPT_METHODS: + problems.append( + f"dts-shared-shapes: {name} is listed as a deliberate return-type " + "difference but both surfaces now declare the same type; drop the entry" + ) + continue + if name in _DTS_RETURN_TYPE_EXEMPT_METHODS: + continue + problems.append( + f"dts-shared-shapes: {interface}.{name} returns a different type on each surface:\n" + f" {wasm_label}: {wasm_returns[name]}\n" + f" {node_label}: {node_returns[name]}" + ) + + wasm_types = set(re.findall(r"^export interface ([A-Za-z0-9_]+)", wasm_dts, re.MULTILINE)) + node_types = set(re.findall(r"^export interface ([A-Za-z0-9_]+)", node_dts, re.MULTILINE)) + surface_only = {("wasm", name) for name in wasm_types - node_types} + surface_only |= {("node", name) for name in node_types - wasm_types} + undeclared = surface_only - _DTS_SURFACE_ONLY_TYPES + if undeclared: + problems.append( + "dts-shared-shapes: type declared on one surface only, without an entry in " + f"_DTS_SURFACE_ONLY_TYPES: {sorted(undeclared)}" + ) + stale = _DTS_SURFACE_ONLY_TYPES - surface_only + if stale: + problems.append(f"dts-shared-shapes: stale _DTS_SURFACE_ONLY_TYPES entries: {sorted(stale)}") + + for name in sorted(wasm_types & node_types): + if name in ("Workbook", "WorkbookCtor"): + continue + wasm_extends, wasm_body = _ts_interface_declaration(wasm_dts, name, WASM_DTS) + node_extends, node_body = _ts_interface_declaration(node_dts, name, NODE_DTS) + if wasm_extends != node_extends: + problems.append( + f"dts-shared-shapes: {name} inherits differently on each surface:\n" + f" {wasm_label}: {wasm_extends or '(nothing)'}\n" + f" {node_label}: {node_extends or '(nothing)'}" + ) + diff = _format_diff( + f"{name} in {wasm_label}", + _ts_field_set(wasm_body) - _ts_field_set(node_body), + f"{name} in {node_label}", + _ts_field_set(node_body) - _ts_field_set(wasm_body), + ) + if diff: + problems.append(f"dts-shared-shapes: {name} field set mismatch:\n" + "\n".join(diff)) + + return problems + + +# Public type name -> the C struct it projects. +_STYLE_RECORD_STRUCTS = { + "CellXf": "fm_cell_xf", + "ColorSpec": "fm_color_spec", + "FontRecord": "fm_font_record", + "FillRecord": "fm_fill_record", + "BorderSide": "fm_border_side", +} + +# Deliberate omissions, keyed by (surface, type name). Python models a border +# side as a plain dict rather than a dataclass, so it has no `BorderSide` type +# to compare; the dict shape is covered by the wasm32 struct-layout check. +_STYLE_RECORD_EXEMPT_TYPES = { + ("python", "BorderSide"), +} + +# C presence flags that neither host language mirrors as a field, because both +# already have a way to say "absent": the TS surface omits the optional +# property, Python leaves it `None`. Naming the value field alone would let a +# host drop the distinction entirely, so each entry lists the value field the +# flag governs and both are required to exist together. +_STYLE_RECORD_PRESENCE_FLAGS = { + "fm_cell_xf": { + "has_text_rotation": "text_rotation", + "has_indent": "indent", + "has_relative_indent": "relative_indent", + "has_shrink_to_fit": "shrink_to_fit", + "has_reading_order": "reading_order", + }, +} + +_TS_FIELD_RE = re.compile(r"^\s*([A-Za-z_$][A-Za-z0-9_$]*)\??\s*:", re.MULTILINE) +_PY_FIELD_RE = re.compile(r"^ ([a-z_][A-Za-z0-9_]*)\s*:", re.MULTILINE) + + +def _snake_to_camel(name: str) -> str: + head, *rest = name.split("_") + return head + "".join(part.capitalize() for part in rest) + + +def _python_class_fields(text: str, class_name: str) -> Optional[Set[str]]: + match = re.search(r"^class %s:\s*$" % re.escape(class_name), text, re.MULTILINE) + if not match: + return None + rest = text[match.end() :] + end = re.search(r"^(?:@|class |def )", rest, re.MULTILINE) + body = rest[: end.start()] if end else rest + return set(_PY_FIELD_RE.findall(body)) + + +def check_style_record_fields() -> List[str]: + problems: List[str] = [] + header = re.sub(r"/\*.*?\*/", "", _read(CAPI_HEADER), flags=re.S) + wasm_dts = _read(WASM_DTS) + node_dts = _read(NODE_DTS) + python_text = _read(PYTHON_WORKBOOK) + + for type_name, struct_name in sorted(_STYLE_RECORD_STRUCTS.items()): + c_fields = _c_struct_fields(header, struct_name, CAPI_HEADER) + # A presence flag is dropped from the comparison, but only once its + # governed value field is confirmed present on the C side -- otherwise + # renaming the value field would silently retire the flag with it. + presence = _STYLE_RECORD_PRESENCE_FLAGS.get(struct_name, {}) + for flag, value_field in sorted(presence.items()): + if flag in c_fields and value_field not in c_fields: + problems.append( + f"style-record-fields: {struct_name}.{flag} is exempted as a presence flag for " + f"{value_field!r}, which no longer exists in {CAPI_HEADER.relative_to(REPO_ROOT)}" + ) + stale_flags = sorted(flag for flag in presence if flag not in c_fields) + if stale_flags: + problems.append(f"style-record-fields: stale presence-flag exemptions for {struct_name}: {stale_flags}") + c_fields = [name for name in c_fields if name not in presence] + camel_fields = {_snake_to_camel(name) for name in c_fields} + for surface, source, dts_text in ( + ("wasm", WASM_DTS, wasm_dts), + ("node", NODE_DTS, node_dts), + ): + if (surface, type_name) in _STYLE_RECORD_EXEMPT_TYPES: + continue + ts_fields = set(_TS_FIELD_RE.findall(_find_interface_body(dts_text, type_name, source))) + diff = _format_diff( + f"{struct_name} in {CAPI_HEADER.relative_to(REPO_ROOT)}", + camel_fields - ts_fields, + f"{type_name} in {source.relative_to(REPO_ROOT)}", + ts_fields - camel_fields, + ) + if diff: + problems.append(f"style-record-fields: {surface} {type_name} mismatch:\n" + "\n".join(diff)) + + if ("python", type_name) in _STYLE_RECORD_EXEMPT_TYPES: + continue + py_fields = _python_class_fields(python_text, type_name) + if py_fields is None: + problems.append( + f"style-record-fields: dataclass {type_name!r} not found in {PYTHON_WORKBOOK.relative_to(REPO_ROOT)}" + ) + continue + diff = _format_diff( + f"{struct_name} in {CAPI_HEADER.relative_to(REPO_ROOT)}", + set(c_fields) - py_fields, + f"{type_name} in {PYTHON_WORKBOOK.relative_to(REPO_ROOT)}", + py_fields - set(c_fields), + ) + if diff: + problems.append(f"style-record-fields: python {type_name} mismatch:\n" + "\n".join(diff)) + + stale = { + (surface, type_name) + for surface, type_name in _STYLE_RECORD_EXEMPT_TYPES + if type_name not in _STYLE_RECORD_STRUCTS + } + if stale: + problems.append(f"style-record-fields: stale exemption entries: {sorted(stale)}") + return problems diff --git a/tools/dev/binding_drift/surface_files.py b/tools/dev/binding_drift/surface_files.py new file mode 100644 index 000000000..8d8ca1bd1 --- /dev/null +++ b/tools/dev/binding_drift/surface_files.py @@ -0,0 +1,84 @@ +"""Repo locations of the hand-synced surface files, and the shared reader and skip ledger.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import List, Set + +REPO_ROOT = Path(__file__).resolve().parents[3] + +CAPI_HEADER = REPO_ROOT / "src" / "c_api" / "formulon_c.h" +CAPI_EXPORTS = REPO_ROOT / "tools" / "wasm" / "capi_exports.txt" +PYTHON_PKG_DIR = REPO_ROOT / "packages" / "python" / "formulon" +PYTHON_C_BINDING = PYTHON_PKG_DIR / "_c.py" + +WASM_BINDINGS_CPP = REPO_ROOT / "src" / "wasm" / "parts" / "bindings_register.cpp" +WASM_DTS = REPO_ROOT / "src" / "wasm" / "formulon.d.ts" + +NODE_WORKBOOK_CLASS_CC = REPO_ROOT / "src" / "node_addon" / "parts" / "workbook_class.cc" +NODE_ADDON_CC = REPO_ROOT / "src" / "node_addon" / "addon.cc" +NODE_DTS = REPO_ROOT / "packages" / "npm-native" / "index.d.ts" +NODE_README = REPO_ROOT / "packages" / "npm-native" / "README.md" +NODE_INDEX_MJS = REPO_ROOT / "packages" / "npm-native" / "index.mjs" +NODE_DIST_DIR = REPO_ROOT / "packages" / "npm-native" / "dist" + +C_ABI_BASELINE = Path(__file__).resolve().parents[1] / "c_abi_baseline.txt" +C_ABI_BREAKS = Path(__file__).resolve().parents[1] / "c_abi_breaks.txt" +# The npm package's constants and helpers live in common.mjs; each published +# entry point (single-threaded and pthread) re-exports it wholesale. +NPM_COMMON_MJS = REPO_ROOT / "packages" / "npm" / "common.mjs" +NPM_ENTRY_MJS = ( + REPO_ROOT / "packages" / "npm" / "index.mjs", + REPO_ROOT / "packages" / "npm" / "threads.mjs", +) + +ERROR_H = REPO_ROOT / "src" / "utils" / "error.h" +VALUE_H = REPO_ROOT / "src" / "value.h" +CF_MATCH_H = REPO_ROOT / "src" / "cf" / "cf_match.h" +CALC_MODE_H = REPO_ROOT / "src" / "calc_settings.h" +EXTERNAL_LINKS_H = REPO_ROOT / "src" / "external_link.h" +PYTHON_STRUCTS = PYTHON_PKG_DIR / "_structs.py" + +# A sub-check appends here when an input it needs is legitimately absent, as +# opposed to wrong. `main` prints these alongside the verdict so "not checked" +# is visible in the output and can never be read as "checked and clean" -- a +# guard that quietly passes when its input is missing is not a guard. +_SKIPPED: List[str] = [] + + +def _read(path: Path) -> str: + if not path.is_file(): + print(f"check_binding_drift: missing file: {path}", file=sys.stderr) + sys.exit(2) + return path.read_text(encoding="utf-8") + + +def _read_bytes(path: Path) -> bytes: + """`_read` for a comparison that must not normalise line endings.""" + if not path.is_file(): + print(f"check_binding_drift: missing file: {path}", file=sys.stderr) + sys.exit(2) + return path.read_bytes() + + +def _format_diff(label_a: str, only_a: Set[str], label_b: str, only_b: Set[str]) -> List[str]: + problems = [] + if only_a: + problems.append(f" in {label_a} but not {label_b}: {sorted(only_a)}") + if only_b: + problems.append(f" in {label_b} but not {label_a}: {sorted(only_b)}") + return problems + + +# --------------------------------------------------------------------------- +# Check 6: public style-record field sets <-> their C ABI struct. +# +# `FontRecord` and friends are declared independently in three public +# surfaces and each one claims to mirror the C struct. Method-level drift +# checking cannot see a field that one surface forgot, which is how a font's +# `vertAlign` reached the WASM `.d.ts` alone: reading a superscript font and +# writing it back through the other bindings silently demoted it. +# --------------------------------------------------------------------------- + +PYTHON_WORKBOOK = PYTHON_PKG_DIR / "workbook.py" diff --git a/tools/dev/binding_drift/ts_decl.py b/tools/dev/binding_drift/ts_decl.py new file mode 100644 index 000000000..5a395ab8a --- /dev/null +++ b/tools/dev/binding_drift/ts_decl.py @@ -0,0 +1,159 @@ +"""Parsing of TypeScript declaration files and the JS constant tables behind them.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Optional, Set + +from .surface_files import _read + + +def _extract_braced_block(text: str, start: int) -> str: + """Returns the substring between `start` (just after an opening `{`) + and its matching closing `}`, using a simple depth counter. + + Safe for this codebase's `.d.ts` files: the only braces inside an + interface body are either the one being matched or self-contained + `{ ... }` pairs inside single-line JSDoc comments (e.g. `` `{ status, + index }` ``), which do not change the net depth. + """ + depth = 1 + i = start + while i < len(text) and depth > 0: + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + i += 1 + return text[start : i - 1] + + +def _find_interface_body(text: str, name: str, source: Path) -> str: + match = re.search(r"export interface %s\s*\{" % re.escape(name), text) + if not match: + print(f"check_binding_drift: interface {name!r} not found in {source}", file=sys.stderr) + sys.exit(2) + return _extract_braced_block(text, match.end()) + + +# Matches a method signature's leading `name(` at the start of a line +# (after indentation); this also catches multi-line signatures whose +# parameter list wraps, since only the opening token is needed. Plain +# data fields (`name: Type;`) do not match -- no `(` follows the name. +_TS_METHOD_RE = re.compile(r"^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", re.MULTILINE) + + +def _extract_ts_methods(body: str) -> Set[str]: + return set(_TS_METHOD_RE.findall(body)) + + +# Top-level members of an exported interface are indented exactly two spaces; +# anything deeper belongs to a nested object type and is compared as part of +# its parent's field text, not on its own. +_TS_MEMBER_NAME_RE = re.compile(r"^ {2}([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", re.MULTILINE) +_TS_MEMBER_FIELD_RE = re.compile(r"^ {2}(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_$]*)(\??)\s*:", re.MULTILINE) + + +def _ts_return_types(body: str) -> dict[str, str]: + """Maps each method in an interface body to its declared return type. + + The parameter list is skipped by bracket balancing so a wrapped or + generic signature is read the same as a single-line one; whitespace in + the return type is collapsed so formatting differences do not register + as drift. + """ + out: dict[str, str] = {} + for match in _TS_MEMBER_NAME_RE.finditer(body): + open_paren = body.index("(", match.start()) + depth = 0 + close_paren = -1 + for i in range(open_paren, len(body)): + char = body[i] + if char in "(<[": + depth += 1 + elif char in ")>]": + depth -= 1 + if depth == 0 and char == ")": + close_paren = i + break + if close_paren < 0: + continue + rest = body[close_paren + 1 :] + if ";" not in rest: + continue + declared = rest[: rest.index(";")].strip() + if declared.startswith(":"): + declared = declared[1:] + out[match.group(1)] = " ".join(declared.split()) + return out + + +def _ts_interface_declaration(text: str, name: str, source: Path) -> tuple[str, str]: + """Returns an interface's `extends` clause (may be empty) and its body. + + Unlike `_find_interface_body` this tolerates `extends`, which matters + here: a record type that inherits its fields declares none of them + locally, so the base list is part of the shape being compared. + """ + match = re.search(r"^export interface %s\b([^{]*)\{" % re.escape(name), text, re.MULTILINE) + if not match: + print(f"check_binding_drift: interface {name!r} not found in {source}", file=sys.stderr) + sys.exit(2) + return " ".join(match.group(1).split()), _extract_braced_block(text, match.end()) + + +def _ts_field_set(body: str) -> Set[str]: + """Field names of an interface body, each suffixed with `?` when optional. + + Optionality is part of the compared shape on purpose: "present only on + success" is how both declaration files spell a payload key the runtime + drops, so a required declaration on one side and an optional one on the + other is a real disagreement about what a caller may dereference. + """ + return {name + optional for name, optional in _TS_MEMBER_FIELD_RE.findall(body)} + + +# --------------------------------------------------------------------------- +# Check 2d: pure-JS helpers shipped by both npm packages. +# +# A helper with no native entry point behind it has to be written out once per +# package, because the two packages share no module. Nothing then holds the +# copies together, and nothing notices when one package documents a helper it +# does not ship -- which is how the WASM declaration file came to send its +# readers to a package that is not published. Both halves are checked here: +# the helper is exported and declared by both packages, and the two +# implementations are the same text. +# --------------------------------------------------------------------------- + + +def _js_exported_function_source(text: str, name: str) -> Optional[str]: + """Whitespace-normalised body of `export function (...) { ... }`.""" + match = re.search(r"^export function %s\s*\(" % re.escape(name), text, re.MULTILINE) + if not match: + return None + body_open = text.index("{", match.end()) + return " ".join(_extract_braced_block(text, body_open + 1).split()) + + +# Frozen ordinal tables are the only way a JS consumer can name a value +# that crosses the boundary as a plain number, so both published ESM entry +# points have to carry the same ones. The WASM `.d.ts` above is already +# pinned to the C/C++ enums, which makes it the single source the two +# `index.mjs` files are measured against -- comparing them only to each +# other would let a shared mistake pass. +_JS_TABLE_RE = re.compile(r"^export const (\w+) = Object\.freeze\(\{(.*?)\}\);", re.DOTALL | re.MULTILINE) +_JS_SCALAR_RE = re.compile(r"^export const (\w+) = (-?\d+);", re.MULTILINE) +_JS_MEMBER_RE = re.compile(r"(\w+)\s*:\s*(-?\d+)") + + +def _parse_js_constants(path: Path) -> dict: + """Reads an `index.mjs`'s exported ordinal tables and scalar constants.""" + text = _read(path) + out: dict = {} + for match in _JS_TABLE_RE.finditer(text): + out[match.group(1)] = {name: int(value) for name, value in _JS_MEMBER_RE.findall(match.group(2))} + for match in _JS_SCALAR_RE.finditer(text): + out[match.group(1)] = int(match.group(2)) + return out diff --git a/tools/dev/check_binding_drift.py b/tools/dev/check_binding_drift.py index 0f1e07cd5..84bcdaa03 100644 --- a/tools/dev/check_binding_drift.py +++ b/tools/dev/check_binding_drift.py @@ -115,2064 +115,27 @@ from __future__ import annotations import argparse -import ast -import importlib.util -import re import sys -from pathlib import Path -from typing import List, NamedTuple, Optional, Set - -REPO_ROOT = Path(__file__).resolve().parents[2] - -CAPI_HEADER = REPO_ROOT / "src" / "c_api" / "formulon_c.h" -CAPI_EXPORTS = REPO_ROOT / "tools" / "wasm" / "capi_exports.txt" -PYTHON_PKG_DIR = REPO_ROOT / "packages" / "python" / "formulon" -PYTHON_C_BINDING = PYTHON_PKG_DIR / "_c.py" - -WASM_BINDINGS_CPP = REPO_ROOT / "src" / "wasm" / "parts" / "bindings_register.cpp" -WASM_DTS = REPO_ROOT / "src" / "wasm" / "formulon.d.ts" - -NODE_WORKBOOK_CLASS_CC = REPO_ROOT / "src" / "node_addon" / "parts" / "workbook_class.cc" -NODE_ADDON_CC = REPO_ROOT / "src" / "node_addon" / "addon.cc" -NODE_DTS = REPO_ROOT / "packages" / "npm-native" / "index.d.ts" -NODE_README = REPO_ROOT / "packages" / "npm-native" / "README.md" -NODE_INDEX_MJS = REPO_ROOT / "packages" / "npm-native" / "index.mjs" -NODE_DIST_DIR = REPO_ROOT / "packages" / "npm-native" / "dist" - -C_ABI_BASELINE = Path(__file__).resolve().parent / "c_abi_baseline.txt" -C_ABI_BREAKS = Path(__file__).resolve().parent / "c_abi_breaks.txt" -# The npm package's constants and helpers live in common.mjs; each published -# entry point (single-threaded and pthread) re-exports it wholesale. -NPM_COMMON_MJS = REPO_ROOT / "packages" / "npm" / "common.mjs" -NPM_ENTRY_MJS = ( - REPO_ROOT / "packages" / "npm" / "index.mjs", - REPO_ROOT / "packages" / "npm" / "threads.mjs", -) - -ERROR_H = REPO_ROOT / "src" / "utils" / "error.h" -VALUE_H = REPO_ROOT / "src" / "value.h" -CF_MATCH_H = REPO_ROOT / "src" / "cf" / "cf_match.h" -CALC_MODE_H = REPO_ROOT / "src" / "calc_settings.h" -EXTERNAL_LINKS_H = REPO_ROOT / "src" / "external_link.h" -PYTHON_STRUCTS = PYTHON_PKG_DIR / "_structs.py" - -# embind auto-adds a `delete()` finaliser to every `class_` -- it has no -# corresponding `.function(...)` registration, so it must be excluded before -# comparing the WASM Workbook interface against bindings_register.cpp. -WASM_AUTO_METHODS = {"delete"} - -# Intentional binding differences are kept exact. This makes a newly added -# method or a removed method fail drift checking instead of becoming a stale -# exception in one binding. -WASM_ONLY_METHODS = { - "addCellStyleXf", - "createTable", - "getSheetAutoFilterXml", - "removeTable", - "setCellStyle", - "setSheetAutoFilterXml", - "updateTable", -} -NODE_ONLY_METHODS = {"dispose", "memoryUsage"} - -# Pure-JS free functions declared in packages/npm-native/index.d.ts that are -# intentionally NOT backed by a native `exports.Set(...)` registration -- they -# are host-side helpers implemented in index.mjs (e.g. the function-metadata -# provider merge helper). Excluded before comparing the d.ts free-function -# surface against the addon's native exports. -NODE_PURE_JS_FREE_FUNCTIONS = {"mergeFunctionMetadata"} - - -# A sub-check appends here when an input it needs is legitimately absent, as -# opposed to wrong. `main` prints these alongside the verdict so "not checked" -# is visible in the output and can never be read as "checked and clean" -- a -# guard that quietly passes when its input is missing is not a guard. -_SKIPPED: List[str] = [] - - -def _read(path: Path) -> str: - if not path.is_file(): - print(f"check_binding_drift: missing file: {path}", file=sys.stderr) - sys.exit(2) - return path.read_text(encoding="utf-8") - - -def _read_bytes(path: Path) -> bytes: - """`_read` for a comparison that must not normalise line endings.""" - if not path.is_file(): - print(f"check_binding_drift: missing file: {path}", file=sys.stderr) - sys.exit(2) - return path.read_bytes() - - -def _extract_braced_block(text: str, start: int) -> str: - """Returns the substring between `start` (just after an opening `{`) - and its matching closing `}`, using a simple depth counter. - - Safe for this codebase's `.d.ts` files: the only braces inside an - interface body are either the one being matched or self-contained - `{ ... }` pairs inside single-line JSDoc comments (e.g. `` `{ status, - index }` ``), which do not change the net depth. - """ - depth = 1 - i = start - while i < len(text) and depth > 0: - if text[i] == "{": - depth += 1 - elif text[i] == "}": - depth -= 1 - i += 1 - return text[start : i - 1] - - -def _find_interface_body(text: str, name: str, source: Path) -> str: - match = re.search(r"export interface %s\s*\{" % re.escape(name), text) - if not match: - print(f"check_binding_drift: interface {name!r} not found in {source}", file=sys.stderr) - sys.exit(2) - return _extract_braced_block(text, match.end()) - - -# Matches a method signature's leading `name(` at the start of a line -# (after indentation); this also catches multi-line signatures whose -# parameter list wraps, since only the opening token is needed. Plain -# data fields (`name: Type;`) do not match -- no `(` follows the name. -_TS_METHOD_RE = re.compile(r"^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", re.MULTILINE) - - -def _extract_ts_methods(body: str) -> Set[str]: - return set(_TS_METHOD_RE.findall(body)) - - -def _format_diff(label_a: str, only_a: Set[str], label_b: str, only_b: Set[str]) -> List[str]: - problems = [] - if only_a: - problems.append(f" in {label_a} but not {label_b}: {sorted(only_a)}") - if only_b: - problems.append(f" in {label_b} but not {label_a}: {sorted(only_b)}") - return problems - - -def _parse_literal_status_exports() -> tuple[Set[str], Optional[str]]: - """Read `_STATUS_RETURNING_EXPORT_NAMES` without importing `_c.py`. - - Importing the binding would load wasmtime and, depending on the - environment, initialize a real WASM instance. The drift check is a - source-level consistency check, so an AST walk keeps it stdlib-only and - side-effect-free. - """ - try: - tree = ast.parse(_read(PYTHON_C_BINDING), filename=str(PYTHON_C_BINDING)) - except SyntaxError as exc: - return set(), f"python-exports: cannot parse {PYTHON_C_BINDING.relative_to(REPO_ROOT)}: {exc}" - - for statement in tree.body: - if isinstance(statement, ast.Assign): - targets = statement.targets - elif isinstance(statement, ast.AnnAssign): - targets = [statement.target] - else: - continue - if not any( - isinstance(target, ast.Name) and target.id == "_STATUS_RETURNING_EXPORT_NAMES" for target in targets - ): - continue - value = statement.value - if not isinstance(value, ast.Tuple): - return set(), "python-exports: _STATUS_RETURNING_EXPORT_NAMES must be a literal tuple" - names: Set[str] = set() - for element in value.elts: - if not isinstance(element, ast.Constant) or not isinstance(element.value, str): - return set(), "python-exports: _STATUS_RETURNING_EXPORT_NAMES must contain only string literals" - names.add(element.value) - return names, None - - return ( - set(), - "python-exports: _STATUS_RETURNING_EXPORT_NAMES literal tuple not found in packages/python/formulon/_c.py", - ) - - -# --------------------------------------------------------------------------- -# Check 1: Python `LIB.fm_*` calls <-> tools/wasm/capi_exports.txt <-> -# src/c_api/formulon_c.h declarations. -# --------------------------------------------------------------------------- - - -def check_python_exports() -> List[str]: - problems: List[str] = [] - - python_calls: Set[str] = set() - for py_file in sorted(PYTHON_PKG_DIR.glob("*.py")): - python_calls |= set(re.findall(r"\bLIB\.(fm_[A-Za-z0-9_]+)", _read(py_file))) - - exports_text = _read(CAPI_EXPORTS) - exports: Set[str] = set() - for line in exports_text.splitlines(): - stripped = line.strip() - if stripped and not stripped.startswith("#"): - exports.add(stripped) - - header_text = _read(CAPI_HEADER) - header_fns = set(re.findall(r"\bfm_[A-Za-z0-9_]+(?=\s*\()", header_text)) - - missing_from_exports = python_calls - exports - if missing_from_exports: - problems.append( - "python-exports: Python calls a symbol not staged in " - f"{CAPI_EXPORTS.relative_to(REPO_ROOT)}: {sorted(missing_from_exports)}" - ) - - # capi_exports.txt also lists a handful of non-`fm_` allocator symbols - # (`malloc`/`free`) needed for host-side scratch-buffer plumbing; those - # are libc, not part of the `fm_*` C ABI, and are never declared in - # formulon_c.h. Only the `fm_*` subset is checked against the header. - fm_exports = {symbol for symbol in exports if symbol.startswith("fm_")} - exports_not_in_header = fm_exports - header_fns - if exports_not_in_header: - problems.append( - "python-exports: symbol staged in " - f"{CAPI_EXPORTS.relative_to(REPO_ROOT)} but not declared in " - f"{CAPI_HEADER.relative_to(REPO_ROOT)}: {sorted(exports_not_in_header)}" - ) - - # The WASM result type is not enough to identify status calls: counts, - # indices, and pointers are also i32. Keep the binding's capture list as - # a literal and compare it to the authoritative header/manifest - # intersection without importing `_c.py` or wasmtime. - header_status_fns = set(re.findall(r"\bFM_API\s+fm_status_t\s+(fm_[A-Za-z0-9_]+)\s*\(", header_text)) - expected_status_exports = header_status_fns & fm_exports - binding_status_exports, parse_problem = _parse_literal_status_exports() - if parse_problem: - problems.append(parse_problem) - - missing_status_exports = expected_status_exports - binding_status_exports - if missing_status_exports: - problems.append( - "python-exports: _STATUS_RETURNING_EXPORT_NAMES is missing status-returning exports: " - f"{sorted(missing_status_exports)}" - ) - - extra_status_exports = binding_status_exports - expected_status_exports - if extra_status_exports: - problems.append( - f"python-exports: _STATUS_RETURNING_EXPORT_NAMES has extra exports: {sorted(extra_status_exports)}" - ) - - return problems - - -# --------------------------------------------------------------------------- -# Check 1b: Python WASM32 POD layouts <-> C ABI header. -# --------------------------------------------------------------------------- - - -def _wasm32_layout(fields: List[tuple[str, int, int]]) -> tuple[dict[str, int], int, int]: - """Lays `fields` out in sequence, returning `(offsets, size, alignment)`.""" - offsets: dict[str, int] = {} - offset = 0 - max_align = 1 - for name, size, align in fields: - offset = (offset + align - 1) // align * align - offsets[name] = offset - offset += size - max_align = max(max_align, align) - return offsets, (offset + max_align - 1) // max_align * max_align, max_align - - -# wasm32 (ILP32) size and alignment for the leaf types the C ABI structs are -# built from. These are properties of the target ABI rather than anything the -# header states, which is why they are the only layout facts tabulated here: -# every type that has a declaration to read -- struct, union, array -- is -# measured from that declaration instead of quoted. A literal for an embedded -# struct would keep asserting its old size after the struct changed, which is -# the one thing this check exists to catch. -_WASM32_SCALARS = { - "uint8_t": (1, 1), - "uint16_t": (2, 2), - "uint32_t": (4, 4), - "int32_t": (4, 4), - "double": (8, 8), - "size_t": (4, 4), -} -_WASM32_POINTER = (4, 4) -# A C enum takes `int` representation. The header pins each enumerator's -# ordinal but says nothing about the storage width, so this is not derivable -# either; *which* names are enums is read off the header. -_WASM32_ENUM = (4, 4) - -_C_STRUCT_RE = re.compile(r"typedef\s+struct\s*\{(.*?)\}\s*(fm_[A-Za-z0-9_]+)\s*;", re.S) -_C_ENUM_RE = re.compile(r"typedef\s+enum\s*\{[^}]*\}\s*([A-Za-z_][A-Za-z0-9_]*)\s*;") -# An anonymous `union { ... } name;` member, matched before the enclosing body -# is split on `;` so the union's own members are not mistaken for the parent's. -_C_UNION_MEMBER_RE = re.compile(r"union\s*\{(.*?)\}\s*([A-Za-z_][A-Za-z0-9_]*)\s*;", re.S) -_C_DECLARATION_RE = re.compile(r"(.+?)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\[(\d+)\])?") - - -class _LayoutError(Exception): - """A C declaration the wasm32 layout resolver cannot measure.""" - - -class _Member(NamedTuple): - """One measured struct member. `ctype` is empty for an anonymous union.""" - - name: str - ctype: str - size: int - align: int - - -class _Wasm32Layouts: - """Measures the C ABI's structs against the wasm32 ABI, from the header. - - Struct members are resolved recursively, so an embedded POD is measured - rather than quoted: changing a field of `fm_color_spec` moves every struct - that embeds it, and the Python side is checked against the new offsets. - """ - - def __init__(self, header: str) -> None: - self._structs = {name: body for body, name in _C_STRUCT_RE.findall(header)} - self._enums = set(_C_ENUM_RE.findall(header)) - self._members: dict[str, List[_Member]] = {} - self._pending: Set[str] = set() - - def is_struct(self, ctype: str) -> bool: - return ctype in self._structs - - def members(self, struct_name: str) -> List[_Member]: - """Members of `struct_name` in declaration order, each measured.""" - cached = self._members.get(struct_name) - if cached is not None: - return cached - body = self._structs.get(struct_name) - if body is None: - raise _LayoutError(f"{struct_name} missing from C header") - if struct_name in self._pending: - raise _LayoutError(f"{struct_name} embeds itself") - self._pending.add(struct_name) - try: - measured = self._parse_body(struct_name, body) - finally: - self._pending.discard(struct_name) - self._members[struct_name] = measured - return measured - - def extent(self, ctype: str) -> tuple[int, int]: - """`(size, alignment)` of one member's declared type.""" - if "*" in ctype: - return _WASM32_POINTER - ctype = ctype.replace("const ", "").strip() - if ctype in self._structs: - _, size, align = _wasm32_layout([(m.name, m.size, m.align) for m in self.members(ctype)]) - return size, align - if ctype in _WASM32_SCALARS: - return _WASM32_SCALARS[ctype] - if ctype in self._enums: - return _WASM32_ENUM - raise _LayoutError(f"unknown field type {ctype!r}") - - def _parse_body(self, owner: str, body: str) -> List[_Member]: - measured: List[_Member] = [] - position = 0 - for union in _C_UNION_MEMBER_RE.finditer(body): - measured.extend(self._parse_declarations(owner, body[position : union.start()])) - size, align = self._union_extent(owner, union.group(1)) - measured.append(_Member(union.group(2), "", size, align)) - position = union.end() - measured.extend(self._parse_declarations(owner, body[position:])) - return measured - - def _parse_declarations(self, owner: str, text: str) -> List[_Member]: - measured: List[_Member] = [] - for declaration in text.split(";"): - declaration = " ".join(declaration.split()) - if not declaration: - continue - match = _C_DECLARATION_RE.fullmatch(declaration) - if not match: - raise _LayoutError(f"cannot parse {owner}: {declaration!r}") - ctype, name, count_text = match.groups() - size, align = self.extent(ctype) - measured.append(_Member(name, ctype.replace("const ", "").strip(), size * int(count_text or "1"), align)) - return measured - - def _union_extent(self, owner: str, body: str) -> tuple[int, int]: - """A union overlays its members: widest size, strictest alignment.""" - size = 0 - align = 1 - for member in self._parse_declarations(owner, body): - size = max(size, member.size) - align = max(align, member.align) - return (size + align - 1) // align * align, align - - -def _flatten_for_python( - layouts: _Wasm32Layouts, struct_name: str, py_field_names: Set[str] -) -> List[tuple[str, int, int]]: - """C members of `struct_name` in the shape the Python layout models them. - - Python keeps one flat field map per struct, so an embedded POD appears in - it one of two ways and both have to be accepted: as a single opaque blob - under the C member's own name (`fm_pivot_cell_t.value`), or spread into - the parent's map (`fm_cf_color_t color` contributing `color_r`). Which one - applies is read off the Python layout, so a struct that switches - representation needs no edit here -- only the nesting depth Python - actually flattens is followed. - """ - fields: List[tuple[str, int, int]] = [] - for member in layouts.members(struct_name): - if member.name in py_field_names or not layouts.is_struct(member.ctype): - fields.append((member.name, member.size, member.align)) - continue - for inner in layouts.members(member.ctype): - name = inner.name if inner.name in py_field_names else f"{member.name}_{inner.name}" - fields.append((name, inner.size, inner.align)) - return fields - - -def check_python_struct_layouts() -> List[str]: - """Verify Python's hand-written WASM32 structs against the C header. - - Every size and offset is measured from the authoritative C declarations - rather than repeated in a Python table -- including the structs embedded - inside other structs, which are laid out recursively. Only the wasm32 - scalar, pointer and enum widths are tabulated, because the header does not - state those. It covers every ``Struct`` exported by ``_structs.py`` and - reports field order, offsets, and final size. - """ - layouts = _Wasm32Layouts(re.sub(r"/\*.*?\*/", "", _read(CAPI_HEADER), flags=re.S)) - spec = importlib.util.spec_from_file_location("formulon_struct_layouts", PYTHON_STRUCTS) - if spec is None or spec.loader is None: - return ["python-struct-layout: could not load packages/python/formulon/_structs.py"] - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - problems: List[str] = [] - for layout in (value for value in vars(module).values() if isinstance(value, module.Struct)): - py_field_names = {name for name, _ in layout.fields} - try: - c_fields = _flatten_for_python(layouts, layout.name, py_field_names) - except _LayoutError as exc: - problems.append(f"python-struct-layout: {exc}") - continue - # Explicit C padding need not be represented in Struct: its alignment - # effect is reproduced by the following semantic field. It is still - # laid out here so the offsets after it are right. - c_offsets, c_size, _ = _wasm32_layout(c_fields) - c_sizes = {name: size for name, size, _ in c_fields} - semantic_names = [name for name, _, _ in c_fields if not name.startswith("_pad")] - py_names = [name for name, _ in layout.fields if not name.startswith("_pad")] - py_sizes = {name: spec[1] for name, spec in layout.fields} - if py_names != semantic_names: - problems.append(f"python-struct-layout: {layout.name} fields differ: C={semantic_names}, Python={py_names}") - for name in py_names: - if name in c_offsets and layout.offsets[name][1] != c_offsets[name]: - problems.append( - f"python-struct-layout: {layout.name}.{name} offset C={c_offsets[name]} Python={layout.offsets[name][1]}" - ) - # Field width is checked separately from offset: narrowing a field - # whose successor is more strictly aligned leaves every offset and - # the total size intact, so the offset comparison alone reads the - # wrong number of bytes without ever disagreeing. - if name in c_sizes and py_sizes[name] != c_sizes[name]: - problems.append( - f"python-struct-layout: {layout.name}.{name} size C={c_sizes[name]} Python={py_sizes[name]}" - ) - if layout.size != c_size: - problems.append(f"python-struct-layout: {layout.name} size C={c_size} Python={layout.size}") - return problems - - -# --------------------------------------------------------------------------- -# Check 1c: Python `LIB.fm_*` call sites <-> the C header's declarations. -# -# `python-exports` matches call *names* only. Nothing compares what a call -# passes against what the entry point declares, so a parameter added to an -# existing `fm_*` function -- the shape a 1.0-frozen ABI is most likely to -# grow -- leaves the Python side calling the old signature and is caught only -# if a runtime binding test happens to exercise that path. -# --------------------------------------------------------------------------- - -_C_FUNCTION_DECL_RE = re.compile( - r"FM_API\s+([A-Za-z_][A-Za-z0-9_ *]*?)\b(fm_[A-Za-z0-9_]+)\s*\(([^;]*?)\)\s*;", - re.S, -) - -# Scratch readers whose name pins how many bytes come back out of the slot. -# `read_cstr` is deliberately absent: it walks to a NUL rather than reading a -# fixed width, so it is checked for the deref mistake only (below). -_SCRATCH_READER_WIDTHS = {"read_u32": 4, "read_i32": 4, "read_f64": 8} - - -def _split_c_params(text: str) -> List[str]: - """Splits a parameter list on its top-level commas.""" - parts: List[str] = [] - depth = 0 - current = "" - for char in text: - if char == "(": - depth += 1 - elif char == ")": - depth -= 1 - if char == "," and depth == 0: - parts.append(current.strip()) - current = "" - else: - current += char - parts.append(current.strip()) - return parts - - -def _c_function_params(header: str) -> dict[str, List[str]]: - """Maps each `FM_API` entry point to its declared parameter types. - - The parameter *name* is stripped, so the values are types in declaration - order (`["fm_workbook_t*", "size_t", "fm_value_t*"]`). Every parameter in - this header is named, so dropping the last whitespace-delimited token is - unambiguous. - """ - return {name: params for name, (_, params) in c_abi_declarations(header).items()} - - -def c_abi_declarations(header: str) -> dict[str, tuple[str, List[str]]]: - """Maps each `FM_API` entry point to its `(return type, parameter types)`. - - Public because `gen_c_abi_baseline.py` writes the released-surface file - with it: generating the baseline through the same parser the check reads - it back with keeps a formatting difference from masquerading as a break. - """ - declarations: dict[str, tuple[str, List[str]]] = {} - for ret, name, params in _C_FUNCTION_DECL_RE.findall(header): - ret = " ".join(ret.split()) - params = " ".join(params.split()) - if params in ("", "void"): - declarations[name] = (ret, []) - continue - declarations[name] = (ret, [" ".join(param.split()[:-1]) for param in _split_c_params(params)]) - return declarations - - -def _module_int_constants(paths: List[Path]) -> dict[str, int]: - """Module-level `NAME = ` bindings across the binding package. - - Collected across files because the constant a scratch allocation is sized - with may be defined in one module and imported into another - (`fm_value_t_size` lives in `_c.py` and is used from `workbook.py`). - """ - constants: dict[str, int] = {} - for path in paths: - for statement in ast.parse(_read(path), filename=str(path)).body: - if not isinstance(statement, ast.Assign) or len(statement.targets) != 1: - continue - target = statement.targets[0] - if isinstance(target, ast.Name) and isinstance(statement.value, ast.Constant): - if isinstance(statement.value.value, int) and not isinstance(statement.value.value, bool): - constants[target.id] = statement.value.value - return constants - - -def _is_lib_call(node: ast.AST, attribute: Optional[str] = None) -> bool: - """True for `LIB.(...)` (any `LIB.*` call when unspecified).""" - if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): - return False - if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "LIB"): - return False - return attribute is None or node.func.attr == attribute - - -def _scratch_slot_widths( - function: ast.AST, constants: dict[str, int], struct_module: object -) -> dict[str, Optional[int]]: - """Local name -> byte width of the WASM scratch block bound to it. - - A width of `None` marks a slot whose size this resolver cannot pin (an - array allocation sized from a runtime length, or a name rebound to two - different widths). Those are reported rather than skipped when the C side - says the parameter is a struct pointer, because an unmeasurable buffer - behind a by-pointer struct is exactly the case a size guard exists for. - """ - widths: dict[str, Optional[int]] = {} - - def record(name: str, width: Optional[int]) -> None: - if name in widths and widths[name] != width: - widths[name] = None - else: - widths[name] = width - - for node in ast.walk(function): - if not isinstance(node, ast.Assign) or len(node.targets) != 1: - continue - target = node.targets[0] - if not isinstance(target, ast.Name) or not isinstance(node.value, ast.Call): - continue - call = node.value - callee = call.func - # `x = _alloc_out_ptr()` -- the package's 4-byte out-i32 / out-ptr slot. - if isinstance(callee, ast.Name) and callee.id == "_alloc_out_ptr": - record(target.id, 4) - # `x = S.alloc_struct(LIB, S.LAYOUT)` -- sized from the layout table - # `python-struct-layouts` already pins against the header. - elif (isinstance(callee, ast.Attribute) and callee.attr == "alloc_struct") or ( - isinstance(callee, ast.Name) and callee.id == "alloc_struct" - ): - layout = call.args[1] if len(call.args) > 1 else None - layout_name = layout.attr if isinstance(layout, ast.Attribute) else None - struct = getattr(struct_module, layout_name, None) if layout_name else None - record(target.id, struct.size if isinstance(struct, struct_module.Struct) else None) - # `x = LIB.alloc(N)` / `LIB.alloc(CONSTANT)` -- a hand-sized block. - elif _is_lib_call(call, "alloc") and call.args: - size = call.args[0] - if isinstance(size, ast.Constant) and isinstance(size.value, int): - record(target.id, size.value) - elif isinstance(size, ast.Name) and size.id in constants: - record(target.id, constants[size.id]) - else: - record(target.id, None) - return widths - - -def _scratch_slot_readers(function: ast.AST) -> dict[str, Set[object]]: - """Local name -> the reads taken against that scratch slot. - - An entry is either a `_SCRATCH_READER_WIDTHS` key, `("bytes", N)` for a - constant-length `LIB.read_bytes`, or `"read_cstr"`. - """ - readers: dict[str, Set[object]] = {} - for node in ast.walk(function): - if not isinstance(node, ast.Call) or not _is_lib_call(node) or not node.args: - continue - first = node.args[0] - if not isinstance(first, ast.Name): - continue - attribute = node.func.attr if isinstance(node.func, ast.Attribute) else "" - if attribute in _SCRATCH_READER_WIDTHS or attribute == "read_cstr": - readers.setdefault(first.id, set()).add(attribute) - elif attribute == "read_bytes" and len(node.args) > 1 and isinstance(node.args[1], ast.Constant): - readers.setdefault(first.id, set()).add(("bytes", node.args[1].value)) - return readers - - -def _pointee(ctype: str) -> Optional[str]: - """The type a parameter points at, or `None` if it is not a pointer.""" - ctype = ctype.strip() - if not ctype.endswith("*"): - return None - return ctype[:-1].strip() - - -def _check_out_param_width( - layouts: _Wasm32Layouts, - site: str, - fn_name: str, - position: int, - ctype: str, - width: Optional[int], - reads: Set[object], -) -> List[str]: - """One scratch slot against the parameter it is passed to.""" - problems: List[str] = [] - pointee = _pointee(ctype) - if pointee is None: - # Emscripten lowers a by-value struct parameter to a pointer into - # linear memory, so a scratch block is the correct thing to pass for - # one. Any other non-pointer parameter takes a scalar, and handing it - # a scratch address means the callee reads the pointer as the value. - bare = ctype.replace("const ", "").strip() - if not layouts.is_struct(bare): - return [ - f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r}, " - "which takes a scalar by value, but the binding passes a scratch-block pointer" - ] - pointee_size, _ = layouts.extent(bare) - is_struct = True - else: - bare = pointee.replace("const ", "").strip() - if bare.endswith("*"): - pointee_size, is_struct = 4, False # wasm32 pointer-to-pointer - else: - try: - pointee_size, _ = layouts.extent(bare) - except _LayoutError as exc: - return [f"python-call-signatures: {site}: {fn_name} parameter {position} ({ctype}): {exc}"] - is_struct = layouts.is_struct(bare) - - if width is None: - if is_struct: - problems.append( - f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r}, " - "but the size of the scratch block passed to it cannot be resolved; give the " - "struct a `_structs.Struct` layout or size the allocation from a module constant" - ) - return problems - - if width < pointee_size: - problems.append( - f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r} " - f"(wasm32 pointee {pointee_size} bytes) but the binding allocates {width} bytes" - ) - elif is_struct and width != pointee_size: - problems.append( - f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r} " - f"(wasm32 sizeof {pointee_size}) but the binding allocates {width} bytes; a struct " - "out-parameter block must match the C size exactly" - ) - - for read in sorted(reads, key=repr): - read_width = _SCRATCH_READER_WIDTHS.get(read) if isinstance(read, str) else read[1] - if read == "read_cstr": - if bare.endswith("*"): - problems.append( - f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r}, " - "so its slot holds a pointer; `read_cstr` on the slot itself decodes the " - "pointer's bytes as text instead of dereferencing it" - ) - continue - if read_width is None: - continue - if read_width > width: - problems.append( - f"python-call-signatures: {site}: {fn_name} parameter {position}'s slot is " - f"{width} bytes but is read with {read!r} ({read_width} bytes)" - ) - elif read_width > pointee_size: - problems.append( - f"python-call-signatures: {site}: {fn_name} parameter {position} is {ctype!r} " - f"(wasm32 pointee {pointee_size} bytes) but its slot is read with {read!r} " - f"({read_width} bytes)" - ) - return problems - - -def check_python_call_signatures() -> List[str]: - """Verify Python's `LIB.fm_*` calls against the C header's declarations. - - Covered, for every `LIB.fm_*(...)` call in `packages/python/formulon`: - - * **Arity.** The number of positional arguments equals the header's - parameter count. A call that splats (`*pointers`) is checked as a lower - bound only and named in the run's SKIPPED list. - * **Scratch-block width.** When an argument is a local bound to a - recognised scratch allocation (``_alloc_out_ptr()``, - ``S.alloc_struct(LIB, S.LAYOUT)``, or ``LIB.alloc(...)`` with a constant - or module-constant size), the block is compared against the wasm32 size - of what the parameter addresses: never smaller, and exactly equal when - that is a struct. This covers both pointer parameters and the by-value - struct parameters Emscripten lowers to a pointer. A struct whose block - size cannot be resolved is reported rather than skipped. - * **Read width.** Reads taken against such a slot (``read_u32`` / - ``read_i32`` / ``read_f64`` / constant-length ``read_bytes``) must not - exceed either the block or the pointee, and ``read_cstr`` must not be - applied to a slot that holds a pointer. - - NOT covered: argument *types* at non-pointer positions (nothing on the - Python side records whether a value was meant to be a `uint32_t` or a - `double`; the wasmtime layer passes plain Python ints and floats), return - types beyond what `python-exports` already pins, and both non-Python - bindings -- embind and N-API bind C++ entry points directly rather than - the `fm_*` C ABI, so this header has no arity relationship to them. - """ - header = re.sub(r"/\*.*?\*/", "", _read(CAPI_HEADER), flags=re.S) - declarations = _c_function_params(header) - layouts = _Wasm32Layouts(header) - - spec = importlib.util.spec_from_file_location("formulon_struct_layouts_sig", PYTHON_STRUCTS) - if spec is None or spec.loader is None: - return ["python-call-signatures: could not load packages/python/formulon/_structs.py"] - struct_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(struct_module) - - sources = sorted(PYTHON_PKG_DIR.glob("*.py")) - constants = _module_int_constants(sources) - - problems: List[str] = [] - checked_calls = 0 - for path in sources: - label = path.relative_to(REPO_ROOT) - tree = ast.parse(_read(path), filename=str(path)) - # Analysis is per enclosing function: a scratch local is only - # meaningfully tied to the calls in the scope that allocated it. - scopes = [node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))] - for scope in scopes: - widths = _scratch_slot_widths(scope, constants, struct_module) - readers = _scratch_slot_readers(scope) - for node in ast.walk(scope): - if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): - continue - if not _is_lib_call(node): - continue - fn_name = node.func.attr - if not fn_name.startswith("fm_"): - continue - site = f"{label}:{node.lineno}" - params = declarations.get(fn_name) - if params is None: - problems.append(f"python-call-signatures: {site}: {fn_name} is not declared in the C header") - continue - checked_calls += 1 - if node.keywords: - problems.append( - f"python-call-signatures: {site}: {fn_name} is called with keyword arguments; " - "the wasmtime export takes positional arguments only" - ) - continue - starred = any(isinstance(arg, ast.Starred) for arg in node.args) - if starred: - fixed = len(node.args) - 1 - if fixed > len(params): - problems.append( - f"python-call-signatures: {site}: {fn_name} passes {fixed} arguments before its " - f"splat but the C header declares only {len(params)} parameters" - ) - _SKIPPED.append( - f"python-call-signatures: {site}: {fn_name} splats its trailing arguments, so only " - f"the {fixed}-argument lower bound is checked against the header's {len(params)}" - ) - continue - if len(node.args) != len(params): - problems.append( - f"python-call-signatures: {site}: {fn_name} is called with {len(node.args)} " - f"arguments but the C header declares {len(params)}: {params}" - ) - continue - for position, argument in enumerate(node.args): - if not isinstance(argument, ast.Name) or argument.id not in widths: - continue - problems.extend( - _check_out_param_width( - layouts, - site, - fn_name, - position, - params[position], - widths[argument.id], - readers.get(argument.id, set()), - ) - ) - - if not checked_calls: - problems.append( - "python-call-signatures: no `LIB.fm_*` call sites found in " - f"{PYTHON_PKG_DIR.relative_to(REPO_ROOT)}; the check has stopped looking at anything" - ) - # A call inside a nested `def` is reached both as part of the enclosing - # scope's walk and as its own scope, so the same finding can be produced - # twice. Deduplicate in place rather than restricting the walk: an inner - # closure that uses a scratch slot its parent allocated still has to be - # checked against that slot's width. - return list(dict.fromkeys(problems)) - - -# --------------------------------------------------------------------------- -# Check 1d: C structs the Python binding decodes inline <-> the C header. -# -# `python-struct-layouts` only sees structs that have a `_structs.Struct` -# entry. A struct small enough to decode with a bare `struct.unpack` -- an -# `fm_value_t`, an `fm_print_range_t` -- has no such entry, so its field -# offsets and widths live as literals in the decoding function and nothing -# compares them to the header. -# --------------------------------------------------------------------------- - -# (source file, decoder qualname, C struct). Each decoder is expected to -# consume the whole struct, so its `struct` calls are read as a description -# of the C layout and compared field for field. -_PYTHON_INLINE_DECODERS = ( - ("Value._from_wasm", "fm_value_t"), - ("Workbook.paginate", "fm_print_range_t"), +from typing import List + +from binding_drift.abi_checks import check_abi_baseline, check_header_error_codes +from binding_drift.c_header import c_abi_declarations # noqa: F401 (re-exported for gen_c_abi_baseline.py) +from binding_drift.declaration_checks import ( + check_dts_enums, + check_dts_node, + check_dts_wasm, + check_pure_js_helpers, + check_readme_counts, + check_staged_dist, ) - -# Python-side size literals for a struct with no `_structs.Struct` entry, as -# (module, binding name, C struct). A `Struct`-shaped tuple binding is -# compared on both size and alignment; a bare int on size alone. -_PYTHON_SIZE_CONSTANTS = ( - (PYTHON_C_BINDING, "fm_value_t_size", "fm_value_t"), - (PYTHON_STRUCTS, "VALUE_BLOB", "fm_value_t"), -) - -# C structs that cross the ABI but that no binding marshals, so there is no -# second side to compare against. Each is pinned by a native/wasm32 -# `static_assert` tripwire in tests/c_api instead; listing them here keeps the -# absence deliberate, and `python-call-signatures` fails if a binding starts -# passing one an unmeasurable block. -_UNMODELLED_C_STRUCTS = {"fm_styles_batch"} - -_STRUCT_FORMAT_WIDTHS = {"b": 1, "B": 1, "h": 2, "H": 2, "i": 4, "I": 4, "q": 8, "Q": 8, "f": 4, "d": 8} - - -def _struct_format_widths(fmt: str) -> Optional[List[int]]: - """Per-item byte widths of a little-endian `struct` format string.""" - if not fmt.startswith("<"): - return None - widths: List[int] = [] - for char in fmt[1:]: - width = _STRUCT_FORMAT_WIDTHS.get(char) - if width is None: - return None - widths.append(width) - return widths - - -def _find_qualified_function(tree: ast.AST, qualname: str) -> Optional[ast.AST]: - class_name, _, function_name = qualname.rpartition(".") - for node in ast.walk(tree): - if not isinstance(node, ast.ClassDef) or node.name != class_name: - continue - for member in node.body: - if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)) and member.name == function_name: - return member - return None - - -def check_python_inline_structs() -> List[str]: - """Verify Python's inline `struct.unpack` decoders against the C header. - - Two things are compared, both measured from the C declarations rather - than tabulated here: - - * A size literal the binding carries for a struct with no - `_structs.Struct` entry (`fm_value_t_size`, `VALUE_BLOB`) equals the - struct's wasm32 size, and its alignment where the binding records one. - * Inside each decoder in `_PYTHON_INLINE_DECODERS`, every - `struct.unpack_from(fmt, buf, offset)` lands on a C member offset and - reads no wider than that member, every whole-struct `struct.unpack(fmt, - ...)` describes the C members' widths in order, and every - constant-length `LIB.read_bytes` spans exactly the struct. - - NOT covered: the *meaning* of a field (a decoder that reads the right - width from the right offset into the wrong attribute still passes), and - any struct in `_UNMODELLED_C_STRUCTS`, which no binding marshals and - which therefore has only a `static_assert` tripwire. - """ - header = re.sub(r"/\*.*?\*/", "", _read(CAPI_HEADER), flags=re.S) - layouts = _Wasm32Layouts(header) - constants = _module_int_constants(sorted(PYTHON_PKG_DIR.glob("*.py"))) - problems: List[str] = [] - - def measure(struct_name: str) -> Optional[tuple[dict[str, int], int, int, dict[str, int]]]: - try: - members = layouts.members(struct_name) - except _LayoutError as exc: - problems.append(f"python-inline-structs: {exc}") - return None - offsets, size, align = _wasm32_layout([(m.name, m.size, m.align) for m in members]) - return offsets, size, align, {m.name: m.size for m in members} - - for struct_name in sorted(_UNMODELLED_C_STRUCTS): - if not layouts.is_struct(struct_name): - problems.append( - f"python-inline-structs: {struct_name} is listed as unmodelled by every binding " - f"but no longer exists in {CAPI_HEADER.relative_to(REPO_ROOT)}" - ) - - for module_path, binding_name, struct_name in _PYTHON_SIZE_CONSTANTS: - measured = measure(struct_name) - if measured is None: - continue - _, c_size, c_align, _ = measured - label = module_path.relative_to(REPO_ROOT) - found = False - for statement in ast.parse(_read(module_path), filename=str(module_path)).body: - if not isinstance(statement, ast.Assign) or len(statement.targets) != 1: - continue - target = statement.targets[0] - if not isinstance(target, ast.Name) or target.id != binding_name: - continue - found = True - value = statement.value - if isinstance(value, ast.Constant) and isinstance(value.value, int): - if value.value != c_size: - problems.append( - f"python-inline-structs: {label}: {binding_name} is {value.value}, " - f"but wasm32 sizeof({struct_name}) is {c_size}" - ) - elif isinstance(value, ast.Tuple) and len(value.elts) == 3: - elements = [element.value if isinstance(element, ast.Constant) else None for element in value.elts] - if elements[1] != c_size: - problems.append( - f"python-inline-structs: {label}: {binding_name} declares size {elements[1]}, " - f"but wasm32 sizeof({struct_name}) is {c_size}" - ) - if elements[2] != c_align: - problems.append( - f"python-inline-structs: {label}: {binding_name} declares alignment {elements[2]}, " - f"but wasm32 alignof({struct_name}) is {c_align}" - ) - else: - problems.append( - f"python-inline-structs: {label}: {binding_name} is neither an int literal nor a " - "(kind, size, alignment) literal tuple, so its layout claim cannot be read" - ) - if not found: - problems.append( - f"python-inline-structs: {label} no longer defines {binding_name}, " - f"which is where the binding's {struct_name} size lives" - ) - - tree = ast.parse(_read(PYTHON_WORKBOOK), filename=str(PYTHON_WORKBOOK)) - label = PYTHON_WORKBOOK.relative_to(REPO_ROOT) - for qualname, struct_name in _PYTHON_INLINE_DECODERS: - function = _find_qualified_function(tree, qualname) - if function is None: - problems.append( - f"python-inline-structs: {label} no longer defines {qualname}, " - f"which is where the inline {struct_name} decoding lives" - ) - continue - measured = measure(struct_name) - if measured is None: - continue - c_offsets, c_size, _, c_sizes = measured - member_widths = [c_sizes[name] for name in c_offsets] - - for node in ast.walk(function): - if isinstance(node, ast.Call) and _is_lib_call(node, "read_bytes") and len(node.args) > 1: - length = node.args[1] - span = None - if isinstance(length, ast.Constant) and isinstance(length.value, int): - span = length.value - elif isinstance(length, ast.Name): - span = constants.get(length.id) - if span is not None and span != c_size: - problems.append( - f"python-inline-structs: {label}:{node.lineno}: {qualname} reads {span} bytes " - f"for a {struct_name}, whose wasm32 size is {c_size}" - ) - if not ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "struct" - ): - continue - if not node.args or not isinstance(node.args[0], ast.Constant): - continue - fmt = node.args[0].value - if not isinstance(fmt, str): - continue - widths = _struct_format_widths(fmt) - if widths is None: - problems.append( - f"python-inline-structs: {label}:{node.lineno}: {qualname} uses the format {fmt!r}, " - "which is not a little-endian fixed-width layout this check can measure" - ) - continue - if node.func.attr == "unpack_from": - if len(node.args) < 3 or not isinstance(node.args[2], ast.Constant): - continue - offset = node.args[2].value - owner = next((name for name, at in c_offsets.items() if at == offset), None) - if owner is None: - problems.append( - f"python-inline-structs: {label}:{node.lineno}: {qualname} decodes at offset " - f"{offset}, which is not a member offset of {struct_name} " - f"({sorted(c_offsets.items(), key=lambda item: item[1])})" - ) - elif sum(widths) > c_sizes[owner]: - problems.append( - f"python-inline-structs: {label}:{node.lineno}: {qualname} reads {sum(widths)} bytes " - f"with {fmt!r} at offset {offset}, but {struct_name}.{owner} is {c_sizes[owner]} bytes" - ) - elif node.func.attr == "unpack" and widths != member_widths: - problems.append( - f"python-inline-structs: {label}:{node.lineno}: {qualname} decodes a whole " - f"{struct_name} with {fmt!r} (field widths {widths}), but the C members are " - f"{list(zip(c_offsets, member_widths))}" - ) - return problems - - -# --------------------------------------------------------------------------- -# Check 2a: WASM embind registration <-> src/wasm/formulon.d.ts. -# --------------------------------------------------------------------------- - - -def check_dts_wasm() -> List[str]: - problems: List[str] = [] - cpp_text = _read(WASM_BINDINGS_CPP) - - instance_cpp = set(re.findall(r'\.function\("([^"]+)"', cpp_text)) - static_cpp = set(re.findall(r'\.class_function\("([^"]+)"', cpp_text)) - free_cpp = set(re.findall(r'(? packages/npm-native/index.d.ts. -# --------------------------------------------------------------------------- - - -def check_dts_node() -> List[str]: - problems: List[str] = [] - cc_text = _read(NODE_WORKBOOK_CLASS_CC) - - instance_cc = set(re.findall(r'InstanceMethod<[^>]+>\("([^"]+)"\)', cc_text)) - static_cc = set(re.findall(r'StaticMethod<[^>]+>\("([^"]+)"\)', cc_text)) - - addon_text = _read(NODE_ADDON_CC) - free_cc = set(re.findall(r'exports\.Set\("([^"]+)"', addon_text)) - {"Workbook"} - - dts_text = _read(NODE_DTS) - instance_dts = _extract_ts_methods(_find_interface_body(dts_text, "Workbook", NODE_DTS)) - static_dts = _extract_ts_methods(_find_interface_body(dts_text, "WorkbookCtor", NODE_DTS)) - free_dts = set(re.findall(r"^export function ([A-Za-z0-9_]+)\(", dts_text, re.MULTILINE)) - free_dts -= NODE_PURE_JS_FREE_FUNCTIONS - - wasm_cpp_text = _read(WASM_BINDINGS_CPP) - wasm_instance = set(re.findall(r'\.function\("([^"]+)"', wasm_cpp_text)) - actual_wasm_only = wasm_instance - instance_cc - actual_node_only = instance_cc - wasm_instance - if actual_wasm_only != WASM_ONLY_METHODS: - problems.append( - "dts-node: WASM-only instance-method allowlist mismatch; " - f"actual={sorted(actual_wasm_only)}, allowlist={sorted(WASM_ONLY_METHODS)}" - ) - if actual_node_only != NODE_ONLY_METHODS: - problems.append( - "dts-node: Node-only instance-method allowlist mismatch; " - f"actual={sorted(actual_node_only)}, allowlist={sorted(NODE_ONLY_METHODS)}" - ) - stale_wasm_only = WASM_ONLY_METHODS - actual_wasm_only - stale_node_only = NODE_ONLY_METHODS - actual_node_only - if stale_wasm_only: - problems.append(f"dts-node: stale WASM-only allowlist entries: {sorted(stale_wasm_only)}") - if stale_node_only: - problems.append(f"dts-node: stale Node-only allowlist entries: {sorted(stale_node_only)}") - - for label, cc_set, dts_set in ( - ("Workbook instance methods", instance_cc, instance_dts), - ("Workbook static factories", static_cc, static_dts), - ("free functions", free_cc, free_dts), - ): - diff = _format_diff( - f"{NODE_WORKBOOK_CLASS_CC.relative_to(REPO_ROOT)} / {NODE_ADDON_CC.relative_to(REPO_ROOT)}", - cc_set - dts_set, - f"{NODE_DTS.relative_to(REPO_ROOT)}", - dts_set - cc_set, - ) - if diff: - problems.append(f"dts-node: {label} mismatch:\n" + "\n".join(diff)) - - return problems - - -# --------------------------------------------------------------------------- -# Check 2c: shared `.d.ts` shapes -- return types and record field sets. -# -# `dts-wasm` and `dts-node` compare method *names* only. That is enough to -# notice a method that exists on one surface and not the other, and nothing -# else: a shared method whose two declarations disagree about what comes back, -# or a record type that grew a field on one side, passes both of them. The -# README's parity claim is about the shapes, not the names, so this check -# compares the shapes. -# -# Types declared in only one of the two published declaration files. Each has -# to be reachable only from that surface's own method allowlist -- a type that -# becomes one-sided for any other reason is a missing declaration, and listing -# it here is the deliberate act that says otherwise. -# --------------------------------------------------------------------------- - -_DTS_SURFACE_ONLY_TYPES = { - # The embind module factory and its options: there is no equivalent on a - # native addon, which is `require`d rather than instantiated. - ("wasm", "FormulonModule"), - ("wasm", "FormulonModuleOptions"), - # Reachable only from `getSheetAutoFilterXml` / `createTable` / - # `updateTable`, all of which are in WASM_ONLY_METHODS. - ("wasm", "SheetAutoFilterXmlResult"), - ("wasm", "TableInput"), - ("wasm", "TableUpdateInput"), -} - -# Shared methods whose two declarations are allowed to differ, with the -# reason. Empty by design: a difference that is not worth an entry here is a -# difference that should not exist. -_DTS_RETURN_TYPE_EXEMPT_METHODS: dict[str, str] = {} - -# Top-level members of an exported interface are indented exactly two spaces; -# anything deeper belongs to a nested object type and is compared as part of -# its parent's field text, not on its own. -_TS_MEMBER_NAME_RE = re.compile(r"^ {2}([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", re.MULTILINE) -_TS_MEMBER_FIELD_RE = re.compile(r"^ {2}(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_$]*)(\??)\s*:", re.MULTILINE) - - -def _ts_return_types(body: str) -> dict[str, str]: - """Maps each method in an interface body to its declared return type. - - The parameter list is skipped by bracket balancing so a wrapped or - generic signature is read the same as a single-line one; whitespace in - the return type is collapsed so formatting differences do not register - as drift. - """ - out: dict[str, str] = {} - for match in _TS_MEMBER_NAME_RE.finditer(body): - open_paren = body.index("(", match.start()) - depth = 0 - close_paren = -1 - for i in range(open_paren, len(body)): - char = body[i] - if char in "(<[": - depth += 1 - elif char in ")>]": - depth -= 1 - if depth == 0 and char == ")": - close_paren = i - break - if close_paren < 0: - continue - rest = body[close_paren + 1 :] - if ";" not in rest: - continue - declared = rest[: rest.index(";")].strip() - if declared.startswith(":"): - declared = declared[1:] - out[match.group(1)] = " ".join(declared.split()) - return out - - -def _ts_interface_declaration(text: str, name: str, source: Path) -> tuple[str, str]: - """Returns an interface's `extends` clause (may be empty) and its body. - - Unlike `_find_interface_body` this tolerates `extends`, which matters - here: a record type that inherits its fields declares none of them - locally, so the base list is part of the shape being compared. - """ - match = re.search(r"^export interface %s\b([^{]*)\{" % re.escape(name), text, re.MULTILINE) - if not match: - print(f"check_binding_drift: interface {name!r} not found in {source}", file=sys.stderr) - sys.exit(2) - return " ".join(match.group(1).split()), _extract_braced_block(text, match.end()) - - -def _ts_field_set(body: str) -> Set[str]: - """Field names of an interface body, each suffixed with `?` when optional. - - Optionality is part of the compared shape on purpose: "present only on - success" is how both declaration files spell a payload key the runtime - drops, so a required declaration on one side and an optional one on the - other is a real disagreement about what a caller may dereference. - """ - return {name + optional for name, optional in _TS_MEMBER_FIELD_RE.findall(body)} - - -def check_dts_shared_shapes() -> List[str]: - problems: List[str] = [] - wasm_dts = _read(WASM_DTS) - node_dts = _read(NODE_DTS) - wasm_label = str(WASM_DTS.relative_to(REPO_ROOT)) - node_label = str(NODE_DTS.relative_to(REPO_ROOT)) - - for interface in ("Workbook", "WorkbookCtor"): - wasm_returns = _ts_return_types(_find_interface_body(wasm_dts, interface, WASM_DTS)) - node_returns = _ts_return_types(_find_interface_body(node_dts, interface, NODE_DTS)) - for name in sorted(set(wasm_returns) & set(node_returns)): - if wasm_returns[name] == node_returns[name]: - if name in _DTS_RETURN_TYPE_EXEMPT_METHODS: - problems.append( - f"dts-shared-shapes: {name} is listed as a deliberate return-type " - "difference but both surfaces now declare the same type; drop the entry" - ) - continue - if name in _DTS_RETURN_TYPE_EXEMPT_METHODS: - continue - problems.append( - f"dts-shared-shapes: {interface}.{name} returns a different type on each surface:\n" - f" {wasm_label}: {wasm_returns[name]}\n" - f" {node_label}: {node_returns[name]}" - ) - - wasm_types = set(re.findall(r"^export interface ([A-Za-z0-9_]+)", wasm_dts, re.MULTILINE)) - node_types = set(re.findall(r"^export interface ([A-Za-z0-9_]+)", node_dts, re.MULTILINE)) - surface_only = {("wasm", name) for name in wasm_types - node_types} - surface_only |= {("node", name) for name in node_types - wasm_types} - undeclared = surface_only - _DTS_SURFACE_ONLY_TYPES - if undeclared: - problems.append( - "dts-shared-shapes: type declared on one surface only, without an entry in " - f"_DTS_SURFACE_ONLY_TYPES: {sorted(undeclared)}" - ) - stale = _DTS_SURFACE_ONLY_TYPES - surface_only - if stale: - problems.append(f"dts-shared-shapes: stale _DTS_SURFACE_ONLY_TYPES entries: {sorted(stale)}") - - for name in sorted(wasm_types & node_types): - if name in ("Workbook", "WorkbookCtor"): - continue - wasm_extends, wasm_body = _ts_interface_declaration(wasm_dts, name, WASM_DTS) - node_extends, node_body = _ts_interface_declaration(node_dts, name, NODE_DTS) - if wasm_extends != node_extends: - problems.append( - f"dts-shared-shapes: {name} inherits differently on each surface:\n" - f" {wasm_label}: {wasm_extends or '(nothing)'}\n" - f" {node_label}: {node_extends or '(nothing)'}" - ) - diff = _format_diff( - f"{name} in {wasm_label}", - _ts_field_set(wasm_body) - _ts_field_set(node_body), - f"{name} in {node_label}", - _ts_field_set(node_body) - _ts_field_set(wasm_body), - ) - if diff: - problems.append(f"dts-shared-shapes: {name} field set mismatch:\n" + "\n".join(diff)) - - return problems - - -# --------------------------------------------------------------------------- -# Check 2d: pure-JS helpers shipped by both npm packages. -# -# A helper with no native entry point behind it has to be written out once per -# package, because the two packages share no module. Nothing then holds the -# copies together, and nothing notices when one package documents a helper it -# does not ship -- which is how the WASM declaration file came to send its -# readers to a package that is not published. Both halves are checked here: -# the helper is exported and declared by both packages, and the two -# implementations are the same text. -# --------------------------------------------------------------------------- - - -def _js_exported_function_source(text: str, name: str) -> Optional[str]: - """Whitespace-normalised body of `export function (...) { ... }`.""" - match = re.search(r"^export function %s\s*\(" % re.escape(name), text, re.MULTILINE) - if not match: - return None - body_open = text.index("{", match.end()) - return " ".join(_extract_braced_block(text, body_open + 1).split()) - - -def _npm_entries_reexport_common(check: str) -> List[str]: - """Every npm entry point must re-export common.mjs, or it ships without the shared surface.""" - return [ - f"{check}: {entry.relative_to(REPO_ROOT)} does not `export * from './common.js'`" - for entry in NPM_ENTRY_MJS - if not re.search(r"^export \* from '\./common\.js';", _read(entry), re.MULTILINE) - ] - - -def check_pure_js_helpers() -> List[str]: - problems: List[str] = _npm_entries_reexport_common("pure-js-helpers") - sources = { - "npm": (NPM_COMMON_MJS, WASM_DTS), - "npm-native": (NODE_INDEX_MJS, NODE_DTS), - } - - for name in sorted(NODE_PURE_JS_FREE_FUNCTIONS): - bodies: dict[str, str] = {} - for package, (mjs_path, dts_path) in sources.items(): - body = _js_exported_function_source(_read(mjs_path), name) - if body is None: - problems.append( - f"pure-js-helpers: {mjs_path.relative_to(REPO_ROOT)} does not export a " - f"`{name}` function. A helper only one package ships cannot be named in a " - "declaration file both packages publish." - ) - else: - bodies[package] = body - if not re.search(r"^export function %s\s*\(" % re.escape(name), _read(dts_path), re.MULTILINE): - problems.append( - f"pure-js-helpers: {dts_path.relative_to(REPO_ROOT)} does not declare `export function {name}`" - ) - if len(bodies) == len(sources) and len(set(bodies.values())) != 1: - problems.append( - f"pure-js-helpers: the `{name}` implementations have diverged between " - f"{NPM_COMMON_MJS.relative_to(REPO_ROOT)} and {NODE_INDEX_MJS.relative_to(REPO_ROOT)}. " - "The two packages share no module, so the copies are held together here or not " - "at all." - ) - - return problems - - -# --------------------------------------------------------------------------- -# Check 3: README instance-method count <-> actual registration count. -# --------------------------------------------------------------------------- - -_NUMBER_WORDS = { - "zero": 0, - "one": 1, - "two": 2, - "three": 3, - "four": 4, - "five": 5, - "six": 6, - "seven": 7, - "eight": 8, - "nine": 9, - "ten": 10, -} - -_README_COUNT_RE = re.compile(r"register (\d+) instance methods plus the\s+(\w+) static factories") -_README_SHARED_COUNT_RE = re.compile(r"instance methods, (\d+) are shared with WASM; (\w+) remain WASM-only") - - -def check_readme_counts() -> List[str]: - problems: List[str] = [] - cc_text = _read(NODE_WORKBOOK_CLASS_CC) - actual_instance = len(set(re.findall(r'InstanceMethod<[^>]+>\("([^"]+)"\)', cc_text))) - actual_static = len(set(re.findall(r'StaticMethod<[^>]+>\("([^"]+)"\)', cc_text))) - - readme_text = _read(NODE_README) - match = _README_COUNT_RE.search(readme_text) - if not match: - problems.append( - f"readme-counts: could not find the instance/static method-count sentence in " - f"{NODE_README.relative_to(REPO_ROOT)} (pattern: {_README_COUNT_RE.pattern!r})" - ) - return problems - - quoted_instance = int(match.group(1)) - quoted_static_word = match.group(2).lower() - quoted_static = _NUMBER_WORDS.get(quoted_static_word) - - if quoted_instance != actual_instance: - problems.append( - f"readme-counts: {NODE_README.relative_to(REPO_ROOT)} claims {quoted_instance} instance methods, " - f"actual count in {NODE_WORKBOOK_CLASS_CC.relative_to(REPO_ROOT)} is {actual_instance}" - ) - if quoted_static is None or quoted_static != actual_static: - problems.append( - f"readme-counts: {NODE_README.relative_to(REPO_ROOT)} claims {match.group(2)!r} static factories, " - f"actual count in {NODE_WORKBOOK_CLASS_CC.relative_to(REPO_ROOT)} is {actual_static}" - ) - - wasm_cpp_text = _read(WASM_BINDINGS_CPP) - wasm_instance = set(re.findall(r'\.function\("([^"]+)"', wasm_cpp_text)) - actual_node_methods = set(re.findall(r'InstanceMethod<[^>]+>\("([^"]+)"\)', cc_text)) - actual_shared = len(wasm_instance & actual_node_methods) - actual_wasm_only = len(wasm_instance - actual_node_methods) - shared_match = _README_SHARED_COUNT_RE.search(readme_text) - if not shared_match: - problems.append( - f"readme-counts: could not find the shared/WASM-only method-count sentence in " - f"{NODE_README.relative_to(REPO_ROOT)}" - ) - else: - quoted_shared = int(shared_match.group(1)) - quoted_wasm_only = _NUMBER_WORDS.get(shared_match.group(2).lower()) - if quoted_shared != actual_shared: - problems.append( - f"readme-counts: {NODE_README.relative_to(REPO_ROOT)} claims {quoted_shared} shared methods, " - f"actual shared count is {actual_shared}" - ) - if quoted_wasm_only is None or quoted_wasm_only != actual_wasm_only: - problems.append( - f"readme-counts: {NODE_README.relative_to(REPO_ROOT)} claims {shared_match.group(2)!r} WASM-only methods, " - f"actual count is {actual_wasm_only}" - ) - - return problems - - -# --------------------------------------------------------------------------- -# Check 5: src/wasm/formulon.d.ts `export enum` ordinals <-> their C/C++ -# source enum. embind never registers these as a real `enum_` -# (that would cost WASM size for values only ever crossing the -# boundary as plain numbers); the `.d.ts` copy is therefore the -# sole place the JS-visible ordinals live, and nothing enforces -# that it still matches the source enum after a reorder / insert. -# --------------------------------------------------------------------------- - -_ENUM_BODY_COMMENT_RE = re.compile(r"//[^\n]*|/\*.*?\*/", re.DOTALL) -_ENUM_MEMBER_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\s*(?:=\s*(-?(?:0[xX][0-9a-fA-F]+|\d+)))?\s*$") - - -def _parse_enum_body(body: str) -> Optional[List[int]]: - """Parses a brace-delimited enumerator list into its ordinal values. - - Handles both explicit `Name = N` members and implicit sequential - values (each unset member is one more than the previous value, C/C++/TS - enum semantics). Returns `None` if a member's initializer is not a - literal integer (e.g. it references another constant) -- the caller - should skip comparison in that case rather than mis-flag a drift. - """ - body = _ENUM_BODY_COMMENT_RE.sub("", body) - values: List[int] = [] - next_value = 0 - for part in body.split(","): - part = part.strip() - if not part: - continue - match = _ENUM_MEMBER_RE.match(part) - if not match: - return None - literal = match.group(2) - if literal is not None: - next_value = int(literal, 0) - values.append(next_value) - next_value += 1 - return values - - -def _extract_c_typedef_enum(text: str, type_name: str) -> Optional[List[int]]: - # `[^}]*` (rather than a non-greedy `.*?`) deliberately cannot cross a - # `}` boundary, so a search starting at an *earlier*, unrelated - # `typedef enum { ... }` block cannot skip past its own close brace - # and accidentally splice in a later block that happens to close with - # this `type_name`. - match = re.search(r"typedef enum\s*\{([^}]*)\}\s*" + re.escape(type_name) + r"\s*;", text, re.DOTALL) - if not match: - return None - return _parse_enum_body(match.group(1)) - - -def _cpp_enum_class_body(text: str, enum_name: str) -> Optional[str]: - """The brace-delimited enumerator list of a scoped enum, or `None`.""" - match = re.search( - r"enum class\s+" + re.escape(enum_name) + r"\s*(?::\s*[\w:]+)?\s*\{([^}]*)\}\s*;", text, re.DOTALL - ) - return match.group(1) if match else None - - -def _extract_cpp_enum_class(text: str, enum_name: str) -> Optional[List[int]]: - body = _cpp_enum_class_body(text, enum_name) - if body is None: - return None - return _parse_enum_body(body) - - -def _extract_ts_enum(text: str, enum_name: str) -> Optional[List[int]]: - match = re.search(r"export (?:const )?enum\s+" + re.escape(enum_name) + r"\s*\{([^}]*)\}", text, re.DOTALL) - if not match: - return None - return _parse_enum_body(match.group(1)) - - -# Maps each `.d.ts` enum name to the (source description, extractor) pair -# that finds its mirrored C/C++ enum's ordinal sequence. `fm_*_t` names -# resolve against formulon_c.h; the remaining few are plain C++ `enum -# class` types in their own small headers (comparing against the giant -# formulon_c.h text for those would just add false-positive risk from an -# unrelated same-named enum elsewhere). -_DTS_ENUM_SOURCES = { - "ValueKind": (CAPI_HEADER, "fm_value_kind_t", _extract_c_typedef_enum), - "WorkbookFormat": (CAPI_HEADER, "fm_workbook_format_t", _extract_c_typedef_enum), - "PivotCellKind": (CAPI_HEADER, "fm_pivot_cell_kind_t", _extract_c_typedef_enum), - "PivotAxis": (CAPI_HEADER, "fm_pivot_axis_t", _extract_c_typedef_enum), - "PivotAggregation": (CAPI_HEADER, "fm_pivot_aggregation_t", _extract_c_typedef_enum), - "PivotShowValuesAs": (CAPI_HEADER, "fm_pivot_show_as_t", _extract_c_typedef_enum), - "PivotFilterType": (CAPI_HEADER, "fm_pivot_filter_type_t", _extract_c_typedef_enum), - "PivotDateGrouping": (CAPI_HEADER, "fm_pivot_date_grouping_t", _extract_c_typedef_enum), - "PivotCalendar": (CAPI_HEADER, "fm_pivot_calendar_t", _extract_c_typedef_enum), - "PivotReportLayout": (CAPI_HEADER, "fm_pivot_layout_t", _extract_c_typedef_enum), - "PivotFilterValueKind": (CAPI_HEADER, "fm_pivot_filter_value_kind_t", _extract_c_typedef_enum), - "SheetVisibility": (CAPI_HEADER, "fm_sheet_visibility_t", _extract_c_typedef_enum), - "CfMatchKind": (CF_MATCH_H, "CFMatchKind", _extract_cpp_enum_class), - "CalcMode": (CALC_MODE_H, "CalcMode", _extract_cpp_enum_class), - "ErrorCode": (VALUE_H, "ErrorCode", _extract_cpp_enum_class), - "ExternalLinkKind": (EXTERNAL_LINKS_H, "Kind", _extract_cpp_enum_class), -} - - -def check_dts_enums() -> List[str]: - problems: List[str] = [] - dts_text = _read(WASM_DTS) - - for ts_name, (source_path, source_enum, extractor) in sorted(_DTS_ENUM_SOURCES.items()): - ts_values = _extract_ts_enum(dts_text, ts_name) - if ts_values is None: - problems.append( - f"dts-enums: could not find/parse `export enum {ts_name}` in {WASM_DTS.relative_to(REPO_ROOT)}" - ) - continue - - source_text = _read(source_path) - source_values = extractor(source_text, source_enum) - if source_values is None: - problems.append( - f"dts-enums: could not find/parse `{source_enum}` in {source_path.relative_to(REPO_ROOT)} " - f"(needed to check {WASM_DTS.relative_to(REPO_ROOT)}'s {ts_name})" - ) - continue - - if ts_values != source_values: - problems.append( - f"dts-enums: {ts_name} in {WASM_DTS.relative_to(REPO_ROOT)} has ordinals {ts_values}, " - f"but its source {source_enum} in {source_path.relative_to(REPO_ROOT)} has {source_values}" - ) - - problems.extend(_check_js_constant_tables(dts_text)) - return problems - - -# Frozen ordinal tables are the only way a JS consumer can name a value -# that crosses the boundary as a plain number, so both published ESM entry -# points have to carry the same ones. The WASM `.d.ts` above is already -# pinned to the C/C++ enums, which makes it the single source the two -# `index.mjs` files are measured against -- comparing them only to each -# other would let a shared mistake pass. -_JS_TABLE_RE = re.compile(r"^export const (\w+) = Object\.freeze\(\{(.*?)\}\);", re.DOTALL | re.MULTILINE) -_JS_SCALAR_RE = re.compile(r"^export const (\w+) = (-?\d+);", re.MULTILINE) -_JS_MEMBER_RE = re.compile(r"(\w+)\s*:\s*(-?\d+)") - - -def _parse_js_constants(path: Path) -> dict: - """Reads an `index.mjs`'s exported ordinal tables and scalar constants.""" - text = _read(path) - out: dict = {} - for match in _JS_TABLE_RE.finditer(text): - out[match.group(1)] = {name: int(value) for name, value in _JS_MEMBER_RE.findall(match.group(2))} - for match in _JS_SCALAR_RE.finditer(text): - out[match.group(1)] = int(match.group(2)) - return out - - -def _parse_ts_named_enum(text: str, name: str) -> Optional[dict]: - """Reads a `.d.ts` ordinal table in either idiom used across the two - declaration files: a TS `export enum` or an `export const` whose type - is a `Readonly<{...}>` literal.""" - match = re.search(r"export enum\s+" + re.escape(name) + r"\s*\{([^}]*)\}", text, re.DOTALL) - if match is None: - match = re.search(r"export const\s+" + re.escape(name) + r"\s*:\s*Readonly<\{([^}]*)\}>", text, re.DOTALL) - if match is None: - return None - body = _ENUM_BODY_COMMENT_RE.sub("", match.group(1)) - members: dict = {} - next_value = 0 - for part in body.replace(";", ",").split(","): - part = part.strip() - if not part: - continue - member = re.match(r"([A-Za-z_]\w*)\s*(?:[:=]\s*(-?\d+))?$", part) - if member is None: - return None - if member.group(2) is not None: - next_value = int(member.group(2)) - members[member.group(1)] = next_value - next_value += 1 - return members - - -def _check_js_constant_tables(wasm_dts_text: str) -> List[str]: - problems: List[str] = _npm_entries_reexport_common("dts-enums") - npm = _parse_js_constants(NPM_COMMON_MJS) - native = _parse_js_constants(NODE_INDEX_MJS) - npm_label = NPM_COMMON_MJS.relative_to(REPO_ROOT) - native_label = NODE_INDEX_MJS.relative_to(REPO_ROOT) - - only_npm = sorted(set(npm) - set(native)) - only_native = sorted(set(native) - set(npm)) - if only_npm: - problems.append(f"dts-enums: exported by {npm_label} but not {native_label}: {only_npm}") - if only_native: - problems.append(f"dts-enums: exported by {native_label} but not {npm_label}: {only_native}") - - for name in sorted(set(npm) & set(native)): - if npm[name] != native[name]: - problems.append(f"dts-enums: {name} is {npm[name]} in {npm_label} but {native[name]} in {native_label}") - - # Each runtime table must also match the declaration file its own - # package ships, and the tables (not the bare scalars) must match the - # canonical WASM `.d.ts`. - node_dts_text = _read(NODE_DTS) - for label, constants, dts_text, dts_path in ( - (npm_label, npm, wasm_dts_text, WASM_DTS), - (native_label, native, node_dts_text, NODE_DTS), - ): - for name, value in sorted(constants.items()): - if not isinstance(value, dict): - if not re.search(r"export const\s+" + re.escape(name) + r"\s*=\s*" + str(value) + r"\b", dts_text): - problems.append( - f"dts-enums: {label} exports {name} = {value}, " - f"not declared with that value in {dts_path.relative_to(REPO_ROOT)}" - ) - continue - declared = _parse_ts_named_enum(dts_text, name) - if declared is None: - problems.append( - f"dts-enums: {label} exports the table {name}, " - f"which {dts_path.relative_to(REPO_ROOT)} does not declare as a value" - ) - elif declared != value: - problems.append( - f"dts-enums: {name} is {value} in {label} but {declared} in {dts_path.relative_to(REPO_ROOT)}" - ) - - canonical_only = sorted( - name for name in _DTS_ENUM_SOURCES if name not in npm or not isinstance(npm.get(name), dict) - ) - if canonical_only: - problems.append( - f"dts-enums: {WASM_DTS.relative_to(REPO_ROOT)} declares these enums but {npm_label} " - f"does not export them as runtime tables: {canonical_only}" - ) - return problems - - -# --------------------------------------------------------------------------- -# Check 6: public style-record field sets <-> their C ABI struct. -# -# `FontRecord` and friends are declared independently in three public -# surfaces and each one claims to mirror the C struct. Method-level drift -# checking cannot see a field that one surface forgot, which is how a font's -# `vertAlign` reached the WASM `.d.ts` alone: reading a superscript font and -# writing it back through the other bindings silently demoted it. -# --------------------------------------------------------------------------- - -PYTHON_WORKBOOK = PYTHON_PKG_DIR / "workbook.py" - -# Public type name -> the C struct it projects. -_STYLE_RECORD_STRUCTS = { - "CellXf": "fm_cell_xf", - "ColorSpec": "fm_color_spec", - "FontRecord": "fm_font_record", - "FillRecord": "fm_fill_record", - "BorderSide": "fm_border_side", -} - -# Deliberate omissions, keyed by (surface, type name). Python models a border -# side as a plain dict rather than a dataclass, so it has no `BorderSide` type -# to compare; the dict shape is covered by the wasm32 struct-layout check. -_STYLE_RECORD_EXEMPT_TYPES = { - ("python", "BorderSide"), -} - -# C presence flags that neither host language mirrors as a field, because both -# already have a way to say "absent": the TS surface omits the optional -# property, Python leaves it `None`. Naming the value field alone would let a -# host drop the distinction entirely, so each entry lists the value field the -# flag governs and both are required to exist together. -_STYLE_RECORD_PRESENCE_FLAGS = { - "fm_cell_xf": { - "has_text_rotation": "text_rotation", - "has_indent": "indent", - "has_relative_indent": "relative_indent", - "has_shrink_to_fit": "shrink_to_fit", - "has_reading_order": "reading_order", - }, -} - -_TS_FIELD_RE = re.compile(r"^\s*([A-Za-z_$][A-Za-z0-9_$]*)\??\s*:", re.MULTILINE) -_PY_FIELD_RE = re.compile(r"^ ([a-z_][A-Za-z0-9_]*)\s*:", re.MULTILINE) - - -def _snake_to_camel(name: str) -> str: - head, *rest = name.split("_") - return head + "".join(part.capitalize() for part in rest) - - -def _c_struct_fields(header: str, struct_name: str, source: Path) -> List[str]: - blocks = {name: body for body, name in _C_STRUCT_RE.findall(header)} - body = blocks.get(struct_name) - if body is None: - print(f"check_binding_drift: struct {struct_name!r} not found in {source}", file=sys.stderr) - sys.exit(2) - fields: List[str] = [] - for declaration in body.split(";"): - declaration = " ".join(declaration.split()) - if not declaration: - continue - match = re.fullmatch(r"(.+?)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\[(\d+)\])?", declaration) - if match and not match.group(2).startswith("_pad"): - fields.append(match.group(2)) - return fields - - -def _python_class_fields(text: str, class_name: str) -> Optional[Set[str]]: - match = re.search(r"^class %s:\s*$" % re.escape(class_name), text, re.MULTILINE) - if not match: - return None - rest = text[match.end() :] - end = re.search(r"^(?:@|class |def )", rest, re.MULTILINE) - body = rest[: end.start()] if end else rest - return set(_PY_FIELD_RE.findall(body)) - - -def check_style_record_fields() -> List[str]: - problems: List[str] = [] - header = re.sub(r"/\*.*?\*/", "", _read(CAPI_HEADER), flags=re.S) - wasm_dts = _read(WASM_DTS) - node_dts = _read(NODE_DTS) - python_text = _read(PYTHON_WORKBOOK) - - for type_name, struct_name in sorted(_STYLE_RECORD_STRUCTS.items()): - c_fields = _c_struct_fields(header, struct_name, CAPI_HEADER) - # A presence flag is dropped from the comparison, but only once its - # governed value field is confirmed present on the C side -- otherwise - # renaming the value field would silently retire the flag with it. - presence = _STYLE_RECORD_PRESENCE_FLAGS.get(struct_name, {}) - for flag, value_field in sorted(presence.items()): - if flag in c_fields and value_field not in c_fields: - problems.append( - f"style-record-fields: {struct_name}.{flag} is exempted as a presence flag for " - f"{value_field!r}, which no longer exists in {CAPI_HEADER.relative_to(REPO_ROOT)}" - ) - stale_flags = sorted(flag for flag in presence if flag not in c_fields) - if stale_flags: - problems.append(f"style-record-fields: stale presence-flag exemptions for {struct_name}: {stale_flags}") - c_fields = [name for name in c_fields if name not in presence] - camel_fields = {_snake_to_camel(name) for name in c_fields} - for surface, source, dts_text in ( - ("wasm", WASM_DTS, wasm_dts), - ("node", NODE_DTS, node_dts), - ): - if (surface, type_name) in _STYLE_RECORD_EXEMPT_TYPES: - continue - ts_fields = set(_TS_FIELD_RE.findall(_find_interface_body(dts_text, type_name, source))) - diff = _format_diff( - f"{struct_name} in {CAPI_HEADER.relative_to(REPO_ROOT)}", - camel_fields - ts_fields, - f"{type_name} in {source.relative_to(REPO_ROOT)}", - ts_fields - camel_fields, - ) - if diff: - problems.append(f"style-record-fields: {surface} {type_name} mismatch:\n" + "\n".join(diff)) - - if ("python", type_name) in _STYLE_RECORD_EXEMPT_TYPES: - continue - py_fields = _python_class_fields(python_text, type_name) - if py_fields is None: - problems.append( - f"style-record-fields: dataclass {type_name!r} not found in {PYTHON_WORKBOOK.relative_to(REPO_ROOT)}" - ) - continue - diff = _format_diff( - f"{struct_name} in {CAPI_HEADER.relative_to(REPO_ROOT)}", - set(c_fields) - py_fields, - f"{type_name} in {PYTHON_WORKBOOK.relative_to(REPO_ROOT)}", - py_fields - set(c_fields), - ) - if diff: - problems.append(f"style-record-fields: python {type_name} mismatch:\n" + "\n".join(diff)) - - stale = { - (surface, type_name) - for surface, type_name in _STYLE_RECORD_EXEMPT_TYPES - if type_name not in _STYLE_RECORD_STRUCTS - } - if stale: - problems.append(f"style-record-fields: stale exemption entries: {sorted(stale)}") - return problems - - -# --------------------------------------------------------------------------- -# Check 7: the npm-native package's staged copies <-> their sources. -# -# `stage.mjs` publishes `packages/npm-native/dist/` by copying `index.d.ts` -# and `index.mjs` verbatim out of the package root. Every other check in this -# file reads the source side only, so an edit that lands in the source but is -# never re-staged publishes a declaration file that no longer describes the -# runtime, or an ordinal table that no longer matches the one `dts-enums` -# verified. The WASM package already guards its own staged `.d.ts` -# (packages/npm/scripts/check-dts.mjs); npm-native had no equivalent, which -# matters most for a mechanical rewrite that touches one side of the pair. -# --------------------------------------------------------------------------- - -# (source, staged copy) pairs `stage.mjs` produces with a verbatim copyFile. -_NODE_STAGED_COPIES = ( - (NODE_DTS, NODE_DIST_DIR / "index.d.ts"), - (NODE_INDEX_MJS, NODE_DIST_DIR / "index.mjs"), +from binding_drift.python_checks import ( + check_python_call_signatures, + check_python_exports, + check_python_inline_structs, + check_python_struct_layouts, ) - - -def check_staged_dist() -> List[str]: - problems: List[str] = [] - for source, staged in _NODE_STAGED_COPIES: - if not staged.is_file(): - # `dist/` is gitignored, so a fresh clone has nothing to compare - # until the package is staged. Record that as not-checked instead - # of passing: this check exists because a silent pass is - # indistinguishable from a real one. - _SKIPPED.append( - f"staged-dist: {staged.relative_to(REPO_ROOT)} is absent; the npm-native " - "package has not been staged in this tree (`make node-package`)" - ) - continue - if _read_bytes(source) != _read_bytes(staged): - problems.append( - f"staged-dist: {staged.relative_to(REPO_ROOT)} differs from " - f"{source.relative_to(REPO_ROOT)}; the published copy is stale -- " - "re-stage the package with `make node-package`" - ) - return problems - - -def _parse_abi_manifest(path: Path) -> List[str]: - """Non-comment, non-blank lines of a baseline / ledger file.""" - lines = [] - for raw in _read(path).splitlines(): - line = raw.strip() - if line and not line.startswith("#"): - lines.append(line) - return lines - - -_ABI_BREAK_RE = re.compile(r"^(removed|signature|retyped)\s+(fm_[A-Za-z0-9_]+)\s*::\s*(\S.*)$") -_ABI_BASELINE_RE = re.compile(r"^(.+?)\s+(fm_[A-Za-z0-9_]+)\((.*)\)$") - - -def check_abi_baseline() -> List[str]: - """The released C ABI surface is still declared, or its loss is recorded. - - A deletion or rename of a base entry point is invisible to every other - check here: five of the eight base/`_ex` families have no in-repo caller - at all, so the C API tests recompile against the new header and pass. The - only party that notices is a third-party consumer compiled against the - released header, which no test can stand in for -- hence a pinned copy of - that header's surface plus an explicit ledger of what was broken on - purpose. - - Signature comparison is by declared parameter *type*, so it catches an - added, dropped or retyped parameter. It does NOT catch a by-value struct - that keeps its name and changes size; that is a calling convention change - the `static_assert(sizeof(...))` tripwires in the C ABI tests pin instead. - """ - problems: List[str] = [] - - baseline: dict[str, tuple[str, List[str]]] = {} - for line in _parse_abi_manifest(C_ABI_BASELINE): - match = _ABI_BASELINE_RE.match(line) - if match is None: - problems.append(f"abi-baseline: unparsable baseline line: {line!r}") - continue - ret, name, params = match.groups() - parsed = [] if params.strip() in ("", "void") else [p.strip() for p in params.split(",")] - baseline[name] = (ret.strip(), parsed) - - ledger: dict[str, str] = {} - for line in _parse_abi_manifest(C_ABI_BREAKS): - match = _ABI_BREAK_RE.match(line) - if match is None: - problems.append(f"abi-baseline: unparsable ledger line: {line!r}") - continue - kind, name, _reason = match.groups() - if name in ledger: - problems.append(f"abi-baseline: {name} is listed twice in {C_ABI_BREAKS.name}") - ledger[name] = kind - - current = c_abi_declarations(_read(CAPI_HEADER)) - - for name, declared in sorted(baseline.items()): - recorded = ledger.get(name) - if name not in current: - if recorded != "removed": - problems.append( - f"abi-baseline: {name} shipped in the released header but is gone from " - f"{CAPI_HEADER.relative_to(REPO_ROOT)}. A consumer built against the release " - f"loses it at link time. Record the break as `removed {name} :: ` in " - f"{C_ABI_BREAKS.relative_to(REPO_ROOT)} and carry it into the CHANGELOG's " - "Removed section, or restore the entry point." - ) - continue - if current[name] != declared: - if recorded not in ("signature", "retyped"): - problems.append( - f"abi-baseline: {name} changed signature since the release.\n" - f" released: {declared[0]} {name}({', '.join(declared[1]) or 'void'})\n" - f" current: {current[name][0]} {name}({', '.join(current[name][1]) or 'void'})\n" - f" Record it in {C_ABI_BREAKS.relative_to(REPO_ROOT)} as `signature` (breaks a " - "stale caller) or `retyped` (invisible to a C caller and to the calling " - "convention), with the reason." - ) - continue - # Declared and unchanged: any ledger entry naming it is stale. - if recorded is not None: - problems.append( - f"abi-baseline: {C_ABI_BREAKS.relative_to(REPO_ROOT)} records {name} as " - f"`{recorded}`, but it is declared unchanged from the release. Drop the line -- a " - "ledger that outlives its break stops meaning anything." - ) - - for name in sorted(set(ledger) - set(baseline)): - problems.append( - f"abi-baseline: {C_ABI_BREAKS.relative_to(REPO_ROOT)} records {name}, which was never " - f"in the released surface. Only an entry point a consumer could have compiled against " - "can be broken; drop the line." - ) - - return problems - - -# A doc comment quotes a status code as `` `kName` (N) ``, and the number may -# wrap onto the next comment line, so the separator spans a `*` continuation -# marker as well as plain spaces. -_HEADER_ERROR_CODE_RE = re.compile(r"`?(k[A-Z][A-Za-z0-9_]*)`?(?:[ \t]|\n[ \t]*\*)*\((\d+)\)") - - -def _cpp_enum_class_values(text: str, enum_name: str) -> Optional[dict[str, int]]: - """Parses a scoped enum into a name -> value map. - - The value-only sibling `_extract_cpp_enum_class` compares ordinal - sequences; this one is for the checks that need to look a member up by - name. Implicit members take one more than the previous value, as in C++. - Returns `None` when the enum is absent or a member's initializer is not a - literal integer, so the caller reports the parse failure rather than a - false drift. - """ - raw_body = _cpp_enum_class_body(text, enum_name) - if raw_body is None: - return None - values: dict[str, int] = {} - next_value = 0 - for part in _ENUM_BODY_COMMENT_RE.sub("", raw_body).split(","): - part = part.strip() - if not part: - continue - member = _ENUM_MEMBER_RE.match(part) - if not member: - return None - literal = member.group(2) - if literal is not None: - next_value = int(literal, 0) - values[member.group(1)] = next_value - next_value += 1 - return values - - -def check_header_error_codes() -> List[str]: - """Every numeric status code quoted in the C header matches its enumerator. - - `fm_status_t` is a bare `int32_t`, so the numbers the header's doc - comments print next to an enumerator name are the whole contract a - binding author reading only the header can act on. A comparison written - against a stale number never matches, and mis-classifies whatever code - actually holds that value. - """ - problems: List[str] = [] - - codes = _cpp_enum_class_values(_read(ERROR_H), "FormulonErrorCode") - if codes is None: - return [ - f"header-error-codes: could not parse `FormulonErrorCode` out of {ERROR_H.relative_to(REPO_ROOT)}; " - "the enum moved or gained a non-literal initializer" - ] - - header = _read(CAPI_HEADER) - for match in _HEADER_ERROR_CODE_RE.finditer(header): - name, quoted = match.group(1), match.group(2) - line_number = header.count("\n", 0, match.start()) + 1 - site = f"{CAPI_HEADER.name}:{line_number}" - if name not in codes: - problems.append( - f"header-error-codes: {site} documents `{name}` ({quoted}), which names no enumerator of " - f"`FormulonErrorCode` in {ERROR_H.relative_to(REPO_ROOT)}" - ) - continue - if int(quoted) != codes[name]: - problems.append( - f"header-error-codes: {site} documents `{name}` as ({quoted}), but " - f"{ERROR_H.relative_to(REPO_ROOT)} defines it as {codes[name]}" - ) - - return problems - +from binding_drift.shape_checks import check_dts_shared_shapes, check_style_record_fields +from binding_drift.surface_files import _SKIPPED CHECKS = { "python-exports": check_python_exports, From d06620605cb14e1e6653a446c4bf4eb0b0496aea Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:03:42 +0900 Subject: [PATCH 03/93] refactor(oracle): split cli.py into targets manifest, preflight and contributor modules - move the targets.yaml loader and host label to targets_manifest.py, the per-target host checks behind setup to preflight.py, and the contributor flow to contribute.py - keep cli.py as the list / setup / gen / contribute dispatcher - update the cli.py references in the _locale.py and oracle_gen.py comments - list the new modules in the tools/oracle README layout tree - behavior-preserving move --- tools/oracle/README.md | 3 + tools/oracle/cli.py | 943 +------------------------------ tools/oracle/contribute.py | 486 ++++++++++++++++ tools/oracle/drivers/_locale.py | 2 +- tools/oracle/oracle_gen.py | 6 +- tools/oracle/preflight.py | 408 +++++++++++++ tools/oracle/targets_manifest.py | 85 +++ 7 files changed, 993 insertions(+), 940 deletions(-) create mode 100644 tools/oracle/contribute.py create mode 100644 tools/oracle/preflight.py create mode 100644 tools/oracle/targets_manifest.py diff --git a/tools/oracle/README.md b/tools/oracle/README.md index 2fa974269..696eea659 100644 --- a/tools/oracle/README.md +++ b/tools/oracle/README.md @@ -426,6 +426,9 @@ spill and are capped at `case_schema.MAX_SHAPE_SAMPLES`. tools/oracle/ ├── targets.yaml declarative target manifest ├── cli.py cross-platform dispatcher (list / setup / gen) +├── targets_manifest.py targets.yaml loader + host label (used by cli.py) +├── preflight.py per-target host checks behind `cli.py setup` +├── contribute.py contributor flow behind `cli.py contribute` ├── oracle_gen.py core generator (loads cases, calls driver, writes JSON) ├── case_schema.py YAML loader + validator ├── driver.py backward-compat re-export shim diff --git a/tools/oracle/cli.py b/tools/oracle/cli.py index f907e7364..eb6bd73a3 100644 --- a/tools/oracle/cli.py +++ b/tools/oracle/cli.py @@ -28,107 +28,24 @@ from __future__ import annotations import argparse -import datetime as _dt -import json -import platform -import re -import subprocess import sys from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional # Local imports — accept both `python3 tools/oracle/cli.py` (no package) # and `python3 -m tools.oracle.cli` (package-style). try: # pragma: no cover - trivial fallback from tools.oracle import oracle_gen, workbook_oracle_gen - from tools.oracle.drivers import resolve_win_python - from tools.oracle.drivers._locale import COUNTRY_CODE_TO_BCP47 + from tools.oracle.contribute import _cmd_contribute + from tools.oracle.preflight import _cmd_setup + from tools.oracle.targets_manifest import DEFAULT_TARGETS_FILE, _load_targets, _platform_label, _select_targets except ImportError: # pragma: no cover sys.path.insert(0, str(Path(__file__).resolve().parent)) import oracle_gen # type: ignore import workbook_oracle_gen # type: ignore - from drivers import resolve_win_python # type: ignore - from drivers._locale import COUNTRY_CODE_TO_BCP47 # type: ignore - - -REPO_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_TARGETS_FILE = Path(__file__).resolve().parent / "targets.yaml" - - -def _load_targets(path: Path) -> Dict[str, Any]: - """Reads and validates targets.yaml; returns the parsed mapping.""" - - if not path.exists(): - raise RuntimeError(f"oracle targets file not found: {path}") - try: - import yaml # type: ignore - - doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except Exception as exc: - raise RuntimeError(f"failed to parse {path}: {exc}") from exc - if not isinstance(doc, dict): - raise RuntimeError(f"{path} root must be a mapping") - targets = doc.get("targets") - if not isinstance(targets, dict) or not targets: - raise RuntimeError(f"{path} has no `targets:` mapping") - return doc - - -def _runs_on_current(target: Dict[str, Any]) -> bool: - """Whether the target declares the current OS in `runs_on:`.""" - - runs_on = target.get("runs_on") or [] - if not isinstance(runs_on, list): - return False - return platform.system() in runs_on - - -def _platform_label() -> str: - """Returns ``platform.system()`` with a ``(WSL2)`` suffix on WSL2. - - The CLI's ``list`` command surfaces this so operators can confirm at - a glance which side of the WSL boundary they are on -- ``Linux`` and - ``Linux (WSL2)`` route to different drivers for the same target. - """ - - if platform.system() == "Linux": - try: - if "microsoft" in Path("/proc/version").read_text(encoding="utf-8").lower(): - return "Linux (WSL2)" - except OSError: - pass - return platform.system() - - -def _select_targets( - doc: Dict[str, Any], - *, - name: Optional[str], - all_targets: bool, -) -> List[Tuple[str, Dict[str, Any]]]: - """Returns the (name, record) list to dispatch. - - If `name` is set, just that one (with no `runs_on` filtering — let - oracle_gen surface the platform error directly). If `all_targets`, - every target whose `runs_on:` contains the current OS. Otherwise the - primary target only. - """ - - targets: Dict[str, Any] = doc.get("targets") or {} - if name is not None: - if name not in targets: - avail = ", ".join(sorted(targets.keys())) - raise RuntimeError(f"unknown target: {name!r} (available: {avail})") - return [(name, targets[name])] - if all_targets: - chosen: List[Tuple[str, Dict[str, Any]]] = [(n, t) for n, t in sorted(targets.items()) if _runs_on_current(t)] - if not chosen: - raise RuntimeError(f"no targets in targets.yaml declare runs_on: [{platform.system()}]") - return chosen - primary = doc.get("primary") - if not isinstance(primary, str) or primary not in targets: - raise RuntimeError("targets.yaml is missing a valid `primary:` entry") - return [(primary, targets[primary])] + from contribute import _cmd_contribute # type: ignore + from preflight import _cmd_setup # type: ignore + from targets_manifest import DEFAULT_TARGETS_FILE, _load_targets, _platform_label, _select_targets # type: ignore def _cmd_list(args: argparse.Namespace) -> int: @@ -197,852 +114,6 @@ def _cmd_workbook(args: argparse.Namespace) -> int: return workbook_oracle_gen.main(gen_argv) -_STATUS_PASS = "PASS" -_STATUS_FAIL = "FAIL" -_STATUS_SKIP = "SKIP" - - -def _print_check(target_name: str, status: str, label: str, hint: str = "") -> None: - """Pretty-prints one preflight check line. - - `status` is one of PASS / FAIL / SKIP. `hint` is appended on a wrapped - indented line when present so operators can copy-paste fixes. - """ - - print(f" [{status}] {label}") - if hint: - for line in hint.splitlines(): - print(f" {line}") - - -def _venv_python() -> Path: - """Returns the canonical path to the rye-managed venv interpreter.""" - - return Path(__file__).resolve().parent / ".venv" / "bin" / "python" - - -def _check_xlwings_import(python_exe: Path) -> Tuple[str, str]: - """Returns (status, hint) for `import xlwings` under `python_exe`.""" - - if not python_exe.exists(): - return ( - _STATUS_FAIL, - f"interpreter not found: {python_exe}\nHint: run `make oracle-setup` to create the venv.", - ) - proc = subprocess.run( - [str(python_exe), "-c", "import xlwings"], - capture_output=True, - text=True, - ) - if proc.returncode == 0: - return (_STATUS_PASS, "") - return ( - _STATUS_FAIL, - "xlwings import failed:\n" - + (proc.stderr.strip() or proc.stdout.strip()) - + "\nHint: cd tools/oracle && rye sync", - ) - - -def _check_excel_reachable(python_exe: Path) -> Tuple[str, str]: - """Returns (status, hint) for an Automation reachability probe. - - Uses ``xlwings.apps.count`` which forces lazy attachment to the - running Excel app and surfaces an Automation permission denial - immediately. We do not start a fresh Excel here -- that is far too - intrusive for a preflight. - """ - - proc = subprocess.run( - [str(python_exe), "-c", "import xlwings, sys; sys.exit(0 if hasattr(xlwings, 'apps') else 1)"], - capture_output=True, - text=True, - ) - if proc.returncode == 0: - return ( - _STATUS_PASS, - "", - ) - return ( - _STATUS_FAIL, - "xlwings.apps lookup failed:\n" - + (proc.stderr.strip() or proc.stdout.strip()) - + "\nHint: System Settings -> Privacy & Security -> Automation\n" - " -> (your terminal) -> Microsoft Excel (allow).", - ) - - -def _discover_win_python_candidates() -> List[Path]: - """Returns plausible Windows-side ``python.exe`` paths visible from WSL2. - - Scans the standard per-user (winget / python.org installer) and - machine-wide install locations, skipping the Microsoft Store stub - under ``WindowsApps`` which is a reparse point and not a real - interpreter. Returns existing files only, deduplicated and sorted by - descending Python version (so Python312 wins over Python311 when - both are present). - """ - - roots: List[Path] = [] - roots.extend(Path("/mnt/c/Users").glob("*/AppData/Local/Programs/Python/Python3*/python.exe")) - roots.extend(Path("/mnt/c/Program Files").glob("Python3*/python.exe")) - roots.extend(Path("/mnt/c/Program Files (x86)").glob("Python3*/python.exe")) - seen: List[Path] = [] - for p in roots: - if "WindowsApps" in p.parts: - continue - if p.is_file() and p not in seen: - seen.append(p) - seen.sort(key=lambda p: p.parent.name, reverse=True) - return seen - - -def _check_win_python_path(target: Dict[str, Any]) -> Tuple[str, str, Optional[Path]]: - """Returns (status, hint, resolved_path) for ``target.win_python``. - - The third element is the resolved Path when the field is set and - points to an existing file, otherwise ``None`` -- callers use it to - decide whether dependent checks can run or must SKIP. - """ - - win_python = resolve_win_python(target) - if not win_python: - candidates = _discover_win_python_candidates() - if candidates: - suggestion = ( - "Hint: Windows-side Python is installed but FORMULON_WIN_PYTHON " - "is not exported. Found:\n" - + "\n".join(f" {p}" for p in candidates) - + "\n Export the one you want to use:\n" - f' export FORMULON_WIN_PYTHON="{candidates[0]}"' - ) - else: - suggestion = ( - "Hint: install Python on Windows (winget install Python.Python.3.12),\n" - " then either export FORMULON_WIN_PYTHON pointing at the\n" - " Windows-side python.exe (preferred for OSS contributors so\n" - " no per-machine path lands in targets.yaml) or, for a\n" - " private fork, add a win_python: line under the target.\n" - " Example:\n" - ' export FORMULON_WIN_PYTHON="/mnt/c/Users//AppData/' - 'Local/Programs/Python/Python312/python.exe"' - ) - return ( - _STATUS_FAIL, - "win_python not configured\n" + suggestion, - None, - ) - p = Path(win_python) - if not p.exists(): - return ( - _STATUS_FAIL, - f"win_python path does not exist: {p}\n" - "Hint: confirm the Windows Python install path; from WSL2 the\n" - " Windows C: drive is mounted at /mnt/c.", - None, - ) - return (_STATUS_PASS, "", p) - - -def _check_win_python_imports(win_python: Path) -> Tuple[str, str]: - """Returns (status, hint) for ``import xlwings, win32com.client`` on the - Windows-side interpreter. Only runs when win_python resolved cleanly. - """ - - proc = subprocess.run( - [str(win_python), "-c", "import xlwings, win32com.client"], - capture_output=True, - text=True, - ) - if proc.returncode == 0: - return (_STATUS_PASS, "") - return ( - _STATUS_FAIL, - "Windows-side xlwings/pywin32 import failed:\n" - + (proc.stderr.strip() or proc.stdout.strip()) - + "\nHint: in PowerShell, run:\n" - " py -m pip install xlwings pywin32 pyyaml", - ) - - -def _check_wslpath() -> Tuple[str, str]: - """Returns (status, hint) for the ``wslpath`` translator. - - ``wslpath -w /tmp`` is the smallest invocation that exercises the - binary; a non-empty stdout proves the WSL2 kernel is providing the - translation service. - """ - - try: - proc = subprocess.run(["wslpath", "-w", "/tmp"], capture_output=True, text=True) - except FileNotFoundError: - return ( - _STATUS_FAIL, - "wslpath not on PATH\n" - "Hint: wslpath only exists on WSL2; if you are on plain Linux\n" - " you cannot drive Windows Excel from this host.", - ) - if proc.returncode == 0 and proc.stdout.strip(): - return (_STATUS_PASS, "") - return ( - _STATUS_FAIL, - f"wslpath -w /tmp failed: rc={proc.returncode}\n" + (proc.stderr.strip() or proc.stdout.strip()), - ) - - -def _runs_on_label(target: Dict[str, Any]) -> str: - """Returns the comma-joined ``runs_on`` for printing.""" - - runs_on = target.get("runs_on") or [] - if not isinstance(runs_on, list): - return "?" - return ",".join(str(x) for x in runs_on) or "?" - - -def _check_target(target_name: str, target: Dict[str, Any], host: str) -> bool: - """Runs the preflight checks for one target. Returns True on success.""" - - print(f"[setup] target={target_name} host={host}") - driver_name = target.get("driver") - - # Host vs runs_on sanity. We still let the per-driver checks run on a - # mismatch (downgraded to SKIP) so the operator sees what would be - # required if they were on the right host. - runs_on = target.get("runs_on") or [] - host_compatible = isinstance(runs_on, list) and platform.system() in runs_on - - if driver_name == "macos_excel": - if host != "Darwin": - _print_check( - target_name, - _STATUS_FAIL, - "host compatibility", - f"target requires Darwin, current host is {host}.\nruns_on={_runs_on_label(target)}", - ) - _print_check(target_name, _STATUS_SKIP, "xlwings import (host mismatch)") - _print_check(target_name, _STATUS_SKIP, "Excel automation reachable (host mismatch)") - return False - ok = True - status, hint = _check_xlwings_import(_venv_python()) - _print_check(target_name, status, "xlwings import", hint) - if status != _STATUS_PASS: - ok = False - _print_check(target_name, _STATUS_SKIP, "Excel automation reachable (xlwings missing)") - else: - status2, hint2 = _check_excel_reachable(_venv_python()) - _print_check(target_name, status2, "Excel automation reachable", hint2) - if status2 != _STATUS_PASS: - ok = False - return ok - - if driver_name == "windows_excel": - # Three legal hosts: Windows (direct COM), WSL2 (bridge), or - # anything else (skip with a host-mismatch FAIL). - # `host` here is the label from `_platform_label()` -- on WSL2 it - # carries the "(WSL2)" suffix, so we cannot compare against the - # bare `platform.system()` value. - is_wsl2 = "(WSL2)" in host - if host == "Windows": - ok = True - # On Windows we can only verify import; the actual COM probe - # depends on Office activation state which we don't want to - # touch from a preflight. Leave it to the operator. - status, hint = _check_xlwings_import(_venv_python()) - _print_check(target_name, status, "xlwings import (Windows host)", hint) - if status != _STATUS_PASS: - ok = False - _print_check( - target_name, - _STATUS_SKIP, - "Excel COM probe (skipped on preflight; manual oracle-gen will surface activation issues)", - ) - return ok and host_compatible - - if is_wsl2: - ok = True - status, hint, win_python = _check_win_python_path(target) - _print_check(target_name, status, "win_python configured", hint) - if win_python is None: - ok = False - _print_check( - target_name, - _STATUS_SKIP, - "Windows-side xlwings + win32com import (depends on win_python)", - ) - else: - status2, hint2 = _check_win_python_imports(win_python) - _print_check(target_name, status2, "Windows-side xlwings + win32com import", hint2) - if status2 != _STATUS_PASS: - ok = False - status3, hint3 = _check_wslpath() - _print_check(target_name, status3, "wslpath translation", hint3) - if status3 != _STATUS_PASS: - ok = False - return ok - - _print_check( - target_name, - _STATUS_FAIL, - "host compatibility", - f"target needs Windows or WSL2, current host is {host}.\nruns_on={_runs_on_label(target)}", - ) - _print_check(target_name, _STATUS_SKIP, "xlwings + win32com import (host mismatch)") - _print_check(target_name, _STATUS_SKIP, "wslpath translation (host mismatch)") - return False - - if driver_name == "wsl_bridge": - is_wsl2 = "(WSL2)" in host - if not is_wsl2: - _print_check( - target_name, - _STATUS_FAIL, - "host compatibility", - f"target needs WSL2, current host is {_platform_label()}.", - ) - _print_check(target_name, _STATUS_SKIP, "win_python configured (host mismatch)") - _print_check(target_name, _STATUS_SKIP, "wslpath translation (host mismatch)") - return False - ok = True - status, hint, win_python = _check_win_python_path(target) - _print_check(target_name, status, "win_python configured", hint) - if win_python is None: - ok = False - _print_check( - target_name, - _STATUS_SKIP, - "Windows-side xlwings + win32com import (depends on win_python)", - ) - else: - status2, hint2 = _check_win_python_imports(win_python) - _print_check(target_name, status2, "Windows-side xlwings + win32com import", hint2) - if status2 != _STATUS_PASS: - ok = False - status3, hint3 = _check_wslpath() - _print_check(target_name, status3, "wslpath translation", hint3) - if status3 != _STATUS_PASS: - ok = False - return ok - - _print_check( - target_name, - _STATUS_FAIL, - f"unknown driver: {driver_name!r}", - "Hint: targets.yaml driver must be one of 'macos_excel', 'windows_excel', 'wsl_bridge'.", - ) - return False - - -_CONTRIBUTE_BANNER = """\ -======================================================================== - Formulon oracle contribution flow - - Thank you for taking the time to donate Excel oracle data. - - Why we need this: - Formulon's 1-bit compatibility claim is anchored to ONE primary - oracle (Mac Excel 365, ja-JP). Several function families behave - differently across locales (BAHTTEXT, NUMBERSTRING, decimal-comma - locales, localized TEXT format codes) and across the Mac/Windows - ports. The maintainer team can't reproduce these from a single - install -- Excel's locale is system-wide, not a runtime switch, - and licenses are per-account/per-platform. Goldens captured by - contributors who actually run Excel in that locale are how - Formulon stays honest. - - What this command does: - 1. Confirms your host can drive the chosen target (preflight). - 2. Drives Excel to evaluate every YAML case under - tests/oracle/cases/ and writes golden JSON. - 3. Records the Excel build + locale to ENVIRONMENT.md. - 4. Prints the exact git commands to push your branch and the - PR URL with the oracle template prefilled. - - Tip: you can re-run safely. Goldens are rewritten from scratch. -======================================================================== -""" - - -# Excel `Application.International(xlCountryCode)` (xlCountryCode = 1) maps -# the host's region setting to a phone-style country code. The shared -# `COUNTRY_CODE_TO_BCP47` map under tools/oracle/drivers/_locale.py is the -# single source of truth -- alias it locally so existing call sites and -# any external scripts that import `_COUNTRY_TO_LOCALE` keep working. -_COUNTRY_TO_LOCALE: Dict[int, str] = COUNTRY_CODE_TO_BCP47 - - -def _short_host(host: str, label: Optional[str] = None) -> Optional[str]: - """Maps platform.system() -> the prefix we use in target names. - - `Darwin` -> `mac`, Windows or WSL2 -> `win`, anything else -> None - (which causes auto-detection to bail out and ask for --target). - """ - - if host == "Darwin": - return "mac" - if host == "Windows": - return "win" - if host == "Linux" and (label or _platform_label()).endswith("(WSL2)"): - return "win" - return None - - -def _version_tuple(s: str) -> Tuple[int, ...]: - """Returns a comparable integer tuple from an Excel version string. - - Accepts ``"16.108.1"``, ``"16.84 (Build 24021522)"``, ``"16.84"``; - extracts every leading-digit run separated by ``.`` until the first - non-numeric chunk. Returns ``(0,)`` if no digits were found, so the - caller can compare without crashing on garbage input. - """ - - parts: List[int] = [] - for chunk in re.split(r"[.\s(]+", s.strip()): - m = re.match(r"\d+", chunk) - if not m: - break - parts.append(int(m.group())) - return tuple(parts) or (0,) - - -_PROBE_SCRIPT = """ -import json -import sys - -try: - import xlwings # noqa: F401 -except Exception as exc: # pragma: no cover - print(json.dumps({"error": "xlwings missing: " + str(exc)})) - sys.exit(2) - -app = None -try: - app = xlwings.App(visible=False, add_book=False) - version = "" - try: - version = str(app.version) if app.version else "" - except Exception: - version = "" - cc = None - try: - api = app.api - # xlCountryCode = 1 in Application.International(...) - v = api.international(1) - cc = v() if callable(v) else v - cc = int(cc) - except Exception: - cc = None - if cc is None: - # Mac AppleScript dictionary fallback: `country code` is a - # property on the application object on some Excel builds. - try: - api = app.api - v = getattr(api, "country_code", None) - if v is not None: - cc = int(v() if callable(v) else v) - except Exception: - cc = None - print(json.dumps({"version": version, "country_code": cc})) -finally: - if app is not None: - try: - app.quit() - except Exception: - pass -""" - - -def _probe_excel_brief(host: str) -> Optional[Dict[str, Any]]: - """Briefly opens Excel via xlwings to read version + country code. - - Returns ``{"version": "16.x", "country_code": , "locale": "xx-YY"}`` - or ``None`` if anything in the chain fails (xlwings not installed, - Excel not reachable, country code couldn't be mapped). The caller - treats ``None`` as "fall back to --target". - """ - - venv = _venv_python() - if not venv.exists(): - return None - try: - proc = subprocess.run( - [str(venv), "-c", _PROBE_SCRIPT], - capture_output=True, - text=True, - timeout=120, - ) - except (subprocess.TimeoutExpired, FileNotFoundError): - return None - if proc.returncode != 0 or not proc.stdout.strip(): - return None - try: - data = json.loads(proc.stdout.strip().splitlines()[-1]) - except json.JSONDecodeError: - return None - if "error" in data: - return None - version = data.get("version") or "" - cc = data.get("country_code") - if not version or cc is None: - return None - locale = _COUNTRY_TO_LOCALE.get(int(cc)) - if not locale: - return {"version": version, "country_code": int(cc), "locale": None} - return {"version": version, "country_code": int(cc), "locale": locale} - - -def _committed_excel_version_for(record: Dict[str, Any]) -> Optional[str]: - """Reads the committed ENVIRONMENT.md for `record` and returns the - Excel version string, or None if the file is missing / unparsable. - """ - - env_md_rel = record.get("environment_md") - if not isinstance(env_md_rel, str) or not env_md_rel: - return None - env_md = REPO_ROOT / env_md_rel - if not env_md.exists(): - return None - pat = re.compile(r"-\s*\*\*Excel version\*\*:\s*`([^`]+)`") - for line in env_md.read_text(encoding="utf-8").splitlines(): - m = pat.match(line.strip()) - if m: - return m.group(1).strip() - return None - - -def _print_unknown_target_help(probe: Dict[str, Any], derived_name: str, targets: Dict[str, Any]) -> None: - """Tells the contributor we don't track their locale yet. - - Prints to stderr -- the banner + this message together give a - contributor enough to either choose an existing target or open an - issue to add theirs. - """ - - locale = probe.get("locale") or "?" - cc = probe.get("country_code") - cc_str = f"country_code={cc}" if cc is not None else "country_code=?" - msg_lines: List[str] = [ - "[contribute] detected Excel:", - f" version : {probe.get('version', '?')}", - f" locale : {locale} ({cc_str})", - "", - f"[contribute] derived target name: {derived_name}", - "[contribute] this target isn't tracked in tools/oracle/targets.yaml.", - "", - "Two ways forward:", - " 1) Open an issue so we can reserve the slot, then re-run:", - f" https://github.com/libraz/formulon/issues/new?title=Oracle+target%3A+{derived_name}", - " 2) Or pass --target= if you intend to map your", - " environment onto an already-tracked target.", - "", - "Existing targets compatible with this host:", - ] - host = platform.system() - compatible = sorted( - [(n, t) for n, t in targets.items() if isinstance(t, dict) and host in (t.get("runs_on") or [])] - ) - if not compatible: - msg_lines.append(" (none)") - else: - for n, t in compatible: - msg_lines.append(f" {n} locale={t.get('locale', '?')} status={t.get('status', '?')}") - print("\n".join(msg_lines), file=sys.stderr) - - -def _print_already_current_thanks( - target_name: str, - record: Dict[str, Any], - committed_version: str, - probed_version: str, -) -> None: - """Closing message when no regeneration is needed. - - The repository's golden was generated against an Excel build at or - above the version installed on this contributor's machine, so we - skip oracle-gen entirely and just thank them for showing up. - """ - - print() - print("========================================================================") - print(" Thank you for trying to contribute oracle data!") - print() - print(f" The repository already has goldens for `{target_name}` generated") - print(f" against Excel {committed_version} -- that's at or above your") - print(f" installed Excel {probed_version}, so there's nothing to refresh") - print(" right now. No PR is needed.") - print() - print(" When would a refresh help?") - print(" * Your Excel updates and overtakes the committed version.") - print(" * A new oracle case is added to tests/oracle/cases/ that this") - print(" target hasn't covered yet.") - print(" * A divergence report against this target lands and we ask for") - print(" a re-run.") - print() - print(" Either way -- thanks for showing up. Run again any time:") - print(" make oracle-contribute") - print("========================================================================") - - -def _git_changed_paths(prefixes: List[str]) -> List[str]: - """Returns repo-relative paths under any prefix that git considers - modified, added, or untracked. Best-effort -- silently returns an - empty list if git is unavailable. - """ - - try: - proc = subprocess.run( - ["git", "-C", str(REPO_ROOT), "status", "--porcelain"], - capture_output=True, - text=True, - ) - except FileNotFoundError: - return [] - if proc.returncode != 0: - return [] - paths: List[str] = [] - for line in proc.stdout.splitlines(): - # porcelain format: "XY path" (status code is two chars + space). - if len(line) < 4: - continue - path = line[3:].strip() - # Handle rename "old -> new" -- keep the new path. - if " -> " in path: - path = path.split(" -> ", 1)[1] - if any(path == p or path.startswith(p.rstrip("/") + "/") for p in prefixes): - paths.append(path) - return sorted(set(paths)) - - -def _print_contribute_thanks(target_name: str, record: Dict[str, Any], targets_file: Path) -> None: - """Prints the closing thank-you message and git/PR instructions.""" - - today = _dt.date.today().isoformat() - branch = f"oracle/{target_name}-{today}" - output_dir = str(record.get("output_dir") or "") - env_md = str(record.get("environment_md") or "") - targets_rel = ( - str(targets_file.relative_to(REPO_ROOT)) if targets_file.is_relative_to(REPO_ROOT) else str(targets_file) - ) - - prefixes = [p for p in (output_dir, env_md, targets_rel) if p] - changed = _git_changed_paths(prefixes) - - print() - print("========================================================================") - print(" Done. Your oracle data is on disk -- thank you for the donation!") - print() - print(f" Target: {target_name}") - print(f" Locale: {record.get('locale', '?')}") - print(f" Host: {_platform_label()}") - if env_md: - print(f" Env file: {env_md}") - print() - if changed: - print(" Files git considers new or changed:") - for path in changed[:25]: - print(f" {path}") - if len(changed) > 25: - print(f" ... ({len(changed) - 25} more)") - else: - print(" No git changes detected. (Goldens may already match HEAD --") - print(" re-running on an unchanged tree is a no-op.)") - print() - print(" Next: push your branch and open a PR.") - print() - print(f" git checkout -b {branch}") - if output_dir: - print(f" git add {output_dir}") - if env_md: - print(f" git add {env_md}") - if record.get("status") == "wanted": - print(f" git add {targets_rel} # bump status: wanted -> scaffolded") - print(f" git commit -m 'test(oracle): contribute {target_name} goldens'") - print(f" git push -u origin {branch}") - print() - print(" Then open a PR (oracle template auto-loads):") - print(f" https://github.com/libraz/formulon/compare/main...{branch}?expand=1&template=oracle.md") - print() - print(" Notes for reviewers and you:") - print(" * Confirm ENVIRONMENT.md records the locale you intended.") - print(" * For first-time targets, a second contributor will be asked") - print(" to regenerate independently and diff -- see CONTRIBUTING.md") - print(" for the two-person verify rule.") - print("========================================================================") - - -def _cmd_contribute(args: argparse.Namespace) -> int: - """Contributor onramp: probe Excel, decide if a refresh is needed, - and (if so) run preflight + gen + push instructions. - - Each contributor has exactly one Excel install, so the flow is: - 1. Open Excel briefly and read version + country code (locale). - 2. Map (host_short, '365', locale) -> a target name in targets.yaml. - 3. If the committed ENVIRONMENT.md records a version >= the probed - one, skip generation and just thank them. - 4. Otherwise run preflight + oracle_gen + closing instructions. - - The operator can override step 1 with --target if the auto-derived - name doesn't fit (or the probe fails). - """ - - doc = _load_targets(args.targets_file) - targets: Dict[str, Any] = doc.get("targets") or {} - print(_CONTRIBUTE_BANNER) - - host_sys = platform.system() - host_label = _platform_label() - host_short = _short_host(host_sys, host_label) - - probe = _probe_excel_brief(host_sys) - - # Resolve the target. Explicit --target wins; otherwise we derive - # from the probe. - name: Optional[str] = None - record: Optional[Dict[str, Any]] = None - - if args.target is not None: - if args.target not in targets: - avail = ", ".join(sorted(targets.keys())) - print( - f"unknown target: {args.target!r} (available: {avail})", - file=sys.stderr, - ) - return 2 - name, record = args.target, targets[args.target] - if probe is not None and probe.get("version"): - print(f"[contribute] detected Excel {probe['version']} (locale={probe.get('locale') or '?'})") - print(f"[contribute] target: {name} (explicit)") - else: - if probe is None: - print( - "[contribute] could not auto-detect your Excel environment.", - file=sys.stderr, - ) - print( - " The probe needs xlwings + a working Excel install. First-time setup:", - file=sys.stderr, - ) - print(" make oracle-setup", file=sys.stderr) - print( - " Already set up? Re-run with TARGET=; run `make oracle-contribute-list` to see the options.", - file=sys.stderr, - ) - return 2 - if not host_short or not probe.get("locale"): - cc = probe.get("country_code") - print( - f"[contribute] detected Excel {probe['version']} (country_code={cc}); locale could not be mapped.", - file=sys.stderr, - ) - print( - " Re-run with TARGET= to pick a target manually," - " or open an issue with the country_code above so we can" - " add the mapping.", - file=sys.stderr, - ) - return 2 - derived = f"{host_short}-365-{probe['locale'].replace('-', '_')}" - if derived not in targets: - _print_unknown_target_help(probe, derived, targets) - return 2 - name, record = derived, targets[derived] - print(f"[contribute] detected Excel {probe['version']} (locale={probe['locale']})") - print(f"[contribute] target: {name} (auto-detected)") - - assert name is not None and record is not None # for type-checkers - - # If the committed golden was generated against a version at or above - # the contributor's Excel, there is nothing for them to refresh. Say - # thanks and exit -- no PR needed. - if probe is not None and probe.get("version"): - committed = _committed_excel_version_for(record) - if committed and _version_tuple(committed) >= _version_tuple(probe["version"]): - _print_already_current_thanks(name, record, committed, probe["version"]) - return 0 - - print() - print(f"[contribute] preflight: target={name}") - if not _check_target(name, record, host_label): - print() - print( - "[contribute] preflight failed. Address the FAIL lines above (the hints are copy-paste ready), then rerun:", - file=sys.stderr, - ) - print(" make oracle-contribute", file=sys.stderr) - return 2 - - print() - print(f"[contribute] generating goldens for target={name}") - rc = oracle_gen.main(["--target", name, "--targets-file", str(args.targets_file)]) - if rc != 0: - print() - print( - f"[contribute] oracle_gen exited with code {rc}. See the log above; common fixes:", - file=sys.stderr, - ) - print(" * Quit Excel and rerun (the driver opens a fresh app).", file=sys.stderr) - print(" * Confirm Excel is signed in and Office is activated.", file=sys.stderr) - print( - " * On macOS, re-grant Automation permission if it was reset by an OS update.", - file=sys.stderr, - ) - return rc - - _print_contribute_thanks(name, record, args.targets_file) - return 0 - - -def _cmd_setup(args: argparse.Namespace) -> int: - """Verifies the host can drive its target oracle. - - With ``--target NAME`` checks just that one. Without, iterates every - target whose ``runs_on:`` includes the current platform (so a Mac - developer never sees noise about Windows-only targets, but a WSL2 - developer correctly sees the windows_excel target). - """ - - doc = _load_targets(args.targets_file) - targets: Dict[str, Any] = doc.get("targets") or {} - host_label = _platform_label() - host = platform.system() - - if args.target is not None: - if args.target not in targets: - avail = ", ".join(sorted(targets.keys())) - raise RuntimeError(f"unknown target: {args.target!r} (available: {avail})") - chosen = [(args.target, targets[args.target])] - else: - chosen = [ - (n, t) - for n, t in sorted(targets.items()) - if isinstance(t, dict) and platform.system() in (t.get("runs_on") or []) - ] - if not chosen: - print( - f"setup: no targets in targets.yaml declare runs_on: [{host}]", - file=sys.stderr, - ) - return 0 - - ready = 0 - failed = 0 - for name, record in chosen: - if not isinstance(record, dict): - print(f"[setup] target={name}: malformed (not a mapping)", file=sys.stderr) - failed += 1 - continue - if _check_target(name, record, host_label): - ready += 1 - else: - failed += 1 - - print() - if failed == 0: - print(f"setup: {ready} target ready.") - return 0 - print(f"setup: {ready} target ready, {failed} needs configuration.") - return 1 - - def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser( prog="oracle-cli", diff --git a/tools/oracle/contribute.py b/tools/oracle/contribute.py new file mode 100644 index 000000000..1e82e9171 --- /dev/null +++ b/tools/oracle/contribute.py @@ -0,0 +1,486 @@ +"""Contributor onramp: probe the local Excel, compare it with the committed goldens and print the donation steps.""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import platform +import re +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# Local imports -- accept both `python3 tools/oracle/cli.py` (no package) +# and `python3 -m tools.oracle.cli` (package-style). +try: # pragma: no cover - trivial fallback + from tools.oracle import oracle_gen + from tools.oracle.drivers._locale import COUNTRY_CODE_TO_BCP47 + from tools.oracle.preflight import _check_target, _venv_python + from tools.oracle.targets_manifest import _load_targets, _platform_label +except ImportError: # pragma: no cover + import oracle_gen # type: ignore + from drivers._locale import COUNTRY_CODE_TO_BCP47 # type: ignore + from preflight import _check_target, _venv_python # type: ignore + from targets_manifest import _load_targets, _platform_label # type: ignore + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +_CONTRIBUTE_BANNER = """\ +======================================================================== + Formulon oracle contribution flow + + Thank you for taking the time to donate Excel oracle data. + + Why we need this: + Formulon's 1-bit compatibility claim is anchored to ONE primary + oracle (Mac Excel 365, ja-JP). Several function families behave + differently across locales (BAHTTEXT, NUMBERSTRING, decimal-comma + locales, localized TEXT format codes) and across the Mac/Windows + ports. The maintainer team can't reproduce these from a single + install -- Excel's locale is system-wide, not a runtime switch, + and licenses are per-account/per-platform. Goldens captured by + contributors who actually run Excel in that locale are how + Formulon stays honest. + + What this command does: + 1. Confirms your host can drive the chosen target (preflight). + 2. Drives Excel to evaluate every YAML case under + tests/oracle/cases/ and writes golden JSON. + 3. Records the Excel build + locale to ENVIRONMENT.md. + 4. Prints the exact git commands to push your branch and the + PR URL with the oracle template prefilled. + + Tip: you can re-run safely. Goldens are rewritten from scratch. +======================================================================== +""" + +# Excel `Application.International(xlCountryCode)` (xlCountryCode = 1) maps +# the host's region setting to a phone-style country code. The shared +# `COUNTRY_CODE_TO_BCP47` map under tools/oracle/drivers/_locale.py is the +# single source of truth -- alias it locally so existing call sites and +# any external scripts that import `_COUNTRY_TO_LOCALE` keep working. +_COUNTRY_TO_LOCALE: Dict[int, str] = COUNTRY_CODE_TO_BCP47 + + +def _short_host(host: str, label: Optional[str] = None) -> Optional[str]: + """Maps platform.system() -> the prefix we use in target names. + + `Darwin` -> `mac`, Windows or WSL2 -> `win`, anything else -> None + (which causes auto-detection to bail out and ask for --target). + """ + + if host == "Darwin": + return "mac" + if host == "Windows": + return "win" + if host == "Linux" and (label or _platform_label()).endswith("(WSL2)"): + return "win" + return None + + +def _version_tuple(s: str) -> Tuple[int, ...]: + """Returns a comparable integer tuple from an Excel version string. + + Accepts ``"16.108.1"``, ``"16.84 (Build 24021522)"``, ``"16.84"``; + extracts every leading-digit run separated by ``.`` until the first + non-numeric chunk. Returns ``(0,)`` if no digits were found, so the + caller can compare without crashing on garbage input. + """ + + parts: List[int] = [] + for chunk in re.split(r"[.\s(]+", s.strip()): + m = re.match(r"\d+", chunk) + if not m: + break + parts.append(int(m.group())) + return tuple(parts) or (0,) + + +_PROBE_SCRIPT = """ +import json +import sys + +try: + import xlwings # noqa: F401 +except Exception as exc: # pragma: no cover + print(json.dumps({"error": "xlwings missing: " + str(exc)})) + sys.exit(2) + +app = None +try: + app = xlwings.App(visible=False, add_book=False) + version = "" + try: + version = str(app.version) if app.version else "" + except Exception: + version = "" + cc = None + try: + api = app.api + # xlCountryCode = 1 in Application.International(...) + v = api.international(1) + cc = v() if callable(v) else v + cc = int(cc) + except Exception: + cc = None + if cc is None: + # Mac AppleScript dictionary fallback: `country code` is a + # property on the application object on some Excel builds. + try: + api = app.api + v = getattr(api, "country_code", None) + if v is not None: + cc = int(v() if callable(v) else v) + except Exception: + cc = None + print(json.dumps({"version": version, "country_code": cc})) +finally: + if app is not None: + try: + app.quit() + except Exception: + pass +""" + + +def _probe_excel_brief(host: str) -> Optional[Dict[str, Any]]: + """Briefly opens Excel via xlwings to read version + country code. + + Returns ``{"version": "16.x", "country_code": , "locale": "xx-YY"}`` + or ``None`` if anything in the chain fails (xlwings not installed, + Excel not reachable, country code couldn't be mapped). The caller + treats ``None`` as "fall back to --target". + """ + + venv = _venv_python() + if not venv.exists(): + return None + try: + proc = subprocess.run( + [str(venv), "-c", _PROBE_SCRIPT], + capture_output=True, + text=True, + timeout=120, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + return None + if proc.returncode != 0 or not proc.stdout.strip(): + return None + try: + data = json.loads(proc.stdout.strip().splitlines()[-1]) + except json.JSONDecodeError: + return None + if "error" in data: + return None + version = data.get("version") or "" + cc = data.get("country_code") + if not version or cc is None: + return None + locale = _COUNTRY_TO_LOCALE.get(int(cc)) + if not locale: + return {"version": version, "country_code": int(cc), "locale": None} + return {"version": version, "country_code": int(cc), "locale": locale} + + +def _committed_excel_version_for(record: Dict[str, Any]) -> Optional[str]: + """Reads the committed ENVIRONMENT.md for `record` and returns the + Excel version string, or None if the file is missing / unparsable. + """ + + env_md_rel = record.get("environment_md") + if not isinstance(env_md_rel, str) or not env_md_rel: + return None + env_md = REPO_ROOT / env_md_rel + if not env_md.exists(): + return None + pat = re.compile(r"-\s*\*\*Excel version\*\*:\s*`([^`]+)`") + for line in env_md.read_text(encoding="utf-8").splitlines(): + m = pat.match(line.strip()) + if m: + return m.group(1).strip() + return None + + +def _print_unknown_target_help(probe: Dict[str, Any], derived_name: str, targets: Dict[str, Any]) -> None: + """Tells the contributor we don't track their locale yet. + + Prints to stderr -- the banner + this message together give a + contributor enough to either choose an existing target or open an + issue to add theirs. + """ + + locale = probe.get("locale") or "?" + cc = probe.get("country_code") + cc_str = f"country_code={cc}" if cc is not None else "country_code=?" + msg_lines: List[str] = [ + "[contribute] detected Excel:", + f" version : {probe.get('version', '?')}", + f" locale : {locale} ({cc_str})", + "", + f"[contribute] derived target name: {derived_name}", + "[contribute] this target isn't tracked in tools/oracle/targets.yaml.", + "", + "Two ways forward:", + " 1) Open an issue so we can reserve the slot, then re-run:", + f" https://github.com/libraz/formulon/issues/new?title=Oracle+target%3A+{derived_name}", + " 2) Or pass --target= if you intend to map your", + " environment onto an already-tracked target.", + "", + "Existing targets compatible with this host:", + ] + host = platform.system() + compatible = sorted( + [(n, t) for n, t in targets.items() if isinstance(t, dict) and host in (t.get("runs_on") or [])] + ) + if not compatible: + msg_lines.append(" (none)") + else: + for n, t in compatible: + msg_lines.append(f" {n} locale={t.get('locale', '?')} status={t.get('status', '?')}") + print("\n".join(msg_lines), file=sys.stderr) + + +def _print_already_current_thanks( + target_name: str, + record: Dict[str, Any], + committed_version: str, + probed_version: str, +) -> None: + """Closing message when no regeneration is needed. + + The repository's golden was generated against an Excel build at or + above the version installed on this contributor's machine, so we + skip oracle-gen entirely and just thank them for showing up. + """ + + print() + print("========================================================================") + print(" Thank you for trying to contribute oracle data!") + print() + print(f" The repository already has goldens for `{target_name}` generated") + print(f" against Excel {committed_version} -- that's at or above your") + print(f" installed Excel {probed_version}, so there's nothing to refresh") + print(" right now. No PR is needed.") + print() + print(" When would a refresh help?") + print(" * Your Excel updates and overtakes the committed version.") + print(" * A new oracle case is added to tests/oracle/cases/ that this") + print(" target hasn't covered yet.") + print(" * A divergence report against this target lands and we ask for") + print(" a re-run.") + print() + print(" Either way -- thanks for showing up. Run again any time:") + print(" make oracle-contribute") + print("========================================================================") + + +def _git_changed_paths(prefixes: List[str]) -> List[str]: + """Returns repo-relative paths under any prefix that git considers + modified, added, or untracked. Best-effort -- silently returns an + empty list if git is unavailable. + """ + + try: + proc = subprocess.run( + ["git", "-C", str(REPO_ROOT), "status", "--porcelain"], + capture_output=True, + text=True, + ) + except FileNotFoundError: + return [] + if proc.returncode != 0: + return [] + paths: List[str] = [] + for line in proc.stdout.splitlines(): + # porcelain format: "XY path" (status code is two chars + space). + if len(line) < 4: + continue + path = line[3:].strip() + # Handle rename "old -> new" -- keep the new path. + if " -> " in path: + path = path.split(" -> ", 1)[1] + if any(path == p or path.startswith(p.rstrip("/") + "/") for p in prefixes): + paths.append(path) + return sorted(set(paths)) + + +def _print_contribute_thanks(target_name: str, record: Dict[str, Any], targets_file: Path) -> None: + """Prints the closing thank-you message and git/PR instructions.""" + + today = _dt.date.today().isoformat() + branch = f"oracle/{target_name}-{today}" + output_dir = str(record.get("output_dir") or "") + env_md = str(record.get("environment_md") or "") + targets_rel = ( + str(targets_file.relative_to(REPO_ROOT)) if targets_file.is_relative_to(REPO_ROOT) else str(targets_file) + ) + + prefixes = [p for p in (output_dir, env_md, targets_rel) if p] + changed = _git_changed_paths(prefixes) + + print() + print("========================================================================") + print(" Done. Your oracle data is on disk -- thank you for the donation!") + print() + print(f" Target: {target_name}") + print(f" Locale: {record.get('locale', '?')}") + print(f" Host: {_platform_label()}") + if env_md: + print(f" Env file: {env_md}") + print() + if changed: + print(" Files git considers new or changed:") + for path in changed[:25]: + print(f" {path}") + if len(changed) > 25: + print(f" ... ({len(changed) - 25} more)") + else: + print(" No git changes detected. (Goldens may already match HEAD --") + print(" re-running on an unchanged tree is a no-op.)") + print() + print(" Next: push your branch and open a PR.") + print() + print(f" git checkout -b {branch}") + if output_dir: + print(f" git add {output_dir}") + if env_md: + print(f" git add {env_md}") + if record.get("status") == "wanted": + print(f" git add {targets_rel} # bump status: wanted -> scaffolded") + print(f" git commit -m 'test(oracle): contribute {target_name} goldens'") + print(f" git push -u origin {branch}") + print() + print(" Then open a PR (oracle template auto-loads):") + print(f" https://github.com/libraz/formulon/compare/main...{branch}?expand=1&template=oracle.md") + print() + print(" Notes for reviewers and you:") + print(" * Confirm ENVIRONMENT.md records the locale you intended.") + print(" * For first-time targets, a second contributor will be asked") + print(" to regenerate independently and diff -- see CONTRIBUTING.md") + print(" for the two-person verify rule.") + print("========================================================================") + + +def _cmd_contribute(args: argparse.Namespace) -> int: + """Contributor onramp: probe Excel, decide if a refresh is needed, + and (if so) run preflight + gen + push instructions. + + Each contributor has exactly one Excel install, so the flow is: + 1. Open Excel briefly and read version + country code (locale). + 2. Map (host_short, '365', locale) -> a target name in targets.yaml. + 3. If the committed ENVIRONMENT.md records a version >= the probed + one, skip generation and just thank them. + 4. Otherwise run preflight + oracle_gen + closing instructions. + + The operator can override step 1 with --target if the auto-derived + name doesn't fit (or the probe fails). + """ + + doc = _load_targets(args.targets_file) + targets: Dict[str, Any] = doc.get("targets") or {} + print(_CONTRIBUTE_BANNER) + + host_sys = platform.system() + host_label = _platform_label() + host_short = _short_host(host_sys, host_label) + + probe = _probe_excel_brief(host_sys) + + # Resolve the target. Explicit --target wins; otherwise we derive + # from the probe. + name: Optional[str] = None + record: Optional[Dict[str, Any]] = None + + if args.target is not None: + if args.target not in targets: + avail = ", ".join(sorted(targets.keys())) + print( + f"unknown target: {args.target!r} (available: {avail})", + file=sys.stderr, + ) + return 2 + name, record = args.target, targets[args.target] + if probe is not None and probe.get("version"): + print(f"[contribute] detected Excel {probe['version']} (locale={probe.get('locale') or '?'})") + print(f"[contribute] target: {name} (explicit)") + else: + if probe is None: + print( + "[contribute] could not auto-detect your Excel environment.", + file=sys.stderr, + ) + print( + " The probe needs xlwings + a working Excel install. First-time setup:", + file=sys.stderr, + ) + print(" make oracle-setup", file=sys.stderr) + print( + " Already set up? Re-run with TARGET=; run `make oracle-contribute-list` to see the options.", + file=sys.stderr, + ) + return 2 + if not host_short or not probe.get("locale"): + cc = probe.get("country_code") + print( + f"[contribute] detected Excel {probe['version']} (country_code={cc}); locale could not be mapped.", + file=sys.stderr, + ) + print( + " Re-run with TARGET= to pick a target manually," + " or open an issue with the country_code above so we can" + " add the mapping.", + file=sys.stderr, + ) + return 2 + derived = f"{host_short}-365-{probe['locale'].replace('-', '_')}" + if derived not in targets: + _print_unknown_target_help(probe, derived, targets) + return 2 + name, record = derived, targets[derived] + print(f"[contribute] detected Excel {probe['version']} (locale={probe['locale']})") + print(f"[contribute] target: {name} (auto-detected)") + + assert name is not None and record is not None # for type-checkers + + # If the committed golden was generated against a version at or above + # the contributor's Excel, there is nothing for them to refresh. Say + # thanks and exit -- no PR needed. + if probe is not None and probe.get("version"): + committed = _committed_excel_version_for(record) + if committed and _version_tuple(committed) >= _version_tuple(probe["version"]): + _print_already_current_thanks(name, record, committed, probe["version"]) + return 0 + + print() + print(f"[contribute] preflight: target={name}") + if not _check_target(name, record, host_label): + print() + print( + "[contribute] preflight failed. Address the FAIL lines above (the hints are copy-paste ready), then rerun:", + file=sys.stderr, + ) + print(" make oracle-contribute", file=sys.stderr) + return 2 + + print() + print(f"[contribute] generating goldens for target={name}") + rc = oracle_gen.main(["--target", name, "--targets-file", str(args.targets_file)]) + if rc != 0: + print() + print( + f"[contribute] oracle_gen exited with code {rc}. See the log above; common fixes:", + file=sys.stderr, + ) + print(" * Quit Excel and rerun (the driver opens a fresh app).", file=sys.stderr) + print(" * Confirm Excel is signed in and Office is activated.", file=sys.stderr) + print( + " * On macOS, re-grant Automation permission if it was reset by an OS update.", + file=sys.stderr, + ) + return rc + + _print_contribute_thanks(name, record, args.targets_file) + return 0 diff --git a/tools/oracle/drivers/_locale.py b/tools/oracle/drivers/_locale.py index 1e66c644b..d565f965a 100644 --- a/tools/oracle/drivers/_locale.py +++ b/tools/oracle/drivers/_locale.py @@ -11,7 +11,7 @@ - :data:`COUNTRY_CODE_TO_BCP47` — the int-keyed map from ``xlCountryCode`` to BCP-47 locale strings. Used both by the driver - (for environment probes) and by ``cli.py`` (for the contributor probe + (for environment probes) and by ``contribute.py`` (for the contributor probe that derives a target name). - :data:`LOCALIZED_ERROR_TO_CANONICAL` — the localized form of every Excel error token mapped back to its canonical English form. The diff --git a/tools/oracle/oracle_gen.py b/tools/oracle/oracle_gen.py index e29393d49..9977c2a24 100644 --- a/tools/oracle/oracle_gen.py +++ b/tools/oracle/oracle_gen.py @@ -495,9 +495,9 @@ def _apply_divergence_metadata( def _version_tuple(s: str) -> Tuple[int, ...]: """Returns a comparable integer tuple from an Excel version string. - Mirrors ``cli.py``'s ``_version_tuple`` (duplicated rather than - imported: ``cli.py`` imports this module, so the reverse import - would be circular). Returns ``(0,)`` for strings with no leading + Mirrors ``contribute.py``'s ``_version_tuple`` (duplicated rather + than imported: the contributor flow sits above this module, so the + reverse import would be circular). Returns ``(0,)`` for strings with no leading digit run, e.g. a hand-seeded placeholder version. """ diff --git a/tools/oracle/preflight.py b/tools/oracle/preflight.py new file mode 100644 index 000000000..4c604ae1e --- /dev/null +++ b/tools/oracle/preflight.py @@ -0,0 +1,408 @@ +"""Preflight checks that verify the host can drive each target oracle.""" + +from __future__ import annotations + +import argparse +import platform +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# Local imports -- accept both `python3 tools/oracle/cli.py` (no package) +# and `python3 -m tools.oracle.cli` (package-style). +try: # pragma: no cover - trivial fallback + from tools.oracle.drivers import resolve_win_python + from tools.oracle.targets_manifest import _load_targets, _platform_label +except ImportError: # pragma: no cover + from drivers import resolve_win_python # type: ignore + from targets_manifest import _load_targets, _platform_label # type: ignore + + +_STATUS_PASS = "PASS" + +_STATUS_FAIL = "FAIL" + +_STATUS_SKIP = "SKIP" + + +def _print_check(target_name: str, status: str, label: str, hint: str = "") -> None: + """Pretty-prints one preflight check line. + + `status` is one of PASS / FAIL / SKIP. `hint` is appended on a wrapped + indented line when present so operators can copy-paste fixes. + """ + + print(f" [{status}] {label}") + if hint: + for line in hint.splitlines(): + print(f" {line}") + + +def _venv_python() -> Path: + """Returns the canonical path to the rye-managed venv interpreter.""" + + return Path(__file__).resolve().parent / ".venv" / "bin" / "python" + + +def _check_xlwings_import(python_exe: Path) -> Tuple[str, str]: + """Returns (status, hint) for `import xlwings` under `python_exe`.""" + + if not python_exe.exists(): + return ( + _STATUS_FAIL, + f"interpreter not found: {python_exe}\nHint: run `make oracle-setup` to create the venv.", + ) + proc = subprocess.run( + [str(python_exe), "-c", "import xlwings"], + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return (_STATUS_PASS, "") + return ( + _STATUS_FAIL, + "xlwings import failed:\n" + + (proc.stderr.strip() or proc.stdout.strip()) + + "\nHint: cd tools/oracle && rye sync", + ) + + +def _check_excel_reachable(python_exe: Path) -> Tuple[str, str]: + """Returns (status, hint) for an Automation reachability probe. + + Uses ``xlwings.apps.count`` which forces lazy attachment to the + running Excel app and surfaces an Automation permission denial + immediately. We do not start a fresh Excel here -- that is far too + intrusive for a preflight. + """ + + proc = subprocess.run( + [str(python_exe), "-c", "import xlwings, sys; sys.exit(0 if hasattr(xlwings, 'apps') else 1)"], + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return ( + _STATUS_PASS, + "", + ) + return ( + _STATUS_FAIL, + "xlwings.apps lookup failed:\n" + + (proc.stderr.strip() or proc.stdout.strip()) + + "\nHint: System Settings -> Privacy & Security -> Automation\n" + " -> (your terminal) -> Microsoft Excel (allow).", + ) + + +def _discover_win_python_candidates() -> List[Path]: + """Returns plausible Windows-side ``python.exe`` paths visible from WSL2. + + Scans the standard per-user (winget / python.org installer) and + machine-wide install locations, skipping the Microsoft Store stub + under ``WindowsApps`` which is a reparse point and not a real + interpreter. Returns existing files only, deduplicated and sorted by + descending Python version (so Python312 wins over Python311 when + both are present). + """ + + roots: List[Path] = [] + roots.extend(Path("/mnt/c/Users").glob("*/AppData/Local/Programs/Python/Python3*/python.exe")) + roots.extend(Path("/mnt/c/Program Files").glob("Python3*/python.exe")) + roots.extend(Path("/mnt/c/Program Files (x86)").glob("Python3*/python.exe")) + seen: List[Path] = [] + for p in roots: + if "WindowsApps" in p.parts: + continue + if p.is_file() and p not in seen: + seen.append(p) + seen.sort(key=lambda p: p.parent.name, reverse=True) + return seen + + +def _check_win_python_path(target: Dict[str, Any]) -> Tuple[str, str, Optional[Path]]: + """Returns (status, hint, resolved_path) for ``target.win_python``. + + The third element is the resolved Path when the field is set and + points to an existing file, otherwise ``None`` -- callers use it to + decide whether dependent checks can run or must SKIP. + """ + + win_python = resolve_win_python(target) + if not win_python: + candidates = _discover_win_python_candidates() + if candidates: + suggestion = ( + "Hint: Windows-side Python is installed but FORMULON_WIN_PYTHON " + "is not exported. Found:\n" + + "\n".join(f" {p}" for p in candidates) + + "\n Export the one you want to use:\n" + f' export FORMULON_WIN_PYTHON="{candidates[0]}"' + ) + else: + suggestion = ( + "Hint: install Python on Windows (winget install Python.Python.3.12),\n" + " then either export FORMULON_WIN_PYTHON pointing at the\n" + " Windows-side python.exe (preferred for OSS contributors so\n" + " no per-machine path lands in targets.yaml) or, for a\n" + " private fork, add a win_python: line under the target.\n" + " Example:\n" + ' export FORMULON_WIN_PYTHON="/mnt/c/Users//AppData/' + 'Local/Programs/Python/Python312/python.exe"' + ) + return ( + _STATUS_FAIL, + "win_python not configured\n" + suggestion, + None, + ) + p = Path(win_python) + if not p.exists(): + return ( + _STATUS_FAIL, + f"win_python path does not exist: {p}\n" + "Hint: confirm the Windows Python install path; from WSL2 the\n" + " Windows C: drive is mounted at /mnt/c.", + None, + ) + return (_STATUS_PASS, "", p) + + +def _check_win_python_imports(win_python: Path) -> Tuple[str, str]: + """Returns (status, hint) for ``import xlwings, win32com.client`` on the + Windows-side interpreter. Only runs when win_python resolved cleanly. + """ + + proc = subprocess.run( + [str(win_python), "-c", "import xlwings, win32com.client"], + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return (_STATUS_PASS, "") + return ( + _STATUS_FAIL, + "Windows-side xlwings/pywin32 import failed:\n" + + (proc.stderr.strip() or proc.stdout.strip()) + + "\nHint: in PowerShell, run:\n" + " py -m pip install xlwings pywin32 pyyaml", + ) + + +def _check_wslpath() -> Tuple[str, str]: + """Returns (status, hint) for the ``wslpath`` translator. + + ``wslpath -w /tmp`` is the smallest invocation that exercises the + binary; a non-empty stdout proves the WSL2 kernel is providing the + translation service. + """ + + try: + proc = subprocess.run(["wslpath", "-w", "/tmp"], capture_output=True, text=True) + except FileNotFoundError: + return ( + _STATUS_FAIL, + "wslpath not on PATH\n" + "Hint: wslpath only exists on WSL2; if you are on plain Linux\n" + " you cannot drive Windows Excel from this host.", + ) + if proc.returncode == 0 and proc.stdout.strip(): + return (_STATUS_PASS, "") + return ( + _STATUS_FAIL, + f"wslpath -w /tmp failed: rc={proc.returncode}\n" + (proc.stderr.strip() or proc.stdout.strip()), + ) + + +def _runs_on_label(target: Dict[str, Any]) -> str: + """Returns the comma-joined ``runs_on`` for printing.""" + + runs_on = target.get("runs_on") or [] + if not isinstance(runs_on, list): + return "?" + return ",".join(str(x) for x in runs_on) or "?" + + +def _check_target(target_name: str, target: Dict[str, Any], host: str) -> bool: + """Runs the preflight checks for one target. Returns True on success.""" + + print(f"[setup] target={target_name} host={host}") + driver_name = target.get("driver") + + # Host vs runs_on sanity. We still let the per-driver checks run on a + # mismatch (downgraded to SKIP) so the operator sees what would be + # required if they were on the right host. + runs_on = target.get("runs_on") or [] + host_compatible = isinstance(runs_on, list) and platform.system() in runs_on + + if driver_name == "macos_excel": + if host != "Darwin": + _print_check( + target_name, + _STATUS_FAIL, + "host compatibility", + f"target requires Darwin, current host is {host}.\nruns_on={_runs_on_label(target)}", + ) + _print_check(target_name, _STATUS_SKIP, "xlwings import (host mismatch)") + _print_check(target_name, _STATUS_SKIP, "Excel automation reachable (host mismatch)") + return False + ok = True + status, hint = _check_xlwings_import(_venv_python()) + _print_check(target_name, status, "xlwings import", hint) + if status != _STATUS_PASS: + ok = False + _print_check(target_name, _STATUS_SKIP, "Excel automation reachable (xlwings missing)") + else: + status2, hint2 = _check_excel_reachable(_venv_python()) + _print_check(target_name, status2, "Excel automation reachable", hint2) + if status2 != _STATUS_PASS: + ok = False + return ok + + if driver_name == "windows_excel": + # Three legal hosts: Windows (direct COM), WSL2 (bridge), or + # anything else (skip with a host-mismatch FAIL). + # `host` here is the label from `_platform_label()` -- on WSL2 it + # carries the "(WSL2)" suffix, so we cannot compare against the + # bare `platform.system()` value. + is_wsl2 = "(WSL2)" in host + if host == "Windows": + ok = True + # On Windows we can only verify import; the actual COM probe + # depends on Office activation state which we don't want to + # touch from a preflight. Leave it to the operator. + status, hint = _check_xlwings_import(_venv_python()) + _print_check(target_name, status, "xlwings import (Windows host)", hint) + if status != _STATUS_PASS: + ok = False + _print_check( + target_name, + _STATUS_SKIP, + "Excel COM probe (skipped on preflight; manual oracle-gen will surface activation issues)", + ) + return ok and host_compatible + + if is_wsl2: + ok = True + status, hint, win_python = _check_win_python_path(target) + _print_check(target_name, status, "win_python configured", hint) + if win_python is None: + ok = False + _print_check( + target_name, + _STATUS_SKIP, + "Windows-side xlwings + win32com import (depends on win_python)", + ) + else: + status2, hint2 = _check_win_python_imports(win_python) + _print_check(target_name, status2, "Windows-side xlwings + win32com import", hint2) + if status2 != _STATUS_PASS: + ok = False + status3, hint3 = _check_wslpath() + _print_check(target_name, status3, "wslpath translation", hint3) + if status3 != _STATUS_PASS: + ok = False + return ok + + _print_check( + target_name, + _STATUS_FAIL, + "host compatibility", + f"target needs Windows or WSL2, current host is {host}.\nruns_on={_runs_on_label(target)}", + ) + _print_check(target_name, _STATUS_SKIP, "xlwings + win32com import (host mismatch)") + _print_check(target_name, _STATUS_SKIP, "wslpath translation (host mismatch)") + return False + + if driver_name == "wsl_bridge": + is_wsl2 = "(WSL2)" in host + if not is_wsl2: + _print_check( + target_name, + _STATUS_FAIL, + "host compatibility", + f"target needs WSL2, current host is {_platform_label()}.", + ) + _print_check(target_name, _STATUS_SKIP, "win_python configured (host mismatch)") + _print_check(target_name, _STATUS_SKIP, "wslpath translation (host mismatch)") + return False + ok = True + status, hint, win_python = _check_win_python_path(target) + _print_check(target_name, status, "win_python configured", hint) + if win_python is None: + ok = False + _print_check( + target_name, + _STATUS_SKIP, + "Windows-side xlwings + win32com import (depends on win_python)", + ) + else: + status2, hint2 = _check_win_python_imports(win_python) + _print_check(target_name, status2, "Windows-side xlwings + win32com import", hint2) + if status2 != _STATUS_PASS: + ok = False + status3, hint3 = _check_wslpath() + _print_check(target_name, status3, "wslpath translation", hint3) + if status3 != _STATUS_PASS: + ok = False + return ok + + _print_check( + target_name, + _STATUS_FAIL, + f"unknown driver: {driver_name!r}", + "Hint: targets.yaml driver must be one of 'macos_excel', 'windows_excel', 'wsl_bridge'.", + ) + return False + + +def _cmd_setup(args: argparse.Namespace) -> int: + """Verifies the host can drive its target oracle. + + With ``--target NAME`` checks just that one. Without, iterates every + target whose ``runs_on:`` includes the current platform (so a Mac + developer never sees noise about Windows-only targets, but a WSL2 + developer correctly sees the windows_excel target). + """ + + doc = _load_targets(args.targets_file) + targets: Dict[str, Any] = doc.get("targets") or {} + host_label = _platform_label() + host = platform.system() + + if args.target is not None: + if args.target not in targets: + avail = ", ".join(sorted(targets.keys())) + raise RuntimeError(f"unknown target: {args.target!r} (available: {avail})") + chosen = [(args.target, targets[args.target])] + else: + chosen = [ + (n, t) + for n, t in sorted(targets.items()) + if isinstance(t, dict) and platform.system() in (t.get("runs_on") or []) + ] + if not chosen: + print( + f"setup: no targets in targets.yaml declare runs_on: [{host}]", + file=sys.stderr, + ) + return 0 + + ready = 0 + failed = 0 + for name, record in chosen: + if not isinstance(record, dict): + print(f"[setup] target={name}: malformed (not a mapping)", file=sys.stderr) + failed += 1 + continue + if _check_target(name, record, host_label): + ready += 1 + else: + failed += 1 + + print() + if failed == 0: + print(f"setup: {ready} target ready.") + return 0 + print(f"setup: {ready} target ready, {failed} needs configuration.") + return 1 diff --git a/tools/oracle/targets_manifest.py b/tools/oracle/targets_manifest.py new file mode 100644 index 000000000..d478853da --- /dev/null +++ b/tools/oracle/targets_manifest.py @@ -0,0 +1,85 @@ +"""Loading and selection of oracle targets from targets.yaml, and the host label.""" + +from __future__ import annotations + +import platform +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +DEFAULT_TARGETS_FILE = Path(__file__).resolve().parent / "targets.yaml" + + +def _load_targets(path: Path) -> Dict[str, Any]: + """Reads and validates targets.yaml; returns the parsed mapping.""" + + if not path.exists(): + raise RuntimeError(f"oracle targets file not found: {path}") + try: + import yaml # type: ignore + + doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception as exc: + raise RuntimeError(f"failed to parse {path}: {exc}") from exc + if not isinstance(doc, dict): + raise RuntimeError(f"{path} root must be a mapping") + targets = doc.get("targets") + if not isinstance(targets, dict) or not targets: + raise RuntimeError(f"{path} has no `targets:` mapping") + return doc + + +def _runs_on_current(target: Dict[str, Any]) -> bool: + """Whether the target declares the current OS in `runs_on:`.""" + + runs_on = target.get("runs_on") or [] + if not isinstance(runs_on, list): + return False + return platform.system() in runs_on + + +def _platform_label() -> str: + """Returns ``platform.system()`` with a ``(WSL2)`` suffix on WSL2. + + The CLI's ``list`` command surfaces this so operators can confirm at + a glance which side of the WSL boundary they are on -- ``Linux`` and + ``Linux (WSL2)`` route to different drivers for the same target. + """ + + if platform.system() == "Linux": + try: + if "microsoft" in Path("/proc/version").read_text(encoding="utf-8").lower(): + return "Linux (WSL2)" + except OSError: + pass + return platform.system() + + +def _select_targets( + doc: Dict[str, Any], + *, + name: Optional[str], + all_targets: bool, +) -> List[Tuple[str, Dict[str, Any]]]: + """Returns the (name, record) list to dispatch. + + If `name` is set, just that one (with no `runs_on` filtering — let + oracle_gen surface the platform error directly). If `all_targets`, + every target whose `runs_on:` contains the current OS. Otherwise the + primary target only. + """ + + targets: Dict[str, Any] = doc.get("targets") or {} + if name is not None: + if name not in targets: + avail = ", ".join(sorted(targets.keys())) + raise RuntimeError(f"unknown target: {name!r} (available: {avail})") + return [(name, targets[name])] + if all_targets: + chosen: List[Tuple[str, Dict[str, Any]]] = [(n, t) for n, t in sorted(targets.items()) if _runs_on_current(t)] + if not chosen: + raise RuntimeError(f"no targets in targets.yaml declare runs_on: [{platform.system()}]") + return chosen + primary = doc.get("primary") + if not isinstance(primary, str) or primary not in targets: + raise RuntimeError("targets.yaml is missing a valid `primary:` entry") + return [(primary, targets[primary])] From 272f93b9633ac9d2d2af8295d33c5734e17b9820 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:03:49 +0900 Subject: [PATCH 04/93] refactor(oracle): extract CF data-bar and icon-set semantics from cf_oracle_gen - move the DataBar / IconSet match computation (threshold resolution, population, per-cell matches) into cf_visual_semantics.py - add cf_visual_semantics_test.py and register it as formulon_cf_visual_semantics_unit in tests/oracle/CMakeLists.txt, since these payloads are computed rather than captured - cf_oracle_gen.py calls the new module; generated output is unchanged --- tests/oracle/CMakeLists.txt | 9 + tools/oracle/cf_oracle_gen.py | 241 +---------------------- tools/oracle/cf_visual_semantics.py | 241 +++++++++++++++++++++++ tools/oracle/cf_visual_semantics_test.py | 224 +++++++++++++++++++++ 4 files changed, 483 insertions(+), 232 deletions(-) create mode 100644 tools/oracle/cf_visual_semantics.py create mode 100644 tools/oracle/cf_visual_semantics_test.py diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 880a4aa7d..6f5658798 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -702,6 +702,15 @@ if(Python3_Interpreter_FOUND) WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} ) set_tests_properties(formulon_divergence_projection_unit PROPERTIES LABELS "oracle;oracle-metadata") + # The DataBar / IconSet match payloads are computed from documented + # semantics rather than captured, so this is the only check on them that + # does not need Excel. + add_test( + NAME formulon_cf_visual_semantics_unit + COMMAND ${_formulon_oracle_python} -m unittest tools.oracle.cf_visual_semantics_test + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + ) + set_tests_properties(formulon_cf_visual_semantics_unit PROPERTIES LABELS "oracle;oracle-metadata") # This entry covers the spec and address handling only. The suite's # authoring half needs the Formulon Python binding, which the oracle # venv does not install -- it carries the capture dependencies (PyYAML, diff --git a/tools/oracle/cf_oracle_gen.py b/tools/oracle/cf_oracle_gen.py index 0642dc5c5..0dddfc93f 100644 --- a/tools/oracle/cf_oracle_gen.py +++ b/tools/oracle/cf_oracle_gen.py @@ -74,6 +74,15 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +try: # pragma: no cover - trivial fallback + from tools.oracle.cf_visual_semantics import _ICON_SET_FORMULON_TO_OOXML, _cell_in_block, _compute_visual_matches +except ImportError: # pragma: no cover + from cf_visual_semantics import ( # type: ignore + _ICON_SET_FORMULON_TO_OOXML, + _cell_in_block, + _compute_visual_matches, + ) + REPO_ROOT = Path(__file__).resolve().parents[2] DEFAULT_CASES_DIR = REPO_ROOT / "tests" / "oracle" / "cases_cf" DEFAULT_GOLDEN_DIR = REPO_ROOT / "tests" / "oracle" / "golden_cf" @@ -260,38 +269,6 @@ def _hex6(rgb: Tuple[int, int, int]) -> str: return f"{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" -# OOXML icon-set tag <-> Formulon `IconSetName` enum string. The OOXML -# attribute (``) is what openpyxl emits and -# what Excel reads; the second column is the name the Formulon CF -# evaluator emits in `IconRender::set_name`. Mirrors src/cf/cf_types.h -# and src/io/cf_reader.cpp. -_ICON_SET_OOXML_TO_FORMULON: Dict[str, str] = { - "3Arrows": "Three_Arrows", - "3ArrowsGray": "Three_ArrowsGray", - "3Flags": "Three_Flags", - "3TrafficLights1": "Three_TrafficLights1", - "3TrafficLights2": "Three_TrafficLights2", - "3Signs": "Three_Signs", - "3Symbols": "Three_Symbols", - "3Symbols2": "Three_Symbols2", - "4Arrows": "Four_Arrows", - "4ArrowsGray": "Four_ArrowsGray", - "4RedToBlack": "Four_RedToBlack", - "4Rating": "Four_Rating", - "4TrafficLights": "Four_TrafficLights", - "5Arrows": "Five_Arrows", - "5ArrowsGray": "Five_ArrowsGray", - "5Rating": "Five_Rating", - "5Quarters": "Five_Quarters", -} - -_ICON_SET_FORMULON_TO_OOXML: Dict[str, str] = {v: k for k, v in _ICON_SET_OOXML_TO_FORMULON.items()} - -# Number of buckets for an icon set, keyed by the OOXML tag's leading -# digit. Used to bound the per-cell icon index after walking thresholds. -_ICON_SET_BUCKET_COUNT: Dict[str, int] = {tag: int(tag[0]) for tag in _ICON_SET_OOXML_TO_FORMULON} - - def _channel_close(actual: List[int], expected: Tuple[int, int, int], tol: int = 2) -> bool: if len(actual) < 3: return False @@ -725,206 +702,6 @@ def _build_workbook(case: Dict[str, Any], path: Path) -> List[Dict[str, Any]]: return descriptors -def _cell_in_block(row: int, col: int, sqref: List[Dict[str, int]]) -> bool: - for r in sqref: - if r["first_row"] <= row <= r["last_row"] and r["first_col"] <= col <= r["last_col"]: - return True - return False - - -def _numeric_population(case: Dict[str, Any], sqref: List[Dict[str, int]]) -> List[float]: - """Returns the sorted ascending list of numeric cell values inside - `sqref` for the given `case`. Mirrors the C++ evaluator's - `collect_numeric_values` helper: booleans, text, and errors are - skipped; only `kind == "number"` cells contribute. Cells outside - `sqref` are ignored; cells in `sqref` that are absent from the - `sheet` array are treated as blank (and skipped). - """ - values: List[float] = [] - for entry in case["sheet"]: - if entry.get("kind") != "number": - continue - if not _cell_in_block(int(entry["row"]), int(entry["col"]), sqref): - continue - values.append(float(entry["value"])) - values.sort() - return values - - -def _resolve_threshold_value( - threshold_type: str, - threshold_value: Optional[Any], - population: List[float], -) -> Optional[float]: - """Resolves a single CFVO to its numeric threshold. Mirrors - `cf_evaluator.cpp::resolve_cfvo` for the threshold types the - generator currently emits (Number / Percent / Percentile / Min / - Max). Returns `None` when the population is empty and the - threshold needs it (Min / Max / Percent / Percentile). - """ - if threshold_type == "Number": - return float(threshold_value) if threshold_value is not None else None - if not population: - return None - pop_min = population[0] - pop_max = population[-1] - if threshold_type == "Min": - return pop_min - if threshold_type == "Max": - return pop_max - if threshold_type == "Percent": - if threshold_value is None: - return None - pct = float(threshold_value) - return pop_min + (pct / 100.0) * (pop_max - pop_min) - if threshold_type == "Percentile": - if threshold_value is None: - return None - pct = float(threshold_value) / 100.0 - count = len(population) - if count == 1: - return population[0] - position = pct * (count - 1) - lower_index = int(position) - if lower_index + 1 >= count: - return population[-1] - fraction = position - lower_index - return population[lower_index] + fraction * (population[lower_index + 1] - population[lower_index]) - return None - - -def _data_bar_match_for_cell( - cell_value: Optional[float], - desc: Dict[str, Any], - population: List[float], -) -> Optional[Dict[str, Any]]: - """Computes the DataBar match payload for a numeric cell value - using the documented Microsoft VBA semantics: - https://learn.microsoft.com/en-us/office/vba/api/excel.databar - Bar length is the linear fraction `(value - min) / (max - min)`, - clamped to [0, 1], scaled into [min_length_pct, max_length_pct]. - Negative values are not handled by these smoke cases (see - README.md "Not yet captured"); `bar_axis_position_pct` is fixed at - 0 and `is_negative` at false. - """ - if cell_value is None: - return None - db = desc["data_bar"] - threshold_min = _resolve_threshold_value(db["min_type"], db["min_value"], population) - threshold_max = _resolve_threshold_value(db["max_type"], db["max_value"], population) - if threshold_min is None or threshold_max is None: - return None - if threshold_max == threshold_min: - return None - min_len = float(db["min_length_pct"]) - max_len = float(db["max_length_pct"]) - fraction = (cell_value - threshold_min) / (threshold_max - threshold_min) - fraction = max(0.0, min(1.0, fraction)) - bar_length = min_len + fraction * (max_len - min_len) - fill = db["fill"] - return { - "kind": "DataBar", - "priority": desc["priority"], - "bar_length_pct": bar_length, - "bar_axis_position_pct": 0.0, - "is_negative": False, - "fill": { - "r": int(fill["r"]), - "g": int(fill["g"]), - "b": int(fill["b"]), - "a": int(fill.get("a", 255)), - }, - } - - -def _icon_set_match_for_cell( - cell_value: Optional[float], - desc: Dict[str, Any], - population: List[float], -) -> Optional[Dict[str, Any]]: - """Computes the IconSet match payload for a numeric cell value - using the documented Microsoft VBA semantics: - https://learn.microsoft.com/en-us/office/vba/api/excel.iconsetcondition - Walks thresholds in ascending order, bumping the bucket index for - each one the cell value crosses (`>=` if `gte`; `>` otherwise). - Applies `reverse` last by mirroring the index across the bucket - range. - """ - if cell_value is None: - return None - spec = desc["icon_set"] - resolved: List[Optional[float]] = [] - for t in spec["thresholds"]: - resolved.append(_resolve_threshold_value(t["type"], t["value"], population)) - if any(value is None for value in resolved): - return None - icon_index = 0 - for i, threshold in enumerate(resolved): - gte = bool(spec["thresholds"][i]["gte"]) - if (cell_value >= threshold) if gte else (cell_value > threshold): - icon_index = i + 1 - bucket_count = _ICON_SET_BUCKET_COUNT.get(spec["name_ooxml"], len(resolved) + 1) - if spec["reverse"]: - icon_index = bucket_count - 1 - icon_index - return { - "kind": "IconSet", - "priority": desc["priority"], - "icon_set_name": spec["name_formulon"], - "icon_index": icon_index, - } - - -def _cell_value_for(case: Dict[str, Any], row: int, col: int) -> Optional[float]: - """Returns the numeric value of `(row, col)` in `case['sheet']`, or - `None` when the cell is blank, non-numeric, or absent. - """ - for entry in case["sheet"]: - if int(entry["row"]) == row and int(entry["col"]) == col: - if entry.get("kind") == "number": - return float(entry["value"]) - return None - return None - - -def _compute_visual_matches( - case: Dict[str, Any], descriptors: List[Dict[str, Any]] -) -> Dict[Tuple[int, int], List[Dict[str, Any]]]: - """Computes per-cell DataBar / IconSet matches for every descriptor - that carries the corresponding payload. Returns a row/col-keyed map - of match-dict lists; cells with no visual rule applied are absent. - - Excel does not expose these match shapes through DisplayFormat, so - the generator computes them from documented semantics. Every cell - inside the rule's sqref produces a match (visual rules apply to - every cell in their range — there is no boolean "fired or not" - decision the way DifferentialFormat-bearing rules have). - """ - out: Dict[Tuple[int, int], List[Dict[str, Any]]] = {} - rng = case["range"] - r1 = int(rng["first_row"]) - c1 = int(rng["first_col"]) - r2 = int(rng["last_row"]) - c2 = int(rng["last_col"]) - sorted_descriptors = sorted(descriptors, key=lambda d: d["priority"]) - for desc in sorted_descriptors: - if desc["type"] not in ("DataBar", "IconSet"): - continue - population = _numeric_population(case, desc["sqref"]) - for r in range(r1, r2 + 1): - for c in range(c1, c2 + 1): - if not _cell_in_block(r, c, desc["sqref"]): - continue - cell_val = _cell_value_for(case, r, c) - if desc["type"] == "DataBar": - match = _data_bar_match_for_cell(cell_val, desc, population) - else: - match = _icon_set_match_for_cell(cell_val, desc, population) - if match is None: - continue - out.setdefault((r, c), []).append(match) - return out - - def _resolve_color_list(value: Any) -> Optional[List[int]]: """Resolves an appscript-color reference to ``[r, g, b]`` or returns ``None`` if the reference cannot be coerced. diff --git a/tools/oracle/cf_visual_semantics.py b/tools/oracle/cf_visual_semantics.py new file mode 100644 index 000000000..db049beb9 --- /dev/null +++ b/tools/oracle/cf_visual_semantics.py @@ -0,0 +1,241 @@ +"""Documented-semantics match payloads for DataBar and IconSet rules. + +Excel does not expose these shapes through DisplayFormat, so the oracle +generator computes them from the Microsoft VBA object-model semantics. +Pure functions over the case JSON; no Excel or openpyxl needed. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +# OOXML icon-set tag <-> Formulon `IconSetName` enum string. The OOXML +# attribute (``) is what openpyxl emits and +# what Excel reads; the second column is the name the Formulon CF +# evaluator emits in `IconRender::set_name`. Mirrors src/cf/cf_types.h +# and src/io/cf_reader.cpp. +_ICON_SET_OOXML_TO_FORMULON: Dict[str, str] = { + "3Arrows": "Three_Arrows", + "3ArrowsGray": "Three_ArrowsGray", + "3Flags": "Three_Flags", + "3TrafficLights1": "Three_TrafficLights1", + "3TrafficLights2": "Three_TrafficLights2", + "3Signs": "Three_Signs", + "3Symbols": "Three_Symbols", + "3Symbols2": "Three_Symbols2", + "4Arrows": "Four_Arrows", + "4ArrowsGray": "Four_ArrowsGray", + "4RedToBlack": "Four_RedToBlack", + "4Rating": "Four_Rating", + "4TrafficLights": "Four_TrafficLights", + "5Arrows": "Five_Arrows", + "5ArrowsGray": "Five_ArrowsGray", + "5Rating": "Five_Rating", + "5Quarters": "Five_Quarters", +} + +_ICON_SET_FORMULON_TO_OOXML: Dict[str, str] = {v: k for k, v in _ICON_SET_OOXML_TO_FORMULON.items()} + +# Number of buckets for an icon set, keyed by the OOXML tag's leading +# digit. Used to bound the per-cell icon index after walking thresholds. +_ICON_SET_BUCKET_COUNT: Dict[str, int] = {tag: int(tag[0]) for tag in _ICON_SET_OOXML_TO_FORMULON} + + +def _cell_in_block(row: int, col: int, sqref: List[Dict[str, int]]) -> bool: + for r in sqref: + if r["first_row"] <= row <= r["last_row"] and r["first_col"] <= col <= r["last_col"]: + return True + return False + + +def _numeric_population(case: Dict[str, Any], sqref: List[Dict[str, int]]) -> List[float]: + """Returns the sorted ascending list of numeric cell values inside + `sqref` for the given `case`. Mirrors the C++ evaluator's + `collect_numeric_values` helper: booleans, text, and errors are + skipped; only `kind == "number"` cells contribute. Cells outside + `sqref` are ignored; cells in `sqref` that are absent from the + `sheet` array are treated as blank (and skipped). + """ + values: List[float] = [] + for entry in case["sheet"]: + if entry.get("kind") != "number": + continue + if not _cell_in_block(int(entry["row"]), int(entry["col"]), sqref): + continue + values.append(float(entry["value"])) + values.sort() + return values + + +def _resolve_threshold_value( + threshold_type: str, + threshold_value: Optional[Any], + population: List[float], +) -> Optional[float]: + """Resolves a single CFVO to its numeric threshold. Mirrors + `cf_evaluator.cpp::resolve_cfvo` for the threshold types the + generator currently emits (Number / Percent / Percentile / Min / + Max). Returns `None` when the population is empty and the + threshold needs it (Min / Max / Percent / Percentile). + """ + if threshold_type == "Number": + return float(threshold_value) if threshold_value is not None else None + if not population: + return None + pop_min = population[0] + pop_max = population[-1] + if threshold_type == "Min": + return pop_min + if threshold_type == "Max": + return pop_max + if threshold_type == "Percent": + if threshold_value is None: + return None + pct = float(threshold_value) + return pop_min + (pct / 100.0) * (pop_max - pop_min) + if threshold_type == "Percentile": + if threshold_value is None: + return None + pct = float(threshold_value) / 100.0 + count = len(population) + if count == 1: + return population[0] + position = pct * (count - 1) + lower_index = int(position) + if lower_index + 1 >= count: + return population[-1] + fraction = position - lower_index + return population[lower_index] + fraction * (population[lower_index + 1] - population[lower_index]) + return None + + +def _data_bar_match_for_cell( + cell_value: Optional[float], + desc: Dict[str, Any], + population: List[float], +) -> Optional[Dict[str, Any]]: + """Computes the DataBar match payload for a numeric cell value + using the documented Microsoft VBA semantics: + https://learn.microsoft.com/en-us/office/vba/api/excel.databar + Bar length is the linear fraction `(value - min) / (max - min)`, + clamped to [0, 1], scaled into [min_length_pct, max_length_pct]. + Negative values are not handled by these smoke cases (see + README.md "Not yet captured"); `bar_axis_position_pct` is fixed at + 0 and `is_negative` at false. + """ + if cell_value is None: + return None + db = desc["data_bar"] + threshold_min = _resolve_threshold_value(db["min_type"], db["min_value"], population) + threshold_max = _resolve_threshold_value(db["max_type"], db["max_value"], population) + if threshold_min is None or threshold_max is None: + return None + if threshold_max == threshold_min: + return None + min_len = float(db["min_length_pct"]) + max_len = float(db["max_length_pct"]) + fraction = (cell_value - threshold_min) / (threshold_max - threshold_min) + fraction = max(0.0, min(1.0, fraction)) + bar_length = min_len + fraction * (max_len - min_len) + fill = db["fill"] + return { + "kind": "DataBar", + "priority": desc["priority"], + "bar_length_pct": bar_length, + "bar_axis_position_pct": 0.0, + "is_negative": False, + "fill": { + "r": int(fill["r"]), + "g": int(fill["g"]), + "b": int(fill["b"]), + "a": int(fill.get("a", 255)), + }, + } + + +def _icon_set_match_for_cell( + cell_value: Optional[float], + desc: Dict[str, Any], + population: List[float], +) -> Optional[Dict[str, Any]]: + """Computes the IconSet match payload for a numeric cell value + using the documented Microsoft VBA semantics: + https://learn.microsoft.com/en-us/office/vba/api/excel.iconsetcondition + Walks thresholds in ascending order, bumping the bucket index for + each one the cell value crosses (`>=` if `gte`; `>` otherwise). + Applies `reverse` last by mirroring the index across the bucket + range. + """ + if cell_value is None: + return None + spec = desc["icon_set"] + resolved: List[Optional[float]] = [] + for t in spec["thresholds"]: + resolved.append(_resolve_threshold_value(t["type"], t["value"], population)) + if any(value is None for value in resolved): + return None + icon_index = 0 + for i, threshold in enumerate(resolved): + gte = bool(spec["thresholds"][i]["gte"]) + if (cell_value >= threshold) if gte else (cell_value > threshold): + icon_index = i + 1 + bucket_count = _ICON_SET_BUCKET_COUNT.get(spec["name_ooxml"], len(resolved) + 1) + if spec["reverse"]: + icon_index = bucket_count - 1 - icon_index + return { + "kind": "IconSet", + "priority": desc["priority"], + "icon_set_name": spec["name_formulon"], + "icon_index": icon_index, + } + + +def _cell_value_for(case: Dict[str, Any], row: int, col: int) -> Optional[float]: + """Returns the numeric value of `(row, col)` in `case['sheet']`, or + `None` when the cell is blank, non-numeric, or absent. + """ + for entry in case["sheet"]: + if int(entry["row"]) == row and int(entry["col"]) == col: + if entry.get("kind") == "number": + return float(entry["value"]) + return None + return None + + +def _compute_visual_matches( + case: Dict[str, Any], descriptors: List[Dict[str, Any]] +) -> Dict[Tuple[int, int], List[Dict[str, Any]]]: + """Computes per-cell DataBar / IconSet matches for every descriptor + that carries the corresponding payload. Returns a row/col-keyed map + of match-dict lists; cells with no visual rule applied are absent. + + Excel does not expose these match shapes through DisplayFormat, so + the generator computes them from documented semantics. Every cell + inside the rule's sqref produces a match (visual rules apply to + every cell in their range — there is no boolean "fired or not" + decision the way DifferentialFormat-bearing rules have). + """ + out: Dict[Tuple[int, int], List[Dict[str, Any]]] = {} + rng = case["range"] + r1 = int(rng["first_row"]) + c1 = int(rng["first_col"]) + r2 = int(rng["last_row"]) + c2 = int(rng["last_col"]) + sorted_descriptors = sorted(descriptors, key=lambda d: d["priority"]) + for desc in sorted_descriptors: + if desc["type"] not in ("DataBar", "IconSet"): + continue + population = _numeric_population(case, desc["sqref"]) + for r in range(r1, r2 + 1): + for c in range(c1, c2 + 1): + if not _cell_in_block(r, c, desc["sqref"]): + continue + cell_val = _cell_value_for(case, r, c) + if desc["type"] == "DataBar": + match = _data_bar_match_for_cell(cell_val, desc, population) + else: + match = _icon_set_match_for_cell(cell_val, desc, population) + if match is None: + continue + out.setdefault((r, c), []).append(match) + return out diff --git a/tools/oracle/cf_visual_semantics_test.py b/tools/oracle/cf_visual_semantics_test.py new file mode 100644 index 000000000..6bbda4c14 --- /dev/null +++ b/tools/oracle/cf_visual_semantics_test.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Unit tests for the DataBar / IconSet documented-semantics computation.""" + +from __future__ import annotations + +import unittest + +from tools.oracle import cf_visual_semantics as sem + +SQREF = [{"first_row": 1, "first_col": 1, "last_row": 5, "last_col": 1}] + + +def _case(values: list[object]) -> dict: + """A one-column sheet; `None` leaves the row absent, strings become text cells.""" + + sheet = [] + for i, v in enumerate(values, start=1): + if v is None: + continue + if isinstance(v, str): + sheet.append({"row": i, "col": 1, "kind": "text", "value": v}) + elif isinstance(v, bool): + sheet.append({"row": i, "col": 1, "kind": "bool", "value": v}) + else: + sheet.append({"row": i, "col": 1, "kind": "number", "value": v}) + return { + "sheet": sheet, + "range": {"first_row": 1, "first_col": 1, "last_row": len(values), "last_col": 1}, + } + + +def _data_bar(min_type="Min", min_value=None, max_type="Max", max_value=None, **extra) -> dict: + return { + "type": "DataBar", + "priority": extra.pop("priority", 1), + "sqref": extra.pop("sqref", SQREF), + "data_bar": { + "min_type": min_type, + "min_value": min_value, + "max_type": max_type, + "max_value": max_value, + "min_length_pct": extra.pop("min_length_pct", 10), + "max_length_pct": extra.pop("max_length_pct", 90), + "fill": extra.pop("fill", {"r": 1, "g": 2, "b": 3}), + }, + } + + +def _icon_set(thresholds, name_ooxml="3Arrows", reverse=False, priority=1) -> dict: + return { + "type": "IconSet", + "priority": priority, + "sqref": SQREF, + "icon_set": { + "name_ooxml": name_ooxml, + "name_formulon": "Three_Arrows", + "reverse": reverse, + "thresholds": [{"type": t, "value": v, "gte": g} for t, v, g in thresholds], + }, + } + + +class CellInBlockTest(unittest.TestCase): + def test_bounds_are_inclusive(self): + self.assertTrue(sem._cell_in_block(1, 1, SQREF)) + self.assertTrue(sem._cell_in_block(5, 1, SQREF)) + self.assertFalse(sem._cell_in_block(6, 1, SQREF)) + self.assertFalse(sem._cell_in_block(1, 2, SQREF)) + + def test_any_block_matches(self): + blocks = SQREF + [{"first_row": 10, "first_col": 3, "last_row": 10, "last_col": 3}] + self.assertTrue(sem._cell_in_block(10, 3, blocks)) + self.assertFalse(sem._cell_in_block(10, 1, blocks)) + + +class PopulationTest(unittest.TestCase): + def test_sorted_numbers_only(self): + case = _case([5, "x", 1.5, True, 3]) + self.assertEqual(sem._numeric_population(case, SQREF), [1.5, 3.0, 5.0]) + + def test_cells_outside_block_ignored(self): + case = _case([1, 2, 3, 4, 5]) + block = [{"first_row": 2, "first_col": 1, "last_row": 3, "last_col": 1}] + self.assertEqual(sem._numeric_population(case, block), [2.0, 3.0]) + + def test_cell_value_for(self): + case = _case([7, "x", None]) + self.assertEqual(sem._cell_value_for(case, 1, 1), 7.0) + self.assertIsNone(sem._cell_value_for(case, 2, 1)) + self.assertIsNone(sem._cell_value_for(case, 3, 1)) + self.assertIsNone(sem._cell_value_for(case, 9, 9)) + + +class ThresholdTest(unittest.TestCase): + POP = [0.0, 10.0, 20.0, 40.0] + + def test_number_ignores_population(self): + self.assertEqual(sem._resolve_threshold_value("Number", 7, []), 7.0) + self.assertIsNone(sem._resolve_threshold_value("Number", None, self.POP)) + + def test_empty_population_needs_none(self): + for kind in ("Min", "Max", "Percent", "Percentile"): + self.assertIsNone(sem._resolve_threshold_value(kind, 50, [])) + + def test_min_max(self): + self.assertEqual(sem._resolve_threshold_value("Min", None, self.POP), 0.0) + self.assertEqual(sem._resolve_threshold_value("Max", None, self.POP), 40.0) + + def test_percent_is_linear_between_extremes(self): + self.assertEqual(sem._resolve_threshold_value("Percent", 25, self.POP), 10.0) + self.assertIsNone(sem._resolve_threshold_value("Percent", None, self.POP)) + + def test_percentile_interpolates_between_ranks(self): + # position = 0.5 * 3 = 1.5 -> halfway between 10 and 20 + self.assertEqual(sem._resolve_threshold_value("Percentile", 50, self.POP), 15.0) + self.assertEqual(sem._resolve_threshold_value("Percentile", 100, self.POP), 40.0) + self.assertEqual(sem._resolve_threshold_value("Percentile", 0, self.POP), 0.0) + self.assertEqual(sem._resolve_threshold_value("Percentile", 50, [3.0]), 3.0) + + def test_unknown_type_is_none(self): + self.assertIsNone(sem._resolve_threshold_value("Formula", "A1", self.POP)) + + +class DataBarTest(unittest.TestCase): + POP = [0.0, 50.0, 100.0] + + def test_length_scales_into_configured_range(self): + match = sem._data_bar_match_for_cell(50.0, _data_bar(), self.POP) + self.assertEqual(match["kind"], "DataBar") + self.assertEqual(match["bar_length_pct"], 50.0) + self.assertEqual(match["bar_axis_position_pct"], 0.0) + self.assertFalse(match["is_negative"]) + self.assertEqual(match["fill"], {"r": 1, "g": 2, "b": 3, "a": 255}) + + def test_length_clamps_to_unit_fraction(self): + desc = _data_bar(min_type="Number", min_value=20, max_type="Number", max_value=80) + self.assertEqual(sem._data_bar_match_for_cell(0.0, desc, self.POP)["bar_length_pct"], 10.0) + self.assertEqual(sem._data_bar_match_for_cell(100.0, desc, self.POP)["bar_length_pct"], 90.0) + + def test_explicit_alpha_kept(self): + desc = _data_bar(fill={"r": 1, "g": 2, "b": 3, "a": 40}) + self.assertEqual(sem._data_bar_match_for_cell(0.0, desc, self.POP)["fill"]["a"], 40) + + def test_no_match_for_blank_degenerate_or_empty(self): + self.assertIsNone(sem._data_bar_match_for_cell(None, _data_bar(), self.POP)) + self.assertIsNone(sem._data_bar_match_for_cell(1.0, _data_bar(), [4.0])) + self.assertIsNone(sem._data_bar_match_for_cell(1.0, _data_bar(), [])) + + +class IconSetTest(unittest.TestCase): + POP = [0.0, 50.0, 100.0] + THRESHOLDS = [("Percent", 33, True), ("Percent", 67, True)] + + def _index(self, value, **kw): + match = sem._icon_set_match_for_cell(value, _icon_set(self.THRESHOLDS, **kw), self.POP) + return match["icon_index"] + + def test_buckets_walk_thresholds(self): + self.assertEqual(self._index(0.0), 0) + self.assertEqual(self._index(40.0), 1) + self.assertEqual(self._index(100.0), 2) + + def test_gte_flag_controls_boundary(self): + strict = [("Number", 50, False)] + inclusive = [("Number", 50, True)] + at = lambda th: sem._icon_set_match_for_cell(50.0, _icon_set(th), self.POP)["icon_index"] # noqa: E731 + self.assertEqual(at(strict), 0) + self.assertEqual(at(inclusive), 1) + + def test_reverse_mirrors_index_across_buckets(self): + self.assertEqual(self._index(100.0, reverse=True), 0) + self.assertEqual(self._index(0.0, reverse=True), 2) + + def test_bucket_count_follows_set_name(self): + self.assertEqual(sem._ICON_SET_BUCKET_COUNT["3Arrows"], 3) + self.assertEqual(sem._ICON_SET_BUCKET_COUNT["5Quarters"], 5) + thresholds = [("Number", 0, True)] * 4 + match = sem._icon_set_match_for_cell(1.0, _icon_set(thresholds, name_ooxml="5Rating", reverse=True), self.POP) + self.assertEqual(match["icon_index"], 0) + + def test_payload_shape_and_absent_cases(self): + match = sem._icon_set_match_for_cell(100.0, _icon_set(self.THRESHOLDS, priority=4), self.POP) + self.assertEqual(match["kind"], "IconSet") + self.assertEqual(match["priority"], 4) + self.assertEqual(match["icon_set_name"], "Three_Arrows") + self.assertIsNone(sem._icon_set_match_for_cell(None, _icon_set(self.THRESHOLDS), self.POP)) + self.assertIsNone(sem._icon_set_match_for_cell(1.0, _icon_set(self.THRESHOLDS), [])) + + def test_name_tables_are_inverse(self): + for ooxml, formulon in sem._ICON_SET_OOXML_TO_FORMULON.items(): + self.assertEqual(sem._ICON_SET_FORMULON_TO_OOXML[formulon], ooxml) + + +class ComputeVisualMatchesTest(unittest.TestCase): + def test_every_numeric_cell_in_range_gets_a_match(self): + case = _case([0, 50, 100, "x", None]) + out = sem._compute_visual_matches(case, [_data_bar()]) + self.assertEqual(sorted(out), [(1, 1), (2, 1), (3, 1)]) + self.assertEqual([out[(r, 1)][0]["bar_length_pct"] for r in (1, 2, 3)], [10.0, 50.0, 90.0]) + + def test_priority_order_and_rule_type_filter(self): + case = _case([0, 50, 100]) + thresholds = [("Percent", 33, True), ("Percent", 67, True)] + descriptors = [ + _icon_set(thresholds, priority=2), + {"type": "CellIs", "priority": 0, "sqref": SQREF}, + _data_bar(priority=1), + ] + out = sem._compute_visual_matches(case, descriptors) + self.assertEqual([m["kind"] for m in out[(1, 1)]], ["DataBar", "IconSet"]) + + def test_sqref_limits_cells_and_population(self): + case = _case([0, 50, 100]) + block = [{"first_row": 2, "first_col": 1, "last_row": 3, "last_col": 1}] + out = sem._compute_visual_matches(case, [_data_bar(sqref=block)]) + self.assertEqual(sorted(out), [(2, 1), (3, 1)]) + self.assertEqual(out[(2, 1)][0]["bar_length_pct"], 10.0) + + def test_degenerate_population_yields_no_matches(self): + self.assertEqual(sem._compute_visual_matches(_case([5, "x"]), [_data_bar()]), {}) + + +if __name__ == "__main__": + unittest.main() From 995b8541b0abd9f9030cfdbb87a39130d5aece1f Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:03:58 +0900 Subject: [PATCH 05/93] refactor(eval): move runtime LAMBDA invocation out of dispatch.cpp - move invoke_lambda, parameter binding, the arity and lambda-depth rules, callable resolution and reference resolution through a lambda body into tree_walker/lambda_invoke.cpp - register lambda_invoke.cpp in CMakeLists.txt - update the file-layout comments in dispatch.h, walker.cpp, depth_guard.h and lambda_value.h - behavior-preserving move --- CMakeLists.txt | 1 + src/eval/lambda_value.h | 2 +- src/eval/tree_walker/depth_guard.h | 8 +- src/eval/tree_walker/dispatch.cpp | 240 ---------------------- src/eval/tree_walker/dispatch.h | 9 +- src/eval/tree_walker/lambda_invoke.cpp | 271 +++++++++++++++++++++++++ src/eval/tree_walker/walker.cpp | 7 +- 7 files changed, 286 insertions(+), 252 deletions(-) create mode 100644 src/eval/tree_walker/lambda_invoke.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 98778ce4c..0294e71a9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -296,6 +296,7 @@ set(FORMULON_CORE_SOURCES src/eval/stats/special_functions.cpp src/eval/tree_walker/broadcast.cpp src/eval/tree_walker/dispatch.cpp + src/eval/tree_walker/lambda_invoke.cpp src/eval/tree_walker/walker.cpp src/eval/tree_walker_lazy_table.cpp src/eval/eval_context.cpp diff --git a/src/eval/lambda_value.h b/src/eval/lambda_value.h index 5a58d50c3..2a77ef593 100644 --- a/src/eval/lambda_value.h +++ b/src/eval/lambda_value.h @@ -53,7 +53,7 @@ struct LambdaValue { /// — must satisfy `param_count - optional_count <= arity <= param_count`; /// anything outside that window surfaces `#VALUE!`. The rule is enforced /// in one place, `invoke_lambda_values_with_ast` in - /// `eval/tree_walker/dispatch.cpp`. + /// `eval/tree_walker/lambda_invoke.cpp`. std::uint32_t param_count; /// Number of trailing parameters declared with `[name]` bracket syntax. /// When the call site provides fewer than `param_count` arguments, the diff --git a/src/eval/tree_walker/depth_guard.h b/src/eval/tree_walker/depth_guard.h index 9dd0aad31..1ed26acf2 100644 --- a/src/eval/tree_walker/depth_guard.h +++ b/src/eval/tree_walker/depth_guard.h @@ -1,10 +1,10 @@ // // Private helper for the tree-walk evaluator: a tiny RAII counter guard // that bounds runaway recursion through `EvalContext::resolve_ref` and -// user-defined LAMBDA closures. Kept header-only so the two evaluator -// translation units that need it (`tree_walker/walker.cpp` and -// `tree_walker/dispatch.cpp`) can construct guards inline without an -// extra function-call hop. +// user-defined LAMBDA closures. Kept header-only so the evaluator +// translation units that need it (`tree_walker/walker.cpp`, +// `tree_walker/dispatch.cpp` and `tree_walker/lambda_invoke.cpp`) can +// construct guards inline without an extra function-call hop. // // This header is internal to the tree-walker family and is not part of // the public evaluator surface — production callers reach the evaluator diff --git a/src/eval/tree_walker/dispatch.cpp b/src/eval/tree_walker/dispatch.cpp index 13f1707f6..e04c6903c 100644 --- a/src/eval/tree_walker/dispatch.cpp +++ b/src/eval/tree_walker/dispatch.cpp @@ -122,246 +122,6 @@ bool is_reference_shape(const parser::AstNode& node) noexcept { namespace { -// `syntax_args` is the call site's argument AST, consulted for one thing -// only: telling a syntactically omitted slot (`f(1, , 3)`) from a supplied -// one. It is deliberately separate from `ast_args`, the per-argument AST -// `eval_binding_source` chose to record on the binding. -// Binds `lv`'s parameters for a call into `*env`; the error a call surfaces -// instead when the arity does not fit or the lambda has no body. -std::optional bind_lambda_params(const LambdaValue* lv, std::uint32_t arity, const Value* args, - const parser::AstNode* const* ast_args, - const parser::AstNode* const* syntax_args, Arena& arena, NameEnv* out) { - const std::uint32_t required = lv->param_count - lv->optional_count; - if (arity < required || arity > lv->param_count) { - return ErrorCode::Value; - } - if (lv->body == nullptr) { - return ErrorCode::Name; - } - NameEnv& env = *out; - if (lv->captured_env != nullptr) { - env = *lv->captured_env; - } - for (std::uint32_t i = 0; i < arity; ++i) { - const parser::AstNode* expr = (ast_args != nullptr) ? ast_args[i] : nullptr; - // `f(1, , 3)` supplies the slot syntactically and omits its value, and - // Excel treats that as omitted wherever it appears -- leading, middle - // or trailing -- not only past the end of the argument list. Binding - // it as an ordinary blank would give the body the right number (an - // omitted slot arithmetic-coerces to 0 either way) while telling - // ISOMITTED the wrong thing, which is the whole of what that function - // is for. Only the AST can answer this: by the time the argument is a - // Value, an omitted slot and a blank cell are the same blank. - const parser::AstNode* syntax = (syntax_args != nullptr) ? syntax_args[i] : expr; - if (syntax != nullptr && is_omitted_arg(*syntax)) { - env = env.extend_omitted(lv->params[i], arena); - continue; - } - env = env.extend(lv->params[i], args[i], expr, arena); - } - for (std::uint32_t i = arity; i < lv->param_count; ++i) { - env = env.extend_omitted(lv->params[i], arena); - } - return std::nullopt; -} - -EvalContext lambda_body_context(const LambdaValue* lv, const NameEnv& env, const EvalContext& ctx) { - EvalContext body_ctx = ctx.with_name_env(&env); - if (lv->name_scope_sheet >= 0) { - body_ctx = body_ctx.with_name_scope_sheet(lv->name_scope_sheet); - } - return body_ctx; -} - -Value invoke_lambda_values_impl(const LambdaValue* lv, std::uint32_t arity, const Value* args, - const parser::AstNode* const* ast_args, const parser::AstNode* const* syntax_args, - Arena& arena, const FunctionRegistry& registry, const EvalContext& ctx) { - NameEnv env; - if (const std::optional err = bind_lambda_params(lv, arity, args, ast_args, syntax_args, arena, &env)) { - return Value::error(*err); - } - return eval_node(*lv->body, arena, registry, lambda_body_context(lv, env, ctx)); -} - -} // namespace - -Value invoke_lambda_values(const LambdaValue* lv, std::uint32_t arity, const Value* args, Arena& arena, - const FunctionRegistry& registry, const EvalContext& ctx) { - return invoke_lambda_values_with_ast(lv, arity, args, /*ast_args=*/nullptr, arena, registry, ctx); -} - -Value invoke_lambda_values_with_ast(const LambdaValue* lv, std::uint32_t arity, const Value* args, - const parser::AstNode* const* ast_args, Arena& arena, - const FunctionRegistry& registry, const EvalContext& ctx) { - if (lv == nullptr || (arity != 0U && args == nullptr)) { - return Value::error(ErrorCode::Value); - } - // Lambda-depth cap fires before the arity check so a runaway - // self-recursion (e.g. `LAMBDA(n, f(n+1))`) cannot keep extending the - // call stack on its own dime. See `kMaxLambdaDepth` for rationale. - EvalDepthGuard lambda_guard(ctx.lambda_depth_counter(), kMaxLambdaDepth); - if (lambda_guard.exceeded()) { - return Value::error(ErrorCode::Calc); - } - return invoke_lambda_values_impl(lv, arity, args, ast_args, /*syntax_args=*/nullptr, arena, registry, ctx); -} - -namespace { - -// Builds the eta-expansion of the built-in `name` at `arity`: -// `LAMBDA(p1, ..., pn, name(p1, ..., pn))`. The parameter names cannot be -// spelled in a formula, so the body can only ever see its own arguments. -// Returns nullptr on arena exhaustion. -const LambdaValue* eta_expand(std::string_view name, std::uint32_t arity, Arena& arena) { - std::string_view* params = nullptr; - const parser::AstNode** body_args = nullptr; - if (arity > 0U) { - params = arena.create_array(arity); - body_args = arena.create_array(arity); - if (params == nullptr || body_args == nullptr) { - return nullptr; - } - } - for (std::uint32_t i = 0; i < arity; ++i) { - params[i] = arena.intern("@" + std::to_string(i + 1U)); - body_args[i] = parser::make_name_ref(arena, params[i]); - if (params[i].empty() || body_args[i] == nullptr) { - return nullptr; - } - } - const parser::AstNode* body = parser::make_call(arena, name, body_args, arity); - auto* lv = arena.create(); - if (body == nullptr || lv == nullptr) { - return nullptr; - } - lv->params = params; - lv->param_count = arity; - lv->optional_count = 0U; - lv->body = body; - lv->captured_env = nullptr; - return lv; -} - -// A lambda is callable by a helper when it accepts `call_arity` arguments -// (trailing `[optional]` params may stay unbound) and carries a body. -const LambdaValue* check_callable(const LambdaValue* lv, std::uint32_t call_arity, Value* out_err) { - const std::uint32_t required = lv->param_count - lv->optional_count; - if (call_arity < required || call_arity > lv->param_count) { - *out_err = Value::error(ErrorCode::Value); - return nullptr; - } - if (lv->body == nullptr) { - *out_err = Value::error(ErrorCode::Name); - return nullptr; - } - return lv; -} - -} // namespace - -const LambdaValue* resolve_callable(const parser::AstNode& arg, std::uint32_t call_arity, Arena& arena, - const FunctionRegistry& registry, const EvalContext& ctx, Value* out_err) { - // `Sheet1!Fn` is neither a binding nor a built-in; it evaluates below. - if (arg.kind() == parser::NodeKind::NameRef && arg.as_name_sheet().empty()) { - const std::string_view name = arg.as_name(); - const NameEnv* env = ctx.name_env(); - // A name bound in scope shadows any built-in of the same spelling. - const auto read = [&](const parser::AstNode& ref) { return eval_node(ref, arena, registry, ctx); }; - if (const Value* bound = (env != nullptr) ? env->lookup_or_read(name, read) : nullptr; bound != nullptr) { - if (bound->is_error()) { - *out_err = *bound; - return nullptr; - } - if (!bound->is_lambda()) { - *out_err = Value::error(ErrorCode::Value); - return nullptr; - } - return check_callable(bound->as_lambda(), call_arity, out_err); - } - // An unbound built-in name is an eta-reduced lambda. A miss falls through - // to ordinary evaluation, which reports `#NAME?`. - if (registry.lookup(name) != nullptr || find_lazy_impl(strip_future_prefix(name)) != nullptr) { - const LambdaValue* lv = eta_expand(name, call_arity, arena); - if (lv == nullptr) { - *out_err = Value::error(ErrorCode::Num); - } - return lv; - } - } - const Value v = eval_node(arg, arena, registry, ctx); - if (v.is_error()) { - *out_err = v; - return nullptr; - } - if (!v.is_lambda()) { - *out_err = Value::error(ErrorCode::Value); - return nullptr; - } - return check_callable(v.as_lambda(), call_arity, out_err); -} - -Value invoke_lambda(const LambdaValue* lv, std::uint32_t arity, const parser::AstNode* const* call_args, Arena& arena, - const FunctionRegistry& registry, const EvalContext& ctx) { - if (lv == nullptr || (arity != 0U && call_args == nullptr)) { - return Value::error(ErrorCode::Value); - } - // Lambda-depth cap fires before the arity check so a runaway - // self-recursion (e.g. `LAMBDA(n, f(n+1))`) cannot keep extending the - // call stack on its own dime. See `kMaxLambdaDepth` for rationale. - EvalDepthGuard lambda_guard(ctx.lambda_depth_counter(), kMaxLambdaDepth); - if (lambda_guard.exceeded()) { - return Value::error(ErrorCode::Calc); - } - const std::uint32_t required = lv->param_count - lv->optional_count; - if (arity < required || arity > lv->param_count) { - return Value::error(ErrorCode::Value); - } - std::vector args; - std::vector bound_asts; - args.reserve(arity); - bound_asts.reserve(arity); - for (std::uint32_t i = 0; i < arity; ++i) { - const parser::AstNode* bound = nullptr; - args.push_back(eval_binding_source(*call_args[i], arena, registry, ctx, &bound)); - bound_asts.push_back(bound); - } - return invoke_lambda_values_impl(lv, arity, args.empty() ? nullptr : args.data(), - bound_asts.empty() ? nullptr : bound_asts.data(), call_args, arena, registry, ctx); -} - -bool resolve_lambda_reference(const LambdaValue* lv, std::uint32_t arity, const parser::AstNode* const* call_args, - Arena& arena, const FunctionRegistry& registry, const EvalContext& ctx, - std::string_view* out_sheet, std::uint32_t* out_top_row, std::uint32_t* out_left_col, - std::uint32_t* out_bottom_row, std::uint32_t* out_right_col, ErrorCode* out_err) { - if (lv == nullptr || (arity != 0U && call_args == nullptr)) { - *out_err = ErrorCode::Value; - return false; - } - EvalDepthGuard lambda_guard(ctx.lambda_depth_counter(), kMaxLambdaDepth); - if (lambda_guard.exceeded()) { - *out_err = ErrorCode::Calc; - return false; - } - std::vector args; - std::vector bound_asts; - for (std::uint32_t i = 0; i < arity; ++i) { - const parser::AstNode* bound = nullptr; - args.push_back(eval_binding_source(*call_args[i], arena, registry, ctx, &bound)); - bound_asts.push_back(bound); - } - NameEnv env; - if (const std::optional err = - bind_lambda_params(lv, arity, args.empty() ? nullptr : args.data(), - bound_asts.empty() ? nullptr : bound_asts.data(), call_args, arena, &env)) { - *out_err = *err; - return false; - } - return resolve_reference_rect(*lv->body, arena, registry, lambda_body_context(lv, env, ctx), out_sheet, out_top_row, - out_left_col, out_bottom_row, out_right_col, out_err); -} - -namespace { - // One argument slot for element-wise broadcasting of a scalar function // over array-shaped arguments: either a scalar (`scalar != nullptr`) or // an ArrayValue (`array != nullptr`). diff --git a/src/eval/tree_walker/dispatch.h b/src/eval/tree_walker/dispatch.h index 650473448..b519b0b58 100644 --- a/src/eval/tree_walker/dispatch.h +++ b/src/eval/tree_walker/dispatch.h @@ -1,10 +1,10 @@ // // Private seam between the tree-walker's recursive node visitor // (`tree_walker/walker.cpp`) and the function-call dispatch path -// (`tree_walker/dispatch.cpp`). The two translation units were split out +// (`tree_walker/dispatch.cpp`) and the runtime lambda invocation path +// (`tree_walker/lambda_invoke.cpp`). The translation units were split out // of the original monolithic `tree_walker.cpp` to keep compile units -// digestible; this header publishes the two entry points walker.cpp -// needs from dispatch.cpp. +// digestible; this header publishes the entry points walker.cpp needs. // // `dispatch_call` is invoked from `eval_node` for every `Call` AST node: // it handles name-bound lambda dispatch, lazy/special-form routing @@ -13,7 +13,8 @@ // `FunctionDef::impl`. // // `invoke_lambda` is shared between the `LambdaCall` AST case (handled -// in walker.cpp) and the name-bound dispatch path (in dispatch.cpp). +// in walker.cpp) and the name-bound dispatch path (in dispatch.cpp); it is +// defined in lambda_invoke.cpp. // Its already-evaluated-arguments siblings `invoke_lambda_values` / // `invoke_lambda_values_with_ast` are the single lambda-invocation entry // point for the lazy lambda helpers, so the arity and diff --git a/src/eval/tree_walker/lambda_invoke.cpp b/src/eval/tree_walker/lambda_invoke.cpp new file mode 100644 index 000000000..6068025cd --- /dev/null +++ b/src/eval/tree_walker/lambda_invoke.cpp @@ -0,0 +1,271 @@ +// +// Runtime invocation of `LambdaValue`s: parameter binding, the arity and +// lambda-depth rules, callable resolution for the lazy lambda helpers, and +// reference resolution through a lambda body. The public contract is declared +// in `tree_walker/dispatch.h`. + +#include +#include +#include +#include +#include + +#include "eval/eval_context.h" +#include "eval/function_registry.h" +#include "eval/lambda_value.h" +#include "eval/lazy_impls.h" +#include "eval/name_env.h" +#include "eval/omitted_arg.h" +#include "eval/range_resolvers.h" +#include "eval/tree_walker/depth_guard.h" +#include "eval/tree_walker/dispatch.h" +#include "eval/tree_walker_lazy_table.h" +#include "parser/ast.h" +#include "utils/arena.h" +#include "value.h" + +namespace formulon { +namespace eval { + +namespace { + +// `syntax_args` is the call site's argument AST, consulted for one thing +// only: telling a syntactically omitted slot (`f(1, , 3)`) from a supplied +// one. It is deliberately separate from `ast_args`, the per-argument AST +// `eval_binding_source` chose to record on the binding. +// Binds `lv`'s parameters for a call into `*env`; the error a call surfaces +// instead when the arity does not fit or the lambda has no body. +std::optional bind_lambda_params(const LambdaValue* lv, std::uint32_t arity, const Value* args, + const parser::AstNode* const* ast_args, + const parser::AstNode* const* syntax_args, Arena& arena, NameEnv* out) { + const std::uint32_t required = lv->param_count - lv->optional_count; + if (arity < required || arity > lv->param_count) { + return ErrorCode::Value; + } + if (lv->body == nullptr) { + return ErrorCode::Name; + } + NameEnv& env = *out; + if (lv->captured_env != nullptr) { + env = *lv->captured_env; + } + for (std::uint32_t i = 0; i < arity; ++i) { + const parser::AstNode* expr = (ast_args != nullptr) ? ast_args[i] : nullptr; + // `f(1, , 3)` supplies the slot syntactically and omits its value, and + // Excel treats that as omitted wherever it appears -- leading, middle + // or trailing -- not only past the end of the argument list. Binding + // it as an ordinary blank would give the body the right number (an + // omitted slot arithmetic-coerces to 0 either way) while telling + // ISOMITTED the wrong thing, which is the whole of what that function + // is for. Only the AST can answer this: by the time the argument is a + // Value, an omitted slot and a blank cell are the same blank. + const parser::AstNode* syntax = (syntax_args != nullptr) ? syntax_args[i] : expr; + if (syntax != nullptr && is_omitted_arg(*syntax)) { + env = env.extend_omitted(lv->params[i], arena); + continue; + } + env = env.extend(lv->params[i], args[i], expr, arena); + } + for (std::uint32_t i = arity; i < lv->param_count; ++i) { + env = env.extend_omitted(lv->params[i], arena); + } + return std::nullopt; +} + +EvalContext lambda_body_context(const LambdaValue* lv, const NameEnv& env, const EvalContext& ctx) { + EvalContext body_ctx = ctx.with_name_env(&env); + if (lv->name_scope_sheet >= 0) { + body_ctx = body_ctx.with_name_scope_sheet(lv->name_scope_sheet); + } + return body_ctx; +} + +Value invoke_lambda_values_impl(const LambdaValue* lv, std::uint32_t arity, const Value* args, + const parser::AstNode* const* ast_args, const parser::AstNode* const* syntax_args, + Arena& arena, const FunctionRegistry& registry, const EvalContext& ctx) { + NameEnv env; + if (const std::optional err = bind_lambda_params(lv, arity, args, ast_args, syntax_args, arena, &env)) { + return Value::error(*err); + } + return eval_node(*lv->body, arena, registry, lambda_body_context(lv, env, ctx)); +} + +} // namespace + +Value invoke_lambda_values(const LambdaValue* lv, std::uint32_t arity, const Value* args, Arena& arena, + const FunctionRegistry& registry, const EvalContext& ctx) { + return invoke_lambda_values_with_ast(lv, arity, args, /*ast_args=*/nullptr, arena, registry, ctx); +} + +Value invoke_lambda_values_with_ast(const LambdaValue* lv, std::uint32_t arity, const Value* args, + const parser::AstNode* const* ast_args, Arena& arena, + const FunctionRegistry& registry, const EvalContext& ctx) { + if (lv == nullptr || (arity != 0U && args == nullptr)) { + return Value::error(ErrorCode::Value); + } + // Lambda-depth cap fires before the arity check so a runaway + // self-recursion (e.g. `LAMBDA(n, f(n+1))`) cannot keep extending the + // call stack on its own dime. See `kMaxLambdaDepth` for rationale. + EvalDepthGuard lambda_guard(ctx.lambda_depth_counter(), kMaxLambdaDepth); + if (lambda_guard.exceeded()) { + return Value::error(ErrorCode::Calc); + } + return invoke_lambda_values_impl(lv, arity, args, ast_args, /*syntax_args=*/nullptr, arena, registry, ctx); +} + +namespace { + +// Builds the eta-expansion of the built-in `name` at `arity`: +// `LAMBDA(p1, ..., pn, name(p1, ..., pn))`. The parameter names cannot be +// spelled in a formula, so the body can only ever see its own arguments. +// Returns nullptr on arena exhaustion. +const LambdaValue* eta_expand(std::string_view name, std::uint32_t arity, Arena& arena) { + std::string_view* params = nullptr; + const parser::AstNode** body_args = nullptr; + if (arity > 0U) { + params = arena.create_array(arity); + body_args = arena.create_array(arity); + if (params == nullptr || body_args == nullptr) { + return nullptr; + } + } + for (std::uint32_t i = 0; i < arity; ++i) { + params[i] = arena.intern("@" + std::to_string(i + 1U)); + body_args[i] = parser::make_name_ref(arena, params[i]); + if (params[i].empty() || body_args[i] == nullptr) { + return nullptr; + } + } + const parser::AstNode* body = parser::make_call(arena, name, body_args, arity); + auto* lv = arena.create(); + if (body == nullptr || lv == nullptr) { + return nullptr; + } + lv->params = params; + lv->param_count = arity; + lv->optional_count = 0U; + lv->body = body; + lv->captured_env = nullptr; + return lv; +} + +// A lambda is callable by a helper when it accepts `call_arity` arguments +// (trailing `[optional]` params may stay unbound) and carries a body. +const LambdaValue* check_callable(const LambdaValue* lv, std::uint32_t call_arity, Value* out_err) { + const std::uint32_t required = lv->param_count - lv->optional_count; + if (call_arity < required || call_arity > lv->param_count) { + *out_err = Value::error(ErrorCode::Value); + return nullptr; + } + if (lv->body == nullptr) { + *out_err = Value::error(ErrorCode::Name); + return nullptr; + } + return lv; +} + +} // namespace + +const LambdaValue* resolve_callable(const parser::AstNode& arg, std::uint32_t call_arity, Arena& arena, + const FunctionRegistry& registry, const EvalContext& ctx, Value* out_err) { + // `Sheet1!Fn` is neither a binding nor a built-in; it evaluates below. + if (arg.kind() == parser::NodeKind::NameRef && arg.as_name_sheet().empty()) { + const std::string_view name = arg.as_name(); + const NameEnv* env = ctx.name_env(); + // A name bound in scope shadows any built-in of the same spelling. + const auto read = [&](const parser::AstNode& ref) { return eval_node(ref, arena, registry, ctx); }; + if (const Value* bound = (env != nullptr) ? env->lookup_or_read(name, read) : nullptr; bound != nullptr) { + if (bound->is_error()) { + *out_err = *bound; + return nullptr; + } + if (!bound->is_lambda()) { + *out_err = Value::error(ErrorCode::Value); + return nullptr; + } + return check_callable(bound->as_lambda(), call_arity, out_err); + } + // An unbound built-in name is an eta-reduced lambda. A miss falls through + // to ordinary evaluation, which reports `#NAME?`. + if (registry.lookup(name) != nullptr || find_lazy_impl(strip_future_prefix(name)) != nullptr) { + const LambdaValue* lv = eta_expand(name, call_arity, arena); + if (lv == nullptr) { + *out_err = Value::error(ErrorCode::Num); + } + return lv; + } + } + const Value v = eval_node(arg, arena, registry, ctx); + if (v.is_error()) { + *out_err = v; + return nullptr; + } + if (!v.is_lambda()) { + *out_err = Value::error(ErrorCode::Value); + return nullptr; + } + return check_callable(v.as_lambda(), call_arity, out_err); +} + +Value invoke_lambda(const LambdaValue* lv, std::uint32_t arity, const parser::AstNode* const* call_args, Arena& arena, + const FunctionRegistry& registry, const EvalContext& ctx) { + if (lv == nullptr || (arity != 0U && call_args == nullptr)) { + return Value::error(ErrorCode::Value); + } + // Lambda-depth cap fires before the arity check so a runaway + // self-recursion (e.g. `LAMBDA(n, f(n+1))`) cannot keep extending the + // call stack on its own dime. See `kMaxLambdaDepth` for rationale. + EvalDepthGuard lambda_guard(ctx.lambda_depth_counter(), kMaxLambdaDepth); + if (lambda_guard.exceeded()) { + return Value::error(ErrorCode::Calc); + } + const std::uint32_t required = lv->param_count - lv->optional_count; + if (arity < required || arity > lv->param_count) { + return Value::error(ErrorCode::Value); + } + std::vector args; + std::vector bound_asts; + args.reserve(arity); + bound_asts.reserve(arity); + for (std::uint32_t i = 0; i < arity; ++i) { + const parser::AstNode* bound = nullptr; + args.push_back(eval_binding_source(*call_args[i], arena, registry, ctx, &bound)); + bound_asts.push_back(bound); + } + return invoke_lambda_values_impl(lv, arity, args.empty() ? nullptr : args.data(), + bound_asts.empty() ? nullptr : bound_asts.data(), call_args, arena, registry, ctx); +} + +bool resolve_lambda_reference(const LambdaValue* lv, std::uint32_t arity, const parser::AstNode* const* call_args, + Arena& arena, const FunctionRegistry& registry, const EvalContext& ctx, + std::string_view* out_sheet, std::uint32_t* out_top_row, std::uint32_t* out_left_col, + std::uint32_t* out_bottom_row, std::uint32_t* out_right_col, ErrorCode* out_err) { + if (lv == nullptr || (arity != 0U && call_args == nullptr)) { + *out_err = ErrorCode::Value; + return false; + } + EvalDepthGuard lambda_guard(ctx.lambda_depth_counter(), kMaxLambdaDepth); + if (lambda_guard.exceeded()) { + *out_err = ErrorCode::Calc; + return false; + } + std::vector args; + std::vector bound_asts; + for (std::uint32_t i = 0; i < arity; ++i) { + const parser::AstNode* bound = nullptr; + args.push_back(eval_binding_source(*call_args[i], arena, registry, ctx, &bound)); + bound_asts.push_back(bound); + } + NameEnv env; + if (const std::optional err = + bind_lambda_params(lv, arity, args.empty() ? nullptr : args.data(), + bound_asts.empty() ? nullptr : bound_asts.data(), call_args, arena, &env)) { + *out_err = *err; + return false; + } + return resolve_reference_rect(*lv->body, arena, registry, lambda_body_context(lv, env, ctx), out_sheet, out_top_row, + out_left_col, out_bottom_row, out_right_col, out_err); +} + +} // namespace eval +} // namespace formulon diff --git a/src/eval/tree_walker/walker.cpp b/src/eval/tree_walker/walker.cpp index 8031d62aa..ae3854c2e 100644 --- a/src/eval/tree_walker/walker.cpp +++ b/src/eval/tree_walker/walker.cpp @@ -4,11 +4,12 @@ // linkage in `eval/lazy_impls.h` so lazy-impl TUs can recurse back into // it), and the read-only spill-collision detector. // -// The function-call dispatch path (`dispatch_call`, `invoke_lambda`, -// range-argument expansion) lives in `tree_walker/dispatch.cpp`; the +// The function-call dispatch path (`dispatch_call`, range-argument +// expansion) lives in `tree_walker/dispatch.cpp`, the runtime lambda +// invocation (`invoke_lambda`) in `tree_walker/lambda_invoke.cpp`; the // array-broadcasting helpers (`broadcast_binop`, `broadcast_unary`, // `apply_binop_per_cell`) live in `tree_walker/broadcast.cpp`. The -// three TUs split the original monolithic `tree_walker.cpp` while +// TUs split the original monolithic `tree_walker.cpp` while // preserving file-local helpers and the existing `formulon::eval` // namespace shape (anonymous helpers are local to each TU). // From ed6a6a23586e872d37f4d4157af83b9a081b71cd Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:04:05 +0900 Subject: [PATCH 06/93] refactor(eval): split FORECAST.ETS series prep and Holt-Winters fit out of forecast_ets_lazy - move series sorting/aggregation, step detection, grid resampling and seasonality detection into ets_series.{h,cpp} - move the Holt-Winters smoothing fit into holt_winters.{h,cpp} - expose Series, AggregationMode, sort_and_aggregate, median_delta, resample_to_grid, detect_seasonality, HoltWintersFit and fit_holt_winters in namespace formulon::eval::ets - forecast_ets_lazy.cpp keeps argument reading, preprocessing and the four impls; register the new TUs in CMakeLists.txt - behavior-preserving move --- CMakeLists.txt | 2 + src/eval/ets_series.cpp | 355 +++++++++++++ src/eval/ets_series.h | 54 ++ src/eval/forecast_ets_lazy.cpp | 920 +-------------------------------- src/eval/forecast_ets_lazy.h | 6 +- src/eval/holt_winters.cpp | 554 ++++++++++++++++++++ src/eval/holt_winters.h | 49 ++ 7 files changed, 1030 insertions(+), 910 deletions(-) create mode 100644 src/eval/ets_series.cpp create mode 100644 src/eval/ets_series.h create mode 100644 src/eval/holt_winters.cpp create mode 100644 src/eval/holt_winters.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 0294e71a9..2d3718b81 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -338,6 +338,8 @@ src/eval/aggregate_lazy.cpp src/eval/workdays_lazy.cpp src/eval/financial_lazy.cpp src/eval/forecast_ets_lazy.cpp + src/eval/ets_series.cpp + src/eval/holt_winters.cpp src/eval/getpivotdata_lazy.cpp src/eval/linest_lazy.cpp src/eval/textjoin_lazy.cpp diff --git a/src/eval/ets_series.cpp b/src/eval/ets_series.cpp new file mode 100644 index 000000000..cf06a94ad --- /dev/null +++ b/src/eval/ets_series.cpp @@ -0,0 +1,355 @@ +// +// Series preparation for the FORECAST.ETS family; the contract is declared in +// `eval/ets_series.h`. + +#include "eval/ets_series.h" + +#include +#include +#include +#include +#include + +#include "utils/error.h" +#include "utils/index_sort.h" +#include "value.h" + +namespace formulon { +namespace eval { +namespace ets { +namespace { + +// Step-size deviation tolerance for the resample grid. A consecutive delta-t +// is required to fall within +/-`kStepDeviation` of the median step +// (Excel's documented +/-30% tolerance). ORACLE-PENDING. +constexpr double kStepDeviation = 0.3; + +// Cap on autocorrelation lags inspected by the seasonality detector. Excel +// allows a maximum seasonality of 8760 (hours in a year), but for the ACF +// scan the detection ceiling is 52 (weeks per year) which matches the +// typical "weekly seasonality on weekly data" calibration. ORACLE-PENDING. +constexpr std::uint32_t kMaxSeasonalityDetectLag = 52U; + +// Minimum |ACF| magnitude required to declare a detected seasonality. +// Below this the auto-detector returns m = 1. ORACLE-PENDING. +constexpr double kAcfDetectionThreshold = 0.3; + +// Relative floor on the linear-trend residual below which the detrended +// series is treated as pure rounding noise and no period is reported. The +// comparison is against the raw mean-centred variance, so it scales with +// the data instead of assuming a magnitude. +constexpr double kDetrendedResidualEpsilon = 1e-20; + +// --------------------------------------------------------------------------- +// Aggregation +// --------------------------------------------------------------------------- + +// Aggregates a run `[begin, end)` of identical-timeline points using the +// requested mode. The COUNT / COUNTA modes return the run length (COUNT +// excludes blanks but blanks have already been rejected at the strict- +// numeric step, so the two modes coincide here -- both return the count). +double aggregate_run(const Series& src, std::size_t begin, std::size_t end, AggregationMode mode) { + const std::size_t n = end - begin; + if (n == 0U) { + return 0.0; + } + if (n == 1U) { + // Optimisation: a singleton run with any mode collapses to its + // single sample (COUNT/COUNTA collapse to 1, but a singleton is + // already its own answer for 5 of the 7 modes; we special-case + // COUNT/COUNTA below). + if (mode == AggregationMode::kCount || mode == AggregationMode::kCountA) { + return 1.0; + } + return src.y[begin]; + } + switch (mode) { + case AggregationMode::kCount: + case AggregationMode::kCountA: + return static_cast(n); + case AggregationMode::kSum: { + double s = 0.0; + for (std::size_t i = begin; i < end; ++i) + s += src.y[i]; + return s; + } + case AggregationMode::kMax: { + double m = src.y[begin]; + for (std::size_t i = begin + 1U; i < end; ++i) { + if (src.y[i] > m) + m = src.y[i]; + } + return m; + } + case AggregationMode::kMin: { + double m = src.y[begin]; + for (std::size_t i = begin + 1U; i < end; ++i) { + if (src.y[i] < m) + m = src.y[i]; + } + return m; + } + case AggregationMode::kMedian: { + std::vector tmp(src.y.begin() + static_cast(begin), + src.y.begin() + static_cast(end)); + std::sort(tmp.begin(), tmp.end()); + const std::size_t mid = tmp.size() / 2U; + if ((tmp.size() & 1U) == 1U) { + return tmp[mid]; + } + return 0.5 * (tmp[mid - 1U] + tmp[mid]); + } + case AggregationMode::kAverage: + default: { + double s = 0.0; + for (std::size_t i = begin; i < end; ++i) + s += src.y[i]; + return s / static_cast(n); + } + } +} + +} // namespace + +// Sorts `src` by t (stable) and collapses runs of identical t via `mode`. +// Returns the new (deduped, sorted) series. +Series sort_and_aggregate(const Series& src, AggregationMode mode) { + const std::size_t n = src.t.size(); + const auto by_t = [&src](std::uint32_t a, std::uint32_t b) { return src.t[a] < src.t[b]; }; + std::vector idx; + sorted_index_order(idx, static_cast(n), make_index_less(by_t)); + Series sorted; + sorted.t.reserve(n); + sorted.y.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + sorted.t.push_back(src.t[idx[i]]); + sorted.y.push_back(src.y[idx[i]]); + } + // Aggregate consecutive runs with identical t. + Series out; + out.t.reserve(sorted.t.size()); + out.y.reserve(sorted.y.size()); + std::size_t i = 0; + while (i < sorted.t.size()) { + std::size_t j = i + 1U; + while (j < sorted.t.size() && sorted.t[j] == sorted.t[i]) { + ++j; + } + out.t.push_back(sorted.t[i]); + out.y.push_back(aggregate_run(sorted, i, j, mode)); + i = j; + } + return out; +} + +// --------------------------------------------------------------------------- +// Step detection and resampling +// --------------------------------------------------------------------------- + +// Computes the median of consecutive deltas in a sorted timeline. Caller +// guarantees `series.t.size() >= 2`. +double median_delta(const std::vector& t) { + std::vector deltas; + deltas.reserve(t.size() - 1U); + for (std::size_t i = 1; i < t.size(); ++i) { + deltas.push_back(t[i] - t[i - 1U]); + } + std::sort(deltas.begin(), deltas.end()); + const std::size_t mid = deltas.size() / 2U; + if ((deltas.size() & 1U) == 1U) { + return deltas[mid]; + } + return 0.5 * (deltas[mid - 1U] + deltas[mid]); +} + +// Resamples a sorted, deduplicated `series` onto an evenly spaced grid +// with step `step`. The first point is taken as the grid origin; for +// each later grid index `k = round((t_i - t_0) / step)` the value is +// the corresponding y. Missing slots are then filled per +// `data_completion`: 0 -> zero-fill, 1 -> linear interpolation between +// the nearest known neighbours. +// +// On step / regularity failures writes `#NUM!` into `*out_err` and +// returns `false`; otherwise writes the resampled series into `*out`. +bool resample_to_grid(const Series& sorted, double step, int data_completion, Series* out, Value* out_err) { + if (step <= 0.0 || !std::isfinite(step)) { + *out_err = Value::error(ErrorCode::Num); + return false; + } + const double t0 = sorted.t.front(); + const double t_last = sorted.t.back(); + // Compute the integer index of the last point. A negative or non-finite + // span is impossible after sorting, but the round-then-cast guard below + // copes with floating-point jitter. + const double span = t_last - t0; + const double last_idx_d = std::round(span / step); + if (!std::isfinite(last_idx_d) || last_idx_d < 0.0) { + *out_err = Value::error(ErrorCode::Num); + return false; + } + const std::size_t grid_size = static_cast(last_idx_d) + 1U; + // Defensive cap: a grid size > 2^20 would mean the timeline spans >1M + // steps, which is well beyond Excel's practical limits and almost + // certainly indicates a step-detection failure. + if (grid_size > (1U << 20U)) { + *out_err = Value::error(ErrorCode::Num); + return false; + } + std::vector grid_y(grid_size, 0.0); + std::vector filled(grid_size, false); + for (std::size_t i = 0; i < sorted.t.size(); ++i) { + const double rel = (sorted.t[i] - t0) / step; + const double rounded = std::round(rel); + // Regularity check: every input must land within +/-30% of an + // integer grid index when measured in step units. + const double resid = std::fabs(rel - rounded); + if (resid > kStepDeviation) { + *out_err = Value::error(ErrorCode::Num); + return false; + } + const std::size_t k = static_cast(rounded); + if (k >= grid_size) { + *out_err = Value::error(ErrorCode::Num); + return false; + } + if (filled[k]) { + // Two distinct timeline values mapping to the same grid index + // means the +/-30% tolerance was insufficient; treat as a + // step-detection failure. + *out_err = Value::error(ErrorCode::Num); + return false; + } + grid_y[k] = sorted.y[i]; + filled[k] = true; + } + + if (data_completion == 0) { + // Zero-fill: missing slots already initialised to 0.0; nothing + // further to do. + } else { + // Linear interpolation between known neighbours. The endpoints are + // always filled (the first sample lives at index 0; the last lives + // at index grid_size - 1) by construction. + std::size_t i = 0; + while (i < grid_size) { + if (filled[i]) { + ++i; + continue; + } + // Find the previous filled index (must exist; first cell is + // filled by construction). + std::size_t prev = i; + while (prev > 0U && !filled[prev]) + --prev; + // Find the next filled index (must exist; last cell is filled). + std::size_t next = i; + while (next < grid_size && !filled[next]) + ++next; + if (next >= grid_size) { + // No right anchor -- defensive; shouldn't happen since the last + // cell is always filled. Treat as a regularity failure. + *out_err = Value::error(ErrorCode::Num); + return false; + } + const double y_prev = grid_y[prev]; + const double y_next = grid_y[next]; + for (std::size_t k = prev + 1U; k < next; ++k) { + const double frac = static_cast(k - prev) / static_cast(next - prev); + grid_y[k] = y_prev + frac * (y_next - y_prev); + filled[k] = true; + } + i = next; + } + } + + out->t.resize(grid_size); + out->y.resize(grid_size); + for (std::size_t i = 0; i < grid_size; ++i) { + out->t[i] = t0 + static_cast(i) * step; + out->y[i] = grid_y[i]; + } + return true; +} + +// --------------------------------------------------------------------------- +// Seasonality detection (autocorrelation) +// --------------------------------------------------------------------------- + +// Returns the auto-detected seasonality length (>= 1). 1 means "no +// seasonality" -- both for series too short to scan and for series +// where no lag clears the threshold. +std::uint32_t detect_seasonality(const std::vector& y) { + const std::size_t n = y.size(); + if (n < 4U) { + return 1U; + } + // The autocorrelation scan runs on the linear-trend residual, not on the + // raw series. A trending series is non-stationary: every lag of a plain + // ramp autocorrelates near 1, so an un-detrended scan reports a period + // for data that has none. Ordinary least squares against the sample + // index is enough here because the resample grid is equally spaced by + // construction, which lets the regression sums close in one pass. + const double count = static_cast(n); + double sum_y = 0.0; + double sum_iy = 0.0; + for (std::size_t i = 0; i < n; ++i) { + sum_y += y[i]; + sum_iy += static_cast(i) * y[i]; + } + const double mean_y = sum_y / count; + const double mean_i = (count - 1.0) / 2.0; + // sum (i - mean_i)^2 over 0..n-1 in closed form. + const double sxx = count * (count * count - 1.0) / 12.0; + const double sxy = sum_iy - mean_i * sum_y; + const double slope = (sxx > 0.0) ? (sxy / sxx) : 0.0; + + std::vector resid(n); + double var = 0.0; + double raw_var = 0.0; + for (std::size_t i = 0; i < n; ++i) { + const double fitted = mean_y + slope * (static_cast(i) - mean_i); + resid[i] = y[i] - fitted; + var += resid[i] * resid[i]; + const double centered = y[i] - mean_y; + raw_var += centered * centered; + } + // A residual that is pure rounding noise carries no seasonal signal, so + // compare it against the scale of the original series rather than to an + // exact zero: a perfect ramp leaves residuals around 1e-15, whose + // autocorrelations are otherwise arbitrary and clear the threshold. + if (raw_var == 0.0 || var <= raw_var * kDetrendedResidualEpsilon) { + return 1U; + } + const std::uint32_t max_lag_u = std::min(static_cast(n / 2U), kMaxSeasonalityDetectLag); + if (max_lag_u < 2U) { + return 1U; + } + // The residual is mean-zero by construction, so the centring term drops + // out of the autocorrelation numerator. + // + // Only positive autocorrelation counts. A seasonality is a repetition, + // and a negative correlation at lag k says the residual flips sign every + // k steps — the repetition is at 2k, not k. Ranking by magnitude would + // let that anti-correlated lag outrank the real period. + double best_acf = 0.0; + std::uint32_t best_lag = 1U; + for (std::uint32_t k = 2U; k <= max_lag_u; ++k) { + double num = 0.0; + for (std::size_t i = k; i < n; ++i) { + num += resid[i] * resid[i - k]; + } + const double r = num / var; + if (r > best_acf) { + best_acf = r; + best_lag = k; + } + } + if (best_acf >= kAcfDetectionThreshold) { + return best_lag; + } + return 1U; +} + +} // namespace ets +} // namespace eval +} // namespace formulon diff --git a/src/eval/ets_series.h b/src/eval/ets_series.h new file mode 100644 index 000000000..46a38cad6 --- /dev/null +++ b/src/eval/ets_series.h @@ -0,0 +1,54 @@ +// +// Series preparation shared by the FORECAST.ETS family: pairing, ordering and +// aggregating the (timeline, value) samples, resampling them onto an evenly +// spaced grid, and detecting the seasonality length. + +#ifndef FORMULON_EVAL_ETS_SERIES_H_ +#define FORMULON_EVAL_ETS_SERIES_H_ + +#include +#include + +#include "value.h" + +namespace formulon { +namespace eval { +namespace ets { + +/// Excel's documented aggregation enumeration for repeated timeline entries. + +enum class AggregationMode : std::uint32_t { + kAverage = 1U, + kCount = 2U, + kCountA = 3U, + kMax = 4U, + kMedian = 5U, + kMin = 6U, + kSum = 7U, +}; + +/// Parallel (t, y) sample vectors. +struct Series { + std::vector t; + std::vector y; +}; + +/// Stable-sorts `src` by t and collapses runs of identical t via `mode`. +Series sort_and_aggregate(const Series& src, AggregationMode mode); + +/// Median of consecutive deltas in a sorted timeline of at least two points. +double median_delta(const std::vector& t); + +/// Resamples a sorted, deduplicated series onto a grid of spacing `step`, +/// filling gaps per `data_completion` (0 zero-fill, 1 linear interpolation). +/// Writes `#NUM!` to `*out_err` and returns false on an irregular timeline. +bool resample_to_grid(const Series& sorted, double step, int data_completion, Series* out, Value* out_err); + +/// Auto-detected seasonality length (>= 1); 1 means no seasonality. +std::uint32_t detect_seasonality(const std::vector& y); + +} // namespace ets +} // namespace eval +} // namespace formulon + +#endif // FORMULON_EVAL_ETS_SERIES_H_ diff --git a/src/eval/forecast_ets_lazy.cpp b/src/eval/forecast_ets_lazy.cpp index ee565d8ee..e234ee1ce 100644 --- a/src/eval/forecast_ets_lazy.cpp +++ b/src/eval/forecast_ets_lazy.cpp @@ -1,8 +1,9 @@ // // Implementation of the FORECAST.ETS family lazy impls. // -// All four functions share a single Holt-Winters additive triple-exponential -// smoothing core implemented in `HoltWintersFit`. The pipeline per call is: +// All four functions share a Holt-Winters additive triple-exponential +// smoothing core (`eval/holt_winters.h`) over a series prepared by +// `eval/ets_series.h`. The pipeline per call is: // // 1. Materialise both array arguments (`values`, `timeline`) via // `eval_node_as_array` so RangeOp / Ref / ArrayLiteral / arithmetic @@ -38,25 +39,23 @@ #include "eval/forecast_ets_lazy.h" -#include -#include #include #include #include -#include #include #include #include "eval/builtins/stats/stats_helpers.h" #include "eval/coerce.h" +#include "eval/ets_series.h" #include "eval/eval_context.h" +#include "eval/holt_winters.h" #include "eval/lazy_impls.h" #include "eval/range_args.h" #include "eval/shape_ops_lazy.h" #include "parser/ast.h" #include "utils/arena.h" #include "utils/error.h" -#include "utils/index_sort.h" #include "value.h" namespace formulon { @@ -67,54 +66,18 @@ namespace { // Constants // --------------------------------------------------------------------------- -// Step-size deviation tolerance for the resample grid. A consecutive delta-t -// is required to fall within +/-`kStepDeviation` of the median step -// (Excel's documented +/-30% tolerance). ORACLE-PENDING. -constexpr double kStepDeviation = 0.3; - -// Cap on autocorrelation lags inspected by the seasonality detector. Excel -// allows a maximum seasonality of 8760 (hours in a year), but for the ACF -// scan the detection ceiling is 52 (weeks per year) which matches the -// typical "weekly seasonality on weekly data" calibration. ORACLE-PENDING. -constexpr std::uint32_t kMaxSeasonalityDetectLag = 52U; - // Maximum manually-specified seasonality. Above this the third argument // is rejected with `#NUM!` (matches Excel's documented 8760 limit). constexpr std::uint32_t kMaxSeasonalityArg = 8760U; -// Minimum |ACF| magnitude required to declare a detected seasonality. -// Below this the auto-detector returns m = 1. ORACLE-PENDING. -constexpr double kAcfDetectionThreshold = 0.3; - -// Relative floor on the linear-trend residual below which the detrended -// series is treated as pure rounding noise and no period is reported. The -// comparison is against the raw mean-centred variance, so it scales with -// the data instead of assuming a magnitude. -constexpr double kDetrendedResidualEpsilon = 1e-20; - -// Nelder-Mead bounds clamp -- coordinates are squeezed into -// (kBoundEps, 1 - kBoundEps) so the gradient never escapes the unit -// cube and the smoothing recurrences stay well-defined. -constexpr double kBoundEps = 1e-6; - -// Nelder-Mead convergence: stop when (fmax - fmin) over the simplex falls -// below this, or when iteration count exceeds kMaxNelderMeadIters. -constexpr double kNelderMeadFTol = 1e-9; -constexpr int kMaxNelderMeadIters = 200; - -// --------------------------------------------------------------------------- -// Aggregation modes (Excel's documented enumeration) -// --------------------------------------------------------------------------- - -enum class AggregationMode : std::uint32_t { - kAverage = 1U, - kCount = 2U, - kCountA = 3U, - kMax = 4U, - kMedian = 5U, - kMin = 6U, - kSum = 7U, -}; +using ets::AggregationMode; +using ets::detect_seasonality; +using ets::fit_holt_winters; +using ets::HoltWintersFit; +using ets::median_delta; +using ets::resample_to_grid; +using ets::Series; +using ets::sort_and_aggregate; // --------------------------------------------------------------------------- // Array materialisation + strict numeric coercion @@ -148,863 +111,6 @@ bool coerce_strict_numeric(const Value& v, double* out, Value* out_err) { return false; } -// --------------------------------------------------------------------------- -// (t, y) pair container shared across the helpers -// --------------------------------------------------------------------------- - -struct Series { - std::vector t; - std::vector y; -}; - -// --------------------------------------------------------------------------- -// Aggregation -// --------------------------------------------------------------------------- - -// Aggregates a run `[begin, end)` of identical-timeline points using the -// requested mode. The COUNT / COUNTA modes return the run length (COUNT -// excludes blanks but blanks have already been rejected at the strict- -// numeric step, so the two modes coincide here -- both return the count). -double aggregate_run(const Series& src, std::size_t begin, std::size_t end, AggregationMode mode) { - const std::size_t n = end - begin; - if (n == 0U) { - return 0.0; - } - if (n == 1U) { - // Optimisation: a singleton run with any mode collapses to its - // single sample (COUNT/COUNTA collapse to 1, but a singleton is - // already its own answer for 5 of the 7 modes; we special-case - // COUNT/COUNTA below). - if (mode == AggregationMode::kCount || mode == AggregationMode::kCountA) { - return 1.0; - } - return src.y[begin]; - } - switch (mode) { - case AggregationMode::kCount: - case AggregationMode::kCountA: - return static_cast(n); - case AggregationMode::kSum: { - double s = 0.0; - for (std::size_t i = begin; i < end; ++i) - s += src.y[i]; - return s; - } - case AggregationMode::kMax: { - double m = src.y[begin]; - for (std::size_t i = begin + 1U; i < end; ++i) { - if (src.y[i] > m) - m = src.y[i]; - } - return m; - } - case AggregationMode::kMin: { - double m = src.y[begin]; - for (std::size_t i = begin + 1U; i < end; ++i) { - if (src.y[i] < m) - m = src.y[i]; - } - return m; - } - case AggregationMode::kMedian: { - std::vector tmp(src.y.begin() + static_cast(begin), - src.y.begin() + static_cast(end)); - std::sort(tmp.begin(), tmp.end()); - const std::size_t mid = tmp.size() / 2U; - if ((tmp.size() & 1U) == 1U) { - return tmp[mid]; - } - return 0.5 * (tmp[mid - 1U] + tmp[mid]); - } - case AggregationMode::kAverage: - default: { - double s = 0.0; - for (std::size_t i = begin; i < end; ++i) - s += src.y[i]; - return s / static_cast(n); - } - } -} - -// Sorts `src` by t (stable) and collapses runs of identical t via `mode`. -// Returns the new (deduped, sorted) series. -Series sort_and_aggregate(const Series& src, AggregationMode mode) { - const std::size_t n = src.t.size(); - const auto by_t = [&src](std::uint32_t a, std::uint32_t b) { return src.t[a] < src.t[b]; }; - std::vector idx; - sorted_index_order(idx, static_cast(n), make_index_less(by_t)); - Series sorted; - sorted.t.reserve(n); - sorted.y.reserve(n); - for (std::size_t i = 0; i < n; ++i) { - sorted.t.push_back(src.t[idx[i]]); - sorted.y.push_back(src.y[idx[i]]); - } - // Aggregate consecutive runs with identical t. - Series out; - out.t.reserve(sorted.t.size()); - out.y.reserve(sorted.y.size()); - std::size_t i = 0; - while (i < sorted.t.size()) { - std::size_t j = i + 1U; - while (j < sorted.t.size() && sorted.t[j] == sorted.t[i]) { - ++j; - } - out.t.push_back(sorted.t[i]); - out.y.push_back(aggregate_run(sorted, i, j, mode)); - i = j; - } - return out; -} - -// --------------------------------------------------------------------------- -// Step detection and resampling -// --------------------------------------------------------------------------- - -// Computes the median of consecutive deltas in a sorted timeline. Caller -// guarantees `series.t.size() >= 2`. -double median_delta(const std::vector& t) { - std::vector deltas; - deltas.reserve(t.size() - 1U); - for (std::size_t i = 1; i < t.size(); ++i) { - deltas.push_back(t[i] - t[i - 1U]); - } - std::sort(deltas.begin(), deltas.end()); - const std::size_t mid = deltas.size() / 2U; - if ((deltas.size() & 1U) == 1U) { - return deltas[mid]; - } - return 0.5 * (deltas[mid - 1U] + deltas[mid]); -} - -// Resamples a sorted, deduplicated `series` onto an evenly spaced grid -// with step `step`. The first point is taken as the grid origin; for -// each later grid index `k = round((t_i - t_0) / step)` the value is -// the corresponding y. Missing slots are then filled per -// `data_completion`: 0 -> zero-fill, 1 -> linear interpolation between -// the nearest known neighbours. -// -// On step / regularity failures writes `#NUM!` into `*out_err` and -// returns `false`; otherwise writes the resampled series into `*out`. -bool resample_to_grid(const Series& sorted, double step, int data_completion, Series* out, Value* out_err) { - if (step <= 0.0 || !std::isfinite(step)) { - *out_err = Value::error(ErrorCode::Num); - return false; - } - const double t0 = sorted.t.front(); - const double t_last = sorted.t.back(); - // Compute the integer index of the last point. A negative or non-finite - // span is impossible after sorting, but the round-then-cast guard below - // copes with floating-point jitter. - const double span = t_last - t0; - const double last_idx_d = std::round(span / step); - if (!std::isfinite(last_idx_d) || last_idx_d < 0.0) { - *out_err = Value::error(ErrorCode::Num); - return false; - } - const std::size_t grid_size = static_cast(last_idx_d) + 1U; - // Defensive cap: a grid size > 2^20 would mean the timeline spans >1M - // steps, which is well beyond Excel's practical limits and almost - // certainly indicates a step-detection failure. - if (grid_size > (1U << 20U)) { - *out_err = Value::error(ErrorCode::Num); - return false; - } - std::vector grid_y(grid_size, 0.0); - std::vector filled(grid_size, false); - for (std::size_t i = 0; i < sorted.t.size(); ++i) { - const double rel = (sorted.t[i] - t0) / step; - const double rounded = std::round(rel); - // Regularity check: every input must land within +/-30% of an - // integer grid index when measured in step units. - const double resid = std::fabs(rel - rounded); - if (resid > kStepDeviation) { - *out_err = Value::error(ErrorCode::Num); - return false; - } - const std::size_t k = static_cast(rounded); - if (k >= grid_size) { - *out_err = Value::error(ErrorCode::Num); - return false; - } - if (filled[k]) { - // Two distinct timeline values mapping to the same grid index - // means the +/-30% tolerance was insufficient; treat as a - // step-detection failure. - *out_err = Value::error(ErrorCode::Num); - return false; - } - grid_y[k] = sorted.y[i]; - filled[k] = true; - } - - if (data_completion == 0) { - // Zero-fill: missing slots already initialised to 0.0; nothing - // further to do. - } else { - // Linear interpolation between known neighbours. The endpoints are - // always filled (the first sample lives at index 0; the last lives - // at index grid_size - 1) by construction. - std::size_t i = 0; - while (i < grid_size) { - if (filled[i]) { - ++i; - continue; - } - // Find the previous filled index (must exist; first cell is - // filled by construction). - std::size_t prev = i; - while (prev > 0U && !filled[prev]) - --prev; - // Find the next filled index (must exist; last cell is filled). - std::size_t next = i; - while (next < grid_size && !filled[next]) - ++next; - if (next >= grid_size) { - // No right anchor -- defensive; shouldn't happen since the last - // cell is always filled. Treat as a regularity failure. - *out_err = Value::error(ErrorCode::Num); - return false; - } - const double y_prev = grid_y[prev]; - const double y_next = grid_y[next]; - for (std::size_t k = prev + 1U; k < next; ++k) { - const double frac = static_cast(k - prev) / static_cast(next - prev); - grid_y[k] = y_prev + frac * (y_next - y_prev); - filled[k] = true; - } - i = next; - } - } - - out->t.resize(grid_size); - out->y.resize(grid_size); - for (std::size_t i = 0; i < grid_size; ++i) { - out->t[i] = t0 + static_cast(i) * step; - out->y[i] = grid_y[i]; - } - return true; -} - -// --------------------------------------------------------------------------- -// Seasonality detection (autocorrelation) -// --------------------------------------------------------------------------- - -// Returns the auto-detected seasonality length (>= 1). 1 means "no -// seasonality" -- both for series too short to scan and for series -// where no lag clears the threshold. -std::uint32_t detect_seasonality(const std::vector& y) { - const std::size_t n = y.size(); - if (n < 4U) { - return 1U; - } - // The autocorrelation scan runs on the linear-trend residual, not on the - // raw series. A trending series is non-stationary: every lag of a plain - // ramp autocorrelates near 1, so an un-detrended scan reports a period - // for data that has none. Ordinary least squares against the sample - // index is enough here because the resample grid is equally spaced by - // construction, which lets the regression sums close in one pass. - const double count = static_cast(n); - double sum_y = 0.0; - double sum_iy = 0.0; - for (std::size_t i = 0; i < n; ++i) { - sum_y += y[i]; - sum_iy += static_cast(i) * y[i]; - } - const double mean_y = sum_y / count; - const double mean_i = (count - 1.0) / 2.0; - // sum (i - mean_i)^2 over 0..n-1 in closed form. - const double sxx = count * (count * count - 1.0) / 12.0; - const double sxy = sum_iy - mean_i * sum_y; - const double slope = (sxx > 0.0) ? (sxy / sxx) : 0.0; - - std::vector resid(n); - double var = 0.0; - double raw_var = 0.0; - for (std::size_t i = 0; i < n; ++i) { - const double fitted = mean_y + slope * (static_cast(i) - mean_i); - resid[i] = y[i] - fitted; - var += resid[i] * resid[i]; - const double centered = y[i] - mean_y; - raw_var += centered * centered; - } - // A residual that is pure rounding noise carries no seasonal signal, so - // compare it against the scale of the original series rather than to an - // exact zero: a perfect ramp leaves residuals around 1e-15, whose - // autocorrelations are otherwise arbitrary and clear the threshold. - if (raw_var == 0.0 || var <= raw_var * kDetrendedResidualEpsilon) { - return 1U; - } - const std::uint32_t max_lag_u = std::min(static_cast(n / 2U), kMaxSeasonalityDetectLag); - if (max_lag_u < 2U) { - return 1U; - } - // The residual is mean-zero by construction, so the centring term drops - // out of the autocorrelation numerator. - // - // Only positive autocorrelation counts. A seasonality is a repetition, - // and a negative correlation at lag k says the residual flips sign every - // k steps — the repetition is at 2k, not k. Ranking by magnitude would - // let that anti-correlated lag outrank the real period. - double best_acf = 0.0; - std::uint32_t best_lag = 1U; - for (std::uint32_t k = 2U; k <= max_lag_u; ++k) { - double num = 0.0; - for (std::size_t i = k; i < n; ++i) { - num += resid[i] * resid[i - k]; - } - const double r = num / var; - if (r > best_acf) { - best_acf = r; - best_lag = k; - } - } - if (best_acf >= kAcfDetectionThreshold) { - return best_lag; - } - return 1U; -} - -// --------------------------------------------------------------------------- -// Holt-Winters fit -// --------------------------------------------------------------------------- - -struct HoltWintersFit { - // Smoothing parameters fitted by Nelder-Mead. `gamma` is exactly 0.0 - // when the model is non-seasonal (m == 1). - double alpha = 0.0; - double beta = 0.0; - double gamma = 0.0; - // Final state at the end of the in-sample series. - double level = 0.0; - double trend = 0.0; - std::vector season; // length m (or empty when m == 1) - std::uint32_t m = 1U; - // In-sample fit diagnostics. `residuals` has the same length as the - // resampled series; the first m entries (or the first 1 when m == 1) - // are zero because no one-step-ahead forecast is defined there. - std::vector residuals; - double mae = 0.0; - double rmse = 0.0; - double mase = 0.0; - double smape = 0.0; -}; - -// Runs the Holt-Winters smoothing recurrences over `y` with explicit -// initial state (`init_level`, `init_trend`, `init_season[0..m-1]`), -// populates `*out` (level / trend / season / residuals), and returns the -// in-sample SSE used by the optimiser. -// -// Recurrences (additive seasonality): -// L_t = alpha (y_t - S_{t-m}) + (1-alpha)(L_{t-1} + B_{t-1}) -// B_t = beta (L_t - L_{t-1}) + (1-beta) B_{t-1} -// S_t = gamma (y_t - L_t) + (1-gamma) S_{t-m} -// Forecast residual at step t is `y_t - (L_{t-1} + B_{t-1} + S_{t-m})`. -// -// `init_season` may be null when `m == 1`. -double simulate_with_init(const std::vector& y, std::uint32_t m, double alpha, double beta, double gamma, - double init_level, double init_trend, const double* init_season, HoltWintersFit* out) { - const std::size_t n = y.size(); - double level = init_level; - double trend = init_trend; - std::vector season(m == 1U ? 0U : static_cast(m), 0.0); - if (m != 1U && init_season != nullptr) { - for (std::uint32_t i = 0; i < m; ++i) { - season[i] = init_season[i]; - } - } - - std::vector residuals(n, 0.0); - double sse = 0.0; - // The first `start` indices have no one-step-ahead forecast (no prior - // level / trend / seasonal estimate is available beneath them). - const std::size_t start = (m == 1U) ? 1U : static_cast(m); - for (std::size_t t = start; t < n; ++t) { - const double prev_level = level; - const double prev_trend = trend; - const double s_prev = (m == 1U) ? 0.0 : season[t % m]; - const double y_hat = prev_level + prev_trend + s_prev; - const double err = y[t] - y_hat; - residuals[t] = err; - sse += err * err; - // Update equations. - level = alpha * (y[t] - s_prev) + (1.0 - alpha) * (prev_level + prev_trend); - trend = beta * (level - prev_level) + (1.0 - beta) * prev_trend; - if (m != 1U) { - season[t % m] = gamma * (y[t] - level) + (1.0 - gamma) * s_prev; - } - } - - if (out != nullptr) { - out->alpha = alpha; - out->beta = beta; - out->gamma = (m == 1U) ? 0.0 : gamma; - out->level = level; - out->trend = trend; - out->season = season; - out->m = m; - out->residuals = std::move(residuals); - } - return sse; -} - -// Computes the textbook two-season-means default initial state used as the -// starting point of the optimiser. With `m == 1` we use Holt's double -// exponential: L_0 = y_0, B_0 = y_1 - y_0 (no seasonal vector). -void compute_default_init(const std::vector& y, std::uint32_t m, double* out_level, double* out_trend, - std::vector* out_season) { - const std::size_t n = y.size(); - out_season->assign(m == 1U ? 0U : static_cast(m), 0.0); - if (m == 1U) { - *out_level = y[0]; - *out_trend = (n >= 2U) ? (y[1] - y[0]) : 0.0; - return; - } - double mean1 = 0.0; - for (std::uint32_t i = 0; i < m; ++i) - mean1 += y[i]; - mean1 /= static_cast(m); - double mean2 = mean1; - if (n >= 2U * static_cast(m)) { - mean2 = 0.0; - for (std::uint32_t i = 0; i < m; ++i) - mean2 += y[m + i]; - mean2 /= static_cast(m); - } - *out_level = mean1; - *out_trend = (mean2 - mean1) / static_cast(m); - double s_sum = 0.0; - for (std::uint32_t i = 0; i < m; ++i) { - (*out_season)[i] = y[i] - mean1; - s_sum += (*out_season)[i]; - } - const double s_mean = s_sum / static_cast(m); - for (std::uint32_t i = 0; i < m; ++i) - (*out_season)[i] -= s_mean; -} - -// Backwards-compatible wrapper: runs `simulate_with_init` from the textbook -// two-season-means default initialisation. -double simulate(const std::vector& y, std::uint32_t m, double alpha, double beta, double gamma, - HoltWintersFit* out) { - double init_level = 0.0; - double init_trend = 0.0; - std::vector init_season; - compute_default_init(y, m, &init_level, &init_trend, &init_season); - return simulate_with_init(y, m, alpha, beta, gamma, init_level, init_trend, - init_season.empty() ? nullptr : init_season.data(), out); -} - -// --------------------------------------------------------------------------- -// Bounded Nelder-Mead in 2D / 3D -// --------------------------------------------------------------------------- - -struct ParamBounds { - double lo; - double hi; -}; - -// Clamps each coordinate of `v` (length `dim`) into its per-coordinate bound. -void clamp_vertex_bounded(double* v, std::size_t dim, const ParamBounds* bounds) noexcept { - for (std::size_t i = 0; i < dim; ++i) { - if (v[i] < bounds[i].lo) - v[i] = bounds[i].lo; - if (v[i] > bounds[i].hi) - v[i] = bounds[i].hi; - } -} - -// Expands a packed parameter vector of length `dim` into (alpha, beta, gamma). -// `dim == 2` => non-seasonal: gamma is forced to 0. -// `dim == 3` => seasonal: all three are read out. -void unpack_params(const double* p, std::size_t dim, double* alpha, double* beta, double* gamma) noexcept { - *alpha = p[0]; - *beta = p[1]; - *gamma = (dim == 3U) ? p[2] : 0.0; -} - -// Objective evaluated by the simplex search. `smooth_dim` is only meaningful -// to the joint objective; the smoothing-only one ignores it. -using SimplexObjective = double (*)(const double* params, std::size_t dim, std::size_t smooth_dim, - const std::vector& y, std::uint32_t m); - -// SSE objective for the smoothing-only optimiser. Returns +inf for non-finite -// SSE (which guides the simplex away from pathological corners). -double objective(const double* params, std::size_t dim, std::size_t /*smooth_dim*/, const std::vector& y, - std::uint32_t m) { - double alpha = 0.0; - double beta = 0.0; - double gamma = 0.0; - unpack_params(params, dim, &alpha, &beta, &gamma); - const double sse = simulate(y, m, alpha, beta, gamma, nullptr); - if (!std::isfinite(sse)) { - return std::numeric_limits::infinity(); - } - return sse; -} - -// Bounded Nelder-Mead over `dim` coordinates, minimising `evaluate`. The -// starting simplex is `seed` plus one `seed_step` perturbation per axis; every -// vertex is clamped into `bounds` after each move. Writes the best vertex into -// `*out_params` and returns its objective value. -// -// The simplex coefficients are the canonical (Nelder-Mead 1965) values: -// reflect rho = 1, expand chi = 2, contract psi = 0.5, shrink sigma = 0.5. -double nelder_mead_search(const std::vector& y, std::uint32_t m, std::size_t dim, std::size_t smooth_dim, - const double* seed, double seed_step, const std::vector& bounds, int iter_cap, - SimplexObjective evaluate, std::vector* out_params) { - const std::size_t k = dim + 1U; - std::vector> simplex(k, std::vector(dim, 0.0)); - // Vertex 0: anchor. - for (std::size_t j = 0; j < dim; ++j) { - simplex[0][j] = seed[j]; - } - // Vertices 1..dim: perturb the i-th axis. - for (std::size_t i = 1; i < k; ++i) { - for (std::size_t j = 0; j < dim; ++j) { - simplex[i][j] = simplex[0][j]; - } - simplex[i][i - 1U] += seed_step; - } - for (auto& v : simplex) { - clamp_vertex_bounded(v.data(), dim, bounds.data()); - } - - std::vector fvals(k, 0.0); - for (std::size_t i = 0; i < k; ++i) { - fvals[i] = evaluate(simplex[i].data(), dim, smooth_dim, y, m); - } - - for (int iter = 0; iter < iter_cap; ++iter) { - // Sort vertices by function value (ascending). - std::vector order(k); - for (std::size_t i = 0; i < k; ++i) - order[i] = i; - std::sort(order.begin(), order.end(), - [&fvals](std::size_t a, std::size_t b) noexcept { return fvals[a] < fvals[b]; }); - const std::size_t best = order[0]; - const std::size_t worst = order[k - 1U]; - const std::size_t second_worst = order[k - 2U]; - - // Convergence check: span of f-values across the simplex. - if (std::isfinite(fvals[best]) && std::isfinite(fvals[worst]) && (fvals[worst] - fvals[best]) < kNelderMeadFTol) { - break; - } - - // Centroid of the best-`dim` vertices (i.e. all except the worst). - std::vector centroid(dim, 0.0); - for (std::size_t i = 0; i < k; ++i) { - if (i == worst) - continue; - for (std::size_t j = 0; j < dim; ++j) - centroid[j] += simplex[i][j]; - } - for (std::size_t j = 0; j < dim; ++j) - centroid[j] /= static_cast(dim); - - // Reflect. - std::vector reflected(dim); - for (std::size_t j = 0; j < dim; ++j) { - reflected[j] = centroid[j] + 1.0 * (centroid[j] - simplex[worst][j]); - } - clamp_vertex_bounded(reflected.data(), dim, bounds.data()); - const double f_reflected = evaluate(reflected.data(), dim, smooth_dim, y, m); - - if (f_reflected < fvals[second_worst] && f_reflected >= fvals[best]) { - simplex[worst] = reflected; - fvals[worst] = f_reflected; - continue; - } - - if (f_reflected < fvals[best]) { - // Expand. - std::vector expanded(dim); - for (std::size_t j = 0; j < dim; ++j) { - expanded[j] = centroid[j] + 2.0 * (reflected[j] - centroid[j]); - } - clamp_vertex_bounded(expanded.data(), dim, bounds.data()); - const double f_expanded = evaluate(expanded.data(), dim, smooth_dim, y, m); - if (f_expanded < f_reflected) { - simplex[worst] = expanded; - fvals[worst] = f_expanded; - } else { - simplex[worst] = reflected; - fvals[worst] = f_reflected; - } - continue; - } - - // Contract. - std::vector contracted(dim); - if (f_reflected < fvals[worst]) { - // Outside contraction. - for (std::size_t j = 0; j < dim; ++j) { - contracted[j] = centroid[j] + 0.5 * (reflected[j] - centroid[j]); - } - } else { - // Inside contraction. - for (std::size_t j = 0; j < dim; ++j) { - contracted[j] = centroid[j] + 0.5 * (simplex[worst][j] - centroid[j]); - } - } - clamp_vertex_bounded(contracted.data(), dim, bounds.data()); - const double f_contracted = evaluate(contracted.data(), dim, smooth_dim, y, m); - if (f_contracted < std::min(f_reflected, fvals[worst])) { - simplex[worst] = contracted; - fvals[worst] = f_contracted; - continue; - } - - // Shrink toward best. - for (std::size_t i = 0; i < k; ++i) { - if (i == best) - continue; - for (std::size_t j = 0; j < dim; ++j) { - simplex[i][j] = simplex[best][j] + 0.5 * (simplex[i][j] - simplex[best][j]); - } - clamp_vertex_bounded(simplex[i].data(), dim, bounds.data()); - fvals[i] = evaluate(simplex[i].data(), dim, smooth_dim, y, m); - } - } - - // Return the best vertex. - std::size_t best_idx = 0; - for (std::size_t i = 1; i < k; ++i) { - if (fvals[i] < fvals[best_idx]) - best_idx = i; - } - *out_params = simplex[best_idx]; - return fvals[best_idx]; -} - -// Runs the search over the unit cube of dimension `dim` (2 or 3), tuning the -// smoothing parameters alone. Writes the best (alpha, beta, gamma) into the -// out-pointers and returns the best SSE. -double nelder_mead(const std::vector& y, std::uint32_t m, std::size_t dim, double* out_alpha, double* out_beta, - double* out_gamma) { - // Initial simplex: (0.2, 0.1, 0.1) plus unit-perturbations of magnitude - // 0.1 in each axis. Comfortably inside the bounds. - const std::array seed{0.2, 0.1, 0.1}; - const std::vector bounds(dim, ParamBounds{kBoundEps, 1.0 - kBoundEps}); - std::vector best; - const double sse = nelder_mead_search(y, m, dim, /*smooth_dim=*/dim, seed.data(), 0.1, bounds, kMaxNelderMeadIters, - &objective, &best); - unpack_params(best.data(), dim, out_alpha, out_beta, out_gamma); - return sse; -} - -// --------------------------------------------------------------------------- -// Joint (alpha, beta, gamma, L_0, B_0, S_0..S_{m-1}) optimisation -// --------------------------------------------------------------------------- -// -// The 3D `nelder_mead` above only tunes the smoothing parameters at a fixed -// two-season-means initial state. On strongly cyclic data Mac Excel reaches -// a much tighter fit by jointly tuning the initial state vector along with -// the smoothing parameters; the joint optimum can sit in a basin our 3D -// optimiser cannot reach. The generalised optimiser below packs: -// -// p = [alpha, beta[, gamma], L_0, B_0, S_0, ..., S_{m-1}] -// -// where the seasonal block is omitted when `m == 1`. The smoothing block is -// clamped to (kBoundEps, 1 - kBoundEps); the initial-state block is bounded -// to a generous data-range envelope so the simplex can move freely without -// running away to non-finite SSE. - -// Computes the per-coordinate bounds used by `nelder_mead_full`. The -// smoothing-param block (the first `smooth_dim` coordinates) is `(eps, -// 1 - eps)`; the initial-state block (the remaining `dim - smooth_dim` -// coordinates) is `[y_min - 5 * range, y_max + 5 * range]` so the simplex -// has room to escape its starting basin. -std::vector make_full_bounds(const std::vector& y, std::size_t dim, std::size_t smooth_dim) { - std::vector bounds(dim); - for (std::size_t i = 0; i < smooth_dim; ++i) { - bounds[i] = ParamBounds{kBoundEps, 1.0 - kBoundEps}; - } - double y_min = y[0]; - double y_max = y[0]; - for (double v : y) { - if (v < y_min) - y_min = v; - if (v > y_max) - y_max = v; - } - const double range = std::max(y_max - y_min, 1.0); - const double lo = y_min - 5.0 * range; - const double hi = y_max + 5.0 * range; - for (std::size_t i = smooth_dim; i < dim; ++i) { - bounds[i] = ParamBounds{lo, hi}; - } - return bounds; -} - -// SSE objective for the joint optimiser. Reads packed params: -// smooth_dim == 2 (m == 1): [alpha, beta, L_0, B_0] -// smooth_dim == 3 (m >= 2): [alpha, beta, gamma, L_0, B_0, S_0..S_{m-1}] -double objective_full(const double* p, std::size_t /*dim*/, std::size_t smooth_dim, const std::vector& y, - std::uint32_t m) { - const double alpha = p[0]; - const double beta = p[1]; - const double gamma = (smooth_dim == 3U) ? p[2] : 0.0; - const double init_level = p[smooth_dim]; - const double init_trend = p[smooth_dim + 1U]; - const double* init_season = (m >= 2U) ? &p[smooth_dim + 2U] : nullptr; - const double sse = simulate_with_init(y, m, alpha, beta, gamma, init_level, init_trend, init_season, nullptr); - return std::isfinite(sse) ? sse : std::numeric_limits::infinity(); -} - -// Joint optimisation over the smoothing parameters and the initial state. -// Only the knobs differ from the smoothing-only search: the simplex is built -// around the supplied `seed` (length `dim`) instead of the fixed anchor, the -// bounds are per-coordinate, and the iteration budget grows with `dim` — a 9D -// landscape needs ~2-3x more iterations than the 3D one to converge through -// the same number of simplex collapses. -double nelder_mead_full(const std::vector& y, std::uint32_t m, std::size_t dim, std::size_t smooth_dim, - const double* seed, double seed_step, const std::vector& bounds, - std::vector* out_params) { - const int iter_cap = static_cast(static_cast(kMaxNelderMeadIters) * std::max(1U, dim)); - return nelder_mead_search(y, m, dim, smooth_dim, seed, seed_step, bounds, iter_cap, &objective_full, out_params); -} - -// --------------------------------------------------------------------------- -// Fit + diagnostics on a resampled, deduplicated, evenly spaced series -// --------------------------------------------------------------------------- - -// Fits a Holt-Winters model to `y` with the given seasonality `m`. On -// success populates `*out` (parameters, final state, residuals, MAE, -// RMSE, MASE, SMAPE) and returns `true`. Writes `*out_err` and returns -// `false` on degenerate inputs (n < 2, or seasonal m with n < 2*m). -bool fit_holt_winters(const std::vector& y, std::uint32_t m, HoltWintersFit* out, Value* out_err) { - const std::size_t n = y.size(); - if (n < 2U) { - *out_err = Value::error(ErrorCode::NA); - return false; - } - if (m >= 2U && n < 2U * static_cast(m)) { - *out_err = Value::error(ErrorCode::NA); - return false; - } - const std::size_t smooth_dim = (m >= 2U) ? 3U : 2U; - double alpha = 0.0; - double beta = 0.0; - double gamma = 0.0; - - // Stage 1: tune (alpha, beta[, gamma]) at the textbook two-season-means - // initialisation. This is the conservative path that has shipped since - // FORECAST.ETS landed; on most series it sits at or near the global - // optimum and is cheap to compute. - const double sse_basic = nelder_mead(y, m, smooth_dim, &alpha, &beta, &gamma); - - // Stage 2: jointly tune the smoothing parameters and the initial state - // (L_0, B_0, S_0..S_{m-1}). The 3D optimum can sit in a basin that the - // smooth-only stage cannot reach because the initial state is fixed; on - // strongly cyclic data this stage finds the basin Mac Excel converges - // to. Multi-start over a small seed grid widens basin coverage further. - double init_level = 0.0; - double init_trend = 0.0; - std::vector init_season; - compute_default_init(y, m, &init_level, &init_trend, &init_season); - const std::size_t full_dim = smooth_dim + 2U + ((m >= 2U) ? static_cast(m) : 0U); - const std::vector full_bounds = make_full_bounds(y, full_dim, smooth_dim); - - // Seed grid. Each entry supplies a (alpha, beta[, gamma]) anchor; the - // initial-state block is always seeded from the textbook default so the - // simplex can move it as needed. Seeds cover low/mid/high alpha to - // hedge against multimodal SSE landscapes. - static constexpr double kSmoothSeeds[][3] = { - {0.2, 0.1, 0.1}, - {0.5, 0.1, 0.1}, - {0.8, 0.05, 0.1}, - {0.5, 0.5, 0.5}, - }; - double sse_full = std::numeric_limits::infinity(); - std::vector full_params; - for (const auto& smooth_seed : kSmoothSeeds) { - std::vector seed(full_dim, 0.0); - seed[0] = smooth_seed[0]; - seed[1] = smooth_seed[1]; - if (smooth_dim == 3U) - seed[2] = smooth_seed[2]; - seed[smooth_dim] = init_level; - seed[smooth_dim + 1U] = init_trend; - if (m >= 2U) { - for (std::uint32_t i = 0; i < m; ++i) { - seed[smooth_dim + 2U + i] = init_season[i]; - } - } - std::vector params; - const double sse = nelder_mead_full(y, m, full_dim, smooth_dim, seed.data(), - /*seed_step=*/0.1, full_bounds, ¶ms); - if (sse < sse_full) { - sse_full = sse; - full_params = std::move(params); - } - } - - // Pick the better fit. The two stages share an objective (SSE on the - // same series), so a direct comparison is sound. On weakly-structured - // data the two-stage paths agree to numerical noise; on strongly-cyclic - // data the joint fit is materially better. - if (sse_full + 1e-9 < sse_basic && std::isfinite(sse_full)) { - alpha = full_params[0]; - beta = full_params[1]; - gamma = (smooth_dim == 3U) ? full_params[2] : 0.0; - init_level = full_params[smooth_dim]; - init_trend = full_params[smooth_dim + 1U]; - if (m >= 2U) { - for (std::uint32_t i = 0; i < m; ++i) { - init_season[i] = full_params[smooth_dim + 2U + i]; - } - } - (void)simulate_with_init(y, m, alpha, beta, gamma, init_level, init_trend, - init_season.empty() ? nullptr : init_season.data(), out); - } else { - // Re-run simulation at the fitted parameters to populate residuals - // and final state. - (void)simulate(y, m, alpha, beta, gamma, out); - } - - // Diagnostic statistics. `start` = first index that has a forecast. - const std::size_t start = (m == 1U) ? 1U : static_cast(m); - if (start >= n) { - *out_err = Value::error(ErrorCode::NA); - return false; - } - const std::size_t k = n - start; - double sum_abs_err = 0.0; - double sum_sq_err = 0.0; - double smape_num = 0.0; - std::size_t smape_n = 0U; - for (std::size_t t = start; t < n; ++t) { - const double err = out->residuals[t]; - sum_abs_err += std::fabs(err); - sum_sq_err += err * err; - const double y_hat = y[t] - err; - const double denom = std::fabs(y[t]) + std::fabs(y_hat); - if (denom > 0.0) { - // SMAPE in the symmetric form: |F - y| / ((|F| + |y|) / 2). - smape_num += std::fabs(err) / (denom * 0.5); - ++smape_n; - } - } - out->mae = sum_abs_err / static_cast(k); - out->rmse = std::sqrt(sum_sq_err / static_cast(k)); - out->smape = (smape_n > 0U) ? (smape_num / static_cast(smape_n)) : 0.0; - - // MASE: mean absolute error / mean absolute first-difference of the - // training series. - double sum_diff = 0.0; - for (std::size_t i = 1; i < n; ++i) { - sum_diff += std::fabs(y[i] - y[i - 1U]); - } - if (sum_diff > 0.0) { - const double scale = sum_diff / static_cast(n - 1U); - out->mase = out->mae / scale; - } else { - out->mase = 0.0; - } - return true; -} - // --------------------------------------------------------------------------- // Front-end: shared input preprocessing // --------------------------------------------------------------------------- diff --git a/src/eval/forecast_ets_lazy.h b/src/eval/forecast_ets_lazy.h index b3087d0d0..11c06b00a 100644 --- a/src/eval/forecast_ets_lazy.h +++ b/src/eval/forecast_ets_lazy.h @@ -20,9 +20,9 @@ // `eval/lazy_impls.h` for the shared `LazyImpl` signature and the // `eval_node` recursion entry point. // -// The implementation lives in a single TU (`forecast_ets_lazy.cpp`) -// because the four impls share a substantial helper surface -// (preprocessing, fit, ACF) that we deliberately keep in-TU. +// `forecast_ets_lazy.cpp` holds argument reading, preprocessing and the four +// impls; series preparation lives in `ets_series.cpp` and the smoothing fit +// in `holt_winters.cpp`. #ifndef FORMULON_EVAL_FORECAST_ETS_LAZY_H_ #define FORMULON_EVAL_FORECAST_ETS_LAZY_H_ diff --git a/src/eval/holt_winters.cpp b/src/eval/holt_winters.cpp new file mode 100644 index 000000000..84682b4fe --- /dev/null +++ b/src/eval/holt_winters.cpp @@ -0,0 +1,554 @@ +// +// Holt-Winters fit for the FORECAST.ETS family; the contract is declared in +// `eval/holt_winters.h`. + +#include "eval/holt_winters.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils/error.h" +#include "utils/index_sort.h" +#include "value.h" + +namespace formulon { +namespace eval { +namespace ets { +namespace { + +// Nelder-Mead bounds clamp -- coordinates are squeezed into +// (kBoundEps, 1 - kBoundEps) so the gradient never escapes the unit +// cube and the smoothing recurrences stay well-defined. +constexpr double kBoundEps = 1e-6; + +// Nelder-Mead convergence: stop when (fmax - fmin) over the simplex falls +// below this, or when iteration count exceeds kMaxNelderMeadIters. +constexpr double kNelderMeadFTol = 1e-9; +constexpr int kMaxNelderMeadIters = 200; + +// Runs the Holt-Winters smoothing recurrences over `y` with explicit +// initial state (`init_level`, `init_trend`, `init_season[0..m-1]`), +// populates `*out` (level / trend / season / residuals), and returns the +// in-sample SSE used by the optimiser. +// +// Recurrences (additive seasonality): +// L_t = alpha (y_t - S_{t-m}) + (1-alpha)(L_{t-1} + B_{t-1}) +// B_t = beta (L_t - L_{t-1}) + (1-beta) B_{t-1} +// S_t = gamma (y_t - L_t) + (1-gamma) S_{t-m} +// Forecast residual at step t is `y_t - (L_{t-1} + B_{t-1} + S_{t-m})`. +// +// `init_season` may be null when `m == 1`. +double simulate_with_init(const std::vector& y, std::uint32_t m, double alpha, double beta, double gamma, + double init_level, double init_trend, const double* init_season, HoltWintersFit* out) { + const std::size_t n = y.size(); + double level = init_level; + double trend = init_trend; + std::vector season(m == 1U ? 0U : static_cast(m), 0.0); + if (m != 1U && init_season != nullptr) { + for (std::uint32_t i = 0; i < m; ++i) { + season[i] = init_season[i]; + } + } + + std::vector residuals(n, 0.0); + double sse = 0.0; + // The first `start` indices have no one-step-ahead forecast (no prior + // level / trend / seasonal estimate is available beneath them). + const std::size_t start = (m == 1U) ? 1U : static_cast(m); + for (std::size_t t = start; t < n; ++t) { + const double prev_level = level; + const double prev_trend = trend; + const double s_prev = (m == 1U) ? 0.0 : season[t % m]; + const double y_hat = prev_level + prev_trend + s_prev; + const double err = y[t] - y_hat; + residuals[t] = err; + sse += err * err; + // Update equations. + level = alpha * (y[t] - s_prev) + (1.0 - alpha) * (prev_level + prev_trend); + trend = beta * (level - prev_level) + (1.0 - beta) * prev_trend; + if (m != 1U) { + season[t % m] = gamma * (y[t] - level) + (1.0 - gamma) * s_prev; + } + } + + if (out != nullptr) { + out->alpha = alpha; + out->beta = beta; + out->gamma = (m == 1U) ? 0.0 : gamma; + out->level = level; + out->trend = trend; + out->season = season; + out->m = m; + out->residuals = std::move(residuals); + } + return sse; +} + +// Computes the textbook two-season-means default initial state used as the +// starting point of the optimiser. With `m == 1` we use Holt's double +// exponential: L_0 = y_0, B_0 = y_1 - y_0 (no seasonal vector). +void compute_default_init(const std::vector& y, std::uint32_t m, double* out_level, double* out_trend, + std::vector* out_season) { + const std::size_t n = y.size(); + out_season->assign(m == 1U ? 0U : static_cast(m), 0.0); + if (m == 1U) { + *out_level = y[0]; + *out_trend = (n >= 2U) ? (y[1] - y[0]) : 0.0; + return; + } + double mean1 = 0.0; + for (std::uint32_t i = 0; i < m; ++i) + mean1 += y[i]; + mean1 /= static_cast(m); + double mean2 = mean1; + if (n >= 2U * static_cast(m)) { + mean2 = 0.0; + for (std::uint32_t i = 0; i < m; ++i) + mean2 += y[m + i]; + mean2 /= static_cast(m); + } + *out_level = mean1; + *out_trend = (mean2 - mean1) / static_cast(m); + double s_sum = 0.0; + for (std::uint32_t i = 0; i < m; ++i) { + (*out_season)[i] = y[i] - mean1; + s_sum += (*out_season)[i]; + } + const double s_mean = s_sum / static_cast(m); + for (std::uint32_t i = 0; i < m; ++i) + (*out_season)[i] -= s_mean; +} + +// Backwards-compatible wrapper: runs `simulate_with_init` from the textbook +// two-season-means default initialisation. +double simulate(const std::vector& y, std::uint32_t m, double alpha, double beta, double gamma, + HoltWintersFit* out) { + double init_level = 0.0; + double init_trend = 0.0; + std::vector init_season; + compute_default_init(y, m, &init_level, &init_trend, &init_season); + return simulate_with_init(y, m, alpha, beta, gamma, init_level, init_trend, + init_season.empty() ? nullptr : init_season.data(), out); +} + +// --------------------------------------------------------------------------- +// Bounded Nelder-Mead in 2D / 3D +// --------------------------------------------------------------------------- + +struct ParamBounds { + double lo; + double hi; +}; + +// Clamps each coordinate of `v` (length `dim`) into its per-coordinate bound. +void clamp_vertex_bounded(double* v, std::size_t dim, const ParamBounds* bounds) noexcept { + for (std::size_t i = 0; i < dim; ++i) { + if (v[i] < bounds[i].lo) + v[i] = bounds[i].lo; + if (v[i] > bounds[i].hi) + v[i] = bounds[i].hi; + } +} + +// Expands a packed parameter vector of length `dim` into (alpha, beta, gamma). +// `dim == 2` => non-seasonal: gamma is forced to 0. +// `dim == 3` => seasonal: all three are read out. +void unpack_params(const double* p, std::size_t dim, double* alpha, double* beta, double* gamma) noexcept { + *alpha = p[0]; + *beta = p[1]; + *gamma = (dim == 3U) ? p[2] : 0.0; +} + +// Objective evaluated by the simplex search. `smooth_dim` is only meaningful +// to the joint objective; the smoothing-only one ignores it. +using SimplexObjective = double (*)(const double* params, std::size_t dim, std::size_t smooth_dim, + const std::vector& y, std::uint32_t m); + +// SSE objective for the smoothing-only optimiser. Returns +inf for non-finite +// SSE (which guides the simplex away from pathological corners). +double objective(const double* params, std::size_t dim, std::size_t /*smooth_dim*/, const std::vector& y, + std::uint32_t m) { + double alpha = 0.0; + double beta = 0.0; + double gamma = 0.0; + unpack_params(params, dim, &alpha, &beta, &gamma); + const double sse = simulate(y, m, alpha, beta, gamma, nullptr); + if (!std::isfinite(sse)) { + return std::numeric_limits::infinity(); + } + return sse; +} + +// Bounded Nelder-Mead over `dim` coordinates, minimising `evaluate`. The +// starting simplex is `seed` plus one `seed_step` perturbation per axis; every +// vertex is clamped into `bounds` after each move. Writes the best vertex into +// `*out_params` and returns its objective value. +// +// The simplex coefficients are the canonical (Nelder-Mead 1965) values: +// reflect rho = 1, expand chi = 2, contract psi = 0.5, shrink sigma = 0.5. +double nelder_mead_search(const std::vector& y, std::uint32_t m, std::size_t dim, std::size_t smooth_dim, + const double* seed, double seed_step, const std::vector& bounds, int iter_cap, + SimplexObjective evaluate, std::vector* out_params) { + const std::size_t k = dim + 1U; + std::vector> simplex(k, std::vector(dim, 0.0)); + // Vertex 0: anchor. + for (std::size_t j = 0; j < dim; ++j) { + simplex[0][j] = seed[j]; + } + // Vertices 1..dim: perturb the i-th axis. + for (std::size_t i = 1; i < k; ++i) { + for (std::size_t j = 0; j < dim; ++j) { + simplex[i][j] = simplex[0][j]; + } + simplex[i][i - 1U] += seed_step; + } + for (auto& v : simplex) { + clamp_vertex_bounded(v.data(), dim, bounds.data()); + } + + std::vector fvals(k, 0.0); + for (std::size_t i = 0; i < k; ++i) { + fvals[i] = evaluate(simplex[i].data(), dim, smooth_dim, y, m); + } + + for (int iter = 0; iter < iter_cap; ++iter) { + // Sort vertices by function value (ascending). + std::vector order(k); + for (std::size_t i = 0; i < k; ++i) + order[i] = i; + std::sort(order.begin(), order.end(), + [&fvals](std::size_t a, std::size_t b) noexcept { return fvals[a] < fvals[b]; }); + const std::size_t best = order[0]; + const std::size_t worst = order[k - 1U]; + const std::size_t second_worst = order[k - 2U]; + + // Convergence check: span of f-values across the simplex. + if (std::isfinite(fvals[best]) && std::isfinite(fvals[worst]) && (fvals[worst] - fvals[best]) < kNelderMeadFTol) { + break; + } + + // Centroid of the best-`dim` vertices (i.e. all except the worst). + std::vector centroid(dim, 0.0); + for (std::size_t i = 0; i < k; ++i) { + if (i == worst) + continue; + for (std::size_t j = 0; j < dim; ++j) + centroid[j] += simplex[i][j]; + } + for (std::size_t j = 0; j < dim; ++j) + centroid[j] /= static_cast(dim); + + // Reflect. + std::vector reflected(dim); + for (std::size_t j = 0; j < dim; ++j) { + reflected[j] = centroid[j] + 1.0 * (centroid[j] - simplex[worst][j]); + } + clamp_vertex_bounded(reflected.data(), dim, bounds.data()); + const double f_reflected = evaluate(reflected.data(), dim, smooth_dim, y, m); + + if (f_reflected < fvals[second_worst] && f_reflected >= fvals[best]) { + simplex[worst] = reflected; + fvals[worst] = f_reflected; + continue; + } + + if (f_reflected < fvals[best]) { + // Expand. + std::vector expanded(dim); + for (std::size_t j = 0; j < dim; ++j) { + expanded[j] = centroid[j] + 2.0 * (reflected[j] - centroid[j]); + } + clamp_vertex_bounded(expanded.data(), dim, bounds.data()); + const double f_expanded = evaluate(expanded.data(), dim, smooth_dim, y, m); + if (f_expanded < f_reflected) { + simplex[worst] = expanded; + fvals[worst] = f_expanded; + } else { + simplex[worst] = reflected; + fvals[worst] = f_reflected; + } + continue; + } + + // Contract. + std::vector contracted(dim); + if (f_reflected < fvals[worst]) { + // Outside contraction. + for (std::size_t j = 0; j < dim; ++j) { + contracted[j] = centroid[j] + 0.5 * (reflected[j] - centroid[j]); + } + } else { + // Inside contraction. + for (std::size_t j = 0; j < dim; ++j) { + contracted[j] = centroid[j] + 0.5 * (simplex[worst][j] - centroid[j]); + } + } + clamp_vertex_bounded(contracted.data(), dim, bounds.data()); + const double f_contracted = evaluate(contracted.data(), dim, smooth_dim, y, m); + if (f_contracted < std::min(f_reflected, fvals[worst])) { + simplex[worst] = contracted; + fvals[worst] = f_contracted; + continue; + } + + // Shrink toward best. + for (std::size_t i = 0; i < k; ++i) { + if (i == best) + continue; + for (std::size_t j = 0; j < dim; ++j) { + simplex[i][j] = simplex[best][j] + 0.5 * (simplex[i][j] - simplex[best][j]); + } + clamp_vertex_bounded(simplex[i].data(), dim, bounds.data()); + fvals[i] = evaluate(simplex[i].data(), dim, smooth_dim, y, m); + } + } + + // Return the best vertex. + std::size_t best_idx = 0; + for (std::size_t i = 1; i < k; ++i) { + if (fvals[i] < fvals[best_idx]) + best_idx = i; + } + *out_params = simplex[best_idx]; + return fvals[best_idx]; +} + +// Runs the search over the unit cube of dimension `dim` (2 or 3), tuning the +// smoothing parameters alone. Writes the best (alpha, beta, gamma) into the +// out-pointers and returns the best SSE. +double nelder_mead(const std::vector& y, std::uint32_t m, std::size_t dim, double* out_alpha, double* out_beta, + double* out_gamma) { + // Initial simplex: (0.2, 0.1, 0.1) plus unit-perturbations of magnitude + // 0.1 in each axis. Comfortably inside the bounds. + const std::array seed{0.2, 0.1, 0.1}; + const std::vector bounds(dim, ParamBounds{kBoundEps, 1.0 - kBoundEps}); + std::vector best; + const double sse = nelder_mead_search(y, m, dim, /*smooth_dim=*/dim, seed.data(), 0.1, bounds, kMaxNelderMeadIters, + &objective, &best); + unpack_params(best.data(), dim, out_alpha, out_beta, out_gamma); + return sse; +} + +// --------------------------------------------------------------------------- +// Joint (alpha, beta, gamma, L_0, B_0, S_0..S_{m-1}) optimisation +// --------------------------------------------------------------------------- +// +// The 3D `nelder_mead` above only tunes the smoothing parameters at a fixed +// two-season-means initial state. On strongly cyclic data Mac Excel reaches +// a much tighter fit by jointly tuning the initial state vector along with +// the smoothing parameters; the joint optimum can sit in a basin our 3D +// optimiser cannot reach. The generalised optimiser below packs: +// +// p = [alpha, beta[, gamma], L_0, B_0, S_0, ..., S_{m-1}] +// +// where the seasonal block is omitted when `m == 1`. The smoothing block is +// clamped to (kBoundEps, 1 - kBoundEps); the initial-state block is bounded +// to a generous data-range envelope so the simplex can move freely without +// running away to non-finite SSE. + +// Computes the per-coordinate bounds used by `nelder_mead_full`. The +// smoothing-param block (the first `smooth_dim` coordinates) is `(eps, +// 1 - eps)`; the initial-state block (the remaining `dim - smooth_dim` +// coordinates) is `[y_min - 5 * range, y_max + 5 * range]` so the simplex +// has room to escape its starting basin. +std::vector make_full_bounds(const std::vector& y, std::size_t dim, std::size_t smooth_dim) { + std::vector bounds(dim); + for (std::size_t i = 0; i < smooth_dim; ++i) { + bounds[i] = ParamBounds{kBoundEps, 1.0 - kBoundEps}; + } + double y_min = y[0]; + double y_max = y[0]; + for (double v : y) { + if (v < y_min) + y_min = v; + if (v > y_max) + y_max = v; + } + const double range = std::max(y_max - y_min, 1.0); + const double lo = y_min - 5.0 * range; + const double hi = y_max + 5.0 * range; + for (std::size_t i = smooth_dim; i < dim; ++i) { + bounds[i] = ParamBounds{lo, hi}; + } + return bounds; +} + +// SSE objective for the joint optimiser. Reads packed params: +// smooth_dim == 2 (m == 1): [alpha, beta, L_0, B_0] +// smooth_dim == 3 (m >= 2): [alpha, beta, gamma, L_0, B_0, S_0..S_{m-1}] +double objective_full(const double* p, std::size_t /*dim*/, std::size_t smooth_dim, const std::vector& y, + std::uint32_t m) { + const double alpha = p[0]; + const double beta = p[1]; + const double gamma = (smooth_dim == 3U) ? p[2] : 0.0; + const double init_level = p[smooth_dim]; + const double init_trend = p[smooth_dim + 1U]; + const double* init_season = (m >= 2U) ? &p[smooth_dim + 2U] : nullptr; + const double sse = simulate_with_init(y, m, alpha, beta, gamma, init_level, init_trend, init_season, nullptr); + return std::isfinite(sse) ? sse : std::numeric_limits::infinity(); +} + +// Joint optimisation over the smoothing parameters and the initial state. +// Only the knobs differ from the smoothing-only search: the simplex is built +// around the supplied `seed` (length `dim`) instead of the fixed anchor, the +// bounds are per-coordinate, and the iteration budget grows with `dim` — a 9D +// landscape needs ~2-3x more iterations than the 3D one to converge through +// the same number of simplex collapses. +double nelder_mead_full(const std::vector& y, std::uint32_t m, std::size_t dim, std::size_t smooth_dim, + const double* seed, double seed_step, const std::vector& bounds, + std::vector* out_params) { + const int iter_cap = static_cast(static_cast(kMaxNelderMeadIters) * std::max(1U, dim)); + return nelder_mead_search(y, m, dim, smooth_dim, seed, seed_step, bounds, iter_cap, &objective_full, out_params); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Fit + diagnostics on a resampled, deduplicated, evenly spaced series +// --------------------------------------------------------------------------- + +// Fits a Holt-Winters model to `y` with the given seasonality `m`. On +// success populates `*out` (parameters, final state, residuals, MAE, +// RMSE, MASE, SMAPE) and returns `true`. Writes `*out_err` and returns +// `false` on degenerate inputs (n < 2, or seasonal m with n < 2*m). +bool fit_holt_winters(const std::vector& y, std::uint32_t m, HoltWintersFit* out, Value* out_err) { + const std::size_t n = y.size(); + if (n < 2U) { + *out_err = Value::error(ErrorCode::NA); + return false; + } + if (m >= 2U && n < 2U * static_cast(m)) { + *out_err = Value::error(ErrorCode::NA); + return false; + } + const std::size_t smooth_dim = (m >= 2U) ? 3U : 2U; + double alpha = 0.0; + double beta = 0.0; + double gamma = 0.0; + + // Stage 1: tune (alpha, beta[, gamma]) at the textbook two-season-means + // initialisation. This is the conservative path that has shipped since + // FORECAST.ETS landed; on most series it sits at or near the global + // optimum and is cheap to compute. + const double sse_basic = nelder_mead(y, m, smooth_dim, &alpha, &beta, &gamma); + + // Stage 2: jointly tune the smoothing parameters and the initial state + // (L_0, B_0, S_0..S_{m-1}). The 3D optimum can sit in a basin that the + // smooth-only stage cannot reach because the initial state is fixed; on + // strongly cyclic data this stage finds the basin Mac Excel converges + // to. Multi-start over a small seed grid widens basin coverage further. + double init_level = 0.0; + double init_trend = 0.0; + std::vector init_season; + compute_default_init(y, m, &init_level, &init_trend, &init_season); + const std::size_t full_dim = smooth_dim + 2U + ((m >= 2U) ? static_cast(m) : 0U); + const std::vector full_bounds = make_full_bounds(y, full_dim, smooth_dim); + + // Seed grid. Each entry supplies a (alpha, beta[, gamma]) anchor; the + // initial-state block is always seeded from the textbook default so the + // simplex can move it as needed. Seeds cover low/mid/high alpha to + // hedge against multimodal SSE landscapes. + static constexpr double kSmoothSeeds[][3] = { + {0.2, 0.1, 0.1}, + {0.5, 0.1, 0.1}, + {0.8, 0.05, 0.1}, + {0.5, 0.5, 0.5}, + }; + double sse_full = std::numeric_limits::infinity(); + std::vector full_params; + for (const auto& smooth_seed : kSmoothSeeds) { + std::vector seed(full_dim, 0.0); + seed[0] = smooth_seed[0]; + seed[1] = smooth_seed[1]; + if (smooth_dim == 3U) + seed[2] = smooth_seed[2]; + seed[smooth_dim] = init_level; + seed[smooth_dim + 1U] = init_trend; + if (m >= 2U) { + for (std::uint32_t i = 0; i < m; ++i) { + seed[smooth_dim + 2U + i] = init_season[i]; + } + } + std::vector params; + const double sse = nelder_mead_full(y, m, full_dim, smooth_dim, seed.data(), + /*seed_step=*/0.1, full_bounds, ¶ms); + if (sse < sse_full) { + sse_full = sse; + full_params = std::move(params); + } + } + + // Pick the better fit. The two stages share an objective (SSE on the + // same series), so a direct comparison is sound. On weakly-structured + // data the two-stage paths agree to numerical noise; on strongly-cyclic + // data the joint fit is materially better. + if (sse_full + 1e-9 < sse_basic && std::isfinite(sse_full)) { + alpha = full_params[0]; + beta = full_params[1]; + gamma = (smooth_dim == 3U) ? full_params[2] : 0.0; + init_level = full_params[smooth_dim]; + init_trend = full_params[smooth_dim + 1U]; + if (m >= 2U) { + for (std::uint32_t i = 0; i < m; ++i) { + init_season[i] = full_params[smooth_dim + 2U + i]; + } + } + (void)simulate_with_init(y, m, alpha, beta, gamma, init_level, init_trend, + init_season.empty() ? nullptr : init_season.data(), out); + } else { + // Re-run simulation at the fitted parameters to populate residuals + // and final state. + (void)simulate(y, m, alpha, beta, gamma, out); + } + + // Diagnostic statistics. `start` = first index that has a forecast. + const std::size_t start = (m == 1U) ? 1U : static_cast(m); + if (start >= n) { + *out_err = Value::error(ErrorCode::NA); + return false; + } + const std::size_t k = n - start; + double sum_abs_err = 0.0; + double sum_sq_err = 0.0; + double smape_num = 0.0; + std::size_t smape_n = 0U; + for (std::size_t t = start; t < n; ++t) { + const double err = out->residuals[t]; + sum_abs_err += std::fabs(err); + sum_sq_err += err * err; + const double y_hat = y[t] - err; + const double denom = std::fabs(y[t]) + std::fabs(y_hat); + if (denom > 0.0) { + // SMAPE in the symmetric form: |F - y| / ((|F| + |y|) / 2). + smape_num += std::fabs(err) / (denom * 0.5); + ++smape_n; + } + } + out->mae = sum_abs_err / static_cast(k); + out->rmse = std::sqrt(sum_sq_err / static_cast(k)); + out->smape = (smape_n > 0U) ? (smape_num / static_cast(smape_n)) : 0.0; + + // MASE: mean absolute error / mean absolute first-difference of the + // training series. + double sum_diff = 0.0; + for (std::size_t i = 1; i < n; ++i) { + sum_diff += std::fabs(y[i] - y[i - 1U]); + } + if (sum_diff > 0.0) { + const double scale = sum_diff / static_cast(n - 1U); + out->mase = out->mae / scale; + } else { + out->mase = 0.0; + } + return true; +} + +} // namespace ets +} // namespace eval +} // namespace formulon diff --git a/src/eval/holt_winters.h b/src/eval/holt_winters.h new file mode 100644 index 000000000..8c9ec6347 --- /dev/null +++ b/src/eval/holt_winters.h @@ -0,0 +1,49 @@ +// +// Holt-Winters additive triple-exponential smoothing fit used by the +// FORECAST.ETS family. + +#ifndef FORMULON_EVAL_HOLT_WINTERS_H_ +#define FORMULON_EVAL_HOLT_WINTERS_H_ + +#include +#include + +#include "value.h" + +namespace formulon { +namespace eval { +namespace ets { + +/// Fitted smoothing parameters, final state and in-sample diagnostics. + +struct HoltWintersFit { + // Smoothing parameters fitted by Nelder-Mead. `gamma` is exactly 0.0 + // when the model is non-seasonal (m == 1). + double alpha = 0.0; + double beta = 0.0; + double gamma = 0.0; + // Final state at the end of the in-sample series. + double level = 0.0; + double trend = 0.0; + std::vector season; // length m (or empty when m == 1) + std::uint32_t m = 1U; + // In-sample fit diagnostics. `residuals` has the same length as the + // resampled series; the first m entries (or the first 1 when m == 1) + // are zero because no one-step-ahead forecast is defined there. + std::vector residuals; + double mae = 0.0; + double rmse = 0.0; + double mase = 0.0; + double smape = 0.0; +}; + +/// Fits a Holt-Winters model to `y` with seasonality `m`. On success fills +/// `*out` and returns true; writes `*out_err` and returns false on degenerate +/// inputs (n < 2, or seasonal m with n < 2*m). +bool fit_holt_winters(const std::vector& y, std::uint32_t m, HoltWintersFit* out, Value* out_err); + +} // namespace ets +} // namespace eval +} // namespace formulon + +#endif // FORMULON_EVAL_HOLT_WINTERS_H_ From 1e7ca9cb47a40caa2551c8368364e0e77aa51968 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:04:12 +0900 Subject: [PATCH 07/93] refactor(parser): move LET and LAMBDA parsing into parser_let_lambda.cpp - move binding-name validation and the parse_let_call / parse_lambda_call Parser members out of parser_reference.cpp - register parser_let_lambda.cpp in CMakeLists.txt - update the file-layout comments in parser.cpp and parser_detail.h - behavior-preserving move --- CMakeLists.txt | 1 + src/parser/parser.cpp | 5 +- src/parser/parser_detail.h | 6 +- src/parser/parser_let_lambda.cpp | 416 +++++++++++++++++++++++++++++++ src/parser/parser_reference.cpp | 393 ----------------------------- 5 files changed, 423 insertions(+), 398 deletions(-) create mode 100644 src/parser/parser_let_lambda.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d3718b81..2ad32d49b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -203,6 +203,7 @@ set(FORMULON_CORE_SOURCES src/parser/parser.cpp src/parser/parser_atoms.cpp src/parser/parser_reference.cpp + src/parser/parser_let_lambda.cpp src/parser/ref_transforms.cpp src/pivot/aggregator.cpp src/pivot/filter_engine.cpp diff --git a/src/parser/parser.cpp b/src/parser/parser.cpp index 76987f033..fc59bd4af 100644 --- a/src/parser/parser.cpp +++ b/src/parser/parser.cpp @@ -34,8 +34,9 @@ // // Source layout: this TU owns the parser entry point, the Pratt expression // loop, atom dispatch, and the panic-mode plumbing. Per-atom helpers live in -// `parser_atoms.cpp`; cell-ref decoding, LET special-form handling, and -// sheet-qualified refs live in `parser_reference.cpp`. +// `parser_atoms.cpp`; cell-ref decoding and sheet-qualified refs live in +// `parser_reference.cpp`; LET / LAMBDA special forms live in +// `parser_let_lambda.cpp`. #include "parser/parser.h" diff --git a/src/parser/parser_detail.h b/src/parser/parser_detail.h index ca28c275e..74e246a42 100644 --- a/src/parser/parser_detail.h +++ b/src/parser/parser_detail.h @@ -1,7 +1,7 @@ // -// Internal helpers shared between parser.cpp, parser_atoms.cpp, and -// parser_reference.cpp. Not a public header; do not include from outside -// src/parser/. +// Internal helpers shared between parser.cpp, parser_atoms.cpp, +// parser_reference.cpp, and parser_let_lambda.cpp. Not a public header; do not +// include from outside src/parser/. #ifndef FORMULON_PARSER_PARSER_DETAIL_H_ #define FORMULON_PARSER_PARSER_DETAIL_H_ diff --git a/src/parser/parser_let_lambda.cpp b/src/parser/parser_let_lambda.cpp new file mode 100644 index 000000000..882b4b3a6 --- /dev/null +++ b/src/parser/parser_let_lambda.cpp @@ -0,0 +1,416 @@ +// +// LET / LAMBDA special-form parsing: binding-name validation and the +// `parse_let_call` / `parse_lambda_call` Parser members. + +#include +#include +#include +#include + +#include "parser/ast.h" +#include "parser/parse_error.h" +#include "parser/parser.h" +#include "parser/parser_detail.h" +#include "parser/token.h" +#include "utils/strings.h" + +namespace formulon { +namespace parser { + +using detail::IsAsciiDigit; +using detail::IsAsciiLetter; +using detail::SpanRange; + +namespace { + +// Returns true iff `name` starts with `[A-Za-z_\]` or any non-ASCII (UTF-8 +// continuation) byte, and continues with `[A-Za-z0-9_.?]` plus non-ASCII +// bytes. Length must be non-zero and <= 255 bytes. This mirrors the +// tokenizer's identifier rule (`Tokenizer::is_ident_start_byte` / +// `is_ident_cont_byte`) and matches the identifier shape Excel accepts for +// LET bindings and defined names, including hiragana / katakana / kanji and +// other locale-specific scripts. The shape is still stricter than the +// tokenizer's Ident rule because it forbids a leading ASCII digit. +bool IsLetNameShape(std::string_view name) noexcept { + if (name.empty() || name.size() > 255) { + return false; + } + const auto first = static_cast(name[0]); + if (!IsAsciiLetter(static_cast(first)) && first != '_' && first != '\\' && first < 0x80) { + return false; + } + for (std::size_t i = 1; i < name.size(); ++i) { + const auto byte = static_cast(name[i]); + const char c = static_cast(byte); + const bool ok = IsAsciiLetter(c) || IsAsciiDigit(c) || c == '_' || c == '.' || c == '?' || byte >= 0x80; + if (!ok) { + return false; + } + } + return true; +} + +// Returns true iff `name` has the shape of an A1-style cell reference +// (`[A-Za-z]{1,3}[1-9][0-9]*`). Excel rejects such identifiers as LET +// binding names because they would be indistinguishable from cell refs when +// used in the body. +bool LooksLikeCellRef(std::string_view name) noexcept { + std::size_t i = 0; + while (i < name.size() && IsAsciiLetter(name[i])) { + ++i; + } + const std::size_t letters = i; + if (letters == 0 || letters > 3) { + return false; + } + // First digit must not be zero so names like `A0` (not a valid ref) still + // go through the LET path. + if (i >= name.size() || name[i] < '1' || name[i] > '9') { + return false; + } + ++i; + while (i < name.size() && IsAsciiDigit(name[i])) { + ++i; + } + return i == name.size(); +} + +} // namespace + +// --------------------------------------------------------------------------- +// LET bindings +// --------------------------------------------------------------------------- + +bool Parser::parse_let_binding_name(std::string_view* out_name, TextRange* out_range) { + // The binding-name slot accepts a single Ident token. CellRef tokens + // (produced for patterns that match the A1 shape) are rejected because the + // tokenizer already routed them away from Ident; this is the expected + // behaviour for names like `A1` that collide with cell refs. + const Token& tok = peek(); + if (tok.kind == TokenKind::CellRef) { + record_error_with_token(ParseErrorCode::LetInvalidName, tok.range, tok.lexeme); + advance(); + return false; + } + if (tok.kind != TokenKind::Ident) { + record_error_with_token(ParseErrorCode::LetInvalidName, tok.range, tok.lexeme); + return false; + } + if (!IsLetNameShape(tok.lexeme) || LooksLikeCellRef(tok.lexeme)) { + record_error_with_token(ParseErrorCode::LetInvalidName, tok.range, tok.lexeme); + advance(); + return false; + } + *out_name = tok.lexeme; + *out_range = tok.range; + advance(); + return true; +} + +AstNode* Parser::parse_let_call(const Token& name_tok) { + const TextRange call_start = name_tok.range; + advance(); // LET Ident + advance(); // LParen + + // Accumulate name / expr pairs until a bare expression (the body) remains. + std::vector names; + std::vector exprs; + AstNode* body = nullptr; + + // Empty arg list is an arity error but still needs recovery; we synthesise + // a placeholder body and return below. + if (peek_kind() == TokenKind::RParen) { + record_error_with_token(ParseErrorCode::LetWrongArity, name_tok.range, name_tok.lexeme); + const Token& rparen_empty = advance(); + return make_recovery_placeholder(SpanRange(call_start, rparen_empty.range)); + } + + // Slot-walk loop. At each iteration pos_ sits on the first token of the + // next slot. A slot is classified as a binding name when it is a lone + // well-shaped Ident followed by a comma (so it cannot stand as a complete + // expression); any other shape is the body. This works for all odd-arity + // well-formed inputs because the body is always the *final* slot, never + // followed by a comma. + while (true) { + if (bailed_) { + break; + } + // CellRef-shaped tokens (e.g. `A1`, `AA10`) that sit in a binding-name + // slot are specifically forbidden by Excel: the name would collide with + // the A1 cell it spells. Detect the shape here so we emit the dedicated + // LetInvalidName diagnostic instead of letting the token fall into the + // body path (which would either parse it as a Ref or surface a + // non-specific arity error). + if (peek_kind() == TokenKind::CellRef && peek_kind_at(1) == TokenKind::Comma) { + const Token& bad = peek(); + record_error_with_token(ParseErrorCode::LetInvalidName, bad.range, bad.lexeme); + advance(); // consume the cell-ref + if (peek_kind() == TokenKind::Comma) { + advance(); // consume the comma + } + // Parse (and discard) the would-be initialiser so siblings continue. + AstNode* expr = parse_expression(0, SyncContext::CallArg); + if (expr == nullptr) { + return nullptr; + } + // The LET grammar requires an odd total arity; since we dropped this + // pair, the final arity is still consistent: continue to the next slot. + if (peek_kind() == TokenKind::Comma) { + advance(); + continue; + } + // No comma: the expression we just parsed was the tail; promote it + // to body if no bindings were valid yet. + if (body == nullptr && names.empty()) { + body = expr; + } + break; + } + const bool is_name_slot = (peek_kind() == TokenKind::Ident) && IsLetNameShape(peek().lexeme) && + !LooksLikeCellRef(peek().lexeme) && peek_kind_at(1) == TokenKind::Comma; + if (is_name_slot) { + std::string_view name; + // The name's source span is written via parse_let_binding_name; we do + // not currently attach it to the LetBinding node, but keeping the slot + // lets that diagnostic link land without another signature change. + TextRange name_range{}; + if (!parse_let_binding_name(&name, &name_range)) { + (void)name_range; + skip_to_sync(SyncContext::CallArg); + if (peek_kind() == TokenKind::Comma) { + advance(); + } + continue; + } + // parse_let_binding_name already advanced over the name; the next + // token is the comma guaranteed by `is_name_slot`. + advance(); // Comma + AstNode* expr = parse_expression(0, SyncContext::CallArg); + if (expr == nullptr) { + return nullptr; // hard arena failure + } + names.push_back(name); + exprs.push_back(expr); + if (peek_kind() == TokenKind::Comma) { + advance(); + continue; // more slots follow + } + // No comma after an (name, expr) pair means this was the penultimate + // slot and the expr we just parsed was the tail of a well-formed LET + // that is missing its body. Treat as an arity error. + record_error_with_token(ParseErrorCode::LetWrongArity, name_tok.range, name_tok.lexeme); + break; + } + + // Body slot: parse a full expression; the next token should be `)`. + body = parse_expression(0, SyncContext::CallArg); + if (body == nullptr) { + return nullptr; // hard arena failure + } + if (peek_kind() == TokenKind::Comma) { + // Extra argument after the body (even arity). Record and recover by + // skipping the rest of the arglist. + record_error_with_token(ParseErrorCode::LetWrongArity, name_tok.range, name_tok.lexeme); + skip_to_sync(SyncContext::Paren); + } + break; + } + + // At this point we expect `)`; emit diagnostics and recover if not. + TextRange end_range = call_start; + if (peek_kind() == TokenKind::RParen) { + const Token& rparen = advance(); + end_range = rparen.range; + } else if (peek_kind() != TokenKind::Eof) { + record_error_with_token(ParseErrorCode::ExpectedCloseParen, call_start, name_tok.lexeme); + } else { + record_error_with_token(ParseErrorCode::ExpectedCloseParen, call_start, name_tok.lexeme); + } + + // Validate arity: need >= 1 binding and a body. + if (names.empty() || body == nullptr) { + if (body == nullptr) { + record_error_with_token(ParseErrorCode::LetWrongArity, call_start, name_tok.lexeme); + } else if (names.empty()) { + record_error_with_token(ParseErrorCode::LetWrongArity, call_start, name_tok.lexeme); + } + return make_recovery_placeholder(SpanRange(call_start, end_range)); + } + + AstNode* n = make_let_binding(arena_, names.data(), exprs.data(), static_cast(names.size()), body); + if (n == nullptr) { + return nullptr; + } + n->set_range(SpanRange(call_start, end_range)); + return n; +} + +// --------------------------------------------------------------------------- +// LAMBDA parameters +// --------------------------------------------------------------------------- + +AstNode* Parser::parse_lambda_call(const Token& name_tok) { + const TextRange call_start = name_tok.range; + advance(); // LAMBDA Ident + advance(); // LParen + + // Collect parameter names then the body. Slot classification: each + // non-final slot must be a bare Ident shape that passes the same + // identifier rules used for LET binding names; the final slot is the + // body. Excel's grammar requires at least one slot total — `LAMBDA()` is + // an error. A single-slot form `LAMBDA(expr)` matches Mac Excel by + // treating `expr` as the body of a zero-parameter lambda. + // + // Parameters introduced with bracket syntax `[name]` are optional. They + // must be trailing (no required parameter may follow an optional one); + // when omitted at the call site they bind to a sentinel that ISOMITTED + // detects. + std::vector params; + std::uint32_t optional_count = 0; + AstNode* body = nullptr; + + // Empty arg list: LAMBDA requires the body slot. + if (peek_kind() == TokenKind::RParen) { + record_error_with_token(ParseErrorCode::LambdaEmpty, name_tok.range, name_tok.lexeme); + const Token& rparen_empty = advance(); + return make_recovery_placeholder(SpanRange(call_start, rparen_empty.range)); + } + + // Slot-walk loop. Each iteration starts at the first token of the next + // slot. A slot is a parameter name iff it is a well-shaped Ident followed + // by a comma; otherwise it is the body. This matches the LET disambiguation + // pattern: the body is always the final slot (never followed by a comma). + while (true) { + if (bailed_) { + break; + } + // CellRef-shaped tokens (e.g. `A1`, `AA10`) sitting in a parameter slot + // collide with the A1 cell they spell; emit the dedicated diagnostic so + // siblings keep parsing. + if (peek_kind() == TokenKind::CellRef && peek_kind_at(1) == TokenKind::Comma) { + const Token& bad = peek(); + record_error_with_token(ParseErrorCode::LambdaInvalidParam, bad.range, bad.lexeme); + advance(); // consume the cell-ref + if (peek_kind() == TokenKind::Comma) { + advance(); + } + continue; + } + // Bracketed optional-parameter shape: `[name]` followed by either a + // comma (more slots ahead) or `)` (this was actually the body slot — + // a `[ref]` structured reference inside the body parses there). We + // only treat it as a param when the closing bracket is followed by a + // comma; otherwise let the body branch handle it. + const bool is_optional_param_slot = (peek_kind() == TokenKind::LBracket) && (peek_kind_at(1) == TokenKind::Ident) && + IsLetNameShape(peek_at(1).lexeme) && !LooksLikeCellRef(peek_at(1).lexeme) && + (peek_kind_at(2) == TokenKind::RBracket) && + (peek_kind_at(3) == TokenKind::Comma); + const bool is_param_slot = (peek_kind() == TokenKind::Ident) && IsLetNameShape(peek().lexeme) && + !LooksLikeCellRef(peek().lexeme) && peek_kind_at(1) == TokenKind::Comma; + if (is_optional_param_slot || is_param_slot) { + const bool optional = is_optional_param_slot; + const Token& tok = optional ? peek_at(1) : peek(); + const std::string_view pname = tok.lexeme; + // Reject duplicates within a single LAMBDA: the second occurrence + // would shadow the first at runtime, which is almost certainly a bug + // and which Excel itself rejects. + bool duplicate = false; + for (const auto& existing : params) { + if (strings::case_insensitive_eq(existing, pname)) { + duplicate = true; + break; + } + } + if (duplicate) { + record_error_with_token(ParseErrorCode::LambdaDuplicateParam, tok.range, tok.lexeme); + if (optional) { + advance(); // LBracket + advance(); // Ident + advance(); // RBracket + } else { + advance(); // Ident + } + if (peek_kind() == TokenKind::Comma) { + advance(); + } + continue; + } + params.push_back(pname); + if (optional) { + ++optional_count; + advance(); // LBracket + advance(); // Ident + advance(); // RBracket + advance(); // Comma + } else { + // A required parameter is illegal once we've started accepting + // optional ones: Excel only allows trailing optionals. + if (optional_count > 0) { + record_error_with_token(ParseErrorCode::LambdaInvalidParam, tok.range, tok.lexeme); + // Treat as optional anyway so we don't lose the slot — but the + // diagnostic is what matters; the AST will still produce a name. + ++optional_count; + } + advance(); // Ident + advance(); // Comma + } + continue; + } + // Non-param-slot: this is either the body (final slot, followed by `)`) + // or a malformed param slot (not an Ident, but followed by a comma). + // Detect the malformed case by looking ahead: if the slot is *not* the + // final one (i.e. there will be a comma after the expression) and the + // current token is not a valid bare-Ident param shape, surface the + // dedicated diagnostic before parsing the slot's expression. + // + // This catches `LAMBDA(x, x+1, y)`: the middle slot is `x+1` which is + // not a bare Ident-Comma shape but is followed by a comma after the + // expression closes. + // + // We cannot know the "followed by comma after expression" answer + // without parsing first, so the strategy is: tentatively parse the + // slot as an expression; if a comma follows, we wrongly admitted a + // non-Ident param slot — emit the diagnostic and discard the + // expression. If `)` follows, the expression is the body. + AstNode* slot = parse_expression(0, SyncContext::CallArg); + if (slot == nullptr) { + return nullptr; // hard arena failure + } + if (peek_kind() == TokenKind::Comma) { + // The slot we just parsed was not the final one, but it was not a + // bare-Ident param shape either: the user wrote something like + // `LAMBDA(x, x+1, y)` where `x+1` is illegal as a parameter name. + record_error_with_token(ParseErrorCode::LambdaInvalidParam, slot->range(), std::string_view{}); + advance(); // consume the comma so the next iteration starts cleanly + continue; + } + // Final slot: this is the body. + body = slot; + break; + } + + // Expect `)`; emit a diagnostic and recover otherwise. + TextRange end_range = call_start; + if (peek_kind() == TokenKind::RParen) { + const Token& rparen = advance(); + end_range = rparen.range; + } else { + record_error_with_token(ParseErrorCode::ExpectedCloseParen, call_start, name_tok.lexeme); + } + + if (body == nullptr) { + record_error_with_token(ParseErrorCode::LambdaEmpty, call_start, name_tok.lexeme); + return make_recovery_placeholder(SpanRange(call_start, end_range)); + } + + AstNode* n = make_lambda(arena_, params.empty() ? nullptr : params.data(), static_cast(params.size()), + optional_count, body); + if (n == nullptr) { + return nullptr; + } + n->set_range(SpanRange(call_start, end_range)); + return n; +} + +} // namespace parser +} // namespace formulon diff --git a/src/parser/parser_reference.cpp b/src/parser/parser_reference.cpp index c71e4ad96..c24b03b66 100644 --- a/src/parser/parser_reference.cpp +++ b/src/parser/parser_reference.cpp @@ -1,7 +1,6 @@ #include #include -#include #include "parser/ast.h" #include "parser/parse_error.h" @@ -9,7 +8,6 @@ #include "parser/parser_detail.h" #include "parser/reference.h" #include "parser/token.h" -#include "utils/strings.h" namespace formulon { namespace parser { @@ -22,62 +20,6 @@ using detail::kMaxExternalBookIndex; using detail::kMaxRow; using detail::SpanRange; -namespace { - -// Returns true iff `name` starts with `[A-Za-z_\]` or any non-ASCII (UTF-8 -// continuation) byte, and continues with `[A-Za-z0-9_.?]` plus non-ASCII -// bytes. Length must be non-zero and <= 255 bytes. This mirrors the -// tokenizer's identifier rule (`Tokenizer::is_ident_start_byte` / -// `is_ident_cont_byte`) and matches the identifier shape Excel accepts for -// LET bindings and defined names, including hiragana / katakana / kanji and -// other locale-specific scripts. The shape is still stricter than the -// tokenizer's Ident rule because it forbids a leading ASCII digit. -bool IsLetNameShape(std::string_view name) noexcept { - if (name.empty() || name.size() > 255) { - return false; - } - const auto first = static_cast(name[0]); - if (!IsAsciiLetter(static_cast(first)) && first != '_' && first != '\\' && first < 0x80) { - return false; - } - for (std::size_t i = 1; i < name.size(); ++i) { - const auto byte = static_cast(name[i]); - const char c = static_cast(byte); - const bool ok = IsAsciiLetter(c) || IsAsciiDigit(c) || c == '_' || c == '.' || c == '?' || byte >= 0x80; - if (!ok) { - return false; - } - } - return true; -} - -// Returns true iff `name` has the shape of an A1-style cell reference -// (`[A-Za-z]{1,3}[1-9][0-9]*`). Excel rejects such identifiers as LET -// binding names because they would be indistinguishable from cell refs when -// used in the body. -bool LooksLikeCellRef(std::string_view name) noexcept { - std::size_t i = 0; - while (i < name.size() && IsAsciiLetter(name[i])) { - ++i; - } - const std::size_t letters = i; - if (letters == 0 || letters > 3) { - return false; - } - // First digit must not be zero so names like `A0` (not a valid ref) still - // go through the LET path. - if (i >= name.size() || name[i] < '1' || name[i] > '9') { - return false; - } - ++i; - while (i < name.size() && IsAsciiDigit(name[i])) { - ++i; - } - return i == name.size(); -} - -} // namespace - // --------------------------------------------------------------------------- // Cell-ref decoding // --------------------------------------------------------------------------- @@ -171,341 +113,6 @@ std::uint32_t Parser::decode_column_letters(std::string_view lex, bool* col_abs) return v; } -// --------------------------------------------------------------------------- -// LET bindings -// --------------------------------------------------------------------------- - -bool Parser::parse_let_binding_name(std::string_view* out_name, TextRange* out_range) { - // The binding-name slot accepts a single Ident token. CellRef tokens - // (produced for patterns that match the A1 shape) are rejected because the - // tokenizer already routed them away from Ident; this is the expected - // behaviour for names like `A1` that collide with cell refs. - const Token& tok = peek(); - if (tok.kind == TokenKind::CellRef) { - record_error_with_token(ParseErrorCode::LetInvalidName, tok.range, tok.lexeme); - advance(); - return false; - } - if (tok.kind != TokenKind::Ident) { - record_error_with_token(ParseErrorCode::LetInvalidName, tok.range, tok.lexeme); - return false; - } - if (!IsLetNameShape(tok.lexeme) || LooksLikeCellRef(tok.lexeme)) { - record_error_with_token(ParseErrorCode::LetInvalidName, tok.range, tok.lexeme); - advance(); - return false; - } - *out_name = tok.lexeme; - *out_range = tok.range; - advance(); - return true; -} - -AstNode* Parser::parse_let_call(const Token& name_tok) { - const TextRange call_start = name_tok.range; - advance(); // LET Ident - advance(); // LParen - - // Accumulate name / expr pairs until a bare expression (the body) remains. - std::vector names; - std::vector exprs; - AstNode* body = nullptr; - - // Empty arg list is an arity error but still needs recovery; we synthesise - // a placeholder body and return below. - if (peek_kind() == TokenKind::RParen) { - record_error_with_token(ParseErrorCode::LetWrongArity, name_tok.range, name_tok.lexeme); - const Token& rparen_empty = advance(); - return make_recovery_placeholder(SpanRange(call_start, rparen_empty.range)); - } - - // Slot-walk loop. At each iteration pos_ sits on the first token of the - // next slot. A slot is classified as a binding name when it is a lone - // well-shaped Ident followed by a comma (so it cannot stand as a complete - // expression); any other shape is the body. This works for all odd-arity - // well-formed inputs because the body is always the *final* slot, never - // followed by a comma. - while (true) { - if (bailed_) { - break; - } - // CellRef-shaped tokens (e.g. `A1`, `AA10`) that sit in a binding-name - // slot are specifically forbidden by Excel: the name would collide with - // the A1 cell it spells. Detect the shape here so we emit the dedicated - // LetInvalidName diagnostic instead of letting the token fall into the - // body path (which would either parse it as a Ref or surface a - // non-specific arity error). - if (peek_kind() == TokenKind::CellRef && peek_kind_at(1) == TokenKind::Comma) { - const Token& bad = peek(); - record_error_with_token(ParseErrorCode::LetInvalidName, bad.range, bad.lexeme); - advance(); // consume the cell-ref - if (peek_kind() == TokenKind::Comma) { - advance(); // consume the comma - } - // Parse (and discard) the would-be initialiser so siblings continue. - AstNode* expr = parse_expression(0, SyncContext::CallArg); - if (expr == nullptr) { - return nullptr; - } - // The LET grammar requires an odd total arity; since we dropped this - // pair, the final arity is still consistent: continue to the next slot. - if (peek_kind() == TokenKind::Comma) { - advance(); - continue; - } - // No comma: the expression we just parsed was the tail; promote it - // to body if no bindings were valid yet. - if (body == nullptr && names.empty()) { - body = expr; - } - break; - } - const bool is_name_slot = (peek_kind() == TokenKind::Ident) && IsLetNameShape(peek().lexeme) && - !LooksLikeCellRef(peek().lexeme) && peek_kind_at(1) == TokenKind::Comma; - if (is_name_slot) { - std::string_view name; - // The name's source span is written via parse_let_binding_name; we do - // not currently attach it to the LetBinding node, but keeping the slot - // lets that diagnostic link land without another signature change. - TextRange name_range{}; - if (!parse_let_binding_name(&name, &name_range)) { - (void)name_range; - skip_to_sync(SyncContext::CallArg); - if (peek_kind() == TokenKind::Comma) { - advance(); - } - continue; - } - // parse_let_binding_name already advanced over the name; the next - // token is the comma guaranteed by `is_name_slot`. - advance(); // Comma - AstNode* expr = parse_expression(0, SyncContext::CallArg); - if (expr == nullptr) { - return nullptr; // hard arena failure - } - names.push_back(name); - exprs.push_back(expr); - if (peek_kind() == TokenKind::Comma) { - advance(); - continue; // more slots follow - } - // No comma after an (name, expr) pair means this was the penultimate - // slot and the expr we just parsed was the tail of a well-formed LET - // that is missing its body. Treat as an arity error. - record_error_with_token(ParseErrorCode::LetWrongArity, name_tok.range, name_tok.lexeme); - break; - } - - // Body slot: parse a full expression; the next token should be `)`. - body = parse_expression(0, SyncContext::CallArg); - if (body == nullptr) { - return nullptr; // hard arena failure - } - if (peek_kind() == TokenKind::Comma) { - // Extra argument after the body (even arity). Record and recover by - // skipping the rest of the arglist. - record_error_with_token(ParseErrorCode::LetWrongArity, name_tok.range, name_tok.lexeme); - skip_to_sync(SyncContext::Paren); - } - break; - } - - // At this point we expect `)`; emit diagnostics and recover if not. - TextRange end_range = call_start; - if (peek_kind() == TokenKind::RParen) { - const Token& rparen = advance(); - end_range = rparen.range; - } else if (peek_kind() != TokenKind::Eof) { - record_error_with_token(ParseErrorCode::ExpectedCloseParen, call_start, name_tok.lexeme); - } else { - record_error_with_token(ParseErrorCode::ExpectedCloseParen, call_start, name_tok.lexeme); - } - - // Validate arity: need >= 1 binding and a body. - if (names.empty() || body == nullptr) { - if (body == nullptr) { - record_error_with_token(ParseErrorCode::LetWrongArity, call_start, name_tok.lexeme); - } else if (names.empty()) { - record_error_with_token(ParseErrorCode::LetWrongArity, call_start, name_tok.lexeme); - } - return make_recovery_placeholder(SpanRange(call_start, end_range)); - } - - AstNode* n = make_let_binding(arena_, names.data(), exprs.data(), static_cast(names.size()), body); - if (n == nullptr) { - return nullptr; - } - n->set_range(SpanRange(call_start, end_range)); - return n; -} - -// --------------------------------------------------------------------------- -// LAMBDA parameters -// --------------------------------------------------------------------------- - -AstNode* Parser::parse_lambda_call(const Token& name_tok) { - const TextRange call_start = name_tok.range; - advance(); // LAMBDA Ident - advance(); // LParen - - // Collect parameter names then the body. Slot classification: each - // non-final slot must be a bare Ident shape that passes the same - // identifier rules used for LET binding names; the final slot is the - // body. Excel's grammar requires at least one slot total — `LAMBDA()` is - // an error. A single-slot form `LAMBDA(expr)` matches Mac Excel by - // treating `expr` as the body of a zero-parameter lambda. - // - // Parameters introduced with bracket syntax `[name]` are optional. They - // must be trailing (no required parameter may follow an optional one); - // when omitted at the call site they bind to a sentinel that ISOMITTED - // detects. - std::vector params; - std::uint32_t optional_count = 0; - AstNode* body = nullptr; - - // Empty arg list: LAMBDA requires the body slot. - if (peek_kind() == TokenKind::RParen) { - record_error_with_token(ParseErrorCode::LambdaEmpty, name_tok.range, name_tok.lexeme); - const Token& rparen_empty = advance(); - return make_recovery_placeholder(SpanRange(call_start, rparen_empty.range)); - } - - // Slot-walk loop. Each iteration starts at the first token of the next - // slot. A slot is a parameter name iff it is a well-shaped Ident followed - // by a comma; otherwise it is the body. This matches the LET disambiguation - // pattern: the body is always the final slot (never followed by a comma). - while (true) { - if (bailed_) { - break; - } - // CellRef-shaped tokens (e.g. `A1`, `AA10`) sitting in a parameter slot - // collide with the A1 cell they spell; emit the dedicated diagnostic so - // siblings keep parsing. - if (peek_kind() == TokenKind::CellRef && peek_kind_at(1) == TokenKind::Comma) { - const Token& bad = peek(); - record_error_with_token(ParseErrorCode::LambdaInvalidParam, bad.range, bad.lexeme); - advance(); // consume the cell-ref - if (peek_kind() == TokenKind::Comma) { - advance(); - } - continue; - } - // Bracketed optional-parameter shape: `[name]` followed by either a - // comma (more slots ahead) or `)` (this was actually the body slot — - // a `[ref]` structured reference inside the body parses there). We - // only treat it as a param when the closing bracket is followed by a - // comma; otherwise let the body branch handle it. - const bool is_optional_param_slot = (peek_kind() == TokenKind::LBracket) && (peek_kind_at(1) == TokenKind::Ident) && - IsLetNameShape(peek_at(1).lexeme) && !LooksLikeCellRef(peek_at(1).lexeme) && - (peek_kind_at(2) == TokenKind::RBracket) && - (peek_kind_at(3) == TokenKind::Comma); - const bool is_param_slot = (peek_kind() == TokenKind::Ident) && IsLetNameShape(peek().lexeme) && - !LooksLikeCellRef(peek().lexeme) && peek_kind_at(1) == TokenKind::Comma; - if (is_optional_param_slot || is_param_slot) { - const bool optional = is_optional_param_slot; - const Token& tok = optional ? peek_at(1) : peek(); - const std::string_view pname = tok.lexeme; - // Reject duplicates within a single LAMBDA: the second occurrence - // would shadow the first at runtime, which is almost certainly a bug - // and which Excel itself rejects. - bool duplicate = false; - for (const auto& existing : params) { - if (strings::case_insensitive_eq(existing, pname)) { - duplicate = true; - break; - } - } - if (duplicate) { - record_error_with_token(ParseErrorCode::LambdaDuplicateParam, tok.range, tok.lexeme); - if (optional) { - advance(); // LBracket - advance(); // Ident - advance(); // RBracket - } else { - advance(); // Ident - } - if (peek_kind() == TokenKind::Comma) { - advance(); - } - continue; - } - params.push_back(pname); - if (optional) { - ++optional_count; - advance(); // LBracket - advance(); // Ident - advance(); // RBracket - advance(); // Comma - } else { - // A required parameter is illegal once we've started accepting - // optional ones: Excel only allows trailing optionals. - if (optional_count > 0) { - record_error_with_token(ParseErrorCode::LambdaInvalidParam, tok.range, tok.lexeme); - // Treat as optional anyway so we don't lose the slot — but the - // diagnostic is what matters; the AST will still produce a name. - ++optional_count; - } - advance(); // Ident - advance(); // Comma - } - continue; - } - // Non-param-slot: this is either the body (final slot, followed by `)`) - // or a malformed param slot (not an Ident, but followed by a comma). - // Detect the malformed case by looking ahead: if the slot is *not* the - // final one (i.e. there will be a comma after the expression) and the - // current token is not a valid bare-Ident param shape, surface the - // dedicated diagnostic before parsing the slot's expression. - // - // This catches `LAMBDA(x, x+1, y)`: the middle slot is `x+1` which is - // not a bare Ident-Comma shape but is followed by a comma after the - // expression closes. - // - // We cannot know the "followed by comma after expression" answer - // without parsing first, so the strategy is: tentatively parse the - // slot as an expression; if a comma follows, we wrongly admitted a - // non-Ident param slot — emit the diagnostic and discard the - // expression. If `)` follows, the expression is the body. - AstNode* slot = parse_expression(0, SyncContext::CallArg); - if (slot == nullptr) { - return nullptr; // hard arena failure - } - if (peek_kind() == TokenKind::Comma) { - // The slot we just parsed was not the final one, but it was not a - // bare-Ident param shape either: the user wrote something like - // `LAMBDA(x, x+1, y)` where `x+1` is illegal as a parameter name. - record_error_with_token(ParseErrorCode::LambdaInvalidParam, slot->range(), std::string_view{}); - advance(); // consume the comma so the next iteration starts cleanly - continue; - } - // Final slot: this is the body. - body = slot; - break; - } - - // Expect `)`; emit a diagnostic and recover otherwise. - TextRange end_range = call_start; - if (peek_kind() == TokenKind::RParen) { - const Token& rparen = advance(); - end_range = rparen.range; - } else { - record_error_with_token(ParseErrorCode::ExpectedCloseParen, call_start, name_tok.lexeme); - } - - if (body == nullptr) { - record_error_with_token(ParseErrorCode::LambdaEmpty, call_start, name_tok.lexeme); - return make_recovery_placeholder(SpanRange(call_start, end_range)); - } - - AstNode* n = make_lambda(arena_, params.empty() ? nullptr : params.data(), static_cast(params.size()), - optional_count, body); - if (n == nullptr) { - return nullptr; - } - n->set_range(SpanRange(call_start, end_range)); - return n; -} - // --------------------------------------------------------------------------- // Sheet-qualified refs // --------------------------------------------------------------------------- From 6c836d5e0d4a05cdc5a70d591f38f067c210e7b5 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:04:20 +0900 Subject: [PATCH 08/93] refactor(parser): give reference.h its own translation unit - move sheet_name_needs_quoting and format_a1 from ast.cpp into reference.cpp, matching the parser/reference.h header - drop the includes ast.cpp no longer needs and register reference.cpp in CMakeLists.txt - behavior-preserving move --- CMakeLists.txt | 1 + src/parser/ast.cpp | 100 --------------------------------- src/parser/reference.cpp | 117 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 100 deletions(-) create mode 100644 src/parser/reference.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ad32d49b..0af554613 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -197,6 +197,7 @@ set(FORMULON_CORE_SOURCES src/cf/scale_evaluator.cpp src/parser/tokenizer.cpp src/parser/ast.cpp + src/parser/reference.cpp src/parser/ast_dump.cpp src/parser/ast_format.cpp src/parser/ast_shift.cpp diff --git a/src/parser/ast.cpp b/src/parser/ast.cpp index cb16e26fa..546a9efd6 100644 --- a/src/parser/ast.cpp +++ b/src/parser/ast.cpp @@ -7,13 +7,11 @@ #include "parser/ast.h" #include -#include #include #include #include "parser/reference.h" #include "parser/token.h" -#include "utils/a1_column.h" #include "utils/arena.h" #include "utils/expected.h" // FM_CHECK #include "utils/strings.h" @@ -958,103 +956,5 @@ ErrorCode AstNode::as_error_literal() const { return data_.error_literal; } -// --------------------------------------------------------------------------- -// Reference helper -// --------------------------------------------------------------------------- - -bool sheet_name_needs_quoting(std::string_view name) noexcept { - if (name.empty()) { - return true; - } - // A bare sheet name cannot open with a digit: the tokenizer reads the run - // as a numeric literal and never reaches the `!`, so `3Q!A1` is not a - // reference at all. Quoting is the only way to write such a name. - if (name.front() >= '0' && name.front() <= '9') { - return true; - } - // A sheet named TRUE/FALSE (any ASCII case) tokenizes as a Bool literal - // before the `!` is ever consulted, so `TRUE!A1` re-parses as `(bool - // true)` followed by a dangling `!`. Quoting is the only unambiguous form. - if (strings::case_insensitive_eq(name, "TRUE") || strings::case_insensitive_eq(name, "FALSE")) { - return true; - } - - std::size_t i = 0; - while (i < name.size() && ((name[i] >= 'A' && name[i] <= 'Z') || (name[i] >= 'a' && name[i] <= 'z'))) { - ++i; - } - const std::size_t letters = i; - const bool cell_ref_prefix = letters >= 1 && letters <= 3 && i < name.size() && name[i] >= '1' && name[i] <= '9'; - for (char c : name) { - const bool bare_name_char = - (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.'; - if (!bare_name_char) { - return true; - } - } - if (!cell_ref_prefix) { - return false; - } - ++i; - while (i < name.size() && name[i] >= '0' && name[i] <= '9') { - ++i; - } - return i == name.size(); -} - -std::string format_a1(const Reference& r) { - FM_CHECK(!(r.is_full_col && r.is_full_row), "format_a1: is_full_col and is_full_row must not both be set"); - std::string out; - if (!r.sheet.empty()) { - if (r.sheet_quoted || sheet_name_needs_quoting(r.sheet)) { - out.push_back('\''); - // Escape any embedded single quotes by doubling them. - for (char c : r.sheet) { - if (c == '\'') { - out.push_back('\''); - } - out.push_back(c); - } - out.push_back('\''); - } else { - out.append(r.sheet); - } - out.push_back('!'); - } - if (r.is_full_col) { - if (r.col_abs) { - out.push_back('$'); - } - FM_CHECK(a1::append_column_letters(out, r.col), "reference column is outside Excel's grid"); - out.push_back(':'); - if (r.col_abs) { - out.push_back('$'); - } - FM_CHECK(a1::append_column_letters(out, r.col), "reference column is outside Excel's grid"); - return out; - } - if (r.is_full_row) { - if (r.row_abs) { - out.push_back('$'); - } - out.append(std::to_string(r.row + 1)); - out.push_back(':'); - if (r.row_abs) { - out.push_back('$'); - } - out.append(std::to_string(r.row + 1)); - return out; - } - if (r.col_abs) { - out.push_back('$'); - } - FM_CHECK(a1::append_column_letters(out, r.col), "reference column is outside Excel's grid"); - if (r.row_abs) { - out.push_back('$'); - } - out.append(std::to_string(r.row + 1)); - return out; -} - } // namespace parser } // namespace formulon diff --git a/src/parser/reference.cpp b/src/parser/reference.cpp new file mode 100644 index 000000000..f78e6660e --- /dev/null +++ b/src/parser/reference.cpp @@ -0,0 +1,117 @@ +// +// Canonical A1 text for a `Reference`; the contract is declared in +// `parser/reference.h`. + +#include "parser/reference.h" + +#include +#include +#include + +#include "utils/a1_column.h" +#include "utils/expected.h" // FM_CHECK +#include "utils/strings.h" + +namespace formulon { +namespace parser { + +// --------------------------------------------------------------------------- +// Reference helper +// --------------------------------------------------------------------------- + +bool sheet_name_needs_quoting(std::string_view name) noexcept { + if (name.empty()) { + return true; + } + // A bare sheet name cannot open with a digit: the tokenizer reads the run + // as a numeric literal and never reaches the `!`, so `3Q!A1` is not a + // reference at all. Quoting is the only way to write such a name. + if (name.front() >= '0' && name.front() <= '9') { + return true; + } + // A sheet named TRUE/FALSE (any ASCII case) tokenizes as a Bool literal + // before the `!` is ever consulted, so `TRUE!A1` re-parses as `(bool + // true)` followed by a dangling `!`. Quoting is the only unambiguous form. + if (strings::case_insensitive_eq(name, "TRUE") || strings::case_insensitive_eq(name, "FALSE")) { + return true; + } + + std::size_t i = 0; + while (i < name.size() && ((name[i] >= 'A' && name[i] <= 'Z') || (name[i] >= 'a' && name[i] <= 'z'))) { + ++i; + } + const std::size_t letters = i; + const bool cell_ref_prefix = letters >= 1 && letters <= 3 && i < name.size() && name[i] >= '1' && name[i] <= '9'; + for (char c : name) { + const bool bare_name_char = + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.'; + if (!bare_name_char) { + return true; + } + } + if (!cell_ref_prefix) { + return false; + } + ++i; + while (i < name.size() && name[i] >= '0' && name[i] <= '9') { + ++i; + } + return i == name.size(); +} + +std::string format_a1(const Reference& r) { + FM_CHECK(!(r.is_full_col && r.is_full_row), "format_a1: is_full_col and is_full_row must not both be set"); + std::string out; + if (!r.sheet.empty()) { + if (r.sheet_quoted || sheet_name_needs_quoting(r.sheet)) { + out.push_back('\''); + // Escape any embedded single quotes by doubling them. + for (char c : r.sheet) { + if (c == '\'') { + out.push_back('\''); + } + out.push_back(c); + } + out.push_back('\''); + } else { + out.append(r.sheet); + } + out.push_back('!'); + } + if (r.is_full_col) { + if (r.col_abs) { + out.push_back('$'); + } + FM_CHECK(a1::append_column_letters(out, r.col), "reference column is outside Excel's grid"); + out.push_back(':'); + if (r.col_abs) { + out.push_back('$'); + } + FM_CHECK(a1::append_column_letters(out, r.col), "reference column is outside Excel's grid"); + return out; + } + if (r.is_full_row) { + if (r.row_abs) { + out.push_back('$'); + } + out.append(std::to_string(r.row + 1)); + out.push_back(':'); + if (r.row_abs) { + out.push_back('$'); + } + out.append(std::to_string(r.row + 1)); + return out; + } + if (r.col_abs) { + out.push_back('$'); + } + FM_CHECK(a1::append_column_letters(out, r.col), "reference column is outside Excel's grid"); + if (r.row_abs) { + out.push_back('$'); + } + out.append(std::to_string(r.row + 1)); + return out; +} + +} // namespace parser +} // namespace formulon From 1511fac7fb4b389323d2bb12336505236699d647 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:04:27 +0900 Subject: [PATCH 09/93] refactor(parser): move storage-operator respelling next to the storage prefixes - move spell_storage_operators and its SINGLE / ANCHORARRAY call helpers from ast_format.{h,cpp} into formula_prefix.{h,cpp} - include formula_prefix.h from ast_format_test.cpp and register formula_prefix.cpp in CMakeLists.txt - behavior-preserving move --- CMakeLists.txt | 1 + src/parser/ast_format.cpp | 102 --------------------- src/parser/ast_format.h | 6 -- src/parser/formula_prefix.cpp | 123 ++++++++++++++++++++++++++ src/parser/formula_prefix.h | 6 ++ tests/unit/parser/ast_format_test.cpp | 1 + 6 files changed, 131 insertions(+), 108 deletions(-) create mode 100644 src/parser/formula_prefix.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0af554613..ff7e8431b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -200,6 +200,7 @@ set(FORMULON_CORE_SOURCES src/parser/reference.cpp src/parser/ast_dump.cpp src/parser/ast_format.cpp + src/parser/formula_prefix.cpp src/parser/ast_shift.cpp src/parser/parser.cpp src/parser/parser_atoms.cpp diff --git a/src/parser/ast_format.cpp b/src/parser/ast_format.cpp index 309522ed8..0f792cda3 100644 --- a/src/parser/ast_format.cpp +++ b/src/parser/ast_format.cpp @@ -16,12 +16,10 @@ #include #include "parser/ast.h" -#include "parser/parser.h" #include "parser/parser_detail.h" #include "parser/reference.h" #include "utils/arena.h" #include "utils/double_format.h" -#include "utils/index_sort.h" #include "utils/strings.h" #include "value.h" @@ -1073,105 +1071,5 @@ std::string format_formula_storage(const AstNode& node, StorageFunctionNameSpell return out; } -namespace { - -bool IsOperatorCall(const AstNode& node, std::string_view name) { - return node.kind() == NodeKind::Call && node.as_call_arity() == 1U && - strings::case_insensitive_eq(node.as_call_name(), name); -} - -bool IsStorageOperatorCall(const AstNode& node) { - return IsOperatorCall(node, "SINGLE") || IsOperatorCall(node, "ANCHORARRAY"); -} - -bool HasStorageOperatorCall(const AstNode& node) { - if (IsStorageOperatorCall(node)) { - return true; - } - for (const AstNode* child : child_nodes(node)) { - if (HasStorageOperatorCall(*child)) { - return true; - } - } - return false; -} - -/// Rewrites `node`'s source text, copying every part without an operator call -/// verbatim so spacing and spelling survive. -class OperatorRespeller { - public: - explicit OperatorRespeller(std::string_view src) : src_(src) {} - - std::string text(const AstNode& node, bool tight_parent) const { - const TextRange r = node.range(); - if (!HasStorageOperatorCall(node)) { - return std::string(src_.substr(r.start, r.end - r.start)); - } - if (IsStorageOperatorCall(node)) { - const AstNode& arg = node.as_call_arg(0); - if (IsOperatorCall(node, "SINGLE")) { - // `@` binds looser than `:`, ` ` and `%`, tighter than every infix operator. - const bool wrap_arg = arg.kind() == NodeKind::BinaryOp || - (arg.kind() == NodeKind::UnaryOp && arg.as_unary_op() == UnaryOp::Percent); - const std::string at = "@" + parenthesised(arg, wrap_arg); - return tight_parent ? "(" + at + ")" : at; - } - const bool bare = arg.kind() == NodeKind::Ref || arg.kind() == NodeKind::Ref3D || - arg.kind() == NodeKind::NameRef || arg.kind() == NodeKind::Call || - arg.kind() == NodeKind::ExternalRef; - return parenthesised(arg, !bare) + "#"; - } - const std::vector children = child_nodes(node); - const auto by_start = [&](std::uint32_t lhs, std::uint32_t rhs) { - return children[lhs]->range().start < children[rhs]->range().start; - }; - std::vector order; - sorted_index_order(order, static_cast(children.size()), make_index_less(by_start)); - const bool tight = node.kind() == NodeKind::RangeOp || node.kind() == NodeKind::IntersectOp || - node.kind() == NodeKind::SpillRef || - (node.kind() == NodeKind::UnaryOp && node.as_unary_op() == UnaryOp::Percent); - std::string out; - std::uint32_t at = r.start; - for (const std::uint32_t index : order) { - const AstNode* child = children[index]; - if (!HasStorageOperatorCall(*child)) { - continue; - } - out.append(src_.substr(at, child->range().start - at)); - out.append(text(*child, tight)); - at = child->range().end; - } - out.append(src_.substr(at, r.end - at)); - return out; - } - - private: - std::string parenthesised(const AstNode& node, bool wrap) const { - std::string inner = text(node, false); - return wrap && (inner.empty() || inner.front() != '(') ? "(" + inner + ")" : inner; - } - - std::string_view src_; -}; - -} // namespace - -std::string spell_storage_operators(std::string_view formula) { - const std::size_t body_at = !formula.empty() && formula.front() == '=' ? 1U : 0U; - const std::string_view body = formula.substr(body_at); - if (!strings::case_insensitive_contains(body, "SINGLE(") && - !strings::case_insensitive_contains(body, "ANCHORARRAY(")) { - return std::string(formula); - } - Arena arena; - const AstNode* root = parse_strict(body, arena); - if (root == nullptr || !HasStorageOperatorCall(*root)) { - return std::string(formula); - } - const TextRange r = root->range(); - const std::string respelled = OperatorRespeller(body).text(*root, false); - return std::string(formula.substr(0, body_at + r.start)) + respelled + std::string(body.substr(r.end)); -} - } // namespace parser } // namespace formulon diff --git a/src/parser/ast_format.h b/src/parser/ast_format.h index eb66d207a..1c69fe7dd 100644 --- a/src/parser/ast_format.h +++ b/src/parser/ast_format.h @@ -86,12 +86,6 @@ using StorageFunctionNameSpeller = std::string (*)(std::string_view name); std::string format_formula_storage(const AstNode& node, StorageFunctionNameSpeller spell, const std::vector* omitted_at = nullptr); -/// Respells each one-argument call to `SINGLE` / `ANCHORARRAY` in `formula` -/// (a leading `=` optional) as the `@x` / `x#` operator Excel's formula bar -/// shows for it, copying every other character verbatim. Returns `formula` -/// unchanged when it holds none or does not parse. -std::string spell_storage_operators(std::string_view formula); - } // namespace parser } // namespace formulon diff --git a/src/parser/formula_prefix.cpp b/src/parser/formula_prefix.cpp new file mode 100644 index 000000000..5bfea533a --- /dev/null +++ b/src/parser/formula_prefix.cpp @@ -0,0 +1,123 @@ +// +// Respelling of the `SINGLE` / `ANCHORARRAY` storage calls as the `@x` / `x#` +// operators; the contract is declared in `parser/formula_prefix.h`. + +#include "parser/formula_prefix.h" + +#include +#include +#include +#include +#include + +#include "parser/ast.h" +#include "parser/parser.h" +#include "utils/arena.h" +#include "utils/index_sort.h" +#include "utils/strings.h" + +namespace formulon { +namespace parser { + +namespace { + +bool IsOperatorCall(const AstNode& node, std::string_view name) { + return node.kind() == NodeKind::Call && node.as_call_arity() == 1U && + strings::case_insensitive_eq(node.as_call_name(), name); +} + +bool IsStorageOperatorCall(const AstNode& node) { + return IsOperatorCall(node, "SINGLE") || IsOperatorCall(node, "ANCHORARRAY"); +} + +bool HasStorageOperatorCall(const AstNode& node) { + if (IsStorageOperatorCall(node)) { + return true; + } + for (const AstNode* child : child_nodes(node)) { + if (HasStorageOperatorCall(*child)) { + return true; + } + } + return false; +} + +/// Rewrites `node`'s source text, copying every part without an operator call +/// verbatim so spacing and spelling survive. +class OperatorRespeller { + public: + explicit OperatorRespeller(std::string_view src) : src_(src) {} + + std::string text(const AstNode& node, bool tight_parent) const { + const TextRange r = node.range(); + if (!HasStorageOperatorCall(node)) { + return std::string(src_.substr(r.start, r.end - r.start)); + } + if (IsStorageOperatorCall(node)) { + const AstNode& arg = node.as_call_arg(0); + if (IsOperatorCall(node, "SINGLE")) { + // `@` binds looser than `:`, ` ` and `%`, tighter than every infix operator. + const bool wrap_arg = arg.kind() == NodeKind::BinaryOp || + (arg.kind() == NodeKind::UnaryOp && arg.as_unary_op() == UnaryOp::Percent); + const std::string at = "@" + parenthesised(arg, wrap_arg); + return tight_parent ? "(" + at + ")" : at; + } + const bool bare = arg.kind() == NodeKind::Ref || arg.kind() == NodeKind::Ref3D || + arg.kind() == NodeKind::NameRef || arg.kind() == NodeKind::Call || + arg.kind() == NodeKind::ExternalRef; + return parenthesised(arg, !bare) + "#"; + } + const std::vector children = child_nodes(node); + const auto by_start = [&](std::uint32_t lhs, std::uint32_t rhs) { + return children[lhs]->range().start < children[rhs]->range().start; + }; + std::vector order; + sorted_index_order(order, static_cast(children.size()), make_index_less(by_start)); + const bool tight = node.kind() == NodeKind::RangeOp || node.kind() == NodeKind::IntersectOp || + node.kind() == NodeKind::SpillRef || + (node.kind() == NodeKind::UnaryOp && node.as_unary_op() == UnaryOp::Percent); + std::string out; + std::uint32_t at = r.start; + for (const std::uint32_t index : order) { + const AstNode* child = children[index]; + if (!HasStorageOperatorCall(*child)) { + continue; + } + out.append(src_.substr(at, child->range().start - at)); + out.append(text(*child, tight)); + at = child->range().end; + } + out.append(src_.substr(at, r.end - at)); + return out; + } + + private: + std::string parenthesised(const AstNode& node, bool wrap) const { + std::string inner = text(node, false); + return wrap && (inner.empty() || inner.front() != '(') ? "(" + inner + ")" : inner; + } + + std::string_view src_; +}; + +} // namespace + +std::string spell_storage_operators(std::string_view formula) { + const std::size_t body_at = !formula.empty() && formula.front() == '=' ? 1U : 0U; + const std::string_view body = formula.substr(body_at); + if (!strings::case_insensitive_contains(body, "SINGLE(") && + !strings::case_insensitive_contains(body, "ANCHORARRAY(")) { + return std::string(formula); + } + Arena arena; + const AstNode* root = parse_strict(body, arena); + if (root == nullptr || !HasStorageOperatorCall(*root)) { + return std::string(formula); + } + const TextRange r = root->range(); + const std::string respelled = OperatorRespeller(body).text(*root, false); + return std::string(formula.substr(0, body_at + r.start)) + respelled + std::string(body.substr(r.end)); +} + +} // namespace parser +} // namespace formulon diff --git a/src/parser/formula_prefix.h b/src/parser/formula_prefix.h index 3a76a1dc1..645f5d31e 100644 --- a/src/parser/formula_prefix.h +++ b/src/parser/formula_prefix.h @@ -130,6 +130,12 @@ inline std::string strip_storage_prefixes(std::string_view formula, bool (*known return out; } +/// Respells each one-argument call to `SINGLE` / `ANCHORARRAY` in `formula` +/// (a leading `=` optional) as the `@x` / `x#` operator Excel's formula bar +/// shows for it, copying every other character verbatim. Returns `formula` +/// unchanged when it holds none or does not parse. +std::string spell_storage_operators(std::string_view formula); + } // namespace parser } // namespace formulon diff --git a/tests/unit/parser/ast_format_test.cpp b/tests/unit/parser/ast_format_test.cpp index 7e1ebefcb..b9eac30ca 100644 --- a/tests/unit/parser/ast_format_test.cpp +++ b/tests/unit/parser/ast_format_test.cpp @@ -13,6 +13,7 @@ #include "gtest/gtest.h" #include "parser/ast.h" #include "parser/ast_dump.h" +#include "parser/formula_prefix.h" #include "parser/parser.h" #include "parser/reference.h" #include "utils/arena.h" From b85fde7bc820a47c4763b76ca5ced7dd3e081386 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:13:30 +0900 Subject: [PATCH 10/93] refactor(xlsb): move workbook.bin global-record decoding into workbook_bin_reader - move the sheet bundle, date system, protection, BrtName, supporting-book and BrtExternSheet decoding out of reader.cpp into workbook_bin_reader.{h,cpp} - reader.cpp keeps the package-level orchestration and consumes the new header - register workbook_bin_reader.cpp in FORMULON_CORE_SOURCES - behavior-preserving move, no logic changes --- CMakeLists.txt | 1 + src/io/xlsb/reader.cpp | 535 +--------------------------- src/io/xlsb/workbook_bin_reader.cpp | 520 +++++++++++++++++++++++++++ src/io/xlsb/workbook_bin_reader.h | 101 ++++++ 4 files changed, 623 insertions(+), 534 deletions(-) create mode 100644 src/io/xlsb/workbook_bin_reader.cpp create mode 100644 src/io/xlsb/workbook_bin_reader.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ff7e8431b..45b5abde5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -190,6 +190,7 @@ set(FORMULON_CORE_SOURCES src/io/xlsb/styles_reader.cpp src/io/xlsb/styles_writer.cpp src/io/xlsb/tail_refs.cpp + src/io/xlsb/workbook_bin_reader.cpp src/io/xlsb/writer.cpp src/cf/cf_evaluator.cpp src/cf/cf_helpers.cpp diff --git a/src/io/xlsb/reader.cpp b/src/io/xlsb/reader.cpp index c018e5000..39484070b 100644 --- a/src/io/xlsb/reader.cpp +++ b/src/io/xlsb/reader.cpp @@ -16,11 +16,9 @@ #include "io/xlsb/reader.h" #include -#include #include #include #include -#include #include #include #include @@ -44,10 +42,9 @@ #include "io/xlsb/sheet_reader.h" #include "io/xlsb/sst_reader.h" #include "io/xlsb/styles_reader.h" +#include "io/xlsb/workbook_bin_reader.h" #include "io/zip_reader.h" #include "parser/ast.h" -#include "parser/ast_format.h" -#include "parser/formula_prefix.h" #include "passthrough_part.h" #include "phonetic.h" #include "pivot/pivot_cache.h" @@ -56,7 +53,6 @@ #include "pugixml.hpp" #include "sheet.h" #include "unknown_relationship.h" -#include "utils/arena.h" #include "utils/error.h" #include "utils/expected.h" #include "utils/resource_budget.h" @@ -355,535 +351,6 @@ Expected LoadWorkbookRels(const ZipReader& zip, std::string // Binary part decoders. // --------------------------------------------------------------------------- -/// One decoded entry of the workbook's sheet bundle. -/// -/// * `name` — display name of the sheet. -/// * `rid` — workbook-rels relationship id pointing at the sheet -/// binary part. -struct SheetBundleEntry { - std::string name; - std::string rid; - /// The sheet's `hsState`, which shares its numbering with - /// `SheetVisibility` and so is carried across whole. Very-hidden stays - /// distinct from hidden: it is the state that keeps a sheet out of - /// Excel's "Unhide" dialog. - SheetVisibility visibility = SheetVisibility::kVisible; -}; - -/// Workbook-global fields needed while constructing the model. -struct WorkbookBinInfo { - std::vector sheets; - bool date1904 = false; - /// `` rebuilt from BrtBookProtection(Iso); empty - /// when the book is unprotected or the records were not decodable. - std::string protection_xml; -}; - -/// Decodes `xl/workbook.bin` to extract the ordered sheet-bundle list and -/// workbook date system and protection. Other records are skipped. -Expected DecodeWorkbookBin(const std::vector& body) { - WorkbookBinInfo info; - ByteSpan protection_iso{}; - ByteSpan cursor{body.data(), body.size()}; - while (cursor.size > 0) { - auto rec_or = read_record(cursor); - if (!rec_or) { - return rec_or.error(); - } - const XlsbRecord& rec = rec_or.value(); - if (rec.type == static_cast(XlsbRecordType::BrtWbProp)) { - // BrtWbProp ([MS-XLSB] §2.4.866) begins with a u32 grbit; bit 0 - // is f1904. The following theme-version and optional code-name - // fields are irrelevant to the workbook model. - ByteSpan p = rec.payload; - auto flags_or = read_u32(p); - if (!flags_or) { - return flags_or.error(); - } - info.date1904 = (flags_or.value() & 0x00000001U) != 0U; - continue; - } - if (rec.type == kBrtBookProtectionIso) { - protection_iso = rec.payload; - continue; - } - if (rec.type == kBrtBookProtection) { - if (!decode_book_protection(rec.payload, protection_iso, info.protection_xml)) { - info.protection_xml.clear(); - StructuredLog("xlsb.book_protection.not_decoded").warn(); - } - continue; - } - if (rec.type != static_cast(XlsbRecordType::BrtBundleSh)) { - continue; - } - // BrtBundleSh ([MS-XLSB] §2.4.304): - // hsState : u32 (visibility) - // iTabID : u32 - // strRelID : XLNullableWideString - // strName : XLWideString - ByteSpan p = rec.payload; - // hsState: 0 = visible, 1 = hidden, 2 = very hidden. - auto hs_state_or = read_u32(p); - if (!hs_state_or) { - return hs_state_or.error(); - } - auto skip2 = read_u32(p); // iTabID - if (!skip2) { - return skip2.error(); - } - auto rid_or = read_xlnullablewidestring(p); - if (!rid_or) { - return rid_or.error(); - } - auto name_or = read_xlwidestring(p); - if (!name_or) { - return name_or.error(); - } - SheetBundleEntry entry; - entry.rid = std::move(rid_or.value()); - entry.name = std::move(name_or.value()); - // An hsState outside the three defined values is not a visibility this - // model can name; treat anything non-zero it cannot place as plain - // hidden, which is the conservative direction (the sheet stays out of - // sight rather than appearing unbidden). - switch (hs_state_or.value()) { - case 0U: - entry.visibility = SheetVisibility::kVisible; - break; - case 2U: - entry.visibility = SheetVisibility::kVeryHidden; - break; - default: - entry.visibility = SheetVisibility::kHidden; - break; - } - info.sheets.push_back(std::move(entry)); - } - if (info.sheets.empty()) { - return make_error(FormulonErrorCode::kIoXlsbCorrupt, "workbook.bin: no BrtBundleSh records", - "context=xlsb_reader part=xl/workbook.bin"); - } - return info; -} - -/// Decodes a UTF-16LE name of `units` code units starting at `cursor`, -/// advancing it past the name. `units` is caller-known (from a fixed-size -/// header field), unlike `read_xlwidestring`'s self-describing length. This -/// path intentionally avoids Expected: malformed -/// workbook names are common fuzz inputs, and libc++'s variant dispatch -/// under UBSan must not turn recoverable input errors into a process abort. -bool ReadFixedWideString(ByteSpan& cursor, std::uint32_t units, std::string& out) { - // Check in the destination width before multiplying. On wasm32, a hostile - // u32 `units` value can wrap `units * 2` back below cursor.size otherwise. - if (units > cursor.size / 2U) { - return false; - } - const std::size_t byte_len = static_cast(units) * 2U; - out.clear(); - out.reserve(byte_len); - for (std::uint32_t i = 0; i < units; ++i) { - const std::size_t offset = static_cast(i) * 2U; - const std::uint16_t cu = static_cast(static_cast(cursor.data[offset]) | - (static_cast(cursor.data[offset + 1U]) << 8)); - std::uint32_t cp = cu; - if (cu >= 0xD800U && cu <= 0xDBFFU && i + 1 < units) { - const std::size_t low_offset = static_cast(i + 1U) * 2U; - const std::uint16_t low = - static_cast(static_cast(cursor.data[low_offset]) | - (static_cast(cursor.data[low_offset + 1U]) << 8)); - if (low >= 0xDC00U && low <= 0xDFFFU) { - cp = 0x10000U + ((static_cast(cu) - 0xD800U) << 10) + (low - 0xDC00U); - ++i; - } - } - if (cp < 0x80U) { - out.push_back(static_cast(cp)); - } else if (cp < 0x800U) { - out.push_back(static_cast(0xC0U | (cp >> 6))); - out.push_back(static_cast(0x80U | (cp & 0x3FU))); - } else if (cp < 0x10000U) { - out.push_back(static_cast(0xE0U | (cp >> 12))); - out.push_back(static_cast(0x80U | ((cp >> 6) & 0x3FU))); - out.push_back(static_cast(0x80U | (cp & 0x3FU))); - } else { - out.push_back(static_cast(0xF0U | (cp >> 18))); - out.push_back(static_cast(0x80U | ((cp >> 12) & 0x3FU))); - out.push_back(static_cast(0x80U | ((cp >> 6) & 0x3FU))); - out.push_back(static_cast(0x80U | (cp & 0x3FU))); - } - } - cursor.data += byte_len; - cursor.size -= byte_len; - return true; -} - -/// True when `name` carries one of Excel's hidden storage prefixes, i.e. -/// the record is a `_xlfn.` future-function or `_xlpm.` -/// LET / LAMBDA-parameter placeholder rather than a user-visible defined -/// name. Matched case-insensitively, the same way `ptg_reader.cpp` -/// resolves these names during Ptg decode. -/// -/// This is deliberately NOT the `fHidden` bit: Excel sets `fHidden` on -/// the placeholders, but it also sets it on an ordinary defined name the -/// user chose to hide from the Name Manager, and both this reader's -/// writer counterpart and Excel itself store the two the same way. -bool IsStoragePlaceholderName(std::string_view name) { - constexpr std::string_view kPrefixes[] = {"_xlfn.", "_xlpm."}; - for (const std::string_view prefix : kPrefixes) { - if (name.size() < prefix.size()) { - continue; - } - bool match = true; - for (std::size_t i = 0; i < prefix.size(); ++i) { - const char lhs = static_cast(std::tolower(static_cast(name[i]))); - if (lhs != prefix[i]) { - match = false; - break; - } - } - if (match) { - return true; - } - } - return false; -} - -/// Decodes the workbook-scope `BrtName` table from `xl/workbook.bin`: -/// ordinary defined names and the hidden `_xlfn.*` / `_xlpm.*` -/// future-function / LET-parameter placeholders `PtgName` resolves by -/// 1-based declaration order. Byte layout verified against a real -/// Excel-365-produced `xl/workbook.bin`: -/// flags (u16) + 3 reserved bytes + itab (i32) + cch (u32) + -/// cch x UTF-16LE code units + . -/// Records other than `BrtName` are skipped. -Expected, Error> DecodeWorkbookNames(const std::vector& body) { - std::vector names; - ByteSpan cursor{body.data(), body.size()}; - while (cursor.size > 0) { - auto rec_or = read_record(cursor); - if (!rec_or) { - return rec_or.error(); - } - const XlsbRecord& rec = rec_or.value(); - if (rec.type != static_cast(XlsbRecordType::BrtName)) { - continue; - } - ByteSpan p = rec.payload; - if (p.size < 5) { - return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, "workbook.bin: BrtName header truncated", - "context=xlsb_reader"); - } - auto flags_or = read_u16(p); - if (!flags_or) { - return flags_or.error(); - } - p.data += 3; // 3 reserved bytes between flags and itab. - p.size -= 3; - auto itab_or = read_u32(p); - if (!itab_or) { - return itab_or.error(); - } - auto cch_or = read_u32(p); - if (!cch_or) { - return cch_or.error(); - } - std::string name; - if (!ReadFixedWideString(p, cch_or.value(), name)) { - return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, "xlsb fixed-length wide string truncated", - "context=xlsb_reader"); - } - XlsbName entry; - entry.itab = static_cast(itab_or.value()); - entry.name = std::move(name); - entry.hidden = (flags_or.value() & 0x0001U) != 0; - names.push_back(std::move(entry)); - } - return names; -} - -/// Decodes the supporting-book list from `xl/workbook.bin` into one -/// entry per book, in `BrtExternSheet`'s `iSupBook` order. The value is -/// `0` for this workbook and `N >= 1` for the N-th external book in -/// package order, which is the number the equivalent xlsx formula text -/// spells as `[N]`. -/// -/// Layout verified against Excel-365-produced `xl/workbook.bin` files: -/// the books are the records between `BrtBeginExternals` and -/// `BrtEndExternals`, one record each, and the record id alone says -/// which kind it is. Only `BrtSupSelf` is this workbook; `BrtSupAddin` -/// and `BrtSupSame` are counted so later entries keep their index, but -/// neither names a resolvable external package, so both are reported as -/// external and therefore refused downstream. -/// -/// A workbook can legitimately have no self entry: one whose only -/// qualified references are cross-workbook carries a single -/// `BrtSupBookSrc`, so `iSupBook == 0` is then an external book. That -/// case is why this list has to be read rather than assumed. -/// -/// A `BrtSupBookSrc` also names the relationship its external link part -/// hangs off, which is how a `[N]` is turned back into a package part. -/// That id is retained on the entry; the other kinds leave it empty. -struct XlsbSupBook { - std::uint32_t external_book = 0; - std::string rel_id; -}; - -std::vector DecodeSupBooks(const std::vector& body) { - std::vector books; - ByteSpan cursor{body.data(), body.size()}; - bool inside = false; - std::uint32_t next_external = 1; - while (cursor.size > 0) { - auto rec_or = read_record(cursor); - if (!rec_or) { - break; - } - const XlsbRecord& rec = rec_or.value(); - const auto type = static_cast(rec.type); - if (type == XlsbRecordType::BrtBeginExternals) { - inside = true; - continue; - } - if (type == XlsbRecordType::BrtEndExternals) { - break; - } - if (!inside) { - continue; - } - switch (type) { - case XlsbRecordType::BrtSupSelf: - books.push_back(XlsbSupBook{}); - break; - case XlsbRecordType::BrtSupBookSrc: { - ByteSpan payload = rec.payload; - auto rid_or = read_xlwidestring(payload); - books.push_back(XlsbSupBook{next_external, rid_or ? std::move(rid_or.value()) : std::string()}); - ++next_external; - break; - } - case XlsbRecordType::BrtSupAddin: - case XlsbRecordType::BrtSupSame: - books.push_back(XlsbSupBook{next_external, std::string()}); - ++next_external; - break; - default: - // `BrtSupTabs` and the future-record framing that decorates a - // book entry sit inside the same block without adding a book. - break; - } - } - return books; -} - -/// Decodes the `BrtExternSheet` table from `xl/workbook.bin`: resolves a -/// `PtgRef3d` / `PtgArea3d` `ixti` (0-based index into the returned -/// vector) to a `(itabFirst, itabLast)` sheet-index range. Byte layout -/// verified against a real Excel-365-produced `xl/workbook.bin`: u32 -/// count, followed by `count` entries of `(iSupBook, itabFirst, -/// itabLast)` as 3 x i32 each. `iSupBook` is resolved through -/// `DecodeSupBooks` so `decode_ptgs` can tell an internal range from an -/// external-workbook one, whose sheet indices index the supporting book -/// rather than this workbook's `sheet_names`. -/// A workbook with no qualified references at all carries no -/// `BrtExternSheet` record, so an empty result is a normal outcome, not -/// an error. -Expected, Error> DecodeExternSheet(const std::vector& body, - const std::vector& books) { - std::vector ranges; - ByteSpan cursor{body.data(), body.size()}; - while (cursor.size > 0) { - auto rec_or = read_record(cursor); - if (!rec_or) { - return rec_or.error(); - } - const XlsbRecord& rec = rec_or.value(); - if (rec.type != static_cast(XlsbRecordType::BrtExternSheet)) { - continue; - } - ByteSpan p = rec.payload; - auto count_or = read_u32(p); - if (!count_or) { - return count_or.error(); - } - // Each entry consumes three u32s. Never reserve beyond what the - // remaining payload can actually hold, so an attacker-controlled - // count (up to 4 billion) cannot force a multi-GB reservation before - // the per-entry reads run out of bytes and fail. - constexpr std::size_t kExternSheetEntryBytes = 12U; - const std::size_t reservable = - static_cast(std::min(count_or.value(), p.size / kExternSheetEntryBytes)); - ranges.reserve(reservable); - for (std::uint32_t i = 0; i < count_or.value(); ++i) { - auto sup_book_or = read_u32(p); - if (!sup_book_or) { - return sup_book_or.error(); - } - auto first_or = read_u32(p); - if (!first_or) { - return first_or.error(); - } - auto last_or = read_u32(p); - if (!last_or) { - return last_or.error(); - } - // An `iSupBook` past the end of the list cannot be resolved. Treat - // it as external: that refuses the reference, where assuming - // "internal" would bind it to a local sheet by index. - // - // An *entirely* absent list is a different situation: the file - // carries qualified references but no supporting-book block to - // resolve them against, which no Excel-written file observed here - // does. Refusing every reference would regress such a file from - // working to undecodable, so it keeps the weaker rule that index - // 0 is this workbook. - const std::uint32_t sup_book = sup_book_or.value(); - std::uint32_t external_book = std::numeric_limits::max(); - if (books.empty()) { - external_book = sup_book == 0U ? 0U : sup_book; - } else if (sup_book < books.size()) { - external_book = books[sup_book].external_book; - } - ranges.push_back(XlsbSheetRange{static_cast(first_or.value()), - static_cast(last_or.value()), external_book}); - } - break; // Exactly one BrtExternSheet record per workbook. - } - return ranges; -} - -/// Registers every user-visible `BrtName` entry as a workbook defined -/// name via a single bulk `Workbook::set_defined_names` call (mirroring -/// the OOXML reader's `ooxml_reader.cpp` pattern -- a load-time -/// population pass, not the incremental single-name edit API -/// `set_defined_name_scoped` guards with dedup/dep-graph-rebuild logic -/// that only matters for post-load mutation). Only the storage -/// placeholders (the `_xlfn.*` / `_xlpm.*` future-function and -/// LET/LAMBDA-parameter names `PtgName` resolves during Ptg decode — see -/// `name_table`) are skipped; they are not user-visible names and never -/// carry a Name Manager comment. A name Excel merely hides from the Name -/// Manager keeps its `fHidden` bit on the produced `DefinedName` and -/// is registered like any other. Walks `xl/workbook.bin`'s `BrtName` -/// records a second time (after `DecodeWorkbookNames` has already built -/// the complete `name_table`), decoding each entry's own formula body so -/// a qualified or name-referencing formula (e.g. `Rate` defined as a -/// cell reference) resolves against the full table rather than a -/// partially-built one. A name whose formula uses a Ptg token outside -/// the supported set logs a structured warning and is skipped rather -/// than failing the whole read — the OOXML reader's `DecodeFormulaText` -/// contract mirrored at the workbook-name level. -Expected RegisterDefinedNames(const std::vector& body, - const std::vector& name_table, - const std::vector& sheet_names, - const std::vector& sheet_ranges, - const XlsbExternalBooks& external_books, Workbook& wb, - std::uint32_t* undecoded_defined_name_count) { - ByteSpan cursor{body.data(), body.size()}; - std::size_t name_index = 0; - std::vector out; - while (cursor.size > 0) { - auto rec_or = read_record(cursor); - if (!rec_or) { - return rec_or.error(); - } - const XlsbRecord& rec = rec_or.value(); - if (rec.type != static_cast(XlsbRecordType::BrtName)) { - continue; - } - if (name_index >= name_table.size()) { - break; // Defensive: should be unreachable (same records, same order). - } - const XlsbName& entry = name_table[name_index]; - ++name_index; - if (IsStoragePlaceholderName(entry.name)) { - continue; - } - ByteSpan p = rec.payload; - if (p.size < 9) { - return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, - "workbook.bin: BrtName header truncated (defined-name pass)", "context=xlsb_reader"); - } - p.data += 9; // flags (2) + 3 reserved bytes + itab (4). - p.size -= 9; - auto cch_or = read_u32(p); - if (!cch_or) { - return cch_or.error(); - } - const std::size_t name_bytes = static_cast(cch_or.value()) * 2; - if (name_bytes > p.size) { - return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, - "workbook.bin: BrtName name truncated (defined-name pass)", "context=xlsb_reader"); - } - p.data += name_bytes; - p.size -= name_bytes; - auto cce_or = read_u32(p); - if (!cce_or) { - return cce_or.error(); - } - const std::uint32_t cce = cce_or.value(); - if (cce > p.size) { - return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, - "workbook.bin: BrtName formula rgce length exceeds payload", "context=xlsb_reader"); - } - if (cce == 0) { - continue; - } - ByteSpan rgce{p.data, cce}; - p.data += cce; - p.size -= cce; - // `cb` + `rgcb` ([MS-XLSB] §2.4.649's `CellParsedFormula`): the - // array-constant / mem-area extra data for this formula's own Ptg - // tokens. Must be skipped correctly (not just assumed absent) to - // land on the trailing comment string below. - auto cb_or = read_u32(p); - if (!cb_or) { - return cb_or.error(); - } - const std::uint32_t cb = cb_or.value(); - if (cb > p.size) { - return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, - "workbook.bin: BrtName formula rgcb length exceeds payload", "context=xlsb_reader"); - } - ByteSpan rgcb{p.data, cb}; - p.data += cb; - p.size -= cb; - Arena arena(/*initial_chunk_bytes=*/4096, kMaxLoadArenaBytes); - auto ast_or = decode_ptgs(rgce, rgcb, arena, sheet_names, name_table, sheet_ranges, external_books); - if (!ast_or) { - StructuredLog("xlsb.defined_name.not_decoded") - .field("name", entry.name) - .field("reason", ast_or.error().message) - .warn(); - if (undecoded_defined_name_count != nullptr) { - ++*undecoded_defined_name_count; - } - continue; - } - // Trailing BrtName strings: a plain name carries exactly one, the - // optional Name Manager comment, as a null `XLNullableWideString` - // when unset (`read_xlnullablewidestring` maps that to an empty - // string). This mirrors the writer's `EmitName`. - auto comment_or = read_xlnullablewidestring(p); - if (!comment_or) { - return comment_or.error(); - } - // Defined-name formulas store the bare expression text (no leading - // `=`), matching the OOXML `` element's text content. - DefinedName dn; - dn.name = entry.name; - // The decoder names a hidden-name callee with its storage prefix; the - // text reads back in formula-bar spelling, like a cell's. - dn.formula = parser::spell_storage_operators( - parser::strip_storage_prefixes(parser::format_formula(*ast_or.value()), &has_storage_prefix)); - dn.local_sheet_id = entry.itab; - dn.hidden = entry.hidden; - dn.comment = std::move(comment_or.value()); - out.push_back(std::move(dn)); - } - wb.set_defined_names(std::move(out)); - return Expected::Ok(); -} - /// Reads every entry of a worksheet's `.rels` part, keeping the original /// relationship ids so the retained tail records still resolve. Internal /// targets are normalised to package-relative paths (matching what the OOXML diff --git a/src/io/xlsb/workbook_bin_reader.cpp b/src/io/xlsb/workbook_bin_reader.cpp new file mode 100644 index 000000000..d9d3fb7a7 --- /dev/null +++ b/src/io/xlsb/workbook_bin_reader.cpp @@ -0,0 +1,520 @@ +// +// Implementation of the `xl/workbook.bin` global-record decoders. See +// `io/xlsb/workbook_bin_reader.h`. + +#include "io/xlsb/workbook_bin_reader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "io/future_functions.h" +#include "io/xlsb/protection_records.h" +#include "io/xlsb/ptg_reader.h" +#include "io/xlsb/record.h" +#include "parser/ast.h" +#include "parser/ast_format.h" +#include "parser/formula_prefix.h" +#include "sheet.h" +#include "utils/arena.h" +#include "utils/error.h" +#include "utils/expected.h" +#include "utils/resource_budget.h" +#include "utils/structured_log.h" +#include "workbook.h" + +namespace formulon { +namespace io { +namespace xlsb { + +/// Decodes `xl/workbook.bin` to extract the ordered sheet-bundle list and +/// workbook date system and protection. Other records are skipped. +Expected DecodeWorkbookBin(const std::vector& body) { + WorkbookBinInfo info; + ByteSpan protection_iso{}; + ByteSpan cursor{body.data(), body.size()}; + while (cursor.size > 0) { + auto rec_or = read_record(cursor); + if (!rec_or) { + return rec_or.error(); + } + const XlsbRecord& rec = rec_or.value(); + if (rec.type == static_cast(XlsbRecordType::BrtWbProp)) { + // BrtWbProp ([MS-XLSB] §2.4.866) begins with a u32 grbit; bit 0 + // is f1904. The following theme-version and optional code-name + // fields are irrelevant to the workbook model. + ByteSpan p = rec.payload; + auto flags_or = read_u32(p); + if (!flags_or) { + return flags_or.error(); + } + info.date1904 = (flags_or.value() & 0x00000001U) != 0U; + continue; + } + if (rec.type == kBrtBookProtectionIso) { + protection_iso = rec.payload; + continue; + } + if (rec.type == kBrtBookProtection) { + if (!decode_book_protection(rec.payload, protection_iso, info.protection_xml)) { + info.protection_xml.clear(); + StructuredLog("xlsb.book_protection.not_decoded").warn(); + } + continue; + } + if (rec.type != static_cast(XlsbRecordType::BrtBundleSh)) { + continue; + } + // BrtBundleSh ([MS-XLSB] §2.4.304): + // hsState : u32 (visibility) + // iTabID : u32 + // strRelID : XLNullableWideString + // strName : XLWideString + ByteSpan p = rec.payload; + // hsState: 0 = visible, 1 = hidden, 2 = very hidden. + auto hs_state_or = read_u32(p); + if (!hs_state_or) { + return hs_state_or.error(); + } + auto skip2 = read_u32(p); // iTabID + if (!skip2) { + return skip2.error(); + } + auto rid_or = read_xlnullablewidestring(p); + if (!rid_or) { + return rid_or.error(); + } + auto name_or = read_xlwidestring(p); + if (!name_or) { + return name_or.error(); + } + SheetBundleEntry entry; + entry.rid = std::move(rid_or.value()); + entry.name = std::move(name_or.value()); + // An hsState outside the three defined values is not a visibility this + // model can name; treat anything non-zero it cannot place as plain + // hidden, which is the conservative direction (the sheet stays out of + // sight rather than appearing unbidden). + switch (hs_state_or.value()) { + case 0U: + entry.visibility = SheetVisibility::kVisible; + break; + case 2U: + entry.visibility = SheetVisibility::kVeryHidden; + break; + default: + entry.visibility = SheetVisibility::kHidden; + break; + } + info.sheets.push_back(std::move(entry)); + } + if (info.sheets.empty()) { + return make_error(FormulonErrorCode::kIoXlsbCorrupt, "workbook.bin: no BrtBundleSh records", + "context=xlsb_reader part=xl/workbook.bin"); + } + return info; +} + +namespace { + +/// Decodes a UTF-16LE name of `units` code units starting at `cursor`, +/// advancing it past the name. `units` is caller-known (from a fixed-size +/// header field), unlike `read_xlwidestring`'s self-describing length. This +/// path intentionally avoids Expected: malformed +/// workbook names are common fuzz inputs, and libc++'s variant dispatch +/// under UBSan must not turn recoverable input errors into a process abort. +bool ReadFixedWideString(ByteSpan& cursor, std::uint32_t units, std::string& out) { + // Check in the destination width before multiplying. On wasm32, a hostile + // u32 `units` value can wrap `units * 2` back below cursor.size otherwise. + if (units > cursor.size / 2U) { + return false; + } + const std::size_t byte_len = static_cast(units) * 2U; + out.clear(); + out.reserve(byte_len); + for (std::uint32_t i = 0; i < units; ++i) { + const std::size_t offset = static_cast(i) * 2U; + const std::uint16_t cu = static_cast(static_cast(cursor.data[offset]) | + (static_cast(cursor.data[offset + 1U]) << 8)); + std::uint32_t cp = cu; + if (cu >= 0xD800U && cu <= 0xDBFFU && i + 1 < units) { + const std::size_t low_offset = static_cast(i + 1U) * 2U; + const std::uint16_t low = + static_cast(static_cast(cursor.data[low_offset]) | + (static_cast(cursor.data[low_offset + 1U]) << 8)); + if (low >= 0xDC00U && low <= 0xDFFFU) { + cp = 0x10000U + ((static_cast(cu) - 0xD800U) << 10) + (low - 0xDC00U); + ++i; + } + } + if (cp < 0x80U) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800U) { + out.push_back(static_cast(0xC0U | (cp >> 6))); + out.push_back(static_cast(0x80U | (cp & 0x3FU))); + } else if (cp < 0x10000U) { + out.push_back(static_cast(0xE0U | (cp >> 12))); + out.push_back(static_cast(0x80U | ((cp >> 6) & 0x3FU))); + out.push_back(static_cast(0x80U | (cp & 0x3FU))); + } else { + out.push_back(static_cast(0xF0U | (cp >> 18))); + out.push_back(static_cast(0x80U | ((cp >> 12) & 0x3FU))); + out.push_back(static_cast(0x80U | ((cp >> 6) & 0x3FU))); + out.push_back(static_cast(0x80U | (cp & 0x3FU))); + } + } + cursor.data += byte_len; + cursor.size -= byte_len; + return true; +} + +/// True when `name` carries one of Excel's hidden storage prefixes, i.e. +/// the record is a `_xlfn.` future-function or `_xlpm.` +/// LET / LAMBDA-parameter placeholder rather than a user-visible defined +/// name. Matched case-insensitively, the same way `ptg_reader.cpp` +/// resolves these names during Ptg decode. +/// +/// This is deliberately NOT the `fHidden` bit: Excel sets `fHidden` on +/// the placeholders, but it also sets it on an ordinary defined name the +/// user chose to hide from the Name Manager, and both this reader's +/// writer counterpart and Excel itself store the two the same way. +bool IsStoragePlaceholderName(std::string_view name) { + constexpr std::string_view kPrefixes[] = {"_xlfn.", "_xlpm."}; + for (const std::string_view prefix : kPrefixes) { + if (name.size() < prefix.size()) { + continue; + } + bool match = true; + for (std::size_t i = 0; i < prefix.size(); ++i) { + const char lhs = static_cast(std::tolower(static_cast(name[i]))); + if (lhs != prefix[i]) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; +} + +} // namespace + +/// Decodes the workbook-scope `BrtName` table from `xl/workbook.bin`: +/// ordinary defined names and the hidden `_xlfn.*` / `_xlpm.*` +/// future-function / LET-parameter placeholders `PtgName` resolves by +/// 1-based declaration order. Byte layout verified against a real +/// Excel-365-produced `xl/workbook.bin`: +/// flags (u16) + 3 reserved bytes + itab (i32) + cch (u32) + +/// cch x UTF-16LE code units + . +/// Records other than `BrtName` are skipped. +Expected, Error> DecodeWorkbookNames(const std::vector& body) { + std::vector names; + ByteSpan cursor{body.data(), body.size()}; + while (cursor.size > 0) { + auto rec_or = read_record(cursor); + if (!rec_or) { + return rec_or.error(); + } + const XlsbRecord& rec = rec_or.value(); + if (rec.type != static_cast(XlsbRecordType::BrtName)) { + continue; + } + ByteSpan p = rec.payload; + if (p.size < 5) { + return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, "workbook.bin: BrtName header truncated", + "context=xlsb_reader"); + } + auto flags_or = read_u16(p); + if (!flags_or) { + return flags_or.error(); + } + p.data += 3; // 3 reserved bytes between flags and itab. + p.size -= 3; + auto itab_or = read_u32(p); + if (!itab_or) { + return itab_or.error(); + } + auto cch_or = read_u32(p); + if (!cch_or) { + return cch_or.error(); + } + std::string name; + if (!ReadFixedWideString(p, cch_or.value(), name)) { + return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, "xlsb fixed-length wide string truncated", + "context=xlsb_reader"); + } + XlsbName entry; + entry.itab = static_cast(itab_or.value()); + entry.name = std::move(name); + entry.hidden = (flags_or.value() & 0x0001U) != 0; + names.push_back(std::move(entry)); + } + return names; +} + +std::vector DecodeSupBooks(const std::vector& body) { + std::vector books; + ByteSpan cursor{body.data(), body.size()}; + bool inside = false; + std::uint32_t next_external = 1; + while (cursor.size > 0) { + auto rec_or = read_record(cursor); + if (!rec_or) { + break; + } + const XlsbRecord& rec = rec_or.value(); + const auto type = static_cast(rec.type); + if (type == XlsbRecordType::BrtBeginExternals) { + inside = true; + continue; + } + if (type == XlsbRecordType::BrtEndExternals) { + break; + } + if (!inside) { + continue; + } + switch (type) { + case XlsbRecordType::BrtSupSelf: + books.push_back(XlsbSupBook{}); + break; + case XlsbRecordType::BrtSupBookSrc: { + ByteSpan payload = rec.payload; + auto rid_or = read_xlwidestring(payload); + books.push_back(XlsbSupBook{next_external, rid_or ? std::move(rid_or.value()) : std::string()}); + ++next_external; + break; + } + case XlsbRecordType::BrtSupAddin: + case XlsbRecordType::BrtSupSame: + books.push_back(XlsbSupBook{next_external, std::string()}); + ++next_external; + break; + default: + // `BrtSupTabs` and the future-record framing that decorates a + // book entry sit inside the same block without adding a book. + break; + } + } + return books; +} + +/// Decodes the `BrtExternSheet` table from `xl/workbook.bin`: resolves a +/// `PtgRef3d` / `PtgArea3d` `ixti` (0-based index into the returned +/// vector) to a `(itabFirst, itabLast)` sheet-index range. Byte layout +/// verified against a real Excel-365-produced `xl/workbook.bin`: u32 +/// count, followed by `count` entries of `(iSupBook, itabFirst, +/// itabLast)` as 3 x i32 each. `iSupBook` is resolved through +/// `DecodeSupBooks` so `decode_ptgs` can tell an internal range from an +/// external-workbook one, whose sheet indices index the supporting book +/// rather than this workbook's `sheet_names`. +/// A workbook with no qualified references at all carries no +/// `BrtExternSheet` record, so an empty result is a normal outcome, not +/// an error. +Expected, Error> DecodeExternSheet(const std::vector& body, + const std::vector& books) { + std::vector ranges; + ByteSpan cursor{body.data(), body.size()}; + while (cursor.size > 0) { + auto rec_or = read_record(cursor); + if (!rec_or) { + return rec_or.error(); + } + const XlsbRecord& rec = rec_or.value(); + if (rec.type != static_cast(XlsbRecordType::BrtExternSheet)) { + continue; + } + ByteSpan p = rec.payload; + auto count_or = read_u32(p); + if (!count_or) { + return count_or.error(); + } + // Each entry consumes three u32s. Never reserve beyond what the + // remaining payload can actually hold, so an attacker-controlled + // count (up to 4 billion) cannot force a multi-GB reservation before + // the per-entry reads run out of bytes and fail. + constexpr std::size_t kExternSheetEntryBytes = 12U; + const std::size_t reservable = + static_cast(std::min(count_or.value(), p.size / kExternSheetEntryBytes)); + ranges.reserve(reservable); + for (std::uint32_t i = 0; i < count_or.value(); ++i) { + auto sup_book_or = read_u32(p); + if (!sup_book_or) { + return sup_book_or.error(); + } + auto first_or = read_u32(p); + if (!first_or) { + return first_or.error(); + } + auto last_or = read_u32(p); + if (!last_or) { + return last_or.error(); + } + // An `iSupBook` past the end of the list cannot be resolved. Treat + // it as external: that refuses the reference, where assuming + // "internal" would bind it to a local sheet by index. + // + // An *entirely* absent list is a different situation: the file + // carries qualified references but no supporting-book block to + // resolve them against, which no Excel-written file observed here + // does. Refusing every reference would regress such a file from + // working to undecodable, so it keeps the weaker rule that index + // 0 is this workbook. + const std::uint32_t sup_book = sup_book_or.value(); + std::uint32_t external_book = std::numeric_limits::max(); + if (books.empty()) { + external_book = sup_book == 0U ? 0U : sup_book; + } else if (sup_book < books.size()) { + external_book = books[sup_book].external_book; + } + ranges.push_back(XlsbSheetRange{static_cast(first_or.value()), + static_cast(last_or.value()), external_book}); + } + break; // Exactly one BrtExternSheet record per workbook. + } + return ranges; +} + +/// Registers every user-visible `BrtName` entry as a workbook defined +/// name via a single bulk `Workbook::set_defined_names` call (mirroring +/// the OOXML reader's `ooxml_reader.cpp` pattern -- a load-time +/// population pass, not the incremental single-name edit API +/// `set_defined_name_scoped` guards with dedup/dep-graph-rebuild logic +/// that only matters for post-load mutation). Only the storage +/// placeholders (the `_xlfn.*` / `_xlpm.*` future-function and +/// LET/LAMBDA-parameter names `PtgName` resolves during Ptg decode — see +/// `name_table`) are skipped; they are not user-visible names and never +/// carry a Name Manager comment. A name Excel merely hides from the Name +/// Manager keeps its `fHidden` bit on the produced `DefinedName` and +/// is registered like any other. Walks `xl/workbook.bin`'s `BrtName` +/// records a second time (after `DecodeWorkbookNames` has already built +/// the complete `name_table`), decoding each entry's own formula body so +/// a qualified or name-referencing formula (e.g. `Rate` defined as a +/// cell reference) resolves against the full table rather than a +/// partially-built one. A name whose formula uses a Ptg token outside +/// the supported set logs a structured warning and is skipped rather +/// than failing the whole read — the OOXML reader's `DecodeFormulaText` +/// contract mirrored at the workbook-name level. +Expected RegisterDefinedNames(const std::vector& body, + const std::vector& name_table, + const std::vector& sheet_names, + const std::vector& sheet_ranges, + const XlsbExternalBooks& external_books, Workbook& wb, + std::uint32_t* undecoded_defined_name_count) { + ByteSpan cursor{body.data(), body.size()}; + std::size_t name_index = 0; + std::vector out; + while (cursor.size > 0) { + auto rec_or = read_record(cursor); + if (!rec_or) { + return rec_or.error(); + } + const XlsbRecord& rec = rec_or.value(); + if (rec.type != static_cast(XlsbRecordType::BrtName)) { + continue; + } + if (name_index >= name_table.size()) { + break; // Defensive: should be unreachable (same records, same order). + } + const XlsbName& entry = name_table[name_index]; + ++name_index; + if (IsStoragePlaceholderName(entry.name)) { + continue; + } + ByteSpan p = rec.payload; + if (p.size < 9) { + return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, + "workbook.bin: BrtName header truncated (defined-name pass)", "context=xlsb_reader"); + } + p.data += 9; // flags (2) + 3 reserved bytes + itab (4). + p.size -= 9; + auto cch_or = read_u32(p); + if (!cch_or) { + return cch_or.error(); + } + const std::size_t name_bytes = static_cast(cch_or.value()) * 2; + if (name_bytes > p.size) { + return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, + "workbook.bin: BrtName name truncated (defined-name pass)", "context=xlsb_reader"); + } + p.data += name_bytes; + p.size -= name_bytes; + auto cce_or = read_u32(p); + if (!cce_or) { + return cce_or.error(); + } + const std::uint32_t cce = cce_or.value(); + if (cce > p.size) { + return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, + "workbook.bin: BrtName formula rgce length exceeds payload", "context=xlsb_reader"); + } + if (cce == 0) { + continue; + } + ByteSpan rgce{p.data, cce}; + p.data += cce; + p.size -= cce; + // `cb` + `rgcb` ([MS-XLSB] §2.4.649's `CellParsedFormula`): the + // array-constant / mem-area extra data for this formula's own Ptg + // tokens. Must be skipped correctly (not just assumed absent) to + // land on the trailing comment string below. + auto cb_or = read_u32(p); + if (!cb_or) { + return cb_or.error(); + } + const std::uint32_t cb = cb_or.value(); + if (cb > p.size) { + return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, + "workbook.bin: BrtName formula rgcb length exceeds payload", "context=xlsb_reader"); + } + ByteSpan rgcb{p.data, cb}; + p.data += cb; + p.size -= cb; + Arena arena(/*initial_chunk_bytes=*/4096, kMaxLoadArenaBytes); + auto ast_or = decode_ptgs(rgce, rgcb, arena, sheet_names, name_table, sheet_ranges, external_books); + if (!ast_or) { + StructuredLog("xlsb.defined_name.not_decoded") + .field("name", entry.name) + .field("reason", ast_or.error().message) + .warn(); + if (undecoded_defined_name_count != nullptr) { + ++*undecoded_defined_name_count; + } + continue; + } + // Trailing BrtName strings: a plain name carries exactly one, the + // optional Name Manager comment, as a null `XLNullableWideString` + // when unset (`read_xlnullablewidestring` maps that to an empty + // string). This mirrors the writer's `EmitName`. + auto comment_or = read_xlnullablewidestring(p); + if (!comment_or) { + return comment_or.error(); + } + // Defined-name formulas store the bare expression text (no leading + // `=`), matching the OOXML `` element's text content. + DefinedName dn; + dn.name = entry.name; + // The decoder names a hidden-name callee with its storage prefix; the + // text reads back in formula-bar spelling, like a cell's. + dn.formula = parser::spell_storage_operators( + parser::strip_storage_prefixes(parser::format_formula(*ast_or.value()), &has_storage_prefix)); + dn.local_sheet_id = entry.itab; + dn.hidden = entry.hidden; + dn.comment = std::move(comment_or.value()); + out.push_back(std::move(dn)); + } + wb.set_defined_names(std::move(out)); + return Expected::Ok(); +} + +} // namespace xlsb +} // namespace io +} // namespace formulon diff --git a/src/io/xlsb/workbook_bin_reader.h b/src/io/xlsb/workbook_bin_reader.h new file mode 100644 index 000000000..bb744be79 --- /dev/null +++ b/src/io/xlsb/workbook_bin_reader.h @@ -0,0 +1,101 @@ +// +// Decoding of the global records of `xl/workbook.bin`: the sheet bundle, +// date system and protection, the `BrtName` table, the supporting-book list +// and the `BrtExternSheet` table that `PtgRef3d` / `PtgNameX` resolve +// through. Consumed by the package reader (`reader.cpp`). + +#ifndef FORMULON_IO_XLSB_WORKBOOK_BIN_READER_H_ +#define FORMULON_IO_XLSB_WORKBOOK_BIN_READER_H_ + +#include +#include +#include + +#include "io/xlsb/ptg_reader.h" +#include "sheet.h" +#include "utils/error.h" +#include "utils/expected.h" +#include "workbook.h" + +namespace formulon { +namespace io { +namespace xlsb { + +/// One decoded entry of the workbook's sheet bundle. +/// +/// * `name` — display name of the sheet. +/// * `rid` — workbook-rels relationship id pointing at the sheet +/// binary part. +struct SheetBundleEntry { + std::string name; + std::string rid; + /// The sheet's `hsState`, which shares its numbering with + /// `SheetVisibility` and so is carried across whole. Very-hidden stays + /// distinct from hidden: it is the state that keeps a sheet out of + /// Excel's "Unhide" dialog. + SheetVisibility visibility = SheetVisibility::kVisible; +}; + +/// Workbook-global fields needed while constructing the model. +struct WorkbookBinInfo { + std::vector sheets; + bool date1904 = false; + /// `` rebuilt from BrtBookProtection(Iso); empty + /// when the book is unprotected or the records were not decodable. + std::string protection_xml; +}; + +/// Decodes the supporting-book list from `xl/workbook.bin` into one +/// entry per book, in `BrtExternSheet`'s `iSupBook` order. The value is +/// `0` for this workbook and `N >= 1` for the N-th external book in +/// package order, which is the number the equivalent xlsx formula text +/// spells as `[N]`. +/// +/// Layout verified against Excel-365-produced `xl/workbook.bin` files: +/// the books are the records between `BrtBeginExternals` and +/// `BrtEndExternals`, one record each, and the record id alone says +/// which kind it is. Only `BrtSupSelf` is this workbook; `BrtSupAddin` +/// and `BrtSupSame` are counted so later entries keep their index, but +/// neither names a resolvable external package, so both are reported as +/// external and therefore refused downstream. +/// +/// A workbook can legitimately have no self entry: one whose only +/// qualified references are cross-workbook carries a single +/// `BrtSupBookSrc`, so `iSupBook == 0` is then an external book. That +/// case is why this list has to be read rather than assumed. +/// +/// A `BrtSupBookSrc` also names the relationship its external link part +/// hangs off, which is how a `[N]` is turned back into a package part. +/// That id is retained on the entry; the other kinds leave it empty. +struct XlsbSupBook { + std::uint32_t external_book = 0; + std::string rel_id; +}; + +/// Decodes `xl/workbook.bin` to extract the ordered sheet-bundle list and +/// workbook date system and protection. Other records are skipped. +Expected DecodeWorkbookBin(const std::vector& body); + +/// Decodes the workbook-scope `BrtName` table (declaration order). +Expected, Error> DecodeWorkbookNames(const std::vector& body); + +/// Decodes the supporting-book list in `BrtExternSheet`'s `iSupBook` order. +std::vector DecodeSupBooks(const std::vector& body); + +/// Decodes the `BrtExternSheet` table into `(itabFirst, itabLast)` ranges. +Expected, Error> DecodeExternSheet(const std::vector& body, + const std::vector& books); + +/// Registers every user-visible `BrtName` entry as a workbook defined name. +Expected RegisterDefinedNames(const std::vector& body, + const std::vector& name_table, + const std::vector& sheet_names, + const std::vector& sheet_ranges, + const XlsbExternalBooks& external_books, Workbook& wb, + std::uint32_t* undecoded_defined_name_count); + +} // namespace xlsb +} // namespace io +} // namespace formulon + +#endif // FORMULON_IO_XLSB_WORKBOOK_BIN_READER_H_ From ec8de1144d10b27edf4be28e7c8abd997385965d Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:13:41 +0900 Subject: [PATCH 11/93] refactor(xlsb): split workbook.bin encoding and Ptg name collection out of the writers - move the workbook.bin globals, sheet bundle, BrtExternSheet and BrtName encoding from writer.cpp into workbook_bin_writer.{h,cpp} - move the collect_* name and sheet-range passes from ptg_writer.cpp into ptg_declarations.cpp; their declarations stay in ptg_writer.h - add ptg_targets.h for the primitives shared by the encoder and the collection pass: IsFutureFunction, UsesHiddenNameRoute, resolve_ixti - kXtiNoSheet now has a single inline constexpr definition in ptg_targets.h (previously defined twice) - register ptg_declarations.cpp and workbook_bin_writer.cpp in FORMULON_CORE_SOURCES - behavior-preserving move, no encoding changes --- CMakeLists.txt | 2 + src/io/xlsb/ptg_declarations.cpp | 340 ++++++++++++++ src/io/xlsb/ptg_targets.h | 61 +++ src/io/xlsb/ptg_writer.cpp | 344 +------------- src/io/xlsb/workbook_bin_writer.cpp | 673 ++++++++++++++++++++++++++++ src/io/xlsb/workbook_bin_writer.h | 53 +++ src/io/xlsb/writer.cpp | 631 +------------------------- 7 files changed, 1132 insertions(+), 972 deletions(-) create mode 100644 src/io/xlsb/ptg_declarations.cpp create mode 100644 src/io/xlsb/ptg_targets.h create mode 100644 src/io/xlsb/workbook_bin_writer.cpp create mode 100644 src/io/xlsb/workbook_bin_writer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 45b5abde5..5dabeb13e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -175,6 +175,7 @@ set(FORMULON_CORE_SOURCES src/io/xlsb/func_id_table.cpp src/io/xlsb/metadata_bin.cpp src/io/xlsb/ptg.cpp + src/io/xlsb/ptg_declarations.cpp src/io/xlsb/ptg_reader.cpp src/io/xlsb/ptg_writer.cpp src/io/xlsb/pivot_reader.cpp @@ -191,6 +192,7 @@ set(FORMULON_CORE_SOURCES src/io/xlsb/styles_writer.cpp src/io/xlsb/tail_refs.cpp src/io/xlsb/workbook_bin_reader.cpp + src/io/xlsb/workbook_bin_writer.cpp src/io/xlsb/writer.cpp src/cf/cf_evaluator.cpp src/cf/cf_helpers.cpp diff --git a/src/io/xlsb/ptg_declarations.cpp b/src/io/xlsb/ptg_declarations.cpp new file mode 100644 index 000000000..9398e9f1b --- /dev/null +++ b/src/io/xlsb/ptg_declarations.cpp @@ -0,0 +1,340 @@ +// +// Implementation of the name / sheet-range collection pass declared in +// `io/xlsb/ptg_writer.h`: which `BrtName` and `BrtExternSheet` entries a +// formula's Ptg encoding will resolve through. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "io/future_functions.h" +#include "io/xlsb/func_id_table.h" +#include "io/xlsb/ptg_targets.h" +#include "io/xlsb/ptg_writer.h" +#include "parser/ast.h" +#include "parser/reference.h" +#include "utils/strings.h" + +namespace formulon { +namespace io { +namespace xlsb { +namespace { + +/// Recursion helper for `collect_ptg_names`: adds `name` to `names` (and +/// marks it in `seen`) unless already present. +void AddName(std::string_view name, std::vector& names, std::unordered_set& seen) { + std::string owned(name); + if (seen.insert(owned).second) { + names.push_back(std::move(owned)); + } +} + +/// Packs an `(itabFirst, itabLast)` pair into a single dedupe key. +std::uint64_t PackRangeKey(std::int32_t itab_first, std::int32_t itab_last) { + return (static_cast(static_cast(itab_first)) << 32) | + static_cast(static_cast(itab_last)); +} + +/// Recursion helper for `collect_ptg_sheet_ranges`: resolves `itab_first` +/// / `itab_last` and, when both are valid, appends the pair to `ranges` +/// unless already present in `seen`. +void AddSheetRange(std::int32_t itab_first, std::int32_t itab_last, SheetRangeTable& ranges, + std::unordered_set& seen) { + if (itab_first < 0 || itab_last < 0) { + return; // Unresolvable sheet name; the encode fails later with a precise error. + } + if (seen.insert(PackRangeKey(itab_first, itab_last)).second) { + ranges.emplace_back(itab_first, itab_last); + } +} + +enum class NameCollectMode : std::uint8_t { kPtg, kScopeResolved, kSheetQualified }; + +/// True when `name` matches an in-scope LET / LAMBDA parameter. +bool InParamScope(const std::vector& scope, std::string_view name) { + // Case-insensitive: see `Encoder::emit_name_ref`. + return std::any_of(scope.begin(), scope.end(), + [name](std::string_view param) { return strings::case_insensitive_eq(param, name); }); +} + +/// Recursive worker for `collect_ptg_names` carrying the LET / LAMBDA +/// parameter names currently in scope (innermost last). A `NameRef` +/// matching an in-scope parameter resolves at encode time to that +/// parameter's hidden `_xlpm.` placeholder (see +/// `Encoder::emit_name_ref`), so it must not be registered as an ordinary +/// workbook defined name. A callee with no function id (`Fn(3)`: a named +/// LAMBDA, or a name no one defined) is a name too. `mode` selects the view: +/// every unqualified name a `PtgName` or self-book `PtgNameX` needs +/// (`kPtg`); only names resolved from the formula's own scope +/// (`kScopeResolved`); or only sheet-qualified names, each added as `sheet` +/// NUL `name` (`kSheetQualified`). +void CollectNamesScoped(const parser::AstNode& node, std::vector& names, + std::unordered_set& seen, std::vector& scope, + NameCollectMode mode) { + // Hidden `_xlfn.*` / `_xlpm.*` records and unqualified names. + auto add = [&](std::string_view name) { + if (mode != NameCollectMode::kSheetQualified) { + AddName(name, names, seen); + } + }; + switch (node.kind()) { + case parser::NodeKind::NameRef: { + const std::string_view name = node.as_name(); + if (!node.as_name_sheet().empty()) { + // Resolves through a scoped key (a definition or its sheet's stub), + // never a LET / LAMBDA parameter or a workbook placeholder. + if (mode == NameCollectMode::kSheetQualified) { + AddName(std::string(node.as_name_sheet()) + '\0' + std::string(name), names, seen); + } + return; + } + if (InParamScope(scope, name)) { + return; // LET / LAMBDA parameter: encoded via its _xlpm. placeholder. + } + add(name); + return; + } + case parser::NodeKind::Call: { + const std::string_view name = canonical_function_name(node.as_call_name()); + if (UsesHiddenNameRoute(name)) { + add(xlsb_hidden_function_name(name)); + } else if (lookup_func_by_name(name) == nullptr && !InParamScope(scope, node.as_call_name())) { + add(node.as_call_name()); + } + const std::uint32_t arity = node.as_call_arity(); + for (std::uint32_t i = 0; i < arity; ++i) { + CollectNamesScoped(node.as_call_arg(i), names, seen, scope, mode); + } + return; + } + case parser::NodeKind::UnaryOp: + CollectNamesScoped(node.as_unary_operand(), names, seen, scope, mode); + return; + case parser::NodeKind::BinaryOp: + CollectNamesScoped(node.as_binary_lhs(), names, seen, scope, mode); + CollectNamesScoped(node.as_binary_rhs(), names, seen, scope, mode); + return; + case parser::NodeKind::RangeOp: + CollectNamesScoped(node.as_range_lhs(), names, seen, scope, mode); + CollectNamesScoped(node.as_range_rhs(), names, seen, scope, mode); + return; + case parser::NodeKind::IntersectOp: + CollectNamesScoped(node.as_intersect_lhs(), names, seen, scope, mode); + CollectNamesScoped(node.as_intersect_rhs(), names, seen, scope, mode); + return; + case parser::NodeKind::UnionOp: { + const std::uint32_t arity = node.as_union_arity(); + for (std::uint32_t i = 0; i < arity; ++i) { + CollectNamesScoped(node.as_union_child(i), names, seen, scope, mode); + } + return; + } + case parser::NodeKind::ImplicitIntersection: + add(xlsb_hidden_function_name("SINGLE")); + CollectNamesScoped(node.as_implicit_intersection_operand(), names, seen, scope, mode); + return; + case parser::NodeKind::ArrayLiteral: { + const std::uint32_t rows = node.as_array_rows(); + const std::uint32_t cols = node.as_array_cols(); + for (std::uint32_t r = 0; r < rows; ++r) { + for (std::uint32_t c = 0; c < cols; ++c) { + CollectNamesScoped(node.as_array_element(r, c), names, seen, scope, mode); + } + } + return; + } + case parser::NodeKind::SpillRef: { + // Stored as a call to the hidden `_xlfn.ANCHORARRAY` name (see + // `Encoder::emit_spill_ref`), so it needs the same BrtName + // registration any other future-function callee gets. + add(xlsb_hidden_function_name("ANCHORARRAY")); + if (const parser::AstNode* anchor = node.as_spill_ref_anchor_expr(); anchor != nullptr) { + CollectNamesScoped(*anchor, names, seen, scope, mode); + } + return; + } + case parser::NodeKind::ExternalRef: + // `[0]!Rate` is never a LET / LAMBDA parameter and, like `Sheet2!Rate`, + // not resolved from the formula's own scope. + if (parser::is_self_book_name_ref(node) && mode == NameCollectMode::kPtg) { + AddName(node.as_external_ref_name(), names, seen); + } + return; + case parser::NodeKind::LambdaCall: { + CollectNamesScoped(node.as_lambda_call_callee(), names, seen, scope, mode); + const std::uint32_t arity = node.as_lambda_call_arity(); + for (std::uint32_t i = 0; i < arity; ++i) { + CollectNamesScoped(node.as_lambda_call_arg(i), names, seen, scope, mode); + } + return; + } + case parser::NodeKind::LetBinding: { + add("_xlfn.LET"); + const std::uint32_t n = node.as_let_binding_count(); + const std::size_t scope_base = scope.size(); + for (std::uint32_t i = 0; i < n; ++i) { + add(std::string("_xlpm.") + std::string(node.as_let_binding_name(i))); + // Excel LET binds sequentially: a value expression sees only the + // earlier bindings, so collect it before pushing this parameter. + CollectNamesScoped(node.as_let_binding_expr(i), names, seen, scope, mode); + scope.push_back(node.as_let_binding_name(i)); + } + CollectNamesScoped(node.as_let_body(), names, seen, scope, mode); + scope.resize(scope_base); + return; + } + case parser::NodeKind::Lambda: { + add("_xlfn.LAMBDA"); + const std::uint32_t n = node.as_lambda_param_count(); + const std::size_t scope_base = scope.size(); + for (std::uint32_t i = 0; i < n; ++i) { + add(std::string("_xlpm.") + std::string(node.as_lambda_param(i))); + scope.push_back(node.as_lambda_param(i)); + } + CollectNamesScoped(node.as_lambda_body(), names, seen, scope, mode); + scope.resize(scope_base); + return; + } + // Leaves, and forms the encoder does not lower (StructuredRef): nothing + // to collect. A future writer bundle that lowers these would extend + // this switch alongside the corresponding `emit_*` case. + default: + return; + } +} + +} // namespace + +std::string sheet_scoped_name_key(std::int32_t itab, std::string_view name) { + std::string key = std::to_string(itab); + key.push_back('!'); + key.append(strings::to_ascii_lower(name)); + return key; +} + +void collect_ptg_names(const parser::AstNode& node, std::vector& names, + std::unordered_set& seen) { + std::vector scope; + CollectNamesScoped(node, names, seen, scope, NameCollectMode::kPtg); +} + +void collect_scope_resolved_names(const parser::AstNode& node, std::vector& names, + std::unordered_set& seen) { + std::vector scope; + CollectNamesScoped(node, names, seen, scope, NameCollectMode::kScopeResolved); +} + +void collect_sheet_qualified_names(const parser::AstNode& node, + std::vector>& qualified) { + std::vector keys; + std::unordered_set seen; + std::vector scope; + CollectNamesScoped(node, keys, seen, scope, NameCollectMode::kSheetQualified); + for (const std::string& key : keys) { + const std::size_t split = key.find('\0'); + qualified.emplace_back(key.substr(0, split), key.substr(split + 1)); + } +} + +void collect_ptg_sheet_ranges(const parser::AstNode& node, const std::vector& sheet_names, + SheetRangeTable& ranges, std::unordered_set& seen) { + switch (node.kind()) { + case parser::NodeKind::Ref: { + const parser::Reference& r = node.as_ref(); + if (!r.sheet.empty()) { + const int itab = resolve_ixti(sheet_names, r.sheet); + AddSheetRange(itab, itab, ranges, seen); + } + return; + } + case parser::NodeKind::Ref3D: { + const int itab_begin = resolve_ixti(sheet_names, node.as_ref3d_sheet_begin()); + const int itab_end = resolve_ixti(sheet_names, node.as_ref3d_sheet_end()); + AddSheetRange(itab_begin, itab_end, ranges, seen); + return; + } + case parser::NodeKind::NameRef: + case parser::NodeKind::ExternalRef: { + // `Sheet1!Rate` and `[0]!Rate` encode as `PtgNameX` through the + // book-scope entry. + const bool name_x = node.kind() == parser::NodeKind::NameRef ? !node.as_name_sheet().empty() + : parser::is_self_book_name_ref(node); + if (name_x && seen.insert(PackRangeKey(kXtiNoSheet, kXtiNoSheet)).second) { + ranges.emplace_back(kXtiNoSheet, kXtiNoSheet); + } + return; + } + case parser::NodeKind::LambdaCall: { + collect_ptg_sheet_ranges(node.as_lambda_call_callee(), sheet_names, ranges, seen); + const std::uint32_t arity = node.as_lambda_call_arity(); + for (std::uint32_t i = 0; i < arity; ++i) { + collect_ptg_sheet_ranges(node.as_lambda_call_arg(i), sheet_names, ranges, seen); + } + return; + } + case parser::NodeKind::Call: { + const std::uint32_t arity = node.as_call_arity(); + for (std::uint32_t i = 0; i < arity; ++i) { + collect_ptg_sheet_ranges(node.as_call_arg(i), sheet_names, ranges, seen); + } + return; + } + case parser::NodeKind::UnaryOp: + collect_ptg_sheet_ranges(node.as_unary_operand(), sheet_names, ranges, seen); + return; + case parser::NodeKind::BinaryOp: + collect_ptg_sheet_ranges(node.as_binary_lhs(), sheet_names, ranges, seen); + collect_ptg_sheet_ranges(node.as_binary_rhs(), sheet_names, ranges, seen); + return; + case parser::NodeKind::RangeOp: + collect_ptg_sheet_ranges(node.as_range_lhs(), sheet_names, ranges, seen); + collect_ptg_sheet_ranges(node.as_range_rhs(), sheet_names, ranges, seen); + return; + case parser::NodeKind::IntersectOp: + collect_ptg_sheet_ranges(node.as_intersect_lhs(), sheet_names, ranges, seen); + collect_ptg_sheet_ranges(node.as_intersect_rhs(), sheet_names, ranges, seen); + return; + case parser::NodeKind::UnionOp: { + const std::uint32_t arity = node.as_union_arity(); + for (std::uint32_t i = 0; i < arity; ++i) { + collect_ptg_sheet_ranges(node.as_union_child(i), sheet_names, ranges, seen); + } + return; + } + case parser::NodeKind::ImplicitIntersection: + collect_ptg_sheet_ranges(node.as_implicit_intersection_operand(), sheet_names, ranges, seen); + return; + case parser::NodeKind::ArrayLiteral: { + const std::uint32_t rows = node.as_array_rows(); + const std::uint32_t cols = node.as_array_cols(); + for (std::uint32_t r = 0; r < rows; ++r) { + for (std::uint32_t c = 0; c < cols; ++c) { + collect_ptg_sheet_ranges(node.as_array_element(r, c), sheet_names, ranges, seen); + } + } + return; + } + case parser::NodeKind::LetBinding: { + const std::uint32_t n = node.as_let_binding_count(); + for (std::uint32_t i = 0; i < n; ++i) { + collect_ptg_sheet_ranges(node.as_let_binding_expr(i), sheet_names, ranges, seen); + } + collect_ptg_sheet_ranges(node.as_let_body(), sheet_names, ranges, seen); + return; + } + case parser::NodeKind::Lambda: + collect_ptg_sheet_ranges(node.as_lambda_body(), sheet_names, ranges, seen); + return; + default: + return; + } +} + +} // namespace xlsb +} // namespace io +} // namespace formulon diff --git a/src/io/xlsb/ptg_targets.h b/src/io/xlsb/ptg_targets.h new file mode 100644 index 000000000..ada787746 --- /dev/null +++ b/src/io/xlsb/ptg_targets.h @@ -0,0 +1,61 @@ +// +// Internal primitives shared by the Ptg encoder (`ptg_writer.cpp`) and the +// workbook-wide name / sheet-range collection pass (`ptg_declarations.cpp`), +// which decide which `BrtName` / `BrtExternSheet` entries a formula needs. + +#ifndef FORMULON_IO_XLSB_PTG_TARGETS_H_ +#define FORMULON_IO_XLSB_PTG_TARGETS_H_ + +#include +#include +#include +#include +#include + +#include "io/future_functions.h" +#include "sheet_name.h" + +namespace formulon { +namespace io { +namespace xlsb { + +/// `itabFirst` / `itabLast` of the `BrtExternSheet` entry a `PtgNameX` +/// naming one of this workbook's own defined names resolves through: the +/// entry names no sheet, the record's own scope does. +inline constexpr std::int32_t kXtiNoSheet = -2; + +/// True when Excel stores a call to `name` under a hidden `_xlfn.*` +/// BrtName rather than a function id. Classified by +/// `io::classify_storage_prefix`, the same enumeration the OOXML writer +/// consults, so the two formats name the callee identically. +inline bool IsFutureFunction(std::string_view name) { + return classify_storage_prefix(name) != parser::StoragePrefixKind::None; +} + +/// True when a call to `name` is encoded through the hidden-name route +/// (`PtgName` + `PtgFuncVar(255)`) rather than a function id: either a +/// `_xlfn.*` future function, or one of the enumerated names Excel +/// spells bare in OOXML yet has no function id for. Both sets are +/// enumerated from observed Excel output — "absent from +/// `func_id_table`" deliberately does not qualify, because that table +/// grows incrementally and a hidden `_xlfn.` Excel does not know +/// resolves to `#NAME?`. +inline bool UsesHiddenNameRoute(std::string_view name) { + return IsFutureFunction(name) || xlsb_uses_hidden_name(name); +} + +/// Resolves a sheet name to its 0-based ixti. Returns -1 when absent. +inline int resolve_ixti(const std::vector& sheet_names, std::string_view sheet) { + for (std::size_t i = 0; i < sheet_names.size(); ++i) { + if (formulon::sheet_names::equal(sheet_names[i], sheet)) { + return static_cast(i); + } + } + return -1; +} + +} // namespace xlsb +} // namespace io +} // namespace formulon + +#endif // FORMULON_IO_XLSB_PTG_TARGETS_H_ diff --git a/src/io/xlsb/ptg_writer.cpp b/src/io/xlsb/ptg_writer.cpp index 24a6629fa..e1f41e281 100644 --- a/src/io/xlsb/ptg_writer.cpp +++ b/src/io/xlsb/ptg_writer.cpp @@ -20,6 +20,7 @@ #include "io/future_functions.h" #include "io/xlsb/func_id_table.h" #include "io/xlsb/ptg.h" +#include "io/xlsb/ptg_targets.h" #include "parser/ast_format.h" #include "parser/reference.h" #include "sheet_name.h" @@ -32,26 +33,6 @@ namespace io { namespace xlsb { namespace { -/// True when Excel stores a call to `name` under a hidden `_xlfn.*` -/// BrtName rather than a function id. Classified by -/// `io::classify_storage_prefix`, the same enumeration the OOXML writer -/// consults, so the two formats name the callee identically. -bool IsFutureFunction(std::string_view name) { - return classify_storage_prefix(name) != parser::StoragePrefixKind::None; -} - -/// True when a call to `name` is encoded through the hidden-name route -/// (`PtgName` + `PtgFuncVar(255)`) rather than a function id: either a -/// `_xlfn.*` future function, or one of the enumerated names Excel -/// spells bare in OOXML yet has no function id for. Both sets are -/// enumerated from observed Excel output — "absent from -/// `func_id_table`" deliberately does not qualify, because that table -/// grows incrementally and a hidden `_xlfn.` Excel does not know -/// resolves to `#NAME?`. -bool UsesHiddenNameRoute(std::string_view name) { - return IsFutureFunction(name) || xlsb_uses_hidden_name(name); -} - /// Built-ins whose result can be a reference, as measured for the class of /// their call token. bool ReturnsReference(std::string_view name) { @@ -268,26 +249,11 @@ parser::Reference OffsetFrom(parser::Reference ref, PtgBaseCell base) { return ref; } -/// `itabFirst` / `itabLast` of the `BrtExternSheet` entry a `PtgNameX` -/// naming one of this workbook's own defined names resolves through: the -/// entry names no sheet, the record's own scope does. -constexpr std::int32_t kXtiNoSheet = -2; - Error unsupported_node(const char* kind) { return make_error(FormulonErrorCode::kIoXlsbUnsupportedPtg, std::string("xlsb encoder cannot lower AST node kind: ") + kind, "context=xlsb_ptg_writer"); } -/// Resolves a sheet name to its 0-based ixti. Returns -1 when absent. -int resolve_ixti(const std::vector& sheet_names, std::string_view sheet) { - for (std::size_t i = 0; i < sheet_names.size(); ++i) { - if (formulon::sheet_names::equal(sheet_names[i], sheet)) { - return static_cast(i); - } - } - return -1; -} - /// One rectangle of a precomputed reference result (`PtgMemArea`'s cache). struct Rect { std::uint32_t row_first; @@ -1895,316 +1861,8 @@ class Encoder { std::vector> let_scope_; }; -/// Recursion helper for `collect_ptg_names`: adds `name` to `names` (and -/// marks it in `seen`) unless already present. -void AddName(std::string_view name, std::vector& names, std::unordered_set& seen) { - std::string owned(name); - if (seen.insert(owned).second) { - names.push_back(std::move(owned)); - } -} - -/// Packs an `(itabFirst, itabLast)` pair into a single dedupe key. -std::uint64_t PackRangeKey(std::int32_t itab_first, std::int32_t itab_last) { - return (static_cast(static_cast(itab_first)) << 32) | - static_cast(static_cast(itab_last)); -} - -/// Recursion helper for `collect_ptg_sheet_ranges`: resolves `itab_first` -/// / `itab_last` and, when both are valid, appends the pair to `ranges` -/// unless already present in `seen`. -void AddSheetRange(std::int32_t itab_first, std::int32_t itab_last, SheetRangeTable& ranges, - std::unordered_set& seen) { - if (itab_first < 0 || itab_last < 0) { - return; // Unresolvable sheet name; the encode fails later with a precise error. - } - if (seen.insert(PackRangeKey(itab_first, itab_last)).second) { - ranges.emplace_back(itab_first, itab_last); - } -} - -enum class NameCollectMode : std::uint8_t { kPtg, kScopeResolved, kSheetQualified }; - -/// True when `name` matches an in-scope LET / LAMBDA parameter. -bool InParamScope(const std::vector& scope, std::string_view name) { - // Case-insensitive: see `Encoder::emit_name_ref`. - return std::any_of(scope.begin(), scope.end(), - [name](std::string_view param) { return strings::case_insensitive_eq(param, name); }); -} - -/// Recursive worker for `collect_ptg_names` carrying the LET / LAMBDA -/// parameter names currently in scope (innermost last). A `NameRef` -/// matching an in-scope parameter resolves at encode time to that -/// parameter's hidden `_xlpm.` placeholder (see -/// `Encoder::emit_name_ref`), so it must not be registered as an ordinary -/// workbook defined name. A callee with no function id (`Fn(3)`: a named -/// LAMBDA, or a name no one defined) is a name too. `mode` selects the view: -/// every unqualified name a `PtgName` or self-book `PtgNameX` needs -/// (`kPtg`); only names resolved from the formula's own scope -/// (`kScopeResolved`); or only sheet-qualified names, each added as `sheet` -/// NUL `name` (`kSheetQualified`). -void CollectNamesScoped(const parser::AstNode& node, std::vector& names, - std::unordered_set& seen, std::vector& scope, - NameCollectMode mode) { - // Hidden `_xlfn.*` / `_xlpm.*` records and unqualified names. - auto add = [&](std::string_view name) { - if (mode != NameCollectMode::kSheetQualified) { - AddName(name, names, seen); - } - }; - switch (node.kind()) { - case parser::NodeKind::NameRef: { - const std::string_view name = node.as_name(); - if (!node.as_name_sheet().empty()) { - // Resolves through a scoped key (a definition or its sheet's stub), - // never a LET / LAMBDA parameter or a workbook placeholder. - if (mode == NameCollectMode::kSheetQualified) { - AddName(std::string(node.as_name_sheet()) + '\0' + std::string(name), names, seen); - } - return; - } - if (InParamScope(scope, name)) { - return; // LET / LAMBDA parameter: encoded via its _xlpm. placeholder. - } - add(name); - return; - } - case parser::NodeKind::Call: { - const std::string_view name = canonical_function_name(node.as_call_name()); - if (UsesHiddenNameRoute(name)) { - add(xlsb_hidden_function_name(name)); - } else if (lookup_func_by_name(name) == nullptr && !InParamScope(scope, node.as_call_name())) { - add(node.as_call_name()); - } - const std::uint32_t arity = node.as_call_arity(); - for (std::uint32_t i = 0; i < arity; ++i) { - CollectNamesScoped(node.as_call_arg(i), names, seen, scope, mode); - } - return; - } - case parser::NodeKind::UnaryOp: - CollectNamesScoped(node.as_unary_operand(), names, seen, scope, mode); - return; - case parser::NodeKind::BinaryOp: - CollectNamesScoped(node.as_binary_lhs(), names, seen, scope, mode); - CollectNamesScoped(node.as_binary_rhs(), names, seen, scope, mode); - return; - case parser::NodeKind::RangeOp: - CollectNamesScoped(node.as_range_lhs(), names, seen, scope, mode); - CollectNamesScoped(node.as_range_rhs(), names, seen, scope, mode); - return; - case parser::NodeKind::IntersectOp: - CollectNamesScoped(node.as_intersect_lhs(), names, seen, scope, mode); - CollectNamesScoped(node.as_intersect_rhs(), names, seen, scope, mode); - return; - case parser::NodeKind::UnionOp: { - const std::uint32_t arity = node.as_union_arity(); - for (std::uint32_t i = 0; i < arity; ++i) { - CollectNamesScoped(node.as_union_child(i), names, seen, scope, mode); - } - return; - } - case parser::NodeKind::ImplicitIntersection: - add(xlsb_hidden_function_name("SINGLE")); - CollectNamesScoped(node.as_implicit_intersection_operand(), names, seen, scope, mode); - return; - case parser::NodeKind::ArrayLiteral: { - const std::uint32_t rows = node.as_array_rows(); - const std::uint32_t cols = node.as_array_cols(); - for (std::uint32_t r = 0; r < rows; ++r) { - for (std::uint32_t c = 0; c < cols; ++c) { - CollectNamesScoped(node.as_array_element(r, c), names, seen, scope, mode); - } - } - return; - } - case parser::NodeKind::SpillRef: { - // Stored as a call to the hidden `_xlfn.ANCHORARRAY` name (see - // `Encoder::emit_spill_ref`), so it needs the same BrtName - // registration any other future-function callee gets. - add(xlsb_hidden_function_name("ANCHORARRAY")); - if (const parser::AstNode* anchor = node.as_spill_ref_anchor_expr(); anchor != nullptr) { - CollectNamesScoped(*anchor, names, seen, scope, mode); - } - return; - } - case parser::NodeKind::ExternalRef: - // `[0]!Rate` is never a LET / LAMBDA parameter and, like `Sheet2!Rate`, - // not resolved from the formula's own scope. - if (parser::is_self_book_name_ref(node) && mode == NameCollectMode::kPtg) { - AddName(node.as_external_ref_name(), names, seen); - } - return; - case parser::NodeKind::LambdaCall: { - CollectNamesScoped(node.as_lambda_call_callee(), names, seen, scope, mode); - const std::uint32_t arity = node.as_lambda_call_arity(); - for (std::uint32_t i = 0; i < arity; ++i) { - CollectNamesScoped(node.as_lambda_call_arg(i), names, seen, scope, mode); - } - return; - } - case parser::NodeKind::LetBinding: { - add("_xlfn.LET"); - const std::uint32_t n = node.as_let_binding_count(); - const std::size_t scope_base = scope.size(); - for (std::uint32_t i = 0; i < n; ++i) { - add(std::string("_xlpm.") + std::string(node.as_let_binding_name(i))); - // Excel LET binds sequentially: a value expression sees only the - // earlier bindings, so collect it before pushing this parameter. - CollectNamesScoped(node.as_let_binding_expr(i), names, seen, scope, mode); - scope.push_back(node.as_let_binding_name(i)); - } - CollectNamesScoped(node.as_let_body(), names, seen, scope, mode); - scope.resize(scope_base); - return; - } - case parser::NodeKind::Lambda: { - add("_xlfn.LAMBDA"); - const std::uint32_t n = node.as_lambda_param_count(); - const std::size_t scope_base = scope.size(); - for (std::uint32_t i = 0; i < n; ++i) { - add(std::string("_xlpm.") + std::string(node.as_lambda_param(i))); - scope.push_back(node.as_lambda_param(i)); - } - CollectNamesScoped(node.as_lambda_body(), names, seen, scope, mode); - scope.resize(scope_base); - return; - } - // Leaves, and forms the encoder does not lower (StructuredRef): nothing - // to collect. A future writer bundle that lowers these would extend - // this switch alongside the corresponding `emit_*` case. - default: - return; - } -} - } // namespace -std::string sheet_scoped_name_key(std::int32_t itab, std::string_view name) { - std::string key = std::to_string(itab); - key.push_back('!'); - key.append(strings::to_ascii_lower(name)); - return key; -} - -void collect_ptg_names(const parser::AstNode& node, std::vector& names, - std::unordered_set& seen) { - std::vector scope; - CollectNamesScoped(node, names, seen, scope, NameCollectMode::kPtg); -} - -void collect_scope_resolved_names(const parser::AstNode& node, std::vector& names, - std::unordered_set& seen) { - std::vector scope; - CollectNamesScoped(node, names, seen, scope, NameCollectMode::kScopeResolved); -} - -void collect_sheet_qualified_names(const parser::AstNode& node, - std::vector>& qualified) { - std::vector keys; - std::unordered_set seen; - std::vector scope; - CollectNamesScoped(node, keys, seen, scope, NameCollectMode::kSheetQualified); - for (const std::string& key : keys) { - const std::size_t split = key.find('\0'); - qualified.emplace_back(key.substr(0, split), key.substr(split + 1)); - } -} - -void collect_ptg_sheet_ranges(const parser::AstNode& node, const std::vector& sheet_names, - SheetRangeTable& ranges, std::unordered_set& seen) { - switch (node.kind()) { - case parser::NodeKind::Ref: { - const parser::Reference& r = node.as_ref(); - if (!r.sheet.empty()) { - const int itab = resolve_ixti(sheet_names, r.sheet); - AddSheetRange(itab, itab, ranges, seen); - } - return; - } - case parser::NodeKind::Ref3D: { - const int itab_begin = resolve_ixti(sheet_names, node.as_ref3d_sheet_begin()); - const int itab_end = resolve_ixti(sheet_names, node.as_ref3d_sheet_end()); - AddSheetRange(itab_begin, itab_end, ranges, seen); - return; - } - case parser::NodeKind::NameRef: - case parser::NodeKind::ExternalRef: { - // `Sheet1!Rate` and `[0]!Rate` encode as `PtgNameX` through the - // book-scope entry. - const bool name_x = node.kind() == parser::NodeKind::NameRef ? !node.as_name_sheet().empty() - : parser::is_self_book_name_ref(node); - if (name_x && seen.insert(PackRangeKey(kXtiNoSheet, kXtiNoSheet)).second) { - ranges.emplace_back(kXtiNoSheet, kXtiNoSheet); - } - return; - } - case parser::NodeKind::LambdaCall: { - collect_ptg_sheet_ranges(node.as_lambda_call_callee(), sheet_names, ranges, seen); - const std::uint32_t arity = node.as_lambda_call_arity(); - for (std::uint32_t i = 0; i < arity; ++i) { - collect_ptg_sheet_ranges(node.as_lambda_call_arg(i), sheet_names, ranges, seen); - } - return; - } - case parser::NodeKind::Call: { - const std::uint32_t arity = node.as_call_arity(); - for (std::uint32_t i = 0; i < arity; ++i) { - collect_ptg_sheet_ranges(node.as_call_arg(i), sheet_names, ranges, seen); - } - return; - } - case parser::NodeKind::UnaryOp: - collect_ptg_sheet_ranges(node.as_unary_operand(), sheet_names, ranges, seen); - return; - case parser::NodeKind::BinaryOp: - collect_ptg_sheet_ranges(node.as_binary_lhs(), sheet_names, ranges, seen); - collect_ptg_sheet_ranges(node.as_binary_rhs(), sheet_names, ranges, seen); - return; - case parser::NodeKind::RangeOp: - collect_ptg_sheet_ranges(node.as_range_lhs(), sheet_names, ranges, seen); - collect_ptg_sheet_ranges(node.as_range_rhs(), sheet_names, ranges, seen); - return; - case parser::NodeKind::IntersectOp: - collect_ptg_sheet_ranges(node.as_intersect_lhs(), sheet_names, ranges, seen); - collect_ptg_sheet_ranges(node.as_intersect_rhs(), sheet_names, ranges, seen); - return; - case parser::NodeKind::UnionOp: { - const std::uint32_t arity = node.as_union_arity(); - for (std::uint32_t i = 0; i < arity; ++i) { - collect_ptg_sheet_ranges(node.as_union_child(i), sheet_names, ranges, seen); - } - return; - } - case parser::NodeKind::ImplicitIntersection: - collect_ptg_sheet_ranges(node.as_implicit_intersection_operand(), sheet_names, ranges, seen); - return; - case parser::NodeKind::ArrayLiteral: { - const std::uint32_t rows = node.as_array_rows(); - const std::uint32_t cols = node.as_array_cols(); - for (std::uint32_t r = 0; r < rows; ++r) { - for (std::uint32_t c = 0; c < cols; ++c) { - collect_ptg_sheet_ranges(node.as_array_element(r, c), sheet_names, ranges, seen); - } - } - return; - } - case parser::NodeKind::LetBinding: { - const std::uint32_t n = node.as_let_binding_count(); - for (std::uint32_t i = 0; i < n; ++i) { - collect_ptg_sheet_ranges(node.as_let_binding_expr(i), sheet_names, ranges, seen); - } - collect_ptg_sheet_ranges(node.as_let_body(), sheet_names, ranges, seen); - return; - } - case parser::NodeKind::Lambda: - collect_ptg_sheet_ranges(node.as_lambda_body(), sheet_names, ranges, seen); - return; - default: - return; - } -} - bool formula_is_dynamic_array(const parser::AstNode& root, const NameShapes& names) { Shapes shapes(names); return MarksDynamic(shapes, root, Slot{}, /*root=*/true); diff --git a/src/io/xlsb/workbook_bin_writer.cpp b/src/io/xlsb/workbook_bin_writer.cpp new file mode 100644 index 000000000..320af3ede --- /dev/null +++ b/src/io/xlsb/workbook_bin_writer.cpp @@ -0,0 +1,673 @@ +// +// Implementation of the `xl/workbook.bin` stream writer. See +// `io/xlsb/workbook_bin_writer.h`. + +#include "io/xlsb/workbook_bin_writer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cf/cf_types.h" +#include "io/dynamic_array_formula.h" +#include "io/future_functions.h" +#include "io/xlsb/protection_records.h" +#include "io/xlsb/ptg_targets.h" +#include "io/xlsb/ptg_writer.h" +#include "io/xlsb/record.h" +#include "io/xlsb/record_writer.h" +#include "parser/ast.h" +#include "parser/parser.h" +#include "sheet.h" +#include "utils/arena.h" +#include "utils/error.h" +#include "utils/expected.h" +#include "workbook.h" + +namespace formulon { +namespace io { +namespace xlsb { +namespace { + +// Workbook-globals record ids the reader does not consume (so they are absent +// from `XlsbRecordType`) but the writer must emit for a well-formed stream. +// A `BrtExternSheet` must live inside a `BrtBeginExternals ... BrtEndExternals` +// block with a `BrtSupSelf` (self-referencing supporting book); Excel rejects a +// bare `BrtExternSheet`. Ids per [MS-XLSB] §2.4. +constexpr std::uint16_t kBrtBeginExternals = 353; +constexpr std::uint16_t kBrtSupSelf = 357; +constexpr std::uint16_t kBrtEndExternals = 354; +// Workbook-globals structural records Excel expects before the sheet bundle. +constexpr std::uint16_t kBrtFileVersion = 128; +constexpr std::uint16_t kBrtBeginBookViews = 135; +constexpr std::uint16_t kBrtWbView = 158; +constexpr std::uint16_t kBrtEndBookViews = 136; +// Opens a future-record block in a sheet tail (x14 extensions). +constexpr std::uint16_t kBrtFrtBegin = 35; + +} // namespace + +// --------------------------------------------------------------------------- +// PtgName table (BrtName): defined names + future-function callees +// --------------------------------------------------------------------------- + +namespace { + +/// Parses `formula` (with or without a leading `=`) and folds every name +/// `collect_ptg_names` finds into `names` / `seen`. Parse failures are +/// silently skipped here — `EncodeCellFormula` / the defined-name +/// encode pass below surface the same failure as a proper `Expected` +/// error when the formula is actually encoded. +/// +/// `scope_sheet_id` is the scope the formula resolves names from (see +/// `BuildNameTableForScope`). Every referenced text defined only as the +/// local name of sheets other than that scope is added to `invisible`: +/// Excel resolves such a reference to `#NAME?`, so it needs a +/// workbook-scope record of its own rather than an ordinal borrowed from +/// another sheet's definition, which would read back as that sheet's +/// qualified name. Every sheet-qualified reference is appended to +/// `qualified` as `(sheet, name)`. +void CollectNamesFromFormula(std::string_view formula, std::int32_t scope_sheet_id, + const std::unordered_map>& defined_scopes, + std::vector& names, std::unordered_set& seen, + std::vector& invisible, std::unordered_set& invisible_seen, + std::vector>& qualified) { + if (!formula.empty() && formula.front() == '=') { + formula.remove_prefix(1); + } + Arena arena; + parser::Parser p(formula, arena); + parser::AstNode* root = p.parse(); + if (root == nullptr || !p.errors().empty()) { + return; + } + collect_ptg_names(*root, names, seen); + collect_sheet_qualified_names(*root, qualified); + std::vector referenced; + std::unordered_set referenced_seen; + collect_scope_resolved_names(*root, referenced, referenced_seen); + for (std::string& text : referenced) { + const auto it = defined_scopes.find(text); + if (it == defined_scopes.end()) { + continue; + } + const std::vector& scopes = it->second; + const bool visible = std::any_of(scopes.begin(), scopes.end(), + [scope_sheet_id](std::int32_t s) { return s < 0 || s == scope_sheet_id; }); + if (!visible && invisible_seen.insert(text).second) { + invisible.push_back(std::move(text)); + } + } +} + +/// Calls `visit` with the text (no leading `=`) of every formula a sheet +/// part carries: cell formulas, conditional-format rule and threshold +/// formulas, and data-validation formulas. The name and ExternSheet tables +/// are both built over this one walk, so every formula the sheet writer +/// encodes finds its entries. +template +void ForEachSheetFormula(const Sheet& sheet, Visit&& visit) { + for (const auto& [row, cells] : sheet.rows()) { + (void)row; + for (const Cell& cell : cells) { + std::string_view body(cell.formula_text); + if (!body.empty() && body.front() == '=') { + body.remove_prefix(1); + } + if (!body.empty()) { + visit(body); + } + } + } + const auto visit_text = [&visit](const std::string& text) { + if (!text.empty()) { + visit(std::string_view(text)); + } + }; + const auto visit_cfvo = [&visit_text](const cf::CfValueObject& v) { + if (v.type == cf::CfvoType::Formula) { + visit_text(v.value); + } + }; + for (const cf::ConditionalFormat& format : sheet.conditional_formats()) { + for (const cf::CFRule& rule : format.rules) { + visit_text(rule.formula1.value_or(std::string())); + visit_text(rule.formula2.value_or(std::string())); + if (rule.color_scale) { + std::for_each(rule.color_scale->thresholds.begin(), rule.color_scale->thresholds.end(), visit_cfvo); + } + if (rule.data_bar) { + visit_cfvo(rule.data_bar->min); + visit_cfvo(rule.data_bar->max); + } + if (rule.icon_set) { + std::for_each(rule.icon_set->thresholds.begin(), rule.icon_set->thresholds.end(), visit_cfvo); + } + } + } + for (const DataValidation& dv : sheet.validations()) { + visit_text(dv.formula1); + visit_text(dv.formula2); + } +} + +} // namespace + +/// Builds the workbook's `BrtName` record order: every genuine defined +/// name (`Workbook::defined_names()`, in declaration order) occupies the +/// leading slots, followed by every future-function callee / `NameRef` +/// `collect_ptg_names` discovers across defined-name and sheet formulas +/// (in first-encounter order) that is not already a defined name, then a +/// workbook-scope placeholder for each name referenced where none of its +/// definitions is visible, then an empty stub scoped to `S` for each +/// `S!Name` that neither `S` nor the workbook defines, as Excel 365 saves +/// it. Slot `i` is the record a `PtgName` reaches with `ilbl == i + 1`. +/// +/// Defined names take one slot each, duplicate name text included: +/// `Workbook::set_defined_name_scoped` deliberately admits a +/// workbook-scoped and a sheet-local name spelling the same text, and +/// each needs its own `BrtName` record for the emission pass (which +/// walks `wb.defined_names()` by the same index) to stay aligned with +/// the `ilbl` values encoded into formulas. +void BuildOrderedNames(const Workbook& wb, std::vector& ordered_names) { + std::vector names; + std::unordered_set seen; + std::vector invisible; + std::unordered_set invisible_seen; + std::vector> qualified; + std::unordered_map> defined_scopes; + std::unordered_set scoped_keys; + for (const DefinedName& dn : wb.defined_names()) { + names.push_back(dn.name); + seen.insert(dn.name); + defined_scopes[dn.name].push_back(dn.local_sheet_id); + scoped_keys.insert(sheet_scoped_name_key(dn.local_sheet_id, dn.name)); + } + for (const DefinedName& dn : wb.defined_names()) { + CollectNamesFromFormula(dn.formula, dn.local_sheet_id, defined_scopes, names, seen, invisible, invisible_seen, + qualified); + } + for (std::size_t i = 0; i < wb.sheet_count(); ++i) { + ForEachSheetFormula(wb.sheet(i), [&](std::string_view formula) { + CollectNamesFromFormula(formula, static_cast(i), defined_scopes, names, seen, invisible, + invisible_seen, qualified); + }); + } + for (std::string& text : names) { + ordered_names.push_back(OrderedName{std::move(text), -1}); + } + for (std::string& text : invisible) { + ordered_names.push_back(OrderedName{std::move(text), -1}); + } + for (auto& [sheet, name] : qualified) { + const std::size_t itab = wb.sheet_index_by_name(sheet); + if (itab >= wb.sheet_count()) { + continue; // The encoder reports the unknown sheet. + } + const auto scope = static_cast(itab); + if (scoped_keys.count(sheet_scoped_name_key(-1, name)) == 0 && + scoped_keys.insert(sheet_scoped_name_key(scope, name)).second) { + ordered_names.push_back(OrderedName{std::move(name), scope}); + } + } +} + +/// Builds the `name -> ilbl` map `encode_ptgs` consults for a formula +/// that lives in `scope_sheet_id` (a 0-based sheet index for a cell +/// formula or a sheet-local defined name, `-1` for a workbook-scoped +/// defined name). +/// +/// A `PtgName` token carries no scope of its own — it is a bare ordinal +/// into the `BrtName` table — so the scope has to be resolved here, at +/// encode time, exactly as Excel resolves it when reading the file back: +/// a sheet-local name shadows a workbook-scoped one of the same text for +/// formulas on that sheet, and the workbook-scoped name is reached only +/// where no local one exists. Encoding a single workbook-wide ordinal +/// per name text would silently re-point one of the two. +/// +/// The three passes are layered by precedence and rely on `emplace` +/// leaving an entry that is already present alone: +/// 1. names local to `scope_sheet_id`, stubs included — these shadow +/// everything; +/// 2. workbook-scoped names, filling any text no local one claimed; +/// 3. every placeholder slot, then every remaining defined slot. The +/// placeholders are the hidden `_xlfn.*` / `_xlpm.*` names (which +/// have no scope), undefined names, and the workbook-scope record +/// `BuildOrderedNames` adds for a text that exists only as another +/// sheet's local name: that reference is `#NAME?` in Excel, and +/// reaching the other sheet's record instead would read back as +/// that sheet's qualified name. +NameTable BuildNameTableForScope(const Workbook& wb, const std::vector& ordered_names, + std::int32_t scope_sheet_id) { + const std::vector& defined = wb.defined_names(); + const std::size_t defined_count = std::min(defined.size(), ordered_names.size()); + NameTable name_table; + name_table.reserve(ordered_names.size()); + if (scope_sheet_id >= 0) { + for (std::size_t i = 0; i < defined_count; ++i) { + if (defined[i].local_sheet_id == scope_sheet_id) { + name_table.emplace(defined[i].name, static_cast(i + 1)); + } + } + // A stub scoped to this sheet is its local name too: Excel resolves a + // bare `NOSUCH(1)` on Sheet1 to the stub `Sheet1!NOSUCH` created there. + for (std::size_t i = defined_count; i < ordered_names.size(); ++i) { + if (ordered_names[i].itab == scope_sheet_id) { + name_table.emplace(ordered_names[i].name, static_cast(i + 1)); + } + } + } + for (std::size_t i = 0; i < defined_count; ++i) { + if (defined[i].local_sheet_id < 0) { + name_table.emplace(defined[i].name, static_cast(i + 1)); + } + } + for (std::size_t i = defined_count; i < ordered_names.size(); ++i) { + const OrderedName& slot = ordered_names[i]; + // A sheet-scoped stub is reachable only through its qualified key. + name_table.emplace(slot.itab < 0 ? slot.name : sheet_scoped_name_key(slot.itab, slot.name), + static_cast(i + 1)); + } + for (std::size_t i = 0; i < defined_count; ++i) { + name_table.emplace(ordered_names[i].name, static_cast(i + 1)); + } + // Scope-independent keys for sheet-qualified references (`Sheet2!Local`). + for (std::size_t i = 0; i < defined_count; ++i) { + name_table.emplace(sheet_scoped_name_key(defined[i].local_sheet_id, defined[i].name), + static_cast(i + 1)); + } + return name_table; +} + +// --------------------------------------------------------------------------- +// ExternSheet table (BrtExternSheet): every sheet-qualified reference's +// (itabFirst, itabLast) span, single- and multi-sheet alike. +// --------------------------------------------------------------------------- + +namespace { + +/// Parses `formula` (no leading `=`) and folds every distinct +/// `(itabFirst, itabLast)` span `collect_ptg_sheet_ranges` finds into +/// `ranges` / `seen`. Parse failures are silently skipped here -- +/// `EncodeCellFormula` surfaces the same failure as a proper `Expected` +/// error when the formula is actually encoded. +void CollectSheetRangesFromFormula(std::string_view formula, const std::vector& sheet_names, + SheetRangeTable& ranges, std::unordered_set& seen) { + Arena arena; + parser::Parser p(formula, arena); + parser::AstNode* root = p.parse(); + if (root == nullptr || !p.errors().empty()) { + return; + } + collect_ptg_sheet_ranges(*root, sheet_names, ranges, seen); +} + +/// True when `records` holds a future-record block (BrtFRTBegin). Such +/// blocks -- x14 conditional formats and validations -- are the retained +/// content that carries sheet-qualified formulas. +bool HoldsFrtBlock(const std::vector& records) { + ByteSpan cursor{records.data(), records.size()}; + while (cursor.size != 0U) { + auto rec = read_record(cursor); + if (!rec) { + return false; + } + if (rec.value().type == kBrtFrtBegin) { + return true; + } + } + return false; +} + +/// The leading entries of the `BrtExternSheet` table: the source +/// workbook's table, in its `ixti` order, when a retained sheet tail holds +/// a block that may reference it, so those verbatim indices keep naming +/// the same sheets. Fails when an entry no longer names a sheet of this +/// workbook (renamed or removed since load, or another workbook's), since +/// the retained bytes would then reference the wrong sheet. +Expected SeedFromRetainedTails(const Workbook& wb) { + SheetRangeTable seed; + for (std::size_t i = 0; i < wb.sheet_count(); ++i) { + const XlsbSheetTail& tail = wb.sheet(i).xlsb_tail(); + if (tail.extern_sheets.empty() || + !(HoldsFrtBlock(tail.before_merges) || HoldsFrtBlock(tail.after_merges_before_hyperlinks) || + HoldsFrtBlock(tail.after_hyperlinks))) { + continue; + } + for (const XlsbExternSheetEntry& entry : tail.extern_sheets) { + if (entry.first.empty() && entry.last.empty() && !entry.unresolved) { + seed.emplace_back(kXtiNoSheet, kXtiNoSheet); + continue; + } + const std::size_t first = wb.sheet_index_by_name(entry.first); + const std::size_t last = wb.sheet_index_by_name(entry.last); + if (entry.unresolved || first >= wb.sheet_count() || last >= wb.sheet_count()) { + return make_error(FormulonErrorCode::kIoXlsbRetainedPartStale, + "retained XLSB sheet records reference a sheet this workbook no longer has", + "context=write_xlsb sheet=" + wb.sheet(i).name() + " ref=" + entry.first); + } + seed.emplace_back(static_cast(first), static_cast(last)); + } + break; + } + return seed; +} + +} // namespace + +/// Builds the `BrtExternSheet` table for the whole workbook: every +/// distinct sheet-qualified reference span (single-sheet `(itab, itab)` +/// or a genuine 3-D range `(itabFirst, itabLast)`) any sheet formula (see +/// `ForEachSheetFormula`) or defined-name formula needs, in first-encounter order. Every +/// `PtgRef3d` / `PtgArea3d` token this writer emits -- single- or +/// multi-sheet alike -- resolves its `ixti` through this one table: +/// once the workbook emits any `BrtExternSheet` entry, the reader +/// interprets *every* `ixti` as an index into it rather than a bare +/// sheet index (see `ptg_reader.cpp`'s `sheet_for_ixti` / +/// `sheet_range_for_ixti`), so single- and multi-sheet references +/// cannot use two different numbering schemes in the same file. +Expected BuildSheetRangeTable(const Workbook& wb, const std::vector& sheet_names) { + SheetRangeTable collected; + std::unordered_set seen; + for (const DefinedName& dn : wb.defined_names()) { + CollectSheetRangesFromFormula(dn.formula, sheet_names, collected, seen); + } + for (std::size_t i = 0; i < wb.sheet_count(); ++i) { + ForEachSheetFormula(wb.sheet(i), [&](std::string_view formula) { + CollectSheetRangesFromFormula(formula, sheet_names, collected, seen); + }); + } + auto ranges = SeedFromRetainedTails(wb); + if (!ranges) { + return ranges.error(); + } + for (const auto& range : collected) { + if (std::find(ranges.value().begin(), ranges.value().end(), range) == ranges.value().end()) { + ranges.value().push_back(range); + } + } + return ranges; +} + +namespace { + +/// Emits one `BrtExternSheet` record. Byte layout verified against a +/// real Excel-365-produced `xl/workbook.bin` (see `workbook_bin_reader.cpp`'s +/// `DecodeExternSheet`, the decoder counterpart): `count(u32)` followed +/// by `count` entries of `(iSupBook, itabFirst, itabLast)` as three +/// `i32`s each. `iSupBook` is always 0 here (internal-workbook sheets +/// only; external-workbook 3-D ranges are out of scope for this +/// writer). +void EmitExternSheet(std::vector& body, const SheetRangeTable& ranges) { + if (ranges.empty()) { + return; + } + std::vector p; + emit_u32(p, static_cast(ranges.size())); + for (const auto& [itab_first, itab_last] : ranges) { + emit_u32(p, 0); // iSupBook + emit_u32(p, static_cast(itab_first)); + emit_u32(p, static_cast(itab_last)); + } + emit_record(body, static_cast(XlsbRecordType::BrtExternSheet), p); +} + +/// Emits one `BrtName` record. Byte layout verified against a real +/// Excel-365-produced `xl/workbook.bin` (see `workbook_bin_reader.cpp`'s +/// `DecodeWorkbookNames`, the decoder counterpart): +/// flags (u16: bit 0 = fHidden) + 3 reserved bytes + itab (i32, +/// `-1` = workbook scope) + cch (u32) + cch x UTF-16LE name + +/// cce (u32) + cce bytes rgce + cb (u32) + cb bytes rgcb. +/// `formula` is the name's own bare expression text (no leading `=`, +/// matching ``'s XML text content); an empty string emits +/// a zero-length `rgce` (the hidden future-function / LET-parameter +/// placeholders this writer registers carry no real formula body — real +/// Excel stores a `#NAME?` placeholder there, which is not required for +/// this writer's own reader to round-trip the name table). +Expected EmitName(std::vector& body, const std::string& name, std::string_view formula, + std::int32_t itab, bool hidden, bool calc_exp, std::string_view comment, + const std::vector& sheet_names, const SheetRangeTable& sheet_ranges, + const NameTable& name_table) { + // Names carrying Excel's hidden storage prefixes are not ordinary + // defined names: `_xlfn.` registers a post-2007 "future function" + // and `_xlpm.` a LET / LAMBDA parameter. Real Excel stores each + // with a specific flag word (fHidden | fFunc | fFutureFunction for the + // former, the proc-parameter flags for the latter), a PtgErr(#NAME?) + // placeholder body, and five trailing null strings. A cell's future- + // function call resolves through the matching BrtName's ilbl, so these + // flags and the placeholder body must match Excel byte-for-byte or the + // callee shows up as #NAME? on load. A prefixed name Excel does not know + // (`_xlfn.FOOBAR`) is an ordinary undefined-name stub instead (measured). + const bool is_param = name.rfind("_xlpm.", 0) == 0; + std::string_view callee(name); + const bool xlfn = callee.rfind("_xlfn.", 0) == 0; + callee.remove_prefix(xlfn ? 6U : 0U); + if (callee.rfind("_xlws.", 0) == 0) { + callee.remove_prefix(6U); + } + const bool is_future_fn = xlfn && has_storage_prefix(callee); + const bool is_placeholder = is_param || is_future_fn; + std::uint32_t flags; + if (is_param) { + flags = 0x00020019U; + } else if (is_future_fn) { + flags = 0x0002000bU; + } else { + flags = hidden ? 0x00000001U : 0x00000000U; + } + std::vector p; + emit_u32(p, flags); // grbit (flags word) + emit_u8(p, 0); // chKey + emit_u32(p, static_cast(itab)); + emit_xlwidestring(p, name); + if (is_placeholder) { + emit_u32(p, 2); // cce + emit_u8(p, 0x1C); // PtgErr + emit_u8(p, 0x1D); // #NAME? error code + emit_u32(p, 0); // cb + } else if (formula.empty()) { + emit_u32(p, 0); // cce + emit_u32(p, 0); // cb + } else { + Arena arena; + parser::Parser parser(formula, arena); + parser::AstNode* root = parser.parse(); + if (root == nullptr || !parser.errors().empty()) { + return make_error(FormulonErrorCode::kIoXlsbUnsupportedPtg, + "xlsb writer: defined-name formula failed to parse for Ptg encoding", + std::string("context=xlsb_writer name=") + name); + } + if (calc_exp) { + p[0] |= 0x10U; // fCalcExp (grbit bit 4) + } + // Measured: a defined-name body's root reference stays reference class. + auto encoded_or = encode_ptgs(*root, sheet_names, sheet_ranges, name_table, PtgRootClass::kReference); + if (!encoded_or) { + return encoded_or.error(); + } + const EncodedFormula& encoded = encoded_or.value(); + emit_u32(p, static_cast(encoded.rgce.size())); + p.insert(p.end(), encoded.rgce.begin(), encoded.rgce.end()); + emit_u32(p, static_cast(encoded.rgcb.size())); + p.insert(p.end(), encoded.rgcb.begin(), encoded.rgcb.end()); + } + // Trailing BrtName strings ([MS-XLSB] §2.4.649). Excel rejects a + // BrtName that stops after the formula. A plain defined name carries + // just the comment (a null `XLNullableWideString` when absent, matching + // Excel's own encoding for a name whose Name Manager "Comment" field + // was never set -- not a zero-length string); a future-function / + // proc-parameter placeholder carries five strings (comment plus four + // further unused strings, always null -- these are internal storage + // artifacts with no Name Manager entry a comment could attach to), + // matching real Excel output. + if (is_placeholder) { + for (int i = 0; i < 5; ++i) { + emit_u32(p, 0xFFFFFFFFU); // null XLNullableWideString + } + } else { + emit_xlnullablewidestring(p, comment.empty() ? std::nullopt : std::make_optional(comment)); + } + emit_record(body, static_cast(XlsbRecordType::BrtName), p); + return Expected::Ok(); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Workbook stream (xl/workbook.bin) +// --------------------------------------------------------------------------- + +Expected, Error> BuildWorkbookBin(const Workbook& wb, + const std::vector& ordered_names, + const SheetRangeTable& sheet_ranges, + const std::vector& sheet_names) { + std::vector body; + emit_record(body, static_cast(XlsbRecordType::BrtBeginBook), ByteSpan{}); + + // Workbook globals Excel expects before the sheet bundle. These are + // fixed-shape records with no dependency on workbook content, so we emit + // known-valid default payloads (byte layout captured from a real Excel 365 + // `xl/workbook.bin`): + // * BrtFileVersion : appName "xl", lastEdited/lowestEdited "7", build. + // * BrtWbProp : default flags + defaultThemeVersion 202300. + // * BrtWbView : a single window view; itabCur (offset 24, u32) is + // patched below to the tab-selected sheet. + // Omitting these produced a workbook stream Excel rejected. + static const std::vector kFileVersionPayload = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x78, 0x00, 0x6c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x37, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x37, 0x00, 0x05, 0x00, 0x00, 0x00, 0x31, 0x00, 0x30, 0x00, 0x36, 0x00, 0x32, 0x00, 0x38, 0x00}; + static const std::vector kDefaultWbPropPayload = {0x20, 0x00, 0x01, 0x00, 0x3c, 0x16, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}; + static const std::vector kWbViewPayload = {0x10, 0x4f, 0x00, 0x00, 0x18, 0x15, 0x00, 0x00, 0x8c, 0x6e, + 0x00, 0x00, 0xd0, 0x43, 0x00, 0x00, 0x58, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78}; + emit_record(body, kBrtFileVersion, kFileVersionPayload); + std::vector wb_prop_payload = kDefaultWbPropPayload; + // BrtWbProp ([MS-XLSB] §2.4.866): f1904 is bit 0 of the leading + // little-endian u32 grbit. Preserve Excel's captured default flags. + if (wb.date1904()) { + wb_prop_payload[0] |= 0x01U; + } + emit_record(body, static_cast(XlsbRecordType::BrtWbProp), wb_prop_payload); + // Excel places the protection records between BrtWbProp and the views. + if (auto protection = emit_book_protection(body, wb.workbook_protection_xml()); !protection) { + return protection.error(); + } + emit_record(body, kBrtBeginBookViews, ByteSpan{}); + // itabCur (u32 at offset 24): measured to equal the tab-selected sheet's + // index in every fixture; a mismatch made Excel show every tab selected. + std::vector wb_view_payload = kWbViewPayload; + std::uint32_t itab_cur = 0U; + for (std::size_t i = 0; i < wb.sheet_count(); ++i) { + if (wb.sheet(i).view().tab_selected) { + itab_cur = static_cast(i); + break; + } + } + wb_view_payload[24] = static_cast(itab_cur & 0xFFU); + wb_view_payload[25] = static_cast((itab_cur >> 8) & 0xFFU); + wb_view_payload[26] = static_cast((itab_cur >> 16) & 0xFFU); + wb_view_payload[27] = static_cast((itab_cur >> 24) & 0xFFU); + emit_record(body, kBrtWbView, wb_view_payload); + emit_record(body, kBrtEndBookViews, ByteSpan{}); + + emit_record(body, static_cast(XlsbRecordType::BrtBeginBundleShs), ByteSpan{}); + for (std::size_t i = 0; i < wb.sheet_count(); ++i) { + // BrtBundleSh ([MS-XLSB] §2.4.304): + // hsState : u32 (0 = visible, 1 = hidden, 2 = very hidden) + // iTabID : u32 (sheet id; 1-based) + // strRelID : XLNullableWideString + // strName : XLWideString + std::vector p; + // `SheetVisibility` is numbered to match `hsState`, so the model state + // is the field value. + const auto hs_state = static_cast(wb.sheet(i).view().visibility()); + emit_u32(p, hs_state); // hsState + emit_u32(p, static_cast(i + 1U)); // iTabID + const std::string rid = std::string("rId") + std::to_string(i + 1U); + emit_xlnullablewidestring(p, std::optional{rid}); + emit_xlwidestring(p, wb.sheet(i).name()); + emit_record(body, static_cast(XlsbRecordType::BrtBundleSh), p); + } + emit_record(body, static_cast(XlsbRecordType::BrtEndBundleShs), ByteSpan{}); + + // BrtExternSheet: every distinct sheet-qualified reference span this + // workbook's formulas need an `ixti` for (single- and multi-sheet + // alike; see `BuildSheetRangeTable`'s doc comment for why both share + // this one table). Omitted entirely when no formula uses a qualified + // reference, matching the fallback the reader's `sheet_for_ixti` / + // `sheet_range_for_ixti` already implement for that case. + // + // The record MUST be wrapped in the externals block: a bare + // `BrtExternSheet` outside `BrtBeginExternals ... BrtEndExternals` is an + // out-of-place record that makes Excel reject the package. A workbook that + // only references its own sheets still needs a single `BrtSupSelf` + // supporting-book entry inside the block. + if (!sheet_ranges.empty()) { + emit_record(body, kBrtBeginExternals, ByteSpan{}); + emit_record(body, kBrtSupSelf, ByteSpan{}); + EmitExternSheet(body, sheet_ranges); + emit_record(body, kBrtEndExternals, ByteSpan{}); + } + + // BrtName table: every genuine defined name (hidden per its own OOXML + // `hidden` flag) followed by every future-function callee / defined + // name `collect_ptg_names` needed a `PtgName` reference for that + // wasn't already a defined name (the `_xlfn.` / `_xlpm.` records + // hidden, a stub for an undefined name not). `ordered_names` is built once by `BuildOrderedNames` + // and shared with every sheet's cell encoder so `ilbl` assignments + // stay consistent workbook-wide. The leading `defined_count` slots are + // `wb.defined_names()` element-for-element, so every defined name + // emits exactly one record at the `ilbl` the encoder assigned it. + // + // A name's own formula resolves other names from the scope the name + // itself lives in, the same rule a cell formula follows, so each entry + // encodes against a table built for its `local_sheet_id`. The tables + // are memoized per scope: workbooks routinely carry many names in the + // same scope and each table is a full pass over the name list. + const std::size_t defined_count = wb.defined_names().size(); + std::unordered_map scoped_tables; + auto table_for_scope = [&](std::int32_t scope_sheet_id) -> const NameTable& { + auto it = scoped_tables.find(scope_sheet_id); + if (it == scoped_tables.end()) { + it = scoped_tables.emplace(scope_sheet_id, BuildNameTableForScope(wb, ordered_names, scope_sheet_id)).first; + } + return it->second; + }; + const std::vector shapes = defined_name_shapes(wb); + for (std::size_t i = 0; i < ordered_names.size(); ++i) { + if (i < defined_count) { + const DefinedName& dn = wb.defined_names()[i]; + if (auto r = EmitName(body, dn.name, dn.formula, dn.local_sheet_id, dn.hidden, shapes[i].calc_exp, dn.comment, + sheet_names, sheet_ranges, table_for_scope(dn.local_sheet_id)); + !r) { + return r.error(); + } + } else { + // Placeholders carry no formula body, so the table they are handed is + // never consulted; the workbook-scope one keeps the call uniform. A + // stub for an undefined name is not hidden, as Excel saves it. + const OrderedName& slot = ordered_names[i]; + if (auto r = EmitName(body, slot.name, /*formula=*/{}, slot.itab, /*hidden=*/false, /*calc_exp=*/false, + /*comment=*/{}, sheet_names, sheet_ranges, table_for_scope(-1)); + !r) { + return r.error(); + } + } + } + + emit_record(body, static_cast(XlsbRecordType::BrtEndBook), ByteSpan{}); + return body; +} + +} // namespace xlsb +} // namespace io +} // namespace formulon diff --git a/src/io/xlsb/workbook_bin_writer.h b/src/io/xlsb/workbook_bin_writer.h new file mode 100644 index 000000000..06056f8a0 --- /dev/null +++ b/src/io/xlsb/workbook_bin_writer.h @@ -0,0 +1,53 @@ +// +// Writer of the `xl/workbook.bin` stream: workbook globals, the sheet +// bundle, the `BrtExternSheet` table and the `BrtName` table. The name and +// sheet-range tables are built once per workbook and shared with every +// sheet's cell encoder so `ilbl` / `ixti` assignments stay consistent. + +#ifndef FORMULON_IO_XLSB_WORKBOOK_BIN_WRITER_H_ +#define FORMULON_IO_XLSB_WORKBOOK_BIN_WRITER_H_ + +#include +#include +#include + +#include "io/xlsb/ptg_writer.h" +#include "utils/error.h" +#include "utils/expected.h" +#include "workbook.h" + +namespace formulon { +namespace io { +namespace xlsb { + +/// One `BrtName` slot: the record's name and scope (`-1`: workbook, else +/// the 0-based sheet a stub is scoped to). Defined-name slots carry their +/// text only; their scope is read from `Workbook::defined_names()`. +struct OrderedName { + std::string name; + std::int32_t itab = -1; +}; + +/// Builds the workbook's `BrtName` record order (defined names first, then +/// placeholders and stubs). +void BuildOrderedNames(const Workbook& wb, std::vector& ordered_names); + +/// Builds the `name -> ilbl` map `encode_ptgs` consults for a formula that +/// lives in `scope_sheet_id` (`-1` for a workbook-scoped defined name). +NameTable BuildNameTableForScope(const Workbook& wb, const std::vector& ordered_names, + std::int32_t scope_sheet_id); + +/// Builds the `BrtExternSheet` table for the whole workbook. +Expected BuildSheetRangeTable(const Workbook& wb, const std::vector& sheet_names); + +/// Emits the whole `xl/workbook.bin` record stream. +Expected, Error> BuildWorkbookBin(const Workbook& wb, + const std::vector& ordered_names, + const SheetRangeTable& sheet_ranges, + const std::vector& sheet_names); + +} // namespace xlsb +} // namespace io +} // namespace formulon + +#endif // FORMULON_IO_XLSB_WORKBOOK_BIN_WRITER_H_ diff --git a/src/io/xlsb/writer.cpp b/src/io/xlsb/writer.cpp index 8379edc2c..6bad0646c 100644 --- a/src/io/xlsb/writer.cpp +++ b/src/io/xlsb/writer.cpp @@ -32,6 +32,7 @@ #include "io/ooxml/zip_part_writer.h" #include "io/xlsb/metadata_bin.h" #include "io/xlsb/protection_records.h" +#include "io/xlsb/ptg_targets.h" #include "io/xlsb/ptg_writer.h" #include "io/xlsb/record.h" #include "io/xlsb/record_writer.h" @@ -39,6 +40,7 @@ #include "io/xlsb/sheet_writer.h" #include "io/xlsb/sst_writer.h" #include "io/xlsb/styles_writer.h" +#include "io/xlsb/workbook_bin_writer.h" #include "io/xml_escape.h" #include "miniz.h" #include "parser/ast.h" @@ -101,25 +103,6 @@ constexpr std::string_view kRelExtendedProps = constexpr std::string_view kRelCustomProps = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; -// Workbook-globals record ids the reader does not consume (so they are absent -// from `XlsbRecordType`) but the writer must emit for a well-formed stream. -// A `BrtExternSheet` must live inside a `BrtBeginExternals ... BrtEndExternals` -// block with a `BrtSupSelf` (self-referencing supporting book); Excel rejects a -// bare `BrtExternSheet`. Ids per [MS-XLSB] §2.4. -constexpr std::uint16_t kBrtBeginExternals = 353; -constexpr std::uint16_t kBrtSupSelf = 357; -constexpr std::uint16_t kBrtEndExternals = 354; -// Workbook-globals structural records Excel expects before the sheet bundle. -constexpr std::uint16_t kBrtFileVersion = 128; -constexpr std::uint16_t kBrtBeginBookViews = 135; -constexpr std::uint16_t kBrtWbView = 158; -constexpr std::uint16_t kBrtEndBookViews = 136; -// Opens a future-record block in a sheet tail (x14 extensions). -constexpr std::uint16_t kBrtFrtBegin = 35; -// `BrtExternSheet` sheet index of the sheetless entry a book-scope -// `PtgNameX` resolves through. -constexpr std::int32_t kXtiNoSheet = -2; - // --------------------------------------------------------------------------- // Emission plan: where do passthrough parts land, do any collide? // --------------------------------------------------------------------------- @@ -775,616 +758,6 @@ std::string BuildWorkbookRels(std::size_t sheet_count, bool emit_sst, const Emis return out; } -// --------------------------------------------------------------------------- -// PtgName table (BrtName): defined names + future-function callees -// --------------------------------------------------------------------------- - -/// One `BrtName` slot: the record's name and scope (`-1`: workbook, else -/// the 0-based sheet a stub is scoped to). Defined-name slots carry their -/// text only; their scope is read from `Workbook::defined_names()`. -struct OrderedName { - std::string name; - std::int32_t itab = -1; -}; - -/// Parses `formula` (with or without a leading `=`) and folds every name -/// `collect_ptg_names` finds into `names` / `seen`. Parse failures are -/// silently skipped here — `EncodeCellFormula` / the defined-name -/// encode pass below surface the same failure as a proper `Expected` -/// error when the formula is actually encoded. -/// -/// `scope_sheet_id` is the scope the formula resolves names from (see -/// `BuildNameTableForScope`). Every referenced text defined only as the -/// local name of sheets other than that scope is added to `invisible`: -/// Excel resolves such a reference to `#NAME?`, so it needs a -/// workbook-scope record of its own rather than an ordinal borrowed from -/// another sheet's definition, which would read back as that sheet's -/// qualified name. Every sheet-qualified reference is appended to -/// `qualified` as `(sheet, name)`. -void CollectNamesFromFormula(std::string_view formula, std::int32_t scope_sheet_id, - const std::unordered_map>& defined_scopes, - std::vector& names, std::unordered_set& seen, - std::vector& invisible, std::unordered_set& invisible_seen, - std::vector>& qualified) { - if (!formula.empty() && formula.front() == '=') { - formula.remove_prefix(1); - } - Arena arena; - parser::Parser p(formula, arena); - parser::AstNode* root = p.parse(); - if (root == nullptr || !p.errors().empty()) { - return; - } - collect_ptg_names(*root, names, seen); - collect_sheet_qualified_names(*root, qualified); - std::vector referenced; - std::unordered_set referenced_seen; - collect_scope_resolved_names(*root, referenced, referenced_seen); - for (std::string& text : referenced) { - const auto it = defined_scopes.find(text); - if (it == defined_scopes.end()) { - continue; - } - const std::vector& scopes = it->second; - const bool visible = std::any_of(scopes.begin(), scopes.end(), - [scope_sheet_id](std::int32_t s) { return s < 0 || s == scope_sheet_id; }); - if (!visible && invisible_seen.insert(text).second) { - invisible.push_back(std::move(text)); - } - } -} - -/// Calls `visit` with the text (no leading `=`) of every formula a sheet -/// part carries: cell formulas, conditional-format rule and threshold -/// formulas, and data-validation formulas. The name and ExternSheet tables -/// are both built over this one walk, so every formula the sheet writer -/// encodes finds its entries. -template -void ForEachSheetFormula(const Sheet& sheet, Visit&& visit) { - for (const auto& [row, cells] : sheet.rows()) { - (void)row; - for (const Cell& cell : cells) { - std::string_view body(cell.formula_text); - if (!body.empty() && body.front() == '=') { - body.remove_prefix(1); - } - if (!body.empty()) { - visit(body); - } - } - } - const auto visit_text = [&visit](const std::string& text) { - if (!text.empty()) { - visit(std::string_view(text)); - } - }; - const auto visit_cfvo = [&visit_text](const cf::CfValueObject& v) { - if (v.type == cf::CfvoType::Formula) { - visit_text(v.value); - } - }; - for (const cf::ConditionalFormat& format : sheet.conditional_formats()) { - for (const cf::CFRule& rule : format.rules) { - visit_text(rule.formula1.value_or(std::string())); - visit_text(rule.formula2.value_or(std::string())); - if (rule.color_scale) { - std::for_each(rule.color_scale->thresholds.begin(), rule.color_scale->thresholds.end(), visit_cfvo); - } - if (rule.data_bar) { - visit_cfvo(rule.data_bar->min); - visit_cfvo(rule.data_bar->max); - } - if (rule.icon_set) { - std::for_each(rule.icon_set->thresholds.begin(), rule.icon_set->thresholds.end(), visit_cfvo); - } - } - } - for (const DataValidation& dv : sheet.validations()) { - visit_text(dv.formula1); - visit_text(dv.formula2); - } -} - -/// Builds the workbook's `BrtName` record order: every genuine defined -/// name (`Workbook::defined_names()`, in declaration order) occupies the -/// leading slots, followed by every future-function callee / `NameRef` -/// `collect_ptg_names` discovers across defined-name and sheet formulas -/// (in first-encounter order) that is not already a defined name, then a -/// workbook-scope placeholder for each name referenced where none of its -/// definitions is visible, then an empty stub scoped to `S` for each -/// `S!Name` that neither `S` nor the workbook defines, as Excel 365 saves -/// it. Slot `i` is the record a `PtgName` reaches with `ilbl == i + 1`. -/// -/// Defined names take one slot each, duplicate name text included: -/// `Workbook::set_defined_name_scoped` deliberately admits a -/// workbook-scoped and a sheet-local name spelling the same text, and -/// each needs its own `BrtName` record for the emission pass (which -/// walks `wb.defined_names()` by the same index) to stay aligned with -/// the `ilbl` values encoded into formulas. -void BuildOrderedNames(const Workbook& wb, std::vector& ordered_names) { - std::vector names; - std::unordered_set seen; - std::vector invisible; - std::unordered_set invisible_seen; - std::vector> qualified; - std::unordered_map> defined_scopes; - std::unordered_set scoped_keys; - for (const DefinedName& dn : wb.defined_names()) { - names.push_back(dn.name); - seen.insert(dn.name); - defined_scopes[dn.name].push_back(dn.local_sheet_id); - scoped_keys.insert(sheet_scoped_name_key(dn.local_sheet_id, dn.name)); - } - for (const DefinedName& dn : wb.defined_names()) { - CollectNamesFromFormula(dn.formula, dn.local_sheet_id, defined_scopes, names, seen, invisible, invisible_seen, - qualified); - } - for (std::size_t i = 0; i < wb.sheet_count(); ++i) { - ForEachSheetFormula(wb.sheet(i), [&](std::string_view formula) { - CollectNamesFromFormula(formula, static_cast(i), defined_scopes, names, seen, invisible, - invisible_seen, qualified); - }); - } - for (std::string& text : names) { - ordered_names.push_back(OrderedName{std::move(text), -1}); - } - for (std::string& text : invisible) { - ordered_names.push_back(OrderedName{std::move(text), -1}); - } - for (auto& [sheet, name] : qualified) { - const std::size_t itab = wb.sheet_index_by_name(sheet); - if (itab >= wb.sheet_count()) { - continue; // The encoder reports the unknown sheet. - } - const auto scope = static_cast(itab); - if (scoped_keys.count(sheet_scoped_name_key(-1, name)) == 0 && - scoped_keys.insert(sheet_scoped_name_key(scope, name)).second) { - ordered_names.push_back(OrderedName{std::move(name), scope}); - } - } -} - -/// Builds the `name -> ilbl` map `encode_ptgs` consults for a formula -/// that lives in `scope_sheet_id` (a 0-based sheet index for a cell -/// formula or a sheet-local defined name, `-1` for a workbook-scoped -/// defined name). -/// -/// A `PtgName` token carries no scope of its own — it is a bare ordinal -/// into the `BrtName` table — so the scope has to be resolved here, at -/// encode time, exactly as Excel resolves it when reading the file back: -/// a sheet-local name shadows a workbook-scoped one of the same text for -/// formulas on that sheet, and the workbook-scoped name is reached only -/// where no local one exists. Encoding a single workbook-wide ordinal -/// per name text would silently re-point one of the two. -/// -/// The three passes are layered by precedence and rely on `emplace` -/// leaving an entry that is already present alone: -/// 1. names local to `scope_sheet_id`, stubs included — these shadow -/// everything; -/// 2. workbook-scoped names, filling any text no local one claimed; -/// 3. every placeholder slot, then every remaining defined slot. The -/// placeholders are the hidden `_xlfn.*` / `_xlpm.*` names (which -/// have no scope), undefined names, and the workbook-scope record -/// `BuildOrderedNames` adds for a text that exists only as another -/// sheet's local name: that reference is `#NAME?` in Excel, and -/// reaching the other sheet's record instead would read back as -/// that sheet's qualified name. -NameTable BuildNameTableForScope(const Workbook& wb, const std::vector& ordered_names, - std::int32_t scope_sheet_id) { - const std::vector& defined = wb.defined_names(); - const std::size_t defined_count = std::min(defined.size(), ordered_names.size()); - NameTable name_table; - name_table.reserve(ordered_names.size()); - if (scope_sheet_id >= 0) { - for (std::size_t i = 0; i < defined_count; ++i) { - if (defined[i].local_sheet_id == scope_sheet_id) { - name_table.emplace(defined[i].name, static_cast(i + 1)); - } - } - // A stub scoped to this sheet is its local name too: Excel resolves a - // bare `NOSUCH(1)` on Sheet1 to the stub `Sheet1!NOSUCH` created there. - for (std::size_t i = defined_count; i < ordered_names.size(); ++i) { - if (ordered_names[i].itab == scope_sheet_id) { - name_table.emplace(ordered_names[i].name, static_cast(i + 1)); - } - } - } - for (std::size_t i = 0; i < defined_count; ++i) { - if (defined[i].local_sheet_id < 0) { - name_table.emplace(defined[i].name, static_cast(i + 1)); - } - } - for (std::size_t i = defined_count; i < ordered_names.size(); ++i) { - const OrderedName& slot = ordered_names[i]; - // A sheet-scoped stub is reachable only through its qualified key. - name_table.emplace(slot.itab < 0 ? slot.name : sheet_scoped_name_key(slot.itab, slot.name), - static_cast(i + 1)); - } - for (std::size_t i = 0; i < defined_count; ++i) { - name_table.emplace(ordered_names[i].name, static_cast(i + 1)); - } - // Scope-independent keys for sheet-qualified references (`Sheet2!Local`). - for (std::size_t i = 0; i < defined_count; ++i) { - name_table.emplace(sheet_scoped_name_key(defined[i].local_sheet_id, defined[i].name), - static_cast(i + 1)); - } - return name_table; -} - -// --------------------------------------------------------------------------- -// ExternSheet table (BrtExternSheet): every sheet-qualified reference's -// (itabFirst, itabLast) span, single- and multi-sheet alike. -// --------------------------------------------------------------------------- - -/// Parses `formula` (no leading `=`) and folds every distinct -/// `(itabFirst, itabLast)` span `collect_ptg_sheet_ranges` finds into -/// `ranges` / `seen`. Parse failures are silently skipped here -- -/// `EncodeCellFormula` surfaces the same failure as a proper `Expected` -/// error when the formula is actually encoded. -void CollectSheetRangesFromFormula(std::string_view formula, const std::vector& sheet_names, - SheetRangeTable& ranges, std::unordered_set& seen) { - Arena arena; - parser::Parser p(formula, arena); - parser::AstNode* root = p.parse(); - if (root == nullptr || !p.errors().empty()) { - return; - } - collect_ptg_sheet_ranges(*root, sheet_names, ranges, seen); -} - -/// True when `records` holds a future-record block (BrtFRTBegin). Such -/// blocks -- x14 conditional formats and validations -- are the retained -/// content that carries sheet-qualified formulas. -bool HoldsFrtBlock(const std::vector& records) { - ByteSpan cursor{records.data(), records.size()}; - while (cursor.size != 0U) { - auto rec = read_record(cursor); - if (!rec) { - return false; - } - if (rec.value().type == kBrtFrtBegin) { - return true; - } - } - return false; -} - -/// The leading entries of the `BrtExternSheet` table: the source -/// workbook's table, in its `ixti` order, when a retained sheet tail holds -/// a block that may reference it, so those verbatim indices keep naming -/// the same sheets. Fails when an entry no longer names a sheet of this -/// workbook (renamed or removed since load, or another workbook's), since -/// the retained bytes would then reference the wrong sheet. -Expected SeedFromRetainedTails(const Workbook& wb) { - SheetRangeTable seed; - for (std::size_t i = 0; i < wb.sheet_count(); ++i) { - const XlsbSheetTail& tail = wb.sheet(i).xlsb_tail(); - if (tail.extern_sheets.empty() || - !(HoldsFrtBlock(tail.before_merges) || HoldsFrtBlock(tail.after_merges_before_hyperlinks) || - HoldsFrtBlock(tail.after_hyperlinks))) { - continue; - } - for (const XlsbExternSheetEntry& entry : tail.extern_sheets) { - if (entry.first.empty() && entry.last.empty() && !entry.unresolved) { - seed.emplace_back(kXtiNoSheet, kXtiNoSheet); - continue; - } - const std::size_t first = wb.sheet_index_by_name(entry.first); - const std::size_t last = wb.sheet_index_by_name(entry.last); - if (entry.unresolved || first >= wb.sheet_count() || last >= wb.sheet_count()) { - return make_error(FormulonErrorCode::kIoXlsbRetainedPartStale, - "retained XLSB sheet records reference a sheet this workbook no longer has", - "context=write_xlsb sheet=" + wb.sheet(i).name() + " ref=" + entry.first); - } - seed.emplace_back(static_cast(first), static_cast(last)); - } - break; - } - return seed; -} - -/// Builds the `BrtExternSheet` table for the whole workbook: every -/// distinct sheet-qualified reference span (single-sheet `(itab, itab)` -/// or a genuine 3-D range `(itabFirst, itabLast)`) any sheet formula (see -/// `ForEachSheetFormula`) or defined-name formula needs, in first-encounter order. Every -/// `PtgRef3d` / `PtgArea3d` token this writer emits -- single- or -/// multi-sheet alike -- resolves its `ixti` through this one table: -/// once the workbook emits any `BrtExternSheet` entry, the reader -/// interprets *every* `ixti` as an index into it rather than a bare -/// sheet index (see `ptg_reader.cpp`'s `sheet_for_ixti` / -/// `sheet_range_for_ixti`), so single- and multi-sheet references -/// cannot use two different numbering schemes in the same file. -Expected BuildSheetRangeTable(const Workbook& wb, const std::vector& sheet_names) { - SheetRangeTable collected; - std::unordered_set seen; - for (const DefinedName& dn : wb.defined_names()) { - CollectSheetRangesFromFormula(dn.formula, sheet_names, collected, seen); - } - for (std::size_t i = 0; i < wb.sheet_count(); ++i) { - ForEachSheetFormula(wb.sheet(i), [&](std::string_view formula) { - CollectSheetRangesFromFormula(formula, sheet_names, collected, seen); - }); - } - auto ranges = SeedFromRetainedTails(wb); - if (!ranges) { - return ranges.error(); - } - for (const auto& range : collected) { - if (std::find(ranges.value().begin(), ranges.value().end(), range) == ranges.value().end()) { - ranges.value().push_back(range); - } - } - return ranges; -} - -/// Emits one `BrtExternSheet` record. Byte layout verified against a -/// real Excel-365-produced `xl/workbook.bin` (see `ptg_reader.cpp`'s -/// `DecodeExternSheet`, the decoder counterpart): `count(u32)` followed -/// by `count` entries of `(iSupBook, itabFirst, itabLast)` as three -/// `i32`s each. `iSupBook` is always 0 here (internal-workbook sheets -/// only; external-workbook 3-D ranges are out of scope for this -/// writer). -void EmitExternSheet(std::vector& body, const SheetRangeTable& ranges) { - if (ranges.empty()) { - return; - } - std::vector p; - emit_u32(p, static_cast(ranges.size())); - for (const auto& [itab_first, itab_last] : ranges) { - emit_u32(p, 0); // iSupBook - emit_u32(p, static_cast(itab_first)); - emit_u32(p, static_cast(itab_last)); - } - emit_record(body, static_cast(XlsbRecordType::BrtExternSheet), p); -} - -/// Emits one `BrtName` record. Byte layout verified against a real -/// Excel-365-produced `xl/workbook.bin` (see `ptg_reader.cpp`'s -/// `DecodeWorkbookNames`, the decoder counterpart): -/// flags (u16: bit 0 = fHidden) + 3 reserved bytes + itab (i32, -/// `-1` = workbook scope) + cch (u32) + cch x UTF-16LE name + -/// cce (u32) + cce bytes rgce + cb (u32) + cb bytes rgcb. -/// `formula` is the name's own bare expression text (no leading `=`, -/// matching ``'s XML text content); an empty string emits -/// a zero-length `rgce` (the hidden future-function / LET-parameter -/// placeholders this writer registers carry no real formula body — real -/// Excel stores a `#NAME?` placeholder there, which is not required for -/// this writer's own reader to round-trip the name table). -Expected EmitName(std::vector& body, const std::string& name, std::string_view formula, - std::int32_t itab, bool hidden, bool calc_exp, std::string_view comment, - const std::vector& sheet_names, const SheetRangeTable& sheet_ranges, - const NameTable& name_table) { - // Names carrying Excel's hidden storage prefixes are not ordinary - // defined names: `_xlfn.` registers a post-2007 "future function" - // and `_xlpm.` a LET / LAMBDA parameter. Real Excel stores each - // with a specific flag word (fHidden | fFunc | fFutureFunction for the - // former, the proc-parameter flags for the latter), a PtgErr(#NAME?) - // placeholder body, and five trailing null strings. A cell's future- - // function call resolves through the matching BrtName's ilbl, so these - // flags and the placeholder body must match Excel byte-for-byte or the - // callee shows up as #NAME? on load. A prefixed name Excel does not know - // (`_xlfn.FOOBAR`) is an ordinary undefined-name stub instead (measured). - const bool is_param = name.rfind("_xlpm.", 0) == 0; - std::string_view callee(name); - const bool xlfn = callee.rfind("_xlfn.", 0) == 0; - callee.remove_prefix(xlfn ? 6U : 0U); - if (callee.rfind("_xlws.", 0) == 0) { - callee.remove_prefix(6U); - } - const bool is_future_fn = xlfn && has_storage_prefix(callee); - const bool is_placeholder = is_param || is_future_fn; - std::uint32_t flags; - if (is_param) { - flags = 0x00020019U; - } else if (is_future_fn) { - flags = 0x0002000bU; - } else { - flags = hidden ? 0x00000001U : 0x00000000U; - } - std::vector p; - emit_u32(p, flags); // grbit (flags word) - emit_u8(p, 0); // chKey - emit_u32(p, static_cast(itab)); - emit_xlwidestring(p, name); - if (is_placeholder) { - emit_u32(p, 2); // cce - emit_u8(p, 0x1C); // PtgErr - emit_u8(p, 0x1D); // #NAME? error code - emit_u32(p, 0); // cb - } else if (formula.empty()) { - emit_u32(p, 0); // cce - emit_u32(p, 0); // cb - } else { - Arena arena; - parser::Parser parser(formula, arena); - parser::AstNode* root = parser.parse(); - if (root == nullptr || !parser.errors().empty()) { - return make_error(FormulonErrorCode::kIoXlsbUnsupportedPtg, - "xlsb writer: defined-name formula failed to parse for Ptg encoding", - std::string("context=xlsb_writer name=") + name); - } - if (calc_exp) { - p[0] |= 0x10U; // fCalcExp (grbit bit 4) - } - // Measured: a defined-name body's root reference stays reference class. - auto encoded_or = encode_ptgs(*root, sheet_names, sheet_ranges, name_table, PtgRootClass::kReference); - if (!encoded_or) { - return encoded_or.error(); - } - const EncodedFormula& encoded = encoded_or.value(); - emit_u32(p, static_cast(encoded.rgce.size())); - p.insert(p.end(), encoded.rgce.begin(), encoded.rgce.end()); - emit_u32(p, static_cast(encoded.rgcb.size())); - p.insert(p.end(), encoded.rgcb.begin(), encoded.rgcb.end()); - } - // Trailing BrtName strings ([MS-XLSB] §2.4.649). Excel rejects a - // BrtName that stops after the formula. A plain defined name carries - // just the comment (a null `XLNullableWideString` when absent, matching - // Excel's own encoding for a name whose Name Manager "Comment" field - // was never set -- not a zero-length string); a future-function / - // proc-parameter placeholder carries five strings (comment plus four - // further unused strings, always null -- these are internal storage - // artifacts with no Name Manager entry a comment could attach to), - // matching real Excel output. - if (is_placeholder) { - for (int i = 0; i < 5; ++i) { - emit_u32(p, 0xFFFFFFFFU); // null XLNullableWideString - } - } else { - emit_xlnullablewidestring(p, comment.empty() ? std::nullopt : std::make_optional(comment)); - } - emit_record(body, static_cast(XlsbRecordType::BrtName), p); - return Expected::Ok(); -} - -// --------------------------------------------------------------------------- -// Workbook stream (xl/workbook.bin) -// --------------------------------------------------------------------------- - -Expected, Error> BuildWorkbookBin(const Workbook& wb, - const std::vector& ordered_names, - const SheetRangeTable& sheet_ranges, - const std::vector& sheet_names) { - std::vector body; - emit_record(body, static_cast(XlsbRecordType::BrtBeginBook), ByteSpan{}); - - // Workbook globals Excel expects before the sheet bundle. These are - // fixed-shape records with no dependency on workbook content, so we emit - // known-valid default payloads (byte layout captured from a real Excel 365 - // `xl/workbook.bin`): - // * BrtFileVersion : appName "xl", lastEdited/lowestEdited "7", build. - // * BrtWbProp : default flags + defaultThemeVersion 202300. - // * BrtWbView : a single window view; itabCur (offset 24, u32) is - // patched below to the tab-selected sheet. - // Omitting these produced a workbook stream Excel rejected. - static const std::vector kFileVersionPayload = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, - 0x00, 0x00, 0x00, 0x78, 0x00, 0x6c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x37, 0x00, 0x01, 0x00, 0x00, 0x00, - 0x37, 0x00, 0x05, 0x00, 0x00, 0x00, 0x31, 0x00, 0x30, 0x00, 0x36, 0x00, 0x32, 0x00, 0x38, 0x00}; - static const std::vector kDefaultWbPropPayload = {0x20, 0x00, 0x01, 0x00, 0x3c, 0x16, - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}; - static const std::vector kWbViewPayload = {0x10, 0x4f, 0x00, 0x00, 0x18, 0x15, 0x00, 0x00, 0x8c, 0x6e, - 0x00, 0x00, 0xd0, 0x43, 0x00, 0x00, 0x58, 0x02, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78}; - emit_record(body, kBrtFileVersion, kFileVersionPayload); - std::vector wb_prop_payload = kDefaultWbPropPayload; - // BrtWbProp ([MS-XLSB] §2.4.866): f1904 is bit 0 of the leading - // little-endian u32 grbit. Preserve Excel's captured default flags. - if (wb.date1904()) { - wb_prop_payload[0] |= 0x01U; - } - emit_record(body, static_cast(XlsbRecordType::BrtWbProp), wb_prop_payload); - // Excel places the protection records between BrtWbProp and the views. - if (auto protection = emit_book_protection(body, wb.workbook_protection_xml()); !protection) { - return protection.error(); - } - emit_record(body, kBrtBeginBookViews, ByteSpan{}); - // itabCur (u32 at offset 24): measured to equal the tab-selected sheet's - // index in every fixture; a mismatch made Excel show every tab selected. - std::vector wb_view_payload = kWbViewPayload; - std::uint32_t itab_cur = 0U; - for (std::size_t i = 0; i < wb.sheet_count(); ++i) { - if (wb.sheet(i).view().tab_selected) { - itab_cur = static_cast(i); - break; - } - } - wb_view_payload[24] = static_cast(itab_cur & 0xFFU); - wb_view_payload[25] = static_cast((itab_cur >> 8) & 0xFFU); - wb_view_payload[26] = static_cast((itab_cur >> 16) & 0xFFU); - wb_view_payload[27] = static_cast((itab_cur >> 24) & 0xFFU); - emit_record(body, kBrtWbView, wb_view_payload); - emit_record(body, kBrtEndBookViews, ByteSpan{}); - - emit_record(body, static_cast(XlsbRecordType::BrtBeginBundleShs), ByteSpan{}); - for (std::size_t i = 0; i < wb.sheet_count(); ++i) { - // BrtBundleSh ([MS-XLSB] §2.4.304): - // hsState : u32 (0 = visible, 1 = hidden, 2 = very hidden) - // iTabID : u32 (sheet id; 1-based) - // strRelID : XLNullableWideString - // strName : XLWideString - std::vector p; - // `SheetVisibility` is numbered to match `hsState`, so the model state - // is the field value. - const auto hs_state = static_cast(wb.sheet(i).view().visibility()); - emit_u32(p, hs_state); // hsState - emit_u32(p, static_cast(i + 1U)); // iTabID - const std::string rid = std::string("rId") + std::to_string(i + 1U); - emit_xlnullablewidestring(p, std::optional{rid}); - emit_xlwidestring(p, wb.sheet(i).name()); - emit_record(body, static_cast(XlsbRecordType::BrtBundleSh), p); - } - emit_record(body, static_cast(XlsbRecordType::BrtEndBundleShs), ByteSpan{}); - - // BrtExternSheet: every distinct sheet-qualified reference span this - // workbook's formulas need an `ixti` for (single- and multi-sheet - // alike; see `BuildSheetRangeTable`'s doc comment for why both share - // this one table). Omitted entirely when no formula uses a qualified - // reference, matching the fallback the reader's `sheet_for_ixti` / - // `sheet_range_for_ixti` already implement for that case. - // - // The record MUST be wrapped in the externals block: a bare - // `BrtExternSheet` outside `BrtBeginExternals ... BrtEndExternals` is an - // out-of-place record that makes Excel reject the package. A workbook that - // only references its own sheets still needs a single `BrtSupSelf` - // supporting-book entry inside the block. - if (!sheet_ranges.empty()) { - emit_record(body, kBrtBeginExternals, ByteSpan{}); - emit_record(body, kBrtSupSelf, ByteSpan{}); - EmitExternSheet(body, sheet_ranges); - emit_record(body, kBrtEndExternals, ByteSpan{}); - } - - // BrtName table: every genuine defined name (hidden per its own OOXML - // `hidden` flag) followed by every future-function callee / defined - // name `collect_ptg_names` needed a `PtgName` reference for that - // wasn't already a defined name (the `_xlfn.` / `_xlpm.` records - // hidden, a stub for an undefined name not). `ordered_names` is built once by `BuildOrderedNames` - // and shared with every sheet's cell encoder so `ilbl` assignments - // stay consistent workbook-wide. The leading `defined_count` slots are - // `wb.defined_names()` element-for-element, so every defined name - // emits exactly one record at the `ilbl` the encoder assigned it. - // - // A name's own formula resolves other names from the scope the name - // itself lives in, the same rule a cell formula follows, so each entry - // encodes against a table built for its `local_sheet_id`. The tables - // are memoized per scope: workbooks routinely carry many names in the - // same scope and each table is a full pass over the name list. - const std::size_t defined_count = wb.defined_names().size(); - std::unordered_map scoped_tables; - auto table_for_scope = [&](std::int32_t scope_sheet_id) -> const NameTable& { - auto it = scoped_tables.find(scope_sheet_id); - if (it == scoped_tables.end()) { - it = scoped_tables.emplace(scope_sheet_id, BuildNameTableForScope(wb, ordered_names, scope_sheet_id)).first; - } - return it->second; - }; - const std::vector shapes = defined_name_shapes(wb); - for (std::size_t i = 0; i < ordered_names.size(); ++i) { - if (i < defined_count) { - const DefinedName& dn = wb.defined_names()[i]; - if (auto r = EmitName(body, dn.name, dn.formula, dn.local_sheet_id, dn.hidden, shapes[i].calc_exp, dn.comment, - sheet_names, sheet_ranges, table_for_scope(dn.local_sheet_id)); - !r) { - return r.error(); - } - } else { - // Placeholders carry no formula body, so the table they are handed is - // never consulted; the workbook-scope one keeps the call uniform. A - // stub for an undefined name is not hidden, as Excel saves it. - const OrderedName& slot = ordered_names[i]; - if (auto r = EmitName(body, slot.name, /*formula=*/{}, slot.itab, /*hidden=*/false, /*calc_exp=*/false, - /*comment=*/{}, sheet_names, sheet_ranges, table_for_scope(-1)); - !r) { - return r.error(); - } - } - } - - emit_record(body, static_cast(XlsbRecordType::BrtEndBook), ByteSpan{}); - return body; -} - } // namespace Expected write_xlsb_with_result(const Workbook& workbook) { From da2ccabc22b51088f1143d1f379a43023faf1593 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:27:53 +0900 Subject: [PATCH 12/93] refactor(io): move the presentation-overlay readers into sheet_overlay_reader - move the merge, hyperlink, data-validation and related overlay readers from sheet_reader.{h,cpp} into sheet_overlay_reader.{h,cpp} - include sheet_overlay_reader.h from ooxml_reader.cpp and the io roundtrip/diagnostics tests - register sheet_overlay_reader.cpp in CMakeLists.txt - point the xml_utils.h comment at read_xsd_bool in io/xsd_bool.h - behavior-preserving move; no logic changes --- CMakeLists.txt | 1 + src/io/ooxml_reader.cpp | 1 + src/io/sheet_overlay_reader.cpp | 383 ++++++++++++++++++ src/io/sheet_overlay_reader.h | 78 ++++ src/io/sheet_reader.cpp | 352 ---------------- src/io/sheet_reader.h | 53 --- src/io/xml_utils.h | 2 +- .../io/data_validation_roundtrip_test.cpp | 2 +- tests/unit/io/hyperlink_roundtrip_test.cpp | 2 +- tests/unit/io/merge_roundtrip_test.cpp | 4 +- tests/unit/io/package_diagnostics_test.cpp | 2 +- 11 files changed, 469 insertions(+), 411 deletions(-) create mode 100644 src/io/sheet_overlay_reader.cpp create mode 100644 src/io/sheet_overlay_reader.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 5dabeb13e..2b4b6bf6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,7 @@ set(FORMULON_CORE_SOURCES src/io/pivot_table_writer.cpp src/io/format_detect.cpp src/io/sax_xml_reader.cpp + src/io/sheet_overlay_reader.cpp src/io/sheet_reader.cpp src/io/sst_reader.cpp src/io/styles_reader.cpp diff --git a/src/io/ooxml_reader.cpp b/src/io/ooxml_reader.cpp index 337141d36..eef26af65 100644 --- a/src/io/ooxml_reader.cpp +++ b/src/io/ooxml_reader.cpp @@ -49,6 +49,7 @@ #include "io/ooxml_defs.h" #include "io/pivot_cache_reader.h" #include "io/pivot_table_reader.h" +#include "io/sheet_overlay_reader.h" #include "io/sheet_reader.h" #include "io/sst_reader.h" #include "io/styles_reader.h" diff --git a/src/io/sheet_overlay_reader.cpp b/src/io/sheet_overlay_reader.cpp new file mode 100644 index 000000000..66bef2614 --- /dev/null +++ b/src/io/sheet_overlay_reader.cpp @@ -0,0 +1,383 @@ +// +// Presentation-overlay readers for one worksheet: merged cells, hyperlinks, +// data validations and sheet protection. See sheet_overlay_reader.h for the +// public contract. + +#include "io/sheet_overlay_reader.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "io/cell_parser.h" +#include "io/cf_reader.h" +#include "io/xml_escape.h" +#include "io/xml_utils.h" +#include "io/xsd_bool.h" +#include "pugixml.hpp" +#include "sheet.h" +#include "utils/error.h" +#include "utils/expected.h" +#include "utils/structured_log.h" + +namespace formulon { +namespace io { + +namespace { + +/// Decodes one A1-style range token (`A1`, `A1:B5`, or `$A$1:$B$5`) +/// into a `MergeRange`. Single-cell tokens land as `first == last`. +/// Both corners are normalised so `first <= last` componentwise. +Expected ParseA1RangeMerge(std::string_view ref) { + const std::size_t colon = ref.find(':'); + if (colon == std::string_view::npos) { + auto rc = parse_a1(ref); + if (!rc) { + std::string ctx("context=sheet_reader ref="); + ctx.append(ref); + return make_error(FormulonErrorCode::kIoSheetCorrupt, "merge/hyperlink: ref token unparseable", std::move(ctx)); + } + MergeRange out{}; + out.first_row = rc.value().first; + out.first_col = rc.value().second; + out.last_row = out.first_row; + out.last_col = out.first_col; + return out; + } + const std::string_view a = ref.substr(0, colon); + const std::string_view b = ref.substr(colon + 1); + auto a_rc = parse_a1(a); + auto b_rc = parse_a1(b); + if (!a_rc || !b_rc) { + std::string ctx("context=sheet_reader ref="); + ctx.append(ref); + return make_error(FormulonErrorCode::kIoSheetCorrupt, "merge: ref token unparseable", std::move(ctx)); + } + const std::uint32_t r0 = a_rc.value().first; + const std::uint32_t c0 = a_rc.value().second; + const std::uint32_t r1 = b_rc.value().first; + const std::uint32_t c1 = b_rc.value().second; + MergeRange out{}; + out.first_row = (r0 < r1) ? r0 : r1; + out.first_col = (c0 < c1) ? c0 : c1; + out.last_row = (r0 < r1) ? r1 : r0; + out.last_col = (c0 < c1) ? c1 : c0; + return out; +} + +/// Emits the WARN diagnostic for one skipped presentation-overlay entry. +/// Merges, hyperlinks and data validations are optional worksheet +/// metadata carrying no cell value, so a single malformed reference +/// drops that entry and load continues — the same disposition +/// `read_conditional_formats` applies to a malformed `` +/// block. Genuine cell-data corruption still fails the sheet. +void SkipOverlayEntry(std::string_view part, std::string_view reason, std::string_view ref, + ReadDiagnostics* diagnostics) { + StructuredLog("io.sheet.overlay.skip") + .field("part", part) + .field("reason", reason) + .field("ref", ref) + .error_code(FormulonErrorCode::kIoSheetCorrupt) + .warn(); + if (diagnostics != nullptr) { + ++diagnostics->skipped_feature_count; + } +} + +/// Splits a whitespace-separated `sqref="A1 B2:C3 D4"` attribute and +/// decodes each token. Returns `kIoSheetCorrupt` on the first +/// unparseable token. +Expected, Error> ParseSqrefRanges(std::string_view sqref) { + std::vector out; + std::size_t i = 0; + while (i < sqref.size()) { + while (i < sqref.size() && (sqref[i] == ' ' || sqref[i] == '\t' || sqref[i] == '\n' || sqref[i] == '\r')) { + ++i; + } + const std::size_t start = i; + while (i < sqref.size() && sqref[i] != ' ' && sqref[i] != '\t' && sqref[i] != '\n' && sqref[i] != '\r') { + ++i; + } + if (start == i) { + break; + } + auto r = ParseA1RangeMerge(sqref.substr(start, i - start)); + if (!r) { + return r.error(); + } + out.push_back(r.value()); + } + return out; +} + +} // namespace + +Expected, Error> read_merges(const pugi::xml_node& worksheet, ReadDiagnostics* diagnostics) { + std::vector out; + if (!worksheet) { + return out; + } + pugi::xml_node mc = worksheet.child("mergeCells"); + if (!mc) { + return out; + } + for (pugi::xml_node m = mc.child("mergeCell"); m; m = m.next_sibling("mergeCell")) { + const std::string_view ref = attr_str(m, "ref"); + if (ref.empty()) { + SkipOverlayEntry("mergeCells", "ref attribute missing or empty", ref, diagnostics); + continue; + } + auto r = ParseA1RangeMerge(ref); + if (!r) { + SkipOverlayEntry("mergeCells", "ref unparseable", ref, diagnostics); + continue; + } + out.push_back(r.value()); + } + return out; +} + +Expected, Error> read_hyperlinks(const pugi::xml_node& worksheet, ReadDiagnostics* diagnostics) { + std::vector out; + if (!worksheet) { + return out; + } + pugi::xml_node node = worksheet.child("hyperlinks"); + if (!node) { + return out; + } + for (pugi::xml_node h = node.child("hyperlink"); h; h = h.next_sibling("hyperlink")) { + const std::string_view ref = attr_str(h, "ref"); + if (ref.empty()) { + SkipOverlayEntry("hyperlinks", "ref attribute missing or empty", ref, diagnostics); + continue; + } + // Hyperlinks may span a range (`ref="A1:B2"`); Excel applies the link + // to every cell. Keep both corners in the model so structural edits can + // shrink/shift the complete rectangle and the writer can regenerate the + // A1 reference from numeric coordinates. + auto range_or = ParseA1RangeMerge(ref); + if (!range_or) { + SkipOverlayEntry("hyperlinks", "ref unparseable", ref, diagnostics); + continue; + } + Hyperlink hl; + hl.row = range_or.value().first_row; + hl.col = range_or.value().first_col; + hl.last_row = range_or.value().last_row; + hl.last_col = range_or.value().last_col; + // Accept both Office-namespaced ("r:id") and bare "id" attribute spellings. + const std::string_view rid_v = attr_str(h, "r:id"); + if (!rid_v.empty()) { + hl.rid.assign(rid_v); + } else { + hl.rid.assign(attr_str(h, "id")); + } + hl.location.assign(attr_str(h, "location")); + hl.display.assign(attr_str(h, "display")); + hl.tooltip.assign(attr_str(h, "tooltip")); + out.push_back(std::move(hl)); + } + return out; +} + +void apply_hyperlink_rels(std::vector& hyperlinks, + const std::unordered_map& rid_to_target) { + for (Hyperlink& h : hyperlinks) { + if (h.rid.empty()) { + continue; + } + auto it = rid_to_target.find(h.rid); + if (it == rid_to_target.end()) { + continue; + } + h.target = it->second; + } +} + +Expected, Error> read_data_validations(const pugi::xml_node& worksheet, + ReadDiagnostics* diagnostics) { + std::vector out; + if (!worksheet) { + return out; + } + pugi::xml_node dvs = worksheet.child("dataValidations"); + if (!dvs) { + return out; + } + for (pugi::xml_node dv = dvs.child("dataValidation"); dv; dv = dv.next_sibling("dataValidation")) { + DataValidation v; + const std::string_view sqref = attr_str(dv, "sqref"); + if (sqref.empty()) { + SkipOverlayEntry("dataValidations", "sqref attribute missing or empty", sqref, diagnostics); + continue; + } + auto ranges_or = ParseSqrefRanges(sqref); + if (!ranges_or) { + SkipOverlayEntry("dataValidations", "sqref unparseable", sqref, diagnostics); + continue; + } + v.ranges = std::move(ranges_or.value()); + + // type + const std::string_view t = attr_str(dv, "type"); + if (t == "whole") { + v.type = 1; + } else if (t == "decimal") { + v.type = 2; + } else if (t == "list") { + v.type = 3; + } else if (t == "date") { + v.type = 4; + } else if (t == "time") { + v.type = 5; + } else if (t == "textLength") { + v.type = 6; + } else if (t == "custom") { + v.type = 7; + } else { + v.type = 0; + } + + // operator + const std::string_view op = attr_str(dv, "operator"); + if (op == "notBetween") { + v.op = 1; + } else if (op == "equal") { + v.op = 2; + } else if (op == "notEqual") { + v.op = 3; + } else if (op == "greaterThan") { + v.op = 4; + } else if (op == "lessThan") { + v.op = 5; + } else if (op == "greaterThanOrEqual") { + v.op = 6; + } else if (op == "lessThanOrEqual") { + v.op = 7; + } else { + v.op = 0; // "between" / unspecified + } + + // errorStyle + const std::string_view es = attr_str(dv, "errorStyle"); + if (es == "warning") { + v.error_style = 1; + } else if (es == "information") { + v.error_style = 2; + } else { + v.error_style = 0; + } + + // Boolean attributes default to false: Excel omits allowBlank when it + // is off (the .xlsb twin's fAllowBlank bit is clear) and writes "1" + // when it is on. + v.allow_blank = false; + if (pugi::xml_attribute ab = dv.attribute("allowBlank"); ab) { + v.allow_blank = parse_xml_bool(ab.value()); + } + if (pugi::xml_attribute sim = dv.attribute("showInputMessage"); sim) { + v.show_input_message = parse_xml_bool(sim.value()); + } + if (pugi::xml_attribute sem = dv.attribute("showErrorMessage"); sem) { + v.show_error_message = parse_xml_bool(sem.value()); + } + // `showDropDown` has inverted semantics: presence with a true value + // suppresses the arrow, so the user-facing `show_dropdown` is the + // negation of the raw attribute (absent/false attribute => shown). + if (pugi::xml_attribute sdd = dv.attribute("showDropDown"); sdd) { + v.show_dropdown = !parse_xml_bool(sdd.value()); + } + + v.error_title.assign(attr_str(dv, "errorTitle")); + v.error_message.assign(attr_str(dv, "error")); + v.prompt_title.assign(attr_str(dv, "promptTitle")); + v.prompt_message.assign(attr_str(dv, "prompt")); + + // `AppendOoxmlTextUnescaped` decodes the OOXML `_xHHHH_` control- + // character escapes the writer emits for this slot (`AppendXmlEscaped` + // in ooxml/sheet_xml_builder.cpp), on top of `.text().get()`'s + // ordinary XML entity decoding. + if (pugi::xml_node f1 = dv.child("formula1"); f1) { + v.formula1.clear(); + AppendOoxmlTextUnescaped(v.formula1, f1.text().get()); + if (!v.formula1.empty() && v.formula1.front() == '=') { + v.formula1.erase(0, 1); + } + v.formula1 = canonical_feature_formula(v.formula1); + } + if (pugi::xml_node f2 = dv.child("formula2"); f2) { + v.formula2.clear(); + AppendOoxmlTextUnescaped(v.formula2, f2.text().get()); + if (!v.formula2.empty() && v.formula2.front() == '=') { + v.formula2.erase(0, 1); + } + v.formula2 = canonical_feature_formula(v.formula2); + } + + out.push_back(std::move(v)); + } + return out; +} + +SheetProtection read_sheet_protection(const pugi::xml_node& worksheet) { + SheetProtection out; + if (!worksheet) { + return out; + } + const pugi::xml_node node = worksheet.child("sheetProtection"); + if (!node) { + return out; + } + out.enabled = true; + + out.algorithm_name.assign(attr_str(node, "algorithmName")); + out.hash_value.assign(attr_str(node, "hashValue")); + out.salt_value.assign(attr_str(node, "saltValue")); + if (pugi::xml_attribute sc = node.attribute("spinCount"); sc) { + // Saturate at both ends rather than truncating. A `static_cast` of a + // value above 2^32-1 wraps, and a value that wraps to exactly 0 is then + // dropped by the writer's non-zero guard, so the saved file would carry + // a hash and salt with no iteration count at all. This surface promises + // verbatim round-trip, so an unrepresentable count must degrade to the + // nearest representable one instead of disappearing. + const long long parsed = sc.as_llong(0); + out.spin_count = parsed < 0 ? 0U + : parsed > static_cast(std::numeric_limits::max()) + ? std::numeric_limits::max() + : static_cast(parsed); + } + out.legacy_password.assign(attr_str(node, "password")); + + // Per-attribute XSD defaults (ECMA-376 §18.3.1.85). Eleven action flags + // (format*, insert*, delete*, sort, autoFilter, pivotTables) default to + // TRUE (locked); the rest default to FALSE. Reading with the wrong + // default silently under-reports protection when Excel omits an + // at-default attribute, so use the tri-state reader with each attribute's + // real default rather than a blanket `false`. + out.sheet = read_xsd_bool(node, "sheet", false); + out.objects = read_xsd_bool(node, "objects", false); + out.scenarios = read_xsd_bool(node, "scenarios", false); + out.format_cells = read_xsd_bool(node, "formatCells", true); + out.format_columns = read_xsd_bool(node, "formatColumns", true); + out.format_rows = read_xsd_bool(node, "formatRows", true); + out.insert_columns = read_xsd_bool(node, "insertColumns", true); + out.insert_rows = read_xsd_bool(node, "insertRows", true); + out.insert_hyperlinks = read_xsd_bool(node, "insertHyperlinks", true); + out.delete_columns = read_xsd_bool(node, "deleteColumns", true); + out.delete_rows = read_xsd_bool(node, "deleteRows", true); + out.select_locked_cells = read_xsd_bool(node, "selectLockedCells", false); + out.select_unlocked_cells = read_xsd_bool(node, "selectUnlockedCells", false); + out.sort = read_xsd_bool(node, "sort", true); + out.auto_filter = read_xsd_bool(node, "autoFilter", true); + out.pivot_tables = read_xsd_bool(node, "pivotTables", true); + + return out; +} + +} // namespace io +} // namespace formulon diff --git a/src/io/sheet_overlay_reader.h b/src/io/sheet_overlay_reader.h new file mode 100644 index 000000000..767e7e028 --- /dev/null +++ b/src/io/sheet_overlay_reader.h @@ -0,0 +1,78 @@ +// +// Per-sheet presentation overlays: ``, ``, +// `` and ``. These carry no cell value, so +// they are read independently of the `` walker in `sheet_reader.h`. + +#ifndef FORMULON_IO_SHEET_OVERLAY_READER_H_ +#define FORMULON_IO_SHEET_OVERLAY_READER_H_ + +#include +#include +#include + +#include "io/package_diagnostics.h" +#include "pugixml.hpp" +#include "sheet.h" +#include "utils/error.h" +#include "utils/expected.h" + +namespace formulon { +namespace io { + +/// Walks `` inside `worksheet` and returns the parsed +/// `MergeRange` list in document order. Returns an empty vector when +/// the sheet has no merges. +/// +/// Invalid-reference policy — shared by every presentation-overlay +/// reader on this sheet (merges, hyperlinks, data validations, and +/// `read_conditional_formats`): an entry whose reference is missing, +/// empty or unparseable is dropped with a WARN diagnostic +/// (`io.sheet.overlay.skip`) and the walk continues. None of this +/// metadata carries a cell value, so one malformed entry must not cost +/// the caller the whole workbook. Corruption inside `` still +/// fails the sheet with `kIoSheetCorrupt`. +/// +/// Every such drop bumps `diagnostics->skipped_feature_count` when +/// `diagnostics` is non-NULL. The structured log is off by default in +/// shipped builds, so that counter is what surfaces the loss to a caller. +/// Passing NULL discards the count. +Expected, Error> read_merges(const pugi::xml_node& worksheet, + ReadDiagnostics* diagnostics = nullptr); + +/// Walks `` inside `worksheet` and returns the parsed +/// `Hyperlink` list in document order. The reader populates +/// `Hyperlink::rid` from the `r:id` attribute as observed; the caller +/// is responsible for joining each rid against the sheet's rels file +/// to fill in `target` (external URL) — see `apply_hyperlink_rels`. +/// A malformed `ref=` drops that hyperlink under the invalid-reference +/// policy documented on `read_merges`, `diagnostics` included. +Expected, Error> read_hyperlinks(const pugi::xml_node& worksheet, + ReadDiagnostics* diagnostics = nullptr); + +/// Joins `hyperlinks` against the parsed sheet rels file (`rid -> target` +/// map) populating `Hyperlink::target` in place for entries whose `rid` +/// matches an entry in `rid_to_target`. Entries with empty `rid` or no +/// matching rid are left untouched (their `target` stays whatever the +/// reader picked up from any inline `location` attribute). +void apply_hyperlink_rels(std::vector& hyperlinks, + const std::unordered_map& rid_to_target); + +/// Walks `` inside `worksheet` and returns the parsed +/// `DataValidation` list in document order. Returns an empty vector +/// when the sheet has no validations. A malformed `sqref=` drops that +/// validation under the invalid-reference policy documented on +/// `read_merges`, `diagnostics` included. +Expected, Error> read_data_validations(const pugi::xml_node& worksheet, + ReadDiagnostics* diagnostics = nullptr); + +/// Reads `` from the sheet and returns the parsed +/// `SheetProtection`. When the element is absent the result has +/// `enabled = false` and every other field at its default. The reader +/// never fails: malformed attribute values fall back to their default +/// (matching pugixml's xsd:boolean conversion behaviour). +SheetProtection read_sheet_protection(const pugi::xml_node& worksheet); + +} // namespace io +} // namespace formulon + +#endif // FORMULON_IO_SHEET_OVERLAY_READER_H_ diff --git a/src/io/sheet_reader.cpp b/src/io/sheet_reader.cpp index 3174030de..78559d587 100644 --- a/src/io/sheet_reader.cpp +++ b/src/io/sheet_reader.cpp @@ -1034,357 +1034,5 @@ Expected read_sheet_data_sax(ByteSpan sheet_xml, std::size_t sheet_ #endif // !FORMULON_WASM || FORMULON_WASM_ENABLE_SAX -namespace { - -/// Decodes one A1-style range token (`A1`, `A1:B5`, or `$A$1:$B$5`) -/// into a `MergeRange`. Single-cell tokens land as `first == last`. -/// Both corners are normalised so `first <= last` componentwise. -Expected ParseA1RangeMerge(std::string_view ref) { - const std::size_t colon = ref.find(':'); - if (colon == std::string_view::npos) { - auto rc = parse_a1(ref); - if (!rc) { - std::string ctx("context=sheet_reader ref="); - ctx.append(ref); - return make_error(FormulonErrorCode::kIoSheetCorrupt, "merge/hyperlink: ref token unparseable", std::move(ctx)); - } - MergeRange out{}; - out.first_row = rc.value().first; - out.first_col = rc.value().second; - out.last_row = out.first_row; - out.last_col = out.first_col; - return out; - } - const std::string_view a = ref.substr(0, colon); - const std::string_view b = ref.substr(colon + 1); - auto a_rc = parse_a1(a); - auto b_rc = parse_a1(b); - if (!a_rc || !b_rc) { - std::string ctx("context=sheet_reader ref="); - ctx.append(ref); - return make_error(FormulonErrorCode::kIoSheetCorrupt, "merge: ref token unparseable", std::move(ctx)); - } - const std::uint32_t r0 = a_rc.value().first; - const std::uint32_t c0 = a_rc.value().second; - const std::uint32_t r1 = b_rc.value().first; - const std::uint32_t c1 = b_rc.value().second; - MergeRange out{}; - out.first_row = (r0 < r1) ? r0 : r1; - out.first_col = (c0 < c1) ? c0 : c1; - out.last_row = (r0 < r1) ? r1 : r0; - out.last_col = (c0 < c1) ? c1 : c0; - return out; -} - -/// Emits the WARN diagnostic for one skipped presentation-overlay entry. -/// Merges, hyperlinks and data validations are optional worksheet -/// metadata carrying no cell value, so a single malformed reference -/// drops that entry and load continues — the same disposition -/// `read_conditional_formats` applies to a malformed `` -/// block. Genuine cell-data corruption still fails the sheet. -void SkipOverlayEntry(std::string_view part, std::string_view reason, std::string_view ref, - ReadDiagnostics* diagnostics) { - StructuredLog("io.sheet.overlay.skip") - .field("part", part) - .field("reason", reason) - .field("ref", ref) - .error_code(FormulonErrorCode::kIoSheetCorrupt) - .warn(); - if (diagnostics != nullptr) { - ++diagnostics->skipped_feature_count; - } -} - -/// Splits a whitespace-separated `sqref="A1 B2:C3 D4"` attribute and -/// decodes each token. Returns `kIoSheetCorrupt` on the first -/// unparseable token. -Expected, Error> ParseSqrefRanges(std::string_view sqref) { - std::vector out; - std::size_t i = 0; - while (i < sqref.size()) { - while (i < sqref.size() && (sqref[i] == ' ' || sqref[i] == '\t' || sqref[i] == '\n' || sqref[i] == '\r')) { - ++i; - } - const std::size_t start = i; - while (i < sqref.size() && sqref[i] != ' ' && sqref[i] != '\t' && sqref[i] != '\n' && sqref[i] != '\r') { - ++i; - } - if (start == i) { - break; - } - auto r = ParseA1RangeMerge(sqref.substr(start, i - start)); - if (!r) { - return r.error(); - } - out.push_back(r.value()); - } - return out; -} - -} // namespace - -Expected, Error> read_merges(const pugi::xml_node& worksheet, ReadDiagnostics* diagnostics) { - std::vector out; - if (!worksheet) { - return out; - } - pugi::xml_node mc = worksheet.child("mergeCells"); - if (!mc) { - return out; - } - for (pugi::xml_node m = mc.child("mergeCell"); m; m = m.next_sibling("mergeCell")) { - const std::string_view ref = attr_str(m, "ref"); - if (ref.empty()) { - SkipOverlayEntry("mergeCells", "ref attribute missing or empty", ref, diagnostics); - continue; - } - auto r = ParseA1RangeMerge(ref); - if (!r) { - SkipOverlayEntry("mergeCells", "ref unparseable", ref, diagnostics); - continue; - } - out.push_back(r.value()); - } - return out; -} - -Expected, Error> read_hyperlinks(const pugi::xml_node& worksheet, ReadDiagnostics* diagnostics) { - std::vector out; - if (!worksheet) { - return out; - } - pugi::xml_node node = worksheet.child("hyperlinks"); - if (!node) { - return out; - } - for (pugi::xml_node h = node.child("hyperlink"); h; h = h.next_sibling("hyperlink")) { - const std::string_view ref = attr_str(h, "ref"); - if (ref.empty()) { - SkipOverlayEntry("hyperlinks", "ref attribute missing or empty", ref, diagnostics); - continue; - } - // Hyperlinks may span a range (`ref="A1:B2"`); Excel applies the link - // to every cell. Keep both corners in the model so structural edits can - // shrink/shift the complete rectangle and the writer can regenerate the - // A1 reference from numeric coordinates. - auto range_or = ParseA1RangeMerge(ref); - if (!range_or) { - SkipOverlayEntry("hyperlinks", "ref unparseable", ref, diagnostics); - continue; - } - Hyperlink hl; - hl.row = range_or.value().first_row; - hl.col = range_or.value().first_col; - hl.last_row = range_or.value().last_row; - hl.last_col = range_or.value().last_col; - // Accept both Office-namespaced ("r:id") and bare "id" attribute spellings. - const std::string_view rid_v = attr_str(h, "r:id"); - if (!rid_v.empty()) { - hl.rid.assign(rid_v); - } else { - hl.rid.assign(attr_str(h, "id")); - } - hl.location.assign(attr_str(h, "location")); - hl.display.assign(attr_str(h, "display")); - hl.tooltip.assign(attr_str(h, "tooltip")); - out.push_back(std::move(hl)); - } - return out; -} - -void apply_hyperlink_rels(std::vector& hyperlinks, - const std::unordered_map& rid_to_target) { - for (Hyperlink& h : hyperlinks) { - if (h.rid.empty()) { - continue; - } - auto it = rid_to_target.find(h.rid); - if (it == rid_to_target.end()) { - continue; - } - h.target = it->second; - } -} - -Expected, Error> read_data_validations(const pugi::xml_node& worksheet, - ReadDiagnostics* diagnostics) { - std::vector out; - if (!worksheet) { - return out; - } - pugi::xml_node dvs = worksheet.child("dataValidations"); - if (!dvs) { - return out; - } - for (pugi::xml_node dv = dvs.child("dataValidation"); dv; dv = dv.next_sibling("dataValidation")) { - DataValidation v; - const std::string_view sqref = attr_str(dv, "sqref"); - if (sqref.empty()) { - SkipOverlayEntry("dataValidations", "sqref attribute missing or empty", sqref, diagnostics); - continue; - } - auto ranges_or = ParseSqrefRanges(sqref); - if (!ranges_or) { - SkipOverlayEntry("dataValidations", "sqref unparseable", sqref, diagnostics); - continue; - } - v.ranges = std::move(ranges_or.value()); - - // type - const std::string_view t = attr_str(dv, "type"); - if (t == "whole") { - v.type = 1; - } else if (t == "decimal") { - v.type = 2; - } else if (t == "list") { - v.type = 3; - } else if (t == "date") { - v.type = 4; - } else if (t == "time") { - v.type = 5; - } else if (t == "textLength") { - v.type = 6; - } else if (t == "custom") { - v.type = 7; - } else { - v.type = 0; - } - - // operator - const std::string_view op = attr_str(dv, "operator"); - if (op == "notBetween") { - v.op = 1; - } else if (op == "equal") { - v.op = 2; - } else if (op == "notEqual") { - v.op = 3; - } else if (op == "greaterThan") { - v.op = 4; - } else if (op == "lessThan") { - v.op = 5; - } else if (op == "greaterThanOrEqual") { - v.op = 6; - } else if (op == "lessThanOrEqual") { - v.op = 7; - } else { - v.op = 0; // "between" / unspecified - } - - // errorStyle - const std::string_view es = attr_str(dv, "errorStyle"); - if (es == "warning") { - v.error_style = 1; - } else if (es == "information") { - v.error_style = 2; - } else { - v.error_style = 0; - } - - // Boolean attributes default to false: Excel omits allowBlank when it - // is off (the .xlsb twin's fAllowBlank bit is clear) and writes "1" - // when it is on. - v.allow_blank = false; - if (pugi::xml_attribute ab = dv.attribute("allowBlank"); ab) { - v.allow_blank = parse_xml_bool(ab.value()); - } - if (pugi::xml_attribute sim = dv.attribute("showInputMessage"); sim) { - v.show_input_message = parse_xml_bool(sim.value()); - } - if (pugi::xml_attribute sem = dv.attribute("showErrorMessage"); sem) { - v.show_error_message = parse_xml_bool(sem.value()); - } - // `showDropDown` has inverted semantics: presence with a true value - // suppresses the arrow, so the user-facing `show_dropdown` is the - // negation of the raw attribute (absent/false attribute => shown). - if (pugi::xml_attribute sdd = dv.attribute("showDropDown"); sdd) { - v.show_dropdown = !parse_xml_bool(sdd.value()); - } - - v.error_title.assign(attr_str(dv, "errorTitle")); - v.error_message.assign(attr_str(dv, "error")); - v.prompt_title.assign(attr_str(dv, "promptTitle")); - v.prompt_message.assign(attr_str(dv, "prompt")); - - // `AppendOoxmlTextUnescaped` decodes the OOXML `_xHHHH_` control- - // character escapes the writer emits for this slot (`AppendXmlEscaped` - // in ooxml/sheet_xml_builder.cpp), on top of `.text().get()`'s - // ordinary XML entity decoding. - if (pugi::xml_node f1 = dv.child("formula1"); f1) { - v.formula1.clear(); - AppendOoxmlTextUnescaped(v.formula1, f1.text().get()); - if (!v.formula1.empty() && v.formula1.front() == '=') { - v.formula1.erase(0, 1); - } - v.formula1 = canonical_feature_formula(v.formula1); - } - if (pugi::xml_node f2 = dv.child("formula2"); f2) { - v.formula2.clear(); - AppendOoxmlTextUnescaped(v.formula2, f2.text().get()); - if (!v.formula2.empty() && v.formula2.front() == '=') { - v.formula2.erase(0, 1); - } - v.formula2 = canonical_feature_formula(v.formula2); - } - - out.push_back(std::move(v)); - } - return out; -} - -SheetProtection read_sheet_protection(const pugi::xml_node& worksheet) { - SheetProtection out; - if (!worksheet) { - return out; - } - const pugi::xml_node node = worksheet.child("sheetProtection"); - if (!node) { - return out; - } - out.enabled = true; - - out.algorithm_name.assign(attr_str(node, "algorithmName")); - out.hash_value.assign(attr_str(node, "hashValue")); - out.salt_value.assign(attr_str(node, "saltValue")); - if (pugi::xml_attribute sc = node.attribute("spinCount"); sc) { - // Saturate at both ends rather than truncating. A `static_cast` of a - // value above 2^32-1 wraps, and a value that wraps to exactly 0 is then - // dropped by the writer's non-zero guard, so the saved file would carry - // a hash and salt with no iteration count at all. This surface promises - // verbatim round-trip, so an unrepresentable count must degrade to the - // nearest representable one instead of disappearing. - const long long parsed = sc.as_llong(0); - out.spin_count = parsed < 0 ? 0U - : parsed > static_cast(std::numeric_limits::max()) - ? std::numeric_limits::max() - : static_cast(parsed); - } - out.legacy_password.assign(attr_str(node, "password")); - - // Per-attribute XSD defaults (ECMA-376 §18.3.1.85). Eleven action flags - // (format*, insert*, delete*, sort, autoFilter, pivotTables) default to - // TRUE (locked); the rest default to FALSE. Reading with the wrong - // default silently under-reports protection when Excel omits an - // at-default attribute, so use the tri-state reader with each attribute's - // real default rather than a blanket `false`. - out.sheet = read_xsd_bool(node, "sheet", false); - out.objects = read_xsd_bool(node, "objects", false); - out.scenarios = read_xsd_bool(node, "scenarios", false); - out.format_cells = read_xsd_bool(node, "formatCells", true); - out.format_columns = read_xsd_bool(node, "formatColumns", true); - out.format_rows = read_xsd_bool(node, "formatRows", true); - out.insert_columns = read_xsd_bool(node, "insertColumns", true); - out.insert_rows = read_xsd_bool(node, "insertRows", true); - out.insert_hyperlinks = read_xsd_bool(node, "insertHyperlinks", true); - out.delete_columns = read_xsd_bool(node, "deleteColumns", true); - out.delete_rows = read_xsd_bool(node, "deleteRows", true); - out.select_locked_cells = read_xsd_bool(node, "selectLockedCells", false); - out.select_unlocked_cells = read_xsd_bool(node, "selectUnlockedCells", false); - out.sort = read_xsd_bool(node, "sort", true); - out.auto_filter = read_xsd_bool(node, "autoFilter", true); - out.pivot_tables = read_xsd_bool(node, "pivotTables", true); - - return out; -} - } // namespace io } // namespace formulon diff --git a/src/io/sheet_reader.h b/src/io/sheet_reader.h index 6541fde8d..658477a42 100644 --- a/src/io/sheet_reader.h +++ b/src/io/sheet_reader.h @@ -238,59 +238,6 @@ Expected read_sheet_data_sax(ByteSpan sheet_xml, std::size_t sheet_ Expected read_sheet_view_and_layout(const pugi::xml_document& sheet_doc, std::size_t sheet_index, Workbook& workbook); -/// Walks `` inside `worksheet` and returns the parsed -/// `MergeRange` list in document order. Returns an empty vector when -/// the sheet has no merges. -/// -/// Invalid-reference policy — shared by every presentation-overlay -/// reader on this sheet (merges, hyperlinks, data validations, and -/// `read_conditional_formats`): an entry whose reference is missing, -/// empty or unparseable is dropped with a WARN diagnostic -/// (`io.sheet.overlay.skip`) and the walk continues. None of this -/// metadata carries a cell value, so one malformed entry must not cost -/// the caller the whole workbook. Corruption inside `` still -/// fails the sheet with `kIoSheetCorrupt`. -/// -/// Every such drop bumps `diagnostics->skipped_feature_count` when -/// `diagnostics` is non-NULL. The structured log is off by default in -/// shipped builds, so that counter is what surfaces the loss to a caller. -/// Passing NULL discards the count. -Expected, Error> read_merges(const pugi::xml_node& worksheet, - ReadDiagnostics* diagnostics = nullptr); - -/// Walks `` inside `worksheet` and returns the parsed -/// `Hyperlink` list in document order. The reader populates -/// `Hyperlink::rid` from the `r:id` attribute as observed; the caller -/// is responsible for joining each rid against the sheet's rels file -/// to fill in `target` (external URL) — see `apply_hyperlink_rels`. -/// A malformed `ref=` drops that hyperlink under the invalid-reference -/// policy documented on `read_merges`, `diagnostics` included. -Expected, Error> read_hyperlinks(const pugi::xml_node& worksheet, - ReadDiagnostics* diagnostics = nullptr); - -/// Joins `hyperlinks` against the parsed sheet rels file (`rid -> target` -/// map) populating `Hyperlink::target` in place for entries whose `rid` -/// matches an entry in `rid_to_target`. Entries with empty `rid` or no -/// matching rid are left untouched (their `target` stays whatever the -/// reader picked up from any inline `location` attribute). -void apply_hyperlink_rels(std::vector& hyperlinks, - const std::unordered_map& rid_to_target); - -/// Walks `` inside `worksheet` and returns the parsed -/// `DataValidation` list in document order. Returns an empty vector -/// when the sheet has no validations. A malformed `sqref=` drops that -/// validation under the invalid-reference policy documented on -/// `read_merges`, `diagnostics` included. -Expected, Error> read_data_validations(const pugi::xml_node& worksheet, - ReadDiagnostics* diagnostics = nullptr); - -/// Reads `` from the sheet and returns the parsed -/// `SheetProtection`. When the element is absent the result has -/// `enabled = false` and every other field at its default. The reader -/// never fails: malformed attribute values fall back to their default -/// (matching pugixml's xsd:boolean conversion behaviour). -SheetProtection read_sheet_protection(const pugi::xml_node& worksheet); - } // namespace io } // namespace formulon diff --git a/src/io/xml_utils.h b/src/io/xml_utils.h index a24fb9968..d3e4b75c2 100644 --- a/src/io/xml_utils.h +++ b/src/io/xml_utils.h @@ -98,7 +98,7 @@ std::int32_t parse_xml_i32_attr(const pugi::xml_attribute& attr, std::int32_t de /// "true", false for everything else (including a missing attribute). /// Matches the lexicon Excel emits for the majority of `xs:boolean` /// attributes. For the case-insensitive variant Excel uses on a small -/// subset of sheet flags, see `sheet_reader.cpp`'s local helper. +/// subset of sheet flags, see `read_xsd_bool` in `io/xsd_bool.h`. bool parse_xml_bool_attr(const pugi::xml_attribute& attr); /// String-view variant of `parse_xml_bool_attr`. Returns true for diff --git a/tests/unit/io/data_validation_roundtrip_test.cpp b/tests/unit/io/data_validation_roundtrip_test.cpp index a117460c1..94790d8e1 100644 --- a/tests/unit/io/data_validation_roundtrip_test.cpp +++ b/tests/unit/io/data_validation_roundtrip_test.cpp @@ -4,7 +4,7 @@ #include #include "gtest/gtest.h" -#include "io/sheet_reader.h" +#include "io/sheet_overlay_reader.h" #include "pugixml.hpp" #include "sheet.h" diff --git a/tests/unit/io/hyperlink_roundtrip_test.cpp b/tests/unit/io/hyperlink_roundtrip_test.cpp index ca058c393..caa484f5c 100644 --- a/tests/unit/io/hyperlink_roundtrip_test.cpp +++ b/tests/unit/io/hyperlink_roundtrip_test.cpp @@ -7,7 +7,7 @@ #include #include "gtest/gtest.h" -#include "io/sheet_reader.h" +#include "io/sheet_overlay_reader.h" #include "pugixml.hpp" #include "sheet.h" diff --git a/tests/unit/io/merge_roundtrip_test.cpp b/tests/unit/io/merge_roundtrip_test.cpp index 81c07ccd5..c9551c34e 100644 --- a/tests/unit/io/merge_roundtrip_test.cpp +++ b/tests/unit/io/merge_roundtrip_test.cpp @@ -1,7 +1,7 @@ // // Round-trip tests for the `` reader. The matching writer // path lives in `ooxml_writer.cpp::BuildMergeCellsBlock`; the reader's -// public entry is `read_merges` in `sheet_reader.h`. Each test builds a +// public entry is `read_merges` in `sheet_overlay_reader.h`. Each test builds a // minimal `` DOM in memory, runs `read_merges`, and checks // the output against the expected `MergeRange` list. @@ -10,7 +10,7 @@ #include #include "gtest/gtest.h" -#include "io/sheet_reader.h" +#include "io/sheet_overlay_reader.h" #include "pugixml.hpp" #include "sheet.h" diff --git a/tests/unit/io/package_diagnostics_test.cpp b/tests/unit/io/package_diagnostics_test.cpp index cbbc87708..7a0cd1d80 100644 --- a/tests/unit/io/package_diagnostics_test.cpp +++ b/tests/unit/io/package_diagnostics_test.cpp @@ -25,7 +25,7 @@ #include "io/ooxml/workbook_xml_builder.h" #include "io/ooxml_reader.h" #include "io/ooxml_writer.h" -#include "io/sheet_reader.h" +#include "io/sheet_overlay_reader.h" #include "io/xlsb/reader.h" #include "io/xlsb/retained_part_fingerprint.h" #include "io/xlsb/writer.h" From 7f40dc38d520ed93f80a74146c2aae65fc395892 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:28:00 +0900 Subject: [PATCH 13/93] refactor(c_api): split style-table mutation out of styles.cpp - move the style-table mutation entry points from c_api/parts/styles.cpp into styles_mutate.cpp - move effective_num_fmt to the model layer (styles.h / styles.cpp) beside builtin_num_fmt so both files share it - register styles_mutate.cpp in CMakeLists.txt - behavior-preserving move; no logic changes --- CMakeLists.txt | 1 + src/c_api/parts/styles.cpp | 804 +----------------------------- src/c_api/parts/styles_mutate.cpp | 802 +++++++++++++++++++++++++++++ src/styles.cpp | 17 + src/styles.h | 8 + 5 files changed, 830 insertions(+), 802 deletions(-) create mode 100644 src/c_api/parts/styles_mutate.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b4b6bf6d..e7710fcc8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -385,6 +385,7 @@ src/eval/aggregate_lazy.cpp src/c_api/parts/sheet_layout.cpp src/c_api/parts/sheets_ui.cpp src/c_api/parts/styles.cpp + src/c_api/parts/styles_mutate.cpp src/c_api/parts/trace.cpp src/c_api/parts/version.cpp src/c_api/parts/workbook.cpp diff --git a/src/c_api/parts/styles.cpp b/src/c_api/parts/styles.cpp index 21a0e898f..8d7ea12e2 100644 --- a/src/c_api/parts/styles.cpp +++ b/src/c_api/parts/styles.cpp @@ -1,62 +1,33 @@ // // C ABI - styles surface (cell xf bindings, fonts, fills, borders, num -// formats, cell styles, dedup-on-insert helpers). +// formats, cell styles). The mutation side lives in styles_mutate.cpp. #include "styles.h" #include #include #include -#include #include #include #include -#include -#include #include -#include #include "c_api/formulon_c.h" #include "c_api/parts/common.h" #include "cell.h" -#include "io/styles_writer.h" #include "sheet.h" #include "utils/error.h" #include "utils/resource_budget.h" #include "workbook.h" +using formulon::effective_num_fmt; using formulon::c_api::parts::check_sheet_u32; using formulon::c_api::parts::clear_last_error; -using formulon::c_api::parts::num_fmt_id_known; using formulon::c_api::parts::set_binding_error; using formulon::c_api::parts::set_last_error; namespace { -static_assert(std::is_nothrow_move_constructible_v, - "StylesTable must be nothrow-move-constructible for staged style commits"); -static_assert(std::is_nothrow_move_assignable_v, - "StylesTable must be nothrow-move-assignable for staged style commits"); - -/// Resolves a number-format id using the effective OOXML mapping. A valid -/// custom record wins over the built-in slot, and duplicate ids resolve to -/// the first valid custom record in document order. `std::nullopt` means that -/// neither a valid custom record nor a non-empty built-in format exists. -std::optional effective_num_fmt(const formulon::StylesTable& styles, std::uint16_t id) { - for (const formulon::NumFmtRecord& record : styles.num_fmts) { - if (record.id == id && record.format_string_index < styles.num_fmt_strings.size()) { - return styles.num_fmt_strings[record.format_string_index]; - } - } - if (id < 164U) { - const char* builtin = formulon::builtin_num_fmt(id); - if (builtin != nullptr && builtin[0] != '\0') { - return std::string_view(builtin); - } - } - return std::nullopt; -} - /// Validates the `(handle, sheet_index, row, col)` quad and resolves /// the cell's `xf_index`. On failure populates the thread-local /// diagnostic and returns the status. On success writes @@ -408,774 +379,3 @@ extern "C" fm_status_t fm_styles_get_cell_style_xf(fm_workbook_t* wb, uint32_t i out->xf_id = 0; return 0; } - -namespace { - -/// Dedup identity for the font / fill / border tables. -/// -/// `fm_styles_add_*` may only hand back an existing index when the styles -/// writer cannot tell the stored record from the candidate, so identity is -/// the XML fragment the writer emits for the record. Deriving the key from -/// the writer rather than restating its rules here folds in every -/// writer-visible field by construction — the presence flags, `` / -/// ``, and the round-trip colour specifications that a -/// hand-written comparator kept missing — and keeps the linear-scan -/// comparison below and the batch hash key from drifting apart, because -/// they are the same function. -std::string font_key(const formulon::FontRecord& value) { - return formulon::io::font_fragment(value); -} - -std::string fill_key(const formulon::FillRecord& value) { - return formulon::io::fill_fragment(value); -} - -std::string border_key(const formulon::BorderRecord& value) { - return formulon::io::border_fragment(value); -} - -bool font_records_equal(const formulon::FontRecord& a, const formulon::FontRecord& b) { - return font_key(a) == font_key(b); -} - -bool fill_records_equal(const formulon::FillRecord& a, const formulon::FillRecord& b) { - return fill_key(a) == fill_key(b); -} - -bool border_records_equal(const formulon::BorderRecord& a, const formulon::BorderRecord& b) { - return border_key(a) == border_key(b); -} - -template -void append_key_scalar(std::string& out, const T& value) { - out.append(reinterpret_cast(&value), sizeof(value)); -} - -std::string cell_xf_key(const formulon::CellXf& value) { - std::string out; - append_key_scalar(out, value.font_index); - append_key_scalar(out, value.fill_index); - append_key_scalar(out, value.border_index); - append_key_scalar(out, value.num_fmt_id); - append_key_scalar(out, value.xf_id); - append_key_scalar(out, value.apply_number_format); - append_key_scalar(out, value.apply_font); - append_key_scalar(out, value.apply_fill); - append_key_scalar(out, value.apply_border); - append_key_scalar(out, value.apply_alignment); - append_key_scalar(out, value.apply_protection); - append_key_scalar(out, value.quote_prefix); - const bool has_alignment = formulon::HasAlignment(value); - append_key_scalar(out, has_alignment); - const bool has_horizontal_align = formulon::HasHorizontalAlign(value); - append_key_scalar(out, has_horizontal_align); - if (has_horizontal_align) { - append_key_scalar(out, value.horizontal_align); - } - const bool has_vertical_align = formulon::HasVerticalAlign(value); - append_key_scalar(out, has_vertical_align); - if (has_vertical_align) { - append_key_scalar(out, value.vertical_align); - } - const bool has_wrap_text = formulon::HasWrapText(value); - append_key_scalar(out, has_wrap_text); - if (has_wrap_text) { - append_key_scalar(out, value.wrap_text); - } - const bool has_justify_last_line = formulon::HasJustifyLastLine(value); - append_key_scalar(out, has_justify_last_line); - if (has_justify_last_line) { - append_key_scalar(out, value.justify_last_line); - } - append_key_scalar(out, value.has_text_rotation); - if (value.has_text_rotation) { - append_key_scalar(out, value.text_rotation); - } - append_key_scalar(out, value.has_indent); - if (value.has_indent) { - append_key_scalar(out, value.indent); - } - append_key_scalar(out, value.has_relative_indent); - if (value.has_relative_indent) { - append_key_scalar(out, value.relative_indent); - } - append_key_scalar(out, value.has_shrink_to_fit); - if (value.has_shrink_to_fit) { - append_key_scalar(out, value.shrink_to_fit); - } - append_key_scalar(out, value.has_reading_order); - if (value.has_reading_order) { - append_key_scalar(out, value.reading_order); - } - append_key_scalar(out, value.has_protection); - if (value.has_protection) { - append_key_scalar(out, value.locked); - append_key_scalar(out, value.hidden); - } - return out; -} - -/// A dxf's engaged children are compared through the same writer-derived -/// keys as the global tables, so a differential font that only differs in -/// `` or in an explicit `` is a distinct record. -/// -bool dxf_records_equal(const formulon::DifferentialFormat& a, const formulon::DifferentialFormat& b) { - return a.has_font == b.has_font && (!a.has_font || font_records_equal(a.font, b.font)) && a.has_fill == b.has_fill && - (!a.has_fill || fill_records_equal(a.fill, b.fill)) && a.has_border == b.has_border && - (!a.has_border || border_records_equal(a.border, b.border)) && a.has_num_fmt == b.has_num_fmt && - (!a.has_num_fmt || (a.num_fmt_id == b.num_fmt_id && a.num_fmt_code == b.num_fmt_code)) && - a.alignment_xml == b.alignment_xml && a.protection_xml == b.protection_xml; -} - -void ensure_default_style_roots(formulon::StylesTable& styles) { - if (styles.fonts.empty()) { - styles.fonts.emplace_back(); - } - if (styles.fills.empty()) { - styles.fills.emplace_back(); - } - if (styles.borders.empty()) { - styles.borders.emplace_back(); - } -} - -void ensure_default_cell_xf(formulon::StylesTable& styles) { - ensure_default_style_roots(styles); - if (styles.cell_xfs.empty()) { - styles.cell_xfs.emplace_back(); - } -} - -/// Rejects an `` whose font / fill / border / number-format reference -/// points past the corresponding table. Auto-growing the parallel tables -/// would hide the caller's ordering mistake and emit a dangling index that -/// Excel reports as a repairable file. -fm_status_t validate_xf_references(const formulon::StylesTable& styles, const fm_cell_xf& record, const char* api) { - auto reject = [api](const char* what, std::string context) { - const std::string message = std::string(api) + ": " + what; - return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, message.c_str(), std::move(context)); - }; - if (record.font_index >= styles.fonts.size()) { - return reject("font_index out of range", "font_index=" + std::to_string(record.font_index) + - " fonts_count=" + std::to_string(styles.fonts.size())); - } - if (record.fill_index >= styles.fills.size()) { - return reject("fill_index out of range", "fill_index=" + std::to_string(record.fill_index) + - " fills_count=" + std::to_string(styles.fills.size())); - } - if (record.border_index >= styles.borders.size()) { - return reject("border_index out of range", "border_index=" + std::to_string(record.border_index) + - " borders_count=" + std::to_string(styles.borders.size())); - } - if (!num_fmt_id_known(styles, record.num_fmt_id)) { - return reject("num_fmt_id not registered", "num_fmt_id=" + std::to_string(record.num_fmt_id)); - } - return 0; -} - -fm_status_t validate_xf_alignment(const fm_cell_xf& record, const char* api) { - auto reject = [api](const char* what, std::string context) { - const std::string message = std::string(api) + ": " + what; - return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, message.c_str(), std::move(context)); - }; - // The presence flags are authoritative. Values for an omitted attribute - // are ignored, including malformed alignment enum ordinals. The accepted - // ordinals are the canonical writer mapping for the corresponding XML - // string enums (general..distributed and top..distributed). - if (record.has_horizontal_align != 0 && record.horizontal_align > 7U) { - return reject("horizontal_align out of range", "horizontal_align=" + std::to_string(record.horizontal_align)); - } - if (record.has_vertical_align != 0 && record.vertical_align > 4U) { - return reject("vertical_align out of range", "vertical_align=" + std::to_string(record.vertical_align)); - } - if (record.has_text_rotation != 0 && record.text_rotation > 180U && record.text_rotation != 255U) { - return reject("text_rotation out of range", "text_rotation=" + std::to_string(record.text_rotation)); - } - if (record.has_indent != 0 && record.indent > 255U) { - return reject("indent out of range", "indent=" + std::to_string(record.indent)); - } - if (record.has_reading_order != 0 && record.reading_order > 2U) { - return reject("reading_order out of range", "reading_order=" + std::to_string(record.reading_order)); - } - return 0; -} - -formulon::CellXf cell_xf_from_c(const fm_cell_xf& record, std::uint32_t xf_id) { - formulon::CellXf candidate; - candidate.font_index = record.font_index; - candidate.fill_index = record.fill_index; - candidate.border_index = record.border_index; - candidate.num_fmt_id = record.num_fmt_id; - candidate.xf_id = xf_id; - - // The record carries explicit presence bits. Keep values for present - // attributes and canonicalize omitted ones so Has* does not infer a child - // from ignored poison values. - candidate.horizontal_align = record.has_horizontal_align != 0 ? record.horizontal_align : 0U; - candidate.vertical_align = record.has_vertical_align != 0 ? record.vertical_align : 2U; - candidate.wrap_text = record.has_wrap_text != 0 && record.wrap_text != 0; - candidate.justify_last_line = record.has_justify_last_line != 0 && record.justify_last_line != 0; - candidate.has_alignment = record.has_alignment != 0; - candidate.has_horizontal_align = record.has_horizontal_align != 0; - candidate.has_vertical_align = record.has_vertical_align != 0; - candidate.has_wrap_text = record.has_wrap_text != 0; - candidate.has_justify_last_line = record.has_justify_last_line != 0; - - // These optional attributes retain their existing presence-plus-value - // contract; values are copied even when the corresponding bit is false. - candidate.has_text_rotation = record.has_text_rotation != 0; - candidate.text_rotation = record.text_rotation; - candidate.has_indent = record.has_indent != 0; - candidate.indent = record.indent; - candidate.has_relative_indent = record.has_relative_indent != 0; - candidate.relative_indent = record.relative_indent; - candidate.has_shrink_to_fit = record.has_shrink_to_fit != 0; - candidate.shrink_to_fit = record.shrink_to_fit != 0; - candidate.has_reading_order = record.has_reading_order != 0; - candidate.reading_order = record.reading_order; - - candidate.has_alignment = formulon::HasAlignment(candidate); - return candidate; -} - -/// Accepts a colour specification from the caller. An out-of-range `kind` -/// degrades to `kNone`, which makes the writer emit the sibling `*_argb` as -/// `rgb` rather than emitting an unknown attribute. Non-`kNone` selectors -/// remain authoritative; no theme or indexed colour is resolved here. -formulon::ColorSpec color_from_c(const fm_color_spec& src) noexcept { - formulon::ColorSpec out; - out.kind = (src.kind <= static_cast(formulon::ColorSpec::Kind::kAuto)) - ? static_cast(src.kind) - : formulon::ColorSpec::Kind::kNone; - out.rgb = src.rgb; - out.theme = src.theme; - out.tint = src.tint; - out.indexed = src.indexed; - return out; -} - -formulon::FontRecord font_from_c(const fm_font_record& record) { - formulon::FontRecord out; - out.name = (record.name != nullptr) ? std::string(record.name) : std::string(); - out.size = record.size; - out.bold = record.bold != 0; - out.italic = record.italic != 0; - out.strike = record.strike != 0; - out.has_bold = record.has_bold != 0; - out.has_italic = record.has_italic != 0; - out.has_strike = record.has_strike != 0; - out.has_family = record.has_family != 0; - out.has_charset = record.has_charset != 0; - out.underline = record.underline; - out.vert_align = record.vert_align; - out.family = record.family; - out.charset = record.charset; - out.scheme = record.scheme; - out.color_argb = record.color_argb; - out.color = color_from_c(record.color); - return out; -} - -formulon::FillRecord fill_from_c(const fm_fill_record& record) noexcept { - formulon::FillRecord out; - out.pattern = record.pattern; - out.fg_argb = record.fg_argb; - out.bg_argb = record.bg_argb; - out.fg = color_from_c(record.fg); - out.bg = color_from_c(record.bg); - return out; -} - -formulon::BorderSide border_side_from_c(const fm_border_side& src) noexcept { - formulon::BorderSide dst; - dst.style = src.style; - dst.color_argb = src.color_argb; - dst.color = color_from_c(src.color); - return dst; -} - -formulon::BorderRecord border_from_c(const fm_border_record& record) noexcept { - formulon::BorderRecord out; - out.left = border_side_from_c(record.left); - out.right = border_side_from_c(record.right); - out.top = border_side_from_c(record.top); - out.bottom = border_side_from_c(record.bottom); - out.diagonal = border_side_from_c(record.diagonal); - out.diagonal_up = record.diagonal_up != 0; - out.diagonal_down = record.diagonal_down != 0; - return out; -} - -} // namespace - -extern "C" fm_status_t fm_styles_add_font(fm_workbook_t* wb, fm_font_record record, uint32_t* out_index) { - clear_last_error(); - if (wb == nullptr || out_index == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_font: NULL argument"); - } - formulon::FontRecord candidate = font_from_c(record); - - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - ensure_default_style_roots(styles); - // The candidate's key is loop-invariant; building it once keeps the scan - // linear in the table size instead of quadratic in fragment construction. - const std::string candidate_key = font_key(candidate); - for (std::size_t i = 0; i < styles.fonts.size(); ++i) { - if (font_key(styles.fonts[i]) == candidate_key) { - *out_index = static_cast(i); - return 0; - } - } - styles.fonts.push_back(std::move(candidate)); - *out_index = static_cast(styles.fonts.size() - 1); - return 0; -} - -extern "C" fm_status_t fm_styles_set_font(fm_workbook_t* wb, uint32_t font_index, fm_font_record record) { - clear_last_error(); - if (wb == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_set_font: NULL argument"); - } - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - // No `ensure_default_style_roots` here: growing the table to satisfy an - // out-of-range index would install a default-constructed font at every - // slot below it, which is a different workbook than the caller asked for. - if (font_index >= styles.fonts.size()) { - return set_binding_error( - formulon::FormulonErrorCode::kInvalidArgument, "fm_styles_set_font: font_index out of range", - "font_index=" + std::to_string(font_index) + " fonts_count=" + std::to_string(styles.fonts.size())); - } - styles.fonts[font_index] = font_from_c(record); - return 0; -} - -extern "C" fm_status_t fm_workbook_set_default_font(fm_workbook_t* wb, fm_font_record record) { - clear_last_error(); - if (wb == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, - "fm_workbook_set_default_font: NULL argument"); - } - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - // Unlike `fm_styles_set_font`, seeding is right here: index 0 is reserved - // by construction, so a table without it is an empty table rather than one - // whose slot the caller mis-numbered. - ensure_default_style_roots(styles); - styles.fonts[0] = font_from_c(record); - return 0; -} - -extern "C" fm_status_t fm_styles_add_fill(fm_workbook_t* wb, fm_fill_record record, uint32_t* out_index) { - clear_last_error(); - if (wb == nullptr || out_index == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_fill: NULL argument"); - } - formulon::FillRecord candidate = fill_from_c(record); - - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - ensure_default_style_roots(styles); - const std::string candidate_key = fill_key(candidate); - for (std::size_t i = 0; i < styles.fills.size(); ++i) { - if (fill_key(styles.fills[i]) == candidate_key) { - *out_index = static_cast(i); - return 0; - } - } - styles.fills.push_back(candidate); - *out_index = static_cast(styles.fills.size() - 1); - return 0; -} - -extern "C" fm_status_t fm_styles_add_border(fm_workbook_t* wb, fm_border_record record, uint32_t* out_index) { - clear_last_error(); - if (wb == nullptr || out_index == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_border: NULL argument"); - } - formulon::BorderRecord candidate = border_from_c(record); - - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - ensure_default_style_roots(styles); - const std::string candidate_key = border_key(candidate); - for (std::size_t i = 0; i < styles.borders.size(); ++i) { - if (border_key(styles.borders[i]) == candidate_key) { - *out_index = static_cast(i); - return 0; - } - } - styles.borders.push_back(candidate); - *out_index = static_cast(styles.borders.size() - 1); - return 0; -} - -extern "C" fm_status_t fm_styles_add_num_fmt(fm_workbook_t* wb, const char* format_code, uint16_t* out_num_fmt_id) { - clear_last_error(); - if (wb == nullptr || out_num_fmt_id == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_num_fmt: NULL argument"); - } - const std::string code = (format_code != nullptr) ? std::string(format_code) : std::string(); - const formulon::StylesTable& existing_styles = wb->workbook().styles(); - - // Step 1: return a built-in slot only when that slot's effective mapping - // matches the requested code. A custom record may override the built-in. - for (uint16_t id = 0; id < 164U; ++id) { - const std::optional resolved = effective_num_fmt(existing_styles, id); - if (resolved.has_value() && code == *resolved) { - *out_num_fmt_id = id; - return 0; - } - } - - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - - // Step 2: existing custom entry whose id still resolves to its code. This - // skips shadowed duplicate ids and invalid string indexes. - for (const formulon::NumFmtRecord& n : styles.num_fmts) { - const std::optional resolved = effective_num_fmt(styles, n.id); - if (resolved.has_value() && code == *resolved) { - *out_num_fmt_id = n.id; - return 0; - } - } - - // Step 3: append a new custom entry. New id is one past the largest - // existing custom id, with `163` as the lower bound (the last - // built-in slot). - std::uint32_t next_id = 163U; - for (const formulon::NumFmtRecord& n : styles.num_fmts) { - if (n.id > next_id) { - next_id = n.id; - } - } - if (next_id >= std::numeric_limits::max()) { - return set_binding_error(formulon::FormulonErrorCode::kPreconditionFailed, - "fm_styles_add_num_fmt: custom numFmtId space exhausted", - "max_num_fmt_id=" + std::to_string(std::numeric_limits::max())); - } - ++next_id; - - styles.num_fmt_strings.push_back(code); - formulon::NumFmtRecord rec; - rec.id = static_cast(next_id); - rec.format_string_index = static_cast(styles.num_fmt_strings.size() - 1); - styles.num_fmts.push_back(rec); - *out_num_fmt_id = static_cast(next_id); - return 0; -} - -namespace { - -fm_status_t add_cell_xf_impl(fm_workbook_t* wb, const fm_cell_xf& record, uint32_t* out_xf_index, const char* api) { - if (wb == nullptr || out_xf_index == nullptr) { - const std::string message = std::string(api) + ": NULL argument"; - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, message.c_str()); - } - if (fm_status_t rc = validate_xf_alignment(record, api); rc != 0) { - return rc; - } - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - ensure_default_cell_xf(styles); - - // Reject out-of-range references rather than auto-growing the parallel - // tables; callers must register fonts / fills / borders beforehand. - if (fm_status_t rc = validate_xf_references(styles, record, api); rc != 0) { - return rc; - } - // A non-zero `xfId` must name an existing `` entry, or the - // emitted attribute dangles. Zero stays legal with an empty table, which is - // what a record that names no parent style looks like. - if (record.xf_id != 0 && record.xf_id >= styles.cell_style_xfs.size()) { - const std::string message = std::string(api) + ": xf_id out of range"; - return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, message.c_str(), - "xf_id=" + std::to_string(record.xf_id) + - " cell_style_xfs_count=" + std::to_string(styles.cell_style_xfs.size())); - } - - // Confirm the read-side `fm_cell_xf` struct mirrors the engine - // `formulon::CellXf` field-for-field; the write side reuses the - // same shape so a layout drift would silently corrupt records. - static_assert(sizeof(record.font_index) == sizeof(formulon::CellXf::font_index), - "fm_cell_xf::font_index width must match formulon::CellXf"); - static_assert(sizeof(record.fill_index) == sizeof(formulon::CellXf::fill_index), - "fm_cell_xf::fill_index width must match formulon::CellXf"); - static_assert(sizeof(record.border_index) == sizeof(formulon::CellXf::border_index), - "fm_cell_xf::border_index width must match formulon::CellXf"); - static_assert(sizeof(record.num_fmt_id) == sizeof(formulon::CellXf::num_fmt_id), - "fm_cell_xf::num_fmt_id width must match formulon::CellXf"); - - formulon::CellXf candidate = cell_xf_from_c(record, record.xf_id); - const std::string candidate_key = cell_xf_key(candidate); - for (std::size_t i = 0; i < styles.cell_xfs.size(); ++i) { - if (cell_xf_key(styles.cell_xfs[i]) == candidate_key) { - *out_xf_index = static_cast(i); - return 0; - } - } - styles.cell_xfs.push_back(candidate); - *out_xf_index = static_cast(styles.cell_xfs.size() - 1); - return 0; -} - -} // namespace - -extern "C" fm_status_t fm_styles_add_cell_xf(fm_workbook_t* wb, fm_cell_xf record, uint32_t* out_xf_index) { - clear_last_error(); - return add_cell_xf_impl(wb, record, out_xf_index, "fm_styles_add_cell_xf"); -} - -extern "C" fm_status_t fm_styles_add_cell_style_xf(fm_workbook_t* wb, fm_cell_xf record, uint32_t* out_xf_id) { - clear_last_error(); - if (wb == nullptr || out_xf_id == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, - "fm_styles_add_cell_style_xf: NULL argument"); - } - if (fm_status_t rc = validate_xf_alignment(record, "fm_styles_add_cell_style_xf"); rc != 0) { - return rc; - } - auto& styles = wb->workbook().mutable_styles(); - ensure_default_style_roots(styles); - if (fm_status_t rc = validate_xf_references(styles, record, "fm_styles_add_cell_style_xf"); rc != 0) { - return rc; - } - formulon::CellXf candidate = cell_xf_from_c(record, 0U); - const std::string candidate_key = cell_xf_key(candidate); - for (std::size_t i = 0; i < styles.cell_style_xfs.size(); ++i) { - if (cell_xf_key(styles.cell_style_xfs[i]) == candidate_key) { - *out_xf_id = static_cast(i); - return 0; - } - } - styles.cell_style_xfs.push_back(std::move(candidate)); - *out_xf_id = static_cast(styles.cell_style_xfs.size() - 1U); - return 0; -} - -extern "C" fm_status_t fm_styles_set_cell_style(fm_workbook_t* wb, const char* name, uint32_t xf_id, - uint32_t builtin_id) { - clear_last_error(); - if (wb == nullptr || name == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, - "fm_styles_set_cell_style: NULL argument"); - } - auto& styles = wb->workbook().mutable_styles(); - if (name[0] == '\0' || xf_id >= styles.cell_style_xfs.size()) { - return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, - "fm_styles_set_cell_style: invalid name or xf_id"); - } - // The OOXML ordinal space is 0..47; anything else must use the sentinel, - // which suppresses the attribute instead of writing an unknown ordinal. - if (builtin_id > 47U && builtin_id != FM_CELL_STYLE_BUILTIN_ID_NONE) { - return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, - "fm_styles_set_cell_style: builtin_id out of range", - "builtin_id=" + std::to_string(builtin_id)); - } - for (auto& style : styles.cell_styles) { - if (style.name == name) { - style.xf_id = xf_id; - style.builtin_id = builtin_id; - return 0; - } - } - formulon::CellStyleRecord style; - style.name = name; - style.xf_id = xf_id; - style.builtin_id = builtin_id; - styles.cell_styles.push_back(std::move(style)); - return 0; -} - -extern "C" fm_status_t fm_styles_add_batch(fm_workbook_t* wb, const fm_styles_batch* batch) { - clear_last_error(); - if (wb == nullptr || batch == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_batch: NULL argument"); - } - if ((batch->font_count != 0U && (batch->fonts == nullptr || batch->font_indices == nullptr)) || - (batch->fill_count != 0U && (batch->fills == nullptr || batch->fill_indices == nullptr)) || - (batch->border_count != 0U && (batch->borders == nullptr || batch->border_indices == nullptr)) || - (batch->cell_xf_count != 0U && (batch->cell_xfs == nullptr || batch->cell_xf_indices == nullptr)) || - (batch->num_fmt_count != 0U && (batch->num_fmt_codes == nullptr || batch->num_fmt_ids == nullptr))) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, - "fm_styles_add_batch: non-empty array is NULL"); - } - - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - // Stage every mutation, including the default roots that are required for - // a valid xf. StylesTable owns strings and all nested records by value, so - // this is a deep copy. The live table and caller-owned output arrays remain - // untouched until every input record has passed validation. - formulon::StylesTable staged = styles; - ensure_default_style_roots(staged); - - std::vector staged_font_indices(batch->font_count); - std::vector staged_fill_indices(batch->fill_count); - std::vector staged_border_indices(batch->border_count); - std::vector staged_cell_xf_indices(batch->cell_xf_count); - std::vector staged_num_fmt_ids(batch->num_fmt_count); - - std::unordered_map fonts; - std::unordered_map fills; - std::unordered_map borders; - fonts.reserve(staged.fonts.size() + batch->font_count); - fills.reserve(staged.fills.size() + batch->fill_count); - borders.reserve(staged.borders.size() + batch->border_count); - for (uint32_t i = 0; i < staged.fonts.size(); ++i) - fonts.emplace(font_key(staged.fonts[i]), i); - for (uint32_t i = 0; i < staged.fills.size(); ++i) - fills.emplace(fill_key(staged.fills[i]), i); - for (uint32_t i = 0; i < staged.borders.size(); ++i) - borders.emplace(border_key(staged.borders[i]), i); - for (size_t i = 0; i < batch->font_count; ++i) { - formulon::FontRecord value = font_from_c(batch->fonts[i]); - const std::string key = font_key(value); - const auto [it, inserted] = fonts.emplace(key, static_cast(staged.fonts.size())); - if (inserted) - staged.fonts.push_back(std::move(value)); - staged_font_indices[i] = it->second; - } - for (size_t i = 0; i < batch->fill_count; ++i) { - formulon::FillRecord value = fill_from_c(batch->fills[i]); - const std::string key = fill_key(value); - const auto [it, inserted] = fills.emplace(key, static_cast(staged.fills.size())); - if (inserted) - staged.fills.push_back(value); - staged_fill_indices[i] = it->second; - } - for (size_t i = 0; i < batch->border_count; ++i) { - formulon::BorderRecord value = border_from_c(batch->borders[i]); - const std::string key = border_key(value); - const auto [it, inserted] = borders.emplace(key, static_cast(staged.borders.size())); - if (inserted) - staged.borders.push_back(value); - staged_border_indices[i] = it->second; - } - std::unordered_map num_fmts; - num_fmts.reserve(staged.num_fmts.size() + batch->num_fmt_count); - std::uint32_t next_num_fmt_id = 163U; - for (const formulon::NumFmtRecord& record : staged.num_fmts) { - const std::optional resolved = effective_num_fmt(staged, record.id); - if (record.format_string_index < staged.num_fmt_strings.size() && resolved.has_value() && - *resolved == staged.num_fmt_strings[record.format_string_index]) { - num_fmts.emplace(staged.num_fmt_strings[record.format_string_index], record.id); - } - if (record.id > next_num_fmt_id) { - next_num_fmt_id = record.id; - } - } - for (size_t i = 0; i < batch->num_fmt_count; ++i) { - const std::string code = batch->num_fmt_codes[i] != nullptr ? batch->num_fmt_codes[i] : ""; - uint16_t builtin_id = 0U; - bool is_builtin = false; - for (uint16_t id = 0U; id < 164U; ++id) { - const std::optional resolved = effective_num_fmt(staged, id); - if (resolved.has_value() && code == *resolved) { - builtin_id = id; - is_builtin = true; - break; - } - } - if (is_builtin) { - staged_num_fmt_ids[i] = builtin_id; - continue; - } - const auto existing = num_fmts.find(code); - if (existing != num_fmts.end()) { - staged_num_fmt_ids[i] = existing->second; - continue; - } - if (next_num_fmt_id >= std::numeric_limits::max()) { - return set_binding_error(formulon::FormulonErrorCode::kPreconditionFailed, - "fm_styles_add_batch: custom numFmtId space exhausted", - "max_num_fmt_id=" + std::to_string(std::numeric_limits::max())); - } - const std::uint32_t candidate_num_fmt_id = next_num_fmt_id + 1U; - num_fmts.emplace(code, static_cast(candidate_num_fmt_id)); - ++next_num_fmt_id; - staged.num_fmt_strings.push_back(code); - formulon::NumFmtRecord record; - record.id = static_cast(next_num_fmt_id); - record.format_string_index = static_cast(staged.num_fmt_strings.size() - 1U); - staged.num_fmts.push_back(record); - staged_num_fmt_ids[i] = record.id; - } - ensure_default_cell_xf(staged); - std::unordered_map xfs; - xfs.reserve(staged.cell_xfs.size() + batch->cell_xf_count); - for (uint32_t i = 0; i < staged.cell_xfs.size(); ++i) - xfs.emplace(cell_xf_key(staged.cell_xfs[i]), i); - for (size_t i = 0; i < batch->cell_xf_count; ++i) { - const fm_cell_xf& record = batch->cell_xfs[i]; - if (fm_status_t rc = validate_xf_alignment(record, "fm_styles_add_batch"); rc != 0) { - return rc; - } - if (fm_status_t rc = validate_xf_references(staged, record, "fm_styles_add_batch"); rc != 0) { - return rc; - } - // The batch shares the single-record presence-flag contract; `xfId` is - // staged as given so a batch may point at a named style added earlier, - // and is rejected when it would dangle. - if (record.xf_id != 0 && record.xf_id >= staged.cell_style_xfs.size()) { - return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, "fm_styles_add_batch: xf_id out of range", - "xf_id=" + std::to_string(record.xf_id) + - " cell_style_xfs_count=" + std::to_string(staged.cell_style_xfs.size())); - } - const formulon::CellXf value = cell_xf_from_c(record, record.xf_id); - const std::string key = cell_xf_key(value); - const auto [it, inserted] = xfs.emplace(key, static_cast(staged.cell_xfs.size())); - if (inserted) - staged.cell_xfs.push_back(value); - staged_cell_xf_indices[i] = it->second; - } - - // Commit the complete staged table and publish output indices only after - // all records have succeeded. A failed batch therefore leaves both the - // workbook and every caller-provided sentinel byte-identical. - styles = std::move(staged); - for (size_t i = 0; i < batch->font_count; ++i) - batch->font_indices[i] = staged_font_indices[i]; - for (size_t i = 0; i < batch->fill_count; ++i) - batch->fill_indices[i] = staged_fill_indices[i]; - for (size_t i = 0; i < batch->border_count; ++i) - batch->border_indices[i] = staged_border_indices[i]; - for (size_t i = 0; i < batch->cell_xf_count; ++i) - batch->cell_xf_indices[i] = staged_cell_xf_indices[i]; - for (size_t i = 0; i < batch->num_fmt_count; ++i) - batch->num_fmt_ids[i] = staged_num_fmt_ids[i]; - return 0; -} - -extern "C" fm_status_t fm_styles_add_dxf(fm_workbook_t* wb, fm_dxf_record record, uint32_t* out_dxf_index) { - clear_last_error(); - if (wb == nullptr || out_dxf_index == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_dxf: NULL argument"); - } - - formulon::DifferentialFormat candidate; - candidate.has_font = record.font_engaged != 0; - if (candidate.has_font) { - candidate.font = font_from_c(record.font); - } - candidate.has_fill = record.fill_engaged != 0; - if (candidate.has_fill) { - candidate.fill = fill_from_c(record.fill); - } - candidate.has_border = record.border_engaged != 0; - if (candidate.has_border) { - candidate.border = border_from_c(record.border); - } - candidate.has_num_fmt = record.num_fmt_engaged != 0; - if (candidate.has_num_fmt) { - candidate.num_fmt_id = record.num_fmt_id; - candidate.num_fmt_code = (record.num_fmt_code != nullptr) ? std::string(record.num_fmt_code) : std::string(); - } - candidate.alignment_xml = (record.alignment_xml != nullptr) ? std::string(record.alignment_xml) : std::string(); - candidate.protection_xml = (record.protection_xml != nullptr) ? std::string(record.protection_xml) : std::string(); - - formulon::StylesTable& styles = wb->workbook().mutable_styles(); - for (std::size_t i = 0; i < styles.dxfs.size(); ++i) { - if (dxf_records_equal(styles.dxfs[i], candidate)) { - *out_dxf_index = static_cast(i); - return 0; - } - } - styles.dxfs.push_back(std::move(candidate)); - *out_dxf_index = static_cast(styles.dxfs.size() - 1); - return 0; -} diff --git a/src/c_api/parts/styles_mutate.cpp b/src/c_api/parts/styles_mutate.cpp new file mode 100644 index 000000000..c76261f56 --- /dev/null +++ b/src/c_api/parts/styles_mutate.cpp @@ -0,0 +1,802 @@ +// +// C ABI - styles mutation surface (fonts, fills, borders, num formats, cell +// xfs, cell styles, dxfs and the batch commit, all with dedup-on-insert). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "c_api/formulon_c.h" +#include "c_api/parts/common.h" +#include "io/styles_writer.h" +#include "styles.h" +#include "utils/error.h" +#include "workbook.h" + +using formulon::effective_num_fmt; +using formulon::c_api::parts::clear_last_error; +using formulon::c_api::parts::num_fmt_id_known; +using formulon::c_api::parts::set_binding_error; + +namespace { + +static_assert(std::is_nothrow_move_constructible_v, + "StylesTable must be nothrow-move-constructible for staged style commits"); +static_assert(std::is_nothrow_move_assignable_v, + "StylesTable must be nothrow-move-assignable for staged style commits"); + +/// Dedup identity for the font / fill / border tables. +/// +/// `fm_styles_add_*` may only hand back an existing index when the styles +/// writer cannot tell the stored record from the candidate, so identity is +/// the XML fragment the writer emits for the record. Deriving the key from +/// the writer rather than restating its rules here folds in every +/// writer-visible field by construction — the presence flags, `` / +/// ``, and the round-trip colour specifications that a +/// hand-written comparator kept missing — and keeps the linear-scan +/// comparison below and the batch hash key from drifting apart, because +/// they are the same function. +std::string font_key(const formulon::FontRecord& value) { + return formulon::io::font_fragment(value); +} + +std::string fill_key(const formulon::FillRecord& value) { + return formulon::io::fill_fragment(value); +} + +std::string border_key(const formulon::BorderRecord& value) { + return formulon::io::border_fragment(value); +} + +bool font_records_equal(const formulon::FontRecord& a, const formulon::FontRecord& b) { + return font_key(a) == font_key(b); +} + +bool fill_records_equal(const formulon::FillRecord& a, const formulon::FillRecord& b) { + return fill_key(a) == fill_key(b); +} + +bool border_records_equal(const formulon::BorderRecord& a, const formulon::BorderRecord& b) { + return border_key(a) == border_key(b); +} + +template +void append_key_scalar(std::string& out, const T& value) { + out.append(reinterpret_cast(&value), sizeof(value)); +} + +std::string cell_xf_key(const formulon::CellXf& value) { + std::string out; + append_key_scalar(out, value.font_index); + append_key_scalar(out, value.fill_index); + append_key_scalar(out, value.border_index); + append_key_scalar(out, value.num_fmt_id); + append_key_scalar(out, value.xf_id); + append_key_scalar(out, value.apply_number_format); + append_key_scalar(out, value.apply_font); + append_key_scalar(out, value.apply_fill); + append_key_scalar(out, value.apply_border); + append_key_scalar(out, value.apply_alignment); + append_key_scalar(out, value.apply_protection); + append_key_scalar(out, value.quote_prefix); + const bool has_alignment = formulon::HasAlignment(value); + append_key_scalar(out, has_alignment); + const bool has_horizontal_align = formulon::HasHorizontalAlign(value); + append_key_scalar(out, has_horizontal_align); + if (has_horizontal_align) { + append_key_scalar(out, value.horizontal_align); + } + const bool has_vertical_align = formulon::HasVerticalAlign(value); + append_key_scalar(out, has_vertical_align); + if (has_vertical_align) { + append_key_scalar(out, value.vertical_align); + } + const bool has_wrap_text = formulon::HasWrapText(value); + append_key_scalar(out, has_wrap_text); + if (has_wrap_text) { + append_key_scalar(out, value.wrap_text); + } + const bool has_justify_last_line = formulon::HasJustifyLastLine(value); + append_key_scalar(out, has_justify_last_line); + if (has_justify_last_line) { + append_key_scalar(out, value.justify_last_line); + } + append_key_scalar(out, value.has_text_rotation); + if (value.has_text_rotation) { + append_key_scalar(out, value.text_rotation); + } + append_key_scalar(out, value.has_indent); + if (value.has_indent) { + append_key_scalar(out, value.indent); + } + append_key_scalar(out, value.has_relative_indent); + if (value.has_relative_indent) { + append_key_scalar(out, value.relative_indent); + } + append_key_scalar(out, value.has_shrink_to_fit); + if (value.has_shrink_to_fit) { + append_key_scalar(out, value.shrink_to_fit); + } + append_key_scalar(out, value.has_reading_order); + if (value.has_reading_order) { + append_key_scalar(out, value.reading_order); + } + append_key_scalar(out, value.has_protection); + if (value.has_protection) { + append_key_scalar(out, value.locked); + append_key_scalar(out, value.hidden); + } + return out; +} + +/// A dxf's engaged children are compared through the same writer-derived +/// keys as the global tables, so a differential font that only differs in +/// `` or in an explicit `` is a distinct record. +/// +bool dxf_records_equal(const formulon::DifferentialFormat& a, const formulon::DifferentialFormat& b) { + return a.has_font == b.has_font && (!a.has_font || font_records_equal(a.font, b.font)) && a.has_fill == b.has_fill && + (!a.has_fill || fill_records_equal(a.fill, b.fill)) && a.has_border == b.has_border && + (!a.has_border || border_records_equal(a.border, b.border)) && a.has_num_fmt == b.has_num_fmt && + (!a.has_num_fmt || (a.num_fmt_id == b.num_fmt_id && a.num_fmt_code == b.num_fmt_code)) && + a.alignment_xml == b.alignment_xml && a.protection_xml == b.protection_xml; +} + +void ensure_default_style_roots(formulon::StylesTable& styles) { + if (styles.fonts.empty()) { + styles.fonts.emplace_back(); + } + if (styles.fills.empty()) { + styles.fills.emplace_back(); + } + if (styles.borders.empty()) { + styles.borders.emplace_back(); + } +} + +void ensure_default_cell_xf(formulon::StylesTable& styles) { + ensure_default_style_roots(styles); + if (styles.cell_xfs.empty()) { + styles.cell_xfs.emplace_back(); + } +} + +/// Rejects an `` whose font / fill / border / number-format reference +/// points past the corresponding table. Auto-growing the parallel tables +/// would hide the caller's ordering mistake and emit a dangling index that +/// Excel reports as a repairable file. +fm_status_t validate_xf_references(const formulon::StylesTable& styles, const fm_cell_xf& record, const char* api) { + auto reject = [api](const char* what, std::string context) { + const std::string message = std::string(api) + ": " + what; + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, message.c_str(), std::move(context)); + }; + if (record.font_index >= styles.fonts.size()) { + return reject("font_index out of range", "font_index=" + std::to_string(record.font_index) + + " fonts_count=" + std::to_string(styles.fonts.size())); + } + if (record.fill_index >= styles.fills.size()) { + return reject("fill_index out of range", "fill_index=" + std::to_string(record.fill_index) + + " fills_count=" + std::to_string(styles.fills.size())); + } + if (record.border_index >= styles.borders.size()) { + return reject("border_index out of range", "border_index=" + std::to_string(record.border_index) + + " borders_count=" + std::to_string(styles.borders.size())); + } + if (!num_fmt_id_known(styles, record.num_fmt_id)) { + return reject("num_fmt_id not registered", "num_fmt_id=" + std::to_string(record.num_fmt_id)); + } + return 0; +} + +fm_status_t validate_xf_alignment(const fm_cell_xf& record, const char* api) { + auto reject = [api](const char* what, std::string context) { + const std::string message = std::string(api) + ": " + what; + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, message.c_str(), std::move(context)); + }; + // The presence flags are authoritative. Values for an omitted attribute + // are ignored, including malformed alignment enum ordinals. The accepted + // ordinals are the canonical writer mapping for the corresponding XML + // string enums (general..distributed and top..distributed). + if (record.has_horizontal_align != 0 && record.horizontal_align > 7U) { + return reject("horizontal_align out of range", "horizontal_align=" + std::to_string(record.horizontal_align)); + } + if (record.has_vertical_align != 0 && record.vertical_align > 4U) { + return reject("vertical_align out of range", "vertical_align=" + std::to_string(record.vertical_align)); + } + if (record.has_text_rotation != 0 && record.text_rotation > 180U && record.text_rotation != 255U) { + return reject("text_rotation out of range", "text_rotation=" + std::to_string(record.text_rotation)); + } + if (record.has_indent != 0 && record.indent > 255U) { + return reject("indent out of range", "indent=" + std::to_string(record.indent)); + } + if (record.has_reading_order != 0 && record.reading_order > 2U) { + return reject("reading_order out of range", "reading_order=" + std::to_string(record.reading_order)); + } + return 0; +} + +formulon::CellXf cell_xf_from_c(const fm_cell_xf& record, std::uint32_t xf_id) { + formulon::CellXf candidate; + candidate.font_index = record.font_index; + candidate.fill_index = record.fill_index; + candidate.border_index = record.border_index; + candidate.num_fmt_id = record.num_fmt_id; + candidate.xf_id = xf_id; + + // The record carries explicit presence bits. Keep values for present + // attributes and canonicalize omitted ones so Has* does not infer a child + // from ignored poison values. + candidate.horizontal_align = record.has_horizontal_align != 0 ? record.horizontal_align : 0U; + candidate.vertical_align = record.has_vertical_align != 0 ? record.vertical_align : 2U; + candidate.wrap_text = record.has_wrap_text != 0 && record.wrap_text != 0; + candidate.justify_last_line = record.has_justify_last_line != 0 && record.justify_last_line != 0; + candidate.has_alignment = record.has_alignment != 0; + candidate.has_horizontal_align = record.has_horizontal_align != 0; + candidate.has_vertical_align = record.has_vertical_align != 0; + candidate.has_wrap_text = record.has_wrap_text != 0; + candidate.has_justify_last_line = record.has_justify_last_line != 0; + + // These optional attributes retain their existing presence-plus-value + // contract; values are copied even when the corresponding bit is false. + candidate.has_text_rotation = record.has_text_rotation != 0; + candidate.text_rotation = record.text_rotation; + candidate.has_indent = record.has_indent != 0; + candidate.indent = record.indent; + candidate.has_relative_indent = record.has_relative_indent != 0; + candidate.relative_indent = record.relative_indent; + candidate.has_shrink_to_fit = record.has_shrink_to_fit != 0; + candidate.shrink_to_fit = record.shrink_to_fit != 0; + candidate.has_reading_order = record.has_reading_order != 0; + candidate.reading_order = record.reading_order; + + candidate.has_alignment = formulon::HasAlignment(candidate); + return candidate; +} + +/// Accepts a colour specification from the caller. An out-of-range `kind` +/// degrades to `kNone`, which makes the writer emit the sibling `*_argb` as +/// `rgb` rather than emitting an unknown attribute. Non-`kNone` selectors +/// remain authoritative; no theme or indexed colour is resolved here. +formulon::ColorSpec color_from_c(const fm_color_spec& src) noexcept { + formulon::ColorSpec out; + out.kind = (src.kind <= static_cast(formulon::ColorSpec::Kind::kAuto)) + ? static_cast(src.kind) + : formulon::ColorSpec::Kind::kNone; + out.rgb = src.rgb; + out.theme = src.theme; + out.tint = src.tint; + out.indexed = src.indexed; + return out; +} + +formulon::FontRecord font_from_c(const fm_font_record& record) { + formulon::FontRecord out; + out.name = (record.name != nullptr) ? std::string(record.name) : std::string(); + out.size = record.size; + out.bold = record.bold != 0; + out.italic = record.italic != 0; + out.strike = record.strike != 0; + out.has_bold = record.has_bold != 0; + out.has_italic = record.has_italic != 0; + out.has_strike = record.has_strike != 0; + out.has_family = record.has_family != 0; + out.has_charset = record.has_charset != 0; + out.underline = record.underline; + out.vert_align = record.vert_align; + out.family = record.family; + out.charset = record.charset; + out.scheme = record.scheme; + out.color_argb = record.color_argb; + out.color = color_from_c(record.color); + return out; +} + +formulon::FillRecord fill_from_c(const fm_fill_record& record) noexcept { + formulon::FillRecord out; + out.pattern = record.pattern; + out.fg_argb = record.fg_argb; + out.bg_argb = record.bg_argb; + out.fg = color_from_c(record.fg); + out.bg = color_from_c(record.bg); + return out; +} + +formulon::BorderSide border_side_from_c(const fm_border_side& src) noexcept { + formulon::BorderSide dst; + dst.style = src.style; + dst.color_argb = src.color_argb; + dst.color = color_from_c(src.color); + return dst; +} + +formulon::BorderRecord border_from_c(const fm_border_record& record) noexcept { + formulon::BorderRecord out; + out.left = border_side_from_c(record.left); + out.right = border_side_from_c(record.right); + out.top = border_side_from_c(record.top); + out.bottom = border_side_from_c(record.bottom); + out.diagonal = border_side_from_c(record.diagonal); + out.diagonal_up = record.diagonal_up != 0; + out.diagonal_down = record.diagonal_down != 0; + return out; +} + +} // namespace + +extern "C" fm_status_t fm_styles_add_font(fm_workbook_t* wb, fm_font_record record, uint32_t* out_index) { + clear_last_error(); + if (wb == nullptr || out_index == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_font: NULL argument"); + } + formulon::FontRecord candidate = font_from_c(record); + + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + ensure_default_style_roots(styles); + // The candidate's key is loop-invariant; building it once keeps the scan + // linear in the table size instead of quadratic in fragment construction. + const std::string candidate_key = font_key(candidate); + for (std::size_t i = 0; i < styles.fonts.size(); ++i) { + if (font_key(styles.fonts[i]) == candidate_key) { + *out_index = static_cast(i); + return 0; + } + } + styles.fonts.push_back(std::move(candidate)); + *out_index = static_cast(styles.fonts.size() - 1); + return 0; +} + +extern "C" fm_status_t fm_styles_set_font(fm_workbook_t* wb, uint32_t font_index, fm_font_record record) { + clear_last_error(); + if (wb == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_set_font: NULL argument"); + } + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + // No `ensure_default_style_roots` here: growing the table to satisfy an + // out-of-range index would install a default-constructed font at every + // slot below it, which is a different workbook than the caller asked for. + if (font_index >= styles.fonts.size()) { + return set_binding_error( + formulon::FormulonErrorCode::kInvalidArgument, "fm_styles_set_font: font_index out of range", + "font_index=" + std::to_string(font_index) + " fonts_count=" + std::to_string(styles.fonts.size())); + } + styles.fonts[font_index] = font_from_c(record); + return 0; +} + +extern "C" fm_status_t fm_workbook_set_default_font(fm_workbook_t* wb, fm_font_record record) { + clear_last_error(); + if (wb == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_workbook_set_default_font: NULL argument"); + } + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + // Unlike `fm_styles_set_font`, seeding is right here: index 0 is reserved + // by construction, so a table without it is an empty table rather than one + // whose slot the caller mis-numbered. + ensure_default_style_roots(styles); + styles.fonts[0] = font_from_c(record); + return 0; +} + +extern "C" fm_status_t fm_styles_add_fill(fm_workbook_t* wb, fm_fill_record record, uint32_t* out_index) { + clear_last_error(); + if (wb == nullptr || out_index == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_fill: NULL argument"); + } + formulon::FillRecord candidate = fill_from_c(record); + + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + ensure_default_style_roots(styles); + const std::string candidate_key = fill_key(candidate); + for (std::size_t i = 0; i < styles.fills.size(); ++i) { + if (fill_key(styles.fills[i]) == candidate_key) { + *out_index = static_cast(i); + return 0; + } + } + styles.fills.push_back(candidate); + *out_index = static_cast(styles.fills.size() - 1); + return 0; +} + +extern "C" fm_status_t fm_styles_add_border(fm_workbook_t* wb, fm_border_record record, uint32_t* out_index) { + clear_last_error(); + if (wb == nullptr || out_index == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_border: NULL argument"); + } + formulon::BorderRecord candidate = border_from_c(record); + + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + ensure_default_style_roots(styles); + const std::string candidate_key = border_key(candidate); + for (std::size_t i = 0; i < styles.borders.size(); ++i) { + if (border_key(styles.borders[i]) == candidate_key) { + *out_index = static_cast(i); + return 0; + } + } + styles.borders.push_back(candidate); + *out_index = static_cast(styles.borders.size() - 1); + return 0; +} + +extern "C" fm_status_t fm_styles_add_num_fmt(fm_workbook_t* wb, const char* format_code, uint16_t* out_num_fmt_id) { + clear_last_error(); + if (wb == nullptr || out_num_fmt_id == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_num_fmt: NULL argument"); + } + const std::string code = (format_code != nullptr) ? std::string(format_code) : std::string(); + const formulon::StylesTable& existing_styles = wb->workbook().styles(); + + // Step 1: return a built-in slot only when that slot's effective mapping + // matches the requested code. A custom record may override the built-in. + for (uint16_t id = 0; id < 164U; ++id) { + const std::optional resolved = effective_num_fmt(existing_styles, id); + if (resolved.has_value() && code == *resolved) { + *out_num_fmt_id = id; + return 0; + } + } + + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + + // Step 2: existing custom entry whose id still resolves to its code. This + // skips shadowed duplicate ids and invalid string indexes. + for (const formulon::NumFmtRecord& n : styles.num_fmts) { + const std::optional resolved = effective_num_fmt(styles, n.id); + if (resolved.has_value() && code == *resolved) { + *out_num_fmt_id = n.id; + return 0; + } + } + + // Step 3: append a new custom entry. New id is one past the largest + // existing custom id, with `163` as the lower bound (the last + // built-in slot). + std::uint32_t next_id = 163U; + for (const formulon::NumFmtRecord& n : styles.num_fmts) { + if (n.id > next_id) { + next_id = n.id; + } + } + if (next_id >= std::numeric_limits::max()) { + return set_binding_error(formulon::FormulonErrorCode::kPreconditionFailed, + "fm_styles_add_num_fmt: custom numFmtId space exhausted", + "max_num_fmt_id=" + std::to_string(std::numeric_limits::max())); + } + ++next_id; + + styles.num_fmt_strings.push_back(code); + formulon::NumFmtRecord rec; + rec.id = static_cast(next_id); + rec.format_string_index = static_cast(styles.num_fmt_strings.size() - 1); + styles.num_fmts.push_back(rec); + *out_num_fmt_id = static_cast(next_id); + return 0; +} + +namespace { + +fm_status_t add_cell_xf_impl(fm_workbook_t* wb, const fm_cell_xf& record, uint32_t* out_xf_index, const char* api) { + if (wb == nullptr || out_xf_index == nullptr) { + const std::string message = std::string(api) + ": NULL argument"; + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, message.c_str()); + } + if (fm_status_t rc = validate_xf_alignment(record, api); rc != 0) { + return rc; + } + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + ensure_default_cell_xf(styles); + + // Reject out-of-range references rather than auto-growing the parallel + // tables; callers must register fonts / fills / borders beforehand. + if (fm_status_t rc = validate_xf_references(styles, record, api); rc != 0) { + return rc; + } + // A non-zero `xfId` must name an existing `` entry, or the + // emitted attribute dangles. Zero stays legal with an empty table, which is + // what a record that names no parent style looks like. + if (record.xf_id != 0 && record.xf_id >= styles.cell_style_xfs.size()) { + const std::string message = std::string(api) + ": xf_id out of range"; + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, message.c_str(), + "xf_id=" + std::to_string(record.xf_id) + + " cell_style_xfs_count=" + std::to_string(styles.cell_style_xfs.size())); + } + + // Confirm the read-side `fm_cell_xf` struct mirrors the engine + // `formulon::CellXf` field-for-field; the write side reuses the + // same shape so a layout drift would silently corrupt records. + static_assert(sizeof(record.font_index) == sizeof(formulon::CellXf::font_index), + "fm_cell_xf::font_index width must match formulon::CellXf"); + static_assert(sizeof(record.fill_index) == sizeof(formulon::CellXf::fill_index), + "fm_cell_xf::fill_index width must match formulon::CellXf"); + static_assert(sizeof(record.border_index) == sizeof(formulon::CellXf::border_index), + "fm_cell_xf::border_index width must match formulon::CellXf"); + static_assert(sizeof(record.num_fmt_id) == sizeof(formulon::CellXf::num_fmt_id), + "fm_cell_xf::num_fmt_id width must match formulon::CellXf"); + + formulon::CellXf candidate = cell_xf_from_c(record, record.xf_id); + const std::string candidate_key = cell_xf_key(candidate); + for (std::size_t i = 0; i < styles.cell_xfs.size(); ++i) { + if (cell_xf_key(styles.cell_xfs[i]) == candidate_key) { + *out_xf_index = static_cast(i); + return 0; + } + } + styles.cell_xfs.push_back(candidate); + *out_xf_index = static_cast(styles.cell_xfs.size() - 1); + return 0; +} + +} // namespace + +extern "C" fm_status_t fm_styles_add_cell_xf(fm_workbook_t* wb, fm_cell_xf record, uint32_t* out_xf_index) { + clear_last_error(); + return add_cell_xf_impl(wb, record, out_xf_index, "fm_styles_add_cell_xf"); +} + +extern "C" fm_status_t fm_styles_add_cell_style_xf(fm_workbook_t* wb, fm_cell_xf record, uint32_t* out_xf_id) { + clear_last_error(); + if (wb == nullptr || out_xf_id == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_styles_add_cell_style_xf: NULL argument"); + } + if (fm_status_t rc = validate_xf_alignment(record, "fm_styles_add_cell_style_xf"); rc != 0) { + return rc; + } + auto& styles = wb->workbook().mutable_styles(); + ensure_default_style_roots(styles); + if (fm_status_t rc = validate_xf_references(styles, record, "fm_styles_add_cell_style_xf"); rc != 0) { + return rc; + } + formulon::CellXf candidate = cell_xf_from_c(record, 0U); + const std::string candidate_key = cell_xf_key(candidate); + for (std::size_t i = 0; i < styles.cell_style_xfs.size(); ++i) { + if (cell_xf_key(styles.cell_style_xfs[i]) == candidate_key) { + *out_xf_id = static_cast(i); + return 0; + } + } + styles.cell_style_xfs.push_back(std::move(candidate)); + *out_xf_id = static_cast(styles.cell_style_xfs.size() - 1U); + return 0; +} + +extern "C" fm_status_t fm_styles_set_cell_style(fm_workbook_t* wb, const char* name, uint32_t xf_id, + uint32_t builtin_id) { + clear_last_error(); + if (wb == nullptr || name == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_styles_set_cell_style: NULL argument"); + } + auto& styles = wb->workbook().mutable_styles(); + if (name[0] == '\0' || xf_id >= styles.cell_style_xfs.size()) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_styles_set_cell_style: invalid name or xf_id"); + } + // The OOXML ordinal space is 0..47; anything else must use the sentinel, + // which suppresses the attribute instead of writing an unknown ordinal. + if (builtin_id > 47U && builtin_id != FM_CELL_STYLE_BUILTIN_ID_NONE) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_styles_set_cell_style: builtin_id out of range", + "builtin_id=" + std::to_string(builtin_id)); + } + for (auto& style : styles.cell_styles) { + if (style.name == name) { + style.xf_id = xf_id; + style.builtin_id = builtin_id; + return 0; + } + } + formulon::CellStyleRecord style; + style.name = name; + style.xf_id = xf_id; + style.builtin_id = builtin_id; + styles.cell_styles.push_back(std::move(style)); + return 0; +} + +extern "C" fm_status_t fm_styles_add_batch(fm_workbook_t* wb, const fm_styles_batch* batch) { + clear_last_error(); + if (wb == nullptr || batch == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_batch: NULL argument"); + } + if ((batch->font_count != 0U && (batch->fonts == nullptr || batch->font_indices == nullptr)) || + (batch->fill_count != 0U && (batch->fills == nullptr || batch->fill_indices == nullptr)) || + (batch->border_count != 0U && (batch->borders == nullptr || batch->border_indices == nullptr)) || + (batch->cell_xf_count != 0U && (batch->cell_xfs == nullptr || batch->cell_xf_indices == nullptr)) || + (batch->num_fmt_count != 0U && (batch->num_fmt_codes == nullptr || batch->num_fmt_ids == nullptr))) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_styles_add_batch: non-empty array is NULL"); + } + + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + // Stage every mutation, including the default roots that are required for + // a valid xf. StylesTable owns strings and all nested records by value, so + // this is a deep copy. The live table and caller-owned output arrays remain + // untouched until every input record has passed validation. + formulon::StylesTable staged = styles; + ensure_default_style_roots(staged); + + std::vector staged_font_indices(batch->font_count); + std::vector staged_fill_indices(batch->fill_count); + std::vector staged_border_indices(batch->border_count); + std::vector staged_cell_xf_indices(batch->cell_xf_count); + std::vector staged_num_fmt_ids(batch->num_fmt_count); + + std::unordered_map fonts; + std::unordered_map fills; + std::unordered_map borders; + fonts.reserve(staged.fonts.size() + batch->font_count); + fills.reserve(staged.fills.size() + batch->fill_count); + borders.reserve(staged.borders.size() + batch->border_count); + for (uint32_t i = 0; i < staged.fonts.size(); ++i) + fonts.emplace(font_key(staged.fonts[i]), i); + for (uint32_t i = 0; i < staged.fills.size(); ++i) + fills.emplace(fill_key(staged.fills[i]), i); + for (uint32_t i = 0; i < staged.borders.size(); ++i) + borders.emplace(border_key(staged.borders[i]), i); + for (size_t i = 0; i < batch->font_count; ++i) { + formulon::FontRecord value = font_from_c(batch->fonts[i]); + const std::string key = font_key(value); + const auto [it, inserted] = fonts.emplace(key, static_cast(staged.fonts.size())); + if (inserted) + staged.fonts.push_back(std::move(value)); + staged_font_indices[i] = it->second; + } + for (size_t i = 0; i < batch->fill_count; ++i) { + formulon::FillRecord value = fill_from_c(batch->fills[i]); + const std::string key = fill_key(value); + const auto [it, inserted] = fills.emplace(key, static_cast(staged.fills.size())); + if (inserted) + staged.fills.push_back(value); + staged_fill_indices[i] = it->second; + } + for (size_t i = 0; i < batch->border_count; ++i) { + formulon::BorderRecord value = border_from_c(batch->borders[i]); + const std::string key = border_key(value); + const auto [it, inserted] = borders.emplace(key, static_cast(staged.borders.size())); + if (inserted) + staged.borders.push_back(value); + staged_border_indices[i] = it->second; + } + std::unordered_map num_fmts; + num_fmts.reserve(staged.num_fmts.size() + batch->num_fmt_count); + std::uint32_t next_num_fmt_id = 163U; + for (const formulon::NumFmtRecord& record : staged.num_fmts) { + const std::optional resolved = effective_num_fmt(staged, record.id); + if (record.format_string_index < staged.num_fmt_strings.size() && resolved.has_value() && + *resolved == staged.num_fmt_strings[record.format_string_index]) { + num_fmts.emplace(staged.num_fmt_strings[record.format_string_index], record.id); + } + if (record.id > next_num_fmt_id) { + next_num_fmt_id = record.id; + } + } + for (size_t i = 0; i < batch->num_fmt_count; ++i) { + const std::string code = batch->num_fmt_codes[i] != nullptr ? batch->num_fmt_codes[i] : ""; + uint16_t builtin_id = 0U; + bool is_builtin = false; + for (uint16_t id = 0U; id < 164U; ++id) { + const std::optional resolved = effective_num_fmt(staged, id); + if (resolved.has_value() && code == *resolved) { + builtin_id = id; + is_builtin = true; + break; + } + } + if (is_builtin) { + staged_num_fmt_ids[i] = builtin_id; + continue; + } + const auto existing = num_fmts.find(code); + if (existing != num_fmts.end()) { + staged_num_fmt_ids[i] = existing->second; + continue; + } + if (next_num_fmt_id >= std::numeric_limits::max()) { + return set_binding_error(formulon::FormulonErrorCode::kPreconditionFailed, + "fm_styles_add_batch: custom numFmtId space exhausted", + "max_num_fmt_id=" + std::to_string(std::numeric_limits::max())); + } + const std::uint32_t candidate_num_fmt_id = next_num_fmt_id + 1U; + num_fmts.emplace(code, static_cast(candidate_num_fmt_id)); + ++next_num_fmt_id; + staged.num_fmt_strings.push_back(code); + formulon::NumFmtRecord record; + record.id = static_cast(next_num_fmt_id); + record.format_string_index = static_cast(staged.num_fmt_strings.size() - 1U); + staged.num_fmts.push_back(record); + staged_num_fmt_ids[i] = record.id; + } + ensure_default_cell_xf(staged); + std::unordered_map xfs; + xfs.reserve(staged.cell_xfs.size() + batch->cell_xf_count); + for (uint32_t i = 0; i < staged.cell_xfs.size(); ++i) + xfs.emplace(cell_xf_key(staged.cell_xfs[i]), i); + for (size_t i = 0; i < batch->cell_xf_count; ++i) { + const fm_cell_xf& record = batch->cell_xfs[i]; + if (fm_status_t rc = validate_xf_alignment(record, "fm_styles_add_batch"); rc != 0) { + return rc; + } + if (fm_status_t rc = validate_xf_references(staged, record, "fm_styles_add_batch"); rc != 0) { + return rc; + } + // The batch shares the single-record presence-flag contract; `xfId` is + // staged as given so a batch may point at a named style added earlier, + // and is rejected when it would dangle. + if (record.xf_id != 0 && record.xf_id >= staged.cell_style_xfs.size()) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, "fm_styles_add_batch: xf_id out of range", + "xf_id=" + std::to_string(record.xf_id) + + " cell_style_xfs_count=" + std::to_string(staged.cell_style_xfs.size())); + } + const formulon::CellXf value = cell_xf_from_c(record, record.xf_id); + const std::string key = cell_xf_key(value); + const auto [it, inserted] = xfs.emplace(key, static_cast(staged.cell_xfs.size())); + if (inserted) + staged.cell_xfs.push_back(value); + staged_cell_xf_indices[i] = it->second; + } + + // Commit the complete staged table and publish output indices only after + // all records have succeeded. A failed batch therefore leaves both the + // workbook and every caller-provided sentinel byte-identical. + styles = std::move(staged); + for (size_t i = 0; i < batch->font_count; ++i) + batch->font_indices[i] = staged_font_indices[i]; + for (size_t i = 0; i < batch->fill_count; ++i) + batch->fill_indices[i] = staged_fill_indices[i]; + for (size_t i = 0; i < batch->border_count; ++i) + batch->border_indices[i] = staged_border_indices[i]; + for (size_t i = 0; i < batch->cell_xf_count; ++i) + batch->cell_xf_indices[i] = staged_cell_xf_indices[i]; + for (size_t i = 0; i < batch->num_fmt_count; ++i) + batch->num_fmt_ids[i] = staged_num_fmt_ids[i]; + return 0; +} + +extern "C" fm_status_t fm_styles_add_dxf(fm_workbook_t* wb, fm_dxf_record record, uint32_t* out_dxf_index) { + clear_last_error(); + if (wb == nullptr || out_dxf_index == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_add_dxf: NULL argument"); + } + + formulon::DifferentialFormat candidate; + candidate.has_font = record.font_engaged != 0; + if (candidate.has_font) { + candidate.font = font_from_c(record.font); + } + candidate.has_fill = record.fill_engaged != 0; + if (candidate.has_fill) { + candidate.fill = fill_from_c(record.fill); + } + candidate.has_border = record.border_engaged != 0; + if (candidate.has_border) { + candidate.border = border_from_c(record.border); + } + candidate.has_num_fmt = record.num_fmt_engaged != 0; + if (candidate.has_num_fmt) { + candidate.num_fmt_id = record.num_fmt_id; + candidate.num_fmt_code = (record.num_fmt_code != nullptr) ? std::string(record.num_fmt_code) : std::string(); + } + candidate.alignment_xml = (record.alignment_xml != nullptr) ? std::string(record.alignment_xml) : std::string(); + candidate.protection_xml = (record.protection_xml != nullptr) ? std::string(record.protection_xml) : std::string(); + + formulon::StylesTable& styles = wb->workbook().mutable_styles(); + for (std::size_t i = 0; i < styles.dxfs.size(); ++i) { + if (dxf_records_equal(styles.dxfs[i], candidate)) { + *out_dxf_index = static_cast(i); + return 0; + } + } + styles.dxfs.push_back(std::move(candidate)); + *out_dxf_index = static_cast(styles.dxfs.size() - 1); + return 0; +} diff --git a/src/styles.cpp b/src/styles.cpp index 8ea4e84a0..636995f36 100644 --- a/src/styles.cpp +++ b/src/styles.cpp @@ -8,6 +8,8 @@ #include #include +#include +#include namespace formulon { namespace { @@ -193,4 +195,19 @@ const char* builtin_num_fmt(std::uint16_t id) { return kBuiltinNumFmts[id]; } +std::optional effective_num_fmt(const formulon::StylesTable& styles, std::uint16_t id) { + for (const formulon::NumFmtRecord& record : styles.num_fmts) { + if (record.id == id && record.format_string_index < styles.num_fmt_strings.size()) { + return styles.num_fmt_strings[record.format_string_index]; + } + } + if (id < 164U) { + const char* builtin = formulon::builtin_num_fmt(id); + if (builtin != nullptr && builtin[0] != '\0') { + return std::string_view(builtin); + } + } + return std::nullopt; +} + } // namespace formulon diff --git a/src/styles.h b/src/styles.h index ccf8b629b..3001be901 100644 --- a/src/styles.h +++ b/src/styles.h @@ -13,7 +13,9 @@ #define FORMULON_STYLES_H_ #include +#include #include +#include #include namespace formulon { @@ -323,6 +325,12 @@ struct StylesTable { /// and writer share a single definition. const char* builtin_num_fmt(std::uint16_t id); +/// Resolves a number-format id using the effective OOXML mapping. A valid +/// custom record wins over the built-in slot, and duplicate ids resolve to +/// the first valid custom record in document order. `std::nullopt` means that +/// neither a valid custom record nor a non-empty built-in format exists. +std::optional effective_num_fmt(const formulon::StylesTable& styles, std::uint16_t id); + } // namespace formulon #endif // FORMULON_STYLES_H_ From 15f92c3ebdda979e610fcb4e02d56b91fc0473ac Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:28:07 +0900 Subject: [PATCH 14/93] refactor(c_api): move print area and print titles into print_area_titles.cpp - move the _xlnm.Print_Area / _xlnm.Print_Titles setters and getters out of print_settings.cpp - add print_area_titles.cpp with its own A1 token helpers and register it in CMakeLists.txt - divide the 17 KB size budget into 12 KB (print_settings) and 5 KB (print_area_titles) - behavior-preserving move; no logic changes --- CMakeLists.txt | 1 + src/c_api/parts/print_area_titles.cpp | 337 ++++++++++++++++++++++++++ src/c_api/parts/print_settings.cpp | 307 +---------------------- 3 files changed, 339 insertions(+), 306 deletions(-) create mode 100644 src/c_api/parts/print_area_titles.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e7710fcc8..98f191acd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -380,6 +380,7 @@ src/eval/aggregate_lazy.cpp src/c_api/parts/pivot_layout.cpp src/c_api/parts/pivot_table.cpp src/c_api/parts/pagination.cpp + src/c_api/parts/print_area_titles.cpp src/c_api/parts/print_settings.cpp src/c_api/parts/xml_fragment.cpp src/c_api/parts/sheet_layout.cpp diff --git a/src/c_api/parts/print_area_titles.cpp b/src/c_api/parts/print_area_titles.cpp new file mode 100644 index 000000000..f4aab1dca --- /dev/null +++ b/src/c_api/parts/print_area_titles.cpp @@ -0,0 +1,337 @@ +// +// C ABI - print area and print titles. +// +// Both settings live in workbook-scope defined names (`_xlnm.Print_Area` / +// `_xlnm.Print_Titles`) rather than in the worksheet part, so they are read +// and written through the defined-name table. The helpers here render and +// parse the sheet-qualified A1 text those names carry. +// +// @size-budget: 5 KB + +#include +#include +#include +#include +#include +#include + +#include "c_api/formulon_c.h" +#include "c_api/parts/common.h" +#include "parser/reference.h" +#include "print/print_area.h" +#include "sheet.h" +#include "utils/a1_column.h" +#include "utils/a1_ref.h" +#include "utils/error.h" +#include "workbook.h" + +using formulon::c_api::parts::check_sheet_index; +using formulon::c_api::parts::clear_last_error; +using formulon::c_api::parts::set_binding_error; +using formulon::c_api::parts::set_last_error; + +namespace { + +using formulon::Sheet; + +// OOXML built-in defined names for the two print settings Excel stores +// outside the worksheet part. +constexpr std::string_view kPrintAreaName = "_xlnm.Print_Area"; +constexpr std::string_view kPrintTitlesName = "_xlnm.Print_Titles"; + +/// Returns the sheet-qualified prefix (`Sheet1!` / `'集計 2026'!`) for a +/// defined-name formula, quoting per Excel's rules. +std::string sheet_qualifier(std::string_view sheet_name) { + std::string out; + if (!formulon::parser::sheet_name_needs_quoting(sheet_name)) { + out.append(sheet_name); + out.push_back('!'); + return out; + } + out.push_back('\''); + for (const char c : sheet_name) { + if (c == '\'') { + out.push_back('\''); + } + out.push_back(c); + } + out.append("'!"); + return out; +} + +/// Rewrites one A1 token with every column and row anchored. +/// +/// Works on the authored text rather than a parsed rectangle so a +/// whole-axis area keeps its shape: `A:D` becomes `$A:$D`, not the +/// `$A$1:$D$1048576` a round trip through `CellRange` would produce. +/// Existing anchors are not doubled. +std::string absolutise_a1_token(std::string_view token) { + // A run is a maximal stretch of one character class. `A1` is two runs + // (the column and the row), each needing its own anchor; `AB` and `12` + // are one run each. + enum class Run { kNone, kLetters, kDigits }; + + std::string out; + out.reserve(token.size() + 4U); + Run run = Run::kNone; + bool anchor_pending = false; + for (const char chr : token) { + if (chr == '$') { + anchor_pending = true; + run = Run::kNone; + out.push_back(chr); + continue; + } + Run kind = Run::kNone; + if ((chr >= 'A' && chr <= 'Z') || (chr >= 'a' && chr <= 'z')) { + kind = Run::kLetters; + } else if (chr >= '0' && chr <= '9') { + kind = Run::kDigits; + } + if (kind == Run::kNone) { + run = Run::kNone; + anchor_pending = false; + out.push_back(chr); + continue; + } + if (kind != run) { + if (!anchor_pending) { + out.push_back('$'); + } + run = kind; + } + anchor_pending = false; + out.push_back(chr); + } + return out; +} + +/// Splits `text` on commas, trimming ASCII whitespace around each token. +/// The caller-facing range syntax is unqualified, so no sheet name (and +/// therefore no quoted comma) can appear. +std::vector split_areas(std::string_view text) { + std::vector out; + std::size_t start = 0; + while (start <= text.size()) { + std::size_t comma = text.find(',', start); + if (comma == std::string_view::npos) { + comma = text.size(); + } + std::string_view token = text.substr(start, comma - start); + while (!token.empty() && (token.front() == ' ' || token.front() == '\t')) { + token.remove_prefix(1); + } + while (!token.empty() && (token.back() == ' ' || token.back() == '\t')) { + token.remove_suffix(1); + } + out.push_back(token); + start = comma + 1; + } + return out; +} + +/// Renders a resolved rectangle as an unqualified, anchor-free A1 range. +std::string format_range(const formulon::print::CellRange& range) { + std::string out; + formulon::a1::append_column_letters(out, range.first_col); + out.append(std::to_string(static_cast(range.first_row) + 1U)); + out.push_back(':'); + formulon::a1::append_column_letters(out, range.last_col); + out.append(std::to_string(static_cast(range.last_row) + 1U)); + return out; +} + +/// True when `token` is a whole-row span (`1:2`), writing the 1-based +/// endpoints back out. +bool parse_row_span(std::string_view token, std::uint32_t* first, std::uint32_t* last) { + const std::size_t colon = token.find(':'); + if (colon == std::string_view::npos) { + return false; + } + const auto to_row = [](std::string_view part, std::uint32_t* out) { + std::size_t pos = 0; + if (!part.empty() && part.front() == '$') { + part.remove_prefix(1); + } + return formulon::a1::parse_uint(part, &pos, out) && pos == part.size() && *out >= 1U && *out <= Sheet::kMaxRows; + }; + return to_row(token.substr(0, colon), first) && to_row(token.substr(colon + 1), last); +} + +/// True when `token` is a whole-column span (`A:A`), writing the 1-based +/// endpoints back out. +bool parse_col_span(std::string_view token, std::uint32_t* first, std::uint32_t* last) { + const std::size_t colon = token.find(':'); + if (colon == std::string_view::npos) { + return false; + } + const auto to_col = [](std::string_view part, std::uint32_t* out) { + std::size_t pos = 0; + if (!part.empty() && part.front() == '$') { + part.remove_prefix(1); + } + return formulon::a1::parse_column_letters(part, &pos, out) && pos == part.size() && *out >= 1U && + *out <= Sheet::kMaxCols; + }; + return to_col(token.substr(0, colon), first) && to_col(token.substr(colon + 1), last); +} + +} // namespace + +/* -------------------------------------------------------------------------- */ +/* Print area and titles */ +/* -------------------------------------------------------------------------- */ + +extern "C" fm_status_t fm_sheet_set_print_area(fm_workbook_t* wb, size_t sheet_index, const char* ranges_a1) { + static constexpr const char* kFn = "fm_sheet_set_print_area"; + clear_last_error(); + if (ranges_a1 == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, kFn, "arg=ranges_a1"); + } + if (auto rc = check_sheet_index(wb, sheet_index, kFn); rc != 0) { + return rc; + } + const std::string_view input(ranges_a1); + std::string formula; + if (!input.empty()) { + const std::string qualifier = sheet_qualifier(wb->workbook().sheet(sheet_index).name()); + const std::vector tokens = split_areas(input); + if (!formulon::c_api::parts::check_range_count(static_cast(tokens.size()), kFn)) { + return static_cast(formulon::FormulonErrorCode::kInvalidArgument); + } + for (const std::string_view token : tokens) { + // Round-trip the token through the resolver's own grammar so a + // malformed area is rejected here rather than surfacing later as a + // `kPrintInvalidArea` from `resolve_print_area`. + formulon::print::CellRange probe; + if (token.empty() || !formulon::print::parse_area_token(token, &probe)) { + return set_binding_error(formulon::FormulonErrorCode::kPrintInvalidArea, kFn, "token=" + std::string(token)); + } + if (!formula.empty()) { + formula.push_back(','); + } + formula.append(qualifier); + formula.append(absolutise_a1_token(token)); + } + } + auto result = wb->workbook().set_defined_name_scoped(std::string(kPrintAreaName), std::move(formula), + static_cast(sheet_index)); + if (!result) { + return set_last_error(result.error()); + } + return 0; +} + +extern "C" fm_status_t fm_sheet_get_print_area(const fm_workbook_t* wb, size_t sheet_index, + const char** out_ranges_a1) { + static constexpr const char* kFn = "fm_sheet_get_print_area"; + clear_last_error(); + if (out_ranges_a1 == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, kFn, "arg=out_ranges_a1"); + } + if (auto rc = check_sheet_index(wb, sheet_index, kFn); rc != 0) { + return rc; + } + auto areas = formulon::print::resolve_print_area(wb->workbook(), static_cast(sheet_index)); + if (!areas) { + return set_last_error(areas.error()); + } + std::string rendered; + for (const formulon::print::CellRange& range : areas.value()) { + if (!rendered.empty()) { + rendered.push_back(','); + } + rendered.append(format_range(range)); + } + fm_workbook_t* mutable_wb = const_cast(wb); + mutable_wb->read_scratch.clear(); + mutable_wb->read_scratch.emplace_back(std::move(rendered)); + *out_ranges_a1 = mutable_wb->read_scratch.back().c_str(); + return 0; +} + +extern "C" fm_status_t fm_sheet_set_print_titles(fm_workbook_t* wb, size_t sheet_index, const char* repeat_rows, + const char* repeat_cols) { + static constexpr const char* kFn = "fm_sheet_set_print_titles"; + clear_last_error(); + if (auto rc = check_sheet_index(wb, sheet_index, kFn); rc != 0) { + return rc; + } + const std::string_view rows(repeat_rows == nullptr ? "" : repeat_rows); + const std::string_view cols(repeat_cols == nullptr ? "" : repeat_cols); + std::string formula; + if (!rows.empty() || !cols.empty()) { + const std::string qualifier = sheet_qualifier(wb->workbook().sheet(sheet_index).name()); + // Excel lists rows before columns; the resolver accepts either order, + // but matching Excel keeps a diffed worksheet quiet. + if (!rows.empty()) { + std::uint32_t first = 0; + std::uint32_t last = 0; + if (!parse_row_span(rows, &first, &last)) { + return set_binding_error(formulon::FormulonErrorCode::kPrintInvalidArea, kFn, + "repeat_rows=" + std::string(rows)); + } + formula.append(qualifier); + formula.append(absolutise_a1_token(rows)); + } + if (!cols.empty()) { + std::uint32_t first = 0; + std::uint32_t last = 0; + if (!parse_col_span(cols, &first, &last)) { + return set_binding_error(formulon::FormulonErrorCode::kPrintInvalidArea, kFn, + "repeat_cols=" + std::string(cols)); + } + if (!formula.empty()) { + formula.push_back(','); + } + formula.append(qualifier); + formula.append(absolutise_a1_token(cols)); + } + } + auto result = wb->workbook().set_defined_name_scoped(std::string(kPrintTitlesName), std::move(formula), + static_cast(sheet_index)); + if (!result) { + return set_last_error(result.error()); + } + return 0; +} + +extern "C" fm_status_t fm_sheet_get_print_titles(const fm_workbook_t* wb, size_t sheet_index, + const char** out_repeat_rows, const char** out_repeat_cols) { + static constexpr const char* kFn = "fm_sheet_get_print_titles"; + clear_last_error(); + if (out_repeat_rows == nullptr || out_repeat_cols == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, kFn, + "arg=out_repeat_rows|out_repeat_cols"); + } + if (auto rc = check_sheet_index(wb, sheet_index, kFn); rc != 0) { + return rc; + } + auto titles = formulon::print::resolve_print_titles(wb->workbook(), static_cast(sheet_index)); + if (!titles) { + return set_last_error(titles.error()); + } + std::string rows; + if (titles.value().repeat_rows.has_value()) { + const auto& span = *titles.value().repeat_rows; + rows.append(std::to_string(static_cast(span.first) + 1U)); + rows.push_back(':'); + rows.append(std::to_string(static_cast(span.second) + 1U)); + } + std::string cols; + if (titles.value().repeat_cols.has_value()) { + const auto& span = *titles.value().repeat_cols; + formulon::a1::append_column_letters(cols, span.first); + cols.push_back(':'); + formulon::a1::append_column_letters(cols, span.second); + } + // Both pointers are handed out from one refresh: clearing between the two + // pushes would dangle the first. + fm_workbook_t* mutable_wb = const_cast(wb); + mutable_wb->read_scratch.clear(); + mutable_wb->read_scratch.emplace_back(std::move(rows)); + *out_repeat_rows = mutable_wb->read_scratch.back().c_str(); + mutable_wb->read_scratch.emplace_back(std::move(cols)); + *out_repeat_cols = mutable_wb->read_scratch.back().c_str(); + return 0; +} diff --git a/src/c_api/parts/print_settings.cpp b/src/c_api/parts/print_settings.cpp index 12f86ca93..1e451e464 100644 --- a/src/c_api/parts/print_settings.cpp +++ b/src/c_api/parts/print_settings.cpp @@ -23,7 +23,7 @@ // (`io/ooxml/print_settings_parse.h`). That is what makes "set it, then // paginate" observe the new setting instead of the loaded one. // -// @size-budget: 17 KB +// @size-budget: 12 KB #include #include @@ -38,12 +38,8 @@ #include "c_api/parts/xml_fragment.h" #include "io/ooxml/print_settings_parse.h" #include "io/xml_utils.h" -#include "parser/reference.h" -#include "print/print_area.h" #include "pugixml.hpp" #include "sheet.h" -#include "utils/a1_column.h" -#include "utils/a1_ref.h" #include "utils/double_format.h" #include "utils/error.h" #include "utils/resource_budget.h" @@ -54,7 +50,6 @@ using formulon::c_api::parts::clear_last_error; using formulon::c_api::parts::fragment_within_size_limit; using formulon::c_api::parts::FragmentValidation; using formulon::c_api::parts::set_binding_error; -using formulon::c_api::parts::set_last_error; using formulon::c_api::parts::validate_single_element_fragment; namespace { @@ -68,11 +63,6 @@ using formulon::SheetPrintSettings; using formulon::io::raw_xml; using formulon::io::ooxml::refresh_structured_views; -// OOXML built-in defined names for the two print settings Excel stores -// outside the worksheet part. -constexpr std::string_view kPrintAreaName = "_xlnm.Print_Area"; -constexpr std::string_view kPrintTitlesName = "_xlnm.Print_Titles"; - // `` is a percentage Excel accepts only inside this band; // the UI spinner enforces the same range. constexpr std::uint32_t kMinPrintScalePercent = 10U; @@ -229,143 +219,6 @@ void set_header_footer_section(pugi::xml_node root, std::size_t section_index, s node.text().set(std::string(text).c_str()); } -/// Returns the sheet-qualified prefix (`Sheet1!` / `'集計 2026'!`) for a -/// defined-name formula, quoting per Excel's rules. -std::string sheet_qualifier(std::string_view sheet_name) { - std::string out; - if (!formulon::parser::sheet_name_needs_quoting(sheet_name)) { - out.append(sheet_name); - out.push_back('!'); - return out; - } - out.push_back('\''); - for (const char c : sheet_name) { - if (c == '\'') { - out.push_back('\''); - } - out.push_back(c); - } - out.append("'!"); - return out; -} - -/// Rewrites one A1 token with every column and row anchored. -/// -/// Works on the authored text rather than a parsed rectangle so a -/// whole-axis area keeps its shape: `A:D` becomes `$A:$D`, not the -/// `$A$1:$D$1048576` a round trip through `CellRange` would produce. -/// Existing anchors are not doubled. -std::string absolutise_a1_token(std::string_view token) { - // A run is a maximal stretch of one character class. `A1` is two runs - // (the column and the row), each needing its own anchor; `AB` and `12` - // are one run each. - enum class Run { kNone, kLetters, kDigits }; - - std::string out; - out.reserve(token.size() + 4U); - Run run = Run::kNone; - bool anchor_pending = false; - for (const char chr : token) { - if (chr == '$') { - anchor_pending = true; - run = Run::kNone; - out.push_back(chr); - continue; - } - Run kind = Run::kNone; - if ((chr >= 'A' && chr <= 'Z') || (chr >= 'a' && chr <= 'z')) { - kind = Run::kLetters; - } else if (chr >= '0' && chr <= '9') { - kind = Run::kDigits; - } - if (kind == Run::kNone) { - run = Run::kNone; - anchor_pending = false; - out.push_back(chr); - continue; - } - if (kind != run) { - if (!anchor_pending) { - out.push_back('$'); - } - run = kind; - } - anchor_pending = false; - out.push_back(chr); - } - return out; -} - -/// Splits `text` on commas, trimming ASCII whitespace around each token. -/// The caller-facing range syntax is unqualified, so no sheet name (and -/// therefore no quoted comma) can appear. -std::vector split_areas(std::string_view text) { - std::vector out; - std::size_t start = 0; - while (start <= text.size()) { - std::size_t comma = text.find(',', start); - if (comma == std::string_view::npos) { - comma = text.size(); - } - std::string_view token = text.substr(start, comma - start); - while (!token.empty() && (token.front() == ' ' || token.front() == '\t')) { - token.remove_prefix(1); - } - while (!token.empty() && (token.back() == ' ' || token.back() == '\t')) { - token.remove_suffix(1); - } - out.push_back(token); - start = comma + 1; - } - return out; -} - -/// Renders a resolved rectangle as an unqualified, anchor-free A1 range. -std::string format_range(const formulon::print::CellRange& range) { - std::string out; - formulon::a1::append_column_letters(out, range.first_col); - out.append(std::to_string(static_cast(range.first_row) + 1U)); - out.push_back(':'); - formulon::a1::append_column_letters(out, range.last_col); - out.append(std::to_string(static_cast(range.last_row) + 1U)); - return out; -} - -/// True when `token` is a whole-row span (`1:2`), writing the 1-based -/// endpoints back out. -bool parse_row_span(std::string_view token, std::uint32_t* first, std::uint32_t* last) { - const std::size_t colon = token.find(':'); - if (colon == std::string_view::npos) { - return false; - } - const auto to_row = [](std::string_view part, std::uint32_t* out) { - std::size_t pos = 0; - if (!part.empty() && part.front() == '$') { - part.remove_prefix(1); - } - return formulon::a1::parse_uint(part, &pos, out) && pos == part.size() && *out >= 1U && *out <= Sheet::kMaxRows; - }; - return to_row(token.substr(0, colon), first) && to_row(token.substr(colon + 1), last); -} - -/// True when `token` is a whole-column span (`A:A`), writing the 1-based -/// endpoints back out. -bool parse_col_span(std::string_view token, std::uint32_t* first, std::uint32_t* last) { - const std::size_t colon = token.find(':'); - if (colon == std::string_view::npos) { - return false; - } - const auto to_col = [](std::string_view part, std::uint32_t* out) { - std::size_t pos = 0; - if (!part.empty() && part.front() == '$') { - part.remove_prefix(1); - } - return formulon::a1::parse_column_letters(part, &pos, out) && pos == part.size() && *out >= 1U && - *out <= Sheet::kMaxCols; - }; - return to_col(token.substr(0, colon), first) && to_col(token.substr(colon + 1), last); -} - /// Upserts a manual break into an axis vector, keeping it sorted by index. fm_status_t upsert_break(std::vector& breaks, std::uint32_t index, std::uint32_t span_max, bool manual, const char* fn) { @@ -535,164 +388,6 @@ extern "C" fm_status_t fm_sheet_set_fit_to_page(fm_workbook_t* wb, size_t sheet_ return 0; } -/* -------------------------------------------------------------------------- */ -/* Print area and titles */ -/* -------------------------------------------------------------------------- */ - -extern "C" fm_status_t fm_sheet_set_print_area(fm_workbook_t* wb, size_t sheet_index, const char* ranges_a1) { - static constexpr const char* kFn = "fm_sheet_set_print_area"; - clear_last_error(); - if (ranges_a1 == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, kFn, "arg=ranges_a1"); - } - if (auto rc = check_sheet_index(wb, sheet_index, kFn); rc != 0) { - return rc; - } - const std::string_view input(ranges_a1); - std::string formula; - if (!input.empty()) { - const std::string qualifier = sheet_qualifier(wb->workbook().sheet(sheet_index).name()); - const std::vector tokens = split_areas(input); - if (!formulon::c_api::parts::check_range_count(static_cast(tokens.size()), kFn)) { - return static_cast(formulon::FormulonErrorCode::kInvalidArgument); - } - for (const std::string_view token : tokens) { - // Round-trip the token through the resolver's own grammar so a - // malformed area is rejected here rather than surfacing later as a - // `kPrintInvalidArea` from `resolve_print_area`. - formulon::print::CellRange probe; - if (token.empty() || !formulon::print::parse_area_token(token, &probe)) { - return set_binding_error(formulon::FormulonErrorCode::kPrintInvalidArea, kFn, "token=" + std::string(token)); - } - if (!formula.empty()) { - formula.push_back(','); - } - formula.append(qualifier); - formula.append(absolutise_a1_token(token)); - } - } - auto result = wb->workbook().set_defined_name_scoped(std::string(kPrintAreaName), std::move(formula), - static_cast(sheet_index)); - if (!result) { - return set_last_error(result.error()); - } - return 0; -} - -extern "C" fm_status_t fm_sheet_get_print_area(const fm_workbook_t* wb, size_t sheet_index, - const char** out_ranges_a1) { - static constexpr const char* kFn = "fm_sheet_get_print_area"; - clear_last_error(); - if (out_ranges_a1 == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, kFn, "arg=out_ranges_a1"); - } - if (auto rc = check_sheet_index(wb, sheet_index, kFn); rc != 0) { - return rc; - } - auto areas = formulon::print::resolve_print_area(wb->workbook(), static_cast(sheet_index)); - if (!areas) { - return set_last_error(areas.error()); - } - std::string rendered; - for (const formulon::print::CellRange& range : areas.value()) { - if (!rendered.empty()) { - rendered.push_back(','); - } - rendered.append(format_range(range)); - } - fm_workbook_t* mutable_wb = const_cast(wb); - mutable_wb->read_scratch.clear(); - mutable_wb->read_scratch.emplace_back(std::move(rendered)); - *out_ranges_a1 = mutable_wb->read_scratch.back().c_str(); - return 0; -} - -extern "C" fm_status_t fm_sheet_set_print_titles(fm_workbook_t* wb, size_t sheet_index, const char* repeat_rows, - const char* repeat_cols) { - static constexpr const char* kFn = "fm_sheet_set_print_titles"; - clear_last_error(); - if (auto rc = check_sheet_index(wb, sheet_index, kFn); rc != 0) { - return rc; - } - const std::string_view rows(repeat_rows == nullptr ? "" : repeat_rows); - const std::string_view cols(repeat_cols == nullptr ? "" : repeat_cols); - std::string formula; - if (!rows.empty() || !cols.empty()) { - const std::string qualifier = sheet_qualifier(wb->workbook().sheet(sheet_index).name()); - // Excel lists rows before columns; the resolver accepts either order, - // but matching Excel keeps a diffed worksheet quiet. - if (!rows.empty()) { - std::uint32_t first = 0; - std::uint32_t last = 0; - if (!parse_row_span(rows, &first, &last)) { - return set_binding_error(formulon::FormulonErrorCode::kPrintInvalidArea, kFn, - "repeat_rows=" + std::string(rows)); - } - formula.append(qualifier); - formula.append(absolutise_a1_token(rows)); - } - if (!cols.empty()) { - std::uint32_t first = 0; - std::uint32_t last = 0; - if (!parse_col_span(cols, &first, &last)) { - return set_binding_error(formulon::FormulonErrorCode::kPrintInvalidArea, kFn, - "repeat_cols=" + std::string(cols)); - } - if (!formula.empty()) { - formula.push_back(','); - } - formula.append(qualifier); - formula.append(absolutise_a1_token(cols)); - } - } - auto result = wb->workbook().set_defined_name_scoped(std::string(kPrintTitlesName), std::move(formula), - static_cast(sheet_index)); - if (!result) { - return set_last_error(result.error()); - } - return 0; -} - -extern "C" fm_status_t fm_sheet_get_print_titles(const fm_workbook_t* wb, size_t sheet_index, - const char** out_repeat_rows, const char** out_repeat_cols) { - static constexpr const char* kFn = "fm_sheet_get_print_titles"; - clear_last_error(); - if (out_repeat_rows == nullptr || out_repeat_cols == nullptr) { - return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, kFn, - "arg=out_repeat_rows|out_repeat_cols"); - } - if (auto rc = check_sheet_index(wb, sheet_index, kFn); rc != 0) { - return rc; - } - auto titles = formulon::print::resolve_print_titles(wb->workbook(), static_cast(sheet_index)); - if (!titles) { - return set_last_error(titles.error()); - } - std::string rows; - if (titles.value().repeat_rows.has_value()) { - const auto& span = *titles.value().repeat_rows; - rows.append(std::to_string(static_cast(span.first) + 1U)); - rows.push_back(':'); - rows.append(std::to_string(static_cast(span.second) + 1U)); - } - std::string cols; - if (titles.value().repeat_cols.has_value()) { - const auto& span = *titles.value().repeat_cols; - formulon::a1::append_column_letters(cols, span.first); - cols.push_back(':'); - formulon::a1::append_column_letters(cols, span.second); - } - // Both pointers are handed out from one refresh: clearing between the two - // pushes would dangle the first. - fm_workbook_t* mutable_wb = const_cast(wb); - mutable_wb->read_scratch.clear(); - mutable_wb->read_scratch.emplace_back(std::move(rows)); - *out_repeat_rows = mutable_wb->read_scratch.back().c_str(); - mutable_wb->read_scratch.emplace_back(std::move(cols)); - *out_repeat_cols = mutable_wb->read_scratch.back().c_str(); - return 0; -} - /* -------------------------------------------------------------------------- */ /* Manual page breaks */ /* -------------------------------------------------------------------------- */ From 12e2eee516cef063d3e8e44e54d28a7d4bfe7ccf Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:38:54 +0900 Subject: [PATCH 15/93] refactor(node): split cell access and sheet UI parts out of the addon sources - cells.cc takes cell mutation and read (including phonetic), ad-hoc eval and lambda text from lifecycle.cc, which keeps calc policy, recalc, dispose, save and diagnostics - sheet_ui.cc takes merges, comments, hyperlinks and validations from sheet_view.cc, which keeps view, protection and column/row layout - register the new parts in addon.cc and cmake/FormulonNodeAddon.cmake - mirrors the WASM parts layout (workbook_cells / workbook_ui); behavior-preserving move --- cmake/FormulonNodeAddon.cmake | 2 + src/node_addon/addon.cc | 12 +- src/node_addon/parts/cells.cc | 382 ++++++++++++++++++++++ src/node_addon/parts/lifecycle.cc | 373 +--------------------- src/node_addon/parts/sheet_ui.cc | 493 +++++++++++++++++++++++++++++ src/node_addon/parts/sheet_view.cc | 486 +--------------------------- 6 files changed, 889 insertions(+), 859 deletions(-) create mode 100644 src/node_addon/parts/cells.cc create mode 100644 src/node_addon/parts/sheet_ui.cc diff --git a/cmake/FormulonNodeAddon.cmake b/cmake/FormulonNodeAddon.cmake index 0d785215c..442f2a6eb 100644 --- a/cmake/FormulonNodeAddon.cmake +++ b/cmake/FormulonNodeAddon.cmake @@ -68,12 +68,14 @@ endif() add_library(formulon_node MODULE src/node_addon/addon.cc src/node_addon/parts/addon_common.cc + src/node_addon/parts/cells.cc src/node_addon/parts/free_funcs.cc src/node_addon/parts/lifecycle.cc src/node_addon/parts/pivot_cache.cc src/node_addon/parts/pivot_table.cc src/node_addon/parts/print.cc src/node_addon/parts/sheet.cc + src/node_addon/parts/sheet_ui.cc src/node_addon/parts/sheet_view.cc src/node_addon/parts/styles.cc src/node_addon/parts/trace_catalog.cc diff --git a/src/node_addon/addon.cc b/src/node_addon/addon.cc index b1f560891..1f819c687 100644 --- a/src/node_addon/addon.cc +++ b/src/node_addon/addon.cc @@ -10,15 +10,19 @@ // * `parts/workbook_class.{h,cc}` -- `Workbook` ObjectWrap definition, // ctor / dtor, static factories, argument helpers, and the // `DefineClass` registration table. -// * `parts/lifecycle.cc` -- cell mutation / read, recalc, -// save, iterative-solver registration, isValid. +// * `parts/cells.cc` -- cell mutation / read, phonetic +// metadata, ad-hoc formula evaluation, lambda text. +// * `parts/lifecycle.cc` -- calc policy, recalc, save, +// iterative-solver registration, diagnostics, isValid. // * `parts/sheet.cc` -- sheet operations, row/col edits, // metadata iteration, defined-name mutation. // * `parts/pivot_cache.cc` -- PivotCache mutation surface. // * `parts/pivot_table.cc` -- PivotTable mutation surface. // * `parts/styles.cc` -- styles + EvaluateCfRange. -// * `parts/sheet_view.cc` -- view / column / row layout + -// merges / comments / hyperlinks / validations. +// * `parts/sheet_view.cc` -- view / protection / column / row +// layout. +// * `parts/sheet_ui.cc` -- merges / comments / hyperlinks / +// validations. // * `parts/free_funcs.{h,cc}` -- evalFormula / version / // lastError* / statusString / the process-wide structured-log // controls. diff --git a/src/node_addon/parts/cells.cc b/src/node_addon/parts/cells.cc new file mode 100644 index 000000000..fc269aa1d --- /dev/null +++ b/src/node_addon/parts/cells.cc @@ -0,0 +1,382 @@ +// Workbook cell bindings: cell mutation and read (including phonetic +// metadata), ad-hoc formula evaluation, and lambda text. + +#include +#include +#include +#include + +#include "node_addon/parts/workbook_class.h" + +namespace formulon_node { + +// ---- Cell mutation -------------------------------------------------- + +Napi::Value Workbook::SetNumber(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const double value = ArgDouble(info, 3); + fm_status_t rc = fm_workbook_set_number(handle_, sheet, row, col, value); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetBool(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + bool value = false; + if (info.Length() > 3) { + value = info[3].ToBoolean().Value(); + } + fm_status_t rc = fm_workbook_set_bool(handle_, sheet, row, col, value ? 1 : 0); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetError(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4) { + // Unlike the other cell-mutation setters, a missing 4th argument + // here has no sane zero-value default: `error_code = 0` silently + // writes `#NULL!`, masking a caller bug instead of surfacing it. + // Reject it the same way the WASM (embind arity check) and Python + // (required positional parameter) bindings already do. + Napi::TypeError::New(env, "setError requires 4 arguments (sheet, row, col, errorCode)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + if (!info[3].IsNumber()) { + // `.As()` below is an unchecked cast: on a non-number + // 4th argument it does not itself throw, so without this check the + // mutation below would run first and any resulting exception would + // only surface afterwards, on return to JS. + Napi::TypeError::New(env, "setError: `errorCode` must be a number").ThrowAsJavaScriptException(); + return env.Undefined(); + } + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const int32_t error_code = info[3].As().Int32Value(); + fm_status_t rc = fm_workbook_set_error(handle_, sheet, row, col, static_cast(error_code)); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetText(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const std::string text = ArgString(info, 3); + fm_status_t rc = fm_workbook_set_text(handle_, sheet, row, col, text.c_str()); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetCellPhonetic(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const std::string phonetic = ArgString(info, 3); + fm_status_t rc = fm_workbook_set_cell_phonetic(handle_, sheet, row, col, phonetic.c_str()); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetCellPhoneticRuns(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + if (info.Length() <= 3 || !info[3].IsArray()) { + return MakeBindingArgumentError(env, "setCellPhoneticRuns: `runs` must be an array of { sb, eb, text }"); + } + const Napi::Array runs = info[3].As(); + const uint32_t count = runs.Length(); + // Two passes so no `c_str()` is taken before `texts` has finished growing. + std::vector texts; + texts.reserve(count); + std::vector records; + records.reserve(count); + for (uint32_t i = 0; i < count; ++i) { + Napi::Value element = runs.Get(i); + if (!element.IsObject()) { + return MakeBindingArgumentError(env, "setCellPhoneticRuns: each run must be an object { sb, eb, text }"); + } + const Napi::Object run = element.As(); + const Napi::Value text = run.Get("text"); + texts.push_back(text.IsString() ? text.As().Utf8Value() : std::string()); + records.push_back(fm_phonetic_run_t{SpecPullU32(run, "sb", 0U), SpecPullU32(run, "eb", 0U), nullptr}); + } + for (uint32_t i = 0; i < count; ++i) { + records[i].text = texts[i].c_str(); + } + if (env.IsExceptionPending()) { + // A malformed `sb`/`eb` left a pending JS exception (see + // SpecPullU32): stop before the C ABI call commits a default value + // for it. + return env.Undefined(); + } + fm_status_t rc = fm_workbook_set_cell_phonetic_runs(handle_, sheet, row, col, records.data(), records.size()); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetCellPhoneticProperties(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + if (info.Length() <= 3 || !info[3].IsObject()) { + return MakeBindingArgumentError( + env, "setCellPhoneticProperties: `properties` must be an object { fontId, type, alignment }"); + } + const Napi::Object props = info[3].As(); + const uint32_t font_id = SpecPullU32(props, "fontId", 0U); + const uint32_t type = SpecPullU32(props, "type", 0U); + const uint32_t alignment = SpecPullU32(props, "alignment", 0U); + if (env.IsExceptionPending()) { + // A malformed field left a pending JS exception (see SpecPullU32): + // stop before the C ABI call commits a default value for it. + return env.Undefined(); + } + fm_status_t rc = fm_workbook_set_cell_phonetic_properties(handle_, sheet, row, col, font_id, type, alignment); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetBlank(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + fm_status_t rc = fm_workbook_set_blank(handle_, sheet, row, col); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetFormula(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const std::string formula = ArgString(info, 3); + fm_status_t rc = fm_workbook_set_formula(handle_, sheet, row, col, formula.c_str()); + return MakeStatus(env, rc); +} + +// ---- Cell read ------------------------------------------------------ + +Napi::Value Workbook::GetValue(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return MakeEmptyValueResult(env, NullHandleError(env)); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + fm_value_t v{}; + fm_status_t rc = fm_workbook_get_value(handle_, sheet, row, col, &v); + return MakeValueResult(env, rc, v); +} + +Napi::Value Workbook::GetCellPhonetic(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return MakeStringFieldResult(env, NullHandleError(env), "value", ""); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const char* text = nullptr; + fm_status_t rc = fm_workbook_get_cell_phonetic(handle_, sheet, row, col, &text); + if (rc != 0) { + return MakeStringFieldResult(env, MakeErrorStatus(env, rc), "value", ""); + } + return MakeStringFieldResult(env, MakeOkStatus(env), "value", text); +} + +Napi::Value Workbook::GetCellPhoneticRuns(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return MakeFieldResult(env, NullHandleError(env), "runs", Napi::Array::New(env, 0)); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + uint32_t count = 0; + fm_status_t rc = fm_workbook_get_cell_phonetic_run_count(handle_, sheet, row, col, &count); + Napi::Array out = Napi::Array::New(env, rc == 0 ? count : 0); + for (uint32_t i = 0; rc == 0 && i < count; ++i) { + fm_phonetic_run_t run{}; + rc = fm_workbook_get_cell_phonetic_run(handle_, sheet, row, col, i, &run); + if (rc != 0) { + break; + } + Napi::Object entry = Napi::Object::New(env); + entry.Set("sb", Napi::Number::New(env, run.sb)); + entry.Set("eb", Napi::Number::New(env, run.eb)); + // Copied immediately: each read refreshes the handle's scratch, so the + // previous run's pointer is dead by the time the next one lands. + entry.Set("text", Napi::String::New(env, run.text != nullptr ? run.text : "")); + out.Set(i, entry); + } + if (rc != 0) { + return MakeFieldResult(env, MakeErrorStatus(env, rc), "runs", Napi::Array::New(env, 0)); + } + return MakeFieldResult(env, MakeOkStatus(env), "runs", out); +} + +Napi::Value Workbook::GetCellPhoneticProperties(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + uint32_t font_id = 0; + uint32_t type = 0; + uint32_t alignment = 0; + const fm_status_t rc = + handle_ != nullptr ? fm_workbook_get_cell_phonetic_properties(handle_, sheet, row, col, &font_id, &type, + &alignment) + : kBindingInvalidHandle; + // The payload keys are declared unconditionally, so a failure reports the + // defaults beside the status rather than dropping them. + if (rc != 0) { + font_id = 0; + type = 0; + alignment = 0; + } + Napi::Object out = Napi::Object::New(env); + out.Set("fontId", Napi::Number::New(env, font_id)); + out.Set("type", Napi::Number::New(env, type)); + out.Set("alignment", Napi::Number::New(env, alignment)); + out.Set("status", MakeStatus(env, rc)); + return out; +} + +Napi::Value Workbook::EvaluateFormulaText(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return MakeEmptyValueResult(env, NullHandleError(env)); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const std::string formula = ArgString(info, 3); + fm_value_t v{}; + fm_status_t rc = fm_workbook_evaluate_formula(handle_, sheet, row, col, formula.c_str(), &v); + return MakeValueResult(env, rc, v); +} + +Napi::Value Workbook::EvaluateFormulaArray(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::Object out = Napi::Object::New(env); + if (handle_ == nullptr) { + out.Set("status", NullHandleError(env)); + out.Set("rows", Napi::Number::New(env, 0)); + out.Set("cols", Napi::Number::New(env, 0)); + out.Set("cells", Napi::Array::New(env, 0)); + return out; + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const std::string formula = ArgString(info, 3); + + uint32_t rows = 0; + uint32_t cols = 0; + fm_status_t rc = fm_workbook_evaluate_formula_array(handle_, sheet, row, col, formula.c_str(), &rows, &cols); + if (rc != 0) { + out.Set("status", MakeErrorStatus(env, rc)); + out.Set("rows", Napi::Number::New(env, 0)); + out.Set("cols", Napi::Number::New(env, 0)); + out.Set("cells", Napi::Array::New(env, 0)); + return out; + } + + // Build a rows x cols nested array of Value objects, reading each stashed + // cell by its row-major index (r * cols + c). + Napi::Array cells = Napi::Array::New(env, rows); + for (uint32_t r = 0; r < rows; ++r) { + Napi::Array js_row = Napi::Array::New(env, cols); + for (uint32_t c = 0; c < cols; ++c) { + const std::size_t index = static_cast(r) * cols + c; + fm_value_t v{}; + fm_status_t cell_rc = fm_workbook_evaluate_formula_array_cell(handle_, index, &v); + if (cell_rc != 0) { + out.Set("status", MakeErrorStatus(env, cell_rc)); + out.Set("rows", Napi::Number::New(env, 0)); + out.Set("cols", Napi::Number::New(env, 0)); + out.Set("cells", Napi::Array::New(env, 0)); + return out; + } + js_row.Set(c, TranslateValue(env, v)); + } + cells.Set(r, js_row); + } + + out.Set("status", MakeOkStatus(env)); + out.Set("rows", Napi::Number::New(env, rows)); + out.Set("cols", Napi::Number::New(env, cols)); + out.Set("cells", cells); + return out; +} + +Napi::Value Workbook::EvaluateConditionalFormula(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return MakeEmptyValueResult(env, NullHandleError(env)); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const uint32_t anchor_row = ArgU32(info, 3); + const uint32_t anchor_col = ArgU32(info, 4); + const std::string formula = ArgString(info, 5); + fm_value_t v{}; + fm_status_t rc = + fm_workbook_evaluate_cf_formula(handle_, sheet, row, col, anchor_row, anchor_col, formula.c_str(), &v); + return MakeValueResult(env, rc, v); +} + +Napi::Value Workbook::GetLambdaText(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return MakeStringFieldResult(env, NullHandleError(env), "text", ""); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const char* text = nullptr; + fm_status_t rc = fm_workbook_lambda_text_at(handle_, sheet, row, col, &text); + if (rc != 0) { + return MakeStringFieldResult(env, MakeErrorStatus(env, rc), "text", ""); + } + return MakeStringFieldResult(env, MakeOkStatus(env), "text", text); +} + +} // namespace formulon_node diff --git a/src/node_addon/parts/lifecycle.cc b/src/node_addon/parts/lifecycle.cc index 369bbbeae..1a6f13b49 100644 --- a/src/node_addon/parts/lifecycle.cc +++ b/src/node_addon/parts/lifecycle.cc @@ -1,5 +1,5 @@ -// Workbook lifecycle bindings: cell mutation / read, recalc & save, -// iterative-solver registration, and the trivial `isValid` predicate. +// Workbook lifecycle bindings: calc policy, recalc & save, iterative-solver +// registration, diagnostics, and the trivial `isValid` predicate. #include #include @@ -70,375 +70,6 @@ bool ReadThreadCount(const Napi::CallbackInfo& info, uint32_t& thread_count) { } // namespace -// ---- Cell mutation -------------------------------------------------- - -Napi::Value Workbook::SetNumber(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const double value = ArgDouble(info, 3); - fm_status_t rc = fm_workbook_set_number(handle_, sheet, row, col, value); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::SetBool(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - bool value = false; - if (info.Length() > 3) { - value = info[3].ToBoolean().Value(); - } - fm_status_t rc = fm_workbook_set_bool(handle_, sheet, row, col, value ? 1 : 0); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::SetError(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (info.Length() < 4) { - // Unlike the other cell-mutation setters, a missing 4th argument - // here has no sane zero-value default: `error_code = 0` silently - // writes `#NULL!`, masking a caller bug instead of surfacing it. - // Reject it the same way the WASM (embind arity check) and Python - // (required positional parameter) bindings already do. - Napi::TypeError::New(env, "setError requires 4 arguments (sheet, row, col, errorCode)") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - if (!info[3].IsNumber()) { - // `.As()` below is an unchecked cast: on a non-number - // 4th argument it does not itself throw, so without this check the - // mutation below would run first and any resulting exception would - // only surface afterwards, on return to JS. - Napi::TypeError::New(env, "setError: `errorCode` must be a number").ThrowAsJavaScriptException(); - return env.Undefined(); - } - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const int32_t error_code = info[3].As().Int32Value(); - fm_status_t rc = fm_workbook_set_error(handle_, sheet, row, col, static_cast(error_code)); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::SetText(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const std::string text = ArgString(info, 3); - fm_status_t rc = fm_workbook_set_text(handle_, sheet, row, col, text.c_str()); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::SetCellPhonetic(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const std::string phonetic = ArgString(info, 3); - fm_status_t rc = fm_workbook_set_cell_phonetic(handle_, sheet, row, col, phonetic.c_str()); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::SetCellPhoneticRuns(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - if (info.Length() <= 3 || !info[3].IsArray()) { - return MakeBindingArgumentError(env, "setCellPhoneticRuns: `runs` must be an array of { sb, eb, text }"); - } - const Napi::Array runs = info[3].As(); - const uint32_t count = runs.Length(); - // Two passes so no `c_str()` is taken before `texts` has finished growing. - std::vector texts; - texts.reserve(count); - std::vector records; - records.reserve(count); - for (uint32_t i = 0; i < count; ++i) { - Napi::Value element = runs.Get(i); - if (!element.IsObject()) { - return MakeBindingArgumentError(env, "setCellPhoneticRuns: each run must be an object { sb, eb, text }"); - } - const Napi::Object run = element.As(); - const Napi::Value text = run.Get("text"); - texts.push_back(text.IsString() ? text.As().Utf8Value() : std::string()); - records.push_back(fm_phonetic_run_t{SpecPullU32(run, "sb", 0U), SpecPullU32(run, "eb", 0U), nullptr}); - } - for (uint32_t i = 0; i < count; ++i) { - records[i].text = texts[i].c_str(); - } - if (env.IsExceptionPending()) { - // A malformed `sb`/`eb` left a pending JS exception (see - // SpecPullU32): stop before the C ABI call commits a default value - // for it. - return env.Undefined(); - } - fm_status_t rc = fm_workbook_set_cell_phonetic_runs(handle_, sheet, row, col, records.data(), records.size()); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::SetCellPhoneticProperties(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - if (info.Length() <= 3 || !info[3].IsObject()) { - return MakeBindingArgumentError( - env, "setCellPhoneticProperties: `properties` must be an object { fontId, type, alignment }"); - } - const Napi::Object props = info[3].As(); - const uint32_t font_id = SpecPullU32(props, "fontId", 0U); - const uint32_t type = SpecPullU32(props, "type", 0U); - const uint32_t alignment = SpecPullU32(props, "alignment", 0U); - if (env.IsExceptionPending()) { - // A malformed field left a pending JS exception (see SpecPullU32): - // stop before the C ABI call commits a default value for it. - return env.Undefined(); - } - fm_status_t rc = fm_workbook_set_cell_phonetic_properties(handle_, sheet, row, col, font_id, type, alignment); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::SetBlank(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - fm_status_t rc = fm_workbook_set_blank(handle_, sheet, row, col); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::SetFormula(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const std::string formula = ArgString(info, 3); - fm_status_t rc = fm_workbook_set_formula(handle_, sheet, row, col, formula.c_str()); - return MakeStatus(env, rc); -} - -// ---- Cell read ------------------------------------------------------ - -Napi::Value Workbook::GetValue(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return MakeEmptyValueResult(env, NullHandleError(env)); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - fm_value_t v{}; - fm_status_t rc = fm_workbook_get_value(handle_, sheet, row, col, &v); - return MakeValueResult(env, rc, v); -} - -Napi::Value Workbook::GetCellPhonetic(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return MakeStringFieldResult(env, NullHandleError(env), "value", ""); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const char* text = nullptr; - fm_status_t rc = fm_workbook_get_cell_phonetic(handle_, sheet, row, col, &text); - if (rc != 0) { - return MakeStringFieldResult(env, MakeErrorStatus(env, rc), "value", ""); - } - return MakeStringFieldResult(env, MakeOkStatus(env), "value", text); -} - -Napi::Value Workbook::GetCellPhoneticRuns(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return MakeFieldResult(env, NullHandleError(env), "runs", Napi::Array::New(env, 0)); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - uint32_t count = 0; - fm_status_t rc = fm_workbook_get_cell_phonetic_run_count(handle_, sheet, row, col, &count); - Napi::Array out = Napi::Array::New(env, rc == 0 ? count : 0); - for (uint32_t i = 0; rc == 0 && i < count; ++i) { - fm_phonetic_run_t run{}; - rc = fm_workbook_get_cell_phonetic_run(handle_, sheet, row, col, i, &run); - if (rc != 0) { - break; - } - Napi::Object entry = Napi::Object::New(env); - entry.Set("sb", Napi::Number::New(env, run.sb)); - entry.Set("eb", Napi::Number::New(env, run.eb)); - // Copied immediately: each read refreshes the handle's scratch, so the - // previous run's pointer is dead by the time the next one lands. - entry.Set("text", Napi::String::New(env, run.text != nullptr ? run.text : "")); - out.Set(i, entry); - } - if (rc != 0) { - return MakeFieldResult(env, MakeErrorStatus(env, rc), "runs", Napi::Array::New(env, 0)); - } - return MakeFieldResult(env, MakeOkStatus(env), "runs", out); -} - -Napi::Value Workbook::GetCellPhoneticProperties(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - uint32_t font_id = 0; - uint32_t type = 0; - uint32_t alignment = 0; - const fm_status_t rc = - handle_ != nullptr ? fm_workbook_get_cell_phonetic_properties(handle_, sheet, row, col, &font_id, &type, - &alignment) - : kBindingInvalidHandle; - // The payload keys are declared unconditionally, so a failure reports the - // defaults beside the status rather than dropping them. - if (rc != 0) { - font_id = 0; - type = 0; - alignment = 0; - } - Napi::Object out = Napi::Object::New(env); - out.Set("fontId", Napi::Number::New(env, font_id)); - out.Set("type", Napi::Number::New(env, type)); - out.Set("alignment", Napi::Number::New(env, alignment)); - out.Set("status", MakeStatus(env, rc)); - return out; -} - -Napi::Value Workbook::EvaluateFormulaText(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return MakeEmptyValueResult(env, NullHandleError(env)); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const std::string formula = ArgString(info, 3); - fm_value_t v{}; - fm_status_t rc = fm_workbook_evaluate_formula(handle_, sheet, row, col, formula.c_str(), &v); - return MakeValueResult(env, rc, v); -} - -Napi::Value Workbook::EvaluateFormulaArray(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - Napi::Object out = Napi::Object::New(env); - if (handle_ == nullptr) { - out.Set("status", NullHandleError(env)); - out.Set("rows", Napi::Number::New(env, 0)); - out.Set("cols", Napi::Number::New(env, 0)); - out.Set("cells", Napi::Array::New(env, 0)); - return out; - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const std::string formula = ArgString(info, 3); - - uint32_t rows = 0; - uint32_t cols = 0; - fm_status_t rc = fm_workbook_evaluate_formula_array(handle_, sheet, row, col, formula.c_str(), &rows, &cols); - if (rc != 0) { - out.Set("status", MakeErrorStatus(env, rc)); - out.Set("rows", Napi::Number::New(env, 0)); - out.Set("cols", Napi::Number::New(env, 0)); - out.Set("cells", Napi::Array::New(env, 0)); - return out; - } - - // Build a rows x cols nested array of Value objects, reading each stashed - // cell by its row-major index (r * cols + c). - Napi::Array cells = Napi::Array::New(env, rows); - for (uint32_t r = 0; r < rows; ++r) { - Napi::Array js_row = Napi::Array::New(env, cols); - for (uint32_t c = 0; c < cols; ++c) { - const std::size_t index = static_cast(r) * cols + c; - fm_value_t v{}; - fm_status_t cell_rc = fm_workbook_evaluate_formula_array_cell(handle_, index, &v); - if (cell_rc != 0) { - out.Set("status", MakeErrorStatus(env, cell_rc)); - out.Set("rows", Napi::Number::New(env, 0)); - out.Set("cols", Napi::Number::New(env, 0)); - out.Set("cells", Napi::Array::New(env, 0)); - return out; - } - js_row.Set(c, TranslateValue(env, v)); - } - cells.Set(r, js_row); - } - - out.Set("status", MakeOkStatus(env)); - out.Set("rows", Napi::Number::New(env, rows)); - out.Set("cols", Napi::Number::New(env, cols)); - out.Set("cells", cells); - return out; -} - -Napi::Value Workbook::EvaluateConditionalFormula(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return MakeEmptyValueResult(env, NullHandleError(env)); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const uint32_t anchor_row = ArgU32(info, 3); - const uint32_t anchor_col = ArgU32(info, 4); - const std::string formula = ArgString(info, 5); - fm_value_t v{}; - fm_status_t rc = - fm_workbook_evaluate_cf_formula(handle_, sheet, row, col, anchor_row, anchor_col, formula.c_str(), &v); - return MakeValueResult(env, rc, v); -} - -Napi::Value Workbook::GetLambdaText(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return MakeStringFieldResult(env, NullHandleError(env), "text", ""); - } - const std::size_t sheet = static_cast(ArgU32(info, 0)); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const char* text = nullptr; - fm_status_t rc = fm_workbook_lambda_text_at(handle_, sheet, row, col, &text); - if (rc != 0) { - return MakeStringFieldResult(env, MakeErrorStatus(env, rc), "text", ""); - } - return MakeStringFieldResult(env, MakeOkStatus(env), "text", text); -} - // ---- Calc policy / behaviour profile -------------------------------- Napi::Value Workbook::CalcMode(const Napi::CallbackInfo& info) { diff --git a/src/node_addon/parts/sheet_ui.cc b/src/node_addon/parts/sheet_ui.cc new file mode 100644 index 000000000..4e307f749 --- /dev/null +++ b/src/node_addon/parts/sheet_ui.cc @@ -0,0 +1,493 @@ +// Per-sheet UI feature bindings: merges, comments, hyperlinks, and data +// validations. + +#include +#include +#include +#include + +#include "node_addon/parts/workbook_class.h" + +namespace formulon_node { + +// ---- Merges --------------------------------------------------------- + +namespace { + +// Reads a `{ firstRow, lastRow, firstCol, lastCol }` range object. +fm_merge_range ReadMergeRange(const Napi::Object& range) { + fm_merge_range m{}; + m.first_row = range.Get("firstRow").ToNumber().Uint32Value(); + m.last_row = range.Get("lastRow").ToNumber().Uint32Value(); + m.first_col = range.Get("firstCol").ToNumber().Uint32Value(); + m.last_col = range.Get("lastCol").ToNumber().Uint32Value(); + return m; +} + +// Reads the range at `info[idx]`; a missing or non-object argument reads as all zeros. +fm_merge_range MergeRangeArg(const Napi::CallbackInfo& info, size_t idx) { + return info.Length() > idx && info[idx].IsObject() ? ReadMergeRange(info[idx].As()) : fm_merge_range{}; +} + +} // namespace + +Napi::Value Workbook::AddMerge(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + fm_status_t rc = fm_sheet_add_merge(handle_, sheet, MergeRangeArg(info, 1)); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::RemoveMerge(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + fm_status_t rc = fm_sheet_remove_merge(handle_, sheet, MergeRangeArg(info, 1)); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::RemoveMergeAt(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + const uint32_t index = ArgU32(info, 1); + fm_status_t rc = fm_sheet_remove_merge_at(handle_, sheet, index); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::ClearMerges(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + fm_status_t rc = fm_sheet_clear_merges(handle_, sheet); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::GetMerges(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::Array arr = Napi::Array::New(env); + if (handle_ == nullptr) { + return FinishListResult(env, arr, kBindingInvalidHandle); + } + const uint32_t sheet = ArgU32(info, 0); + uint32_t count = 0; + fm_status_t rc = fm_sheet_get_merge_count(handle_, sheet, &count); + if (rc != 0) { + return FinishListResult(env, arr, rc); + } + std::size_t emitted = 0; + for (uint32_t i = 0; i < count; ++i) { + fm_merge_range m{}; + rc = fm_sheet_get_merge_at(handle_, sheet, i, &m); + if (rc != 0) { + return FinishListResult(env, arr, rc); + } + Napi::Object item = Napi::Object::New(env); + item.Set("firstRow", Napi::Number::New(env, m.first_row)); + item.Set("lastRow", Napi::Number::New(env, m.last_row)); + item.Set("firstCol", Napi::Number::New(env, m.first_col)); + item.Set("lastCol", Napi::Number::New(env, m.last_col)); + arr.Set(static_cast(emitted), item); + ++emitted; + } + return FinishListResult(env, arr, 0); +} + +// ---- Comments ------------------------------------------------------- + +Napi::Value Workbook::GetComment(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return env.Null(); + } + const uint32_t sheet = ArgU32(info, 0); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + fm_comment c{}; + if (fm_sheet_get_comment_at(handle_, sheet, row, col, &c) != 0) { + return env.Null(); + } + Napi::Object o = Napi::Object::New(env); + o.Set("author", Napi::String::New(env, c.author != nullptr ? c.author : "")); + o.Set("text", Napi::String::New(env, c.text != nullptr ? c.text : "")); + return o; +} + +Napi::Value Workbook::GetCommentResult(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::Object out = Napi::Object::New(env); + if (handle_ == nullptr) { + out.Set("status", NullHandleError(env)); + out.Set("comment", env.Null()); + return out; + } + const uint32_t sheet = ArgU32(info, 0); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + fm_comment c{}; + const fm_status_t rc = fm_sheet_get_comment_at(handle_, sheet, row, col, &c); + if (rc != 0) { + out.Set("status", MakeErrorStatus(env, rc)); + out.Set("comment", env.Null()); + return out; + } + Napi::Object comment = Napi::Object::New(env); + comment.Set("author", Napi::String::New(env, c.author != nullptr ? c.author : "")); + comment.Set("text", Napi::String::New(env, c.text != nullptr ? c.text : "")); + out.Set("status", MakeOkStatus(env)); + out.Set("comment", comment); + return out; +} + +Napi::Value Workbook::GetComments(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::Array arr = Napi::Array::New(env); + if (handle_ == nullptr) { + return FinishListResult(env, arr, kBindingInvalidHandle); + } + const uint32_t sheet = ArgU32(info, 0); + uint32_t count = 0; + fm_status_t rc = fm_sheet_get_comment_count(handle_, sheet, &count); + if (rc != 0) { + return FinishListResult(env, arr, rc); + } + std::size_t emitted = 0; + for (uint32_t i = 0; i < count; ++i) { + fm_comment c{}; + rc = fm_sheet_get_comment_at_index(handle_, sheet, i, &c); + if (rc != 0) { + return FinishListResult(env, arr, rc); + } + Napi::Object item = Napi::Object::New(env); + item.Set("row", Napi::Number::New(env, c.row)); + item.Set("col", Napi::Number::New(env, c.col)); + item.Set("author", Napi::String::New(env, c.author != nullptr ? c.author : "")); + item.Set("text", Napi::String::New(env, c.text != nullptr ? c.text : "")); + arr.Set(static_cast(emitted), item); + ++emitted; + } + return FinishListResult(env, arr, 0); +} + +Napi::Value Workbook::SetComment(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const std::string author = ArgString(info, 3); + const std::string text = ArgString(info, 4); + // Empty strings turn into NULL in the C ABI to remove an entry, + // matching the embind binding's contract. + const char* author_c = author.empty() ? nullptr : author.c_str(); + const char* text_c = text.empty() ? nullptr : text.c_str(); + fm_status_t rc = fm_sheet_set_comment(handle_, sheet, row, col, author_c, text_c); + return MakeStatus(env, rc); +} + +// ---- Hyperlinks ----------------------------------------------------- + +namespace { + +// Adds the hyperlink described by the already type-checked arguments +// `(sheet, row, col[, lastRow, lastCol], target, display, tooltip, location)`. +// Empty strings are forwarded as NULL. +fm_status_t AddHyperlinkFromArgs(fm_workbook_t* wb, const Napi::CallbackInfo& info, bool ranged) { + const uint32_t sheet = Workbook::ArgU32(info, 0); + const uint32_t row = Workbook::ArgU32(info, 1); + const uint32_t col = Workbook::ArgU32(info, 2); + const uint32_t last_row = ranged ? Workbook::ArgU32(info, 3) : row; + const uint32_t last_col = ranged ? Workbook::ArgU32(info, 4) : col; + const std::size_t text_idx = ranged ? 5 : 3; + // Keep the std::string buffers alive until after the C ABI call so the + // borrowed `const char*` pointers in `fm_hyperlink` stay valid. + const std::string target = Workbook::ArgString(info, text_idx); + const std::string display = Workbook::ArgString(info, text_idx + 1); + const std::string tooltip = Workbook::ArgString(info, text_idx + 2); + const std::string location = Workbook::ArgString(info, text_idx + 3); + fm_hyperlink hl{}; + hl.row = row; + hl.col = col; + hl.last_row = last_row; + hl.last_col = last_col; + hl.target = target.empty() ? nullptr : target.c_str(); + hl.location = location.empty() ? nullptr : location.c_str(); + hl.display = display.empty() ? nullptr : display.c_str(); + hl.tooltip = tooltip.empty() ? nullptr : tooltip.c_str(); + return fm_sheet_add_hyperlink(wb, sheet, hl); +} + +} // namespace + +Napi::Value Workbook::AddHyperlink(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + if (info.Length() < 7 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsString() || + !info[4].IsString() || !info[5].IsString() || !info[6].IsString()) { + Napi::TypeError::New(env, + "addHyperlink expects (sheet:number, row:number, col:number, " + "target:string, display:string, tooltip:string, location:string)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + return MakeStatus(env, AddHyperlinkFromArgs(handle_, info, /*ranged=*/false)); +} + +Napi::Value Workbook::AddHyperlinkRange(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + if (info.Length() < 9 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsNumber() || + !info[4].IsNumber() || !info[5].IsString() || !info[6].IsString() || !info[7].IsString() || !info[8].IsString()) { + Napi::TypeError::New(env, + "addHyperlinkRange expects (sheet:number, row:number, col:number, " + "lastRow:number, lastCol:number, target:string, display:string, " + "tooltip:string, location:string)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + return MakeStatus(env, AddHyperlinkFromArgs(handle_, info, /*ranged=*/true)); +} + +Napi::Value Workbook::GetHyperlinks(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::Array arr = Napi::Array::New(env); + if (handle_ == nullptr) { + return FinishListResult(env, arr, kBindingInvalidHandle); + } + const uint32_t sheet = ArgU32(info, 0); + uint32_t count = 0; + fm_status_t rc = fm_sheet_get_hyperlink_count(handle_, sheet, &count); + if (rc != 0) { + return FinishListResult(env, arr, rc); + } + std::size_t emitted = 0; + for (uint32_t i = 0; i < count; ++i) { + fm_hyperlink h{}; + rc = fm_sheet_get_hyperlink_at(handle_, sheet, i, &h); + if (rc != 0) { + return FinishListResult(env, arr, rc); + } + Napi::Object item = Napi::Object::New(env); + item.Set("row", Napi::Number::New(env, h.row)); + item.Set("col", Napi::Number::New(env, h.col)); + item.Set("lastRow", Napi::Number::New(env, h.last_row)); + item.Set("lastCol", Napi::Number::New(env, h.last_col)); + item.Set("target", Napi::String::New(env, h.target != nullptr ? h.target : "")); + item.Set("location", Napi::String::New(env, h.location != nullptr ? h.location : "")); + item.Set("display", Napi::String::New(env, h.display != nullptr ? h.display : "")); + item.Set("tooltip", Napi::String::New(env, h.tooltip != nullptr ? h.tooltip : "")); + arr.Set(static_cast(emitted), item); + ++emitted; + } + return FinishListResult(env, arr, 0); +} + +Napi::Value Workbook::RemoveHyperlink(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + fm_status_t rc = fm_sheet_remove_hyperlink(handle_, sheet, row, col); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::RemoveHyperlinkAt(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + const uint32_t index = ArgU32(info, 1); + fm_status_t rc = fm_sheet_remove_hyperlink_at(handle_, sheet, index); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::ClearHyperlinks(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + fm_status_t rc = fm_sheet_clear_hyperlinks(handle_, sheet); + return MakeStatus(env, rc); +} + +// ---- Validations ---------------------------------------------------- + +Napi::Value Workbook::GetValidations(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::Array arr = Napi::Array::New(env); + if (handle_ == nullptr) { + return FinishListResult(env, arr, kBindingInvalidHandle); + } + const uint32_t sheet = ArgU32(info, 0); + uint32_t count = 0; + fm_status_t rc = fm_sheet_get_validation_count(handle_, sheet, &count); + if (rc != 0) { + return FinishListResult(env, arr, rc); + } + std::size_t emitted = 0; + for (uint32_t i = 0; i < count; ++i) { + fm_data_validation v{}; + rc = fm_sheet_get_validation_at(handle_, sheet, i, &v); + if (rc != 0) { + return FinishListResult(env, arr, rc); + } + Napi::Object item = Napi::Object::New(env); + Napi::Array ranges = Napi::Array::New(env); + for (uint32_t r = 0; r < v.range_count; ++r) { + Napi::Object rng = Napi::Object::New(env); + rng.Set("firstRow", Napi::Number::New(env, v.ranges[r].first_row)); + rng.Set("lastRow", Napi::Number::New(env, v.ranges[r].last_row)); + rng.Set("firstCol", Napi::Number::New(env, v.ranges[r].first_col)); + rng.Set("lastCol", Napi::Number::New(env, v.ranges[r].last_col)); + ranges.Set(r, rng); + } + item.Set("ranges", ranges); + item.Set("type", Napi::Number::New(env, v.type)); + item.Set("op", Napi::Number::New(env, v.op)); + item.Set("errorStyle", Napi::Number::New(env, v.error_style)); + item.Set("allowBlank", Napi::Boolean::New(env, v.allow_blank != 0)); + item.Set("showInputMessage", Napi::Boolean::New(env, v.show_input_message != 0)); + item.Set("showErrorMessage", Napi::Boolean::New(env, v.show_error_message != 0)); + item.Set("showDropDown", Napi::Boolean::New(env, v.show_dropdown != 0)); + item.Set("formula1", Napi::String::New(env, v.formula1 != nullptr ? v.formula1 : "")); + item.Set("formula2", Napi::String::New(env, v.formula2 != nullptr ? v.formula2 : "")); + item.Set("errorTitle", Napi::String::New(env, v.error_title != nullptr ? v.error_title : "")); + item.Set("errorMessage", Napi::String::New(env, v.error_message != nullptr ? v.error_message : "")); + item.Set("promptTitle", Napi::String::New(env, v.prompt_title != nullptr ? v.prompt_title : "")); + item.Set("promptMessage", Napi::String::New(env, v.prompt_message != nullptr ? v.prompt_message : "")); + arr.Set(static_cast(emitted), item); + ++emitted; + } + return FinishListResult(env, arr, 0); +} + +Napi::Value Workbook::AddValidation(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) { + Napi::TypeError::New(env, "addValidation expects (sheet:number, validation:object)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + const uint32_t sheet = ArgU32(info, 0); + Napi::Object v = info[1].As(); + + // Pull every JS field into local storage first; the C ABI receives + // borrowed `const char*` views that must stay valid until + // `fm_sheet_add_validation` returns. + std::vector ranges_buf; + if (v.Has("ranges")) { + Napi::Value ranges_js = v.Get("ranges"); + if (ranges_js.IsArray()) { + Napi::Array ranges_arr = ranges_js.As(); + const uint32_t n = ranges_arr.Length(); + ranges_buf.reserve(n); + for (uint32_t i = 0; i < n; ++i) { + Napi::Value rng_v = ranges_arr.Get(i); + if (!rng_v.IsObject()) { + continue; + } + ranges_buf.push_back(ReadMergeRange(rng_v.As())); + } + } + } + auto pull_string = [&](const char* key) -> std::string { + if (!v.Has(key)) { + return std::string(); + } + Napi::Value f = v.Get(key); + if (f.IsUndefined() || f.IsNull()) { + return std::string(); + } + return f.ToString().Utf8Value(); + }; + auto pull_u8 = [&](const char* key) -> uint8_t { + if (!v.Has(key)) { + return 0; + } + Napi::Value f = v.Get(key); + if (f.IsUndefined() || f.IsNull()) { + return 0; + } + return static_cast(f.ToNumber().Uint32Value() & 0xFFU); + }; + auto pull_bool = [&](const char* key, bool dflt) -> bool { + if (!v.Has(key)) { + return dflt; + } + Napi::Value f = v.Get(key); + if (f.IsUndefined() || f.IsNull()) { + return dflt; + } + return f.ToBoolean().Value(); + }; + const std::string formula1 = pull_string("formula1"); + const std::string formula2 = pull_string("formula2"); + const std::string error_title = pull_string("errorTitle"); + const std::string error_message = pull_string("errorMessage"); + const std::string prompt_title = pull_string("promptTitle"); + const std::string prompt_message = pull_string("promptMessage"); + + fm_data_validation dv{}; + dv.ranges = ranges_buf.empty() ? nullptr : ranges_buf.data(); + dv.range_count = static_cast(ranges_buf.size()); + dv.type = pull_u8("type"); + dv.op = pull_u8("op"); + dv.error_style = pull_u8("errorStyle"); + dv.allow_blank = pull_bool("allowBlank", false) ? 1 : 0; + dv.show_input_message = pull_bool("showInputMessage", false) ? 1 : 0; + dv.show_error_message = pull_bool("showErrorMessage", false) ? 1 : 0; + dv.show_dropdown = pull_bool("showDropDown", true) ? 1 : 0; + dv.formula1 = formula1.empty() ? nullptr : formula1.c_str(); + dv.formula2 = formula2.empty() ? nullptr : formula2.c_str(); + dv.error_title = error_title.empty() ? nullptr : error_title.c_str(); + dv.error_message = error_message.empty() ? nullptr : error_message.c_str(); + dv.prompt_title = prompt_title.empty() ? nullptr : prompt_title.c_str(); + dv.prompt_message = prompt_message.empty() ? nullptr : prompt_message.c_str(); + fm_status_t rc = fm_sheet_add_validation(handle_, sheet, dv); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::RemoveValidationAt(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + const uint32_t index = ArgU32(info, 1); + fm_status_t rc = fm_sheet_remove_validation_at(handle_, sheet, index); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::ClearValidations(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t sheet = ArgU32(info, 0); + fm_status_t rc = fm_sheet_clear_validations(handle_, sheet); + return MakeStatus(env, rc); +} + +} // namespace formulon_node diff --git a/src/node_addon/parts/sheet_view.cc b/src/node_addon/parts/sheet_view.cc index 6f145bd50..366415461 100644 --- a/src/node_addon/parts/sheet_view.cc +++ b/src/node_addon/parts/sheet_view.cc @@ -1,11 +1,9 @@ -// Sheet view / layout bindings (zoom, freeze, tab visibility, column / -// row layout) and the per-sheet UI feature surfaces: merges, -// comments, hyperlinks, and data validations. +// Sheet view / layout bindings: zoom, freeze, tab visibility, protection, +// and column / row layout. #include #include #include -#include #include "node_addon/parts/workbook_class.h" @@ -441,484 +439,4 @@ Napi::Value Workbook::SetRowOutline(const Napi::CallbackInfo& info) { return MakeStatus(env, rc); } -// ---- Merges --------------------------------------------------------- - -namespace { - -// Reads a `{ firstRow, lastRow, firstCol, lastCol }` range object. -fm_merge_range ReadMergeRange(const Napi::Object& range) { - fm_merge_range m{}; - m.first_row = range.Get("firstRow").ToNumber().Uint32Value(); - m.last_row = range.Get("lastRow").ToNumber().Uint32Value(); - m.first_col = range.Get("firstCol").ToNumber().Uint32Value(); - m.last_col = range.Get("lastCol").ToNumber().Uint32Value(); - return m; -} - -// Reads the range at `info[idx]`; a missing or non-object argument reads as all zeros. -fm_merge_range MergeRangeArg(const Napi::CallbackInfo& info, size_t idx) { - return info.Length() > idx && info[idx].IsObject() ? ReadMergeRange(info[idx].As()) : fm_merge_range{}; -} - -} // namespace - -Napi::Value Workbook::AddMerge(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - fm_status_t rc = fm_sheet_add_merge(handle_, sheet, MergeRangeArg(info, 1)); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::RemoveMerge(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - fm_status_t rc = fm_sheet_remove_merge(handle_, sheet, MergeRangeArg(info, 1)); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::RemoveMergeAt(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - const uint32_t index = ArgU32(info, 1); - fm_status_t rc = fm_sheet_remove_merge_at(handle_, sheet, index); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::ClearMerges(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - fm_status_t rc = fm_sheet_clear_merges(handle_, sheet); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::GetMerges(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - Napi::Array arr = Napi::Array::New(env); - if (handle_ == nullptr) { - return FinishListResult(env, arr, kBindingInvalidHandle); - } - const uint32_t sheet = ArgU32(info, 0); - uint32_t count = 0; - fm_status_t rc = fm_sheet_get_merge_count(handle_, sheet, &count); - if (rc != 0) { - return FinishListResult(env, arr, rc); - } - std::size_t emitted = 0; - for (uint32_t i = 0; i < count; ++i) { - fm_merge_range m{}; - rc = fm_sheet_get_merge_at(handle_, sheet, i, &m); - if (rc != 0) { - return FinishListResult(env, arr, rc); - } - Napi::Object item = Napi::Object::New(env); - item.Set("firstRow", Napi::Number::New(env, m.first_row)); - item.Set("lastRow", Napi::Number::New(env, m.last_row)); - item.Set("firstCol", Napi::Number::New(env, m.first_col)); - item.Set("lastCol", Napi::Number::New(env, m.last_col)); - arr.Set(static_cast(emitted), item); - ++emitted; - } - return FinishListResult(env, arr, 0); -} - -// ---- Comments ------------------------------------------------------- - -Napi::Value Workbook::GetComment(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return env.Null(); - } - const uint32_t sheet = ArgU32(info, 0); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - fm_comment c{}; - if (fm_sheet_get_comment_at(handle_, sheet, row, col, &c) != 0) { - return env.Null(); - } - Napi::Object o = Napi::Object::New(env); - o.Set("author", Napi::String::New(env, c.author != nullptr ? c.author : "")); - o.Set("text", Napi::String::New(env, c.text != nullptr ? c.text : "")); - return o; -} - -Napi::Value Workbook::GetCommentResult(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - Napi::Object out = Napi::Object::New(env); - if (handle_ == nullptr) { - out.Set("status", NullHandleError(env)); - out.Set("comment", env.Null()); - return out; - } - const uint32_t sheet = ArgU32(info, 0); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - fm_comment c{}; - const fm_status_t rc = fm_sheet_get_comment_at(handle_, sheet, row, col, &c); - if (rc != 0) { - out.Set("status", MakeErrorStatus(env, rc)); - out.Set("comment", env.Null()); - return out; - } - Napi::Object comment = Napi::Object::New(env); - comment.Set("author", Napi::String::New(env, c.author != nullptr ? c.author : "")); - comment.Set("text", Napi::String::New(env, c.text != nullptr ? c.text : "")); - out.Set("status", MakeOkStatus(env)); - out.Set("comment", comment); - return out; -} - -Napi::Value Workbook::GetComments(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - Napi::Array arr = Napi::Array::New(env); - if (handle_ == nullptr) { - return FinishListResult(env, arr, kBindingInvalidHandle); - } - const uint32_t sheet = ArgU32(info, 0); - uint32_t count = 0; - fm_status_t rc = fm_sheet_get_comment_count(handle_, sheet, &count); - if (rc != 0) { - return FinishListResult(env, arr, rc); - } - std::size_t emitted = 0; - for (uint32_t i = 0; i < count; ++i) { - fm_comment c{}; - rc = fm_sheet_get_comment_at_index(handle_, sheet, i, &c); - if (rc != 0) { - return FinishListResult(env, arr, rc); - } - Napi::Object item = Napi::Object::New(env); - item.Set("row", Napi::Number::New(env, c.row)); - item.Set("col", Napi::Number::New(env, c.col)); - item.Set("author", Napi::String::New(env, c.author != nullptr ? c.author : "")); - item.Set("text", Napi::String::New(env, c.text != nullptr ? c.text : "")); - arr.Set(static_cast(emitted), item); - ++emitted; - } - return FinishListResult(env, arr, 0); -} - -Napi::Value Workbook::SetComment(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - const std::string author = ArgString(info, 3); - const std::string text = ArgString(info, 4); - // Empty strings turn into NULL in the C ABI to remove an entry, - // matching the embind binding's contract. - const char* author_c = author.empty() ? nullptr : author.c_str(); - const char* text_c = text.empty() ? nullptr : text.c_str(); - fm_status_t rc = fm_sheet_set_comment(handle_, sheet, row, col, author_c, text_c); - return MakeStatus(env, rc); -} - -// ---- Hyperlinks ----------------------------------------------------- - -namespace { - -// Adds the hyperlink described by the already type-checked arguments -// `(sheet, row, col[, lastRow, lastCol], target, display, tooltip, location)`. -// Empty strings are forwarded as NULL. -fm_status_t AddHyperlinkFromArgs(fm_workbook_t* wb, const Napi::CallbackInfo& info, bool ranged) { - const uint32_t sheet = Workbook::ArgU32(info, 0); - const uint32_t row = Workbook::ArgU32(info, 1); - const uint32_t col = Workbook::ArgU32(info, 2); - const uint32_t last_row = ranged ? Workbook::ArgU32(info, 3) : row; - const uint32_t last_col = ranged ? Workbook::ArgU32(info, 4) : col; - const std::size_t text_idx = ranged ? 5 : 3; - // Keep the std::string buffers alive until after the C ABI call so the - // borrowed `const char*` pointers in `fm_hyperlink` stay valid. - const std::string target = Workbook::ArgString(info, text_idx); - const std::string display = Workbook::ArgString(info, text_idx + 1); - const std::string tooltip = Workbook::ArgString(info, text_idx + 2); - const std::string location = Workbook::ArgString(info, text_idx + 3); - fm_hyperlink hl{}; - hl.row = row; - hl.col = col; - hl.last_row = last_row; - hl.last_col = last_col; - hl.target = target.empty() ? nullptr : target.c_str(); - hl.location = location.empty() ? nullptr : location.c_str(); - hl.display = display.empty() ? nullptr : display.c_str(); - hl.tooltip = tooltip.empty() ? nullptr : tooltip.c_str(); - return fm_sheet_add_hyperlink(wb, sheet, hl); -} - -} // namespace - -Napi::Value Workbook::AddHyperlink(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - if (info.Length() < 7 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsString() || - !info[4].IsString() || !info[5].IsString() || !info[6].IsString()) { - Napi::TypeError::New(env, - "addHyperlink expects (sheet:number, row:number, col:number, " - "target:string, display:string, tooltip:string, location:string)") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - return MakeStatus(env, AddHyperlinkFromArgs(handle_, info, /*ranged=*/false)); -} - -Napi::Value Workbook::AddHyperlinkRange(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - if (info.Length() < 9 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsNumber() || - !info[4].IsNumber() || !info[5].IsString() || !info[6].IsString() || !info[7].IsString() || !info[8].IsString()) { - Napi::TypeError::New(env, - "addHyperlinkRange expects (sheet:number, row:number, col:number, " - "lastRow:number, lastCol:number, target:string, display:string, " - "tooltip:string, location:string)") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - return MakeStatus(env, AddHyperlinkFromArgs(handle_, info, /*ranged=*/true)); -} - -Napi::Value Workbook::GetHyperlinks(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - Napi::Array arr = Napi::Array::New(env); - if (handle_ == nullptr) { - return FinishListResult(env, arr, kBindingInvalidHandle); - } - const uint32_t sheet = ArgU32(info, 0); - uint32_t count = 0; - fm_status_t rc = fm_sheet_get_hyperlink_count(handle_, sheet, &count); - if (rc != 0) { - return FinishListResult(env, arr, rc); - } - std::size_t emitted = 0; - for (uint32_t i = 0; i < count; ++i) { - fm_hyperlink h{}; - rc = fm_sheet_get_hyperlink_at(handle_, sheet, i, &h); - if (rc != 0) { - return FinishListResult(env, arr, rc); - } - Napi::Object item = Napi::Object::New(env); - item.Set("row", Napi::Number::New(env, h.row)); - item.Set("col", Napi::Number::New(env, h.col)); - item.Set("lastRow", Napi::Number::New(env, h.last_row)); - item.Set("lastCol", Napi::Number::New(env, h.last_col)); - item.Set("target", Napi::String::New(env, h.target != nullptr ? h.target : "")); - item.Set("location", Napi::String::New(env, h.location != nullptr ? h.location : "")); - item.Set("display", Napi::String::New(env, h.display != nullptr ? h.display : "")); - item.Set("tooltip", Napi::String::New(env, h.tooltip != nullptr ? h.tooltip : "")); - arr.Set(static_cast(emitted), item); - ++emitted; - } - return FinishListResult(env, arr, 0); -} - -Napi::Value Workbook::RemoveHyperlink(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - const uint32_t row = ArgU32(info, 1); - const uint32_t col = ArgU32(info, 2); - fm_status_t rc = fm_sheet_remove_hyperlink(handle_, sheet, row, col); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::RemoveHyperlinkAt(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - const uint32_t index = ArgU32(info, 1); - fm_status_t rc = fm_sheet_remove_hyperlink_at(handle_, sheet, index); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::ClearHyperlinks(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - fm_status_t rc = fm_sheet_clear_hyperlinks(handle_, sheet); - return MakeStatus(env, rc); -} - -// ---- Validations ---------------------------------------------------- - -Napi::Value Workbook::GetValidations(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - Napi::Array arr = Napi::Array::New(env); - if (handle_ == nullptr) { - return FinishListResult(env, arr, kBindingInvalidHandle); - } - const uint32_t sheet = ArgU32(info, 0); - uint32_t count = 0; - fm_status_t rc = fm_sheet_get_validation_count(handle_, sheet, &count); - if (rc != 0) { - return FinishListResult(env, arr, rc); - } - std::size_t emitted = 0; - for (uint32_t i = 0; i < count; ++i) { - fm_data_validation v{}; - rc = fm_sheet_get_validation_at(handle_, sheet, i, &v); - if (rc != 0) { - return FinishListResult(env, arr, rc); - } - Napi::Object item = Napi::Object::New(env); - Napi::Array ranges = Napi::Array::New(env); - for (uint32_t r = 0; r < v.range_count; ++r) { - Napi::Object rng = Napi::Object::New(env); - rng.Set("firstRow", Napi::Number::New(env, v.ranges[r].first_row)); - rng.Set("lastRow", Napi::Number::New(env, v.ranges[r].last_row)); - rng.Set("firstCol", Napi::Number::New(env, v.ranges[r].first_col)); - rng.Set("lastCol", Napi::Number::New(env, v.ranges[r].last_col)); - ranges.Set(r, rng); - } - item.Set("ranges", ranges); - item.Set("type", Napi::Number::New(env, v.type)); - item.Set("op", Napi::Number::New(env, v.op)); - item.Set("errorStyle", Napi::Number::New(env, v.error_style)); - item.Set("allowBlank", Napi::Boolean::New(env, v.allow_blank != 0)); - item.Set("showInputMessage", Napi::Boolean::New(env, v.show_input_message != 0)); - item.Set("showErrorMessage", Napi::Boolean::New(env, v.show_error_message != 0)); - item.Set("showDropDown", Napi::Boolean::New(env, v.show_dropdown != 0)); - item.Set("formula1", Napi::String::New(env, v.formula1 != nullptr ? v.formula1 : "")); - item.Set("formula2", Napi::String::New(env, v.formula2 != nullptr ? v.formula2 : "")); - item.Set("errorTitle", Napi::String::New(env, v.error_title != nullptr ? v.error_title : "")); - item.Set("errorMessage", Napi::String::New(env, v.error_message != nullptr ? v.error_message : "")); - item.Set("promptTitle", Napi::String::New(env, v.prompt_title != nullptr ? v.prompt_title : "")); - item.Set("promptMessage", Napi::String::New(env, v.prompt_message != nullptr ? v.prompt_message : "")); - arr.Set(static_cast(emitted), item); - ++emitted; - } - return FinishListResult(env, arr, 0); -} - -Napi::Value Workbook::AddValidation(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) { - Napi::TypeError::New(env, "addValidation expects (sheet:number, validation:object)").ThrowAsJavaScriptException(); - return env.Undefined(); - } - const uint32_t sheet = ArgU32(info, 0); - Napi::Object v = info[1].As(); - - // Pull every JS field into local storage first; the C ABI receives - // borrowed `const char*` views that must stay valid until - // `fm_sheet_add_validation` returns. - std::vector ranges_buf; - if (v.Has("ranges")) { - Napi::Value ranges_js = v.Get("ranges"); - if (ranges_js.IsArray()) { - Napi::Array ranges_arr = ranges_js.As(); - const uint32_t n = ranges_arr.Length(); - ranges_buf.reserve(n); - for (uint32_t i = 0; i < n; ++i) { - Napi::Value rng_v = ranges_arr.Get(i); - if (!rng_v.IsObject()) { - continue; - } - ranges_buf.push_back(ReadMergeRange(rng_v.As())); - } - } - } - auto pull_string = [&](const char* key) -> std::string { - if (!v.Has(key)) { - return std::string(); - } - Napi::Value f = v.Get(key); - if (f.IsUndefined() || f.IsNull()) { - return std::string(); - } - return f.ToString().Utf8Value(); - }; - auto pull_u8 = [&](const char* key) -> uint8_t { - if (!v.Has(key)) { - return 0; - } - Napi::Value f = v.Get(key); - if (f.IsUndefined() || f.IsNull()) { - return 0; - } - return static_cast(f.ToNumber().Uint32Value() & 0xFFU); - }; - auto pull_bool = [&](const char* key, bool dflt) -> bool { - if (!v.Has(key)) { - return dflt; - } - Napi::Value f = v.Get(key); - if (f.IsUndefined() || f.IsNull()) { - return dflt; - } - return f.ToBoolean().Value(); - }; - const std::string formula1 = pull_string("formula1"); - const std::string formula2 = pull_string("formula2"); - const std::string error_title = pull_string("errorTitle"); - const std::string error_message = pull_string("errorMessage"); - const std::string prompt_title = pull_string("promptTitle"); - const std::string prompt_message = pull_string("promptMessage"); - - fm_data_validation dv{}; - dv.ranges = ranges_buf.empty() ? nullptr : ranges_buf.data(); - dv.range_count = static_cast(ranges_buf.size()); - dv.type = pull_u8("type"); - dv.op = pull_u8("op"); - dv.error_style = pull_u8("errorStyle"); - dv.allow_blank = pull_bool("allowBlank", false) ? 1 : 0; - dv.show_input_message = pull_bool("showInputMessage", false) ? 1 : 0; - dv.show_error_message = pull_bool("showErrorMessage", false) ? 1 : 0; - dv.show_dropdown = pull_bool("showDropDown", true) ? 1 : 0; - dv.formula1 = formula1.empty() ? nullptr : formula1.c_str(); - dv.formula2 = formula2.empty() ? nullptr : formula2.c_str(); - dv.error_title = error_title.empty() ? nullptr : error_title.c_str(); - dv.error_message = error_message.empty() ? nullptr : error_message.c_str(); - dv.prompt_title = prompt_title.empty() ? nullptr : prompt_title.c_str(); - dv.prompt_message = prompt_message.empty() ? nullptr : prompt_message.c_str(); - fm_status_t rc = fm_sheet_add_validation(handle_, sheet, dv); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::RemoveValidationAt(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - const uint32_t index = ArgU32(info, 1); - fm_status_t rc = fm_sheet_remove_validation_at(handle_, sheet, index); - return MakeStatus(env, rc); -} - -Napi::Value Workbook::ClearValidations(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - if (handle_ == nullptr) { - return NullHandleError(env); - } - const uint32_t sheet = ArgU32(info, 0); - fm_status_t rc = fm_sheet_clear_validations(handle_, sheet); - return MakeStatus(env, rc); -} - } // namespace formulon_node From 9d48a0aa7487b167d123bf5eaace5f86b6f86ac9 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 15:51:17 +0900 Subject: [PATCH 16/93] refactor(eval): share the serial recalc steps between full and partial recalc - detail::SpillWaveBudget in spill_release owns the spill release/dependency wave ceilings and no-progress tracking for the full, partial and parallel drivers; each driver keeps deciding when it counts - private SerialEvalPass + evaluate_formula_cell_locked replace four copies of the single-cell evaluate block - evaluate_cyclic_component_locked replaces the duplicated cyclic-component branch - error messages are unchanged - recalc_engine.h docs now describe the current SCC/cycle handling: dirty-subgraph Tarjan, components settled by reads, the iterative solver, or #REF! --- src/eval/recalc_engine.cpp | 498 +++++++++++++------------------------ src/eval/recalc_engine.h | 54 ++-- src/eval/scheduler.cpp | 34 +-- src/eval/spill_release.cpp | 16 ++ src/eval/spill_release.h | 28 +++ 5 files changed, 264 insertions(+), 366 deletions(-) diff --git a/src/eval/recalc_engine.cpp b/src/eval/recalc_engine.cpp index 011bf2156..bdc68ebaa 100644 --- a/src/eval/recalc_engine.cpp +++ b/src/eval/recalc_engine.cpp @@ -34,6 +34,7 @@ #include "utils/expected.h" #include "utils/rect_iterator.h" #include "utils/resource_budget.h" +#include "utils/status_macros.h" #include "value.h" #include "workbook.h" @@ -42,13 +43,9 @@ namespace { // The spill-release queue, the progress snapshot and the wave ceilings are // shared with the parallel scheduler; see `spill_release.h`. -using detail::BlockedSpillState; -using detail::canonical_release_targets; -using detail::kMaxNoProgressSpillWaves; -using detail::kMaxSpillReleaseWaves; using detail::queue_spill_release; -using detail::snapshot_blocked_spills; using detail::SpillReleaseQueue; +using detail::SpillWaveBudget; void mark_spill_release_wave(const RecalcEngine::LockedMutator& mutator, const std::vector& anchors, const DepGraph& graph) { @@ -397,31 +394,168 @@ void RecalcEngine::DynamicReadPass::mark_stale_dirty() { } } -void RecalcEngine::commit_unresolved_cycle_locked(Workbook& workbook, const std::vector& component, - DynamicReadPass& dynamic, SpillReleaseCallback release_callback, - void* release_user_data, RecalcStats& stats) { +void RecalcEngine::commit_unresolved_cycle_locked(const SerialEvalPass& pass, + const std::vector& component) { // Excel shows a warning and leaves the cells as they were; with no UI to // host that banner Formulon surfaces #REF!, keeping Excel's last value // only for a cycle through an OFFSET / INDIRECT read. const bool dynamic_cycle = closes_through_dynamic_edge(component, graph_); - const std::uint64_t ordinal = dynamic.next_ordinal(); + const std::uint64_t ordinal = pass.dynamic.next_ordinal(); if (dynamic_cycle) { - dynamic.log().restore_prior_values(workbook, component); + pass.dynamic.log().restore_prior_values(pass.workbook, component); } for (const CellNodeId c : component) { - if (c.sheet_id >= workbook.sheet_count()) { + if (c.sheet_id >= pass.workbook.sheet_count()) { continue; // A virtual range node. } if (!dynamic_cycle) { - Sheet& sheet = workbook.sheet(c.sheet_id); - SpillCommitter committer(&sheet, c.row, c.col, release_callback, release_user_data); + Sheet& sheet = pass.workbook.sheet(c.sheet_id); + SpillCommitter committer(&sheet, c.row, c.col, pass.release_callback, pass.release_user_data); sheet.set_cell_cached_value(c.row, c.col, committer.commit(Value::error(ErrorCode::Ref))); } - dynamic.log().note_commit(c, ordinal); - ++stats.cycle_cells; + pass.dynamic.log().note_commit(c, ordinal); + ++pass.stats.cycle_cells; } } +Expected RecalcEngine::evaluate_formula_cell_locked(const SerialEvalPass& pass, CellNodeId cell) { + if (cell.sheet_id >= pass.workbook.sheet_count()) { + return Expected::Ok(); + } + Sheet& sheet = pass.workbook.sheet(cell.sheet_id); + Cell staged; + if (!stage_formula_cell(sheet, cell.row, cell.col, staged)) { + // The dep graph may carry pure-input cells (read-only literals that + // someone reads via `add_dependency`). They have nothing to evaluate. + return Expected::Ok(); + } + // Reset the per-pass arena before each evaluate so the bump allocator + // does not grow without bound across cells. Both result shapes + // already survive this reset: + // * Array results are deep-copied into sheet-owned storage by + // `Sheet::commit_spill` (driven by + // `EvalContext::dispatch_array_result`), keeping the spill table + // independent of the arena. + // * Scalar Text results are deep-copied into the destination cell's + // `Cell::cached_text_owned` by `Sheet::set_cell_cached_value` on + // the write below, so the cached `string_view` does not dangle + // when the next cell's evaluation resets the arena. + arena_->reset(); + EvaluateCellOptions opts; + opts.spill_release_callback = pass.release_callback; + opts.spill_release_user_data = pass.release_user_data; + const std::uint64_t ordinal = pass.dynamic.next_ordinal(); + pass.dynamic.log().observe(cell, ordinal, opts, &sheet); + Value result = + evaluate_cell_for_recalc(pass.workbook, sheet, staged, cell.row, cell.col, pass.registry, *arena_, opts); + pass.dynamic.log().end(); + if (arena_->exhausted()) { + return make_error(FormulonErrorCode::kOutOfMemory, pass.oom_message); + } + sheet.set_cell_cached_value(cell.row, cell.col, result); + pass.dynamic.log().note_commit(cell, ordinal); + ++pass.stats.cells_evaluated; + return Expected::Ok(); +} + +Expected RecalcEngine::evaluate_cyclic_component_locked(const SerialEvalPass& pass, + const std::vector& component, + const char* iterative_oom_message) { + const std::size_t sheet_count = pass.workbook.sheet_count(); + // Evaluation glue for the read-ordered settle (iterative calc off) + // and the solver (on). `evaluate_with` mirrors the singleton path's + // evaluator glue: parse on the fly, dispatch through `evaluate()`, + // fold dynamic-array spills back into a scalar anchor. The `commit` + // lambda writes the new value into the cell store so the next + // evaluation reads the freshest value back. + // Hoisted out of `evaluate_one` so a staged formula's bytes stay live + // exactly as long as the arena contents of the same call: a string + // literal inside the formula surfaces in the returned Value as a view + // into this buffer, and the solver consumes each result before asking + // for the next one (which resets the arena). + Cell staged; + const std::uint64_t component_ordinal = pass.dynamic.next_ordinal(); + auto evaluate_with = [&](CellNodeId c, EvalState* observer) -> Value { + if (c.sheet_id >= sheet_count) { + return Value::error(ErrorCode::Ref); + } + Sheet& sheet = pass.workbook.sheet(c.sheet_id); + if (!stage_formula_cell(sheet, c.row, c.col, staged)) { + // No formula text: nothing to evaluate. Treat as Blank so the + // solver still has a value to compare against — this happens + // only on logic bugs but we degrade gracefully. + return Value::blank(); + } + // Reset the bump arena per-evaluation. The previous iteration's + // committed Text scalars survive this reset because + // `Sheet::set_cell_cached_value` deep-copies Text payloads into + // each cell's own `cached_text_owned` storage; Array results are + // similarly deep-copied into `SpillRegion::owned_strings` by + // `Sheet::commit_spill`. + arena_->reset(); + EvaluateCellOptions opts; + opts.spill_release_callback = pass.release_callback; + opts.spill_release_user_data = pass.release_user_data; + opts.read_observer = observer; + pass.dynamic.log().observe(c, component_ordinal, opts, nullptr); + pass.dynamic.log().note_iterative_member(c, component_ordinal); + Value result = evaluate_cell_for_recalc(pass.workbook, sheet, staged, c.row, c.col, pass.registry, *arena_, opts); + pass.dynamic.log().end(); + return result; + }; + auto evaluate_one = [&](CellNodeId c) { return evaluate_with(c, nullptr); }; + auto commit = [&](CellNodeId c, Value v) { + if (c.sheet_id >= sheet_count) { + return; + } + Sheet& sheet = pass.workbook.sheet(c.sheet_id); + sheet.set_cell_cached_value(c.row, c.col, v); + pass.dynamic.log().note_commit(c, component_ordinal); + }; + + const std::vector cells = cells_of_component(component); + if (!iterative_.enabled) { + // Excel judges circularity by what evaluation reads, so only a cycle + // that reads back into itself is one. A cycle through an OFFSET / + // INDIRECT read keeps its own treatment. + const bool settled = !closes_through_dynamic_edge(component, graph_) && + settle_component_by_reads(pass.workbook, cells, evaluate_with, commit); + if (arena_->exhausted()) { + return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during recalc"); + } + if (settled) { + pass.stats.cells_evaluated += static_cast(cells.size()); + return Expected::Ok(); + } + commit_unresolved_cycle_locked(pass, component); + return Expected::Ok(); + } + // A reader first evaluated as a singleton in this recalc has already + // stepped once; the solver starts from the value it showed before. + pass.dynamic.log().restore_prior_values(pass.workbook, cells); + const IterativeOutcome outcome = + run_iterative_solve(cells, iterative_, evaluate_one, commit, progress_cb_, progress_user_data_); + if (arena_->exhausted()) { + return make_error(FormulonErrorCode::kOutOfMemory, iterative_oom_message); + } + if (outcome.converged) { + // Solver wrote the converged values into the cell store; count + // each member as evaluated (singleton-style accounting) plus + // tagged as iterative. + pass.stats.cells_evaluated += static_cast(cells.size()); + pass.stats.iterative_cells += static_cast(cells.size()); + } else { + // Iteration-limit exhaustion or callback-driven abort leaves the + // last-iteration values in place. The user-visible failure mode is + // still "the cycle did not resolve", so we + // count the members in `cycle_cells` to mirror the + // disabled-iterative-calc accounting. + // A later pass can continue from the retained approximation. + pass.stats.cycle_cells += static_cast(cells.size()); + } + return Expected::Ok(); +} + bool RecalcEngine::drop_dynamic_only_cycles_locked(const std::vector>& sccs) { if (!graph_.has_source_edges(DepGraph::DependencySource::kDynamicReference)) { return false; @@ -615,13 +749,10 @@ Expected RecalcEngine::recalc_locked(Workbook& workbook, con RecalcStats stats; SpillReleaseQueue release_queue{&workbook}; const SpillReleaseCallback release_callback = &queue_spill_release; - std::size_t release_waves = 0U; - std::size_t dependency_waves = 0U; - std::size_t no_progress_waves = 0U; - std::vector previous_release_state; - std::vector previous_release_targets; - bool have_previous_release_state = false; + SpillWaveBudget wave_budget; DynamicReadPass dynamic(*this, workbook); + const SerialEvalPass pass{ + workbook, registry, dynamic, release_callback, &release_queue, stats, "evaluation arena exhausted during recalc"}; // Per-wave locals begin after this label and are destroyed on the backward // jump, while the counters, queue, and accumulated stats above persist. @@ -677,11 +808,6 @@ Expected RecalcEngine::recalc_locked(Workbook& workbook, con std::vector> sccs = graph_.tarjan_scc_subset(dirty_nodes); dynamic.settle_sccs(sccs, dirty_nodes); - // Index sheet pointers once so we can resolve `CellNodeId::sheet_id` to - // a `Sheet*` without a per-cell lookup. Workbook sheet count is small - // (typically <= a few dozen). - const std::size_t sheet_count = workbook.sheet_count(); - // ---- Phase 4: evaluate every dirty SCC. ---- // Also track which dirty cells have been visited via the SCC walk, so a // final sweep can pick up dirty cells with no graph edges (e.g. a @@ -711,138 +837,15 @@ Expected RecalcEngine::recalc_locked(Workbook& workbook, con visited_in_sccs.insert(c); } - // Evaluation glue for the read-ordered settle (iterative calc off) - // and the solver (on). `evaluate_with` mirrors the singleton path's - // evaluator glue: parse on the fly, dispatch through `evaluate()`, - // fold dynamic-array spills back into a scalar anchor. The `commit` - // lambda writes the new value into the cell store so the next - // evaluation reads the freshest value back. - // Hoisted out of `evaluate_one` so a staged formula's bytes stay live - // exactly as long as the arena contents of the same call: a string - // literal inside the formula surfaces in the returned Value as a view - // into this buffer, and the solver consumes each result before asking - // for the next one (which resets the arena). - Cell staged; - const std::uint64_t component_ordinal = dynamic.next_ordinal(); - auto evaluate_with = [&](CellNodeId c, EvalState* observer) -> Value { - if (c.sheet_id >= sheet_count) { - return Value::error(ErrorCode::Ref); - } - Sheet& sheet = workbook.sheet(c.sheet_id); - if (!stage_formula_cell(sheet, c.row, c.col, staged)) { - // No formula text: nothing to evaluate. Treat as Blank so the - // solver still has a value to compare against — this happens - // only on logic bugs but we degrade gracefully. - return Value::blank(); - } - // Reset the bump arena per-evaluation. The previous iteration's - // committed Text scalars survive this reset because - // `Sheet::set_cell_cached_value` deep-copies Text payloads into - // each cell's own `cached_text_owned` storage; Array results are - // similarly deep-copied into `SpillRegion::owned_strings` by - // `Sheet::commit_spill`. - arena_->reset(); - EvaluateCellOptions opts; - opts.spill_release_callback = release_callback; - opts.spill_release_user_data = &release_queue; - opts.read_observer = observer; - dynamic.log().observe(c, component_ordinal, opts, nullptr); - dynamic.log().note_iterative_member(c, component_ordinal); - Value result = evaluate_cell_for_recalc(workbook, sheet, staged, c.row, c.col, registry, *arena_, opts); - dynamic.log().end(); - return result; - }; - auto evaluate_one = [&](CellNodeId c) { return evaluate_with(c, nullptr); }; - auto commit = [&](CellNodeId c, Value v) { - if (c.sheet_id >= sheet_count) { - return; - } - Sheet& sheet = workbook.sheet(c.sheet_id); - sheet.set_cell_cached_value(c.row, c.col, v); - dynamic.log().note_commit(c, component_ordinal); - }; - - const std::vector cells = cells_of_component(component); - if (!iterative_.enabled) { - // Excel judges circularity by what evaluation reads, so only a cycle - // that reads back into itself is one. A cycle through an OFFSET / - // INDIRECT read keeps its own treatment. - const bool settled = !closes_through_dynamic_edge(component, graph_) && - settle_component_by_reads(workbook, cells, evaluate_with, commit); - if (arena_->exhausted()) { - return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during recalc"); - } - if (settled) { - stats.cells_evaluated += static_cast(cells.size()); - continue; - } - commit_unresolved_cycle_locked(workbook, component, dynamic, release_callback, &release_queue, stats); - continue; - } - // A reader first evaluated as a singleton in this recalc has already - // stepped once; the solver starts from the value it showed before. - dynamic.log().restore_prior_values(workbook, cells); - const IterativeOutcome outcome = - run_iterative_solve(cells, iterative_, evaluate_one, commit, progress_cb_, progress_user_data_); - if (arena_->exhausted()) { - return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during iterative recalc"); - } - if (outcome.converged) { - // Solver wrote the converged values into the cell store; count - // each member as evaluated (singleton-style accounting) plus - // tagged as iterative. - stats.cells_evaluated += static_cast(cells.size()); - stats.iterative_cells += static_cast(cells.size()); - } else { - // Iteration-limit exhaustion or callback-driven abort leaves the - // last-iteration values in place. The user-visible failure mode is - // still "the cycle did not resolve", so we - // count the members in `cycle_cells` to mirror the - // disabled-iterative-calc accounting. - // A later pass can continue from the retained approximation. - stats.cycle_cells += static_cast(cells.size()); - } + RETURN_IF_ERROR( + evaluate_cyclic_component_locked(pass, component, "evaluation arena exhausted during iterative recalc")); continue; } // Plain singleton: evaluate the cell. const CellNodeId only = component.front(); visited_in_sccs.insert(only); - if (only.sheet_id >= sheet_count) { - continue; - } - Sheet& sheet = workbook.sheet(only.sheet_id); - Cell staged; - if (!stage_formula_cell(sheet, only.row, only.col, staged)) { - // The dep graph may carry pure-input cells (read-only literals that - // someone reads via `add_dependency`). They have nothing to evaluate. - continue; - } - // Reset the per-pass arena before each evaluate so the bump allocator - // does not grow without bound across cells. Both result shapes - // already survive this reset: - // * Array results are deep-copied into sheet-owned storage by - // `Sheet::commit_spill` (driven by - // `EvalContext::dispatch_array_result`), keeping the spill table - // independent of the arena. - // * Scalar Text results are deep-copied into the destination cell's - // `Cell::cached_text_owned` by `Sheet::set_cell_cached_value` on - // the write below, so the cached `string_view` does not dangle - // when the next cell's evaluation resets the arena. - arena_->reset(); - EvaluateCellOptions opts; - opts.spill_release_callback = release_callback; - opts.spill_release_user_data = &release_queue; - const std::uint64_t ordinal = dynamic.next_ordinal(); - dynamic.log().observe(only, ordinal, opts, &sheet); - Value result = evaluate_cell_for_recalc(workbook, sheet, staged, only.row, only.col, registry, *arena_, opts); - dynamic.log().end(); - if (arena_->exhausted()) { - return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during recalc"); - } - sheet.set_cell_cached_value(only.row, only.col, result); - dynamic.log().note_commit(only, ordinal); - ++stats.cells_evaluated; + RETURN_IF_ERROR(evaluate_formula_cell_locked(pass, only)); } // ---- Phase 4b: defensive pickup for dirty cells not visited by Tarjan. ---- @@ -855,28 +858,7 @@ Expected RecalcEngine::recalc_locked(Workbook& workbook, con } }); for (CellNodeId c : standalone_dirty) { - if (c.sheet_id >= sheet_count) { - continue; - } - Sheet& sheet = workbook.sheet(c.sheet_id); - Cell staged; - if (!stage_formula_cell(sheet, c.row, c.col, staged)) { - continue; - } - arena_->reset(); - EvaluateCellOptions opts; - opts.spill_release_callback = release_callback; - opts.spill_release_user_data = &release_queue; - const std::uint64_t ordinal = dynamic.next_ordinal(); - dynamic.log().observe(c, ordinal, opts, &sheet); - Value result = evaluate_cell_for_recalc(workbook, sheet, staged, c.row, c.col, registry, *arena_, opts); - dynamic.log().end(); - if (arena_->exhausted()) { - return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during recalc"); - } - sheet.set_cell_cached_value(c.row, c.col, result); - dynamic.log().note_commit(c, ordinal); - ++stats.cells_evaluated; + RETURN_IF_ERROR(evaluate_formula_cell_locked(pass, c)); } // Reconcile compact-range -> spill-producer edges only after every cell in @@ -890,7 +872,7 @@ Expected RecalcEngine::recalc_locked(Workbook& workbook, con const bool dynamic_retry = !dynamic.end_wave(nullptr).empty(); const bool dependency_retry = !dependency_delta.added.empty() || dynamic_retry; if (dependency_retry) { - ++dependency_waves; + wave_budget.count_dependency_wave(); for (const DepGraph::Edge& edge : dependency_delta.added) { dirty_.mark(edge.first); } @@ -901,22 +883,9 @@ Expected RecalcEngine::recalc_locked(Workbook& workbook, con // of losing them when this pass clears its dirty set. const std::vector released = release_queue.take(); if (!released.empty() || dependency_retry) { - ++release_waves; - if (!released.empty()) { - const std::vector release_state = snapshot_blocked_spills(workbook); - const std::vector release_targets = canonical_release_targets(released); - if (have_previous_release_state && release_state == previous_release_state && - release_targets == previous_release_targets) { - ++no_progress_waves; - } else { - no_progress_waves = 0U; - } - previous_release_state = release_state; - previous_release_targets = release_targets; - have_previous_release_state = true; - } - if (release_waves > kMaxSpillReleaseWaves || dependency_waves > kMaxSpillReleaseWaves || - (!released.empty() && no_progress_waves >= kMaxNoProgressSpillWaves)) { + wave_budget.count_release_wave(); + wave_budget.observe_release(workbook, released); + if (wave_budget.exceeded(!released.empty())) { // Do not recurse through an unbounded chain of release waves. Preserve // the existing dirty set (including unrelated work) and keep the // release targets dirty for a caller retry after an external mutation. @@ -959,13 +928,15 @@ Expected RecalcEngine::partial_recalc_locked(Workbook& workb RecalcStats stats; SpillReleaseQueue release_queue{&workbook}; const SpillReleaseCallback release_callback = &queue_spill_release; - std::size_t release_waves = 0U; - std::size_t dependency_waves = 0U; - std::size_t no_progress_waves = 0U; - std::vector previous_release_state; - std::vector previous_release_targets; - bool have_previous_release_state = false; + SpillWaveBudget wave_budget; DynamicReadPass dynamic(*this, workbook); + const SerialEvalPass pass{workbook, + registry, + dynamic, + release_callback, + &release_queue, + stats, + "evaluation arena exhausted during partial recalc"}; // ---- Phase 0: validate the viewport. ---- // Empty viewport — collapsed row / column range, or unknown sheet — @@ -1217,72 +1188,8 @@ Expected RecalcEngine::partial_recalc_locked(Workbook& workb visited_in_sccs.insert(c); } - // Hoisted for the same reason as the full-recalc solver above: the - // staged bytes have to outlive each `evaluate_one` call, because a - // string literal in the formula surfaces in the result as a view - // into them. - Cell staged; - const std::uint64_t component_ordinal = dynamic.next_ordinal(); - auto evaluate_with = [&](CellNodeId c, EvalState* observer) -> Value { - if (c.sheet_id >= sheet_count) { - return Value::error(ErrorCode::Ref); - } - Sheet& sheet = workbook.sheet(c.sheet_id); - if (!stage_formula_cell(sheet, c.row, c.col, staged)) { - return Value::blank(); - } - arena_->reset(); - EvaluateCellOptions opts; - opts.spill_release_callback = release_callback; - opts.spill_release_user_data = &release_queue; - opts.read_observer = observer; - dynamic.log().observe(c, component_ordinal, opts, nullptr); - dynamic.log().note_iterative_member(c, component_ordinal); - Value result = evaluate_cell_for_recalc(workbook, sheet, staged, c.row, c.col, registry, *arena_, opts); - dynamic.log().end(); - return result; - }; - auto evaluate_one = [&](CellNodeId c) { return evaluate_with(c, nullptr); }; - auto commit = [&](CellNodeId c, Value v) { - if (c.sheet_id >= sheet_count) { - return; - } - Sheet& sheet = workbook.sheet(c.sheet_id); - sheet.set_cell_cached_value(c.row, c.col, v); - dynamic.log().note_commit(c, component_ordinal); - }; - - const std::vector cells = cells_of_component(component); - if (!iterative_.enabled) { - // Excel judges circularity by what evaluation reads, so only a cycle - // that reads back into itself is one. A cycle through an OFFSET / - // INDIRECT read keeps its own treatment. - const bool settled = !closes_through_dynamic_edge(component, graph_) && - settle_component_by_reads(workbook, cells, evaluate_with, commit); - if (arena_->exhausted()) { - return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during recalc"); - } - if (settled) { - stats.cells_evaluated += static_cast(cells.size()); - continue; - } - commit_unresolved_cycle_locked(workbook, component, dynamic, release_callback, &release_queue, stats); - continue; - } - dynamic.log().restore_prior_values(workbook, cells); - const IterativeOutcome outcome = - run_iterative_solve(cells, iterative_, evaluate_one, commit, progress_cb_, progress_user_data_); - if (arena_->exhausted()) { - return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during partial recalc"); - } - if (outcome.converged) { - stats.cells_evaluated += static_cast(cells.size()); - stats.iterative_cells += static_cast(cells.size()); - } else { - // A finite iteration budget leaves the solver's final approximation - // in place so a later viewport/full pass can resume from it. - stats.cycle_cells += static_cast(cells.size()); - } + RETURN_IF_ERROR( + evaluate_cyclic_component_locked(pass, component, "evaluation arena exhausted during partial recalc")); continue; } @@ -1293,28 +1200,7 @@ Expected RecalcEngine::partial_recalc_locked(Workbook& workb continue; } visited_in_sccs.insert(only); - if (only.sheet_id >= sheet_count) { - continue; - } - Sheet& sheet = workbook.sheet(only.sheet_id); - Cell staged; - if (!stage_formula_cell(sheet, only.row, only.col, staged)) { - continue; - } - arena_->reset(); - EvaluateCellOptions opts; - opts.spill_release_callback = release_callback; - opts.spill_release_user_data = &release_queue; - const std::uint64_t ordinal = dynamic.next_ordinal(); - dynamic.log().observe(only, ordinal, opts, &sheet); - Value result = evaluate_cell_for_recalc(workbook, sheet, staged, only.row, only.col, registry, *arena_, opts); - dynamic.log().end(); - if (arena_->exhausted()) { - return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during partial recalc"); - } - sheet.set_cell_cached_value(only.row, only.col, result); - dynamic.log().note_commit(only, ordinal); - ++stats.cells_evaluated; + RETURN_IF_ERROR(evaluate_formula_cell_locked(pass, only)); } // ---- Phase 4b: standalone dirty cells inside the closure. ---- @@ -1328,28 +1214,7 @@ Expected RecalcEngine::partial_recalc_locked(Workbook& workb } }); for (CellNodeId c : standalone_dirty) { - if (c.sheet_id >= sheet_count) { - continue; - } - Sheet& sheet = workbook.sheet(c.sheet_id); - Cell staged; - if (!stage_formula_cell(sheet, c.row, c.col, staged)) { - continue; - } - arena_->reset(); - EvaluateCellOptions opts; - opts.spill_release_callback = release_callback; - opts.spill_release_user_data = &release_queue; - const std::uint64_t ordinal = dynamic.next_ordinal(); - dynamic.log().observe(c, ordinal, opts, &sheet); - Value result = evaluate_cell_for_recalc(workbook, sheet, staged, c.row, c.col, registry, *arena_, opts); - dynamic.log().end(); - if (arena_->exhausted()) { - return make_error(FormulonErrorCode::kOutOfMemory, "evaluation arena exhausted during partial recalc"); - } - sheet.set_cell_cached_value(c.row, c.col, result); - dynamic.log().note_commit(c, ordinal); - ++stats.cells_evaluated; + RETURN_IF_ERROR(evaluate_formula_cell_locked(pass, c)); } // Reconcile globally after this partial wave. A newly discovered @@ -1412,24 +1277,13 @@ Expected RecalcEngine::partial_recalc_locked(Workbook& workb } if (release_in_closure || dependency_retry_in_closure) { if (!released.empty()) { - ++release_waves; - const std::vector release_state = snapshot_blocked_spills(workbook); - const std::vector release_targets = canonical_release_targets(released); - if (have_previous_release_state && release_state == previous_release_state && - release_targets == previous_release_targets) { - ++no_progress_waves; - } else { - no_progress_waves = 0U; - } - previous_release_state = release_state; - previous_release_targets = release_targets; - have_previous_release_state = true; + wave_budget.count_release_wave(); + wave_budget.observe_release(workbook, released); } if (dependency_retry_in_closure) { - ++dependency_waves; + wave_budget.count_dependency_wave(); } - if (release_waves > kMaxSpillReleaseWaves || dependency_waves > kMaxSpillReleaseWaves || - (!released.empty() && no_progress_waves >= kMaxNoProgressSpillWaves)) { + if (wave_budget.exceeded(!released.empty())) { return make_error(FormulonErrorCode::kGraphScheduleFailed, "recalc waves made no progress", "partial spill recovery or dynamic-reference retries exceeded the bounded wave budget"); } diff --git a/src/eval/recalc_engine.h b/src/eval/recalc_engine.h index 3bac4fa77..ad83f2eec 100644 --- a/src/eval/recalc_engine.h +++ b/src/eval/recalc_engine.h @@ -9,9 +9,8 @@ // * a `DirtySet` accumulating cells that need to be re-evaluated on the // next pass. // -// The engine itself is single-threaded; the multi-threaded scheduler that -// will eventually parallelise SCC evaluation lives in a later phase. For -// now the contract is intentionally narrow: +// The engine evaluates on the calling thread; the parallel scheduler in +// `scheduler.cpp` drives the same state through friend access. The contract: // // * `register_formula(cell, ast, workbook)` analyses `ast` (via // `extract_deps`) and updates the dep graph + volatile tracker for @@ -25,12 +24,13 @@ // formula text changes. // * `recalc(workbook, registry)` performs an end-to-end incremental // recalc: it seeds the dirty set with volatile cells, BFS-propagates -// dirtiness through reverse edges, runs Tarjan SCC over the *full* -// graph (so the reverse-topological order is preserved), and evaluates -// every dirty SCC in order. Singleton SCCs without a self-loop are -// evaluated via the tree walker; SCCs with cycles surface `#REF!` on -// every member (until the iterative solver lands in a follow-up -// bundle). The dirty set is cleared at the end of the pass. +// dirtiness through reverse edges, runs Tarjan SCC over the dirty +// induced subgraph (`tarjan_scc_subset`), and evaluates every dirty SCC +// in reverse-topological order. Singleton SCCs without a self-loop are +// evaluated via the tree walker; a cyclic SCC is settled by what its +// members read, or by the iterative solver when iterative calc is +// enabled, and otherwise surfaces `#REF!`. The dirty set is cleared at +// the end of the pass. #ifndef FORMULON_EVAL_RECALC_ENGINE_H_ #define FORMULON_EVAL_RECALC_ENGINE_H_ @@ -241,13 +241,14 @@ class RecalcEngine { /// regardless of upstream changes. /// 2. BFS-propagate dirtiness via `DepGraph::dependents_of` until no new /// cells are added. - /// 3. Compute Tarjan SCCs of the entire graph. Tarjan emits leaves + /// 3. Compute Tarjan SCCs of the dirty subgraph. Tarjan emits leaves /// first, which is exactly the order the evaluator wants (a cell's /// dependencies are evaluated before the cell itself). /// 4. Walk the SCC list and evaluate every component whose intersection /// with the dirty set is non-empty. Singleton SCCs without a - /// self-loop run through the tree walker; cyclic SCCs receive - /// `#REF!` on every member. + /// self-loop run through the tree walker; a cyclic SCC is settled by + /// what its members read, or by the iterative solver when iterative + /// calc is enabled, and otherwise surfaces `#REF!`. /// 5. Clear the dirty set. /// /// Returns `RecalcStats` describing the pass. The engine never propagates @@ -468,14 +469,35 @@ class RecalcEngine { bool first_wave_ = true; }; + /// What every evaluation site of one serial recalc call shares. + /// `oom_message` is that driver's arena-exhaustion error text. + struct SerialEvalPass { + Workbook& workbook; + const FunctionRegistry& registry; + DynamicReadPass& dynamic; + SpillReleaseCallback release_callback; + void* release_user_data; + RecalcStats& stats; + const char* oom_message; + }; + + /// Evaluates the formula at `cell` outside any cycle and commits its + /// result; a cell with no formula is left alone. + Expected evaluate_formula_cell_locked(const SerialEvalPass& pass, CellNodeId cell); + + /// Settles a cyclic component by its reads (iterative calc off) or runs + /// the iterative solver (on). A solve that exhausts the arena fails with + /// `iterative_oom_message`. + Expected evaluate_cyclic_component_locked(const SerialEvalPass& pass, + const std::vector& component, + const char* iterative_oom_message); + /// Commits a cyclic component iterative calc does not resolve: `#REF!` on /// every member, except that a cycle closing through an OFFSET / INDIRECT /// read restores each member's value from before this recalc, as Excel /// leaves a circular cell at its last value. Counts the members in - /// `stats.cycle_cells`. - void commit_unresolved_cycle_locked(Workbook& workbook, const std::vector& component, - DynamicReadPass& dynamic, SpillReleaseCallback release_callback, - void* release_user_data, RecalcStats& stats); + /// `pass.stats.cycle_cells`. + void commit_unresolved_cycle_locked(const SerialEvalPass& pass, const std::vector& component); // Serialises every mutating access to `graph_`, `volatiles_`, `dirty_`, // and `arena_`. Held for the full duration of each public entry; the diff --git a/src/eval/scheduler.cpp b/src/eval/scheduler.cpp index dd4021863..b1623eae9 100644 --- a/src/eval/scheduler.cpp +++ b/src/eval/scheduler.cpp @@ -65,13 +65,9 @@ using detail::RecalcReentryGuard; // The spill-release queue, the progress snapshot and the wave ceilings are // shared with the serial `RecalcEngine::recalc`; see `spill_release.h`. -using detail::BlockedSpillState; -using detail::canonical_release_targets; -using detail::kMaxNoProgressSpillWaves; -using detail::kMaxSpillReleaseWaves; using detail::queue_spill_release; -using detail::snapshot_blocked_spills; using detail::SpillReleaseQueue; +using detail::SpillWaveBudget; // Maps each cell in the dirty SCC list to its 0-based super-node id. SCC // indices are assigned in iteration order of `sccs_dirty`. @@ -617,12 +613,7 @@ Expected recalc_parallel_impl(Workbook& wb, const FunctionRegistry& std::uint64_t parallel_steps = 0; std::uint64_t serial_fallback_steps = 0; std::uint64_t cycle_recoveries = 0; - std::size_t release_waves = 0U; - std::size_t dependency_waves = 0U; - std::size_t no_progress_waves = 0U; - std::vector previous_release_state; - std::vector previous_release_targets; - bool have_previous_release_state = false; + SpillWaveBudget wave_budget; const std::uint32_t configured_worker_count = resolve_thread_count(cfg.num_threads); // Keep one pool for the complete pass. A spill/dependency retry is another // wave of the same recalc, not a new recalc invocation; workers therefore @@ -901,7 +892,7 @@ Expected recalc_parallel_impl(Workbook& wb, const FunctionRegistry& const bool dynamic_retry = !dynamic.end_wave(nullptr).empty(); const bool dependency_retry = !dependency_delta.added.empty() || dynamic_retry; if (dependency_retry) { - ++dependency_waves; + wave_budget.count_dependency_wave(); for (const DepGraph::Edge& edge : dependency_delta.added) { engine.dirty_.mark(edge.first); } @@ -909,22 +900,9 @@ Expected recalc_parallel_impl(Workbook& wb, const FunctionRegistry& const std::vector released = release_queue.take(); if (!released.empty() || dependency_retry) { - ++release_waves; - if (!released.empty()) { - const std::vector release_state = snapshot_blocked_spills(wb); - const std::vector release_targets = canonical_release_targets(released); - if (have_previous_release_state && release_state == previous_release_state && - release_targets == previous_release_targets) { - ++no_progress_waves; - } else { - no_progress_waves = 0U; - } - previous_release_state = release_state; - previous_release_targets = release_targets; - have_previous_release_state = true; - } - if (release_waves > kMaxSpillReleaseWaves || dependency_waves > kMaxSpillReleaseWaves || - (!released.empty() && no_progress_waves >= kMaxNoProgressSpillWaves)) { + wave_budget.count_release_wave(); + wave_budget.observe_release(wb, released); + if (wave_budget.exceeded(!released.empty())) { // Keep the current dirty set, including unrelated work, while // retaining the release targets for a caller retry after an // external mutation. diff --git a/src/eval/spill_release.cpp b/src/eval/spill_release.cpp index 8a900347a..ec83f4618 100644 --- a/src/eval/spill_release.cpp +++ b/src/eval/spill_release.cpp @@ -75,4 +75,20 @@ std::vector canonical_release_targets(const std::vector& return sorted; } +void SpillWaveBudget::observe_release(const Workbook& workbook, const std::vector& released) { + if (released.empty()) { + return; + } + const std::vector release_state = snapshot_blocked_spills(workbook); + const std::vector release_targets = canonical_release_targets(released); + if (have_previous_ && release_state == previous_state_ && release_targets == previous_targets_) { + ++no_progress_waves_; + } else { + no_progress_waves_ = 0U; + } + previous_state_ = release_state; + previous_targets_ = release_targets; + have_previous_ = true; +} + } // namespace formulon::eval::detail diff --git a/src/eval/spill_release.h b/src/eval/spill_release.h index 1d5676a56..34e48c948 100644 --- a/src/eval/spill_release.h +++ b/src/eval/spill_release.h @@ -89,6 +89,34 @@ std::vector canonical_release_targets(const std::vector& constexpr std::size_t kMaxSpillReleaseWaves = 4096U; constexpr std::size_t kMaxNoProgressSpillWaves = 8U; +/// Wave counters and the no-progress snapshot of one recalc call's release +/// loop. Each driver decides which waves it counts; the ceilings are shared. +class SpillWaveBudget { + public: + void count_release_wave() noexcept { ++release_waves_; } + void count_dependency_wave() noexcept { ++dependency_waves_; } + + /// Counts a no-progress wave when the blocked geometry and the woken + /// targets both match the previous release, and resets the count + /// otherwise. No-op when `released` is empty. + void observe_release(const Workbook& workbook, const std::vector& released); + + /// Whether a ceiling is exceeded. The no-progress ceiling applies only to + /// a wave that released anchors. + bool exceeded(bool released_any) const noexcept { + return release_waves_ > kMaxSpillReleaseWaves || dependency_waves_ > kMaxSpillReleaseWaves || + (released_any && no_progress_waves_ >= kMaxNoProgressSpillWaves); + } + + private: + std::size_t release_waves_ = 0U; + std::size_t dependency_waves_ = 0U; + std::size_t no_progress_waves_ = 0U; + std::vector previous_state_; + std::vector previous_targets_; + bool have_previous_ = false; +}; + } // namespace formulon::eval::detail #endif // FORMULON_EVAL_SPILL_RELEASE_H_ From 9d232b26d84bcf5acd89e945c8fb9e489b96276f Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 16:01:54 +0900 Subject: [PATCH 17/93] refactor(eval): move the wildcard matcher out of criteria into wildcard.{h,cpp} - split the wildcard matcher bodies from eval/criteria.cpp into eval/wildcard.cpp, declared in eval/wildcard.h - SEARCH/MATCH/XLOOKUP callers (text.cpp, text_dbcs.cpp, lookups/classic.cpp, lookups/xlookup.cpp) include eval/wildcard.h instead of criteria.h - move the wildcard tests from criteria_test.cpp to wildcard_test.cpp and register both new sources in CMake - fix the `?` doc comment to say it matches one code point, as the code does - list the new files in tools/dev/coverage_report.py and run_mutation.sh --- CMakeLists.txt | 1 + src/eval/builtins/text.cpp | 2 +- src/eval/builtins/text_dbcs.cpp | 2 +- src/eval/criteria.cpp | 196 +-------------------------- src/eval/criteria.h | 47 +------ src/eval/lookups/classic.cpp | 2 +- src/eval/lookups/xlookup.cpp | 2 +- src/eval/wildcard.cpp | 213 ++++++++++++++++++++++++++++++ src/eval/wildcard.h | 61 +++++++++ tests/CMakeLists.txt | 1 + tests/unit/eval/criteria_test.cpp | 94 ------------- tests/unit/eval/wildcard_test.cpp | 112 ++++++++++++++++ tools/dev/coverage_report.py | 3 + tools/dev/run_mutation.sh | 3 +- 14 files changed, 400 insertions(+), 339 deletions(-) create mode 100644 src/eval/wildcard.cpp create mode 100644 src/eval/wildcard.h create mode 100644 tests/unit/eval/wildcard_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 98f191acd..b2ffe197c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -310,6 +310,7 @@ set(FORMULON_CORE_SOURCES src/eval/external_ref.cpp src/eval/eval_state.cpp src/eval/criteria.cpp + src/eval/wildcard.cpp src/eval/jp_fold.cpp src/eval/jp_kana_table.cpp src/eval/jis0208_table.cpp diff --git a/src/eval/builtins/text.cpp b/src/eval/builtins/text.cpp index 35a2c866f..96f1f5119 100644 --- a/src/eval/builtins/text.cpp +++ b/src/eval/builtins/text.cpp @@ -22,9 +22,9 @@ #include "eval/builtins/registration_helpers.h" #include "eval/builtins/text_detail.h" #include "eval/coerce.h" -#include "eval/criteria.h" #include "eval/function_registry.h" #include "eval/jis0208_table.h" +#include "eval/wildcard.h" #include "utils/arena.h" #include "utils/text_ops.h" #include "utils/utf8_length.h" diff --git a/src/eval/builtins/text_dbcs.cpp b/src/eval/builtins/text_dbcs.cpp index 491d343dd..bf5d24ed4 100644 --- a/src/eval/builtins/text_dbcs.cpp +++ b/src/eval/builtins/text_dbcs.cpp @@ -12,7 +12,7 @@ #include "eval/builtins/text_detail.h" #include "eval/coerce.h" -#include "eval/criteria.h" +#include "eval/wildcard.h" #include "utils/arena.h" #include "utils/expected.h" #include "utils/text_ops.h" diff --git a/src/eval/criteria.cpp b/src/eval/criteria.cpp index 90185de10..5456becd2 100644 --- a/src/eval/criteria.cpp +++ b/src/eval/criteria.cpp @@ -12,9 +12,9 @@ #include "eval/coerce.h" #include "eval/date_text_parse.h" #include "eval/jp_fold.h" +#include "eval/wildcard.h" #include "utils/strings.h" #include "utils/text_ops.h" -#include "utils/utf8_length.h" #include "value.h" namespace formulon { @@ -143,24 +143,6 @@ std::string fold_criteria_text_for_profile(std::string_view input, ExcelProfile return fold_fullwidth_digits_only(input); } -} // namespace - -bool scan_has_wildcard(std::string_view rhs) { - for (std::size_t i = 0; i < rhs.size(); ++i) { - const char c = rhs[i]; - if (c == '~' && i + 1 < rhs.size()) { - ++i; // Skip the escaped byte. - continue; - } - if (c == '*' || c == '?') { - return true; - } - } - return false; -} - -namespace { - // Scans `rhs` for a `~` that does not escape `*` or `?` (the only two // metacharacters Excel's wildcard dialect documents). Used by // `parse_criterion` to flag malformed criteria that Mac Excel 365 rejects @@ -186,184 +168,8 @@ bool scan_has_invalid_wildcard_escape(std::string_view rhs) { return false; } -// Core two-pointer wildcard matcher. When `prefix_match_ok` is false the -// pattern must consume `text` exactly (SEARCH whole-string / criteria -// equality semantics). When `prefix_match_ok` is true the match succeeds -// as soon as the pattern is exhausted, even if `text` has unmatched bytes -// remaining — used by `wildcard_find` to answer "does the pattern match a -// prefix of this suffix?". When the match succeeds in prefix mode, -// `*out_consumed` receives the number of `text` bytes the pattern -// consumed. -// -// Character comparisons are ASCII case-insensitive to match Excel's -// criteria semantics (e.g. `*blue` matches `BLUE`). Non-ASCII bytes are -// compared byte-for-byte, consistent with `strings::case_insensitive_eq`. -// UTF-8 continuation: returns the byte length of the code point that -// starts at `text[i]`, capped at `text.size() - i`. A leading byte with -// an invalid continuation pattern falls back to 1 byte (mirrors Excel's -// lenient "one display unit" heuristic for broken UTF-8). -std::size_t utf8_codepoint_bytes(std::string_view text, std::size_t i) { - if (i >= text.size()) { - return 0; - } - const auto lead = static_cast(text[i]); - std::size_t len = 1; - if ((lead & 0x80u) == 0x00u) { - len = 1; - } else if ((lead & 0xE0u) == 0xC0u) { - len = 2; - } else if ((lead & 0xF0u) == 0xE0u) { - len = 3; - } else if ((lead & 0xF8u) == 0xF0u) { - len = 4; - } - if (i + len > text.size()) { - len = text.size() - i; - } - return len; -} - -bool wildcard_match_impl(std::string_view pattern, std::string_view text, bool prefix_match_ok, - std::size_t* out_consumed, bool question_is_byte = false) { - std::size_t pi = 0; - std::size_t ti = 0; - std::size_t star_pi = std::string_view::npos; - std::size_t star_ti = 0; - while (ti < text.size()) { - // Prefix mode: succeed as soon as the pattern is fully consumed, even - // if there is unmatched text remaining. - if (prefix_match_ok && pi == pattern.size()) { - if (out_consumed != nullptr) { - *out_consumed = ti; - } - return true; - } - if (pi < pattern.size()) { - const char pc = pattern[pi]; - if (pc == '~' && pi + 1 < pattern.size()) { - // Escaped literal: match the next pattern byte case-insensitively - // (Excel preserves the overall case-insensitive rule even for - // escape-protected characters). - if (strings::ascii_to_lower(pattern[pi + 1]) == strings::ascii_to_lower(text[ti])) { - pi += 2; - ++ti; - continue; - } - } else if (pc == '~' && pi + 1 == pattern.size()) { - // Trailing unpaired `~`: Excel treats it as a no-op (the escape - // consumes nothing of the text), so the remaining pattern is - // effectively empty — succeed if we're in prefix mode or if the - // text is also exhausted at this point. - ++pi; - continue; - } else if (pc == '*') { - star_pi = pi; - star_ti = ti; - ++pi; - continue; - } else if (pc == '?') { - if (question_is_byte) { - // SEARCHB byte-mode `?`: the pattern character matches only a - // codepoint whose ja-JP DBCS cost is 1 (ASCII or half-width - // katakana). Anything wider (kanji, hiragana, full-width, emoji) - // refuses to match here, falling through to the `*`-backtrack / - // failure path below. - std::size_t cp_bytes = 0; - const std::uint32_t cp = decode_utf8_step(text, ti, &cp_bytes); - if (byte_count_jajp(cp) == 1) { - ++pi; - ti += cp_bytes; - continue; - } - } else { - // Default mode: `?` matches exactly one UTF-8 code point (1-4 - // bytes) rather than a single byte, so multibyte scripts (CJK, - // emoji, combining marks treated as atomic) match as Excel users - // would expect. - const std::size_t cp_bytes = utf8_codepoint_bytes(text, ti); - ++pi; - ti += cp_bytes; - continue; - } - } else if (strings::ascii_to_lower(pc) == strings::ascii_to_lower(text[ti])) { - ++pi; - ++ti; - continue; - } - } - if (star_pi != std::string_view::npos) { - // Backtrack: extend the most-recent `*` by one CODEPOINT of text. This - // must advance a whole UTF-8 codepoint, not a single byte — otherwise - // `star_ti` can land mid-sequence and a following `?` or literal would - // match a continuation byte (e.g. `*??c*` against `あcう` split the - // 3-byte `あ`). Codepoint-stepping keeps every subsequent match aligned. - pi = star_pi + 1; - const std::size_t step = utf8_codepoint_bytes(text, star_ti); - star_ti += (step == 0 ? 1 : step); - ti = star_ti; - continue; - } - return false; - } - // Trailing `*`s consume nothing. - while (pi < pattern.size() && pattern[pi] == '*') { - ++pi; - } - // A single unpaired trailing `~` also consumes nothing (Excel leniency). - // This must be the very last pattern char, otherwise it is part of an - // escape pair `~X` and that pair requires a text char to match. - if (pi + 1 == pattern.size() && pattern[pi] == '~') { - ++pi; - } - const bool ok = (pi == pattern.size()); - if (ok && out_consumed != nullptr) { - *out_consumed = ti; - } - return ok; -} - } // namespace -bool wildcard_match(std::string_view pattern, std::string_view text) { - return wildcard_match_impl(pattern, text, /*prefix_match_ok=*/false, /*out_consumed=*/nullptr, - /*question_is_byte=*/false); -} - -std::size_t wildcard_find(std::string_view pattern, std::string_view text) { - // Try each start offset in `text` and attempt a prefix match. Because `*` - // already permits zero-or-more bytes, a leading `*` in the pattern makes - // offset 0 always match — but the loop below is still correct in that - // case (it would find offset 0 on the first iteration). - for (std::size_t start = 0; start <= text.size(); ++start) { - if (wildcard_match_impl(pattern, text.substr(start), /*prefix_match_ok=*/true, - /*out_consumed=*/nullptr, /*question_is_byte=*/false)) { - return start; - } - } - return std::string_view::npos; -} - -std::size_t wildcard_find_dbcs(std::string_view pattern, std::string_view text) { - // Iterate start positions over codepoint boundaries (not byte-by-byte) so - // the returned offset is always codepoint-aligned. SEARCHB looks the - // offset up against `build_dbcs_char_map`'s `byte_offset` records via - // direct equality; a misaligned offset would silently route into the - // defensive `total_dbcs + 1` fallback. - std::size_t start = 0; - while (start <= text.size()) { - if (wildcard_match_impl(pattern, text.substr(start), /*prefix_match_ok=*/true, - /*out_consumed=*/nullptr, /*question_is_byte=*/true)) { - return start; - } - if (start == text.size()) { - break; - } - const std::size_t step = utf8_codepoint_bytes(text, start); - start += (step == 0 ? 1 : step); - } - return std::string_view::npos; -} - void ParsedCriterion::rebind_rhs_text() noexcept { if (rhs_text_owns_storage_) { rhs_text = rhs_storage; diff --git a/src/eval/criteria.h b/src/eval/criteria.h index 11a20204e..13ad7605a 100644 --- a/src/eval/criteria.h +++ b/src/eval/criteria.h @@ -20,9 +20,8 @@ // Wildcard matching: `?` matches exactly one Unicode codepoint, `*` matches // any run of codepoints, and `~` escapes the next metacharacter. The matcher // walks the text codepoint-by-codepoint (including `*` backtracking), so -// multibyte UTF-8 (CJK, emoji) behaves as Excel users expect. A separate -// byte-oriented variant (`wildcard_find_dbcs`) backs SEARCHB's ja-JP DBCS -// rule; see its declaration below. +// multibyte UTF-8 (CJK, emoji) behaves as Excel users expect. The matcher +// itself lives in `wildcard.h`. #ifndef FORMULON_EVAL_CRITERIA_H_ #define FORMULON_EVAL_CRITERIA_H_ @@ -234,48 +233,6 @@ bool matches_criterion(const Value& cell, const ParsedCriterion& criterion); /// callers with an `EvalContext` should use this overload. bool matches_criterion(const Value& cell, const ParsedCriterion& criterion, ExcelProfile profile); -/// Byte-level wildcard matcher. `pattern` may contain unescaped `*` / `?` -/// and `~`-escaped literals; `text` is matched verbatim. Shared between -/// `matches_criterion` (for `COUNTIF`/`SUMIF`/`AVERAGEIF` equality paths) -/// and scalar lookups such as `MATCH(..., 0)` that honour the same -/// DOS-style wildcard dialect. -/// -/// Iterative backtrack (no recursion) keeps stack depth constant. See the -/// header-level divergence note: `?` matches one byte, not one code point. -bool wildcard_match(std::string_view pattern, std::string_view text); - -/// Scans `rhs` for an unescaped `*` or `?`. A leading `~` escapes the next -/// byte, so `"~*"` contains no wildcard but `"*~*"` does. Returns `true` on -/// the first unescaped metacharacter encountered. -bool scan_has_wildcard(std::string_view rhs); - -/// Returns the byte offset where `pattern` first matches a prefix of -/// `text` starting at that offset (wildcards: `*`, `?`, `~` for escape). -/// Returns `std::string_view::npos` when no position in `text` begins a -/// match. The match is anchored at its start but not at its end — it -/// succeeds as soon as `pattern` is consumed, regardless of any unmatched -/// suffix in `text`. This mirrors the SEARCH contract where the pattern -/// need only match somewhere inside the haystack. -/// -/// Callers that need case-insensitive matching must lower-case both inputs -/// first (the matcher is byte-exact). UTF-16 offset mapping is likewise the -/// caller's responsibility; this helper only reports a byte offset into -/// `text`. -std::size_t wildcard_find(std::string_view pattern, std::string_view text); - -/// Same contract as `wildcard_find`, but `?` matches only a single -/// SBCS-byte codepoint under the ja-JP DBCS rule (`byte_count_jajp(cp) -/// == 1`): ASCII and half-width katakana match, every other codepoint -/// (kanji, hiragana, full-width punctuation, emoji) refuses to match. -/// `*`, `~?`, `~*`, and literal characters retain their normal -/// semantics. Used exclusively by SEARCHB to mirror Mac Excel 365's -/// byte-oriented wildcard dialect. -/// -/// The returned offset is always aligned to a UTF-8 codepoint boundary in -/// `text`, so callers translating the offset to a DBCS position (via -/// `build_dbcs_char_map`) can use direct equality on `byte_offset`. -std::size_t wildcard_find_dbcs(std::string_view pattern, std::string_view text); - } // namespace eval } // namespace formulon diff --git a/src/eval/lookups/classic.cpp b/src/eval/lookups/classic.cpp index 04f7b7c96..0ea614201 100644 --- a/src/eval/lookups/classic.cpp +++ b/src/eval/lookups/classic.cpp @@ -15,7 +15,6 @@ #include #include "eval/coerce.h" -#include "eval/criteria.h" #include "eval/declared_rect.h" #include "eval/dynamic_array/common.h" #include "eval/eval_context.h" @@ -25,6 +24,7 @@ #include "eval/lookups/common.h" #include "eval/name_env_resolve.h" #include "eval/range_args.h" +#include "eval/wildcard.h" #include "parser/ast.h" #include "utils/arena.h" #include "utils/expected.h" diff --git a/src/eval/lookups/xlookup.cpp b/src/eval/lookups/xlookup.cpp index 9ca9136a7..b0dff2f95 100644 --- a/src/eval/lookups/xlookup.cpp +++ b/src/eval/lookups/xlookup.cpp @@ -15,7 +15,6 @@ #include "eval/array_alloc.h" #include "eval/coerce.h" -#include "eval/criteria.h" #include "eval/eval_context.h" #include "eval/function_registry.h" #include "eval/jp_fold.h" @@ -23,6 +22,7 @@ #include "eval/omitted_arg.h" #include "eval/range_args.h" #include "eval/range_resolvers.h" +#include "eval/wildcard.h" #include "parser/ast.h" #include "utils/arena.h" #include "utils/strings.h" diff --git a/src/eval/wildcard.cpp b/src/eval/wildcard.cpp new file mode 100644 index 000000000..8714af8dc --- /dev/null +++ b/src/eval/wildcard.cpp @@ -0,0 +1,213 @@ +// +// Excel's `*` / `?` / `~` wildcard dialect used by criteria matching and the +// text and lookup builtins. See `wildcard.h` for the authoritative contract. + +#include "eval/wildcard.h" + +#include +#include +#include + +#include "utils/strings.h" +#include "utils/text_ops.h" +#include "utils/utf8_length.h" + +namespace formulon { +namespace eval { + +bool scan_has_wildcard(std::string_view rhs) { + for (std::size_t i = 0; i < rhs.size(); ++i) { + const char c = rhs[i]; + if (c == '~' && i + 1 < rhs.size()) { + ++i; // Skip the escaped byte. + continue; + } + if (c == '*' || c == '?') { + return true; + } + } + return false; +} + +namespace { + +// Core two-pointer wildcard matcher. When `prefix_match_ok` is false the +// pattern must consume `text` exactly (SEARCH whole-string / criteria +// equality semantics). When `prefix_match_ok` is true the match succeeds +// as soon as the pattern is exhausted, even if `text` has unmatched bytes +// remaining — used by `wildcard_find` to answer "does the pattern match a +// prefix of this suffix?". When the match succeeds in prefix mode, +// `*out_consumed` receives the number of `text` bytes the pattern +// consumed. +// +// Character comparisons are ASCII case-insensitive to match Excel's +// criteria semantics (e.g. `*blue` matches `BLUE`). Non-ASCII bytes are +// compared byte-for-byte, consistent with `strings::case_insensitive_eq`. +// UTF-8 continuation: returns the byte length of the code point that +// starts at `text[i]`, capped at `text.size() - i`. A leading byte with +// an invalid continuation pattern falls back to 1 byte (mirrors Excel's +// lenient "one display unit" heuristic for broken UTF-8). +std::size_t utf8_codepoint_bytes(std::string_view text, std::size_t i) { + if (i >= text.size()) { + return 0; + } + const auto lead = static_cast(text[i]); + std::size_t len = 1; + if ((lead & 0x80u) == 0x00u) { + len = 1; + } else if ((lead & 0xE0u) == 0xC0u) { + len = 2; + } else if ((lead & 0xF0u) == 0xE0u) { + len = 3; + } else if ((lead & 0xF8u) == 0xF0u) { + len = 4; + } + if (i + len > text.size()) { + len = text.size() - i; + } + return len; +} + +bool wildcard_match_impl(std::string_view pattern, std::string_view text, bool prefix_match_ok, + std::size_t* out_consumed, bool question_is_byte = false) { + std::size_t pi = 0; + std::size_t ti = 0; + std::size_t star_pi = std::string_view::npos; + std::size_t star_ti = 0; + while (ti < text.size()) { + // Prefix mode: succeed as soon as the pattern is fully consumed, even + // if there is unmatched text remaining. + if (prefix_match_ok && pi == pattern.size()) { + if (out_consumed != nullptr) { + *out_consumed = ti; + } + return true; + } + if (pi < pattern.size()) { + const char pc = pattern[pi]; + if (pc == '~' && pi + 1 < pattern.size()) { + // Escaped literal: match the next pattern byte case-insensitively + // (Excel preserves the overall case-insensitive rule even for + // escape-protected characters). + if (strings::ascii_to_lower(pattern[pi + 1]) == strings::ascii_to_lower(text[ti])) { + pi += 2; + ++ti; + continue; + } + } else if (pc == '~' && pi + 1 == pattern.size()) { + // Trailing unpaired `~`: Excel treats it as a no-op (the escape + // consumes nothing of the text), so the remaining pattern is + // effectively empty — succeed if we're in prefix mode or if the + // text is also exhausted at this point. + ++pi; + continue; + } else if (pc == '*') { + star_pi = pi; + star_ti = ti; + ++pi; + continue; + } else if (pc == '?') { + if (question_is_byte) { + // SEARCHB byte-mode `?`: the pattern character matches only a + // codepoint whose ja-JP DBCS cost is 1 (ASCII or half-width + // katakana). Anything wider (kanji, hiragana, full-width, emoji) + // refuses to match here, falling through to the `*`-backtrack / + // failure path below. + std::size_t cp_bytes = 0; + const std::uint32_t cp = decode_utf8_step(text, ti, &cp_bytes); + if (byte_count_jajp(cp) == 1) { + ++pi; + ti += cp_bytes; + continue; + } + } else { + // Default mode: `?` matches exactly one UTF-8 code point (1-4 + // bytes) rather than a single byte, so multibyte scripts (CJK, + // emoji, combining marks treated as atomic) match as Excel users + // would expect. + const std::size_t cp_bytes = utf8_codepoint_bytes(text, ti); + ++pi; + ti += cp_bytes; + continue; + } + } else if (strings::ascii_to_lower(pc) == strings::ascii_to_lower(text[ti])) { + ++pi; + ++ti; + continue; + } + } + if (star_pi != std::string_view::npos) { + // Backtrack: extend the most-recent `*` by one CODEPOINT of text. This + // must advance a whole UTF-8 codepoint, not a single byte — otherwise + // `star_ti` can land mid-sequence and a following `?` or literal would + // match a continuation byte (e.g. `*??c*` against `あcう` split the + // 3-byte `あ`). Codepoint-stepping keeps every subsequent match aligned. + pi = star_pi + 1; + const std::size_t step = utf8_codepoint_bytes(text, star_ti); + star_ti += (step == 0 ? 1 : step); + ti = star_ti; + continue; + } + return false; + } + // Trailing `*`s consume nothing. + while (pi < pattern.size() && pattern[pi] == '*') { + ++pi; + } + // A single unpaired trailing `~` also consumes nothing (Excel leniency). + // This must be the very last pattern char, otherwise it is part of an + // escape pair `~X` and that pair requires a text char to match. + if (pi + 1 == pattern.size() && pattern[pi] == '~') { + ++pi; + } + const bool ok = (pi == pattern.size()); + if (ok && out_consumed != nullptr) { + *out_consumed = ti; + } + return ok; +} + +} // namespace + +bool wildcard_match(std::string_view pattern, std::string_view text) { + return wildcard_match_impl(pattern, text, /*prefix_match_ok=*/false, /*out_consumed=*/nullptr, + /*question_is_byte=*/false); +} + +std::size_t wildcard_find(std::string_view pattern, std::string_view text) { + // Try each start offset in `text` and attempt a prefix match. Because `*` + // already permits zero-or-more bytes, a leading `*` in the pattern makes + // offset 0 always match — but the loop below is still correct in that + // case (it would find offset 0 on the first iteration). + for (std::size_t start = 0; start <= text.size(); ++start) { + if (wildcard_match_impl(pattern, text.substr(start), /*prefix_match_ok=*/true, + /*out_consumed=*/nullptr, /*question_is_byte=*/false)) { + return start; + } + } + return std::string_view::npos; +} + +std::size_t wildcard_find_dbcs(std::string_view pattern, std::string_view text) { + // Iterate start positions over codepoint boundaries (not byte-by-byte) so + // the returned offset is always codepoint-aligned. SEARCHB looks the + // offset up against `build_dbcs_char_map`'s `byte_offset` records via + // direct equality; a misaligned offset would silently route into the + // defensive `total_dbcs + 1` fallback. + std::size_t start = 0; + while (start <= text.size()) { + if (wildcard_match_impl(pattern, text.substr(start), /*prefix_match_ok=*/true, + /*out_consumed=*/nullptr, /*question_is_byte=*/true)) { + return start; + } + if (start == text.size()) { + break; + } + const std::size_t step = utf8_codepoint_bytes(text, start); + start += (step == 0 ? 1 : step); + } + return std::string_view::npos; +} + +} // namespace eval +} // namespace formulon diff --git a/src/eval/wildcard.h b/src/eval/wildcard.h new file mode 100644 index 000000000..7b1b7956e --- /dev/null +++ b/src/eval/wildcard.h @@ -0,0 +1,61 @@ +// +// Excel's wildcard dialect: `*` matches any run of codepoints, `?` matches +// exactly one codepoint, and `~` escapes the next metacharacter. Matching is +// ASCII case-insensitive and walks UTF-8 codepoint-by-codepoint. Shared by +// the criteria matcher, SEARCH/SEARCHB and the exact-match lookups. + +#ifndef FORMULON_EVAL_WILDCARD_H_ +#define FORMULON_EVAL_WILDCARD_H_ + +#include +#include + +namespace formulon { +namespace eval { + +/// Byte-level wildcard matcher. `pattern` may contain unescaped `*` / `?` +/// and `~`-escaped literals; `text` is matched verbatim. Shared between +/// `matches_criterion` (for `COUNTIF`/`SUMIF`/`AVERAGEIF` equality paths) +/// and scalar lookups such as `MATCH(..., 0)` that honour the same +/// DOS-style wildcard dialect. +/// +/// Iterative backtrack (no recursion) keeps stack depth constant. `?` +/// matches exactly one code point (1-4 UTF-8 bytes), not one byte. +bool wildcard_match(std::string_view pattern, std::string_view text); + +/// Scans `rhs` for an unescaped `*` or `?`. A leading `~` escapes the next +/// byte, so `"~*"` contains no wildcard but `"*~*"` does. Returns `true` on +/// the first unescaped metacharacter encountered. +bool scan_has_wildcard(std::string_view rhs); + +/// Returns the byte offset where `pattern` first matches a prefix of +/// `text` starting at that offset (wildcards: `*`, `?`, `~` for escape). +/// Returns `std::string_view::npos` when no position in `text` begins a +/// match. The match is anchored at its start but not at its end — it +/// succeeds as soon as `pattern` is consumed, regardless of any unmatched +/// suffix in `text`. This mirrors the SEARCH contract where the pattern +/// need only match somewhere inside the haystack. +/// +/// Callers that need case-insensitive matching must lower-case both inputs +/// first (the matcher is byte-exact). UTF-16 offset mapping is likewise the +/// caller's responsibility; this helper only reports a byte offset into +/// `text`. +std::size_t wildcard_find(std::string_view pattern, std::string_view text); + +/// Same contract as `wildcard_find`, but `?` matches only a single +/// SBCS-byte codepoint under the ja-JP DBCS rule (`byte_count_jajp(cp) +/// == 1`): ASCII and half-width katakana match, every other codepoint +/// (kanji, hiragana, full-width punctuation, emoji) refuses to match. +/// `*`, `~?`, `~*`, and literal characters retain their normal +/// semantics. Used exclusively by SEARCHB to mirror Mac Excel 365's +/// byte-oriented wildcard dialect. +/// +/// The returned offset is always aligned to a UTF-8 codepoint boundary in +/// `text`, so callers translating the offset to a DBCS position (via +/// `build_dbcs_char_map`) can use direct equality on `byte_offset`. +std::size_t wildcard_find_dbcs(std::string_view pattern, std::string_view text); + +} // namespace eval +} // namespace formulon + +#endif // FORMULON_EVAL_WILDCARD_H_ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0098e990d..449529c3b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -181,6 +181,7 @@ set(FORMULON_UNIT_TEST_SOURCES unit/eval/stats/incomplete_beta_test.cpp unit/eval/coerce_test.cpp unit/eval/criteria_test.cpp + unit/eval/wildcard_test.cpp unit/eval/jp_fold_test.cpp unit/eval/jis0208_table_test.cpp unit/eval/builtins_countif_test.cpp diff --git a/tests/unit/eval/criteria_test.cpp b/tests/unit/eval/criteria_test.cpp index 207a728cd..526504988 100644 --- a/tests/unit/eval/criteria_test.cpp +++ b/tests/unit/eval/criteria_test.cpp @@ -362,50 +362,6 @@ TEST(CriteriaMatchWildcard, StarAloneMatchesTextCellsOnly) { EXPECT_FALSE(matches_criterion(Value::blank(), c)); } -// --------------------------------------------------------------------------- -// wildcard_match: codepoint-aware `*` backtracking and `?` matching. -// --------------------------------------------------------------------------- - -TEST(WildcardMatchCodepoint, StarBacktrackAdvancesWholeCodepoints) { - // Regression: `*` backtracking used to advance one BYTE, letting a - // following `?` match a UTF-8 continuation byte and spuriously match. - // `*??c*` against "あcう" must NOT match (only 3 codepoints: あ, c, う — - // there is no "c" two codepoints in from any start after consuming two). - EXPECT_FALSE(wildcard_match("*??c*", - "\xE3\x81\x82" - "c" - "\xE3\x81\x86")); -} - -TEST(WildcardMatchCodepoint, QuestionMatchesExactlyOneCodepoint) { - // "?" matches a single multibyte codepoint. - EXPECT_TRUE(wildcard_match("?", "\xE3\x81\x82")); // あ - EXPECT_FALSE(wildcard_match("?", - "\xE3\x81\x82" - "\xE3\x81\x84")); // あい (2 cp) - EXPECT_FALSE(wildcard_match("??", "\xE3\x81\x82")); // one cp cannot fill two ? -} - -TEST(WildcardMatchCodepoint, QuestionMatchesSupplementaryPlaneEmoji) { - // A 4-byte codepoint (U+1F600) is one "?" unit. - EXPECT_TRUE(wildcard_match("?", "\xF0\x9F\x98\x80")); - EXPECT_TRUE(wildcard_match("a?b", - "a" - "\xF0\x9F\x98\x80" - "b")); -} - -TEST(WildcardMatchCodepoint, StarSwallowsMultibyteRun) { - // `*` skips a run of multibyte codepoints and the trailing literal aligns. - EXPECT_TRUE(wildcard_match("*c", - "\xE3\x81\x82" - "\xE3\x81\x84" - "c")); // あいc - EXPECT_TRUE(wildcard_match("*?c", - "\xE3\x81\x82" - "c")); // あc -} - // --------------------------------------------------------------------------- // matches_criterion: blank-cell rules // --------------------------------------------------------------------------- @@ -608,56 +564,6 @@ TEST(WildcardCriterion, NotEqPatternCountsNonTextCells) { EXPECT_TRUE(matches_criterion(Value::number(1213.0), c)); } -// --------------------------------------------------------------------------- -// wildcard_find_dbcs: byte-mode `?` for SEARCHB. `?` matches only codepoints -// whose ja-JP DBCS cost is 1 (ASCII, half-width katakana); kanji/hiragana/ -// full-width punctuation/emoji refuse to match. `*` and `~?`/`~*` retain -// their normal semantics. -// --------------------------------------------------------------------------- - -TEST(WildcardFindDbcs, QuestionSkipsKanjiToAsciiOffset) { - // "漢" is a 3-byte UTF-8 codepoint with DBCS cost 2; `?` refuses to match - // it. The next codepoint is 'A' at byte offset 3. - EXPECT_EQ(wildcard_find_dbcs("?", "漢ABC"), std::size_t{3}); -} - -TEST(WildcardFindDbcs, QuestionMatchesAsciiAtOffsetZero) { - EXPECT_EQ(wildcard_find_dbcs("?", "abc"), std::size_t{0}); -} - -TEST(WildcardFindDbcs, QuestionFindsNothingInPureKanji) { - // No SBCS codepoint anywhere; `?` matches nothing. - EXPECT_EQ(wildcard_find_dbcs("?", "漢"), std::string_view::npos); -} - -TEST(WildcardFindDbcs, QuestionWithLiteralTailAfterKanji) { - // At start=0, "?" against 漢 fails. At start=3 the suffix is "abc" but - // pattern "?abc" needs 4 chars and only "abc" remains — no match. - EXPECT_EQ(wildcard_find_dbcs("?abc", "漢abc"), std::string_view::npos); -} - -TEST(WildcardFindDbcs, QuestionInsideAsciiHaystack) { - EXPECT_EQ(wildcard_find_dbcs("a?c", "abc"), std::size_t{0}); -} - -TEST(WildcardFindDbcs, StarSwallowsKanjiThenQuestionMatchesAscii) { - // `*` consumes the leading 漢 (2 DBCS bytes / 3 UTF-8 bytes) and `?` - // then matches 'A'. - EXPECT_EQ(wildcard_find_dbcs("*?", "漢A"), std::size_t{0}); -} - -TEST(WildcardFindDbcs, EscapedQuestionIsLiteralRegardlessOfMode) { - // `~?` is a literal '?' under both wildcard modes; the byte-mode flag - // only affects unescaped `?`. - EXPECT_EQ(wildcard_find_dbcs("~?", "x?y"), std::size_t{1}); -} - -TEST(WildcardFindDbcs, QuestionMatchesHalfWidthKatakana) { - // ア (U+FF71) is half-width katakana, classified as DBCS=1 in ja-JP, so - // byte-mode `?` matches it at offset 0. - EXPECT_EQ(wildcard_find_dbcs("?", "アABC"), std::size_t{0}); -} - } // namespace } // namespace eval } // namespace formulon diff --git a/tests/unit/eval/wildcard_test.cpp b/tests/unit/eval/wildcard_test.cpp new file mode 100644 index 000000000..33a4505ac --- /dev/null +++ b/tests/unit/eval/wildcard_test.cpp @@ -0,0 +1,112 @@ +// +// Unit tests for the `*` / `?` / `~` wildcard matchers declared in +// `wildcard.h`: codepoint-aware matching and the SEARCHB byte-mode `?`. + +#include "eval/wildcard.h" + +#include +#include + +#include "gtest/gtest.h" + +namespace formulon { +namespace eval { +namespace { + +// --------------------------------------------------------------------------- +// wildcard_match: codepoint-aware `*` backtracking and `?` matching. +// --------------------------------------------------------------------------- + +TEST(WildcardMatchCodepoint, StarBacktrackAdvancesWholeCodepoints) { + // Regression: `*` backtracking used to advance one BYTE, letting a + // following `?` match a UTF-8 continuation byte and spuriously match. + // `*??c*` against "あcう" must NOT match (only 3 codepoints: あ, c, う — + // there is no "c" two codepoints in from any start after consuming two). + EXPECT_FALSE(wildcard_match("*??c*", + "\xE3\x81\x82" + "c" + "\xE3\x81\x86")); +} + +TEST(WildcardMatchCodepoint, QuestionMatchesExactlyOneCodepoint) { + // "?" matches a single multibyte codepoint. + EXPECT_TRUE(wildcard_match("?", "\xE3\x81\x82")); // あ + EXPECT_FALSE(wildcard_match("?", + "\xE3\x81\x82" + "\xE3\x81\x84")); // あい (2 cp) + EXPECT_FALSE(wildcard_match("??", "\xE3\x81\x82")); // one cp cannot fill two ? +} + +TEST(WildcardMatchCodepoint, QuestionMatchesSupplementaryPlaneEmoji) { + // A 4-byte codepoint (U+1F600) is one "?" unit. + EXPECT_TRUE(wildcard_match("?", "\xF0\x9F\x98\x80")); + EXPECT_TRUE(wildcard_match("a?b", + "a" + "\xF0\x9F\x98\x80" + "b")); +} + +TEST(WildcardMatchCodepoint, StarSwallowsMultibyteRun) { + // `*` skips a run of multibyte codepoints and the trailing literal aligns. + EXPECT_TRUE(wildcard_match("*c", + "\xE3\x81\x82" + "\xE3\x81\x84" + "c")); // あいc + EXPECT_TRUE(wildcard_match("*?c", + "\xE3\x81\x82" + "c")); // あc +} + +// --------------------------------------------------------------------------- +// wildcard_find_dbcs: byte-mode `?` for SEARCHB. `?` matches only codepoints +// whose ja-JP DBCS cost is 1 (ASCII, half-width katakana); kanji/hiragana/ +// full-width punctuation/emoji refuse to match. `*` and `~?`/`~*` retain +// their normal semantics. +// --------------------------------------------------------------------------- + +TEST(WildcardFindDbcs, QuestionSkipsKanjiToAsciiOffset) { + // "漢" is a 3-byte UTF-8 codepoint with DBCS cost 2; `?` refuses to match + // it. The next codepoint is 'A' at byte offset 3. + EXPECT_EQ(wildcard_find_dbcs("?", "漢ABC"), std::size_t{3}); +} + +TEST(WildcardFindDbcs, QuestionMatchesAsciiAtOffsetZero) { + EXPECT_EQ(wildcard_find_dbcs("?", "abc"), std::size_t{0}); +} + +TEST(WildcardFindDbcs, QuestionFindsNothingInPureKanji) { + // No SBCS codepoint anywhere; `?` matches nothing. + EXPECT_EQ(wildcard_find_dbcs("?", "漢"), std::string_view::npos); +} + +TEST(WildcardFindDbcs, QuestionWithLiteralTailAfterKanji) { + // At start=0, "?" against 漢 fails. At start=3 the suffix is "abc" but + // pattern "?abc" needs 4 chars and only "abc" remains — no match. + EXPECT_EQ(wildcard_find_dbcs("?abc", "漢abc"), std::string_view::npos); +} + +TEST(WildcardFindDbcs, QuestionInsideAsciiHaystack) { + EXPECT_EQ(wildcard_find_dbcs("a?c", "abc"), std::size_t{0}); +} + +TEST(WildcardFindDbcs, StarSwallowsKanjiThenQuestionMatchesAscii) { + // `*` consumes the leading 漢 (2 DBCS bytes / 3 UTF-8 bytes) and `?` + // then matches 'A'. + EXPECT_EQ(wildcard_find_dbcs("*?", "漢A"), std::size_t{0}); +} + +TEST(WildcardFindDbcs, EscapedQuestionIsLiteralRegardlessOfMode) { + // `~?` is a literal '?' under both wildcard modes; the byte-mode flag + // only affects unescaped `?`. + EXPECT_EQ(wildcard_find_dbcs("~?", "x?y"), std::size_t{1}); +} + +TEST(WildcardFindDbcs, QuestionMatchesHalfWidthKatakana) { + // ア (U+FF71) is half-width katakana, classified as DBCS=1 in ja-JP, so + // byte-mode `?` matches it at offset 0. + EXPECT_EQ(wildcard_find_dbcs("?", "アABC"), std::size_t{0}); +} + +} // namespace +} // namespace eval +} // namespace formulon diff --git a/tools/dev/coverage_report.py b/tools/dev/coverage_report.py index bc7bee0fa..ec4f73ade 100755 --- a/tools/dev/coverage_report.py +++ b/tools/dev/coverage_report.py @@ -26,6 +26,7 @@ ``src/eval/conditional_aggregates.{h,cpp}``, ``src/eval/special_forms_lazy.{h,cpp}``, ``src/eval/criteria.{h,cpp}``, + ``src/eval/wildcard.{h,cpp}``, ``src/eval/text_ops.{h,cpp}``, ``src/eval/date_time.{h,cpp}``, ``src/eval/coerce.{h,cpp}``, @@ -78,6 +79,8 @@ "src/eval/special_forms_lazy.cpp", "src/eval/criteria.h", "src/eval/criteria.cpp", + "src/eval/wildcard.h", + "src/eval/wildcard.cpp", "src/utils/text_ops.h", "src/utils/text_ops.cpp", "src/utils/date_time.h", diff --git a/tools/dev/run_mutation.sh b/tools/dev/run_mutation.sh index 569156a9a..dd6e04ace 100755 --- a/tools/dev/run_mutation.sh +++ b/tools/dev/run_mutation.sh @@ -39,6 +39,7 @@ # --include-path= flag for mull. # Default: src/eval/coerce.cpp, # src/eval/criteria.cpp, +# src/eval/wildcard.cpp, # src/eval/text_ops.cpp # MULL_RUNNER Mull runner binary (default: mull-runner-cxx) # CMAKE cmake binary (default: cmake) @@ -59,7 +60,7 @@ BUILD_DIR="${FORMULON_MUT_BUILD_DIR:-build-mutation}" TIMEOUT_SEC="${FORMULON_MUT_TIMEOUT:-120}" STRICT_FLAG="${FORMULON_MUT_STRICT:-}" TARGET_BIN="${FORMULON_MUT_TARGET:-formulon_unit_tests}" -FILTER_RAW="${FORMULON_MUT_FILTER:-src/eval/coerce.cpp,src/eval/criteria.cpp,src/eval/text_ops.cpp}" +FILTER_RAW="${FORMULON_MUT_FILTER:-src/eval/coerce.cpp,src/eval/criteria.cpp,src/eval/wildcard.cpp,src/eval/text_ops.cpp}" MULL_RUNNER="${MULL_RUNNER:-mull-runner-cxx}" CMAKE="${CMAKE:-cmake}" CTEST="${CTEST:-ctest}" From 7c42f6bdb4a0448dac3933e0604a2bd548db3716 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 16:02:01 +0900 Subject: [PATCH 18/93] refactor(eval): give a1_parse.h its own translation unit - move the A1/R1C1 parsing bodies from reference/common.cpp to eval/a1_parse.cpp next to the existing a1_parse.h - reference/common.cpp keeps the INDIRECT/OFFSET shared resolution - rephrase a TODO comment in reference/common.h and indirect.h as the constraint it records - register eval/a1_parse.cpp in CMakeLists.txt --- CMakeLists.txt | 1 + src/eval/a1_parse.cpp | 492 ++++++++++++++++++++++++++++++++++ src/eval/reference/common.cpp | 476 -------------------------------- src/eval/reference/common.h | 18 +- src/eval/reference/indirect.h | 3 +- 5 files changed, 502 insertions(+), 488 deletions(-) create mode 100644 src/eval/a1_parse.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b2ffe197c..58f2781ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -353,6 +353,7 @@ src/eval/aggregate_lazy.cpp src/eval/textjoin_lazy.cpp src/eval/textsplit_lazy.cpp src/eval/trimrange_lazy.cpp + src/eval/a1_parse.cpp src/eval/reference/common.cpp src/eval/reference/indirect.cpp src/eval/reference/intersection.cpp diff --git a/src/eval/a1_parse.cpp b/src/eval/a1_parse.cpp new file mode 100644 index 000000000..0b4045ed7 --- /dev/null +++ b/src/eval/a1_parse.cpp @@ -0,0 +1,492 @@ +// +// A1 and R1C1 textual reference parsers declared in `eval/a1_parse.h`: +// `parse_a1_ref`, `parse_r1c1_ref` and `column_letters`, with the sheet +// qualifier and endpoint lexers they share. + +#include "eval/a1_parse.h" + +#include +#include +#include +#include +#include +#include + +#include "sheet.h" +#include "utils/a1_column.h" + +namespace formulon { +namespace eval { +namespace refs_internal { + +namespace { + +// ASCII letter / digit predicates — intentionally local to avoid pulling +// in `` which is locale-sensitive on some platforms. +constexpr bool is_letter(char ch) noexcept { + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); +} + +constexpr bool is_digit(char ch) noexcept { + return ch >= '0' && ch <= '9'; +} + +// Parses letters (A..XFD) at `text[*i]` into a 1-based column index, then +// advances `*i` past the consumed bytes. Returns 0 on malformed input +// (no letters, too many letters, or overflow past XFD = 16384). +std::uint32_t parse_column_letters(std::string_view text, std::size_t* i) { + std::uint32_t col = 0; + std::size_t letters_seen = 0; + while (*i < text.size() && is_letter(text[*i])) { + char ch = text[*i]; + if (ch >= 'a' && ch <= 'z') { + ch = static_cast(ch - ('a' - 'A')); + } + col = col * 26u + static_cast(ch - 'A' + 1); + ++(*i); + ++letters_seen; + if (letters_seen > 3 || col > Sheet::kMaxCols) { + return 0; + } + } + if (letters_seen == 0) { + return 0; + } + return col; +} + +// Parses digits at `text[*i]` into a 1-based row index, advancing `*i` +// past them. Returns 0 on no digits, too many digits, or row > kMaxRows. +std::uint32_t parse_row_digits(std::string_view text, std::size_t* i) { + std::uint64_t row = 0; + std::size_t digits_seen = 0; + while (*i < text.size() && is_digit(text[*i])) { + row = row * 10u + static_cast(text[*i] - '0'); + ++(*i); + ++digits_seen; + if (digits_seen > 7 || row > Sheet::kMaxRows) { + return 0; + } + } + if (digits_seen == 0 || row == 0) { + return 0; + } + return static_cast(row); +} + +// Parses `[$]?:[$]?` consuming the entire remainder of +// `text[start..]`, where `parse_unit` reads one 1-based unit and returns 0 +// on malformed input. On success writes the 0-based min / max of the two +// units and returns true. +bool parse_full_axis_span(std::string_view text, std::size_t start, + std::uint32_t (*parse_unit)(std::string_view, std::size_t*), std::uint32_t* out_lo, + std::uint32_t* out_hi) { + std::size_t i = start; + if (i < text.size() && text[i] == '$') { + ++i; + } + const std::uint32_t u1 = parse_unit(text, &i); + if (u1 == 0) { + return false; + } + if (i >= text.size() || text[i] != ':') { + return false; + } + ++i; + if (i < text.size() && text[i] == '$') { + ++i; + } + const std::uint32_t u2 = parse_unit(text, &i); + if (u2 == 0) { + return false; + } + if (i != text.size()) { + return false; + } + *out_lo = std::min(u1, u2) - 1U; + *out_hi = std::max(u1, u2) - 1U; + return true; +} + +// Attempts to parse `text[start..]` as a full-column shape +// `[$]?:[$]?` consuming the entire remainder. On +// success sets `out->is_full_col` / `out->is_range`, populates +// `col`/`col2` with the min/max 0-based columns, and fills +// `row`/`row2` with the full-column row span, then returns true. On +// failure leaves `*out` untouched and returns false. +bool try_parse_full_col(std::string_view text, std::size_t start, A1Parse* out) { + std::uint32_t lo = 0; + std::uint32_t hi = 0; + if (!parse_full_axis_span(text, start, parse_column_letters, &lo, &hi)) { + return false; + } + out->col = lo; + out->col2 = hi; + out->row = 0; + out->row2 = Sheet::kMaxRows - 1U; + out->is_full_col = true; + out->is_range = true; + return true; +} + +// Attempts to parse `text[start..]` as a full-row shape +// `[$]?:[$]?` consuming the entire remainder. On +// success sets `out->is_full_row` / `out->is_range`, populates +// `row`/`row2` with the min/max 0-based rows, and fills `col`/`col2` +// with the full-row column span, then returns true. +bool try_parse_full_row(std::string_view text, std::size_t start, A1Parse* out) { + std::uint32_t lo = 0; + std::uint32_t hi = 0; + if (!parse_full_axis_span(text, start, parse_row_digits, &lo, &hi)) { + return false; + } + out->row = lo; + out->row2 = hi; + out->col = 0; + out->col2 = Sheet::kMaxCols - 1U; + out->is_full_row = true; + out->is_range = true; + return true; +} + +// Parses a single A1 endpoint (optional `$` markers, letters, digits). +// Returns `false` on any malformed shape; on success writes 0-based +// row/col to `*out_row` / `*out_col` and advances `*i`. +bool parse_a1_endpoint(std::string_view text, std::size_t* i, std::uint32_t* out_row, std::uint32_t* out_col) { + // Optional leading `$` on the column. + if (*i < text.size() && text[*i] == '$') { + ++(*i); + } + const std::uint32_t col_1based = parse_column_letters(text, i); + if (col_1based == 0) { + return false; + } + // Optional `$` between column and row. + if (*i < text.size() && text[*i] == '$') { + ++(*i); + } + const std::uint32_t row_1based = parse_row_digits(text, i); + if (row_1based == 0) { + return false; + } + *out_col = col_1based - 1U; + *out_row = row_1based - 1U; + return true; +} + +// Copies `quoted` (everything between the surrounding single quotes) +// into `out`, collapsing each `''` pair into a single `'`. Returns the +// new size. `quoted` already excludes the outer quotes. +std::size_t unescape_quoted_sheet(std::string_view quoted, char* out, std::size_t cap) { + std::size_t w = 0; + for (std::size_t r = 0; r < quoted.size() && w < cap; ++r) { + if (quoted[r] == '\'' && r + 1 < quoted.size() && quoted[r + 1] == '\'') { + out[w++] = '\''; + ++r; + continue; + } + out[w++] = quoted[r]; + } + return w; +} + +} // namespace + +std::size_t column_letters(std::uint32_t col, char* out) { + if (col == 0U || out == nullptr) { + return 0U; + } + std::string letters; + if (!a1::append_column_letters(letters, col - 1U)) { + return 0U; + } + std::memcpy(out, letters.data(), letters.size()); + return letters.size(); +} + +// Outcome of reading an optional leading `Sheet!` qualifier. +enum class SheetQualifier : std::uint8_t { + kNone, ///< No qualifier; `*i` is unmoved and the whole text is the reference. + kParsed, ///< Qualifier consumed; `*sheet` is set and `*i` points past the `!`. + kMalformed, ///< Unterminated quote or a quoted name with no `!`; the whole reference is invalid. +}; + +// Reads the optional sheet qualifier at the head of `text`, advancing `*i` +// past it. Shared by the A1 and R1C1 parsers, which differ only in how +// they read the reference body that follows. +SheetQualifier parse_sheet_qualifier(std::string_view text, std::size_t* i, std::string_view* sheet) { + if (text.empty()) { + return SheetQualifier::kNone; + } + if (text[0] == '\'') { + // Scan for the closing `'` that is NOT followed by another `'` (the + // doubled form is an escaped apostrophe and stays inside the name). + std::size_t j = 1; + while (j < text.size()) { + if (text[j] == '\'') { + if (j + 1 < text.size() && text[j + 1] == '\'') { + j += 2; + continue; + } + break; + } + ++j; + } + if (j >= text.size() || text[j] != '\'') { + return SheetQualifier::kMalformed; // unterminated + } + // Inside content is `text.substr(1, j - 1)`; unescape `''` -> `'`. + // We don't own backing storage here, so write into a static-sized + // local buffer; Excel sheet-name limit is 31 chars (we allow up to + // 255 defensively, capped by the source view size). + static thread_local char scratch[256]; + const std::size_t content_len = j - 1; + const std::size_t used = unescape_quoted_sheet(text.substr(1, content_len), scratch, sizeof(scratch)); + // This view points into thread_local storage — callers keep it alive + // only until the next parse on the same thread. The consuming code + // (`indirect`) copies before storing. + *sheet = std::string_view(scratch, used); + std::size_t after = j + 1; + if (after >= text.size() || text[after] != '!') { + return SheetQualifier::kMalformed; // missing `!` after quoted sheet + } + *i = after + 1; + return SheetQualifier::kParsed; + } + // Bare sheet name: run of letters/digits/underscore followed by `!`. + // We only commit to treating it as a sheet qualifier if we find the + // `!` — otherwise the run is part of the reference itself. + std::size_t j = 0; + while (j < text.size() && (is_letter(text[j]) || is_digit(text[j]) || text[j] == '_')) { + ++j; + } + if (j > 0 && j < text.size() && text[j] == '!') { + *sheet = text.substr(0, j); + *i = j + 1; + return SheetQualifier::kParsed; + } + return SheetQualifier::kNone; +} + +A1Parse parse_a1_ref(std::string_view text) { + A1Parse out; + if (text.empty()) { + return out; + } + std::size_t i = 0; + if (parse_sheet_qualifier(text, &i, &out.sheet) == SheetQualifier::kMalformed) { + return out; + } + + // Full-column / full-row shapes (`D:D`, `$FF:FG`, `5:5`, `$12:$23`) + // are tried before the single-endpoint path because they never share a + // prefix with a valid single-cell reference (the latter always has a + // digit immediately after the letter run, never a `:`). + if (try_parse_full_col(text, i, &out)) { + out.valid = true; + return out; + } + if (try_parse_full_row(text, i, &out)) { + out.valid = true; + return out; + } + + // Parse the first endpoint. + if (!parse_a1_endpoint(text, &i, &out.row, &out.col)) { + return out; + } + + // Optional `:` + second endpoint for ranges. + if (i < text.size() && text[i] == ':') { + ++i; + if (!parse_a1_endpoint(text, &i, &out.row2, &out.col2)) { + return out; + } + out.is_range = true; + } + + // Trailing garbage -> invalid. + if (i != text.size()) { + return out; + } + out.valid = true; + return out; +} + +namespace { + +// One axis of an R1C1 endpoint: `R`/`C` on its own, `R5` (absolute), or +// `R[-2]` (relative). `present` distinguishes an axis that was written +// from one that was left out, which is what makes `R5` a whole row and +// `R5C2` a single cell. +struct R1C1Axis { + bool present = false; + bool relative = false; + long long value = 0; ///< 1-based index when absolute, signed offset when relative. +}; + +// Reads one `R`/`C` axis at `text[*i]` when the marker matches `marker`. +// Returns false only on malformed input; an absent axis leaves `*out` +// unset and still returns true. +bool parse_r1c1_axis(std::string_view text, std::size_t* i, char marker, R1C1Axis* out) { + if (*i >= text.size()) { + return true; + } + char head = text[*i]; + if (head >= 'a' && head <= 'z') { + head = static_cast(head - ('a' - 'A')); + } + if (head != marker) { + return true; + } + ++*i; + out->present = true; + if (*i < text.size() && text[*i] == '[') { + ++*i; + bool negative = false; + if (*i < text.size() && (text[*i] == '-' || text[*i] == '+')) { + negative = text[*i] == '-'; + ++*i; + } + const std::size_t digits_start = *i; + long long magnitude = 0; + while (*i < text.size() && is_digit(text[*i])) { + magnitude = magnitude * 10 + (text[*i] - '0'); + if (magnitude > Sheet::kMaxRows) { + return false; // saturate well before overflow; out of grid either way + } + ++*i; + } + if (*i == digits_start || *i >= text.size() || text[*i] != ']') { + return false; + } + ++*i; + out->relative = true; + out->value = negative ? -magnitude : magnitude; + return true; + } + // A bare `R` / `C` is the current row / column: a zero relative offset. + const std::size_t digits_start = *i; + long long absolute = 0; + while (*i < text.size() && is_digit(text[*i])) { + absolute = absolute * 10 + (text[*i] - '0'); + if (absolute > Sheet::kMaxRows) { + return false; + } + ++*i; + } + if (*i == digits_start) { + out->relative = true; + out->value = 0; + return true; + } + out->value = absolute; + return true; +} + +// Resolves one axis to a 0-based coordinate against `base`, rejecting +// anything outside `[0, max)`. A relative axis with no base has nothing +// to measure from and fails rather than assuming the origin. +bool resolve_r1c1_axis(const R1C1Axis& axis, bool base_present, std::uint32_t base, std::uint32_t max, + std::uint32_t* out) { + if (axis.relative && !base_present) { + return false; + } + const long long resolved = axis.relative ? static_cast(base) + axis.value : axis.value - 1; + if (resolved < 0 || resolved >= static_cast(max)) { + return false; + } + *out = static_cast(resolved); + return true; +} + +// Parses one `R...C...` endpoint. At least one axis must be written. +bool parse_r1c1_endpoint(std::string_view text, std::size_t* i, R1C1Axis* row, R1C1Axis* col) { + if (!parse_r1c1_axis(text, i, 'R', row)) { + return false; + } + if (!parse_r1c1_axis(text, i, 'C', col)) { + return false; + } + return row->present || col->present; +} + +} // namespace + +A1Parse parse_r1c1_ref(std::string_view text, const R1C1Base& base) { + A1Parse out; + if (text.empty()) { + return out; + } + std::size_t i = 0; + if (parse_sheet_qualifier(text, &i, &out.sheet) == SheetQualifier::kMalformed) { + return out; + } + + R1C1Axis row1; + R1C1Axis col1; + if (!parse_r1c1_endpoint(text, &i, &row1, &col1)) { + return out; + } + R1C1Axis row2 = row1; + R1C1Axis col2 = col1; + bool is_range = false; + if (i < text.size() && text[i] == ':') { + ++i; + row2 = R1C1Axis{}; + col2 = R1C1Axis{}; + if (!parse_r1c1_endpoint(text, &i, &row2, &col2)) { + return out; + } + // A range whose endpoints name different axes (`R2:R3C4`) has no + // rectangle; Excel rejects it rather than guessing the missing bound. + if (row1.present != row2.present || col1.present != col2.present) { + return out; + } + is_range = true; + } + if (i != text.size()) { + return out; // trailing garbage + } + + // An endpoint that names only one axis is unbounded along the other, the + // same shape `5:5` and `D:D` take on the A1 side. + if (!col1.present) { + if (!resolve_r1c1_axis(row1, base.present, base.row, Sheet::kMaxRows, &out.row) || + !resolve_r1c1_axis(row2, base.present, base.row, Sheet::kMaxRows, &out.row2)) { + return out; + } + out.col = 0; + out.col2 = Sheet::kMaxCols - 1U; + out.is_full_row = true; + out.is_range = true; + out.valid = true; + return out; + } + if (!row1.present) { + if (!resolve_r1c1_axis(col1, base.present, base.col, Sheet::kMaxCols, &out.col) || + !resolve_r1c1_axis(col2, base.present, base.col, Sheet::kMaxCols, &out.col2)) { + return out; + } + out.row = 0; + out.row2 = Sheet::kMaxRows - 1U; + out.is_full_col = true; + out.is_range = true; + out.valid = true; + return out; + } + + if (!resolve_r1c1_axis(row1, base.present, base.row, Sheet::kMaxRows, &out.row) || + !resolve_r1c1_axis(col1, base.present, base.col, Sheet::kMaxCols, &out.col) || + !resolve_r1c1_axis(row2, base.present, base.row, Sheet::kMaxRows, &out.row2) || + !resolve_r1c1_axis(col2, base.present, base.col, Sheet::kMaxCols, &out.col2)) { + return out; + } + out.is_range = is_range; + out.valid = true; + return out; +} + +} // namespace refs_internal +} // namespace eval +} // namespace formulon diff --git a/src/eval/reference/common.cpp b/src/eval/reference/common.cpp index c320b53fc..844aeb8f1 100644 --- a/src/eval/reference/common.cpp +++ b/src/eval/reference/common.cpp @@ -1,10 +1,6 @@ // // Shared bodies for the reference-family lazy impls. Hosts: // -// * The A1 textual reference parser (`parse_a1_ref`, `column_letters`, -// `A1Parse`) — public surface declared in `eval/a1_parse.h` and used -// by INDIRECT (this subdirectory), INFO ("address"), CELL, and the -// `ADDRESS` builtin. // * Small helpers `apply_offset` / `read_int` that the OFFSET and // CHOOSE expanders share with the intersection resolver. // * The rectangle-construction routines `resolve_indirect_reference`, @@ -17,9 +13,7 @@ #include #include -#include #include -#include #include #include @@ -34,11 +28,9 @@ #include "parser/reference.h" #include "sheet.h" #include "sheet_name.h" -#include "utils/a1_column.h" #include "utils/arena.h" #include "utils/error.h" #include "utils/expected.h" -#include "utils/strings.h" #include "value.h" #include "workbook.h" @@ -47,474 +39,6 @@ namespace eval { namespace refs_internal { -namespace { - -// ASCII letter / digit predicates — intentionally local to avoid pulling -// in `` which is locale-sensitive on some platforms. -constexpr bool is_letter(char ch) noexcept { - return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); -} - -constexpr bool is_digit(char ch) noexcept { - return ch >= '0' && ch <= '9'; -} - -// Parses letters (A..XFD) at `text[*i]` into a 1-based column index, then -// advances `*i` past the consumed bytes. Returns 0 on malformed input -// (no letters, too many letters, or overflow past XFD = 16384). -std::uint32_t parse_column_letters(std::string_view text, std::size_t* i) { - std::uint32_t col = 0; - std::size_t letters_seen = 0; - while (*i < text.size() && is_letter(text[*i])) { - char ch = text[*i]; - if (ch >= 'a' && ch <= 'z') { - ch = static_cast(ch - ('a' - 'A')); - } - col = col * 26u + static_cast(ch - 'A' + 1); - ++(*i); - ++letters_seen; - if (letters_seen > 3 || col > Sheet::kMaxCols) { - return 0; - } - } - if (letters_seen == 0) { - return 0; - } - return col; -} - -// Parses digits at `text[*i]` into a 1-based row index, advancing `*i` -// past them. Returns 0 on no digits, too many digits, or row > kMaxRows. -std::uint32_t parse_row_digits(std::string_view text, std::size_t* i) { - std::uint64_t row = 0; - std::size_t digits_seen = 0; - while (*i < text.size() && is_digit(text[*i])) { - row = row * 10u + static_cast(text[*i] - '0'); - ++(*i); - ++digits_seen; - if (digits_seen > 7 || row > Sheet::kMaxRows) { - return 0; - } - } - if (digits_seen == 0 || row == 0) { - return 0; - } - return static_cast(row); -} - -// Parses `[$]?:[$]?` consuming the entire remainder of -// `text[start..]`, where `parse_unit` reads one 1-based unit and returns 0 -// on malformed input. On success writes the 0-based min / max of the two -// units and returns true. -bool parse_full_axis_span(std::string_view text, std::size_t start, - std::uint32_t (*parse_unit)(std::string_view, std::size_t*), std::uint32_t* out_lo, - std::uint32_t* out_hi) { - std::size_t i = start; - if (i < text.size() && text[i] == '$') { - ++i; - } - const std::uint32_t u1 = parse_unit(text, &i); - if (u1 == 0) { - return false; - } - if (i >= text.size() || text[i] != ':') { - return false; - } - ++i; - if (i < text.size() && text[i] == '$') { - ++i; - } - const std::uint32_t u2 = parse_unit(text, &i); - if (u2 == 0) { - return false; - } - if (i != text.size()) { - return false; - } - *out_lo = std::min(u1, u2) - 1U; - *out_hi = std::max(u1, u2) - 1U; - return true; -} - -// Attempts to parse `text[start..]` as a full-column shape -// `[$]?:[$]?` consuming the entire remainder. On -// success sets `out->is_full_col` / `out->is_range`, populates -// `col`/`col2` with the min/max 0-based columns, and fills -// `row`/`row2` with the full-column row span, then returns true. On -// failure leaves `*out` untouched and returns false. -bool try_parse_full_col(std::string_view text, std::size_t start, A1Parse* out) { - std::uint32_t lo = 0; - std::uint32_t hi = 0; - if (!parse_full_axis_span(text, start, parse_column_letters, &lo, &hi)) { - return false; - } - out->col = lo; - out->col2 = hi; - out->row = 0; - out->row2 = Sheet::kMaxRows - 1U; - out->is_full_col = true; - out->is_range = true; - return true; -} - -// Attempts to parse `text[start..]` as a full-row shape -// `[$]?:[$]?` consuming the entire remainder. On -// success sets `out->is_full_row` / `out->is_range`, populates -// `row`/`row2` with the min/max 0-based rows, and fills `col`/`col2` -// with the full-row column span, then returns true. -bool try_parse_full_row(std::string_view text, std::size_t start, A1Parse* out) { - std::uint32_t lo = 0; - std::uint32_t hi = 0; - if (!parse_full_axis_span(text, start, parse_row_digits, &lo, &hi)) { - return false; - } - out->row = lo; - out->row2 = hi; - out->col = 0; - out->col2 = Sheet::kMaxCols - 1U; - out->is_full_row = true; - out->is_range = true; - return true; -} - -// Parses a single A1 endpoint (optional `$` markers, letters, digits). -// Returns `false` on any malformed shape; on success writes 0-based -// row/col to `*out_row` / `*out_col` and advances `*i`. -bool parse_a1_endpoint(std::string_view text, std::size_t* i, std::uint32_t* out_row, std::uint32_t* out_col) { - // Optional leading `$` on the column. - if (*i < text.size() && text[*i] == '$') { - ++(*i); - } - const std::uint32_t col_1based = parse_column_letters(text, i); - if (col_1based == 0) { - return false; - } - // Optional `$` between column and row. - if (*i < text.size() && text[*i] == '$') { - ++(*i); - } - const std::uint32_t row_1based = parse_row_digits(text, i); - if (row_1based == 0) { - return false; - } - *out_col = col_1based - 1U; - *out_row = row_1based - 1U; - return true; -} - -// Copies `quoted` (everything between the surrounding single quotes) -// into `out`, collapsing each `''` pair into a single `'`. Returns the -// new size. `quoted` already excludes the outer quotes. -std::size_t unescape_quoted_sheet(std::string_view quoted, char* out, std::size_t cap) { - std::size_t w = 0; - for (std::size_t r = 0; r < quoted.size() && w < cap; ++r) { - if (quoted[r] == '\'' && r + 1 < quoted.size() && quoted[r + 1] == '\'') { - out[w++] = '\''; - ++r; - continue; - } - out[w++] = quoted[r]; - } - return w; -} - -} // namespace - -std::size_t column_letters(std::uint32_t col, char* out) { - if (col == 0U || out == nullptr) { - return 0U; - } - std::string letters; - if (!a1::append_column_letters(letters, col - 1U)) { - return 0U; - } - std::memcpy(out, letters.data(), letters.size()); - return letters.size(); -} - -// Outcome of reading an optional leading `Sheet!` qualifier. -enum class SheetQualifier : std::uint8_t { - kNone, ///< No qualifier; `*i` is unmoved and the whole text is the reference. - kParsed, ///< Qualifier consumed; `*sheet` is set and `*i` points past the `!`. - kMalformed, ///< Unterminated quote or a quoted name with no `!`; the whole reference is invalid. -}; - -// Reads the optional sheet qualifier at the head of `text`, advancing `*i` -// past it. Shared by the A1 and R1C1 parsers, which differ only in how -// they read the reference body that follows. -SheetQualifier parse_sheet_qualifier(std::string_view text, std::size_t* i, std::string_view* sheet) { - if (text.empty()) { - return SheetQualifier::kNone; - } - if (text[0] == '\'') { - // Scan for the closing `'` that is NOT followed by another `'` (the - // doubled form is an escaped apostrophe and stays inside the name). - std::size_t j = 1; - while (j < text.size()) { - if (text[j] == '\'') { - if (j + 1 < text.size() && text[j + 1] == '\'') { - j += 2; - continue; - } - break; - } - ++j; - } - if (j >= text.size() || text[j] != '\'') { - return SheetQualifier::kMalformed; // unterminated - } - // Inside content is `text.substr(1, j - 1)`; unescape `''` -> `'`. - // We don't own backing storage here, so write into a static-sized - // local buffer; Excel sheet-name limit is 31 chars (we allow up to - // 255 defensively, capped by the source view size). - static thread_local char scratch[256]; - const std::size_t content_len = j - 1; - const std::size_t used = unescape_quoted_sheet(text.substr(1, content_len), scratch, sizeof(scratch)); - // This view points into thread_local storage — callers keep it alive - // only until the next parse on the same thread. The consuming code - // (`indirect`) copies before storing. - *sheet = std::string_view(scratch, used); - std::size_t after = j + 1; - if (after >= text.size() || text[after] != '!') { - return SheetQualifier::kMalformed; // missing `!` after quoted sheet - } - *i = after + 1; - return SheetQualifier::kParsed; - } - // Bare sheet name: run of letters/digits/underscore followed by `!`. - // We only commit to treating it as a sheet qualifier if we find the - // `!` — otherwise the run is part of the reference itself. - std::size_t j = 0; - while (j < text.size() && (is_letter(text[j]) || is_digit(text[j]) || text[j] == '_')) { - ++j; - } - if (j > 0 && j < text.size() && text[j] == '!') { - *sheet = text.substr(0, j); - *i = j + 1; - return SheetQualifier::kParsed; - } - return SheetQualifier::kNone; -} - -A1Parse parse_a1_ref(std::string_view text) { - A1Parse out; - if (text.empty()) { - return out; - } - std::size_t i = 0; - if (parse_sheet_qualifier(text, &i, &out.sheet) == SheetQualifier::kMalformed) { - return out; - } - - // Full-column / full-row shapes (`D:D`, `$FF:FG`, `5:5`, `$12:$23`) - // are tried before the single-endpoint path because they never share a - // prefix with a valid single-cell reference (the latter always has a - // digit immediately after the letter run, never a `:`). - if (try_parse_full_col(text, i, &out)) { - out.valid = true; - return out; - } - if (try_parse_full_row(text, i, &out)) { - out.valid = true; - return out; - } - - // Parse the first endpoint. - if (!parse_a1_endpoint(text, &i, &out.row, &out.col)) { - return out; - } - - // Optional `:` + second endpoint for ranges. - if (i < text.size() && text[i] == ':') { - ++i; - if (!parse_a1_endpoint(text, &i, &out.row2, &out.col2)) { - return out; - } - out.is_range = true; - } - - // Trailing garbage -> invalid. - if (i != text.size()) { - return out; - } - out.valid = true; - return out; -} - -namespace { - -// One axis of an R1C1 endpoint: `R`/`C` on its own, `R5` (absolute), or -// `R[-2]` (relative). `present` distinguishes an axis that was written -// from one that was left out, which is what makes `R5` a whole row and -// `R5C2` a single cell. -struct R1C1Axis { - bool present = false; - bool relative = false; - long long value = 0; ///< 1-based index when absolute, signed offset when relative. -}; - -// Reads one `R`/`C` axis at `text[*i]` when the marker matches `marker`. -// Returns false only on malformed input; an absent axis leaves `*out` -// unset and still returns true. -bool parse_r1c1_axis(std::string_view text, std::size_t* i, char marker, R1C1Axis* out) { - if (*i >= text.size()) { - return true; - } - char head = text[*i]; - if (head >= 'a' && head <= 'z') { - head = static_cast(head - ('a' - 'A')); - } - if (head != marker) { - return true; - } - ++*i; - out->present = true; - if (*i < text.size() && text[*i] == '[') { - ++*i; - bool negative = false; - if (*i < text.size() && (text[*i] == '-' || text[*i] == '+')) { - negative = text[*i] == '-'; - ++*i; - } - const std::size_t digits_start = *i; - long long magnitude = 0; - while (*i < text.size() && is_digit(text[*i])) { - magnitude = magnitude * 10 + (text[*i] - '0'); - if (magnitude > Sheet::kMaxRows) { - return false; // saturate well before overflow; out of grid either way - } - ++*i; - } - if (*i == digits_start || *i >= text.size() || text[*i] != ']') { - return false; - } - ++*i; - out->relative = true; - out->value = negative ? -magnitude : magnitude; - return true; - } - // A bare `R` / `C` is the current row / column: a zero relative offset. - const std::size_t digits_start = *i; - long long absolute = 0; - while (*i < text.size() && is_digit(text[*i])) { - absolute = absolute * 10 + (text[*i] - '0'); - if (absolute > Sheet::kMaxRows) { - return false; - } - ++*i; - } - if (*i == digits_start) { - out->relative = true; - out->value = 0; - return true; - } - out->value = absolute; - return true; -} - -// Resolves one axis to a 0-based coordinate against `base`, rejecting -// anything outside `[0, max)`. A relative axis with no base has nothing -// to measure from and fails rather than assuming the origin. -bool resolve_r1c1_axis(const R1C1Axis& axis, bool base_present, std::uint32_t base, std::uint32_t max, - std::uint32_t* out) { - if (axis.relative && !base_present) { - return false; - } - const long long resolved = axis.relative ? static_cast(base) + axis.value : axis.value - 1; - if (resolved < 0 || resolved >= static_cast(max)) { - return false; - } - *out = static_cast(resolved); - return true; -} - -// Parses one `R...C...` endpoint. At least one axis must be written. -bool parse_r1c1_endpoint(std::string_view text, std::size_t* i, R1C1Axis* row, R1C1Axis* col) { - if (!parse_r1c1_axis(text, i, 'R', row)) { - return false; - } - if (!parse_r1c1_axis(text, i, 'C', col)) { - return false; - } - return row->present || col->present; -} - -} // namespace - -A1Parse parse_r1c1_ref(std::string_view text, const R1C1Base& base) { - A1Parse out; - if (text.empty()) { - return out; - } - std::size_t i = 0; - if (parse_sheet_qualifier(text, &i, &out.sheet) == SheetQualifier::kMalformed) { - return out; - } - - R1C1Axis row1; - R1C1Axis col1; - if (!parse_r1c1_endpoint(text, &i, &row1, &col1)) { - return out; - } - R1C1Axis row2 = row1; - R1C1Axis col2 = col1; - bool is_range = false; - if (i < text.size() && text[i] == ':') { - ++i; - row2 = R1C1Axis{}; - col2 = R1C1Axis{}; - if (!parse_r1c1_endpoint(text, &i, &row2, &col2)) { - return out; - } - // A range whose endpoints name different axes (`R2:R3C4`) has no - // rectangle; Excel rejects it rather than guessing the missing bound. - if (row1.present != row2.present || col1.present != col2.present) { - return out; - } - is_range = true; - } - if (i != text.size()) { - return out; // trailing garbage - } - - // An endpoint that names only one axis is unbounded along the other, the - // same shape `5:5` and `D:D` take on the A1 side. - if (!col1.present) { - if (!resolve_r1c1_axis(row1, base.present, base.row, Sheet::kMaxRows, &out.row) || - !resolve_r1c1_axis(row2, base.present, base.row, Sheet::kMaxRows, &out.row2)) { - return out; - } - out.col = 0; - out.col2 = Sheet::kMaxCols - 1U; - out.is_full_row = true; - out.is_range = true; - out.valid = true; - return out; - } - if (!row1.present) { - if (!resolve_r1c1_axis(col1, base.present, base.col, Sheet::kMaxCols, &out.col) || - !resolve_r1c1_axis(col2, base.present, base.col, Sheet::kMaxCols, &out.col2)) { - return out; - } - out.row = 0; - out.row2 = Sheet::kMaxRows - 1U; - out.is_full_col = true; - out.is_range = true; - out.valid = true; - return out; - } - - if (!resolve_r1c1_axis(row1, base.present, base.row, Sheet::kMaxRows, &out.row) || - !resolve_r1c1_axis(col1, base.present, base.col, Sheet::kMaxCols, &out.col) || - !resolve_r1c1_axis(row2, base.present, base.row, Sheet::kMaxRows, &out.row2) || - !resolve_r1c1_axis(col2, base.present, base.col, Sheet::kMaxCols, &out.col2)) { - return out; - } - out.is_range = is_range; - out.valid = true; - return out; -} - bool apply_offset(std::uint32_t base, int offset, std::uint32_t max, std::uint32_t* out) { const long long sum = static_cast(base) + static_cast(offset); if (sum < 0 || sum >= static_cast(max)) { diff --git a/src/eval/reference/common.h b/src/eval/reference/common.h index c43cc2f80..20b5666ce 100644 --- a/src/eval/reference/common.h +++ b/src/eval/reference/common.h @@ -8,18 +8,14 @@ // `read_int`) that the three reference-family pipelines all touch. // // The public A1-text parser (`parse_a1_ref`, `column_letters`, `A1Parse`) -// continues to live in `eval/a1_parse.h`; the bodies are defined in -// `reference/common.cpp` and are reachable from outside the subdirectory -// through that header. Public lazy impl declarations -// (`eval_indirect_lazy`, `eval_offset_lazy`) live in `reference/indirect.h` -// and `reference/offset.h`; the range-expander / range-resolver externs -// live in `eval/range_expanders.h` and `eval/range_resolvers.h`. +// lives in `eval/a1_parse.h`, with its bodies in `eval/a1_parse.cpp`. +// Public lazy impl declarations (`eval_indirect_lazy`, `eval_offset_lazy`) +// live in `reference/indirect.h` and `reference/offset.h`; the +// range-expander / range-resolver externs live in `eval/range_expanders.h` +// and `eval/range_resolvers.h`. // -// TODO(D-07): the A1 endpoint parser duplicates work that the OOXML-side -// `src/io/a1_ref.h` already does for a more restricted dialect. Hoisting -// a shared parser into a refs layer depends on cross-layer design -// decisions (eval -> io dependency direction); defer the unification -// until that lands. Until then, the parser stays local here. +// The A1 endpoint parser stays local to eval rather than sharing the OOXML +// side's `src/io/a1_ref.h`, which would need an eval -> io dependency. #ifndef FORMULON_EVAL_REFERENCE_COMMON_H_ #define FORMULON_EVAL_REFERENCE_COMMON_H_ diff --git a/src/eval/reference/indirect.h b/src/eval/reference/indirect.h index 8fcd1a63c..d1facce98 100644 --- a/src/eval/reference/indirect.h +++ b/src/eval/reference/indirect.h @@ -3,7 +3,8 @@ // own header so the central dispatch table (`tree_walker_lazy_table.cpp`) // can include it directly without pulling in the OFFSET / intersection // surfaces. The accompanying text-to-rectangle decoder lives in -// `eval/a1_parse.h` (public) and `reference/common.h` (intra-subdir). +// `eval/a1_parse.h` (public, defined in `eval/a1_parse.cpp`) and +// `reference/common.h` (intra-subdir). #ifndef FORMULON_EVAL_REFERENCE_INDIRECT_H_ #define FORMULON_EVAL_REFERENCE_INDIRECT_H_ From a9c7498a8fa17d5dc19c0da1fc19fefbfa34a4f4 Mon Sep 17 00:00:00 2001 From: libraz Date: Mon, 5 Oct 2026 17:53:32 +0900 Subject: [PATCH 19/93] test(c_api): split the C API test suite into per-topic files - split formulon_c_cf_test.cpp into cf_authoring, cf_evaluation, cf_overlay, cf_validation and cf_visual_payload files - split formulon_c_pivot_test.cpp into pivot_authoring, filters, grouping, lifecycle and read files - split formulon_c_styles_test.cpp into styles_alignment, cell_xf, dxf, fills_borders, fonts, lifetime, mutation and num_formats files - move save and value cases out of formulon_c_test.cpp into formulon_c_save_test.cpp and formulon_c_values_test.cpp - add shared formulon_c_*_test_helpers.h headers; update tests/c_api/CMakeLists.txt - test cases are unchanged, only moved --- tests/c_api/CMakeLists.txt | 23 +- tests/c_api/formulon_c_cf_authoring_test.cpp | 308 +++ tests/c_api/formulon_c_cf_evaluation_test.cpp | 305 +++ tests/c_api/formulon_c_cf_overlay_test.cpp | 223 ++ tests/c_api/formulon_c_cf_test.cpp | 1651 ------------ tests/c_api/formulon_c_cf_test_helpers.h | 114 + tests/c_api/formulon_c_cf_validation_test.cpp | 367 +++ .../formulon_c_cf_visual_payload_test.cpp | 348 +++ .../c_api/formulon_c_pivot_authoring_test.cpp | 480 ++++ tests/c_api/formulon_c_pivot_filters_test.cpp | 505 ++++ .../c_api/formulon_c_pivot_grouping_test.cpp | 373 +++ .../c_api/formulon_c_pivot_lifecycle_test.cpp | 308 +++ tests/c_api/formulon_c_pivot_read_test.cpp | 267 ++ tests/c_api/formulon_c_pivot_test.cpp | 2200 ---------------- tests/c_api/formulon_c_pivot_test_helpers.h | 268 ++ tests/c_api/formulon_c_save_test.cpp | 646 +++++ .../formulon_c_styles_alignment_test.cpp | 440 ++++ .../c_api/formulon_c_styles_cell_xf_test.cpp | 290 ++ tests/c_api/formulon_c_styles_dxf_test.cpp | 210 ++ .../formulon_c_styles_fills_borders_test.cpp | 258 ++ tests/c_api/formulon_c_styles_fonts_test.cpp | 164 ++ .../c_api/formulon_c_styles_lifetime_test.cpp | 185 ++ .../c_api/formulon_c_styles_mutation_test.cpp | 299 +++ .../formulon_c_styles_num_formats_test.cpp | 254 ++ tests/c_api/formulon_c_styles_test.cpp | 2337 ----------------- tests/c_api/formulon_c_styles_test_helpers.h | 227 ++ tests/c_api/formulon_c_test.cpp | 1032 +------- tests/c_api/formulon_c_test_helpers.h | 25 + tests/c_api/formulon_c_values_test.cpp | 344 +++ 29 files changed, 7230 insertions(+), 7221 deletions(-) create mode 100644 tests/c_api/formulon_c_cf_authoring_test.cpp create mode 100644 tests/c_api/formulon_c_cf_evaluation_test.cpp create mode 100644 tests/c_api/formulon_c_cf_overlay_test.cpp delete mode 100644 tests/c_api/formulon_c_cf_test.cpp create mode 100644 tests/c_api/formulon_c_cf_test_helpers.h create mode 100644 tests/c_api/formulon_c_cf_validation_test.cpp create mode 100644 tests/c_api/formulon_c_cf_visual_payload_test.cpp create mode 100644 tests/c_api/formulon_c_pivot_authoring_test.cpp create mode 100644 tests/c_api/formulon_c_pivot_filters_test.cpp create mode 100644 tests/c_api/formulon_c_pivot_grouping_test.cpp create mode 100644 tests/c_api/formulon_c_pivot_lifecycle_test.cpp create mode 100644 tests/c_api/formulon_c_pivot_read_test.cpp delete mode 100644 tests/c_api/formulon_c_pivot_test.cpp create mode 100644 tests/c_api/formulon_c_pivot_test_helpers.h create mode 100644 tests/c_api/formulon_c_save_test.cpp create mode 100644 tests/c_api/formulon_c_styles_alignment_test.cpp create mode 100644 tests/c_api/formulon_c_styles_cell_xf_test.cpp create mode 100644 tests/c_api/formulon_c_styles_dxf_test.cpp create mode 100644 tests/c_api/formulon_c_styles_fills_borders_test.cpp create mode 100644 tests/c_api/formulon_c_styles_fonts_test.cpp create mode 100644 tests/c_api/formulon_c_styles_lifetime_test.cpp create mode 100644 tests/c_api/formulon_c_styles_mutation_test.cpp create mode 100644 tests/c_api/formulon_c_styles_num_formats_test.cpp delete mode 100644 tests/c_api/formulon_c_styles_test.cpp create mode 100644 tests/c_api/formulon_c_styles_test_helpers.h create mode 100644 tests/c_api/formulon_c_test_helpers.h create mode 100644 tests/c_api/formulon_c_values_test.cpp diff --git a/tests/c_api/CMakeLists.txt b/tests/c_api/CMakeLists.txt index ebdd55b3f..b6ef242d6 100644 --- a/tests/c_api/CMakeLists.txt +++ b/tests/c_api/CMakeLists.txt @@ -6,7 +6,11 @@ set(FORMULON_C_API_TEST_SOURCES formulon_c_adhoc_arena_cap_test.cpp formulon_c_calc_mode_test.cpp - formulon_c_cf_test.cpp + formulon_c_cf_authoring_test.cpp + formulon_c_cf_evaluation_test.cpp + formulon_c_cf_overlay_test.cpp + formulon_c_cf_validation_test.cpp + formulon_c_cf_visual_payload_test.cpp formulon_c_clock_test.cpp formulon_c_evaluate_formula_test.cpp formulon_c_external_links_test.cpp @@ -14,15 +18,28 @@ set(FORMULON_C_API_TEST_SOURCES formulon_c_lambda_text_test.cpp formulon_c_logging_test.cpp formulon_c_named_styles_test.cpp - formulon_c_pivot_test.cpp + formulon_c_pivot_authoring_test.cpp + formulon_c_pivot_filters_test.cpp + formulon_c_pivot_grouping_test.cpp + formulon_c_pivot_lifecycle_test.cpp + formulon_c_pivot_read_test.cpp formulon_c_print_settings_test.cpp formulon_c_protection_test.cpp formulon_c_read_scratch_test.cpp formulon_c_sheet_features_test.cpp formulon_c_sheet_layout_test.cpp formulon_c_spill_test.cpp - formulon_c_styles_test.cpp + formulon_c_styles_alignment_test.cpp + formulon_c_styles_cell_xf_test.cpp + formulon_c_styles_dxf_test.cpp + formulon_c_styles_fills_borders_test.cpp + formulon_c_styles_fonts_test.cpp + formulon_c_styles_lifetime_test.cpp + formulon_c_styles_mutation_test.cpp + formulon_c_styles_num_formats_test.cpp formulon_c_test.cpp + formulon_c_values_test.cpp + formulon_c_save_test.cpp formulon_c_trace_test.cpp formulon_c_zip_budget_test.cpp partial_recalc_test.cpp diff --git a/tests/c_api/formulon_c_cf_authoring_test.cpp b/tests/c_api/formulon_c_cf_authoring_test.cpp new file mode 100644 index 000000000..4ad40685d --- /dev/null +++ b/tests/c_api/formulon_c_cf_authoring_test.cpp @@ -0,0 +1,308 @@ +// Stable C ABI conditional-format authoring tests. + +#include "formulon_c_cf_test_helpers.h" + +TEST(FormulonCApiCfMutate, AddCellIsRuleRoundTrips) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDxfs(wb.handle, 1); + + fm_cf_cell_range_t sqref{}; + sqref.first_row = 0; + sqref.first_col = 0; + sqref.last_row = 9; + sqref.last_col = 0; + + fm_cf_rule_t rule{}; + rule.type = 1; // CellIs + rule.op_engaged = 1; + rule.op = 5; // GreaterThan + std::string formula = "50"; + rule.formula1 = formula.c_str(); + rule.dxf_id_engaged = 1; + rule.dxf_id = 0; + rule.sqref = &sqref; + rule.sqref_count = 1; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0); + EXPECT_EQ(rule_index, 0U); + + std::size_t count = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 1U); + + fm_cf_rule_t out{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); + EXPECT_EQ(out.type, 1U); + EXPECT_EQ(out.op_engaged, 1); + EXPECT_EQ(out.op, 5U); + EXPECT_EQ(out.priority, 1); // auto-assigned since input <= 0 + EXPECT_EQ(out.dxf_id_engaged, 1); + EXPECT_EQ(out.dxf_id, 0U); + ASSERT_NE(out.formula1, nullptr); + EXPECT_STREQ(out.formula1, "50"); + ASSERT_NE(out.sqref, nullptr); + EXPECT_EQ(out.sqref_count, 1U); + EXPECT_EQ(out.sqref[0].first_row, 0U); + EXPECT_EQ(out.sqref[0].last_row, 9U); + ASSERT_NE(out.id, nullptr); + EXPECT_FALSE(std::string(out.id).empty()); +} + +TEST(FormulonCApiCfMutate, AddRuleRejectsDxfIdPastTheStylesTable) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDxfs(wb.handle, 2); + + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule{}; + rule.type = 1; // CellIs + rule.op_engaged = 1; + rule.op = 5; // GreaterThan + rule.formula1 = "50"; + rule.dxf_id_engaged = 1; + rule.dxf_id = 2; // one past the last registered `` + rule.sqref = &sqref; + rule.sqref_count = 1; + + std::size_t rule_index = 99U; + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_STREQ(fm_last_error_message(), "fm_sheet_cf_add_rule: dxf_id out of range"); + + // The rejected rule is not appended, so a workbook saved after the failed + // call carries no `` whose `dxfId` Excel cannot resolve. + std::size_t count = 99U; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 0U); + + // The last in-range index is still accepted. + rule.dxf_id = 1; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 1U); +} + +TEST(FormulonCApiCfMutate, AddRuleWithoutDxfIdIgnoresTheEmptyStylesTable) { + // A rule that engages no differential format must stay addable on a + // workbook that has registered no `` at all. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule{}; + rule.type = 1; // CellIs + rule.op_engaged = 1; + rule.op = 5; // GreaterThan + rule.formula1 = "50"; + rule.sqref = &sqref; + rule.sqref_count = 1; + + std::size_t rule_index = 99U; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + EXPECT_EQ(rule_index, 0U); + + fm_cf_rule_t out{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); + EXPECT_EQ(out.dxf_id_engaged, 0); +} + +TEST(FormulonCApiCfMutate, AddMultipleRulesAutoIncrementsPriority) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cf_cell_range_t sqref{0, 0, 0, 0}; + for (int i = 0; i < 3; ++i) { + fm_cf_rule_t rule{}; + rule.type = 0; // Expression + std::string f = "TRUE"; + rule.formula1 = f.c_str(); + rule.sqref = &sqref; + rule.sqref_count = 1; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0); + EXPECT_EQ(rule_index, static_cast(i)); + } + + std::size_t count = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 3U); + for (std::size_t i = 0; i < 3; ++i) { + fm_cf_rule_t out{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, i, &out), 0); + EXPECT_EQ(out.priority, static_cast(i + 1)); + } +} + +TEST(FormulonCApiCfMutate, RemoveAtFlattensIndices) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cf_cell_range_t sqref{0, 0, 0, 0}; + std::vector formulas{"A1", "A2", "A3"}; + std::size_t expected_index = 0; + for (const auto& f : formulas) { + fm_cf_rule_t rule{}; + rule.type = 0; // Expression + rule.formula1 = f.c_str(); + rule.sqref = &sqref; + rule.sqref_count = 1; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0); + EXPECT_EQ(rule_index, expected_index); + ++expected_index; + } + std::size_t count = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + ASSERT_EQ(count, 3U); + + ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 1), 0); + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 2U); + + // A record's storage belongs to the handle and the next get takes it + // back, so each rule is read before the following one is fetched. + fm_cf_rule_t out0{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out0), 0); + EXPECT_STREQ(out0.formula1, "A1"); + fm_cf_rule_t out1{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 1, &out1), 0); + EXPECT_STREQ(out1.formula1, "A3"); +} + +TEST(FormulonCApiCfMutate, ClearRemovesAllBlocks) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 0, 0}; + fm_cf_rule_t rule{}; + rule.type = 0; + std::string f = "TRUE"; + rule.formula1 = f.c_str(); + rule.sqref = &sqref; + rule.sqref_count = 1; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0); + EXPECT_EQ(rule_index, 0U); + + ASSERT_EQ(fm_sheet_cf_clear(wb.handle, 0), 0); + std::size_t count = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 0U); +} + +TEST(FormulonCApiCfMutate, AddsVisualRuleTypesAndPreservesThroughSaveLoad) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 0, 0}; + + fm_cf_rule_t rule{}; + rule.type = 2; // ColorScale + rule.sqref = &sqref; + rule.sqref_count = 1; + fm_cfvo_t thresholds[3]{}; + thresholds[0].type = 3; // Min + thresholds[0].gte = 1; + thresholds[1].type = 1; // Percent + thresholds[1].value = "50"; + thresholds[1].gte = 1; + thresholds[2].type = 4; // Max + thresholds[2].gte = 1; + fm_cf_color_t colors[3]{{255, 0, 0, 255}, {255, 255, 0, 255}, {0, 255, 0, 255}}; + rule.color_scale_thresholds = thresholds; + rule.color_scale_colors = colors; + rule.color_scale_count = 3; + std::size_t color_rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &color_rule_index), 0); + EXPECT_EQ(color_rule_index, 0U); + + fm_cf_cell_range_t db_sqref{1, 0, 1, 0}; + fm_cf_rule_t db_rule{}; + db_rule.type = 3; // DataBar + db_rule.sqref = &db_sqref; + db_rule.sqref_count = 1; + db_rule.data_bar_engaged = 1; + db_rule.data_bar_min.type = 3; // Min + db_rule.data_bar_min.gte = 1; + db_rule.data_bar_max.type = 4; // Max + db_rule.data_bar_max.gte = 1; + db_rule.data_bar_fill = fm_cf_color_t{99, 142, 198, 255}; + db_rule.data_bar_show_value = 1; + db_rule.data_bar_min_length_pct = 10; + db_rule.data_bar_max_length_pct = 90; + std::size_t data_bar_rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, db_rule, &data_bar_rule_index), 0); + EXPECT_EQ(data_bar_rule_index, 1U); + + fm_cf_cell_range_t icon_sqref{2, 0, 2, 0}; + fm_cf_rule_t icon_rule{}; + icon_rule.type = 4; // IconSet + icon_rule.sqref = &icon_sqref; + icon_rule.sqref_count = 1; + icon_rule.icon_set_engaged = 1; + icon_rule.icon_set_name = 0; // Three_Arrows + fm_cfvo_t icon_thresholds[2]{}; + icon_thresholds[0].type = 1; // Percent + icon_thresholds[0].value = "33"; + icon_thresholds[0].gte = 1; + icon_thresholds[1].type = 1; // Percent + icon_thresholds[1].value = "67"; + icon_thresholds[1].gte = 1; + icon_rule.icon_set_thresholds = icon_thresholds; + icon_rule.icon_set_threshold_count = 2; + icon_rule.icon_set_show_value = 1; + icon_rule.icon_set_percent = 1; + std::size_t icon_rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, icon_rule, &icon_rule_index), 0); + EXPECT_EQ(icon_rule_index, 2U); + + const auto& blocks = wb.handle->workbook().sheet(0).conditional_formats(); + ASSERT_EQ(blocks.size(), 3U); + ASSERT_EQ(blocks[0].rules.size(), 1U); + ASSERT_TRUE(blocks[0].rules[0].color_scale.has_value()); + ASSERT_EQ(blocks[0].rules[0].color_scale->thresholds.size(), 3U); + EXPECT_EQ(blocks[0].rules[0].color_scale->thresholds[1].value, "50"); + EXPECT_EQ(blocks[0].rules[0].color_scale->colors[2].g, 255U); + ASSERT_TRUE(blocks[1].rules[0].data_bar.has_value()); + EXPECT_EQ(blocks[1].rules[0].data_bar->fill.b, 198U); + ASSERT_TRUE(blocks[2].rules[0].icon_set.has_value()); + EXPECT_EQ(blocks[2].rules[0].icon_set->thresholds[1].value, "67"); + + fm_cf_rule_t out_color{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out_color), 0); + ASSERT_EQ(out_color.color_scale_count, 3U); + ASSERT_NE(out_color.color_scale_thresholds, nullptr); + ASSERT_NE(out_color.color_scale_colors, nullptr); + EXPECT_EQ(out_color.color_scale_thresholds[1].type, 1U); + EXPECT_STREQ(out_color.color_scale_thresholds[1].value, "50"); + EXPECT_EQ(out_color.color_scale_colors[2].g, 255U); + + fm_cf_rule_t out_bar{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 1, &out_bar), 0); + EXPECT_EQ(out_bar.data_bar_engaged, 1); + EXPECT_EQ(out_bar.data_bar_fill.b, 198U); + EXPECT_EQ(out_bar.data_bar_min_length_pct, 10U); + EXPECT_EQ(out_bar.data_bar_max_length_pct, 90U); + + fm_cf_rule_t out_icon{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 2, &out_icon), 0); + EXPECT_EQ(out_icon.icon_set_engaged, 1); + EXPECT_EQ(out_icon.icon_set_threshold_count, 2U); + ASSERT_NE(out_icon.icon_set_thresholds, nullptr); + EXPECT_STREQ(out_icon.icon_set_thresholds[1].value, "67"); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + ASSERT_GT(saved.len, 0U); + + WorkbookGuard reloaded; + ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); + const auto& reloaded_blocks = reloaded.handle->workbook().sheet(0).conditional_formats(); + ASSERT_EQ(reloaded_blocks.size(), 3U); + ASSERT_EQ(reloaded_blocks[0].rules.size(), 1U); + ASSERT_TRUE(reloaded_blocks[0].rules[0].color_scale.has_value()); + EXPECT_EQ(reloaded_blocks[0].rules[0].color_scale->colors[0].r, 255U); + ASSERT_TRUE(reloaded_blocks[1].rules[0].data_bar.has_value()); + EXPECT_EQ(reloaded_blocks[1].rules[0].data_bar->fill.g, 142U); + ASSERT_TRUE(reloaded_blocks[2].rules[0].icon_set.has_value()); + EXPECT_EQ(reloaded_blocks[2].rules[0].icon_set->thresholds.size(), 2U); +} diff --git a/tests/c_api/formulon_c_cf_evaluation_test.cpp b/tests/c_api/formulon_c_cf_evaluation_test.cpp new file mode 100644 index 000000000..3f2dea1a2 --- /dev/null +++ b/tests/c_api/formulon_c_cf_evaluation_test.cpp @@ -0,0 +1,305 @@ +// Stable C ABI conditional-format evaluation and readback tests. + +#include +#include +#include + +#include "formulon_c_cf_test_helpers.h" + +TEST(FormulonCApiCf, EvaluateRangeOnEmptyCfWorkbookReturnsZero) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + CfResultsGuard results; + ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 9, 9, std::nan(""), &results.handle), 0); + ASSERT_NE(results.handle, nullptr); + EXPECT_EQ(fm_cf_results_cell_count(results.handle), 0U); +} + +TEST(FormulonCApiCf, EvaluateRangeWithCellIsRule) { + WorkbookGuard wb = WorkbookFromMutator([](formulon::Workbook& w) { + auto& sheet = w.sheet(0); + sheet.set_cell_value(0, 0, formulon::Value::number(10.0)); + sheet.set_cell_value(1, 0, formulon::Value::number(60.0)); + sheet.set_cell_value(2, 0, formulon::Value::number(90.0)); + + formulon::cf::ConditionalFormat block{}; + block.sqref.push_back(MakeRange(0, 0, 2, 0)); + formulon::cf::CFRule rule; + rule.type = formulon::cf::RuleType::CellIs; + rule.priority = 1; + rule.dxf_id = 7U; + rule.op = formulon::cf::CellIsOperator::GreaterThan; + rule.formula1 = "50"; + block.rules.push_back(std::move(rule)); + sheet.mutable_conditional_formats().push_back(std::move(block)); + }); + ASSERT_NE(wb.handle, nullptr); + // Recalc so cached cell values are populated for the loaded handle; + // CF evaluation reads the cached values via `EvalContext`. + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + CfResultsGuard results; + ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 2, 0, std::nan(""), &results.handle), 0); + ASSERT_NE(results.handle, nullptr); + ASSERT_EQ(fm_cf_results_cell_count(results.handle), 2U); + + // First matched cell: A2 (row=1). + std::uint32_t row = 0; + std::uint32_t col = 0; + std::size_t match_count = 0; + ASSERT_EQ(fm_cf_results_cell_at(results.handle, 0, &row, &col, &match_count), 0); + EXPECT_EQ(row, 1U); + EXPECT_EQ(col, 0U); + ASSERT_EQ(match_count, 1U); + fm_cf_match_t m{}; + ASSERT_EQ(fm_cf_results_match_at(results.handle, 0, 0, &m), 0); + EXPECT_EQ(m.kind, FM_CF_DIFFERENTIAL_FORMAT); + EXPECT_EQ(m.priority, 1); + EXPECT_EQ(m.dxf_id_engaged, 1); + EXPECT_EQ(m.dxf_id, 7U); + + // Second matched cell: A3 (row=2). + ASSERT_EQ(fm_cf_results_cell_at(results.handle, 1, &row, &col, &match_count), 0); + EXPECT_EQ(row, 2U); + EXPECT_EQ(col, 0U); + ASSERT_EQ(match_count, 1U); + ASSERT_EQ(fm_cf_results_match_at(results.handle, 1, 0, &m), 0); + EXPECT_EQ(m.kind, FM_CF_DIFFERENTIAL_FORMAT); + EXPECT_EQ(m.dxf_id, 7U); +} + +TEST(FormulonCApiCf, EvaluateRangeRejectsRequestPastViewportCeiling) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + // Two full columns are past the viewport ceiling the engine enforces; + // the binding must surface the refusal instead of returning a handle. + CfResultsGuard results; + EXPECT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, formulon::cf::kCfMaxRows - 1U, 1, std::nan(""), + &results.handle), + static_cast(formulon::FormulonErrorCode::kSecResourceLimit)); + EXPECT_EQ(results.handle, nullptr); +} + +TEST(FormulonCApiCf, EvaluateWholeColumnRangeReturnsBlockMatches) { + WorkbookGuard wb = WorkbookFromMutator([](formulon::Workbook& w) { + auto& sheet = w.sheet(0); + sheet.set_cell_value(0, 0, formulon::Value::number(10.0)); + sheet.set_cell_value(1, 0, formulon::Value::number(60.0)); + + formulon::cf::ConditionalFormat block{}; + block.sqref.push_back(MakeRange(0, 0, 1, 0)); + formulon::cf::CFRule rule; + rule.type = formulon::cf::RuleType::CellIs; + rule.priority = 1; + rule.dxf_id = 7U; + rule.op = formulon::cf::CellIsOperator::GreaterThan; + rule.formula1 = "50"; + block.rules.push_back(std::move(rule)); + sheet.mutable_conditional_formats().push_back(std::move(block)); + }); + ASSERT_NE(wb.handle, nullptr); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + // A whole column sits exactly at the ceiling and is answered from the + // block, not from the column's million rows. + CfResultsGuard results; + ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, formulon::cf::kCfMaxRows - 1U, 0, std::nan(""), + &results.handle), + 0) + << fm_last_error_message(); + ASSERT_EQ(fm_cf_results_cell_count(results.handle), 1U); + + std::uint32_t row = 0; + std::uint32_t col = 0; + std::size_t match_count = 0; + ASSERT_EQ(fm_cf_results_cell_at(results.handle, 0, &row, &col, &match_count), 0); + EXPECT_EQ(row, 1U); + EXPECT_EQ(col, 0U); + ASSERT_EQ(match_count, 1U); + fm_cf_match_t m{}; + ASSERT_EQ(fm_cf_results_match_at(results.handle, 0, 0, &m), 0); + EXPECT_EQ(m.dxf_id_engaged, 1); + EXPECT_EQ(m.dxf_id, 7U); +} + +TEST(FormulonCApiCf, EvaluateRangeWithColorScale) { + WorkbookGuard wb = WorkbookFromMutator([](formulon::Workbook& w) { + auto& sheet = w.sheet(0); + sheet.set_cell_value(0, 0, formulon::Value::number(0.0)); + sheet.set_cell_value(1, 0, formulon::Value::number(50.0)); + sheet.set_cell_value(2, 0, formulon::Value::number(100.0)); + + formulon::cf::ConditionalFormat block{}; + block.sqref.push_back(MakeRange(0, 0, 2, 0)); + + formulon::cf::CFRule rule; + rule.type = formulon::cf::RuleType::ColorScale; + rule.priority = 1; + + formulon::cf::ColorScaleSpec spec; + spec.thresholds.push_back({formulon::cf::CfvoType::Min, "", true}); + spec.thresholds.push_back({formulon::cf::CfvoType::Percentile, "50", true}); + spec.thresholds.push_back({formulon::cf::CfvoType::Max, "", true}); + spec.colors.push_back({255, 0, 0, 255}); // red + spec.colors.push_back({255, 255, 0, 255}); // yellow + spec.colors.push_back({0, 255, 0, 255}); // green + rule.color_scale = std::move(spec); + + block.rules.push_back(std::move(rule)); + sheet.mutable_conditional_formats().push_back(std::move(block)); + }); + ASSERT_NE(wb.handle, nullptr); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + CfResultsGuard results; + ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 2, 0, std::nan(""), &results.handle), 0); + ASSERT_EQ(fm_cf_results_cell_count(results.handle), 3U); + + const std::vector> expected = { + {255, 0, 0}, // A1 = 0 → red endpoint + {255, 255, 0}, // A2 = 50 → yellow midpoint + {0, 255, 0}, // A3 = 100 → green endpoint + }; + + for (std::size_t i = 0; i < expected.size(); ++i) { + std::uint32_t row = 0; + std::uint32_t col = 0; + std::size_t match_count = 0; + ASSERT_EQ(fm_cf_results_cell_at(results.handle, i, &row, &col, &match_count), 0); + ASSERT_EQ(match_count, 1U) << "i=" << i; + fm_cf_match_t m{}; + ASSERT_EQ(fm_cf_results_match_at(results.handle, i, 0, &m), 0) << "i=" << i; + EXPECT_EQ(m.kind, FM_CF_COLOR_SCALE) << "i=" << i; + const auto& [er, eg, eb] = expected[i]; + EXPECT_NEAR(static_cast(m.color.r), static_cast(er), 1) << "i=" << i; + EXPECT_NEAR(static_cast(m.color.g), static_cast(eg), 1) << "i=" << i; + EXPECT_NEAR(static_cast(m.color.b), static_cast(eb), 1) << "i=" << i; + } +} + +TEST(FormulonCApiCf, DestroyHandlesNullSafely) { + fm_cf_results_destroy(nullptr); + // No assertion needed; we just exercise the null path for ASan. + SUCCEED(); +} + +TEST(FormulonCApiCf, OutOfRangeIndicesReturnInvalidArgument) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + CfResultsGuard results; + ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 0, 0, std::nan(""), &results.handle), 0); + ASSERT_EQ(fm_cf_results_cell_count(results.handle), 0U); + + std::uint32_t row = 0; + std::uint32_t col = 0; + std::size_t match_count = 0; + fm_status_t rc = fm_cf_results_cell_at(results.handle, 0, &row, &col, &match_count); + EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + fm_cf_match_t m{}; + rc = fm_cf_results_match_at(results.handle, 0, 0, &m); + EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); +} + +TEST(FormulonCApiCf, NullWorkbookSetsBindingError) { + fm_cf_results_t* out = reinterpret_cast(static_cast(1)); + fm_status_t rc = fm_workbook_cf_evaluate_range(nullptr, 0, 0, 0, 0, 0, std::nan(""), &out); + EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + EXPECT_EQ(out, nullptr); + fm_cf_results_destroy(out); +} + +TEST(FormulonCApiCf, OutOfRangeSheetIndexReturnsInvalidArgument) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_results_t* out = reinterpret_cast(static_cast(1)); + fm_status_t rc = fm_workbook_cf_evaluate_range(wb.handle, 99, 0, 0, 0, 0, std::nan(""), &out); + EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(out, nullptr); + fm_cf_results_destroy(out); +} + +TEST(FormulonCApiCf, OutOfGridOrReversedRectReturnsInvalidArgument) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_results_t* out = reinterpret_cast(static_cast(1)); + // A reversed rectangle (last < first) would wrap the iteration span. + EXPECT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 5, 5, 0, 0, std::nan(""), &out), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(out, nullptr); + // A corner past the grid ceiling would materialize billions of cells. + out = reinterpret_cast(static_cast(1)); + EXPECT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, formulon::Sheet::kMaxRows, 0, std::nan(""), &out), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(out, nullptr); + fm_cf_results_destroy(out); +} + +TEST(FormulonCApiCf, ExpressionRuleEvaluatesFunctionsAndQualifiedRefs) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDxfs(wb.handle, 4); + ASSERT_EQ(fm_workbook_add_sheet(wb.handle, "Sheet2"), 0); + ASSERT_EQ(fm_workbook_set_number(wb.handle, 0, 0, 0, 11.0), 0); // Sheet1!A1 + ASSERT_EQ(fm_workbook_set_number(wb.handle, 1, 0, 0, 5.0), 0); // Sheet2!A1 + + fm_cf_cell_range_t sqref{0, 0, 0, 0}; + fm_cf_rule_t rule{}; + rule.type = 0; // Expression + rule.formula1 = "AND(A1>10,Sheet2!A1=5)"; + rule.dxf_id_engaged = 1; + rule.dxf_id = 3; + rule.sqref = &sqref; + rule.sqref_count = 1; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + EXPECT_EQ(rule_index, 0U); + + CfResultsGuard results; + ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 0, 0, std::nan(""), &results.handle), 0) + << fm_last_error_message(); + ASSERT_EQ(fm_cf_results_cell_count(results.handle), 1U); + + fm_cf_match_t match{}; + ASSERT_EQ(fm_cf_results_match_at(results.handle, 0, 0, &match), 0); + EXPECT_EQ(match.kind, FM_CF_DIFFERENTIAL_FORMAT); + EXPECT_EQ(match.dxf_id_engaged, 1); + EXPECT_EQ(match.dxf_id, 3U); +} + +TEST(FormulonCApiCf, ExpressionRuleRecursivelyEvaluatesFormulaCells) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDxfs(wb.handle, 5); + ASSERT_EQ(fm_workbook_add_sheet(wb.handle, "Sheet2"), 0); + ASSERT_EQ(fm_workbook_set_number(wb.handle, 1, 0, 0, 7.0), 0); // Sheet2!A1 + ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, 0, 0, "=SUM(Sheet2!A1,5)"), 0); // Sheet1!A1 + + fm_cf_cell_range_t sqref{0, 0, 0, 0}; + fm_cf_rule_t rule{}; + rule.type = 0; // Expression + rule.formula1 = "A1>10"; + rule.dxf_id_engaged = 1; + rule.dxf_id = 4; + rule.sqref = &sqref; + rule.sqref_count = 1; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + EXPECT_EQ(rule_index, 0U); + + // No explicit recalc: CF evaluation should use a recursive EvalState + // instead of reading the formula cell's stale blank cached value. + CfResultsGuard results; + ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 0, 0, std::nan(""), &results.handle), 0) + << fm_last_error_message(); + ASSERT_EQ(fm_cf_results_cell_count(results.handle), 1U); + + fm_cf_match_t match{}; + ASSERT_EQ(fm_cf_results_match_at(results.handle, 0, 0, &match), 0); + EXPECT_EQ(match.kind, FM_CF_DIFFERENTIAL_FORMAT); + EXPECT_EQ(match.dxf_id_engaged, 1); + EXPECT_EQ(match.dxf_id, 4U); +} diff --git a/tests/c_api/formulon_c_cf_overlay_test.cpp b/tests/c_api/formulon_c_cf_overlay_test.cpp new file mode 100644 index 000000000..c92f28501 --- /dev/null +++ b/tests/c_api/formulon_c_cf_overlay_test.cpp @@ -0,0 +1,223 @@ +// Stable C ABI conditional-format extended-overlay tests. + +#include +#include + +#include "formulon_c_cf_test_helpers.h" + +namespace { + +constexpr const char* kX14IdA = "{11111111-1111-1111-1111-111111111111}"; +constexpr const char* kX14IdB = "{22222222-2222-2222-2222-222222222222}"; + +// Builds an Excel-shaped worksheet-level x14 overlay carrying one +// dataBar `` per id. +std::string X14OverlayFor(std::initializer_list ids) { + std::string rules; + for (const char* id : ids) { + rules.append("" + ""); + } + return "" + "" + "" + + rules + "A1:A10"; +} + +// Builds the nested `` link a legacy `` carries to reach +// its x14 counterpart. This, not an `id` attribute on the ``, is +// how Excel spells the cross-reference, so a rule loaded from an +// x14-bearing file always has one. +std::string X14RuleLinkFor(const char* id) { + return std::string( + "") + + id + ""; +} + +// Seeds sheet 0 with one CF block holding two id-bearing dataBar rules +// plus the matching two-entry x14 overlay, mirroring the state produced +// by loading an Excel 2010+ file with extended data bars. +void SeedDataBarRulesWithOverlay(fm_workbook_t* handle) { + auto& sheet = handle->workbook().sheet(0); + formulon::cf::ConditionalFormat block{}; + block.sqref.push_back(MakeRange(0, 0, 9, 0)); + for (const char* id : {kX14IdA, kX14IdB}) { + formulon::cf::CFRule rule; + rule.type = formulon::cf::RuleType::DataBar; + rule.id = id; + rule.ext_lst_raw = X14RuleLinkFor(id); + rule.data_bar = formulon::cf::DataBarSpec{}; + block.rules.push_back(std::move(rule)); + } + sheet.mutable_conditional_formats().push_back(std::move(block)); + sheet.set_ext_lst_xml(X14OverlayFor({kX14IdA, kX14IdB})); +} + +bool ContainsSubstring(const std::string& haystack, const std::string& needle) { + return haystack.find(needle) != std::string::npos; +} + +} // namespace + +TEST(FormulonCApiCfMutate, RemoveAtPrunesRemovedRuleFromX14Overlay) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDataBarRulesWithOverlay(wb.handle); + + ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); + + const std::string& overlay = wb.handle->workbook().sheet(0).ext_lst_xml(); + EXPECT_FALSE(ContainsSubstring(overlay, kX14IdA)); + EXPECT_TRUE(ContainsSubstring(overlay, kX14IdB)); +} + +TEST(FormulonCApiCfMutate, ClearEmptiesX14Overlay) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDataBarRulesWithOverlay(wb.handle); + + ASSERT_EQ(fm_sheet_cf_clear(wb.handle, 0), 0); + EXPECT_TRUE(wb.handle->workbook().sheet(0).ext_lst_xml().empty()); +} + +TEST(FormulonCApiCfMutate, MalformedOverlayDroppedWhollyOnRemove) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDataBarRulesWithOverlay(wb.handle); + wb.handle->workbook().sheet(0).set_ext_lst_xml(""); + + ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); + EXPECT_TRUE(wb.handle->workbook().sheet(0).ext_lst_xml().empty()); +} + +TEST(FormulonCApiCfMutate, RemovedRuleDoesNotResurfaceThroughSaveLoad) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDataBarRulesWithOverlay(wb.handle); + + ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + ASSERT_GT(saved.len, 0U); + + WorkbookGuard reloaded; + ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); + const auto& sheet = reloaded.handle->workbook().sheet(0); + ASSERT_EQ(sheet.conditional_formats().size(), 1U); + ASSERT_EQ(sheet.conditional_formats()[0].rules.size(), 1U); + EXPECT_EQ(sheet.conditional_formats()[0].rules[0].id, kX14IdB); + EXPECT_FALSE(ContainsSubstring(sheet.ext_lst_xml(), kX14IdA)); + EXPECT_TRUE(ContainsSubstring(sheet.ext_lst_xml(), kX14IdB)); +} + +TEST(FormulonCApiCfMutate, DataBarExtensionFieldsSurviveSaveAndLoad) { + // Reaching the model is not enough: axis, gradient and the negative + // colours have no legacy `` attribute, so a save that does not + // build the x14 extension loses every one of them and the caller gets a + // plain bar back. This is the only test on this path that crosses the + // ABI boundary in both directions. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule = MakeDataBarRule(&sqref); + rule.data_bar_gradient_engaged = 1; + rule.data_bar_gradient = 0; + rule.data_bar_axis_position_engaged = 1; + rule.data_bar_axis_position = 1; // middle + rule.data_bar_negative_fill_engaged = 1; + rule.data_bar_negative_fill = fm_cf_color_t{255, 0, 0, 255}; + rule.data_bar_border_engaged = 1; + rule.data_bar_border = fm_cf_color_t{1, 2, 3, 255}; + rule.data_bar_negative_border_engaged = 1; + rule.data_bar_negative_border = fm_cf_color_t{4, 5, 6, 255}; + rule.data_bar_axis_color_engaged = 1; + rule.data_bar_axis_color = fm_cf_color_t{7, 8, 9, 255}; + rule.data_bar_direction = 2; // right to left + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + WorkbookGuard reloaded; + ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); + + fm_cf_rule_t back{}; + ASSERT_EQ(fm_sheet_cf_get_at(reloaded.handle, 0, 0, &back), 0) << fm_last_error_message(); + EXPECT_EQ(back.data_bar_gradient, 0); + EXPECT_EQ(back.data_bar_axis_position, 1U); + EXPECT_EQ(back.data_bar_negative_fill.r, 255U); + EXPECT_EQ(back.data_bar_negative_fill.g, 0U); + EXPECT_EQ(back.data_bar_border_engaged, 1); + EXPECT_EQ(back.data_bar_border.b, 3U); + EXPECT_EQ(back.data_bar_negative_border_engaged, 1); + EXPECT_EQ(back.data_bar_negative_border.b, 6U); + EXPECT_EQ(back.data_bar_axis_color.r, 7U); + EXPECT_EQ(back.data_bar_axis_color.b, 9U); + EXPECT_EQ(back.data_bar_direction, 2U); +} + +TEST(FormulonCApiCfMutate, IconSetFloorSurvivesSaveAndLoad) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule{}; + rule.type = 4; // IconSet + rule.sqref = &sqref; + rule.sqref_count = 1; + rule.icon_set_engaged = 1; + rule.icon_set_name = 2; // Three_Flags + fm_cfvo_t thresholds[2]{}; + thresholds[0].type = 0; // Number + thresholds[0].value = "7"; + thresholds[0].gte = 1; + thresholds[1].type = 0; + thresholds[1].value = "9"; + thresholds[1].gte = 1; + rule.icon_set_thresholds = thresholds; + rule.icon_set_threshold_count = 2; + rule.icon_set_show_value = 1; + rule.icon_set_percent = 1; + rule.icon_set_floor_engaged = 1; + rule.icon_set_floor.type = 0; // Number + rule.icon_set_floor.value = "5"; + rule.icon_set_floor.gte = 0; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + WorkbookGuard reloaded; + ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); + fm_cf_rule_t back{}; + ASSERT_EQ(fm_sheet_cf_get_at(reloaded.handle, 0, 0, &back), 0) << fm_last_error_message(); + EXPECT_EQ(back.icon_set_floor_engaged, 1); + EXPECT_EQ(back.icon_set_floor.type, 0U); + EXPECT_STREQ(back.icon_set_floor.value, "5"); + EXPECT_EQ(back.icon_set_floor.gte, 0); +} + +TEST(FormulonCApiCfMutate, UnengagedIconSetFloorIsExcelsDefault) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule{}; + rule.type = 4; // IconSet + rule.sqref = &sqref; + rule.sqref_count = 1; + rule.icon_set_engaged = 1; + fm_cfvo_t thresholds[2]{}; + rule.icon_set_thresholds = thresholds; + rule.icon_set_threshold_count = 2; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + const auto& floor = wb.handle->workbook().sheet(0).conditional_formats()[0].rules[0].icon_set->floor; + EXPECT_EQ(floor.type, formulon::cf::CfvoType::Percent); + EXPECT_EQ(floor.value, "0"); + EXPECT_TRUE(floor.gte); +} diff --git a/tests/c_api/formulon_c_cf_test.cpp b/tests/c_api/formulon_c_cf_test.cpp deleted file mode 100644 index 7d3377dea..000000000 --- a/tests/c_api/formulon_c_cf_test.cpp +++ /dev/null @@ -1,1651 +0,0 @@ -// -// Stable C ABI conditional-format end-to-end tests. -// -// Drives the `fm_workbook_cf_evaluate_range` / `fm_cf_results_*` surface -// declared in `c_api/formulon_c.h` through the same opaque-handle -// pattern the rest of the C ABI suite uses. Evaluation tests seed CF -// blocks through OOXML round-trip; mutation tests below drive the -// public `fm_sheet_cf_*` add/remove/clear surface directly. - -#include -#include -#include -#include -#include -#include - -#include "c_api/borrowed_arena.h" -#include "c_api/formulon_c.h" -#include "c_api/parts/common.h" -#include "cf/cf_types.h" -#include "gtest/gtest.h" -#include "sheet.h" -#include "utils/error.h" -#include "utils/expected.h" -#include "value.h" -#include "workbook.h" - -namespace { - -// RAII guard so the workbook handle is released even on test failure. -// Move-only so factory functions can return one by value. -struct WorkbookGuard { - fm_workbook_t* handle = nullptr; - ~WorkbookGuard() { fm_workbook_destroy(handle); } - WorkbookGuard() = default; - WorkbookGuard(const WorkbookGuard&) = delete; - WorkbookGuard& operator=(const WorkbookGuard&) = delete; - WorkbookGuard(WorkbookGuard&& other) noexcept : handle(other.handle) { other.handle = nullptr; } - WorkbookGuard& operator=(WorkbookGuard&& other) noexcept { - if (this != &other) { - fm_workbook_destroy(handle); - handle = other.handle; - other.handle = nullptr; - } - return *this; - } -}; - -// RAII guard for `fm_cf_results_t` handles. -struct CfResultsGuard { - fm_cf_results_t* handle = nullptr; - ~CfResultsGuard() { fm_cf_results_destroy(handle); } - CfResultsGuard() = default; - CfResultsGuard(const CfResultsGuard&) = delete; - CfResultsGuard& operator=(const CfResultsGuard&) = delete; -}; - -struct BufferGuard { - uint8_t* data = nullptr; - size_t len = 0; - ~BufferGuard() { fm_buffer_free(data); } - BufferGuard() = default; - BufferGuard(const BufferGuard&) = delete; - BufferGuard& operator=(const BufferGuard&) = delete; -}; - -// Builds a `Workbook`, applies `mutate` to seed cells / CF blocks, then -// serialises it through OOXML and loads the resulting bytes through the -// C ABI. The returned guard owns a populated `fm_workbook_t*` ready for -// CF evaluation. -template -WorkbookGuard WorkbookFromMutator(MutateFn&& mutate) { - formulon::Workbook wb = formulon::Workbook::create(); - // Registers the `` records the CF rules below reference. The - // writer omits any `dxfId` its styles part cannot resolve, so a rule - // whose differential format was never registered would come back from - // the load with no `dxf_id` at all. - wb.mutable_styles().dxfs.resize(8); - mutate(wb); - auto bytes = wb.save(); - EXPECT_TRUE(static_cast(bytes)) << "Workbook::save: " << (bytes ? "" : bytes.error().message); - WorkbookGuard guard; - if (!bytes) { - return guard; - } - const auto& src = bytes.value(); - EXPECT_EQ(fm_workbook_load(src.data(), src.size(), &guard.handle), 0) - << "fm_workbook_load: " << fm_last_error_message(); - return guard; -} - -// Registers `count` `` slots on an existing handle. `fm_sheet_cf_add_rule` -// refuses a `dxf_id` past the end of the table, so a mutation test that -// engages a differential format has to seed the table first. -void SeedDxfs(fm_workbook_t* handle, std::size_t count) { - handle->workbook().mutable_styles().dxfs.resize(count); -} - -formulon::cf::CFCellRange MakeRange(std::uint32_t r1, std::uint32_t c1, std::uint32_t r2, std::uint32_t c2) { - return {{r1, c1}, {r2, c2}}; -} - -} // namespace - -TEST(FormulonCApiCf, EvaluateRangeOnEmptyCfWorkbookReturnsZero) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - CfResultsGuard results; - ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 9, 9, std::nan(""), &results.handle), 0); - ASSERT_NE(results.handle, nullptr); - EXPECT_EQ(fm_cf_results_cell_count(results.handle), 0U); -} - -TEST(FormulonCApiCf, EvaluateRangeWithCellIsRule) { - WorkbookGuard wb = WorkbookFromMutator([](formulon::Workbook& w) { - auto& sheet = w.sheet(0); - sheet.set_cell_value(0, 0, formulon::Value::number(10.0)); - sheet.set_cell_value(1, 0, formulon::Value::number(60.0)); - sheet.set_cell_value(2, 0, formulon::Value::number(90.0)); - - formulon::cf::ConditionalFormat block{}; - block.sqref.push_back(MakeRange(0, 0, 2, 0)); - formulon::cf::CFRule rule; - rule.type = formulon::cf::RuleType::CellIs; - rule.priority = 1; - rule.dxf_id = 7U; - rule.op = formulon::cf::CellIsOperator::GreaterThan; - rule.formula1 = "50"; - block.rules.push_back(std::move(rule)); - sheet.mutable_conditional_formats().push_back(std::move(block)); - }); - ASSERT_NE(wb.handle, nullptr); - // Recalc so cached cell values are populated for the loaded handle; - // CF evaluation reads the cached values via `EvalContext`. - ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); - - CfResultsGuard results; - ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 2, 0, std::nan(""), &results.handle), 0); - ASSERT_NE(results.handle, nullptr); - ASSERT_EQ(fm_cf_results_cell_count(results.handle), 2U); - - // First matched cell: A2 (row=1). - std::uint32_t row = 0; - std::uint32_t col = 0; - std::size_t match_count = 0; - ASSERT_EQ(fm_cf_results_cell_at(results.handle, 0, &row, &col, &match_count), 0); - EXPECT_EQ(row, 1U); - EXPECT_EQ(col, 0U); - ASSERT_EQ(match_count, 1U); - fm_cf_match_t m{}; - ASSERT_EQ(fm_cf_results_match_at(results.handle, 0, 0, &m), 0); - EXPECT_EQ(m.kind, FM_CF_DIFFERENTIAL_FORMAT); - EXPECT_EQ(m.priority, 1); - EXPECT_EQ(m.dxf_id_engaged, 1); - EXPECT_EQ(m.dxf_id, 7U); - - // Second matched cell: A3 (row=2). - ASSERT_EQ(fm_cf_results_cell_at(results.handle, 1, &row, &col, &match_count), 0); - EXPECT_EQ(row, 2U); - EXPECT_EQ(col, 0U); - ASSERT_EQ(match_count, 1U); - ASSERT_EQ(fm_cf_results_match_at(results.handle, 1, 0, &m), 0); - EXPECT_EQ(m.kind, FM_CF_DIFFERENTIAL_FORMAT); - EXPECT_EQ(m.dxf_id, 7U); -} - -TEST(FormulonCApiCf, EvaluateRangeRejectsRequestPastViewportCeiling) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - // Two full columns are past the viewport ceiling the engine enforces; - // the binding must surface the refusal instead of returning a handle. - CfResultsGuard results; - EXPECT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, formulon::cf::kCfMaxRows - 1U, 1, std::nan(""), - &results.handle), - static_cast(formulon::FormulonErrorCode::kSecResourceLimit)); - EXPECT_EQ(results.handle, nullptr); -} - -TEST(FormulonCApiCf, EvaluateWholeColumnRangeReturnsBlockMatches) { - WorkbookGuard wb = WorkbookFromMutator([](formulon::Workbook& w) { - auto& sheet = w.sheet(0); - sheet.set_cell_value(0, 0, formulon::Value::number(10.0)); - sheet.set_cell_value(1, 0, formulon::Value::number(60.0)); - - formulon::cf::ConditionalFormat block{}; - block.sqref.push_back(MakeRange(0, 0, 1, 0)); - formulon::cf::CFRule rule; - rule.type = formulon::cf::RuleType::CellIs; - rule.priority = 1; - rule.dxf_id = 7U; - rule.op = formulon::cf::CellIsOperator::GreaterThan; - rule.formula1 = "50"; - block.rules.push_back(std::move(rule)); - sheet.mutable_conditional_formats().push_back(std::move(block)); - }); - ASSERT_NE(wb.handle, nullptr); - ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); - - // A whole column sits exactly at the ceiling and is answered from the - // block, not from the column's million rows. - CfResultsGuard results; - ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, formulon::cf::kCfMaxRows - 1U, 0, std::nan(""), - &results.handle), - 0) - << fm_last_error_message(); - ASSERT_EQ(fm_cf_results_cell_count(results.handle), 1U); - - std::uint32_t row = 0; - std::uint32_t col = 0; - std::size_t match_count = 0; - ASSERT_EQ(fm_cf_results_cell_at(results.handle, 0, &row, &col, &match_count), 0); - EXPECT_EQ(row, 1U); - EXPECT_EQ(col, 0U); - ASSERT_EQ(match_count, 1U); - fm_cf_match_t m{}; - ASSERT_EQ(fm_cf_results_match_at(results.handle, 0, 0, &m), 0); - EXPECT_EQ(m.dxf_id_engaged, 1); - EXPECT_EQ(m.dxf_id, 7U); -} - -TEST(FormulonCApiCf, EvaluateRangeWithColorScale) { - WorkbookGuard wb = WorkbookFromMutator([](formulon::Workbook& w) { - auto& sheet = w.sheet(0); - sheet.set_cell_value(0, 0, formulon::Value::number(0.0)); - sheet.set_cell_value(1, 0, formulon::Value::number(50.0)); - sheet.set_cell_value(2, 0, formulon::Value::number(100.0)); - - formulon::cf::ConditionalFormat block{}; - block.sqref.push_back(MakeRange(0, 0, 2, 0)); - - formulon::cf::CFRule rule; - rule.type = formulon::cf::RuleType::ColorScale; - rule.priority = 1; - - formulon::cf::ColorScaleSpec spec; - spec.thresholds.push_back({formulon::cf::CfvoType::Min, "", true}); - spec.thresholds.push_back({formulon::cf::CfvoType::Percentile, "50", true}); - spec.thresholds.push_back({formulon::cf::CfvoType::Max, "", true}); - spec.colors.push_back({255, 0, 0, 255}); // red - spec.colors.push_back({255, 255, 0, 255}); // yellow - spec.colors.push_back({0, 255, 0, 255}); // green - rule.color_scale = std::move(spec); - - block.rules.push_back(std::move(rule)); - sheet.mutable_conditional_formats().push_back(std::move(block)); - }); - ASSERT_NE(wb.handle, nullptr); - ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); - - CfResultsGuard results; - ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 2, 0, std::nan(""), &results.handle), 0); - ASSERT_EQ(fm_cf_results_cell_count(results.handle), 3U); - - const std::vector> expected = { - {255, 0, 0}, // A1 = 0 → red endpoint - {255, 255, 0}, // A2 = 50 → yellow midpoint - {0, 255, 0}, // A3 = 100 → green endpoint - }; - - for (std::size_t i = 0; i < expected.size(); ++i) { - std::uint32_t row = 0; - std::uint32_t col = 0; - std::size_t match_count = 0; - ASSERT_EQ(fm_cf_results_cell_at(results.handle, i, &row, &col, &match_count), 0); - ASSERT_EQ(match_count, 1U) << "i=" << i; - fm_cf_match_t m{}; - ASSERT_EQ(fm_cf_results_match_at(results.handle, i, 0, &m), 0) << "i=" << i; - EXPECT_EQ(m.kind, FM_CF_COLOR_SCALE) << "i=" << i; - const auto& [er, eg, eb] = expected[i]; - EXPECT_NEAR(static_cast(m.color.r), static_cast(er), 1) << "i=" << i; - EXPECT_NEAR(static_cast(m.color.g), static_cast(eg), 1) << "i=" << i; - EXPECT_NEAR(static_cast(m.color.b), static_cast(eb), 1) << "i=" << i; - } -} - -TEST(FormulonCApiCf, DestroyHandlesNullSafely) { - fm_cf_results_destroy(nullptr); - // No assertion needed; we just exercise the null path for ASan. - SUCCEED(); -} - -TEST(FormulonCApiCf, OutOfRangeIndicesReturnInvalidArgument) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - CfResultsGuard results; - ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 0, 0, std::nan(""), &results.handle), 0); - ASSERT_EQ(fm_cf_results_cell_count(results.handle), 0U); - - std::uint32_t row = 0; - std::uint32_t col = 0; - std::size_t match_count = 0; - fm_status_t rc = fm_cf_results_cell_at(results.handle, 0, &row, &col, &match_count); - EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - fm_cf_match_t m{}; - rc = fm_cf_results_match_at(results.handle, 0, 0, &m); - EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); -} - -TEST(FormulonCApiCf, NullWorkbookSetsBindingError) { - fm_cf_results_t* out = reinterpret_cast(static_cast(1)); - fm_status_t rc = fm_workbook_cf_evaluate_range(nullptr, 0, 0, 0, 0, 0, std::nan(""), &out); - EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - EXPECT_EQ(out, nullptr); - fm_cf_results_destroy(out); -} - -TEST(FormulonCApiCf, OutOfRangeSheetIndexReturnsInvalidArgument) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_results_t* out = reinterpret_cast(static_cast(1)); - fm_status_t rc = fm_workbook_cf_evaluate_range(wb.handle, 99, 0, 0, 0, 0, std::nan(""), &out); - EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(out, nullptr); - fm_cf_results_destroy(out); -} - -TEST(FormulonCApiCf, OutOfGridOrReversedRectReturnsInvalidArgument) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_results_t* out = reinterpret_cast(static_cast(1)); - // A reversed rectangle (last < first) would wrap the iteration span. - EXPECT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 5, 5, 0, 0, std::nan(""), &out), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(out, nullptr); - // A corner past the grid ceiling would materialize billions of cells. - out = reinterpret_cast(static_cast(1)); - EXPECT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, formulon::Sheet::kMaxRows, 0, std::nan(""), &out), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(out, nullptr); - fm_cf_results_destroy(out); -} - -TEST(FormulonCApiCf, ExpressionRuleEvaluatesFunctionsAndQualifiedRefs) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDxfs(wb.handle, 4); - ASSERT_EQ(fm_workbook_add_sheet(wb.handle, "Sheet2"), 0); - ASSERT_EQ(fm_workbook_set_number(wb.handle, 0, 0, 0, 11.0), 0); // Sheet1!A1 - ASSERT_EQ(fm_workbook_set_number(wb.handle, 1, 0, 0, 5.0), 0); // Sheet2!A1 - - fm_cf_cell_range_t sqref{0, 0, 0, 0}; - fm_cf_rule_t rule{}; - rule.type = 0; // Expression - rule.formula1 = "AND(A1>10,Sheet2!A1=5)"; - rule.dxf_id_engaged = 1; - rule.dxf_id = 3; - rule.sqref = &sqref; - rule.sqref_count = 1; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - EXPECT_EQ(rule_index, 0U); - - CfResultsGuard results; - ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 0, 0, std::nan(""), &results.handle), 0) - << fm_last_error_message(); - ASSERT_EQ(fm_cf_results_cell_count(results.handle), 1U); - - fm_cf_match_t match{}; - ASSERT_EQ(fm_cf_results_match_at(results.handle, 0, 0, &match), 0); - EXPECT_EQ(match.kind, FM_CF_DIFFERENTIAL_FORMAT); - EXPECT_EQ(match.dxf_id_engaged, 1); - EXPECT_EQ(match.dxf_id, 3U); -} - -TEST(FormulonCApiCf, ExpressionRuleRecursivelyEvaluatesFormulaCells) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDxfs(wb.handle, 5); - ASSERT_EQ(fm_workbook_add_sheet(wb.handle, "Sheet2"), 0); - ASSERT_EQ(fm_workbook_set_number(wb.handle, 1, 0, 0, 7.0), 0); // Sheet2!A1 - ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, 0, 0, "=SUM(Sheet2!A1,5)"), 0); // Sheet1!A1 - - fm_cf_cell_range_t sqref{0, 0, 0, 0}; - fm_cf_rule_t rule{}; - rule.type = 0; // Expression - rule.formula1 = "A1>10"; - rule.dxf_id_engaged = 1; - rule.dxf_id = 4; - rule.sqref = &sqref; - rule.sqref_count = 1; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - EXPECT_EQ(rule_index, 0U); - - // No explicit recalc: CF evaluation should use a recursive EvalState - // instead of reading the formula cell's stale blank cached value. - CfResultsGuard results; - ASSERT_EQ(fm_workbook_cf_evaluate_range(wb.handle, 0, 0, 0, 0, 0, std::nan(""), &results.handle), 0) - << fm_last_error_message(); - ASSERT_EQ(fm_cf_results_cell_count(results.handle), 1U); - - fm_cf_match_t match{}; - ASSERT_EQ(fm_cf_results_match_at(results.handle, 0, 0, &match), 0); - EXPECT_EQ(match.kind, FM_CF_DIFFERENTIAL_FORMAT); - EXPECT_EQ(match.dxf_id_engaged, 1); - EXPECT_EQ(match.dxf_id, 4U); -} - -// --------------------------------------------------------------------------- -// CF mutation API (fm_sheet_cf_count / get_at / add_rule / remove_at / clear) -// --------------------------------------------------------------------------- - -TEST(FormulonCApiCfMutate, AddCellIsRuleRoundTrips) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDxfs(wb.handle, 1); - - fm_cf_cell_range_t sqref{}; - sqref.first_row = 0; - sqref.first_col = 0; - sqref.last_row = 9; - sqref.last_col = 0; - - fm_cf_rule_t rule{}; - rule.type = 1; // CellIs - rule.op_engaged = 1; - rule.op = 5; // GreaterThan - std::string formula = "50"; - rule.formula1 = formula.c_str(); - rule.dxf_id_engaged = 1; - rule.dxf_id = 0; - rule.sqref = &sqref; - rule.sqref_count = 1; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0); - EXPECT_EQ(rule_index, 0U); - - std::size_t count = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 1U); - - fm_cf_rule_t out{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); - EXPECT_EQ(out.type, 1U); - EXPECT_EQ(out.op_engaged, 1); - EXPECT_EQ(out.op, 5U); - EXPECT_EQ(out.priority, 1); // auto-assigned since input <= 0 - EXPECT_EQ(out.dxf_id_engaged, 1); - EXPECT_EQ(out.dxf_id, 0U); - ASSERT_NE(out.formula1, nullptr); - EXPECT_STREQ(out.formula1, "50"); - ASSERT_NE(out.sqref, nullptr); - EXPECT_EQ(out.sqref_count, 1U); - EXPECT_EQ(out.sqref[0].first_row, 0U); - EXPECT_EQ(out.sqref[0].last_row, 9U); - ASSERT_NE(out.id, nullptr); - EXPECT_FALSE(std::string(out.id).empty()); -} - -TEST(FormulonCApiCfMutate, AddRuleRejectsDxfIdPastTheStylesTable) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDxfs(wb.handle, 2); - - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule{}; - rule.type = 1; // CellIs - rule.op_engaged = 1; - rule.op = 5; // GreaterThan - rule.formula1 = "50"; - rule.dxf_id_engaged = 1; - rule.dxf_id = 2; // one past the last registered `` - rule.sqref = &sqref; - rule.sqref_count = 1; - - std::size_t rule_index = 99U; - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_STREQ(fm_last_error_message(), "fm_sheet_cf_add_rule: dxf_id out of range"); - - // The rejected rule is not appended, so a workbook saved after the failed - // call carries no `` whose `dxfId` Excel cannot resolve. - std::size_t count = 99U; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 0U); - - // The last in-range index is still accepted. - rule.dxf_id = 1; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 1U); -} - -TEST(FormulonCApiCfMutate, AddRuleWithoutDxfIdIgnoresTheEmptyStylesTable) { - // A rule that engages no differential format must stay addable on a - // workbook that has registered no `` at all. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule{}; - rule.type = 1; // CellIs - rule.op_engaged = 1; - rule.op = 5; // GreaterThan - rule.formula1 = "50"; - rule.sqref = &sqref; - rule.sqref_count = 1; - - std::size_t rule_index = 99U; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - EXPECT_EQ(rule_index, 0U); - - fm_cf_rule_t out{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); - EXPECT_EQ(out.dxf_id_engaged, 0); -} - -TEST(FormulonCApiCfMutate, AddMultipleRulesAutoIncrementsPriority) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - fm_cf_cell_range_t sqref{0, 0, 0, 0}; - for (int i = 0; i < 3; ++i) { - fm_cf_rule_t rule{}; - rule.type = 0; // Expression - std::string f = "TRUE"; - rule.formula1 = f.c_str(); - rule.sqref = &sqref; - rule.sqref_count = 1; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0); - EXPECT_EQ(rule_index, static_cast(i)); - } - - std::size_t count = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 3U); - for (std::size_t i = 0; i < 3; ++i) { - fm_cf_rule_t out{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, i, &out), 0); - EXPECT_EQ(out.priority, static_cast(i + 1)); - } -} - -TEST(FormulonCApiCfMutate, RemoveAtFlattensIndices) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - fm_cf_cell_range_t sqref{0, 0, 0, 0}; - std::vector formulas{"A1", "A2", "A3"}; - std::size_t expected_index = 0; - for (const auto& f : formulas) { - fm_cf_rule_t rule{}; - rule.type = 0; // Expression - rule.formula1 = f.c_str(); - rule.sqref = &sqref; - rule.sqref_count = 1; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0); - EXPECT_EQ(rule_index, expected_index); - ++expected_index; - } - std::size_t count = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - ASSERT_EQ(count, 3U); - - ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 1), 0); - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 2U); - - // A record's storage belongs to the handle and the next get takes it - // back, so each rule is read before the following one is fetched. - fm_cf_rule_t out0{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out0), 0); - EXPECT_STREQ(out0.formula1, "A1"); - fm_cf_rule_t out1{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 1, &out1), 0); - EXPECT_STREQ(out1.formula1, "A3"); -} - -TEST(FormulonCApiCfMutate, ClearRemovesAllBlocks) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 0, 0}; - fm_cf_rule_t rule{}; - rule.type = 0; - std::string f = "TRUE"; - rule.formula1 = f.c_str(); - rule.sqref = &sqref; - rule.sqref_count = 1; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0); - EXPECT_EQ(rule_index, 0U); - - ASSERT_EQ(fm_sheet_cf_clear(wb.handle, 0), 0); - std::size_t count = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 0U); -} - -TEST(FormulonCApiCfMutate, AddsVisualRuleTypesAndPreservesThroughSaveLoad) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 0, 0}; - - fm_cf_rule_t rule{}; - rule.type = 2; // ColorScale - rule.sqref = &sqref; - rule.sqref_count = 1; - fm_cfvo_t thresholds[3]{}; - thresholds[0].type = 3; // Min - thresholds[0].gte = 1; - thresholds[1].type = 1; // Percent - thresholds[1].value = "50"; - thresholds[1].gte = 1; - thresholds[2].type = 4; // Max - thresholds[2].gte = 1; - fm_cf_color_t colors[3]{{255, 0, 0, 255}, {255, 255, 0, 255}, {0, 255, 0, 255}}; - rule.color_scale_thresholds = thresholds; - rule.color_scale_colors = colors; - rule.color_scale_count = 3; - std::size_t color_rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &color_rule_index), 0); - EXPECT_EQ(color_rule_index, 0U); - - fm_cf_cell_range_t db_sqref{1, 0, 1, 0}; - fm_cf_rule_t db_rule{}; - db_rule.type = 3; // DataBar - db_rule.sqref = &db_sqref; - db_rule.sqref_count = 1; - db_rule.data_bar_engaged = 1; - db_rule.data_bar_min.type = 3; // Min - db_rule.data_bar_min.gte = 1; - db_rule.data_bar_max.type = 4; // Max - db_rule.data_bar_max.gte = 1; - db_rule.data_bar_fill = fm_cf_color_t{99, 142, 198, 255}; - db_rule.data_bar_show_value = 1; - db_rule.data_bar_min_length_pct = 10; - db_rule.data_bar_max_length_pct = 90; - std::size_t data_bar_rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, db_rule, &data_bar_rule_index), 0); - EXPECT_EQ(data_bar_rule_index, 1U); - - fm_cf_cell_range_t icon_sqref{2, 0, 2, 0}; - fm_cf_rule_t icon_rule{}; - icon_rule.type = 4; // IconSet - icon_rule.sqref = &icon_sqref; - icon_rule.sqref_count = 1; - icon_rule.icon_set_engaged = 1; - icon_rule.icon_set_name = 0; // Three_Arrows - fm_cfvo_t icon_thresholds[2]{}; - icon_thresholds[0].type = 1; // Percent - icon_thresholds[0].value = "33"; - icon_thresholds[0].gte = 1; - icon_thresholds[1].type = 1; // Percent - icon_thresholds[1].value = "67"; - icon_thresholds[1].gte = 1; - icon_rule.icon_set_thresholds = icon_thresholds; - icon_rule.icon_set_threshold_count = 2; - icon_rule.icon_set_show_value = 1; - icon_rule.icon_set_percent = 1; - std::size_t icon_rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, icon_rule, &icon_rule_index), 0); - EXPECT_EQ(icon_rule_index, 2U); - - const auto& blocks = wb.handle->workbook().sheet(0).conditional_formats(); - ASSERT_EQ(blocks.size(), 3U); - ASSERT_EQ(blocks[0].rules.size(), 1U); - ASSERT_TRUE(blocks[0].rules[0].color_scale.has_value()); - ASSERT_EQ(blocks[0].rules[0].color_scale->thresholds.size(), 3U); - EXPECT_EQ(blocks[0].rules[0].color_scale->thresholds[1].value, "50"); - EXPECT_EQ(blocks[0].rules[0].color_scale->colors[2].g, 255U); - ASSERT_TRUE(blocks[1].rules[0].data_bar.has_value()); - EXPECT_EQ(blocks[1].rules[0].data_bar->fill.b, 198U); - ASSERT_TRUE(blocks[2].rules[0].icon_set.has_value()); - EXPECT_EQ(blocks[2].rules[0].icon_set->thresholds[1].value, "67"); - - fm_cf_rule_t out_color{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out_color), 0); - ASSERT_EQ(out_color.color_scale_count, 3U); - ASSERT_NE(out_color.color_scale_thresholds, nullptr); - ASSERT_NE(out_color.color_scale_colors, nullptr); - EXPECT_EQ(out_color.color_scale_thresholds[1].type, 1U); - EXPECT_STREQ(out_color.color_scale_thresholds[1].value, "50"); - EXPECT_EQ(out_color.color_scale_colors[2].g, 255U); - - fm_cf_rule_t out_bar{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 1, &out_bar), 0); - EXPECT_EQ(out_bar.data_bar_engaged, 1); - EXPECT_EQ(out_bar.data_bar_fill.b, 198U); - EXPECT_EQ(out_bar.data_bar_min_length_pct, 10U); - EXPECT_EQ(out_bar.data_bar_max_length_pct, 90U); - - fm_cf_rule_t out_icon{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 2, &out_icon), 0); - EXPECT_EQ(out_icon.icon_set_engaged, 1); - EXPECT_EQ(out_icon.icon_set_threshold_count, 2U); - ASSERT_NE(out_icon.icon_set_thresholds, nullptr); - EXPECT_STREQ(out_icon.icon_set_thresholds[1].value, "67"); - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); - ASSERT_GT(saved.len, 0U); - - WorkbookGuard reloaded; - ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); - const auto& reloaded_blocks = reloaded.handle->workbook().sheet(0).conditional_formats(); - ASSERT_EQ(reloaded_blocks.size(), 3U); - ASSERT_EQ(reloaded_blocks[0].rules.size(), 1U); - ASSERT_TRUE(reloaded_blocks[0].rules[0].color_scale.has_value()); - EXPECT_EQ(reloaded_blocks[0].rules[0].color_scale->colors[0].r, 255U); - ASSERT_TRUE(reloaded_blocks[1].rules[0].data_bar.has_value()); - EXPECT_EQ(reloaded_blocks[1].rules[0].data_bar->fill.g, 142U); - ASSERT_TRUE(reloaded_blocks[2].rules[0].icon_set.has_value()); - EXPECT_EQ(reloaded_blocks[2].rules[0].icon_set->thresholds.size(), 2U); -} - -namespace { - -// Minimal valid DataBar rule. Extension fields stay zero-initialized so -// each test below engages only what it is about to assert. -fm_cf_rule_t MakeDataBarRule(const fm_cf_cell_range_t* sqref) { - fm_cf_rule_t rule{}; - rule.type = 3; // DataBar - rule.sqref = sqref; - rule.sqref_count = 1; - rule.data_bar_engaged = 1; - rule.data_bar_min.type = 3; // Min - rule.data_bar_min.gte = 1; - rule.data_bar_max.type = 4; // Max - rule.data_bar_max.gte = 1; - rule.data_bar_fill = fm_cf_color_t{99, 142, 198, 255}; - rule.data_bar_show_value = 1; - rule.data_bar_min_length_pct = 10; - rule.data_bar_max_length_pct = 90; - return rule; -} - -const formulon::cf::DataBarSpec& OnlyDataBarSpec(fm_workbook_t* handle) { - const auto& blocks = handle->workbook().sheet(0).conditional_formats(); - EXPECT_EQ(blocks.size(), 1U); - EXPECT_EQ(blocks[0].rules.size(), 1U); - EXPECT_TRUE(blocks[0].rules[0].data_bar.has_value()); - return *blocks[0].rules[0].data_bar; -} - -} // namespace - -TEST(FormulonCApiCfMutate, ZeroInitializedDataBarExtensionKeepsTheModelDefaults) { - // The extension fields were appended to a struct callers already - // zero-initialize. Leaving every `*_engaged` flag at zero must produce - // exactly the spec the ABI produced before those fields existed. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - const fm_cf_rule_t rule = MakeDataBarRule(&sqref); - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - - const formulon::cf::DataBarSpec& spec = OnlyDataBarSpec(wb.handle); - EXPECT_TRUE(spec.gradient); - EXPECT_EQ(spec.axis_position, formulon::cf::DataBarAxisPosition::Automatic); - // Unengaged negative fill still mirrors the positive fill. - EXPECT_EQ(spec.negative_fill.r, spec.fill.r); - EXPECT_EQ(spec.negative_fill.g, spec.fill.g); - EXPECT_EQ(spec.negative_fill.b, spec.fill.b); - EXPECT_EQ(spec.negative_fill.a, spec.fill.a); - EXPECT_FALSE(spec.border.has_value()); - EXPECT_FALSE(spec.negative_border.has_value()); - EXPECT_EQ(spec.axis_color.r, 0U); - EXPECT_EQ(spec.axis_color.g, 0U); - EXPECT_EQ(spec.axis_color.b, 0U); - EXPECT_EQ(spec.axis_color.a, 255U); -} - -TEST(FormulonCApiCfMutate, DataBarExtensionFieldsReachTheModelWhenEngaged) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule = MakeDataBarRule(&sqref); - rule.data_bar_gradient_engaged = 1; - rule.data_bar_gradient = 0; // solid, not gradient - rule.data_bar_axis_position_engaged = 1; - rule.data_bar_axis_position = 1; // middle - rule.data_bar_negative_fill_engaged = 1; - rule.data_bar_negative_fill = fm_cf_color_t{255, 0, 0, 255}; - rule.data_bar_border_engaged = 1; - rule.data_bar_border = fm_cf_color_t{1, 2, 3, 255}; - rule.data_bar_negative_border_engaged = 1; - rule.data_bar_negative_border = fm_cf_color_t{4, 5, 6, 255}; - rule.data_bar_axis_color_engaged = 1; - rule.data_bar_axis_color = fm_cf_color_t{7, 8, 9, 255}; - - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - - const formulon::cf::DataBarSpec& spec = OnlyDataBarSpec(wb.handle); - EXPECT_FALSE(spec.gradient); - EXPECT_EQ(spec.axis_position, formulon::cf::DataBarAxisPosition::Middle); - EXPECT_EQ(spec.negative_fill.r, 255U); - EXPECT_EQ(spec.negative_fill.g, 0U); - ASSERT_TRUE(spec.border.has_value()); - EXPECT_EQ(spec.border->r, 1U); - EXPECT_EQ(spec.border->b, 3U); - ASSERT_TRUE(spec.negative_border.has_value()); - EXPECT_EQ(spec.negative_border->r, 4U); - EXPECT_EQ(spec.negative_border->b, 6U); - EXPECT_EQ(spec.axis_color.r, 7U); - EXPECT_EQ(spec.axis_color.b, 9U); -} - -TEST(FormulonCApiCfMutate, DataBarRuleSurvivesGetAtFedBackIntoAddRule) { - // The only way to edit a CF rule through this ABI is get -> remove -> - // add, so that path has to be an identity on the rule model, and the - // record has to stay readable across the removal without the caller - // copying anything out of it. Before the extension fields existed the - // sequence silently reset axis, gradient and the negative colours to - // their defaults. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t seed = MakeDataBarRule(&sqref); - seed.data_bar_gradient_engaged = 1; - seed.data_bar_gradient = 0; - seed.data_bar_axis_position_engaged = 1; - seed.data_bar_axis_position = 1; // middle - seed.data_bar_negative_fill_engaged = 1; - seed.data_bar_negative_fill = fm_cf_color_t{200, 30, 40, 255}; - seed.data_bar_border_engaged = 1; - seed.data_bar_border = fm_cf_color_t{11, 22, 33, 255}; - seed.data_bar_axis_color_engaged = 1; - seed.data_bar_axis_color = fm_cf_color_t{44, 55, 66, 255}; - std::size_t seed_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, seed, &seed_index), 0) << fm_last_error_message(); - - // Read it back, change one unrelated field, and write it out again. - fm_cf_rule_t round_trip{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &round_trip), 0); - EXPECT_EQ(round_trip.data_bar_gradient_engaged, 1); - EXPECT_EQ(round_trip.data_bar_gradient, 0); - EXPECT_EQ(round_trip.data_bar_axis_position_engaged, 1); - EXPECT_EQ(round_trip.data_bar_axis_position, 1U); - EXPECT_EQ(round_trip.data_bar_negative_fill_engaged, 1); - EXPECT_EQ(round_trip.data_bar_negative_fill.r, 200U); - EXPECT_EQ(round_trip.data_bar_border_engaged, 1); - EXPECT_EQ(round_trip.data_bar_border.g, 22U); - // No negative border was set, so the getter must report it absent - // rather than handing back an engaged black. - EXPECT_EQ(round_trip.data_bar_negative_border_engaged, 0); - EXPECT_EQ(round_trip.data_bar_axis_color_engaged, 1); - EXPECT_EQ(round_trip.data_bar_axis_color.b, 66U); - - round_trip.stop_if_true = 1; - ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); - std::size_t rewritten_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, round_trip, &rewritten_index), 0) << fm_last_error_message(); - - const formulon::cf::DataBarSpec& spec = OnlyDataBarSpec(wb.handle); - EXPECT_FALSE(spec.gradient); - EXPECT_EQ(spec.axis_position, formulon::cf::DataBarAxisPosition::Middle); - EXPECT_EQ(spec.negative_fill.r, 200U); - EXPECT_EQ(spec.negative_fill.g, 30U); - ASSERT_TRUE(spec.border.has_value()); - EXPECT_EQ(spec.border->b, 33U); - EXPECT_FALSE(spec.negative_border.has_value()); - EXPECT_EQ(spec.axis_color.g, 55U); -} - -TEST(FormulonCApiCfMutate, GetAtRecordStaysReadableUntilTheNextGet) { - // The record the getter fills has to outlive the mutations of the ABI's - // own edit procedure, and unrelated reads that recycle the handle's text - // scratch must not disturb it either. Only the next successful CF get - // takes the storage back. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - ASSERT_EQ(fm_workbook_set_text(wb.handle, 0, 5, 5, "unrelated"), 0); - - fm_cf_cell_range_t sqref[2]{{0, 0, 9, 0}, {4, 3, 14, 3}}; - fm_cf_rule_t seed{}; - seed.id = "{AB000000-0000-0000-0000-000000000001}"; - seed.type = 2; // ColorScale - seed.sqref = sqref; - seed.sqref_count = 2; - seed.formula1 = "SEEDFORMULA"; - fm_cfvo_t thresholds[2]{}; - thresholds[0].type = 0; // Number - thresholds[0].value = "11"; - thresholds[1].type = 0; - thresholds[1].value = "33"; - fm_cf_color_t colors[2]{{255, 0, 0, 255}, {0, 255, 0, 255}}; - seed.color_scale_thresholds = thresholds; - seed.color_scale_colors = colors; - seed.color_scale_count = 2; - std::size_t seed_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, seed, &seed_index), 0) << fm_last_error_message(); - - fm_cf_rule_t held{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &held), 0); - ASSERT_NE(held.sqref, nullptr); - ASSERT_EQ(held.sqref_count, 2U); - ASSERT_EQ(held.color_scale_count, 2U); - - // A removal frees the block the rule lived in. - ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); - std::size_t remaining = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &remaining), 0); - ASSERT_EQ(remaining, 0U); - EXPECT_STREQ(held.id, "{AB000000-0000-0000-0000-000000000001}"); - EXPECT_STREQ(held.formula1, "SEEDFORMULA"); - EXPECT_EQ(held.sqref[1].first_col, 3U); - EXPECT_EQ(held.sqref[1].last_row, 14U); - EXPECT_STREQ(held.color_scale_thresholds[1].value, "33"); - - // An unrelated text read recycles `read_scratch`. - fm_value_t unrelated{}; - ASSERT_EQ(fm_workbook_get_value(wb.handle, 0, 5, 5, &unrelated), 0); - EXPECT_STREQ(held.id, "{AB000000-0000-0000-0000-000000000001}"); - EXPECT_STREQ(held.color_scale_thresholds[0].value, "11"); - - // Feeding the held record straight back is the documented edit path; it - // must not need a caller-side copy of anything. - std::size_t rewritten_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, held, &rewritten_index), 0) << fm_last_error_message(); - const auto& blocks = wb.handle->workbook().sheet(0).conditional_formats(); - ASSERT_EQ(blocks.size(), 1U); - ASSERT_EQ(blocks[0].sqref.size(), 2U); - EXPECT_EQ(blocks[0].sqref[1], MakeRange(4, 3, 14, 3)); - EXPECT_EQ(blocks[0].rules[0].id, "{AB000000-0000-0000-0000-000000000001}"); - ASSERT_TRUE(blocks[0].rules[0].color_scale.has_value()); - EXPECT_EQ(blocks[0].rules[0].color_scale->thresholds[1].value, "33"); -} - -TEST(FormulonCApiCfMutate, DataBarRejectsInvertedLengthsAndUnknownAxisPosition) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - - fm_cf_rule_t inverted = MakeDataBarRule(&sqref); - inverted.data_bar_min_length_pct = 90; - inverted.data_bar_max_length_pct = 10; - std::size_t rule_index = 0; - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, inverted, &rule_index), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - fm_cf_rule_t bad_axis = MakeDataBarRule(&sqref); - bad_axis.data_bar_axis_position_engaged = 1; - bad_axis.data_bar_axis_position = 3; // one past `none` - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, bad_axis, &rule_index), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - // An unengaged out-of-domain axis ordinal is ignored, not rejected: the - // flag is what makes the value meaningful. - fm_cf_rule_t stale_axis = MakeDataBarRule(&sqref); - stale_axis.data_bar_axis_position = 3; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, stale_axis, &rule_index), 0) << fm_last_error_message(); - EXPECT_EQ(OnlyDataBarSpec(wb.handle).axis_position, formulon::cf::DataBarAxisPosition::Automatic); -} - -TEST(FormulonCApiCfMutate, ThresholdStringsStayLiveWhileLaterPayloadsArePulled) { - // Mirrors what the JS bindings do for a rule object that carries more - // than one payload key: every threshold string is pulled into one - // caller-side store, and the record keeps a borrowed view of each. The - // store must therefore not relocate bytes an earlier pull already - // published into the record - which is what a growing vector does on - // the second short string it holds. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - formulon::c_api::BorrowedStringArena strings; - - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule{}; - rule.type = 2; // ColorScale - rule.sqref = &sqref; - rule.sqref_count = 1; - - fm_cfvo_t thresholds[3]{}; - const char* const kThresholdValues[3] = {"11", "22", "33"}; - for (std::size_t i = 0; i < 3; ++i) { - thresholds[i].type = 0; // Number - thresholds[i].gte = 1; - thresholds[i].value = strings.emplace(kThresholdValues[i]); - } - fm_cf_color_t colors[3]{{255, 0, 0, 255}, {255, 255, 0, 255}, {0, 255, 0, 255}}; - rule.color_scale_thresholds = thresholds; - rule.color_scale_colors = colors; - rule.color_scale_count = 3; - - // The same JS object also carried `dataBar` and `iconSet` keys, so the - // binding pulls their threshold strings into the same store after the - // colorScale views are already in the record. - rule.data_bar_engaged = 1; - rule.data_bar_min.type = 0; - rule.data_bar_min.value = strings.emplace("5"); - rule.data_bar_max.type = 0; - rule.data_bar_max.value = strings.emplace("95"); - fm_cfvo_t icon_thresholds[2]{}; - icon_thresholds[0].type = 1; // Percent - icon_thresholds[0].value = strings.emplace("40"); - icon_thresholds[1].type = 1; - icon_thresholds[1].value = strings.emplace("80"); - rule.icon_set_engaged = 1; - rule.icon_set_thresholds = icon_thresholds; - rule.icon_set_threshold_count = 2; - - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - - const auto& blocks = wb.handle->workbook().sheet(0).conditional_formats(); - ASSERT_EQ(blocks.size(), 1U); - ASSERT_TRUE(blocks[0].rules[0].color_scale.has_value()); - const auto& stored = blocks[0].rules[0].color_scale->thresholds; - ASSERT_EQ(stored.size(), 3U); - EXPECT_EQ(stored[0].value, "11"); - EXPECT_EQ(stored[1].value, "22"); - EXPECT_EQ(stored[2].value, "33"); - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); - ASSERT_GT(saved.len, 0U); - WorkbookGuard reloaded; - ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); - const auto& reloaded_blocks = reloaded.handle->workbook().sheet(0).conditional_formats(); - ASSERT_EQ(reloaded_blocks.size(), 1U); - ASSERT_TRUE(reloaded_blocks[0].rules[0].color_scale.has_value()); - const auto& reloaded_thresholds = reloaded_blocks[0].rules[0].color_scale->thresholds; - ASSERT_EQ(reloaded_thresholds.size(), 3U); - EXPECT_EQ(reloaded_thresholds[0].value, "11"); - EXPECT_EQ(reloaded_thresholds[1].value, "22"); - EXPECT_EQ(reloaded_thresholds[2].value, "33"); -} - -TEST(FormulonCApiCfMutate, RuleEngagingSeveralVisualPayloadsSurfacesLivePointers) { - // `cf::CFRule` declares the three visual payloads mutually exclusive, - // but a rule can only be trusted to honour that once every producer - // does. The getter must keep every pointer it writes into the record - // valid until it returns, including for a rule that engages two. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - auto& sheet = wb.handle->workbook().sheet(0); - formulon::cf::ConditionalFormat block{}; - block.sqref.push_back(MakeRange(0, 0, 9, 0)); - formulon::cf::CFRule rule; - rule.type = formulon::cf::RuleType::ColorScale; - rule.priority = 1; - formulon::cf::ColorScaleSpec color_scale; - for (const char* value : {"11", "22", "33"}) { - formulon::cf::CfValueObject cfvo; - cfvo.type = formulon::cf::CfvoType::Number; - cfvo.value = value; - color_scale.thresholds.push_back(cfvo); - color_scale.colors.push_back(formulon::cf::Color{255, 0, 0, 255}); - } - rule.color_scale = std::move(color_scale); - formulon::cf::IconSetSpec icon_set; - for (const char* value : {"40", "80"}) { - formulon::cf::CfValueObject cfvo; - cfvo.type = formulon::cf::CfvoType::Percent; - cfvo.value = value; - icon_set.thresholds.push_back(cfvo); - } - rule.icon_set = std::move(icon_set); - block.rules.push_back(std::move(rule)); - sheet.mutable_conditional_formats().push_back(std::move(block)); - - fm_cf_rule_t out{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); - ASSERT_EQ(out.color_scale_count, 3U); - ASSERT_NE(out.color_scale_thresholds, nullptr); - ASSERT_NE(out.color_scale_colors, nullptr); - // Read after the iconSet payload was filled: the colorScale array must - // not have moved out from under the pointer already in the record. - EXPECT_STREQ(out.color_scale_thresholds[0].value, "11"); - EXPECT_STREQ(out.color_scale_thresholds[2].value, "33"); - EXPECT_EQ(out.color_scale_colors[0].r, 255U); - ASSERT_EQ(out.icon_set_threshold_count, 2U); - ASSERT_NE(out.icon_set_thresholds, nullptr); - EXPECT_STREQ(out.icon_set_thresholds[1].value, "80"); -} - -TEST(FormulonCApiCfMutate, OutOfGridSqrefRejectedAndLeavesTheModelUnchanged) { - // An out-of-grid rectangle has no A1 spelling, so letting one into the - // model leaves the save path with a reference it cannot write. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_rule_t rule{}; - rule.type = 0; // Expression - rule.formula1 = "TRUE"; - rule.sqref_count = 1; - std::size_t rule_index = 0; - - // One past the last column, on a whole-column range. - fm_cf_cell_range_t past_last_col{0, formulon::Sheet::kMaxCols, formulon::Sheet::kMaxRows - 1U, - formulon::Sheet::kMaxCols}; - rule.sqref = &past_last_col; - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - // One past the last row. - fm_cf_cell_range_t past_last_row{0, 0, formulon::Sheet::kMaxRows, 0}; - rule.sqref = &past_last_row; - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - // Inverted rectangle. - fm_cf_cell_range_t inverted{5, 0, 1, 0}; - rule.sqref = &inverted; - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - // A rejected range leaves no block behind, and the rejection is - // reported rather than aborting the process at save time. - EXPECT_TRUE(wb.handle->workbook().sheet(0).conditional_formats().empty()); - BufferGuard rejected_save; - ASSERT_EQ(fm_workbook_save(wb.handle, &rejected_save.data, &rejected_save.len), 0); - - // The widest legal whole-column range still round-trips. - fm_cf_cell_range_t whole_col{0, 0, formulon::Sheet::kMaxRows - 1U, 0}; - rule.sqref = &whole_col; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); - ASSERT_GT(saved.len, 0U); - WorkbookGuard reloaded; - ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); - const auto& blocks = reloaded.handle->workbook().sheet(0).conditional_formats(); - ASSERT_EQ(blocks.size(), 1U); - ASSERT_EQ(blocks[0].sqref.size(), 1U); - EXPECT_EQ(blocks[0].sqref[0], MakeRange(0, 0, formulon::Sheet::kMaxRows - 1U, 0)); -} - -TEST(FormulonCApiCfMutate, EmptySqrefRejected) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_rule_t rule{}; - rule.type = 0; - rule.sqref = nullptr; - rule.sqref_count = 0; - std::size_t rule_index = 0; - fm_status_t rc = fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index); - EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); -} - -TEST(FormulonCApiCfMutate, AddRuleRejectsExcessiveSqrefCount) { - // Hostile caller passes the maximum unsigned 32-bit value as - // `sqref_count`. The `sqref` pointer is non-null so the early - // null-check does not short-circuit, but the binding's range-count - // cap must reject the call before the body attempts a 4 GiB - // `reserve()`. Sheet state must be unchanged on rejection. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::size_t before = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &before), 0); - - fm_cf_cell_range_t sqref{0, 0, 0, 0}; - fm_cf_rule_t rule{}; - rule.type = 0; // Expression - std::string f = "TRUE"; - rule.formula1 = f.c_str(); - rule.sqref = &sqref; - rule.sqref_count = 0xFFFFFFFFu; - std::size_t rule_index = 0; - fm_status_t rc = fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index); - EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - std::size_t after = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &after), 0); - EXPECT_EQ(before, after); -} - -TEST(FormulonCApiCfMutate, GetAtDisengagesADxfIdTheStylesTableCannotResolve) { - // A package can carry a `dxfId` past the end of its own ``, and - // the loader keeps the rule. Surfacing that index would hand the caller - // a value `fm_styles_get_dxf` rejects and `fm_sheet_cf_add_rule` - // refuses, so the get / remove / add-back edit path would not close. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDxfs(wb.handle, 2); - - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule{}; - rule.type = 1; // CellIs - rule.op_engaged = 1; - rule.op = 5; // GreaterThan - rule.formula1 = "50"; - rule.dxf_id_engaged = 1; - rule.dxf_id = 1; - rule.sqref = &sqref; - rule.sqref_count = 1; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - - // Shrink the table under the rule, the way a package whose styles part - // and worksheet part disagree arrives. - wb.handle->workbook().mutable_styles().dxfs.resize(1); - - fm_cf_rule_t out{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0) << fm_last_error_message(); - EXPECT_EQ(out.dxf_id_engaged, 0); - - // The record still feeds straight back in, which is what the disengage - // buys: with the raw index it would be rejected. - std::size_t reinserted = 0; - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, out, &reinserted), 0) << fm_last_error_message(); - - // A resolvable index is still reported. - SeedDxfs(wb.handle, 2); - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); - EXPECT_EQ(out.dxf_id_engaged, 1); - EXPECT_EQ(out.dxf_id, 1U); -} - -TEST(FormulonCApiCfMutate, AddRuleRejectsNonGuidId) { - // The id becomes an `ST_Guid`-typed attribute in the saved package, so - // a host key like "sales-databar" would make Excel repair the workbook - // and drop every conditional format in it. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule{}; - rule.type = 0; // Expression - rule.formula1 = "TRUE"; - rule.sqref = &sqref; - rule.sqref_count = 1; - - std::size_t rule_index = 99U; - for (const char* rejected : {"sales-databar", "{}", "FC000000-0000-0000-0000-000000000001", - "{FC000000-0000-0000-0000-00000000000}", "{GC000000-0000-0000-0000-000000000001}"}) { - rule.id = rejected; - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)) - << rejected; - } - - std::size_t count = 99U; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 0U); - - // A GUID-shaped id is accepted and comes back verbatim. - rule.id = "{3B4F1C22-0000-4000-8000-0123456789AB}"; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - fm_cf_rule_t out{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); - EXPECT_STREQ(out.id, "{3B4F1C22-0000-4000-8000-0123456789AB}"); - - // An absent id is the "synthesize one for me" request, not a malformed - // one, and the id it produces is itself GUID-shaped. - rule.id = ""; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, rule_index, &out), 0); - ASSERT_NE(out.id, nullptr); - const std::string synthesized(out.id); - EXPECT_EQ(synthesized.size(), 38U); - EXPECT_EQ(synthesized.front(), '{'); - EXPECT_EQ(synthesized.back(), '}'); -} - -TEST(FormulonCApiCfMutate, AddRuleRejectsEnumOrdinalsPastTheirDomain) { - // Each of these would otherwise be `static_cast` into its enum and - // collapse onto the consuming switch's default: an unknown `op` writes - // `operator="equal"`, an unknown `time_period` writes `today`. The - // caller sees `kOk` and a rule that means something else. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t base{}; - base.formula1 = "TRUE"; - base.sqref = &sqref; - base.sqref_count = 1; - - const auto expect_rejected = [&wb](fm_cf_rule_t rule, const char* what) { - std::size_t rule_index = 99U; - EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)) - << what; - }; - - fm_cf_rule_t unknown_type = base; - unknown_type.type = static_cast(formulon::cf::RuleType::UniqueValues) + 1U; - expect_rejected(unknown_type, "type"); - - fm_cf_rule_t unknown_op = base; - unknown_op.type = 1; // CellIs - unknown_op.op_engaged = 1; - unknown_op.op = static_cast(formulon::cf::CellIsOperator::NotBetween) + 1U; - expect_rejected(unknown_op, "op"); - - fm_cf_rule_t unknown_period = base; - unknown_period.type = 15; // TimePeriod - unknown_period.time_period_engaged = 1; - unknown_period.time_period = static_cast(formulon::cf::TimePeriod::NextMonth) + 1U; - expect_rejected(unknown_period, "time_period"); - - fm_cfvo_t icon_thresholds[3]{}; - icon_thresholds[1].type = static_cast(formulon::cf::CfvoType::AutoMax) + 1U; - fm_cf_rule_t unknown_cfvo = base; - unknown_cfvo.type = 4; // IconSet - unknown_cfvo.icon_set_engaged = 1; - unknown_cfvo.icon_set_thresholds = icon_thresholds; - unknown_cfvo.icon_set_threshold_count = 3; - expect_rejected(unknown_cfvo, "icon_set_thresholds[].type"); - - fm_cfvo_t legal_thresholds[3]{}; - fm_cf_rule_t unknown_icon_set = base; - unknown_icon_set.type = 4; // IconSet - unknown_icon_set.icon_set_engaged = 1; - unknown_icon_set.icon_set_name = static_cast(formulon::cf::IconSetName::Five_Quarters) + 1U; - unknown_icon_set.icon_set_thresholds = legal_thresholds; - unknown_icon_set.icon_set_threshold_count = 3; - expect_rejected(unknown_icon_set, "icon_set_name"); - - fm_cfvo_t color_thresholds[2]{}; - fm_cf_color_t colors[2]{}; - color_thresholds[0].type = static_cast(formulon::cf::CfvoType::AutoMax) + 1U; - fm_cf_rule_t unknown_color_cfvo = base; - unknown_color_cfvo.type = 2; // ColorScale - unknown_color_cfvo.color_scale_thresholds = color_thresholds; - unknown_color_cfvo.color_scale_colors = colors; - unknown_color_cfvo.color_scale_count = 2; - expect_rejected(unknown_color_cfvo, "color_scale_thresholds[].type"); - - fm_cf_rule_t unknown_floor = base; - unknown_floor.type = 4; // IconSet - unknown_floor.icon_set_engaged = 1; - unknown_floor.icon_set_thresholds = legal_thresholds; - unknown_floor.icon_set_threshold_count = 2; - unknown_floor.icon_set_floor_engaged = 1; - unknown_floor.icon_set_floor.type = static_cast(formulon::cf::CfvoType::AutoMax) + 1U; - expect_rejected(unknown_floor, "icon_set_floor.type"); - - fm_cf_rule_t unknown_direction = base; - unknown_direction.type = 3; // DataBar - unknown_direction.data_bar_engaged = 1; - unknown_direction.data_bar_max.type = 4; // Max - unknown_direction.data_bar_min.type = 3; // Min - unknown_direction.data_bar_min_length_pct = 10; - unknown_direction.data_bar_max_length_pct = 90; - unknown_direction.data_bar_direction = static_cast(formulon::cf::DataBarDirection::RightToLeft) + 1U; - expect_rejected(unknown_direction, "data_bar_direction"); - - // Every rejection left the sheet as it was. - std::size_t count = 99U; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 0U); -} - -TEST(FormulonCApiCfMutate, OutOfRangeIndexReturnsInvalidArgument) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_rule_t out{}; - fm_status_t rc = fm_sheet_cf_get_at(wb.handle, 0, 99, &out); - EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - rc = fm_sheet_cf_remove_at(wb.handle, 0, 99); - EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); -} - -TEST(FormulonCApiCfMutate, PreLoadedRulesEnumerableViaFlatIndex) { - WorkbookGuard wb = WorkbookFromMutator([](formulon::Workbook& w) { - auto& sheet = w.sheet(0); - formulon::cf::ConditionalFormat block; - block.sqref = {MakeRange(0, 0, 9, 0)}; - formulon::cf::CFRule r; - r.type = formulon::cf::RuleType::CellIs; - r.priority = 1; - r.op = formulon::cf::CellIsOperator::GreaterThan; - r.formula1 = "50"; - r.dxf_id = 0; - block.rules.push_back(std::move(r)); - sheet.mutable_conditional_formats().push_back(std::move(block)); - }); - std::size_t count = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 1U); - fm_cf_rule_t out{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); - EXPECT_EQ(out.type, 1U); - ASSERT_NE(out.formula1, nullptr); - EXPECT_STREQ(out.formula1, "50"); -} - -TEST(FormulonCApiCfMutate, DuplicatePredicateRulesGetDistinctStableIndices) { - // Two rules with the identical predicate (same type/op/formula/sqref) - // must still be tracked as distinct entries: `fm_sheet_cf_add_rule` - // always appends a new block rather than deduping against an existing - // one, so the returned index must reflect append order, not predicate - // identity. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDxfs(wb.handle, 1); - - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule{}; - rule.type = 1; // CellIs - rule.op_engaged = 1; - rule.op = 5; // GreaterThan - rule.formula1 = "50"; - rule.dxf_id_engaged = 1; - rule.dxf_id = 0; - rule.sqref = &sqref; - rule.sqref_count = 1; - - std::size_t first_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &first_index), 0) << fm_last_error_message(); - - // Re-populate the rule: `fm_sheet_cf_add_rule` deep-copies string / - // range payloads, but reuse a fresh `fm_cf_rule_t` to avoid relying on - // stale scratch state from the first call. - fm_cf_rule_t rule2{}; - rule2.type = 1; // CellIs - rule2.op_engaged = 1; - rule2.op = 5; // GreaterThan - rule2.formula1 = "50"; - rule2.dxf_id_engaged = 1; - rule2.dxf_id = 0; - rule2.sqref = &sqref; - rule2.sqref_count = 1; - - std::size_t second_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule2, &second_index), 0) << fm_last_error_message(); - - EXPECT_EQ(first_index, 0U); - EXPECT_EQ(second_index, 1U); - EXPECT_NE(first_index, second_index); - - std::size_t count = 0; - ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 2U); - - // The indices returned by add_rule must match what a subsequent - // flattened readback reports for rule order/position. - fm_cf_rule_t readback_first{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, first_index, &readback_first), 0); - EXPECT_EQ(readback_first.priority, 1); // auto-assigned to the first rule added - - fm_cf_rule_t readback_second{}; - ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, second_index, &readback_second), 0); - EXPECT_EQ(readback_second.priority, 2); // auto-assigned one past the first -} - -namespace { - -constexpr const char* kX14IdA = "{11111111-1111-1111-1111-111111111111}"; -constexpr const char* kX14IdB = "{22222222-2222-2222-2222-222222222222}"; - -// Builds an Excel-shaped worksheet-level x14 overlay carrying one -// dataBar `` per id. -std::string X14OverlayFor(std::initializer_list ids) { - std::string rules; - for (const char* id : ids) { - rules.append("" - ""); - } - return "" - "" - "" + - rules + "A1:A10"; -} - -// Builds the nested `` link a legacy `` carries to reach -// its x14 counterpart. This, not an `id` attribute on the ``, is -// how Excel spells the cross-reference, so a rule loaded from an -// x14-bearing file always has one. -std::string X14RuleLinkFor(const char* id) { - return std::string( - "") + - id + ""; -} - -// Seeds sheet 0 with one CF block holding two id-bearing dataBar rules -// plus the matching two-entry x14 overlay, mirroring the state produced -// by loading an Excel 2010+ file with extended data bars. -void SeedDataBarRulesWithOverlay(fm_workbook_t* handle) { - auto& sheet = handle->workbook().sheet(0); - formulon::cf::ConditionalFormat block{}; - block.sqref.push_back(MakeRange(0, 0, 9, 0)); - for (const char* id : {kX14IdA, kX14IdB}) { - formulon::cf::CFRule rule; - rule.type = formulon::cf::RuleType::DataBar; - rule.id = id; - rule.ext_lst_raw = X14RuleLinkFor(id); - rule.data_bar = formulon::cf::DataBarSpec{}; - block.rules.push_back(std::move(rule)); - } - sheet.mutable_conditional_formats().push_back(std::move(block)); - sheet.set_ext_lst_xml(X14OverlayFor({kX14IdA, kX14IdB})); -} - -bool ContainsSubstring(const std::string& haystack, const std::string& needle) { - return haystack.find(needle) != std::string::npos; -} - -} // namespace - -TEST(FormulonCApiCfMutate, RemoveAtPrunesRemovedRuleFromX14Overlay) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDataBarRulesWithOverlay(wb.handle); - - ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); - - const std::string& overlay = wb.handle->workbook().sheet(0).ext_lst_xml(); - EXPECT_FALSE(ContainsSubstring(overlay, kX14IdA)); - EXPECT_TRUE(ContainsSubstring(overlay, kX14IdB)); -} - -TEST(FormulonCApiCfMutate, ClearEmptiesX14Overlay) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDataBarRulesWithOverlay(wb.handle); - - ASSERT_EQ(fm_sheet_cf_clear(wb.handle, 0), 0); - EXPECT_TRUE(wb.handle->workbook().sheet(0).ext_lst_xml().empty()); -} - -TEST(FormulonCApiCfMutate, MalformedOverlayDroppedWhollyOnRemove) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDataBarRulesWithOverlay(wb.handle); - wb.handle->workbook().sheet(0).set_ext_lst_xml(""); - - ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); - EXPECT_TRUE(wb.handle->workbook().sheet(0).ext_lst_xml().empty()); -} - -TEST(FormulonCApiCfMutate, RemovedRuleDoesNotResurfaceThroughSaveLoad) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - SeedDataBarRulesWithOverlay(wb.handle); - - ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); - ASSERT_GT(saved.len, 0U); - - WorkbookGuard reloaded; - ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); - const auto& sheet = reloaded.handle->workbook().sheet(0); - ASSERT_EQ(sheet.conditional_formats().size(), 1U); - ASSERT_EQ(sheet.conditional_formats()[0].rules.size(), 1U); - EXPECT_EQ(sheet.conditional_formats()[0].rules[0].id, kX14IdB); - EXPECT_FALSE(ContainsSubstring(sheet.ext_lst_xml(), kX14IdA)); - EXPECT_TRUE(ContainsSubstring(sheet.ext_lst_xml(), kX14IdB)); -} - -TEST(FormulonCApiCfMutate, DataBarExtensionFieldsSurviveSaveAndLoad) { - // Reaching the model is not enough: axis, gradient and the negative - // colours have no legacy `` attribute, so a save that does not - // build the x14 extension loses every one of them and the caller gets a - // plain bar back. This is the only test on this path that crosses the - // ABI boundary in both directions. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule = MakeDataBarRule(&sqref); - rule.data_bar_gradient_engaged = 1; - rule.data_bar_gradient = 0; - rule.data_bar_axis_position_engaged = 1; - rule.data_bar_axis_position = 1; // middle - rule.data_bar_negative_fill_engaged = 1; - rule.data_bar_negative_fill = fm_cf_color_t{255, 0, 0, 255}; - rule.data_bar_border_engaged = 1; - rule.data_bar_border = fm_cf_color_t{1, 2, 3, 255}; - rule.data_bar_negative_border_engaged = 1; - rule.data_bar_negative_border = fm_cf_color_t{4, 5, 6, 255}; - rule.data_bar_axis_color_engaged = 1; - rule.data_bar_axis_color = fm_cf_color_t{7, 8, 9, 255}; - rule.data_bar_direction = 2; // right to left - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); - WorkbookGuard reloaded; - ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); - - fm_cf_rule_t back{}; - ASSERT_EQ(fm_sheet_cf_get_at(reloaded.handle, 0, 0, &back), 0) << fm_last_error_message(); - EXPECT_EQ(back.data_bar_gradient, 0); - EXPECT_EQ(back.data_bar_axis_position, 1U); - EXPECT_EQ(back.data_bar_negative_fill.r, 255U); - EXPECT_EQ(back.data_bar_negative_fill.g, 0U); - EXPECT_EQ(back.data_bar_border_engaged, 1); - EXPECT_EQ(back.data_bar_border.b, 3U); - EXPECT_EQ(back.data_bar_negative_border_engaged, 1); - EXPECT_EQ(back.data_bar_negative_border.b, 6U); - EXPECT_EQ(back.data_bar_axis_color.r, 7U); - EXPECT_EQ(back.data_bar_axis_color.b, 9U); - EXPECT_EQ(back.data_bar_direction, 2U); -} - -TEST(FormulonCApiCfMutate, IconSetFloorSurvivesSaveAndLoad) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule{}; - rule.type = 4; // IconSet - rule.sqref = &sqref; - rule.sqref_count = 1; - rule.icon_set_engaged = 1; - rule.icon_set_name = 2; // Three_Flags - fm_cfvo_t thresholds[2]{}; - thresholds[0].type = 0; // Number - thresholds[0].value = "7"; - thresholds[0].gte = 1; - thresholds[1].type = 0; - thresholds[1].value = "9"; - thresholds[1].gte = 1; - rule.icon_set_thresholds = thresholds; - rule.icon_set_threshold_count = 2; - rule.icon_set_show_value = 1; - rule.icon_set_percent = 1; - rule.icon_set_floor_engaged = 1; - rule.icon_set_floor.type = 0; // Number - rule.icon_set_floor.value = "5"; - rule.icon_set_floor.gte = 0; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); - WorkbookGuard reloaded; - ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); - fm_cf_rule_t back{}; - ASSERT_EQ(fm_sheet_cf_get_at(reloaded.handle, 0, 0, &back), 0) << fm_last_error_message(); - EXPECT_EQ(back.icon_set_floor_engaged, 1); - EXPECT_EQ(back.icon_set_floor.type, 0U); - EXPECT_STREQ(back.icon_set_floor.value, "5"); - EXPECT_EQ(back.icon_set_floor.gte, 0); -} - -TEST(FormulonCApiCfMutate, UnengagedIconSetFloorIsExcelsDefault) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - fm_cf_cell_range_t sqref{0, 0, 9, 0}; - fm_cf_rule_t rule{}; - rule.type = 4; // IconSet - rule.sqref = &sqref; - rule.sqref_count = 1; - rule.icon_set_engaged = 1; - fm_cfvo_t thresholds[2]{}; - rule.icon_set_thresholds = thresholds; - rule.icon_set_threshold_count = 2; - std::size_t rule_index = 0; - ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); - const auto& floor = wb.handle->workbook().sheet(0).conditional_formats()[0].rules[0].icon_set->floor; - EXPECT_EQ(floor.type, formulon::cf::CfvoType::Percent); - EXPECT_EQ(floor.value, "0"); - EXPECT_TRUE(floor.gte); -} diff --git a/tests/c_api/formulon_c_cf_test_helpers.h b/tests/c_api/formulon_c_cf_test_helpers.h new file mode 100644 index 000000000..c10e181d0 --- /dev/null +++ b/tests/c_api/formulon_c_cf_test_helpers.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include + +#include "c_api/borrowed_arena.h" +#include "c_api/formulon_c.h" +#include "c_api/parts/common.h" +#include "cf/cf_types.h" +#include "gtest/gtest.h" +#include "sheet.h" +#include "utils/error.h" +#include "utils/expected.h" +#include "value.h" +#include "workbook.h" + +namespace { + +// RAII guard so the workbook handle is released even on test failure. +// Move-only so factory functions can return one by value. +struct WorkbookGuard { + fm_workbook_t* handle = nullptr; + ~WorkbookGuard() { fm_workbook_destroy(handle); } + WorkbookGuard() = default; + WorkbookGuard(const WorkbookGuard&) = delete; + WorkbookGuard& operator=(const WorkbookGuard&) = delete; + WorkbookGuard(WorkbookGuard&& other) noexcept : handle(other.handle) { other.handle = nullptr; } + WorkbookGuard& operator=(WorkbookGuard&& other) noexcept { + if (this != &other) { + fm_workbook_destroy(handle); + handle = other.handle; + other.handle = nullptr; + } + return *this; + } +}; + +// RAII guard for `fm_cf_results_t` handles. +struct CfResultsGuard { + fm_cf_results_t* handle = nullptr; + ~CfResultsGuard() { fm_cf_results_destroy(handle); } + CfResultsGuard() = default; + CfResultsGuard(const CfResultsGuard&) = delete; + CfResultsGuard& operator=(const CfResultsGuard&) = delete; +}; + +struct BufferGuard { + uint8_t* data = nullptr; + size_t len = 0; + ~BufferGuard() { fm_buffer_free(data); } + BufferGuard() = default; + BufferGuard(const BufferGuard&) = delete; + BufferGuard& operator=(const BufferGuard&) = delete; +}; + +// Builds a `Workbook`, applies `mutate` to seed cells / CF blocks, then +// serialises it through OOXML and loads the resulting bytes through the +// C ABI. The returned guard owns a populated `fm_workbook_t*` ready for +// CF evaluation. +template +[[maybe_unused]] inline WorkbookGuard WorkbookFromMutator(MutateFn&& mutate) { + formulon::Workbook wb = formulon::Workbook::create(); + // Registers the `` records the CF rules below reference. The + // writer omits any `dxfId` its styles part cannot resolve, so a rule + // whose differential format was never registered would come back from + // the load with no `dxf_id` at all. + wb.mutable_styles().dxfs.resize(8); + mutate(wb); + auto bytes = wb.save(); + EXPECT_TRUE(static_cast(bytes)) << "Workbook::save: " << (bytes ? "" : bytes.error().message); + WorkbookGuard guard; + if (!bytes) { + return guard; + } + const auto& src = bytes.value(); + EXPECT_EQ(fm_workbook_load(src.data(), src.size(), &guard.handle), 0) + << "fm_workbook_load: " << fm_last_error_message(); + return guard; +} + +// Registers `count` `` slots on an existing handle. `fm_sheet_cf_add_rule` +// refuses a `dxf_id` past the end of the table, so a mutation test that +// engages a differential format has to seed the table first. +[[maybe_unused]] inline void SeedDxfs(fm_workbook_t* handle, std::size_t count) { + handle->workbook().mutable_styles().dxfs.resize(count); +} + +[[maybe_unused]] inline formulon::cf::CFCellRange MakeRange(std::uint32_t r1, std::uint32_t c1, std::uint32_t r2, + std::uint32_t c2) { + return {{r1, c1}, {r2, c2}}; +} + +// Minimal valid DataBar rule. Extension fields stay zero-initialized so +// each test below engages only what it is about to assert. +[[maybe_unused]] inline fm_cf_rule_t MakeDataBarRule(const fm_cf_cell_range_t* sqref) { + fm_cf_rule_t rule{}; + rule.type = 3; // DataBar + rule.sqref = sqref; + rule.sqref_count = 1; + rule.data_bar_engaged = 1; + rule.data_bar_min.type = 3; // Min + rule.data_bar_min.gte = 1; + rule.data_bar_max.type = 4; // Max + rule.data_bar_max.gte = 1; + rule.data_bar_fill = fm_cf_color_t{99, 142, 198, 255}; + rule.data_bar_show_value = 1; + rule.data_bar_min_length_pct = 10; + rule.data_bar_max_length_pct = 90; + return rule; +} + +} // namespace diff --git a/tests/c_api/formulon_c_cf_validation_test.cpp b/tests/c_api/formulon_c_cf_validation_test.cpp new file mode 100644 index 000000000..04989892d --- /dev/null +++ b/tests/c_api/formulon_c_cf_validation_test.cpp @@ -0,0 +1,367 @@ +// Stable C ABI conditional-format validation tests. + +#include + +#include "formulon_c_cf_test_helpers.h" + +TEST(FormulonCApiCfMutate, OutOfGridSqrefRejectedAndLeavesTheModelUnchanged) { + // An out-of-grid rectangle has no A1 spelling, so letting one into the + // model leaves the save path with a reference it cannot write. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_rule_t rule{}; + rule.type = 0; // Expression + rule.formula1 = "TRUE"; + rule.sqref_count = 1; + std::size_t rule_index = 0; + + // One past the last column, on a whole-column range. + fm_cf_cell_range_t past_last_col{0, formulon::Sheet::kMaxCols, formulon::Sheet::kMaxRows - 1U, + formulon::Sheet::kMaxCols}; + rule.sqref = &past_last_col; + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + // One past the last row. + fm_cf_cell_range_t past_last_row{0, 0, formulon::Sheet::kMaxRows, 0}; + rule.sqref = &past_last_row; + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + // Inverted rectangle. + fm_cf_cell_range_t inverted{5, 0, 1, 0}; + rule.sqref = &inverted; + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + // A rejected range leaves no block behind, and the rejection is + // reported rather than aborting the process at save time. + EXPECT_TRUE(wb.handle->workbook().sheet(0).conditional_formats().empty()); + BufferGuard rejected_save; + ASSERT_EQ(fm_workbook_save(wb.handle, &rejected_save.data, &rejected_save.len), 0); + + // The widest legal whole-column range still round-trips. + fm_cf_cell_range_t whole_col{0, 0, formulon::Sheet::kMaxRows - 1U, 0}; + rule.sqref = &whole_col; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + ASSERT_GT(saved.len, 0U); + WorkbookGuard reloaded; + ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); + const auto& blocks = reloaded.handle->workbook().sheet(0).conditional_formats(); + ASSERT_EQ(blocks.size(), 1U); + ASSERT_EQ(blocks[0].sqref.size(), 1U); + EXPECT_EQ(blocks[0].sqref[0], MakeRange(0, 0, formulon::Sheet::kMaxRows - 1U, 0)); +} + +TEST(FormulonCApiCfMutate, EmptySqrefRejected) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_rule_t rule{}; + rule.type = 0; + rule.sqref = nullptr; + rule.sqref_count = 0; + std::size_t rule_index = 0; + fm_status_t rc = fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index); + EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); +} + +TEST(FormulonCApiCfMutate, AddRuleRejectsExcessiveSqrefCount) { + // Hostile caller passes the maximum unsigned 32-bit value as + // `sqref_count`. The `sqref` pointer is non-null so the early + // null-check does not short-circuit, but the binding's range-count + // cap must reject the call before the body attempts a 4 GiB + // `reserve()`. Sheet state must be unchanged on rejection. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::size_t before = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &before), 0); + + fm_cf_cell_range_t sqref{0, 0, 0, 0}; + fm_cf_rule_t rule{}; + rule.type = 0; // Expression + std::string f = "TRUE"; + rule.formula1 = f.c_str(); + rule.sqref = &sqref; + rule.sqref_count = 0xFFFFFFFFu; + std::size_t rule_index = 0; + fm_status_t rc = fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index); + EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + std::size_t after = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &after), 0); + EXPECT_EQ(before, after); +} + +TEST(FormulonCApiCfMutate, GetAtDisengagesADxfIdTheStylesTableCannotResolve) { + // A package can carry a `dxfId` past the end of its own ``, and + // the loader keeps the rule. Surfacing that index would hand the caller + // a value `fm_styles_get_dxf` rejects and `fm_sheet_cf_add_rule` + // refuses, so the get / remove / add-back edit path would not close. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDxfs(wb.handle, 2); + + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule{}; + rule.type = 1; // CellIs + rule.op_engaged = 1; + rule.op = 5; // GreaterThan + rule.formula1 = "50"; + rule.dxf_id_engaged = 1; + rule.dxf_id = 1; + rule.sqref = &sqref; + rule.sqref_count = 1; + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + + // Shrink the table under the rule, the way a package whose styles part + // and worksheet part disagree arrives. + wb.handle->workbook().mutable_styles().dxfs.resize(1); + + fm_cf_rule_t out{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0) << fm_last_error_message(); + EXPECT_EQ(out.dxf_id_engaged, 0); + + // The record still feeds straight back in, which is what the disengage + // buys: with the raw index it would be rejected. + std::size_t reinserted = 0; + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, out, &reinserted), 0) << fm_last_error_message(); + + // A resolvable index is still reported. + SeedDxfs(wb.handle, 2); + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); + EXPECT_EQ(out.dxf_id_engaged, 1); + EXPECT_EQ(out.dxf_id, 1U); +} + +TEST(FormulonCApiCfMutate, AddRuleRejectsNonGuidId) { + // The id becomes an `ST_Guid`-typed attribute in the saved package, so + // a host key like "sales-databar" would make Excel repair the workbook + // and drop every conditional format in it. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule{}; + rule.type = 0; // Expression + rule.formula1 = "TRUE"; + rule.sqref = &sqref; + rule.sqref_count = 1; + + std::size_t rule_index = 99U; + for (const char* rejected : {"sales-databar", "{}", "FC000000-0000-0000-0000-000000000001", + "{FC000000-0000-0000-0000-00000000000}", "{GC000000-0000-0000-0000-000000000001}"}) { + rule.id = rejected; + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)) + << rejected; + } + + std::size_t count = 99U; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 0U); + + // A GUID-shaped id is accepted and comes back verbatim. + rule.id = "{3B4F1C22-0000-4000-8000-0123456789AB}"; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + fm_cf_rule_t out{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); + EXPECT_STREQ(out.id, "{3B4F1C22-0000-4000-8000-0123456789AB}"); + + // An absent id is the "synthesize one for me" request, not a malformed + // one, and the id it produces is itself GUID-shaped. + rule.id = ""; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, rule_index, &out), 0); + ASSERT_NE(out.id, nullptr); + const std::string synthesized(out.id); + EXPECT_EQ(synthesized.size(), 38U); + EXPECT_EQ(synthesized.front(), '{'); + EXPECT_EQ(synthesized.back(), '}'); +} + +TEST(FormulonCApiCfMutate, AddRuleRejectsEnumOrdinalsPastTheirDomain) { + // Each of these would otherwise be `static_cast` into its enum and + // collapse onto the consuming switch's default: an unknown `op` writes + // `operator="equal"`, an unknown `time_period` writes `today`. The + // caller sees `kOk` and a rule that means something else. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t base{}; + base.formula1 = "TRUE"; + base.sqref = &sqref; + base.sqref_count = 1; + + const auto expect_rejected = [&wb](fm_cf_rule_t rule, const char* what) { + std::size_t rule_index = 99U; + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)) + << what; + }; + + fm_cf_rule_t unknown_type = base; + unknown_type.type = static_cast(formulon::cf::RuleType::UniqueValues) + 1U; + expect_rejected(unknown_type, "type"); + + fm_cf_rule_t unknown_op = base; + unknown_op.type = 1; // CellIs + unknown_op.op_engaged = 1; + unknown_op.op = static_cast(formulon::cf::CellIsOperator::NotBetween) + 1U; + expect_rejected(unknown_op, "op"); + + fm_cf_rule_t unknown_period = base; + unknown_period.type = 15; // TimePeriod + unknown_period.time_period_engaged = 1; + unknown_period.time_period = static_cast(formulon::cf::TimePeriod::NextMonth) + 1U; + expect_rejected(unknown_period, "time_period"); + + fm_cfvo_t icon_thresholds[3]{}; + icon_thresholds[1].type = static_cast(formulon::cf::CfvoType::AutoMax) + 1U; + fm_cf_rule_t unknown_cfvo = base; + unknown_cfvo.type = 4; // IconSet + unknown_cfvo.icon_set_engaged = 1; + unknown_cfvo.icon_set_thresholds = icon_thresholds; + unknown_cfvo.icon_set_threshold_count = 3; + expect_rejected(unknown_cfvo, "icon_set_thresholds[].type"); + + fm_cfvo_t legal_thresholds[3]{}; + fm_cf_rule_t unknown_icon_set = base; + unknown_icon_set.type = 4; // IconSet + unknown_icon_set.icon_set_engaged = 1; + unknown_icon_set.icon_set_name = static_cast(formulon::cf::IconSetName::Five_Quarters) + 1U; + unknown_icon_set.icon_set_thresholds = legal_thresholds; + unknown_icon_set.icon_set_threshold_count = 3; + expect_rejected(unknown_icon_set, "icon_set_name"); + + fm_cfvo_t color_thresholds[2]{}; + fm_cf_color_t colors[2]{}; + color_thresholds[0].type = static_cast(formulon::cf::CfvoType::AutoMax) + 1U; + fm_cf_rule_t unknown_color_cfvo = base; + unknown_color_cfvo.type = 2; // ColorScale + unknown_color_cfvo.color_scale_thresholds = color_thresholds; + unknown_color_cfvo.color_scale_colors = colors; + unknown_color_cfvo.color_scale_count = 2; + expect_rejected(unknown_color_cfvo, "color_scale_thresholds[].type"); + + fm_cf_rule_t unknown_floor = base; + unknown_floor.type = 4; // IconSet + unknown_floor.icon_set_engaged = 1; + unknown_floor.icon_set_thresholds = legal_thresholds; + unknown_floor.icon_set_threshold_count = 2; + unknown_floor.icon_set_floor_engaged = 1; + unknown_floor.icon_set_floor.type = static_cast(formulon::cf::CfvoType::AutoMax) + 1U; + expect_rejected(unknown_floor, "icon_set_floor.type"); + + fm_cf_rule_t unknown_direction = base; + unknown_direction.type = 3; // DataBar + unknown_direction.data_bar_engaged = 1; + unknown_direction.data_bar_max.type = 4; // Max + unknown_direction.data_bar_min.type = 3; // Min + unknown_direction.data_bar_min_length_pct = 10; + unknown_direction.data_bar_max_length_pct = 90; + unknown_direction.data_bar_direction = static_cast(formulon::cf::DataBarDirection::RightToLeft) + 1U; + expect_rejected(unknown_direction, "data_bar_direction"); + + // Every rejection left the sheet as it was. + std::size_t count = 99U; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 0U); +} + +TEST(FormulonCApiCfMutate, OutOfRangeIndexReturnsInvalidArgument) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_rule_t out{}; + fm_status_t rc = fm_sheet_cf_get_at(wb.handle, 0, 99, &out); + EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + rc = fm_sheet_cf_remove_at(wb.handle, 0, 99); + EXPECT_EQ(rc, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); +} + +TEST(FormulonCApiCfMutate, PreLoadedRulesEnumerableViaFlatIndex) { + WorkbookGuard wb = WorkbookFromMutator([](formulon::Workbook& w) { + auto& sheet = w.sheet(0); + formulon::cf::ConditionalFormat block; + block.sqref = {MakeRange(0, 0, 9, 0)}; + formulon::cf::CFRule r; + r.type = formulon::cf::RuleType::CellIs; + r.priority = 1; + r.op = formulon::cf::CellIsOperator::GreaterThan; + r.formula1 = "50"; + r.dxf_id = 0; + block.rules.push_back(std::move(r)); + sheet.mutable_conditional_formats().push_back(std::move(block)); + }); + std::size_t count = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 1U); + fm_cf_rule_t out{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); + EXPECT_EQ(out.type, 1U); + ASSERT_NE(out.formula1, nullptr); + EXPECT_STREQ(out.formula1, "50"); +} + +TEST(FormulonCApiCfMutate, DuplicatePredicateRulesGetDistinctStableIndices) { + // Two rules with the identical predicate (same type/op/formula/sqref) + // must still be tracked as distinct entries: `fm_sheet_cf_add_rule` + // always appends a new block rather than deduping against an existing + // one, so the returned index must reflect append order, not predicate + // identity. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + SeedDxfs(wb.handle, 1); + + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule{}; + rule.type = 1; // CellIs + rule.op_engaged = 1; + rule.op = 5; // GreaterThan + rule.formula1 = "50"; + rule.dxf_id_engaged = 1; + rule.dxf_id = 0; + rule.sqref = &sqref; + rule.sqref_count = 1; + + std::size_t first_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &first_index), 0) << fm_last_error_message(); + + // Re-populate the rule: `fm_sheet_cf_add_rule` deep-copies string / + // range payloads, but reuse a fresh `fm_cf_rule_t` to avoid relying on + // stale scratch state from the first call. + fm_cf_rule_t rule2{}; + rule2.type = 1; // CellIs + rule2.op_engaged = 1; + rule2.op = 5; // GreaterThan + rule2.formula1 = "50"; + rule2.dxf_id_engaged = 1; + rule2.dxf_id = 0; + rule2.sqref = &sqref; + rule2.sqref_count = 1; + + std::size_t second_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule2, &second_index), 0) << fm_last_error_message(); + + EXPECT_EQ(first_index, 0U); + EXPECT_EQ(second_index, 1U); + EXPECT_NE(first_index, second_index); + + std::size_t count = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 2U); + + // The indices returned by add_rule must match what a subsequent + // flattened readback reports for rule order/position. + fm_cf_rule_t readback_first{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, first_index, &readback_first), 0); + EXPECT_EQ(readback_first.priority, 1); // auto-assigned to the first rule added + + fm_cf_rule_t readback_second{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, second_index, &readback_second), 0); + EXPECT_EQ(readback_second.priority, 2); // auto-assigned one past the first +} diff --git a/tests/c_api/formulon_c_cf_visual_payload_test.cpp b/tests/c_api/formulon_c_cf_visual_payload_test.cpp new file mode 100644 index 000000000..0ba3f1fcd --- /dev/null +++ b/tests/c_api/formulon_c_cf_visual_payload_test.cpp @@ -0,0 +1,348 @@ +// Stable C ABI conditional-format visual payload tests. + +#include + +#include "formulon_c_cf_test_helpers.h" + +namespace { + +const formulon::cf::DataBarSpec& OnlyDataBarSpec(fm_workbook_t* handle) { + const auto& blocks = handle->workbook().sheet(0).conditional_formats(); + EXPECT_EQ(blocks.size(), 1U); + EXPECT_EQ(blocks[0].rules.size(), 1U); + EXPECT_TRUE(blocks[0].rules[0].data_bar.has_value()); + return *blocks[0].rules[0].data_bar; +} + +} // namespace + +TEST(FormulonCApiCfMutate, ZeroInitializedDataBarExtensionKeepsTheModelDefaults) { + // The extension fields were appended to a struct callers already + // zero-initialize. Leaving every `*_engaged` flag at zero must produce + // exactly the spec the ABI produced before those fields existed. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + const fm_cf_rule_t rule = MakeDataBarRule(&sqref); + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + + const formulon::cf::DataBarSpec& spec = OnlyDataBarSpec(wb.handle); + EXPECT_TRUE(spec.gradient); + EXPECT_EQ(spec.axis_position, formulon::cf::DataBarAxisPosition::Automatic); + // Unengaged negative fill still mirrors the positive fill. + EXPECT_EQ(spec.negative_fill.r, spec.fill.r); + EXPECT_EQ(spec.negative_fill.g, spec.fill.g); + EXPECT_EQ(spec.negative_fill.b, spec.fill.b); + EXPECT_EQ(spec.negative_fill.a, spec.fill.a); + EXPECT_FALSE(spec.border.has_value()); + EXPECT_FALSE(spec.negative_border.has_value()); + EXPECT_EQ(spec.axis_color.r, 0U); + EXPECT_EQ(spec.axis_color.g, 0U); + EXPECT_EQ(spec.axis_color.b, 0U); + EXPECT_EQ(spec.axis_color.a, 255U); +} + +TEST(FormulonCApiCfMutate, DataBarExtensionFieldsReachTheModelWhenEngaged) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule = MakeDataBarRule(&sqref); + rule.data_bar_gradient_engaged = 1; + rule.data_bar_gradient = 0; // solid, not gradient + rule.data_bar_axis_position_engaged = 1; + rule.data_bar_axis_position = 1; // middle + rule.data_bar_negative_fill_engaged = 1; + rule.data_bar_negative_fill = fm_cf_color_t{255, 0, 0, 255}; + rule.data_bar_border_engaged = 1; + rule.data_bar_border = fm_cf_color_t{1, 2, 3, 255}; + rule.data_bar_negative_border_engaged = 1; + rule.data_bar_negative_border = fm_cf_color_t{4, 5, 6, 255}; + rule.data_bar_axis_color_engaged = 1; + rule.data_bar_axis_color = fm_cf_color_t{7, 8, 9, 255}; + + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + + const formulon::cf::DataBarSpec& spec = OnlyDataBarSpec(wb.handle); + EXPECT_FALSE(spec.gradient); + EXPECT_EQ(spec.axis_position, formulon::cf::DataBarAxisPosition::Middle); + EXPECT_EQ(spec.negative_fill.r, 255U); + EXPECT_EQ(spec.negative_fill.g, 0U); + ASSERT_TRUE(spec.border.has_value()); + EXPECT_EQ(spec.border->r, 1U); + EXPECT_EQ(spec.border->b, 3U); + ASSERT_TRUE(spec.negative_border.has_value()); + EXPECT_EQ(spec.negative_border->r, 4U); + EXPECT_EQ(spec.negative_border->b, 6U); + EXPECT_EQ(spec.axis_color.r, 7U); + EXPECT_EQ(spec.axis_color.b, 9U); +} + +TEST(FormulonCApiCfMutate, DataBarRuleSurvivesGetAtFedBackIntoAddRule) { + // The only way to edit a CF rule through this ABI is get -> remove -> + // add, so that path has to be an identity on the rule model, and the + // record has to stay readable across the removal without the caller + // copying anything out of it. Before the extension fields existed the + // sequence silently reset axis, gradient and the negative colours to + // their defaults. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t seed = MakeDataBarRule(&sqref); + seed.data_bar_gradient_engaged = 1; + seed.data_bar_gradient = 0; + seed.data_bar_axis_position_engaged = 1; + seed.data_bar_axis_position = 1; // middle + seed.data_bar_negative_fill_engaged = 1; + seed.data_bar_negative_fill = fm_cf_color_t{200, 30, 40, 255}; + seed.data_bar_border_engaged = 1; + seed.data_bar_border = fm_cf_color_t{11, 22, 33, 255}; + seed.data_bar_axis_color_engaged = 1; + seed.data_bar_axis_color = fm_cf_color_t{44, 55, 66, 255}; + std::size_t seed_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, seed, &seed_index), 0) << fm_last_error_message(); + + // Read it back, change one unrelated field, and write it out again. + fm_cf_rule_t round_trip{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &round_trip), 0); + EXPECT_EQ(round_trip.data_bar_gradient_engaged, 1); + EXPECT_EQ(round_trip.data_bar_gradient, 0); + EXPECT_EQ(round_trip.data_bar_axis_position_engaged, 1); + EXPECT_EQ(round_trip.data_bar_axis_position, 1U); + EXPECT_EQ(round_trip.data_bar_negative_fill_engaged, 1); + EXPECT_EQ(round_trip.data_bar_negative_fill.r, 200U); + EXPECT_EQ(round_trip.data_bar_border_engaged, 1); + EXPECT_EQ(round_trip.data_bar_border.g, 22U); + // No negative border was set, so the getter must report it absent + // rather than handing back an engaged black. + EXPECT_EQ(round_trip.data_bar_negative_border_engaged, 0); + EXPECT_EQ(round_trip.data_bar_axis_color_engaged, 1); + EXPECT_EQ(round_trip.data_bar_axis_color.b, 66U); + + round_trip.stop_if_true = 1; + ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); + std::size_t rewritten_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, round_trip, &rewritten_index), 0) << fm_last_error_message(); + + const formulon::cf::DataBarSpec& spec = OnlyDataBarSpec(wb.handle); + EXPECT_FALSE(spec.gradient); + EXPECT_EQ(spec.axis_position, formulon::cf::DataBarAxisPosition::Middle); + EXPECT_EQ(spec.negative_fill.r, 200U); + EXPECT_EQ(spec.negative_fill.g, 30U); + ASSERT_TRUE(spec.border.has_value()); + EXPECT_EQ(spec.border->b, 33U); + EXPECT_FALSE(spec.negative_border.has_value()); + EXPECT_EQ(spec.axis_color.g, 55U); +} + +TEST(FormulonCApiCfMutate, GetAtRecordStaysReadableUntilTheNextGet) { + // The record the getter fills has to outlive the mutations of the ABI's + // own edit procedure, and unrelated reads that recycle the handle's text + // scratch must not disturb it either. Only the next successful CF get + // takes the storage back. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_text(wb.handle, 0, 5, 5, "unrelated"), 0); + + fm_cf_cell_range_t sqref[2]{{0, 0, 9, 0}, {4, 3, 14, 3}}; + fm_cf_rule_t seed{}; + seed.id = "{AB000000-0000-0000-0000-000000000001}"; + seed.type = 2; // ColorScale + seed.sqref = sqref; + seed.sqref_count = 2; + seed.formula1 = "SEEDFORMULA"; + fm_cfvo_t thresholds[2]{}; + thresholds[0].type = 0; // Number + thresholds[0].value = "11"; + thresholds[1].type = 0; + thresholds[1].value = "33"; + fm_cf_color_t colors[2]{{255, 0, 0, 255}, {0, 255, 0, 255}}; + seed.color_scale_thresholds = thresholds; + seed.color_scale_colors = colors; + seed.color_scale_count = 2; + std::size_t seed_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, seed, &seed_index), 0) << fm_last_error_message(); + + fm_cf_rule_t held{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &held), 0); + ASSERT_NE(held.sqref, nullptr); + ASSERT_EQ(held.sqref_count, 2U); + ASSERT_EQ(held.color_scale_count, 2U); + + // A removal frees the block the rule lived in. + ASSERT_EQ(fm_sheet_cf_remove_at(wb.handle, 0, 0), 0); + std::size_t remaining = 0; + ASSERT_EQ(fm_sheet_cf_count(wb.handle, 0, &remaining), 0); + ASSERT_EQ(remaining, 0U); + EXPECT_STREQ(held.id, "{AB000000-0000-0000-0000-000000000001}"); + EXPECT_STREQ(held.formula1, "SEEDFORMULA"); + EXPECT_EQ(held.sqref[1].first_col, 3U); + EXPECT_EQ(held.sqref[1].last_row, 14U); + EXPECT_STREQ(held.color_scale_thresholds[1].value, "33"); + + // An unrelated text read recycles `read_scratch`. + fm_value_t unrelated{}; + ASSERT_EQ(fm_workbook_get_value(wb.handle, 0, 5, 5, &unrelated), 0); + EXPECT_STREQ(held.id, "{AB000000-0000-0000-0000-000000000001}"); + EXPECT_STREQ(held.color_scale_thresholds[0].value, "11"); + + // Feeding the held record straight back is the documented edit path; it + // must not need a caller-side copy of anything. + std::size_t rewritten_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, held, &rewritten_index), 0) << fm_last_error_message(); + const auto& blocks = wb.handle->workbook().sheet(0).conditional_formats(); + ASSERT_EQ(blocks.size(), 1U); + ASSERT_EQ(blocks[0].sqref.size(), 2U); + EXPECT_EQ(blocks[0].sqref[1], MakeRange(4, 3, 14, 3)); + EXPECT_EQ(blocks[0].rules[0].id, "{AB000000-0000-0000-0000-000000000001}"); + ASSERT_TRUE(blocks[0].rules[0].color_scale.has_value()); + EXPECT_EQ(blocks[0].rules[0].color_scale->thresholds[1].value, "33"); +} + +TEST(FormulonCApiCfMutate, DataBarRejectsInvertedLengthsAndUnknownAxisPosition) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + + fm_cf_rule_t inverted = MakeDataBarRule(&sqref); + inverted.data_bar_min_length_pct = 90; + inverted.data_bar_max_length_pct = 10; + std::size_t rule_index = 0; + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, inverted, &rule_index), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + fm_cf_rule_t bad_axis = MakeDataBarRule(&sqref); + bad_axis.data_bar_axis_position_engaged = 1; + bad_axis.data_bar_axis_position = 3; // one past `none` + EXPECT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, bad_axis, &rule_index), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + // An unengaged out-of-domain axis ordinal is ignored, not rejected: the + // flag is what makes the value meaningful. + fm_cf_rule_t stale_axis = MakeDataBarRule(&sqref); + stale_axis.data_bar_axis_position = 3; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, stale_axis, &rule_index), 0) << fm_last_error_message(); + EXPECT_EQ(OnlyDataBarSpec(wb.handle).axis_position, formulon::cf::DataBarAxisPosition::Automatic); +} + +TEST(FormulonCApiCfMutate, ThresholdStringsStayLiveWhileLaterPayloadsArePulled) { + // Mirrors what the JS bindings do for a rule object that carries more + // than one payload key: every threshold string is pulled into one + // caller-side store, and the record keeps a borrowed view of each. The + // store must therefore not relocate bytes an earlier pull already + // published into the record - which is what a growing vector does on + // the second short string it holds. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + formulon::c_api::BorrowedStringArena strings; + + fm_cf_cell_range_t sqref{0, 0, 9, 0}; + fm_cf_rule_t rule{}; + rule.type = 2; // ColorScale + rule.sqref = &sqref; + rule.sqref_count = 1; + + fm_cfvo_t thresholds[3]{}; + const char* const kThresholdValues[3] = {"11", "22", "33"}; + for (std::size_t i = 0; i < 3; ++i) { + thresholds[i].type = 0; // Number + thresholds[i].gte = 1; + thresholds[i].value = strings.emplace(kThresholdValues[i]); + } + fm_cf_color_t colors[3]{{255, 0, 0, 255}, {255, 255, 0, 255}, {0, 255, 0, 255}}; + rule.color_scale_thresholds = thresholds; + rule.color_scale_colors = colors; + rule.color_scale_count = 3; + + // The same JS object also carried `dataBar` and `iconSet` keys, so the + // binding pulls their threshold strings into the same store after the + // colorScale views are already in the record. + rule.data_bar_engaged = 1; + rule.data_bar_min.type = 0; + rule.data_bar_min.value = strings.emplace("5"); + rule.data_bar_max.type = 0; + rule.data_bar_max.value = strings.emplace("95"); + fm_cfvo_t icon_thresholds[2]{}; + icon_thresholds[0].type = 1; // Percent + icon_thresholds[0].value = strings.emplace("40"); + icon_thresholds[1].type = 1; + icon_thresholds[1].value = strings.emplace("80"); + rule.icon_set_engaged = 1; + rule.icon_set_thresholds = icon_thresholds; + rule.icon_set_threshold_count = 2; + + std::size_t rule_index = 0; + ASSERT_EQ(fm_sheet_cf_add_rule(wb.handle, 0, rule, &rule_index), 0) << fm_last_error_message(); + + const auto& blocks = wb.handle->workbook().sheet(0).conditional_formats(); + ASSERT_EQ(blocks.size(), 1U); + ASSERT_TRUE(blocks[0].rules[0].color_scale.has_value()); + const auto& stored = blocks[0].rules[0].color_scale->thresholds; + ASSERT_EQ(stored.size(), 3U); + EXPECT_EQ(stored[0].value, "11"); + EXPECT_EQ(stored[1].value, "22"); + EXPECT_EQ(stored[2].value, "33"); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + ASSERT_GT(saved.len, 0U); + WorkbookGuard reloaded; + ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0); + const auto& reloaded_blocks = reloaded.handle->workbook().sheet(0).conditional_formats(); + ASSERT_EQ(reloaded_blocks.size(), 1U); + ASSERT_TRUE(reloaded_blocks[0].rules[0].color_scale.has_value()); + const auto& reloaded_thresholds = reloaded_blocks[0].rules[0].color_scale->thresholds; + ASSERT_EQ(reloaded_thresholds.size(), 3U); + EXPECT_EQ(reloaded_thresholds[0].value, "11"); + EXPECT_EQ(reloaded_thresholds[1].value, "22"); + EXPECT_EQ(reloaded_thresholds[2].value, "33"); +} + +TEST(FormulonCApiCfMutate, RuleEngagingSeveralVisualPayloadsSurfacesLivePointers) { + // `cf::CFRule` declares the three visual payloads mutually exclusive, + // but a rule can only be trusted to honour that once every producer + // does. The getter must keep every pointer it writes into the record + // valid until it returns, including for a rule that engages two. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + auto& sheet = wb.handle->workbook().sheet(0); + formulon::cf::ConditionalFormat block{}; + block.sqref.push_back(MakeRange(0, 0, 9, 0)); + formulon::cf::CFRule rule; + rule.type = formulon::cf::RuleType::ColorScale; + rule.priority = 1; + formulon::cf::ColorScaleSpec color_scale; + for (const char* value : {"11", "22", "33"}) { + formulon::cf::CfValueObject cfvo; + cfvo.type = formulon::cf::CfvoType::Number; + cfvo.value = value; + color_scale.thresholds.push_back(cfvo); + color_scale.colors.push_back(formulon::cf::Color{255, 0, 0, 255}); + } + rule.color_scale = std::move(color_scale); + formulon::cf::IconSetSpec icon_set; + for (const char* value : {"40", "80"}) { + formulon::cf::CfValueObject cfvo; + cfvo.type = formulon::cf::CfvoType::Percent; + cfvo.value = value; + icon_set.thresholds.push_back(cfvo); + } + rule.icon_set = std::move(icon_set); + block.rules.push_back(std::move(rule)); + sheet.mutable_conditional_formats().push_back(std::move(block)); + + fm_cf_rule_t out{}; + ASSERT_EQ(fm_sheet_cf_get_at(wb.handle, 0, 0, &out), 0); + ASSERT_EQ(out.color_scale_count, 3U); + ASSERT_NE(out.color_scale_thresholds, nullptr); + ASSERT_NE(out.color_scale_colors, nullptr); + // Read after the iconSet payload was filled: the colorScale array must + // not have moved out from under the pointer already in the record. + EXPECT_STREQ(out.color_scale_thresholds[0].value, "11"); + EXPECT_STREQ(out.color_scale_thresholds[2].value, "33"); + EXPECT_EQ(out.color_scale_colors[0].r, 255U); + ASSERT_EQ(out.icon_set_threshold_count, 2U); + ASSERT_NE(out.icon_set_thresholds, nullptr); + EXPECT_STREQ(out.icon_set_thresholds[1].value, "80"); +} diff --git a/tests/c_api/formulon_c_pivot_authoring_test.cpp b/tests/c_api/formulon_c_pivot_authoring_test.cpp new file mode 100644 index 000000000..c34d2765b --- /dev/null +++ b/tests/c_api/formulon_c_pivot_authoring_test.cpp @@ -0,0 +1,480 @@ +// Stable C ABI PivotTable authoring and cache tests. + +#include + +#include "formulon_c_pivot_test_helpers.h" + +TEST(FormulonCApiPivot, CreatePivotFromScratch) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + std::size_t cache_count = 0; + ASSERT_EQ(fm_workbook_pivot_cache_count(wb.handle, &cache_count), 0); + EXPECT_EQ(cache_count, 1U); + + std::size_t pivot_count = 0; + ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 0, &pivot_count), 0); + EXPECT_EQ(pivot_count, 1U); + + std::size_t records = 0; + ASSERT_EQ(fm_workbook_pivot_cache_record_count(wb.handle, cache_id, &records), 0); + EXPECT_EQ(records, 4U); + + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + + const std::vector cells = CollectCells(projected.handle); + ASSERT_FALSE(cells.empty()); + + // North (regions 0, 2) sum = 400; South (regions 1, 3) sum = 600. + // Grand total = 1000. + bool saw_north = false; + bool saw_south = false; + bool saw_grand = false; + for (const fm_pivot_cell_t& c : cells) { + if (c.kind == FM_PIVOT_CELL_DATA && c.value.kind == FM_VAL_NUMBER) { + if (c.value.u.number == 400.0) { + saw_north = true; + } else if (c.value.u.number == 600.0) { + saw_south = true; + } + } + if (c.kind == FM_PIVOT_CELL_GRAND_TOTAL && c.value.kind == FM_VAL_NUMBER && c.value.u.number == 1000.0) { + saw_grand = true; + } + } + EXPECT_TRUE(saw_north); + EXPECT_TRUE(saw_south); + EXPECT_TRUE(saw_grand); +} + +TEST(FormulonCApiPivot, SaveRejectsAPivotCacheWithNoWorksheetSource) { + // The failure this replaces was silent: the package saved, validated + // against the schema, and round-tripped through our own reader, and only + // Excel -- offering to repair it -- ever said otherwise. A host has no + // way to discover that from the API, so the save has to say it. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + ASSERT_EQ(fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, /*present=*/0, nullptr, nullptr, nullptr), + 0) + << fm_last_error_message(); + + BufferGuard saved; + EXPECT_NE(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << "a cache with no source must not save silently"; + EXPECT_NE(std::string(fm_last_error_message()).find("worksheet source"), std::string::npos) + << "message must name what is missing: " << fm_last_error_message(); + + // Restoring the source makes the same workbook savable again, so the + // check is about the cache's state and not about some other damage. + ASSERT_EQ( + fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, /*present=*/1, "A1:B5", "Sheet1", nullptr), 0); + BufferGuard retry; + EXPECT_EQ(fm_workbook_save(wb.handle, &retry.data, &retry.len), 0) << fm_last_error_message(); +} + +TEST(FormulonCApiPivot, SavedScratchPivotEmitsLocationRequiredDefaults) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + ASSERT_EQ(fm_workbook_pivot_set_anchor(wb.handle, 0, pivot_idx, 0U, 3U, 5U, 2U), 0) << fm_last_error_message(); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << fm_last_error_message(); + const std::vector package(saved.data, saved.data + saved.len); + const std::string pivot_xml = ExtractZipEntry(package, "xl/pivotTables/pivotTable1.xml"); + ASSERT_FALSE(pivot_xml.empty()); + EXPECT_NE(pivot_xml.find(""), + std::string::npos) + << "xml=" << pivot_xml; +} + +TEST(FormulonCApiPivot, SavedPivotLocationRefCoversTheProjectedGrid) { + // `fm_workbook_pivot_create` installs a 1x1 placeholder span and nothing + // revises it as fields are added, so a pivot built purely through the C + // surface used to save a `ref` describing a single cell. Excel opens such + // a file cleanly and recognises the pivot, then terminates the moment the + // report is refreshed -- the defect is invisible to a round trip and has + // to be pinned on the emitted bytes. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + // Deliberately no `fm_workbook_pivot_set_anchor`: this is the path a host + // takes when it never reasons about the report's extent at all. + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + std::uint32_t top = 0; + std::uint32_t left = 0; + std::uint32_t rows = 0; + std::uint32_t cols = 0; + ASSERT_EQ(fm_pivot_cells_bounds(projected.handle, &top, &left, &rows, &cols), 0); + ASSERT_GT(rows, 1U) << "projection must be larger than the placeholder for this test to mean anything"; + ASSERT_GT(cols, 1U); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << fm_last_error_message(); + const std::vector package(saved.data, saved.data + saved.len); + const std::string pivot_xml = ExtractZipEntry(package, "xl/pivotTables/pivotTable1.xml"); + ASSERT_FALSE(pivot_xml.empty()); + + // Anchored at D1, projected 4 rows x 2 cols -> D1:E4. + EXPECT_NE(pivot_xml.find("::quiet_NaN(), + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -1.0, + 1e30, + 4.3e9, // Past the wasm32 `size_t` range. + 2.0, // One past the last shared item. + }; + for (const double bad : bad_indices) { + ASSERT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, 0, bad), 0) << fm_last_error_message(); + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + EXPECT_FALSE(CollectCells(projected.handle).empty()); + } + + // A valid index still resolves after all of that. + ASSERT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, 0, 0.0), 0) << fm_last_error_message(); + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + bool saw_north_total = false; + for (const fm_pivot_cell_t& c : CollectCells(projected.handle)) { + if (c.kind == FM_PIVOT_CELL_DATA && c.value.kind == FM_VAL_NUMBER && c.value.u.number == 400.0) { + saw_north_total = true; + } + } + EXPECT_TRUE(saw_north_total); +} + +TEST(FormulonCApiPivot, PivotCacheWorksheetSourceRoundTripsThroughApi) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + int32_t present = -1; + const char* ref = nullptr; + const char* sheet = nullptr; + const char* name = nullptr; + // `BuildScratchPivot` installs a source because a cache without one + // cannot be saved. Clear it here so the absent state this test is about + // is established by the test rather than inherited from the helper. + ASSERT_EQ(fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, 0, nullptr, nullptr, nullptr), 0); + ASSERT_EQ(fm_workbook_pivot_cache_get_worksheet_source(wb.handle, cache_id, &present, &ref, &sheet, &name), 0); + EXPECT_EQ(present, 0); + EXPECT_STREQ(ref, ""); + EXPECT_STREQ(sheet, ""); + EXPECT_STREQ(name, ""); + + ASSERT_EQ(fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, 1, "$A$1:$C$5", "Data", nullptr), 0) + << fm_last_error_message(); + ASSERT_EQ(fm_workbook_pivot_cache_get_worksheet_source(wb.handle, cache_id, &present, &ref, &sheet, &name), 0); + EXPECT_EQ(present, 1); + EXPECT_STREQ(ref, "$A$1:$C$5"); + EXPECT_STREQ(sheet, "Data"); + EXPECT_STREQ(name, ""); + + ASSERT_EQ(fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, 0, "ignored", "ignored", "ignored"), 0); + ASSERT_EQ(fm_workbook_pivot_cache_get_worksheet_source(wb.handle, cache_id, &present, &ref, &sheet, &name), 0); + EXPECT_EQ(present, 0); + EXPECT_STREQ(ref, ""); + EXPECT_STREQ(sheet, ""); + EXPECT_STREQ(name, ""); +} + +TEST(FormulonCApiPivot, PivotReportLayoutRoundTripsThroughApi) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + fm_pivot_layout_t layout = FM_PIVOT_LAYOUT_TABULAR; + ASSERT_EQ(fm_workbook_pivot_get_layout(wb.handle, 0, pivot_idx, &layout), 0); + EXPECT_EQ(layout, FM_PIVOT_LAYOUT_COMPACT); + + ASSERT_EQ(fm_workbook_pivot_set_layout(wb.handle, 0, pivot_idx, FM_PIVOT_LAYOUT_TABULAR), 0); + ASSERT_EQ(fm_workbook_pivot_get_layout(wb.handle, 0, pivot_idx, &layout), 0); + EXPECT_EQ(layout, FM_PIVOT_LAYOUT_TABULAR); + + ASSERT_EQ(fm_workbook_pivot_set_layout(wb.handle, 0, pivot_idx, FM_PIVOT_LAYOUT_OUTLINE), 0); + ASSERT_EQ(fm_workbook_pivot_get_layout(wb.handle, 0, pivot_idx, &layout), 0); + EXPECT_EQ(layout, FM_PIVOT_LAYOUT_OUTLINE); + + // The setter accepts a raw int32_t so FFI callers can probe the complete + // invalid domain without constructing an out-of-domain C enum. + const std::int32_t invalid_layouts[] = { + 99, + std::numeric_limits::min(), + std::numeric_limits::max(), + }; + for (const std::int32_t raw : invalid_layouts) { + EXPECT_EQ(fm_workbook_pivot_set_layout(wb.handle, 0, pivot_idx, raw), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)) + << raw; + } +} + +TEST(FormulonCApiPivot, ScalarEnumMutatorsRejectRawValuesWithoutMutation) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + BufferGuard before; + ASSERT_EQ(fm_workbook_save(wb.handle, &before.data, &before.len), 0) << fm_last_error_message(); + const std::vector snapshot(before.data, before.data + before.len); + const std::int32_t invalid[] = {99, std::numeric_limits::min(), + std::numeric_limits::max()}; + const fm_status_t expected = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + for (const std::int32_t raw : invalid) { + EXPECT_EQ(fm_workbook_pivot_field_set_axis(wb.handle, 0, pivot_idx, 0, raw), expected) << raw; + EXPECT_EQ(fm_workbook_pivot_field_add_subtotal_fn(wb.handle, 0, pivot_idx, 0, raw), expected) << raw; + EXPECT_EQ(fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, raw, 0, -1, -1, 1, -1.0, -1.0), + expected) + << raw; + EXPECT_EQ(fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, 3, raw, -1, -1, 1, -1.0, -1.0), + expected) + << raw; + } + + BufferGuard after; + ASSERT_EQ(fm_workbook_save(wb.handle, &after.data, &after.len), 0) << fm_last_error_message(); + EXPECT_EQ(std::vector(after.data, after.data + after.len), snapshot); +} + +TEST(FormulonCApiPivot, DateGroupRejectsZeroIntervalAndInvertedWindowWithoutMutation) { + // FM_PIVOT_DATE_DAYS-only validation: a zero interval and an explicit + // Start past an explicit End are both nonsensical groupings, so + // neither may reach the model. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + BufferGuard before; + ASSERT_EQ(fm_workbook_save(wb.handle, &before.data, &before.len), 0) << fm_last_error_message(); + const std::vector snapshot(before.data, before.data + before.len); + const fm_status_t expected = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + + // interval_days == 0. + EXPECT_EQ( + fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, FM_PIVOT_DATE_DAYS, + FM_PIVOT_CALENDAR_GREGORIAN, -1, -1, /*interval_days=*/0, -1.0, -1.0), + expected); + // start_serial_or_neg1 > end_serial_or_neg1 (both explicit). + EXPECT_EQ(fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, FM_PIVOT_DATE_DAYS, + FM_PIVOT_CALENDAR_GREGORIAN, -1, -1, /*interval_days=*/7, + /*start_serial=*/61.0, /*end_serial=*/60.0), + expected); + + BufferGuard after; + ASSERT_EQ(fm_workbook_save(wb.handle, &after.data, &after.len), 0) << fm_last_error_message(); + EXPECT_EQ(std::vector(after.data, after.data + after.len), snapshot); +} + +TEST(FormulonCApiPivot, StructEnumMutatorsRejectRawValuesWithoutMutation) { + // The struct-taking twins of the scalar mutators above must enforce the + // same domains. Without that, an out-of-domain `axis` collapses onto + // `PivotAxis::Row` and an out-of-domain `show_as` onto `Normal`, and + // the call reports success on a pivot that means something else. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + BufferGuard before; + ASSERT_EQ(fm_workbook_save(wb.handle, &before.data, &before.len), 0) << fm_last_error_message(); + const std::vector snapshot(before.data, before.data + before.len); + const fm_status_t expected = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + + fm_pivot_field_spec_t field_spec{}; + field_spec.source_name = "Region"; + field_spec.axis = RawEnumValue(99); + std::size_t field_idx = 99U; + EXPECT_EQ(fm_workbook_pivot_field_add(wb.handle, 0, pivot_idx, &field_spec, &field_idx), expected); + + fm_pivot_data_field_spec_t df_spec{}; + df_spec.name = "Total"; + df_spec.field_index = 1; + df_spec.aggregation = RawEnumValue(99); + std::size_t df_idx = 99U; + EXPECT_EQ(fm_workbook_pivot_data_field_add(wb.handle, 0, pivot_idx, &df_spec, &df_idx), expected); + EXPECT_EQ(fm_workbook_pivot_data_field_set(wb.handle, 0, pivot_idx, 0, &df_spec), expected); + + df_spec.aggregation = FM_PIVOT_AGG_SUM; + df_spec.show_as = RawEnumValue(99); + EXPECT_EQ(fm_workbook_pivot_data_field_add(wb.handle, 0, pivot_idx, &df_spec, &df_idx), expected); + EXPECT_EQ(fm_workbook_pivot_data_field_set(wb.handle, 0, pivot_idx, 0, &df_spec), expected); + + BufferGuard after; + ASSERT_EQ(fm_workbook_save(wb.handle, &after.data, &after.len), 0) << fm_last_error_message(); + EXPECT_EQ(std::vector(after.data, after.data + after.len), snapshot); +} + +TEST(FormulonCApiPivot, PivotProjectionUsesWorkbookLocaleAndReportLayout) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + ASSERT_EQ(fm_workbook_pivot_set_layout(wb.handle, 0, pivot_idx, FM_PIVOT_LAYOUT_TABULAR), 0); + + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + std::uint32_t top = 0; + std::uint32_t left = 0; + std::uint32_t rows = 0; + std::uint32_t cols = 0; + ASSERT_EQ(fm_pivot_cells_bounds(projected.handle, &top, &left, &rows, &cols), 0); + EXPECT_EQ(cols, 2U); // Tabular exposes its two row-field columns. + + bool saw_japanese_total = false; + const std::size_t cell_count = fm_pivot_cells_count(projected.handle); + for (std::size_t i = 0; i < cell_count; ++i) { + fm_pivot_cell_t cell{}; + ASSERT_EQ(fm_pivot_cells_at(projected.handle, i, &cell), 0); + if (cell.value.kind == FM_VAL_TEXT && cell.value.u.text != nullptr && + std::string_view(cell.value.u.text) == "総計") { + saw_japanese_total = true; + } + } + EXPECT_TRUE(saw_japanese_total); +} + +TEST(FormulonCApiPivot, PivotCacheErrorSettersRejectInvalidErrorCodes) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_error(wb.handle, cache_id, 0, -1), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_error(wb.handle, cache_id, 0, 999), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(fm_workbook_pivot_cache_record_set_error(wb.handle, cache_id, 0, 1, -1), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(fm_workbook_pivot_cache_record_set_error(wb.handle, cache_id, 0, 1, 999), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); +} + +TEST(FormulonCApiPivot, PivotCacheRecordSettersRejectOutOfRangeFieldIndex) { + // BuildScratchPivot declares two cache fields (Region=0, Amount=1), so + // field_idx 2 is the first out-of-range value. A large / wrapping field_idx + // must be rejected before `grow_record_cells` resizes `field_idx + 1` + // cells — otherwise SIZE_MAX wraps to 0 and the subsequent cell write lands + // out of bounds. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + const auto kInvalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + const std::size_t kFieldCount = 2; + const std::size_t kMax = static_cast(-1); + + // In-range write still succeeds (guards against an over-tight bound). + EXPECT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, kFieldCount - 1, 1.0), 0); + + // field_idx == field_count and beyond are rejected on every setter. + EXPECT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, kFieldCount, 1.0), kInvalid); + EXPECT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, kMax, 1.0), kInvalid); + EXPECT_EQ(fm_workbook_pivot_cache_record_set_text(wb.handle, cache_id, 0, kMax, "x"), kInvalid); + EXPECT_EQ(fm_workbook_pivot_cache_record_set_bool(wb.handle, cache_id, 0, kMax, 1), kInvalid); + EXPECT_EQ(fm_workbook_pivot_cache_record_set_blank(wb.handle, cache_id, 0, kMax), kInvalid); + EXPECT_EQ(fm_workbook_pivot_cache_record_set_error(wb.handle, cache_id, 0, kMax, 0), kInvalid); +} diff --git a/tests/c_api/formulon_c_pivot_filters_test.cpp b/tests/c_api/formulon_c_pivot_filters_test.cpp new file mode 100644 index 000000000..46ee53f35 --- /dev/null +++ b/tests/c_api/formulon_c_pivot_filters_test.cpp @@ -0,0 +1,505 @@ +// Stable C ABI PivotTable filter tests. + +#include "formulon_c_pivot_test_helpers.h" + +TEST(FormulonCApiPivot, MutateExistingPivotFilter) { + // Build a scratch pivot — the OOXML reader populates only `custom_name` + // from ``, while the filter resolver matches on + // `source_name`, so a filter applied to a loaded workbook would silently + // no-op. Building from scratch lets us exercise the same surface against + // a pivot whose `source_name` is set the way the resolver expects. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + // Baseline projection contains "South" because no filter is active. + { + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0); + bool saw_south = false; + for (const fm_pivot_cell_t& c : CollectCells(projected.handle)) { + if (c.kind == FM_PIVOT_CELL_ROW_LABEL && c.value.kind == FM_VAL_TEXT && c.value.u.text != nullptr && + std::string_view(c.value.u.text) == "South") { + saw_south = true; + } + } + EXPECT_TRUE(saw_south); + } + + // Add a label filter that only retains "North". + fm_pivot_filter_spec_t spec{}; + spec.axis = FM_PIVOT_AXIS_ROW; + spec.field_name = "Region"; + spec.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; + spec.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; + spec.value_text = "N"; + spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), 0) << fm_last_error_message(); + + std::size_t filter_count = 0; + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &filter_count), 0); + EXPECT_EQ(filter_count, 1U); + + // Re-project — "South" must be dropped. + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0); + bool saw_south = false; + bool saw_north = false; + for (const fm_pivot_cell_t& c : CollectCells(projected.handle)) { + if (c.kind == FM_PIVOT_CELL_ROW_LABEL && c.value.kind == FM_VAL_TEXT && c.value.u.text != nullptr) { + if (std::string_view(c.value.u.text) == "South") { + saw_south = true; + } + if (std::string_view(c.value.u.text) == "North") { + saw_north = true; + } + } + } + EXPECT_TRUE(saw_north); + EXPECT_FALSE(saw_south); + + // Verify remove_at clears the filter. + ASSERT_EQ(fm_workbook_pivot_filter_remove_at(wb.handle, 0, pivot_idx, 0), 0); + std::size_t after_count = 99; + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &after_count), 0); + EXPECT_EQ(after_count, 0U); +} + +TEST(FormulonCApiPivot, InspectPivotFiltersByIndex) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t int_filter{}; + int_filter.axis = FM_PIVOT_AXIS_VALUE; + int_filter.field_name = "Amount"; + int_filter.type = FM_PIVOT_FILTER_VALUE_TOP_10; + int_filter.value_kind = FM_PIVOT_FILTER_VALUE_INT; + int_filter.value_int = 3; + int_filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + int_filter.data_field_index = 0; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &int_filter), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t double_filter{}; + double_filter.axis = FM_PIVOT_AXIS_VALUE; + double_filter.field_name = "Amount"; + double_filter.type = FM_PIVOT_FILTER_VALUE_GREATER_THAN; + double_filter.value_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; + double_filter.value_double = 50.5; + double_filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + double_filter.data_field_index = 0; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &double_filter), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t text_filter{}; + text_filter.axis = FM_PIVOT_AXIS_ROW; + text_filter.field_name = "Region"; + text_filter.type = FM_PIVOT_FILTER_LABEL_CONTAINS; + text_filter.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; + text_filter.value_text = "o"; + text_filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &text_filter), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t range_int_double{}; + range_int_double.axis = FM_PIVOT_AXIS_VALUE; + range_int_double.field_name = "Amount"; + range_int_double.type = FM_PIVOT_FILTER_VALUE_BETWEEN; + range_int_double.value_kind = FM_PIVOT_FILTER_VALUE_INT; + range_int_double.value_int = 100; + range_int_double.value_high_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; + range_int_double.value_high_double = 500.25; + range_int_double.data_field_index = 0; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &range_int_double), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t range_double_int{}; + range_double_int.axis = FM_PIVOT_AXIS_VALUE; + range_double_int.field_name = "Amount"; + range_double_int.type = FM_PIVOT_FILTER_VALUE_BETWEEN; + range_double_int.value_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; + range_double_int.value_double = 99.75; + range_double_int.value_high_kind = FM_PIVOT_FILTER_VALUE_INT; + range_double_int.value_high_int = 450; + range_double_int.data_field_index = 0; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &range_double_int), 0) << fm_last_error_message(); + + std::size_t filter_count = 0; + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &filter_count), 0); + ASSERT_EQ(filter_count, 5U); + + // Establish a projected result before inspection; a read must not change + // either the active-filter count or the memoised evaluation state. + PivotCellsGuard before_projection; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &before_projection.handle), 0) << fm_last_error_message(); + const std::vector before_cells = CollectCells(before_projection.handle); + + fm_pivot_filter_spec_t got{}; + ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 0, &got), 0); + EXPECT_EQ(got.axis, FM_PIVOT_AXIS_VALUE); + ASSERT_NE(got.field_name, nullptr); + EXPECT_STREQ(got.field_name, "Amount"); + EXPECT_EQ(got.type, FM_PIVOT_FILTER_VALUE_TOP_10); + EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_INT); + EXPECT_EQ(got.value_int, 3); + EXPECT_DOUBLE_EQ(got.value_double, 0.0); + EXPECT_EQ(got.value_text, nullptr); + EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_NONE); + EXPECT_EQ(got.value_high_int, 0); + EXPECT_DOUBLE_EQ(got.value_high_double, 0.0); + EXPECT_EQ(got.data_field_index, 0U); + + got = {}; + ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 1, &got), 0); + EXPECT_EQ(got.axis, FM_PIVOT_AXIS_VALUE); + ASSERT_NE(got.field_name, nullptr); + EXPECT_STREQ(got.field_name, "Amount"); + EXPECT_EQ(got.type, FM_PIVOT_FILTER_VALUE_GREATER_THAN); + EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_DOUBLE); + EXPECT_EQ(got.value_int, 0); + EXPECT_DOUBLE_EQ(got.value_double, 50.5); + EXPECT_EQ(got.value_text, nullptr); + EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_NONE); + EXPECT_EQ(got.value_high_int, 0); + EXPECT_DOUBLE_EQ(got.value_high_double, 0.0); + EXPECT_EQ(got.data_field_index, 0U); + + got = {}; + ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 2, &got), 0); + EXPECT_EQ(got.axis, FM_PIVOT_AXIS_ROW); + ASSERT_NE(got.field_name, nullptr); + EXPECT_STREQ(got.field_name, "Region"); + EXPECT_EQ(got.type, FM_PIVOT_FILTER_LABEL_CONTAINS); + EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_TEXT); + EXPECT_EQ(got.value_int, 0); + EXPECT_DOUBLE_EQ(got.value_double, 0.0); + ASSERT_NE(got.value_text, nullptr); + EXPECT_STREQ(got.value_text, "o"); + EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_NONE); + EXPECT_EQ(got.value_high_int, 0); + EXPECT_DOUBLE_EQ(got.value_high_double, 0.0); + EXPECT_EQ(got.data_field_index, 0U); + + got = {}; + ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 3, &got), 0); + EXPECT_EQ(got.axis, FM_PIVOT_AXIS_VALUE); + ASSERT_NE(got.field_name, nullptr); + EXPECT_STREQ(got.field_name, "Amount"); + EXPECT_EQ(got.type, FM_PIVOT_FILTER_VALUE_BETWEEN); + EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_INT); + EXPECT_EQ(got.value_int, 100); + EXPECT_DOUBLE_EQ(got.value_double, 0.0); + EXPECT_EQ(got.value_text, nullptr); + EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_DOUBLE); + EXPECT_EQ(got.value_high_int, 0); + EXPECT_DOUBLE_EQ(got.value_high_double, 500.25); + EXPECT_EQ(got.data_field_index, 0U); + + got = {}; + ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 4, &got), 0); + EXPECT_EQ(got.axis, FM_PIVOT_AXIS_VALUE); + ASSERT_NE(got.field_name, nullptr); + EXPECT_STREQ(got.field_name, "Amount"); + EXPECT_EQ(got.type, FM_PIVOT_FILTER_VALUE_BETWEEN); + EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_DOUBLE); + EXPECT_EQ(got.value_int, 0); + EXPECT_DOUBLE_EQ(got.value_double, 99.75); + EXPECT_EQ(got.value_text, nullptr); + EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_INT); + EXPECT_EQ(got.value_high_int, 450); + EXPECT_DOUBLE_EQ(got.value_high_double, 0.0); + EXPECT_EQ(got.data_field_index, 0U); + + std::size_t unchanged_count = 0; + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &unchanged_count), 0); + EXPECT_EQ(unchanged_count, filter_count); + PivotCellsGuard after_projection; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &after_projection.handle), 0) << fm_last_error_message(); + const std::vector after_cells = CollectCells(after_projection.handle); + ASSERT_EQ(after_cells.size(), before_cells.size()); + for (std::size_t i = 0; i < before_cells.size(); ++i) { + EXPECT_EQ(after_cells[i].row, before_cells[i].row); + EXPECT_EQ(after_cells[i].col, before_cells[i].col); + EXPECT_EQ(after_cells[i].kind, before_cells[i].kind); + EXPECT_EQ(after_cells[i].value.kind, before_cells[i].value.kind); + if (before_cells[i].value.kind == FM_VAL_NUMBER) { + EXPECT_DOUBLE_EQ(after_cells[i].value.u.number, before_cells[i].value.u.number); + } else if (before_cells[i].value.kind == FM_VAL_TEXT) { + ASSERT_NE(before_cells[i].value.u.text, nullptr); + ASSERT_NE(after_cells[i].value.u.text, nullptr); + EXPECT_STREQ(after_cells[i].value.u.text, before_cells[i].value.u.text); + } + } + + // Adding a filter invalidates the old model-backed views. Do not inspect + // those pointers after the mutation; reacquire the entry instead. + fm_pivot_filter_spec_t mutation{}; + mutation.axis = FM_PIVOT_AXIS_ROW; + mutation.field_name = "Region"; + mutation.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; + mutation.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; + mutation.value_text = "N"; + mutation.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &mutation), 0) << fm_last_error_message(); + fm_pivot_filter_spec_t reacquired{}; + ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 2, &reacquired), 0); + EXPECT_EQ(reacquired.value_kind, FM_PIVOT_FILTER_VALUE_TEXT); + ASSERT_NE(reacquired.value_text, nullptr); + EXPECT_STREQ(reacquired.value_text, "o"); + + const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + const auto make_sentinel = [] { + fm_pivot_filter_spec_t sentinel{}; + sentinel.axis = FM_PIVOT_AXIS_PAGE; + sentinel.field_name = "sentinel-field"; + sentinel.type = FM_PIVOT_FILTER_LABEL_DATE; + sentinel.value_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; + sentinel.value_int = -123456789; + sentinel.value_double = -9876.5; + sentinel.value_text = "sentinel-text"; + sentinel.value_high_kind = FM_PIVOT_FILTER_VALUE_INT; + sentinel.value_high_int = 13579; + sentinel.value_high_double = 24680.5; + sentinel.data_field_index = 0xDEADBEEFU; + return sentinel; + }; + const auto expect_unchanged = [](const fm_pivot_filter_spec_t& expected, const fm_pivot_filter_spec_t& actual) { + EXPECT_EQ(actual.axis, expected.axis); + EXPECT_EQ(actual.field_name, expected.field_name); + EXPECT_EQ(actual.type, expected.type); + EXPECT_EQ(actual.value_kind, expected.value_kind); + EXPECT_EQ(actual.value_int, expected.value_int); + EXPECT_DOUBLE_EQ(actual.value_double, expected.value_double); + EXPECT_EQ(actual.value_text, expected.value_text); + EXPECT_EQ(actual.value_high_kind, expected.value_high_kind); + EXPECT_EQ(actual.value_high_int, expected.value_high_int); + EXPECT_DOUBLE_EQ(actual.value_high_double, expected.value_high_double); + EXPECT_EQ(actual.data_field_index, expected.data_field_index); + }; + const auto expect_error_preserves_output = [&](const auto& invoke, fm_status_t expected_status) { + fm_pivot_filter_spec_t invalid_out = make_sentinel(); + const fm_pivot_filter_spec_t before = invalid_out; + EXPECT_EQ(invoke(&invalid_out), expected_status); + expect_unchanged(before, invalid_out); + }; + expect_error_preserves_output( + [&](fm_pivot_filter_spec_t* out) { return fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 6, out); }, + invalid); + expect_error_preserves_output( + [&](fm_pivot_filter_spec_t* out) { return fm_workbook_pivot_filter_at(wb.handle, 1, pivot_idx, 0, out); }, + invalid); + expect_error_preserves_output( + [&](fm_pivot_filter_spec_t* out) { return fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx + 1, 0, out); }, + invalid); + expect_error_preserves_output( + [&](fm_pivot_filter_spec_t* out) { return fm_workbook_pivot_filter_at(nullptr, 0, pivot_idx, 0, out); }, + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + EXPECT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 0, nullptr), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); +} + +TEST(FormulonCApiPivot, GetterRejectsUnrepresentableModelEnumsWithoutMutation) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + auto* table = wb.handle->workbook().sheet(0).mutable_pivot_tables()[pivot_idx].get(); + ASSERT_NE(table, nullptr); + const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + const auto make_sentinel = [] { + fm_pivot_filter_spec_t sentinel{}; + sentinel.axis = FM_PIVOT_AXIS_PAGE; + sentinel.field_name = "sentinel-field"; + sentinel.type = FM_PIVOT_FILTER_LABEL_DATE; + sentinel.value_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; + sentinel.value_int = -123456789; + sentinel.value_double = -9876.5; + sentinel.value_text = "sentinel-text"; + sentinel.value_high_kind = FM_PIVOT_FILTER_VALUE_INT; + sentinel.value_high_int = 13579; + sentinel.value_high_double = 24680.5; + sentinel.data_field_index = 0xDEADBEEFU; + return sentinel; + }; + const auto inject_and_expect_rejected = [&](formulon::pivot::PivotAxis axis, formulon::pivot::FilterType type) { + table->mutable_active_filters().clear(); + formulon::pivot::PivotFilter filter; + filter.axis = axis; + filter.field_name = "Region"; + filter.type = type; + filter.value = std::string("N"); + table->mutable_active_filters().push_back(filter); + + fm_pivot_filter_spec_t out = make_sentinel(); + const fm_pivot_filter_spec_t before = out; + EXPECT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 0, &out), invalid); + EXPECT_EQ(std::memcmp(&out, &before, sizeof(out)), 0); + }; + + inject_and_expect_rejected(formulon::pivot::PivotAxis::None, formulon::pivot::FilterType::LabelContains); + inject_and_expect_rejected(formulon::pivot::PivotAxis::Row, static_cast(0xFF)); +} + +TEST(FormulonCApiPivot, PivotFilterAddRejectsRawAxisAndTypeWithoutMutation) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t spec{}; + spec.axis = RawEnumValue(99); + spec.field_name = "Region"; + spec.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; + spec.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; + spec.value_text = "N"; + spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); + + std::size_t count = 99; + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); + EXPECT_EQ(count, 0U); + + spec.axis = FM_PIVOT_AXIS_ROW; + spec.type = RawEnumValue(99); + EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); + EXPECT_EQ(count, 0U); +} + +TEST(FormulonCApiPivot, PivotFilterRejectsInvalidDataFieldWithoutMutation) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t spec{}; + spec.axis = FM_PIVOT_AXIS_ROW; + spec.field_name = "Region"; + spec.type = FM_PIVOT_FILTER_VALUE_TOP_10; + spec.value_kind = FM_PIVOT_FILTER_VALUE_INT; + spec.value_int = 1; + spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + spec.data_field_index = 1; // BuildScratchPivot has only slot 0. + const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); + + std::size_t count = 99; + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); + EXPECT_EQ(count, 0U); +} + +TEST(FormulonCApiPivot, PivotFilterRejectsSelectorWhenNoDataFields) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + ASSERT_EQ(fm_workbook_pivot_data_field_clear(wb.handle, 0, pivot_idx), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t spec{}; + spec.axis = FM_PIVOT_AXIS_ROW; + spec.field_name = "Region"; + spec.type = FM_PIVOT_FILTER_VALUE_TOP_10; + spec.value_kind = FM_PIVOT_FILTER_VALUE_INT; + spec.value_int = 1; + spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + spec.data_field_index = 0; + const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); + + std::size_t count = 99; + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); + EXPECT_EQ(count, 0U); +} + +TEST(FormulonCApiPivot, PivotFilterRejectsUnknownFieldNameWithoutMutation) { + // A label filter naming no pivot field would be a silent no-op inside the + // engine, so the mutator rejects it instead of reporting success. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t spec{}; + spec.axis = FM_PIVOT_AXIS_ROW; + spec.field_name = "NoSuchField"; + spec.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; + spec.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; + spec.value_text = "N"; + spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); + + std::size_t count = 99; + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); + EXPECT_EQ(count, 0U); + + // The data field's display name resolves through to its source field, so + // the same call shape is accepted for it. + spec.field_name = "Sum of Amount"; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), 0) << fm_last_error_message(); + ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); + EXPECT_EQ(count, 1U); +} + +TEST(FormulonCApiPivot, FilterErrorUsesApiName) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + fm_pivot_filter_spec_t spec{}; + spec.axis = FM_PIVOT_AXIS_ROW; + spec.field_name = "Region"; + spec.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; + spec.value_kind = FM_PIVOT_FILTER_VALUE_NONE; + spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); + EXPECT_EQ(std::string_view(fm_last_error_message()).find("fm_workbook_pivot_filter_add:"), 0U); +} + +TEST(FormulonCApiPivot, MutateShowValuesAs) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + // Re-set the data field to PercentOfRow. + std::size_t df_count = 0; + ASSERT_EQ(fm_workbook_pivot_data_field_count(wb.handle, 0, pivot_idx, &df_count), 0); + ASSERT_EQ(df_count, 1U); + + fm_pivot_data_field_spec_t df_spec{}; + df_spec.name = "Sum of Amount"; + df_spec.field_index = 1U; // Amount is the second pivot field. + df_spec.aggregation = FM_PIVOT_AGG_SUM; + df_spec.number_format = ""; + df_spec.show_as = FM_PIVOT_SHOW_AS_PERCENT_OF_ROW; + df_spec.show_as_base_field = -1; + df_spec.show_as_base_item = -1; + ASSERT_EQ(fm_workbook_pivot_data_field_set(wb.handle, 0, pivot_idx, 0, &df_spec), 0) << fm_last_error_message(); + + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + + // Each FM_PIVOT_CELL_DATA cell should equal 1.0 (single data column -> + // each row's percent-of-row is 100% of itself). + std::size_t data_count = 0; + for (const fm_pivot_cell_t& c : CollectCells(projected.handle)) { + if (c.kind == FM_PIVOT_CELL_DATA && c.value.kind == FM_VAL_NUMBER) { + EXPECT_DOUBLE_EQ(c.value.u.number, 1.0); + ++data_count; + } + } + EXPECT_GT(data_count, 0U); +} diff --git a/tests/c_api/formulon_c_pivot_grouping_test.cpp b/tests/c_api/formulon_c_pivot_grouping_test.cpp new file mode 100644 index 000000000..badfb1cec --- /dev/null +++ b/tests/c_api/formulon_c_pivot_grouping_test.cpp @@ -0,0 +1,373 @@ +// Stable C ABI PivotTable grouping and item-binding tests. + +#include + +#include "formulon_c_pivot_test_helpers.h" + +namespace { + +fm_status_t BuildDaysGroupedPivot(fm_workbook_t* wb, double start_serial, std::size_t* out_pivot_index) { + std::uint32_t cache_id = 0; + fm_status_t st = fm_workbook_pivot_cache_create(wb, 0, &cache_id); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_cache_set_worksheet_source(wb, cache_id, /*present=*/1, "A1:B71", "Sheet1", nullptr); + if (st != 0) { + return st; + } + std::size_t date_idx = 99; + st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Date", &date_idx); + if (st != 0) { + return st; + } + std::size_t amount_idx = 99; + st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Amount", &amount_idx); + if (st != 0) { + return st; + } + for (double serial = 0.0; serial <= 70.0; serial += 1.0) { + std::size_t rec_idx = 99; + st = fm_workbook_pivot_cache_record_add(wb, cache_id, &rec_idx); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, date_idx, serial); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, amount_idx, 1.0); + if (st != 0) { + return st; + } + } + std::size_t pivot_idx = 99; + st = fm_workbook_pivot_create(wb, 0, "PT", cache_id, /*anchor_row=*/0U, /*anchor_col=*/3U, &pivot_idx); + if (st != 0) { + return st; + } + fm_pivot_field_spec_t date_spec{}; + date_spec.source_name = "Date"; + date_spec.custom_name = ""; + date_spec.axis = FM_PIVOT_AXIS_ROW; + date_spec.subtotal_top = 0; + date_spec.number_format = ""; + std::size_t date_field = 99; + st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, &date_spec, &date_field); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_field_set_date_group(wb, 0, pivot_idx, date_field, FM_PIVOT_DATE_DAYS, + FM_PIVOT_CALENDAR_GREGORIAN, -1, -1, /*interval_days=*/7, start_serial, + /*end_serial_or_neg1=*/-1.0); + if (st != 0) { + return st; + } + fm_pivot_field_spec_t amount_spec{}; + amount_spec.source_name = "Amount"; + amount_spec.custom_name = ""; + amount_spec.axis = FM_PIVOT_AXIS_VALUE; + amount_spec.subtotal_top = 0; + amount_spec.number_format = ""; + std::size_t amount_field = 99; + st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, &amount_spec, &amount_field); + if (st != 0) { + return st; + } + const std::uint32_t row_order[] = {static_cast(date_field)}; + st = fm_workbook_pivot_set_row_field_order(wb, 0, pivot_idx, row_order, 1U); + if (st != 0) { + return st; + } + fm_pivot_data_field_spec_t df_spec{}; + df_spec.name = "Sum of Amount"; + df_spec.field_index = static_cast(amount_field); + df_spec.aggregation = FM_PIVOT_AGG_SUM; + df_spec.number_format = ""; + df_spec.show_as = FM_PIVOT_SHOW_AS_NORMAL; + df_spec.show_as_base_field = -1; + df_spec.show_as_base_item = -1; + std::size_t df_idx = 99; + st = fm_workbook_pivot_data_field_add(wb, 0, pivot_idx, &df_spec, &df_idx); + if (st != 0) { + return st; + } + if (out_pivot_index != nullptr) { + *out_pivot_index = pivot_idx; + } + return 0; +} + +// Collects (row label, Sum(Amount)) pairs from a one-row-field, +// one-data-field pivot's projected grid, in row order. +std::vector> CollectDaysGroupRows(fm_pivot_cells_t* handle) { + std::vector cells = CollectCells(handle); + std::vector> labels_by_row; + std::vector sums_by_row; + for (const fm_pivot_cell_t& c : cells) { + if (c.kind == FM_PIVOT_CELL_ROW_LABEL && c.value.kind == FM_VAL_TEXT) { + labels_by_row.emplace_back(std::string(c.value.u.text), 0.0); + } else if (c.kind == FM_PIVOT_CELL_DATA && c.value.kind == FM_VAL_NUMBER) { + sums_by_row.push_back(c.value.u.number); + } + } + EXPECT_EQ(labels_by_row.size(), sums_by_row.size()); + for (std::size_t i = 0; i < labels_by_row.size() && i < sums_by_row.size(); ++i) { + labels_by_row[i].second = sums_by_row[i]; + } + return labels_by_row; +} + +// Region shared items are `[0] = "North"`, `[1] = blank`; the two records +// carry one of each. The blank shared item deliberately does NOT sit at index +// 0, which is what a name-addressed item silently binds to. +fm_status_t BuildBlankItemPivot(fm_workbook_t* wb, std::uint32_t* out_cache_id, std::size_t* out_pivot_index) { + std::uint32_t cache_id = 0; + if (fm_status_t st = fm_workbook_pivot_cache_create(wb, 0U, &cache_id); st != 0) { + return st; + } + std::size_t region_idx = 99; + std::size_t amount_idx = 99; + if (fm_status_t st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Region", ®ion_idx); st != 0) { + return st; + } + if (fm_status_t st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Amount", &amount_idx); st != 0) { + return st; + } + if (fm_status_t st = fm_workbook_pivot_cache_field_add_shared_item_text(wb, cache_id, region_idx, "North"); st != 0) { + return st; + } + if (fm_status_t st = fm_workbook_pivot_cache_field_add_shared_item_blank(wb, cache_id, region_idx); st != 0) { + return st; + } + + std::size_t rec_idx = 99; + if (fm_status_t st = fm_workbook_pivot_cache_record_add(wb, cache_id, &rec_idx); st != 0) { + return st; + } + if (fm_status_t st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, region_idx, 0.0); st != 0) { + return st; + } + if (fm_status_t st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, amount_idx, 100.0); st != 0) { + return st; + } + if (fm_status_t st = fm_workbook_pivot_cache_record_add(wb, cache_id, &rec_idx); st != 0) { + return st; + } + if (fm_status_t st = fm_workbook_pivot_cache_record_set_blank(wb, cache_id, rec_idx, region_idx); st != 0) { + return st; + } + if (fm_status_t st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, amount_idx, 200.0); st != 0) { + return st; + } + + std::size_t pivot_idx = 99; + if (fm_status_t st = fm_workbook_pivot_create(wb, 0, "PT", cache_id, 0U, 0U, &pivot_idx); st != 0) { + return st; + } + fm_pivot_field_spec_t region_spec{}; + region_spec.source_name = "Region"; + region_spec.custom_name = ""; + region_spec.axis = FM_PIVOT_AXIS_ROW; + region_spec.subtotal_top = 0; + region_spec.number_format = ""; + std::size_t region_field = 99; + if (fm_status_t st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, ®ion_spec, ®ion_field); st != 0) { + return st; + } + fm_pivot_field_spec_t amount_spec{}; + amount_spec.source_name = "Amount"; + amount_spec.custom_name = ""; + amount_spec.axis = FM_PIVOT_AXIS_VALUE; + amount_spec.subtotal_top = 0; + amount_spec.number_format = ""; + std::size_t amount_field = 99; + if (fm_status_t st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, &amount_spec, &amount_field); st != 0) { + return st; + } + const std::uint32_t row_order[] = {static_cast(region_field)}; + if (fm_status_t st = fm_workbook_pivot_set_row_field_order(wb, 0, pivot_idx, row_order, 1U); st != 0) { + return st; + } + fm_pivot_data_field_spec_t df_spec{}; + df_spec.name = "Sum of Amount"; + df_spec.field_index = static_cast(amount_field); + df_spec.aggregation = FM_PIVOT_AGG_SUM; + df_spec.number_format = ""; + df_spec.show_as = FM_PIVOT_SHOW_AS_NORMAL; + df_spec.show_as_base_field = -1; + df_spec.show_as_base_item = -1; + std::size_t df_idx = 99; + if (fm_status_t st = fm_workbook_pivot_data_field_add(wb, 0, pivot_idx, &df_spec, &df_idx); st != 0) { + return st; + } + if (out_cache_id != nullptr) { + *out_cache_id = cache_id; + } + if (out_pivot_index != nullptr) { + *out_pivot_index = pivot_idx; + } + return 0; +} + +// Sum of every `FM_PIVOT_CELL_DATA` cell in the projected layout. +double SumDataCells(fm_pivot_cells_t* handle) { + double total = 0.0; + const std::size_t n = fm_pivot_cells_count(handle); + for (std::size_t i = 0; i < n; ++i) { + fm_pivot_cell_t cell{}; + EXPECT_EQ(fm_pivot_cells_at(handle, i, &cell), 0); + if (cell.kind == FM_PIVOT_CELL_DATA && cell.value.kind == FM_VAL_NUMBER) { + total += cell.value.u.number; + } + } + return total; +} + +} // namespace + +TEST(FormulonCApiPivot, DaysGroupMatchesPivotWeek1900FixtureThroughTheAbi) { + // Ground truth transcribed from + // tests/fixtures/excel/win/pivot_week_1900/results.json (Windows Excel + // 365 ja-JP, build 16.0.20228): four Start variants of the same + // By-7-days grouping, read from the real Excel-rendered pivot. `auto` + // (Start=True) and `start0` (Start=0) coincide because 0 is already + // the data minimum. + const double kAuto = -1.0; + const std::vector> kAutoAndStart0Rows = { + {"1900/1/0 - 1900/1/6", 7.0}, {"1900/1/7 - 1900/1/13", 7.0}, {"1900/1/14 - 1900/1/20", 7.0}, + {"1900/1/21 - 1900/1/27", 7.0}, {"1900/1/28 - 1900/2/3", 7.0}, {"1900/2/4 - 1900/2/10", 7.0}, + {"1900/2/11 - 1900/2/17", 7.0}, {"1900/2/18 - 1900/2/24", 7.0}, {"1900/2/25 - 1900/3/2", 7.0}, + {"1900/3/3 - 1900/3/9", 7.0}, {"1900/3/10 - 1900/3/11", 1.0}, + }; + const std::vector> kStart1Rows = { + {"<1900/1/1", 1.0}, + {"1900/1/1 - 1900/1/7", 7.0}, + {"1900/1/8 - 1900/1/14", 7.0}, + {"1900/1/15 - 1900/1/21", 7.0}, + {"1900/1/22 - 1900/1/28", 7.0}, + {"1900/1/29 - 1900/2/4", 7.0}, + {"1900/2/5 - 1900/2/11", 7.0}, + {"1900/2/12 - 1900/2/18", 7.0}, + {"1900/2/19 - 1900/2/25", 7.0}, + {"1900/2/26 - 1900/3/3", 7.0}, + {"1900/3/4 - 1900/3/10", 7.0}, + }; + const std::vector> kStart61Rows = { + {"<1900/3/1", 61.0}, + {"1900/3/1 - 1900/3/7", 7.0}, + {"1900/3/8 - 1900/3/11", 3.0}, + }; + + const struct { + const char* name; + double start_serial; + const std::vector>* expected; + } variants[] = { + {"auto", kAuto, &kAutoAndStart0Rows}, + {"start0", 0.0, &kAutoAndStart0Rows}, + {"start1", 1.0, &kStart1Rows}, + {"start61", 61.0, &kStart61Rows}, + }; + + for (const auto& variant : variants) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0) << variant.name; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildDaysGroupedPivot(wb.handle, variant.start_serial, &pivot_idx), 0) + << variant.name << ": " << fm_last_error_message(); + + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) + << variant.name << ": " << fm_last_error_message(); + + const std::vector> rows = CollectDaysGroupRows(projected.handle); + ASSERT_EQ(rows.size(), variant.expected->size()) << variant.name; + for (std::size_t i = 0; i < rows.size(); ++i) { + EXPECT_EQ(rows[i].first, (*variant.expected)[i].first) << variant.name << " row " << i; + EXPECT_DOUBLE_EQ(rows[i].second, (*variant.expected)[i].second) << variant.name << " row " << i; + } + } +} + +TEST(FormulonCApiPivot, AddItemAtBindsTheBlankItemByCacheIndex) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildBlankItemPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + // Both source rows contribute before any manual filter exists. + { + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 300.0); + } + + // The blank shared item is at cache index 1, so the index-addressed adder is + // the only way to name it: an empty label carries no binding of its own. + ASSERT_EQ(fm_workbook_pivot_field_add_item(wb.handle, 0, pivot_idx, 0, "North", 1), 0); + ASSERT_EQ(fm_workbook_pivot_field_add_item_at(wb.handle, 0, pivot_idx, 0, /*cache_index=*/1U, /*visible=*/0), 0); + { + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 100.0); + } +} + +TEST(FormulonCApiPivot, AddItemWithEmptyNameCannotHideTheBlankRow) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildBlankItemPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + // A name-addressed item defaults to cache index 0, which here holds + // "North". The item is unlabelled, so it takes its label from that binding + // and hides North, never the blank row. This is the gap + // `fm_workbook_pivot_field_add_item_at` closes. + ASSERT_EQ(fm_workbook_pivot_field_add_item(wb.handle, 0, pivot_idx, 0, "North", 1), 0); + ASSERT_EQ(fm_workbook_pivot_field_add_item(wb.handle, 0, pivot_idx, 0, "", 0), 0); + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 200.0); +} + +TEST(FormulonCApiPivot, AddItemAtHidesANonBlankItemBeforeAnyReload) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + ASSERT_EQ(fm_workbook_pivot_field_clear_items(wb.handle, 0, pivot_idx, 0), 0); + ASSERT_EQ(fm_workbook_pivot_field_add_item_at(wb.handle, 0, pivot_idx, 0, /*cache_index=*/1U, /*visible=*/0), 0); + { + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 400.0); // North only: 100 + 300. + } + EXPECT_FALSE(LayoutHasText(wb.handle, pivot_idx, "South")); + + // The label is derived at evaluation time, so repopulating the shared + // items after the item was added changes nothing. + ASSERT_EQ(fm_workbook_pivot_cache_field_clear_shared_items(wb.handle, cache_id, 0), 0); + ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_text(wb.handle, cache_id, 0, "North"), 0); + ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_text(wb.handle, cache_id, 0, "South"), 0); + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 400.0); +} + +TEST(FormulonCApiPivot, SoleVisiblePageItemAddedByCacheIndexNamesItsValue) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + ASSERT_EQ(fm_workbook_pivot_set_row_field_order(wb.handle, 0, pivot_idx, nullptr, 0U), 0); + ASSERT_EQ(fm_workbook_pivot_field_set_axis(wb.handle, 0, pivot_idx, 0, FM_PIVOT_AXIS_PAGE), 0); + ASSERT_EQ(fm_workbook_pivot_field_clear_items(wb.handle, 0, pivot_idx, 0), 0); + ASSERT_EQ(fm_workbook_pivot_field_add_item_at(wb.handle, 0, pivot_idx, 0, /*cache_index=*/0U, /*visible=*/0), 0); + ASSERT_EQ(fm_workbook_pivot_field_add_item_at(wb.handle, 0, pivot_idx, 0, /*cache_index=*/1U, /*visible=*/1), 0); + EXPECT_TRUE(LayoutHasText(wb.handle, pivot_idx, "South")); +} diff --git a/tests/c_api/formulon_c_pivot_lifecycle_test.cpp b/tests/c_api/formulon_c_pivot_lifecycle_test.cpp new file mode 100644 index 000000000..e464d6e15 --- /dev/null +++ b/tests/c_api/formulon_c_pivot_lifecycle_test.cpp @@ -0,0 +1,308 @@ +// Stable C ABI PivotTable lifecycle and number-format tests. + +#include "formulon_c_pivot_test_helpers.h" + +TEST(FormulonCApiPivot, RemovePivotAndCache) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + std::size_t before_pivots = 0; + std::size_t before_caches = 0; + ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 0, &before_pivots), 0); + ASSERT_EQ(fm_workbook_pivot_cache_count(wb.handle, &before_caches), 0); + EXPECT_EQ(before_pivots, 1U); + EXPECT_EQ(before_caches, 1U); + + // Remove the pivot first, then the cache. + ASSERT_EQ(fm_workbook_pivot_remove(wb.handle, 0, pivot_idx), 0); + std::size_t after_remove_pivots = 99; + ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 0, &after_remove_pivots), 0); + EXPECT_EQ(after_remove_pivots, 0U); + + ASSERT_EQ(fm_workbook_pivot_cache_remove(wb.handle, cache_id), 0); + std::size_t after_caches = 99; + ASSERT_EQ(fm_workbook_pivot_cache_count(wb.handle, &after_caches), 0); + EXPECT_EQ(after_caches, 0U); +} + +TEST(FormulonCApiPivot, CacheRemoveBlockedByPivot) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + EXPECT_EQ(fm_workbook_pivot_cache_remove(wb.handle, cache_id), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + // Removing a non-existent cache id must also surface kInvalidArgument. + EXPECT_EQ(fm_workbook_pivot_cache_remove(wb.handle, 9999U), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); +} + +TEST(FormulonCApiPivot, NullPointerArgumentsRejected) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + ASSERT_EQ(fm_workbook_pivot_cache_create(wb.handle, 0, &cache_id), 0); + std::size_t field_idx = 99; + ASSERT_EQ(fm_workbook_pivot_cache_field_add(wb.handle, cache_id, "F", &field_idx), 0); + + std::size_t out_count = 0; + EXPECT_EQ(fm_workbook_pivot_cache_count(nullptr, &out_count), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + EXPECT_EQ(fm_workbook_pivot_cache_count(wb.handle, nullptr), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + + std::size_t fc = 0; + EXPECT_EQ(fm_workbook_pivot_cache_field_count(nullptr, cache_id, &fc), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + + EXPECT_EQ(fm_workbook_pivot_cache_field_add(wb.handle, cache_id, nullptr, &field_idx), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + + EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_text(wb.handle, cache_id, field_idx, nullptr), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + + std::size_t pivot_idx = 99; + EXPECT_EQ(fm_workbook_pivot_create(wb.handle, 0, nullptr, cache_id, 0, 0, &pivot_idx), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + + // spec arguments + EXPECT_EQ(fm_workbook_pivot_field_add(wb.handle, 0, 0, nullptr, &pivot_idx), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + + fm_pivot_filter_spec_t bad_filter{}; + bad_filter.field_name = nullptr; + bad_filter.type = FM_PIVOT_FILTER_LABEL_CONTAINS; + bad_filter.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; + bad_filter.value_text = "X"; + bad_filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, 0, &bad_filter), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); +} + +TEST(FormulonCApiPivot, NullHandleStatusMatchesRecordedError) { + // The shared-item and record setters resolve their target through a + // common lookup helper. The status each returns must be the code that + // helper recorded, so a caller branching on the return value and a + // caller reading `fm_last_error_message` reach the same conclusion. + const auto null_pointer = static_cast(formulon::FormulonErrorCode::kBindingNullPointer); + const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); + + const auto expect_null_handle = [&](fm_status_t status) { + EXPECT_EQ(status, null_pointer); + EXPECT_NE(std::string(fm_last_error_message()).find("wb is NULL"), std::string::npos) + << "message: " << fm_last_error_message(); + }; + + expect_null_handle(fm_workbook_pivot_cache_field_add_shared_item_number(nullptr, 0U, 0U, 1.0)); + expect_null_handle(fm_workbook_pivot_cache_field_add_shared_item_bool(nullptr, 0U, 0U, 1)); + expect_null_handle(fm_workbook_pivot_cache_field_add_shared_item_blank(nullptr, 0U, 0U)); + expect_null_handle(fm_workbook_pivot_cache_field_add_shared_item_error(nullptr, 0U, 0U, /*error=*/1)); + expect_null_handle(fm_workbook_pivot_cache_field_clear_shared_items(nullptr, 0U, 0U)); + expect_null_handle(fm_workbook_pivot_cache_record_set_number(nullptr, 0U, 0U, 0U, 1.0)); + expect_null_handle(fm_workbook_pivot_cache_record_set_text(nullptr, 0U, 0U, 0U, "x")); + expect_null_handle(fm_workbook_pivot_cache_record_set_bool(nullptr, 0U, 0U, 0U, 1)); + expect_null_handle(fm_workbook_pivot_cache_record_set_blank(nullptr, 0U, 0U, 0U)); + expect_null_handle(fm_workbook_pivot_cache_record_set_error(nullptr, 0U, 0U, 0U, /*error=*/1)); + + // A live handle with an out-of-range coordinate keeps reporting + // kInvalidArgument through the same helpers. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + ASSERT_EQ(fm_workbook_pivot_cache_create(wb.handle, 0, &cache_id), 0); + std::size_t field_idx = 99; + ASSERT_EQ(fm_workbook_pivot_cache_field_add(wb.handle, cache_id, "F", &field_idx), 0); + + EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_number(wb.handle, cache_id, field_idx + 1U, 1.0), invalid); + EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_number(wb.handle, cache_id + 100U, field_idx, 1.0), invalid); + EXPECT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, /*record_idx=*/0U, field_idx, 1.0), invalid); +} + +TEST(FormulonCApiPivot, OutOfGridAnchorRejected) { + // Excel grid ceilings; kept as literals so this test needs no core + // header. Mirrors Sheet::kMaxRows / kMaxCols. + constexpr std::uint32_t kMaxRows = 1'048'576U; + constexpr std::uint32_t kMaxCols = 16'384U; + + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + ASSERT_EQ(fm_workbook_pivot_cache_create(wb.handle, 0, &cache_id), 0); + std::size_t field_idx = 99; + ASSERT_EQ(fm_workbook_pivot_cache_field_add(wb.handle, cache_id, "F", &field_idx), 0); + + // create() with an anchor past the grid must be rejected, not stored. + std::size_t pivot_idx = 99; + EXPECT_EQ(fm_workbook_pivot_create(wb.handle, 0, "PT", cache_id, kMaxRows, 0U, &pivot_idx), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(fm_workbook_pivot_create(wb.handle, 0, "PT", cache_id, 0U, kMaxCols, &pivot_idx), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + // A valid create succeeds, then set_anchor must reject a span whose far + // corner leaves the grid (the wrap the audit flagged) and a zero span. + ASSERT_EQ(fm_workbook_pivot_create(wb.handle, 0, "PT", cache_id, 0U, 0U, &pivot_idx), 0); + EXPECT_EQ( + fm_workbook_pivot_set_anchor(wb.handle, 0, pivot_idx, kMaxRows - 1U, 0U, /*span_rows=*/2U, /*span_cols=*/1U), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(fm_workbook_pivot_set_anchor(wb.handle, 0, pivot_idx, 0U, 0U, /*span_rows=*/0U, /*span_cols=*/1U), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + // An in-grid span still succeeds. + EXPECT_EQ(fm_workbook_pivot_set_anchor(wb.handle, 0, pivot_idx, 0U, 0U, /*span_rows=*/3U, /*span_cols=*/2U), 0); +} + +TEST(FormulonCApiPivot, MutationAndClearExportsCompleteTheirLifecycles) { + // Exercise the C ABI setters that are not needed by the projected-layout + // examples above. Each mutation is followed by the corresponding clear + // where one exists, which also verifies that the handle remains usable + // across each invalidation boundary. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + + std::uint32_t id_at_zero = 0; + ASSERT_EQ(fm_workbook_pivot_cache_id_at(wb.handle, 0, &id_at_zero), 0); + EXPECT_EQ(id_at_zero, cache_id); + + const char* field_name = nullptr; + ASSERT_EQ(fm_workbook_pivot_cache_field_name(wb.handle, cache_id, 0, &field_name), 0); + EXPECT_STREQ(field_name, "Region"); + + ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_number(wb.handle, cache_id, 0, 7.0), 0); + ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_bool(wb.handle, cache_id, 0, 1), 0); + ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_blank(wb.handle, cache_id, 0), 0); + ASSERT_EQ(fm_workbook_pivot_cache_field_clear_shared_items(wb.handle, cache_id, 0), 0); + std::size_t shared_count = 99; + ASSERT_EQ(fm_workbook_pivot_cache_field_shared_item_count(wb.handle, cache_id, 0, &shared_count), 0); + EXPECT_EQ(shared_count, 0U); + + std::size_t record_idx = 99; + ASSERT_EQ(fm_workbook_pivot_cache_record_add(wb.handle, cache_id, &record_idx), 0); + ASSERT_EQ(fm_workbook_pivot_cache_record_set_text(wb.handle, cache_id, record_idx, 0, "North"), 0); + ASSERT_EQ(fm_workbook_pivot_cache_record_set_bool(wb.handle, cache_id, record_idx, 0, 1), 0); + ASSERT_EQ(fm_workbook_pivot_cache_record_set_blank(wb.handle, cache_id, record_idx, 0), 0); + ASSERT_EQ(fm_workbook_pivot_cache_record_set_error(wb.handle, cache_id, record_idx, 0, 1), 0); + ASSERT_EQ(fm_workbook_pivot_cache_record_clear(wb.handle, cache_id), 0); + std::size_t record_count = 99; + ASSERT_EQ(fm_workbook_pivot_cache_record_count(wb.handle, cache_id, &record_count), 0); + EXPECT_EQ(record_count, 0U); + + ASSERT_EQ(fm_workbook_pivot_set_name(wb.handle, 0, pivot_idx, "Renamed"), 0); + ASSERT_EQ(fm_workbook_pivot_set_grand_totals(wb.handle, 0, pivot_idx, 0, 1), 0); + std::size_t field_count = 99; + ASSERT_EQ(fm_workbook_pivot_field_count(wb.handle, 0, pivot_idx, &field_count), 0); + ASSERT_EQ(field_count, 2U); + + ASSERT_EQ(fm_workbook_pivot_field_set_axis(wb.handle, 0, pivot_idx, 0, FM_PIVOT_AXIS_ROW), 0); + ASSERT_EQ(fm_workbook_pivot_field_set_sort(wb.handle, 0, pivot_idx, 0, 0, "Amount"), 0); + ASSERT_EQ(fm_workbook_pivot_field_set_subtotal_top(wb.handle, 0, pivot_idx, 0, 1), 0); + ASSERT_EQ(fm_workbook_pivot_field_set_item_visible(wb.handle, 0, pivot_idx, 0, 0, 0), 0); + ASSERT_EQ(fm_workbook_pivot_field_clear_items(wb.handle, 0, pivot_idx, 0), 0); + ASSERT_EQ(fm_workbook_pivot_field_add_subtotal_fn(wb.handle, 0, pivot_idx, 0, FM_PIVOT_AGG_COUNT), 0); + ASSERT_EQ(fm_workbook_pivot_field_clear_subtotal_fns(wb.handle, 0, pivot_idx, 0), 0); + ASSERT_EQ(fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, FM_PIVOT_DATE_YEAR, + FM_PIVOT_CALENDAR_GREGORIAN, 2020, 2026, 1, -1.0, -1.0), + 0); + ASSERT_EQ(fm_workbook_pivot_field_clear_date_group(wb.handle, 0, pivot_idx, 0), 0); + ASSERT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, "4"), 0); + const std::uint32_t col_order[] = {1U}; + ASSERT_EQ(fm_workbook_pivot_set_col_field_order(wb.handle, 0, pivot_idx, col_order, 1U), 0); + + ASSERT_EQ(fm_workbook_pivot_data_field_clear(wb.handle, 0, pivot_idx), 0); + std::size_t data_field_count = 99; + ASSERT_EQ(fm_workbook_pivot_data_field_count(wb.handle, 0, pivot_idx, &data_field_count), 0); + EXPECT_EQ(data_field_count, 0U); + + fm_pivot_filter_spec_t filter{}; + filter.axis = FM_PIVOT_AXIS_ROW; + filter.field_name = "Region"; + filter.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; + filter.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; + filter.value_text = "N"; + filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; + ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &filter), 0); + ASSERT_EQ(fm_workbook_pivot_filter_clear(wb.handle, 0, pivot_idx), 0); + + ASSERT_EQ(fm_workbook_pivot_field_clear(wb.handle, 0, pivot_idx), 0); + ASSERT_EQ(fm_workbook_pivot_field_count(wb.handle, 0, pivot_idx, &field_count), 0); + EXPECT_EQ(field_count, 0U); + ASSERT_EQ(fm_workbook_pivot_cache_field_clear(wb.handle, cache_id), 0); + ASSERT_EQ(fm_workbook_pivot_cache_field_count(wb.handle, cache_id, &field_count), 0); + EXPECT_EQ(field_count, 0U); +} + +TEST(FormulonCApiPivot, FieldNumberFormatRoundTripsAsNumFmtId) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + std::uint32_t cache_id = 0; + std::size_t pivot_idx = 0; + ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); + std::uint16_t custom_id = 0; + ASSERT_EQ(fm_styles_add_num_fmt(wb.handle, "0.000", &custom_id), 0) << fm_last_error_message(); + const std::string custom = std::to_string(custom_id); + ASSERT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, custom.c_str()), 0) + << fm_last_error_message(); + + const std::string first = SavedPivotXml(wb.handle); + const std::string attr = "numFmtId=\"" + custom + "\""; + EXPECT_EQ(CountOccurrences(first, attr), 1U) << first; + + // A reload reads the attribute into the model rather than into the + // passthrough bin, so the next save still emits it exactly once. + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << fm_last_error_message(); + WorkbookGuard reloaded; + ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0) << fm_last_error_message(); + const std::string second = SavedPivotXml(reloaded.handle); + EXPECT_EQ(CountOccurrences(second, attr), 1U) << second; + EXPECT_NE(second.find("(formulon::FormulonErrorCode::kInvalidArgument); + + // A format code, a signed id, an id nothing registered, one past uint16. + for (const char* bad : {"0.00", "-4", "200", "65536"}) { + EXPECT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, bad), invalid) << bad; + + fm_pivot_field_spec_t field_spec{}; + field_spec.source_name = "Amount"; + field_spec.axis = FM_PIVOT_AXIS_VALUE; + field_spec.number_format = bad; + std::size_t field_idx = 99; + EXPECT_EQ(fm_workbook_pivot_field_add(wb.handle, 0, pivot_idx, &field_spec, &field_idx), invalid) << bad; + + fm_pivot_data_field_spec_t df_spec{}; + df_spec.name = "Sum of Amount"; + df_spec.field_index = 1U; + df_spec.aggregation = FM_PIVOT_AGG_SUM; + df_spec.number_format = bad; + df_spec.show_as_base_field = -1; + df_spec.show_as_base_item = -1; + std::size_t df_idx = 99; + EXPECT_EQ(fm_workbook_pivot_data_field_add(wb.handle, 0, pivot_idx, &df_spec, &df_idx), invalid) << bad; + EXPECT_EQ(fm_workbook_pivot_data_field_set(wb.handle, 0, pivot_idx, 0, &df_spec), invalid) << bad; + } + std::size_t field_count = 0; + ASSERT_EQ(fm_workbook_pivot_field_count(wb.handle, 0, pivot_idx, &field_count), 0); + EXPECT_EQ(field_count, 2U); + std::size_t df_count = 0; + ASSERT_EQ(fm_workbook_pivot_data_field_count(wb.handle, 0, pivot_idx, &df_count), 0); + EXPECT_EQ(df_count, 1U); + + // A built-in id and the empty string (clear) are accepted. + EXPECT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, "4"), 0) << fm_last_error_message(); + EXPECT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, ""), 0) << fm_last_error_message(); +} diff --git a/tests/c_api/formulon_c_pivot_read_test.cpp b/tests/c_api/formulon_c_pivot_read_test.cpp new file mode 100644 index 000000000..cd239d113 --- /dev/null +++ b/tests/c_api/formulon_c_pivot_read_test.cpp @@ -0,0 +1,267 @@ +// Stable C ABI PivotTable read and loaded-package tests. + +#include "formulon_c_pivot_test_helpers.h" + +namespace { + +std::vector BuildPivotWorkbookBytes() { + const std::string_view content_types = + "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"; + + const std::string_view package_rels = + "\n" + "\n" + " \n" + "\n"; + + const std::string_view workbook_xml = + "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"; + + const std::string_view workbook_rels = + "\n" + "\n" + " \n" + " \n" + " \n" + "\n"; + + const std::string_view sheet_xml = + "\n" + "\n" + " \n" + "\n"; + + const std::string_view sheet2_rels = + "\n" + "\n" + " \n" + "\n"; + + const std::string_view pivot_cache_def = + "\n" + "\n" + // Excel always names the range the records came from. The bare + // `` this once carried is a form no + // Excel-authored package uses, and one Excel refuses to open. + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"; + + const std::string_view pivot_cache_def_rels = + "\n" + "\n" + " \n" + "\n"; + + const std::string_view pivot_cache_records = + "\n" + "\n" + " \n" + " \n" + " \n" + "\n"; + + const std::string_view pivot_table_def = + "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"; + + return BuildZip({ + {"[Content_Types].xml", content_types}, + {"_rels/.rels", package_rels}, + {"xl/workbook.xml", workbook_xml}, + {"xl/_rels/workbook.xml.rels", workbook_rels}, + {"xl/worksheets/sheet1.xml", sheet_xml}, + {"xl/worksheets/sheet2.xml", sheet_xml}, + {"xl/worksheets/_rels/sheet2.xml.rels", sheet2_rels}, + {"xl/pivotCache/pivotCacheDefinition1.xml", pivot_cache_def}, + {"xl/pivotCache/_rels/pivotCacheDefinition1.xml.rels", pivot_cache_def_rels}, + {"xl/pivotCache/pivotCacheRecords1.xml", pivot_cache_records}, + {"xl/pivotTables/pivotTable1.xml", pivot_table_def}, + }); +} + +const fm_pivot_cell_t* FindCell(const std::vector& cells, std::uint32_t row, std::uint32_t col) { + for (const fm_pivot_cell_t& cell : cells) { + if (cell.row == row && cell.col == col) { + return &cell; + } + } + return nullptr; +} + +} // namespace + +TEST(FormulonCApiPivot, CountAndLayoutLoadedPivot) { + const std::vector bytes = BuildPivotWorkbookBytes(); + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_load(bytes.data(), bytes.size(), &wb.handle), 0) << fm_last_error_message(); + ASSERT_NE(wb.handle, nullptr); + + std::size_t count = 99; + ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 0, &count), 0); + EXPECT_EQ(count, 0U); + ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 1, &count), 0); + EXPECT_EQ(count, 1U); + + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 1, 0, &projected.handle), 0) << fm_last_error_message(); + ASSERT_NE(projected.handle, nullptr); + + std::uint32_t top = 0; + std::uint32_t left = 0; + std::uint32_t rows = 0; + std::uint32_t cols = 0; + ASSERT_EQ(fm_pivot_cells_bounds(projected.handle, &top, &left, &rows, &cols), 0); + EXPECT_EQ(top, 0U); + EXPECT_EQ(left, 3U); + EXPECT_EQ(rows, 4U); + EXPECT_EQ(cols, 2U); + + std::vector cells; + const std::size_t cell_count = fm_pivot_cells_count(projected.handle); + ASSERT_GT(cell_count, 0U); + cells.resize(cell_count); + for (std::size_t i = 0; i < cell_count; ++i) { + ASSERT_EQ(fm_pivot_cells_at(projected.handle, i, &cells[i]), 0); + } + + const fm_pivot_cell_t* row_labels = FindCell(cells, 0, 3); + ASSERT_NE(row_labels, nullptr); + EXPECT_EQ(row_labels->kind, FM_PIVOT_CELL_HEADER); + EXPECT_EQ(row_labels->value.kind, FM_VAL_TEXT); + ASSERT_NE(row_labels->value.u.text, nullptr); + EXPECT_STREQ(row_labels->value.u.text, "行ラベル"); + + const fm_pivot_cell_t* north_label = FindCell(cells, 1, 3); + ASSERT_NE(north_label, nullptr); + EXPECT_EQ(north_label->kind, FM_PIVOT_CELL_ROW_LABEL); + EXPECT_EQ(north_label->value.kind, FM_VAL_TEXT); + EXPECT_STREQ(north_label->value.u.text, "North"); + + const fm_pivot_cell_t* north_sum = FindCell(cells, 1, 4); + ASSERT_NE(north_sum, nullptr); + EXPECT_EQ(north_sum->kind, FM_PIVOT_CELL_DATA); + EXPECT_EQ(north_sum->value.kind, FM_VAL_NUMBER); + EXPECT_DOUBLE_EQ(north_sum->value.u.number, 400.0); + ASSERT_NE(north_sum->field_name, nullptr); + EXPECT_STREQ(north_sum->field_name, "Sum of Amount"); + + const fm_pivot_cell_t* grand_total = FindCell(cells, 3, 4); + ASSERT_NE(grand_total, nullptr); + EXPECT_EQ(grand_total->kind, FM_PIVOT_CELL_GRAND_TOTAL); + EXPECT_EQ(grand_total->value.kind, FM_VAL_NUMBER); + EXPECT_DOUBLE_EQ(grand_total->value.u.number, 600.0); +} + +TEST(FormulonCApiPivot, InvalidPivotArgsReturnErrors) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + std::size_t count = 0; + EXPECT_EQ(fm_workbook_pivot_count(nullptr, 0, &count), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); + EXPECT_EQ(fm_workbook_pivot_count(wb.handle, 99, &count), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + + fm_pivot_cells_t* cells = nullptr; + EXPECT_EQ(fm_workbook_pivot_layout(wb.handle, 0, 0, &cells), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(cells, nullptr); + + fm_pivot_cell_t cell{}; + EXPECT_EQ(fm_pivot_cells_at(nullptr, 0, &cell), + static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); +} + +TEST(FormulonCApiPivot, AddingAFieldAfterLoadReprojectsTheLocationRef) { + // The loaded package declares `ref="D1:E5"`, which described the report + // as Excel left it. Adding a column field widens the grid, so re-emitting + // the authored span would now under-size it -- the same crash, reached by + // loading rather than by building. + WorkbookGuard wb; + const std::vector package = BuildPivotWorkbookBytes(); + ASSERT_EQ(fm_workbook_load(package.data(), package.size(), &wb.handle), 0) << fm_last_error_message(); + + fm_pivot_field_spec_t region_spec{}; + region_spec.source_name = "Region"; + region_spec.custom_name = ""; + region_spec.axis = FM_PIVOT_AXIS_COL; + region_spec.subtotal_top = 0; + region_spec.number_format = ""; + std::size_t added = 99; + ASSERT_EQ(fm_workbook_pivot_field_add(wb.handle, 1, 0, ®ion_spec, &added), 0) << fm_last_error_message(); + const std::uint32_t col_order[] = {static_cast(added)}; + ASSERT_EQ(fm_workbook_pivot_set_col_field_order(wb.handle, 1, 0, col_order, 1), 0) << fm_last_error_message(); + + PivotCellsGuard projected; + ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 1, 0, &projected.handle), 0) << fm_last_error_message(); + std::uint32_t top = 0; + std::uint32_t left = 0; + std::uint32_t rows = 0; + std::uint32_t cols = 0; + ASSERT_EQ(fm_pivot_cells_bounds(projected.handle, &top, &left, &rows, &cols), 0); + ASSERT_GT(cols, 2U) << "the added column field must widen the report"; + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << fm_last_error_message(); + const std::vector written(saved.data, saved.data + saved.len); + const std::string pivot_xml = ExtractZipEntry(written, "xl/pivotTables/pivotTable1.xml"); + ASSERT_FALSE(pivot_xml.empty()); + EXPECT_EQ(pivot_xml.find(" -Enum RawEnumValue(std::uint32_t raw) { - static_assert(sizeof(Enum) == sizeof(std::uint32_t), "C ABI enums are int-sized"); - Enum value{}; - std::memcpy(&value, &raw, sizeof(value)); - return value; -} - -std::vector BuildZip(const std::vector& parts) { - mz_zip_archive writer{}; - EXPECT_NE(mz_zip_writer_init_heap(&writer, 0, 4096), MZ_FALSE); - for (const auto& p : parts) { - EXPECT_NE(mz_zip_writer_add_mem(&writer, p.path, p.body.data(), p.body.size(), - static_cast(MZ_DEFAULT_COMPRESSION)), - MZ_FALSE) - << "miniz add failed for " << p.path; - } - void* archive_ptr = nullptr; - std::size_t archive_size = 0; - EXPECT_NE(mz_zip_writer_finalize_heap_archive(&writer, &archive_ptr, &archive_size), MZ_FALSE); - EXPECT_NE(mz_zip_writer_end(&writer), MZ_FALSE); - std::vector out(static_cast(archive_ptr), - static_cast(archive_ptr) + archive_size); - mz_free(archive_ptr); - return out; -} - -std::string ExtractZipEntry(const std::vector& archive_bytes, std::string_view path) { - mz_zip_archive reader{}; - if (mz_zip_reader_init_mem(&reader, archive_bytes.data(), archive_bytes.size(), 0) == MZ_FALSE) { - ADD_FAILURE() << "mz_zip_reader_init_mem failed"; - return {}; - } - const int index = mz_zip_reader_locate_file(&reader, std::string(path).c_str(), nullptr, 0); - if (index < 0) { - ADD_FAILURE() << "entry not found: " << path; - mz_zip_reader_end(&reader); - return {}; - } - std::size_t extracted_size = 0; - void* extracted = mz_zip_reader_extract_to_heap(&reader, static_cast(index), &extracted_size, 0); - if (extracted == nullptr) { - ADD_FAILURE() << "extract_to_heap failed for: " << path; - mz_zip_reader_end(&reader); - return {}; - } - std::string body(static_cast(extracted), extracted_size); - mz_free(extracted); - mz_zip_reader_end(&reader); - return body; -} - -std::vector BuildPivotWorkbookBytes() { - const std::string_view content_types = - "\n" - "\n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - "\n"; - - const std::string_view package_rels = - "\n" - "\n" - " \n" - "\n"; - - const std::string_view workbook_xml = - "\n" - "\n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - "\n"; - - const std::string_view workbook_rels = - "\n" - "\n" - " \n" - " \n" - " \n" - "\n"; - - const std::string_view sheet_xml = - "\n" - "\n" - " \n" - "\n"; - - const std::string_view sheet2_rels = - "\n" - "\n" - " \n" - "\n"; - - const std::string_view pivot_cache_def = - "\n" - "\n" - // Excel always names the range the records came from. The bare - // `` this once carried is a form no - // Excel-authored package uses, and one Excel refuses to open. - " \n" - " \n" - " \n" - " \n" - " \n" - "\n"; - - const std::string_view pivot_cache_def_rels = - "\n" - "\n" - " \n" - "\n"; - - const std::string_view pivot_cache_records = - "\n" - "\n" - " \n" - " \n" - " \n" - "\n"; - - const std::string_view pivot_table_def = - "\n" - "\n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - "\n"; - - return BuildZip({ - {"[Content_Types].xml", content_types}, - {"_rels/.rels", package_rels}, - {"xl/workbook.xml", workbook_xml}, - {"xl/_rels/workbook.xml.rels", workbook_rels}, - {"xl/worksheets/sheet1.xml", sheet_xml}, - {"xl/worksheets/sheet2.xml", sheet_xml}, - {"xl/worksheets/_rels/sheet2.xml.rels", sheet2_rels}, - {"xl/pivotCache/pivotCacheDefinition1.xml", pivot_cache_def}, - {"xl/pivotCache/_rels/pivotCacheDefinition1.xml.rels", pivot_cache_def_rels}, - {"xl/pivotCache/pivotCacheRecords1.xml", pivot_cache_records}, - {"xl/pivotTables/pivotTable1.xml", pivot_table_def}, - }); -} - -const fm_pivot_cell_t* FindCell(const std::vector& cells, std::uint32_t row, std::uint32_t col) { - for (const fm_pivot_cell_t& cell : cells) { - if (cell.row == row && cell.col == col) { - return &cell; - } - } - return nullptr; -} - -} // namespace - -TEST(FormulonCApiPivot, CountAndLayoutLoadedPivot) { - const std::vector bytes = BuildPivotWorkbookBytes(); - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_load(bytes.data(), bytes.size(), &wb.handle), 0) << fm_last_error_message(); - ASSERT_NE(wb.handle, nullptr); - - std::size_t count = 99; - ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 0, &count), 0); - EXPECT_EQ(count, 0U); - ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 1, &count), 0); - EXPECT_EQ(count, 1U); - - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 1, 0, &projected.handle), 0) << fm_last_error_message(); - ASSERT_NE(projected.handle, nullptr); - - std::uint32_t top = 0; - std::uint32_t left = 0; - std::uint32_t rows = 0; - std::uint32_t cols = 0; - ASSERT_EQ(fm_pivot_cells_bounds(projected.handle, &top, &left, &rows, &cols), 0); - EXPECT_EQ(top, 0U); - EXPECT_EQ(left, 3U); - EXPECT_EQ(rows, 4U); - EXPECT_EQ(cols, 2U); - - std::vector cells; - const std::size_t cell_count = fm_pivot_cells_count(projected.handle); - ASSERT_GT(cell_count, 0U); - cells.resize(cell_count); - for (std::size_t i = 0; i < cell_count; ++i) { - ASSERT_EQ(fm_pivot_cells_at(projected.handle, i, &cells[i]), 0); - } - - const fm_pivot_cell_t* row_labels = FindCell(cells, 0, 3); - ASSERT_NE(row_labels, nullptr); - EXPECT_EQ(row_labels->kind, FM_PIVOT_CELL_HEADER); - EXPECT_EQ(row_labels->value.kind, FM_VAL_TEXT); - ASSERT_NE(row_labels->value.u.text, nullptr); - EXPECT_STREQ(row_labels->value.u.text, "行ラベル"); - - const fm_pivot_cell_t* north_label = FindCell(cells, 1, 3); - ASSERT_NE(north_label, nullptr); - EXPECT_EQ(north_label->kind, FM_PIVOT_CELL_ROW_LABEL); - EXPECT_EQ(north_label->value.kind, FM_VAL_TEXT); - EXPECT_STREQ(north_label->value.u.text, "North"); - - const fm_pivot_cell_t* north_sum = FindCell(cells, 1, 4); - ASSERT_NE(north_sum, nullptr); - EXPECT_EQ(north_sum->kind, FM_PIVOT_CELL_DATA); - EXPECT_EQ(north_sum->value.kind, FM_VAL_NUMBER); - EXPECT_DOUBLE_EQ(north_sum->value.u.number, 400.0); - ASSERT_NE(north_sum->field_name, nullptr); - EXPECT_STREQ(north_sum->field_name, "Sum of Amount"); - - const fm_pivot_cell_t* grand_total = FindCell(cells, 3, 4); - ASSERT_NE(grand_total, nullptr); - EXPECT_EQ(grand_total->kind, FM_PIVOT_CELL_GRAND_TOTAL); - EXPECT_EQ(grand_total->value.kind, FM_VAL_NUMBER); - EXPECT_DOUBLE_EQ(grand_total->value.u.number, 600.0); -} - -TEST(FormulonCApiPivot, InvalidPivotArgsReturnErrors) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - - std::size_t count = 0; - EXPECT_EQ(fm_workbook_pivot_count(nullptr, 0, &count), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - EXPECT_EQ(fm_workbook_pivot_count(wb.handle, 99, &count), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - fm_pivot_cells_t* cells = nullptr; - EXPECT_EQ(fm_workbook_pivot_layout(wb.handle, 0, 0, &cells), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(cells, nullptr); - - fm_pivot_cell_t cell{}; - EXPECT_EQ(fm_pivot_cells_at(nullptr, 0, &cell), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); -} - -// ---------------------------------------------------------------------------- -// Pivot mutation surface -// ---------------------------------------------------------------------------- - -namespace { - -// Builds a pivot cache + table from scratch via the C ABI: -// * 2 fields: "Region" (text) and "Amount" (numeric). -// * 4 records: (North, 100), (South, 200), (North, 300), (South, 400). -// * Pivot table on sheet 0, name "PT", anchor (0, 3), with Region as the -// row field and Amount aggregated as Sum. -fm_status_t BuildScratchPivot(fm_workbook_t* wb, std::uint32_t* out_cache_id, std::size_t* out_pivot_index) { - std::uint32_t cache_id = 0; - fm_status_t st = fm_workbook_pivot_cache_create(wb, 0, &cache_id); - if (st != 0) { - return st; - } - // Excel refuses a package whose cache declares no source, so a cache - // that is going to be saved needs one. The range only has to describe - // where the records came from; it is not required to hold data, since - // the records themselves carry the values. - st = fm_workbook_pivot_cache_set_worksheet_source(wb, cache_id, /*present=*/1, "A1:B5", "Sheet1", nullptr); - if (st != 0) { - return st; - } - std::size_t region_idx = 99; - st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Region", ®ion_idx); - if (st != 0) { - return st; - } - std::size_t amount_idx = 99; - st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Amount", &amount_idx); - if (st != 0) { - return st; - } - // Shared items for "Region": index 0 = "North", index 1 = "South". - st = fm_workbook_pivot_cache_field_add_shared_item_text(wb, cache_id, region_idx, "North"); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_cache_field_add_shared_item_text(wb, cache_id, region_idx, "South"); - if (st != 0) { - return st; - } - // 4 records. - const struct { - double region_index; - double amount; - } rows[] = {{0.0, 100.0}, {1.0, 200.0}, {0.0, 300.0}, {1.0, 400.0}}; - for (const auto& row : rows) { - std::size_t rec_idx = 99; - st = fm_workbook_pivot_cache_record_add(wb, cache_id, &rec_idx); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, region_idx, row.region_index); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, amount_idx, row.amount); - if (st != 0) { - return st; - } - } - // Pivot table. - std::size_t pivot_idx = 99; - st = fm_workbook_pivot_create(wb, 0, "PT", cache_id, /*anchor_row=*/0U, /*anchor_col=*/3U, &pivot_idx); - if (st != 0) { - return st; - } - // Region (row) + items 0 and 1, plus the OOXML "default" subtotal item. - fm_pivot_field_spec_t region_spec{}; - region_spec.source_name = "Region"; - region_spec.custom_name = ""; - region_spec.axis = FM_PIVOT_AXIS_ROW; - region_spec.subtotal_top = 0; - region_spec.number_format = ""; - std::size_t region_field = 99; - st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, ®ion_spec, ®ion_field); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_field_add_item(wb, 0, pivot_idx, region_field, "North", 1); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_field_add_item(wb, 0, pivot_idx, region_field, "South", 1); - if (st != 0) { - return st; - } - // Amount (value-axis source field). - fm_pivot_field_spec_t amount_spec{}; - amount_spec.source_name = "Amount"; - amount_spec.custom_name = ""; - amount_spec.axis = FM_PIVOT_AXIS_VALUE; - amount_spec.subtotal_top = 0; - amount_spec.number_format = ""; - std::size_t amount_field = 99; - st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, &amount_spec, &amount_field); - if (st != 0) { - return st; - } - // Row-field order. - const std::uint32_t row_order[] = {static_cast(region_field)}; - st = fm_workbook_pivot_set_row_field_order(wb, 0, pivot_idx, row_order, 1U); - if (st != 0) { - return st; - } - // Data field: Sum of Amount. - fm_pivot_data_field_spec_t df_spec{}; - df_spec.name = "Sum of Amount"; - df_spec.field_index = static_cast(amount_field); - df_spec.aggregation = FM_PIVOT_AGG_SUM; - df_spec.number_format = ""; - df_spec.show_as = FM_PIVOT_SHOW_AS_NORMAL; - df_spec.show_as_base_field = -1; - df_spec.show_as_base_item = -1; - std::size_t df_idx = 99; - st = fm_workbook_pivot_data_field_add(wb, 0, pivot_idx, &df_spec, &df_idx); - if (st != 0) { - return st; - } - if (out_cache_id != nullptr) { - *out_cache_id = cache_id; - } - if (out_pivot_index != nullptr) { - *out_pivot_index = pivot_idx; - } - return 0; -} - -std::vector CollectCells(fm_pivot_cells_t* handle) { - const std::size_t n = fm_pivot_cells_count(handle); - std::vector out(n); - for (std::size_t i = 0; i < n; ++i) { - EXPECT_EQ(fm_pivot_cells_at(handle, i, &out[i]), 0); - } - return out; -} - -} // namespace - -TEST(FormulonCApiPivot, CreatePivotFromScratch) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - std::size_t cache_count = 0; - ASSERT_EQ(fm_workbook_pivot_cache_count(wb.handle, &cache_count), 0); - EXPECT_EQ(cache_count, 1U); - - std::size_t pivot_count = 0; - ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 0, &pivot_count), 0); - EXPECT_EQ(pivot_count, 1U); - - std::size_t records = 0; - ASSERT_EQ(fm_workbook_pivot_cache_record_count(wb.handle, cache_id, &records), 0); - EXPECT_EQ(records, 4U); - - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - - const std::vector cells = CollectCells(projected.handle); - ASSERT_FALSE(cells.empty()); - - // North (regions 0, 2) sum = 400; South (regions 1, 3) sum = 600. - // Grand total = 1000. - bool saw_north = false; - bool saw_south = false; - bool saw_grand = false; - for (const fm_pivot_cell_t& c : cells) { - if (c.kind == FM_PIVOT_CELL_DATA && c.value.kind == FM_VAL_NUMBER) { - if (c.value.u.number == 400.0) { - saw_north = true; - } else if (c.value.u.number == 600.0) { - saw_south = true; - } - } - if (c.kind == FM_PIVOT_CELL_GRAND_TOTAL && c.value.kind == FM_VAL_NUMBER && c.value.u.number == 1000.0) { - saw_grand = true; - } - } - EXPECT_TRUE(saw_north); - EXPECT_TRUE(saw_south); - EXPECT_TRUE(saw_grand); -} - -// Builds, entirely through the C ABI, the pivot -// tests/fixtures/excel/win/pivot_week_1900.xlsx pins: a "Date"/"Amount" -// source (71 records, Excel serials 0..70 under the 1900 date system, -// amount 1 each), Date on the row axis grouped `FM_PIVOT_DATE_DAYS` at -// a 7-day interval, Sum(Amount) as the one data field. `start_serial` -// mirrors the fixture's Start variants (`-1.0` = auto). -fm_status_t BuildDaysGroupedPivot(fm_workbook_t* wb, double start_serial, std::size_t* out_pivot_index) { - std::uint32_t cache_id = 0; - fm_status_t st = fm_workbook_pivot_cache_create(wb, 0, &cache_id); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_cache_set_worksheet_source(wb, cache_id, /*present=*/1, "A1:B71", "Sheet1", nullptr); - if (st != 0) { - return st; - } - std::size_t date_idx = 99; - st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Date", &date_idx); - if (st != 0) { - return st; - } - std::size_t amount_idx = 99; - st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Amount", &amount_idx); - if (st != 0) { - return st; - } - for (double serial = 0.0; serial <= 70.0; serial += 1.0) { - std::size_t rec_idx = 99; - st = fm_workbook_pivot_cache_record_add(wb, cache_id, &rec_idx); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, date_idx, serial); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, amount_idx, 1.0); - if (st != 0) { - return st; - } - } - std::size_t pivot_idx = 99; - st = fm_workbook_pivot_create(wb, 0, "PT", cache_id, /*anchor_row=*/0U, /*anchor_col=*/3U, &pivot_idx); - if (st != 0) { - return st; - } - fm_pivot_field_spec_t date_spec{}; - date_spec.source_name = "Date"; - date_spec.custom_name = ""; - date_spec.axis = FM_PIVOT_AXIS_ROW; - date_spec.subtotal_top = 0; - date_spec.number_format = ""; - std::size_t date_field = 99; - st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, &date_spec, &date_field); - if (st != 0) { - return st; - } - st = fm_workbook_pivot_field_set_date_group(wb, 0, pivot_idx, date_field, FM_PIVOT_DATE_DAYS, - FM_PIVOT_CALENDAR_GREGORIAN, -1, -1, /*interval_days=*/7, start_serial, - /*end_serial_or_neg1=*/-1.0); - if (st != 0) { - return st; - } - fm_pivot_field_spec_t amount_spec{}; - amount_spec.source_name = "Amount"; - amount_spec.custom_name = ""; - amount_spec.axis = FM_PIVOT_AXIS_VALUE; - amount_spec.subtotal_top = 0; - amount_spec.number_format = ""; - std::size_t amount_field = 99; - st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, &amount_spec, &amount_field); - if (st != 0) { - return st; - } - const std::uint32_t row_order[] = {static_cast(date_field)}; - st = fm_workbook_pivot_set_row_field_order(wb, 0, pivot_idx, row_order, 1U); - if (st != 0) { - return st; - } - fm_pivot_data_field_spec_t df_spec{}; - df_spec.name = "Sum of Amount"; - df_spec.field_index = static_cast(amount_field); - df_spec.aggregation = FM_PIVOT_AGG_SUM; - df_spec.number_format = ""; - df_spec.show_as = FM_PIVOT_SHOW_AS_NORMAL; - df_spec.show_as_base_field = -1; - df_spec.show_as_base_item = -1; - std::size_t df_idx = 99; - st = fm_workbook_pivot_data_field_add(wb, 0, pivot_idx, &df_spec, &df_idx); - if (st != 0) { - return st; - } - if (out_pivot_index != nullptr) { - *out_pivot_index = pivot_idx; - } - return 0; -} - -// Collects (row label, Sum(Amount)) pairs from a one-row-field, -// one-data-field pivot's projected grid, in row order. -std::vector> CollectDaysGroupRows(fm_pivot_cells_t* handle) { - std::vector cells = CollectCells(handle); - std::vector> labels_by_row; - std::vector sums_by_row; - for (const fm_pivot_cell_t& c : cells) { - if (c.kind == FM_PIVOT_CELL_ROW_LABEL && c.value.kind == FM_VAL_TEXT) { - labels_by_row.emplace_back(std::string(c.value.u.text), 0.0); - } else if (c.kind == FM_PIVOT_CELL_DATA && c.value.kind == FM_VAL_NUMBER) { - sums_by_row.push_back(c.value.u.number); - } - } - EXPECT_EQ(labels_by_row.size(), sums_by_row.size()); - for (std::size_t i = 0; i < labels_by_row.size() && i < sums_by_row.size(); ++i) { - labels_by_row[i].second = sums_by_row[i]; - } - return labels_by_row; -} - -TEST(FormulonCApiPivot, DaysGroupMatchesPivotWeek1900FixtureThroughTheAbi) { - // Ground truth transcribed from - // tests/fixtures/excel/win/pivot_week_1900/results.json (Windows Excel - // 365 ja-JP, build 16.0.20228): four Start variants of the same - // By-7-days grouping, read from the real Excel-rendered pivot. `auto` - // (Start=True) and `start0` (Start=0) coincide because 0 is already - // the data minimum. - const double kAuto = -1.0; - const std::vector> kAutoAndStart0Rows = { - {"1900/1/0 - 1900/1/6", 7.0}, {"1900/1/7 - 1900/1/13", 7.0}, {"1900/1/14 - 1900/1/20", 7.0}, - {"1900/1/21 - 1900/1/27", 7.0}, {"1900/1/28 - 1900/2/3", 7.0}, {"1900/2/4 - 1900/2/10", 7.0}, - {"1900/2/11 - 1900/2/17", 7.0}, {"1900/2/18 - 1900/2/24", 7.0}, {"1900/2/25 - 1900/3/2", 7.0}, - {"1900/3/3 - 1900/3/9", 7.0}, {"1900/3/10 - 1900/3/11", 1.0}, - }; - const std::vector> kStart1Rows = { - {"<1900/1/1", 1.0}, - {"1900/1/1 - 1900/1/7", 7.0}, - {"1900/1/8 - 1900/1/14", 7.0}, - {"1900/1/15 - 1900/1/21", 7.0}, - {"1900/1/22 - 1900/1/28", 7.0}, - {"1900/1/29 - 1900/2/4", 7.0}, - {"1900/2/5 - 1900/2/11", 7.0}, - {"1900/2/12 - 1900/2/18", 7.0}, - {"1900/2/19 - 1900/2/25", 7.0}, - {"1900/2/26 - 1900/3/3", 7.0}, - {"1900/3/4 - 1900/3/10", 7.0}, - }; - const std::vector> kStart61Rows = { - {"<1900/3/1", 61.0}, - {"1900/3/1 - 1900/3/7", 7.0}, - {"1900/3/8 - 1900/3/11", 3.0}, - }; - - const struct { - const char* name; - double start_serial; - const std::vector>* expected; - } variants[] = { - {"auto", kAuto, &kAutoAndStart0Rows}, - {"start0", 0.0, &kAutoAndStart0Rows}, - {"start1", 1.0, &kStart1Rows}, - {"start61", 61.0, &kStart61Rows}, - }; - - for (const auto& variant : variants) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0) << variant.name; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildDaysGroupedPivot(wb.handle, variant.start_serial, &pivot_idx), 0) - << variant.name << ": " << fm_last_error_message(); - - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) - << variant.name << ": " << fm_last_error_message(); - - const std::vector> rows = CollectDaysGroupRows(projected.handle); - ASSERT_EQ(rows.size(), variant.expected->size()) << variant.name; - for (std::size_t i = 0; i < rows.size(); ++i) { - EXPECT_EQ(rows[i].first, (*variant.expected)[i].first) << variant.name << " row " << i; - EXPECT_DOUBLE_EQ(rows[i].second, (*variant.expected)[i].second) << variant.name << " row " << i; - } - } -} - -TEST(FormulonCApiPivot, SaveRejectsAPivotCacheWithNoWorksheetSource) { - // The failure this replaces was silent: the package saved, validated - // against the schema, and round-tripped through our own reader, and only - // Excel -- offering to repair it -- ever said otherwise. A host has no - // way to discover that from the API, so the save has to say it. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - ASSERT_EQ(fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, /*present=*/0, nullptr, nullptr, nullptr), - 0) - << fm_last_error_message(); - - BufferGuard saved; - EXPECT_NE(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << "a cache with no source must not save silently"; - EXPECT_NE(std::string(fm_last_error_message()).find("worksheet source"), std::string::npos) - << "message must name what is missing: " << fm_last_error_message(); - - // Restoring the source makes the same workbook savable again, so the - // check is about the cache's state and not about some other damage. - ASSERT_EQ( - fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, /*present=*/1, "A1:B5", "Sheet1", nullptr), 0); - BufferGuard retry; - EXPECT_EQ(fm_workbook_save(wb.handle, &retry.data, &retry.len), 0) << fm_last_error_message(); -} - -TEST(FormulonCApiPivot, SavedScratchPivotEmitsLocationRequiredDefaults) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - ASSERT_EQ(fm_workbook_pivot_set_anchor(wb.handle, 0, pivot_idx, 0U, 3U, 5U, 2U), 0) << fm_last_error_message(); - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << fm_last_error_message(); - const std::vector package(saved.data, saved.data + saved.len); - const std::string pivot_xml = ExtractZipEntry(package, "xl/pivotTables/pivotTable1.xml"); - ASSERT_FALSE(pivot_xml.empty()); - EXPECT_NE(pivot_xml.find(""), - std::string::npos) - << "xml=" << pivot_xml; -} - -TEST(FormulonCApiPivot, SavedPivotLocationRefCoversTheProjectedGrid) { - // `fm_workbook_pivot_create` installs a 1x1 placeholder span and nothing - // revises it as fields are added, so a pivot built purely through the C - // surface used to save a `ref` describing a single cell. Excel opens such - // a file cleanly and recognises the pivot, then terminates the moment the - // report is refreshed -- the defect is invisible to a round trip and has - // to be pinned on the emitted bytes. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - // Deliberately no `fm_workbook_pivot_set_anchor`: this is the path a host - // takes when it never reasons about the report's extent at all. - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - std::uint32_t top = 0; - std::uint32_t left = 0; - std::uint32_t rows = 0; - std::uint32_t cols = 0; - ASSERT_EQ(fm_pivot_cells_bounds(projected.handle, &top, &left, &rows, &cols), 0); - ASSERT_GT(rows, 1U) << "projection must be larger than the placeholder for this test to mean anything"; - ASSERT_GT(cols, 1U); - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << fm_last_error_message(); - const std::vector package(saved.data, saved.data + saved.len); - const std::string pivot_xml = ExtractZipEntry(package, "xl/pivotTables/pivotTable1.xml"); - ASSERT_FALSE(pivot_xml.empty()); - - // Anchored at D1, projected 4 rows x 2 cols -> D1:E4. - EXPECT_NE(pivot_xml.find(" package = BuildPivotWorkbookBytes(); - ASSERT_EQ(fm_workbook_load(package.data(), package.size(), &wb.handle), 0) << fm_last_error_message(); - - fm_pivot_field_spec_t region_spec{}; - region_spec.source_name = "Region"; - region_spec.custom_name = ""; - region_spec.axis = FM_PIVOT_AXIS_COL; - region_spec.subtotal_top = 0; - region_spec.number_format = ""; - std::size_t added = 99; - ASSERT_EQ(fm_workbook_pivot_field_add(wb.handle, 1, 0, ®ion_spec, &added), 0) << fm_last_error_message(); - const std::uint32_t col_order[] = {static_cast(added)}; - ASSERT_EQ(fm_workbook_pivot_set_col_field_order(wb.handle, 1, 0, col_order, 1), 0) << fm_last_error_message(); - - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 1, 0, &projected.handle), 0) << fm_last_error_message(); - std::uint32_t top = 0; - std::uint32_t left = 0; - std::uint32_t rows = 0; - std::uint32_t cols = 0; - ASSERT_EQ(fm_pivot_cells_bounds(projected.handle, &top, &left, &rows, &cols), 0); - ASSERT_GT(cols, 2U) << "the added column field must widen the report"; - - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << fm_last_error_message(); - const std::vector written(saved.data, saved.data + saved.len); - const std::string pivot_xml = ExtractZipEntry(written, "xl/pivotTables/pivotTable1.xml"); - ASSERT_FALSE(pivot_xml.empty()); - EXPECT_EQ(pivot_xml.find("::quiet_NaN(), - std::numeric_limits::infinity(), - -std::numeric_limits::infinity(), - -1.0, - 1e30, - 4.3e9, // Past the wasm32 `size_t` range. - 2.0, // One past the last shared item. - }; - for (const double bad : bad_indices) { - ASSERT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, 0, bad), 0) << fm_last_error_message(); - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - EXPECT_FALSE(CollectCells(projected.handle).empty()); - } - - // A valid index still resolves after all of that. - ASSERT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, 0, 0.0), 0) << fm_last_error_message(); - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - bool saw_north_total = false; - for (const fm_pivot_cell_t& c : CollectCells(projected.handle)) { - if (c.kind == FM_PIVOT_CELL_DATA && c.value.kind == FM_VAL_NUMBER && c.value.u.number == 400.0) { - saw_north_total = true; - } - } - EXPECT_TRUE(saw_north_total); -} - -TEST(FormulonCApiPivot, PivotCacheWorksheetSourceRoundTripsThroughApi) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - int32_t present = -1; - const char* ref = nullptr; - const char* sheet = nullptr; - const char* name = nullptr; - // `BuildScratchPivot` installs a source because a cache without one - // cannot be saved. Clear it here so the absent state this test is about - // is established by the test rather than inherited from the helper. - ASSERT_EQ(fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, 0, nullptr, nullptr, nullptr), 0); - ASSERT_EQ(fm_workbook_pivot_cache_get_worksheet_source(wb.handle, cache_id, &present, &ref, &sheet, &name), 0); - EXPECT_EQ(present, 0); - EXPECT_STREQ(ref, ""); - EXPECT_STREQ(sheet, ""); - EXPECT_STREQ(name, ""); - - ASSERT_EQ(fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, 1, "$A$1:$C$5", "Data", nullptr), 0) - << fm_last_error_message(); - ASSERT_EQ(fm_workbook_pivot_cache_get_worksheet_source(wb.handle, cache_id, &present, &ref, &sheet, &name), 0); - EXPECT_EQ(present, 1); - EXPECT_STREQ(ref, "$A$1:$C$5"); - EXPECT_STREQ(sheet, "Data"); - EXPECT_STREQ(name, ""); - - ASSERT_EQ(fm_workbook_pivot_cache_set_worksheet_source(wb.handle, cache_id, 0, "ignored", "ignored", "ignored"), 0); - ASSERT_EQ(fm_workbook_pivot_cache_get_worksheet_source(wb.handle, cache_id, &present, &ref, &sheet, &name), 0); - EXPECT_EQ(present, 0); - EXPECT_STREQ(ref, ""); - EXPECT_STREQ(sheet, ""); - EXPECT_STREQ(name, ""); -} - -TEST(FormulonCApiPivot, PivotReportLayoutRoundTripsThroughApi) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - fm_pivot_layout_t layout = FM_PIVOT_LAYOUT_TABULAR; - ASSERT_EQ(fm_workbook_pivot_get_layout(wb.handle, 0, pivot_idx, &layout), 0); - EXPECT_EQ(layout, FM_PIVOT_LAYOUT_COMPACT); - - ASSERT_EQ(fm_workbook_pivot_set_layout(wb.handle, 0, pivot_idx, FM_PIVOT_LAYOUT_TABULAR), 0); - ASSERT_EQ(fm_workbook_pivot_get_layout(wb.handle, 0, pivot_idx, &layout), 0); - EXPECT_EQ(layout, FM_PIVOT_LAYOUT_TABULAR); - - ASSERT_EQ(fm_workbook_pivot_set_layout(wb.handle, 0, pivot_idx, FM_PIVOT_LAYOUT_OUTLINE), 0); - ASSERT_EQ(fm_workbook_pivot_get_layout(wb.handle, 0, pivot_idx, &layout), 0); - EXPECT_EQ(layout, FM_PIVOT_LAYOUT_OUTLINE); - - // The setter accepts a raw int32_t so FFI callers can probe the complete - // invalid domain without constructing an out-of-domain C enum. - const std::int32_t invalid_layouts[] = { - 99, - std::numeric_limits::min(), - std::numeric_limits::max(), - }; - for (const std::int32_t raw : invalid_layouts) { - EXPECT_EQ(fm_workbook_pivot_set_layout(wb.handle, 0, pivot_idx, raw), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)) - << raw; - } -} - -TEST(FormulonCApiPivot, ScalarEnumMutatorsRejectRawValuesWithoutMutation) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - BufferGuard before; - ASSERT_EQ(fm_workbook_save(wb.handle, &before.data, &before.len), 0) << fm_last_error_message(); - const std::vector snapshot(before.data, before.data + before.len); - const std::int32_t invalid[] = {99, std::numeric_limits::min(), - std::numeric_limits::max()}; - const fm_status_t expected = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - for (const std::int32_t raw : invalid) { - EXPECT_EQ(fm_workbook_pivot_field_set_axis(wb.handle, 0, pivot_idx, 0, raw), expected) << raw; - EXPECT_EQ(fm_workbook_pivot_field_add_subtotal_fn(wb.handle, 0, pivot_idx, 0, raw), expected) << raw; - EXPECT_EQ(fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, raw, 0, -1, -1, 1, -1.0, -1.0), - expected) - << raw; - EXPECT_EQ(fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, 3, raw, -1, -1, 1, -1.0, -1.0), - expected) - << raw; - } - - BufferGuard after; - ASSERT_EQ(fm_workbook_save(wb.handle, &after.data, &after.len), 0) << fm_last_error_message(); - EXPECT_EQ(std::vector(after.data, after.data + after.len), snapshot); -} - -TEST(FormulonCApiPivot, DateGroupRejectsZeroIntervalAndInvertedWindowWithoutMutation) { - // FM_PIVOT_DATE_DAYS-only validation: a zero interval and an explicit - // Start past an explicit End are both nonsensical groupings, so - // neither may reach the model. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - BufferGuard before; - ASSERT_EQ(fm_workbook_save(wb.handle, &before.data, &before.len), 0) << fm_last_error_message(); - const std::vector snapshot(before.data, before.data + before.len); - const fm_status_t expected = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - - // interval_days == 0. - EXPECT_EQ( - fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, FM_PIVOT_DATE_DAYS, - FM_PIVOT_CALENDAR_GREGORIAN, -1, -1, /*interval_days=*/0, -1.0, -1.0), - expected); - // start_serial_or_neg1 > end_serial_or_neg1 (both explicit). - EXPECT_EQ(fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, FM_PIVOT_DATE_DAYS, - FM_PIVOT_CALENDAR_GREGORIAN, -1, -1, /*interval_days=*/7, - /*start_serial=*/61.0, /*end_serial=*/60.0), - expected); - - BufferGuard after; - ASSERT_EQ(fm_workbook_save(wb.handle, &after.data, &after.len), 0) << fm_last_error_message(); - EXPECT_EQ(std::vector(after.data, after.data + after.len), snapshot); -} - -TEST(FormulonCApiPivot, StructEnumMutatorsRejectRawValuesWithoutMutation) { - // The struct-taking twins of the scalar mutators above must enforce the - // same domains. Without that, an out-of-domain `axis` collapses onto - // `PivotAxis::Row` and an out-of-domain `show_as` onto `Normal`, and - // the call reports success on a pivot that means something else. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - BufferGuard before; - ASSERT_EQ(fm_workbook_save(wb.handle, &before.data, &before.len), 0) << fm_last_error_message(); - const std::vector snapshot(before.data, before.data + before.len); - const fm_status_t expected = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - - fm_pivot_field_spec_t field_spec{}; - field_spec.source_name = "Region"; - field_spec.axis = RawEnumValue(99); - std::size_t field_idx = 99U; - EXPECT_EQ(fm_workbook_pivot_field_add(wb.handle, 0, pivot_idx, &field_spec, &field_idx), expected); - - fm_pivot_data_field_spec_t df_spec{}; - df_spec.name = "Total"; - df_spec.field_index = 1; - df_spec.aggregation = RawEnumValue(99); - std::size_t df_idx = 99U; - EXPECT_EQ(fm_workbook_pivot_data_field_add(wb.handle, 0, pivot_idx, &df_spec, &df_idx), expected); - EXPECT_EQ(fm_workbook_pivot_data_field_set(wb.handle, 0, pivot_idx, 0, &df_spec), expected); - - df_spec.aggregation = FM_PIVOT_AGG_SUM; - df_spec.show_as = RawEnumValue(99); - EXPECT_EQ(fm_workbook_pivot_data_field_add(wb.handle, 0, pivot_idx, &df_spec, &df_idx), expected); - EXPECT_EQ(fm_workbook_pivot_data_field_set(wb.handle, 0, pivot_idx, 0, &df_spec), expected); - - BufferGuard after; - ASSERT_EQ(fm_workbook_save(wb.handle, &after.data, &after.len), 0) << fm_last_error_message(); - EXPECT_EQ(std::vector(after.data, after.data + after.len), snapshot); -} - -TEST(FormulonCApiPivot, PivotProjectionUsesWorkbookLocaleAndReportLayout) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - ASSERT_EQ(fm_workbook_pivot_set_layout(wb.handle, 0, pivot_idx, FM_PIVOT_LAYOUT_TABULAR), 0); - - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - std::uint32_t top = 0; - std::uint32_t left = 0; - std::uint32_t rows = 0; - std::uint32_t cols = 0; - ASSERT_EQ(fm_pivot_cells_bounds(projected.handle, &top, &left, &rows, &cols), 0); - EXPECT_EQ(cols, 2U); // Tabular exposes its two row-field columns. - - bool saw_japanese_total = false; - const std::size_t cell_count = fm_pivot_cells_count(projected.handle); - for (std::size_t i = 0; i < cell_count; ++i) { - fm_pivot_cell_t cell{}; - ASSERT_EQ(fm_pivot_cells_at(projected.handle, i, &cell), 0); - if (cell.value.kind == FM_VAL_TEXT && cell.value.u.text != nullptr && - std::string_view(cell.value.u.text) == "総計") { - saw_japanese_total = true; - } - } - EXPECT_TRUE(saw_japanese_total); -} - -TEST(FormulonCApiPivot, PivotCacheErrorSettersRejectInvalidErrorCodes) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_error(wb.handle, cache_id, 0, -1), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_error(wb.handle, cache_id, 0, 999), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(fm_workbook_pivot_cache_record_set_error(wb.handle, cache_id, 0, 1, -1), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(fm_workbook_pivot_cache_record_set_error(wb.handle, cache_id, 0, 1, 999), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); -} - -TEST(FormulonCApiPivot, PivotCacheRecordSettersRejectOutOfRangeFieldIndex) { - // BuildScratchPivot declares two cache fields (Region=0, Amount=1), so - // field_idx 2 is the first out-of-range value. A large / wrapping field_idx - // must be rejected before `grow_record_cells` resizes `field_idx + 1` - // cells — otherwise SIZE_MAX wraps to 0 and the subsequent cell write lands - // out of bounds. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - const auto kInvalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - const std::size_t kFieldCount = 2; - const std::size_t kMax = static_cast(-1); - - // In-range write still succeeds (guards against an over-tight bound). - EXPECT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, kFieldCount - 1, 1.0), 0); - - // field_idx == field_count and beyond are rejected on every setter. - EXPECT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, kFieldCount, 1.0), kInvalid); - EXPECT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, 0, kMax, 1.0), kInvalid); - EXPECT_EQ(fm_workbook_pivot_cache_record_set_text(wb.handle, cache_id, 0, kMax, "x"), kInvalid); - EXPECT_EQ(fm_workbook_pivot_cache_record_set_bool(wb.handle, cache_id, 0, kMax, 1), kInvalid); - EXPECT_EQ(fm_workbook_pivot_cache_record_set_blank(wb.handle, cache_id, 0, kMax), kInvalid); - EXPECT_EQ(fm_workbook_pivot_cache_record_set_error(wb.handle, cache_id, 0, kMax, 0), kInvalid); -} - -TEST(FormulonCApiPivot, MutateExistingPivotFilter) { - // Build a scratch pivot — the OOXML reader populates only `custom_name` - // from ``, while the filter resolver matches on - // `source_name`, so a filter applied to a loaded workbook would silently - // no-op. Building from scratch lets us exercise the same surface against - // a pivot whose `source_name` is set the way the resolver expects. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - // Baseline projection contains "South" because no filter is active. - { - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0); - bool saw_south = false; - for (const fm_pivot_cell_t& c : CollectCells(projected.handle)) { - if (c.kind == FM_PIVOT_CELL_ROW_LABEL && c.value.kind == FM_VAL_TEXT && c.value.u.text != nullptr && - std::string_view(c.value.u.text) == "South") { - saw_south = true; - } - } - EXPECT_TRUE(saw_south); - } - - // Add a label filter that only retains "North". - fm_pivot_filter_spec_t spec{}; - spec.axis = FM_PIVOT_AXIS_ROW; - spec.field_name = "Region"; - spec.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; - spec.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; - spec.value_text = "N"; - spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), 0) << fm_last_error_message(); - - std::size_t filter_count = 0; - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &filter_count), 0); - EXPECT_EQ(filter_count, 1U); - - // Re-project — "South" must be dropped. - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0); - bool saw_south = false; - bool saw_north = false; - for (const fm_pivot_cell_t& c : CollectCells(projected.handle)) { - if (c.kind == FM_PIVOT_CELL_ROW_LABEL && c.value.kind == FM_VAL_TEXT && c.value.u.text != nullptr) { - if (std::string_view(c.value.u.text) == "South") { - saw_south = true; - } - if (std::string_view(c.value.u.text) == "North") { - saw_north = true; - } - } - } - EXPECT_TRUE(saw_north); - EXPECT_FALSE(saw_south); - - // Verify remove_at clears the filter. - ASSERT_EQ(fm_workbook_pivot_filter_remove_at(wb.handle, 0, pivot_idx, 0), 0); - std::size_t after_count = 99; - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &after_count), 0); - EXPECT_EQ(after_count, 0U); -} - -TEST(FormulonCApiPivot, InspectPivotFiltersByIndex) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t int_filter{}; - int_filter.axis = FM_PIVOT_AXIS_VALUE; - int_filter.field_name = "Amount"; - int_filter.type = FM_PIVOT_FILTER_VALUE_TOP_10; - int_filter.value_kind = FM_PIVOT_FILTER_VALUE_INT; - int_filter.value_int = 3; - int_filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - int_filter.data_field_index = 0; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &int_filter), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t double_filter{}; - double_filter.axis = FM_PIVOT_AXIS_VALUE; - double_filter.field_name = "Amount"; - double_filter.type = FM_PIVOT_FILTER_VALUE_GREATER_THAN; - double_filter.value_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; - double_filter.value_double = 50.5; - double_filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - double_filter.data_field_index = 0; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &double_filter), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t text_filter{}; - text_filter.axis = FM_PIVOT_AXIS_ROW; - text_filter.field_name = "Region"; - text_filter.type = FM_PIVOT_FILTER_LABEL_CONTAINS; - text_filter.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; - text_filter.value_text = "o"; - text_filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &text_filter), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t range_int_double{}; - range_int_double.axis = FM_PIVOT_AXIS_VALUE; - range_int_double.field_name = "Amount"; - range_int_double.type = FM_PIVOT_FILTER_VALUE_BETWEEN; - range_int_double.value_kind = FM_PIVOT_FILTER_VALUE_INT; - range_int_double.value_int = 100; - range_int_double.value_high_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; - range_int_double.value_high_double = 500.25; - range_int_double.data_field_index = 0; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &range_int_double), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t range_double_int{}; - range_double_int.axis = FM_PIVOT_AXIS_VALUE; - range_double_int.field_name = "Amount"; - range_double_int.type = FM_PIVOT_FILTER_VALUE_BETWEEN; - range_double_int.value_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; - range_double_int.value_double = 99.75; - range_double_int.value_high_kind = FM_PIVOT_FILTER_VALUE_INT; - range_double_int.value_high_int = 450; - range_double_int.data_field_index = 0; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &range_double_int), 0) << fm_last_error_message(); - - std::size_t filter_count = 0; - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &filter_count), 0); - ASSERT_EQ(filter_count, 5U); - - // Establish a projected result before inspection; a read must not change - // either the active-filter count or the memoised evaluation state. - PivotCellsGuard before_projection; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &before_projection.handle), 0) << fm_last_error_message(); - const std::vector before_cells = CollectCells(before_projection.handle); - - fm_pivot_filter_spec_t got{}; - ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 0, &got), 0); - EXPECT_EQ(got.axis, FM_PIVOT_AXIS_VALUE); - ASSERT_NE(got.field_name, nullptr); - EXPECT_STREQ(got.field_name, "Amount"); - EXPECT_EQ(got.type, FM_PIVOT_FILTER_VALUE_TOP_10); - EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_INT); - EXPECT_EQ(got.value_int, 3); - EXPECT_DOUBLE_EQ(got.value_double, 0.0); - EXPECT_EQ(got.value_text, nullptr); - EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_NONE); - EXPECT_EQ(got.value_high_int, 0); - EXPECT_DOUBLE_EQ(got.value_high_double, 0.0); - EXPECT_EQ(got.data_field_index, 0U); - - got = {}; - ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 1, &got), 0); - EXPECT_EQ(got.axis, FM_PIVOT_AXIS_VALUE); - ASSERT_NE(got.field_name, nullptr); - EXPECT_STREQ(got.field_name, "Amount"); - EXPECT_EQ(got.type, FM_PIVOT_FILTER_VALUE_GREATER_THAN); - EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_DOUBLE); - EXPECT_EQ(got.value_int, 0); - EXPECT_DOUBLE_EQ(got.value_double, 50.5); - EXPECT_EQ(got.value_text, nullptr); - EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_NONE); - EXPECT_EQ(got.value_high_int, 0); - EXPECT_DOUBLE_EQ(got.value_high_double, 0.0); - EXPECT_EQ(got.data_field_index, 0U); - - got = {}; - ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 2, &got), 0); - EXPECT_EQ(got.axis, FM_PIVOT_AXIS_ROW); - ASSERT_NE(got.field_name, nullptr); - EXPECT_STREQ(got.field_name, "Region"); - EXPECT_EQ(got.type, FM_PIVOT_FILTER_LABEL_CONTAINS); - EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_TEXT); - EXPECT_EQ(got.value_int, 0); - EXPECT_DOUBLE_EQ(got.value_double, 0.0); - ASSERT_NE(got.value_text, nullptr); - EXPECT_STREQ(got.value_text, "o"); - EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_NONE); - EXPECT_EQ(got.value_high_int, 0); - EXPECT_DOUBLE_EQ(got.value_high_double, 0.0); - EXPECT_EQ(got.data_field_index, 0U); - - got = {}; - ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 3, &got), 0); - EXPECT_EQ(got.axis, FM_PIVOT_AXIS_VALUE); - ASSERT_NE(got.field_name, nullptr); - EXPECT_STREQ(got.field_name, "Amount"); - EXPECT_EQ(got.type, FM_PIVOT_FILTER_VALUE_BETWEEN); - EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_INT); - EXPECT_EQ(got.value_int, 100); - EXPECT_DOUBLE_EQ(got.value_double, 0.0); - EXPECT_EQ(got.value_text, nullptr); - EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_DOUBLE); - EXPECT_EQ(got.value_high_int, 0); - EXPECT_DOUBLE_EQ(got.value_high_double, 500.25); - EXPECT_EQ(got.data_field_index, 0U); - - got = {}; - ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 4, &got), 0); - EXPECT_EQ(got.axis, FM_PIVOT_AXIS_VALUE); - ASSERT_NE(got.field_name, nullptr); - EXPECT_STREQ(got.field_name, "Amount"); - EXPECT_EQ(got.type, FM_PIVOT_FILTER_VALUE_BETWEEN); - EXPECT_EQ(got.value_kind, FM_PIVOT_FILTER_VALUE_DOUBLE); - EXPECT_EQ(got.value_int, 0); - EXPECT_DOUBLE_EQ(got.value_double, 99.75); - EXPECT_EQ(got.value_text, nullptr); - EXPECT_EQ(got.value_high_kind, FM_PIVOT_FILTER_VALUE_INT); - EXPECT_EQ(got.value_high_int, 450); - EXPECT_DOUBLE_EQ(got.value_high_double, 0.0); - EXPECT_EQ(got.data_field_index, 0U); - - std::size_t unchanged_count = 0; - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &unchanged_count), 0); - EXPECT_EQ(unchanged_count, filter_count); - PivotCellsGuard after_projection; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &after_projection.handle), 0) << fm_last_error_message(); - const std::vector after_cells = CollectCells(after_projection.handle); - ASSERT_EQ(after_cells.size(), before_cells.size()); - for (std::size_t i = 0; i < before_cells.size(); ++i) { - EXPECT_EQ(after_cells[i].row, before_cells[i].row); - EXPECT_EQ(after_cells[i].col, before_cells[i].col); - EXPECT_EQ(after_cells[i].kind, before_cells[i].kind); - EXPECT_EQ(after_cells[i].value.kind, before_cells[i].value.kind); - if (before_cells[i].value.kind == FM_VAL_NUMBER) { - EXPECT_DOUBLE_EQ(after_cells[i].value.u.number, before_cells[i].value.u.number); - } else if (before_cells[i].value.kind == FM_VAL_TEXT) { - ASSERT_NE(before_cells[i].value.u.text, nullptr); - ASSERT_NE(after_cells[i].value.u.text, nullptr); - EXPECT_STREQ(after_cells[i].value.u.text, before_cells[i].value.u.text); - } - } - - // Adding a filter invalidates the old model-backed views. Do not inspect - // those pointers after the mutation; reacquire the entry instead. - fm_pivot_filter_spec_t mutation{}; - mutation.axis = FM_PIVOT_AXIS_ROW; - mutation.field_name = "Region"; - mutation.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; - mutation.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; - mutation.value_text = "N"; - mutation.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &mutation), 0) << fm_last_error_message(); - fm_pivot_filter_spec_t reacquired{}; - ASSERT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 2, &reacquired), 0); - EXPECT_EQ(reacquired.value_kind, FM_PIVOT_FILTER_VALUE_TEXT); - ASSERT_NE(reacquired.value_text, nullptr); - EXPECT_STREQ(reacquired.value_text, "o"); - - const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - const auto make_sentinel = [] { - fm_pivot_filter_spec_t sentinel{}; - sentinel.axis = FM_PIVOT_AXIS_PAGE; - sentinel.field_name = "sentinel-field"; - sentinel.type = FM_PIVOT_FILTER_LABEL_DATE; - sentinel.value_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; - sentinel.value_int = -123456789; - sentinel.value_double = -9876.5; - sentinel.value_text = "sentinel-text"; - sentinel.value_high_kind = FM_PIVOT_FILTER_VALUE_INT; - sentinel.value_high_int = 13579; - sentinel.value_high_double = 24680.5; - sentinel.data_field_index = 0xDEADBEEFU; - return sentinel; - }; - const auto expect_unchanged = [](const fm_pivot_filter_spec_t& expected, const fm_pivot_filter_spec_t& actual) { - EXPECT_EQ(actual.axis, expected.axis); - EXPECT_EQ(actual.field_name, expected.field_name); - EXPECT_EQ(actual.type, expected.type); - EXPECT_EQ(actual.value_kind, expected.value_kind); - EXPECT_EQ(actual.value_int, expected.value_int); - EXPECT_DOUBLE_EQ(actual.value_double, expected.value_double); - EXPECT_EQ(actual.value_text, expected.value_text); - EXPECT_EQ(actual.value_high_kind, expected.value_high_kind); - EXPECT_EQ(actual.value_high_int, expected.value_high_int); - EXPECT_DOUBLE_EQ(actual.value_high_double, expected.value_high_double); - EXPECT_EQ(actual.data_field_index, expected.data_field_index); - }; - const auto expect_error_preserves_output = [&](const auto& invoke, fm_status_t expected_status) { - fm_pivot_filter_spec_t invalid_out = make_sentinel(); - const fm_pivot_filter_spec_t before = invalid_out; - EXPECT_EQ(invoke(&invalid_out), expected_status); - expect_unchanged(before, invalid_out); - }; - expect_error_preserves_output( - [&](fm_pivot_filter_spec_t* out) { return fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 6, out); }, - invalid); - expect_error_preserves_output( - [&](fm_pivot_filter_spec_t* out) { return fm_workbook_pivot_filter_at(wb.handle, 1, pivot_idx, 0, out); }, - invalid); - expect_error_preserves_output( - [&](fm_pivot_filter_spec_t* out) { return fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx + 1, 0, out); }, - invalid); - expect_error_preserves_output( - [&](fm_pivot_filter_spec_t* out) { return fm_workbook_pivot_filter_at(nullptr, 0, pivot_idx, 0, out); }, - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - EXPECT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 0, nullptr), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); -} - -TEST(FormulonCApiPivot, GetterRejectsUnrepresentableModelEnumsWithoutMutation) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - auto* table = wb.handle->workbook().sheet(0).mutable_pivot_tables()[pivot_idx].get(); - ASSERT_NE(table, nullptr); - const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - const auto make_sentinel = [] { - fm_pivot_filter_spec_t sentinel{}; - sentinel.axis = FM_PIVOT_AXIS_PAGE; - sentinel.field_name = "sentinel-field"; - sentinel.type = FM_PIVOT_FILTER_LABEL_DATE; - sentinel.value_kind = FM_PIVOT_FILTER_VALUE_DOUBLE; - sentinel.value_int = -123456789; - sentinel.value_double = -9876.5; - sentinel.value_text = "sentinel-text"; - sentinel.value_high_kind = FM_PIVOT_FILTER_VALUE_INT; - sentinel.value_high_int = 13579; - sentinel.value_high_double = 24680.5; - sentinel.data_field_index = 0xDEADBEEFU; - return sentinel; - }; - const auto inject_and_expect_rejected = [&](formulon::pivot::PivotAxis axis, formulon::pivot::FilterType type) { - table->mutable_active_filters().clear(); - formulon::pivot::PivotFilter filter; - filter.axis = axis; - filter.field_name = "Region"; - filter.type = type; - filter.value = std::string("N"); - table->mutable_active_filters().push_back(filter); - - fm_pivot_filter_spec_t out = make_sentinel(); - const fm_pivot_filter_spec_t before = out; - EXPECT_EQ(fm_workbook_pivot_filter_at(wb.handle, 0, pivot_idx, 0, &out), invalid); - EXPECT_EQ(std::memcmp(&out, &before, sizeof(out)), 0); - }; - - inject_and_expect_rejected(formulon::pivot::PivotAxis::None, formulon::pivot::FilterType::LabelContains); - inject_and_expect_rejected(formulon::pivot::PivotAxis::Row, static_cast(0xFF)); -} - -TEST(FormulonCApiPivot, PivotFilterAddRejectsRawAxisAndTypeWithoutMutation) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t spec{}; - spec.axis = RawEnumValue(99); - spec.field_name = "Region"; - spec.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; - spec.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; - spec.value_text = "N"; - spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); - - std::size_t count = 99; - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); - EXPECT_EQ(count, 0U); - - spec.axis = FM_PIVOT_AXIS_ROW; - spec.type = RawEnumValue(99); - EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); - EXPECT_EQ(count, 0U); -} - -TEST(FormulonCApiPivot, PivotFilterRejectsInvalidDataFieldWithoutMutation) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t spec{}; - spec.axis = FM_PIVOT_AXIS_ROW; - spec.field_name = "Region"; - spec.type = FM_PIVOT_FILTER_VALUE_TOP_10; - spec.value_kind = FM_PIVOT_FILTER_VALUE_INT; - spec.value_int = 1; - spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - spec.data_field_index = 1; // BuildScratchPivot has only slot 0. - const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); - - std::size_t count = 99; - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); - EXPECT_EQ(count, 0U); -} - -TEST(FormulonCApiPivot, PivotFilterRejectsSelectorWhenNoDataFields) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - ASSERT_EQ(fm_workbook_pivot_data_field_clear(wb.handle, 0, pivot_idx), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t spec{}; - spec.axis = FM_PIVOT_AXIS_ROW; - spec.field_name = "Region"; - spec.type = FM_PIVOT_FILTER_VALUE_TOP_10; - spec.value_kind = FM_PIVOT_FILTER_VALUE_INT; - spec.value_int = 1; - spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - spec.data_field_index = 0; - const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); - - std::size_t count = 99; - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); - EXPECT_EQ(count, 0U); -} - -TEST(FormulonCApiPivot, PivotFilterRejectsUnknownFieldNameWithoutMutation) { - // A label filter naming no pivot field would be a silent no-op inside the - // engine, so the mutator rejects it instead of reporting success. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t spec{}; - spec.axis = FM_PIVOT_AXIS_ROW; - spec.field_name = "NoSuchField"; - spec.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; - spec.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; - spec.value_text = "N"; - spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); - - std::size_t count = 99; - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); - EXPECT_EQ(count, 0U); - - // The data field's display name resolves through to its source field, so - // the same call shape is accepted for it. - spec.field_name = "Sum of Amount"; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), 0) << fm_last_error_message(); - ASSERT_EQ(fm_workbook_pivot_filter_count(wb.handle, 0, pivot_idx, &count), 0); - EXPECT_EQ(count, 1U); -} - -TEST(FormulonCApiPivot, FilterErrorUsesApiName) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - fm_pivot_filter_spec_t spec{}; - spec.axis = FM_PIVOT_AXIS_ROW; - spec.field_name = "Region"; - spec.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; - spec.value_kind = FM_PIVOT_FILTER_VALUE_NONE; - spec.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &spec), invalid); - EXPECT_EQ(std::string_view(fm_last_error_message()).find("fm_workbook_pivot_filter_add:"), 0U); -} - -TEST(FormulonCApiPivot, MutateShowValuesAs) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - // Re-set the data field to PercentOfRow. - std::size_t df_count = 0; - ASSERT_EQ(fm_workbook_pivot_data_field_count(wb.handle, 0, pivot_idx, &df_count), 0); - ASSERT_EQ(df_count, 1U); - - fm_pivot_data_field_spec_t df_spec{}; - df_spec.name = "Sum of Amount"; - df_spec.field_index = 1U; // Amount is the second pivot field. - df_spec.aggregation = FM_PIVOT_AGG_SUM; - df_spec.number_format = ""; - df_spec.show_as = FM_PIVOT_SHOW_AS_PERCENT_OF_ROW; - df_spec.show_as_base_field = -1; - df_spec.show_as_base_item = -1; - ASSERT_EQ(fm_workbook_pivot_data_field_set(wb.handle, 0, pivot_idx, 0, &df_spec), 0) << fm_last_error_message(); - - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - - // Each FM_PIVOT_CELL_DATA cell should equal 1.0 (single data column -> - // each row's percent-of-row is 100% of itself). - std::size_t data_count = 0; - for (const fm_pivot_cell_t& c : CollectCells(projected.handle)) { - if (c.kind == FM_PIVOT_CELL_DATA && c.value.kind == FM_VAL_NUMBER) { - EXPECT_DOUBLE_EQ(c.value.u.number, 1.0); - ++data_count; - } - } - EXPECT_GT(data_count, 0U); -} - -TEST(FormulonCApiPivot, RemovePivotAndCache) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - std::size_t before_pivots = 0; - std::size_t before_caches = 0; - ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 0, &before_pivots), 0); - ASSERT_EQ(fm_workbook_pivot_cache_count(wb.handle, &before_caches), 0); - EXPECT_EQ(before_pivots, 1U); - EXPECT_EQ(before_caches, 1U); - - // Remove the pivot first, then the cache. - ASSERT_EQ(fm_workbook_pivot_remove(wb.handle, 0, pivot_idx), 0); - std::size_t after_remove_pivots = 99; - ASSERT_EQ(fm_workbook_pivot_count(wb.handle, 0, &after_remove_pivots), 0); - EXPECT_EQ(after_remove_pivots, 0U); - - ASSERT_EQ(fm_workbook_pivot_cache_remove(wb.handle, cache_id), 0); - std::size_t after_caches = 99; - ASSERT_EQ(fm_workbook_pivot_cache_count(wb.handle, &after_caches), 0); - EXPECT_EQ(after_caches, 0U); -} - -TEST(FormulonCApiPivot, CacheRemoveBlockedByPivot) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - EXPECT_EQ(fm_workbook_pivot_cache_remove(wb.handle, cache_id), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - // Removing a non-existent cache id must also surface kInvalidArgument. - EXPECT_EQ(fm_workbook_pivot_cache_remove(wb.handle, 9999U), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); -} - -TEST(FormulonCApiPivot, NullPointerArgumentsRejected) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - ASSERT_EQ(fm_workbook_pivot_cache_create(wb.handle, 0, &cache_id), 0); - std::size_t field_idx = 99; - ASSERT_EQ(fm_workbook_pivot_cache_field_add(wb.handle, cache_id, "F", &field_idx), 0); - - std::size_t out_count = 0; - EXPECT_EQ(fm_workbook_pivot_cache_count(nullptr, &out_count), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - EXPECT_EQ(fm_workbook_pivot_cache_count(wb.handle, nullptr), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - - std::size_t fc = 0; - EXPECT_EQ(fm_workbook_pivot_cache_field_count(nullptr, cache_id, &fc), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - - EXPECT_EQ(fm_workbook_pivot_cache_field_add(wb.handle, cache_id, nullptr, &field_idx), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - - EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_text(wb.handle, cache_id, field_idx, nullptr), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - - std::size_t pivot_idx = 99; - EXPECT_EQ(fm_workbook_pivot_create(wb.handle, 0, nullptr, cache_id, 0, 0, &pivot_idx), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - - // spec arguments - EXPECT_EQ(fm_workbook_pivot_field_add(wb.handle, 0, 0, nullptr, &pivot_idx), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); - - fm_pivot_filter_spec_t bad_filter{}; - bad_filter.field_name = nullptr; - bad_filter.type = FM_PIVOT_FILTER_LABEL_CONTAINS; - bad_filter.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; - bad_filter.value_text = "X"; - bad_filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - EXPECT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, 0, &bad_filter), - static_cast(formulon::FormulonErrorCode::kBindingNullPointer)); -} - -TEST(FormulonCApiPivot, NullHandleStatusMatchesRecordedError) { - // The shared-item and record setters resolve their target through a - // common lookup helper. The status each returns must be the code that - // helper recorded, so a caller branching on the return value and a - // caller reading `fm_last_error_message` reach the same conclusion. - const auto null_pointer = static_cast(formulon::FormulonErrorCode::kBindingNullPointer); - const auto invalid = static_cast(formulon::FormulonErrorCode::kInvalidArgument); - - const auto expect_null_handle = [&](fm_status_t status) { - EXPECT_EQ(status, null_pointer); - EXPECT_NE(std::string(fm_last_error_message()).find("wb is NULL"), std::string::npos) - << "message: " << fm_last_error_message(); - }; - - expect_null_handle(fm_workbook_pivot_cache_field_add_shared_item_number(nullptr, 0U, 0U, 1.0)); - expect_null_handle(fm_workbook_pivot_cache_field_add_shared_item_bool(nullptr, 0U, 0U, 1)); - expect_null_handle(fm_workbook_pivot_cache_field_add_shared_item_blank(nullptr, 0U, 0U)); - expect_null_handle(fm_workbook_pivot_cache_field_add_shared_item_error(nullptr, 0U, 0U, /*error=*/1)); - expect_null_handle(fm_workbook_pivot_cache_field_clear_shared_items(nullptr, 0U, 0U)); - expect_null_handle(fm_workbook_pivot_cache_record_set_number(nullptr, 0U, 0U, 0U, 1.0)); - expect_null_handle(fm_workbook_pivot_cache_record_set_text(nullptr, 0U, 0U, 0U, "x")); - expect_null_handle(fm_workbook_pivot_cache_record_set_bool(nullptr, 0U, 0U, 0U, 1)); - expect_null_handle(fm_workbook_pivot_cache_record_set_blank(nullptr, 0U, 0U, 0U)); - expect_null_handle(fm_workbook_pivot_cache_record_set_error(nullptr, 0U, 0U, 0U, /*error=*/1)); - - // A live handle with an out-of-range coordinate keeps reporting - // kInvalidArgument through the same helpers. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - ASSERT_EQ(fm_workbook_pivot_cache_create(wb.handle, 0, &cache_id), 0); - std::size_t field_idx = 99; - ASSERT_EQ(fm_workbook_pivot_cache_field_add(wb.handle, cache_id, "F", &field_idx), 0); - - EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_number(wb.handle, cache_id, field_idx + 1U, 1.0), invalid); - EXPECT_EQ(fm_workbook_pivot_cache_field_add_shared_item_number(wb.handle, cache_id + 100U, field_idx, 1.0), invalid); - EXPECT_EQ(fm_workbook_pivot_cache_record_set_number(wb.handle, cache_id, /*record_idx=*/0U, field_idx, 1.0), invalid); -} - -TEST(FormulonCApiPivot, OutOfGridAnchorRejected) { - // Excel grid ceilings; kept as literals so this test needs no core - // header. Mirrors Sheet::kMaxRows / kMaxCols. - constexpr std::uint32_t kMaxRows = 1'048'576U; - constexpr std::uint32_t kMaxCols = 16'384U; - - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - ASSERT_EQ(fm_workbook_pivot_cache_create(wb.handle, 0, &cache_id), 0); - std::size_t field_idx = 99; - ASSERT_EQ(fm_workbook_pivot_cache_field_add(wb.handle, cache_id, "F", &field_idx), 0); - - // create() with an anchor past the grid must be rejected, not stored. - std::size_t pivot_idx = 99; - EXPECT_EQ(fm_workbook_pivot_create(wb.handle, 0, "PT", cache_id, kMaxRows, 0U, &pivot_idx), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(fm_workbook_pivot_create(wb.handle, 0, "PT", cache_id, 0U, kMaxCols, &pivot_idx), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - - // A valid create succeeds, then set_anchor must reject a span whose far - // corner leaves the grid (the wrap the audit flagged) and a zero span. - ASSERT_EQ(fm_workbook_pivot_create(wb.handle, 0, "PT", cache_id, 0U, 0U, &pivot_idx), 0); - EXPECT_EQ( - fm_workbook_pivot_set_anchor(wb.handle, 0, pivot_idx, kMaxRows - 1U, 0U, /*span_rows=*/2U, /*span_cols=*/1U), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - EXPECT_EQ(fm_workbook_pivot_set_anchor(wb.handle, 0, pivot_idx, 0U, 0U, /*span_rows=*/0U, /*span_cols=*/1U), - static_cast(formulon::FormulonErrorCode::kInvalidArgument)); - // An in-grid span still succeeds. - EXPECT_EQ(fm_workbook_pivot_set_anchor(wb.handle, 0, pivot_idx, 0U, 0U, /*span_rows=*/3U, /*span_cols=*/2U), 0); -} - -TEST(FormulonCApiPivot, MutationAndClearExportsCompleteTheirLifecycles) { - // Exercise the C ABI setters that are not needed by the projected-layout - // examples above. Each mutation is followed by the corresponding clear - // where one exists, which also verifies that the handle remains usable - // across each invalidation boundary. - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - std::uint32_t id_at_zero = 0; - ASSERT_EQ(fm_workbook_pivot_cache_id_at(wb.handle, 0, &id_at_zero), 0); - EXPECT_EQ(id_at_zero, cache_id); - - const char* field_name = nullptr; - ASSERT_EQ(fm_workbook_pivot_cache_field_name(wb.handle, cache_id, 0, &field_name), 0); - EXPECT_STREQ(field_name, "Region"); - - ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_number(wb.handle, cache_id, 0, 7.0), 0); - ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_bool(wb.handle, cache_id, 0, 1), 0); - ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_blank(wb.handle, cache_id, 0), 0); - ASSERT_EQ(fm_workbook_pivot_cache_field_clear_shared_items(wb.handle, cache_id, 0), 0); - std::size_t shared_count = 99; - ASSERT_EQ(fm_workbook_pivot_cache_field_shared_item_count(wb.handle, cache_id, 0, &shared_count), 0); - EXPECT_EQ(shared_count, 0U); - - std::size_t record_idx = 99; - ASSERT_EQ(fm_workbook_pivot_cache_record_add(wb.handle, cache_id, &record_idx), 0); - ASSERT_EQ(fm_workbook_pivot_cache_record_set_text(wb.handle, cache_id, record_idx, 0, "North"), 0); - ASSERT_EQ(fm_workbook_pivot_cache_record_set_bool(wb.handle, cache_id, record_idx, 0, 1), 0); - ASSERT_EQ(fm_workbook_pivot_cache_record_set_blank(wb.handle, cache_id, record_idx, 0), 0); - ASSERT_EQ(fm_workbook_pivot_cache_record_set_error(wb.handle, cache_id, record_idx, 0, 1), 0); - ASSERT_EQ(fm_workbook_pivot_cache_record_clear(wb.handle, cache_id), 0); - std::size_t record_count = 99; - ASSERT_EQ(fm_workbook_pivot_cache_record_count(wb.handle, cache_id, &record_count), 0); - EXPECT_EQ(record_count, 0U); - - ASSERT_EQ(fm_workbook_pivot_set_name(wb.handle, 0, pivot_idx, "Renamed"), 0); - ASSERT_EQ(fm_workbook_pivot_set_grand_totals(wb.handle, 0, pivot_idx, 0, 1), 0); - std::size_t field_count = 99; - ASSERT_EQ(fm_workbook_pivot_field_count(wb.handle, 0, pivot_idx, &field_count), 0); - ASSERT_EQ(field_count, 2U); - - ASSERT_EQ(fm_workbook_pivot_field_set_axis(wb.handle, 0, pivot_idx, 0, FM_PIVOT_AXIS_ROW), 0); - ASSERT_EQ(fm_workbook_pivot_field_set_sort(wb.handle, 0, pivot_idx, 0, 0, "Amount"), 0); - ASSERT_EQ(fm_workbook_pivot_field_set_subtotal_top(wb.handle, 0, pivot_idx, 0, 1), 0); - ASSERT_EQ(fm_workbook_pivot_field_set_item_visible(wb.handle, 0, pivot_idx, 0, 0, 0), 0); - ASSERT_EQ(fm_workbook_pivot_field_clear_items(wb.handle, 0, pivot_idx, 0), 0); - ASSERT_EQ(fm_workbook_pivot_field_add_subtotal_fn(wb.handle, 0, pivot_idx, 0, FM_PIVOT_AGG_COUNT), 0); - ASSERT_EQ(fm_workbook_pivot_field_clear_subtotal_fns(wb.handle, 0, pivot_idx, 0), 0); - ASSERT_EQ(fm_workbook_pivot_field_set_date_group(wb.handle, 0, pivot_idx, 0, FM_PIVOT_DATE_YEAR, - FM_PIVOT_CALENDAR_GREGORIAN, 2020, 2026, 1, -1.0, -1.0), - 0); - ASSERT_EQ(fm_workbook_pivot_field_clear_date_group(wb.handle, 0, pivot_idx, 0), 0); - ASSERT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, "4"), 0); - const std::uint32_t col_order[] = {1U}; - ASSERT_EQ(fm_workbook_pivot_set_col_field_order(wb.handle, 0, pivot_idx, col_order, 1U), 0); - - ASSERT_EQ(fm_workbook_pivot_data_field_clear(wb.handle, 0, pivot_idx), 0); - std::size_t data_field_count = 99; - ASSERT_EQ(fm_workbook_pivot_data_field_count(wb.handle, 0, pivot_idx, &data_field_count), 0); - EXPECT_EQ(data_field_count, 0U); - - fm_pivot_filter_spec_t filter{}; - filter.axis = FM_PIVOT_AXIS_ROW; - filter.field_name = "Region"; - filter.type = FM_PIVOT_FILTER_LABEL_BEGINS_WITH; - filter.value_kind = FM_PIVOT_FILTER_VALUE_TEXT; - filter.value_text = "N"; - filter.value_high_kind = FM_PIVOT_FILTER_VALUE_NONE; - ASSERT_EQ(fm_workbook_pivot_filter_add(wb.handle, 0, pivot_idx, &filter), 0); - ASSERT_EQ(fm_workbook_pivot_filter_clear(wb.handle, 0, pivot_idx), 0); - - ASSERT_EQ(fm_workbook_pivot_field_clear(wb.handle, 0, pivot_idx), 0); - ASSERT_EQ(fm_workbook_pivot_field_count(wb.handle, 0, pivot_idx, &field_count), 0); - EXPECT_EQ(field_count, 0U); - ASSERT_EQ(fm_workbook_pivot_cache_field_clear(wb.handle, cache_id), 0); - ASSERT_EQ(fm_workbook_pivot_cache_field_count(wb.handle, cache_id, &field_count), 0); - EXPECT_EQ(field_count, 0U); -} - -namespace { - -// Region shared items are `[0] = "North"`, `[1] = blank`; the two records -// carry one of each. The blank shared item deliberately does NOT sit at index -// 0, which is what a name-addressed item silently binds to. -fm_status_t BuildBlankItemPivot(fm_workbook_t* wb, std::uint32_t* out_cache_id, std::size_t* out_pivot_index) { - std::uint32_t cache_id = 0; - if (fm_status_t st = fm_workbook_pivot_cache_create(wb, 0U, &cache_id); st != 0) { - return st; - } - std::size_t region_idx = 99; - std::size_t amount_idx = 99; - if (fm_status_t st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Region", ®ion_idx); st != 0) { - return st; - } - if (fm_status_t st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Amount", &amount_idx); st != 0) { - return st; - } - if (fm_status_t st = fm_workbook_pivot_cache_field_add_shared_item_text(wb, cache_id, region_idx, "North"); st != 0) { - return st; - } - if (fm_status_t st = fm_workbook_pivot_cache_field_add_shared_item_blank(wb, cache_id, region_idx); st != 0) { - return st; - } - - std::size_t rec_idx = 99; - if (fm_status_t st = fm_workbook_pivot_cache_record_add(wb, cache_id, &rec_idx); st != 0) { - return st; - } - if (fm_status_t st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, region_idx, 0.0); st != 0) { - return st; - } - if (fm_status_t st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, amount_idx, 100.0); st != 0) { - return st; - } - if (fm_status_t st = fm_workbook_pivot_cache_record_add(wb, cache_id, &rec_idx); st != 0) { - return st; - } - if (fm_status_t st = fm_workbook_pivot_cache_record_set_blank(wb, cache_id, rec_idx, region_idx); st != 0) { - return st; - } - if (fm_status_t st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, amount_idx, 200.0); st != 0) { - return st; - } - - std::size_t pivot_idx = 99; - if (fm_status_t st = fm_workbook_pivot_create(wb, 0, "PT", cache_id, 0U, 0U, &pivot_idx); st != 0) { - return st; - } - fm_pivot_field_spec_t region_spec{}; - region_spec.source_name = "Region"; - region_spec.custom_name = ""; - region_spec.axis = FM_PIVOT_AXIS_ROW; - region_spec.subtotal_top = 0; - region_spec.number_format = ""; - std::size_t region_field = 99; - if (fm_status_t st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, ®ion_spec, ®ion_field); st != 0) { - return st; - } - fm_pivot_field_spec_t amount_spec{}; - amount_spec.source_name = "Amount"; - amount_spec.custom_name = ""; - amount_spec.axis = FM_PIVOT_AXIS_VALUE; - amount_spec.subtotal_top = 0; - amount_spec.number_format = ""; - std::size_t amount_field = 99; - if (fm_status_t st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, &amount_spec, &amount_field); st != 0) { - return st; - } - const std::uint32_t row_order[] = {static_cast(region_field)}; - if (fm_status_t st = fm_workbook_pivot_set_row_field_order(wb, 0, pivot_idx, row_order, 1U); st != 0) { - return st; - } - fm_pivot_data_field_spec_t df_spec{}; - df_spec.name = "Sum of Amount"; - df_spec.field_index = static_cast(amount_field); - df_spec.aggregation = FM_PIVOT_AGG_SUM; - df_spec.number_format = ""; - df_spec.show_as = FM_PIVOT_SHOW_AS_NORMAL; - df_spec.show_as_base_field = -1; - df_spec.show_as_base_item = -1; - std::size_t df_idx = 99; - if (fm_status_t st = fm_workbook_pivot_data_field_add(wb, 0, pivot_idx, &df_spec, &df_idx); st != 0) { - return st; - } - if (out_cache_id != nullptr) { - *out_cache_id = cache_id; - } - if (out_pivot_index != nullptr) { - *out_pivot_index = pivot_idx; - } - return 0; -} - -// Sum of every `FM_PIVOT_CELL_DATA` cell in the projected layout. -double SumDataCells(fm_pivot_cells_t* handle) { - double total = 0.0; - const std::size_t n = fm_pivot_cells_count(handle); - for (std::size_t i = 0; i < n; ++i) { - fm_pivot_cell_t cell{}; - EXPECT_EQ(fm_pivot_cells_at(handle, i, &cell), 0); - if (cell.kind == FM_PIVOT_CELL_DATA && cell.value.kind == FM_VAL_NUMBER) { - total += cell.value.u.number; - } - } - return total; -} - -} // namespace - -TEST(FormulonCApiPivot, AddItemAtBindsTheBlankItemByCacheIndex) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildBlankItemPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - // Both source rows contribute before any manual filter exists. - { - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 300.0); - } - - // The blank shared item is at cache index 1, so the index-addressed adder is - // the only way to name it: an empty label carries no binding of its own. - ASSERT_EQ(fm_workbook_pivot_field_add_item(wb.handle, 0, pivot_idx, 0, "North", 1), 0); - ASSERT_EQ(fm_workbook_pivot_field_add_item_at(wb.handle, 0, pivot_idx, 0, /*cache_index=*/1U, /*visible=*/0), 0); - { - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 100.0); - } -} - -TEST(FormulonCApiPivot, AddItemWithEmptyNameCannotHideTheBlankRow) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildBlankItemPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - - // A name-addressed item defaults to cache index 0, which here holds - // "North". The item is unlabelled, so it takes its label from that binding - // and hides North, never the blank row. This is the gap - // `fm_workbook_pivot_field_add_item_at` closes. - ASSERT_EQ(fm_workbook_pivot_field_add_item(wb.handle, 0, pivot_idx, 0, "North", 1), 0); - ASSERT_EQ(fm_workbook_pivot_field_add_item(wb.handle, 0, pivot_idx, 0, "", 0), 0); - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 200.0); -} - -namespace { - -std::size_t CountOccurrences(const std::string& haystack, std::string_view needle) { - std::size_t count = 0; - for (std::size_t at = haystack.find(needle); at != std::string::npos; at = haystack.find(needle, at + 1U)) { - ++count; - } - return count; -} - -std::string SavedPivotXml(fm_workbook_t* wb) { - BufferGuard saved; - EXPECT_EQ(fm_workbook_save(wb, &saved.data, &saved.len), 0) << fm_last_error_message(); - return ExtractZipEntry(std::vector(saved.data, saved.data + saved.len), - "xl/pivotTables/pivotTable1.xml"); -} - -// True when the projected layout holds a text cell spelling `text`. -bool LayoutHasText(fm_workbook_t* wb, std::size_t pivot_idx, std::string_view text) { - PivotCellsGuard projected; - EXPECT_EQ(fm_workbook_pivot_layout(wb, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - for (const fm_pivot_cell_t& cell : CollectCells(projected.handle)) { - if (cell.value.kind == FM_VAL_TEXT && cell.value.u.text != nullptr && text == cell.value.u.text) { - return true; - } - } - return false; -} - -} // namespace - -TEST(FormulonCApiPivot, FieldNumberFormatRoundTripsAsNumFmtId) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - std::uint16_t custom_id = 0; - ASSERT_EQ(fm_styles_add_num_fmt(wb.handle, "0.000", &custom_id), 0) << fm_last_error_message(); - const std::string custom = std::to_string(custom_id); - ASSERT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, custom.c_str()), 0) - << fm_last_error_message(); - - const std::string first = SavedPivotXml(wb.handle); - const std::string attr = "numFmtId=\"" + custom + "\""; - EXPECT_EQ(CountOccurrences(first, attr), 1U) << first; - - // A reload reads the attribute into the model rather than into the - // passthrough bin, so the next save still emits it exactly once. - BufferGuard saved; - ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0) << fm_last_error_message(); - WorkbookGuard reloaded; - ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &reloaded.handle), 0) << fm_last_error_message(); - const std::string second = SavedPivotXml(reloaded.handle); - EXPECT_EQ(CountOccurrences(second, attr), 1U) << second; - EXPECT_NE(second.find("(formulon::FormulonErrorCode::kInvalidArgument); - - // A format code, a signed id, an id nothing registered, one past uint16. - for (const char* bad : {"0.00", "-4", "200", "65536"}) { - EXPECT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, bad), invalid) << bad; - - fm_pivot_field_spec_t field_spec{}; - field_spec.source_name = "Amount"; - field_spec.axis = FM_PIVOT_AXIS_VALUE; - field_spec.number_format = bad; - std::size_t field_idx = 99; - EXPECT_EQ(fm_workbook_pivot_field_add(wb.handle, 0, pivot_idx, &field_spec, &field_idx), invalid) << bad; - - fm_pivot_data_field_spec_t df_spec{}; - df_spec.name = "Sum of Amount"; - df_spec.field_index = 1U; - df_spec.aggregation = FM_PIVOT_AGG_SUM; - df_spec.number_format = bad; - df_spec.show_as_base_field = -1; - df_spec.show_as_base_item = -1; - std::size_t df_idx = 99; - EXPECT_EQ(fm_workbook_pivot_data_field_add(wb.handle, 0, pivot_idx, &df_spec, &df_idx), invalid) << bad; - EXPECT_EQ(fm_workbook_pivot_data_field_set(wb.handle, 0, pivot_idx, 0, &df_spec), invalid) << bad; - } - std::size_t field_count = 0; - ASSERT_EQ(fm_workbook_pivot_field_count(wb.handle, 0, pivot_idx, &field_count), 0); - EXPECT_EQ(field_count, 2U); - std::size_t df_count = 0; - ASSERT_EQ(fm_workbook_pivot_data_field_count(wb.handle, 0, pivot_idx, &df_count), 0); - EXPECT_EQ(df_count, 1U); - - // A built-in id and the empty string (clear) are accepted. - EXPECT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, "4"), 0) << fm_last_error_message(); - EXPECT_EQ(fm_workbook_pivot_field_set_number_format(wb.handle, 0, pivot_idx, 1, ""), 0) << fm_last_error_message(); -} - -// An item added by cache index carries no name until a load resolves it, so -// evaluation labels it from its binding: hiding index 1 hides "South". -TEST(FormulonCApiPivot, AddItemAtHidesANonBlankItemBeforeAnyReload) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - ASSERT_EQ(fm_workbook_pivot_field_clear_items(wb.handle, 0, pivot_idx, 0), 0); - ASSERT_EQ(fm_workbook_pivot_field_add_item_at(wb.handle, 0, pivot_idx, 0, /*cache_index=*/1U, /*visible=*/0), 0); - { - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 400.0); // North only: 100 + 300. - } - EXPECT_FALSE(LayoutHasText(wb.handle, pivot_idx, "South")); - - // The label is derived at evaluation time, so repopulating the shared - // items after the item was added changes nothing. - ASSERT_EQ(fm_workbook_pivot_cache_field_clear_shared_items(wb.handle, cache_id, 0), 0); - ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_text(wb.handle, cache_id, 0, "North"), 0); - ASSERT_EQ(fm_workbook_pivot_cache_field_add_shared_item_text(wb.handle, cache_id, 0, "South"), 0); - PivotCellsGuard projected; - ASSERT_EQ(fm_workbook_pivot_layout(wb.handle, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); - EXPECT_DOUBLE_EQ(SumDataCells(projected.handle), 400.0); -} - -TEST(FormulonCApiPivot, SoleVisiblePageItemAddedByCacheIndexNamesItsValue) { - WorkbookGuard wb; - ASSERT_EQ(fm_workbook_create(&wb.handle), 0); - std::uint32_t cache_id = 0; - std::size_t pivot_idx = 0; - ASSERT_EQ(BuildScratchPivot(wb.handle, &cache_id, &pivot_idx), 0) << fm_last_error_message(); - ASSERT_EQ(fm_workbook_pivot_set_row_field_order(wb.handle, 0, pivot_idx, nullptr, 0U), 0); - ASSERT_EQ(fm_workbook_pivot_field_set_axis(wb.handle, 0, pivot_idx, 0, FM_PIVOT_AXIS_PAGE), 0); - ASSERT_EQ(fm_workbook_pivot_field_clear_items(wb.handle, 0, pivot_idx, 0), 0); - ASSERT_EQ(fm_workbook_pivot_field_add_item_at(wb.handle, 0, pivot_idx, 0, /*cache_index=*/0U, /*visible=*/0), 0); - ASSERT_EQ(fm_workbook_pivot_field_add_item_at(wb.handle, 0, pivot_idx, 0, /*cache_index=*/1U, /*visible=*/1), 0); - EXPECT_TRUE(LayoutHasText(wb.handle, pivot_idx, "South")); -} diff --git a/tests/c_api/formulon_c_pivot_test_helpers.h b/tests/c_api/formulon_c_pivot_test_helpers.h new file mode 100644 index 000000000..2ab0ed9f4 --- /dev/null +++ b/tests/c_api/formulon_c_pivot_test_helpers.h @@ -0,0 +1,268 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "c_api/formulon_c.h" +#include "c_api/parts/common.h" +#include "gtest/gtest.h" +#include "miniz.h" +#include "pivot/pivot_table.h" +#include "utils/error.h" + +namespace { + +struct WorkbookGuard { + fm_workbook_t* handle = nullptr; + ~WorkbookGuard() { fm_workbook_destroy(handle); } + WorkbookGuard() = default; + WorkbookGuard(const WorkbookGuard&) = delete; + WorkbookGuard& operator=(const WorkbookGuard&) = delete; +}; + +struct PivotCellsGuard { + fm_pivot_cells_t* handle = nullptr; + ~PivotCellsGuard() { fm_pivot_cells_destroy(handle); } + PivotCellsGuard() = default; + PivotCellsGuard(const PivotCellsGuard&) = delete; + PivotCellsGuard& operator=(const PivotCellsGuard&) = delete; +}; + +struct BufferGuard { + std::uint8_t* data = nullptr; + std::size_t len = 0; + ~BufferGuard() { fm_buffer_free(data); } + BufferGuard() = default; + BufferGuard(const BufferGuard&) = delete; + BufferGuard& operator=(const BufferGuard&) = delete; +}; + +struct PartFile { + const char* path; + std::string_view body; +}; + +// A foreign-ABI caller can put any int-sized value into an enum field, which is +// exactly what the C surface has to reject. Writing the bytes reproduces that +// without a constant conversion: an enumerator outside the enum's implied value +// range is unspecified, and GCC rejects it under -Wconversion. +template +[[maybe_unused]] inline Enum RawEnumValue(std::uint32_t raw) { + static_assert(sizeof(Enum) == sizeof(std::uint32_t), "C ABI enums are int-sized"); + Enum value{}; + std::memcpy(&value, &raw, sizeof(value)); + return value; +} + +[[maybe_unused]] inline std::vector BuildZip(const std::vector& parts) { + mz_zip_archive writer{}; + EXPECT_NE(mz_zip_writer_init_heap(&writer, 0, 4096), MZ_FALSE); + for (const auto& p : parts) { + EXPECT_NE(mz_zip_writer_add_mem(&writer, p.path, p.body.data(), p.body.size(), + static_cast(MZ_DEFAULT_COMPRESSION)), + MZ_FALSE) + << "miniz add failed for " << p.path; + } + void* archive_ptr = nullptr; + std::size_t archive_size = 0; + EXPECT_NE(mz_zip_writer_finalize_heap_archive(&writer, &archive_ptr, &archive_size), MZ_FALSE); + EXPECT_NE(mz_zip_writer_end(&writer), MZ_FALSE); + std::vector out(static_cast(archive_ptr), + static_cast(archive_ptr) + archive_size); + mz_free(archive_ptr); + return out; +} + +[[maybe_unused]] inline std::string ExtractZipEntry(const std::vector& archive_bytes, + std::string_view path) { + mz_zip_archive reader{}; + if (mz_zip_reader_init_mem(&reader, archive_bytes.data(), archive_bytes.size(), 0) == MZ_FALSE) { + ADD_FAILURE() << "mz_zip_reader_init_mem failed"; + return {}; + } + const int index = mz_zip_reader_locate_file(&reader, std::string(path).c_str(), nullptr, 0); + if (index < 0) { + ADD_FAILURE() << "entry not found: " << path; + mz_zip_reader_end(&reader); + return {}; + } + std::size_t extracted_size = 0; + void* extracted = mz_zip_reader_extract_to_heap(&reader, static_cast(index), &extracted_size, 0); + if (extracted == nullptr) { + ADD_FAILURE() << "extract_to_heap failed for: " << path; + mz_zip_reader_end(&reader); + return {}; + } + std::string body(static_cast(extracted), extracted_size); + mz_free(extracted); + mz_zip_reader_end(&reader); + return body; +} + +[[maybe_unused]] inline std::vector CollectCells(fm_pivot_cells_t* handle) { + const std::size_t n = fm_pivot_cells_count(handle); + std::vector out(n); + for (std::size_t i = 0; i < n; ++i) { + EXPECT_EQ(fm_pivot_cells_at(handle, i, &out[i]), 0); + } + return out; +} + +[[maybe_unused]] inline std::size_t CountOccurrences(const std::string& haystack, std::string_view needle) { + std::size_t count = 0; + for (std::size_t at = haystack.find(needle); at != std::string::npos; at = haystack.find(needle, at + 1U)) { + ++count; + } + return count; +} + +[[maybe_unused]] inline std::string SavedPivotXml(fm_workbook_t* wb) { + BufferGuard saved; + EXPECT_EQ(fm_workbook_save(wb, &saved.data, &saved.len), 0) << fm_last_error_message(); + return ExtractZipEntry(std::vector(saved.data, saved.data + saved.len), + "xl/pivotTables/pivotTable1.xml"); +} + +// True when the projected layout holds a text cell spelling `text`. +[[maybe_unused]] inline bool LayoutHasText(fm_workbook_t* wb, std::size_t pivot_idx, std::string_view text) { + PivotCellsGuard projected; + EXPECT_EQ(fm_workbook_pivot_layout(wb, 0, pivot_idx, &projected.handle), 0) << fm_last_error_message(); + for (const fm_pivot_cell_t& cell : CollectCells(projected.handle)) { + if (cell.value.kind == FM_VAL_TEXT && cell.value.u.text != nullptr && text == cell.value.u.text) { + return true; + } + } + return false; +} + +// Builds a pivot cache + table from scratch via the C ABI: +// * 2 fields: "Region" (text) and "Amount" (numeric). +// * 4 records: (North, 100), (South, 200), (North, 300), (South, 400). +// * Pivot table on sheet 0, name "PT", anchor (0, 3), with Region as the +// row field and Amount aggregated as Sum. +[[maybe_unused]] inline fm_status_t BuildScratchPivot(fm_workbook_t* wb, std::uint32_t* out_cache_id, + std::size_t* out_pivot_index) { + std::uint32_t cache_id = 0; + fm_status_t st = fm_workbook_pivot_cache_create(wb, 0, &cache_id); + if (st != 0) { + return st; + } + // Excel refuses a package whose cache declares no source, so a cache + // that is going to be saved needs one. The range only has to describe + // where the records came from; it is not required to hold data, since + // the records themselves carry the values. + st = fm_workbook_pivot_cache_set_worksheet_source(wb, cache_id, /*present=*/1, "A1:B5", "Sheet1", nullptr); + if (st != 0) { + return st; + } + std::size_t region_idx = 99; + st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Region", ®ion_idx); + if (st != 0) { + return st; + } + std::size_t amount_idx = 99; + st = fm_workbook_pivot_cache_field_add(wb, cache_id, "Amount", &amount_idx); + if (st != 0) { + return st; + } + // Shared items for "Region": index 0 = "North", index 1 = "South". + st = fm_workbook_pivot_cache_field_add_shared_item_text(wb, cache_id, region_idx, "North"); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_cache_field_add_shared_item_text(wb, cache_id, region_idx, "South"); + if (st != 0) { + return st; + } + // 4 records. + const struct { + double region_index; + double amount; + } rows[] = {{0.0, 100.0}, {1.0, 200.0}, {0.0, 300.0}, {1.0, 400.0}}; + for (const auto& row : rows) { + std::size_t rec_idx = 99; + st = fm_workbook_pivot_cache_record_add(wb, cache_id, &rec_idx); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, region_idx, row.region_index); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_cache_record_set_number(wb, cache_id, rec_idx, amount_idx, row.amount); + if (st != 0) { + return st; + } + } + // Pivot table. + std::size_t pivot_idx = 99; + st = fm_workbook_pivot_create(wb, 0, "PT", cache_id, /*anchor_row=*/0U, /*anchor_col=*/3U, &pivot_idx); + if (st != 0) { + return st; + } + // Region (row) + items 0 and 1, plus the OOXML "default" subtotal item. + fm_pivot_field_spec_t region_spec{}; + region_spec.source_name = "Region"; + region_spec.custom_name = ""; + region_spec.axis = FM_PIVOT_AXIS_ROW; + region_spec.subtotal_top = 0; + region_spec.number_format = ""; + std::size_t region_field = 99; + st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, ®ion_spec, ®ion_field); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_field_add_item(wb, 0, pivot_idx, region_field, "North", 1); + if (st != 0) { + return st; + } + st = fm_workbook_pivot_field_add_item(wb, 0, pivot_idx, region_field, "South", 1); + if (st != 0) { + return st; + } + // Amount (value-axis source field). + fm_pivot_field_spec_t amount_spec{}; + amount_spec.source_name = "Amount"; + amount_spec.custom_name = ""; + amount_spec.axis = FM_PIVOT_AXIS_VALUE; + amount_spec.subtotal_top = 0; + amount_spec.number_format = ""; + std::size_t amount_field = 99; + st = fm_workbook_pivot_field_add(wb, 0, pivot_idx, &amount_spec, &amount_field); + if (st != 0) { + return st; + } + // Row-field order. + const std::uint32_t row_order[] = {static_cast(region_field)}; + st = fm_workbook_pivot_set_row_field_order(wb, 0, pivot_idx, row_order, 1U); + if (st != 0) { + return st; + } + // Data field: Sum of Amount. + fm_pivot_data_field_spec_t df_spec{}; + df_spec.name = "Sum of Amount"; + df_spec.field_index = static_cast(amount_field); + df_spec.aggregation = FM_PIVOT_AGG_SUM; + df_spec.number_format = ""; + df_spec.show_as = FM_PIVOT_SHOW_AS_NORMAL; + df_spec.show_as_base_field = -1; + df_spec.show_as_base_item = -1; + std::size_t df_idx = 99; + st = fm_workbook_pivot_data_field_add(wb, 0, pivot_idx, &df_spec, &df_idx); + if (st != 0) { + return st; + } + if (out_cache_id != nullptr) { + *out_cache_id = cache_id; + } + if (out_pivot_index != nullptr) { + *out_pivot_index = pivot_idx; + } + return 0; +} + +} // namespace diff --git a/tests/c_api/formulon_c_save_test.cpp b/tests/c_api/formulon_c_save_test.cpp new file mode 100644 index 000000000..8140a66d2 --- /dev/null +++ b/tests/c_api/formulon_c_save_test.cpp @@ -0,0 +1,646 @@ +// Stable C ABI (`src/c_api/formulon_c.h`) end-to-end tests. +// +// The test driver is C++ for gtest convenience but everything it +// touches across the boundary is the pure-C surface declared in +// `formulon_c.h`. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "c_api/parts/common.h" +#include "formulon_c_test_helpers.h" +#include "gtest/gtest.h" +#include "io/format_detect.h" +#include "io/xlsb/writer.h" +#include "io/zip_reader.h" +#include "passthrough_part.h" +#include "sheet.h" +#include "unknown_relationship.h" +#include "utils/error.h" +#include "value.h" +#include "workbook.h" + +namespace { + +void MarkZipEntriesEncrypted(std::vector& bytes) { + for (std::size_t i = 0; i + 8U <= bytes.size(); ++i) { + const bool local = bytes[i] == 'P' && bytes[i + 1U] == 'K' && bytes[i + 2U] == 3U && bytes[i + 3U] == 4U; + const bool central = bytes[i] == 'P' && bytes[i + 1U] == 'K' && bytes[i + 2U] == 1U && bytes[i + 3U] == 2U; + if (local || central) { + const std::size_t flag_offset = i + (local ? 6U : 8U); + bytes[flag_offset] = static_cast(bytes[flag_offset] | 0x01U); + } + } +} + +std::vector AppendEmptyZipEntry(const std::vector& bytes, std::string_view name) { + const std::uint8_t signature[] = {0x50, 0x4b, 0x05, 0x06}; + const auto eocd_it = std::find_end(bytes.begin(), bytes.end(), std::begin(signature), std::end(signature)); + EXPECT_NE(eocd_it, bytes.end()); + if (eocd_it == bytes.end()) { + return {}; + } + const std::size_t eocd = static_cast(eocd_it - bytes.begin()); + const auto read16 = [&](std::size_t offset) { + return static_cast(bytes[offset] | (static_cast(bytes[offset + 1U]) << 8U)); + }; + const auto read32 = [&](std::size_t offset) { + return static_cast(bytes[offset] | (static_cast(bytes[offset + 1U]) << 8U) | + (static_cast(bytes[offset + 2U]) << 16U) | + (static_cast(bytes[offset + 3U]) << 24U)); + }; + const std::uint16_t count = read16(eocd + 10U); + const std::uint32_t central_size = read32(eocd + 12U); + const std::uint32_t central_offset = read32(eocd + 16U); + const auto put16 = [](std::vector& out, std::uint16_t value) { + out.push_back(static_cast(value)); + out.push_back(static_cast(value >> 8U)); + }; + const auto put32 = [](std::vector& out, std::uint32_t value) { + out.push_back(static_cast(value)); + out.push_back(static_cast(value >> 8U)); + out.push_back(static_cast(value >> 16U)); + out.push_back(static_cast(value >> 24U)); + }; + const std::string encoded_name(name); + std::vector local; + put32(local, 0x04034b50U); + put16(local, 20); + put16(local, 0); + put16(local, 0); + put16(local, 0); + put16(local, 0); + put32(local, 0); + put32(local, 0); + put32(local, 0); + put16(local, static_cast(encoded_name.size())); + put16(local, 0); + local.insert(local.end(), encoded_name.begin(), encoded_name.end()); + std::vector central; + put32(central, 0x02014b50U); + put16(central, 20); + put16(central, 20); + put16(central, 0); + put16(central, 0); + put16(central, 0); + put16(central, 0); + put32(central, 0); + put32(central, 0); + put32(central, 0); + put16(central, static_cast(encoded_name.size())); + put16(central, 0); + put16(central, 0); + put16(central, 0); + put16(central, 0); + put32(central, 0); + put32(central, central_offset); + central.insert(central.end(), encoded_name.begin(), encoded_name.end()); + std::vector out; + out.insert(out.end(), bytes.begin(), bytes.begin() + central_offset); + out.insert(out.end(), local.begin(), local.end()); + out.insert(out.end(), bytes.begin() + central_offset, bytes.begin() + central_offset + central_size); + out.insert(out.end(), central.begin(), central.end()); + put32(out, 0x06054b50U); + put16(out, 0); + put16(out, 0); + put16(out, count + 1U); + put16(out, count + 1U); + put32(out, central_size + static_cast(central.size())); + put32(out, central_offset + static_cast(local.size())); + put16(out, 0); + return out; +} + +} // namespace + +TEST(FormulonCApi, LoadMapsCorruptAndEncryptedContainersToIoErrors) { + fm_workbook_t* loaded = reinterpret_cast(0x1); + const std::vector garbage = {0x01U, 0x02U, 0x03U, 0x04U}; + EXPECT_EQ(fm_workbook_load(garbage.data(), garbage.size(), &loaded), + static_cast(formulon::FormulonErrorCode::kIoZipCorrupt)); + EXPECT_EQ(loaded, nullptr); + + loaded = reinterpret_cast(0x1); + const std::vector cdfv2 = {0xD0U, 0xCFU, 0x11U, 0xE0U, 0xA1U, 0xB1U, 0x1AU, 0xE1U}; + EXPECT_EQ(fm_workbook_load(cdfv2.data(), cdfv2.size(), &loaded), + static_cast(formulon::FormulonErrorCode::kIoZipEncrypted)); + EXPECT_EQ(loaded, nullptr); + + formulon::Workbook source = formulon::Workbook::create(); + auto saved = source.save(); + ASSERT_TRUE(static_cast(saved)); + std::vector encrypted = saved.value(); + MarkZipEntriesEncrypted(encrypted); + loaded = reinterpret_cast(0x1); + EXPECT_EQ(fm_workbook_load(encrypted.data(), encrypted.size(), &loaded), + static_cast(formulon::FormulonErrorCode::kIoZipEncrypted)); + EXPECT_EQ(loaded, nullptr); +} +TEST(FormulonCApi, SaveLoadRoundTrip) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_add_sheet(wb.handle, "Second"), 0); + ASSERT_EQ(fm_workbook_set_number(wb.handle, 0, 0, 0, 7.0), 0); + ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, 1, 0, "=A1+1"), 0); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + BufferGuard buf; + ASSERT_EQ(fm_workbook_save(wb.handle, &buf.data, &buf.len), 0); + ASSERT_NE(buf.data, nullptr); + EXPECT_GT(buf.len, 0U); + + WorkbookGuard loaded; + ASSERT_EQ(fm_workbook_load(buf.data, buf.len, &loaded.handle), 0); + EXPECT_EQ(fm_workbook_sheet_count(loaded.handle), 2U); + + const char* sheet0 = nullptr; + ASSERT_EQ(fm_workbook_sheet_name(loaded.handle, 0, &sheet0), 0); + EXPECT_STREQ(sheet0, "Sheet1"); + + // The literal A1=7 must round-trip; the formula B1=A1+1 may need a + // recalc on the loaded workbook to populate its cached value. + ASSERT_EQ(fm_workbook_recalc(loaded.handle), 0); + fm_value_t a1{}; + ASSERT_EQ(fm_workbook_get_value(loaded.handle, 0, 0, 0, &a1), 0); + EXPECT_EQ(a1.kind, FM_VAL_NUMBER); + EXPECT_DOUBLE_EQ(a1.u.number, 7.0); +} +TEST(FormulonCApi, SaveExXlsxMatchesSave) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_number(wb.handle, 0, 0, 0, 7.0), 0); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + BufferGuard xlsx_buf; + ASSERT_EQ(fm_workbook_save_as(wb.handle, FM_WORKBOOK_FORMAT_XLSX, &xlsx_buf.data, &xlsx_buf.len), 0); + ASSERT_NE(xlsx_buf.data, nullptr); + EXPECT_GT(xlsx_buf.len, 0U); + + // `FM_WORKBOOK_FORMAT_XLSX` must produce an OOXML container, so the + // C ABI's own format sniff (used by `fm_workbook_load`) reports it as + // such rather than xlsb. + formulon::io::ByteSpan xlsx_span{xlsx_buf.data, xlsx_buf.len}; + EXPECT_EQ(formulon::io::detect_workbook_format(xlsx_span), formulon::WorkbookFormat::Ooxml); +} +TEST(FormulonCApi, SaveExXlsbProducesLoadableXlsbContainer) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_number(wb.handle, 0, 0, 0, 42.0), 0); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + BufferGuard xlsb_buf; + ASSERT_EQ(fm_workbook_save_as(wb.handle, FM_WORKBOOK_FORMAT_XLSB, &xlsb_buf.data, &xlsb_buf.len), 0); + ASSERT_NE(xlsb_buf.data, nullptr); + EXPECT_GT(xlsb_buf.len, 0U); + + // The bytes must be a real MS-XLSB package (declares `xl/workbook.bin`, + // not `xl/workbook.xml`), and must load back through the byte-only + // C ABI, which auto-detects the container from its contents. + formulon::io::ByteSpan xlsb_span{xlsb_buf.data, xlsb_buf.len}; + EXPECT_EQ(formulon::io::detect_workbook_format(xlsb_span), formulon::WorkbookFormat::Xlsb); + + WorkbookGuard loaded; + ASSERT_EQ(fm_workbook_load(xlsb_buf.data, xlsb_buf.len, &loaded.handle), 0); + ASSERT_EQ(fm_workbook_recalc(loaded.handle), 0); + fm_value_t v{}; + ASSERT_EQ(fm_workbook_get_value(loaded.handle, 0, 0, 0, &v), 0); + EXPECT_EQ(v.kind, FM_VAL_NUMBER); + EXPECT_DOUBLE_EQ(v.u.number, 42.0); +} +TEST(FormulonCApi, SaveWithDiagnosticsReportsTheXlsbCountersAndLeavesTheXlsxOnesZero) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, 0, 0, "=T[C]"), 0); + + auto& sheet = wb.handle->workbook().sheet(0); + formulon::Hyperlink hyperlink; + hyperlink.target = "https://example.com"; + sheet.mutable_hyperlinks().push_back(std::move(hyperlink)); + sheet.mutable_validations().push_back(formulon::DataValidation{}); + sheet.set_auto_filter_xml(""); + + // The OOXML writer represents all of the above, so a clean XLSX save + // reports nothing lost -- including on the two fields both writers own. + BufferGuard xlsx_buf; + fm_save_diagnostics_t xlsx{99U, 99U, 99U, 99U, 99U}; + ASSERT_EQ(fm_workbook_save_with_diagnostics(wb.handle, FM_WORKBOOK_FORMAT_XLSX, &xlsx_buf.data, &xlsx_buf.len, &xlsx), + 0); + EXPECT_EQ(xlsx.downgraded_formula_count, 0U); + EXPECT_EQ(xlsx.deferred_feature_count, 0U); + EXPECT_EQ(xlsx.dropped_part_count, 0U); + EXPECT_EQ(xlsx.dropped_relationship_count, 0U); + EXPECT_EQ(xlsx.renumbered_part_count, 0U); + + BufferGuard xlsb_buf; + fm_save_diagnostics_t xlsb{}; + ASSERT_EQ(fm_workbook_save_with_diagnostics(wb.handle, FM_WORKBOOK_FORMAT_XLSB, &xlsb_buf.data, &xlsb_buf.len, &xlsb), + 0); + EXPECT_EQ(xlsb.downgraded_formula_count, 1U); + // Auto-filter state remains deferred. Hyperlinks and validations are + // written from the model and are therefore not counted here. + EXPECT_EQ(xlsb.deferred_feature_count, 1U); + // `renumbered_part_count` has no XLSB source: the binary writer never + // reassigns a part id. + EXPECT_EQ(xlsb.renumbered_part_count, 0U); +} +TEST(FormulonCApi, SaveWithDiagnosticsCarriesTheOoxmlWriterCountersAcrossTheAbi) { + // The OOXML writer's own counters are unit-tested at the io layer; this + // pins that they survive the projection onto `fm_save_diagnostics_t` + // instead of arriving as zero. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_number(wb.handle, 0, 0, 0, 1.0), 0); + + // A preserved copy of a part the writer always generates loses the + // collision and is dropped; a relationship whose target part is absent is + // dropped rather than left dangling. + formulon::PassthroughPart stale; + stale.path = "xl/styles.xml"; + stale.content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"; + stale.bytes = {'<', '/', '>'}; + wb.handle->workbook().set_passthrough_parts({std::move(stale)}); + formulon::UnknownRelationship orphan; + orphan.id = "rId9"; + orphan.type = "http://schemas.example.com/orphan"; + orphan.target = "xl/missing.xml"; + wb.handle->workbook().set_unknown_workbook_rels({std::move(orphan)}); + + BufferGuard buf; + fm_save_diagnostics_t d{99U, 99U, 99U, 99U, 99U}; + ASSERT_EQ(fm_workbook_save_with_diagnostics(wb.handle, FM_WORKBOOK_FORMAT_XLSX, &buf.data, &buf.len, &d), 0); + EXPECT_GT(buf.len, 0U); + EXPECT_EQ(d.dropped_part_count, 1U); + EXPECT_EQ(d.dropped_relationship_count, 1U); + EXPECT_EQ(d.downgraded_formula_count, 0U); + EXPECT_EQ(d.deferred_feature_count, 0U); + EXPECT_EQ(d.renumbered_part_count, 0U); +} +TEST(FormulonCApi, ReadDiagnosticsAreZeroForCleanRoundTrip) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_number(wb.handle, 0, 0, 0, 42.0), 0); + + for (const fm_workbook_format_t format : {FM_WORKBOOK_FORMAT_XLSX, FM_WORKBOOK_FORMAT_XLSB}) { + BufferGuard bytes; + ASSERT_EQ(fm_workbook_save_as(wb.handle, format, &bytes.data, &bytes.len), 0); + WorkbookGuard loaded; + ASSERT_EQ(fm_workbook_load(bytes.data, bytes.len, &loaded.handle), 0); + + fm_read_diagnostics_t d{99U, 99U, 99U, 99U, 99U}; + ASSERT_EQ(fm_workbook_read_diagnostics(loaded.handle, &d), 0); + EXPECT_EQ(d.undecoded_formula_count, 0U) << "format=" << format; + EXPECT_EQ(d.undecoded_defined_name_count, 0U) << "format=" << format; + EXPECT_EQ(d.undecoded_part_count, 0U) << "format=" << format; + EXPECT_EQ(d.skipped_feature_count, 0U) << "format=" << format; + EXPECT_EQ(d.unknown_content_type_count, 0U) << "format=" << format; + } +} +TEST(FormulonCApi, DiagnosticsEntryPointsInitializeOutputsOnFailure) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + // A workbook that was never loaded reports every counter as zero rather + // than leaving the caller's struct untouched. + fm_read_diagnostics_t read{99U, 99U, 99U, 99U, 99U}; + ASSERT_EQ(fm_workbook_read_diagnostics(wb.handle, &read), 0); + EXPECT_EQ(read.undecoded_formula_count, 0U); + EXPECT_EQ(read.skipped_feature_count, 0U); + + uint8_t* bytes = reinterpret_cast(static_cast(1)); + size_t len = 99U; + fm_save_diagnostics_t save{99U, 99U, 99U, 99U, 99U}; + EXPECT_NE(fm_workbook_save_with_diagnostics(wb.handle, FM_WORKBOOK_FORMAT_UNKNOWN, &bytes, &len, &save), 0); + EXPECT_EQ(bytes, nullptr); + EXPECT_EQ(len, 0U); + EXPECT_EQ(save.downgraded_formula_count, 0U); + EXPECT_EQ(save.deferred_feature_count, 0U); + EXPECT_EQ(save.dropped_part_count, 0U); + EXPECT_EQ(save.dropped_relationship_count, 0U); + EXPECT_EQ(save.renumbered_part_count, 0U); + + read = fm_read_diagnostics_t{99U, 99U, 99U, 99U, 99U}; + EXPECT_NE(fm_workbook_read_diagnostics(nullptr, &read), 0); + EXPECT_EQ(read.undecoded_formula_count, 0U); + EXPECT_EQ(read.undecoded_defined_name_count, 0U); + EXPECT_EQ(read.undecoded_part_count, 0U); + EXPECT_EQ(read.skipped_feature_count, 0U); + EXPECT_EQ(read.unknown_content_type_count, 0U); +} +TEST(FormulonCApi, ReadDiagnosticsPreservesUnknownPartFromDeterministicFixture) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + BufferGuard bytes; + ASSERT_EQ(fm_workbook_save_as(wb.handle, FM_WORKBOOK_FORMAT_XLSB, &bytes.data, &bytes.len), 0); + const std::vector fixture = + AppendEmptyZipEntry(std::vector(bytes.data, bytes.data + bytes.len), "xl/dropped.bin"); + ASSERT_FALSE(fixture.empty()); + WorkbookGuard loaded; + ASSERT_EQ(fm_workbook_load(fixture.data(), fixture.size(), &loaded.handle), 0); + fm_read_diagnostics_t d{}; + ASSERT_EQ(fm_workbook_read_diagnostics(loaded.handle, &d), 0); + EXPECT_EQ(d.undecoded_formula_count, 0U); + EXPECT_EQ(d.undecoded_defined_name_count, 0U); + // Unknown package parts are retained as passthrough data, so loading the + // fixture is lossless and the undecoded-part diagnostic remains zero. + EXPECT_EQ(d.undecoded_part_count, 0U); +} +TEST(FormulonCApi, DiagnosticFailuresNameTheInvokedSymbol) { + fm_read_diagnostics_t read{77U, 77U, 77U, 77U, 77U}; + EXPECT_NE(fm_workbook_read_diagnostics(nullptr, &read), 0); + EXPECT_EQ(read.undecoded_formula_count, 0U); + EXPECT_EQ(read.skipped_feature_count, 0U); + EXPECT_STREQ(fm_last_error_message(), "fm_workbook_read_diagnostics: NULL argument"); + + uint8_t* bytes = reinterpret_cast(static_cast(1)); + size_t len = 77U; + EXPECT_NE(fm_workbook_save_as(nullptr, FM_WORKBOOK_FORMAT_XLSB, &bytes, &len), 0); + EXPECT_EQ(bytes, nullptr); + EXPECT_EQ(len, 0U); + EXPECT_STREQ(fm_last_error_message(), "fm_workbook_save_as: NULL argument"); + + fm_save_diagnostics_t save{77U, 77U, 77U, 77U, 77U}; + bytes = reinterpret_cast(static_cast(1)); + len = 77U; + EXPECT_NE(fm_workbook_save_with_diagnostics(nullptr, FM_WORKBOOK_FORMAT_XLSB, &bytes, &len, &save), 0); + EXPECT_EQ(bytes, nullptr); + EXPECT_EQ(len, 0U); + EXPECT_EQ(save.downgraded_formula_count, 0U); + EXPECT_EQ(save.renumbered_part_count, 0U); + EXPECT_STREQ(fm_last_error_message(), "fm_workbook_save_with_diagnostics: NULL argument"); +} +TEST(FormulonCApi, EverySaveEntryPointZeroesOutParamsOnFailure) { + // The whole save family shares one failure-path contract, so a caller may + // reuse the same out variables across calls and free unconditionally. A + // member that left a previous call's pointer in place would hand that + // stale pointer to `fm_buffer_free` a second time. + uint8_t* const poison = reinterpret_cast(static_cast(1)); + + uint8_t* bytes = poison; + size_t len = 77U; + EXPECT_NE(fm_workbook_save(nullptr, &bytes, &len), 0); + EXPECT_EQ(bytes, nullptr); + EXPECT_EQ(len, 0U); + EXPECT_STREQ(fm_last_error_message(), "fm_workbook_save: NULL argument"); + + bytes = poison; + len = 77U; + EXPECT_NE(fm_workbook_save_as(nullptr, FM_WORKBOOK_FORMAT_XLSX, &bytes, &len), 0); + EXPECT_EQ(bytes, nullptr); + EXPECT_EQ(len, 0U); + + bytes = poison; + len = 77U; + fm_save_diagnostics_t save{77U, 77U, 77U, 77U, 77U}; + EXPECT_NE(fm_workbook_save_with_diagnostics(nullptr, FM_WORKBOOK_FORMAT_XLSX, &bytes, &len, &save), 0); + EXPECT_EQ(bytes, nullptr); + EXPECT_EQ(len, 0U); + EXPECT_EQ(save.downgraded_formula_count, 0U); + EXPECT_EQ(save.deferred_feature_count, 0U); + EXPECT_EQ(save.dropped_part_count, 0U); + EXPECT_EQ(save.dropped_relationship_count, 0U); + EXPECT_EQ(save.renumbered_part_count, 0U); + + // A NULL out-parameter is rejected without being dereferenced, on the base + // entry point as well as the extended ones. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + len = 77U; + EXPECT_NE(fm_workbook_save(wb.handle, nullptr, &len), 0); + EXPECT_EQ(len, 0U); + bytes = poison; + EXPECT_NE(fm_workbook_save(wb.handle, &bytes, nullptr), 0); + EXPECT_EQ(bytes, nullptr); +} +TEST(FormulonCApi, BaseSaveProducesTheSameBytesAsTheXlsxFormatSelector) { + // The header declares the two equivalent; delegating the base entry point + // to the shared implementation is what keeps that true. + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, 0, 0, "=1+2"), 0); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + BufferGuard base; + ASSERT_EQ(fm_workbook_save(wb.handle, &base.data, &base.len), 0); + BufferGuard selected; + ASSERT_EQ(fm_workbook_save_as(wb.handle, FM_WORKBOOK_FORMAT_XLSX, &selected.data, &selected.len), 0); + + ASSERT_EQ(base.len, selected.len); + ASSERT_GT(base.len, 0U); + EXPECT_EQ(std::memcmp(base.data, selected.data, base.len), 0); +} +TEST(FormulonCApi, ReservedStatusCodesKeepTheirSlotsAndSpelling) { + // Some documented codes are allocated but no shipping path builds them. + // The header promises numeric identity with `formulon::FormulonErrorCode`, + // so those slots must not be reused or renumbered, and `fm_status_string` + // must keep naming them exactly: only a value outside the enum may fall + // back to `"kUnknownError"`. + struct Reserved { + fm_status_t code; + const char* name; + }; + const Reserved reserved[] = { + {5008, "kIoXmlDoctype"}, + {5009, "kIoXmlEntityExplosion"}, + {5015, "kIoCsvEncodingDetect"}, + {6000, "kCryptoAgileNotSupported"}, + {6001, "kCryptoStandardNotSupported"}, + {6002, "kCryptoBadPassword"}, + {6003, "kCryptoHashMismatch"}, + {6004, "kCryptoKeyDerivationFailed"}, + }; + for (const Reserved& entry : reserved) { + EXPECT_STREQ(fm_status_string(entry.code), entry.name) << "code=" << entry.code; + } + + // The reserved crypto band is not what an encrypted package reports; a + // binding offering a password prompt must branch on `kIoZipEncrypted`. + EXPECT_STREQ(fm_status_string(static_cast(formulon::FormulonErrorCode::kIoZipEncrypted)), + "kIoZipEncrypted"); + EXPECT_STREQ(fm_status_string(123456), "kUnknownError"); +} +TEST(FormulonCApi, MemoryUsageTracksTheWorkbookAndRejectsNulls) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + size_t empty = 0U; + ASSERT_EQ(fm_workbook_memory_usage(wb.handle, &empty), 0); + EXPECT_GT(empty, 0U); + + for (uint32_t row = 0; row < 200U; ++row) { + ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, row, 0, "=1+2"), 0); + } + size_t filled = 0U; + ASSERT_EQ(fm_workbook_memory_usage(wb.handle, &filled), 0); + EXPECT_GT(filled, empty); + + // Both NULL arguments are rejected rather than silently reporting 0, + // which is what lets a binding distinguish "empty" from "broken". + size_t scratch = 0U; + EXPECT_NE(fm_workbook_memory_usage(nullptr, &scratch), 0); + EXPECT_NE(fm_workbook_memory_usage(wb.handle, nullptr), 0); +} +TEST(FormulonCApi, SaveExRejectsUnknownFormat) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + const std::int32_t invalid_formats[] = {99, std::numeric_limits::min(), + std::numeric_limits::max()}; + for (const std::int32_t raw : invalid_formats) { + BufferGuard buf; + EXPECT_EQ(fm_workbook_save_as(wb.handle, raw, &buf.data, &buf.len), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(buf.data, nullptr); + EXPECT_EQ(buf.len, 0U); + fm_save_diagnostics_t save{99U, 99U, 99U, 99U, 99U}; + EXPECT_EQ(fm_workbook_save_with_diagnostics(wb.handle, raw, &buf.data, &buf.len, &save), + static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + EXPECT_EQ(buf.data, nullptr); + EXPECT_EQ(buf.len, 0U); + EXPECT_EQ(save.downgraded_formula_count, 0U); + EXPECT_EQ(save.deferred_feature_count, 0U); + EXPECT_EQ(save.dropped_part_count, 0U); + EXPECT_EQ(save.dropped_relationship_count, 0U); + EXPECT_EQ(save.renumbered_part_count, 0U); + } +} +TEST(FormulonCApi, LoadRoutesXlsbBytesToXlsbReader) { + // Build a minimal `.xlsb` byte stream via the engine writer, then load + // it through the byte-only C ABI. The load boundary must detect the + // xlsb container and route to `read_xlsb` rather than failing in the + // OOXML reader with a "missing xl/workbook.xml" diagnostic. + formulon::Workbook src = formulon::Workbook::create_empty(); + formulon::Sheet& s = src.sheet(src.add_sheet("S")); + s.set_cell_value(0U, 0U, formulon::Value::number(123.5)); + auto xlsb_or = formulon::io::xlsb::write_xlsb(src); + ASSERT_TRUE(static_cast(xlsb_or)) << xlsb_or.error().message; + const std::vector& xlsb = xlsb_or.value(); + + WorkbookGuard loaded; + ASSERT_EQ(fm_workbook_load(xlsb.data(), xlsb.size(), &loaded.handle), 0); + EXPECT_EQ(fm_workbook_sheet_count(loaded.handle), 1U); + + fm_value_t v{}; + ASSERT_EQ(fm_workbook_get_value(loaded.handle, 0, 0, 0, &v), 0); + EXPECT_EQ(v.kind, FM_VAL_NUMBER); + EXPECT_DOUBLE_EQ(v.u.number, 123.5); +} +TEST(FormulonCApi, SaveLoadFormulaTextResult) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, 1, 0, "=UPPER(\"world\")"), 0); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + BufferGuard buf; + ASSERT_EQ(fm_workbook_save(wb.handle, &buf.data, &buf.len), 0); + ASSERT_NE(buf.data, nullptr); + EXPECT_GT(buf.len, 0U); + + WorkbookGuard loaded; + ASSERT_EQ(fm_workbook_load(buf.data, buf.len, &loaded.handle), 0); + ASSERT_EQ(fm_workbook_recalc(loaded.handle), 0); + fm_value_t v{}; + ASSERT_EQ(fm_workbook_get_value(loaded.handle, 0, 1, 0, &v), 0); + EXPECT_EQ(v.kind, FM_VAL_TEXT); + ASSERT_NE(v.u.text, nullptr); + EXPECT_STREQ(v.u.text, "WORLD"); +} +TEST(FormulonCApi, SaveLoadFormulaBoolResult) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, 2, 0, "=TRUE()"), 0); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + BufferGuard buf; + ASSERT_EQ(fm_workbook_save(wb.handle, &buf.data, &buf.len), 0); + ASSERT_NE(buf.data, nullptr); + EXPECT_GT(buf.len, 0U); + + WorkbookGuard loaded; + ASSERT_EQ(fm_workbook_load(buf.data, buf.len, &loaded.handle), 0); + ASSERT_EQ(fm_workbook_recalc(loaded.handle), 0); + fm_value_t v{}; + ASSERT_EQ(fm_workbook_get_value(loaded.handle, 0, 2, 0, &v), 0); + EXPECT_EQ(v.kind, FM_VAL_BOOL); + EXPECT_EQ(v.u.boolean, 1); +} +TEST(FormulonCApi, SaveLoadFormulaErrorResult) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_formula(wb.handle, 0, 3, 0, "=1/0"), 0); + ASSERT_EQ(fm_workbook_recalc(wb.handle), 0); + + BufferGuard buf; + ASSERT_EQ(fm_workbook_save(wb.handle, &buf.data, &buf.len), 0); + ASSERT_NE(buf.data, nullptr); + EXPECT_GT(buf.len, 0U); + + WorkbookGuard loaded; + ASSERT_EQ(fm_workbook_load(buf.data, buf.len, &loaded.handle), 0); + ASSERT_EQ(fm_workbook_recalc(loaded.handle), 0); + fm_value_t v{}; + ASSERT_EQ(fm_workbook_get_value(loaded.handle, 0, 3, 0, &v), 0); + EXPECT_EQ(v.kind, FM_VAL_ERROR); + EXPECT_EQ(v.u.error_code, 1); +} + +TEST(FormulonCApi, ThreadLocalLastErrorIsolation) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + // Establish a known last-error on the main thread. + fm_value_t v{}; + ASSERT_NE(fm_workbook_get_value(nullptr, 0, 0, 0, &v), 0); + const std::string main_msg = fm_last_error_message(); + EXPECT_GT(main_msg.size(), 0U); + + // A worker thread triggers a *different* error path; the main + // thread's last error must remain untouched. + std::atomic worker_rc{0}; + std::thread worker([&]() { + // Out-of-range sheet on a freshly created workbook on this thread. + fm_workbook_t* local = nullptr; + if (fm_workbook_create(&local) != 0) { + return; + } + worker_rc.store(fm_workbook_set_number(local, 99, 0, 0, 1.0)); + fm_workbook_destroy(local); + }); + worker.join(); + EXPECT_NE(worker_rc.load(), 0); + + // Main thread's diagnostic survived the worker's run. + EXPECT_EQ(std::string(fm_last_error_message()), main_msg); +} +TEST(FormulonCApi, StatusStringCoversKnownCodes) { + // Spot-check a handful of band-spanning codes; the source-of-truth is + // `formulon::to_cstring`, which the C API forwards to. + EXPECT_STREQ(fm_status_string(0), "kOk"); + EXPECT_STREQ(fm_status_string(2), "kInvalidArgument"); + EXPECT_STREQ(fm_status_string(7000), "kBindingInvalidHandle"); + EXPECT_STREQ(fm_status_string(7001), "kBindingNullPointer"); + // Unknown numeric values must still return a non-NULL fallback. + const char* unknown = fm_status_string(123456); + ASSERT_NE(unknown, nullptr); + EXPECT_GT(std::strlen(unknown), 0U); +} +TEST(FormulonCApi, ErrorDisplayNameUsesExcelLiterals) { + EXPECT_STREQ(fm_error_display_name(0), "#NULL!"); + EXPECT_STREQ(fm_error_display_name(1), "#DIV/0!"); + EXPECT_STREQ(fm_error_display_name(3), "#REF!"); + EXPECT_STREQ(fm_error_display_name(-1), "#UNKNOWN!"); + EXPECT_STREQ(fm_error_display_name(999), "#UNKNOWN!"); +} +TEST(FormulonCApi, VersionStringNonEmpty) { + const char* v = fm_version_string(); + ASSERT_NE(v, nullptr); + EXPECT_GT(std::strlen(v), 0U); +} diff --git a/tests/c_api/formulon_c_styles_alignment_test.cpp b/tests/c_api/formulon_c_styles_alignment_test.cpp new file mode 100644 index 000000000..5d8ca4d7b --- /dev/null +++ b/tests/c_api/formulon_c_styles_alignment_test.cpp @@ -0,0 +1,440 @@ +// Stable C ABI alignment and presence-flag tests. + +#include "formulon_c_styles_test_helpers.h" + +TEST(FormulonCApiStyles, CellXfAlignmentEnumsValidateRangesBeforeMutation) { + const auto expect_invalid = [](fm_status_t status, const char* api) { + EXPECT_EQ(status, static_cast(formulon::FormulonErrorCode::kInvalidArgument)); + const char* message = fm_last_error_message(); + ASSERT_NE(message, nullptr); + EXPECT_EQ(std::string(message).rfind(api, 0), 0U) << message; + }; + + // The upper boundary of each ordinal is accepted. + { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf record{}; + record.horizontal_align = 7; // distributed + record.has_horizontal_align = 1; + record.vertical_align = 4; // distributed + record.has_vertical_align = 1; + uint32_t index = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, record, &index), 0); + } + + // The presence flag decides whether a value is read at all, so an + // out-of-range ordinal on an omitted attribute is ignored rather than + // rejected: the record it describes has no such attribute to be invalid. + { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf record{}; + record.horizontal_align = 8; + record.vertical_align = 5; + uint32_t index = 0xAABBCCDDU; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, record, &index), 0); + EXPECT_NE(index, 0xAABBCCDDU); + fm_cell_xf got{}; + ASSERT_EQ(fm_styles_get_cell_xf(wb.handle, index, &got), 0); + EXPECT_EQ(got.horizontal_align, 0U); + EXPECT_EQ(got.vertical_align, 2U); + } + + // Every add shape validates before it creates default roots or changes the + // output index. Keep each case isolated so the size checks cover all paths. + { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf record{}; + record.horizontal_align = 8; + record.has_horizontal_align = 1; + uint32_t index = 0xAABBCCDDU; + const std::size_t before = wb.handle->workbook().styles().cell_xfs.size(); + expect_invalid(fm_styles_add_cell_xf(wb.handle, record, &index), "fm_styles_add_cell_xf:"); + EXPECT_EQ(index, 0xAABBCCDDU); + EXPECT_EQ(wb.handle->workbook().styles().cell_xfs.size(), before); + } + { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf record{}; + record.vertical_align = 5; + record.has_vertical_align = 1; + uint32_t index = 0xAABBCCDDU; + const std::size_t before = wb.handle->workbook().styles().cell_style_xfs.size(); + expect_invalid(fm_styles_add_cell_style_xf(wb.handle, record, &index), "fm_styles_add_cell_style_xf:"); + EXPECT_EQ(index, 0xAABBCCDDU); + EXPECT_EQ(wb.handle->workbook().styles().cell_style_xfs.size(), before); + } + { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf record{}; + record.horizontal_align = 8; + record.has_horizontal_align = 1; + uint32_t indices[] = {0xAABBCCDDU}; + const fm_styles_batch batch{nullptr, 0U, nullptr, nullptr, 0U, nullptr, nullptr, 0U, + nullptr, &record, 1U, indices, nullptr, 0U, nullptr}; + const std::size_t before = wb.handle->workbook().styles().cell_xfs.size(); + expect_invalid(fm_styles_add_batch(wb.handle, &batch), "fm_styles_add_batch:"); + EXPECT_EQ(indices[0], 0xAABBCCDDU); + EXPECT_EQ(wb.handle->workbook().styles().cell_xfs.size(), before); + } + // A batch record naming a `` entry that does not exist is + // rejected rather than emitted as a dangling `xfId`. + { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf record{}; + record.xf_id = 3U; + uint32_t indices[] = {0xAABBCCDDU}; + const fm_styles_batch batch{nullptr, 0U, nullptr, nullptr, 0U, nullptr, nullptr, 0U, + nullptr, &record, 1U, indices, nullptr, 0U, nullptr}; + expect_invalid(fm_styles_add_batch(wb.handle, &batch), "fm_styles_add_batch:"); + EXPECT_EQ(indices[0], 0xAABBCCDDU); + } +} + +TEST(FormulonCApiStyles, AddBatchEmitsAlignmentOnlyForFlaggedAttributes) { + const auto batch_xf_alignment = [](const fm_cell_xf& record) { + WorkbookGuard wb; + EXPECT_EQ(fm_workbook_create(&wb.handle), 0); + uint32_t xf_indices[] = {0xDEADBEEFU}; + const fm_styles_batch batch{nullptr, 0U, nullptr, nullptr, 0U, nullptr, nullptr, 0U, + nullptr, &record, 1U, xf_indices, nullptr, 0U, nullptr}; + EXPECT_EQ(fm_styles_add_batch(wb.handle, &batch), 0) << fm_last_error_message(); + fm_cell_xf got{}; + EXPECT_EQ(fm_styles_get_cell_xf(wb.handle, xf_indices[0], &got), 0); + return got; + }; + + // The pre-collapse caller shape: a value with no presence flag. + fm_cell_xf inferred{}; + inferred.horizontal_align = 3; // center-continuous + const fm_cell_xf without_flag = batch_xf_alignment(inferred); + EXPECT_EQ(without_flag.has_alignment, 0); + EXPECT_EQ(without_flag.has_horizontal_align, 0); + EXPECT_EQ(without_flag.horizontal_align, 0U); + + // The same value, now declared present, is the post-collapse spelling. + fm_cell_xf flagged{}; + flagged.horizontal_align = 3; + flagged.has_horizontal_align = 1; + const fm_cell_xf with_flag = batch_xf_alignment(flagged); + EXPECT_EQ(with_flag.has_alignment, 1); + EXPECT_EQ(with_flag.has_horizontal_align, 1); + EXPECT_EQ(with_flag.horizontal_align, 3U); +} + +TEST(FormulonCApiStyles, ZeroInitializedCellXfUsesDefaultAlignment) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cell_xf record{}; + uint32_t index = 0xFFFFFFFFU; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, record, &index), 0); + EXPECT_EQ(index, 0U); + + fm_cell_xf got{}; + ASSERT_EQ(fm_styles_get_cell_xf(wb.handle, index, &got), 0); + EXPECT_EQ(got.horizontal_align, 0U); + EXPECT_EQ(got.vertical_align, 2U); + EXPECT_EQ(got.wrap_text, 0); + EXPECT_EQ(got.justify_last_line, 0); + EXPECT_EQ(got.has_alignment, 0); + EXPECT_EQ(got.has_horizontal_align, 0); + EXPECT_EQ(got.has_vertical_align, 0); + EXPECT_EQ(got.has_wrap_text, 0); + EXPECT_EQ(got.has_justify_last_line, 0); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + const std::string styles_xml = ExtractStylesXml(saved); + const std::size_t cell_xfs_begin = styles_xml.find("", cell_xfs_begin); + ASSERT_NE(cell_xfs_begin, std::string::npos); + ASSERT_NE(cell_xfs_end, std::string::npos); + EXPECT_EQ(styles_xml.substr(cell_xfs_begin, cell_xfs_end - cell_xfs_begin).find("", cell_xfs_begin); + ASSERT_NE(cell_xfs_begin, std::string::npos); + ASSERT_NE(cell_xfs_end, std::string::npos); + const std::string cell_xfs = styles_xml.substr(cell_xfs_begin, cell_xfs_end - cell_xfs_begin); + EXPECT_NE(cell_xfs.find(""), std::string::npos); +} + +TEST(FormulonCApiStyles, CellXfExplicitTopAlignmentRemainsPresent) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cell_xf record{}; + record.vertical_align = 0; // top + record.has_vertical_align = 1; + uint32_t index = 0xFFFFFFFFU; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, record, &index), 0); + + fm_cell_xf got{}; + ASSERT_EQ(fm_styles_get_cell_xf(wb.handle, index, &got), 0); + EXPECT_EQ(got.vertical_align, 0U); + EXPECT_EQ(got.has_vertical_align, 1); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + const std::string styles_xml = ExtractStylesXml(saved); + const std::size_t cell_xfs_begin = styles_xml.find("", cell_xfs_begin); + ASSERT_NE(cell_xfs_begin, std::string::npos); + ASSERT_NE(cell_xfs_end, std::string::npos); + EXPECT_NE(styles_xml.substr(cell_xfs_begin, cell_xfs_end - cell_xfs_begin).find(""), + std::string::npos); +} + +TEST(FormulonCApiStyles, ZeroInitializedCellStyleXfUsesDefaultAlignment) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cell_xf record{}; + uint32_t index = 0xFFFFFFFFU; + ASSERT_EQ(fm_styles_add_cell_style_xf(wb.handle, record, &index), 0); + + fm_cell_xf got{}; + ASSERT_EQ(fm_styles_get_cell_style_xf(wb.handle, index, &got), 0); + EXPECT_EQ(got.horizontal_align, 0U); + EXPECT_EQ(got.vertical_align, 2U); + EXPECT_EQ(got.wrap_text, 0); + EXPECT_EQ(got.justify_last_line, 0); + EXPECT_EQ(got.has_alignment, 0); + EXPECT_EQ(got.has_vertical_align, 0); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + const std::string styles_xml = ExtractStylesXml(saved); + const std::size_t style_xfs_begin = styles_xml.find("", style_xfs_begin); + ASSERT_NE(style_xfs_begin, std::string::npos); + ASSERT_NE(style_xfs_end, std::string::npos); + EXPECT_EQ(styles_xml.substr(style_xfs_begin, style_xfs_end - style_xfs_begin).find("", cell_xfs_begin); + ASSERT_NE(cell_xfs_begin, std::string::npos); + ASSERT_NE(cell_xfs_end, std::string::npos); + EXPECT_NE(styles_xml.substr(cell_xfs_begin, cell_xfs_end - cell_xfs_begin).find(""), + std::string::npos); +} + +TEST(FormulonCApiStyles, CellXfPreservesOptionalAlignmentAndPresence) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cell_xf xf{}; + xf.vertical_align = 2; + xf.justify_last_line = 1; + xf.xf_id = 0; + xf.has_alignment = 1; + xf.has_text_rotation = 1; + xf.text_rotation = 255; + xf.has_indent = 1; + xf.indent = 7; + xf.has_relative_indent = 1; + xf.relative_indent = -3; + xf.has_shrink_to_fit = 1; + xf.shrink_to_fit = 0; + xf.has_reading_order = 1; + xf.reading_order = 2; + xf.has_horizontal_align = 1; + xf.has_vertical_align = 1; + xf.has_wrap_text = 1; + xf.has_justify_last_line = 1; + + uint32_t index = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &index), 0); + uint32_t duplicate = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &duplicate), 0); + EXPECT_EQ(duplicate, index); + + fm_cell_xf reread{}; + ASSERT_EQ(fm_styles_get_cell_xf(wb.handle, index, &reread), 0); + EXPECT_EQ(reread.justify_last_line, 1); + EXPECT_EQ(reread.xf_id, 0U); + EXPECT_EQ(reread.has_alignment, 1); + EXPECT_EQ(reread.has_text_rotation, 1); + EXPECT_EQ(reread.text_rotation, 255U); + EXPECT_EQ(reread.has_indent, 1); + EXPECT_EQ(reread.indent, 7U); + EXPECT_EQ(reread.has_relative_indent, 1); + EXPECT_EQ(reread.relative_indent, -3); + EXPECT_EQ(reread.has_shrink_to_fit, 1); + EXPECT_EQ(reread.shrink_to_fit, 0); + EXPECT_EQ(reread.has_reading_order, 1); + EXPECT_EQ(reread.reading_order, 2U); + EXPECT_EQ(reread.has_horizontal_align, 1); + EXPECT_EQ(reread.has_vertical_align, 1); + EXPECT_EQ(reread.has_wrap_text, 1); + EXPECT_EQ(reread.has_justify_last_line, 1); + + // Presence is part of the dedup key: explicit zero / false differs from + // an omitted attribute even when the effective value is the same. + fm_cell_xf absent = xf; + absent.has_text_rotation = 0; + absent.has_indent = 0; + absent.has_relative_indent = 0; + absent.has_shrink_to_fit = 0; + absent.has_reading_order = 0; + uint32_t absent_index = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, absent, &absent_index), 0); + EXPECT_NE(absent_index, index); +} + +TEST(FormulonCApiStyles, CellXfRejectsInvalidExcelAlignmentRanges) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf xf{}; + uint32_t index = 0; + + xf.has_text_rotation = 1; + xf.text_rotation = 181; + EXPECT_NE(fm_styles_add_cell_xf(wb.handle, xf, &index), 0); + xf.text_rotation = 255; + EXPECT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &index), 0); + + xf.has_text_rotation = 0; + xf.has_indent = 1; + xf.indent = 256; + EXPECT_NE(fm_styles_add_cell_xf(wb.handle, xf, &index), 0); + xf.indent = 255; + EXPECT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &index), 0); + + xf.has_indent = 0; + xf.has_reading_order = 1; + xf.reading_order = 3; + EXPECT_NE(fm_styles_add_cell_xf(wb.handle, xf, &index), 0); +} + +TEST(FormulonCApiStyles, CellXfPresenceFlagsDistinguishExplicitDefaults) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_cell_xf explicit_defaults{}; + explicit_defaults.vertical_align = 2; + explicit_defaults.has_alignment = 1; + explicit_defaults.has_horizontal_align = 1; + explicit_defaults.has_vertical_align = 1; + explicit_defaults.has_wrap_text = 1; + explicit_defaults.has_justify_last_line = 1; + uint32_t explicit_index = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, explicit_defaults, &explicit_index), 0); + + fm_cell_xf omitted_defaults = explicit_defaults; + omitted_defaults.has_horizontal_align = 0; + omitted_defaults.has_vertical_align = 0; + omitted_defaults.has_wrap_text = 0; + omitted_defaults.has_justify_last_line = 0; + uint32_t omitted_index = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, omitted_defaults, &omitted_index), 0); + EXPECT_NE(explicit_index, omitted_index); + + fm_cell_xf reread{}; + ASSERT_EQ(fm_styles_get_cell_xf(wb.handle, explicit_index, &reread), 0); + EXPECT_EQ(reread.has_horizontal_align, 1); + EXPECT_EQ(reread.has_vertical_align, 1); + EXPECT_EQ(reread.has_wrap_text, 1); + EXPECT_EQ(reread.has_justify_last_line, 1); +} + +TEST(FormulonCApiStyles, CellStyleXfRoundTripsOptionalAlignment) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf style{}; + style.has_text_rotation = 1; + style.text_rotation = 0; + style.has_relative_indent = 1; + style.relative_indent = -2; + style.has_shrink_to_fit = 1; + style.shrink_to_fit = false; + uint32_t style_index = 0; + ASSERT_EQ(fm_styles_add_cell_style_xf(wb.handle, style, &style_index), 0); + + fm_cell_xf reread{}; + ASSERT_EQ(fm_styles_get_cell_style_xf(wb.handle, style_index, &reread), 0); + EXPECT_EQ(reread.has_text_rotation, 1); + EXPECT_EQ(reread.text_rotation, 0U); + EXPECT_EQ(reread.has_relative_indent, 1); + EXPECT_EQ(reread.relative_indent, -2); + EXPECT_EQ(reread.has_shrink_to_fit, 1); + EXPECT_EQ(reread.shrink_to_fit, 0); +} diff --git a/tests/c_api/formulon_c_styles_cell_xf_test.cpp b/tests/c_api/formulon_c_styles_cell_xf_test.cpp new file mode 100644 index 000000000..aae4dc377 --- /dev/null +++ b/tests/c_api/formulon_c_styles_cell_xf_test.cpp @@ -0,0 +1,290 @@ +// Stable C ABI cell XF index and mutation tests. + +#include "formulon_c_styles_test_helpers.h" + +TEST(FormulonCApiStyles, CellXfIndexDefaultsToZero) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + uint32_t xf = 99; + EXPECT_EQ(fm_cell_get_xf_index(wb.handle, 0, 0, 0, &xf), 0); + EXPECT_EQ(xf, 0U); +} + +TEST(FormulonCApiStyles, CellXfIndexRoundTrips) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + EXPECT_EQ(fm_cell_set_xf_index(wb.handle, 0, 5, 7, 42), 0); + uint32_t xf = 0; + EXPECT_EQ(fm_cell_get_xf_index(wb.handle, 0, 5, 7, &xf), 0); + EXPECT_EQ(xf, 42U); +} + +TEST(FormulonCApiStyles, SetXfIndexRejectsBadSheet) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + // sheet 99 does not exist. + EXPECT_NE(fm_cell_set_xf_index(wb.handle, 99, 0, 0, 1), 0); +} + +TEST(FormulonCApiStyles, NullArgumentRejected) { + uint32_t xf = 0; + EXPECT_NE(fm_cell_get_xf_index(nullptr, 0, 0, 0, &xf), 0); + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + EXPECT_NE(fm_cell_get_xf_index(wb.handle, 0, 0, 0, nullptr), 0); +} + +TEST(FormulonCApiStyles, GetCellXfRejectsOutOfRange) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + fm_cell_xf xf{}; + // A fresh workbook seeds one default xf, so index 0 resolves and index + // 1 is the first out-of-range one. + EXPECT_EQ(fm_styles_get_cell_xf(wb.handle, 0, &xf), 0); + EXPECT_NE(fm_styles_get_cell_xf(wb.handle, 1, &xf), 0); +} + +TEST(FormulonCApiStyles, CellXfGetterDiagnosticsUseInvokedApi) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + auto expect_prefix = [](fm_status_t status, const char* prefix) { + EXPECT_NE(status, 0); + const char* message = fm_last_error_message(); + ASSERT_NE(message, nullptr); + EXPECT_EQ(std::string(message).rfind(prefix, 0), 0U) << message; + }; + + // Index 1 is past both seeded single-entry tables, so each getter takes + // its out-of-range path and must name itself in the diagnostic. + fm_cell_xf cell_xf{}; + expect_prefix(fm_styles_get_cell_xf(wb.handle, 1, &cell_xf), "fm_styles_get_cell_xf:"); + expect_prefix(fm_styles_get_cell_style_xf(wb.handle, 1, &cell_xf), "fm_styles_get_cell_style_xf:"); +} + +TEST(FormulonCApiStyles, CellXfAdderDiagnosticsUseInvokedApi) { + auto expect_prefix = [](fm_status_t status, const char* prefix) { + EXPECT_NE(status, 0); + const char* message = fm_last_error_message(); + ASSERT_NE(message, nullptr); + EXPECT_EQ(std::string(message).rfind(prefix, 0), 0U) << message; + }; + + uint32_t index = 0; + fm_cell_xf cell_xf{}; + expect_prefix(fm_styles_add_cell_xf(nullptr, cell_xf, &index), "fm_styles_add_cell_xf:"); + + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + cell_xf.font_index = 1U; + expect_prefix(fm_styles_add_cell_xf(wb.handle, cell_xf, &index), "fm_styles_add_cell_xf:"); + + cell_xf = fm_cell_xf{}; + cell_xf.xf_id = 1U; + expect_prefix(fm_styles_add_cell_xf(wb.handle, cell_xf, &index), "fm_styles_add_cell_xf:"); + + cell_xf = fm_cell_xf{}; + cell_xf.has_text_rotation = 1; + cell_xf.text_rotation = 181U; + expect_prefix(fm_styles_add_cell_xf(wb.handle, cell_xf, &index), "fm_styles_add_cell_xf:"); +} + +TEST(FormulonCApiStyles, SetThenSaveLoadPreservesXfIndex) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + // Register the xf records the stamp below names. A `` against + // a shorter `` resolves to no style, so the reader falls back + // to the default rather than handing back an index whose record + // `fm_styles_get_cell_xf` would then refuse to return. + wb.handle->workbook().mutable_styles().cell_xfs.resize(8); + + // Stamp xf_index = 7 on cell A1 and save. + EXPECT_EQ(fm_workbook_set_number(wb.handle, 0, 0, 0, 3.14), 0); + EXPECT_EQ(fm_cell_set_xf_index(wb.handle, 0, 0, 0, 7), 0); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + ASSERT_GT(saved.len, 0U); + + WorkbookGuard wb2; + ASSERT_EQ(fm_workbook_load(saved.data, saved.len, &wb2.handle), 0); + uint32_t xf = 0; + EXPECT_EQ(fm_cell_get_xf_index(wb2.handle, 0, 0, 0, &xf), 0); + EXPECT_EQ(xf, 7U); +} + +TEST(FormulonCApiStyles, AddCellXfDedupReturnsExistingIndex) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + uint32_t font_idx = 0; + uint32_t fill_idx = 0; + uint32_t border_idx = 0; + ASSERT_EQ(fm_styles_add_font(wb.handle, MakeArial(), &font_idx), 0); + ASSERT_EQ(fm_styles_add_fill(wb.handle, MakeRedFill(), &fill_idx), 0); + ASSERT_EQ(fm_styles_add_border(wb.handle, MakeThinBoxBorder(), &border_idx), 0); + + fm_cell_xf xf{}; + xf.font_index = font_idx; + xf.fill_index = fill_idx; + xf.border_index = border_idx; + xf.num_fmt_id = 0; // built-in General + xf.horizontal_align = 1; + xf.vertical_align = 2; + xf.wrap_text = 1; + + uint32_t a = 0xFFFFFFFFU; + uint32_t b = 0xFFFFFFFFU; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &a), 0); + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &b), 0); + EXPECT_EQ(a, b); +} + +TEST(FormulonCApiStyles, AddCellXfDistinctReturnsNewIndex) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + uint32_t font_idx = 0; + uint32_t fill_idx = 0; + uint32_t border_idx = 0; + ASSERT_EQ(fm_styles_add_font(wb.handle, MakeArial(), &font_idx), 0); + ASSERT_EQ(fm_styles_add_fill(wb.handle, MakeRedFill(), &fill_idx), 0); + ASSERT_EQ(fm_styles_add_border(wb.handle, MakeThinBoxBorder(), &border_idx), 0); + + fm_cell_xf xf{}; + xf.font_index = font_idx; + xf.fill_index = fill_idx; + xf.border_index = border_idx; + xf.num_fmt_id = 0; + xf.horizontal_align = 1; + xf.vertical_align = 2; + xf.wrap_text = 1; + + xf.has_wrap_text = 1; + + uint32_t a = 0; + uint32_t b = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &a), 0); + // Flip wrap_text — distinct record. + xf.wrap_text = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &b), 0); + EXPECT_NE(a, b); +} + +TEST(FormulonCApiStyles, AddCellXfGrowsTable) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + uint32_t font_idx = 0; + uint32_t fill_idx = 0; + uint32_t border_idx = 0; + ASSERT_EQ(fm_styles_add_font(wb.handle, MakeArial(), &font_idx), 0); + ASSERT_EQ(fm_styles_add_fill(wb.handle, MakeRedFill(), &fill_idx), 0); + ASSERT_EQ(fm_styles_add_border(wb.handle, MakeThinBoxBorder(), &border_idx), 0); + uint32_t before = 0; + ASSERT_EQ(fm_styles_get_cell_xf_count(wb.handle, &before), 0); + fm_cell_xf xf{}; + xf.font_index = font_idx; + xf.fill_index = fill_idx; + xf.border_index = border_idx; + xf.num_fmt_id = 0; + // Distinguish this record from the all-zero placeholder xf that + // `ensure_default_cell_xf` seeds at index 0 so the caller's record is + // guaranteed to land at a fresh index instead of deduping to it. The + // presence flag is what makes the value part of the record. + xf.wrap_text = 1; + xf.has_wrap_text = 1; + uint32_t idx = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &idx), 0); + uint32_t after = 0; + ASSERT_EQ(fm_styles_get_cell_xf_count(wb.handle, &after), 0); + // Index 0 is the seeded default xf. + EXPECT_EQ(before, 1U); + EXPECT_EQ(after, 2U); + EXPECT_EQ(idx, 1U); +} + +TEST(FormulonCApiStyles, AddCellXfOnFreshWorkbookKeepsZeroAsDefault) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + uint32_t font_idx = 0; + uint32_t fill_idx = 0; + uint32_t border_idx = 0; + ASSERT_EQ(fm_styles_add_font(wb.handle, MakeArial(), &font_idx), 0); + ASSERT_EQ(fm_styles_add_fill(wb.handle, MakeRedFill(), &fill_idx), 0); + ASSERT_EQ(fm_styles_add_border(wb.handle, MakeThinBoxBorder(), &border_idx), 0); + + // A fresh workbook seeds Excel's reserved slots, so the caller's first + // record lands after them: one default font, one empty border, and the + // two fills (`none`, `gray125`) Excel always writes first. + EXPECT_EQ(font_idx, 1U); + EXPECT_EQ(fill_idx, 2U); + EXPECT_EQ(border_idx, 1U); + + fm_cell_xf xf{}; + xf.font_index = font_idx; + xf.fill_index = fill_idx; + xf.border_index = border_idx; + xf.num_fmt_id = 0; + // Distinguish this record from the all-zero placeholder xf that + // `ensure_default_cell_xf` seeds at index 0 so the caller's record is + // guaranteed to land at a fresh index instead of deduping to it. The + // presence flag is what makes the value part of the record. + xf.wrap_text = 1; + xf.has_wrap_text = 1; + + uint32_t xf_idx = 0; + ASSERT_EQ(fm_styles_add_cell_xf(wb.handle, xf, &xf_idx), 0); + EXPECT_NE(xf_idx, 0U); + + fm_cell_xf default_xf{}; + ASSERT_EQ(fm_styles_get_cell_xf(wb.handle, 0, &default_xf), 0); + EXPECT_EQ(default_xf.font_index, 0U); + EXPECT_EQ(default_xf.fill_index, 0U); + EXPECT_EQ(default_xf.border_index, 0U); + EXPECT_EQ(default_xf.num_fmt_id, 0U); + EXPECT_EQ(default_xf.wrap_text, 0); +} + +TEST(FormulonCApiStyles, AddCellXfRejectsOutOfRangeIndex) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + // Empty styles table: any non-zero index is out of range. + fm_cell_xf xf{}; + xf.font_index = 5; // intentionally OOR + xf.fill_index = 0; + xf.border_index = 0; + xf.num_fmt_id = 0; + uint32_t idx = 0xFFFFFFFFU; + EXPECT_NE(fm_styles_add_cell_xf(wb.handle, xf, &idx), 0); +} + +TEST(FormulonCApiStyles, AddCellXfRejectsUnknownNumFmt) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + uint32_t font_idx = 0; + uint32_t fill_idx = 0; + uint32_t border_idx = 0; + ASSERT_EQ(fm_styles_add_font(wb.handle, MakeArial(), &font_idx), 0); + ASSERT_EQ(fm_styles_add_fill(wb.handle, MakeRedFill(), &fill_idx), 0); + ASSERT_EQ(fm_styles_add_border(wb.handle, MakeThinBoxBorder(), &border_idx), 0); + + fm_cell_xf xf{}; + xf.font_index = font_idx; + xf.fill_index = fill_idx; + xf.border_index = border_idx; + xf.num_fmt_id = 200; // not a registered custom and not a documented built-in + uint32_t idx = 0xFFFFFFFFU; + EXPECT_NE(fm_styles_add_cell_xf(wb.handle, xf, &idx), 0); +} + +TEST(FormulonCApiStyles, AddNullArgumentRejected) { + uint32_t idx = 0; + EXPECT_NE(fm_styles_add_font(nullptr, MakeArial(), &idx), 0); + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + EXPECT_NE(fm_styles_add_font(wb.handle, MakeArial(), nullptr), 0); + uint16_t id = 0; + EXPECT_NE(fm_styles_add_num_fmt(nullptr, "General", &id), 0); + EXPECT_NE(fm_styles_add_num_fmt(wb.handle, "General", nullptr), 0); + EXPECT_NE(fm_styles_add_dxf(nullptr, fm_dxf_record{}, &idx), 0); + EXPECT_NE(fm_styles_add_dxf(wb.handle, fm_dxf_record{}, nullptr), 0); +} diff --git a/tests/c_api/formulon_c_styles_dxf_test.cpp b/tests/c_api/formulon_c_styles_dxf_test.cpp new file mode 100644 index 000000000..992e636da --- /dev/null +++ b/tests/c_api/formulon_c_styles_dxf_test.cpp @@ -0,0 +1,210 @@ +// Stable C ABI differential-format tests. + +#include "formulon_c_styles_test_helpers.h" + +TEST(FormulonCApiStyles, DifferentialFormatGetterExposesCfDxfTable) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + formulon::StylesTable& styles = wb.handle->workbook().mutable_styles(); + formulon::DifferentialFormat dxf; + dxf.has_font = true; + dxf.font.bold = true; + dxf.font.color_argb = 0xFFFF0000U; + dxf.has_fill = true; + dxf.fill.pattern = 1; + dxf.fill.fg_argb = 0xFFFFFF00U; + dxf.has_border = true; + dxf.border.left.style = 1; + dxf.border.left.color_argb = 0xFF000000U; + dxf.has_num_fmt = true; + dxf.num_fmt_id = 164; + dxf.num_fmt_code = "0.00"; + styles.dxfs.push_back(dxf); + + uint32_t count = 0; + ASSERT_EQ(fm_styles_get_dxf_count(wb.handle, &count), 0); + EXPECT_EQ(count, 1U); + + fm_dxf_record out{}; + ASSERT_EQ(fm_styles_get_dxf(wb.handle, 0, &out), 0); + EXPECT_EQ(out.font_engaged, 1); + EXPECT_EQ(out.font.bold, 1); + EXPECT_EQ(out.font.color_argb, 0xFFFF0000U); + EXPECT_EQ(out.fill_engaged, 1); + EXPECT_EQ(out.fill.pattern, 1U); + EXPECT_EQ(out.fill.fg_argb, 0xFFFFFF00U); + EXPECT_EQ(out.border_engaged, 1); + EXPECT_EQ(out.border.left.style, 1U); + EXPECT_EQ(out.border.left.color_argb, 0xFF000000U); + EXPECT_EQ(out.num_fmt_engaged, 1); + EXPECT_EQ(out.num_fmt_id, 164U); + ASSERT_NE(out.num_fmt_code, nullptr); + EXPECT_STREQ(out.num_fmt_code, "0.00"); + + EXPECT_NE(fm_styles_get_dxf(wb.handle, 1, &out), 0); +} + +TEST(FormulonCApiStyles, AddDxfDedupsAndReadsBack) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_dxf_record dxf{}; + dxf.font_engaged = 1; + dxf.font.name = "Arial"; + dxf.font.size = 12.0; + dxf.font.bold = 1; + dxf.font.color_argb = 0xFFFF0000U; + dxf.fill_engaged = 1; + dxf.fill.pattern = 1; + dxf.fill.fg_argb = 0xFFFFFF00U; + dxf.num_fmt_engaged = 1; + dxf.num_fmt_id = 164; + dxf.num_fmt_code = "0.00"; + + uint32_t a = 0xFFFFFFFFU; + uint32_t b = 0xFFFFFFFFU; + ASSERT_EQ(fm_styles_add_dxf(wb.handle, dxf, &a), 0); + ASSERT_EQ(fm_styles_add_dxf(wb.handle, dxf, &b), 0); + EXPECT_EQ(a, b); + + uint32_t count = 0; + ASSERT_EQ(fm_styles_get_dxf_count(wb.handle, &count), 0); + EXPECT_EQ(count, 1U); + + fm_dxf_record out{}; + ASSERT_EQ(fm_styles_get_dxf(wb.handle, a, &out), 0); + EXPECT_EQ(out.font_engaged, 1); + EXPECT_STREQ(out.font.name, "Arial"); + EXPECT_EQ(out.font.bold, 1); + EXPECT_EQ(out.font.color_argb, 0xFFFF0000U); + EXPECT_EQ(out.fill_engaged, 1); + EXPECT_EQ(out.fill.pattern, 1U); + EXPECT_EQ(out.fill.fg_argb, 0xFFFFFF00U); + EXPECT_EQ(out.num_fmt_engaged, 1); + EXPECT_EQ(out.num_fmt_id, 164U); + ASSERT_NE(out.num_fmt_code, nullptr); + EXPECT_STREQ(out.num_fmt_code, "0.00"); +} + +TEST(FormulonCApiStyles, DxfFontRoundTripsVerticalAlignmentThroughOoxml) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_dxf_record dxf{}; + dxf.font_engaged = 1; + dxf.font.name = "Calibri"; + dxf.font.size = 9.0; + dxf.font.color_argb = 0xFF112233U; + dxf.font.vert_align = 1; // superscript + uint32_t index = 0xFFFFFFFFU; + ASSERT_EQ(fm_styles_add_dxf(wb.handle, dxf, &index), 0); + + const std::string xml = formulon::io::write_styles(wb.handle->workbook().styles()); + EXPECT_NE(xml.find(""), std::string::npos); + + fm_dxf_record loaded{}; + ASSERT_EQ(fm_styles_get_dxf(wb.handle, index, &loaded), 0); + EXPECT_EQ(loaded.font.vert_align, 1U); + + uint32_t count_before = 0; + ASSERT_EQ(fm_styles_get_dxf_count(wb.handle, &count_before), 0); + uint32_t reindex = 0xFFFFFFFFU; + ASSERT_EQ(fm_styles_add_dxf(wb.handle, loaded, &reindex), 0); + EXPECT_EQ(reindex, index); + uint32_t count_after = 0; + ASSERT_EQ(fm_styles_get_dxf_count(wb.handle, &count_after), 0); + EXPECT_EQ(count_after, count_before); +} + +TEST(FormulonCApiStyles, DxfAlignmentAndProtectionPreserveIdentityThroughOoxml) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + std::string alignment_input = ""; + std::string protection_input = "