Skip to content

Commit c646b88

Browse files
authored
feat(fmt): enforce doc comment styling (#11955)
* feat(fmt): force doc comment styling * tests: add unit tests * style: clippy + comments * fix: default config test
1 parent b7d4970 commit c646b88

File tree

6 files changed

+295
-2
lines changed

6 files changed

+295
-2
lines changed

crates/config/src/fmt.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub struct FormatterConfig {
2929
pub override_spacing: bool,
3030
/// Wrap comments on `line_length` reached
3131
pub wrap_comments: bool,
32+
/// Style of doc comments
33+
pub docs_style: DocCommentStyle,
3234
/// Globs to ignore
3335
pub ignore: Vec<String>,
3436
/// Add new line at start and end of contract declarations
@@ -101,6 +103,19 @@ pub enum HexUnderscore {
101103
Bytes,
102104
}
103105

106+
/// Style of doc comments
107+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
108+
#[serde(rename_all = "snake_case")]
109+
pub enum DocCommentStyle {
110+
/// Preserve the source code style
111+
#[default]
112+
Preserve,
113+
/// Use single-line style (`///`)
114+
Line,
115+
/// Use block style (`/** .. */`)
116+
Block,
117+
}
118+
104119
/// Style of string quotes
105120
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
106121
#[serde(rename_all = "snake_case")]
@@ -200,6 +215,7 @@ impl Default for FormatterConfig {
200215
sort_imports: false,
201216
pow_no_space: false,
202217
call_compact_args: true,
218+
docs_style: DocCommentStyle::default(),
203219
}
204220
}
205221
}

crates/fmt/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ The formatter supports multiple configuration options defined in `foundry.toml`.
122122
| `single_line_statement_blocks` | `preserve` | The style of single-line blocks in statements. Options: `preserve`, `single`, `multi`. |
123123
| `override_spacing` | `false` | Print a space in the `override` attribute. |
124124
| `wrap_comments` | `false` | Wrap comments when `line_length` is reached. |
125+
| `docs_style` | `preserve` | Enforces the style of doc (natspec) comments. Options: `preserve`, `line`, `block`. |
125126
| `ignore` | `[]` | Globs to ignore. |
126127
| `contract_new_lines` | `false` | Add a new line at the start and end of contract declarations. |
127128
| `sort_imports` | `false` | Sort import statements alphabetically in groups. A group is a set of imports separated by a newline. |

