Skip to content

Commit

Permalink
Add Annotation and gutter icon to feature flags
Browse files Browse the repository at this point in the history
  • Loading branch information
arpita184 committed Sep 14, 2023
1 parent 70e1fff commit c53ae2c
Show file tree
Hide file tree
Showing 8 changed files with 348 additions and 0 deletions.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions skate-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ tasks
// endregion

dependencies {
implementation(libs.kotlin.compilerEmbeddable)
implementation(libs.bugsnag) { exclude(group = "org.slf4j") }
testImplementation(libs.junit)
testImplementation(libs.truth)
Expand Down
22 changes: 22 additions & 0 deletions skate-plugin/src/main/kotlin/com/slack/sgp/intellij/Constants.kt
Original file line number Diff line number Diff line change
@@ -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<String>("__tests_only_kotlin_id__")
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,22 @@ class SkatePluginSettings : SimplePersistentStateComponent<SkatePluginSettings.S
state.isWhatsNewEnabled = value
}

var isLinkifiedFeatureFlagsEnabled: Boolean
get() = state.isLinkifiedFeatureFlagsEnabled
set(value) {
state.isLinkifiedFeatureFlagsEnabled = value
}

var featureFlagBaseUrl: String?
get() = state.featureFlagBaseUrl ?: "https://houston.tinyspeck.com/experiments/all"
set(value) {
state.featureFlagBaseUrl = value
}

class State : BaseState() {
var whatsNewFilePath by string()
var isWhatsNewEnabled by property(true)
var isLinkifiedFeatureFlagsEnabled by property(true)
var featureFlagBaseUrl by string()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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.openapi.diagnostic.Logger

/**
* Extracts feature flags from Kotlin files. It searches for enum entries annotated with
* 'FeatureFlag'.
*/
class FeatureFlagExtractor {

private val LOG: Logger = Logger.getInstance(FeatureFlagExtractor::class.java)

companion object {
private val featureFlagCache = mutableMapOf<com.intellij.psi.PsiFile, List<String>>()
}

fun setFeatureFlagsForPsiFile(psiFile: com.intellij.psi.PsiFile, flags: List<String>) {
featureFlagCache[psiFile] = flags
}

fun getFeatureFlagsForPsiFile(psiFile: com.intellij.psi.PsiFile): List<String>? {
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<String> {
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<String> {
val annotatedEnumEntries = mutableListOf<String>()

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
}
}
Original file line number Diff line number Diff line change
@@ -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<PsiFile, List<FeatureFlagSymbol>>() {
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<FeatureFlagSymbol> {
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<String>?
): List<FeatureFlagSymbol> {
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<SkatePluginSettings>()
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<FeatureFlagSymbol>,
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()
}
Original file line number Diff line number Diff line change
@@ -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<SkatePluginSettings>()

fun Project.isLinkifiedFeatureFlagsEnabled(): Boolean = settings().isLinkifiedFeatureFlagsEnabled
1 change: 1 addition & 0 deletions skate-plugin/src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
serviceImplementation="com.slack.sgp.intellij.SkateProjectServiceImpl"/>
<projectConfigurable instance="com.slack.sgp.intellij.SkateConfig"/>
<errorHandler implementation="com.slack.sgp.intellij.SkateErrorHandler"/>
<externalAnnotator language="kotlin" implementationClass="com.slack.sgp.intellij.featureflags.FeatureFlagAnnotator"/>
</extensions>

<applicationListeners>
Expand Down

0 comments on commit c53ae2c

Please sign in to comment.