subedit 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {subedit-0.2.0 → subedit-0.3.0}/PKG-INFO +8 -6
  2. {subedit-0.2.0 → subedit-0.3.0}/README.md +7 -5
  3. {subedit-0.2.0 → subedit-0.3.0}/plugins/subedit/.claude-plugin/plugin.json +1 -1
  4. {subedit-0.2.0 → subedit-0.3.0}/plugins/subedit/scripts/run-subedit-hook.sh +1 -1
  5. {subedit-0.2.0 → subedit-0.3.0}/pyproject.toml +1 -1
  6. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/__init__.py +1 -1
  7. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/linter.py +10 -3
  8. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/comment.py +8 -1
  9. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/diagnostic.py +5 -2
  10. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/prose.py +7 -4
  11. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/prose_map.py +5 -2
  12. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/text.py +13 -0
  13. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/source_parser.py +5 -0
  14. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/rules/rewrite.py +7 -1
  15. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/rules/structure.py +14 -5
  16. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/__init__.py +4 -2
  17. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/__init__.py +3 -0
  18. subedit-0.3.0/src/subedit/infrastructure/config_sources/formatter_width.py +92 -0
  19. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/toml_config_source.py +6 -5
  20. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/linters/autocorrect_linter.py +3 -3
  21. subedit-0.3.0/src/subedit/infrastructure/parsers/languages/code_line.py +52 -0
  22. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/language_profile.py +7 -2
  23. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/python_profile.py +57 -2
  24. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/typescript_profile.py +45 -9
  25. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +56 -14
  26. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/cli.py +4 -3
  27. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/Ellipsis.yml +2 -1
  28. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/ExternalRef.yml +1 -1
  29. {subedit-0.2.0 → subedit-0.3.0}/tests/test_cli.py +14 -0
  30. {subedit-0.2.0 → subedit-0.3.0}/tests/test_config.py +48 -1
  31. {subedit-0.2.0 → subedit-0.3.0}/tests/test_diagnostic.py +9 -0
  32. subedit-0.3.0/tests/test_language_parity.py +126 -0
  33. {subedit-0.2.0 → subedit-0.3.0}/tests/test_parser.py +1 -1
  34. {subedit-0.2.0 → subedit-0.3.0}/tests/test_parser_oracle.py +4 -0
  35. {subedit-0.2.0 → subedit-0.3.0}/tests/test_report_aggregator.py +34 -0
  36. {subedit-0.2.0 → subedit-0.3.0}/tests/test_text.py +8 -0
  37. {subedit-0.2.0 → subedit-0.3.0}/tests/test_typescript.py +105 -3
  38. {subedit-0.2.0 → subedit-0.3.0}/tests/test_vale_style.py +24 -0
  39. {subedit-0.2.0 → subedit-0.3.0}/uv.lock +1 -1
  40. {subedit-0.2.0 → subedit-0.3.0}/.claude/settings.json +0 -0
  41. {subedit-0.2.0 → subedit-0.3.0}/.claude-plugin/marketplace.json +0 -0
  42. {subedit-0.2.0 → subedit-0.3.0}/.github/dependabot.yml +0 -0
  43. {subedit-0.2.0 → subedit-0.3.0}/.github/release.yml +0 -0
  44. {subedit-0.2.0 → subedit-0.3.0}/.github/workflows/ci.yml +0 -0
  45. {subedit-0.2.0 → subedit-0.3.0}/.github/workflows/pr.yml +0 -0
  46. {subedit-0.2.0 → subedit-0.3.0}/.github/workflows/release.yml +0 -0
  47. {subedit-0.2.0 → subedit-0.3.0}/.gitignore +0 -0
  48. {subedit-0.2.0 → subedit-0.3.0}/.vale.ini +0 -0
  49. {subedit-0.2.0 → subedit-0.3.0}/LICENSE +0 -0
  50. {subedit-0.2.0 → subedit-0.3.0}/docs/rules.md +0 -0
  51. {subedit-0.2.0 → subedit-0.3.0}/examples/README.md +0 -0
  52. {subedit-0.2.0 → subedit-0.3.0}/examples/probe.py +0 -0
  53. {subedit-0.2.0 → subedit-0.3.0}/examples/subedit.toml +0 -0
  54. {subedit-0.2.0 → subedit-0.3.0}/examples/walkthrough.py +0 -0
  55. {subedit-0.2.0 → subedit-0.3.0}/plugins/subedit/hooks/hooks.json +0 -0
  56. {subedit-0.2.0 → subedit-0.3.0}/plugins/subedit/skills/judging-findings/SKILL.md +0 -0
  57. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/__init__.py +0 -0
  58. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/corpus_meter.py +0 -0
  59. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/fixer.py +0 -0
  60. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/application/report_aggregator.py +0 -0
  61. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/__init__.py +0 -0
  62. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/__init__.py +0 -0
  63. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/comment_layout.py +0 -0
  64. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/document.py +0 -0
  65. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/position.py +0 -0
  66. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/models/rule_set.py +0 -0
  67. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/__init__.py +0 -0
  68. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/external_linter.py +0 -0
  69. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/language_conventions.py +0 -0
  70. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/ports/rule.py +0 -0
  71. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/rules/__init__.py +0 -0
  72. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/domain/rules/layout.py +0 -0
  73. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/config_sources/config_schema.py +0 -0
  74. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/linters/__init__.py +0 -0
  75. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/linters/source_path.py +0 -0
  76. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/linters/vale_linter.py +0 -0
  77. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/__init__.py +0 -0
  78. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/infrastructure/parsers/languages/__init__.py +0 -0
  79. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/__init__.py +0 -0
  80. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/harnesses/__init__.py +0 -0
  81. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
  82. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
  83. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
  84. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/hook.py +0 -0
  85. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/report.py +0 -0
  86. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/presentation/rule_reference.py +0 -0
  87. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/DateRef.yml +0 -0
  88. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/EditHistory.yml +0 -0
  89. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/EmDash.yml +0 -0
  90. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/MidSentenceColon.yml +0 -0
  91. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/MidSentenceColonMarkup.yml +0 -0
  92. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/Semicolon.yml +0 -0
  93. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/styles/subedit/SignatureTag.yml +0 -0
  94. {subedit-0.2.0 → subedit-0.3.0}/src/subedit/vale/vale.ini +0 -0
  95. {subedit-0.2.0 → subedit-0.3.0}/subedit.toml +0 -0
  96. {subedit-0.2.0 → subedit-0.3.0}/tests/fixtures/autocorrect/unspaced.py +0 -0
  97. {subedit-0.2.0 → subedit-0.3.0}/tests/fixtures/vale/clean.md +0 -0
  98. {subedit-0.2.0 → subedit-0.3.0}/tests/fixtures/vale/violations.py +0 -0
  99. {subedit-0.2.0 → subedit-0.3.0}/tests/test_comment.py +0 -0
  100. {subedit-0.2.0 → subedit-0.3.0}/tests/test_corpus_meter.py +0 -0
  101. {subedit-0.2.0 → subedit-0.3.0}/tests/test_fixer.py +0 -0
  102. {subedit-0.2.0 → subedit-0.3.0}/tests/test_harnesses.py +0 -0
  103. {subedit-0.2.0 → subedit-0.3.0}/tests/test_hook.py +0 -0
  104. {subedit-0.2.0 → subedit-0.3.0}/tests/test_linter.py +0 -0
  105. {subedit-0.2.0 → subedit-0.3.0}/tests/test_parser_ocp.py +0 -0
  106. {subedit-0.2.0 → subedit-0.3.0}/tests/test_plugin.py +0 -0
  107. {subedit-0.2.0 → subedit-0.3.0}/tests/test_position.py +0 -0
  108. {subedit-0.2.0 → subedit-0.3.0}/tests/test_prose.py +0 -0
  109. {subedit-0.2.0 → subedit-0.3.0}/tests/test_rules_layout.py +0 -0
  110. {subedit-0.2.0 → subedit-0.3.0}/tests/test_rules_rewrite.py +0 -0
  111. {subedit-0.2.0 → subedit-0.3.0}/tests/test_rules_structure.py +0 -0
  112. {subedit-0.2.0 → subedit-0.3.0}/tests/test_schema.py +0 -0
  113. {subedit-0.2.0 → subedit-0.3.0}/tests/test_self_hosting.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: subedit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A linter and formatter for the prose inside code comments, across languages.
