diff options
4 files changed, 874 insertions, 3 deletions
diff --git a/tools/lint/checks/src/com/android/bluetooth/lint/BluetoothLintCheckerRegistry.kt b/tools/lint/checks/src/com/android/bluetooth/lint/BluetoothLintCheckerRegistry.kt index 863fd5cf4f..94dc4add29 100644 --- a/tools/lint/checks/src/com/android/bluetooth/lint/BluetoothLintCheckerRegistry.kt +++ b/tools/lint/checks/src/com/android/bluetooth/lint/BluetoothLintCheckerRegistry.kt @@ -24,7 +24,12 @@ import com.google.auto.service.AutoService @AutoService(IssueRegistry::class) @Suppress("UnstableApiUsage") class BluetoothLintCheckerIssueRegistry : IssueRegistry() { - override val issues = listOf(LogEnforcementVariableCreationDetector.ISSUE) + override val issues = + listOf( + LogEnforcementVariableCreationDetector.ISSUE, + GuardedLogLineDetector.ISSUE, + GuardedLogLineDetector.WARNING + ) override val api: Int get() = CURRENT_API diff --git a/tools/lint/checks/src/com/android/bluetooth/lint/GuardedLogLineDetector.kt b/tools/lint/checks/src/com/android/bluetooth/lint/GuardedLogLineDetector.kt new file mode 100644 index 0000000000..4a4f0e17c9 --- /dev/null +++ b/tools/lint/checks/src/com/android/bluetooth/lint/GuardedLogLineDetector.kt @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.bluetooth.lint + +import com.android.tools.lint.client.api.UElementHandler +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import org.jetbrains.uast.UBinaryExpression +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UClass +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.UIfExpression +import org.jetbrains.uast.UParenthesizedExpression +import org.jetbrains.uast.UPolyadicExpression +import org.jetbrains.uast.UQualifiedReferenceExpression +import org.jetbrains.uast.USimpleNameReferenceExpression +import org.jetbrains.uast.UUnaryExpression + +/** + * Lint check for guarded log lines + * + * Logging enforcement variables are not allowed to be _used_. i.e.: + * + * if (DBG) { + * Log.d(TAG, "message"); + * } + * if (Log.isLoggable(TAG, Log.DEBUG)) { + * Log.d(TAG, "message"); + * } + * if (foo != null) { + * // ... + * } else if (DBG) { + * Log.d(TAG, "foo was null"); + * } + * if (!DBG) { + * // ... + * } else { + * Log.d(TAG, "foo was null"); + * } + * if (DBG) { + * if (foo != null) { + * Log.d(TAG, "foo was null"); + * } + * } + */ +class GuardedLogLineDetector : Detector(), SourceCodeScanner { + private val LOG_ENFORCEMENT_VARS = listOf("DBG", "DEBUG", "VDBG", "VERBOSE", "D", "V") + private val LOG_ENFORCEMENT_VAR_ENDINGS = listOf("_DBG", "_VDBG") + private val LOG_LOGGING_FUNCTIONS = listOf("wtf", "e", "w", "i", "d", "v") + + enum class LogEnforcementType { + NONE, + VARIABLE, + IS_LOGGABLE + } + + companion object { + const val GUARDED_LOG_INVOCATION_ERROR = + "Do not guard log invocations with if blocks using log enforcement variables or" + + " isLoggable(). The Log framework does this check for you. Remove the surrounding if" + + " block and call to log completely unguarded" + + val ISSUE = + Issue.create( + id = "GuardedLogInvocation", + briefDescription = + "Do not guard log invocations with if blocks using log enforcement variables", + explanation = + "The BT stack defines a process default log level, which allows the Android" + + " Log framework (For Java, Kotlin, _and_ Native) to properly enforce log" + + " levels for us. Using our own variables for enforcement causes inconsistency" + + " in log output and double checks against the log level each time we log." + + " Please delete this variable and use the Log functions unguarded in your" + + " code.", + category = Category.CORRECTNESS, + severity = Severity.ERROR, + implementation = + Implementation(GuardedLogLineDetector::class.java, Scope.JAVA_FILE_SCOPE), + androidSpecific = true, + ) + + val WARNING = + Issue.create( + id = "GuardedLogInvocation", + briefDescription = + "Guarding log invocations with calls to Log#isLoggable() should be used" + + " rarely, if ever. Please reconsider what you're logging and/or if it needs" + + "to be guarded in the first place.", + explanation = + "The BT stack defines a process default log level, which allows the Android" + + " Log framework (For Java, Kotlin, _and_ Native) to properly enforce log" + + " levels for us. Using Log#isLoggable() calls to guard invocations is at the" + + " very least redunant. It's also typically used in patterns where non-log" + + " code is guarded, like string builders and loops. In rare cases, we've " + + " even seen abuse of log level checking to hide different logic/behavior, or " + + " forms of debug, like writing to disk. Please reconsider what you're logging" + + " and if it should be guarded be Log#isLoggable().", + category = Category.CORRECTNESS, + severity = Severity.WARNING, + implementation = + Implementation(GuardedLogLineDetector::class.java, Scope.JAVA_FILE_SCOPE), + androidSpecific = true, + ) + } + + override fun getApplicableUastTypes(): List<Class<out UElement>> { + return listOf(UCallExpression::class.java) + } + + override fun createUastHandler(context: JavaContext): UElementHandler? { + return object : UElementHandler() { + override fun visitCallExpression(node: UCallExpression) { + val callingClass = findOwningUClass(node) + if (!isBluetoothClass(callingClass)) { + return + } + + if (!isLoggingFunction(node)) { + return + } + + var ifStatement = findNextContainingUIfExpression(node.uastParent) + while (ifStatement != null) { + var enforcementType = isExpressionWithLogEnforcement(ifStatement.condition) + if (enforcementType == LogEnforcementType.VARIABLE) { + context.report( + issue = ISSUE, + location = context.getNameLocation(ifStatement), + message = GUARDED_LOG_INVOCATION_ERROR, + ) + return + } else if (enforcementType == LogEnforcementType.IS_LOGGABLE) { + context.report( + issue = WARNING, + location = context.getNameLocation(ifStatement), + message = GUARDED_LOG_INVOCATION_ERROR, + ) + return + } + + ifStatement = findNextContainingUIfExpression(ifStatement.uastParent) + } + } + } + } + + /** Traverse the element tree upward to find the closest UClass to a given expression */ + private fun findOwningUClass(node: UElement?): UClass? { + if (node == null) { + return null + } + + if (node is UClass) { + return node + } + + return findOwningUClass(node.uastParent) + } + + /* + * Returns the most recent parent IfExpression for a given expression, or null if the expression + * is not contained in an IfExpression + */ + private fun findNextContainingUIfExpression(node: UElement?): UIfExpression? { + if (node == null) { + return null + } + + if (node is UIfExpression) { + return node + } + + return findNextContainingUIfExpression(node.uastParent) + } + + /* + * Determines if the given Expression contains any usages of Log.isLoggable, or any variables + * that are likely log enforcement variables + */ + private fun isExpressionWithLogEnforcement(node: UExpression): LogEnforcementType { + when (node) { + // A simple class or local variable reference, i.e. "DBG" or "VDBG" + is USimpleNameReferenceExpression -> { + if (isLogEnforcementVariable(node.identifier)) { + return LogEnforcementType.VARIABLE + } + return LogEnforcementType.NONE + } + + // An actual function call, i.e. "isLoggable()" part of Log.isLoggable() + is UCallExpression -> { + if (isLoggableFunction(node)) { + return LogEnforcementType.IS_LOGGABLE + } + return LogEnforcementType.NONE + } + + // A unary operation on another expression, i.e. "!DBG" or "!Log.isLoggable()"" + is UUnaryExpression -> { + return isExpressionWithLogEnforcement(node.operand) + } + + // A binary operation on another expression, i.e. "DBG || Log.isLoggable()" + is UBinaryExpression -> { + val leftEnforcementType = isExpressionWithLogEnforcement(node.leftOperand) + if (leftEnforcementType != LogEnforcementType.NONE) { + return leftEnforcementType + } + + val rightEnforcementType = isExpressionWithLogEnforcement(node.rightOperand) + if (rightEnforcementType != LogEnforcementType.NONE) { + return rightEnforcementType + } + + return LogEnforcementType.NONE + } + + // A conditional expression with multiple operators, i.e. "mFoo || DBG && i < 6" + is UPolyadicExpression -> { + for (subExpression in node.operands) { + var enforcementType = isExpressionWithLogEnforcement(subExpression) + if (enforcementType != LogEnforcementType.NONE) { + return enforcementType + } + } + return LogEnforcementType.NONE + } + + // A function compound call, i.e. "Log.isLoggable()"" + is UQualifiedReferenceExpression -> { + return isExpressionWithLogEnforcement(node.selector) + } + + // An expression surrounded by parenthesis, i.e. "(DBG || Log.isLoggable())" + is UParenthesizedExpression -> { + return isExpressionWithLogEnforcement(node.expression) + } + } + return LogEnforcementType.NONE + } + + /* + * Determines if the given call is one to any of the various Log framework calls that write a + * log line to logcat, i.e. wtf, e, w, i, d, or v + */ + private fun isLoggingFunction(node: UCallExpression): Boolean { + val resolvedMethod = node.resolve() + val methodClassName = resolvedMethod?.containingClass?.qualifiedName + val methodName = resolvedMethod?.name + return methodClassName == "android.util.Log" && methodName in LOG_LOGGING_FUNCTIONS + } + + /** Determines if the given call is one to Log.isLoggable() */ + private fun isLoggableFunction(node: UCallExpression): Boolean { + val resolvedMethod = node.resolve() + val methodClassName = resolvedMethod?.containingClass?.qualifiedName + val methodName = resolvedMethod?.name + return methodClassName == "android.util.Log" && methodName == "isLoggable" + } + + /* + * Checks a string variable name to see if its one of the common names used in the stack for + * log enforcement variables. + * + * These include things like DBG, VDBG, DEBUG, VERBOSE, D, V, etc., or variables that _end_ in + * _DBG or _VDBG + */ + private fun isLogEnforcementVariable(name: String): Boolean { + val nameUpper = name.uppercase() + return nameUpper in LOG_ENFORCEMENT_VARS || + LOG_ENFORCEMENT_VAR_ENDINGS.any { nameUpper.endsWith(it) } + } +} diff --git a/tools/lint/checks/src/com/android/bluetooth/lint/Utils.kt b/tools/lint/checks/src/com/android/bluetooth/lint/Utils.kt index 12ec820df2..9acf2af5ac 100644 --- a/tools/lint/checks/src/com/android/bluetooth/lint/Utils.kt +++ b/tools/lint/checks/src/com/android/bluetooth/lint/Utils.kt @@ -21,8 +21,8 @@ import org.jetbrains.uast.UClass val BLUETOOTH_PLATFORM_PACKAGE = "com.android.bluetooth" /** Returns true */ -fun isBluetoothClass(node: UClass): Boolean { - return node.qualifiedName?.startsWith(BLUETOOTH_PLATFORM_PACKAGE) ?: false +fun isBluetoothClass(node: UClass?): Boolean { + return node?.qualifiedName?.startsWith(BLUETOOTH_PLATFORM_PACKAGE) ?: false } /** Writes lines to debug output, visible in the isolated Java output */ diff --git a/tools/lint/checks/tests/com/android/bluetooth/lint/test/GuardedLogLineDetectorTest.kt b/tools/lint/checks/tests/com/android/bluetooth/lint/test/GuardedLogLineDetectorTest.kt new file mode 100644 index 0000000000..e6088b6c97 --- /dev/null +++ b/tools/lint/checks/tests/com/android/bluetooth/lint/test/GuardedLogLineDetectorTest.kt @@ -0,0 +1,572 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.bluetooth.lint.test + +import com.android.bluetooth.lint.GuardedLogLineDetector +import com.android.tools.lint.checks.infrastructure.LintDetectorTest +import com.android.tools.lint.checks.infrastructure.TestFile +import com.android.tools.lint.checks.infrastructure.TestLintTask +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@Suppress("UnstableApiUsage") +@RunWith(JUnit4::class) +class GuardedLogLineDetectorTest : LintDetectorTest() { + override fun getDetector(): Detector = GuardedLogLineDetector() + + override fun getIssues(): List<Issue> = listOf(GuardedLogLineDetector.ISSUE) + + override fun lint(): TestLintTask = super.lint().allowMissingSdk(true) + + @Test + fun testUnguardedLogStatements_noIssuesFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + Log.v(TAG, "Log as v"); + Log.d(TAG, "Log as d"); + Log.i(TAG, "Log as i"); + Log.w(TAG, "Log as w"); + Log.e(TAG, "Log as e"); + Log.wtf(TAG, "Log as a"); + } +} + """ + ), + *stubs + ) + .run() + .expectClean() + } + + @Test + fun testUnguardedLogStatements_inSafeIfStatement_noIssuesFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (i > 6) { + Log.v(TAG, "Log as v"); + Log.d(TAG, "Log as d"); + Log.i(TAG, "Log as i"); + Log.w(TAG, "Log as w"); + Log.e(TAG, "Log as e"); + Log.wtf(TAG, "Log as a"); + } + } +} + """ + ), + *stubs + ) + .run() + .expectClean() + } + + @Test + fun testGuardedLogWithIsLoggable_warningFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + + public Foo() { + init(6); + } + + public void init(int i) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Log as v"); + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.WARNING) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(0, 1)) + } + + @Test + fun testGuardedLogWithDbgVariable_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (DBG) { + Log.d(TAG, "Log as v"); + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogWithUnaryDbgVariable_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (!DBG) { + Log.d(TAG, "Log as v"); + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogWithBinaryDbgVariable_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (true || DBG) { + Log.d(TAG, "Log as v"); + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogWithPolyadicDbgVariable_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (true || DBG || Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Log as v"); + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogWithParenthesizedDbgVariable_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if ((true || DBG || Log.isLoggable(TAG, Log.DEBUG))) { + Log.d(TAG, "Log as v"); + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogInNestedIfWithDbgVariable_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if ( i > 6) { + if (DBG) { + Log.d(TAG, "Log as v"); + } + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogInIfElseWithDbgVariableInElseIf_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (i > 6) { + return; + } else if (DBG) { + Log.d(TAG, "Log as d"); + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogInIfElseWithDbgVariableInIf_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (!DBG) { + return; + } else { + Log.d(TAG, "Log as d"); + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogInNestedIfWithDbgVariableOuterIfAndLogInInner_issueFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (DBG) { + if (i > 6) { + Log.d(TAG, "Log as d"); + } + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.ISSUE) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(1, 0)) + } + + @Test + fun testGuardedLogInNestedIfWithIsLoggableOuterIfAndLogInInner_warningFound() { + lint() + .files( + java( + """ +package com.android.bluetooth; + +import android.util.Log; + +public final class Foo { + private static final String TAG = Foo.class.getSimpleName(); + private static final boolean DBG = true; + + public Foo() { + init(6); + } + + public void init(int i) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + if (i > 6) { + Log.d(TAG, "Log as d"); + } + } + } +} + """ + ), + *stubs + ) + .issues(GuardedLogLineDetector.WARNING) + .run() + .expectContains(GuardedLogLineDetector.GUARDED_LOG_INVOCATION_ERROR) + .expectContains(createErrorCountString(0, 1)) + } + + private val logFramework: TestFile = + java( + """ + package android.util; + public class Log { + public static final int ASSERT = 7; + public static final int ERROR = 6; + public static final int WARN = 5; + public static final int INFO = 4; + public static final int DEBUG = 3; + public static final int VERBOSE = 2; + + public static Boolean isLoggable(String tag, int level) { + return true; + } + + public static int wtf(String msg) { + return 1; + } + + public static int e(String msg) { + return 1; + } + + public static int w(String msg) { + return 1; + } + + public static int i(String msg) { + return 1; + } + + public static int d(String msg) { + return 1; + } + + public static int v(String msg) { + return 1; + } + } + """ + ) + .indented() + + private val constantsHelper: TestFile = + java( + """ + package com.android.bluetooth; + + public class FooConstants { + public static final String TAG = "FooConstants"; + public static final boolean DBG = true; + public static final boolean VDBG = Log.isLoggable(TAG, Log.VERBOSE); + } + """ + ) + .indented() + + private val stubs = + arrayOf( + logFramework, + constantsHelper, + ) + + private fun createErrorCountString(errors: Int, warnings: Int): String { + return "%d errors, %d warnings".format(errors, warnings) + } + + private fun createFixDiff(lineNumber: Int, lines: String): String { + // All lines are removed. Add enough spaces to match the below indenting + val minusedlines = lines.replace("\n ", "\n - ") + return """ + Fix for src/com/android/bluetooth/Foo.java line $lineNumber: Update log tag initialization: + @@ -$lineNumber +$lineNumber + - $minusedlines + - + """ + .trimIndent() + } +} |