Flix language support and breakpoint debugging for the wstein/flix-fork Flix compiler build, for IntelliJ-based IDEs.
Completion, diagnostics and hover come from the same flix lsp language server the official VS
Code extension downloads -- no reimplementation -- via LSP4IJ's LSP client. Debugging runs
on IntelliJ's own JVM debugger rather than a separate debug protocol, so one session covers Flix
and every other JVM language in the same process: step from Flix into Java or Kotlin and back, with
each language's own breakpoints, source navigation and evaluator.
IntelliJ's own LSP Client API covers language features, but is available only to plugins in IntelliJ-based IDEs under conditions LSP4IJ does not impose, and running both clients against one server would mean two clients for one language. LSP4IJ is therefore the sole LSP client here.
LSP4IJ also ships a generic DAP client. This plugin does not use it: debugging goes through IntelliJ's own JVM debugger instead (ADR 0002), which is what lets one session cover Flix and every other JVM language in the same process.
- Language features (LSP): live-verified -- syntax highlighting, diagnostics and completion via
flix lsp, through LSP4IJ. - Flix language support (PSI): a real
Language("Flix"), file type, Grammar-Kit/JFlex parser and PSI, syntax highlighter, brace matcher, commenter, quote handler and folding, adopted fromintellij-flixper ADR 0001. The adopted grammar parses 427 of 427 compilable files in the upstream Flix corpus, up from 211 at adoption; see the parser corpus evaluation. This replaced the bundled TextMate fallback, which a real file type deactivates. - Debugging: IntelliJ's own JVM debugger, per ADR 0002. The gate is Green -- Flix and Java breakpoints in one session, stepping in both directions, mixed stack navigation, Java locals and evaluation, and a Step Over that stops on the next line of the same Flix definition rather than descending into everything the line calls.
- Project tasks:
build,check,test,run,clean,doc,build-jar,build-fatjar,build-pkg,outdatedandinit, under Tools > Flix and as a Flix Task run configuration. Not terminal wrappers: each runs through the platform's own execution machinery, so Stop kills the compiler, the exit code reaches the Run widget, the task can be re-run and saved, and the line numbers in a diagnostic are links into the source. Registered from the language module, so they stay available in an IDE with neither LSP4IJ nor the Java plugin. The subcommand names are checked againstflix --helpbyFlixTaskTestrather than reviewed — an unknown one is not rejected by the compiler, it is demoted to a file argument and reported asUnrecognized file extension. - Show AST: Tools > Flix > Show AST.
ShowAstProviderhas been in the compiler all along, reachable only throughlsp/showAston Flix's VS Code protocol, which negotiates no capabilities and which no other client speaks — the same shape the diagram was in. The server now serves it as a realworkspace/executeCommandreturning the directory it wrote, and the action opens what is in it. - Live templates: fifteen — declarations (
def,pdef,mod,enum,struct,trait,instance,eff,test) and expression scaffolding (region,try,run,foreach,rules,query). Deliberately not anything the server derives from a symbol: its two hand-written snippets aremainand a default effect handler, andMagicMatchCompleterexpandsx.matchinto one case per constructor ofx's enum, which no template could do. Every expansion is parsed by this plugin's own parser inFlixLiveTemplateSyntaxTestrather than reviewed — which caught thedeftemplate expanding todef f(): Unit =, leaving the buffer unparseable until the body was typed, and the language server reporting that as errors. - Settings: Settings > Languages & Frameworks > Flix — extra JVM arguments and extra Flix
arguments, matching the VS Code extension's
flix.extraJvmArgsandflix.extraFlixArgs. They reach the language server and every task, in the two positions the compiler's argument parser requires. Deliberately not the run/debug configuration, which has its own fields: a debug launch already passes--Xdebug, and a second occurrence is rejected outright asUnknown option --Xdebug. - Code lenses: both of them.
flix.runMainabove an entry point is live-verified and its symbol argument is honoured;flix.cmdTestsabove a test runs the test task. The second one used to fail with "Missing 'flix.cmdTests' command" because LSP4IJ resolves a server-defined command throughActionManagerand only the first had an action —FlixAssembledPluginTestnow pins both. - Gutter run arrow beside
def main: anchored on the declaration's name leaf, delegating to the platform's genericExecutorAction. - Split Mode: live-verified as an actual frontend+backend process pair
(
./gradlew runIdeSplitMode), with everything registered backend-side and the frontend a thin client. One real bug found getting here: an XML comment containing--made the entire plugin fail to load on both sides, whileverifyPluginProjectConfigurationandbuildPluginboth passed.verifyPluginStructuredoes detect it, but does not fail the build over it -- its output has to be read, not just its exit code.
Language support works: syntax, parsing, folding, the LSP features, the gutter marker. Only the debugger module needs the Java plugin, and it is declared optional, so an IDE without one skips it and loads the rest rather than failing. Debugging is the single feature that is unavailable there — not the plugin.
Not yet verified -- see verification coverage for the full matrix, which is explicit about what has been measured and what has not. The two that matter most: the native run configuration has never been exercised in a live session, and breakpoints on some Flix lines do not bind.
Press Debug on a .flix file, or use the gutter arrow beside def main. There is no setup
step: the Flix run configuration is created from the declaration under the caret.
What happens is deliberately unremarkable. The configuration launches the Flix compiler with
run --Xdebug --yes and a JDWP agent on a free port, and IntelliJ's own Java debugger attaches to
it. This plugin speaks no debug protocol; it decides which port to listen on and hands that to the
platform.
--Xdebug is not optional, and is not only a JDWP switch. The compiler emits line numbers for
let, calls, if and statement sequences only under it, and it turns the optimizer off
entirely. Without it most statements have no breakpointable line at all, and a breakpoint on one can
never verify no matter what the IDE does.
Turning the optimizer off is what keeps small functions breakpointable. A function whose body is
folded into its caller gets no class of its own, and its line does not survive at the call site
either — the inlined body starts at the call site's own bytecode offset, and only one line entry per
offset reaches the class file. The line then exists nowhere in the program, which is the fate of
every single-expression helper: pub def maxDemo(): Int32 \ IO = Math.max(10, 20) could never take
a breakpoint while its multi-line neighbours could. The cost is that a debug session runs
unoptimized, which is the trade every other toolchain makes.
In order: FLIX_JAR in the project's .envrc, then the $FLIX_JAR environment variable, then the
compiler a flixw wrapper has pinned, then flix.jar in the
project root. Every process the plugin starts -- the language server, a flix subcommand and the
debuggee -- resolves it the same way, so a debug session cannot run a different compiler than the
editor was analysed with.
The .envrc is parsed, never executed. direnv requires an explicit direnv allow before it
will evaluate one, and opening a project in an editor is not that consent; the understood subset is
export NAME=VALUE, and a value produced by a command substitution or guarded by a conditional is
simply not seen. A flixw wrapper is read the same way -- its .flixw/lock.toml and cache layout,
not ./flixw info -- so cloning a repository never runs a script it ships.
A wrapper also pins a JDK, and the plugin honours FLIX_JAVA_HOME and the JDK flixw installed
before falling back to java on PATH. It deliberately does not reproduce flixw's full search:
one of flixw's steps is "the JVM flixw is running on", and the JVM the IDE runs on is not the
terminal's.
The build matters, not just the flag. Four fixes in wstein/flix-fork are load-bearing for debugging, and a jar predating any of them behaves as though the plugin is at fault:
| Fix | Without it |
|---|---|
One LineNumberTable entry per bytecode offset |
A line sharing a bytecode offset with another cannot take a breakpoint. Its neighbours can, and javap still shows the line, so nothing looks wrong. |
| The declaration line yields its offset to the first statement | The first statement of every function has no line entry at all, so a breakpoint on it never binds — while the def line above it and every later line do. |
The optimizer is off under --Xdebug |
A single-expression function is folded into its caller, gets no class of its own, and its line survives nowhere — so pub def maxDemo(): Int32 \ IO = Math.max(10, 20) can never take a breakpoint. |
| Workspace jars loaded from the folder URI | The language server resolves no [jar-dependencies], so every Java import reports "Undefined Java class" while the compiler builds the same project without complaint. |
If breakpoints on some lines refuse to bind while adjacent ones work, rebuild the fork before looking anywhere else.
Check the class before suspecting the plugin:
javap -l -p 'build/class/Def$yourFunction.class' | grep -A20 LineNumberTableA line absent from the table is a compiler-invocation problem, not an IDE one. If the line is
there, turn on the plugin's own logging -- sandbox IDEs launched by runIde/runIdeSplitMode
already have it enabled -- and read which step declined:
tail -f .intellijPlatform/sandbox/*/IU-*/system*/log/idea.log | grep dev.wsteinThe gate runbook has a table mapping each log line to its cause.
flix-lab is the companion project: a Flix workspace with VS
Code tooling, and the fixture this plugin is developed against. It carries a Greeter in Java,
Kotlin, Scala, Groovy and JRuby, all called from Main.flix, which is what makes mixed-language
debugging testable.
The two projects no longer share code. They did: a FlixDebugAdapter.java DAP↔JDI bridge was
vendored here and kept in sync by a script. That path was retired once IntelliJ's own JVM debugger
proved out (ADR 0002), so the adapter now lives only in flix-lab, serving its VS Code
client. What both projects still share is the compiler: the same flix-vendor-*.jar build of
wstein/flix-fork.
This repository implements a modular IntelliJ Platform plugin using content modules:
.
├── .github/ GitHub Workflows, issue templates, and Dependabot configuration
├── .qodana/profiles/ Qodana plugin inspections profile
├── .run/ Predefined Run/Debug Configurations
├── language/ Language module -- Flix Language, FileType, parser, PSI, editor support
│ ├── build.gradle.kts Grammar-Kit: generates the lexer/parser/PSI from src/main/grammar
│ └── src/
│ ├── main/
│ │ ├── grammar/ Flix.bnf, _Flix.flex, Flix.tokens.txt (adopted; see NOTICE)
│ │ ├── kotlin/org/flixlang/intellij/ language, editor, highlighting, gutter marker
│ │ └── resources/flix.jetbrains.plugin.language.xml
│ └── test/kotlin/ parser corpus gate, parsing, recovery, folding, gutter anchoring
├── debugger/ Debugger module -- Flix source positions for IntelliJ's JVM debugger
│ ├── build.gradle.kts depends on the Java plugin; optional, so non-Java IDEs still load
│ └── src/
│ ├── main/kotlin/dev/wstein/flixplugin/
│ │ ├── debugger/ PositionManager, stepping policy, source lookup
│ │ └── run/ the Flix run/debug configuration and its context producer
│ └── test/kotlin/ JDI-stub tests for the dual-mode SMAP mapping rules
├── backend/ Backend module -- LSP4IJ server registration, flix.runMain, gutter marker
│ ├── build.gradle.kts LSP4IJ dependency
│ └── src/
│ ├── main/
│ │ ├── java/dev/wstein/flixplugin/ Flix*.java (LSP factory, run action, jar resolution)
│ │ └── resources/flix.jetbrains.plugin.backend.xml module descriptor
│ └── test/java/dev/wstein/flixplugin/ FlixForkTest
├── frontend/ Frontend module -- placeholder, no genuinely frontend-only UI yet
├── shared/ Shared module -- compiler-jar resolution and launch command
├── src/
│ ├── main/resources/META-INF/plugin.xml Root descriptor, declares the content modules
│ └── test/kotlin/ FlixPluginDescriptorTest -- registration-wiring invariants
├── buildSrc/ Build logic -- the integration-glue contract checker
├── flix-integration.yaml Cross-module wiring contract; checked by `./gradlew checkIntegrationGlue`
├── build.gradle.kts Root build -- assembles the final plugin, splitMode = true
├── gradle.properties
└── settings.gradle.kts
Content module descriptors (flix.jetbrains.plugin.backend.xml etc.) use
<dependencies><plugin id="..."/></dependencies> for external-plugin dependencies -- the classic
<depends> tag from the root plugin.xml is explicitly disallowed inside a module descriptor
(confirmed against the Modular Plugins documentation).
./gradlew buildPluginProduces build/distributions/flix.jetbrains.plugin-<version>.zip.
./gradlew testFlixForkTest uses HeavyPlatformTestCase (a project backed by real files on disk), not the
lighter BasePlatformTestCase (an in-memory VFS project) -- FlixFork.resolveJar does plain
java.io/java.nio.file calls against project.getBasePath(), which only resolve against a real
directory.
A content-module descriptor is inert on its own: the platform reads it only when the root
plugin.xml names it in <content>. A module-local test fixture therefore never loads our
<extensions>, and every LanguageBraceMatching.forLanguage-style lookup resolves null. Rather
than assert wiring through a fixture that cannot represent it, the suite splits along that line:
- Behaviour is asserted against the implementations directly, on real parsed PSI -- folding regions, brace pairs, commenter prefixes, gutter anchoring, incremental reparse.
- Wiring is asserted by
FlixPluginDescriptorTestagainst the descriptors themselves -- every content module declared, every extension naming a class that exists and implements its extension point's interface, exactly one file type claiming*.flix, and every descriptor well-formed.
Each failure then names one cause instead of two. The well-formedness check earns its place: --
inside an XML comment is invalid, is not caught by verifyPluginProjectConfiguration or
buildPlugin, and makes the entire plugin fail to load with only "contains invalid plugin
descriptor" to go on.
FlixCorpusTest parses every .flix file the Flix compiler accepts -- 243 under
main/src/library and 185 under examples -- and requires all of them to parse cleanly,
losslessly and without crashing. It needs a Flix checkout, and skips when there is none, so CI
without one still passes:
./gradlew test -PflixCorpusDir=/path/to/flixFLIX_DIR and -DflixCorpusDir work too, and ~/github.com/flix/flix is tried by default. See
the evaluation for what it measured and the one file it
excludes.
| Configuration name | Description |
|---|---|
| Run IDE with Plugin (Frontend) | Runs :runIdeFrontend. Use the Debug icon for plugin debugging. |
| Run IDE with Plugin (Backend) | Runs :runIdeBackend. Use the Debug icon for plugin debugging. |
| Run IDE with Plugin (Split Mode) | Runs both simultaneously to launch the plugin in split mode. |
No JetBrains Marketplace listing currently -- consistent with flix-lab's VS Code extension being
"private": true / sideloaded. Install the built zip manually via Settings/Preferences → Plugins →
gear icon → Install Plugin from Disk...
You'll also need LSP4IJ installed from the Marketplace (it's a <plugin> dependency of
the backend module; IntelliJ should prompt for it).
Generator-provided scaffolding, unmodified: Build and
Release workflows, issue templates,
Dependabot config, and a Qodana inspections profile
(.qodana/profiles/plugin.yaml, run locally via ./gradlew qodanaScan, requires Docker). None of
these have been exercised yet (no CI run, no Qodana scan) -- they're present and should work per
the generator's defaults, but that's unverified.
Two architecture decisions shape the plugin, both recorded in docs/adr/:
- ADR 0001 -- one
Language("Flix"), adopted fromintellij-flixrather than reimplemented. Two language owners means two parsers and two file types, and which one wins depends on load order. - ADR 0002 -- IntelliJ's own Java debugger is the sole JDWP owner. Two debuggers cannot share one debuggee: they compete for suspension, breakpoints and lifecycle.
Status. Both are implemented. The native debugger gate is Green: Flix and Java breakpoints in one session, stepping in both directions, mixed stack navigation, Java evaluation, and Flix-aware Step Over, with one JDWP owner throughout. The DAP path has been removed. What remains is verification rather than construction -- see verification coverage, which is explicit about which rows have been measured and which have not.
The property this buys, which a Flix-only debug adapter could not: a Flix frame can step into Java, Kotlin or Scala and back, with each language's own plugin owning its breakpoints, source positions and evaluator inside the same debug process.
- The native run/debug configuration has never been exercised in a live session. It was built
after the debugger gate, which ran entirely through a hand-made Remote JVM Debug configuration.
With the DAP path removed there is no fallback if it misbehaves. Two blocking defects in it were
found by review and fixed (
GenericDebuggerRunnerrequiresModuleRunProfileon the configuration andRemoteConnectionCreatoron the state); neither fix is confirmed live. - Breakpoints on some Flix lines do not bind. In
flix-lab'sMain.flix, lines calling into Kotlin/Scala/Groovy/JRuby do not bind while adjacent lines do. Artifact inspection has ruled out bytecode presence, class coverage, SMAP mapping and reachability -- the failing and working lines are indistinguishable in the class files. Tracked as row 9 of the verification coverage. - Flix values in CPS frames are not presented. A
Clo$continuation keeps its state in fields (l0..l8,pc) rather than locals, because the frame must survive suspension and resumption, so the variables view is empty for those frames. The values are present and reachable; reading them needs Flix-aware renderers, which the plan places after this milestone. DirectDef$frames show variables normally. verifyPluginhas never been run. The task exists; nothing invokes it.- Exception breakpoints, JDK/library source attachment, class redefinition and stale-cache invalidation have no coverage -- and no known failure either.
- Kotlin, Scala and Groovy interop is unmeasured.
flix-labnow carries aGreeterin five languages, which is the fixture for it, but no session has exercised them. Kotlin coroutine debugging is explicitly not claimed: it needs the Kotlin debugger's agent injected into an externally launched JVM, which has not been proven.
Apache License 2.0 -- see LICENSE.
NOTICE records the provenance of every derived component: the imported
intellij-flix revision, the upstream Flix revision the grammar and token inventory are derived
from (pinned as flixCorpusCommit in gradle.properties), and the Flix revision the grammar derives from.
- Architecture decision records
- Parser corpus evaluation -- the gate evidence behind ADR 0001
- Native JVM debugger gate -- the runbook for proving ADR 0002
- IntelliJ Platform SDK Plugin SDK
- Modular Plugins (content modules)
- LSP4IJ
- flix-lab -- the VS Code side of this tooling
- wstein/flix-fork -- the Flix compiler build this targets