Skip to content

Plugin API — overview

All PluginApi types live in namespace MTE::plugin. The public headers may not include any Qt, Scintilla, or MTE-internal header — only the standard library. The one deliberate exception is the opt-in PluginApi/Qt/ folder (PluginQtGlue.h, ISettingsPage.h, ThemeFormat.h) for plugins that ship their own Qt widgets.

The same contract is fulfilled by two backends: the native Qt loader (shared libraries) and the out-of-process Python backend (Python plugins). This section documents the C++ surface; every service here that is backend-neutral has a Python counterpart on ctx.

Plugin lifecycle

A native plugin implements one class:

class IPlugin {
public:
    virtual PluginInfo info() const = 0;                    // id, name, version, apiVersion
    virtual bool initialize(IPluginContext& ctx) = 0;       // register everything here
    virtual void shutdown() noexcept = 0;                   // release every token
};
  • info() must return the same id and apiVersion as plugin.json; the host cross-checks and refuses to load on mismatch.
  • initialize() runs once at startup (after every plugin is discovered). Register commands, menus, providers, and panels here; return false only on a real failure (the host unloads the plugin and logs it).
  • shutdown() runs at exit (or when the plugin is being unloaded). It must not throw; reset() every token and null your stored service pointers.

RAII tokens

Every registration returns a token (CommandToken, MenuToken, DockToken, SettingsPageToken, Subscription, …) — a move-only BasicToken that owns the registration: destroying or reset()-ing it revokes the command / menu item / panel / subscription. Keep each token as a member and release it in shutdown(). Two accessors matter:

  • id() — non-destructive TokenId view, for APIs that address the item without taking it (e.g. IMenuRegistry::setItemChecked / setItemEnabled).
  • release() — detaches and returns the id; the token no longer revokes anything. Rarely what you want.

The service locator

initialize() receives an IPluginContext, a service locator owned by the host. Do not store the context past initialize(); store the individual service references you need (each service outlives every plugin).

