subedit 0.1.0__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.
- subedit/__init__.py +3 -0
- subedit/application/__init__.py +16 -0
- subedit/application/corpus_meter.py +87 -0
- subedit/application/fixer.py +68 -0
- subedit/application/linter.py +55 -0
- subedit/application/report_aggregator.py +23 -0
- subedit/domain/__init__.py +63 -0
- subedit/domain/models/__init__.py +36 -0
- subedit/domain/models/comment.py +168 -0
- subedit/domain/models/diagnostic.py +123 -0
- subedit/domain/models/document.py +23 -0
- subedit/domain/models/position.py +108 -0
- subedit/domain/models/prose.py +215 -0
- subedit/domain/models/rule_set.py +30 -0
- subedit/domain/models/text.py +23 -0
- subedit/domain/ports/__init__.py +14 -0
- subedit/domain/ports/external_linter.py +14 -0
- subedit/domain/ports/language_conventions.py +25 -0
- subedit/domain/ports/rule.py +28 -0
- subedit/domain/ports/source_parser.py +12 -0
- subedit/domain/rules/__init__.py +15 -0
- subedit/domain/rules/layout.py +57 -0
- subedit/domain/rules/rewrite.py +83 -0
- subedit/domain/rules/structure.py +141 -0
- subedit/infrastructure/__init__.py +47 -0
- subedit/infrastructure/config_sources/__init__.py +11 -0
- subedit/infrastructure/config_sources/config_schema.py +43 -0
- subedit/infrastructure/config_sources/toml_config_source.py +102 -0
- subedit/infrastructure/linters/__init__.py +5 -0
- subedit/infrastructure/linters/autocorrect_linter.py +71 -0
- subedit/infrastructure/linters/vale_linter.py +87 -0
- subedit/infrastructure/parsers/__init__.py +12 -0
- subedit/infrastructure/parsers/languages/__init__.py +11 -0
- subedit/infrastructure/parsers/languages/language_profile.py +26 -0
- subedit/infrastructure/parsers/languages/python_profile.py +71 -0
- subedit/infrastructure/parsers/tree_sitter_parser.py +219 -0
- subedit/presentation/__init__.py +0 -0
- subedit/presentation/cli.py +193 -0
- subedit/presentation/harnesses/__init__.py +40 -0
- subedit/presentation/harnesses/agent_harness.py +43 -0
- subedit/presentation/harnesses/claude_code_harness.py +37 -0
- subedit/presentation/harnesses/codex_harness.py +43 -0
- subedit/presentation/hook.py +108 -0
- subedit/presentation/report.py +79 -0
- subedit/presentation/rule_reference.py +21 -0
- subedit-0.1.0.dist-info/METADATA +193 -0
- subedit-0.1.0.dist-info/RECORD +50 -0
- subedit-0.1.0.dist-info/WHEEL +4 -0
- subedit-0.1.0.dist-info/entry_points.txt +3 -0
- subedit-0.1.0.dist-info/licenses/LICENSE +21 -0
subedit/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .corpus_meter import CorpusMeter, CorpusStatistics, Distribution
|
|
2
|
+
from .fixer import Fixer, FixResult
|
|
3
|
+
from .linter import Linter, RuleEngine
|
|
4
|
+
from .report_aggregator import ReportAggregator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'CorpusMeter',
|
|
9
|
+
'CorpusStatistics',
|
|
10
|
+
'Distribution',
|
|
11
|
+
'FixResult',
|
|
12
|
+
'Fixer',
|
|
13
|
+
'Linter',
|
|
14
|
+
'ReportAggregator',
|
|
15
|
+
'RuleEngine',
|
|
16
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import statistics
|
|
3
|
+
|
|
4
|
+
from ..domain.models.comment import CommentForm, CommentGroup, CommentPlacement, Visibility
|
|
5
|
+
from ..domain.models.document import Document
|
|
6
|
+
from ..domain.ports import SourceParser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
10
|
+
class Distribution:
|
|
11
|
+
label: str
|
|
12
|
+
measurements: tuple[int, ...]
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def count(self) -> int:
|
|
16
|
+
return len(self.measurements)
|
|
17
|
+
|
|
18
|
+
def percentile(self, fraction: float) -> int:
|
|
19
|
+
if not self.measurements:
|
|
20
|
+
return 0
|
|
21
|
+
sorted_values = sorted(self.measurements)
|
|
22
|
+
position = min(len(sorted_values) - 1, max(0, round(fraction * (len(sorted_values) - 1))))
|
|
23
|
+
return sorted_values[position]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def maximum(self) -> int:
|
|
27
|
+
return max(self.measurements, default=0)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def median(self) -> int:
|
|
31
|
+
return round(statistics.median(self.measurements)) if self.measurements else 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
35
|
+
class CorpusStatistics:
|
|
36
|
+
document_count: int
|
|
37
|
+
group_count: int
|
|
38
|
+
line_counts: tuple[Distribution, ...]
|
|
39
|
+
physical_widths: Distribution
|
|
40
|
+
sentence_lengths: Distribution
|
|
41
|
+
unbreakable_counts_by_width: tuple[tuple[int, int], ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CorpusMeter:
|
|
45
|
+
def __init__(self, parser: SourceParser, candidate_widths: tuple[int, ...] = (72, 80, 88, 100, 120)) -> None:
|
|
46
|
+
self._parser = parser
|
|
47
|
+
self._candidate_widths = candidate_widths
|
|
48
|
+
|
|
49
|
+
def run(self, documents: tuple[Document, ...]) -> CorpusStatistics:
|
|
50
|
+
grouped_line_counts: dict[str, list[int]] = {}
|
|
51
|
+
physical_widths: list[int] = []
|
|
52
|
+
sentence_lengths: list[int] = []
|
|
53
|
+
unbreakable_counts = dict.fromkeys(self._candidate_widths, 0)
|
|
54
|
+
group_count = 0
|
|
55
|
+
|
|
56
|
+
for document in documents:
|
|
57
|
+
for group in self._parser.parse(document):
|
|
58
|
+
group_count += 1
|
|
59
|
+
grouped_line_counts.setdefault(self._label_for(group), []).append(group.line_count)
|
|
60
|
+
physical_widths.extend(line.physical_width for line in group.lines)
|
|
61
|
+
for sentence in group.prose().sentences():
|
|
62
|
+
sentence_lengths.append(len(sentence.text))
|
|
63
|
+
for width in self._candidate_widths:
|
|
64
|
+
if self._is_unbreakable(sentence.text, width):
|
|
65
|
+
unbreakable_counts[width] += 1
|
|
66
|
+
|
|
67
|
+
return CorpusStatistics(
|
|
68
|
+
document_count=len(documents),
|
|
69
|
+
group_count=group_count,
|
|
70
|
+
line_counts=tuple(
|
|
71
|
+
Distribution(label, tuple(measurements)) for label, measurements in sorted(grouped_line_counts.items())
|
|
72
|
+
),
|
|
73
|
+
physical_widths=Distribution('physical-width', tuple(physical_widths)),
|
|
74
|
+
sentence_lengths=Distribution('sentence-length', tuple(sentence_lengths)),
|
|
75
|
+
unbreakable_counts_by_width=tuple(sorted(unbreakable_counts.items())),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _label_for(self, group: CommentGroup) -> str:
|
|
79
|
+
if group.form is CommentForm.DOC:
|
|
80
|
+
visibility = Visibility.PUBLIC if group.attaches_to_public_symbol else Visibility.INTERNAL
|
|
81
|
+
return f'doc/{visibility.value}'
|
|
82
|
+
if group.placement is CommentPlacement.FILE_HEADER:
|
|
83
|
+
return 'line/file-header'
|
|
84
|
+
return f'line/{group.placement.value}'
|
|
85
|
+
|
|
86
|
+
def _is_unbreakable(self, sentence: str, width: int) -> bool:
|
|
87
|
+
return len(sentence) > width and ',' not in sentence
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
|
|
3
|
+
from ..domain.models.diagnostic import Diagnostic, FixKind, Report
|
|
4
|
+
from ..domain.models.document import Document
|
|
5
|
+
from ..domain.models.rule_set import RuleSet
|
|
6
|
+
from ..domain.models.text import TextEdit, apply_edits
|
|
7
|
+
from .linter import Linter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MAX_ROUNDS = 10
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
14
|
+
class FixResult:
|
|
15
|
+
document: Document
|
|
16
|
+
report: Report
|
|
17
|
+
round_count: int
|
|
18
|
+
applied_edit_count: int
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def has_changes(self) -> bool:
|
|
22
|
+
return self.applied_edit_count > 0
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def unresolved_findings(self) -> tuple[Diagnostic, ...]:
|
|
26
|
+
return self.report.needing_author
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Fixer:
|
|
30
|
+
def __init__(self, linter: Linter, max_rounds: int = MAX_ROUNDS) -> None:
|
|
31
|
+
self._linter = linter
|
|
32
|
+
self._max_rounds = max_rounds
|
|
33
|
+
|
|
34
|
+
def run(self, document: Document, rule_set: RuleSet) -> FixResult:
|
|
35
|
+
working_document = document
|
|
36
|
+
applied_edit_count = 0
|
|
37
|
+
round_count = 0
|
|
38
|
+
report = self._linter.run(working_document, rule_set)
|
|
39
|
+
|
|
40
|
+
while round_count < self._max_rounds:
|
|
41
|
+
edits = self._next_edits(report)
|
|
42
|
+
if not edits:
|
|
43
|
+
break
|
|
44
|
+
working_document = working_document.with_text(apply_edits(working_document.text, edits))
|
|
45
|
+
applied_edit_count += len(edits)
|
|
46
|
+
round_count += 1
|
|
47
|
+
report = self._linter.run(working_document, rule_set)
|
|
48
|
+
|
|
49
|
+
return FixResult(
|
|
50
|
+
document=working_document, report=report, round_count=round_count, applied_edit_count=applied_edit_count
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _next_edits(self, report: Report) -> tuple[TextEdit, ...]:
|
|
54
|
+
for mechanical_kind in (FixKind.TARGETED, FixKind.REFLOW):
|
|
55
|
+
edits = self._without_overlaps(report.by_fix_kind(mechanical_kind))
|
|
56
|
+
if edits:
|
|
57
|
+
return edits
|
|
58
|
+
return ()
|
|
59
|
+
|
|
60
|
+
def _without_overlaps(self, diagnostics: tuple[Diagnostic, ...]) -> tuple[TextEdit, ...]:
|
|
61
|
+
candidate_edits = [edit for diagnostic in diagnostics if diagnostic.fix for edit in diagnostic.fix.edits]
|
|
62
|
+
candidate_edits.sort(key=lambda edit: (edit.range.start, edit.range.end))
|
|
63
|
+
accepted_edits: list[TextEdit] = []
|
|
64
|
+
for edit in candidate_edits:
|
|
65
|
+
if accepted_edits and accepted_edits[-1].range.overlaps(edit.range):
|
|
66
|
+
continue
|
|
67
|
+
accepted_edits.append(edit)
|
|
68
|
+
return tuple(accepted_edits)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
|
|
3
|
+
from ..domain.models.comment import CommentGroup
|
|
4
|
+
from ..domain.models.diagnostic import Diagnostic, Report
|
|
5
|
+
from ..domain.models.document import Document
|
|
6
|
+
from ..domain.models.rule_set import RuleSet
|
|
7
|
+
from ..domain.ports import SourceParser
|
|
8
|
+
from ..domain.ports.rule import Rule, RuleContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RuleEngine:
|
|
12
|
+
def __init__(self, rules: tuple[Rule, ...]) -> None:
|
|
13
|
+
duplicate_ids = self._find_duplicate_ids(rules)
|
|
14
|
+
if duplicate_ids:
|
|
15
|
+
raise ValueError(f'Duplicate rule ids registered: {sorted(duplicate_ids)}')
|
|
16
|
+
self._rules = rules
|
|
17
|
+
|
|
18
|
+
def run(self, document: Document, groups: tuple[CommentGroup, ...], rule_set: RuleSet) -> tuple[Diagnostic, ...]:
|
|
19
|
+
context = RuleContext(document=document, rule_set=rule_set)
|
|
20
|
+
enabled_rules = [rule for rule in self._rules if rule_set.is_enabled(rule.metadata.rule_id)]
|
|
21
|
+
diagnostics: list[Diagnostic] = []
|
|
22
|
+
for group in groups:
|
|
23
|
+
for rule in enabled_rules:
|
|
24
|
+
for diagnostic in rule.check(group, context):
|
|
25
|
+
diagnostics.append(self._with_severity(diagnostic, rule, rule_set))
|
|
26
|
+
return tuple(diagnostics)
|
|
27
|
+
|
|
28
|
+
def _with_severity(self, diagnostic: Diagnostic, rule: Rule, rule_set: RuleSet) -> Diagnostic:
|
|
29
|
+
severity = rule_set.severity_for(rule.metadata)
|
|
30
|
+
if diagnostic.severity is severity:
|
|
31
|
+
return diagnostic
|
|
32
|
+
return dataclasses.replace(diagnostic, severity=severity)
|
|
33
|
+
|
|
34
|
+
def _find_duplicate_ids(self, rules: tuple[Rule, ...]) -> set[str]:
|
|
35
|
+
seen_ids: set[str] = set()
|
|
36
|
+
duplicate_ids: set[str] = set()
|
|
37
|
+
for rule in rules:
|
|
38
|
+
if rule.metadata.rule_id in seen_ids:
|
|
39
|
+
duplicate_ids.add(rule.metadata.rule_id)
|
|
40
|
+
seen_ids.add(rule.metadata.rule_id)
|
|
41
|
+
return duplicate_ids
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Linter:
|
|
45
|
+
def __init__(self, parser: SourceParser, engine: RuleEngine) -> None:
|
|
46
|
+
self._parser = parser
|
|
47
|
+
self._engine = engine
|
|
48
|
+
|
|
49
|
+
def run(self, document: Document, rule_set: RuleSet) -> Report:
|
|
50
|
+
if not self._parser.supports(document.language_id):
|
|
51
|
+
return Report()
|
|
52
|
+
groups = self._parser.parse(document)
|
|
53
|
+
diagnostics = self._engine.run(document, groups, rule_set)
|
|
54
|
+
sorted_diagnostics = sorted(diagnostics, key=lambda item: (item.range.start, item.rule_id))
|
|
55
|
+
return Report(tuple(sorted_diagnostics))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ..domain.models.diagnostic import Report
|
|
2
|
+
from ..domain.models.document import Document
|
|
3
|
+
from ..domain.models.rule_set import RuleSet
|
|
4
|
+
from ..domain.ports.external_linter import ExternalLinter
|
|
5
|
+
from .linter import Linter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReportAggregator:
|
|
9
|
+
def __init__(self, linter: Linter, linters: tuple[ExternalLinter, ...] = ()) -> None:
|
|
10
|
+
self._linter = linter
|
|
11
|
+
self._linters = linters
|
|
12
|
+
|
|
13
|
+
def run(self, document: Document, rule_set: RuleSet) -> Report:
|
|
14
|
+
report = self._linter.run(document, rule_set)
|
|
15
|
+
missing_sources: list[str] = []
|
|
16
|
+
for linter in self._linters:
|
|
17
|
+
if not linter.is_available():
|
|
18
|
+
missing_sources.append(linter.name)
|
|
19
|
+
continue
|
|
20
|
+
report = report.merged_with(Report(linter.run(document)))
|
|
21
|
+
if missing_sources:
|
|
22
|
+
report = report.merged_with(Report(unavailable_sources=tuple(missing_sources)))
|
|
23
|
+
return report
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from .models import (
|
|
2
|
+
SEVERITY_BY_NAME,
|
|
3
|
+
CommentForm,
|
|
4
|
+
CommentGroup,
|
|
5
|
+
CommentLine,
|
|
6
|
+
CommentPlacement,
|
|
7
|
+
Diagnostic,
|
|
8
|
+
Document,
|
|
9
|
+
Encoding,
|
|
10
|
+
Fix,
|
|
11
|
+
FixKind,
|
|
12
|
+
Position,
|
|
13
|
+
PositionMap,
|
|
14
|
+
Prose,
|
|
15
|
+
Report,
|
|
16
|
+
RuleMetadata,
|
|
17
|
+
RuleSet,
|
|
18
|
+
RuleSettings,
|
|
19
|
+
Sentence,
|
|
20
|
+
Severity,
|
|
21
|
+
SourceRange,
|
|
22
|
+
SymbolKind,
|
|
23
|
+
SymbolRef,
|
|
24
|
+
TextEdit,
|
|
25
|
+
Visibility,
|
|
26
|
+
apply_edits,
|
|
27
|
+
)
|
|
28
|
+
from .ports import DelimiterSplit, ExternalLinter, LanguageConventions, Rule, RuleContext, SourceParser
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
'SEVERITY_BY_NAME',
|
|
33
|
+
'CommentForm',
|
|
34
|
+
'CommentGroup',
|
|
35
|
+
'CommentLine',
|
|
36
|
+
'CommentPlacement',
|
|
37
|
+
'DelimiterSplit',
|
|
38
|
+
'Diagnostic',
|
|
39
|
+
'Document',
|
|
40
|
+
'Encoding',
|
|
41
|
+
'ExternalLinter',
|
|
42
|
+
'Fix',
|
|
43
|
+
'FixKind',
|
|
44
|
+
'LanguageConventions',
|
|
45
|
+
'Position',
|
|
46
|
+
'PositionMap',
|
|
47
|
+
'Prose',
|
|
48
|
+
'Report',
|
|
49
|
+
'Rule',
|
|
50
|
+
'RuleContext',
|
|
51
|
+
'RuleMetadata',
|
|
52
|
+
'RuleSet',
|
|
53
|
+
'RuleSettings',
|
|
54
|
+
'Sentence',
|
|
55
|
+
'Severity',
|
|
56
|
+
'SourceParser',
|
|
57
|
+
'SourceRange',
|
|
58
|
+
'SymbolKind',
|
|
59
|
+
'SymbolRef',
|
|
60
|
+
'TextEdit',
|
|
61
|
+
'Visibility',
|
|
62
|
+
'apply_edits',
|
|
63
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .comment import CommentForm, CommentGroup, CommentLine, CommentPlacement, SymbolKind, SymbolRef, Visibility
|
|
2
|
+
from .diagnostic import SEVERITY_BY_NAME, Diagnostic, Fix, FixKind, Report, RuleMetadata, Severity
|
|
3
|
+
from .document import Document
|
|
4
|
+
from .position import Encoding, Position, PositionMap, SourceRange
|
|
5
|
+
from .prose import Prose, Sentence
|
|
6
|
+
from .rule_set import RuleSet, RuleSettings
|
|
7
|
+
from .text import TextEdit, apply_edits
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'SEVERITY_BY_NAME',
|
|
12
|
+
'CommentForm',
|
|
13
|
+
'CommentGroup',
|
|
14
|
+
'CommentLine',
|
|
15
|
+
'CommentPlacement',
|
|
16
|
+
'Diagnostic',
|
|
17
|
+
'Document',
|
|
18
|
+
'Encoding',
|
|
19
|
+
'Fix',
|
|
20
|
+
'FixKind',
|
|
21
|
+
'Position',
|
|
22
|
+
'PositionMap',
|
|
23
|
+
'Prose',
|
|
24
|
+
'Report',
|
|
25
|
+
'RuleMetadata',
|
|
26
|
+
'RuleSet',
|
|
27
|
+
'RuleSettings',
|
|
28
|
+
'Sentence',
|
|
29
|
+
'Severity',
|
|
30
|
+
'SourceRange',
|
|
31
|
+
'SymbolKind',
|
|
32
|
+
'SymbolRef',
|
|
33
|
+
'TextEdit',
|
|
34
|
+
'Visibility',
|
|
35
|
+
'apply_edits',
|
|
36
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import enum
|
|
3
|
+
|
|
4
|
+
from .position import SourceRange
|
|
5
|
+
from .prose import Prose, reflow_sentence
|
|
6
|
+
from .text import TextEdit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommentForm(enum.Enum):
|
|
10
|
+
LINE = 'line'
|
|
11
|
+
BLOCK = 'block'
|
|
12
|
+
DOC = 'doc'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CommentPlacement(enum.Enum):
|
|
16
|
+
FILE_HEADER = 'file-header'
|
|
17
|
+
LEADING_DECLARATION = 'leading-declaration'
|
|
18
|
+
INLINE_BODY = 'inline-body'
|
|
19
|
+
TRAILING = 'trailing'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Visibility(enum.Enum):
|
|
23
|
+
PUBLIC = 'public'
|
|
24
|
+
INTERNAL = 'internal'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SymbolKind(enum.Enum):
|
|
28
|
+
MODULE = 'module'
|
|
29
|
+
CLASS = 'class'
|
|
30
|
+
FUNCTION = 'function'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
34
|
+
class SymbolRef:
|
|
35
|
+
name: str
|
|
36
|
+
kind: SymbolKind
|
|
37
|
+
visibility: Visibility
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
41
|
+
class CommentLine:
|
|
42
|
+
range: SourceRange
|
|
43
|
+
indent: str
|
|
44
|
+
delimiter: str
|
|
45
|
+
text: str
|
|
46
|
+
suffix: str = ''
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def physical_width(self) -> int:
|
|
50
|
+
return len(self.indent) + len(self.delimiter) + len(self.text) + len(self.suffix)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def prefix_width(self) -> int:
|
|
54
|
+
return len(self.indent) + len(self.delimiter)
|
|
55
|
+
|
|
56
|
+
def render(self) -> str:
|
|
57
|
+
if not self.text and not self.suffix:
|
|
58
|
+
return (self.indent + self.delimiter).rstrip()
|
|
59
|
+
return self.indent + self.delimiter + self.text + self.suffix
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
63
|
+
class CommentGroup:
|
|
64
|
+
range: SourceRange
|
|
65
|
+
lines: tuple[CommentLine, ...]
|
|
66
|
+
form: CommentForm
|
|
67
|
+
placement: CommentPlacement
|
|
68
|
+
attachment: SymbolRef | None = None
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
if not self.lines:
|
|
72
|
+
raise ValueError('A comment group needs at least one line')
|
|
73
|
+
if self.attachment is not None and self.placement is not CommentPlacement.LEADING_DECLARATION:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f'Only a leading declaration comment documents a symbol, '
|
|
76
|
+
f'but a {self.placement.value} comment carries {self.attachment.name!r}'
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def line_count(self) -> int:
|
|
81
|
+
return len(self.lines)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def longest_line(self) -> int:
|
|
85
|
+
return max(line.physical_width for line in self.lines)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def attaches_to_public_symbol(self) -> bool:
|
|
89
|
+
return self.attachment is not None and self.attachment.visibility is Visibility.PUBLIC
|
|
90
|
+
|
|
91
|
+
def prose(self) -> Prose:
|
|
92
|
+
filled_lines = [line for line in self.lines if line.text.strip()]
|
|
93
|
+
base = min((len(line.indent) for line in filled_lines), default=0)
|
|
94
|
+
rendered_lines = [
|
|
95
|
+
' ' * max(0, len(line.indent) - base) + line.text.rstrip() if line.text.strip() else ''
|
|
96
|
+
for line in self.lines
|
|
97
|
+
]
|
|
98
|
+
return Prose('\n'.join(rendered_lines))
|
|
99
|
+
|
|
100
|
+
def reflow(self, width: int) -> 'CommentGroup':
|
|
101
|
+
texts = self._reflow_into_texts(width)
|
|
102
|
+
if not texts:
|
|
103
|
+
return self
|
|
104
|
+
return self._rebuild_lines(texts)
|
|
105
|
+
|
|
106
|
+
def _reflow_into_texts(self, width: int) -> tuple[str, ...]:
|
|
107
|
+
budget = max(20, width - self.lines[0].prefix_width)
|
|
108
|
+
out: list[str] = []
|
|
109
|
+
for paragraph, is_literal in self._split_into_paragraphs():
|
|
110
|
+
if out:
|
|
111
|
+
out.append('')
|
|
112
|
+
if is_literal:
|
|
113
|
+
out.extend(paragraph)
|
|
114
|
+
continue
|
|
115
|
+
joined_text = ' '.join(line.strip() for line in paragraph if line.strip())
|
|
116
|
+
for sentence in Prose(joined_text).sentences():
|
|
117
|
+
out.extend(reflow_sentence(sentence.text, budget))
|
|
118
|
+
return tuple(out)
|
|
119
|
+
|
|
120
|
+
def _split_into_paragraphs(self) -> tuple[tuple[tuple[str, ...], bool], ...]:
|
|
121
|
+
paragraphs: list[tuple[tuple[str, ...], bool]] = []
|
|
122
|
+
for segment in self.prose().segments():
|
|
123
|
+
current_lines: list[str] = []
|
|
124
|
+
for line in segment.lines:
|
|
125
|
+
if line.strip():
|
|
126
|
+
current_lines.append(line)
|
|
127
|
+
elif current_lines:
|
|
128
|
+
paragraphs.append((tuple(current_lines), segment.is_literal))
|
|
129
|
+
current_lines = []
|
|
130
|
+
if current_lines:
|
|
131
|
+
paragraphs.append((tuple(current_lines), segment.is_literal))
|
|
132
|
+
return tuple(paragraphs)
|
|
133
|
+
|
|
134
|
+
def _rebuild_lines(self, texts: tuple[str, ...]) -> 'CommentGroup':
|
|
135
|
+
if self.form is not CommentForm.DOC:
|
|
136
|
+
return self.with_texts(texts)
|
|
137
|
+
opening, closing = self.lines[0], self.lines[-1]
|
|
138
|
+
closes_alone = len(self.lines) > 1 and not closing.text.strip()
|
|
139
|
+
body = [dataclasses.replace(opening, delimiter='', suffix='', text=text, range=opening.range) for text in texts]
|
|
140
|
+
body[0] = dataclasses.replace(body[0], delimiter=opening.delimiter)
|
|
141
|
+
if closes_alone or len(texts) > 1:
|
|
142
|
+
body.append(
|
|
143
|
+
dataclasses.replace(closing, delimiter=closing.delimiter or opening.delimiter, text='', suffix='')
|
|
144
|
+
)
|
|
145
|
+
if not closes_alone:
|
|
146
|
+
body[-1] = dataclasses.replace(body[-1], delimiter=opening.delimiter.strip())
|
|
147
|
+
else:
|
|
148
|
+
body[0] = dataclasses.replace(body[0], suffix=closing.suffix or opening.delimiter.strip())
|
|
149
|
+
return dataclasses.replace(self, lines=tuple(body))
|
|
150
|
+
|
|
151
|
+
def with_texts(self, texts: tuple[str, ...]) -> 'CommentGroup':
|
|
152
|
+
if not texts:
|
|
153
|
+
raise ValueError('A comment block needs at least one line')
|
|
154
|
+
template = self.lines[0]
|
|
155
|
+
rebuilt = tuple(
|
|
156
|
+
dataclasses.replace(
|
|
157
|
+
self.lines[index] if index < len(self.lines) else template,
|
|
158
|
+
text=text,
|
|
159
|
+
)
|
|
160
|
+
for index, text in enumerate(texts)
|
|
161
|
+
)
|
|
162
|
+
return dataclasses.replace(self, lines=rebuilt)
|
|
163
|
+
|
|
164
|
+
def render(self) -> str:
|
|
165
|
+
return '\n'.join(line.render() for line in self.lines)
|
|
166
|
+
|
|
167
|
+
def as_edit(self) -> TextEdit:
|
|
168
|
+
return TextEdit(self.range, self.render())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import enum
|
|
3
|
+
|
|
4
|
+
from .position import SourceRange
|
|
5
|
+
from .text import TextEdit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Severity(enum.IntEnum):
|
|
9
|
+
SUGGESTION = 1
|
|
10
|
+
WARNING = 2
|
|
11
|
+
ERROR = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SEVERITY_BY_NAME = {severity.name.lower(): severity for severity in Severity}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FixKind(enum.Enum):
|
|
18
|
+
TARGETED = 'targeted'
|
|
19
|
+
REFLOW = 'reflow'
|
|
20
|
+
REWRITE = 'rewrite'
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_mechanical(self) -> bool:
|
|
24
|
+
return self is not FixKind.REWRITE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
28
|
+
class Fix:
|
|
29
|
+
kind: FixKind
|
|
30
|
+
edits: tuple[TextEdit, ...] = ()
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if self.kind is FixKind.REWRITE and self.edits:
|
|
34
|
+
raise ValueError('A REWRITE fix carries no edits, only the author can resolve it')
|
|
35
|
+
if self.kind.is_mechanical and not self.edits:
|
|
36
|
+
raise ValueError(f'A {self.kind.name} fix must carry at least one edit')
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def targeted(cls, *edits: TextEdit) -> 'Fix':
|
|
40
|
+
return cls(FixKind.TARGETED, edits)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def reflow(cls, *edits: TextEdit) -> 'Fix':
|
|
44
|
+
return cls(FixKind.REFLOW, edits)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def rewrite(cls) -> 'Fix':
|
|
48
|
+
return cls(FixKind.REWRITE)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
52
|
+
class RuleMetadata:
|
|
53
|
+
rule_id: str
|
|
54
|
+
summary: str
|
|
55
|
+
fix_kind: FixKind
|
|
56
|
+
default_severity: Severity = Severity.ERROR
|
|
57
|
+
source: str = 'subedit'
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if not self.rule_id:
|
|
61
|
+
raise ValueError('A rule needs an id')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
65
|
+
class Diagnostic:
|
|
66
|
+
rule_id: str
|
|
67
|
+
range: SourceRange
|
|
68
|
+
message: str
|
|
69
|
+
severity: Severity = Severity.ERROR
|
|
70
|
+
fix: Fix | None = None
|
|
71
|
+
source: str = 'subedit'
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if not self.message:
|
|
75
|
+
raise ValueError(f'Rule {self.rule_id} produced an empty message')
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_mechanical(self) -> bool:
|
|
79
|
+
return self.fix is not None and self.fix.kind.is_mechanical
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def needs_author(self) -> bool:
|
|
83
|
+
return not self.is_mechanical
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
87
|
+
class Report:
|
|
88
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
89
|
+
unavailable_sources: tuple[str, ...] = ()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def mechanical(self) -> tuple[Diagnostic, ...]:
|
|
93
|
+
return tuple(diagnostic for diagnostic in self.diagnostics if diagnostic.is_mechanical)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def needing_author(self) -> tuple[Diagnostic, ...]:
|
|
97
|
+
return tuple(diagnostic for diagnostic in self.diagnostics if diagnostic.needs_author)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_clean(self) -> bool:
|
|
101
|
+
return not self.diagnostics
|
|
102
|
+
|
|
103
|
+
def by_fix_kind(self, wanted: FixKind) -> tuple[Diagnostic, ...]:
|
|
104
|
+
return tuple(
|
|
105
|
+
diagnostic
|
|
106
|
+
for diagnostic in self.diagnostics
|
|
107
|
+
if diagnostic.fix is not None and diagnostic.fix.kind is wanted
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def merged_with(self, other: 'Report') -> 'Report':
|
|
111
|
+
seen_keys: set[tuple[str, int, int]] = set()
|
|
112
|
+
merged_diagnostics: list[Diagnostic] = []
|
|
113
|
+
for diagnostic in (*self.diagnostics, *other.diagnostics):
|
|
114
|
+
key = (diagnostic.rule_id, diagnostic.range.start, diagnostic.range.end)
|
|
115
|
+
if key in seen_keys:
|
|
116
|
+
continue
|
|
117
|
+
seen_keys.add(key)
|
|
118
|
+
merged_diagnostics.append(diagnostic)
|
|
119
|
+
merged_diagnostics.sort(key=lambda diagnostic: (diagnostic.range.start, diagnostic.rule_id))
|
|
120
|
+
return Report(
|
|
121
|
+
tuple(merged_diagnostics),
|
|
122
|
+
tuple(dict.fromkeys((*self.unavailable_sources, *other.unavailable_sources))),
|
|
123
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import functools
|
|
3
|
+
|
|
4
|
+
from .position import PositionMap
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass(frozen=True)
|
|
8
|
+
class Document:
|
|
9
|
+
uri: str
|
|
10
|
+
text: str
|
|
11
|
+
language_id: str
|
|
12
|
+
version: int = 0
|
|
13
|
+
|
|
14
|
+
@functools.cached_property
|
|
15
|
+
def positions(self) -> PositionMap:
|
|
16
|
+
return PositionMap(self.text)
|
|
17
|
+
|
|
18
|
+
def with_text(self, text: str, version: int | None = None) -> 'Document':
|
|
19
|
+
return dataclasses.replace(
|
|
20
|
+
self,
|
|
21
|
+
text=text,
|
|
22
|
+
version=self.version + 1 if version is None else version,
|
|
23
|
+
)
|