Extract optional plugin dependencies into IntelliJ content modules.
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
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:
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
If a class in the new module is used directly by another plugin (not just another module within the same plugin), set the module's visibility on the <idea-plugin> root. Choose the level by who the consumer is:
visibility="internal" — the consumer is another plugin inside this monorepo (a separate <plugin id="...">, e.g. a class subclassed or called from it). This is the common case.
visibility="public" — the class is part of API meant for external third-party plugins.
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><pluginid="com.example.some-plugin"/><!-- manual: not emitted by generator --><!-- region Generated dependencies - run `Generate Product Layouts` to regenerate --><modulename="intellij.<host-module>"/><!-- ... --><!-- endregion --></dependencies><extensionsdefaultExtensionNs="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.kt for package com.intellij.foo.bar.
Package names must follow the com.<module-name> convention:
IntelliJProjectPackageNamesTest enforces this. Do not add exceptions to non-standard-root-packages.txt for 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. PluginsAvailableInIdeaFreeModeTest enforces this.
5. Declare the EP in the host module (Pattern B only)
In the host module's XML, declare the EP with qualifiedName:
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:
interfaceMyFeatureHelper {
fundoSomething(element: PsiElement): Booleancompanionobject {
@JvmFieldval EP = ExtensionPointName.create<MyFeatureHelper>("com.intellij.<language>.<epName>")
// An EP can have several registered extensions — process all of them, not just the first.@JvmStaticfuncheckSomething(element: PsiElement?): Boolean =
element != null && EP.extensionList.any { it.doSomething(element) }
}
}
Register the implementation in the new module's XML:
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)interfaceMyLifecycleHelper {
funcaptureState(file: PsiFile): Any? // returns X-typed data, opaque to hostfunapplyState(file: PsiFile, state: Any?)// receives it back; casts inside implcompanionobject {
val EP = ExtensionPointName.create<MyLifecycleHelper>("com.intellij.<language>.myLifecycleHelper")
funcaptureState(file: PsiFile): Any? = EP.extensionList.firstOrNull()?.captureState(file)
funapplyState(file: PsiFile, state: Any?) = EP.extensionList.firstOrNull()?.applyState(file, state)
}
}
// In new CSS/X moduleinternalclassMyLifecycleHelperImpl : MyLifecycleHelper {
overridefuncaptureState(file: PsiFile): Any? = getThings(file) // returns List<XThing>overridefunapplyState(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.
Project module files — register the new module with the helper; it updates .idea/modules.xml, updates community/.idea/modules.xml for community modules, preserves canonical entry order, and removes the .iml trailing newline:
bun build/jps-module.mjs register plugins/<plugin-name>/<module-dir>/intellij.<module-name>.iml --fix-iml-eof
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><modulename="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:
Run them one at a time. tests.cmd kills leftover processes by name on startup (Killing process containing subpath 'ide-tests'), so a concurrent run shoots down the one already in flight — which surfaces as an unrelated-looking failure such as AllProductsPackagingTest#build.
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 -->
Module registered by appending to modules.xml
noisy project-file diff or project-structure drift
Use bun build/jps-module.mjs register <path-to-iml> --fix-iml-eof; order is by .iml basename
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
Two tests.cmd runs at once
A test that passes on its own fails, typically AllProductsPackagingTest#build; log shows Killing process containing subpath 'ide-tests'
Run the required tests one at a time — each tests.cmd kills leftover ide-tests processes on startup, including a run still in flight
Examples in the JavaScript Plugin
intellij.javascript.regexp (Pattern A) — moved JSRegexpInjector, JSRegexpHost, JSRegExpModifierProvider out of javascript-backend to make the regexp dependency optional.
intellij.javascript.backend.css (Pattern B) — introduced JsCssIntegrationHelper EP in javascript-backend, implemented in the new module. Also moved JavaScriptCssUsagesProvider, JQueryCssElementDescriptorProvider, JQueryCssInspectionSuppressor. Downstream fixes required in javascript-ultimate, jsf-core, webpack.
intellij.javascript.backend.spellchecker (Pattern A) — moved all spellchecker-related files out of javascript-backend. Required visibility="public" because CoffeeScript plugin (a separate plugin) subclasses JSSpellcheckingStrategy — both the class and its inner tokenizer had to be marked open after Java→Kotlin conversion. javascript-grazie required 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: introduced JsXmlContextHelper EP (interface + static dispatch companion) for scattered instanceof XmlTag/XmlElement checks across ~60 core files. Required visibility="public". Downstream fixes required in javascript-ultimate, jsf-core, webpack, flex, vuejs, svelte, react and others. Note: not all XML IML deps could be removed from javascript-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 from intellij.vuejs.backend. Three EPs introduced:
VueCssLanguageProvider — exposes getCssLanguage(), getDefaultStyleLanguage(), getStyleCommenter(). Implemented by VueCssLanguageProviderImpl using CSSLanguage.INSTANCE, PostCssLanguage.INSTANCE, and PostCssCommentProvider. The getDefaultStyleLanguage()/getStyleCommenter() methods were needed because PostCssLanguage extends CssLanguageProperties (supertype cascade): even removing a direct PostCssLanguage reference left the compiler needing intellij.css.common. The fix was to move all PostCssLanguage usage into the EP implementation and return Language (not PostCssLanguage) across the boundary.
VueCssExtractHelper — multi-method lifecycle EP using opaque Any? state. captureUnusedStyles(file): Any? returns a List<CssSelectorSuffix> opaquely; optimizeStyles(file, state: Any?) casts it back with @Suppress("UNCHECKED_CAST") inside the impl. This kept CssSelectorSuffix (from intellij.css.analysis) entirely within the CSS module.
VueCssBindingHelper — single-method EP wrapping CssClassInJSLiteralOrIdentifierReferenceProvider.getClassesFromEmbeddedContent() to remove the intellij.javascript.web.css dep from the host.