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.
Files changed (113) hide show
  1. {subedit-0.2.0 → subedit-0.3.1}/PKG-INFO +11 -7
  2. {subedit-0.2.0 → subedit-0.3.1}/README.md +10 -6
  3. {subedit-0.2.0 → subedit-0.3.1}/plugins/subedit/.claude-plugin/plugin.json +1 -1
  4. {subedit-0.2.0 → subedit-0.3.1}/plugins/subedit/scripts/run-subedit-hook.sh +1 -1
  5. {subedit-0.2.0 → subedit-0.3.1}/pyproject.toml +1 -1
  6. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/__init__.py +1 -1
  7. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/linter.py +10 -3
  8. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/comment.py +33 -3
  9. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/diagnostic.py +5 -2
  10. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/prose.py +26 -10
  11. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/prose_map.py +5 -2
  12. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/text.py +13 -0
  13. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/source_parser.py +5 -0
  14. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/rules/layout.py +3 -2
  15. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/rules/rewrite.py +7 -1
  16. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/rules/structure.py +14 -5
  17. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/__init__.py +4 -2
  18. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/config_sources/__init__.py +3 -0
  19. subedit-0.3.1/src/subedit/infrastructure/config_sources/formatter_width.py +92 -0
  20. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/config_sources/toml_config_source.py +6 -5
  21. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/linters/autocorrect_linter.py +3 -3
  22. subedit-0.3.1/src/subedit/infrastructure/parsers/languages/code_line.py +52 -0
  23. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/languages/language_profile.py +13 -2
  24. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/languages/python_profile.py +58 -2
  25. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/languages/typescript_profile.py +49 -9
  26. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +59 -14
  27. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/cli.py +4 -3
  28. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/Ellipsis.yml +2 -1
  29. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/ExternalRef.yml +1 -1
  30. {subedit-0.2.0 → subedit-0.3.1}/tests/test_cli.py +14 -0
  31. {subedit-0.2.0 → subedit-0.3.1}/tests/test_config.py +48 -1
  32. {subedit-0.2.0 → subedit-0.3.1}/tests/test_diagnostic.py +9 -0
  33. {subedit-0.2.0 → subedit-0.3.1}/tests/test_fixer.py +2 -2
  34. subedit-0.3.1/tests/test_language_parity.py +189 -0
  35. {subedit-0.2.0 → subedit-0.3.1}/tests/test_parser.py +1 -1
  36. {subedit-0.2.0 → subedit-0.3.1}/tests/test_parser_oracle.py +4 -0
  37. {subedit-0.2.0 → subedit-0.3.1}/tests/test_prose.py +16 -0
  38. {subedit-0.2.0 → subedit-0.3.1}/tests/test_report_aggregator.py +34 -0
  39. {subedit-0.2.0 → subedit-0.3.1}/tests/test_rules_layout.py +21 -4
  40. {subedit-0.2.0 → subedit-0.3.1}/tests/test_text.py +8 -0
  41. {subedit-0.2.0 → subedit-0.3.1}/tests/test_typescript.py +105 -3
  42. {subedit-0.2.0 → subedit-0.3.1}/tests/test_vale_style.py +24 -0
  43. {subedit-0.2.0 → subedit-0.3.1}/uv.lock +1 -1
  44. {subedit-0.2.0 → subedit-0.3.1}/.claude/settings.json +0 -0
  45. {subedit-0.2.0 → subedit-0.3.1}/.claude-plugin/marketplace.json +0 -0
  46. {subedit-0.2.0 → subedit-0.3.1}/.github/dependabot.yml +0 -0
  47. {subedit-0.2.0 → subedit-0.3.1}/.github/release.yml +0 -0
  48. {subedit-0.2.0 → subedit-0.3.1}/.github/workflows/ci.yml +0 -0
  49. {subedit-0.2.0 → subedit-0.3.1}/.github/workflows/pr.yml +0 -0
  50. {subedit-0.2.0 → subedit-0.3.1}/.github/workflows/release.yml +0 -0
  51. {subedit-0.2.0 → subedit-0.3.1}/.gitignore +0 -0
  52. {subedit-0.2.0 → subedit-0.3.1}/.vale.ini +0 -0
  53. {subedit-0.2.0 → subedit-0.3.1}/LICENSE +0 -0
  54. {subedit-0.2.0 → subedit-0.3.1}/docs/rules.md +0 -0
  55. {subedit-0.2.0 → subedit-0.3.1}/examples/README.md +0 -0
  56. {subedit-0.2.0 → subedit-0.3.1}/examples/probe.py +0 -0
  57. {subedit-0.2.0 → subedit-0.3.1}/examples/subedit.toml +0 -0
  58. {subedit-0.2.0 → subedit-0.3.1}/examples/walkthrough.py +0 -0
  59. {subedit-0.2.0 → subedit-0.3.1}/plugins/subedit/hooks/hooks.json +0 -0
  60. {subedit-0.2.0 → subedit-0.3.1}/plugins/subedit/skills/judging-findings/SKILL.md +0 -0
  61. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/__init__.py +0 -0
  62. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/corpus_meter.py +0 -0
  63. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/fixer.py +0 -0
  64. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/application/report_aggregator.py +0 -0
  65. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/__init__.py +0 -0
  66. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/__init__.py +0 -0
  67. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/comment_layout.py +0 -0
  68. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/document.py +0 -0
  69. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/position.py +0 -0
  70. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/models/rule_set.py +0 -0
  71. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/__init__.py +0 -0
  72. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/external_linter.py +0 -0
  73. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/language_conventions.py +0 -0
  74. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/ports/rule.py +0 -0
  75. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/domain/rules/__init__.py +0 -0
  76. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/config_sources/config_schema.py +0 -0
  77. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/linters/__init__.py +0 -0
  78. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/linters/source_path.py +0 -0
  79. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/linters/vale_linter.py +0 -0
  80. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/__init__.py +0 -0
  81. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/infrastructure/parsers/languages/__init__.py +0 -0
  82. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/__init__.py +0 -0
  83. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/harnesses/__init__.py +0 -0
  84. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
  85. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
  86. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
  87. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/hook.py +0 -0
  88. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/report.py +0 -0
  89. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/presentation/rule_reference.py +0 -0
  90. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/DateRef.yml +0 -0
  91. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/EditHistory.yml +0 -0
  92. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/EmDash.yml +0 -0
  93. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/MidSentenceColon.yml +0 -0
  94. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/MidSentenceColonMarkup.yml +0 -0
  95. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/Semicolon.yml +0 -0
  96. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/styles/subedit/SignatureTag.yml +0 -0
  97. {subedit-0.2.0 → subedit-0.3.1}/src/subedit/vale/vale.ini +0 -0
  98. {subedit-0.2.0 → subedit-0.3.1}/subedit.toml +0 -0
  99. {subedit-0.2.0 → subedit-0.3.1}/tests/fixtures/autocorrect/unspaced.py +0 -0
  100. {subedit-0.2.0 → subedit-0.3.1}/tests/fixtures/vale/clean.md +0 -0
  101. {subedit-0.2.0 → subedit-0.3.1}/tests/fixtures/vale/violations.py +0 -0
  102. {subedit-0.2.0 → subedit-0.3.1}/tests/test_comment.py +0 -0
  103. {subedit-0.2.0 → subedit-0.3.1}/tests/test_corpus_meter.py +0 -0
  104. {subedit-0.2.0 → subedit-0.3.1}/tests/test_harnesses.py +0 -0
  105. {subedit-0.2.0 → subedit-0.3.1}/tests/test_hook.py +0 -0
  106. {subedit-0.2.0 → subedit-0.3.1}/tests/test_linter.py +0 -0
  107. {subedit-0.2.0 → subedit-0.3.1}/tests/test_parser_ocp.py +0 -0
  108. {subedit-0.2.0 → subedit-0.3.1}/tests/test_plugin.py +0 -0
  109. {subedit-0.2.0 → subedit-0.3.1}/tests/test_position.py +0 -0
  110. {subedit-0.2.0 → subedit-0.3.1}/tests/test_rules_rewrite.py +0 -0
  111. {subedit-0.2.0 → subedit-0.3.1}/tests/test_rules_structure.py +0 -0
  112. {subedit-0.2.0 → subedit-0.3.1}/tests/test_schema.py +0 -0
  113. {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.2.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 one sentence per line, and only tells you about the sentences a tool cannot fix for you.
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 | Default width |
156
+ | Language | Files | Doc comment | Width read from |
155
157
  | --- | --- | --- | --- |
156
- | Python | `.py`, `.pyi` | `"""` docstring | 120 |
157
- | TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | 80, as Prettier sets it |
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 JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written.
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` takes the default of each file's language.
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 one sentence per line, and only tells you about the sentences a tool cannot fix for you.
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 | Default width |
122
+ | Language | Files | Doc comment | Width read from |
121
123
  | --- | --- | --- | --- |
122
- | Python | `.py`, `.pyi` | `"""` docstring | 120 |
123
- | TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | 80, as Prettier sets it |
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 JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written.
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` takes the default of each file's language.
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
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "subedit",
3
3
  "displayName": "subedit",
4
- "version": "0.2.0",
4
+ "version": "0.3.1",
5
5
  "description": "Repairs comment layout silently on write, and returns only what needs rewriting.",
6
6
  "author": {
7
7
  "name": "mroops0111"
@@ -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.2.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,6 +1,6 @@
1
1
  [project]
2
2
  name = "subedit"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "A linter and formatter for the prose inside code comments, across languages."
5
5
  authors = [
6
6
  {name = "Yun-Tai Yang"},
@@ -1,3 +1,3 @@
1
- __version__ = '0.2.0'
1
+ __version__ = '0.3.1'
2
2
 
3
3
  __all__ = ['__version__']
@@ -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
- severity = rule_set.severity_for(rule.metadata)
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
- return Inspection(Report(tuple(sorted_diagnostics)), ProseMap.of_comments(groups))
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
- return dataclasses.replace(self, lines=self.layout.rebuild(self.lines, texts, width))
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
- for sentence_index, sentence in enumerate(Prose(' '.join(lines)).sentences()):
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
- if self.has_summary_line and paragraph_index == 0 and sentence_index == 0:
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
- key = (diagnostic.rule_id, diagnostic.range.start, diagnostic.range.end)
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 commented out of a language whose statements look like one.
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: re.Pattern[str] | None = None
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 bool(self.code_line.search(line))
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
- for line in self.physical_lines:
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((' ', '\t'))
178
+ return line.startswith((' ', '\t'))
172
179
 
173
- # A wrapped list item is indented under its marker rather than to a fixed width,
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 trailing_word and len(trailing_word.group(1).replace('.', '')) == 1:
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(code_range.start <= diagnostic.range.start < code_range.end for code_range in self.code):
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 differs from one sentence per line. '
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
- name = group.attachment.name if group.attachment else 'the symbol'
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
- {extension: language.default_line_width for language in LANGUAGES for extension in language.extensions},
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