
org.sonar.python.checks.quickfix.PythonQuickFixVerifier Maven / Gradle / Ivy
/*
* SonarQube Python Plugin
* Copyright (C) 2011-2025 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* 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.python.checks.quickfix;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Stream;
import org.sonar.api.SonarProduct;
import org.sonar.plugins.python.api.PythonCheck;
import org.sonar.plugins.python.api.PythonCheck.PreciseIssue;
import org.sonar.plugins.python.api.PythonFile;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
import org.sonar.plugins.python.api.PythonVisitorContext;
import org.sonar.plugins.python.api.quickfix.PythonQuickFix;
import org.sonar.plugins.python.api.quickfix.PythonTextEdit;
import org.sonar.python.SubscriptionVisitor;
import org.sonar.python.caching.CacheContextImpl;
import org.sonar.python.parser.PythonParser;
import org.sonar.python.semantic.ProjectLevelSymbolTable;
import org.sonar.python.tree.IPythonTreeMaker;
import org.sonar.python.tree.PythonTreeMaker;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
public class PythonQuickFixVerifier {
private PythonQuickFixVerifier() {
}
public static void verify(PythonCheck check, String codeWithIssue, String... codesFixed) {
verify(PythonQuickFixVerifier::createPythonVisitorContext, check, false, codeWithIssue, codesFixed);
}
public static void verifyNoQuickFixes(PythonCheck check, String codeWithIssue) {
verifyNoQuickFixes(PythonQuickFixVerifier::createPythonVisitorContext, check, codeWithIssue);
}
public static void verifyQuickFixMessages(PythonCheck check, String codeWithIssue, String... expectedMessages) {
verifyQuickFixMessages(PythonQuickFixVerifier::createPythonVisitorContext, check, codeWithIssue, expectedMessages);
}
public static void verifyIPython(PythonCheck check, String codeWithIssue, String... codesFixed) {
verify(PythonQuickFixVerifier::createIPythonVisitorContext, check, true, codeWithIssue, codesFixed);
}
public static void verifyIPythonNoQuickFixes(PythonCheck check, String codeWithIssue) {
verifyNoQuickFixes(PythonQuickFixVerifier::createIPythonVisitorContext, check, codeWithIssue);
}
public static void verifyIPythonQuickFixMessages(PythonCheck check, String codeWithIssue, String... expectedMessages) {
verifyQuickFixMessages(PythonQuickFixVerifier::createIPythonVisitorContext, check, codeWithIssue, expectedMessages);
}
public static void verify(Function createVisitorContext, PythonCheck check, boolean isIPython, String codeWithIssue, String... codesFixed) {
List issues = PythonQuickFixVerifier
.getIssuesWithQuickFix(createVisitorContext, check, codeWithIssue);
assertThat(issues)
.as("Number of issues")
.overridingErrorMessage("Expected 1 issue but found %d", issues.size())
.hasSize(1);
PreciseIssue issue = issues.get(0);
assertThat(issue.quickFixes())
.as("Number of quickfixes")
.overridingErrorMessage("Expected %d quickfix but found %d", codesFixed.length, issue.quickFixes().size())
.hasSize(codesFixed.length);
PythonParser pythonParser = isIPython ? PythonParser.createIPythonParser() : PythonParser.create();
assertThatCode(() -> pythonParser.parse(String.join("\n", codesFixed))).as("Correction of quick fixes")
.overridingErrorMessage("The code expected to be generated by the quickfix is not valid (I)Python code.\nResults is :\n%s", Arrays.asList(codesFixed))
.doesNotThrowAnyException();
List appliedQuickFix = issue.quickFixes().stream()
.map(quickFix -> applyQuickFix(codeWithIssue, quickFix))
.toList();
assertThat(appliedQuickFix)
.as("The code with the quickfix applied is not the expected result.\n" +
"Applied QuickFixes are:\n%s\nExpected result:\n%s", appliedQuickFix, Arrays.asList(codesFixed))
.isEqualTo(Arrays.asList(codesFixed));
}
public static void verifyNoQuickFixes(Function createVisitorContext, PythonCheck check, String codeWithIssue) {
List issues = PythonQuickFixVerifier
.getIssuesWithQuickFix(createVisitorContext, check, codeWithIssue);
assertThat(issues)
.as("Number of issues")
.overridingErrorMessage("Expected 1 issue but found %d", issues.size())
.hasSize(1);
PreciseIssue issue = issues.get(0);
assertThat(issue.quickFixes())
.as("Number of quick fixes")
.overridingErrorMessage("Expected no quick fixes for the issue but found %d", issue.quickFixes().size())
.isEmpty();
}
public static void verifyQuickFixMessages(Function createVisitorContext,
PythonCheck check,
String codeWithIssue,
String... expectedMessages) {
Stream descriptions = PythonQuickFixVerifier
.getIssuesWithQuickFix(createVisitorContext, check, codeWithIssue)
.stream()
.flatMap(issue -> issue.quickFixes().stream())
.map(PythonQuickFix::getDescription);
assertThat(descriptions).containsExactly(expectedMessages);
}
private static List scanFileForIssues(PythonCheck check, PythonVisitorContext context) {
check.scanFile(context);
if (check instanceof PythonSubscriptionCheck pythonSubscriptionCheck) {
SubscriptionVisitor.analyze(Collections.singletonList(pythonSubscriptionCheck), context);
}
return context.getIssues();
}
private static List getIssuesWithQuickFix(Function createVisitorContext, PythonCheck check, String codeWithIssue) {
var visitorContext = createVisitorContext.apply(codeWithIssue);
return scanFileForIssues(check, visitorContext);
}
private static PythonVisitorContext createPythonVisitorContext(String code) {
return createVisitorContext(PythonParser.create(), new PythonTreeMaker(), code);
}
private static PythonVisitorContext createIPythonVisitorContext(String code) {
return createVisitorContext(PythonParser.createIPythonParser(), new IPythonTreeMaker(), code);
}
private static PythonVisitorContext createVisitorContext(PythonParser parser, PythonTreeMaker treeMaker, String code) {
var pythonFile = new PythonQuickFixFile(code);
var astNode = parser.parse(pythonFile.content());
var fileInput = treeMaker.fileInput(astNode);
return new PythonVisitorContext(fileInput,
pythonFile, null, "",
ProjectLevelSymbolTable.empty(), CacheContextImpl.dummyCache(), SonarProduct.SONARLINT);
}
private static String applyQuickFix(String codeWithIssue, PythonQuickFix quickFix) {
List sortedEdits = sortTextEdits(quickFix.getTextEdits());
String codeBeingFixed = codeWithIssue;
for (PythonTextEdit edit : sortedEdits) {
codeBeingFixed = applyTextEdit(codeBeingFixed, edit);
}
return codeBeingFixed;
}
private static String applyTextEdit(String codeWithIssue, PythonTextEdit textEdit) {
String replacement = textEdit.replacementText();
int start = convertPositionToIndex(codeWithIssue, textEdit.startLine(), textEdit.startLineOffset());
int end = convertPositionToIndex(codeWithIssue, textEdit.endLine(), textEdit.endLineOffset());
return codeWithIssue.substring(0, start) + replacement + codeWithIssue.substring(end);
}
private static List sortTextEdits(List pythonTextEdits) {
checkNoCollision(pythonTextEdits);
ArrayList list = new ArrayList<>(pythonTextEdits);
list.sort(Comparator.comparingInt(PythonTextEdit::startLine).thenComparing(PythonTextEdit::startLineOffset));
Collections.reverse(list);
return Collections.unmodifiableList(list);
}
private static void checkNoCollision(List pythonTextEdits) throws IllegalArgumentException {
for (int i = 0; i < pythonTextEdits.size(); i++) {
PythonTextEdit edit = pythonTextEdits.get(i);
for (int j = i + 1; j < pythonTextEdits.size(); j++) {
PythonTextEdit edit2 = pythonTextEdits.get(j);
if (oneEnclosedByTheOther(edit2, edit)) {
throw new IllegalArgumentException("There is a collision between the range of the quickfixes.");
}
}
}
}
// Returns true if the range of one edit crosses the range of the other. If one end of both edits is the same point,
// we should return false
private static boolean oneEnclosedByTheOther(PythonTextEdit toCheck, PythonTextEdit reference) {
if (onSameLine(toCheck, reference)) {
// If on same line, we need to check that the bounds of toCheck are not contained in reference bounds
return !(toCheck.endLineOffset() < reference.startLineOffset() || toCheck.startLineOffset() > reference.endLineOffset());
} else {
if (compactOnDifferentLines(toCheck, reference)) {
return false;
} else if (isCompact(toCheck)) {
return isSecondInFirst(toCheck, reference);
} else if (isCompact(reference)) {
return isSecondInFirst(reference, toCheck);
} else {
// Both edits exploded on different lines
if (noLineIntersection(toCheck, reference)) {
return false;
} else {
// There is an intersection between edits, only need to check valid case
if (reference.startLine() == toCheck.endLine()) {
return !(toCheck.endLineOffset() <= reference.startLineOffset());
} else if (reference.endLine() == toCheck.startLine()) {
return !(reference.endLineOffset() <= toCheck.startLineOffset());
}
}
}
}
// All other cases are invalid and will cause an intersection
return true;
}
private static boolean onSameLine(PythonTextEdit check, PythonTextEdit ref) {
return ref.startLine() == ref.endLine() && ref.endLine() == check.startLine() && check.startLine() == check.endLine();
}
private static boolean compactOnDifferentLines(PythonTextEdit check, PythonTextEdit ref) {
return ref.startLine() == ref.endLine() && check.startLine() == check.endLine() && ref.endLine() != check.startLine();
}
private static boolean isCompact(PythonTextEdit check) {
return check.startLine() == check.endLine();
}
// Returns true if there is an intersection between the ranges
// The first parameter is a compact edit, i.e., the edit is only one one line
private static boolean isSecondInFirst(PythonTextEdit first, PythonTextEdit second) {
if (first.startLine() == second.startLine()) {
return second.startLineOffset() < first.endLineOffset();
} else if (first.endLine() == second.endLine()) {
return first.startLineOffset() < second.endLineOffset();
}
// No intersection
return false;
}
private static boolean noLineIntersection(PythonTextEdit check, PythonTextEdit ref) {
return check.endLine() < ref.startLine() || check.startLine() > ref.endLine();
}
private static int convertPositionToIndex(String fileContent, int line, int lineOffset) {
int currentLine = 1;
int currentIndex = 0;
while (currentLine < line) {
currentIndex = fileContent.indexOf("\n", currentIndex) + 1;
currentLine++;
}
return currentIndex + lineOffset;
}
private static class PythonQuickFixFile implements PythonFile {
private final String content;
public PythonQuickFixFile(String content) {
this.content = content;
}
@Override
public String content() {
return this.content;
}
@Override
public String fileName() {
return "PythonQuickFixFile";
}
@Override
public URI uri() {
return URI.create(this.fileName());
}
@Override
public String key() {
return "PythonQuickFixFile";
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy