Skip to content

Commit 64ce6e2

Browse files
committed
Lint rule to fix the space assignment syntax
1 parent 9d60ce5 commit 64ce6e2

File tree

7 files changed

+402
-16
lines changed

7 files changed

+402
-16
lines changed

gradle/wrapper/gradle-wrapper.properties

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https://services.gradle.org/distributions/gradle-8.11.1-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
44
networkTimeout=10000
55
validateDistributionUrl=true
66
zipStoreBase=GRADLE_USER_HOME

gradlew

+1-2
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,7 @@ done
8686
# shellcheck disable=SC2034
8787
APP_BASE_NAME=${0##*/}
8888
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89-
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
90-
' "$PWD" ) || exit
89+
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
9190

9291
# Use the maximum available, or set MAX_FD != -1 to use that value.
9392
MAX_FD=maximum

src/main/groovy/com/netflix/nebula/lint/rule/GradleLintRule.groovy

+16-11
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,7 @@ import com.netflix.nebula.lint.GradleViolation
2020
import com.netflix.nebula.lint.plugin.LintRuleRegistry
2121
import org.codehaus.groovy.ast.ASTNode
2222
import org.codehaus.groovy.ast.ClassNode
23-
import org.codehaus.groovy.ast.expr.ArgumentListExpression
24-
import org.codehaus.groovy.ast.expr.BinaryExpression
25-
import org.codehaus.groovy.ast.expr.ClosureExpression
26-
import org.codehaus.groovy.ast.expr.ConstantExpression
27-
import org.codehaus.groovy.ast.expr.Expression
28-
import org.codehaus.groovy.ast.expr.GStringExpression
29-
import org.codehaus.groovy.ast.expr.MapExpression
30-
import org.codehaus.groovy.ast.expr.MethodCallExpression
31-
import org.codehaus.groovy.ast.expr.PropertyExpression
32-
import org.codehaus.groovy.ast.expr.VariableExpression
23+
import org.codehaus.groovy.ast.expr.*
3324
import org.codehaus.groovy.ast.stmt.ExpressionStatement
3425
import org.codenarc.rule.AbstractAstVisitorRule
3526
import org.codenarc.rule.AstVisitor
@@ -43,7 +34,6 @@ import org.slf4j.LoggerFactory
4334
import java.text.ParseException
4435

4536
abstract class GradleLintRule extends GroovyAstVisitor implements Rule {
46-
Project project // will be non-null if type is GradleModelAware, otherwise null
4737
BuildFiles buildFiles
4838
SourceCode sourceCode
4939
List<GradleViolation> gradleViolations = []
@@ -152,6 +142,21 @@ abstract class GradleLintRule extends GroovyAstVisitor implements Rule {
152142
calls.collect { call -> _dslStack(call) }.flatten() as List<String>
153143
}
154144

145+
final List<Expression> typedDslStack(List<MethodCallExpression> calls) {
146+
def _dslStack
147+
_dslStack = { Expression expr ->
148+
if (expr instanceof PropertyExpression)
149+
_dslStack(expr.objectExpression) + expr.property
150+
else if (expr instanceof MethodCallExpression)
151+
_dslStack(expr.objectExpression) + expr
152+
else if (expr instanceof VariableExpression)
153+
expr.text == 'this' ? [] : [expr]
154+
else []
155+
}
156+
157+
calls.collect { call -> _dslStack(call) }.flatten() as List<String>
158+
}
159+
155160
private final String containingConfiguration(MethodCallExpression call) {
156161
def stackStartingWithConfName = dslStack(callStack + call).dropWhile { it != 'configurations' }.drop(1)
157162
stackStartingWithConfName.isEmpty() ? null : stackStartingWithConfName[0]

src/main/groovy/com/netflix/nebula/lint/rule/GradleModelAware.groovy

+184-2
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,194 @@
1616

1717
package com.netflix.nebula.lint.rule
1818

19+
import org.codehaus.groovy.ast.expr.*
20+
import org.gradle.api.Action
1921
import org.gradle.api.Project
22+
import org.gradle.api.Task
23+
import org.gradle.api.internal.DefaultDomainObjectCollection
24+
import org.gradle.api.plugins.ExtensionAware
25+
import org.gradle.configuration.ImportsReader
26+
27+
import javax.annotation.Nullable
2028

2129
/**
2230
* Decorate lint rule visitors with this interface in order to use the
2331
* evaluated Gradle project model in the rule
2432
*/
25-
interface GradleModelAware {
26-
void setProject(Project project)
33+
trait GradleModelAware {
34+
Project project
35+
Map<String, List<String>> projectDefaultImports = null
36+
37+
TypeInformation receiver(MethodCallExpression call) {
38+
List<Expression> fullCallStack = typedDslStack(callStack + call)
39+
List<TypeInformation> typedStack = []
40+
for (Expression currentMethod in fullCallStack) {
41+
if (typedStack.empty) {
42+
typedStack.add(new TypeInformation(project))
43+
}
44+
while (!typedStack.empty) {
45+
def current = typedStack.last()
46+
def candidate = findDirectCandidate(current, currentMethod)
47+
if (candidate != null) {
48+
typedStack.add(candidate)
49+
break
50+
}
51+
typedStack.removeLast()
52+
}
53+
}
54+
if (typedStack.size() >= 2) { //there should be the method return type and the receiver at least
55+
return typedStack[-2]
56+
} else {
57+
return null
58+
}
59+
}
60+
61+
private findDirectCandidate(TypeInformation current, Expression currentExpression) {
62+
String methodName
63+
switch (currentExpression) {
64+
case MethodCallExpression:
65+
methodName = currentExpression.methodAsString
66+
break
67+
case PropertyExpression:
68+
methodName = currentExpression.propertyAsString
69+
break
70+
case VariableExpression:
71+
methodName = currentExpression.text
72+
break
73+
case ConstantExpression:
74+
methodName = currentExpression.text
75+
break
76+
default:
77+
return null
78+
}
79+
def getter = current.clazz.getMethods().find { it.name == "get${methodName.capitalize()}" }
80+
if (getter != null) {
81+
if (current.object != null) {
82+
try {
83+
return new TypeInformation(getter.invoke(current.object))
84+
} catch (ignored) {
85+
// ignore and fallback to the return type
86+
}
87+
}
88+
return new TypeInformation(null, getter.returnType)
89+
}
90+
91+
// there is no public API for DomainObjectCollection.type
92+
if (current.object != null && DefaultDomainObjectCollection.class.isAssignableFrom(current.clazz)) {
93+
def collectionItemType = ((DefaultDomainObjectCollection) current.object).type
94+
95+
if (methodName == "withType" && currentExpression instanceof MethodCallExpression && currentExpression.arguments.size() >= 1) {
96+
def className = currentExpression.arguments[0]
97+
def candidate = findSuitableClass(className.text, collectionItemType)
98+
if (candidate != null) {
99+
collectionItemType = candidate
100+
}
101+
}
102+
103+
if ((methodName == "create" || methodName == "register") && currentExpression instanceof MethodCallExpression && currentExpression.arguments.size() >= 2 && currentExpression.arguments[1] !instanceof ClosureExpression) {
104+
def className = currentExpression.arguments[1]
105+
def candidate = findSuitableClass(className.text, collectionItemType)
106+
if (candidate != null) {
107+
collectionItemType = candidate
108+
}
109+
}
110+
111+
def transformationOrFactoryMethod = current.clazz.getMethods().find { it.name == methodName && it.parameterTypes[-1] == Action.class }
112+
if (transformationOrFactoryMethod != null) {
113+
if (collectionItemType.isAssignableFrom(transformationOrFactoryMethod.returnType)) {
114+
return new TypeInformation(null, transformationOrFactoryMethod.returnType)
115+
} else {
116+
// assume that all actions are done on the collection type
117+
return new TypeInformation(null, collectionItemType)
118+
}
119+
}
120+
}
121+
122+
// note that we can't use tasks.findByName because it may lead to unwanted side effects because of potential task creation
123+
if (Project.class.isAssignableFrom(current.clazz) && methodName == "task" && currentExpression instanceof MethodCallExpression) {
124+
def taskType = extractTaskType(currentExpression)
125+
if (taskType != null) {
126+
return new TypeInformation(null, taskType)
127+
}
128+
return new TypeInformation(null, Task.class)
129+
}
130+
131+
def factoryMethod = current.clazz.getMethods().find { it.name == methodName && it.parameterTypes[-1] == Action.class }
132+
if (factoryMethod != null) {
133+
// assume that this is a factory method that returns the created type
134+
return new TypeInformation(null, factoryMethod.returnType)
135+
}
136+
137+
if (current.object != null && current.object instanceof ExtensionAware) {
138+
def extension = current.object.extensions.findByName(methodName)
139+
if (extension != null) {
140+
return new TypeInformation(extension)
141+
}
142+
}
143+
return null;
144+
}
145+
146+
private List<Class> findClassInScope(String name) {
147+
if (this.projectDefaultImports == null) {
148+
this.projectDefaultImports = project.services.get(ImportsReader.class).getSimpleNameToFullClassNamesMapping()
149+
}
150+
return this.projectDefaultImports.get(name);
151+
}
152+
153+
@Nullable
154+
private Class findSuitableClass(String className, Class parentClass) {
155+
def candidates = (findClassInScope(className) ?: []) + [className]
156+
for (String candidate in candidates) {
157+
try {
158+
def candidateClass = Class.forName(candidate)
159+
if (parentClass.isAssignableFrom(candidateClass)) {
160+
return candidateClass
161+
}
162+
} catch (ignored) {
163+
// ignore and try the next candidate
164+
}
165+
}
166+
return null
167+
}
168+
169+
@Nullable
170+
private Class extractTaskType(MethodCallExpression currentExpression) {
171+
for (Expression arg in currentExpression.arguments) {
172+
if (arg instanceof VariableExpression || arg instanceof ConstantExpression) {
173+
def candidate = findSuitableClass(arg.text, Task.class)
174+
if (candidate != null) {
175+
return candidate
176+
}
177+
} else if (arg instanceof MapExpression) {
178+
def type = arg
179+
.mapEntryExpressions
180+
.find { it.keyExpression.text == "type" }
181+
?.valueExpression?.text
182+
if (type != null) {
183+
def candidate = findSuitableClass(type, Task.class)
184+
if (candidate != null) {
185+
return candidate
186+
}
187+
}
188+
}
189+
}
190+
return null
191+
}
192+
}
193+
194+
class TypeInformation {
195+
@Nullable
196+
Class clazz
197+
@Nullable
198+
Object object
199+
200+
TypeInformation(Object object) {
201+
this.object = object
202+
this.clazz = object.class
203+
}
204+
205+
TypeInformation(Object object, Class clazz) {
206+
this.object = object
207+
this.clazz = clazz
208+
}
27209
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.netflix.nebula.lint.rule.dsl
2+
3+
import com.netflix.nebula.lint.rule.GradleLintRule
4+
import com.netflix.nebula.lint.rule.GradleModelAware
5+
import org.codehaus.groovy.ast.expr.ClosureExpression
6+
import org.codehaus.groovy.ast.expr.MethodCallExpression
7+
8+
class SpaceAssignmentRule extends GradleLintRule implements GradleModelAware {
9+
10+
String description = "space-assignment syntax is deprecated"
11+
12+
@Override
13+
void visitMethodCallExpression(MethodCallExpression call) {
14+
if (call.arguments.size() != 1 || call.arguments[-1] instanceof ClosureExpression) {
15+
return
16+
}
17+
18+
def receiverClass = receiver(call)?.clazz
19+
if (receiverClass == null) {
20+
return // no enough data to analyze
21+
}
22+
23+
def invokedMethodName = call.method.value
24+
25+
// check if the method has a matching property
26+
def setter = receiverClass.getMethods().find { it.name == "set${invokedMethodName.capitalize()}" }
27+
if (setter == null) {
28+
return // no matching property
29+
}
30+
31+
// check if it's a generated method for space assignment
32+
def exactMethod = receiverClass.getMethods().find { it.name == invokedMethodName }
33+
if (exactMethod != null) {
34+
def deprecatedAnnotation = exactMethod.getAnnotation(Deprecated)
35+
if (deprecatedAnnotation != null) {
36+
// may be false positive when the explicit method is deprecated
37+
addBuildLintViolation(description, call)
38+
.replaceWith(call, getReplacement(call))
39+
}
40+
} else {
41+
addBuildLintViolation(description, call)
42+
.replaceWith(call, getReplacement(call))
43+
}
44+
}
45+
46+
def getReplacement(MethodCallExpression call){
47+
def originalLine = getSourceCode().line(call.lineNumber-1)
48+
return originalLine.replaceFirst(call.methodAsString, call.methodAsString + " =")
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
implementation-class=com.netflix.nebula.lint.rule.dsl.SpaceAssignmentRule

0 commit comments

Comments
 (0)