"""Implementation of NameRule.""" from __future__ import annotations import re import sys from copy import deepcopy from typing import TYPE_CHECKING, Any from ansiblelint.constants import LINE_NUMBER_KEY from ansiblelint.rules import AnsibleLintRule, TransformMixin if TYPE_CHECKING: from ruamel.yaml.comments import CommentedMap, CommentedSeq from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable from ansiblelint.utils import Task class NameRule(AnsibleLintRule, TransformMixin): """Rule for checking task and play names.""" id = "name" description = ( "All tasks and plays should have a distinct name for readability " "and for ``--start-at-task`` to work" ) severity = "MEDIUM" tags = ["idiom"] version_added = "v6.9.1 (last update)" _re_templated_inside = re.compile(r".*\{\{.*\}\}.*\w.*$") _ids = { "name[play]": "All plays should be named.", "name[missing]": "All tasks should be named.", "name[prefix]": "Task name should start with a prefix.", "name[casing]": "All names should start with an uppercase letter.", "name[template]": "Jinja templates should only be at the end of 'name'", } def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: """Return matches found for a specific play (entry in playbook).""" results = [] if file.kind != "playbook": return [] if "name" not in data: return [ self.create_matcherror( message="All plays should be named.", lineno=data[LINE_NUMBER_KEY], tag="name[play]", filename=file, ), ] results.extend( self._check_name( data["name"], lintable=file, lineno=data[LINE_NUMBER_KEY], ), ) return results def matchtask( self, task: Task, file: Lintable | None = None, ) -> list[MatchError]: results = [] name = task.get("name") if not name: results.append( self.create_matcherror( message="All tasks should be named.", lineno=task[LINE_NUMBER_KEY], tag="name[missing]", filename=file, ), ) else: results.extend( self._prefix_check( name, lintable=file, lineno=task[LINE_NUMBER_KEY], ), ) return results def _prefix_check( self, name: str, lintable: Lintable | None, lineno: int, ) -> list[MatchError]: results: list[MatchError] = [] effective_name = name if lintable is None: return [] if not results: results.extend( self._check_name( effective_name, lintable=lintable, lineno=lineno, ), ) return results def _check_name( self, name: str, lintable: Lintable | None, lineno: int, ) -> list[MatchError]: # This rules applies only to languages that do have uppercase and # lowercase letter, so we ignore anything else. On Unicode isupper() # is not necessarily the opposite of islower() results = [] # stage one check prefix effective_name = name if self._collection and lintable: prefix = self._collection.options.task_name_prefix.format( stem=lintable.path.stem, ) if lintable.kind == "tasks" and lintable.path.stem != "main": if not name.startswith(prefix): # For the moment in order to raise errors this rule needs to be # enabled manually. Still, we do allow use of prefixes even without # having to enable the rule. if "name[prefix]" in self._collection.options.enable_list: results.append( self.create_matcherror( message=f"Task name should start with '{prefix}'.", lineno=lineno, tag="name[prefix]", filename=lintable, ), ) return results else: effective_name = name[len(prefix) :] if ( effective_name[0].isalpha() and effective_name[0].islower() and not effective_name[0].isupper() ): results.append( self.create_matcherror( message="All names should start with an uppercase letter.", lineno=lineno, tag="name[casing]", filename=lintable, ), ) if self._re_templated_inside.match(name): results.append( self.create_matcherror( message="Jinja templates should only be at the end of 'name'", lineno=lineno, tag="name[template]", filename=lintable, ), ) return results def transform( self, match: MatchError, lintable: Lintable, data: CommentedMap | CommentedSeq | str, ) -> None: if match.tag == "name[casing]": target_task = self.seek(match.yaml_path, data) # Not using capitalize(), since that rewrites the rest of the name to lower case target_task[ "name" ] = f"{target_task['name'][:1].upper()}{target_task['name'][1:]}" match.fixed = True if "pytest" in sys.modules: from ansiblelint.config import options from ansiblelint.file_utils import Lintable # noqa: F811 from ansiblelint.rules import RulesCollection from ansiblelint.runner import Runner def test_file_positive() -> None: """Positive test for name[missing].""" collection = RulesCollection() collection.register(NameRule()) success = "examples/playbooks/rule-name-missing-pass.yml" good_runner = Runner(success, rules=collection) assert [] == good_runner.run() def test_file_negative() -> None: """Negative test for name[missing].""" collection = RulesCollection() collection.register(NameRule()) failure = "examples/playbooks/rule-name-missing-fail.yml" bad_runner = Runner(failure, rules=collection) errs = bad_runner.run() assert len(errs) == 5 def test_name_prefix_negative() -> None: """Negative test for name[missing].""" custom_options = deepcopy(options) custom_options.enable_list = ["name[prefix]"] collection = RulesCollection(options=custom_options) collection.register(NameRule()) failure = Lintable( "examples/playbooks/tasks/rule-name-prefix-fail.yml", kind="tasks", ) bad_runner = Runner(failure, rules=collection) results = bad_runner.run() assert len(results) == 3 # , "\n".join(results) assert results[0].tag == "name[casing]" assert results[1].tag == "name[prefix]" assert results[2].tag == "name[prefix]" def test_rule_name_lowercase() -> None: """Negative test for a task that starts with lowercase.""" collection = RulesCollection() collection.register(NameRule()) failure = "examples/playbooks/rule-name-casing.yml" bad_runner = Runner(failure, rules=collection) errs = bad_runner.run() assert len(errs) == 1 assert errs[0].tag == "name[casing]" assert errs[0].rule.id == "name" def test_name_play() -> None: """Positive test for name[play].""" collection = RulesCollection() collection.register(NameRule()) success = "examples/playbooks/rule-name-play-fail.yml" errs = Runner(success, rules=collection).run() assert len(errs) == 1 assert errs[0].tag == "name[play]" assert errs[0].rule.id == "name" def test_name_template() -> None: """Negative test for name[templated].""" collection = RulesCollection() collection.register(NameRule()) failure = "examples/playbooks/rule-name-templated-fail.yml" bad_runner = Runner(failure, rules=collection) errs = bad_runner.run() assert len(errs) == 1 assert errs[0].tag == "name[template]" def test_when_no_lintable() -> None: """Test when lintable is None.""" name_rule = NameRule() # pylint: disable=protected-access result = name_rule._prefix_check("Foo", None, 1) # noqa: SLF001 assert len(result) == 0