Repository: MarketSquare/robotframework-robocop
Pull request #1097 (2024-06-19 09:10:58)

Add unused-keyword rule
Partially implents issue #1017


Patch:
diff --git a/docs/releasenotes/unreleased/rules.1.rst b/docs/releasenotes/unreleased/rules.1.rst
index ae75c73..500323a 100644
--- a/docs/releasenotes/unreleased/rules.1.rst
+++ b/docs/releasenotes/unreleased/rules.1.rst
@@ -1,5 +1,19 @@
-empty-variable can be now disabled for VAR (#1056)
---------------------------------------------------
+New unused-keyword rule (#1097)
+-------------------------------
 
-I0912 ``empty-variable`` received new parameter ``variable_source`` which allows to enable the rule either only for
-variables from ```*** Variables ***``` section or only ``VAR`` statements. By default it works on both.
+New I10101 ``unused-keyword`` community rule. This optional rule finds not used keywords defined in suites (robot files
+with tests/tasks) or private keywords (keywords with ``robot:private`` tag).
+
+For example::
+
+    *** Test Cases ***
+    Test that only non used keywords are reported
+        Used Keyword
+
+    *** Keywords ***
+    Not Used Keyword  # this keyword will be reported as not used
+        [Arguments]    ${arg}
+        Should Be True    ${arg}>50
+
+
+This rule will be developed in the future releases to cover other sources like resource files.
diff --git a/docs/releasenotes/unreleased/rules.2.rst b/docs/releasenotes/unreleased/rules.2.rst
index 2ea31a3..ae75c73 100644
--- a/docs/releasenotes/unreleased/rules.2.rst
+++ b/docs/releasenotes/unreleased/rules.2.rst
@@ -1,9 +1,5 @@
-New first-argument-in-new-line rule (#1001)
--------------------------------------------
+empty-variable can be now disabled for VAR (#1056)
+--------------------------------------------------
 
-New W1018 ``first-argument-in-new-line`` rule that checks if first argument is placed in the same line as
-``[Argument]`` setting.
-
-This rule covers Robot Framework Style Guide recommendation:
-
-https://docs.robotframework.org/docs/style_guide#line-continuation-for-arguments-in-keyword-definition
+I0912 ``empty-variable`` received new parameter ``variable_source`` which allows to enable the rule either only for
+variables from ```*** Variables ***``` section or only ``VAR`` statements. By default it works on both.
diff --git a/docs/releasenotes/unreleased/rules.3.rst b/docs/releasenotes/unreleased/rules.3.rst
new file mode 100644
index 0000000..2ea31a3
--- /dev/null
+++ b/docs/releasenotes/unreleased/rules.3.rst
@@ -0,0 +1,9 @@
+New first-argument-in-new-line rule (#1001)
+-------------------------------------------
+
+New W1018 ``first-argument-in-new-line`` rule that checks if first argument is placed in the same line as
+``[Argument]`` setting.
+
+This rule covers Robot Framework Style Guide recommendation:
+
+https://docs.robotframework.org/docs/style_guide#line-continuation-for-arguments-in-keyword-definition
diff --git a/robocop/checkers/community_rules/usage.py b/robocop/checkers/community_rules/usage.py
new file mode 100644
index 0000000..40901d5
--- /dev/null
+++ b/robocop/checkers/community_rules/usage.py
@@ -0,0 +1,212 @@
+from dataclasses import dataclass, field
+from itertools import chain
+from typing import Dict, Iterable, List, Optional, Pattern, Set, Union
+
+from robot.api import Token
+from robot.errors import DataError
+from robot.parsing.model.blocks import Keyword
+from robot.parsing.model.statements import Tags
+from robot.running.arguments import EmbeddedArguments
+
+from robocop.checkers import ProjectChecker
+from robocop.rules import Message, Rule, RuleSeverity
+from robocop.utils.misc import ROBOT_VERSION, normalize_robot_name
+from robocop.utils.run_keywords import iterate_keyword_names
+
+RULE_CATEGORY_ID = "01"
+
+rules = {
+    "10101": Rule(
+        rule_id="10101",
+        name="unused-keyword",
+        msg="Keyword '{{ keyword_name }}' is not used",
+        severity=RuleSeverity.INFO,
+        added_in_version="5.1.0",
+        enabled=False,
+        docs="""
+        Reports not used keywords.
+        
+        Example::
+
+            *** Test Cases ***
+            Test that only non used keywords are reported
+                Used Keyword
+        
+            *** Keywords ***
+            Not Used Keyword  # this keyword will be reported as not used
+                [Arguments]    ${arg}
+                Should Be True    ${arg}>50
+        
+        Rule is under development - may report false negatives or positives. Currently it does only support 
+        keywords from suites and private keywords. If the keyword is called dynamically (for example through variable)
+        it will be not detected as used.
+        """,
+    )
+}
+
+if ROBOT_VERSION.major < 6:
+    KeywordEmbedded = EmbeddedArguments
+else:
+    KeywordEmbedded = EmbeddedArguments.from_name
+
+
+@dataclass
+class KeywordUsage:
+    found_def: bool = False
+    used: int = 0
+    names: Set[str] = field(default_factory=set)
+
+    def update(self, name: str):
+        self.used += 1
+        self.names.add(name)
+
+
+@dataclass
+class KeywordDefinition:
+    name: Union[str, Pattern]
+    keyword_node: Keyword
+    used: int = 0
+    used_names: Set[str] = field(default_factory=set)
+    is_private: bool = False
+
+    def update(self, used_as: KeywordUsage):
+        used_as.found_def = True
+        self.used += used_as.used
+        self.used_names.update(used_as.names)
+
+
+@dataclass
+class RobotFile:
+    path: str
+    is_suite: bool = False
+    normal_keywords: Dict[str, KeywordDefinition] = field(default_factory=dict)
+    embedded_keywords: Dict[str, KeywordDefinition] = field(default_factory=dict)
+    used_keywords: Dict[str, KeywordUsage] = field(default_factory=dict)
+
+    @property
+    def keywords(self) -> Iterable[KeywordDefinition]:
+        return chain(self.normal_keywords.values(), self.embedded_keywords.values())
+
+    @property
+    def any_private(self) -> bool:
+        return any(keyword.is_private for keyword in self.keywords)
+
+    @property
+    def private_keywords(self) -> List[KeywordDefinition]:
+        return [keyword for keyword in self.keywords if keyword.is_private]
+
+    @property
+    def not_used_keywords(self) -> List[KeywordDefinition]:
+        not_used = []
+        for keyword in self.keywords:
+            if keyword.used or not (self.is_suite or keyword.is_private):
+                continue
+            not_used.append(keyword)
+        return not_used
+
+    def search_usage(self):
+        # TODO search in other files (imports) for non suites
+        # TODO option to also report keyword only used in not used keywords ('Nested Not Used Keyword' from tests)
+        # TODO below could be done inside robotfile? unless the access to others is required
+        for normalized_name, keyword_usage in self.used_keywords.items():
+            if normalized_name in self.normal_keywords:
+                self.normal_keywords[normalized_name].update(keyword_usage)
+            else:
+                for name in keyword_usage.names:
+                    for keyword_name, keyword_def in self.embedded_keywords.items():
+                        if keyword_def.name.match(name):
+                            # not entirely correct since keyword usage could be two usages with the same normalized name
+                            self.embedded_keywords[keyword_name].update(keyword_usage)
+
+
+class UnusedKeywords(ProjectChecker):
+    reports = ("unused-keyword",)
+
+    # TODO ignore run keywords with variables?
+    # TODO handle BDD
+
+    def __init__(self):
+        self.files: Dict[str, RobotFile] = {}
+        self.current_file: Optional[RobotFile] = None
+        super().__init__()
+
+    def scan_project(self) -> List["Message"]:
+        self.issues = []
+        for robot_file in self.files.values():
+            if not (robot_file.is_suite or robot_file.any_private):
+                continue
+            robot_file.search_usage()
+            for keyword in robot_file.not_used_keywords:
+                name = keyword.keyword_node.name
+                self.report(
+                    "unused-keyword",
+                    source=robot_file.path,
+                    node=keyword.keyword_node,
+                    keyword_name=name,
+                    end_col=len(name) + 1,
+                )
+        return self.issues
+
+    def visit_File(self, node):  # noqa
+        self.current_file = RobotFile(node.source)  # TODO handle "-"
+        self.generic_visit(node)
+        self.files[self.current_file.path] = self.current_file
+
+    def visit_TestCaseSection(self, node):  # noqa
+        self.current_file.is_suite = True
+        self.generic_visit(node)
+
+    def mark_used_keywords(self, node, name_token_type):
+        for keyword in iterate_keyword_names(node, name_token_type):
+            self.mark_used_keyword(keyword.value, keyword)
+
+    def mark_used_keyword(self, name: str, keyword):
+        if not name:
+            return
+        normalized_name = normalize_robot_name(name)
+        if normalized_name not in self.current_file.used_keywords:
+            self.current_file.used_keywords[normalized_name] = KeywordUsage()
+        self.current_file.used_keywords[normalized_name].update(name)
+        # what about possible library names? searching removes, but for sake of collecting
+
+    def visit_Setup(self, node):  # noqa
+        self.mark_used_keywords(node, Token.NAME)
+
+    visit_TestTeardown = visit_SuiteTeardown = visit_Teardown = visit_TestSetup = visit_SuiteSetup = visit_Setup
+
+    def visit_Template(self, node):  # noqa
+        # allow / disallow param
+        if node.value:
+            name_token = node.get_token(Token.NAME)
+            self.mark_used_keyword(node.value, name_token)
+        self.generic_visit(node)
+
+    visit_TestTemplate = visit_Template
+
+    def visit_KeywordCall(self, node):  # noqa
+        self.mark_used_keywords(node, Token.KEYWORD)
+
+    def visit_Keyword(self, node):  # noqa
+        try:
+            embedded = KeywordEmbedded(node.name)
+            if embedded and embedded.args:
+                self.current_file.embedded_keywords[node.name] = KeywordDefinition(
+                    embedded.name, node, is_private=self.is_keyword_private(node)
+                )
+            else:
+                normalized_name = normalize_robot_name(node.name)
+                self.current_file.normal_keywords[normalized_name] = KeywordDefinition(
+                    node.name, node, is_private=self.is_keyword_private(node)
+                )
+        except DataError:
+            pass
+        self.generic_visit(node)
+
+    @staticmethod
+    def is_keyword_private(node):
+        for statement in node.body:
+            if isinstance(statement, Tags):
+                for tag in statement.get_tokens(Token.ARGUMENT):
+                    if tag.value == "robot:private":
+                        return True
+        return False


Test patch:
diff --git a/tests/atest/rules/community_rules/keywords/unused-keyword/__init__.py b/tests/atest/rules/community_rules/keywords/unused-keyword/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/atest/rules/community_rules/keywords/unused-keyword/expected_output.txt b/tests/atest/rules/community_rules/keywords/unused-keyword/expected_output.txt
new file mode 100644
index 0000000..9aa0c4e
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/unused-keyword/expected_output.txt
@@ -0,0 +1,4 @@
+suite.robot:19:1:19:17 [I] 10101 Keyword 'Not Used Keyword' is not used
+suite.robot:34:1:34:21 [I] 10101 Keyword 'Embedded ${not} Used' is not used
+tasks.robot:16:1:16:17 [I] 10101 Keyword 'Not Used Keyword' is not used
+resource.robot:10:1:10:25 [I] 10101 Keyword 'Private Not Used Keyword' is not used
\ No newline at end of file
diff --git a/tests/atest/rules/community_rules/keywords/unused-keyword/resource.robot b/tests/atest/rules/community_rules/keywords/unused-keyword/resource.robot
new file mode 100644
index 0000000..7751cbf
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/unused-keyword/resource.robot
@@ -0,0 +1,12 @@
+*** Keywords ***
+Not Used Keyword
+    Log    ${TEST_NAME}
+    Private Used Keyword
+
+Private Used Keyword
+    [Tags]    robot:private
+    No Operation
+
+Private Not Used Keyword
+    [Tags]    robot:private
+    No Operation
diff --git a/tests/atest/rules/community_rules/keywords/unused-keyword/suite.robot b/tests/atest/rules/community_rules/keywords/unused-keyword/suite.robot
new file mode 100644
index 0000000..c5b346d
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/unused-keyword/suite.robot
@@ -0,0 +1,62 @@
+*** Keywords ***
+Used Keyword
+    Used Keyword 2    ${arg}
+    FOR    ${var}  IN RANGE  10
+        Used Keyword 3    ${var}
+    END
+    Embedded word
+    Embedded word 1
+    Embedded Inside Keyword
+
+Used Keyword 2
+    [Arguments]    ${arg}
+    Log    ${arg}
+
+Used Keyword 3
+    [Arguments]    ${arg}
+    Used Keyword 2    ${arg}
+
+Not Used Keyword
+    Nested Not Used Keyword
+
+Nested Not Used Keyword  # it should be reported in the future since parent keyword is not used
+    Log    ${TEST_NAME}
+
+Embedded ${variable}
+    No Operation
+
+Embedded ${variable} ${numbers:\d+}
+    No Operation
+
+Embedded ${inside} Keyword
+    No Operation
+
+Embedded ${not} Used
+    No Operation
+
+Used In Setup
+    No Operation
+
+Used In Teardown
+    No Operation
+
+Used In Timeout
+    No Operation
+
+Used In Run Keyword
+    No Operation
+
+
+*** Test Cases ***
+Test cases are last for testing purposes
+    Used_Keyword
+
+Test with settings
+    [Setup]    Used In Setup
+    [Template]    Used In Timeout
+    [Teardown]    Used In Teardown
+
+Test with run keywords
+    Run Keywords    Used Keyword
+    ...    AND
+    ...    Used In Run Keyword
diff --git a/tests/atest/rules/community_rules/keywords/unused-keyword/tasks.robot b/tests/atest/rules/community_rules/keywords/unused-keyword/tasks.robot
new file mode 100644
index 0000000..c7645c8
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/unused-keyword/tasks.robot
@@ -0,0 +1,25 @@
+*** Keywords ***
+Used Keyword
+    Used Keyword 2    ${arg}
+    FOR    ${var}  IN RANGE  10
+        Used Keyword 3    ${var}
+    END
+
+Used Keyword 2
+    [Arguments]    ${arg}
+    Log    ${arg}
+
+Used Keyword 3
+    [Arguments]    ${arg}
+    Used Keyword 2    ${arg}
+
+Not Used Keyword
+    Nested Not Used Keyword
+
+Nested Not Used Keyword
+    Log    ${TEST_NAME}
+
+
+*** Tasks ***
+Test cases are last for testing purposes
+    Used_Keyword
diff --git a/tests/atest/rules/community_rules/keywords/unused-keyword/test_rule.py b/tests/atest/rules/community_rules/keywords/unused-keyword/test_rule.py
new file mode 100644
index 0000000..ce1ed4f
--- /dev/null
+++ b/tests/atest/rules/community_rules/keywords/unused-keyword/test_rule.py
@@ -0,0 +1,6 @@
+from tests.atest.utils import RuleAcceptance
+
+
+class TestRuleAcceptance(RuleAcceptance):
+    def test_rule(self):
+        self.check_rule(src_files=["."], expected_file="expected_output.txt", issue_format="end_col")