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.

comparaison text editor

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 ».

Wizard Création

  • 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.

choix des dépendances

  • 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 Classes du pluginLa 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"

menu contextuel pour ouvrir

Lors de l'ouverture de notre fichier, les mots clés que nous avons défini sont colorés.

coloration syntaxique

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…

diagramme classes complétion

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);
	}
}

Back

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

}

Back

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

Back

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

Back

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

}