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

feat: add move prototype refactoring #323

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package de.vette.idea.neos.lang.fusion.refactoring;

import com.intellij.codeInsight.FileModificationService;
import com.intellij.ide.util.EditorHelper;
import com.intellij.notification.NotificationGroupManager;
import com.intellij.notification.NotificationType;
import com.intellij.openapi.application.WriteAction;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.NlsContexts;
import com.intellij.openapi.util.Ref;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.VfsUtil;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.*;
import com.intellij.psi.impl.source.tree.LeafPsiElement;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.refactoring.BaseRefactoringProcessor;
import com.intellij.refactoring.util.CommonRefactoringUtil;
import com.intellij.usageView.BaseUsageViewDescriptor;
import com.intellij.usageView.UsageInfo;
import com.intellij.usageView.UsageViewDescriptor;
import com.intellij.util.PathUtil;
import de.vette.idea.neos.lang.fusion.FusionBundle;
import de.vette.idea.neos.lang.fusion.psi.FusionFile;
import de.vette.idea.neos.lang.fusion.psi.FusionPrototypeSignature;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class MovePrototypeProcessor extends BaseRefactoringProcessor {

private final String myTitle;
private final @Nullable PsiFile myTargetFile;
private final PsiElement[] myAffectedElements;
private final boolean myOpenInEditor;
private @Nullable String myTargetFilePath = null;

public MovePrototypeProcessor(
@NotNull Project project,
String title,
@NotNull PsiFile targetFile,
Iterable<FusionPrototypeSignature> signaturesToMove,
boolean openInEditor
) {
super(project);
this.myTitle = title;
this.myTargetFile = targetFile;
this.myAffectedElements = MovePrototypeProcessor.collectAffectedElements(signaturesToMove, project);
this.myOpenInEditor = openInEditor;
}

public MovePrototypeProcessor(
@NotNull Project project,
String title,
@NotNull String targetFilePath,
Iterable<FusionPrototypeSignature> signaturesToMove,
boolean openInEditor
) {
super(project);
this.myTitle = title;
this.myTargetFile = getOrCreateFileFromPath(targetFilePath);
this.myTargetFilePath = targetFilePath;
this.myAffectedElements = MovePrototypeProcessor.collectAffectedElements(signaturesToMove, project);
this.myOpenInEditor = openInEditor;
}

private static boolean isMultilineComment(@Nullable PsiElement element) {
return element instanceof PsiComment && element.getText().startsWith("/*");
}

private @Nullable FusionFile getOrCreateFileFromPath(String targetFilePath) {
String path = FileUtil.toSystemIndependentName(targetFilePath);
VirtualFile file = LocalFileSystem.getInstance().findFileByPath(path);
if (file != null) {
PsiFile psiFile = PsiManager.getInstance(myProject).findFile(file);
return psiFile instanceof FusionFile ? (FusionFile) psiFile : null;
}

String fileName = PathUtil.getFileName(path);
Ref<VirtualFile> fileRef = Ref.create();
CommandProcessor.getInstance().executeCommand(myProject, () -> {
try {
WriteAction.run(() -> {
VirtualFile parentDir = VfsUtil.createDirectories(PathUtil.getParentPath(path));
fileRef.set(parentDir.createChildData(this, fileName));
});
} catch (IOException e) {
CommonRefactoringUtil.showErrorMessage(myTitle, FusionBundle.message("refactoring.move.prototype.error.creating.file", e.getMessage()), null, myProject);
}
}, FusionBundle.message("refactoring.move.prototype.create.file", fileName), "movePrototypeRefactoring");

if (fileRef.isNull()) {
return null;
}

PsiFile psiFile = PsiManager.getInstance(myProject).findFile(fileRef.get());
return psiFile instanceof FusionFile ? (FusionFile) psiFile : null;
}

public static PsiElement[] collectAffectedElements(Iterable<FusionPrototypeSignature> prototypes, Project project) {
List<PsiElement> elements = new ArrayList<>();
for (FusionPrototypeSignature prototype : prototypes) {
elements.addAll(getPsiElementsForPrototypeSignature(prototype, project));
}
return elements.toArray(PsiElement.EMPTY_ARRAY);
}

private static List<PsiElement> getPsiElementsForPrototypeSignature(FusionPrototypeSignature prototype, Project project) {
PsiElement topLevelElement = prototype;
while (!(topLevelElement.getParent() instanceof PsiFile) && topLevelElement.getParent() != null) {
topLevelElement = topLevelElement.getParent();
}
PsiElement firstElement = topLevelElement;

// collect preceding comments
do {
PsiElement prevSibling = firstElement.getPrevSibling();
if (prevSibling instanceof PsiWhiteSpace) {
// between multiline-comments and a prototype seems to be a whitespace
if (isMultilineComment(prevSibling.getPrevSibling())) {
firstElement = prevSibling.getPrevSibling();
}
break;
} else if (prevSibling instanceof PsiComment) {
firstElement = prevSibling;
} else {
break;
}
} while (true);

// collect elements to move in correct order
List<PsiElement> elementsToMove = new ArrayList<>();
while (firstElement != null) {
elementsToMove.add(firstElement);
if (firstElement == topLevelElement) {
break;
}
firstElement = firstElement.getNextSibling();
}
elementsToMove.add(PsiParserFacade.getInstance(project).createWhiteSpaceFromText("\n"));

return elementsToMove;
}

@Override
protected @NotNull UsageViewDescriptor createUsageViewDescriptor(UsageInfo @NotNull [] usages) {
return new BaseUsageViewDescriptor(myAffectedElements);
}

@Override
protected UsageInfo @NotNull [] findUsages() {
return new UsageInfo[0];
}

@Override
protected void performRefactoring(UsageInfo @NotNull [] usages) {
PsiFile targetFile = myTargetFile != null
? myTargetFile
: myTargetFilePath != null ? getOrCreateFileFromPath(myTargetFilePath) : null;
if (targetFile == null) {
// there could be other errors as well, but we assume, they have been validated before
if (myTargetFilePath != null) {
CommonRefactoringUtil.showErrorMessage(myTitle, FusionBundle.message("refactoring.move.prototype.error.creating.file", myTargetFilePath), null, myProject);
return;
}
return;
}
// we assume that everything is from the same file. we could alternatively go over everything by signature
PsiFile originalFile = myAffectedElements[0].getContainingFile();
FileModificationService.getInstance().preparePsiElementsForWrite(originalFile, targetFile);

if (targetFile.getChildren().length != 0) {
targetFile.add(PsiParserFacade.getInstance(myProject).createWhiteSpaceFromText("\n"));
}

PsiElement firstElement = null;
int movedElements = 0;

for (PsiElement element : myAffectedElements) {
PsiElement newElement = targetFile.add(element);
if (PsiTreeUtil.findChildOfType(element, FusionPrototypeSignature.class) != null) {
movedElements++;
}
if (firstElement == null) {
firstElement = newElement;
}
}

// deleting the elements when moving creates an error, as parent elements for subsequent elements may be deleted
for (PsiElement element : myAffectedElements) {
// there were a lot of issues with deleting elements, so we try to go over them kind of gracefully
if (element instanceof PsiWhiteSpace) {
continue;
}
if (element instanceof LeafPsiElement && ((LeafPsiElement) element).getTreeParent() == null) {
continue;
}
try {
element.delete();
} catch (Throwable e) {
// ignore
}
}

if (myOpenInEditor && firstElement != null) {
EditorHelper.openInEditor(firstElement);
}

String message = FusionBundle.message("refactoring.move.prototype.0.moved.elements", movedElements);
NotificationGroupManager.getInstance()
.getNotificationGroup("Neos")
.createNotification(message, NotificationType.INFORMATION)
.notify(myProject);
}

@Override
protected @NotNull @NlsContexts.Command String getCommandName() {
String path = myTargetFile != null ? myTargetFile.getVirtualFile().getPath() : myTargetFilePath;
return FusionBundle.message("refactoring.move.prototype.move.to", path);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package de.vette.idea.neos.lang.fusion.refactoring;

import com.intellij.lang.Language;
import com.intellij.openapi.actionSystem.DataContext;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiFile;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.refactoring.RefactoringActionHandler;
import com.intellij.refactoring.actions.BaseRefactoringAction;
import de.vette.idea.neos.NeosProjectService;
import de.vette.idea.neos.lang.fusion.FusionLanguage;
import de.vette.idea.neos.lang.fusion.psi.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public class MovePrototypeToFile extends BaseRefactoringAction implements RefactoringActionHandler {

@Override
protected boolean isAvailableInEditorOnly() {
return true;
}

@Override
protected boolean isAvailableForFile(PsiFile file) {
if (!(file instanceof FusionFile)) {
return false;
}

if (findAllPrototypeSignatures(file).isEmpty()) {
return false;
}

return super.isAvailableForFile(file);
}

@Override
protected boolean isEnabledOnElements(PsiElement @NotNull [] psiElements) {
for (PsiElement element : psiElements) {
if (!(element instanceof FusionPrototypeSignature)) {
return false;
}
}
return true;
}

@Override
protected boolean isAvailableForLanguage(Language language) {
return language == FusionLanguage.INSTANCE;
}

@Override
protected @Nullable RefactoringActionHandler getHandler(@NotNull DataContext dataContext) {
return this;
}

@Override
public void invoke(@NotNull Project project, Editor editor, PsiFile psiFile, DataContext dataContext) {
List<FusionPrototypeSignature> selectedSignatures = new ArrayList<>();
editor.getCaretModel().getAllCarets().forEach(caret -> {
PsiElement element = psiFile.findElementAt(caret.getOffset());
PsiElement signature = PsiTreeUtil.findFirstParent(element, e -> e instanceof FusionPrototypeSignature);
if (isTopLevelPrototype(signature)) {
selectedSignatures.add((FusionPrototypeSignature) signature);
}
});
List<FusionPrototypeSignature> allSignatures = findAllPrototypeSignatures(psiFile);
startRefactoring(project, selectedSignatures, allSignatures);
}

@Override
public void invoke(@NotNull Project project, PsiElement @NotNull [] psiElements, DataContext dataContext) {
// not sure when this is called

List<FusionPrototypeSignature> selectedSignatures = new ArrayList<>();
List<FusionPrototypeSignature> allSignatures = new ArrayList<>();
Set<PsiFile> visitedFiles = new HashSet<>();
for (PsiElement element : psiElements) {
if (!isTopLevelPrototype(element)) {
continue;
}
selectedSignatures.add((FusionPrototypeSignature) element);
PsiFile file = element.getContainingFile();
if (visitedFiles.contains(file)) {
continue;
}
visitedFiles.add(file);
allSignatures.addAll(findAllPrototypeSignatures(file));
}

startRefactoring(project, selectedSignatures, allSignatures);
}

private void startRefactoring(Project project , List<FusionPrototypeSignature> selectedSignatures, List<FusionPrototypeSignature> allSignatures) {
if (allSignatures.isEmpty()) {
NeosProjectService.getLogger().debug("No prototypes found");

return;
}

MovePrototypeDialog dialog = new MovePrototypeDialog(project, allSignatures, selectedSignatures);
dialog.show();
}

public static List<FusionPrototypeSignature> findAllPrototypeSignatures(PsiFile psiFile) {
List<FusionPrototypeSignature> signatures = new ArrayList<>(PsiTreeUtil.findChildrenOfType(psiFile, FusionPrototypeSignature.class));
return signatures.stream().filter(MovePrototypeToFile::isTopLevelPrototype).collect(Collectors.toList());
}

/**
* Determines whether the prototype definition is an override on some path or not.
*/
private static boolean isTopLevelPrototype(@Nullable PsiElement prototypeSignature) {
if (!(prototypeSignature instanceof FusionPrototypeSignature)) {
return false;
}

PsiElement parent = prototypeSignature.getParent();
while (parent != null) {
// TODO: there might be a better way to check this
if (parent instanceof FusionBlock || parent instanceof FusionPrototypeSignature) {
return false;
}
parent = parent.getParent();
}

return true;
}
}
3 changes: 3 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -167,5 +167,8 @@
<add-to-group group-id="EditorPopupMenu" anchor="first"/>
<add-to-group group-id="ProjectViewPopupMenu" anchor="first"/>
</action>
<action id="de.vette.idea.neos.fusion.refactoring.MovePrototypeToFile" class="de.vette.idea.neos.lang.fusion.refactoring.MovePrototypeToFile" text="Move Prototype">
<add-to-group group-id="RefactoringMenu"/>
</action>
</actions>
</idea-plugin>
13 changes: 12 additions & 1 deletion src/main/resources/messages/FusionBundle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
usage.type.definition=Definition
usage.type.deleted=Deleted
usage.type.instance=Instance
usage.type.inherited=Inherited
usage.type.inherited=Inherited
refactoring.move.prototype.title=Move Prototype
refactoring.move.prototype.target.file=Move to
refactoring.move.prototype.no.prototypes.selected=No prototype selected to be moved
refactoring.move.prototype.source.target.files.should.be.different=Source and target files should be different.
refactoring.move.prototype.target.file.not.specified=Target file not specified.
refactoring.move.prototype.target.not.a.fusion.file=Target file is not a fusion file.
refactoring.move.prototype.error.creating.file=Error creating file:\n{0}
refactoring.move.prototype.create.file=Create file ''{0}''
refactoring.move.prototype.moving.between.packages=Target file seems to be a different package (moving from {0} to {1})!
refactoring.move.prototype.move.to=Move prototypes to file ''{0}''
refactoring.move.prototype.0.moved.elements=Moved {0, choice, 1#prototype|2#{0} prototypes}.
Loading
Loading