diff --git a/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/spring/Bean.java b/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/spring/Bean.java index 89a7ceb12..3d76da77a 100644 --- a/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/spring/Bean.java +++ b/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/spring/Bean.java @@ -25,8 +25,9 @@ public class Bean { private final Location location; private final InjectionPoint[] injectionPoints; private final Set supertypes; + private final String[] annotations; - public Bean(String name, String type, Location location, InjectionPoint[] injectionPoints, String[] supertypes) { + public Bean(String name, String type, Location location, InjectionPoint[] injectionPoints, String[] supertypes, String[] annotations) { this.name = name; this.type = type; this.location = location; @@ -39,6 +40,7 @@ public class Bean { } this.supertypes = new HashSet<>(Arrays.asList(supertypes)); + this.annotations = annotations; } public String getName() { @@ -61,6 +63,10 @@ public class Bean { return type != null && ((this.type != null && this.type.equals(type)) || (supertypes.contains(type))); } + public String[] getAnnotations() { + return annotations; + } + @Override public String toString() { Gson gson = new Gson(); diff --git a/headless-services/spring-boot-language-server/pom.xml b/headless-services/spring-boot-language-server/pom.xml index 6489cf2e2..edbf88010 100644 --- a/headless-services/spring-boot-language-server/pom.xml +++ b/headless-services/spring-boot-language-server/pom.xml @@ -61,6 +61,11 @@ org.springframework spring-web + + org.springframework.modulith + spring-modulith-core + 1.0.0-SNAPSHOT + org.springframework.ide.vscode commons-yaml @@ -192,6 +197,11 @@ ${dependencies.version} test + + org.springframework.ide.vscode + bosh-language-server + 1.48.0-SNAPSHOT + 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 13dcf6ff7..bce4abe5d 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 @@ -40,8 +40,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.ide.vscode.boot.common.PropertyCompletionFactory; import org.springframework.ide.vscode.boot.common.RelaxedNameConfig; -import org.springframework.ide.vscode.boot.java.JavaDefinitionHandler; import org.springframework.ide.vscode.boot.index.SpringMetamodelIndex; +import org.springframework.ide.vscode.boot.java.JavaDefinitionHandler; import org.springframework.ide.vscode.boot.java.handlers.BootJavaCodeActionProvider; import org.springframework.ide.vscode.boot.java.handlers.BootJavaProjectReconcilerScheduler; import org.springframework.ide.vscode.boot.java.handlers.BootJavaReconcileEngine; @@ -74,6 +74,7 @@ import org.springframework.ide.vscode.boot.metadata.LoggerNameProvider; import org.springframework.ide.vscode.boot.metadata.ProjectBasedPropertyIndexProvider; import org.springframework.ide.vscode.boot.metadata.SpringPropertyIndex; import org.springframework.ide.vscode.boot.metadata.ValueProviderRegistry; +import org.springframework.ide.vscode.boot.modulith.ModulithService; import org.springframework.ide.vscode.boot.properties.completions.SpringPropertiesCompletionEngine; import org.springframework.ide.vscode.boot.xml.SpringXMLCompletionEngine; import org.springframework.ide.vscode.boot.yaml.completions.ApplicationYamlAssistContext; @@ -374,4 +375,9 @@ public class BootLanguageServerBootApp { return new JavaDefinitionHandler(cuCache, projectFinder, List.of(new PropertyValueAnnotationDefProvider())); } + @Bean + ModulithService modulithService(SimpleLanguageServer server, ProjectObserver projectObserver, SpringMetamodelIndex springIndex, JavaProjectFinder projectFinder) { + return new ModulithService(projectObserver, server.getWorkspaceService().getFileObserver(), projectFinder, springIndex); + } + } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/Annotations.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/Annotations.java index 336a49568..0e04ef7c3 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/Annotations.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/Annotations.java @@ -19,6 +19,7 @@ public class Annotations { public static final String BEAN = "org.springframework.context.annotation.Bean"; public static final String PROFILE = "org.springframework.context.annotation.Profile"; public static final String CONDITIONAL = "org.springframework.context.annotation.Conditional"; + public static final String BOOT_APP = "org.springframework.boot.autoconfigure.SpringBootApplication"; public static final String COMPONENT = "org.springframework.stereotype.Component"; public static final String CONFIGURATION = "org.springframework.context.annotation.Configuration"; 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 b84585bb0..e835b17b9 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 @@ -25,7 +25,9 @@ public enum Boot3JavaProblemType implements ProblemType { JAVA_TYPE_NOT_SUPPORTED(ERROR, "Type no supported as of Spring Boot 3", "Type not supported as of Spring Boot 3"), - FACTORIES_KEY_NOT_SUPPORTED(ERROR, "Spring factories key not supported", "Spring factories key not supported"); + 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"); 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/beans/BeansSymbolProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeansSymbolProvider.java index 3985a3893..ddd6aaa55 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeansSymbolProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/BeansSymbolProvider.java @@ -32,6 +32,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ide.vscode.boot.java.Annotations; +import org.springframework.ide.vscode.boot.java.annotations.AnnotationHierarchies; import org.springframework.ide.vscode.boot.java.handlers.AbstractSymbolProvider; import org.springframework.ide.vscode.boot.java.handlers.EnhancedSymbolInformation; import org.springframework.ide.vscode.boot.java.handlers.SymbolAddOnInformation; @@ -93,8 +94,12 @@ public class BeansSymbolProvider extends AbstractSymbolProvider { Set supertypes = new HashSet<>(); ASTUtils.findSupertypes(beanType, supertypes); + + String[] annotations = AnnotationHierarchies + .findTransitiveSuperAnnotationBindings(node.resolveAnnotationBinding()) + .map(t -> t.getAnnotationType().getQualifiedName()).toArray(String[]::new); - Bean beanDefinition = new Bean(nameAndRegion.getT1(), beanType.getQualifiedName(), location, injectionPoints, (String[]) supertypes.toArray(new String[supertypes.size()])); + Bean beanDefinition = new Bean(nameAndRegion.getT1(), beanType.getQualifiedName(), location, injectionPoints, (String[]) supertypes.toArray(new String[supertypes.size()]), annotations); context.getGeneratedSymbols().add(new CachedSymbol(context.getDocURI(), context.getLastModified(), enhancedSymbol, beanDefinition)); @@ -213,7 +218,7 @@ public class BeansSymbolProvider extends AbstractSymbolProvider { } return result.toString(); } - + private boolean isMethodAbstract(MethodDeclaration method) { List modifiers = method.modifiers(); for (Object modifier : modifiers) { diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/ComponentSymbolProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/ComponentSymbolProvider.java index 520235a6e..bade1be5c 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/ComponentSymbolProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/beans/ComponentSymbolProvider.java @@ -14,6 +14,7 @@ import java.util.Collection; import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.eclipse.jdt.core.dom.Annotation; import org.eclipse.jdt.core.dom.ITypeBinding; @@ -27,6 +28,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.Tuple.Two; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ide.vscode.boot.java.Annotations; +import org.springframework.ide.vscode.boot.java.annotations.AnnotationHierarchies; import org.springframework.ide.vscode.boot.java.handlers.AbstractSymbolProvider; import org.springframework.ide.vscode.boot.java.handlers.EnhancedSymbolInformation; import org.springframework.ide.vscode.boot.java.handlers.SymbolAddOnInformation; @@ -91,8 +93,10 @@ public class ComponentSymbolProvider extends AbstractSymbolProvider { Set supertypes = new HashSet<>(); ASTUtils.findSupertypes(beanType, supertypes); + + String[] annotations = Stream.concat(Stream.of(annotationType), metaAnnotations.stream()).map(t -> t.getQualifiedName()).toArray(String[]::new); - Bean beanDefinition = new Bean(beanName, beanType.getQualifiedName(), location, injectionPoints, (String[]) supertypes.toArray(new String[supertypes.size()])); + Bean beanDefinition = new Bean(beanName, beanType.getQualifiedName(), location, injectionPoints, (String[]) supertypes.toArray(new String[supertypes.size()]), annotations); return Tuple.two(new EnhancedSymbolInformation(symbol, addon), beanDefinition); } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositorySymbolProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositorySymbolProvider.java index 6337886f2..3afb985d8 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositorySymbolProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositorySymbolProvider.java @@ -72,7 +72,7 @@ public class DataRepositorySymbolProvider extends AbstractSymbolProvider { ASTUtils.findSupertypes(concreteBeanTypeBindung, supertypes); String concreteRepoType = concreteBeanTypeBindung.getQualifiedName(); - Bean beanDefinition = new Bean(beanName, concreteRepoType, location, injectionPoints, (String[]) supertypes.toArray(new String[supertypes.size()])); + Bean beanDefinition = new Bean(beanName, concreteRepoType, location, injectionPoints, (String[]) supertypes.toArray(new String[supertypes.size()]), new String[0]); context.getGeneratedSymbols().add(new CachedSymbol(context.getDocURI(), context.getLastModified(), enhancedSymbol, beanDefinition)); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/BootCodeActionRepository.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/BootCodeActionRepository.java index 0910fff51..f8ae96c3c 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/BootCodeActionRepository.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/BootCodeActionRepository.java @@ -20,6 +20,7 @@ import org.springframework.ide.vscode.boot.java.rewrite.reconcile.BeanPostProces import org.springframework.ide.vscode.boot.java.rewrite.reconcile.Boot3NotSupportedTypeProblem; import org.springframework.ide.vscode.boot.java.rewrite.reconcile.EntityIdForRepoProblem; import org.springframework.ide.vscode.boot.java.rewrite.reconcile.HttpSecurityLamdaDslCodeAction; +import org.springframework.ide.vscode.boot.java.rewrite.reconcile.ModulithTypeReferenceViolation; import org.springframework.ide.vscode.boot.java.rewrite.reconcile.NoAutowiredOnConstructorProblem; import org.springframework.ide.vscode.boot.java.rewrite.reconcile.NoRepoAnnotationProblem; import org.springframework.ide.vscode.boot.java.rewrite.reconcile.NoRequestMappingAnnotationCodeAction; @@ -51,7 +52,8 @@ public class BootCodeActionRepository extends CodeActionRepository { new AddConfigurationIfBeansPresentCodeAction(), new AuthorizeHttpRequestsCodeAction(), new WebSecurityConfigurerAdapterCodeAction(), - new EntityIdForRepoProblem() + new EntityIdForRepoProblem(), + new ModulithTypeReferenceViolation() ); } 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 new file mode 100644 index 000000000..f117607fb --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/rewrite/reconcile/ModulithTypeReferenceViolation.java @@ -0,0 +1,110 @@ +/******************************************************************************* + * 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.ide.vscode.boot.java.rewrite.reconcile; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.openrewrite.ExecutionContext; +import org.openrewrite.Tree; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaVisitor; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.J.CompilationUnit; +import org.openrewrite.java.tree.J.FieldAccess; +import org.openrewrite.java.tree.J.Identifier; +import org.openrewrite.java.tree.JavaType.FullyQualified; +import org.openrewrite.java.tree.TypeUtils; +import org.springframework.ide.vscode.boot.java.Boot3JavaProblemType; +import org.springframework.ide.vscode.boot.modulith.AppModules; +import org.springframework.ide.vscode.boot.modulith.ModulithService; +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.reconcile.ProblemType; +import org.springframework.ide.vscode.commons.rewrite.config.MarkerVisitorContext; +import org.springframework.ide.vscode.commons.rewrite.config.RecipeCodeActionDescriptor; +import org.springframework.ide.vscode.commons.rewrite.java.FixAssistMarker; + +public class ModulithTypeReferenceViolation implements RecipeCodeActionDescriptor { + + private static final String MSG_PKG_NAME = "packageName"; + + @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; + + return new JavaIsoVisitor() { + + @Override + public CompilationUnit visitCompilationUnit(CompilationUnit cu, ExecutionContext p) { + if (appModules == null) { + return cu; + } else { + String pkgName = cu.getPackageDeclaration() == null ? "" : cu.getPackageDeclaration().getPackageName(); + p.putMessage(MSG_PKG_NAME, pkgName); + return super.visitCompilationUnit(cu, p); + } + } + + + + @Override + public FieldAccess visitFieldAccess(FieldAccess fieldAccess, ExecutionContext p) { + FieldAccess fa = super.visitFieldAccess(fieldAccess, p); + return process(fa, p.getMessage(MSG_PKG_NAME), TypeUtils.asFullyQualified(fa.getType())); + } + + @Override + public Identifier visitIdentifier(Identifier identifier, ExecutionContext p) { + Identifier i = super.visitIdentifier(identifier, p); + if (!(getCursor().getParent().firstEnclosingOrThrow(J.class) instanceof J.FieldAccess)) { + return process(i, p.getMessage(MSG_PKG_NAME), TypeUtils.asFullyQualified(identifier.getType())); + } + return i; + } + + private T process(T node, String packageName, FullyQualified type) { + if (type != null) { + if (!appModules.isReferenceAllowed(packageName, type.getFullyQualifiedName())) { + FixAssistMarker fixMarker = new FixAssistMarker(Tree.randomId(), getId()) + .withLabel("Type is not allowed to be used in this package. Consider changing you 'Modulith' structure." ); + node = node.withMarkers(node.getMarkers().add(fixMarker)); + } + } + return node; + } + + }; + } + + @Override + public boolean isApplicable(IJavaProject project) { + Version v = SpringProjectUtil.getDependencyVersion(project, "spring-modulith-core"); + return v != null; + } + + @Override + public ProblemType getProblemType() { + return Boot3JavaProblemType.MODULITH_TYPE_REF_VIOLATION; + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/utils/SymbolCacheOnDisc.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/utils/SymbolCacheOnDisc.java index 299909366..5768f02a4 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/utils/SymbolCacheOnDisc.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/utils/SymbolCacheOnDisc.java @@ -381,8 +381,11 @@ public class SymbolCacheOnDisc implements SymbolCache { JsonElement supertypesObject = parsedObject.get("supertypes"); String[] supertypes = context.deserialize(supertypesObject, String[].class); + + JsonElement annotationsObject = parsedObject.get("annotations"); + String[] annotations = annotationsObject == null? new String[0] : context.deserialize(annotationsObject, String[].class); - return new Bean(beanName, beanType, location, injectionPoints, supertypes); + return new Bean(beanName, beanType, location, injectionPoints, supertypes, annotations); } } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/AppModule.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/AppModule.java new file mode 100644 index 000000000..93319b898 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/AppModule.java @@ -0,0 +1,21 @@ +/******************************************************************************* + * 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.ide.vscode.boot.modulith; + +import java.util.Collection; + +record AppModule( + String name, + String basePackage, + Collection namedInterfaces +) { + +} 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 new file mode 100644 index 000000000..de19b76c6 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/AppModules.java @@ -0,0 +1,33 @@ +/******************************************************************************* + * 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.ide.vscode.boot.modulith; + +import java.util.List; + +public final class AppModules { + + private List modules; + + public AppModules(List modules) { + this.modules = modules; + } + + public boolean isReferenceAllowed(String targetPackage, String referenceFqName) { + String referencePackage = ModulithService.getPackageNameFromTypeFQName(referenceFqName); + return modules + .stream() + .filter(m -> m.basePackage().equals(referencePackage)) + .findFirst() + .map(m -> m.namedInterfaces().contains(referenceFqName)) + .orElse(true); + } + +} 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 new file mode 100644 index 000000000..2e3713f25 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/modulith/ModulithService.java @@ -0,0 +1,212 @@ +/******************************************************************************* + * 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.ide.vscode.boot.modulith; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +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.java.Annotations; +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.protocol.java.Classpath; +import org.springframework.ide.vscode.commons.protocol.spring.Bean; +import org.springframework.ide.vscode.commons.util.FileObserver; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +public class ModulithService { + + private static final Logger log = LoggerFactory.getLogger(ModulithService.class); + + private static final List FILE_PATTERNS = List.of("**/*.java"); + + private Map> cache; + + private SpringMetamodelIndex springIndex; + + private JavaProjectFinder projectFinder; + + public ModulithService(ProjectObserver projectObserver, FileObserver fileObserver, JavaProjectFinder projectFinder, SpringMetamodelIndex springIndex) { + this.projectFinder = projectFinder; + this.springIndex = springIndex; + cache = new ConcurrentHashMap<>(); + + projectObserver.addListener(new ProjectObserver.Listener() { + + @Override + public void deleted(IJavaProject project) { + invalidate(project); + } + + @Override + public void created(IJavaProject project) { + invalidate(project); + } + + @Override + public void changed(IJavaProject project) { + invalidate(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(this::invalidate); + } + } + + private void invalidate(IJavaProject project) { + CompletableFuture future = cache.remove(project); + if (future != null) { + future.cancel(true); + } + } + + 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(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); + } + } + } + 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 javaCmd, String cp, String pkg) { + try { + Process process = Runtime.getRuntime().exec(new String[] { + javaCmd, + "-cp", + cp, + "org.springframework.modulith.core.util.ApplicationModulesExporter", + pkg + }); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + StringBuilder builder = new StringBuilder(); + String line = null; + // skip first line + boolean skipFirstLine = true; + while ((line = reader.readLine()) != null) { + if (skipFirstLine) { + skipFirstLine = false; + } else { + builder.append(line); + builder.append(System.getProperty("line.separator")); + } + } + + return process.onExit().thenApply(p -> { + String result = builder.toString(); + log.info(result); + return Optional.ofNullable(loadAppModules(JsonParser.parseString(result).getAsJsonObject())); + }); + } catch (Exception e) { + log.error("", e); + } + return null; + } + + private static List loadAppModules(JsonObject json) { + return json.keySet() + .stream() + .map(name -> loadAppModule(name, json.get(name).getAsJsonObject())) + .collect(Collectors.toList()); + } + + private static AppModule loadAppModule(String name, JsonObject json) { + String basePackage = json.get("basePackage").getAsString(); + JsonObject nameInterfacesJson = json.get("namedInterfaces").getAsJsonObject(); + List namedInterfaces = nameInterfacesJson.keySet() + .stream() + .flatMap(k -> nameInterfacesJson.get(k).getAsJsonArray().asList().stream().map(js -> js.getAsString())) + .collect(Collectors.toList()); + return new AppModule(name, basePackage, namedInterfaces); + } +} 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 8ca1e8f54..a3ccaf8f5 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 @@ -108,6 +108,12 @@ "label": "Spring factories key not supported", "description": "Spring factories key not supported", "defaultSeverity": "ERROR" + }, + { + "code": "MODULITH_TYPE_REF_VIOLATION", + "label": "Import from restricted module", + "description": "Restricted dependency", + "defaultSeverity": "ERROR" } ] }, diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/metamodel/test/SpringMetamodelIndexTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/metamodel/test/SpringMetamodelIndexTest.java index 0df5046a5..411a3e623 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/metamodel/test/SpringMetamodelIndexTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/metamodel/test/SpringMetamodelIndexTest.java @@ -36,6 +36,7 @@ public class SpringMetamodelIndexTest { private InjectionPoint[] emptyInjectionPoints = new InjectionPoint[0]; private String[] emptySupertypes = new String[0]; + private String[] emptyAnnotations = new String[0]; private Location locationForDoc1 = new Location("docURI1", new Range(new Position(1, 1), new Position(1, 10))); private Location locationForDoc2 = new Location("docURI2", new Range(new Position(2, 1), new Position(2, 10))); @@ -50,9 +51,9 @@ public class SpringMetamodelIndexTest { @Test void testSimpleProjectWithBeansPerProject() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", new Bean[] {bean1, bean2, bean3}); @@ -65,7 +66,7 @@ public class SpringMetamodelIndexTest { assertTrue(beansList.contains(bean2)); assertTrue(beansList.contains(bean3)); - Bean anotherBean = new Bean("anotherBean", "beanType", null, emptyInjectionPoints, emptySupertypes); + Bean anotherBean = new Bean("anotherBean", "beanType", null, emptyInjectionPoints, emptySupertypes, emptyAnnotations); assertFalse(beansList.contains(anotherBean)); } @@ -73,9 +74,9 @@ public class SpringMetamodelIndexTest { @Test void testSimpleProjectWithBeansPerDocument() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean2 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean3 = new Bean("beanWithDifferentName", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean2 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean3 = new Bean("beanWithDifferentName", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", new Bean[] {bean1, bean2, bean3}); @@ -100,9 +101,9 @@ public class SpringMetamodelIndexTest { @Test void testSimpleProjectWithBeansPerName() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean2 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean3 = new Bean("beanWithDifferentName", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean2 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean3 = new Bean("beanWithDifferentName", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", new Bean[] {bean1, bean2, bean3}); @@ -121,15 +122,15 @@ public class SpringMetamodelIndexTest { @Test void testUpdateBeansForSpecificDoc() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", locationForDoc1.getUri(), new Bean[] {bean1, bean2}); index.updateBeans("someProject", locationForDoc2.getUri(), new Bean[] {bean3}); - Bean updatedBean1 = new Bean("updated1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean updatedBean2 = new Bean("updated2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); + Bean updatedBean1 = new Bean("updated1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean updatedBean2 = new Bean("updated2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", locationForDoc1.getUri(), new Bean[] {updatedBean1, updatedBean2}); @@ -145,19 +146,19 @@ public class SpringMetamodelIndexTest { assertFalse(beansList.contains(bean1)); assertFalse(beansList.contains(bean2)); - Bean anotherBean = new Bean("anotherBean", "beanType", null, emptyInjectionPoints, emptySupertypes); + Bean anotherBean = new Bean("anotherBean", "beanType", null, emptyInjectionPoints, emptySupertypes, emptyAnnotations); assertFalse(beansList.contains(anotherBean)); } @Test void testUpdateAllBeansForSpecificProject() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", new Bean[] {bean1, bean2}); - Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes); + Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", new Bean[] {bean3}); @@ -174,9 +175,9 @@ public class SpringMetamodelIndexTest { @Test void testRemoveAllBeansForSpecificProject() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject1", new Bean[] {bean1, bean2}); index.updateBeans("someProject2", new Bean[] {bean3}); @@ -198,9 +199,9 @@ public class SpringMetamodelIndexTest { @Test void testRemoveAllBeansForSpecificDocument() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean3 = new Bean("beanName3", "beanType", locationForDoc2, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", new Bean[] {bean1, bean2, bean3}); index.removeBeans("someProject", locationForDoc1.getUri()); @@ -220,7 +221,7 @@ public class SpringMetamodelIndexTest { InjectionPoint point1 = new InjectionPoint("point1", "point1-type", locationForDoc2); InjectionPoint point2 = new InjectionPoint("point2", "point2-type", locationForDoc1); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, new InjectionPoint[] {point1, point2}, new String[] {"supertype1", "supertype2"}); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, new InjectionPoint[] {point1, point2}, new String[] {"supertype1", "supertype2"}, emptyAnnotations); String serialized = bean1.toString(); Gson gson = SymbolCacheOnDisc.createGson(); @@ -248,7 +249,7 @@ public class SpringMetamodelIndexTest { @Test void testEmptyInjectionPointsOptimizationWithSerializeDeserializeBeans() { - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); String serialized = bean1.toString(); Gson gson = SymbolCacheOnDisc.createGson(); @@ -263,15 +264,15 @@ public class SpringMetamodelIndexTest { @Test void testEmptyInjectionPointsOptimization() { - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); assertSame(DefaultValues.EMPTY_INJECTION_POINTS, bean1.getInjectionPoints()); } @Test void testFindNoMatchingBeansWithEmptySupertypes() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); - Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes); + Bean bean1 = new Bean("beanName1", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); + Bean bean2 = new Bean("beanName2", "beanType", locationForDoc1, emptyInjectionPoints, emptySupertypes, emptyAnnotations); index.updateBeans("someProject", new Bean[] {bean1, bean2}); @@ -285,8 +286,8 @@ public class SpringMetamodelIndexTest { @Test void testFindMatchingBeansWithOneProject() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType1", locationForDoc1, emptyInjectionPoints, new String[] {"supertype1", "supertype2"}); - Bean bean2 = new Bean("beanName2", "beanType2", locationForDoc1, emptyInjectionPoints, new String[] {"supertype3", "supertype4", "supertype5"}); + Bean bean1 = new Bean("beanName1", "beanType1", locationForDoc1, emptyInjectionPoints, new String[] {"supertype1", "supertype2"}, emptyAnnotations); + Bean bean2 = new Bean("beanName2", "beanType2", locationForDoc1, emptyInjectionPoints, new String[] {"supertype3", "supertype4", "supertype5"}, emptyAnnotations); index.updateBeans("someProject", new Bean[] {bean1, bean2}); @@ -312,11 +313,11 @@ public class SpringMetamodelIndexTest { @Test void testFindMatchingBeansWithMultipleProjects() { SpringMetamodelIndex index = new SpringMetamodelIndex(); - Bean bean1 = new Bean("beanName1", "beanType1", locationForDoc1, emptyInjectionPoints, new String[] {"supertype1", "supertype2"}); - Bean bean2 = new Bean("beanName2", "beanType2", locationForDoc1, emptyInjectionPoints, new String[] {"supertype3", "supertype4, supertype5"}); + Bean bean1 = new Bean("beanName1", "beanType1", locationForDoc1, emptyInjectionPoints, new String[] {"supertype1", "supertype2"}, emptyAnnotations); + Bean bean2 = new Bean("beanName2", "beanType2", locationForDoc1, emptyInjectionPoints, new String[] {"supertype3", "supertype4, supertype5"}, emptyAnnotations); - Bean bean3 = new Bean("beanName3", "beanType1", locationForDoc1, emptyInjectionPoints, new String[] {"supertype1", "supertype2"}); - Bean bean4 = new Bean("beanName4", "beanType2", locationForDoc1, emptyInjectionPoints, new String[] {"supertype3", "supertype4, supertype5"}); + Bean bean3 = new Bean("beanName3", "beanType1", locationForDoc1, emptyInjectionPoints, new String[] {"supertype1", "supertype2"}, emptyAnnotations); + Bean bean4 = new Bean("beanName4", "beanType2", locationForDoc1, emptyInjectionPoints, new String[] {"supertype3", "supertype4, supertype5"}, emptyAnnotations); index.updateBeans("projectA", new Bean[] {bean1, bean2}); index.updateBeans("projectB", new Bean[] {bean3, bean4}); diff --git a/vscode-extensions/vscode-spring-boot/package.json b/vscode-extensions/vscode-spring-boot/package.json index 9e9840f7a..7a0148ba2 100644 --- a/vscode-extensions/vscode-spring-boot/package.json +++ b/vscode-extensions/vscode-spring-boot/package.json @@ -494,6 +494,18 @@ "HINT", "ERROR" ] + }, + "spring-boot.ls.problem.boot3.MODULITH_TYPE_REF_VIOLATION": { + "type": "string", + "default": "ERROR", + "description": "Restricted dependency", + "enum": [ + "IGNORE", + "INFO", + "WARNING", + "HINT", + "ERROR" + ] } } }, @@ -1021,4 +1033,4 @@ "extensionDependencies": [ "redhat.java" ] -} +} \ No newline at end of file