diff --git a/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/GroupFusionPaths.java b/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/GroupFusionPaths.java new file mode 100644 index 00000000..94aeba13 --- /dev/null +++ b/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/GroupFusionPaths.java @@ -0,0 +1,168 @@ +package de.vette.idea.neos.lang.fusion.codeInsight.intention; + +import com.intellij.codeInsight.intention.BaseElementAtCaretIntentionAction; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiWhiteSpace; +import com.intellij.psi.codeStyle.CodeStyleManager; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.IncorrectOperationException; +import de.vette.idea.neos.lang.fusion.FusionBundle; +import de.vette.idea.neos.lang.fusion.psi.*; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +public class GroupFusionPaths extends BaseElementAtCaretIntentionAction { + @Override + public boolean isAvailable(@NotNull Project project, @NotNull Editor editor, @NotNull PsiElement element) { + if (!(element.getContainingFile() instanceof FusionFile)) { + return false; + } + if (element.getNode().getElementType() != FusionTypes.PATH_SEPARATOR) { + return false; + } + var path = PsiTreeUtil.findFirstParent(element, true, e -> e instanceof FusionPath); + if (path == null) { + return false; + } + var prefix = getPathPrefix((FusionPath) path, element); + var context = getContext(element); + var siblingCandidates = getElementsInPath(prefix, context); + this.setText(FusionBundle.message("intention.group.fusion.paths.of", prefix)); + return siblingCandidates.size() > 1; + } + + protected String getPathPrefix(FusionPath path, PsiElement separator) { + var child = path.getFirstChild(); + StringBuilder prefix = new StringBuilder(); + while (child != separator) { + prefix.append(child.getText()); + child = child.getNextSibling(); + } + return prefix.toString(); + } + + protected PsiElement getContext(PsiElement element) { + var closestBlock = PsiTreeUtil.findFirstParent(element, true, e -> e instanceof FusionBlock); + if (closestBlock != null) { + return closestBlock; + } + // we expect a path always to be within a block or to be at the top level of the file + return element.getContainingFile(); + } + + protected @NotNull List getElementsInPath(String prefix, PsiElement context) { + var prefixLike = prefix + "."; + var elements = new ArrayList(); + + for (var child : context.getChildren()) { + if (child instanceof FusionPropertyBlock propertyBlock) { + var path = propertyBlock.getPath().getText(); + if (path.equals(prefix) || path.startsWith(prefixLike)) { + elements.add(propertyBlock); + } + } else if (child instanceof FusionPropertyAssignment assignment) { + var path = assignment.getPath().getText(); + if (path.equals(prefix)) { + // we can only merge into assignments that instantiate a prototype + if (assignment.getAssignmentValue() == null || assignment.getAssignmentValue().getPrototypeInstance() == null) { + return new ArrayList<>(); + } + elements.add(assignment); + } else if (path.startsWith(prefixLike)) { + elements.add(assignment); + } + } + } + + return elements; + } + + @Override + public void invoke(@NotNull Project project, @NotNull Editor editor, @NotNull PsiElement element) throws IncorrectOperationException { + var path = PsiTreeUtil.findFirstParent(element, true, e -> e instanceof FusionPath); + if (path == null) { + return; + } + var prefix = getPathPrefix((FusionPath) path, element); + var context = getContext(element); + var siblingCandidates = getElementsInPath(prefix, context); + if (siblingCandidates.size() < 2) { + return; + } + + var prefixLength = 1; + var prefixStart = path.getFirstChild(); + while (prefixStart != element) { + prefixLength++; + prefixStart = prefixStart.getNextSibling(); + } + + // TODO: check if this would merge into a prototype instantiation: in general that could work, but not with this + // code. Also for some prototypes we might want some order of properties, e.g. any other property before + // "renderer" + + // I would have preferred working with the PSI tree, but I couldn't get correct line breaks within reasonable time + var newBlockCode = new StringBuilder(); + newBlockCode.append(prefix); + newBlockCode.append(" {"); + + List elementsToRemove = new ArrayList<>(); + for (var sibling : siblingCandidates) { + FusionPath siblingPath; + if (sibling instanceof FusionPropertyBlock propertyBlock) { + siblingPath = propertyBlock.getPath(); + } else if (sibling instanceof FusionPropertyAssignment assignment) { + siblingPath = assignment.getPath(); + } else { + continue; + } + + // drop prefix from path + var removedCount = prefixLength; + while (siblingPath.getFirstChild() != null && removedCount > 0) { + removedCount--; + siblingPath.getFirstChild().delete(); + } + + elementsToRemove.add(sibling); + if (sibling.getNextSibling() instanceof PsiWhiteSpace) { + sibling.getNextSibling().delete(); + } + + if (sibling instanceof FusionPropertyBlock propertyBlock) { + var openingBrace = propertyBlock.getBlock().getLeftBrace(); + var closingBrace = propertyBlock.getBlock().getRightBrace(); + var child = openingBrace.getNextSibling(); + while (child != closingBrace) { + if (!(child instanceof PsiWhiteSpace)) { + newBlockCode.append("\n"); + newBlockCode.append(child.getText()); + } + child = child.getNextSibling(); + } + } else { + newBlockCode.append("\n"); + newBlockCode.append(sibling.getText()); + } + } + + newBlockCode.append("\n}\n"); + var newBlock = FusionElementFactory.createFusionFile(project, newBlockCode.toString()); + var addedBlock = (FusionPropertyBlock) siblingCandidates.get(0).getParent().addBefore(newBlock.getFirstChild(), siblingCandidates.get(0)); + addedBlock.getParent().addAfter(newBlock.getLastChild(), addedBlock); + + elementsToRemove.forEach(PsiElement::delete); + + CodeStyleManager.getInstance(project).reformat(addedBlock); + } + + @Override + public @NotNull @IntentionFamilyName String getFamilyName() { + return FusionBundle.message("intention.group.fusion.paths"); + } +} diff --git a/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/MergeFusionPathUp.java b/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/MergeFusionPathUp.java new file mode 100644 index 00000000..db083abe --- /dev/null +++ b/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/MergeFusionPathUp.java @@ -0,0 +1,88 @@ +package de.vette.idea.neos.lang.fusion.codeInsight.intention; + +import com.intellij.codeInsight.intention.BaseElementAtCaretIntentionAction; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.codeInspection.util.IntentionName; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiWhiteSpace; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.IncorrectOperationException; +import de.vette.idea.neos.NeosProjectService; +import de.vette.idea.neos.lang.fusion.FusionBundle; +import de.vette.idea.neos.lang.fusion.psi.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class MergeFusionPathUp extends BaseElementAtCaretIntentionAction { + @Override + public @NotNull @IntentionName String getText() { + return getFamilyName(); + } + + @Override + public @NotNull @IntentionFamilyName String getFamilyName() { + return FusionBundle.message("intention.merge.fusion.path.up"); + } + + @Override + public boolean isAvailable(@NotNull Project project, @NotNull Editor editor, @NotNull PsiElement element) { + if (!(element.getContainingFile() instanceof FusionFile)) { + return false; + } + + var path = (FusionPath) PsiTreeUtil.findFirstParent(element, true, e -> e instanceof FusionPath); + + var closestBlock = getClosestBlock(path); + return closestBlock != null; + } + + protected @Nullable FusionPropertyBlock getClosestBlock(@Nullable FusionPath path) { + if (path == null || path.getParent() == null || !(path.getParent().getParent() instanceof FusionBlock)) { + return null; + } + + FusionBlock block = (FusionBlock) path.getParent().getParent(); + return block.getParent() instanceof FusionPropertyBlock ? (FusionPropertyBlock) block.getParent() : null; + } + + protected int getChildrenCount(FusionPropertyBlock block) { + return block.getBlock().getChildren().length; + } + + @Override + public void invoke(@NotNull Project project, @NotNull Editor editor, @NotNull PsiElement element) throws IncorrectOperationException { + var path = (FusionPath) PsiTreeUtil.findFirstParent(element, true, e -> e instanceof FusionPath); + var block = getClosestBlock(path); + if (path == null || block == null) { + return; + } + + int blockChildrenCount = getChildrenCount(block); + NeosProjectService.getLogger().debug("blockChildrenCount: " + blockChildrenCount); + var originalStatement = path.getParent(); + var pathSeparator = FusionElementFactory.createFusionFile(project, "foo.bar").getFirstChild().getNextSibling(); + var child = block.getPath().getFirstChild(); + var anchor = path.getFirstChild(); + while (child != null) { + path.addBefore(child.copy(), anchor); + child = child.getNextSibling(); + } + path.addBefore(pathSeparator.copy(), anchor); + block.getParent().addAfter(originalStatement, block); + + if (blockChildrenCount == 1) { + // block should now be empty + block.delete(); + } else { + var nl = FusionElementFactory.createFusionFile(project, "\n").getFirstChild(); + block.getParent().addAfter(nl, block); + // remove trailing line break + if (originalStatement.getNextSibling() instanceof PsiWhiteSpace) { + originalStatement.getNextSibling().delete(); + } + originalStatement.delete(); + } + } +} diff --git a/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/SplitFusionPath.java b/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/SplitFusionPath.java new file mode 100644 index 00000000..73e35b29 --- /dev/null +++ b/src/main/java/de/vette/idea/neos/lang/fusion/codeInsight/intention/SplitFusionPath.java @@ -0,0 +1,69 @@ +package de.vette.idea.neos.lang.fusion.codeInsight.intention; + +import com.intellij.codeInsight.intention.BaseElementAtCaretIntentionAction; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.codeInspection.util.IntentionName; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElement; +import com.intellij.psi.codeStyle.CodeStyleManager; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.IncorrectOperationException; +import de.vette.idea.neos.lang.fusion.FusionBundle; +import de.vette.idea.neos.lang.fusion.psi.*; +import org.jetbrains.annotations.NotNull; + +public class SplitFusionPath extends BaseElementAtCaretIntentionAction { + + @Override + public @NotNull @IntentionName String getText() { + return getFamilyName(); + } + + @Override + public @NotNull @IntentionFamilyName String getFamilyName() { + return FusionBundle.message("intention.split.fusion.path"); + } + + @Override + public boolean isAvailable(@NotNull Project project, @NotNull Editor editor, @NotNull PsiElement element) { + if (!(element.getContainingFile() instanceof FusionFile)) { + return false; + } + if (element.getNode().getElementType() != FusionTypes.PATH_SEPARATOR) { + return false; + } + var path = PsiTreeUtil.findFirstParent(element, true, e -> e instanceof FusionPath); + return path != null && path.getChildren().length > 1; + } + + @Override + public void invoke(@NotNull Project project, @NotNull Editor editor, @NotNull PsiElement element) throws IncorrectOperationException { + var path = PsiTreeUtil.findFirstParent(element, true, e -> e instanceof FusionPath); + if (path == null) { + return; + } + + var originalStatement = path.getParent(); + + var newBlock = (FusionPropertyBlock) FusionElementFactory.createFusionFile(project, "dummy {\n\n}").getFirstChild(); + var newPath = newBlock.getPath(); + var child = path.getFirstChild(); + while (child != element) { + // this seems to create a copy and does not "remount" the element, so we need to delete the original + newPath.add(child); + var next = child.getNextSibling(); + child.delete(); + child = next; + } + element.delete(); + newPath.getFirstChild().delete(); + + var insertedBlock = (FusionPropertyBlock) originalStatement.getParent().addBefore(newBlock, originalStatement); + insertedBlock.getBlock().addAfter(originalStatement, insertedBlock.getBlock().getLeftBrace().getNextSibling()); + + originalStatement.delete(); + + CodeStyleManager.getInstance(project).reformat(insertedBlock); + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 45653b23..2da88d22 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -120,6 +120,22 @@ + + NeosFusion + de.vette.idea.neos.lang.fusion.codeInsight.intention.SplitFusionPath + Neos Fusion + + + NeosFusion + de.vette.idea.neos.lang.fusion.codeInsight.intention.MergeFusionPathUp + Neos Fusion + + + NeosFusion + de.vette.idea.neos.lang.fusion.codeInsight.intention.GroupFusionPaths + Neos Fusion + + diff --git a/src/main/resources/intentionDescriptions/GroupFusionPaths/after.fusion.template b/src/main/resources/intentionDescriptions/GroupFusionPaths/after.fusion.template new file mode 100644 index 00000000..2fa37636 --- /dev/null +++ b/src/main/resources/intentionDescriptions/GroupFusionPaths/after.fusion.template @@ -0,0 +1,4 @@ +renderer.@process { + processor1 = Vendor.Package:StringProcessor + processor2 = ${props.prefix + value} +} \ No newline at end of file diff --git a/src/main/resources/intentionDescriptions/GroupFusionPaths/before.fusion.template b/src/main/resources/intentionDescriptions/GroupFusionPaths/before.fusion.template new file mode 100644 index 00000000..b915a14e --- /dev/null +++ b/src/main/resources/intentionDescriptions/GroupFusionPaths/before.fusion.template @@ -0,0 +1,2 @@ +renderer.@process.processor1 = Vendor.Package:StringProcessor +renderer.@process.processor2 = ${props.prefix + value} \ No newline at end of file diff --git a/src/main/resources/intentionDescriptions/GroupFusionPaths/description.html b/src/main/resources/intentionDescriptions/GroupFusionPaths/description.html new file mode 100644 index 00000000..af46698f --- /dev/null +++ b/src/main/resources/intentionDescriptions/GroupFusionPaths/description.html @@ -0,0 +1,5 @@ + + +Groups all fusion paths with the prefix before the caret into a single block. + + \ No newline at end of file diff --git a/src/main/resources/intentionDescriptions/MergeFusionPathUp/after.fusion.template b/src/main/resources/intentionDescriptions/MergeFusionPathUp/after.fusion.template new file mode 100644 index 00000000..9365fc05 --- /dev/null +++ b/src/main/resources/intentionDescriptions/MergeFusionPathUp/after.fusion.template @@ -0,0 +1,6 @@ +foo.bar = ${value} + +foo { + baz = ${value2} +} +foo.bar = ${value} diff --git a/src/main/resources/intentionDescriptions/MergeFusionPathUp/before.fusion.template b/src/main/resources/intentionDescriptions/MergeFusionPathUp/before.fusion.template new file mode 100644 index 00000000..d2b6d57e --- /dev/null +++ b/src/main/resources/intentionDescriptions/MergeFusionPathUp/before.fusion.template @@ -0,0 +1,8 @@ +foo { + bar = ${value} +} + +foo { + bar = ${value1} + baz = ${value2} +} \ No newline at end of file diff --git a/src/main/resources/intentionDescriptions/MergeFusionPathUp/description.html b/src/main/resources/intentionDescriptions/MergeFusionPathUp/description.html new file mode 100644 index 00000000..64eea9ec --- /dev/null +++ b/src/main/resources/intentionDescriptions/MergeFusionPathUp/description.html @@ -0,0 +1,12 @@ + + +Merges a fusion path to the parent block. +

+ If a fusion path (as part of an assignment or a block) is within a block, it will be moved to an assignment (or + block) with the merged path. +

+

+ If a block contains multiple paths, only the current path will be pulled out and removed from the block. +

+ + \ No newline at end of file diff --git a/src/main/resources/intentionDescriptions/SplitFusionPath/after.fusion.template b/src/main/resources/intentionDescriptions/SplitFusionPath/after.fusion.template new file mode 100644 index 00000000..de27a2cf --- /dev/null +++ b/src/main/resources/intentionDescriptions/SplitFusionPath/after.fusion.template @@ -0,0 +1,3 @@ +foo { + bar.baz = ${value} +} \ No newline at end of file diff --git a/src/main/resources/intentionDescriptions/SplitFusionPath/before.fusion.template b/src/main/resources/intentionDescriptions/SplitFusionPath/before.fusion.template new file mode 100644 index 00000000..8622b574 --- /dev/null +++ b/src/main/resources/intentionDescriptions/SplitFusionPath/before.fusion.template @@ -0,0 +1 @@ +foo.bar.baz = ${value} \ No newline at end of file diff --git a/src/main/resources/intentionDescriptions/SplitFusionPath/description.html b/src/main/resources/intentionDescriptions/SplitFusionPath/description.html new file mode 100644 index 00000000..4c298d77 --- /dev/null +++ b/src/main/resources/intentionDescriptions/SplitFusionPath/description.html @@ -0,0 +1,5 @@ + + +Splits a fusion path into nested blocks at the current path separator. + + \ No newline at end of file diff --git a/src/main/resources/messages/FusionBundle.properties b/src/main/resources/messages/FusionBundle.properties index 6867f2a4..22d20cdd 100644 --- a/src/main/resources/messages/FusionBundle.properties +++ b/src/main/resources/messages/FusionBundle.properties @@ -1,4 +1,8 @@ usage.type.definition=Definition usage.type.deleted=Deleted usage.type.instance=Instance -usage.type.inherited=Inherited \ No newline at end of file +usage.type.inherited=Inherited +intention.split.fusion.path=Split fusion path +intention.merge.fusion.path.up=Merge fusion path up +intention.group.fusion.paths=Group fusion paths +intention.group.fusion.paths.of=Group paths of "{0}" \ No newline at end of file diff --git a/src/test/java/de/vette/idea/neos/fusion/codeInsight/intention/FusionPathIntentionTest.java b/src/test/java/de/vette/idea/neos/fusion/codeInsight/intention/FusionPathIntentionTest.java new file mode 100644 index 00000000..c5e5ed75 --- /dev/null +++ b/src/test/java/de/vette/idea/neos/fusion/codeInsight/intention/FusionPathIntentionTest.java @@ -0,0 +1,40 @@ +package de.vette.idea.neos.fusion.codeInsight.intention; + +import com.intellij.codeInsight.intention.IntentionAction; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import util.FusionTestUtils; + +import java.io.File; + +public class FusionPathIntentionTest extends BasePlatformTestCase { + @Override + protected @NonNls @NotNull String getTestDataPath() { + return FusionTestUtils.BASE_TEST_DATA_PATH + File.separator + "fusion" + File.separator + "codeInsight" + File.separator + "intention"; + } + + public void testSplitFusionPath() { + doTest("splitFusionPath", "Split fusion path"); + } + + public void testMergeOnlyFusionPathUp() { + doTest("mergeOnlyFusionPathUp", "Merge fusion path up"); + } + + public void testMergeSingleFusionPathUp() { + doTest("mergeSingleFusionPathUp", "Merge fusion path up"); + } + + public void testGroupFusionPaths() { + doTest("groupPaths", "Group paths of \"renderer.@process\""); + } + + private void doTest(String testName, String hint) { + myFixture.configureByFile(testName + ".before.fusion"); + final IntentionAction action = myFixture.findSingleIntention(hint); + assertNotNull(action); + myFixture.launchAction(action); + myFixture.checkResultByFile(testName + ".after.fusion"); + } +} diff --git a/testData/fusion/codeInsight/intention/groupPaths.after.fusion b/testData/fusion/codeInsight/intention/groupPaths.after.fusion new file mode 100644 index 00000000..f589901a --- /dev/null +++ b/testData/fusion/codeInsight/intention/groupPaths.after.fusion @@ -0,0 +1,5 @@ +renderer.@process { + processor1 = Vendor.Package:StringProcessor + processor2 = ${props.prefix + value} + processor3 = ${value + props.suffix} +} diff --git a/testData/fusion/codeInsight/intention/groupPaths.before.fusion b/testData/fusion/codeInsight/intention/groupPaths.before.fusion new file mode 100644 index 00000000..a608535c --- /dev/null +++ b/testData/fusion/codeInsight/intention/groupPaths.before.fusion @@ -0,0 +1,5 @@ +renderer.@process.processor1 = Vendor.Package:StringProcessor +renderer.@process.processor2 = ${props.prefix + value} +renderer.@process { + processor3 = ${value + props.suffix} +} \ No newline at end of file diff --git a/testData/fusion/codeInsight/intention/mergeOnlyFusionPathUp.after.fusion b/testData/fusion/codeInsight/intention/mergeOnlyFusionPathUp.after.fusion new file mode 100644 index 00000000..0e95ec34 --- /dev/null +++ b/testData/fusion/codeInsight/intention/mergeOnlyFusionPathUp.after.fusion @@ -0,0 +1 @@ +foo.bar = ${value} \ No newline at end of file diff --git a/testData/fusion/codeInsight/intention/mergeOnlyFusionPathUp.before.fusion b/testData/fusion/codeInsight/intention/mergeOnlyFusionPathUp.before.fusion new file mode 100644 index 00000000..ba514cdf --- /dev/null +++ b/testData/fusion/codeInsight/intention/mergeOnlyFusionPathUp.before.fusion @@ -0,0 +1,3 @@ +foo { + bar = ${value} +} \ No newline at end of file diff --git a/testData/fusion/codeInsight/intention/mergeSingleFusionPathUp.after.fusion b/testData/fusion/codeInsight/intention/mergeSingleFusionPathUp.after.fusion new file mode 100644 index 00000000..085a03b5 --- /dev/null +++ b/testData/fusion/codeInsight/intention/mergeSingleFusionPathUp.after.fusion @@ -0,0 +1,4 @@ +foo { + baz = ${value2} +} +foo.bar = ${value} \ No newline at end of file diff --git a/testData/fusion/codeInsight/intention/mergeSingleFusionPathUp.before.fusion b/testData/fusion/codeInsight/intention/mergeSingleFusionPathUp.before.fusion new file mode 100644 index 00000000..2a1c381c --- /dev/null +++ b/testData/fusion/codeInsight/intention/mergeSingleFusionPathUp.before.fusion @@ -0,0 +1,4 @@ +foo { + bar = ${value} + baz = ${value2} +} \ No newline at end of file diff --git a/testData/fusion/codeInsight/intention/splitFusionPath.after.fusion b/testData/fusion/codeInsight/intention/splitFusionPath.after.fusion new file mode 100644 index 00000000..de27a2cf --- /dev/null +++ b/testData/fusion/codeInsight/intention/splitFusionPath.after.fusion @@ -0,0 +1,3 @@ +foo { + bar.baz = ${value} +} \ No newline at end of file diff --git a/testData/fusion/codeInsight/intention/splitFusionPath.before.fusion b/testData/fusion/codeInsight/intention/splitFusionPath.before.fusion new file mode 100644 index 00000000..6043e6a2 --- /dev/null +++ b/testData/fusion/codeInsight/intention/splitFusionPath.before.fusion @@ -0,0 +1 @@ +foo.bar.baz = ${value} \ No newline at end of file