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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import bisect
|
|
2
|
+
import dataclasses
|
|
3
|
+
import enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Encoding(enum.Enum):
|
|
7
|
+
UTF8 = 'utf-8'
|
|
8
|
+
UTF16 = 'utf-16'
|
|
9
|
+
UTF32 = 'utf-32'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
13
|
+
class Position:
|
|
14
|
+
line: int
|
|
15
|
+
character: int
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
if self.line < 0 or self.character < 0:
|
|
19
|
+
raise ValueError(f'Position must be non negative, got line {self.line} character {self.character}')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
23
|
+
class SourceRange:
|
|
24
|
+
start: int
|
|
25
|
+
end: int
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if self.start < 0:
|
|
29
|
+
raise ValueError(f'Range start must be non negative, got {self.start}')
|
|
30
|
+
if self.end < self.start:
|
|
31
|
+
raise ValueError(f'Range end {self.end} precedes start {self.start}')
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def length(self) -> int:
|
|
35
|
+
return self.end - self.start
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_empty(self) -> bool:
|
|
39
|
+
return self.start == self.end
|
|
40
|
+
|
|
41
|
+
def overlaps(self, other: 'SourceRange') -> bool:
|
|
42
|
+
return self.start < other.end and other.start < self.end
|
|
43
|
+
|
|
44
|
+
def contains(self, other: 'SourceRange') -> bool:
|
|
45
|
+
return self.start <= other.start and other.end <= self.end
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PositionMap:
|
|
49
|
+
def __init__(self, text: str) -> None:
|
|
50
|
+
self._text = text
|
|
51
|
+
self._encoded_text = text.encode('utf-8')
|
|
52
|
+
starts = [0]
|
|
53
|
+
for index, byte in enumerate(self._encoded_text):
|
|
54
|
+
if byte == 0x0A:
|
|
55
|
+
starts.append(index + 1)
|
|
56
|
+
self._line_starts = starts
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def line_count(self) -> int:
|
|
60
|
+
return len(self._line_starts)
|
|
61
|
+
|
|
62
|
+
def line_range(self, line: int) -> SourceRange:
|
|
63
|
+
self._require_line(line)
|
|
64
|
+
start = self._line_starts[line]
|
|
65
|
+
if line + 1 < len(self._line_starts):
|
|
66
|
+
end = self._line_starts[line + 1] - 1
|
|
67
|
+
if end > start and self._encoded_text[end - 1] == 0x0D:
|
|
68
|
+
end -= 1
|
|
69
|
+
else:
|
|
70
|
+
end = len(self._encoded_text)
|
|
71
|
+
return SourceRange(start, end)
|
|
72
|
+
|
|
73
|
+
def line_text(self, line: int) -> str:
|
|
74
|
+
span = self.line_range(line)
|
|
75
|
+
return self._encoded_text[span.start : span.end].decode('utf-8')
|
|
76
|
+
|
|
77
|
+
def to_position(self, offset: int, encoding: Encoding = Encoding.UTF16) -> Position:
|
|
78
|
+
self._require_offset(offset)
|
|
79
|
+
line = bisect.bisect_right(self._line_starts, offset) - 1
|
|
80
|
+
span = self.line_range(line)
|
|
81
|
+
prefix = self._encoded_text[span.start : min(offset, span.end)].decode('utf-8')
|
|
82
|
+
return Position(line, self._measure(prefix, encoding))
|
|
83
|
+
|
|
84
|
+
def to_offset(self, position: Position, encoding: Encoding = Encoding.UTF16) -> int:
|
|
85
|
+
self._require_line(position.line)
|
|
86
|
+
span = self.line_range(position.line)
|
|
87
|
+
text = self._encoded_text[span.start : span.end].decode('utf-8')
|
|
88
|
+
consumed = 0
|
|
89
|
+
for index, character in enumerate(text):
|
|
90
|
+
if consumed >= position.character:
|
|
91
|
+
return span.start + len(text[:index].encode('utf-8'))
|
|
92
|
+
consumed += self._measure(character, encoding)
|
|
93
|
+
return span.end
|
|
94
|
+
|
|
95
|
+
def _measure(self, text: str, encoding: Encoding) -> int:
|
|
96
|
+
if encoding is Encoding.UTF8:
|
|
97
|
+
return len(text.encode('utf-8'))
|
|
98
|
+
if encoding is Encoding.UTF32:
|
|
99
|
+
return len(text)
|
|
100
|
+
return sum(2 if character > '' else 1 for character in text)
|
|
101
|
+
|
|
102
|
+
def _require_line(self, line: int) -> None:
|
|
103
|
+
if not 0 <= line < len(self._line_starts):
|
|
104
|
+
raise ValueError(f'Line {line} out of range, document has {len(self._line_starts)} lines')
|
|
105
|
+
|
|
106
|
+
def _require_offset(self, offset: int) -> None:
|
|
107
|
+
if not 0 <= offset <= len(self._encoded_text):
|
|
108
|
+
raise ValueError(f'Offset {offset} out of range, document has {len(self._encoded_text)} bytes')
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# Abbreviations whose period does not end a sentence, written without that period.
|
|
6
|
+
ABBREVIATIONS = frozenset(
|
|
7
|
+
{
|
|
8
|
+
'e.g',
|
|
9
|
+
'i.e',
|
|
10
|
+
'etc',
|
|
11
|
+
'vs',
|
|
12
|
+
'cf',
|
|
13
|
+
'al',
|
|
14
|
+
'approx',
|
|
15
|
+
'fig',
|
|
16
|
+
'ca',
|
|
17
|
+
'resp',
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_SENTENCE_END = re.compile(r'[.!?]')
|
|
22
|
+
_TRAILING_WORD = re.compile(r'([A-Za-z][A-Za-z.]*)$')
|
|
23
|
+
_URL = re.compile(r'\b(?:https?://|www\.)\S+')
|
|
24
|
+
_CODE_SPAN = re.compile(r'`[^`]*`')
|
|
25
|
+
_FENCE = re.compile(r'^\s*(```|~~~)')
|
|
26
|
+
_LIST_DELIMITER = re.compile(r'^\s*(?:[0-9]+[.)]|[-*+]|[a-zA-Z][.)])\s')
|
|
27
|
+
_LITERAL_DELIMITER = '\\b'
|
|
28
|
+
_CLOSING_MARKS = '"\')]}'
|
|
29
|
+
|
|
30
|
+
# Only before a number, since "No. 5" abbreviates while a trailing no ends its sentence.
|
|
31
|
+
NUMBER_ABBREVIATION = 'no'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
35
|
+
class Sentence:
|
|
36
|
+
text: str
|
|
37
|
+
offset: int
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def end(self) -> int:
|
|
41
|
+
return self.offset + len(self.text)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
45
|
+
class Segment:
|
|
46
|
+
lines: tuple[str, ...]
|
|
47
|
+
is_literal: bool
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def text(self) -> str:
|
|
51
|
+
return '\n'.join(self.lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
55
|
+
class Prose:
|
|
56
|
+
text: str
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def physical_lines(self) -> tuple[str, ...]:
|
|
60
|
+
return tuple(self.text.split('\n'))
|
|
61
|
+
|
|
62
|
+
def segments(self) -> tuple[Segment, ...]:
|
|
63
|
+
segments: list[Segment] = []
|
|
64
|
+
current_lines: list[str] = []
|
|
65
|
+
segment_is_literal = False
|
|
66
|
+
inside_fence = False
|
|
67
|
+
inside_literal_marker = False
|
|
68
|
+
inside_list = False
|
|
69
|
+
|
|
70
|
+
def flush(next_is_literal: bool) -> None:
|
|
71
|
+
nonlocal current_lines, segment_is_literal
|
|
72
|
+
if current_lines and next_is_literal != segment_is_literal:
|
|
73
|
+
segments.append(Segment(tuple(current_lines), segment_is_literal))
|
|
74
|
+
current_lines = []
|
|
75
|
+
segment_is_literal = next_is_literal
|
|
76
|
+
|
|
77
|
+
for line in self.physical_lines:
|
|
78
|
+
stripped_line = line.strip()
|
|
79
|
+
if _FENCE.match(line):
|
|
80
|
+
inside_fence = not inside_fence
|
|
81
|
+
flush(next_is_literal=True)
|
|
82
|
+
current_lines.append(line)
|
|
83
|
+
continue
|
|
84
|
+
if stripped_line.startswith(_LITERAL_DELIMITER):
|
|
85
|
+
inside_literal_marker = stripped_line == _LITERAL_DELIMITER
|
|
86
|
+
flush(next_is_literal=True)
|
|
87
|
+
current_lines.append(line)
|
|
88
|
+
continue
|
|
89
|
+
if not stripped_line:
|
|
90
|
+
inside_literal_marker = False
|
|
91
|
+
inside_list = False
|
|
92
|
+
flush(next_is_literal=inside_fence)
|
|
93
|
+
current_lines.append(line)
|
|
94
|
+
continue
|
|
95
|
+
if self._is_list_item(line):
|
|
96
|
+
inside_list = True
|
|
97
|
+
elif not self._continues_list_item(line, inside_list):
|
|
98
|
+
inside_list = False
|
|
99
|
+
flush(next_is_literal=inside_fence or inside_literal_marker or inside_list or self._is_indented(line))
|
|
100
|
+
current_lines.append(line)
|
|
101
|
+
|
|
102
|
+
if current_lines:
|
|
103
|
+
segments.append(Segment(tuple(current_lines), segment_is_literal))
|
|
104
|
+
return tuple(segments)
|
|
105
|
+
|
|
106
|
+
def _is_indented(self, line: str) -> bool:
|
|
107
|
+
return line.startswith((' ', '\t'))
|
|
108
|
+
|
|
109
|
+
# A wrapped list item is indented under its marker rather than to a fixed width,
|
|
110
|
+
# so the continuation belongs to the item instead of opening a paragraph.
|
|
111
|
+
def _continues_list_item(self, line: str, inside_list: bool) -> bool:
|
|
112
|
+
return inside_list and line[:1].isspace()
|
|
113
|
+
|
|
114
|
+
def _is_list_item(self, line: str) -> bool:
|
|
115
|
+
return bool(_LIST_DELIMITER.match(line))
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def flattened(self) -> str:
|
|
119
|
+
return ' '.join(
|
|
120
|
+
line.strip()
|
|
121
|
+
for segment in self.segments()
|
|
122
|
+
if not segment.is_literal
|
|
123
|
+
for line in segment.lines
|
|
124
|
+
if line.strip()
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def sentences(self) -> tuple[Sentence, ...]:
|
|
128
|
+
text = self.flattened
|
|
129
|
+
if not text:
|
|
130
|
+
return ()
|
|
131
|
+
protected = self._find_protected_spans(text)
|
|
132
|
+
found_sentences: list[Sentence] = []
|
|
133
|
+
start = 0
|
|
134
|
+
for sentence_end in _SENTENCE_END.finditer(text):
|
|
135
|
+
end = sentence_end.end()
|
|
136
|
+
if end < len(text) and text[end] in _CLOSING_MARKS:
|
|
137
|
+
end += 1
|
|
138
|
+
if self._is_protected(sentence_end.start(), protected):
|
|
139
|
+
continue
|
|
140
|
+
if not self._terminates_a_sentence(text, sentence_end.start()):
|
|
141
|
+
continue
|
|
142
|
+
found_sentences.append(Sentence(text[start:end].strip(), start))
|
|
143
|
+
start = end
|
|
144
|
+
while start < len(text) and text[start] == ' ':
|
|
145
|
+
start += 1
|
|
146
|
+
if start < len(text):
|
|
147
|
+
found_sentences.append(Sentence(text[start:].strip(), start))
|
|
148
|
+
return tuple(sentence for sentence in found_sentences if sentence.text)
|
|
149
|
+
|
|
150
|
+
def _find_protected_spans(self, text: str) -> tuple[tuple[int, int], ...]:
|
|
151
|
+
spans = [(url.start(), url.end()) for url in _URL.finditer(text)]
|
|
152
|
+
spans.extend((code_span.start(), code_span.end()) for code_span in _CODE_SPAN.finditer(text))
|
|
153
|
+
return tuple(spans)
|
|
154
|
+
|
|
155
|
+
def _is_protected(self, offset: int, spans: tuple[tuple[int, int], ...]) -> bool:
|
|
156
|
+
return any(start <= offset < end for start, end in spans)
|
|
157
|
+
|
|
158
|
+
def _terminates_a_sentence(self, text: str, offset: int) -> bool:
|
|
159
|
+
if text[offset] != '.':
|
|
160
|
+
return self._followed_by_break(text, offset)
|
|
161
|
+
if offset + 1 < len(text) and text[offset + 1].isdigit():
|
|
162
|
+
return False
|
|
163
|
+
trailing_word = _TRAILING_WORD.search(text[:offset])
|
|
164
|
+
word = trailing_word.group(1).lower().rstrip('.') if trailing_word else ''
|
|
165
|
+
if word in ABBREVIATIONS:
|
|
166
|
+
return False
|
|
167
|
+
if word == NUMBER_ABBREVIATION and text[offset + 1 :].lstrip()[:1].isdigit():
|
|
168
|
+
return False
|
|
169
|
+
if trailing_word and len(trailing_word.group(1).replace('.', '')) == 1:
|
|
170
|
+
return False
|
|
171
|
+
return self._followed_by_break(text, offset)
|
|
172
|
+
|
|
173
|
+
def _followed_by_break(self, text: str, offset: int) -> bool:
|
|
174
|
+
rest = text[offset + 1 :]
|
|
175
|
+
if not rest:
|
|
176
|
+
return True
|
|
177
|
+
if rest[0] in _CLOSING_MARKS:
|
|
178
|
+
rest = rest[1:]
|
|
179
|
+
return not rest or rest[0] == ' '
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def reflow_sentence(sentence: str, width: int) -> tuple[str, ...]:
|
|
183
|
+
if len(sentence) <= width:
|
|
184
|
+
return (sentence,)
|
|
185
|
+
pieces = _comma_pieces(sentence)
|
|
186
|
+
if len(pieces) == 1:
|
|
187
|
+
return (sentence,)
|
|
188
|
+
return _pack(pieces, width)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _comma_pieces(sentence: str) -> tuple[str, ...]:
|
|
192
|
+
pieces: list[str] = []
|
|
193
|
+
start = 0
|
|
194
|
+
for comma in re.finditer(r',\s+', sentence):
|
|
195
|
+
pieces.append(sentence[start : comma.end()].rstrip())
|
|
196
|
+
start = comma.end()
|
|
197
|
+
tail = sentence[start:].strip()
|
|
198
|
+
if tail:
|
|
199
|
+
pieces.append(tail)
|
|
200
|
+
return tuple(pieces) if pieces else (sentence,)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _pack(pieces: tuple[str, ...], width: int) -> tuple[str, ...]:
|
|
204
|
+
lines: list[str] = []
|
|
205
|
+
current_line = ''
|
|
206
|
+
for piece in pieces:
|
|
207
|
+
candidate = f'{current_line} {piece}'.strip() if current_line else piece
|
|
208
|
+
if current_line and len(candidate) > width:
|
|
209
|
+
lines.append(current_line)
|
|
210
|
+
current_line = piece
|
|
211
|
+
else:
|
|
212
|
+
current_line = candidate
|
|
213
|
+
if current_line:
|
|
214
|
+
lines.append(current_line)
|
|
215
|
+
return tuple(lines)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from .diagnostic import RuleMetadata, Severity
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
8
|
+
class RuleSettings:
|
|
9
|
+
severity: Severity | None = None
|
|
10
|
+
options: typing.Mapping[str, object] = dataclasses.field(default_factory=dict)
|
|
11
|
+
|
|
12
|
+
def integer(self, name: str, fallback: int) -> int:
|
|
13
|
+
option = self.options.get(name, fallback)
|
|
14
|
+
return option if isinstance(option, int) and not isinstance(option, bool) else fallback
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
18
|
+
class RuleSet:
|
|
19
|
+
enabled_rule_ids: frozenset[str]
|
|
20
|
+
settings: typing.Mapping[str, RuleSettings] = dataclasses.field(default_factory=dict)
|
|
21
|
+
line_width: int = 120
|
|
22
|
+
|
|
23
|
+
def is_enabled(self, rule_id: str) -> bool:
|
|
24
|
+
return rule_id in self.enabled_rule_ids
|
|
25
|
+
|
|
26
|
+
def settings_for(self, rule_id: str) -> RuleSettings:
|
|
27
|
+
return self.settings.get(rule_id, RuleSettings())
|
|
28
|
+
|
|
29
|
+
def severity_for(self, metadata: RuleMetadata) -> Severity:
|
|
30
|
+
return self.settings_for(metadata.rule_id).severity or metadata.default_severity
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import itertools
|
|
3
|
+
|
|
4
|
+
from .position import SourceRange
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
8
|
+
class TextEdit:
|
|
9
|
+
range: SourceRange
|
|
10
|
+
new_text: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def apply_edits(text: str, edits: tuple[TextEdit, ...]) -> str:
|
|
14
|
+
if not edits:
|
|
15
|
+
return text
|
|
16
|
+
sorted_edits = sorted(edits, key=lambda edit: (edit.range.start, edit.range.end))
|
|
17
|
+
for earlier_edit, later_edit in itertools.pairwise(sorted_edits):
|
|
18
|
+
if earlier_edit.range.overlaps(later_edit.range):
|
|
19
|
+
raise ValueError(f'Overlapping edits at bytes {earlier_edit.range.start} and {later_edit.range.start}')
|
|
20
|
+
buffer = bytearray(text.encode('utf-8'))
|
|
21
|
+
for edit in reversed(sorted_edits):
|
|
22
|
+
buffer[edit.range.start : edit.range.end] = edit.new_text.encode('utf-8')
|
|
23
|
+
return buffer.decode('utf-8')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .external_linter import ExternalLinter
|
|
2
|
+
from .language_conventions import DelimiterSplit, LanguageConventions
|
|
3
|
+
from .rule import Rule, RuleContext
|
|
4
|
+
from .source_parser import SourceParser
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'DelimiterSplit',
|
|
9
|
+
'ExternalLinter',
|
|
10
|
+
'LanguageConventions',
|
|
11
|
+
'Rule',
|
|
12
|
+
'RuleContext',
|
|
13
|
+
'SourceParser',
|
|
14
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
|
|
3
|
+
from ..models.diagnostic import Diagnostic
|
|
4
|
+
from ..models.document import Document
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExternalLinter(abc.ABC):
|
|
8
|
+
name: str
|
|
9
|
+
|
|
10
|
+
@abc.abstractmethod
|
|
11
|
+
def is_available(self) -> bool: ...
|
|
12
|
+
|
|
13
|
+
@abc.abstractmethod
|
|
14
|
+
def run(self, document: Document) -> tuple[Diagnostic, ...]: ...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import dataclasses
|
|
3
|
+
|
|
4
|
+
from ..models.comment import CommentForm, Visibility
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
8
|
+
class DelimiterSplit:
|
|
9
|
+
indent: str
|
|
10
|
+
delimiter: str
|
|
11
|
+
text: str
|
|
12
|
+
suffix: str = ''
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LanguageConventions(abc.ABC):
|
|
16
|
+
language_id: str
|
|
17
|
+
extensions: frozenset[str]
|
|
18
|
+
doc_delimiter: str
|
|
19
|
+
signature_tags: tuple[str, ...]
|
|
20
|
+
|
|
21
|
+
@abc.abstractmethod
|
|
22
|
+
def visibility_of(self, name: str) -> Visibility: ...
|
|
23
|
+
|
|
24
|
+
@abc.abstractmethod
|
|
25
|
+
def split_delimiter(self, line: str, form: CommentForm) -> DelimiterSplit: ...
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import dataclasses
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from ..models.comment import CommentGroup
|
|
6
|
+
from ..models.diagnostic import Diagnostic, RuleMetadata
|
|
7
|
+
from ..models.document import Document
|
|
8
|
+
from ..models.rule_set import RuleSet, RuleSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
12
|
+
class RuleContext:
|
|
13
|
+
document: Document
|
|
14
|
+
rule_set: RuleSet
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def line_width(self) -> int:
|
|
18
|
+
return self.rule_set.line_width
|
|
19
|
+
|
|
20
|
+
def settings(self, rule_id: str) -> RuleSettings:
|
|
21
|
+
return self.rule_set.settings_for(rule_id)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Rule(abc.ABC):
|
|
25
|
+
metadata: RuleMetadata
|
|
26
|
+
|
|
27
|
+
@abc.abstractmethod
|
|
28
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]: ...
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
|
|
3
|
+
from ..models.comment import CommentGroup
|
|
4
|
+
from ..models.document import Document
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SourceParser(abc.ABC):
|
|
8
|
+
@abc.abstractmethod
|
|
9
|
+
def supports(self, language_id: str) -> bool: ...
|
|
10
|
+
|
|
11
|
+
@abc.abstractmethod
|
|
12
|
+
def parse(self, document: Document) -> tuple[CommentGroup, ...]: ...
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .layout import LineTooLongRule, MidClauseBreakRule
|
|
2
|
+
from .rewrite import StubFragmentRule, UnbreakableSentenceRule
|
|
3
|
+
from .structure import BlockTooLongRule, FileHeaderCommentRule, PlainCommentOnPublicRule, SignatureRestatingTagRule
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'BlockTooLongRule',
|
|
8
|
+
'FileHeaderCommentRule',
|
|
9
|
+
'LineTooLongRule',
|
|
10
|
+
'MidClauseBreakRule',
|
|
11
|
+
'PlainCommentOnPublicRule',
|
|
12
|
+
'SignatureRestatingTagRule',
|
|
13
|
+
'StubFragmentRule',
|
|
14
|
+
'UnbreakableSentenceRule',
|
|
15
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from ..models.comment import CommentGroup
|
|
4
|
+
from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata
|
|
5
|
+
from ..ports.rule import Rule, RuleContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
MID_CLAUSE_BREAK = RuleMetadata(
|
|
9
|
+
rule_id='mid-clause-break',
|
|
10
|
+
summary='A comment block must be laid out one sentence per line, splitting only at commas.',
|
|
11
|
+
fix_kind=FixKind.REFLOW,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
LINE_TOO_LONG = RuleMetadata(
|
|
15
|
+
rule_id='line-too-long',
|
|
16
|
+
summary='A physical comment line must fit the configured width, counting indent and delimiter.',
|
|
17
|
+
fix_kind=FixKind.REFLOW,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MidClauseBreakRule(Rule):
|
|
22
|
+
metadata = MID_CLAUSE_BREAK
|
|
23
|
+
|
|
24
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
25
|
+
reflowed = group.reflow(context.line_width)
|
|
26
|
+
if reflowed.render() == group.render():
|
|
27
|
+
return
|
|
28
|
+
yield Diagnostic(
|
|
29
|
+
rule_id=self.metadata.rule_id,
|
|
30
|
+
range=group.range,
|
|
31
|
+
message=(
|
|
32
|
+
'Comment layout differs from one sentence per line. '
|
|
33
|
+
'Break only at a period or a comma, never mid clause.'
|
|
34
|
+
),
|
|
35
|
+
fix=Fix.reflow(reflowed.as_edit()),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LineTooLongRule(Rule):
|
|
40
|
+
metadata = LINE_TOO_LONG
|
|
41
|
+
|
|
42
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
43
|
+
width = context.line_width
|
|
44
|
+
if group.longest_line <= width:
|
|
45
|
+
return
|
|
46
|
+
reflowed = group.reflow(width)
|
|
47
|
+
if reflowed.longest_line > width:
|
|
48
|
+
return
|
|
49
|
+
yield Diagnostic(
|
|
50
|
+
rule_id=self.metadata.rule_id,
|
|
51
|
+
range=group.range,
|
|
52
|
+
message=(
|
|
53
|
+
f'A comment line runs to {group.longest_line} characters against a limit of {width}, '
|
|
54
|
+
'counting indent and delimiter.'
|
|
55
|
+
),
|
|
56
|
+
fix=Fix.reflow(reflowed.as_edit()),
|
|
57
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from ..models.comment import CommentGroup
|
|
4
|
+
from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata
|
|
5
|
+
from ..models.prose import Prose, reflow_sentence
|
|
6
|
+
from ..ports.rule import Rule, RuleContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
STUB_FRAGMENT = RuleMetadata(
|
|
10
|
+
rule_id='stub-fragment',
|
|
11
|
+
summary='A sentence forced to break must not leave a stub of a line behind.',
|
|
12
|
+
fix_kind=FixKind.REWRITE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
UNBREAKABLE_SENTENCE = RuleMetadata(
|
|
16
|
+
rule_id='unbreakable-sentence',
|
|
17
|
+
summary='A sentence over the width must carry a comma so it has somewhere legal to break.',
|
|
18
|
+
fix_kind=FixKind.REWRITE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_MINIMUM_CHARACTERS = 24
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def prose_sentences(group: CommentGroup) -> tuple[str, ...]:
|
|
26
|
+
return tuple(
|
|
27
|
+
sentence.text
|
|
28
|
+
for segment in group.prose().segments()
|
|
29
|
+
if not segment.is_literal
|
|
30
|
+
for sentence in Prose(segment.text).sentences()
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sentence_budget(group: CommentGroup, context: RuleContext) -> int:
|
|
35
|
+
return max(20, context.line_width - group.lines[0].prefix_width)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StubFragmentRule(Rule):
|
|
39
|
+
metadata = STUB_FRAGMENT
|
|
40
|
+
|
|
41
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
42
|
+
budget = sentence_budget(group, context)
|
|
43
|
+
minimum_length = context.settings(self.metadata.rule_id).integer(
|
|
44
|
+
'minimum_characters', DEFAULT_MINIMUM_CHARACTERS
|
|
45
|
+
)
|
|
46
|
+
for sentence in prose_sentences(group):
|
|
47
|
+
split_pieces = reflow_sentence(sentence, budget)
|
|
48
|
+
if len(split_pieces) < 2:
|
|
49
|
+
continue
|
|
50
|
+
shortest_piece = min(split_pieces, key=len)
|
|
51
|
+
if len(shortest_piece) >= minimum_length:
|
|
52
|
+
continue
|
|
53
|
+
yield Diagnostic(
|
|
54
|
+
rule_id=self.metadata.rule_id,
|
|
55
|
+
range=group.range,
|
|
56
|
+
message=(
|
|
57
|
+
f'Breaking this sentence leaves a line of {len(shortest_piece)} characters, below the minimum of '
|
|
58
|
+
f'{minimum_length}, so the layout reads as a stub rather than a clause. '
|
|
59
|
+
f'Rewrite it to fit one line, or split it into two complete sentences. '
|
|
60
|
+
f'The fragment is "{shortest_piece.strip()}".'
|
|
61
|
+
),
|
|
62
|
+
fix=Fix.rewrite(),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class UnbreakableSentenceRule(Rule):
|
|
67
|
+
metadata = UNBREAKABLE_SENTENCE
|
|
68
|
+
|
|
69
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
70
|
+
budget = max(20, context.line_width - group.lines[0].prefix_width)
|
|
71
|
+
for sentence in prose_sentences(group):
|
|
72
|
+
if len(sentence) <= budget or ',' in sentence:
|
|
73
|
+
continue
|
|
74
|
+
yield Diagnostic(
|
|
75
|
+
rule_id=self.metadata.rule_id,
|
|
76
|
+
range=group.range,
|
|
77
|
+
message=(
|
|
78
|
+
f'This sentence runs to {len(sentence)} characters against a budget of {budget} characters '
|
|
79
|
+
'and contains no comma, so there is no legal place to break it. '
|
|
80
|
+
'Add a comma at a clause boundary, or split it into two sentences.'
|
|
81
|
+
),
|
|
82
|
+
fix=Fix.rewrite(),
|
|
83
|
+
)
|