diff --git a/Sources/SwiftFormat/API/Configuration+Default.swift b/Sources/SwiftFormat/API/Configuration+Default.swift index 3f0123fb4..50aa653c2 100644 --- a/Sources/SwiftFormat/API/Configuration+Default.swift +++ b/Sources/SwiftFormat/API/Configuration+Default.swift @@ -38,5 +38,7 @@ extension Configuration { self.spacesAroundRangeFormationOperators = false self.noAssignmentInExpressions = NoAssignmentInExpressionsConfiguration() self.multiElementCollectionTrailingCommas = true + self.wrapComments = false + self.maximumCommentTextWidth = 72 } } diff --git a/Sources/SwiftFormat/API/Configuration.swift b/Sources/SwiftFormat/API/Configuration.swift index ab8a3e952..6d6c82886 100644 --- a/Sources/SwiftFormat/API/Configuration.swift +++ b/Sources/SwiftFormat/API/Configuration.swift @@ -43,6 +43,8 @@ public struct Configuration: Codable, Equatable { case spacesAroundRangeFormationOperators case noAssignmentInExpressions case multiElementCollectionTrailingCommas + case wrapComments + case maximumCommentTextWidth } /// A dictionary containing the default enabled/disabled states of rules, keyed by the rules' @@ -186,6 +188,13 @@ public struct Configuration: Codable, Equatable { /// ``` public var multiElementCollectionTrailingCommas: Bool + /// Determines if comments should wrap onto multiple lines when they exceed the line length. + public var wrapComments: Bool + + /// The maximum length of the text of a doc comment, after which the formatter will wrap + /// (if comment wrapping is enabled), even if the allowable line length is greater. + public var maximumCommentTextWidth: Int + /// Creates a new `Configuration` by loading it from a configuration file. public init(contentsOf url: URL) throws { let data = try Data(contentsOf: url) @@ -272,6 +281,12 @@ public struct Configuration: Codable, Equatable { try container.decodeIfPresent( Bool.self, forKey: .multiElementCollectionTrailingCommas) ?? defaults.multiElementCollectionTrailingCommas + self.wrapComments = + try container.decodeIfPresent(Bool.self, forKey: .wrapComments) + ?? defaults.wrapComments + self.maximumCommentTextWidth = + try container.decodeIfPresent(Int.self, forKey: .maximumCommentTextWidth) + ?? defaults.maximumCommentTextWidth // If the `rules` key is not present at all, default it to the built-in set // so that the behavior is the same as if the configuration had been @@ -305,6 +320,8 @@ public struct Configuration: Codable, Equatable { try container.encode(indentSwitchCaseLabels, forKey: .indentSwitchCaseLabels) try container.encode(noAssignmentInExpressions, forKey: .noAssignmentInExpressions) try container.encode(multiElementCollectionTrailingCommas, forKey: .multiElementCollectionTrailingCommas) + try container.encode(wrapComments, forKey: .wrapComments) + try container.encode(maximumCommentTextWidth, forKey: .maximumCommentTextWidth) try container.encode(rules, forKey: .rules) } diff --git a/Sources/SwiftFormat/PrettyPrint/Comment.swift b/Sources/SwiftFormat/PrettyPrint/Comment.swift index 60d63af10..8ba5d0951 100644 --- a/Sources/SwiftFormat/PrettyPrint/Comment.swift +++ b/Sources/SwiftFormat/PrettyPrint/Comment.swift @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// import Foundation +import Markdown import SwiftSyntax extension StringProtocol { @@ -31,6 +32,35 @@ extension StringProtocol { } } +fileprivate func markdownFormat(_ lines: [String], _ usableWidth: Int, linePrefix: String = "") -> [String] { + let linePrefix = linePrefix + " " + let document = Document(parsing: lines.joined(separator: "\n"), options: .disableSmartOpts) + let lineLimit = MarkupFormatter.Options.PreferredLineLimit( + maxLength: usableWidth, + breakWith: .softBreak + ) + let formatterOptions = MarkupFormatter.Options( + orderedListNumerals: .incrementing(start: 1), + useCodeFence: .always, + condenseAutolinks: false, + preferredLineLimit: lineLimit, + customLinePrefix: linePrefix + ) + let output = document.format(options: formatterOptions) + let lines = output.split(separator: "\n") + if lines.isEmpty { + return [linePrefix] + } + return lines.map { + // unfortunately we have to do a bit of post-processing... + if let last = $0.last, let secondLast = $0.dropLast().last, last.isWhitespace && secondLast.isWhitespace { + return $0.trimmingTrailingWhitespace() + " \\" + } else { + return $0.trimmingTrailingWhitespace() + } + } +} + struct Comment { enum Kind { case line, docLine, block, docBlock @@ -85,10 +115,24 @@ struct Comment { } } - func print(indent: [Indent]) -> String { + func print(indent: [Indent], width: Int, textWidth: Int, wrap: Bool) -> String { switch self.kind { + case .docLine where wrap: + let indentation = indent.indentation() + let usableWidth = width - indentation.count + let wrappedLines = markdownFormat(self.text, min(usableWidth - kind.prefixLength, textWidth)) + let emptyLinesTrimmed = wrappedLines.map { + if $0.allSatisfy({ $0.isWhitespace }) { + return kind.prefix + } else { + return kind.prefix + $0 + } + } + return emptyLinesTrimmed.joined(separator: "\n" + indentation) case .line, .docLine: let separator = "\n" + indent.indentation() + kind.prefix + // trailing whitespace is meaningful in Markdown, so we can't remove it + // when formatting comments, but we can here let trimmedLines = self.text.map { $0.trimmingTrailingWhitespace() } return kind.prefix + trimmedLines.joined(separator: separator) case .block, .docBlock: diff --git a/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift b/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift index 0b4ff792a..b551ee6a9 100644 --- a/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift +++ b/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift @@ -530,7 +530,8 @@ public class PrettyPrinter { case .comment(let comment, let wasEndOfLine): lastBreak = false - write(comment.print(indent: currentIndentation)) + write(comment.print( + indent: currentIndentation, width: configuration.lineLength, textWidth: configuration.maximumCommentTextWidth, wrap: configuration.wrapComments)) if wasEndOfLine { if comment.length > spaceRemaining && !isBreakingSuppressed { diagnose(.moveEndOfLineComment, category: .endOfLineComment) @@ -803,7 +804,7 @@ public class PrettyPrinter { print("[COMMENT DocBlock Length: \(length) EOL: \(wasEndOfLine) Idx: \(idx)]") } printDebugIndent() - print(comment.print(indent: debugIndent)) + print(comment.print(indent: debugIndent, width: configuration.lineLength, textWidth: configuration.maximumCommentTextWidth, wrap: false)) case .verbatim(let verbatim): printDebugIndent() diff --git a/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift b/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift index 84403f64d..05d55a702 100644 --- a/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift @@ -1,4 +1,5 @@ import _SwiftFormatTestSupport +import SwiftFormat final class CommentTests: PrettyPrintTestCase { func testDocumentationComments() { @@ -795,6 +796,9 @@ final class CommentTests: PrettyPrintTestCase { """ assertPrettyPrintEqual(input: input, expected: input, linelength: 80) + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: input, linelength: 80, configuration: config) } func testNonmergeableComments() { @@ -811,6 +815,9 @@ final class CommentTests: PrettyPrintTestCase { """ assertPrettyPrintEqual(input: input, expected: input, linelength: 80) + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: input, linelength: 80, configuration: config) } func testMergeableComments() { @@ -832,4 +839,184 @@ final class CommentTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: input, linelength: 80) } + + func testDocLineFormattingWhitespace() { + let input = """ + /// This has trailing whitespace \u{0020}\u{0020}\u{0020}\u{0020}\u{0020} + /// This has leading whitespace. + """ + let expected = #""" + /// This has trailing whitespace \ + /// This has leading whitespace. + + """# + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testDocLineFormattingShortLines() { + let input = """ + /// Test imports with comments. + /// + /// Comments that are short + /// should be merged. + """ + + let expected = """ + /// Test imports with comments. + /// + /// Comments that are short should be merged. + + """ + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testDocLineFormattingMultipleBlocks() { + let input = """ + /// This is one comment block. + /// These lines should merge. + /// + + /// This is another block. Since these lines are much longer, they should stay separate. Make + /// sure the lines wrap nicely. + """ + + let expected = """ + /// This is one comment block. These lines should merge. + + /// This is another block. Since these lines are much + /// longer, they should stay separate. Make sure the lines + /// wrap nicely. + + """ + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 60, configuration: config) + } + + func testDocLineFormattingForceNewline() { + let input = """ + /// This is one comment block. \u{005c} + /// These lines should not merge.\u{0020}\u{0020} + /// None of them.\u{0020}\u{0020}\u{0020}\u{0020} + /// The end. + """ + + let expected = #""" + /// This is one comment block. \ + /// These lines should not merge. \ + /// None of them. \ + /// The end. + + """# + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testDocLineFormattingBasics() { + let input = """ + /// Here is some **bold text** and __another form__ followed + /// by *italic text* and again in _this form_ and then ~~strikethrough~~ + /// and **nested bold *and italic* text** and ***bold and italic*** + """ + + let expected = """ + /// Here is some **bold text** and **another form** followed by *italic + /// text* and again in *this form* and then ~strikethrough~ and **nested + /// bold *and italic* text** and ***bold and italic*** + + """ + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testDocLineFormattingQuote() { + let input = """ + /// > This is some quoted text that should still be wrapped properly. + """ + + let expected = """ + /// > This is some + /// > quoted text that + /// > should still be + /// > wrapped properly. + + """ + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 24, configuration: config) + } + + func testDocLineFormattingOrderedList() { + let input = """ + /// 4. This is a list. + /// 4. The list should be renumbered. + /// 8. But of course, not reordered. + /// 1. This is a subitem. + /// 1. Final item. + """ + + let expected = """ + /// 1. This is a list. + /// 2. The list should be renumbered. + /// 3. But of course, not reordered. + /// 1. This is a subitem. + /// 4. Final item. + + """ + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testDocLineFormattingUnorderedList() { + let input = """ + /// - This is a list. + /// - The list is unordered. + /// - This is a subitem. + /// - Final item. + """ + + let expected = """ + /// - This is a list. + /// - The list is unordered. + /// - This is a subitem. + /// - Final item. + + """ + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testDocLineFormattingCodeBlock() { + let input = """ + /// ``` + /// for (i=0; i<9; i++) { + /// a[i] = b[i]; + /// } + /// ``` + + /// This is inline `*x = *y;` and should be left alone. + + """ + + var config = Configuration.forTesting + config.wrapComments = true + assertPrettyPrintEqual(input: input, expected: input, linelength: 80, configuration: config) + } + }