Skip to content

Custom rules

How to include custom rules

You can include your own custom rules with --custom-rules option. It accepts a list of paths to files, directories or name of the Python module. Example:

robocop check --custom-rules my/own/rule.py --custom-rules custom_rules.py
[tool.robocop.lint]
custom_rules = [
    "my/own/rule.py",
    "custom_rules.py"
]

How to write custom rules

Writing your own rules requires implementing the following:

  1. Rule class, which describes what code issue we are looking for and how it will be reported
  2. Checker class, which scans the code and reports code issues.

Custom rule class inherits from Robocop Rule class and must override the following attributes:

  • name (rule name)
  • rule_id
  • message
  • severity
  • __doc__ (class documentation that will be used as rule documentation)

It can optionally override the following attributes:

  • severity_threshold
  • version (supported Robot Framework version, for example >=6)
  • enabled (default True, can be used to define rules disabled by default)
  • deprecated (set to True to deprecate rule)
  • parameters

For reference, you may look at existing rules in the Robocop.

Example rule definition:

rule definition
from robocop.linter.rules import (
    Rule,
    RuleParam,
    RuleSeverity
)

class ArgumentsPerLineRule(Rule):
    """
    Rule description and documentation.

    Supports Markdown.
    """

    name = "rule-name"
    rule_id = "GROUP01"
    message = "Rule message"
    severity = RuleSeverity.INFO
    parameters = [
        RuleParam(
            name="parameter_name",
            default=1,
            converter=int,
            desc="Parameter which will be converted to integer",
        ),
    ]

This rule can be used by checker class. Checker class can inherit from one of the following:

  • VisitorChecker which visits Robot Framework code using ast
  • RawFileChecker which scans every line (without parsing code as Robot Framework code)

Each checker class should define which rules it uses as class attribute and rule class as a type. For example:

example.py
from robocop.linter.rules import (
    Rule,
    RuleParam,
    RuleSeverity,
    VisitorChecker
)


class ExampleTestCaseRule(Rule):
    """
    Check if there is 'Example' in the test case name.
    """

    name = "example-in-name"
    rule_id = "EX01"
    message = "There is 'Example' in test case name"
    severity = RuleSeverity.WARNING


class NoExamplesChecker(VisitorChecker):
    example_in_name: ExampleTestCaseRule

    def visit_TestCaseName(self, node):  # noqa: N802
        if 'Example' in node.name:
            self.report(self.example_in_name, node=node, col=node.name.find('Example'))

Issue position

When reporting an issue, you need to specify the position of the issue in the source code. report() method only requires node argument, which can be used to determine the position of the issue. But it is recommended to pass more detailed information (for example, lineno, end_lineno, col, end_col) to report() method.

Verify if the reported position is not exceeding physical locations. Some reporters may not be able to handle incorrect positions. For example, SonarQube platform requires strictly correct reports and may fail to parse the whole file.

Rule parameters

Rules can have configurable values. You need to specify them using the RuleParam class and pass it as an argument to Rule:

example.py
from robocop.linter.rules import (
    Rule,
    RuleParam,
    RuleSeverity,
    VisitorChecker
)


class ExampleTestCaseRule(Rule):
    """
    Check if there is a parametrised substring in the test case name.
    """

    name = "example-in-name"
    rule_id = "EX01"
    message = "There is '{variable}' in test case name"
    severity = RuleSeverity.WARNING
    parameters = [
        RuleParam(
            name="param_name",
            default="Example",
            converter=str,
            desc="Optional desc",
        ),
    ]


class NoExamplesChecker(VisitorChecker):
    example_in_name: ExampleTestCaseRule

    def visit_TestCaseName(self, node):  # noqa: N802
        if self.example_in_name.param_name in node.name:
            self.report(
                self.example_in_name,
                variable=self.example_in_name.param_name,
                node=node,
                col=node.name.find(self.example_in_name.param_name))

Configurable parameter can be referred by its name in command line options:

