Harbor Night and the theme reference were missing the --syn-php-variable / --syn-php-function-call tokens added for PHP highlighting, so scripts/theme/theme.test.ts failed on main. Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com>
Writing OpenIDEA plugins
OpenIDEA plugins are sandboxed WebAssembly components (wasmtime, component model, WASI preview 2). A plugin can register commands (they become IDE actions you can search for and bind to keys), read project files, edit the focused editor, react to workbench events, run background tasks, show notifications and status bar items, keep data in its own storage, read and fetch git repositories, provide completion, quick fixes, navigation and gutter markers, call web services and run approved programs, show tool windows and custom editors, and contribute menus, shortcuts, settings, themes, file icon themes and languages. Plugins that only contribute data (themes, icons, languages, menus, settings) need no code at all.
Preview. The plugin system ships as a preview and the official plugins are versioned
0.x. Released API versions only grow: a plugin built foropenidea:plugin@1.0keeps loading in later 1.x IDEs. See Evolving the plugin API below.
plugins/
└─ official/
├─ harbor-night/ # data only: a colour theme (UI and editor scheme)
├─ material-icons/ # data only: a file icon theme
├─ solidity/ # language + syntax provider + run configurations
├─ string-tools/ # 26 text commands, versioned multi-cursor edits
├─ key-promoter/ # action events, storage
├─ git-toolbox/ # on-schedule fetch, tasks, VCS, status bar item
├─ checkstyle/ # one-shot process, diagnostics, quick fixes
├─ ai-completion/ # inline completion over the network, secrets
├─ mybatis/ # navigation, gutter markers, completion across files
├─ csv-editor/ # custom editor sharing the text document
├─ excalidraw/ # web view custom editor, file icons, a template
├─ graphviz/ # web view with code: live preview, problems from the page, a language
├─ regex-tester/ # html view talking to its code, settings, the editor's selection
└─ license-header/ # file-opened events, quick fix, project scan in a task
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave
The plugin API is defined in wit/ (openidea:plugin@1.1.0, world
plugin).
1. Quick start
The openidea-plugin tool scaffolds, builds, checks and links plugins:
cargo install --locked --git https://github.com/l0ng-ai/openidea ide-host --bin openidea-plugin
# in a checkout of this repository: cargo install --path crates/ide-host --bin openidea-plugin
openidea-plugin new my-plugin # Rust command plugin (SDK)
openidea-plugin new my-theme --template theme # also: icon-theme, language
cd my-plugin
cargo test # logic, against the SDK's mock host
openidea-plugin build # → dist/plugin.wasm, then `check`
openidea-plugin link # load this folder in the IDE
Each release also attaches the tool for Linux x64
(openidea-plugin-linux-x86_64.gz). A plugin is a project of its own: keep it
in its own repository, and list it in the community marketplace when it is
ready (§8.1).
A Rust plugin made by new depends on the SDK of the tool's release, from
this repository's tag (openidea-plugin = { git = "…", tag = "vX.Y.Z" });
change the tag to build against a later API. Inside a checkout of this
repository, or with --sdk <path to sdk/rust/openidea-plugin>, it depends on
that SDK by path instead.
build runs cargo build --release --target wasm32-wasip2 (installing the
target through rustup if needed), copies the component to the manifest's
wasm path and runs check. check validates the package exactly as
Install Plugin from Disk… would — manifest, every contribution, the SVG
sanitiser, the component — and that the component imports nothing but the
plugin API and WASI and links against the IDE's: every function it imports
exists with the same signature, and it exports the plugin world. It prints what the user will be asked to grant.
link adds the folder to links.json in the IDE's plugins directory
($OPENIDEA_PLUGINS_DIR, default <app-data>/ai.l0ng.openidea/plugins). Linked
plugins are read from the folder itself, so the edit loop is build then
Settings › Plugins › Reload. unlink and links undo and list.
2. The package
A plugin is a directory with a plugin.toml:
id = "acme.word-tools" # [a-z0-9][a-z0-9_-]*, optionally with dotted namespaces
name = "Word Tools"
version = "1.2.0" # semver
description = "Counts and transforms words."
authors = ["You <you@example.com>"]
homepage = "https://example.com" # optional
license = "MIT" # optional
change-notes = "1.2.0 — …" # shown under What's New
categories = ["tools"] # marketplace categories, see below
engines.openidea = "^1.0" # the plugin API this package needs
wasm = "dist/plugin.wasm" # omit for data-only plugins
activation = ["on-language:markdown"] # optional, see below
[capabilities]
required = ["project.read", "editor.write"]
optional = ["net:api.example.com"]
[[contributes.commands]]
id = "count" # [a-z0-9][a-z0-9._-]*
title = "Word Tools: Count Words"
category = "Word Tools" # group in Search Everywhere / Keymap
when = "editorFocus" # optional: same expressions as built-in actions
categories (up to 3) file the plugin in the marketplace: languages,
themes, icon-themes, linters (linters and formatters), vcs, ai,
frameworks, tools, other. Without them, a plugin contributing themes,
icon themes or languages is filed under those, any other under other.
Categories an IDE doesn't know, and any after the first three it knows, are
ignored (check warns about both).
All paths are relative to the package directory and may not leave it. A manifest is validated as a whole: if one contribution is invalid, nothing is installed or loaded. Contribution types the IDE does not know (a newer API) are skipped with a warning.
Manifests in the 0.1 format (openidea-api = "0.1", permissions = [...])
still load (their permissions become required capabilities; contributions
work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
Contribution points
| Point | Declaration | Code needed |
|---|---|---|
commands |
id, title, category?, when? |
to run them |
themes |
path (theme JSON), editor-scheme? |
no |
iconThemes |
id, label, path (icon theme JSON, see §5) |
no |
languages |
path (languages/<id>.toml format, see §6) |
no |
runDetectors |
id, name, files, command, args?, test? (§6.2) |
no |
providers |
kinds, languages (editor providers, see Providers) |
yes |
secrets |
name, label, description? (see Network) |
to use them |
menus |
menu (editor.context, projectView.context, editorTab.context, tools), command, when? |
no |
keymaps |
command, key, mac? (Ctrl+Alt+C; a chord: Meta+K Meta+C) |
no |
statusBar |
id, text, tooltip?, command?, align?, priority? |
no |
settings |
schema (JSON file), title? — one table, see Settings |
no |
views |
id, title, icon? (an icon name, or an SVG of the package), location (left, right, bottom), web? (see Web views) |
without web |
customEditors |
id, title, patterns (file names), priority (default, option), layout?, web? |
without web |
fileIcons |
id, patterns (file names), icon (SVG), iconDark? — whatever icon theme is in use |
no |
fileTemplates |
id, name, extension, path (the new file's content; template variables work) — New menu |
no |
Declared contributions are live as soon as the plugin is enabled — commands show in Find Action, menus and the status bar, shortcuts work, themes and icon themes can be applied, settings pages are there — before any plugin code is loaded. Menus, shortcuts and status bar items refer to the plugin's declared commands. A plugin shortcut never takes a keystroke the IDE (or the user) already uses; the user can rebind it in the keymap editor.
Activation
Code is instantiated lazily: when one of the plugin's commands runs, or when
an event in activation fires:
| Event | Fires when |
|---|---|
on-command:<id> |
the command runs (implied for every command) |
on-language:<id> |
a file of that language is opened (*: any) |
on-file:<glob> |
a project file matching the glob is opened (**) |
on-view:<id> |
one of the plugin's views is shown |
on-schedule:<secs> |
every secs seconds (at least 60): Event::Scheduled |
on-event:<kind> |
a workbench event of that kind (see Events) |
on-startup-finished |
shortly after the workbench is ready (use sparingly) |
Until then the plugin is listed as Inactive and costs nothing at start-up.
An instance unused for 5 minutes is reclaimed (its deactivate runs) and the
plugin is Inactive again until its next event; on-schedule activates it
when due but does not keep it alive. Keep nothing in memory you can't rebuild
in activate.
Capabilities
Code can only do what its capabilities allow, and the user approves them when installing (or when a new version asks for more). Low-risk capabilities are listed; high-risk ones are called out as equivalent to running code on the machine. Users can revoke optional capabilities in Settings › Plugins.
| Capability | Risk | Unlocks today |
|---|---|---|
project.read |
low | Project::root, read_file, find_files, host::projects, publish_diagnostics |
editor.read |
low | TextEditor::document, selections |
editor.write |
medium | TextEditor::apply_edits |
vcs.read |
low | Project::vcs_status (branch, upstream, ahead/behind, changes), vcs_remotes |
vcs.write |
medium | Project::vcs_fetch (trusted projects only) |
storage |
low | host::storage (1 MiB per plugin) |
events |
low | file opened/saved, editor focused, repository changed events |
events.input |
medium | action-performed events (which actions you run, and how) |
process:<program> |
high | Project::run_process, language servers, debug adapters, runs |
net:<host> |
medium | host::fetch to that host (*.example.com: its subdomains) |
secrets |
medium | {{secret:<name>}} in request headers, host::secret_is_set |
clipboard |
medium | host::copy_to_clipboard and the clipboard API in the plugin's web views |
process: capabilities are also derived from what the contributions start: a
language's language-servers and debugger commands and a run detector's
command. Those are optional — refusing one keeps the rest of the plugin
working. On grant the program is resolved to an absolute path and its content
hash is recorded; it is checked again right before every start, and a replaced
file is refused until the user approves it again. In a remote project the
server resolves and pins the program on its own machine (see Remote
projects). fs.read:<path> can be declared and granted; it unlocks nothing
yet.
Logging, notifications, status bar items, tasks and run-time contributions
are always available. Calling a gated function without its capability returns
Error::Denied("<capability>") — the plugin keeps running and can degrade
(Git Toolbox still shows the branch without vcs.write).
3. Writing code (Rust SDK)
openidea-plugin new writes this for you. Cargo.toml:
[package]
name = "openidea-plugin-word-tools"
version = "0.1.0"
edition = "2024"
[workspace] # keep it out of any parent workspace
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { git = "https://github.com/l0ng-ai/openidea", tag = "vX.Y.Z" } # or { path = "…/sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"
src/lib.rs:
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
struct WordTools;
impl Plugin for WordTools {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
let editor = ctx.editor().ok_or("no file is open")?;
match id {
"count" => {
let words = editor.document()?.text.split_whitespace().count();
host::notify(NotificationLevel::Info, "Word count", &format!("{words} words"));
Ok(None)
}
// Every selection (a cursor: empty) in one undoable change.
"shout" => {
editor.transform_selections(|s| s.to_uppercase())?;
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
}
register_plugin!(WordTools);
On wasm32-wasip2 the SDK calls the IDE; on your machine it calls the SDK's
mock host, so the logic is unit-tested with cargo test:
#[test]
fn counts_and_shouts() {
let host = openidea_plugin::mock::MockHost::install();
host.open_editor("/project/a.md", "markdown", "one two three", &[(0, 4, 0, 7)]);
WordTools.run_command("count", &host.invocation(), None).unwrap();
assert_eq!(host.notifications()[0].body, "3 words");
WordTools.run_command("shout", &host.invocation(), None).unwrap();
assert_eq!(host.editor_text(), "one TWO three");
}
The mock enforces capabilities like the IDE (MockHost::install().with_capabilities(&[...])),
keeps a versioned editor document (type_in_editor makes edits stale), and
records notifications, status items, storage, subscriptions, tasks, fetches,
commands, languages, themes and log lines.
Any language with component-model tooling works (componentize-py,
jco componentize, TinyGo): the only contract is the plugin world in
wit/ (plugin-syntax adds the syntax provider). Build it yourself,
then run openidea-plugin check.
The API
| Rust SDK | WIT | Notes |
|---|---|---|
Plugin::run_command(id, ctx, arg) |
export commands.run |
ctx is the window: ctx.project(), ctx.editor(). An error shows " |