Skip to content

Commit d991842

Browse files
Centralize directive parsing and declaration logic (#362)
Introduces a new `AutomaticDirectiveConvertible` protocol along with several property wrappers to allow for declaring new markup directives with built-in parsing and diagnostics. The general architecture is based on `ArgumentParser`. Here’s an example directive: public final class Intro: Semantic, AutomaticDirectiveConvertible { public let originalMarkup: BlockDirective /// The title of the containing ``Tutorial``. @DirectiveArgumentWrapped public private(set) var title: String /// An optional video, displayed as a modal. @ChildDirective public private(set) var video: VideoMedia? = nil /// An optional standout image. @ChildDirective public private(set) var image: ImageMedia? = nil /// The child markup content. @ChildMarkup(numberOfParagraphs: .zeroOrMore) public private(set) var content: MarkupContainer static var keyPaths: [String : AnyKeyPath] = [ "title" : \Intro._title, "video" : \Intro._video, "image" : \Intro._image, "content" : \Intro._content ] init(originalMarkup: BlockDirective, title: String, image: ImageMedia?, video: VideoMedia?, content: MarkupContainer) { self.originalMarkup = originalMarkup super.init() self.content = content self.title = title self.image = image self.video = video } @available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.") init(originalMarkup: BlockDirective) { self.originalMarkup = originalMarkup } } This has some immediate benefits of reducing code duplication but longer term the main goal is to create a single source of truth for what Directives are allowed where and what arguments they expect. The next step here will be to expose this information to DocC’s own conceptual documentation so that directive documentation can be auto-generated based on this metadata. Longer term, this information can be used to power other integrations like code completion strategies. Resolves rdar://99065717
1 parent 2ad068a commit d991842

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+2180
-729
lines changed

Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,16 +211,18 @@ struct RenderContentCompiler: MarkupVisitor {
211211
mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> [RenderContent] {
212212
switch blockDirective.name {
213213
case Snippet.directiveName:
214-
let arguments = blockDirective.arguments()
215-
guard let snippetURL = arguments[Snippet.Semantics.Path.argumentName],
216-
let snippetReference = resolveSymbolReference(destination: snippetURL.value),
214+
guard let snippet = Snippet(from: blockDirective, for: bundle, in: context) else {
215+
return []
216+
}
217+
218+
guard let snippetReference = resolveSymbolReference(destination: snippet.path),
217219
let snippetEntity = try? context.entity(with: snippetReference),
218220
let snippetSymbol = snippetEntity.symbol,
219221
let snippetMixin = snippetSymbol.mixins[SymbolGraph.Symbol.Snippet.mixinKey] as? SymbolGraph.Symbol.Snippet else {
220222
return []
221223
}
222224

223-
if let requestedSlice = arguments[Snippet.Semantics.Slice.argumentName]?.value,
225+
if let requestedSlice = snippet.slice,
224226
let requestedLineRange = snippetMixin.slices[requestedSlice] {
225227
// Render only the slice.
226228
let lineRange = requestedLineRange.lowerBound..<min(requestedLineRange.upperBound, snippetMixin.lines.count)
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import Markdown
13+
14+
/// A directive convertible semantic object that uses property wrappers
15+
/// to support automatic parsing with diagnostics.
16+
///
17+
/// Conform to this protocol to create a directive that can be automatically
18+
/// converted from a given block directive markup object.
19+
protocol AutomaticDirectiveConvertible: DirectiveConvertible, Semantic {
20+
init(originalMarkup: BlockDirective)
21+
22+
/// Returns false if the directive is invalid and should not be initialized.
23+
///
24+
/// Implement this method to perform additional validation after
25+
/// a directive has been parsed.
26+
///
27+
/// Return false if a serious enough error is encountered such that the directive
28+
/// should not be initialized.
29+
func validate(
30+
source: URL?,
31+
for bundle: DocumentationBundle,
32+
in context: DocumentationContext,
33+
problems: inout [Problem]
34+
) -> Bool
35+
36+
/// The key paths to any property wrapped directive arguments, child directives,
37+
/// or child markup properties.
38+
///
39+
/// This allows the automatic directive conversion to set these values based on the
40+
/// property names that Swift's mirror provides. This should no longer be necessary
41+
/// with future improvements to introspection in Swift.
42+
///
43+
/// > Important: Provide the key paths to the underscored property wrappers, not the
44+
/// > non-underscored projected values of the property wrappers.
45+
///
46+
/// class Intro: Semantic, AutomaticDirectiveConvertible {
47+
/// let originalMarkup: BlockDirective
48+
///
49+
/// @DirectiveArgumentWrapped
50+
/// private(set) var title: String
51+
///
52+
/// @ChildDirective
53+
/// private(set) var video: VideoMedia? = nil
54+
///
55+
/// @ChildDirective
56+
/// private(set) var image: ImageMedia? = nil
57+
///
58+
/// @ChildMarkup(numberOfParagraphs: .zeroOrMore)
59+
/// private(set) var content: MarkupContainer
60+
///
61+
/// static var keyPaths: [String : AnyKeyPath] = [
62+
/// "title" : \Intro._title,
63+
/// "video" : \Intro._video,
64+
/// "image" : \Intro._image,
65+
/// "content" : \Intro._content,
66+
/// ]
67+
///
68+
/// init(originalMarkup: BlockDirective) {
69+
/// self.originalMarkup = originalMarkup
70+
/// }
71+
/// }
72+
static var keyPaths: [String : AnyKeyPath] { get }
73+
}
74+
75+
extension AutomaticDirectiveConvertible {
76+
public static var directiveName: String {
77+
String(describing: self)
78+
}
79+
80+
func validate(
81+
source: URL?,
82+
for bundle: DocumentationBundle,
83+
in context: DocumentationContext,
84+
problems: inout [Problem]
85+
) -> Bool {
86+
return true
87+
}
88+
}
89+
90+
extension AutomaticDirectiveConvertible {
91+
/// Creates a directive from a given piece of block directive markup.
92+
///
93+
/// Performs some semantic analyses to determine whether a valid directive can be created
94+
/// and returns nils upon failure.
95+
///
96+
/// > Tip: ``DirectiveConvertible/init(from:source:for:in:problems:)`` performs
97+
/// the same function but supports collecting an array of problems for diagnostics.
98+
///
99+
/// - Parameters:
100+
/// - directive: The block directive that will be parsed
101+
/// - source: An optional URL for the source location where this directive is written.
102+
/// - bundle: The documentation bundle that owns the directive.
103+
/// - context: The documentation context in which the bundle resides.
104+
public init?(
105+
from directive: BlockDirective,
106+
source: URL? = nil,
107+
for bundle: DocumentationBundle,
108+
in context: DocumentationContext
109+
) {
110+
var problems = [Problem]()
111+
112+
self.init(
113+
from: directive,
114+
source: source,
115+
for: bundle,
116+
in: context,
117+
problems: &problems
118+
)
119+
}
120+
121+
public init?(
122+
from directive: BlockDirective,
123+
source: URL?,
124+
for bundle: DocumentationBundle,
125+
in context: DocumentationContext,
126+
problems: inout [Problem]
127+
) {
128+
precondition(directive.name == Self.directiveName)
129+
self.init(originalMarkup: directive)
130+
131+
let reflectedDirective = DirectiveIndex.shared.reflection(of: type(of: self))
132+
133+
let arguments = Semantic.Analyses.HasOnlyKnownArguments<Self>(
134+
severityIfFound: .warning,
135+
allowedArguments: reflectedDirective.arguments.map(\.name)
136+
)
137+
.analyze(
138+
directive,
139+
children: directive.children,
140+
source: source,
141+
for: bundle,
142+
in: context,
143+
problems: &problems
144+
)
145+
146+
// If we encounter an unrecoverable error while parsing directives,
147+
// set this value to true.
148+
var unableToCreateParentDirective = false
149+
150+
for reflectedArgument in reflectedDirective.arguments {
151+
let parsedValue = Semantic.Analyses.ArgumentValueParser<Self>(
152+
severityIfNotFound: reflectedArgument.required ? .warning : nil,
153+
argumentName: reflectedArgument.name,
154+
allowedValues: reflectedArgument.allowedValues,
155+
convert: { argumentValue in
156+
return reflectedArgument.parseArgument(bundle, argumentValue)
157+
},
158+
valueTypeDiagnosticName: reflectedArgument.typeDisplayName
159+
)
160+
.analyze(directive, arguments: arguments, problems: &problems)
161+
162+
if let parsedValue = parsedValue {
163+
reflectedArgument.setValue(on: self, to: parsedValue)
164+
} else if !reflectedArgument.storedAsOptional {
165+
unableToCreateParentDirective = true
166+
}
167+
}
168+
169+
Semantic.Analyses.HasOnlyKnownDirectives<Self>(
170+
severityIfFound: .warning,
171+
allowedDirectives: reflectedDirective.childDirectives.map(\.name)
172+
)
173+
.analyze(
174+
directive,
175+
children: directive.children,
176+
source: source,
177+
for: bundle,
178+
in: context,
179+
problems: &problems
180+
)
181+
182+
var remainder = MarkupContainer(directive.children)
183+
184+
// Comments are always allowed so extract them from the
185+
// directive's children.
186+
(_, remainder) = Semantic.Analyses.extractAll(
187+
childType: Comment.self,
188+
children: remainder,
189+
source: source,
190+
for: bundle,
191+
in: context,
192+
problems: &problems
193+
)
194+
195+
for childDirective in reflectedDirective.childDirectives {
196+
switch childDirective.requirements {
197+
case .one:
198+
let parsedDirective: DirectiveConvertible?
199+
(parsedDirective, remainder) = Semantic.Analyses.extractExactlyOne(
200+
childType: childDirective.type,
201+
parentDirective: directive,
202+
children: remainder,
203+
source: source,
204+
for: bundle,
205+
in: context,
206+
problems: &problems
207+
)
208+
209+
guard let parsedDirective = parsedDirective else {
210+
if !childDirective.storedAsArray && !childDirective.storedAsOptional {
211+
unableToCreateParentDirective = true
212+
}
213+
214+
continue
215+
}
216+
217+
if childDirective.storedAsArray {
218+
childDirective.setValue(on: self, to: [parsedDirective])
219+
} else {
220+
childDirective.setValue(on: self, to: parsedDirective)
221+
}
222+
case .zeroOrOne:
223+
let parsedDirective: DirectiveConvertible?
224+
(parsedDirective, remainder) = Semantic.Analyses.extractAtMostOne(
225+
childType: childDirective.type,
226+
parentDirective: directive,
227+
children: remainder,
228+
source: source,
229+
for: bundle,
230+
in: context,
231+
problems: &problems
232+
)
233+
234+
guard let parsedDirective = parsedDirective else {
235+
if childDirective.storedAsArray && !childDirective.storedAsOptional {
236+
childDirective.setValue(on: self, to: [])
237+
}
238+
239+
continue
240+
}
241+
242+
if childDirective.storedAsArray {
243+
childDirective.setValue(on: self, to: [parsedDirective])
244+
} else {
245+
childDirective.setValue(on: self, to: parsedDirective)
246+
}
247+
case .zeroOrMore:
248+
let parsedDirectives: [DirectiveConvertible]
249+
(parsedDirectives, remainder) = Semantic.Analyses.extractAll(
250+
childType: childDirective.type,
251+
children: remainder,
252+
source: source,
253+
for: bundle,
254+
in: context,
255+
problems: &problems
256+
)
257+
258+
if !parsedDirectives.isEmpty || !childDirective.storedAsOptional {
259+
childDirective.setValue(on: self, to: parsedDirectives)
260+
}
261+
case .oneOrMore:
262+
let parsedDirectives: [DirectiveConvertible]
263+
(parsedDirectives, remainder) = Semantic.Analyses.extractAtLeastOne(
264+
childType: childDirective.type,
265+
parentDirective: directive,
266+
children: remainder,
267+
source: source,
268+
for: bundle,
269+
in: context,
270+
problems: &problems
271+
)
272+
273+
if !parsedDirectives.isEmpty || !childDirective.storedAsOptional {
274+
childDirective.setValue(on: self, to: parsedDirectives)
275+
}
276+
}
277+
}
278+
279+
let supportsChildMarkup: Bool
280+
if case let .supportsMarkup(markupRequirements) = reflectedDirective.childMarkupSupport,
281+
let firstChildMarkup = markupRequirements.first
282+
{
283+
guard markupRequirements.count < 2 else {
284+
fatalError("""
285+
Automatic directive conversion is not supported for directives \
286+
with multiple '@ChildMarkup' properties.
287+
"""
288+
)
289+
}
290+
291+
let content: MarkupContainer
292+
if firstChildMarkup.required {
293+
content = Semantic.Analyses.HasContent<Self>().analyze(
294+
directive,
295+
children: remainder,
296+
source: source,
297+
for: bundle,
298+
in: context,
299+
problems: &problems
300+
)
301+
} else if !remainder.isEmpty {
302+
content = MarkupContainer(remainder)
303+
} else {
304+
content = MarkupContainer()
305+
}
306+
307+
firstChildMarkup.setValue(on: self, to: content)
308+
309+
supportsChildMarkup = true
310+
} else {
311+
supportsChildMarkup = false
312+
}
313+
314+
if !remainder.isEmpty && reflectedDirective.childDirectives.isEmpty && !supportsChildMarkup {
315+
let removeInnerContentReplacement: [Solution] = directive.children.range.map {
316+
[
317+
Solution(
318+
summary: "Remove inner content",
319+
replacements: [
320+
Replacement(range: $0, replacement: "")
321+
]
322+
)
323+
]
324+
} ?? []
325+
326+
let noInnerContentDiagnostic = Diagnostic(
327+
source: source,
328+
severity: .warning,
329+
range: directive.range,
330+
identifier: "org.swift.docc.\(Self.directiveName).NoInnerContentAllowed",
331+
summary: "The \(Self.directiveName.singleQuoted) directive does not support inner content",
332+
explanation: "Elements inside this directive will be ignored"
333+
)
334+
335+
problems.append(
336+
Problem(
337+
diagnostic: noInnerContentDiagnostic,
338+
possibleSolutions: removeInnerContentReplacement
339+
)
340+
)
341+
} else if !remainder.isEmpty && !supportsChildMarkup {
342+
let diagnostic = Diagnostic(
343+
source: source,
344+
severity: .warning,
345+
range: directive.range,
346+
identifier: "org.swift.docc.\(Self.directiveName).UnexpectedContent",
347+
summary: """
348+
\(Self.directiveName.singleQuoted) contains unexpected content
349+
""",
350+
explanation: """
351+
Arbitrary markup content is not allowed as a child of the \
352+
\(Self.directiveName.singleQuoted) directive.
353+
"""
354+
)
355+
356+
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: []))
357+
}
358+
359+
guard !unableToCreateParentDirective else {
360+
return nil
361+
}
362+
363+
guard validate(source: source, for: bundle, in: context, problems: &problems) else {
364+
return nil
365+
}
366+
}
367+
}

0 commit comments

Comments
 (0)