Repository: warpnet/salt-lint (python)

Issue:
Feature Request: Check usage of dots in SLS file names
**Is your feature request related to a problem? Please describe.**

As described by the [official SaltStack documentation](https://docs.saltstack.com/en/3000/topics/tutorials/starting_states.html#moving-beyond-a-single-sls):

> The initial implementation of top.sls and Include declaration followed the python import model where a slash is represented as a period. This means that a SLS file with a period in the name ( besides the suffix period) can not be referenced. For example, webserver_1.0.sls is not referenceable because webserver_1.0 would refer to the directory/file webserver_1/0.sls

> The same applies for any subdirectories, this is especially 'tricky' when git repos are created. Another command that typically can't render it's output is `state.show_sls` of a file in a path that contains a dot.


**Describe the solution you'd like**

Check for any dots in the basename of the SLS file.



Pull request description:
Add rule to check file name of SLS files
Add new rule to check file name of SLS files, as SLS file with a period
in the name (besides the suffix period) can not be referenced.

This pull request also allows rules to only run for specific file types by
adding valid `languages` to the rule. File types are determined based
upon the file extension.

Fixes #205


Patch:
diff --git a/README.md b/README.md
index fbc3364..5758647 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,7 @@ Rule | Description
 [211](https://github.com/warpnet/salt-lint/wiki/211) | `pillar.get` or `grains.get` should be formatted differently
 [212](https://github.com/warpnet/salt-lint/wiki/212) | Most files should not contain irregular spaces
 [213](https://github.com/warpnet/salt-lint/wiki/213) | SaltStack recommends using `cmd.run` together with `onchanges`, rather than `cmd.wait`
+[214](https://github.com/warpnet/salt-lint/wiki/214) | SLS file with a period in the name (besides the suffix period) can not be referenced
 
 ### Jinja
 
diff --git a/saltlint/linter/rule.py b/saltlint/linter/rule.py
index bf95848..becf140 100644
--- a/saltlint/linter/rule.py
+++ b/saltlint/linter/rule.py
@@ -5,12 +5,19 @@
 import re
 import six
 
-from saltlint.utils import get_rule_skips_from_line
+from saltlint.utils import get_rule_skips_from_line, get_file_type
 from saltlint.linter.match import Match
 
 
 class Rule(object):
 
+    id = None
+    shortdesc = None
+    description = None
+    languages = []
+    match = None
+    matchtext = None
+
     def __init__(self, config=None):
         self.config = config
 
@@ -20,18 +27,30 @@ class Rule(object):
     def verbose(self):
         return self.id + ": " + self.shortdesc + "\n " + self.description
 
-    match = None
-    matchtext = None
-
     @staticmethod
     def unjinja(text):
         return re.sub(r"{{[^}]*}}", "JINJA_VAR", text)
 
+    def is_valid_language(self, file):
+        """
+        Returns True if the file type is in the supported languages or no
+        language is specified for the linting rule and False otherwise.
+
+        The file type is determined based upon the file extension.
+        """
+        if not self.languages or get_file_type(file["path"]) in self.languages:
+            return True
+        return False
+
     def matchlines(self, file, text):
         matches = []
+
         if not self.match:
             return matches
 
+        if not self.is_valid_language(file):
+            return matches
+
         # arrays are 0-based, line numbers are 1-based
         # so use prev_line_no as the counter
         for (prev_line_no, line) in enumerate(text.split("\n")):
@@ -58,6 +77,9 @@ class Rule(object):
         if not self.matchtext:
             return matches
 
+        if not self.is_valid_language(file):
+            return matches
+
         results = self.matchtext(file, text)
 
         for line, section, message in results:
diff --git a/saltlint/linter/runner.py b/saltlint/linter/runner.py
index 3add57d..752d0e6 100644
--- a/saltlint/linter/runner.py
+++ b/saltlint/linter/runner.py
@@ -6,18 +6,20 @@ from __future__ import print_function
 
 import os
 
+from saltlint.utils import get_file_type
+
 
 class Runner(object):
 
     def __init__(self, collection, file_name, config, checked_files=None):
         self.collection = collection
 
-        self.states = set()
+        self.files = set()
         # Assume the provided file name is a directory
         if os.path.isdir(file_name):
-            self.states.add((os.path.join(file_name, 'init.sls'), 'state'))
+            self.files.add((os.path.join(file_name, 'init.sls'), 'state'))
         else:
-            self.states.add((file_name, 'state'))
+            self.files.add((file_name, get_file_type(file_name)))
 
         # Get configuration options
         self.config = config
@@ -49,9 +51,9 @@ class Runner(object):
 
     def run(self):
         files = []
-        for index, state in enumerate(self.states):
-            file_path = state[0]
-            file_type = state[1]
+        for index, file in enumerate(self.files):
+            file_path = file[0]
+            file_type = file[1]
             file_dict = {'path': file_path, 'type': file_type}
             # Skip excluded files
             if self.is_excluded(file_path):
diff --git a/saltlint/rules/CmdRunQuietRule.py b/saltlint/rules/CmdRunQuietRule.py
index 5dd68ce..f290750 100644
--- a/saltlint/rules/CmdRunQuietRule.py
+++ b/saltlint/rules/CmdRunQuietRule.py
@@ -4,6 +4,8 @@
 import re
 from saltlint.linter.rule import Rule
 from saltlint.utils import get_rule_skips_from_text
+from saltlint.utils import LANGUAGE_SLS
+
 
 class CmdRunQuietRule(Rule):
     id = '901'
@@ -11,6 +13,7 @@ class CmdRunQuietRule(Rule):
     description = 'Using the quiet argument with cmd.run is deprecated. Use output_loglevel: quiet'
 
     severity = 'HIGH'
+    languages = [LANGUAGE_SLS]
     tags = ['deprecation']
     version_added = 'develop'
 
diff --git a/saltlint/rules/CmdWaitRecommendRule.py b/saltlint/rules/CmdWaitRecommendRule.py
index 90ccb12..2fd7405 100644
--- a/saltlint/rules/CmdWaitRecommendRule.py
+++ b/saltlint/rules/CmdWaitRecommendRule.py
@@ -3,6 +3,7 @@
 
 import re
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_SLS
 
 
 class CmdWaitRecommendRule(Rule):
@@ -11,6 +12,7 @@ class CmdWaitRecommendRule(Rule):
     description = 'SaltStack recommends using cmd.run together with onchanges, rather than cmd.wait'
 
     severity = 'LOW'
+    languages = [LANGUAGE_SLS]
     tags = ['formatting']
     version_added = 'develop'
 
diff --git a/saltlint/rules/FileExtensionRule.py b/saltlint/rules/FileExtensionRule.py
index c8157e5..9949a56 100644
--- a/saltlint/rules/FileExtensionRule.py
+++ b/saltlint/rules/FileExtensionRule.py
@@ -13,16 +13,15 @@ class FileExtensionRule(Rule):
     description = 'Salt State files should have the ".sls" extension'
     severity = 'MEDIUM'
     tags = ['formatting']
-    done = []  # already noticed path list
     version_added = 'v0.0.1'
 
-    def match(self, file, line):
-        if file['type'] != 'state':
-            return False
-
+    def matchtext(self, file, text):
+        results = []
         path = file['path']
         ext = os.path.splitext(path)
-        if ext[1] not in ['.sls'] and path not in self.done:
-            self.done.append(path)
-            return True
-        return False
+        if ext[1] not in ['.sls']:
+            line_no = 1
+            lines = text.splitlines()
+            line = lines[0] if len(lines) > 0 else ''
+            results.append((line_no, line, self.shortdesc))
+        return results
diff --git a/saltlint/rules/FileModeLeadingZeroRule.py b/saltlint/rules/FileModeLeadingZeroRule.py
index 226bea3..2849a57 100644
--- a/saltlint/rules/FileModeLeadingZeroRule.py
+++ b/saltlint/rules/FileModeLeadingZeroRule.py
@@ -5,6 +5,7 @@
 
 import re
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_SLS
 
 
 class FileModeLeadingZeroRule(Rule):
@@ -12,10 +13,11 @@ class FileModeLeadingZeroRule(Rule):
     shortdesc = 'File modes should always contain a leading zero'
     description = 'File modes should always contain a leading zero'
     severity = 'LOW'
+    languages = [LANGUAGE_SLS]
     tags = ['formatting']
     version_added = 'v0.0.3'
 
-    bracket_regex = re.compile(r"^\s+- ((dir_)|(file_))?mode: ((')|(\"))?[0-9]{3}([\D]|$)")
+    regex = re.compile(r"^\s+- ((dir_)|(file_))?mode: ((')|(\"))?[0-9]{3}([\D]|$)")
 
     def match(self, file, line):
-        return self.bracket_regex.search(line)
+        return self.regex.search(line)
diff --git a/saltlint/rules/FileModeQuotationRule.py b/saltlint/rules/FileModeQuotationRule.py
index e449c0c..07a21eb 100644
--- a/saltlint/rules/FileModeQuotationRule.py
+++ b/saltlint/rules/FileModeQuotationRule.py
@@ -5,6 +5,7 @@
 
 import re
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_SLS
 
 
 class FileModeQuotationRule(Rule):
@@ -12,10 +13,11 @@ class FileModeQuotationRule(Rule):
     shortdesc = 'File modes should always be encapsulated in quotation marks'
     description = 'File modes should always be encapsulated in quotation marks'
     severity = 'HIGH'
+    languages = [LANGUAGE_SLS]
     tags = ['formatting']
     version_added = 'v0.0.3'
 
-    bracket_regex = re.compile(r"^\s+- ((dir_)|(file_))?mode: [0-9]{3,4}")
+    regex = re.compile(r"^\s+- ((dir_)|(file_))?mode: [0-9]{3,4}")
 
     def match(self, file, line):
-        return self.bracket_regex.search(line)
+        return self.regex.search(line)
diff --git a/saltlint/rules/JinjaCommentHasSpacesRule.py b/saltlint/rules/JinjaCommentHasSpacesRule.py
index 6caa1b6..7c54719 100644
--- a/saltlint/rules/JinjaCommentHasSpacesRule.py
+++ b/saltlint/rules/JinjaCommentHasSpacesRule.py
@@ -5,6 +5,7 @@
 
 import re
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_JINJA, LANGUAGE_SLS
 
 
 class JinjaCommentHasSpacesRule(Rule):
@@ -12,6 +13,7 @@ class JinjaCommentHasSpacesRule(Rule):
     shortdesc = 'Jinja comment should have spaces before and after: {# comment #}'
     description = 'Jinja comment should have spaces before and after: ``{# comment #}``'
     severity = 'LOW'
+    languages = [LANGUAGE_SLS, LANGUAGE_JINJA]
     tags = ['formatting', 'jinja']
     version_added = 'v0.0.5'
 
diff --git a/saltlint/rules/JinjaPillarGrainsGetFormatRule.py b/saltlint/rules/JinjaPillarGrainsGetFormatRule.py
index efd8181..accace3 100644
--- a/saltlint/rules/JinjaPillarGrainsGetFormatRule.py
+++ b/saltlint/rules/JinjaPillarGrainsGetFormatRule.py
@@ -5,6 +5,7 @@
 
 import re
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_JINJA, LANGUAGE_SLS
 
 
 class JinjaPillarGrainsGetFormatRule(Rule):
@@ -14,6 +15,7 @@ class JinjaPillarGrainsGetFormatRule(Rule):
                   "like salt['pillar.get']('item'), grains['item1'] or " \
                   " pillar.get('item')"
     severity = 'HIGH'
+    languages = [LANGUAGE_SLS, LANGUAGE_JINJA]
     tags = ['formatting', 'jinja']
     version_added = 'v0.0.10'
 
diff --git a/saltlint/rules/JinjaStatementHasSpacesRule.py b/saltlint/rules/JinjaStatementHasSpacesRule.py
index 6bcf290..59992e9 100644
--- a/saltlint/rules/JinjaStatementHasSpacesRule.py
+++ b/saltlint/rules/JinjaStatementHasSpacesRule.py
@@ -5,6 +5,7 @@
 
 import re
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_JINJA, LANGUAGE_SLS
 
 
 class JinjaStatementHasSpacesRule(Rule):
@@ -12,6 +13,7 @@ class JinjaStatementHasSpacesRule(Rule):
     shortdesc = 'Jinja statement should have spaces before and after: {% statement %}'
     description = 'Jinja statement should have spaces before and after: ``{% statement %}``'
     severity = 'LOW'
+    languages = [LANGUAGE_SLS, LANGUAGE_JINJA]
     tags = ['formatting', 'jinja']
     version_added = 'v0.0.2'
 
diff --git a/saltlint/rules/JinjaVariableHasSpacesRule.py b/saltlint/rules/JinjaVariableHasSpacesRule.py
index f864fce..53fd0eb 100644
--- a/saltlint/rules/JinjaVariableHasSpacesRule.py
+++ b/saltlint/rules/JinjaVariableHasSpacesRule.py
@@ -5,6 +5,7 @@
 
 import re
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_JINJA, LANGUAGE_SLS
 
 
 class JinjaVariableHasSpacesRule(Rule):
@@ -12,6 +13,7 @@ class JinjaVariableHasSpacesRule(Rule):
     shortdesc = 'Jinja variables should have spaces before and after: {{ var_name }}'
     description = 'Jinja variables should have spaces before and after: ``{{ var_name }}``'
     severity = 'LOW'
+    languages = [LANGUAGE_SLS, LANGUAGE_JINJA]
     tags = ['formatting', 'jinja']
     version_added = 'v0.0.1'
 
diff --git a/saltlint/rules/NoTabsRule.py b/saltlint/rules/NoTabsRule.py
index 57bfa90..2a06763 100644
--- a/saltlint/rules/NoTabsRule.py
+++ b/saltlint/rules/NoTabsRule.py
@@ -4,6 +4,7 @@
 # Modified work Copyright (c) 2020 Warpnet B.V.
 
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_SLS
 
 
 class NoTabsRule(Rule):
@@ -11,6 +12,7 @@ class NoTabsRule(Rule):
     shortdesc = 'Most files should not contain tabs'
     description = 'Tabs can cause unexpected display issues, use spaces'
     severity = 'LOW'
+    languages = [LANGUAGE_SLS]
     tags = ['formatting']
     version_added = 'v0.0.1'
 
diff --git a/saltlint/rules/SLSFileNameRule.py b/saltlint/rules/SLSFileNameRule.py
new file mode 100644
index 0000000..f251b50
--- /dev/null
+++ b/saltlint/rules/SLSFileNameRule.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# opyright (c) 2020 Warpnet B.V.
+
+import os
+from saltlint.linter.rule import Rule
+
+
+class SLSFileNameRule(Rule):
+    id = '214'
+    shortdesc = ('SLS file with a period in the name (besides the suffix period) can not be '
+                 'referenced')
+    description = ('SLS file with a period in the name (besides the suffix period) can not be '
+                   'referenced')
+    severity = 'HIGH'
+    tags = ['formatting']
+    version_added = 'develop'
+
+    def matchtext(self, file, text):
+        results = []
+        path = file['path']
+        basename = os.path.basename(path)
+        if len(basename.split('.')) > 2:
+            line_no = 1
+            lines = text.splitlines()
+            line = lines[0] if len(lines) > 0 else ''
+            results.append((line_no, line, self.shortdesc))
+        return results
diff --git a/saltlint/rules/YamlHasOctalValueRule.py b/saltlint/rules/YamlHasOctalValueRule.py
index d39477c..57b4f82 100644
--- a/saltlint/rules/YamlHasOctalValueRule.py
+++ b/saltlint/rules/YamlHasOctalValueRule.py
@@ -5,6 +5,7 @@
 
 import re
 from saltlint.linter.rule import Rule
+from saltlint.utils import LANGUAGE_SLS
 
 
 class YamlHasOctalValueRule(Rule):
@@ -12,6 +13,7 @@ class YamlHasOctalValueRule(Rule):
     shortdesc = 'Numbers that start with `0` should always be encapsulated in quotation marks'
     description = 'Numbers that start with `0` should always be encapsulated in quotation marks'
     severity = 'HIGH'
+    languages = [LANGUAGE_SLS]
     tags = ['formatting']
     version_added = 'v0.0.6'
 
diff --git a/saltlint/utils.py b/saltlint/utils.py
index dbcdf23..5d8974d 100644
--- a/saltlint/utils.py
+++ b/saltlint/utils.py
@@ -7,6 +7,10 @@ import imp
 import os
 
 
+LANGUAGE_SLS = "sls"
+LANGUAGE_JINJA = "jinja"
+
+
 def load_plugins(directory, config):
     result = []
     fh = None
@@ -40,3 +44,12 @@ def get_rule_skips_from_text(text):
 
     # Return a list of unique ids
     return list(set(rule_id_list))
+
+def get_file_type(file_name):
+    extension = os.path.splitext(file_name)[1].lower()
+
+    if extension == ".sls":
+        return LANGUAGE_SLS
+    if extension in [".jinja", ".jinja2"]:
+        return LANGUAGE_JINJA
+    return None


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index 6c72cf6..9e6000c 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -19,7 +19,7 @@ class RunFromText(object):
         return runner.run()
 
     def run_state(self, state_text):
-        with tempfile.NamedTemporaryFile() as fp:
+        with tempfile.NamedTemporaryFile(suffix=".sls") as fp:
             fp.write(state_text.encode())
             fp.seek(0)
             results = self._call_runner(fp.name)
diff --git a/tests/test-extension-failure.extra.sls b/tests/test-extension-failure.extra.sls
new file mode 100644
index 0000000..e69de29
diff --git a/tests/unit/TestSLSFileNameRule.py b/tests/unit/TestSLSFileNameRule.py
new file mode 100644
index 0000000..770070a
--- /dev/null
+++ b/tests/unit/TestSLSFileNameRule.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
+
+import unittest
+
+from saltlint.config import Configuration
+from saltlint.linter.runner import Runner
+from saltlint.linter.collection import RulesCollection
+from saltlint.rules.SLSFileNameRule import SLSFileNameRule
+
+
+class TestSLSFileNameRule(unittest.TestCase):
+    collection = RulesCollection()
+
+    def setUp(self):
+        self.collection.register(SLSFileNameRule())
+
+    def test_file_positive(self):
+        path = 'tests/test-extension-success.sls'
+        runner = Runner(self.collection, path, Configuration())
+        self.assertEqual([], runner.run())
+
+        path = 'tests/test-extension-failure'
+        runner = Runner(self.collection, path, Configuration())
+        self.assertEqual([], runner.run())
+
+    def test_file_negative(self):
+        path = 'tests/test-extension-failure.extra.sls'
+        runner = Runner(self.collection, path, Configuration())
+        errors = runner.run()
+        self.assertEqual(1, len(errors))