Conversation
The decoder strips quoteless strings with strings.TrimSpace, which is
Unicode-aware, but needsQuotes anchors on Go RE2 \s, which is ASCII
only. A value starting or ending with U+00A0, U+1680, U+2000-U+200A,
U+205F or U+3000 was therefore written quoteless and came back short:
Marshal(map[string]interface{}{"k": " ab"})
// {
// k: ab
// }
Unmarshal of that output gives "ab"
needsQuotes already enumerates non-ASCII code points by hand for exactly
this reason; the list just stops short of the spaces TrimSpace removes.
Test with the same function the decoder uses, so the two cannot drift.
hjson-js quotes all of these.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The inconsistency
The decoder strips quoteless strings with
strings.TrimSpace, which isUnicode-aware (
decode.go:511, under the comment "remove any whitespace at theend (ignored in quoteless strings)" at
:510).The encoder decides whether to quote with
needsQuotes(encode.go:82), whichanchors on
^\sand\s$. Go's RE2\sis ASCII only —[\t\n\f\r ].So a value that starts or ends with U+00A0, U+1680, U+2000–U+200A, U+205F or
U+3000 is written quoteless by
quote()(encode.go:136) and then trimmed awaywhen it is read back.
needsQuotesalready enumerates non-ASCII code points by hand for exactly thisreason —
\x{2028}-\x{202f},\x{feff}and friends are in there preciselybecause
\scannot reach them. The list just stops short of the spacesTrimSpaceremoves.What a caller sees
Silent data loss through the library's own round trip. Measured on master, for
{rune}+"ab"and"ab"+{rune}:"ab"unicode.IsSpace, correctly left quoteless)hjson-jsquotes every one of the failing cases, so the two implementationsdisagree here.
The change
One condition, using the same function the decoder uses, so the two cannot drift
apart again.
quoteName(encode.go:189) is a separate helper for keys and is unaffected —the key reader does not
TrimSpace, and I checked thatkandkas keys already round-trip correctly on master.
The alternative
Widening the
^\s/\s$anchors inneedsQuotesto an explicit class(
[\t\n\v\f\r \x{0085}\x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}])also passes every test and mutation row here. I went with
TrimSpacebecause itis one line and it is literally the function the parser calls, so the encoder and
decoder cannot disagree in future. Both were benchmarked and neither is
distinguishable from master (prebuilt binaries, rotating order, 30 reps,
Marshalofassets/pass1_test.json): master min 115581 ns/op, regex-widen114906,
TrimSpace112578 — all inside this box's run-to-run drift. Happy toswap to the regex form if you prefer keeping the decision in one regexp.
I did not touch the decoder: making it trim ASCII-only would change the meaning
of documents already on disk, and
hjson-jssettles the direction by quoting onoutput.
Tests
TestQuoteUnicodeWhitespaceasserts both the quoting decision and the roundtrip, for each rune in both leading and trailing position.
The
U+200BandU+180Erows are the ones that matter for correctness of thefix: those are not
unicode.IsSpace,TrimSpacedoes not strip them, andthey must stay quoteless. Over-correcting to a cutset that also trims U+200B
fails that row; dropping U+3000 from the class fails the U+3000 row; reverting
fails the six uncovered runes.
Verification
go test ./...green, including all 155 asset conformance cases.gofmt -l .clean. The fix uses only
strings.TrimSpaceandlen, and the test onlybytes.Containsand a struct-literal table, so the go 1.12 CI row is fine. Ialso ran the four
hjson-cli | diffsteps fromtest.ymllocally after the fix— all four match.
Separately, I re-ran a full structural differential of hjson-go against
hjson-js@5734a70over all 180 shared asset documents after the fix: 0divergences.
Note
Open PR #64 also touches
quote()(it reflows the signature). It has been idlesince 2025-06-20, and my insertion sits directly under a line it keeps, so the
conflict should be trivial — but flagging it in case you want them ordered.
Precedent for the bug class: your #42, "Marshal
\rto quoted strings" — acharacter the encoder emitted unquoted that did not survive
Unmarshal.Disclosure
I used an AI assistant to help find and prepare this change. I reviewed and
tested it myself, and the numbers above are from runs I performed.