This is a port of intellij-rust to the Consulo platform. The language support — lexer, parser, PSI, name resolution, type inference — is a Java translation of the original Kotlin. Everything that touches the IDE around it — registration, the project model, SDKs, building, running — is written against Consulo's APIs and does not resemble the original.
Read this document as two halves. Language support is essentially upstream's design and upstream's documentation still applies to it. Platform integration is where this project differs, and is the part worth reading before changing anything.
rust-platform-compat/ unsupported Consulo API shims; see its README, the goal is to delete it
rust-base/ language-agnostic foundations: stdext, openapiext, icons, localize
rust-language-api/ the Rust Language, file type and naming constants
plugin/ everything else that ships
src/main/java/org/rust/... ported language + IDE support
src/main/java/consulo/rust/... Consulo-specific integration
src/main/resources/META-INF/plugin.xml
tests/ parser tests; a plain jar module, NOT a consulo-plugin
legacy/ upstream modules not ported (clion, debugger, profiler, ...)
exampleProject/ template sources for the new-project wizard
legacy/ is kept for reference only. Nothing in it is built, and none of it is
reachable from plugin/.
Tests cannot live in a module that produces the plugin. PluginManager picks its
backend from the first PluginManagerInternal that ServiceLoader offers, so
consulo-test-junit-impl has to be declared ahead of the platform dependencies or
the light container fails with "Not initialized" before any test body runs.
Maven, not Gradle. The root pom.xml is a parent with a single module,
plugin. There is no Gradle anywhere in the project and nothing should
reintroduce it.
The consulo-maven-plugin runs four generators at generate-sources:
| goal | input | output |
|---|---|---|
generate-lexers |
org/rust/lang/core/lexer/RustLexer.flex |
JFlex lexer |
generate-parsers |
org/rust/lang/core/parser/RustParser.bnf |
Grammar-Kit parser + PSI interfaces |
generate-icon |
icon resources | icon accessor class |
generate-localize |
LOCALIZE-LIB/** |
localization accessors |
Generated sources land in plugin/target/generated-sources/parsers and are not
checked in. When a PSI interface looks wrong, regenerate before reading it —
and read it there, not anywhere else.
module-info.java declares the JPMS module consulo.rust. It is an open
module so Consulo's component container can reflect over every package. Adding a
dependency on a new Consulo API means adding a requires line there as well as
to plugin/pom.xml; a missing requires fails at runtime, not at compile time.
Consulo discovers everything through annotations. There is no XML registration
beyond the plugin descriptor itself, and IntelliJ's @Service annotation is
inert here — a class carrying it is simply never instantiated.
| annotation | purpose | count |
|---|---|---|
@ExtensionImpl |
an extension point implementation | ~289 |
@ServiceAPI / @ServiceImpl |
service interface / implementation | ~33 each |
@ActionImpl |
an action, including its menu placement | ~30 |
@TopicAPI / @TopicImpl |
message-bus topic / subscriber | 7 each |
@Inject |
constructor injection (jakarta.inject) |
~30 |
@ActionImpl carries the action's parents and anchors in the annotation, so
there is no <actions> section to keep in sync.
A Rust toolchain is a Consulo bundle (SDK), described by
consulo.rust.bundle.RustBundleType. The bundle home is the toolchain root
— a rustup toolchain directory — never a bin directory, because Consulo
watches the SDK home and watching a directory of executables is wrong.
RustBundleType detects candidates from $RUSTUP_HOME/toolchains,
~/.rustup/toolchains, and the sysroot of a rustc found on PATH. A home is
valid when it has bin/rustc, bin/cargo and lib/rustlib/src/rust. Only the
standard-library sources are attached as a root — lib/rustlib/src/rust/library
as SourcesOrderRootType — which is what makes String and the rest of std
resolve and navigate.
consulo.rust.module.extension.RustModuleExtension marks a module as Rust and
binds it to a bundle. This is the single source of truth for which toolchain to
use. Building, running, cargo invocations, the external linter and the
crates.io index refresh all resolve their toolchain through
RustModuleExtension.findToolchain(module). Nothing should search PATH for a
Rust installation on its own.
Building goes through Consulo's regular compiler API, not through a private build session.
consulo.rust.compiler.CargoCompilerRunner implements
consulo.compiler.CompilerRunner. CompileDriver selects it by asking each
registered runner checkAvailable, and it answers yes when the project has a
module with a RustModuleExtension. It then builds a cargo command line,
runs it with --message-format=json, and translates each diagnostic into a
BuildProgress event. The platform owns the progress UI, cancellation and the
build tool window.
Two things about CompilerRunner are easy to get wrong:
- the
DataContexthanded tocheckAvailableis a view over the compile scope's user data only.Project.KEYis not in it. Inject the project instead, and gate onModuleExtensionHelper.hasModuleExtension. CompilerManager.makeasserts it is called on the UI thread.
A run configuration only gets a make-before-run task if it implements
RunProfileWithCompileBeforeLaunchOption; RsCommandConfiguration does, and its
getModules() returns the Rust modules, which is what puts the build on the
module extension's toolchain. The plugin defines no BeforeRunTaskProvider
of its own — the platform's CompileStepBeforeRun covers every case, including
wasm-pack, whose different command line is produced inside the compiler runner.
Unchanged from upstream in shape: a project holds CargoProjects, each with one
CargoWorkspace, holding Packages, holding Targets. A target is what Rust
calls a crate.
CargoProject
|
CargoWorkspace
/ \
Package Package
/ \ |
Target Target Target
(main.rs) (lib.rs) (lib.rs)
CargoProject is persisted and always present; CargoWorkspace comes from
cargo metadata and is null while a project is invalid or still syncing.
Dependencies in Cargo.toml are packages with one library target; extern crate foo names a target. Target names appear in Rust code with - replaced by _
— use normName.
Workspace changes are published on CargoProjectsService.CARGO_PROJECTS_TOPIC.
Anything cached against the workspace must invalidate on it; see
RsCargoStructureInvalidator.
The parts below are upstream's design, translated. Upstream's documentation and the IntelliJ SDK docs still describe them accurately.
org.rust.lang— lexer, parser, PSI, name resolution, type inference.org.rust.cargo.api— the cargo contract the language layer is allowed to use: the workspace and project model, cfg options, settings services, toolchain data. Nothing here may reach into the rest oforg.rust.cargo.org.rust.cargo— Cargo and rustup integration: the toolchain itself, the tools that run it, run configurations, the tool window.org.rust.ide— everything the user sees: inspections, intentions, completion, navigation, refactoring.
The language layer depends on org.rust.cargo.api only. Five imports in
lang/core/macros/proc and three in RustParserDefinition still break that rule;
they are the last thing standing between the language layer and its own module.
Interfaces live in org.rust.lang.core.psi and …psi.ext; implementations,
mixins and the static Util helpers live in …psi.impl and …psi.ext.impl.
RustParser.bnf names the impl packages in its extends, mixin and
elementTypeFactory attributes, so moving a mixin means editing the grammar.
Grammar-Kit generates an interface per rule (RsStructItem) and an
implementation (RsStructItemImpl). Custom behaviour is added by having the
generated implementation extend a hand-written mixin (RsStructItemImplMixin),
or by a static helper class.
The Kotlin→Java conversion turned extension-function files into XxxUtil
classes of static methods. Where the logic was folded into a mixin instead, the
Util class may not exist at all — check the mixin first.
Two mechanisms, and knowing which one answers a given question saves a lot of time.
Def maps (org.rust.lang.core.resolve2, 58 files) build a per-crate map of
what each module declares and imports, mirroring rustc's own resolution order.
CrateDefMap holds ModData per module; ModCollector walks stubs to collect
items; DefCollector resolves the imports between them. This is what answers
"what does this module contain", including use re-exports across crates.
Lexical walk (NameResolution.java) walks PSI outwards from the reference
through enclosing scopes, and consults the def map when it reaches a module.
This is what answers "what is in scope here".
A path like Type::assoc_fn is resolved by neither directly: the qualifier is
lowered to a Ty and the associated item is found through ImplLookup, which
searches the impl index. Self:: and generic types need care — a generic type's
parameters become inference variables during resolution, and those must be
folded away before the result is cached (FoldUtil.foldTyInferWithTyPlaceholder).
Results are cached against modification trackers. Anything derived from the workspace must depend on the Rust structure modification tracker, not on the PSI tracker alone.
org.rust.lang.core.types.infer, modelled on rustc. Inference runs per
RsInferenceContextOwner (a function or constant body), walking it top-down to
build a map from expressions to types. Unknown types become variables in a
UnificationTable; trait obligations are collected and discharged through
ImplLookup.select.
Because inference is cached per function body, never call
ExtensionsUtil.getType() from inside inference — it re-enters the cached
inference of the same function and recurses without bound. Derive the type from
the declaration instead.
Stubs are a condensed AST holding what resolution needs and nothing more, stored in a compact binary form so declarations can be listed without parsing. PSI switches between stub-backed and AST-backed transparently, so an API that only works on AST will silently force a reparse.
All other indexes are built on stubs. The index is only correct per-file: an indexer may look at one file and nothing else.
Resolution reads the impl index. A lookup that runs before indexing finishes
sees no impls at all and will cache that empty answer, which is the usual cause
of a reference that is red until something invalidates it — see
RsIndexReadyInvalidator.
Working: project sync, def-map construction, the SDK and module extension, building through the compiler API, the external linter, and most navigation and highlighting.
Known gaps, in the order they matter:
- Macro expansion is a placeholder.
DeclMacroExpanderandProcMacroExpanderdo not expand. Anything generated by a derive or a function-like macro —#[derive(Parser)], for instance — does not resolve. - Trait selection is partial.
ImplLookupassembles candidates from impls and caller bounds, but the builtin-bound, projection and object-type candidate families are not ported. - Method resolution is sensitive to index timing, as described above.
- Nine parser fixtures are disabled. They parse correctly and flag the same
malformed code; they differ from the recorded trees only in where an error node
hangs or how verbose its expected-token list is. Matching them exactly needs
per-frame variant tracking in
GeneratedParserUtilBase, which every Grammar-Kit language in the platform shares. - Several IntelliJ-only features have no Consulo equivalent and were dropped deliberately: trusted-project state, dynamic plugin unloading, the macro-expansion virtual file system's global index filter.
When the platform genuinely cannot do something, say so in a comment at the point of failure rather than leaving a silent no-op.