5
5
  Project-URL: Homepage, https://github.com/mroops0111/subedit
6
6
  Project-URL: Repository, https://github.com/mroops0111/subedit
@@ -151,13 +151,15 @@ Registering the hook with both agents is the normal case. Each agent names its e
151
151
 
152
152
  ## Languages
153
153
 
154
- | Language | Files | Doc comment | Default width |
154
+ | Language | Files | Doc comment | Width read from |
155
155
  | --- | --- | --- | --- |
156
- | Python | `.py`, `.pyi` | `"""` docstring | 120 |
157
- | TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | 80, as Prettier sets it |
156
+ | Python | `.py`, `.pyi` | `"""` docstring | `line-length` for ruff or black |
157
+ | TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | `printWidth` for Prettier, `lineWidth` for Biome |
158
158
 
159
159
  A TypeScript symbol is public when it is exported, and a class member stays public unless it is `private`, `protected`, or `#named`.
160
- A JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written.
160
+ A plain comment opening a paragraph over several declarations heads them as a group, so it documents none of them. One right under another declaration may be either, so `plain-comment-on-public` reports it as a warning.
161
+ A JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written, as a docstring's sections from `Args:`, `Parameters`, or `:param` onward are.
162
+ A comment line that the language's own grammar reads as code, such as `// return result` or `# client.send(`, is left as written in either language.
161
163
  A plain `/* */` block and a directive such as `// eslint-disable-next-line` carry no prose for subedit to lay out, so only Vale reads the block, and nothing reads the directive.
