Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Improve overall typing and add initial support for Go To Declaration on x-data derived properties #45

Merged
merged 2 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
### Added
- Added support for IntelliJ platform version 2023.2
- Added support for [`glhd/alpine-wizard`](https://github.com/glhd/alpine-wizard)
- Added typing support for `$el` and `$root`
- Improved intellisense for `x-data` derived properties
- Added initial support for Go To Declaration for `x-data` derived properties

## [0.5.0] - 2023-05-31

Expand Down
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.changelog) // Gradle Changelog Plugin
alias(libs.plugins.qodana) // Gradle Qodana Plugin
alias(libs.plugins.kover) // Gradle Kover Plugin
kotlin("plugin.serialization") version "1.9.10"
}

group = properties("pluginGroup").get()
Expand All @@ -23,7 +24,7 @@ repositories {

// Dependencies are managed with Gradle version catalog - read more: https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog
dependencies {
// implementation(libs.annotations)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
}

// Set the JVM language level used to build the project. Use Java 11 for 2020.3+, and Java 17 for 2022.2+.
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pluginGroup = com.github.inxilpro.intellijalpine
pluginName = Alpine.js Support
pluginRepositoryUrl = https://github.com/inxilpro/IntellijAlpine
# SemVer format -> https://semver.org
pluginVersion = 0.6.0
pluginVersion = 0.6.1

# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 231
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/github/inxilpro/intellijalpine/Alpine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@
object Alpine {
@JvmField
val ICON = IconLoader.getIcon("/alpineicon.svg", javaClass)

val NAMESPACE = "IntelliJAlpine Plugin"

Check notice on line 9 in src/main/kotlin/com/github/inxilpro/intellijalpine/Alpine.kt

View workflow job for this annotation

GitHub Actions / Build

Might be 'const'

Might be 'const'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.github.inxilpro.intellijalpine

import com.intellij.lang.javascript.psi.JSInheritedLanguagesHelper
import com.intellij.lang.javascript.psi.JSObjectLiteralExpression
import com.intellij.psi.util.elementType

Check warning on line 5 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineAttributeInjectionHeader.kt

View workflow job for this annotation

GitHub Actions / Build

Unused import directive

Unused import directive
import com.intellij.psi.xml.XmlAttribute
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

object AlpineAttributeInjectionHeader {

@Serializable
data class DataIndicesHeader(
@SerialName("aso") val attributeStartOffset: Int,
@SerialName("tso") val tagStartOffset: Int,
@SerialName("e") val expression: String,
)

@Serializable
data class Header(
@SerialName("ns") val namespace: String,
@SerialName("so") val startOffset: String,
@SerialName("do") val dataOffsets: List<DataIndicesHeader>,
)

fun deserialize(header: String): Header {
return Json.decodeFromString<Header>(header)

Check notice on line 30 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineAttributeInjectionHeader.kt

View workflow job for this annotation

GitHub Actions / Build

Unnecessary type argument

Remove explicit type arguments
}

fun serialize(startOffset: Int, data: List<XmlAttribute>): String {
val mappedData = data.flatMap { attribute ->
val expression = JSInheritedLanguagesHelper.createExpressionFromText(
attribute.value!!,
attribute
) as JSObjectLiteralExpression
expression.properties.map {
DataIndicesHeader(
attribute.textOffset,
attribute.parent?.textOffset!!,
it.name.toString()
)
}
}
val json = Json.encodeToString(Header(Alpine.NAMESPACE, Int.MAX_VALUE.toString(), mappedData))
return patchStartOffset(json, startOffset)
}

private fun patchStartOffset(json: String, startOffset: Int): String {
return json.replace(
"\"so\":\"${Int.MAX_VALUE}\"",
"\"so\":\"${startOffset.toString().padStart(Int.MAX_VALUE.toString().length, '0')}\""
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.github.inxilpro.intellijalpine

import com.intellij.lang.javascript.navigation.JSGotoDeclarationHandler
import com.intellij.lang.javascript.psi.JSInheritedLanguagesHelper

Check warning on line 4 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineGotoDeclarationHandler.kt

View workflow job for this annotation

GitHub Actions / Build

Unused import directive

Unused import directive
import com.intellij.lang.javascript.psi.JSObjectLiteralExpression

Check warning on line 5 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineGotoDeclarationHandler.kt

View workflow job for this annotation

GitHub Actions / Build

Unused import directive

Unused import directive
import com.intellij.lang.javascript.psi.impl.JSPsiElementFactory

Check warning on line 6 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineGotoDeclarationHandler.kt

View workflow job for this annotation

GitHub Actions / Build

Unused import directive

Unused import directive
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.html.HtmlTag
import com.intellij.psi.util.elementType
import com.intellij.psi.xml.XmlElementType.HTML_TAG
import com.intellij.refactoring.suggested.startOffset
import java.util.*

class AlpineGotoDeclarationHandler : JSGotoDeclarationHandler() {

override fun getGotoDeclarationTargets(
sourceElement: PsiElement?,
offset: Int,
editor: Editor?
): Array<PsiElement>? {
if (sourceElement == null) return null
if (editor == null) return null

val targets = super.getGotoDeclarationTargets(sourceElement, offset, editor)

// There should only be one target
if (targets?.size != 1) return targets

// Check if this is a snippet generated by us.
val header = tryParseHeader(editor.document.charsSequence.lines().first()) ?: return targets

// Check if the found target was generated by us.
if (targets.first().startOffset > header.startOffset.toInt()) return targets

// Get the $data offsets for the found target.
val dataOffset = header.dataOffsets.firstOrNull { it.expression.contains(sourceElement.text) } ?: return targets

// Retrieve the currently opened PSI Document.
val textEditor = editor.project?.let { FileEditorManager.getInstance(it).selectedTextEditor } ?: return null
val document = editor.project?.let { PsiDocumentManager.getInstance(it).getCachedPsiFile(textEditor.document) }
?: return null

// Finally, search for the tag referenced by our dataOffset and return it.
return searchForTag(document, dataOffset)
?.let { arrayOf(it.getAttribute("x-data")?.valueElement as PsiElement) }
?: return targets
}

private fun tryParseHeader(header: String): AlpineAttributeInjectionHeader.Header? {
return try {
AlpineAttributeInjectionHeader.deserialize(header)
} catch (_: Exception) {
null
}
}

private fun searchForTag(
document: PsiFile,
dataOffset: AlpineAttributeInjectionHeader.DataIndicesHeader
): HtmlTag? {
var children = document.children.asList()
do {
val newChildren = Vector<PsiElement>()

children.forEach {
if (it.startOffset <= dataOffset.tagStartOffset) {
if (it.startOffset == dataOffset.tagStartOffset && it.elementType == HTML_TAG) {
return it as HtmlTag
}

newChildren.addAll(it.children)
}
}
children = newChildren
} while (children.isNotEmpty())

return null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiLanguageInjectionHost
import com.intellij.psi.html.HtmlTag
import com.intellij.psi.impl.source.html.dtd.HtmlAttributeDescriptorImpl
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 com.intellij.refactoring.suggested.startOffset

Check warning on line 15 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineJavaScriptAttributeValueInjector.kt

View workflow job for this annotation

GitHub Actions / Build

Unused import directive

Unused import directive
import org.apache.commons.lang3.tuple.MutablePair
import org.apache.html.dom.HTMLDocumentImpl
import java.util.*

class AlpineJavaScriptAttributeValueInjector : MultiHostInjector {
private companion object {
Expand Down Expand Up @@ -90,10 +93,10 @@

val coreMagics =
"""
/** @type {HTMLElement} */
/** @type {elType} */
let ${'$'}el;

/** @type {HTMLElement} */
/** @type {rootType} */
let ${'$'}root;

/**
Expand Down Expand Up @@ -124,14 +127,14 @@

""".trimIndent()

val eventMagics = "/** @type {Event} */\nlet ${'$'}event;\n\n"
val eventMagics = "/** @type {eventType} */\nlet ${'$'}event;\n\n"

Check notice on line 130 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineJavaScriptAttributeValueInjector.kt

View workflow job for this annotation

GitHub Actions / Build

Might be 'const'

Might be 'const'
}

override fun getLanguagesToInject(registrar: MultiHostRegistrar, host: PsiElement) {
if (host !is XmlAttributeValue) {
return
}
if (!isValidInjectionTarget(host)) {
if (!AttributeUtil.isValidInjectionTarget(host)) {
return
}

Expand Down Expand Up @@ -168,11 +171,11 @@
private fun getJavaScriptRanges(host: XmlAttributeValue, content: String): List<TextRange> {
val valueRange = ElementManipulators.getValueTextRange(host)

if (host.containingFile.viewProvider.languages.filter { "PHP" == it.id || "Blade" == it.id }.isEmpty()) {

Check notice on line 174 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineJavaScriptAttributeValueInjector.kt

View workflow job for this annotation

GitHub Actions / Build

Call chain on collection type can be simplified

Call chain on collection type may be simplified
return listOf(valueRange)
}

val phpMatcher = Regex("(?:(?<!@)\\{\\{.+?}}|<\\?(?:=|php).+?\\?>|@[a-zA-Z]+\\(.*\\)(?:\\.defer)?)")

Check warning on line 178 in src/main/kotlin/com/github/inxilpro/intellijalpine/AlpineJavaScriptAttributeValueInjector.kt

View workflow job for this annotation

GitHub Actions / Build

Unnecessary non-capturing group

Unnecessary non-capturing group `(?:(?``|@[a-zA-Z]+\\(.*\\)(?:\\.defer)?)`
val ranges = mutableListOf<TextRange>()

var offset = valueRange.startOffset
Expand All @@ -186,64 +189,19 @@
return ranges.toList()
}

private fun isValidInjectionTarget(host: XmlAttributeValue): Boolean {
// Make sure that we have an XML attribute as a parent
val attribute = host.parent as? XmlAttribute ?: return false

// Make sure we have an HTML tag (and not a Blade <x- tag)
val tag = attribute.parent as? HtmlTag ?: return false
if (!isValidHtmlTag(tag)) {
return false
}

// Make sure we have an attribute that looks like it's Alpine
val attributeName = attribute.name
if (!isAlpineAttributeName(attributeName)) {
return false
}

// Make sure it's a valid Attribute to operate on
if (!isValidAttribute(attribute)) {
return false
}

// Make sure it's an attribute that is parsed as JavaScript
if (!shouldInjectJavaScript(attributeName)) {
return false
}

return true
}

private fun isValidAttribute(attribute: XmlAttribute): Boolean {
return attribute.descriptor is HtmlAttributeDescriptorImpl || attribute.descriptor is AlpineAttributeDescriptor
}

private fun isValidHtmlTag(tag: HtmlTag): Boolean {
return !tag.name.startsWith("x-")
}

private fun isAlpineAttributeName(name: String): Boolean {
return name.startsWith("x-") || name.startsWith("@") || name.startsWith(':')
}

private fun shouldInjectJavaScript(name: String): Boolean {
return !name.startsWith("x-transition:") && "x-mask" != name && "x-modelable" != name
}

private fun getPrefixAndSuffix(directive: String, host: XmlAttributeValue): Pair<String, String> {
val context = MutablePair(globalMagics, "")

if ("x-data" != directive) {
context.left = coreMagics + context.left
context.left = addTypingToCoreMagics(host) + context.left
}

if ("x-spread" == directive) {
context.right += "()"
}

if (AttributeUtil.isEvent(directive)) {
context.left += eventMagics
context.left += addTypingToEventMagics(directive, host)
} else if ("x-for" == directive) {
context.left += "for (let "
context.right += ") {}"
Expand All @@ -260,32 +218,68 @@
context.right += "\n)"
}

addWithData(host, directive, context)
addWithData(host, context)

return context.toPair()
}

private fun addWithData(host: XmlAttributeValue, directive: String, context: MutablePair<String, String>) {
var dataParent: HtmlTag?
private fun addWithData(host: XmlAttributeValue, context: MutablePair<String, String>) {
val dataParents = LinkedList<XmlAttribute>()
val attribute = host.parent as XmlAttribute

if ("x-data" == directive) {
val parentTag = PsiTreeUtil.findFirstParent(host) { it is HtmlTag } ?: return
dataParent = PsiTreeUtil.findFirstParent(parentTag) {
it != parentTag && it is HtmlTag && it.getAttribute("x-data") != null
} as HtmlTag?
} else {
dataParent = PsiTreeUtil.findFirstParent(host) {
it is HtmlTag && it.getAttribute("x-data") != null
} as HtmlTag?
var parent = attribute.parent as HtmlTag?
do {
parent?.getAttribute("x-data")?.let {
if (it.value != null) dataParents.addFirst(it)
}

parent = parent?.parentTag as HtmlTag?
} while (parent != null)

if (dataParents.isNotEmpty()) {
val data = dataParents.joinToString(", ") { data -> "...{${data.value?.removeSurrounding("{", "}")}}" }
val (prefix, suffix) = context
context.left = "$globalState\n$alpineWizardState\nlet ${'$'}data = {$data};\nwith (${'$'}data) {\n\n$prefix"
context.right = "$suffix\n\n}"
}

if (dataParent is HtmlTag) {
val data = dataParent.getAttribute("x-data")?.value
if (null != data) {
val (prefix, suffix) = context
context.left = "$globalState\n$alpineWizardState\nlet ${'$'}data = $data;\nwith (${'$'}data) {\n\n$prefix"
context.right = "$suffix\n\n}"
val header = AlpineAttributeInjectionHeader.serialize(context.left.length + 1, dataParents)
context.left = "$header\n${context.left}"
}

private fun addTypingToCoreMagics(host: XmlAttributeValue): String {
var typedCoreMagics = coreMagics
val attribute = host.parent as XmlAttribute
val tag = attribute.parent

fun jsElementNameFromXmlTag(tag: XmlTag): String {
return HTMLDocumentImpl().createElement(tag.localName).javaClass.simpleName.removeSuffix("Impl")
}

// Determine type for $el
run {
val elType = jsElementNameFromXmlTag(tag)
typedCoreMagics = typedCoreMagics.replace("{elType}", elType)
}

// Determine type for $root
run {
val elType = if (tag.getAttribute("x-data") != null) {
jsElementNameFromXmlTag(tag)
} else {
PsiTreeUtil.findFirstParent(tag.parentTag)
{ it is HtmlTag && it.getAttribute("x-data") != null }
?.let { jsElementNameFromXmlTag(it as XmlTag) }
?: "HTMLElement"
}
typedCoreMagics = typedCoreMagics.replace("{rootType}", elType)
}

return typedCoreMagics
}

private fun addTypingToEventMagics(directive: String, host: XmlAttributeValue): String {
val eventName = AttributeUtil.getEventNameFromDirective(directive)
return eventMagics.replace("eventType", eventName)
}
}
Loading
Loading