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,141 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from ..models.comment import CommentForm, CommentGroup, CommentPlacement
|
|
4
|
+
from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata
|
|
5
|
+
from ..ports.language_conventions import LanguageConventions
|
|
6
|
+
from ..ports.rule import Rule, RuleContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
FILE_HEADER_COMMENT = RuleMetadata(
|
|
10
|
+
rule_id='file-header-comment',
|
|
11
|
+
summary='A file must not open with a banner comment before its first declaration.',
|
|
12
|
+
fix_kind=FixKind.REWRITE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
PLAIN_COMMENT_ON_PUBLIC = RuleMetadata(
|
|
16
|
+
rule_id='plain-comment-on-public',
|
|
17
|
+
summary='A public symbol must be documented in the doc comment form its language renders.',
|
|
18
|
+
fix_kind=FixKind.REWRITE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
SIGNATURE_RESTATING_TAG = RuleMetadata(
|
|
22
|
+
rule_id='signature-restating-tag',
|
|
23
|
+
summary='A doc comment must not restate what the type signature already carries.',
|
|
24
|
+
fix_kind=FixKind.REWRITE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FileHeaderCommentRule(Rule):
|
|
29
|
+
metadata = FILE_HEADER_COMMENT
|
|
30
|
+
|
|
31
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
32
|
+
if group.placement is not CommentPlacement.FILE_HEADER:
|
|
33
|
+
return
|
|
34
|
+
yield Diagnostic(
|
|
35
|
+
rule_id=self.metadata.rule_id,
|
|
36
|
+
range=group.range,
|
|
37
|
+
message=(
|
|
38
|
+
'This comment opens the file before any declaration. '
|
|
39
|
+
'A reader has the file, not a banner about it, so delete it. '
|
|
40
|
+
'If it states a constraint, move that to the declaration the constraint applies to.'
|
|
41
|
+
),
|
|
42
|
+
fix=Fix.rewrite(),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PlainCommentOnPublicRule(Rule):
|
|
47
|
+
metadata = PLAIN_COMMENT_ON_PUBLIC
|
|
48
|
+
|
|
49
|
+
def __init__(self, conventions: LanguageConventions) -> None:
|
|
50
|
+
self._conventions = conventions
|
|
51
|
+
|
|
52
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
53
|
+
if not group.attaches_to_public_symbol or group.form is CommentForm.DOC:
|
|
54
|
+
return
|
|
55
|
+
if group.placement is not CommentPlacement.LEADING_DECLARATION:
|
|
56
|
+
return
|
|
57
|
+
delimiter = self._conventions.doc_delimiter
|
|
58
|
+
name = group.attachment.name if group.attachment else 'the symbol'
|
|
59
|
+
yield Diagnostic(
|
|
60
|
+
rule_id=self.metadata.rule_id,
|
|
61
|
+
range=group.range,
|
|
62
|
+
message=(
|
|
63
|
+
f'{name} is public but documented with a plain comment. '
|
|
64
|
+
f'Move the text into a {delimiter} doc comment so tooling renders it on hover.'
|
|
65
|
+
),
|
|
66
|
+
fix=Fix.rewrite(),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SignatureRestatingTagRule(Rule):
|
|
71
|
+
metadata = SIGNATURE_RESTATING_TAG
|
|
72
|
+
|
|
73
|
+
def __init__(self, conventions: LanguageConventions) -> None:
|
|
74
|
+
self._conventions = conventions
|
|
75
|
+
|
|
76
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
77
|
+
if group.form is not CommentForm.DOC:
|
|
78
|
+
return
|
|
79
|
+
for line in group.lines:
|
|
80
|
+
matched_tag = self._first_matching_tag(line.text)
|
|
81
|
+
if matched_tag is None:
|
|
82
|
+
continue
|
|
83
|
+
yield Diagnostic(
|
|
84
|
+
rule_id=self.metadata.rule_id,
|
|
85
|
+
range=group.range,
|
|
86
|
+
message=(
|
|
87
|
+
f'The tag "{matched_tag}" restates the signature, which the annotations already carry. '
|
|
88
|
+
'Delete the tag and its block. '
|
|
89
|
+
'If a parameter needs explaining, say why it matters in prose instead.'
|
|
90
|
+
),
|
|
91
|
+
fix=Fix.rewrite(),
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
def _first_matching_tag(self, text: str) -> str | None:
|
|
96
|
+
stripped_text = text.strip()
|
|
97
|
+
for matched_tag in self._conventions.signature_tags:
|
|
98
|
+
if stripped_text.startswith(matched_tag):
|
|
99
|
+
return matched_tag
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
BLOCK_TOO_LONG = RuleMetadata(
|
|
104
|
+
rule_id='block-too-long',
|
|
105
|
+
summary='A comment block long enough to restate the code has stopped earning its place.',
|
|
106
|
+
fix_kind=FixKind.REWRITE,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Set from the p95 of a measured corpus, which subedit stats reports.
|
|
111
|
+
# A comment past this length has usually started restating the code below it.
|
|
112
|
+
DEFAULT_LIMITS = {
|
|
113
|
+
'line': 4,
|
|
114
|
+
'doc-internal': 13,
|
|
115
|
+
'doc-public': 20,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class BlockTooLongRule(Rule):
|
|
120
|
+
metadata = BLOCK_TOO_LONG
|
|
121
|
+
|
|
122
|
+
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
123
|
+
category = self._category(group)
|
|
124
|
+
limit = context.settings(self.metadata.rule_id).integer(category, DEFAULT_LIMITS[category])
|
|
125
|
+
if group.line_count <= limit:
|
|
126
|
+
return
|
|
127
|
+
yield Diagnostic(
|
|
128
|
+
rule_id=self.metadata.rule_id,
|
|
129
|
+
range=group.range,
|
|
130
|
+
message=(
|
|
131
|
+
f'This comment runs to {group.line_count} lines against a limit of {limit} lines for {category} '
|
|
132
|
+
'comments. A comment earns its place by stating one constraint the code cannot show. '
|
|
133
|
+
'Cut it to that constraint, or move the explanation into a name.'
|
|
134
|
+
),
|
|
135
|
+
fix=Fix.rewrite(),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _category(self, group: CommentGroup) -> str:
|
|
139
|
+
if group.form is not CommentForm.DOC:
|
|
140
|
+
return 'line'
|
|
141
|
+
return 'doc-public' if group.attaches_to_public_symbol else 'doc-internal'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from ..domain.rules import (
|
|
2
|
+
BlockTooLongRule,
|
|
3
|
+
FileHeaderCommentRule,
|
|
4
|
+
LineTooLongRule,
|
|
5
|
+
MidClauseBreakRule,
|
|
6
|
+
PlainCommentOnPublicRule,
|
|
7
|
+
SignatureRestatingTagRule,
|
|
8
|
+
StubFragmentRule,
|
|
9
|
+
UnbreakableSentenceRule,
|
|
10
|
+
)
|
|
11
|
+
from .config_sources import CONFIG_NAME, TomlConfigSource
|
|
12
|
+
from .linters import AutoCorrectLinter, ValeLinter
|
|
13
|
+
from .parsers import PYTHON, LanguageProfile, NodeRole, TreeSitterParser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
LAYOUT_RULES = (MidClauseBreakRule(), LineTooLongRule())
|
|
17
|
+
REWRITE_RULES = (StubFragmentRule(), UnbreakableSentenceRule())
|
|
18
|
+
STRUCTURAL_RULES = (
|
|
19
|
+
FileHeaderCommentRule(),
|
|
20
|
+
PlainCommentOnPublicRule(PYTHON),
|
|
21
|
+
SignatureRestatingTagRule(PYTHON),
|
|
22
|
+
BlockTooLongRule(),
|
|
23
|
+
)
|
|
24
|
+
ALL_RULES = LAYOUT_RULES + REWRITE_RULES + STRUCTURAL_RULES
|
|
25
|
+
|
|
26
|
+
DEFAULT_PARSER = TreeSitterParser((PYTHON,))
|
|
27
|
+
DEFAULT_CONFIG_SOURCE = TomlConfigSource(tuple(rule.metadata.rule_id for rule in ALL_RULES))
|
|
28
|
+
|
|
29
|
+
EXTERNAL_LINTERS = (ValeLinter(), AutoCorrectLinter())
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
'ALL_RULES',
|
|
33
|
+
'CONFIG_NAME',
|
|
34
|
+
'DEFAULT_CONFIG_SOURCE',
|
|
35
|
+
'DEFAULT_PARSER',
|
|
36
|
+
'EXTERNAL_LINTERS',
|
|
37
|
+
'LAYOUT_RULES',
|
|
38
|
+
'PYTHON',
|
|
39
|
+
'REWRITE_RULES',
|
|
40
|
+
'STRUCTURAL_RULES',
|
|
41
|
+
'AutoCorrectLinter',
|
|
42
|
+
'LanguageProfile',
|
|
43
|
+
'NodeRole',
|
|
44
|
+
'TomlConfigSource',
|
|
45
|
+
'TreeSitterParser',
|
|
46
|
+
'ValeLinter',
|
|
47
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
from ...domain.models.diagnostic import SEVERITY_BY_NAME, Severity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuleTable(pydantic.BaseModel):
|
|
9
|
+
model_config = pydantic.ConfigDict(extra='allow', frozen=True)
|
|
10
|
+
|
|
11
|
+
severity: str | None = None
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def resolved_severity(self) -> Severity | None:
|
|
15
|
+
return SEVERITY_BY_NAME.get(self.severity) if self.severity else None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def options(self) -> dict[str, object]:
|
|
19
|
+
return dict(self.__pydantic_extra__ or {})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
RuleEntry = typing.Annotated[bool | str | RuleTable, pydantic.Field(union_mode='left_to_right')]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SubeditSection(pydantic.BaseModel):
|
|
26
|
+
model_config = pydantic.ConfigDict(frozen=True, populate_by_name=True)
|
|
27
|
+
|
|
28
|
+
line_width: int = pydantic.Field(default=120, alias='line-width', gt=0)
|
|
29
|
+
exclude: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigFile(pydantic.BaseModel):
|
|
33
|
+
model_config = pydantic.ConfigDict(frozen=True)
|
|
34
|
+
|
|
35
|
+
subedit: SubeditSection = pydantic.Field(default_factory=SubeditSection)
|
|
36
|
+
rules: dict[str, RuleEntry] = pydantic.Field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def parse(cls, raw: typing.Mapping[str, object]) -> 'ConfigFile':
|
|
40
|
+
try:
|
|
41
|
+
return cls.model_validate(raw)
|
|
42
|
+
except pydantic.ValidationError:
|
|
43
|
+
return cls()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import pathlib
|
|
3
|
+
import tomllib
|
|
4
|
+
|
|
5
|
+
from ...domain.models.rule_set import RuleSet, RuleSettings
|
|
6
|
+
from .config_schema import ConfigFile, RuleTable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CONFIG_NAME = 'subedit.toml'
|
|
10
|
+
PYPROJECT_NAME = 'pyproject.toml'
|
|
11
|
+
DEFAULT_WIDTH = 120
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TomlConfigSource:
|
|
15
|
+
def __init__(self, available_rule_ids: tuple[str, ...]) -> None:
|
|
16
|
+
self._available_rule_ids = available_rule_ids
|
|
17
|
+
|
|
18
|
+
def resolve(self, path: str) -> RuleSet:
|
|
19
|
+
config_file = self._read(path)
|
|
20
|
+
if config_file is None:
|
|
21
|
+
return self.defaults()
|
|
22
|
+
return self._to_rule_set(config_file)
|
|
23
|
+
|
|
24
|
+
def defaults(self) -> RuleSet:
|
|
25
|
+
return RuleSet(enabled_rule_ids=frozenset(self._available_rule_ids), line_width=DEFAULT_WIDTH)
|
|
26
|
+
|
|
27
|
+
def exclude_patterns(self, path: str) -> tuple[str, ...]:
|
|
28
|
+
config_file = self._read(path)
|
|
29
|
+
return config_file.subedit.exclude if config_file else ()
|
|
30
|
+
|
|
31
|
+
def is_excluded(self, target: pathlib.Path, root: pathlib.Path, patterns: tuple[str, ...]) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
relative = target.resolve().relative_to(root.resolve()).as_posix()
|
|
34
|
+
except ValueError:
|
|
35
|
+
relative = target.resolve().as_posix()
|
|
36
|
+
return any(fnmatch.fnmatch(relative, pattern) for pattern in patterns)
|
|
37
|
+
|
|
38
|
+
def root_for(self, path: str) -> pathlib.Path:
|
|
39
|
+
config_path = self._locate(pathlib.Path(path))
|
|
40
|
+
return config_path.parent if config_path else pathlib.Path.cwd()
|
|
41
|
+
|
|
42
|
+
def _read(self, path: str) -> ConfigFile | None:
|
|
43
|
+
config_path = self._locate(pathlib.Path(path))
|
|
44
|
+
if config_path is None:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
raw_table = tomllib.loads(config_path.read_text(encoding='utf-8'))
|
|
48
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
49
|
+
return None
|
|
50
|
+
if config_path.name == PYPROJECT_NAME:
|
|
51
|
+
raw_table = self._reshape_pyproject(raw_table)
|
|
52
|
+
if raw_table is None:
|
|
53
|
+
return None
|
|
54
|
+
return ConfigFile.parse(raw_table)
|
|
55
|
+
|
|
56
|
+
def _reshape_pyproject(self, raw_table: dict[str, object]) -> dict[str, object] | None:
|
|
57
|
+
tools = raw_table.get('tool')
|
|
58
|
+
tool_section = tools.get('subedit') if isinstance(tools, dict) else None
|
|
59
|
+
if not isinstance(tool_section, dict):
|
|
60
|
+
return None
|
|
61
|
+
return {
|
|
62
|
+
'subedit': {name: setting for name, setting in tool_section.items() if name != 'rules'},
|
|
63
|
+
'rules': tool_section.get('rules', {}),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def _locate(self, start: pathlib.Path) -> pathlib.Path | None:
|
|
67
|
+
base = start if start.is_dir() else start.parent
|
|
68
|
+
for directory in (base.resolve(), *base.resolve().parents):
|
|
69
|
+
for name in (CONFIG_NAME, PYPROJECT_NAME):
|
|
70
|
+
config_path = directory / name
|
|
71
|
+
if config_path.is_file() and self._carries_settings(config_path):
|
|
72
|
+
return config_path
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def _carries_settings(self, config_path: pathlib.Path) -> bool:
|
|
76
|
+
if config_path.name != PYPROJECT_NAME:
|
|
77
|
+
return True
|
|
78
|
+
try:
|
|
79
|
+
tools = tomllib.loads(config_path.read_text(encoding='utf-8')).get('tool')
|
|
80
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
81
|
+
return False
|
|
82
|
+
return isinstance(tools, dict) and 'subedit' in tools
|
|
83
|
+
|
|
84
|
+
def _to_rule_set(self, config_file: ConfigFile) -> RuleSet:
|
|
85
|
+
enabled_ids = set(self._available_rule_ids)
|
|
86
|
+
settings: dict[str, RuleSettings] = {}
|
|
87
|
+
|
|
88
|
+
for rule_id, rule_entry in config_file.rules.items():
|
|
89
|
+
if rule_id not in self._available_rule_ids:
|
|
90
|
+
continue
|
|
91
|
+
if rule_entry is False or rule_entry == 'off':
|
|
92
|
+
enabled_ids.discard(rule_id)
|
|
93
|
+
elif isinstance(rule_entry, RuleTable):
|
|
94
|
+
settings[rule_id] = RuleSettings(severity=rule_entry.resolved_severity, options=rule_entry.options)
|
|
95
|
+
elif isinstance(rule_entry, str):
|
|
96
|
+
settings[rule_id] = RuleSettings(severity=RuleTable(severity=rule_entry).resolved_severity)
|
|
97
|
+
|
|
98
|
+
return RuleSet(
|
|
99
|
+
enabled_rule_ids=frozenset(enabled_ids),
|
|
100
|
+
settings=settings,
|
|
101
|
+
line_width=config_file.subedit.line_width,
|
|
102
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from ...domain.models.diagnostic import Diagnostic, Fix, Severity
|
|
7
|
+
from ...domain.models.document import Document
|
|
8
|
+
from ...domain.models.position import Position, SourceRange
|
|
9
|
+
from ...domain.models.text import TextEdit
|
|
10
|
+
from ...domain.ports.external_linter import ExternalLinter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
LOCATION = re.compile(r'^(?P<source_path>.+?):(?P<line>\d+):(?P<column>\d+)$')
|
|
14
|
+
TIMEOUT_SECONDS = 30
|
|
15
|
+
RULE_ID = 'autocorrect/spacing'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutoCorrectLinter(ExternalLinter):
|
|
19
|
+
name = 'autocorrect'
|
|
20
|
+
|
|
21
|
+
def __init__(self, executable: str = 'autocorrect') -> None:
|
|
22
|
+
self._executable = executable
|
|
23
|
+
|
|
24
|
+
def is_available(self) -> bool:
|
|
25
|
+
return shutil.which(self._executable) is not None
|
|
26
|
+
|
|
27
|
+
def run(self, document: Document) -> tuple[Diagnostic, ...]:
|
|
28
|
+
source_path = pathlib.Path(document.uri.removeprefix('file://'))
|
|
29
|
+
if not self.is_available() or not source_path.is_file():
|
|
30
|
+
return ()
|
|
31
|
+
corrected_text = self._run_autocorrect(source_path)
|
|
32
|
+
if corrected_text is None or corrected_text == document.text:
|
|
33
|
+
return ()
|
|
34
|
+
return self._diff_lines(document, corrected_text)
|
|
35
|
+
|
|
36
|
+
def _run_autocorrect(self, source_path: pathlib.Path) -> str | None:
|
|
37
|
+
try:
|
|
38
|
+
process = subprocess.run( # noqa: S603
|
|
39
|
+
[self._executable, '--stdin', str(source_path)],
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
check=False,
|
|
43
|
+
timeout=TIMEOUT_SECONDS,
|
|
44
|
+
input=source_path.read_text(encoding='utf-8'),
|
|
45
|
+
)
|
|
46
|
+
except (OSError, UnicodeDecodeError, subprocess.TimeoutExpired):
|
|
47
|
+
return None
|
|
48
|
+
return process.stdout if process.stdout else None
|
|
49
|
+
|
|
50
|
+
def _diff_lines(self, document: Document, corrected_text: str) -> tuple[Diagnostic, ...]:
|
|
51
|
+
original_lines = document.text.splitlines(keepends=True)
|
|
52
|
+
corrected_lines = corrected_text.splitlines(keepends=True)
|
|
53
|
+
if len(original_lines) != len(corrected_lines):
|
|
54
|
+
return ()
|
|
55
|
+
diagnostics: list[Diagnostic] = []
|
|
56
|
+
for index, (original, fixed) in enumerate(zip(original_lines, corrected_lines, strict=True)):
|
|
57
|
+
if original == fixed:
|
|
58
|
+
continue
|
|
59
|
+
start_offset = document.positions.to_offset(Position(index, 0))
|
|
60
|
+
end_offset = start_offset + len(original.rstrip('\n').encode('utf-8'))
|
|
61
|
+
diagnostics.append(
|
|
62
|
+
Diagnostic(
|
|
63
|
+
rule_id=RULE_ID,
|
|
64
|
+
range=SourceRange(start_offset, end_offset),
|
|
65
|
+
message='Spacing or punctuation between CJK and Latin text needs correcting.',
|
|
66
|
+
severity=Severity.ERROR,
|
|
67
|
+
fix=Fix.targeted(TextEdit(SourceRange(start_offset, end_offset), fixed.rstrip('\n'))),
|
|
68
|
+
source=self.name,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return tuple(diagnostics)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
from ...domain.models.diagnostic import SEVERITY_BY_NAME, Diagnostic, Fix, Severity
|
|
8
|
+
from ...domain.models.document import Document
|
|
9
|
+
from ...domain.models.position import Position, SourceRange
|
|
10
|
+
from ...domain.ports.external_linter import ExternalLinter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TIMEOUT_SECONDS = 30
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValeAlert(pydantic.BaseModel):
|
|
17
|
+
model_config = pydantic.ConfigDict(frozen=True, extra='ignore')
|
|
18
|
+
|
|
19
|
+
check: str = pydantic.Field(alias='Check')
|
|
20
|
+
message: str = pydantic.Field(alias='Message')
|
|
21
|
+
line: int = pydantic.Field(alias='Line', ge=1)
|
|
22
|
+
span: tuple[int, int] = pydantic.Field(default=(1, 1), alias='Span')
|
|
23
|
+
severity: str = pydantic.Field(default='error', alias='Severity')
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def resolved_severity(self) -> Severity:
|
|
27
|
+
return SEVERITY_BY_NAME.get(self.severity, Severity.ERROR)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ValeReport(pydantic.RootModel[dict[str, list[ValeAlert]]]):
|
|
31
|
+
@classmethod
|
|
32
|
+
def parse(cls, payload: str) -> tuple[ValeAlert, ...]:
|
|
33
|
+
try:
|
|
34
|
+
report = cls.model_validate_json(payload or '{}')
|
|
35
|
+
except pydantic.ValidationError:
|
|
36
|
+
return ()
|
|
37
|
+
return tuple(alert for alerts in report.root.values() for alert in alerts)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ValeLinter(ExternalLinter):
|
|
41
|
+
name = 'vale'
|
|
42
|
+
|
|
43
|
+
def __init__(self, executable: str = 'vale') -> None:
|
|
44
|
+
self._executable = executable
|
|
45
|
+
|
|
46
|
+
def is_available(self) -> bool:
|
|
47
|
+
return shutil.which(self._executable) is not None
|
|
48
|
+
|
|
49
|
+
def run(self, document: Document) -> tuple[Diagnostic, ...]:
|
|
50
|
+
source_path = pathlib.Path(document.uri.removeprefix('file://'))
|
|
51
|
+
if not self.is_available() or not source_path.is_file():
|
|
52
|
+
return ()
|
|
53
|
+
try:
|
|
54
|
+
process = subprocess.run( # noqa: S603
|
|
55
|
+
[self._executable, '--no-exit', '--output=JSON', str(source_path)],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
check=False,
|
|
59
|
+
timeout=TIMEOUT_SECONDS,
|
|
60
|
+
)
|
|
61
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
62
|
+
return ()
|
|
63
|
+
return self._decode(document, process.stdout)
|
|
64
|
+
|
|
65
|
+
def _decode(self, document: Document, payload: str) -> tuple[Diagnostic, ...]:
|
|
66
|
+
diagnostics: list[Diagnostic] = []
|
|
67
|
+
for alert in ValeReport.parse(payload):
|
|
68
|
+
diagnostic = self._to_diagnostic(document, alert)
|
|
69
|
+
if diagnostic is not None:
|
|
70
|
+
diagnostics.append(diagnostic)
|
|
71
|
+
return tuple(diagnostics)
|
|
72
|
+
|
|
73
|
+
def _to_diagnostic(self, document: Document, alert: ValeAlert) -> Diagnostic | None:
|
|
74
|
+
start_column, end_column = alert.span
|
|
75
|
+
try:
|
|
76
|
+
start_offset = document.positions.to_offset(Position(alert.line - 1, start_column - 1))
|
|
77
|
+
end_offset = document.positions.to_offset(Position(alert.line - 1, end_column))
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
return Diagnostic(
|
|
81
|
+
rule_id=alert.check,
|
|
82
|
+
range=SourceRange(start_offset, max(start_offset, end_offset)),
|
|
83
|
+
message=alert.message,
|
|
84
|
+
severity=alert.resolved_severity,
|
|
85
|
+
fix=Fix.rewrite(),
|
|
86
|
+
source=self.name,
|
|
87
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .languages import PYTHON, GrammarAdapter, LanguageProfile, NodeRole, PythonProfile
|
|
2
|
+
from .tree_sitter_parser import TreeSitterParser
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
'PYTHON',
|
|
7
|
+
'GrammarAdapter',
|
|
8
|
+
'LanguageProfile',
|
|
9
|
+
'NodeRole',
|
|
10
|
+
'PythonProfile',
|
|
11
|
+
'TreeSitterParser',
|
|
12
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import enum
|
|
3
|
+
|
|
4
|
+
from ....domain.models.comment import SymbolKind
|
|
5
|
+
from ....domain.ports.language_conventions import LanguageConventions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NodeRole(enum.Enum):
|
|
9
|
+
ROOT = 'root'
|
|
10
|
+
DEFINITION = 'definition'
|
|
11
|
+
COMMENT = 'comment'
|
|
12
|
+
OTHER = 'other'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GrammarAdapter(abc.ABC):
|
|
16
|
+
@abc.abstractmethod
|
|
17
|
+
def query(self) -> str: ...
|
|
18
|
+
|
|
19
|
+
@abc.abstractmethod
|
|
20
|
+
def role_of(self, node_type: str) -> NodeRole: ...
|
|
21
|
+
|
|
22
|
+
@abc.abstractmethod
|
|
23
|
+
def symbol_kind(self, node_type: str) -> SymbolKind: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LanguageProfile(LanguageConventions, GrammarAdapter): ...
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from ....domain.models.comment import CommentForm, SymbolKind, Visibility
|
|
4
|
+
from ....domain.ports.language_conventions import DelimiterSplit
|
|
5
|
+
from .language_profile import LanguageProfile, NodeRole
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_DOC_DELIMITER = re.compile(r'^([rRbBuUfF]{0,2})("""|\'\'\')')
|
|
9
|
+
_HASH_DELIMITER = re.compile(r'^(#+\s?)')
|
|
10
|
+
|
|
11
|
+
QUERY = """
|
|
12
|
+
(comment) @comment
|
|
13
|
+
(module . (string) @doc)
|
|
14
|
+
(function_definition body: (block . (string) @doc))
|
|
15
|
+
(class_definition body: (block . (string) @doc))
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PythonProfile(LanguageProfile):
|
|
20
|
+
language_id = 'python'
|
|
21
|
+
extensions = frozenset({'.py', '.pyi'})
|
|
22
|
+
doc_delimiter = '"""'
|
|
23
|
+
signature_tags = ('Args:', 'Returns:', 'Raises:', 'Yields:', ':param', ':return', ':rtype')
|
|
24
|
+
_ROLES = {
|
|
25
|
+
'module': NodeRole.ROOT,
|
|
26
|
+
'comment': NodeRole.COMMENT,
|
|
27
|
+
'function_definition': NodeRole.DEFINITION,
|
|
28
|
+
'class_definition': NodeRole.DEFINITION,
|
|
29
|
+
'decorated_definition': NodeRole.DEFINITION,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def query(self) -> str:
|
|
33
|
+
return QUERY
|
|
34
|
+
|
|
35
|
+
def role_of(self, node_type: str) -> NodeRole:
|
|
36
|
+
return self._ROLES.get(node_type, NodeRole.OTHER)
|
|
37
|
+
|
|
38
|
+
def symbol_kind(self, node_type: str) -> SymbolKind:
|
|
39
|
+
return SymbolKind.CLASS if node_type == 'class_definition' else SymbolKind.FUNCTION
|
|
40
|
+
|
|
41
|
+
def visibility_of(self, name: str) -> Visibility:
|
|
42
|
+
return Visibility.INTERNAL if name.startswith('_') else Visibility.PUBLIC
|
|
43
|
+
|
|
44
|
+
def split_delimiter(self, line: str, form: CommentForm) -> DelimiterSplit:
|
|
45
|
+
indent = line[: len(line) - len(line.lstrip())]
|
|
46
|
+
rest = line[len(indent) :]
|
|
47
|
+
if form is CommentForm.DOC:
|
|
48
|
+
delimiter, text, suffix = self._split_doc(rest)
|
|
49
|
+
return DelimiterSplit(indent=indent, delimiter=delimiter, text=text, suffix=suffix)
|
|
50
|
+
hash_delimiter = _HASH_DELIMITER.match(rest)
|
|
51
|
+
if hash_delimiter is None:
|
|
52
|
+
return DelimiterSplit(indent=indent, delimiter='', text=rest.rstrip())
|
|
53
|
+
return DelimiterSplit(
|
|
54
|
+
indent=indent, delimiter=hash_delimiter.group(1), text=rest[hash_delimiter.end() :].rstrip()
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _split_doc(self, rest: str) -> tuple[str, str, str]:
|
|
58
|
+
opening = _DOC_DELIMITER.match(rest)
|
|
59
|
+
delimiter = opening.group(0) if opening else ''
|
|
60
|
+
body = rest[len(delimiter) :]
|
|
61
|
+
delimiter = opening.group(2) if opening else ''
|
|
62
|
+
suffix = ''
|
|
63
|
+
for candidate in (delimiter, '"""', "'''"):
|
|
64
|
+
if candidate and body.endswith(candidate):
|
|
65
|
+
suffix = candidate
|
|
66
|
+
body = body[: -len(candidate)]
|
|
67
|
+
break
|
|
68
|
+
return delimiter, body.rstrip(), suffix
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
PYTHON = PythonProfile()
|