162
164
  A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
163
165
 
@@ -175,7 +177,7 @@ mid-clause-break = "error"
175
177
  block-too-long = { severity = "warning", line = 3, doc-public = 20 }
176
178
  ```
177
179
 
178
- Left unset, `line-width` takes the default of each file's language.
180
+ Left unset, `line-width` follows the project's formatter, from the config the formatter would read for that file, and is 120 where no formatter sets one.
179
181
 
180
182
  Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
181
183
 
@@ -117,13 +117,15 @@ Registering the hook with both agents is the normal case. Each agent names its e
117
117
 
118
118
  ## Languages
119
119
 
120
- | Language | Files | Doc comment | Default width |
120
+ | Language | Files | Doc comment | Width read from |
121
121
  | --- | --- | --- | --- |
122
- | Python | `.py`, `.pyi` | `"""` docstring | 120 |
123
- | TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | 80, as Prettier sets it |
122
+ | Python | `.py`, `.pyi` | `"""` docstring | `line-length` for ruff or black |
123
+ | TypeScript | `.ts`, `.mts`, `.cts`, `.tsx` | `/** */` JSDoc | `printWidth` for Prettier, `lineWidth` for Biome |
124
124
 
125
125
  A TypeScript symbol is public when it is exported, and a class member stays public unless it is `private`, `protected`, or `#named`.
