Skip to content

Commit 443d59b

Browse files
authored
feat: Fix rule where it is being report (#1630)
Add another method for registering the fix for the rule - when reporting the issue itself. Is is better to use fix() method in rule (because it's lazily loaded) but in some cases we may need access to more data not available in diagnostic itself. For those sitations we can now pass Fix() instance when reporting the issue.
1 parent 457d135 commit 443d59b

File tree

11 files changed

+137
-13
lines changed

11 files changed

+137
-13
lines changed

docs/linter/custom_rules.md

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ When you create a rule that can apply an automatic fix, you need to:
316316
- inherit from ``FixableRule``
317317
- specify fix_availability (which can be ``FixAvailability.ALWAYS`` or ``FixAvailability.SOMETIMES``)
318318
- implement ``fix()`` method and return ``Fix`` instance or ``None`` when no fix can be generated
319+
- or instead of implementing ``fix()`` method create ``Fix`` instance and pass it when reporting the issue
319320

320321
A ``Fix`` contains a list of ``TextEdit`` entries that describe which lines in the original file should be replaced.
321322
The source file is processed as a list of lines, preserving newline characters (``\n``). If you replace one or more lines
@@ -342,10 +343,62 @@ def fix(self, diag: Diagnostic, source_lines: list[str]) -> Fix | None:
342343
)
343344
```
344345

345-
The ``fix()`` method gets the diagnostic object. You can also read the contents of the source file from the
346+
The ``fix()`` method gets the diagnostic object. You can also read the contents of the source file from the
346347
``source_lines`` list.
347348

348-
If, for a particular case, it is not possible to create a valid fix, the ``fix()`` method should return None.
349+
If, for a particular case, it is not possible to create a valid fix, the ``fix()`` method should return None.
350+
351+
It is also possible to create ``Fix`` when reporting the issue. The main benefit is that ``fix()`` method only
352+
receives diagnostic range and raw source lines, and you may have access to more data on how to fix the issue in place
353+
where you found the issue.
354+
355+
The following custom rule checks if the file contains the ``PLACEHOLD`` string at the end of file.
356+
The issue is reported at the the end of the first section, so ``fix()`` method can't fix the issue. That's why
357+
we can create ``Fix`` and pass it to ``report()`` method instead:
358+
359+
```python
360+
from robot.parsing.model.statements import Error
361+
362+
from robocop.linter.fix import Fix, FixAvailability, FixApplicability, TextEdit
363+
from robocop.linter.rules import FixableRule, RuleSeverity, VisitorChecker
364+
365+
366+
class CustomWithFix(FixableRule):
367+
"""
368+
Custom rule that does have a fix.
369+
370+
The fix is available only when reporting the issue.
371+
"""
372+
name = "fixable-rule"
373+
rule_id = "FIX01"
374+
message = "Custom rule message"
375+
severity = RuleSeverity.INFO
376+
added_in_version = "8.0.0"
377+
fix_availability = FixAvailability.ALWAYS
378+
379+
380+
class CustomChecker(VisitorChecker):
381+
fixable_rule: CustomWithFix
382+
383+
def visit_File(self, node):
384+
if isinstance(node.sections[0].body[-1], Error): # placeholder is not recognized as valid statement
385+
return
386+
fix = Fix(
387+
edits=[
388+
TextEdit(rule_id=self.fixable_rule.rule_id,
389+
rule_name=self.fixable_rule.name,
390+
start_line=node.end_lineno,
391+
end_line=node.end_lineno,
392+
start_col=node.end_col_offset + 1,
393+
end_col=node.end_col_offset + 1,
394+
replacement="PLACEHOLDER"
395+
)
396+
],
397+
message="Replace last character of file with 'PLACEHOLDER'",
398+
applicability=FixApplicability.SAFE
399+
)
400+
self.report(self.fixable_rule, lineno=node.lineno, col=node.col_offset + 1, fix=fix)
401+
```
349402

350403
## Project checks
351404

@@ -361,7 +414,7 @@ be used to run any code, for example, analysis of the project dependencies and a
361414
Example project checker:
362415

363416
```python title="project_checker.py"
364-
from robocop.config import ConfigManager
417+
from robocop.config_manager import ConfigManager
365418
from robocop.linter.rules import Rule, ProjectChecker, RuleSeverity
366419

367420

src/robocop/linter/diagnostics.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def __init__(
7373
node=None,
7474
extended_disablers: tuple[int, int] | None = None,
7575
sev_threshold_value: int | None = None,
76+
fix: Fix | None = None,
7677
**kwargs,
7778
) -> None:
7879
self.rule = rule
@@ -82,15 +83,13 @@ def __init__(
8283
self.extended_disablers = extended_disablers if extended_disablers else []
8384
self.reported_arguments = kwargs
8485
self.severity = rule.get_severity_with_threshold(sev_threshold_value)
86+
self.fix = fix
8587
self._message = None
8688

8789
@property
8890
def message(self) -> str:
8991
return self.rule.message.format(**self.reported_arguments)
9092

91-
def fix(self, source_lines: list[str]) -> Fix | None:
92-
return self.rule.fix(self, source_lines)
93-
9493
@staticmethod
9594
def get_range(
9695
lineno: int, col: int, end_lineno: int | None, end_col: int | None, node: type[Statement | Block] | None

src/robocop/linter/fix.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,11 @@ def apply_fixes(self, source_file: SourceFile, fixes: list[Fix]) -> bool:
160160
applicable_fixes = [
161161
fix
162162
for fix in fixes
163-
if fix.applicability == FixApplicability.SAFE
164-
or (fix.applicability == FixApplicability.UNSAFE and allow_unsafe)
163+
if fix
164+
and (
165+
fix.applicability == FixApplicability.SAFE
166+
or (fix.applicability == FixApplicability.UNSAFE and allow_unsafe)
167+
)
165168
]
166169

167170
# Collect all edits from safe fixes

src/robocop/linter/rules/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
import inspect
2929
import pkgutil
3030
import sys
31-
from abc import ABC, abstractmethod
3231
from collections import defaultdict
3332
from enum import Enum
3433
from functools import total_ordering
@@ -40,7 +39,7 @@
4039

4140
from robocop import __version__, exceptions
4241
from robocop.linter.diagnostics import Diagnostic
43-
from robocop.linter.fix import FixAvailability
42+
from robocop.linter.fix import Fix, FixAvailability
4443
from robocop.version_handling import Version, VersionSpecifier
4544

4645
try:
@@ -482,7 +481,7 @@ def fix(self, diag: Diagnostic, source_lines: list[str]) -> Fix | None: # noqa:
482481
return None
483482

484483

485-
class FixableRule(Rule, ABC):
484+
class FixableRule(Rule):
486485
"""
487486
Abstract base class for rules that can automatically fix issues.
488487
@@ -493,7 +492,6 @@ class FixableRule(Rule, ABC):
493492
fix_availability: FixAvailability
494493
fixable: bool = True
495494

496-
@abstractmethod
497495
def fix(self, diag: Diagnostic, source_lines: list[str]) -> Fix | None:
498496
"""
499497
Generate TextEdit to fix the issue or return None if no fix available.
@@ -540,6 +538,7 @@ def report(
540538
extended_disablers: tuple[int, int] | None = None,
541539
sev_threshold_value: int | None = None,
542540
source: SourceFile | None = None,
541+
fix: Fix | None = None,
543542
**kwargs,
544543
) -> None:
545544
if not rule.enabled:
@@ -561,6 +560,7 @@ def report(
561560
source=source or self.source_file,
562561
extended_disablers=extended_disablers,
563562
sev_threshold_value=sev_threshold_value,
563+
fix=fix,
564564
**kwargs,
565565
)
566566
self.issues.append(diagnostic)

src/robocop/linter/runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def run_check(self, source_file: SourceFile, fix_applier: FixApplier = None) ->
184184
fix_applier.fix_stats.total_fixes += max(prev_fixable - len(fixable_diagnostics), 0)
185185
prev_fixable = len(fixable_diagnostics)
186186
# Collect fixes from diagnostics
187-
fixes = [diag.fix(source_file) for diag in fixable_diagnostics]
187+
fixes = [diag.fix or diag.rule.fix(diag, source_file) for diag in fixable_diagnostics]
188188
if not fix_applier.apply_fixes(source_file, fixes):
189189
break
190190
if source_file.config.linter.fix and not source_file.config.linter.diff:

src/robocop/source_file.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def source_lines(self) -> list[str]:
6969
list[str]: A list of source code lines.
7070
7171
"""
72+
# TODO: potential issue: robotcode send model with the updated code, but the file is not saved to disk yet
7273
if self._source_lines is None:
7374
try:
7475
self._source_lines = self._read_lines()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from robot.parsing.model.statements import Error
2+
3+
from robocop.linter.fix import Fix, FixApplicability, FixAvailability, TextEdit
4+
from robocop.linter.rules import FixableRule, RuleSeverity, VisitorChecker
5+
6+
7+
class CustomWithFix(FixableRule):
8+
"""
9+
Custom rule that does have a fix.
10+
11+
The fix is available only when reporting the issue.
12+
"""
13+
14+
name = "fixable-rule"
15+
rule_id = "FIX01"
16+
message = "Custom rule message"
17+
severity = RuleSeverity.INFO
18+
added_in_version = "8.0.0"
19+
fix_availability = FixAvailability.ALWAYS
20+
21+
22+
class CustomChecker(VisitorChecker):
23+
fixable_rule: CustomWithFix
24+
25+
def visit_File(self, node): # noqa: N802
26+
if isinstance(node.sections[0].body[-1], Error): # placeholder added in first run
27+
return
28+
fix = Fix(
29+
edits=[
30+
TextEdit(
31+
rule_id=self.fixable_rule.rule_id,
32+
rule_name=self.fixable_rule.name,
33+
start_line=node.end_lineno,
34+
end_line=node.end_lineno,
35+
start_col=node.end_col_offset + 1,
36+
end_col=node.end_col_offset + 1,
37+
replacement="PLACEHOLDER",
38+
)
39+
],
40+
message="Replace last character of file with 'PLACEHOLDER'",
41+
applicability=FixApplicability.SAFE,
42+
)
43+
self.report(self.fixable_rule, lineno=node.lineno, col=node.col_offset + 1, fix=fix)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fixed 1 issue:
2+
- actual_fixed${/}test.robot:
3+
1 x FIX01 (fixable-rule)
4+
5+
Found 1 issue (1 fixed, 0 remaining).
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*** Settings ***
2+
Documentation Last line should have PLACEHOLDER added.
3+
PLACEHOLDER
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*** Settings ***
2+
Documentation Last line should have PLACEHOLDER added.

0 commit comments

Comments
 (0)