Skip to content

Commit 634a112

Browse files
authored
Merge pull request #178 from allevato/better-error-handling
Refactor main executable into a frontend architecture.
2 parents df44950 + 3d859da commit 634a112

File tree

9 files changed

+394
-270
lines changed

9 files changed

+394
-270
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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 Foundation
14+
import SwiftFormatConfiguration
15+
16+
/// Loads formatter configurations, caching them in memory so that multiple operations in the same
17+
/// directory do not repeatedly hit the file system.
18+
struct ConfigurationLoader {
19+
/// A mapping from configuration file URLs to the loaded configuration data.
20+
private var cache = [URL: Configuration]()
21+
22+
/// Returns the configuration associated with the configuration file at the given path.
23+
///
24+
/// - Throws: If an error occurred loading the configuration.
25+
mutating func configuration(atPath path: String) throws -> Configuration {
26+
return try configuration(at: URL(fileURLWithPath: path))
27+
}
28+
29+
/// Returns the configuration found by searching in the directory (and ancestor directories)
30+
/// containing the given `.swift` source file.
31+
///
32+
/// If no configuration file was found during the search, this method returns nil.
33+
///
34+
/// - Throws: If a configuration file was found but an error occurred loading it.
35+
mutating func configuration(forSwiftFileAtPath path: String) throws -> Configuration? {
36+
let swiftFileURL = URL(fileURLWithPath: path)
37+
guard let configurationFileURL = Configuration.url(forConfigurationFileApplyingTo: swiftFileURL)
38+
else {
39+
return nil
40+
}
41+
return try configuration(at: configurationFileURL)
42+
}
43+
44+
/// Returns the configuration associated with the configuration file at the given URL.
45+
///
46+
/// - Throws: If an error occurred loading the configuration.
47+
private mutating func configuration(at url: URL) throws -> Configuration {
48+
if let cachedConfiguration = cache[url] {
49+
return cachedConfiguration
50+
}
51+
52+
let configuration = try Configuration(contentsOf: url)
53+
cache[url] = configuration
54+
return configuration
55+
}
56+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 Foundation
14+
import SwiftFormat
15+
import SwiftFormatConfiguration
16+
import SwiftSyntax
17+
18+
/// The frontend for formatting operations.
19+
class FormatFrontend: Frontend {
20+
/// Whether or not to format the Swift file in-place.
21+
private let inPlace: Bool
22+
23+
init(lintFormatOptions: LintFormatOptions, inPlace: Bool) {
24+
self.inPlace = inPlace
25+
super.init(lintFormatOptions: lintFormatOptions)
26+
}
27+
28+
override func processFile(_ fileToProcess: FileToProcess) {
29+
// Even though `diagnosticEngine` is defined, it's use is reserved for fatal messages. Pass nil
30+
// to the formatter to suppress other messages since they will be fixed or can't be
31+
// automatically fixed anyway.
32+
let formatter = SwiftFormatter(
33+
configuration: fileToProcess.configuration, diagnosticEngine: nil)
34+
formatter.debugOptions = debugOptions
35+
36+
let path = fileToProcess.path
37+
guard let source = fileToProcess.sourceText else {
38+
diagnosticEngine.diagnose(
39+
Diagnostic.Message(.error, "Unable to read source for formatting from \(path)."))
40+
return
41+
}
42+
43+
var stdoutStream = FileHandle.standardOutput
44+
do {
45+
let assumingFileURL = URL(fileURLWithPath: path)
46+
if inPlace {
47+
var buffer = ""
48+
try formatter.format(source: source, assumingFileURL: assumingFileURL, to: &buffer)
49+
50+
let bufferData = buffer.data(using: .utf8)! // Conversion to UTF-8 cannot fail
51+
try bufferData.write(to: assumingFileURL, options: .atomic)
52+
} else {
53+
try formatter.format(source: source, assumingFileURL: assumingFileURL, to: &stdoutStream)
54+
}
55+
} catch SwiftFormatError.fileNotReadable {
56+
diagnosticEngine.diagnose(
57+
Diagnostic.Message(
58+
.error, "Unable to format \(path): file is not readable or does not exist."))
59+
return
60+
} catch SwiftFormatError.fileContainsInvalidSyntax(let position) {
61+
guard !lintFormatOptions.ignoreUnparsableFiles else {
62+
guard !inPlace else {
63+
// For in-place mode, nothing is expected to stdout and the file shouldn't be modified.
64+
return
65+
}
66+
stdoutStream.write(source)
67+
return
68+
}
69+
let location = SourceLocationConverter(file: path, source: source).location(for: position)
70+
diagnosticEngine.diagnose(
71+
Diagnostic.Message(.error, "file contains invalid or unrecognized Swift syntax."),
72+
location: location)
73+
return
74+
} catch {
75+
diagnosticEngine.diagnose(Diagnostic.Message(.error, "Unable to format \(path): \(error)"))
76+
}
77+
}
78+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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 Foundation
14+
import SwiftFormat
15+
import SwiftFormatConfiguration
16+
import SwiftSyntax
17+
18+
class Frontend {
19+
/// Represents a file to be processed by the frontend and any file-specific options associated
20+
/// with it.
21+
final class FileToProcess {
22+
/// An open file handle to the source code of the file.
23+
private let fileHandle: FileHandle
24+
25+
/// The path to the source file being processed.
26+
///
27+
/// It is the responsibility of the specific frontend to make guarantees about the validity of
28+
/// this path. For example, the formatting frontend ensures that it is a path to an existing
29+
/// file only when doing in-place formatting (so that the file can be replaced). In other
30+
/// situations, it may correspond to a different file than the underlying file handle (if
31+
/// standard input is used with the `--assume-filename` flag), or it may not be a valid path at
32+
/// all (the string `"<stdin>"`).
33+
let path: String
34+
35+
/// The configuration that should applied for this file.
36+
let configuration: Configuration
37+
38+
/// Returns the string contents of the file.
39+
///
40+
/// The contents of the file are assumed to be UTF-8 encoded. If there is an error decoding the
41+
/// contents, `nil` will be returned.
42+
lazy var sourceText: String? = {
43+
let sourceData = fileHandle.readDataToEndOfFile()
44+
defer { fileHandle.closeFile() }
45+
return String(data: sourceData, encoding: .utf8)
46+
}()
47+
48+
init(fileHandle: FileHandle, path: String, configuration: Configuration) {
49+
self.fileHandle = fileHandle
50+
self.path = path
51+
self.configuration = configuration
52+
}
53+
}
54+
55+
/// The diagnostic engine to which warnings and errors will be emitted.
56+
final let diagnosticEngine: DiagnosticEngine = {
57+
let engine = DiagnosticEngine()
58+
let consumer = PrintingDiagnosticConsumer()
59+
engine.addConsumer(consumer)
60+
return engine
61+
}()
62+
63+
/// Options that apply during formatting or linting.
64+
final let lintFormatOptions: LintFormatOptions
65+
66+
/// Loads formatter configuration files.
67+
final var configurationLoader = ConfigurationLoader()
68+
69+
/// Advanced options that are useful for developing/debugging but otherwise not meant for general
70+
/// use.
71+
final var debugOptions: DebugOptions {
72+
[
73+
lintFormatOptions.debugDisablePrettyPrint ? .disablePrettyPrint : [],
74+
lintFormatOptions.debugDumpTokenStream ? .dumpTokenStream : [],
75+
]
76+
}
77+
78+
/// Indicates whether any errors were emitted during execution.
79+
final var errorsWereEmitted: Bool { diagnosticEngine.hasErrors }
80+
81+
/// Creates a new frontend with the given options.
82+
///
83+
/// - Parameter lintFormatOptions: Options that apply during formatting or linting.
84+
init(lintFormatOptions: LintFormatOptions) {
85+
self.lintFormatOptions = lintFormatOptions
86+
}
87+
88+
/// Runs the linter or formatter over the inputs.
89+
final func run() {
90+
let paths = lintFormatOptions.paths
91+
92+
if paths.isEmpty {
93+
processStandardInput()
94+
} else {
95+
processPaths(paths)
96+
}
97+
}
98+
99+
/// Called by the frontend to process a single file.
100+
///
101+
/// Subclasses must override this method to provide the actual linting or formatting logic.
102+
///
103+
/// - Parameter fileToProcess: A `FileToProcess` that contains information about the file to be
104+
/// processed.
105+
func processFile(_ fileToProcess: FileToProcess) {
106+
fatalError("Must be overridden by subclasses.")
107+
}
108+
109+
/// Processes source content from standard input.
110+
private func processStandardInput() {
111+
guard let configuration = configuration(
112+
atPath: lintFormatOptions.configurationPath,
113+
orInferredFromSwiftFileAtPath: nil)
114+
else {
115+
// Already diagnosed in the called method.
116+
return
117+
}
118+
119+
let fileToProcess = FileToProcess(
120+
fileHandle: FileHandle.standardInput,
121+
path: lintFormatOptions.assumeFilename ?? "<stdin>",
122+
configuration: configuration)
123+
processFile(fileToProcess)
124+
}
125+
126+
/// Processes source content from a list of files and/or directories provided as paths.
127+
private func processPaths(_ paths: [String]) {
128+
precondition(
129+
!paths.isEmpty,
130+
"processPaths(_:) should only be called when paths is non-empty.")
131+
132+
for path in FileIterator(paths: paths) {
133+
guard let sourceFile = FileHandle(forReadingAtPath: path) else {
134+
diagnosticEngine.diagnose(Diagnostic.Message(.error, "Unable to open \(path)"))
135+
continue
136+
}
137+
138+
guard let configuration = configuration(
139+
atPath: lintFormatOptions.configurationPath, orInferredFromSwiftFileAtPath: path)
140+
else {
141+
// Already diagnosed in the called method.
142+
continue
143+
}
144+
145+
let fileToProcess = FileToProcess(
146+
fileHandle: sourceFile, path: path, configuration: configuration)
147+
processFile(fileToProcess)
148+
}
149+
}
150+
151+
/// Returns the configuration that applies to the given `.swift` source file, when an explicit
152+
/// configuration path is also perhaps provided.
153+
///
154+
/// - Parameters:
155+
/// - configurationFilePath: The path to a configuration file that will be loaded, or `nil` to
156+
/// try to infer it from `swiftFilePath`.
157+
/// - swiftFilePath: The path to a `.swift` file, which will be used to infer the path to the
158+
/// configuration file if `configurationFilePath` is nil.
159+
/// - Returns: If successful, the returned configuration is the one loaded from
160+
/// `configurationFilePath` if it was provided, or by searching in paths inferred by
161+
/// `swiftFilePath` if one exists, or the default configuration otherwise. If an error occurred
162+
/// when reading the configuration, a diagnostic is emitted and `nil` is returned.
163+
private func configuration(
164+
atPath configurationFilePath: String?,
165+
orInferredFromSwiftFileAtPath swiftFilePath: String?
166+
) -> Configuration? {
167+
// If an explicit configuration file path was given, try to load it and fail if it cannot be
168+
// loaded. (Do not try to fall back to a path inferred from the source file path.)
169+
if let configurationFilePath = configurationFilePath {
170+
do {
171+
return try configurationLoader.configuration(atPath: configurationFilePath)
172+
} catch {
173+
diagnosticEngine.diagnose(
174+
Diagnostic.Message(.error, "Unable to read configuration: \(error.localizedDescription)"))
175+
return nil
176+
}
177+
}
178+
179+
// If no explicit configuration file path was given but a `.swift` source file path was given,
180+
// then try to load the configuration by inferring it based on the source file path.
181+
if let swiftFilePath = swiftFilePath {
182+
do {
183+
if let configuration =
184+
try configurationLoader.configuration(forSwiftFileAtPath: swiftFilePath)
185+
{
186+
return configuration
187+
}
188+
// Fall through to the default return at the end of the function.
189+
} catch {
190+
diagnosticEngine.diagnose(
191+
Diagnostic.Message(.error, "Unable to read configuration for \(swiftFilePath): "
192+
+ "\(error.localizedDescription)"))
193+
return nil
194+
}
195+
}
196+
197+
// If neither path was given (for example, formatting standard input with no assumed filename)
198+
// or if there was no configuration found by inferring it from the source file path, return the
199+
// default configuration.
200+
return Configuration()
201+
}
202+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 Foundation
14+
import SwiftFormat
15+
import SwiftFormatConfiguration
16+
import SwiftSyntax
17+
18+
/// The frontend for linting operations.
19+
class LintFrontend: Frontend {
20+
override func processFile(_ fileToProcess: FileToProcess) {
21+
let linter = SwiftLinter(
22+
configuration: fileToProcess.configuration, diagnosticEngine: diagnosticEngine)
23+
linter.debugOptions = debugOptions
24+
25+
let path = fileToProcess.path
26+
guard let source = fileToProcess.sourceText else {
27+
diagnosticEngine.diagnose(
28+
Diagnostic.Message(
29+
.error, "Unable to read source for linting from \(path)."))
30+
return
31+
}
32+
33+
do {
34+
let assumingFileURL = URL(fileURLWithPath: path)
35+
try linter.lint(source: source, assumingFileURL: assumingFileURL)
36+
} catch SwiftFormatError.fileNotReadable {
37+
diagnosticEngine.diagnose(
38+
Diagnostic.Message(
39+
.error, "Unable to lint \(path): file is not readable or does not exist."))
40+
return
41+
} catch SwiftFormatError.fileContainsInvalidSyntax(let position) {
42+
let location = SourceLocationConverter(file: path, source: source).location(for: position)
43+
diagnosticEngine.diagnose(
44+
Diagnostic.Message(.error, "file contains invalid or unrecognized Swift syntax."),
45+
location: location)
46+
return
47+
} catch {
48+
diagnosticEngine.diagnose(Diagnostic.Message(.error, "Unable to lint \(path): \(error)"))
49+
return
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)