You can use the Eclipse JDT Language Server, which is used by different editors already (e.g. Visual Studio Code, and EMACS):
https://github.com/eclipse/eclipse.jdt.ls
This way, you'll be able to provide many JDT features available in the LSP definition (e.g. code completions, references, diagnostics, etc.):
https://microsoft.github.io/language-server-protocol/specifications/specification-current/
There are bindings for Java available with LSP4J over Maven:
<dependency>
<groupId>org.eclipse.lsp4j</groupId>
<artifactId>org.eclipse.lsp4j</artifactId>
<version>0.12.0</version>
</dependency>
A simple implementation might look like this:
ExpressionLanguageClient.java
import org.eclipse.lsp4j.MessageActionItem;
import org.eclipse.lsp4j.MessageParams;
import org.eclipse.lsp4j.PublishDiagnosticsParams;
import org.eclipse.lsp4j.ShowMessageRequestParams;
import org.eclipse.lsp4j.jsonrpc.services.JsonNotification;
import org.eclipse.lsp4j.services.LanguageClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.CompletableFuture;
public class ExpressionLanguageClient implements LanguageClient {
private final static Logger logger = LoggerFactory.getLogger(ExpressionLanguageClient.class);
final private ExpressionCodeAssistantService expressionCodeAssistantService;
public ExpressionLanguageClient(ExpressionCodeAssistantService expressionCodeAssistantService) {
this.expressionCodeAssistantService = expressionCodeAssistantService;
}
@Override
public void telemetryEvent(Object o) {
// TODO
logger.info("Expression LSP telemetry: " + o);
}
@Override
public void publishDiagnostics(PublishDiagnosticsParams publishDiagnosticsParams) {
// TODO
logger.info("Expression LSP diagnostics: " + publishDiagnosticsParams);
}
@Override
public void showMessage(MessageParams messageParams) {
// TODO
logger.info("Expression LSP show message: " + messageParams);
}
@Override
public CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams showMessageRequestParams) {
return null;
}
@Override
public void logMessage(MessageParams messageParams) {
expressionCodeAssistantService.lspLogMessage(messageParams.getMessage());
}
@JsonNotification("language/status")
public void languageStatus(Object o) {
// avoid unsupported notification warnings
}
}
CodeAssistantService.java
// start the Eclipse JDT LS required for the code assistant features
try {
String[] command = new String[]{
"java",
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Dlog.level=ALL",
"-noverify",
"-Xmx1G",
"-jar",
".../eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/plugins/org.eclipse.equinox.launcher_1.6.400.v20210924-0641.jar",
"-configuration",
".../eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/config_linux",
"-data",
"...",
"--add-modules=ALL-SYSTEM",
"--add-opens java.base/java.util=ALL-UNNAMED",
"--add-opens java.base/java.lang=ALL-UNNAMED"
};
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
.start();
ExpressionLanguageClient expressionLanguageClient = new ExpressionLanguageClient(this);
launcher = LSPLauncher.createClientLauncher(
expressionLanguageClient,
process.getInputStream(),
process.getOutputStream()
);
launcher.startListening();
} catch (Exception e) {
logger.error("Could not start the language server", e);
}
Be sure to customize the commands where necessary (paths and config_mac/linux/windows).
As soon as the language server is running (maybe listen for a log message), you need to call the init event (be sure to call from a separate thread, if you call it from the language client, because it would cause a deadlock otherwise):
InitializeParams initializeParams = new InitializeParams();
initializeParams.setProcessId(((int) ProcessHandle.current().pid()));
// workspace folders are read from the initialization options, not from the param
List<String> workspaceFolders = new ArrayList<>();
workspaceFolders.add("file:" + getTempDirectory());
Map<String, Object> initializationOptions = new HashMap<>();
initializationOptions.put("workspaceFolders", workspaceFolders);
initializeParams.setInitializationOptions(initializationOptions);
CompletableFuture<InitializeResult> init = launcher.getRemoteProxy().initialize(initializeParams);
try {
init.get();
logger.info("LSP initialized");
} catch (Exception e) {
logger.error("Could not initialize LSP server", e);
}
Now, you're able to get code completions like this:
TextDocumentItem textDocumentItem = new TextDocumentItem();
textDocumentItem.setText(isolatedCodeResult.getCode());
textDocumentItem.setUri("file:" + dummyFilePath);
textDocumentItem.setLanguageId("java");
DidOpenTextDocumentParams didOpenTextDocumentParams = new DidOpenTextDocumentParams();
didOpenTextDocumentParams.setTextDocument(textDocumentItem);
launcher.getRemoteProxy().getTextDocumentService().didOpen(didOpenTextDocumentParams);
TextDocumentIdentifier textDocumentIdentifier = new TextDocumentIdentifier();
textDocumentIdentifier.setUri("file:" + dummyFilePath);
CompletionParams completionParams = new CompletionParams();
completionParams.setPosition(new Position(line + lineOffset, column));
completionParams.setTextDocument(textDocumentIdentifier);
CompletableFuture<Either<List<CompletionItem>, CompletionList>> completion =
launcher.getRemoteProxy().getTextDocumentService().completion(completionParams);
logger.info("Found completions: " + completion.get().getRight().getItems());
Make sure, the dummyFilePath is a an existing java file. The content does not matter, but it needs to exist in order for the JDT LS to work.
I am not sure, whether it would be better to always sync the source files with the language server. Maybe this would be faster, especially for large projects. If you just need content assistant features for minor source files, the provided example should be sufficient.