subedit 0.1.5__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {subedit-0.1.5 → subedit-0.3.0}/.vale.ini +1 -1
- {subedit-0.1.5 → subedit-0.3.0}/PKG-INFO +23 -7
- {subedit-0.1.5 → subedit-0.3.0}/README.md +20 -4
- {subedit-0.1.5 → subedit-0.3.0}/docs/rules.md +1 -1
- {subedit-0.1.5 → subedit-0.3.0}/plugins/subedit/.claude-plugin/plugin.json +1 -1
- {subedit-0.1.5 → subedit-0.3.0}/plugins/subedit/scripts/run-subedit-hook.sh +1 -1
- {subedit-0.1.5 → subedit-0.3.0}/plugins/subedit/skills/judging-findings/SKILL.md +3 -2
- {subedit-0.1.5 → subedit-0.3.0}/pyproject.toml +3 -3
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/__init__.py +1 -1
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/application/__init__.py +2 -1
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/application/linter.py +23 -4
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/application/report_aggregator.py +4 -2
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/__init__.py +18 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/models/__init__.py +28 -2
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/models/comment.py +34 -70
- subedit-0.3.0/src/subedit/domain/models/comment_layout.py +130 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/models/diagnostic.py +5 -2
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/models/prose.py +65 -2
- subedit-0.3.0/src/subedit/domain/models/prose_map.py +44 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/models/rule_set.py +5 -1
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/models/text.py +13 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/ports/source_parser.py +5 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/rules/layout.py +13 -1
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/rules/rewrite.py +16 -10
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/rules/structure.py +40 -15
- subedit-0.3.0/src/subedit/infrastructure/__init__.py +77 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/__init__.py +3 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/config_schema.py +2 -1
- subedit-0.3.0/src/subedit/infrastructure/config_sources/formatter_width.py +92 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/toml_config_source.py +21 -9
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/linters/autocorrect_linter.py +16 -5
- subedit-0.3.0/src/subedit/infrastructure/linters/source_path.py +12 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/linters/vale_linter.py +10 -2
- subedit-0.3.0/src/subedit/infrastructure/parsers/__init__.py +24 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/__init__.py +4 -0
- subedit-0.3.0/src/subedit/infrastructure/parsers/languages/code_line.py +52 -0
- subedit-0.3.0/src/subedit/infrastructure/parsers/languages/language_profile.py +61 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/python_profile.py +62 -3
- subedit-0.3.0/src/subedit/infrastructure/parsers/languages/typescript_profile.py +216 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +81 -23
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/cli.py +28 -7
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/harnesses/__init__.py +1 -3
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/hook.py +5 -2
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/Ellipsis.yml +2 -1
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/ExternalRef.yml +1 -1
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/vale.ini +1 -1
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_cli.py +14 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_comment.py +9 -3
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_config.py +48 -1
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_diagnostic.py +9 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_fixer.py +2 -1
- subedit-0.3.0/tests/test_language_parity.py +126 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_linter.py +20 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_parser.py +26 -2
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_parser_ocp.py +3 -1
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_parser_oracle.py +4 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_report_aggregator.py +74 -1
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_rules_layout.py +22 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_rules_structure.py +5 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_schema.py +2 -2
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_text.py +8 -0
- subedit-0.3.0/tests/test_typescript.py +384 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_vale_style.py +24 -0
- {subedit-0.1.5 → subedit-0.3.0}/uv.lock +3 -3
- subedit-0.1.5/src/subedit/infrastructure/__init__.py +0 -47
- subedit-0.1.5/src/subedit/infrastructure/parsers/__init__.py +0 -12
- subedit-0.1.5/src/subedit/infrastructure/parsers/languages/language_profile.py +0 -33
- {subedit-0.1.5 → subedit-0.3.0}/.claude/settings.json +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/.claude-plugin/marketplace.json +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/.github/dependabot.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/.github/release.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/.github/workflows/ci.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/.github/workflows/pr.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/.github/workflows/release.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/.gitignore +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/LICENSE +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/examples/README.md +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/examples/probe.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/examples/subedit.toml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/examples/walkthrough.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/plugins/subedit/hooks/hooks.json +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/application/corpus_meter.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/application/fixer.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/models/document.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/models/position.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/ports/__init__.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/ports/external_linter.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/ports/language_conventions.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/ports/rule.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/domain/rules/__init__.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/infrastructure/linters/__init__.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/__init__.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/report.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/presentation/rule_reference.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/DateRef.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/EditHistory.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/EmDash.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/MidSentenceColon.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/MidSentenceColonMarkup.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/Semicolon.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/src/subedit/vale/styles/subedit/SignatureTag.yml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/subedit.toml +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/fixtures/autocorrect/unspaced.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/fixtures/vale/clean.md +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/fixtures/vale/violations.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_corpus_meter.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_harnesses.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_hook.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_plugin.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_position.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_prose.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_rules_rewrite.py +0 -0
- {subedit-0.1.5 → subedit-0.3.0}/tests/test_self_hosting.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: subedit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A linter and formatter for the prose inside code comments, across languages.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mroops0111/subedit
|
|
6
6
|
Project-URL: Repository, https://github.com/mroops0111/subedit
|
|
@@ -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,27 @@ 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 | Width read from |
|
|
155
|
+
| --- | --- | --- | --- |
|
|
156
|
+
| Python | `.py`, `.pyi` | `"""` docstring | `line-length` for ruff or black |
|
|
157
|
+
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | `printWidth` for Prettier, `lineWidth` for Biome |
|
|
158
|
+
|
|
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 plain comment opening a paragraph over several declarations heads them as a group, so it documents none of them. One right under another declaration may be either, so `plain-comment-on-public` reports it as a warning.
|
|
161
|
+
A JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written, as a docstring's sections from `Args:`, `Parameters`, or `:param` onward are.
|
|
162
|
+
A comment line that the language's own grammar reads as code, such as `// return result` or `# client.send(`, is left as written in either language.
|
|
163
|
+
A plain `/* */` block and a directive such as `// eslint-disable-next-line` carry no prose for subedit to lay out, so only Vale reads the block, and nothing reads the directive.
|
|
164
|
+
A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
|
|
165
|
+
|
|
152
166
|
## Configuration
|
|
153
167
|
|
|
154
168
|
Settings come from `subedit.toml`, or from `[tool.subedit]` in `pyproject.toml`, whichever sits closest above the file being checked.
|
|
155
169
|
|
|
156
170
|
```toml
|
|
157
171
|
[subedit]
|
|
158
|
-
line-width =
|
|
172
|
+
line-width = 100
|
|
159
173
|
exclude = ["tests/fixtures/**"]
|
|
160
174
|
|
|
161
175
|
[rules]
|
|
@@ -163,6 +177,8 @@ mid-clause-break = "error"
|
|
|
163
177
|
block-too-long = { severity = "warning", line = 3, doc-public = 20 }
|
|
164
178
|
```
|
|
165
179
|
|
|
180
|
+
Left unset, `line-width` follows the project's formatter, from the config the formatter would read for that file, and is 120 where no formatter sets one.
|
|
181
|
+
|
|
166
182
|
Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
|
|
167
183
|
|
|
168
184
|
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 +210,12 @@ Code comments are the first surface, and Markdown comes next.
|
|
|
194
210
|
|
|
195
211
|
## Related Tools
|
|
196
212
|
|
|
197
|
-
`subedit` covers the one thing the usual
|
|
213
|
+
`subedit` covers the one thing the usual linters and type checkers leave alone.
|
|
198
214
|
|
|
199
215
|
| Tool | Checks |
|
|
200
216
|
| --- | --- |
|
|
201
|
-
| ruff | code structure |
|
|
202
|
-
| basedpyright | types |
|
|
217
|
+
| ruff, ESLint | code structure |
|
|
218
|
+
| basedpyright, tsc | types |
|
|
203
219
|
| **subedit** | **the natural language inside source** |
|
|
204
220
|
|
|
205
221
|
## License
|
|
@@ -115,13 +115,27 @@ 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 | Width read from |
|
|
121
|
+
| --- | --- | --- | --- |
|
|
122
|
+
| Python | `.py`, `.pyi` | `"""` docstring | `line-length` for ruff or black |
|
|
123
|
+
| TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | `printWidth` for Prettier, `lineWidth` for Biome |
|
|
124
|
+
|
|
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 plain comment opening a paragraph over several declarations heads them as a group, so it documents none of them. One right under another declaration may be either, so `plain-comment-on-public` reports it as a warning.
|
|
127
|
+
A JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written, as a docstring's sections from `Args:`, `Parameters`, or `:param` onward are.
|
|
128
|
+
A comment line that the language's own grammar reads as code, such as `// return result` or `# client.send(`, is left as written in either language.
|
|
129
|
+
A plain `/* */` block and a directive such as `// eslint-disable-next-line` carry no prose for subedit to lay out, so only Vale reads the block, and nothing reads the directive.
|
|
130
|
+
A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
|
|
131
|
+
|
|
118
132
|
## Configuration
|
|
119
133
|
|
|
120
134
|
Settings come from `subedit.toml`, or from `[tool.subedit]` in `pyproject.toml`, whichever sits closest above the file being checked.
|
|
121
135
|
|
|
122
136
|
```toml
|
|
123
137
|
[subedit]
|
|
124
|
-
line-width =
|
|
138
|
+
line-width = 100
|
|
125
139
|
exclude = ["tests/fixtures/**"]
|
|
126
140
|
|
|
127
141
|
[rules]
|
|
@@ -129,6 +143,8 @@ mid-clause-break = "error"
|
|
|
129
143
|
block-too-long = { severity = "warning", line = 3, doc-public = 20 }
|
|
130
144
|
```
|
|
131
145
|
|
|
146
|
+
Left unset, `line-width` follows the project's formatter, from the config the formatter would read for that file, and is 120 where no formatter sets one.
|
|
147
|
+
|
|
132
148
|
Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
|
|
133
149
|
|
|
134
150
|
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 +176,12 @@ Code comments are the first surface, and Markdown comes next.
|
|
|
160
176
|
|
|
161
177
|
## Related Tools
|
|
162
178
|
|
|
163
|
-
`subedit` covers the one thing the usual
|
|
179
|
+
`subedit` covers the one thing the usual linters and type checkers leave alone.
|
|
164
180
|
|
|
165
181
|
| Tool | Checks |
|
|
166
182
|
| --- | --- |
|
|
167
|
-
| ruff | code structure |
|
|
168
|
-
| basedpyright | types |
|
|
183
|
+
| ruff, ESLint | code structure |
|
|
184
|
+
| basedpyright, tsc | types |
|
|
169
185
|
| **subedit** | **the natural language inside source** |
|
|
170
186
|
|
|
171
187
|
## 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, splitting no list, wherever it has to break, and a
|
|
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. |
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# because the first run installs subedit and downloads Vale, which no edit should wait on.
|
|
5
5
|
set -uo pipefail
|
|
6
6
|
|
|
7
|
-
SPEC='subedit[vale]>=0.
|
|
7
|
+
SPEC='subedit[vale]>=0.3.0'
|
|
8
8
|
# A bare uvx takes the first Python it finds, which on a stock Mac is too old for subedit.
|
|
9
9
|
PYTHON='>=3.11'
|
|
10
10
|
|
|
@@ -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
|
-
- `
|
|
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.
|
|
3
|
+
version = "0.3.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,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',
|
|
@@ -1,8 +1,9 @@
|
|
|
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
|
+
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
|
|
@@ -26,7 +27,13 @@ class RuleEngine:
|
|
|
26
27
|
return tuple(diagnostics)
|
|
27
28
|
|
|
28
29
|
def _with_severity(self, diagnostic: Diagnostic, rule: Rule, rule_set: RuleSet) -> Diagnostic:
|
|
29
|
-
|
|
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
|
|
30
37
|
if diagnostic.severity is severity:
|
|
31
38
|
return diagnostic
|
|
32
39
|
return dataclasses.replace(diagnostic, severity=severity)
|
|
@@ -41,15 +48,27 @@ class RuleEngine:
|
|
|
41
48
|
return duplicate_ids
|
|
42
49
|
|
|
43
50
|
|
|
51
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
52
|
+
class Inspection:
|
|
53
|
+
"""What one parse of a document yields, the findings and the map of where its prose lies."""
|
|
54
|
+
|
|
55
|
+
report: Report = dataclasses.field(default_factory=Report)
|
|
56
|
+
prose_map: ProseMap = dataclasses.field(default_factory=ProseMap)
|
|
57
|
+
|
|
58
|
+
|
|
44
59
|
class Linter:
|
|
45
60
|
def __init__(self, parser: SourceParser, engine: RuleEngine) -> None:
|
|
46
61
|
self._parser = parser
|
|
47
62
|
self._engine = engine
|
|
48
63
|
|
|
49
64
|
def run(self, document: Document, rule_set: RuleSet) -> Report:
|
|
65
|
+
return self.inspect(document, rule_set).report
|
|
66
|
+
|
|
67
|
+
def inspect(self, document: Document, rule_set: RuleSet) -> Inspection:
|
|
50
68
|
if not self._parser.supports(document.language_id):
|
|
51
|
-
return
|
|
69
|
+
return Inspection()
|
|
52
70
|
groups = self._parser.parse(document)
|
|
53
71
|
diagnostics = self._engine.run(document, groups, rule_set)
|
|
54
72
|
sorted_diagnostics = sorted(diagnostics, key=lambda item: (item.range.start, item.rule_id))
|
|
55
|
-
|
|
73
|
+
prose_map = ProseMap.of_comments(groups, self._parser.directive_ranges(document))
|
|
74
|
+
return Inspection(Report(tuple(sorted_diagnostics)), prose_map)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -36,28 +37,8 @@ class SymbolRef:
|
|
|
36
37
|
name: str
|
|
37
38
|
kind: SymbolKind
|
|
38
39
|
visibility: Visibility
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
61
42
|
|
|
62
43
|
|
|
63
44
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
@@ -67,6 +48,8 @@ class CommentGroup:
|
|
|
67
48
|
form: CommentForm
|
|
68
49
|
placement: CommentPlacement
|
|
69
50
|
attachment: SymbolRef | None = None
|
|
51
|
+
layout: CommentLayout = LINE_LAYOUT
|
|
52
|
+
markup: ProseMarkup = PLAIN_MARKUP
|
|
70
53
|
|
|
71
54
|
def __post_init__(self) -> None:
|
|
72
55
|
if not self.lines:
|
|
@@ -85,6 +68,11 @@ class CommentGroup:
|
|
|
85
68
|
def longest_line(self) -> int:
|
|
86
69
|
return max(line.physical_width for line in self.lines)
|
|
87
70
|
|
|
71
|
+
@property
|
|
72
|
+
def has_summary_line(self) -> bool:
|
|
73
|
+
"""Whether the first sentence is a one-line summary, as a style guide such as PEP 257 asks."""
|
|
74
|
+
return self.layout.summary_convention is not None
|
|
75
|
+
|
|
88
76
|
@property
|
|
89
77
|
def attaches_to_public_symbol(self) -> bool:
|
|
90
78
|
return self.attachment is not None and self.attachment.visibility is Visibility.PUBLIC
|
|
@@ -96,20 +84,32 @@ class CommentGroup:
|
|
|
96
84
|
' ' * max(0, len(line.indent) - base) + line.text.rstrip() if line.text.strip() else ''
|
|
97
85
|
for line in self.lines
|
|
98
86
|
]
|
|
99
|
-
return Prose('\n'.join(rendered_lines))
|
|
87
|
+
return Prose('\n'.join(rendered_lines), self.markup)
|
|
88
|
+
|
|
89
|
+
def code_ranges(self) -> tuple[SourceRange, ...]:
|
|
90
|
+
"""Where the lines holding code sit, which a prose checker has no business reading."""
|
|
91
|
+
return tuple(self.lines[index].range for index in sorted(self.prose().code_line_indexes()))
|
|
100
92
|
|
|
101
93
|
def reflow(self, width: int, stub_floor: int = 0) -> 'CommentGroup':
|
|
94
|
+
# A trailing comment shares its line with code, so a second line would have no code to sit beside.
|
|
95
|
+
if self.placement is CommentPlacement.TRAILING:
|
|
96
|
+
return self
|
|
102
97
|
# A one line docstring that fits is the summary convention, not a block to lay out.
|
|
103
98
|
if self.form is CommentForm.DOC and self.line_count == 1 and self.longest_line <= width:
|
|
104
99
|
return self
|
|
105
100
|
texts = self._reflow_into_texts(width, stub_floor)
|
|
106
101
|
# Text that ends up on one line closes there too, so the closing quotes need room beside it.
|
|
107
|
-
closing_width = len(self.
|
|
102
|
+
closing_width = len(self.closing())
|
|
108
103
|
if len(texts) == 1 and len(texts[0]) > self.text_budget(width, first_line=True) - closing_width:
|
|
109
104
|
texts = self._reflow_into_texts(width, stub_floor, closing_width)
|
|
110
105
|
if not texts:
|
|
111
106
|
return self
|
|
112
|
-
|
|
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
|
|
113
113
|
|
|
114
114
|
def sentences_held_by_a_list(self, width: int, stub_floor: int = 0) -> tuple[str, ...]:
|
|
115
115
|
"""The sentences reflow leaves as the author broke them, since every legal break would split a list."""
|
|
@@ -119,33 +119,29 @@ class CommentGroup:
|
|
|
119
119
|
|
|
120
120
|
def text_budget(self, width: int, *, first_line: bool) -> int:
|
|
121
121
|
"""The characters of text a line holds, which on a docstring's opening line also cede the quotes."""
|
|
122
|
-
|
|
123
|
-
quoted = first_line or self.form is not CommentForm.DOC
|
|
124
|
-
return max(20, width - (opening.prefix_width if quoted else len(opening.indent)))
|
|
122
|
+
return self.layout.text_budget(self.lines, width, first_line=first_line)
|
|
125
123
|
|
|
126
124
|
# A sentence takes as few lines as fit, and the author's layout stays when it breaks no worse than the best one.
|
|
127
125
|
# A sentence whose new layout would strand a stub, split a list, or still overflow needs its author,
|
|
128
126
|
# so it keeps the layout they gave it rather than one its own check rejects.
|
|
129
|
-
# A
|
|
127
|
+
# A summary never breaks where the comment's style guide keeps it to one line.
|
|
130
128
|
def _reflow_into_texts(
|
|
131
129
|
self, width: int, stub_floor: int, reserve: int = 0, held: list[str] | None = None
|
|
132
130
|
) -> tuple[str, ...]:
|
|
133
131
|
body_budget = self.text_budget(width, first_line=False)
|
|
134
132
|
out: list[str] = []
|
|
135
133
|
for paragraph_index, (paragraph, is_literal, follows_blank) in enumerate(self._split_into_paragraphs()):
|
|
136
|
-
|
|
137
|
-
# while a line comment's list may sit right under the line that introduces it.
|
|
138
|
-
if out and (follows_blank or self.form is CommentForm.DOC):
|
|
134
|
+
if out and (follows_blank or self.layout.parts_every_paragraph):
|
|
139
135
|
out.append('')
|
|
140
136
|
if is_literal:
|
|
141
137
|
out.extend(paragraph)
|
|
142
138
|
continue
|
|
143
139
|
lines = tuple(line.strip() for line in paragraph if line.strip())
|
|
144
140
|
for sentence_index, sentence in enumerate(Prose(' '.join(lines)).sentences()):
|
|
145
|
-
if sentence_index == 1 and paragraph_index == 0 and self.
|
|
141
|
+
if sentence_index == 1 and paragraph_index == 0 and self.has_summary_line:
|
|
146
142
|
out.append('')
|
|
147
143
|
first_budget = self.text_budget(width, first_line=not out) - (reserve if not out else 0)
|
|
148
|
-
if self.
|
|
144
|
+
if self.has_summary_line and paragraph_index == 0 and sentence_index == 0:
|
|
149
145
|
best_lines: tuple[str, ...] = (sentence.text,)
|
|
150
146
|
else:
|
|
151
147
|
best_lines = reflow_sentence(sentence.text, body_budget, stub_floor, first_width=first_budget)
|
|
@@ -185,41 +181,9 @@ class CommentGroup:
|
|
|
185
181
|
follows_blank = False
|
|
186
182
|
return tuple(paragraphs)
|
|
187
183
|
|
|
188
|
-
def
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
opening, closing = self.lines[0], self.lines[-1]
|
|
192
|
-
closes_alone = len(self.lines) > 1 and not closing.text.strip()
|
|
193
|
-
body = [dataclasses.replace(opening, delimiter='', suffix='', text=text, range=opening.range) for text in texts]
|
|
194
|
-
body[0] = dataclasses.replace(body[0], delimiter=opening.delimiter)
|
|
195
|
-
# Text that fits one line closes on that line, as ruff and black format it.
|
|
196
|
-
if len(texts) > 1:
|
|
197
|
-
body.append(
|
|
198
|
-
dataclasses.replace(closing, delimiter=closing.delimiter or opening.delimiter, text='', suffix='')
|
|
199
|
-
)
|
|
200
|
-
if not closes_alone:
|
|
201
|
-
body[-1] = dataclasses.replace(body[-1], delimiter=opening.delimiter.strip())
|
|
202
|
-
else:
|
|
203
|
-
body[0] = dataclasses.replace(body[0], suffix=self.closing_quotes())
|
|
204
|
-
return dataclasses.replace(self, lines=tuple(body))
|
|
205
|
-
|
|
206
|
-
def closing_quotes(self) -> str:
|
|
207
|
-
if self.form is not CommentForm.DOC:
|
|
208
|
-
return ''
|
|
209
|
-
return self.lines[-1].suffix or self.lines[0].delimiter.strip()
|
|
210
|
-
|
|
211
|
-
def with_texts(self, texts: tuple[str, ...]) -> 'CommentGroup':
|
|
212
|
-
if not texts:
|
|
213
|
-
raise ValueError('A comment block needs at least one line')
|
|
214
|
-
template = self.lines[0]
|
|
215
|
-
rebuilt = tuple(
|
|
216
|
-
dataclasses.replace(
|
|
217
|
-
self.lines[index] if index < len(self.lines) else template,
|
|
218
|
-
text=text,
|
|
219
|
-
)
|
|
220
|
-
for index, text in enumerate(texts)
|
|
221
|
-
)
|
|
222
|
-
return dataclasses.replace(self, lines=rebuilt)
|
|
184
|
+
def closing(self) -> str:
|
|
185
|
+
"""What closes the comment on its last text line, such as a docstring's quotes."""
|
|
186
|
+
return self.layout.closing(self.lines)
|
|
223
187
|
|
|
224
188
|
def render(self) -> str:
|
|
225
189
|
return '\n'.join(line.render() for line in self.lines)
|