<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pal</title>
    <description>The latest articles on DEV Community by Pal (@pal11103).</description>
    <link>https://dev.to/pal11103</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4064494%2F6f4f7f5b-f3c7-4a7a-9f1c-09664c16f659.png</url>
      <title>DEV Community: Pal</title>
      <link>https://dev.to/pal11103</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pal11103"/>
    <language>en</language>
    <item>
      <title>The standard library is not a validator: 72 hours of zero-dependency JSON in Rust</title>
      <dc:creator>Pal</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:32:16 +0000</pubDate>
      <link>https://dev.to/pal11103/the-standard-library-is-not-a-validator-72-hours-of-zero-dependency-json-in-rust-afj</link>
      <guid>https://dev.to/pal11103/the-standard-library-is-not-a-validator-72-hours-of-zero-dependency-json-in-rust-afj</guid>
      <description>&lt;p&gt;I spent last weekend building a JSON toolkit in Rust under one rule: &lt;strong&gt;no third-party dependencies&lt;/strong&gt;. Not "few". None. The &lt;code&gt;[dependencies]&lt;/code&gt; table in &lt;code&gt;Cargo.toml&lt;/code&gt; is present and empty, and &lt;code&gt;Cargo.lock&lt;/code&gt; holds exactly one package — the project itself. No &lt;code&gt;serde&lt;/code&gt;, No &lt;code&gt;serde_json&lt;/code&gt;, No &lt;code&gt;clap&lt;/code&gt;, No &lt;code&gt;itoa&lt;/code&gt;, No &lt;code&gt;ryu&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That constraint is the premise of the &lt;a href="https://hackathon-raptors.com" rel="noopener noreferrer"&gt;Zero Dependency hackathon&lt;/a&gt;,&lt;br&gt;
and it is a good premise, because it forces you to find out what the standard library actually promises. Here is the thing I did not expect to find:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;About 10% of the JSON documents that RFC 8259 says a parser &lt;strong&gt;must reject&lt;/strong&gt; are accepted by Rust's own number parser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not a subtle 10%. &lt;code&gt;NaN&lt;/code&gt;, &lt;code&gt;Infinity&lt;/code&gt;, &lt;code&gt;.5&lt;/code&gt;, &lt;code&gt;5.&lt;/code&gt;, &lt;code&gt;+1&lt;/code&gt; and &lt;code&gt;012&lt;/code&gt; are all invalid JSON, and&lt;br&gt;
&lt;code&gt;f64::from_str&lt;/code&gt; and &lt;code&gt;i64::from_str&lt;/code&gt; take every one of them. If you write a JSON parser the obvious way — scan to the end of the number token, hand the slice to &lt;code&gt;from_str&lt;/code&gt; — you ship a parser that is silently non-conformant, with no warning anywhere.&lt;/p&gt;

&lt;p&gt;I have the number because I counted it against a real corpus before writing the parser. The rest of this post is what that measurement did to the design, and what generalizes to languages that are not Rust.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/pal-123456789/jaq-lite" rel="noopener noreferrer"&gt;&lt;strong&gt;jaq-lite&lt;/strong&gt;&lt;/a&gt; is a hand-rolled RFC 8259 parser, a serializer, and a jq-style query CLI with &lt;code&gt;rustc&lt;/code&gt;-style caret diagnostics — 4,670 lines under &lt;code&gt;src/&lt;/code&gt; and 4,432 lines of tests, standard library only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"users":[{"name":"ada","age":36},{"name":"linus","age":54}]}'&lt;/span&gt; | jaq-lite &lt;span class="s1"&gt;'.users[] | .name'&lt;/span&gt;
&lt;span class="go"&gt;"ada"
"linus"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It supports identity, field access, quoted fields, indexes, iteration, pipes, commas, parentheses, the optional operator &lt;code&gt;?&lt;/code&gt;, and eleven builtins (&lt;code&gt;length&lt;/code&gt;, &lt;code&gt;keys&lt;/code&gt;, &lt;code&gt;keys_unsorted&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;to_entries&lt;/code&gt;, &lt;code&gt;from_entries&lt;/code&gt;, &lt;code&gt;flatten&lt;/code&gt;, &lt;code&gt;first&lt;/code&gt;, &lt;code&gt;last&lt;/code&gt;, &lt;code&gt;reverse&lt;/code&gt;, &lt;code&gt;not&lt;/code&gt;). Exit codes follow jq: 2 for a bad flag, 3 for a filter that does not compile, 5 for input that is not JSON, 0 otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/nst/JSONTestSuite" rel="noopener noreferrer"&gt;JSONTestSuite&lt;/a&gt; is the standard conformance corpus:&lt;br&gt;
318 files in &lt;code&gt;test_parsing/&lt;/code&gt;, named by what a parser is supposed to do with them. 95 &lt;code&gt;y_&lt;/code&gt; files must parse, 188 &lt;code&gt;n_&lt;/code&gt; files must be rejected, and 35 &lt;code&gt;i_&lt;/code&gt; files are cases the standard leaves to the implementation. I vendored it at a pinned commit before writing a line of parser code.&lt;/p&gt;

