Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Flag Annotator #576

Merged
merged 28 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c53ae2c
Add Annotation and gutter icon to feature flags
arpita184 Sep 14, 2023
1b83c8f
Add tests
arpita184 Sep 20, 2023
99c4006
Fix java.lang.NoClassDefFoundError
arpita184 Sep 21, 2023
1de3ef7
Run spotless
arpita184 Sep 21, 2023
78d6ae6
fix url
arpita184 Sep 21, 2023
078d7c2
Merge branch 'main' into ap_feature_flag_annotator
arpita184 Sep 21, 2023
4c0937d
Remove redundant logging
arpita184 Sep 21, 2023
d7f4a00
Test cleanup
arpita184 Sep 21, 2023
38de1b7
File rename and updating imports
arpita184 Sep 22, 2023
7843719
Remove gutter icon, add lazy loading and add clear intent with top le…
arpita184 Sep 22, 2023
a945d8c
Test cleanup
arpita184 Sep 22, 2023
533826c
Add syntax highlight
arpita184 Sep 22, 2023
69f3cf4
Update url reference
arpita184 Sep 22, 2023
0bb695b
Step 1: Removing usage of reflection and using intellij's PSI framework
arpita184 Sep 25, 2023
252b25b
Streamline annotator
arpita184 Sep 25, 2023
3d79394
Strongly typed Psi checks and some more cleanup
arpita184 Sep 27, 2023
c3029fe
Splitting up tests to their respective test classes
arpita184 Sep 27, 2023
73c96a8
Further cleanup
arpita184 Sep 27, 2023
ec81f44
Use UAST API instead of using traversal APIs and add tests
arpita184 Sep 27, 2023
54b1aff
Remove redundant check
arpita184 Sep 27, 2023
758f15c
Merge main
arpita184 Sep 27, 2023
c3b4a20
Cleanup
arpita184 Sep 27, 2023
627ff13
Fix tests
arpita184 Sep 27, 2023
d7408ef
Fix tests
arpita184 Sep 27, 2023
d1a586e
Add UI configuration to take user input for Feature Flag values and e…
arpita184 Sep 28, 2023
d93844e
assign conditions to a variable for easier readability
arpita184 Sep 28, 2023
dacb0bc
Early return when textRange not found
arpita184 Sep 28, 2023
27765be
Improved settings screen message
arpita184 Sep 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
6 changes: 5 additions & 1 deletion skate-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ repositories { mavenCentral() }

// Configure Gradle IntelliJ Plugin
// Read more: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html
intellij { plugins.add("org.intellij.plugins.markdown") }
intellij {
plugins.add("org.intellij.plugins.markdown")
plugins.add("org.jetbrains.kotlin")
arpita184 marked this conversation as resolved.
Show resolved Hide resolved
}

