subedit 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → subedit-0.3.0}/PKG-INFO +8 -6
- {subedit-0.2.0 → subedit-0.3.0}/README.md +7 -5
- {subedit-0.2.0 → subedit-0.3.0}/plugins/subedit/.claude-plugin/plugin.json +1 -1
- {subedit-0.2.0 → subedit-0.3.0}/plugins/subedit/scripts/run-subedit-hook.sh +1 -1
- {subedit-0.2.0 → subedit-0.3.0}/pyproject.toml +1 -1
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/__init__.py +1 -1
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/linter.py +10 -3
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/comment.py +8 -1
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/diagnostic.py +5 -2
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/prose.py +7 -4
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/prose_map.py +5 -2
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/text.py +13 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/source_parser.py +5 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/rules/rewrite.py +7 -1
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/rules/structure.py +14 -5
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/__init__.py +4 -2
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/__init__.py +3 -0
- subedit-0.3.0/src/subedit/infrastructure/config_sources/formatter_width.py +92 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/toml_config_source.py +6 -5
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/linters/autocorrect_linter.py +3 -3
- subedit-0.3.0/src/subedit/infrastructure/parsers/languages/code_line.py +52 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/language_profile.py +7 -2
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/python_profile.py +57 -2
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/typescript_profile.py +45 -9
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +56 -14
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/cli.py +4 -3
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/Ellipsis.yml +2 -1
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/ExternalRef.yml +1 -1
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_cli.py +14 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_config.py +48 -1
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_diagnostic.py +9 -0
- subedit-0.3.0/tests/test_language_parity.py +126 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_parser.py +1 -1
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_parser_oracle.py +4 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_report_aggregator.py +34 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_text.py +8 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_typescript.py +105 -3
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_vale_style.py +24 -0
- {subedit-0.2.0 → subedit-0.3.0}/uv.lock +1 -1
- {subedit-0.2.0 → subedit-0.3.0}/.claude/settings.json +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/.claude-plugin/marketplace.json +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/.github/dependabot.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/.github/release.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/.github/workflows/ci.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/.github/workflows/pr.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/.github/workflows/release.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/.gitignore +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/.vale.ini +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/LICENSE +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/docs/rules.md +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/examples/README.md +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/examples/probe.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/examples/subedit.toml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/examples/walkthrough.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/plugins/subedit/hooks/hooks.json +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/plugins/subedit/skills/judging-findings/SKILL.md +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/corpus_meter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/fixer.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/report_aggregator.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/comment_layout.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/document.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/position.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/rule_set.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/external_linter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/language_conventions.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/rule.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/rules/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/rules/layout.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/config_schema.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/linters/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/linters/source_path.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/linters/vale_linter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/harnesses/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/hook.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/report.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/rule_reference.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/DateRef.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/EditHistory.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/EmDash.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/MidSentenceColon.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/MidSentenceColonMarkup.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/Semicolon.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/SignatureTag.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/vale.ini +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/subedit.toml +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/fixtures/autocorrect/unspaced.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/fixtures/vale/clean.md +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/fixtures/vale/violations.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_comment.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_corpus_meter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_fixer.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_harnesses.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_hook.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_linter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_parser_ocp.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_plugin.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_position.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_prose.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_rules_layout.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_rules_rewrite.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_rules_structure.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_schema.py +0 -0
- {subedit-0.2.0 → subedit-0.3.0}/tests/test_self_hosting.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: subedit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A linter and formatter for the prose inside code comments, across languages.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mroops0111/subedit
|
|
6
6
|
Project-URL: Repository, https://github.com/mroops0111/subedit
|
|
@@ -151,13 +151,15 @@ Registering the hook with both agents is the normal case. Each agent names its e
|
|
|
151
151
|
|
|
152
152
|
## Languages
|
|
153
153
|
|
|
154
|
-
| Language | Files | Doc comment |
|
|
154
|
+
| Language | Files | Doc comment | Width read from |
|
|
155
155
|
| --- | --- | --- | --- |
|
|
156
|
-
| Python | `.py`, `.pyi` | `"""` docstring |
|
|
157
|
-
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc |
|
|
156
|
+
| Python | `.py`, `.pyi` | `"""` docstring | `line-length` for ruff or black |
|
|
157
|
+
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | `printWidth` for Prettier, `lineWidth` for Biome |
|
|
158
158
|
|
|
159
159
|
A TypeScript symbol is public when it is exported, and a class member stays public unless it is `private`, `protected`, or `#named`.
|
|
160
|
-
A
|
|
160
|
+
A plain comment opening a paragraph over several declarations heads them as a group, so it documents none of them. One right under another declaration may be either, so `plain-comment-on-public` reports it as a warning.
|
|
161
|
+
A JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written, as a docstring's sections from `Args:`, `Parameters`, or `:param` onward are.
|
|
162
|
+
A comment line that the language's own grammar reads as code, such as `// return result` or `# client.send(`, is left as written in either language.
|
|
161
163
|
A plain `/* */` block and a directive such as `// eslint-disable-next-line` carry no prose for subedit to lay out, so only Vale reads the block, and nothing reads the directive.
|
|
162
164
|
A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
|
|
163
165
|
|
|
@@ -175,7 +177,7 @@ mid-clause-break = "error"
|
|
|
175
177
|
block-too-long = { severity = "warning", line = 3, doc-public = 20 }
|
|
176
178
|
```
|
|
177
179
|
|
|
178
|
-
Left unset, `line-width`
|
|
180
|
+
Left unset, `line-width` follows the project's formatter, from the config the formatter would read for that file, and is 120 where no formatter sets one.
|
|
179
181
|
|
|
180
182
|
Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
|
|
181
183
|
|
|
@@ -117,13 +117,15 @@ Registering the hook with both agents is the normal case. Each agent names its e
|
|
|
117
117
|
|
|
118
118
|
## Languages
|
|
119
119
|
|
|
120
|
-
| Language | Files | Doc comment |
|
|
120
|
+
| Language | Files | Doc comment | Width read from |
|
|
121
121
|
| --- | --- | --- | --- |
|
|
122
|
-
| Python | `.py`, `.pyi` | `"""` docstring |
|
|
123
|
-
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc |
|
|
122
|
+
| Python | `.py`, `.pyi` | `"""` docstring | `line-length` for ruff or black |
|
|
123
|
+
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | `printWidth` for Prettier, `lineWidth` for Biome |
|
|
124
124
|
|
|
125
125
|
A TypeScript symbol is public when it is exported, and a class member stays public unless it is `private`, `protected`, or `#named`.
|
|
126
|
-
A
|
|
126
|
+
A plain comment opening a paragraph over several declarations heads them as a group, so it documents none of them. One right under another declaration may be either, so `plain-comment-on-public` reports it as a warning.
|
|
127
|
+
A JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written, as a docstring's sections from `Args:`, `Parameters`, or `:param` onward are.
|
|
128
|
+
A comment line that the language's own grammar reads as code, such as `// return result` or `# client.send(`, is left as written in either language.
|
|
127
129
|
A plain `/* */` block and a directive such as `// eslint-disable-next-line` carry no prose for subedit to lay out, so only Vale reads the block, and nothing reads the directive.
|
|
128
130
|
A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
|
|
129
131
|
|
|
@@ -141,7 +143,7 @@ mid-clause-break = "error"
|
|
|
141
143
|
block-too-long = { severity = "warning", line = 3, doc-public = 20 }
|
|
142
144
|
```
|
|
143
145
|
|
|
144
|
-
Left unset, `line-width`
|
|
146
|
+
Left unset, `line-width` follows the project's formatter, from the config the formatter would read for that file, and is 120 where no formatter sets one.
|
|
145
147
|
|
|
146
148
|
Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
|
|
147
149
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# because the first run installs subedit and downloads Vale, which no edit should wait on.
|
|
5
5
|
set -uo pipefail
|
|
6
6
|
|
|
7
|
-
SPEC='subedit[vale]>=0.
|
|
7
|
+
SPEC='subedit[vale]>=0.3.0'
|
|
8
8
|
# A bare uvx takes the first Python it finds, which on a stock Mac is too old for subedit.
|
|
9
9
|
PYTHON='>=3.11'
|
|
10
10
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import dataclasses
|
|
2
2
|
|
|
3
3
|
from ..domain.models.comment import CommentGroup
|
|
4
|
-
from ..domain.models.diagnostic import Diagnostic, Report
|
|
4
|
+
from ..domain.models.diagnostic import Diagnostic, Report, Severity
|
|
5
5
|
from ..domain.models.document import Document
|
|
6
6
|
from ..domain.models.prose_map import ProseMap
|
|
7
7
|
from ..domain.models.rule_set import RuleSet
|
|
@@ -27,7 +27,13 @@ class RuleEngine:
|
|
|
27
27
|
return tuple(diagnostics)
|
|
28
28
|
|
|
29
29
|
def _with_severity(self, diagnostic: Diagnostic, rule: Rule, rule_set: RuleSet) -> Diagnostic:
|
|
30
|
-
|
|
30
|
+
configured = rule_set.settings_for(rule.metadata.rule_id).severity
|
|
31
|
+
if configured is not None:
|
|
32
|
+
severity = configured
|
|
33
|
+
elif diagnostic.tentative:
|
|
34
|
+
severity = Severity.WARNING
|
|
35
|
+
else:
|
|
36
|
+
severity = rule.metadata.default_severity
|
|
31
37
|
if diagnostic.severity is severity:
|
|
32
38
|
return diagnostic
|
|
33
39
|
return dataclasses.replace(diagnostic, severity=severity)
|
|
@@ -64,4 +70,5 @@ class Linter:
|
|
|
64
70
|
groups = self._parser.parse(document)
|
|
65
71
|
diagnostics = self._engine.run(document, groups, rule_set)
|
|
66
72
|
sorted_diagnostics = sorted(diagnostics, key=lambda item: (item.range.start, item.rule_id))
|
|
67
|
-
|
|
73
|
+
prose_map = ProseMap.of_comments(groups, self._parser.directive_ranges(document))
|
|
74
|
+
return Inspection(Report(tuple(sorted_diagnostics)), prose_map)
|
|
@@ -37,6 +37,8 @@ class SymbolRef:
|
|
|
37
37
|
name: str
|
|
38
38
|
kind: SymbolKind
|
|
39
39
|
visibility: Visibility
|
|
40
|
+
# A comment over several declarations, right under code, may head them as a group rather than document this one.
|
|
41
|
+
may_head_a_group: bool = False
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
@@ -102,7 +104,12 @@ class CommentGroup:
|
|
|
102
104
|
texts = self._reflow_into_texts(width, stub_floor, closing_width)
|
|
103
105
|
if not texts:
|
|
104
106
|
return self
|
|
105
|
-
|
|
107
|
+
rebuilt = dataclasses.replace(self, lines=self.layout.rebuild(self.lines, texts, width))
|
|
108
|
+
# A sentence with no legal break may come out longer than the author wrapped it,
|
|
109
|
+
# and a fix that pushes a line past the width trades one finding for another.
|
|
110
|
+
if rebuilt.longest_line > max(width, self.longest_line):
|
|
111
|
+
return self
|
|
112
|
+
return rebuilt
|
|
106
113
|
|
|
107
114
|
def sentences_held_by_a_list(self, width: int, stub_floor: int = 0) -> tuple[str, ...]:
|
|
108
115
|
"""The sentences reflow leaves as the author broke them, since every legal break would split a list."""
|
|
@@ -69,6 +69,8 @@ class Diagnostic:
|
|
|
69
69
|
severity: Severity = Severity.ERROR
|
|
70
70
|
fix: Fix | None = None
|
|
71
71
|
source: str = 'subedit'
|
|
72
|
+
# A finding the rule may have read wrong reaches the author as advice, unless the project sets its severity.
|
|
73
|
+
tentative: bool = False
|
|
72
74
|
|
|
73
75
|
def __post_init__(self) -> None:
|
|
74
76
|
if not self.message:
|
|
@@ -121,10 +123,11 @@ class Report:
|
|
|
121
123
|
)
|
|
122
124
|
|
|
123
125
|
def merged_with(self, other: 'Report') -> 'Report':
|
|
124
|
-
seen_keys: set[tuple[str, int, int]] = set()
|
|
126
|
+
seen_keys: set[tuple[str, int, int, str]] = set()
|
|
125
127
|
merged_diagnostics: list[Diagnostic] = []
|
|
126
128
|
for diagnostic in (*self.diagnostics, *other.diagnostics):
|
|
127
|
-
|
|
129
|
+
# One rule can find several sentences in one block, and each finding carries its own message.
|
|
130
|
+
key = (diagnostic.rule_id, diagnostic.range.start, diagnostic.range.end, diagnostic.message)
|
|
128
131
|
if key in seen_keys:
|
|
129
132
|
continue
|
|
130
133
|
seen_keys.add(key)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import dataclasses
|
|
2
2
|
import itertools
|
|
3
3
|
import re
|
|
4
|
+
import typing
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
# Abbreviations whose period does not end a sentence, written without that period.
|
|
@@ -60,12 +61,12 @@ class ProseMarkup:
|
|
|
60
61
|
"""What a language writes in its comments besides prose, which reflow leaves as written.
|
|
61
62
|
|
|
62
63
|
A tag section, such as JSDoc's from its first @throws on, runs to the end of the comment.
|
|
63
|
-
An example tag's body is code, and so is a line
|
|
64
|
+
An example tag's body is code, and so is a line that the language's grammar reads as code commented out.
|
|
64
65
|
"""
|
|
65
66
|
|
|
66
67
|
tag_section: re.Pattern[str] | None = None
|
|
67
68
|
example_tag: str | None = None
|
|
68
|
-
code_line:
|
|
69
|
+
code_line: typing.Callable[[str], bool] | None = None
|
|
69
70
|
|
|
70
71
|
def opens_tag_section(self, line: str) -> bool:
|
|
71
72
|
return self.tag_section is not None and bool(self.tag_section.match(line))
|
|
@@ -74,7 +75,7 @@ class ProseMarkup:
|
|
|
74
75
|
return self.example_tag is not None and line.strip().startswith(self.example_tag)
|
|
75
76
|
|
|
76
77
|
def is_code(self, line: str) -> bool:
|
|
77
|
-
return self.code_line is not None and
|
|
78
|
+
return self.code_line is not None and self.code_line(line)
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
PLAIN_MARKUP = ProseMarkup()
|
|
@@ -167,8 +168,10 @@ class Prose:
|
|
|
167
168
|
indexes.add(index)
|
|
168
169
|
return frozenset(indexes)
|
|
169
170
|
|
|
171
|
+
# A line set two spaces past the margin, such as a tree diagram or an aligned table, is laid out by hand.
|
|
172
|
+
# Markdown wants four for a code block, but a comment's prefix already takes the space a writer would count.
|
|
170
173
|
def _is_indented(self, line: str) -> bool:
|
|
171
|
-
return line.startswith(('
|
|
174
|
+
return line.startswith((' ', '\t'))
|
|
172
175
|
|
|
173
176
|
# A wrapped list item is indented under its marker rather than to a fixed width,
|
|
174
177
|
# so the continuation belongs to the item instead of opening a paragraph.
|
|
@@ -15,11 +15,14 @@ class ProseMap:
|
|
|
15
15
|
|
|
16
16
|
editable: tuple[SourceRange, ...] = ()
|
|
17
17
|
code: tuple[SourceRange, ...] = ()
|
|
18
|
+
# A directive's text, such as the reason after @ts-expect-error, is read by a tool, so nothing reads it as prose.
|
|
19
|
+
directives: tuple[SourceRange, ...] = ()
|
|
18
20
|
|
|
19
21
|
@classmethod
|
|
20
|
-
def of_comments(cls, groups: tuple[CommentGroup, ...]) -> 'ProseMap':
|
|
22
|
+
def of_comments(cls, groups: tuple[CommentGroup, ...], directives: tuple[SourceRange, ...] = ()) -> 'ProseMap':
|
|
21
23
|
"""A trailing comment shares its line with code, so only a comment standing on its own lines is editable."""
|
|
22
24
|
return cls(
|
|
25
|
+
directives=directives,
|
|
23
26
|
editable=tuple(
|
|
24
27
|
line.range
|
|
25
28
|
for group in groups
|
|
@@ -31,7 +34,7 @@ class ProseMap:
|
|
|
31
34
|
|
|
32
35
|
def admits(self, diagnostic: Diagnostic) -> bool:
|
|
33
36
|
"""Whether a finding reads prose rather than code, and any edit it offers stays on lines of prose."""
|
|
34
|
-
if any(
|
|
37
|
+
if any(region.start <= diagnostic.range.start < region.end for region in (*self.code, *self.directives)):
|
|
35
38
|
return False
|
|
36
39
|
if diagnostic.fix is None:
|
|
37
40
|
return True
|
|
@@ -10,6 +10,19 @@ class TextEdit:
|
|
|
10
10
|
new_text: str
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def source_lines(text: str) -> list[str]:
|
|
14
|
+
"""The lines a source file holds, each with its newline, split where a newline falls and nowhere else.
|
|
15
|
+
|
|
16
|
+
str.splitlines also splits at characters such as a form feed or a record separator,
|
|
17
|
+
which leave a line's number out of step with what an editor, patch, and PositionMap count.
|
|
18
|
+
"""
|
|
19
|
+
pieces = text.split('\n')
|
|
20
|
+
lines = [piece + '\n' for piece in pieces[:-1]]
|
|
21
|
+
if pieces[-1]:
|
|
22
|
+
lines.append(pieces[-1])
|
|
23
|
+
return lines
|
|
24
|
+
|
|
25
|
+
|
|
13
26
|
def apply_edits(text: str, edits: tuple[TextEdit, ...]) -> str:
|
|
14
27
|
if not edits:
|
|
15
28
|
return text
|
|
@@ -2,6 +2,7 @@ import abc
|
|
|
2
2
|
|
|
3
3
|
from ..models.comment import CommentGroup
|
|
4
4
|
from ..models.document import Document
|
|
5
|
+
from ..models.position import SourceRange
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class SourceParser(abc.ABC):
|
|
@@ -10,3 +11,7 @@ class SourceParser(abc.ABC):
|
|
|
10
11
|
|
|
11
12
|
@abc.abstractmethod
|
|
12
13
|
def parse(self, document: Document) -> tuple[CommentGroup, ...]: ...
|
|
14
|
+
|
|
15
|
+
def directive_ranges(self, document: Document) -> tuple[SourceRange, ...]:
|
|
16
|
+
"""Where a comment holds a directive such as eslint-disable or noqa, which a tool reads instead of a person."""
|
|
17
|
+
return ()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import typing
|
|
2
2
|
|
|
3
|
-
from ..models.comment import CommentGroup
|
|
3
|
+
from ..models.comment import CommentGroup, CommentPlacement
|
|
4
4
|
from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata, Severity
|
|
5
5
|
from ..models.prose import Prose, reflow_sentence
|
|
6
6
|
from ..ports.rule import Rule, RuleContext
|
|
@@ -60,6 +60,9 @@ class StubFragmentRule(Rule):
|
|
|
60
60
|
metadata = STUB_FRAGMENT
|
|
61
61
|
|
|
62
62
|
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
63
|
+
# A trailing comment never breaks, so a rule judging where a sentence breaks leaves it to line-too-long.
|
|
64
|
+
if group.placement is CommentPlacement.TRAILING:
|
|
65
|
+
return
|
|
63
66
|
minimum_length = stub_floor(context)
|
|
64
67
|
for index, (sentence, budget) in enumerate(budgeted_sentences(group, context)):
|
|
65
68
|
if is_summary(group, index):
|
|
@@ -87,6 +90,9 @@ class UnbreakableSentenceRule(Rule):
|
|
|
87
90
|
metadata = UNBREAKABLE_SENTENCE
|
|
88
91
|
|
|
89
92
|
def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
|
|
93
|
+
# A trailing comment never breaks, so its width is line-too-long's to judge.
|
|
94
|
+
if group.placement is CommentPlacement.TRAILING:
|
|
95
|
+
return
|
|
90
96
|
sentences = budgeted_sentences(group, context)
|
|
91
97
|
for index, (sentence, budget) in enumerate(sentences):
|
|
92
98
|
if is_summary(group, index):
|
|
@@ -70,15 +70,24 @@ class PlainCommentOnPublicRule(Rule):
|
|
|
70
70
|
if conventions is None:
|
|
71
71
|
return
|
|
72
72
|
delimiter = conventions.doc_delimiter
|
|
73
|
-
|
|
73
|
+
attachment = group.attachment
|
|
74
|
+
name = attachment.name if attachment else 'the symbol'
|
|
75
|
+
message = (
|
|
76
|
+
f'{name} is public but documented with a plain comment. '
|
|
77
|
+
f'Move the text into a {delimiter} doc comment so tooling renders it on hover.'
|
|
78
|
+
)
|
|
79
|
+
may_head_a_group = attachment is not None and attachment.may_head_a_group
|
|
80
|
+
if may_head_a_group:
|
|
81
|
+
message += (
|
|
82
|
+
f' If it heads the declarations below it as a group rather than describing {name}, leave it, '
|
|
83
|
+
'or set it apart with a blank line above it.'
|
|
84
|
+
)
|
|
74
85
|
yield Diagnostic(
|
|
75
86
|
rule_id=self.metadata.rule_id,
|
|
76
87
|
range=group.range,
|
|
77
|
-
message=
|
|
78
|
-
f'{name} is public but documented with a plain comment. '
|
|
79
|
-
f'Move the text into a {delimiter} doc comment so tooling renders it on hover.'
|
|
80
|
-
),
|
|
88
|
+
message=message,
|
|
81
89
|
fix=Fix.rewrite(),
|
|
90
|
+
tentative=may_head_a_group,
|
|
82
91
|
)
|
|
83
92
|
|
|
84
93
|
|
|
@@ -10,7 +10,7 @@ from ..domain.rules import (
|
|
|
10
10
|
StubFragmentRule,
|
|
11
11
|
UnbreakableSentenceRule,
|
|
12
12
|
)
|
|
13
|
-
from .config_sources import CONFIG_NAME, TomlConfigSource
|
|
13
|
+
from .config_sources import CONFIG_NAME, FormatterWidths, TomlConfigSource
|
|
14
14
|
from .linters import AutoCorrectLinter, ValeLinter
|
|
15
15
|
from .parsers import PYTHON, TSX, TYPESCRIPT, LanguageProfile, NodeRole, TreeSitterParser
|
|
16
16
|
|
|
@@ -38,7 +38,9 @@ ALL_RULES = LAYOUT_RULES + REWRITE_RULES + STRUCTURAL_RULES
|
|
|
38
38
|
DEFAULT_PARSER = TreeSitterParser(LANGUAGES)
|
|
39
39
|
DEFAULT_CONFIG_SOURCE = TomlConfigSource(
|
|
40
40
|
tuple(rule.metadata.rule_id for rule in ALL_RULES),
|
|
41
|
-
|
|
41
|
+
FormatterWidths(
|
|
42
|
+
{extension: language.width_settings for language in LANGUAGES for extension in language.extensions}
|
|
43
|
+
),
|
|
42
44
|
)
|
|
43
45
|
|
|
44
46
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from .config_schema import ConfigFile, RuleTable, SubeditSection
|
|
2
|
+
from .formatter_width import FormatterWidths, WidthSetting
|
|
2
3
|
from .toml_config_source import CONFIG_NAME, TomlConfigSource
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
__all__ = [
|
|
6
7
|
'CONFIG_NAME',
|
|
7
8
|
'ConfigFile',
|
|
9
|
+
'FormatterWidths',
|
|
8
10
|
'RuleTable',
|
|
9
11
|
'SubeditSection',
|
|
10
12
|
'TomlConfigSource',
|
|
13
|
+
'WidthSetting',
|
|
11
14
|
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import json
|
|
3
|
+
import pathlib
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# A string is matched first, so a URL inside one, as a $schema value holds, keeps its slashes.
|
|
10
|
+
_JSON_COMMENT = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', re.DOTALL)
|
|
11
|
+
_TRAILING_COMMA = re.compile(r',(\s*[}\]])')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _without_comments(text: str) -> str:
|
|
15
|
+
"""JSON with the comments and trailing commas that JSONC allows taken out, as biome.jsonc writes it."""
|
|
16
|
+
return _TRAILING_COMMA.sub(r'\1', _JSON_COMMENT.sub(lambda found: found.group(1) or '', text))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
20
|
+
class WidthSetting:
|
|
21
|
+
"""Where a formatter keeps its line width, such as line-length under [tool.ruff] in pyproject.toml."""
|
|
22
|
+
|
|
23
|
+
file_name: str
|
|
24
|
+
key_path: tuple[str, ...]
|
|
25
|
+
|
|
26
|
+
def read(self, directory: pathlib.Path) -> int | None:
|
|
27
|
+
config_path = directory / self.file_name
|
|
28
|
+
if not config_path.is_file():
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
text = config_path.read_text(encoding='utf-8')
|
|
32
|
+
except OSError:
|
|
33
|
+
return None
|
|
34
|
+
return self._width_in(self._parse(text, config_path.suffix))
|
|
35
|
+
|
|
36
|
+
def _parse(self, text: str, suffix: str) -> object:
|
|
37
|
+
if suffix == '.toml':
|
|
38
|
+
try:
|
|
39
|
+
return tomllib.loads(text)
|
|
40
|
+
except tomllib.TOMLDecodeError:
|
|
41
|
+
return None
|
|
42
|
+
for candidate in (text, _without_comments(text)):
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(candidate)
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
continue
|
|
47
|
+
return self._parse_flat(text)
|
|
48
|
+
|
|
49
|
+
# A YAML .prettierrc holds a top-level key on a line of its own.
|
|
50
|
+
def _parse_flat(self, text: str) -> object:
|
|
51
|
+
if len(self.key_path) != 1:
|
|
52
|
+
return None
|
|
53
|
+
(key,) = self.key_path
|
|
54
|
+
found = re.search(rf'^{re.escape(key)}\s*:\s*(\d+)\s*$', text, re.MULTILINE)
|
|
55
|
+
return {key: int(found.group(1))} if found else None
|
|
56
|
+
|
|
57
|
+
def _width_in(self, table: object) -> int | None:
|
|
58
|
+
for key in self.key_path:
|
|
59
|
+
if not isinstance(table, dict):
|
|
60
|
+
return None
|
|
61
|
+
table = typing.cast(dict[str, object], table).get(key)
|
|
62
|
+
return table if isinstance(table, int) and not isinstance(table, bool) and table > 0 else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FormatterWidths:
|
|
66
|
+
"""The line width a project's own formatter sets, found the way the formatter finds its config.
|
|
67
|
+
|
|
68
|
+
The search climbs from the file toward the root, and the nearest directory that sets a width wins.
|
|
69
|
+
A formatter left at its own default sets nothing here, so the width falls back to subedit's default.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, settings_by_suffix: typing.Mapping[str, tuple[WidthSetting, ...]]) -> None:
|
|
73
|
+
self._settings_by_suffix = dict(settings_by_suffix)
|
|
74
|
+
self._widths: dict[tuple[pathlib.Path, tuple[WidthSetting, ...]], int | None] = {}
|
|
75
|
+
|
|
76
|
+
def width_for(self, path: str) -> int | None:
|
|
77
|
+
settings = self._settings_by_suffix.get(pathlib.Path(path).suffix)
|
|
78
|
+
if not settings:
|
|
79
|
+
return None
|
|
80
|
+
start = pathlib.Path(path)
|
|
81
|
+
directory = (start if start.is_dir() else start.parent).resolve()
|
|
82
|
+
if (directory, settings) not in self._widths:
|
|
83
|
+
self._widths[directory, settings] = self._width_from(directory, settings)
|
|
84
|
+
return self._widths[directory, settings]
|
|
85
|
+
|
|
86
|
+
def _width_from(self, directory: pathlib.Path, settings: tuple[WidthSetting, ...]) -> int | None:
|
|
87
|
+
for candidate in (directory, *directory.parents):
|
|
88
|
+
for setting in settings:
|
|
89
|
+
width = setting.read(candidate)
|
|
90
|
+
if width is not None:
|
|
91
|
+
return width
|
|
92
|
+
return None
|
{subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/toml_config_source.py
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fnmatch
|
|
2
2
|
import pathlib
|
|
3
3
|
import tomllib
|
|
4
|
-
import typing
|
|
5
4
|
|
|
6
5
|
from ...domain.models.rule_set import DEFAULT_LINE_WIDTH, RuleSet, RuleSettings
|
|
7
6
|
from .config_schema import ConfigFile, RuleTable
|
|
7
|
+
from .formatter_width import FormatterWidths
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
CONFIG_NAME = 'subedit.toml'
|
|
@@ -15,11 +15,11 @@ class TomlConfigSource:
|
|
|
15
15
|
def __init__(
|
|
16
16
|
self,
|
|
17
17
|
available_rule_ids: tuple[str, ...],
|
|
18
|
-
|
|
18
|
+
formatter_widths: FormatterWidths | None = None,
|
|
19
19
|
fallback_width: int = DEFAULT_LINE_WIDTH,
|
|
20
20
|
) -> None:
|
|
21
21
|
self._available_rule_ids = available_rule_ids
|
|
22
|
-
self.
|
|
22
|
+
self._formatter_widths = formatter_widths or FormatterWidths({})
|
|
23
23
|
self._fallback_width = fallback_width
|
|
24
24
|
|
|
25
25
|
def resolve(self, path: str) -> RuleSet:
|
|
@@ -32,8 +32,9 @@ class TomlConfigSource:
|
|
|
32
32
|
return RuleSet(enabled_rule_ids=frozenset(self._available_rule_ids), line_width=self.default_width(path))
|
|
33
33
|
|
|
34
34
|
def default_width(self, path: str) -> int:
|
|
35
|
-
"""The width a file gets when
|
|
36
|
-
|
|
35
|
+
"""The width a file gets when subedit's config sets none, which the project's formatter sets if it has one."""
|
|
36
|
+
formatter_width = self._formatter_widths.width_for(path)
|
|
37
|
+
return self._fallback_width if formatter_width is None else formatter_width
|
|
37
38
|
|
|
38
39
|
def exclude_patterns(self, path: str) -> tuple[str, ...]:
|
|
39
40
|
config_file = self._read(path)
|
|
@@ -6,7 +6,7 @@ import autocorrect_py
|
|
|
6
6
|
from ...domain.models.diagnostic import Diagnostic, Fix, Severity
|
|
7
7
|
from ...domain.models.document import Document
|
|
8
8
|
from ...domain.models.position import Position, SourceRange
|
|
9
|
-
from ...domain.models.text import TextEdit
|
|
9
|
+
from ...domain.models.text import TextEdit, source_lines
|
|
10
10
|
from ...domain.ports.external_linter import ExternalLinter
|
|
11
11
|
from .source_path import as_known_path
|
|
12
12
|
|
|
@@ -59,8 +59,8 @@ class AutoCorrectLinter(ExternalLinter):
|
|
|
59
59
|
autocorrect_py.load_config(config_text) # pyright: ignore[reportAttributeAccessIssue]
|
|
60
60
|
|
|
61
61
|
def _diff_lines(self, document: Document, corrected_text: str) -> tuple[Diagnostic, ...]:
|
|
62
|
-
original_lines = document.text
|
|
63
|
-
corrected_lines = corrected_text
|
|
62
|
+
original_lines = source_lines(document.text)
|
|
63
|
+
corrected_lines = source_lines(corrected_text)
|
|
64
64
|
if len(original_lines) != len(corrected_lines):
|
|
65
65
|
return ()
|
|
66
66
|
diagnostics: list[Diagnostic] = []
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from tree_sitter import Language, Node, Parser
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# A list item opens with a marker that a grammar reads as an operator, as "- `url` (ssh)" parses as a call.
|
|
8
|
+
_LIST_ITEM = re.compile(r'^(?:[-*+]|\d+[.)])\s')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GrammarCodeLine:
|
|
12
|
+
"""Whether a comment line is code commented out, read by parsing the line with the language's own grammar.
|
|
13
|
+
|
|
14
|
+
A line is code when it parses cleanly and holds a statement no sentence makes, such as a call or an assignment.
|
|
15
|
+
A line that only opens or closes a block cannot parse alone, so a pattern for such lines backs the grammar up.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
language: Language,
|
|
21
|
+
statement_types: frozenset[str],
|
|
22
|
+
*,
|
|
23
|
+
block_line: re.Pattern[str] | None = None,
|
|
24
|
+
required_fields: typing.Mapping[str, str] | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._parser = Parser(language)
|
|
27
|
+
self._statement_types = statement_types
|
|
28
|
+
self._block_line = block_line
|
|
29
|
+
# A node that only counts with a field present, such as a Python annotation, which "Note: this" parses as.
|
|
30
|
+
self._required_fields = dict(required_fields or {})
|
|
31
|
+
self._verdicts: dict[str, bool] = {}
|
|
32
|
+
|
|
33
|
+
def __call__(self, line: str) -> bool:
|
|
34
|
+
if self._block_line is not None and self._block_line.search(line):
|
|
35
|
+
return True
|
|
36
|
+
text = line.strip()
|
|
37
|
+
if text not in self._verdicts:
|
|
38
|
+
self._verdicts[text] = self._parses_as_statement(text)
|
|
39
|
+
return self._verdicts[text]
|
|
40
|
+
|
|
41
|
+
def _parses_as_statement(self, text: str) -> bool:
|
|
42
|
+
if not text or _LIST_ITEM.match(text):
|
|
43
|
+
return False
|
|
44
|
+
root = self._parser.parse(text.encode('utf-8')).root_node
|
|
45
|
+
return not root.has_error and self._holds_statement(root)
|
|
46
|
+
|
|
47
|
+
def _holds_statement(self, node: Node) -> bool:
|
|
48
|
+
if node.type in self._statement_types:
|
|
49
|
+
field = self._required_fields.get(node.type)
|
|
50
|
+
if field is None or node.child_by_field_name(field) is not None:
|
|
51
|
+
return True
|
|
52
|
+
return any(self._holds_statement(child) for child in node.named_children)
|
{subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/language_profile.py
RENAMED
|
@@ -6,8 +6,8 @@ from tree_sitter import Language, Node
|
|
|
6
6
|
from ....domain.models.comment import SymbolKind, Visibility
|
|
7
7
|
from ....domain.models.comment_layout import CommentLayout
|
|
8
8
|
from ....domain.models.prose import PLAIN_MARKUP, ProseMarkup
|
|
9
|
-
from ....domain.models.rule_set import DEFAULT_LINE_WIDTH
|
|
10
9
|
from ....domain.ports.language_conventions import LanguageConventions
|
|
10
|
+
from ...config_sources.formatter_width import WidthSetting
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class NodeRole(enum.Enum):
|
|
@@ -43,10 +43,15 @@ class LanguageProfile(LanguageConventions, GrammarAdapter):
|
|
|
43
43
|
line_markup: ProseMarkup = PLAIN_MARKUP
|
|
44
44
|
# A docstring lives inside what it documents, while a JSDoc block stands above it.
|
|
45
45
|
doc_leads_declaration = False
|
|
46
|
-
|
|
46
|
+
# Where the language's formatters set a line width, which subedit follows over a default of its own.
|
|
47
|
+
width_settings: tuple[WidthSetting, ...] = ()
|
|
47
48
|
# Directories a package manager fills with code nobody in the project wrote.
|
|
48
49
|
vendor_directories: frozenset[str] = frozenset()
|
|
49
50
|
|
|
51
|
+
def imports_for_effect(self, node: Node) -> bool:
|
|
52
|
+
"""Whether an import binds no name and runs only for its side effect, which a comment over it explains."""
|
|
53
|
+
return False
|
|
54
|
+
|
|
50
55
|
def exported_names(self, root: Node) -> frozenset[str]:
|
|
51
56
|
"""The names a module exports apart from where it declares them, read once per parse."""
|
|
52
57
|
return frozenset()
|