Skip to content
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
8 changes: 1 addition & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,8 @@ You can find the plugin on [IntelliJ marketplace](https://plugins.jetbrains.com/

## Compatibility

Manually verified with these products…

- IntelliJ IDEA Community Edition 2025.2+
- IntelliJ IDEA Ultimate 2025.2+
- PhpStorm 2025.2+
- WebStorm 2025.2+
Manually verified with IntelliJ IDEA Unified distribution 2025.3+

## ❤️🙏 Love & Thanks

- [tscharke](https://github.com/tscharke) for contributing

6 changes: 6 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.intellij.platform.gradle.TestFrameworkType

fun properties(key: String) = providers.gradleProperty(key)

Expand All @@ -24,7 +25,12 @@ dependencies {
val type = properties("platformType").get()
val version = properties("platformVersion").get()
create(type, version)

testFramework(TestFrameworkType.Platform)
bundledPlugin("JavaScript")
}

testImplementation(kotlin("test"))
}

tasks {
Expand Down
10 changes: 5 additions & 5 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ org.gradle.caching=true

pluginName=html-attribute-folder
pluginGroup=dev.zbinski
pluginVersion=1.3.0
platformVersion=2025.2
pluginVersion=1.4.0
platformVersion=2025.3
# @see https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-types.html#IntelliJPlatformType
platformType=IC
platformType=IU
# @see https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html#platformVersions
pluginSinceBuild=252
pluginUntilBuild=252.*
pluginSinceBuild=253
pluginUntilBuild=253.*
javaVersion=21
80 changes: 71 additions & 9 deletions src/main/kotlin/dev/zbinski/htmlattributefolder/AttributeFolder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,53 @@ class AttributeFolder: FoldingBuilderEx(), DumbAware {
for (item in getAttributes(Array(1) { root })) {
var end: Int
var start: Int

if (settings.foldingMethod == 1) {
end = item.attribute.textRange.endOffset
start = item.attribute.textRange.startOffset
} else {
val len = item.attributeName.length + settings.attributeSeparator.length + settings.attributeWrapper.length
start = item.attribute.textRange.startOffset + len
end = item.attribute.textRange.endOffset - settings.attributeWrapper.length
val text = item.attribute.text
val base = item.attribute.textRange.startOffset

// Find '=' then the first non-space char after it
val eq = text.indexOf(settings.attributeSeparator)
if (eq < 0) continue
var i = eq + 1
while (i < text.length && text[i].isWhitespace()) i++
if (i >= text.length) continue

when (text[i]) {
// name="value" or name='value'
'"', '\'' -> {
val quote = text[i]
val open = i
val close = text.indexOf(quote, startIndex = open + 1)
if (close < 0) continue

start = base + open + 1
end = base + close
}
// name={...} or name={{...}}
'{' -> {
val outerOpen = i
val outerClose = findMatchingBrace(text, outerOpen) ?: continue

val isDouble = outerOpen + 1 < text.length && text[outerOpen + 1] == '{'
if (isDouble) {
val innerOpen = outerOpen + 1
val innerClose = findMatchingBrace(text, innerOpen) ?: continue

// Fold inside INNER braces INCLUDING whitespace so output becomes: style={{__PLACEHOLDER__}}
start = base + innerOpen + 1
end = base + innerClose
} else {
// Fold inside single braces INCLUDING whitespace: name={__PLACEHOLDER__}
start = base + outerOpen + 1
end = base + outerClose
}
}
else -> continue
}
}

if (end > start) {
Expand Down Expand Up @@ -57,24 +97,46 @@ class AttributeFolder: FoldingBuilderEx(), DumbAware {
return settings.collapseByDefault
}

private fun findMatchingBrace(text: String, openIndex: Int): Int? {
if (openIndex !in text.indices || text[openIndex] != '{') return null
var depth = 0
var i = openIndex
while (i < text.length) {
when (text[i]) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) return i
}
}
i++
}
return null
}

private fun getAttributes(
elements: Array<PsiElement>,
attributes: ArrayList<String> = settings.attributes
): Sequence<Attribute> = sequence {
for (child in elements) {
val t = child.text
for (attributeName in attributes) {
val attributeBeginning = attributeName + settings.attributeSeparator + settings.attributeWrapper
if (child.text.startsWith(attributeBeginning)) {
val startsLikeAttribute =
t.startsWith("$attributeName=\"") ||
t.startsWith("$attributeName='") ||
t.startsWith("$attributeName={")

if (startsLikeAttribute) {
yield(object : Attribute {
override val attribute = child
override val attributeName = attributeName
})
}
}

val items = getAttributes(child.children, arrayListOf(attributeName)).iterator()
while (items.hasNext()) {
yield(items.next())
}
val items = getAttributes(child.children, attributes).iterator()
while (items.hasNext()) {
yield(items.next())
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-name -->
<name>HTML Attribute Folder</name>

<version>1.3.0</version>
<version>1.4.0</version>

<!-- A displayed Vendor name or Organization ID displayed on the Plugins Page. -->
<vendor email="dawid@zbinski.dev" url="https://zbinski.dev">Dawid Zbiński</vendor>
Expand All @@ -29,6 +29,7 @@
<depends>com.intellij.modules.platform</depends>
<depends>com.intellij.modules.lang</depends>
<depends>com.intellij.modules.xml</depends>
<depends optional="true">com.intellij.modules.javascript</depends>

<!-- Extension points defined by the plugin.
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package dev.zbinski.htmlattributefolder

private const val HTML_SNIPPET =
"""<div class="a b" className="c d" style="background-color: red;" data-foo="foo-data" />"""

class AttributeFolderHTMLTest : BaseAttributeFolderTest() {
fun testFoldingHtmlAttributesCollapsed() {
assertContains(document.text, """class="a b"""")
assertContains(document.text, """className="c d"""")
assertContains(document.text, """style="background-color: red;"""")
assertContains(document.text, """data-foo="foo-data"""")

configureAttributeFolder(collapseByDefault = true)

val visualText = applyPluginFoldingAndRender()
assertContains(visualText, """class="__PLACEHOLDER__"""")
assertContains(visualText, """className="__PLACEHOLDER__"""")
assertContains(visualText, """style="background-color: red;"""")
assertContains(visualText, """data-foo="foo-data"""")
}

fun testFoldingHtmlAttributesUncollapsed() {
assertContains(document.text, """class="a b"""")
assertContains(document.text, """className="c d"""")
assertContains(document.text, """style="background-color: red;"""")
assertContains(document.text, """data-foo="foo-data"""")

configureAttributeFolder(collapseByDefault = false)

val visualText = applyPluginFoldingAndRender()
assertContains(visualText, """class="a b"""")
assertContains(visualText, """className="c d"""")
assertContains(visualText, """style="background-color: red;"""")
assertContains(visualText, """data-foo="foo-data"""")
}

override fun setUp() {
super.setUp()
setupDocument("test.html", HTML_SNIPPET)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package dev.zbinski.htmlattributefolder

private const val TSX_SNIPPET = """
export const Component = () => (
<div class="a b" className="c d" style={{ backgroundColor: "red", nested: { "foo": "bar" } }} data-foo="foo-data" />
)
"""

class AttributeFolderTSXTest : BaseAttributeFolderTest() {
fun testFoldingTSXAttributesCollapsed() {
assertContains(document.text, """class="a b"""")
assertContains(document.text, """className="c d"""")
assertContains(document.text, """style={{ backgroundColor: "red", nested: { "foo": "bar" } }}""")
assertContains(document.text, """data-foo="foo-data"""")

configureAttributeFolder(collapseByDefault = true, arrayListOf("class", "className", "style"))

val visualText = applyPluginFoldingAndRender()
assertContains(visualText, """class="__PLACEHOLDER__"""")
assertContains(visualText, """className="__PLACEHOLDER__"""")
assertContains(visualText, """style={{__PLACEHOLDER__}}""")
assertContains(visualText, """data-foo="foo-data"""")
}

fun testFoldingTSXAttributesUncollapsed() {
assertContains(document.text, """class="a b"""")
assertContains(document.text, """className="c d"""")
assertContains(document.text, """style={{ backgroundColor: "red", nested: { "foo": "bar" } }}""")
assertContains(document.text, """data-foo="foo-data"""")

configureAttributeFolder(collapseByDefault = false, arrayListOf("class", "className", "style"))

val visualText = applyPluginFoldingAndRender()
assertContains(visualText, """class="a b"""")
assertContains(visualText, """className="c d"""")
assertContains(visualText, """style={{ backgroundColor: "red", nested: { "foo": "bar" } }}""")
assertContains(visualText, """data-foo="foo-data"""")
}

override fun setUp() {
super.setUp()
setupDocument("test.tsx", TSX_SNIPPET)
skipTestIfJSXIsNotSupported()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package dev.zbinski.htmlattributefolder

import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.FoldRegion
import com.intellij.psi.PsiFile
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import org.junit.Assume

abstract class BaseAttributeFolderTest : BasePlatformTestCase() {
protected lateinit var file: PsiFile
protected lateinit var document: Document

protected fun setupDocument(fileName: String, text: String) {
file = myFixture.configureByText(fileName, text)
document = myFixture.getDocument(file)
}

protected fun configureAttributeFolder(collapseByDefault: Boolean, listOfAttributes: ArrayList<String> = arrayListOf("class", "className")) {
val state = AttributeFolderState.instance
state.attributes = listOfAttributes
state.foldingMethod = 0
state.collapseByDefault = collapseByDefault
state.placeholder = "__PLACEHOLDER__"
}

protected fun applyPluginFoldingAndRender(): String {
val builder = AttributeFolder()
val descriptors = builder.buildFoldRegions(file, document, false)
val editor = myFixture.editor

editor.foldingModel.runBatchFoldingOperation {
for (d in descriptors) {
val region = editor.foldingModel.addFoldRegion(
d.range.startOffset,
d.range.endOffset,
builder.getPlaceholderText(d.element)
)
if (region != null) {
region.isExpanded = !builder.isCollapsedByDefault(d.element)
}
}
}

return renderVisualText(document.text, editor.foldingModel.allFoldRegions.toList())
}

protected fun renderVisualText(documentText: String, regions: List<FoldRegion>): String {
val collapsed = regions.filter { !it.isExpanded }
.sortedBy { it.startOffset }

val sb = StringBuilder()
var i = 0

for (r in collapsed) {
if (r.startOffset < i) continue
sb.append(documentText.substring(i, r.startOffset))
sb.append(r.placeholderText ?: "")
i = r.endOffset
}

sb.append(documentText.substring(i))
return sb.toString()
}

protected fun assertContains(actual: String, expectedSubstring: String, context: String = "") {
kotlin.test.assertTrue(
actual.contains(expectedSubstring),
message = buildString {
appendLine("Expected substring not found:")
appendLine(" expected: $expectedSubstring")
appendLine()
appendLine(" actual:")
appendLine(actual)
if (context.isNotBlank()) {
appendLine()
appendLine(" context:")
appendLine(context)
}
}
)
}

protected fun skipTestIfJSXIsNotSupported() {
val languageId = file.language.id
Assume.assumeTrue(
"Skipping test: JSX/TSX not supported in this test runtime (languageId=$languageId)",
languageId.contains("TypeScript", ignoreCase = true) && languageId.contains("JSX", ignoreCase = true)
)
}
}