Skip to content

Commit a65a7e3

Browse files
committed
Fail fast and safe for unparseable files. Use the same DiagnosticEngine for all diagnostics.
After finding some "source" files that stretched the syntax parser to its limits, it looks like the formatter cannot trust any AST that contains any unknown/invalid nodes. It's possible for an unknown node to cause the parser to group tokens in ways that create known but incorrect nodes. Instead of potentially formatting those tokens as the wrong kind of node, which may be destructive, fail early on the file and refuse to format it. When that happens, the formatter raises a diagnostic (which goes to stderr by default). Additionally, the formatter was using a new diagnostic engine for every file and was explicitly writing certain messages directly to stderr. I've reworked the format and lint operators to use the same diagnostic engine for every file and to use that diagnostic engine instead of stderr. By default, the diagnostic engine writes to stderr so it's a essentially a no-op change but can be helpful if anyone wants to add a diagnostic consumer later.
1 parent 0266e97 commit a65a7e3

File tree

10 files changed

+227
-46
lines changed

10 files changed

+227
-46
lines changed

Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ let package = Package(
7171
"SwiftToolsSupport-auto",
7272
]
7373
),
74+
.testTarget(
75+
name: "SwiftFormatTests",
76+
dependencies: [
77+
"SwiftFormat",
78+
]
79+
),
7480
.testTarget(
7581
name: "SwiftFormatConfigurationTests",
7682
dependencies: ["SwiftFormatConfiguration"]

Sources/SwiftFormat/SwiftFormatError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ public enum SwiftFormatError: Error {
1818

1919
/// The requested file was a directory.
2020
case isDirectory
21+
22+
/// The file contains invalid or unrecognized Swift syntax and cannot be handled safely.
23+
case fileContainsInvalidSyntax
2124
}

Sources/SwiftFormat/SwiftFormatter.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,11 @@ public final class SwiftFormatter {
9191
public func format<Output: TextOutputStream>(
9292
syntax: SourceFileSyntax, assumingFileURL url: URL?, to outputStream: inout Output
9393
) throws {
94-
let assumedURL = url ?? URL(fileURLWithPath: "source")
94+
guard isSyntaxValidForProcessing(Syntax(syntax)) else {
95+
throw SwiftFormatError.fileContainsInvalidSyntax
96+
}
9597

98+
let assumedURL = url ?? URL(fileURLWithPath: "source")
9699
let context = Context(
97100
configuration: configuration, diagnosticEngine: diagnosticEngine, fileURL: assumedURL,
98101
sourceFileSyntax: syntax)

Sources/SwiftFormat/SwiftLinter.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ public final class SwiftLinter {
7575
/// - url: A file URL denoting the filename/path that should be assumed for this syntax tree.
7676
/// - Throws: If an unrecoverable error occurs when formatting the code.
7777
public func lint(syntax: SourceFileSyntax, assumingFileURL url: URL) throws {
78+
guard isSyntaxValidForProcessing(Syntax(syntax)) else {
79+
throw SwiftFormatError.fileContainsInvalidSyntax
80+
}
81+
7882
let context = Context(
7983
configuration: configuration, diagnosticEngine: diagnosticEngine, fileURL: url,
8084
sourceFileSyntax: syntax)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SwiftSyntax
14+
15+
/// A SyntaxVisitor that searches for nodes that cannot be handled safely.
16+
fileprivate class SyntaxValidatingVisitor: SyntaxVisitor {
17+
/// Tracks whether an invalid node has been encountered.
18+
var isValidSyntax = true
19+
20+
override func visit(_ node: UnknownSyntax) -> SyntaxVisitorContinueKind {
21+
isValidSyntax = false
22+
return .skipChildren
23+
}
24+
25+
override func visit(_ node: UnknownDeclSyntax) -> SyntaxVisitorContinueKind {
26+
isValidSyntax = false
27+
return .skipChildren
28+
}
29+
30+
override func visit(_ node: UnknownExprSyntax) -> SyntaxVisitorContinueKind {
31+
isValidSyntax = false
32+
return .skipChildren
33+
}
34+
35+
override func visit(_ node: UnknownStmtSyntax) -> SyntaxVisitorContinueKind {
36+
isValidSyntax = false
37+
return .skipChildren
38+
}
39+
40+
override func visit(_ node: UnknownTypeSyntax) -> SyntaxVisitorContinueKind {
41+
isValidSyntax = false
42+
return .skipChildren
43+
}
44+
45+
override func visit(_ node: UnknownPatternSyntax) -> SyntaxVisitorContinueKind {
46+
isValidSyntax = false
47+
return .skipChildren
48+
}
49+
50+
override func visit(_ node: NonEmptyTokenListSyntax) -> SyntaxVisitorContinueKind {
51+
isValidSyntax = false
52+
return .skipChildren
53+
}
54+
55+
override func visit(_ node: AttributeSyntax) -> SyntaxVisitorContinueKind {
56+
// The token list is used to collect any unexpected tokens. When it's missing or empty, then
57+
// there were no unexpected tokens. Otherwise, the attribute is invalid.
58+
guard node.tokenList?.isEmpty ?? true else {
59+
isValidSyntax = false
60+
return .skipChildren
61+
}
62+
return .visitChildren
63+
}
64+
}
65+
66+
/// Returns whether the given syntax contains any nodes which are invalid or unrecognized and
67+
/// cannot be handled safely.
68+
///
69+
/// - Parameter syntax: The root of a tree of syntax nodes to check for compatibility.
70+
func isSyntaxValidForProcessing(_ syntax: Syntax) -> Bool {
71+
let visitor = SyntaxValidatingVisitor()
72+
visitor.walk(syntax)
73+
return visitor.isValidSyntax
74+
}

Sources/swift-format/Run.swift

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,40 +25,38 @@ import TSCBasic
2525
/// - sourceFile: A file handle from which to read the source code to be linted.
2626
/// - assumingFilename: The filename of the source file, used in diagnostic output.
2727
/// - debugOptions: The set containing any debug options that were supplied on the command line.
28+
/// - diagnosticEngine: A diagnostic collector that handles diagnostic messages.
2829
/// - Returns: Zero if there were no lint errors, otherwise a non-zero number.
2930
func lintMain(
3031
configuration: Configuration, sourceFile: FileHandle, assumingFilename: String?,
31-
debugOptions: DebugOptions
32+
debugOptions: DebugOptions, diagnosticEngine: DiagnosticEngine
3233
) -> Int {
33-
let diagnosticEngine = makeDiagnosticEngine()
3434
let linter = SwiftLinter(configuration: configuration, diagnosticEngine: diagnosticEngine)
3535
linter.debugOptions = debugOptions
3636
let assumingFileURL = URL(fileURLWithPath: assumingFilename ?? "<stdin>")
3737

3838
guard let source = readSource(from: sourceFile) else {
39-
stderrStream.write("Unable to read source for linting from \(assumingFileURL.path).\n")
40-
stderrStream.flush()
39+
diagnosticEngine.diagnose(
40+
Diagnostic.Message(.error, "Unable to read source for linting from \(assumingFileURL.path)."))
4141
return 1
4242
}
4343

4444
do {
4545
try linter.lint(source: source, assumingFileURL: assumingFileURL)
4646
} catch SwiftFormatError.fileNotReadable {
4747
let path = assumingFileURL.path
48-
stderrStream.write("Unable to lint \(path): file is not readable or does not exist.\n")
49-
stderrStream.flush()
48+
diagnosticEngine.diagnose(
49+
Diagnostic.Message(.error, "Unable to lint \(path): file is not readable or does not exist."))
5050
return 1
51-
} catch {
51+
} catch SwiftFormatError.fileContainsInvalidSyntax {
52+
let path = assumingFileURL.path
53+
diagnosticEngine.diagnose(
54+
Diagnostic.Message(
55+
.error, "Unable to line \(path): file contains invalid or unrecognized Swift syntax."))
56+
return 1
57+
} catch {
5258
let path = assumingFileURL.path
53-
// Workaround: we're unable to directly catch unknownTokenKind errors due to access
54-
// restrictions. TODO: this can be removed when we update to Swift 5.0.
55-
if "\(error)" == "unknownTokenKind(\"pound_error\")" {
56-
stderrStream.write("Unable to lint \(path): unknownTokenKind(\"pound_error\")\n")
57-
stderrStream.flush()
58-
return 1
59-
}
60-
stderrStream.write("Unable to lint \(path): \(error)\n")
61-
stderrStream.flush()
59+
diagnosticEngine.diagnose(Diagnostic.Message(.error, "Unable to lint \(path): \(error)"))
6260
exit(1)
6361
}
6462
return diagnosticEngine.diagnostics.isEmpty ? 0 : 1
@@ -72,18 +70,20 @@ func lintMain(
7270
/// - assumingFilename: The filename of the source file, used in diagnostic output.
7371
/// - inPlace: Whether or not to overwrite the current file when formatting.
7472
/// - debugOptions: The set containing any debug options that were supplied on the command line.
73+
/// - diagnosticEngine: A diagnostic collector that handles diagnostic messages.
7574
/// - Returns: Zero if there were no format errors, otherwise a non-zero number.
7675
func formatMain(
7776
configuration: Configuration, sourceFile: FileHandle, assumingFilename: String?, inPlace: Bool,
78-
debugOptions: DebugOptions
77+
debugOptions: DebugOptions, diagnosticEngine: DiagnosticEngine
7978
) -> Int {
80-
let formatter = SwiftFormatter(configuration: configuration, diagnosticEngine: nil)
79+
let formatter = SwiftFormatter(configuration: configuration, diagnosticEngine: diagnosticEngine)
8180
formatter.debugOptions = debugOptions
8281
let assumingFileURL = URL(fileURLWithPath: assumingFilename ?? "<stdin>")
8382

8483
guard let source = readSource(from: sourceFile) else {
85-
stderrStream.write("Unable to read source for formatting from \(assumingFileURL.path).\n")
86-
stderrStream.flush()
84+
diagnosticEngine.diagnose(
85+
Diagnostic.Message(
86+
.error, "Unable to read source for formatting from \(assumingFileURL.path)."))
8787
return 1
8888
}
8989

@@ -103,26 +103,24 @@ func formatMain(
103103
}
104104
} catch SwiftFormatError.fileNotReadable {
105105
let path = assumingFileURL.path
106-
stderrStream.write("Unable to format \(path): file is not readable or does not exist.\n")
107-
stderrStream.flush()
106+
diagnosticEngine.diagnose(
107+
Diagnostic.Message(
108+
.error, "Unable to format \(path): file is not readable or does not exist."))
109+
return 1
110+
} catch SwiftFormatError.fileContainsInvalidSyntax {
111+
let path = assumingFileURL.path
112+
diagnosticEngine.diagnose(
113+
Diagnostic.Message(
114+
.error, "Unable to format \(path): file contains invalid or unrecognized Swift syntax."))
108115
return 1
109116
} catch {
110117
let path = assumingFileURL.path
111-
stderrStream.write("Unable to format \(path): \(error)\n")
112-
stderrStream.flush()
118+
diagnosticEngine.diagnose(Diagnostic.Message(.error, "Unable to format \(path): \(error)"))
113119
exit(1)
114120
}
115121
return 0
116122
}
117123

118-
/// Makes and returns a new configured diagnostic engine.
119-
fileprivate func makeDiagnosticEngine() -> DiagnosticEngine {
120-
let engine = DiagnosticEngine()
121-
let consumer = PrintingDiagnosticConsumer()
122-
engine.addConsumer(consumer)
123-
return engine
124-
}
125-
126124
/// Reads from the given file handle until EOF is reached, then returns the contents as a UTF8
127125
/// encoded string.
128126
fileprivate func readSource(from fileHandle: FileHandle) -> String? {

Sources/swift-format/main.swift

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import Foundation
1414
import SwiftFormat
1515
import SwiftFormatConfiguration
1616
import SwiftFormatCore
17+
import SwiftSyntax
1718
import TSCBasic
1819
import TSCUtility
1920

2021
fileprivate func main(_ arguments: [String]) -> Int32 {
2122
let url = URL(fileURLWithPath: arguments.first!)
2223
let options = processArguments(commandName: url.lastPathComponent, Array(arguments.dropFirst()))
24+
let diagnosticEngine = makeDiagnosticEngine()
2325
switch options.mode {
2426
case .format:
2527
if options.paths.isEmpty {
@@ -29,13 +31,17 @@ fileprivate func main(_ arguments: [String]) -> Int32 {
2931
formatMain(
3032
configuration: configuration, sourceFile: FileHandle.standardInput,
3133
assumingFilename: options.assumeFilename, inPlace: false,
32-
debugOptions: options.debugOptions))
34+
debugOptions: options.debugOptions, diagnosticEngine: diagnosticEngine))
3335
}
34-
return processSources(from: options.paths, configurationPath: options.configurationPath) {
36+
return processSources(
37+
from: options.paths, configurationPath: options.configurationPath,
38+
diagnosticEngine: diagnosticEngine
39+
) {
3540
(sourceFile, path, configuration) in
3641
formatMain(
3742
configuration: configuration, sourceFile: sourceFile, assumingFilename: path,
38-
inPlace: options.inPlace, debugOptions: options.debugOptions)
43+
inPlace: options.inPlace, debugOptions: options.debugOptions,
44+
diagnosticEngine: diagnosticEngine)
3945
}
4046
case .lint:
4147
if options.paths.isEmpty {
@@ -44,13 +50,17 @@ fileprivate func main(_ arguments: [String]) -> Int32 {
4450
return Int32(
4551
lintMain(
4652
configuration: configuration, sourceFile: FileHandle.standardInput,
47-
assumingFilename: options.assumeFilename, debugOptions: options.debugOptions))
53+
assumingFilename: options.assumeFilename, debugOptions: options.debugOptions,
54+
diagnosticEngine: diagnosticEngine))
4855
}
49-
return processSources(from: options.paths, configurationPath: options.configurationPath) {
56+
return processSources(
57+
from: options.paths, configurationPath: options.configurationPath,
58+
diagnosticEngine: diagnosticEngine
59+
) {
5060
(sourceFile, path, configuration) in
5161
lintMain(
5262
configuration: configuration, sourceFile: sourceFile, assumingFilename: path,
53-
debugOptions: options.debugOptions)
63+
debugOptions: options.debugOptions, diagnosticEngine: diagnosticEngine)
5464
}
5565
case .dumpConfiguration:
5666
dumpDefaultConfiguration()
@@ -66,16 +76,17 @@ fileprivate func main(_ arguments: [String]) -> Int32 {
6676
/// - Parameters:
6777
/// - paths: The file paths for the source files to process with a transformation.
6878
/// - configurationPath: The file path to a swift-format configuration file.
79+
/// - diagnosticEngine: A diagnostic collector that handles diagnostic messages.
6980
/// - transform: A closure that performs a transformation on a specific source file.
7081
fileprivate func processSources(
71-
from paths: [String], configurationPath: String?,
82+
from paths: [String], configurationPath: String?, diagnosticEngine: DiagnosticEngine,
7283
transform: (FileHandle, String, Configuration) -> Int
7384
) -> Int32 {
7485
var result = 0
7586
for path in FileIterator(paths: paths) {
7687
guard let sourceFile = FileHandle(forReadingAtPath: path) else {
77-
stderrStream.write("Unable to create a file handle for source from \(path).\n")
78-
stderrStream.flush()
88+
diagnosticEngine.diagnose(
89+
Diagnostic.Message(.error, "Unable to create a file handle for source from \(path)."))
7990
return 1
8091
}
8192
let configuration = loadConfiguration(forSwiftFile: path, configFilePath: configurationPath)
@@ -84,6 +95,14 @@ fileprivate func processSources(
8495
return Int32(result)
8596
}
8697

98+
/// Makes and returns a new configured diagnostic engine.
99+
fileprivate func makeDiagnosticEngine() -> DiagnosticEngine {
100+
let engine = DiagnosticEngine()
101+
let consumer = PrintingDiagnosticConsumer()
102+
engine.addConsumer(consumer)
103+
return engine
104+
}
105+
87106
/// Load the configuration.
88107
fileprivate func loadConfiguration(
89108
forSwiftFile swiftFilePath: String?, configFilePath: String?
@@ -92,15 +111,15 @@ fileprivate func loadConfiguration(
92111
return decodedConfiguration(fromFile: URL(fileURLWithPath: configFilePath))
93112
}
94113

95-
if let swiftFileURL = swiftFilePath.map(URL.init(fileURLWithPath:)),
96-
let configFileURL = Configuration.url(forConfigurationFileApplyingTo: swiftFileURL) {
97-
return decodedConfiguration(fromFile: configFileURL)
114+
if let swiftFileURL = swiftFilePath.map(URL.init(fileURLWithPath:)),
115+
let configFileURL = Configuration.url(forConfigurationFileApplyingTo: swiftFileURL)
116+
{
117+
return decodedConfiguration(fromFile: configFileURL)
98118
}
99119

100120
return Configuration()
101121
}
102122

103-
104123
/// Loads and returns a `Configuration` from the given JSON file if it is found and is valid. If the
105124
/// file does not exist or there was an error decoding it, the program exits with a non-zero exit
106125
/// code.

Tests/LinuxMain.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import SwiftFormatConfigurationTests
44
import SwiftFormatCoreTests
55
import SwiftFormatPrettyPrintTests
66
import SwiftFormatRulesTests
7+
import SwiftFormatTests
78
import SwiftFormatWhitespaceLinterTests
89

910
var tests = [XCTestCaseEntry]()
1011
tests += SwiftFormatConfigurationTests.__allTests()
1112
tests += SwiftFormatCoreTests.__allTests()
1213
tests += SwiftFormatPrettyPrintTests.__allTests()
1314
tests += SwiftFormatRulesTests.__allTests()
15+
tests += SwiftFormatTests.__allTests()
1416
tests += SwiftFormatWhitespaceLinterTests.__allTests()
1517

1618
XCTMain(tests)

0 commit comments

Comments
 (0)