Skip to content

Commit e0f7221

Browse files
authored
Merge pull request #1193 from DougGregor/package-editing-refactor
Add refactoring actions for adding a test targets and products to a package manifest
2 parents 8046100 + 3674e7f commit e0f7221

File tree

7 files changed

+381
-1
lines changed

7 files changed

+381
-1
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ let package = Package(
309309
.product(name: "SwiftRefactor", package: "swift-syntax"),
310310
.product(name: "SwiftSyntax", package: "swift-syntax"),
311311
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
312+
.product(name: "SwiftPM-auto", package: "swift-package-manager"),
312313
],
313314
exclude: ["CMakeLists.txt"]
314315
),

Sources/LanguageServerProtocol/SupportTypes/VersionedTextDocumentIdentifier.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,18 @@ public struct OptionalVersionedTextDocumentIdentifier: Hashable, Codable, Sendab
5353
self.uri = uri
5454
self.version = version
5555
}
56+
57+
enum CodingKeys: CodingKey {
58+
case uri
59+
case version
60+
}
61+
62+
public func encode(to encoder: any Encoder) throws {
63+
var container = encoder.container(keyedBy: CodingKeys.self)
64+
try container.encode(self.uri, forKey: .uri)
65+
66+
// Note: we use encode(_:forKey:) here instead of encodeIf(_:forKey:)
67+
// because VSCode will drop requests without the explicit 'null'.
68+
try container.encode(self.version, forKey: .version)
69+
}
5670
}

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ target_sources(SourceKitLSP PRIVATE
2323
target_sources(SourceKitLSP PRIVATE
2424
Swift/AdjustPositionToStartOfIdentifier.swift
2525
Swift/CodeActions/ConvertIntegerLiteral.swift
26+
Swift/CodeActions/PackageManifestEdits.swift
2627
Swift/CodeActions/SyntaxCodeActionProvider.swift
2728
Swift/CodeActions/SyntaxCodeActions.swift
2829
Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift
@@ -74,5 +75,6 @@ target_link_libraries(SourceKitLSP PUBLIC
7475
SwiftSyntax::SwiftRefactor
7576
SwiftSyntax::SwiftSyntax)
7677
target_link_libraries(SourceKitLSP PRIVATE
78+
PackageModelSyntax
7779
$<$<NOT:$<PLATFORM_ID:Darwin>>:FoundationXML>)
7880

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 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 PackageModel
15+
import PackageModelSyntax
16+
import SwiftRefactor
17+
import SwiftSyntax
18+
19+
/// Syntactic code action provider to provide refactoring actions that
20+
/// edit a package manifest.
21+
struct PackageManifestEdits: SyntaxCodeActionProvider {
22+
static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] {
23+
guard let token = scope.firstToken,
24+
let call = token.findEnclosingCall()
25+
else {
26+
return []
27+
}
28+
29+
return addTestTargetActions(call: call, in: scope) + addProductActions(call: call, in: scope)
30+
}
31+
32+
/// Produce code actions to add test target(s) if we are currently on
33+
/// a target for which we know how to create a test.
34+
static func addTestTargetActions(
35+
call: FunctionCallExprSyntax,
36+
in scope: SyntaxCodeActionScope
37+
) -> [CodeAction] {
38+
guard let calledMember = call.findMemberAccessCallee(),
39+
targetsThatAllowTests.contains(calledMember),
40+
let targetName = call.findStringArgument(label: "name")
41+
else {
42+
return []
43+
}
44+
45+
do {
46+
// Describe the target we are going to create.
47+
let target = try TargetDescription(
48+
name: "\(targetName)Tests",
49+
dependencies: [.byName(name: targetName, condition: nil)],
50+
type: .test
51+
)
52+
53+
let edits = try AddTarget.addTarget(target, to: scope.file)
54+
return [
55+
CodeAction(
56+
title: "Add test target",
57+
kind: .refactor,
58+
edit: edits.asWorkspaceEdit(snapshot: scope.snapshot)
59+
)
60+
]
61+
} catch {
62+
return []
63+
}
64+
}
65+
66+
/// A list of target kinds that allow the creation of tests.
67+
static let targetsThatAllowTests: Set<String> = [
68+
"executableTarget",
69+
"macro",
70+
"target",
71+
]
72+
73+
/// Produce code actions to add a product if we are currently on
74+
/// a target for which we can create a product.
75+
static func addProductActions(
76+
call: FunctionCallExprSyntax,
77+
in scope: SyntaxCodeActionScope
78+
) -> [CodeAction] {
79+
guard let calledMember = call.findMemberAccessCallee(),
80+
targetsThatAllowProducts.contains(calledMember),
81+
let targetName = call.findStringArgument(label: "name")
82+
else {
83+
return []
84+
}
85+
86+
do {
87+
let type: ProductType =
88+
calledMember == "executableTarget"
89+
? .executable
90+
: .library(.automatic)
91+
92+
// Describe the target we are going to create.
93+
let product = try ProductDescription(
94+
name: targetName,
95+
type: type,
96+
targets: [targetName]
97+
)
98+
99+
let edits = try AddProduct.addProduct(product, to: scope.file)
100+
return [
101+
CodeAction(
102+
title: "Add product to export this target",
103+
kind: .refactor,
104+
edit: edits.asWorkspaceEdit(snapshot: scope.snapshot)
105+
)
106+
]
107+
} catch {
108+
return []
109+
}
110+
}
111+
112+
/// A list of target kinds that allow the creation of tests.
113+
static let targetsThatAllowProducts: Set<String> = [
114+
"executableTarget",
115+
"target",
116+
]
117+
}
118+
119+
fileprivate extension PackageEditResult {
120+
/// Translate package manifest edits into a workspace edit. This can
121+
/// involve both modifications to the manifest file as well as the creation
122+
/// of new files.
123+
/// `snapshot` is the latest snapshot of the `Package.swift` file.
124+
func asWorkspaceEdit(snapshot: DocumentSnapshot) -> WorkspaceEdit {
125+
// The edits to perform on the manifest itself.
126+
let manifestTextEdits = manifestEdits.map { edit in
127+
TextEdit(
128+
range: snapshot.range(of: edit.range),
129+
newText: edit.replacement
130+
)
131+
}
132+
133+
// If we couldn't figure out the manifest directory, or there are no
134+
// files to add, the only changes are the manifest edits. We're done
135+
// here.
136+
let manifestDirectoryURL = snapshot.uri.fileURL?
137+
.deletingLastPathComponent()
138+
guard let manifestDirectoryURL, !auxiliaryFiles.isEmpty else {
139+
return WorkspaceEdit(
140+
changes: [snapshot.uri: manifestTextEdits]
141+
)
142+
}
143+
144+
// Use the more full-featured documentChanges, which takes precedence
145+
// over the individual changes to documents.
146+
var documentChanges: [WorkspaceEditDocumentChange] = []
147+
148+
// Put the manifest changes into the array.
149+
documentChanges.append(
150+
.textDocumentEdit(
151+
TextDocumentEdit(
152+
textDocument: .init(snapshot.uri, version: snapshot.version),
153+
edits: manifestTextEdits.map { .textEdit($0) }
154+
)
155+
)
156+
)
157+
158+
// Create an populate all of the auxiliary files.
159+
for (relativePath, contents) in auxiliaryFiles {
160+
guard
161+
let url = URL(
162+
string: relativePath.pathString,
163+
relativeTo: manifestDirectoryURL
164+
)
165+
else {
166+
continue
167+
}
168+
169+
let documentURI = DocumentURI(url)
170+
let createFile = CreateFile(
171+
uri: documentURI
172+
)
173+
174+
let zeroPosition = Position(line: 0, utf16index: 0)
175+
let edit = TextEdit(
176+
range: zeroPosition..<zeroPosition,
177+
newText: contents.description
178+
)
179+
180+
documentChanges.append(.createFile(createFile))
181+
documentChanges.append(
182+
.textDocumentEdit(
183+
TextDocumentEdit(
184+
textDocument: .init(documentURI, version: snapshot.version),
185+
edits: [.textEdit(edit)]
186+
)
187+
)
188+
)
189+
}
190+
191+
return WorkspaceEdit(
192+
changes: [snapshot.uri: manifestTextEdits],
193+
documentChanges: documentChanges
194+
)
195+
}
196+
}
197+
198+
fileprivate extension SyntaxProtocol {
199+
// Find an enclosing call syntax expression.
200+
func findEnclosingCall() -> FunctionCallExprSyntax? {
201+
var current = Syntax(self)
202+
while true {
203+
if let call = current.as(FunctionCallExprSyntax.self) {
204+
return call
205+
}
206+
207+
if let parent = current.parent {
208+
current = parent
209+
continue
210+
}
211+
212+
return nil
213+
}
214+
}
215+
}
216+
217+
fileprivate extension FunctionCallExprSyntax {
218+
/// Find an argument with the given label that has a string literal as
219+
/// its argument.
220+
func findStringArgument(label: String) -> String? {
221+
for arg in arguments {
222+
if arg.label?.text == label {
223+
return arg.expression.as(StringLiteralExprSyntax.self)?
224+
.representedLiteralValue
225+
}
226+
}
227+
228+
return nil
229+
}
230+
231+
/// Find the callee when it is a member access expression referencing
232+
/// a declaration when a specific name.
233+
func findMemberAccessCallee() -> String? {
234+
guard
235+
let memberAccess = self.calledExpression
236+
.as(MemberAccessExprSyntax.self)
237+
else {
238+
return nil
239+
}
240+
241+
return memberAccess.declName.baseName.text
242+
}
243+
}

Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActions.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ let allSyntaxCodeActions: [SyntaxCodeActionProvider.Type] = [
2020
FormatRawStringLiteral.self,
2121
MigrateToNewIfLetSyntax.self,
2222
OpaqueParameterToGeneric.self,
23+
PackageManifestEdits.self,
2324
RemoveSeparatorsFromIntegerLiteral.self,
2425
]

Tests/LanguageServerProtocolTests/CodingTests.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ final class CodingTests: XCTestCase {
7474
OptionalVersionedTextDocumentIdentifier(uri, version: nil),
7575
json: """
7676
{
77-
"uri" : "\(urljson)"
77+
"uri" : "\(urljson)",
78+
"version" : null
7879
}
7980
"""
8081
)

0 commit comments

Comments
 (0)