diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6533619..0c7cf2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: zulu - java-version: 17 + java-version: 21 # Setup Gradle - name: Setup Gradle diff --git a/.github/workflows/run-ui-tests.yml b/.github/workflows/run-ui-tests.yml index d70ebfb..a9b336c 100644 --- a/.github/workflows/run-ui-tests.yml +++ b/.github/workflows/run-ui-tests.yml @@ -41,7 +41,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: zulu - java-version: 17 + java-version: 21 # Setup Gradle - name: Setup Gradle diff --git a/CHANGELOG.md b/CHANGELOG.md index 93736cb..dfb6db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ # Alpine.js Support -## [Unreleased] +## [0.8.1] - 2026-06-02 + +### Changed +- Improved plugin auto-detection performance: the project is now scanned once for all plugins (instead of once per plugin), candidate files are located via the IDE's text index rather than reading every file, and `node_modules` is excluded from detection + +## [0.8.0] - 2026-06-02 ### Changed - Improved support for [alpine-wizard](https://github.com/glhd/alpine-wizard) @@ -12,7 +17,9 @@ - Added support for [alpine-ajax](https://alpine-ajax.js.org/) - Added basic support for [alpine-tooltip](https://github.com/ryangjchandler/alpine-tooltip) - Added configuration for plugins (enable/disable) when not auto-detected -- Added support for newer IntelliJ platforms +- Added support for PhpStorm / IntelliJ Platform 2026.1.2+ (removed the upper compatibility bound) +- Added quick documentation for Alpine directives +- Added inspection flagging the deprecated `x-spread` directive - Added better local PhpStorm testing - Added better handling of non-HTML file types - Added new plugin extension system @@ -151,7 +158,9 @@ A bug was introduced that impacted Alpine when you did something like `x-data="@ ### Fixed - Removed Alpine icon which seemed to cause issues for some people -[Unreleased]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.6...HEAD +[Unreleased]: https://github.com/inxilpro/IntellijAlpine/compare/v0.8.1...HEAD +[0.8.1]: https://github.com/inxilpro/IntellijAlpine/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.6...v0.8.0 [0.6.6]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.5...v0.6.6 [0.6.5]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.4...v0.6.5 [0.6.4]: https://github.com/inxilpro/IntellijAlpine/compare/v0.6.3...v0.6.4 diff --git a/CLAUDE.md b/CLAUDE.md index 5645fe7..897e1af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,9 +21,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Testing & Verification - `./gradlew test` - Run unit tests - `./gradlew check` - Run all checks (tests + verification) -- `./gradlew verifyPlugin` - Validate plugin structure and descriptors -- `./gradlew runPluginVerifier` - Check binary compatibility with target IDEs -- `./gradlew runInspections` - Run Qodana code inspections +- `./gradlew verifyPluginStructure` - Validate plugin structure and descriptors +- `./gradlew verifyPlugin` - Check binary compatibility with target IDEs (replaces 1.x `runPluginVerifier`) +- `./gradlew qodanaScan` - Run Qodana code inspections (requires Docker) - `./gradlew koverReport` - Generate code coverage reports ## Architecture diff --git a/build.gradle.kts b/build.gradle.kts index 94f58c0..9d582e2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,9 @@ dependencies { providers.gradleProperty("platformBundledPlugins") .map { it.split(',').map(String::trim).filter(String::isNotEmpty) }) + // The JSON plugin was extracted into a separate module (2024.3+); package.json parsing needs it explicitly. + bundledModule("com.intellij.modules.json") + // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file for plugin from JetBrains Marketplace. plugins( providers.gradleProperty("platformPlugins") @@ -110,7 +113,8 @@ intellijPlatform { ideaVersion { sinceBuild = providers.gradleProperty("pluginSinceBuild") - untilBuild = providers.gradleProperty("pluginUntilBuild") + // No upper bound — keep the plugin compatible with future platform releases. + untilBuild = provider { null } } } @@ -131,8 +135,9 @@ intellijPlatform { pluginVerification { ides { - // Only verify against the current version to speed up CI - ide("IU-2025.1") + // Verify against JetBrains' curated set of IDEs across the supported range, + // auto-tracking new platform releases since there's no upper compatibility bound. + recommended() } } } diff --git a/gradle.properties b/gradle.properties index 76f757f..a02a04c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,15 +4,15 @@ pluginGroup = com.github.inxilpro.intellijalpine pluginName = Alpine.js Support pluginRepositoryUrl = https://github.com/inxilpro/IntellijAlpine # SemVer format -> https://semver.org -pluginVersion = 0.7.0 +pluginVersion = 0.8.1 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html +# No upper bound: the plugin stays compatible with current and future platforms (verified via the plugin verifier). pluginSinceBuild = 251 -pluginUntilBuild = 253.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType = IU -platformVersion = 2025.1 +platformVersion = 2026.1 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP @@ -21,7 +21,7 @@ platformPlugins = platformBundledPlugins = JavaScript,HtmlTools # Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion = 8.13 +gradleVersion = 9.0.0 # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib kotlin.stdlib.default.dependency = false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d464b30..8d12a6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,8 +5,8 @@ opentest4j = "1.3.0" # plugins changelog = "2.2.1" -intelliJPlatform = "2.6.0" -kotlin = "2.1.21" +intelliJPlatform = "2.16.0" +kotlin = "2.3.20" kover = "0.9.1" qodana = "2025.1.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..2a84e18 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/attributes/AlpineAttributeDescriptor.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/attributes/AlpineAttributeDescriptor.kt index c37ab8d..b3a43d0 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/attributes/AlpineAttributeDescriptor.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/attributes/AlpineAttributeDescriptor.kt @@ -22,7 +22,8 @@ class AlpineAttributeDescriptor( return info.typeText } - override fun init(psiElement: PsiElement) {} + @Suppress("WRONG_NULLABILITY_FOR_JAVA_OVERRIDE") + override fun init(psiElement: PsiElement?) {} override fun isRequired(): Boolean = false diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePluginRegistry.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePluginRegistry.kt index 6b3286a..e99848b 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePluginRegistry.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePluginRegistry.kt @@ -6,6 +6,8 @@ import com.github.inxilpro.intellijalpine.core.detection.PluginDetector import com.github.inxilpro.intellijalpine.settings.AlpineProjectSettingsState import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.BulkFileListener @@ -14,6 +16,8 @@ import com.intellij.util.messages.MessageBusConnection import org.apache.commons.lang3.tuple.MutablePair import java.util.concurrent.ConcurrentHashMap +private val LOG = logger() + @Service(Service.Level.APP) class AlpinePluginRegistry { private val listeners = ConcurrentHashMap() @@ -72,24 +76,30 @@ class AlpinePluginRegistry { } fun checkAndAutoEnablePlugins(project: Project) { - getRegisteredPlugins().forEach { plugin -> - if (!isPluginEnabled(project, plugin.getPluginName()) && detector.detect(project, plugin)) { - enablePlugin(project, plugin.getPluginName()) - } - } + if (DumbService.isDumb(project)) return + detectAndEnable(project, "for project '${project.name}'") setupPackageJsonListener(project) } + private fun detectAndEnable(project: Project, reason: String) { + val candidates = getRegisteredPlugins().filter { !isPluginEnabled(project, it.getPluginName()) } + if (candidates.isEmpty()) return + + detector.detect(project, candidates).forEach { plugin -> + LOG.info("Auto-enabling Alpine plugin '${plugin.getPluginName()}' $reason") + enablePlugin(project, plugin.getPluginName()) + } + } + private fun setupPackageJsonListener(project: Project) { val projectPath = project.basePath ?: project.name - // Don't set up listener if already exists if (listeners.containsKey(projectPath)) { return } - val connection = project.messageBus.connect() + val connection = project.messageBus.connect(project) listeners[projectPath] = connection connection.subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener { @@ -101,11 +111,9 @@ class AlpinePluginRegistry { if (hasPackageJsonChanges) { ApplicationManager.getApplication().executeOnPooledThread { - ApplicationManager.getApplication().runReadAction { - getRegisteredPlugins().forEach { plugin -> - if (!isPluginEnabled(project, plugin.getPluginName()) && detector.detect(project, plugin)) { - enablePlugin(project, plugin.getPluginName()) - } + DumbService.getInstance(project).runWhenSmart { + ApplicationManager.getApplication().runReadAction { + detectAndEnable(project, "after package.json change") } } } diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/DetectionScopes.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/DetectionScopes.kt new file mode 100644 index 0000000..6f5c5cb --- /dev/null +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/DetectionScopes.kt @@ -0,0 +1,25 @@ +package com.github.inxilpro.intellijalpine.core.detection + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.search.DelegatingGlobalSearchScope +import com.intellij.psi.search.GlobalSearchScope + +internal object DetectionScopes { + fun projectScopeExcludingNodeModules(project: Project): GlobalSearchScope { + return object : DelegatingGlobalSearchScope(GlobalSearchScope.projectScope(project)) { + override fun contains(file: VirtualFile): Boolean { + return super.contains(file) && !isInNodeModules(file) + } + } + } + + private fun isInNodeModules(file: VirtualFile): Boolean { + var current: VirtualFile? = file.parent + while (current != null) { + if (current.name == "node_modules") return true + current = current.parent + } + return false + } +} diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/DetectionStrategy.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/DetectionStrategy.kt index 926263f..8f34f9f 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/DetectionStrategy.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/DetectionStrategy.kt @@ -4,5 +4,5 @@ import com.github.inxilpro.intellijalpine.core.AlpinePlugin import com.intellij.openapi.project.Project interface DetectionStrategy { - fun detect(project: Project, plugin: AlpinePlugin): Boolean -} \ No newline at end of file + fun detect(project: Project, plugins: List): Set +} diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PackageJsonDetector.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PackageJsonDetector.kt index 23dd873..7fa2068 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PackageJsonDetector.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PackageJsonDetector.kt @@ -3,30 +3,39 @@ package com.github.inxilpro.intellijalpine.core.detection import com.github.inxilpro.intellijalpine.core.AlpinePlugin import com.intellij.json.psi.JsonFile import com.intellij.json.psi.JsonObject +import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project import com.intellij.psi.PsiManager import com.intellij.psi.search.FilenameIndex -import com.intellij.psi.search.GlobalSearchScope class PackageJsonDetector : DetectionStrategy { - override fun detect(project: Project, plugin: AlpinePlugin): Boolean { + override fun detect(project: Project, plugins: List): Set { + if (plugins.isEmpty() || DumbService.isDumb(project)) return emptySet() + val packageJsonFiles = FilenameIndex.getVirtualFilesByName( "package.json", - GlobalSearchScope.projectScope(project) + DetectionScopes.projectScopeExcludingNodeModules(project) ) + if (packageJsonFiles.isEmpty()) return emptySet() + + val psiManager = PsiManager.getInstance(project) + val detected = mutableSetOf() - return packageJsonFiles.any { virtualFile -> - val psiFile = PsiManager.getInstance(project).findFile(virtualFile) - if (psiFile is JsonFile) { - val rootObject = psiFile.topLevelValue as? JsonObject - val dependencies = rootObject?.findProperty("dependencies")?.value as? JsonObject - val devDependencies = rootObject?.findProperty("devDependencies")?.value as? JsonObject + for (virtualFile in packageJsonFiles) { + val psiFile = psiManager.findFile(virtualFile) as? JsonFile ?: continue + val rootObject = psiFile.topLevelValue as? JsonObject ?: continue + val dependencies = rootObject.findProperty("dependencies")?.value as? JsonObject + val devDependencies = rootObject.findProperty("devDependencies")?.value as? JsonObject - hasPluginDependency(plugin, dependencies) || hasPluginDependency(plugin, devDependencies) - } else { - false + for (plugin in plugins) { + if (plugin in detected) continue + if (hasPluginDependency(plugin, dependencies) || hasPluginDependency(plugin, devDependencies)) { + detected += plugin + } } } + + return detected } private fun hasPluginDependency(plugin: AlpinePlugin, dependencies: JsonObject?): Boolean { @@ -34,4 +43,4 @@ class PackageJsonDetector : DetectionStrategy { dependencies?.findProperty(packageName) != null } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PluginDetector.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PluginDetector.kt index 5f0bc4f..2f0d884 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PluginDetector.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/PluginDetector.kt @@ -9,7 +9,23 @@ class PluginDetector : DetectionStrategy { ScriptReferenceDetector() ) - override fun detect(project: Project, plugin: AlpinePlugin): Boolean { - return strategies.any { it.detect(project, plugin) } || plugin.performDetection(project) + override fun detect(project: Project, plugins: List): Set { + if (plugins.isEmpty()) return emptySet() + + val detected = mutableSetOf() + + for (strategy in strategies) { + val remaining = plugins.filter { it !in detected } + if (remaining.isEmpty()) break + detected += strategy.detect(project, remaining) + } + + for (plugin in plugins) { + if (plugin !in detected && plugin.performDetection(project)) { + detected += plugin + } + } + + return detected } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/ScriptReferenceDetector.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/ScriptReferenceDetector.kt index bf31184..465f878 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/ScriptReferenceDetector.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/detection/ScriptReferenceDetector.kt @@ -1,61 +1,97 @@ package com.github.inxilpro.intellijalpine.core.detection import com.github.inxilpro.intellijalpine.core.AlpinePlugin +import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile -import com.intellij.psi.search.FilenameIndex -import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.PsiSearchHelper +import com.intellij.psi.search.UsageSearchContext class ScriptReferenceDetector : DetectionStrategy { - override fun detect(project: Project, plugin: AlpinePlugin): Boolean { - return hasScriptTagReferences(project, plugin) || hasImportReferences(project, plugin) + private companion object { + val HTML_EXTENSIONS = setOf("html", "htm", "php", "twig", "djhtml", "jinja", "astro") + val JS_EXTENSIONS = setOf("js", "ts", "mjs") + + // "alpine" appears in nearly every file of an Alpine project, so it does nothing to + // narrow the candidate set. The precise package name is still confirmed per file below. + val GENERIC_TOKENS = setOf("alpine") + + const val MIN_TOKEN_LENGTH = 4 + const val MAX_FILE_SIZE = 1_000_000L + + private val NON_ALPHANUMERIC = Regex("[^a-zA-Z0-9]+") + private val SCRIPT_TAG_REGEX = Regex("]*src=['\"]([^'\"]*)['\"][^>]*>", RegexOption.IGNORE_CASE) } - private fun hasScriptTagReferences(project: Project, plugin: AlpinePlugin): Boolean { - val htmlFiles = mutableListOf() - val extensions = listOf("html", "htm", "php", "twig", "djhtml", "jinja", "astro") + override fun detect(project: Project, plugins: List): Set { + if (plugins.isEmpty() || DumbService.isDumb(project)) return emptySet() - for (extension in extensions) { - htmlFiles.addAll( - FilenameIndex.getAllFilesByExt(project, extension, GlobalSearchScope.projectScope(project)) - ) - } + val tokens = plugins.flatMapTo(mutableSetOf()) { searchTokensFor(it) } + if (tokens.isEmpty()) return emptySet() + + val candidates = findCandidateFiles(project, tokens) + if (candidates.isEmpty()) return emptySet() + + val detected = mutableSetOf() + val remaining = plugins.toMutableList() + + for (file in candidates) { + if (remaining.isEmpty()) break + + val extension = file.extension?.lowercase() + val isHtml = extension in HTML_EXTENSIONS + val isJs = extension in JS_EXTENSIONS + if (!isHtml && !isJs) continue + if (file.length > MAX_FILE_SIZE || file.fileType.isBinary) continue - return htmlFiles.any { virtualFile -> - try { - val content = String(virtualFile.contentsToByteArray()) - hasScriptTagsInContent(content, plugin) + val content = try { + VfsUtilCore.loadText(file) } catch (_: Exception) { - false + continue + } + + val iterator = remaining.iterator() + while (iterator.hasNext()) { + val plugin = iterator.next() + val matched = + if (isHtml) hasScriptTagsInContent(content, plugin) else hasImportStatements(content, plugin) + if (matched) { + detected += plugin + iterator.remove() + } } } + + return detected } - private fun hasImportReferences(project: Project, plugin: AlpinePlugin): Boolean { - val jsExtensions = listOf("js", "ts", "mjs") - val jsFiles = mutableListOf() + private fun findCandidateFiles(project: Project, tokens: Set): Set { + val helper = PsiSearchHelper.getInstance(project) + val scope = DetectionScopes.projectScopeExcludingNodeModules(project) + val candidates = mutableSetOf() - for (extension in jsExtensions) { - jsFiles.addAll( - FilenameIndex.getAllFilesByExt(project, extension, GlobalSearchScope.projectScope(project)) - ) + for (token in tokens) { + helper.processCandidateFilesForText(scope, UsageSearchContext.ANY, false, token) { file -> + candidates.add(file) + true + } } - return jsFiles.any { virtualFile -> - try { - val content = String(virtualFile.contentsToByteArray()) - hasImportStatements(content, plugin) - } catch (_: Exception) { - false - } + return candidates + } + + private fun searchTokensFor(plugin: AlpinePlugin): List { + return plugin.getPackageNamesForDetection().flatMap { packageName -> + val tokens = packageName.split(NON_ALPHANUMERIC).filter { it.isNotEmpty() } + val distinctive = tokens.filter { it.length >= MIN_TOKEN_LENGTH && it.lowercase() !in GENERIC_TOKENS } + distinctive.ifEmpty { listOfNotNull(tokens.maxByOrNull { it.length }) } } } private fun hasScriptTagsInContent(content: String, plugin: AlpinePlugin): Boolean { - val scriptTagRegex = Regex("]*src=['\"]([^'\"]*)['\"][^>]*>", RegexOption.IGNORE_CASE) - return scriptTagRegex.findAll(content).any { match -> - val src = match.groupValues[1] - containsPackageReference(src, plugin) + return SCRIPT_TAG_REGEX.findAll(content).any { match -> + containsPackageReference(match.groupValues[1], plugin) } } @@ -80,4 +116,4 @@ class ScriptReferenceDetector : DetectionStrategy { src.contains(packageName, ignoreCase = true) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/documentation/AlpineDocumentationProvider.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/documentation/AlpineDocumentationProvider.kt new file mode 100644 index 0000000..d7e4b6a --- /dev/null +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/documentation/AlpineDocumentationProvider.kt @@ -0,0 +1,232 @@ +package com.github.inxilpro.intellijalpine.documentation + +import com.intellij.lang.documentation.DocumentationProvider +import com.intellij.psi.PsiElement +import com.intellij.psi.impl.source.xml.XmlTokenImpl +import com.intellij.psi.xml.XmlAttribute +import com.intellij.psi.xml.XmlTokenType + +class AlpineDocumentationProvider : DocumentationProvider { + + private val directiveDocs = mapOf( + "x-data" to DirectiveDoc( + "x-data", + "Declare a new Alpine component and its reactive data.", + "x-data=\"{ open: false }\"", + "Everything inside a tag with x-data becomes an Alpine component. " + + "Any data properties declared will be reactive — when they change, the DOM updates automatically." + ), + "x-init" to DirectiveDoc( + "x-init", + "Run an expression when a component is initialized.", + "x-init=\"date = new Date()\"", + "Runs the given expression once when the component is initialized. " + + "Can also be used to run async code using await." + ), + "x-show" to DirectiveDoc( + "x-show", + "Toggle the visibility of an element.", + "x-show=\"open\"", + "Sets display: none on the element when the expression evaluates to false. " + + "Works with x-transition for animated show/hide." + ), + "x-bind" to DirectiveDoc( + "x-bind", + "Dynamically set HTML attributes on an element.", + "x-bind:class=\"{ active: isActive }\" or :class=\"{ active: isActive }\"", + "Sets the value of an attribute to the result of a JavaScript expression. " + + "Shorthand: :attribute." + ), + "x-on" to DirectiveDoc( + "x-on", + "Listen for browser events on an element.", + "x-on:click=\"open = !open\" or @click=\"open = !open\"", + "Attaches an event listener to the element. Shorthand: @event. " + + "Supports modifiers like .prevent, .stop, .window, etc." + ), + "x-text" to DirectiveDoc( + "x-text", + "Set the text content of an element.", + "x-text=\"message\"", + "Sets the inner text of the element to the result of the expression. " + + "HTML is escaped automatically." + ), + "x-html" to DirectiveDoc( + "x-html", + "Set the inner HTML of an element.", + "x-html=\"content\"", + "Sets the inner HTML of the element. Warning: only use with trusted content " + + "to avoid XSS vulnerabilities." + ), + "x-model" to DirectiveDoc( + "x-model", + "Two-way bind a form input to Alpine data.", + "x-model=\"search\"", + "Keeps the value of an input element in sync with a data property. " + + "Supports modifiers: .lazy, .number, .debounce, .throttle." + ), + "x-modelable" to DirectiveDoc( + "x-modelable", + "Expose a property for external binding via x-model.", + "x-modelable=\"value\"", + "Allows a component property to be bound from outside using x-model." + ), + "x-for" to DirectiveDoc( + "x-for", + "Repeat a block of HTML based on a data set.", + "<template x-for=\"item in items\">", + "Must be used on a <template> tag. Iterates over arrays or ranges." + ), + "x-transition" to DirectiveDoc( + "x-transition", + "Apply CSS transitions to show/hide elements.", + "x-transition or x-transition:enter=\"transition ease-out\"", + "Adds transition classes during enter/leave phases. Can specify classes for each phase: " + + ":enter, :enter-start, :enter-end, " + + ":leave, :leave-start, :leave-end." + ), + "x-effect" to DirectiveDoc( + "x-effect", + "Run a reactive side effect.", + "x-effect=\"console.log(count)\"", + "Re-runs the expression whenever any reactive data it references changes." + ), + "x-ref" to DirectiveDoc( + "x-ref", + "Reference an element directly via \$refs.", + "x-ref=\"input\" then \$refs.input.focus()", + "Creates a named reference to the DOM element accessible via \$refs." + ), + "x-if" to DirectiveDoc( + "x-if", + "Conditionally add/remove a block of HTML.", + "<template x-if=\"open\">", + "Must be used on a <template> tag. Unlike x-show, " + + "this completely adds/removes the element from the DOM." + ), + "x-id" to DirectiveDoc( + "x-id", + "Scope generated IDs with \$id().", + "x-id=\"['text-input']\"", + "Provides a scoping context for IDs generated via the \$id() magic." + ), + "x-cloak" to DirectiveDoc( + "x-cloak", + "Hide the element until Alpine initializes.", + "x-cloak", + "Hides the element until Alpine finishes initializing, preventing flash of unstyled content. " + + "Requires a CSS rule: [x-cloak] { display: none !important; }." + ), + "x-ignore" to DirectiveDoc( + "x-ignore", + "Prevent Alpine from initializing a section of HTML.", + "x-ignore", + "Tells Alpine to skip this element and all its children." + ), + "x-teleport" to DirectiveDoc( + "x-teleport", + "Move an element to another location in the DOM.", + "<template x-teleport=\"body\">", + "Must be used on a <template> tag. Moves the template content " + + "to the target selector." + ), + "x-mask" to DirectiveDoc( + "x-mask", + "Apply an input mask to an element.", + "x-mask=\"(999) 999-9999\"", + "Constrains user input to match the specified pattern. 9 for digits, " + + "a for letters, * for both." + ), + "x-intersect" to DirectiveDoc( + "x-intersect", + "Trigger when element enters or leaves the viewport.", + "x-intersect=\"shown = true\"", + "Uses the Intersection Observer API to detect when an element is visible." + ), + "x-trap" to DirectiveDoc( + "x-trap", + "Trap keyboard focus within an element.", + "x-trap=\"open\"", + "When the expression is truthy, traps focus within the element (useful for modals/dialogs)." + ), + "x-collapse" to DirectiveDoc( + "x-collapse", + "Animate show/hide with a height transition.", + "x-collapse", + "Smoothly animates the element's height when toggling visibility with x-show." + ), + "x-spread" to DirectiveDoc( + "x-spread", + "Deprecated. Use x-bind with an object instead.", + "x-spread=\"directives\"", + "This directive is deprecated in Alpine v3. Use object syntax with x-bind instead." + ), + ) + + override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? { + val attributeName = resolveAttributeName(element, originalElement) ?: return null + return generateDirectiveDoc(attributeName) + } + + override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? { + val attributeName = resolveAttributeName(element, originalElement) ?: return null + val baseName = attributeName.substringBefore('.') + val doc = directiveDocs[baseName] ?: return null + return doc.summary + } + + private fun resolveAttributeName(element: PsiElement?, originalElement: PsiElement?): String? { + val target = element ?: originalElement ?: return null + + if (target is XmlTokenImpl && target.tokenType == XmlTokenType.XML_NAME) { + val attr = target.parent as? XmlAttribute ?: return null + val name = attr.name + if (name.startsWith("x-") || name.startsWith("@") || name.startsWith(":")) { + return name + } + } + + if (target is XmlAttribute) { + val name = target.name + if (name.startsWith("x-") || name.startsWith("@") || name.startsWith(":")) { + return name + } + } + + return null + } + + private fun generateDirectiveDoc(attributeName: String): String? { + val baseName = when { + attributeName.startsWith("@") -> "x-on" + attributeName.startsWith(":") -> "x-bind" + else -> attributeName.substringBefore('.').substringBefore(':').let { base -> + if (directiveDocs.containsKey(attributeName.substringBefore('.'))) { + attributeName.substringBefore('.') + } else { + base + } + } + } + + val doc = directiveDocs[baseName] ?: return null + + return buildString { + append("
")
+            append(doc.name)
+            append("
") + append("
") + append("

${doc.summary}

") + append("

Example: ${doc.example}

") + append("

${doc.details}

") + append("
") + } + } + + private data class DirectiveDoc( + val name: String, + val summary: String, + val example: String, + val details: String, + ) +} diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/inspections/DeprecatedXSpreadInspection.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/inspections/DeprecatedXSpreadInspection.kt new file mode 100644 index 0000000..5525d1b --- /dev/null +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/inspections/DeprecatedXSpreadInspection.kt @@ -0,0 +1,22 @@ +package com.github.inxilpro.intellijalpine.inspections + +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElementVisitor +import com.intellij.psi.XmlElementVisitor +import com.intellij.psi.xml.XmlAttribute + +class DeprecatedXSpreadInspection : LocalInspectionTool() { + override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { + return object : XmlElementVisitor() { + override fun visitXmlAttribute(attribute: XmlAttribute) { + if (attribute.name == "x-spread") { + holder.registerProblem( + attribute.nameElement, + "The 'x-spread' directive is deprecated in Alpine.js v3. Use 'x-bind' with an object instead." + ) + } + } + } + } +} diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineTargetReferenceContributor.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineTargetReferenceContributor.kt index 514eccd..4765269 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineTargetReferenceContributor.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineTargetReferenceContributor.kt @@ -10,6 +10,9 @@ import com.intellij.psi.PsiReferenceBase import com.intellij.psi.PsiReferenceContributor import com.intellij.psi.PsiReferenceProvider import com.intellij.psi.PsiReferenceRegistrar +import com.intellij.psi.util.CachedValueProvider +import com.intellij.psi.util.CachedValuesManager +import com.intellij.psi.util.PsiModificationTracker import com.intellij.psi.util.PsiTreeUtil import com.intellij.psi.xml.XmlAttributeValue import com.intellij.psi.xml.XmlFile @@ -63,15 +66,15 @@ class AlpineIdReference( override fun resolve(): PsiElement? { val xmlFile = element.containingFile as? XmlFile ?: return null - return findElementWithId(xmlFile, idValue) + return getIdMap(xmlFile)[idValue] } override fun getVariants(): Array { val xmlFile = element.containingFile as? XmlFile ?: return emptyArray() - val allIds = collectElementIds(xmlFile) + val idMap = getIdMap(xmlFile) val currentValue = (element as XmlAttributeValue).value val usedIds = currentValue.split("\\s+".toRegex()).filter { it.isNotBlank() }.toSet() - val availableIds = allIds - usedIds + val availableIds = idMap.keys - usedIds return availableIds.map { id -> LookupElementBuilder.create(id) @@ -80,21 +83,22 @@ class AlpineIdReference( }.toTypedArray() } - override fun isSoft(): Boolean = false // Hard reference - should show error if unresolved + override fun isSoft(): Boolean = false - private fun findElementWithId(xmlFile: XmlFile, id: String): PsiElement? { - val allTags = PsiTreeUtil.findChildrenOfType(xmlFile, XmlTag::class.java) + private fun getIdMap(xmlFile: XmlFile): Map { + return CachedValuesManager.getCachedValue(xmlFile) { + val allTags = PsiTreeUtil.findChildrenOfType(xmlFile, XmlTag::class.java) + val map = mutableMapOf() - return allTags.firstOrNull { tag -> - tag.getAttribute("id")?.value == id - }?.getAttribute("id")?.valueElement - } - - private fun collectElementIds(xmlFile: XmlFile): Set { - val allTags = PsiTreeUtil.findChildrenOfType(xmlFile, XmlTag::class.java) + for (tag in allTags) { + val idAttr = tag.getAttribute("id") + val idVal = idAttr?.value + if (!idVal.isNullOrBlank() && idAttr.valueElement != null) { + map[idVal] = idAttr.valueElement!! + } + } - return allTags.mapNotNull { tag -> - tag.getAttribute("id")?.value?.takeIf { it.isNotBlank() } - }.toSet() + CachedValueProvider.Result.create(map as Map, PsiModificationTracker.MODIFICATION_COUNT) + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsComponent.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsComponent.kt deleted file mode 100644 index 8446fed..0000000 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsComponent.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.inxilpro.intellijalpine.settings - -import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry -import com.intellij.openapi.project.Project -import com.intellij.ui.TitledSeparator -import com.intellij.ui.components.JBCheckBox -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.FormBuilder -import com.intellij.util.ui.UIUtil -import javax.swing.JComponent -import javax.swing.JPanel - -class AlpineSettingsComponent(project: Project?) { - val panel: JPanel - - private val myShowGutterIconsStatus = JBCheckBox("Show Alpine gutter icons") - private val pluginCheckBoxes = mutableMapOf() - - val preferredFocusedComponent: JComponent - get() = myShowGutterIconsStatus - - var showGutterIconsStatus: Boolean - get() = myShowGutterIconsStatus.isSelected - set(newStatus) { - myShowGutterIconsStatus.isSelected = newStatus - } - - fun getPluginStatus(pluginName: String): Boolean { - return pluginCheckBoxes[pluginName]?.isSelected ?: false - } - - fun setPluginStatus(pluginName: String, enabled: Boolean) { - pluginCheckBoxes[pluginName]?.isSelected = enabled - } - - init { - val builder = FormBuilder.createFormBuilder() - .addComponent(TitledSeparator("Plugin Settings")) - .addComponent(myShowGutterIconsStatus, 1) - - // Only show project settings if we have a project context - if (project != null) { - builder.addVerticalGap(10) // Add spacing between sections - .addComponent(TitledSeparator("Project Settings for “${project.name}”")) - - val projectLabel = JBLabel("These settings apply only to the current project") - projectLabel.foreground = UIUtil.getContextHelpForeground() - projectLabel.font = UIUtil.getLabelFont(UIUtil.FontSize.SMALL) - builder.addComponent(projectLabel, 1) - .addVerticalGap(5) - - // Dynamically add checkboxes for each registered plugin - AlpinePluginRegistry.instance.getRegisteredPlugins().forEach { plugin -> - val checkBox = JBCheckBox("Enable “${plugin.getPackageDisplayName()}” support for this project") - pluginCheckBoxes[plugin.getPluginName()] = checkBox - builder.addComponent(checkBox, 1) - } - } - - panel = builder.addComponentFillVertically(JPanel(), 0).panel - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsConfigurable.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsConfigurable.kt index c93a64b..81cc8fe 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsConfigurable.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsConfigurable.kt @@ -3,56 +3,61 @@ package com.github.inxilpro.intellijalpine.settings import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry import com.intellij.openapi.options.Configurable import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.dsl.builder.panel import javax.swing.JComponent class AlpineSettingsConfigurable(private val project: Project?) : Configurable { - private var mySettingsComponent: AlpineSettingsComponent? = null + private lateinit var showGutterIcons: JBCheckBox + private val pluginCheckBoxes = mutableMapOf() @Suppress("DialogTitleCapitalization") - override fun getDisplayName(): String { - return "Alpine.js" - } + override fun getDisplayName(): String = "Alpine.js" - override fun getPreferredFocusedComponent(): JComponent? { - return mySettingsComponent?.preferredFocusedComponent - } + override fun createComponent(): JComponent { + showGutterIcons = JBCheckBox("Show Alpine gutter icons") + pluginCheckBoxes.clear() + + return panel { + group("Plugin Settings") { + row { cell(showGutterIcons) } + } - override fun createComponent(): JComponent? { - mySettingsComponent = AlpineSettingsComponent(project) - return mySettingsComponent?.panel + if (project != null) { + group("Project Settings for “${project.name}”") { + AlpinePluginRegistry.instance.getRegisteredPlugins().forEach { plugin -> + row { + val cb = JBCheckBox("Enable “${plugin.getPackageDisplayName()}” support for this project") + pluginCheckBoxes[plugin.getPluginName()] = cb + cell(cb) + } + } + } + } + } } override fun isModified(): Boolean { val appSettings = AlpineSettingsState.instance - var isModified = mySettingsComponent?.showGutterIconsStatus != appSettings.showGutterIcons + if (showGutterIcons.isSelected != appSettings.showGutterIcons) return true - // Check project settings if we have a project if (project != null) { val registry = AlpinePluginRegistry.instance - registry.getRegisteredPlugins().forEach { plugin -> - val pluginName = plugin.getPluginName() - val currentStatus = mySettingsComponent?.getPluginStatus(pluginName) ?: false - val savedStatus = registry.isPluginEnabled(project, pluginName) - if (currentStatus != savedStatus) { - isModified = true - } + for ((pluginName, cb) in pluginCheckBoxes) { + if (cb.isSelected != registry.isPluginEnabled(project, pluginName)) return true } } - return isModified + return false } override fun apply() { - val appSettings = AlpineSettingsState.instance - appSettings.showGutterIcons = mySettingsComponent?.showGutterIconsStatus != false + AlpineSettingsState.instance.showGutterIcons = showGutterIcons.isSelected - // Apply project settings if we have a project if (project != null) { val registry = AlpinePluginRegistry.instance - registry.getRegisteredPlugins().forEach { plugin -> - val pluginName = plugin.getPluginName() - val enabled = mySettingsComponent?.getPluginStatus(pluginName) ?: false - if (enabled) { + for ((pluginName, cb) in pluginCheckBoxes) { + if (cb.isSelected) { registry.enablePlugin(project, pluginName) } else { registry.disablePlugin(project, pluginName) @@ -62,21 +67,13 @@ class AlpineSettingsConfigurable(private val project: Project?) : Configurable { } override fun reset() { - val appSettings = AlpineSettingsState.instance - mySettingsComponent?.showGutterIconsStatus = appSettings.showGutterIcons + showGutterIcons.isSelected = AlpineSettingsState.instance.showGutterIcons - // Reset project settings if we have a project if (project != null) { val registry = AlpinePluginRegistry.instance - registry.getRegisteredPlugins().forEach { plugin -> - val pluginName = plugin.getPluginName() - val enabled = registry.isPluginEnabled(project, pluginName) - mySettingsComponent?.setPluginStatus(pluginName, enabled) + for ((pluginName, cb) in pluginCheckBoxes) { + cb.isSelected = registry.isPluginEnabled(project, pluginName) } } } - - override fun disposeUIResources() { - mySettingsComponent = null - } -} \ No newline at end of file +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 54c7f5f..f2014b5 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -13,10 +13,11 @@ com.intellij.modules.platform com.intellij.modules.lang com.intellij.modules.xml + com.intellij.modules.json JavaScript - + @@ -41,6 +42,14 @@ id="com.github.inxilpro.intellijalpine.settings.AlpineSettingsConfigurable" displayName="Alpine.js"/> + +