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/build.gradle.kts b/build.gradle.kts index 94f58c0..9839a3e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,28 +36,8 @@ dependencies { // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { - var platformType = providers.gradleProperty("platformType").get() - var platformVersion = providers.gradleProperty("platformVersion").get() - - // Auto-detect local PhpStorm installation if possible - val localPhpStorm = file("${System.getProperty("user.home")}/Applications/PhpStorm.app/Contents") - if (localPhpStorm.exists()) { - val infoPlist = file("${localPhpStorm}/Info.plist") - if (infoPlist.exists()) { - try { - val plistContent = infoPlist.readText() - val versionMatch = Regex("CFBundleShortVersionString\\s*([^<]+)").find(plistContent) - if (versionMatch != null) { - val detectedVersion = versionMatch.groupValues[1] - // Convert version like "2025.1.3" to "2025.1" - platformVersion = detectedVersion.split(".").take(2).joinToString(".") - platformType = "PS" - } - } catch (e: Exception) { - println("Unable to parse PhpStorm version number: ${e.message} (will not use local IDE)") - } - } - } + val platformType = providers.gradleProperty("platformType").get() + val platformVersion = providers.gradleProperty("platformVersion").get() println("Using $platformType $platformVersion for testing") create(platformType, platformVersion) @@ -131,8 +111,7 @@ intellijPlatform { pluginVerification { ides { - // Only verify against the current version to speed up CI - ide("IU-2025.1") + recommended() } } } diff --git a/gradle.properties b/gradle.properties index 76f757f..78c4a32 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,3 +34,6 @@ org.gradle.caching = true # Enable Gradle Kotlin DSL Lazy Property Assignment -> https://docs.gradle.org/current/userguide/kotlin_dsl.html#kotdsl:assignment systemProp.org.gradle.unsafe.kotlin.assignment = true + +# Increase heap for building against large local IDE installations +org.gradle.jvmargs = -Xmx2g diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d464b30..7244f32 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ opentest4j = "1.3.0" # plugins changelog = "2.2.1" -intelliJPlatform = "2.6.0" +intelliJPlatform = "2.11.0" kotlin = "2.1.21" kover = "0.9.1" qodana = "2025.1.1" 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/AlpinePlugin.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePlugin.kt index dc42ced..fcdb88a 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePlugin.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePlugin.kt @@ -4,7 +4,6 @@ import com.github.inxilpro.intellijalpine.attributes.AttributeInfo import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.project.Project -import org.apache.commons.lang3.tuple.MutablePair interface AlpinePlugin { companion object { @@ -20,7 +19,7 @@ interface AlpinePlugin { fun getTypeText(info: AttributeInfo): String? = null - fun injectJsContext(context: MutablePair): MutablePair = context + fun injectJsContext(context: JsContext): JsContext = context fun directiveSupportJavaScript(directive: String): Boolean = true 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..8658388 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePluginRegistry.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/AlpinePluginRegistry.kt @@ -6,14 +6,17 @@ 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 import com.intellij.openapi.vfs.newvfs.events.VFileEvent 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() @@ -61,7 +64,7 @@ class AlpinePluginRegistry { return getRegisteredPlugins().firstNotNullOfOrNull { it.getTypeText(info) } } - fun injectAllJsContext(project: Project, context: MutablePair): MutablePair { + fun injectAllJsContext(project: Project, context: JsContext): JsContext { return getEnabledPlugins(project).fold(context) { acc, plugin -> plugin.injectJsContext(acc) } @@ -72,8 +75,11 @@ class AlpinePluginRegistry { } fun checkAndAutoEnablePlugins(project: Project) { + if (DumbService.isDumb(project)) return + getRegisteredPlugins().forEach { plugin -> if (!isPluginEnabled(project, plugin.getPluginName()) && detector.detect(project, plugin)) { + LOG.info("Auto-enabling Alpine plugin '${plugin.getPluginName()}' for project '${project.name}'") enablePlugin(project, plugin.getPluginName()) } } @@ -84,12 +90,11 @@ class AlpinePluginRegistry { 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,10 +106,13 @@ 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 { + getRegisteredPlugins().forEach { plugin -> + if (!isPluginEnabled(project, plugin.getPluginName()) && detector.detect(project, plugin)) { + LOG.info("Auto-enabling Alpine plugin '${plugin.getPluginName()}' after package.json change") + enablePlugin(project, plugin.getPluginName()) + } } } } diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/core/JsContext.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/JsContext.kt new file mode 100644 index 0000000..aa9136f --- /dev/null +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/core/JsContext.kt @@ -0,0 +1,3 @@ +package com.github.inxilpro.intellijalpine.core + +data class JsContext(var prefix: String, var suffix: String) 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..916570e 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,6 +3,7 @@ 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 @@ -10,6 +11,7 @@ import com.intellij.psi.search.GlobalSearchScope class PackageJsonDetector : DetectionStrategy { override fun detect(project: Project, plugin: AlpinePlugin): Boolean { + if (DumbService.isDumb(project)) return false val packageJsonFiles = FilenameIndex.getVirtualFilesByName( "package.json", GlobalSearchScope.projectScope(project) 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..fa42695 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,6 +1,7 @@ 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.VirtualFile import com.intellij.psi.search.FilenameIndex @@ -8,6 +9,7 @@ import com.intellij.psi.search.GlobalSearchScope class ScriptReferenceDetector : DetectionStrategy { override fun detect(project: Project, plugin: AlpinePlugin): Boolean { + if (DumbService.isDumb(project)) return false return hasScriptTagReferences(project, plugin) || hasImportReferences(project, plugin) } 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/injection/AlpineJavaScriptAttributeValueInjector.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/injection/AlpineJavaScriptAttributeValueInjector.kt index 1018878..114bae3 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/injection/AlpineJavaScriptAttributeValueInjector.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/injection/AlpineJavaScriptAttributeValueInjector.kt @@ -2,6 +2,7 @@ package com.github.inxilpro.intellijalpine.injection import com.github.inxilpro.intellijalpine.attributes.AttributeUtil import com.github.inxilpro.intellijalpine.core.AlpinePluginRegistry +import com.github.inxilpro.intellijalpine.core.JsContext import com.github.inxilpro.intellijalpine.support.LanguageUtil import com.intellij.lang.Language import com.intellij.lang.injection.MultiHostInjector @@ -15,7 +16,6 @@ import com.intellij.psi.util.PsiTreeUtil import com.intellij.psi.xml.XmlAttribute import com.intellij.psi.xml.XmlAttributeValue import com.intellij.psi.xml.XmlTag -import org.apache.commons.lang3.tuple.MutablePair import org.apache.html.dom.HTMLDocumentImpl class AlpineJavaScriptAttributeValueInjector : MultiHostInjector { @@ -148,41 +148,41 @@ class AlpineJavaScriptAttributeValueInjector : MultiHostInjector { } private fun getPrefixAndSuffix(directive: String, host: XmlAttributeValue): Pair { - val globalContext = MutablePair(globalMagics, "") + val globalContext = JsContext(globalMagics, "") val context = AlpinePluginRegistry.instance.injectAllJsContext(host.project, globalContext) if ("x-data" != directive) { - context.left = addTypingToCoreMagics(host) + context.left + context.prefix = addTypingToCoreMagics(host) + context.prefix } if ("x-spread" == directive) { - context.right += "()" + context.suffix += "()" } if (AttributeUtil.isEvent(directive)) { - context.left += addTypingToEventMagics(directive, host) + context.prefix += addTypingToEventMagics(directive, host) } else if ("x-for" == directive) { - context.left += "for (let " - context.right += ") {}" + context.prefix += "for (let " + context.suffix += ") {}" } else if ("x-ref" == directive) { - context.left += "\$refs." - context.right += "= \$el" + context.prefix += "\$refs." + context.suffix += "= \$el" } else if ("x-teleport" == directive) { - context.left += "{ /** @var {HTMLElement} teleport */let teleport = " - context.right += " }" + context.prefix += "{ /** @var {HTMLElement} teleport */let teleport = " + context.suffix += " }" } else if ("x-init" == directive) { // We want x-init to skip the directive wrapping } else { - context.left += "__ALPINE_DIRECTIVE(\n" - context.right += "\n)" + context.prefix += "__ALPINE_DIRECTIVE(\n" + context.suffix += "\n)" } addWithData(host, directive, context) - return context.toPair() + return Pair(context.prefix, context.suffix) } - private fun addWithData(host: XmlAttributeValue, directive: String, context: MutablePair) { + private fun addWithData(host: XmlAttributeValue, directive: String, context: JsContext) { val dataParent: HtmlTag? if ("x-data" == directive) { @@ -200,8 +200,8 @@ class AlpineJavaScriptAttributeValueInjector : MultiHostInjector { val data = dataParent.getAttribute("x-data")?.value if (null != data) { val (prefix, suffix) = context - context.left = "$globalState\nlet ${'$'}data = $data;\nwith (${'$'}data) {\n\n$prefix" - context.right = "$suffix\n\n}" + context.prefix = "$globalState\nlet ${'$'}data = $data;\nwith (${'$'}data) {\n\n$prefix" + context.suffix = "$suffix\n\n}" } } } 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..47673d9 --- /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, + "x-spread is deprecated in Alpine.js v3. Use x-bind with an object instead." + ) + } + } + } + } +} diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineAjaxPlugin.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineAjaxPlugin.kt index 062d454..f2f9ae9 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineAjaxPlugin.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineAjaxPlugin.kt @@ -4,9 +4,9 @@ import com.github.inxilpro.intellijalpine.attributes.AttributeInfo import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions import com.github.inxilpro.intellijalpine.core.AlpinePlugin import com.github.inxilpro.intellijalpine.core.CompletionProviderRegistration +import com.github.inxilpro.intellijalpine.core.JsContext import com.intellij.patterns.XmlPatterns import com.intellij.psi.xml.XmlTokenType -import org.apache.commons.lang3.tuple.MutablePair class AlpineAjaxPlugin : AlpinePlugin { @@ -59,7 +59,7 @@ class AlpineAjaxPlugin : AlpinePlugin { } } - override fun injectJsContext(context: MutablePair): MutablePair { + override fun injectJsContext(context: JsContext): JsContext { val magics = """ /** * @param {string} action @@ -67,10 +67,10 @@ class AlpineAjaxPlugin : AlpinePlugin { * @return {Promise} */ function ${'$'}ajax(action, options = {}) {} - + """.trimIndent() - return MutablePair(context.left + magics, context.right) + return JsContext(context.prefix + magics, context.suffix) } override fun directiveSupportJavaScript(directive: String): Boolean { 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..dea2dd3 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 @@ -44,7 +47,6 @@ class AlpineTargetReferenceProvider : PsiReferenceProvider() { for (id in ids) { val startIndex = value.indexOf(id, searchStart) if (startIndex >= 0) { - // Range relative to the attribute value element (includes quotes) val range = TextRange(startIndex + 1, startIndex + id.length + 1) references.add(AlpineIdReference(attributeValue, range, id)) searchStart = startIndex + id.length @@ -63,15 +65,16 @@ class AlpineIdReference( override fun resolve(): PsiElement? { val xmlFile = element.containingFile as? XmlFile ?: return null - return findElementWithId(xmlFile, idValue) + val idMap = getIdMap(xmlFile) + return idMap[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/plugins/AlpineWizardPlugin.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineWizardPlugin.kt index 06d4b8e..6e765d1 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineWizardPlugin.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/AlpineWizardPlugin.kt @@ -3,7 +3,7 @@ package com.github.inxilpro.intellijalpine.plugins import com.github.inxilpro.intellijalpine.attributes.AttributeInfo import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions import com.github.inxilpro.intellijalpine.core.AlpinePlugin -import org.apache.commons.lang3.tuple.MutablePair +import com.github.inxilpro.intellijalpine.core.JsContext class AlpineWizardPlugin : AlpinePlugin { @@ -34,7 +34,7 @@ class AlpineWizardPlugin : AlpinePlugin { } } - override fun injectJsContext(context: MutablePair): MutablePair { + override fun injectJsContext(context: JsContext): JsContext { val wizardMagics = """ class AlpineWizardStep { /** @type {HTMLElement} */ el; @@ -78,7 +78,7 @@ class AlpineWizardPlugin : AlpinePlugin { """.trimIndent() - return MutablePair(context.left + wizardMagics, context.right) + return JsContext(context.prefix + wizardMagics, context.suffix) } override fun injectAutoCompleteSuggestions(suggestions: AutoCompleteSuggestions) { diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/TooltipPlugin.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/TooltipPlugin.kt index 5cce162..a51c27f 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/TooltipPlugin.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/plugins/TooltipPlugin.kt @@ -3,7 +3,7 @@ package com.github.inxilpro.intellijalpine.plugins import com.github.inxilpro.intellijalpine.attributes.AttributeInfo import com.github.inxilpro.intellijalpine.completion.AutoCompleteSuggestions import com.github.inxilpro.intellijalpine.core.AlpinePlugin -import org.apache.commons.lang3.tuple.MutablePair +import com.github.inxilpro.intellijalpine.core.JsContext class TooltipPlugin : AlpinePlugin { @@ -44,7 +44,7 @@ class TooltipPlugin : AlpinePlugin { } } - override fun injectJsContext(context: MutablePair): MutablePair { + override fun injectJsContext(context: JsContext): JsContext { val magics = """ /** * @param {string} value @@ -52,10 +52,10 @@ class TooltipPlugin : AlpinePlugin { * @return {Promise} */ function ${'$'}tooltip(value, options = {}) {} - + """.trimIndent() - return MutablePair(context.left + magics, context.right) + return JsContext(context.prefix + magics, context.suffix) } override fun getDirectives(): List = listOf( diff --git a/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineProjectSettingsState.kt b/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineProjectSettingsState.kt index ac3331d..db7bfa3 100644 --- a/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineProjectSettingsState.kt +++ b/src/main/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineProjectSettingsState.kt @@ -1,11 +1,13 @@ package com.github.inxilpro.intellijalpine.settings import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.project.Project import com.intellij.util.xmlb.XmlSerializerUtil +@Service(Service.Level.PROJECT) @State( name = "com.github.inxilpro.intellijalpine.AlpineProjectSettingsState", storages = [Storage("alpinejs-support.xml")] 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..e5f2e6b 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 \u201c${project.name}\u201d") { + AlpinePluginRegistry.instance.getRegisteredPlugins().forEach { plugin -> + row { + val cb = JBCheckBox("Enable \u201c${plugin.getPackageDisplayName()}\u201d 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..d4155af 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -16,7 +16,7 @@ JavaScript - + @@ -41,6 +41,14 @@ id="com.github.inxilpro.intellijalpine.settings.AlpineSettingsConfigurable" displayName="Alpine.js"/> + + diff --git a/src/test/kotlin/com/github/inxilpro/intellijalpine/completion/AlpineCompletionContributorTest.kt b/src/test/kotlin/com/github/inxilpro/intellijalpine/completion/AlpineCompletionContributorTest.kt new file mode 100644 index 0000000..125452c --- /dev/null +++ b/src/test/kotlin/com/github/inxilpro/intellijalpine/completion/AlpineCompletionContributorTest.kt @@ -0,0 +1,100 @@ +package com.github.inxilpro.intellijalpine.completion + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class AlpineCompletionContributorTest : BasePlatformTestCase() { + + override fun getTestDataPath(): String = "src/test/testData/completion" + + fun testAlpineDirectiveSuggestionsInHtml() { + myFixture.configureByText( + "test.html", + """
>
""" + ) + val lookups = myFixture.completeBasic() + assertNotNull("Completion should return results", lookups) + val lookupStrings = lookups.map { it.lookupString } + + assertContainsElements(lookupStrings, "x-data", "x-show", "x-model", "x-text", "x-html") + assertContainsElements(lookupStrings, "x-init", "x-ref", "x-cloak", "x-effect") + assertContainsElements(lookupStrings, "x-bind", "x-on", "x-transition") + } + + fun testEventShorthandSuggestions() { + myFixture.configureByText( + "test.html", + """
""" + ) + val lookups = myFixture.completeBasic() + assertNotNull("Completion should return results", lookups) + val lookupStrings = lookups.map { it.lookupString } + + assertTrue( + "Should contain click-related event suggestions", + lookupStrings.any { it.contains("click") } + ) + } + + fun testBindShorthandSuggestions() { + myFixture.configureByText( + "test.html", + """
>
""" + ) + val lookups = myFixture.completeBasic() + assertNotNull("Completion should return results", lookups) + val lookupStrings = lookups.map { it.lookupString } + + assertTrue( + "Should contain class binding suggestion", + lookupStrings.any { it.contains("class") } + ) + } + + fun testModelModifierSuggestions() { + myFixture.configureByText( + "test.html", + """
>
""" + ) + val lookups = myFixture.completeBasic() + assertNotNull("Completion should return results", lookups) + val lookupStrings = lookups.map { it.lookupString } + + assertContainsElements(lookupStrings, "x-model.lazy", "x-model.number") + } + + fun testTemplateDirectivesOnlyOnTemplate() { + myFixture.configureByText( + "test.html", + """
>
""" + ) + val lookups = myFixture.completeBasic() + assertNotNull("Completion should return results", lookups) + val lookupStrings = lookups.map { it.lookupString } + + assertDoesntContain(lookupStrings, "x-if", "x-for", "x-teleport") + } + + fun testTemplateDirectivesOnTemplateTag() { + myFixture.configureByText( + "test.html", + """""" + ) + val lookups = myFixture.completeBasic() + assertNotNull("Completion should return results", lookups) + val lookupStrings = lookups.map { it.lookupString } + + assertContainsElements(lookupStrings, "x-if", "x-for", "x-teleport") + } + + fun testNoAlpineDirectivesForNonAlpinePrefix() { + myFixture.configureByText( + "test.html", + """
>
""" + ) + val lookups = myFixture.completeBasic() + if (lookups != null) { + val lookupStrings = lookups.map { it.lookupString } + assertDoesntContain(lookupStrings, "x-show", "x-model", "x-text") + } + } +} diff --git a/src/test/kotlin/com/github/inxilpro/intellijalpine/core/detection/PackageJsonDetectorTest.kt b/src/test/kotlin/com/github/inxilpro/intellijalpine/core/detection/PackageJsonDetectorTest.kt new file mode 100644 index 0000000..ca383af --- /dev/null +++ b/src/test/kotlin/com/github/inxilpro/intellijalpine/core/detection/PackageJsonDetectorTest.kt @@ -0,0 +1,56 @@ +package com.github.inxilpro.intellijalpine.core.detection + +import com.github.inxilpro.intellijalpine.plugins.AlpineAjaxPlugin +import com.github.inxilpro.intellijalpine.plugins.AlpineWizardPlugin +import com.github.inxilpro.intellijalpine.plugins.TooltipPlugin +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.psi.PsiDocumentManager +import com.intellij.testFramework.IndexingTestUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class PackageJsonDetectorTest : BasePlatformTestCase() { + private val detector = PackageJsonDetector() + + private fun createPackageJson(content: String) { + runWriteAction { + val sourceRoot = myFixture.tempDirFixture.getFile("")!! + val file = sourceRoot.findChild("package.json")?.also { + VfsUtil.saveText(it, content) + } ?: sourceRoot.createChildData(this, "package.json").also { + VfsUtil.saveText(it, content) + } + file + } + PsiDocumentManager.getInstance(project).commitAllDocuments() + IndexingTestUtil.waitUntilIndexesAreReady(project) + } + + fun testDetectsAlpineAjaxInDependencies() { + createPackageJson("""{"name":"test","dependencies":{"alpine-ajax":"^0.8.0"}}""") + assertTrue(detector.detect(project, AlpineAjaxPlugin())) + } + + fun testDetectsAlpineWizardInDevDependencies() { + createPackageJson("""{"name":"test","devDependencies":{"@glhd/alpine-wizard":"^1.0.0"}}""") + assertTrue(detector.detect(project, AlpineWizardPlugin())) + } + + fun testDetectsAlpineTooltipInDependencies() { + createPackageJson("""{"name":"test","dependencies":{"@ryangjchandler/alpine-tooltip":"^2.0.0"}}""") + assertTrue(detector.detect(project, TooltipPlugin())) + } + + fun testDoesNotDetectMissingPlugin() { + createPackageJson("""{"name":"test","dependencies":{"react":"^18.0.0"}}""") + assertFalse(detector.detect(project, AlpineAjaxPlugin())) + assertFalse(detector.detect(project, AlpineWizardPlugin())) + assertFalse(detector.detect(project, TooltipPlugin())) + } + + fun testDoesNotDetectWrongPlugin() { + createPackageJson("""{"name":"test","dependencies":{"alpine-ajax":"^0.8.0"}}""") + assertFalse(detector.detect(project, AlpineWizardPlugin())) + assertFalse(detector.detect(project, TooltipPlugin())) + } +} diff --git a/src/test/kotlin/com/github/inxilpro/intellijalpine/core/detection/ScriptReferenceDetectorTest.kt b/src/test/kotlin/com/github/inxilpro/intellijalpine/core/detection/ScriptReferenceDetectorTest.kt new file mode 100644 index 0000000..546cb6f --- /dev/null +++ b/src/test/kotlin/com/github/inxilpro/intellijalpine/core/detection/ScriptReferenceDetectorTest.kt @@ -0,0 +1,50 @@ +package com.github.inxilpro.intellijalpine.core.detection + +import com.github.inxilpro.intellijalpine.plugins.AlpineAjaxPlugin +import com.github.inxilpro.intellijalpine.plugins.AlpineWizardPlugin +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class ScriptReferenceDetectorTest : BasePlatformTestCase() { + private val detector = ScriptReferenceDetector() + + override fun getTestDataPath(): String = "src/test/testData/detection" + + fun testDetectsAlpineAjaxScriptTag() { + myFixture.copyFileToProject("html_with_alpine_script.html", "index.html") + assertTrue(detector.detect(project, AlpineAjaxPlugin())) + } + + fun testDoesNotDetectMissingScriptReference() { + myFixture.copyFileToProject("html_without_alpine.html", "index.html") + assertFalse(detector.detect(project, AlpineAjaxPlugin())) + } + + fun testDoesNotDetectWrongPluginFromScriptTag() { + myFixture.copyFileToProject("html_with_alpine_script.html", "index.html") + assertFalse(detector.detect(project, AlpineWizardPlugin())) + } + + fun testDetectsImportStatements() { + myFixture.addFileToProject( + "app.js", + "import Ajax from 'alpine-ajax';" + ) + assertTrue(detector.detect(project, AlpineAjaxPlugin())) + } + + fun testDetectsRequireStatements() { + myFixture.addFileToProject( + "app.js", + "const wizard = require('@glhd/alpine-wizard');" + ) + assertTrue(detector.detect(project, AlpineWizardPlugin())) + } + + fun testDoesNotDetectUnrelatedImports() { + myFixture.addFileToProject( + "app.js", + "import React from 'react';" + ) + assertFalse(detector.detect(project, AlpineAjaxPlugin())) + } +} diff --git a/src/test/kotlin/com/github/inxilpro/intellijalpine/documentation/AlpineDocumentationProviderTest.kt b/src/test/kotlin/com/github/inxilpro/intellijalpine/documentation/AlpineDocumentationProviderTest.kt new file mode 100644 index 0000000..dfb107f --- /dev/null +++ b/src/test/kotlin/com/github/inxilpro/intellijalpine/documentation/AlpineDocumentationProviderTest.kt @@ -0,0 +1,99 @@ +package com.github.inxilpro.intellijalpine.documentation + +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.xml.XmlAttribute +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class AlpineDocumentationProviderTest : BasePlatformTestCase() { + private val provider = AlpineDocumentationProvider() + + fun testDocumentationForXData() { + val psiFile = myFixture.configureByText( + "test.html", + """
ta="{ open: false }">
""" + ) + val element = psiFile.findElementAt(myFixture.caretOffset) + val doc = provider.generateDoc(element, element) + assertNotNull("Should generate documentation for x-data", doc) + assertTrue("Doc should mention component scope", doc!!.contains("component")) + } + + fun testDocumentationForXShow() { + val psiFile = myFixture.configureByText( + "test.html", + """
ow="open">
""" + ) + val element = psiFile.findElementAt(myFixture.caretOffset) + val doc = provider.generateDoc(element, element) + assertNotNull("Should generate documentation for x-show", doc) + assertTrue("Doc should mention visibility", doc!!.contains("visibility")) + } + + fun testDocumentationForEventShorthand() { + val psiFile = myFixture.configureByText( + "test.html", + """
""" + ) + val element = psiFile.findElementAt(myFixture.caretOffset) + val doc = provider.generateDoc(element, element) + assertNotNull("Should generate documentation for @click (maps to x-on)", doc) + assertTrue("Doc should describe event listener", doc!!.contains("event")) + } + + fun testDocumentationForBindShorthand() { + val psiFile = myFixture.configureByText( + "test.html", + """
ass="{ active: true }">
""" + ) + val element = psiFile.findElementAt(myFixture.caretOffset) + val doc = provider.generateDoc(element, element) + assertNotNull("Should generate documentation for :class (maps to x-bind)", doc) + assertTrue("Doc should describe attribute binding", doc!!.contains("attribute")) + } + + fun testNoDocumentationForNonAlpineAttribute() { + val psiFile = myFixture.configureByText( + "test.html", + """
ass="test">
""" + ) + val element = psiFile.findElementAt(myFixture.caretOffset) + val doc = provider.generateDoc(element, element) + assertNull("Should not generate documentation for non-Alpine attribute", doc) + } + + fun testQuickNavigateInfoForXModel() { + val psiFile = myFixture.configureByText( + "test.html", + """
del="name">
""" + ) + val element = psiFile.findElementAt(myFixture.caretOffset) + val info = provider.getQuickNavigateInfo(element, element) + assertNotNull("Should return quick navigate info for x-model", info) + assertTrue("Info should mention two-way binding", info!!.contains("Two-way")) + } + + fun testDocumentationForDeprecatedXSpread() { + val psiFile = myFixture.configureByText( + "test.html", + """
ead="directives">
""" + ) + val element = psiFile.findElementAt(myFixture.caretOffset) + val doc = provider.generateDoc(element, element) + assertNotNull("Should generate documentation for x-spread", doc) + assertTrue("Doc should mention deprecated", doc!!.contains("Deprecated") || doc.contains("deprecated")) + } + + fun testDocumentationForXTransition() { + val psiFile = myFixture.configureByText( + "test.html", + """
sition>
""" + ) + val attributes = PsiTreeUtil.findChildrenOfType(psiFile, XmlAttribute::class.java) + val xTransition = attributes.find { it.name == "x-transition" } + assertNotNull("Should find x-transition attribute", xTransition) + + val doc = provider.generateDoc(xTransition!!.nameElement, xTransition.nameElement) + assertNotNull("Should generate documentation for x-transition", doc) + assertTrue("Doc should mention transition", doc!!.contains("transition")) + } +} diff --git a/src/test/kotlin/com/github/inxilpro/intellijalpine/injection/AlpineJavaScriptInjectionTest.kt b/src/test/kotlin/com/github/inxilpro/intellijalpine/injection/AlpineJavaScriptInjectionTest.kt new file mode 100644 index 0000000..21df8c8 --- /dev/null +++ b/src/test/kotlin/com/github/inxilpro/intellijalpine/injection/AlpineJavaScriptInjectionTest.kt @@ -0,0 +1,81 @@ +package com.github.inxilpro.intellijalpine.injection + +import com.intellij.lang.injection.InjectedLanguageManager +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.xml.XmlAttribute +import com.intellij.psi.xml.XmlAttributeValue +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class AlpineJavaScriptInjectionTest : BasePlatformTestCase() { + + override fun getTestDataPath(): String = "src/test/testData/injection" + + private fun assertInjectedInAttribute(html: String, attributeName: String) { + val psiFile = myFixture.configureByText("test.html", html) + val injectedManager = InjectedLanguageManager.getInstance(project) + + val attributes = PsiTreeUtil.findChildrenOfType(psiFile, XmlAttribute::class.java) + val attribute = attributes.find { it.name == attributeName } + assertNotNull("Should find $attributeName attribute", attribute) + + val attrValue = attribute!!.valueElement + assertNotNull("Attribute should have a value element", attrValue) + + val injectedPsi = injectedManager.getInjectedPsiFiles(attrValue!!) + assertNotNull("JavaScript should be injected into $attributeName", injectedPsi) + assertFalse("Should have injected content", injectedPsi!!.isEmpty()) + } + + fun testJavaScriptInjectedInXShow() { + assertInjectedInAttribute( + """
""", + "x-show" + ) + } + + fun testJavaScriptInjectedInXText() { + assertInjectedInAttribute( + """
""", + "x-text" + ) + } + + fun testJavaScriptInjectedInEventHandler() { + assertInjectedInAttribute( + """
""", + "@click" + ) + } + + fun testJavaScriptInjectedInXData() { + assertInjectedInAttribute( + """
""", + "x-data" + ) + } + + fun testXDataScopeWrapping() { + val psiFile = myFixture.configureByText( + "test.html", + """
""" + ) + val injectedManager = InjectedLanguageManager.getInstance(project) + + val attributes = PsiTreeUtil.findChildrenOfType(psiFile, XmlAttribute::class.java) + val xTextAttr = attributes.find { it.name == "x-text" } + assertNotNull("Should find x-text attribute", xTextAttr) + + val attrValue = xTextAttr!!.valueElement + assertNotNull("Attribute should have a value element", attrValue) + + val injectedPsi = injectedManager.getInjectedPsiFiles(attrValue!!) + assertNotNull("JavaScript should be injected", injectedPsi) + assertFalse("Should have injected content", injectedPsi!!.isEmpty()) + + val injectedText = injectedPsi[0].first.text + assertTrue( + "Injected JS should contain x-data scope wrapping with \$data", + injectedText.contains("\$data") + ) + } +} diff --git a/src/test/kotlin/com/github/inxilpro/intellijalpine/inspections/DeprecatedXSpreadInspectionTest.kt b/src/test/kotlin/com/github/inxilpro/intellijalpine/inspections/DeprecatedXSpreadInspectionTest.kt new file mode 100644 index 0000000..9661c63 --- /dev/null +++ b/src/test/kotlin/com/github/inxilpro/intellijalpine/inspections/DeprecatedXSpreadInspectionTest.kt @@ -0,0 +1,35 @@ +package com.github.inxilpro.intellijalpine.inspections + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class DeprecatedXSpreadInspectionTest : BasePlatformTestCase() { + + override fun setUp() { + super.setUp() + myFixture.enableInspections(DeprecatedXSpreadInspection::class.java) + } + + fun testXSpreadWarning() { + myFixture.configureByText( + "test.html", + """
x-spread="directives">
""" + ) + myFixture.checkHighlighting() + } + + fun testNoWarningForXBind() { + myFixture.configureByText( + "test.html", + """
""" + ) + myFixture.checkHighlighting() + } + + fun testNoWarningForOtherDirectives() { + myFixture.configureByText( + "test.html", + """
""" + ) + myFixture.checkHighlighting() + } +} diff --git a/src/test/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsTest.kt b/src/test/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsTest.kt new file mode 100644 index 0000000..0cebb36 --- /dev/null +++ b/src/test/kotlin/com/github/inxilpro/intellijalpine/settings/AlpineSettingsTest.kt @@ -0,0 +1,75 @@ +package com.github.inxilpro.intellijalpine.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class AlpineSettingsTest : BasePlatformTestCase() { + + fun testAppSettingsPersistGutterIcons() { + val settings = ApplicationManager.getApplication().getService(AlpineSettingsState::class.java) + ?: return + + val original = settings.showGutterIcons + + settings.showGutterIcons = false + assertFalse(settings.showGutterIcons) + + settings.showGutterIcons = true + assertTrue(settings.showGutterIcons) + + settings.showGutterIcons = original + } + + fun testProjectSettingsPluginEnableDisable() { + val settings = project.getService(AlpineProjectSettingsState::class.java) + ?: return + + assertFalse("Plugins should be disabled by default", settings.isPluginEnabled("alpine-ajax")) + + settings.setPluginEnabled("alpine-ajax", true) + assertTrue("Plugin should be enabled after enabling", settings.isPluginEnabled("alpine-ajax")) + + settings.setPluginEnabled("alpine-ajax", false) + assertFalse("Plugin should be disabled after disabling", settings.isPluginEnabled("alpine-ajax")) + } + + fun testProjectSettingsMultiplePlugins() { + val settings = project.getService(AlpineProjectSettingsState::class.java) + ?: return + + settings.setPluginEnabled("alpine-ajax", true) + settings.setPluginEnabled("alpine-wizard", true) + settings.setPluginEnabled("alpine-tooltip", false) + + assertTrue(settings.isPluginEnabled("alpine-ajax")) + assertTrue(settings.isPluginEnabled("alpine-wizard")) + assertFalse(settings.isPluginEnabled("alpine-tooltip")) + } + + fun testProjectSettingsStateRoundTrip() { + val settings = AlpineProjectSettingsState() + settings.setPluginEnabled("alpine-ajax", true) + settings.setPluginEnabled("alpine-wizard", false) + + val state = settings.state + assertNotNull("State should not be null", state) + + val newSettings = AlpineProjectSettingsState() + newSettings.loadState(state!!) + + assertTrue(newSettings.isPluginEnabled("alpine-ajax")) + assertFalse(newSettings.isPluginEnabled("alpine-wizard")) + } + + fun testAppSettingsStateRoundTrip() { + val original = AlpineSettingsState() + original.showGutterIcons = false + + val state = original.state + assertNotNull("State should not be null", state) + + val restored = AlpineSettingsState() + restored.loadState(state!!) + assertFalse(restored.showGutterIcons) + } +} diff --git a/src/test/testData/completion/alpine_directives.html b/src/test/testData/completion/alpine_directives.html new file mode 100644 index 0000000..c2202e9 --- /dev/null +++ b/src/test/testData/completion/alpine_directives.html @@ -0,0 +1,7 @@ + + +Alpine Completion Test + +
>
+ + diff --git a/src/test/testData/completion/alpine_event_shorthand.html b/src/test/testData/completion/alpine_event_shorthand.html new file mode 100644 index 0000000..673558a --- /dev/null +++ b/src/test/testData/completion/alpine_event_shorthand.html @@ -0,0 +1,9 @@ + + +Alpine Event Shorthand Test + +
+ +
+ + diff --git a/src/test/testData/completion/alpine_modifiers.html b/src/test/testData/completion/alpine_modifiers.html new file mode 100644 index 0000000..773a4fe --- /dev/null +++ b/src/test/testData/completion/alpine_modifiers.html @@ -0,0 +1,9 @@ + + +Alpine Modifier Test + +
+ > +
+ + diff --git a/src/test/testData/detection/html_with_alpine_script.html b/src/test/testData/detection/html_with_alpine_script.html new file mode 100644 index 0000000..39be56f --- /dev/null +++ b/src/test/testData/detection/html_with_alpine_script.html @@ -0,0 +1,10 @@ + + + + + + + +
+ + diff --git a/src/test/testData/detection/html_without_alpine.html b/src/test/testData/detection/html_without_alpine.html new file mode 100644 index 0000000..daee581 --- /dev/null +++ b/src/test/testData/detection/html_without_alpine.html @@ -0,0 +1,9 @@ + + + + + + +
+ + diff --git a/src/test/testData/detection/package_with_alpine_ajax.json b/src/test/testData/detection/package_with_alpine_ajax.json new file mode 100644 index 0000000..0df2bb4 --- /dev/null +++ b/src/test/testData/detection/package_with_alpine_ajax.json @@ -0,0 +1,7 @@ +{ + "name": "test-project", + "dependencies": { + "alpinejs": "^3.0.0", + "alpine-ajax": "^0.8.0" + } +} diff --git a/src/test/testData/detection/package_with_alpine_tooltip.json b/src/test/testData/detection/package_with_alpine_tooltip.json new file mode 100644 index 0000000..aa6f577 --- /dev/null +++ b/src/test/testData/detection/package_with_alpine_tooltip.json @@ -0,0 +1,7 @@ +{ + "name": "test-project", + "dependencies": { + "alpinejs": "^3.0.0", + "@ryangjchandler/alpine-tooltip": "^2.0.0" + } +} diff --git a/src/test/testData/detection/package_with_alpine_wizard.json b/src/test/testData/detection/package_with_alpine_wizard.json new file mode 100644 index 0000000..83753da --- /dev/null +++ b/src/test/testData/detection/package_with_alpine_wizard.json @@ -0,0 +1,7 @@ +{ + "name": "test-project", + "devDependencies": { + "alpinejs": "^3.0.0", + "@glhd/alpine-wizard": "^1.0.0" + } +} diff --git a/src/test/testData/detection/package_without_alpine.json b/src/test/testData/detection/package_without_alpine.json new file mode 100644 index 0000000..b542ad7 --- /dev/null +++ b/src/test/testData/detection/package_without_alpine.json @@ -0,0 +1,7 @@ +{ + "name": "test-project", + "dependencies": { + "react": "^18.0.0", + "lodash": "^4.17.0" + } +} diff --git a/src/test/testData/injection/alpine_expression.html b/src/test/testData/injection/alpine_expression.html new file mode 100644 index 0000000..6db33a0 --- /dev/null +++ b/src/test/testData/injection/alpine_expression.html @@ -0,0 +1,10 @@ + + +Alpine Injection Test + +
+ + +
+ + diff --git a/src/test/testData/injection/alpine_xdata_scope.html b/src/test/testData/injection/alpine_xdata_scope.html new file mode 100644 index 0000000..dd455d4 --- /dev/null +++ b/src/test/testData/injection/alpine_xdata_scope.html @@ -0,0 +1,12 @@ + + +Alpine X-Data Scope Test + +
+ +
Loading...
+
+ +