Accessor Interface Use for
ctx.editor() IEditorService read/edit buffer, selection, caret, files, search, bookmarks, indentation, split-view groups
ctx.commands() ICommandRegistry register / invoke user actions (with shortcuts)
ctx.menus() IMenuRegistry menu items, separators, checkable toggles, greying items out
ctx.docks() IDockRegistry dockable side panels
ctx.documents() IDocumentTypeRegistry first-class MainWindow tab types (diff, log view, …)
ctx.compare() ICompareService open the two-pane compare view
ctx.diagnostics() IDiagnosticsSink publish squiggles + margin glyphs
ctx.events() IEventBus subscribe to / publish editor events
ctx.completions() ICompletionRegistry contribute auto-completion candidates
ctx.contextMenu() IContextMenuRegistry dynamic entries in the editor's right-click menu
ctx.snippetSessions() ISnippetSessionService provide Tab-expandable snippets (host runs the field session)
ctx.hover() IHoverRegistry hover tooltips
ctx.signatureHelp() ISignatureHelpRegistry call tips on ( / ,
ctx.symbols() ISymbolRegistry document / workspace symbols (go-to-symbol)
ctx.languages() ILanguageRegistry list languages, read / switch a document's language, contribute a language definition
ctx.statusBar() IStatusBar transient messages + persistent segments
ctx.settings() ISettings persistent per-plugin key/value config
ctx.appSettings() const ISettings& read app-wide settings (AppSettingsKeys.h)
ctx.documentSettings() IDocumentSettings .editorconfig-layered per-document config
ctx.settingsRegistry() ISettingsRegistry add a page to the Preferences dialog
ctx.themePalette() IThemePalette named theme colours, Theme Editor integration
ctx.lineMarkers() ILineMarkerService margin glyphs and line tints that follow edits (breakpoints, change strips); the claim on the margin click
ctx.log() ILogger trace / debug / info / warning / error
ctx.pluginDataDir() std::filesystem::path your private writable directory
ctx.diagnosticsDir() std::filesystem::path shared crash-artifacts + log dir (read-only for plugins)

Extending the API later means adding a new service interface and accessor — a compatible addition under the versioning rule: existing plugins keep loading. The toolchain a plugin build itself has to match (compiler, runtime, Qt minor) is listed under Writing a plugin, "Build requirements".

IEditorService — the document model

The editor as seen by plugins — no Scintilla, no Qt. Documents are referenced by an opaque DocumentId (std::uint64_t, 0 = invalid). Offsets are byte offsets into UTF-8 text; MatchRange is half-open {start, end} with noMatch() meaning "not found"; EditorPosition is 0-based {line, column}.

// Documents
DocumentId               activeDocument() const;
std::vector<DocumentId>  openDocuments() const;
void                     setActiveDocument(DocumentId);
std::optional<std::filesystem::path> filePath(DocumentId) const;
DocumentId               openFile(std::filesystem::path);
DocumentId               openFileAt(std::filesystem::path, EditorPosition); // jump there
void                     revealLine(DocumentId, std::int64_t line);        // show, don't move
bool                     saveDocument(DocumentId);
bool                     closeDocument(DocumentId);  // prompts if modified
bool                     isModified(DocumentId) const;
DocumentEncoding         documentEncoding(DocumentId) const;
void                     setDocumentEncoding(DocumentId, DocumentEncoding);
LineEnding               lineEnding(DocumentId) const;
void                     setLineEnding(DocumentId, LineEnding);

// Text, selection, caret
std::string   text(DocumentId) const;              // whole buffer
std::string   textRange(DocumentId, start, end) const;
void          setText(DocumentId, std::string_view);
std::int64_t  length(DocumentId) const;
std::string   selectedText(DocumentId) const;
void          replaceSelection(DocumentId, std::string_view);
MatchRange    selectionRange(DocumentId) const;
void          setSelection(DocumentId, MatchRange);
EditorPosition caret(DocumentId) const;
void          setCaret(DocumentId, EditorPosition);

// Search & replace (SearchOptions: case, whole-word, regex, wrap)
MatchRange    findInRange(DocumentId, needle, range, SearchOptions) const;
void          replaceTarget(DocumentId, MatchRange, std::string_view);

// Bookmarks, marks, URL highlighting
void toggleBookmark(DocumentId, line);   std::vector<std::int64_t> bookmarkedLines(DocumentId) const;
void markRange(DocumentId, MatchRange);  void clearMarks(DocumentId);

// Language & indentation
std::string      colorizerId(DocumentId) const;      // "cpp", "" if none
IndentationStyle indentation(DocumentId) const;      // {useTabs, width}
void             setIndentation(DocumentId, bool useTabs, int width);
void             setWrapIndent(DocumentId, WrapIndentMode); // where wrapped
WrapIndentMode   wrapIndent(DocumentId) const;             // continuation rows
                 // start: LeftMargin / SameAsLine / Indented

// Semantic classification (from the active colorizer's style rules;
// cheap -- no re-lex). CategorySpan = {start, length, SyntaxCategory}.
// The spell checker keeps to Comment/Documentation/String spans this way.
std::vector<CategorySpan> textCategories(DocumentId, start, end) const;

// Clipboard
std::string clipboardText() const;

It also exposes the split-view tab-group layout in backend-neutral terms (groupCount(), groupOf(), groupWeights(), moveDocumentToGroup(), setGroupWeights()), so a plugin can capture and restore pane arrangements without seeing Qt's splitter. See PluginApi/IEditorService.h for the full list — the header is the authoritative reference.

A document shown in two panes is one document with one DocumentId, so groupOf() can only name one of its seats. Two methods describe and rebuild a split:

std::vector<int> groupsOf(DocumentId doc) const;      // every pane showing it
bool showDocumentInGroup(DocumentId doc, int group);  // put it in one more

showDocumentInGroup() activates a view already in that pane, and otherwise creates a second shared view there — the same thing View ▸ Split Document Right does, so no DocumentOpened is raised and both views edit one document. Panes are the plugin-facing concept; individual views are not, which is why the call names a pane rather than handing back a view.

Taking the user somewhere

openFileAt(path, position) is the one call behind "go to build error", "jump to stack frame" or "open search hit", so they all land the same way. It opens the file exactly as openFile() does (an open file's tab is activated, not opened twice), makes it the active document, places the caret with no selection, unfolds a fold that hides the line, scrolls the line into view — centred when it was off-screen, not scrolled when it was already visible — and gives the editor keyboard focus. The position is clamped, never refused: a line past the end lands on the last line, a column past the end of the line lands at its end, negative values count as 0. It returns the document the caret was placed in, or 0 when the file could not be opened — and then nothing moved.

revealLine(doc, line) shows a place without taking the user there: it unfolds and scrolls the pane that shows doc, but does not move the caret or the selection, does not switch tabs and does not take focus — a panel previewing a stack frame or following its selection uses it.

Document encoding

A document's on-disk character encoding is core state, not a plugin's. The host detects it when the file is read and writes the same encoding back on every save, so a UTF-8-BOM file keeps its mark and a UTF-16 file stays UTF-16. DocumentEncoding (PluginApi/DocumentEncoding.h) is a backend-agnostic POD enum covering the five Unicode/ANSI primaries plus the legacy code pages.

setDocumentEncoding() records a choice: it changes no bytes and re-decodes nothing — the buffer is already Unicode, and the new encoding takes effect on the next save. A plugin offering a "convert to" action should therefore also call setModified(doc, true); one offering "interpret as" reloads the text itself first and then records the choice. The host publishes DocumentEncodingChanged whenever the value actually changes. A project .editorconfig charset still wins at save time.

Document line endings

A document's line terminator is core state too. The host detects it when the file is read, pushes it into the view so Enter inserts that sequence, and writes the buffer's bytes back verbatim — so a Unix file edited on Windows stays LF and a save with no edits is byte-identical. LineEnding (PluginApi/LineEnding.h) is a backend-agnostic POD enum — Unknown / Lf / CrLf / Cr — naming the byte sequence rather than a backend constant. It ships two inline helpers: lineEndingBytes() and platformLineEnding() (what a new buffer starts with: CRLF on Windows, LF elsewhere).

setLineEnding() records the choice, retunes the document's view so the next Enter inserts it, and decides what the next save writes. It converts no existing text: a plugin rewriting the whole buffer (the EOL Conversion commands) replaces the text itself first, so the conversion stays one undoable edit. The host publishes DocumentLineEndingChanged whenever the value actually changes. A project .editorconfig end_of_line still wins at save time, and moves the document onto that convention with it.

Threading

All context and service calls happen on the host's UI thread. Do not call services from a background thread you spawned; marshal back first.

Versioning

The contract carries a two-part version, both in PluginApi/PluginInfo.h:

constexpr std::uint32_t MTE_PLUGIN_API_VERSION       = 1; // major
constexpr std::uint32_t MTE_PLUGIN_API_MINOR_VERSION = 0; // minor

What decides between them is who implements the interface:

Change Kind Version
A method appended at the end of an interface only the editor implements and plugins only call (IEditorService, IPluginContext, every I…Registry, ILineMarkerService, …) compatible minor + 1
A new interface, a new event struct, a new enumerator at the end of an enum compatible minor + 1
Any change to a struct that crosses the boundary (PluginInfo, ContextMenuRequest, DockPanelDescriptor, an event struct) breaking major + 1
A changed signature, a removed or reordered method, a new overload of an existing name breaking major + 1
Any change to an interface a plugin implements (IPlugin, IHoverProvider, IContextMenuProvider, IDockPanel, ISettingsPage, …) breaking major + 1
A release built with a toolchain whose binary interface differs from the previous one — another compiler family or standard library, a new Qt major breaking major + 1

A newer toolset of the same compiler family, a different C++ standard level and a newer Qt minor are not such a break and move neither number; see Build requirements for what a native plugin has to match.

The editor loads a plugin only when

  • apiVersion in plugin.json and IPlugin::info().apiVersion equal its own major, and
  • apiMinorVersion in plugin.json (optional, default 0) and IPlugin::info().apiMinorVersion are not above its own minor. The value in info() defaults to the minor of the headers the plugin was compiled against; a plugin that knows it uses nothing newer may state a lower number in plugin.json, and must still pass the second check.

A refused plugin stays listed on Preferences ▸ Plugins with the reason ("Incompatible (API mismatch)", "Needs a newer version of OctoEdit") and gets a log entry — never a crash. A Python plugin has no info() compiled against the headers, so its plugin.json is its only statement: declare the minor of the newest call you use.

Pre-release policy

Before the first public release the contract may still change without bumping either number; both stay at 1 / 0 until then. The table above is what every change is judged by from the first release on.