Conversation
A qualified type name split across lines or spaces failed to parse, because the lexer swallowed a dotted name into one token and had no rule for '.'. Lex a bare '.' and a dot-leading name as their own tokens, and rejoin the segments in the grammar. Also turns a lone '.' from an uncaught Failure(\"float_of_string\") into a parse error, and rejects the empty segment in 'a..b' plus five dot-leading spellings that protoc rejects too. 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.
Summary
Fixes #255. A qualified type name split across lines or spaces, the shape that appears throughout
the googleapis
.protofiles, failed to parse.protoctreats whitespace and comments asinsignificant between the segments of a dotted name;
ocaml-protocdid not.The example from #255 (
google/ads/googleads/v18/services/google_ads_service.proto) now compiles:repeated google.ads.googleads.v18.resources .OfflineConversionUploadConversionActionSummary offline_conversion_upload_conversion_action_summary = 228;Problem
pb_parsing_lexer.mlllexed an entire dotted name as one token:and there was no rule for
.at all, so every qualified-name position in the grammar was spelled asa single
T_ident. A name broken by whitespace therefore arrived as several tokens and no productionmatched. Measured against
protoc23.2, every one of these is accepted byprotocand rejectedbefore this change:
a.b.cNL.Inner f = 1;Parsing error(the reported case)a.b.c .Inner f = 1;Parsing errora.b.c. Inner f = 1;Failure("float_of_string")a . b . c . Inner f = 1;Failure("float_of_string"). a.b.c.Inner f = 1;Failure("float_of_string")a.b.c //cNL.Inner f = 1;Parsing errora.b.c /*c*/ .Inner f = 1;Parsing errorThe same breakage applied to every other position that names a type:
package a . b . c;, rpcrequest/response types,
mapvalue types,extendtargets, and oneof field types.Two things fall out of the same root cause and are fixed here too:
.raised an uncaught exception rather than a parse error.float_literalhas everypart optional, so it matched a bare
.andfloat_of_string "."raised.message M { int32 . = 1; }reported
Failure("float_of_string")with a location pointing at unrelated earlier text. It nowreports a normal
Parsing errorat the right place.protocrejects them, because aleading dot was absorbed into an ordinary identifier: a declared message name
.Foo, a field name.y, an enum value.A, a constant.foo, and an option message-literal key.k. Lexing adot-leading name as its own token makes all five parse errors, matching
protoc.a..b.Cparsed, and the empty segment leakeddownstream as an empty type: the old error was
unresolved type for field name : f (type:, ...)with a blank type.
protocrejectsa..b.C; so does this change, as a parse error.Fix
Make
.a real token and rejoin the segments in the grammar, rather than widening the lexer regexp.A regexp cannot span case
a.b.c /*c*/ .Inner, because the comment is consumed by a separate lexerrule.
pb_parsing_lexer.mll: splitfull_identintoident_path(no leading dot) anddot_ident_path(leading dot, emitted as the new
T_dot_ident), and add a rule for a bare.emitting the newT_dot. The"."rule sits with the other punctuation, ahead offloat_literal, sincefloat_literalalso matches a lone.and ocamllex resolves an equal-length tie in favour of theearlier rule. Tightening
("." * ident) *to("." ident) *is what rejects the empty segment.pb_parsing_parser.mly: addqualified_ident/qualified_ident_tail, which rebuild the dottedstring by concatenating the pieces' lexemes. Only the first piece may be a bare identifier; every
continuation must begin with a dot. That asymmetry is what keeps the
TYPE fieldnameadjacencyunambiguous, and
ocamlyaccreports zero shift/reduce and zero reduce/reduce conflicts.The post-dot segment is
field_name, so a keyword remains legal as a path segment.qualified_identis then used at the positions that name a type:normal_field,oneof_field,mapkey and value,message_type(rpc),package_declaration,extend, and the parenthesisedoption extension name. Declared names (message, enum, service, rpc, field, oneof, enum value) are
deliberately left as plain
T_ident, since protobuf allows only a simple name there.option_identifier_itemalso acceptsT_dot_ident, sooption (ext).sub = vkeeps working, since itrelied on
.sublexing as aT_ident.field_namegains aT_returnsalternative. It already listed every other keyword thatresolve_identifierproduces, andreturnswas the only one missing, so without ita . returns . Cwould be the one split path that still failed. As a side effect this also lets a field be named
returns, whichprotocallows andocaml-protocpreviously rejected.pb_parsing.ml: render the two new tokens instring_of_token(used for the error context).Keeping an unbroken dotted run as a single token is deliberate: it is what preserves a keyword as
an inner segment (
a.map.C,a.to.C) and leavesa._priv.Cunmangled, both of which work todayand would regress under a scheme that lexed every segment separately.
Testing
src/tests/unit-tests/parse_qualified_ident.mlasserts that seven split spellingsparse to the same
field_typestructure as the canonicala.b.c.Inner, that the path segmentsand the
from_rootleading-dot marker survive, that a keyword segment works split or not, thatbuiltins still resolve to builtins, that a split name works in a
packagedeclaration and a oneoffield, and that
a..b.C, a trailing dot and a lone dot are all rejected as parse errors ratherthan as
Failure.hunk on its own (each qualified-name position, each
normal_fieldandoneof_fieldlabel/optionvariant, the
T_returnsalternative, dropping the leading dot, making the continuationnon-recursive, and dropping the dot between segments), is caught by the new test. No mutation
survives. An earlier round of this battery is what caught that the map, rpc,
extendandoption-name positions were initially asserted by nothing.
dune build @runtest --force: all existing tests pass unchanged, includingGoogle unittest(which parses Google's real
descriptor.protoandunittest.proto)..protoundersrc/(49 files) gives byte-identical generated
.ml/.mlifor the 37 that compile (76 files), andthe identical error message for the 12 that do not.
protoc23.2 over 33 inputs covering whitespace and comments inevery qualified-name position, keyword and underscore segments, the dotted option-name forms, and
float/int/hex literals: agreement went from 16/33 to 29/33, and the four remaining rows are cases
where
protocerrors for a semantic reason (an undefined option extension) whileocaml-protoconly parses, i.e. they confirm nothing regressed.
Known limitations
Three things I deliberately left alone, all pre-existing and orthogonal to #255. Happy to follow up
on any of them separately.
Whitespace around a dot inside an option name is still a parse error, so
option a . b = 1;andoption (ext) . sub = 1;still fail whileprotocparses both (its complaint about them is thesemantic
Option "a" unknown). Option names are assembled by a different mechanism,option_identifier, which concatenates adjacent items rather than going throughqualified_ident.Only the unbroken and dot-leading spellings are supported, as before.
A whitespace-split segment spelled
e1orE1still escapes asFailure("float_of_string"), andone spelled
inflexes as a float, soa . e1 . Canda . inf . Care rejected whereprotocaccepts them. That is the pre-existing float-literal-versus-identifier ambiguity the lexer already
flags in its own
TODO fix: somehow E1 for field identified get lexed into a floatcomment; the sameinputs failed before this change, and fixing it means tightening
float_literal, which felt like aseparate change.
A non-first path segment beginning with
_is treated differently when the name is split, becauseresolve_identifiermangles a standalone lexeme starting with_top+ lexeme:a._priv.Cgivessegment
_priv, whilea . _priv . Cgivesp_priv. Before this change the split spelling was ahard crash, and the remaining failure mode is a loud "unresolved type" error rather than silent
corruption, so I left that pre-existing mangling rule alone rather than widen the diff.
I did not touch
CHANGES.md, since it looks like entries are added there in theprepare for <version>release commits rather than per PR, but glad to add one if you prefer.
AI assistance disclosure: this change was developed with AI assistance (Claude). The design was
chosen after differentially testing candidate approaches against
protocas an oracle, and allresults reported above were produced by running the builds and the test suite locally.