Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.8] add @Available directive for setting platform availability #453

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,19 @@ public struct RenderNodeTranslator: SemanticVisitor {
))
}
}

if let availability = article.metadata?.availability, !availability.isEmpty {
let renderAvailability = availability.compactMap({
let currentPlatform = PlatformName(metadataPlatform: $0.platform).flatMap { name in
context.externalMetadata.currentPlatforms?[name.displayName]
}
return .init($0, current: currentPlatform)
}).sorted(by: AvailabilityRenderOrder.compare)

if !renderAvailability.isEmpty {
node.metadata.platformsVariants = .init(defaultValue: renderAvailability)
}
}

collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
node.references = createTopicRenderReferences()
Expand Down Expand Up @@ -1193,6 +1206,19 @@ public struct RenderNodeTranslator: SemanticVisitor {
.filter({ !($0.unconditionallyUnavailable == true) })
.sorted(by: AvailabilityRenderOrder.compare)
)

if let availability = documentationNode.metadata?.availability, !availability.isEmpty {
let renderAvailability = availability.compactMap({
let currentPlatform = PlatformName(metadataPlatform: $0.platform).flatMap { name in
context.externalMetadata.currentPlatforms?[name.displayName]
}
return .init($0, current: currentPlatform)
}).sorted(by: AvailabilityRenderOrder.compare)

if !renderAvailability.isEmpty {
node.metadata.platformsVariants.defaultValue = renderAvailability
}
}

node.metadata.requiredVariants = VariantCollection<Bool>(from: symbol.isRequiredVariants) ?? .init(defaultValue: false)
node.metadata.role = contentRenderer.role(for: documentationNode.kind).rawValue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ public struct AvailabilityRenderItem: Codable, Hashable, Equatable {
isBeta = false
}
}

init?(_ availability: Metadata.Availability, current: PlatformVersion?) {
if availability.introduced == nil {
// FIXME: Deprecated/Beta markings need platform versions to display properly in Swift-DocC-Render (rdar://56897597)
// Fill in the appropriate values here when that's fixed (https://github.com/apple/swift-docc/issues/441)
return nil
}

let platformName = PlatformName(metadataPlatform: availability.platform)
name = platformName?.displayName
introduced = availability.introduced
}

/// Creates a new item with the given platform name and version string.
/// - Parameters:
Expand Down
80 changes: 80 additions & 0 deletions Sources/SwiftDocC/Semantics/Metadata/Availability.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2022 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
import Markdown

extension Metadata {
/// A directive that sets the platform availability information for a documentation page.
///
/// `@Available` is analagous to the `@available` attribute in Swift: It allows you to specify a
/// platform version that the page relates to. To specify a platform and version, list the platform
/// name and use the `introduced` argument:
///
/// ```markdown
/// @Available(macOS, introduced: "12.0")
/// ```
///
/// The available platforms are `macOS`, `iOS`, `watchOS`, and `tvOS`.
///
/// This directive is available on both articles and documentation extension files. In extension
/// files, the information overrides any information from the symbol itself.
///
/// This directive is only valid within a ``Metadata`` directive:
///
/// ```markdown
/// @Metadata {
/// @Available(macOS, introduced: "12.0")
/// @Available(iOS, introduced: "15.0")
/// }
/// ```
public final class Availability: Semantic, AutomaticDirectiveConvertible {
static public let directiveName: String = "Available"

public enum Platform: String, RawRepresentable, CaseIterable, DirectiveArgumentValueConvertible {
// FIXME: re-add `case any = "*"` when `isBeta` and `isDeprecated` are implemented
// cf. https://github.com/apple/swift-docc/issues/441
case macOS, iOS, watchOS, tvOS

public init?(rawValue: String) {
for platform in Self.allCases {
if platform.rawValue.lowercased() == rawValue.lowercased() {
self = platform
return
}
}
return nil
}
}

/// The platform that this argument's information applies to.
@DirectiveArgumentWrapped(name: .unnamed)
public var platform: Platform

/// The platform version that this page applies to.
@DirectiveArgumentWrapped
public var introduced: String

// FIXME: `isBeta` and `isDeprecated` properties/arguments
// cf. https://github.com/apple/swift-docc/issues/441

static var keyPaths: [String : AnyKeyPath] = [
"platform" : \Availability._platform,
"introduced" : \Availability._introduced,
]

public let originalMarkup: Markdown.BlockDirective

@available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.")
init(originalMarkup: Markdown.BlockDirective) {
self.originalMarkup = originalMarkup
}
}
}
45 changes: 44 additions & 1 deletion Sources/SwiftDocC/Semantics/Metadata/Metadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Markdown
/// - ``DisplayName``
/// - ``PageImage``
/// - ``CallToAction``
/// - ``Availability``
public final class Metadata: Semantic, AutomaticDirectiveConvertible {
public let originalMarkup: BlockDirective

Expand All @@ -48,6 +49,9 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {

@ChildDirective
var callToAction: CallToAction? = nil

@ChildDirective(requirements: .zeroOrMore)
var availability: [Availability]

static var keyPaths: [String : AnyKeyPath] = [
"documentationOptions" : \Metadata._documentationOptions,
Expand All @@ -56,6 +60,7 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {
"pageImages" : \Metadata._pageImages,
"customMetadata" : \Metadata._customMetadata,
"callToAction" : \Metadata._callToAction,
"availability" : \Metadata._availability,
]

/// Creates a metadata object with a given markup, documentation extension, and technology root.
Expand All @@ -78,7 +83,7 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {

func validate(source: URL?, for bundle: DocumentationBundle, in context: DocumentationContext, problems: inout [Problem]) -> Bool {
// Check that something is configured in the metadata block
if documentationOptions == nil && technologyRoot == nil && displayName == nil && pageImages.isEmpty && customMetadata.isEmpty {
if documentationOptions == nil && technologyRoot == nil && displayName == nil && pageImages.isEmpty && customMetadata.isEmpty && callToAction == nil && availability.isEmpty {
let diagnostic = Diagnostic(
source: source,
severity: .information,
Expand Down Expand Up @@ -132,6 +137,44 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [solution]))
}
}

let categorizedAvailability = Dictionary(grouping: availability, by: \.platform)

for availabilityAttrs in categorizedAvailability.values {
guard availabilityAttrs.count > 1 else {
continue
}

let duplicateIntroduced = availabilityAttrs.filter({ $0.introduced != nil })
if duplicateIntroduced.count > 1 {
for avail in duplicateIntroduced {
let diagnostic = Diagnostic(
source: avail.originalMarkup.nameLocation?.source,
severity: .warning,
range: avail.originalMarkup.range,
identifier: "org.swift.docc.\(Metadata.Availability.self).DuplicateIntroduced",
summary: "Duplicate \(Metadata.Availability.directiveName.singleQuoted) directive with 'introduced' argument",
explanation: """
A documentation page can only contain a single 'introduced' version for each platform.
"""
)

guard let range = avail.originalMarkup.range else {
problems.append(Problem(diagnostic: diagnostic))
continue
}

let solution = Solution(
summary: "Remove extraneous \(Metadata.Availability.directiveName.singleQuoted) directive",
replacements: [
Replacement(range: range, replacement: "")
]
)

problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [solution]))
}
}
}

return true
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/SwiftDocC/Semantics/Symbol/PlatformName.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,14 @@ public struct PlatformName: Codable, Hashable, Equatable {
}
self = knowDomain
}

/// Creates a new platform name from the given metadata availability attribute platform.
///
/// Returns `nil` if the given platform was ``Metadata/Availability/Platform-swift.enum/any``.
init?(metadataPlatform platform: Metadata.Availability.Platform) {
// Note: This is still an optional initializer to prevent source breakage when
// `Availability.Platform` re-introduces the `.any` case
// cf. https://github.com/apple/swift-docc/issues/441
self = .init(operatingSystemName: platform.rawValue)
}
}
75 changes: 75 additions & 0 deletions Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,79 @@ class PlatformAvailabilityTests: XCTestCase {
// The "iOS" platform in the fixture is unconditionally unavailable
XCTAssertEqual(true, platforms.first { $0.name == "iOS" }?.unconditionallyUnavailable)
}

