Repository: MarketSquare/robotframework-robocop

Issue:
[Bug] bad-indent false positive if [Documentation] used in test case that uses Test Template
### What happened?

Seeing "Line is under-indented (bad-indent)" when a test case for which Test Template is used contains a [Documentation] line.

Example:

```
*** Settings ***

Test Template  Example

*** Test Cases ***

Example  argument=abc
  [Documentation]  Documentation text

*** Keywords ***

Example
  [Arguments]  ${argument}
  Log  ${argument}
```

### What command/code did you try to run?

robocop example.robot

### What is the full error message?

example.robot:8:1 [W] 1008 Line is under-indented (bad-indent)

### What did you expect to happen instead?

Did not expect this to trigger a warning.

### Operating System

Linux

### Robocop version

3.0.0

Discussion:
@mnojek: Hey @sekwahs and thanks for the report.
Unfortunately, I can't reproduce it, because you pasted the code in an unformatted way, so all the whitespaces were removed, and that is crucial to understand the issue.

Can you please try to enclose your code inside a triple \`\`\` character, so that the code saves the original formatting?

\`\`\`
Like this
\`\`\`

will result in:
```
Like this
```@sekwahs: Updated example.

On Wed, Mar 29, 2023, 9:08 AM Mateusz Nojek ***@***.***>
wrote:

> Hey @sekwahs <https://github.com/sekwahs> and thanks for the report.
> Unfortunately, I can't reproduce it, because you pasted the code in an
> unformatted way, so all the whitespaces were removed, and that is crucial
> to understand the issue.
>
> Can you please try to enclose your code inside a triple ``` character, so
> that the code saves the original formatting?
>
> ```
> Like this
> ```
>
> will result in:
>
> Like this
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/MarketSquare/robotframework-robocop/issues/808#issuecomment-1488692618>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/A23OLD5RXCOIGFUYEQWNHWLW6Q64JANCNFSM6AAAAAAWL745YE>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>
@mnojek: OK, after reviewing this case, I started thinking if this rule should be applied to templated suites at all.

The [official documentation](http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#test-templates) is very scarce about using test case settings along with the templated suite. Actually, it does not mention it at all, and I see people doing it (because it's allowed). There are no examples how to do it, so the users come up with different approaches.

The code you shared with us looks correct (apart from the other formatting, which violates many Robocop rules, but this is not our concern here) and is properly parsed by Robot Framework. 

Honestly, I don't know what should be the approach for formatting templated suites.

In our test data we have such example (that reports no Robocop issues):

```
*** Test Cases ***                  USERNAME            PASSWORD
Invalid User Name                   [Tags]              foo
                                    invalid             ${VALID PASSWORD}
Invalid Password                    [Documentation]     foo
                                    [Tags]              bar
                                    ${VALID USER}       invalid
Invalid User Name and Password      [Tags]              baz
                                    invalid             invalid
Empty User Name
                                    ${EMPTY}            ${VALID PASSWORD}
Empty Password                      ${VALID USER}       ${EMPTY}
                                    [Tags]              spam  eggs
Empty User Name and Password        ${EMPTY}            ${EMPTY}
```

and also yours above, which represents different approach.

BTW, your code would not report any issues if written like this:

```
Example  argument=abc
         [Documentation]  Documentation text
```

I need to ask for an advice here @bhirsz and maybe @pekkaklarck.
What is the intended way to indent templated test cases when the users want to also use test case settings together with them?@mnojek: Ok, I gave a lot of time to think about it and I must say, that this behavior is not a bug.

Robocop purpose is to serve as a tool that checks whether the code aligns with good practices or contains errors.
There are 3 levels of severity: errors, warnings and infos. Errors are something that can break the code and work in an unintended way. Warnings let you know that you probably haven't used common or standard way of writing the code. Infos are there just to give some hints or suggestions, but basically the code is fine.

In your code snippet, Robocop complained about the line being under-indented and... it's true.
Your code does not follow common practice. It works, so the report is not an error, but it's a warning saying that there are probably better ways to format your code.

As suggested in my previous comment - if you align it like this:
```
Example  argument=abc
         [Documentation]  Documentation text
```
Robocop will not complain.

If you don't want to receive this message, you can either disable it from the command line (`robocop -e 1008`) completely, or just make an exception in this line adding a comment with a disabler:
```
Example  argument=abc
  [Documentation]  Documentation text  # robocop: disable=1008
```

@sekwahs  Feel free to share what you feel about it, but this will not be fixed, since it's not a bug. It's just how Robocop works.@sekwahs: We differ on our interpretation of the word indentation. In my programming experience indentation is the amount of whitespace at the beginning of a line and is typically used to indicate logical syntactic levels within code; spacing between language elements is not considered to be indentation.

In this new 3.0.0 implementation of bad-indent the meaning of the word indentation is apparently alignment of words across vertical sections of code regardless of their actual relationship to each other. In the cited example, "argument=abc" has no inherent or implied relationship to the line that starts with "[Documentation]" and thus should require no relative alignment across lines.

Perhaps my confusion is based on the name of the rule, bad-indent, which implies it deals solely with indentation. I see now that the documentation for the rule says "Line is misaligned or indent is invalid," so the rule actually looks for more than indentation. In this case I would argue that alignment checking should be covered by a different rule than indentation checking since a) the rule is bad-indent, not bad-alignment, and b) indentation is inherently important in robot and consistency promotes readability, but alignment of unrelated language elements across lines is not inherently important, is not restricted by robot, and whether or how such alignment should be done is a very debatable topic and a choice which should be left to programmers. I could argue at length on why the alignment suggested above between the start of a line within a keyword body should have no connection with the alignment of arguments to the keyword, but that isn't the important issue here; the difference between indentation checking and alignment checking is what is relevant here, along with the ability to separate the two types of checking when enforcing rules.

