Blog

Tuesday, 7 August 2012

POM Sorter IntelliJ Idea plugin

Do you like Rearranger plugin for IntelliJ Idea, the one that helps you sort members of Java classes? Don't you miss such great functionality for Maven project descriptor? We've got something for you. The Pom Sorter plugin.
Why would you like to sort pom.xml? There are several reasons. We found out that when we want to update some dependency, plugin or profile in one of our projects, then it would be great to move such change to all other projects. But with messed up poms where every developer puts dependencies where they want it is a nightmare. Having all poms sorted in the same way would make the task as easy as comparing 2 poms with Idea's diff tool.

We've created simple plugin for Idea to sort poms. Right now it has got fixed ordering of tags, but this will be customizable soon. Downloads are available at IntelliJ's plugin repository. Sources are available at https://github.com/it-crowd/pom-sorter.

Writing this plugin tought me couple of cool things about Idea, which I'd like to share with you. Generally plugin consists of 2 classes:
  • PomSorter - a project component that will do the sorting
  • SortPomAction - an action that we bind to context menu
Of course there is plugin.xml that we use to tell idea what class is for what.

So plugin.xml tells idea that we've got project scoped component called PomSorter (each class, even from different plugins will be able to reach it by calling project.getComponent(PomSorter.class)). We also register SortPomAction and attach it to ToolsXmlGroup and XmlGenerateToolsGroup groups. First one is in toolbar and other is in editor's context menu.
public class SortPomAction extends AnAction {
// -------------------------- OTHER METHODS --------------------------

    @Override
    public void actionPerformed(AnActionEvent event)
    {
        final Project project = event.getProject();
        if (project == null) {
            Messages.showWarningDialog("Cannot obtain project from AnActionEvent", "Invalid State");
            return;
        }
        final PsiFile psiFile = event.getData(LangDataKeys.PSI_FILE);
        if (psiFile == null) {
            Notifications.inform("No file selected", "Please select POM.xml first", project);
            return;
        }
        final PsiFile xmlFile = psiFile.getViewProvider().getPsi(StdLanguages.XML);
        if (xmlFile == null || !(xmlFile instanceof XmlFile)) {
            Notifications.inform("Selected file is not XmlFile", "Please select POM.xml first", project);
            return;
        }
        final Editor editor = DataKeys.EDITOR.getData(event.getDataContext());
        project.getComponent(PomSorter.class).sortFile((XmlFile) xmlFile, editor == null ? null : editor.getDocument());
    }

    @Override
    public void update(AnActionEvent e)
    {
        final Presentation presentation = e.getPresentation();
        final PsiFile psiFile = e.getData(LangDataKeys.PSI_FILE);
        if (psiFile == null) {
            presentation.setEnabledAndVisible(false);
            return;
        }
        final PsiFile xmlFile = psiFile.getViewProvider().getPsi(StdLanguages.XML);
        if (xmlFile == null || !(xmlFile instanceof XmlFile)) {
            presentation.setEnabledAndVisible(false);
            return;
        }
        presentation.setEnabledAndVisible(true);
    }
}
As you can see in actionPerformed method we extract psiFile from event context. If Action is invoked from editor context then psi file should be there. Then we try to obtain XML view of PSI structure to make sure we work with XML file. Finally we delegate all the job to PomSorter.
In order to disable our action in case no file is selected or it is not XML file we override update method and setup "Presentation" properly.

Normally, as a Java coder, I'd open pom with some InputStreamReader, read it, and then sort it somehow. But since this is Idea plugin we can, and should leverage it's great (oh so great) PSI model. In this model each file is represented as tree structure, so we can have a node that is Java class, it has child nodes that represent methods, attributes and others. Same goes for XML and other file types supported by Idea.

Let's look at entry point to our PomSorter:
    public void sortFile(final XmlFile xmlFile, final Document document)
    {
        final XmlTag rootTag = xmlFile.getRootTag();
        if (rootTag != null) {
            ApplicationManager.getApplication().runWriteAction(new Runnable() {
                public void run()
                {
                    final PsiDocumentManager psiDocumentManager = PsiDocumentManager.getInstance(project);
                    psiDocumentManager.commitDocument(document);
                    xmlFile.accept(new PomSortVisitor());
                    CodeStyleManager.getInstance(rootTag.getProject()).reformat(rootTag);
                    psiDocumentManager.commitDocument(document);
                }
            });
        }
    }
