diff --git a/crates/config/src/fmt.rs b/crates/config/src/fmt.rs index 86bd14d2e8911..65235d4025804 100644 --- a/crates/config/src/fmt.rs +++ b/crates/config/src/fmt.rs @@ -29,6 +29,8 @@ pub struct FormatterConfig { pub override_spacing: bool, /// Wrap comments on `line_length` reached pub wrap_comments: bool, + /// Style of doc comments + pub docs_style: DocCommentStyle, /// Globs to ignore pub ignore: Vec, /// Add new line at start and end of contract declarations @@ -101,6 +103,19 @@ pub enum HexUnderscore { Bytes, } +/// Style of doc comments +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DocCommentStyle { + /// Preserve the source code style + #[default] + Preserve, + /// Use single-line style (`///`) + Line, + /// Use block style (`/** .. */`) + Block, +} + /// Style of string quotes #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -200,6 +215,7 @@ impl Default for FormatterConfig { sort_imports: false, pow_no_space: false, call_compact_args: true, + docs_style: DocCommentStyle::default(), } } } diff --git a/crates/fmt/README.md b/crates/fmt/README.md index 0229e8d770070..1066c0253cefb 100644 --- a/crates/fmt/README.md +++ b/crates/fmt/README.md @@ -122,6 +122,7 @@ The formatter supports multiple configuration options defined in `foundry.toml`. | `single_line_statement_blocks` | `preserve` | The style of single-line blocks in statements. Options: `preserve`, `single`, `multi`. | | `override_spacing` | `false` | Print a space in the `override` attribute. | | `wrap_comments` | `false` | Wrap comments when `line_length` is reached. | +| `docs_style` | `preserve` | Enforces the style of doc (natspec) comments. Options: `preserve`, `line`, `block`. | | `ignore` | `[]` | Globs to ignore. | | `contract_new_lines` | `false` | Add a new line at the start and end of contract declarations. | | `sort_imports` | `false` | Sort import statements alphabetically in groups. A group is a set of imports separated by a newline. | diff --git a/crates/fmt/src/state/mod.rs b/crates/fmt/src/state/mod.rs index 437f634b2e24f..ff97f05a478b7 100644 --- a/crates/fmt/src/state/mod.rs +++ b/crates/fmt/src/state/mod.rs @@ -8,7 +8,7 @@ use foundry_common::{ comments::{Comment, CommentStyle, Comments, estimate_line_width, line_with_tabs}, iter::IterDelimited, }; -use foundry_config::fmt::IndentStyle; +use foundry_config::fmt::{DocCommentStyle, IndentStyle}; use solar::parse::{ ast::{self, Span}, interface::{BytePos, SourceMap}, @@ -480,9 +480,30 @@ impl<'sess> State<'sess, '_> { let config_cache = config; let mut buffered_blank = None; while self.peek_comment().is_some_and(|c| c.pos() < pos) { - let cmnt = self.next_comment().unwrap(); + let mut cmnt = self.next_comment().unwrap(); let style_cache = cmnt.style; + // Merge consecutive line doc comments when converting to block style + if self.config.docs_style == foundry_config::fmt::DocCommentStyle::Block + && cmnt.is_doc + && cmnt.kind == ast::CommentKind::Line + { + let mut ref_line = self.sm.lookup_char_pos(cmnt.span.hi()).line; + while let Some(next_cmnt) = self.peek_comment() { + if !next_cmnt.is_doc + || next_cmnt.kind != ast::CommentKind::Line + || ref_line + 1 != self.sm.lookup_char_pos(next_cmnt.span.lo()).line + { + break; + } + + let next_to_merge = self.next_comment().unwrap(); + cmnt.lines.extend(next_to_merge.lines); + cmnt.span = cmnt.span.to(next_to_merge.span); + ref_line += 1; + } + } + // Ensure breaks are never skipped when there are multiple comments if self.peek_comment_before(pos).is_some() { config.iso_no_break = false; @@ -662,6 +683,11 @@ impl<'sess> State<'sess, '_> { fn print_comment(&mut self, mut cmnt: Comment, mut config: CommentConfig) { self.cursor.advance_to(cmnt.span.hi(), true); + + if cmnt.is_doc { + cmnt = style_doc_comment(self.config.docs_style, cmnt); + } + match cmnt.style { CommentStyle::Mixed => { let Some(prefix) = cmnt.prefix() else { return }; @@ -1056,3 +1082,47 @@ fn snippet_with_tabs(s: String, tab_width: usize) -> String { formatted } + +/// Formats a doc comment with the requested style. +/// +/// NOTE: assumes comments have already been normalized. +fn style_doc_comment(style: DocCommentStyle, mut cmnt: Comment) -> Comment { + match style { + DocCommentStyle::Line if cmnt.kind == ast::CommentKind::Block => { + let mut new_lines = Vec::new(); + for (pos, line) in cmnt.lines.iter().delimited() { + if pos.is_first || pos.is_last { + // Skip the opening '/**' and closing '*/' lines + continue; + } + + // Convert ' * {content}' to '/// {content}' + let trimmed = line.trim_start(); + if let Some(content) = trimmed.strip_prefix('*') { + new_lines.push(format!("///{content}")); + } else if !trimmed.is_empty() { + new_lines.push(format!("/// {trimmed}")); + } + } + + cmnt.lines = new_lines; + cmnt.kind = ast::CommentKind::Line; + cmnt + } + DocCommentStyle::Block if cmnt.kind == ast::CommentKind::Line => { + let mut new_lines = vec!["/**".to_string()]; + + for line in &cmnt.lines { + // Convert '/// {content}' to ' * {content}' + new_lines.push(format!(" *{content}", content = &line[3..])) + } + + new_lines.push(" */".to_string()); + cmnt.lines = new_lines; + cmnt.kind = ast::CommentKind::Block; + cmnt + } + // Otherwise, no conversion needed. + _ => cmnt, + } +} diff --git a/crates/fmt/testdata/DocComments/block.fmt.sol b/crates/fmt/testdata/DocComments/block.fmt.sol new file mode 100644 index 0000000000000..f216134747684 --- /dev/null +++ b/crates/fmt/testdata/DocComments/block.fmt.sol @@ -0,0 +1,117 @@ +// config: docs_style = "block" +pragma solidity ^0.8.13; + +/** + * @title A Hello world example + */ +contract HelloWorld { + /** + * Some example struct + */ + struct Person { + uint256 age; + address wallet; + } + + /** + * Here's a more double asterix comment + */ + Person public theDude; + + /** + * Will this long comment be wrapped leaving + * orphan words? + */ + Person public anotherDude; + + /** + * Constructs the dude + * @param age The dude's age + */ + constructor(uint256 age) { + theDude = Person({age: age, wallet: msg.sender}); + } + + /** + * @dev does nothing + */ + function example() public { + /** + * Does this add a whitespace error? + * + * Let's find out. + */ + } + + /** + * @dev Calculates a rectangle's surface and perimeter. + * @param w Width of the rectangle. + * @param h Height of the rectangle. + * @return s The calculated surface. + * @return p The calculated perimeter. + */ + function rectangle(uint256 w, uint256 h) + public + pure + returns (uint256 s, uint256 p) + { + s = w * h; + p = 2 * (w + h); + } + + /** + * A long doc line comment that will be wrapped + */ + function docLineOverflow() external {} + + function docLinePostfixOverflow() external {} + + /** + * A long doc line comment that will be wrapped + */ + + /** + * @notice Here is my comment + * - item 1 + * - item 2 + * Some equations: + * y = mx + b + */ + function anotherExample() external {} + + /** + * contract A { + * function foo() public { + * // does nothing. + * } + * } + */ + function multilineIndent() external {} + + /** + * contract A { + * function foo() public { + * // does nothing. + * } + * } + */ + function multilineMalformedIndent() external {} + + /** + * contract A { + * function withALongNameThatWillCauseCommentWrap() public { + * // does nothing. + * } + * } + */ + function malformedIndentOverflow() external {} +} + +/** + * contract A { + * function foo() public { + * // does nothing. + * } + * } + */ +function freeFloatingMultilineIndent() {} diff --git a/crates/fmt/testdata/DocComments/line.fmt.sol b/crates/fmt/testdata/DocComments/line.fmt.sol new file mode 100644 index 0000000000000..bf484db9e948d --- /dev/null +++ b/crates/fmt/testdata/DocComments/line.fmt.sol @@ -0,0 +1,87 @@ +// config: docs_style = "line" +pragma solidity ^0.8.13; + +/// @title A Hello world example +contract HelloWorld { + /// Some example struct + struct Person { + uint256 age; + address wallet; + } + + /// Here's a more double asterix comment + Person public theDude; + + /// Will this long comment be wrapped leaving + /// orphan words? + Person public anotherDude; + + /// Constructs the dude + /// @param age The dude's age + constructor(uint256 age) { + theDude = Person({age: age, wallet: msg.sender}); + } + + /// @dev does nothing + function example() public { + /// Does this add a whitespace error? + /// + /// Let's find out. + } + + /// @dev Calculates a rectangle's surface and perimeter. + /// @param w Width of the rectangle. + /// @param h Height of the rectangle. + /// @return s The calculated surface. + /// @return p The calculated perimeter. + function rectangle(uint256 w, uint256 h) + public + pure + returns (uint256 s, uint256 p) + { + s = w * h; + p = 2 * (w + h); + } + + /// A long doc line comment that will be wrapped + function docLineOverflow() external {} + + function docLinePostfixOverflow() external {} + + /// A long doc line comment that will be wrapped + + /// @notice Here is my comment + /// - item 1 + /// - item 2 + /// Some equations: + /// y = mx + b + function anotherExample() external {} + + /// contract A { + /// function foo() public { + /// // does nothing. + /// } + /// } + function multilineIndent() external {} + + /// contract A { + /// function foo() public { + /// // does nothing. + /// } + /// } + function multilineMalformedIndent() external {} + + /// contract A { + /// function withALongNameThatWillCauseCommentWrap() public { + /// // does nothing. + /// } + /// } + function malformedIndentOverflow() external {} +} + +/// contract A { +/// function foo() public { +/// // does nothing. +/// } +/// } +function freeFloatingMultilineIndent() {} diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 991effdef5f95..1f30e7d31d4b7 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -132,6 +132,7 @@ hex_underscore = "remove" single_line_statement_blocks = "preserve" override_spacing = false wrap_comments = false +docs_style = "preserve" ignore = [] contract_new_lines = false sort_imports = false @@ -1306,6 +1307,7 @@ forgetest_init!(test_default_config, |prj, cmd| { "single_line_statement_blocks": "preserve", "override_spacing": false, "wrap_comments": false, + "docs_style": "preserve", "ignore": [], "contract_new_lines": false, "sort_imports": false,