&lt;p&gt;51 of those files are &lt;code&gt;n_number_*&lt;/code&gt;. Four contain invalid UTF-8, leaving 47 that are a single well-formed literal. I tested each one against Rust's &lt;em&gt;documented&lt;/em&gt; &lt;code&gt;from_str&lt;/code&gt; grammars — the float grammar being &lt;code&gt;Sign? (inf | infinity | nan | Number)&lt;/code&gt;, case insensitive, and the integer grammar being &lt;code&gt;Sign? Digit+&lt;/code&gt; with leading zeros permitted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;19 of the 47 are accepted.&lt;/strong&gt; Fifteen by &lt;code&gt;f64::from_str&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.123    -.123   .2e-3        &amp;lt;- float grammar allows Digit* '.' Digit+
1.      -2.     0.e1         &amp;lt;- and Digit+ '.' Digit*
2.e3    2.e+3   2.e-3
Inf     +Inf    Infinity     &amp;lt;- the inf / infinity keywords, case-insensitive
-Infinity       NaN   -NaN   &amp;lt;- and nan, with or without a sign
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And four more by &lt;code&gt;i64::from_str&lt;/code&gt;, which permits a leading &lt;code&gt;+&lt;/code&gt; and leading zeros: &lt;code&gt;+1&lt;/code&gt;, &lt;code&gt;012&lt;/code&gt;, &lt;code&gt;-01&lt;/code&gt;, &lt;code&gt;-012&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nineteen fixtures is about a tenth of the entire must-reject set. Delegating validation to &lt;code&gt;from_str&lt;/code&gt; fails all nineteen with no error message, no warning, and no test failure anywhere except a conformance harness you had to build first in order to notice.&lt;/p&gt;

&lt;p&gt;This is not a bug in Rust, and that is the part worth internalizing. &lt;code&gt;from_str&lt;/code&gt; is a &lt;strong&gt;conversion&lt;/strong&gt;, and its contract is Rust's literal syntax, not JSON's. It is permissive about exactly the forms Rust's own grammar permits, which is why it takes &lt;code&gt;inf&lt;/code&gt; and &lt;code&gt;+1&lt;/code&gt;.&lt;br&gt;
Nothing in its signature or its name claims to be a validator. I reached for it because it was the obvious tool, and the obvious tool answers a different question.&lt;/p&gt;

&lt;p&gt;So the number grammar is hand-written: a small state machine over bytes that enforces RFC 8259's actual production — optional minus, an integer part that is either &lt;code&gt;0&lt;/code&gt; alone or a nonzero digit followed by digits, an optional fraction that must have at least one digit after the dot, an optional exponent that must have at least one digit after &lt;code&gt;e&lt;/code&gt; and its optional sign — and then, critically, checks the terminator. &lt;code&gt;1 000.0&lt;/code&gt; is in the corpus precisely because a grammar that stops at the space and does not ask what follows will accept &lt;code&gt;1&lt;/code&gt; and then either choke or silently succeed.&lt;/p&gt;

&lt;p&gt;The state machine is a few dozen lines. It was the highest-value few dozen lines in the project, and I only knew that because I had counted the fixtures first.&lt;/p&gt;
&lt;h2&gt;
  
  
  This is not a Rust problem
&lt;/h2&gt;

