subedit 0.1.4__tar.gz → 0.2.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.
Files changed (114) hide show
  1. {subedit-0.1.4 → subedit-0.2.0}/.vale.ini +1 -1
  2. {subedit-0.1.4 → subedit-0.2.0}/PKG-INFO +21 -7
  3. {subedit-0.1.4 → subedit-0.2.0}/README.md +18 -4
  4. {subedit-0.1.4 → subedit-0.2.0}/docs/rules.md +1 -1
  5. {subedit-0.1.4 → subedit-0.2.0}/plugins/subedit/.claude-plugin/plugin.json +1 -1
  6. {subedit-0.1.4 → subedit-0.2.0}/plugins/subedit/scripts/run-subedit-hook.sh +1 -1
  7. {subedit-0.1.4 → subedit-0.2.0}/plugins/subedit/skills/judging-findings/SKILL.md +3 -2
  8. {subedit-0.1.4 → subedit-0.2.0}/pyproject.toml +3 -3
  9. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/__init__.py +1 -1
  10. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/application/__init__.py +2 -1
  11. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/application/linter.py +14 -2
  12. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/application/report_aggregator.py +4 -2
  13. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/__init__.py +18 -0
  14. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/models/__init__.py +28 -2
  15. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/models/comment.py +56 -76
  16. subedit-0.2.0/src/subedit/domain/models/comment_layout.py +130 -0
  17. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/models/prose.py +104 -7
  18. subedit-0.2.0/src/subedit/domain/models/prose_map.py +41 -0
  19. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/models/rule_set.py +5 -1
  20. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/rules/layout.py +13 -1
  21. subedit-0.2.0/src/subedit/domain/rules/rewrite.py +139 -0
  22. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/rules/structure.py +26 -10
  23. subedit-0.2.0/src/subedit/infrastructure/__init__.py +75 -0
  24. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/config_sources/config_schema.py +2 -1
  25. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/config_sources/toml_config_source.py +20 -9
  26. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/linters/autocorrect_linter.py +13 -2
  27. subedit-0.2.0/src/subedit/infrastructure/linters/source_path.py +12 -0
  28. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/linters/vale_linter.py +21 -2
  29. subedit-0.2.0/src/subedit/infrastructure/parsers/__init__.py +24 -0
  30. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/parsers/languages/__init__.py +4 -0
  31. subedit-0.2.0/src/subedit/infrastructure/parsers/languages/language_profile.py +56 -0
  32. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/parsers/languages/python_profile.py +5 -1
  33. subedit-0.2.0/src/subedit/infrastructure/parsers/languages/typescript_profile.py +180 -0
  34. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +27 -11
  35. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/cli.py +24 -4
  36. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/harnesses/__init__.py +1 -3
  37. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/hook.py +5 -2
  38. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/vale.ini +1 -1
  39. {subedit-0.1.4 → subedit-0.2.0}/tests/test_comment.py +9 -3
  40. {subedit-0.1.4 → subedit-0.2.0}/tests/test_fixer.py +2 -1
  41. {subedit-0.1.4 → subedit-0.2.0}/tests/test_linter.py +20 -0
  42. {subedit-0.1.4 → subedit-0.2.0}/tests/test_parser.py +25 -1
  43. {subedit-0.1.4 → subedit-0.2.0}/tests/test_parser_ocp.py +3 -1
  44. {subedit-0.1.4 → subedit-0.2.0}/tests/test_report_aggregator.py +49 -1
  45. {subedit-0.1.4 → subedit-0.2.0}/tests/test_rules_layout.py +53 -0
  46. {subedit-0.1.4 → subedit-0.2.0}/tests/test_rules_rewrite.py +23 -0
  47. {subedit-0.1.4 → subedit-0.2.0}/tests/test_rules_structure.py +5 -0
  48. {subedit-0.1.4 → subedit-0.2.0}/tests/test_schema.py +2 -2
  49. subedit-0.2.0/tests/test_typescript.py +282 -0
  50. {subedit-0.1.4 → subedit-0.2.0}/uv.lock +3 -3
  51. subedit-0.1.4/src/subedit/domain/rules/rewrite.py +0 -99
  52. subedit-0.1.4/src/subedit/infrastructure/__init__.py +0 -47
  53. subedit-0.1.4/src/subedit/infrastructure/parsers/__init__.py +0 -12
  54. subedit-0.1.4/src/subedit/infrastructure/parsers/languages/language_profile.py +0 -33
  55. {subedit-0.1.4 → subedit-0.2.0}/.claude/settings.json +0 -0
  56. {subedit-0.1.4 → subedit-0.2.0}/.claude-plugin/marketplace.json +0 -0
  57. {subedit-0.1.4 → subedit-0.2.0}/.github/dependabot.yml +0 -0
  58. {subedit-0.1.4 → subedit-0.2.0}/.github/release.yml +0 -0
  59. {subedit-0.1.4 → subedit-0.2.0}/.github/workflows/ci.yml +0 -0
  60. {subedit-0.1.4 → subedit-0.2.0}/.github/workflows/pr.yml +0 -0
  61. {subedit-0.1.4 → subedit-0.2.0}/.github/workflows/release.yml +0 -0
  62. {subedit-0.1.4 → subedit-0.2.0}/.gitignore +0 -0
  63. {subedit-0.1.4 → subedit-0.2.0}/LICENSE +0 -0
  64. {subedit-0.1.4 → subedit-0.2.0}/examples/README.md +0 -0
  65. {subedit-0.1.4 → subedit-0.2.0}/examples/probe.py +0 -0
  66. {subedit-0.1.4 → subedit-0.2.0}/examples/subedit.toml +0 -0
  67. {subedit-0.1.4 → subedit-0.2.0}/examples/walkthrough.py +0 -0
  68. {subedit-0.1.4 → subedit-0.2.0}/plugins/subedit/hooks/hooks.json +0 -0
  69. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/application/corpus_meter.py +0 -0
  70. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/application/fixer.py +0 -0
  71. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/models/diagnostic.py +0 -0
  72. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/models/document.py +0 -0
  73. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/models/position.py +0 -0
  74. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/models/text.py +0 -0
  75. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/ports/__init__.py +0 -0
  76. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/ports/external_linter.py +0 -0
  77. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/ports/language_conventions.py +0 -0
  78. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/ports/rule.py +0 -0
  79. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/ports/source_parser.py +0 -0
  80. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/domain/rules/__init__.py +0 -0
  81. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/config_sources/__init__.py +0 -0
  82. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/infrastructure/linters/__init__.py +0 -0
  83. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/__init__.py +0 -0
  84. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
  85. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
  86. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
  87. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/report.py +0 -0
  88. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/presentation/rule_reference.py +0 -0
  89. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/DateRef.yml +0 -0
  90. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/EditHistory.yml +0 -0
  91. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/Ellipsis.yml +0 -0
  92. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/EmDash.yml +0 -0
  93. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/ExternalRef.yml +0 -0
  94. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/MidSentenceColon.yml +0 -0
  95. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/MidSentenceColonMarkup.yml +0 -0
  96. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/Semicolon.yml +0 -0
  97. {subedit-0.1.4 → subedit-0.2.0}/src/subedit/vale/styles/subedit/SignatureTag.yml +0 -0
  98. {subedit-0.1.4 → subedit-0.2.0}/subedit.toml +0 -0
  99. {subedit-0.1.4 → subedit-0.2.0}/tests/fixtures/autocorrect/unspaced.py +0 -0
  100. {subedit-0.1.4 → subedit-0.2.0}/tests/fixtures/vale/clean.md +0 -0
  101. {subedit-0.1.4 → subedit-0.2.0}/tests/fixtures/vale/violations.py +0 -0
  102. {subedit-0.1.4 → subedit-0.2.0}/tests/test_cli.py +0 -0
  103. {subedit-0.1.4 → subedit-0.2.0}/tests/test_config.py +0 -0
  104. {subedit-0.1.4 → subedit-0.2.0}/tests/test_corpus_meter.py +0 -0
  105. {subedit-0.1.4 → subedit-0.2.0}/tests/test_diagnostic.py +0 -0
  106. {subedit-0.1.4 → subedit-0.2.0}/tests/test_harnesses.py +0 -0
  107. {subedit-0.1.4 → subedit-0.2.0}/tests/test_hook.py +0 -0
  108. {subedit-0.1.4 → subedit-0.2.0}/tests/test_parser_oracle.py +0 -0
  109. {subedit-0.1.4 → subedit-0.2.0}/tests/test_plugin.py +0 -0
  110. {subedit-0.1.4 → subedit-0.2.0}/tests/test_position.py +0 -0
  111. {subedit-0.1.4 → subedit-0.2.0}/tests/test_prose.py +0 -0
  112. {subedit-0.1.4 → subedit-0.2.0}/tests/test_self_hosting.py +0 -0
  113. {subedit-0.1.4 → subedit-0.2.0}/tests/test_text.py +0 -0
  114. {subedit-0.1.4 → subedit-0.2.0}/tests/test_vale_style.py +0 -0
