diff --git a/.github/README.md b/.github/README.md index 936b5d0..b5c1e8c 100644 --- a/.github/README.md +++ b/.github/README.md @@ -31,6 +31,7 @@ A CLI and Android Studio plugin for generating Clean Architecture boilerplate. | Generate a data source | ✔️ | ✔️ | | Automatic git staging | ✔️ | ✔️ | | Configurable | ✔️ | ✔️ | +| Inline inspections | ✔️ | ❌️ | **Android Studio Plugin** is available on the IDE Plugins Marketplace. @@ -38,7 +39,7 @@ A CLI and Android Studio plugin for generating Clean Architecture boilerplate. ## Android Studio plugin -Adds multiple time-saving code generation shortcuts to Android Studio. +Adds multiple time-saving code generation shortcuts to Android Studio. ### Usage @@ -53,6 +54,9 @@ Settings are available under `Tools` > `Clean Architecture`. For a working project example, visit [Clean Architecture For Android](https://github.com/EranBoudjnah/CleanArchitectureForAndroid). +#### Inspections +Work out of the box. You can disable any or all inspections via Android Studio's settings. + ## CLI Generates Clean Architecture Android code from your terminal. diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 02ff145..5b64b5d 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -12,6 +12,7 @@ diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b8157..caaa461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.3.0] + +### Added +- Added inspections to the plugin +- Added a reference project to README for easier onboarding (#29) +- Added Homebrew installation instructions to README (#28) +- Added backwards compatibility for Android Studio Meerkat (#25) +- Added settings for custom git path (#20) +- Added version output to the CLI (#26) +- Added git automation to the CLI for staging and commits (#19) + +### Changed +- Renamed generated binary from cli to cag (#27) +- Migrated UI to use JetBrains UI DSL v2 (#24) +- Deduplicated version catalog entries (#23) +- Refreshed and updated the README content (#30) + +### Fixed +- Removed dexmaker from presentation-test module (#22) +- Ensured new project minimum SDK version respects IDE-specified value (#21) + ## [0.2.0] ### Added diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index ccbed0f..54e1a62 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.mitteloupe.cag" -version = "0.0.2" +version = "0.3.0" repositories { mavenCentral() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 155edd9..f38707c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,11 +7,14 @@ mockk = "1.13.11" junit4 = "4.13.2" androidStudio = "2025.1.4.7" hamcrest = "3.0" +lint = "31.13.0" [libraries] mockk = { module = "io.mockk:mockk", version.ref = "mockk" } junit4 = { module = "junit:junit", version.ref = "junit4" } hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "hamcrest" } +lint-api = { module = "com.android.tools.lint:lint-api", version.ref = "lint" } +lint-checks = { module = "com.android.tools.lint:lint-checks", version.ref = "lint" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts new file mode 100644 index 0000000..e0e4ed3 --- /dev/null +++ b/lint/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation(kotlin("stdlib")) + + compileOnly(libs.lint.api) + compileOnly(libs.lint.checks) + + testImplementation(libs.junit4) +} + +tasks.register("lintJar") { + dependsOn(tasks.named("compileKotlin")) + from(sourceSets.main.get().output) + archiveBaseName.set("lint") +} diff --git a/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/lint/CagIssueRegistry.kt b/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/lint/CagIssueRegistry.kt new file mode 100644 index 0000000..143ba29 --- /dev/null +++ b/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/lint/CagIssueRegistry.kt @@ -0,0 +1,12 @@ +package com.mitteloupe.cag.cleanarchitecturegenerator.lint + +import com.android.tools.lint.client.api.IssueRegistry +import com.android.tools.lint.detector.api.CURRENT_API +import com.android.tools.lint.detector.api.Issue + +class CagIssueRegistry : IssueRegistry() { + override val issues: List + get() = listOf(ViewModelPublicFunctionShouldStartWithOnDetector.ISSUE) + + override val api: Int = CURRENT_API +} diff --git a/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/lint/ViewModelPublicFunctionShouldStartWithOnDetector.kt b/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/lint/ViewModelPublicFunctionShouldStartWithOnDetector.kt new file mode 100644 index 0000000..d515fe0 --- /dev/null +++ b/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/lint/ViewModelPublicFunctionShouldStartWithOnDetector.kt @@ -0,0 +1,57 @@ +package com.mitteloupe.cag.cleanarchitecturegenerator.lint + +import com.android.tools.lint.client.api.UElementHandler +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.mitteloupe.cag.cleanarchitecturegenerator.rule.ViewModelPublicFunctionShouldStartWithOnRule +import org.jetbrains.uast.UMethod +import org.jetbrains.uast.UastVisibility + +class ViewModelPublicFunctionShouldStartWithOnDetector : Detector(), Detector.UastScanner { + + override fun getApplicableUastTypes() = listOf(UMethod::class.java) + + override fun createUastHandler(context: JavaContext) = + object : UElementHandler() { + override fun visitMethod(node: UMethod) { + val functionName = node.name + val isValid = ViewModelPublicFunctionShouldStartWithOnRule.isValid( + className = node.containingClass?.name, + functionName = functionName, + isPublic = node.visibility == UastVisibility.PUBLIC + ) + + if (!isValid) { + context.report( + ISSUE, + node, + context.getNameLocation(node), + ViewModelPublicFunctionShouldStartWithOnRule.violationMessage(functionName) + ) + } + } + } + + companion object { + val ISSUE: Issue = Issue.create( + id = "ViewModelPublicFunctionShouldStartWithOn", + briefDescription = "Public ViewModel functions should start with 'on'", + explanation = """ + Public functions in ViewModel classes should start with 'on' to clearly indicate that they represent user or system events, \ + following Clean Architecture naming conventions for better readability and maintainability. + """.trimIndent(), + category = Category.CORRECTNESS, + priority = 5, + severity = Severity.WARNING, + implementation = Implementation( + ViewModelPublicFunctionShouldStartWithOnDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + } +} diff --git a/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelFunctionShouldNotContainUiTermsRule.kt b/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelFunctionShouldNotContainUiTermsRule.kt new file mode 100644 index 0000000..93a8cd3 --- /dev/null +++ b/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelFunctionShouldNotContainUiTermsRule.kt @@ -0,0 +1,47 @@ +package com.mitteloupe.cag.cleanarchitecturegenerator.rule + +import com.intellij.codeInsight.intention.FileModifier.SafeTypeForPreview + +private val uiTermsRegex = + "(${firstLetter("c")}lick(ed)?|${firstLetter("s")}croll(ed)?|${firstLetter("s")}wipe(d)?|${firstLetter("t")}ap(ped)?|${firstLetter("l")}ongPress(ed)?|${firstLetter("t")}ouch(ed)?)".toRegex() + +private fun firstLetter(firstCharacter: String) = + "((?<=\\w)${firstCharacter.uppercase()}|^${firstCharacter.lowercase()})" + +object ViewModelFunctionShouldNotContainUiTermsRule { + fun validate( + className: String?, + functionName: String + ): Result { + if (className?.endsWith("ViewModel") != true) { + return Result.Valid + } + + val uiTermsMatchResult = uiTermsRegex.find(functionName) ?: return Result.Valid + + return Result.Invalid(functionName = functionName, offendingValue = uiTermsMatchResult.value) + } + + fun violationMessage(offendingValue: String): String = + "Function name contains UI term '$offendingValue', consider replacing it with 'Action'" + + sealed interface Result { + data object Valid : Result + + @SafeTypeForPreview + data class Invalid( + private val functionName: String, + val offendingValue: String + ) : Result { + fun fixFunctionName(): String { + val capitalizedA = + if (functionName.indexOf(offendingValue) == 0) { + "a" + } else { + "A" + } + return functionName.replaceFirst(offendingValue.toRegex(RegexOption.IGNORE_CASE), "${capitalizedA}ction") + } + } + } +} diff --git a/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelPublicFunctionShouldStartWithOnRule.kt b/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelPublicFunctionShouldStartWithOnRule.kt new file mode 100644 index 0000000..8689bfb --- /dev/null +++ b/lint/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelPublicFunctionShouldStartWithOnRule.kt @@ -0,0 +1,17 @@ +package com.mitteloupe.cag.cleanarchitecturegenerator.rule + +object ViewModelPublicFunctionShouldStartWithOnRule { + private val validFunctionNameRegex = Regex("^on[A-Z0-9].*$") + + fun isValid( + className: String?, + functionName: String, + isPublic: Boolean + ): Boolean = + !isPublic || + className?.endsWith("ViewModel") != true || + functionName.matches(validFunctionNameRegex) + + fun violationMessage(functionName: String): String = + "ViewModel public function name '$functionName' should start with 'on'" +} diff --git a/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry b/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry new file mode 100644 index 0000000..39ba598 --- /dev/null +++ b/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry @@ -0,0 +1 @@ +com.mitteloupe.cag.cleanarchitecturegenerator.lint.CagIssueRegistry diff --git a/lint/src/test/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelFunctionShouldNotContainUiTermsRuleTest.kt b/lint/src/test/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelFunctionShouldNotContainUiTermsRuleTest.kt new file mode 100644 index 0000000..f6bbed4 --- /dev/null +++ b/lint/src/test/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelFunctionShouldNotContainUiTermsRuleTest.kt @@ -0,0 +1,152 @@ +package com.mitteloupe.cag.cleanarchitecturegenerator.rule + +import com.mitteloupe.cag.cleanarchitecturegenerator.rule.ViewModelFunctionShouldNotContainUiTermsRuleTest.Fixes +import com.mitteloupe.cag.cleanarchitecturegenerator.rule.ViewModelFunctionShouldNotContainUiTermsRuleTest.InvalidCases +import com.mitteloupe.cag.cleanarchitecturegenerator.rule.ViewModelFunctionShouldNotContainUiTermsRuleTest.ValidCases +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters +import org.junit.runners.Suite.SuiteClasses + +@RunWith(Enclosed::class) +@SuiteClasses( + ValidCases::class, + InvalidCases::class, + Fixes::class +) +class ViewModelFunctionShouldNotContainUiTermsRuleTest { + @RunWith(Parameterized::class) + class ValidCases( + private val className: String?, + private val functionName: String + ) { + companion object { + @JvmStatic + @Parameters(name = "{index}: Given class {0} with function {1} then is valid") + fun testCases() = listOf( + testCase("MainViewModel", "onStart"), + testCase("MainViewModel", "loadData"), + testCase("MainViewModel", "submitForm"), + testCase("MainController", "onClick"), + testCase(null, "clicked"), + testCase("SomeOtherClass", "onSwipe") + ) + + private fun testCase(className: String?, functionName: String) = + arrayOf(className, functionName) + } + + @Test + fun `When validate then is Valid`() { + // When + val result = ViewModelFunctionShouldNotContainUiTermsRule.validate(className, functionName) + + // Then + Assert.assertTrue(result is ViewModelFunctionShouldNotContainUiTermsRule.Result.Valid) + } + } + + @RunWith(Parameterized::class) + class InvalidCases( + private val className: String, + private val functionName: String, + private val expectedUiTerm: String + ) { + companion object { + @JvmStatic + @Parameters(name = "{index}: Given class {0} with function {1} then is invalid (UI term: {2})") + fun testCases() = listOf( + testCase("MainViewModel", "click", "click"), + testCase("MainViewModel", "onScrolled", "Scrolled"), + testCase("LoginViewModel", "onTap", "Tap"), + testCase("HomeViewModel", "handleSwipe", "Swipe"), + testCase("ProfileViewModel", "longPressedExit", "longPressed"), + testCase("SettingsViewModel", "onTouchEvent", "Touch") + ) + + private fun testCase(className: String, functionName: String, offendingValue: String) = + arrayOf(className, functionName, offendingValue) + } + + @Test + fun `When validate then is Invalid`() { + // Given + val expectedResult = ViewModelFunctionShouldNotContainUiTermsRule.Result.Invalid(functionName, expectedUiTerm) + + // When + val result = ViewModelFunctionShouldNotContainUiTermsRule.validate(className, functionName) + + // Then + assertEquals(expectedResult, result) + val invalidResult = result as ViewModelFunctionShouldNotContainUiTermsRule.Result.Invalid + assertEquals(expectedUiTerm, invalidResult.offendingValue) + } + + @Test + fun `When violationMessage then returns expected message`() { + // Given + val expectedMessage = "Function name contains UI term '$expectedUiTerm', consider replacing it with 'Action'" + + // When + val actualMessage = ViewModelFunctionShouldNotContainUiTermsRule.violationMessage(expectedUiTerm) + + // Then + assertEquals(expectedMessage, actualMessage) + } + } + + @RunWith(Parameterized::class) + class Fixes( + private val className: String, + private val functionName: String, + private val expectedFixedName: String + ) { + companion object { + @JvmStatic + @Parameters(name = "{index}: Given class {0} with function {1} then is renamed to {2}") + fun testCases() = listOf( + testCase("MainViewModel", "click", "action"), + testCase("MainViewModel", "onScrolled", "onAction"), + testCase("LoginViewModel", "onTap", "onAction"), + testCase("HomeViewModel", "handleSwipe", "handleAction"), + testCase("ProfileViewModel", "longPressedExit", "actionExit"), + testCase("SettingsViewModel", "onTouchEvent", "onActionEvent") + ) + + private fun testCase(className: String, functionName: String, expectedFixedName: String) = + arrayOf(className, functionName, expectedFixedName) + } + + @Test + fun `When fixFunctionName then is Invalid`() { + // Given + val result = + ViewModelFunctionShouldNotContainUiTermsRule.validate( + className, + functionName + ) as ViewModelFunctionShouldNotContainUiTermsRule.Result.Invalid + + // When + val actualFixedName = result.fixFunctionName() + + // Then + assertEquals(expectedFixedName, actualFixedName) + } + + @Test + fun `When violationMessage then returns expected message`() { + // Given + val expectedMessage = "Function name contains UI term '$expectedFixedName', consider replacing it with 'Action'" + + // When + val actualMessage = ViewModelFunctionShouldNotContainUiTermsRule.violationMessage(expectedFixedName) + + // Then + assertEquals(expectedMessage, actualMessage) + } + } +} diff --git a/lint/src/test/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelPublicFunctionShouldStartWithOnRuleTest.kt b/lint/src/test/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelPublicFunctionShouldStartWithOnRuleTest.kt new file mode 100644 index 0000000..664923a --- /dev/null +++ b/lint/src/test/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/rule/ViewModelPublicFunctionShouldStartWithOnRuleTest.kt @@ -0,0 +1,103 @@ +package com.mitteloupe.cag.cleanarchitecturegenerator.rule + +import com.mitteloupe.cag.cleanarchitecturegenerator.rule.ViewModelPublicFunctionShouldStartWithOnRuleTest.InvalidCases +import com.mitteloupe.cag.cleanarchitecturegenerator.rule.ViewModelPublicFunctionShouldStartWithOnRuleTest.ValidCases +import org.junit.Assert +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Suite + +@RunWith(Enclosed::class) +@Suite.SuiteClasses( + ValidCases::class, + InvalidCases::class +) +class ViewModelPublicFunctionShouldStartWithOnRuleTest { + @RunWith(Parameterized::class) + class ValidCases( + private val className: String?, + private val functionName: String, + private val isPublic: Boolean + ) { + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: Given {0} with function named {1}, isPublic={2} then is valid") + fun testCases() = + listOf( + testCase(className = "MainViewModel", functionName = "onClick", isPublic = true), + testCase(className = "MainViewModel", functionName = "on1Start", isPublic = true), + testCase(className = "MainViewModel", functionName = "onStart", isPublic = false), + testCase(className = "BaseController", functionName = "click", isPublic = true), + testCase(className = null, functionName = "click", isPublic = true) + ) + + private fun testCase( + className: String?, + functionName: String, + isPublic: Boolean + ) = arrayOf(className, functionName, isPublic) + } + + @Test + fun `When isValid`() { + // When + val actualIsValid = + ViewModelPublicFunctionShouldStartWithOnRule.isValid( + className = className, + functionName = functionName, + isPublic = isPublic + ) + + // Then + Assert.assertTrue(actualIsValid) + } + } + + @RunWith(Parameterized::class) + class InvalidCases( + private val className: String?, + private val functionName: String, + private val expectedMessage: String + ) { + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: Given {0} with public function named {1} then is invalid ({2})") + fun testCases() = + listOf( + testCase(className = "MainViewModel", functionName = "click"), + testCase(className = "TestViewModel", functionName = "on"), + testCase(className = "AnotherViewModel", functionName = "OnStart") + ) + + private fun testCase( + className: String?, + functionName: String + ) = arrayOf(className, functionName, "ViewModel public function name '$functionName' should start with 'on'") + } + + @Test + fun `When isValid`() { + // When + val actualIsValid = + ViewModelPublicFunctionShouldStartWithOnRule.isValid( + className = className, + functionName = functionName, + isPublic = true + ) + + // Then + Assert.assertFalse(actualIsValid) + } + + @Test + fun `When violationMessage`() { + // When + val message = ViewModelPublicFunctionShouldStartWithOnRule.violationMessage(functionName) + + // Then + Assert.assertEquals(expectedMessage, message) + } + } +} diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index c87de99..62c71b2 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -29,6 +29,7 @@ intellijPlatform { dependencies { implementation(project(":core")) implementation(project(":git")) + implementation(project(":lint")) intellijPlatform { androidStudio(libs.versions.androidStudio.get()) diff --git a/plugin/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/inspection/ViewModelFunctionShouldNotContainUiTermsInspection.kt b/plugin/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/inspection/ViewModelFunctionShouldNotContainUiTermsInspection.kt new file mode 100644 index 0000000..64b0902 --- /dev/null +++ b/plugin/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/inspection/ViewModelFunctionShouldNotContainUiTermsInspection.kt @@ -0,0 +1,56 @@ +package com.mitteloupe.cag.cleanarchitecturegenerator.inspection + +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.LocalQuickFix +import com.intellij.codeInspection.ProblemDescriptor +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElementVisitor +import com.mitteloupe.cag.cleanarchitecturegenerator.rule.ViewModelFunctionShouldNotContainUiTermsRule +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtPsiFactory +import org.jetbrains.kotlin.psi.KtVisitorVoid +import org.jetbrains.kotlin.psi.psiUtil.containingClassOrObject + +class ViewModelFunctionShouldNotContainUiTermsInspection : LocalInspectionTool() { + override fun buildVisitor( + holder: ProblemsHolder, + isOnTheFly: Boolean + ): PsiElementVisitor = + object : KtVisitorVoid() { + override fun visitNamedFunction(function: KtNamedFunction) { + val functionName = function.name.orEmpty() + val result = + ViewModelFunctionShouldNotContainUiTermsRule.validate( + className = function.containingClassOrObject?.name, + functionName = functionName + ) + + if (result is ViewModelFunctionShouldNotContainUiTermsRule.Result.Invalid) { + holder.registerProblem( + function.nameIdentifier ?: function, + ViewModelFunctionShouldNotContainUiTermsRule.violationMessage(result.offendingValue), + ProblemHighlightType.GENERIC_ERROR_OR_WARNING, + ReplaceUiTermWithActionQuickFix(result) + ) + } + } + } + + private class ReplaceUiTermWithActionQuickFix( + private val result: ViewModelFunctionShouldNotContainUiTermsRule.Result.Invalid + ) : LocalQuickFix { + override fun getFamilyName() = "Replace '${result.offendingValue}' with 'Action'" + + override fun applyFix( + project: Project, + descriptor: ProblemDescriptor + ) { + val function = descriptor.psiElement.parent as? KtNamedFunction ?: return + val identifier = function.nameIdentifier ?: return + val newName = result.fixFunctionName() + identifier.replace(KtPsiFactory(project).createIdentifier(newName)) + } + } +} diff --git a/plugin/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/inspection/ViewModelPublicFunctionShouldStartWithOnInspection.kt b/plugin/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/inspection/ViewModelPublicFunctionShouldStartWithOnInspection.kt new file mode 100644 index 0000000..dc0838e --- /dev/null +++ b/plugin/src/main/kotlin/com/mitteloupe/cag/cleanarchitecturegenerator/inspection/ViewModelPublicFunctionShouldStartWithOnInspection.kt @@ -0,0 +1,34 @@ +package com.mitteloupe.cag.cleanarchitecturegenerator.inspection + +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElementVisitor +import com.mitteloupe.cag.cleanarchitecturegenerator.rule.ViewModelPublicFunctionShouldStartWithOnRule +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtVisitorVoid +import org.jetbrains.kotlin.psi.psiUtil.containingClassOrObject +import org.jetbrains.kotlin.psi.psiUtil.isPublic + +class ViewModelPublicFunctionShouldStartWithOnInspection : LocalInspectionTool() { + override fun buildVisitor( + holder: ProblemsHolder, + isOnTheFly: Boolean + ): PsiElementVisitor = + object : KtVisitorVoid() { + override fun visitNamedFunction(function: KtNamedFunction) { + val functionName = function.name.orEmpty() + val isValid = + ViewModelPublicFunctionShouldStartWithOnRule.isValid( + className = function.containingClassOrObject?.name, + functionName = functionName, + isPublic = function.isPublic + ) + if (!isValid) { + holder.registerProblem( + function.nameIdentifier ?: function, + ViewModelPublicFunctionShouldStartWithOnRule.violationMessage(functionName) + ) + } + } + } +} diff --git a/plugin/src/main/resources/META-INF/plugin.xml b/plugin/src/main/resources/META-INF/plugin.xml index e5eee8a..58d80e1 100644 --- a/plugin/src/main/resources/META-INF/plugin.xml +++ b/plugin/src/main/resources/META-INF/plugin.xml @@ -7,36 +7,33 @@ Guidelines: https://plugins.jetbrains.com/docs/marketplace/best-practices-for-listing.html#plugin-name --> Clean Architecture Generator (CAG) - 0.2.0 + 0.3.0 0.2.0 +

