Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,20 @@ shadowJar {

repositories {
mavenCentral()
maven { url 'https://repo.gradle.org/gradle/libs-releases' }
}

def lsp4jVersion = "0.24.0"

dependencies {
implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.12.0"
implementation "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc:0.12.0"
implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:$lsp4jVersion"
implementation "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc:$lsp4jVersion"
implementation 'org.gradle:gradle-tooling-api:8.10.2'
implementation "org.apache.groovy:groovy:4.0.26"
implementation "com.google.code.gson:gson:2.13.1"
implementation "io.github.classgraph:classgraph:4.8.179"
implementation "org.slf4j:slf4j-api:2.0.17"
implementation "org.slf4j:slf4j-simple:2.0.17"
testImplementation "org.junit.jupiter:junit-jupiter-api:5.11.4"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.11.4"
}
Expand Down
18 changes: 18 additions & 0 deletions mason-registry/packages/groovy-language-server/package.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
name: groovy-language-server
description: A language server for Groovy (pvangeel fork - Gradle classpath resolution).
homepage: https://github.com/pvangeel/groovy-language-server
licenses:
- Apache-2.0
languages:
- Groovy
categories:
- LSP
source:
id: "pkg:github/pvangeel/groovy-language-server@fetch_classpath_from_gradle_configuration"
build:
run: ./gradlew --no-daemon shadowJar
bin:
groovy-language-server: "java-jar:build/libs/groovy-language-server-all.jar"
neovim:
lspconfig: groovyls
219 changes: 193 additions & 26 deletions src/main/java/net/prominic/groovyls/GroovyLanguageServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,46 +19,90 @@
////////////////////////////////////////////////////////////////////////////////
package net.prominic.groovyls;

import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import net.prominic.groovyls.config.CompilationUnitFactory;
import net.prominic.groovyls.config.ICompilationUnitFactory;
import org.eclipse.lsp4j.*;
import org.eclipse.lsp4j.jsonrpc.Launcher;
import org.eclipse.lsp4j.services.*;
import org.gradle.tooling.GradleConnector;
import org.gradle.tooling.ProjectConnection;
import org.gradle.tooling.model.idea.IdeaContentRoot;
import org.gradle.tooling.model.idea.IdeaDependency;
import org.gradle.tooling.model.idea.IdeaModule;
import org.gradle.tooling.model.idea.IdeaProject;
import org.gradle.tooling.model.idea.IdeaSingleEntryLibraryDependency;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Stream;

import org.eclipse.lsp4j.CompletionOptions;
import org.eclipse.lsp4j.InitializeParams;
import org.eclipse.lsp4j.InitializeResult;
import org.eclipse.lsp4j.ServerCapabilities;
import org.eclipse.lsp4j.SignatureHelpOptions;
import org.eclipse.lsp4j.TextDocumentSyncKind;
import org.eclipse.lsp4j.jsonrpc.Launcher;
import org.eclipse.lsp4j.services.LanguageClient;
import org.eclipse.lsp4j.services.LanguageClientAware;
import org.eclipse.lsp4j.services.LanguageServer;
import org.eclipse.lsp4j.services.TextDocumentService;
import org.eclipse.lsp4j.services.WorkspaceService;
public class GroovyLanguageServer implements LanguageServer, LanguageClientAware {

import net.prominic.groovyls.config.CompilationUnitFactory;
import net.prominic.groovyls.config.ICompilationUnitFactory;
private static final Logger logger = LoggerFactory.getLogger(GroovyLanguageServer.class);
private LanguageClient client;
private List<WorkspaceFolder> workspaceFolders;
private final ExecutorService gradleImportExecutor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "gradle-import");
thread.setDaemon(true);
return thread;
});

