ショートカットでファイルを切り替えるだけのEclipseプラグイン(作り方)

WEB+DB PRESS Vol.43のEclipse特集を読んで以来、積極的にショートカットを使うようにしている。特にJUnitのCtrl+9とかDoltengのCtrl+3~6とかで関連ファイルとの切り替えが出来るのは本当に楽だと思う。


TestCaseじゃなくても、組になったファイルを交互に編集する事で開発を進める事って結構ある(StrutsだったらActionとActionFormとテンプレートファイルとか)んだけど、自分達の開発手順にぴったり合ったプラグインってそうそうないので、なんとか自分で作れないかと思ってやってみた。

方針

今回、Eclipseプラグインを触るのは全く初めてだったので、編集中のファイルのパスの取得方法やファイルをエディタでオープンする方法については、ファイルの切り替え機能を持っているDolteng様のコードを参考にした。


対応箇所の自動選択とか不足箇所の自動挿入とか、コピペ元のようにいろいろ頑張れる余地はありそうだけど、今回はとりあえずファイルの切り替え表示だけできるようにする。


プリファレンスやダイアログを出すのは敷居が高そうなので、「Hello, World」テンプレートをベースに作る。設定は現在開いているWorkspace上にeclipseという一般プロジェクトを作って、その下に置いたプロパティファイルを読み込むようにする。


設定は、対象となるファイルのパターン(正規表現)と切り替え先ファイルのフォーマット(JavaScript)のペアで表現する。Workspace毎に設定ファイルを作れれば、プロジェクト単位での規約に合わせて切り替えルールを設定出来るので良そう。


今回はActionHtml.properties(階層が変わるパターン)とExtension.properties(同一階層で拡張子などの接尾辞が変わるパターン)の二つのパターンを用意。同じクラスにし、どちらの設定を使うかは、actionのidの最後に$ActionHtmlや$Extensionなどのように$を付ける事で切り替える。もっとバリエーションが欲しければ説明文中の「拡張」タブのところでaction/command/keyを追加する事で好きなだけ増やせる。

環境

プラグイン作成手順

スクリーンショットとか取ってないので作業メモレベルで。プロジェクト名やIDやパッケージ名は仮なので、適当に変えて下さい。

プラグイン・プロジェクトの新規作成

メニューから「ファイル」>「新規作成」>「プラグイン・プロジェクト」を選ぶとWizardが立ち上がるので以下の順番で入力

  • プラグインプロジェクト」パネル
    • プロジェクト名:「sample.eclipse.plugin」
  • プラグインコンテンツ」パネル
  • 「テンプレート」パネル
    • 「Hello, World」を選択
  • 「サンプルのアクション・セット」
    • アクション・クラス名「SwitchFileAction」
ライブラリの追加

rhinoを使うのでjarをプロジェクト下にコピー
パス: template/jars/rhino-1.6r5.jar

プラグインビューによる設定

プラグインマニフェスト・エディター(META-INF/MENIFEST.MFを開く)で各タブに以下の項目を設定

  • 「概要」タブ
    • 「実行環境」に「J2SE-1.5」を追加
  • 「ビルド」タブ
    • ランタイム情報に「ライブラリー/.」「フォルダー/src/」が登録されていることを確認
  • 「ランタイム」タブ
    • 「エクスポートされるパッケージ」
      • sample.eclupse.plugin.actionsを追加
    • 「クラスパス」
      • template/jars/rhino-1.6r5.jarを追加(自動で「.」も追加される)
  • 「拡張」タブ
    • org.eclipse.ui.actionSetsの「サンプルのアクション・セット」を右クリックし、新規>action
      • id: sample.eclipse.plugin.actions.SwitchFileAction$ActionHtml
      • label: Switch Action and Html
      • definitionId: sample.eclipse.plugin.actions.SwitchFileAction$ActionHtml
      • class: sample.eclipse.plugin.actions.SwitchFileAction
    • 「サンプルのアクション・セット」を右クリックし、新規>action
      • id: sample.eclipse.plugin.actions.SwitchFileAction$Extension
      • label: Switch By Extension
      • definitionId: sample.eclipse.plugin.actions.SwitchFileAction$Extension
      • class: sample.eclipse.plugin.actions.SwitchFileAction
    • 「サンプルアクション」と「サンプルメニュー」を削除
    • 「追加」ボタンをクリックし、org.eclipse.ui.commandsを追加
    • 「org.eclipse.ui.commands」を右クリックし、新規>category
      • id: sample.eclipse.command.category
      • name: Sample
    • 「org.eclipse.ui.commands」を右クリックし、新規>command
      • id: sample.eclipse.plugin.actions.SwitchFileAction$ActionHtml
      • name: Switch Action and Html
      • categoryId: sample.eclipse.command.category
    • 「org.eclipse.ui.commands」を右クリックし、新規>command
      • id: sample.eclipse.plugin.actions.SwitchFileAction$Extension
      • name: Switch By Extension
      • categoryId: sample.eclipse.command.category
    • 「追加」ボタンをクリックし、org.eclipse.ui.bindingsを追加
    • 「org.eclipse.ui.bindings」を右クリックし、新規>key
      • sequence: Ctrl+3
      • schemeId: org.eclipse.ui.defaultAcceleratorConfiguration
      • contextId: org.eclipse.ui.textEditorScope
      • commandId: sample.eclipse.plugin.actions.SwitchFileAction$Extension
    • 「org.eclipse.ui.bindings」を右クリックし、新規>key
      • sequence: Ctrl+5
      • schemeId: org.eclipse.ui.defaultAcceleratorConfiguration
      • contextId: org.eclipse.ui.textEditorScope
      • commandId: sample.eclipse.plugin.actions.SwitchFileAction$ActionHtml