Main job is performed in line 10 by PomSortVisitor. It iterates over all tags and sort's them. What is important here is to run this within WriteAction. Other thing is to commit the document before and after our changes, so they are reflected in change history.
    private class PomSortVisitor extends XmlRecursiveElementVisitor {
// -------------------------- OTHER METHODS --------------------------

        @Override
        public void visitXmlTag(XmlTag tag)
        {
            super.visitXmlTag(tag);
            final String tagName = tag.getName();
            if ("project".equals(tagName)) {
                sortProject(tag);
            } else if ("dependencies".equals(tagName)) {
                sortDependencies(tag);
            } else if ("plugins".equals(tagName)) {
                sortPlugins(tag);
            } else if ("plugin".equals(tagName)) {
                sortPlugin(tag);
            } else if ("dependency".equals(tagName)) {
                sortDependency(tag);
            } else if ("build".equals(tagName)) {
                sortBuild(tag);
            } else if ("profile".equals(tagName)) {
                sortProfile(tag);
            } else if ("execution".equals(tagName)) {
                sortExecution(tag);
            } else {
                sortByChildTagName(tag);
            }
        }
    }
Above you can see PomSortVisitor, which is inner class of PomSorter. super.visitXmlTag(tag) will continue traversal through the PSI tree. Then we check what tag are we currently in and we sort it appropriately. Let's look at sortProject method. It simply delegates to sortChildren and passes projectChildrenComparator, a comparator that extends FixedOrderComparator. FixedOrderComparator is also inner class of PomSorter and we will present it below. What sortChildren method does is creating ArrayList of XmlTags that are children of currenlty sorted tag. Then we sort that list and remove current tag's children. If there is any text within the tag (i.e. module, groupId) then we put if first and then we add sorted child tags that we've previously removed. What is important here is that we cannot add exactly the same instances we've removed because they are not valid anymore. We'd hit some nasty exceptions. Instead of that we need to call tag.createChildTag method (tag is the one we sort, a parent). Other interesting thing is that when I was invoking tag.add then while I was debugging the plugin everything worked well but when I installed plugin and run in in normal Idea instance then the order was not kept. I had to invoke tag.addAfter instead.
    private static final Map<String,Integer> PROJECT_CHILDREN_PRIORITY = new HashMap<String,Integer>();

    static {
        int i = 0;
        PROJECT_CHILDREN_PRIORITY.put("modelVersion", i++);
        PROJECT_CHILDREN_PRIORITY.put("parent", i++);
        PROJECT_CHILDREN_PRIORITY.put("groupId", i++);
        PROJECT_CHILDREN_PRIORITY.put("artifactId", i++);
        PROJECT_CHILDREN_PRIORITY.put("version", i++);
        //...
    }

    private FixedOrderComparator projectChildrenComparator = new FixedOrderComparator(PROJECT_CHILDREN_PRIORITY);

    private void sortProject(XmlTag tag)
    {
        sortChildren(tag, projectChildrenComparator);
    }
    private void sortChildren(XmlTag tag, Comparator<XmlTag> comparator)
    {
        if (tag == null) {
            return;
        }
        final List<XmlTag> xmlTags = new ArrayList<XmlTag>();
        final XmlTag[] subTags = tag.getSubTags();
        Collections.addAll(xmlTags, subTags);
        Collections.sort(xmlTags, comparator);
        final XmlTagValue xmlTagValue = tag.getValue();
        final XmlTagChild[] children = xmlTagValue.getChildren();
        if (children.length > 0) {
            tag.deleteChildRange(children[0], children[children.length - 1]);
        }
        final StringBuilder stringBuilder = new StringBuilder();
        for (XmlText xmlText : xmlTagValue.getTextElements()) {
            stringBuilder.append(xmlText.getText().trim());
        }
        tag.getValue().setText(stringBuilder.toString());
        XmlTag previousChildTag = null;
        for (XmlTag childTag : xmlTags) {
            final XmlTag xmlTag = tag.createChildTag(childTag.getName(), null, childTag.getValue().getText(), false);
            previousChildTag = (XmlTag) tag.addAfter(xmlTag, previousChildTag);
        }
    }
And here is our FixedOrderComparator. It uses map that tells what is order of particular tag. If priority is the same then we compare names.
    private class FixedOrderComparator implements Comparator {
// ------------------------------ FIELDS ------------------------------

        private final NullComparator nullComparator = new NullComparator();

        private final Map<String,Integer> priority;

// --------------------------- CONSTRUCTORS ---------------------------

        private FixedOrderComparator(Map<String,Integer> priority)
        {
            this.priority = priority;
        }

// ------------------------ INTERFACE METHODS ------------------------


// --------------------- Interface Comparator ---------------------

        @Override
        public int compare(XmlTag a, XmlTag b)
        {
            Integer priorityA = priority.get(a.getName());
            if (priorityA == null) {
                priorityA = priority.get(null);
            }
            Integer priorityB = priority.get(b.getName());
            if (priorityB == null) {
                priorityB = priority.get(null);
            }
            if (priorityA.equals(priorityB)) {
                return nullComparator.compare(a.getName(), b.getName());
            } else {
                return priorityA.compareTo(priorityB);
            }
        }
    }
This is it for now. In next releases of the plugin we'll try to allow user to configure ordering for each tag and keep comments (right now all comments are removed). Enjoy and give feedback.

No comments:

Post a Comment