fix: restore the character that terminates a number - #5344
Draft
nlohmann wants to merge 1 commit into
Draft
Conversation
operator>> is documented to leave the stream positioned right after the
parsed value, so that concatenated JSON values can be read back to back.
That did not hold for numbers: a number is only terminated by the
character following it, and lexer::scan_number() reads that character
and calls unget() -- which is simulated and rewinds only the lexer's own
bookkeeping. input_stream_adapter consumes via sbumpc() with no matching
sungetc(), so the terminating character stayed consumed and the next
extraction started one byte too late ('1true' left the stream at 'rue').
Propagating unget() to the adapter directly does not work: next_unget
makes the following get() replay the cached character, so the terminator
would be delivered twice. Instead, restore the still-pending character
once at the end of a non-strict parse, where the input is handed back to
the caller:
- input_stream_adapter gains unget_character() (sungetc()) and advertises
it via supports_unget, detected the same way as supports_seek.
- lexer::restore_pending_unget() turns a pending simulated unget of a
real (non-EOF) character into a real one and clears next_unget so the
character is not also replayed. It is a no-op for adapters that cannot
unget, and reports failure when sungetc() fails, in which case the
input is left as it was before.
- parser calls it on the three non-strict paths, i.e. for operator>> and
sax_parse(strict = false).
Strict parse()/accept() are unaffected: they require the input to end
after the value, so the character is consumed by the end-of-input check
anyway. Parse error messages and reported positions are unchanged.
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
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.
Fixes #5340. Draft: this changes observable behavior of
operator>>andsax_parse(strict = false), so it is a candidate for the next major release rather than a patch release. The version numbers in the docs assume 4.0.0 and need adjusting if that changes.Note
Stacked on #5343 (the short-term documentation qualification), so that the docs diff here reads as "replace the caveat with the fixed behavior". GitHub will retarget this to
developonce #5343 merges.The bug
A number is the only JSON value whose end can only be detected by reading the character that follows it.
lexer::scan_number()reads that character and callslexer::unget(), butunget()is deliberately simulated — it rewinds only the lexer's own bookkeeping (chars_read_total,chars_read_current_line,token_string), not the input.input_stream_adapter::get_character()consumes viasbumpc()with no matchingsungetc(), so the terminating character stays consumed:This is invisible to
parse()/accept(), which require the input to end after the value anyway. It is only observable where the caller keeps using the input:operator>>andsax_parse(strict = false).The fix
Propagating
unget()straight through to the adapter does not work —next_ungetmakes the followingget()replay the cached character, so the terminator would be delivered twice. The character has to be restored once, at the point where the input is handed back to the caller.input_stream_adaptergainsunget_character()(sungetc()) and advertises it viasupports_unget, detected with the sameis_detectedtag-dispatch idiom already used forsupports_seek. Other adapters compile to a no-op.lexer::restore_pending_unget()turns a pending simulated unget of a real (non-EOF) character into a real one, and clearsnext_ungetso the character is not also replayed. A pending unget of EOF is skipped — EOF was never consumed. This is the samepending_real_ungetcondition the lexer already computes incollect_token_chars.parsercalls it on the three non-strict paths (bothparse()branches andsax_parse), folded into the existingif (strict && …)checks.The invariant that makes this safe:
get()clearsnext_ungetwhenever it consumes it, so a pending unget always refers to the most recent adapter read, and there is never more than one.When
sungetc()failssungetc()can fail if the streambuf has no putback position. Then the character stays consumed — which is exactly today's behavior, so a best-effort unget is never worse than the status quo.restore_pending_unget()reports this rather than asserting. Covered by a test using astreambufwhosepbackfailalways fails.Verification
1truerue❌true✅1 truetrue❌true✅1[2]2]❌[2]✅1{}}❌{}✅1"a"a"❌"a"✅-0.5e3xx✅Note
1 truewas wrong before too — the issue's table lists it as fine because the swallowed byte happened to be whitespace, but the position was still off by one.tests/src/unit-deserialization.cpp(stream position after extraction (#5340)): number terminators for every following value type, self-delimiting values, a number at end of input, repeated extraction of1true[2]3"x"{"a":4}5,sax_parse(strict = false), strict parsing still rejecting trailing data, and the failing-putback streambuf. 8 assertions in this section fail without the code change.unit-class_parser,unit-class_lexer,unit-deserialization,unit-class_parser_diagnostic_positions,unit-user_defined_input,unit-regression2,unit-disabled_exceptionspass against bothinclude/andsingle_include/— 20,398 assertions, 0 failures.develop.Public API
No breaking changes to the public API surface: no signature, type, or name changes; nothing added to or removed from the public interface.
lexer::restore_pending_unget()andinput_stream_adapter::unget_character()are new members indetail::.There is a behavior change, which is why this is a draft targeting the next major release:
operator>>andsax_parse(strict = false)on astd::istreamnow leave the stream one byte earlier when the parsed value was a number. Code that relied on the terminating byte being swallowed will observe it again.parse(),accept(), and all non-stream inputs (strings, iterators, containers,FILE*) are unchanged.Follow-up
docs/mkdocs/docs/api/operator_gtgt.mdandsax_parse.mdsay "version 4.0.0"; these need updating if the change lands elsewhere. #5343's admonition is replaced here by the version note.unit-deserialization.cpp; verified to fail without the fix).make amalgamatewas run;single_includematchesinclude(+96/−3, no unrelated reformatting).This pull request was written by Claude Code.