/// Ensure that adding `@Available` directives in an article causes the final RenderNode to contain the appropriate availability data.
func testPlatformAvailabilityFromArticle() throws {
let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle")
let reference = ResolvedTopicReference(
bundleIdentifier: bundle.identifier,
path: "/documentation/AvailableArticle",
sourceLanguage: .swift
)
let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article)
var translator = RenderNodeTranslator(
context: context,
bundle: bundle,
identifier: reference,
source: nil
)
let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode)
let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue)
XCTAssertEqual(availability.count, 1)
let iosAvailability = try XCTUnwrap(availability.first)
XCTAssertEqual(iosAvailability.name, "iOS")
XCTAssertEqual(iosAvailability.introduced, "16.0")
}

/// Ensure that adding `@Available` directives in an extension file overrides the symbol's availability.
func testPlatformAvailabilityFromExtension() throws {
let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle")
let reference = ResolvedTopicReference(
bundleIdentifier: bundle.identifier,
path: "/documentation/MyKit/MyClass",
sourceLanguage: .swift
)
let symbol = try XCTUnwrap(context.entity(with: reference).semantic as? Symbol)
var translator = RenderNodeTranslator(
context: context,
bundle: bundle,
identifier: reference,
source: nil
)
let renderNode = try XCTUnwrap(translator.visitSymbol(symbol) as? RenderNode)
let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue)
XCTAssertEqual(availability.count, 1)
let iosAvailability = try XCTUnwrap(availability.first)
XCTAssertEqual(iosAvailability.name, "iOS")
XCTAssertEqual(iosAvailability.introduced, "16.0")
}

func testMultiplePlatformAvailabilityFromArticle() throws {
let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle")
let reference = ResolvedTopicReference(
bundleIdentifier: bundle.identifier,
path: "/documentation/AvailabilityBundle/ComplexAvailable",
sourceLanguage: .swift
)
let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article)
var translator = RenderNodeTranslator(
context: context,
bundle: bundle,
identifier: reference,
source: nil
)
let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode)
let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue)
XCTAssertEqual(availability.count, 3)

XCTAssert(availability.contains(where: { item in
item.name == "iOS" && item.introduced == "15.0"
}))
XCTAssert(availability.contains(where: { item in
item.name == "macOS" && item.introduced == "12.0"
}))
XCTAssert(availability.contains(where: { item in
item.name == "watchOS" && item.introduced == "7.0"
}))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class DirectiveIndexTests: XCTestCase {
"AutomaticArticleSubheading",
"AutomaticSeeAlso",
"AutomaticTitleHeading",
"Available",
"CallToAction",
"Chapter",
"Choice",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class DirectiveMirrorTests: XCTestCase {
XCTAssertFalse(reflectedDirective.allowsMarkup)
XCTAssert(reflectedDirective.arguments.isEmpty)

XCTAssertEqual(reflectedDirective.childDirectives.count, 6)
XCTAssertEqual(reflectedDirective.childDirectives.count, 7)

XCTAssertEqual(
reflectedDirective.childDirectives["DocumentationExtension"]?.propertyLabel,
Expand Down
Loading