diff --git a/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift b/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift index 6dc01370ab..a1f75e4ab6 100644 --- a/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift +++ b/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift @@ -47,6 +47,27 @@ struct SymbolGraphLoader { /// The symbol graph decoding strategy to use. private(set) var decodingStrategy: DecodingConcurrencyStrategy = .concurrentlyEachFileInBatches + + /// The symbol default availability. + func symbolDefaultAvailability(_ moduleAvailability: [DefaultAvailability.ModuleAvailability]?) -> [DefaultAvailability.ModuleAvailability]? { + // Apply default availability options mutations. + guard let moduleAvailability else { return nil } + if let defaultAvailabilityOptions = bundle.info.defaultAvailabilityOptions { + // Remove the availability version if `inheritVersionNumber` is not part + // of the default availability options. + if !defaultAvailabilityOptions.shouldApplyOption(.inheritVersionNumber) { + return moduleAvailability.map { defaultAvailability in + var defaultAvailability = defaultAvailability + switch defaultAvailability.versionInformation { + case .available(_): defaultAvailability.versionInformation = .available(version: nil) + case .unavailable: () + } + return defaultAvailability + } + } + } + return moduleAvailability + } /// Loads all symbol graphs in the given bundle. /// @@ -153,7 +174,7 @@ struct SymbolGraphLoader { var defaultUnavailablePlatforms = [PlatformName]() var defaultAvailableInformation = [DefaultAvailability.ModuleAvailability]() - if let defaultAvailabilities = bundle.info.defaultAvailability?.modules[unifiedGraph.moduleName] { + if let defaultAvailabilities = symbolDefaultAvailability(bundle.info.defaultAvailability?.modules[unifiedGraph.moduleName]) { let (unavailablePlatforms, availablePlatforms) = defaultAvailabilities.categorize(where: { $0.versionInformation == .unavailable }) defaultUnavailablePlatforms = unavailablePlatforms.map(\.platformName) defaultAvailableInformation = availablePlatforms @@ -282,7 +303,7 @@ struct SymbolGraphLoader { private func addDefaultAvailability(to symbolGraph: inout SymbolGraph, moduleName: String) { let selector = UnifiedSymbolGraph.Selector(forSymbolGraph: symbolGraph) // Check if there are defined default availabilities for the current module - if let defaultAvailabilities = bundle.info.defaultAvailability?.modules[moduleName], + if let defaultAvailabilities = symbolDefaultAvailability(bundle.info.defaultAvailability?.modules[moduleName]), let platformName = symbolGraph.module.platform.name.map(PlatformName.init) { // Prepare a default availability versions lookup for this module. @@ -401,10 +422,12 @@ extension SymbolGraph.SemanticVersion { extension SymbolGraph.Symbol.Availability.AvailabilityItem { /// Create an availability item with a `domain` and an `introduced` version. /// - parameter defaultAvailability: Default availability information for symbols that lack availability authored in code. - /// - Note: If the `defaultAvailability` argument doesn't have a valid - /// platform version that can be parsed as a `SemanticVersion`, returns `nil`. + /// - Note: If the `defaultAvailability` argument has a introduced version that can't + /// be parsed as a `SemanticVersion`, returns `nil`. init?(_ defaultAvailability: DefaultAvailability.ModuleAvailability) { - guard let introducedVersion = defaultAvailability.introducedVersion, let platformVersion = SymbolGraph.SemanticVersion(string: introducedVersion) else { + let introducedVersion = defaultAvailability.introducedVersion + let platformVersion = introducedVersion.map { SymbolGraph.SemanticVersion(string: $0) } ?? nil + if platformVersion == nil && introducedVersion != nil { return nil } let domain = SymbolGraph.Symbol.Availability.Domain(rawValue: defaultAvailability.platformName.rawValue) diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/DefaultAvailability.swift b/Sources/SwiftDocC/Infrastructure/Workspace/DefaultAvailability.swift index af56bd238b..9e944b56d4 100644 --- a/Sources/SwiftDocC/Infrastructure/Workspace/DefaultAvailability.swift +++ b/Sources/SwiftDocC/Infrastructure/Workspace/DefaultAvailability.swift @@ -51,7 +51,7 @@ public struct DefaultAvailability: Codable, Equatable { /// Unavailable or Available with an introduced version. enum VersionInformation: Hashable { case unavailable - case available(version: String) + case available(version: String?) } /// The name of the platform, e.g. "macOS". @@ -71,7 +71,7 @@ public struct DefaultAvailability: Codable, Equatable { public var introducedVersion: String? { switch versionInformation { case .available(let introduced): - return introduced.description + return introduced?.description case .unavailable: return nil } diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/DefaultAvailailabilityOptions.swift b/Sources/SwiftDocC/Infrastructure/Workspace/DefaultAvailailabilityOptions.swift new file mode 100644 index 0000000000..781d829430 --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Workspace/DefaultAvailailabilityOptions.swift @@ -0,0 +1,75 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation + +/// A collection of options that customaise the default availability behaviour. +/// +/// Default availability options are applied to all the modules contained in the documentation bundle. +/// +/// This information can be authored in the bundle's Info.plist file, as a dictionary of option name and boolean pairs. +/// +/// ``` +/// CDDefaultAvailabilityOptions +/// +/// OptionName +/// +/// +/// ``` +public struct DefaultAvailabilityOptions: Codable, Equatable { + + /// A set of non-standard behaviors that apply to this node. + fileprivate(set) var options: Options + + /// Options that specify behaviors of the default availability logic. + struct Options: OptionSet { + + let rawValue: Int + + /// Enable or disable symbol availability version inference from the module default availability. + static let inheritVersionNumber = Options(rawValue: 1 << 0) + } + + /// String representation of the default availability options. + private enum CodingKeys: String, CodingKey { + case inheritVersionNumber = "InheritVersionNumber" + } + + public init() { + self.options = .inheritVersionNumber + } + + public init(from decoder: any Decoder) throws { + self.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + if try values.decodeIfPresent(Bool.self, forKey: .inheritVersionNumber) == false { + options.remove(.inheritVersionNumber) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if !options.contains(.inheritVersionNumber) { + try container.encode(false, forKey: .inheritVersionNumber) + } + } + + /// Convenient method to determine if an option has to be applied depending + /// on it's exsistence inside the options set. + func shouldApplyOption(_ option: Options) -> Bool { + switch option { + case .inheritVersionNumber: + return options.contains(.inheritVersionNumber) + default: + return false + } + } +} + diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift b/Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift index d68a209a07..7fd105f601 100644 --- a/Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift +++ b/Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift @@ -11,6 +11,16 @@ import Foundation extension DocumentationBundle { + + /// Options to define the inherit default availability behaviour. + public enum InheritDefaultAvailabilityOptions: String, Codable { + /// The platforms with the designated versions defined in the default availability will be used by the symbols as availability information. + /// This is the default behaviour. + case platformAndVersion + /// Only the platforms defined in the default availability will be passed to the symbols. + case platformOnly + } + /// Information about a documentation bundle that's unrelated to its documentation content. /// /// This information is meant to be decoded from the bundle's Info.plist file. @@ -39,6 +49,10 @@ extension DocumentationBundle { /// The keys that must be present in an Info.plist file in order for doc compilation to proceed. static let requiredKeys: Set = [.displayName, .identifier] + /// The default availability behaviour options. + public var defaultAvailabilityOptions: DefaultAvailabilityOptions? + + enum CodingKeys: String, CodingKey, CaseIterable { case displayName = "CFBundleDisplayName" case identifier = "CFBundleIdentifier" @@ -47,6 +61,7 @@ extension DocumentationBundle { case defaultAvailability = "CDAppleDefaultAvailability" case defaultModuleKind = "CDDefaultModuleKind" case featureFlags = "CDExperimentalFeatureFlags" + case defaultAvailabilityOptions = "CDDefaultAvailabilityOptions" var argumentName: String? { switch self { @@ -60,7 +75,7 @@ extension DocumentationBundle { return "--default-code-listing-language" case .defaultModuleKind: return "--fallback-default-module-kind" - case .defaultAvailability, .featureFlags: + case .defaultAvailability, .defaultAvailabilityOptions, .featureFlags: return nil } } @@ -91,13 +106,15 @@ extension DocumentationBundle { /// - defaultCodeListingLanguage: The default language identifier for code listings in the bundle. /// - defaultAvailability: The default availability for the various modules in the bundle. /// - defaultModuleKind: The default kind for the various modules in the bundle. + /// - defaultAvailabilityOptions: The options to enable or disable symbol availability logic from the module default availability. public init( displayName: String, identifier: String, version: String?, defaultCodeListingLanguage: String?, defaultAvailability: DefaultAvailability?, - defaultModuleKind: String? + defaultModuleKind: String?, + defaultAvailabilityOptions: DefaultAvailabilityOptions? ) { self.displayName = displayName self.identifier = identifier @@ -105,6 +122,7 @@ extension DocumentationBundle { self.defaultCodeListingLanguage = defaultCodeListingLanguage self.defaultAvailability = defaultAvailability self.defaultModuleKind = defaultModuleKind + self.defaultAvailabilityOptions = defaultAvailabilityOptions } /// Creates documentation bundle information from the given Info.plist data, falling back to the values @@ -234,6 +252,8 @@ extension DocumentationBundle { self.defaultCodeListingLanguage = try decodeOrFallbackIfPresent(String.self, with: .defaultCodeListingLanguage) self.defaultModuleKind = try decodeOrFallbackIfPresent(String.self, with: .defaultModuleKind) + self.defaultAvailabilityOptions = try decodeOrFallbackIfPresent(DefaultAvailabilityOptions.self, with: .defaultAvailabilityOptions) + self.defaultAvailability = try decodeOrFallbackIfPresent(DefaultAvailability.self, with: .defaultAvailability) self.defaultAvailability = try decodeOrFallbackIfPresent(DefaultAvailability.self, with: .defaultAvailability) self.featureFlags = try decodeOrFallbackIfPresent(BundleFeatureFlags.self, with: .featureFlags) } @@ -245,7 +265,9 @@ extension DocumentationBundle { defaultCodeListingLanguage: String? = nil, defaultModuleKind: String? = nil, defaultAvailability: DefaultAvailability? = nil, - featureFlags: BundleFeatureFlags? = nil + featureFlags: BundleFeatureFlags? = nil, + inheritDefaultAvailability: InheritDefaultAvailabilityOptions? = nil, + defaultAvailabilityOptions: DefaultAvailabilityOptions? = nil ) { self.displayName = displayName self.identifier = identifier @@ -254,6 +276,22 @@ extension DocumentationBundle { self.defaultModuleKind = defaultModuleKind self.defaultAvailability = defaultAvailability self.featureFlags = featureFlags + self.defaultAvailabilityOptions = defaultAvailabilityOptions + } + + public func encode(to encoder: any Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: DocumentationBundle.Info.CodingKeys.self) + + try container.encode(self.displayName, forKey: DocumentationBundle.Info.CodingKeys.displayName) + try container.encode(self.identifier, forKey: DocumentationBundle.Info.CodingKeys.identifier) + try container.encodeIfPresent(self.version, forKey: DocumentationBundle.Info.CodingKeys.version) + try container.encodeIfPresent(self.defaultCodeListingLanguage, forKey: DocumentationBundle.Info.CodingKeys.defaultCodeListingLanguage) + try container.encodeIfPresent(self.defaultAvailability, forKey: DocumentationBundle.Info.CodingKeys.defaultAvailability) + try container.encodeIfPresent(self.defaultModuleKind, forKey: DocumentationBundle.Info.CodingKeys.defaultModuleKind) + try container.encodeIfPresent(self.featureFlags, forKey: DocumentationBundle.Info.CodingKeys.featureFlags) + if defaultAvailabilityOptions != .init() { + try container.encodeIfPresent(self.defaultAvailabilityOptions, forKey: DocumentationBundle.Info.CodingKeys.defaultAvailabilityOptions) + } } } } @@ -272,6 +310,7 @@ extension BundleDiscoveryOptions { /// - fallbackDefaultModuleKind: A fallback default module kind for the bundle. /// - fallbackDefaultAvailability: A fallback default availability for the bundle. /// - additionalSymbolGraphFiles: Additional symbol graph files to augment any discovered bundles. + /// - defaultAvailabilityOptions: Options to configure default availability behaviour. public init( fallbackDisplayName: String? = nil, fallbackIdentifier: String? = nil, @@ -279,7 +318,8 @@ extension BundleDiscoveryOptions { fallbackDefaultCodeListingLanguage: String? = nil, fallbackDefaultModuleKind: String? = nil, fallbackDefaultAvailability: DefaultAvailability? = nil, - additionalSymbolGraphFiles: [URL] = [] + additionalSymbolGraphFiles: [URL] = [], + defaultAvailabilityOptions: DefaultAvailabilityOptions? = nil ) { // Iterate over all possible coding keys with a switch // to build up the dictionary of fallback options. @@ -304,6 +344,8 @@ extension BundleDiscoveryOptions { value = fallbackDefaultModuleKind case .featureFlags: value = nil + case .defaultAvailabilityOptions: + value = defaultAvailabilityOptions } guard let unwrappedValue = value else { diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index 9c19000123..3ddc28bb6c 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -1241,8 +1241,10 @@ public struct RenderNodeTranslator: SemanticVisitor { node.metadata.platformsVariants = VariantCollection<[AvailabilityRenderItem]?>(from: symbol.availabilityVariants) { _, availability in availability.availability .compactMap { availability -> AvailabilityRenderItem? in - // Filter items with insufficient availability data - guard availability.introducedVersion != nil else { + // Filter items with insufficient availability data unless the default availability behaviour + // allows availability withound version information. + let omitDefaultAvailabilityVersionFromSymbols = bundle.info.defaultAvailabilityOptions?.shouldApplyOption(.inheritVersionNumber) == false + guard availability.introducedVersion != nil || omitDefaultAvailabilityVersionFromSymbols else { return nil } guard let name = availability.domain.map({ PlatformName(operatingSystemName: $0.rawValue) }), diff --git a/Tests/SwiftDocCTests/Rendering/DefaultAvailabilityTests.swift b/Tests/SwiftDocCTests/Rendering/DefaultAvailabilityTests.swift index 6662c6a555..04248fa220 100644 --- a/Tests/SwiftDocCTests/Rendering/DefaultAvailabilityTests.swift +++ b/Tests/SwiftDocCTests/Rendering/DefaultAvailabilityTests.swift @@ -577,6 +577,234 @@ class DefaultAvailabilityTests: XCTestCase { module.filter({ $0.platformName.displayName == "iPadOS" }).first?.versionInformation, .available(version: "10.0") ) + } + + func testInheritDefaultAvailabilityOptions() throws { + func makeInfoPlist( + inheritDefaultAvailability: String, + defaultAvailability: String? = nil + ) -> String { + let defaultAvailability = defaultAvailability ?? """ + + name + iOS + version + 8.0 + + """ + return """ + + + \(inheritDefaultAvailability) + CDAppleDefaultAvailability + + MyModule + + \(defaultAvailability) + + + + + """ + } + func setupContext( + inheritDefaultAvailability: String = "", + defaultAvailability: String? = nil + ) throws -> (DocumentationBundle, DocumentationContext) { + // Create an empty bundle + let targetURL = try createTemporaryDirectory(named: "test.docc") + // Create symbol graph + let symbolGraphURL = targetURL.appendingPathComponent("MyModule.symbols.json") + try symbolGraphString.write(to: symbolGraphURL, atomically: true, encoding: .utf8) + // Create info plist + let infoPlistURL = targetURL.appendingPathComponent("Info.plist") + let infoPlist = makeInfoPlist(inheritDefaultAvailability: inheritDefaultAvailability, defaultAvailability: defaultAvailability) + try infoPlist.write(to: infoPlistURL, atomically: true, encoding: .utf8) + // Load the bundle & reference resolve symbol graph docs + let (_, bundle, context) = try loadBundle(from: targetURL) + return (bundle, context) + } + + let symbols = """ + { + "kind": { + "displayName" : "Instance Property", + "identifier" : "swift.property" + }, + "identifier": { + "precise": "c:@F@SymbolWithAvailability", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Foo" + ], + "names": { + "title": "Foo", + }, + "accessLevel": "public", + "availability" : [ + { + "domain" : "ios", + "introduced" : { + "major" : 10, + "minor" : 0 + } + } + ] + }, + { + "kind": { + "displayName" : "Instance Property", + "identifier" : "swift.property" + }, + "identifier": { + "precise": "c:@F@SymbolWithoutAvailability", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Foo" + ], + "names": { + "title": "Bar", + }, + "accessLevel": "public" + } + """ + let symbolGraphString = makeSymbolGraphString( + moduleName: "MyModule", + symbols: symbols, + platform: """ + "operatingSystem" : { + "minimumVersion" : { + "major" : 10, + "minor" : 0 + }, + "name" : "ios" + } + """ + ) + + // Don't use default availability version for symbols. + + var (bundle, context) = try setupContext( + inheritDefaultAvailability: """ + CDDefaultAvailabilityOptions + + InheritVersionNumber + + + """, + defaultAvailability: """ + + name + iOS + version + 8.0 + + """ + ) + // Verify we add the version number into the symbols that have availability annotation. + guard let availability = (context.documentationCache["c:@F@SymbolWithAvailability"]?.semantic as? Symbol)?.availability?.availability else { + XCTFail("Did not find availability for symbol 'c:@F@SymbolWithAvailability'") + return + } + XCTAssertNotNil(availability.first(where: { $0.domain?.rawValue == "iOS" })) + XCTAssertEqual(availability.first(where: { $0.domain?.rawValue == "iOS" })?.introducedVersion, SymbolGraph.SemanticVersion(major: 10, minor: 0, patch: 0)) + // Verify we don't add the version number into the symbols that don't have availability annotation. + guard let availability = (context.documentationCache["c:@F@SymbolWithoutAvailability"]?.semantic as? Symbol)?.availability?.availability else { + XCTFail("Did not find availability for symbol 'c:@F@SymbolWithoutAvailability'") + return + } + XCTAssertNotNil(availability.first(where: { $0.domain?.rawValue == "iOS" })) + XCTAssertEqual(availability.first(where: { $0.domain?.rawValue == "iOS" })?.introducedVersion, nil) + // Verify we keep the module availability information. + let identifier = ResolvedTopicReference(bundleIdentifier: "test", path: "/documentation/MyModule", fragment: nil, sourceLanguage: .swift) + let node = try context.entity(with: identifier) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: identifier) + let renderNode = translator.visit(node.semantic) as! RenderNode + XCTAssertEqual(renderNode.metadata.platforms?.count, 3) + XCTAssertEqual(renderNode.metadata.platforms?.first?.name, "iOS") + XCTAssertEqual(renderNode.metadata.platforms?.first?.introduced, "8.0") + + // Add an extra default availability to test behaviour when mixin in source with default behaviour. + (_, context) = try setupContext( + inheritDefaultAvailability: """ + CDDefaultAvailabilityOptions + + InheritVersionNumber + + + """, + defaultAvailability: """ + + name + iOS + version + 8.0 + + + name + watchOS + version + 5.0 + + """ + ) + + // Verify we add the version number into the symbols that have availability annotation. + guard let availability = (context.documentationCache["c:@F@SymbolWithAvailability"]?.semantic as? Symbol)?.availability?.availability else { + XCTFail("Did not find availability for symbol 'c:@F@SymbolWithAvailability'") + return + } + XCTAssertNotNil(availability.first(where: { $0.domain?.rawValue == "iOS" })) + XCTAssertNotNil(availability.first(where: { $0.domain?.rawValue == "watchOS" })) + XCTAssertEqual(availability.first(where: { $0.domain?.rawValue == "iOS" })?.introducedVersion, SymbolGraph.SemanticVersion(major: 10, minor: 0, patch: 0)) + XCTAssertEqual(availability.first(where: { $0.domain?.rawValue == "watchOS" })?.introducedVersion, nil) + + // Use default availability version for symbols. + + (_, context) = try setupContext(inheritDefaultAvailability: """ + CDDefaultAvailabilityOptions + + InheritVersionNumber + + + """) + + // Verify we add the version number into the symbols that have availability annotation. + guard let availability = (context.documentationCache["c:@F@SymbolWithAvailability"]?.semantic as? Symbol)?.availability?.availability else { + XCTFail("Did not find availability for symbol 'c:@F@SymbolWithAvailability'") + return + } + XCTAssertNotNil(availability.first(where: { $0.domain?.rawValue == "iOS" })) + XCTAssertEqual(availability.first(where: { $0.domain?.rawValue == "iOS" })?.introducedVersion, SymbolGraph.SemanticVersion(major: 10, minor: 0, patch: 0)) + // Verify we don't add the version number into the symbols that don't have availability annotation. + guard let availability = (context.documentationCache["c:@F@SymbolWithoutAvailability"]?.semantic as? Symbol)?.availability?.availability else { + XCTFail("Did not find availability for symbol 'c:@F@SymbolWithoutAvailability'") + return + } + XCTAssertNotNil(availability.first(where: { $0.domain?.rawValue == "iOS" })) + XCTAssertEqual(availability.first(where: { $0.domain?.rawValue == "iOS" })?.introducedVersion, SymbolGraph.SemanticVersion(major: 8, minor: 0, patch: 0)) + + // Don't specify availability inherit behaviour. + + (_, context) = try setupContext() + + // Verify we add the version number into the symbols that have availability annotation. + guard let availability = (context.documentationCache["c:@F@SymbolWithAvailability"]?.semantic as? Symbol)?.availability?.availability else { + XCTFail("Did not find availability for symbol 'c:@F@SymbolWithAvailability'") + return + } + XCTAssertNotNil(availability.first(where: { $0.domain?.rawValue == "iOS" })) + XCTAssertEqual(availability.first(where: { $0.domain?.rawValue == "iOS" })?.introducedVersion, SymbolGraph.SemanticVersion(major: 10, minor: 0, patch: 0)) + // Verify we don't add the version number into the symbols that don't have availability annotation. + guard let availability = (context.documentationCache["c:@F@SymbolWithoutAvailability"]?.semantic as? Symbol)?.availability?.availability else { + XCTFail("Did not find availability for symbol 'c:@F@SymbolWithoutAvailability'") + return + } + XCTAssertNotNil(availability.first(where: { $0.domain?.rawValue == "iOS" })) + XCTAssertEqual(availability.first(where: { $0.domain?.rawValue == "iOS" })?.introducedVersion, SymbolGraph.SemanticVersion(major: 8, minor: 0, patch: 0)) } + }