From 245852543d527a55894db3432029e0880689e087 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Thu, 4 Jul 2024 14:26:58 +0100 Subject: [PATCH] Compute beta platform information for non-symbol documentation (#959) Fixes a bug where beta platform information was never being derived for non-symbol documentation, such as sample code articles. Sample code articles defined with `@Available` directives were never displaying a beta badge regardless of whether the platform was configured to be a beta platform. * Use `VersionTriplet` for `@Available` directive * Compute platform beta status when using `@Available` * Adds documentation for new `@Available` behavior * Add unit tests for new `@Available` behaviour * Add unit tests to `PlatformAvailabilityTests` for beta availability * Add diagnostic explanation for non-convertible directive arguments * Revert change to make `isBeta` a `let` property * Use if-expression to declare variables * Make assertions in `PlatformAvailabilityTests` clearer * Make `AvailabilityRenderItem.isBeta(introduced:current:)` private * Move to using `SemanticVersion` rather than `VersionTriplet` * Merge "deprecated" parameter changes with SemanticVersion changes * Update `@Available` documentation Resolves rdar://129355087. --- .../DocumentationContentRenderer.swift | 2 +- .../AvailabilityRenderMetadataItem.swift | 41 +++-- .../AutomaticDirectiveConvertible.swift | 1 + .../DirectiveArgumentValueConvertible.swift | 7 + .../DirectiveArgumentWrapper.swift | 15 ++ .../DirectiveMirror.swift | 2 + .../HasArgumentOfType.swift | 47 ++++-- .../Semantics/Metadata/Availability.swift | 57 ++++++- .../Model/SemaToRenderNodeTests.swift | 2 +- .../Rendering/PlatformAvailabilityTests.swift | 114 +++++++++++++ ...nticVersionStringRepresentationTests.swift | 31 +++- .../Semantics/MetadataAvailabilityTests.swift | 151 ++++++++++++------ 12 files changed, 385 insertions(+), 85 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift b/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift index 99e08eb9d1..f6cf7a5613 100644 --- a/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift +++ b/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift @@ -230,7 +230,7 @@ public class DocumentationContentRenderer { } // Verify that the current platform is in beta and the version number matches the introduced platform version. - guard current.beta && introduced.isEqualToVersionTriplet(current.version) else { + guard current.beta && SemanticVersion(introduced).isEqualToVersionTriplet(current.version) else { return false } } diff --git a/Sources/SwiftDocC/Model/Rendering/Symbol/AvailabilityRenderMetadataItem.swift b/Sources/SwiftDocC/Model/Rendering/Symbol/AvailabilityRenderMetadataItem.swift index c94de34998..5883fde724 100644 --- a/Sources/SwiftDocC/Model/Rendering/Symbol/AvailabilityRenderMetadataItem.swift +++ b/Sources/SwiftDocC/Model/Rendering/Symbol/AvailabilityRenderMetadataItem.swift @@ -11,7 +11,7 @@ import Foundation import SymbolKit -extension SymbolGraph.SemanticVersion { +extension SemanticVersion { enum Precision: Int { case all = 0, patch, minor @@ -45,6 +45,14 @@ extension SymbolGraph.SemanticVersion { .joined(separator: ".") } + init(_ semanticVersion: SymbolGraph.SemanticVersion) { + self.major = semanticVersion.major + self.minor = semanticVersion.minor + self.patch = semanticVersion.patch + self.prerelease = semanticVersion.prerelease + self.buildMetadata = semanticVersion.buildMetadata + } + /// Compares a version triplet to a semantic version. /// - Parameter version: A version triplet to compare to this semantic version. /// - Returns: Returns whether the given triple represents the same version as the current version. @@ -125,29 +133,32 @@ public struct AvailabilityRenderItem: Codable, Hashable, Equatable { init(_ availability: SymbolGraph.Symbol.Availability.AvailabilityItem, current: PlatformVersion?) { let platformName = availability.domain.map({ PlatformName(operatingSystemName: $0.rawValue) }) name = platformName?.displayName - introduced = availability.introducedVersion?.stringRepresentation(precisionUpToNonsignificant: .minor) - deprecated = availability.deprecatedVersion?.stringRepresentation(precisionUpToNonsignificant: .minor) - obsoleted = availability.obsoletedVersion?.stringRepresentation(precisionUpToNonsignificant: .minor) + + let introducedVersion = availability.introducedVersion.flatMap { SemanticVersion($0) } + introduced = introducedVersion?.stringRepresentation(precisionUpToNonsignificant: .minor) + deprecated = availability.deprecatedVersion.flatMap { SemanticVersion($0).stringRepresentation(precisionUpToNonsignificant: .minor) } + obsoleted = availability.obsoletedVersion.flatMap { SemanticVersion($0).stringRepresentation(precisionUpToNonsignificant: .minor) } message = availability.message renamed = availability.renamed unconditionallyUnavailable = availability.isUnconditionallyUnavailable unconditionallyDeprecated = availability.isUnconditionallyDeprecated - - if let introducedVersion = availability.introducedVersion, let current, current.beta, introducedVersion.isEqualToVersionTriplet(current.version) { - isBeta = true - } else { - isBeta = false - } + isBeta = AvailabilityRenderItem.isBeta(introduced: introducedVersion, current: current) } init?(_ availability: Metadata.Availability, current: PlatformVersion?) { - // 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) - let platformName = PlatformName(metadataPlatform: availability.platform) name = platformName?.displayName - introduced = availability.introduced - deprecated = availability.deprecated + introduced = availability.introduced.stringRepresentation(precisionUpToNonsignificant: .minor) + deprecated = availability.deprecated.flatMap { $0.stringRepresentation(precisionUpToNonsignificant: .minor) } + isBeta = AvailabilityRenderItem.isBeta(introduced: availability.introduced, current: current) + } + + private static func isBeta(introduced: SemanticVersion?, current: PlatformVersion?) -> Bool { + guard let introduced, let current, current.beta, introduced.isEqualToVersionTriplet(current.version) else { + return false + } + + return true } /// Creates a new item with the given platform name and version string. diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/AutomaticDirectiveConvertible.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/AutomaticDirectiveConvertible.swift index 060eff438c..13d9612455 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/AutomaticDirectiveConvertible.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/AutomaticDirectiveConvertible.swift @@ -157,6 +157,7 @@ extension AutomaticDirectiveConvertible { severityIfNotFound: reflectedArgument.required ? .warning : nil, argumentName: reflectedArgument.name, allowedValues: reflectedArgument.allowedValues, + expectedFormat: reflectedArgument.expectedFormat, convert: { argumentValue in return reflectedArgument.parseArgument(bundle, argumentValue) }, diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveArgumentValueConvertible.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveArgumentValueConvertible.swift index 1eb8fb1e75..5e1ca39ae8 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveArgumentValueConvertible.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveArgumentValueConvertible.swift @@ -16,6 +16,13 @@ protocol DirectiveArgumentValueConvertible { init?(rawDirectiveArgumentValue: String) static func allowedValues() -> [String]? + static func expectedFormat() -> String? +} + +extension DirectiveArgumentValueConvertible { + static func expectedFormat() -> String? { + return nil + } } extension RawRepresentable where Self: DirectiveArgumentValueConvertible, RawValue == String { diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveArgumentWrapper.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveArgumentWrapper.swift index 2dcd3af45c..83639012ac 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveArgumentWrapper.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveArgumentWrapper.swift @@ -16,6 +16,7 @@ protocol _DirectiveArgumentProtocol { var required: Bool { get } var name: _DirectiveArgumentName { get } var allowedValues: [String]? { get } + var expectedFormat: String? { get } var hiddenFromDocumentation: Bool { get } var parseArgument: (_ bundle: DocumentationBundle, _ argumentValue: String) -> (Any?) { get } @@ -64,6 +65,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { let name: _DirectiveArgumentName let typeDisplayName: String let allowedValues: [String]? + let expectedFormat: String? let hiddenFromDocumentation: Bool let parseArgument: (_ bundle: DocumentationBundle, _ argumentValue: String) -> (Any?) @@ -99,6 +101,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: _DirectiveArgumentName = .inferredFromPropertyName, parseArgument: @escaping (_ bundle: DocumentationBundle, _ argumentValue: String) -> (Value?), allowedValues: [String]? = nil, + expectedFormat: String? = nil, hiddenFromDocumentation: Bool = false ) { self.init( @@ -106,6 +109,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: name, transform: parseArgument, allowedValues: allowedValues, + expectedFormat: expectedFormat, required: nil, hiddenFromDocumentation: hiddenFromDocumentation ) @@ -116,6 +120,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: _DirectiveArgumentName = .inferredFromPropertyName, parseArgument: @escaping (_ bundle: DocumentationBundle, _ argumentValue: String) -> (Value?), allowedValues: [String]? = nil, + expectedFormat: String? = nil, hiddenFromDocumentation: Bool = false ) { self.init( @@ -123,6 +128,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: name, transform: parseArgument, allowedValues: allowedValues, + expectedFormat: expectedFormat, required: nil, hiddenFromDocumentation: hiddenFromDocumentation ) @@ -133,6 +139,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: _DirectiveArgumentName, transform: @escaping (_ bundle: DocumentationBundle, _ argumentValue: String) -> (Value?), allowedValues: [String]?, + expectedFormat: String?, required: Bool?, hiddenFromDocumentation: Bool ) { @@ -143,6 +150,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { self.typeDisplayName = typeDisplayNameDescription(defaultValue: value, required: required) self.parseArgument = transform self.allowedValues = allowedValues + self.expectedFormat = expectedFormat self.required = required self.hiddenFromDocumentation = hiddenFromDocumentation } @@ -166,6 +174,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: _DirectiveArgumentName = .inferredFromPropertyName, parseArgument: @escaping (_ bundle: DocumentationBundle, _ argumentValue: String) -> (Value?), allowedValues: [String]? = nil, + expectedFormat: String? = nil, required: Bool, hiddenFromDocumentation: Bool = false ) { @@ -174,6 +183,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: name, transform: parseArgument, allowedValues: allowedValues, + expectedFormat: expectedFormat, required: required, hiddenFromDocumentation: hiddenFromDocumentation ) @@ -185,6 +195,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: _DirectiveArgumentName = .inferredFromPropertyName, parseArgument: @escaping (_ bundle: DocumentationBundle, _ argumentValue: String) -> (Value?), allowedValues: [String]? = nil, + expectedFormat: String? = nil, required: Bool, hiddenFromDocumentation: Bool = false ) { @@ -193,6 +204,7 @@ public struct DirectiveArgumentWrapped: _DirectiveArgumentProtocol { name: name, transform: parseArgument, allowedValues: allowedValues, + expectedFormat: expectedFormat, required: required, hiddenFromDocumentation: hiddenFromDocumentation ) @@ -229,6 +241,7 @@ extension DirectiveArgumentWrapped where Value: DirectiveArgumentValueConvertibl Value.init(rawDirectiveArgumentValue: argument) } self.allowedValues = Value.allowedValues() + self.expectedFormat = Value.expectedFormat() self.required = required self.hiddenFromDocumentation = hiddenFromDocumentation } @@ -335,6 +348,7 @@ extension DirectiveArgumentWrapped where Value: _OptionalDirectiveArgument { name: _DirectiveArgumentName, parseArgument: @escaping (_ bundle: DocumentationBundle, _ argumentValue: String) -> (Value?), allowedValues: [String]? = nil, + expectedFormat: String? = nil, hiddenFromDocumentation: Bool = false ) { self.name = name @@ -342,6 +356,7 @@ extension DirectiveArgumentWrapped where Value: _OptionalDirectiveArgument { self.typeDisplayName = typeDisplayNameDescription(optionalDefaultValue: value, required: false) self.parseArgument = parseArgument self.allowedValues = allowedValues + self.expectedFormat = expectedFormat self.required = false self.hiddenFromDocumentation = hiddenFromDocumentation } diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveMirror.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveMirror.swift index 9c4c5656fa..26f43c3b90 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveMirror.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveMirror.swift @@ -59,6 +59,7 @@ struct DirectiveMirror { name: argumentName, unnamed: unnamed, allowedValues: argument.allowedValues, + expectedFormat: argument.expectedFormat, propertyLabel: label, argument: argument, parseArgument: argument.parseArgument @@ -166,6 +167,7 @@ extension DirectiveMirror { let unnamed: Bool let allowedValues: [String]? + let expectedFormat: String? let propertyLabel: String let argument: _DirectiveArgumentProtocol diff --git a/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasArgumentOfType.swift b/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasArgumentOfType.swift index 2c4b68869d..b3b56c62d2 100644 --- a/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasArgumentOfType.swift +++ b/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasArgumentOfType.swift @@ -19,12 +19,19 @@ protocol DirectiveArgument { /// If non-`nil`, the list of allowed values the argument can take on, /// suggested to the author as possible solutions static func allowedValues() -> [String]? + + /// If non-`nil`, a string describing the expected format for the argument value, + /// shown to the author as part of the diagnostic summary when an invalid value is provided. + static func expectedFormat() -> String? } extension DirectiveArgument { static func allowedValues() -> [String]? { return nil } + static func expectedFormat() -> String? { + return nil + } static func convert(_ argument: String) -> ArgumentValue? { return ArgumentValue(rawDirectiveArgumentValue: argument) } @@ -52,6 +59,7 @@ extension Semantic.Analyses { severityIfNotFound: severityIfNotFound, argumentName: Converter.argumentName, allowedValues: Converter.allowedValues(), + expectedFormat: Converter.expectedFormat(), convert: Converter.convert(_:), valueTypeDiagnosticName: String(describing: Converter.ArgumentValue.self) ).analyze(directive, arguments: arguments, problems: &problems) as? Converter.ArgumentValue @@ -62,6 +70,7 @@ extension Semantic.Analyses { let severityIfNotFound: DiagnosticSeverity? let argumentName: String let allowedValues: [String]? + let expectedFormat: String? let convert: (String) -> (Any?) let valueTypeDiagnosticName: String @@ -73,33 +82,45 @@ extension Semantic.Analyses { let arguments = directive.arguments(problems: &problems) let source = directive.range?.lowerBound.source let diagnosticArgumentName = argumentName.isEmpty ? "unlabeled" : argumentName - + let diagnosticArgumentDescription = if argumentName.isEmpty { + "an unnamed parameter" + } else { + "the \(argumentName.singleQuoted) parameter" + } + let diagnosticExplanation = if let expectedFormat { + """ + \(Parent.directiveName) expects an argument for \(diagnosticArgumentDescription) \ + that's convertible to \(expectedFormat) + """ + } else { + """ + \(Parent.directiveName) expects an argument for \(diagnosticArgumentDescription) \ + that's convertible to \(valueTypeDiagnosticName.singleQuoted) + """ + } guard let argument = arguments[argumentName] else { if let severity = severityIfNotFound { - let argumentDiagnosticDescription: String - if argumentName.isEmpty { - argumentDiagnosticDescription = "an unnamed parameter" - } else { - argumentDiagnosticDescription = "the \(argumentName.singleQuoted) parameter" - } - let diagnostic = Diagnostic( source: source, severity: severity, range: directive.range, identifier: "org.swift.docc.HasArgument.\(diagnosticArgumentName)", summary: "Missing argument for \(diagnosticArgumentName) parameter", - explanation: """ - \(Parent.directiveName) expects an argument for \(argumentDiagnosticDescription) \ - that's convertible to \(valueTypeDiagnosticName.singleQuoted) - """ + explanation: diagnosticExplanation ) problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) } return nil } guard let value = convert(argument.value) else { - let diagnostic = Diagnostic(source: source, severity: .warning, range: argument.valueRange, identifier: "org.swift.docc.HasArgument.\(diagnosticArgumentName).ConversionFailed", summary: "Cannot convert \(argument.value.singleQuoted) to type \(valueTypeDiagnosticName.singleQuoted)") + let diagnostic = Diagnostic( + source: source, + severity: .warning, + range: argument.valueRange, + identifier: "org.swift.docc.HasArgument.\(diagnosticArgumentName).ConversionFailed", + summary: "Cannot convert \(argument.value.singleQuoted) to type \(valueTypeDiagnosticName.singleQuoted)", + explanation: diagnosticExplanation + ) let solutions = allowedValues.map { allowedValues -> [Solution] in return allowedValues.compactMap { allowedValue -> Solution? in guard let range = argument.valueRange else { diff --git a/Sources/SwiftDocC/Semantics/Metadata/Availability.swift b/Sources/SwiftDocC/Semantics/Metadata/Availability.swift index 4130c45903..fa477d6963 100644 --- a/Sources/SwiftDocC/Semantics/Metadata/Availability.swift +++ b/Sources/SwiftDocC/Semantics/Metadata/Availability.swift @@ -34,6 +34,17 @@ extension Metadata { /// @Available("My Package", introduced: "1.0") /// ``` /// + /// Only strings which are valid semantic version numbers may be passed to the `introduced` and `deprecated` arguments. Specifying an incomplete version number is allowed, as long as all components of the version are valid numbers: + /// + /// ```markdown + /// @Available("My Package", introduced: "1.0.0") + /// @Available("My Package", introduced: "1.0") + /// @Available("My Package", introduced: "1") + /// @Available("My Package", introduced: "1.0.0", deprecated: "2.3.2") + /// ``` + /// + /// If an invalid semantic version number is provided, a compiler warning will be issued and the directive will be ignored. + /// /// This directive is available on both articles and documentation extension files. In extension /// files, the information overrides any information from the symbol itself. /// @@ -64,7 +75,7 @@ extension Metadata { } } if rawValue == "*" { - // Reserve the `*` platform for when `isBeta` and `isDeprecated` can be implemented + // Reserve the `*` platform for when we have decided on how `*` availability should be displayed (https://github.com/apple/swift-docc/issues/969) return nil } else { self = .other(rawValue) @@ -92,11 +103,11 @@ extension Metadata { /// The platform version that this page was introduced in. @DirectiveArgumentWrapped - public var introduced: String + public var introduced: SemanticVersion /// The platform version that this page was deprecated in. @DirectiveArgumentWrapped - public var deprecated: String? = nil + public var deprecated: SemanticVersion? = nil static var keyPaths: [String : AnyKeyPath] = [ "platform" : \Availability._platform, @@ -112,3 +123,43 @@ extension Metadata { } } } + +extension SemanticVersion: DirectiveArgumentValueConvertible { + static let separator = "." + + init?(rawDirectiveArgumentValue: String) { + guard !rawDirectiveArgumentValue.hasSuffix(Self.separator), + !rawDirectiveArgumentValue.hasPrefix(Self.separator) else { + return nil + } + + // Split the string into major, minor and patch components + let availabilityComponents = rawDirectiveArgumentValue.split(separator: .init(Self.separator), maxSplits: 2) + guard !availabilityComponents.isEmpty else { + return nil + } + + // If any of the components are missing, default to 0 + var intAvailabilityComponents = [0, 0, 0] + for (index, component) in availabilityComponents.enumerated() { + // If any of the components isn't a number, the input is not valid + guard let intComponent = Int(component) else { + return nil + } + + intAvailabilityComponents[index] = intComponent + } + + self.major = intAvailabilityComponents[0] + self.minor = intAvailabilityComponents[1] + self.patch = intAvailabilityComponents[2] + } + + static func allowedValues() -> [String]? { + nil + } + + static func expectedFormat() -> String? { + return "a semantic version number ('[0-9]+(.[0-9]+)?(.[0-9]+)?')" + } +} diff --git a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift index f40534d9ec..fe49ceb9dc 100644 --- a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift @@ -1566,7 +1566,7 @@ class SemaToRenderNodeTests: XCTestCase { let platforms = (renderNode.metadata.platforms ?? []).sorted(by: { lhs, rhs in lhs.name! < rhs.name! }) XCTAssertEqual(platforms.count,6) - let versionString = version.stringRepresentation(precisionUpToNonsignificant: .patch) + let versionString = SemanticVersion(version).stringRepresentation(precisionUpToNonsignificant: .patch) XCTAssertEqual(platforms[0].name, "Mac Catalyst") XCTAssertEqual(platforms[0].introduced, versionString) diff --git a/Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift b/Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift index 1c69d35b15..8924071a4c 100644 --- a/Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift +++ b/Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift @@ -53,6 +53,7 @@ class PlatformAvailabilityTests: XCTestCase { let iosAvailability = try XCTUnwrap(availability.first) XCTAssertEqual(iosAvailability.name, "iOS") XCTAssertEqual(iosAvailability.introduced, "16.0") + XCTAssert(iosAvailability.isBeta != true) } /// Ensure that adding `@Available` directives in an extension file overrides the symbol's availability. @@ -76,6 +77,7 @@ class PlatformAvailabilityTests: XCTestCase { let iosAvailability = try XCTUnwrap(availability.first) XCTAssertEqual(iosAvailability.name, "iOS") XCTAssertEqual(iosAvailability.introduced, "16.0") + XCTAssert(iosAvailability.isBeta != true) } func testMultiplePlatformAvailabilityFromArticle() throws { @@ -105,6 +107,10 @@ class PlatformAvailabilityTests: XCTestCase { XCTAssert(availability.contains(where: { item in item.name == "watchOS" && item.introduced == "7.0" })) + + XCTAssert(availability.allSatisfy { item in + item.isBeta != true + }) } func testArbitraryPlatformAvailability() throws { @@ -131,6 +137,9 @@ class PlatformAvailabilityTests: XCTestCase { XCTAssert(availability.contains(where: { item in item.name == "My Package" && item.introduced == "2.0" })) + XCTAssert(availability.allSatisfy { item in + item.isBeta != true + }) } // Test that the Info.plist default availability does not affect the deprecated/unavailable availabilities provided by the symbol graph. @@ -170,5 +179,110 @@ class PlatformAvailabilityTests: XCTestCase { XCTAssertFalse(availability.contains(where: { platform in platform.name == "tvOS" })) + XCTAssert(availability.allSatisfy { item in + item.isBeta != true + }) + } + + /// Ensure that adding `@Available` directives for platform versions marked as beta in an article causes the final RenderNode to contain the appropriate availability data. + func testBetaPlatformAvailabilityFromArticle() throws { + let platformMetadata = [ + "iOS": PlatformVersion(VersionTriplet(16, 0, 0), beta: true), + ] + let (bundle, context) = try testBundleWithConfiguredPlatforms(named: "AvailabilityBundle", platformMetadata: platformMetadata) + 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") + XCTAssert(iosAvailability.isBeta == true) } + + func testMultipleBetaPlatformAvailabilityFromArticle() throws { + let platformMetadata = [ + "iOS": PlatformVersion(VersionTriplet(15, 0, 0), beta: true), + "macOS": PlatformVersion(VersionTriplet(12, 0, 0), beta: true), + "watchOS": PlatformVersion(VersionTriplet(7, 0, 0), beta: true), + ] + let (bundle, context) = try testBundleWithConfiguredPlatforms(named: "AvailabilityBundle", platformMetadata: platformMetadata) + 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" + })) + + XCTAssert(availability.allSatisfy { item in + item.isBeta == true + }) + } + + /// Ensure that adding `@Available` directives in an extension file overrides the symbol's availability. + func testBetaPlatformAvailabilityFromExtension() throws { + let platformMetadata = [ + "iOS": PlatformVersion(VersionTriplet(16, 0, 0), beta: true), + ] + let (bundle, context) = try testBundleWithConfiguredPlatforms(named: "AvailabilityBundle", platformMetadata: platformMetadata) + 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") + XCTAssert(iosAvailability.isBeta == true) + } + + + func testBundleWithConfiguredPlatforms(named testBundleName: String, platformMetadata: [String : PlatformVersion]) throws -> (DocumentationBundle, DocumentationContext) { + let bundleURL = try XCTUnwrap(Bundle.module.url(forResource: testBundleName, withExtension: "docc", subdirectory: "Test Bundles")) + let (_, bundle, context) = try loadBundle(from: bundleURL) { context in + context.externalMetadata.currentPlatforms = platformMetadata + } + return (bundle, context) + } + } diff --git a/Tests/SwiftDocCTests/Rendering/SemanticVersionStringRepresentationTests.swift b/Tests/SwiftDocCTests/Rendering/SemanticVersionStringRepresentationTests.swift index ded6880824..0dfda10a6d 100644 --- a/Tests/SwiftDocCTests/Rendering/SemanticVersionStringRepresentationTests.swift +++ b/Tests/SwiftDocCTests/Rendering/SemanticVersionStringRepresentationTests.swift @@ -14,32 +14,49 @@ import SymbolKit @testable import SwiftDocC class SemanticVersionStringRepresentationTests: XCTestCase { - func test() { - let oneComponent = SymbolGraph.SemanticVersion(major: 1, minor: 0, patch: 0) + func testConversionToVersionTriplet() { + let symbolOne = SymbolGraph.SemanticVersion(major: 1, minor: 0, patch: 0) + XCTAssertEqual(SemanticVersion(symbolOne), SemanticVersion(major: 1, minor: 0, patch: 0)) + + let symbolTwo = SymbolGraph.SemanticVersion(major: 1, minor: 2, patch: 0) + XCTAssertEqual(SemanticVersion(symbolTwo), SemanticVersion(major: 1, minor: 2, patch: 0)) + + let symbolThree = SymbolGraph.SemanticVersion(major: 1, minor: 2, patch: 3) + XCTAssertEqual(SemanticVersion(symbolThree), SemanticVersion(major: 1, minor: 2, patch: 3)) + + let symbolFour = SymbolGraph.SemanticVersion(major: 0, minor: 0, patch: 0) + XCTAssertEqual(SemanticVersion(symbolFour), SemanticVersion(major: 0, minor: 0, patch: 0)) + + let symbolFive = SymbolGraph.SemanticVersion(major: 1, minor: 0, patch: 1) + XCTAssertEqual(SemanticVersion(symbolFive), SemanticVersion(major: 1, minor: 0, patch: 1)) + } + + func testStringRepresentation() { + let oneComponent = SemanticVersion(major: 1, minor: 0, patch: 0) XCTAssertEqual(oneComponent.stringRepresentation(precisionUpToNonsignificant: .minor), "1.0") XCTAssertEqual(oneComponent.stringRepresentation(precisionUpToNonsignificant: .patch), "1.0.0") XCTAssertEqual(oneComponent.stringRepresentation(precisionUpToNonsignificant: .all), "1.0.0") - let twoComponents = SymbolGraph.SemanticVersion(major: 1, minor: 2, patch: 0) + let twoComponents = SemanticVersion(major: 1, minor: 2, patch: 0) XCTAssertEqual(twoComponents.stringRepresentation(precisionUpToNonsignificant: .minor), "1.2") XCTAssertEqual(twoComponents.stringRepresentation(precisionUpToNonsignificant: .patch), "1.2.0") XCTAssertEqual(twoComponents.stringRepresentation(precisionUpToNonsignificant: .all), "1.2.0") - let threeComponents = SymbolGraph.SemanticVersion(major: 1, minor: 2, patch: 3) + let threeComponents = SemanticVersion(major: 1, minor: 2, patch: 3) XCTAssertEqual(threeComponents.stringRepresentation(precisionUpToNonsignificant: .minor), "1.2.3") XCTAssertEqual(threeComponents.stringRepresentation(precisionUpToNonsignificant: .patch), "1.2.3") XCTAssertEqual(threeComponents.stringRepresentation(precisionUpToNonsignificant: .all), "1.2.3") - let zeroVersion = SymbolGraph.SemanticVersion(major: 0, minor: 0, patch: 0) + let zeroVersion = SemanticVersion(major: 0, minor: 0, patch: 0) XCTAssertEqual(zeroVersion.stringRepresentation(precisionUpToNonsignificant: .minor), "0.0") XCTAssertEqual(zeroVersion.stringRepresentation(precisionUpToNonsignificant: .patch), "0.0.0") XCTAssertEqual(zeroVersion.stringRepresentation(precisionUpToNonsignificant: .all), "0.0.0") - let zeroMinorVersion = SymbolGraph.SemanticVersion(major: 1, minor: 0, patch: 1) + let zeroMinorVersion = SemanticVersion(major: 1, minor: 0, patch: 1) XCTAssertEqual(zeroMinorVersion.stringRepresentation(precisionUpToNonsignificant: .minor), "1.0.1") XCTAssertEqual(zeroMinorVersion.stringRepresentation(precisionUpToNonsignificant: .patch), "1.0.1") XCTAssertEqual(zeroMinorVersion.stringRepresentation(precisionUpToNonsignificant: .all), "1.0.1") } -} +} diff --git a/Tests/SwiftDocCTests/Semantics/MetadataAvailabilityTests.swift b/Tests/SwiftDocCTests/Semantics/MetadataAvailabilityTests.swift index 9b517a45bd..0e27957539 100644 --- a/Tests/SwiftDocCTests/Semantics/MetadataAvailabilityTests.swift +++ b/Tests/SwiftDocCTests/Semantics/MetadataAvailabilityTests.swift @@ -17,38 +17,22 @@ import Markdown class MetadataAvailabilityTests: XCTestCase { func testInvalidWithNoArguments() throws { let source = "@Available" - let document = Document(parsing: source, options: .parseBlockDirectives) - let directive = document.child(at: 0) as? BlockDirective - XCTAssertNotNil(directive) - - let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle") - - directive.map { directive in - var problems = [Problem]() - XCTAssertEqual(Metadata.Availability.directiveName, directive.name) - let availability = Metadata.Availability(from: directive, source: nil, for: bundle, in: context, problems: &problems) - XCTAssertNil(availability) + + try assertDirective(Metadata.Availability.self, source: source) { directive, problems in + XCTAssertNil(directive) + + XCTAssertEqual(2, problems.count) + let diagnosticIdentifiers = Set(problems.map { $0.diagnostic.identifier }) + let diagnosticExplanations = Set(problems.map { $0.diagnostic.explanation }) + XCTAssertEqual(diagnosticIdentifiers, ["org.swift.docc.HasArgument.unlabeled", "org.swift.docc.HasArgument.introduced"]) + XCTAssertEqual(diagnosticExplanations, [ + "Available expects an argument for the \'introduced\' parameter that\'s convertible to a semantic version number (\'[0-9]+(.[0-9]+)?(.[0-9]+)?\')", + "Available expects an argument for an unnamed parameter that\'s convertible to \'Platform\'" + ]) } } func testInvalidDuplicateIntroduced() throws { - func assertInvalidDirective(source: String) throws { - let document = Document(parsing: source, options: .parseBlockDirectives) - let directive = document.child(at: 0) as? BlockDirective - XCTAssertNotNil(directive) - - let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle") - - directive.map { directive in - var problems = [Problem]() - XCTAssertEqual(Metadata.directiveName, directive.name) - let _ = Metadata(from: directive, source: nil, for: bundle, in: context, problems: &problems) - XCTAssertEqual(2, problems.count) - let diagnosticIdentifiers = Set(problems.map { $0.diagnostic.identifier }) - XCTAssertEqual(diagnosticIdentifiers, ["org.swift.docc.\(Metadata.Availability.self).DuplicateIntroduced"]) - } - } - for platform in Metadata.Availability.Platform.defaultCases { let source = """ @Metadata { @@ -56,12 +40,81 @@ class MetadataAvailabilityTests: XCTestCase { @Available(\(platform.rawValue), introduced: \"2.0\") } """ - try assertInvalidDirective(source: source) + try assertDirective(Metadata.self, source: source) { directive, problems in + XCTAssertEqual(2, problems.count) + let diagnosticIdentifiers = Set(problems.map { $0.diagnostic.identifier }) + XCTAssertEqual(diagnosticIdentifiers, ["org.swift.docc.\(Metadata.Availability.self).DuplicateIntroduced"]) + } + } + } + + func testInvalidIntroducedFormat() throws { + let source = """ + @Metadata { + @TechnologyRoot + @Available(Package, introduced: \"\") + @Available(Package, introduced: \".\") + @Available(Package, introduced: \"1.\") + @Available(Package, introduced: \".1\") + @Available(Package, introduced: \"test\") + @Available(Package, introduced: \"test.1.2\") + @Available(Package, introduced: \"2.1.test\") + @Available(Package, introduced: \"test.test.test\") + } + """ + + try assertDirective(Metadata.self, source: source) { directive, problems in + XCTAssertEqual(8, problems.count) + let diagnosticIdentifiers = Set(problems.map { $0.diagnostic.identifier }) + let diagnosticExplanations = Set(problems.map { $0.diagnostic.explanation }) + XCTAssertEqual(diagnosticIdentifiers, ["org.swift.docc.HasArgument.introduced.ConversionFailed"]) + XCTAssertEqual(diagnosticExplanations, [ + "Available expects an argument for the \'introduced\' parameter that\'s convertible to a semantic version number (\'[0-9]+(.[0-9]+)?(.[0-9]+)?\')", + ]) + } + } + + func testValidSemanticVersionFormat() throws { + let source = """ + @Metadata { + @Available(iOS, introduced: \"3.5.2\", deprecated: \"5.6.7\") + @Available(macOS, introduced: \"3.5\", deprecated: \"5.6\") + @Available(Package, introduced: \"3\", deprecated: \"5\") + } + """ + + try assertDirective(Metadata.self, source: source) { directive, problems in + XCTAssertEqual(0, problems.count) + + let directive = try XCTUnwrap(directive) + XCTAssertEqual(3, directive.availability.count) + + let platforms = directive.availability.map { $0.platform } + XCTAssertEqual(platforms, [ + .iOS, + .macOS, + .other("Package") + ]) + + let introducedVersions = directive.availability.map { $0.introduced } + XCTAssertEqual(introducedVersions, [ + SemanticVersion(major: 3, minor: 5, patch: 2), + SemanticVersion(major: 3, minor: 5, patch: 0), + SemanticVersion(major: 3, minor: 0, patch: 0) + ]) + + let deprecatedVersions = directive.availability.map { $0.deprecated } + XCTAssertEqual(deprecatedVersions, [ + SemanticVersion(major: 5, minor: 6, patch: 7), + SemanticVersion(major: 5, minor: 6, patch: 0), + SemanticVersion(major: 5, minor: 0, patch: 0) + ]) + } } - func testValidDirective() throws { - // assemble all the combinations of arguments you could give + func testValidIntroducedDirective() throws { + // Assemble all the combinations of arguments you could give let validArguments: [String] = [ "deprecated: \"1.0\"", ] @@ -69,24 +122,26 @@ class MetadataAvailabilityTests: XCTestCase { var validArgumentsWithVersion = ["introduced: \"1.0\""] for arg in validArguments { validArgumentsWithVersion.append("introduced: \"1.0\", \(arg)") + validArgumentsWithVersion.append("\(arg), introduced: \"1.0\"") } var checkPlatforms = Metadata.Availability.Platform.defaultCases.map({ $0.rawValue }) - checkPlatforms.append("Package") - + checkPlatforms += [ + "Package", + "\"My Package\"", // Also check a platform with spaces in the name + // FIXME: Test validArguments with the `*` platform once that's introduced (https://github.com/apple/swift-docc/issues/969) +// "*", + ] + for platform in checkPlatforms { - // FIXME: Test validArguments with the `*` platform once that's introduced - // cf. https://github.com/apple/swift-docc/issues/441 for args in validArgumentsWithVersion { try assertValidAvailability(source: "@Available(\(platform), \(args))") } } - - // also check a platform with spaces in the name - for args in validArgumentsWithVersion { - try assertValidAvailability(source: "@Available(\"My Package\", \(args))") - } - + } + + /// Basic validity test for giving several directives. + func testMultipleAvailabilityDirectives() throws { let source = """ @Metadata { @Available(macOS, introduced: "11.0") @@ -97,19 +152,25 @@ class MetadataAvailabilityTests: XCTestCase { """ try assertValidMetadata(source: source) } - - func assertValidDirective(_ type: Directive.Type, source: String) throws { + + func assertDirective(_ type: Directive.Type, source: String, assertion assert: (Directive?, [Problem]) throws -> Void) throws { let document = Document(parsing: source, options: .parseBlockDirectives) let directive = document.child(at: 0) as? BlockDirective XCTAssertNotNil(directive) let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle") - directive.map { directive in + try directive.map { directive in var problems = [Problem]() XCTAssertEqual(Directive.directiveName, directive.name) let converted = Directive(from: directive, source: nil, for: bundle, in: context, problems: &problems) - XCTAssertNotNil(converted) + try assert(converted, problems) + } + } + + func assertValidDirective(_ type: Directive.Type, source: String) throws { + try assertDirective(type, source: source) { directive, problems in + XCTAssertNotNil(directive) XCTAssert(problems.isEmpty) } }