&lt;p&gt;Before publishing that finding I checked whether it was a quirk of one standard library. It is not. Every language ships a lenient string-to-number conversion, because a conversion's job is to be generous about its own literal forms. Paste these and watch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Infinity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NaN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;012&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1_0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1_0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# digit separators, since 3.6
&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;10.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  12  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# surrounding whitespace is stripped
&lt;/span&gt;&lt;span class="mf"&gt;12.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python goes one better, and this one genuinely surprised me — the standard library's &lt;em&gt;JSON module itself&lt;/em&gt; is non-conformant by design:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NaN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Infinity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-Infinity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is documented behaviour, not a bug, and it is why a payload written by Python's &lt;code&gt;json.dumps&lt;/code&gt; can be rejected by a strict reader in another language. If you exchange JSON with a Python service, &lt;code&gt;allow_nan=False&lt;/code&gt; is worth knowing about.&lt;/p&gt;

&lt;p&gt;JavaScript's &lt;code&gt;Number()&lt;/code&gt; is looser still, because it accepts every numeric literal form the language has, including bases JSON does not have at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 0.5      Number("0x1")  // 1&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 5        Number("0b11") // 3&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 1        Number("0o7")  // 7&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;012&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 12       Number(" 12 ") // 12&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Infinity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Infinity     Number("") // 0  &amp;lt;- the empty string is zero&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;JSON.parse&lt;/code&gt; is strict about all of them, which is the point: V8 ships a separate grammar for JSON rather than reusing &lt;code&gt;Number()&lt;/code&gt;. Somebody there had this same realization and paid for the second implementation.&lt;/p&gt;

&lt;p&gt;The transferable rule is small enough to remember: &lt;strong&gt;if a function's job is to convert, it is not your validator.&lt;/strong&gt; When a wire format specifies a grammar, implement the grammar. The convenience helper will agree with it most of the time, and the disagreements are exactly the inputs an attacker or a buggy producer will send you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same trap, three more times
&lt;/h2&gt;

&lt;p&gt;Once you are looking for it, the pattern is everywhere in a parser. Three more instances I hit in the same weekend, all from the same root cause — a helper whose contract is &lt;em&gt;nearly&lt;/em&gt; the one you want:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;char::is_whitespace()&lt;/code&gt; follows Unicode &lt;code&gt;White_Space&lt;/code&gt;, which is about a dozen characters including the no-break space U+00A0 and the vertical tab. JSON's whitespace is exactly four bytes: space, tab, line feed, carriage return. A lexer that skips &lt;code&gt;is_whitespace()&lt;/code&gt; accepts documents it must reject.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;char::is_numeric()&lt;/code&gt; accepts &lt;code&gt;１&lt;/code&gt;, U+FF11 FULLWIDTH DIGIT ONE — which is in the corpus as a must-reject fixture. The funny part is that &lt;code&gt;from_str&lt;/code&gt; gets this one right, because Rust's integer parser only takes ASCII digits. So a lexer that uses &lt;code&gt;is_numeric()&lt;/code&gt; to find where a number token &lt;em&gt;ends&lt;/em&gt; fails the fixture even with a perfectly correct grammar behind it. The answer is &lt;code&gt;is_ascii_digit()&lt;/code&gt;, and the general answer is that "is this a digit" is a different question from "is this one of the ten characters my grammar permits".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;read_to_string()&lt;/code&gt; panics on 25 of the 318 fixtures, because they are not valid UTF-8 — 12 of those are must-reject cases, which means the naive harness dies before it can print a score for the very inputs it exists to check. The parser therefore takes &lt;code&gt;&amp;amp;[u8]&lt;/code&gt; and gates UTF-8 explicitly with &lt;code&gt;Utf8Error::valid_up_to()&lt;/code&gt;, so "not valid UTF-8 after 3 bytes" is a diagnostic rather than a panic. The conformance harness uses &lt;code&gt;fs::read&lt;/code&gt;, never &lt;code&gt;read_to_string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And two fixtures nest 100,000 levels deep. Recursive descent on those is a stack overflow, which is a crash and not a rejection — the corpus scores it as a failure either way, but your users experience the difference. Both recursive descents carry an explicit &lt;code&gt;u32&lt;/code&gt; counter and refuse &lt;em&gt;before&lt;/em&gt; recursing: 128 levels for a document, 64 for filter parentheses. Both limits are readable back out of the error variant that reports them, rather than being buried in an English message, because a number in prose is not available to a caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostics are worth the afternoon
&lt;/h2&gt;

&lt;p&gt;The one feature I would keep if I had to cut everything else is the error format. This is &lt;code&gt;rustc&lt;/code&gt;'s shape, and it takes maybe two hours to implement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jaq-lite &lt;span class="nb"&gt;.&lt;/span&gt; bad.json
&lt;span class="go"&gt;jaq-lite: bad.json: line 1, column 2: expected a string as the object key
  |