実装

プロジェクト生成時にWizardで入力したSwitchFileActionクラス(IWorkbenchWindowActionDelegateの実装クラス)が出来ているので内容を変更。ショートカットキー入力時にrun()メソッドが呼ばれる

package sample.eclipse.plugin.actions;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.IWorkspace;
import org.eclipse.core.resources.IWorkspaceRoot;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.jface.action.IAction;
import org.eclipse.jface.viewers.ISelection;
import org.eclipse.ui.IEditorInput;
import org.eclipse.ui.IEditorPart;
import org.eclipse.ui.IFileEditorInput;
import org.eclipse.ui.IWorkbenchPage;
import org.eclipse.ui.IWorkbenchPart;
import org.eclipse.ui.IWorkbenchWindow;
import org.eclipse.ui.IWorkbenchWindowActionDelegate;
import org.eclipse.ui.PartInitException;
import org.eclipse.ui.ide.IDE;

/**
 * キー入力で編集中のファイルに関連するファイルに切り替えるアクション
 * actionIdの最後に$(設定名)を付け、現在のWorkspace下のeclipseフォルダ内の
 * 該当するpropertiesファイル内の設定内容にしたがってファイルを切り替える
 */
public class SwitchFileAction implements IWorkbenchWindowActionDelegate {
    /** actionIdの区切り文字。SEPARATOR("$")移行を設定名として使用 */
    private static final String SEPARATOR = "$";
    /** 設定ファイルを配置するディレクトリ名 */
    private static final String PROPERTIES_PREFIX = "/eclipse/";
    /** 設定ファイルの拡張子*/
    private static final String PROPERTIES_SUFFIX = ".properties";
    
    private IWorkbenchWindow window;
    /** 切り替え対象ファイルを取得する為のCounterpartMapperオブジェクトを
     * 保持するMap。キーは設定名、値は設定名に対応するCounterpartMapperオ
     * ブジェクト
     */
    private Map<String, CounterpartMapper> mapperMap =
            new HashMap<String, CounterpartMapper>();
    /**
     * The constructor.
     */
    public SwitchFileAction() {
    }

    /**
     * 現在アクティブな編集対象ファイルを取得し、対応するファイルのエディタ
     * をオープンする
     * @param action IAction
     */
    public void run(IAction action) {
        // 現在編集されているファイルの情報を取得
        IFile ifile = getSelectedFile();
        if (ifile == null) {
            return;
        }
        // actionIdの#以降の部分を元に使用する切り替え設定を選択
        String actionId = action.getId();
        String subname = actionId.substring(actionId.indexOf(SEPARATOR) + 1);
        CounterpartMapper mapper = getMapper(subname);
        if (mapper == null) {
            return;
        }
        // 切り替え先ファイルのパスを取得
         String counterpart = mapper.getCounterpart(ifile.getFullPath().toString());
         if (counterpart == null) {
             return;
         }
         // 切り替え先ファイルをエディタで開く
         openEditor(counterpart);
    }


