mirror of
https://github.com/apple/sourcekit-lsp.git
synced 2026-03-02 18:23:24 +01:00
467 lines
17 KiB
Swift
467 lines
17 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
//
|
|
// This source file is part of the Swift.org open source project
|
|
//
|
|
// Copyright (c) 2014 - 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 the list of Swift project authors
|
|
//
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
@_spi(SourceKitLSP) import LanguageServerProtocol
|
|
@_spi(SourceKitLSP) import SKLogging
|
|
import SourceKitLSP
|
|
import SwiftParser
|
|
import SwiftSyntax
|
|
|
|
// MARK: - Attribute parsing
|
|
|
|
/// Get the traits applied to a testing attribute.
|
|
///
|
|
/// - Parameters:
|
|
/// - testAttribute: The attribute to inspect.
|
|
///
|
|
/// - Returns: An array of `ExprSyntax` instances representing the traits
|
|
/// applied to `testAttribute`. If the attribute has no traits, the empty
|
|
/// array is returned.
|
|
private func traits(ofTestAttribute testAttribute: AttributeSyntax) -> [ExprSyntax] {
|
|
guard let argument = testAttribute.arguments, case let .argumentList(argumentList) = argument else {
|
|
return []
|
|
}
|
|
|
|
// Skip the display name if present.
|
|
var traitArgumentsRange = argumentList.startIndex..<argumentList.endIndex
|
|
if let firstArgument = argumentList.first,
|
|
firstArgument.label == nil,
|
|
firstArgument.expression.is(StringLiteralExprSyntax.self)
|
|
{
|
|
traitArgumentsRange = argumentList.index(after: argumentList.startIndex)..<argumentList.endIndex
|
|
}
|
|
|
|
// Look for any traits in the remaining arguments and slice them off.
|
|
if let labelledArgumentIndex = argumentList[traitArgumentsRange].firstIndex(where: { $0.label != nil }) {
|
|
// There is an argument with a label, so splice there.
|
|
traitArgumentsRange = traitArgumentsRange.lowerBound..<labelledArgumentIndex
|
|
}
|
|
|
|
return argumentList[traitArgumentsRange].map(\.expression)
|
|
}
|
|
|
|
/// Contains information about a testing attribute such as `@Test` or `@Suite`.
|
|
struct TestingAttributeData {
|
|
/// The display name in the attribute, if any.
|
|
let displayName: String?
|
|
|
|
/// The tags applied to the attribute.
|
|
let tags: [String]
|
|
|
|
/// Whether or not the attributed test is unconditionally disabled.
|
|
///
|
|
/// Disabled tests can be presented differently in IDEs to indicate that they will never run, although they will still
|
|
/// be represented in the results.
|
|
let isDisabled: Bool
|
|
|
|
/// Whether or not the attributed test is hidden.
|
|
///
|
|
/// Hidden tests are not reported by SourceKit-LSP and are not run automatically.
|
|
let isHidden: Bool
|
|
|
|
/// Extract the testing attribute data from the given attribute, which is assumed to be an `@Test` or `@Suite`
|
|
/// attribute.
|
|
init(attribute: AttributeSyntax) {
|
|
// If the first argument is an unlabelled string literal, it is the
|
|
// display name of the test. Otherwise, the test does not have a display
|
|
// name.
|
|
if case .argumentList(let argumentList) = attribute.arguments,
|
|
let firstArgument = argumentList.first,
|
|
firstArgument.label == nil,
|
|
let stringLiteral = firstArgument.expression.as(StringLiteralExprSyntax.self)
|
|
{
|
|
self.displayName = stringLiteral.representedLiteralValue
|
|
} else {
|
|
self.displayName = nil
|
|
}
|
|
|
|
let traitArguments = traits(ofTestAttribute: attribute)
|
|
|
|
// Map the arguments to tag's names.
|
|
self.tags = traitArguments.lazy
|
|
.compactMap { $0.as(FunctionCallExprSyntax.self) }
|
|
.filter { functionCall in
|
|
switch functionCall.calledExpression.as(MemberAccessExprSyntax.self)?.fullyQualifiedName {
|
|
case "tags", "Tag.List.tags", "Testing.Tag.List.tags":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}.flatMap(\.arguments)
|
|
.compactMap {
|
|
if let memberAccess = $0.expression.as(MemberAccessExprSyntax.self) {
|
|
var components = memberAccess.components[...]
|
|
if components.starts(with: ["Testing", "Tag"]) {
|
|
components = components.dropFirst(2)
|
|
} else if components.starts(with: ["Tag"]) {
|
|
components = components.dropFirst(1)
|
|
}
|
|
return components.joined(separator: ".")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
self.isDisabled = traitArguments.lazy
|
|
.compactMap { $0.as(FunctionCallExprSyntax.self) }
|
|
.filter { functionCall in
|
|
switch functionCall.calledExpression.as(MemberAccessExprSyntax.self)?.fullyQualifiedName {
|
|
case "disabled", "ConditionTrait.disabled", "Testing.ConditionTrait.disabled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
.contains { functionCall in
|
|
// Ignore disabled traits which have an `if:` parameter since
|
|
// they're conditional.
|
|
let hasConditionParam = functionCall.arguments.lazy
|
|
.compactMap(\.label?.text)
|
|
.contains("if")
|
|
if hasConditionParam {
|
|
return false
|
|
}
|
|
|
|
// Ignore disabled traits which have a trailing closure since
|
|
// they're conditional.
|
|
if functionCall.trailingClosure != nil {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
self.isHidden = traitArguments.lazy
|
|
.compactMap { $0.as(MemberAccessExprSyntax.self) }
|
|
.contains { memberAccess in
|
|
switch memberAccess.fullyQualifiedName {
|
|
case "hidden", "HiddenTrait.hidden", "Testing.HiddenTrait.hidden":
|
|
true
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Test scanning
|
|
|
|
final class SyntacticSwiftTestingTestScanner: SyntaxVisitor {
|
|
/// The `DocumentSnapshot` of the syntax tree that is being visited.
|
|
///
|
|
/// Used to convert `AbsolutePosition` to line-column.
|
|
private let snapshot: DocumentSnapshot
|
|
|
|
/// Whether all tests discovered by the scanner should be marked as disabled.
|
|
///
|
|
/// This is the case when the scanner is looking for tests inside a disabled suite.
|
|
private let allTestsDisabled: Bool
|
|
|
|
/// The names of the types that this scanner is scanning members for.
|
|
///
|
|
/// For example, when scanning for tests inside `Bar` in the following, this is `["Foo", "Bar"]`
|
|
///
|
|
/// ```swift
|
|
/// struct Foo {
|
|
/// struct Bar {
|
|
/// @Test func myTest() {}
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
private let parentTypeNames: [String]
|
|
|
|
/// The discovered test items.
|
|
private var result: [AnnotatedTestItem] = []
|
|
|
|
private init(
|
|
snapshot: DocumentSnapshot,
|
|
allTestsDisabled: Bool,
|
|
parentTypeNames: [String]
|
|
) {
|
|
self.snapshot = snapshot
|
|
self.allTestsDisabled = allTestsDisabled
|
|
self.parentTypeNames = parentTypeNames
|
|
super.init(viewMode: .fixedUp)
|
|
}
|
|
|
|
/// Public entry point. Scans the syntax tree of the given snapshot for swift-testing tests.
|
|
package static func findTestSymbols(
|
|
in snapshot: DocumentSnapshot,
|
|
syntaxTreeManager: SyntaxTreeManager
|
|
) async -> [AnnotatedTestItem] {
|
|
guard snapshot.text.contains("Suite") || snapshot.text.contains("Test") else {
|
|
// If the file contains swift-testing tests, it must contain a `@Suite` or `@Test` attribute.
|
|
// Only check for the attribute name because the attribute may be module qualified and contain an arbitrary amount
|
|
// of whitespace.
|
|
// This is intended to filter out files that obviously do not contain tests.
|
|
return []
|
|
}
|
|
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
|
|
let visitor = SyntacticSwiftTestingTestScanner(
|
|
snapshot: snapshot,
|
|
allTestsDisabled: false,
|
|
parentTypeNames: []
|
|
)
|
|
visitor.walk(syntaxTree)
|
|
return visitor.result
|
|
}
|
|
|
|
/// Visit a class/struct/... or extension declaration.
|
|
///
|
|
/// `typeNames` is the name of the class struct or, if this is an extension, an array containing the components of the
|
|
/// extended type. For example, `extension Foo.Bar {}` passes `["Foo", "Bar"]` as `typeNames`.
|
|
/// `typeNames` must not be empty.
|
|
private func visitTypeOrExtensionDecl(
|
|
_ node: DeclGroupSyntax,
|
|
typeNames: [String]
|
|
) -> SyntaxVisitorContinueKind {
|
|
precondition(!typeNames.isEmpty)
|
|
let superclassName = node.inheritanceClause?.inheritedTypes.first?.type.as(IdentifierTypeSyntax.self)?.name.text
|
|
if superclassName == "XCTestCase" {
|
|
return .skipChildren
|
|
}
|
|
|
|
let suiteAttribute = node.attributes
|
|
.compactMap { $0.as(AttributeSyntax.self) }
|
|
.first { $0.isNamed("Suite", inModuleNamed: "Testing") }
|
|
let attributeData = suiteAttribute.map(TestingAttributeData.init(attribute:))
|
|
|
|
if attributeData?.isHidden ?? false {
|
|
return .skipChildren
|
|
}
|
|
|
|
let displayName = attributeData?.displayName ?? typeNames.last!
|
|
let typeNames = typeNames.map { backtickIfNeeded($0).name }
|
|
|
|
let memberScanner = SyntacticSwiftTestingTestScanner(
|
|
snapshot: snapshot,
|
|
allTestsDisabled: attributeData?.isDisabled ?? false,
|
|
parentTypeNames: parentTypeNames + typeNames
|
|
)
|
|
memberScanner.walk(node.memberBlock)
|
|
|
|
guard !memberScanner.result.isEmpty || suiteAttribute != nil else {
|
|
// Only include this declaration if it has an `@Suite` attribute or contains nested tests.
|
|
return .skipChildren
|
|
}
|
|
|
|
let range = snapshot.absolutePositionRange(
|
|
of: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia
|
|
)
|
|
// Members won't be extensions since extensions will only be at the top level.
|
|
let testItem = AnnotatedTestItem(
|
|
testItem: TestItem(
|
|
id: (parentTypeNames + typeNames).joined(separator: "/"),
|
|
label: displayName,
|
|
disabled: (attributeData?.isDisabled ?? false) || allTestsDisabled,
|
|
style: TestStyle.swiftTesting,
|
|
location: Location(uri: snapshot.uri, range: range),
|
|
children: memberScanner.result.map(\.testItem),
|
|
tags: attributeData?.tags.map(TestTag.init(id:)) ?? []
|
|
),
|
|
isExtension: node.is(ExtensionDeclSyntax.self)
|
|
)
|
|
result.append(testItem)
|
|
return .skipChildren
|
|
}
|
|
|
|
override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
guard let identifier = node.name.identifier else {
|
|
return .skipChildren
|
|
}
|
|
return visitTypeOrExtensionDecl(node, typeNames: [identifier.name])
|
|
}
|
|
|
|
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
guard let identifier = node.name.identifier else {
|
|
return .skipChildren
|
|
}
|
|
return visitTypeOrExtensionDecl(node, typeNames: [identifier.name])
|
|
}
|
|
|
|
override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
guard let identifier = node.name.identifier else {
|
|
return .skipChildren
|
|
}
|
|
return visitTypeOrExtensionDecl(node, typeNames: [identifier.name])
|
|
}
|
|
|
|
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
guard let newContextComponents = node.extendedType.components else {
|
|
return .skipChildren
|
|
}
|
|
|
|
return visitTypeOrExtensionDecl(node, typeNames: newContextComponents)
|
|
}
|
|
|
|
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
guard let identifier = node.name.identifier else {
|
|
return .skipChildren
|
|
}
|
|
return visitTypeOrExtensionDecl(node, typeNames: [identifier.name])
|
|
}
|
|
|
|
/// If the given name requires backticks to be a valid decl identifier,
|
|
/// applies backticks and returns `true` along with the new name. Otherwise
|
|
/// returns `false` with the name.
|
|
func backtickIfNeeded(_ name: String) -> (backticked: Bool, name: String) {
|
|
var name = name
|
|
if name.first == "`" && name.last == "`" {
|
|
name = String(name.dropFirst().dropLast())
|
|
}
|
|
let needsBackticks = !name.isValidSwiftIdentifier(for: .variableName)
|
|
return (needsBackticks, needsBackticks ? "`\(name)`" : name)
|
|
}
|
|
|
|
override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
let testAttribute = node.attributes
|
|
.compactMap { $0.as(AttributeSyntax.self) }
|
|
.first { $0.isNamed("Test", inModuleNamed: "Testing") }
|
|
|
|
guard let testAttribute, let identifier = node.name.identifier else {
|
|
return .skipChildren
|
|
}
|
|
let attributeData = TestingAttributeData(attribute: testAttribute)
|
|
if attributeData.isHidden {
|
|
return .skipChildren
|
|
}
|
|
|
|
let parameters = node.signature.parameterClause.parameters.map { param in
|
|
let result =
|
|
if let identifier = param.firstName.identifier {
|
|
backtickIfNeeded(identifier.name).name
|
|
} else {
|
|
// Something like `_`, leave as-is.
|
|
param.firstName.text
|
|
}
|
|
return "\(result):"
|
|
}.joined()
|
|
|
|
let (hasBackticks, baseName) = backtickIfNeeded(identifier.name)
|
|
let fullName = "\(baseName)(\(parameters))"
|
|
|
|
// If we have a display name provided by the attribute, use it, otherwise
|
|
// we can infer the display name from a raw identifier if we have one.
|
|
//
|
|
// A raw identifier is considered an alternative way of spelling the display
|
|
// name, so e.g these have the same display name:
|
|
//
|
|
// ```
|
|
// @Test("foo bar") func foo() {}
|
|
// @Test func `foo bar`() {}
|
|
// ```
|
|
//
|
|
// as such it shouldn't include any parameters. If we just have a regular
|
|
// name then we use the full name as the display name.
|
|
let displayName = attributeData.displayName ?? (hasBackticks ? identifier.name : fullName)
|
|
|
|
let range = snapshot.absolutePositionRange(
|
|
of: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia
|
|
)
|
|
let testItem = AnnotatedTestItem(
|
|
testItem: TestItem(
|
|
id: (parentTypeNames + [fullName]).joined(separator: "/"),
|
|
label: displayName,
|
|
disabled: attributeData.isDisabled || allTestsDisabled,
|
|
style: TestStyle.swiftTesting,
|
|
location: Location(uri: snapshot.uri, range: range),
|
|
children: [],
|
|
tags: attributeData.tags.map(TestTag.init(id:))
|
|
),
|
|
isExtension: false
|
|
)
|
|
result.append(testItem)
|
|
return .visitChildren
|
|
}
|
|
}
|
|
|
|
// MARK: - SwiftSyntax Utilities
|
|
|
|
fileprivate extension AttributeSyntax {
|
|
/// Check whether or not this attribute is named with the specified name and
|
|
/// module.
|
|
///
|
|
/// The attribute's name is accepted either without or with the specified
|
|
/// module name as a prefix to allow for either syntax. The name of this
|
|
/// attribute must not include generic type parameters.
|
|
///
|
|
/// - Parameters:
|
|
/// - name: The `"."`-separated type name to compare against.
|
|
/// - moduleName: The module the specified type is declared in.
|
|
///
|
|
/// - Returns: Whether or not this type has the given name.
|
|
func isNamed(_ name: String, inModuleNamed moduleName: String) -> Bool {
|
|
if let identifierType = attributeName.as(IdentifierTypeSyntax.self) {
|
|
return identifierType.name.text == name
|
|
} else if let memberType = attributeName.as(MemberTypeSyntax.self),
|
|
let baseIdentifierType = memberType.baseType.as(IdentifierTypeSyntax.self),
|
|
baseIdentifierType.genericArgumentClause == nil
|
|
{
|
|
return memberType.name.text == name && baseIdentifierType.name.text == moduleName
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
fileprivate extension MemberAccessExprSyntax {
|
|
/// The fully-qualified name of this instance (subject to available
|
|
/// information.)
|
|
///
|
|
/// The value of this property are all the components of the based name
|
|
/// name joined together with `.`.
|
|
var fullyQualifiedName: String {
|
|
components.joined(separator: ".")
|
|
}
|
|
|
|
/// The name components of this instance (subject to available
|
|
/// information.)
|
|
///
|
|
/// The value of this property is this base name of this instance,
|
|
/// i.e. the string value of `base` preceeded with any preceding base names
|
|
/// and followed by its `name` property.
|
|
///
|
|
/// For example, if this instance represents
|
|
/// the expression `x.y.z(123)`, the value of this property is
|
|
/// `["x", "y", "z"]`.
|
|
var components: [String] {
|
|
if let declReferenceExpr = base?.as(DeclReferenceExprSyntax.self) {
|
|
return [declReferenceExpr.baseName.text, declName.baseName.text]
|
|
} else if let baseMemberAccessExpr = base?.as(MemberAccessExprSyntax.self) {
|
|
return baseMemberAccessExpr.components + [declName.baseName.text]
|
|
}
|
|
return [declName.baseName.text]
|
|
}
|
|
}
|
|
|
|
fileprivate extension TypeSyntax {
|
|
/// If this type is a simple chain of `MemberTypeSyntax` and `IdentifierTypeSyntax`, return the components that make
|
|
/// up the qualified type.
|
|
///
|
|
/// ### Examples
|
|
/// - `Foo.Bar` returns `["Foo", "Bar"]`
|
|
/// - `Foo` returns `["Foo"]`
|
|
/// - `[Int]` returns `nil`
|
|
var components: [String]? {
|
|
switch self.as(TypeSyntaxEnum.self) {
|
|
case .identifierType(let identifierType):
|
|
return [identifierType.name.identifier?.name ?? identifierType.name.text]
|
|
case .memberType(let memberType):
|
|
guard let baseComponents = memberType.baseType.components else {
|
|
return nil
|
|
}
|
|
return baseComponents + [memberType.name.identifier?.name ?? memberType.name.text]
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|