60 minutes pour créer un plugin Text Editor pour Eclipse
N’avez-vous jamais été confronté à un format de fichier, voir un langage quelque peu exotique ? Souvent, lire ou modifier ces fichiers dans un éditeur est pénible. On aimerait avoir de la coloration syntaxique, de la complétion, des liens entre les mots clefs, l’affichage de la documentation…
Écrire un éditeur pour ça serait trop couteux. En revanche, écrire un plugin Eclipse qui permet d’éditer ces fichiers n’est pas très compliqué. L’exemple qui nous servira de support ici est le DSL de tests d'intégration utilisé dans le framework de test GWT-test-utils.
Les images ci après montrent les différences entre la version sans coloration syntaxique et la version avec coloration syntaxique.
La suite de cet article présente ce qu'il faut faire pour créér un plugin Text Editor avec de la coloration syntaxique et de la complétion sur les mots-clés.
Lister les mots clefs
Dans notre exemple, on souhaite distinguer trois catégories de mots clefs :
- Les mots clefs violets : runmacro, assertExact, ...
- Les mots clefs bleus : tearDown, …
- Les mots clefs verts « macros » : checkContact, checkSecurity, …
A partir de là il faut un peu moins de 30 minutes pour réaliser le plugin éditeur de texte avec coloration syntaxique…
Créer le projet eclipse pour le plugin
- Créer un nouveau projet choisir le « Plug-in Project ».
- Ajouter les dépendances org.eclipse.jface.text et org.eclipse.ui.editors. Ces dépendances sont utilisées pour l’implémentation de notre plugin éditeur de texte.
- Ajouter l’extension org.eclipse.ui.editors et éditer le fichier plugin.xml pour y ajouter la définition de l’éditeur
Créer les classes nécessaires à la coloration syntaxique
La figure ci après présente l’ensemble des classes utilisées pour la coloration syntaxique La classe Editor (voir le code) correspond à la définition de l’éditeur. Au chargement du document, on attribue à l'éditeur une "configuration" ( EditorSourceViewerConfiguration ) qui définit le comportement de l'éditeur. Dans la classe EditorSourceViewerConfiguration (voir le code), la méthode getPresentationReconciler est surchargée pour que le text editor soit notifié et traite toutes les modifications de texte. Il est à noter que pour le reconciler :
- le damager porte la responsabilité de définir la région du document à reconstruire
- le repairer porte la responsabilité de reconstruire une portion de document modifiée
public IPresentationReconciler getPresentationReconciler(ISourceViewer sourceViewer) {
PresentationReconciler reconciler = new PresentationReconciler();
DefaultDamagerRepairer dr = new DefaultDamagerRepairer(getScanner());
reconciler.setDamager(dr, IDocument.DEFAULT_CONTENT_TYPE);
reconciler.setRepairer(dr, IDocument.DEFAULT_CONTENT_TYPE);
return reconciler;
}
Dans la classe EditorScanner (voir le code) sont définis les styles (fonte, couleur) des différents mots clés détectés :
RGB KEYWORD= new RGB(140, 10, 210);
IToken keyword = new Token(new TextAttribute(new Color(Display.getCurrent(), KEYWORD), null, SWT.BOLD));
Cette classe définit aussi les styles à appliquer en fonction des mots clés. Pour détecter ces mots clés, on créé une règle utilisée lors du parsing du document. La règle ci après définit quels sont les symboles par lesquels commence un mot clé et quels sont les symboles du reste du mot.
WordRule rule = new WordRule(new IWordDetector() {
public boolean isWordPart(char c) {
return Character.isJavaIdentifierPart(c) ;
}
public boolean isWordStart(char c) {
return Character.isJavaIdentifierStart(c) ;
}
}, defaut);
Pour appliquer un style à un mot, il suffit alors de définir que, pour la règle préalablement définit, lorsque l'on détecte un mot, il faut appliquer le style keyword.
for (String k : KeyWords.KEYWORDS) {
rule.addWord(k, keyword);
}
D'autre types de règles peuvent être définis, c'est le cas de la règle utilisée pour la définition des commentaires. Dans ce cas, on utilise une règle qui s'applique à la ligne complète cette règle indique que toute ligne commençant par ** aura le style comment:
new SingleLineRule("**", null, comment);
Après 30 minutes, voir pour croire
Pour lancer un eclipse avec notre plugin, il faut ouvrir le fichier plugin.xml avec le plug-in Manifest Editor (click droit sur le fichier plugin.xml, puis open with plug-in Manifest Editor) et de lancer l’application depuis l’onglet overview. Il faut se construire un projet avec un fichier d'exemple et ouvrir le fichier par le menu contextuel "open with Editor Sample"
Lors de l'ouverture de notre fichier, les mots clés que nous avons défini sont colorés.
Ajouter la complétion
Pour se faciliter la vie on veut mettre de la complétion sur les mots clés. L’opération nécessite l’ajout de deux classes, l’une pour construire la liste des mots à proposer à partir du début d’un mot (WordProvider), l’autre étant l’implémentation de l’interface IContentAssistProcessor qui est l’objet dont la responsabilité est d’extraire le dernier mot saisi (EditorContentAssistProcessor). Il faut aussi "greffer" notre ContentAssistProcessor…
La classe WordProvider (voir le code) a une seule méthode qui retourne tous les mots débutant par la chaine de caractère passée en paramètre. Dans l'extrait de la classe EditorContentAssistProcessor (voir le code) ci après, on cherche à obtenir la liste de proposition en fonction du contexte d'appel
public ICompletionProposal[] computeCompletionProposals(ITextViewer textViewer, int documentOffset) {
IDocument document = textViewer.getDocument();
int currOffset = documentOffset - 1; //position du cusreur dans le texte
String currWord = "";
char currChar;
// tant que l'on ne rencontre pas de séparateur (espace ou point virgule), on construit le mot et on recule dans le flux
while (currOffset > 0 && !(Character.isWhitespace(currChar = document.getChar(currOffset)) || currChar == ';')) {
currWord = currChar + currWord;
currOffset--;
}
// Calcul de la liste de proposition
List suggestions = wordProvider.suggest(currWord);
ICompletionProposal[] proposals = null;
if (suggestions.size() > 0) {
proposals = buildProposals(suggestions, currWord, documentOffset - currWord.length());
}
return proposals;
}
Pour que notre éditeur propose la complétion, il faut spécifier quel objet sait résoudre les demandes de complétion et activer le raccourci clavier. C'est dans la classe EditorSourceViewerConfiguration que l'on va définir l'assistant à utiliser.
public IContentAssistant getContentAssistant(ISourceViewer sourceViewer) {
assistant = new ContentAssistant();
assistant.setContentAssistProcessor(new EditorContentAssistProcessor(), IDocument.DEFAULT_CONTENT_TYPE);
assistant.setInformationControlCreator(getInformationControlCreator(sourceViewer));
return assistant;
}
Pour activer le raccourci "ctrl+space ", il faut enrichir la méthode createActions de la classe Editor.
protected void createActions() throws Exception{
super.createActions();
ResourceBundle resourceBundle = null;
resourceBundle = new PropertyResourceBundle(
new StringBufferInputStream(
"ContentAssistProposal.label=Content assist\nContentAssistProposal.tooltip=Content assist\nContentAssistProposal.description=Provides Content Assistance"));
ContentAssistAction action = new ContentAssistAction(resourceBundle, "ContentAssistProposal.", this);
action.setActionDefinitionId(ITextEditorActionDefinitionIds.CONTENT_ASSIST_PROPOSALS);
setAction("ContentAssist", action);
}
30 minutes plus tard, voir pour croire…
En moins de 60 minutes vous avez réalisé votre éditeur custom avec coloration syntaxique et complétion. Ce que vous y avez gagné du confort pour vos yeux, du confort pour vos mains, une meilleure adhésion des utilisateurs à votre format de fichier, de la productivité.
Pour aller plus loin, il faut prendre en compte dans certain cas une gestion plus complexe des « keywords ». Dans notre exemple, les mots clés de type macros (coloration verte) sont enrichis au fur et à mesure que l’on en rajoute dans le projet courant. Il faut alors prévoir les mécanismes nécessaires de mise à jour des mots clés. Certains comportements de l'éditeur de texte comme les hyperliens sur les mots clés ou l'affichage de la documentation dans la complétion peuvent encore améliorer le confort d'utilisation.
Annexe : le code des différentes classes
Le code présenté dans l'annexe permet de colorer le mot clé helloWorld et de proposer sa complétion.
Activator
package com.octo.sample;
import org.eclipse.ui.plugin.AbstractUIPlugin;
import org.osgi.framework.BundleContext;
/**
* The activator class controls the plug-in life cycle
*/
public class Activator extends AbstractUIPlugin {
public static final String PLUGIN_ID = "ArticlePlugin";
private static Activator plugin;
public Activator() {
}
public void start(BundleContext context) throws Exception {
super.start(context);
plugin = this;
}
public void stop(BundleContext context) throws Exception {
plugin = null;
super.stop(context);
}
public static Activator getDefault() {
return plugin;
}
}
EditorBack
package com.octo.sample;
import java.io.IOException;
import java.io.StringBufferInputStream;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import org.eclipse.ui.editors.text.TextEditor;
import org.eclipse.ui.texteditor.ContentAssistAction;
import org.eclipse.ui.texteditor.ITextEditorActionDefinitionIds;
@SuppressWarnings("deprecation")
public class Editor extends TextEditor {
public Editor() {
super();
EditorSourceViewerConfiguration configuration = new EditorSourceViewerConfiguration();
setSourceViewerConfiguration(configuration);
}
@Override
protected void createActions() {
super.createActions();
ResourceBundle resourceBundle = null;
try {
resourceBundle = new PropertyResourceBundle(
new StringBufferInputStream(
"ContentAssistProposal.label=Content assist\nContentAssistProposal.tooltip=Content assist\nContentAssistProposal.description=Provides Content Assistance"));
} catch (IOException e) {
e.printStackTrace();
}
ContentAssistAction action = new ContentAssistAction(resourceBundle, "ContentAssistProposal.", this);
String id = ITextEditorActionDefinitionIds.CONTENT_ASSIST_PROPOSALS;
action.setActionDefinitionId(id);
setAction("ContentAssist", action);
}
}
EditorContentAssistProcessor Back
package com.octo.sample;
import java.util.Iterator;
import java.util.List;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.contentassist.CompletionProposal;
import org.eclipse.jface.text.contentassist.ICompletionProposal;
import org.eclipse.jface.text.contentassist.IContentAssistProcessor;
import org.eclipse.jface.text.contentassist.IContextInformation;
import org.eclipse.jface.text.contentassist.IContextInformationValidator;
public class EditorContentAssistProcessor implements IContentAssistProcessor {
EditorContentAssistProcessor() {
this.wordProvider = new WordProvider();
}
private WordProvider wordProvider;
private String lastError;
@Override
public ICompletionProposal[] computeCompletionProposals(ITextViewer textViewer, int documentOffset) {
IDocument document = textViewer.getDocument();
int currOffset = documentOffset - 1;
try {
String currWord = "";
char currChar;
while (currOffset > 0 && !(Character.isWhitespace(currChar = document.getChar(currOffset)) || currChar == ';')) {
currWord = currChar + currWord;
currOffset--;
}
List 0) {
proposals = buildProposals(suggestions, currWord, documentOffset - currWord.length());
lastError = null;
}
return proposals;
} catch (Exception e) {
e.printStackTrace();
lastError = e.getMessage();
}
return null;
}
private ICompletionProposal[] buildProposals(List< String > suggestions, String replacedWord, int offset) throws Exception {
ICompletionProposal[] proposals = new ICompletionProposal[suggestions.size()];
int index = 0;
for (Iterator< String > i = suggestions.iterator(); i.hasNext();) {
String currSuggestion = (String) i.next();
CompletionProposal cp = new CompletionProposal(currSuggestion, offset, replacedWord.length(), currSuggestion.length(), null,
currSuggestion, null, null);
proposals[index] = cp;
index++;
}
return proposals;
}
@Override
public IContextInformation[] computeContextInformation(ITextViewer itextviewer, int i) {
lastError = "No Context Information available";
return null;
}
@Override
public char[] getCompletionProposalAutoActivationCharacters() {
return null;
}
@Override
public char[] getContextInformationAutoActivationCharacters() {
return null;
}
@Override
public IContextInformationValidator getContextInformationValidator() {
return null;
}
@Override
public String getErrorMessage() {
return lastError;
}
}
EditorScannerBack
package com.octo.sample;
import org.eclipse.jface.text.TextAttribute;
import org.eclipse.jface.text.rules.IRule;
import org.eclipse.jface.text.rules.IToken;
import org.eclipse.jface.text.rules.IWordDetector;
import org.eclipse.jface.text.rules.RuleBasedScanner;
import org.eclipse.jface.text.rules.SingleLineRule;
import org.eclipse.jface.text.rules.Token;
import org.eclipse.jface.text.rules.WordRule;
import org.eclipse.swt.SWT;
import org.eclipse.swt.graphics.Color;
import org.eclipse.swt.graphics.RGB;
import org.eclipse.swt.widgets.Display;
public class EditorScanner extends RuleBasedScanner {
private static RGB COMMENT = new RGB(128, 128, 128);
public EditorScanner() {
super();
setRules(extractRules());
}
private static RGB KEYWORD= new RGB(140, 10, 210);
private static RGB DEFAULT = new RGB(0,0,0);
private IRule[] extractRules() {
IToken keyword = new Token(new TextAttribute(new Color(Display.getCurrent(), KEYWORD), null, SWT.BOLD));
IToken comment = new Token(new TextAttribute(new Color(Display.getCurrent(), COMMENT), null, SWT.ITALIC));
IToken defaut = new Token(new TextAttribute(new Color(Display.getCurrent(), DEFAULT)));
WordRule rule = new WordRule(new IWordDetector() {
@Override
public boolean isWordPart(char c) {
return Character.isJavaIdentifierPart(c) ;
}
@Override
public boolean isWordStart(char c) {
return Character.isJavaIdentifierStart(c) ;
}
}, defaut);
for (String k : KeyWords.KEYWORDS) {
rule.addWord(k, keyword);
}
IRule [] rules = new IRule[2];
rules[0]=rule;
rules[1]=new SingleLineRule("**", null, comment);
return rules;
}
}
EditorSourceViewerConfiguration Back
package com.octo.sample;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.contentassist.ContentAssistant;
import org.eclipse.jface.text.contentassist.IContentAssistant;
import org.eclipse.jface.text.presentation.IPresentationReconciler;
import org.eclipse.jface.text.presentation.PresentationReconciler;
import org.eclipse.jface.text.rules.DefaultDamagerRepairer;
import org.eclipse.jface.text.rules.ITokenScanner;
import org.eclipse.jface.text.source.ISourceViewer;
import org.eclipse.ui.editors.text.TextSourceViewerConfiguration;
public class EditorSourceViewerConfiguration extends TextSourceViewerConfiguration{
private ITokenScanner scanner=null;
@Override
public IPresentationReconciler getPresentationReconciler(ISourceViewer sourceViewer) {
PresentationReconciler reconciler = new PresentationReconciler();
DefaultDamagerRepairer dr = new DefaultDamagerRepairer(getScanner());
reconciler.setDamager(dr, IDocument.DEFAULT_CONTENT_TYPE);
reconciler.setRepairer(dr, IDocument.DEFAULT_CONTENT_TYPE);
return reconciler;
}
private ContentAssistant assistant = null;
@Override
public IContentAssistant getContentAssistant(ISourceViewer sourceViewer) {
if(assistant==null){
assistant = new ContentAssistant();
assistant.setContentAssistProcessor(new EditorContentAssistProcessor(), IDocument.DEFAULT_CONTENT_TYPE);
assistant.setInformationControlCreator(getInformationControlCreator(sourceViewer));
}
return assistant;
}
private ITokenScanner getScanner(){
if(scanner == null)
scanner=new EditorScanner();
return scanner;
}
}
Keywords
package com.octo.sample;
public class KeyWords {
public static final String [] KEYWORDS = {"helloWorld"};
}
WordProvider
package com.octo.sample;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class WordProvider {
public List< String > suggest(String word) {
ArrayList< String >wordBuffer = new ArrayList< String >();
for(String s:KeyWords.KEYWORDS)
if(s.startsWith(word))
wordBuffer.add(s);
Collections.sort(wordBuffer);
return wordBuffer;
}
}