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,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,11 @@
1
+ from .config_schema import ConfigFile, RuleTable, SubeditSection
2
+ from .toml_config_source import CONFIG_NAME, TomlConfigSource
3
+
4
+
5
+ __all__ = [
6
+ 'CONFIG_NAME',
7
+ 'ConfigFile',
8
+ 'RuleTable',
9
+ 'SubeditSection',
10
+ 'TomlConfigSource',
11
+ ]
@@ -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,5 @@
1
+ from .autocorrect_linter import AutoCorrectLinter
2
+ from .vale_linter import ValeLinter
3
+
4
+
5
+ __all__ = ['AutoCorrectLinter', 'ValeLinter']
@@ -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,11 @@
1
+ from .language_profile import GrammarAdapter, LanguageProfile, NodeRole
2
+ from .python_profile import PYTHON, PythonProfile
3
+
4
+
5
+ __all__ = [
6
+ 'PYTHON',
7
+ 'GrammarAdapter',
8
+ 'LanguageProfile',
9
+ 'NodeRole',
10
+ 'PythonProfile',
11
+ ]
@@ -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()