Skip to content

fix(ereal): implement flag-honoring operator<<() for decimal output#538

Open
Ravenwater wants to merge 2 commits intomainfrom
fix/ereal_ostream
Open

fix(ereal): implement flag-honoring operator<<() for decimal output#538
Ravenwater wants to merge 2 commits intomainfrom
fix/ereal_ostream

Conversation

@Ravenwater
Copy link
Contributor

@Ravenwater Ravenwater commented Mar 5, 2026

Summary

  • Replace the TBD stub in ereal's operator<<() with a complete ostream implementation that honors all standard formatting flags (std::fixed, std::scientific, std::setprecision, std::setw, std::showpos, std::uppercase, std::left/std::right/std::internal, fill character)
  • Port the decimal conversion algorithm from dd_impl.hpp (to_string, to_digits, round_string, append_exponent), adapted for ereal's multi-component expansion arithmetic
  • Guard against empty limb vectors during digit extraction (expansion ops can produce empty vectors when components become zero, unlike dd which always has exactly 2 components)
  • Add ostream_formatting.cpp regression test covering all formatting modes, special values, alignment, and extended precision output

Test plan

  • Build and run er_api_api — produces decimal values instead of "TBD"
  • Build and run er_arith_addition, er_arith_subtraction, er_arith_multiplication, er_arith_division — no regressions
  • Build and run er_api_ostream_formatting at all regression levels — PASS
  • Verify with both gcc and clang
  • ASAN clean (no memory errors)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Enhanced ereal value formatting with configurable precision, width, alignment, notation (scientific/fixed), sign display, uppercase, and custom fill characters via a public string-formatting API.
  • Tests

    • Added an extensive test suite demonstrating and validating formatted output across modes, alignment, precision, special values (NaN/INF), extended precision, rounding, and zero handling.

Replace the TBD stub in ereal's operator<<() with a complete ostream
implementation that honors all standard formatting flags (fixed,
scientific, precision, width, fill, showpos, uppercase, left/right
alignment). Port the decimal conversion algorithm from dd_impl.hpp,
adapted for ereal's multi-component expansion arithmetic with a guard
against empty limb vectors during digit extraction.

Add ostream_formatting.cpp regression test covering all formatting
modes, special values, alignment, and extended precision output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

Adds a public string-formatting API (to_string) and related digit/rounding helpers to ereal, updates operator<< to use it, and introduces a comprehensive test file exercising formatting modes, precision, alignment, and special values.

Changes

Cohort / File(s) Summary
Test Suite
elastic/ereal/api/ostream_formatting.cpp
New C++ test file with multiple test suites validating default output, scientific notation, fixed-point formatting, showpos, uppercase exponent, width/alignment/fill, special values (NaN/Inf), extended precision, and zero handling.
Formatting API & Implementation
include/sw/universal/number/ereal/ereal_impl.hpp
Added public to_string() with parameters (precision, width, fixed/scientific, alignment, showpos, uppercase, fill), helpers to_digits(), round_string(), append_exponent(), and tracing flags bTraceDecimalConversion/bTraceDecimalRounding. Reworked operator<< to use the new API and implemented decimal digit generation, rounding, and exponent formatting logic.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 Hopped into code with a tiny spring,

I taught ereal how to sparkle and sing.
Precision and places, exponents tall,
Align left or right — I do it all!
Now numbers parade in a formatted ball.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: implementing flag-honoring operator<<() for decimal output in ereal, which is the primary objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/ereal_ostream

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
elastic/ereal/api/ostream_formatting.cpp (1)

90-128: Add focused regressions for sub-unit fixed values and std::internal with sign.

Current cases miss two high-value edges: fixed output for (0,1) (e.g., 0.5) and internal padding with showpos (sign should stay at column start).