1 | {1:2}
  |  ^
[exit 5]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The renderer works on raw bytes rather than on a &lt;code&gt;str&lt;/code&gt;, for the reason above: input that is not valid UTF-8 is one of the failures it has to draw a caret under. A position is stored as a byte offset and lines are only counted when a message is actually printed, so nothing pays for a line table it never uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 35 files where the standard declines to decide
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;i_&lt;/code&gt; fixtures are the interesting third of the corpus: lone surrogates, a UTF-8 BOM before an empty object, integers that exceed 64 bits, duplicate keys. RFC 8259 permits a parser to accept or reject each of these, so a score against them means nothing — you can claim any number you like by changing your mind.&lt;/p&gt;

&lt;p&gt;What I did instead was write four policies before looking at the files, and derive all 35 decisions from them. Policy 1 is the UTF-8 gate and settles 14 of the cases. The other three handle the numeric range, duplicate keys, and structural edge cases, covering 10, 10 and 1 respectively, with no residue: 10 accepted, 25 rejected, every one traceable to a rule rather than to a judgement call made at 2am.&lt;/p&gt;

&lt;p&gt;That is the transferable move for implementation-defined behaviour anywhere: &lt;strong&gt;publish the policy, not the tally.&lt;/strong&gt; A reader can disagree with a policy. A tally offers nothing to disagree with.&lt;/p&gt;

&lt;h2&gt;
  
  
  A number is the bytes it was written with
&lt;/h2&gt;

&lt;p&gt;The design decision I am happiest about started as a compatibility question. jq does not round-trip numbers through a float: give it &lt;code&gt;1.0&lt;/code&gt; and you get &lt;code&gt;1.0&lt;/code&gt; back, &lt;code&gt;-0.0&lt;/code&gt; stays &lt;code&gt;-0.0&lt;/code&gt;, and a 30-digit integer comes back intact. It only canonicalizes exponent form,&lt;br&gt;
turning &lt;code&gt;1e2&lt;/code&gt; into &lt;code&gt;1E+2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So a number here is not an &lt;code&gt;f64&lt;/code&gt;. It is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// the exact span the RFC 8259 grammar validated&lt;/span&gt;
    &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// for comparisons and arithmetic-free semantics&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Construction is crate-private, which makes "&lt;code&gt;raw&lt;/code&gt; was validated" a structural invariant rather than a comment. Three things fall out of it. Round-tripping becomes byte-exact over all 95 documents that must parse, rather than merely semantically equal. &lt;code&gt;-0.0 != 0.0&lt;/code&gt; comes free from comparing the text, while &lt;code&gt;PartialEq&lt;/code&gt; on &lt;code&gt;val&lt;/code&gt; still gives jq's &lt;code&gt;1.0 == 1&lt;/code&gt;. And it &lt;em&gt;deletes&lt;/em&gt; code from the serializer: no appending &lt;code&gt;.0&lt;/code&gt; to integral floats, no scientific-notation threshold, no non-finite branch, because a literal the grammar accepted can never be &lt;code&gt;inf&lt;/code&gt; or &lt;code&gt;NaN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cost is real and I state it rather than hiding it: roughly 16 bytes plus the digits against 8 for a bare &lt;code&gt;f64&lt;/code&gt;, which a number-dense document pays for.&lt;/p&gt;

&lt;p&gt;Numbers the tool &lt;em&gt;synthesizes&lt;/em&gt; — &lt;code&gt;length&lt;/code&gt;, indices — are the only remaining place text gets generated, and they go through &lt;code&gt;core::fmt::NumBuffer&lt;/code&gt; with &lt;code&gt;format_into&lt;/code&gt;. That is also the &lt;code&gt;itoa&lt;/code&gt; replacement, and it is measurably faster than the &lt;code&gt;format!&lt;/code&gt; most people would reach for: 1.68 times, measured. One consequence is a named divergence from jq, in the README rather than in a footnote: &lt;code&gt;1e2&lt;/code&gt; stays &lt;code&gt;1e2&lt;/code&gt; here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility is compared, not remembered
&lt;/h2&gt;

&lt;p&gt;"jq-compatible" is the kind of claim that rots quietly. So CI runs 62 comparisons against a real jq 1.8.1 binary on every push: eight identity cases, six path expressions, 29 builtins over the corpus, and 19 over shapes the script writes into its own temporary directory. It reads its builtin roster out of the code's own match arms and &lt;strong&gt;fails if a builtin exists that no comparison reaches&lt;/strong&gt;, which is a different and much more useful failure than a disagreement — it catches the feature you added and forgot to compare.&lt;/p&gt;

