Skip to content

Commit e275934

Browse files
authored
Add support for formatting code blocks in markdown files (#2068)
1 parent 0e8d46c commit e275934

10 files changed

+452
-33
lines changed

Sources/Arguments.swift

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,12 @@ func argumentsFor(_ options: Options, excludingDefaults: Bool = false) -> [Strin
455455
}
456456
arguments.remove("minversion")
457457
}
458+
do {
459+
if !excludingDefaults || fileOptions.markdownFormattingMode != nil {
460+
args["markdownfiles"] = fileOptions.markdownFormattingMode?.rawValue ?? "ignore"
461+
}
462+
arguments.remove("markdownfiles")
463+
}
458464
assert(arguments.isEmpty)
459465
}
460466
if let formatOptions = options.formatOptions {
@@ -526,8 +532,8 @@ private func processOption(_ key: String,
526532
}
527533

528534
/// Parse rule names from arguments
529-
public func rulesFor(_ args: [String: String], lint: Bool) throws -> Set<String> {
530-
var rules = allRules
535+
public func rulesFor(_ args: [String: String], lint: Bool, initial: Set<String>? = nil) throws -> Set<String> {
536+
var rules = initial ?? allRules
531537
rules = try args["rules"].map {
532538
try Set(parseRules($0))
533539
} ?? rules.subtracting(FormatRules.disabledByDefault.map(\.name))
@@ -582,6 +588,24 @@ func fileOptionsFor(_ args: [String: String], in directory: String) throws -> Fi
582588
}
583589
options.minVersion = minVersion
584590
}
591+
try processOption("markdownfiles", in: args, from: &arguments) {
592+
containsFileOption = true
593+
switch $0.lowercased() {
594+
case "ignore":
595+
break
596+
case MarkdownFormattingMode.lenient.rawValue:
597+
options.supportedFileExtensions.append("md")
598+
options.markdownFormattingMode = .lenient
599+
case MarkdownFormattingMode.strict.rawValue:
600+
options.supportedFileExtensions.append("md")
601+
options.markdownFormattingMode = .strict
602+
default:
603+
throw FormatError.options("""
604+
Valid options for --markdownfiles are 'ignore' (default), \
605+
'format-lenient', or 'format-strict'.
606+
""")
607+
}
608+
}
585609
assert(arguments.isEmpty, "\(arguments.joined(separator: ","))")
586610
return containsFileOption ? options : nil
587611
}
@@ -590,17 +614,32 @@ func fileOptionsFor(_ args: [String: String], in directory: String) throws -> Fi
590614
/// Returns nil if the arguments dictionary does not contain any formatting arguments
591615
public func formatOptionsFor(_ args: [String: String]) throws -> FormatOptions? {
592616
var options = FormatOptions.default
593-
var arguments = Set(formattingArguments)
617+
let containsFormatOption = try applyFormatOptions(from: args, to: &options)
618+
return containsFormatOption ? options : nil
619+
}
594620

621+
public func applyFormatOptions(from args: [String: String], to formatOptions: inout FormatOptions) throws -> Bool {
622+
var arguments = Set(formattingArguments)
595623
var containsFormatOption = false
596624
for option in Descriptors.all {
597625
try processOption(option.argumentName, in: args, from: &arguments) {
598626
containsFormatOption = true
599-
try option.toOptions($0, &options)
627+
try option.toOptions($0, &formatOptions)
600628
}
601629
}
602630
assert(arguments.isEmpty, "\(arguments.joined(separator: ","))")
603-
return containsFormatOption ? options : nil
631+
return containsFormatOption
632+
}
633+
634+
/// Applies additional arguments to the given `Options` struct
635+
func applyArguments(_ args: [String: String], lint: Bool, to options: inout Options) throws {
636+
options.rules = try rulesFor(args, lint: lint, initial: options.rules)
637+
638+
var formatOptions = options.formatOptions ?? .default
639+
let containsFormatOption = try applyFormatOptions(from: args, to: &formatOptions)
640+
if containsFormatOption {
641+
options.formatOptions = formatOptions
642+
}
604643
}
605644

606645
/// Get deprecation warnings from a set of arguments
@@ -638,6 +677,7 @@ let fileArguments = [
638677
"exclude",
639678
"unexclude",
640679
"minversion",
680+
"markdownfiles",
641681
]
642682

