diff --git a/src/robocop/formatter/formatters/ReplaceEmptyValues.py b/src/robocop/formatter/formatters/ReplaceEmptyValues.py index 716c340b2..bf88671df 100644 --- a/src/robocop/formatter/formatters/ReplaceEmptyValues.py +++ b/src/robocop/formatter/formatters/ReplaceEmptyValues.py @@ -2,12 +2,19 @@ from typing import TYPE_CHECKING -from robot.api.parsing import Token +from robot.api.parsing import Keyword, KeywordSection, TestCase, TestCaseSection, Token + +try: # RF7+ + from robot.api.parsing import Var +except ImportError: + Var = None from robocop.formatter.disablers import skip_if_disabled, skip_section_if_disabled from robocop.formatter.formatters import Formatter if TYPE_CHECKING: + from collections.abc import Sequence + from robot.parsing.model.blocks import VariableSection from robot.parsing.model.statements import Variable @@ -41,12 +48,99 @@ class ReplaceEmptyValues(Formatter): ... ${EMPTY} ... value3 ``` + + By default, this formatter only processes the Variables section. You can configure + which sections to process using the ``sections`` parameter: + - ``variables`` (default) - only Variables section + - ``keywords`` - only Keywords section + - ``testcases`` - only Test Cases section + - ``all`` - all sections + - Comma-separated list - e.g., ``variables,keywords`` + + Configuration example in pyproject.toml: + ```toml + [tool.robocop.format] + configure = [ + "ReplaceEmptyValues.sections=all", + # or + "ReplaceEmptyValues.sections=variables,keywords", + ] + ``` """ HANDLES_SKIP = frozenset({"skip_sections"}) + def _replace_empty_args( + self, tokens: Sequence[Token], empty_value: str, *, trim_eol: bool = False + ) -> tuple[Token, ...]: + """Replace empty argument tokens while preserving continuation alignment.""" + new_tokens = [] + continuation_sep = Token(Token.SEPARATOR, self.formatting_config.continuation_indent) + prev_token = None + for token in tokens: + token_value = token.value or "" + if token.type == Token.ARGUMENT and not token_value.strip(): + if not prev_token or prev_token.type != Token.SEPARATOR: + new_tokens.append(continuation_sep) + new_tokens.append(Token(Token.ARGUMENT, empty_value)) + else: + if trim_eol and token.type == Token.EOL and token.value: + token.value = token.value.lstrip(" ") + new_tokens.append(token) + prev_token = token + return tuple(new_tokens) + + def _insert_arg_after_token(self, tokens: Sequence[Token], anchor: Token, value: str) -> tuple[Token, ...]: + separator = Token(Token.SEPARATOR, self.formatting_config.separator) + new_tokens = [] + for token in tokens: + new_tokens.append(token) + if token == anchor: + new_tokens.append(separator) + new_tokens.append(Token(Token.ARGUMENT, value)) + return tuple(new_tokens) + + @staticmethod + def _get_empty_value(var_name: str) -> str | None: + if var_name.startswith("${"): + return "${EMPTY}" + if var_name.startswith("@{"): + return "@{EMPTY}" + if var_name.startswith("&{"): + return "&{EMPTY}" + return None + + def __init__(self, sections: str = "variables") -> None: + super().__init__() + if sections == "all": + self.enabled_sections = {"variables", "keywords", "testcases"} + else: + self.enabled_sections = {s.strip().lower() for s in sections.split(",")} + @skip_section_if_disabled def visit_VariableSection(self, node: VariableSection) -> VariableSection: # noqa: N802 + if "variables" not in self.enabled_sections: + return node + return self.generic_visit(node) + + @skip_section_if_disabled + def visit_TestCaseSection(self, node: TestCaseSection) -> TestCaseSection: # noqa: N802 + if "testcases" not in self.enabled_sections: + return node + return self.generic_visit(node) + + @skip_section_if_disabled + def visit_KeywordSection(self, node: KeywordSection) -> KeywordSection: # noqa: N802 + if "keywords" not in self.enabled_sections: + return node + return self.generic_visit(node) + + @skip_if_disabled + def visit_TestCase(self, node: TestCase) -> TestCase: # noqa: N802 + return self.generic_visit(node) + + @skip_if_disabled + def visit_Keyword(self, node: Keyword) -> Keyword: # noqa: N802 return self.generic_visit(node) @skip_if_disabled @@ -54,22 +148,32 @@ def visit_Variable(self, node: Variable) -> Variable: # noqa: N802 if node.errors or not node.name: return node args = node.get_tokens(Token.ARGUMENT) - sep = Token(Token.SEPARATOR, self.formatting_config.separator) - new_line_sep = Token(Token.SEPARATOR, self.formatting_config.continuation_indent) if args: - tokens = [] - prev_token = None - for token in node.tokens: - if token.type == Token.ARGUMENT and not token.value: - if not prev_token or prev_token.type != Token.SEPARATOR: - tokens.append(new_line_sep) - tokens.append(Token(Token.ARGUMENT, "${EMPTY}")) - else: - if token.type == Token.EOL: - token.value = token.value.lstrip(" ") - tokens.append(token) - prev_token = token + node.tokens = self._replace_empty_args(node.tokens, "${EMPTY}", trim_eol=True) + else: + node.tokens = self._insert_arg_after_token(node.tokens, node.tokens[0], node.name[0] + "{EMPTY}") + return node + + @skip_if_disabled + def visit_Var(self, node: Var) -> Var: # noqa: N802 + """Handle inline VAR statements to replace empty values with proper EMPTY variables.""" + if Var is None or node.errors: + return node + + variable_token = node.get_token(Token.VARIABLE) + if not variable_token: + return node + + args = node.get_tokens(Token.ARGUMENT) + if any((arg.value or "").strip() for arg in args): + return node + + empty_value = self._get_empty_value(variable_token.value) + if empty_value is None: + return node + + if args: + node.tokens = self._replace_empty_args(node.tokens, empty_value) else: - tokens = [node.tokens[0], sep, Token(Token.ARGUMENT, node.name[0] + "{EMPTY}"), *node.tokens[1:]] - node.tokens = tokens + node.tokens = self._insert_arg_after_token(node.tokens, variable_token, empty_value) return node diff --git a/tests/formatter/formatters/ReplaceEmptyValues/expected/all_sections.robot b/tests/formatter/formatters/ReplaceEmptyValues/expected/all_sections.robot new file mode 100644 index 000000000..d7dbfbeca --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/expected/all_sections.robot @@ -0,0 +1,18 @@ +*** Variables *** +${EMPTY_SCALAR} ${EMPTY} +@{EMPTY_LIST} @{EMPTY} +&{EMPTY_DICT} &{EMPTY} + + +*** Test Cases *** +Test With Empty Vars + VAR ${empty_in_test} ${EMPTY} + VAR @{empty_list_test} @{EMPTY} + Log ${empty_in_test} + + +*** Keywords *** +Keyword With Empty Vars + VAR ${empty_in_kw} ${EMPTY} + VAR &{empty_dict_kw} &{EMPTY} + Log ${empty_in_kw} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/expected/keywords.robot b/tests/formatter/formatters/ReplaceEmptyValues/expected/keywords.robot new file mode 100644 index 000000000..5c787e996 --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/expected/keywords.robot @@ -0,0 +1,21 @@ +*** Keywords *** +Keyword With Empty Vars + VAR ${empty_scalar} ${EMPTY} + VAR @{empty_list} @{EMPTY} + VAR &{empty_dict} &{EMPTY} + VAR ${empty_scalar_cont} + ... ${EMPTY} + VAR ${scalar} value + VAR @{list} item1 item2 + VAR &{dict} key=value + Log ${empty_scalar} + +Keyword With Traditional VAR + [Documentation] Test with traditional Set Variable + ${empty} Set Variable + ${filled} Set Variable value + RETURN ${empty} + +Keyword With Empty Assignment + ${var1} ${var2} ${var3} Get Multiple Values + Log Many ${var1} ${var2} ${var3} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/expected/mixed_sections.robot b/tests/formatter/formatters/ReplaceEmptyValues/expected/mixed_sections.robot new file mode 100644 index 000000000..8b2f1ec30 --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/expected/mixed_sections.robot @@ -0,0 +1,15 @@ +*** Variables *** +${VAR_EMPTY} ${EMPTY} +@{VAR_LIST} @{EMPTY} + + +*** Test Cases *** +Test Should Not Be Modified + VAR ${empty_in_test} + Log ${empty_in_test} + + +*** Keywords *** +Keyword With Empty Vars + VAR ${empty_in_kw} ${EMPTY} + Log ${empty_in_kw} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/expected/testcases.robot b/tests/formatter/formatters/ReplaceEmptyValues/expected/testcases.robot new file mode 100644 index 000000000..1ad68dd32 --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/expected/testcases.robot @@ -0,0 +1,21 @@ +*** Test Cases *** +Test With Empty Vars + VAR ${empty_scalar} ${EMPTY} + VAR @{empty_list} @{EMPTY} + VAR &{empty_dict} &{EMPTY} + VAR ${empty_scalar_cont} + ... ${EMPTY} + VAR ${scalar} value + Log ${empty_scalar} + +Test With Scoped VAR + VAR ${empty_test} ${EMPTY} scope=TEST + VAR ${empty_suite} ${EMPTY} scope=SUITE + VAR ${empty_global} ${EMPTY} scope=GLOBAL + VAR @{empty_list} @{EMPTY} scope=TEST + Log ${empty_test} + +Test Traditional Assignment + ${empty} Set Variable + ${filled} Set Variable value + Log ${empty} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/source/all_sections.robot b/tests/formatter/formatters/ReplaceEmptyValues/source/all_sections.robot new file mode 100644 index 000000000..2456fd380 --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/source/all_sections.robot @@ -0,0 +1,18 @@ +*** Variables *** +${EMPTY_SCALAR} ${EMPTY} +@{EMPTY_LIST} @{EMPTY} +&{EMPTY_DICT} &{EMPTY} + + +*** Test Cases *** +Test With Empty Vars + VAR ${empty_in_test} + VAR @{empty_list_test} + Log ${empty_in_test} + + +*** Keywords *** +Keyword With Empty Vars + VAR ${empty_in_kw} + VAR &{empty_dict_kw} + Log ${empty_in_kw} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/source/keywords.robot b/tests/formatter/formatters/ReplaceEmptyValues/source/keywords.robot new file mode 100644 index 000000000..e6a845765 --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/source/keywords.robot @@ -0,0 +1,21 @@ +*** Keywords *** +Keyword With Empty Vars + VAR ${empty_scalar} + VAR @{empty_list} + VAR &{empty_dict} + VAR ${empty_scalar_cont} + ... + VAR ${scalar} value + VAR @{list} item1 item2 + VAR &{dict} key=value + Log ${empty_scalar} + +Keyword With Traditional VAR + [Documentation] Test with traditional Set Variable + ${empty} Set Variable + ${filled} Set Variable value + RETURN ${empty} + +Keyword With Empty Assignment + ${var1} ${var2} ${var3} Get Multiple Values + Log Many ${var1} ${var2} ${var3} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/source/mixed_sections.robot b/tests/formatter/formatters/ReplaceEmptyValues/source/mixed_sections.robot new file mode 100644 index 000000000..017660f05 --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/source/mixed_sections.robot @@ -0,0 +1,15 @@ +*** Variables *** +${VAR_EMPTY} ${EMPTY} +@{VAR_LIST} @{EMPTY} + + +*** Test Cases *** +Test Should Not Be Modified + VAR ${empty_in_test} + Log ${empty_in_test} + + +*** Keywords *** +Keyword With Empty Vars + VAR ${empty_in_kw} + Log ${empty_in_kw} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/source/testcases.robot b/tests/formatter/formatters/ReplaceEmptyValues/source/testcases.robot new file mode 100644 index 000000000..730e1d477 --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/source/testcases.robot @@ -0,0 +1,21 @@ +*** Test Cases *** +Test With Empty Vars + VAR ${empty_scalar} + VAR @{empty_list} + VAR &{empty_dict} + VAR ${empty_scalar_cont} + ... + VAR ${scalar} value + Log ${empty_scalar} + +Test With Scoped VAR + VAR ${empty_test} scope=TEST + VAR ${empty_suite} scope=SUITE + VAR ${empty_global} scope=GLOBAL + VAR @{empty_list} scope=TEST + Log ${empty_test} + +Test Traditional Assignment + ${empty} Set Variable + ${filled} Set Variable value + Log ${empty} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/source/variables_only.robot b/tests/formatter/formatters/ReplaceEmptyValues/source/variables_only.robot new file mode 100644 index 000000000..4f3dec0eb --- /dev/null +++ b/tests/formatter/formatters/ReplaceEmptyValues/source/variables_only.robot @@ -0,0 +1,4 @@ +*** Variables *** +${EMPTY_VAR} ${EMPTY} +@{EMPTY_LIST} @{EMPTY} +&{EMPTY_DICT} &{EMPTY} diff --git a/tests/formatter/formatters/ReplaceEmptyValues/test_formatter.py b/tests/formatter/formatters/ReplaceEmptyValues/test_formatter.py index 0fa4c9aac..15aee033d 100644 --- a/tests/formatter/formatters/ReplaceEmptyValues/test_formatter.py +++ b/tests/formatter/formatters/ReplaceEmptyValues/test_formatter.py @@ -9,3 +9,57 @@ def test_formatter(self): def test_skip_section(self): self.compare(source="test.robot", skip_sections=["variables"], not_modified=True) + + def test_keywords_section(self): + """Test formatting empty values in Keywords section only.""" + configure = [f"{self.FORMATTER_NAME}.sections=keywords"] + self.compare( + source="keywords.robot", + expected="keywords.robot", + configure=configure, + ) + + def test_testcases_section(self): + """Test formatting empty values in Test Cases section only.""" + configure = [f"{self.FORMATTER_NAME}.sections=testcases"] + self.compare( + source="testcases.robot", + expected="testcases.robot", + configure=configure, + ) + + def test_all_sections(self): + """Test formatting empty values in all sections (variables, keywords, testcases).""" + configure = [f"{self.FORMATTER_NAME}.sections=all"] + self.compare( + source="all_sections.robot", + expected="all_sections.robot", + configure=configure, + ) + + def test_mixed_sections_list(self): + """Test formatting with a list of specific sections (variables and keywords).""" + configure = [f"{self.FORMATTER_NAME}.sections=variables,keywords"] + self.compare( + source="mixed_sections.robot", + expected="mixed_sections.robot", + configure=configure, + ) + + def test_keywords_section_only_does_not_modify_variables(self): + """Test that when configured for keywords only, variables section is not modified.""" + configure = [f"{self.FORMATTER_NAME}.sections=keywords"] + self.compare( + source="variables_only.robot", + not_modified=True, + configure=configure, + ) + + def test_testcases_section_only_does_not_modify_variables(self): + """Test that when configured for testcases only, variables section is not modified.""" + configure = [f"{self.FORMATTER_NAME}.sections=testcases"] + self.compare( + source="variables_only.robot", + not_modified=True, + configure=configure, + )