name: extract-module description: Extract an optional dependency from a plugin module into a new content module. Use when making a library dependency optional by separating integration code into its own module.
Extracting an Optional Dependency into a New Content Module
Use this guide when making a dependency of a plugin module optional by moving it into a separate content module.
Goal: the host module continues to work when the new module is absent, and behaves identically when it is present.
When to Use Each Pattern
Pattern A — Simple move
All code that touches dependency X is isolated in a few files with no callers inside the host module. Move those files wholesale into the new module.
Pattern B — Extension Point (EP)
The host module's core files contain scattered references to X. Introduce an EP interface in the host module (no X imports), implement it in the new module, and replace direct X calls with null-safe static helpers.
Steps
1. Create the module directory
plugins/<plugin-name>/<module-dir>/
resources/
intellij.<module-name>.xml ← module descriptor
src/
com/intellij/<...>/ ← sources (directory must match package)
intellij.<module-name>.iml ← module definition
2. Write the .iml
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="jetbrains-annotations" level="project" />
<orderEntry type="module" module-name="intellij.<host-module>" />
<!-- add other required modules -->
</component>
</module>
Rules:
- Never use
packagePrefixon<sourceFolder>. Use a matching directory structure instead. .imlfiles are serialized in canonical form — no trailing newline, no reformatting, no reordering.
Kotlin stdlib — mandatory for any Kotlin module
intellij.platform.core.impl must be a direct IML dependency of every new Kotlin module. It is the only module in the dependency graph that explicitly exports kotlin-stdlib, making the stdlib available on the Bazel compilation classpath. Without it, the Kotlin compiler fails with:
Cannot access built-in declaration 'kotlin.Any'. Ensure that you have a dependency on the Kotlin standard library.
This error is deceptive: it looks like a single missing type, but it means the entire stdlib is absent, so virtually every Kotlin annotation (@JvmStatic, @Throws, checkNotNull, ::class.java, …) and all built-in types fail simultaneously.
Common class-to-module mapping
Bazel strict-deps requires every class to be in a direct IML dependency. The following classes live in modules that differ from what their package names suggest:
| Class(es) | Package | IML module | Bazel target |
|---|---|---|---|
ExecutionException, GeneralCommandLine, KillableColoredProcessHandler, OSProcessHandler, ScriptRunnerUtil, Url, NetUtils |
com.intellij.execution.*, com.intellij.util.* |
intellij.platform.ide.util.io |
@community//platform/platform-util-io:ide-util-io |
AsyncPromise, Promise |
org.jetbrains.concurrency |
intellij.platform.concurrency |
@community//platform/util/concurrency |
AppExecutorUtil, ProcessAdapter, ProcessEvent, ProcessOutputTypes, ParametersListUtil, FileUtil, StringUtil |
com.intellij.util.*, com.intellij.openapi.util.* |
intellij.platform.util |
@community//platform/util |
Editor |
com.intellij.openapi.editor |
intellij.platform.editor.ui |
@community//platform/editor-ui-api |
MultipleLangCommentProvider |
com.intellij.psi.templateLanguages |
intellij.platform.lang.impl |
@community//platform/lang-impl |
Note: the IML module name for AsyncPromise/Promise is intellij.platform.concurrency (not intellij.platform.util.concurrency), even though the source lives under platform/util/concurrency/.
If the new module calls a method whose return type comes from a third module (e.g. a method returning ImmutableList<String>), that third module (intellij.libraries.guava) must also be a direct IML dep — Kotlin's type checker needs to verify the return type at compile time.
3. Write the module descriptor XML
Add visibility="public" to the <idea-plugin> root if any class in the new module is used directly by another plugin (not just another module within the same plugin). For example, if a class is subclassed or called from a plugin with a separate <plugin id="...">:
<idea-plugin visibility="public">
...
</idea-plugin>
Without this, the other plugin cannot load the class even if it declares the module as a dependency.
The generator manages a <dependencies> region. For dependencies the generator doesn't produce automatically (e.g. a plugin dependency that was removed from the host), declare them before the <!-- region --> marker, inside the same <dependencies> tag:
<idea-plugin>
<dependencies>
<plugin id="com.example.some-plugin"/> <!-- manual: not emitted by generator -->
<!-- region Generated dependencies - run `Generate Product Layouts` to regenerate -->
<module name="intellij.<host-module>"/>
<!-- ... -->
<!-- endregion -->
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<!-- register extensions here -->
</extensions>
</idea-plugin>
Do not create a second standalone <dependencies> block before the region — this makes the file "out of sync" and breaks AllProductsPackagingTest#suiteValidations.
4. Package and source file conventions
- Always create Kotlin files for new code. But when you just move the file, please keep it the same type it was (Kotlin or Java).
- Source files must live in a directory matching their package:
src/com/intellij/foo/bar/MyClass.ktfor packagecom.intellij.foo.bar. - Package names must follow the
com.<module-name>convention:- Module
intellij.foo.bar→ packagecom.intellij.foo.bar
- Module
IntelliJProjectPackageNamesTestenforces this. Do not add exceptions tonon-standard-root-packages.txtfor new modules.- Preserve freemium availability: both the host module and the new content module must have the same availability in IDEA Free mode as the original module had. If the original module was available in free mode, both new modules must remain available there. If the original was not available in free mode, neither should the new modules be.
PluginsAvailableInIdeaFreeModeTestenforces this.
5. Declare the EP in the host module (Pattern B only)
In the host module's XML, declare the EP with qualifiedName:
<extensionPoints>
<extensionPoint qualifiedName="com.intellij.<language>.<epName>"
interface="com.intellij.<language>.<feature>.MyFeatureHelper"
dynamic="true"/>
</extensionPoints>
qualifiedName format: com.intellij.<language/framework, lowercase>.<epNameLikeThisOne>
Write the EP interface in the host module (no X imports). Provide @JvmStatic companion helpers that gracefully return no-op defaults when the EP is absent:
interface MyFeatureHelper {
fun doSomething(element: PsiElement): Boolean
companion object {
@JvmField
val EP = ExtensionPointName.create<MyFeatureHelper>("com.intellij.<language>.<epName>")
@JvmStatic fun getInstance(): MyFeatureHelper? = EP.extensionList.firstOrNull()
@JvmStatic fun checkSomething(element: PsiElement?): Boolean =
if (element == null) false else getInstance()?.doSomething(element) ?: false
}
}
Register the implementation in the new module's XML:
<extensions defaultExtensionNs="com.intellij">
<<language>.<epName> implementation="com.intellij.<language>.<feature>.MyFeatureHelperImpl"/>
</extensions>
Multi-method EP bundling and opaque state pattern
When separating a library whose operations form a lifecycle (capture some state, then use it later), bundle all related operations into one EP rather than creating one EP per method. This avoids proliferating EP registrations and keeps the implementation cohesive.
When lifecycle EP methods need to pass typed state between calls (e.g., capture a List<CssSelectorSuffix> in step 1, consume it in step 2), but the host module cannot import that type, use Any? as the state type. The EP interface uses Any?; the implementation casts internally with @Suppress("UNCHECKED_CAST"):
// In host module (no X imports)
interface MyLifecycleHelper {
fun captureState(file: PsiFile): Any? // returns X-typed data, opaque to host
fun applyState(file: PsiFile, state: Any?) // receives it back; casts inside impl
companion object {
val EP = ExtensionPointName.create<MyLifecycleHelper>("com.intellij.<language>.myLifecycleHelper")
fun captureState(file: PsiFile): Any? = EP.extensionList.firstOrNull()?.captureState(file)
fun applyState(file: PsiFile, state: Any?) = EP.extensionList.firstOrNull()?.applyState(file, state)
}
}
// In new CSS/X module
internal class MyLifecycleHelperImpl : MyLifecycleHelper {
override fun captureState(file: PsiFile): Any? = getThings(file) // returns List<XThing>
override fun applyState(file: PsiFile, state: Any?) {
@Suppress("UNCHECKED_CAST")
val things = state as? List<XThing> ?: emptyList()
// use things...
}
}
6. Register the new module in the plugin
plugin/resources/META-INF/plugin.xml — add a <module> content entry.
plugin/plugin-content.yaml — add a jar entry.
.idea/modules.xml — register the new module (follow the existing entries).
7. Fix downstream consumers of the removed transitive dependency
Removing a dependency from the host module may break modules that relied on it transitively. AllProductsPackagingTest#targetValidations will report which ones.
Fix: add explicit deps to their plugin XML in the manual section before the <!-- region --> marker:
<dependencies>
<module name="intellij.some.formerly.transitive.module"/>
<!-- region Generated dependencies ... -->
...
<!-- endregion -->
</dependencies>
Also check other plugins. If the moved code was a superclass or utility called from a different plugin, that plugin will fail to compile. For each such plugin:
- Add
<module name="intellij.new.module"/>to its plugin XML. - Add
<orderEntry type="module" module-name="intellij.new.module" />to its.iml. - Ensure
visibility="public"is set on the new module's XML (see step 3).
Kotlin open — required when a class is subclassed from outside the module
Kotlin classes are final by default. If the moved class is:
- subclassed by another module or plugin, or
- has an inner class that is anonymously subclassed elsewhere,
both the outer class and the relevant inner class must be marked open:
open class MyStrategy : BaseStrategy() {
// ...
open class MyTokenizer : BaseTokenizer() {
protected open fun shouldSkip(element: MyElement): Boolean { ... }
}
}
Forgetting open produces cannot inherit from final class compile errors in the downstream plugin.
8. git add all new files
New files are untracked. Add them explicitly before running tests:
git add plugins/<plugin-name>/<new-module>/
Required Commands After Changes
After any .iml change
./build/jpsModelToBazel.cmd
After any .iml, plugin XML, or module structure change
./bazel.cmd run //platform/buildScripts:plugin-model-tool
Expected: ✓ All files unchanged. If files change, inspect them — the generator may be removing deps you set incorrectly, or adding ones you missed.
Required tests
# Validates packaging: runtime deps available, generated XMLs in sync
./tests.cmd --module intellij.idea.ultimate.build.tests \
--test "com.intellij.idea.ultimate.build.smokeTests.AllProductsPackagingTest"
# Validates package naming: com.<module-name> convention
./tests.cmd --module intellij.projectStructureTests \
--test "com.intellij.ideaProjectStructure.fast.IntelliJProjectPackageNamesTest"
# Validates plugin availability in IDEA Free mode
./tests.cmd --module intellij.projectStructureTests \
--test "com.intellij.idea.ultimate.build.smokeTests.PluginsAvailableInIdeaFreeModeTest"
All three must pass before the work is done.
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
packagePrefix in .iml |
IntelliJProjectPackageNamesTest finds wrong root package |
Remove packagePrefix; use matching directory structure |
| Package doesn't match module name | IntelliJProjectPackageNamesTest fails |
Rename to com.<module-name> and move files |
EP registered with name instead of qualifiedName |
EP not found | Use qualifiedName="com.intellij.<language>.epName" |
Manual <dependencies> block as a separate tag (not inside the region's block) |
AllProductsPackagingTest#suiteValidations: "Generated file is out of sync" |
Merge into one <dependencies> block; manual entries go before <!-- region --> |
New files not git added |
Build/tests miss new sources | git add new module directory |
Skipped plugin-model-tool |
Generated XML has stale/missing deps | Run ./bazel.cmd run //platform/buildScripts:plugin-model-tool |
| New source file created as Java | Style violation | Always use .kt for new code |
| Removed transitive dep breaks callers | AllProductsPackagingTest#targetValidations fails |
Add explicit <module name="..."/> to affected modules' plugin XMLs |
| Cross-plugin subclass of moved class | cannot inherit from final class in another plugin |
Mark the class (and subclassable inner classes) open; add visibility="public" to the new module XML; add module dep to the other plugin's IML and plugin.xml |
| File moved but package declaration not updated | IntelliJProjectPackageNamesTest: "packages [com.old.pkg] are found in the module" |
Change the package declaration and physically move the file to the matching directory |
| File moved but physical directory not moved | IDE confused, search finds file in two places | Always move both the file AND update its package declaration |
| Nullable override mismatch after Java→Kotlin conversion | 'override' overrides nothing |
Match the Kotlin override param types exactly — if the base class uses platform types (unannotated Java), both nullable and non-null work; but if a subclass uses ? the base must too |
return@label in val lambda |
Unresolved label 'myLabel' |
Labels only work at the call site. Use .let { ... } chaining instead |
| Kotlin interface constant access from Java | cannot find symbol CONSTANT_NAME |
Use explicit class qualification: MyInterface.CONSTANT_NAME |
Kotlin-defined fun getXxx() not auto-exposed as property |
Unresolved reference 'xxx' |
Call with explicit (): element.getXxx() |
object : JavaInterface() with parentheses |
This type does not have a constructor |
Interfaces have no constructor; use object : JavaInterface without () |
| Top-level Kotlin function imported from Java | cannot find symbol myFunction |
From Java the class is MyFileKt; use import static com.pkg.MyFileKt.myFunction |
| Supertype cascade after removing a dep | Cannot access 'com.X.BaseClass' which is a supertype of 'SubClass' — even though BaseClass is never directly imported |
Kotlin needs the full supertype chain of every used type. If you use SubClass (from dep Y) whose supertype BaseClass lives in dep X, removing X breaks compilation even without direct X imports. Fix: move the SubClass usage entirely into the EP implementation in the new module, and expose only a non-X return type (e.g. Language instead of PostCssLanguage) through the EP interface |
| Class hierarchy access fails after removing a dep | cannot access BaseClass: class file not found |
Subclasses of platform types may transitively require the platform dep; keep it even without direct imports |
| Broad downstream breakage after large file move | Many unrelated modules fail to compile | After moving 10+ files, build //plugins/... //contrib/... immediately to find all broken consumers |
Examples in the JavaScript Plugin
intellij.javascript.regexp(Pattern A) — movedJSRegexpInjector,JSRegexpHost,JSRegExpModifierProviderout ofjavascript-backendto make the regexp dependency optional.intellij.javascript.backend.css(Pattern B) — introducedJsCssIntegrationHelperEP injavascript-backend, implemented in the new module. Also movedJavaScriptCssUsagesProvider,JQueryCssElementDescriptorProvider,JQueryCssInspectionSuppressor. Downstream fixes required injavascript-ultimate,jsf-core,webpack.intellij.javascript.backend.spellchecker(Pattern A) — moved all spellchecker-related files out ofjavascript-backend. Requiredvisibility="public"because CoffeeScript plugin (a separate plugin) subclassesJSSpellcheckingStrategy— both the class and its inner tokenizer had to be markedopenafter Java→Kotlin conversion.javascript-grazierequired a manual dep added outside the generated region.intellij.javascript.backend.xml(Pattern A + B) — largest extraction to date (~40 files). Pattern A: moved all JSX/HTML/injection files wholesale. Pattern B: introducedJsXmlContextHelperEP (interface + static dispatch companion) for scatteredinstanceof XmlTag/XmlElementchecks across ~60 core files. Requiredvisibility="public". Downstream fixes required injavascript-ultimate,jsf-core,webpack,flex,vuejs,svelte,reactand others. Note: not all XML IML deps could be removed fromjavascript-backend— some remained due to indirect class hierarchy usage.
Examples in the Vue Plugin
intellij.vuejs.backend.css(Pattern B, 3 EPs) — removed all CSS plugin dependencies fromintellij.vuejs.backend. Three EPs introduced:VueCssLanguageProvider— exposesgetCssLanguage(),getDefaultStyleLanguage(),getStyleCommenter(). Implemented byVueCssLanguageProviderImplusingCSSLanguage.INSTANCE,PostCssLanguage.INSTANCE, andPostCssCommentProvider. ThegetDefaultStyleLanguage()/getStyleCommenter()methods were needed becausePostCssLanguageextendsCssLanguageProperties(supertype cascade): even removing a directPostCssLanguagereference left the compiler needingintellij.css.common. The fix was to move allPostCssLanguageusage into the EP implementation and returnLanguage(notPostCssLanguage) across the boundary.VueCssExtractHelper— multi-method lifecycle EP using opaqueAny?state.captureUnusedStyles(file): Any?returns aList<CssSelectorSuffix>opaquely;optimizeStyles(file, state: Any?)casts it back with@Suppress("UNCHECKED_CAST")inside the impl. This keptCssSelectorSuffix(fromintellij.css.analysis) entirely within the CSS module.VueCssBindingHelper— single-method EP wrappingCssClassInJSLiteralOrIdentifierReferenceProvider.getClassesFromEmbeddedContent()to remove theintellij.javascript.web.cssdep from the host.