@@ -1,7 +1,7 @@
1
1
  StylesPath = src/subedit/vale/styles
2
2
  MinAlertLevel = warning
3
3
 
4
- [*.{py,ts,tsx,js,go,rs}]
4
+ [*.{py,pyi,ts,mts,cts,tsx,js,go,rs}]
5
5
  BasedOnStyles = subedit
6
6
  subedit.MidSentenceColonMarkup = NO
7
7
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: subedit
3
- Version: 0.1.4
3
+ Version: 0.2.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
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/mroops0111/subedit/issues
8
8
  Author: Yun-Tai Yang
9
9
  License: MIT
10
10
  License-File: LICENSE
11
- Keywords: comments,formatter,linter,lsp,prose,tree-sitter
11
+ Keywords: comments,formatter,linter,lsp,prose,python,tree-sitter,typescript
12
12
  Classifier: Development Status :: 1 - Planning
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: MIT License
@@ -21,13 +21,13 @@ Requires-Python: >=3.11
21
21
  Requires-Dist: autocorrect-py>=2.14.0
22
22
  Requires-Dist: pydantic>=2.13.5
23
23
  Requires-Dist: tree-sitter-python>=0.25.0
24
+ Requires-Dist: tree-sitter-typescript>=0.23.2
24
25
  Requires-Dist: tree-sitter>=0.26.0
