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 all 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
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,31 @@ class SkatePluginSettings : SimplePersistentStateComponent<SkatePluginSettings.S
state.translatorFileNameSuffix = 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
set(value) {
state.featureFlagBaseUrl = value
}

var featureFlagAnnotation: String?
get() = state.featureFlagAnnotation
set(value) {
state.featureFlagAnnotation = value
}

class State : BaseState() {
var whatsNewFilePath by string()
var isWhatsNewEnabled by property(true)
var translatorSourceModelsPackageName by string()
var translatorFileNameSuffix by string()
var isLinkifiedFeatureFlagsEnabled by property(false)
var featureFlagBaseUrl by string()
var featureFlagAnnotation by string()
arpita184 marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.slack.sgp.intellij.util.isLinkifiedFeatureFlagsEnabled
import java.net.URI
import org.jetbrains.kotlin.psi.KtFile

class FeatureFlagAnnotator : ExternalAnnotator<List<FeatureFlagSymbol>, List<FeatureFlagSymbol>>() {

override fun collectInformation(file: PsiFile): List<FeatureFlagSymbol> {
val isEligibleForLinkifiedFeatureProcessing =
file.project.isLinkifiedFeatureFlagsEnabled() && file is KtFile && isKotlinFeatureFile(file)

if (!isEligibleForLinkifiedFeatureProcessing) {
return emptyList()
}
return FeatureFlagExtractor.extractFeatureFlags(file)
}

override fun doAnnotate(collectedInfo: List<FeatureFlagSymbol>): List<FeatureFlagSymbol> =
collectedInfo

override fun apply(
file: PsiFile,
annotationResult: List<FeatureFlagSymbol>,
holder: AnnotationHolder
) {
for (symbol in annotationResult) {
val message = "Open at: ${symbol.url}"
holder
.newAnnotation(HighlightSeverity.INFORMATION, "Open for more details.")
.range(symbol.range)
.needsUpdateOnTyping(true)
.withFix(UrlIntentionAction(message, symbol.url))
.create()
}
}

private fun isKotlinFeatureFile(psiFile: PsiFile): Boolean = psiFile.name.endsWith("Feature.kt")
}

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,64 @@
/*
* 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.components.service
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiFile
import com.slack.sgp.intellij.SkatePluginSettings
import java.util.Locale
import org.jetbrains.uast.UEnumConstant
import org.jetbrains.uast.UFile
import org.jetbrains.uast.evaluateString
import org.jetbrains.uast.toUElementOfType

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

/**
* 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.
*/
fun extractFeatureFlags(psiFile: PsiFile): List<FeatureFlagSymbol> {
val settings = psiFile.project.service<SkatePluginSettings>()
val baseUrl = settings.featureFlagBaseUrl
val flagAnnotation = settings.featureFlagAnnotation.orEmpty()

val uFile = psiFile.toUElementOfType<UFile>() ?: return emptyList()

return uFile
.allClassesAndInnerClasses()
.filter { it.isEnum }
.flatMap { enumClass -> enumClass.uastDeclarations.filterIsInstance<UEnumConstant>() }
.mapNotNull { enumConstant ->
enumConstant.findAnnotation(flagAnnotation)?.let { flagAnnotation ->
val textRange = enumConstant.sourcePsi?.textRange ?: return@mapNotNull null
val key =
flagAnnotation.findAttributeValue("key")?.evaluateString()?.takeUnless {
it.trim().isBlank()
} ?: enumConstant.name.lowercase(Locale.US)
FeatureFlagSymbol(textRange, "$baseUrl$key")
}
}
.toList()
}
}

