From 27a58e88292aa967505e2f2fb483d8c616070d68 Mon Sep 17 00:00:00 2001 From: Shawn Hyam Date: Fri, 10 May 2024 09:04:32 -0400 Subject: [PATCH 1/3] Early implementation of formatting comments with Markdown. --- .../API/Configuration+Default.swift | 1 + Sources/SwiftFormat/API/Configuration.swift | 8 ++++ Sources/SwiftFormat/PrettyPrint/Comment.swift | 47 +++++++++++++++++-- .../SwiftFormat/PrettyPrint/PrettyPrint.swift | 5 +- .../PrettyPrint/CommentTests.swift | 7 +++ 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftFormat/API/Configuration+Default.swift b/Sources/SwiftFormat/API/Configuration+Default.swift index 3f0123fb4..f302ca42b 100644 --- a/Sources/SwiftFormat/API/Configuration+Default.swift +++ b/Sources/SwiftFormat/API/Configuration+Default.swift @@ -38,5 +38,6 @@ extension Configuration { self.spacesAroundRangeFormationOperators = false self.noAssignmentInExpressions = NoAssignmentInExpressionsConfiguration() self.multiElementCollectionTrailingCommas = true + self.wrapComments = false } } diff --git a/Sources/SwiftFormat/API/Configuration.swift b/Sources/SwiftFormat/API/Configuration.swift index ab8a3e952..32fba9bfa 100644 --- a/Sources/SwiftFormat/API/Configuration.swift +++ b/Sources/SwiftFormat/API/Configuration.swift @@ -43,6 +43,7 @@ public struct Configuration: Codable, Equatable { case spacesAroundRangeFormationOperators case noAssignmentInExpressions case multiElementCollectionTrailingCommas + case wrapComments } /// A dictionary containing the default enabled/disabled states of rules, keyed by the rules' @@ -186,6 +187,9 @@ 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 + /// 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 +276,9 @@ 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 // 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 +312,7 @@ 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(rules, forKey: .rules) } diff --git a/Sources/SwiftFormat/PrettyPrint/Comment.swift b/Sources/SwiftFormat/PrettyPrint/Comment.swift index 60d63af10..e1e6fcc9a 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,28 @@ 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: .onlyWhenLanguageIsPresent, + 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 { String($0) } +} + struct Comment { enum Kind { case line, docLine, block, docBlock @@ -85,12 +108,28 @@ struct Comment { } } - func print(indent: [Indent]) -> String { + func print(indent: [Indent], width: Int, wrap: Bool) -> String { switch self.kind { case .line, .docLine: - let separator = "\n" + indent.indentation() + kind.prefix - let trimmedLines = self.text.map { $0.trimmingTrailingWhitespace() } - return kind.prefix + trimmedLines.joined(separator: separator) + if wrap { + let indentation = indent.indentation() + let usableWidth = width - indentation.count + let wrappedLines = markdownFormat(self.text, usableWidth - kind.prefixLength) + let emptyLinesTrimmed = wrappedLines.map { + if $0.allSatisfy({ $0.isWhitespace }) { + return kind.prefix + } else { + return kind.prefix + $0 + } + } + return emptyLinesTrimmed.joined(separator: "\n" + indentation) + } else { + 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: let separator = "\n" return kind.prefix + self.text.joined(separator: separator) + "*/" diff --git a/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift b/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift index 1201f84c2..d5713799f 100644 --- a/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift +++ b/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift @@ -513,7 +513,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, wrap: configuration.wrapComments)) if wasEndOfLine { if comment.length > spaceRemaining && !isBreakingSuppressed { diagnose(.moveEndOfLineComment, category: .endOfLineComment) @@ -749,7 +750,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, wrap: false)) case .verbatim(let verbatim): printDebugIndent() diff --git a/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift b/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift index 84403f64d..20e892b58 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() { From 8d901c55caf641fdcce9949ffd3503622ef3d8f1 Mon Sep 17 00:00:00 2001 From: Shawn Hyam Date: Wed, 15 May 2024 13:41:01 -0400 Subject: [PATCH 2/3] Add option for maximum width of the text in a doc comment. --- .../API/Configuration+Default.swift | 1 + Sources/SwiftFormat/API/Configuration.swift | 9 +++++ Sources/SwiftFormat/PrettyPrint/Comment.swift | 36 +++++++++---------- .../SwiftFormat/PrettyPrint/PrettyPrint.swift | 4 +-- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/Sources/SwiftFormat/API/Configuration+Default.swift b/Sources/SwiftFormat/API/Configuration+Default.swift index f302ca42b..50aa653c2 100644 --- a/Sources/SwiftFormat/API/Configuration+Default.swift +++ b/Sources/SwiftFormat/API/Configuration+Default.swift @@ -39,5 +39,6 @@ extension Configuration { 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 32fba9bfa..6d6c82886 100644 --- a/Sources/SwiftFormat/API/Configuration.swift +++ b/Sources/SwiftFormat/API/Configuration.swift @@ -44,6 +44,7 @@ public struct Configuration: Codable, Equatable { case noAssignmentInExpressions case multiElementCollectionTrailingCommas case wrapComments + case maximumCommentTextWidth } /// A dictionary containing the default enabled/disabled states of rules, keyed by the rules' @@ -190,6 +191,10 @@ public struct Configuration: Codable, Equatable { /// 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) @@ -279,6 +284,9 @@ public struct Configuration: Codable, Equatable { 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 @@ -313,6 +321,7 @@ public struct Configuration: Codable, Equatable { 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 e1e6fcc9a..30c5426c4 100644 --- a/Sources/SwiftFormat/PrettyPrint/Comment.swift +++ b/Sources/SwiftFormat/PrettyPrint/Comment.swift @@ -108,28 +108,26 @@ struct Comment { } } - func print(indent: [Indent], width: Int, wrap: Bool) -> String { + func print(indent: [Indent], width: Int, textWidth: Int, wrap: Bool) -> String { switch self.kind { - case .line, .docLine: - if wrap { - let indentation = indent.indentation() - let usableWidth = width - indentation.count - let wrappedLines = markdownFormat(self.text, usableWidth - kind.prefixLength) - let emptyLinesTrimmed = wrappedLines.map { - if $0.allSatisfy({ $0.isWhitespace }) { - return kind.prefix - } else { - return kind.prefix + $0 - } + 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) - } else { - 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) } + 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: let separator = "\n" return kind.prefix + self.text.joined(separator: separator) + "*/" diff --git a/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift b/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift index d5713799f..84317340f 100644 --- a/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift +++ b/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift @@ -514,7 +514,7 @@ public class PrettyPrinter { lastBreak = false write(comment.print( - indent: currentIndentation, width: configuration.lineLength, wrap: configuration.wrapComments)) + indent: currentIndentation, width: configuration.lineLength, textWidth: configuration.maximumCommentTextWidth, wrap: configuration.wrapComments)) if wasEndOfLine { if comment.length > spaceRemaining && !isBreakingSuppressed { diagnose(.moveEndOfLineComment, category: .endOfLineComment) @@ -750,7 +750,7 @@ public class PrettyPrinter { print("[COMMENT DocBlock Length: \(length) EOL: \(wasEndOfLine) Idx: \(idx)]") } printDebugIndent() - print(comment.print(indent: debugIndent, width: configuration.lineLength, wrap: false)) + print(comment.print(indent: debugIndent, width: configuration.lineLength, textWidth: configuration.maximumCommentTextWidth, wrap: false)) case .verbatim(let verbatim): printDebugIndent() From 457ddea0814caa9c4a00888a768865ceec9e46bb Mon Sep 17 00:00:00 2001 From: Shawn Hyam Date: Tue, 21 May 2024 10:36:00 -0400 Subject: [PATCH 3/3] Add some tests for Markdown formatting. --- Sources/SwiftFormat/PrettyPrint/Comment.swift | 11 +- .../PrettyPrint/CommentTests.swift | 180 ++++++++++++++++++ 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftFormat/PrettyPrint/Comment.swift b/Sources/SwiftFormat/PrettyPrint/Comment.swift index 30c5426c4..8ba5d0951 100644 --- a/Sources/SwiftFormat/PrettyPrint/Comment.swift +++ b/Sources/SwiftFormat/PrettyPrint/Comment.swift @@ -41,7 +41,7 @@ fileprivate func markdownFormat(_ lines: [String], _ usableWidth: Int, linePrefi ) let formatterOptions = MarkupFormatter.Options( orderedListNumerals: .incrementing(start: 1), - useCodeFence: .onlyWhenLanguageIsPresent, + useCodeFence: .always, condenseAutolinks: false, preferredLineLimit: lineLimit, customLinePrefix: linePrefix @@ -51,7 +51,14 @@ fileprivate func markdownFormat(_ lines: [String], _ usableWidth: Int, linePrefi if lines.isEmpty { return [linePrefix] } - return lines.map { String($0) } + 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 { diff --git a/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift b/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift index 20e892b58..05d55a702 100644 --- a/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift @@ -839,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) + } + }