diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c01072 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +.idea/ +build/ + +*.iml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..050d956 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2017 Piotr Wielgolaski + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f85566a --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# Intellij plugin for validating AWS CloudFormation templates using cfn-lint + + +### Install plugin + +Download JAR file from [releases](https://github.com/binxio/cfn-lint-plugin/releases) section. Then follow JetBrains [Installing Plugin from Disk](https://www.jetbrains.com/help/idea/managing-plugins.html#installing-plugins-from-disk) instructions. + + +## Credits +Inspired by [shellcheck-plugin](https://github.com/pwielgolaski/shellcheck) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3a26ce6 --- /dev/null +++ b/build.gradle @@ -0,0 +1,28 @@ +plugins { + id 'java' + id 'org.jetbrains.intellij' version '0.3.5' + id 'io.freefair.git-version' version '2.5.9' +} + +group 'io.binx.cfnlint.plugin' + +sourceCompatibility = 1.8 + +repositories { + mavenCentral() +} + +dependencies { + testCompile group: 'junit', name: 'junit', version: '4.12' +} + +intellij { + version '2018.1.4' +} +patchPluginXml { + changeNotes """ + first release. work in progress. + """ +} + +apply plugin: "io.freefair.git-version" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e9fe877 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'cfn-lint-plugin' + diff --git a/src/main/java/io/binx/cfnlint/plugin/AnnotationResult.java b/src/main/java/io/binx/cfnlint/plugin/AnnotationResult.java new file mode 100644 index 0000000..d412d50 --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/AnnotationResult.java @@ -0,0 +1,27 @@ +package io.binx.cfnlint.plugin; + +import io.binx.cfnlint.plugin.utils.CheckResult; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +class AnnotationResult { + + private final CheckAnnotationInput input; + private final CheckResult result; + + AnnotationResult(CheckAnnotationInput input, CheckResult result) { + this.input = input; + this.result = result; + } + + public List getIssues() { + return Optional.ofNullable(result).map(CheckResult::getIssues).orElse(Collections.emptyList()); + } + + public CheckAnnotationInput getInput() { + return input; + } + +} diff --git a/src/main/java/io/binx/cfnlint/plugin/Bundle.java b/src/main/java/io/binx/cfnlint/plugin/Bundle.java new file mode 100644 index 0000000..01a80d8 --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/Bundle.java @@ -0,0 +1,34 @@ +package io.binx.cfnlint.plugin; + +import com.intellij.CommonBundle; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.PropertyKey; + +import java.lang.ref.Reference; +import java.lang.ref.SoftReference; +import java.util.ResourceBundle; + +public final class Bundle { + + + + public static String message(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key, @NotNull Object... params) { + return CommonBundle.message(getBundle(), key, params); + } + + @NonNls + private static final String BUNDLE = "io.binx.cfnlint.plugin.Bundle"; + private static Reference ourBundle; + private Bundle() { + } + + private static ResourceBundle getBundle() { + ResourceBundle bundle = com.intellij.reference.SoftReference.dereference(ourBundle); + if (bundle == null) { + bundle = ResourceBundle.getBundle(BUNDLE); + ourBundle = new SoftReference<>(bundle); + } + return bundle; + } +} diff --git a/src/main/java/io/binx/cfnlint/plugin/CheckAnnotationInput.java b/src/main/java/io/binx/cfnlint/plugin/CheckAnnotationInput.java new file mode 100644 index 0000000..ed0be51 --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/CheckAnnotationInput.java @@ -0,0 +1,32 @@ +package io.binx.cfnlint.plugin; + +import com.intellij.psi.PsiFile; + +class CheckAnnotationInput { + private final CheckProjectComponent component; + private final PsiFile psiFile; + private final String fileContent; + + CheckAnnotationInput(CheckProjectComponent component, PsiFile psiFile, String fileContent) { + this.component = component; + this.psiFile = psiFile; + this.fileContent = fileContent; + } + + CheckProjectComponent getComponent() { + return component; + } + + String getCwd() { + return psiFile.getProject().getBasePath(); + } + + String getFilePath() { + return psiFile.getVirtualFile().getPath(); + } + + String getFileContent() { + return fileContent; + } + +} diff --git a/src/main/java/io/binx/cfnlint/plugin/CheckExternalAnnotator.java b/src/main/java/io/binx/cfnlint/plugin/CheckExternalAnnotator.java new file mode 100644 index 0000000..d3df3d7 --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/CheckExternalAnnotator.java @@ -0,0 +1,166 @@ +package io.binx.cfnlint.plugin; + +import com.intellij.lang.annotation.Annotation; +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.ExternalAnnotator; +import com.intellij.lang.annotation.HighlightSeverity; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.MultiplePsiFilesPerDocumentFileViewProvider; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiFile; +import com.intellij.util.DocumentUtil; +import io.binx.cfnlint.plugin.utils.CheckResult; +import io.binx.cfnlint.plugin.utils.CheckRunner; +import org.apache.commons.lang.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +public class CheckExternalAnnotator extends + ExternalAnnotator { + + private static final Logger LOG = Logger.getInstance(CheckExternalAnnotator.class); + + @Nullable + @Override + public CheckAnnotationInput collectInformation(@NotNull PsiFile file, @NotNull Editor editor, boolean hasErrors) { + return collectInformation(file); + } + + @Nullable + @Override + public CheckAnnotationInput collectInformation(@NotNull PsiFile file) { + if (file.getContext() != null) { + return null; + } + VirtualFile virtualFile = file.getVirtualFile(); + if (virtualFile == null || !virtualFile.isInLocalFileSystem()) { + return null; + } + if (file.getViewProvider() instanceof MultiplePsiFilesPerDocumentFileViewProvider) { + return null; + } + CheckProjectComponent component = file.getProject().getComponent(CheckProjectComponent.class); + if (!component.isSettingsValid() || !component.isEnabled() || !isFile(file)) { + return null; + } + FileDocumentManager fileDocumentManager = FileDocumentManager.getInstance(); + boolean fileModified = fileDocumentManager.isFileModified(virtualFile); + return new CheckAnnotationInput(component, file, fileModified ? file.getText() : null); + } + + @Nullable + @Override + public AnnotationResult doAnnotate(CheckAnnotationInput input) { + CheckProjectComponent component = input.getComponent(); + try { + CheckResult result = CheckRunner.runCheck(component.getSettings().executable, input.getCwd(), input.getFilePath(), input.getFileContent()); + + if (StringUtils.isNotEmpty(result.getErrorOutput())) { + component.showInfoNotification(result.getErrorOutput(), NotificationType.WARNING); + return null; + } + return new AnnotationResult(input, result); + } catch (Exception e) { + LOG.error("Error running inspection: ", e); + component.showInfoNotification("Error running inspection: " + e.getMessage(), NotificationType.ERROR); + } + return null; + } + + @Override + public void apply(@NotNull PsiFile file, AnnotationResult annotationResult, @NotNull AnnotationHolder holder) { + if (annotationResult == null) { + return; + } + Document document = PsiDocumentManager.getInstance(file.getProject()).getDocument(file); + if (document == null) { + return; + } + + CheckProjectComponent component = annotationResult.getInput().getComponent(); + for (CheckResult.Issue issue : annotationResult.getIssues()) { + HighlightSeverity severity = getHighlightSeverity(issue, component.getSettings().treatAllIssuesAsWarnings); + createAnnotation(holder, document, issue, severity, component); + } + } + + private static HighlightSeverity getHighlightSeverity(CheckResult.Issue issue, boolean treatAsWarnings) { + switch (issue.level.toLowerCase()) { + case "error": + return treatAsWarnings ? HighlightSeverity.WARNING : HighlightSeverity.ERROR; + case "warning": + return HighlightSeverity.WARNING; + case "info": + return HighlightSeverity.INFORMATION; + default: + return HighlightSeverity.INFORMATION; + } + } + + @Nullable + private Annotation createAnnotation(@NotNull AnnotationHolder holder, @NotNull Document document, @NotNull CheckResult.Issue issue, + @NotNull HighlightSeverity severity, + CheckProjectComponent component) { + int errorLine = issue.location.start.lineNumber - 1; + boolean showErrorOnWholeLine = component.getSettings().highlightWholeLine; + + if (errorLine < 0 || errorLine >= document.getLineCount()) { + return null; + } + + int lineStartOffset = document.getLineStartOffset(errorLine); + int lineEndOffset = document.getLineEndOffset(errorLine); + + int errorLineStartOffset = appendNormalizeColumn(document, lineStartOffset, lineEndOffset, issue.location.start.columnNumber - 1); + if (errorLineStartOffset == -1) { + return null; + } + + TextRange range; + if (showErrorOnWholeLine) { + int start = DocumentUtil.getFirstNonSpaceCharOffset(document, lineStartOffset, lineEndOffset); + range = new TextRange(start, lineEndOffset); + } else { + range = new TextRange(errorLineStartOffset, errorLineStartOffset + 1); + } + + Annotation annotation = holder.createAnnotation(severity, range, ": " + issue.getFormattedMessage()); + if (annotation != null) { + annotation.setAfterEndOfLine(errorLineStartOffset == lineEndOffset); + } + return annotation; + } + + private int appendNormalizeColumn(@NotNull Document document, int startOffset, int endOffset, int column) { + CharSequence text = document.getImmutableCharSequence(); + int col = 0; + for (int i = startOffset; i < endOffset; i++) { + char c = text.charAt(i); + col += (c == '\t' ? 8 : 1); + if (col > column) { + return i; + } + } + return startOffset; + } + + private static boolean isFile(PsiFile file) { + // TODO move to settings? + List acceptedExtensions = Arrays.asList("yml", "yaml", "json"); + boolean isCloudFormation = file.getFileType().getName().equals("CloudFormation"); + String fileExtension = Optional.ofNullable(file.getVirtualFile()).map(VirtualFile::getExtension).orElse(""); + return isCloudFormation || acceptedExtensions.contains(fileExtension); + } +} + + diff --git a/src/main/java/io/binx/cfnlint/plugin/CheckProjectComponent.java b/src/main/java/io/binx/cfnlint/plugin/CheckProjectComponent.java new file mode 100644 index 0000000..f7ffeaa --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/CheckProjectComponent.java @@ -0,0 +1,91 @@ +package io.binx.cfnlint.plugin; + +import com.intellij.notification.Notification; +import com.intellij.notification.NotificationListener; +import com.intellij.notification.NotificationType; +import com.intellij.notification.Notifications; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import io.binx.cfnlint.plugin.settings.Settings; +import io.binx.cfnlint.plugin.settings.SettingsPage; +import org.jetbrains.annotations.NotNull; + +public class CheckProjectComponent implements com.intellij.openapi.components.ProjectComponent { + private Project project; + private Settings settings; + private boolean settingValidStatus; + private int settingHashCode; + + private static final Logger LOG = Logger.getInstance(CheckProjectComponent.class); + + private static final String PLUGIN_NAME = "cfn-lint"; + + public CheckProjectComponent(Project project, Settings settings) { + this.project = project; + this.settings = settings; + } + + @Override + public void projectOpened() { + if (isEnabled()) { + isSettingsValid(); + } + } + + @Override + public void projectClosed() { + } + + @Override + public void initComponent() { + if (isEnabled()) { + isSettingsValid(); + } + } + + @Override + public void disposeComponent() { + } + + @NotNull + @Override + public String getComponentName() { + return CheckProjectComponent.class.getName(); + } + + Settings getSettings() { + return settings; + } + + boolean isEnabled() { + return settings.pluginEnabled; + } + + boolean isSettingsValid() { + if (settings.hashCode() != settingHashCode) { + settingHashCode = settings.hashCode(); + settingValidStatus = settings.isValid(project); + if (!settingValidStatus) { + validationFailed(Bundle.message("settings.invalid")); + } + } + return settingValidStatus; + } + + private void validationFailed(String msg) { + NotificationListener notificationListener = (notification, event) -> new SettingsPage(project, settings).showSettings(); + String errorMessage = msg + Bundle.message("settings.fix"); + showInfoNotification(errorMessage, NotificationType.WARNING, notificationListener); + LOG.debug(msg); + settingValidStatus = false; + } + + void showInfoNotification(String content, NotificationType type) { + showInfoNotification(content, type, null); + } + + private void showInfoNotification(String content, NotificationType type, NotificationListener notificationListener) { + Notification notification = new Notification(PLUGIN_NAME, PLUGIN_NAME, content, type, notificationListener); + Notifications.Bus.notify(notification, this.project); + } +} diff --git a/src/main/java/io/binx/cfnlint/plugin/Inspection.java b/src/main/java/io/binx/cfnlint/plugin/Inspection.java new file mode 100644 index 0000000..16127df --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/Inspection.java @@ -0,0 +1,55 @@ +package io.binx.cfnlint.plugin; + +import com.intellij.codeInspection.BatchSuppressableTool; +import com.intellij.codeInspection.ExternalAnnotatorInspectionVisitor; +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.LocalInspectionTool; +import com.intellij.codeInspection.LocalInspectionToolSession; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.ProblemsHolder; +import com.intellij.codeInspection.SuppressQuickFix; +import com.intellij.codeInspection.ex.UnfairLocalInspectionTool; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiElementVisitor; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class Inspection extends LocalInspectionTool implements BatchSuppressableTool, UnfairLocalInspectionTool { + + public Inspection() { + } + + @NotNull + public String getDisplayName() + { + return Bundle.message("inspection.display.name"); + } + + @NotNull + public String getShortName() { + return Bundle.message("inspection.short.name"); + } + + + public ProblemDescriptor[] checkFile(@NotNull PsiFile file, @NotNull final InspectionManager manager, final boolean isOnTheFly) { + return ExternalAnnotatorInspectionVisitor.checkFileWithExternalAnnotator(file, manager, isOnTheFly, new CheckExternalAnnotator()); + } + + @NotNull + @Override + public PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly, @NotNull LocalInspectionToolSession session) { + return new ExternalAnnotatorInspectionVisitor(holder, new CheckExternalAnnotator(), isOnTheFly); + } + + @Override + public boolean isSuppressedFor(@NotNull PsiElement element) { + return false; + } + + @NotNull + @Override + public SuppressQuickFix[] getBatchSuppressActions(@Nullable PsiElement element) { + return new SuppressQuickFix[0]; + } +} diff --git a/src/main/java/io/binx/cfnlint/plugin/settings/Finder.java b/src/main/java/io/binx/cfnlint/plugin/settings/Finder.java new file mode 100644 index 0000000..789a6fd --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/settings/Finder.java @@ -0,0 +1,45 @@ +package io.binx.cfnlint.plugin.settings; + +import com.intellij.execution.configurations.PathEnvironmentVariableUtil; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.util.List; +import java.util.stream.Collectors; + +public final class Finder { + private Finder() { + } + + @NotNull + static List findAllExe() { + List fromPath = PathEnvironmentVariableUtil.findAllExeFilesInPath(getBinName("cfn-lint")); + return fromPath.stream().map(File::getAbsolutePath).distinct().collect(Collectors.toList()); + } + + static String getBinName(String baseBinName) { + // TODO do we need different name for windows? + return SystemInfo.isWindows ? baseBinName + ".cmd" : baseBinName; + } + + static boolean validatePath(Project project, String path) { + File filePath = new File(path); + if (filePath.isAbsolute()) { + if (!filePath.exists() || !filePath.isFile()) { + return false; + } + } else { + if (project == null || project.getBaseDir() == null) { + return true; + } + VirtualFile child = project.getBaseDir().findFileByRelativePath(path); + if (child == null || !child.exists() || child.isDirectory()) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/io/binx/cfnlint/plugin/settings/Settings.java b/src/main/java/io/binx/cfnlint/plugin/settings/Settings.java new file mode 100644 index 0000000..e6090cc --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/settings/Settings.java @@ -0,0 +1,50 @@ +package io.binx.cfnlint.plugin.settings; + +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import com.intellij.openapi.project.Project; +import com.intellij.util.xmlb.XmlSerializerUtil; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +@State(name = "CheckProjectComponent", storages = {@Storage("cfnlintPlugin.xml") }) +public class Settings implements PersistentStateComponent { + public String executable = ""; + public boolean treatAllIssuesAsWarnings; + public boolean highlightWholeLine; + public boolean pluginEnabled; + + @Nullable + @Override + public Settings getState() { + return this; + } + + @Override + public void loadState(Settings state) { + XmlSerializerUtil.copyBean(state, this); + } + + + public boolean isValid(Project project) { + return Finder.validatePath(project, executable); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Settings settings = (Settings) o; + return treatAllIssuesAsWarnings == settings.treatAllIssuesAsWarnings && + highlightWholeLine == settings.highlightWholeLine && + pluginEnabled == settings.pluginEnabled && + Objects.equals(executable, settings.executable); + } + + @Override + public int hashCode() { + return Objects.hash(executable, treatAllIssuesAsWarnings, highlightWholeLine, pluginEnabled); + } +} diff --git a/src/main/java/io/binx/cfnlint/plugin/settings/SettingsPage.form b/src/main/java/io/binx/cfnlint/plugin/settings/SettingsPage.form new file mode 100644 index 0000000..faece7f --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/settings/SettingsPage.form @@ -0,0 +1,86 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/java/io/binx/cfnlint/plugin/settings/SettingsPage.java b/src/main/java/io/binx/cfnlint/plugin/settings/SettingsPage.java new file mode 100644 index 0000000..2779998 --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/settings/SettingsPage.java @@ -0,0 +1,154 @@ +package io.binx.cfnlint.plugin.settings; + +import com.intellij.execution.ExecutionException; +import com.intellij.ide.actions.ShowSettingsUtilImpl; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.options.ex.SingleConfigurableEditor; +import com.intellij.openapi.project.Project; +import com.intellij.ui.DocumentAdapter; +import com.intellij.ui.TextFieldWithHistory; +import com.intellij.ui.TextFieldWithHistoryWithBrowseButton; +import com.intellij.util.ui.SwingHelper; +import com.intellij.util.ui.UIUtil; +import io.binx.cfnlint.plugin.Bundle; +import io.binx.cfnlint.plugin.utils.CheckRunner; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import java.awt.event.ItemEvent; +import java.util.stream.Stream; + +public class SettingsPage implements Configurable { + private final Project project; + private final Settings settings; + + private JCheckBox pluginEnabledCheckbox; + private JPanel panel; + private JPanel errorPanel; + private JCheckBox treatAllIssuesCheckBox; + private JCheckBox highlightWholeLineCheckBox; + private JLabel versionLabel; + private JLabel exeLabel; + private TextFieldWithHistoryWithBrowseButton exeField; + + public SettingsPage(@NotNull final Project project, @NotNull Settings settings) { + this.project = project; + this.settings = settings; + initField(); + } + + private void addListeners() { + pluginEnabledCheckbox.addItemListener(e -> setEnabledState(e.getStateChange() == ItemEvent.SELECTED)); + DocumentAdapter docAdp = new DocumentAdapter() { + protected void textChanged(DocumentEvent e) { + updateLaterInEDT(); + } + }; + exeField.getChildComponent().getTextEditor().getDocument().addDocumentListener(docAdp); + } + + private void updateLaterInEDT() { + UIUtil.invokeLaterIfNeeded(SettingsPage.this::update); + } + + private void update() { + ApplicationManager.getApplication().assertIsDispatchThread(); + updateVersion(); + } + + private void setEnabledState(boolean enabled) { + Stream.of(exeField, exeLabel, + treatAllIssuesCheckBox, highlightWholeLineCheckBox) + .forEach(c -> c.setEnabled(enabled)); + } + + private void updateVersion() { + updateVersion(exeField.getChildComponent().getText()); + } + private void updateVersion(String exe) { + String version = "n.a."; + if (Finder.validatePath(project, exe)) { + try { + version = CheckRunner.runVersion(exe, project.getBasePath()); + } catch (ExecutionException e) { + version = "error"; + } + } + versionLabel.setText(version); + } + + private void initField() { + TextFieldWithHistory textFieldWithHistory = exeField.getChildComponent(); + textFieldWithHistory.setHistorySize(-1); + textFieldWithHistory.setMinimumAndPreferredWidth(0); + + SwingHelper.addHistoryOnExpansion(textFieldWithHistory, Finder::findAllExe); + SwingHelper.installFileCompletionAndBrowseDialog(project, exeField, "Select Exe", FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor()); + } + + @Nls + @Override + public String getDisplayName() { + return Bundle.message("name"); + } + + @Nullable + @Override + public String getHelpTopic() { + return null; + } + + @Nullable + @Override + public JComponent createComponent() { + loadSettings(); + updateVersion(); + addListeners(); + return panel; + } + + @Override + public boolean isModified() { + return pluginEnabledCheckbox.isSelected() != settings.pluginEnabled + || !exeField.getChildComponent().getText().equals(settings.executable) + || treatAllIssuesCheckBox.isSelected() != settings.treatAllIssuesAsWarnings + || highlightWholeLineCheckBox.isSelected() != settings.highlightWholeLine; + } + + @Override + public void apply() throws ConfigurationException { + saveSettings(); + } + + private void saveSettings() { + settings.pluginEnabled = pluginEnabledCheckbox.isSelected(); + settings.executable = exeField.getChildComponent().getText(); + settings.treatAllIssuesAsWarnings = treatAllIssuesCheckBox.isSelected(); + settings.highlightWholeLine = highlightWholeLineCheckBox.isSelected(); + } + + private void loadSettings() { + pluginEnabledCheckbox.setSelected(settings.pluginEnabled); + exeField.getChildComponent().setText(settings.executable); + treatAllIssuesCheckBox.setSelected(settings.treatAllIssuesAsWarnings); + highlightWholeLineCheckBox.setSelected(settings.highlightWholeLine); + setEnabledState(settings.pluginEnabled); + } + + @Override + public void reset() { + loadSettings(); + } + + public void showSettings() { + String dimensionKey = ShowSettingsUtilImpl.createDimensionKey(this); + SingleConfigurableEditor singleConfigurableEditor = new SingleConfigurableEditor(project, this, dimensionKey, false); + singleConfigurableEditor.show(); + } +} diff --git a/src/main/java/io/binx/cfnlint/plugin/utils/CheckResult.java b/src/main/java/io/binx/cfnlint/plugin/utils/CheckResult.java new file mode 100644 index 0000000..796710d --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/utils/CheckResult.java @@ -0,0 +1,105 @@ +package io.binx.cfnlint.plugin.utils; + +import com.google.gson.annotations.SerializedName; + +import java.util.Collections; +import java.util.List; + +public class CheckResult { + private final List issues; + private final String errorOutput; + + public CheckResult(List issues, String errorOutput) { + this.issues = issues == null ? Collections.emptyList() : issues; + this.errorOutput = errorOutput; + } + + public CheckResult(String errorOutput) { + this(null, errorOutput); + } + + public List getIssues() { + return issues; + } + + public String getErrorOutput() { + return errorOutput; + } + + public static class Rule { + @SerializedName("Id") + public String id; + @SerializedName("Description") + public String description; + @SerializedName("ShortDescription") + public String shortDescription; + @SerializedName("Source") + public String source; + + @Override + public String toString() { + return "Rule{" + + "id='" + id + '\'' + + ", description='" + description + '\'' + + ", shortDescription='" + shortDescription + '\'' + + ", source='" + source + '\'' + + '}'; + } + } + public static class Offset { + @SerializedName("ColumnNumber") + public int columnNumber; + @SerializedName("LineNumber") + public int lineNumber; + + @Override + public String toString() { + return "Offset{" + + "columnNumber=" + columnNumber + + ", lineNumber=" + lineNumber + + '}'; + } + } + + public static class Location { + @SerializedName("Start") + public Offset start; + @SerializedName("End") + public Offset end; + + @Override + public String toString() { + return "Location{" + + "start=" + start + + ", end=" + end + + '}'; + } + } + + public static class Issue { + @SerializedName("Rule") + public Rule rule; + @SerializedName("Location") + public Location location; + @SerializedName("Level") + public String level; + @SerializedName("Message") + public String message; + @SerializedName("Filename") + public String filename; + public String getFormattedMessage() { + return message.trim() + " [" + (rule == null ? "none" : rule.id) + "]"; + } + + @Override + public String toString() { + return "Issue{" + + "rule=" + rule + + ", location=" + location + + ", level='" + level + '\'' + + ", message='" + message + '\'' + + ", filename='" + filename + '\'' + + '}'; + } + } +} diff --git a/src/main/java/io/binx/cfnlint/plugin/utils/CheckRunner.java b/src/main/java/io/binx/cfnlint/plugin/utils/CheckRunner.java new file mode 100644 index 0000000..7fd0257 --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/utils/CheckRunner.java @@ -0,0 +1,112 @@ +package io.binx.cfnlint.plugin.utils; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.ColoredProcessHandler; +import com.intellij.execution.process.OSProcessHandler; +import com.intellij.execution.process.ProcessAdapter; +import com.intellij.execution.process.ProcessEvent; +import com.intellij.execution.process.ProcessOutput; +import com.intellij.execution.process.ProcessOutputTypes; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.Key; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class CheckRunner { + private CheckRunner() { + } + + private static final Logger LOG = Logger.getInstance(CheckRunner.class); + private static final int TIME_OUT = (int) TimeUnit.SECONDS.toMillis(120L); + + public static CheckResult runCheck(@NotNull String exe, @NotNull String cwd, @NotNull String file, String content) { + CheckResult result; + try { + File path = new File(new File(cwd), file); + GeneralCommandLine commandLine = createCommandLine(exe, cwd) + .withInput(content) + .withParameters("-f", "json", "-t", file); + ProcessOutput out = execute(commandLine); + try { + result = new CheckResult(parse(out.getStdout()), out.getStderr()); + } catch (Exception e) { + result = new CheckResult(out.getStdout()); + } + } catch (Exception e) { + LOG.error("Problem with running exe", e); + result = new CheckResult(e.toString()); + } + return result; + } + + private static List parse(String json) { + Gson g = new GsonBuilder().create(); + Type listType = new TypeToken>() { + }.getType(); + return g.fromJson(json, listType); + } + + @NotNull + public static String runVersion(@NotNull String exe, @NotNull String cwd) throws ExecutionException { + if (!new File(exe).exists()) { + LOG.warn("Calling version with invalid exe " + exe); + return ""; + } + + ProcessOutput out = execute(createCommandLine(exe, cwd).withParameters("--version")); + if (out.getExitCode() == 0) { + String output = out.getStdout().trim(); + Matcher matcher = Pattern.compile("^version:(.+)$", Pattern.MULTILINE).matcher(output); + return matcher.find() ? matcher.group(1).trim() : output; + } + + return ""; + } + + @NotNull + private static CommandLineWithInput createCommandLine(@NotNull String exe, @NotNull String cwd) { + CommandLineWithInput commandLine = new CommandLineWithInput(); + commandLine.setExePath(exe); + commandLine.setWorkDirectory(cwd); + return commandLine; + } + + @NotNull + public static ProcessOutput execute(@NotNull GeneralCommandLine commandLine) throws ExecutionException { + LOG.info("Running command: " + commandLine.getCommandLineString()); + Process process = commandLine.createProcess(); + OSProcessHandler processHandler = new ColoredProcessHandler(process, commandLine.getCommandLineString(), StandardCharsets.UTF_8); + final ProcessOutput output = new ProcessOutput(); + processHandler.addProcessListener(new ProcessAdapter() { + public void onTextAvailable(ProcessEvent event, Key outputType) { + if (outputType.equals(ProcessOutputTypes.STDERR)) { + output.appendStderr(event.getText()); + } else if (!outputType.equals(ProcessOutputTypes.SYSTEM)) { + output.appendStdout(event.getText()); + } + } + }); + processHandler.startNotify(); + if (processHandler.waitFor(TIME_OUT)) { + output.setExitCode(process.exitValue()); + } else { + processHandler.destroyProcess(); + output.setTimeout(); + } + if (output.isTimeout()) { + throw new ExecutionException("Command '" + commandLine.getCommandLineString() + "' is timed out."); + } + return output; + } +} diff --git a/src/main/java/io/binx/cfnlint/plugin/utils/CommandLineWithInput.java b/src/main/java/io/binx/cfnlint/plugin/utils/CommandLineWithInput.java new file mode 100644 index 0000000..3a3f187 --- /dev/null +++ b/src/main/java/io/binx/cfnlint/plugin/utils/CommandLineWithInput.java @@ -0,0 +1,34 @@ +package io.binx.cfnlint.plugin.utils; + +import com.google.common.io.CharSource; +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +public class CommandLineWithInput extends GeneralCommandLine { + + private String input; + + public CommandLineWithInput withInput(String input) { + this.input = input; + return this; + } + + @NotNull + @Override + public Process createProcess() throws ExecutionException { + Process process = super.createProcess(); + if (input != null) { + try (OutputStream stdin = process.getOutputStream()) { + CharSource.wrap(input).asByteSource(StandardCharsets.UTF_8).copyTo(stdin); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return process; + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..079635d --- /dev/null +++ b/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,47 @@ + + io.binx.cfnlint.plugin + cfn-lint + 0.1.0 + Web + binx.io + + + + 0.1.0 First experimental version based upon shellcheck

