Skip to content

Commit 3d859da

Browse files
committed
Refactor main executable into a frontend architecture.
Instead of a bunch of free functions that pass their arguments around, this change now refactors the core format and lint file handling functionality into a set of "frontend" classes so that the diagnostic engine and related state can be created in one place and shared. This also significantly improves error handling. Whereas we were previously killing the process on the first sign of a source file or configuration file being invalid, we now emit an error and skip the file, allowing any remaining inputs to still be processed. Lastly, this introduces a configuration cache so that we're not re-reading the configuration from the file system for every source file we process. In most cases, when we're processing a recursive directory structure, they'll all share a configuration file at a common root, so we cache that based on its file URL and return it when requested.
1 parent 98f458f commit 3d859da

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)