LSP Support in Eclipse Che

When we try to implement code authoring tools for a particular language we need a thorough understanding of the structure and usage of the language in question: for example, in order to provide accurate code completion for the Java language, we not only need to be able to parse Java source code and class files, but also understand the build process and dependencies (for example a maven pom.xml file) in order to know the visible universe at any given source location. The chances that these essential language tools are compatible with the language and technology of the development tool are small; if these language-aware tools exist, they are often written in the target language itself (for example, the typescript compiler is written in typescript). The team developing Visual Studio Code solved this mismatch by putting the language smartness engines in a separate process (a language server) and communicating with it via a standardized wire protocol: the Language Server Protocol. Developers from Red Hat, Codenvy and IBM have since worked to bring support for LSP to other development tools like Eclipse IDE and Eclipse Che.

This genesis has influenced the design of the protocol: one of its assumptions is that the host tool tightly controls the lifecycle of the language server and that the language server has access to the files making up the development workspace.

Developer Machine LSP

How LSP support works in Eclipse Che

Eclipse Che, however is already a distributed system with the IDE running in the browser and communicating with a workspace machine that holds the necessary tools and runtimes to develop for a particular runtime stack. The language servers add a third tier that needs to run somewhere.

In Che, the front end does not directly communicate with the various language servers, instead, it talks to a single back end service that handles dispatching requests to the appropriate language server. Language servers are added to the system by plugging into an extension point in the wsagent process that runs in every workspace.

Since many existing language servers expect to find the workspace files in a local file system, and since the workspace machine likely already contains many prerequisites for the language server, it makes sense to to run the language servers in the workspace machine. The language servers that are integrated with the Che distribution are packaged as so called “workspace agents”. Workspace agents are components that can be installed into a workspace via a the runtime configuration of the workspace machine.

Browser Developer Machine LSP

How to add your own LS to Che

In order to familiarize yourself with Che extension development I recommend you read through the che docs beginning with https://www.eclipse.org/che/docs/assemblies/intro/index.html.

