From c2781c181e0ea5d4519ed80a6cdf5a2a6c890170 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 12 Sep 2026 09:26:08 +0200 Subject: [PATCH 1/3] feat(api): a table position spelling reads without throwing `TablePosition::try_to_column_num` and `try_to_row_num` answer an optional, so a parser that must not throw on bad input can use them; the throwing pair is written on top of them. Both fold case, because a spreadsheet reads a column letter without it, and `to_column_num` no longer wraps silently on a spelling past the index range. The ascii character classes the parsers wrote each for themselves (`is_ascii_digit`, `is_ascii_letter`, `is_ascii_letter_or_digit`, `to_upper`) join `util::string`, and rtf, the odf value cursor and the odf geometry parsers call them there. `is_ascii_space` is `is_ascii_whitespace`, which is what it tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VVmjmddv2Ui17Nptc1ggue --- CHANGELOG.md | 4 ++ .../internal/odf/odf_enhanced_geometry.cpp | 12 ++-- src/odr/internal/odf/odf_geometry.cpp | 12 ++-- src/odr/internal/odf/odf_value_cursor.hpp | 25 +++----- src/odr/internal/rtf/rtf_tokenizer.cpp | 22 +++---- src/odr/internal/util/string_util.cpp | 62 +++++++++++------- src/odr/internal/util/string_util.hpp | 33 ++++++---- src/odr/table_position.cpp | 63 +++++++++++++------ src/odr/table_position.hpp | 7 +++ test/src/table_position_test.cpp | 10 +++ 10 files changed, 162 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7895480f0..e013f4f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- `TablePosition::try_to_column_num` / `try_to_row_num` read a column or row + spelling as an optional instead of throwing, and without case. **Fix**: + `to_column_num` wrapped silently past the index range instead of refusing. + - **Breaking**: the codes the page reports through `odr.onError` and `odr.onEditRefused` moved from 1 to 9 onto 1001 to 1009. `readOnly` is 1005, not 5; the `reason` string is unchanged. diff --git a/src/odr/internal/odf/odf_enhanced_geometry.cpp b/src/odr/internal/odf/odf_enhanced_geometry.cpp index 751ec050a..24c196129 100644 --- a/src/odr/internal/odf/odf_enhanced_geometry.cpp +++ b/src/odr/internal/odf/odf_enhanced_geometry.cpp @@ -13,6 +13,8 @@ namespace odr::internal::odf { +namespace str = util::string; + namespace { /// Recursive descent over 20.36's grammar: sums of products of unary terms, @@ -104,7 +106,7 @@ class FormulaParser : private ValueCursor { if (peek() == '$') { take(); - const std::string_view digits = take_while(is_digit); + const std::string_view digits = take_while(str::is_ascii_digit); std::size_t index = 0; const std::from_chars_result read = std::from_chars(digits.data(), digits.data() + digits.size(), index); @@ -117,18 +119,18 @@ class FormulaParser : private ValueCursor { if (peek() == '?') { take(); - const std::string_view name = take_while(is_letter_or_digit); + const std::string_view name = take_while(str::is_ascii_letter_or_digit); if (name.empty()) { return {}; } return (*m_equations)(name); } - if (peek() == '.' || is_digit(peek())) { + if (peek() == '.' || str::is_ascii_digit(peek())) { return read_number(); } - const std::string_view name = take_while(is_letter_or_digit); + const std::string_view name = take_while(str::is_ascii_letter_or_digit); if (name.empty()) { return {}; } @@ -274,7 +276,7 @@ class EnhancedPathParser : private ValueCursor { skip_separators(); if (peek() == '$' || peek() == '?') { const char kind = take(); - const std::string_view name = take_while(is_letter_or_digit); + const std::string_view name = take_while(str::is_ascii_letter_or_digit); if (name.empty()) { return {}; } diff --git a/src/odr/internal/odf/odf_geometry.cpp b/src/odr/internal/odf/odf_geometry.cpp index 6e10eafe2..5b3ef1311 100644 --- a/src/odr/internal/odf/odf_geometry.cpp +++ b/src/odr/internal/odf/odf_geometry.cpp @@ -22,6 +22,8 @@ namespace odr::internal::odf { +namespace str = util::string; + namespace { /// The square a shape with no view box of its own is drawn into; the size is @@ -86,7 +88,9 @@ class TransformParser : private ValueCursor { /// is seen, which keeps a list of pure rotations unitless. std::string m_unit; - [[nodiscard]] std::string_view read_name() { return take_while(is_letter); } + [[nodiscard]] std::string_view read_name() { + return take_while(str::is_ascii_letter); + } /// Reduced to centimetres. [[nodiscard]] std::optional read_length() { @@ -94,8 +98,8 @@ class TransformParser : private ValueCursor { if (!value.has_value()) { return {}; } - const std::string_view unit = - take_while([](const char c) { return is_letter(c) || c == '%'; }); + const std::string_view unit = take_while( + [](const char c) { return str::is_ascii_letter(c) || c == '%'; }); // A zero needs no unit. if (unit.empty()) { @@ -640,7 +644,7 @@ odf::read_hundredth_millimetres(const pugi::xml_attribute attribute) { } in.skip_space(); const double scale = - odf::centimetres_per(in.take_while(odf::ValueCursor::is_letter)); + odf::centimetres_per(in.take_while(str::is_ascii_letter)); if (scale == 0) { return {}; } diff --git a/src/odr/internal/odf/odf_value_cursor.hpp b/src/odr/internal/odf/odf_value_cursor.hpp index 79c0c9b91..6ad08cb5c 100644 --- a/src/odr/internal/odf/odf_value_cursor.hpp +++ b/src/odr/internal/odf/odf_value_cursor.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -8,6 +10,8 @@ namespace odr::internal::odf { +namespace str = util::string; + /// A cursor over one of the small languages an odf attribute is written in. /// Reads are bounded by what remains, which carries no terminator. class ValueCursor { @@ -32,14 +36,14 @@ class ValueCursor { /// Whitespace only: a comma separates the arguments of a formula. void skip_space() { - while (is_space(peek())) { + while (str::is_ascii_whitespace(peek())) { m_rest.remove_prefix(1); } } /// Whitespace and the commas a coordinate list may be written with. void skip_separators() { - while (is_space(peek()) || peek() == ',') { + while (str::is_ascii_whitespace(peek()) || peek() == ',') { m_rest.remove_prefix(1); } } @@ -88,25 +92,14 @@ class ValueCursor { [[nodiscard]] bool starts_number() const { const char c = peek(); - return c == '-' || c == '+' || c == '.' || is_digit(c); - } - - static bool is_letter(const char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); - } - static bool is_digit(const char c) { return c >= '0' && c <= '9'; } - static bool is_letter_or_digit(const char c) { - return is_letter(c) || is_digit(c); + return c == '-' || c == '+' || c == '.' || str::is_ascii_digit(c); } private: - static bool is_space(const char c) { - return c == ' ' || c == '\t' || c == '\r' || c == '\n'; - } /// A superset of a number's characters, to bound the run `std::strtod` reads. static bool is_number_char(const char c) { - return is_digit(c) || c == '+' || c == '-' || c == '.' || c == 'e' || - c == 'E'; + return str::is_ascii_digit(c) || c == '+' || c == '-' || c == '.' || + c == 'e' || c == 'E'; } std::string_view m_rest; diff --git a/src/odr/internal/rtf/rtf_tokenizer.cpp b/src/odr/internal/rtf/rtf_tokenizer.cpp index 268bd6d8d..a538874af 100644 --- a/src/odr/internal/rtf/rtf_tokenizer.cpp +++ b/src/odr/internal/rtf/rtf_tokenizer.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include #include @@ -8,15 +10,9 @@ namespace odr::internal::rtf { -namespace { +namespace str = util::string; -/// Only the ascii letters open a control word (*Control Word*); the locale -/// must not widen that. -bool is_letter(const char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); -} - -bool is_digit(const char c) { return c >= '0' && c <= '9'; } +namespace { std::uint8_t hex_char_to_int(const char c) { if (c >= '0' && c <= '9') { @@ -115,7 +111,7 @@ Token Tokenizer::read_control() { throw std::runtime_error("rtf: trailing backslash"); } - if (const auto c = static_cast(i); !is_letter(c)) { + if (const auto c = static_cast(i); !str::is_ascii_letter(c)) { bumpc(); if (c == '\'') { const char_type first = bumpc(); @@ -129,7 +125,8 @@ Token Tokenizer::read_control() { std::string name; while (true) { const int_type letter = geti(); - if (letter == eof || !is_letter(static_cast(letter))) { + if (letter == eof || + !str::is_ascii_letter(static_cast(letter))) { break; } name.push_back(bumpc()); @@ -142,7 +139,7 @@ Token Tokenizer::read_control() { std::optional parameter; if (const int_type delimiter = geti(); delimiter != eof) { const auto d = static_cast(delimiter); - if (d == '-' || is_digit(d)) { + if (d == '-' || str::is_ascii_digit(d)) { const bool negative = d == '-'; if (negative) { bumpc(); @@ -151,7 +148,8 @@ Token Tokenizer::read_control() { std::size_t digits = 0; while (digits < max_parameter_digits) { const int_type digit = geti(); - if (digit == eof || !is_digit(static_cast(digit))) { + if (digit == eof || + !str::is_ascii_digit(static_cast(digit))) { break; } value = value * 10 + (bumpc() - '0'); diff --git a/src/odr/internal/util/string_util.cpp b/src/odr/internal/util/string_util.cpp index 21a3611d1..963422622 100644 --- a/src/odr/internal/util/string_util.cpp +++ b/src/odr/internal/util/string_util.cpp @@ -22,14 +22,28 @@ bool string::ends_with(const std::string &string, const std::string &with) { return string.ends_with(with); } -bool string::is_ascii_space(const char c) { +bool string::is_ascii_whitespace(const char c) { return std::isspace(static_cast(c)) != 0; } +bool string::is_ascii_digit(const char c) { return c >= '0' && c <= '9'; } + +bool string::is_ascii_letter(const char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} + +bool string::is_ascii_letter_or_digit(const char c) { + return is_ascii_letter(c) || is_ascii_digit(c); +} + char string::to_lower(const char c) { return static_cast(std::tolower(static_cast(c))); } +char string::to_upper(const char c) { + return static_cast(std::toupper(static_cast(c))); +} + std::string string::to_lower(const std::string_view string) { std::string result; result.reserve(string.size()); @@ -68,57 +82,61 @@ std::size_t string::find_ignore_case(const std::string_view string, return from + static_cast(found.begin() - rest.begin()); } -void string::ltrim_inplace(std::string &s, const CharPredicate is_space) { - s.erase(s.begin(), std::ranges::find_if(s, [is_space](const char ch) { - return !is_space(ch); +void string::ltrim_inplace(std::string &s, const CharPredicate is_whitespace) { + s.erase(s.begin(), std::ranges::find_if(s, [is_whitespace](const char ch) { + return !is_whitespace(ch); })); } -void string::rtrim_inplace(std::string &s, const CharPredicate is_space) { - s.erase(std::find_if(s.rbegin(), s.rend(), - [is_space](const char ch) { return !is_space(ch); }) +void string::rtrim_inplace(std::string &s, const CharPredicate is_whitespace) { + s.erase(std::find_if( + s.rbegin(), s.rend(), + [is_whitespace](const char ch) { return !is_whitespace(ch); }) .base(), s.end()); } -void string::trim_inplace(std::string &s, const CharPredicate is_space) { - rtrim_inplace(s, is_space); - ltrim_inplace(s, is_space); +void string::trim_inplace(std::string &s, const CharPredicate is_whitespace) { + rtrim_inplace(s, is_whitespace); + ltrim_inplace(s, is_whitespace); } -std::string string::ltrim(const std::string &s, const CharPredicate is_space) { - return std::string(ltrim_view(s, is_space)); +std::string string::ltrim(const std::string &s, + const CharPredicate is_whitespace) { + return std::string(ltrim_view(s, is_whitespace)); } -std::string string::rtrim(const std::string &s, const CharPredicate is_space) { - return std::string(rtrim_view(s, is_space)); +std::string string::rtrim(const std::string &s, + const CharPredicate is_whitespace) { + return std::string(rtrim_view(s, is_whitespace)); } -std::string string::trim(const std::string &s, const CharPredicate is_space) { - return std::string(trim_view(s, is_space)); +std::string string::trim(const std::string &s, + const CharPredicate is_whitespace) { + return std::string(trim_view(s, is_whitespace)); } std::string_view string::ltrim_view(std::string_view s, - const CharPredicate is_space) { + const CharPredicate is_whitespace) { std::size_t begin = 0; - while (begin < s.size() && is_space(s[begin])) { + while (begin < s.size() && is_whitespace(s[begin])) { ++begin; } return s.substr(begin); } std::string_view string::rtrim_view(std::string_view s, - const CharPredicate is_space) { + const CharPredicate is_whitespace) { std::size_t end = s.size(); - while (end > 0 && is_space(s[end - 1])) { + while (end > 0 && is_whitespace(s[end - 1])) { --end; } return s.substr(0, end); } std::string_view string::trim_view(std::string_view s, - const CharPredicate is_space) { - return ltrim_view(rtrim_view(s, is_space), is_space); + const CharPredicate is_whitespace) { + return ltrim_view(rtrim_view(s, is_whitespace), is_whitespace); } void string::replace_all(std::string &string, const std::string &search, diff --git a/src/odr/internal/util/string_util.hpp b/src/odr/internal/util/string_util.hpp index 25de90777..3f862a4fe 100644 --- a/src/odr/internal/util/string_util.hpp +++ b/src/odr/internal/util/string_util.hpp @@ -17,10 +17,17 @@ bool ends_with(const std::string &string, const std::string &with); using CharPredicate = bool (*)(char); /// `std::isspace` for the default C locale, made safe for any `char` value. -bool is_ascii_space(char c); +bool is_ascii_whitespace(char c); -/// `std::tolower` for the default C locale, made safe for any `char` value. +/// The ascii classes a parser asks for, which no locale may widen. +bool is_ascii_digit(char c); +bool is_ascii_letter(char c); +bool is_ascii_letter_or_digit(char c); + +/// `std::tolower` / `std::toupper` for the default C locale, made safe for any +/// `char` value. char to_lower(char c); +char to_upper(char c); std::string to_lower(std::string_view string); /// The comparisons below fold case with @ref to_lower, so only ascii letters @@ -32,24 +39,28 @@ bool starts_with_ignore_case(std::string_view string, std::string_view prefix); std::size_t find_ignore_case(std::string_view string, std::string_view needle, std::size_t from = 0); -void ltrim_inplace(std::string &s, CharPredicate is_space = is_ascii_space); -void rtrim_inplace(std::string &s, CharPredicate is_space = is_ascii_space); -void trim_inplace(std::string &s, CharPredicate is_space = is_ascii_space); +void ltrim_inplace(std::string &s, + CharPredicate is_whitespace = is_ascii_whitespace); +void rtrim_inplace(std::string &s, + CharPredicate is_whitespace = is_ascii_whitespace); +void trim_inplace(std::string &s, + CharPredicate is_whitespace = is_ascii_whitespace); std::string ltrim(const std::string &s, - CharPredicate is_space = is_ascii_space); + CharPredicate is_whitespace = is_ascii_whitespace); std::string rtrim(const std::string &s, - CharPredicate is_space = is_ascii_space); -std::string trim(const std::string &s, CharPredicate is_space = is_ascii_space); + CharPredicate is_whitespace = is_ascii_whitespace); +std::string trim(const std::string &s, + CharPredicate is_whitespace = is_ascii_whitespace); /// Trim and return a subrange of `s`, so the leading offset is recoverable as /// `result.data() - s.data()`. std::string_view ltrim_view(std::string_view s, - CharPredicate is_space = is_ascii_space); + CharPredicate is_whitespace = is_ascii_whitespace); std::string_view rtrim_view(std::string_view s, - CharPredicate is_space = is_ascii_space); + CharPredicate is_whitespace = is_ascii_whitespace); std::string_view trim_view(std::string_view s, - CharPredicate is_space = is_ascii_space); + CharPredicate is_whitespace = is_ascii_whitespace); void replace_all(std::string &string, const std::string &search, const std::string &replace); diff --git a/src/odr/table_position.cpp b/src/odr/table_position.cpp index cc1f55f72..1c1783e60 100644 --- a/src/odr/table_position.cpp +++ b/src/odr/table_position.cpp @@ -1,47 +1,74 @@ #include #include +#include #include #include namespace odr { -std::uint32_t TablePosition::to_column_num(const std::string &string) { +namespace { + +constexpr std::uint64_t index_limit = std::numeric_limits::max(); + +} // namespace + +/// Bijective base 26, the digits 1-26. +std::optional +TablePosition::try_to_column_num(const std::string_view string) { if (string.empty()) { - throw std::invalid_argument("s is empty"); + return {}; } - - std::uint32_t result = 0; + std::uint64_t result = 0; for (const char c : string) { - if (c < 'A' || c > 'Z') { - throw std::invalid_argument("illegal character in \"" + string + "\""); + const char letter = internal::util::string::to_upper(c); + if (letter < 'A' || letter > 'Z') { + return {}; + } + result = result * 26 + static_cast(letter - 'A' + 1); + if (result > index_limit) { + return {}; } - result = result * 26 + static_cast(c - 'A' + 1); } - return result - 1; + return static_cast(result - 1); } -/// @param string the 1-based row number, as written in a cell reference. -std::uint32_t TablePosition::to_row_num(const std::string &string) { +std::optional +TablePosition::try_to_row_num(const std::string_view string) { if (string.empty()) { - throw std::invalid_argument("s is empty"); + return {}; } - std::uint64_t result = 0; for (const char c : string) { - if (c < '0' || c > '9') { - throw std::invalid_argument("illegal character in \"" + string + "\""); + if (!internal::util::string::is_ascii_digit(c)) { + return {}; } result = result * 10 + static_cast(c - '0'); - if (result > std::numeric_limits::max()) { - throw std::invalid_argument("row out of range in \"" + string + "\""); + if (result > index_limit) { + return {}; } } if (result == 0) { - throw std::invalid_argument("row is not 1-based in \"" + string + "\""); + return {}; + } + return static_cast(result - 1); +} + +std::uint32_t TablePosition::to_column_num(const std::string &string) { + if (const std::optional column = try_to_column_num(string); + column.has_value()) { + return *column; + } + throw std::invalid_argument("no column in \"" + string + "\""); +} + +std::uint32_t TablePosition::to_row_num(const std::string &string) { + if (const std::optional row = try_to_row_num(string); + row.has_value()) { + return *row; } - return static_cast(result) - 1; + throw std::invalid_argument("no row in \"" + string + "\""); } std::string TablePosition::to_column_string(const std::uint32_t column) { diff --git a/src/odr/table_position.hpp b/src/odr/table_position.hpp index f6743e7a7..4a87d8455 100644 --- a/src/odr/table_position.hpp +++ b/src/odr/table_position.hpp @@ -2,12 +2,19 @@ #include #include +#include #include +#include namespace odr { /// A cell by column and row, and the spreadsheet spelling of one: `B3`. struct TablePosition final { + /// Nothing where @p string is no index: empty, a character the axis is not + /// written in, a row that is not 1-based, or past the grid. Case is folded. + static std::optional try_to_column_num(std::string_view); + static std::optional try_to_row_num(std::string_view); + /// @throws std::invalid_argument where the pair above answers nothing. static std::uint32_t to_column_num(const std::string &string); static std::uint32_t to_row_num(const std::string &string); static std::string to_column_string(std::uint32_t column); diff --git a/test/src/table_position_test.cpp b/test/src/table_position_test.cpp index 2bfe8358e..df7ab84f9 100644 --- a/test/src/table_position_test.cpp +++ b/test/src/table_position_test.cpp @@ -20,6 +20,16 @@ TEST(TablePosition, direct) { EXPECT_EQ("C2", tp.to_string()); } +TEST(TablePosition, a_column_letter_is_read_without_case) { + EXPECT_EQ(TablePosition::try_to_column_num("aa"), 26); +} + +TEST(TablePosition, a_spelling_past_the_index_range_is_nothing) { + EXPECT_FALSE(TablePosition::try_to_column_num("ABCDEFGHI").has_value()); + EXPECT_FALSE(TablePosition::try_to_row_num("99999999999").has_value()); + EXPECT_FALSE(TablePosition::try_to_row_num("0").has_value()); +} + TEST(TablePosition, string1) { const std::string input = "A1"; const TablePosition tp(input); From fb57154849a9ec54cd5d9aeedc50d2cf9b91418e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 12 Sep 2026 09:49:14 +0200 Subject: [PATCH 2/3] refactor: one cursor over the small languages a string holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `internal::TextCursor` holds what the odf value cursor and the formula parser each wrote for themselves: `peek`, `take`, `advance`, `seek`, `skip_whitespace`, `consume` and the `take_while` over a character predicate. `odf::ValueCursor` keeps only what an odf attribute adds — the comma as a separator, and the number `std::strtod` reads out of a copied run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VVmjmddv2Ui17Nptc1ggue --- AGENTS.md | 2 +- src/odr/internal/common/text_cursor.hpp | 87 +++++++++++++++++++ .../internal/odf/odf_enhanced_geometry.cpp | 12 +-- src/odr/internal/odf/odf_geometry.cpp | 2 +- src/odr/internal/odf/odf_value_cursor.hpp | 66 ++------------ 5 files changed, 102 insertions(+), 67 deletions(-) create mode 100644 src/odr/internal/common/text_cursor.hpp diff --git a/AGENTS.md b/AGENTS.md index 9c1dce478..56fbf1e2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ producer's layout recorded — odf's `text:soft-page-break` — are not parsed. |------|------| | `src/odr/*.hpp` | **Public API**: `file`, `document`, `document_element`, `html`, `style`, `quantity` (`Measure`), `odr`. | | `src/odr/internal/abstract/` | Core interfaces: `File`/`DecodedFile`, `Document` + `ElementAdapter`, `Filesystem`, `Archive`, `HtmlService`. | -| `src/odr/internal/common/` | Reusable impls: `Path`/`AbsPath`, base `Document`, the shared `ElementRegistry` + `ElementAdapter`, filesystem, `style`, table cursor/range, temp files. | +| `src/odr/internal/common/` | Reusable impls: `Path`/`AbsPath`, base `Document`, the shared `ElementRegistry` + `ElementAdapter`, filesystem, `style`, table cursor/range, `TextCursor`, temp files. | | `src/odr/internal/util/` | Helpers: `byte_stream_util`, `string_util`, `stream_util`, `document_util`. | | `src/odr/internal/magic.*`, `open_strategy.*` | File-type detection + open/dispatch. | | `src/odr/internal/file_type_table.*` | **The** per-`FileType` table: extensions, MIME types, category, document type, `FileTypeCapabilities`. Every public lookup in `odr.hpp` is a thin forward into it — extend the table, not the lookups. | diff --git a/src/odr/internal/common/text_cursor.hpp b/src/odr/internal/common/text_cursor.hpp new file mode 100644 index 000000000..a1e13422e --- /dev/null +++ b/src/odr/internal/common/text_cursor.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include + +#include +#include + +namespace odr::internal { + +/// A cursor over a small language written into one string — an odf attribute, +/// a formula. Reads are bounded by what remains, which carries no terminator. +class TextCursor { +public: + explicit TextCursor(const std::string_view input) : m_rest{input} {} + + [[nodiscard]] bool empty() const { return m_rest.empty(); } + + /// What the cursor has not read, which @ref seek takes back. + [[nodiscard]] std::string_view rest() const { return m_rest; } + + /// The character @p ahead of the cursor, or `\0` past the end. + [[nodiscard]] char peek(const std::size_t ahead = 0) const { + return ahead < m_rest.size() ? m_rest[ahead] : '\0'; + } + + /// The next character, consumed. + char take() { + const char c = peek(); + if (!m_rest.empty()) { + advance(1); + } + return c; + } + + void advance(const std::size_t count) { m_rest.remove_prefix(count); } + + /// Back to a view @ref rest answered earlier, undoing what was read since. + void seek(const std::string_view at) { m_rest = at; } + + void skip_whitespace() { + while (util::string::is_ascii_whitespace(peek())) { + advance(1); + } + } + + /// Whitespace ahead of it is filler; the text itself is the token. + [[nodiscard]] bool consume(const char c) { + skip_whitespace(); + if (peek() != c) { + return false; + } + advance(1); + return true; + } + + [[nodiscard]] bool consume(const std::string_view text) { + skip_whitespace(); + if (!m_rest.starts_with(text)) { + return false; + } + advance(text.size()); + return true; + } + + /// The leading run of characters @p accept admits, left in place. + [[nodiscard]] std::string_view + peek_while(const util::string::CharPredicate accept) const { + std::size_t length = 0; + while (length < m_rest.size() && accept(m_rest[length])) { + ++length; + } + return m_rest.substr(0, length); + } + + /// The same run, consumed. + [[nodiscard]] std::string_view + take_while(const util::string::CharPredicate accept) { + const std::string_view taken = peek_while(accept); + advance(taken.size()); + return taken; + } + +private: + std::string_view m_rest; +}; + +} // namespace odr::internal diff --git a/src/odr/internal/odf/odf_enhanced_geometry.cpp b/src/odr/internal/odf/odf_enhanced_geometry.cpp index 24c196129..a9c024705 100644 --- a/src/odr/internal/odf/odf_enhanced_geometry.cpp +++ b/src/odr/internal/odf/odf_enhanced_geometry.cpp @@ -28,7 +28,7 @@ class FormulaParser : private ValueCursor { [[nodiscard]] std::optional parse() { const std::optional value = expression(); - skip_space(); + skip_whitespace(); if (!value.has_value() || !empty()) { return {}; } @@ -42,7 +42,7 @@ class FormulaParser : private ValueCursor { [[nodiscard]] std::optional expression() { std::optional result = term(); while (result.has_value()) { - skip_space(); + skip_whitespace(); const char op = peek(); if (op != '+' && op != '-') { break; @@ -60,7 +60,7 @@ class FormulaParser : private ValueCursor { [[nodiscard]] std::optional term() { std::optional result = unary(); while (result.has_value()) { - skip_space(); + skip_whitespace(); const char op = peek(); if (op != '*' && op != '/') { break; @@ -79,7 +79,7 @@ class FormulaParser : private ValueCursor { } [[nodiscard]] std::optional unary() { - skip_space(); + skip_whitespace(); if (peek() == '-') { take(); const std::optional value = unary(); @@ -93,7 +93,7 @@ class FormulaParser : private ValueCursor { } [[nodiscard]] std::optional primary() { - skip_space(); + skip_whitespace(); if (peek() == '(') { take(); @@ -134,7 +134,7 @@ class FormulaParser : private ValueCursor { if (name.empty()) { return {}; } - skip_space(); + skip_whitespace(); return peek() == '(' ? function(name) : named(name); } diff --git a/src/odr/internal/odf/odf_geometry.cpp b/src/odr/internal/odf/odf_geometry.cpp index 5b3ef1311..3e3f40694 100644 --- a/src/odr/internal/odf/odf_geometry.cpp +++ b/src/odr/internal/odf/odf_geometry.cpp @@ -642,7 +642,7 @@ odf::read_hundredth_millimetres(const pugi::xml_attribute attribute) { if (!value.has_value()) { return {}; } - in.skip_space(); + in.skip_whitespace(); const double scale = odf::centimetres_per(in.take_while(str::is_ascii_letter)); if (scale == 0) { diff --git a/src/odr/internal/odf/odf_value_cursor.hpp b/src/odr/internal/odf/odf_value_cursor.hpp index 6ad08cb5c..0ad404d88 100644 --- a/src/odr/internal/odf/odf_value_cursor.hpp +++ b/src/odr/internal/odf/odf_value_cursor.hpp @@ -1,80 +1,30 @@ #pragma once +#include #include #include #include #include #include -#include namespace odr::internal::odf { namespace str = util::string; /// A cursor over one of the small languages an odf attribute is written in. -/// Reads are bounded by what remains, which carries no terminator. -class ValueCursor { +class ValueCursor : public TextCursor { public: - explicit ValueCursor(const std::string_view input) : m_rest{input} {} + using TextCursor::TextCursor; - [[nodiscard]] bool empty() const { return m_rest.empty(); } - - /// The next character, or `\0` where the input ended. - [[nodiscard]] char peek() const { - return m_rest.empty() ? '\0' : m_rest.front(); - } - - /// The next character, consumed. - char take() { - const char c = peek(); - if (!m_rest.empty()) { - m_rest.remove_prefix(1); - } - return c; - } - - /// Whitespace only: a comma separates the arguments of a formula. - void skip_space() { - while (str::is_ascii_whitespace(peek())) { - m_rest.remove_prefix(1); - } - } - - /// Whitespace and the commas a coordinate list may be written with. + /// Whitespace and the commas a coordinate list may be written with. @ref + /// consume leaves a comma, which a formula separates its arguments with. void skip_separators() { while (str::is_ascii_whitespace(peek()) || peek() == ',') { - m_rest.remove_prefix(1); + advance(1); } } - /// Only spaces are skipped ahead of @p c: a comma is an argument separator - /// where a formula is concerned, not filler. - [[nodiscard]] bool consume(const char c) { - skip_space(); - if (peek() != c) { - return false; - } - m_rest.remove_prefix(1); - return true; - } - - /// The leading run of characters @p accept admits, left in place. - [[nodiscard]] std::string_view peek_while(bool (*accept)(char)) const { - std::size_t length = 0; - while (length < m_rest.size() && accept(m_rest[length])) { - ++length; - } - return m_rest.substr(0, length); - } - - /// The same run, consumed. - [[nodiscard]] std::string_view take_while(bool (*accept)(char)) { - const std::string_view taken = peek_while(accept); - m_rest.remove_prefix(taken.size()); - return taken; - } - /// `std::strtod` wants a terminator, which the view does not promise, so the /// run it bounds is copied out. [[nodiscard]] std::optional read_number() { @@ -86,7 +36,7 @@ class ValueCursor { return {}; } // `strtod` may stop short of the run, on a trailing `e` say - m_rest.remove_prefix(static_cast(end - number.c_str())); + advance(static_cast(end - number.c_str())); return value; } @@ -101,8 +51,6 @@ class ValueCursor { return str::is_ascii_digit(c) || c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E'; } - - std::string_view m_rest; }; } // namespace odr::internal::odf From 2d046679f8cbacbe3e30f2703e43c152c9a4b938 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 12 Sep 2026 09:49:24 +0200 Subject: [PATCH 3/3] feat(formula): one AST for both spreadsheet formula syntaxes `internal/formula` parses what a `table:formula` and an `` state into one tree. The two syntaxes share their expression grammar, so one recursive descent takes a `Syntax` and branches where they part: the reference (`[Sheet1.A1:.B2]` against `Sheet1!A1:B2`), the argument separator and the row separator of an array. References, ranges, sheet-qualified references and named expressions are read; nothing resolves a name or evaluates anything. A formula that does not parse answers nothing, so a caller reads no reference out of one it cannot read. A spelling past the grid is a name rather than a position, which is also what `A0` and `ABCDEFGHI1` are. Step 3.1 of `docs/design/spreadsheet-editing.md`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VVmjmddv2Ui17Nptc1ggue --- AGENTS.md | 1 + CMakeLists.txt | 2 + docs/design/spreadsheet-editing.md | 12 +- src/odr/internal/formula/formula_ast.hpp | 171 +++++ src/odr/internal/formula/formula_parser.cpp | 690 ++++++++++++++++++ src/odr/internal/formula/formula_parser.hpp | 22 + test/CMakeLists.txt | 1 + .../internal/formula/formula_parser_test.cpp | 413 +++++++++++ 8 files changed, 1307 insertions(+), 5 deletions(-) create mode 100644 src/odr/internal/formula/formula_ast.hpp create mode 100644 src/odr/internal/formula/formula_parser.cpp create mode 100644 src/odr/internal/formula/formula_parser.hpp create mode 100644 test/src/internal/formula/formula_parser_test.cpp diff --git a/AGENTS.md b/AGENTS.md index 56fbf1e2f..0ae78e667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,7 @@ producer's layout recorded — odf's `text:soft-page-break` — are not parsed. | `src/odr/internal/util/` | Helpers: `byte_stream_util`, `string_util`, `stream_util`, `document_util`. | | `src/odr/internal/magic.*`, `open_strategy.*` | File-type detection + open/dispatch. | | `src/odr/internal/file_type_table.*` | **The** per-`FileType` table: extensions, MIME types, category, document type, `FileTypeCapabilities`. Every public lookup in `odr.hpp` is a thin forward into it — extend the table, not the lookups. | +| `src/odr/internal/formula/` | Spreadsheet formulas: one AST, parsed from both OpenFormula and OOXML. Format-agnostic, and shared by the odf and ooxml engines. | | `src/odr/internal/html/` | Generic HTML renderer. | | `src/odr/internal/html/frontend/` | The stylesheets and scripts the renderer writes into the page, as the files a browser reads. `cmake/frontend_assets.cmake` embeds them into the library; `frontend.cpp` names them and decides which view writes which. | | `src/odr/internal/cfb/`, `zip/` | Container formats (CFB, ZIP). | diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e9909a8a..cbcc97432 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -188,6 +188,8 @@ set(ODR_SOURCE_FILES "src/odr/internal/csv/csv_file.cpp" "src/odr/internal/csv/csv_util.cpp" + "src/odr/internal/formula/formula_parser.cpp" + "src/odr/internal/html/common.cpp" "src/odr/internal/html/document.cpp" "src/odr/internal/html/document_style.cpp" diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index e8d55d8c6..ec82b709f 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -1,6 +1,6 @@ # Spreadsheet editing design -Status: **steps 0, 1 and 2 landed; step 3 is next.** This +Status: **steps 0, 1 and 2 landed; step 3 is under way.** This records why spreadsheet editing is staged the way it is, what the code already gives us, and the order the steps go in. It is a plan, not a record — update it as steps land. @@ -461,10 +461,12 @@ Each step ships on its own. "Both" means `.ods` and `.xlsx`. ### Step 3 — Formulas, read side -1. Parse both syntaxes into one AST: OpenFormula (`of:=SUM([.A1:.B2])`, - `table:formula`) and OOXML (`SUM(A1:B2)`, ``, shared and array - formulas). References, ranges, sheet-qualified references, named ranges - left as opaque. +1. **Landed.** `internal/formula` parses both syntaxes into one AST: + OpenFormula (`of:=SUM([.A1:.B2])`, `table:formula`) and OOXML + (`SUM(A1:B2)`, ``). One recursive descent takes a `Syntax` and branches + where the two part. A named expression, a reference over several sheets and + a spelling past the grid (`A0`) stay opaque names; a formula that does not + parse answers nothing. 2. Reference extraction → dependency graph per document; `Document` answers "which cells depend on this position". 3. View: a commit marks dependents stale (a class, the host is told); the diff --git a/src/odr/internal/formula/formula_ast.hpp b/src/odr/internal/formula/formula_ast.hpp new file mode 100644 index 000000000..619f40e70 --- /dev/null +++ b/src/odr/internal/formula/formula_ast.hpp @@ -0,0 +1,171 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace odr::internal::formula { + +/// The error values a formula states and computes. [OpenFormula] 4.3, +/// [ECMA-376] 18.17.2.4. +enum class ErrorType { + null, ///< `#NULL!` + division, ///< `#DIV/0!` + value, ///< `#VALUE!` + reference, ///< `#REF!` + name, ///< `#NAME?` + number, ///< `#NUM!` + not_available, ///< `#N/A` +}; + +enum class UnaryOperator { + plus, + minus, + percent, ///< postfix, and the only one +}; + +enum class BinaryOperator { + add, + subtract, + multiply, + divide, + power, + concat, + equal, + not_equal, + less, + less_equal, + greater, + greater_equal, + range, ///< `A1:B2` where the two sides are not one token + intersect, ///< `!` in OpenFormula, a space in ooxml, which is not parsed + unite, ///< `~` in OpenFormula, `,` in ooxml +}; + +/// One axis of a reference: the index the file states, and its `$`. +struct Coordinate final { + std::uint32_t index{0}; + bool absolute{false}; + + friend bool operator==(const Coordinate &, const Coordinate &) = default; +}; + +struct NumberLiteral final { + double value{0}; + + friend bool operator==(const NumberLiteral &, + const NumberLiteral &) = default; +}; + +struct StringLiteral final { + std::string value; + + friend bool operator==(const StringLiteral &, + const StringLiteral &) = default; +}; + +struct BooleanLiteral final { + bool value{false}; + + friend bool operator==(const BooleanLiteral &, + const BooleanLiteral &) = default; +}; + +struct ErrorLiteral final { + ErrorType type{ErrorType::null}; + + friend bool operator==(const ErrorLiteral &, const ErrorLiteral &) = default; +}; + +/// One cell. An unstated sheet is the one the formula sits on, and an unstated +/// axis a whole column or row, which only a range spells (`A:A`, `1:1`). +struct CellReference final { + std::optional document{}; + std::optional sheet{}; + bool sheet_absolute{false}; + std::optional column{}; + std::optional row{}; + + friend bool operator==(const CellReference &, + const CellReference &) = default; +}; + +/// `A1:B2`, as the two corners the file spells. The second may name its own +/// sheet. +struct RangeReference final { + CellReference from; + CellReference to; + + friend bool operator==(const RangeReference &, + const RangeReference &) = default; +}; + +/// A named expression, left opaque: nothing here resolves what it stands for. +struct NameReference final { + std::optional document{}; + std::optional sheet{}; + std::string name{}; + + friend bool operator==(const NameReference &, + const NameReference &) = default; +}; + +/// The arguments are the node's children. +struct FunctionCall final { + std::string name; + + friend bool operator==(const FunctionCall &, const FunctionCall &) = default; +}; + +/// The operand is the node's only child. +struct UnaryOperation final { + UnaryOperator op{UnaryOperator::plus}; + + friend bool operator==(const UnaryOperation &, + const UnaryOperation &) = default; +}; + +/// The two operands are the node's children. +struct BinaryOperation final { + BinaryOperator op{BinaryOperator::add}; + + friend bool operator==(const BinaryOperation &, + const BinaryOperation &) = default; +}; + +/// An argument the formula leaves out: the second of `IF(A1,,B1)`. +struct Missing final { + friend bool operator==(const Missing &, const Missing &) = default; +}; + +/// `{1;2|3;4}` in OpenFormula, `{1,2;3,4}` in ooxml. The elements are the +/// node's children, row by row. +struct ArrayLiteral final { + std::uint32_t columns{0}; + std::uint32_t rows{0}; + + friend bool operator==(const ArrayLiteral &, const ArrayLiteral &) = default; +}; + +/// One node of a parsed formula. What it holds says what its children are. +struct Node final { + using Content = + std::variant; + + Content content; + std::vector children; + + template [[nodiscard]] bool holds() const noexcept { + return std::holds_alternative(content); + } + /// @throws std::bad_variant_access where the node holds something else. + template [[nodiscard]] const T &get() const { + return std::get(content); + } +}; + +} // namespace odr::internal::formula diff --git a/src/odr/internal/formula/formula_parser.cpp b/src/odr/internal/formula/formula_parser.cpp new file mode 100644 index 000000000..f02e5d399 --- /dev/null +++ b/src/odr/internal/formula/formula_parser.cpp @@ -0,0 +1,690 @@ +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace odr::internal::formula { + +namespace str = util::string; + +namespace { + +/// What a function name, a sheet name, a named expression and a cell +/// reference are all spelled out of. `_xlfn.FLOOR.MATH` is one run of these. +bool is_name_char(const char c) { + return str::is_ascii_letter_or_digit(c) || c == '_' || c == '.' || c == '$' || + c == '\\'; +} + +/// `$A$1`, `A1`, and the half a whole column or row states: `A`, `1`. Takes +/// from @p text what it reads and leaves the rest. +std::optional take_coordinates(std::string_view &text, + CellReference reference) { + const auto character = [&text](const std::size_t i) { + return i < text.size() ? text[i] : '\0'; + }; + const bool column_absolute = character(0) == '$'; + const std::size_t first = column_absolute ? 1 : 0; + std::size_t letters = first; + while (str::is_ascii_letter(character(letters))) { + ++letters; + } + const bool row_absolute = + letters > first ? character(letters) == '$' : column_absolute; + std::size_t after = letters + (letters > first && row_absolute ? 1 : 0); + std::size_t digits = after; + while (str::is_ascii_digit(character(digits))) { + ++digits; + } + + if (letters > first) { + const std::optional column = + TablePosition::try_to_column_num(text.substr(first, letters - first)); + if (!column.has_value()) { + return {}; + } + reference.column = Coordinate{*column, column_absolute}; + } + if (digits > after) { + const std::optional row = + TablePosition::try_to_row_num(text.substr(after, digits - after)); + if (!row.has_value()) { + return {}; + } + reference.row = Coordinate{*row, row_absolute}; + } else if (letters > first) { + // a column on its own: `$A` states nothing about a row + digits = letters; + } + if (!reference.column.has_value() && !reference.row.has_value()) { + return {}; + } + text.remove_prefix(digits); + return reference; +} + +struct ErrorSpelling final { + std::string_view text; + ErrorType type; +}; + +constexpr std::array error_spellings{{ + {"#DIV/0!", ErrorType::division}, + {"#VALUE!", ErrorType::value}, + {"#NAME?", ErrorType::name}, + {"#NULL!", ErrorType::null}, + {"#NUM!", ErrorType::number}, + {"#REF!", ErrorType::reference}, + {"#N/A", ErrorType::not_available}, +}}; + +Node make(Node::Content content, std::vector children = {}) { + return Node{std::move(content), std::move(children)}; +} + +Node make_unary(const UnaryOperator op, Node operand) { + std::vector children; + children.push_back(std::move(operand)); + return make(UnaryOperation{op}, std::move(children)); +} + +std::optional make_binary(const BinaryOperator op, Node left, + std::optional right) { + if (!right.has_value()) { + return {}; + } + std::vector children; + children.push_back(std::move(left)); + children.push_back(std::move(*right)); + return make(BinaryOperation{op}, std::move(children)); +} + +/// Recursive descent over the grammar the two syntaxes share. A production +/// that does not parse answers nothing, and the whole parse fails with it. +class Parser final : private TextCursor { +public: + Parser(const std::string_view input, const Syntax syntax) + : TextCursor{input}, m_syntax{syntax} {} + + [[nodiscard]] std::optional parse() { + std::optional node = expression(); + skip_whitespace(); + if (!node.has_value() || !empty()) { + return {}; + } + return node; + } + +private: + Syntax m_syntax{Syntax::ooxml}; + + [[nodiscard]] std::string_view take_name() { + return take_while(is_name_char); + } + + /// The argument separator of the dialect: OpenFormula writes `;`, ooxml `,`. + [[nodiscard]] char separator() const { + return m_syntax == Syntax::opendocument ? ';' : ','; + } + + [[nodiscard]] std::optional expression() { return comparison(); } + + [[nodiscard]] std::optional comparison() { + std::optional left = concatenation(); + while (left.has_value()) { + BinaryOperator op{}; + if (consume("<>")) { + op = BinaryOperator::not_equal; + } else if (consume("<=")) { + op = BinaryOperator::less_equal; + } else if (consume(">=")) { + op = BinaryOperator::greater_equal; + } else if (consume('=')) { + op = BinaryOperator::equal; + } else if (consume('<')) { + op = BinaryOperator::less; + } else if (consume('>')) { + op = BinaryOperator::greater; + } else { + break; + } + left = make_binary(op, std::move(*left), concatenation()); + } + return left; + } + + [[nodiscard]] std::optional concatenation() { + std::optional left = additive(); + while (left.has_value() && consume('&')) { + left = make_binary(BinaryOperator::concat, std::move(*left), additive()); + } + return left; + } + + [[nodiscard]] std::optional additive() { + std::optional left = multiplicative(); + while (left.has_value()) { + skip_whitespace(); + BinaryOperator op{}; + if (peek() == '+') { + op = BinaryOperator::add; + } else if (peek() == '-') { + op = BinaryOperator::subtract; + } else { + break; + } + advance(1); + left = make_binary(op, std::move(*left), multiplicative()); + } + return left; + } + + [[nodiscard]] std::optional multiplicative() { + std::optional left = power(); + while (left.has_value()) { + skip_whitespace(); + BinaryOperator op{}; + if (peek() == '*') { + op = BinaryOperator::multiply; + } else if (peek() == '/') { + op = BinaryOperator::divide; + } else { + break; + } + advance(1); + left = make_binary(op, std::move(*left), power()); + } + return left; + } + + /// `-2^2` is 4: a sign binds tighter than the power, as it does in a sheet. + [[nodiscard]] std::optional power() { + std::optional left = unary(); + while (left.has_value() && consume('^')) { + left = make_binary(BinaryOperator::power, std::move(*left), unary()); + } + return left; + } + + [[nodiscard]] std::optional unary() { + skip_whitespace(); + if (peek() == '-' || peek() == '+') { + const UnaryOperator op = + peek() == '-' ? UnaryOperator::minus : UnaryOperator::plus; + advance(1); + std::optional operand = unary(); + if (!operand.has_value()) { + return {}; + } + return make_unary(op, std::move(*operand)); + } + return postfix(); + } + + [[nodiscard]] std::optional postfix() { + std::optional node = reference_expression(); + while (node.has_value() && consume('%')) { + node = make_unary(UnaryOperator::percent, std::move(*node)); + } + return node; + } + + /// The reference operators, tighter than everything above them. A range is + /// usually one token; this joins the two halves a formula spells apart. + [[nodiscard]] std::optional reference_expression() { + std::optional left = primary(); + while (left.has_value()) { + skip_whitespace(); + BinaryOperator op{}; + if (peek() == ':') { + op = BinaryOperator::range; + } else if (m_syntax == Syntax::opendocument && peek() == '!') { + op = BinaryOperator::intersect; + } else if (m_syntax == Syntax::opendocument && peek() == '~') { + op = BinaryOperator::unite; + } else { + break; + } + advance(1); + left = make_binary(op, std::move(*left), primary()); + } + return left; + } + + [[nodiscard]] std::optional primary() { + skip_whitespace(); + const char c = peek(); + if (c == '(') { + return group(); + } + if (c == '{') { + return array(); + } + if (c == '"') { + return string_literal(); + } + if (c == '#') { + return error_literal(); + } + if (str::is_ascii_digit(c) || (c == '.' && str::is_ascii_digit(peek(1)))) { + return number_literal(); + } + if (m_syntax == Syntax::opendocument) { + return opendocument_primary(); + } + return ooxml_primary(); + } + + /// A parenthesised expression, and in ooxml the union a comma inside it + /// writes: `SUM((A1:A2,B1:B2))`. + [[nodiscard]] std::optional group() { + if (!consume('(')) { + return {}; + } + std::optional node = expression(); + while (node.has_value() && m_syntax == Syntax::ooxml && consume(',')) { + node = make_binary(BinaryOperator::unite, std::move(*node), expression()); + } + if (!node.has_value() || !consume(')')) { + return {}; + } + return node; + } + + [[nodiscard]] std::optional array() { + if (!consume('{')) { + return {}; + } + const char row_separator = m_syntax == Syntax::opendocument ? '|' : ';'; + std::vector elements; + std::uint32_t columns = 0; + std::uint32_t rows = 0; + std::uint32_t in_row = 0; + while (true) { + std::optional element = expression(); + if (!element.has_value()) { + return {}; + } + elements.push_back(std::move(*element)); + ++in_row; + if (consume(separator())) { + continue; + } + if (columns != 0 && columns != in_row) { + return {}; + } + columns = in_row; + in_row = 0; + ++rows; + if (consume(row_separator)) { + continue; + } + break; + } + if (!consume('}')) { + return {}; + } + return make(ArrayLiteral{columns, rows}, std::move(elements)); + } + + [[nodiscard]] std::optional string_literal() { + std::optional text = quoted('"'); + if (!text.has_value()) { + return {}; + } + return make(StringLiteral{std::move(*text)}); + } + + /// A quoted run, a doubled quote standing for the quote itself. The cursor + /// does not move where the quote never closes. + [[nodiscard]] std::optional quoted(const char quote) { + if (peek() != quote) { + return {}; + } + const std::string_view run = rest(); + std::string text; + std::size_t at = 1; + while (at < run.size()) { + const char c = run[at]; + if (c != quote) { + text += c; + ++at; + continue; + } + if (at + 1 < run.size() && run[at + 1] == quote) { + text += quote; + at += 2; + continue; + } + advance(at + 1); + return text; + } + return {}; + } + + [[nodiscard]] std::optional error_literal() { + for (const ErrorSpelling &spelling : error_spellings) { + if (rest().starts_with(spelling.text)) { + advance(spelling.text.size()); + return make(ErrorLiteral{spelling.type}); + } + } + return {}; + } + + /// Digits, one `.`, an exponent. The decimal separator is the `.` both + /// syntaxes state, whatever the document's locale shows. + [[nodiscard]] std::optional number_literal() { + std::size_t length = 0; + while (str::is_ascii_digit(peek(length))) { + ++length; + } + if (peek(length) == '.') { + ++length; + while (str::is_ascii_digit(peek(length))) { + ++length; + } + } + if (peek(length) == 'e' || peek(length) == 'E') { + std::size_t exponent = length + 1; + if (peek(exponent) == '+' || peek(exponent) == '-') { + ++exponent; + } + if (str::is_ascii_digit(peek(exponent))) { + while (str::is_ascii_digit(peek(exponent))) { + ++exponent; + } + length = exponent; + } + } + // `strtod` wants a terminator, which the view does not promise + const std::string text(rest().substr(0, length)); + char *end = nullptr; + const double value = std::strtod(text.c_str(), &end); + if (end != text.c_str() + text.size()) { + return {}; + } + advance(length); + return make(NumberLiteral{value}); + } + + /// `[.A1]`, `[Sheet1.A1:.B2]`, `[#REF!]`, `$$Name`, `SUM(`, `TRUE`. + [[nodiscard]] std::optional opendocument_primary() { + if (peek() == '[') { + return opendocument_reference(); + } + if (peek() == '$' && peek(1) == '$') { + advance(2); + if (const std::optional quoted_name = quoted('\''); + quoted_name.has_value()) { + return make(NameReference{.name = *quoted_name}); + } + const std::string_view name = take_name(); + if (name.empty()) { + return {}; + } + return make(NameReference{.name = std::string(name)}); + } + const std::string_view name = take_name(); + if (name.empty()) { + return {}; + } + if (peek() == '(') { + return function_call(std::string(name)); + } + const bool boolean_true = str::equals_ignore_case(name, "TRUE"); + if (boolean_true || str::equals_ignore_case(name, "FALSE")) { + return make(BooleanLiteral{boolean_true}); + } + return make(NameReference{.name = std::string(name)}); + } + + /// What `[` encloses: one locator, or two around a `:`, or the error a + /// deleted target left behind. + [[nodiscard]] std::optional opendocument_reference() { + if (!consume('[')) { + return {}; + } + skip_whitespace(); + if (peek() == '#') { + std::optional error = error_literal(); + if (!error.has_value() || !consume(']')) { + return {}; + } + return error; + } + const std::optional from = opendocument_locator(); + if (!from.has_value()) { + return {}; + } + if (consume(':')) { + const std::optional to = opendocument_locator(); + if (!to.has_value() || !consume(']')) { + return {}; + } + return make(RangeReference{*from, *to}); + } + if (!consume(']')) { + return {}; + } + if (!from->column.has_value() || !from->row.has_value()) { + return {}; + } + return make(*from); + } + + /// `['file:///x.ods'#$Sheet1.A1]`: the document, the sheet and the cell, + /// every part of it optional but the cell's own `.`. + [[nodiscard]] std::optional opendocument_locator() { + CellReference reference; + skip_whitespace(); + if (peek() == '\'') { + const std::optional text = quoted('\''); + if (!text.has_value()) { + return {}; + } + if (consume('#')) { + reference.document = *text; + } else { + reference.sheet = *text; + } + } + if (!reference.sheet.has_value()) { + if (peek() == '$') { + reference.sheet_absolute = true; + advance(1); + } + if (peek() == '\'') { + const std::optional text = quoted('\''); + if (!text.has_value()) { + return {}; + } + reference.sheet = *text; + } else if (const std::string_view name = take_sheet_name(); + !name.empty()) { + reference.sheet = std::string(name); + } + } + if (!consume('.')) { + return {}; + } + std::string_view text = rest(); + const std::optional read = + take_coordinates(text, std::move(reference)); + seek(text); + return read; + } + + /// An unquoted sheet name, which ends at the `.` in front of the cell. + [[nodiscard]] std::string_view take_sheet_name() { + return take_while( + [](const char c) { return c != '.' && c != ':' && c != ']'; }); + } + + /// `[1]Sheet1!A1:B2`, `'My Sheet'!A1`, `SUM(`, `TRUE`, a named expression. + [[nodiscard]] std::optional ooxml_primary() { + std::optional document; + if (peek() == '[') { + const std::size_t close = rest().find(']'); + if (close == std::string_view::npos) { + return {}; + } + document = std::string(rest().substr(1, close - 1)); + advance(close + 1); + // `[1]!Name` names the other workbook itself, with no sheet between + if (peek() == '!') { + advance(1); + } + } + const std::optional sheet = ooxml_sheet(); + if (peek() == '#') { + return error_literal(); + } + + const std::string_view name = take_name(); + if (name.empty()) { + return {}; + } + if (peek() == '(' && !sheet.has_value()) { + return function_call(std::string(name)); + } + + std::string_view rest = name; + const std::optional from = take_coordinates( + rest, CellReference{.document = document, .sheet = sheet}); + const bool whole = from.has_value() && rest.empty(); + const bool complete = + whole && from->column.has_value() && from->row.has_value(); + + if (whole && peek() == ':') { + advance(1); + std::string_view tail = take_name(); + const std::optional to = + take_coordinates(tail, CellReference{}); + if (!to.has_value() || !tail.empty() || + from->column.has_value() != to->column.has_value() || + from->row.has_value() != to->row.has_value()) { + return {}; + } + return make(RangeReference{*from, *to}); + } + if (complete) { + return make(*from); + } + const bool boolean_true = str::equals_ignore_case(name, "TRUE"); + if (!sheet.has_value() && !document.has_value() && + (boolean_true || str::equals_ignore_case(name, "FALSE"))) { + return make(BooleanLiteral{boolean_true}); + } + return make(NameReference{ + .document = document, .sheet = sheet, .name = std::string(name)}); + } + + /// The `Sheet1!` a reference may carry. A span over several sheets + /// (`'A B':'C D'!`) stays the spelling the file states, quotes and all. + [[nodiscard]] std::optional ooxml_sheet() { + const std::string_view start = rest(); + const std::optional first = quoted('\''); + std::string spelled; + if (first.has_value()) { + spelled = "'" + *first + "'"; + } else if (const std::string_view name = take_name(); !name.empty()) { + spelled = std::string(name); + } else { + seek(start); + return {}; + } + if (peek() == '!') { + advance(1); + return first.has_value() ? *first : spelled; + } + if (peek() == ':') { + advance(1); + std::string span = spelled + ":"; + if (const std::optional second = quoted('\''); + second.has_value()) { + span += "'" + *second + "'"; + } else if (const std::string_view name = take_name(); !name.empty()) { + span += std::string(name); + } else { + seek(start); + return {}; + } + if (peek() == '!') { + advance(1); + return span; + } + } + seek(start); + return {}; + } + + [[nodiscard]] std::optional function_call(std::string name) { + if (!consume('(')) { + return {}; + } + std::vector arguments; + if (consume(')')) { + return make(FunctionCall{std::move(name)}, std::move(arguments)); + } + while (true) { + skip_whitespace(); + if (peek() == separator() || peek() == ')') { + arguments.push_back(make(Missing{})); + } else { + std::optional argument = expression(); + if (!argument.has_value()) { + return {}; + } + arguments.push_back(std::move(*argument)); + } + if (consume(separator())) { + continue; + } + if (consume(')')) { + break; + } + return {}; + } + return make(FunctionCall{std::move(name)}, std::move(arguments)); + } +}; + +/// The `of:` a `table:formula` carries, and the `=` both may. The prefix is +/// the producer's namespace, so it is whatever name it declared. +std::string_view strip_prefix(std::string_view formula) { + if (const std::size_t assign = formula.find(":="); + assign != std::string_view::npos && + std::ranges::all_of(formula.substr(0, assign), [](const char c) { + return str::is_ascii_letter(c) || str::is_ascii_digit(c) || c == '_' || + c == '-'; + })) { + return formula.substr(assign + 2); + } + if (formula.starts_with('=')) { + return formula.substr(1); + } + return formula; +} + +} // namespace + +} // namespace odr::internal::formula + +namespace odr::internal { + +std::optional formula::parse(const std::string_view formula, + const formula::Syntax syntax) { + return formula::Parser(formula::strip_prefix(formula), syntax).parse(); +} + +} // namespace odr::internal diff --git a/src/odr/internal/formula/formula_parser.hpp b/src/odr/internal/formula/formula_parser.hpp new file mode 100644 index 000000000..f55d80038 --- /dev/null +++ b/src/odr/internal/formula/formula_parser.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include +#include + +namespace odr::internal::formula { + +/// The syntax a formula is written in. The two share their expression +/// grammar and differ over how they spell a reference and separate arguments. +enum class Syntax { + opendocument, ///< OpenFormula, as `table:formula` states it + ooxml, ///< the expression an `` holds +}; + +/// Parses @p formula, with or without the `of:=` prefix. Nothing where it does +/// not parse, so a caller reads no reference out of a formula it cannot read. +[[nodiscard]] std::optional parse(std::string_view formula, + Syntax syntax); + +} // namespace odr::internal::formula diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 2c2d21f61..b9f61c053 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -61,6 +61,7 @@ add_executable(odr_test "src/internal/csv/csv_file_test.cpp" "src/internal/encoding/text_encoding_test.cpp" + "src/internal/formula/formula_parser_test.cpp" "src/internal/svg/svg_file_test.cpp" "src/internal/svg/svg_writer_test.cpp" "src/internal/xml/xml_file_test.cpp" diff --git a/test/src/internal/formula/formula_parser_test.cpp b/test/src/internal/formula/formula_parser_test.cpp new file mode 100644 index 000000000..faaeeb365 --- /dev/null +++ b/test/src/internal/formula/formula_parser_test.cpp @@ -0,0 +1,413 @@ +#include +#include + +#include + +#include +#include + +using namespace odr::internal::formula; + +namespace { + +std::optional odf(const std::string &text) { + return parse(text, Syntax::opendocument); +} + +std::optional ooxml(const std::string &text) { + return parse(text, Syntax::ooxml); +} + +CellReference cell_of(const Node &node) { return node.get(); } + +CellReference at(const std::uint32_t column, const std::uint32_t row) { + CellReference reference; + reference.column = Coordinate{column, false}; + reference.row = Coordinate{row, false}; + return reference; +} + +} // namespace + +TEST(FormulaParser, a_number_is_read_with_its_exponent) { + const std::optional node = ooxml("1.25e-3"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_DOUBLE_EQ(node->get().value, 1.25e-3); +} + +TEST(FormulaParser, a_doubled_quote_is_one_quote_in_a_string) { + const std::optional node = ooxml(R"("a""b")"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().value, "a\"b"); +} + +TEST(FormulaParser, a_bare_word_is_a_boolean_where_it_names_one) { + const std::optional node = ooxml("TRUE"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_TRUE(node->get().value); +} + +TEST(FormulaParser, an_error_is_read_by_its_spelling) { + const std::optional node = ooxml("#NAME?"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().type, ErrorType::name); +} + +TEST(FormulaParser, an_ooxml_cell_reference_is_a_position) { + const std::optional node = ooxml("B3"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(cell_of(*node), at(1, 2)); +} + +TEST(FormulaParser, the_dollars_of_a_reference_are_kept) { + const std::optional node = ooxml("$B$3"); + + ASSERT_TRUE(node.has_value()); + const CellReference reference = cell_of(*node); + ASSERT_TRUE(reference.column.has_value()); + ASSERT_TRUE(reference.row.has_value()); + EXPECT_EQ(reference.column->index, 1); + EXPECT_TRUE(reference.column->absolute); + EXPECT_EQ(reference.row->index, 2); + EXPECT_TRUE(reference.row->absolute); +} + +TEST(FormulaParser, a_column_letter_is_read_without_case) { + const std::optional node = ooxml("aa1"); + + ASSERT_TRUE(node.has_value()); + EXPECT_EQ(cell_of(*node), at(26, 0)); +} + +TEST(FormulaParser, an_ooxml_range_states_both_corners) { + const std::optional node = ooxml("A1:B2"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().from, at(0, 0)); + EXPECT_EQ(node->get().to, at(1, 1)); +} + +TEST(FormulaParser, a_whole_column_states_no_row) { + const std::optional node = ooxml("A:A"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + const RangeReference range = node->get(); + ASSERT_TRUE(range.from.column.has_value()); + EXPECT_EQ(range.from.column->index, 0); + EXPECT_FALSE(range.from.row.has_value()); + EXPECT_FALSE(range.to.row.has_value()); +} + +TEST(FormulaParser, an_ooxml_reference_takes_its_sheet_name) { + const std::optional node = ooxml("'My Sheet'!A1:B2"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + const RangeReference range = node->get(); + ASSERT_TRUE(range.from.sheet.has_value()); + EXPECT_EQ(*range.from.sheet, "My Sheet"); + EXPECT_FALSE(range.to.sheet.has_value()); +} + +TEST(FormulaParser, an_external_ooxml_reference_states_its_document) { + const std::optional node = ooxml("[1]Sheet1!A1"); + + ASSERT_TRUE(node.has_value()); + const CellReference reference = cell_of(*node); + ASSERT_TRUE(reference.document.has_value()); + EXPECT_EQ(*reference.document, "1"); + ASSERT_TRUE(reference.sheet.has_value()); + EXPECT_EQ(*reference.sheet, "Sheet1"); +} + +TEST(FormulaParser, a_reference_over_several_sheets_keeps_its_spelling) { + const std::optional node = ooxml("Sheet1:Sheet3!A1"); + + ASSERT_TRUE(node.has_value()); + const CellReference reference = cell_of(*node); + ASSERT_TRUE(reference.sheet.has_value()); + EXPECT_EQ(*reference.sheet, "Sheet1:Sheet3"); +} + +TEST(FormulaParser, a_quoted_span_of_sheets_keeps_its_quotes) { + const std::optional node = ooxml("'A B':'C D'!A1"); + + ASSERT_TRUE(node.has_value()); + const CellReference reference = cell_of(*node); + ASSERT_TRUE(reference.sheet.has_value()); + EXPECT_EQ(*reference.sheet, "'A B':'C D'"); +} + +TEST(FormulaParser, a_name_of_another_workbook_carries_no_sheet) { + const std::optional node = ooxml("[1]!Total"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + const NameReference name = node->get(); + ASSERT_TRUE(name.document.has_value()); + EXPECT_EQ(*name.document, "1"); + EXPECT_FALSE(name.sheet.has_value()); + EXPECT_EQ(name.name, "Total"); +} + +/// `LOG10` reads as column `LOG`, row 10 until the `(` says it is a call. +TEST(FormulaParser, a_name_in_front_of_a_paren_is_a_function) { + const std::optional node = ooxml("LOG10(100)"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().name, "LOG10"); + ASSERT_EQ(node->children.size(), 1); +} + +TEST(FormulaParser, a_word_that_is_no_reference_is_a_name) { + const std::optional node = ooxml("Sales"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().name, "Sales"); +} + +TEST(FormulaParser, an_omitted_argument_is_its_own_node) { + const std::optional node = ooxml("IF(A1,,B1)"); + + ASSERT_TRUE(node.has_value()); + ASSERT_EQ(node->children.size(), 3); + EXPECT_TRUE(node->children[1].holds()); +} + +TEST(FormulaParser, a_function_of_no_arguments_parses) { + const std::optional node = ooxml("TODAY()"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_TRUE(node->children.empty()); +} + +TEST(FormulaParser, a_prefixed_function_name_keeps_its_dots) { + const std::optional node = ooxml("_xlfn.FLOOR.MATH(A1)"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().name, "_xlfn.FLOOR.MATH"); +} + +TEST(FormulaParser, a_comma_inside_a_group_unites_two_ranges) { + const std::optional node = ooxml("SUM((A1:A2,B1:B2))"); + + ASSERT_TRUE(node.has_value()); + ASSERT_EQ(node->children.size(), 1); + ASSERT_TRUE(node->children[0].holds()); + EXPECT_EQ(node->children[0].get().op, BinaryOperator::unite); +} + +TEST(FormulaParser, an_array_states_its_shape) { + const std::optional node = ooxml("{1,2;3,4}"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().columns, 2); + EXPECT_EQ(node->get().rows, 2); + EXPECT_EQ(node->children.size(), 4); +} + +TEST(FormulaParser, a_product_binds_tighter_than_a_sum) { + const std::optional node = ooxml("1+2*3"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().op, BinaryOperator::add); + ASSERT_EQ(node->children.size(), 2); + EXPECT_EQ(node->children[1].get().op, + BinaryOperator::multiply); +} + +TEST(FormulaParser, a_power_binds_tighter_than_a_product) { + const std::optional node = ooxml("2*3^2"); + + ASSERT_TRUE(node.has_value()); + EXPECT_EQ(node->get().op, BinaryOperator::multiply); + EXPECT_EQ(node->children[1].get().op, BinaryOperator::power); +} + +TEST(FormulaParser, a_sign_binds_tighter_than_a_power) { + const std::optional node = ooxml("-2^2"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().op, BinaryOperator::power); + EXPECT_TRUE(node->children[0].holds()); +} + +TEST(FormulaParser, a_percent_binds_tighter_than_a_sign) { + const std::optional node = ooxml("-3%"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().op, UnaryOperator::minus); + EXPECT_EQ(node->children[0].get().op, UnaryOperator::percent); +} + +TEST(FormulaParser, a_comparison_is_the_outermost_operator) { + const std::optional node = ooxml("A1+1<>B1&\"x\""); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().op, BinaryOperator::not_equal); +} + +TEST(FormulaParser, space_between_tokens_is_filler) { + const std::optional node = ooxml(" 1 + 2 "); + + ASSERT_TRUE(node.has_value()); + EXPECT_EQ(node->get().op, BinaryOperator::add); +} + +TEST(FormulaParser, a_leading_equals_sign_is_dropped) { + const std::optional node = ooxml("=A1"); + + ASSERT_TRUE(node.has_value()); + EXPECT_EQ(cell_of(*node), at(0, 0)); +} + +TEST(FormulaParser, what_does_not_parse_is_nothing) { + EXPECT_FALSE(ooxml("SUM(").has_value()); + EXPECT_FALSE(ooxml("1 +").has_value()); + EXPECT_FALSE(ooxml("\"open").has_value()); + EXPECT_FALSE(ooxml("").has_value()); + EXPECT_FALSE(ooxml("A1 B1").has_value()); +} + +/// Rows are 1-based, and both axes stop at what an index holds. +TEST(FormulaParser, a_spelling_past_the_grid_is_a_name) { + for (const std::string text : {"ABCDEFGHI1", "A99999999999", "A0"}) { + const std::optional node = ooxml(text); + ASSERT_TRUE(node.has_value()) << text; + EXPECT_TRUE(node->holds()) << text; + } +} + +TEST(FormulaParser, the_namespace_prefix_of_a_table_formula_is_dropped) { + const std::optional node = odf("of:=[.B3]"); + + ASSERT_TRUE(node.has_value()); + EXPECT_EQ(cell_of(*node), at(1, 2)); +} + +TEST(FormulaParser, an_older_producers_prefix_is_dropped_too) { + const std::optional node = odf("oooc:=[.A1]"); + + ASSERT_TRUE(node.has_value()); + EXPECT_EQ(cell_of(*node), at(0, 0)); +} + +TEST(FormulaParser, a_string_holding_the_prefix_spelling_survives) { + const std::optional node = odf(R"(="a:=b")"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().value, "a:=b"); +} + +TEST(FormulaParser, an_odf_range_is_one_bracket) { + const std::optional node = odf("of:=SUM([.A1:.B2])"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().name, "SUM"); + ASSERT_EQ(node->children.size(), 1); + ASSERT_TRUE(node->children[0].holds()); + EXPECT_EQ(node->children[0].get().to, at(1, 1)); +} + +TEST(FormulaParser, an_odf_reference_takes_its_sheet_name) { + const std::optional node = odf("of:=[$'My Sheet'.$A$1]"); + + ASSERT_TRUE(node.has_value()); + const CellReference reference = cell_of(*node); + EXPECT_TRUE(reference.sheet_absolute); + ASSERT_TRUE(reference.sheet.has_value()); + EXPECT_EQ(*reference.sheet, "My Sheet"); +} + +TEST(FormulaParser, an_unquoted_odf_sheet_name_is_read) { + const std::optional node = odf("of:=[Sheet2.A1]"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(cell_of(*node).sheet.has_value()); + EXPECT_EQ(*cell_of(*node).sheet, "Sheet2"); +} + +TEST(FormulaParser, the_second_corner_of_an_odf_range_may_name_a_sheet) { + const std::optional node = odf("of:=[.A1:Sheet2.B2]"); + + ASSERT_TRUE(node.has_value()); + const RangeReference range = node->get(); + EXPECT_FALSE(range.from.sheet.has_value()); + ASSERT_TRUE(range.to.sheet.has_value()); + EXPECT_EQ(*range.to.sheet, "Sheet2"); +} + +TEST(FormulaParser, an_external_odf_reference_states_its_document) { + const std::optional node = odf("of:=['file:///x.ods'#$Sheet1.A1]"); + + ASSERT_TRUE(node.has_value()); + const CellReference reference = cell_of(*node); + ASSERT_TRUE(reference.document.has_value()); + EXPECT_EQ(*reference.document, "file:///x.ods"); + ASSERT_TRUE(reference.sheet.has_value()); + EXPECT_EQ(*reference.sheet, "Sheet1"); +} + +TEST(FormulaParser, a_lost_odf_reference_is_an_error) { + const std::optional node = odf("of:=[#REF!]"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().type, ErrorType::reference); +} + +TEST(FormulaParser, an_odf_function_separates_its_arguments_with_semicolons) { + const std::optional node = odf("of:=IF([.A1]>0;\"a\";\"b\")"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->children.size(), 3); +} + +TEST(FormulaParser, an_odf_named_expression_carries_two_dollars) { + const std::optional node = odf("of:=$$'Total Sales'"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().name, "Total Sales"); +} + +TEST(FormulaParser, an_odf_array_separates_its_rows_with_bars) { + const std::optional node = odf("of:={1;2|3;4}"); + + ASSERT_TRUE(node.has_value()); + ASSERT_TRUE(node->holds()); + EXPECT_EQ(node->get().columns, 2); + EXPECT_EQ(node->get().rows, 2); +} + +TEST(FormulaParser, the_odf_reference_operators_parse) { + const std::optional node = odf("of:=SUM([.A1:.A2]~[.B1:.B2])"); + + ASSERT_TRUE(node.has_value()); + ASSERT_EQ(node->children.size(), 1); + EXPECT_EQ(node->children[0].get().op, BinaryOperator::unite); +}