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
@@ -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
+ )