diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0112c596a..4d60f141e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,6 +78,7 @@ gradlePlugins-wire = { module = "com.squareup.wire:wire-gradle-plugin", version. guava = "com.google.guava:guava:32.1.2-jre" kotlinCliUtil = "com.slack.cli:kotlin-cli-util:2.2.1" kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } +kotlin-compilerEmbeddable = { module = "org.jetbrains.kotlin:kotlin-compiler-embeddable", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } ktfmt = { module = "com.facebook:ktfmt", version.ref = "ktfmt" } jgrapht = "org.jgrapht:jgrapht-core:1.5.2" diff --git a/skate-plugin/build.gradle.kts b/skate-plugin/build.gradle.kts index 0d6d1c33d..5587ada82 100644 --- a/skate-plugin/build.gradle.kts +++ b/skate-plugin/build.gradle.kts @@ -85,6 +85,7 @@ tasks // endregion dependencies { + implementation(libs.kotlin.compilerEmbeddable) implementation(libs.bugsnag) { exclude(group = "org.slf4j") } testImplementation(libs.junit) testImplementation(libs.truth) diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/Constants.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/Constants.kt new file mode 100644 index 000000000..30916a5ea --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/Constants.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2023 Slack Technologies, LLC + * + * 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 + * + * https://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.slack.sgp.intellij + +import com.intellij.openapi.util.Key + +// Note this is a hack to allow tests to not use KotlinLanguage class due to classloading problems +// with IJ and detekt. +val TEST_KOTLIN_LANGUAGE_ID_KEY = Key.create("__tests_only_kotlin_id__") diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/SkatePluginSettings.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/SkatePluginSettings.kt index 0e91de915..2f684d2d8 100644 --- a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/SkatePluginSettings.kt +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/SkatePluginSettings.kt @@ -38,8 +38,22 @@ class SkatePluginSettings : SimplePersistentStateComponent>() + } + + fun setFeatureFlagsForPsiFile(psiFile: com.intellij.psi.PsiFile, flags: List) { + featureFlagCache[psiFile] = flags + } + + fun getFeatureFlagsForPsiFile(psiFile: com.intellij.psi.PsiFile): List? { + return featureFlagCache[psiFile] + } + /** + * Extracts the names of feature flags from the provided PSI file. Only processes Kotlin files. + * + * @param psiFile The PSI representation of the file to process. + * @return A list of feature flag names in a file + */ + fun extractFeatureFlags(psiFile: com.intellij.psi.PsiFile): List { + if (!isKtFile(psiFile)) { + LOG.info("$psiFile is not a KtFile") + return emptyList() + } + LOG.info("Getting Enum Entries") + val enumsWithAnnotation = findAnnotatedEnums(psiFile) + LOG.info("Enums with Annotations: $enumsWithAnnotation") + return enumsWithAnnotation + } + + fun isKtFile(obj: Any): Boolean { + return obj.javaClass.getName() == "org.jetbrains.kotlin.psi.KtFile" + } + + fun findAnnotatedEnums(psiFile: Any): List { + val annotatedEnumEntries = mutableListOf() + + fun addIfAnnotatedEnum(element: Any) { + if (isKtEnumEntry(element) && hasFeatureFlagAnnotation(element)) { + element.javaClass + .getMethod("getName") + .invoke(element) + .takeIf { it is String } + ?.let { annotatedEnumEntries.add(it as String) } + } + } + + fun recurse(element: Any) { + addIfAnnotatedEnum(element) + // Traverse children + element.javaClass.methods + .find { it.name == "getChildren" } + ?.let { method -> (method.invoke(element) as? Array<*>)?.forEach { recurse(it!!) } } + } + recurse(psiFile) + return annotatedEnumEntries + } + + /** + * Checks if a given enum entry is a feature flag. An enum is considered a feature flag if it's + * within a Kotlin class and has an annotation named "FeatureFlag". + * + * @param element The enum entry to check. + * @return true if the enum entry is a feature flag, false otherwise. + */ + fun hasFeatureFlagAnnotation(element: Any): Boolean { + val annotationEntriesMethod = element.javaClass.getMethod("getAnnotationEntries") + val annotationEntries = annotationEntriesMethod.invoke(element) as? List<*> + return annotationEntries?.any { + val shortNameMethod = it!!.javaClass.getMethod("getShortName") + val shortName = shortNameMethod.invoke(it) + shortName?.toString() == "FeatureFlag" + } + ?: false + } + + fun isKtEnumEntry(element: Any): Boolean { + LOG.info("Checking if element is KtEnumEntry") + val result = element.javaClass.name == "org.jetbrains.kotlin.psi.KtEnumEntry" + LOG.info("Element isKtEnumEntry: $result") + return result + } +} diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/featureflags/featureFlagAnnotator.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/featureflags/featureFlagAnnotator.kt new file mode 100644 index 000000000..346cbbfa4 --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/featureflags/featureFlagAnnotator.kt @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2023 Slack Technologies, LLC + * + * 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 + * + * https://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.slack.sgp.intellij.featureflags + +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.lang.annotation.ExternalAnnotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.markup.GutterIconRenderer +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.util.IconUtil +import com.slack.sgp.intellij.SkatePluginSettings +import com.slack.sgp.intellij.TEST_KOTLIN_LANGUAGE_ID_KEY +import com.slack.sgp.intellij.util.isLinkifiedFeatureFlagsEnabled +import java.net.URI +import javax.swing.Icon +import org.jetbrains.kotlin.idea.KotlinLanguage + +class FeatureFlagAnnotator : ExternalAnnotator>() { + private val LOG: Logger = Logger.getInstance(FeatureFlagAnnotator::class.java) + private val extractor = FeatureFlagExtractor() + + override fun collectInformation(file: PsiFile): PsiFile? { + if (!isKotlinFeatureFile(file)) { + LOG.info("Skipping non-Feature Kotlin file: $file") + return null + } + LOG.info("Getting CollectedInformation for file: $file") + if (extractor.getFeatureFlagsForPsiFile(file) == null) { + LOG.info("Extracting featureFlags") + val flags = extractor.extractFeatureFlags(file) + LOG.info("FeatureFlags found: $flags") + extractor.setFeatureFlagsForPsiFile(file, flags) + } + return file + } + + override fun doAnnotate(collectedInfo: PsiFile): List { + LOG.info("Starting Annotation") + if (!collectedInfo.project.isLinkifiedFeatureFlagsEnabled() || !isKotlinFile(collectedInfo)) { + LOG.info("This is not a kotline file or FF not enabled for file: $collectedInfo") + return emptyList() + } + + val flagsForFile = extractor.getFeatureFlagsForPsiFile(collectedInfo) + LOG.info("Transform Feature Flag method called") + return transformToFeatureFlagSymbols(collectedInfo, flagsForFile) + } + + private fun transformToFeatureFlagSymbols( + psiFile: PsiFile, + flags: List? + ): List { + LOG.info("Transforming feature flags: $flags") + if (flags == null || flags.isEmpty()) { + LOG.info("No flags to transform. Flags, returning empty list") + return emptyList() + } + + val settings = psiFile.project.service() + val baseUrl = settings.featureFlagBaseUrl.orEmpty() + LOG.info("BaseURL is : $baseUrl") + return flags.mapNotNull { flag -> + val textRange = findTextRangeForFlag(psiFile, flag) + if (textRange != null) { + val url = "$baseUrl?q=$flag" + FeatureFlagSymbol(textRange, url) + } else { + null + } + } + } + + private fun findTextRangeForFlag(psiFile: PsiFile, flag: String): TextRange? { + LOG.info("Finding text range for a flag: $flag") + // Use Pita's approach of traversing the PSI structure to find the text range + val elements = PsiTreeUtil.findChildrenOfType(psiFile, PsiElement::class.java) + for (element in elements) { + if (element.text == flag) { + LOG.info("Text range found: ${element.textRange}") + return element.textRange + } + } + return null + } + + private fun isKotlinFile(psiFile: PsiFile): Boolean = + psiFile.language.id == KotlinLanguage.INSTANCE.id || + psiFile.getUserData(TEST_KOTLIN_LANGUAGE_ID_KEY) == KotlinLanguage.INSTANCE.id + + private fun isKotlinFeatureFile(psiFile: PsiFile): Boolean = psiFile.name.endsWith("Feature.kt") + + override fun apply( + file: PsiFile, + annotationResult: List, + holder: AnnotationHolder + ) { + for (symbol in annotationResult) { + LOG.info("Applying Annotation for a symbol: $symbol") + val textRange = symbol.textRange + val message = "Open on Houston: ${symbol.url}" + holder + .newAnnotation( + HighlightSeverity.INFORMATION, + "More details about feature flag on Houston..." + ) + .range(textRange) + .needsUpdateOnTyping(true) + .withFix(UrlIntentionAction(message, symbol.url)) + .gutterIconRenderer(MyGutterIconRenderer(symbol.url)) + .create() + } + } +} + +class FeatureFlagSymbol(val textRange: TextRange, val url: String) + +class UrlIntentionAction( + private val message: String, + private val url: String, +) : IntentionAction { + override fun getText(): String = message + + override fun getFamilyName(): String = text + + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?) = true + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + BrowserUtil.browse(URI(url)) + } + + override fun startInWriteAction(): Boolean { + return false + } +} + +class MyGutterIconRenderer(private val url: String) : GutterIconRenderer() { + + private val scaledWebIcon: Icon = IconUtil.scale(AllIcons.General.Web, null, 0.5f) + + override fun getIcon() = scaledWebIcon + + override fun getClickAction() = + object : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(url) + } + } + + override fun equals(other: Any?) = other is MyGutterIconRenderer && other.url == url + + override fun hashCode() = url.hashCode() +} diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/util/ProjectUtils.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/util/ProjectUtils.kt new file mode 100644 index 000000000..8615d034c --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/util/ProjectUtils.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2023 Slack Technologies, LLC + * + * 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 + * + * https://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.slack.sgp.intellij.util + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.slack.sgp.intellij.SkatePluginSettings + +fun Project.settings(): SkatePluginSettings = service() + +fun Project.isLinkifiedFeatureFlagsEnabled(): Boolean = settings().isLinkifiedFeatureFlagsEnabled diff --git a/skate-plugin/src/main/resources/META-INF/plugin.xml b/skate-plugin/src/main/resources/META-INF/plugin.xml index b6aa9819e..1a6a9edbd 100644 --- a/skate-plugin/src/main/resources/META-INF/plugin.xml +++ b/skate-plugin/src/main/resources/META-INF/plugin.xml @@ -30,6 +30,7 @@ serviceImplementation="com.slack.sgp.intellij.SkateProjectServiceImpl"/> +