&lt;p&gt;An external binary is not a dependency: nothing about jq ships in the artifact, and the comparison exists so that "jq-compatible" is a measurement rather than an adjective.&lt;/p&gt;

&lt;p&gt;One trap worth passing on. jq's Windows build writes CRLF, because its stdout is in text mode; Rust writes LF. A naive byte diff therefore fails on every single line for a reason that has nothing to do with correctness, and Git Bash does not save you. The differential runs under WSL, which is also where the reproducibility check lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I made my documents fail the build
&lt;/h2&gt;

&lt;p&gt;This is the practice I would take to any codebase, and it came out of being burned. A README number goes stale in the commit &lt;em&gt;after&lt;/em&gt; the one that wrote it, and prose does not fail a build, so nothing tells you. In this repo, 14 of the 191 tests do nothing but read documents and compare them to the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every hash recorded in &lt;code&gt;BUILD_LOG.md&lt;/code&gt; must agree with the others that describe the same
build&lt;/li&gt;
&lt;li&gt;the README must quote the reproducible-build script's assertion phrases &lt;strong&gt;verbatim&lt;/strong&gt;, and
the CI job the README names by name must exist in &lt;code&gt;ci.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the README must contain &lt;strong&gt;no&lt;/strong&gt; 64-hex-character token, so the decision not to publish a
digest as if it were a constant cannot quietly reverse&lt;/li&gt;
&lt;li&gt;the test count stated in &lt;code&gt;CLAIMS.md&lt;/code&gt; is grepped out of &lt;code&gt;src/&lt;/code&gt; and &lt;code&gt;tests/&lt;/code&gt; rather than
typed, because it had already gone stale twice&lt;/li&gt;
&lt;li&gt;every limit the code enforces must be stated in the README, and every builtin the code has
must be named there&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one that convinced me was a row in the claims ledger that said &lt;em&gt;thirteen&lt;/em&gt; while the file it described held &lt;em&gt;fourteen&lt;/em&gt; tests. Every gate passed while it was false, because no program was reading it. Now one is. A rewording fails in the commit that reworded it.&lt;/p&gt;

&lt;p&gt;The general shape: for each claim in your docs, ask what file would contradict it, then read both in a test. It costs about twenty lines each and it converts documentation from a liability into an asset that CI defends.&lt;/p&gt;

&lt;h2&gt;
  
  
  A digest is evidence about a tree, not about a project
&lt;/h2&gt;

&lt;p&gt;The build is byte-for-byte reproducible, and the mechanism is three lines in &lt;code&gt;Cargo.toml&lt;/code&gt;:&lt;br&gt;
&lt;code&gt;codegen-units = 1&lt;/code&gt; removes parallel-codegen nondeterminism, and &lt;code&gt;debug = 0&lt;/code&gt; with &lt;code&gt;strip = "symbols"&lt;/code&gt; removes the absolute paths and symbol tables that otherwise record where the build happened.&lt;/p&gt;

&lt;p&gt;Two details make the check honest. The script builds in two directories of &lt;strong&gt;different lengths&lt;/strong&gt;, because equal-length paths give a false pass — a path baked into the binary would still compare equal. And it runs a control build with debug info left in, which yields a different digest every time; that is the sensitivity check that proves the comparison can fail at all. A test that cannot fail is not evidence.&lt;/p&gt;

&lt;p&gt;The published digests are dated samples tied to the commit that produced them, not constants.&lt;br&gt;
The binary was 468,704 bytes at commit 34 and 490,336 at commit 65, because 31 commits of source landed in between. Publish the recipe; date the digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reporting speed without lying
&lt;/h2&gt;

&lt;p&gt;The README prints 26.5 MiB/s for parsing and 197.9 for serializing, over a 1,196,671-byte document the test generates for itself. Then it spends a paragraph explaining why you should not trust that figure: five runs of one unchanged binary, across two boots of the same laptop, span 17.7 to 41.8 MiB/s. A factor of 2.4, from source that did not change.&lt;/p&gt;

