Xtext-Editor für binäre Dateien

Im ersten Teil dieser Serie habe ich erläutert, wie sich mit Hilfe von Xtext ein Java-Bytecode-Editor entwickelt und nutzen lässt, mit dem sich die Inhalte von.class-Dateien sichtbar machen lassen. In diesem zweiten Teil möchte ich auf die technische Problemstellung eingehen, die sich ergibt, wenn man eine Binärdatei mit einem Xtext-basierten Editor bearbeitbar machen möchte.

Der erste Punkt, den es für einen textuellen Editor für Binärdateien zu lösen gilt, ist die Umwandlung der Binärdaten in ein textuelles Format, ohne das der textuelle Editor davon etwas mitbekommt.

Dies kann durch einen Austausch des IDocumentProvider für den Editor geschehen, der dann beim Laden und Speichern entsprechende Transformationen vornimmt. Wie in Xtext üblich geschieht das durch Dependency Injection und eine Registrierung innerhalb des UI-Moduls:

package com.itemis.jbc.ui

import com.itemis.jbc.ui.custom.JBCDocumentProvider
import org.eclipse.xtext.ui.editor.model.XtextDocumentProvider

@FinalFieldsConstructor
class JBCUiModule extends AbstractJBCUiModule {
    def Class<? extends XtextDocumentProvider> bindXtextDocumentProvider() {
        JBCDocumentProvider
    }
}

Der JBCDocumentProvider überschreibt nun die beiden Methoden setDocumentContent und doSaveDocument. In der ersten wird der eingelesene Binärstream in Text umgewandelt. In der zweiten wird umgekehrt aus dem Modell des Editors, erhalten vom XTextDocument, wieder binärer Inhalt gemacht.

package com.itemis.jbc.ui.custom

import com.itemis.jbc.binary.ByteCodeWriter
import com.itemis.jbc.jbc.ClassFile
import java.io.ByteArrayInputStream
import java.io.InputStream
import org.eclipse.core.runtime.CoreException
import org.eclipse.core.runtime.IProgressMonitor
import org.eclipse.jface.text.IDocument
import org.eclipse.ui.IFileEditorInput
import org.eclipse.xtext.resource.XtextResource
import org.eclipse.xtext.ui.editor.model.XtextDocument
import org.eclipse.xtext.ui.editor.model.XtextDocumentProvider
import org.eclipse.xtext.util.concurrent.IUnitOfWork

class JBCDocumentProvider extends XtextDocumentProvider {
    override protected setDocumentContent(IDocument document, InputStream contentStream,
            String encoding) throws CoreException {         document.set(new JBCInputStreamContentReader().readContent(contentStream, encoding))
    }

    override protected doSaveDocument(IProgressMonitor monitor, Object element,
            IDocument document, boolean overwrite) throws CoreException {
        if (element instanceof IFileEditorInput) {
            if (document instanceof XtextDocument) {
                if (element.file.exists && element.file.name.endsWith(".class")) {
                    document.readOnly(new IUnitOfWork.Void<XtextResource>() {
                        override process(XtextResource resource) throws Exception {
                            val ast = resource.parseResult.rootASTElement
                            element.file.setContents(new ByteArrayInputStream(

                                    ByteCodeWriter.writeClassFile(ast as ClassFile)),
true, true, monitor))});
                    return;
                }
            }
        }
        super.doSaveDocument(monitor, element, document, overwrite)
    }
}

Das reicht um dem Xtext-basierten Editor vorzugaukeln, er würde eine normale Textdatei vor sich haben. Das Resultat ist allerdings noch nicht ganz befriedigend. Der Editor vergleicht den textuellen Inhalt mit den Binärdaten, die aus der.class-Datei bezogen werden, um geänderte Regionen hervorzuheben. Dies geschieht, weil der Vergleichsalgorithmus den Datei-Inhalt nicht vom Editor direkt bezieht, sondern den IFileEditorInput von diesem anfordert, um über die Methode getStorage an den InputStream zu gelangen.

NoProxyForIFileInput.png

Um den Vergleich sinnvoll zu machen, muss also auch dieser Stream auf die gleiche Weise transformiert werden, wie es bei der Erstellung des IDocument getan wird. Um dies zu realisieren, wird die Methode doSetInput(IEditorInput input) vom JBCEditor so überschrieben, dass der gesetzte Input in einem dynamischen Proxy verpackt wird.

package com.itemis.jbc.ui.custom

import java.io.InputStreamReader
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import org.eclipse.core.resources.IEncodedStorage
import org.eclipse.core.resources.IStorage
import org.eclipse.core.runtime.CoreException
import org.eclipse.ui.IEditorInput
import org.eclipse.ui.IFileEditorInput
import org.eclipse.xtext.ui.editor.XtextEditor
import org.eclipse.xtext.util.StringInputStream

class JBCEditor extends XtextEditor {
    override protected doSetInput(IEditorInput input) throws CoreException {
        if (input instanceof IFileEditorInput) {
            if (input.file.name.endsWith(".class")) {
                super.doSetInput(input.proxy)
                return
            }
        }
        super.doSetInput(input)
    }
    def private IFileEditorInput proxy(IFileEditorInput editorInput) {
        Proxy.newProxyInstance(this.class.classLoader, #[IFileEditorInput],
                new IFileEditorInputHandler(editorInput)) as IFileEditorInput
    }
}

Dieser liefert auf die Anfrage getStorage einen weiteren dynamischen Proxy zurück, der den von getContents gelieferten Datei-Inhalt in das textuelle Format umwandelt.

package class IFileEditorInputHandler implements InvocationHandler {
    private final IFileEditorInput original

    new(IFileEditorInput original) {
        this.original = original
    }
    override invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.name.equals("getStorage")) {
            return (method.invoke(original, args) as IStorage).proxy
        } else {
            return method.invoke(original, args)
        }
    }
    def private IStorage proxy(IStorage storage) {
        Proxy.newProxyInstance(this.class.classLoader, #[IStorage],
new IStorageHandler(storage)) as IStorage
    }
}
package class IStorageHandler implements InvocationHandler {
    private final IStorage original

    new(IStorage original) {
        this.original = original
    }
    override invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.name.equals("getContents") && method.parameterCount === 0) {
            val reader = new InputStreamReader(original.contents)
            try {
                val content = new JBCInputStreamContentReader().readContent(original.contents
                        (original as IEncodedStorage).charset)
                return new StringInputStream(content)
            } finally {
                reader.close()
            }
        } else {
            return method.invoke(original, args)
        }
    }
}

Im Resultat liefert der Aufruf editor.getStorage().getContents() den gleichen Inhalt wie auch document.get() . Der Vergleich von Dokumenten- und Datei-Inhalt liefert dann die erwarteten Ergebnisse.

WithProxyForIFileInput.png

Der hier mit Hilfe von Xtext umgesetzte Editor ist in sofern recht einfach, als dass jede .class-Datei für sich betrachtet wird. Es gibt keinen globalen Scope, so dass Referenzen zwischen mehreren Dateien nicht aufgelöst und validiert werden können. Es ist also nicht komfortabel möglich, ein ganzes Projekt direkt im class-file-Format zu schreiben.

Das stellt jedoch kein prinzipielles Problem dar, sondern ist lediglich eine Designentscheidung. Der Editor ist explizit zum Bearbeiten einzelner .class-Dateien gedacht. Es spricht aber nichts dagegen, die Techniken auf andere Binärdateien auszuweiten, um auch für diese komfortable Editoren zu erstellen, ohne dass ein explizites textuelles, in Dateien abgelegtes Zwischenformat benötigt wird. Diese Dateien könnten dann durchaus durch Referenzen und einen globalen Scope verlinkt werden.

Übrigens: Mehr zu Xtext lest ihr in unserem kostenlosen, englisch-sprachigen FAQ "1001 Tipps & Tricks".

Get 1001 Tipps & Tricks

Über den Autor

Arne Deutsch arbeitet als IT-Berater bei der itemis AG in Bonn. Seine Schwerpunkte sind Language Engineering, Xtext und die Entwicklung von Tools für Eclipse.