Modulith support
This commit is contained in:
@@ -25,8 +25,9 @@ public class Bean {
|
||||
private final Location location;
|
||||
private final InjectionPoint[] injectionPoints;
|
||||
private final Set<String> 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();
|
||||
|
||||
@@ -61,6 +61,11 @@
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-core</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ide.vscode</groupId>
|
||||
<artifactId>commons-yaml</artifactId>
|
||||
@@ -192,6 +197,11 @@
|
||||
<version>${dependencies.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ide.vscode</groupId>
|
||||
<artifactId>bosh-language-server</artifactId>
|
||||
<version>1.48.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<profiles>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> 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) {
|
||||
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ExecutionContext> getMarkerVisitor(MarkerVisitorContext context) {
|
||||
ModulithService modulithService = context.appContext().getBean(ModulithService.class);
|
||||
CompletableFuture<AppModules> 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<ExecutionContext>() {
|
||||
|
||||
@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 extends J> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> namedInterfaces
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -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<AppModule> modules;
|
||||
|
||||
public AppModules(List<AppModule> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String> FILE_PATTERNS = List.of("**/*.java");
|
||||
|
||||
private Map<IJavaProject, CompletableFuture<AppModules>> 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<AppModules> future = cache.remove(project);
|
||||
if (future != null) {
|
||||
future.cancel(true);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<AppModules> loadModulesMetadata(IJavaProject project) {
|
||||
Version v = SpringProjectUtil.getDependencyVersion(project, "spring-modulith-core");
|
||||
if (v != null) {
|
||||
Set<String> 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<AppModule> 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<AppModules> getModulesData(IJavaProject project) {
|
||||
synchronized(project) {
|
||||
CompletableFuture<AppModules> modules = cache.get(project);
|
||||
if (modules == null) {
|
||||
modules = loadModulesMetadata(project);
|
||||
cache.put(project, modules);
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> findRootPackages(IJavaProject project) {
|
||||
HashSet<String> 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<Optional<List<AppModule>>> 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<AppModule> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user