diff --git a/.github/workflows/on-release.yml b/.github/workflows/on-release.yml index 7427df4..707424f 100644 --- a/.github/workflows/on-release.yml +++ b/.github/workflows/on-release.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v7 - + - name: Amalgamate fast_float.h run: | mkdir build @@ -29,13 +29,13 @@ jobs: cp tests/string_test.cpp build/ cd build g++ string_test.cpp + ./a.out - name: Upload Release Asset - uses: actions/upload-release-asset@v1 + # --clobber replaces an asset that was already attached by hand, which + # would otherwise make the upload fail. env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: build/fast_float/fast_float.h - asset_name: fast_float.h - asset_content_type: text/plain + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ github.event.release.tag_name }}" \ + build/fast_float/fast_float.h --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..08d3313 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Release + +# Manually triggered. Bumps the version (patch, minor or major), regenerates +# the amalgamated fast_float.h, runs the tests, commits the version bump to +# main, tags it and publishes a GitHub release with fast_float.h attached. +# +# Releases created with GITHUB_TOKEN do not trigger other workflows, so +# on-release.yml does not run for releases made here. + +'on': + workflow_dispatch: + inputs: + bump: + description: 'Which part of the version to bump' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + + - name: Compute new version + id: version + run: | + current=$(git describe --abbrev=0 --tags) + current=${current#v} + IFS=. read -r major minor patch <<< "$current" + case "${{ inputs.bump }}" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + *) echo "unknown bump type"; exit 1 ;; + esac + new="$major.$minor.$patch" + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "new=$new" >> "$GITHUB_OUTPUT" + echo "Releasing $current -> $new (${{ inputs.bump }})" + + - name: Bump version files and amalgamate + run: | + new="${{ steps.version.outputs.new }}" + IFS=. read -r major minor patch <<< "$new" + python3 script/release.py "$new" + rm -f CMakeLists.txt.bak README.md.bak MODULE.bazel.bak \ + include/fast_float/float_common.h.bak + grep -q "project(fast_float VERSION $new LANGUAGES CXX)" CMakeLists.txt + grep -q "#define FASTFLOAT_VERSION_MAJOR $major$" include/fast_float/float_common.h + grep -q "#define FASTFLOAT_VERSION_MINOR $minor$" include/fast_float/float_common.h + grep -q "#define FASTFLOAT_VERSION_PATCH $patch$" include/fast_float/float_common.h + grep -q "version = \"$new\"," MODULE.bazel + grep -q "releases/download/v$new/fast_float.h" README.md + grep -q "#define FASTFLOAT_VERSION_PATCH $patch$" fast_float.h + + - name: Test amalgamation + run: | + mkdir -p amalgamation/fast_float + cp fast_float.h amalgamation/fast_float/ + g++ -O2 -std=c++17 -Iamalgamation -o amalgamation/string_test tests/string_test.cpp + ./amalgamation/string_test + + - name: Build and test + run: | + cmake -B build -DFASTFLOAT_TEST=ON -DCMAKE_CXX_FLAGS='-Werror -Wundef' + cmake --build build --parallel + ctest --test-dir build --output-on-failure + + - name: Commit, tag and push + run: | + new="${{ steps.version.outputs.new }}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CMakeLists.txt README.md MODULE.bazel include/fast_float/float_common.h + git commit -m "Version $new" + git tag -a "v$new" -m "version $new" + git push origin main + git push origin "v$new" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + new="${{ steps.version.outputs.new }}" + gh release create "v$new" \ + --title "Version $new" \ + --generate-notes \ + --notes-start-tag "v${{ steps.version.outputs.current }}" \ + fast_float.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 308e82f..78b350f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.14) -project(fast_float VERSION 8.3.0 LANGUAGES CXX) +project(fast_float VERSION 8.3.2 LANGUAGES CXX) set(FASTFLOAT_CXX_STANDARD 11 CACHE STRING "the C++ standard to use for fastfloat") set(CMAKE_CXX_STANDARD ${FASTFLOAT_CXX_STANDARD}) option(FASTFLOAT_TEST "Enable tests" OFF) diff --git a/MODULE.bazel b/MODULE.bazel index 9f63ac4..d821208 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,7 +2,7 @@ module( name = "fast_float", - version = "8.2.4", + version = "8.3.2", compatibility_level = 8, ) diff --git a/README.md b/README.md index 4f1ae8a..976e531 100644 --- a/README.md +++ b/README.md @@ -702,7 +702,7 @@ sufficiently recent version of CMake (3.11 or better at least): FetchContent_Declare( fast_float GIT_REPOSITORY https://github.com/irainman/fast_float.git - GIT_TAG tags/v8.3.0 + GIT_TAG tags/v8.3.2 GIT_SHALLOW TRUE) FetchContent_MakeAvailable(fast_float) @@ -718,7 +718,7 @@ You may also use [CPM](https://github.com/cpm-cmake/CPM.cmake), like so: CPMAddPackage( NAME fast_float GITHUB_REPOSITORY "irainman/fast_float" - GIT_TAG v8.3.0) + GIT_TAG v8.3.2) ``` ## Using as single header @@ -730,7 +730,7 @@ if desired as described in the command line help. You may directly download automatically generated single-header files: - + ## Benchmarking diff --git a/include/fast_float/ascii_number.h b/include/fast_float/ascii_number.h index 826f764..d461ca9 100644 --- a/include/fast_float/ascii_number.h +++ b/include/fast_float/ascii_number.h @@ -30,10 +30,14 @@ fastfloat_inline FASTFLOAT_CONSTEVAL bool has_simd_opt() noexcept { // Next function can be micro-optimized, but compilers are entirely // able to optimize it well. template -fastfloat_inline FASTFLOAT_CONSTEXPR14 bool is_integer(UC c) noexcept { - const auto d = c - UC('0'); - // UC may be signed. - return d >= 0 && d <= 9; +fastfloat_inline constexpr bool is_integer(UC c) noexcept { + // UC can be signed (wchar_t is a signed int on Linux and macOS), and the + // subtraction is then promoted to int and overflows for code units near the + // bottom of the range. Subtracting in the unsigned type wraps instead, which + // selects the same code units, as ch_to_digit already does. + using UnsignedUC = typename std::make_unsigned::type; + return static_cast(static_cast(c) - + static_cast(UC('0'))) <= 9u; } #if FASTFLOAT_IS_BIG_ENDIAN @@ -470,9 +474,12 @@ enum class parse_error : uint_fast8_t { template struct parsed_number_string_t { FASTFLOAT_NO_UNIQUE_ADDRESS am_mant_t mantissa; - FASTFLOAT_NO_UNIQUE_ADDRESS UC const *lastmatch; FASTFLOAT_NO_UNIQUE_ADDRESS am_pow_t exponent; + FASTFLOAT_NO_UNIQUE_ADDRESS UC const *lastmatch; + // The field order matters: placing 'error' next to the booleans avoids + // padding, keeping the struct at 64 bytes on 64-bit systems instead of 72. + // See https://github.com/fastfloat/fast_float/issues/418 FASTFLOAT_NO_UNIQUE_ADDRESS parse_error error; #ifndef FASTFLOAT_ONLY_POSITIVE_C_NUMBER_WO_INF_NAN FASTFLOAT_NO_UNIQUE_ADDRESS bool negative; diff --git a/include/fast_float/float_common.h b/include/fast_float/float_common.h index 1e77011..9cac7f7 100644 --- a/include/fast_float/float_common.h +++ b/include/fast_float/float_common.h @@ -18,7 +18,7 @@ #define FASTFLOAT_VERSION_MAJOR 8 #define FASTFLOAT_VERSION_MINOR 3 -#define FASTFLOAT_VERSION_PATCH 0 +#define FASTFLOAT_VERSION_PATCH 2 #define FASTFLOAT_STRINGIZE_IMPL(x) #x #define FASTFLOAT_STRINGIZE(x) FASTFLOAT_STRINGIZE_IMPL(x) diff --git a/include/fast_float/parse_number.h b/include/fast_float/parse_number.h index 3cd91df..5ed0181 100644 --- a/include/fast_float/parse_number.h +++ b/include/fast_float/parse_number.h @@ -588,7 +588,9 @@ FASTFLOAT_CONSTEXPR20 int const decimal_exponent) noexcept { #ifndef FASTFLOAT_ONLY_POSITIVE_C_NUMBER_WO_INF_NAN const auto is_negative = mantissa < 0; - const auto m = static_cast(is_negative ? -mantissa : mantissa); + // Negate as unsigned: -mantissa overflows for the most negative int64_t. + const auto m = is_negative ? am_mant_t(0) - static_cast(mantissa) + : static_cast(mantissa); #else FASTFLOAT_ASSUME(mantissa >= 0); const auto m = static_cast(mantissa); diff --git a/script/release.py b/script/release.py index 0171e19..1199b08 100755 --- a/script/release.py +++ b/script/release.py @@ -145,6 +145,18 @@ for line in fileinput.input(versionfile, inplace=1, backup=".bak"): print(f"{versionfile} modified") +bazelfile = f"{maindir}{os.sep}MODULE.bazel" + +for line in fileinput.input(bazelfile, inplace=1, backup=".bak"): + line = re.sub( + r'^(\s*version = )"\d+\.\d+\.\d+",', + rf'\g<1>"{newversionstring}",', + line.rstrip(), + ) + print(line) + +print(f"modified {bazelfile}, a backup was made") + readmefile = f"{maindir}{os.sep}README.md" for line in fileinput.input(readmefile, inplace=1, backup=".bak"): @@ -173,6 +185,7 @@ with open(f"{maindir}{os.sep}fast_float.h", "w") as outfile: if cp.returncode != 0: print("Failed to run amalgamate") + sys.exit(cp.returncode) else: print("amalgamate.py ran successfully") print(f"You should upload {maindir}{os.sep}fast_float.h") diff --git a/tests/basictest.cpp b/tests/basictest.cpp index dfdd501..9736ab0 100644 --- a/tests/basictest.cpp +++ b/tests/basictest.cpp @@ -80,6 +80,12 @@ constexpr std::string_view const round_name(int d) { } } +// Guard against padding regressions that hurt performance. +// See https://github.com/fastfloat/fast_float/issues/418 +static_assert(sizeof(void *) != 8 || + sizeof(fast_float::parsed_number_string_t) <= 64, + "parsed_number_string_t should fit in 64 bytes"); + #define FASTFLOAT_STR(x) #x #define SHOW_DEFINE(x) printf("%s='%s'\n", #x, FASTFLOAT_STR(x)) @@ -2475,6 +2481,10 @@ TEST_CASE("integer_times_pow10") { all::verify_integer_times_pow10(12345678901234567890ull, -42); all::verify_integer_times_pow10(std::numeric_limits::max(), 42); all::verify_integer_times_pow10(std::numeric_limits::max(), -42); + // the most negative int64_t has no positive counterpart to negate into + all::verify_integer_times_pow10(std::numeric_limits::min(), 0); + all::verify_integer_times_pow10(std::numeric_limits::min(), 42); + all::verify_integer_times_pow10(std::numeric_limits::min(), -42); all::verify_integer_times_pow10(std::numeric_limits::max(), 42); all::verify_integer_times_pow10(std::numeric_limits::max(), -42); } diff --git a/tests/wide_char_test.cpp b/tests/wide_char_test.cpp index f31e1ba..7ba54a2 100644 --- a/tests/wide_char_test.cpp +++ b/tests/wide_char_test.cpp @@ -70,9 +70,34 @@ bool test_non_space_with_space_low_byte() { return true; } +// is_integer subtracts UC('0') from the code unit. When wchar_t is signed the +// subtraction is promoted to int and overflows for code units near the bottom +// of the range, so the sanitized build aborts on input the library is supposed +// to reject. Such a unit is not a digit, so both entry points must fail. +bool test_code_unit_below_zero_digit() { + if (!std::is_signed::value) { + return true; // only reproducible where wchar_t is signed + } + wchar_t const input[] = {(std::numeric_limits::min)(), L'1'}; + double result; + auto answer = fast_float::from_chars(input, input + 2, result); + if (answer.ec != std::errc::invalid_argument) { + std::cerr << "a code unit below L'0' must not start a number\n"; + return false; + } + unsigned long long integer; + auto int_answer = fast_float::from_chars(input, input + 2, integer, 10); + if (int_answer.ec != std::errc::invalid_argument) { + std::cerr << "a code unit below L'0' must not start an integer\n"; + return false; + } + return true; +} + int main() { if (test_minus() && test_plus() && test_space() && test_nan() && - test_non_space_with_space_low_byte()) { + test_non_space_with_space_low_byte() && + test_code_unit_below_zero_digit()) { std::cout << "all ok" << std::endl; return EXIT_SUCCESS; }