diff --git a/build.gradle b/build.gradle index e93a8cb..37d31e8 100644 --- a/build.gradle +++ b/build.gradle @@ -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" } diff --git a/src/main/java/net/prominic/groovyls/GroovyLanguageServer.java b/src/main/java/net/prominic/groovyls/GroovyLanguageServer.java index 46e4042..eab0878 100644 --- a/src/main/java/net/prominic/groovyls/GroovyLanguageServer.java +++ b/src/main/java/net/prominic/groovyls/GroovyLanguageServer.java @@ -19,46 +19,79 @@ //////////////////////////////////////////////////////////////////////////////// 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.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.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; - -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; - -import net.prominic.groovyls.config.CompilationUnitFactory; -import net.prominic.groovyls.config.ICompilationUnitFactory; +import java.util.stream.Stream; public class GroovyLanguageServer implements LanguageServer, LanguageClientAware { - 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 + private static final Logger logger = LoggerFactory.getLogger(GroovyLanguageServer.class); + private LanguageClient client; + + 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."); + + InputStream in = socket.getInputStream(); + OutputStream out = socket.getOutputStream(); + startServer(in, out); + } + } else { + logger.info("Groovy Language Server starting in stdio mode."); + 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 launcher = Launcher.createLauncher(server, LanguageClient.class, systemIn, systemOut); + Launcher 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()); @@ -68,15 +101,47 @@ public GroovyLanguageServer(ICompilationUnitFactory compilationUnitFactory) { this.groovyServices = new GroovyServices(compilationUnitFactory); } + private List discoverGradleProjects(Path root) throws IOException { + List gradleProjects = new ArrayList<>(); + try (Stream 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 initialize(InitializeParams params) { 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); } + List folders = params.getWorkspaceFolders(); + if (folders != null) { + for (WorkspaceFolder folder : folders) { + Path folderPath = Paths.get(URI.create(folder.getUri())); + try { + List 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(e.getMessage(), e); + } + } + } + CompletionOptions completionOptions = new CompletionOptions(false, Arrays.asList(".")); ServerCapabilities serverCapabilities = new ServerCapabilities(); serverCapabilities.setCompletionProvider(completionOptions); @@ -97,6 +162,65 @@ public CompletableFuture initialize(InitializeParams params) { return CompletableFuture.completedFuture(initializeResult); } + 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); + + List classpathList = new ArrayList<>(); + + for (IdeaModule module : project.getChildren()) { + // Compiler output dirs + if (module.getCompilerOutput() != null) { + File outputDir = module.getCompilerOutput().getOutputDir(); + if (outputDir != null) { + classpathList.add(outputDir.getAbsolutePath()); + } + } + + classpathList.addAll(discoverClassDirs(folderPath)); + + 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(classpathList); + } catch (Exception e) { + logger.error(e.getMessage(), e); + } + } + + private List discoverClassDirs(Path projectDir) throws IOException { + Path classesRoot = projectDir.resolve("build/classes"); + List classDirs = new ArrayList<>(); + + if (Files.exists(classesRoot)) { + try (Stream stream = Files.walk(classesRoot, 2)) { + stream + .filter(Files::isDirectory) + .map(Path::toString) + .forEach(classDirs::add); + } + } + return classDirs; + } + @Override public CompletableFuture shutdown() { return CompletableFuture.completedFuture(new Object()); @@ -119,6 +243,7 @@ public WorkspaceService getWorkspaceService() { @Override public void connect(LanguageClient client) { + this.client = client; groovyServices.connect(client); } } diff --git a/src/main/java/net/prominic/groovyls/GroovyServices.java b/src/main/java/net/prominic/groovyls/GroovyServices.java index b234642..853fdda 100644 --- a/src/main/java/net/prominic/groovyls/GroovyServices.java +++ b/src/main/java/net/prominic/groovyls/GroovyServices.java @@ -48,38 +48,7 @@ import org.codehaus.groovy.control.messages.Message; import org.codehaus.groovy.control.messages.SyntaxErrorMessage; import org.codehaus.groovy.syntax.SyntaxException; -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionList; -import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.DefinitionParams; -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; -import org.eclipse.lsp4j.DidChangeConfigurationParams; -import org.eclipse.lsp4j.DidChangeTextDocumentParams; -import org.eclipse.lsp4j.DidChangeWatchedFilesParams; -import org.eclipse.lsp4j.DidCloseTextDocumentParams; -import org.eclipse.lsp4j.DidOpenTextDocumentParams; -import org.eclipse.lsp4j.DidSaveTextDocumentParams; -import org.eclipse.lsp4j.DocumentSymbol; -import org.eclipse.lsp4j.DocumentSymbolParams; -import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.LocationLink; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.PublishDiagnosticsParams; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.ReferenceParams; -import org.eclipse.lsp4j.RenameParams; -import org.eclipse.lsp4j.SignatureHelp; -import org.eclipse.lsp4j.SignatureHelpParams; -import org.eclipse.lsp4j.SymbolInformation; -import org.eclipse.lsp4j.TextDocumentContentChangeEvent; -import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.eclipse.lsp4j.TypeDefinitionParams; -import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; -import org.eclipse.lsp4j.WorkspaceEdit; -import org.eclipse.lsp4j.WorkspaceSymbolParams; +import org.eclipse.lsp4j.*; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; @@ -185,6 +154,17 @@ public void didChangeConfiguration(DidChangeConfigurationParams params) { this.updateClasspath(settings); } + void updateClasspath(List classpathList) { + if (!classpathList.equals(compilationUnitFactory.getAdditionalClasspathList())) { + compilationUnitFactory.setAdditionalClasspathList(classpathList); + + createOrUpdateCompilationUnit(); + compile(); + visitAST(); + previousContext = null; + } + } + private void updateClasspath(JsonObject settings) { List classpathList = new ArrayList<>(); @@ -198,14 +178,7 @@ private void updateClasspath(JsonObject settings) { } } - if (!classpathList.equals(compilationUnitFactory.getAdditionalClasspathList())) { - compilationUnitFactory.setAdditionalClasspathList(classpathList); - - createOrUpdateCompilationUnit(); - compile(); - visitAST(); - previousContext = null; - } + updateClasspath(classpathList); } // --- REQUESTS @@ -359,9 +332,9 @@ public CompletableFuture>> docume } @Override - public CompletableFuture> symbol(WorkspaceSymbolParams params) { + public CompletableFuture, List>> symbol(WorkspaceSymbolParams params) { WorkspaceSymbolProvider provider = new WorkspaceSymbolProvider(astVisitor); - return provider.provideWorkspaceSymbols(params.getQuery()); + return provider.provideWorkspaceSymbols(params.getQuery()).thenApply(Either::forLeft); } @Override diff --git a/src/main/java/net/prominic/groovyls/config/CompilationUnitFactory.java b/src/main/java/net/prominic/groovyls/config/CompilationUnitFactory.java index 3a89786..c81f9a2 100644 --- a/src/main/java/net/prominic/groovyls/config/CompilationUnitFactory.java +++ b/src/main/java/net/prominic/groovyls/config/CompilationUnitFactory.java @@ -1,4 +1,4 @@ -//////////////////////////////////////////////////////////////////////////////// +/// ///////////////////////////////////////////////////////////////////////////// // Copyright 2022 Prominic.NET, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -138,24 +138,31 @@ protected void getClasspathList(List result) { mustBeDirectory = true; } - File file = new File(entry); - if (!file.exists()) { - continue; - } - if (file.isDirectory()) { - for (File child : file.listFiles()) { - if (!child.getName().endsWith(".jar") || !child.isFile()) { - continue; - } - result.add(child.getPath()); - } - } else if (!mustBeDirectory && file.isFile()) { - if (file.getName().endsWith(".jar")) { - result.add(entry); - } - } - } - } + File file = new File(entry); + if (!file.exists()) { + continue; + } + + if (file.isDirectory()) { + // Always add directories (important for build/classes output) + result.add(file.getPath()); + + // And if user used '*', include jars inside + if (mustBeDirectory) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isFile() && child.getName().endsWith(".jar")) { + result.add(child.getPath()); + } + } + } + } + } else if (!mustBeDirectory && file.isFile() && file.getName().endsWith(".jar")) { + result.add(entry); + } + } + } protected void addDirectoryToCompilationUnit(Path dirPath, GroovyLSCompilationUnit compilationUnit, FileContentsTracker fileContentsTracker, Set changedUris) { diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 7178d1b..63ac6b7 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -21,7 +21,7 @@ "Groovy", "Grails" ], - "main": "extension", + "main": "./build/extension.js", "engines": { "vscode": "^1.99.0" }, @@ -77,6 +77,11 @@ "items": { "type": "string" } + }, + "groovy.debug.serverPort": { + "type": "number", + "default": 0, + "description": "If set, connect to an existing Groovy LSP server on this TCP port instead of starting one automatically." } } } diff --git a/vscode-extension/src/main/ts/extension.ts b/vscode-extension/src/main/ts/extension.ts index e0c6939..69dee28 100644 --- a/vscode-extension/src/main/ts/extension.ts +++ b/vscode-extension/src/main/ts/extension.ts @@ -20,10 +20,13 @@ import findJava from "./utils/findJava"; import * as path from "path"; import * as vscode from "vscode"; +import * as net from "net"; import { LanguageClient, LanguageClientOptions, Executable, + ServerOptions, + StreamInfo, } from "vscode-languageclient/node"; const MISSING_JAVA_ERROR = @@ -39,8 +42,32 @@ let extensionContext: vscode.ExtensionContext | null = null; let languageClient: LanguageClient | null = null; let javaPath: string | null = null; +export function activate(context: vscode.ExtensionContext) { + extensionContext = context; + javaPath = findJava(); + + vscode.workspace.onDidChangeConfiguration(onDidChangeConfiguration); + + vscode.commands.registerCommand( + "groovy.restartServer", + restartLanguageServer + ); + + startLanguageServer(); +} + +export function deactivate(): Thenable | undefined { + if (!languageClient) { + return undefined; + } + return languageClient.stop(); +} + function onDidChangeConfiguration(event: vscode.ConfigurationChangeEvent) { - if (event.affectsConfiguration("groovy.java.home")) { + if ( + event.affectsConfiguration("groovy.java.home") || + event.affectsConfiguration("groovy.debug.serverPort") + ) { javaPath = findJava(); //we're going to try to kill the language server and then restart //it with the new settings @@ -73,47 +100,68 @@ function restartLanguageServer() { ); } -export function activate(context: vscode.ExtensionContext) { - extensionContext = context; - javaPath = findJava(); - vscode.workspace.onDidChangeConfiguration(onDidChangeConfiguration); - - vscode.commands.registerCommand( - "groovy.restartServer", - restartLanguageServer - ); - - startLanguageServer(); -} - -export function deactivate() { - extensionContext = null; -} - function startLanguageServer() { vscode.window.withProgress( { location: vscode.ProgressLocation.Window }, (progress) => { return new Promise(async (resolve, reject) => { if (!extensionContext) { - //something very bad happened! resolve(); vscode.window.showErrorMessage(STARTUP_ERROR); return; } - if (!javaPath) { - resolve(); - let settingsJavaHome = vscode.workspace - .getConfiguration("groovy") - .get("java.home") as string; - if (settingsJavaHome) { - vscode.window.showErrorMessage(INVALID_JAVA_ERROR); - } else { - vscode.window.showErrorMessage(MISSING_JAVA_ERROR); + + const config = vscode.workspace.getConfiguration("groovy"); + const port = config.get("debug.serverPort") ?? 0; + + progress.report({message: INITIALIZING_MESSAGE}); + + let serverOptions: ServerOptions; + + if (port > 0) { + // === Debug mode: connect to running server === + serverOptions = () => { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + socket.connect(port, "127.0.0.1", () => { + console.log(`Connected to Groovy LSP on port ${port}`); + resolve({reader: socket, writer: socket}); + }); + socket.on("error", reject); + }); + }; + } else { + // === Normal mode: launch Java process === + if (!javaPath) { + resolve(); + let settingsJavaHome = config.get("java.home"); + if (settingsJavaHome) { + vscode.window.showErrorMessage(INVALID_JAVA_ERROR); + } else { + vscode.window.showErrorMessage(MISSING_JAVA_ERROR); + } + return; } - return; + + const args = [ + "-jar", + path.resolve( + extensionContext.extensionPath, + "bin", + "groovy-language-server-all.jar" + ), + ]; + + //uncomment to allow a debugger to attach to the language server + //args.unshift("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005,quiet=y"); + let executable: Executable = { + command: javaPath, + args, + }; + + serverOptions = executable; } - progress.report({ message: INITIALIZING_MESSAGE }); + let clientOptions: LanguageClientOptions = { documentSelector: [{ scheme: "file", language: "groovy" }], synchronize: { @@ -133,33 +181,20 @@ function startLanguageServer() { protocol2Code: (value) => vscode.Uri.parse(value), }, }; - let args = [ - "-jar", - path.resolve( - extensionContext.extensionPath, - "bin", - "groovy-language-server-all.jar" - ), - ]; - //uncomment to allow a debugger to attach to the language server - //args.unshift("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005,quiet=y"); - let executable: Executable = { - command: javaPath, - args: args, - }; + languageClient = new LanguageClient( "groovy", "Groovy Language Server", - executable, + serverOptions, clientOptions ); + try { await languageClient.start(); } catch (e) { - resolve(); vscode.window.showErrorMessage(STARTUP_ERROR); - return; } + resolve(); }); }