    /**
     * ファイル名を指定し、エディタで開く
     * @param fullpath ファイル名(Workspace上の仮想パス)
     * @return 開けた場合にtrueを戻す
     */
    private boolean openEditor(String fullpath) {
        IWorkspace iw = ResourcesPlugin.getWorkspace();
        IWorkspaceRoot root = iw.getRoot(); 
        IWorkbenchPage page = window.getActivePage();
        if (page == null) {
            return false;
        }
        IResource file = root.findMember(fullpath);
        if (file != null && file instanceof IFile) {
            try {
                return IDE.openEditor(page, (IFile) file, true) != null;
            } catch (PartInitException e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    /**
     * actionIdの$以降の部分ごとに、対応するCounterpartMapperを戻す
     * @param subname actionIdの$以降の部分
     * @return subnameに対応するCounterpartMapper
     */
    private CounterpartMapper getMapper(String subname) {
        IWorkspace iw = ResourcesPlugin.getWorkspace();
        IWorkspaceRoot root = iw.getRoot(); 
        String propPath = PROPERTIES_PREFIX +  subname + PROPERTIES_SUFFIX;
        IResource resource = root.findMember(propPath);
        
        if (resource == null || ! (resource instanceof IFile)) {
            return null;
        }
        IFile file = (IFile) resource;
        
        CounterpartMapper mapper = this.mapperMap.get(subname);
        if (mapper == null &&
                resource != null && resource instanceof IFile) {
            mapper = new CounterpartMapper();
            this.mapperMap.put(subname, mapper);
        }
        
        try {
            mapper.loadSettingIfNeeded(
                    file.getLocalTimeStamp(), file.getContents());
        } catch (IOException e) {
            e.printStackTrace();
        } catch (CoreException e) {
            e.printStackTrace();
        }

        return mapper;
    }

    /**
     * @return 現在選択中のファイルを戻す
     */
    private IFile getSelectedFile() {
        IWorkbenchPage page = window.getActivePage();
        if (page == null) {
            return null;
        }
        IWorkbenchPart part = page.getActivePart();
        if(part == null || !( part instanceof IEditorPart)) {
            return null;
        }
        IEditorPart editor = (IEditorPart)part;
        if (editor == null) {
            return null;
        }
        IEditorInput editorInput = editor.getEditorInput();
        if (editorInput == null || !(editorInput instanceof IFileEditorInput)) {
            return null;
        }
        IFileEditorInput fileInput = (IFileEditorInput) editorInput;
        IFile ifile = fileInput.getFile();
        return ifile;
    }

    /**
     * Selection in the workbench has been changed. We 
     * can change the state of the 'real' action here
     * if we want, but this can only happen after 
     * the delegate has been created.
     * @see IWorkbenchWindowActionDelegate#selectionChanged
     */
    public void selectionChanged(IAction action, ISelection selection) {
    }
    /**
     * We can use this method to dispose of any system
     * resources we previously allocated.
     * @see IWorkbenchWindowActionDelegate#dispose
     */
    public void dispose() {
    }

    /**
     * We will cache window object in order to
     * be able to provide parent shell for the message dialog.
     * @see IWorkbenchWindowActionDelegate#init
     */
    public void init(IWorkbenchWindow window) {
        this.window = window;
    }
}


設定保持&切り替え先ファイルパス取得用クラス
上のアクションクラスと同じ階層にCounterpartMapper.javaを作成

package sample.eclipse.plugin.actions;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.mozilla.javascript.Context;
import org.mozilla.javascript.ScriptableObject;


/**
 * ファイル名に対して対応する切り替え先のファイル名を戻すためのクラス。
 * プロパティファイルの内容を読み込んで設定の初期化をおこなう。
 * プロパティファイルは、以下のフォーマットで記述する。(XXXは任意の文字列)
 * <pre>
 * pattern.XXX = マッチング用の正規表現パターン文字列
 * target.XXX = 対象ファイル名を生成するJavaScript文字列
 * </pre>
 * 正規表現でマッチした文字列は、$1~$nでJavaScript内で後方参照することができる。
 */
public class CounterpartMapper {
    private static final String PATTERN_PREFIX = "pattern.";
    private static final String TARGET_PREFIX = "target.";
    private volatile List<Entry> entries = new ArrayList<Entry>();
    private long lastFileTime = 0;

    /**
     * ファイルが更新されていればInputStreamからプロパティ値を読み込んで設定を更新する
     * @param filetime ファイルのタイムスタンプ
     * @param in InputStream
     * @throws IOException ファイル読み込み時の例外
     */
    public synchronized void loadSettingIfNeeded(long filetime, InputStream in)
            throws IOException {
        if (!isNeeded(filetime)) {
            return;
        }
        Properties properties = new Properties();
        properties.load(in);
        this.entries = prepareEntries(properties);
        this.lastFileTime = filetime;
    }
    
    /**
     * 最後に読み込んだファイルのタイムスタンプと引数を比較し、更新されていればtrueを戻す
     * @param filetime ファイルのタイムスタンプ
     * @return 再読み込みが必要ならtrueを戻す
     */
    private boolean isNeeded(long filetime) {
        return lastFileTime < filetime;
    }

    /**
     * currentで指定されたファイル名に対応する切り替え先のファイル名を戻す
     * @param current 現在のファイル名(Workspace上の仮想パス)
     * @return 切り替え先のファイル名(Workspace上の仮想パス)
     */
    public String getCounterpart(String current) {
        Context context = Context.enter();
        try {
            ScriptableObject scope = context.initStandardObjects();
            for (Entry entry : this.entries) {
                Pattern pattern = entry.getPattern();
                Matcher matcher = pattern.matcher(current);
                if (!matcher.find()) {
                    continue;
                }
                int count = matcher.groupCount();
                for (int index = 1; index <= count; index++) {
                    String groupString = matcher.group(index);
                    ScriptableObject.putProperty(scope, "$"+index, groupString);
                }
                
                Object a = context.evaluateString(scope, entry.getTarget(), "", 0, null);
                return a.toString();
            }
        } finally {
            Context.exit();
        }
        return null;
    }
    /**
     * プロパティの中身を読み込み、パターン/置換スクリプトのペアのリストを生成する
     * @param properties プロパティ
     * @return パターン/置換スクリプトのペアのリスト
     */
    @SuppressWarnings("unchecked")
    private List<Entry> prepareEntries(Properties properties) {
        List<Entry> newEntries = new ArrayList<Entry>();
        Enumeration<String> propertyNames =
            (Enumeration<String>) properties.propertyNames();
        while(propertyNames.hasMoreElements()) {
            String key = propertyNames.nextElement();
            if (!key.startsWith(PATTERN_PREFIX)) {
                continue;
            }
            String patternValue = properties.getProperty(key);
            String suffix = key.substring(PATTERN_PREFIX.length());
            String targetKey = TARGET_PREFIX + suffix;
            String targetValue = properties.getProperty(targetKey);
            if (patternValue != null && targetValue != null) {
                newEntries.add(new Entry(
                        Pattern.compile(patternValue),
                        targetValue));
            }
        }
        return newEntries;
    }
    /**
     * パターン毎の切り替え先ファイルの設定
     */
    private static class Entry {
        Pattern pattern;
        String targetScript;
        public Entry(Pattern pattern, String targetScript) {
            this.pattern = pattern;
            this.targetScript = targetScript;
        }
        public Pattern getPattern() {
            return pattern;
        }
        public String getTarget() {
            return targetScript;
        }
    }
}
デバッグ

プラグインマニフェスト・エディターの「概要」から「Eclipseアプリケーションをデバッグ・モードで起動」をクリックすると、「runtime-Eclipseアプリケーション」というワークスペースが出来る

  • キーバインドが設定されている事を確認
    • ウィンドウ>「設定」で設定ダイアログを出す
    • 「一般」>キーで以下の項目が表示される事を確認

Sample | Switch Action and HTML | Ctrl+5
Sample | Switch By Extension | Ctrl+3

    • 出ていなければ、「変更」で
      • キーシーケンス
        • 名前 Ctrl+3(コントロールキーを押しながら3キーを押すと入力される)
      • 場合 テキストの編集
      • コマンド
        • カテゴリー Sample
        • 名前 Switch By Extension
      • 上記を選んで「追加」

あとは、適当にプロジェクトを作って、同一フォルダ内のHogeActionImpl.javaとHogeAction.javaがCtrl+3などで切り替えられる事を確認

設定ファイルの記述

デバッグ用の「runtime-Eclipseアプリケーション」ワークスペース上で、パッケージエクスプローラから一般プロジェクトをeclipseという名前で作成し、
その下にActionHtml.properties, Extension.propertiesを作成

ActionHtml.properties:

pattern.1=/frontapp/src/sample/struts/action/(.*)/(.*)ActionImpl\.java
target.1='/html/' + $1 + '/' + $2.toLowerCase() + '.html'
pattern.2=/html/(.*)/(.*)\.html
target.2='/frontapp/src/sample/struts/action/' + $1 + '/' + $2.substr(0,1).toUpperCase() + $2.substr(1) + 'ActionImpl.java'

この例はプロジェクトの規約でsample.struts.action.subapp.HogeFugaAction(Impl)のテンプレートは/html/subapp/hogeFuga.htmlですよという場合。

Extension.properties:

pattern.1=^(.*)\.html
target.1=$1 + '.mayaa'
pattern.2=^(.*)\.mayaa
target.2=$1 + '.html'
pattern.3=^(.*)ActionImpl\.java
target.3=$1 + 'Action.java'
pattern.4=^(.*)Action\.java
target.4=$1 + 'ActionImpl.java'
pattern.5=^(.*)LogicImpl\.java
target.5=$1 + 'Logic.java'
pattern.6=^(.*)Logic\.java
target.6=$1 + 'LogicImpl.java'

htmlとその設定ファイル、インタフェースと実装クラスが同一階層にある場合の例

エクスポート(プラグインファイルjar化)

「概要」から「エクスポート・ウィザード」を選択し、ディレクトリパスを入力すると、そのディレクトリ下のpluginsの下にsample.eclipse.plugin_1.0.0.jarが出来る。
これをECLIPSE_HOMEのpluginsの下にコピーすればインストールされる

感想など

系統だってプラグイン作成法を学んだわけではないのでちょっと行儀の悪い部分はあるかも。