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
+