Skip to content

Parse the option syntax that googleapis actually uses (#254) - #270

Open
MavenRain wants to merge 1 commit into
mransan:masterfrom
MavenRain:fix/aggregate-option-separators-254
Open

MavenRain wants to merge 1 commit into
mransan:masterfrom
MavenRain:fix/aggregate-option-separators-254

Conversation

@MavenRain

@MavenRain MavenRain commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #254.

Problem

ocaml-protoc cannot parse most real Google API .proto files. From the issue:

rpc Search(SearchGoogleAdsRequest) returns (SearchGoogleAdsResponse) {
  option (google.api.http) = {
    post: "/v18/customers/{customer_id=*}/googleAds:search"
    body: "*"
  };
}
google/ads/googleads/v18/services/google_ads_service.proto:317:10: Parsing error at `{ post : "/v18/customers/{customer_id=*}/googleAds:search" bo

The parse dies exactly where body begins, because entries of a message literal had to
be separated by a comma:

option_content_map :
  | option_content_map_item  { [$1] }
  | option_content_map_item T_comma  { [$1] }
  | option_content_map_item T_comma option_content_map { $1::$3 }

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/api support protos) and measured what actually breaks. Four
distinct constructs are involved, and a fix that stops at the comma still leaves 4 of the
11 failing:

construct occurrences in the corpus example
message-literal entries on their own line, so whitespace-separated 356 { post: "a" body: "b" }
colon omitted before a message value 36, all additional_bindings or routing_parameters { additional_bindings { get: "/x" } }
adjacent string literals concatenated 5 sites across 4 files option (google.api.oauth_scopes) = "https://…/a," "https://…/b";
a keyword used as a field name 0 { 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:

option (google.api.oauth_scopes) =
    "https://www.googleapis.com/auth/cloud-platform,"
    "https://www.googleapis.com/auth/pubsub";

The colon stays mandatory before a scalar value: protoc rejects { post "a" } and so
does this patch. Making the colon optional everywhere would have accepted protos that
protoc refuses.

Keyword field names are included despite zero corpus occurrences because the cost is one
token change: option_content_map_item takes the field_name nonterminal the grammar
already has for exactly this purpose, which also simplifies the action from snd $1, $3 to
$1, $3.

Fix

Confined to src/compilerlib/pb_parsing_parser.mly. The lexer is untouched and no new
token is introduced; T_semi, T_comma and T_colon all already existed.

  • option_separator is a new nonterminal covering , and ;, and option_content_map
    now admits entries with no separator between them.
  • option_message_value is factored out of option_value, so a message literal is
    written the same way in both the key: { … } and key { … } positions.
  • option_content_map_item takes field_name instead of T_ident, and gains a
    colon-less form whose value is a message literal.
  • string_literal is a new nonterminal so adjacent string literals concatenate, which is
    what protoc does everywhere a string constant is accepted.

Testing

ocamlyacc conflicts: 0, unchanged from master. This is the invariant that matters
most, 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 strings
concatenate) makes ocamlyacc report 1 shift/reduce conflict, which confirms that silence
on the real grammar is meaningful.

Real googleapis protos, the 11-file corpus described above:

parse fail
master 5 6
this PR 11 0

Differential oracle against protoc 23.2, 46 hand-written aggregate-syntax cases. The
oracle declares the custom option for real (extend google.protobuf.MethodOptions) so that
protoc actually parses and type-checks the aggregate body. That detail is load-bearing:
with an undeclared option protoc stops at "Option ... unknown" and never looks inside
the braces, so it "accepts" garbage like { post: }. The harness therefore self-tests and
refuses to report anything unless protoc rejects 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 that
assert the parsed Pb_option.value structure, plus 5 that must be rejected (including
{ post "a" }, which guards the scalar-colon rule). Every production added by this patch
is 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.proto
documents additional_bindings:

option (google.api.http) = {
  get: "/v1/messages/{message_id}"
  additional_bindings { get: "/v1/users/{user_id}/messages/{message_id}" }
  additional_bindings { post: "/v1/x" body: "*" }
};

That now parses, and both entries are preserved in the resulting Message_literal. Anything
that looks an option up by name still sees only the first, because assoc_option_name in
pb_raw_option.ml is a List.find. That is a property of the existing option representation
rather 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:

  • Angle brackets as message delimiters, { msg < b: 1 > }. Measured conflict-free, so
    this is a scope call rather than a risk one.
  • Extension keys, { [google.api.http]: 1 }. Also conflict-free, but it needs a
    convention decision first: Pb_option.value is a re-export of the public runtime type
    Pbrt_options.value, so an extension key has nowhere to live but a bare string, and a
    key spelled that way is then silently dropped by the generated decoders
    (pb_codegen_decode_pb_options.ml). I would rather agree the representation with you
    than invent one.
  • # comments inside an aggregate, which protoc accepts there.
  • Dotted keys, { msg.b: 1 }, which protoc rejects. The lexer's full_ident folds a
    dotted name into one T_ident, so master accepted these too; the behaviour is unchanged
    here, and tightening it is a separate question about the lexer.
  • returns as a field name. field_name covers 20 of the 21 keywords and omits exactly
    this one, which looks like drift against the note at pb_parsing_lexer.mll:60-64. It is
    a 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 like
protoc now. It does not reach syntax = "pro" "to3"; or import "a" "b";, because
those two rules take T_string directly rather than a constant. protoc accepts 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 a
list) is accepted by ocaml-protoc but rejected by protoc; it predates this change, and
the 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.

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>
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.

Parsing-error on "post RPC"

1 participant