&lt;p&gt;So the suite asserts a &lt;strong&gt;floor&lt;/strong&gt; and never the figure — 5 MiB/s release, 1 debug, raisable through an environment variable and lowerable by nothing. A regression that changes the shape of the algorithm fails a test; an unlucky sample on a busy machine does not. And what gets printed is the mean over the timing window rather than the fastest round in it, because interference only ever slows a round down, which makes the fastest round the flattering one to report and not the one a caller experiences.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;No arithmetic, no variables, no user-defined functions. &lt;code&gt;JQ_COLORS&lt;/code&gt; is not parsed. There is no &lt;code&gt;--max-depth&lt;/code&gt; for either nesting limit. &lt;code&gt;1e2&lt;/code&gt; does not become &lt;code&gt;1E+2&lt;/code&gt;. All of that is in the README, and one of those 14 tests fails the build if the code enforces a limit the README does not state — which is the only reason I trust the list to still be complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;git clone https://github.com/pal-123456789/jaq-lite
cd jaq-lite
cargo test --release
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;191 tests, no network access after the clone, and one package in the lockfile.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/pal-123456789/jaq-lite" rel="noopener noreferrer"&gt;pal-123456789/jaq-lite&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/pal-123456789/jaq-lite/releases/tag/v0.1.0" rel="noopener noreferrer"&gt;v0.1.0 release&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://youtu.be/roh0pGwUPsQ" rel="noopener noreferrer"&gt;five-minute walkthrough&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you take one thing from this: the corpus came first, and it is the reason I know a number instead of an opinion. Whatever format you are parsing, find the conformance suite before you write the parser. Nineteen files is not a lot to fix on day one, and it is a very unpleasant thing to discover in production.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>json</category>
      <category>testing</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How We Ported Python’s natsort to Rust — And the 133,000 Fuzz Inputs That Proved Us Wrong</title>
      <dc:creator>Pal</dc:creator>
      <pubDate>Wed, 05 Aug 2026 15:26:51 +0000</pubDate>
      <link>https://dev.to/pal11103/how-we-ported-pythons-natsort-to-rust-and-the-133000-fuzz-inputs-that-proved-us-wrong-5</link>
      <guid>https://dev.to/pal11103/how-we-ported-pythons-natsort-to-rust-and-the-133000-fuzz-inputs-that-proved-us-wrong-5</guid>
      <description>&lt;p&gt;&lt;strong&gt;A post-mortem on building natsort-rs, fixing a 45x performance bug, and catching subtle edge cases with a custom Pytest monkeypatch bridge.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Why natsort?&lt;/strong&gt;&lt;br&gt;
