Aller au contenu

API de plugin — vue d'ensemble

Tous les types de PluginApi vivent dans namespace MTE::plugin. Les en-têtes publics ne peuvent inclure aucun en-tête Qt, Scintilla ou interne à MTE — uniquement la bibliothèque standard. La seule exception délibérée est le dossier optionnel PluginApi/Qt/ (PluginQtGlue.h, ISettingsPage.h, ThemeFormat.h) pour les plugins qui livrent leurs propres widgets Qt.

Le même contrat est rempli par deux backends : le chargeur natif Qt (bibliothèques partagées) et le backend Python hors processus (plugins Python). Cette section documente la surface C++ ; chaque service décrit ici qui est neutre vis-à-vis du backend a un équivalent Python sur ctx.

Cycle de vie du plugin

Un plugin natif implémente une seule classe :

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() doit renvoyer les mêmes id et apiVersion que plugin.json ; l'hôte effectue une vérification croisée et refuse le chargement en cas d'incohérence.
  • initialize() s'exécute une fois au démarrage (après la découverte de tous les plugins). Enregistrez-y vos commandes, menus, fournisseurs et panneaux ; ne renvoyez false qu'en cas d'échec réel (l'hôte décharge alors le plugin et journalise l'incident).
  • shutdown() s'exécute à la sortie (ou quand le plugin est déchargé). Elle ne doit pas lever d'exception ; faites reset() sur chaque jeton et mettez à null vos pointeurs de services stockés.

Jetons RAII

Chaque enregistrement renvoie un jeton (CommandToken, MenuToken, DockToken, SettingsPageToken, Subscription, …) — un BasicToken déplaçable uniquement (move-only) qui possède l'enregistrement : le détruire ou appeler reset() révoque la commande / l'élément de menu / le panneau / l'abonnement. Conservez chaque jeton comme membre et libérez-le dans shutdown(). Deux accesseurs comptent :

  • id() — vue TokenId non destructive, pour les API qui désignent l'élément sans en prendre possession (p. ex. IMenuRegistry::setItemChecked / setItemEnabled).
  • release() — détache et renvoie l'id ; le jeton ne révoque alors plus rien. Rarement ce que vous voulez.

Le localisateur de services

initialize() reçoit un IPluginContext, un localisateur de services possédé par l'hôte. Ne stockez pas le contexte au-delà d'initialize() ; stockez les références des services individuels dont vous avez besoin (chaque service survit à tous les plugins).