robocop check --custom-rules my/own/rule.py --configure example-in-name.param_name=AnotherExample
[tool.robocop.lint]
custom_rules = [
    "my/own/rule.py"
]
configure = [
    "example-in-name.param_name=AnotherExample"
]

The value of the configurable parameter can be retrieved by using attribute access:

self.name_of_the_rule.name_of_param

Parameter value is passed as string. Use converter argument to define a method that will be used to convert the value:

RuleParam(name="int_param", converter=int, default=10, desc="Optional desc")  # convert str to int
# my_own_method will be called with a custom_param value
RuleParam(name="custom_param", converter=my_own_method, default="custom", desc="Optional desc")

Templated rule messages

When defining rule messages, you can use Python string formatting to supply dynamic values to a rule message:

message = "There is '{variable}' in test case name"

Variables need to be passed to report() method by their name:

self.report(self.my_rule, variable="some string", number=10, node=node)

Robot Framework version support

You can enable (or disable) your rule for a particular Robot Framework version. Add version parameter to the Rule definition:

class ExampleRule(Rule):
"""
Rule description and documentation.

Supports rst.
"""

name = "external-rule"
rule_id = "EX03"
message = "This is external rule"
severity = RuleSeverity.INFO
version = ">=5.0"

In this case rule "external-rule" will be enabled only for Robot Framework versions equal to 5.0 or higher.

It is also possible to adjust the behaviour of your checker depending on the Robot Framework version:

some_checker.py
from robocop.linter.utils.misc import ROBOT_VERSION

(...)
if ROBOT_VERSION.major == 3:
    # do stuff for RF 3.x version
else:
    # execute this code for RF != 3.x

File-wide rules

If you want to report a rule violation for a whole file and do not show any specific line in the extended view, use file_wide_rule = True attribute in the rule class.

Change Rule class behaviour

It is possible to change the behaviour or attributes of the Rule class. You can define your own class, which inherits from Rule and adjust the code.

For example, if you want to change the rule documentation URL (which is part of some reports), you can do it in the following way:

from robocop.linter.rules import Rule, RuleSeverity

class CustomParentRule(Rule):
    @property
    def docs_url(self):
        return f"https://your.company.com/robocop/rules/{self.name}"


class ExternalRule(CustomParentRule):
    name = "external-rule"
    rule_id = "CUS01"
    message = "Your own rule."
    severity = RuleSeverity.INFO

You may override other attributes and methods as well.

Fixable rules

You can define a fix to the issue found by the rule. Use FixableRule class as a base for your rule:

from robocop.linter.fix import FixApplicability, FixAvailability, Fix, TextEdit
from robocop.linter.diagnostics import Diagnostic
from robocop.linter.rules import FixableRule, RuleSeverity


class ExampleRuleWithFix(FixableRule):
    """
    Check if there is a parametrised substring in the test case name.
    """

    name = "rule-name"
    rule_id = "EX01"
    message = "Error message"
    severity = RuleSeverity.WARNING
    fix_availability = FixAvailability.ALWAYS

    def fix(self, diag: Diagnostic, source_lines: list[str]) -> Fix | None:
        return Fix(
            edits=[TextEdit.replace_at_range(self.rule_id, self.name, diag.range, "")],
            message="Remove unnecessary part of string",
            applicability=FixApplicability.SAFE,
        )

Rule fixes are applied using text-based edits rather than a formatter-style approach that modifies a parsed AST model. When you create a rule that can apply an automatic fix, you need to:

  • inherit from FixableRule
  • specify fix_availability (which can be FixAvailability.ALWAYS or FixAvailability.SOMETIMES)
  • implement fix() method and return Fix instance or None when no fix can be generated
  • or instead of implementing fix() method create Fix instance and pass it when reporting the issue

A Fix contains a list of TextEdit entries that describe which lines in the original file should be replaced. The source file is processed as a list of lines, preserving newline characters (\n). If you replace one or more lines with multiple new lines, you must include the newline markers explicitly in the replacement string.

