argusf 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argusf/__init__.py +3 -0
- argusf/analysis/__init__.py +14 -0
- argusf/analysis/engine.py +100 -0
- argusf/analysis/rule.py +45 -0
- argusf/analysis/rules/__init__.py +5 -0
- argusf/analysis/rules/documentation.py +30 -0
- argusf/analysis/rules/findings.py +67 -0
- argusf/analysis/rules/registry.py +22 -0
- argusf/analysis/rules/rulesets/__init__.py +4 -0
- argusf/analysis/rules/rulesets/rgus/__init__.py +22 -0
- argusf/analysis/rules/rulesets/rgus/rgus001_no_goto.py +54 -0
- argusf/analysis/rules/rulesets/rgus/rgus002_common_block.py +55 -0
- argusf/analysis/rules/rulesets/rgus/rgus003_equivalence.py +55 -0
- argusf/analysis/rules/rulesets/rgus/rgus004_implicit_typing.py +129 -0
- argusf/analysis/rules/rulesets/rgus/rgus005_arithmetic_if.py +62 -0
- argusf/analysis/rules/rulesets/rgus/rgus006_entry_statement.py +67 -0
- argusf/analysis/rules/rulesets/rgus/rgus007_pause_statement.py +93 -0
- argusf/analysis/rules/rulesets/rgus/rgus008_todo_comment.py +100 -0
- argusf/analysis/rules/rulesets/rgus/rgus009_unsorted_use_statements.py +258 -0
- argusf/analysis/rules/selection.py +83 -0
- argusf/analysis/suppression.py +92 -0
- argusf/analysis/syntax_errors.py +41 -0
- argusf/autofix/__init__.py +26 -0
- argusf/autofix/applier.py +130 -0
- argusf/autofix/context.py +82 -0
- argusf/autofix/diff.py +39 -0
- argusf/autofix/edits.py +31 -0
- argusf/autofix/engine.py +192 -0
- argusf/autofix/models.py +39 -0
- argusf/autofix/source.py +68 -0
- argusf/autofix/writer.py +53 -0
- argusf/cache/__init__.py +5 -0
- argusf/cache/backend.py +294 -0
- argusf/cli.py +428 -0
- argusf/config/__init__.py +1 -0
- argusf/config/config_resolver.py +212 -0
- argusf/config/errors.py +8 -0
- argusf/config/file_reader/__init__.py +3 -0
- argusf/config/file_reader/reader.py +58 -0
- argusf/config/models.py +110 -0
- argusf/config/validation.py +113 -0
- argusf/constants.py +66 -0
- argusf/diagnostics.py +45 -0
- argusf/discovery/__init__.py +3 -0
- argusf/discovery/backend.py +250 -0
- argusf/discovery/gitignore.py +125 -0
- argusf/discovery/repo.py +30 -0
- argusf/hashing/__init__.py +5 -0
- argusf/hashing/hash.py +48 -0
- argusf/ir/__init__.py +1 -0
- argusf/ir/models/__init__.py +49 -0
- argusf/ir/models/findings.py +74 -0
- argusf/ir/models/source.py +199 -0
- argusf/orchestrator.py +127 -0
- argusf/parser/__init__.py +1 -0
- argusf/parser/backend.py +24 -0
- argusf/parser/treesitter/__init__.py +1 -0
- argusf/parser/treesitter/backend.py +157 -0
- argusf/parser/treesitter/walker/__init__.py +5 -0
- argusf/parser/treesitter/walker/handlers.py +209 -0
- argusf/parser/treesitter/walker/walker.py +82 -0
- argusf/registry/__init__.py +3 -0
- argusf/registry/registry.py +69 -0
- argusf/reporting/__init__.py +22 -0
- argusf/reporting/formatting.py +162 -0
- argusf/reporting/registry.py +20 -0
- argusf/reporting/reporters/__init__.py +15 -0
- argusf/reporting/reporters/base.py +29 -0
- argusf/reporting/reporters/concise.py +35 -0
- argusf/reporting/reporters/diff.py +30 -0
- argusf/reporting/reporters/json.py +59 -0
- argusf/reporting/reporters/null.py +21 -0
- argusf/reporting/reporters/standard.py +78 -0
- argusf/reporting/reporters/statistics.py +99 -0
- argusf-0.1.0.dev0.dist-info/METADATA +166 -0
- argusf-0.1.0.dev0.dist-info/RECORD +79 -0
- argusf-0.1.0.dev0.dist-info/WHEEL +4 -0
- argusf-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- argusf-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from argusf.analysis.rule import Rule, RuleMetadata
|
|
2
|
+
from argusf.analysis.rules.findings import build_statement_finding
|
|
3
|
+
from argusf.analysis.rules.registry import register
|
|
4
|
+
from argusf.ir.models import EntryStatement, Finding, Severity, SourceFile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register
|
|
8
|
+
class NoEntryStatement(Rule):
|
|
9
|
+
"""## What it does
|
|
10
|
+
|
|
11
|
+
Checks for use of `ENTRY` statements.
|
|
12
|
+
|
|
13
|
+
## Why is this bad?
|
|
14
|
+
|
|
15
|
+
`ENTRY` adds an extra entry point partway through a subroutine or
|
|
16
|
+
function, so the same body serves several procedures with different
|
|
17
|
+
argument lists and, in functions, different result variables. Which
|
|
18
|
+
code executes and which arguments are defined depends on where control
|
|
19
|
+
entered, which makes the procedure very hard to reason about. `ENTRY`
|
|
20
|
+
has been obsolescent since Fortran 2008. Separate procedures sharing
|
|
21
|
+
code through a module express the same design explicitly.
|
|
22
|
+
|
|
23
|
+
## Example
|
|
24
|
+
|
|
25
|
+
```fortran
|
|
26
|
+
subroutine setup(n)
|
|
27
|
+
integer :: n, m
|
|
28
|
+
n = 1
|
|
29
|
+
return
|
|
30
|
+
entry teardown(m)
|
|
31
|
+
m = 0
|
|
32
|
+
end subroutine setup
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Use instead:
|
|
36
|
+
|
|
37
|
+
```fortran
|
|
38
|
+
module lifecycle
|
|
39
|
+
contains
|
|
40
|
+
subroutine setup(n)
|
|
41
|
+
integer :: n
|
|
42
|
+
n = 1
|
|
43
|
+
end subroutine setup
|
|
44
|
+
subroutine teardown(m)
|
|
45
|
+
integer :: m
|
|
46
|
+
m = 0
|
|
47
|
+
end subroutine teardown
|
|
48
|
+
end module lifecycle
|
|
49
|
+
```
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
metadata = RuleMetadata(
|
|
53
|
+
id="RGUS006",
|
|
54
|
+
name="entry-statement",
|
|
55
|
+
message="Use of ENTRY statement detected",
|
|
56
|
+
description="Flags ENTRY statements, which create multiple entry points into one procedure.",
|
|
57
|
+
default_severity=Severity.MEDIUM,
|
|
58
|
+
suggestion="Split each entry point into its own procedure, sharing code via a module",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def check(self, source_file: SourceFile) -> list[Finding]:
|
|
62
|
+
return [
|
|
63
|
+
build_statement_finding(self.metadata, source_file, statement)
|
|
64
|
+
for unit in source_file.walk_units()
|
|
65
|
+
for statement in unit.statements
|
|
66
|
+
if isinstance(statement, EntryStatement)
|
|
67
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from argusf.analysis.rule import Rule, RuleMetadata
|
|
2
|
+
from argusf.analysis.rules.findings import build_statement_finding
|
|
3
|
+
from argusf.analysis.rules.registry import register
|
|
4
|
+
from argusf.autofix.edits import deletion, replacement
|
|
5
|
+
from argusf.autofix.models import Applicability, Fix
|
|
6
|
+
from argusf.autofix.source import full_lines_span, span_fits
|
|
7
|
+
from argusf.ir.models import Finding, PauseStatement, Severity, SourceFile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@register
|
|
11
|
+
class NoPauseStatement(Rule):
|
|
12
|
+
"""## What it does
|
|
13
|
+
|
|
14
|
+
Checks for use of `PAUSE` statements.
|
|
15
|
+
|
|
16
|
+
## Why is this bad?
|
|
17
|
+
|
|
18
|
+
`PAUSE` suspends execution until the operator resumes it, a relic of
|
|
19
|
+
batch operation. How (and whether) execution can be resumed is
|
|
20
|
+
processor dependent, batch and HPC environments have no operator to
|
|
21
|
+
resume anything, and the statement was deleted from the standard in
|
|
22
|
+
Fortran 95 — compilers accept it only as a legacy extension. An
|
|
23
|
+
explicit `WRITE`/`READ` pair makes the interaction visible and
|
|
24
|
+
portable.
|
|
25
|
+
|
|
26
|
+
## Example
|
|
27
|
+
|
|
28
|
+
```fortran
|
|
29
|
+
pause 'check the intermediate output'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Use instead:
|
|
33
|
+
|
|
34
|
+
```fortran
|
|
35
|
+
write (*, *) 'check the intermediate output; press Enter'
|
|
36
|
+
read (*, *)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Fix safety
|
|
40
|
+
|
|
41
|
+
The fix deletes the `PAUSE` line (or substitutes `CONTINUE` when the
|
|
42
|
+
line also carries a statement label or further statements), so the
|
|
43
|
+
program no longer stops there. Removing a deliberate operator pause
|
|
44
|
+
changes runtime behaviour, so the fix is always marked unsafe.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
metadata = RuleMetadata(
|
|
48
|
+
id="RGUS007",
|
|
49
|
+
name="pause-statement",
|
|
50
|
+
message="Use of PAUSE statement detected",
|
|
51
|
+
description="Flags PAUSE statements, deleted from the standard in Fortran 95.",
|
|
52
|
+
default_severity=Severity.HIGH,
|
|
53
|
+
suggestion="Replace PAUSE with an explicit WRITE/READ prompt, or remove it",
|
|
54
|
+
fixable=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def check(self, source_file: SourceFile) -> list[Finding]:
|
|
58
|
+
return [
|
|
59
|
+
build_statement_finding(self.metadata, source_file, statement, fix=self._build_fix(source_file, statement))
|
|
60
|
+
for unit in source_file.walk_units()
|
|
61
|
+
for statement in unit.statements
|
|
62
|
+
if isinstance(statement, PauseStatement)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def _build_fix(self, source_file: SourceFile, statement: PauseStatement) -> Fix | None:
|
|
66
|
+
"""Build the PAUSE-removal fix, or None.
|
|
67
|
+
|
|
68
|
+
Unsafe either way: removing a PAUSE changes runtime behaviour
|
|
69
|
+
(the program no longer stops for the operator).
|
|
70
|
+
"""
|
|
71
|
+
source = source_file.source
|
|
72
|
+
span = statement.span
|
|
73
|
+
if not span_fits(source, span):
|
|
74
|
+
return None
|
|
75
|
+
lines = full_lines_span(source, span)
|
|
76
|
+
before = source[lines.start_byte : span.start_byte].strip()
|
|
77
|
+
after = source[span.end_byte : lines.end_byte].strip()
|
|
78
|
+
if before or (after and not after.startswith(b"!")):
|
|
79
|
+
# The line carries more than the PAUSE — a statement label
|
|
80
|
+
# (possibly a GOTO target) before it, or further statements
|
|
81
|
+
# after a semicolon. Substitute CONTINUE so the line's other
|
|
82
|
+
# meaning stays intact. A trailing !-comment does not force
|
|
83
|
+
# this path: it describes the PAUSE and goes with it.
|
|
84
|
+
return Fix(
|
|
85
|
+
applicability=Applicability.UNSAFE,
|
|
86
|
+
edits=(replacement(span, "continue"),),
|
|
87
|
+
message="Replace `pause` with `continue`",
|
|
88
|
+
)
|
|
89
|
+
return Fix(
|
|
90
|
+
applicability=Applicability.UNSAFE,
|
|
91
|
+
edits=(deletion(lines),),
|
|
92
|
+
message="Remove `pause` statement",
|
|
93
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from argusf.analysis.rule import ConfigurableRule, RuleMetadata
|
|
5
|
+
from argusf.analysis.rules.registry import register
|
|
6
|
+
from argusf.ir.models import Comment, Finding, Severity, SourceFile
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from argusf.config.models import LintConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register
|
|
15
|
+
class TodoComment(ConfigurableRule):
|
|
16
|
+
"""## What it does
|
|
17
|
+
|
|
18
|
+
Checks for comments that begin with a task tag (`TODO`, `FIXME` and
|
|
19
|
+
`XXX` by default).
|
|
20
|
+
|
|
21
|
+
## Why is this bad?
|
|
22
|
+
|
|
23
|
+
Task tags mark work the author deferred: a missing edge case, a
|
|
24
|
+
known inaccuracy, a planned clean-up. Left in the code they rot
|
|
25
|
+
silently — nothing ensures the task is ever resolved, revisited, or
|
|
26
|
+
even seen again. Surfacing them keeps deferred work visible so it
|
|
27
|
+
can be resolved, or tracked somewhere with more accountability than
|
|
28
|
+
a comment.
|
|
29
|
+
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
```fortran
|
|
33
|
+
! TODO: handle the singular matrix case
|
|
34
|
+
call invert(a)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Options
|
|
38
|
+
|
|
39
|
+
- `lint.task_tags`
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
metadata = RuleMetadata(
|
|
43
|
+
id="RGUS008",
|
|
44
|
+
name="todo-comment",
|
|
45
|
+
message="Comment contains a task tag",
|
|
46
|
+
description="Flags comments that begin with a configured task tag such as TODO or FIXME.",
|
|
47
|
+
default_severity=Severity.INFO,
|
|
48
|
+
suggestion="Resolve the task, or move it to an issue tracker and remove the comment",
|
|
49
|
+
preview=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
"""Construct the rule unconfigured.
|
|
54
|
+
|
|
55
|
+
The engine injects the resolved lint config via configure()
|
|
56
|
+
before any check() call.
|
|
57
|
+
"""
|
|
58
|
+
self._pattern: re.Pattern[str] | None = None
|
|
59
|
+
|
|
60
|
+
def configure(self, config: LintConfig) -> None:
|
|
61
|
+
self._pattern = self._tag_pattern(config.task_tags)
|
|
62
|
+
|
|
63
|
+
def _tag_pattern(self, tags: Sequence[str]) -> re.Pattern[str] | None:
|
|
64
|
+
"""Compile the task-tag match pattern, or None if disabled.
|
|
65
|
+
|
|
66
|
+
The tag must open the comment (any number of bangs, then
|
|
67
|
+
optional whitespace); prose that merely mentions a tag
|
|
68
|
+
mid-comment does not fire. The lookahead stops a tag matching
|
|
69
|
+
as the prefix of a longer word (TODO vs TODOS). Matching is
|
|
70
|
+
case-sensitive. An empty tag list disables the rule.
|
|
71
|
+
"""
|
|
72
|
+
if not tags:
|
|
73
|
+
return None
|
|
74
|
+
alternation = "|".join(re.escape(tag) for tag in tags)
|
|
75
|
+
return re.compile(rf"^!+\s*({alternation})(?!\w)")
|
|
76
|
+
|
|
77
|
+
def check(self, source_file: SourceFile) -> list[Finding]:
|
|
78
|
+
pattern = self._pattern
|
|
79
|
+
if pattern is None:
|
|
80
|
+
return []
|
|
81
|
+
return [
|
|
82
|
+
self._build_finding(source_file, comment, match)
|
|
83
|
+
for comment in source_file.walk_comments()
|
|
84
|
+
if (match := pattern.match(comment.text)) is not None
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
def _build_finding(self, source_file: SourceFile, comment: Comment, match: re.Match[str]) -> Finding:
|
|
88
|
+
tag = match.group(1)
|
|
89
|
+
span = comment.span
|
|
90
|
+
return Finding(
|
|
91
|
+
rule_id=self.metadata.id,
|
|
92
|
+
message=f"Comment contains task tag {tag}",
|
|
93
|
+
severity=self.metadata.default_severity,
|
|
94
|
+
file_path=source_file.file_path,
|
|
95
|
+
line=span.start_row,
|
|
96
|
+
column=span.start_col + match.start(1),
|
|
97
|
+
end_line=span.start_row,
|
|
98
|
+
end_column=span.start_col + match.end(1),
|
|
99
|
+
suggestion=self.metadata.suggestion,
|
|
100
|
+
)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from itertools import pairwise
|
|
2
|
+
|
|
3
|
+
from argusf.analysis.rule import Rule, RuleMetadata
|
|
4
|
+
from argusf.analysis.rules.registry import register
|
|
5
|
+
from argusf.autofix.edits import replacement
|
|
6
|
+
from argusf.autofix.models import Applicability, Fix
|
|
7
|
+
from argusf.autofix.source import full_lines_span, line_indent, slice_of, span_between, span_fits
|
|
8
|
+
from argusf.ir.models import Comment, Finding, Severity, SourceFile, Statement, UseStatement
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register
|
|
12
|
+
class UnsortedUseStatements(Rule):
|
|
13
|
+
"""## What it does
|
|
14
|
+
|
|
15
|
+
Checks for blocks of consecutive `USE` statements that are not sorted
|
|
16
|
+
alphabetically by module name.
|
|
17
|
+
|
|
18
|
+
## Why is this bad?
|
|
19
|
+
|
|
20
|
+
A unit's `USE` statements are its import list. Keeping each block
|
|
21
|
+
sorted makes a dependency findable at a glance, keeps diffs that add a
|
|
22
|
+
module small and conflict-free, and stops duplicate imports from
|
|
23
|
+
hiding. The order has no semantic meaning in Fortran, so sorting is
|
|
24
|
+
free.
|
|
25
|
+
|
|
26
|
+
Only runs of directly adjacent `USE` statements are compared; a blank
|
|
27
|
+
line or a comment line starts a new block, so deliberate grouping is
|
|
28
|
+
preserved (sort within your groups). Module names compare
|
|
29
|
+
case-insensitively, as Fortran itself treats them.
|
|
30
|
+
|
|
31
|
+
## Example
|
|
32
|
+
|
|
33
|
+
```fortran
|
|
34
|
+
use solver_mod
|
|
35
|
+
use kinds_mod
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Use instead:
|
|
39
|
+
|
|
40
|
+
```fortran
|
|
41
|
+
use kinds_mod
|
|
42
|
+
use solver_mod
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Fix safety
|
|
46
|
+
|
|
47
|
+
The fix reorders whole lines (continuations and trailing comments move
|
|
48
|
+
with their statement) and never crosses a blank line, comment line, or
|
|
49
|
+
non-USE statement. A line holding several `;`-separated USE statements
|
|
50
|
+
is split so each statement gets its own line — `;` is a pure statement
|
|
51
|
+
separator, so the split is as semantically inert as the reordering.
|
|
52
|
+
Because `USE` order is semantically inert, the fix is safe. A shared
|
|
53
|
+
line carrying anything besides its USE statements and `;` separators
|
|
54
|
+
(a trailing comment, a statement label) is reported without a fix.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
metadata = RuleMetadata(
|
|
58
|
+
id="RGUS009",
|
|
59
|
+
name="unsorted-use-statements",
|
|
60
|
+
message="Block of USE statements is not sorted by module name",
|
|
61
|
+
description="Flags consecutive USE statements that are not sorted alphabetically by module name.",
|
|
62
|
+
default_severity=Severity.LOW,
|
|
63
|
+
suggestion="Sort the USE statements alphabetically by module name",
|
|
64
|
+
preview=True, # Must survey how real codebases group their USE blocks first
|
|
65
|
+
fixable=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def check(self, source_file: SourceFile) -> list[Finding]:
|
|
69
|
+
return [
|
|
70
|
+
self._build_finding(source_file, run)
|
|
71
|
+
for unit in source_file.walk_units()
|
|
72
|
+
for run in self._use_runs(unit.statements)
|
|
73
|
+
if not self._is_sorted(run)
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def _use_runs(self, statements: list[Statement]) -> list[list[UseStatement]]:
|
|
77
|
+
"""Group statements into runs of adjacent USE statements.
|
|
78
|
+
|
|
79
|
+
A run is a maximal sequence of USE statements on directly
|
|
80
|
+
adjacent lines — the unit of sorting. Statements arrive in
|
|
81
|
+
document order, so one pass suffices: each statement either
|
|
82
|
+
rides along, extends the current run, or ends it (and a USE
|
|
83
|
+
after a gap immediately starts the next one).
|
|
84
|
+
"""
|
|
85
|
+
runs: list[list[UseStatement]] = []
|
|
86
|
+
current: list[UseStatement] = []
|
|
87
|
+
for statement in statements:
|
|
88
|
+
if self._is_trailing_comment(statement, current):
|
|
89
|
+
# `use m ! why`: the comment is a separate statement on
|
|
90
|
+
# the USE's own line. The whole line moves as one, so it
|
|
91
|
+
# neither breaks nor extends the run.
|
|
92
|
+
continue
|
|
93
|
+
if not self._extends_run(statement, current):
|
|
94
|
+
# Anything else ends the run: a non-USE statement, a
|
|
95
|
+
# comment on its own line, or a USE after a blank line
|
|
96
|
+
# (blank lines produce no statement, so they are visible
|
|
97
|
+
# only as a row gap).
|
|
98
|
+
if current:
|
|
99
|
+
runs.append(current)
|
|
100
|
+
current = []
|
|
101
|
+
if isinstance(statement, UseStatement):
|
|
102
|
+
current.append(statement)
|
|
103
|
+
if current:
|
|
104
|
+
runs.append(current)
|
|
105
|
+
return runs
|
|
106
|
+
|
|
107
|
+
def _is_trailing_comment(self, statement: Statement, current: list[UseStatement]) -> bool:
|
|
108
|
+
return isinstance(statement, Comment) and bool(current) and statement.span.start_row == current[-1].span.end_row
|
|
109
|
+
|
|
110
|
+
def _extends_run(self, statement: Statement, current: list[UseStatement]) -> bool:
|
|
111
|
+
"""Whether `statement` continues the current run.
|
|
112
|
+
|
|
113
|
+
True when it is a USE on the line directly below the run's last
|
|
114
|
+
line (or the run is empty and it starts one).
|
|
115
|
+
"""
|
|
116
|
+
if not isinstance(statement, UseStatement):
|
|
117
|
+
return False
|
|
118
|
+
return not current or statement.span.start_row <= current[-1].span.end_row + 1
|
|
119
|
+
|
|
120
|
+
def _is_sorted(self, run: list[UseStatement]) -> bool:
|
|
121
|
+
return all(self._sort_key(a) <= self._sort_key(b) for a, b in pairwise(run))
|
|
122
|
+
|
|
123
|
+
def _sort_key(self, statement: UseStatement) -> str:
|
|
124
|
+
"""The casefolded module name a block is sorted by.
|
|
125
|
+
|
|
126
|
+
Fortran names are case-insensitive, so comparison casefolds.
|
|
127
|
+
"""
|
|
128
|
+
return statement.module_name.casefold()
|
|
129
|
+
|
|
130
|
+
def _build_finding(self, source_file: SourceFile, run: list[UseStatement]) -> Finding:
|
|
131
|
+
first, last = run[0], run[-1]
|
|
132
|
+
return Finding(
|
|
133
|
+
rule_id=self.metadata.id,
|
|
134
|
+
message=self.metadata.message,
|
|
135
|
+
severity=self.metadata.default_severity,
|
|
136
|
+
file_path=source_file.file_path,
|
|
137
|
+
line=first.span.start_row,
|
|
138
|
+
column=first.span.start_col,
|
|
139
|
+
end_line=last.span.end_row,
|
|
140
|
+
end_column=last.span.end_col,
|
|
141
|
+
suggestion=self.metadata.suggestion,
|
|
142
|
+
fix=self._build_fix(source_file, run),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def _build_fix(self, source_file: SourceFile, run: list[UseStatement]) -> Fix | None:
|
|
146
|
+
"""Build the block-reordering fix for a run, or None.
|
|
147
|
+
|
|
148
|
+
One edit replacing the whole block with its statements
|
|
149
|
+
reordered, one per line. A statement alone on its line moves
|
|
150
|
+
with its whole line (trailing comments and continuation lines
|
|
151
|
+
travel with it); `;`-separated statements sharing a line are
|
|
152
|
+
split onto a line each.
|
|
153
|
+
"""
|
|
154
|
+
source = source_file.source
|
|
155
|
+
if not span_fits(source, run[-1].span):
|
|
156
|
+
return None
|
|
157
|
+
pieces = self._pieces(source, run)
|
|
158
|
+
if pieces is None:
|
|
159
|
+
return None
|
|
160
|
+
# Stable sort: duplicate imports of the same module keep their
|
|
161
|
+
# document order.
|
|
162
|
+
pieces.sort(key=lambda piece: piece[0])
|
|
163
|
+
content = "".join(self._terminated(text) for _, text in pieces)
|
|
164
|
+
block = full_lines_span(source, span_between(source, run[0].span.start_byte, run[-1].span.end_byte))
|
|
165
|
+
if not slice_of(source, block).endswith(b"\n"):
|
|
166
|
+
# The block sat at end-of-file without a final newline; a pure
|
|
167
|
+
# reorder must preserve that, so drop the one _terminated added.
|
|
168
|
+
content = content.removesuffix("\n")
|
|
169
|
+
return Fix(
|
|
170
|
+
applicability=Applicability.SAFE,
|
|
171
|
+
edits=(replacement(block, content),),
|
|
172
|
+
message="Sort `use` statements",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _pieces(self, source: bytes, run: list[UseStatement]) -> list[tuple[str, str]] | None:
|
|
176
|
+
"""The (sort key, text) pairs the block is rebuilt from.
|
|
177
|
+
|
|
178
|
+
One per statement, or None when the block cannot be rebuilt
|
|
179
|
+
faithfully.
|
|
180
|
+
"""
|
|
181
|
+
pieces: list[tuple[str, str]] = []
|
|
182
|
+
try:
|
|
183
|
+
for line_statements in self._statements_by_line(source, run):
|
|
184
|
+
if len(line_statements) == 1:
|
|
185
|
+
pieces.append(self._whole_line(source, line_statements[0]))
|
|
186
|
+
elif self._splits_cleanly(source, line_statements):
|
|
187
|
+
pieces.extend(self._split_line(source, line_statements))
|
|
188
|
+
else:
|
|
189
|
+
# The shared line carries something besides its USE
|
|
190
|
+
# statements and `;` separators (a trailing comment, a
|
|
191
|
+
# statement label) that has no unambiguous home once
|
|
192
|
+
# the line splits; report without a fix.
|
|
193
|
+
return None
|
|
194
|
+
except UnicodeDecodeError:
|
|
195
|
+
# Moving lines we cannot decode risks mangling them; report
|
|
196
|
+
# without a fix.
|
|
197
|
+
return None
|
|
198
|
+
return pieces
|
|
199
|
+
|
|
200
|
+
def _statements_by_line(self, source: bytes, run: list[UseStatement]) -> list[list[UseStatement]]:
|
|
201
|
+
"""Group the run's statements by physical line.
|
|
202
|
+
|
|
203
|
+
Statements whose whole-line extents overlap are `;`-separated
|
|
204
|
+
on a shared line and must be rewritten together.
|
|
205
|
+
"""
|
|
206
|
+
groups: list[list[UseStatement]] = []
|
|
207
|
+
previous_line_end = 0
|
|
208
|
+
for statement in run:
|
|
209
|
+
line = full_lines_span(source, statement.span)
|
|
210
|
+
if groups and line.start_byte < previous_line_end:
|
|
211
|
+
groups[-1].append(statement)
|
|
212
|
+
else:
|
|
213
|
+
groups.append([statement])
|
|
214
|
+
previous_line_end = line.end_byte
|
|
215
|
+
return groups
|
|
216
|
+
|
|
217
|
+
def _whole_line(self, source: bytes, statement: UseStatement) -> tuple[str, str]:
|
|
218
|
+
"""The (sort key, text) for a statement that owns its line(s).
|
|
219
|
+
|
|
220
|
+
It moves whole, so its trailing comment and continuation lines
|
|
221
|
+
travel with it.
|
|
222
|
+
"""
|
|
223
|
+
return self._sort_key(statement), slice_of(source, full_lines_span(source, statement.span)).decode()
|
|
224
|
+
|
|
225
|
+
def _splits_cleanly(self, source: bytes, line_statements: list[UseStatement]) -> bool:
|
|
226
|
+
"""Whether a shared line holds only USE statements and `;`.
|
|
227
|
+
|
|
228
|
+
Blanks out the statements' own bytes and checks that nothing
|
|
229
|
+
but `;` separators and whitespace remains.
|
|
230
|
+
"""
|
|
231
|
+
lines = full_lines_span(
|
|
232
|
+
source, span_between(source, line_statements[0].span.start_byte, line_statements[-1].span.end_byte)
|
|
233
|
+
)
|
|
234
|
+
residue = bytearray(slice_of(source, lines))
|
|
235
|
+
for statement in line_statements:
|
|
236
|
+
start = statement.span.start_byte - lines.start_byte
|
|
237
|
+
end = statement.span.end_byte - lines.start_byte
|
|
238
|
+
residue[start:end] = b" " * (end - start)
|
|
239
|
+
return not residue.translate(None, delete=b" \t;\r\n")
|
|
240
|
+
|
|
241
|
+
def _split_line(self, source: bytes, line_statements: list[UseStatement]) -> list[tuple[str, str]]:
|
|
242
|
+
"""Move each statement of a shared line onto its own line.
|
|
243
|
+
|
|
244
|
+
Each keeps the shared line's indentation.
|
|
245
|
+
"""
|
|
246
|
+
indent = line_indent(source, line_statements[0].span.start_byte)
|
|
247
|
+
return [
|
|
248
|
+
(self._sort_key(statement), f"{indent}{slice_of(source, statement.span).decode()}")
|
|
249
|
+
for statement in line_statements
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
def _terminated(self, line: str) -> str:
|
|
253
|
+
"""Ensure `line` ends with a newline.
|
|
254
|
+
|
|
255
|
+
The block's last line may sit at end-of-file without one; once
|
|
256
|
+
it moves up it needs a newline.
|
|
257
|
+
"""
|
|
258
|
+
return line if line.endswith("\n") else f"{line}\n"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Resolving select/ignore selectors to a set of rule ids."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from argusf.analysis.rules import RuleRegistry
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
ALL_SELECTOR = "ALL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnknownRuleSelectorError(ValueError):
|
|
14
|
+
"""Raised when a selector matches no known rule."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, selector: str) -> None:
|
|
17
|
+
"""Build the error for an unrecognised `selector`.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
selector: The selector that matched no rule.
|
|
21
|
+
"""
|
|
22
|
+
super().__init__(f"Unknown rule selector: {selector!r}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _matches(selector: str, rule_id: str) -> bool:
|
|
26
|
+
return selector == ALL_SELECTOR or rule_id.startswith(selector)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _specificity(selector: str) -> int:
|
|
30
|
+
return 0 if selector == ALL_SELECTOR else len(selector)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RuleSelectionResolver:
|
|
34
|
+
"""Resolves select/ignore selectors against known rule ids."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, known_rule_ids: Iterable[str] | None = None) -> None:
|
|
37
|
+
"""Bind the resolver to a set of known rule ids.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
known_rule_ids: Rule ids to resolve against; defaults to
|
|
41
|
+
the registered rules. Passing an explicit iterable
|
|
42
|
+
keeps unit tests independent of the registry.
|
|
43
|
+
"""
|
|
44
|
+
self._known_rule_ids = list(RuleRegistry.keys() if known_rule_ids is None else known_rule_ids)
|
|
45
|
+
|
|
46
|
+
def resolve(self, select: Iterable[str], ignore: Iterable[str]) -> set[str]:
|
|
47
|
+
"""Return the rule ids enabled by `select` minus `ignore`.
|
|
48
|
+
|
|
49
|
+
Per rule, the most specific matching selector on each side
|
|
50
|
+
wins; on a tie, ignore beats select. ALL is least specific.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
select: Enabling selectors (codes, prefixes, or ALL).
|
|
54
|
+
ignore: Disabling selectors, same forms.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The set of enabled rule ids.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
UnknownRuleSelectorError: On a selector matching no known
|
|
61
|
+
rule.
|
|
62
|
+
"""
|
|
63
|
+
select = list(select)
|
|
64
|
+
ignore = list(ignore)
|
|
65
|
+
self._validate(select)
|
|
66
|
+
self._validate(ignore)
|
|
67
|
+
return {rule_id for rule_id in self._known_rule_ids if self._is_selected(rule_id, select, ignore)}
|
|
68
|
+
|
|
69
|
+
def _validate(self, selectors: list[str]) -> None:
|
|
70
|
+
for selector in selectors:
|
|
71
|
+
if selector == ALL_SELECTOR:
|
|
72
|
+
continue
|
|
73
|
+
if not selector or not any(rule_id.startswith(selector) for rule_id in self._known_rule_ids):
|
|
74
|
+
raise UnknownRuleSelectorError(selector)
|
|
75
|
+
|
|
76
|
+
def _is_selected(self, rule_id: str, select: list[str], ignore: list[str]) -> bool:
|
|
77
|
+
# The most specific (longest) matching selector on each side
|
|
78
|
+
# decides; on a specificity tie, ignore takes precedence over
|
|
79
|
+
# select. ALL is the least specific selector. A rule matched by
|
|
80
|
+
# neither side is off.
|
|
81
|
+
best_select = max((_specificity(s) for s in select if _matches(s, rule_id)), default=-1)
|
|
82
|
+
best_ignore = max((_specificity(s) for s in ignore if _matches(s, rule_id)), default=-1)
|
|
83
|
+
return best_select > best_ignore
|