+ ]]>
+ + + + + + com.intellij.modules.lang + + + + + + + + + + + + + + + io.binx.cfnlint.plugin.CheckProjectComponent + + + + + + + +
diff --git a/src/main/resources/inspectionDescriptions/cfn-lintInspection.html b/src/main/resources/inspectionDescriptions/cfn-lintInspection.html new file mode 100644 index 0000000..5cd7d7f --- /dev/null +++ b/src/main/resources/inspectionDescriptions/cfn-lintInspection.html @@ -0,0 +1,6 @@ + + +Run cfn-lint to validate AWS CloudFormation tempaltes + + + \ No newline at end of file diff --git a/src/main/resources/io/binx/cfnlint/plugin/Bundle.properties b/src/main/resources/io/binx/cfnlint/plugin/Bundle.properties new file mode 100644 index 0000000..9cc741e --- /dev/null +++ b/src/main/resources/io/binx/cfnlint/plugin/Bundle.properties @@ -0,0 +1,20 @@ +# Main +name=CFNLint + +# Inspections +inspection.group.name=cfn-lint +inspection.display.name=cfn-lint +inspection.short.name=cfn-lint + +# Settings +settings.fix=\nFix Configuration +settings.invalid=Invalid configuration +settings.config.enable=&Enable +settings.config.enable.tooltip=To apply the settings, you must restart IDE +settings.highlight.whole.line=&Highlight whole line +settings.highlight.whole.line.tooltip=Make warnings and errors even more visible by highlighting whole line +settings.treat.all.as.warnings=&Treat all issues as warnings +settings.treat.all.as.warnings.tooltip=Ignore the severity in the config of warnings and errors and treat all issues as warnings +settings.config.exe=cfn-lint exe +settings.config.exe.tooltip=Path to cfn-lint exec +