Skip to content

Format numbers per the XPath 1.0 string() rules - #125

Merged
zhengchun merged 1 commit into
antchfx:masterfrom
gaoflow:fix-number-string-conversion
Jul 19, 2026
Merged

Format numbers per the XPath 1.0 string() rules#125
zhengchun merged 1 commit into
antchfx:masterfrom
gaoflow:fix-number-string-conversion

Conversation

@gaoflow

@gaoflow gaoflow commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

string() on a number — and every other place a number is coerced to text — goes through asString, which uses strconv.FormatFloat(v, 'g', -1, 64). Go's 'g' verb switches to scientific notation whenever that is shorter and prints Go's own +Inf/-Inf/-0 tokens, none of which are valid XPath. The XPath 1.0 REC §4.2 defines an exact algorithm: finite numbers render in decimal form with no exponent, the infinities become Infinity/-Infinity, NaN becomes NaN, and negative zero becomes 0.

concat has the same root cause from the other side — its type switch only handled string and node-set arguments, so a numeric or boolean argument was silently dropped.

Before:

string(1000000)          => "1e+06"    want "1000000"
string(0.00001)          => "1e-05"    want "0.00001"
string(1 div 0)          => "+Inf"     want "Infinity"
string(-0)               => "-0"       want "0"
concat("id-", 5, "-x")   => "id--x"    want "id-5-x"
concat(1, 2, 3)          => ""         want "123"
concat("a", true(), "b") => "ab"       want "atrueb"

Fix: add formatNumber implementing the §4.2 rule and use it in asString; route each concat argument through asString so all argument types are stringified exactly as string() converts them.

I checked the output against libxml2 (via lxml) across the common range of magnitudes and they now agree. Two notes on scope:

  • For very large / very small magnitudes libxml2 itself switches to scientific notation (e.g. string(1e20) -> 1e+20), which §4.2 does not permit. This change follows §4.2 and always emits decimal form (100000000000000000000), so the spec arbitrates that range rather than libxml2.
  • It deliberately does not change the number of significant digits (string(2 div 3) stays 0.6666666666666666). FormatFloat(_, 'f', -1, 64) already emits the shortest round-tripping representation, which is what §4.2 asks for ("as many digits as are needed to uniquely distinguish the number").

Regression cases added to Test_func_string and Test_func_concat; the full suite passes. The xmllint property tests in #114 are a complementary differential check, but their generators use integer-only literals and string/path arguments, so these particular edges aren't currently exercised.

strconv.FormatFloat(_, 'g', -1) emits scientific notation and Go's
+Inf/-Inf/-0 tokens, but XPath 1.0 (REC 4.2) requires finite numbers in
decimal form with no exponent, "Infinity"/"-Infinity" for the
infinities, "NaN", and "0" for negative zero. Add formatNumber
following that rule and use it in asString.

concat also silently dropped non-string arguments: its type switch only
handled string and node-set, so concat('id-', 5, '-x') returned 'id--x'.
Route each argument through asString so number and boolean arguments are
stringified the same way string() converts them.
@zhengchun
zhengchun merged commit 30af867 into antchfx:master Jul 19, 2026
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