diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/plugin.xml b/eclipse-language-servers/org.springframework.tooling.boot.ls/plugin.xml index c9453ba38..87ff4611d 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/plugin.xml +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/plugin.xml @@ -256,6 +256,10 @@ class="org.springframework.tooling.boot.ls.commands.RewriteRefactoringsHandler$UpgradeBootVersion" commandId="org.springframework.tooling.boot.ls.rewrite.boot-upgrade"> + + @@ -282,6 +286,11 @@ id="org.springframework.tooling.boot.ls.rewrite.boot-upgrade" name="Upgrade Spring Boot Version..."> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootProjectTester.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootProjectTester.java index ed0d2c82d..cb6ecd003 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootProjectTester.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootProjectTester.java @@ -39,7 +39,23 @@ public class BootProjectTester extends PropertyTester { if (project != null) { IJavaProject jp = JavaCore.create(project); if (jp != null) { - return isBootProject(project); + return isProjectWithDependency(jp, "spring-boot"); + } + } + } + } else if ("isProjectWithDependencyResource".equals(property)) { + IResource resource = null; + if (receiver instanceof IAdaptable) { + resource = ((IAdaptable) receiver).getAdapter(IResource.class); + } else if (receiver instanceof IDocument) { + resource = LSPEclipseUtils.getFile((IDocument) receiver); + } + if (resource != null) { + IProject project = resource.getProject(); + if (project != null) { + IJavaProject jp = JavaCore.create(project); + if (jp != null) { + return isProjectWithDependency(jp, (String) args[0]); } } } @@ -47,42 +63,38 @@ public class BootProjectTester extends PropertyTester { return false; } - private static boolean isBootProject(IProject project) { - if (project==null || ! project.isAccessible()) { + private static boolean isProjectWithDependency(IJavaProject jp, String dep) { + if (jp == null || ! jp.getProject().isAccessible()) { return false; } try { - if (project.hasNature(JavaCore.NATURE_ID)) { - IJavaProject jp = JavaCore.create(project); - IClasspathEntry[] classpath = jp.getResolvedClasspath(true); - //Look for a 'spring-boot' jar or project entry + IClasspathEntry[] classpath = jp.getResolvedClasspath(true); - for (IClasspathEntry e : classpath) { - if (isBootJar(e) || isBootProject(e)) { - return true; - } - } + for (IClasspathEntry e : classpath) { + if (isDependencyJar(dep, e) || isDependencyProject(dep, e)) { + return true; + } } } catch (Exception e) { CorePlugin.log(e); } return false; } - - private static boolean isBootJar(IClasspathEntry e) { + + private static boolean isDependencyJar(String dep, IClasspathEntry e) { if (e.getEntryKind()==IClasspathEntry.CPE_LIBRARY) { IPath path = e.getPath(); String name = path.lastSegment(); - return name.endsWith(".jar") && name.startsWith("spring-boot"); + return name.endsWith(".jar") && name.startsWith(dep); } return false; } - private static boolean isBootProject(IClasspathEntry e) { + private static boolean isDependencyProject(String dep, IClasspathEntry e) { if (e.getEntryKind()==IClasspathEntry.CPE_PROJECT) { IPath path = e.getPath(); String name = path.lastSegment(); - return name.startsWith("spring-boot"); + return name.startsWith(dep); } return false; } diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/commands/RefreshModulithMetadata.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/commands/RefreshModulithMetadata.java new file mode 100644 index 000000000..67d2363ab --- /dev/null +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/commands/RefreshModulithMetadata.java @@ -0,0 +1,59 @@ +/******************************************************************************* + * Copyright (c) 2023 VMware, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * VMware, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.tooling.boot.ls.commands; + +import java.util.List; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.Assert; +import org.eclipse.core.runtime.IAdaptable; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.LanguageServersRegistry; +import org.eclipse.lsp4e.LanguageServersRegistry.LanguageServerDefinition; +import org.eclipse.lsp4j.ExecuteCommandParams; +import org.eclipse.ui.handlers.HandlerUtil; +import org.springframework.tooling.boot.ls.BootLanguageServerPlugin; + +@SuppressWarnings("restriction") +public class RefreshModulithMetadata extends AbstractHandler { + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + IStructuredSelection selection = HandlerUtil.getCurrentStructuredSelection(event); + Object o = selection.getFirstElement(); + IProject project = null; + if (o instanceof IResource) { + project = ((IResource) o).getProject(); + } else if (o instanceof IProject) { + project = (IProject) o; + } else if (o instanceof IAdaptable) { + project = ((IAdaptable) o).getAdapter(IProject.class); + } + if (project != null) { + LanguageServerDefinition def = LanguageServersRegistry.getInstance().getDefinition(BootLanguageServerPlugin.BOOT_LS_DEFINITION_ID); + Assert.isLegal(def != null, "No definition found for Boot Language Server"); + + final String uri = project.getLocationURI().toASCIIString(); + ExecuteCommandParams commandParams = new ExecuteCommandParams(); + commandParams.setCommand("sts/modulith/metadata/refresh"); + commandParams.setArguments(List.of(uri)); + + LanguageServers.forProject(project).withPreferredServer(def).computeFirst(ls -> ls.getWorkspaceService().executeCommand(commandParams)); + } + return null; + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerBootApp.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerBootApp.java index 67893ccd3..4cede7916 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerBootApp.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerBootApp.java @@ -376,8 +376,11 @@ public class BootLanguageServerBootApp { } @Bean - ModulithService modulithService(SimpleLanguageServer server, ProjectObserver projectObserver, SpringMetamodelIndex springIndex, JavaProjectFinder projectFinder) { - return new ModulithService(projectObserver, server.getWorkspaceService().getFileObserver(), projectFinder, springIndex); + ModulithService modulithService(SimpleLanguageServer server, JavaProjectFinder projectFinder, + ProjectObserver projectObserver, SpringSymbolIndex springIndex, + Optional projectReconcileScheduler, BootJavaReconcileEngine reconciler, + BootJavaConfig config) { + return new ModulithService(server, projectFinder, projectObserver, springIndex, reconciler, projectReconcileScheduler, config); } } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/common/ProjectReconcileScheduler.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/common/ProjectReconcileScheduler.java index 7f2c0b61f..6305dce92 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/common/ProjectReconcileScheduler.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/common/ProjectReconcileScheduler.java @@ -69,7 +69,7 @@ public abstract class ProjectReconcileScheduler { } } - protected final void scheduleValidation(IJavaProject project) { + public final void scheduleValidation(IJavaProject project) { if (!SpringProjectUtil.isSpringProject(project)) { // Bail out if not a spring project return; diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/Boot3JavaProblemType.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/Boot3JavaProblemType.java index e835b17b9..4610bea91 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/Boot3JavaProblemType.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/Boot3JavaProblemType.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2020, 2022 Pivotal, Inc. + * Copyright (c) 2020, 2023 Pivotal, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -27,7 +27,7 @@ public enum Boot3JavaProblemType implements ProblemType { FACTORIES_KEY_NOT_SUPPORTED(ERROR, "Spring factories key not supported", "Spring factories key not supported"), - MODULITH_TYPE_REF_VIOLATION(ERROR, "Restricted dependency", "Import from restricted module"); + MODULITH_TYPE_REF_VIOLATION(ERROR, "Modulith restricted type reference", "Modulith restricted type reference"); private final ProblemSeverity defaultSeverity; private String description; diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/reconcile/ModulithTypeReferenceViolation.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/reconcile/ModulithTypeReferenceViolation.java index 5009778ef..c32253a5a 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/reconcile/ModulithTypeReferenceViolation.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/reconcile/ModulithTypeReferenceViolation.java @@ -11,8 +11,6 @@ package org.springframework.ide.vscode.boot.java.rewrite.reconcile; import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import org.openrewrite.ExecutionContext; import org.openrewrite.Tree; @@ -44,16 +42,7 @@ public class ModulithTypeReferenceViolation implements RecipeCodeActionDescripto @Override public JavaVisitor getMarkerVisitor(MarkerVisitorContext context) { ModulithService modulithService = context.appContext().getBean(ModulithService.class); - CompletableFuture future = modulithService.getModulesData(context.project()); - - AppModules modules = null; - try { - modules = future == null ? null : future.get(); - } catch (InterruptedException | ExecutionException e) { - // ignore - } - - final AppModules appModules = modules; + AppModules appModules = modulithService.getModulesData(context.project()); return new JavaIsoVisitor() { diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/AppModules.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/AppModules.java index 88af53765..9939a99eb 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/AppModules.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/AppModules.java @@ -12,6 +12,7 @@ package org.springframework.ide.vscode.boot.modulith; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; public final class AppModules { @@ -60,4 +61,21 @@ public final class AppModules { return packageHierarchy; } + @Override + public int hashCode() { + return Objects.hash(modules); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + AppModules other = (AppModules) obj; + return Objects.equals(modules, other.modules); + } + } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/ModulithService.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/ModulithService.java index 356b1345d..067c26cde 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/ModulithService.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/ModulithService.java @@ -14,10 +14,12 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; 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.HashSet; import java.util.List; import java.util.Map; @@ -25,154 +27,233 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.MessageType; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ide.vscode.boot.index.SpringMetamodelIndex; +import org.springframework.ide.vscode.boot.app.BootJavaConfig; +import org.springframework.ide.vscode.boot.app.SpringSymbolIndex; import org.springframework.ide.vscode.boot.java.Annotations; -import org.springframework.ide.vscode.commons.java.IClasspathUtil; +import org.springframework.ide.vscode.boot.java.Boot3JavaProblemType; +import org.springframework.ide.vscode.boot.java.handlers.BootJavaProjectReconcilerScheduler; +import org.springframework.ide.vscode.boot.java.handlers.BootJavaReconcileEngine; import org.springframework.ide.vscode.commons.java.IJavaProject; import org.springframework.ide.vscode.commons.java.SpringProjectUtil; import org.springframework.ide.vscode.commons.java.Version; import org.springframework.ide.vscode.commons.languageserver.java.JavaProjectFinder; import org.springframework.ide.vscode.commons.languageserver.java.ProjectObserver; +import org.springframework.ide.vscode.commons.languageserver.reconcile.ProblemCategory.Toggle.Option; +import org.springframework.ide.vscode.commons.languageserver.util.SimpleLanguageServer; import org.springframework.ide.vscode.commons.protocol.java.Classpath; import org.springframework.ide.vscode.commons.protocol.spring.Bean; -import org.springframework.ide.vscode.commons.util.FileObserver; +import org.springframework.ide.vscode.commons.protocol.spring.BeansParams; +import org.springframework.ide.vscode.commons.util.text.TextDocument; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.tngtech.archunit.thirdparty.com.google.common.base.Objects; public class ModulithService { - + private static final Logger log = LoggerFactory.getLogger(ModulithService.class); - private static final List FILE_PATTERNS = List.of("**/*.java"); + private static final String CMD_MODULITH_REFRESH = "sts/modulith/metadata/refresh"; + private static final String CMD_LIST_MODULITH_PROJECTS = "sts/modulith/projects"; - private Map> cache; - - private SpringMetamodelIndex springIndex; - + private SimpleLanguageServer server; + private Optional projectReconcileScheduler; + private SpringSymbolIndex springIndex; + private BootJavaReconcileEngine reconciler; private JavaProjectFinder projectFinder; + private BootJavaConfig config; - public ModulithService(ProjectObserver projectObserver, FileObserver fileObserver, JavaProjectFinder projectFinder, SpringMetamodelIndex springIndex) { + private Map cache; + private Map> metadataRequested; + + public ModulithService( + SimpleLanguageServer server, + JavaProjectFinder projectFinder, + ProjectObserver projectObserver, + SpringSymbolIndex springIndex, + BootJavaReconcileEngine reconciler, + Optional projectReconcileScheduler, + BootJavaConfig config + ) { this.projectFinder = projectFinder; + this.config = config; + this.cache = new ConcurrentHashMap<>(); + this.metadataRequested = new ConcurrentHashMap<>(); + this.server = server; + this.projectReconcileScheduler = projectReconcileScheduler; this.springIndex = springIndex; - cache = new ConcurrentHashMap<>(); + this.reconciler = reconciler; projectObserver.addListener(new ProjectObserver.Listener() { @Override public void deleted(IJavaProject project) { - invalidate(project); + removeFromCache(project); } @Override public void created(IJavaProject project) { - invalidate(project); + Version v = SpringProjectUtil.getDependencyVersion(project, "spring-modulith-core"); + if (v != null) { + if (anyClassFilesPresent(project)) { + requestMetadata(project); + } else { + waitForClassFilesCreatedInTargetFolder(project); + } + } } @Override public void changed(IJavaProject project) { - invalidate(project); + Version v = SpringProjectUtil.getDependencyVersion(project, "spring-modulith-core"); + if (v == null) { + removeFromCache(project); + } else if (anyClassFilesPresent(project)) { + requestMetadata(project); + } else { + waitForClassFilesCreatedInTargetFolder(project); + } } - }); - fileObserver.onFilesCreated(FILE_PATTERNS, this::handleFilesChanged); - fileObserver.onFilesDeleted(FILE_PATTERNS, this::handleFilesChanged); - } - - private void handleFilesChanged(String[] files) { - for (String f : files) { - URI uri = URI.create(f); - TextDocumentIdentifier docId = new TextDocumentIdentifier(uri.toASCIIString()); - projectFinder.find(docId).ifPresent(project -> { - synchronized (project) { - if (cache.containsKey(project)) { - Path filePath = Paths.get(uri); - if (IClasspathUtil.getProjectJavaSourceFoldersWithoutTests(project.getClasspath()).map(folder -> folder.toPath()).anyMatch(folderPath -> filePath.startsWith(folderPath))) { - cache.remove(project); - } - } - } - }); - } - } - - private void invalidate(IJavaProject project) { - CompletableFuture future = cache.remove(project); - if (future != null) { - future.cancel(true); - } - } + server.onCommand(CMD_MODULITH_REFRESH, params -> { + String uri = ((JsonElement) params.getArguments().get(0)).getAsString(); + return projectFinder.find(new TextDocumentIdentifier(uri)).map(this::refreshMetadata).orElse(CompletableFuture.completedFuture(false)).thenApply(String::valueOf); + }); + server.onCommand(CMD_LIST_MODULITH_PROJECTS, params -> { + return CompletableFuture.completedFuture(projectFinder.all() + .stream() + .filter(p -> SpringProjectUtil.getDependencyVersion(p, "spring-modulith-core") != null) + .collect(Collectors.toMap(p -> p.getElementName(), p -> p.getLocationUri().toASCIIString())) + ); + }); + } + + private void waitForClassFilesCreatedInTargetFolder(IJavaProject project) { + final AtomicReference subscription = new AtomicReference<>(); + subscription.set(server.getWorkspaceService().getFileObserver().onFilesCreated(getNonTestClassOutputFolders(project).map(p -> p.toString() + "/**/*.class").collect(Collectors.toList()), files -> { + if (subscription.get() != null) { + server.getWorkspaceService().getFileObserver().unsubscribe(subscription.get()); + requestMetadata(project); + } + })); + } + + public AppModules getModulesData(IJavaProject project) { + return cache.get(project.getLocationUri()); + } + + private CompletableFuture refreshMetadata(IJavaProject project) { + Version v = SpringProjectUtil.getDependencyVersion(project, "spring-modulith-core"); + if (v == null) { + server.getClient().showMessage(new MessageParams(MessageType.Error, "Project '" + project.getElementName() + "' does not depend on spring-modulith.")); + return CompletableFuture.completedFuture(false); + } + if (!anyClassFilesPresent(project)) { + server.getClient().showMessage(new MessageParams(MessageType.Error, "Project '" + project.getElementName() + "' output folder does not contain any '.class' files. Consider re-building.")); + return CompletableFuture.completedFuture(false); + } + clearMetadataRequest(project); + return requestMetadata(project).whenComplete((refreshed, throwable) -> { + if (throwable != null) { + server.getClient().showMessage(new MessageParams(MessageType.Error, "Project '" + project.getElementName() + "' Modulith metadata refresh has failed. " + throwable.getMessage())); + } else { + if (refreshed) { + server.getClient().showMessage(new MessageParams(MessageType.Info, "Project '" + project.getElementName() + "' Modulith metadata has been changed.")); + } else { + server.getClient().showMessage(new MessageParams(MessageType.Info, "Project '" + project.getElementName() + "' Modulith metadata has been refreshed but it has not unchanged.")); + } + } + }); + } + + CompletableFuture requestMetadata(IJavaProject p) { + URI uri = p.getLocationUri(); + CompletableFuture f = metadataRequested.get(uri); + if (f == null) { + f = loadModulesMetadata(p).thenApply(appModules -> updateAppModulesCache(p, appModules)); + metadataRequested.put(uri, f); + } + return f; + } + + private boolean updateAppModulesCache(IJavaProject project, AppModules modules) { + URI uri = project.getLocationUri(); + AppModules oldModules = modules == null ? cache.remove(uri) : cache.put(uri, modules); + if (!Objects.equal(modules, oldModules)) { + validate(project); + return true; + } + return false; + } + + private AppModules removeFromCache(IJavaProject project) { + clearMetadataRequest(project); + return cache.remove(project.getLocationUri()); + } + + private void clearMetadataRequest(IJavaProject project) { + CompletableFuture f = metadataRequested.remove(project.getLocationUri()); + if (f != null && !f.isDone()) { + f.cancel(true); + } + } + + private void validate(IJavaProject project) { + if (server.getDiagnosticSeverityProvider().getDiagnosticSeverity(Boot3JavaProblemType.MODULITH_TYPE_REF_VIOLATION) != null + && config.getProblemApplicability(Boot3JavaProblemType.MODULITH_TYPE_REF_VIOLATION) != Option.OFF) { + for (TextDocument doc : server.getTextDocumentService().getAll()) { + if (projectFinder.find(doc.getId()).orElse(null) == project) { + server.validateWith(doc.getId(), reconciler); + } + } + projectReconcileScheduler.ifPresent(r -> r.scheduleValidation(project)); + } + } + private CompletableFuture loadModulesMetadata(IJavaProject project) { Version v = SpringProjectUtil.getDependencyVersion(project, "spring-modulith-core"); if (v != null) { - Set packages = findRootPackages(project); - if (!packages.isEmpty()) { - try { - String javaCmd = ProcessHandle.current().info().command().orElseThrow(); - String classpathStr = project.getClasspath().getClasspathEntries().stream().map(cpe -> { - if (Classpath.ENTRY_KIND_SOURCE.equals(cpe.getKind())) { - return cpe.getOutputFolder(); - } else { - return cpe.getPath(); - } - }).collect(Collectors.joining(System.getProperty("path.separator"))); - List allAppModules = new ArrayList<>(); - CompletableFuture[] aggregateFuture = packages - .stream() - .map(pkg -> computeAppModules(project.getElementName(), javaCmd, classpathStr, pkg).thenAccept(oa -> oa.ifPresent(allAppModules::addAll))) - .toArray(CompletableFuture[]::new); - return CompletableFuture.allOf(aggregateFuture).thenApply(r -> new AppModules(allAppModules)); - } catch (Exception e) { - log.error("", e); + log.info("Loading Modulith metadata for project '" + project.getElementName() + "'..."); + return findRootPackages(project).thenComposeAsync(packages -> { + if (!packages.isEmpty()) { + try { + String javaCmd = ProcessHandle.current().info().command().orElseThrow(); + String classpathStr = project.getClasspath().getClasspathEntries().stream().map(cpe -> { + if (Classpath.ENTRY_KIND_SOURCE.equals(cpe.getKind())) { + return cpe.getOutputFolder(); + } else { + return cpe.getPath(); + } + }).collect(Collectors.joining(System.getProperty("path.separator"))); + List allAppModules = new ArrayList<>(); + CompletableFuture[] aggregateFuture = packages + .stream() + .map(pkg -> computeAppModules(project.getElementName(), javaCmd, classpathStr, pkg).thenAccept(allAppModules::addAll)) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(aggregateFuture).thenApply(r -> new AppModules(allAppModules)); + } catch (Exception e) { + log.error("", e); + } } - } + return CompletableFuture.completedFuture(null); + }); } return CompletableFuture.completedFuture(null); } - public CompletableFuture getModulesData(IJavaProject project) { - synchronized(project) { - CompletableFuture modules = cache.get(project); - if (modules == null) { - modules = loadModulesMetadata(project); - cache.put(project, modules); - } - return modules; - } - } - - private Set findRootPackages(IJavaProject project) { - HashSet packages = new HashSet<>(); - for (Bean bean : springIndex.getBeansOfProject(project.getElementName())) { - String beanType = bean.getType(); - if (beanType != null) { - if (Arrays.stream(bean.getAnnotations()).anyMatch(Annotations.BOOT_APP::equals)) { - packages.add(getPackageNameFromTypeFQName(beanType)); - } - } - } - return packages; - } - - static String getPackageNameFromTypeFQName(String fqn) { - int idx = 0; - for (; idx < fqn.length() - 1; idx++) { - char c = fqn.charAt(idx); - if (c == '.' && Character.isUpperCase(fqn.charAt(idx + 1))) { - return fqn.substring(0, idx); - } - } - return fqn; - } - - private CompletableFuture>> computeAppModules(String projectName, String javaCmd, + private CompletableFuture> computeAppModules(String projectName, String javaCmd, String cp, String pkg) { try { File outputFile = File.createTempFile(projectName + "-" + pkg, "json"); @@ -191,19 +272,49 @@ public class ModulithService { log.info("Updating Modulith metadata for project '" + projectName + "'"); JsonObject json = JsonParser.parseReader(new FileReader(outputFile)).getAsJsonObject(); log.info("Modulith metadata: " + json); - return Optional.ofNullable(loadAppModules(json)); + return loadAppModules(json); } catch (Exception e) { log.error("", e); } } else { log.error("Failed to generate modulith metadata for project '" + projectName + "'. Modulith Exporter process exited with code " + process.exitValue()); } - return Optional.empty(); + return Collections.emptyList(); }); } catch (IOException e) { log.error("", e); } - return CompletableFuture.completedFuture(Optional.empty()); + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + private CompletableFuture> findRootPackages(IJavaProject project) { + BeansParams params = new BeansParams(); + params.setProjectName(project.getElementName()); + return springIndex.beans(params).thenApply(beansOfProject -> { + HashSet packages = new HashSet<>(); + if (beansOfProject != null) { + for (Bean bean : beansOfProject) { + String beanType = bean.getType(); + if (beanType != null) { + if (Arrays.stream(bean.getAnnotations()).anyMatch(Annotations.BOOT_APP::equals)) { + packages.add(getPackageNameFromTypeFQName(beanType)); + } + } + } + } + return packages; + }); + } + + static String getPackageNameFromTypeFQName(String fqn) { + int idx = 0; + for (; idx < fqn.length() - 1; idx++) { + char c = fqn.charAt(idx); + if (c == '.' && Character.isUpperCase(fqn.charAt(idx + 1))) { + return fqn.substring(0, idx); + } + } + return fqn; } private static List loadAppModules(JsonObject json) { @@ -222,4 +333,27 @@ public class ModulithService { .collect(Collectors.toList()); return new AppModule(name, basePackage, namedInterfaces); } + + private static boolean anyClassFilesPresent(IJavaProject p) { + return getNonTestClassOutputFolders(p).anyMatch(path -> { + try { + return Files.exists(path) && Files.walk(path).anyMatch(f -> Files.isRegularFile(f) && f.toFile().getName().endsWith(".class")); + } catch (IOException e) { + log.error("", e); + return false; + } + }); + } + + private static Stream getNonTestClassOutputFolders(IJavaProject p) { + try { + return p.getClasspath().getClasspathEntries() + .stream() + .filter(cpe -> Classpath.ENTRY_KIND_SOURCE.equals(cpe.getKind()) && cpe.isJavaContent() && cpe.isOwn() && !cpe.isTest()) + .map(cpe -> Paths.get(cpe.getOutputFolder())); + } catch (Exception e) { + log.error("", e); + return Stream.empty(); + } + } } diff --git a/headless-services/spring-boot-language-server/src/main/resources/problem-types.json b/headless-services/spring-boot-language-server/src/main/resources/problem-types.json index a3ccaf8f5..39da42893 100644 --- a/headless-services/spring-boot-language-server/src/main/resources/problem-types.json +++ b/headless-services/spring-boot-language-server/src/main/resources/problem-types.json @@ -111,8 +111,8 @@ }, { "code": "MODULITH_TYPE_REF_VIOLATION", - "label": "Import from restricted module", - "description": "Restricted dependency", + "label": "Modulith restricted type reference", + "description": "Modulith restricted type reference", "defaultSeverity": "ERROR" } ] diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/modulith/ModulithServiceTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/modulith/ModulithServiceTest.java index 59e6e0939..2ff2ca93e 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/modulith/ModulithServiceTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/modulith/ModulithServiceTest.java @@ -11,6 +11,7 @@ package org.springframework.ide.vscode.boot.modulith; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -48,6 +49,7 @@ public class ModulithServiceTest { @BeforeEach public void setup() throws Exception { + harness.intialize(null); // Use project harness with a customizer to land the project in the 'temp' folder rather than 'target/test-classes' because Modulith will filter everything with 'target/test-classes' out :-\ jp = projects.mavenProject("spring-modulith-example-full", p -> {}); harness.useProject(jp); @@ -68,7 +70,8 @@ public class ModulithServiceTest { @Test void sanityTest() throws Exception { - List modules = modulithService.getModulesData(jp).get().modules; + assertTrue(modulithService.requestMetadata(jp).get()); + List modules = modulithService.getModulesData(jp).modules; assertEquals(2, modules.size()); AppModule orderModule = modules.get(0); assertEquals("order", orderModule.name()); diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/super-property-nav-sample/pom.xml b/headless-services/spring-boot-language-server/src/test/resources/test-projects/super-property-nav-sample/pom.xml index 1f7645b0f..6d98fc9df 100644 --- a/headless-services/spring-boot-language-server/src/test/resources/test-projects/super-property-nav-sample/pom.xml +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/super-property-nav-sample/pom.xml @@ -47,7 +47,7 @@ io.spring.initializr initializr-generator-spring - 0.8.0.BUILD-SNAPSHOT + 0.8.0.RELEASE diff --git a/vscode-extensions/vscode-spring-boot/lib/Main.ts b/vscode-extensions/vscode-spring-boot/lib/Main.ts index 0d19300d8..90979e037 100644 --- a/vscode-extensions/vscode-spring-boot/lib/Main.ts +++ b/vscode-extensions/vscode-spring-boot/lib/Main.ts @@ -164,6 +164,21 @@ export function activate(context: VSCode.ExtensionContext): Thenable client.stop()); liveHoverUi.activate(client, options, context); rewrite.activate(client, options, context); + + VSCode.commands.registerCommand('vscode-spring-boot.spring.modulith.metadata.refresh', async () => { + const modulithProjects = await VSCode.commands.executeCommand('sts/modulith/projects'); + const projectNames = Object.keys(modulithProjects); + if (projectNames.length === 0) { + VSCode.window.showErrorMessage('No Spring Modulith projects found'); + } else { + const projectName = projectNames.length === 1 ? projectNames[0] : await VSCode.window.showQuickPick( + projectNames, + { placeHolder: "Select the target project." }, + ); + VSCode.commands.executeCommand('sts/modulith/metadata/refresh', modulithProjects[projectName]); + } + }); + return new ApiManager(client).api; }); } diff --git a/vscode-extensions/vscode-spring-boot/package.json b/vscode-extensions/vscode-spring-boot/package.json index 7a0148ba2..7c0840bb1 100644 --- a/vscode-extensions/vscode-spring-boot/package.json +++ b/vscode-extensions/vscode-spring-boot/package.json @@ -126,6 +126,11 @@ "command": "sts/common-properties/reload", "title": "Reload Shared Properties Metadata", "category": "Spring Boot" + }, + { + "command": "vscode-spring-boot.spring.modulith.metadata.refresh", + "title": "Refresh Modulith Metadata", + "category": "Spring Boot" } ], "configuration": [ @@ -498,7 +503,7 @@ "spring-boot.ls.problem.boot3.MODULITH_TYPE_REF_VIOLATION": { "type": "string", "default": "ERROR", - "description": "Restricted dependency", + "description": "Modulith restricted type reference", "enum": [ "IGNORE", "INFO",