diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiAssignmentDiagnostics.java b/java-frontend/src/main/java/org/sonar/java/model/PsiAssignmentDiagnostics.java new file mode 100644 index 00000000000..44b59a271b4 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiAssignmentDiagnostics.java @@ -0,0 +1,59 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.psi.JavaTokenType; +import com.intellij.psi.PsiAssignmentExpression; +import com.intellij.psi.PsiField; +import com.intellij.psi.PsiModifier; +import com.intellij.psi.PsiReferenceExpression; +import com.intellij.psi.PsiSuperExpression; +import com.intellij.psi.PsiThisExpression; +import com.intellij.psi.PsiVariable; +import com.intellij.psi.util.PsiUtil; + +final class PsiAssignmentDiagnostics { + private PsiAssignmentDiagnostics() { + } + + static boolean hasNoEffect(PsiAssignmentExpression expression) { + if (expression.getOperationTokenType() != JavaTokenType.EQ) { + return false; + } + var left = PsiUtil.skipParenthesizedExprDown(expression.getLExpression()); + var right = PsiUtil.skipParenthesizedExprDown(expression.getRExpression()); + if (right instanceof PsiAssignmentExpression assignment) { + right = assignment.getLExpression(); + } + if (!(left instanceof PsiReferenceExpression lhs) || !(right instanceof PsiReferenceExpression rhs) + || !(lhs.resolve() instanceof PsiVariable variable) || variable.hasModifierProperty(PsiModifier.VOLATILE) || !variable.equals(rhs.resolve())) { + return false; + } + if (variable instanceof PsiField) { + return currentInstance(lhs, variable) && currentInstance(rhs, variable); + } + return true; + } + + private static boolean currentInstance(PsiReferenceExpression reference, PsiVariable variable) { + var qualifier = PsiUtil.skipParenthesizedExprDown(reference.getQualifierExpression()); + return qualifier == null || qualifier instanceof PsiThisExpression current && current.getQualifier() == null + || qualifier instanceof PsiSuperExpression current && current.getQualifier() == null + || variable.hasModifierProperty(PsiModifier.STATIC) && qualifier instanceof PsiReferenceExpression name + && name.resolve() instanceof com.intellij.psi.PsiClass; + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiCastDiagnostics.java b/java-frontend/src/main/java/org/sonar/java/model/PsiCastDiagnostics.java new file mode 100644 index 00000000000..0bbfadf7cb8 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiCastDiagnostics.java @@ -0,0 +1,153 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.PsiArrayType; +import com.intellij.psi.PsiAssignmentExpression; +import com.intellij.psi.PsiCallExpression; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiExpression; +import com.intellij.psi.PsiExpressionList; +import com.intellij.psi.PsiFunctionalExpression; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiMethodCallExpression; +import com.intellij.psi.PsiParenthesizedExpression; +import com.intellij.psi.PsiPrimitiveType; +import com.intellij.psi.PsiReturnStatement; +import com.intellij.psi.PsiTypeCastExpression; +import com.intellij.psi.PsiVariable; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.psi.util.PsiUtil; +import com.intellij.psi.util.TypeConversionUtil; + +final class PsiCastDiagnostics { + private PsiCastDiagnostics() { + } + + static boolean isRedundant(PsiTypeCastExpression cast) { + var operand = PsiUtil.skipParenthesizedExprDown(cast.getOperand()); + if (operand == null || operand instanceof PsiFunctionalExpression || cast.getType() == null || operand.getType() == null) { + return false; + } + var from = operand.getType(); + var to = cast.getCastType().getType(); + if (!resolved(from) || !resolved(to)) { + return false; + } + var anchor = anchor(cast); + var parent = anchor.getParent(); + if (parent instanceof com.intellij.psi.PsiReferenceExpression reference && reference.getParent() instanceof PsiMethodCallExpression call + && call.getArgumentList().isEmpty() && "getClass".equals(reference.getReferenceName())) { + return true; + } + boolean assignable = TypeConversionUtil.isAssignable(to, from); + if (parent instanceof PsiExpressionList arguments && arguments.getParent() instanceof PsiCallExpression call) { + return assignable && sameCall(call, cast); + } + if (operand instanceof PsiMethodCallExpression call) { + var method = call.resolveMethod(); + if (method != null && method.hasTypeParameters() && call.getTypeArguments().length == 0 + && method.getReturnType() instanceof PsiClassType result && result.resolve() instanceof com.intellij.psi.PsiTypeParameter parameter + && java.util.Arrays.stream(method.getParameterList().getParameters()).anyMatch(argument -> + PsiDiagnosticTypes.mentions(argument.getType(), parameter))) { + return false; + } + } + if (to.equals(from)) { + return true; + } + if (parent instanceof com.intellij.psi.PsiArrayAccessExpression && assignable) { + return true; + } + if (parent instanceof com.intellij.psi.PsiPolyadicExpression binary && from instanceof PsiPrimitiveType && to instanceof PsiPrimitiveType) { + var operands = binary.getOperands(); + return assignable && java.util.Arrays.stream(operands).filter(expression -> expression != anchor) + .filter(expression -> !(PsiUtil.skipParenthesizedExprDown(expression) instanceof PsiTypeCastExpression)) + .anyMatch(expression -> to.equals(expression.getType())) && to.equals(binary.getType()); + } + com.intellij.psi.PsiType expected = null; + if (parent instanceof PsiVariable variable) { + expected = variable.getType(); + } else if (parent instanceof PsiAssignmentExpression assignment && assignment.getRExpression() == anchor) { + expected = assignment.getLExpression().getType(); + } else if (parent instanceof PsiReturnStatement) { + var method = PsiTreeUtil.getParentOfType(parent, PsiMethod.class); + expected = method == null ? null : method.getReturnType(); + } else if (parent instanceof PsiTypeCastExpression outer) { + expected = outer.getType(); + } + if (expected == null || !TypeConversionUtil.isAssignable(expected, from)) { + return false; + } + if (assignable) { + return true; + } + if (operand instanceof PsiMethodCallExpression call) { + var method = call.resolveMethod(); + return method != null && method.getReturnType() instanceof PsiClassType result && result.resolve() instanceof com.intellij.psi.PsiTypeParameter; + } + return false; + } + + private static boolean resolved(com.intellij.psi.PsiType type) { + if (type instanceof PsiClassType cls) { + return cls.resolve() != null && java.util.Arrays.stream(cls.getParameters()).allMatch(PsiCastDiagnostics::resolved); + } + if (type instanceof PsiArrayType array) { + return resolved(array.getComponentType()); + } + if (type instanceof com.intellij.psi.PsiWildcardType wildcard) { + return !wildcard.isBounded() || resolved(wildcard.getBound()); + } + return true; + } + + static PsiExpression anchor(PsiTypeCastExpression cast) { + PsiExpression anchor = cast; + while (anchor.getParent() instanceof PsiParenthesizedExpression parentheses) { + anchor = parentheses; + } + return anchor; + } + + private static boolean sameCall(PsiCallExpression call, PsiTypeCastExpression cast) { + var original = call.resolveMethodGenerics(); + if (!original.isValidResult() || original.getElement() == null) { + return false; + } + int start = cast.getTextRange().getStartOffset() - call.getTextRange().getStartOffset(); + int end = cast.getTextRange().getEndOffset() - call.getTextRange().getStartOffset(); + String text = call.getText(); + var replacement = JavaPsiFacade.getElementFactory(call.getProject()).createExpressionFromText( + text.substring(0, start) + cast.getOperand().getText() + text.substring(end), call); + if (!(replacement instanceof PsiCallExpression alternative)) { + return false; + } + var result = alternative.resolveMethodGenerics(); + if (!result.isValidResult() || !original.getElement().equals(result.getElement())) { + return false; + } + var method = (PsiMethod) original.getElement(); + if (method.isVarArgs() && cast.getOperand().getType() instanceof PsiArrayType && !cast.getCastType().getType().equals(cast.getOperand().getType())) { + return false; + } + return java.util.Arrays.stream(method.getTypeParameters()).allMatch(parameter -> java.util.Objects.equals( + original.getSubstitutor().substitute(parameter), result.getSubstitutor().substitute(parameter))); + } + +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiCatchDiagnostics.java b/java-frontend/src/main/java/org/sonar/java/model/PsiCatchDiagnostics.java new file mode 100644 index 00000000000..1f0c187e584 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiCatchDiagnostics.java @@ -0,0 +1,85 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.codeInsight.ExceptionUtil; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiDisjunctionType; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiJavaCodeReferenceElement; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiMethodCallExpression; +import com.intellij.psi.PsiNewExpression; +import com.intellij.psi.PsiTryStatement; +import com.intellij.psi.PsiTypeElement; +import com.intellij.psi.util.InheritanceUtil; +import com.intellij.psi.util.PsiTreeUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; + +final class PsiCatchDiagnostics { + private PsiCatchDiagnostics() { + } + + static void collect(PsiTryStatement statement, BiConsumer warning) { + var block = statement.getTryBlock(); + if (block == null || incompleteExecutable(statement)) { + return; + } + var thrown = new ArrayList(ExceptionUtil.getThrownExceptions(block)); + if (statement.getResourceList() != null) { + thrown.addAll(ExceptionUtil.getThrownExceptions(statement.getResourceList())); + } + var previous = new ArrayList(); + for (var section : statement.getCatchSections()) { + var parameter = section.getParameter(); + if (parameter == null || parameter.getTypeElement() == null) { + continue; + } + var type = parameter.getType(); + List alternatives = type instanceof PsiDisjunctionType union ? union.getDisjunctions() : List.of(type); + for (var alternative : alternatives) { + if (!(alternative instanceof PsiClassType cls) || cls.resolve() == null + || InheritanceUtil.isInheritor(cls, "java.lang.RuntimeException") || InheritanceUtil.isInheritor(cls, "java.lang.Error") + || cls.equalsToText("java.lang.Exception") || cls.equalsToText("java.lang.Throwable")) { + continue; + } + var relevant = thrown.stream().filter(exception -> alternative.isAssignableFrom(exception) || exception.isAssignableFrom(alternative)).toList(); + if (!relevant.isEmpty() && relevant.stream().allMatch(exception -> previous.stream().anyMatch(caught -> caught.isAssignableFrom(exception))) + && previous.stream().noneMatch(caught -> caught.isAssignableFrom(alternative))) { + var element = parameter.getTypeElement(); + if (type instanceof PsiDisjunctionType) { + element = PsiTreeUtil.getChildrenOfTypeAsList(element, PsiTypeElement.class).stream() + .filter(candidate -> candidate.getType().equals(alternative)).findFirst().orElse(element); + } + warning.accept(element, "Unreachable catch block for " + PsiDiagnosticTypes.name(alternative) + + ". Only more specific exceptions are thrown and they are handled by previous catch block(s)."); + } + } + previous.addAll(alternatives); + } + } + + private static boolean incompleteExecutable(PsiTryStatement statement) { + var method = PsiTreeUtil.getParentOfType(statement, PsiMethod.class); + var scope = method == null || method.getBody() == null ? statement : method.getBody(); + return PsiTreeUtil.findChildrenOfType(scope, PsiMethodCallExpression.class).stream().anyMatch(call -> call.resolveMethod() == null) + || PsiTreeUtil.findChildrenOfType(scope, PsiJavaCodeReferenceElement.class).stream() + .anyMatch(reference -> (reference.getParent() instanceof PsiTypeElement || reference.getParent() instanceof PsiNewExpression) && reference.resolve() == null); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiDiagnosticProvider.java b/java-frontend/src/main/java/org/sonar/java/model/PsiDiagnosticProvider.java new file mode 100644 index 00000000000..202ec8a74a3 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiDiagnosticProvider.java @@ -0,0 +1,181 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.psi.JavaRecursiveElementWalkingVisitor; +import com.intellij.psi.PsiAnnotation; +import com.intellij.psi.PsiAssignmentExpression; +import com.intellij.psi.PsiComment; +import com.intellij.psi.PsiJavaCodeReferenceElement; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiLambdaExpression; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiMethodCallExpression; +import com.intellij.psi.PsiMethodReferenceExpression; +import com.intellij.psi.PsiNewExpression; +import com.intellij.psi.PsiReferenceExpression; +import com.intellij.psi.PsiReferenceList; +import com.intellij.psi.PsiTryStatement; +import com.intellij.psi.PsiTypeCastExpression; +import com.intellij.psi.PsiTypeElement; +import com.intellij.psi.PsiWhiteSpace; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.psi.util.PsiUtil; +import com.sonar.sslr.api.RecognitionException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.sonar.java.psi.JavaPsiConfiguration; +import org.sonar.java.psi.PsiLanguageValidator; + +final class PsiDiagnosticProvider { + private final PsiJavaFile file; + private final LineColumnConverter positions; + private final int[] offsets; + private final List warnings = new ArrayList<>(); + private final Set problems = new LinkedHashSet<>(); + + private PsiDiagnosticProvider(PsiJavaFile file, String source) { + this.file = file; + positions = new LineColumnConverter(source); + offsets = PsiTokenIndex.unicodeInput(source).offsets(); + } + + static PsiDiagnosticProvider create(PsiJavaFile file, JavaPsiConfiguration configuration, String source) { + var provider = new PsiDiagnosticProvider(file, source); + provider.language(configuration); + provider.collect(); + return provider; + } + + void attach(JavaTree.CompilationUnitTreeImpl tree) { + tree.undefinedTypes.addAll(problems); + warnings.sort(Comparator.comparing(JWarning::start).thenComparing(JWarning::end)); + JWarning.Mapper.warningsFor(warnings).mappedInto(tree); + } + + private void language(JavaPsiConfiguration configuration) { + for (var method : PsiTreeUtil.findChildrenOfType(file, PsiMethod.class)) { + if (method.isConstructor() && method.getContainingClass() != null && method.getContainingClass().isInterface()) { + var following = method.getNextSibling(); + while (following instanceof PsiWhiteSpace || following != null && following.getText().equals(";")) { + following = following.getNextSibling(); + } + if (following instanceof PsiComment && following.getText().startsWith("//") + || PsiTreeUtil.findChildrenOfType(method, PsiComment.class).stream() + .anyMatch(comment -> comment.getText().startsWith("//") && comment.getTextRange().getStartOffset() > method.getParameterList().getTextRange().getEndOffset())) { + throw new IndexOutOfBoundsException("Missing method return type"); + } + problems.add(new JProblem("Return type for the method is missing", JProblem.Type.PREVIEW_FEATURE_USED)); + } + } + var result = PsiLanguageValidator.validate(file, configuration); + var fatal = java.util.stream.Stream.of(result.diagnostics(), result.rawSyntaxErrors(), PsiParseDiagnostics.additional(file, configuration)) + .flatMap(List::stream) + .filter(diagnostic -> diagnostic.kind() == PsiLanguageValidator.Kind.FATAL) + .min(Comparator.comparingInt(PsiLanguageValidator.Diagnostic::start)); + if (fatal.isPresent()) { + var diagnostic = fatal.get(); + var pos = positions.toPos(offset(diagnostic.start())); + throw new RecognitionException(pos.line(), "Parse error at line " + pos.line() + " column " + (pos.columnOffset() + 1) + ": " + diagnostic.message()); + } + for (var diagnostic : result.diagnostics()) { + if (diagnostic.kind() != PsiLanguageValidator.Kind.FATAL) { + problems.add(new JProblem(diagnostic.message(), diagnostic.kind() == PsiLanguageValidator.Kind.UNDEFINED_TYPE + ? JProblem.Type.UNDEFINED_TYPE : JProblem.Type.PREVIEW_FEATURE_USED)); + } + } + } + + private void collect() { + var imports = new PsiImportDiagnostics(file); + file.accept(new JavaRecursiveElementWalkingVisitor() { + @Override + public void visitReferenceElement(PsiJavaCodeReferenceElement reference) { + imports.reference(reference); + if ((reference.getParent() instanceof PsiTypeElement || reference.getParent() instanceof PsiReferenceList + || reference.getParent() instanceof PsiAnnotation || reference.getParent() instanceof PsiNewExpression) + && reference.resolve() == null && !Set.of("var", "record").contains(reference.getText())) { + var missing = reference; + while (missing.getQualifier() instanceof PsiJavaCodeReferenceElement qualifier && qualifier.resolve() == null) { + missing = qualifier; + } + problems.add(new JProblem(missing.getQualifiedName() + " cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE)); + } + super.visitReferenceElement(reference); + } + + @Override + public void visitReferenceExpression(PsiReferenceExpression reference) { + imports.reference(reference); + if (reference.getQualifierExpression() == null && reference.resolve() == null + && reference.getParent() instanceof PsiReferenceExpression parent && parent.getQualifierExpression() == reference) { + var lambda = PsiTreeUtil.getParentOfType(reference, PsiLambdaExpression.class); + var invocation = lambda == null ? null : PsiTreeUtil.getParentOfType(lambda, PsiMethodCallExpression.class); + if (invocation == null || invocation.resolveMethod() != null) { + var receiver = parent; + while (receiver.getParent() instanceof PsiReferenceExpression outer && outer.getQualifierExpression() == receiver) { + receiver = outer; + } + boolean methodQualifier = receiver.getParent() instanceof PsiMethodCallExpression call && call.getMethodExpression() == receiver + || receiver instanceof PsiMethodReferenceExpression || receiver.getParent() instanceof PsiMethodReferenceExpression; + problems.add(new JProblem(reference.getReferenceName() + " cannot be resolved" + (methodQualifier ? "" : " to a variable"), JProblem.Type.PREVIEW_FEATURE_USED)); + } + } + super.visitReferenceExpression(reference); + } + + @Override + public void visitTypeCastExpression(PsiTypeCastExpression cast) { + if (PsiCastDiagnostics.isRedundant(cast)) { + var anchor = PsiCastDiagnostics.anchor(cast); + warning(JProblem.Type.REDUNDANT_CAST, anchor.getTextRange().getStartOffset(), + "Unnecessary cast from " + PsiDiagnosticTypes.name(cast.getOperand().getType()) + " to " + PsiDiagnosticTypes.name(cast.getCastType().getType())); + } + super.visitTypeCastExpression(cast); + } + + @Override + public void visitAssignmentExpression(PsiAssignmentExpression assignment) { + if (PsiAssignmentDiagnostics.hasNoEffect(assignment)) { + warning(JProblem.Type.ASSIGNMENT_HAS_NO_EFFECT, assignment.getLExpression().getTextRange().getStartOffset(), + "The assignment to variable " + ((PsiReferenceExpression) PsiUtil.skipParenthesizedExprDown(assignment.getLExpression())).getReferenceName() + " has no effect"); + } + super.visitAssignmentExpression(assignment); + } + + @Override + public void visitTryStatement(PsiTryStatement statement) { + PsiCatchDiagnostics.collect(statement, (element, message) -> warning(JProblem.Type.MASKED_CATCH, element.getTextRange().getStartOffset(), message)); + super.visitTryStatement(statement); + } + }); + imports.collect((element, message) -> warning(JProblem.Type.UNUSED_IMPORT, element.getTextRange().getStartOffset(), message), problems); + PsiResolutionDiagnostics.collect(file, problems); + } + + private void warning(JProblem.Type type, int start, String message) { + var pos = positions.toPos(offset(start)); + warnings.add(new JWarning(message, type, pos.line(), pos.columnOffset(), pos.line(), pos.columnOffset())); + } + + private int offset(int logicalOffset) { + return offsets[Math.max(0, Math.min(logicalOffset, file.getTextLength()))]; + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiDiagnosticTypes.java b/java-frontend/src/main/java/org/sonar/java/model/PsiDiagnosticTypes.java new file mode 100644 index 00000000000..0bc298fb111 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiDiagnosticTypes.java @@ -0,0 +1,62 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.psi.PsiArrayType; +import com.intellij.psi.PsiClassType; + +final class PsiDiagnosticTypes { + private PsiDiagnosticTypes() { + } + + static String name(com.intellij.psi.PsiType type) { + if (type instanceof PsiArrayType array) { + return name(array.getComponentType()) + "[]"; + } + if (type instanceof PsiClassType cls && cls.resolve() != null) { + var declaration = cls.resolve(); + String name = declaration.getName(); + for (var owner = declaration instanceof com.intellij.psi.PsiTypeParameter ? null : declaration.getContainingClass(); owner != null; owner = owner.getContainingClass()) { + name = owner.getName() + "." + name; + } + if (cls.getParameters().length != 0) { + name += java.util.Arrays.stream(cls.getParameters()).map(PsiDiagnosticTypes::name).collect(java.util.stream.Collectors.joining(",", "<", ">")); + } + return name; + } + if (type instanceof com.intellij.psi.PsiWildcardType wildcard) { + return wildcard.isBounded() ? "? " + (wildcard.isExtends() ? "extends " : "super ") + name(wildcard.getBound()) : "?"; + } + return type.getPresentableText(); + } + + static boolean mentions(com.intellij.psi.PsiType type, com.intellij.psi.PsiTypeParameter parameter) { + if (type instanceof PsiClassType cls) { + return parameter.equals(cls.resolve()) || java.util.Arrays.stream(cls.getParameters()).anyMatch(argument -> mentions(argument, parameter)); + } + if (type instanceof PsiArrayType array) { + return mentions(array.getComponentType(), parameter); + } + if (type instanceof com.intellij.psi.PsiWildcardType wildcard && wildcard.isBounded()) { + return mentions(wildcard.getBound(), parameter); + } + if (type instanceof com.intellij.psi.PsiIntersectionType intersection) { + return java.util.Arrays.stream(intersection.getConjuncts()).anyMatch(conjunct -> mentions(conjunct, parameter)); + } + return false; + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiImportDiagnostics.java b/java-frontend/src/main/java/org/sonar/java/model/PsiImportDiagnostics.java new file mode 100644 index 00000000000..873a915a2bb --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiImportDiagnostics.java @@ -0,0 +1,101 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiImportStatement; +import com.intellij.psi.PsiImportStatementBase; +import com.intellij.psi.PsiImportStaticStatement; +import com.intellij.psi.PsiJavaCodeReferenceElement; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.util.PsiTreeUtil; +import java.util.HashSet; +import java.util.Set; +import java.util.function.BiConsumer; + +final class PsiImportDiagnostics { + private final PsiJavaFile file; + private final Set used = new HashSet<>(); + + PsiImportDiagnostics(PsiJavaFile file) { + this.file = file; + } + + void reference(PsiJavaCodeReferenceElement reference) { + if (reference.getQualifier() != null || PsiTreeUtil.getParentOfType(reference, PsiImportStatementBase.class) != null) { + return; + } + var result = reference.advancedResolve(false); + if (result.isValidResult() && result.getCurrentFileResolveScope() instanceof PsiImportStatementBase statement) { + used.add(statement); + } + } + + void collect(BiConsumer warning, Set problems) { + if (file.getImportList() == null) { + return; + } + var statements = file.getImportList().getAllImportStatements(); + boolean unresolved = false; + for (var statement : statements) { + if (statement.getImportReference() != null && statement.resolve() == null) { + unresolved = true; + var reference = statement.getImportReference(); + while (reference.getQualifier() instanceof PsiJavaCodeReferenceElement prefix && prefix.resolve() == null) { + reference = prefix; + } + problems.add(new JProblem("The import " + reference.getQualifiedName() + " cannot be resolved", JProblem.Type.UNDEFINED_TYPE)); + } + } + if (unresolved || file.getModuleDeclaration() != null) { + return; + } + var seen = new HashSet(); + for (var statement : statements) { + var reference = statement.getImportReference(); + if (statement instanceof com.intellij.psi.PsiImportModuleStatement module) { + String name = module.getReferenceName(); + if ((!seen.add("module " + name) || !used.contains(module)) && module.getModuleReference() != null) { + warning.accept(module.getModuleReference(), "The import " + name + " is never used"); + } + continue; + } + if (reference == null) { + continue; + } + String name = reference.getQualifiedName(); + String key = (statement instanceof PsiImportStaticStatement ? "static " : "") + name + statement.isOnDemand(); + boolean duplicate = !seen.add(key); + if (!duplicate && ownDeclaration(statement)) { + continue; + } + if (duplicate || redundantWildcard(statement) || !used.contains(statement)) { + warning.accept(reference, "The import " + name + " is never used"); + } + } + } + + private boolean ownDeclaration(PsiImportStatementBase statement) { + return statement instanceof PsiImportStatement && !statement.isOnDemand() + && java.util.Arrays.asList(file.getClasses()).contains(statement.resolve()); + } + + private boolean redundantWildcard(PsiImportStatementBase statement) { + return statement instanceof PsiImportStatement && statement.isOnDemand() + && (statement.getImportReference().getQualifiedName().equals("java.lang") || statement.getImportReference().getQualifiedName().equals(file.getPackageName())); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiParseDiagnostics.java b/java-frontend/src/main/java/org/sonar/java/model/PsiParseDiagnostics.java new file mode 100644 index 00000000000..834639b7075 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiParseDiagnostics.java @@ -0,0 +1,72 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.lang.java.lexer.JavaLexer; +import com.intellij.pom.java.LanguageLevel; +import com.intellij.psi.JavaTokenType; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiLiteralExpression; +import com.intellij.psi.util.PsiTreeUtil; +import java.util.ArrayList; +import java.util.List; +import org.sonar.java.psi.JavaPsiConfiguration; +import org.sonar.java.psi.PsiLanguageValidator.Diagnostic; +import org.sonar.java.psi.PsiLanguageValidator.Kind; + +final class PsiParseDiagnostics { + private PsiParseDiagnostics() { + } + + static List additional(PsiJavaFile file, JavaPsiConfiguration configuration) { + var diagnostics = new ArrayList(); + for (var literal : PsiTreeUtil.findChildrenOfType(file, PsiLiteralExpression.class)) { + if (literal.getText().startsWith("'") && literal.getValue() == null) { + int start = literal.getTextRange().getStartOffset(); + diagnostics.add(new Diagnostic(Kind.FATAL, start, start, "Invalid character constant")); + } + } + var lexer = new JavaLexer(LanguageLevel.JDK_26_PREVIEW); + lexer.start(file.getText()); + boolean imported = false; + boolean inImport = false; + int emptyDeclaration = -1; + while (lexer.getTokenType() != null) { + var token = lexer.getTokenType(); + if (token == JavaTokenType.IMPORT_KEYWORD) { + if (emptyDeclaration >= 0) { + int start = configuration.sourceVersion() >= 25 ? lexer.getTokenStart() : emptyDeclaration; + diagnostics.add(new Diagnostic(Kind.FATAL, start, start, + configuration.sourceVersion() >= 25 ? "Syntax error on token \"import\", int expected" : "Syntax error on token \";\", delete this token")); + break; + } + imported = true; + inImport = true; + } else if (token == JavaTokenType.SEMICOLON) { + if (inImport) { + inImport = false; + } else if (imported) { + emptyDeclaration = lexer.getTokenStart(); + } + } else if (!inImport && token != com.intellij.psi.TokenType.WHITE_SPACE && !token.toString().contains("COMMENT")) { + break; + } + lexer.advance(); + } + return diagnostics; + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiResolutionDiagnostics.java b/java-frontend/src/main/java/org/sonar/java/model/PsiResolutionDiagnostics.java new file mode 100644 index 00000000000..ee224213ad2 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiResolutionDiagnostics.java @@ -0,0 +1,95 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.psi.JavaRecursiveElementWalkingVisitor; +import com.intellij.psi.PsiArrayInitializerExpression; +import com.intellij.psi.PsiArrayType; +import com.intellij.psi.PsiClass; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiExpression; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiLambdaExpression; +import com.intellij.psi.PsiMethodCallExpression; +import com.intellij.psi.PsiNamedElement; +import com.intellij.psi.infos.MethodCandidateInfo; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +/** Preserves legacy problem messages and categories for partial hierarchy, conversion, and inference resolution. */ +final class PsiResolutionDiagnostics { + private PsiResolutionDiagnostics() { + } + + static void collect(PsiJavaFile file, Set problems) { + file.accept(new JavaRecursiveElementWalkingVisitor() { + @Override + public void visitClass(PsiClass declaration) { + if (Arrays.stream(declaration.getSuperTypes()).map(PsiClassType::resolve).filter(java.util.Objects::nonNull) + .anyMatch(parent -> incompleteHierarchy(parent, new HashSet<>()))) { + add(problems, "The hierarchy of the type " + declaration.getName() + " is inconsistent"); + } + super.visitClass(declaration); + } + + @Override + public void visitArrayInitializerExpression(PsiArrayInitializerExpression expression) { + if (expression.getType() instanceof PsiArrayType array && array.getComponentType() instanceof PsiClassType expected && expected.resolve() == null) { + for (var initializer : expression.getInitializers()) { + if (initializer.getType() instanceof PsiClassType actual && actual.resolve() != null) { + add(problems, "Type mismatch: cannot convert from " + PsiDiagnosticTypes.name(actual) + " to " + PsiDiagnosticTypes.name(expected)); + } + } + } + super.visitArrayInitializerExpression(expression); + } + + @Override + public void visitMethodCallExpression(PsiMethodCallExpression call) { + var resolution = call.resolveMethodGenerics(); + if (resolution instanceof MethodCandidateInfo candidate && candidate.getElement().hasTypeParameters() + && (candidate.getInferenceErrorMessage() != null || incompleteLambda(call))) { + var method = candidate.getElement(); + String variables = Arrays.stream(method.getTypeParameters()).map(PsiNamedElement::getName).collect(Collectors.joining(",")); + String parameters = Arrays.stream(method.getParameterList().getParameters()).map(parameter -> PsiDiagnosticTypes.name(parameter.getType())) + .collect(Collectors.joining(", ")); + add(problems, "Cannot infer type argument(s) for <" + variables + "> " + method.getName() + "(" + parameters + ")"); + } + super.visitMethodCallExpression(call); + } + }); + } + + private static boolean incompleteLambda(PsiMethodCallExpression call) { + return Arrays.stream(call.getArgumentList().getExpressions()).anyMatch(argument -> argument instanceof PsiLambdaExpression lambda + && lambda.getBody() instanceof PsiExpression body + && (body.getType() == null || body.getType() instanceof PsiClassType type && type.resolve() == null)); + } + + private static boolean incompleteHierarchy(PsiClass declaration, Set seen) { + if (!seen.add(declaration)) { + return false; + } + return Arrays.stream(declaration.getSuperTypes()).anyMatch(parent -> parent.resolve() == null || incompleteHierarchy(parent.resolve(), seen)); + } + + private static void add(Set problems, String message) { + problems.add(new JProblem(message, JProblem.Type.PREVIEW_FEATURE_USED)); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/PsiTokenIndex.java b/java-frontend/src/main/java/org/sonar/java/model/PsiTokenIndex.java new file mode 100644 index 00000000000..0cf5b0a5261 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/PsiTokenIndex.java @@ -0,0 +1,258 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.intellij.lang.java.lexer.JavaLexer; +import com.intellij.pom.java.LanguageLevel; +import com.intellij.psi.TokenType; +import com.intellij.psi.impl.source.tree.java.PsiLiteralExpressionImpl; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.sonar.plugins.java.api.tree.SyntaxTrivia; + +/** Indexes physical UTF-16 source slices independently of PSI's decoded names and composite operator nodes. */ +final class PsiTokenIndex { + record Lexeme(int start, int end, String kind, InternalSyntaxToken token) { + } + + private final String source; + private final LineColumnConverter positions; + private final List tokens = new ArrayList<>(); + private final Map slices = new HashMap<>(); + + PsiTokenIndex(String source) { + this.source = source; + positions = new LineColumnConverter(source); + var lexer = new JavaLexer(LanguageLevel.JDK_26_PREVIEW); + var input = unicodeInput(source); + lexer.start(input.text()); + List trivia = new ArrayList<>(); + int commentStart = 0; + int commentEnd = 0; + while (lexer.getTokenType() != null) { + int start = input.offsets()[lexer.getTokenStart()]; + int end = input.offsets()[lexer.getTokenEnd()]; + String kind = lexer.getTokenType().toString(); + int restart = -1; + if (kind.equals("TEXT_BLOCK_LITERAL") && !opensTextBlock(input.text(), lexer.getTokenStart())) { + restart = lexer.getTokenStart() + 2; + end = input.offsets()[restart]; + kind = "STRING_LITERAL"; + } + String text = source.substring(start, end); + if (kind.contains("COMMENT")) { + int logicalEnd = lexer.getTokenEnd(); + if (kind.equals("END_OF_LINE_COMMENT") && logicalEnd < input.text().length() + && (input.text().charAt(logicalEnd) == '\n' || input.text().charAt(logicalEnd) == '\r') + && input.offsets()[logicalEnd + 1] - end > 1) { + end = input.offsets()[logicalEnd + 1]; + text = source.substring(start, end); + } + var commentKind = text.startsWith("///") ? SyntaxTrivia.CommentKind.MARKDOWN + : text.startsWith("/**") && text.length() > 4 ? SyntaxTrivia.CommentKind.JAVADOC + : text.startsWith("//") ? SyntaxTrivia.CommentKind.LINE : SyntaxTrivia.CommentKind.BLOCK; + if (commentKind == SyntaxTrivia.CommentKind.MARKDOWN && !trivia.isEmpty() + && trivia.getLast().commentKind() == SyntaxTrivia.CommentKind.MARKDOWN + && source.substring(commentEnd, start).matches("(?:\\r\\n?|\\n)[ \t\f]*")) { + trivia.removeLast(); + start = commentStart; + text = source.substring(start, end); + } + var pos = positions.toPos(start); + trivia.add(new InternalSyntaxTrivia(commentKind, text, pos.line(), pos.columnOffset())); + commentStart = start; + commentEnd = end; + } else if (lexer.getTokenType() != TokenType.WHITE_SPACE) { + validateLiteral(kind, text, start); + if (lexer.getTokenType() == TokenType.BAD_CHARACTER) { + throw new IllegalArgumentException("Invalid Java token at UTF-16 offset " + start); + } + var pos = positions.toPos(start); + tokens.add(new Lexeme(start, end, kind, new InternalSyntaxToken(pos.line(), pos.columnOffset(), text, List.copyOf(trivia), false))); + trivia.clear(); + } + if (restart < 0) { + lexer.advance(); + } else { + lexer.start(input.text(), restart, input.text().length(), 0); + } + } + mergeShifts(); + var pos = positions.toPos(source.length()); + tokens.add(new Lexeme(source.length(), source.length(), "EOF", + new InternalSyntaxToken(pos.line(), pos.columnOffset(), "", List.copyOf(trivia), true))); + } + + private static boolean opensTextBlock(String text, int start) { + int i = start + 3; + while (i < text.length() && (text.charAt(i) == ' ' || text.charAt(i) == '\t' || text.charAt(i) == '\f')) { + i++; + } + return i < text.length() && (text.charAt(i) == '\r' || text.charAt(i) == '\n'); + } + + private static void validateLiteral(String kind, String raw, int offset) { + if (!kind.equals("STRING_LITERAL") && !kind.equals("CHARACTER_LITERAL") && !kind.equals("TEXT_BLOCK_LITERAL")) { + return; + } + String text = decoded(raw); + boolean valid; + if (kind.equals("TEXT_BLOCK_LITERAL")) { + valid = text.length() >= 6 && text.endsWith("\"\"\""); + } else { + char quote = kind.equals("STRING_LITERAL") ? '"' : '\''; + var value = new StringBuilder(); + valid = text.length() >= 2 && text.charAt(text.length() - 1) == quote + && text.indexOf('\n') < 0 && text.indexOf('\r') < 0 + && PsiLiteralExpressionImpl.parseStringCharacters(text.substring(1, text.length() - 1), value, null) + && (quote == '"' || value.length() == 1); + } + if (!valid) { + throw new IllegalArgumentException("Invalid Java literal at UTF-16 offset " + offset); + } + } + + record UnicodeInput(String text, int[] offsets) { + } + + static UnicodeInput unicodeInput(String raw) { + var decoded = new StringBuilder(raw.length()); + int[] offsets = new int[raw.length() + 1]; + int slashes = 0; + boolean escaped = false; + for (int i = 0; i < raw.length();) { + offsets[decoded.length()] = i; + char c = raw.charAt(i++); + boolean unicode = false; + if (c == '\\' && (escaped || slashes % 2 == 0) && i < raw.length() && raw.charAt(i) == 'u') { + while (i < raw.length() && raw.charAt(i) == 'u') { + i++; + } + if (i + 4 > raw.length()) { + throw new IllegalArgumentException("Incomplete Unicode escape at UTF-16 offset " + i); + } + int value = 0; + for (int j = 0; j < 4; j++) { + int digit = Character.digit(raw.charAt(i++), 16); + if (digit < 0) { + throw new IllegalArgumentException("Invalid Unicode escape at UTF-16 offset " + (i - 1)); + } + value = value * 16 + digit; + } + c = (char) value; + unicode = true; + } + decoded.append(c); + slashes = c == '\\' ? slashes + 1 : 0; + escaped = unicode; + } + offsets[decoded.length()] = raw.length(); + return new UnicodeInput(decoded.toString(), offsets); + } + + static String decoded(String text) { + return unicodeInput(text).text(); + } + + private void mergeShifts() { + for (int i = 0; i < tokens.size(); i++) { + var first = tokens.get(i); + if (!decoded(first.token().text()).equals(">")) { + continue; + } + int j = i + 1; + int end = first.end(); + String operator = ">"; + while (j < tokens.size() && tokens.get(j).start() == end && operator.length() < 3 && decoded(tokens.get(j).token().text()).equals(">")) { + end = tokens.get(j++).end(); + operator += ">"; + } + if (j < tokens.size() && tokens.get(j).start() == end && decoded(tokens.get(j).token().text()).equals("=")) { + end = tokens.get(j++).end(); + operator += "="; + } + if (j > i + 1) { + var pos = positions.toPos(first.start()); + tokens.set(i, new Lexeme(first.start(), end, operator, + new InternalSyntaxToken(pos.line(), pos.columnOffset(), source.substring(first.start(), end), first.token().trivias(), false))); + tokens.subList(i + 1, j).clear(); + } + } + } + + List lexemes() { + return java.util.Collections.unmodifiableList(tokens); + } + + InternalSyntaxToken eof() { + return tokens.getLast().token(); + } + + int index(int offset) { + int low = 0; + int high = tokens.size() - 1; + while (low < high) { + int middle = (low + high) >>> 1; + if (tokens.get(middle).start() < offset) { + low = middle + 1; + } else { + high = middle; + } + } + return low; + } + + InternalSyntaxToken at(int offset) { + return tokens.get(index(offset)).token(); + } + + InternalSyntaxToken find(int start, int end, String text, boolean last) { + InternalSyntaxToken result = null; + for (int i = index(start); i < tokens.size() && tokens.get(i).start() < end; i++) { + var token = tokens.get(i).token(); + if (decoded(token.text()).equals(text)) { + result = token; + if (!last) { + break; + } + } + } + if (result == null) { + throw new IllegalArgumentException("Missing token '" + text + "' in [" + start + ", " + end + ")"); + } + return result; + } + + InternalSyntaxToken slice(int start, int end) { + int index = index(start); + if (index > 0 && tokens.get(index - 1).end() > start) { + index--; + } + var lexeme = tokens.get(index); + if (lexeme.start() == start && lexeme.end() == end) { + return lexeme.token(); + } + long key = ((long) start << 32) | end; + return slices.computeIfAbsent(key, unused -> { + var pos = positions.toPos(start); + List trivia = lexeme.start() == start ? lexeme.token().trivias() : List.of(); + return new InternalSyntaxToken(pos.line(), pos.columnOffset(), source.substring(start, end), trivia, false); + }); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/ExplicitJavaFileManager.java b/java-frontend/src/main/java/org/sonar/java/psi/ExplicitJavaFileManager.java new file mode 100644 index 00000000000..51193fe0372 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/ExplicitJavaFileManager.java @@ -0,0 +1,135 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.core.CoreJavaFileManager; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiClass; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiJavaModule; +import com.intellij.psi.PsiManager; +import com.intellij.psi.PsiModifierList; +import com.intellij.psi.PsiPackage; +import com.intellij.psi.impl.file.PsiPackageImpl; +import com.intellij.psi.search.GlobalSearchScope; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +final class ExplicitJavaFileManager extends CoreJavaFileManager { + + private final PsiManager manager; + private final Map> classes = new HashMap<>(); + private final JavaPsiModuleGraph moduleGraph; + private final Set packages = new HashSet<>(); + private final Map packageInfos = new HashMap<>(); + + ExplicitJavaFileManager(PsiManager manager, int javaVersion, boolean ignoreUnnamedModuleForSplitPackage) { + super(manager); + this.manager = manager; + moduleGraph = new JavaPsiModuleGraph(javaVersion, ignoreUnnamedModuleForSplitPackage); + } + + void index(List files) { + for (var file : files) { + var name = file.getPackageName(); + if (file.getName().equals("package-info.java")) { + packageInfos.putIfAbsent(name, file); + } + moduleGraph.addSourcePackage(name); + while (true) { + packages.add(name); + if (name.isEmpty()) { + break; + } + int separator = name.lastIndexOf('.'); + name = separator < 0 ? "" : name.substring(0, separator); + } + for (var declaration : file.getClasses()) { + index(declaration); + } + } + } + + void indexClasspathModule(VirtualFile root) { + ReadAction.run(() -> { + PsiJavaModule module = null; + if (root.getFileSystem() instanceof JrtVirtualFileSystem) { + var descriptor = root.findChild("module-info.class"); + if (descriptor != null && manager.findFile(descriptor) instanceof PsiJavaFile file) { + module = file.getModuleDeclaration(); + } + } + moduleGraph.addRoot(root, module); + }); + } + + JavaPsiModuleGraph moduleGraph() { + return moduleGraph; + } + + @Override + public Collection findModules(String name, GlobalSearchScope scope) { + return moduleGraph.findModules(name, scope); + } + + private void index(PsiClass declaration) { + var name = declaration.getQualifiedName(); + if (name != null) { + classes.computeIfAbsent(name, ignored -> new ArrayList<>()).add(declaration); + } + for (var member : declaration.getInnerClasses()) { + index(member); + } + } + + @Override + public PsiClass findClass(String name, GlobalSearchScope scope) { + var source = sources(name, scope).findFirst(); + return source.orElseGet(() -> super.findClass(name, scope)); + } + + @Override + public PsiClass[] findClasses(String name, GlobalSearchScope scope) { + return Stream.concat(sources(name, scope), Stream.of(super.findClasses(name, scope))).toArray(PsiClass[]::new); + } + + @Override + public PsiPackage findPackage(String name) { + var info = packageInfos.get(name); + if (info != null) { + return new PsiPackageImpl(manager, name) { + @Override + public PsiModifierList getAnnotationList() { + var statement = info.getPackageStatement(); + return statement == null ? null : statement.getAnnotationList(); + } + }; + } + return packages.contains(name) ? new PsiPackageImpl(manager, name) : super.findPackage(name); + } + + private Stream sources(String name, GlobalSearchScope scope) { + return classes.getOrDefault(name, List.of()).stream().filter(declaration -> scope.contains(declaration.getContainingFile().getVirtualFile())); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiApplication.java b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiApplication.java new file mode 100644 index 00000000000..9b9f6e5536c --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiApplication.java @@ -0,0 +1,211 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.core.CoreApplicationEnvironment; +import com.intellij.core.CoreJavaDirectoryService; +import com.intellij.core.CorePsiPackageImplementationHelper; +import com.intellij.ide.highlighter.JavaClassFileType; +import com.intellij.ide.highlighter.JavaFileType; +import com.intellij.java.frontback.psi.impl.syntax.JavaSyntaxDefinitionExtension; +import com.intellij.lang.LanguageASTFactory; +import com.intellij.lang.java.JavaLanguage; +import com.intellij.lang.java.JavaParserDefinition; +import com.intellij.lang.java.syntax.JavaElementTypeConverterExtension; +import com.intellij.mock.MockApplication; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.application.PathManager; +import com.intellij.openapi.extensions.ExtensionPoint; +import com.intellij.openapi.extensions.ExtensionsArea; +import com.intellij.openapi.fileTypes.BinaryFileTypeDecompilers; +import com.intellij.openapi.projectRoots.JavaVersionService; +import com.intellij.openapi.util.Condition; +import com.intellij.openapi.util.Disposer; +import com.intellij.openapi.util.registry.Registry; +import com.intellij.openapi.vfs.impl.jar.CoreJarFileSystem; +import com.intellij.platform.syntax.psi.CommonElementTypeConverterFactory; +import com.intellij.platform.syntax.psi.ElementTypeConverters; +import com.intellij.platform.syntax.psi.LanguageSyntaxDefinitions; +import com.intellij.platform.syntax.psi.PsiSyntaxBuilderFactory; +import com.intellij.platform.syntax.psi.PsiSyntaxBuilderFactoryImpl; +import com.intellij.pom.java.LanguageLevel; +import com.intellij.psi.ClassFileViewProviderFactory; +import com.intellij.psi.FileTypeFileViewProviders; +import com.intellij.psi.JavaDirectoryService; +import com.intellij.psi.JavaModuleGraphHelper; +import com.intellij.psi.PsiSubstitutorFactory; +import com.intellij.psi.compiled.ClassFileDecompilers; +import com.intellij.psi.impl.JavaClassSupersImpl; +import com.intellij.psi.impl.LanguageConstantExpressionEvaluator; +import com.intellij.psi.impl.PsiExpressionEvaluator; +import com.intellij.psi.impl.PsiSubstitutorFactoryImpl; +import com.intellij.psi.impl.compiled.ClassFileStubBuilder; +import com.intellij.psi.impl.compiled.ClsDecompilerImpl; +import com.intellij.psi.impl.file.PsiPackageImplementationHelper; +import com.intellij.psi.impl.source.tree.JavaASTFactory; +import com.intellij.psi.stubs.BinaryFileStubBuilders; +import com.intellij.psi.stubs.StubElementRegistryServiceImplKt; +import com.intellij.psi.util.JavaClassSupers; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Element; + +final class JavaPsiApplication extends CoreApplicationEnvironment { + + private static JavaPsiApplication shared; + private static int users; + private static final List DESCRIPTORS = List.of( + "META-INF/Core.xml", "META-INF/Core.analyzer.xml", "META-INF/CoreImpl.xml", "META-INF/CoreImpl.analyzer.xml", + "intellij.java.frontback.psi.xml", "intellij.java.frontback.psi.impl.xml", + "intellij.java.psi.xml", "intellij.java.psi.impl.xml"); + + private JavaPsiApplication(Disposable owner) { + super(owner, false); + registerExtensionPoints(application.getExtensionArea(), false); + Registry.get("javac.fresh.variables.for.captured.wildcards.only").setValue(true); + Registry.get("unsound.capture.conversion.java.spec.change").setValue(false); + Registry.get("java.highest.language.level").setValue(Integer.toString(LanguageLevel.HIGHEST.feature())); + registerFileType(JavaClassFileType.INSTANCE, "class"); + registerFileType(JavaFileType.INSTANCE, "java"); + addExtension(StubElementRegistryServiceImplKt.STUB_REGISTRY_EP, new JavaPsiStubFactories()); + addExplicitExtension(FileTypeFileViewProviders.INSTANCE, JavaClassFileType.INSTANCE, new ClassFileViewProviderFactory()); + addExplicitExtension(BinaryFileStubBuilders.INSTANCE, JavaClassFileType.INSTANCE, new ClassFileStubBuilder()); + addExplicitExtension(LanguageASTFactory.INSTANCE, JavaLanguage.INSTANCE, new JavaASTFactory()); + addExplicitExtension(LanguageSyntaxDefinitions.getINSTANCE(), JavaLanguage.INSTANCE, new JavaSyntaxDefinitionExtension()); + addExplicitExtension(ElementTypeConverters.getInstance(), JavaLanguage.INSTANCE, new CommonElementTypeConverterFactory()); + addExplicitExtension(ElementTypeConverters.getInstance(), JavaLanguage.INSTANCE, new JavaElementTypeConverterExtension()); + registerParserDefinition(new JavaParserDefinition()); + addExtension(com.intellij.psi.augment.PsiAugmentProvider.EP_NAME, new com.intellij.psi.impl.RecordAugmentProvider()); + addExplicitExtension(LanguageConstantExpressionEvaluator.INSTANCE, JavaLanguage.INSTANCE, new PsiExpressionEvaluator()); + registerApplicationService(PsiPackageImplementationHelper.class, new CorePsiPackageImplementationHelper()); + registerApplicationService(PsiSubstitutorFactory.class, new PsiSubstitutorFactoryImpl()); + registerApplicationService(JavaClassSupers.class, new JavaClassSupersImpl()); + registerApplicationService(JavaModuleGraphHelper.class, new JavaPsiModuleGraphHelper()); + registerApplicationService(JavaDirectoryService.class, new CoreJavaDirectoryService()); + registerApplicationService(JavaVersionService.class, new JavaVersionService()); + registerApplicationService(PsiSyntaxBuilderFactory.class, new PsiSyntaxBuilderFactoryImpl()); + registerApplicationDynamicExtensionPoint("com.intellij.psi.classFileDecompiler", ClassFileDecompilers.Decompiler.class); + addExtension(ClassFileDecompilers.STATIC_EP_NAME, new ClsDecompilerImpl()); + BinaryFileTypeDecompilers.getInstance(); + } + + @Override + protected MockApplication createApplication(Disposable owner) { + return new MockApplication(owner) { + @Override + public boolean isUnitTestMode() { + return false; + } + + @Override + public void invokeLater(Runnable action) { + action.run(); + } + + @Override + public void invokeLater(Runnable action, ModalityState state) { + action.run(); + } + + @Override + public void invokeLater(Runnable action, Condition expired) { + if (!expired.value(null)) { + action.run(); + } + } + + @Override + public void invokeLater(Runnable action, ModalityState state, Condition expired) { + invokeLater(action, expired); + } + }; + } + + static synchronized JavaPsiApplication acquire() { + var current = ApplicationManager.getApplication(); + if (current != null && (shared == null || current != shared.getApplication())) { + throw new IllegalStateException("Cannot replace an existing IntelliJ application"); + } + if (shared == null) { + PathManager.setExplicitConfigPath(Path.of(System.getProperty("java.io.tmpdir"), "sonar-java-psi-" + UUID.randomUUID())); + var owner = Disposer.newDisposable("SonarJava PSI application"); + try { + shared = new JavaPsiApplication(owner); + } catch (RuntimeException | Error failure) { + try { + Disposer.dispose(owner); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } finally { + ApplicationManager.setApplication(null); + } + throw failure; + } + } + users++; + return shared; + } + + static synchronized void release() { + if (--users == 0) { + var previous = shared; + shared = null; + try { + ((CoreJarFileSystem) previous.getJarFileSystem()).clearHandlersCache(); + Disposer.dispose(previous.getParentDisposable()); + } finally { + ApplicationManager.setApplication(null); + } + } + } + + static void registerExtensionPoints(ExtensionsArea area, boolean project) { + for (var resource : DESCRIPTORS) { + try (var input = JavaPsiApplication.class.getClassLoader().getResourceAsStream(resource)) { + if (input == null) { + throw new IllegalStateException("Missing PSI descriptor: " + resource); + } + var factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + var document = factory.newDocumentBuilder().parse(input); + var points = document.getElementsByTagName("extensionPoint"); + for (int i = 0; i < points.getLength(); i++) { + var point = (Element) points.item(i); + if (project != "IDEA_PROJECT".equals(point.getAttribute("area"))) { + continue; + } + var name = point.hasAttribute("qualifiedName") ? point.getAttribute("qualifiedName") : "com.intellij." + point.getAttribute("name"); + if (!area.hasExtensionPoint(name)) { + var bean = point.hasAttribute("beanClass"); + area.registerExtensionPoint(name, point.getAttribute(bean ? "beanClass" : "interface"), + bean ? ExtensionPoint.Kind.BEAN_CLASS : ExtensionPoint.Kind.INTERFACE); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (javax.xml.parsers.ParserConfigurationException | org.xml.sax.SAXException e) { + throw new IllegalStateException("Cannot register PSI extensions", e); + } + } + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiConfiguration.java b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiConfiguration.java new file mode 100644 index 00000000000..ae31331a9ce --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiConfiguration.java @@ -0,0 +1,40 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Effective frontend inputs. A null runtime selects the running VM only when no + * rt.jar, android.jar or jrt-fs.jar entry suppresses the implicit boot classpath. + * Source strings and charsets belong to the caller's decoded snapshots. + */ +public record JavaPsiConfiguration(Path runtime, List classpath, int sourceVersion, + boolean previewEnabled, boolean ignoreUnnamedModuleForSplitPackage) { + + public JavaPsiConfiguration { + runtime = (runtime == null ? Path.of(System.getProperty("java.home")) : runtime).toAbsolutePath().normalize(); + classpath = classpath.stream().map(path -> Objects.requireNonNull(path).toAbsolutePath().normalize()).toList(); + } + + public List runtimeArchives() { + return classpath.stream().filter(path -> Set.of("rt.jar", "android.jar", "jrt-fs.jar").contains(path.getFileName().toString())).toList(); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiEnvironment.java b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiEnvironment.java new file mode 100644 index 00000000000..542c97bcbeb --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiEnvironment.java @@ -0,0 +1,330 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.codeInsight.BaseExternalAnnotationsManager; +import com.intellij.codeInsight.ExternalAnnotationsManager; +import com.intellij.core.JavaCoreProjectEnvironment; +import com.intellij.ide.highlighter.JavaFileType; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.progress.EmptyProgressIndicatorBase; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.LanguageLevelProjectExtension; +import com.intellij.openapi.util.Computable; +import com.intellij.openapi.util.Disposer; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.pom.java.LanguageLevel; +import com.intellij.psi.PsiElementFinder; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiManager; +import com.intellij.psi.impl.PsiElementFinderImpl; +import com.intellij.psi.impl.PsiJavaModuleModificationTracker; +import com.intellij.psi.impl.file.impl.JavaFileManager; +import com.intellij.testFramework.LightVirtualFile; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.BooleanSupplier; + +/** + * Owns a standalone PSI project for explicitly supplied Java files. + * Source contents are captured before opening the project and remain fixed for the session. + * PSI objects obtained in {@link #read} are valid only while this environment remains open. + */ +public final class JavaPsiEnvironment implements AutoCloseable { + + private final Disposable disposable; + private JavaCoreProjectEnvironment environment; + private List sourceFiles; + private BooleanSupplier isCanceled; + + private JavaPsiEnvironment(Disposable disposable, JavaCoreProjectEnvironment environment, List sourceFiles) { + this.disposable = disposable; + this.environment = environment; + this.sourceFiles = sourceFiles; + } + + public static JavaPsiEnvironment open(Path jdkHome, List sourceFiles, List classpath, int javaVersion) { + return open(jdkHome, sourceFiles, classpath, javaVersion, false); + } + + public static JavaPsiEnvironment open(Path jdkHome, List sourceFiles, List classpath, int javaVersion, boolean preview) { + return open(jdkHome, sourceFiles, classpath, javaVersion, preview, false); + } + + public static JavaPsiEnvironment open(Path jdkHome, List sourceFiles, List classpath, + int javaVersion, boolean preview, boolean ignoreUnnamedModuleForSplitPackage) { + var snapshots = sourceFiles.stream().map(JavaPsiEnvironment::javaSource).distinct().map(path -> { + try { + return new SourceSnapshot(path, Files.readString(path), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }).toList(); + return openSnapshots(jdkHome, snapshots, classpath, javaVersion, preview, ignoreUnnamedModuleForSplitPackage); + } + + public record SourceSnapshot(Path path, String contents, Charset charset) { + public SourceSnapshot { + path = path.toAbsolutePath().normalize(); + java.util.Objects.requireNonNull(contents); + java.util.Objects.requireNonNull(charset); + } + } + + public static JavaPsiEnvironment openSnapshots(Path jdkHome, List snapshots, + List classpath, int javaVersion, boolean preview) { + return openSnapshots(jdkHome, snapshots, classpath, javaVersion, preview, false); + } + + public static synchronized JavaPsiEnvironment openSnapshots(Path jdkHome, List snapshots, + List classpath, int javaVersion, boolean preview, boolean ignoreUnnamedModuleForSplitPackage) { + return open(new JavaPsiConfiguration(jdkHome, classpath, javaVersion, preview, ignoreUnnamedModuleForSplitPackage), snapshots); + } + + /** Opens a project under cancellation and retains the supplier for subsequent lazy reads. */ + public static synchronized JavaPsiEnvironment open(JavaPsiConfiguration configuration, List snapshots, BooleanSupplier isCanceled) { + JavaPsiApplication.acquire(); + try { + var indicator = indicator(isCanceled); + return ProgressManager.getInstance().runProcess(() -> { + indicator.checkCanceled(); + var result = open(configuration, snapshots); + result.isCanceled = isCanceled; + return result; + }, indicator); + } finally { + JavaPsiApplication.release(); + } + } + + public static synchronized JavaPsiEnvironment open(JavaPsiConfiguration configuration, List snapshots) { + int javaVersion = configuration.sourceVersion(); + boolean preview = configuration.previewEnabled(); + boolean ignoreUnnamedModuleForSplitPackage = configuration.ignoreUnnamedModuleForSplitPackage(); + var languageLevel = Arrays.stream(LanguageLevel.values()) + .filter(level -> level.feature() == javaVersion && level.isPreview() == preview && !level.isUnsupported()) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Unsupported Java source version: " + javaVersion + ", preview=" + preview)); + var runtime = configuration.runtime(); + var inputs = snapshots.stream() + .filter(input -> javaVersion >= 9 || !input.path().getFileName().toString().equals("module-info.java")) + .toList(); + if (inputs.stream().map(SourceSnapshot::path).distinct().count() != inputs.size()) { + throw new IllegalArgumentException("Duplicate source snapshot paths"); + } + var libraries = new java.util.ArrayList<>(configuration.classpath().stream() + .filter(path -> Files.isDirectory(path) || Files.isRegularFile(path)).map(Path::toFile).toList()); + var explicitRuntimes = configuration.runtimeArchives().stream().map(Path::toFile).toList(); + var images = new java.util.ArrayList(); + if (explicitRuntimes.isEmpty()) { + if (Files.isRegularFile(runtime.resolve("lib/modules"))) { + images.add(runtime); + } else { + var archive = List.of(runtime.resolve("jre/lib/rt.jar"), runtime.resolve("lib/rt.jar")).stream() + .filter(Files::isRegularFile).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Expected a JDK image or runtime archive: " + runtime)); + libraries.add(archive.toFile()); + } + } else { + explicitRuntimes.stream().filter(file -> file.getName().equals("jrt-fs.jar")) + .forEach(file -> { + if (!file.isFile()) { + throw new IllegalStateException("Failed to init packages for " + file); + } + images.add(file.toPath().getParent().getParent()); + }); + } + ProgressManager.checkCanceled(); + var application = JavaPsiApplication.acquire(); + var owner = Disposer.newDisposable("SonarJava PSI project"); + try { + var core = new JavaCoreProjectEnvironment(owner, application) { + @Override + protected JavaFileManager createCoreFileManager() { + return new ExplicitJavaFileManager(myPsiManager, javaVersion, ignoreUnnamedModuleForSplitPackage); + } + + @Override + public void addSourcesToClasspath(VirtualFile root) { + super.addSourcesToClasspath(root); + ((ExplicitJavaFileManager) getProject().getService(JavaFileManager.class)).indexClasspathModule(root); + } + }; + JavaPsiApplication.registerExtensionPoints(core.getProject().getExtensionArea(), true); + core.getProject().registerService(com.intellij.pom.tree.TreeAspect.class, new com.intellij.pom.tree.TreeAspect()); + core.getProject().registerService(com.intellij.pom.PomModel.class, new com.intellij.pom.core.impl.PomModelImpl(core.getProject())); + core.getProject().registerService(PsiJavaModuleModificationTracker.class, new PsiJavaModuleModificationTracker(core.getProject())); + PsiElementFinder.EP.getPoint(core.getProject()).registerExtension(new PsiElementFinderImpl(core.getProject()), owner); + core.getProject().registerService(ExternalAnnotationsManager.class, new BaseExternalAnnotationsManager(PsiManager.getInstance(core.getProject())) { + @Override + protected boolean hasAnyAnnotationsRoots() { + return false; + } + + @Override + public boolean hasConfiguredAnnotationRoot(com.intellij.psi.PsiModifierListOwner owner) { + return false; + } + + @Override + protected List getExternalAnnotationsRoots(VirtualFile file) { + return List.of(); + } + }); + ApplicationManager.getApplication().runWriteAction(() -> + LanguageLevelProjectExtension.getInstance(core.getProject()).setLanguageLevel(languageLevel)); + for (var library : libraries) { + var root = library.isDirectory() + ? application.getLocalFileSystem().findFileByPath(library.getPath()) + : application.getJarFileSystem().findFileByPath(library.getPath() + "!/"); + if (root != null) { + core.addSourcesToClasspath(root); + } + } + for (var image : images) { + var jrt = new JrtVirtualFileSystem(image); + Disposer.register(owner, () -> { + try { + jrt.close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + for (var module : jrt.moduleRoots()) { + ProgressManager.checkCanceled(); + core.addSourcesToClasspath(module); + } + } + var files = ReadAction.compute(() -> inputs.stream().map(file -> findJavaFile(core, file)).toList()); + ReadAction.run(() -> ((ExplicitJavaFileManager) core.getProject().getService(JavaFileManager.class)).index(files)); + return new JavaPsiEnvironment(owner, core, files); + } catch (IOException failure) { + cleanup(owner, failure); + throw new UncheckedIOException(failure); + } catch (RuntimeException | Error failure) { + cleanup(owner, failure); + throw failure; + } + } + + public synchronized T read(BiFunction, T> action) { + if (environment == null) { + throw new IllegalStateException("PSI environment is closed"); + } + Computable read = () -> ReadAction.compute(() -> { + if (Thread.currentThread().isInterrupted() || (isCanceled != null && isCanceled.getAsBoolean())) { + throw new ProcessCanceledException(); + } + ProgressManager.checkCanceled(); + return action.apply(environment.getProject(), sourceFiles); + }); + var progress = ProgressManager.getInstance(); + if (isCanceled == null || progress.getProgressIndicator() != null) { + return read.compute(); + } + return progress.runProcess(read, indicator(isCanceled)); + } + + private static CancellationIndicator indicator(BooleanSupplier isCanceled) { + return new CancellationIndicator(isCanceled, ProgressManager.getInstance().getProgressIndicator()); + } + + private static final class CancellationIndicator extends EmptyProgressIndicatorBase { + private final BooleanSupplier isCanceled; + private final Thread thread = Thread.currentThread(); + private final ProgressIndicator parent; + private volatile boolean canceled; + + private CancellationIndicator(BooleanSupplier isCanceled, ProgressIndicator parent) { + this.isCanceled = isCanceled; + this.parent = parent; + } + + @Override + public void cancel() { + canceled = true; + } + + @Override + public boolean isCanceled() { + return thread.isInterrupted() || isCanceled.getAsBoolean() || canceled || (parent != null && parent.isCanceled()); + } + } + + @Override + public synchronized void close() { + if (environment != null) { + environment = null; + sourceFiles = List.of(); + synchronized (JavaPsiEnvironment.class) { + try { + Disposer.dispose(disposable); + } finally { + JavaPsiApplication.release(); + } + } + } + } + + private static void cleanup(Disposable owner, Throwable failure) { + try { + Disposer.dispose(owner); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } finally { + try { + JavaPsiApplication.release(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + } + + private static Path javaSource(Path path) { + var normalized = path.toAbsolutePath().normalize(); + if (!Files.isRegularFile(normalized) || !normalized.toString().endsWith(".java")) { + throw new IllegalArgumentException("Expected a Java source file: " + normalized); + } + return normalized; + } + + private static PsiJavaFile findJavaFile(JavaCoreProjectEnvironment environment, SourceSnapshot input) { + var virtualFile = new LightVirtualFile(input.path().getFileName().toString(), JavaFileType.INSTANCE, + input.contents(), input.charset(), 0) { + @Override + public String getPath() { + return input.path().toString(); + } + }; + virtualFile.setWritable(false); + if (PsiManager.getInstance(environment.getProject()).findFile(virtualFile) instanceof PsiJavaFile file) { + return file; + } + throw new IllegalStateException("Unable to load Java PSI: " + input); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiModuleGraph.java b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiModuleGraph.java new file mode 100644 index 00000000000..fe6855ff6a0 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiModuleGraph.java @@ -0,0 +1,230 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiClass; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiJavaCodeReferenceElement; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiJavaModule; +import com.intellij.psi.PsiModifier; +import com.intellij.psi.search.GlobalSearchScope; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Session-owned module visibility for SonarJava's explicit sources and ordered classpath. */ +final class JavaPsiModuleGraph { + + private final boolean modulesEnabled; + private final boolean ignoreUnnamedModuleForSplitPackage; + private final Map> modules = new HashMap<>(); + private final Map moduleRoots = new HashMap<>(); + private final List unnamedRoots = new ArrayList<>(); + private final Set sourcePackages = new HashSet<>(); + private final Map> exporters = new HashMap<>(); + private final Map> transitive = new HashMap<>(); + + JavaPsiModuleGraph(int javaVersion, boolean ignoreUnnamedModuleForSplitPackage) { + modulesEnabled = javaVersion >= 9; + this.ignoreUnnamedModuleForSplitPackage = ignoreUnnamedModuleForSplitPackage; + } + + void addRoot(VirtualFile root, PsiJavaModule module) { + if (module == null) { + unnamedRoots.add(root); + return; + } + moduleRoots.put(root, module); + modules.computeIfAbsent(module.getName(), ignored -> new ArrayList<>()).add(module); + for (var export : module.getExports()) { + var name = export.getPackageName(); + if (name != null) { + exporters.computeIfAbsent(name, ignored -> new LinkedHashSet<>()).add(module); + } + } + transitive.clear(); + } + + void addSourcePackage(String name) { + sourcePackages.add(name); + } + + Collection findModules(String name, GlobalSearchScope scope) { + return modules.getOrDefault(name, List.of()).stream() + .filter(module -> scope.contains(module.getContainingFile().getVirtualFile())).toList(); + } + + private PsiJavaModule findModule(String name) { + var candidates = modules.getOrDefault(name, List.of()); + return candidates.size() == 1 ? candidates.getFirst() : null; + } + + PsiJavaModule descriptor(PsiElement element) { + if (element == null) { + return null; + } + if (element instanceof PsiJavaModule module) { + return module; + } + var file = element instanceof PsiFile psiFile ? psiFile : element.getContainingFile(); + var entry = file == null ? null : file.getVirtualFile(); + while (entry != null) { + var module = moduleRoots.get(entry); + if (module != null) { + return module; + } + entry = entry.getParent(); + } + return null; + } + + /** Module imports follow only requires-transitive edges, including static-transitive edges at analysis time. */ + Set transitiveDependencies(PsiJavaModule source) { + return transitive.computeIfAbsent(source, module -> { + var visited = new LinkedHashSet(); + var pending = new ArrayDeque(); + pending.add(module); + while (!pending.isEmpty()) { + ProgressManager.checkCanceled(); + for (var requires : pending.removeFirst().getRequires()) { + if (requires.hasModifierProperty(PsiModifier.TRANSITIVE)) { + var dependency = findModule(requires.getModuleName()); + if (dependency != null && dependency != module && visited.add(dependency)) { + pending.add(dependency); + } + } + } + } + return Set.copyOf(visited); + }); + } + + /** ECJ treats a type reached twice within one module import as ambiguous, even when both paths reach the same declaration. */ + boolean ambiguousModuleImport(PsiClass target, PsiElement place) { + if (!(place instanceof PsiJavaCodeReferenceElement reference) || reference.getQualifier() != null || + !java.util.Objects.equals(reference.getReferenceName(), target.getName()) || target.getContainingClass() != null || + !(place.getContainingFile() instanceof PsiJavaFile file) || !(target.getContainingFile() instanceof PsiJavaFile targetFile)) { + return false; + } + var packageName = targetFile.getPackageName(); + var imports = file.getImportList(); + var targetModule = descriptor(target); + if (imports == null || targetModule == null || "java.lang".equals(packageName) || packageName.equals(file.getPackageName()) || + imports.findSingleClassImportStatement(target.getQualifiedName()) != null || imports.findOnDemandImportStatement(packageName) != null) { + return false; + } + for (var statement : imports.getImportModuleStatements()) { + var module = findModule(statement.getReferenceName()); + if (module != null && importPaths(module, targetModule, new HashSet<>()) > 1) { + return true; + } + } + return false; + } + + private int importPaths(PsiJavaModule module, PsiJavaModule target, Set path) { + ProgressManager.checkCanceled(); + if (!path.add(module)) { + return 0; + } + int count = module == target ? 1 : 0; + try { + for (var requires : module.getRequires()) { + if (requires.hasModifierProperty(PsiModifier.TRANSITIVE)) { + var dependency = findModule(requires.getModuleName()); + if (dependency != null) { + count += importPaths(dependency, target, path); + if (count > 1) { + return 2; + } + } + } + } + return count; + } finally { + path.remove(module); + } + } + + private boolean reads(PsiJavaModule source, PsiJavaModule target) { + if (source == null || source == target || PsiJavaModule.JAVA_BASE.equals(target.getName())) { + return true; + } + for (var requires : source.getRequires()) { + var dependency = findModule(requires.getModuleName()); + if (dependency == target || dependency != null && transitiveDependencies(dependency).contains(target)) { + return true; + } + } + return false; + } + + boolean accessible(PsiJavaModule target, PsiElement place) { + return !modulesEnabled || reads(descriptor(place), target); + } + + private boolean exports(PsiJavaModule module, String name, PsiJavaModule reader) { + if (module == reader) { + return true; + } + for (var export : module.getExports()) { + if (name.equals(export.getPackageName()) && + (export.getModuleNames().isEmpty() || reader != null && export.getModuleNames().contains(reader.getName()))) { + return true; + } + } + return false; + } + + private boolean hasUnnamedPackage(String name) { + if (sourcePackages.contains(name)) { + return true; + } + var path = name.replace('.', '/'); + return unnamedRoots.stream().anyMatch(root -> root.findFileByRelativePath(path) != null); + } + + boolean accessible(String packageName, PsiFile targetFile, PsiElement place) { + if (!modulesEnabled) { + return true; + } + var reader = descriptor(place); + var target = descriptor(targetFile); + if (reader != null && reader == target) { + return true; + } + var visible = exporters.getOrDefault(packageName, Set.of()).stream() + .filter(module -> reads(reader, module) && exports(module, packageName, reader)).toList(); + if (visible.size() > 1 || !visible.isEmpty() && !ignoreUnnamedModuleForSplitPackage && hasUnnamedPackage(packageName)) { + return false; + } + if (targetFile == null) { + return !visible.isEmpty() || reader == null && hasUnnamedPackage(packageName); + } + return target == null ? reader == null : reads(reader, target) && exports(target, packageName, reader); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiModuleGraphHelper.java b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiModuleGraphHelper.java new file mode 100644 index 00000000000..5d0276ec17c --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiModuleGraphHelper.java @@ -0,0 +1,57 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.psi.JavaModuleGraphHelper; +import com.intellij.psi.PsiClass; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiJavaModule; +import com.intellij.psi.impl.file.impl.JavaFileManager; +import java.util.Set; + +final class JavaPsiModuleGraphHelper extends JavaModuleGraphHelper { + + private static JavaPsiModuleGraph graph(PsiElement element) { + return ((ExplicitJavaFileManager) JavaFileManager.getInstance(element.getProject())).moduleGraph(); + } + + @Override + public PsiJavaModule findDescriptorByElement(PsiElement element) { + return element == null ? null : graph(element).descriptor(element); + } + + @Override + public Set getAllTransitiveDependencies(PsiJavaModule module) { + return graph(module).transitiveDependencies(module); + } + + @Override + public boolean isAccessible(PsiClass target, PsiElement place) { + return super.isAccessible(target, place) && !graph(place).ambiguousModuleImport(target, place); + } + + @Override + public boolean isAccessible(String packageName, PsiFile targetFile, PsiElement place) { + return graph(place).accessible(packageName, targetFile, place); + } + + @Override + public boolean isAccessible(PsiJavaModule targetModule, PsiElement place) { + return graph(place).accessible(targetModule, place); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiStubFactories.java b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiStubFactories.java new file mode 100644 index 00000000000..d752e0425f1 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/JavaPsiStubFactories.java @@ -0,0 +1,96 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.psi.impl.java.stubs.JavaStubElementTypePsiElementMappingRegistry; +import com.intellij.psi.impl.java.stubs.JavaStubElementTypes; +import com.intellij.psi.impl.java.stubs.factories.JavaAnnotationParameterListStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaAnnotationStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaClassInitializerStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaClassReferenceListStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaClassStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaFieldStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaImportListStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaImportStatementStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaLiteralExpressionStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaMethodStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaModifierListStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaModuleStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaNameValuePairStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaPackageAccessibilityStatementStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaPackageStatementStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaParameterListStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaParameterStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaProvidesStatementStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaRecordComponentStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaRecordHeaderStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaRequiresStatementStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaTypeParameterListStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaTypeParameterStubFactory; +import com.intellij.psi.impl.java.stubs.factories.JavaUsesStatementStubFactory; +import com.intellij.psi.impl.java.stubs.factories.LambdaExpressionStubFactory; +import com.intellij.psi.impl.java.stubs.factories.MethodReferenceStubFactory; +import com.intellij.psi.stubs.StubRegistry; +import com.intellij.psi.stubs.StubRegistryExtension; + +/** Registers in-memory Java stub factories without persistent index serializers. */ +final class JavaPsiStubFactories implements StubRegistryExtension { + + @Override + public void register(StubRegistry registry) { + JavaStubElementTypePsiElementMappingRegistry.getInstance(); + registry.registerLightStubFactory(JavaStubElementTypes.LITERAL_EXPRESSION, new JavaLiteralExpressionStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.MODIFIER_LIST, new JavaModifierListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.ANNOTATION, new JavaAnnotationStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.ANNOTATION_PARAMETER_LIST, new JavaAnnotationParameterListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.NAME_VALUE_PAIR, new JavaNameValuePairStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.LAMBDA_EXPRESSION, new LambdaExpressionStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.METHOD_REF_EXPRESSION, new MethodReferenceStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.PARAMETER_LIST, new JavaParameterListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.PARAMETER, new JavaParameterStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.TYPE_PARAMETER, new JavaTypeParameterStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.TYPE_PARAMETER_LIST, new JavaTypeParameterListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.CLASS_INITIALIZER, new JavaClassInitializerStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.IMPORT_LIST, new JavaImportListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.MODULE, new JavaModuleStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.REQUIRES_STATEMENT, new JavaRequiresStatementStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.USES_STATEMENT, new JavaUsesStatementStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.PROVIDES_STATEMENT, new JavaProvidesStatementStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.RECORD_COMPONENT, new JavaRecordComponentStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.RECORD_HEADER, new JavaRecordHeaderStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.PACKAGE_STATEMENT, new JavaPackageStatementStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.EXPORTS_STATEMENT, new JavaPackageAccessibilityStatementStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.OPENS_STATEMENT, new JavaPackageAccessibilityStatementStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.CLASS, new JavaClassStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.IMPLICIT_CLASS, new JavaClassStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.ANONYMOUS_CLASS, new JavaClassStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.ENUM_CONSTANT_INITIALIZER, new JavaClassStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.METHOD, new JavaMethodStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.ANNOTATION_METHOD, new JavaMethodStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.FIELD, new JavaFieldStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.ENUM_CONSTANT, new JavaFieldStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.EXTENDS_LIST, new JavaClassReferenceListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.PERMITS_LIST, new JavaClassReferenceListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.IMPLEMENTS_LIST, new JavaClassReferenceListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.THROWS_LIST, new JavaClassReferenceListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.EXTENDS_BOUND_LIST, new JavaClassReferenceListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.PROVIDES_WITH_LIST, new JavaClassReferenceListStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.IMPORT_STATEMENT, new JavaImportStatementStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.IMPORT_STATIC_STATEMENT, new JavaImportStatementStubFactory()); + registry.registerLightStubFactory(JavaStubElementTypes.IMPORT_MODULE_STATEMENT, new JavaImportStatementStubFactory()); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/JrtVirtualFileSystem.java b/java-frontend/src/main/java/org/sonar/java/psi/JrtVirtualFileSystem.java new file mode 100644 index 00000000000..1e0f87696fb --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/JrtVirtualFileSystem.java @@ -0,0 +1,232 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.openapi.vfs.DeprecatedVirtualFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URLClassLoader; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.spi.FileSystemProvider; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class JrtVirtualFileSystem extends DeprecatedVirtualFileSystem implements AutoCloseable { + + private final String home; + private final FileSystem image; + private final URLClassLoader loader; + private final Map files = new HashMap<>(); + + JrtVirtualFileSystem(Path jdkHome) throws IOException { + home = jdkHome.toString(); + var providerJar = jdkHome.resolve("lib/jrt-fs.jar"); + if (!Files.isRegularFile(providerJar)) { + throw new IOException("Missing selected JDK provider: " + providerJar); + } + loader = new URLClassLoader(new java.net.URL[] {providerJar.toUri().toURL()}, ClassLoader.getPlatformClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.startsWith("jdk.internal.jrtfs.")) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + var loaded = findLoadedClass(name); + var result = loaded == null ? findClass(name) : loaded; + if (resolve) { + resolveClass(result); + } + return result; + } + } + }; + try { + var provider = (FileSystemProvider) loader.loadClass("jdk.internal.jrtfs.JrtFileSystemProvider").getConstructor().newInstance(); + image = provider.newFileSystem(URI.create("jrt:/"), Map.of("java.home", home)); + } catch (ReflectiveOperationException | IOException | RuntimeException | Error failure) { + try { + loader.close(); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + if (failure instanceof IOException io) { + throw io; + } + if (failure instanceof Error error) { + throw error; + } + throw new IllegalStateException("Cannot open selected JDK image: " + jdkHome, failure); + } + } + + List moduleRoots() throws IOException { + try (var modules = Files.list(image.getPath("/modules"))) { + return modules.sorted().map(this::file).toList(); + } + } + + private synchronized VirtualFile file(Path path) { + return files.computeIfAbsent(path, Entry::new); + } + + @Override + public String getProtocol() { + return "jrt"; + } + + @Override + public VirtualFile findFileByPath(String path) { + if (!path.startsWith(home + "!")) { + return null; + } + var entry = image.getPath(path.substring(home.length() + 1)); + return Files.exists(entry) ? file(entry) : null; + } + + @Override + public VirtualFile refreshAndFindFileByPath(String path) { + return findFileByPath(path); + } + + @Override + public void refresh(boolean asynchronous) { + } + + @Override + public void close() throws IOException { + files.clear(); + try { + image.close(); + } finally { + loader.close(); + } + } + + private final class Entry extends VirtualFile { + + private final Path path; + + private Entry(Path path) { + this.path = path; + } + + @Override + public String getName() { + var name = path.getFileName(); + return name == null ? "" : name.toString(); + } + + @Override + public JrtVirtualFileSystem getFileSystem() { + return JrtVirtualFileSystem.this; + } + + @Override + public String getPath() { + return home + "!" + path; + } + + @Override + public boolean isWritable() { + return false; + } + + @Override + public boolean isDirectory() { + return Files.isDirectory(path); + } + + @Override + public boolean isValid() { + return image.isOpen(); + } + + @Override + public VirtualFile getParent() { + return path.getParent() == null ? null : file(path.getParent()); + } + + @Override + public VirtualFile findChild(String name) { + if (name.isEmpty() || name.contains("/") || name.contains("\\") || name.equals(".") || name.equals("..")) { + return null; + } + var child = path.resolve(name); + return Files.exists(child) ? file(child) : null; + } + + @Override + public VirtualFile[] getChildren() { + if (!isDirectory()) { + return EMPTY_ARRAY; + } + try (var children = Files.list(path)) { + return children.map(JrtVirtualFileSystem.this::file).toArray(VirtualFile[]::new); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public long getTimeStamp() { + return 0; + } + + @Override + public long getModificationStamp() { + return 0; + } + + @Override + public long getLength() { + try { + return Files.size(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public InputStream getInputStream() throws IOException { + return Files.newInputStream(path); + } + + @Override + public byte[] contentsToByteArray() throws IOException { + return Files.readAllBytes(path); + } + + @Override + public OutputStream getOutputStream(Object requestor, long stamp, long time) throws IOException { + throw new IOException("JDK image is read-only"); + } + + @Override + public void refresh(boolean asynchronous, boolean recursive, Runnable postRunnable) { + if (postRunnable != null) { + postRunnable.run(); + } + } + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/PsiLanguageFeatures.java b/java-frontend/src/main/java/org/sonar/java/psi/PsiLanguageFeatures.java new file mode 100644 index 00000000000..737a5a3802f --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/PsiLanguageFeatures.java @@ -0,0 +1,154 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.psi.JavaRecursiveElementWalkingVisitor; +import com.intellij.psi.PsiCaseLabelElementList; +import com.intellij.psi.PsiClass; +import com.intellij.psi.PsiDeconstructionPattern; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiExpression; +import com.intellij.psi.PsiExpressionStatement; +import com.intellij.psi.PsiImplicitClass; +import com.intellij.psi.PsiImportModuleStatement; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiLiteralExpression; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiMethodCallExpression; +import com.intellij.psi.PsiModifier; +import com.intellij.psi.PsiParameter; +import com.intellij.psi.PsiPattern; +import com.intellij.psi.PsiSwitchExpression; +import com.intellij.psi.PsiSwitchLabeledRuleStatement; +import com.intellij.psi.PsiTypeTestPattern; +import com.intellij.psi.PsiVariable; +import com.intellij.psi.util.PsiTreeUtil; +import java.util.ArrayList; +import java.util.List; + +/** Collects syntax from PSI nodes, without interpreting comments or literal contents as code. */ +public final class PsiLanguageFeatures { + public enum Feature { + LOCAL_VAR, LAMBDA_VAR, SWITCH_EXPRESSION, SWITCH_RULE, TEXT_BLOCK, RECORD, + TYPE_PATTERN, RECORD_PATTERN, SWITCH_PATTERN, SEALED, UNNAMED_VARIABLE, + MODULE_IMPORT, FLEXIBLE_CONSTRUCTOR, COMPACT_SOURCE + } + + public record Occurrence(Feature feature, PsiElement element) { + } + + private PsiLanguageFeatures() { + } + + public static List collect(PsiJavaFile file) { + var result = new ArrayList(); + file.accept(new JavaRecursiveElementWalkingVisitor() { + private void add(Feature feature, PsiElement element) { + result.add(new Occurrence(feature, element)); + } + + @Override + public void visitClass(PsiClass declaration) { + if (declaration.isRecord()) { + add(Feature.RECORD, declaration); + } + if (declaration instanceof PsiImplicitClass) { + add(Feature.COMPACT_SOURCE, declaration); + } + if (declaration.hasModifierProperty(PsiModifier.SEALED)) { + add(Feature.SEALED, declaration); + } + super.visitClass(declaration); + } + + @Override + public void visitVariable(PsiVariable variable) { + var type = variable.getTypeElement(); + if (type != null && type.getText().equals("var")) { + add(variable instanceof PsiParameter ? Feature.LAMBDA_VAR : Feature.LOCAL_VAR, type); + } + if (variable.getNameIdentifier() != null && "_".equals(variable.getName())) { + add(Feature.UNNAMED_VARIABLE, variable.getNameIdentifier()); + } + super.visitVariable(variable); + } + + @Override + public void visitSwitchExpression(PsiSwitchExpression expression) { + add(Feature.SWITCH_EXPRESSION, expression); + super.visitSwitchExpression(expression); + } + + @Override + public void visitSwitchLabeledRuleStatement(PsiSwitchLabeledRuleStatement statement) { + add(Feature.SWITCH_RULE, statement); + super.visitSwitchLabeledRuleStatement(statement); + } + + @Override + public void visitLiteralExpression(PsiLiteralExpression expression) { + if (expression.isTextBlock()) { + add(Feature.TEXT_BLOCK, expression); + } + super.visitLiteralExpression(expression); + } + + @Override + public void visitTypeTestPattern(PsiTypeTestPattern pattern) { + add(Feature.TYPE_PATTERN, pattern); + super.visitTypeTestPattern(pattern); + } + + @Override + public void visitDeconstructionPattern(PsiDeconstructionPattern pattern) { + add(Feature.RECORD_PATTERN, pattern); + super.visitDeconstructionPattern(pattern); + } + + @Override + public void visitPattern(PsiPattern pattern) { + if (PsiTreeUtil.getParentOfType(pattern, PsiCaseLabelElementList.class, true, PsiExpression.class) != null) { + add(Feature.SWITCH_PATTERN, pattern); + } + super.visitPattern(pattern); + } + + @Override + public void visitImportModuleStatement(PsiImportModuleStatement statement) { + add(Feature.MODULE_IMPORT, statement); + super.visitImportModuleStatement(statement); + } + + @Override + public void visitMethod(PsiMethod method) { + var body = method.getBody(); + if (method.isConstructor() && body != null) { + var statements = body.getStatements(); + for (int index = 1; index < statements.length; index++) { + if (statements[index] instanceof PsiExpressionStatement statement + && statement.getExpression() instanceof PsiMethodCallExpression call + && ("super".equals(call.getMethodExpression().getReferenceName()) || "this".equals(call.getMethodExpression().getReferenceName()))) { + add(Feature.FLEXIBLE_CONSTRUCTOR, call); + } + } + } + super.visitMethod(method); + } + }); + return List.copyOf(result); + } +} diff --git a/java-frontend/src/main/java/org/sonar/java/psi/PsiLanguageValidator.java b/java-frontend/src/main/java/org/sonar/java/psi/PsiLanguageValidator.java new file mode 100644 index 00000000000..9939162435b --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/psi/PsiLanguageValidator.java @@ -0,0 +1,253 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.ide.highlighter.JavaFileType; +import com.intellij.pom.java.LanguageLevel; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.JavaTokenType; +import com.intellij.psi.PsiMember; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiParameterList; +import com.intellij.psi.PsiDeconstructionPattern; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiErrorElement; +import com.intellij.psi.PsiInstanceOfExpression; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiKeyword; +import com.intellij.psi.PsiManager; +import com.intellij.psi.PsiPrimitiveType; +import com.intellij.psi.PsiSwitchBlock; +import com.intellij.psi.PsiTypeElement; +import com.intellij.psi.PsiTypeTestPattern; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.psi.util.PsiUtil; +import com.intellij.testFramework.LightVirtualFile; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import org.sonar.java.psi.PsiLanguageFeatures.Feature; + +/** + * Applies source-level policy separately from raw parser errors. The result keeps + * fatal syntax failures distinct from diagnostics exposed on a converted unit. + * It is a bounded language validator, not a semantic diagnostic provider. + */ +public final class PsiLanguageValidator { + public enum Kind { + FATAL, UNDEFINED_TYPE, PREVIEW_FEATURE_USED + } + + public record Diagnostic(Kind kind, int start, int end, String message) { + } + + public record Result(List diagnostics, List rawSyntaxErrors) { + public Result { + diagnostics = List.copyOf(diagnostics); + rawSyntaxErrors = List.copyOf(rawSyntaxErrors); + } + + public boolean hasFatalError() { + return diagnostics.stream().anyMatch(diagnostic -> diagnostic.kind() == Kind.FATAL) || !rawSyntaxErrors.isEmpty(); + } + } + + private static final Set LEGACY_SWITCH_SELECTORS = Set.of( + "java.lang.String", "java.lang.Byte", "java.lang.Short", "java.lang.Character", "java.lang.Integer"); + + private PsiLanguageValidator() { + } + + public static Result validate(PsiJavaFile file, JavaPsiConfiguration configuration) { + var input = new LightVirtualFile(file.getName(), JavaFileType.INSTANCE, file.getText()); + var syntax = (PsiJavaFile) PsiManager.getInstance(file.getProject()).findFile(input); + syntax.putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, LanguageLevel.JDK_26_PREVIEW); + int version = configuration.sourceVersion(); + var diagnostics = new ArrayList(); + for (var occurrence : PsiLanguageFeatures.collect(syntax)) { + var element = occurrence.element(); + switch (occurrence.feature()) { + case LOCAL_VAR, LAMBDA_VAR -> { + if (version < 10) { + var reference = ((PsiTypeElement) element).getInnermostComponentReferenceElement(); + if (reference == null || reference.resolve() == null) { + add(diagnostics, Kind.UNDEFINED_TYPE, element, "var cannot be resolved to a type"); + } + } else if (version == 10 && occurrence.feature() == Feature.LAMBDA_VAR) { + add(diagnostics, Kind.FATAL, element, "'var' is not allowed here"); + } + } + case SWITCH_EXPRESSION -> { + if (version < 14) { + add(diagnostics, Kind.FATAL, element, "Switch Expressions are supported from Java 14 onwards only"); + } + } + case SWITCH_RULE -> { + if (version < 14) { + add(diagnostics, Kind.FATAL, element, "Arrow in case statement supported from Java 14 onwards only"); + } + } + case TEXT_BLOCK -> feature(diagnostics, element, version, 15, "Text Blocks"); + case RECORD -> { + if (version < 16 && element.getParent() instanceof PsiJavaFile) { + feature(diagnostics, element, version, 25, "Compact Source Files and Instance Main Methods"); + add(diagnostics, Kind.PREVIEW_FEATURE_USED, element, "Implicitly declared class must have a candidate main method"); + } + } + case TYPE_PATTERN -> { + feature(diagnostics, element, version, 16, "Type Patterns"); + var pattern = (PsiTypeTestPattern) element; + var variable = pattern.getPatternVariable(); + if ((!configuration.previewEnabled() || version < 26) && variable != null + && variable.getType() instanceof PsiPrimitiveType primitive + && pattern.getParent() instanceof PsiInstanceOfExpression expression + && expression.getOperand().getType() instanceof PsiClassType operand && operand.resolve() != null) { + add(diagnostics, Kind.PREVIEW_FEATURE_USED, expression, + "Incompatible conditional operand types " + operand.getPresentableText() + " and " + primitive.getPresentableText()); + } + } + case RECORD_PATTERN -> { + feature(diagnostics, element, version, 21, "Record Pattern"); + var original = PsiTreeUtil.getParentOfType(file.findElementAt(element.getTextOffset()), PsiDeconstructionPattern.class, false); + if (original != null) { + var reference = original.getTypeElement().getInnermostComponentReferenceElement(); + if (reference != null && reference.getQualifier() == null && reference.resolve() == null) { + add(diagnostics, Kind.UNDEFINED_TYPE, element, reference.getReferenceName() + " cannot be resolved to a type"); + } + } + } + case SWITCH_PATTERN -> { + feature(diagnostics, element, version, 21, "Pattern Matching in Switch"); + var block = PsiTreeUtil.getParentOfType(element, PsiSwitchBlock.class); + if (version < 21 && block != null && block.getExpression() != null + && block.getExpression().getType() instanceof PsiClassType type && !LEGACY_SWITCH_SELECTORS.contains(type.rawType().getCanonicalText())) { + var resolved = type.resolve(); + if (resolved != null && !resolved.isEnum()) { + add(diagnostics, Kind.PREVIEW_FEATURE_USED, block.getExpression(), + "Cannot switch on a value of type " + type.getPresentableText() + ". Only convertible int values, strings or enum variables are permitted"); + } + } + } + case SEALED -> { + if (version < 17) { + var keyword = PsiTreeUtil.getChildrenOfTypeAsList(element, PsiKeyword.class).stream() + .filter(token -> token.getText().equals("class")).findFirst().orElse(null); + add(diagnostics, Kind.FATAL, keyword == null ? element : keyword, "Syntax error on token \"class\", . expected"); + } + } + case UNNAMED_VARIABLE -> { + if (version >= 9 && version < 22) { + add(diagnostics, Kind.FATAL, element, "'_' is a keyword from source level 9 onwards, cannot be used as identifier"); + } + } + case MODULE_IMPORT -> feature(diagnostics, element, version, 25, "Module Import Declarations"); + case FLEXIBLE_CONSTRUCTOR -> feature(diagnostics, element, version, 25, "Flexible Constructor Bodies"); + case COMPACT_SOURCE -> feature(diagnostics, element, version, 25, "Compact Source Files and Instance Main Methods"); + } + } + diagnostics.sort(Comparator.comparingInt(Diagnostic::start)); + var syntaxErrors = PsiTreeUtil.findChildrenOfType(syntax, PsiErrorElement.class).stream() + .filter(error -> !recoveredResource(error) && !recoveredRequires(error)) + .map(PsiLanguageValidator::syntaxError) + .toList(); + return new Result(diagnostics, syntaxErrors); + } + + /** C-style resource dimensions are accepted by the reference parser and converted as a recovered local declaration. */ + private static boolean recoveredRequires(PsiErrorElement error) { + var requires = PsiTreeUtil.getParentOfType(error, com.intellij.psi.PsiRequiresStatement.class); + return requires != null && requires.getModuleReference() == null && requires.hasModifierProperty("transitive") + && "Identifier expected".equals(error.getErrorDescription()); + } + + private static boolean recoveredResource(PsiErrorElement error) { + var resources = PsiTreeUtil.getParentOfType(error, com.intellij.psi.PsiResourceList.class); + if (resources == null || PsiTreeUtil.findChildrenOfType(resources, com.intellij.psi.PsiResourceVariable.class).stream() + .noneMatch(variable -> variable.getInitializer() == null && variable.getNameIdentifier() != null + && resources.getText().substring(variable.getNameIdentifier().getTextRange().getEndOffset() - resources.getTextRange().getStartOffset()).stripLeading().startsWith("["))) { + return false; + } + String text = resources.getText(); + if (!text.endsWith(")")) { + return false; + } + var recovered = com.intellij.psi.JavaPsiFacade.getElementFactory(error.getProject()) + .createCodeBlockFromText("{" + text.substring(1, text.length() - 1) + ";}", resources); + return !PsiTreeUtil.hasErrorElements(recovered); + } + + private static Diagnostic syntaxError(PsiErrorElement error) { + var parameters = PsiTreeUtil.getParentOfType(error, PsiParameterList.class); + if (parameters != null && parameters.getNode().findChildByType(JavaTokenType.RPARENTH) == null && parameters.getParent() instanceof PsiMethod method + && method.getBody() == null && method.getContainingClass() != null + && method.getContainingClass().getParent() instanceof PsiJavaFile) { + PsiElement anchor = method.getContainingClass().getLBrace(); + for (var previous = method.getPrevSibling(); previous != null; previous = previous.getPrevSibling()) { + if (previous instanceof PsiMember) { + anchor = previous; + break; + } + } + if (anchor != null) { + int position = anchor.getTextRange().getEndOffset() - 1; + return new Diagnostic(Kind.FATAL, position, position, "Syntax error, insert \"}\" to complete ClassBody"); + } + } + var declaration = PsiTreeUtil.getParentOfType(error, com.intellij.psi.PsiClass.class); + if (declaration != null && declaration.getLBrace() == null && declaration.getNameIdentifier() != null + && error.getErrorDescription().equals("'{' expected")) { + int start = declaration.getNameIdentifier().getTextRange().getEndOffset() - 1; + return new Diagnostic(Kind.FATAL, start, start, "Syntax error, insert \"ClassBody\" to complete CompilationUnit"); + } + var previous = PsiTreeUtil.prevLeaf(error); + while (previous instanceof com.intellij.psi.PsiWhiteSpace || previous instanceof com.intellij.psi.PsiComment) { + previous = PsiTreeUtil.prevLeaf(previous); + } + if (previous != null) { + int position = previous.getTextRange().getEndOffset() - 1; + if (error.getErrorDescription().equals("'}' expected") && error.getParent() instanceof com.intellij.psi.PsiClass) { + return new Diagnostic(Kind.FATAL, position, position, "Syntax error, insert \"}\" to complete ClassBody"); + } + if (error.getErrorDescription().equals("';' expected")) { + boolean field = PsiTreeUtil.getParentOfType(error, com.intellij.psi.PsiField.class) != null; + if (Set.of("return", "break", "continue").contains(previous.getText())) { + position = previous.getTextRange().getStartOffset(); + } + return new Diagnostic(Kind.FATAL, position, position, + "Syntax error, insert \";\" to complete " + (field ? "ClassBodyDeclarations" : "BlockStatements")); + } + if (error.getErrorDescription().equals("Expression expected") && previous.getText().equals("=") + && PsiTreeUtil.getParentOfType(error, com.intellij.psi.PsiVariable.class) != null) { + return new Diagnostic(Kind.FATAL, position, position, "Syntax error on token \"=\", VariableInitializer expected after this token"); + } + } + int start = error.getTextRange().getStartOffset(); + return new Diagnostic(Kind.FATAL, start, Math.max(start, error.getTextRange().getEndOffset() - 1), error.getErrorDescription()); + } + + private static void feature(List diagnostics, PsiElement element, int version, int minimum, String name) { + if (version < minimum) { + add(diagnostics, Kind.PREVIEW_FEATURE_USED, element, + "The Java feature '" + name + "' is only available with source level " + minimum + " and above"); + } + } + + private static void add(List diagnostics, Kind kind, PsiElement element, String message) { + diagnostics.add(new Diagnostic(kind, element.getTextRange().getStartOffset(), element.getTextRange().getEndOffset() - 1, message)); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/model/PsiDiagnosticProviderTest.java b/java-frontend/src/test/java/org/sonar/java/model/PsiDiagnosticProviderTest.java new file mode 100644 index 00000000000..0f9171a3148 --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/model/PsiDiagnosticProviderTest.java @@ -0,0 +1,234 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import com.sonar.sslr.api.RecognitionException; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sonar.java.psi.JavaPsiConfiguration; +import org.sonar.java.psi.JavaPsiEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PsiDiagnosticProviderTest { + @ParameterizedTest + @MethodSource("warnings") + void warning_kinds_preserve_import_cast_assignment_and_catch_behavior(String source, List expected) { + assertThat(warnings("C.java", source, List.of())).extracting(JProblem::type).containsExactlyInAnyOrderElementsOf(expected); + } + + static Stream warnings() { + return Stream.of( + Arguments.of("package test; import java.util.List; import test.C; import java.lang.Object; class C { void f(String s) { String o = (String) ((String) s); } }", List.of(JProblem.Type.UNUSED_IMPORT, JProblem.Type.UNUSED_IMPORT, JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST)), + Arguments.of("import java.util.*; import static java.util.Collections.*; class C { Object f() { return emptyList(); } }", List.of(JProblem.Type.UNUSED_IMPORT)), + Arguments.of("import java.util.List; import java.util.List; class C { List x; }", List.of(JProblem.Type.UNUSED_IMPORT)), + Arguments.of("import java.util.List; import java.util.Set; /** {@link List} */ class C {}", List.of(JProblem.Type.UNUSED_IMPORT)), + Arguments.of("import missing.Type; import java.util.List; class C { Type x; }", List.of()), + Arguments.of("import java.util.List; import java.util.List; class C {}", List.of(JProblem.Type.UNUSED_IMPORT, JProblem.Type.UNUSED_IMPORT)), + Arguments.of("import java.util.*; import java.util.List; class C { List x; }", List.of(JProblem.Type.UNUSED_IMPORT)), + Arguments.of("import static java.util.Collections.emptyList; import static java.util.Collections.emptySet; class C { Object x=emptyList(); }", List.of(JProblem.Type.UNUSED_IMPORT)), + Arguments.of("import java.util.List; /** @see List#size() */ class C {}", List.of()), + Arguments.of("import java.util.List; /** Just List text */ class C {}", List.of(JProblem.Type.UNUSED_IMPORT)), + Arguments.of("import module java.sql; class C {}", List.of(JProblem.Type.UNUSED_IMPORT)), + Arguments.of("class C { T f(T t) { return (T)t; } }", List.of(JProblem.Type.REDUNDANT_CAST)), + Arguments.of("class C { static class R implements AutoCloseable { public void close() throws E {} } void f() { try (R r = new R<>()) {} catch (java.io.FileNotFoundException e) {} catch (java.io.IOException e) {} } }", List.of(JProblem.Type.MASKED_CATCH)), + Arguments.of("class C { void f() { try { try { throw new java.io.FileNotFoundException(); } finally {} } catch (java.io.FileNotFoundException e) {} catch (java.io.IOException e) {} } }", List.of(JProblem.Type.MASKED_CATCH)), + Arguments.of("class C { T id(T t) { return t; } Object f(String s) { return id((Object)s); } }", List.of()), + Arguments.of("class C { T id(T t) { return t; } String f(String s) { return id((String)s); } }", List.of(JProblem.Type.REDUNDANT_CAST)), + Arguments.of("class C { void f(Object o, int i) {} void g(Integer i) { f((Object)i, (int)i); } }", List.of(JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST)), + Arguments.of("class C { static int s; int x; C f() { return this; } void g(C c) { c.s=c.s; f().s=s; this.s=s; C.s=C.s; f().x=f().x; } }", List.of(JProblem.Type.ASSIGNMENT_HAS_NO_EFFECT, JProblem.Type.ASSIGNMENT_HAS_NO_EFFECT)), + Arguments.of("class C { int x; class D extends C { void f() { C.this.x=this.x; this.x=C.this.x; C.this.x=C.this.x; } } }", List.of()), + Arguments.of("class Élève {\r\n void f(String s) { String o = (Str\\u0069ng)s; o = (String)s; } }", List.of(JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST)), + Arguments.of("class C { volatile int x; void f() { x=x; } }", List.of()), + Arguments.of("class C { void f(Object... values) {} void g(String[] s) { f((Object)s); f((Object[])s); } }", List.of()), + Arguments.of("class C { Missing f(Missing a) { return (Missing)a; } }", List.of()), + Arguments.of("class C { Object f(String s) { return (Object) s; } void f(Object o) {} void f(String s) {} void g(String s) { f((Object)s); } }", List.of(JProblem.Type.REDUNDANT_CAST)), + Arguments.of("class C { int f(int i, long l) { return (int)i + (int)l; } long g(int i,long l) { return (long)i + l; } }", List.of(JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST)), + Arguments.of("class C { java.util.function.Supplier s = (java.util.function.Supplier) () -> \"a\"; java.util.function.Supplier o=(java.util.function.Supplier) Object::new; }", List.of()), + Arguments.of("class C { void f() { try { throw new java.io.IOException(); } catch (Exception e) {} catch (java.io.IOException e) {} } }", List.of()), + Arguments.of("class C { int x; static int s; void f(C c, int x, int[] a) { x=x; this.x=x; this.x=this.x; c.x=c.x; s=C.s; a[0]=a[0]; } }", List.of(JProblem.Type.ASSIGNMENT_HAS_NO_EFFECT, JProblem.Type.ASSIGNMENT_HAS_NO_EFFECT, JProblem.Type.ASSIGNMENT_HAS_NO_EFFECT)), + Arguments.of("class C { void f(Object o, String s, int i, Integer n) { Object a=(Object)s; String b=(String)s; int j=(int)i; long k=(long)i; Integer m=(Integer)i; int p=(int)n; } }", List.of(JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST, JProblem.Type.REDUNDANT_CAST)), + Arguments.of("class C { void f(Object o) {} void f(String s) {} void g(String s) { f((Object)s); f((String)s); } }", List.of(JProblem.Type.REDUNDANT_CAST)), + Arguments.of("class C { void f() { try { throw new java.io.FileNotFoundException(); } catch (java.io.FileNotFoundException e) {} catch (java.io.IOException e) {} } }", List.of(JProblem.Type.MASKED_CATCH)), + Arguments.of("class C { void f() { try { throw new java.io.IOException(); } catch (java.io.IOException e) {} catch (Exception e) {} } }", List.of()) + ); + } + + @Test + void warning_messages_and_positions_refer_to_the_original_source() { + String source = "class C {\n void f(String s) { String o = (String)s; }\n}"; + var warnings = warnings("C.java", source, List.of()); + assertThat(warnings).hasSize(1); + var warning = warnings.getFirst(); + assertThat(warning.type()).isEqualTo(JProblem.Type.REDUNDANT_CAST); + assertThat(warning.message()).isEqualTo("Unnecessary cast from String to String"); + assertThat(warning.start().line()).isEqualTo(2); + assertThat(warning.start().columnOffset()).isEqualTo(31); + assertThat(warning.syntaxTree().kind()).isEqualTo(org.sonar.plugins.java.api.tree.Tree.Kind.TYPE_CAST); + assertThat(warning.syntaxTree().firstToken().text()).isEqualTo("("); + } + + @ParameterizedTest + @MethodSource("malformed") + void malformed_input_keeps_error_location_and_message(String source, int line, String message) { + assertThatThrownBy(() -> problems(source, 26, false)).isInstanceOfSatisfying(RecognitionException.class, failure -> { + assertThat(failure.getLine()).isEqualTo(line); + assertThat(failure.getMessage()).isEqualTo(message); + }); + } + + static Stream malformed() { + return Stream.of( + Arguments.of("class C", 1, "Parse error at line 1 column 7: Syntax error, insert \"ClassBody\" to complete CompilationUnit"), + Arguments.of("class C {", 1, "Parse error at line 1 column 9: Syntax error, insert \"}\" to complete ClassBody"), + Arguments.of("class C { void f() { }", 1, "Parse error at line 1 column 22: Syntax error, insert \"}\" to complete ClassBody"), + Arguments.of("class C { int x }", 1, "Parse error at line 1 column 15: Syntax error, insert \";\" to complete ClassBodyDeclarations"), + Arguments.of("class C { void f() { int x = ; } }", 1, "Parse error at line 1 column 28: Syntax error on token \"=\", VariableInitializer expected after this token"), + Arguments.of("class C { void f() { return } }", 1, "Parse error at line 1 column 22: Syntax error, insert \";\" to complete BlockStatements"), + Arguments.of("import a; ; import b;", 1, "Parse error at line 1 column 13: Syntax error on token \"import\", int expected"), + Arguments.of("class C { char c = ''; }", 1, "Parse error at line 1 column 20: Invalid character constant"), + Arguments.of("class C { void m( }", 1, "Parse error at line 1 column 9: Syntax error, insert \"}\" to complete ClassBody"), + Arguments.of("class C { void good() {} void bad( }", 1, "Parse error at line 1 column 24: Syntax error, insert \"}\" to complete ClassBody"), + Arguments.of("class C { void m(int x }", 1, "Parse error at line 1 column 9: Syntax error, insert \"}\" to complete ClassBody"), + Arguments.of("class Élève {\r\n void méthode( }", 1, "Parse error at line 1 column 13: Syntax error, insert \"}\" to complete ClassBody"), + Arguments.of("class C { void m(", 1, "Parse error at line 1 column 9: Syntax error, insert \"}\" to complete ClassBody") + ); + } + + @ParameterizedTest + @MethodSource("missingTypes") + void unresolved_code_keeps_recoverable_problem_kinds_and_messages(String source, List expected) { + assertThat(problems(source, 26, false)).containsExactlyInAnyOrderElementsOf(expected); + } + + static Stream missingTypes() { + return Stream.of( + Arguments.of("interface Foo { public foo(); }", List.of(new JProblem("Return type for the method is missing", JProblem.Type.PREVIEW_FEATURE_USED))), + Arguments.of("import org.foo.Bar; class C { void f(Bar b) {} }", List.of(new JProblem("Bar cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE), new JProblem("The import org.foo cannot be resolved", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { Missing x; Missing y; }", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("import java.util.List; class C { Missing x; }", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("import java.util.Missing; class C {}", List.of(new JProblem("The import java.util.Missing cannot be resolved", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { Missing.Type x; }", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C extends Missing {}", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C implements Missing {}", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("@Missing class C {}", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C {}", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { void f() throws Missing {} }", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { Missing x; }", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { void f() { Missing.m(); } }", List.of(new JProblem("Missing cannot be resolved", JProblem.Type.PREVIEW_FEATURE_USED))), + Arguments.of("class C { Object x=new Missing(); }", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { class D implements Missing {} }", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("package p; class C implements Missing {}", List.of(new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { interface I extends Missing {} class D implements I {} }", List.of(new JProblem("The hierarchy of the type D is inconsistent", JProblem.Type.PREVIEW_FEATURE_USED), new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { Missing[] values = {new C()}; }", List.of(new JProblem("Type mismatch: cannot convert from C to Missing", JProblem.Type.PREVIEW_FEATURE_USED), new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))), + Arguments.of("class C { Object[] values = java.util.stream.Stream.of(1).map(x -> new Missing()).toArray(); }", List.of(new JProblem("Cannot infer type argument(s) for map(Function)", JProblem.Type.PREVIEW_FEATURE_USED), new JProblem("Missing cannot be resolved to a type", JProblem.Type.UNDEFINED_TYPE))) + ); + } + + @Test + void missing_interface_return_type_keeps_its_recovery_failure() { + assertThatThrownBy(() -> problems("interface Foo { public foo(); // comment\n }", 26, false)) + .isInstanceOf(IndexOutOfBoundsException.class).hasMessage("Missing method return type"); + } + + @ParameterizedTest + @MethodSource("features") + void source_levels_gate_language_features(String source, int minimum, List rejectedLevels, JProblem.Type kind) { + for (int version : List.of(9, 10, 11, 14, 15, 16, 17, 20, 21, 24, 25, 26)) { + if (rejectedLevels.contains(version)) { + assertThatThrownBy(() -> problems(source, version, false)).isInstanceOf(RecognitionException.class); + } else if (version < minimum) { + assertThat(problems(source, version, false)).extracting(JProblem::type).contains(kind); + } else { + assertThat(problems(source, version, false)).as("Java %s: %s", version, source).isEmpty(); + } + } + } + + static Stream features() { + return Stream.of( + Arguments.of("class C { void f() { var x = 1; } }", 10, List.of(), JProblem.Type.UNDEFINED_TYPE), + Arguments.of("class C { java.util.function.IntUnaryOperator f = (var x) -> x; }", 11, List.of(10), JProblem.Type.UNDEFINED_TYPE), + Arguments.of("class C { String s = \"\"\"\ntext\n\"\"\"; }", 15, List.of(), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("class C { boolean f(Object o) { return o instanceof String s; } }", 16, List.of(), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("class C { record R(int i) {} boolean f(Object o) { return o instanceof R(int i); } }", 21, List.of(), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("class C { int f(Object o) { return switch(o) { case String s -> 1; default -> 0; }; } }", 21, List.of(9, 10, 11), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("sealed class C permits D {} final class D extends C {}", 17, List.of(9, 10, 11, 14, 15, 16), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("class C { void f() { int _ = 1; } }", 22, List.of(9, 10, 11, 14, 15, 16, 17, 20, 21), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("import module java.sql; class C {}", 25, List.of(), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("class C { C() { int i = 1; super(); } }", 25, List.of(), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("void main() {}", 25, List.of(), JProblem.Type.PREVIEW_FEATURE_USED), + Arguments.of("record C(int x) {}", 16, List.of(), JProblem.Type.PREVIEW_FEATURE_USED)); + } + + @ParameterizedTest + @ValueSource(ints = {9, 10, 11, 14, 15, 16, 17, 20, 21, 24, 25, 26}) + void primitive_patterns_require_preview(int version) { + String source = "class C { boolean f(Object o) { return o instanceof int i; } }"; + assertThat(problems(source, version, false)).extracting(JProblem::type).contains(JProblem.Type.PREVIEW_FEATURE_USED); + assertThat(problems(source, 26, true)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {7, 22, 26}) + void repository_sensor_source_reports_missing_dependencies_without_failing(int version) throws IOException { + String source = Files.readString(Path.of("../sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java")); + assertThat(problems(source, version, false)).extracting(JProblem::type).contains(JProblem.Type.UNDEFINED_TYPE); + } + + private static List problems(String source, int version, boolean preview) { + var configuration = new JavaPsiConfiguration(null, List.of(), version, preview, false); + var input = new JavaPsiEnvironment.SourceSnapshot(Path.of("C.java"), PsiTokenIndex.unicodeInput(source).text(), StandardCharsets.UTF_8); + try (var environment = JavaPsiEnvironment.open(configuration, List.of(input))) { + return environment.read((project, files) -> { + var diagnostics = PsiDiagnosticProvider.create(files.getFirst(), configuration, source); + var tree = new JavaTree.CompilationUnitTreeImpl(null, List.of(), List.of(), null, new InternalSyntaxToken(1, 0, "", List.of(), true)); + diagnostics.attach(tree); + return List.copyOf(tree.undefinedTypes()); + }); + } + } + + private static List warnings(String name, String source, List classpath) { + var configuration = new JavaPsiConfiguration(null, classpath.stream().map(File::toPath).toList(), 26, false, false); + var input = new JavaPsiEnvironment.SourceSnapshot(Path.of(name), PsiTokenIndex.unicodeInput(source).text(), StandardCharsets.UTF_8); + try (var environment = JavaPsiEnvironment.open(configuration, List.of(input)); + var parsed = FrontendParser.parse(JParserConfig.MAXIMUM_SUPPORTED_JAVA_VERSION, classpath, name, source)) { + return environment.read((project, files) -> { + var tree = parsed.tree(); + PsiDiagnosticProvider.create(files.getFirst(), configuration, source).attach(tree); + List result = new ArrayList<>(); + for (var kind : JProblem.Type.values()) { + result.addAll(tree.warnings(kind)); + } + return result; + }); + } + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/model/PsiTokenIndexTest.java b/java-frontend/src/test/java/org/sonar/java/model/PsiTokenIndexTest.java new file mode 100644 index 00000000000..14eb23c8c57 --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/model/PsiTokenIndexTest.java @@ -0,0 +1,124 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sonar.java.psi.JavaPsiConfiguration; +import org.sonar.java.psi.JavaPsiEnvironment; +import org.sonar.plugins.java.api.tree.SyntaxTrivia; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PsiTokenIndexTest { + @ParameterizedTest + @MethodSource("sources") + void physical_tokens_preserve_original_utf16_slices(String source) { + var index = index(source); + int previousEnd = 0; + for (var lexeme : index.lexemes()) { + assertThat(lexeme.start()).isGreaterThanOrEqualTo(previousEnd); + assertThat(lexeme.token().text()).isEqualTo(source.substring(lexeme.start(), lexeme.end())); + String prefix = source.substring(0, lexeme.start()); + assertThat(lexeme.token().line()).isEqualTo(prefix.split("\\r\\n?|\\n", -1).length); + int lineStart = Math.max(prefix.lastIndexOf('\n'), prefix.lastIndexOf('\r')) + 1; + assertThat(lexeme.token().range().start().columnOffset()).isEqualTo(lexeme.start() - lineStart); + previousEnd = lexeme.end(); + } + assertThat(index.eof().isEOF()).isTrue(); + assertThat(previousEnd).isEqualTo(source.length()); + } + + @Test + void operators_keep_physical_spelling_and_combined_shifts() { + assertThat(index("a>>b; a>>>b; a>>=b; a>>>=b;").lexemes().stream().map(lexeme -> lexeme.token().text())) + .containsExactly("a", ">>", "b", ";", "a", ">>>", "b", ";", "a", ">>=", "b", ";", "a", ">>>=", "b", ";", ""); + } + + @Test + void unicode_identifiers_keep_their_raw_ranges() { + String source = "cl" + "\\" + "u0061ss A {}"; + var token = index(source).lexemes().getFirst().token(); + assertThat(token.text()).isEqualTo("cl" + "\\" + "u0061ss"); + assertThat(token.range().start()).isEqualTo(org.sonar.plugins.java.api.location.Position.at(1, 1)); + assertThat(token.range().end()).isEqualTo(org.sonar.plugins.java.api.location.Position.at(1, 11)); + } + + @Test + void markdown_groups_keep_indentation_and_eof_comments() { + var index = index("/// first\n\t/// second\nclass A {} // eof"); + var trivia = index.lexemes().getFirst().token().trivias(); + assertThat(trivia).hasSize(1); + assertThat(trivia.getFirst().commentKind()).isEqualTo(SyntaxTrivia.CommentKind.MARKDOWN); + assertThat(trivia.getFirst().comment()).isEqualTo("/// first\n\t/// second"); + assertThat(index.eof().trivias()).extracting(SyntaxTrivia::comment).containsExactly("// eof"); + } + + static Stream sources() { + String escape = "\\" + "u"; + return Stream.of( + "", " \t\r\n", "\"\"\"text\"\"\"", "/// first\n\n/// second", "/**/ /** doc */ /*block*/ // line\r\nclass A {} // eof", + "/// first\n/// second\nclass A {}", "/// one\r\n\t/// two\r/// three\n", + "class A {\r\n\tint 𐐀;\rString x=\"😀\";\n}", + "class A { String x=\"\"\"\r\n abc\\\r\n def\r\n \"\"\"; }", + "int x=0xCA_FE; long y=0b11L; double a=.25e+2, b=0x1.fp-2; float f=1f; char c='\\n';", + "a>>b; a>>>b; a>>=b; a>>>=b; a< index(source)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void contextual_closers_share_slices_without_duplicating_trivia() { + var index = index("/* c */ >>>"); + var first = index.slice(8, 9); + assertThat(first.text()).isEqualTo(">"); + assertThat(first.trivias()).hasSize(1); + assertThat(index.slice(8, 9)).isSameAs(first); + assertThat(index.slice(9, 10).trivias()).isEmpty(); + assertThat(index.slice(10, 11).trivias()).isEmpty(); + assertThat(index.eof().isEOF()).isTrue(); + } + + @Test + void escaped_comment_delimiters_preserve_the_existing_rejection() { + String source = "\\" + "u002f\\" + "u002a comment */ class A {}"; + assertThatThrownBy(() -> index(source)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid comment kind"); + } + + private static PsiTokenIndex index(String source) { + var config = new JavaPsiConfiguration(null, List.of(), 26, true, true); + try (var environment = JavaPsiEnvironment.open(config, List.of(new JavaPsiEnvironment.SourceSnapshot(Path.of("File.java"), source, StandardCharsets.UTF_8)))) { + return environment.read((project, files) -> new PsiTokenIndex(source)); + } + } + +} diff --git a/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiClasspathTest.java b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiClasspathTest.java new file mode 100644 index 00000000000..ed60d286f0c --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiClasspathTest.java @@ -0,0 +1,178 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.openapi.progress.EmptyProgressIndicator; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiMethodCallExpression; +import com.intellij.psi.util.PsiTreeUtil; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class JavaPsiClasspathTest { + + @TempDir + Path temporary; + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void resolves_a_generic_method_from_a_dependency_jar_or_directory(boolean archive) throws IOException, InterruptedException { + var library = library("generic", "public static T identity(T value) { return value; }", archive); + var caller = Files.writeString(temporary.resolve("Example.java"), """ + class Example { String value = library.Dependency.identity("value"); } + """); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(caller), List.of(library), 21)) { + environment.read((project, files) -> { + var call = PsiTreeUtil.findChildOfType(files.getFirst(), PsiMethodCallExpression.class); + assertNotNull(call); + var result = call.resolveMethodGenerics(); + assertTrue(result.isValidResult()); + var method = (PsiMethod) result.getElement(); + assertNotNull(method); + assertEquals("library.Dependency", method.getContainingClass().getQualifiedName()); + assertEquals("java.lang.String", result.getSubstitutor().substitute(method.getTypeParameters()[0]).getCanonicalText()); + assertEquals("java.lang.String", call.getType().getCanonicalText()); + assertTrue(method.getContainingFile().getVirtualFile().getPath().startsWith(library.toString())); + return null; + }); + } + if (archive) { + Files.delete(library); + } + } + + @Test + void preserves_classpath_order_when_dependencies_contain_the_same_class() throws IOException, InterruptedException { + var first = library("first", "public static String value() { return null; }", true); + var second = library("second", "public static Integer value() { return null; }", true); + var caller = Files.writeString(temporary.resolve("Example.java"), """ + class Example { Object value = library.Dependency.value(); } + """); + assertEquals("java.lang.String", callType(caller, List.of(first, second))); + assertEquals("java.lang.Integer", callType(caller, List.of(second, first))); + Files.delete(first); + Files.delete(second); + } + + @Test + void explicit_source_wins_over_a_binary_with_the_same_name() throws IOException, InterruptedException { + var binary = library("binary", "public static Integer value() { return null; }", true); + var declaration = Files.writeString(temporary.resolve("Dependency.java"), + "package library; public class Dependency { public static String value() { return null; } }"); + var caller = Files.writeString(temporary.resolve("Example.java"), "class Example { Object value = library.Dependency.value(); }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(caller, declaration), List.of(binary), 21)) { + environment.read((project, files) -> { + var call = PsiTreeUtil.findChildOfType(files.getFirst(), PsiMethodCallExpression.class); + assertNotNull(call); + assertEquals("java.lang.String", call.getType().getCanonicalText()); + org.junit.jupiter.api.Assertions.assertSame(files.get(1), call.resolveMethod().getContainingFile()); + return null; + }); + } + Files.delete(binary); + } + + @Test + void replacing_a_dependency_after_disposal_exposes_the_new_binary() throws IOException, InterruptedException { + var binary = library("replace", "public static String value() { return null; }", true); + var caller = Files.writeString(temporary.resolve("Example.java"), "class Example { Object value = library.Dependency.value(); }"); + assertEquals("java.lang.String", callType(caller, List.of(binary))); + Files.delete(binary); + assertEquals(binary, library("replace", "public static Integer value() { return null; }", true)); + assertEquals("java.lang.Integer", callType(caller, List.of(binary))); + Files.delete(binary); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void releases_loaded_archives_after_callback_failure_or_cancellation(boolean canceled) throws Exception { + var binary = library("failure", "public static String value() { return null; }", true); + var caller = Files.writeString(temporary.resolve("Example.java"), "class Example { Object value = library.Dependency.value(); }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(caller), List.of(binary), 21)) { + var indicator = new EmptyProgressIndicator(); + Class expected = canceled ? ProcessCanceledException.class : IllegalStateException.class; + assertThrows(expected, + () -> ProgressManager.getInstance().runProcess(() -> environment.read((project, files) -> { + var call = PsiTreeUtil.findChildOfType(files.getFirst(), PsiMethodCallExpression.class); + assertNotNull(call); + assertEquals("java.lang.String", call.getType().getCanonicalText()); + if (canceled) { + indicator.cancel(); + ProgressManager.checkCanceled(); + } + throw new IllegalStateException("Callback failed after loading a dependency"); + }), indicator)); + } + Files.delete(binary); + assertEquals(binary, library("failure", "public static Integer value() { return null; }", true)); + assertEquals("java.lang.Integer", callType(caller, List.of(binary))); + Files.delete(binary); + } + + private String callType(Path caller, List classpath) { + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(caller), classpath, 21)) { + return environment.read((project, files) -> { + var call = PsiTreeUtil.findChildOfType(files.getFirst(), PsiMethodCallExpression.class); + assertNotNull(call); + assertTrue(call.resolveMethodGenerics().isValidResult()); + return call.getType().getCanonicalText(); + }); + } + } + + private Path library(String name, String method, boolean archive) throws IOException, InterruptedException { + var root = Files.createDirectories(temporary.resolve(name)); + var source = Files.writeString(root.resolve("Dependency.java"), "package library; public class Dependency { " + method + " }"); + var classes = Files.createDirectories(root.resolve("classes")); + var executable = System.getProperty("os.name").startsWith("Windows") ? "javac.exe" : "javac"; + var compiler = Path.of(System.getProperty("java.home"), "bin", executable); + var process = new ProcessBuilder(compiler.toString(), "--release", "17", "-d", classes.toString(), source.toString()) + .redirectErrorStream(true).start(); + var compilerOutput = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.waitFor(), compilerOutput); + if (!archive) { + return classes; + } + var jar = root.resolve("dependency.jar"); + try (var output = new JarOutputStream(Files.newOutputStream(jar))) { + output.putNextEntry(new JarEntry("library/Dependency.class")); + Files.copy(classes.resolve("library/Dependency.class"), output); + output.closeEntry(); + } + return jar; + } + + private static Path jdkHome() { + return Path.of(System.getProperty("psi.test.jdkHome", System.getProperty("java.home"))).toAbsolutePath().normalize(); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiConfigurationTest.java b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiConfigurationTest.java new file mode 100644 index 00000000000..96c012adb0d --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiConfigurationTest.java @@ -0,0 +1,111 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.search.GlobalSearchScope; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class JavaPsiConfigurationTest { + @TempDir + Path temporary; + + @Test + void implicit_runtime_and_decoded_snapshot_need_neither_launch_flags_nor_a_disk_source() { + String text = "class Example { String élève; }\r\n"; + var input = new JavaPsiEnvironment.SourceSnapshot(temporary.resolve("Example.java"), text, StandardCharsets.ISO_8859_1); + var configuration = new JavaPsiConfiguration(null, List.of(temporary.resolve("missing.jar")), 21, false, false); + String configProperty = System.getProperty("idea.config.path"); + try (var environment = JavaPsiEnvironment.open(configuration, List.of(input))) { + environment.read((project, files) -> { + var file = files.getFirst(); + assertEquals(text, file.getText()); + assertEquals(StandardCharsets.ISO_8859_1, file.getVirtualFile().getCharset()); + assertEquals(text.indexOf("élève"), file.getClasses()[0].getFields()[0].getNameIdentifier().getTextOffset()); + assertNotNull(JavaPsiFacade.getInstance(project).findClass("java.lang.String", GlobalSearchScope.allScope(project))); + assertFalse(ApplicationManager.getApplication().isUnitTestMode()); + return null; + }); + } + assertEquals(configProperty, System.getProperty("idea.config.path")); + assertNull(ApplicationManager.getApplication()); + } + + @ParameterizedTest + @ValueSource(strings = {"rt.jar", "android.jar"}) + void even_a_missing_explicit_runtime_suppresses_host_lookup(String archive) { + var configuration = new JavaPsiConfiguration(temporary, List.of(temporary.resolve(archive)), 21, false, false); + var source = new JavaPsiEnvironment.SourceSnapshot(temporary.resolve("Example.java"), "class Example { String value; }", StandardCharsets.UTF_8); + try (var environment = JavaPsiEnvironment.open(configuration, List.of(source))) { + environment.read((project, files) -> { + assertNull(JavaPsiFacade.getInstance(project).findClass("java.lang.String", GlobalSearchScope.allScope(project))); + return null; + }); + } + } + + @ParameterizedTest + @ValueSource(ints = {8, 9, 21, 26}) + void module_info_filter_uses_the_effective_source_level(int version) { + var source = new JavaPsiEnvironment.SourceSnapshot(temporary.resolve("module-info.java"), "module example {}", StandardCharsets.UTF_8); + var configuration = new JavaPsiConfiguration(null, List.of(), version, false, false); + try (var environment = JavaPsiEnvironment.open(configuration, List.of(source))) { + assertEquals(version < 9 ? 0 : 1, environment.read((project, files) -> files.size()).intValue()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"Example", "Example.JAVA", "generated.source"}) + void decoded_snapshots_do_not_require_the_experiment_file_extension(String name) { + var configuration = new JavaPsiConfiguration(null, List.of(), 21, false, false); + var source = new JavaPsiEnvironment.SourceSnapshot(temporary.resolve(name), "class Example {}", StandardCharsets.UTF_8); + try (var environment = JavaPsiEnvironment.open(configuration, List.of(source))) { + assertEquals("Example", environment.read((project, files) -> files.getFirst().getClasses()[0].getName())); + } + } + + @Test + void missing_selected_jrt_provider_preserves_the_failure_contract_without_host_repair() { + var provider = temporary.resolve("jdk/lib/jrt-fs.jar"); + var configuration = new JavaPsiConfiguration(null, List.of(provider), 21, false, false); + var failure = assertThrows(IllegalStateException.class, () -> JavaPsiEnvironment.open(configuration, List.of())); + assertEquals("Failed to init packages for " + provider, failure.getMessage()); + } + + @Test + void configuration_keeps_an_ordered_copy_of_the_classpath() { + var paths = new ArrayList<>(List.of(temporary.resolve("first"), temporary.resolve("second"))); + var configuration = new JavaPsiConfiguration(null, paths, 21, false, true); + paths.clear(); + assertEquals(List.of(temporary.resolve("first"), temporary.resolve("second")), configuration.classpath()); + assertThrows(UnsupportedOperationException.class, () -> configuration.classpath().clear()); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiEnvironmentTest.java b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiEnvironmentTest.java new file mode 100644 index 00000000000..9a328f95958 --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiEnvironmentTest.java @@ -0,0 +1,302 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.mock.MockApplication; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.progress.EmptyProgressIndicator; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.roots.LanguageLevelProjectExtension; +import com.intellij.openapi.util.Disposer; +import com.intellij.pom.java.LanguageLevel; +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiErrorElement; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiMethodCallExpression; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.psi.util.PsiTreeUtil; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JavaPsiEnvironmentTest { + + @TempDir + Path temporary; + + @Test + void cancellable_sessions_retain_the_callers_progress_scope() { + var canceled = new AtomicBoolean(); + var configuration = new JavaPsiConfiguration(null, List.of(), 21, false, false); + try (var environment = JavaPsiEnvironment.open(configuration, List.of(), canceled::get)) { + var indicator = new EmptyProgressIndicator(); + assertThrows(ProcessCanceledException.class, () -> ProgressManager.getInstance().runProcess(() -> environment.read((project, files) -> { + indicator.cancel(); + ProgressManager.checkCanceled(); + throw new AssertionError("Cancellation must propagate"); + }), indicator)); + assertThrows(ProcessCanceledException.class, () -> environment.read((project, files) -> { + canceled.set(true); + return environment.read((unused, inputs) -> inputs.size()); + })); + } + assertNull(ApplicationManager.getApplication()); + } + + @Test + void parses_a_source_file_and_resolves_a_jdk_type() throws IOException { + var source = Files.writeString(temporary.resolve("Example.java"), "package sample; class Example { String value; }"); + var release = new Properties(); + try (var input = Files.newInputStream(jdkHome().resolve("release"))) { + release.load(input); + } + var selectedVersion = Runtime.Version.parse(release.getProperty("JAVA_VERSION").replace("\"", "")).feature(); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), 21)) { + environment.read((project, files) -> { + assertEquals(LanguageLevel.JDK_21, LanguageLevelProjectExtension.getInstance(project).getLanguageLevel()); + assertEquals(1, files.size()); + var file = files.getFirst(); + assertEquals("sample", file.getPackageName()); + assertNull(PsiTreeUtil.findChildOfType(file, PsiErrorElement.class)); + var declaration = file.getClasses()[0]; + assertEquals("sample.Example", declaration.getQualifiedName()); + var stringType = (PsiClassType) declaration.getFields()[0].getType(); + var resolved = stringType.resolve(); + assertNotNull(resolved); + assertEquals("java.lang.String", resolved.getQualifiedName()); + assertTrue(resolved.getContainingFile().getVirtualFile().getPath().contains("java.base/java/lang/String.class")); + assertTrue(resolved.getContainingFile().getVirtualFile().getPath().startsWith(jdkHome().toString())); + try (var bytes = new DataInputStream(new ByteArrayInputStream(resolved.getContainingFile().getVirtualFile().contentsToByteArray()))) { + assertEquals(0xCAFEBABE, bytes.readInt()); + bytes.readUnsignedShort(); + assertEquals(selectedVersion + 44, bytes.readUnsignedShort()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return null; + }); + } + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment")); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.jetbrains.kotlin.cli.jvm.K2JVMCompiler")); + } + + @ParameterizedTest + @ValueSource(ints = {26, 27}) + void parses_modern_java_and_resolves_types_at_the_requested_language_level(int version) throws IOException { + var input = source("Example.java", """ + class Parent { Parent(String value) {} } + class Example extends Parent { + Example(String value) { + var trimmed = value.trim(); + super(trimmed); + } + } + """); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(input), List.of(), version)) { + environment.read((project, files) -> { + var level = LanguageLevelProjectExtension.getInstance(project).getLanguageLevel(); + assertEquals(version, level.feature()); + assertFalse(level.isUnsupported()); + assertFalse(level.isPreview()); + assertNull(PsiTreeUtil.findChildOfType(files.getFirst(), PsiErrorElement.class)); + var call = PsiTreeUtil.findChildOfType(files.getFirst(), PsiMethodCallExpression.class); + assertNotNull(call); + assertNotNull(call.resolveMethod()); + assertEquals("java.lang.String", call.getType().getCanonicalText()); + return null; + }); + } + } + + @Test + void resolves_secondary_classes_nested_classes_and_generic_calls_across_explicit_files() throws IOException { + var caller = source("Example.java", """ + package sample; + class Example { + String value = Secondary.identity("value"); + Secondary.Nested nested; + } + """); + var declarations = source("Declarations.java", """ + package sample; + class Declarations {} + class Secondary { + static T identity(T value) { return value; } + static class Nested {} + } + """); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(caller, declarations), List.of(), 21)) { + environment.read((project, files) -> { + var call = PsiTreeUtil.findChildOfType(files.getFirst(), PsiMethodCallExpression.class); + assertNotNull(call); + var result = call.resolveMethodGenerics(); + assertTrue(result.isValidResult()); + var method = (PsiMethod) result.getElement(); + assertNotNull(method); + assertEquals("sample.Secondary", method.getContainingClass().getQualifiedName()); + assertSame(files.get(1), method.getContainingFile()); + assertEquals("java.lang.String", result.getSubstitutor().substitute(method.getTypeParameters()[0]).getCanonicalText()); + assertEquals("java.lang.String", call.getType().getCanonicalText()); + var nestedType = (PsiClassType) files.getFirst().getClasses()[0].getFields()[1].getType(); + assertEquals("sample.Secondary.Nested", nestedType.resolve().getQualifiedName()); + return null; + }); + } + } + + @Test + void does_not_resolve_unsupplied_sources_in_the_same_directory() throws IOException { + var caller = source("Example.java", "package sample; class Example { Hidden value; }"); + source("Hidden.java", "package sample; class Hidden {}"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(caller), List.of(), 21)) { + environment.read((project, files) -> { + assertNull(PsiTreeUtil.findChildOfType(files.getFirst(), PsiErrorElement.class)); + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + assertNull(type.resolve()); + assertNull(JavaPsiFacade.getInstance(project).findClass("sample.Hidden", GlobalSearchScope.allScope(project))); + assertNull(JavaPsiFacade.getInstance(project).findClass("org.junit.jupiter.api.Test", GlobalSearchScope.allScope(project))); + return null; + }); + } + } + + @Test + void disposing_one_project_preserves_another_live_project() throws IOException { + var first = source("First.java", "package first; class First { String value; }"); + var second = source("Second.java", "package second; class Second { Integer value; }"); + try (var firstEnvironment = JavaPsiEnvironment.open(jdkHome(), List.of(first), List.of(), 21)) { + try (var secondEnvironment = JavaPsiEnvironment.open(jdkHome(), List.of(second), List.of(), 21)) { + secondEnvironment.read((project, files) -> { + assertEquals("second.Second", files.getFirst().getClasses()[0].getQualifiedName()); + assertNull(JavaPsiFacade.getInstance(project).findClass("first.First", GlobalSearchScope.allScope(project))); + return null; + }); + } + firstEnvironment.read((project, files) -> { + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + assertEquals("java.lang.String", type.resolve().getQualifiedName()); + assertNull(JavaPsiFacade.getInstance(project).findClass("second.Second", GlobalSearchScope.allScope(project))); + return null; + }); + } + } + + @Test + void close_is_idempotent_and_prevents_further_queries() throws IOException { + var input = source("Example.java", "class Example {}"); + var environment = JavaPsiEnvironment.open(jdkHome(), List.of(input), List.of(), 21); + environment.close(); + environment.close(); + assertThrows(IllegalStateException.class, () -> environment.read((project, files) -> files.size()).intValue()); + } + + @Test + void repeated_sessions_load_updated_source_text() throws IOException { + var input = source("Example.java", "class Example { String value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(input), List.of(), 21)) { + assertEquals("String", environment.read((project, files) -> files.getFirst().getClasses()[0].getFields()[0].getType().getPresentableText())); + } + Files.writeString(input, "class Example { Integer value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(input), List.of(), 21)) { + assertEquals("Integer", environment.read((project, files) -> files.getFirst().getClasses()[0].getFields()[0].getType().getPresentableText())); + } + Files.delete(input); + assertFalse(Files.exists(input)); + } + + @Test + void rejects_unsupported_java_versions_instead_of_silently_using_another_level() { + assertThrows(IllegalArgumentException.class, () -> JavaPsiEnvironment.open(jdkHome(), List.of(), List.of(), 99)); + } + + @Test + void rejects_a_missing_selected_jdk_instead_of_using_the_running_jvm() { + assertThrows(IllegalArgumentException.class, () -> JavaPsiEnvironment.open(temporary, List.of(), List.of(), 21)); + } + + @Test + void rejects_missing_source_files_but_tolerates_missing_classpath_entries() throws IOException { + var input = source("Example.java", "class Example {}"); + var missing = temporary.resolve("Missing.java"); + assertThrows(IllegalArgumentException.class, () -> JavaPsiEnvironment.open(jdkHome(), List.of(missing), List.of(), 21)); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(input), List.of(missing), 21)) { + assertEquals(1, environment.read((project, files) -> files.size()).intValue()); + } + } + + @Test + void rejects_a_missing_or_corrupt_jdk_provider_and_releases_the_failed_environment() throws IOException { + var library = Files.createDirectories(temporary.resolve("jdk/lib")); + Files.createFile(library.resolve("modules")); + var selectedJdk = library.getParent(); + assertThrows(UncheckedIOException.class, () -> JavaPsiEnvironment.open(selectedJdk, List.of(), List.of(), 27)); + assertNull(ApplicationManager.getApplication()); + Files.writeString(library.resolve("jrt-fs.jar"), "corrupt archive"); + assertThrows(IllegalStateException.class, () -> JavaPsiEnvironment.open(selectedJdk, List.of(), List.of(), 27)); + assertNull(ApplicationManager.getApplication()); + var source = source("Example.java", "class Example { String value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), 27)) { + assertEquals("java.lang.String", environment.read((project, files) -> files.getFirst().getClasses()[0].getFields()[0].getType().getCanonicalText())); + } + } + + @Test + void refuses_to_replace_an_existing_intellij_application() { + var previous = ApplicationManager.getApplication(); + var owner = Disposer.newDisposable(); + try { + var host = new MockApplication(owner); + ApplicationManager.setApplication(host); + assertThrows(IllegalStateException.class, () -> JavaPsiEnvironment.open(jdkHome(), List.of(), List.of(), 21)); + assertSame(host, ApplicationManager.getApplication()); + } finally { + try { + Disposer.dispose(owner); + } finally { + ApplicationManager.setApplication(previous); + } + } + } + + private Path source(String filename, String contents) throws IOException { + return Files.writeString(temporary.resolve(filename), contents); + } + + private static Path jdkHome() { + return Path.of(System.getProperty("psi.test.jdkHome", System.getProperty("java.home"))).toAbsolutePath().normalize(); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiMetadataTest.java b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiMetadataTest.java new file mode 100644 index 00000000000..0573637afb6 --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiMetadataTest.java @@ -0,0 +1,194 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiJavaFile; +import com.intellij.psi.PsiManager; +import com.intellij.psi.PsiModifier; +import com.intellij.psi.search.GlobalSearchScope; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JavaPsiMetadataTest { + @TempDir + Path temporary; + + @Test + void explicit_package_info_is_visible_without_indexing_its_directory() throws Exception { + var info = source("package-info.java", "@Deprecated(since=\"source\") package sample;"); + source("Unlisted.java", "package sample; class Unlisted {}"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(info), List.of(), 26)) { + environment.read((project, files) -> { + var facade = JavaPsiFacade.getInstance(project); + var declaration = facade.findPackage("sample"); + assertNotNull(declaration); + assertNotNull(declaration.getAnnotationList()); + var annotation = declaration.getAnnotationList().findAnnotation("java.lang.Deprecated"); + assertNotNull(annotation); + assertEquals("\"source\"", annotation.findAttributeValue("since").getText()); + assertNull(facade.findClass("sample.Unlisted", GlobalSearchScope.allScope(project))); + return null; + }); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void binary_package_annotations_nested_classes_and_signatures_are_available(boolean archive) throws Exception { + var library = library(archive); + var caller = source("Example.java", "class Example { library.Api.Nested value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(caller), List.of(library), 26)) { + environment.read((project, files) -> { + var facade = JavaPsiFacade.getInstance(project); + var annotation = facade.findPackage("library").getAnnotationList().findAnnotation("java.lang.Deprecated"); + assertNotNull(annotation); + assertEquals("\"binary\"", annotation.findAttributeValue("since").getText()); + var api = facade.findClass("library.Api", GlobalSearchScope.allScope(project)); + assertNotNull(api.getModifierList().findAnnotation("java.lang.Deprecated")); + var moduleFile = api.getContainingFile().getVirtualFile().getParent().getParent().findChild("module-info.class"); + assertNotNull(moduleFile); + var module = ((PsiJavaFile) PsiManager.getInstance(project).findFile(moduleFile)).getModuleDeclaration(); + assertNotNull(module); + assertEquals("dependency", module.getName()); + assertNotNull(module.getModifierList().findAnnotation("java.lang.Deprecated")); + assertEquals("library", module.getExports().iterator().next().getPackageName()); + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + var result = type.resolveGenerics(); + assertTrue(result.isValidResult()); + var nested = result.getElement(); + assertEquals("library.Api.Nested", nested.getQualifiedName()); + assertEquals("java.lang.Number", nested.getTypeParameters()[0].getExtendsListTypes()[0].getCanonicalText()); + assertEquals("java.lang.Integer", result.getSubstitutor().substitute(nested.getTypeParameters()[0]).getCanonicalText()); + var method = api.findMethodsByName("identity", false)[0]; + assertEquals("T", method.getReturnType().getCanonicalText()); + assertEquals("java.lang.CharSequence", method.getTypeParameters()[0].getExtendsListTypes()[0].getCanonicalText()); + assertEquals("java.io.IOException", method.getThrowsList().getReferencedTypes()[0].getCanonicalText()); + return null; + }); + } + if (archive) { + Files.delete(library); + } + } + + @Test + void package_info_snapshots_are_isolated_between_sessions() { + var path = temporary.resolve("package-info.java"); + var first = new JavaPsiEnvironment.SourceSnapshot(path, "@Deprecated(since=\"first\") package sample;", StandardCharsets.UTF_8); + var second = new JavaPsiEnvironment.SourceSnapshot(path, "@Deprecated(since=\"second\") package sample;", StandardCharsets.UTF_8); + try (var left = JavaPsiEnvironment.openSnapshots(jdkHome(), List.of(first), List.of(), 26, false); + var right = JavaPsiEnvironment.openSnapshots(jdkHome(), List.of(second), List.of(), 26, false)) { + assertEquals("\"first\"", left.read((project, files) -> JavaPsiFacade.getInstance(project).findPackage("sample") + .getAnnotationList().findAnnotation("java.lang.Deprecated").findAttributeValue("since").getText())); + assertEquals("\"second\"", right.read((project, files) -> JavaPsiFacade.getInstance(project).findPackage("sample") + .getAnnotationList().findAnnotation("java.lang.Deprecated").findAttributeValue("since").getText())); + } + } + + @Test + void source_module_metadata_keeps_annotations_and_directives() throws Exception { + var descriptor = source("module-info.java", """ + @Deprecated module example { + requires transitive java.sql; + requires static java.logging; + exports api to java.base; + opens api to java.sql; + uses api.Service; + provides api.Service with api.Provider; + } + """); + var service = source("Service.java", "package api; public interface Service {}"); + var provider = source("Provider.java", "package api; public class Provider implements Service {}"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(descriptor, service, provider), List.of(), 26)) { + environment.read((project, files) -> { + var module = files.getFirst().getModuleDeclaration(); + assertNotNull(module); + assertEquals("example", module.getName()); + assertNotNull(module.getModifierList().findAnnotation("java.lang.Deprecated")); + var requires = module.getRequires().iterator(); + assertTrue(requires.next().hasModifierProperty(PsiModifier.TRANSITIVE)); + assertTrue(requires.next().hasModifierProperty(PsiModifier.STATIC)); + assertEquals("api", module.getExports().iterator().next().getPackageName()); + assertEquals(List.of("java.base"), new ArrayList<>(module.getExports().iterator().next().getModuleNames())); + assertEquals("api", module.getOpens().iterator().next().getPackageName()); + assertEquals("api.Service", module.getUses().iterator().next().getClassReference().getQualifiedName()); + assertEquals("api.Service", module.getProvides().iterator().next().getInterfaceReference().getQualifiedName()); + return null; + }); + } + } + + private Path library(boolean archive) throws Exception { + var classes = Files.createDirectories(temporary.resolve("classes")); + var sources = Map.of( + "module-info.java", "@Deprecated module dependency { exports library; }", + "package-info.java", "@Deprecated(since=\"binary\") package library;", + "Api.java", """ + package library; + @Deprecated public class Api { + public static class Nested {} + public static T identity(T value) throws java.io.IOException { return value; } + } + """); + var command = new ArrayList<>(List.of(Path.of(System.getProperty("java.home"), "bin", + System.getProperty("os.name").startsWith("Windows") ? "javac.exe" : "javac").toString(), + "--release", "17", "-proc:none", "-d", classes.toString())); + for (var entry : sources.entrySet()) { + command.add(source(entry.getKey(), entry.getValue()).toString()); + } + var process = new ProcessBuilder(command).redirectErrorStream(true).start(); + var output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.waitFor(), output); + if (!archive) { + return classes; + } + var jar = temporary.resolve("metadata.jar"); + try (var outputStream = new JarOutputStream(Files.newOutputStream(jar)); var paths = Files.walk(classes)) { + for (var path : paths.filter(Files::isRegularFile).toList()) { + outputStream.putNextEntry(new JarEntry(classes.relativize(path).toString().replace('\\', '/'))); + Files.copy(path, outputStream); + outputStream.closeEntry(); + } + } + return jar; + } + + private Path source(String name, String contents) throws Exception { + return Files.writeString(temporary.resolve(name), contents); + } + + private static Path jdkHome() { + return Path.of(System.getProperty("psi.test.jdkHome", System.getProperty("java.home"))); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiModuleGraphTest.java b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiModuleGraphTest.java new file mode 100644 index 00000000000..d0447b6828e --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiModuleGraphTest.java @@ -0,0 +1,282 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.psi.JavaModuleGraphHelper; +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiJavaModule; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.testFramework.LightVirtualFile; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JavaPsiModuleGraphTest { + @TempDir + Path temporary; + + @ParameterizedTest + @CsvSource({"java.sql, Connection, java.sql.Connection", "java.sql.rowset, Connection, java.sql.Connection", + "java.desktop, Node, org.w3c.dom.Node"}) + void resolves_direct_and_transitive_module_imports(String module, String name, String expected) throws IOException { + var source = source("Example.java", "import module " + module + "; class Example { " + name + " value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), 26)) { + assertEquals(expected, environment.read((project, files) -> { + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + var resolved = type.resolveGenerics(); + assertTrue(resolved.isValidResult()); + return resolved.getElement().getQualifiedName(); + })); + } + } + + @ParameterizedTest + @CsvSource({"Connection, false", "java.sql.Connection, true", "String, true"}) + void preserves_ecj_duplicate_path_ambiguity_without_rejecting_qualified_or_implicit_imports(String typeName, boolean expected) throws IOException { + var source = source("Example.java", "import module java.se; class Example { " + typeName + " value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), 26)) { + environment.read((project, files) -> { + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + assertEquals(expected, type.resolveGenerics().isValidResult()); + return null; + }); + } + } + + @ParameterizedTest + @ValueSource(strings = {"import java.sql.Connection;", "import java.sql.*;"}) + void ordinary_imports_shadow_ecj_module_import_ambiguity(String ordinaryImport) throws IOException { + var source = source("Example.java", "import module java.se; " + ordinaryImport + " class Example { Connection value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), 26)) { + environment.read((project, files) -> { + assertTrue(((PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType()).resolveGenerics().isValidResult()); + return null; + }); + } + } + + @Test + void transitive_exports_exclude_non_transitive_requires() { + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(), List.of(), 26)) { + environment.read((project, files) -> { + var facade = JavaPsiFacade.getInstance(project); + var scope = GlobalSearchScope.allScope(project); + var desktop = facade.findModule("java.desktop", scope); + var dependencies = JavaModuleGraphHelper.getInstance().getAllTransitiveDependencies(desktop).stream() + .map(PsiJavaModule::getName).collect(Collectors.toSet()); + assertTrue(dependencies.containsAll(Set.of("java.xml", "java.datatransfer"))); + assertFalse(dependencies.contains("java.prefs")); + return null; + }); + } + } + + @ParameterizedTest + @CsvSource({"8, true", "26, false"}) + void jdk_internal_packages_respect_the_source_level(int version, boolean expected) throws IOException { + var source = source("Example.java", "class Example { jdk.internal.misc.Unsafe value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), version)) { + environment.read((project, files) -> { + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + var target = type.resolve(); + assertNotNull(target); + assertEquals(expected, JavaPsiFacade.getInstance(project).getResolveHelper() + .isAccessible(target, files.getFirst().getClasses()[0], null)); + assertEquals(expected, type.resolveGenerics().isValidResult()); + return null; + }); + } + } + + @Test + void qualified_exports_and_non_transitive_readability_are_enforced() { + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(), List.of(), 26)) { + environment.read((project, files) -> { + var facade = JavaPsiFacade.getInstance(project); + var scope = GlobalSearchScope.allScope(project); + var helper = JavaModuleGraphHelper.getInstance(); + var base = facade.findModule("java.base", scope); + var desktop = facade.findModule("java.desktop", scope); + var prefs = facade.findModule("java.prefs", scope); + var rowset = facade.findModule("java.sql.rowset", scope); + var internal = facade.findClass("jdk.internal.misc.Unsafe", scope).getContainingFile(); + assertTrue(helper.isAccessible(prefs, desktop)); + assertFalse(helper.isAccessible(prefs, rowset)); + assertTrue(helper.isAccessible(base, rowset)); + assertTrue(helper.isAccessible("jdk.internal.misc", internal, base)); + assertTrue(helper.isAccessible("jdk.internal.misc", internal, desktop)); + assertFalse(helper.isAccessible("jdk.internal.misc", internal, rowset)); + return null; + }); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void split_packages_match_the_ecj_option_for_directories_and_jars(boolean archive) throws Exception { + var classes = compile("split", "8", Map.of("Extra.java", "package org.w3c.dom; public class Extra {}")); + var library = archive ? jar(classes) : classes; + var source = source("Example.java", "class Example { org.w3c.dom.Node node; org.w3c.dom.Extra extra; }"); + for (int version : List.of(8, 26)) { + for (boolean ignore : List.of(false, true)) { + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(library), version, false, ignore)) { + environment.read((project, files) -> { + for (var field : files.getFirst().getClasses()[0].getFields()) { + var type = (PsiClassType) field.getType(); + assertEquals(version < 9 || ignore, type.resolveGenerics().isValidResult(), field.getName()); + } + return null; + }); + } + } + } + } + + @Test + void modular_classpath_entries_and_explicit_sources_keep_ecj_unnamed_visibility() throws Exception { + var classes = compile("dependency", "17", Map.of( + "module-info.java", "module dependency { exports api; }", + "api/Visible.java", "package api; public class Visible {}", + "internal/Hidden.java", "package internal; public class Hidden {}")); + var library = jar(classes); + var module = source("module-info.java", "module example {}"); + var source = source("Example.java", "class Example { internal.Hidden hidden; java.sql.Connection connection; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source, module), List.of(library), 26)) { + environment.read((project, files) -> { + var facade = JavaPsiFacade.getInstance(project); + var scope = GlobalSearchScope.allScope(project); + assertNull(facade.findModule("dependency", scope)); + assertNull(facade.findModule("example", scope)); + assertNull(JavaModuleGraphHelper.getInstance().findDescriptorByElement(files.getFirst().getClasses()[0])); + for (var field : files.getFirst().getClasses()[0].getFields()) { + assertTrue(((PsiClassType) field.getType()).resolveGenerics().isValidResult()); + } + return null; + }); + } + } + + @Test + void traversal_terminates_on_cycles_and_missing_modules_and_includes_static_transitive_edges() { + var snapshots = List.of( + new JavaPsiEnvironment.SourceSnapshot(temporary.resolve("a/module-info.java"), + "module a { requires transitive b; requires transitive missing; }", StandardCharsets.UTF_8), + new JavaPsiEnvironment.SourceSnapshot(temporary.resolve("b/module-info.java"), + "module b { requires static transitive c; }", StandardCharsets.UTF_8), + new JavaPsiEnvironment.SourceSnapshot(temporary.resolve("c/module-info.java"), + "module c { requires transitive a; }", StandardCharsets.UTF_8)); + try (var environment = JavaPsiEnvironment.openSnapshots(jdkHome(), snapshots, List.of(), 26, false)) { + environment.read((project, files) -> { + var graph = new JavaPsiModuleGraph(26, false); + var modules = files.stream().map(file -> file.getModuleDeclaration()).toList(); + for (var module : modules) { + graph.addRoot(new LightVirtualFile(module.getName()), module); + } + assertEquals(Set.of(modules.get(1), modules.get(2)), graph.transitiveDependencies(modules.getFirst())); + return null; + }); + } + } + + @ParameterizedTest + @CsvSource({"org.w3c.dom, false", "org.w3c.dom.nested, true"}) + void only_the_declared_source_package_participates_in_split_package_detection(String packageName, boolean strictValid) throws IOException { + var extra = source("Extra.java", "package " + packageName + "; public class Extra {}"); + var caller = source("Example.java", "class Example { org.w3c.dom.Node node; }"); + for (boolean ignore : List.of(false, true)) { + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(caller, extra), List.of(), 26, false, ignore)) { + environment.read((project, files) -> { + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + assertEquals(ignore || strictValid, type.resolveGenerics().isValidResult()); + return null; + }); + } + } + } + + @Test + void overlapping_sessions_keep_split_package_policies_independent() throws Exception { + var library = compile("split", "8", Map.of("Extra.java", "package org.w3c.dom; public class Extra {}")); + var source = source("Example.java", "class Example { org.w3c.dom.Node node; }"); + try (var strict = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(library), 26, false, false); + var permissive = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(library), 26, false, true)) { + assertFalse(strict.read((project, files) -> + ((PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType()).resolveGenerics().isValidResult())); + assertTrue(permissive.read((project, files) -> + ((PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType()).resolveGenerics().isValidResult())); + assertFalse(strict.read((project, files) -> + ((PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType()).resolveGenerics().isValidResult())); + } + } + + private Path source(String name, String contents) throws IOException { + return Files.writeString(temporary.resolve(name), contents); + } + + private Path compile(String name, String release, Map sources) throws Exception { + var directory = Files.createDirectories(temporary.resolve(name)); + var classes = Files.createDirectories(directory.resolve("classes")); + var command = new java.util.ArrayList<>(List.of( + Path.of(System.getProperty("java.home"), "bin", System.getProperty("os.name").startsWith("Windows") ? "javac.exe" : "javac").toString(), + "--release", release, "-proc:none", "-d", classes.toString())); + for (var entry : sources.entrySet()) { + var path = directory.resolve(entry.getKey()); + Files.createDirectories(path.getParent()); + Files.writeString(path, entry.getValue()); + command.add(path.toString()); + } + var process = new ProcessBuilder(command).redirectErrorStream(true).start(); + var output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.waitFor(), output); + return classes; + } + + private Path jar(Path classes) throws IOException { + var path = classes.getParent().resolve("dependency.jar"); + try (var output = new JarOutputStream(Files.newOutputStream(path)); var files = Files.walk(classes)) { + for (var file : files.filter(Files::isRegularFile).toList()) { + output.putNextEntry(new JarEntry(classes.relativize(file).toString().replace('\\', '/'))); + Files.copy(file, output); + output.closeEntry(); + } + } + return path; + } + + private static Path jdkHome() { + return Path.of(System.getProperty("psi.test.jdkHome", System.getProperty("java.home"))).toAbsolutePath().normalize(); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiQualificationTest.java b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiQualificationTest.java new file mode 100644 index 00000000000..406514b07bb --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/psi/JavaPsiQualificationTest.java @@ -0,0 +1,310 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import com.intellij.mock.MockApplication; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.progress.EmptyProgressIndicator; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.roots.LanguageLevelProjectExtension; +import com.intellij.openapi.util.Disposer; +import com.intellij.pom.java.JavaFeature; +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiErrorElement; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.psi.util.PsiTreeUtil; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BiFunction; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class JavaPsiQualificationTest { + + @TempDir + Path temporary; + + @ParameterizedTest + @ValueSource(ints = {26, 27}) + void selects_preview_levels_and_parses_primitive_patterns(int version) throws IOException { + var source = source("Example.java", "class Example { boolean test(Object value) { return value instanceof int; } }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), version, true)) { + environment.read((project, files) -> { + var level = LanguageLevelProjectExtension.getInstance(project).getLanguageLevel(); + assertEquals(version, level.feature()); + assertTrue(level.isPreview()); + assertTrue(JavaFeature.PRIMITIVE_TYPES_IN_PATTERNS.isSufficient(level)); + assertNull(PsiTreeUtil.findChildOfType(files.getFirst(), PsiErrorElement.class)); + return null; + }); + } + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), version)) { + environment.read((project, files) -> { + var level = LanguageLevelProjectExtension.getInstance(project).getLanguageLevel(); + assertFalse(level.isPreview()); + assertFalse(JavaFeature.PRIMITIVE_TYPES_IN_PATTERNS.isSufficient(level)); + return null; + }); + } + } + + @ParameterizedTest + @ValueSource(strings = {"rt.jar", "android.jar"}) + void explicit_incomplete_runtime_suppresses_the_host_jdk(String name) throws IOException { + var archive = temporary.resolve(name); + try (var output = new JarOutputStream(Files.newOutputStream(archive))) { + output.putNextEntry(new JarEntry("META-INF/")); + output.closeEntry(); + } + var source = source("Example.java", "class Example { String value; }"); + try (var environment = JavaPsiEnvironment.open(temporary.resolve("absent-jdk"), List.of(source), List.of(archive), 21)) { + environment.read((project, files) -> { + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + assertNull(type.resolve()); + assertNull(JavaPsiFacade.getInstance(project).findClass("java.lang.String", GlobalSearchScope.allScope(project))); + return null; + }); + } + Files.delete(archive); + } + + @Test + void supplied_android_runtime_resolves_only_its_own_classes() throws IOException { + var archive = Path.of(System.getProperty("psi.test.repositoryRoot", "../../sonar-java"), "java-frontend/src/test/resources/android.jar").toRealPath(); + var source = source("Example.java", "class Example { String missing; Integer available; }"); + try (var environment = JavaPsiEnvironment.open(temporary, List.of(source), List.of(archive), 17)) { + environment.read((project, files) -> { + var fields = files.getFirst().getClasses()[0].getFields(); + assertNull(((PsiClassType) fields[0].getType()).resolve()); + var integer = ((PsiClassType) fields[1].getType()).resolve(); + assertNotNull(integer); + assertTrue(integer.getContainingFile().getVirtualFile().getPath().startsWith(archive.toString())); + return null; + }); + } + } + + @Test + void malformed_selected_provider_preserves_the_ecj_class_format_failure() throws IOException { + var lib = Files.createDirectories(temporary.resolve("jdk/lib")); + var provider = lib.resolve("jrt-fs.jar"); + try (var output = new JarOutputStream(Files.newOutputStream(provider))) { + output.putNextEntry(new JarEntry("jdk/internal/jrtfs/JrtFileSystemProvider.class")); + output.closeEntry(); + } + var failure = assertThrows(ClassFormatError.class, + () -> JavaPsiEnvironment.open(jdkHome(), List.of(), List.of(provider), 21)); + assertEquals("Truncated class file", failure.getMessage()); + assertNull(ApplicationManager.getApplication()); + Files.delete(provider); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(), List.of(), 21)) { + assertEquals(Integer.valueOf(0), environment.read((project, files) -> files.size())); + } + } + + @Test + void cancellation_and_callback_failure_release_read_access_and_allow_recovery() throws IOException { + var source = source("Example.java", "class Example { String value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), 21)) { + var failure = new IllegalStateException("callback failure"); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> environment.read((project, files) -> { throw failure; }))); + var indicator = new EmptyProgressIndicator(); + assertThrows(ProcessCanceledException.class, () -> ProgressManager.getInstance().runProcess(() -> { + indicator.cancel(); + environment.read((project, files) -> { fail("Canceled action must not run"); return null; }); + }, indicator)); + assertEquals("Example", environment.read((project, files) -> files.getFirst().getClasses()[0].getName())); + } + assertNull(ApplicationManager.getApplication()); + } + + @Test + void independent_sessions_resolve_concurrently_and_dispose_cleanly() throws Exception { + var first = source("First.java", "package first; class First { String value; }"); + var second = source("Second.java", "package second; class Second { Integer value; }"); + var ready = new CountDownLatch(2); + try (var workers = Executors.newFixedThreadPool(2)) { + var jobs = List.of(first, second).stream().map(source -> workers.submit(() -> { + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), 21)) { + ready.countDown(); + assertTrue(ready.await(15, TimeUnit.SECONDS)); + return environment.read((project, files) -> { + var own = files.getFirst().getClasses()[0]; + var other = source.equals(first) ? "second.Second" : "first.First"; + assertNull(JavaPsiFacade.getInstance(project).findClass(other, GlobalSearchScope.allScope(project))); + return ((PsiClassType) own.getFields()[0].getType()).resolve().getQualifiedName(); + }); + } + })).toList(); + assertEquals("java.lang.String", jobs.get(0).get(30, TimeUnit.SECONDS)); + assertEquals("java.lang.Integer", jobs.get(1).get(30, TimeUnit.SECONDS)); + } + assertNull(ApplicationManager.getApplication()); + } + + @Test + void isolated_classloader_embeds_psi_without_replacing_host_application() throws Exception { + var previous = ApplicationManager.getApplication(); + var owner = Disposer.newDisposable(); + var source = source("Example.java", "class Example { String value; }"); + var classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + var urls = Arrays.stream(classpath.split(java.util.regex.Pattern.quote(File.pathSeparator))).map(path -> { + try { + return Path.of(path).toUri().toURL(); + } catch (java.net.MalformedURLException e) { + throw new IllegalArgumentException(e); + } + }).toArray(URL[]::new); + try (var loader = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader())) { + var host = new MockApplication(owner); + ApplicationManager.setApplication(host); + var type = loader.loadClass(JavaPsiEnvironment.class.getName()); + assertNotSame(JavaPsiEnvironment.class, type); + try (var environment = (AutoCloseable) type.getMethod("open", Path.class, List.class, List.class, int.class) + .invoke(null, jdkHome(), List.of(source), List.of(), 21)) { + var query = (BiFunction, Integer>) (project, files) -> files.size(); + assertEquals(1, type.getMethod("read", BiFunction.class).invoke(environment, query)); + assertSame(host, ApplicationManager.getApplication()); + } + assertSame(host, ApplicationManager.getApplication()); + } finally { + try { + Disposer.dispose(owner); + } finally { + ApplicationManager.setApplication(previous); + } + } + } + + @Test + void resolves_a_jdk_module_import() throws IOException { + var source = source("Example.java", "import module java.base; class Example { List values; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(source), List.of(), 26)) { + environment.read((project, files) -> { + assertNull(PsiTreeUtil.findChildOfType(files.getFirst(), PsiErrorElement.class)); + var facade = JavaPsiFacade.getInstance(project); + assertNotNull(facade.findModule("java.base", GlobalSearchScope.allScope(project))); + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + assertNotNull(type.resolve()); + assertEquals("java.util.List", type.resolve().getQualifiedName()); + return null; + }); + } + } + + @ParameterizedTest + @ValueSource(ints = {6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26}) + void maps_each_supported_sonar_source_level(int version) throws IOException { + var input = source("Example.java", "class Example { String value; }"); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(input), List.of(), version)) { + environment.read((project, files) -> { + assertEquals(version, LanguageLevelProjectExtension.getInstance(project).getLanguageLevel().feature()); + assertNull(PsiTreeUtil.findChildOfType(files.getFirst(), PsiErrorElement.class)); + var type = (PsiClassType) files.getFirst().getClasses()[0].getFields()[0].getType(); + assertEquals("java.lang.String", type.resolve().getQualifiedName()); + return null; + }); + } + } + + @Test + void decoded_snapshots_preserve_paths_encodings_line_endings_and_offsets() { + var declarations = temporary.resolve("Declarations.java"); + var caller = temporary.resolve("Caller.java"); + var text = "package sample;\r\nclass Helper { String café; }\r\n"; + var snapshots = List.of( + new JavaPsiEnvironment.SourceSnapshot(declarations, text, StandardCharsets.ISO_8859_1), + new JavaPsiEnvironment.SourceSnapshot(caller, "package sample; class Caller { Helper value; }", StandardCharsets.UTF_8)); + try (var environment = JavaPsiEnvironment.openSnapshots(jdkHome(), snapshots, List.of(), 26, false)) { + environment.read((project, files) -> { + var file = files.getFirst(); + assertEquals(text, file.getText()); + assertEquals(declarations.toString(), file.getVirtualFile().getPath()); + assertEquals(StandardCharsets.ISO_8859_1, file.getVirtualFile().getCharset()); + assertFalse(file.getVirtualFile().isWritable()); + var field = file.getClasses()[0].getFields()[0]; + assertEquals(text.indexOf("café"), field.getNameIdentifier().getTextOffset()); + var reference = (PsiClassType) files.get(1).getClasses()[0].getFields()[0].getType(); + assertSame(file.getClasses()[0], reference.resolve()); + return null; + }); + } + } + + @Test + void opened_sources_are_snapshots_even_before_the_first_read() throws IOException { + var original = "class Example { String original; }\r\n"; + var input = source("Example.java", original); + try (var environment = JavaPsiEnvironment.open(jdkHome(), List.of(input), List.of(), 21)) { + Files.writeString(input, "class Changed {}\n"); + Files.delete(input); + assertEquals(original, environment.read((project, files) -> files.getFirst().getText())); + } + } + + @Test + void duplicate_source_declarations_follow_explicit_input_order() throws IOException { + var first = source("First.java", "package sample; class Duplicate { String first; }"); + var second = source("Second.java", "package sample; class Duplicate { Integer second; }"); + for (var inputs : List.of(List.of(first, second), List.of(second, first))) { + try (var environment = JavaPsiEnvironment.open(jdkHome(), inputs, List.of(), 21)) { + environment.read((project, files) -> { + var facade = JavaPsiFacade.getInstance(project); + var scope = GlobalSearchScope.allScope(project); + assertSame(files.getFirst().getClasses()[0], facade.findClass("sample.Duplicate", scope)); + assertEquals(2, facade.findClasses("sample.Duplicate", scope).length); + return null; + }); + } + } + } + + private Path source(String name, String text) throws IOException { + return Files.writeString(temporary.resolve(name), text); + } + + private static Path jdkHome() { + return Path.of(System.getProperty("psi.test.jdkHome", System.getProperty("java.home"))).toAbsolutePath().normalize(); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/psi/PsiLanguageValidatorTest.java b/java-frontend/src/test/java/org/sonar/java/psi/PsiLanguageValidatorTest.java new file mode 100644 index 00000000000..f4fe9b1e943 --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/psi/PsiLanguageValidatorTest.java @@ -0,0 +1,137 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.psi; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PsiLanguageValidatorTest { + static Stream boundaries() { + return Stream.of( + Arguments.of("class Example { String s = \"\"\"\ntext\n\"\"\"; }", 14, 15, "Text Blocks"), + Arguments.of("class Example { boolean m(Object o) { return o instanceof String s; } }", 15, 16, "Type Patterns"), + Arguments.of("class Example { record R(int i) {} boolean m(Object o) { return o instanceof R(int i); } }", 20, 21, "Record Pattern"), + Arguments.of("class Example { int m(Object o) { return switch(o) { case String s -> 1; default -> 0; }; } }", 20, 21, "Pattern Matching in Switch"), + Arguments.of("import module java.sql; class Example {}", 24, 25, "Module Import Declarations"), + Arguments.of("class Example { Example() { int i = 1; super(); } }", 24, 25, "Flexible Constructor Bodies"), + Arguments.of("void main() {}", 24, 25, "Compact Source Files and Instance Main Methods")); + } + + @ParameterizedTest + @MethodSource("boundaries") + void exposes_feature_diagnostics_without_making_them_fatal(String source, int before, int after, String feature) { + var result = validate(source, before, false); + assertFalse(result.hasFatalError()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.kind() == PsiLanguageValidator.Kind.PREVIEW_FEATURE_USED + && diagnostic.message().equals("The Java feature '" + feature + "' is only available with source level " + after + " and above"))); + assertTrue(validate(source, after, false).diagnostics().isEmpty()); + } + + @Test + void keyword_failures_preserve_the_original_message_and_utf16_range() { + var source = "sealed class Example permits Child {} final class Child extends Example {}"; + var result = validate(source, 16, false); + var diagnostic = result.diagnostics().getFirst(); + assertEquals(PsiLanguageValidator.Kind.FATAL, diagnostic.kind()); + assertEquals("Syntax error on token \"class\", . expected", diagnostic.message()); + assertEquals(7, diagnostic.start()); + assertEquals(11, diagnostic.end()); + assertTrue(validate("class Example { void m() { int _ = 1; } }", 21, false).hasFatalError()); + assertFalse(validate("class Example { void m() { int _ = 1; } }", 22, false).hasFatalError()); + } + + @Test + void distinguishes_local_var_from_lambda_var() { + var local = "class Example { void m() { var x = 1; } }"; + assertEquals("var cannot be resolved to a type", validate(local, 9, false).diagnostics().getFirst().message()); + assertTrue(validate(local, 10, false).diagnostics().isEmpty()); + var lambda = "class Example { java.util.function.IntUnaryOperator f = (var x) -> x; }"; + assertEquals("'var' is not allowed here", validate(lambda, 10, false).diagnostics().getFirst().message()); + assertFalse(validate(lambda, 11, false).hasFatalError()); + } + + @ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {21, 25, 26}) + void primitive_instanceof_patterns_expose_a_diagnostic_when_preview_is_disabled(int version) { + var source = "class Example { boolean m(Object o) { return o instanceof int i; } }"; + var result = validate(source, version, false); + assertFalse(result.hasFatalError()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.kind() == PsiLanguageValidator.Kind.PREVIEW_FEATURE_USED + && diagnostic.message().equals("Incompatible conditional operand types Object and int"))); + assertTrue(validate(source, 26, true).diagnostics().isEmpty()); + } + + @Test + void exposes_recovered_record_and_switch_selector_diagnostics() { + var record = validate("record Example(int i) {}", 15, false); + assertTrue(record.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().equals("Implicitly declared class must have a candidate main method"))); + var nested = validate("class Example { record Pair(int i) {} boolean m(Object o) { return o instanceof Pair(int i); } }", 15, false); + assertTrue(nested.diagnostics().stream().anyMatch(diagnostic -> diagnostic.kind() == PsiLanguageValidator.Kind.UNDEFINED_TYPE + && diagnostic.message().equals("Pair cannot be resolved to a type"))); + var selector = validate("class Example { int m(Object o) { return switch(o) { case String s -> 1; default -> 0; }; } }", 20, false); + assertTrue(selector.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().equals("Cannot switch on a value of type Object. Only convertible int values, strings or enum variables are permitted"))); + } + + static Stream malformedHeaders() { + return Stream.of( + Arguments.of("class C { void m( }", 8), + Arguments.of("class C { C( }", 8), + Arguments.of("class C { String m( }", 8), + Arguments.of("class C { T m( }", 8), + Arguments.of("class C { void m(int x }", 8), + Arguments.of("class C { void good() {} void bad( }", 23), + Arguments.of("class Élève {\r\n void méthode( }", 12), + Arguments.of("class C { void m(", 8), + Arguments.of("class C { void m(/*text*/ }", 8)); + } + + @ParameterizedTest + @MethodSource("malformedHeaders") + void preserves_ecj_top_level_method_header_recovery(String source, int anchor) { + var result = validate(source, 26, false); + assertTrue(result.hasFatalError()); + var diagnostic = result.rawSyntaxErrors().getFirst(); + assertEquals("Syntax error, insert \"}\" to complete ClassBody", diagnostic.message()); + assertEquals(anchor, diagnostic.start()); + assertEquals(anchor, diagnostic.end()); + } + + @Test + void ignores_feature_names_in_comments_and_strings_and_keeps_malformed_syntax_fatal() { + var source = "class Example { String s = \"sealed record var import module\"; }"; + assertTrue(validate(source, 6, false).diagnostics().isEmpty()); + assertTrue(validate("class Example { void m( }", 26, false).hasFatalError()); + } + + private static PsiLanguageValidator.Result validate(String source, int version, boolean preview) { + var configuration = new JavaPsiConfiguration(null, List.of(), version, preview, false); + var snapshot = new JavaPsiEnvironment.SourceSnapshot(Path.of("Example.java"), source, StandardCharsets.UTF_8); + try (var environment = JavaPsiEnvironment.open(configuration, List.of(snapshot))) { + return environment.read((project, files) -> PsiLanguageValidator.validate(files.getFirst(), configuration)); + } + } +}