0.3.0

Added

    -
  • Added optional git initialization for new projects (#16)
  • -
  • Added automatic git staging option (#14)
  • -
  • Added man page support and improved help documentation (#13)
  • -
  • Implemented configuration file support (#12)
  • -
  • Added settings panel for better user configuration (#11)
  • -
  • Added ktlint and detekt options for new features (#8)
  • -
  • Added app module selector for new features (#6)
  • -
  • Added Gradle check task validation (#5)
  • +
  • Added inspections to the plugin
  • +
  • Added a reference project to README for easier onboarding (#29)
  • +
  • Added Homebrew installation instructions to README (#28)
  • +
  • Added backwards compatibility for Android Studio Meerkat (#25)
  • +
  • Added settings for custom git path (#20)
  • +
  • Added version output to the CLI (#26)
  • +
  • Added git automation to the CLI for staging and commits (#19)
- +

Changed

    -
  • Improved version catalog logic and fixed related bugs
  • -
  • Simplified version catalog implementation (#9)
  • -
  • Moved all requests to a dedicated request package
  • -
  • Updated plugin name and template thumbnail
  • -
  • Improved README documentation
  • +
  • Renamed generated binary from cli to cag (#27)
  • +
  • Migrated UI to use JetBrains UI DSL v2 (#24)
  • +
  • Deduplicated version catalog entries (#23)
  • +
  • Refreshed and updated the README content (#30)
- +

Fixed

    -
  • Fixed multiple generation glitches
  • -
  • Fixed issue where new architecture module option was shown for existing modules (#7)
  • -
  • Various bug fixes in version catalog logic
  • +
  • Removed dexmaker from presentation-test module (#22)
  • +
  • Ensured new project minimum SDK version respects IDE-specified value (#21)
]]>
@@ -50,12 +47,14 @@ +
  • New Clean Architecture project - A template for creating new projects
  • Architecture Package - A Clean Architecture architecture package
  • Features - Complete feature modules with presentation, domain, and data layers
  • Data Sources - Repository implementations and data source classes
  • Use Cases - Domain use cases following Clean Architecture principles
  • ViewModels - Presentation ViewModels following Clean Architecture principles
  • + In addition, provides some inline inspections to help maintain conventions.
    Designed for Android and Kotlin projects following Clean Architecture patterns. ]]>
    @@ -73,6 +72,20 @@ + + + + + + +

    Highlights public function names in ViewModel classes that contain UI-specific terms like "Click", "Scroll", or "Swipe".

    + +

    + This inspection helps ensure ViewModel functions are named in a UI-agnostic way by detecting naming patterns that leak UI layer concerns into the presentation layer. + It encourages replacing UI interaction verbs with more generic alternatives such as Action to promote cleaner architecture and separation of concerns. +

    + +
    
    +class LoginViewModel {
    +    // Correct
    +    fun onLoginAction()
    +
    +    // Incorrect
    +    fun onLoginClicked()
    +}
    +
    + +

    This inspection supports an auto-fix that renames the function by replacing the UI-related term with Action, e.g., onLoginClicked becomes onLoginAction.

    + + + diff --git a/plugin/src/main/resources/inspectionDescriptions/ViewModelPublicFunctionShouldStartWithOn.html b/plugin/src/main/resources/inspectionDescriptions/ViewModelPublicFunctionShouldStartWithOn.html new file mode 100644 index 0000000..f2b8fb1 --- /dev/null +++ b/plugin/src/main/resources/inspectionDescriptions/ViewModelPublicFunctionShouldStartWithOn.html @@ -0,0 +1,23 @@ + + + +

    Detects public functions in ViewModel classes that do not follow the naming convention of starting with on.

    + +

    + This inspection encourages a clear communication pattern between the View (UI) and the ViewModel in MVVM architecture. + By enforcing that public ViewModel methods start with on, such as onLoginAction() or onSearchSubmitted(), + it helps reinforce the principle that the ViewModel should respond to UI events without being tightly coupled to UI logic. +

    + +
    
    +// Correct
    +fun onItemSelected(item: Item)
    +
    +// Incorrect
    +fun selectItem(item: Item)
    +
    + +

    This helps maintain consistency and improves the readability and maintainability of your codebase.

    + + + diff --git a/plugin/src/main/resources/messages/CleanArchitectureGeneratorBundle.properties b/plugin/src/main/resources/messages/CleanArchitectureGeneratorBundle.properties index d456fe6..d9f39d5 100644 --- a/plugin/src/main/resources/messages/CleanArchitectureGeneratorBundle.properties +++ b/plugin/src/main/resources/messages/CleanArchitectureGeneratorBundle.properties @@ -85,3 +85,7 @@ settings.auto.add.to.git.tooltip=When enabled, newly generated files will be add settings.git.path.label=Git executable path (optional) settings.git.path.tooltip=Absolute path to git executable, e.g., /usr/bin/git. Leave empty to use PATH. settings.git.not.found.warning=Git not found. Set a valid path. + +inspection.clean.architecture.group.name=Clean Architecture checks +inspection.viewmodel.function.name.starts.with.on.display.name=Public ViewModel function name starts with 'on' +inspection.viewmodel.function.name.ui.agnostic.display.name=ViewModel function name contains UI terms diff --git a/settings.gradle.kts b/settings.gradle.kts index b1e4963..6a9683f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,3 +11,6 @@ project(":cli") include(":git") project(":git") + +include(":lint") +project(":lint")