Skip to content

Commit 32b8857

Browse files
committed
Add style/docstring-leading-blank lint rule
Flags docstrings where the opening """ is immediately followed by a blank line. The first line of content should begin on the line immediately after the opening """. Also adds regression tests for the style/blank-lines rule to verify that a leading blank line inside a docstring is not miscounted as a blank line between entities. Closes #5120
1 parent a125b78 commit 32b8857

File tree

6 files changed

+684
-0
lines changed

6 files changed

+684
-0
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
## Add style/docstring-leading-blank lint rule
2+
3+
pony-lint now flags docstrings where a blank line immediately follows the opening `"""`. The first line of content should begin on the line right after the opening delimiter.
4+
5+
```pony
6+
// Flagged — blank line after opening """
7+
class Foo
8+
"""
9+
10+
Foo docstring.
11+
"""
12+
13+
// Clean — content starts on the next line
14+
class Foo
15+
"""
16+
Foo docstring.
17+
"""
18+
```
19+
20+
Types and methods annotated with `\nodoc\` are exempt, consistent with `style/docstring-format`.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
use ast = "pony_compiler"
2+
3+
primitive DocstringLeadingBlank is ASTRule
4+
"""
5+
Flags docstrings where the opening `\"\"\"` is immediately followed by a
6+
blank line.
7+
8+
A leading blank line wastes vertical space and can confuse the
9+
`style/blank-lines` rule's entity-boundary counting. The first line of
10+
content should begin on the line immediately after the opening `\"\"\"`.
11+
12+
Types and methods annotated with `\nodoc\` are exempt, as are methods
13+
inside `\nodoc\`-annotated entities — consistent with
14+
`style/docstring-format`.
15+
"""
16+
fun id(): String val => "style/docstring-leading-blank"
17+
fun category(): String val => "style"
18+
19+
fun description(): String val =>
20+
"no blank line after opening \"\"\""
21+
22+
fun default_status(): RuleStatus => RuleOn
23+
24+
fun node_filter(): Array[ast.TokenId] val =>
25+
[
26+
ast.TokenIds.tk_class(); ast.TokenIds.tk_actor()
27+
ast.TokenIds.tk_primitive(); ast.TokenIds.tk_struct()
28+
ast.TokenIds.tk_trait(); ast.TokenIds.tk_interface()
29+
ast.TokenIds.tk_fun(); ast.TokenIds.tk_new(); ast.TokenIds.tk_be()
30+
]
31+
32+
fun check(node: ast.AST box, source: SourceFile val)
33+
: Array[Diagnostic val] val
34+
=>
35+
"""
36+
Check that a docstring does not have a blank line immediately after
37+
the opening `\"\"\"`. Skips `\nodoc\`-annotated nodes and methods
38+
inside `\nodoc\`-annotated entities.
39+
"""
40+
let token_id = node.id()
41+
let is_method =
42+
(token_id == ast.TokenIds.tk_fun())
43+
or (token_id == ast.TokenIds.tk_new())
44+
or (token_id == ast.TokenIds.tk_be())
45+
46+
// Skip \nodoc\-annotated nodes
47+
if node.has_annotation("nodoc") then
48+
return recover val Array[Diagnostic val] end
49+
end
50+
51+
if is_method then
52+
// Skip methods inside \nodoc\-annotated entities
53+
try
54+
let entity =
55+
(node.parent() as ast.AST).parent() as ast.AST
56+
if entity.has_annotation("nodoc") then
57+
return recover val Array[Diagnostic val] end
58+
end
59+
end
60+
end
61+
62+
// Find the docstring node
63+
let doc_node: ast.AST box =
64+
if is_method then
65+
let found =
66+
try
67+
let c7 = node(7)?
68+
if c7.id() == ast.TokenIds.tk_string() then
69+
c7
70+
else
71+
try
72+
let body = node(6)?
73+
if body.id() != ast.TokenIds.tk_none() then
74+
let b0 = body(0)?
75+
if b0.id() == ast.TokenIds.tk_string() then
76+
b0
77+
else
78+
return recover val Array[Diagnostic val] end
79+
end
80+
else
81+
return recover val Array[Diagnostic val] end
82+
end
83+
else
84+
return recover val Array[Diagnostic val] end
85+
end
86+
end
87+
else
88+
return recover val Array[Diagnostic val] end
89+
end
90+
found
91+
else
92+
try
93+
let c6 = node(6)?
94+
if c6.id() == ast.TokenIds.tk_none() then
95+
return recover val Array[Diagnostic val] end
96+
end
97+
c6
98+
else
99+
return recover val Array[Diagnostic val] end
100+
end
101+
end
102+
103+
// We have a docstring — check for a leading blank line
104+
let start_line = doc_node.line()
105+
if start_line == 0 then
106+
return recover val Array[Diagnostic val] end
107+
end
108+
109+
_check_leading_blank(source, start_line)
110+
111+
fun _check_leading_blank(source: SourceFile val, start_line: USize)
112+
: Array[Diagnostic val] val
113+
=>
114+
"""
115+
Check whether the line after the opening `\"\"\"` at `start_line`
116+
(1-based) is blank. Only fires when the opening `\"\"\"` is on its
117+
own line (multi-line docstring).
118+
"""
119+
let open_idx = start_line - 1
120+
let open_line =
121+
try source.lines(open_idx)?
122+
else return recover val Array[Diagnostic val] end
123+
end
124+
125+
// Only check multi-line docstrings where """ is on its own line
126+
if not _is_only_triple_quote(open_line) then
127+
return recover val Array[Diagnostic val] end
128+
end
129+
130+
// Check if the next line is blank
131+
try
132+
let next_line = source.lines(open_idx + 1)?
133+
if _is_blank(next_line) then
134+
return recover val
135+
[ Diagnostic(
136+
id(),
137+
"no blank line after opening \"\"\"",
138+
source.rel_path,
139+
start_line + 1,
140+
1)]
141+
end
142+
end
143+
end
144+
recover val Array[Diagnostic val] end
145+
146+
fun _is_only_triple_quote(line: String val): Bool =>
147+
"""
148+
Check if a line contains only whitespace and a single `\"\"\"`.
149+
"""
150+
var found_quote = false
151+
var i: USize = 0
152+
let size = line.size()
153+
while i < size do
154+
try
155+
let ch = line(i)?
156+
if (ch == '"') and ((i + 3) <= size)
157+
and (line(i + 1)? == '"') and (line(i + 2)? == '"')
158+
then
159+
if found_quote then return false end
160+
found_quote = true
161+
i = i + 3
162+
elseif (ch != ' ') and (ch != '\t') then
163+
return false
164+
else
165+
i = i + 1
166+
end
167+
else
168+
i = i + 1
169+
end
170+
end
171+
found_quote
172+
173+
fun _is_blank(line: String val): Bool =>
174+
"""
175+
Check if a line is blank (empty or all whitespace).
176+
"""
177+
var i: USize = 0
178+
while i < line.size() do
179+
try
180+
let ch = line(i)?
181+
if (ch != ' ') and (ch != '\t') then return false end
182+
end
183+
i = i + 1
184+
end
185+
true

tools/pony-lint/main.pony

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ actor Main
8888
.> push(DotSpacing)
8989
.> push(BlankLines)
9090
.> push(DocstringFormat)
91+
.> push(DocstringLeadingBlank)
9192
.> push(PackageDocstring)
9293
.> push(PreferChaining)
9394
.> push(OperatorSpacing)

0 commit comments

Comments
 (0)