Skip to content

Surface SwiftPM plugin-generated sources to editor clients #2732

Description

@thliu21

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

  1. Create a Swift package with a build-tool plugin that emits an invalid Swift
    source file.
  2. Run swift build and surface the compiler diagnostic through an editor
    integration.
  3. Follow the diagnostic into the file under .build/plugins/outputs/....
  4. Edit and save the generated file.
  5. 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?

  1. Extend SwiftPM's SourceKitLSP API so the built-in build server can mark at
    least build-tool-plugin-derived sources as generated: true.
  2. Preserve that value in SourceKit-LSP for both its built-in server and external
    BSP servers.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions