Eclipse, Xtext, english, Software Development

Xtext editors for binary files

What does "4 + 1" mean? Well, for example itemis employees have been developing a Java bytecode editor with Xtext. This editor allows the contents of .class files to be made visible and editable.
In the first part of this article I explained how the JBC editor is used. In this second part I want to discuss the technical problems that arise when you want to make a binary file editable with an Xtext-based editor. 

The first issue to solve for a text editor for binary files is to convert the binary data into a textual format without the text editor being involved. This is done by replacing the editor with an IDocumentProvider, which then performs appropriate transformations when loading and saving. As usual in Xtext this is done by dependency injection and registration within the UI module:

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

 
The JBCDocumentProvider now overrides the two methods setDocumentContent and doSaveDocument. The first method converts the binary stream into text, while the second returns binary content from the model the editor obtained from the XTextDocument.

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

 
This is enough to fool the Xtext-based editor, as it provides it with a plain text file, but the result is not quite satisfactory. This is because the editor compares the textual content with the binary data obtained from the .class file to highlight changed regions. This happens because the comparison algorithm does not get the file content directly from the editor, but instead requests IFileEditorInput from the file content and getStorage via the method to get the InputStream.
 

NoProxyForIFileInput

To make the comparison meaningful, this stream also has to be transformed in the same way as was done when creating the IDocument. To do this, the doSetInput (IEditorInput input) method is overridden by the JBCEditor, so that the set input is packaged in a dynamic proxy.

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
    }


The latter returns another dynamic proxy for the getStorage query, which converts the file content supplied by getContents into textual format.
 

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

 
As a result the editor.getStorage().GetContents() call returns the same content as was supplied by the document.get(), and the comparison of the document content with that from the file now yields the expected results.
 

WithProxyForIFileInput

The editor implemented here is quite simple, in that each .class file is considered individually: there is no global scope to allow references between multiple files to be resolved and validated. This means that it isn’t easy to develop an entire project directly in class-file-format.

However, this is not a fundamental problem, merely a design decision. The editor is explicitly intended for editing individual .class files. There is nothing wrong, however, with the idea of extending the techniques to other binaries in order to create useful editors for them without an explicit intermediate textual format. These could be stored in files, and these files be linked by references within a global scope.

    
About Arne Deutsch

Arne Deutsch works as an IT consultant at itemis AG in Bonn. His focus is on Language Engineering, Xtext and the development of tools for Eclipse.