25
26
  Requires-Dist: typer>=0.27.2
26
27
  Provides-Extra: dev
27
28
  Requires-Dist: basedpyright>=1.40.1; extra == 'dev'
28
29
  Requires-Dist: pytest>=9.0.0; extra == 'dev'
29
30
  Requires-Dist: ruff>=0.16.7; extra == 'dev'
30
- Requires-Dist: tree-sitter-typescript>=0.23.2; extra == 'dev'
31
31
  Provides-Extra: vale
32
32
  Requires-Dist: vale>=3.22.0.0; extra == 'vale'
33
33
  Description-Content-Type: text/markdown
@@ -149,13 +149,25 @@ Add this to `~/.codex/hooks.json`.
149
149
 
150
150
  Registering the hook with both agents is the normal case. Each agent names its edit tools differently, so every payload identifies its sender, and a payload from anything else exits 0 untouched. `subedit-hook --help` prints the payload shapes it accepts.
151
151
 
152
+ ## Languages
153
+
154
+ | Language | Files | Doc comment | Default width |
155
+ | --- | --- | --- | --- |
156
+ | Python | `.py`, `.pyi` | `"""` docstring | 120 |
157
+ | TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | 80, as Prettier sets it |
158
+
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 JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written.
161
+ 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
+ A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
163
+
152
164
  ## Configuration
153
165
 
154
166
  Settings come from `subedit.toml`, or from `[tool.subedit]` in `pyproject.toml`, whichever sits closest above the file being checked.
155
167
 
156
168
  ```toml
157
169
  [subedit]
158
- line-width = 120
170
+ line-width = 100
159
171
  exclude = ["tests/fixtures/**"]
160
172
 
161
173
  [rules]
@@ -163,6 +175,8 @@ mid-clause-break = "error"
163
175
  block-too-long = { severity = "warning", line = 3, doc-public = 20 }
164
176
  ```
165
177
 
178
+ Left unset, `line-width` takes the default of each file's language.
179
+
166
180
  Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
167
181
 
168
182
  A rule at `error` fails `check` and stops an agent, while one at `warning` is shown as advice and blocks nothing. The same table sets the delegated Vale rules by name.
@@ -194,12 +208,12 @@ Code comments are the first surface, and Markdown comes next.
194
208
 
195
209
  ## Related Tools
196
210
 
197
- `subedit` covers the one thing the usual Python tools leave alone.
211
+ `subedit` covers the one thing the usual linters and type checkers leave alone.
198
212
 
199
213
  | Tool | Checks |
200
214
  | --- | --- |
201
- | ruff | code structure |
202
- | basedpyright | types |
215
+ | ruff, ESLint | code structure |
216
+ | basedpyright, tsc | types |
203
217
  | **subedit** | **the natural language inside source** |
204
218
 
205
219
  ## License
@@ -115,13 +115,25 @@ Add this to `~/.codex/hooks.json`.
115
115
 
116
116
  Registering the hook with both agents is the normal case. Each agent names its edit tools differently, so every payload identifies its sender, and a payload from anything else exits 0 untouched. `subedit-hook --help` prints the payload shapes it accepts.
117
117
 
118
+ ## Languages
119
+
120
+ | Language | Files | Doc comment | Default width |
121
+ | --- | --- | --- | --- |
122
+ | Python | `.py`, `.pyi` | `"""` docstring | 120 |
123
+ | TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | 80, as Prettier sets it |
124
+
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 JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written.
127
+ 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
+ A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
129
+
118
130
  ## Configuration
119
131
 
120
132
  Settings come from `subedit.toml`, or from `[tool.subedit]` in `pyproject.toml`, whichever sits closest above the file being checked.
121
133
 
122
134
  ```toml
123
135
  [subedit]
124
- line-width = 120
136
+ line-width = 100
125
137
  exclude = ["tests/fixtures/**"]
126
138
 
127
139
  [rules]
@@ -129,6 +141,8 @@ mid-clause-break = "error"
129
141
  block-too-long = { severity = "warning", line = 3, doc-public = 20 }
130
142
  ```
