From 31190fd1cd30aa9376ef71425d82399b6a6ccd6e Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Fri, 25 Sep 2026 15:05:16 +0200 Subject: [PATCH 1/2] Document and test LC_NUMERIC for C number parsing --- .github/workflows/ci.yml | 10 ++++++++++ doc/guide.md | 26 ++++++++++++++++++++++++++ test/babashka/ffi_test.clj | 30 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f19de47..7a4193e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,11 @@ jobs: done exit 1 + # a decimal-comma locale for c-locale-test + - name: Install de_DE.UTF-8 + if: runner.os == 'Linux' + run: sudo locale-gen de_DE.UTF-8 + # Runs the suite through babashka's built-in babashka.ffi, so the calls # go through the compiled trampolines instead of FFM handles. - name: Run tests @@ -85,6 +90,11 @@ jobs: with: cli: latest + # a decimal-comma locale for c-locale-test + - name: Install de_DE.UTF-8 + if: runner.os == 'Linux' + run: sudo locale-gen de_DE.UTF-8 + - name: Run tests run: clojure -M:test diff --git a/doc/guide.md b/doc/guide.md index eb4b4cf..b7ef000 100644 --- a/doc/guide.md +++ b/doc/guide.md @@ -28,6 +28,7 @@ Without either setting, the JDK warns about native access. - [Unions](#unions) - [Out parameters](#out-parameters) - [Create a callback](#create-a-callback) +- [Set the C locale](#set-the-c-locale) - [Performance and limits](#performance-and-limits) - [On the JVM](#on-the-jvm) - [In a babashka native binary](#in-a-babashka-native-binary) @@ -946,6 +947,31 @@ A `:bool` callback argument becomes `true` or `false`. > **_NOTE_** Do not let a callback throw an exception. Catch exceptions inside > the callback, or the process can stop. +## Set the C locale + +Set `LC_NUMERIC` to `"C"` before you call C code that parses or prints +floating-point numbers. On Linux and macOS, the JVM and babashka take the C +locale from the environment. On Windows, the C locale stays `"C"`. In a locale with a decimal comma, such as `de_DE.UTF-8`, +`strtod` reads `"0.95"` as `0.0` and `printf` writes `0,95`. + +On Linux and macOS, call `setlocale`: + +```clojure +;; the value of LC_NUMERIC in locale.h: 1 on Linux, 4 on macOS +(def lc-numeric + (if (re-find #"(?i)linux" (System/getProperty "os.name")) 1 4)) + +(let [setlocale (ffi/cfn "setlocale" [:int :string] :pointer)] + (setlocale lc-numeric "C")) +``` + +Alternatively, set `LC_NUMERIC=C` in the environment before starting babashka. +`LC_ALL` overrides `LC_NUMERIC`. + +```sh +LC_NUMERIC=C bb script.clj +``` + ## Performance and limits The host determines how `babashka.ffi` calls C and how much each call costs. diff --git a/test/babashka/ffi_test.clj b/test/babashka/ffi_test.clj index d3e26af..ed38448 100644 --- a/test/babashka/ffi_test.clj +++ b/test/babashka/ffi_test.clj @@ -958,3 +958,33 @@ (is (= (:babashka.ffi/backend (meta c-abs)) (:babashka.ffi/backend (meta (vary-meta c-abs assoc :x 1))))) (is (= 5 ((vary-meta c-abs assoc :x 1) -5))))))) + +(deftest c-locale-test + (if-not @default-lookup? + (println "C locale skipped: this build has no default lookup") + (let [os (System/getProperty "os.name") + windows? (str/starts-with? os "Windows") + _ (when windows? (ffi/load-library "ucrtbase.dll")) + lc-numeric (if (re-find #"(?i)linux" os) 1 4) + query (ffi/cfn "setlocale" [:int :pointer] :pointer) + setlocale (ffi/cfn "setlocale" [:int :string] :pointer) + strtod (ffi/cfn "strtod" [:string :pointer] :double) + current #(ffi/ptr->string (query lc-numeric ffi/null))] + (if windows? + (testing "LC_NUMERIC is \"C\" on Windows and strtod reads \"0.95\" as 0.95" + (is (= "C" (current))) + (is (= 0.95 (strtod "0.95" ffi/null)))) + (let [env (some #(not-empty (System/getenv %)) ["LC_ALL" "LC_NUMERIC" "LANG"]) + original (current)] + (testing "LC_NUMERIC equals LC_ALL, LC_NUMERIC or LANG from the environment" + (is (= (or env "C") original))) + (if (ffi/null? (setlocale lc-numeric "de_DE.UTF-8")) + (println "decimal comma skipped: de_DE.UTF-8 is not installed") + (try + (testing "strtod reads \"0.95\" as 0.0 in de_DE.UTF-8" + (is (= 0.0 (strtod "0.95" ffi/null)))) + (testing "setlocale LC_NUMERIC \"C\" makes strtod read \"0.95\" as 0.95" + (setlocale lc-numeric "C") + (is (= 0.95 (strtod "0.95" ffi/null)))) + (finally + (setlocale lc-numeric original))))))))) From 11df5060097f9c6ee4b04192b3da7a374c36c364 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Fri, 25 Sep 2026 15:20:01 +0200 Subject: [PATCH 2/2] docs --- doc/guide.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/doc/guide.md b/doc/guide.md index b7ef000..1d138d7 100644 --- a/doc/guide.md +++ b/doc/guide.md @@ -951,10 +951,16 @@ A `:bool` callback argument becomes `true` or `false`. Set `LC_NUMERIC` to `"C"` before you call C code that parses or prints floating-point numbers. On Linux and macOS, the JVM and babashka take the C -locale from the environment. On Windows, the C locale stays `"C"`. In a locale with a decimal comma, such as `de_DE.UTF-8`, -`strtod` reads `"0.95"` as `0.0` and `printf` writes `0,95`. +locale from the environment. On Windows, the C locale stays `"C"`. -On Linux and macOS, call `setlocale`: +In a locale with a decimal comma, such as `de_DE.UTF-8`, every C function that +parses or formats a floating-point number expects or writes a comma, and none of +them reports an error. For example, `strtod` reads `"0.95"` as `0.0`, so a +library that parses a JSON or glTF file gets wrong values. And `printf("%g", +0.95)` writes `0,95`, so a CSV or JSON file that was written by C, no longer +parses elsewhere. + +To fix this, on Linux and macOS, call `setlocale`: ```clojure ;; the value of LC_NUMERIC in locale.h: 1 on Linux, 4 on macOS @@ -972,6 +978,10 @@ Alternatively, set `LC_NUMERIC=C` in the environment before starting babashka. LC_NUMERIC=C bb script.clj ``` +Unfortunately babashka.ffi cannot do this for you. The locale is shared by the +whole process, and babashka.ffi cannot tell whether other code set it on +purpose. + ## Performance and limits The host determines how `babashka.ffi` calls C and how much each call costs.