Skip to content

Commit 113bd0d

Browse files
committed
Move syntactic XCTest scanner to a separate file
The swift-testing scanner is in its own file and the XCTest scanner should be as well, for consistency. rdar://126529507
1 parent 98718d4 commit 113bd0d

File tree

4 files changed

+143
-128
lines changed

4 files changed

+143
-128
lines changed

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ target_sources(SourceKitLSP PRIVATE
5353
Swift/SwiftLanguageService.swift
5454
Swift/SwiftTestingScanner.swift
5555
Swift/SymbolInfo.swift
56+
Swift/SyntacticSwiftXCTestScanner.swift
5657
Swift/SyntacticTestIndex.swift
5758
Swift/SyntaxHighlightingToken.swift
5859
Swift/SyntaxHighlightingTokenParser.swift

Sources/SourceKitLSP/LanguageService.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ public enum LanguageServerState {
2424
case semanticFunctionalityDisabled
2525
}
2626

27+
public struct AnnotatedTestItem: Sendable {
28+
/// The test item to be annotated
29+
public var testItem: TestItem
30+
31+
/// Whether the `TestItem` is an extension.
32+
public var isExtension: Bool
33+
34+
public init(
35+
testItem: TestItem,
36+
isExtension: Bool
37+
) {
38+
self.testItem = testItem
39+
self.isExtension = isExtension
40+
}
41+
}
42+
2743
public struct RenameLocation: Sendable {
2844
/// How the identifier at a given location is being used.
2945
///
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import LanguageServerProtocol
14+
import SwiftSyntax
15+
16+
/// Scans a source file for `XCTestCase` classes and test methods.
17+
///
18+
/// The syntax visitor scans from class and extension declarations that could be `XCTestCase` classes or extensions
19+
/// thereof. It then calls into `findTestMethods` to find the actual test methods.
20+
final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
21+
/// The document snapshot of the syntax tree that is being walked.
22+
private var snapshot: DocumentSnapshot
23+
24+
/// The workspace symbols representing the found `XCTestCase` subclasses and test methods.
25+
private var result: [AnnotatedTestItem] = []
26+
27+
private init(snapshot: DocumentSnapshot) {
28+
self.snapshot = snapshot
29+
super.init(viewMode: .fixedUp)
30+
}
31+
32+
public static func findTestSymbols(
33+
in snapshot: DocumentSnapshot,
34+
syntaxTreeManager: SyntaxTreeManager
35+
) async -> [AnnotatedTestItem] {
36+
guard snapshot.text.contains("XCTestCase") || snapshot.text.contains("test") else {
37+
// If the file contains tests that can be discovered syntactically, it needs to have a class inheriting from
38+
// `XCTestCase` or a function starting with `test`.
39+
// This is intended to filter out files that obviously do not contain tests.
40+
return []
41+
}
42+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
43+
let visitor = SyntacticSwiftXCTestScanner(snapshot: snapshot)
44+
visitor.walk(syntaxTree)
45+
return visitor.result
46+
}
47+
48+
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [TestItem] {
49+
return members.compactMap { (member) -> TestItem? in
50+
guard let function = member.decl.as(FunctionDeclSyntax.self) else {
51+
return nil
52+
}
53+
guard function.name.text.starts(with: "test") else {
54+
return nil
55+
}
56+
guard function.modifiers.map(\.name.tokenKind).allSatisfy({ $0 != .keyword(.static) && $0 != .keyword(.class) })
57+
else {
58+
// Test methods can't be static.
59+
return nil
60+
}
61+
guard function.signature.returnClause == nil, function.signature.parameterClause.parameters.isEmpty else {
62+
// Test methods can't have a return type or have parameters.
63+
// Technically we are also filtering out functions that have an explicit `Void` return type here but such
64+
// declarations are probably less common than helper functions that start with `test` and have a return type.
65+
return nil
66+
}
67+
let range = snapshot.absolutePositionRange(
68+
of: function.positionAfterSkippingLeadingTrivia..<function.endPositionBeforeTrailingTrivia
69+
)
70+
71+
return TestItem(
72+
id: "\(containerName)/\(function.name.text)()",
73+
label: "\(function.name.text)()",
74+
disabled: false,
75+
style: TestStyle.xcTest,
76+
location: Location(uri: snapshot.uri, range: range),
77+
children: [],
78+
tags: []
79+
)
80+
}
81+
}
82+
83+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
84+
guard let inheritedTypes = node.inheritanceClause?.inheritedTypes, let superclass = inheritedTypes.first else {
85+
// The class has no superclass and thus can't inherit from XCTestCase.
86+
// Continue scanning its children in case it has a nested subclass that inherits from XCTestCase.
87+
return .visitChildren
88+
}
89+
let superclassName = superclass.type.as(IdentifierTypeSyntax.self)?.name.text
90+
if superclassName == "NSObject" {
91+
// We know that the class can't be an subclass of `XCTestCase` so don't visit it.
92+
// We can't explicitly check for the `XCTestCase` superclass because the class might inherit from a class that in
93+
// turn inherits from `XCTestCase`. Resolving that inheritance hierarchy would be semantic.
94+
return .visitChildren
95+
}
96+
let testMethods = findTestMethods(in: node.memberBlock.members, containerName: node.name.text)
97+
guard !testMethods.isEmpty || superclassName == "XCTestCase" else {
98+
// Don't report a test class if it doesn't contain any test methods.
99+
return .visitChildren
100+
}
101+
let range = snapshot.absolutePositionRange(
102+
of: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia
103+
)
104+
let testItem = AnnotatedTestItem(
105+
testItem: TestItem(
106+
id: node.name.text,
107+
label: node.name.text,
108+
disabled: false,
109+
style: TestStyle.xcTest,
110+
location: Location(uri: snapshot.uri, range: range),
111+
children: testMethods,
112+
tags: []
113+
),
114+
isExtension: false
115+
)
116+
result.append(testItem)
117+
return .visitChildren
118+
}
119+
120+
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
121+
result += findTestMethods(in: node.memberBlock.members, containerName: node.extendedType.trimmedDescription)
122+
.map { AnnotatedTestItem(testItem: $0, isExtension: true) }
123+
return .visitChildren
124+
}
125+
}

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 1 addition & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,6 @@ public enum TestStyle {
2121
public static let swiftTesting = "swift-testing"
2222
}
2323

