Repository navigation
ast: record the parentheses on a call site (Call.Paren) - #43
Merged
Merged
Conversation
`foo` and `foo()` built identical `ast.Call`s, so nothing downstream could tell a VCALL from an FCALL. `ast.Call` carries no position either, so a consumer could not re-read the source to recover the parentheses. The difference is observable in MRI: nope_xyz # NameError: undefined local variable or method 'nope_xyz' nope_xyz() # NoMethodError: undefined method 'nope_xyz' MRI keeps them apart by building different nodes from different productions: `fcall: operation` feeding `method_call: fcall paren_args` (parse.y-ruby_4_0:3572-3577, 5242-5248) makes an FCALL, while a bare name reaching `gettable` makes a VCALL (parse.y-ruby_4_0:13086). This records the same distinction as one bit of written form, set wherever the parser consumes a written `(` … `)` argument list: the bare-identifier call, a capitalised call, a `.` or `&.` call, a `::` call, and the `.()` shorthand. Paren is written form and nothing more. It is true for an empty list (`foo()`) and false for every unparenthesised call whatever else is present, including `a[0]` — brackets are not parentheses (MRI: `primary_value '[' opt_call_args rbracket`, parse.y-ruby_4_0:5292-5297). Deciding VCALL takes more than this bit, and the doc comment says so: MRI's `operation` admits tFID, so `foo!` and `foo?` are FCALLs with no parentheses (parse.y-ruby_4_0:4370-4374, 6696-6698), and a literal block makes an FCALL too (`primary: fcall brace_block`, parse.y-ruby_4_0:4458-4463). The rule a consumer wants is `Recv == nil && !Paren && len(Args) == 0 && Block == nil` on a plain identifier name; a test exercises exactly that rule over the edge set. The zero value is false, so no existing consumer changes. No existing test was modified; the full suite passes unchanged. Closes #41 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Closes #41.
The gap
fooandfoo()built identicalast.Calls, so nothing downstream could tell a VCALL from an FCALL, andast.Callcarries no position from which to re-read the source. Reproduction from the issue, against ruby 4.0.5:The grammar says where the information is
parse.y-ruby_4_0fcall : operationNEW_FCALL($1, 0, &@$)method_call : fcall paren_argsgettable,case ID_LOCALreturn NEW_VCALL(id, loc)— the VCALLprimary : tFIDNEW_FCALLforfoo!/foo?— no parentheses, still an FCALLoperation : ident_or_const | tFIDfcallat allprimary : fcall brace_blockprimary_value '[' opt_call_args rbracketNEW_CALL, with brackets, not parensParenis set wherever the parser consumes a written(…)argument list: the bare-identifier call, a capitalised call (Integer("42")), a./&.call, a::call, and the.()shorthand.The bit takes both values
Measured by the tests added here —
Parenis neither always true nor always false, and each pair that must differ does:NameParenfoofoofoo()foofoo(1)foofoo ()fooputs 1putsobj.foofooobj.foo()fooobj.foo barfooobj&.foo/obj&.foo()foofoo.()/foo.(1)callFoo::bar/Foo::bar(1)barInteger("42")Integera[0][]foo!/foo?foo!/foo?x = 1; xVarRef, not aCallx = 1; x()xTwo of those need saying out loud:
foo ()isParen: falsewith one argument. The space means the()is a grouped expression passed as a command argument, not an argument list. MRI agrees it is an FCALL, and it stays one for a consumer, because a VCALL also requires zero arguments.foo!andfoo?areParen: falseand yet FCALLs in MRI (primary: tFID), confirmed against the binary: both raiseNoMethodError. So the bit alone does not decide VCALL, and the doc comment says so rather than letting a consumer guess. The rule isRecv == nil && !Paren && len(Args) == 0 && Block == nilon a plain identifier name, andTestCallParenVCallRuleexercises exactly that rule over the whole edge set against MRI's answers.Negative control: with every
Parenforced tofalse, 12 assertions in the new tests fail. The tests measure the change.One place the oracle and the binary disagree
The
{x:}value-omitted shorthand.assoc : tLABEL(6669-6675) callsgettable, so the implied value is a VCALL, and ourbarewordValuebuilds aCallwithParen: falseaccordingly. The installedruby 4.0.5raisesNoMethodErrorthere, which would say FCALL — but that binary is+PRISMand runs the Prism parser by default;ruby --parser=parse.yraisesNameError, agreeing with the oracle. The oracle wins, and this PR follows it. Flagging it because a consumer measuring against a stockrubywill see the Prism answer.Existing consumers are unaffected
git diff --name-only origin/mainisast/ast.goandparser.goonly; the one new file iscall_paren_test.go.parser,ast,lexer,token),go vet ./...clean,gofmt -lclean on both changed files.gofmt -l .does reportparen_singleton_def_test.go— pre-existing onmain(verified against a pristineorigin/maincopy) and untouched here.The zero value is
false, so anyone who does not read the field sees nothing.Why this is a separate PR from #40
No shared code: different structs, disjoint parser sites, no common helper. Same idea, same precedent (
RangeLit.Exclusive), but nothing is reused by merging them, and split they review and merge independently.What the consumer will need (separately)
Not in this PR —
go-embedded-ruby/rubyneeds a released parser version first, and a sibling wave is working there. When it picks this up: the existingFlagSendVCallshould be set fromRecv == nil && !Paren && len(Args) == 0 && Block == nil && Namea plain identifier (no trailing!,?or=), which is whatTestCallParenVCallRulehere encodes. That flag then selectsNameErroroverNoMethodError, coveringlanguage/send_spec.rb:235.🤖 Generated with Claude Code