Accesseur Interface À utiliser pour
ctx.editor() IEditorService lire/modifier le tampon, la sélection, le caret, les fichiers, la recherche, les signets, l'indentation, les groupes de vue scindée
ctx.commands() ICommandRegistry enregistrer / invoquer des actions utilisateur (avec raccourcis)
ctx.menus() IMenuRegistry éléments de menu, séparateurs, bascules cochables, éléments grisés
ctx.docks() IDockRegistry panneaux latéraux ancrables
ctx.documents() IDocumentTypeRegistry types d'onglets de première classe de la MainWindow (diff, vue de log, …)
ctx.compare() ICompareService ouvrir la vue de comparaison à deux volets
ctx.diagnostics() IDiagnosticsSink publier des soulignements ondulés + glyphes de marge
ctx.events() IEventBus s'abonner aux événements de l'éditeur / en publier
ctx.completions() ICompletionRegistry contribuer des candidats d'auto-complétion
ctx.contextMenu() IContextMenuRegistry entrées dynamiques du menu contextuel de l'éditeur
ctx.snippetSessions() ISnippetSessionService fournir des snippets extensibles par Tab (l'hôte gère la session de champs)
ctx.hover() IHoverRegistry infobulles de survol
ctx.signatureHelp() ISignatureHelpRegistry info-bulles d'appel sur ( / ,
ctx.symbols() ISymbolRegistry symboles du document / de l'espace de travail (aller au symbole)
ctx.languages() ILanguageRegistry lister les langages, lire / changer le langage d'un document, contribuer une définition de langage
ctx.statusBar() IStatusBar messages transitoires + segments persistants
ctx.settings() ISettings configuration clé/valeur persistante par plugin
ctx.appSettings() const ISettings& lire les réglages globaux de l'application (AppSettingsKeys.h)
ctx.documentSettings() IDocumentSettings configuration par document superposée par .editorconfig
ctx.settingsRegistry() ISettingsRegistry ajouter une page à la boîte de dialogue Préférences
ctx.themePalette() IThemePalette couleurs de thème nommées, intégration avec l'éditeur de thèmes
ctx.lineMarkers() ILineMarkerService glyphes de marge et teintes de ligne qui suivent les modifications (points d'arrêt, bandes de modifications) ; la revendication du clic dans la marge
ctx.log() ILogger trace / debug / info / warning / error
ctx.pluginDataDir() std::filesystem::path votre répertoire privé accessible en écriture
ctx.diagnosticsDir() std::filesystem::path répertoire partagé des artefacts de crash + journaux (lecture seule pour les plugins)

Étendre l'API plus tard consiste à ajouter une nouvelle interface de service et son accesseur — un ajout compatible selon la règle de versionnement : les plugins existants continuent de se charger. La chaîne d'outils que le build du plugin lui-même doit respecter (compilateur, runtime, version mineure de Qt) est décrite dans Écrire un plugin, section « Prérequis de compilation ».

IEditorService — le modèle de document

L'éditeur tel que vu par les plugins — sans Scintilla, sans Qt. Les documents sont référencés par un DocumentId opaque (std::uint64_t, 0 = invalide). Les offsets sont des offsets en octets dans le texte UTF-8 ; MatchRange est un intervalle semi-ouvert {start, end} où noMatch() signifie « non trouvé » ; EditorPosition est un {line, column} en base 0.

// 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;

Il expose aussi la disposition des groupes d'onglets de la vue scindée en termes neutres vis-à-vis du backend (groupCount(), groupOf(), groupWeights(), moveDocumentToGroup(), setGroupWeights()), afin qu'un plugin puisse capturer et restaurer l'agencement des volets sans voir le splitter de Qt. Consultez PluginApi/IEditorService.h pour la liste complète — l'en-tête est la référence qui fait foi.

Un document affiché dans deux volets est un seul document, avec un seul DocumentId : groupOf() ne peut donc nommer qu'une de ses places. Deux méthodes décrivent et reconstruisent une scission :

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

showDocumentInGroup() active une vue déjà présente dans ce volet et, sinon, y crée une seconde vue partagée — exactement ce que fait Affichage ▸ Scinder le document à droite — si bien qu'aucun DocumentOpened n'est émis et que les deux vues éditent un seul document. Les volets sont le concept exposé aux extensions, les vues individuelles ne le sont pas : l'appel désigne donc un volet au lieu de rendre une poignée de vue.

Emmener l'utilisateur quelque part

openFileAt(path, position) est l'unique appel derrière « aller à l'erreur de compilation », « sauter à la trame de pile » ou « ouvrir le résultat de recherche », si bien que tous arrivent de la même façon. Il ouvre le fichier exactement comme openFile() (l'onglet d'un fichier déjà ouvert est activé, pas ouvert une seconde fois), en fait le document actif, place le curseur sans sélection, déplie un repli qui masque la ligne, fait défiler la ligne dans la vue — centrée si elle était hors de l'écran, sans défilement si elle était déjà visible — et donne le focus clavier à l'éditeur. La position est bornée, jamais refusée : une ligne au-delà de la fin arrive sur la dernière ligne, une colonne au-delà de la fin de la ligne arrive à sa fin, les valeurs négatives comptent pour 0. Il renvoie le document dans lequel le curseur a été placé, ou 0 si le fichier n'a pas pu être ouvert — et alors rien n'a bougé.

revealLine(doc, line) montre un endroit sans y emmener l'utilisateur : il déplie et fait défiler le volet qui affiche doc, mais ne déplace ni le curseur ni la sélection, ne change pas d'onglet et ne prend pas le focus — un panneau qui prévisualise une trame de pile ou suit sa sélection l'utilise.

Encodage du document

L'encodage de caractères d'un document sur le disque est un état du cœur, pas celui d'une extension. L'hôte le détecte à la lecture du fichier et réécrit le même encodage à chaque enregistrement, si bien qu'un fichier UTF-8-BOM conserve sa marque et qu'un fichier UTF-16 reste en UTF-16. DocumentEncoding (PluginApi/DocumentEncoding.h) est un énuméré POD neutre vis-à-vis du backend couvrant les cinq encodages Unicode/ANSI principaux ainsi que les pages de codes héritées.

setDocumentEncoding() enregistre un choix : il ne change aucun octet et ne redécode rien — le tampon est déjà en Unicode, et le nouvel encodage prend effet au prochain enregistrement. Une extension qui propose une action « convertir en » doit donc aussi appeler setModified(doc, true) ; une extension qui propose « interpréter comme » recharge d'abord le texte elle-même, puis enregistre le choix. L'hôte publie DocumentEncodingChanged dès que la valeur change réellement. Un charset dans le .editorconfig du projet reste prioritaire au moment de l'enregistrement.

Fins de ligne d'un document

La fin de ligne d'un document est elle aussi un état du cœur. L'hôte la détecte à la lecture du fichier, la transmet à la vue pour que Enter insère cette séquence, et réécrit les octets du tampon tels quels — un fichier Unix édité sous Windows reste donc en LF, et un enregistrement sans modification est identique octet pour octet. LineEnding (PluginApi/LineEnding.h) est une énumération POD indépendante du backend — Unknown / Lf / CrLf / Cr — qui nomme la séquence d'octets plutôt qu'une constante de backend. Elle fournit deux aides inline : lineEndingBytes() et platformLineEnding() (ce avec quoi démarre un nouveau tampon : CRLF sous Windows, LF ailleurs).

setLineEnding() enregistre le choix, réaccorde la vue du document pour que le prochain Enter l'insère, et décide de ce qu'écrira l'enregistrement suivant. Elle ne convertit aucun texte existant : une extension qui réécrit tout le tampon (les commandes de Conversion des fins de ligne) remplace le texte elle-même d'abord, afin que la conversion reste une seule édition annulable. L'hôte publie DocumentLineEndingChanged dès que la valeur change réellement. Un end_of_line du .editorconfig du projet l'emporte toujours à l'enregistrement, et bascule le document sur cette convention avec lui.

Threads

Tous les appels au contexte et aux services ont lieu sur le thread UI de l'hôte. N'appelez pas les services depuis un thread d'arrière-plan que vous avez créé ; revenez d'abord sur le thread UI.

Gestion des versions

Le contrat porte une version en deux parties, toutes deux dans PluginApi/PluginInfo.h :

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

Ce qui décide entre les deux, c'est qui implémente l'interface :

Changement Nature Version
Une méthode ajoutée à la fin d'une interface que seul l'éditeur implémente et que les plugins se contentent d'appeler (IEditorService, IPluginContext, chaque I…Registry, ILineMarkerService, …) compatible mineure + 1
Une nouvelle interface, une nouvelle structure d'événement, un nouvel énumérateur à la fin d'une énumération compatible mineure + 1
Toute modification d'une structure qui franchit la frontière (PluginInfo, ContextMenuRequest, DockPanelDescriptor, une structure d'événement) incompatible majeure + 1
Une signature modifiée, une méthode supprimée ou réordonnée, une nouvelle surcharge d'un nom existant incompatible majeure + 1
Toute modification d'une interface qu'un plugin implémente (IPlugin, IHoverProvider, IContextMenuProvider, IDockPanel, ISettingsPage, …) incompatible majeure + 1
Une version construite avec une chaîne d'outils dont l'interface binaire diffère de la précédente — une autre famille de compilateurs ou une autre bibliothèque standard, une nouvelle version majeure de Qt incompatible majeure + 1

Un jeu d'outils plus récent de la même famille de compilateurs, un autre niveau de norme C++ et une version mineure plus récente de Qt ne constituent pas une telle rupture et ne modifient aucun des deux nombres ; voir Prérequis de compilation pour ce qu'un plugin natif doit respecter.

L'éditeur ne charge un plugin que si

  • apiVersion dans plugin.json et IPlugin::info().apiVersion sont égaux à sa propre version majeure, et
  • apiMinorVersion dans plugin.json (facultatif, 0 par défaut) et IPlugin::info().apiMinorVersion ne dépassent pas sa propre version mineure. La valeur de info() vaut par défaut la mineure des en-têtes avec lesquels le plugin a été compilé ; un plugin qui sait n'utiliser rien de plus récent peut indiquer un nombre plus bas dans plugin.json, et doit tout de même passer la seconde vérification.

Un plugin refusé reste listé dans Préférences ▸ Extensions avec la raison (« Incompatible (incompatibilité d'API) », « Nécessite une version plus récente d'OctoEdit ») et reçoit une entrée de journal — jamais un plantage. Un plugin Python n'a pas d'info() compilé avec les en-têtes, son plugin.json est donc sa seule déclaration : indiquez la mineure de l'appel le plus récent que vous utilisez.

Politique de pré-version

Avant la première version publique, le contrat peut encore changer sans incrémenter l'un ou l'autre nombre ; d'ici là, ils restent à 1 / 0. À partir de la première version, chaque changement est jugé selon le tableau ci-dessus.