diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce053762..b5abdd03 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,6 @@ sonar-analyzer-commons = { group = "org.sonarsource.analyzer-commons", name = "s sonar-analyzer-test-commons = { group = "org.sonarsource.analyzer-commons", name = "sonar-analyzer-test-commons", version.ref = "analyzer-commons" } slang-api = { group = "org.sonarsource.slang", name = "slang-api", version.ref = "slang-dependencies" } slang-checks = { group = "org.sonarsource.slang", name = "slang-checks", version.ref = "slang-dependencies" } -slang-plugin = { group = "org.sonarsource.slang", name = "slang-plugin", version.ref = "slang-dependencies" } checkstyle-import = { group = "org.sonarsource.slang", name = "checkstyle-import", version.ref = "slang-dependencies" } minimal-json = { group = "com.eclipsesource.minimal-json", name = "minimal-json", version.ref = "minimal-json" } sonar-plugin-api-test-fixtures = { group = "org.sonarsource.api.plugin", name = "sonar-plugin-api-test-fixtures", version.ref = "plugin-api" } diff --git a/sonar-go-plugin/build.gradle.kts b/sonar-go-plugin/build.gradle.kts index 901939fb..c19a8852 100644 --- a/sonar-go-plugin/build.gradle.kts +++ b/sonar-go-plugin/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { compileOnly(libs.sonar.plugin.api) implementation(libs.sonar.analyzer.commons) - implementation(libs.slang.plugin) implementation(libs.slang.checks) implementation(libs.slang.api) implementation(libs.checkstyle.import) diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/AbstractPropertyHandlerSensor.java b/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/AbstractPropertyHandlerSensor.java new file mode 100644 index 00000000..bbad5758 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/AbstractPropertyHandlerSensor.java @@ -0,0 +1,107 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.externalreport; + +import java.io.File; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.api.batch.sensor.Sensor; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.batch.sensor.SensorDescriptor; +import org.sonar.api.notifications.AnalysisWarnings; +import org.sonarsource.analyzer.commons.ExternalReportProvider; + +public abstract class AbstractPropertyHandlerSensor implements Sensor { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractPropertyHandlerSensor.class); + private final AnalysisWarnings analysisWarnings; + private final String propertyKey; + private final String propertyName; + private final String configurationKey; + private final String languageKey; + + protected AbstractPropertyHandlerSensor(AnalysisWarnings analysisWarnings, String propertyKey, String propertyName, + String configurationKey, String languageKey) { + this.analysisWarnings = analysisWarnings; + this.propertyKey = propertyKey; + this.propertyName = propertyName; + this.configurationKey = configurationKey; + this.languageKey = languageKey; + } + + public final String propertyName() { + return propertyName; + } + + public final String propertyKey() { + return propertyKey; + } + + public final String configurationKey() { + return configurationKey; + } + + @Override + public void describe(SensorDescriptor descriptor) { + descriptor + .onlyOnLanguage(languageKey) + .onlyWhenConfiguration(conf -> conf.hasKey(configurationKey())) + .name("Import of " + propertyName() + " issues"); + } + + @Override + public void execute(SensorContext context) { + executeOnFiles(reportFiles(context), reportConsumer(context)); + } + + public abstract Consumer reportConsumer(SensorContext context); + + private void executeOnFiles(List reportFiles, Consumer action) { + reportFiles.stream() + .filter(File::exists) + .forEach(file -> { + LOG.info("Importing {}", file); + action.accept(file); + }); + reportMissingFiles(reportFiles); + } + + private List reportFiles(SensorContext context) { + return ExternalReportProvider.getReportFiles(context, configurationKey()); + } + + private void reportMissingFiles(List reportFiles) { + List missingFiles = reportFiles.stream() + .filter(file -> !file.exists()) + .map(File::getPath) + .toList(); + + if (!missingFiles.isEmpty()) { + String missingFilesAsString = missingFiles.stream().collect(Collectors.joining("\n- ", "\n- ", "")); + String logWarning = String.format("Unable to import %s report file(s):%s%nThe report file(s) can not be found. Check that the property '%s' is correctly configured.", + propertyName(), missingFilesAsString, configurationKey()); + LOG.warn(logWarning); + + String uiWarning = String.format("Unable to import %d %s report file(s).%nPlease check that property '%s' is correctly configured and the analysis logs for more details.", + missingFiles.size(), propertyName(), configurationKey()); + analysisWarnings.addUnique(uiWarning); + } + } +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/AbstractReportSensor.java b/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/AbstractReportSensor.java index 09c24b7e..05909647 100644 --- a/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/AbstractReportSensor.java +++ b/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/AbstractReportSensor.java @@ -42,7 +42,6 @@ import org.sonar.api.server.rule.RulesDefinition.NewRepository; import org.sonar.api.server.rule.RulesDefinition.NewRule; import org.sonar.go.plugin.GoLanguage; -import org.sonarsource.slang.plugin.AbstractPropertyHandlerSensor; import static java.nio.charset.StandardCharsets.UTF_8; import static org.sonarsource.slang.utils.LogArg.lazyArg; diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/GolangCILintReportSensor.java b/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/GolangCILintReportSensor.java index dfb561dd..328c48b8 100644 --- a/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/GolangCILintReportSensor.java +++ b/sonar-go-plugin/src/main/java/org/sonar/go/externalreport/GolangCILintReportSensor.java @@ -27,7 +27,6 @@ import org.sonar.api.rules.RuleType; import org.sonar.go.plugin.GoLanguage; import org.sonarsource.slang.externalreport.CheckstyleFormatImporter; -import org.sonarsource.slang.plugin.AbstractPropertyHandlerSensor; public class GolangCILintReportSensor extends AbstractPropertyHandlerSensor { diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/ChecksVisitor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/ChecksVisitor.java new file mode 100644 index 00000000..e0be2154 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/ChecksVisitor.java @@ -0,0 +1,129 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Objects; +import java.util.function.BiConsumer; +import javax.annotation.Nullable; +import org.sonar.api.batch.rule.Checks; +import org.sonar.api.rule.RuleKey; +import org.sonarsource.slang.api.HasTextRange; +import org.sonarsource.slang.api.TextRange; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.checks.api.CheckContext; +import org.sonarsource.slang.checks.api.InitContext; +import org.sonarsource.slang.checks.api.SecondaryLocation; +import org.sonarsource.slang.checks.api.SlangCheck; +import org.sonarsource.slang.visitors.TreeVisitor; + +public class ChecksVisitor extends TreeVisitor { + + private final DurationStatistics statistics; + + public ChecksVisitor(Checks checks, DurationStatistics statistics) { + this.statistics = statistics; + Collection rulesActiveInSonarQube = checks.all(); + for (SlangCheck check : rulesActiveInSonarQube) { + RuleKey ruleKey = checks.ruleKey(check); + Objects.requireNonNull(ruleKey); + check.initialize(new ContextAdapter(ruleKey)); + } + } + + public class ContextAdapter implements InitContext, CheckContext { + + public final RuleKey ruleKey; + private InputFileContext currentCtx; + + public ContextAdapter(RuleKey ruleKey) { + this.ruleKey = ruleKey; + } + + @Override + public void register(Class cls, BiConsumer visitor) { + ChecksVisitor.this.register(cls, statistics.time(ruleKey.rule(), (ctx, tree) -> { + currentCtx = ctx; + visitor.accept(this, tree); + })); + } + + @Override + public Deque ancestors() { + return currentCtx.ancestors(); + } + + @Override + public String filename() { + return currentCtx.inputFile.filename(); + } + + @Override + public String fileContent() { + try { + return currentCtx.inputFile.contents(); + } catch (IOException e) { + throw new IllegalStateException("Cannot read content of " + currentCtx.inputFile, e); + } + } + + @Override + public void reportIssue(TextRange textRange, String message) { + reportIssue(textRange, message, Collections.emptyList(), null); + } + + @Override + public void reportIssue(HasTextRange toHighlight, String message) { + reportIssue(toHighlight, message, Collections.emptyList()); + } + + @Override + public void reportIssue(HasTextRange toHighlight, String message, SecondaryLocation secondaryLocation) { + reportIssue(toHighlight, message, Collections.singletonList(secondaryLocation)); + } + + @Override + public void reportIssue(HasTextRange toHighlight, String message, List secondaryLocations) { + reportIssue(toHighlight, message, secondaryLocations, null); + } + + @Override + public void reportIssue(HasTextRange toHighlight, String message, List secondaryLocations, @Nullable Double gap) { + reportIssue(toHighlight.textRange(), message, secondaryLocations, gap); + } + + @Override + public void reportFileIssue(String message) { + reportFileIssue(message, null); + } + + @Override + public void reportFileIssue(String message, @Nullable Double gap) { + reportIssue((TextRange) null, message, Collections.emptyList(), gap); + } + + private void reportIssue(@Nullable TextRange textRange, String message, List secondaryLocations, @Nullable Double gap) { + currentCtx.reportIssue(ruleKey, textRange, message, secondaryLocations, gap); + } + + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CommentAnalysisUtils.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CommentAnalysisUtils.java new file mode 100644 index 00000000..0bc9aa33 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CommentAnalysisUtils.java @@ -0,0 +1,71 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import org.sonarsource.slang.api.Comment; +import org.sonarsource.slang.api.TextRange; + +public class CommentAnalysisUtils { + public static final String NOSONAR_PREFIX = "NOSONAR"; + + private static final boolean[] IS_NON_BLANK_CHAR_IN_COMMENTS = new boolean[127]; + static { + for (int c = 0; c < IS_NON_BLANK_CHAR_IN_COMMENTS.length; c++) { + IS_NON_BLANK_CHAR_IN_COMMENTS[c] = c > ' ' && "*#-=|".indexOf(c) == -1; + } + } + + private CommentAnalysisUtils() { + } + + static boolean isNosonarComment(Comment comment) { + return comment.contentText().trim().toUpperCase(Locale.ENGLISH).startsWith(NOSONAR_PREFIX); + } + + static Set findNonEmptyCommentLines(TextRange range, String content) { + Set lineNumbers = new HashSet<>(); + + int startLine = range.start().line(); + if (startLine == range.end().line()) { + if (isNotBlank(content)) { + lineNumbers.add(startLine); + } + } else { + String[] lines = content.split("\r\n|\n|\r", -1); + for (int i = 0; i < lines.length; i++) { + if (isNotBlank(lines[i])) { + lineNumbers.add(startLine + i); + } + } + } + + return lineNumbers; + } + + private static boolean isNotBlank(String line) { + for (int i = 0; i < line.length(); i++) { + char ch = line.charAt(i); + if (ch >= IS_NON_BLANK_CHAR_IN_COMMENTS.length || IS_NON_BLANK_CHAR_IN_COMMENTS[ch]) { + return true; + } + } + return false; + } +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CpdVisitor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CpdVisitor.java new file mode 100644 index 00000000..664defc6 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CpdVisitor.java @@ -0,0 +1,184 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.sensor.cache.ReadCache; +import org.sonar.api.batch.sensor.cpd.NewCpdTokens; +import org.sonarsource.slang.api.TextRange; +import org.sonarsource.slang.api.Token; +import org.sonarsource.slang.api.TopLevelTree; +import org.sonarsource.slang.impl.TextRangeImpl; +import org.sonarsource.slang.impl.TokenImpl; + +public class CpdVisitor extends PullRequestAwareVisitor { + static final char ASCII_UNIT_SEPARATOR = 31; + static final char ASCII_RECORD_SEPARATOR = 30; + private static final Logger LOG = LoggerFactory.getLogger(CpdVisitor.class.getName()); + + public CpdVisitor() { + register(TopLevelTree.class, (ctx, tree) -> { + NewCpdTokens cpdTokens = ctx.sensorContext.newCpdTokens().onFile(ctx.inputFile); + List tokens = tree.metaData().tokens(); + List tokensToCache = new ArrayList<>(tokens.size()); + + boolean foundFirstToken = (tree.firstCpdToken() == null); + + for (Token token : tokens) { + foundFirstToken = foundFirstToken || (token == tree.firstCpdToken()); + if (foundFirstToken) { + String text = substituteText(token); + cpdTokens.addToken(ctx.textRange(token.textRange()), text); + if (ctx.sensorContext.isCacheEnabled()) { + tokensToCache.add(token); + } + } + } + cpdTokens.save(); + cacheNewTokens(ctx, tokensToCache); + }); + } + + @Override + public boolean reusePreviousResults(InputFileContext ctx) { + if (canReusePreviousResults(ctx)) { + NewCpdTokens reusedTokens = ctx.sensorContext.newCpdTokens().onFile(ctx.inputFile); + // Load from the cache and skip parsing + String fileKey = ctx.inputFile.key(); + LOG.debug("Looking up cached CPD tokens for {} ...", fileKey); + ReadCache cache = ctx.sensorContext.previousCache(); + String key = computeCacheKey(ctx.inputFile); + if (cache.contains(key)) { + LOG.debug("Found cached CPD tokens for {}.", fileKey); + LOG.debug("Loading cached CPD tokens for {} ...", fileKey); + List tokens = null; + try (InputStream in = cache.read(key)) { + tokens = deserialize(in.readAllBytes()); + } catch (IllegalArgumentException | IOException e) { + LOG.warn("Failed to load cached CPD tokens for input file %s.".formatted(fileKey)); + return false; + } + LOG.debug("Loaded cached CPD tokens for {}.", fileKey); + for (Token token : tokens) { + String text = substituteText(token); + reusedTokens.addToken(ctx.textRange(token.textRange()), text); + } + try { + ctx.sensorContext.nextCache().copyFromPrevious(key); + } catch (IllegalArgumentException e) { + LOG.warn("Failed to copy previous cached results for input file %s.".formatted(fileKey)); + return false; + } + reusedTokens.save(); + return true; + } + } + return false; + } + + private static void cacheNewTokens(InputFileContext ctx, List tokens) { + if (ctx.sensorContext.isCacheEnabled()) { + try { + ctx.sensorContext.nextCache().write( + computeCacheKey(ctx.inputFile), + serialize(tokens)); + } catch (IllegalArgumentException e) { + LOG.warn("Failed to write CPD tokens to cache for input file {}: {}", ctx.inputFile.key(), e.getMessage()); + } + } + } + + /** + * Computes a unique key for a file that can be used to store its CPD tokens in a cache. + */ + static String computeCacheKey(InputFile inputFile) { + return "slang:cpd-tokens:%s".formatted(inputFile.key()); + } + + /** + * Transforms a list of tokens into a byte array for caching. + * Must be reversible by {@link #deserialize(byte[])}. + */ + static byte[] serialize(List tokens) { + return tokens.stream() + .map(CpdVisitor::serialize) + .collect(Collectors.joining(String.valueOf(ASCII_RECORD_SEPARATOR))) + .getBytes(StandardCharsets.UTF_8); + } + + private static String serialize(Token token) { + TextRange textRange = token.textRange(); + return String.format( + "%d,%d,%d,%d%c%s%c%s", + textRange.start().line(), + textRange.start().lineOffset(), + textRange.end().line(), + textRange.end().lineOffset(), + ASCII_UNIT_SEPARATOR, + token.text(), + ASCII_UNIT_SEPARATOR, + token.type()); + } + + /** + * Deserialize a byte array, serialized by {@link #serialize(List)}, into a list of tokens. + * + * @throws IllegalArgumentException - when failing to deserialize (eg: unexpected format) + */ + static List deserialize(byte[] serialized) { + if (serialized.length == 0) { + return Collections.emptyList(); + } + String str = new String(serialized, StandardCharsets.UTF_8); + String[] tokensAsStrings = str.split(String.valueOf(ASCII_RECORD_SEPARATOR)); + try { + return Arrays.stream(tokensAsStrings) + .map(CpdVisitor::deserialize) + .toList(); + } catch (IllegalArgumentException | IndexOutOfBoundsException | NoSuchElementException e) { + throw new IllegalArgumentException( + "Could not deserialize cached CPD tokens: %s".formatted(e.getMessage()), + e); + } + } + + private static Token deserialize(String tokenAsString) { + String[] fields = tokenAsString.split(String.valueOf(ASCII_UNIT_SEPARATOR)); + List rangeIndices = Arrays.stream(fields[0].split(",")) + .map(Integer::valueOf) + .toList(); + TextRange textRange = new TextRangeImpl(rangeIndices.get(0), rangeIndices.get(1), rangeIndices.get(2), rangeIndices.get(3)); + String text = fields[1]; + Token.Type type = Token.Type.valueOf(fields[2]); + return new TokenImpl(textRange, text, type); + } + + private static String substituteText(Token token) { + return token.type() == Token.Type.STRING_LITERAL ? "LITERAL" : token.text(); + } +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CyclomaticComplexityVisitor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CyclomaticComplexityVisitor.java new file mode 100644 index 00000000..9f259bb6 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/CyclomaticComplexityVisitor.java @@ -0,0 +1,71 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.util.ArrayList; +import java.util.List; +import org.sonarsource.slang.api.BinaryExpressionTree; +import org.sonarsource.slang.api.FunctionDeclarationTree; +import org.sonarsource.slang.api.HasTextRange; +import org.sonarsource.slang.api.IfTree; +import org.sonarsource.slang.api.LoopTree; +import org.sonarsource.slang.api.MatchCaseTree; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.visitors.TreeContext; +import org.sonarsource.slang.visitors.TreeVisitor; + +public class CyclomaticComplexityVisitor extends TreeVisitor { + + private List complexityTrees = new ArrayList<>(); + + public CyclomaticComplexityVisitor() { + + register(FunctionDeclarationTree.class, (ctx, tree) -> { + if (tree.name() != null && tree.body() != null) { + complexityTrees.add(tree); + } + }); + + register(IfTree.class, (ctx, tree) -> complexityTrees.add(tree.ifKeyword())); + + register(LoopTree.class, (ctx, tree) -> complexityTrees.add(tree)); + + register(MatchCaseTree.class, (ctx, tree) -> { + if (tree.expression() != null) { + complexityTrees.add(tree); + } + }); + + register(BinaryExpressionTree.class, (ctx, tree) -> { + if (tree.operator() == BinaryExpressionTree.Operator.CONDITIONAL_AND || + tree.operator() == BinaryExpressionTree.Operator.CONDITIONAL_OR) { + complexityTrees.add(tree); + } + }); + } + + public List complexityTrees(Tree tree) { + this.complexityTrees = new ArrayList<>(); + this.scan(new TreeContext(), tree); + return this.complexityTrees; + } + + @Override + protected void before(TreeContext ctx, Tree root) { + complexityTrees = new ArrayList<>(); + } +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/DurationStatistics.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/DurationStatistics.java new file mode 100644 index 00000000..7ab1b64a --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/DurationStatistics.java @@ -0,0 +1,98 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.text.NumberFormat; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiConsumer; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.api.config.Configuration; + +class DurationStatistics { + + private static final Logger LOG = LoggerFactory.getLogger(DurationStatistics.class); + + private static final String PROPERTY_KEY = "sonar.slang.duration.statistics"; + + private final Map stats = new ConcurrentHashMap<>(); + + private final boolean recordStat; + + DurationStatistics(Configuration config) { + recordStat = config.getBoolean(PROPERTY_KEY).orElse(false); + } + + BiConsumer time(String id, BiConsumer consumer) { + if (recordStat) { + return (t, u) -> time(id, () -> consumer.accept(t, u)); + } else { + return consumer; + } + } + + void time(String id, Runnable runnable) { + if (recordStat) { + time(id, () -> { + runnable.run(); + return null; + }); + } else { + runnable.run(); + } + } + + T time(String id, Supplier supplier) { + if (recordStat) { + long startTime = System.nanoTime(); + T result = supplier.get(); + store(id, System.nanoTime() - startTime); + return result; + } else { + return supplier.get(); + } + } + + void store(String id, long elapsedTime) { + stats.computeIfAbsent(id, key -> new AtomicLong(0)).addAndGet(elapsedTime); + } + + void log() { + if (recordStat) { + StringBuilder out = new StringBuilder(); + DecimalFormatSymbols symbols = new DecimalFormatSymbols(Locale.ROOT); + symbols.setGroupingSeparator('\''); + NumberFormat format = new DecimalFormat("#,###", symbols); + out.append("Duration Statistics"); + stats.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get())) + .forEach(e -> out.append(", ") + .append(e.getKey()) + .append(" ") + .append(format.format(e.getValue().get() / 1_000_000L)) + .append(" ms")); + LOG.info("{}", out); + } + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/GoRulesDefinition.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/GoRulesDefinition.java index 58ef72cb..0b759b13 100644 --- a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/GoRulesDefinition.java +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/GoRulesDefinition.java @@ -16,15 +16,20 @@ */ package org.sonar.go.plugin; +import java.lang.reflect.Field; +import java.util.Arrays; import java.util.List; import org.sonar.api.SonarRuntime; import org.sonar.api.server.rule.RulesDefinition; +import org.sonar.api.utils.AnnotationUtils; +import org.sonar.check.RuleProperty; import org.sonar.go.externalreport.AbstractReportSensor; import org.sonar.go.externalreport.GoLintReportSensor; import org.sonar.go.externalreport.GoVetReportSensor; import org.sonarsource.analyzer.commons.RuleMetadataLoader; import org.sonarsource.slang.checks.utils.Language; -import org.sonarsource.slang.plugin.RulesDefinitionUtils; +import org.sonarsource.slang.checks.utils.PropertyDefaultValue; +import org.sonarsource.slang.checks.utils.PropertyDefaultValues; public class GoRulesDefinition implements RulesDefinition { @@ -45,11 +50,36 @@ public void define(Context context) { List> checks = GoCheckList.checks(); metadataLoader.addRulesByAnnotatedClass(repository, checks); - RulesDefinitionUtils.setDefaultValuesForParameters(repository, checks, Language.GO); + setDefaultValuesForParameters(repository, checks, Language.GO); repository.done(); AbstractReportSensor.createExternalRuleRepository(context, GoVetReportSensor.LINTER_ID, GoVetReportSensor.LINTER_NAME); AbstractReportSensor.createExternalRuleRepository(context, GoLintReportSensor.LINTER_ID, GoLintReportSensor.LINTER_NAME); } + + private static void setDefaultValuesForParameters(RulesDefinition.NewRepository repository, List> checks, Language language) { + for (Class check : checks) { + org.sonar.check.Rule ruleAnnotation = AnnotationUtils.getAnnotation(check, org.sonar.check.Rule.class); + String ruleKey = ruleAnnotation.key(); + for (Field field : check.getDeclaredFields()) { + RuleProperty ruleProperty = field.getAnnotation(RuleProperty.class); + PropertyDefaultValues defaultValues = field.getAnnotation(PropertyDefaultValues.class); + if (ruleProperty == null || defaultValues == null) { + continue; + } + String paramKey = ruleProperty.key(); + + List valueForLanguage = Arrays.stream(defaultValues.value()) + .filter(defaultValue -> defaultValue.language() == language) + .toList(); + if (valueForLanguage.size() != 1) { + throw new IllegalStateException("Invalid @PropertyDefaultValue on " + check.getSimpleName() + + " for language " + language); + } + valueForLanguage + .forEach(defaultValue -> repository.rule(ruleKey).param(paramKey).setDefaultValue(defaultValue.defaultValue())); + } + } + } } diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/GoSensor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/GoSensor.java index 476d6db1..0f5f5fce 100644 --- a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/GoSensor.java +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/GoSensor.java @@ -30,7 +30,6 @@ import org.sonarsource.slang.api.Tree; import org.sonarsource.slang.api.VariableDeclarationTree; import org.sonarsource.slang.checks.api.SlangCheck; -import org.sonarsource.slang.plugin.SlangSensor; public class GoSensor extends SlangSensor { @@ -42,7 +41,7 @@ public GoSensor(SonarRuntime sonarRuntime, CheckFactory checkFactory, FileLinesC NoSonarFilter noSonarFilter, GoLanguage language, GoConverter goConverter) { super(sonarRuntime, noSonarFilter, fileLinesContextFactory, language); checks = checkFactory.create(GoRulesDefinition.REPOSITORY_KEY); - checks.addAnnotatedChecks((Iterable) GoCheckList.checks()); + checks.addAnnotatedChecks(GoCheckList.checks()); this.goConverter = goConverter; } diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/InputFileContext.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/InputFileContext.java new file mode 100644 index 00000000..7e9cb3cd --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/InputFileContext.java @@ -0,0 +1,134 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nullable; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.TextPointer; +import org.sonar.api.batch.fs.TextRange; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.batch.sensor.error.NewAnalysisError; +import org.sonar.api.batch.sensor.issue.NewIssue; +import org.sonar.api.batch.sensor.issue.NewIssueLocation; +import org.sonar.api.rule.RuleKey; +import org.sonarsource.slang.checks.api.SecondaryLocation; +import org.sonarsource.slang.visitors.TreeContext; + +public class InputFileContext extends TreeContext { + + private static final String PARSING_ERROR_RULE_KEY = "ParsingError"; + private Map> filteredRules = new HashMap<>(); + + public final SensorContext sensorContext; + + public final InputFile inputFile; + + public InputFileContext(SensorContext sensorContext, InputFile inputFile) { + this.sensorContext = sensorContext; + this.inputFile = inputFile; + } + + public TextRange textRange(org.sonarsource.slang.api.TextRange textRange) { + return inputFile.newRange( + textRange.start().line(), + textRange.start().lineOffset(), + textRange.end().line(), + textRange.end().lineOffset()); + } + + public void reportIssue(RuleKey ruleKey, + @Nullable org.sonarsource.slang.api.TextRange textRange, + String message, + List secondaryLocations, + @Nullable Double gap) { + + if (textRange != null && filteredRules.getOrDefault(ruleKey.toString(), Collections.emptySet()) + .stream().anyMatch(textRange::isInside)) { + // Issue is filtered by one of the filter. + return; + } + + NewIssue issue = sensorContext.newIssue(); + NewIssueLocation issueLocation = issue.newLocation() + .on(inputFile) + .message(message); + + if (textRange != null) { + issueLocation.at(textRange(textRange)); + } + + issue + .forRule(ruleKey) + .at(issueLocation) + .gap(gap); + + secondaryLocations.forEach(secondary -> issue.addLocation( + issue.newLocation() + .on(inputFile) + .at(textRange(secondary.textRange)) + .message(secondary.message == null ? "" : secondary.message))); + + issue.save(); + } + + public void reportAnalysisParseError(String repositoryKey, InputFile inputFile, @Nullable org.sonarsource.slang.api.TextPointer location) { + reportAnalysisError("Unable to parse file: " + inputFile, location); + RuleKey parsingErrorRuleKey = RuleKey.of(repositoryKey, PARSING_ERROR_RULE_KEY); + if (sensorContext.activeRules().find(parsingErrorRuleKey) == null) { + return; + } + NewIssue parseError = sensorContext.newIssue(); + NewIssueLocation parseErrorLocation = parseError.newLocation() + .on(inputFile) + .message("A parsing error occurred in this file."); + + Optional.ofNullable(location) + .map(org.sonarsource.slang.api.TextPointer::line) + .map(inputFile::selectLine) + .ifPresent(parseErrorLocation::at); + + parseError + .forRule(parsingErrorRuleKey) + .at(parseErrorLocation) + .save(); + } + + public void reportAnalysisError(String message, @Nullable org.sonarsource.slang.api.TextPointer location) { + NewAnalysisError error = sensorContext.newAnalysisError(); + error + .message(message) + .onFile(inputFile); + + if (location != null) { + TextPointer pointerLocation = inputFile.newPointer(location.line(), location.lineOffset()); + error.at(pointerLocation); + } + + error.save(); + } + + public void setFilteredRules(Map> filteredRules) { + this.filteredRules = filteredRules; + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/IssueSuppressionVisitor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/IssueSuppressionVisitor.java new file mode 100644 index 00000000..5b460e09 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/IssueSuppressionVisitor.java @@ -0,0 +1,91 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.util.ArrayList; +import java.util.Arrays; +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.regex.Matcher; +import java.util.regex.Pattern; +import org.sonarsource.slang.api.Annotation; +import org.sonarsource.slang.api.ClassDeclarationTree; +import org.sonarsource.slang.api.FunctionDeclarationTree; +import org.sonarsource.slang.api.ParameterTree; +import org.sonarsource.slang.api.TextRange; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.api.VariableDeclarationTree; +import org.sonarsource.slang.visitors.TreeVisitor; + +public class IssueSuppressionVisitor extends TreeVisitor { + + private Map> filteredRules; + + private static final List SUPPRESS_ANNOTATION_NAMES = Arrays.asList("Suppress", "SuppressWarnings"); + + private static final Pattern LITERAL_PATTERN = Pattern.compile("\"(.*?)\""); + + public IssueSuppressionVisitor() { + register(FunctionDeclarationTree.class, (ctx, tree) -> checkSuppressAnnotations(tree)); + register(ClassDeclarationTree.class, (ctx, tree) -> checkSuppressAnnotations(tree)); + register(VariableDeclarationTree.class, (ctx, tree) -> checkSuppressAnnotations(tree)); + register(ParameterTree.class, (ctx, tree) -> checkSuppressAnnotations(tree)); + } + + private void checkSuppressAnnotations(Tree tree) { + List annotations = tree.metaData().annotations(); + TextRange textRange = tree.textRange(); + + annotations.forEach(annotation -> { + if (SUPPRESS_ANNOTATION_NAMES.contains(annotation.shortName())) { + getSuppressedKeys(annotation.argumentsText()).forEach(ruleKey -> filteredRules.computeIfAbsent(ruleKey, key -> new HashSet<>()).add(textRange)); + } + }); + } + + private static Collection getSuppressedKeys(List argumentsText) { + List keys = new ArrayList<>(); + for (String s : argumentsText) { + keys.addAll(getArgumentsValues(s)); + } + return keys; + } + + private static Collection getArgumentsValues(String argumentText) { + List values = new ArrayList<>(); + Matcher m = LITERAL_PATTERN.matcher(argumentText); + while (m.find()) { + values.add(m.group(1)); + } + return values; + } + + @Override + protected void before(InputFileContext ctx, Tree root) { + filteredRules = new HashMap<>(); + } + + @Override + protected void after(InputFileContext ctx, Tree root) { + ctx.setFilteredRules(filteredRules); + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/MetricVisitor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/MetricVisitor.java new file mode 100644 index 00000000..bf12dca4 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/MetricVisitor.java @@ -0,0 +1,151 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import org.sonar.api.batch.measure.Metric; +import org.sonar.api.measures.CoreMetrics; +import org.sonar.api.measures.FileLinesContext; +import org.sonar.api.measures.FileLinesContextFactory; +import org.sonarsource.slang.api.BlockTree; +import org.sonarsource.slang.api.ClassDeclarationTree; +import org.sonarsource.slang.api.Comment; +import org.sonarsource.slang.api.FunctionDeclarationTree; +import org.sonarsource.slang.api.TopLevelTree; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.checks.complexity.CognitiveComplexity; +import org.sonarsource.slang.visitors.TreeVisitor; + +public class MetricVisitor extends TreeVisitor { + private final FileLinesContextFactory fileLinesContextFactory; + private final Predicate executableLineOfCodePredicate; + + private Set linesOfCode; + private Set commentLines; + private Set executableLines; + private int numberOfFunctions; + private int numberOfClasses; + private int complexity; + private int statements; + private int cognitiveComplexity; + + public MetricVisitor(FileLinesContextFactory fileLinesContextFactory, Predicate executableLineOfCodePredicate) { + this.fileLinesContextFactory = fileLinesContextFactory; + this.executableLineOfCodePredicate = executableLineOfCodePredicate; + + register(TopLevelTree.class, (ctx, tree) -> { + List declarations = tree.declarations(); + int firstTokenLine = declarations.isEmpty() ? tree.textRange().end().line() : declarations.get(0).textRange().start().line(); + tree.allComments() + .forEach(comment -> commentLines.addAll(findNonEmptyCommentLines(comment, firstTokenLine))); + addExecutableLines(declarations); + linesOfCode.addAll(tree.metaData().linesOfCode()); + complexity = new CyclomaticComplexityVisitor().complexityTrees(tree).size(); + statements = new StatementsVisitor().statements(tree); + cognitiveComplexity = new CognitiveComplexity(tree).value(); + }); + + register(FunctionDeclarationTree.class, (ctx, tree) -> { + if (tree.name() != null && tree.body() != null) { + numberOfFunctions++; + } + }); + + register(ClassDeclarationTree.class, (ctx, tree) -> numberOfClasses++); + + register(BlockTree.class, (ctx, tree) -> addExecutableLines(tree.statementOrExpressions())); + } + + static Set findNonEmptyCommentLines(Comment comment, int firstTokenLine) { + boolean isFileHeader = comment.textRange().end().line() < firstTokenLine; + + if (!isFileHeader && !CommentAnalysisUtils.isNosonarComment(comment)) { + return CommentAnalysisUtils.findNonEmptyCommentLines(comment.contentRange(), comment.contentText()); + } + + return Set.of(); + } + + private void addExecutableLines(List trees) { + trees.stream() + .filter(executableLineOfCodePredicate) + .forEach(t -> executableLines.add(t.metaData().textRange().start().line())); + } + + @Override + protected void before(InputFileContext ctx, Tree root) { + linesOfCode = new HashSet<>(); + commentLines = new HashSet<>(); + executableLines = new HashSet<>(); + numberOfFunctions = 0; + numberOfClasses = 0; + complexity = 0; + cognitiveComplexity = 0; + } + + @Override + protected void after(InputFileContext ctx, Tree root) { + saveMetric(ctx, CoreMetrics.NCLOC, linesOfCode().size()); + saveMetric(ctx, CoreMetrics.COMMENT_LINES, commentLines().size()); + saveMetric(ctx, CoreMetrics.FUNCTIONS, numberOfFunctions()); + saveMetric(ctx, CoreMetrics.CLASSES, numberOfClasses()); + saveMetric(ctx, CoreMetrics.COMPLEXITY, complexity); + saveMetric(ctx, CoreMetrics.STATEMENTS, statements); + saveMetric(ctx, CoreMetrics.COGNITIVE_COMPLEXITY, cognitiveComplexity); + + FileLinesContext fileLinesContext = fileLinesContextFactory.createFor(ctx.inputFile); + linesOfCode().forEach(line -> fileLinesContext.setIntValue(CoreMetrics.NCLOC_DATA_KEY, line, 1)); + executableLines().forEach(line -> fileLinesContext.setIntValue(CoreMetrics.EXECUTABLE_LINES_DATA_KEY, line, 1)); + fileLinesContext.save(); + } + + private static void saveMetric(InputFileContext ctx, Metric metric, Integer value) { + ctx.sensorContext.newMeasure() + .on(ctx.inputFile) + .forMetric(metric) + .withValue(value) + .save(); + } + + public Set linesOfCode() { + return linesOfCode; + } + + public Set commentLines() { + return commentLines; + } + + public Set executableLines() { + return executableLines; + } + + public int numberOfFunctions() { + return numberOfFunctions; + } + + public int numberOfClasses() { + return numberOfClasses; + } + + public int cognitiveComplexity() { + return cognitiveComplexity; + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/PullRequestAwareVisitor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/PullRequestAwareVisitor.java new file mode 100644 index 00000000..23958225 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/PullRequestAwareVisitor.java @@ -0,0 +1,44 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import org.sonar.api.batch.fs.InputFile; +import org.sonarsource.slang.visitors.TreeVisitor; + +/** + * A type of Visitor that can leverage previous results rather than recompute findings from scratch. + */ +public abstract class PullRequestAwareVisitor extends TreeVisitor { + /** + * Tries to copy the cached results from a previous analysis into the cache for the next one. + * + * @param inputFileContext The input file and its context + * @return true if successful, false otherwise. + */ + public abstract boolean reusePreviousResults(InputFileContext inputFileContext); + + /** + * The simplest logic to test that the cache can be used for a given file, without checking that the cache contains + * any relevant data. + * Should be called by callers of {@link #reusePreviousResults(InputFileContext)} or any overriding implementation. + */ + public boolean canReusePreviousResults(InputFileContext inputFileContext) { + return inputFileContext.sensorContext.canSkipUnchangedFiles() && + inputFileContext.sensorContext.isCacheEnabled() && + inputFileContext.inputFile.status() == InputFile.Status.SAME; + } +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SkipNoSonarLinesVisitor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SkipNoSonarLinesVisitor.java new file mode 100644 index 00000000..bb740ecc --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SkipNoSonarLinesVisitor.java @@ -0,0 +1,64 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.sonar.api.issue.NoSonarFilter; +import org.sonarsource.slang.api.Comment; +import org.sonarsource.slang.api.TopLevelTree; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.visitors.TreeVisitor; + +public class SkipNoSonarLinesVisitor extends TreeVisitor { + + private final NoSonarFilter noSonarFilter; + + private Set noSonarLines; + + public SkipNoSonarLinesVisitor(NoSonarFilter noSonarFilter) { + this.noSonarFilter = noSonarFilter; + + register(TopLevelTree.class, (ctx, tree) -> { + List declarations = tree.declarations(); + int firstTokenLine = declarations.isEmpty() ? tree.textRange().end().line() : declarations.get(0).textRange().start().line(); + tree.allComments() + .forEach(comment -> noSonarLines.addAll(findNoSonarCommentLines(comment, firstTokenLine))); + }); + } + + @Override + protected void before(InputFileContext ctx, Tree root) { + noSonarLines = new HashSet<>(); + } + + @Override + protected void after(InputFileContext ctx, Tree root) { + noSonarFilter.noSonarInFile(ctx.inputFile, noSonarLines); + } + + private static Set findNoSonarCommentLines(Comment comment, int firstTokenLine) { + boolean isFileHeader = comment.textRange().end().line() < firstTokenLine; + + if (!isFileHeader && CommentAnalysisUtils.isNosonarComment(comment)) { + return CommentAnalysisUtils.findNonEmptyCommentLines(comment.contentRange(), comment.contentText()); + } + + return Set.of(); + } +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SlangSensor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SlangSensor.java new file mode 100644 index 00000000..d5fadba3 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SlangSensor.java @@ -0,0 +1,268 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.api.SonarProduct; +import org.sonar.api.SonarRuntime; +import org.sonar.api.batch.fs.FilePredicate; +import org.sonar.api.batch.fs.FileSystem; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.rule.Checks; +import org.sonar.api.batch.sensor.Sensor; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.batch.sensor.SensorDescriptor; +import org.sonar.api.issue.NoSonarFilter; +import org.sonar.api.measures.FileLinesContextFactory; +import org.sonar.api.resources.Language; +import org.sonar.go.plugin.caching.HashCacheUtils; +import org.sonar.go.plugin.converter.ASTConverterValidation; +import org.sonarsource.analyzer.commons.ProgressReport; +import org.sonarsource.slang.api.ASTConverter; +import org.sonarsource.slang.api.BlockTree; +import org.sonarsource.slang.api.ClassDeclarationTree; +import org.sonarsource.slang.api.FunctionDeclarationTree; +import org.sonarsource.slang.api.ImportDeclarationTree; +import org.sonarsource.slang.api.PackageDeclarationTree; +import org.sonarsource.slang.api.ParseException; +import org.sonarsource.slang.api.TextPointer; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.checks.api.SlangCheck; +import org.sonarsource.slang.visitors.TreeVisitor; + +public abstract class SlangSensor implements Sensor { + static final Predicate EXECUTABLE_LINE_PREDICATE = t -> !(t instanceof PackageDeclarationTree) + && !(t instanceof ImportDeclarationTree) + && !(t instanceof ClassDeclarationTree) + && !(t instanceof FunctionDeclarationTree) + && !(t instanceof BlockTree); + + private static final Logger LOG = LoggerFactory.getLogger(SlangSensor.class); + private static final Pattern EMPTY_FILE_CONTENT_PATTERN = Pattern.compile("\\s*+"); + + protected final SonarRuntime sonarRuntime; + private final NoSonarFilter noSonarFilter; + private final Language language; + private FileLinesContextFactory fileLinesContextFactory; + + protected SlangSensor(SonarRuntime sonarRuntime, NoSonarFilter noSonarFilter, FileLinesContextFactory fileLinesContextFactory, Language language) { + this.sonarRuntime = sonarRuntime; + this.noSonarFilter = noSonarFilter; + this.fileLinesContextFactory = fileLinesContextFactory; + this.language = language; + } + + @Override + public void describe(SensorDescriptor descriptor) { + descriptor + .onlyOnLanguage(language.getKey()) + .name(language.getName() + " Sensor"); + } + + protected abstract ASTConverter astConverter(SensorContext sensorContext); + + protected abstract Checks checks(); + + protected abstract String repositoryKey(); + + protected Predicate executableLineOfCodePredicate() { + return EXECUTABLE_LINE_PREDICATE; + } + + private boolean analyseFiles(ASTConverter converter, + SensorContext sensorContext, + Iterable inputFiles, + ProgressReport progressReport, + List> visitors, + DurationStatistics statistics) { + if (sensorContext.canSkipUnchangedFiles()) { + LOG.info("The {} analyzer is running in a context where unchanged files can be skipped.", this.language); + } + + for (InputFile inputFile : inputFiles) { + if (sensorContext.isCancelled()) { + return false; + } + InputFileContext inputFileContext = new InputFileContext(sensorContext, inputFile); + try { + analyseFile(converter, inputFileContext, inputFile, visitors, statistics); + } catch (ParseException e) { + logParsingError(inputFile, e); + inputFileContext.reportAnalysisParseError(repositoryKey(), inputFile, e.getPosition()); + } + progressReport.nextFile(); + } + return true; + } + + static void analyseFile(ASTConverter converter, + InputFileContext inputFileContext, + InputFile inputFile, + List> visitors, + DurationStatistics statistics) { + List> canBeSkipped = new ArrayList<>(); + if (fileCanBeSkipped(inputFileContext)) { + String fileKey = inputFile.key(); + LOG.debug("Checking that previous results can be reused for input file {}.", fileKey); + + Map successfulCacheReuseByVisitor = visitors.stream() + .filter(PullRequestAwareVisitor.class::isInstance) + .map(PullRequestAwareVisitor.class::cast) + .collect(Collectors.toMap(visitor -> visitor, visitor -> reusePreviousResults(visitor, inputFileContext))); + + boolean allVisitorsSuccessful = successfulCacheReuseByVisitor.values().stream().allMatch(Boolean.TRUE::equals); + if (allVisitorsSuccessful) { + LOG.debug("Skipping input file {} (status is unchanged).", fileKey); + HashCacheUtils.copyFromPrevious(inputFileContext); + return; + } + LOG.debug("Will convert input file {} for full analysis.", fileKey); + successfulCacheReuseByVisitor.entrySet().stream() + .filter(Map.Entry::getValue) + .map(Map.Entry::getKey) + .forEach(canBeSkipped::add); + } + String content; + String fileName; + try { + content = inputFile.contents(); + fileName = inputFile.toString(); + } catch (IOException | RuntimeException e) { + throw toParseException("read", inputFile, e); + } + + if (EMPTY_FILE_CONTENT_PATTERN.matcher(content).matches()) { + return; + } + + Tree tree = statistics.time("Parse", () -> { + try { + return converter.parse(content, fileName); + } catch (RuntimeException e) { + throw toParseException("parse", inputFile, e); + } + }); + for (TreeVisitor visitor : visitors) { + try { + if (canBeSkipped.contains(visitor)) { + continue; + } + String visitorId = visitor.getClass().getSimpleName(); + statistics.time(visitorId, () -> visitor.scan(inputFileContext, tree)); + } catch (RuntimeException e) { + inputFileContext.reportAnalysisError(e.getMessage(), null); + LOG.error("Cannot analyse '" + inputFile + "': " + e.getMessage(), e); + } + } + writeHashToCache(inputFileContext); + } + + private static boolean fileCanBeSkipped(InputFileContext inputFileContext) { + SensorContext sensorContext = inputFileContext.sensorContext; + if (!sensorContext.canSkipUnchangedFiles()) { + return false; + } + return HashCacheUtils.hasSameHashCached(inputFileContext); + } + + private static void writeHashToCache(InputFileContext inputFileContext) { + HashCacheUtils.writeHashForNextAnalysis(inputFileContext); + } + + private static boolean reusePreviousResults(PullRequestAwareVisitor visitor, InputFileContext inputFileContext) { + boolean success = visitor.reusePreviousResults(inputFileContext); + if (success) { + return true; + } + String message = String.format( + "Visitor %s failed to reuse previous results for input file %s.", + visitor.getClass().getSimpleName(), + inputFileContext.inputFile.key()); + LOG.debug(message); + return false; + } + + private static ParseException toParseException(String action, InputFile inputFile, Exception cause) { + TextPointer position = cause instanceof ParseException actual ? actual.getPosition() : null; + return new ParseException("Cannot " + action + " '" + inputFile + "': " + cause.getMessage(), position, cause); + } + + private static void logParsingError(InputFile inputFile, ParseException e) { + TextPointer position = e.getPosition(); + String positionMessage = ""; + if (position != null) { + positionMessage = String.format("Parse error at position %s:%s", position.line(), position.lineOffset()); + } + LOG.error("Unable to parse file: {}. {}", inputFile.uri(), positionMessage); + LOG.error(e.getMessage()); + } + + @Override + public void execute(SensorContext sensorContext) { + DurationStatistics statistics = new DurationStatistics(sensorContext.config()); + FileSystem fileSystem = sensorContext.fileSystem(); + FilePredicate mainFilePredicate = fileSystem.predicates().and( + fileSystem.predicates().hasLanguage(language.getKey()), + fileSystem.predicates().hasType(InputFile.Type.MAIN)); + Iterable inputFiles = fileSystem.inputFiles(mainFilePredicate); + List filenames = StreamSupport.stream(inputFiles.spliterator(), false).map(InputFile::toString).toList(); + ProgressReport progressReport = new ProgressReport("Progress of the " + language.getName() + " analysis", TimeUnit.SECONDS.toMillis(10)); + progressReport.start(filenames); + boolean success = false; + ASTConverter converter = ASTConverterValidation.wrap(astConverter(sensorContext), sensorContext.config()); + try { + success = analyseFiles(converter, sensorContext, inputFiles, progressReport, visitors(sensorContext, statistics), statistics); + } finally { + if (success) { + progressReport.stop(); + } else { + progressReport.cancel(); + } + converter.terminate(); + } + statistics.log(); + } + + private List> visitors(SensorContext sensorContext, DurationStatistics statistics) { + if (sensorContext.runtime().getProduct() == SonarProduct.SONARLINT) { + return Arrays.asList( + new IssueSuppressionVisitor(), + new SkipNoSonarLinesVisitor(noSonarFilter), + new ChecksVisitor(checks(), statistics)); + } else { + return Arrays.asList( + new IssueSuppressionVisitor(), + new MetricVisitor(fileLinesContextFactory, executableLineOfCodePredicate()), + new SkipNoSonarLinesVisitor(noSonarFilter), + new ChecksVisitor(checks(), statistics), + new CpdVisitor(), + new SyntaxHighlighter()); + } + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/StatementsVisitor.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/StatementsVisitor.java new file mode 100644 index 00000000..0845b811 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/StatementsVisitor.java @@ -0,0 +1,75 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import org.sonarsource.slang.api.BlockTree; +import org.sonarsource.slang.api.ClassDeclarationTree; +import org.sonarsource.slang.api.FunctionDeclarationTree; +import org.sonarsource.slang.api.ImportDeclarationTree; +import org.sonarsource.slang.api.NativeTree; +import org.sonarsource.slang.api.PackageDeclarationTree; +import org.sonarsource.slang.api.TopLevelTree; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.visitors.TreeContext; +import org.sonarsource.slang.visitors.TreeVisitor; + +public class StatementsVisitor extends TreeVisitor { + + private int statements; + + public StatementsVisitor() { + + register(BlockTree.class, (ctx, tree) -> tree.statementOrExpressions().forEach(stmt -> { + if (!isDeclaration(stmt)) { + statements++; + } + })); + + register(TopLevelTree.class, (ctx, tree) -> tree.declarations().forEach(decl -> { + if (!isDeclaration(decl) && !isNative(decl) && !isBlock(decl)) { + statements++; + } + })); + } + + public int statements(Tree tree) { + statements = 0; + scan(new TreeContext(), tree); + return statements; + } + + @Override + protected void before(TreeContext ctx, Tree root) { + statements = 0; + } + + private static boolean isDeclaration(Tree tree) { + return tree instanceof ClassDeclarationTree + || tree instanceof FunctionDeclarationTree + || tree instanceof PackageDeclarationTree + || tree instanceof ImportDeclarationTree; + } + + private static boolean isNative(Tree tree) { + return tree instanceof NativeTree; + } + + private static boolean isBlock(Tree tree) { + return tree instanceof BlockTree; + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SyntaxHighlighter.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SyntaxHighlighter.java new file mode 100644 index 00000000..09a841d3 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/SyntaxHighlighter.java @@ -0,0 +1,65 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import org.sonar.api.batch.sensor.highlighting.NewHighlighting; +import org.sonar.api.batch.sensor.highlighting.TypeOfText; +import org.sonarsource.slang.api.LiteralTree; +import org.sonarsource.slang.api.StringLiteralTree; +import org.sonarsource.slang.api.TextRange; +import org.sonarsource.slang.api.Token; +import org.sonarsource.slang.api.TopLevelTree; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.visitors.TreeVisitor; + +import static org.sonar.api.batch.sensor.highlighting.TypeOfText.COMMENT; +import static org.sonar.api.batch.sensor.highlighting.TypeOfText.CONSTANT; +import static org.sonar.api.batch.sensor.highlighting.TypeOfText.KEYWORD; +import static org.sonar.api.batch.sensor.highlighting.TypeOfText.STRING; + +public class SyntaxHighlighter extends TreeVisitor { + + private NewHighlighting newHighlighting; + + public SyntaxHighlighter() { + register(TopLevelTree.class, (ctx, tree) -> { + tree.allComments().forEach( + comment -> highlight(ctx, comment.textRange(), COMMENT)); + tree.metaData().tokens().stream() + .filter(t -> t.type() == Token.Type.KEYWORD) + .forEach(token -> highlight(ctx, token.textRange(), KEYWORD)); + }); + + register(LiteralTree.class, (ctx, tree) -> highlight(ctx, tree.metaData().textRange(), tree instanceof StringLiteralTree ? STRING : CONSTANT)); + } + + @Override + protected void before(InputFileContext ctx, Tree root) { + newHighlighting = ctx.sensorContext.newHighlighting() + .onFile(ctx.inputFile); + } + + @Override + protected void after(InputFileContext ctx, Tree root) { + newHighlighting.save(); + } + + private void highlight(InputFileContext ctx, TextRange range, TypeOfText typeOfText) { + newHighlighting.highlight(ctx.textRange(range), typeOfText); + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/caching/HashCacheUtils.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/caching/HashCacheUtils.java new file mode 100644 index 00000000..3f4c4bfa --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/caching/HashCacheUtils.java @@ -0,0 +1,126 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin.caching; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.batch.sensor.cache.ReadCache; +import org.sonar.api.batch.sensor.cache.WriteCache; +import org.sonar.go.plugin.InputFileContext; + +/** + * A utility class that enables developers to check that a file has changed and commit its checksum for future analysis. + */ +public class HashCacheUtils { + private static final Logger LOG = LoggerFactory.getLogger(HashCacheUtils.class); + private static final String BYTES_TO_HEX_FORMAT = "%032X"; + + private HashCacheUtils() { + /* Instances of this utility class should not be created. */ + } + + /** + * Checks that a file that matches it previous hash in the cache. + * + * @param inputFileContext The context joining both SensorContext and InputFile + * @return True if the file has its status set to InputFile.Status.SAME and matches its MD5 hash in the cache. + */ + public static boolean hasSameHashCached(InputFileContext inputFileContext) { + InputFile inputFile = inputFileContext.inputFile; + String fileKey = inputFile.key(); + if (inputFile.status() != InputFile.Status.SAME) { + LOG.debug("File {} is considered changed: file status is {}.", fileKey, inputFile.status()); + return false; + } + SensorContext sensorContext = inputFileContext.sensorContext; + if (!sensorContext.isCacheEnabled()) { + LOG.debug("File {} is considered changed: hash cache is disabled.", fileKey); + return false; + } + String hashKey = computeKey(inputFile); + ReadCache previousCache = sensorContext.previousCache(); + if (!previousCache.contains(hashKey)) { + LOG.debug("File {} is considered changed: hash could not be found in the cache.", fileKey); + return false; + } + + byte[] expectedHashAsBytes; + try (InputStream in = previousCache.read(hashKey)) { + expectedHashAsBytes = in.readAllBytes(); + } catch (IOException e) { + LOG.debug("File {} is considered changed: failed to read hash from the cache.", fileKey); + return false; + } + String expected = md5sumBytesToHex(expectedHashAsBytes); + String actual = inputFile.md5Hash(); + return expected.equals(actual); + } + + /** + * Copies the hash from the previous analysis for the next one. + * For consistency, this method should only be called if the hash matches (see hasSameHashCached). + * + * @param inputFileContext Context joining SensorContext and InputFile. + * @return true if successfully copied. False if failing to copy because of a runtime caching issue or repeated call. + */ + public static boolean copyFromPrevious(InputFileContext inputFileContext) { + if (!inputFileContext.sensorContext.isCacheEnabled()) { + return false; + } + InputFile inputFile = inputFileContext.inputFile; + String cacheKey = computeKey(inputFile); + WriteCache nextCache = inputFileContext.sensorContext.nextCache(); + try { + nextCache.copyFromPrevious(cacheKey); + } catch (IllegalArgumentException ignored) { + LOG.warn("Failed to copy hash from previous analysis for {}.", inputFile.key()); + return false; + } + return true; + } + + public static boolean writeHashForNextAnalysis(InputFileContext inputFileContext) { + if (!inputFileContext.sensorContext.isCacheEnabled()) { + return false; + } + InputFile inputFile = inputFileContext.inputFile; + WriteCache nextCache = inputFileContext.sensorContext.nextCache(); + try { + nextCache.write(computeKey(inputFileContext.inputFile), inputFile.md5Hash().getBytes(StandardCharsets.UTF_8)); + } catch (IllegalArgumentException ignored) { + LOG.warn("Failed to write hash for {} to cache.", inputFile.key()); + return false; + } + return true; + } + + private static String computeKey(InputFile inputFile) { + return "slang:hash:" + inputFile.key(); + } + + private static String md5sumBytesToHex(byte[] bytes) { + BigInteger bi = new BigInteger(1, bytes); + return BYTES_TO_HEX_FORMAT.formatted(bi).toLowerCase(Locale.getDefault()); + } +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/caching/package-info.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/caching/package-info.java new file mode 100644 index 00000000..a38b433b --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/caching/package-info.java @@ -0,0 +1,18 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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/ + */ +@javax.annotation.ParametersAreNonnullByDefault +package org.sonar.go.plugin.caching; diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/converter/ASTConverterValidation.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/converter/ASTConverterValidation.java new file mode 100644 index 00000000..2a55ebe4 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/converter/ASTConverterValidation.java @@ -0,0 +1,338 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin.converter; + +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.api.config.Configuration; +import org.sonarsource.slang.api.ASTConverter; +import org.sonarsource.slang.api.Comment; +import org.sonarsource.slang.api.IdentifierTree; +import org.sonarsource.slang.api.TextPointer; +import org.sonarsource.slang.api.TextRange; +import org.sonarsource.slang.api.Token; +import org.sonarsource.slang.api.TopLevelTree; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.api.TreeMetaData; +import org.sonarsource.slang.impl.LiteralTreeImpl; +import org.sonarsource.slang.impl.NativeTreeImpl; +import org.sonarsource.slang.impl.PlaceHolderTreeImpl; +import org.sonarsource.slang.impl.TextPointerImpl; + +import static org.sonarsource.slang.utils.LogArg.lazyArg; + +public class ASTConverterValidation implements ASTConverter { + + private static final Logger LOG = LoggerFactory.getLogger(ASTConverterValidation.class); + + private static final Pattern PUNCTUATOR_PATTERN = Pattern.compile("[^0-9A-Za-z]++"); + + private static final Set ALLOWED_MISPLACED_TOKENS_OUTSIDE_PARENT_RANGE = new HashSet<>(Collections.singleton("implicit")); + + private final ASTConverter wrapped; + + private final Map firstErrorOfEachKind = new TreeMap<>(); + + private final ValidationMode mode; + + public enum ValidationMode { + THROW_EXCEPTION, + LOG_ERROR + } + + @Nullable + private String currentFile = null; + + public ASTConverterValidation(ASTConverter wrapped, ValidationMode mode) { + this.wrapped = wrapped; + this.mode = mode; + } + + public static ASTConverter wrap(ASTConverter converter, Configuration configuration) { + String mode = configuration.get("sonar.slang.converter.validation").orElse(null); + if (mode == null) { + return converter; + } else if (mode.equals("throw")) { + return new ASTConverterValidation(converter, ValidationMode.THROW_EXCEPTION); + } else if (mode.equals("log")) { + return new ASTConverterValidation(converter, ValidationMode.LOG_ERROR); + } else { + throw new IllegalStateException("Unsupported mode: " + mode); + } + } + + @Override + public Tree parse(String content) { + return parse(content, null); + } + + @Override + public Tree parse(String content, @Nullable String currentFile) { + this.currentFile = currentFile; + Tree tree = wrapped.parse(content, currentFile); + assertTreeIsValid(tree); + assertTokensMatchSourceCode(tree, content); + return tree; + } + + @Override + public void terminate() { + List errors = errors(); + if (!errors.isEmpty()) { + String delimiter = "\n [AST ERROR] "; + LOG.error("AST Converter Validation detected {} errors:{}{}", errors.size(), delimiter, lazyArg(() -> String.join(delimiter, errors))); + } + wrapped.terminate(); + } + + ValidationMode mode() { + return mode; + } + + List errors() { + return firstErrorOfEachKind.entrySet().stream() + .map(entry -> entry.getKey() + entry.getValue()) + .toList(); + } + + private void raiseError(String messageKey, String messageDetails, TextPointer position) { + if (mode == ValidationMode.THROW_EXCEPTION) { + throw new IllegalStateException("ASTConverterValidationException: " + messageKey + messageDetails + + " at " + position.line() + ":" + position.lineOffset()); + } else { + String positionDetails = String.format(" (line: %d, column: %d)", position.line(), (position.lineOffset() + 1)); + if (currentFile != null) { + positionDetails += " in file: " + currentFile; + } + firstErrorOfEachKind.putIfAbsent(messageKey, messageDetails + positionDetails); + } + } + + private static String kind(Tree tree) { + return tree.getClass().getSimpleName(); + } + + private void assertTreeIsValid(Tree tree) { + assertTextRangeIsValid(tree); + assertTreeHasAtLeastOneToken(tree); + assertTokensAndChildTokens(tree); + for (Tree child : tree.children()) { + if (child == null) { + raiseError(kind(tree) + " has a null child", "", tree.textRange().start()); + } else if (child.metaData() == null) { + raiseError(kind(child) + " metaData is null", "", tree.textRange().start()); + } else { + assertTreeIsValid(child); + } + } + } + + private void assertTextRangeIsValid(Tree tree) { + TextPointer start = tree.metaData().textRange().start(); + TextPointer end = tree.metaData().textRange().end(); + + boolean startOffsetAfterEndOffset = !(tree instanceof TopLevelTree) && + start.line() == end.line() && + start.lineOffset() >= end.lineOffset(); + + if (start.line() <= 0 || end.line() <= 0 || + start.line() > end.line() || + start.lineOffset() < 0 || end.lineOffset() < 0 || + startOffsetAfterEndOffset) { + raiseError(kind(tree) + " invalid range ", tree.metaData().textRange().toString(), start); + } + } + + private void assertTreeHasAtLeastOneToken(Tree tree) { + if (!(tree instanceof TopLevelTree) && tree.metaData().tokens().isEmpty()) { + raiseError(kind(tree) + " has no token", "", tree.textRange().start()); + } + } + + private void assertTokensMatchSourceCode(Tree tree, String code) { + CodeFormToken codeFormToken = new CodeFormToken(tree.metaData()); + codeFormToken.assertEqualTo(code); + } + + private void assertTokensAndChildTokens(Tree tree) { + assertTokensAreInsideRange(tree); + Set parentTokens = new HashSet<>(tree.metaData().tokens()); + Map childByToken = new HashMap<>(); + for (Tree child : tree.children()) { + if (child != null && child.metaData() != null && !isAllowedMisplacedTree(child)) { + assertChildRangeIsInsideParentRange(tree, child); + assertChildTokens(parentTokens, childByToken, tree, child); + } + } + parentTokens.removeAll(childByToken.keySet()); + assertUnexpectedTokenKind(tree, parentTokens); + } + + private static boolean isAllowedMisplacedTree(Tree tree) { + List tokens = tree.metaData().tokens(); + return tokens.size() == 1 && ALLOWED_MISPLACED_TOKENS_OUTSIDE_PARENT_RANGE.contains(tokens.get(0).text()); + } + + private void assertUnexpectedTokenKind(Tree tree, Set tokens) { + if (tree instanceof NativeTreeImpl || tree instanceof LiteralTreeImpl || tree instanceof PlaceHolderTreeImpl) { + return; + } + List unexpectedTokens; + if (tree instanceof IdentifierTree) { + unexpectedTokens = tokens.stream() + .filter(token -> token.type() == Token.Type.KEYWORD || token.type() == Token.Type.STRING_LITERAL) + .toList(); + } else { + unexpectedTokens = tokens.stream() + .filter(token -> token.type() != Token.Type.KEYWORD) + .filter(token -> !PUNCTUATOR_PATTERN.matcher(token.text()).matches()) + .filter(token -> !ALLOWED_MISPLACED_TOKENS_OUTSIDE_PARENT_RANGE.contains(token.text())) + .toList(); + } + if (!unexpectedTokens.isEmpty()) { + String tokenList = unexpectedTokens.stream() + .sorted(Comparator.comparing(token -> token.textRange().start())) + .map(Token::text) + .collect(Collectors.joining("', '")); + raiseError("Unexpected tokens in " + kind(tree), ": '" + tokenList + "'", tree.textRange().start()); + } + } + + private void assertTokensAreInsideRange(Tree tree) { + TextRange parentRange = tree.metaData().textRange(); + tree.metaData().tokens().stream() + .filter(token -> !ALLOWED_MISPLACED_TOKENS_OUTSIDE_PARENT_RANGE.contains(token.text())) + .filter(token -> !token.textRange().isInside(parentRange)) + .findFirst() + .ifPresent(token -> raiseError( + kind(tree) + " contains a token outside its range", + " range: " + parentRange + " tokenRange: " + token.textRange() + " token: '" + token.text() + "'", + token.textRange().start())); + } + + private void assertChildRangeIsInsideParentRange(Tree parent, Tree child) { + TextRange parentRange = parent.metaData().textRange(); + TextRange childRange = child.metaData().textRange(); + if (!childRange.isInside(parentRange)) { + raiseError(kind(parent) + " contains a child " + kind(child) + " outside its range", + ", parentRange: " + parentRange + " childRange: " + childRange, + childRange.start()); + } + } + + private void assertChildTokens(Set parentTokens, Map childByToken, Tree parent, Tree child) { + for (Token token : child.metaData().tokens()) { + if (!parentTokens.contains(token)) { + raiseError(kind(child) + " contains a token missing in its parent " + kind(parent), + ", token: '" + token.text() + "'", + token.textRange().start()); + } + Tree intersectingChild = childByToken.get(token); + if (intersectingChild != null) { + raiseError(kind(parent) + " has a token used by both children " + kind(intersectingChild) + " and " + kind(child), + ", token: '" + token.text() + "'", + token.textRange().start()); + } else { + childByToken.put(token, child); + } + } + } + + private class CodeFormToken { + + private final StringBuilder code = new StringBuilder(); + private final List commentsInside; + private int lastLine = 1; + private int lastLineOffset = 0; + private int lastComment = 0; + + private CodeFormToken(TreeMetaData metaData) { + this.commentsInside = metaData.commentsInside(); + metaData.tokens().forEach(this::add); + addRemainingComments(); + } + + private void add(Token token) { + while (lastComment < commentsInside.size() && + commentsInside.get(lastComment).textRange().start().compareTo(token.textRange().start()) < 0) { + Comment comment = commentsInside.get(lastComment); + addTextAt(comment.text(), comment.textRange()); + lastComment++; + } + addTextAt(token.text(), token.textRange()); + } + + private void addRemainingComments() { + for (int i = lastComment; i < commentsInside.size(); i++) { + addTextAt(commentsInside.get(i).text(), commentsInside.get(i).textRange()); + } + } + + private void addTextAt(String text, TextRange textRange) { + while (lastLine < textRange.start().line()) { + code.append("\n"); + lastLine++; + lastLineOffset = 0; + } + while (lastLineOffset < textRange.start().lineOffset()) { + code.append(' '); + lastLineOffset++; + } + code.append(text); + lastLine = textRange.end().line(); + lastLineOffset = textRange.end().lineOffset(); + } + + private void assertEqualTo(String expectedCode) { + String[] actualLines = lines(this.code.toString()); + String[] expectedLines = lines(expectedCode); + for (int i = 0; i < actualLines.length && i < expectedLines.length; i++) { + if (!actualLines[i].equals(expectedLines[i])) { + raiseError("Unexpected AST difference", ":\n" + + " Actual : " + actualLines[i] + "\n" + + " Expected : " + expectedLines[i] + "\n", + new TextPointerImpl(i + 1, 0)); + } + } + if (actualLines.length != expectedLines.length) { + raiseError( + "Unexpected AST number of lines", + " actual: " + actualLines.length + ", expected: " + expectedLines.length, + new TextPointerImpl(Math.min(actualLines.length, expectedLines.length), 0)); + } + } + + private String[] lines(String code) { + return code + .replace('\t', ' ') + .replaceFirst("[\r\n ]+$", "") + .split(" *(\r\n|\n|\r)", -1); + } + } + +} diff --git a/sonar-go-plugin/src/main/java/org/sonar/go/plugin/converter/package-info.java b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/converter/package-info.java new file mode 100644 index 00000000..59ce75b9 --- /dev/null +++ b/sonar-go-plugin/src/main/java/org/sonar/go/plugin/converter/package-info.java @@ -0,0 +1,18 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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/ + */ +@javax.annotation.ParametersAreNonnullByDefault +package org.sonar.go.plugin.converter; diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/checks/GoVerifier.java b/sonar-go-plugin/src/test/java/org/sonar/go/checks/GoVerifier.java index 387ffd1c..6d07fdb5 100644 --- a/sonar-go-plugin/src/test/java/org/sonar/go/checks/GoVerifier.java +++ b/sonar-go-plugin/src/test/java/org/sonar/go/checks/GoVerifier.java @@ -24,7 +24,7 @@ import java.util.List; import java.util.function.BiConsumer; import javax.annotation.Nullable; -import org.sonar.go.converter.GoConverter; +import org.sonar.go.testing.TestGoConverter; import org.sonarsource.analyzer.commons.checks.verifier.SingleFileVerifier; import org.sonarsource.slang.api.ASTConverter; import org.sonarsource.slang.api.HasTextRange; @@ -43,10 +43,9 @@ public class GoVerifier { private static final Path BASE_DIR = Paths.get("src", "test", "resources", "checks"); - private static final ASTConverter CONVERTER = new GoConverter(Paths.get("build", "tmp").toFile()); public static void verify(String fileName, SlangCheck check) { - verify(CONVERTER, BASE_DIR.resolve(fileName), check); + verify(TestGoConverter.GO_CONVERTER, BASE_DIR.resolve(fileName), check); } public static void verify(ASTConverter converter, Path path, SlangCheck check) { diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/converter/GoConverterTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/converter/GoConverterTest.java index a55ca7e4..d56d498f 100644 --- a/sonar-go-plugin/src/test/java/org/sonar/go/converter/GoConverterTest.java +++ b/sonar-go-plugin/src/test/java/org/sonar/go/converter/GoConverterTest.java @@ -17,11 +17,11 @@ package org.sonar.go.converter; import java.io.IOException; -import java.nio.file.Paths; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; +import org.sonar.go.testing.TestGoConverter; import org.sonarsource.slang.api.BlockTree; import org.sonarsource.slang.api.ClassDeclarationTree; import org.sonarsource.slang.api.FunctionDeclarationTree; @@ -44,8 +44,7 @@ class GoConverterTest { @Test void test_parse_return() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); - Tree tree = converter.parse("package main\nfunc foo() {return 42}"); + Tree tree = TestGoConverter.parse("package main\nfunc foo() {return 42}"); List returnList = getReturnsList(tree); assertThat(returnList).hasSize(1); @@ -54,8 +53,7 @@ void test_parse_return() { @Test void test_parse_binary_notation() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); - Tree tree = converter.parse("package main\nfunc foo() {return 0b_0010_1010}"); + Tree tree = TestGoConverter.parse("package main\nfunc foo() {return 0b_0010_1010}"); List returnList = getReturnsList(tree); assertThat(returnList).hasSize(1); @@ -64,16 +62,14 @@ void test_parse_binary_notation() { @Test void test_parse_imaginary_literals() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); - Tree tree = converter.parse("package main\nfunc foo() {return 6.67428e-11i}"); + Tree tree = TestGoConverter.parse("package main\nfunc foo() {return 6.67428e-11i}"); List returnList = getReturnsList(tree); assertThat(returnList).hasSize(1); } @Test void test_parse_embed_overlapping_interfaces() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); - Tree tree = converter.parse("package main\ntype A interface{\n DoX() string\n}\ntype B interface{\n DoX() \n}\ntype AB interface{\n A\n B\n}"); + Tree tree = TestGoConverter.parse("package main\ntype A interface{\n DoX() string\n}\ntype B interface{\n DoX() \n}\ntype AB interface{\n A\n B\n}"); List classList = tree.descendants() .filter(t -> t instanceof ClassDeclarationTree) .collect(Collectors.toList()); @@ -82,16 +78,14 @@ void test_parse_embed_overlapping_interfaces() { @Test void test_parse_infinite_for() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); - Tree tree = converter.parse("package main\nfunc foo() {for {}}"); + Tree tree = TestGoConverter.parse("package main\nfunc foo() {for {}}"); List returnList = tree.descendants().filter(t -> t instanceof LoopTree).collect(Collectors.toList()); assertThat(returnList).hasSize(1); } @Test void test_parse_generics() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); - Tree tree = converter.parse("package main\nfunc f1[T any]() {}\nfunc f2() {\nf:=f1[string]}"); + Tree tree = TestGoConverter.parse("package main\nfunc f1[T any]() {}\nfunc f2() {\nf:=f1[string]}"); List functions = tree.descendants().filter(t -> t instanceof FunctionDeclarationTree).collect(Collectors.toList()); assertThat(functions).hasSize(2); @@ -108,9 +102,8 @@ void test_parse_generics() { @Test void parse_error() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); ParseException e = assertThrows(ParseException.class, - () -> converter.parse("$!#@")); + () -> TestGoConverter.parse("$!#@")); assertThat(e).hasMessage("Go parser external process returned non-zero exit value: 2"); } @@ -126,24 +119,22 @@ void invalid_command() { @Test void parse_accepted_big_file() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); String code = "package main\n" + "func foo() {\n" + "}\n"; String bigCode = code + new String(new char[700_000 - code.length()]).replace("\0", "\n"); - Tree tree = converter.parse(bigCode); + Tree tree = TestGoConverter.parse(bigCode); assertThat(tree).isInstanceOf(TopLevelTree.class); } @Test void parse_rejected_big_file() { - GoConverter converter = new GoConverter(Paths.get("build", "tmp").toFile()); String code = "package main\n" + "func foo() {\n" + "}\n"; String bigCode = code + new String(new char[1_500_000]).replace("\0", "\n"); ParseException e = assertThrows(ParseException.class, - () -> converter.parse(bigCode)); + () -> TestGoConverter.parse(bigCode)); assertThat(e).hasMessage("The file size is too big and should be excluded, its size is 1500028 (maximum allowed is 1500000 bytes)"); } diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/externalreport/AbstractPropertyHandlerSensorTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/externalreport/AbstractPropertyHandlerSensorTest.java new file mode 100644 index 00000000..76beccdd --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/externalreport/AbstractPropertyHandlerSensorTest.java @@ -0,0 +1,145 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.externalreport; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.event.Level; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.batch.sensor.internal.DefaultSensorDescriptor; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.testfixtures.log.LogTesterJUnit5; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +class AbstractPropertyHandlerSensorTest { + + private static final List ANALYSIS_WARNINGS = new ArrayList<>(); + private static final Path PROJECT_DIR = Paths.get("src", "test", "resources", "propertyHandler"); + + @RegisterExtension + public LogTesterJUnit5 logTester = new LogTesterJUnit5().setLevel(Level.DEBUG); + + @BeforeEach + void setup() { + ANALYSIS_WARNINGS.clear(); + } + + @Test + void test_descriptor() { + DefaultSensorDescriptor sensorDescriptor = new DefaultSensorDescriptor(); + PropertyHandlerSensorTester sensor = new PropertyHandlerSensorTester(); + sensor.describe(sensorDescriptor); + assertThat(sensorDescriptor.name()).isEqualTo("Import of propertyName issues"); + assertThat(sensorDescriptor.languages()).containsOnly("dummy"); + assertThat(logTester.logs()).isEmpty(); + } + + @Test + void test_configuration() { + PropertyHandlerSensorTester sensor = new PropertyHandlerSensorTester(); + assertThat(sensor.propertyKey()).isEqualTo("propertyKey"); + assertThat(sensor.propertyName()).isEqualTo("propertyName"); + assertThat(sensor.configurationKey()).isEqualTo("sonar.configuration.key"); + } + + @Test + void do_nothing_if_property_not_configured() throws Exception { + SensorContextTester context = createContext(PROJECT_DIR); + PropertyHandlerSensorTester sensor = new PropertyHandlerSensorTester(); + sensor.execute(context); + + assertThat(ANALYSIS_WARNINGS).isEmpty(); + assertThat(logTester.logs()).isEmpty(); + } + + @Test + void report_issue_if_file_missing() throws Exception { + SensorContextTester context = createContext(PROJECT_DIR); + PropertyHandlerSensorTester sensor = new PropertyHandlerSensorTester(); + context.settings().setProperty("sonar.configuration.key", "missing-file1.txt,missing-file2.txt,dummyReport.txt,missing-file3.txt"); + + sensor.execute(context); + List infos = logTester.logs(Level.INFO); + assertThat(infos).hasSize(1); + assertThat(infos.get(0)) + .startsWith("Importing") + .endsWith("dummyReport.txt"); + + List warnings = logTester.logs(Level.WARN); + assertThat(warnings) + .hasSize(1) + .hasSameSizeAs(ANALYSIS_WARNINGS); + assertThat(warnings.get(0)) + .startsWith("Unable to import propertyName report file(s):") + .contains("missing-file1.txt") + .contains("missing-file2.txt") + .contains("missing-file3.txt") + .doesNotContain("dummyReport.txt") + .endsWith("The report file(s) can not be found. Check that the property 'sonar.configuration.key' is correctly configured."); + assertThat(ANALYSIS_WARNINGS.get(0)) + .startsWith("Unable to import 3 propertyName report file(s).") + .endsWith("Please check that property 'sonar.configuration.key' is correctly configured and the analysis logs for more details."); + } + + private static SensorContextTester createContext(Path projectDir) throws IOException { + SensorContextTester context = SensorContextTester.create(projectDir); + Files.list(projectDir) + .filter(file -> !Files.isDirectory(file)) + .forEach(file -> addFileToContext(context, projectDir, file)); + return context; + } + + private static void addFileToContext(SensorContextTester context, Path projectDir, Path file) { + try { + String projectId = projectDir.getFileName().toString() + "-project"; + context.fileSystem().add(TestInputFileBuilder.create(projectId, projectDir.toFile(), file.toFile()) + .setCharset(UTF_8) + .setLanguage("dummy") + .setContents(new String(Files.readAllBytes(file), UTF_8)) + .setType(InputFile.Type.MAIN) + .build()); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static class PropertyHandlerSensorTester extends AbstractPropertyHandlerSensor { + + private PropertyHandlerSensorTester() { + super(ANALYSIS_WARNINGS::add, "propertyKey", "propertyName", "sonar.configuration.key", "dummy"); + } + + @Override + public Consumer reportConsumer(SensorContext context) { + return file -> { + /* do nothing */ }; + } + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/AbstractSensorTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/AbstractSensorTest.java new file mode 100644 index 00000000..c64a596e --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/AbstractSensorTest.java @@ -0,0 +1,99 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import javax.annotation.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.event.Level; +import org.sonar.api.SonarEdition; +import org.sonar.api.SonarQubeSide; +import org.sonar.api.SonarRuntime; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.rule.CheckFactory; +import org.sonar.api.batch.rule.internal.ActiveRulesBuilder; +import org.sonar.api.batch.rule.internal.NewActiveRule; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.internal.SonarRuntimeImpl; +import org.sonar.api.measures.FileLinesContext; +import org.sonar.api.measures.FileLinesContextFactory; +import org.sonar.api.resources.Language; +import org.sonar.api.rule.RuleKey; +import org.sonar.api.testfixtures.log.LogTesterJUnit5; +import org.sonar.api.utils.Version; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public abstract class AbstractSensorTest { + + protected File baseDir; + protected SensorContextTester context; + protected FileLinesContextFactory fileLinesContextFactory = mock(FileLinesContextFactory.class); + public static final SonarRuntime SQ_LTS_RUNTIME = SonarRuntimeImpl.forSonarQube(Version.create(8, 9), SonarQubeSide.SCANNER, SonarEdition.DEVELOPER); + + @RegisterExtension + public LogTesterJUnit5 logTester = new LogTesterJUnit5().setLevel(Level.DEBUG); + + @BeforeEach + public void setup(@TempDir File tmpBaseDir) { + baseDir = tmpBaseDir; + context = SensorContextTester.create(baseDir); + FileLinesContext fileLinesContext = mock(FileLinesContext.class); + when(fileLinesContextFactory.createFor(any(InputFile.class))).thenReturn(fileLinesContext); + } + + protected CheckFactory checkFactory(String... ruleKeys) { + ActiveRulesBuilder builder = new ActiveRulesBuilder(); + for (String ruleKey : ruleKeys) { + NewActiveRule newRule = new NewActiveRule.Builder() + .setRuleKey(RuleKey.of(repositoryKey(), ruleKey)) + .setName(ruleKey) + .build(); + builder.addRule(newRule); + } + context.setActiveRules(builder.build()); + return new CheckFactory(context.activeRules()); + } + + protected InputFile createInputFile(String relativePath, String content) { + return createInputFile(relativePath, content, null); + } + + protected InputFile createInputFile(String relativePath, String content, @Nullable InputFile.Status status) { + TestInputFileBuilder builder = new TestInputFileBuilder("moduleKey", relativePath) + .setModuleBaseDir(baseDir.toPath()) + .setType(InputFile.Type.MAIN) + .setLanguage(language().getKey()) + .setCharset(StandardCharsets.UTF_8) + .setContents(content); + if (status != null) { + builder.setStatus(status); + } + return builder.build(); + } + + protected abstract String repositoryKey(); + + protected abstract Language language(); + +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CommentAnalysisUtilsTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CommentAnalysisUtilsTest.java new file mode 100644 index 00000000..e74f8b64 --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CommentAnalysisUtilsTest.java @@ -0,0 +1,82 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.sonarsource.slang.api.Comment; +import org.sonarsource.slang.api.TextRange; +import org.sonarsource.slang.impl.CommentImpl; +import org.sonarsource.slang.impl.TextRangeImpl; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommentAnalysisUtilsTest { + + private static final String CODE = """ + // NOSONAR + def fun() { + /* todo */ + }"""; + private static final TextRange CODE_TEXT_RANGE = new TextRangeImpl(1, 0, 4, 0); + + @Test + void testNosonarComment() { + TextRange noSonarCommentTextRange = new TextRangeImpl(1, 2, 1, 10); + Comment nosonarComment = new CommentImpl(CODE, " NOSONAR ", CODE_TEXT_RANGE, noSonarCommentTextRange); + assertThat(CommentAnalysisUtils.isNosonarComment(nosonarComment)).isTrue(); + } + + @Test + void testNotNosonarComment() { + TextRange todoCommentTextRange = new TextRangeImpl(3, 2, 3, 8); + Comment todoComment = new CommentImpl(CODE, " todo ", CODE_TEXT_RANGE, todoCommentTextRange); + assertThat(CommentAnalysisUtils.isNosonarComment(todoComment)).isFalse(); + } + + @Test + void testAddNonBlankSingleLineComment() { + testAddCommentLines("single line comment", + new TextRangeImpl(2, 2, 2, 17), + Set.of(2)); + } + + @Test + void testAddBlankSingleLineComment() { + testAddCommentLines("*#=|", + new TextRangeImpl(2, 2, 2, 6), + Set.of()); + } + + @Test + void testAddNonBlankMultiLineComment() { + testAddCommentLines("multi \nline \ncomment", + new TextRangeImpl(7, 2, 9, 7), + Set.of(7, 8, 9)); + } + + @Test + void testAddBlankMultiLineComment() { + testAddCommentLines(" \n#= \n*|", + new TextRangeImpl(7, 2, 9, 4), + Set.of()); + } + + private void testAddCommentLines(String comment, TextRange commentTextRange, Set expectedCommentLines) { + assertThat(CommentAnalysisUtils.findNonEmptyCommentLines(commentTextRange, comment)).containsAll(expectedCommentLines); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CpdVisitorTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CpdVisitorTest.java new file mode 100644 index 00000000..7ab051fb --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CpdVisitorTest.java @@ -0,0 +1,485 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.event.Level; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.DefaultInputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.sensor.cache.ReadCache; +import org.sonar.api.batch.sensor.cache.WriteCache; +import org.sonar.api.batch.sensor.cpd.internal.TokensLine; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.testfixtures.log.LogTesterJUnit5; +import org.sonar.go.plugin.caching.DummyReadCache; +import org.sonar.go.plugin.caching.DummyWriteCache; +import org.sonar.go.testing.TestGoConverter; +import org.sonarsource.slang.api.Token; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.impl.TextRangeImpl; +import org.sonarsource.slang.impl.TokenImpl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.sonar.go.plugin.CpdVisitor.ASCII_RECORD_SEPARATOR; +import static org.sonar.go.plugin.CpdVisitor.ASCII_UNIT_SEPARATOR; +import static org.sonar.go.plugin.CpdVisitor.computeCacheKey; + +class CpdVisitorTest { + + @RegisterExtension + public LogTesterJUnit5 logTester = new LogTesterJUnit5().setLevel(Level.DEBUG); + + @Test + void test(@TempDir File tempFolder) throws Exception { + File file = File.createTempFile("file", ".tmp", tempFolder); + String content = """ + package main + import "fmt" + func main() { + x := 1 + fmt.Println(x * 42) + }"""; + SensorContextTester sensorContext = SensorContextTester.create(tempFolder); + DefaultInputFile inputFile = new TestInputFileBuilder("moduleKey", file.getName()) + .setContents(content) + .build(); + Tree root = TestGoConverter.parse(content); + InputFileContext ctx = new InputFileContext(sensorContext, inputFile); + new CpdVisitor().scan(ctx, root); + + List cpdTokenLines = sensorContext.cpdTokens(inputFile.key()); + assertThat(cpdTokenLines).hasSize(6); + + assertThat(cpdTokenLines.get(0).getValue()).isEqualTo("packagemain"); + assertThat(cpdTokenLines.get(0).getStartLine()).isEqualTo(1); + assertThat(cpdTokenLines.get(0).getStartUnit()).isEqualTo(1); + assertThat(cpdTokenLines.get(0).getEndUnit()).isEqualTo(2); + + assertThat(cpdTokenLines.get(1).getValue()).isEqualTo("importLITERAL"); + assertThat(cpdTokenLines.get(1).getStartLine()).isEqualTo(2); + assertThat(cpdTokenLines.get(1).getStartUnit()).isEqualTo(3); + assertThat(cpdTokenLines.get(1).getEndUnit()).isEqualTo(4); + + assertThat(cpdTokenLines.get(2).getValue()).isEqualTo("funcmain(){"); + assertThat(cpdTokenLines.get(2).getStartLine()).isEqualTo(3); + assertThat(cpdTokenLines.get(2).getStartUnit()).isEqualTo(5); + assertThat(cpdTokenLines.get(2).getEndUnit()).isEqualTo(9); + + assertThat(cpdTokenLines.get(3).getValue()).isEqualTo("x:=1"); + assertThat(cpdTokenLines.get(3).getStartLine()).isEqualTo(4); + assertThat(cpdTokenLines.get(3).getStartUnit()).isEqualTo(10); + assertThat(cpdTokenLines.get(3).getEndUnit()).isEqualTo(12); + + assertThat(cpdTokenLines.get(4).getValue()).isEqualTo("fmt.Println(x*42)"); + assertThat(cpdTokenLines.get(4).getStartLine()).isEqualTo(5); + assertThat(cpdTokenLines.get(4).getStartUnit()).isEqualTo(13); + assertThat(cpdTokenLines.get(4).getEndUnit()).isEqualTo(20); + + assertThat(cpdTokenLines.get(5).getValue()).isEqualTo("}"); + assertThat(cpdTokenLines.get(5).getStartLine()).isEqualTo(6); + assertThat(cpdTokenLines.get(5).getStartUnit()).isEqualTo(21); + assertThat(cpdTokenLines.get(5).getEndUnit()).isEqualTo(21); + } + + @Nested + class BranchAnalysisContext { + private static final List EXPECTED_TOKENS = List.of( + new TokenImpl(new TextRangeImpl(1, 0, 1, 7), "package", Token.Type.KEYWORD), + new TokenImpl(new TextRangeImpl(1, 8, 1, 13), "hello", Token.Type.OTHER), + new TokenImpl(new TextRangeImpl(1, 13, 1, 14), ";", Token.Type.OTHER)); + + private SensorContextTester sensorContext; + private DefaultInputFile inputFile; + private Tree root; + private InputFileContext inputFileContext; + private DummyWriteCache nextCache; + + @BeforeEach + void setup(@TempDir File tempFolder) throws IOException { + File file = File.createTempFile("file", ".tmp", tempFolder); + String content = "package hello;"; + sensorContext = SensorContextTester.create(tempFolder); + inputFile = new TestInputFileBuilder("moduleKey", file.getName()) + .setContents(content) + .build(); + root = TestGoConverter.parse(content); + inputFileContext = new InputFileContext(sensorContext, inputFile); + // Set up the writing cache + nextCache = new DummyWriteCache(); + sensorContext.setNextCache(nextCache); + sensorContext.setCacheEnabled(true); + } + + @Test + void tokens_are_systematically_persisted_in_the_cache_when_caching_is_enabled() { + // Produce tokens + new CpdVisitor().scan(inputFileContext, root); + + String cacheKey = computeCacheKey(inputFile); + assertThat(nextCache.persisted) + .hasSize(1) + .containsKey(cacheKey); + byte[] serialized = nextCache.persisted.get(cacheKey); + assertThat(serialized).isEqualTo(CpdVisitor.serialize(EXPECTED_TOKENS)); + } + + @Test + void tokens_are_not_persisted_in_the_cache_when_caching_is_disabled() { + // Disable the cache + sensorContext.setCacheEnabled(false); + + new CpdVisitor().scan(inputFileContext, root); + + assertThat(nextCache.persisted).isEmpty(); + } + + @Test + void tokens_are_not_persisted_when_the_cache_already_contains_an_entry_for_the_file() { + // Set up the cache where will be writing but where an entry already exists + var cache = new DummyWriteCache(); + String cacheKey = computeCacheKey(inputFile); + cache.persisted.put(cacheKey, new byte[] {}); + sensorContext.setNextCache(cache); + + new CpdVisitor().scan(inputFileContext, root); + + assertThat(cache.persisted).containsOnlyKeys(cacheKey); + String expectedWarningMessage = "Failed to write CPD tokens to cache for input file %s: ".formatted(inputFile.key()) + + "The cache already contains the key: %s".formatted(cacheKey); + assertThat(logTester.logs(Level.WARN)).containsOnly(expectedWarningMessage); + } + } + + @Nested + class PullRequestAnalysisContext { + private static final List EXPECTED_TOKENS = List.of( + new TokenImpl(new TextRangeImpl(1, 0, 1, 6), "import", Token.Type.OTHER), + new TokenImpl(new TextRangeImpl(1, 7, 1, 12), "hello", Token.Type.OTHER), + new TokenImpl(new TextRangeImpl(1, 12, 1, 13), ";", Token.Type.OTHER)); + + private String cacheKey; + private DefaultInputFile inputFile; + private SensorContextTester sensorContext; + private InputFileContext inputFileContext; + private DummyReadCache previousCache; + private DummyWriteCache nextCache; + + @BeforeEach + /** + * Sets up the happy path to reuse tokens. + * - Skipping of unchanged files is enabled + * - Caching is enabled + * - The previous cache contains an entry for the input file with properly serialized tokens + * - The previous and next caches are bound together + */ + public void setup(@TempDir File tempFolder) throws IOException { + // Create file and set its status to something else than SAME + File file = File.createTempFile("file", ".tmp", tempFolder); + String content = "import hello;"; + inputFile = new TestInputFileBuilder("moduleKey", file.getName()) + .setContents(content) + .setStatus(InputFile.Status.SAME) + .build(); + // Set context for PR analysis + sensorContext = spy(SensorContextTester.create(tempFolder)); + sensorContext.setCanSkipUnchangedFiles(true); + sensorContext.setCacheEnabled(true); + inputFileContext = new InputFileContext(sensorContext, inputFile); + // Setup caches + cacheKey = "slang:cpd-tokens:" + inputFile.key(); + previousCache = spy(new DummyReadCache()); + previousCache.persisted.put(cacheKey, CpdVisitor.serialize(EXPECTED_TOKENS)); + sensorContext.setPreviousCache(previousCache); + nextCache = spy(new DummyWriteCache()); + nextCache.bind(previousCache); + sensorContext.setNextCache(nextCache); + } + + private void assertNoInteractionWithNextCache(WriteCache nextCache) { + verify(nextCache, never()).write(any(String.class), any(byte[].class)); + verify(nextCache, never()).write(any(String.class), any(InputStream.class)); + verify(nextCache, never()).copyFromPrevious(any()); + } + + private void assertNoInteractionWithPreviousCache(ReadCache previousCache) { + verify(previousCache, never()).contains(any()); + verify(previousCache, never()).read(any()); + } + + @Test + void reuses_results_from_previous_analysis_when_available_in_cache() { + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isTrue(); + verify(previousCache, times(1)).contains(cacheKey); + verify(previousCache, atLeastOnce()).read(cacheKey); + verify(nextCache, times(1)).copyFromPrevious(cacheKey); + + assertThat(nextCache.persisted).containsAllEntriesOf(previousCache.persisted); + } + + @Test + void does_not_reuse_results_from_previous_analysis_when_cache_is_disabled() { + // Disable the cache + sensorContext.setCacheEnabled(false); + + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isFalse(); + // Ensure there are no unexpected interactions with the caches + verify(sensorContext, times(1)).isCacheEnabled(); + assertNoInteractionWithPreviousCache(previousCache); + assertNoInteractionWithNextCache(nextCache); + assertThat(nextCache.persisted).isEmpty(); + } + + @Test + void does_not_reuse_results_from_previous_analysis_when_unchanged_files_cannot_be_skipped() { + // Disable the skipping of unchanged files + sensorContext.setCanSkipUnchangedFiles(false); + + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isFalse(); + // Ensure there are no unexpected interactions with the caches + verify(sensorContext, never()).isCacheEnabled(); + assertNoInteractionWithPreviousCache(previousCache); + assertNoInteractionWithNextCache(nextCache); + assertThat(nextCache.persisted).isEmpty(); + } + + @Test + void does_not_reuse_results_from_previous_analysis_when_file_is_changed(@TempDir File tempFolder) throws IOException { + // Prepare a file with a status different from InputFile.Status.SAME + File file = File.createTempFile("file", ".tmp", tempFolder); + String content = "import hello;"; + inputFile = new TestInputFileBuilder("moduleKey", file.getName()) + .setContents(content) + .setStatus(InputFile.Status.CHANGED) + .build(); + inputFileContext = new InputFileContext(sensorContext, inputFile); + + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isFalse(); + // Ensure there are no unexpected interactions with the caches + verify(sensorContext, times(1)).isCacheEnabled(); + assertNoInteractionWithPreviousCache(previousCache); + assertNoInteractionWithNextCache(nextCache); + assertThat(nextCache.persisted).isEmpty(); + } + + @Test + void does_not_reuse_results_from_previous_analysis_when_cache_miss() { + // Empty the previous cache + previousCache.persisted.clear(); + + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isFalse(); + // Ensure there are no unexpected interactions with the caches + verify(sensorContext, times(1)).isCacheEnabled(); + verify(previousCache, times(1)).contains(cacheKey); + verify(previousCache, never()).read(any()); + assertNoInteractionWithNextCache(nextCache); + assertThat(nextCache.persisted).isEmpty(); + } + + @Test + void does_not_reuse_results_from_previous_analysis_when_failing_to_deserialize() { + // Replace data in the cache with gibberish that cannot be deserialized + previousCache.persisted.put(cacheKey, new byte[] {0xC, 0xA, 0xF, 0xE}); + + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isFalse(); + // Ensure there are no unexpected interactions with the caches + verify(sensorContext, times(1)).isCacheEnabled(); + verify(previousCache, times(1)).contains(cacheKey); + verify(previousCache, times(1)).read(cacheKey); + assertNoInteractionWithNextCache(nextCache); + assertThat(nextCache.persisted).isEmpty(); + + assertThat(logTester.logs(Level.WARN)).contains( + String.format("Failed to load cached CPD tokens for input file %s.", inputFile.key())); + } + + @Test + void does_not_reuse_results_from_previous_analysis_when_failing_to_read_stream_from_the_cache() throws IOException { + // Replace the previous cache with a cache returning a faulty stream + InputStream in = spy(new ByteArrayInputStream(new byte[] {})); + doThrow(new IOException("This is expected")).when(in).readAllBytes(); + DummyReadCache cacheReturningFaultyStreams = new DummyReadCache() { + @Override + public InputStream read(String ignored) { + return in; + } + }; + cacheReturningFaultyStreams.persisted.putAll(previousCache.persisted); + nextCache.bind(cacheReturningFaultyStreams); + sensorContext.setPreviousCache(cacheReturningFaultyStreams); + + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isFalse(); + assertThat(logTester.logs(Level.WARN)) + .containsOnly("Failed to load cached CPD tokens for input file %s.".formatted(inputFile.key())); + } + + @Test + void does_not_reuse_results_from_previous_analysis_when_failing_to_read_from_the_cache() { + // Replace the previous cache with an empty cache pretending it contains the key + DummyReadCache lyingCache = new DummyReadCache() { + @Override + public boolean contains(String ignored) { + return true; + } + }; + sensorContext.setPreviousCache(lyingCache); + nextCache.bind(lyingCache); + sensorContext.setPreviousCache(lyingCache); + + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isFalse(); + assertThat(logTester.logs(Level.WARN)) + .containsOnly("Failed to load cached CPD tokens for input file %s.".formatted(inputFile.key())); + } + + @Test + void does_not_reuse_results_from_previous_analysis_when_failing_to_copy_from_previous_cache() { + // Replace the next cache with a cache failing to copy from the previous analysis + DummyWriteCache cacheFailingToCopyFromPrevious = new DummyWriteCache() { + @Override + public void copyFromPrevious(String key) { + throw new IllegalArgumentException("This is expected"); + } + }; + sensorContext.setNextCache(cacheFailingToCopyFromPrevious); + cacheFailingToCopyFromPrevious.bind(previousCache); + + CpdVisitor visitor = new CpdVisitor(); + + assertThat(visitor.reusePreviousResults(inputFileContext)).isFalse(); + String expectedWarningMessage = "Failed to copy previous cached results for input file %s.".formatted(inputFile.key()); + assertThat(logTester.logs(Level.WARN)) + .containsOnly(expectedWarningMessage); + } + } + + @Test + void test_computeCacheKey(@TempDir File tempFolder) throws IOException { + File file = File.createTempFile("file", ".tmp", tempFolder); + InputFile inputFile = new TestInputFileBuilder("moduleKey", file.getName()) + .setContents("") + .build(); + assertThat(CpdVisitor.computeCacheKey(inputFile)).isEqualTo("slang:cpd-tokens:" + inputFile.key()); + } + + @Test + void serialize_produces_the_expected_format() { + assertThat(CpdVisitor.serialize(Collections.emptyList())).isEmpty(); + Token token = new TokenImpl(new TextRangeImpl(1, 0, 1, 6), "import", Token.Type.KEYWORD); + List singleToken = Collections.singletonList(token); + byte[] serialized = CpdVisitor.serialize(singleToken); + assertThat(new String(serialized, StandardCharsets.UTF_8)) + .isNotBlank() + .isEqualTo("1,0,1,6" + ASCII_UNIT_SEPARATOR + "import" + ASCII_UNIT_SEPARATOR + "KEYWORD"); + + List tokens = List.of( + new TokenImpl(new TextRangeImpl(1, 0, 1, 7), "correct", Token.Type.KEYWORD), + new TokenImpl(new TextRangeImpl(1, 9, 1, 13), "horse", Token.Type.STRING_LITERAL), + new TokenImpl(new TextRangeImpl(1, 15, 1, 21), "battery", Token.Type.OTHER), + new TokenImpl(new TextRangeImpl(1, 23, 1, 28), "staple", Token.Type.KEYWORD)); + String tokensSerializedAsString = new String(CpdVisitor.serialize(tokens), StandardCharsets.UTF_8); + String[] serializedTokens = tokensSerializedAsString.split(String.valueOf(ASCII_RECORD_SEPARATOR)); + assertThat(serializedTokens).hasSize(4); + + assertThat(serializedTokens[0]).isEqualTo("1,0,1,7" + ASCII_UNIT_SEPARATOR + "correct" + ASCII_UNIT_SEPARATOR + "KEYWORD"); + assertThat(serializedTokens[1]).isEqualTo("1,9,1,13" + ASCII_UNIT_SEPARATOR + "horse" + ASCII_UNIT_SEPARATOR + "STRING_LITERAL"); + assertThat(serializedTokens[2]).isEqualTo("1,15,1,21" + ASCII_UNIT_SEPARATOR + "battery" + ASCII_UNIT_SEPARATOR + "OTHER"); + assertThat(serializedTokens[3]).isEqualTo("1,23,1,28" + ASCII_UNIT_SEPARATOR + "staple" + ASCII_UNIT_SEPARATOR + "KEYWORD"); + } + + @Test + void deserialize_produces_the_expected_tokens() { + // No data + assertThat(CpdVisitor.deserialize(new byte[] {})).isEmpty(); + + byte[] singleSerializedToken = ("1,0,1,6" + ASCII_UNIT_SEPARATOR + "import" + ASCII_UNIT_SEPARATOR + + "KEYWORD").getBytes(StandardCharsets.UTF_8); + List singleToken = CpdVisitor.deserialize(singleSerializedToken); + Token expectedToken = new TokenImpl( + new TextRangeImpl(1, 0, 1, 6), + "import", + Token.Type.KEYWORD); + assertThat(singleToken).containsExactly(expectedToken); + + byte[] serializedTokens = ("1,0,1,7" + ASCII_UNIT_SEPARATOR + "correct" + ASCII_UNIT_SEPARATOR + "KEYWORD" + + ASCII_RECORD_SEPARATOR + + "1,9,1,13" + ASCII_UNIT_SEPARATOR + "horse" + ASCII_UNIT_SEPARATOR + "STRING_LITERAL" + + ASCII_RECORD_SEPARATOR + + "1,15,1,21" + ASCII_UNIT_SEPARATOR + "battery" + ASCII_UNIT_SEPARATOR + "OTHER" + + ASCII_RECORD_SEPARATOR + + "1,23,1,28" + ASCII_UNIT_SEPARATOR + "staple" + ASCII_UNIT_SEPARATOR + "KEYWORD").getBytes(StandardCharsets.UTF_8); + + assertThat(CpdVisitor.deserialize(serializedTokens)).containsExactly( + new TokenImpl(new TextRangeImpl(1, 0, 1, 7), "correct", Token.Type.KEYWORD), + new TokenImpl(new TextRangeImpl(1, 9, 1, 13), "horse", Token.Type.STRING_LITERAL), + new TokenImpl(new TextRangeImpl(1, 15, 1, 21), "battery", Token.Type.OTHER), + new TokenImpl(new TextRangeImpl(1, 23, 1, 28), "staple", Token.Type.KEYWORD)); + } + + @Test + void deserialize_throws_an_IllegalArgumentException_when_deserialization_fails() { + byte[] missingLineEndOffset = ("1,0,1," + ASCII_UNIT_SEPARATOR + "correct" + ASCII_UNIT_SEPARATOR + "KEYWORD").getBytes(StandardCharsets.UTF_8); + assertThatThrownBy(() -> CpdVisitor.deserialize(missingLineEndOffset)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Could not deserialize cached CPD tokens:"); + + byte[] nonNumericLineEndOffset = ("1,0,1,vb" + ASCII_UNIT_SEPARATOR + "correct" + ASCII_UNIT_SEPARATOR + "KEYWORD").getBytes(StandardCharsets.UTF_8); + assertThatThrownBy(() -> CpdVisitor.deserialize(nonNumericLineEndOffset)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Could not deserialize cached CPD tokens:"); + + byte[] unexpectedTokenType = ("1,0,1,7" + ASCII_UNIT_SEPARATOR + "correct" + ASCII_UNIT_SEPARATOR + "UNKNOWN").getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> CpdVisitor.deserialize(unexpectedTokenType)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Could not deserialize cached CPD tokens:"); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CyclomaticComplexityVisitorTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CyclomaticComplexityVisitorTest.java new file mode 100644 index 00000000..10918c52 --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/CyclomaticComplexityVisitorTest.java @@ -0,0 +1,98 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.sonar.go.testing.TestGoConverter; +import org.sonarsource.slang.api.FunctionDeclarationTree; +import org.sonarsource.slang.api.HasTextRange; +import org.sonarsource.slang.api.LoopTree; +import org.sonarsource.slang.api.MatchCaseTree; +import org.sonarsource.slang.api.Token; +import org.sonarsource.slang.api.Tree; + +import static org.assertj.core.api.Assertions.assertThat; + +class CyclomaticComplexityVisitorTest { + + private static List getComplexityTrees(String content) { + Tree root = TestGoConverter.parse(content); + return new CyclomaticComplexityVisitor().complexityTrees(root); + } + + @Test + void test_matchCases() { + String content = """ + package main + + func foo(a int) string { + switch a { + case 0: + return "none" + case 1: + return "one" + case 2: + return "many" + default: + return "it's complicated" + } + }"""; + List trees = getComplexityTrees(content); + assertThat(trees) + .hasSize(4); + trees.remove(0); + assertThat(trees) + .allMatch(MatchCaseTree.class::isInstance); + } + + @Test + void test_functions_with_conditional() { + String content = """ + package main + func foo(a int) { + if a == 2 { + print(a + 1) + } else { + print(a) + } + } + """; + List trees = getComplexityTrees(content); + assertThat(trees).hasSize(2); + assertThat(trees.get(0)).isInstanceOf(FunctionDeclarationTree.class); + assertThat(trees.get(1)).isInstanceOf(Token.class); + } + + @Test + void test_loops() { + String content = """ + package main + func foo2() { + for _, element := range someSlice { + for (element > y) { + element = element - 1 + } + } + }"""; + List trees = getComplexityTrees(content); + trees.remove(0); + assertThat(trees) + .hasSize(2) + .allMatch(LoopTree.class::isInstance); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/DurationStatisticsTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/DurationStatisticsTest.java new file mode 100644 index 00000000..6b027c04 --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/DurationStatisticsTest.java @@ -0,0 +1,75 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.event.Level; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.testfixtures.log.LogTesterJUnit5; + +import static org.assertj.core.api.Assertions.assertThat; + +class DurationStatisticsTest { + + private SensorContextTester sensorContext = SensorContextTester.create(Paths.get(".")); + + @RegisterExtension + public LogTesterJUnit5 logTester = new LogTesterJUnit5().setLevel(Level.DEBUG); + + @Test + void statistics_disabled() { + DurationStatistics statistics = new DurationStatistics(sensorContext.config()); + fillStatistics(statistics); + statistics.log(); + assertThat(logTester.logs(Level.INFO)).isEmpty(); + } + + @Test + void statistics_activated() { + sensorContext.settings().setProperty("sonar.slang.duration.statistics", "true"); + DurationStatistics statistics = new DurationStatistics(sensorContext.config()); + fillStatistics(statistics); + statistics.log(); + assertThat(logTester.logs(Level.INFO)).hasSize(1); + assertThat(logTester.logs(Level.INFO).get(0)).startsWith("Duration Statistics, "); + } + + @Test + void statistics_format() { + sensorContext.settings().setProperty("sonar.slang.duration.statistics", "true"); + DurationStatistics statistics = new DurationStatistics(sensorContext.config()); + statistics.store("A", 12_000_000L); + statistics.store("B", 15_000_000_000L); + statistics.log(); + assertThat(logTester.logs(Level.INFO)).hasSize(1); + assertThat(logTester.logs(Level.INFO).get(0)).isEqualTo("Duration Statistics, B 15'000 ms, A 12 ms"); + } + + private void fillStatistics(DurationStatistics statistics) { + StringBuilder txt = new StringBuilder(); + statistics.time("A", () -> txt.append("1")).append(2); + statistics.time("B", () -> { + txt.append("3"); + }); + statistics + .time("C", (t, u) -> txt.append(t).append(u)) + .accept("4", "5"); + assertThat(txt).hasToString("12345"); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/MetricVisitorTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/MetricVisitorTest.java new file mode 100644 index 00000000..1a12495e --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/MetricVisitorTest.java @@ -0,0 +1,276 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.DefaultInputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.measures.FileLinesContext; +import org.sonar.api.measures.FileLinesContextFactory; +import org.sonar.go.testing.TestGoConverter; +import org.sonarsource.slang.api.Tree; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MetricVisitorTest { + + private File tempFolder; + private org.sonar.go.plugin.MetricVisitor visitor; + private SensorContextTester sensorContext; + private DefaultInputFile inputFile; + + @BeforeEach + void setUp(@TempDir File tempFolder) { + this.tempFolder = tempFolder; + sensorContext = SensorContextTester.create(tempFolder); + FileLinesContext mockFileLinesContext = mock(FileLinesContext.class); + FileLinesContextFactory mockFileLinesContextFactory = mock(FileLinesContextFactory.class); + when(mockFileLinesContextFactory.createFor(any(InputFile.class))).thenReturn(mockFileLinesContext); + visitor = new MetricVisitor(mockFileLinesContextFactory, SlangSensor.EXECUTABLE_LINE_PREDICATE); + } + + @Test + @Disabled("SONARGO-99 Unable to parse empty file or with comments only") + void emptySource() throws Exception { + scan(""); + assertThat(visitor.linesOfCode()).isEmpty(); + assertThat(visitor.commentLines()).isEmpty(); + assertThat(visitor.numberOfFunctions()).isZero(); + } + + @Test + void linesOfCode() throws Exception { + scan(""" + package main + func main() { + x + 1; + } + // comment + func function1() { // comment + x = true || false; + }"""); + assertThat(visitor.linesOfCode()).containsExactly(1, 2, 3, 4, 6, 7, 8); + } + + @Test + void commentLines() throws Exception { + scan(""" + package main + func foo() { + x + 1; + // comment + } + func function1() { // comment + x = true || false; + }"""); + assertThat(visitor.commentLines()).containsExactly(4, 6); + } + + @Test + void commentBeforeTheFirstTokenCorrespondToTheIgnoredHeader() throws Exception { + scan(""" + // first line of the header + // second line of the header + /* + this is also part of the header + */ + package abc; // comment 1 + import "x"; + + func function1() { // comment 2 + // + /**/ + }"""); + assertThat(visitor.commentLines()).containsExactly(1, 2, 4, 6, 9); + } + + @Test + @Disabled("SONARGO-99 Unable to parse empty file or with comments only") + void commentsWithoutDeclarationsAreIgnored() throws Exception { + scan(""" + // header 1 + /** + * header 2 + */"""); + assertThat(visitor.commentLines()).isEmpty(); + } + + @Test + void noSonarCommentsDoNotAccountForTheCommentMetrics() throws Exception { + scan(""" + package main + func function1() { + // comment1 + // NOSONAR comment2 + // comment3 + }"""); + assertThat(visitor.commentLines()).containsExactly(3, 5); + } + + @Test + void emptyLinesDoNotAccountForTheCommentMetrics() throws Exception { + scan(""" + package abc // comment 1 + /* + + comment 2 + + comment 3 + + */ + + func function1() { // comment 4 + /** + * + # + = + - + | + | comment 5 + | どのように + | + */ + }"""); + assertThat(visitor.commentLines()).containsExactlyInAnyOrder(1, 4, 6, 10, 17, 18); + } + + @Test + void multiLineComment() throws Exception { + scan(""" + /*start + x + 1 + end*/ + package main"""); + assertThat(visitor.commentLines()).containsExactly(1, 2, 3); + assertThat(visitor.linesOfCode()).containsExactly(4); + } + + @Test + void functions() throws Exception { + scan(""" + package main + type A struct { + x int + }"""); + assertThat(visitor.numberOfFunctions()).isZero(); + + scan(""" + package main + // Only functions with implementation bodies are considered for the metric + func noBodyFunction() + // It counts + func main() { + // Anonymous functions are not considered for function metric computation + func() { + x = 1; + } + } + func function1() { // comment + x = true || false; + }"""); + assertThat(visitor.numberOfFunctions()).isEqualTo(2); + } + + @Test + void classes() throws Exception { + scan(""" + package main + func noBodyFunction()"""); + assertThat(visitor.numberOfClasses()).isZero(); + + scan(""" + package main + type C struct {} + func function() {} + type D struct { x int } + type E struct { + }"""); + assertThat(visitor.numberOfClasses()).isEqualTo(3); + } + + @Test + void cognitiveComplexity() throws Exception { + // +1 for 'if' + // +1 for 'if' + // + 2 for nested 'if' + // +1 for match + // +2 for nested 'if' + scan(""" + package main + func function() int { + if 1 != 1 { // +1 + if 1 != 1 { // +2 (nested) + return 1 + } + } + bar := func(a int) { + switch a { // +2 (nested) + case 1: + doSomething() + case 2: + doSomething() + default: + if 1 != 1 { // +3 (double nested) + doSomething() + } + } + } + bar(1) + return 0 + } + """); + assertThat(visitor.cognitiveComplexity()).isEqualTo(8); + } + + @Test + void executable_lines() throws Exception { + scan(""" + package abc + import "x" + + func foo() { + statementOnSeveralLines(a, + b) + } + + func bar() { + x = 42 + }"""); + assertThat(visitor.executableLines()).containsExactly(5, 10); + } + + private void scan(String code) throws IOException { + File tmpFile = File.createTempFile("file", ".tmp", tempFolder); + inputFile = new TestInputFileBuilder("moduleKey", tmpFile.getName()) + .setCharset(StandardCharsets.UTF_8) + .initMetadata(code).build(); + InputFileContext ctx = new InputFileContext(sensorContext, inputFile); + Tree root = TestGoConverter.parse(code); + visitor.scan(ctx, root); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/SkipNoSonarLinesVisitorTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/SkipNoSonarLinesVisitorTest.java new file mode 100644 index 00000000..ea27d716 --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/SkipNoSonarLinesVisitorTest.java @@ -0,0 +1,101 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.io.File; +import java.io.IOException; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.issue.NoSonarFilter; +import org.sonar.go.testing.TestGoConverter; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class SkipNoSonarLinesVisitorTest { + + private File tempFolder; + + private NoSonarFilter mockNoSonarFilter; + private SkipNoSonarLinesVisitor visitor; + + @BeforeEach + void setUp(@TempDir File tempFolder) { + this.tempFolder = tempFolder; + + mockNoSonarFilter = mock(NoSonarFilter.class); + visitor = new SkipNoSonarLinesVisitor(mockNoSonarFilter); + } + + @Test + void testNoDeclarations() throws Exception { + testNosonarCommentLines("// NOSONAR comment\npackage main", Set.of(1)); + } + + @Test + void testSingleNosonarComment() throws Exception { + testNosonarCommentLines(""" + package main + import "something" + // NOSONAR comment + func function1() { // comment + x = true || false; }""", + Set.of(3)); + } + + @Test + void testMultipleNosonarComments() throws IOException { + testNosonarCommentLines(""" + /* File Header */ + package main + import "something" + func foo() { // NOSONAR + // comment + } + + func bar() { + // nosonar + foo(); + }""", + Set.of(4, 9)); + } + + private void testNosonarCommentLines(String content, Set expectedNosonarCommentLines) throws IOException { + InputFile inputFile = createInputFile(content); + + visitor.scan(createInputFileContext(inputFile), TestGoConverter.parse(content)); + + verify(mockNoSonarFilter).noSonarInFile(inputFile, expectedNosonarCommentLines); + } + + private InputFile createInputFile(String content) throws IOException { + File file = File.createTempFile("file", ".tmp", tempFolder); + return new TestInputFileBuilder("moduleKey", file.getName()) + .setContents(content) + .build(); + } + + private InputFileContext createInputFileContext(InputFile inputFile) { + SensorContextTester sensorContext = SensorContextTester.create(tempFolder); + return new InputFileContext(sensorContext, inputFile); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/SlangSensorTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/SlangSensorTest.java new file mode 100644 index 00000000..383d421b --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/SlangSensorTest.java @@ -0,0 +1,731 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.event.Level; +import org.sonar.api.SonarEdition; +import org.sonar.api.SonarQubeSide; +import org.sonar.api.SonarRuntime; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.TextPointer; +import org.sonar.api.batch.rule.CheckFactory; +import org.sonar.api.batch.rule.Checks; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.batch.sensor.error.AnalysisError; +import org.sonar.api.batch.sensor.highlighting.TypeOfText; +import org.sonar.api.batch.sensor.internal.DefaultSensorDescriptor; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.batch.sensor.issue.Issue; +import org.sonar.api.batch.sensor.issue.IssueLocation; +import org.sonar.api.batch.sensor.issue.internal.DefaultNoSonarFilter; +import org.sonar.api.internal.SonarRuntimeImpl; +import org.sonar.api.measures.CoreMetrics; +import org.sonar.api.resources.Language; +import org.sonar.api.rule.RuleKey; +import org.sonar.api.utils.Version; +import org.sonar.go.converter.GoConverter; +import org.sonar.go.plugin.caching.DummyReadCache; +import org.sonar.go.plugin.caching.DummyWriteCache; +import org.sonar.go.testing.TestGoConverter; +import org.sonarsource.slang.api.ASTConverter; +import org.sonarsource.slang.api.TopLevelTree; +import org.sonarsource.slang.api.Tree; +import org.sonarsource.slang.checks.IdenticalBinaryOperandCheck; +import org.sonarsource.slang.checks.StringLiteralDuplicatedCheck; +import org.sonarsource.slang.checks.api.SlangCheck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.go.testing.TextRangeAssert.assertThat; + +class SlangSensorTest extends AbstractSensorTest { + + @Test + void test_one_rule() { + InputFile inputFile = createInputFile("file1.slang", """ + package main + func main() { + print (1 == 1) + }"""); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S1764"); + sensor(checkFactory).execute(context); + Collection issues = context.allIssues(); + assertThat(issues).hasSize(1); + Issue issue = issues.iterator().next(); + assertThat(issue.ruleKey().rule()).isEqualTo("S1764"); + IssueLocation location = issue.primaryLocation(); + assertThat(location.inputComponent()).isEqualTo(inputFile); + assertThat(location.message()).isEqualTo("Correct one of the identical sub-expressions on both sides this operator"); + assertThat(location.textRange()).hasRange(3, 14, 3, 15); + } + + @Test + void test_rule_with_gap() { + InputFile inputFile = createInputFile("file1.slang", """ + package main + func f() { + print("string literal") + print("string literal") + print("string literal") + }"""); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S1192"); + sensor(checkFactory).execute(context); + Collection issues = context.allIssues(); + assertThat(issues).hasSize(1); + Issue issue = issues.iterator().next(); + assertThat(issue.ruleKey().rule()).isEqualTo("S1192"); + IssueLocation location = issue.primaryLocation(); + assertThat(location.inputComponent()).isEqualTo(inputFile); + assertThat(location.message()).isEqualTo("Define a constant instead of duplicating this literal \"string literal\" 3 times."); + assertThat(location.textRange()).hasRange(3, 8, 3, 24); + assertThat(issue.gap()).isEqualTo(2.0); + } + + @Test + @Disabled("SONARGO-100 Fix NOSONAR suppression") + void test_commented_code() { + InputFile inputFile = createInputFile("file1.slang", """ + package main + func main() { + // func foo() { if (true) {print("string literal");}} + print (1 == 1); + print(b); + // a b c ... + foo(); + // Coefficients of polynomial + b = DoubleArray(n); // linear + c = DoubleArray(n + 1); // quadratic + d = DoubleArray(n); // cubic + }"""); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S125"); + sensor(checkFactory).execute(context); + Collection issues = context.allIssues(); + assertThat(issues).hasSize(1); + Issue issue = issues.iterator().next(); + assertThat(issue.ruleKey().rule()).isEqualTo("S125"); + IssueLocation location = issue.primaryLocation(); + assertThat(location.inputComponent()).isEqualTo(inputFile); + assertThat(location.message()).isEqualTo("Remove this commented out code."); + } + + @Test + @Disabled("SONARGO-100 Fix NOSONAR suppression") + void test_nosonar_commented_code() { + InputFile inputFile = createInputFile("file1.slang", """ + package main + func main() { + // func foo() { if (true) {print("string literal");}} NOSONAR + print (1 == 1); + print(b); + // a b c ... + foo(); + // Coefficients of polynomial + b = DoubleArray(n); // linear + c = DoubleArray(n + 1); // quadratic + d = DoubleArray(n); // cubic + }"""); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S125"); + sensor(checkFactory).execute(context); + Collection issues = context.allIssues(); + assertThat(issues).isEmpty(); + } + + @Test + void simple_file() { + InputFile inputFile = createInputFile("file1.go", + """ + package main + func main(x int) { + print (1 == 1) + print("abc") + } + type A struct {}"""); + context.fileSystem().add(inputFile); + sensor(checkFactory()).execute(context); + assertThat(context.highlightingTypeAt(inputFile.key(), 2, 0)).containsExactly(TypeOfText.KEYWORD); + assertThat(context.highlightingTypeAt(inputFile.key(), 2, 4)).isEmpty(); + assertThat(context.measure(inputFile.key(), CoreMetrics.NCLOC).value()).isEqualTo(6); + assertThat(context.measure(inputFile.key(), CoreMetrics.COMMENT_LINES).value()).isZero(); + assertThat(context.measure(inputFile.key(), CoreMetrics.FUNCTIONS).value()).isEqualTo(1); + assertThat(context.measure(inputFile.key(), CoreMetrics.CLASSES).value()).isEqualTo(1); + assertThat(context.cpdTokens(inputFile.key()).get(1).getValue()).isEqualTo("funcmain(xint){"); + assertThat(context.measure(inputFile.key(), CoreMetrics.COMPLEXITY).value()).isEqualTo(1); + assertThat(context.measure(inputFile.key(), CoreMetrics.STATEMENTS).value()).isEqualTo(2); + + assertThat(logTester.logs()).contains("1 source file to be analyzed"); + } + + @Test + void suppress_issues_in_class() { + InputFile inputFile = createInputFile("file1.slang", """ + @Suppress("slang:S1764") + class { fun main() { + print (1 == 1);} }"""); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S1764"); + sensor(checkFactory).execute(context); + Collection issues = context.allIssues(); + assertThat(issues).isEmpty(); + } + + @Test + @Disabled("SONARGO-100 Fix NOSONAR suppression") + void suppress_issues_in_var() { + InputFile inputFile = createInputFile("file1.slang", """ + package main + func bar() { + b = (1 == 1); // NOSONAR + c = (1 == 1); + } + """); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S1764"); + sensor(checkFactory).execute(context); + Collection issues = context.allIssues(); + assertThat(issues).hasSize(1); + Issue issue = issues.iterator().next(); + IssueLocation location = issue.primaryLocation(); + assertThat(location.textRange()).hasRange(5, 22, 5, 23); + } + + @Test + void test_fail_input() throws IOException { + InputFile inputFile = createInputFile("fakeFile.slang", ""); + InputFile spyInputFile = spy(inputFile); + when(spyInputFile.contents()).thenThrow(IOException.class); + context.fileSystem().add(spyInputFile); + CheckFactory checkFactory = checkFactory("S1764"); + sensor(checkFactory).execute(context); + Collection analysisErrors = context.allAnalysisErrors(); + assertThat(analysisErrors).hasSize(1); + AnalysisError analysisError = analysisErrors.iterator().next(); + assertThat(analysisError.inputFile()).isEqualTo(spyInputFile); + assertThat(analysisError.message()).isEqualTo("Unable to parse file: fakeFile.slang"); + assertThat(analysisError.location()).isNull(); + + assertThat(logTester.logs()).contains(String.format("Unable to parse file: %s. ", inputFile.uri())); + } + + @Test + void test_fail_parsing() { + InputFile inputFile = createInputFile("file1.slang", + """ + class A { + fun x() {} + fun y() {}\ + """); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("ParsingError"); + sensor(checkFactory).execute(context); + + Collection issues = context.allIssues(); + assertThat(issues).hasSize(1); + Issue issue = issues.iterator().next(); + assertThat(issue.ruleKey().rule()).isEqualTo("ParsingError"); + IssueLocation location = issue.primaryLocation(); + assertThat(location.inputComponent()).isEqualTo(inputFile); + assertThat(location.message()).isEqualTo("A parsing error occurred in this file."); + assertThat(location.textRange()).isNull(); + + Collection analysisErrors = context.allAnalysisErrors(); + assertThat(analysisErrors).hasSize(1); + AnalysisError analysisError = analysisErrors.iterator().next(); + assertThat(analysisError.inputFile()).isEqualTo(inputFile); + assertThat(analysisError.message()).isEqualTo("Unable to parse file: file1.slang"); + TextPointer textPointer = analysisError.location(); + assertThat(textPointer).isNull(); + + assertThat(logTester.logs()).contains(String.format("Unable to parse file: %s. ", inputFile.uri())); + } + + @Test + void test_fail_parsing_without_parsing_error_rule_activated() { + InputFile inputFile = createInputFile("file1.slang", "{"); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S1764"); + sensor(checkFactory).execute(context); + assertThat(context.allIssues()).isEmpty(); + assertThat(context.allAnalysisErrors()).hasSize(1); + } + + @Test + void test_empty_file() { + InputFile inputFile = createInputFile("empty.slang", "\t\t \r\n \n "); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S1764"); + sensor(checkFactory).execute(context); + Collection analysisErrors = context.allAnalysisErrors(); + assertThat(analysisErrors).isEmpty(); + assertThat(logTester.logs(Level.ERROR)).isEmpty(); + assertThat(logTester.logs(Level.WARN)).isEmpty(); + } + + @Test + void test_failure_in_check() { + InputFile inputFile = createInputFile("file1.slang", """ + package main + func f() {}"""); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = mock(CheckFactory.class); + var checks = mock(Checks.class); + SlangCheck failingCheck = init -> init.register(TopLevelTree.class, (ctx, tree) -> { + throw new IllegalStateException("BOUM"); + }); + when(checks.ruleKey(failingCheck)).thenReturn(RuleKey.of(repositoryKey(), "failing")); + when(checkFactory.create(repositoryKey())).thenReturn(checks); + when(checks.all()).thenReturn(Collections.singletonList(failingCheck)); + sensor(checkFactory).execute(context); + + Collection analysisErrors = context.allAnalysisErrors(); + assertThat(analysisErrors).hasSize(1); + AnalysisError analysisError = analysisErrors.iterator().next(); + assertThat(analysisError.inputFile()).isEqualTo(inputFile); + assertThat(logTester.logs()).contains("Cannot analyse 'file1.slang': BOUM"); + } + + @Test + void test_descriptor() { + DefaultSensorDescriptor sensorDescriptor = new DefaultSensorDescriptor(); + SlangSensor sensor = sensor(mock(CheckFactory.class)); + sensor.describe(sensorDescriptor); + assertThat(sensorDescriptor.languages()).hasSize(1); + assertThat(sensorDescriptor.languages()).containsExactly("slang"); + assertThat(sensorDescriptor.name()).isEqualTo("SLang Sensor"); + } + + @Test + void test_sonarlint_descriptor() { + DefaultSensorDescriptor sensorDescriptor = new DefaultSensorDescriptor(); + SlangSensor sensor = sensor(SonarRuntimeImpl.forSonarLint(Version.create(6, 5)), mock(CheckFactory.class)); + sensor.describe(sensorDescriptor); + assertThat(sensorDescriptor.languages()).hasSize(1); + assertThat(sensorDescriptor.languages()).containsExactly("slang"); + assertThat(sensorDescriptor.name()).isEqualTo("SLang Sensor"); + } + + @Test + void test_cancellation() { + InputFile inputFile = createInputFile("file1.slang", + "fun main() {\nprint (1 == 1);}"); + context.fileSystem().add(inputFile); + CheckFactory checkFactory = checkFactory("S1764"); + context.setCancelled(true); + sensor(checkFactory).execute(context); + Collection issues = context.allIssues(); + assertThat(issues).isEmpty(); + } + + @Test + void test_sonarlint_context() { + SonarRuntime sonarLintRuntime = SonarRuntimeImpl.forSonarLint(Version.create(3, 9)); + SensorContextTester context = SensorContextTester.create(baseDir); + InputFile inputFile = createInputFile("file1.slang", """ + package main + func main(x int) { + print (1 == 1) + print("abc") + } + type A struct {}"""); + context.fileSystem().add(inputFile); + context.setRuntime(sonarLintRuntime); + sensor(checkFactory("S1764")).execute(context); + + assertThat(context.allIssues()).hasSize(1); + + // No CPD, highlighting and metrics in SonarLint + assertThat(context.highlightingTypeAt(inputFile.key(), 1, 0)).isEmpty(); + assertThat(context.measure(inputFile.key(), CoreMetrics.NCLOC)).isNull(); + assertThat(context.cpdTokens(inputFile.key())).isNull(); + + assertThat(logTester.logs()).contains("1 source file to be analyzed"); + } + + @Test + void test_sensor_descriptor_does_not_process_files_independently() { + final SlangSensor sensor = sensor( + SonarRuntimeImpl.forSonarQube(Version.create(9, 3), SonarQubeSide.SCANNER, SonarEdition.DEVELOPER), + checkFactory()); + DefaultSensorDescriptor descriptor = new DefaultSensorDescriptor(); + sensor.describe(descriptor); + assertThat(descriptor.isProcessesFilesIndependently()).isFalse(); + } + + @Test + void test_sensor_logs_when_unchanged_files_can_be_skipped() { + // Enable PR context + SensorContextTester sensorContext = SensorContextTester.create(baseDir); + sensorContext.setCanSkipUnchangedFiles(true); + sensorContext.setCacheEnabled(true); + // Execute sensor + SlangSensor sensor = sensor( + SonarRuntimeImpl.forSonarQube(Version.create(9, 3), SonarQubeSide.SCANNER, SonarEdition.DEVELOPER), + checkFactory()); + sensor.execute(sensorContext); + assertThat(logTester.logs(Level.INFO)).contains( + "The SLANG analyzer is running in a context where unchanged files can be skipped."); + } + + @Nested + class PullRequestContext { + private static final String ORIGINAL_FILE_CONTENT = """ + package main + func main() { + print (1 == 1) + } + """; + + private SensorContextTester sensorContext; + private DummyWriteCache nextCache; + byte[] md5Hash; + private InputFile inputFile; + private InputFileContext inputFileContext; + private GoConverter converter; + private PullRequestAwareVisitor visitor; + private String hashKey; + + /** + * Set up for happy with PR context + */ + @BeforeEach + void setup() throws NoSuchAlgorithmException, IOException { + // Enable PR context + sensorContext = SensorContextTester.create(baseDir); + sensorContext.setCanSkipUnchangedFiles(true); + sensorContext.setCacheEnabled(true); + // Add one unchanged file to analyze + inputFile = createInputFile( + "file1.slang", + ORIGINAL_FILE_CONTENT, + InputFile.Status.SAME); + sensorContext.fileSystem().add(inputFile); + inputFileContext = new InputFileContext(sensorContext, inputFile); + // Add the hash of the file to the cache + MessageDigest md5 = MessageDigest.getInstance("MD5"); + try (InputStream in = new ByteArrayInputStream(ORIGINAL_FILE_CONTENT.getBytes(StandardCharsets.UTF_8))) { + md5Hash = md5.digest(in.readAllBytes()); + } + DummyReadCache previousCache = new DummyReadCache(); + hashKey = "slang:hash:" + inputFile.key(); + previousCache.persisted.put(hashKey, md5Hash); + sensorContext.setPreviousCache(previousCache); + + // Bind the next cache + nextCache = spy(new DummyWriteCache()); + nextCache.bind(previousCache); + sensorContext.setNextCache(nextCache); + + converter = spy(TestGoConverter.GO_CONVERTER); + visitor = spy(new SuccessfulReuseVisitor()); + } + + @Test + void skips_conversion_for_unchanged_file_with_cached_results() { + // Execute analyzeFile + SlangSensor.analyseFile( + converter, + inputFileContext, + inputFile, + List.of(visitor), + new DurationStatistics(sensorContext.config())); + verify(visitor, times(1)).reusePreviousResults(inputFileContext); + verify(converter, never()).parse(any(String.class), any(String.class)); + assertThat(logTester.logs(Level.DEBUG)).contains( + "Checking that previous results can be reused for input file moduleKey:file1.slang.", + "Skipping input file moduleKey:file1.slang (status is unchanged)."); + assertThat(nextCache.persisted).containsKey(hashKey); + verify(nextCache, times(1)).copyFromPrevious(hashKey); + } + + @Test + void does_not_skip_conversion_for_unchanged_file_when_cached_results_cannot_be_reused() { + // Set the only pull request aware visitor to fail reusing previous results + visitor = spy(new FailingToReuseVisitor()); + // Execute analyzeFile + SlangSensor.analyseFile( + converter, + inputFileContext, + inputFile, + List.of(visitor), + new DurationStatistics(sensorContext.config())); + verify(visitor, times(1)).reusePreviousResults(inputFileContext); + verify(converter, times(1)).parse(any()); + assertThat(logTester.logs(Level.DEBUG)).contains( + "Checking that previous results can be reused for input file moduleKey:file1.slang.", + "Visitor FailingToReuseVisitor failed to reuse previous results for input file moduleKey:file1.slang.", + "Will convert input file moduleKey:file1.slang for full analysis."); + assertThat(nextCache.persisted).containsKey(hashKey); + verify(nextCache, never()).copyFromPrevious(hashKey); + verify(nextCache, times(1)).write(eq(hashKey), any(byte[].class)); + } + + @Test + void does_not_skip_conversion_when_unchanged_files_cannot_be_skipped() { + // Disable the skipping of unchanged files + sensorContext.setCanSkipUnchangedFiles(false); + // Execute analyzeFile + SlangSensor.analyseFile( + converter, + inputFileContext, + inputFile, + List.of(visitor), + new DurationStatistics(sensorContext.config())); + verify(visitor, never()).reusePreviousResults(inputFileContext); + verify(converter, times(1)).parse(any()); + assertThat(logTester.logs(Level.DEBUG)).doesNotContain( + "Skipping input file moduleKey:file1.slang (status is unchanged)."); + verify(nextCache, never()).copyFromPrevious(hashKey); + verify(nextCache, times(1)).write(eq(hashKey), any(byte[].class)); + } + + @Test + void does_not_skip_conversion_when_the_file_has_same_contents_but_input_file_status_is_changed() { + // Create a changed file + InputFile changedFile = createInputFile( + "file1.slang", + ORIGINAL_FILE_CONTENT, + InputFile.Status.CHANGED); + inputFileContext = new InputFileContext(sensorContext, changedFile); + sensorContext.fileSystem().add(changedFile); + // Execute analyzeFile + SlangSensor.analyseFile( + converter, + inputFileContext, + changedFile, + List.of(visitor), + new DurationStatistics(sensorContext.config())); + verify(visitor, never()).reusePreviousResults(inputFileContext); + verify(converter, times(1)).parse(any()); + assertThat(logTester.logs(Level.DEBUG)) + .doesNotContain("Skipping input file moduleKey:file1.slang (status is unchanged).") + .contains("File moduleKey:file1.slang is considered changed: file status is CHANGED."); + + verify(nextCache, never()).copyFromPrevious(hashKey); + verify(nextCache, times(1)).write(eq(hashKey), any(byte[].class)); + } + + @Test + void does_not_skip_conversion_when_the_file_content_has_changed_but_input_file_status_is_SAME() { + // Create a changed file + InputFile changedFile = createInputFile( + "file1.slang", + "// This is definitely not the same thing\npackage main", + InputFile.Status.SAME); + sensorContext.fileSystem().add(changedFile); + inputFileContext = new InputFileContext(sensorContext, changedFile); + // Execute analyzeFile + SlangSensor.analyseFile( + converter, + inputFileContext, + changedFile, + List.of(visitor), + new DurationStatistics(sensorContext.config())); + verify(visitor, never()).reusePreviousResults(inputFileContext); + verify(converter, times(1)).parse(any()); + assertThat(logTester.logs(Level.DEBUG)).doesNotContain("Skipping input file moduleKey:file1.slang (status is unchanged)."); + + verify(nextCache, never()).copyFromPrevious(hashKey); + verify(nextCache, times(1)).write(eq(hashKey), any(byte[].class)); + } + + @Test + void does_not_skip_conversion_when_the_file_content_is_unchanged_but_cache_is_disabled() { + // Disable caching + sensorContext.setCacheEnabled(false); + // Execute analyzeFile + SlangSensor.analyseFile( + converter, + inputFileContext, + inputFile, + List.of(visitor), + new DurationStatistics(sensorContext.config())); + verify(visitor, never()).reusePreviousResults(inputFileContext); + verify(converter, times(1)).parse(any()); + assertThat(logTester.logs(Level.DEBUG)) + .doesNotContain("Skipping input file moduleKey:file1.slang (status is unchanged).") + .contains("File moduleKey:file1.slang is considered changed: hash cache is disabled."); + + verify(nextCache, never()).copyFromPrevious(hashKey); + verify(nextCache, never()).write(eq(hashKey), any(byte[].class)); + } + + @Test + void does_not_skip_conversion_when_the_file_content_is_unchanged_but_no_hash_in_cache() { + // Set an empty previous cache + sensorContext.setPreviousCache(new DummyReadCache()); + // Execute analyzeFile + SlangSensor.analyseFile( + converter, + inputFileContext, + inputFile, + List.of(visitor), + new DurationStatistics(sensorContext.config())); + verify(visitor, never()).reusePreviousResults(inputFileContext); + verify(converter, times(1)).parse(any()); + assertThat(logTester.logs(Level.DEBUG)) + .doesNotContain("Skipping input file moduleKey:file1.slang (status is unchanged).") + .contains("File moduleKey:file1.slang is considered changed: hash could not be found in the cache."); + + verify(nextCache, never()).copyFromPrevious(hashKey); + verify(nextCache, times(1)).write(eq(hashKey), any(byte[].class)); + } + + @Test + void does_not_skip_conversion_when_the_file_content_is_unchanged_but_failing_to_read_the_cache() { + // Set a previous cache that contains a stream to a hash that will fail to close + DummyReadCache corruptedCache = spy(new DummyReadCache()); + doReturn(true).when(corruptedCache).contains(hashKey); + InputStream failingToClose = new ByteArrayInputStream(ORIGINAL_FILE_CONTENT.getBytes(StandardCharsets.UTF_8)) { + @Override + public void close() throws IOException { + throw new IOException("BOOM!"); + } + }; + doReturn(failingToClose).when(corruptedCache).read(hashKey); + sensorContext.setPreviousCache(corruptedCache); + // Execute analyzeFile + SlangSensor.analyseFile( + converter, + inputFileContext, + inputFile, + List.of(visitor), + new DurationStatistics(sensorContext.config())); + verify(visitor, never()).reusePreviousResults(inputFileContext); + verify(converter, times(1)).parse(any()); + assertThat(logTester.logs(Level.DEBUG)) + .doesNotContain("Skipping input file moduleKey:file1.slang (status is unchanged).") + .contains("File moduleKey:file1.slang is considered changed: failed to read hash from the cache."); + + verify(nextCache, never()).copyFromPrevious(hashKey); + verify(nextCache, times(1)).write(eq(hashKey), any(byte[].class)); + } + + @Test + void successful_visitor_is_not_called_to_visit_the_ast_after_conversion() { + FailingToReuseVisitor failing = spy(new FailingToReuseVisitor()); + SlangSensor.analyseFile( + converter, + inputFileContext, + inputFile, + List.of(visitor, failing), + new DurationStatistics(sensorContext.config())); + verify(visitor, times(1)).reusePreviousResults(inputFileContext); + verify(failing, times(1)).reusePreviousResults(inputFileContext); + verify(converter, times(1)).parse(any()); + verify(visitor, never()).scan(eq(inputFileContext), any(Tree.class)); + verify(failing, times(1)).scan(eq(inputFileContext), any(Tree.class)); + assertThat(logTester.logs(Level.DEBUG)).doesNotContain( + "Skipping input file moduleKey:file1.slang (status is unchanged)."); + } + } + + @Override + protected String repositoryKey() { + return "slang"; + } + + @Override + protected Language language() { + return SlangLanguage.SLANG; + } + + private SlangSensor sensor(CheckFactory checkFactory) { + return sensor(SQ_LTS_RUNTIME, checkFactory); + } + + private SlangSensor sensor(SonarRuntime sonarRuntime, CheckFactory checkFactory) { + return new SlangSensor(sonarRuntime, new DefaultNoSonarFilter(), fileLinesContextFactory, SlangLanguage.SLANG) { + @Override + protected ASTConverter astConverter(SensorContext sensorContext) { + return TestGoConverter.GO_CONVERTER; + } + + @Override + protected Checks checks() { + Checks checks = checkFactory.create(repositoryKey()); + checks.addAnnotatedChecks( + StringLiteralDuplicatedCheck.class, + // TODO SONARGO-100 Fix NOSONAR suppression + // new CommentedCodeCheck(new SlangCodeVerifier()), + IdenticalBinaryOperandCheck.class); + return checks; + } + + @Override + protected String repositoryKey() { + return SlangSensorTest.this.repositoryKey(); + } + }; + } + + enum SlangLanguage implements Language { + SLANG; + + @Override + public String getKey() { + return "slang"; + } + + @Override + public String getName() { + return "SLang"; + } + + @Override + public String[] getFileSuffixes() { + return new String[] {".slang"}; + } + } + + static class SuccessfulReuseVisitor extends PullRequestAwareVisitor { + @Override + public boolean reusePreviousResults(InputFileContext unused) { + return true; + } + } + + static class FailingToReuseVisitor extends PullRequestAwareVisitor { + @Override + public boolean reusePreviousResults(InputFileContext unused) { + return false; + } + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/DummyReadCache.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/DummyReadCache.java new file mode 100644 index 00000000..3da2091a --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/DummyReadCache.java @@ -0,0 +1,40 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin.caching; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import org.sonar.api.batch.sensor.cache.ReadCache; + +public class DummyReadCache implements ReadCache { + public final Map persisted = new HashMap<>(); + + @Override + public InputStream read(String key) { + if (!persisted.containsKey(key)) { + throw new IllegalArgumentException(String.format("Cache does not contain key %s", key)); + } + return new ByteArrayInputStream(persisted.get(key)); + } + + @Override + public boolean contains(String key) { + return persisted.containsKey(key); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/DummyWriteCache.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/DummyWriteCache.java new file mode 100644 index 00000000..93c42054 --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/DummyWriteCache.java @@ -0,0 +1,58 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin.caching; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import org.sonar.api.batch.sensor.cache.ReadCache; +import org.sonar.api.batch.sensor.cache.WriteCache; + +public class DummyWriteCache implements WriteCache { + public final Map persisted = new HashMap<>(); + ReadCache previousCache; + + @Override + public void write(String key, InputStream data) { + try { + write(key, data.readAllBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void write(String key, byte[] data) { + if (persisted.containsKey(key)) { + throw new IllegalArgumentException(String.format("The cache already contains the key: %s", key)); + } + persisted.put(key, data); + } + + @Override + public void copyFromPrevious(String key) { + if (previousCache == null) { + throw new IllegalStateException("The write cache needs to be bound with a ReadCache!"); + } + write(key, previousCache.read(key)); + } + + public void bind(DummyReadCache previousCache) { + this.previousCache = previousCache; + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/HashCacheUtilsTest.java b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/HashCacheUtilsTest.java new file mode 100644 index 00000000..03916c44 --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/plugin/caching/HashCacheUtilsTest.java @@ -0,0 +1,149 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.plugin.caching; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.event.Level; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.testfixtures.log.LogTesterJUnit5; +import org.sonar.go.plugin.InputFileContext; + +import static org.assertj.core.api.Assertions.assertThat; + +class HashCacheUtilsTest { + private static final String CONTENTS = "// Hello, world!"; + private static final String EXPECTED_HASH = "180dd7ee70f338197b90e0635cad1131"; + private static final String MODULE_KEY = "moduleKey"; + private static final String FILENAME = "file1.slang"; + private static final String CACHE_KEY = "slang:hash:%s:%s".formatted(MODULE_KEY, FILENAME); + + @RegisterExtension + public LogTesterJUnit5 logTester = new LogTesterJUnit5().setLevel(Level.DEBUG); + + private SensorContextTester sensorContext; + private DummyReadCache previousCache; + private DummyWriteCache nextCache; + private InputFileContext inputFileContext; + + @BeforeEach + void setup(@TempDir File tmpBaseDir) { + sensorContext = SensorContextTester.create(tmpBaseDir); + previousCache = new DummyReadCache(); + nextCache = new DummyWriteCache(); + nextCache.bind(previousCache); + + sensorContext.setCacheEnabled(true); + sensorContext.setPreviousCache(previousCache); + sensorContext.setNextCache(nextCache); + + InputFile inputFile = new TestInputFileBuilder(MODULE_KEY, FILENAME) + .setModuleBaseDir(tmpBaseDir.toPath()) + .setType(InputFile.Type.MAIN) + .setLanguage("slang") + .setCharset(StandardCharsets.UTF_8) + .setContents(CONTENTS) + .build(); + previousCache.persisted.put(CACHE_KEY, EXPECTED_HASH.getBytes(StandardCharsets.UTF_8)); + + sensorContext.fileSystem().add(inputFile); + inputFileContext = new InputFileContext(sensorContext, inputFile); + } + + @Test + void copyFromPrevious_fails_to_copy_when_called_a_second_time() { + // Succeed on first try + assertThat(HashCacheUtils.copyFromPrevious(inputFileContext)).isTrue(); + assertThat(logTester.logs(Level.WARN)).isEmpty(); + assertThat(nextCache.persisted) + .containsOnlyKeys("slang:hash:moduleKey:file1.slang"); + + // Fail on second try + assertThat(HashCacheUtils.copyFromPrevious(inputFileContext)).isFalse(); + assertThat(logTester.logs(Level.WARN)) + .containsOnly("Failed to copy hash from previous analysis for moduleKey:file1.slang."); + assertThat(nextCache.persisted) + .containsOnlyKeys("slang:hash:moduleKey:file1.slang"); + } + + @Test + void copyFromPrevious_fails_to_copy_when_entry_does_not_exist_in_previous_cache() { + // Set an empty previous cache and try to copy from it + previousCache = new DummyReadCache(); + nextCache = new DummyWriteCache(); + nextCache.bind(previousCache); + sensorContext.setPreviousCache(previousCache); + sensorContext.setNextCache(nextCache); + + // Try and fail to copy + assertThat(HashCacheUtils.copyFromPrevious(inputFileContext)).isFalse(); + assertThat(nextCache.persisted).isEmpty(); + assertThat(logTester.logs(Level.WARN)) + .containsOnly("Failed to copy hash from previous analysis for moduleKey:file1.slang."); + } + + @Test + void copyFromPrevious_does_not_attempt_to_copy_when_the_cache_is_disabled() { + // Disable caching + sensorContext.setCacheEnabled(false); + + // Try and fail to copy + assertThat(HashCacheUtils.copyFromPrevious(inputFileContext)).isFalse(); + assertThat(nextCache.persisted).isEmpty(); + } + + @Test + void writeHashForNextAnalysis_writes_the_md5_sum_of_the_file_to_the_cache() { + assertThat(HashCacheUtils.writeHashForNextAnalysis(inputFileContext)).isTrue(); + + assertThat(nextCache.persisted).containsOnlyKeys("slang:hash:moduleKey:file1.slang"); + byte[] written = nextCache.persisted.get("slang:hash:moduleKey:file1.slang"); + assertThat(written) + .as("Hash should be written in hexadecimal form.") + .hasSize(32); + String actual = new String(written, StandardCharsets.UTF_8); + assertThat(actual).isEqualTo(EXPECTED_HASH); + } + + @Test + void writeHashForNextAnalysis_fails_to_write_the_hash_of_the_same_file_a_second_time_to() { + // Succeed on first attempt + assertThat(HashCacheUtils.writeHashForNextAnalysis(inputFileContext)).isTrue(); + assertThat(nextCache.persisted).containsOnlyKeys("slang:hash:moduleKey:file1.slang"); + + // Fail on second attempt + assertThat(HashCacheUtils.writeHashForNextAnalysis(inputFileContext)).isFalse(); + assertThat(nextCache.persisted).containsOnlyKeys("slang:hash:moduleKey:file1.slang"); + assertThat(logTester.logs(Level.WARN)) + .containsOnly("Failed to write hash for moduleKey:file1.slang to cache."); + } + + @Test + void writeHashForNextAnalysis_fails_when_the_cache_is_disabled() { + // Disable the cache + sensorContext.setCacheEnabled(false); + + assertThat(HashCacheUtils.writeHashForNextAnalysis(inputFileContext)).isFalse(); + assertThat(nextCache.persisted).isEmpty(); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/testing/TestGoConverter.java b/sonar-go-plugin/src/test/java/org/sonar/go/testing/TestGoConverter.java new file mode 100644 index 00000000..78269ef5 --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/testing/TestGoConverter.java @@ -0,0 +1,31 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.testing; + +import java.io.File; +import java.nio.file.Paths; +import org.sonar.go.converter.GoConverter; +import org.sonarsource.slang.api.Tree; + +public class TestGoConverter { + private static final File CONVERTER_DIR = Paths.get("build", "tmp").toFile(); + public static final GoConverter GO_CONVERTER = new GoConverter(CONVERTER_DIR); + + public static Tree parse(String content) { + return GO_CONVERTER.parse(content); + } +} diff --git a/sonar-go-plugin/src/test/java/org/sonar/go/testing/TextRangeAssert.java b/sonar-go-plugin/src/test/java/org/sonar/go/testing/TextRangeAssert.java new file mode 100644 index 00000000..1c47c388 --- /dev/null +++ b/sonar-go-plugin/src/test/java/org/sonar/go/testing/TextRangeAssert.java @@ -0,0 +1,44 @@ +/* + * SonarSource Go + * Copyright (C) 2018-2024 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.go.testing; + +import javax.annotation.Nullable; +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.SoftAssertions; +import org.sonar.api.batch.fs.TextRange; + +public class TextRangeAssert extends AbstractAssert { + + private TextRangeAssert(@Nullable TextRange actual) { + super(actual, TextRangeAssert.class); + } + + public static TextRangeAssert assertThat(@Nullable TextRange actual) { + return new TextRangeAssert(actual); + } + + public TextRangeAssert hasRange(int startLine, int startLineOffset, int endLine, int endLineOffset) { + isNotNull(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(actual.start().line()).as("startLine mismatch").isEqualTo(startLine); + softly.assertThat(actual.start().lineOffset()).as("startLineOffset mismatch").isEqualTo(startLineOffset); + softly.assertThat(actual.end().line()).as("endLine mismatch").isEqualTo(endLine); + softly.assertThat(actual.end().lineOffset()).as("endLineOffset mismatch").isEqualTo(endLineOffset); + }); + return this; + } +} diff --git a/sonar-go-plugin/src/test/resources/propertyHandler/dummyReport.txt b/sonar-go-plugin/src/test/resources/propertyHandler/dummyReport.txt new file mode 100644 index 00000000..48cdce85 --- /dev/null +++ b/sonar-go-plugin/src/test/resources/propertyHandler/dummyReport.txt @@ -0,0 +1 @@ +placeholder