Skip to content

Commit a7b8348

Browse files
authored
Emit YAML processing errors that Xcode can display (#81)
### Motivation If there are any errors in the openapi.yaml file currently the errors show up in the build transcript but are not shown in the Xcode issue sidebar and are also not linked to the openapi.yaml file in the sidebar. This makes it hard to know what has failed, and what to do to fix the file. ### Modifications The YAML parser now catches decoding errors, and if they are common parsing or scanning errors it will emit a diagnostic message then throw a failure exit code. This required importing the swift-argument-parser module into the main _OpenAPIGeneratorCore. Added `absoluteFilePath` and `lineNumber` optional properties to the `Diagnostics` struct to capture more info. Modified the `Diagnostics.description` method to build a properly formatted message that Xcode can parse to point out the file and line causing issues. ### Result In consuming projects, invalid yaml in the openapi.yaml file is now highlighted in the Xcode Issue Navigator after a build with this plugin. ### Test Plan Added unit tests to verify emitted diagnostic and thrown error. Also tested manually with an example project. ### Resolves - Resolves #65. --------- Signed-off-by: Kyle Hammond <[email protected]>
1 parent 28258e2 commit a7b8348

File tree

4 files changed

+251
-53
lines changed

4 files changed

+251
-53
lines changed

Sources/_OpenAPIGeneratorCore/Diagnostics.swift

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,39 +37,55 @@ public struct Diagnostic: Error, Codable {
3737
/// A user-friendly description of the diagnostic.
3838
public var message: String
3939

40+
/// Describes the source file that triggered a diagnostic.
41+
public struct Location: Codable {
42+
/// The absolute path to a specific source file that triggered the diagnostic.
43+
public var filePath: String
44+
45+
/// The line number (if known) of the line within the source file that triggered the diagnostic.
46+
public var lineNumber: Int?
47+
}
48+
49+
/// The source file that triggered the diagnostic.
50+
public var location: Location?
51+
4052
/// Additional information about where the issue occurred.
4153
public var context: [String: String] = [:]
4254

4355
/// Creates an informative message, which doesn't represent an issue.
44-
public static func note(message: String, context: [String: String] = [:]) -> Diagnostic {
45-
.init(severity: .note, message: message, context: context)
56+
public static func note(message: String, location: Location? = nil, context: [String: String] = [:]) -> Diagnostic {
57+
.init(severity: .note, message: message, location: location, context: context)
4658
}
4759

4860
/// Creates a recoverable issue, which doesn't prevent the generator
4961
/// from continuing.
5062
/// - Parameters:
5163
/// - message: The message that describes the warning.
64+
/// - location: Describe the source file that triggered the diagnostic (if known).
5265
/// - context: A set of key-value pairs that help the user understand
5366
/// where the warning occurred.
5467
/// - Returns: A warning diagnostic.
5568
public static func warning(
5669
message: String,
70+
location: Location? = nil,
5771
context: [String: String] = [:]
5872
) -> Diagnostic {
59-
.init(severity: .warning, message: message, context: context)
73+
.init(severity: .warning, message: message, location: location, context: context)
6074
}
6175

6276
/// Creates a non-recoverable issue, which leads the generator to stop.
6377
/// - Parameters:
6478
/// - message: The message that describes the error.
79+
/// - location: Describe the source file that triggered the diagnostic (if known).
6580
/// - context: A set of key-value pairs that help the user understand
6681
/// where the warning occurred.
6782
/// - Returns: An error diagnostic.
6883
public static func error(
6984
message: String,
85+
location: Location? = nil,
7086
context: [String: String] = [:]
7187
) -> Diagnostic {
72-
.init(severity: .error, message: message, context: context)
88+
.init(severity: .error, message: message, location: location, context: context)
7389
}
7490

7591
/// Creates a diagnostic for an unsupported feature.
@@ -79,17 +95,23 @@ public struct Diagnostic: Error, Codable {
7995
/// - feature: A human-readable name of the feature.
8096
/// - foundIn: A description of the location in which the unsupported
8197
/// feature was detected.
98+
/// - location: Describe the source file that triggered the diagnostic (if known).
8299
/// - context: A set of key-value pairs that help the user understand
83100
/// where the warning occurred.
84101
/// - Returns: A warning diagnostic.
85102
public static func unsupported(
86103
_ feature: String,
87104
foundIn: String,
105+
location: Location? = nil,
88106
context: [String: String] = [:]
89107
) -> Diagnostic {
90108
var context = context
91109
context["foundIn"] = foundIn
92-
return warning(message: "Feature \"\(feature)\" is not supported, skipping", context: context)
110+
return warning(
111+
message: "Feature \"\(feature)\" is not supported, skipping",
112+
location: location,
113+
context: context
114+
)
93115
}
94116
}
95117

@@ -101,8 +123,16 @@ extension Diagnostic.Severity: CustomStringConvertible {
101123

102124
extension Diagnostic: CustomStringConvertible {
103125
public var description: String {
126+
var prefix = ""
127+
if let location = location {
128+
prefix = "\(location.filePath):"
129+
if let line = location.lineNumber {
130+
prefix += "\(line):"
131+
}
132+
prefix += " "
133+
}
104134
let contextString = context.map { "\($0)=\($1)" }.sorted().joined(separator: ", ")
105-
return "\(severity): \(message) [\(contextString.isEmpty ? "" : "context: \(contextString)")]"
135+
return "\(prefix)\(severity): \(message)\(contextString.isEmpty ? "" : " [context: \(contextString)]")"
106136
}
107137
}
108138

Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
// SPDX-License-Identifier: Apache-2.0
1212
//
1313
//===----------------------------------------------------------------------===//
14-
import Yams
15-
import OpenAPIKit30
1614
import Foundation
15+
import OpenAPIKit30
16+
import Yams
1717

1818
/// A parser that uses the Yams library to parse the provided
1919
/// raw file into an OpenAPI document.
@@ -30,37 +30,93 @@ struct YamsParser: ParserProtocol {
3030
var openapi: String?
3131
}
3232

33-
struct OpenAPIVersionError: Error, CustomStringConvertible, LocalizedError {
34-
var versionString: String
35-
var description: String {
36-
"Unsupported document version: \(versionString). Please provide a document with OpenAPI versions in the 3.0.x set."
37-
}
33+
let versionedDocument: OpenAPIVersionedDocument
34+
do {
35+
versionedDocument = try decoder.decode(
36+
OpenAPIVersionedDocument.self,
37+
from: openapiData
38+
)
39+
} catch DecodingError.dataCorrupted(let errorContext) {
40+
try checkParsingError(context: errorContext, input: input)
41+
throw DecodingError.dataCorrupted(errorContext)
3842
}
3943

40-
struct OpenAPIMissingVersionError: Error, CustomStringConvertible, LocalizedError {
41-
var description: String {
42-
"No openapi key found, please provide a valid OpenAPI document with OpenAPI versions in the 3.0.x set."
43-
}
44-
}
45-
46-
let versionedDocument = try decoder.decode(
47-
OpenAPIVersionedDocument.self,
48-
from: openapiData
49-
)
50-
5144
guard let openAPIVersion = versionedDocument.openapi else {
52-
throw OpenAPIMissingVersionError()
45+
throw Diagnostic.openAPIMissingVersionError(location: .init(filePath: input.absolutePath.path))
5346
}
5447
switch openAPIVersion {
5548
case "3.0.0", "3.0.1", "3.0.2", "3.0.3":
5649
break
5750
default:
58-
throw OpenAPIVersionError(versionString: "openapi: \(openAPIVersion)")
51+
throw Diagnostic.openAPIVersionError(
52+
versionString: "openapi: \(openAPIVersion)",
53+
location: .init(filePath: input.absolutePath.path)
54+
)
5955
}
6056

61-
return try decoder.decode(
62-
OpenAPI.Document.self,
63-
from: input.contents
57+
do {
58+
return try decoder.decode(
59+
OpenAPI.Document.self,
60+
from: input.contents
61+
)
62+
} catch DecodingError.dataCorrupted(let errorContext) {
63+
try checkParsingError(context: errorContext, input: input)
64+
throw DecodingError.dataCorrupted(errorContext)
65+
}
66+
}
67+
68+
/// Detect specific YAML parsing errors to throw nicely formatted diagnostics for IDEs
69+
/// - Parameters:
70+
/// - context: The error context that triggered the `DecodingError`.
71+
/// - input: The input file that was being worked on when the error was triggered.
72+
/// - Throws: Will throw a `Diagnostic` if the decoding error is a common parsing error.
73+
private func checkParsingError(
74+
context: DecodingError.Context,
75+
input: InMemoryInputFile
76+
) throws {
77+
if let yamlError = context.underlyingError as? YamlError {
78+
if case .parser(let yamlContext, let yamlProblem, let yamlMark, _) = yamlError {
79+
throw Diagnostic.error(
80+
message: "\(yamlProblem) \(yamlContext?.description ?? "")",
81+
location: .init(filePath: input.absolutePath.path, lineNumber: yamlMark.line - 1)
82+
)
83+
} else if case .scanner(let yamlContext, let yamlProblem, let yamlMark, _) = yamlError {
84+
throw Diagnostic.error(
85+
message: "\(yamlProblem) \(yamlContext?.description ?? "")",
86+
location: .init(filePath: input.absolutePath.path, lineNumber: yamlMark.line - 1)
87+
)
88+
}
89+
} else if let openAPIError = context.underlyingError as? OpenAPIError {
90+
throw Diagnostic.error(
91+
message: openAPIError.localizedDescription,
92+
location: .init(filePath: input.absolutePath.path)
93+
)
94+
}
95+
}
96+
}
97+
98+
extension Diagnostic {
99+
/// Use when the document is an unsupported version.
100+
/// - Parameters:
101+
/// - versionString: The OpenAPI version number that was parsed from the document.
102+
/// - location: Describes the input file being worked on when the error occurred.
103+
/// - Returns: An error diagnostic.
104+
static func openAPIVersionError(versionString: String, location: Location) -> Diagnostic {
105+
return error(
106+
message:
107+
"Unsupported document version: \(versionString). Please provide a document with OpenAPI versions in the 3.0.x set.",
108+
location: location
109+
)
110+
}
111+
112+
/// Use when the YAML document is completely missing the `openapi` version key.
113+
/// - Parameter location: Describes the input file being worked on when the error occurred
114+
/// - Returns: An error diagnostic.
115+
static func openAPIMissingVersionError(location: Location) -> Diagnostic {
116+
return error(
117+
message:
118+
"No openapi key found, please provide a valid OpenAPI document with OpenAPI versions in the 3.0.x set.",
119+
location: location
64120
)
65121
}
66122
}

Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414
import _OpenAPIGeneratorCore
15+
import ArgumentParser
1516
import Foundation
1617

1718
extension _GenerateOptions {
@@ -65,13 +66,19 @@ extension _GenerateOptions {
6566
- Additional imports: \(resolvedAdditionalImports.isEmpty ? "<none>" : resolvedAdditionalImports.joined(separator: ", "))
6667
"""
6768
)
68-
try _Tool.runGenerator(
69-
doc: doc,
70-
configs: configs,
71-
isPluginInvocation: isPluginInvocation,
72-
outputDirectory: outputDirectory,
73-
diagnostics: diagnostics
74-
)
69+
do {
70+
try _Tool.runGenerator(
71+
doc: doc,
72+
configs: configs,
73+
isPluginInvocation: isPluginInvocation,
74+
outputDirectory: outputDirectory,
75+
diagnostics: diagnostics
76+
)
77+
} catch let error as Diagnostic {
78+
// Emit our nice Diagnostics message instead of relying on ArgumentParser output.
79+
diagnostics.emit(error)
80+
throw ExitCode.failure
81+
}
7582
try finalizeDiagnostics()
7683
}
7784
}

0 commit comments

Comments
 (0)