Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 121 additions & 17 deletions src/robocop/formatter/formatters/ReplaceEmptyValues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -41,35 +48,132 @@ 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
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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Var is None probably will never be true (because visit_Var is only handled by version which supports Var) but it may be required for mypy checker.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about:

VAR    ${name}
...

There is arg, but it's empty.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good one. I didn't think of multilines. It is now implemented!


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
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
*** Variables ***
${EMPTY_VAR} ${EMPTY}
@{EMPTY_LIST} @{EMPTY}
&{EMPTY_DICT} &{EMPTY}
54 changes: 54 additions & 0 deletions tests/formatter/formatters/ReplaceEmptyValues/test_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Loading