If with release 3.0.0 there are new or old rules that allow checking only indentation without checking alignment based on language elements following the initial indentation, please point me to these so I can attempt to find a solution that provides the functionality desired by my development team.@bhirsz: I agree with the notion that "bad-indent" should mark problems with the indent, not the alignment. In this case we are actually iterating over statements in the node and we're a not checking if the line is the same.

In case of:

```
Example  argument=abc
  [Documentation]  Documentation text  # robocop: disable=1008
```

We're getting following statements:

- Example
- argument=abc
- [Documentation]  Documentation text  # robocop: disable=1008

And then we're checking for indentation for each statement. 

I think we should:

 - if the indent is the same as the previous statement, we should report new rule "bad-alignment"
 - otherwise, check the indent and optionally report "bad-indent"


We could also wait for Robot Framework style guide decision on indentation etc, they have this dicussion on their roadmap. Unfortunately "bad-indent" rule is one of most unstable rules (we keep changing its implementation) and it could be good to do it once and for good.@bhirsz: And the last statement is the reason we think it would be best to "park" this issue for now. We don't want to keep changing the rule and it's best to wait until there is official guideline in the Robot Framework (and it's in the progress). There is very high chance that we will follow approach proposed by you in the end but still, we don't want to rush it to avoid changing this rule yet again.

In the meantime you can exclude bad-indent and bad-block-indent and create your own custom rule. Below there is version that *should* work like you want:

```
class IndentChecker(VisitorChecker):
    """Checker for indentation violations."""

    reports = ("bad-indent-rule", "bad-block-indent-rule")  # just use any name different than official bad-indent one

    def __init__(self):
        self.indents = []
        self.parent_indent = 0
        # used to ignore indents from statements in the same line as parent, i.e. Inline IFs
        self.parent_line = 0
        self.last_statement_line = 0
        # used to denote end of keyword/test for comments indents
        self.end_of_node = False
        
        super().__init__()

    def visit_File(self, node):  # noqa
        self.indents = []
        self.parent_indent = 0
        self.parent_line = 0
        self.last_statement_line = 0
        self.end_of_node = False
        self.generic_visit(node)

    def visit_TestCase(self, node):  # noqa
        end_index = index_of_first_standalone_comment(node)
        with block_indent(self, node):
            for index, child in enumerate(node.body):
                if index == end_index:
                    self.end_of_node = True
                self.visit(child)

    visit_Keyword = visit_TestCase  # noqa

    def visit_TestCaseSection(self, node):  # noqa
        self.check_standalone_comments_indent(node)

    def visit_KeywordSection(self, node):  # noqa
        self.check_standalone_comments_indent(node)

    def check_standalone_comments_indent(self, node):
        # comments before first test case / keyword
        for child in node.body:
            if (
                getattr(child, "type", "") == Token.COMMENT
                and getattr(child, "tokens", None)
                and child.tokens[0].type == Token.SEPARATOR
            ):
                self.report(
                    "bad-indent",
                    bad_indent_msg="Line is over-indented",
                    node=child,
                    col=1,
                    end_col=token_col(child, Token.COMMENT),
                )
        self.generic_visit(node)

    def visit_For(self, node):
        self.visit_Statement(node.header)
        with block_indent(self, node):
            for child in node.body:
                self.visit(child)
        self.visit_Statement(node.end)

    visit_While = visit_ForLoop = visit_For

    def get_common_if_indent(self, node):
        indents = count_indents(node)
        head = node
        while head.orelse:
            head = head.orelse
            indents += count_indents(head)
        most_common = most_common_indent(indents)
        self.indents.append(most_common)

    def get_common_try_indent(self, node):
        indents = count_indents(node)
        head = node
        while head.next:
            head = head.next
            indents += count_indents(head)
        most_common = most_common_indent(indents)
        self.indents.append(most_common)

    def visit_statements_in_branch(self, node):
        with replace_parent_indent(self, node):
            for child in node.body:
                self.visit(child)

    def visit_If(self, node):
        self.visit_Statement(node.header)
        if node.type == "INLINE IF":
            return
        self.get_common_if_indent(node)
        self.visit_statements_in_branch(node)
        if node.orelse is not None:
            self.visit_IfBranch(node.orelse)
        self.indents.pop()
        self.visit_Statement(node.end)

    def visit_IfBranch(self, node):  # noqa
        indent = self.indents.pop()
        self.visit_Statement(node.header)
        self.indents.append(indent)
        self.visit_statements_in_branch(node)
        if node.orelse is not None:
            self.visit_IfBranch(node.orelse)

    def visit_Try(self, node):
        self.visit_Statement(node.header)
        self.get_common_try_indent(node)
        self.visit_statements_in_branch(node)
        if node.next is not None:
            self.visit_TryBranch(node.next)
        self.indents.pop()
        self.visit_Statement(node.end)

    def visit_TryBranch(self, node):  # noqa
        indent = self.indents.pop()
        self.visit_Statement(node.header)
        self.indents.append(indent)
        self.visit_statements_in_branch(node)
        if node.next is not None:
            self.visit_TryBranch(node.next)

    def get_required_indent(self, statement):
        if isinstance(statement, Comment) and self.end_of_node:
            return 0
        if self.param("bad-indent", "indent") != -1:
            return self.param("bad-indent", "indent") * len(self.indents)
        return self.indents[-1]

    def visit_Statement(self, statement):  # noqa
        if statement is None or isinstance(statement, EmptyLine) or not self.indents:
            self.last_statement_line = statement.lineno
            return
        # Ignore indent if current line is on the same line as parent, i.e. test case header or inline IFs
        if self.parent_line == statement.lineno:
            self.last_statement_line = statement.lineno
            return
         if self.last_statement_line == statement.lineno:    # ignore bad alignment
            return
        self.last_statement_line = statement.lineno
        indent = get_indent(statement)
        if self.parent_indent and (indent - 2 < self.parent_indent):
            self.report(
                "bad-block-indent",
                node=statement,
                col=1,
                end_col=indent + 1,
            )
            return
        req_indent = self.get_required_indent(statement)
        if indent == req_indent:
            return
        over_or_under = "over" if indent > req_indent else "under"
        self.report(
            "bad-indent",
            bad_indent_msg=f"Line is {over_or_under}-indented",
            node=statement,
            col=1,
            end_col=indent + 1,
        )
```

The code could be a lot shorter if you just want to check strict indentation, not "most common indent in block" which is also allowed.@bhirsz: There is official Style Guide which covers similar topic:

https://docs.robotframework.org/docs/style_guide#note-on-documentation-and-tags


```
*** Test Cases ***    ARG1    ARG2    [Documentation]           [Tags]
TestA                 aaa     AAA     Prints some message       tagA
TestB                 bbb     BBB     Prints another message    tagB
```

If someone combines Test Template and test settings, it's recommended to put them into columns similar as arguments.

As for example from this thread:

```
*** Test Cases ***
Example  argument=abc
    [Documentation]  Documentation text
Second test  argument=abc
    [Documentation]  Documentation text
```

It uses different syntax, but it's still fair syntax. I believe we should simply ignore this particular case and cover it in separate rule such as 'test-template-aligned' or something. It will be part of the upcoming 6.0 release.

Solution patch:
diff --git a/docs/releasenotes/6.0.0a3.rst b/docs/releasenotes/6.0.0a3.rst
index d22bee7..24dd0d9 100644
--- a/docs/releasenotes/6.0.0a3.rst
+++ b/docs/releasenotes/6.0.0a3.rst
@@ -955,3 +955,10 @@ Project-level rules were previous reported after other runs. Sorting them by lin
 files to mix up.
 
 Now project-level rules are grouped and sorted together with other rules which fixes this issue.
+
+bad-indent raised for templates suites (#808)
+---------------------------------------------
+
+Templated suites (with the use of ``Test Template`` setting) allows to use different styles of alignment. It doesn't
+work well with ``bad-indent`` rule and caused false positive warnings. It is now disabled for templated suites.
+Instead we will implement separate rule for templated suites alignment.
diff --git a/src/robocop/linter/rules/spacing.py b/src/robocop/linter/rules/spacing.py
index 836513e..7c56e42 100644
--- a/src/robocop/linter/rules/spacing.py
+++ b/src/robocop/linter/rules/spacing.py
@@ -999,7 +999,7 @@ def block_indent(checker: type[BaseChecker], node: type[Node]):
 def index_of_first_standalone_comment(node: type[Node]) -> int:
     """
     Get index of first standalone comment.
-    Comment can be standalone only if there are not other data statements in the node.
+    Comment can be standalone only if there are not the other data statements in the node.
     """
     last_standalone_comment = len(node.body)
     for index, child in enumerate(node.body[::-1], start=-(len(node.body) - 1)):
@@ -1043,6 +1043,8 @@ class UnevenIndentChecker(VisitorChecker):
     visit_Keyword = visit_TestCase  # noqa: N815
 
     def visit_TestCaseSection(self, node) -> None:  # noqa: N802
+        if self.templated_suite:
+            return
         self.check_standalone_comments_indent(node)
 
     def visit_KeywordSection(self, node) -> None:  # noqa: N802
diff --git a/src/robocop/linter/runner.py b/src/robocop/linter/runner.py
index 93ea052..e8669d9 100644
--- a/src/robocop/linter/runner.py
+++ b/src/robocop/linter/runner.py
@@ -56,8 +56,6 @@ class RobocopLinter:
             diagnostics = self.run_check(model, source, config)
             issues_no += len(diagnostics)
             self.diagnostics.extend(diagnostics)
-        # several configs, each can have or have not project checkers..
-        # so we would need to first gather info, and then "mamy
         self.diagnostics.extend(self.run_project_checks())
         if not files:
             print("No Robot files were found with the existing configuration.")


Test patch:
diff --git a/tests/linter/rules/spacing/bad_indent/bug758/expected_output.txt b/tests/linter/rules/spacing/bad_indent/bug758/expected_output.txt
deleted file mode 100644
index 8d8d558..0000000
--- a/tests/linter/rules/spacing/bad_indent/bug758/expected_output.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-bug758${/}templated_suite.robot:21:1 [W] SPC08 Line is under-indented
-bug758${/}templated_suite.robot:26:1 [W] SPC08 Line is under-indented
-bug758${/}templated_suite.robot:30:1 [W] SPC08 Line is under-indented
diff --git a/tests/linter/rules/spacing/bad_indent/test_rule.py b/tests/linter/rules/spacing/bad_indent/test_rule.py
index 22daa8e..c1b1a3b 100644
--- a/tests/linter/rules/spacing/bad_indent/test_rule.py
+++ b/tests/linter/rules/spacing/bad_indent/test_rule.py
@@ -4,6 +4,9 @@ from tests.linter.utils import RuleAcceptance
 
 
 class TestRuleAcceptance(RuleAcceptance):
+    def test_template_suite_with_settings(self):
+        self.check_rule(src_files=["template_suite_with_settings.robot"], expected_file=None)
+
     @pytest.mark.parametrize("config", [None, ["bad-indent.indent=3"]])
     def test_templated_suite(self, config):
         self.check_rule(configure=config, src_files=["templated_suite.robot"], expected_file=None)
@@ -38,7 +41,7 @@ class TestRuleAcceptance(RuleAcceptance):
         self.check_rule(
             configure=["bad-indent.indent=-1"],
             src_files=["bug758/templated_suite.robot"],
-            expected_file="bug758/expected_output.txt",
+            expected_file=None,
         )
 
     def test_groups(self):