Inside IntelliJ IDEA's Static Analysis Engine: An Architecture Note
Architecture note on IntelliJ IDEA's built-in static analysis: how the daemon engine reuses PSI for incremental in-editor feedback, trust boundaries for profiles and plugins, observable operational metrics, and failure modes that trigger redesign.
03 Nov 2025, 20:32 UTC

The Problem: Immediate Code Quality Feedback Without Leaving the Editor
Developers need to catch bugs, style violations, and architectural drift while typing—not after a CI run finishes. IntelliJ IDEA solves this with a built-in static analysis inspection engine that highlights issues inline, offers quick-fixes, and respects project-specific profiles. The engine must be fast enough for keystroke-level feedback, extensible for custom rules, and safe enough to run on untrusted codebases.
Requirements Driving the Design
- In-editor immediacy: Issues appear as annotations while editing, with quick-fix actions one keystroke away (Alt+Enter).
- Profile portability: Inspection profiles live in
.idea/inspectionProfiles/or.idea/codeStyles/, version-controlled so the team shares the same rules. - Incremental analysis: Only the changed file and its dependent PSI (Program Structure Interface) nodes are re-analyzed on save or edit.
- CI parity: The same inspection set runs headless via
inspect.sh/inspect.batfor pipeline gates.
Smallest Suitable Design
The engine runs on the IDE's background thread pool (ApplicationManager.getApplication().executeOnPooledThread()), reusing the existing PSI tree built by the language plugin. No external process spawns; inspections are registered as extension points (com.intellij.codeInspection.LocalInspectionTool) and invoked by the InspectionEngine during the "daemon" pass.
// Minimal inspection plugin registration in plugin.xml
Each inspection implements buildVisitor() returning a PsiElementVisitor that walks the PSI. The engine caches visitor results per file; on edit, only the dirty PSI subtree is revisited.
Trust and Data Boundaries
| Boundary | Policy |
|---|---|
| Source files | Read-only access to locally indexed PSI; no network I/O. |
| Inspection profiles | Trusted only when loaded from version-controlled project settings (.idea/). User-level profiles (config/inspection/) are treated as local overrides. |
| Third-party plugins | Run in the same JVM; no sandbox by default. Plugins invoking external tools (e.g., SpotBugs, Checkstyle via CLI) cross the boundary and must be vetted. |
| Exported reports | HTML/XML/JSON output from inspect.sh contains only issue metadata—no source code snippets unless explicitly configured. |
Operational Checks You Can Observe
- Annotation gutter: Open a Java file, introduce an unused import. The yellow/red marker appears instantly; hover shows description and quick-fix.
- Statistics dialog:
View → Tool Windows → Statistics→ "Inspections" tab. Shows per-inspection invocation count, total time, and memory delta since last GC. - Profile switch latency: In
Settings → Editor → Inspections, toggle between "Project Default" and a custom profile. The editor re-highlights within ~200 ms for a 50 KLOC module—no full reindex. - Headless parity test: Run
./bin/inspect.sh /path/to/project /path/to/profile.xml /tmp/out -v2and compare issue counts with the IDE's "Analyze → Inspect Code…" dialog.
Failure Modes and When the Design Breaks
Stale PSI from External Changes
If a file is modified by an external tool (e.g., code generator, git checkout) while the IDE is focused, the PSI may not reflect disk state until the next file-watcher event. Inspections then produce false positives (missing symbols) or false negatives (removed issues still highlighted).
Mitigation: FileDocumentManager.getInstance().reloadFiles() or "File → Synchronize" forces re-parse. For heavy external workflows, enable "Save files on frame deactivation" (Settings → Appearance & Behavior → System Settings).
Heap Pressure in Monorepos
The PSI tree for a 500 KLOC Java module can consume 1.5–2 GB. Default heap (-Xmx2g) leaves little room for inspections running concurrently with indexing.
Check: In Statistics, watch "Used memory" during "Analyze → Inspect Code… Whole Project". If it exceeds 80% of -Xmx, increase heap in idea.vmoptions or enable "Power Save Mode" to pause background inspections.
Third-Party Plugins Spawning Processes
Plugins like "SonarLint" or "Checkstyle-IDEA" may launch external JVMs per file. This violates the lightweight boundary: CPU spikes, non-deterministic latency, and potential supply-chain risk if the external tool reads arbitrary paths.
Redesign trigger: If >10% of inspection time is spent in ProcessBuilder.start(), migrate to in-process APIs (e.g., Checkstyle's Checker API) or sandbox the plugin via a separate JVM with a security manager.
Conditions That Would Change the Architecture
- Language Server Protocol adoption: If LSP-based diagnostics become the primary contract, the daemon engine could delegate to a language server process, shifting trust boundaries to the LSP provider.
- Distributed indexing: For repos >1 MLOC, offloading PSI construction to a remote build farm (like Bazel's remote execution) would require serializing PSI deltas—currently not supported.
- Sandboxed plugin runtime: A future JetBrains platform version introducing WASM or GraalVM isolation for plugins would let third-party inspections run with restricted FS/network caps without redesigning the core engine.
Verification Checklist
- Open a project, enable "Project Default" inspection profile.
- Edit a Java file: add
import java.util.List;without usage. Verify yellow highlight and "Remove unused import" quick-fix. - Switch to a custom profile that disables "Unused import". Confirm highlight disappears without reindex.
- Open Statistics, run "Analyze → Inspect Code…" on the module. Note inspection time < 5 s for 50 KLOC.
- Run headless
inspect.shwith the same profile; diff issue counts against IDE report.
Limitations
- This note covers the JVM-language inspection pipeline (Java, Kotlin, Groovy). JavaScript/TypeScript inspections use a separate ESLint/TSLint integration with different trust boundaries.
- Memory figures assume JDK 17+ with default GC (G1). ZGC or Shenandoah may change heap pressure thresholds.
- External-tool plugins are not audited here; each must be evaluated for its own process-spawning behavior.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.