131
143
 
144
+ Left unset, `line-width` takes the default of each file's language.
145
+
132
146
  Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
133
147
 
134
148
  A rule at `error` fails `check` and stops an agent, while one at `warning` is shown as advice and blocks nothing. The same table sets the delegated Vale rules by name.
@@ -160,12 +174,12 @@ Code comments are the first surface, and Markdown comes next.
160
174
 
161
175
  ## Related Tools
162
176
 
163
- `subedit` covers the one thing the usual Python tools leave alone.
177
+ `subedit` covers the one thing the usual linters and type checkers leave alone.
164
178
 
165
179
  | Tool | Checks |
166
180
  | --- | --- |
167
- | ruff | code structure |
168
- | basedpyright | types |
181
+ | ruff, ESLint | code structure |
182
+ | basedpyright, tsc | types |
169
183
  | **subedit** | **the natural language inside source** |
170
184
 
171
185
  ## License
@@ -15,4 +15,4 @@ The level says whether a rewrite blocks. An error fails check and stops an agent
15
15
  | `plain-comment-on-public` | rewrite | error | A public symbol must be documented in the doc comment form its language renders. |
16
16
  | `signature-restating-tag` | rewrite | error | A doc comment must not restate what the type signature already carries. |
17
17
  | `stub-fragment` | rewrite | warning | A sentence forced to break must not leave a stub of a line behind. |
18
- | `unbreakable-sentence` | rewrite | error | A sentence over the width must carry a clause comma wherever it has to break. |
18
+ | `unbreakable-sentence` | rewrite | error | A sentence over the width must carry a clause comma, splitting no list, wherever it has to break, and a summary its style guide keeps to one line must fit it. |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "subedit",
3
3
  "displayName": "subedit",
4
- "version": "0.1.4",
4
+ "version": "0.2.0",
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.1.4'
7
+ SPEC='subedit[vale]>=0.2.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
 
@@ -36,8 +36,9 @@ A warning passes when the text meets its criterion. Leave such text exactly as i
36
36
 
37
37
  An error always needs a rewrite. The message states the fix, and these notes cover what it leaves out.
38
38
 
39
- - `unbreakable-sentence`: a line can break only at a comma that ends a clause, and a comma inside brackets or a code span does not count. Add a comma at a clause boundary, or split the sentence in two.
40
- - `plain-comment-on-public`: move the text into the docstring.
39
+ - `unbreakable-sentence`: a line can break only at a comma that ends a clause, and a comma inside brackets or a code span does not count. Add a comma at a clause boundary, or split the sentence in two. When every such comma would split a list, move the list to the end of the sentence. When the message names a style guide that keeps a summary to one line, shorten the summary and move the detail into the body.
40
+ - `line-too-long` (trailing comment): a comment beside code cannot break, so shorten it or move it to its own line above the code.
41
+ - `plain-comment-on-public`: move the text into the doc comment, a docstring in Python or a `/** */` block in TypeScript.
41
42
  - `signature-restating-tag` and `subedit.SignatureTag`: delete the tag, since the signature already says it.
42
43
  - `subedit.EditHistory`: state the current behavior, not its history.
43
44
  - `subedit.Semicolon`, `subedit.EmDash`: split into two sentences, or use a comma or parentheses.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "subedit"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "A linter and formatter for the prose inside code comments, across languages."
