Contributing
September 12, 2026 · View on GitHub
Changelog
Every pull request needs an entry in CHANGELOG.md under ## [Unreleased]. CI
checks this and fails the Changelog / changelog-entry job if the file is untouched.
CHANGELOG.md is not just a record. The Gradle Changelog Plugin renders the entry for the version
being built into the plugin's changeNotes, which is the "What's New" users read on the
Marketplace page and in the IDE's Plugins settings
dialog. A change that never reaches CHANGELOG.md is invisible to users, not merely undocumented.
Which group
Not every group reaches users. Pick accordingly:
| Group | Published to users? |
|---|---|
### Breaking changes | yes |
### Enhancements | yes |
### Bug Fixes | yes |
### Threading / Platform Hygiene | no - recorded for contributors |
### Build / CI | no - recorded for contributors |
If a change has no effect anyone using the plugin can observe, it belongs in one of the lower two. The
group vocabulary and which of them publish are declared in gradle.properties (changelogGroups,
changelogPublishedGroups), and CI checks your entry against them.
Format
CHANGELOG.md follows Keep a Changelog. Add your entry under
## [Unreleased], in the group that matches, using a - bullet with a link to the PR and your
@handle. Nested sub-bullets are fine and render correctly, so one PR link can carry several items:
## [Unreleased]
### Enhancements
- [#1234](https://github.com/KronicDeth/intellij-elixir/pull/1234) - [@you](https://github.com/you)
- **Go-to-Declaration now works on `foo`.** Lead with what a user can now do, then name the API if
it helps - the readers are Elixir developers, so `ElixirPsiImplUtil` is fair game once the effect
is clear.
Write for someone deciding whether to upgrade. Say what changed and why it matters; for a fix, say what used to go wrong.
Two mechanical things to know:
- Every entry must be a list item. The changelog parser models list items and nothing else, so a bare paragraph under a group heading is silently dropped when the file is rewritten at release.
- Do not add a version heading. Only
## [Unreleased]gets edited by hand;./gradlew patchChangelogcreates the release section at release time.
Skipping the check
Apply the no-changelog label when there is genuinely nothing to record - a test-only fix, or a
revert of something that never shipped. Dependabot pull requests are exempt automatically.
Development
Importing the project
- Fork
KronicDeth/intellij-elixiron GitHub. Pull requests are opened from a fork, so do this before cloning. - In the IDE, choose File > New > Project from Version Control... (or Clone Repository from the Welcome screen).
- Enter your fork's URL and clone it.
- Open the clone and accept the trust prompt. IDEA detects the Gradle build and imports it, including
the
:jps-sharedand:jps-buildersubprojects.
For plugin development setup and background generally, see JetBrains' Developing plugins documentation.
Building and running
Gradle will handle all dependency management, including fetching the Intellij IDEA platform specified in gradle.properties, so you can use a normal JDK instead of setting up an "Intellij Platform Plugin SDK".
Elixir and Erlang
The tests need Elixir and Erlang. The build does not compile them from source - it resolves an
already-installed pair. The resolveElixirErlangSdks task looks in this order and stops at the first hit:
| # | Erlang | Elixir |
|---|---|---|
| 1 | ERLANG_SDK_HOME env var | ELIXIR_SDK_HOME env var |
| 2 | erl on PATH | elixir on PATH |
| 3 | mise install erlang@<expected> | mise install elixir@<expected> |
| 4 | - | download + make from source (last resort, slow) |
It then exports ELIXIR_LANG_ELIXIR_PATH, ELIXIR_EBIN_DIRECTORY, ELIXIR_VERSION, ERLANG_SDK_HOME
and a PATH prefix to every test task, so you never set those by hand.
CI takes row 1 for both, via setup-env. Row 2 does not work for Elixir on windows-2025 and the
runners have no mise, so otherwise the Windows leg falls to row 4 on every run.
The expected versions come from mise, which this project requires:
mise install # installs the pinned Elixir, Erlang and JBR
mise.toml is the committed pin. The build does not parse it - it asks mise (mise current elixir)
for the version that will actually be on your PATH, so a personal override in the gitignored
mise.local.toml is honoured rather than silently disagreeing with the build.
You can override per invocation, which takes priority over mise entirely:
./gradlew check -PelixirVersion=1.17.3 -PotpVersion=27.1.2
With neither mise nor both properties, the build stops and tells you so - it never guesses. If what mise reports is not installed, the resolver works down the list above, and step 4 means it can turn into a from-source Elixir build that takes many minutes. See Working with a different Elixir/OTP version.
Building the quoter (see Test tasks) additionally needs network access on a cold cache -
it downloads the quoterRepo/quoterRef pinned in gradle.properties and runs mix local.hex /
mix local.rebar / mix deps.get.
NOTE: Tests that need an Elixir SDK fail rather than skip when it is missing, so run them through Gradle. Running one from the IDE's JUnit runner works only if you supply the environment variables listed above yourself.
Windows Development Setup
Prerequisites
- Build Environment: PowerShell, Git Bash, MSYS2, WSL, or cmd. PowerShell is fine for every Gradle task including the quoter build - see the argument-quoting note below.
- Erlang/OTP and Elixir: required for running tests (not just building), at the versions
above. Install both with
mise install, or install them yourself and puterl/erl.exeandelixironPATH, or pointERLANG_SDK_HOME/ELIXIR_SDK_HOMEat them. - JetBrains Runtime: 21 for IDEA 2026.1, 25 for 2026.2 and later.
build.gradle.ktspicks the bytecode level from the platform build number (262+ → 25), andjavac --releasevalidates the platform JARs against it, so the wrong JDK fails the compile rather than producing a bad build.mise installprovisions the pinned JBR. - Make: not required in the normal path. Only the last-resort from-source Elixir fallback uses it.
Important
In PowerShell, quote -P flags that contain a dot: .\gradlew.bat "-PelixirVersion=1.17.3".
Unquoted, PowerShell splits the argument and Gradle reports Task '.17.3' not found.
Building on Windows
# Everything: test + :jps-builder:test
.\gradlew.bat check
# Build the plugin zip only, no tests
.\gradlew.bat buildPlugin
Platform Support
The build system automatically detects your platform:
- Windows: Uses
.batexecutables for Elixir commands, ProcessBuilder for daemon management - Linux/macOS: Uses standard executables, native daemon support
Troubleshooting
Quoter fails to start on Windows:
- Check that no antivirus is blocking the process
- Verify no orphaned Erlang processes:
tasklist | findstr erl - Kill orphaned processes:
taskkill /F /IM erl.exe
Deleting a checkout or worktree fails with "Device or resource busy":
- A leftover
epmdrunning from inside the tree pins the directory. The build startsepmdfrom the Erlang SDK to avoid this, but one started earlier can still be holding it. - Find it with
Get-Process epmd | Select-Object Id,Path; if the path is inside the checkout, stop it withStop-Process -Id <id> -Force. - Check
epmd -namesfirst:epmdis machine-wide, so stopping it deregisters every node and fails any test run in progress in another checkout.
erl not found, or the build starts compiling Elixir from source:
- The resolver could not find the expected version. Check what it decided - it logs
Expected versions (from ...): Elixir ..., Erlang ...followed byResolved SDKs: ...with the source of each (env:ERLANG_SDK_HOME,path,mise,source-download). - Confirm what mise reports (
mise current elixir,mise current erlang) is actually installed, or pass-PelixirVersion=/-PotpVersion=to match what you have. - As a last resort, set
ERLANG_SDK_HOMEandELIXIR_SDK_HOMEexplicitly - they take priority over everything else.
Path with spaces warning:
- Cosmetic only - Erlang installed in "Program Files" shows warnings but works correctly
kerl build fails on Linux as of May 2026
This affects only the from-source Elixir/Erlang fallback, which the normal path never reaches - if you hit it, the real problem is that the resolver could not find your installed pair (see above).
If you see this:
beam/dist.c:5678:15: error: two or more data types in declaration specifiers
5678 | Eterm bool = ((monitor_oflags & ERTS_ML_FLG_SPAWN_MONITOR)
^~~~
As the project still uses OTP 24, which uses a local variable named bool in beam/dist.c, which was legal C in 2022... but is not legal C now, because GCC 15 switched its default C standard to C23, which makes bool a reserved built-in type name. ArchLinux (btw) ships GCC 16.1.1, Deian 13 seems to be 14.2.0.
You can work around this by forcing an older C standard, for example:
export CC=gcc
export CFLAGS="-O2 -g -std=gnu17 -Wno-error -fcommon"
export CPPFLAGS="-D_FORTIFY_SOURCE=0"
export KERL_CONFIGURE_OPTIONS="--disable-debug --without-odbc --without-wx"
unset KERL_USE_AUTOCONF
From command line
Let's assume that you don't have much knowledge of the Java/Kotlin ecosystem, and just want to help contribute some changes. Here are some tips on how make your changes and test them without hopefully too much fuss,
To launch an IDE, you can use runIde, which will launch IntelliJ IDEA:
./gradlew runIntellijIdea
Testing in other IDEs
Important
In the 2025 release, JetBrains "unified" the Professional and Community editions of IntelliJ IDEA into a single edition. See The Unified IntelliJ IDEA: More Free Features, a Better Experience, Smoother Flow Other IDEs, such as PyCharm was also unified into a single edition.
To test another IDE, you can use one of the following tasks:
runIntellijIdea/runIntellijIdeaEAPrunRubyMine/runRubyMineEAPrunWebStorm/runWebStormEAPrunPyCharm/runPyCharmEAPrunRustRover/runRustRoverEAPrunCLion/runCLionEAPrunGoLand/runGoLandEAPrunPhpStorm/runPhpStormEAP
These task names are generated from the platformVersion* properties in gradle.properties, so that
file is the authoritative list if it drifts from this one.
Running the latest EAP snapshot
To use runIde or runPyCharm etc with the latest EAP snapshot, you need to pass the useDynamicEapVersion gradle property
when running the task (or Run Configuration in IntelliJ IDEA).
For example, to launch the latest RubyMine EAP:
./gradlew runRubyMineEAP -PuseDynamicEapVersion=true
Testing
Test tasks
| Task | Source root | What it covers | Quoter daemon | Network on a cold cache |
|---|---|---|---|---|
test | tests/ | the whole JUnit suite, parser tests included | yes (soft) | yes |
:jps-builder:test | jps-builder/tests/ | the JPS build process | no | no |
check | - | both of the above | yes | yes |
testUI | testUI/kotlin | IDE UI tests; needs a built plugin | no | no |
./gradlew check # everything
./gradlew test --tests "org.elixir_lang.psi.operation.PrefixTest" # a single test
./gradlew test --tests "org.elixir_lang.parser_definition.*" # just the parser suite
test builds and starts the Elixir quoter daemon, because the parser tests
(org.elixir_lang.parser_definition.*) quote source through it and compare the result against the
plugin's own quoting. Gradle stops the daemon at the end of the build. On a warm cache this costs
about a second; the first run in a fresh clone downloads and builds the quoter.
The quoter is a soft requirement, because it may not build for every Elixir/OTP pair the pipeline
tests against. releaseQuoter records whether it produced a daemon and succeeds either way, so only
the tests that quote through it fail - immediately and by name - and the rest of the suite runs. They
fail rather than skip so the counts still say what was not asserted. A recorded failure is never up to
date, so the next build retries the quoter; recovering from an offline run or a killed compile needs
nothing from you. Dependency fetching (getQuoterDeps) is not soft: no network on a cold cache still
fails the build outright.
To stop at the quoter instead, which is what you want when debugging the quoter itself:
./gradlew check -PquoterRequired=true
The quoter is built with MIX_ENV=prod, so its release lands in
cache/<pair>-quoter-<ref>/_build/prod/rel/quoter. The environment is set by the
build rather than left to mix release, which has no preferred environment and assembles under :dev
when MIX_ENV is unset - compiling the quoter's dev/test-only dependencies, none of which the daemon
needs at runtime. Exporting MIX_ENV overrides this, and the path the build looks in follows the
same value, so the two cannot disagree. If you switch it, expect one extra releaseQuoter run; the
_build subtree for the previous environment is left behind and can be deleted.
To build (so you get a .zip file):
./gradlew buildPlugin # the zip, no tests - prefer this
./gradlew build # the zip AND the full suite
./gradlew build -x test # the zip, skipping the root suite
Debugging a failing test
- Quoting mismatches report only the first divergence, as
quoted forms diverge at {2}[0]{1}[0]: keyword key, expected :from_brackets, got :line, where the path notation is{i}tuple,[i]list,[:key]keyword,%{key}map. Pass-Delixir.quoter.fullDump=truefor the complete side-by-side terms. - The platform log for a failing test is written to
build/idea-sandbox/.../log_*/splitTestLogs/rather than stderr, and stderr carries aLog saved to:pointer. Thetesttask setsidea.split.test.logs=truefor this - without it the buffered log is duplicated into the JUnit XML and can grow past what report tooling can parse.
Which versions CI tests against
.github/ci-versions.json declares every version CI uses - the supported IDEA platforms (with the
JBR level each needs and the products to verify them against) and the Elixir/OTP pairs.
Edit that one file to add or bump a version: the test legs, the plugin verifier's IDE lists, and the
release build's default platform are all derived from it.
Three places in this document quote that data rather than deriving it - the JBR levels under
Windows Development Setup, and the worked check names under
Reading a leg in the checks list. Nothing keeps them in sync, so
update them in the same commit as .github/ci-versions.json.
Tests always run against IntelliJ IDEA. The legs are every declared IDEA version on Ubuntu with
beam.baseline, plus one leg per beam.additional pair on the minimum supported IDEA, plus
beam.baseline on Windows.
Two scripts in .github/scripts read that file, so you can see what a change to it produces without
pushing:
| Script | Answers |
|---|---|
compose-legs.js | which test and verification legs exist |
resolve-versions.js | which versions a caller gets when it names none (SETUP_ELIXIR=true to include Elixir) |
node .github/scripts/compose-legs.js
SETUP_ELIXIR=true node .github/scripts/resolve-versions.js
Outside the runner they print what they would otherwise hand to the workflow - the leg tables and the resolved versions - so a bad declaration is diagnosable locally rather than from a failed run.
| Elixir | OTP | Status | |
|---|---|---|---|
beam.baseline | 1.13.4 | 24.3.4.6 | supported - must be green |
beam.additional | later minors, plus pairs covering an OTP major no other leg covers | see the file | supported when the entry has no continue-on-error, otherwise informational |
beam.additional is not one-entry-per-Elixir-minor: a pair may exist to cover an OTP major no other
pair covers, because most of the decompiled surface is Erlang and the BEAM chunk formats track OTP
rather than Elixir. So the same Elixir can appear twice with different OTPs - 1.13.4 currently does.
For such a leg, prefer the cleanest in-window Elixir so it isolates the OTP surface instead of
inheriting a quoting backlog.
Check which OTP an Elixir supports against the
compatibility table - it accounts for
support added in patch releases, e.g. 1.14 is "23 - 25 (and Erlang/OTP 26 from v1.14.5)". Within that
range, prefer the version's recommended_otp from
elixir-versions.yml,
or the highest supported OTP where none is declared. Don't use that file's otp_versions list to
decide the range: it is per-minor and misses patch-level additions.
Widening Elixir support
beam.additional is a ratchet. Each entry moves one pair - an Elixir version, an OTP major, or both -
from unsupported to supported:
- Add the pair with
"continue-on-error": true. That declares it unsupported, and its leg is expected to be red. Adding a version is safe at any time - it cannot block a merge. - Fix what it reports, until the leg goes green.
- Delete its
continue-on-error. The version is now supported: from then on, any change that breaks it fails the pipeline.
continue-on-error covers the whole leg - toolchain setup, compile, sandbox, quoter build and tests -
not just the test step, because an unsupported Elixir can fail at any of those and they all mean the
same thing. setup-beam may not publish the pair. The annotation on a failed informational leg names
the phase it died in, so you can tell those apart.
Reading a leg in the checks list
Each leg is named after the axis its group varies, so the part that distinguishes it survives the
checks list's truncation: test (IDEA 2026.2.2), test (1.19.5+28.4) (Elixir + OTP),
test (Win25, IDEA 2026.1.5). The same name is used for the leg's Test Results (...) check, so a
row in one list maps to the other without translating.
A leg that stopped before its tests ran says so in the name:
Test Results (1.19.5+28.4, INCOMPLETE - failed at compile)
That matters because the check's own title only ever describes the result files it found - a leg that
never compiled published the six :jps-builder:test cases and nothing else, and the title reads
All 6 tests pass. Treat INCOMPLETE as "these numbers cover only what got as far as running"; the
stage named after it is where the leg actually died.
The same information arrives as a comment on the pull request: one table of totals and one row per leg, split by whether the leg can block a merge. The icons say only that:
| Icon | Meaning |
|---|---|
| ✅ | passed |
| ❌ | a required leg failed - tests, or an earlier stage; either way it blocks |
| ⚠️ | an informational leg failed - this version is not supported yet |
| ⬜ | the leg published no results at all |
| ❔ | the leg did not report its status, so no claim is made either way |
The status text names the reason: tests failed with a count beside it, versus
failed at compile where the counts cover only what ran. For which tests failed, follow a row
to its Test Results (...) check or read the Failed tests summary on the leg's own job - the
comment deals in counts only.
Internal API usages
JetBrains have agreed to ignore the internal platform APIs listed in
.github/internal-api-usages-allowlist.txt. Any other internal API usage blocks the plugin from being
published, so .github/scripts/check-internal-api-usages.js fails any verification leg that reports
one.
The allowlist records that agreement; it does not suppress a warning. Adding a line does not make a usage publishable - that needs a new agreement with JetBrains, and removing the usage is the better answer. A moved usage (an approved API called from code the allowlist does not name) is the exception: the ceiling is unchanged, so just replace the line with the reported one, which pastes in verbatim.
To check a local run - verifyPlugin covers more IDEs than CI, so it may report more:
REPORTS_DIR=build/reports/pluginVerifier VERIFY_OUTCOME=success \
node .github/scripts/check-internal-api-usages.js
Working with a different Elixir/OTP version
The pinned pair is the one you get by default. To work against another:
Just for one run - nothing on disk changes, and this is what CI does:
./gradlew check -PelixirVersion=1.17.3 -PotpVersion=27.1.2
Switch your working copy - mise use writes mise.toml, and the build follows it immediately:
mise use elixir@1.17.3-otp-27 erlang@27.1.2
To switch only for yourself without touching the committed pin, add --env local; that writes the
gitignored mise.local.toml, which outranks mise.toml. The build follows either, because it asks
mise for the effective version rather than reading a file.
Note
You do not need the exact -otp-N suffix in -PelixirVersion - mise resolves 1.17.3 to the
installed 1.17.3-otp-27. If the version is not installed yet, the resolver runs mise install for
you; installing it up front (mise install elixir@1.17.3-otp-27 erlang@27.1.2) just makes the first
build faster.
Expect the known failures from the table above; compare against them before assuming you broke
something. ./gradlew check on the pinned pair must stay at zero.
Adding a version to CI is one edit to .github/ci-versions.json - add a beam.additional
entry, or an idea.additionalToTest entry for a platform. The matrix, the JBR levels and the verifier's IDE
lists all follow from it.
Other IDEs appear only in plugin verification. Each IDEA version lists the products to verify it
against in a verify array, and CI runs one job per product/version pair (.github/workflows/shared-verify.yml),
each verifying the plugin zip that was built once. Adding a product is one entry in that array; the
values are intellij-repository artifact ids (ideaIU, rubymine, pycharmPC, webstorm, ...),
not marketing names.
From IntelliJ IDEA
Running the plugin in a specific IDE
- Open the Gradle Tool Window (
View > Tool Windows > GradleOR from the Gradle button on the right tool button bar) - Expand
intellij-elixir (root) > Tasks > intellij platform - Double click
runIntellijIdea(or the task for whichever IDE you want to test with - see Testing in other IDEs) - Now the Run Configuration will be selected, and you can click the green arrow at the top of the screen.
Verification
- Expand
verification - Double click
check- it runstestand:jps-builder:test.
The committed Run Tests configuration under .run/ already runs check.
GrammarKit (Parser / PSI Generation)
The Elixir parser and PSI element classes in gen/ are generated from src/org/elixir_lang/Elixir.bnf using the GrammarKit plugin. If you modify the .bnf file (e.g. adding a mixin, changing a rule, or adding a new production), you must regenerate the parser code.
Prerequisites
- Install the GrammarKit plugin in IntelliJ IDEA (Settings → Plugins → search "Grammar-Kit").
Regenerating Parser Code
- Open
src/org/elixir_lang/Elixir.bnfin the editor. - Right-click inside the file → Generate Parser Code.
- The generator writes updated files into the
gen/directory.
Fixing CRLF Line Endings (Windows)
The GrammarKit generator writes files with CRLF line endings, but the repository uses LF. After regenerating, convert line endings from Git Bash:
cd ~/IdeaProjects/intellij-elixir
find gen -type f | xargs dos2unix.exe
Then review the actual changes:
git diff --stat -- gen/
Many gen/ files may show whitespace-only or formatting diffs from a generator version difference - these can be included in your commit or excluded as appropriate.
Source Layout: gen/ vs src/
The gen/ directory must contain only GrammarKit/JFlex-generated files. All hand-written code lives in src/. Both directories are source roots with the same package structure (org.elixir_lang), so files can be moved between them without changing imports.
Generated files have a header comment: // This is a generated file. Not intended for manual editing. (GrammarKit) or /* The following code was generated by JFlex */ (JFlex). If you need to add hand-written code that lives alongside generated PSI classes (e.g. a new PsiScopeProcessor, stub type, or operation interface), place it in src/ under the matching package path.
The full set of source roots:
| Root | Contents |
|---|---|
src/ | hand-written plugin code |
gen/ | GrammarKit/JFlex output - never edit by hand |
tests/ | the JUnit suite, run by test |
testUI/kotlin | IDE UI tests run by testUI |
jps-shared/, jps-builder/ | JPS model and build-process modules |
Key BNF Concepts
Rule names vs interface names: GrammarKit generates PSI classes named after the BNF rule (e.g. rule heredoc → ElixirHeredoc). The implements attribute on a rule specifies the hand-written interface the generated class implements. These are independent - do not confuse them.
Visitor method generation: For each rule, GrammarKit generates a visitRuleName(ElixirRuleName) method in ElixirVisitor. For each interface in implements, it generates a visitInterfaceName(InterfaceName) bridge method. If a rule name and an interface name (after stripping packages) are identical, the visitor generates a self-recursive method - causing a StackOverflowError at runtime.
Example of the collision:
// BAD: rule "heredoc" implements interface "Heredoc" - visitor generates:
// visitHeredoc(ElixirHeredoc) { visitHeredoc(this); } ← infinite recursion!
// GOOD: rule "heredoc" implements interface "HeredocLiteral" - visitor generates:
// visitHeredoc(ElixirHeredoc) { visitHeredocLiteral(this); } ← safe dispatch
Resolution: When adding a new implements interface to a rule, ensure the interface's simple name does not match any BNF rule name. If it would collide, rename the interface (e.g. Heredoc → HeredocLiteral) or the rule.
extends attribute: Causes the child rule's generated interface to extend the parent rule's interface, AND collapses AST nodes. Use it for expression hierarchies where shallow AST is desired. Do not use it solely for visitor type compatibility - it changes the PSI tree shape and will break parsing tests that compare golden .txt files.
mixin attribute: Specifies a hand-written base class for the generated *Impl class. Use this to add custom behaviour (e.g. implementing HintedReferenceHost) without editing generated code. The mixin class must extend ASTWrapperPsiElement (or the appropriate stub base class) and live in src/.
fake rules: Define PSI interfaces and visitor methods without affecting the parser. Useful for creating intermediate types in the PSI hierarchy (e.g. fake binary_expr ::= expr+ to group add/mul expressions under a common interface).
ElixirPsiImplUtil Method Resolution
GrammarKit resolves methods=[...] declarations by searching psiImplUtilClass (ElixirPsiImplUtil) for static methods whose first parameter type matches the rule's generated interface or one of its implements interfaces. It tries types in order: the concrete generated type first, then each implements interface.
Ambiguity pitfall: If a generated class implements two interfaces A and B, and ElixirPsiImplUtil has both foo(A) and foo(B), the Java compiler reports an ambiguous call even though GrammarKit only emits a call to one. The fix is to consolidate the overloads:
- Remove the more-specific overload and fold it into the broader one using
instanceof, OR - Make one interface extend the other so Java's overload resolution picks the more-specific type
Testing After BNF Changes
After regenerating parser code:
- Compile first - fix any ambiguity or type errors in
ElixirPsiImplUtilbefore running tests. - Run all parsing tests - BNF changes often affect golden
.txtfiles undertestData/. If the PSI tree shape is intentionally unchanged but element type names changed (e.g. from a rename), bulk-update the golden files:find testData -name '*.txt' -exec grep -l oldName {} \; | xargs sed -i 's/oldName/newName/g' - Run annotator/inspection tests - these exercise the visitor and may hit
StackOverflowErrorif a visitor collision exists. - Check
base.txt- the plugin verifier baseline file also contains PSI element names.
Suppressing Warnings in the BNF
//noinspection BnfResolve for JFlex tokens: GrammarKit warns Unresolved rule reference for tokens that are defined by JFlex (e.g. pin = DO), not by BNF rules. These are false positives - suppress them with a comment on the line above:
//noinspection BnfResolve
pin = DO
gen/ inspection suppression: The build script marks gen/ as generated sources via idea.module.generatedSourceDirs so that IntelliJ suppresses inspections (e.g. unused imports) on GrammarKit-generated code. There is no GrammarKit configuration to control which imports the generator emits.
JFlex Lexer Regeneration
The Elixir lexer gen/org/elixir_lang/ElixirFlexLexer.java is generated from src/org/elixir_lang/Elixir.flex using JFlex. If you modify Elixir.flex (e.g. adding a new state, changing a rule, or fixing escape handling), you must regenerate the lexer.
Prerequisites
- Install the GrammarKit plugin in IntelliJ IDEA (it bundles JFlex). Settings → Plugins → search "Grammar-Kit".
Regenerating the Lexer
- Open
src/org/elixir_lang/Elixir.flexin the editor. - Right-click inside the file → Run JFlex Generator.
- The generator overwrites
gen/org/elixir_lang/ElixirFlexLexer.javain place. The first time you run it (or on a fresh checkout) it may prompt you to select an output folder - point it at the repository root so it discoversgen/automatically.
gen/ is declared as generatedSourceDirs in build.gradle.kts, so the IDE warns you if you try to manually edit it and suppresses inspections on it automatically.
⚠️ No Manual Post-Regeneration Step Required
The stack is cleared automatically. ElixirFlexLexerAdapter.start() calls the generated
clearStack() method before each lex, so no hand-patching of the generated file is required
after regeneration.
Color Schemes
JetBrains plugins are able to set the text attribute values for TextAttributeKeys that are unique to the plugin by using additionalTextAttributes entries in resources/META-INF/plugin.xml (the plugin ships two: colorSchemes/ElixirDefault.xml for the Default scheme and colorSchemes/ElixirDarcula.xml for Darcula). If you have a Color Scheme for Elixir you like, you can propose it as the default for a named theme by extracting the additionTextAttributes file from an Exported Settings .jar.
Customizing Scheme
- Preferences > Editor > Colors & Fonts > Elixir
- Customize the colors
- Click "Save As" to name the Scheme (
My $SCHEME_NAME)_
Exporting Settings
- File > Export Settings
- Click "Select None"
- Check "Editor Colors"
- Change the "Export settings to:" path to a place you can easily access it in the terminal
- Click "OK"
Unpack Settings
mkdir settingscd settingsjar xf $SAVE_DIRECTORY/settings.jar
Convert ICLS to Additional Text Attributes format
mv colors/colors/My\ $SCHEME_NAME.icls $INTELLIJ_ELIXIR/colorSchemes/Elixir$SCHEME_NAME.xml($SCHEME_NAMEwill beDefault,Darculaor another shared theme name.)- Remove all elements except for
scheme attributes. - Remove the outer
schemetag - Rename the
attributestag tolist. - Add
<?xml version='1.0'?>to the top of the file
Add Additional Text Attributes to plugin
- In
resources/META-INF/plugin.xmlinside theidea-plugin extensions[defaultExtensionNs="com.intellij"]tag, add a new additionalTextAttribute tag:<additionalTextAttributes file="colorSchemes/Elixir$SCHEME_NAME.xml" scheme="SCHEME_NAME"/>
Building
Plugin version scheme
The built plugin's version depends on how it was produced:
| Build | Version | Example |
|---|---|---|
Tagged release (tag.yml, -PpluginVersionOverride) | the git tag verbatim | 24.0.0 |
Release channel (-PpublishChannels=default) | base version, no suffix | 24.0.0 |
| CI canary | <base>-pre+<UTC commit time>.<commit> | 24.0.1-pre+20260804164541.64e3d69a |
| Local build | <base>-dev+<UTC commit time>.<commit> | 24.0.1-dev+20260804164541.64e3d69a |
-PversionSuffix=<s> | <base>-<s> | 24.0.1-rc1 |
<base> is pluginVersion from gradle.properties, with the patch bumped for any non-release
channel so the IDE does not offer to "update" a local build to the released one. A work tree that
differs from HEAD in any way adds -wip after the commit - including untracked files, since
an un-added source file is compiled into the plugin just like a committed one.
Why the commit is there: the version string is the only field identifying the built code in a
JetBrains Marketplace exception report. The IDE sends IdeaPluginDescriptor.version as
plugin.version, and the exception analyzer surfaces it as pluginVersion with no companion
metadata - so without the commit, a crash report cannot be traced to a source revision.
-dev vs -pre separates a maintainer's own sandbox reports from real canary users' reports.
The timestamp is the commit's committer date, not the build's clock. A build-clock stamp reads as a source date without being one, which is how a report from a build stamped 30 June turned out to be running source from three weeks earlier. Committer date rather than author date, so a rebased or amended commit sorts later.
Three consequences worth knowing:
- The whole suffix is a pure function of (commit, dirty), so rebuilding unchanged source produces an
identical version and leaves
patchPluginXml- and everything downstream of the patched descriptor -UP-TO-DATE. A wall-clock stamp re-runs all of it on every reconfiguration. - Reading
HEADis a configuration-cache input (versioning.GitSourceIdValueSource), so committing or flipping the tree's dirty state invalidates the configuration cache. Ordinary editing does not: a tree that was already dirty stays dirty, so the value is unchanged and the cache is reused. Release builds skip the lookup entirely. - If
gitis unavailable the version falls back to a build-clock stamp with no commit. The two are told apart by the commit: a lone timestamp is a build time, a timestamp followed by a commit is that commit's time.
Documentation
Three files carry a table of contents generated by doctoc:
AGENTS.md, CONTRIBUTING.md and
COLOR_SCHEME_DESIGN.md. CHANGELOG.md, README.md, RELEASING.md and
UPGRADING.md have none - do not add one.
Regenerate with mise, which needs no global install:
mise exec -c "npx doctoc AGENTS.md CONTRIBUTING.md COLOR_SCHEME_DESIGN.md" node@25.4.0
Use -c rather than --: PowerShell strips -- before mise sees it.
Important
Always name the files. Never run doctoc . It recurses and ignores .gitignore, so it inserts a
TOC into every markdown file it can reach.