from robocop.linter.fix import FixApplicability, FixAvailability, Fix, TextEdit


def fix(self, diag: Diagnostic, source_lines: list[str]) -> Fix | None:
    return Fix(
        edits=[
            TextEdit(
                rule_id="MISS01",
                rule_name="missing-lines",
                start_line=2,
                start_col=1,
                end_line=2,
                end_col=1,
                replacement="    Replace line 2 with multiple\n    lines with indentation\n    [Teardown]    Keyword",
            )],
        message="Insert multiline string",
        applicability=FixApplicability.UNSAFE
    )

The fix() method gets the diagnostic object. You can also read the contents of the source file from the source_lines list.

If, for a particular case, it is not possible to create a valid fix, the fix() method should return None.

It is also possible to create Fix when reporting the issue. The main benefit is that fix() method only receives diagnostic range and raw source lines, and you may have access to more data on how to fix the issue in place where you found the issue.

The following custom rule checks if the file contains the PLACEHOLD string at the end of file. The issue is reported at the the end of the first section, so fix() method can't fix the issue. That's why we can create Fix and pass it to report() method instead:

from robot.parsing.model.statements import Error

from robocop.linter.fix import Fix, FixAvailability, FixApplicability, TextEdit
from robocop.linter.rules import FixableRule, RuleSeverity, VisitorChecker


class CustomWithFix(FixableRule):
    """
    Custom rule that does have a fix.

    The fix is available only when reporting the issue.
    """
    name = "fixable-rule"
    rule_id = "FIX01"
    message = "Custom rule message"
    severity = RuleSeverity.INFO
    added_in_version = "8.0.0"
    fix_availability = FixAvailability.ALWAYS


class CustomChecker(VisitorChecker):
    fixable_rule: CustomWithFix

    def visit_File(self, node):
        if isinstance(node.sections[0].body[-1], Error):  # placeholder is not recognized as valid statement
            return
        fix = Fix(
            edits=[
                TextEdit(rule_id=self.fixable_rule.rule_id,
                         rule_name=self.fixable_rule.name,
                         start_line=node.end_lineno,
                         end_line=node.end_lineno,
                         start_col=node.end_col_offset + 1,
                         end_col=node.end_col_offset + 1,
                         replacement="PLACEHOLDER"
                         )
            ],
            message="Replace last character of file with 'PLACEHOLDER'",
            applicability=FixApplicability.SAFE
        )
        self.report(self.fixable_rule, lineno=node.lineno, col=node.col_offset + 1, fix=fix)

Project checks

Project checkers are special kind of checker that can be only run using check-project command:

robocop check-project

They are only run once per whole project and accept configuration manager as input to the entrypoint method. It can be used to run any code, for example, analysis of the project dependencies and architecture.

Example project checker:

project_checker.py
from robocop.config.manager import ConfigManager
from robocop.linter.rules import Rule, ProjectChecker, RuleSeverity


class ProjectCheckerRule(Rule):
    rule_id = "PROJ01"
    name = "project-checker-rule"
    message = "This check will be called after visiting all files"
    severity = RuleSeverity.INFO


class TestTotalCountRule(Rule):
    rule_id = "PROJ02"
    name = "test-total-count"
    message = "There is total of {files} files in the project."
    severity = RuleSeverity.INFO


class MyProjectChecker(ProjectChecker):
    """Project checker."""

    project_checker: ProjectCheckerRule
    test_total_count: TestTotalCountRule

    def scan_project(self, config_manager: ConfigManager) -> None:
        files_count = 0
        for robot_file in config_manager.root.rglob("*.robot"):
            files_count += 1
            self.report(self.project_checker, source=robot_file)
        # files can be also parsed (with get_model) and checked here
        self.report(self.test_total_count, source="Project-name", files=files_count)

Each project checker must inherit from ProjectChecker class and implement scan_project() method.