Skip to content

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;
};
  • source is 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;
}
  • registerProvider returns a HoverToken (RAII); destroy it in shutdown() to unregister.
  • Every provider registers at a priority. Pick the band that describes what you answer, not how important it feels: kFallback for word- or text-level information (a dictionary definition, a URL target), kLanguage for language-level information about the symbol (a language server's hover, a linter's rule), kActiveSession for 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 kActiveSession provider 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-based line and a byte column within 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 / requestWorkspaceSymbols on the registry are the pull counterpart of registerProvider — 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).