Let’s follow the example of the JSON language server to illustrate what needs to be done. First, let’s package the json language server as a workspace agent (see https://www.eclipse.org/che/docs/assemblies/sdk-custom-agents/index.html). A new agent should be added as a new module in the /che/agents maven project. An agent requires some metadata and a shell script that will be executed when the the workspace machine is started. A simple way to implement the agent interface is to extend BasicAgent, as in the JSON Language Server Agent: it reads metadata from a json file

{
  "id": "org.eclipse.che.ls.json",
  "name": "JSON language server",
  "description": "JSON intellisense",
  "dependencies": [],
  "properties": {}
}

and reads the agent startup script from a script file: the file first installs necessary packages:

command -v tar >/dev/null 2>&1 || { PACKAGES=${PACKAGES}" tar"; }
...

# Red Hat Enterprise Linux 7
if echo ${LINUX_TYPE} | grep -qi "rhel"; then
	test "${PACKAGES}" = "" || {
    	${SUDO} yum install ${PACKAGES};
	}

	command -v nodejs >/dev/null 2>&1 || {
    	curl --silent --location https://rpm.nodesource.com/setup_6.x | ${SUDO} bash -;
    	${SUDO} yum -y install nodejs;
	}
…handle other distros...

Finally it downloads and installs the agent. Note how the script writes a launch script for the language server into a local file on the last line:

AGENT_BINARIES_URI=https://codenvy.com/update/repository/public/download/org.eclipse.che.ls.json.binaries
...
curl -s ${AGENT_BINARIES_URI} | tar xzf - -C ${LS_DIR}

touch ${LS_LAUNCHER}
chmod +x ${LS_LAUNCHER}
echo "nodejs ${LS_DIR}/vscode-json-server/server.js" > ${LS_LAUNCHER}

Both the JSON file and install script are read from the project resources. Workspace agents need to be registered in the wsmaster project: we need to add them as a dependency of the project and register the LSJsonAgent class in the WsMasterModule:

@DynaModule
public class WsMasterModule extends AbstractModule {
	@Override
	protected void configure() {
      ...
    	Multibinder agents = Multibinder.newSetBinder(binder(), Agent.class);
            ...
    	    	agents.addBinding().to(LSJsonAgent.class);

After rebuilding Che, the new agent should be visible in the runtime configuration of workspaces. Turn it on and watch the dev machine log to see the startup script run when the workspace starts up.

Eclipse Che IDE

Once the agent launches correctly, we need to register the language server with the workspace agent and provide a way to start the language server and to set up a communication channel to it. For this, add a new module to the /che/plugins project. To hook up the language server, we will extend the class LanguageServerTemplate: The first step in the implementation is to set up a description of the files this language server is supposed to handle:

@Singleton
public class JsonLanguageServerLauncher extends LanguageServerLauncherTemplate {

    private static final String LANGUAGE_ID = "json";
    private static final String[] EXTENSIONS  = new String[] { "json", "bowerrc", "jshintrc", "jscsrc", "eslintrc", "babelrc" };
    private static final String[] MIME_TYPES  = new String[] { "application/json" };
    private static final LanguageDescription description;
    static {
        description = new LanguageDescription();
        description.setFileExtensions(asList(EXTENSIONS));
        description.setLanguageId(LANGUAGE_ID);
        description.setMimeTypes(asList(MIME_TYPES));
    }

    @Override
    public LanguageDescription getLanguageDescription() {
        return description;
    }
...

Then we need some code that will start the language server process: note that it calls the script we have created in the agent startup script above.

   @Inject
    public JsonLanguageServerLauncher() {
        launchScript = Paths.get(System.getenv("HOME"), "che/ls-json/launch.sh");
    }

    @Override
    public boolean isAbleToLaunch() {
        return Files.exists(launchScript);
    }

    protected Process startLanguageServerProcess(String projectPath) throws LanguageServerException {
        ProcessBuilder processBuilder = new ProcessBuilder(launchScript.toString());
        processBuilder.redirectInput(ProcessBuilder.Redirect.PIPE);
        processBuilder.redirectOutput(ProcessBuilder.Redirect.PIPE);
        try {
            return processBuilder.start();
        } catch (IOException e) {
            throw new LanguageServerException("Can't start JSON language server", e);
        }
    }

And finally we need to hook up the language server to a Eclipse LSP4J LanguageServer endpoint:

    protected LanguageServer connectToLanguageServer(Process    
                                                    languageServerProcess, 
                                                    LanguageClient client) {
        Launcher launcher = 
           Launcher.createLauncher(client, LanguageServer.class, 
                                   languageServerProcess.getInputStream(),                                                                           
                                   languageServerProcess.getOutputStream());
        launcher.startListening();
        return launcher.getRemoteProxy();
    }

This particular language server communicates via standard in and standard out. Other language servers may implement communication via other channels, for example sockets. It is up to the LanguageServerLauncher to set up the appropriate communication channels.

All that needs to be done now is to register our JsonLanguageServerLauncher with the dependency injection framework:

@DynaModule
public class JsonModule extends AbstractModule {
	@Override
	protected void configure() {
    	    Multibinder.newSetBinder(binder(), LanguageServerLauncher.class)
                     .addBinding()
                     .to(JsonLanguageServerLauncher.class);
	}
}

In order for the project to be included in the wsagent process, it needs to be added as a dependency to the wsagent assembly in the /che/assembly/assembly-wsagent-war maven project.

Restrictions

Currently, there can only be one language server registered for any file type. VS Code supports multiple language servers per file and defines rules how results from the language servers are merged. This can be used to implement add-on language servers like linters.

Che will currently start one instance of a language server per project, not a single server per workspace. So language servers must be able to function with multiple copies of the server running.

Future Directions

LSP support in Che is under active development. Current areas of interest include implementing more protocol features (see issue #2109) and support for multiple language servers per file. The workspace agent presented in this article uses a script to install prerequisites. However, in a containerized world, it would make more sense to run each language server in its own container.

Another area of work is the inclusion of more language servers into Che.

About the Authors

Thomas Mader

Thomas Mäder
Red Hat