5
5
  authors = [
6
6
  {name = "Yun-Tai Yang"},
@@ -8,7 +8,7 @@ authors = [
8
8
  readme = "README.md"
9
9
  license = {text = "MIT"}
10
10
  requires-python = ">=3.11"
11
- keywords = ["linter", "formatter", "comments", "prose", "lsp", "tree-sitter"]
11
+ keywords = ["linter", "formatter", "comments", "prose", "lsp", "tree-sitter", "python", "typescript"]
12
12
  classifiers = [
13
13
  "Development Status :: 1 - Planning",
14
14
  "Intended Audience :: Developers",
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "pydantic>=2.13.5",
26
26
  "tree-sitter>=0.26.0",
27
27
  "tree-sitter-python>=0.25.0",
28
+ "tree-sitter-typescript>=0.23.2",
28
29
  "typer>=0.27.2",
29
30
  ]
30
31
 
@@ -33,7 +34,6 @@ dev = [
33
34
  "pytest>=9.0.0",
34
35
  "basedpyright>=1.40.1",
35
36
  "ruff>=0.16.7",
36
- "tree-sitter-typescript>=0.23.2",
37
37
  ]
38
38
  vale = [
39
39
  "vale>=3.22.0.0",
@@ -1,3 +1,3 @@
1
- __version__ = '0.1.4'
1
+ __version__ = '0.2.0'
2
2
 
3
3
  __all__ = ['__version__']
@@ -1,6 +1,6 @@
1
1
  from .corpus_meter import CorpusMeter, CorpusStatistics, Distribution
2
2
  from .fixer import Fixer, FixResult
3
- from .linter import Linter, RuleEngine
3
+ from .linter import Inspection, Linter, RuleEngine
4
4
  from .report_aggregator import ReportAggregator
5
5
 
6
6
 
@@ -10,6 +10,7 @@ __all__ = [
10
10
  'Distribution',
11
11
  'FixResult',
12
12
  'Fixer',
13
+ 'Inspection',
13
14
  'Linter',
14
15
  'ReportAggregator',
15
16
  'RuleEngine',
@@ -3,6 +3,7 @@ import dataclasses
3
3
  from ..domain.models.comment import CommentGroup
4
4
  from ..domain.models.diagnostic import Diagnostic, Report
5
5
  from ..domain.models.document import Document
6
+ from ..domain.models.prose_map import ProseMap
6
7
  from ..domain.models.rule_set import RuleSet
7
8
  from ..domain.ports import SourceParser
8
9
  from ..domain.ports.rule import Rule, RuleContext
@@ -41,15 +42,26 @@ class RuleEngine:
41
42
  return duplicate_ids
42
43
 
43
44
 
45
+ @dataclasses.dataclass(frozen=True, slots=True)
46
+ class Inspection:
47
+ """What one parse of a document yields, the findings and the map of where its prose lies."""
48
+
49
+ report: Report = dataclasses.field(default_factory=Report)
50
+ prose_map: ProseMap = dataclasses.field(default_factory=ProseMap)
51
+
52
+
44
53
  class Linter:
45
54
  def __init__(self, parser: SourceParser, engine: RuleEngine) -> None:
46
55
  self._parser = parser
47
56
  self._engine = engine
48
57
 
49
58
  def run(self, document: Document, rule_set: RuleSet) -> Report:
59
+ return self.inspect(document, rule_set).report
60
+
61
+ def inspect(self, document: Document, rule_set: RuleSet) -> Inspection:
50
62
  if not self._parser.supports(document.language_id):
51
- return Report()
63
+ return Inspection()
52
64
  groups = self._parser.parse(document)
53
65
  diagnostics = self._engine.run(document, groups, rule_set)
54
66
  sorted_diagnostics = sorted(diagnostics, key=lambda item: (item.range.start, item.rule_id))
55
- return Report(tuple(sorted_diagnostics))
67
+ return Inspection(Report(tuple(sorted_diagnostics)), ProseMap.of_comments(groups))
@@ -13,13 +13,15 @@ class ReportAggregator:
13
13
  self._external_linters = external_linters
14
14
 
15
15
  def run(self, document: Document, rule_set: RuleSet) -> Report:
16
- report = self._linter.run(document, rule_set)
16
+ inspection = self._linter.inspect(document, rule_set)
17
+ report = inspection.report
17
18
  missing_sources: list[str] = []
18
19
  for external_linter in self._external_linters:
19
20
  if not external_linter.is_available():
20
21
  missing_sources.append(external_linter.name)
21
22
  continue
22
- report = report.merged_with(Report(self._configured(external_linter.run(document), rule_set)))
23
+ found = tuple(item for item in external_linter.run(document) if inspection.prose_map.admits(item))
24
+ report = report.merged_with(Report(self._configured(found, rule_set)))
23
25
  if missing_sources:
24
26
  report = report.merged_with(Report(unavailable_sources=tuple(missing_sources)))
25
27
  return report
@@ -1,7 +1,11 @@
1
1
  from .models import (
2
+ LINE_LAYOUT,
3
+ PLAIN_MARKUP,
4
+ QUOTED_DOC_LAYOUT,
2
5
  SEVERITY_BY_NAME,
3
6
  CommentForm,
4
7
  CommentGroup,
8
+ CommentLayout,
5
9
  CommentLine,
6
10
  CommentPlacement,
7
11
  Diagnostic,
@@ -9,9 +13,14 @@ from .models import (
9
13
  Encoding,
10
14
  Fix,
11
15
  FixKind,
16
+ FramedDocLayout,
17
+ LinePrefixLayout,
12
18
  Position,
13
19
  PositionMap,
14
20
  Prose,
21
+ ProseMap,
22
+ ProseMarkup,
23
+ QuotedDocLayout,
15
24
  Report,
16
25
  RuleMetadata,
17
26
  RuleSet,
@@ -29,9 +38,13 @@ from .ports import DelimiterSplit, ExternalLinter, LanguageConventions, Rule, Ru
29
38
 
30
39
 
31
40
  __all__ = [
41
+ 'LINE_LAYOUT',
42
+ 'PLAIN_MARKUP',
43
+ 'QUOTED_DOC_LAYOUT',
32
44
  'SEVERITY_BY_NAME',
33
45
  'CommentForm',
34
46
  'CommentGroup',
47
+ 'CommentLayout',
35
48
  'CommentLine',
36
49
  'CommentPlacement',
37
50
  'DelimiterSplit',
@@ -41,10 +54,15 @@ __all__ = [
41
54
  'ExternalLinter',
42
55
  'Fix',
43
56
  'FixKind',
57
+ 'FramedDocLayout',
44
58
  'LanguageConventions',
59
+ 'LinePrefixLayout',
45
60
  'Position',
46
61
  'PositionMap',
47
62
  'Prose',
63
+ 'ProseMap',
64
+ 'ProseMarkup',
65
+ 'QuotedDocLayout',
48
66
  'Report',
49
67
  'Rule',
50
68
  'RuleContext',
@@ -1,16 +1,37 @@
1
- from .comment import CommentForm, CommentGroup, CommentLine, CommentPlacement, SymbolKind, SymbolRef, Visibility
1
+ from .comment import (
2
+ CommentForm,
3
+ CommentGroup,
4
+ CommentLine,
5
+ CommentPlacement,
6
+ SymbolKind,
7
+ SymbolRef,
8
+ Visibility,
9
+ )
10
+ from .comment_layout import (
11
+ LINE_LAYOUT,
12
+ QUOTED_DOC_LAYOUT,
13
+ CommentLayout,
14
+ FramedDocLayout,
15
+ LinePrefixLayout,
16
+ QuotedDocLayout,
17
+ )
2
18
  from .diagnostic import SEVERITY_BY_NAME, Diagnostic, Fix, FixKind, Report, RuleMetadata, Severity
3
19
  from .document import Document
4
20
  from .position import Encoding, Position, PositionMap, SourceRange
5
- from .prose import Prose, Sentence
21
+ from .prose import PLAIN_MARKUP, Prose, ProseMarkup, Sentence
22
+ from .prose_map import ProseMap
6
23
  from .rule_set import RuleSet, RuleSettings
7
24
  from .text import TextEdit, apply_edits
8
25
 
9
26
 
10
27
  __all__ = [
28
+ 'LINE_LAYOUT',
29
+ 'PLAIN_MARKUP',
30
+ 'QUOTED_DOC_LAYOUT',
11
31
  'SEVERITY_BY_NAME',
12
32
  'CommentForm',
13
33
  'CommentGroup',
34
+ 'CommentLayout',
14
35
  'CommentLine',
15
36
  'CommentPlacement',
16
37
  'Diagnostic',
@@ -18,9 +39,14 @@ __all__ = [
18
39
  'Encoding',
19
40
  'Fix',
20
41
  'FixKind',
42
+ 'FramedDocLayout',
43
+ 'LinePrefixLayout',
21
44
  'Position',
22
45
  'PositionMap',
23
46
  'Prose',
47
+ 'ProseMap',
48
+ 'ProseMarkup',
49
+ 'QuotedDocLayout',
24
50
  'Report',
25
51
  'RuleMetadata',
26
52
  'RuleSet',
@@ -2,8 +2,9 @@ import dataclasses
2
2
  import enum
3
3
  import itertools
4
4
 
5
+ from .comment_layout import LINE_LAYOUT, CommentLayout, CommentLine
5
6
  from .position import SourceRange
6
- from .prose import Prose, Sentence, break_cost, comma_breaks, reflow_sentence
7
+ from .prose import PLAIN_MARKUP, Prose, ProseMarkup, Sentence, break_cost, comma_breaks, reflow_sentence
7
8
  from .text import TextEdit
8
9
 
9
10
 
@@ -38,28 +39,6 @@ class SymbolRef:
38
39
  visibility: Visibility
39
40
 
40
41
 
41
- @dataclasses.dataclass(frozen=True, slots=True)
42
- class CommentLine:
43
- range: SourceRange
44
- indent: str
45
- delimiter: str
46
- text: str
47
- suffix: str = ''
48
-
49
- @property
50
- def physical_width(self) -> int:
51
- return len(self.indent) + len(self.delimiter) + len(self.text) + len(self.suffix)
52
-
53
- @property
54
- def prefix_width(self) -> int:
55
- return len(self.indent) + len(self.delimiter)
56
-
57
- def render(self) -> str:
58
- if not self.text and not self.suffix:
59
- return (self.indent + self.delimiter).rstrip()
60
- return self.indent + self.delimiter + self.text + self.suffix
61
-
62
-
63
42
  @dataclasses.dataclass(frozen=True, slots=True)
64
43
  class CommentGroup:
65
44
  range: SourceRange
@@ -67,6 +46,8 @@ class CommentGroup:
67
46
  form: CommentForm
68
47
  placement: CommentPlacement
69
48
  attachment: SymbolRef | None = None
49
+ layout: CommentLayout = LINE_LAYOUT
50
+ markup: ProseMarkup = PLAIN_MARKUP
70
51
 
71
52
  def __post_init__(self) -> None:
72
53
  if not self.lines:
@@ -85,6 +66,11 @@ class CommentGroup:
85
66
  def longest_line(self) -> int:
86
67
  return max(line.physical_width for line in self.lines)
87
68
 
69
+ @property
70
+ def has_summary_line(self) -> bool:
71
+ """Whether the first sentence is a one-line summary, as a style guide such as PEP 257 asks."""
72
+ return self.layout.summary_convention is not None
73
+
88
74
  @property
89
75
  def attaches_to_public_symbol(self) -> bool:
90
76
  return self.attachment is not None and self.attachment.visibility is Visibility.PUBLIC
@@ -96,51 +82,72 @@ class CommentGroup:
96
82
  ' ' * max(0, len(line.indent) - base) + line.text.rstrip() if line.text.strip() else ''
97
83
  for line in self.lines
98
84
  ]
99
- return Prose('\n'.join(rendered_lines))
85
+ return Prose('\n'.join(rendered_lines), self.markup)
86
+
87
+ def code_ranges(self) -> tuple[SourceRange, ...]:
88
+ """Where the lines holding code sit, which a prose checker has no business reading."""
89
+ return tuple(self.lines[index].range for index in sorted(self.prose().code_line_indexes()))
100
90
 
101
91
  def reflow(self, width: int, stub_floor: int = 0) -> 'CommentGroup':
92
+ # A trailing comment shares its line with code, so a second line would have no code to sit beside.
93
+ if self.placement is CommentPlacement.TRAILING:
94
+ return self
102
95
  # A one line docstring that fits is the summary convention, not a block to lay out.
103
96
  if self.form is CommentForm.DOC and self.line_count == 1 and self.longest_line <= width:
104
97
  return self
105
98
  texts = self._reflow_into_texts(width, stub_floor)
106
99
  # Text that ends up on one line closes there too, so the closing quotes need room beside it.
107
- closing_width = len(self._closing_quotes())
100
+ closing_width = len(self.closing())
108
101
  if len(texts) == 1 and len(texts[0]) > self.text_budget(width, first_line=True) - closing_width:
109
102
  texts = self._reflow_into_texts(width, stub_floor, closing_width)
110
103
  if not texts:
111
104
  return self
112
- return self._rebuild_lines(texts)
105
+ return dataclasses.replace(self, lines=self.layout.rebuild(self.lines, texts, width))
106
+
107
+ def sentences_held_by_a_list(self, width: int, stub_floor: int = 0) -> tuple[str, ...]:
108
+ """The sentences reflow leaves as the author broke them, since every legal break would split a list."""
109
+ held: list[str] = []
110
+ self._reflow_into_texts(width, stub_floor, held=held)
111
+ return tuple(held)
113
112
 
114
113
  def text_budget(self, width: int, *, first_line: bool) -> int:
115
114
  """The characters of text a line holds, which on a docstring's opening line also cede the quotes."""
116
- opening = self.lines[0]
117
- quoted = first_line or self.form is not CommentForm.DOC
118
- return max(20, width - (opening.prefix_width if quoted else len(opening.indent)))
115
+ return self.layout.text_budget(self.lines, width, first_line=first_line)
119
116
 
120
117
  # A sentence takes as few lines as fit, and the author's layout stays when it breaks no worse than the best one.
121
- # A sentence whose new layout would strand a stub or still overflow needs its author,
118
+ # A sentence whose new layout would strand a stub, split a list, or still overflow needs its author,
122
119
  # so it keeps the layout they gave it rather than one its own check rejects.
123
- def _reflow_into_texts(self, width: int, stub_floor: int, reserve: int = 0) -> tuple[str, ...]:
120
+ # A summary never breaks where the comment's style guide keeps it to one line.
121
+ def _reflow_into_texts(
122
+ self, width: int, stub_floor: int, reserve: int = 0, held: list[str] | None = None
123
+ ) -> tuple[str, ...]:
124
124
  body_budget = self.text_budget(width, first_line=False)
125
125
  out: list[str] = []
126
- for paragraph_index, (paragraph, is_literal) in enumerate(self._split_into_paragraphs()):
127
- if out:
126
+ for paragraph_index, (paragraph, is_literal, follows_blank) in enumerate(self._split_into_paragraphs()):
127
+ if out and (follows_blank or self.layout.parts_every_paragraph):
128
128
  out.append('')
129
129
  if is_literal:
130
130
  out.extend(paragraph)
131
131
  continue
132
132
  lines = tuple(line.strip() for line in paragraph if line.strip())
133
133
  for sentence_index, sentence in enumerate(Prose(' '.join(lines)).sentences()):
134
- if sentence_index == 1 and paragraph_index == 0 and self.form is CommentForm.DOC:
134
+ if sentence_index == 1 and paragraph_index == 0 and self.has_summary_line:
135
135
  out.append('')
136
136
  first_budget = self.text_budget(width, first_line=not out) - (reserve if not out else 0)
137
- best_lines = reflow_sentence(sentence.text, body_budget, stub_floor, first_width=first_budget)
137
+ if self.has_summary_line and paragraph_index == 0 and sentence_index == 0:
138
+ best_lines: tuple[str, ...] = (sentence.text,)
139
+ else:
140
+ best_lines = reflow_sentence(sentence.text, body_budget, stub_floor, first_width=first_budget)
138
141
  given_lines = _given_lines(lines, sentence)
139
142
  if given_lines and _keeps_given(given_lines, best_lines, (first_budget, body_budget), stub_floor):
140
143
  out.extend(given_lines)
141
- elif _is_legal(best_lines, (first_budget, body_budget), stub_floor):
144
+ elif _is_legal(best_lines, (first_budget, body_budget), stub_floor) and not (
145
+ given_lines and break_cost(best_lines)
146
+ ):
142
147
  out.extend(best_lines)
143
148
  elif given_lines:
149
+ if held is not None and _is_legal(best_lines, (first_budget, body_budget), stub_floor):
150
+ held.append(sentence.text)
144
151
  out.extend(given_lines)
145
152
  elif _strands_stub(best_lines, stub_floor):
146
153
  return ()
@@ -148,55 +155,28 @@ class CommentGroup:
148
155
  out.extend(best_lines)
149
156
  return tuple(out)
150
157
 
151
- def _split_into_paragraphs(self) -> tuple[tuple[tuple[str, ...], bool], ...]:
152
- paragraphs: list[tuple[tuple[str, ...], bool]] = []
158
+ def _split_into_paragraphs(self) -> tuple[tuple[tuple[str, ...], bool, bool], ...]:
159
+ """Each paragraph's lines, whether they are literal, and whether a blank line comes before them."""
160
+ paragraphs: list[tuple[tuple[str, ...], bool, bool]] = []
161
+ follows_blank = False
153
162
  for segment in self.prose().segments():
154
163
  current_lines: list[str] = []
155
164
  for line in segment.lines:
156
165
  if line.strip():
157
166
  current_lines.append(line)
158
- elif current_lines:
159
- paragraphs.append((tuple(current_lines), segment.is_literal))
167
+ continue
168
+ if current_lines:
169
+ paragraphs.append((tuple(current_lines), segment.is_literal, follows_blank))
160
170
  current_lines = []
171
+ follows_blank = True
161
172
  if current_lines:
162
- paragraphs.append((tuple(current_lines), segment.is_literal))
173
+ paragraphs.append((tuple(current_lines), segment.is_literal, follows_blank))
174
+ follows_blank = False
163
175
  return tuple(paragraphs)
164
176
 
165
- def _rebuild_lines(self, texts: tuple[str, ...]) -> 'CommentGroup':
166
- if self.form is not CommentForm.DOC:
167
- return self.with_texts(texts)
168
- opening, closing = self.lines[0], self.lines[-1]
169
- closes_alone = len(self.lines) > 1 and not closing.text.strip()
170
- body = [dataclasses.replace(opening, delimiter='', suffix='', text=text, range=opening.range) for text in texts]
171
- body[0] = dataclasses.replace(body[0], delimiter=opening.delimiter)
172
- # Text that fits one line closes on that line, as ruff and black format it.
173
- if len(texts) > 1:
174
- body.append(
175
- dataclasses.replace(closing, delimiter=closing.delimiter or opening.delimiter, text='', suffix='')
176
- )
177
- if not closes_alone:
178
- body[-1] = dataclasses.replace(body[-1], delimiter=opening.delimiter.strip())
179
- else:
180
- body[0] = dataclasses.replace(body[0], suffix=self._closing_quotes())
181
- return dataclasses.replace(self, lines=tuple(body))
182
-
183
- def _closing_quotes(self) -> str:
184
- if self.form is not CommentForm.DOC:
185
- return ''
186
- return self.lines[-1].suffix or self.lines[0].delimiter.strip()
187
-
188
- def with_texts(self, texts: tuple[str, ...]) -> 'CommentGroup':
189
- if not texts:
190
- raise ValueError('A comment block needs at least one line')
191
- template = self.lines[0]
192
- rebuilt = tuple(
193
- dataclasses.replace(
194
- self.lines[index] if index < len(self.lines) else template,
195
- text=text,
196
- )
197
- for index, text in enumerate(texts)
198
- )
199
- return dataclasses.replace(self, lines=rebuilt)
177
+ def closing(self) -> str:
178
+ """What closes the comment on its last text line, such as a docstring's quotes."""
179
+ return self.layout.closing(self.lines)
200
180
 
201
181
  def render(self) -> str:
202
182
  return '\n'.join(line.render() for line in self.lines)