Description
A SwiftPM build-tool plugin can generate a Swift source file under
.build/plugins/outputs/.... When that file contains a compiler error, following
the diagnostic opens the generated file like an ordinary editable source file.
Saving a fix there is misleading because the next plugin run regenerates and
overwrites it.
This scenario was reported in swiftlang/swift-package-manager#10288 and
reproduced with swift build using SwiftPM / Swift 6.3.3 on macOS 26.0:
swiftlang/swift-package-manager#10288
swiftlang/swift-package-manager#10331 explored changing filesystem permissions.
Review there pointed out that SwiftPM should not override permissions
intentionally selected by a plugin and suggested solving the problem through
generated-source metadata and editor integration instead:
swiftlang/swift-package-manager#10331 (review)
BSP already defines SourceItem.generated for sources that are automatically
generated by the build and are not intended to be manually edited:
https://github.com/swiftlang/swift-tools-protocols/blob/5b3c1b600da965f08f56ece1a714e1821f29ef34/Sources/BuildServerProtocol/Messages/BuildTargetSourcesRequest.swift#L57-L70
On current SourceKit-LSP main, however:
- The built-in SwiftPM build server emits
generated: false for every source:
|
var result: [SourcesItem] = [] |
|
// TODO: Query The SwiftPM build server for the document's language and add it to SourceItem.data |
|
// (https://github.com/swiftlang/sourcekit-lsp/issues/1267) |
|
for target in request.targets { |
|
if target == .forPackageManifest { |
|
let versionSpecificManifests = try? FileManager.default.contentsOfDirectory( |
|
at: projectRoot, |
|
includingPropertiesForKeys: nil |
|
).compactMap { (url) -> SourceItem? in |
|
guard (try? Self.versionSpecificPackageManifestNameRegex.wholeMatch(in: url.lastPathComponent)) != nil else { |
|
return nil |
|
} |
|
return SourceItem( |
|
uri: DocumentURI(url), |
|
kind: .file, |
|
generated: false |
|
) |
|
} |
|
let packageManifest = SourceItem( |
|
uri: DocumentURI(projectRoot.appending(component: "Package.swift")), |
|
kind: .file, |
|
generated: false |
|
) |
|
result.append( |
|
SourcesItem( |
|
target: target, |
|
sources: [packageManifest] + (versionSpecificManifests ?? []) |
|
) |
|
) |
|
} |
|
guard let swiftPMTarget = self.swiftPMTargets[target] else { |
|
continue |
|
} |
|
var sources: [SourceItem] = [] |
|
for sourceItem in swiftPMTarget.sources { |
|
let outputPath: String? = |
|
if let outputFile = sourceItem.outputFile { |
|
orLog("Getting file path of output file") { try outputFile.filePath } |
|
} else if swiftPMTarget.compiler == .swift { |
|
indexUnitOutputPath(forSwiftFile: DocumentURI(sourceItem.sourceFile)) |
|
} else { |
|
nil |
|
} |
|
sources.append( |
|
SourceItem( |
|
uri: DocumentURI(sourceItem.sourceFile), |
|
kind: .file, |
|
generated: false, |
|
dataKind: .sourceKit, |
|
data: SourceKitSourceItemData(outputPath: outputPath).encodeToLSPAny() |
|
) |
|
) |
|
} |
|
for url in swiftPMTarget.headers { |
|
sources.append( |
|
SourceItem( |
|
uri: DocumentURI(url), |
|
kind: .file, |
|
generated: false, |
|
dataKind: .sourceKit, |
|
data: SourceKitSourceItemData(kind: .header).encodeToLSPAny() |
|
) |
|
) |
|
} |
|
for url in (swiftPMTarget.resources + swiftPMTarget.ignored + swiftPMTarget.others) { |
|
var data: SourceKitSourceItemData? = nil |
|
if url.isDirectory, url.pathExtension == "docc" { |
|
data = SourceKitSourceItemData(kind: .doccCatalog) |
|
} |
|
sources.append( |
|
SourceItem( |
|
uri: DocumentURI(url), |
|
kind: url.isDirectory ? .directory : .file, |
|
generated: false, |
|
dataKind: data != nil ? .sourceKit : nil, |
|
data: data?.encodeToLSPAny() |
|
) |
|
) |
|
} |
|
result.append(SourcesItem(target: target, sources: sources)) |
|
} |
|
return BuildTargetSourcesResponse(items: result) |
BuildServerManager.SourceFileInfo does not retain the generated value when
consuming buildTarget/sources:
|
package struct SourceFileInfo: Sendable { |
|
/// Maps the targets that this source file is a member of to the output path the file has within that target. |
|
/// |
|
/// The value in the dictionary can be: |
|
/// - `.path` if the build server supports output paths and produced a result |
|
/// - `.notSupported` if the build server does not support output paths. |
|
/// - `nil` if the build server supports output paths but did not return an output path for this file in this target. |
|
package var targetsToOutputPath: [BuildTargetIdentifier: OutputPath?] |
|
|
|
/// The targets that this source file is a member of |
|
package var targets: some Collection<BuildTargetIdentifier> & Sendable { targetsToOutputPath.keys } |
|
|
|
/// `true` if this file belongs to the root project that the user is working on. It is false, if the file belongs |
|
/// to a dependency of the project. |
|
package var isPartOfRootProject: Bool |
|
|
|
/// Whether the file might contain test cases. This property is an over-approximation. It might be true for files |
|
/// from non-test targets or files that don't actually contain any tests. |
|
package var mayContainTests: Bool |
|
|
|
/// Source files returned here fall into two categories: |
|
/// - Buildable source files are files that can be built by the build server and that make sense to background index |
|
/// - Non-buildable source files include eg. the SwiftPM package manifest or header files. We have sufficient |
|
/// compiler arguments for these files to provide semantic editor functionality but we can't build them. |
|
package var isBuildable: Bool |
|
|
|
/// If this source item gets copied to a different destination during preparation, the destinations it will be copied |
|
/// to. |
|
package var copyDestinations: Set<DocumentURI> |
|
|
|
fileprivate func merging(_ other: SourceFileInfo?) -> SourceFileInfo { |
|
guard let other else { |
|
return self |
|
} |
|
let mergedTargetsToOutputPaths = targetsToOutputPath.merging( |
|
other.targetsToOutputPath, |
|
uniquingKeysWith: { lhs, rhs in |
|
if lhs == rhs { |
|
return lhs |
|
} |
|
logger.error("Received mismatching output files: \(lhs?.forLogging) vs \(rhs?.forLogging)") |
|
// Deterministically pick an output file if they mismatch. But really, this shouldn't happen. |
|
switch (lhs, rhs) { |
|
case (let lhs?, nil): return lhs |
|
case (nil, let rhs?): return rhs |
|
case (nil, nil): return nil // Should be handled above already |
|
case (let lhs?, let rhs?): return min(lhs, rhs) |
|
} |
|
} |
|
) |
|
return SourceFileInfo( |
|
targetsToOutputPath: mergedTargetsToOutputPaths, |
|
isPartOfRootProject: other.isPartOfRootProject || isPartOfRootProject, |
|
mayContainTests: other.mayContainTests || mayContainTests, |
|
isBuildable: other.isBuildable || isBuildable, |
|
copyDestinations: copyDestinations.union(other.copyDestinations) |
|
) |
|
} |
|
} |
|
let sourcesItems = try await self.sourceFiles(in: Set(targets.keys)) |
|
|
|
var files: [DocumentURI: SourceFileInfo] = [:] |
|
var directories: [DocumentURI: (pathComponents: [String]?, info: SourceFileInfo)] = [:] |
|
for sourcesItem in sourcesItems { |
|
let target = targets[sourcesItem.target]?.target |
|
let isPartOfRootProject = !(target?.tags.contains(.dependency) ?? false) |
|
let mayContainTests = target?.tags.contains(.test) ?? true |
|
for sourceItem in sourcesItem.sources { |
|
let sourceKitData = sourceItem.sourceKitData |
|
let outputPath: OutputPath? = |
|
if !(await self.initializationData?.outputPathsProvider ?? false) { |
|
.notSupported |
|
} else if let outputPath = sourceKitData?.outputPath { |
|
.path(outputPath) |
|
} else { |
|
nil |
|
} |
|
let info = SourceFileInfo( |
|
targetsToOutputPath: [sourcesItem.target: outputPath], |
|
isPartOfRootProject: isPartOfRootProject, |
|
mayContainTests: mayContainTests, |
|
isBuildable: !(target?.tags.contains(.notBuildable) ?? false) |
|
&& (sourceKitData?.kind ?? .source) == .source, |
|
copyDestinations: Set(sourceKitData?.copyDestinations ?? []) |
|
) |
|
switch sourceItem.kind { |
|
case .file: |
|
files[sourceItem.uri] = info.merging(files[sourceItem.uri]) |
|
case .directory: |
|
directories[sourceItem.uri] = ( |
|
sourceItem.uri.fileURL?.pathComponents, info.merging(directories[sourceItem.uri]?.info) |
|
) |
|
} |
|
} |
|
} |
|
return SourceFilesAndDirectories(files: files, directories: directories) |
|
} |
- SwiftPM knows which sources are plugin-derived, but its SourceKitLSP API
currently flattens them without exposing that provenance:
https://github.com/swiftlang/swift-package-manager/blob/52e885f0dc4283ebf64eb1db74d251de158b9861/Sources/SourceKitLSPAPI/BuildDescription.swift#L35-L47
https://github.com/swiftlang/swift-package-manager/blob/52e885f0dc4283ebf64eb1db74d251de158b9861/Sources/SourceKitLSPAPI/BuildDescription.swift#L155-L163
Reproduction scenario
- Create a Swift package with a build-tool plugin that emits an invalid Swift
source file.
- Run
swift build and surface the compiler diagnostic through an editor
integration.
- Follow the diagnostic into the file under
.build/plugins/outputs/....
- Edit and save the generated file.
- Run the plugin/build again and observe that the edit is overwritten.
Expected: The editor identifies the document as generated and either presents
it read-only or gives an explicit guard before allowing an edit or save.
Actual: It is presented like an ordinary writable source file.
Proposed direction / questions
Would the following component split be appropriate?
- Extend SwiftPM's SourceKitLSP API so the built-in build server can mark at
least build-tool-plugin-derived sources as generated: true.
- Preserve that value in SourceKit-LSP for both its built-in server and external
BSP servers.
- Define how SourceKit-LSP communicates or applies the generated-document state
for editor clients.
For the editor-facing step, would maintainers prefer a SourceKit-LSP-specific
client extension, or could a read-only virtual-document mechanism such as LSP
3.18 workspace/textDocumentContent be used without losing diagnostic
locations, build settings, navigation, or semantic functionality?
The desired solution should leave on-disk permissions unchanged so plugins can
continue to control their own outputs and SwiftPM can regenerate and clean them
normally.
Related
Description
A SwiftPM build-tool plugin can generate a Swift source file under
.build/plugins/outputs/.... When that file contains a compiler error, followingthe diagnostic opens the generated file like an ordinary editable source file.
Saving a fix there is misleading because the next plugin run regenerates and
overwrites it.
This scenario was reported in swiftlang/swift-package-manager#10288 and
reproduced with
swift buildusing SwiftPM / Swift 6.3.3 on macOS 26.0:swiftlang/swift-package-manager#10288
swiftlang/swift-package-manager#10331 explored changing filesystem permissions.
Review there pointed out that SwiftPM should not override permissions
intentionally selected by a plugin and suggested solving the problem through
generated-source metadata and editor integration instead:
swiftlang/swift-package-manager#10331 (review)
BSP already defines
SourceItem.generatedfor sources that are automaticallygenerated by the build and are not intended to be manually edited:
https://github.com/swiftlang/swift-tools-protocols/blob/5b3c1b600da965f08f56ece1a714e1821f29ef34/Sources/BuildServerProtocol/Messages/BuildTargetSourcesRequest.swift#L57-L70
On current SourceKit-LSP
main, however:generated: falsefor every source:sourcekit-lsp/Sources/BuildServerIntegration/SwiftPMBuildServer.swift
Lines 648 to 729 in 0764123
BuildServerManager.SourceFileInfodoes not retain thegeneratedvalue whenconsuming
buildTarget/sources:sourcekit-lsp/Sources/BuildServerIntegration/BuildServerManager.swift
Lines 54 to 112 in 0764123
sourcekit-lsp/Sources/BuildServerIntegration/BuildServerManager.swift
Lines 1684 to 1721 in 0764123
currently flattens them without exposing that provenance:
https://github.com/swiftlang/swift-package-manager/blob/52e885f0dc4283ebf64eb1db74d251de158b9861/Sources/SourceKitLSPAPI/BuildDescription.swift#L35-L47
https://github.com/swiftlang/swift-package-manager/blob/52e885f0dc4283ebf64eb1db74d251de158b9861/Sources/SourceKitLSPAPI/BuildDescription.swift#L155-L163
Reproduction scenario
source file.
swift buildand surface the compiler diagnostic through an editorintegration.
.build/plugins/outputs/....Expected: The editor identifies the document as generated and either presents
it read-only or gives an explicit guard before allowing an edit or save.
Actual: It is presented like an ordinary writable source file.
Proposed direction / questions
Would the following component split be appropriate?
least build-tool-plugin-derived sources as
generated: true.BSP servers.
for editor clients.
For the editor-facing step, would maintainers prefer a SourceKit-LSP-specific
client extension, or could a read-only virtual-document mechanism such as LSP
3.18
workspace/textDocumentContentbe used without losing diagnosticlocations, build settings, navigation, or semantic functionality?
The desired solution should leave on-disk permissions unchanged so plugins can
continue to control their own outputs and SwiftPM can regenerate and clean them
normally.
Related
Generated sources should not be writable swift-package-manager#10288
Make generated Swift sources read-only between builds swift-package-manager#10331
protection:
Source generated by build plugin is ignored #665