G
YouTube TikTok Games
Skip to content

GROOVY-12713: type check the block form of nested copyWith - #3245

Open
codeconsole wants to merge 9 commits into
apache:masterfrom
codeconsole:fix/groovy-12713-copywith-block-stc
Open

codeconsole wants to merge 9 commits into
apache:masterfrom
codeconsole:fix/groovy-12713-copywith-block-stc

Conversation

@codeconsole

@codeconsole codeconsole commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Second part of GROOVY-12713, on top of #3244 (whose commit this branch contains; the diff against master shows both until that one is merged).

copyWith { name = old.name.toUpperCase(); address.city = 'x' } runs its closure against a CopyWithRecorder (NestedCopyWithSupport.applyBlock) that turns the assignments into the map form, so under @TypeChecked and @CompileStatic every name in the block was "The variable [address] is undeclared.", while the same block worked dynamically.

The type checker now recognises the generated copyWith(Closure) of a type whose copyWith(Map) declares its components as @NamedParams (the same test as for the dotted keys), types the closure's delegate as what it is at run time, a CopyWithRecorder (DELEGATE_FIRST), and inside the block:

  • a bare component name (name) or a component path (company.address.zip) that is the target of an assignment has the declared component type, so the assignment is checked against it (Cannot assign value of type java.lang.String to variable of type int), and an unknown component is still No such property: state for class: Address;
  • read anywhere else, a component path is the recorder that reading it returns at run time, so name = name.toUpperCase() is a compile error (Cannot find matching method ...CopyWithRecorder#toUpperCase()); read the current value through old, which is typed as the copied type: name = old.name.toUpperCase();
  • path.modify { it ... } always resolves to the recorder's modify, even when the component type has a modify method of its own, with the component's type as the closure's parameter and result type;
  • the recorder has none of the copied type's methods, so a method call goes to the enclosing class, as at run time;
  • the recorder answers every other bare name too, so a name that is not a component, such as a field or property of the enclosing class, is an error (... a qualifier is required to reference a member of the enclosing class); this.x reaches it. Closures inside the block without a delegate of their own (each, a modify closure) resolve names the same way;
  • all of those are marked for dynamic resolution, so static compilation dispatches them through the closure at run time, where the recorder records them; reads through old stay static, since old is the real object.

Nothing changes outside such a block: the marker lives on the block's closure for the duration of its visit, and the lookup of the type's named parameters is the helper #3244 added.

Tests: RecordNestedCopyWithTest gains the block under @CompileStatic with writes at three depths, old and modify (and runs the result), a wrongly typed value and an unknown component; ImmutableNestedCopyWithTest the block under @TypeChecked. On JDK 21 those two classes, groovy.transform.stc.*, org.codehaus.groovy.classgen.asm.sc.*, NamedParameterTest, ImmutableTransformTest and RecordTest pass (4,845 tests).

Candidate for GROOVY_6_0_X together with #3244.

@daniellansun daniellansun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build-and-test is red with the two ParserNegativeSyntaxTest failures that were broken on master after GROOVY-12641 (GEP-19). Those fixtures have been fixed on current master (see #3247).

Please rebase onto the latest master and push so CI can re-run against that tip — that should clear this noise. If anything is still red after the rebase, we can dig into it then.

The copyWith(Map) that @RecordType and @immutable generate accepts dotted
keys ('address.city') since GROOVY-12015, but the named-argument check of
the static type checker reported every key that is not one of the parameter's
@NamedParams, so the nested form failed with "unexpected named arg" under
@TypeChecked and @CompileStatic while it worked dynamically.

checkNamedParamsAnnotation now walks a dotted key: each segment must be a
named parameter of the type reached so far (the @NamedParam type of the
previous segment, whose own copyWith(Map) declares the next level), an unknown
segment is reported as before, and the value is checked against the last
segment's declared type. The walk stops, accepting the key, at a type whose
copyWith(Map) declares no named parameters; the runtime checks the rest.
Keys without a dot are checked exactly as before.

Tests in RecordNestedCopyWithTest and ImmutableNestedCopyWithTest: a
statically compiled method using two nested keys, an unknown nested segment
("unexpected named arg: address.state") and a wrongly typed value for a nested
key. The block form (copyWith { address.city = 'x' }) is still dynamic only;
the issue tracks it.
copyWith { name = old.name.toUpperCase(); address.city = 'x' } runs its
closure against a CopyWithRecorder that turns the assignments into the map
form, so under @TypeChecked and @CompileStatic every name in it was an
undeclared variable. The type checker now recognises the generated
copyWith(Closure) of a type whose copyWith(Map) declares named parameters,
makes that type the closure's delegate, and inside the block types a bare
component name, a component path and old as the declared component types and
the copied type; assignments to them are checked against those types,
path.modify { it ... } gets the component's type as its closure parameter, and
all of them are marked for dynamic resolution so static compilation dispatches
them through the closure at run time, where the recorder answers. Reads
through old stay static: old is the real object. An unknown component is still
"No such property".

Tests: RecordNestedCopyWithTest (the block under @CompileStatic with nested
writes, old and modify; a wrongly typed value; an unknown component) and
ImmutableNestedCopyWithTest (the block under @TypeChecked).
@codeconsole
codeconsole force-pushed the fix/groovy-12713-copywith-block-stc branch from 0f1b5be to 2706945 Compare November 6, 2026 21:57
@codeconsole

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (5170b46, which has #3247) and pushed, on top of the rebased #3244 (this diff shows both commits until that one merges); the rebase was clean and RecordNestedCopyWithTest, ImmutableNestedCopyWithTest and NamedParameterTest pass locally on the rebased branch.

@codecov-commenter

codecov-commenter commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.36232% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.4186%. Comparing base (5170b46) to head (3caa0ab).
⚠️ Report is 32 commits behind head on master.

Files with missing lines Patch % Lines
...roovy/transform/stc/StaticTypeCheckingVisitor.java 75.3623% 3 Missing and 31 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #3245        +/-   ##
==================================================
+ Coverage     74.3428%   74.4186%   +0.0758%     
- Complexity      44310      44424       +114     
==================================================
  Files            1699       1699                
  Lines          150862     151176       +314     
  Branches        28756      28829        +73     
==================================================
+ Hits           112155     112503       +348     
+ Misses          28435      28381        -54     
- Partials        10272      10292        +20     
Files with missing lines Coverage Δ
...roovy/transform/stc/StaticTypeCheckingVisitor.java 87.8264% <75.3623%> (-0.2188%) ⬇️

... and 41 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@daniellansun daniellansun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for rebasing and stacking cleanly on the rebased #3244. Tip 2706945846a9 is green.

The block-form STC work for nested copyWith is carefully scoped: isCopyWithBlock only engages when the receiver’s map copyWith declares @NamedParams; bare names and dotted paths inside the closure are typed from those components and marked DYNAMIC_RESOLUTION so the recorder stays the dispatch target; old is typed as the copied type; and modify { … } is modelled as a synthetic method so the nested closure is checked. The new record / immutable tests (happy path, bad assignment types, unknown components) exercise the important edges.

Please merge #3244 first so this tip’s history stays linear. Aside from that ordering note, I am happy to approve.

# Conflicts:
#	src/test/groovy/org/codehaus/groovy/transform/RecordNestedCopyWithTest.groovy
…gate

A component read in the block is the recorder, not the value; modify on a
component path is always the recorder's; method calls go to the owner; and
a bare name that is not a component is an error rather than an owner member.
@testlens-app

testlens-app Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 3caa0ab
▶️ Tests: 35327 executed
⚪️ Checks: 26/26 completed


Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

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

3 participants