public class GroovyLanguageServer implements LanguageServer, LanguageClientAware {
public static void main(String[] args) throws IOException {
if (args.length > 0 && "--tcp".equals(args[0])) {
int port = 5007;
if (args.length > 1) {
try {
port = Integer.parseInt(args[1]);
} catch (NumberFormatException e) {
logger.error("Invalid port number: {}", args[1]);
System.exit(1);
}
}

try (ServerSocket serverSocket = new ServerSocket(port)) {
logger.info("Groovy Language Server listening on port {}", port);
Socket socket = serverSocket.accept();
logger.info("Client connected.");

public static void main(String[] args) {
InputStream systemIn = System.in;
OutputStream systemOut = System.out;
// redirect System.out to System.err because we need to prevent
// System.out from receiving anything that isn't an LSP message
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
startServer(in, out);
}
} else {
logger.info("Groovy Language Server starting in stdio mode. {}", Arrays.stream(args).map(Object::toString));
InputStream in = System.in;
OutputStream out = System.out;
startServer(in, out);
}
}

private static void startServer(InputStream in, OutputStream out) {
// Redirect System.out to System.err to avoid corrupting the communication channel
System.setOut(new PrintStream(System.err));

GroovyLanguageServer server = new GroovyLanguageServer();
Launcher<LanguageClient> launcher = Launcher.createLauncher(server, LanguageClient.class, systemIn, systemOut);
Launcher<LanguageClient> launcher = Launcher.createLauncher(server, LanguageClient.class, in, out);
server.connect(launcher.getRemoteProxy());
launcher.startListening();
}

private GroovyServices groovyServices;
private final GroovyServices groovyServices;

public GroovyLanguageServer() {
this(new CompilationUnitFactory());
Expand All @@ -68,15 +112,33 @@ public GroovyLanguageServer(ICompilationUnitFactory compilationUnitFactory) {
this.groovyServices = new GroovyServices(compilationUnitFactory);
}

private List<Path> discoverGradleProjects(Path root) throws IOException {
List<Path> gradleProjects = new ArrayList<>();
try (Stream<Path> fileStream = Files.walk(root)) {
fileStream
.filter(Files::isRegularFile)
.filter(p -> Set.of("build.gradle", "build.gradle.kts").contains(p.getFileName().toString()))
.forEach(buildFile -> gradleProjects.add(buildFile.getParent()));
}
return gradleProjects;
}

@Override
public CompletableFuture<InitializeResult> initialize(InitializeParams params) {
logger.info("Initializing Groovy Language Server.");
String rootUriString = params.getRootUri();
if (rootUriString != null) {
URI uri = URI.create(params.getRootUri());
URI uri = URI.create(rootUriString);
Path workspaceRoot = Paths.get(uri);
groovyServices.setWorkspaceRoot(workspaceRoot);
}

workspaceFolders = params.getWorkspaceFolders();
if ((workspaceFolders == null || workspaceFolders.isEmpty()) && rootUriString != null) {
// fall back to the root URI for clients that don't send workspace folders
workspaceFolders = Collections.singletonList(new WorkspaceFolder(rootUriString, rootUriString));
}

CompletionOptions completionOptions = new CompletionOptions(false, Arrays.asList("."));
ServerCapabilities serverCapabilities = new ServerCapabilities();
serverCapabilities.setCompletionProvider(completionOptions);
Expand All @@ -97,8 +159,112 @@ public CompletableFuture<InitializeResult> initialize(InitializeParams params) {
return CompletableFuture.completedFuture(initializeResult);
}

@Override
public void initialized(InitializedParams params) {
// Gradle project import can take anywhere from seconds to minutes (cold daemon,
// dependency downloads, etc). Running it here - after the InitializeResult has
// already been sent - keeps the client's initialize request from hanging.
if (workspaceFolders == null) {
return;
}
gradleImportExecutor.submit(() -> {
for (WorkspaceFolder folder : workspaceFolders) {
Path folderPath = Paths.get(URI.create(folder.getUri()));
try {
List<Path> gradleProjects = discoverGradleProjects(folderPath);
for (Path gradleProject : gradleProjects) {
if (client != null) {
client.showMessage(new MessageParams(
MessageType.Info,
"Importing Gradle project: " + gradleProject
));
}
importGradleProject(gradleProject);
}
} catch (IOException e) {
logger.error("Failed to discover Gradle projects in {}", folderPath, e);
}
}
});
}

public void importGradleProject(Path folderPath) {
GradleConnector connector = GradleConnector.newConnector()
.forProjectDirectory(folderPath.toFile());

try (ProjectConnection connection = connector.connect()) {
// First run the build (blocking)
connection.newBuild()
.forTasks("classes")
.setStandardOutput(System.out)
.setStandardError(System.err)
.run();

IdeaProject project = connection.getModel(IdeaProject.class);

// LinkedHashSet: multiple modules commonly share the same content roots and
// dependency jars, so de-dupe while keeping a stable, deterministic order.
Set<String> classpathList = new LinkedHashSet<>();

for (IdeaModule module : project.getChildren()) {
// Compiler output dirs
if (module.getCompilerOutput() != null) {
File outputDir = module.getCompilerOutput().getOutputDir();
if (outputDir != null) {
classpathList.add(outputDir.getAbsolutePath());
}
}

// Each module has its own content root(s) - not the multi-project root -
// under which its own build/classes output lives.
for (IdeaContentRoot contentRoot : module.getContentRoots()) {
File rootDirectory = contentRoot.getRootDirectory();
if (rootDirectory != null) {
classpathList.addAll(discoverClassDirs(rootDirectory.toPath()));
}
}

for (IdeaDependency dep : module.getDependencies()) {
if (dep instanceof IdeaSingleEntryLibraryDependency) {
File file = ((IdeaSingleEntryLibraryDependency) dep).getFile();
if (file != null && file.exists()) {
classpathList.add(file.getAbsolutePath());
}
}
}
}

logger.info("classpathList: {}", classpathList);
groovyServices.updateClasspath(new ArrayList<>(classpathList));
} catch (Exception e) {
logger.error("Failed to import Gradle project at {}", folderPath, e);
if (client != null) {
client.showMessage(new MessageParams(
MessageType.Error,
"Failed to import Gradle project " + folderPath + ": " + e.getMessage()
));
}
}
}

private List<String> discoverClassDirs(Path projectDir) throws IOException {
Path classesRoot = projectDir.resolve("build/classes");
List<String> classDirs = new ArrayList<>();

if (Files.exists(classesRoot)) {
try (Stream<Path> stream = Files.walk(classesRoot, 2)) {
stream
.filter(Files::isDirectory)
.map(Path::toString)
.forEach(classDirs::add);
}
}
return classDirs;
}

@Override
public CompletableFuture<Object> shutdown() {
gradleImportExecutor.shutdownNow();
return CompletableFuture.completedFuture(new Object());
}

Expand All @@ -119,6 +285,7 @@ public WorkspaceService getWorkspaceService() {

@Override
public void connect(LanguageClient client) {
this.client = client;
groovyServices.connect(client);
}
}
Loading