24-
public struct AnnotatedTestItem: Sendable {
25-
/// The test item to be annotated
26-
public var testItem: TestItem
27-
28-
/// Whether the `TestItem` is an extension.
29-
public var isExtension: Bool
30-
31-
public init(
32-
testItem: TestItem,
33-
isExtension: Bool
34-
) {
35-
self.testItem = testItem
36-
self.isExtension = isExtension
37-
}
38-
}
39-
4024
fileprivate extension SymbolOccurrence {
4125
/// Assuming that this is a symbol occurrence returned by the index, return whether it can constitute the definition
4226
/// of a test case.
@@ -352,117 +336,6 @@ extension SourceKitLSPServer {
352336
}
353337
}
354338

355-
/// Scans a source file for `XCTestCase` classes and test methods.
356-
///
357-
/// The syntax visitor scans from class and extension declarations that could be `XCTestCase` classes or extensions
358-
/// thereof. It then calls into `findTestMethods` to find the actual test methods.
359-
final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
360-
/// The document snapshot of the syntax tree that is being walked.
361-
private var snapshot: DocumentSnapshot
362-
363-
/// The workspace symbols representing the found `XCTestCase` subclasses and test methods.
364-
private var result: [AnnotatedTestItem] = []
365-
366-
private init(snapshot: DocumentSnapshot) {
367-
self.snapshot = snapshot
368-
super.init(viewMode: .fixedUp)
369-
}
370-
371-
public static func findTestSymbols(
372-
in snapshot: DocumentSnapshot,
373-
syntaxTreeManager: SyntaxTreeManager
374-
) async -> [AnnotatedTestItem] {
375-
guard snapshot.text.contains("XCTestCase") || snapshot.text.contains("test") else {
376-
// If the file contains tests that can be discovered syntactically, it needs to have a class inheriting from
377-
// `XCTestCase` or a function starting with `test`.
378-
// This is intended to filter out files that obviously do not contain tests.
379-
return []
380-
}
381-
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
382-
let visitor = SyntacticSwiftXCTestScanner(snapshot: snapshot)
383-
visitor.walk(syntaxTree)
384-
return visitor.result
385-
}
386-
387-
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [TestItem] {
388-
return members.compactMap { (member) -> TestItem? in
389-
guard let function = member.decl.as(FunctionDeclSyntax.self) else {
390-
return nil
391-
}
392-
guard function.name.text.starts(with: "test") else {
393-
return nil
394-
}
395-
guard function.modifiers.map(\.name.tokenKind).allSatisfy({ $0 != .keyword(.static) && $0 != .keyword(.class) })
396-
else {
397-
// Test methods can't be static.
398-
return nil
399-
}
400-
guard function.signature.returnClause == nil, function.signature.parameterClause.parameters.isEmpty else {
401-
// Test methods can't have a return type or have parameters.
402-
// Technically we are also filtering out functions that have an explicit `Void` return type here but such
403-
// declarations are probably less common than helper functions that start with `test` and have a return type.
404-
return nil
405-
}
406-
let range = snapshot.absolutePositionRange(
407-
of: function.positionAfterSkippingLeadingTrivia..<function.endPositionBeforeTrailingTrivia
408-
)
409-
410-
return TestItem(
411-
id: "\(containerName)/\(function.name.text)()",
412-
label: "\(function.name.text)()",
413-
disabled: false,
414-
style: TestStyle.xcTest,
415-
location: Location(uri: snapshot.uri, range: range),
416-
children: [],
417-
tags: []
418-
)
419-
}
420-
}
421-
422-
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
423-
guard let inheritedTypes = node.inheritanceClause?.inheritedTypes, let superclass = inheritedTypes.first else {
424-
// The class has no superclass and thus can't inherit from XCTestCase.
425-
// Continue scanning its children in case it has a nested subclass that inherits from XCTestCase.
426-
return .visitChildren
427-
}
428-
let superclassName = superclass.type.as(IdentifierTypeSyntax.self)?.name.text
429-
if superclassName == "NSObject" {
430-
// We know that the class can't be an subclass of `XCTestCase` so don't visit it.
431-
// We can't explicitly check for the `XCTestCase` superclass because the class might inherit from a class that in
432-
// turn inherits from `XCTestCase`. Resolving that inheritance hierarchy would be semantic.
433-
return .visitChildren
434-
}
435-
let testMethods = findTestMethods(in: node.memberBlock.members, containerName: node.name.text)
436-
guard !testMethods.isEmpty || superclassName == "XCTestCase" else {
437-
// Don't report a test class if it doesn't contain any test methods.
438-
return .visitChildren
439-
}
440-
let range = snapshot.absolutePositionRange(
441-
of: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia
442-
)
443-
let testItem = AnnotatedTestItem(
444-
testItem: TestItem(
445-
id: node.name.text,
446-
label: node.name.text,
447-
disabled: false,
448-
style: TestStyle.xcTest,
449-
location: Location(uri: snapshot.uri, range: range),
450-
children: testMethods,
451-
tags: []
452-
),
453-
isExtension: false
454-
)
455-
result.append(testItem)
456-
return .visitChildren
457-
}
458-
459-
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
460-
result += findTestMethods(in: node.memberBlock.members, containerName: node.extendedType.trimmedDescription)
461-
.map { AnnotatedTestItem(testItem: $0, isExtension: true) }
462-
return .visitChildren
463-
}
464-
}
465-
466339
extension TestItem {
467340
/// Use out-of-date semantic information to filter syntactic symbols.
468341
///
@@ -506,7 +379,7 @@ extension AnnotatedTestItem {
506379
}
507380
}
508381

509-
extension Array<AnnotatedTestItem> {
382+
fileprivate extension Array<AnnotatedTestItem> {
510383
/// When the test scanners discover tests in extensions they are captured in their own parent `TestItem`, not the
511384
/// `TestItem` generated from the class/struct's definition. This is largely because of the syntatic nature of the
512385
/// test scanners as they are today, which only know about tests within the context of the current file. Extensions

0 commit comments

Comments
 (0)