Conversation
Entries of a message literal had to be separated by a comma. In protobuf's
text format they are separated by whitespace; a comma or a semicolon may
appear between them but neither is required. Google's protos separate
entries with a newline alone, so none of them parsed:
option (google.api.http) = {
post: "/v18/customers/{customer_id=*}/googleAds:search"
body: "*"
};
Fixing the comma alone is not enough. Measured over 11 real googleapis
protos (11,266 lines), three more constructs are involved, and the most
easily missed is adjacent string concatenation, which every service proto
that declares OAuth scopes relies on:
option (google.api.oauth_scopes) =
"https://www.googleapis.com/auth/cloud-platform,"
"https://www.googleapis.com/auth/pubsub";
So this change makes four things parse: entries separated by whitespace or
by ";"; a colon omitted before a message value; a keyword used as a field
name, via the field_name nonterminal the grammar already has; and adjacent
string literals concatenated. The colon stays mandatory before a scalar
value, since protoc rejects `{ post "a" }`.
The lexer is untouched and no new token is introduced. ocamlyacc still
reports zero conflicts, and a control grammar that makes the same
juxtaposition ambiguous in the list form does report one, so that zero is a
real measurement. Over the 11-file corpus, parsing goes from 5 files to 11.
Against protoc 23.2 over 46 aggregate-syntax cases, agreement goes from
21/46 to 39/46. Every production added here is pinned by its own case in
the new unit test.
Angle-bracket message delimiters and bracketed extension keys are left out
deliberately: both occur zero times in the corpus, and an extension key has
no representation in the public option type that round-trips today.
Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
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 #254.
Problem
ocaml-protoccannot parse most real Google API.protofiles. From the issue:rpc Search(SearchGoogleAdsRequest) returns (SearchGoogleAdsResponse) { option (google.api.http) = { post: "/v18/customers/{customer_id=*}/googleAds:search" body: "*" }; }The parse dies exactly where
bodybegins, because entries of a message literal had tobe separated by a comma:
In protobuf's text format entries are separated by whitespace; a comma or a semicolon may
appear between them but neither is required. Google's protos separate entries with a
newline alone, so none of them parsed.
Fixing only that is not enough to make the issue go away. I took a corpus of 11 real
googleapis files (11,266 lines: pubsub, spanner, firestore, cloudbuild, language,
longrunning, and the
google/apisupport protos) and measured what actually breaks. Fourdistinct constructs are involved, and a fix that stops at the comma still leaves 4 of the
11 failing:
{ post: "a" body: "b" }additional_bindingsorrouting_parameters{ additional_bindings { get: "/x" } }option (google.api.oauth_scopes) = "https://…/a," "https://…/b";{ to: 1 },{ option: 2 },{ max: 3 }The third is the one that is easy to miss. Every service proto that declares OAuth scopes
wraps the value across lines, and C-style adjacent string concatenation is a grammar
feature, not a lexer one:
The colon stays mandatory before a scalar value:
protocrejects{ post "a" }and sodoes this patch. Making the colon optional everywhere would have accepted protos that
protocrefuses.Keyword field names are included despite zero corpus occurrences because the cost is one
token change:
option_content_map_itemtakes thefield_namenonterminal the grammaralready has for exactly this purpose, which also simplifies the action from
snd $1, $3to$1, $3.Fix
Confined to
src/compilerlib/pb_parsing_parser.mly. The lexer is untouched and no newtoken is introduced;
T_semi,T_commaandT_colonall already existed.option_separatoris a new nonterminal covering,and;, andoption_content_mapnow admits entries with no separator between them.
option_message_valueis factored out ofoption_value, so a message literal iswritten the same way in both the
key: { … }andkey { … }positions.option_content_map_itemtakesfield_nameinstead ofT_ident, and gains acolon-less form whose value is a message literal.
string_literalis a new nonterminal so adjacent string literals concatenate, which iswhat
protocdoes everywhere a string constant is accepted.Testing
ocamlyaccconflicts: 0, unchanged from master. This is the invariant that mattersmost, since a new conflict would silently change how existing protos parse. I did not take
the absence of a warning on faith: a control grammar that extends the same juxtaposition
trick into the list form (where
[ "a" "b" ]really is ambiguous once adjacent stringsconcatenate) makes
ocamlyaccreport1 shift/reduce conflict, which confirms that silenceon the real grammar is meaningful.
Real googleapis protos, the 11-file corpus described above:
Differential oracle against
protoc23.2, 46 hand-written aggregate-syntax cases. Theoracle declares the custom option for real (
extend google.protobuf.MethodOptions) so thatprotocactually parses and type-checks the aggregate body. That detail is load-bearing:with an undeclared option
protocstops at "Option ... unknown" and never looks insidethe braces, so it "accepts" garbage like
{ post: }. The harness therefore self-tests andrefuses to report anything unless
protocrejects all 8 malformed negative cases.Agreement goes from 21/46 to 39/46.
New unit test
src/tests/unit-tests/parse_aggregate_option.ml: 20 accepted cases thatassert the parsed
Pb_option.valuestructure, plus 5 that must be rejected (including{ post "a" }, which guards the scalar-colon rule). Every production added by this patchis pinned by its own named case, checked with a mutation battery that removes one production
at a time, rebuilds, and requires the test to fail; all 8 mutants are killed, with no
survivors and none lost to a compile error.
The existing suite is unaffected.
One behaviour worth calling out, since the colon-less form is what makes it reachable. The
text format writes a repeated field by repeating the key, which is how
google/api/http.protodocuments
additional_bindings:That now parses, and both entries are preserved in the resulting
Message_literal. Anythingthat looks an option up by name still sees only the first, because
assoc_option_nameinpb_raw_option.mlis aList.find. That is a property of the existing option representationrather than something this change introduces, and it does not affect type generation, which is
what the issue is about.
Deliberately not included
The remaining 7 oracle disagreements are all constructs with zero occurrences in the
corpus, and I left them out to keep this PR to what the issue is about. Happy to follow up
on any of them:
{ msg < b: 1 > }. Measured conflict-free, sothis is a scope call rather than a risk one.
{ [google.api.http]: 1 }. Also conflict-free, but it needs aconvention decision first:
Pb_option.valueis a re-export of the public runtime typePbrt_options.value, so an extension key has nowhere to live but a barestring, and akey spelled that way is then silently dropped by the generated decoders
(
pb_codegen_decode_pb_options.ml). I would rather agree the representation with youthan invent one.
#comments inside an aggregate, whichprotocaccepts there.{ msg.b: 1 }, whichprotocrejects. The lexer'sfull_identfolds adotted name into one
T_ident, so master accepted these too; the behaviour is unchangedhere, and tightening it is a separate question about the lexer.
returnsas a field name.field_namecovers 20 of the 21 keywords and omits exactlythis one, which looks like drift against the note at
pb_parsing_lexer.mll:60-64. It isa one-line addition but it changes behaviour outside options, so it belongs in its own PR.
Concatenation is added to
constant, so it applies wherever a string constant is accepted:option values, field defaults (
[default = "a" "b"]) and enum-value options all behave likeprotocnow. It does not reachsyntax = "pro" "to3";orimport "a" "b";, becausethose two rules take
T_stringdirectly rather than a constant.protocaccepts both.That divergence predates this change and I left it alone rather than widen the diff; say the
word if you would like it folded in.
One more pre-existing divergence is left alone.
{ nums: [1, 2,] }(trailing comma in alist) is accepted by
ocaml-protocbut rejected byprotoc; it predates this change, andthe new test pins it as-is so the behaviour is not altered by accident.
Note
This PR was written with AI assistance (Claude). The differential oracle, the corpus
measurement and the mutation battery are scripts I ran and checked; every number above is
reproducible and I am glad to share the harness.