Language intelligence (diagnostics, hover, signature help, symbols)¶
A small, backend-neutral contract family for language intelligence — the data a
provider feeds to the editor's code-intelligence surfaces. It follows the
project's rule "plugins contribute data, core renders": the squiggles, the
hover popup and caret jumps live in the editor view and are never exposed to
plugins; a plugin only supplies POD/std::string data, so the same contract can
later be fulfilled by a Python backend.
The first consumer is the LSP plugin, but the channels are generic — a linter or spell-checker can publish diagnostics the same way.
What is not here
Go-to-definition, find-references, rename and formatting need no API of
their own — they only navigate or edit text, which IEditorService already
expresses (caret, openFile, setCaret, setText), so they stay in-plugin
commands. Completion reuses the existing ICompletionRegistry. A new
contract is added only when there is a core rendering/picker surface and
potentially several providers — which is the case for diagnostics, hover,
signature help, and symbols.
Diagnostics — push¶
A provider pushes diagnostics whenever it has them (it decides when results
are ready); the editor renders them as squiggly underlines. Reach the sink via
IPluginContext::diagnostics(). The value types are plain POD
(PluginApi/DiagnosticTypes.h):
enum class DiagnosticSeverity { Error, Warning, Information, Hint };
struct Diagnostic {
MatchRange range; // UTF-8 byte range in the document
DiagnosticSeverity severity = DiagnosticSeverity::Error;
std::string message;
std::string code;
};
struct IDiagnosticsSink { // PluginApi/IDiagnosticsSink.h
virtual void publishDiagnostics(DocumentId doc, std::string_view source,
const std::vector<Diagnostic>& diags) = 0;
virtual void clearDiagnostics(DocumentId doc) = 0;
};
sourceis a per-(doc, source)replace key. Publishing for a source replaces that source's previous diagnostics on the document, so multiple producers (LSP, spell-check, build-log) coexist without clobbering each other.- Publish an empty vector for a source to retract its diagnostics.
- Severities render with distinct colours (error/warning/info/hint); the editor also widens very short ranges so a single-character marker stays visible.
ctx.diagnostics().publishDiagnostics(doc, "org.mycompany.linter", {
{ byteRange, DiagnosticSeverity::Warning, "unused variable", "W001" },
});
The host always provides a valid sink (a no-op when the editor has no diagnostics
surface), so you never need to null-check diagnostics().
Hover — async pull¶
Hover is driven by the editor (mouse dwell over a symbol). A plugin
registers a provider that answers asynchronously; reach the registry via
IPluginContext::hover() (PluginApi/IHoverRegistry.h):
struct HoverRequest { DocumentId doc; std::int64_t position; }; // byte offset
using HoverReply = std::function<void(std::string markup)>; // empty = nothing
struct IHoverProvider {
virtual void requestHover(const HoverRequest&, HoverReply reply) = 0;
};
struct IHoverRegistry {
virtual HoverToken registerProvider(IHoverProvider& provider,
std::int32_t priority) = 0;
};
namespace hover_priority {
inline constexpr std::int32_t kFallback = 0;
inline constexpr std::int32_t kLanguage = 100;
inline constexpr std::int32_t kActiveSession = 1000;
}
registerProviderreturns aHoverToken(RAII); destroy it inshutdown()to unregister.- Every provider registers at a priority. Pick the band that describes
what you answer, not how important it feels:
kFallbackfor word- or text-level information (a dictionary definition, a URL target),kLanguagefor language-level information about the symbol (a language server's hover, a linter's rule),kActiveSessionfor information that must win while it is active (a debugger's value under the pointer). Values between the bands (kLanguage + 10) are allowed when you must order yourself against a known neighbour. - On dwell the host asks every provider at once and shows the non-empty reply of the highest-priority provider; equal priorities go to the provider registered first. A lower-priority reply that arrives first is held until every higher provider has replied empty. When every provider replies empty, nothing is shown.
- A slow provider cannot hold the tooltip back for ever: 300 ms after the
request the best reply received so far is shown and later replies for that
request are dropped. A
kActiveSessionprovider must therefore reply empty at once when it has nothing to say (no session) — otherwise it delays every hover in the editor by up to the timeout. - Replies that arrive after the user moved on are dropped.
- The reply is plain markup text and is rendered as Markdown in a popup, so headings, bold and code blocks work — keep the contract string-in / string-out.
class MyHover final : public IHoverProvider {
public:
void requestHover(const HoverRequest& r, HoverReply reply) override {
// ...look up info for r.doc at byte offset r.position, possibly async...
reply("**foo** — a thing\n\n```cpp\nint foo();\n```");
}
};
// in initialize():
// m_token = ctx.hover().registerProvider(m_hover, hover_priority::kLanguage);
Signature help — async pull¶
Like hover, but driven by typing a call's ( or a ,. A provider answers the
active signature; the host renders it as a call tip with the active parameter
highlighted. Reach the registry via IPluginContext::signatureHelp()
(PluginApi/ISignatureHelpRegistry.h):
struct SignatureHelpRequest { DocumentId doc; std::int64_t position; std::string languageId; };
struct SignatureHelp { std::string signature; std::int32_t activeStart, activeEnd; }; // byte range in `signature`
using SignatureHelpReply = std::function<void(SignatureHelp)>; // empty signature = nothing
struct ISignatureHelpProvider {
virtual void requestSignatureHelp(const SignatureHelpRequest&, SignatureHelpReply) = 0;
};
struct ISignatureHelpRegistry { virtual SignatureHelpToken registerProvider(ISignatureHelpProvider&) = 0; };
The host shows the first non-empty reply; [activeStart, activeEnd) is the
byte span of the active parameter within signature, used for the highlight.
Symbols — async pull (go-to-symbol)¶
A provider enumerates a document's symbols (and searches the workspace); the host
renders the picker (the Command Palette's Ctrl+Shift+O / Ctrl+T modes) and any
plugin can pull the aggregated symbols — the Function List dock does, making
this a genuinely multi-consumer contract.
Reach it via IPluginContext::symbols() (PluginApi/ISymbolRegistry.h):
struct SymbolLocation { std::string path; std::int32_t line, column; }; // path empty = request doc; column = byte offset in line
struct SymbolInfo { std::string name; std::int32_t kind; std::string containerName; SymbolLocation location; }; // kind = LSP SymbolKind (1..26)
using SymbolReply = std::function<void(std::vector<SymbolInfo>)>;
struct ISymbolProvider {
virtual void requestDocumentSymbols(const DocumentSymbolRequest&, SymbolReply) = 0; // { DocumentId doc; std::string languageId; }
virtual void requestWorkspaceSymbols(const WorkspaceSymbolRequest&, SymbolReply) = 0; // { std::string query; }
};
struct ISymbolRegistry {
virtual SymbolToken registerProvider(ISymbolProvider&) = 0; // producer side
// consumer side (pull) — used by the Command Palette and the Function List dock:
virtual void requestDocumentSymbols(const DocumentSymbolRequest&, SymbolReply) const = 0;
virtual void requestWorkspaceSymbols(const WorkspaceSymbolRequest&, SymbolReply) const = 0;
};
- Locations are expressed as the host navigates: a filesystem
path(empty means the request's document) plus a 0-basedlineand a bytecolumnwithin it — no further conversion needed at the call site. - The host delivers the first non-empty symbol list, or an empty list once every provider has answered empty (so a waiting picker is always told something).
- Consumer side:
requestDocumentSymbols/requestWorkspaceSymbolson the registry are the pull counterpart ofregisterProvider— a plugin calls them to read what other plugins (e.g. a language server) contribute. The reply fires once, on the UI thread, possibly later (async). The Function List dock uses this ask-and-fall-back: it shows its own regex outline instantly and, if a non-empty reply arrives, upgrades to it; an empty reply never clears the baseline. Guard a late reply against teardown and against a newer request (doc id + generation).
Languages — enumerate, switch, contribute¶
Syntax colouring is a core feature: the host owns a colorizer catalog
(the shipped colorizers/*.json files plus the user's own), detects a
document's language from its path and first line, and colours the view
without any plugin. ctx.languages() (ILanguageRegistry) is how a plugin
joins in:
// Enumeration -- e.g. to fill a per-language settings dropdown.
std::vector<LanguageInfo> langs = ctx.languages().availableLanguages(); // {id, name}, by name
// Per-document state. languageFor() reports the same id as
// IEditorService::colorizerId(); setLanguageFor() takes exactly the path
// the Language menu takes ("" clears colouring; false = unknown id / doc).
std::string id = ctx.languages().languageFor(doc);
bool ok = ctx.languages().setLanguageFor(doc, "json");
bool redone = ctx.languages().autoDetectFor(doc); // undo a manual override
// Contribution: a whole language definition, in the same shape as a
// shipped colorizer file -- backend lexer NAME, style NAMES -> category,
// keyword groups by the lexer's own slot description. Never engine ids.
LanguageDefinition def;
def.id = "mylang"; def.name = "My Language"; def.lexer = "cpp";
def.extensions = {"mylang"};
def.keywords = {{"Primary keywords and identifiers", "if else while"}};
def.styles = {{"SCE_C_COMMENT", SyntaxCategory::Comment},
{"SCE_C_STRING", SyntaxCategory::String}};
m_lang = ctx.languages().registerLanguage(def); // LanguageToken (RAII)
A contributed language behaves exactly like a shipped one: the host resolves
the names against the backend lexer's metadata and the active theme supplies
the colours. Registering an id that already exists replaces it for as long
as the token lives; when the token dies the displaced definition comes back
and open documents using the id are re-coloured — so a plugin can override a
shipped language without permanently damaging the catalog. Contributions also
survive the catalog reload that a Preferences ▸ Apply or a theme switch
triggers. The host cannot gain new backend lexers at runtime — lexer must
name one that exists. Always valid: a host without a colouring layer supplies
a no-op that enumerates nothing and refuses every change.
Status¶
All four contracts (diagnostics, hover, signature help, symbols) were added
pre-release without bumping MTE_PLUGIN_API_VERSION; the language surface
(ILanguageRegistry) followed the same way. Their first consumer is the
LSP plugin; the symbol contract is intentionally
multi-consumer (the Command Palette today, the Function List dock next).