subedit 0.2.0__tar.gz → 0.3.1__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.1}/PKG-INFO +11 -7
- {subedit-0.2.0 → subedit-0.3.1}/README.md +10 -6
- {subedit-0.2.0 → subedit-0.3.1}/plugins/subedit/.claude-plugin/plugin.json +1 -1
- {subedit-0.2.0 → subedit-0.3.1}/plugins/subedit/scripts/run-subedit-hook.sh +1 -1
- {subedit-0.2.0 → subedit-0.3.1}/pyproject.toml +1 -1
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/__init__.py +1 -1
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/linter.py +10 -3
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/comment.py +33 -3
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/diagnostic.py +5 -2
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/prose.py +26 -10
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/prose_map.py +5 -2
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/text.py +13 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/source_parser.py +5 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/rules/layout.py +3 -2
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/rules/rewrite.py +7 -1
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/rules/structure.py +14 -5
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/__init__.py +4 -2
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/config_sources/__init__.py +3 -0
- subedit-0.3.1/src/subedit/infrastructure/config_sources/formatter_width.py +92 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/config_sources/toml_config_source.py +6 -5
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/linters/autocorrect_linter.py +3 -3
- subedit-0.3.1/src/subedit/infrastructure/parsers/languages/code_line.py +52 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/languages/language_profile.py +13 -2
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/languages/python_profile.py +58 -2
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/languages/typescript_profile.py +49 -9
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +59 -14
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/cli.py +4 -3
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/Ellipsis.yml +2 -1
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/ExternalRef.yml +1 -1
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_cli.py +14 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_config.py +48 -1
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_diagnostic.py +9 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_fixer.py +2 -2
- subedit-0.3.1/tests/test_language_parity.py +189 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_parser.py +1 -1
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_parser_oracle.py +4 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_prose.py +16 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_report_aggregator.py +34 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_rules_layout.py +21 -4
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_text.py +8 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_typescript.py +105 -3
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_vale_style.py +24 -0
- {subedit-0.2.0 → subedit-0.3.1}/uv.lock +1 -1
- {subedit-0.2.0 → subedit-0.3.1}/.claude/settings.json +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/.claude-plugin/marketplace.json +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/.github/dependabot.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/.github/release.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/.github/workflows/ci.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/.github/workflows/pr.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/.github/workflows/release.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/.gitignore +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/.vale.ini +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/LICENSE +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/docs/rules.md +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/examples/README.md +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/examples/probe.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/examples/subedit.toml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/examples/walkthrough.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/plugins/subedit/hooks/hooks.json +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/plugins/subedit/skills/judging-findings/SKILL.md +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/corpus_meter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/fixer.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/report_aggregator.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/comment_layout.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/document.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/position.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/rule_set.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/external_linter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/language_conventions.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/rule.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/rules/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/config_sources/config_schema.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/linters/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/linters/source_path.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/linters/vale_linter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/languages/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/harnesses/__init__.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/hook.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/report.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/rule_reference.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/DateRef.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/EditHistory.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/EmDash.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/MidSentenceColon.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/MidSentenceColonMarkup.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/Semicolon.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/SignatureTag.yml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/vale.ini +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/subedit.toml +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/fixtures/autocorrect/unspaced.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/fixtures/vale/clean.md +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/fixtures/vale/violations.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_comment.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_corpus_meter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_harnesses.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_hook.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_linter.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_parser_ocp.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_plugin.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_position.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_rules_rewrite.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_rules_structure.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/tests/test_schema.py +0 -0
- {subedit-0.2.0 → subedit-0.3.1}/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.1
|
|
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
|
|
@@ -38,7 +38,7 @@ Your house style, enforced in the places prose hides inside a codebase.
|
|
|
38
38
|
|
|
39
39
|
A subeditor is the person at a publication who makes every piece of copy match its house style. `subedit` does that job for the prose in your code.
|
|
40
40
|
|
|
41
|
-
Linters check your code. Almost nothing checks the sentences you write around it. `subedit` reads the comments and docstrings in your source, lays them out
|
|
41
|
+
Linters check your code. Almost nothing checks the sentences you write around it. `subedit` reads the comments and docstrings in your source, lays them out so no sentence breaks mid clause, and only tells you about the sentences a tool cannot fix for you.
|
|
42
42
|
|
|
43
43
|
## Example
|
|
44
44
|
|
|
@@ -56,6 +56,8 @@ An editor wraps a comment at whatever column it reaches, so sentences break mid
|
|
|
56
56
|
# Retry once, then give up.
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
Short sentences may share a line while it fits, as in `# Retry once. Then give up.` A line of them that overflows is split, one sentence a line.
|
|
60
|
+
|
|
59
61
|
A sentence that runs past the width with no comma has nowhere legal to break, and only its author knows where the meaning divides. That one comes back to you, or to the agent that wrote it.
|
|
60
62
|
|
|
61
63
|
```text
|
|
@@ -151,13 +153,15 @@ Registering the hook with both agents is the normal case. Each agent names its e
|
|
|
151
153
|
|
|
152
154
|
## Languages
|
|
153
155
|
|
|
154
|
-
| Language | Files | Doc comment |
|
|
156
|
+
| Language | Files | Doc comment | Width read from |
|
|
155
157
|
| --- | --- | --- | --- |
|
|
156
|
-
| Python | `.py`, `.pyi` | `"""` docstring |
|
|
157
|
-
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc |
|
|
158
|
+
| Python | `.py`, `.pyi` | `"""` docstring | `line-length` for ruff or black |
|
|
159
|
+
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | `printWidth` for Prettier, `lineWidth` for Biome |
|
|
158
160
|
|
|
159
161
|
A TypeScript symbol is public when it is exported, and a class member stays public unless it is `private`, `protected`, or `#named`.
|
|
160
|
-
A
|
|
162
|
+
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.
|
|
163
|
+
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.
|
|
164
|
+
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
165
|
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
166
|
A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
|
|
163
167
|
|
|
@@ -175,7 +179,7 @@ mid-clause-break = "error"
|
|
|
175
179
|
block-too-long = { severity = "warning", line = 3, doc-public = 20 }
|
|
176
180
|
```
|
|
177
181
|
|
|
178
|
-
Left unset, `line-width`
|
|
182
|
+
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
183
|
|
|
180
184
|
Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
|
|
181
185
|
|
|
@@ -4,7 +4,7 @@ Your house style, enforced in the places prose hides inside a codebase.
|
|
|
4
4
|
|
|
5
5
|
A subeditor is the person at a publication who makes every piece of copy match its house style. `subedit` does that job for the prose in your code.
|
|
6
6
|
|
|
7
|
-
Linters check your code. Almost nothing checks the sentences you write around it. `subedit` reads the comments and docstrings in your source, lays them out
|
|
7
|
+
Linters check your code. Almost nothing checks the sentences you write around it. `subedit` reads the comments and docstrings in your source, lays them out so no sentence breaks mid clause, and only tells you about the sentences a tool cannot fix for you.
|
|
8
8
|
|
|
9
9
|
## Example
|
|
10
10
|
|
|
@@ -22,6 +22,8 @@ An editor wraps a comment at whatever column it reaches, so sentences break mid
|
|
|
22
22
|
# Retry once, then give up.
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
Short sentences may share a line while it fits, as in `# Retry once. Then give up.` A line of them that overflows is split, one sentence a line.
|
|
26
|
+
|
|
25
27
|
A sentence that runs past the width with no comma has nowhere legal to break, and only its author knows where the meaning divides. That one comes back to you, or to the agent that wrote it.
|
|
26
28
|
|
|
27
29
|
```text
|
|
@@ -117,13 +119,15 @@ Registering the hook with both agents is the normal case. Each agent names its e
|
|
|
117
119
|
|
|
118
120
|
## Languages
|
|
119
121
|
|
|
120
|
-
| Language | Files | Doc comment |
|
|
122
|
+
| Language | Files | Doc comment | Width read from |
|
|
121
123
|
| --- | --- | --- | --- |
|
|
122
|
-
| Python | `.py`, `.pyi` | `"""` docstring |
|
|
123
|
-
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc |
|
|
124
|
+
| Python | `.py`, `.pyi` | `"""` docstring | `line-length` for ruff or black |
|
|
125
|
+
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | `printWidth` for Prettier, `lineWidth` for Biome |
|
|
124
126
|
|
|
125
127
|
A TypeScript symbol is public when it is exported, and a class member stays public unless it is `private`, `protected`, or `#named`.
|
|
126
|
-
A
|
|
128
|
+
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.
|
|
129
|
+
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.
|
|
130
|
+
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
131
|
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
132
|
A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
|
|
129
133
|
|
|
@@ -141,7 +145,7 @@ mid-clause-break = "error"
|
|
|
141
145
|
block-too-long = { severity = "warning", line = 3, doc-public = 20 }
|
|
142
146
|
```
|
|
143
147
|
|
|
144
|
-
Left unset, `line-width`
|
|
148
|
+
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
149
|
|
|
146
150
|
Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
|
|
147
151
|
|
|
@@ -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.1'
|
|
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."""
|
|
@@ -130,11 +137,21 @@ class CommentGroup:
|
|
|
130
137
|
out.extend(paragraph)
|
|
131
138
|
continue
|
|
132
139
|
lines = tuple(line.strip() for line in paragraph if line.strip())
|
|
133
|
-
|
|
140
|
+
sentences = Prose(' '.join(lines)).sentences()
|
|
141
|
+
shared_until = 0
|
|
142
|
+
for sentence_index, sentence in enumerate(sentences):
|
|
143
|
+
if sentence.offset < shared_until:
|
|
144
|
+
continue
|
|
134
145
|
if sentence_index == 1 and paragraph_index == 0 and self.has_summary_line:
|
|
135
146
|
out.append('')
|
|
136
147
|
first_budget = self.text_budget(width, first_line=not out) - (reserve if not out else 0)
|
|
137
|
-
|
|
148
|
+
is_summary = self.has_summary_line and paragraph_index == 0 and sentence_index == 0
|
|
149
|
+
shared_line = None if is_summary else _shared_line(lines, sentences[sentence_index:])
|
|
150
|
+
if shared_line is not None and len(shared_line) <= first_budget:
|
|
151
|
+
out.append(shared_line)
|
|
152
|
+
shared_until = sentence.offset + len(shared_line)
|
|
153
|
+
continue
|
|
154
|
+
if is_summary:
|
|
138
155
|
best_lines: tuple[str, ...] = (sentence.text,)
|
|
139
156
|
else:
|
|
140
157
|
best_lines = reflow_sentence(sentence.text, body_budget, stub_floor, first_width=first_budget)
|
|
@@ -185,6 +202,19 @@ class CommentGroup:
|
|
|
185
202
|
return TextEdit(self.range, self.render())
|
|
186
203
|
|
|
187
204
|
|
|
205
|
+
# A line the author filled with several short sentences, each whole, reads as well as one sentence a line,
|
|
206
|
+
# so it stays while it fits.
|
|
207
|
+
def _shared_line(lines: tuple[str, ...], sentences: tuple[Sentence, ...]) -> str | None:
|
|
208
|
+
start = 0
|
|
209
|
+
for line in lines:
|
|
210
|
+
if start == sentences[0].offset:
|
|
211
|
+
end = start + len(line)
|
|
212
|
+
ending_here = [sentence for sentence in sentences[1:] if sentence.end == end]
|
|
213
|
+
return line if ending_here and sentences[0].end < end else None
|
|
214
|
+
start += len(line) + 1
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
188
218
|
# The lines a sentence already occupies, when it has them to itself.
|
|
189
219
|
def _given_lines(lines: tuple[str, ...], sentence: Sentence) -> tuple[str, ...] | None:
|
|
190
220
|
covering: list[str] = []
|
|
@@ -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.
|
|
@@ -20,6 +21,8 @@ ABBREVIATIONS = frozenset(
|
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
_SENTENCE_END = re.compile(r'[.!?]')
|
|
24
|
+
# A colon ends a lead-in as a period ends a sentence, so the line after either opens something new.
|
|
25
|
+
_ENDS_A_SENTENCE = re.compile(r'[.!?:]["\')\]]*\s*$')
|
|
23
26
|
_TRAILING_WORD = re.compile(r'([A-Za-z][A-Za-z.]*)$')
|
|
24
27
|
_URL = re.compile(r'\b(?:https?://|www\.)\S+')
|
|
25
28
|
# A span opened by two backticks, as reStructuredText writes code, closes on two.
|
|
@@ -29,6 +32,7 @@ _FENCE = re.compile(r'^\s*(```|~~~)')
|
|
|
29
32
|
_LIST_DELIMITER = re.compile(r'^\s*(?:[0-9]+[.)]|[-*+]|[a-zA-Z][.)])\s')
|
|
30
33
|
_LITERAL_DELIMITER = '\\b'
|
|
31
34
|
_CLOSING_MARKS = '"\')]}'
|
|
35
|
+
_OPENING_MARKS = '"\'([{'
|
|
32
36
|
_APOSTROPHES = ("'", '\u2019')
|
|
33
37
|
|
|
34
38
|
# Only before a number, since "No. 5" abbreviates while a trailing no ends its sentence.
|
|
@@ -60,12 +64,12 @@ class ProseMarkup:
|
|
|
60
64
|
"""What a language writes in its comments besides prose, which reflow leaves as written.
|
|
61
65
|
|
|
62
66
|
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
|
|
67
|
+
An example tag's body is code, and so is a line that the language's grammar reads as code commented out.
|
|
64
68
|
"""
|
|
65
69
|
|
|
66
70
|
tag_section: re.Pattern[str] | None = None
|
|
67
71
|
example_tag: str | None = None
|
|
68
|
-
code_line:
|
|
72
|
+
code_line: typing.Callable[[str], bool] | None = None
|
|
69
73
|
|
|
70
74
|
def opens_tag_section(self, line: str) -> bool:
|
|
71
75
|
return self.tag_section is not None and bool(self.tag_section.match(line))
|
|
@@ -74,7 +78,7 @@ class ProseMarkup:
|
|
|
74
78
|
return self.example_tag is not None and line.strip().startswith(self.example_tag)
|
|
75
79
|
|
|
76
80
|
def is_code(self, line: str) -> bool:
|
|
77
|
-
return self.code_line is not None and
|
|
81
|
+
return self.code_line is not None and self.code_line(line)
|
|
78
82
|
|
|
79
83
|
|
|
80
84
|
PLAIN_MARKUP = ProseMarkup()
|
|
@@ -106,7 +110,8 @@ class Prose:
|
|
|
106
110
|
current_lines = []
|
|
107
111
|
segment_is_literal = next_is_literal
|
|
108
112
|
|
|
109
|
-
|
|
113
|
+
lines = self.physical_lines
|
|
114
|
+
for previous_line, line in zip(('', *lines), lines, strict=False):
|
|
110
115
|
stripped_line = line.strip()
|
|
111
116
|
inside_tags = inside_tags or self.markup.opens_tag_section(line)
|
|
112
117
|
if inside_tags:
|
|
@@ -132,7 +137,7 @@ class Prose:
|
|
|
132
137
|
continue
|
|
133
138
|
if self._is_list_item(line):
|
|
134
139
|
inside_list = True
|
|
135
|
-
elif not self._continues_list_item(line, inside_list):
|
|
140
|
+
elif not self._continues_list_item(line, previous_line, inside_list):
|
|
136
141
|
inside_list = False
|
|
137
142
|
# The indented body under a commented-out opening brace is code too.
|
|
138
143
|
inside_code = self.markup.is_code(line) or (inside_code and line[:1].isspace())
|
|
@@ -167,13 +172,15 @@ class Prose:
|
|
|
167
172
|
indexes.add(index)
|
|
168
173
|
return frozenset(indexes)
|
|
169
174
|
|
|
175
|
+
# A line set two spaces past the margin, such as a tree diagram or an aligned table, is laid out by hand.
|
|
176
|
+
# Markdown wants four for a code block, but a comment's prefix already takes the space a writer would count.
|
|
170
177
|
def _is_indented(self, line: str) -> bool:
|
|
171
|
-
return line.startswith(('
|
|
178
|
+
return line.startswith((' ', '\t'))
|
|
172
179
|
|
|
173
|
-
# A wrapped list item is indented under its marker
|
|
180
|
+
# A wrapped list item is indented under its marker, or left at the margin while its sentence runs on,
|
|
174
181
|
# so the continuation belongs to the item instead of opening a paragraph.
|
|
175
|
-
def _continues_list_item(self, line: str, inside_list: bool) -> bool:
|
|
176
|
-
return inside_list and line[:1].isspace()
|
|
182
|
+
def _continues_list_item(self, line: str, previous_line: str, inside_list: bool) -> bool:
|
|
183
|
+
return inside_list and (line[:1].isspace() or not _ENDS_A_SENTENCE.search(previous_line))
|
|
177
184
|
|
|
178
185
|
def _is_list_item(self, line: str) -> bool:
|
|
179
186
|
return bool(_LIST_DELIMITER.match(line))
|
|
@@ -234,10 +241,19 @@ class Prose:
|
|
|
234
241
|
return False
|
|
235
242
|
if word == NUMBER_ABBREVIATION and text[offset + 1 :].lstrip()[:1].isdigit():
|
|
236
243
|
return False
|
|
237
|
-
if
|
|
244
|
+
if (
|
|
245
|
+
trailing_word
|
|
246
|
+
and len(trailing_word.group(1).replace('.', '')) == 1
|
|
247
|
+
and self._stands_alone(text, trailing_word)
|
|
248
|
+
):
|
|
238
249
|
return False
|
|
239
250
|
return self._followed_by_break(text, offset)
|
|
240
251
|
|
|
252
|
+
# An initial stands as a word of its own, so a letter closing Ctrl+C or a plural such as `Event`s is none.
|
|
253
|
+
def _stands_alone(self, text: str, trailing_word: re.Match[str]) -> bool:
|
|
254
|
+
before = text[: trailing_word.start()]
|
|
255
|
+
return not before or before[-1].isspace() or before[-1] in _OPENING_MARKS
|
|
256
|
+
|
|
241
257
|
def _followed_by_break(self, text: str, offset: int) -> bool:
|
|
242
258
|
rest = text[offset + 1 :]
|
|
243
259
|
if not rest:
|
|
@@ -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 ()
|
|
@@ -30,8 +30,9 @@ class MidClauseBreakRule(Rule):
|
|
|
30
30
|
rule_id=self.metadata.rule_id,
|
|
31
31
|
range=group.range,
|
|
32
32
|
message=(
|
|
33
|
-
'Comment layout
|
|
34
|
-
'A sentence that fits takes one line, and a longer one breaks only at a comma ending a clause.'
|
|
33
|
+
'Comment layout breaks a sentence where it need not. '
|
|
34
|
+
'A sentence that fits takes one line, and a longer one breaks only at a comma ending a clause. '
|
|
35
|
+
'Short sentences may share a line that fits.'
|
|
35
36
|
),
|
|
36
37
|
fix=Fix.reflow(reflowed.as_edit()),
|
|
37
38
|
)
|
|
@@ -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
|