Skip to content

Keep :: and ?. through decompile instead of collapsing both to a dot - #67

Merged
ghedwards merged 1 commit into
masterfrom
claude/fix-member-operators
Aug 13, 2026
Merged

Keep :: and ?. through decompile instead of collapsing both to a dot#67
ghedwards merged 1 commit into
masterfrom
claude/fix-member-operators

Conversation

@ghedwards

Copy link
Copy Markdown

memberExpression accepts three separators — DOT, DOUBLECOLUMN and nullSafeOperator — and none reached the AST. DOUBLECOLUMN appeared nowhere in the Java source at all. CFFullVarExpression hardcoded . between members:

a::b    ->  a.b
a?.b    ->  a.b
a?.b()  ->  a.b()
a::b.c  ->  a.b.c

Why this is more than cosmetic

?. is the one that matters. a?.b yields null where a.b throws, so the round trip changed what the code does — and safe navigation is far more common in real code than static references. :: turns a static member reference into an ordinary property access on a variable that may not exist.

This started as an aside while verifying cfmleditor/CFLint#50 — I noticed Some::myVar decompiling to Some.myVar and flagged it in #64 as needing confirmation rather than asserting it was a bug. Confirming it turned up the ?. case, which I had not expected and which is the more serious half.

An existing fixture was pinning the wrong output

acf2016/safenav.cfc parses if(xyz?.bar). Its recorded decompile read:

if(xyz.bar )

The operator was being dropped, and the expectation had been recorded from the broken result — so the suite was actively asserting the bug. Corrected here by hand, since AutoReplaceFailedTestResults does not cover the decompile section.

That fixture is named safenav. The one test in the corpus specifically about safe navigation was locking in the behaviour that defeats it.

Keyed by source offset, not by index

The operator is recorded on CFFullVarExpression against the member's character offset rather than its position in the list. Members are gathered through aggregateResult, where one source construct does not reliably yield one element — a[1].b puts three expressions in the list with a single dot between them — so index alignment drifts. Source offsets do not.

Only the two non-default operators are stored, so an absent entry still means a dot and nothing changes for ordinary member access. Decompile's existing logic for whether to emit a separator is untouched; only which separator it writes.

Verification

Round trips, including the array-member cases the offset keying exists to survive:

a[1].b     ok      a?.b?.c    ok      a.b().c    ok
a[1]?.b    ok      a::b::c    ok      a?.b().c   ok
a.b?.c.d   ok      a?.b[1].c  ok      a.b.c.d.e  ok
  • 325 tests, ./gradlew build
  • Differential harness unchanged at 1, nothing newly broken case-by-case
  • CFLint 675 against a clean build — worth stating, since this changes decompiled output that CFLint consumes
  • Both fixtures fail with cfml.parsing/src/main stashed, safenav included

Not covered

multipartIdentifier also admits DOUBLECOLUMN and is used for catch types, for..in keys and type specs. I did not touch it — :: is unlikely there and I have no failing case. Worth a look if one turns up.


Generated by Claude Code

memberExpression accepts three separators -- DOT, DOUBLECOLUMN and
nullSafeOperator -- and none of them reached the AST. DOUBLECOLUMN appeared
nowhere in the Java source at all. CFFullVarExpression hardcoded "." between
members, so:

  a::b   ->  a.b
  a?.b   ->  a.b
  a?.b() ->  a.b()

?. is the one that matters. a?.b yields null where a.b throws, so the round
trip changed what the code does, and safe navigation is far more common in
real code than static references. :: turns a static member reference into an
ordinary property access on a variable that may not exist.

An existing fixture was pinning the wrong output. acf2016/safenav.cfc parses
if(xyz?.bar) and its recorded decompile read if(xyz.bar) -- the operator was
being dropped and the expectation had been recorded from the broken result.
That section is corrected here by hand, since AutoReplaceFailedTestResults
does not cover decompile.

The operator is recorded on CFFullVarExpression keyed by the member's
character offset, not by its index. Members are gathered through
aggregateResult, where one source construct does not reliably yield one
element -- a[1].b puts three expressions in the list with a single dot between
them -- so index alignment would drift. Source offsets do not.

Only the two non-default operators are stored; an absent entry still means a
dot, so nothing changes for ordinary member access. Decompile's existing logic
for whether to emit a separator is untouched; only which separator it writes.

Round trips verified across chains, calls and array members: a[1]?.b,
a?.b?.c, a::b::c, a.b?.c.d, a?.b[1].c, a?.b().c.

325 tests, ./gradlew build, differential harness unchanged at 1 with nothing
newly broken, CFLint's 675 against a clean build. Both fixtures fail with
cfml.parsing/src/main stashed.
@ghedwards
ghedwards merged commit 4c02cf7 into master Aug 13, 2026
3 checks passed
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.

2 participants