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.
Files changed (50) hide show
  1. subedit/__init__.py +3 -0
  2. subedit/application/__init__.py +16 -0
  3. subedit/application/corpus_meter.py +87 -0
  4. subedit/application/fixer.py +68 -0
  5. subedit/application/linter.py +55 -0
  6. subedit/application/report_aggregator.py +23 -0
  7. subedit/domain/__init__.py +63 -0
  8. subedit/domain/models/__init__.py +36 -0
  9. subedit/domain/models/comment.py +168 -0
  10. subedit/domain/models/diagnostic.py +123 -0
  11. subedit/domain/models/document.py +23 -0
  12. subedit/domain/models/position.py +108 -0
  13. subedit/domain/models/prose.py +215 -0
  14. subedit/domain/models/rule_set.py +30 -0
  15. subedit/domain/models/text.py +23 -0
  16. subedit/domain/ports/__init__.py +14 -0
  17. subedit/domain/ports/external_linter.py +14 -0
  18. subedit/domain/ports/language_conventions.py +25 -0
  19. subedit/domain/ports/rule.py +28 -0
  20. subedit/domain/ports/source_parser.py +12 -0
  21. subedit/domain/rules/__init__.py +15 -0
  22. subedit/domain/rules/layout.py +57 -0
  23. subedit/domain/rules/rewrite.py +83 -0
  24. subedit/domain/rules/structure.py +141 -0
  25. subedit/infrastructure/__init__.py +47 -0
  26. subedit/infrastructure/config_sources/__init__.py +11 -0
  27. subedit/infrastructure/config_sources/config_schema.py +43 -0
  28. subedit/infrastructure/config_sources/toml_config_source.py +102 -0
  29. subedit/infrastructure/linters/__init__.py +5 -0
  30. subedit/infrastructure/linters/autocorrect_linter.py +71 -0
  31. subedit/infrastructure/linters/vale_linter.py +87 -0
  32. subedit/infrastructure/parsers/__init__.py +12 -0
  33. subedit/infrastructure/parsers/languages/__init__.py +11 -0
  34. subedit/infrastructure/parsers/languages/language_profile.py +26 -0
  35. subedit/infrastructure/parsers/languages/python_profile.py +71 -0
  36. subedit/infrastructure/parsers/tree_sitter_parser.py +219 -0
  37. subedit/presentation/__init__.py +0 -0
  38. subedit/presentation/cli.py +193 -0
  39. subedit/presentation/harnesses/__init__.py +40 -0
  40. subedit/presentation/harnesses/agent_harness.py +43 -0
  41. subedit/presentation/harnesses/claude_code_harness.py +37 -0
  42. subedit/presentation/harnesses/codex_harness.py +43 -0
  43. subedit/presentation/hook.py +108 -0
  44. subedit/presentation/report.py +79 -0
  45. subedit/presentation/rule_reference.py +21 -0
  46. subedit-0.1.0.dist-info/METADATA +193 -0
  47. subedit-0.1.0.dist-info/RECORD +50 -0
  48. subedit-0.1.0.dist-info/WHEEL +4 -0
  49. subedit-0.1.0.dist-info/entry_points.txt +3 -0
  50. subedit-0.1.0.dist-info/licenses/LICENSE +21 -0
subedit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __version__ = '0.1.0'
2
+
3
+ __all__ = ['__version__']
@@ -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
+ )