Porting Python code to Rust using the "Astral playbook" sounds straightforward on paper: rewrite the hot path, compile to native binary, profit. We chose Seth M. Morton’s popular natsort library—a ubiquitous utility for natural sorting (item2 before item10).&lt;br&gt;
In Python, natsort relies heavily on dynamic typing, tuple-based comparisons, and fallback numeric parsing (try_int, try_float). Translating Python’s dynamic Union[str, int, float] return types into Rust required introducing explicit tagged enums (ParsedComponent) and a hand-crafted Ord trait implementation in key.rs.&lt;br&gt;
Zero unsafe blocks. Pure memory safety. But getting it to build was only 10% of the battle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Benchmark That Humbling Us (And the Fix)&lt;/strong&gt;&lt;br&gt;
When we ran our first full 10,000-item workload benchmark against Python's natsort, the result was embarrassing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python natsort&lt;/strong&gt;: ~0.24s mean&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our Compiled Rust Port&lt;/strong&gt;: ~0.90s mean&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An native binary running nearly 4x slower than interpreted Python was unacceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Root Cause&lt;/strong&gt;: In our initial implementation, natsorted() recompiled the number-matching regular expression from scratch for every single item in the input array. For 10,000 items, we were recompiling regex 10,000 times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Fix&lt;/strong&gt;: We refactored split.rs to compile the regex once per natsorted() invocation and pass a reference down through the key generation pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Real Result&lt;/strong&gt;: Latency dropped from ~900ms to ~20ms—shifting our port from 4x slower than Python to 11.8x faster on identical workloads. Disclosing this mistake is important: benchmarking without profiling is just guessing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Edge Case That Ate Hours: Bare "nan" Tokens&lt;/strong&gt;&lt;br&gt;
To prove behavioral equivalence, we didn't just write unit tests—we built a differential fuzz harness (fuzz/differential_fuzz.py) that generated randomized strings, floats, and signs, feeding them simultaneously into real Python natsort and our Rust CLI.&lt;br&gt;
At iteration 125,000 (REAL mode, Seed 2), the fuzzer flagged a mismatch (1 out of 500 batches / 0.2% failure rate).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Input&lt;/strong&gt;: A bare text token "nAn" without any accompanying digits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python's Behavior&lt;/strong&gt;: Python's parse_string_factory applied try_float() to every split component—even ones that didn't match the numeric regex. Because Python’s float("nan") parses as NaN, Python treated "nAn" as a float and sorted it first under default NaN ordering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our Rust Port&lt;/strong&gt;: Our regex splitter only attempted float parsing on substrings containing digits or adjacent signs. It treated "nAn" as plain text, placing it alphabetically amidst text strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Fix&lt;/strong&gt;: We rewrote split_into_components_with_regex to split into all pieces (matched and unmatched), uniformly apply float parsing, and insert NUMAFTER-aware separators between adjacent numeric components. We re-ran 133,000+ randomized inputs through the fuzzer across DEFAULT and REAL modes: 0 mismatches.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Pytest Bridge: What Fuzzing Missed&lt;/strong&gt;&lt;br&gt;
Fuzzing is great for random input distribution, but structured logic bugs require deterministic tests.&lt;br&gt;
Instead of re-writing natsort’s test suite in Rust (which risks recreating our own biases), we built a Pytest monkeypatch bridge in tests/original/conftest.py. It intercept calls to natsort.natsorted at module load time and shells out directly to our compiled Rust binary using raw bitmask CLI flags (--alg=).&lt;br&gt;
This bridge caught two major bugs that 133,000 fuzz items completely missed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The NUMAFTER Leading Placeholder Bug&lt;/strong&gt;: When an input string started with a number (e.g., "100_apples"), our sequence-alternation logic inserted a hardcoded empty string as a leading placeholder rather than a NUMAFTER-aware sentinel value. This masked NUMAFTER's effect entirely whenever the first component was numeric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing PRESORT&lt;/strong&gt;: ns.PRESORT had zero implementation in our Rust port—an outright missing feature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once both were fixed, running pytest test_natsorted.py directly against the unmodified upstream Python test file yielded 16 passed, 35 honestly skipped (unsupported locale/path flags), 0 failed, and 0 errors.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;What We Walked Back &amp;amp; Honest Disclosures&lt;/strong&gt;&lt;br&gt;
If you want honest numbers over confident claims, here is what natsort-rs does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Unicode Claim Correction&lt;/strong&gt;: We initially documented that Rust's char::to_digit(10) supported non-ASCII Unicode decimal digits (like Devanagari or Arabic-Indic numerals). A clippy review revealed char::to_digit is strictly ASCII-only at any radix. We updated our documentation to explicitly state ASCII-only numeral support rather than leaving an overclaim standing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numeric Bounds&lt;/strong&gt;: We bounded integers to i64 and floats to f64. Numbers exceeding 9.2 × 10¹⁸ fall back gracefully to text comparison rather than arbitrary-precision integer comparisons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy OS Locales&lt;/strong&gt;: We explicitly skipped ns.LOCALE and os_sorted. Replicating 30-entry legacy OSX/BSD lookup tables or linking against heavy ICU C-libraries added build complexity for non-deterministic OS features.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codebase&lt;/strong&gt;: 0 unsafe blocks, clean clippy &amp;amp; fmt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing&lt;/strong&gt;: 42 Rust-native tests (including 7 proptest property-based tests verifying Ord transitivity and reflexivity).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parity&lt;/strong&gt;: 133,000+ fuzz items (0 mismatches) + Upstream Pytest suite bridge (16 passed, 0 failed).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt;: 9–12x speedup over Python.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check out the code, benchmark methodology, and full 12-section decision log on GitHub:&lt;br&gt;
🔗 &lt;br&gt;
&lt;a href="//github.com/pal-123456789/port_mortem-natsort-rust"&gt;GitHub Link&lt;/a&gt;&lt;br&gt;
&lt;a href="https://x.com/i/status/2085012198171226326" rel="noopener noreferrer"&gt;X Link&lt;/a&gt;&lt;br&gt;
&lt;a href="https://youtu.be/pX7gl6dFbbY?si=ekWIXhqtSTd94e0h" rel="noopener noreferrer"&gt;YouTube Link&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Submitted for Hackathon Raptors: Port Mortem 2026 (Track D: Python -&amp;gt; Rust) 🦖&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
