On IDEA Plugin Development
Given that the team’s code formatting plugin was far too outdated, I undertook an upgrade and rewrite. The new implementation switched the core formatting library, added Kotlin language support, and introduced several static code inspection (Inspections) features. Here I document my understanding of IDEA Plugin development and my first experience with it.
Why IDEA Plugins?
Focusing on Android development, I see the necessity of IDEA Plugin development in two areas: standardization and developer efficiency.
As a local development tool, every team member can customize their IDE configuration. If different developers use different coding standards and conventions, code management chaos is inevitable. Nobody wants to pull the latest remote code and face a flood of conflicts due to inconsistent formatting.
Beyond formatting, development teams also have various standards around code security. For example, use of java.util.Random (pseudo-random) or Color.parseColor() potentially throwing IllegalArgumentException when parsing unknown content. If these issues aren’t caught during development, the workload piles up at Code Review — or worse, reaches production. The accumulation of such trivial issues creates hidden risks for code quality and security, impacting daily development efficiency.
All these problems can be solved through custom IDEA Plugins. In summary, IDEA Plugin development aims to meet a specific team’s specific pain points around coding standards, architectural constraints, and unique workflows, thereby improving engineering efficiency.
PSI
PSI (Program Structure Interface) is a core concept in the IntelliJ platform — an abstract representation of code. Think of it as analogous to Android’s View hierarchy. In Android, all UI elements are abstracted as View objects organized in a tree structure; you traverse the View tree to find, modify, or operate on UI elements. Similarly, PSI parses source code (Java, Kotlin files) into an abstract syntax tree (AST) where each node is a PsiElement representing a code structure — class, method, variable, expression, etc.
Specifically, PsiElement is the base class of the PSI hierarchy. For example:
PsiFile: Represents a source file — the PSI tree rootPsiClass: Represents a class or interfacePsiMethod: Represents a methodPsiExpression: Represents an expression, e.g.,new Random()orColor.parseColor("#FFFFFF")PsiReferenceExpression: Represents a reference expression, e.g., the method name in a method callPsiNewExpression: Represents anewkeyword object creationPsiMethodCallExpression: Represents a method call expression
PSI allows structured access, analysis, and modification of code without directly manipulating text. It’s like using findViewById to find a Button and calling setText() instead of modifying XML strings directly.
In plugin development, PsiDocumentManager is commonly used to get the PsiFile for the current document. For example, PsiDocumentManager.getInstance(project).getPsiFile(document) retrieves the PsiFile associated with a Document. With the PsiFile, you can use PSI APIs to traverse and analyze code structure for inspections, refactoring, formatting, and more.
Format
Previously, the plugin maintained formatting rules internally. After the upgrade, I delegated concrete formatting rules to mature open-source libraries — google-java-format for Java, ktfmt for Kotlin — and rewrote the formatting logic to be as concise as possible for better maintainability. The flow has three phases: register, listen, format.
On startup, FormatInstaller registers a custom FormatCodeStyleManager as the project’s CodeStyleManager. It wraps IntelliJ IDEA’s native CodeStyleManager and decides whether to use custom formatting logic or delegate to the native manager based on file type.
By implementing DocumentManagerListener, the plugin listens for pre-save events. When a user saves a Java or Kotlin file, the beforeDocumentSaving callback triggers formatting.
public class DocumentManagerListener implements FileDocumentManagerListener { @Override public void beforeDocumentSaving(@NotNull Document document) { // ... get current Project and PsiFile if (psiFile != null && (psiFile.getName().endsWith(".java") || psiFile.getName().endsWith(".kt"))) { ApplicationManager.getApplication().invokeLater(() -> { new WriteCommandAction.Simple(project) { @Override protected void run() { CodeStyleManagerDecorator.getInstance(project).reformatText(psiFile, Collections.singletonList(psiFile.getTextRange())); ApplicationManager.getApplication().invokeLater(() -> FileDocumentManager.getInstance().saveDocument(document)); } }.execute(); }); } }}Upon receiving a format request, FormatCodeStyleManager calls the appropriate formatting tool based on file type (Java or Kotlin) and obtains the formatted result. All replacements execute within a WriteCommandAction for atomicity.
private void formatInternal(PsiFile file, Collection<? extends TextRange> ranges) { if (JavaFileType.INSTANCE.equals(file.getFileType())) { performReplacements(document, JavaFormatterUtil.getReplacements(new Formatter(), document.getText(), ranges)); } else if (KotlinFileType.INSTANCE.getName().equals(file.getFileType().getName())) { performReplacements(document, KotlinFormatterUtil.getReplacements(KotlinUiFormatterStyle.GOOGLE, document.getText())); }}
private void performReplacements(final Document document, final Map<TextRange, String> replacements) { if (replacements.isEmpty()) return; TreeMap<TextRange, String> sorted = new TreeMap<>(comparing(TextRange::getStartOffset)); sorted.putAll(replacements); WriteCommandAction.runWriteCommandAction(getProject(), () -> { for (Entry<TextRange, String> entry : sorted.descendingMap().entrySet()) { document.replaceString(entry.getKey().getStartOffset(), entry.getKey().getEndOffset(), entry.getValue()); } PsiDocumentManager.getInstance(getProject()).commitDocument(document); });}Inspection
The plugin also includes a series of static code inspections to help team members catch potential issues during development. This parallels Android Lint’s goal — providing feedback at compile or development time to prevent issues from reaching later stages.
An Inspection typically extends AbstractBaseJavaLocalInspectionTool and overrides buildVisitor to return a PsiElementVisitor. This Visitor traverses the PSI tree and executes check logic when visiting specific PsiElement types. When problems are found, ProblemsHolder.registerProblem registers a ProblemDescriptor, which highlights the issue in the IDE and can include a LocalQuickFix for auto-repair.
Since most Inspections are business-specific, I’ll highlight two purely technical checks.
Random Pseudo-Random Number
Purpose: Detect usage of java.util.Random. For security-sensitive scenarios, java.security.SecureRandom is recommended.
Implementation: Override JavaElementVisitor.visitNewExpression to check if the PsiNewExpression creates a java.util.Random instance.
Quick Fix (RandomQuickFix): Replaces new java.util.Random() with new java.security.SecureRandom() and auto-imports SecureRandom.
@Overridepublic void visitNewExpression(PsiNewExpression expression) { super.visitNewExpression(expression); String qualifiedName = null; PsiJavaCodeReferenceElement classReference = expression.getClassReference(); if (classReference != null) { qualifiedName = classReference.getQualifiedName(); } if ("java.util.Random".equals(qualifiedName)) { holder.registerProblem( expression, INSPECTION_DESCRIPTION, INSPECTION_TYPE, new RandomQuickFix()); }}
private static class RandomQuickFix implements LocalQuickFix { @Override public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { PsiElementFactory factory = JavaPsiFacade.getElementFactory(project); PsiExpression newExpression = factory.createExpressionFromText("new java.security.SecureRandom()", descriptor.getPsiElement()); PsiElement replacedElement = descriptor.getPsiElement().replace(newExpression); JavaCodeStyleManager codeStyleManager = JavaCodeStyleManager.getInstance(project); codeStyleManager.shortenClassReferences(replacedElement); codeStyleManager.optimizeImports(replacedElement.getContainingFile()); }}Color.parseColor()
Purpose: When Color.parseColor() parses an unknown-content variable, recommend wrapping it in a try-catch for IllegalArgumentException to improve robustness.
Implementation: Override JavaElementVisitor.visitMethodCallExpression to check for Color.parseColor calls. If the argument isn’t a constant/literal and the call isn’t wrapped in try-catch, register the problem. isWrappedInTryCatch traverses up the PSI tree looking for PsiTryStatement.
Quick Fix (ColorParseFix): Wraps the Color.parseColor() call in a try-catch block.
@Overridepublic void visitMethodCallExpression(PsiMethodCallExpression expression) { super.visitMethodCallExpression(expression); // ... get methodExpression if ("Color.parseColor".equals(method)) { PsiExpression argument = argumentList.getExpressions()[0]; if (!PsiUtil.isConstantExpression(argument) && !(argument instanceof PsiLiteralExpression) && !isWrappedInTryCatch(expression)) { holder.registerProblem( expression, INSPECTION_DESCRIPTION, INSPECTION_TYPE, new ColorParseFix()); } }}
private boolean isWrappedInTryCatch(PsiElement element) { // ... traverse up PSI tree looking for PsiTryStatement}
private static class ColorParseFix implements LocalQuickFix { @Override public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor problemDescriptor) { // ... create try-catch block and replace original statement }}Android Studio Adaptation
Android Studio is built on the IntelliJ IDEA platform. To allow plugin access to JDK internal APIs (which libraries like google-java-format may depend on), specific --add-opens and --add-exports options must be added to JVM startup parameters. Insufficient JVM options may cause InaccessibleObjectException errors, breaking formatting functionality. Different IDEA platform versions handle these JVM parameters differently.
For platform baseline version >= 213, directly call VMOptions.setOption() for the required --add-opens and --add-exports parameters.
For baseline versions < 213, manually modify the VM Options file — read its contents, append missing parameters, and ensure the file is writable.
if (version >= 213) { VMOptions.setOption("--add-opens=java.base/java.lang", "=ALL-UNNAMED"); VMOptions.setOption("--add-opens=java.base/java.util", "=ALL-UNNAMED"); // ...} else { Path path = VMOptions.getWriteFile(); if (path == null) { return; } File file = path.toFile(); try { String content = FileUtil.loadFile(file); if (!content.contains("--add-opens=java.base/java.lang=ALL-UNNAMED")) { content = content + "\n" + "--add-opens=java.base/java.lang=ALL-UNNAMED\n" + "--add-opens=java.base/java.util=ALL-UNNAMED\n" + // ... FileUtil.writeToFile(file, content); } } catch (IOException ignored) { }}