crates/fmt/src/state/mod.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use foundry_common::{
88
comments::{Comment, CommentStyle, Comments, estimate_line_width, line_with_tabs},
99
iter::IterDelimited,
1010
};
11-
use foundry_config::fmt::IndentStyle;
11+
use foundry_config::fmt::{DocCommentStyle, IndentStyle};
1212
use solar::parse::{
1313
ast::{self, Span},
1414
interface::{BytePos, SourceMap},
@@ -480,9 +480,30 @@ impl<'sess> State<'sess, '_> {
480480
let config_cache = config;
481481
let mut buffered_blank = None;
482482
while self.peek_comment().is_some_and(|c| c.pos() < pos) {
483-
let cmnt = self.next_comment().unwrap();
483+
let mut cmnt = self.next_comment().unwrap();
484484
let style_cache = cmnt.style;
485485

486+
// Merge consecutive line doc comments when converting to block style
487+
if self.config.docs_style == foundry_config::fmt::DocCommentStyle::Block
488+
&& cmnt.is_doc
489+
&& cmnt.kind == ast::CommentKind::Line
490+
{
491+
let mut ref_line = self.sm.lookup_char_pos(cmnt.span.hi()).line;
492+
while let Some(next_cmnt) = self.peek_comment() {
493+
if !next_cmnt.is_doc
494+
|| next_cmnt.kind != ast::CommentKind::Line
495+
|| ref_line + 1 != self.sm.lookup_char_pos(next_cmnt.span.lo()).line
496+
{
497+
break;
498+
}
499+
500+
let next_to_merge = self.next_comment().unwrap();
501+
cmnt.lines.extend(next_to_merge.lines);
502+
cmnt.span = cmnt.span.to(next_to_merge.span);
503+
ref_line += 1;
504+
}
505+
}
506+
486507
// Ensure breaks are never skipped when there are multiple comments
487508
if self.peek_comment_before(pos).is_some() {
488509
config.iso_no_break = false;
@@ -662,6 +683,11 @@ impl<'sess> State<'sess, '_> {
662683

663684
fn print_comment(&mut self, mut cmnt: Comment, mut config: CommentConfig) {
664685
self.cursor.advance_to(cmnt.span.hi(), true);
686+
687+
if cmnt.is_doc {
688+
cmnt = style_doc_comment(self.config.docs_style, cmnt);
689+
}
690+
665691
match cmnt.style {
666692
CommentStyle::Mixed => {
667693
let Some(prefix) = cmnt.prefix() else { return };
@@ -1056,3 +1082,47 @@ fn snippet_with_tabs(s: String, tab_width: usize) -> String {
10561082

10571083
formatted
10581084
}
1085+
1086+
/// Formats a doc comment with the requested style.
1087+
///
1088+
/// NOTE: assumes comments have already been normalized.
1089+
fn style_doc_comment(style: DocCommentStyle, mut cmnt: Comment) -> Comment {
1090+
match style {
1091+
DocCommentStyle::Line if cmnt.kind == ast::CommentKind::Block => {
1092+
let mut new_lines = Vec::new();
1093+
for (pos, line) in cmnt.lines.iter().delimited() {
1094+
if pos.is_first || pos.is_last {
1095+
// Skip the opening '/**' and closing '*/' lines
1096+
continue;
1097+
}
1098+
1099+
// Convert ' * {content}' to '/// {content}'
1100+
let trimmed = line.trim_start();
1101+
if let Some(content) = trimmed.strip_prefix('*') {
1102+
new_lines.push(format!("///{content}"));
1103+
} else if !trimmed.is_empty() {
1104+
new_lines.push(format!("/// {trimmed}"));
1105+
}
1106+
}
1107+
1108+
cmnt.lines = new_lines;
1109+
cmnt.kind = ast::CommentKind::Line;
1110+
cmnt
1111+
}
1112+
DocCommentStyle::Block if cmnt.kind == ast::CommentKind::Line => {
1113+
let mut new_lines = vec!["/**".to_string()];
1114+
1115+
for line in &cmnt.lines {
1116+
// Convert '/// {content}' to ' * {content}'
1117+
new_lines.push(format!(" *{content}", content = &line[3..]))
1118+
}
1119+
1120+
new_lines.push(" */".to_string());
1121+
cmnt.lines = new_lines;
1122+
cmnt.kind = ast::CommentKind::Block;
1123+
cmnt
1124+
}
1125+
// Otherwise, no conversion needed.
1126+
_ => cmnt,
1127+
}
1128+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// config: docs_style = "block"
2+
pragma solidity ^0.8.13;
3+
4+
/**
5+
* @title A Hello world example
6+
*/
7+
contract HelloWorld {
8+
/**
9+
* Some example struct
10+
*/
11+
struct Person {
12+
uint256 age;
13+
address wallet;
14+
}
15+
16+
/**
17+
* Here's a more double asterix comment
18+
*/
19+
Person public theDude;
20+
21+
/**
22+
* Will this long comment be wrapped leaving
23+
* orphan words?
24+
*/
25+
Person public anotherDude;
26+
27+
/**
28+
* Constructs the dude
29+
* @param age The dude's age
30+
*/
31+
constructor(uint256 age) {
32+
theDude = Person({age: age, wallet: msg.sender});
33+
}
34+
35+
/**
36+
* @dev does nothing
37+
*/
38+
function example() public {
39+
/**
40+
* Does this add a whitespace error?
41+
*
42+
* Let's find out.
43+
*/
44+
}
45+
46+
/**
47+
* @dev Calculates a rectangle's surface and perimeter.
48+
* @param w Width of the rectangle.
49+
* @param h Height of the rectangle.
50+
* @return s The calculated surface.
51+
* @return p The calculated perimeter.
52+
*/
53+
function rectangle(uint256 w, uint256 h)
54+
public
55+
pure
56+
returns (uint256 s, uint256 p)
57+
{
58+
s = w * h;
59+
p = 2 * (w + h);
60+
}
61+
62+
/**
63+
* A long doc line comment that will be wrapped
64+
*/
65+
function docLineOverflow() external {}
66+
67+
function docLinePostfixOverflow() external {}
68+
69+
/**
70+
* A long doc line comment that will be wrapped
71+
*/
72+
73+
/**
74+
* @notice Here is my comment
75+
* - item 1
76+
* - item 2
77+
* Some equations:
78+
* y = mx + b
79+
*/
80+
function anotherExample() external {}
81+
82+
/**
83+
* contract A {
84+
* function foo() public {
85+
* // does nothing.
86+
* }
87+
* }
88+
*/
89+
function multilineIndent() external {}
90+
91+
/**
92+
* contract A {
93+
* function foo() public {
94+
* // does nothing.
95+
* }
96+
* }
97+
*/
98+
function multilineMalformedIndent() external {}
99+
100+
/**
101+
* contract A {
102+
* function withALongNameThatWillCauseCommentWrap() public {
103+
* // does nothing.
104+
* }
105+
* }
106+
*/
107+
function malformedIndentOverflow() external {}
108+
}
109+
110+
/**
111+
* contract A {
112+
* function foo() public {
113+
* // does nothing.
114+
* }
115+
* }
116+
*/
117+
function freeFloatingMultilineIndent() {}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// config: docs_style = "line"
2+
pragma solidity ^0.8.13;
3+
4+
/// @title A Hello world example
5+
contract HelloWorld {
6+
/// Some example struct
7+
struct Person {
8+
uint256 age;
9+
address wallet;
10+
}
11+
12+
/// Here's a more double asterix comment
13+
Person public theDude;
14+
15+
/// Will this long comment be wrapped leaving
16+
/// orphan words?
17+
Person public anotherDude;
18+
19+
/// Constructs the dude
20+
/// @param age The dude's age
21+
constructor(uint256 age) {
22+
theDude = Person({age: age, wallet: msg.sender});
23+
}
24+
25+
/// @dev does nothing
26+
function example() public {
27+
/// Does this add a whitespace error?
28+
///
29+
/// Let's find out.
30+
}
31+
32+
/// @dev Calculates a rectangle's surface and perimeter.
33+
/// @param w Width of the rectangle.
34+
/// @param h Height of the rectangle.
35+
/// @return s The calculated surface.
36+
/// @return p The calculated perimeter.
37+
function rectangle(uint256 w, uint256 h)
38+
public
39+
pure
40+
returns (uint256 s, uint256 p)
41+
{
42+
s = w * h;
43+
p = 2 * (w + h);
44+
}
45+
46+
/// A long doc line comment that will be wrapped
47+
function docLineOverflow() external {}
48+
49+
function docLinePostfixOverflow() external {}
50+
51+
/// A long doc line comment that will be wrapped
52+
53+
/// @notice Here is my comment
54+
/// - item 1
55+
/// - item 2
56+
/// Some equations:
57+
/// y = mx + b
58+
function anotherExample() external {}
59+
60+
/// contract A {
61+
/// function foo() public {
62+
/// // does nothing.
63+
/// }
64+
/// }
65+
function multilineIndent() external {}
66+
67+
/// contract A {
68+
/// function foo() public {
69+
/// // does nothing.
70+
/// }
71+
/// }
72+
function multilineMalformedIndent() external {}
73+
74+
/// contract A {
75+
/// function withALongNameThatWillCauseCommentWrap() public {
76+
/// // does nothing.
77+
/// }
78+
/// }
79+
function malformedIndentOverflow() external {}
80+
}
81+
82+
/// contract A {
83+
/// function foo() public {
84+
/// // does nothing.
85+
/// }
86+
/// }
87+
function freeFloatingMultilineIndent() {}

crates/forge/tests/cli/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ hex_underscore = "remove"
132132
single_line_statement_blocks = "preserve"
133133
override_spacing = false
134134
wrap_comments = false
135+
docs_style = "preserve"
135136
ignore = []
136137
contract_new_lines = false
137138
sort_imports = false
@@ -1306,6 +1307,7 @@ forgetest_init!(test_default_config, |prj, cmd| {
13061307
"single_line_statement_blocks": "preserve",
13071308
"override_spacing": false,
13081309
"wrap_comments": false,
1310+
"docs_style": "preserve",
13091311
"ignore": [],
13101312
"contract_new_lines": false,
13111313
"sort_imports": false,

0 commit comments

Comments
 (0)