data class FeatureFlagSymbol(val range: TextRange, val url: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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 org.jetbrains.uast.UClass
import org.jetbrains.uast.UFile

fun UFile.allClassesAndInnerClasses(): Sequence<UClass> {
return classes.asSequence().flatMap(UClass::asSequenceWithInnerClasses)
}

fun UClass.asSequenceWithInnerClasses(): Sequence<UClass> {
return sequenceOf(this)
.plus(innerClasses.asSequence().flatMap(UClass::asSequenceWithInnerClasses))
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ package com.slack.sgp.intellij.ui
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogPanel
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.dsl.builder.Cell
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.bindSelected
import com.intellij.ui.dsl.builder.bindText
import com.intellij.ui.dsl.builder.panel
import com.intellij.ui.dsl.builder.selected
import com.intellij.ui.layout.ComponentPredicate
import com.slack.sgp.intellij.SkateBundle
import com.slack.sgp.intellij.SkatePluginSettings
import java.io.File
Expand All @@ -34,6 +38,7 @@ internal class SkateConfigUI(
fun createPanel(): DialogPanel = panel {
checkBoxRow()
choosePathRow()
featureFlagSettings()
}

private fun Panel.checkBoxRow() {
Expand Down Expand Up @@ -71,4 +76,53 @@ internal class SkateConfigUI(
.enabled(settings.isWhatsNewEnabled)
}
}

private fun Panel.featureFlagSettings() {
arpita184 marked this conversation as resolved.
Show resolved Hide resolved
lateinit var linkifiedFeatureFlagsCheckBox: Cell<JBCheckBox>

row(SkateBundle.message("skate.configuration.enableFeatureFlagLinking.title")) {
linkifiedFeatureFlagsCheckBox =
checkBox(SkateBundle.message("skate.configuration.enableFeatureFlagLinking.description"))
.bindSelected(
getter = { settings.isLinkifiedFeatureFlagsEnabled },
setter = { settings.isLinkifiedFeatureFlagsEnabled = it }
)
}

bindAndValidateTextFieldRow(
titleMessageKey = "skate.configuration.featureFlagAnnotation.title",
getter = { settings.featureFlagAnnotation },
setter = { settings.featureFlagAnnotation = it },
errorMessageKey = "skate.configuration.featureFlagFieldEmpty.error",
enabledCondition = linkifiedFeatureFlagsCheckBox.selected
)

bindAndValidateTextFieldRow(
titleMessageKey = "skate.configuration.featureFlagBaseUrl.title",
getter = { settings.featureFlagBaseUrl },
setter = { settings.featureFlagBaseUrl = it },
errorMessageKey = "skate.configuration.featureFlagFieldEmpty.error",
enabledCondition = linkifiedFeatureFlagsCheckBox.selected
)
}

private fun Panel.bindAndValidateTextFieldRow(
titleMessageKey: String,
getter: () -> String?,
setter: (String) -> Unit,
errorMessageKey: String,
enabledCondition: ComponentPredicate? = null
) {
row(SkateBundle.message(titleMessageKey)) {
textField()
.bindText(getter = { getter().orEmpty() }, setter = setter)
.validationOnApply {
if (it.text.isBlank()) error(SkateBundle.message(errorMessageKey)) else null
}
.validationOnInput {
if (it.text.isBlank()) error(SkateBundle.message(errorMessageKey)) else null
}
.apply { enabledCondition?.let { enabledIf(it) } }
}
}
}
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 @@ -32,6 +32,7 @@
<projectConfigurable instance="com.slack.sgp.intellij.SkateConfig"/>
<errorHandler implementation="com.slack.sgp.intellij.SkateErrorHandler"/>
<annotator language="kotlin" implementationClass="com.slack.sgp.intellij.modeltranslator.TranslatorAnnotator"/>
<externalAnnotator language="kotlin" implementationClass="com.slack.sgp.intellij.featureflags.FeatureFlagAnnotator"/>
</extensions>

<applicationListeners>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ skate.configuration.choosePath.dialog.title=Choose Changelog File
skate.configuration.enableWhatsNew.title=Enable What's New Panel
skate.configuration.enableWhatsNew.description=If enabled, shows the What's New Panel if there are new changes detected in a specified changelog file.
skate.modelTranslator.description=Generate translator body
skate.configuration.enableFeatureFlagLinking.title=Enable Feature Flag Linking
skate.configuration.enableFeatureFlagLinking.description=If enabled, show inspections for opening feature flag links from corresponding enum entries.
skate.configuration.featureFlagBaseUrl.title=Base URL for feature flag linking. The flag key will be appended to the end of this
skate.configuration.featureFlagAnnotation.title=Feature flag annotation's fully-qualified name (com.example.FeatureFlag)
skate.configuration.featureFlagFieldEmpty.error=Field cannot be empty when feature flag linking is enabled
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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.psi.PsiFile
import com.intellij.testFramework.fixtures.LightPlatformCodeInsightFixture4TestCase

abstract class BaseFeatureFlagTest : LightPlatformCodeInsightFixture4TestCase() {
// language=kotlin
protected val fileContent =
"""
package slack.featureflag

annotation class FeatureFlag(defaultValue: Boolean, val key: String = "", minimization: Minimization)

enum class Minimization { AUTHENTICATED }

enum class TestFeatures :
FeatureFlagEnum {
@Deprecated("test")
@FeatureFlag(defaultValue = false, key ="test_flag_one", minimization = AUTHENTICATED)
FLAG_ONE,
@FeatureFlag(defaultValue = false, minimization = AUTHENTICATED)
FLAG_TWO,
@FeatureFlag(defaultValue = false, minimization = AUTHENTICATED)
FLAG_THREE,
}
"""

protected fun createKotlinFile(
name: String,
text: String,
): PsiFile {
return myFixture.configureByText(name, text)
}
}
Loading