From 090f362f74b6164d34dc4ba385f7492804db0f4a Mon Sep 17 00:00:00 2001 From: Antonio Aversa Date: Thu, 30 Jan 2025 10:21:22 +0100 Subject: [PATCH] SONARKT-424 Kotlin android metric --- .../org/sonarsource/slang/TestsHelper.java | 1 + .../sonarsource/slang/SlangRulingTest.java | 3 +- .../kotlin/metrics/MetricVisitor.kt | 23 ++++++++++ .../kotlin/metrics/TelemetryData.kt | 37 ++++++++++++++++ .../kotlin/metrics/MetricVisitorTest.kt | 38 +++++++++++++++- .../kotlin/metrics/TelemetryDataTest.kt | 43 +++++++++++++++++++ .../sonarsource/kotlin/plugin/KotlinSensor.kt | 16 ++++++- .../kotlin/plugin/KotlinSensorTest.kt | 12 ++++++ 8 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 sonar-kotlin-metrics/src/main/java/org/sonarsource/kotlin/metrics/TelemetryData.kt create mode 100644 sonar-kotlin-metrics/src/test/java/org/sonarsource/kotlin/metrics/TelemetryDataTest.kt diff --git a/its/plugin/src/test/java/org/sonarsource/slang/TestsHelper.java b/its/plugin/src/test/java/org/sonarsource/slang/TestsHelper.java index f2d86581e..e9dc8d457 100644 --- a/its/plugin/src/test/java/org/sonarsource/slang/TestsHelper.java +++ b/its/plugin/src/test/java/org/sonarsource/slang/TestsHelper.java @@ -47,6 +47,7 @@ public class TestsHelper { .setSonarVersion(System.getProperty(SQ_VERSION_PROPERTY, DEFAULT_SQ_VERSION)) .restoreProfileAtStartup(FileLocation.of("src/test/resources/suppress-warnings-kotlin.xml")) .restoreProfileAtStartup(FileLocation.of("src/test/resources/norule.xml")) + .setServerProperty("sonar.telemetry.enable", "false") .build(); } diff --git a/its/ruling/src/test/java/org/sonarsource/slang/SlangRulingTest.java b/its/ruling/src/test/java/org/sonarsource/slang/SlangRulingTest.java index f8b1a83be..d751f4e66 100644 --- a/its/ruling/src/test/java/org/sonarsource/slang/SlangRulingTest.java +++ b/its/ruling/src/test/java/org/sonarsource/slang/SlangRulingTest.java @@ -65,7 +65,8 @@ public static void setUp() { OrchestratorExtensionBuilder builder = OrchestratorExtension.builderEnv() .useDefaultAdminCredentialsForBuilds(true) .setSonarVersion(System.getProperty(SQ_VERSION_PROPERTY, DEFAULT_SQ_VERSION)) - .addPlugin(MavenLocation.of("org.sonarsource.sonar-lits-plugin", "sonar-lits-plugin", "0.11.0.2659")); + .addPlugin(MavenLocation.of("org.sonarsource.sonar-lits-plugin", "sonar-lits-plugin", "0.11.0.2659")) + .setServerProperty("sonar.telemetry.enable", "false"); addLanguagePlugins(builder); diff --git a/sonar-kotlin-metrics/src/main/java/org/sonarsource/kotlin/metrics/MetricVisitor.kt b/sonar-kotlin-metrics/src/main/java/org/sonarsource/kotlin/metrics/MetricVisitor.kt index d4eccec90..8c9a0a596 100644 --- a/sonar-kotlin-metrics/src/main/java/org/sonarsource/kotlin/metrics/MetricVisitor.kt +++ b/sonar-kotlin-metrics/src/main/java/org/sonarsource/kotlin/metrics/MetricVisitor.kt @@ -21,6 +21,7 @@ import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiComment import com.intellij.psi.PsiWhiteSpace import org.jetbrains.kotlin.kdoc.psi.api.KDoc +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtClassOrObject @@ -46,6 +47,7 @@ const val NOSONAR_PREFIX = "NOSONAR" class MetricVisitor( private val fileLinesContextFactory: FileLinesContextFactory, private val noSonarFilter: NoSonarFilter, + private val telemetryData: TelemetryData, // Some metrics are stored in telemetry ) : KotlinFileVisitor() { private lateinit var ktMetricVisitor: KtMetricVisitor @@ -67,6 +69,8 @@ class MetricVisitor( ktMetricVisitor.executableLines.forEach { line -> fileLinesContext.setIntValue(CoreMetrics.EXECUTABLE_LINES_DATA_KEY, line, 1) } fileLinesContext.save() noSonarFilter.noSonarInFile(ctx.inputFile, ktMetricVisitor.nosonarLines) + + telemetryData.hasAndroidImports = telemetryData.hasAndroidImports || ktMetricVisitor.hasAndroidImports } fun commentLines() = ktMetricVisitor.commentLines.toSet() @@ -82,9 +86,17 @@ class MetricVisitor( fun cognitiveComplexity() = ktMetricVisitor.cognitiveComplexity fun executableLines() = ktMetricVisitor.executableLines + + fun hasAndroidImports() = ktMetricVisitor.hasAndroidImports } private class KtMetricVisitor : KtTreeVisitorVoid() { + private companion object { + val ANDROID_PACKAGES = setOf(Name.identifier("android"), Name.identifier("androidx")) + } + + // User metrics + var linesOfCode: Set = mutableSetOf() private set @@ -112,6 +124,11 @@ private class KtMetricVisitor : KtTreeVisitorVoid() { var cognitiveComplexity = 0 private set + // Telemetry metrics + + var hasAndroidImports = false + private set + override fun visitKtFile(file: KtFile) { linesOfCode = file.linesOfCode() @@ -153,6 +170,12 @@ private class KtMetricVisitor : KtTreeVisitorVoid() { super.visitBlockExpression(expression) } + override fun visitImportDirective(importDirective: KtImportDirective) { + hasAndroidImports = hasAndroidImports || + ANDROID_PACKAGES.any { importDirective.importPath?.fqName?.startsWith(it) ?: false } + super.visitImportDirective(importDirective) + } + private fun addExecutableLines(elements: List, document: Document) { elements.asSequence() .filterNot { diff --git a/sonar-kotlin-metrics/src/main/java/org/sonarsource/kotlin/metrics/TelemetryData.kt b/sonar-kotlin-metrics/src/main/java/org/sonarsource/kotlin/metrics/TelemetryData.kt new file mode 100644 index 000000000..9b14a2536 --- /dev/null +++ b/sonar-kotlin-metrics/src/main/java/org/sonarsource/kotlin/metrics/TelemetryData.kt @@ -0,0 +1,37 @@ +/* + * SonarSource Kotlin + * Copyright (C) 2018-2025 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonarsource.kotlin.metrics + +import org.sonar.api.batch.sensor.SensorContext +import org.sonar.api.utils.Version + +private val MIN_SQS_SUPPORTED: Version = Version.create(10, 9) + +class TelemetryData { + var hasAndroidImports = false + var hasAndroidImportsReportedAsTrue = false + + fun report(sensorContext: SensorContext) { + if (hasAndroidImportsReportedAsTrue || !sensorContext.isTelemetrySupported()) return + sensorContext.addTelemetryProperty("kotlin.android", if (hasAndroidImports) "1" else "0") + hasAndroidImportsReportedAsTrue = hasAndroidImports + } +} + +private fun SensorContext.isTelemetrySupported() = + this.runtime().apiVersion.isGreaterThanOrEqual(MIN_SQS_SUPPORTED) + diff --git a/sonar-kotlin-metrics/src/test/java/org/sonarsource/kotlin/metrics/MetricVisitorTest.kt b/sonar-kotlin-metrics/src/test/java/org/sonarsource/kotlin/metrics/MetricVisitorTest.kt index 28897f23c..1f0b3f342 100644 --- a/sonar-kotlin-metrics/src/test/java/org/sonarsource/kotlin/metrics/MetricVisitorTest.kt +++ b/sonar-kotlin-metrics/src/test/java/org/sonarsource/kotlin/metrics/MetricVisitorTest.kt @@ -73,7 +73,7 @@ internal class MetricVisitorTest { ) ) ).thenReturn(mockFileLinesContext) - visitor = MetricVisitor(mockFileLinesContextFactory, mockNoSonarFilter) + visitor = MetricVisitor(mockFileLinesContextFactory, mockNoSonarFilter, TelemetryData()) } @Test @@ -341,6 +341,42 @@ internal class MetricVisitorTest { assertThat(visitor.executableLines()).containsExactly(5, 10) } + @Test + fun hasAndroidImports() { + fun assert(expected: Boolean, code: String) { + scan(code) + assertThat(visitor.hasAndroidImports()).isEqualTo(expected) + } + + assert(true, "import android.content.Context") + assert(true, "import android.content.Context;") + assert(true, "import android.net.Uri") + assert(true, "import androidx.core.view.WindowCompat") + assert(true, "import android.graphics.*") + assert(true, "import android.content.Context as AndroidContext") + + assert(false, "import java.io.ByteArrayOutputStream") + assert(false, "import kotlin.properties.ReadWriteProperty") + assert(false, "/* import android.content.Context */") + assert(false, """ + // import android.content.Context + """.trimIndent()) + assert(false, "import mylibrary.android.MyClass") + assert(false, "import androidy.core.view.WindowCompat") + assert(false, "package android") + assert(false, "class android {}") + assert(false, "fun android() = 42") + + assert(true, """ + import java.io.* + import android.content.SharedPreferences + """.trimIndent()) + assert(true, """ + import android.util.Log + import com.facebook.react.PackageList + """.trimIndent()) + } + private fun scan(code: String) { inputFile = TestInputFileBuilder("moduleKey", createTempFile(tempFolder, suffix = ".kt").name) .setCharset(StandardCharsets.UTF_8) diff --git a/sonar-kotlin-metrics/src/test/java/org/sonarsource/kotlin/metrics/TelemetryDataTest.kt b/sonar-kotlin-metrics/src/test/java/org/sonarsource/kotlin/metrics/TelemetryDataTest.kt new file mode 100644 index 000000000..a89d69913 --- /dev/null +++ b/sonar-kotlin-metrics/src/test/java/org/sonarsource/kotlin/metrics/TelemetryDataTest.kt @@ -0,0 +1,43 @@ +/* + * SonarSource Kotlin + * Copyright (C) 2018-2025 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonarsource.kotlin.metrics + +import org.junit.jupiter.api.Test + +import org.junit.jupiter.api.Assertions.* +import org.sonar.api.batch.sensor.internal.SensorContextTester +import java.nio.file.Path + +class TelemetryDataTest { + @Test + fun hasAndroidImportsReportedAsTrue() { + val data = TelemetryData() + assertFalse(data.hasAndroidImports) + assertFalse(data.hasAndroidImportsReportedAsTrue) + data.hasAndroidImports = false + assertFalse(data.hasAndroidImportsReportedAsTrue) + val sensorContext = SensorContextTester.create(Path.of(".")) + data.report(sensorContext) + assertFalse(data.hasAndroidImportsReportedAsTrue) + data.hasAndroidImports = true + data.report(sensorContext) + assertTrue(data.hasAndroidImportsReportedAsTrue) + data.hasAndroidImports = false + data.report(sensorContext) + assertTrue(data.hasAndroidImportsReportedAsTrue) + } +} diff --git a/sonar-kotlin-plugin/src/main/java/org/sonarsource/kotlin/plugin/KotlinSensor.kt b/sonar-kotlin-plugin/src/main/java/org/sonarsource/kotlin/plugin/KotlinSensor.kt index 577fe86a3..3e5ea556a 100644 --- a/sonar-kotlin-plugin/src/main/java/org/sonarsource/kotlin/plugin/KotlinSensor.kt +++ b/sonar-kotlin-plugin/src/main/java/org/sonarsource/kotlin/plugin/KotlinSensor.kt @@ -43,6 +43,7 @@ import org.sonarsource.kotlin.metrics.IssueSuppressionVisitor import org.sonarsource.kotlin.metrics.MetricVisitor import org.sonarsource.kotlin.metrics.SyntaxHighlighter import org.sonarsource.kotlin.api.visiting.KtChecksVisitor +import org.sonarsource.kotlin.metrics.TelemetryData import kotlin.jvm.optionals.getOrElse @@ -58,12 +59,23 @@ class KotlinSensor( ): AbstractKotlinSensor( checkFactory, language, KOTLIN_CHECKS ) { + private val telemetryData = TelemetryData() + override fun describe(descriptor: SensorDescriptor) { descriptor .onlyOnLanguage(language.key) .name(language.name + " Sensor") } + override fun execute(sensorContext: SensorContext) { + super.execute(sensorContext) + // The MetricsVisitor instantiated by the visitors method keeps a shared reference + // to the TelemetryData of this sensor, and updates it accordingly. The report method + // of TelemetryData takes care of not sending metrics more than once, when execute is + // run multiple times. + telemetryData.report(sensorContext) + } + override fun getExecuteContext( sensorContext: SensorContext, filesToAnalyze: Iterable, @@ -93,13 +105,13 @@ class KotlinSensor( if (sensorContext.runtime().product == SonarProduct.SONARLINT) { listOf( IssueSuppressionVisitor(), - MetricVisitor(fileLinesContextFactory, noSonarFilter), + MetricVisitor(fileLinesContextFactory, noSonarFilter, telemetryData), KtChecksVisitor(checks), ) } else { listOf( IssueSuppressionVisitor(), - MetricVisitor(fileLinesContextFactory, noSonarFilter), + MetricVisitor(fileLinesContextFactory, noSonarFilter, telemetryData), KtChecksVisitor(checks), CopyPasteDetector(), SyntaxHighlighter(), diff --git a/sonar-kotlin-plugin/src/test/java/org/sonarsource/kotlin/plugin/KotlinSensorTest.kt b/sonar-kotlin-plugin/src/test/java/org/sonarsource/kotlin/plugin/KotlinSensorTest.kt index 0ea7730aa..d13970c14 100644 --- a/sonar-kotlin-plugin/src/test/java/org/sonarsource/kotlin/plugin/KotlinSensorTest.kt +++ b/sonar-kotlin-plugin/src/test/java/org/sonarsource/kotlin/plugin/KotlinSensorTest.kt @@ -711,6 +711,18 @@ internal class KotlinSensorTest : AbstractSensorTest() { assertAnalysisIsNotIncremental(files) } + @Test + fun `execute reports telemetry`() { + val sensor = sensor(checkFactory()) + var telemetrySet = false + val context = spyk(context) { + every { addTelemetryProperty(any(), any()) } answers { telemetrySet = true } + } + assertThat(telemetrySet).isFalse() + sensor.execute(context) + assertThat(telemetrySet).isTrue() + } + private fun assertAnalysisIsIncremental(files: Map) { val addedFile = files[InputFile.Status.ADDED] val changedFile = files[InputFile.Status.CHANGED]