fun isGitHash(hash: String): Boolean {
if (hash.length != 40) {
Expand Down Expand Up @@ -86,6 +89,7 @@ tasks

dependencies {
implementation(libs.bugsnag) { exclude(group = "org.slf4j") }
compileOnly(libs.kotlin.compilerEmbeddable)
testImplementation(libs.junit)
testImplementation(libs.truth)
}
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
ZacSweers marked this conversation as resolved.
Show resolved Hide resolved
get() = state.isLinkifiedFeatureFlagsEnabled
set(value) {
state.isLinkifiedFeatureFlagsEnabled = value
}

var featureFlagBaseUrl: String?
get() = state.featureFlagBaseUrl ?: "https://houston.tinyspeck.com/experiments/all"
arpita184 marked this conversation as resolved.
Show resolved Hide resolved
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,130 @@
/*
* 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.ide.BrowserUtil
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.ExternalAnnotator
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.Editor
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.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 org.jetbrains.kotlin.idea.KotlinLanguage

class FeatureFlagAnnotator : ExternalAnnotator<PsiFile, List<FeatureFlagSymbol>>() {
private val extractor by lazy { FeatureFlagExtractor() }

override fun collectInformation(file: PsiFile): PsiFile? {
if (!isKotlinFeatureFile(file)) {
return null
}
if (extractor.getFeatureFlagsForPsiFile(file) == null) {
val flags = extractor.extractFeatureFlags(file)
extractor.setFeatureFlagsForPsiFile(file, flags)
}
return file
}

override fun doAnnotate(collectedInfo: PsiFile): List<FeatureFlagSymbol> {
if (!collectedInfo.project.isLinkifiedFeatureFlagsEnabled() || !isKotlinFile(collectedInfo)) {
return emptyList()
}

val flagsForFile = extractor.getFeatureFlagsForPsiFile(collectedInfo)
return transformToFeatureFlagSymbols(collectedInfo, flagsForFile)
}
arpita184 marked this conversation as resolved.
Show resolved Hide resolved

private fun transformToFeatureFlagSymbols(
psiFile: PsiFile,
flags: List<String>?
): List<FeatureFlagSymbol> {
if (flags.isNullOrEmpty()) {
return emptyList()
}
val baseUrl = psiFile.project.service<SkatePluginSettings>().featureFlagBaseUrl.orEmpty()

return flags.mapNotNull { flag ->
val textRange = runReadAction { findTextRangeForFlag(psiFile, flag) }
if (textRange != null) {
val url = "$baseUrl?q=${flag.lowercase()}"
FeatureFlagSymbol(textRange, url)
} else {
null
}
}
}

private fun findTextRangeForFlag(psiFile: PsiFile, flag: String): TextRange? {
val elements = PsiTreeUtil.findChildrenOfType(psiFile, PsiElement::class.java)
return elements.firstOrNull { it.text == flag }?.textRange
}

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) {
val message = "Open on Houston: ${symbol.url}"
holder
.newAnnotation(
HighlightSeverity.INFORMATION,
"More details about feature flag on Houston..."
)
.range(symbol.textRange)
.needsUpdateOnTyping(true)
.withFix(UrlIntentionAction(message, 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
}
}
arpita184 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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
import com.intellij.psi.PsiFile

/**
* Responsible for extracting feature flags. Searches for enum entries annotated with 'FeatureFlag'
* to identify feature flags.
*/
class FeatureFlagExtractor {

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

// Caches the feature flags for a given PSI file to optimize repeated lookups
companion object {
private val featureFlagCache = mutableMapOf<PsiFile, List<String>>()
}

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

fun getFeatureFlagsForPsiFile(psiFile: 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: PsiFile): List<String> {
log.info("Looking for feature flags in a file: $psiFile")
val enumsWithAnnotation = findAnnotatedEnums(psiFile)
log.info("Found feature flags: $enumsWithAnnotation")
return enumsWithAnnotation
}

/** Recursively searches the given PSI file for enums with 'FeatureFlag' annotations. */
private 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) }
arpita184 marked this conversation as resolved.
Show resolved Hide resolved
}
}

fun recurse(element: Any) {
addIfAnnotatedEnum(element)
element.javaClass.methods
.find { it.name == "getChildren" }
?.let { method -> (method.invoke(element) as? Array<*>)?.forEach { recurse(it!!) } }
}
recurse(psiFile)
return annotatedEnumEntries
}

/**
* Determines if a given PSI element is an enum entry annotated with 'FeatureFlag'.
arpita184 marked this conversation as resolved.
Show resolved Hide resolved
*
* @param element The enum entry to check.
* @return true if the enum entry is a feature flag, false otherwise.
*/
private 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
}

/** Checks if the given PSI element is a Kotlin enum entry. */
private fun isKtEnumEntry(element: Any): Boolean {
log.info("Checking if element is a Kotlin Enum Entry")
val result = element.javaClass.name == "org.jetbrains.kotlin.psi.KtEnumEntry"
log.info("Element is Kotlin Enum Entry: $result")
return result
}
}
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
2 changes: 2 additions & 0 deletions skate-plugin/src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html -->
<depends>com.intellij.modules.platform</depends>
<depends>org.intellij.plugins.markdown</depends>
<depends>org.jetbrains.kotlin</depends>

<!-- Extension points defined by the plugin.
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
Expand All @@ -30,6 +31,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
Loading