Skip to content

Commit 0703ba2

Browse files
[DON-2384] Add color and typography lint detectors (#2580)
* Add color and typography lint detectors - Add HardcodedComposeColorDetector: Detects hardcoded colors in Compose code - Add HardcodedTypographyDetector: Detects hardcoded typography values - Add BpkTokensCopyDetector: Detects copying of Backpack tokens - Add unit tests for all three detectors - Register new detectors in IssueRegistry These detectors help enforce the use of Backpack design tokens for colors and typography in Compose code. * Fix HardcodedTypographyDetector to use const val EXPLANATION * address comment * improve copy detector --------- Co-authored-by: lokmane.krizou@skyscanner.net <lokmane.krizou@skyscanner.net>
1 parent db94468 commit 0703ba2

File tree

7 files changed

+1346
-0
lines changed

7 files changed

+1346
-0
lines changed

backpack-lint/src/main/java/net/skyscanner/backpack/lint/IssueRegistry.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ import com.android.tools.lint.detector.api.Issue
2525
import net.skyscanner.backpack.lint.check.BpkComponentUsageDetector
2626
import net.skyscanner.backpack.lint.check.BpkComposeComponentUsageDetector
2727
import net.skyscanner.backpack.lint.check.BpkDeprecatedColorUsageDetector
28+
import net.skyscanner.backpack.lint.check.BpkTokensCopyDetector
2829
import net.skyscanner.backpack.lint.check.HardcodedBorderRadiusDetector
2930
import net.skyscanner.backpack.lint.check.HardcodedColorResourceDetector
3031
import net.skyscanner.backpack.lint.check.HardcodedColorUsageDetector
32+
import net.skyscanner.backpack.lint.check.HardcodedComposeColorDetector
3133
import net.skyscanner.backpack.lint.check.HardcodedPaddingDetector
3234
import net.skyscanner.backpack.lint.check.HardcodedSizeDetector
35+
import net.skyscanner.backpack.lint.check.HardcodedTypographyDetector
3336
import net.skyscanner.backpack.lint.check.UnicodeIconUsageDetector
3437

3538
@Suppress("unused", "UnstableApiUsage")
@@ -46,6 +49,9 @@ class IssueRegistry : IssueRegistry() {
4649
HardcodedPaddingDetector.ISSUE,
4750
HardcodedBorderRadiusDetector.ISSUE,
4851
HardcodedSizeDetector.ISSUE,
52+
HardcodedComposeColorDetector.ISSUE,
53+
HardcodedTypographyDetector.ISSUE,
54+
BpkTokensCopyDetector.ISSUE,
4955
UnicodeIconUsageDetector.ISSUE,
5056
)
5157

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Backpack for Android - Skyscanner's Design System
3+
*
4+
* Copyright 2018 - 2026 Skyscanner Ltd
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package net.skyscanner.backpack.lint.check
20+
21+
import com.android.tools.lint.detector.api.Category
22+
import com.android.tools.lint.detector.api.Detector
23+
import com.android.tools.lint.detector.api.Implementation
24+
import com.android.tools.lint.detector.api.Issue
25+
import com.android.tools.lint.detector.api.JavaContext
26+
import com.android.tools.lint.detector.api.Scope
27+
import com.android.tools.lint.detector.api.Severity
28+
import com.android.tools.lint.detector.api.SourceCodeScanner
29+
import com.intellij.psi.PsiMethod
30+
import net.skyscanner.backpack.lint.util.LintConstants
31+
import org.jetbrains.uast.UCallExpression
32+
import org.jetbrains.uast.UQualifiedReferenceExpression
33+
34+
@Suppress("UnstableApiUsage")
35+
class BpkTokensCopyDetector : Detector(), SourceCodeScanner {
36+
37+
companion object {
38+
private const val EXPLANATION =
39+
"Do not use .copy() to modify design tokens. Request a new semantic token from the design team instead. Using .copy() bypasses the design system and breaks theming support.\n\n${LintConstants.SUPPORT_MESSAGE}"
40+
41+
val ISSUE = Issue.create(
42+
id = "BpkTokensCopy",
43+
briefDescription = "Design token modified with .copy()",
44+
explanation = EXPLANATION,
45+
category = Category.CORRECTNESS,
46+
severity = Severity.ERROR,
47+
implementation = Implementation(
48+
BpkTokensCopyDetector::class.java,
49+
Scope.JAVA_FILE_SCOPE,
50+
),
51+
)
52+
53+
private val STANDALONE_TOKEN_PREFIXES = listOf(
54+
"BpkSpacing", "BpkBorderRadius", "BpkElevation", "BpkDimension",
55+
)
56+
57+
private val IMPORTED_TOKEN_PATTERN = Regex("""\b(colors|typography)\.[a-zA-Z]""")
58+
}
59+
60+
override fun getApplicableMethodNames(): List<String> = listOf("copy")
61+
62+
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
63+
val receiverText = getReceiverText(node) ?: return
64+
65+
if (isDesignTokenReceiver(receiverText)) {
66+
context.report(ISSUE, context.getLocation(node), EXPLANATION)
67+
}
68+
}
69+
70+
private fun getReceiverText(node: UCallExpression): String? {
71+
val directReceiver = node.receiver
72+
if (directReceiver != null) {
73+
return directReceiver.asSourceString()
74+
}
75+
76+
val parent = node.uastParent
77+
if (parent is UQualifiedReferenceExpression) {
78+
val receiver = parent.receiver
79+
return receiver.asSourceString()
80+
}
81+
82+
return null
83+
}
84+
85+
private fun isDesignTokenReceiver(receiverText: String): Boolean {
86+
// BpkTheme.colors.xxx, BpkTheme.typography.xxx
87+
if (receiverText.contains("BpkTheme.colors.") ||
88+
receiverText.contains("BpkTheme.typography.") ||
89+
receiverText.contains("BpkTheme.getColors()") ||
90+
receiverText.contains("BpkTheme.getTypography()")) {
91+
return true
92+
}
93+
94+
// Standalone tokens: BpkSpacing.xxx, BpkBorderRadius.xxx, etc.
95+
if (STANDALONE_TOKEN_PREFIXES.any { receiverText.contains("$it.") }) {
96+
return true
97+
}
98+
99+
// Imported references: colors.xxx, typography.xxx (with word boundary)
100+
return IMPORTED_TOKEN_PATTERN.containsMatchIn(receiverText)
101+
}
102+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* Backpack for Android - Skyscanner's Design System
3+
*
4+
* Copyright 2018 - 2026 Skyscanner Ltd
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package net.skyscanner.backpack.lint.check
20+
21+
import com.android.tools.lint.detector.api.Category
22+
import net.skyscanner.backpack.lint.util.LintConstants
23+
import com.android.tools.lint.detector.api.Detector
24+
import com.android.tools.lint.detector.api.Implementation
25+
import com.android.tools.lint.detector.api.Issue
26+
import com.android.tools.lint.detector.api.JavaContext
27+
import com.android.tools.lint.detector.api.LintFix
28+
import com.android.tools.lint.detector.api.Scope
29+
import com.android.tools.lint.detector.api.Severity
30+
import com.android.tools.lint.detector.api.SourceCodeScanner
31+
import com.intellij.psi.PsiElement
32+
import com.intellij.psi.PsiField
33+
import com.intellij.psi.PsiMethod
34+
import org.jetbrains.uast.UCallExpression
35+
import org.jetbrains.uast.ULiteralExpression
36+
import org.jetbrains.uast.UQualifiedReferenceExpression
37+
import org.jetbrains.uast.UReferenceExpression
38+
39+
@Suppress("UnstableApiUsage")
40+
class HardcodedComposeColorDetector : Detector(), SourceCodeScanner {
41+
42+
companion object {
43+
private const val EXPLANATION =
44+
"This color doesn't exist in Backpack. Please check BpkTheme.colors for available colors.\n\n${LintConstants.SUPPORT_MESSAGE}"
45+
46+
private const val COMPOSE_COLOR_CLASS = "androidx.compose.ui.graphics.Color"
47+
private const val COMPOSE_COLOR_KT_CLASS = "androidx.compose.ui.graphics.ColorKt"
48+
49+
private fun getColorSuggestion(hex: String): String {
50+
val tokens = GeneratedColorTokenMap.COLOR_TOKEN_MAP[hex]
51+
return if (tokens != null) {
52+
if (tokens.size == 1) {
53+
"Use ${tokens[0]} instead of Color($hex)"
54+
} else {
55+
val tokenList = tokens.joinToString("\n- ")
56+
"Use one of these tokens instead of Color($hex). All these tokens have the same color value:\n- $tokenList"
57+
}
58+
} else {
59+
EXPLANATION
60+
}
61+
}
62+
63+
val ISSUE = Issue.create(
64+
id = "HardcodedComposeColor",
65+
briefDescription = "Hardcoded Compose Color detected",
66+
explanation = EXPLANATION,
67+
category = Category.CORRECTNESS,
68+
severity = Severity.ERROR,
69+
implementation = Implementation(
70+
HardcodedComposeColorDetector::class.java,
71+
Scope.JAVA_FILE_SCOPE,
72+
),
73+
)
74+
75+
private val HARDCODED_COLOR_NAMES = setOf(
76+
"Red", "Blue", "Green", "Yellow", "Cyan", "Magenta", "White", "Black",
77+
"Gray", "LightGray", "DarkGray",
78+
)
79+
}
80+
81+
override fun getApplicableConstructorTypes(): List<String> = listOf(COMPOSE_COLOR_CLASS)
82+
83+
override fun getApplicableMethodNames(): List<String> = listOf("Color") + HARDCODED_COLOR_NAMES.toList()
84+
85+
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
86+
val methodName = node.methodName ?: return
87+
if (methodName != "Color" && methodName != "constructor-impl") return
88+
89+
val containingClass = method.containingClass ?: return
90+
val fqName = context.evaluator.getQualifiedName(containingClass)
91+
if (!isComposeColorClass(fqName)) return
92+
93+
reportHardcodedColorIfLiteral(context, node)
94+
}
95+
96+
override fun visitConstructor(
97+
context: JavaContext,
98+
node: UCallExpression,
99+
constructor: PsiMethod,
100+
) {
101+
val containingClass = constructor.containingClass ?: return
102+
val fqName = context.evaluator.getQualifiedName(containingClass)
103+
if (fqName != COMPOSE_COLOR_CLASS) return
104+
105+
reportHardcodedColorIfLiteral(context, node)
106+
}
107+
108+
private fun isComposeColorClass(fqName: String?): Boolean {
109+
return fqName == COMPOSE_COLOR_CLASS || fqName == COMPOSE_COLOR_KT_CLASS
110+
}
111+
112+
private fun reportHardcodedColorIfLiteral(context: JavaContext, node: UCallExpression) {
113+
val arguments = node.valueArguments
114+
if (arguments.isEmpty()) return
115+
116+
val hexValue = extractHexValue(arguments.firstOrNull()) ?: return
117+
val message = getColorSuggestion(hexValue)
118+
val tokens = GeneratedColorTokenMap.COLOR_TOKEN_MAP[hexValue]
119+
val fix = buildColorLintFix(hexValue, tokens)
120+
121+
context.report(ISSUE, context.getLocation(node), message, fix)
122+
}
123+
124+
private fun buildColorLintFix(hexValue: String, tokens: List<String>?): LintFix? {
125+
tokens ?: return null
126+
return if (tokens.size == 1) {
127+
LintFix.create()
128+
.replace()
129+
.text("Color($hexValue)")
130+
.with(tokens[0])
131+
.autoFix()
132+
.build()
133+
} else {
134+
val alternatives = tokens.map { tokenName ->
135+
LintFix.create()
136+
.replace()
137+
.text("Color($hexValue)")
138+
.with(tokenName)
139+
.build()
140+
}
141+
LintFix.create().alternatives(*alternatives.toTypedArray())
142+
}
143+
}
144+
145+
private fun extractHexValue(argument: org.jetbrains.uast.UElement?): String? {
146+
if (argument is ULiteralExpression) {
147+
val value = argument.value
148+
if (value is Long || value is Int) {
149+
val hex = value.toString().toLongOrNull()?.toString(16)?.uppercase()
150+
return hex?.let { "0x${it.padStart(8, '0')}" }
151+
}
152+
}
153+
return null
154+
}
155+
156+
override fun getApplicableReferenceNames(): List<String> = HARDCODED_COLOR_NAMES.toList()
157+
158+
override fun visitReference(
159+
context: JavaContext,
160+
reference: UReferenceExpression,
161+
referenced: PsiElement,
162+
) {
163+
// Check if this is a Color.Red, Color.Blue, etc. reference
164+
if (referenced !is PsiField) return
165+
166+
val parent = reference.uastParent
167+
if (parent is UQualifiedReferenceExpression) {
168+
val containingClass = referenced.containingClass ?: return
169+
val evaluator = context.evaluator
170+
val qualifiedReference = evaluator.getQualifiedName(containingClass)
171+
172+
if (qualifiedReference == COMPOSE_COLOR_CLASS) {
173+
val fieldName = referenced.name
174+
if (fieldName !in setOf("Unspecified", "Transparent")) {
175+
context.report(
176+
ISSUE,
177+
context.getLocation(parent),
178+
EXPLANATION,
179+
)
180+
}
181+
}
182+
}
183+
}
184+
}

0 commit comments

Comments
 (0)