126
- A JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written.
126
+ A plain comment opening a paragraph over several declarations heads them as a group, so it documents none of them. One right under another declaration may be either, so `plain-comment-on-public` reports it as a warning.
127
+ A JSDoc block keeps its frame when reflowed. Its tag section, from the first tag such as `@throws` onward, is left as written, as a docstring's sections from `Args:`, `Parameters`, or `:param` onward are.
128
+ A comment line that the language's own grammar reads as code, such as `// return result` or `# client.send(`, is left as written in either language.
127
129
  A plain `/* */` block and a directive such as `// eslint-disable-next-line` carry no prose for subedit to lay out, so only Vale reads the block, and nothing reads the directive.
128
130
  A directory walk skips dotted directories such as `.venv`, and the package directories each language installs into, such as `node_modules` or `venv`.
129
131
 
@@ -141,7 +143,7 @@ mid-clause-break = "error"
141
143
  block-too-long = { severity = "warning", line = 3, doc-public = 20 }
142
144
  ```
143
145
 
144
- Left unset, `line-width` takes the default of each file's language.
146
+ Left unset, `line-width` follows the project's formatter, from the config the formatter would read for that file, and is 120 where no formatter sets one.
145
147
 
146
148
  Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
147
149
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "subedit",
3
3
  "displayName": "subedit",
4
- "version": "0.2.0",
4
+ "version": "0.3.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.2.0'
7
+ SPEC='subedit[vale]>=0.3.0'
8
8
  # A bare uvx takes the first Python it finds, which on a stock Mac is too old for subedit.
9
9
  PYTHON='>=3.11'
10
10
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "subedit"
3
- version = "0.2.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"},
@@ -1,3 +1,3 @@
1
- __version__ = '0.2.0'
1
+ __version__ = '0.3.0'
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."""
@@ -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.
@@ -60,12 +61,12 @@ class ProseMarkup:
60
61
  """What a language writes in its comments besides prose, which reflow leaves as written.
61
62
 
62
63
  A tag section, such as JSDoc's from its first @throws on, runs to the end of the comment.
63
- An example tag's body is code, and so is a line commented out of a language whose statements look like one.
64
+ An example tag's body is code, and so is a line that the language's grammar reads as code commented out.
64
65
  """
65
66
 
66
67
  tag_section: re.Pattern[str] | None = None
67
68
  example_tag: str | None = None
68
- code_line: re.Pattern[str] | None = None
69
+ code_line: typing.Callable[[str], bool] | None = None
69
70
 
70
71
  def opens_tag_section(self, line: str) -> bool:
71
72
  return self.tag_section is not None and bool(self.tag_section.match(line))
@@ -74,7 +75,7 @@ class ProseMarkup:
74
75
  return self.example_tag is not None and line.strip().startswith(self.example_tag)
75
76
 
76
77
  def is_code(self, line: str) -> bool:
77
- return self.code_line is not None and bool(self.code_line.search(line))
78
+ return self.code_line is not None and self.code_line(line)
78
79
 
79
80
 
80
81
  PLAIN_MARKUP = ProseMarkup()
@@ -167,8 +168,10 @@ class Prose:
167
168
  indexes.add(index)
168
169
  return frozenset(indexes)
169
170
 
171
+ # A line set two spaces past the margin, such as a tree diagram or an aligned table, is laid out by hand.
172
+ # Markdown wants four for a code block, but a comment's prefix already takes the space a writer would count.
170
173
  def _is_indented(self, line: str) -> bool:
171
- return line.startswith((' ', '\t'))
174
+ return line.startswith((' ', '\t'))
172
175
 
173
176
  # A wrapped list item is indented under its marker rather than to a fixed width,
174
177
  # so the continuation belongs to the item instead of opening a paragraph.
@@ -15,11 +15,14 @@ class ProseMap:
15
15
 
16
16
  editable: tuple[SourceRange, ...] = ()
17
17
  code: tuple[SourceRange, ...] = ()
18
+ # A directive's text, such as the reason after @ts-expect-error, is read by a tool, so nothing reads it as prose.
19
+ directives: tuple[SourceRange, ...] = ()
18
20
 
19
21
  @classmethod
20
- def of_comments(cls, groups: tuple[CommentGroup, ...]) -> 'ProseMap':
22
+ def of_comments(cls, groups: tuple[CommentGroup, ...], directives: tuple[SourceRange, ...] = ()) -> 'ProseMap':
21
23
  """A trailing comment shares its line with code, so only a comment standing on its own lines is editable."""
22
24
  return cls(
25
+ directives=directives,
23
26
  editable=tuple(
24
27
  line.range
25
28
  for group in groups
@@ -31,7 +34,7 @@ class ProseMap:
31
34
 
32
35
  def admits(self, diagnostic: Diagnostic) -> bool:
33
36
  """Whether a finding reads prose rather than code, and any edit it offers stays on lines of prose."""
34
- if any(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 ()
@@ -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
@@ -1,10 +1,10 @@
1
1
  import fnmatch
2
2
  import pathlib
3
3
  import tomllib
4
- import typing
5
4
 
6
5
  from ...domain.models.rule_set import DEFAULT_LINE_WIDTH, RuleSet, RuleSettings
7
6
  from .config_schema import ConfigFile, RuleTable
7
+ from .formatter_width import FormatterWidths
8
8
 
9
9
 
10
10
  CONFIG_NAME = 'subedit.toml'
@@ -15,11 +15,11 @@ class TomlConfigSource:
15
15
  def __init__(
16
16
  self,
17
17
  available_rule_ids: tuple[str, ...],
18
- default_widths: typing.Mapping[str, int] | None = None,
18
+ formatter_widths: FormatterWidths | None = None,
19
19
  fallback_width: int = DEFAULT_LINE_WIDTH,
20
20
  ) -> None:
21
21
  self._available_rule_ids = available_rule_ids
22
- self._default_widths = dict(default_widths or {})
22
+ self._formatter_widths = formatter_widths or FormatterWidths({})
23
23
  self._fallback_width = fallback_width
24
24
 
25
25
  def resolve(self, path: str) -> RuleSet:
@@ -32,8 +32,9 @@ class TomlConfigSource:
32
32
  return RuleSet(enabled_rule_ids=frozenset(self._available_rule_ids), line_width=self.default_width(path))
33
33
 
34
34
  def default_width(self, path: str) -> int:
35
- """The width a file gets when no config sets one, which follows its language's formatter."""
36
- return self._default_widths.get(pathlib.Path(path).suffix, self._fallback_width)
35
+ """The width a file gets when subedit's config sets none, which the project's formatter sets if it has one."""
36
+ formatter_width = self._formatter_widths.width_for(path)
37
+ return self._fallback_width if formatter_width is None else formatter_width
37
38
 
38
39
  def exclude_patterns(self, path: str) -> tuple[str, ...]:
39
40
  config_file = self._read(path)
@@ -6,7 +6,7 @@ import autocorrect_py
6
6
  from ...domain.models.diagnostic import Diagnostic, Fix, Severity
7
7
  from ...domain.models.document import Document
8
8
  from ...domain.models.position import Position, SourceRange
9
- from ...domain.models.text import TextEdit
9
+ from ...domain.models.text import TextEdit, source_lines
10
10
  from ...domain.ports.external_linter import ExternalLinter
11
11
  from .source_path import as_known_path
12
12
 
@@ -59,8 +59,8 @@ class AutoCorrectLinter(ExternalLinter):
59
59
  autocorrect_py.load_config(config_text) # pyright: ignore[reportAttributeAccessIssue]
60
60
 
61
61
  def _diff_lines(self, document: Document, corrected_text: str) -> tuple[Diagnostic, ...]:
62
- original_lines = document.text.splitlines(keepends=True)
63
- corrected_lines = corrected_text.splitlines(keepends=True)
62
+ original_lines = source_lines(document.text)
63
+ corrected_lines = source_lines(corrected_text)
64
64
  if len(original_lines) != len(corrected_lines):
65
65
  return ()
66
66
  diagnostics: list[Diagnostic] = []
@@ -0,0 +1,52 @@
1
+ import re
2
+ import typing
3
+
4
+ from tree_sitter import Language, Node, Parser
5
+
6
+
7
+ # A list item opens with a marker that a grammar reads as an operator, as "- `url` (ssh)" parses as a call.
8
+ _LIST_ITEM = re.compile(r'^(?:[-*+]|\d+[.)])\s')
9
+
10
+
11
+ class GrammarCodeLine:
12
+ """Whether a comment line is code commented out, read by parsing the line with the language's own grammar.
13
+
14
+ A line is code when it parses cleanly and holds a statement no sentence makes, such as a call or an assignment.
15
+ A line that only opens or closes a block cannot parse alone, so a pattern for such lines backs the grammar up.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ language: Language,
21
+ statement_types: frozenset[str],
22
+ *,
23
+ block_line: re.Pattern[str] | None = None,
24
+ required_fields: typing.Mapping[str, str] | None = None,
25
+ ) -> None:
26
+ self._parser = Parser(language)
27
+ self._statement_types = statement_types
28
+ self._block_line = block_line
29
+ # A node that only counts with a field present, such as a Python annotation, which "Note: this" parses as.
30
+ self._required_fields = dict(required_fields or {})
31
+ self._verdicts: dict[str, bool] = {}
32
+
33
+ def __call__(self, line: str) -> bool:
34
+ if self._block_line is not None and self._block_line.search(line):
35
+ return True
36
+ text = line.strip()
37
+ if text not in self._verdicts:
38
+ self._verdicts[text] = self._parses_as_statement(text)
39
+ return self._verdicts[text]
40
+
41
+ def _parses_as_statement(self, text: str) -> bool:
42
+ if not text or _LIST_ITEM.match(text):
43
+ return False
44
+ root = self._parser.parse(text.encode('utf-8')).root_node
45
+ return not root.has_error and self._holds_statement(root)
46
+
47
+ def _holds_statement(self, node: Node) -> bool:
48
+ if node.type in self._statement_types:
49
+ field = self._required_fields.get(node.type)
50
+ if field is None or node.child_by_field_name(field) is not None:
51
+ return True
52
+ return any(self._holds_statement(child) for child in node.named_children)
@@ -6,8 +6,8 @@ from tree_sitter import Language, Node
6
6
  from ....domain.models.comment import SymbolKind, Visibility
7
7
  from ....domain.models.comment_layout import CommentLayout
8
8
  from ....domain.models.prose import PLAIN_MARKUP, ProseMarkup
9
- from ....domain.models.rule_set import DEFAULT_LINE_WIDTH
10
9
  from ....domain.ports.language_conventions import LanguageConventions
10
+ from ...config_sources.formatter_width import WidthSetting
11
11
 
12
12
 
13
13
  class NodeRole(enum.Enum):
@@ -43,10 +43,15 @@ class LanguageProfile(LanguageConventions, GrammarAdapter):
43
43
  line_markup: ProseMarkup = PLAIN_MARKUP
44
44
  # A docstring lives inside what it documents, while a JSDoc block stands above it.
45
45
  doc_leads_declaration = False
46
- default_line_width = DEFAULT_LINE_WIDTH
46
+ # Where the language's formatters set a line width, which subedit follows over a default of its own.
47
+ width_settings: tuple[WidthSetting, ...] = ()
47
48
  # Directories a package manager fills with code nobody in the project wrote.
48
49
  vendor_directories: frozenset[str] = frozenset()
49
50
 
51
+ def imports_for_effect(self, node: Node) -> bool:
52
+ """Whether an import binds no name and runs only for its side effect, which a comment over it explains."""
53
+ return False
54
+
50
55
  def exported_names(self, root: Node) -> frozenset[str]:
51
56
  """The names a module exports apart from where it declares them, read once per parse."""
52
57
  return frozenset()