Suggested additions
 int test_fixed_output() {
 	int nrOfFailedTests = 0;
+	{
+		ereal<4> x(0.5);
+		std::string s = capture(x, 2, std::ios_base::fixed);
+		if (s != "0.50") {
+			std::cout << "FAIL: fixed output of 0.5 = '" << s << "', expected '0.50'\n";
+			++nrOfFailedTests;
+		}
+	}
 	{
 		ereal<4> x(3.14159);
 		std::string s = capture(x, 3, std::ios_base::fixed);
@@
 int test_width_alignment() {
 	int nrOfFailedTests = 0;
@@
 	// Custom fill character
 	{
 		ereal<4> x(1.0);
 		std::string s = capture(x, 2, std::ios_base::scientific, 20, '*');
 		if (s.find('*') == std::string::npos) {
 			std::cout << "FAIL: custom fill output = '" << s << "', expected '*' fill\n";
 			++nrOfFailedTests;
 		}
 	}
+	// Internal alignment with sign
+	{
+		ereal<4> x(1.0);
+		std::string s = capture(x, 2,
+			std::ios_base::scientific | std::ios_base::showpos | std::ios_base::internal,
+			14, '_');
+		if (s.empty() || s[0] != '+' || s[1] != '_') {
+			std::cout << "FAIL: internal showpos output = '" << s
+			          << "', expected '+' followed by fill\n";
+			++nrOfFailedTests;
+		}
+	}

Also applies to: 180-225

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@elastic/ereal/api/ostream_formatting.cpp` around lines 90 - 128, Extend the
test_fixed_output cases to cover the sub-unit fixed value and the
internal-padding-with-sign scenario: add a case that constructs an ereal (e.g.,
ereal<4> or ereal<8>) with value 0.5, call capture(value, precision,
std::ios_base::fixed) and assert the output contains a decimal point, correct
fractional digits and no exponent; add another case that uses std::internal with
showpos and a width (e.g., std::setw(10) and std::showpos passed into capture or
the stream) and assert the '+' sign remains at the start of the field (column 0)
while padding is applied after the sign. Modify test_fixed_output (and mirror
the same additions to the analogous test block referenced around lines 180-225)
to include these assertions, referencing the existing capture function and ereal
template types so the new checks integrate with the current test harness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@include/sw/universal/number/ereal/ereal_impl.hpp`:
- Around line 500-505: The internal-padding branch wrongly assumes only negative
values have a leading sign; update the logic in the block guarded by the
variable internal to inspect the resulting string s for a leading sign character
(e.g., '+' or '-') instead of relying solely on negative, and insert the fill
after that sign when present (otherwise insert at position 0). Modify the code
that currently uses negative and
s.insert(static_cast<std::string::size_type>(...), nrCharsToFill, fill) so it
computes an index like (s.size()>0 && (s[0]=='+'||s[0]=='-')) ? 1 : 0 and uses
that index for insertion; keep variables internal, negative, s, nrCharsToFill,
and fill unchanged otherwise.
- Around line 417-419: The decimal-scale calculation using
static_cast<int>(std::log10(std::fabs(_limb[0]))) miscomputes scales for values
in (0,1) because the cast truncates toward zero; update the calculation in the
ereal implementation so powerOfTenScale uses
std::floor(std::log10(std::fabs(_limb[0]))) (or std::floor on the log10 result)
before converting to int, then recompute integerDigits and nrDigits from that
floor result so fixed-format values in (0,1) get integerDigits = 0 and the "0."
prefix path is taken correctly (adjust references to powerOfTenScale,
integerDigits, nrDigits, _limb, fixed, precision).
- Around line 436-439: The early-return in the branch checking (fixed &&
(precision == 0) && (std::fabs(_limb[0]) < 1.0)) writes one digit into s and
returns before calling Fill(s), bypassing width/fill/alignment logic; change
this branch to append the '0' or '1' char to s but do not return — let execution
fall through so the later Fill(s) call (and any subsequent formatting logic)
runs; adjust any local flags or state if necessary to prevent additional digits
being appended later, but do not call return from inside that branch.

---

Nitpick comments:
In `@elastic/ereal/api/ostream_formatting.cpp`:
- Around line 90-128: Extend the test_fixed_output cases to cover the sub-unit
fixed value and the internal-padding-with-sign scenario: add a case that
constructs an ereal (e.g., ereal<4> or ereal<8>) with value 0.5, call
capture(value, precision, std::ios_base::fixed) and assert the output contains a
decimal point, correct fractional digits and no exponent; add another case that
uses std::internal with showpos and a width (e.g., std::setw(10) and
std::showpos passed into capture or the stream) and assert the '+' sign remains
at the start of the field (column 0) while padding is applied after the sign.
Modify test_fixed_output (and mirror the same additions to the analogous test
block referenced around lines 180-225) to include these assertions, referencing
the existing capture function and ereal template types so the new checks
integrate with the current test harness.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 508d3e1f-7c1d-4580-b643-171e001e3a0c

📥 Commits

Reviewing files that changed from the base of the PR and between eaaf535 and 0d39b8e.

📒 Files selected for processing (2)
  • elastic/ereal/api/ostream_formatting.cpp
  • include/sw/universal/number/ereal/ereal_impl.hpp

@coveralls
Copy link

Pull Request Test Coverage Report for Build 22738809745

Details

  • 137 of 175 (78.29%) changed or added relevant lines in 1 file are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage decreased (-0.02%) to 84.491%

Changes Missing Coverage Covered Lines Changed/Added Lines %
include/sw/universal/number/ereal/ereal_impl.hpp 137 175 78.29%
Totals Coverage Status
Change from base Build 22731278268: -0.02%
Covered Lines: 41533
Relevant Lines: 49157

💛 - Coveralls

@Ravenwater Ravenwater self-assigned this Mar 5, 2026
- Use std::floor(std::log10(...)) instead of static_cast<int> truncation
  so values in (0,1) get correct powerOfTenScale (e.g., 0.5 → "0.50")
- Remove early return in fixed/precision-0/sub-unit branch so
  width/fill/alignment logic is not bypassed
- Fix std::internal padding to check actual leading sign character
  instead of only the negative flag, so showpos '+' is handled correctly
- Add test cases for sub-unit fixed output and internal+showpos padding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@elastic/ereal/api/ostream_formatting.cpp`:
- Around line 56-57: The test currently indexes the string variable s with s[0]
and s.back() (e.g., in the "-42.5" default-output check) without verifying it's
non-empty; change these checks to first assert !s.empty() (or s.empty() and fail
with a clear message) and then use s.front() / s.back() for the character
comparisons, updating the failure messages to report an empty output when
appropriate; apply the same guard-and-check pattern to all other checks that use
s[0] or s.back() (the occurrences flagged in this file).

In `@include/sw/universal/number/ereal/ereal_impl.hpp`:
- Around line 638-641: to_digits() currently returns early on error without
normalizing the output, leaving exponent and the digit buffer in a partially
populated state; update both early-return paths (the one around the check using
_ten/_one and the similar block around lines 674-677) to set a deterministic
safe fallback (e.g., set exponent to 0 and normalize the digit buffer to
represent zero: digits[0]=0 and ndigits=1 or equivalent members used by this
implementation) before returning so callers receive a well-defined, normalized
representation on failure.
- Around line 495-498: Guard against negative std::streamsize width before
converting to size_t: check if width > 0 (or clamp with auto effectiveWidth =
std::max<std::streamsize>(0, width)) before computing strLength/ nrCharsToFill
and doing the static_cast to size_t; use effectiveWidth when calculating
nrCharsToFill and the comparison with strLength so negative widths behave like 0
and do not produce gigantic unsigned values (refer to the variables/identifiers
width, strLength, nrCharsToFill, and internal).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4e330492-e213-41fa-961c-400b4d8c0e6e

📥 Commits

Reviewing files that changed from the base of the PR and between 0d39b8e and cf064ac.

📒 Files selected for processing (2)
  • elastic/ereal/api/ostream_formatting.cpp
  • include/sw/universal/number/ereal/ereal_impl.hpp

Comment on lines +56 to +57
if (s[0] != '-') {
std::cout << "FAIL: default output of -42.5 = '" << s << "', expected leading '-'\n";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid unchecked indexing in failure checks.

Several assertions index s[0] / s.back() without guarding emptiness. If formatting regresses to empty output, tests can crash instead of reporting a clean failure.

💡 Proposed fix pattern
-		if (s[0] != '-') {
+		if (s.empty() || s[0] != '-') {
 			std::cout << "FAIL: default output of -42.5 = '" << s << "', expected leading '-'\n";
 			++nrOfFailedTests;
 		}
@@
-		if (s.back() != ' ') {
+		if (s.empty() || s.back() != ' ') {
 			std::cout << "FAIL: left-aligned output = '" << s << "', expected trailing spaces\n";
 			++nrOfFailedTests;
 		}

Also applies to: 147-148, 156-157, 203-204, 218-219, 240-241

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@elastic/ereal/api/ostream_formatting.cpp` around lines 56 - 57, The test
currently indexes the string variable s with s[0] and s.back() (e.g., in the
"-42.5" default-output check) without verifying it's non-empty; change these
checks to first assert !s.empty() (or s.empty() and fail with a clear message)
and then use s.front() / s.back() for the character comparisons, updating the
failure messages to report an empty output when appropriate; apply the same
guard-and-check pattern to all other checks that use s[0] or s.back() (the
occurrences flagged in this file).

Comment on lines +495 to +498
size_t strLength = s.length();
if (strLength < static_cast<size_t>(width)) {
size_t nrCharsToFill = (width - strLength);
if (internal) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

std::streamsize width C++ signed negative standard behavior formatted output

💡 Result:

std::ios_base::width() stores the minimum field width used by the next formatted output operation; std::setw(n) just sets that state. The parameter type is signed (std::streamsize), so passing a negative value is well-formed and not undefined by itself. [1]

For formatted output, the standard’s padding rule is effectively:

  • padding happens only if len(seq) < os.width()
  • otherwise, no padding is added. [2]

So if os.width() is negative, the condition len < width is false, and no padding occurs—i.e., a negative width behaves like “no minimum width” (same observable effect as width 0) for standard formatted output. [2]

(Implementations typically still “consume” the width for that operation by resetting it back to 0 afterward, as usual for formatted output.) [1]

Sources:
[1] cppreference: std::ios_base::width, std::setw notes https://en.cppreference.com/w/cpp/io/ios_base/width.html
[2] C++ draft wording: [ostream.formatted.reqmts] padding rule https://eel.is/c++draft/input.output


Guard negative stream width before size_t conversion.

width is std::streamsize (signed). Converting a negative width to size_t produces a large unsigned value, triggering unwanted padding insertion. The C++ standard specifies that negative width should behave like width 0 (no padding), but this code doesn't guard the conversion.

💡 Proposed fix
-		size_t strLength = s.length();
-		if (strLength < static_cast<size_t>(width)) {
+		size_t strLength = s.length();
+		if (width > 0 && strLength < static_cast<size_t>(width)) {
 			size_t nrCharsToFill = (width - strLength);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@include/sw/universal/number/ereal/ereal_impl.hpp` around lines 495 - 498,
Guard against negative std::streamsize width before converting to size_t: check
if width > 0 (or clamp with auto effectiveWidth = std::max<std::streamsize>(0,
width)) before computing strLength/ nrCharsToFill and doing the static_cast to
size_t; use effectiveWidth when calculating nrCharsToFill and the comparison
with strLength so negative widths behave like 0 and do not produce gigantic
unsigned values (refer to the variables/identifiers width, strLength,
nrCharsToFill, and internal).

Comment on lines +638 to +641
if ((r >= _ten) || (r < _one)) {
std::cerr << "ereal::to_digits() failed to compute exponent\n";
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make to_digits() failure exits deterministic.

On the error returns, exponent/digit buffer are not normalized to a safe fallback. Callers can still consume partially populated digits and produce malformed output.

💡 Proposed fix
 		if ((r >= _ten) || (r < _one)) {
 			std::cerr << "ereal::to_digits() failed to compute exponent\n";
+			std::fill(s.begin(), s.end(), '0');
+			if (!s.empty()) s.back() = 0;
+			exponent = 0;
 			return;
 		}
@@
 		if (s[0] <= '0') {
 			std::cerr << "ereal::to_digits() non-positive leading digit\n";
+			std::fill(s.begin(), s.end(), '0');
+			if (!s.empty()) s.back() = 0;
+			exponent = 0;
 			return;
 		}

Also applies to: 674-677

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@include/sw/universal/number/ereal/ereal_impl.hpp` around lines 638 - 641,
to_digits() currently returns early on error without normalizing the output,
leaving exponent and the digit buffer in a partially populated state; update
both early-return paths (the one around the check using _ten/_one and the
similar block around lines 674-677) to set a deterministic safe fallback (e.g.,
set exponent to 0 and normalize the digit buffer to represent zero: digits[0]=0
and ndigits=1 or equivalent members used by this implementation) before
returning so callers receive a well-defined, normalized representation on
failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants