Skip to content

Commit a87f610

Browse files
add the ability to set a subset of feature flags from Info.plist (#891)
rdar://126305435
1 parent 49bbf7b commit a87f610

File tree

8 files changed

+306
-7
lines changed

8 files changed

+306
-7
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2012,7 +2012,36 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
20122012
*/
20132013
private func register(_ bundle: DocumentationBundle) throws {
20142014
try shouldContinueRegistration()
2015-
2015+
2016+
let currentFeatureFlags: FeatureFlags?
2017+
if let bundleFlags = bundle.info.featureFlags {
2018+
currentFeatureFlags = FeatureFlags.current
2019+
FeatureFlags.current.loadFlagsFromBundle(bundleFlags)
2020+
2021+
for unknownFeatureFlag in bundleFlags.unknownFeatureFlags {
2022+
let suggestions = NearMiss.bestMatches(
2023+
for: DocumentationBundle.Info.BundleFeatureFlags.CodingKeys.allCases.map({ $0.stringValue }),
2024+
against: unknownFeatureFlag)
2025+
var summary: String = "Unknown feature flag in Info.plist: \(unknownFeatureFlag.singleQuoted)"
2026+
if !suggestions.isEmpty {
2027+
summary += ". Possible suggestions: \(suggestions.map(\.singleQuoted).joined(separator: ", "))"
2028+
}
2029+
diagnosticEngine.emit(.init(diagnostic:
2030+
.init(
2031+
severity: .warning,
2032+
identifier: "org.swift.docc.UnknownBundleFeatureFlag",
2033+
summary: summary
2034+
)))
2035+
}
2036+
} else {
2037+
currentFeatureFlags = nil
2038+
}
2039+
defer {
2040+
if let currentFeatureFlags = currentFeatureFlags {
2041+
FeatureFlags.current = currentFeatureFlags
2042+
}
2043+
}
2044+
20162045
// Note: Each bundle is registered and processed separately.
20172046
// Documents and symbols may both reference each other so the bundle is registered in 4 steps
20182047

Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ extension DocumentationBundle {
3232

3333
/// The default kind for the various modules in the bundle.
3434
public var defaultModuleKind: String?
35-
35+
36+
/// The parsed feature flags that were set for this bundle.
37+
internal var featureFlags: BundleFeatureFlags?
38+
3639
/// The keys that must be present in an Info.plist file in order for doc compilation to proceed.
3740
static let requiredKeys: Set<CodingKeys> = [.displayName, .identifier]
3841

@@ -43,7 +46,8 @@ extension DocumentationBundle {
4346
case defaultCodeListingLanguage = "CDDefaultCodeListingLanguage"
4447
case defaultAvailability = "CDAppleDefaultAvailability"
4548
case defaultModuleKind = "CDDefaultModuleKind"
46-
49+
case featureFlags = "CDExperimentalFeatureFlags"
50+
4751
var argumentName: String? {
4852
switch self {
4953
case .displayName:
@@ -56,7 +60,7 @@ extension DocumentationBundle {
5660
return "--default-code-listing-language"
5761
case .defaultModuleKind:
5862
return "--fallback-default-module-kind"
59-
case .defaultAvailability:
63+
case .defaultAvailability, .featureFlags:
6064
return nil
6165
}
6266
}
@@ -231,6 +235,7 @@ extension DocumentationBundle {
231235
self.defaultCodeListingLanguage = try decodeOrFallbackIfPresent(String.self, with: .defaultCodeListingLanguage)
232236
self.defaultModuleKind = try decodeOrFallbackIfPresent(String.self, with: .defaultModuleKind)
233237
self.defaultAvailability = try decodeOrFallbackIfPresent(DefaultAvailability.self, with: .defaultAvailability)
238+
self.featureFlags = try decodeOrFallbackIfPresent(BundleFeatureFlags.self, with: .featureFlags)
234239
}
235240

236241
init(
@@ -239,14 +244,16 @@ extension DocumentationBundle {
239244
version: String? = nil,
240245
defaultCodeListingLanguage: String? = nil,
241246
defaultModuleKind: String? = nil,
242-
defaultAvailability: DefaultAvailability? = nil
247+
defaultAvailability: DefaultAvailability? = nil,
248+
featureFlags: BundleFeatureFlags? = nil
243249
) {
244250
self.displayName = displayName
245251
self.identifier = identifier
246252
self.version = version
247253
self.defaultCodeListingLanguage = defaultCodeListingLanguage
248254
self.defaultModuleKind = defaultModuleKind
249255
self.defaultAvailability = defaultAvailability
256+
self.featureFlags = featureFlags
250257
}
251258
}
252259
}
@@ -295,6 +302,8 @@ extension BundleDiscoveryOptions {
295302
value = fallbackDefaultAvailability
296303
case .defaultModuleKind:
297304
value = fallbackDefaultModuleKind
305+
case .featureFlags:
306+
value = nil
298307
}
299308

300309
guard let unwrappedValue = value else {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2024 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+
13+
extension DocumentationBundle.Info {
14+
/// A collection of feature flags that can be enabled from a bundle's Info.plist.
15+
///
16+
/// This is a subset of flags from ``FeatureFlags`` that can influence how a documentation
17+
/// bundle is written, and so can be considered a property of the documentation itself, rather
18+
/// than as an experimental behavior that can be enabled for one-off builds.
19+
///
20+
/// ```xml
21+
/// <key>CDExperimentalFeatureFlags</key>
22+
/// <dict>
23+
/// <key>ExperimentalOverloadedSymbolPresentation</key>
24+
/// <true/>
25+
/// </dict>
26+
/// ```
27+
internal struct BundleFeatureFlags: Codable, Equatable {
28+
// FIXME: Automatically expose all the feature flags from the global FeatureFlags struct
29+
30+
/// Whether or not experimental support for combining overloaded symbol pages is enabled.
31+
///
32+
/// This feature flag corresponds to ``FeatureFlags/isExperimentalOverloadedSymbolPresentationEnabled``.
33+
public var experimentalOverloadedSymbolPresentation: Bool?
34+
35+
public init(experimentalOverloadedSymbolPresentation: Bool? = nil) {
36+
self.experimentalOverloadedSymbolPresentation = experimentalOverloadedSymbolPresentation
37+
self.unknownFeatureFlags = []
38+
}
39+
40+
/// A list of decoded feature flag keys that didn't match a known feature flag.
41+
public let unknownFeatureFlags: [String]
42+
43+
enum CodingKeys: String, CodingKey, CaseIterable {
44+
case experimentalOverloadedSymbolPresentation = "ExperimentalOverloadedSymbolPresentation"
45+
}
46+
47+
struct AnyCodingKeys: CodingKey {
48+
var stringValue: String
49+
50+
init?(stringValue: String) {
51+
self.stringValue = stringValue
52+
}
53+
54+
var intValue: Int? { nil }
55+
init?(intValue: Int) {
56+
return nil
57+
}
58+
}
59+
60+
public init(from decoder: any Decoder) throws {
61+
let values = try decoder.container(keyedBy: AnyCodingKeys.self)
62+
var unknownFeatureFlags: [String] = []
63+
64+
for flagName in values.allKeys {
65+
if let codingKey = CodingKeys(stringValue: flagName.stringValue) {
66+
switch codingKey {
67+
case .experimentalOverloadedSymbolPresentation:
68+
self.experimentalOverloadedSymbolPresentation = try values.decode(Bool.self, forKey: flagName)
69+
}
70+
} else {
71+
unknownFeatureFlags.append(flagName.stringValue)
72+
}
73+
}
74+
75+
self.unknownFeatureFlags = unknownFeatureFlags
76+
}
77+
78+
public func encode(to encoder: any Encoder) throws {
79+
var container = encoder.container(keyedBy: CodingKeys.self)
80+
81+
try container.encode(experimentalOverloadedSymbolPresentation, forKey: .experimentalOverloadedSymbolPresentation)
82+
}
83+
}
84+
}

Sources/SwiftDocC/SwiftDocC.docc/SwiftDocC.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,6 @@ Converting in-memory documentation into rendering nodes and persisting them on d
5454
### Development
5555

5656
- <doc:Features>
57+
- <doc:AddingFeatureFlags>
5758

58-
<!-- Copyright (c) 2021-2023 Apple Inc and the Swift Project authors. All Rights Reserved. -->
59+
<!-- Copyright (c) 2021-2024 Apple Inc and the Swift Project authors. All Rights Reserved. -->
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Adding Feature Flags
2+
3+
Develop experimental features by adding feature flags.
4+
5+
## Overview
6+
7+
Make new features in Swift-DocC optional during active development by creating a command-line flag that
8+
enables the new behavior. Then set the new flag in the ``FeatureFlags``' ``FeatureFlags/current`` instance,
9+
making it available for the rest of the compilation process.
10+
11+
### The FeatureFlags structure
12+
13+
Feature flags are defined in the ``FeatureFlags`` structure. This type has a static
14+
``FeatureFlags/current`` property that contains a global instance of the flags that can be accessed
15+
throughout the compiler. When adding a flag property to this struct, give it a reasonable default
16+
value so that the default initializer can be used.
17+
18+
### Feature flags on the command line
19+
20+
Command-line feature flags live in the `Docc.Convert.FeatureFlagOptions` in `SwiftDocCUtilities`.
21+
This type implements the `ParsableArguments` protocol from Swift Argument Parser to create an option
22+
group for the `convert` and `preview` commands.
23+
24+
These options are then handled in `ConvertAction.init(fromConvertCommand:)`, still in
25+
`SwiftDocCUtilities`, where they are written into the global feature flags ``FeatureFlags/current``
26+
instance, which can then be used during the compilation process.
27+
28+
### Feature flags in Info.plist
29+
30+
A subset of feature flags can affect how a documentation bundle is authored. For example, the
31+
experimental overloaded symbol presentation can affect how a bundle curates its symbols due to the
32+
creation of overload group pages. These flags should also be added to the
33+
``DocumentationBundle/Info/BundleFeatureFlags`` type, so that they can be parsed out of a bundle's
34+
Info.plist.
35+
36+
Feature flags that are loaded from an Info.plist file are saved into the global feature flags while
37+
the bundle is being registered. To ensure that your new feature flag is properly loaded, update the
38+
``FeatureFlags/loadFlagsFromBundle(_:)`` method to load your new field into the global flags.
39+
40+
<!-- Copyright (c) 2024 Apple Inc and the Swift Project authors. All Rights Reserved. -->

Sources/SwiftDocC/Utility/FeatureFlags.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,11 @@ public struct FeatureFlags: Codable {
4646
additionalFlags: [String : Bool] = [:]
4747
) {
4848
}
49+
50+
/// Set feature flags that were loaded from a bundle's Info.plist.
51+
internal mutating func loadFlagsFromBundle(_ bundleFlags: DocumentationBundle.Info.BundleFeatureFlags) {
52+
if let overloadsPresentation = bundleFlags.experimentalOverloadedSymbolPresentation {
53+
self.isExperimentalOverloadedSymbolPresentationEnabled = overloadsPresentation
54+
}
55+
}
4956
}

Tests/SwiftDocCTests/Infrastructure/DocumentationBundleInfoTests.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,4 +414,32 @@ class DocumentationBundleInfoTests: XCTestCase {
414414
)
415415
)
416416
}
417+
418+
func testFeatureFlags() throws {
419+
let infoPlistWithFeatureFlags = """
420+
<plist version="1.0">
421+
<dict>
422+
<key>CFBundleDisplayName</key>
423+
<string>Info Plist Display Name</string>
424+
<key>CFBundleIdentifier</key>
425+
<string>com.info.Plist</string>
426+
<key>CFBundleVersion</key>
427+
<string>1.0.0</string>
428+
<key>CDExperimentalFeatureFlags</key>
429+
<dict>
430+
<key>ExperimentalOverloadedSymbolPresentation</key>
431+
<true/>
432+
</dict>
433+
</dict>
434+
</plist>
435+
"""
436+
437+
let infoPlistWithFeatureFlagsData = Data(infoPlistWithFeatureFlags.utf8)
438+
let info = try DocumentationBundle.Info(
439+
from: infoPlistWithFeatureFlagsData,
440+
bundleDiscoveryOptions: nil)
441+
442+
let featureFlags = try XCTUnwrap(info.featureFlags)
443+
XCTAssertTrue(try XCTUnwrap(featureFlags.experimentalOverloadedSymbolPresentation))
444+
}
417445
}

Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4613,7 +4613,54 @@ let expected = """
46134613
}
46144614
}
46154615
}
4616-
4616+
4617+
func testContextRecognizesOverloadsFromPlistFlag() throws {
4618+
let overloadableKindIDs = SymbolGraph.Symbol.KindIdentifier.allCases.filter { $0.isOverloadableKind }
4619+
// Generate a 4 symbols with the same name for every overloadable symbol kind
4620+
let symbols: [SymbolGraph.Symbol] = overloadableKindIDs.flatMap { [
4621+
makeSymbol(identifier: "first-\($0.identifier)-id", kind: $0),
4622+
makeSymbol(identifier: "second-\($0.identifier)-id", kind: $0),
4623+
makeSymbol(identifier: "third-\($0.identifier)-id", kind: $0),
4624+
makeSymbol(identifier: "fourth-\($0.identifier)-id", kind: $0),
4625+
] }
4626+
4627+
let tempURL = try createTempFolder(content: [
4628+
Folder(name: "unit-test.docc", content: [
4629+
JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(
4630+
moduleName: "ModuleName",
4631+
symbols: symbols
4632+
)),
4633+
DataFile(name: "Info.plist", data: Data("""
4634+
<plist version="1.0">
4635+
<dict>
4636+
<key>CDExperimentalFeatureFlags</key>
4637+
<dict>
4638+
<key>ExperimentalOverloadedSymbolPresentation</key>
4639+
<true/>
4640+
</dict>
4641+
</dict>
4642+
</plist>
4643+
""".utf8))
4644+
])
4645+
])
4646+
let (_, bundle, context) = try loadBundle(from: tempURL)
4647+
let moduleReference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/ModuleName", sourceLanguage: .swift)
4648+
4649+
for kindID in overloadableKindIDs {
4650+
switch context.resolve(.unresolved(.init(topicURL: .init(symbolPath: "SymbolName-\(kindID.identifier)"))), in: moduleReference, fromSymbolLink: true) {
4651+
case let .failure(_, errorMessage):
4652+
XCTFail("Could not resolve overload group page for \(kindID.identifier). Error message: \(errorMessage)")
4653+
continue
4654+
case let .success(overloadGroupReference):
4655+
let overloadGroupNode = try context.entity(with: overloadGroupReference)
4656+
let overloadGroupSymbol = try XCTUnwrap(overloadGroupNode.semantic as? Symbol)
4657+
let overloadGroupReferences = try XCTUnwrap(overloadGroupSymbol.overloadsVariants.firstValue)
4658+
4659+
XCTAssertEqual(overloadGroupReferences.displayIndex, 0)
4660+
}
4661+
}
4662+
}
4663+
46174664
// The overload behavior doesn't apply to symbol kinds that don't support overloading
46184665
func testContextDoesNotRecognizeNonOverloadableSymbolKinds() throws {
46194666
enableFeatureFlag(\.isExperimentalOverloadedSymbolPresentationEnabled)
@@ -4651,6 +4698,60 @@ let expected = """
46514698
}
46524699
}
46534700

4701+
func testWarnsOnUnknownPlistFeatureFlag() throws {
4702+
let tempURL = try createTempFolder(content: [
4703+
Folder(name: "unit-test.docc", content: [
4704+
DataFile(name: "Info.plist", data: Data("""
4705+
<plist version="1.0">
4706+
<dict>
4707+
<key>CDExperimentalFeatureFlags</key>
4708+
<dict>
4709+
<key>NonExistentFeature</key>
4710+
<true/>
4711+
</dict>
4712+
</dict>
4713+
</plist>
4714+
""".utf8))
4715+
])
4716+
])
4717+
let (_, _, context) = try loadBundle(from: tempURL)
4718+
4719+
let unknownFeatureFlagProblems = context.problems.filter({ $0.diagnostic.identifier == "org.swift.docc.UnknownBundleFeatureFlag" })
4720+
XCTAssertEqual(unknownFeatureFlagProblems.count, 1)
4721+
let problem = try XCTUnwrap(unknownFeatureFlagProblems.first)
4722+
4723+
XCTAssertEqual(problem.diagnostic.severity, .warning)
4724+
XCTAssertEqual(problem.diagnostic.summary, "Unknown feature flag in Info.plist: 'NonExistentFeature'")
4725+
}
4726+
4727+
func testUnknownFeatureFlagSuggestsOtherFlags() throws {
4728+
let tempURL = try createTempFolder(content: [
4729+
Folder(name: "unit-test.docc", content: [
4730+
DataFile(name: "Info.plist", data: Data("""
4731+
<plist version="1.0">
4732+
<dict>
4733+
<key>CDExperimentalFeatureFlags</key>
4734+
<dict>
4735+
<key>ExperimenalOverloadedSymbolPresentation</key>
4736+
<true/>
4737+
</dict>
4738+
</dict>
4739+
</plist>
4740+
""".utf8))
4741+
])
4742+
])
4743+
let (_, _, context) = try loadBundle(from: tempURL)
4744+
4745+
let unknownFeatureFlagProblems = context.problems.filter({ $0.diagnostic.identifier == "org.swift.docc.UnknownBundleFeatureFlag" })
4746+
XCTAssertEqual(unknownFeatureFlagProblems.count, 1)
4747+
let problem = try XCTUnwrap(unknownFeatureFlagProblems.first)
4748+
4749+
XCTAssertEqual(problem.diagnostic.severity, .warning)
4750+
XCTAssertEqual(
4751+
problem.diagnostic.summary,
4752+
"Unknown feature flag in Info.plist: 'ExperimenalOverloadedSymbolPresentation'. Possible suggestions: 'ExperimentalOverloadedSymbolPresentation'")
4753+
}
4754+
46544755
// A test helper that creates a symbol with a given identifier and kind.
46554756
private func makeSymbol(
46564757
name: String = "SymbolName",

0 commit comments

Comments
 (0)