diff options
| author | Elena Vilchik | 2019-12-18 17:10:10 +0100 |
|---|---|---|
| committer | Alban Auzeill | 2019-12-18 17:10:10 +0100 |
| commit | c8f0071c4f5336dfe0efc5d3c218ab49f2401264 (patch) | |
| tree | 254cd5ed9531d7c62bab4f8ec082e085795ecb8f /sonar-css-plugin/src/main/java/org | |
| parent | 13fe08e87c8a70ffe6e248b774ef826bbe1f779d (diff) | |
| download | sonar-css-c8f0071c4f5336dfe0efc5d3c218ab49f2401264.tar.bz2 | |
Rely on NodeJS API of Stylelint to execute CSS rules (#221)
Diffstat (limited to 'sonar-css-plugin/src/main/java/org')
13 files changed, 629 insertions, 307 deletions
diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssPlugin.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssPlugin.java index d627527..a5df2fc 100644 --- a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssPlugin.java +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssPlugin.java @@ -20,17 +20,13 @@ package org.sonar.css.plugin; import org.sonar.api.Plugin; -import org.sonar.api.SonarProduct; -import org.sonar.api.SonarRuntime; import org.sonar.api.config.PropertyDefinition; import org.sonar.api.resources.Qualifiers; -import org.sonar.api.utils.Version; -import org.sonar.css.plugin.bundle.CssBundleHandler; +import org.sonar.css.plugin.bundle.CssAnalyzerBundle; +import org.sonar.css.plugin.server.CssAnalyzerBridgeServer; public class CssPlugin implements Plugin { - private static final Version ANALYSIS_WARNINGS_MIN_SUPPORTED_SQ_VERSION = Version.create(7, 4); - static final String FILE_SUFFIXES_KEY = "sonar.css.file.suffixes"; public static final String FILE_SUFFIXES_DEFVALUE = ".css,.less,.scss"; @@ -45,16 +41,14 @@ public class CssPlugin implements Plugin { @Override public void define(Context context) { - boolean externalIssuesSupported = context.getSonarQubeVersion().isGreaterThanOrEqual(Version.create(7, 2)); - context.addExtensions( MetricSensor.class, CssLanguage.class, SonarWayProfile.class, - new CssRulesDefinition(externalIssuesSupported), - CssBundleHandler.class, + CssRulesDefinition.class, + CssAnalyzerBundle.class, + CssAnalyzerBridgeServer.class, CssRuleSensor.class, - StylelintCommandProvider.class, StylelintReportSensor.class, MinifiedFilesFilter.class, @@ -69,30 +63,16 @@ public class CssPlugin implements Plugin { .build() ); - - if (externalIssuesSupported) { - context.addExtension( - PropertyDefinition.builder(STYLELINT_REPORT_PATHS) - .defaultValue(STYLELINT_REPORT_PATHS_DEFAULT_VALUE) - .name("Stylelint Report Files") - .description("Paths (absolute or relative) to the JSON files with stylelint issues.") - .onQualifiers(Qualifiers.PROJECT) - .subCategory(LINTER_SUBCATEGORY) - .category(CSS_CATEGORY) - .multiValues(true) - .build()); - } - - if (isAnalysisWarningsSupported(context.getRuntime())) { - context.addExtension(AnalysisWarningsWrapper.class); - } + context.addExtension( + PropertyDefinition.builder(STYLELINT_REPORT_PATHS) + .defaultValue(STYLELINT_REPORT_PATHS_DEFAULT_VALUE) + .name("Stylelint Report Files") + .description("Paths (absolute or relative) to the JSON files with stylelint issues.") + .onQualifiers(Qualifiers.PROJECT) + .subCategory(LINTER_SUBCATEGORY) + .category(CSS_CATEGORY) + .multiValues(true) + .build()); } - /** - * Drop this and related when the minimum supported version of SonarQube API reaches 7.4. - */ - private static boolean isAnalysisWarningsSupported(SonarRuntime runtime) { - return runtime.getApiVersion().isGreaterThanOrEqual(ANALYSIS_WARNINGS_MIN_SUPPORTED_SQ_VERSION) - && runtime.getProduct() != SonarProduct.SONARLINT; - } } diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssRuleSensor.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssRuleSensor.java index 87328d7..644ece8 100644 --- a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssRuleSensor.java +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssRuleSensor.java @@ -21,16 +21,22 @@ package org.sonar.css.plugin; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonSyntaxException; import java.io.File; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.Paths; import java.util.Collections; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import javax.annotation.Nullable; +import org.sonar.api.batch.fs.FilePredicate; +import org.sonar.api.batch.fs.FilePredicates; import org.sonar.api.batch.fs.FileSystem; import org.sonar.api.batch.fs.InputFile; import org.sonar.api.batch.rule.CheckFactory; @@ -39,109 +45,180 @@ import org.sonar.api.batch.sensor.SensorContext; import org.sonar.api.batch.sensor.SensorDescriptor; import org.sonar.api.batch.sensor.issue.NewIssue; import org.sonar.api.batch.sensor.issue.NewIssueLocation; +import org.sonar.api.notifications.AnalysisWarnings; import org.sonar.api.rule.RuleKey; import org.sonar.api.utils.log.Logger; import org.sonar.api.utils.log.Loggers; import org.sonar.css.plugin.CssRules.StylelintConfig; -import org.sonar.css.plugin.StylelintReport.Issue; -import org.sonar.css.plugin.StylelintReport.IssuesPerFile; -import org.sonar.css.plugin.bundle.BundleHandler; -import org.sonarsource.nodejs.NodeCommand; +import org.sonar.css.plugin.server.AnalyzerBridgeServer.Issue; +import org.sonar.css.plugin.server.CssAnalyzerBridgeServer; +import org.sonar.css.plugin.server.AnalyzerBridgeServer.Request; +import org.sonar.css.plugin.server.exception.ServerAlreadyFailedException; +import org.sonarsource.analyzer.commons.ProgressReport; import org.sonarsource.nodejs.NodeCommandException; public class CssRuleSensor implements Sensor { private static final Logger LOG = Loggers.get(CssRuleSensor.class); + private static final String CONFIG_PATH = "css-bundle/stylelintconfig.json"; - private final BundleHandler bundleHandler; private final CssRules cssRules; - private final LinterCommandProvider linterCommandProvider; - @Nullable - private final AnalysisWarningsWrapper analysisWarnings; + private final CssAnalyzerBridgeServer cssAnalyzerBridgeServer; + private final AnalysisWarnings analysisWarnings; + public CssRuleSensor( - BundleHandler bundleHandler, CheckFactory checkFactory, - LinterCommandProvider linterCommandProvider, - @Nullable AnalysisWarningsWrapper analysisWarnings + CssAnalyzerBridgeServer cssAnalyzerBridgeServer, + @Nullable AnalysisWarnings analysisWarnings ) { - this.bundleHandler = bundleHandler; - this.linterCommandProvider = linterCommandProvider; this.cssRules = new CssRules(checkFactory); + this.cssAnalyzerBridgeServer = cssAnalyzerBridgeServer; this.analysisWarnings = analysisWarnings; } - public CssRuleSensor( - BundleHandler bundleHandler, - CheckFactory checkFactory, - LinterCommandProvider linterCommandProvider - ) { - this(bundleHandler, checkFactory, linterCommandProvider, null); - } - @Override public void describe(SensorDescriptor descriptor) { descriptor + .createIssuesForRuleRepository("css") .name("SonarCSS Rules"); } @Override public void execute(SensorContext context) { + reportOldNodeProperty(context); + + boolean failFast = context.config().getBoolean("sonar.internal.analysis.failFast").orElse(false); + + try { + List<InputFile> inputFiles = getInputFiles(context); + if (inputFiles.isEmpty()) { + LOG.info("No CSS, PHP or HTML files are found in the project. CSS analysis is skipped."); + } else { + cssAnalyzerBridgeServer.startServerLazily(context); + File configFile = createLinterConfig(context); + analyzeFiles(context, inputFiles, configFile); + } + } catch (CancellationException e) { + // do not propagate the exception + LOG.info(e.toString()); + } catch (ServerAlreadyFailedException e) { + LOG.debug("Skipping start of css-bundle server due to the failure during first analysis"); + LOG.debug("Skipping execution of CSS rules due to the problems with css-bundle server"); + } catch (NodeCommandException e) { + LOG.error(e.getMessage(), e); + reportAnalysisWarning("CSS rules were not executed. " + e.getMessage()); + if (failFast) { + throw new IllegalStateException("Analysis failed (\"sonar.internal.analysis.failFast\"=true)", e); + } + } catch (Exception e) { + LOG.error("Failure during analysis, " + cssAnalyzerBridgeServer.getCommandInfo(), e); + if (failFast) { + throw new IllegalStateException("Analysis failed (\"sonar.internal.analysis.failFast\"=true)", e); + } + } + } + + private void reportOldNodeProperty(SensorContext context) { if (context.config().hasKey(CssPlugin.FORMER_NODE_EXECUTABLE)) { String msg = "Property '" + CssPlugin.FORMER_NODE_EXECUTABLE + "' is ignored, 'sonar.nodejs.executable' should be used instead"; LOG.warn(msg); - if (analysisWarnings != null) { - analysisWarnings.addUnique(msg); + reportAnalysisWarning(msg); + } + } + + void analyzeFiles(SensorContext context, List<InputFile> inputFiles, File configFile) throws InterruptedException, IOException { + ProgressReport progressReport = new ProgressReport("Analysis progress", TimeUnit.SECONDS.toMillis(10)); + boolean success = false; + try { + progressReport.start(inputFiles.stream().map(InputFile::toString).collect(Collectors.toList())); + for (InputFile inputFile : inputFiles) { + if (context.isCancelled()) { + throw new CancellationException("Analysis interrupted because the SensorContext is in cancelled state"); + } + if (cssAnalyzerBridgeServer.isAlive()) { + try { + analyzeFile(context, inputFile, configFile); + } catch (IOException | RuntimeException e) { + throw new IOException("Failure during analysis of " + inputFile.uri() + ": " + e.getMessage()); + } + progressReport.nextFile(); + } else { + throw new IllegalStateException("css-bundle server is not answering"); + } } + success = true; + } finally { + if (success) { + progressReport.stop(); + } else { + progressReport.cancel(); + } + progressReport.join(); } + } - if (cssRules.isEmpty()) { - LOG.warn("No rules are activated in CSS Quality Profile"); + void analyzeFile(SensorContext context, InputFile inputFile, File configFile) throws IOException { + URI uri = inputFile.uri(); + if (!"file".equalsIgnoreCase(uri.getScheme())) { + LOG.debug("Skipping {} as it has not 'file' scheme", uri); return; } + Request request = new Request(new File(uri).getAbsolutePath(), configFile.toString()); + LOG.debug("Analyzing " + request.filePath); + Issue[] issues = cssAnalyzerBridgeServer.analyze(request); + LOG.debug("Found {} issue(s)", issues.length); + saveIssues(context, inputFile, issues); + } - File deployDestination = context.fileSystem().workDir(); + private void saveIssues(SensorContext context, InputFile inputFile, Issue[] issues) { + for (Issue issue : issues) { + NewIssue sonarIssue = context.newIssue(); - try { - bundleHandler.deployBundle(deployDestination); - createLinterConfig(deployDestination); - StringBuilder output = new StringBuilder(); + RuleKey ruleKey = cssRules.getActiveSonarKey(issue.rule); - NodeCommand nodeCommand = linterCommandProvider.nodeCommand(deployDestination, context, output::append, LOG::error); - LOG.debug("Starting process: " + nodeCommand.toString()); - nodeCommand.start(); + if (ruleKey == null) { + if ("CssSyntaxError".equals(issue.rule)) { + String errorMessage = issue.text.replace("(CssSyntaxError)", "").trim(); + LOG.error("Failed to parse {}, line {}, {}", inputFile.uri(), issue.line, errorMessage); + } else { + LOG.error("Unknown stylelint rule or rule not enabled: '" + issue.rule + "'"); + } - if (isSuccessful(nodeCommand.waitFor())) { - saveIssues(context, output.toString()); - } - } catch (NodeCommandException e) { - LOG.error(e.getMessage() + " No CSS files will be analyzed.", e); - if (analysisWarnings != null) { - analysisWarnings.addUnique("CSS files were not analyzed. " + e.getMessage()); + } else { + NewIssueLocation location = sonarIssue.newLocation() + .on(inputFile) + .at(inputFile.selectLine(issue.line)) + .message(normalizeMessage(issue.text)); + + sonarIssue + .at(location) + .forRule(ruleKey) + .save(); } - } catch (Exception e) { - LOG.error("Failed to run external linting process", e); } } - private boolean isSuccessful(int exitValue) { - // exit codes 0 and 2 are expected. 0 - means no issues were found, 2 - means that at least one "error-level" rule found issue - // see https://github.com/stylelint/stylelint/blob/master/docs/user-guide/cli.md#exit-codes - boolean isSuccessful = exitValue == 0 || exitValue == 2; - if (!isSuccessful) { - LOG.error("Analysis didn't terminate normally, please verify ERROR and WARN logs above. Exit code {}", exitValue); - } - return isSuccessful; + private static List<InputFile> getInputFiles(SensorContext context) { + FileSystem fileSystem = context.fileSystem(); + FilePredicates predicates = context.fileSystem().predicates(); + FilePredicate mainFilePredicate = predicates.and( + fileSystem.predicates().hasType(InputFile.Type.MAIN), + fileSystem.predicates().hasLanguages(CssLanguage.KEY, "php", "web")); + return StreamSupport.stream(fileSystem.inputFiles(mainFilePredicate).spliterator(), false) + .collect(Collectors.toList()); } - private void createLinterConfig(File deployDestination) throws IOException { - String configPath = linterCommandProvider.configPath(deployDestination); + private File createLinterConfig(SensorContext context) throws IOException { StylelintConfig config = cssRules.getConfig(); final GsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.registerTypeAdapter(StylelintConfig.class, config); final Gson gson = gsonBuilder.create(); String configAsJson = gson.toJson(config); - Files.write(Paths.get(configPath), Collections.singletonList(configAsJson), StandardCharsets.UTF_8); + File configFile = new File(context.fileSystem().workDir(), CONFIG_PATH).getAbsoluteFile(); + Files.createDirectories(configFile.toPath().getParent()); + Files.write(configFile.toPath(), Collections.singletonList(configAsJson), StandardCharsets.UTF_8); + return configFile; } private static String normalizeMessage(String message) { @@ -155,51 +232,9 @@ public class CssRuleSensor implements Sensor { } } - private void saveIssues(SensorContext context, String issuesAsJson) { - IssuesPerFile[] issues; - try { - issues = new Gson().fromJson(issuesAsJson, IssuesPerFile[].class); - } catch (JsonSyntaxException e) { - throw new IllegalStateException("Failed to parse JSON result of external linting process execution: \n-------\n" + issuesAsJson + "\n-------", e); - } - - FileSystem fileSystem = context.fileSystem(); - - for (IssuesPerFile issuesPerFile : issues) { - InputFile inputFile = fileSystem.inputFile(fileSystem.predicates().hasAbsolutePath(issuesPerFile.source)); - - if (inputFile != null) { - for (Issue issue : issuesPerFile.warnings) { - saveIssue(context, inputFile, issue); - } - } + private void reportAnalysisWarning(String message) { + if (analysisWarnings != null) { + analysisWarnings.addUnique(message); } } - - private void saveIssue(SensorContext context, InputFile inputFile, Issue issue) { - NewIssue sonarIssue = context.newIssue(); - - RuleKey ruleKey = cssRules.getActiveSonarKey(issue.rule); - - if (ruleKey == null) { - if ("CssSyntaxError".equals(issue.rule)) { - String errorMessage = issue.text.replace("(CssSyntaxError)", "").trim(); - LOG.error("Failed to parse {}, line {}, {}", inputFile.uri(), issue.line, errorMessage); - } else { - LOG.error("Unknown stylelint rule or rule not enabled: '" + issue.rule + "'"); - } - - } else { - NewIssueLocation location = sonarIssue.newLocation() - .on(inputFile) - .at(inputFile.selectLine(issue.line)) - .message(normalizeMessage(issue.text)); - - sonarIssue - .at(location) - .forRule(ruleKey) - .save(); - } - } - } diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssRulesDefinition.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssRulesDefinition.java index cc88ad3..4d0d959 100644 --- a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssRulesDefinition.java +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/CssRulesDefinition.java @@ -31,12 +31,6 @@ public class CssRulesDefinition implements RulesDefinition { public static final String RESOURCE_FOLDER = "org/sonar/l10n/css/rules/"; - private final boolean externalIssuesSupported; - - public CssRulesDefinition(boolean externalIssuesSupported) { - this.externalIssuesSupported = externalIssuesSupported; - } - @Override public void define(Context context) { NewRepository repository = context @@ -47,8 +41,6 @@ public class CssRulesDefinition implements RulesDefinition { ruleMetadataLoader.addRulesByAnnotatedClass(repository, CssRules.getRuleClasses()); repository.done(); - if (externalIssuesSupported) { - StylelintReportSensor.getStylelintRuleLoader().createExternalRuleRepository(context); - } + StylelintReportSensor.getStylelintRuleLoader().createExternalRuleRepository(context); } } diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/StylelintCommandProvider.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/StylelintCommandProvider.java deleted file mode 100644 index c43af5c..0000000 --- a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/StylelintCommandProvider.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * SonarCSS - * Copyright (C) 2018-2019 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 GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * 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 GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.css.plugin; - -import java.io.File; -import java.nio.file.Paths;import java.util.Arrays; -import java.util.List; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.sonar.api.batch.ScannerSide; -import org.sonar.api.batch.sensor.SensorContext; -import org.sonarsource.nodejs.NodeCommand; - -@ScannerSide -public class StylelintCommandProvider implements LinterCommandProvider { - - private static final String CONFIG_PATH = "css-bundle/stylelintconfig.json"; - private static final List<String> LANGUAGES_TO_ANALYZE = Arrays.asList("css", "html", "php"); - - @Override - public NodeCommand nodeCommand(File deployDestination, SensorContext context, Consumer<String> output, Consumer<String> error) { - String projectBaseDir = context.fileSystem().baseDir().getAbsolutePath(); - - List<String> suffixes = LANGUAGES_TO_ANALYZE.stream() - .map(language -> context.config().getStringArray("sonar." + language + ".file.suffixes")) - .flatMap(Stream::of) - .collect(Collectors.toList()); - - String filesGlob = "**" + File.separator + "*{" + String.join(",", suffixes) + "}"; - String filesToAnalyze = Paths.get(projectBaseDir, "TOREPLACE").toString(); - filesToAnalyze = filesToAnalyze.replace("TOREPLACE", filesGlob); - - String[] args = { - new File(deployDestination, "css-bundle/node_modules/stylelint/bin/stylelint").getAbsolutePath(), - filesToAnalyze, - "--config", new File(deployDestination, CONFIG_PATH).getAbsolutePath(), - "-f", "json" - }; - - return NodeCommand.builder() - .outputConsumer(output) - .errorConsumer(error) - .minNodeVersion(6) - .configuration(context.config()) - .nodeJsArgs(args) - .build(); - } - - @Override - public String configPath(File deployDestination) { - return new File(deployDestination, CONFIG_PATH).getAbsolutePath(); - } - -} diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/LinterCommandProvider.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/Bundle.java index 74a343c..8e9ce9b 100644 --- a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/LinterCommandProvider.java +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/Bundle.java @@ -17,17 +17,18 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -package org.sonar.css.plugin; +package org.sonar.css.plugin.bundle; -import java.io.File; -import java.util.function.Consumer; -import org.sonar.api.batch.sensor.SensorContext; -import org.sonarsource.nodejs.NodeCommand; +import java.io.IOException; +import java.nio.file.Path; -public interface LinterCommandProvider { +public interface Bundle { - NodeCommand nodeCommand(File deployDestination, SensorContext context, Consumer<String> output, Consumer<String> error); + void deploy(Path deployLocation) throws IOException; - String configPath(File deployDestination); + /** + * should be called after deploy(Path deployLocation) + */ + String startServerScript(); } diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/CssAnalyzerBundle.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/CssAnalyzerBundle.java new file mode 100644 index 0000000..ba5d714 --- /dev/null +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/CssAnalyzerBundle.java @@ -0,0 +1,82 @@ +/* + * SonarCSS + * Copyright (C) 2018-2019 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 GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * 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 GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.css.plugin.bundle; + +import java.io.InputStream; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.sonar.api.internal.google.common.annotations.VisibleForTesting; +import org.sonar.api.scanner.ScannerSide; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.sonar.api.utils.log.Profiler; +import org.sonar.css.plugin.Zip; +import org.sonarsource.api.sonarlint.SonarLintSide; + +import static org.sonarsource.api.sonarlint.SonarLintSide.MULTIPLE_ANALYSES; + +@ScannerSide +@SonarLintSide(lifespan = MULTIPLE_ANALYSES) +public class CssAnalyzerBundle implements Bundle { + + private static final Logger LOG = Loggers.get(CssAnalyzerBundle.class); + private static final Profiler PROFILER = Profiler.createIfDebug(LOG); + + // this archive is created in css-bundle module + private static final String DEFAULT_BUNDLE_LOCATION = "/css-bundle.zip"; + private static final Path DEFAULT_STARTUP_SCRIPT = Paths.get("css-bundle", "bin", "server"); + + final String bundleLocation; + + private String startServerScript = DEFAULT_STARTUP_SCRIPT.toString(); + + public CssAnalyzerBundle() { + this(DEFAULT_BUNDLE_LOCATION); + } + + @VisibleForTesting + CssAnalyzerBundle(String bundleLocation) { + this.bundleLocation = bundleLocation; + } + + @Override + public void deploy(Path deployLocation) { + PROFILER.startDebug("Deploying bundle"); + LOG.debug("Deploying css-bundle into {}", deployLocation); + InputStream bundle = getClass().getResourceAsStream(bundleLocation); + if (bundle == null) { + throw new IllegalStateException("css-bundle not found in " + bundleLocation); + } + try { + LOG.debug("Deploying css-bundle to {}", deployLocation.toAbsolutePath()); + Zip.extract(bundle, deployLocation.toFile()); + startServerScript = deployLocation.resolve(DEFAULT_STARTUP_SCRIPT).toAbsolutePath().toString(); + } catch (Exception e) { + throw new IllegalStateException("Failed to deploy css-bundle (with classpath '" + bundleLocation + "')", e); + } + PROFILER.stopDebug(); + } + + @Override + public String startServerScript() { + return startServerScript; + } + +} diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/CssBundleHandler.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/CssBundleHandler.java deleted file mode 100644 index 41019b5..0000000 --- a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/CssBundleHandler.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SonarCSS - * Copyright (C) 2018-2019 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 GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * 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 GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.css.plugin.bundle; - -import java.io.File; -import java.io.InputStream; -import org.sonar.api.batch.ScannerSide; -import org.sonar.api.utils.log.Logger; -import org.sonar.api.utils.log.Loggers; -import org.sonar.css.plugin.Zip; - -@ScannerSide -public class CssBundleHandler implements BundleHandler { - - private static final String BUNDLE_LOCATION = "/css-bundle.zip"; - private static final Logger LOG = Loggers.get(CssBundleHandler.class); - String bundleLocation = BUNDLE_LOCATION; - - /** - * Extracting "css-bundle.zip" (containing stylelint) - * to deployDestination (".sonar" directory of the analyzed project). - */ - @Override - public void deployBundle(File deployDestination) { - InputStream bundle = getClass().getResourceAsStream(bundleLocation); - if (bundle == null) { - throw new IllegalStateException("CSS bundle not found at " + bundleLocation); - } - try { - LOG.debug("Deploying bundle to {}", deployDestination.getAbsolutePath()); - Zip.extract(bundle, deployDestination); - } catch (Exception e) { - throw new IllegalStateException("Failed to deploy CSS bundle (with classpath '" + bundleLocation + "')", e); - } - } - -} diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/AnalyzerBridgeServer.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/AnalyzerBridgeServer.java new file mode 100644 index 0000000..0656b06 --- /dev/null +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/AnalyzerBridgeServer.java @@ -0,0 +1,64 @@ +/* + * SonarCSS + * Copyright (C) 2018-2019 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 GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * 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 GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.css.plugin.server; + +import java.io.IOException; +import org.sonar.api.Startable; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.scanner.ScannerSide; +import org.sonarsource.api.sonarlint.SonarLintSide; + +import static org.sonarsource.api.sonarlint.SonarLintSide.MULTIPLE_ANALYSES; + +@ScannerSide +@SonarLintSide(lifespan = MULTIPLE_ANALYSES) +public interface AnalyzerBridgeServer extends Startable { + + void startServerLazily(SensorContext context) throws IOException; + + Issue[] analyze(Request request) throws IOException; + + String getCommandInfo(); + + boolean isAlive(); + + class Request { + public final String filePath; + public final String configFile; + + public Request(String filePath, String configFile) { + this.filePath = filePath; + this.configFile = configFile; + } + } + + class Issue { + public final Integer line; + public final String rule; + public final String text; + + public Issue(Integer line, String rule, String text) { + this.line = line; + this.rule = rule; + this.text = text; + } + } + +} diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/CssAnalyzerBridgeServer.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/CssAnalyzerBridgeServer.java new file mode 100644 index 0000000..fb8ee45 --- /dev/null +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/CssAnalyzerBridgeServer.java @@ -0,0 +1,232 @@ +/* + * SonarCSS + * Copyright (C) 2018-2019 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 GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * 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 GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.css.plugin.server; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.sonar.api.utils.log.Profiler; +import org.sonar.css.plugin.bundle.Bundle; +import org.sonar.css.plugin.server.exception.ServerAlreadyFailedException; +import org.sonarsource.nodejs.NodeCommand; +import org.sonarsource.nodejs.NodeCommandBuilder; +import org.sonarsource.nodejs.NodeCommandException; + +public class CssAnalyzerBridgeServer implements AnalyzerBridgeServer { + + private static final Logger LOG = Loggers.get(CssAnalyzerBridgeServer.class); + private static final Profiler PROFILER = Profiler.createIfDebug(LOG); + + private static final int DEFAULT_TIMEOUT_SECONDS = 60; + // internal property to set "--max-old-space-size" for Node process running this server + private static final String MAX_OLD_SPACE_SIZE_PROPERTY = "sonar.css.node.maxspace"; + private static final Gson GSON = new Gson(); + + private final OkHttpClient client; + private final NodeCommandBuilder nodeCommandBuilder; + final int timeoutSeconds; + private final Bundle bundle; + private int port; + private NodeCommand nodeCommand; + private boolean failedToStart; + + // Used by pico container for dependency injection + @SuppressWarnings("unused") + public CssAnalyzerBridgeServer(Bundle bundle) { + this(NodeCommand.builder(), DEFAULT_TIMEOUT_SECONDS, bundle); + } + + protected CssAnalyzerBridgeServer(NodeCommandBuilder nodeCommandBuilder, int timeoutSeconds, Bundle bundle) { + this.nodeCommandBuilder = nodeCommandBuilder; + this.timeoutSeconds = timeoutSeconds; + this.bundle = bundle; + this.client = new OkHttpClient.Builder() + .callTimeout(Duration.ofSeconds(timeoutSeconds)) + .readTimeout(Duration.ofSeconds(timeoutSeconds)) + .build(); + } + + // for testing purposes + public void deploy(File deployLocation) throws IOException { + bundle.deploy(deployLocation.toPath()); + } + + public void startServer(SensorContext context) throws IOException { + PROFILER.startDebug("Starting server"); + port = NetUtils.findOpenPort(); + + File scriptFile = new File(bundle.startServerScript()); + if (!scriptFile.exists()) { + throw new NodeCommandException("Node.js script to start css-bundle server doesn't exist: " + scriptFile.getAbsolutePath()); + } + + initNodeCommand(context, scriptFile); + + LOG.debug("Starting Node.js process to start css-bundle server at port " + port); + nodeCommand.start(); + + if (!NetUtils.waitServerToStart("localhost", port, timeoutSeconds * 1000)) { + throw new NodeCommandException("Failed to start server (" + timeoutSeconds + "s timeout)"); + } + PROFILER.stopDebug(); + } + + private void initNodeCommand(SensorContext context, File scriptFile) { + nodeCommandBuilder + .outputConsumer(message -> { + if (message.startsWith("DEBUG")) { + LOG.debug(message.substring(5).trim()); + } else if (message.startsWith("WARN")) { + LOG.warn(message.substring(4).trim()); + } else { + LOG.info(message); + } + }) + .minNodeVersion(8) + .configuration(context.config()) + .script(scriptFile.getAbsolutePath()) + .scriptArgs(String.valueOf(port)); + + context.config() + .getInt(MAX_OLD_SPACE_SIZE_PROPERTY) + .ifPresent(nodeCommandBuilder::maxOldSpaceSize); + + nodeCommand = nodeCommandBuilder.build(); + } + + @Override + public void startServerLazily(SensorContext context) throws IOException { + // required for SonarLint context to avoid restarting already failed server + if (failedToStart) { + throw new ServerAlreadyFailedException(); + } + + try { + if (isAlive()) { + LOG.debug("css-bundle server is up, no need to start."); + return; + } + deploy(context.fileSystem().workDir()); + startServer(context); + } catch (NodeCommandException e) { + failedToStart = true; + throw e; + } + } + + @Override + public Issue[] analyze(Request request) throws IOException { + String json = GSON.toJson(request); + return parseResponse(request(json)); + } + + private String request(String json) throws IOException { + okhttp3.Request request = new okhttp3.Request.Builder() + .url(url("analyze")) + .post(RequestBody.create(MediaType.get("application/json"), json)) + .build(); + + try (Response response = client.newCall(request).execute()) { + // in this case response.body() is never null (according to docs) + return response.body().string(); + } + } + + private static Issue[] parseResponse(String result) { + try { + return GSON.fromJson(result, Issue[].class); + } catch (JsonSyntaxException e) { + String msg = "Failed to parse response: \n-----\n" + result + "\n-----\n"; + LOG.error(msg, e); + throw new IllegalStateException("Failed to parse response", e); + } + } + + public boolean isAlive() { + if (nodeCommand == null) { + return false; + } + okhttp3.Request request = new okhttp3.Request.Builder() + .url(url("status")) + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + String body = response.body().string(); + // in this case response.body() is never null (according to docs) + return "OK!".equals(body); + } catch (IOException e) { + LOG.error("Error requesting server status. Server is probably dead.", e); + return false; + } + } + + @Override + public String getCommandInfo() { + if (nodeCommand == null) { + return "Node.js command to start css-bundle server was not built yet."; + } else { + return "Node.js command to start css-bundle was: " + nodeCommand.toString(); + } + } + + @Override + public void start() { + // Server is started lazily by the sensor + } + + @Override + public void stop() { + clean(); + } + + void clean() { + if (nodeCommand != null) { + nodeCommand.destroy(); + nodeCommand = null; + } + } + + private HttpUrl url(String endpoint) { + HttpUrl.Builder builder = new HttpUrl.Builder(); + return builder + .scheme("http") + .host("localhost") + .port(port) + .addPathSegment(endpoint) + .build(); + } + + // for testing purposes + public void setPort(int port) { + this.port = port; + } + +} diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/NetUtils.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/NetUtils.java new file mode 100644 index 0000000..fd3ab85 --- /dev/null +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/NetUtils.java @@ -0,0 +1,64 @@ +/* + * SonarCSS + * Copyright (C) 2018-2019 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 GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * 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 GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.css.plugin.server; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketAddress; + +public class NetUtils { + + private NetUtils() { + } + + public static int findOpenPort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + public static boolean waitServerToStart(String host, int port, int timeoutMs) { + int sleepStep = 20; + SocketAddress address = new InetSocketAddress(host, port); + long start = System.currentTimeMillis(); + try { + while (!serverListening(address)) { + if (System.currentTimeMillis() - start > timeoutMs) { + return false; + } + Thread.sleep(sleepStep); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return true; + } + + private static boolean serverListening(SocketAddress address) { + try (Socket s = new Socket()) { + s.connect(address, 1); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/AnalysisWarningsWrapper.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/exception/ServerAlreadyFailedException.java index 84871bf..0865aa4 100644 --- a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/AnalysisWarningsWrapper.java +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/exception/ServerAlreadyFailedException.java @@ -17,27 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -package org.sonar.css.plugin; - -import org.sonar.api.batch.InstantiationStrategy; -import org.sonar.api.batch.ScannerSide; -import org.sonar.api.notifications.AnalysisWarnings; +package org.sonar.css.plugin.server.exception; /** - * Wrap an AnalysisWarnings instance, available since SQ API 7.4. - * Do not load this class on older runtimes. - * Drop this class when the minimum supported version of SonarQube API reaches 7.4. + * This exception is required to inform sensor about analyzer bridge server start up failure in SonarLint + * It is required to not try to start it again */ -@ScannerSide -@InstantiationStrategy("PER_BATCH") -public class AnalysisWarningsWrapper { - private final AnalysisWarnings analysisWarnings; - - public AnalysisWarningsWrapper(AnalysisWarnings analysisWarnings) { - this.analysisWarnings = analysisWarnings; - } - - public void addUnique(String text) { - this.analysisWarnings.addUnique(text); - } +public class ServerAlreadyFailedException extends RuntimeException { } diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/BundleHandler.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/exception/package-info.java index 3000751..4f5b753 100644 --- a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/bundle/BundleHandler.java +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/exception/package-info.java @@ -17,12 +17,5 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -package org.sonar.css.plugin.bundle; - -import java.io.File; - -public interface BundleHandler { - - void deployBundle(File deployDestination); - -} +@javax.annotation.ParametersAreNonnullByDefault +package org.sonar.css.plugin.server.exception; diff --git a/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/package-info.java b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/package-info.java new file mode 100644 index 0000000..66fd848 --- /dev/null +++ b/sonar-css-plugin/src/main/java/org/sonar/css/plugin/server/package-info.java @@ -0,0 +1,21 @@ +/* + * SonarCSS + * Copyright (C) 2018-2019 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 GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * 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 GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@javax.annotation.ParametersAreNonnullByDefault +package org.sonar.css.plugin.server; |