643683
let rulesArguments = [

Sources/CommandLine.swift

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ func printHelp(as type: CLI.OutputType) {
218218
--verbose Display detailed formatting output and warnings/errors
219219
--quiet Disables non-critical output messages and warnings
220220
--outputtokens Outputs an array of tokens instead of text when using stdin
221+
--markdownfiles Format Swift code block in markdown files: 'format-strict', 'format-lenient' (ignore parsing errors), or 'ignore' (default)
221222
222223
SwiftFormat has a number of rules that can be enabled or disabled. By default
223224
most rules are enabled. Use --rules to display all enabled/disabled rules.
@@ -945,11 +946,11 @@ func computeHash(_ source: String) -> String {
945946
return "\(count)\(hash)"
946947
}
947948

948-
func applyRules(_ source: String, options: Options, lineRange: ClosedRange<Int>?,
949+
func applyRules(_ source: String, tokens: [Token]? = nil, options: Options, lineRange: ClosedRange<Int>?,
949950
verbose: Bool, lint: Bool, reporter: Reporter?) throws -> [Token]
950951
{
951952
// Parse source
952-
var tokens = tokenize(source)
953+
var tokens = tokens ?? tokenize(source)
953954

954955
// Get rules
955956
let rulesByName = FormatRules.byName
@@ -1089,11 +1090,71 @@ func processInput(_ inputURLs: [URL],
10891090
print("-- no changes (cached)", as: .success)
10901091
}
10911092
} else {
1092-
let outputTokens = try applyRules(input, options: options, lineRange: lineRange,
1093-
verbose: verbose, lint: lint, reporter: reporter)
1094-
output = sourceCode(for: outputTokens)
1095-
if output != input {
1096-
sourceHash = nil
1093+
// Format individual code blocks in markdown files is enabled
1094+
if inputURL.pathExtension == "md", options.fileOptions?.supportedFileExtensions.contains("md") == true {
1095+
var markdown = input
1096+
let swiftCodeBlocks = parseSwiftCodeBlocks(fromMarkdown: input)
1097+
1098+
// Iterate backwards through the code blocks to not invalidate existing indices
1099+
for swiftCodeBlock in swiftCodeBlocks.reversed() {
1100+
// Determine the options to use when formatting this block
1101+
var markdownOptions = options
1102+
markdownOptions.formatOptions?.fragment = true
1103+
1104+
if swiftCodeBlock.options?.contains("no-format") == true {
1105+
continue
1106+
} else if let options = swiftCodeBlock.options?.components(separatedBy: " "), !options.isEmpty {
1107+
let arguments = try preprocessArguments(options, commandLineArguments)
1108+
try applyArguments(arguments, lint: lint, to: &markdownOptions)
1109+
}
1110+
1111+
// Update linebreak line numbers to reflect the actual line in the markdown file
1112+
// rather than only the line within the code block. This makes it easier to
1113+
// understand printed diagnostics that include line numbers.
1114+
let inputTokens = tokenize(swiftCodeBlock.text).map { token in
1115+
if case let .linebreak(string, lineInCodeBlock) = token {
1116+
return Token.linebreak(string, lineInCodeBlock + swiftCodeBlock.lineStartIndex)
1117+
} else {
1118+
return token
1119+
}
1120+
}
1121+
1122+
var outputTokens: [Token]?
1123+
let parsingError = parsingError(for: inputTokens, options: markdownOptions.formatOptions ?? .default, allowErrorsInFragments: false)
1124+
1125+
switch options.fileOptions?.markdownFormattingMode {
1126+
case .lenient, nil:
1127+
// Ignore code blocks that fail to parse
1128+
if parsingError == nil {
1129+
outputTokens = try? applyRules(swiftCodeBlock.text, tokens: inputTokens, options: markdownOptions, lineRange: lineRange,
1130+
verbose: verbose, lint: lint, reporter: reporter)
1131+
}
1132+
case .strict:
1133+
if let parsingError {
1134+
throw parsingError
1135+
}
1136+
1137+
outputTokens = try applyRules(swiftCodeBlock.text, tokens: inputTokens, options: markdownOptions, lineRange: lineRange,
1138+
verbose: verbose, lint: lint, reporter: reporter)
1139+
}
1140+
1141+
if let outputTokens {
1142+
assert(markdown[swiftCodeBlock.range] == swiftCodeBlock.text)
1143+
markdown.replaceSubrange(swiftCodeBlock.range, with: sourceCode(for: outputTokens))
1144+
}
1145+
}
1146+
1147+
output = markdown
1148+
if markdown != input {
1149+
sourceHash = nil
1150+
}
1151+
} else {
1152+
let outputTokens = try applyRules(input, options: options, lineRange: lineRange,
1153+
verbose: verbose, lint: lint, reporter: reporter)
1154+
output = sourceCode(for: outputTokens)
1155+
if output != input {
1156+
sourceHash = nil
1157+
}
10971158
}
10981159
}
10991160
let cacheValue = cache.map { _ in
@@ -1231,3 +1292,58 @@ private struct OutputTokensData: Encodable {
12311292
return String(data: encodedData, encoding: .utf8)!
12321293
}
12331294
}
1295+
1296+
/// Parses Swift code blocks in the given code file.
1297+
///
1298+
/// Any text on following the open delimiter on that initial line is returned in `options`.
1299+
///
1300+
/// For example:
1301+
///
1302+
/// ```swift {{options like `no-format`, `--disable ruleName` can be put here}}
1303+
/// // This content is returned as text,
1304+
/// // and its range in the markdown string is returned as range.
1305+
/// ```
1306+
func parseSwiftCodeBlocks(fromMarkdown markdown: String)
1307+
-> [(range: Range<String.Index>, text: String, options: String?, lineStartIndex: Int)]
1308+
{
1309+
let lines = markdown.lineRanges
1310+
var codeBlocks: [(range: Range<String.Index>, text: String, options: String?, lineStartIndex: Int)] = []
1311+
var codeStartLineIndex: Int?
1312+
var codeBlockOptions: String?
1313+
var codeBlockStack = 0
1314+
1315+
for (lineIndex, lineRange) in lines.enumerated() {
1316+
let lineText = markdown[lineRange].trimmingCharacters(in: .whitespacesAndNewlines)
1317+
1318+
if lineText.hasPrefix("```"), lineText != "```" {
1319+
// If we're already inside a code block, don't start a new one
1320+
if codeStartLineIndex != nil {
1321+
codeBlockStack += 1
1322+
} else if lineText.hasPrefix("```swift"), lineIndex != lines.indices.last {
1323+
codeStartLineIndex = lineIndex + 1
1324+
1325+
// Any text following the code block start delimiter are treated as SwiftFormat options
1326+
if lineText.hasPrefix("```swift ") {
1327+
codeBlockOptions = String(lineText.dropFirst("```swift ".count))
1328+
}
1329+
}
1330+
} else if lineText == "```", let startLine = codeStartLineIndex {
1331+
if codeBlockStack > 0 {
1332+
// If we're inside a nested code block, pop it off the stack.
1333+
codeBlockStack -= 1
1334+
} else {
1335+
// Otherwise this is the end of a code block
1336+
let codeEnd = lines[lineIndex - 1].upperBound
1337+
let range = lines[startLine].lowerBound ..< codeEnd
1338+
let codeText = String(markdown[range])
1339+
1340+
assert(markdown[range] == codeText)
1341+
codeBlocks.append((range, codeText, codeBlockOptions, startLine))
1342+
codeStartLineIndex = nil
1343+
codeBlockOptions = nil
1344+
}
1345+
}
1346+
}
1347+
1348+
return codeBlocks
1349+
}

Sources/Formatter.swift

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -802,11 +802,31 @@ extension String {
802802
}
803803
}
804804

805-
private extension Collection<Token> where Index == Int {
805+
extension Collection<Token> {
806806
/// Ranges of lines within this array of tokens
807-
var lineRanges: [ClosedRange<Int>] {
808-
var lineRanges: [ClosedRange<Int>] = []
809-
var currentLine: ClosedRange<Int>?
807+
var lineRanges: [ClosedRange<Index>] {
808+
lineRanges(isLinebreak: \.isLinebreak)
809+
}
810+
811+
/// All of the lines within this array of tokens
812+
var lines: [SubSequence] {
813+
lineRanges.map { lineRange in
814+
self[lineRange]
815+
}
816+
}
817+
}
818+
819+
extension String {
820+
/// Ranges of lines within this string
821+
var lineRanges: [ClosedRange<Index>] {
822+
lineRanges(isLinebreak: { $0 == "\n" })
823+
}
824+
}
825+
826+
private extension Collection {
827+
func lineRanges(isLinebreak: (Element) -> Bool) -> [ClosedRange<Index>] {
828+
var lineRanges: [ClosedRange<Index>] = []
829+
var currentLine: ClosedRange<Index>?
810830

811831
for (index, token) in zip(indices, self) {
812832
if currentLine == nil {
@@ -815,7 +835,7 @@ private extension Collection<Token> where Index == Int {
815835
currentLine = currentLine!.lowerBound ... index
816836
}
817837

818-
if token.isLinebreak {
838+
if isLinebreak(token) {
819839
lineRanges.append(currentLine!)
820840
currentLine = nil
821841
}
@@ -827,13 +847,6 @@ private extension Collection<Token> where Index == Int {
827847

828848
return lineRanges
829849
}
830-
831-
/// All of the lines within this array of tokens
832-
var lines: [SubSequence] {
833-
lineRanges.map { lineRange in
834-
self[lineRange]
835-
}
836-
}
837850
}
838851

839852
/// A type that references an auto-updating subrange of indicies in a `Formatter`

Sources/Options.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1047,27 +1047,37 @@ public struct FormatOptions: CustomStringConvertible {
10471047
}
10481048
}
10491049

1050+
public enum MarkdownFormattingMode: String {
1051+
/// Errors in markdown code blocks are ignored
1052+
case lenient = "format-lenient"
1053+
/// Errors in markdown code blocks are emitted
1054+
case strict = "format-strict"
1055+
}
1056+
10501057
/// File enumeration options
10511058
public struct FileOptions {
10521059
public var followSymlinks: Bool
10531060
public var supportedFileExtensions: [String]
10541061
public var excludedGlobs: [Glob]
10551062
public var unexcludedGlobs: [Glob]
10561063
public var minVersion: Version
1064+
public var markdownFormattingMode: MarkdownFormattingMode?
10571065

10581066
public static let `default` = FileOptions()
10591067

10601068
public init(followSymlinks: Bool = false,
10611069
supportedFileExtensions: [String] = ["swift"],
10621070
excludedGlobs: [Glob] = [],
10631071
unexcludedGlobs: [Glob] = [],
1064-
minVersion: Version = .undefined)
1072+
minVersion: Version = .undefined,
1073+
markdownFormattingMode: MarkdownFormattingMode? = nil)
10651074
{
10661075
self.followSymlinks = followSymlinks
10671076
self.supportedFileExtensions = supportedFileExtensions
10681077
self.excludedGlobs = excludedGlobs
10691078
self.unexcludedGlobs = unexcludedGlobs
10701079
self.minVersion = minVersion
1080+
self.markdownFormattingMode = markdownFormattingMode
10711081
}
10721082

10731083
public func shouldSkipFile(_ inputURL: URL) -> Bool {

Sources/Rules/ExtensionAccessControl.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ public extension FormatRule {
1313
help: "Configure the placement of an extension's access control keyword.",
1414
options: ["extensionacl"]
1515
) { formatter in
16-
guard !formatter.options.fragment else { return }
17-
1816
let declarations = formatter.parseDeclarations()
1917
declarations.forEachRecursiveDeclaration { declaration in
2018
guard let extensionDeclaration = declaration.asTypeDeclaration,

Sources/Rules/Indent.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ public extension FormatRule {
663663
) ?? index
664664
}
665665
let lastToken = formatter.tokens[lastIndex]
666-
if formatter.options.fragment, lastToken == .delimiter(",") {
666+
if formatter.options.fragment, lastToken == .delimiter(","), formatter.startOfScope(at: i) == nil {
667667
break // Can't reliably indent
668668
}
669669
if lastIndex == formatter.startOfLine(at: lastIndex, excludingIndent: true) {

Sources/Rules/OrganizeDeclarations.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ public extension FormatRule {
2323
],
2424
sharedOptions: ["sortedpatterns", "lineaftermarks", "linebreaks"]
2525
) { formatter in
26-
guard !formatter.options.fragment else { return }
27-
2826
formatter.parseDeclarations().forEachRecursiveDeclaration { declaration in
2927
// Organize the body of type declarations
3028
guard let typeDeclaration = declaration.asTypeDeclaration else { return }

Sources/SwiftFormat.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,9 +448,9 @@ public func newOffset(for offset: SourceOffset, in tokens: [Token], tabWidth: In
448448
}
449449

450450
/// Process parsing errors
451-
public func parsingError(for tokens: [Token], options: FormatOptions) -> FormatError? {
451+
public func parsingError(for tokens: [Token], options: FormatOptions, allowErrorsInFragments: Bool = true) -> FormatError? {
452452
guard let index = tokens.firstIndex(where: {
453-
guard options.fragment || !$0.isError else { return true }
453+
guard (options.fragment && allowErrorsInFragments) || !$0.isError else { return true }
454454
guard !options.ignoreConflictMarkers, case let .operator(string, _) = $0 else { return false }
455455
return string.hasPrefix("<<<<<") || string.hasPrefix("=====") || string.hasPrefix(">>>>>")
456456
}) else {

0 commit comments

Comments
 (0)