summaryrefslogtreecommitdiffstats
path: root/src/ansiblelint/rules/yaml_rule.py
blob: 4da4d4101231f30bc54aa61294d8db219ad6f64d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
"""Implementation of yaml linting rule (yamllint integration)."""
from __future__ import annotations

import logging
import sys
from collections.abc import Iterable
from typing import TYPE_CHECKING

from yamllint.linter import run as run_yamllint

from ansiblelint.constants import LINE_NUMBER_KEY, SKIPPED_RULES_KEY
from ansiblelint.file_utils import Lintable
from ansiblelint.rules import AnsibleLintRule
from ansiblelint.yaml_utils import load_yamllint_config

if TYPE_CHECKING:
    from typing import Any

    from ansiblelint.errors import MatchError

_logger = logging.getLogger(__name__)


class YamllintRule(AnsibleLintRule):
    """Violations reported by yamllint."""

    id = "yaml"
    severity = "VERY_LOW"
    tags = ["formatting", "yaml"]
    version_added = "v5.0.0"
    config = load_yamllint_config()
    has_dynamic_tags = True
    link = "https://yamllint.readthedocs.io/en/stable/rules.html"
    # ensure this rule runs before most of other common rules
    _order = 1
    _ids = {
        "yaml[anchors]": "",
        "yaml[braces]": "",
        "yaml[brackets]": "",
        "yaml[colons]": "",
        "yaml[commas]": "",
        "yaml[comments-indentation]": "",
        "yaml[comments]": "",
        "yaml[document-end]": "",
        "yaml[document-start]": "",
        "yaml[empty-lines]": "",
        "yaml[empty-values]": "",
        "yaml[float-values]": "",
        "yaml[hyphens]": "",
        "yaml[indentation]": "",
        "yaml[key-duplicates]": "",
        "yaml[key-ordering]": "",
        "yaml[line-length]": "",
        "yaml[new-line-at-end-of-file]": "",
        "yaml[new-lines]": "",
        "yaml[octal-values]": "",
        "yaml[quoted-strings]": "",
        "yaml[trailing-spaces]": "",
        "yaml[truthy]": "",
    }

    def matchyaml(self, file: Lintable) -> list[MatchError]:
        """Return matches found for a specific YAML text."""
        matches: list[MatchError] = []
        if str(file.base_kind) != "text/yaml":
            return matches

        for problem in run_yamllint(
            file.content,
            YamllintRule.config,
            filepath=file.path,
        ):
            self.severity = "VERY_LOW"
            if problem.level == "error":
                self.severity = "MEDIUM"
            matches.append(
                self.create_matcherror(
                    # yamllint does return lower-case sentences
                    message=problem.desc.capitalize(),
                    lineno=problem.line,
                    details="",
                    filename=file,
                    tag=f"yaml[{problem.rule}]",
                ),
            )
        return matches


def _combine_skip_rules(data: Any) -> set[str]:
    """Return a consolidated list of skipped rules."""
    result = set(data.get(SKIPPED_RULES_KEY, []))
    tags = data.get("tags", [])
    if tags and (
        isinstance(tags, Iterable)
        and "skip_ansible_lint" in tags
        or tags == "skip_ansible_lint"
    ):
        result.add("skip_ansible_lint")
    return result


def _fetch_skips(data: Any, collector: dict[int, set[str]]) -> dict[int, set[str]]:
    """Retrieve a dictionary with line: skips by looking recursively in given JSON structure."""
    if hasattr(data, "get") and data.get(LINE_NUMBER_KEY):
        rules = _combine_skip_rules(data)
        if rules:
            collector[data.get(LINE_NUMBER_KEY)].update(rules)
    if isinstance(data, Iterable) and not isinstance(data, str):
        if isinstance(data, dict):
            for _entry, value in data.items():
                _fetch_skips(value, collector)
        else:  # must be some kind of list
            for entry in data:
                if (
                    entry
                    and hasattr(entry, "get")
                    and LINE_NUMBER_KEY in entry
                    and SKIPPED_RULES_KEY in entry
                    and entry[SKIPPED_RULES_KEY]
                ):
                    collector[entry[LINE_NUMBER_KEY]].update(entry[SKIPPED_RULES_KEY])
                _fetch_skips(entry, collector)
    return collector


# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
    import pytest

    # pylint: disable=ungrouped-imports
    from ansiblelint.config import options
    from ansiblelint.rules import RulesCollection
    from ansiblelint.runner import Runner

    @pytest.mark.parametrize(
        ("file", "expected_kind", "expected"),
        (
            pytest.param(
                "examples/yamllint/invalid.yml",
                "yaml",
                [
                    'Missing document start "---"',
                    'Duplication of key "foo" in mapping',
                    "Trailing spaces",
                ],
                id="invalid",
            ),
            pytest.param("examples/yamllint/valid.yml", "yaml", [], id="valid"),
            pytest.param(
                "examples/yamllint/line-length.yml",
                "yaml",
                ["Line too long (166 > 160 characters)"],
                id="line-length",
            ),
            pytest.param(
                "examples/yamllint/multi-document.yaml",
                "yaml",
                [],
                id="multi-document",
            ),
            pytest.param(
                "examples/yamllint/skipped-rule.yml",
                "yaml",
                [],
                id="skipped-rule",
            ),
            pytest.param(
                "examples/playbooks/rule-yaml-fail.yml",
                "playbook",
                [
                    "Truthy value should be one of [false, true]",
                    "Truthy value should be one of [false, true]",
                    "Truthy value should be one of [false, true]",
                ],
                id="rule-yaml-fail",
            ),
            pytest.param(
                "examples/playbooks/rule-yaml-pass.yml",
                "playbook",
                [],
                id="rule-yaml-pass",
            ),
        ),
    )
    @pytest.mark.filterwarnings("ignore::ansible_compat.runtime.AnsibleWarning")
    def test_yamllint(file: str, expected_kind: str, expected: list[str]) -> None:
        """Validate parsing of ansible output."""
        lintable = Lintable(file)
        assert lintable.kind == expected_kind

        rules = RulesCollection(options=options)
        rules.register(YamllintRule())
        results = Runner(lintable, rules=rules).run()

        assert len(results) == len(expected), results
        for idx, result in enumerate(results):
            assert result.filename.endswith(file)
            assert expected[idx] in result.message
            assert isinstance(result.tag, str)
            assert result.tag.startswith("yaml[")

    def test_yamllint_has_help(default_rules_collection: RulesCollection) -> None:
        """Asserts that we loaded markdown documentation in help property."""
        for rule in default_rules_collection:
            if rule.id == "yaml":
                assert rule.help is not None
                assert len(rule.help) > 100
                break
        else:  # pragma: no cover
            pytest.fail("No yaml rule found")