diff --git a/BuildSupport/SwiftSyntax/CMakeLists.txt b/BuildSupport/SwiftSyntax/CMakeLists.txt new file mode 100644 index 00000000000..7a3cfbfd4de --- /dev/null +++ b/BuildSupport/SwiftSyntax/CMakeLists.txt @@ -0,0 +1,17 @@ +SET(SWIFTPM_PATH_TO_SWIFT_SYNTAX_SOURCE ${CMAKE_SOURCE_DIR}/../swift-syntax) +message(STATUS "swift-syntax path: ${SWIFTPM_PATH_TO_SWIFT_SYNTAX_SOURCE}") + +include(FetchContent) + +if(NOT EXISTS "${SWIFTPM_PATH_TO_SWIFT_SYNTAX_SOURCE}") + message(SEND_ERROR "swift-syntax is required to build SwiftPM. Please run update-checkout or specify SWIFTPM_PATH_TO_SWIFT_SYNTAX_SOURCE") + return() +endif() + +# Build swift-syntax libraries with FetchContent. +# set(CMAKE_Swift_COMPILER_TARGET ${SWIFT_HOST_TRIPLE}) +set(BUILD_SHARED_LIBS OFF) + +file(TO_CMAKE_PATH "${SWIFTPM_PATH_TO_SWIFT_SYNTAX_SOURCE}" swift_syntax_path) +FetchContent_Declare(SwiftSyntax SOURCE_DIR "${swift_syntax_path}") +FetchContent_MakeAvailable(SwiftSyntax) diff --git a/CMakeLists.txt b/CMakeLists.txt index 029f3ffa337..1a9153193ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,5 +54,6 @@ find_package(SQLite3 REQUIRED) # Enable `package` modifier for the whole package. add_compile_options("$<$:-package-name;SwiftPM>") +add_subdirectory(BuildSupport/SwiftSyntax) add_subdirectory(Sources) add_subdirectory(cmake/modules) diff --git a/Package.swift b/Package.swift index 95a74ccc320..b48a7debc47 100644 --- a/Package.swift +++ b/Package.swift @@ -49,6 +49,7 @@ let swiftPMDataModelProduct = ( "PackageLoading", "PackageMetadata", "PackageModel", + "PackageModelSyntax", "SourceControl", "Workspace", ] @@ -246,6 +247,23 @@ let package = Package( swiftSettings: packageModelResourcesSettings ), + .target( + /** Primary Package model objects relationship to SwiftSyntax */ + name: "PackageModelSyntax", + dependencies: [ + "Basics", + "PackageLoading", + "PackageModel", + .product(name: "SwiftBasicFormat", package: "swift-syntax"), + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftIDEUtils", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + ], + exclude: ["CMakeLists.txt"] + ), + .target( /** Package model conventions and loading support */ name: "PackageLoading", @@ -414,10 +432,12 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "SwiftIDEUtils", package: "swift-syntax"), "Basics", "Build", "CoreCommands", "PackageGraph", + "PackageModelSyntax", "SourceControl", "Workspace", "XCBuildSupport", @@ -635,6 +655,14 @@ let package = Package( name: "PackageModelTests", dependencies: ["PackageModel", "SPMTestSupport"] ), + .testTarget( + name: "PackageModelSyntaxTests", + dependencies: [ + "PackageModelSyntax", + "SPMTestSupport", + .product(name: "SwiftIDEUtils", package: "swift-syntax"), + ] + ), .testTarget( name: "PackageGraphTests", dependencies: ["PackageGraph", "SPMTestSupport"] @@ -785,6 +813,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil { .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMinor(from: "1.2.2")), .package(url: "https://github.com/apple/swift-driver.git", branch: relatedDependenciesBranch), .package(url: "https://github.com/apple/swift-crypto.git", .upToNextMinor(from: "3.0.0")), + .package(url: "https://github.com/apple/swift-syntax.git", branch: relatedDependenciesBranch), .package(url: "https://github.com/apple/swift-system.git", .upToNextMinor(from: "1.1.1")), .package(url: "https://github.com/apple/swift-collections.git", .upToNextMinor(from: "1.0.1")), .package(url: "https://github.com/apple/swift-certificates.git", .upToNextMinor(from: "1.0.1")), @@ -795,6 +824,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil { .package(path: "../swift-argument-parser"), .package(path: "../swift-driver"), .package(path: "../swift-crypto"), + .package(path: "../swift-syntax"), .package(path: "../swift-system"), .package(path: "../swift-collections"), .package(path: "../swift-certificates"), diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index 5af06570360..1f4226f88e6 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -21,6 +21,7 @@ add_subdirectory(PackageFingerprint) add_subdirectory(PackageGraph) add_subdirectory(PackageLoading) add_subdirectory(PackageModel) +add_subdirectory(PackageModelSyntax) add_subdirectory(PackagePlugin) add_subdirectory(PackageRegistry) add_subdirectory(PackageSigning) diff --git a/Sources/Commands/CMakeLists.txt b/Sources/Commands/CMakeLists.txt index e74414c60bf..f7978b6d84e 100644 --- a/Sources/Commands/CMakeLists.txt +++ b/Sources/Commands/CMakeLists.txt @@ -7,6 +7,8 @@ # See http://swift.org/CONTRIBUTORS.txt for Swift project authors add_library(Commands + PackageCommands/AddDependency.swift + PackageCommands/AddTarget.swift PackageCommands/APIDiff.swift PackageCommands/ArchiveSource.swift PackageCommands/CompletionCommand.swift @@ -56,6 +58,7 @@ target_link_libraries(Commands PUBLIC CoreCommands LLBuildManifest PackageGraph + PackageModelSyntax SourceControl TSCBasic TSCUtility diff --git a/Sources/Commands/PackageCommands/AddDependency.swift b/Sources/Commands/PackageCommands/AddDependency.swift new file mode 100644 index 00000000000..f0b21c8b5c7 --- /dev/null +++ b/Sources/Commands/PackageCommands/AddDependency.swift @@ -0,0 +1,155 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift 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 http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Basics +import CoreCommands +import PackageModel +import PackageModelSyntax +import SwiftParser +import SwiftSyntax +import TSCBasic +import TSCUtility +import Workspace + +extension SwiftPackageCommand { + struct AddDependency: SwiftCommand { + package static let configuration = CommandConfiguration( + abstract: "Add a package dependency to the manifest") + + @Argument(help: "The URL or directory of the package to add") + var dependency: String + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Option(help: "The exact package version to depend on") + var exact: Version? + + @Option(help: "The specific package revision to depend on") + var revision: String? + + @Option(help: "The branch of the package to depend on") + var branch: String? + + @Option(help: "The package version to depend on (up to the next major version)") + var from: Version? + + @Option(help: "The package version to depend on (up to the next minor version)") + var upToNextMinorFrom: Version? + + @Option(help: "Specify upper bound on the package version range (exclusive)") + var to: Version? + + func run(_ swiftCommandState: SwiftCommandState) throws { + let workspace = try swiftCommandState.getActiveWorkspace() + + guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { + throw StringError("unknown package") + } + + // Load the manifest file + let fileSystem = workspace.fileSystem + let manifestPath = packagePath.appending("Package.swift") + let manifestContents: ByteString + do { + manifestContents = try fileSystem.readFileContents(manifestPath) + } catch { + throw StringError("cannot find package manifest in \(manifestPath)") + } + + // Parse the manifest. + let manifestSyntax = manifestContents.withData { data in + data.withUnsafeBytes { buffer in + buffer.withMemoryRebound(to: UInt8.self) { buffer in + Parser.parse(source: buffer) + } + } + } + + let identity = PackageIdentity(url: .init(dependency)) + + // Collect all of the possible version requirements. + var requirements: [PackageDependency.SourceControl.Requirement] = [] + if let exact { + requirements.append(.exact(exact)) + } + + if let branch { + requirements.append(.branch(branch)) + } + + if let revision { + requirements.append(.revision(revision)) + } + + if let from { + requirements.append(.range(.upToNextMajor(from: from))) + } + + if let upToNextMinorFrom { + requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) + } + + if requirements.count > 1 { + throw StringError("must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from") + } + + guard let firstRequirement = requirements.first else { + throw StringError("must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from") + } + + let requirement: PackageDependency.SourceControl.Requirement + if case .range(let range) = firstRequirement { + if let to { + requirement = .range(range.lowerBound.. = [ + "targets", + "swiftLanguageVersions", + "cLanguageStandard", + "cxxLanguageStandard" + ] + + /// Produce the set of source edits needed to add the given package + /// dependency to the given manifest file. + public static func addPackageDependency( + _ dependency: PackageDependency, + to manifest: SourceFileSyntax + ) throws -> PackageEditResult { + // Make sure we have a suitable tools version in the manifest. + try manifest.checkEditManifestToolsVersion() + + guard let packageCall = manifest.findCall(calleeName: "Package") else { + throw ManifestEditError.cannotFindPackage + } + + let edits = try packageCall.appendingToArrayArgument( + label: "dependencies", + trailingLabels: Self.argumentLabelsAfterDependencies, + newElement: dependency.asSyntax() + ) + + return PackageEditResult(manifestEdits: edits) + } +} diff --git a/Sources/PackageModelSyntax/AddTarget.swift b/Sources/PackageModelSyntax/AddTarget.swift new file mode 100644 index 00000000000..1157775d985 --- /dev/null +++ b/Sources/PackageModelSyntax/AddTarget.swift @@ -0,0 +1,227 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import PackageModel +import SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder + +/// Add a target to a manifest's source code. +public struct AddTarget { + /// The set of argument labels that can occur after the "targets" + /// argument in the Package initializers. + /// + /// TODO: Could we generate this from the the PackageDescription module, so + /// we don't have keep it up-to-date manually? + private static let argumentLabelsAfterTargets: Set = [ + "swiftLanguageVersions", + "cLanguageStandard", + "cxxLanguageStandard" + ] + + /// Add the given target to the manifest, producing a set of edit results + /// that updates the manifest and adds some source files to stub out the + /// new target. + public static func addTarget( + _ target: TargetDescription, + to manifest: SourceFileSyntax + ) throws -> PackageEditResult { + // Make sure we have a suitable tools version in the manifest. + try manifest.checkEditManifestToolsVersion() + + guard let packageCall = manifest.findCall(calleeName: "Package") else { + throw ManifestEditError.cannotFindPackage + } + + // Create a mutable version of target to which we can add more + // content when needed. + var target = target + + // Macro targets need to depend on a couple of libraries from + // SwiftSyntax. + if target.type == .macro { + target.dependencies.append(contentsOf: macroTargetDependencies) + } + + let manifestEdits = try packageCall.appendingToArrayArgument( + label: "targets", + trailingLabels: Self.argumentLabelsAfterTargets, + newElement: target.asSyntax() + ) + + let outerDirectory: String? = switch target.type { + case .binary, .plugin, .system: nil + case .executable, .regular, .macro: "Sources" + case .test: "Tests" + } + + guard let outerDirectory else { + return PackageEditResult(manifestEdits: manifestEdits) + } + + let outerPath = try RelativePath(validating: outerDirectory) + + /// The set of auxiliary files this refactoring will create. + var auxiliaryFiles: AuxiliaryFiles = [] + + // Add the primary source file. Every target type has this. + addPrimarySourceFile( + outerPath: outerPath, + target: target, + to: &auxiliaryFiles + ) + + // Perform any other actions that are needed for this target type. + switch target.type { + case .macro: + // Macros need a file that introduces the main entrypoint + // describing all of the macros. + auxiliaryFiles.addSourceFile( + path: outerPath.appending( + components: [target.name, "ProvidedMacros.swift"] + ), + sourceCode: """ + import SwiftCompilerPlugin + + @main + struct \(raw: target.name)Macros: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + \(raw: target.name).self, + ] + } + """ + ) + + default: break; + } + + return PackageEditResult( + manifestEdits: manifestEdits, + auxiliaryFiles: auxiliaryFiles + ) + } + + /// Add the primary source file for a target to the list of auxiliary + /// source files. + fileprivate static func addPrimarySourceFile( + outerPath: RelativePath, + target: TargetDescription, + to auxiliaryFiles: inout AuxiliaryFiles + ) { + let sourceFilePath = outerPath.appending( + components: [target.name, "\(target.name).swift"] + ) + + // Introduce imports for each of the dependencies that were specified. + var importModuleNames = target.dependencies.map { + $0.name + } + + // Add appropriate test module dependencies. + if target.type == .test { + importModuleNames.append("XCTest") + } + + let importDecls = importModuleNames.lazy.sorted().map { name in + DeclSyntax("import \(raw: name)").with(\.trailingTrivia, .newline) + } + + let imports = CodeBlockItemListSyntax { + for importDecl in importDecls { + importDecl + } + } + + let sourceFileText: SourceFileSyntax = switch target.type { + case .binary, .plugin, .system: + fatalError("should have exited above") + + case .macro: + """ + \(imports) + struct \(raw: target.name): Macro { + /// TODO: Implement one or more of the protocols that inherit + /// from Macro. The appropriate macro protocol is determined + /// by the "macro" declaration that \(raw: target.name) implements. + /// Examples include: + /// @freestanding(expression) macro --> ExpressionMacro + /// @attached(member) macro --> MemberMacro + } + """ + + case .test: + """ + \(imports) + class \(raw: target.name): XCTestCase { + func test\(raw: target.name)() { + XCTAssertEqual(42, 17 + 25) + } + } + """ + + case .regular: + """ + \(imports) + """ + + case .executable: + """ + \(imports) + @main + struct \(raw: target.name)Main { + static func main() { + print("Hello, world") + } + } + """ + } + + auxiliaryFiles.addSourceFile( + path: sourceFilePath, + sourceCode: sourceFileText + ) + } +} + +fileprivate extension TargetDescription.Dependency { + /// Retrieve the name of the dependency + var name: String { + switch self { + case .target(name: let name, condition: _), + .byName(name: let name, condition: _), + .product(name: let name, package: _, moduleAliases: _, condition: _): + name + } + } +} + +/// The array of auxiliary files that can be added by a package editing +/// operation. +fileprivate typealias AuxiliaryFiles = [(RelativePath, SourceFileSyntax)] + +fileprivate extension AuxiliaryFiles { + /// Add a source file to the list of auxiliary files. + mutating func addSourceFile( + path: RelativePath, + sourceCode: SourceFileSyntax + ) { + self.append((path, sourceCode)) + } +} + +/// The set of dependencies we need to introduce to a newly-created macro +/// target. +fileprivate let macroTargetDependencies: [TargetDescription.Dependency] = [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), +] diff --git a/Sources/PackageModelSyntax/CMakeLists.txt b/Sources/PackageModelSyntax/CMakeLists.txt new file mode 100644 index 00000000000..04f4b298bed --- /dev/null +++ b/Sources/PackageModelSyntax/CMakeLists.txt @@ -0,0 +1,60 @@ +# This source file is part of the Swift 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 http://swift.org/LICENSE.txt for license information +# See http://swift.org/CONTRIBUTORS.txt for Swift project authors + +add_library(PackageModelSyntax + AddPackageDependency.swift + AddTarget.swift + ManifestEditError.swift + ManifestSyntaxRepresentable.swift + PackageDependency+Syntax.swift + PackageEditResult.swift + SyntaxEditUtils.swift + TargetDescription+Syntax.swift +) +target_link_libraries(PackageModelSyntax PUBLIC + Basics + PackageLoading + PackageModel + + SwiftBasicFormat + SwiftDiagnostics + SwiftIDEUtils + SwiftParser + SwiftSyntax + SwiftSyntaxBuilder +) + +# NOTE(compnerd) workaround for CMake not setting up include flags yet +set_target_properties(PackageModelSyntax PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) + +install(TARGETS PackageModelSyntax + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) +set_property(GLOBAL APPEND PROPERTY SwiftPM_EXPORTS PackageModelSyntax) + +set(SWIFT_SYNTAX_MODULES + SwiftBasicFormat + SwiftParser + SwiftParserDiagnostics + SwiftDiagnostics + SwiftSyntax + SwiftOperators + SwiftSyntaxBuilder + SwiftSyntaxMacros + SwiftSyntaxMacroExpansion + SwiftCompilerPluginMessageHandling + # Support for LSP + SwiftIDEUtils + SwiftRefactor +) +export(TARGETS ${SWIFT_SYNTAX_MODULES} + NAMESPACE SwiftSyntax:: + FILE ${CMAKE_BINARY_DIR}/cmake/modules/SwiftSyntaxConfig.cmake + EXPORT_LINK_INTERFACE_LIBRARIES) \ No newline at end of file diff --git a/Sources/PackageModelSyntax/ManifestEditError.swift b/Sources/PackageModelSyntax/ManifestEditError.swift new file mode 100644 index 00000000000..cba8eb520dd --- /dev/null +++ b/Sources/PackageModelSyntax/ManifestEditError.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import PackageLoading +import PackageModel +import SwiftSyntax + +/// An error describing problems that can occur when attempting to edit a +/// package manifest programattically. +package enum ManifestEditError: Error { + case cannotFindPackage + case cannotFindArrayLiteralArgument(argumentName: String, node: Syntax) + case oldManifest(ToolsVersion) +} + +extension ToolsVersion { + /// The minimum tools version of the manifest file that we support edit + /// operations on. + static let minimumManifestEditVersion = v5_5 +} + +extension ManifestEditError: CustomStringConvertible { + package var description: String { + switch self { + case .cannotFindPackage: + "invalid manifest: unable to find 'Package' declaration" + case .cannotFindArrayLiteralArgument(argumentName: let name, node: _): + "unable to find array literal for '\(name)' argument" + case .oldManifest(let version): + "package manifest version \(version) is too old: please update to manifest version \(ToolsVersion.minimumManifestEditVersion) or newer" + } + } +} + +extension SourceFileSyntax { + /// Check that the manifest described by this source file meets the minimum + /// tools version requirements for editing the manifest. + func checkEditManifestToolsVersion() throws { + let toolsVersion = try ToolsVersionParser.parse(utf8String: description) + if toolsVersion < ToolsVersion.minimumManifestEditVersion { + throw ManifestEditError.oldManifest(toolsVersion) + } + } +} diff --git a/Sources/PackageModelSyntax/ManifestSyntaxRepresentable.swift b/Sources/PackageModelSyntax/ManifestSyntaxRepresentable.swift new file mode 100644 index 00000000000..8af1d379c18 --- /dev/null +++ b/Sources/PackageModelSyntax/ManifestSyntaxRepresentable.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift 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 http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import SwiftSyntax + +/// Describes an entity in the package model that can be represented as +/// a syntax node. +protocol ManifestSyntaxRepresentable { + /// The most specific kind of syntax node that best describes this entity + /// in the manifest. + /// + /// There might be other kinds of syntax nodes that can also represent + /// the syntax, but this is the one that a canonical manifest will use. + /// As an example, a package dependency is usually expressed as, e.g., + /// .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1") + /// + /// However, there could be other forms, e.g., this is also valid: + /// Package.Dependency.package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1") + associatedtype PreferredSyntax: SyntaxProtocol + + /// Provides a suitable syntax node to describe this entity in the package + /// model. + /// + /// The resulting syntax is a fragment that describes just this entity, + /// and it's enclosing entity will need to understand how to fit it in. + /// For example, a `PackageDependency` entity would map to syntax for + /// something like + /// .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1") + func asSyntax() -> PreferredSyntax +} + +extension String: ManifestSyntaxRepresentable { + typealias PreferredSyntax = ExprSyntax + + func asSyntax() -> ExprSyntax { "\(literal: self)" } +} diff --git a/Sources/PackageModelSyntax/PackageDependency+Syntax.swift b/Sources/PackageModelSyntax/PackageDependency+Syntax.swift new file mode 100644 index 00000000000..cf870669903 --- /dev/null +++ b/Sources/PackageModelSyntax/PackageDependency+Syntax.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift 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 http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import PackageModel +import SwiftSyntax +import SwiftParser +import struct TSCUtility.Version + +extension PackageDependency: ManifestSyntaxRepresentable { + func asSyntax() -> ExprSyntax { + switch self { + case .fileSystem(let filesystem): filesystem.asSyntax() + case .sourceControl(let sourceControl): sourceControl.asSyntax() + case .registry(let registry): registry.asSyntax() + } + } +} + +extension PackageDependency.FileSystem: ManifestSyntaxRepresentable { + func asSyntax() -> ExprSyntax { + fatalError() + } +} + +extension PackageDependency.SourceControl: ManifestSyntaxRepresentable { + func asSyntax() -> ExprSyntax { + // TODO: Not handling identity, nameForTargetDependencyResolutionOnly, + // or productFilter yet. + switch location { + case .local(let path): + ".package(path: \(literal: path.description), \(requirement.asSyntax()))" + case .remote(let url): + ".package(url: \(literal: url.description), \(requirement.asSyntax()))" + } + } +} + +extension PackageDependency.Registry: ManifestSyntaxRepresentable { + func asSyntax() -> ExprSyntax { + fatalError() + } +} + +extension PackageDependency.SourceControl.Requirement: ManifestSyntaxRepresentable { + func asSyntax() -> LabeledExprSyntax { + switch self { + case .exact(let version): + LabeledExprSyntax( + label: "exact", + expression: version.asSyntax() + ) + + case .range(let range) where range == .upToNextMajor(from: range.lowerBound): + LabeledExprSyntax( + label: "from", + expression: range.lowerBound.asSyntax() + ) + + case .range(let range): + LabeledExprSyntax( + expression: "\(range.lowerBound.asSyntax())..<\(range.upperBound.asSyntax())" as ExprSyntax + ) + + case .revision(let revision): + LabeledExprSyntax( + label: "revision", + expression: "\(literal: revision)" as ExprSyntax + ) + + case .branch(let branch): + LabeledExprSyntax( + label: "branch", + expression: "\(literal: branch)" as ExprSyntax + ) + } + } +} + +extension Version: ManifestSyntaxRepresentable { + func asSyntax() -> ExprSyntax { + return "\(literal: description)" + } +} diff --git a/Sources/PackageModelSyntax/PackageEditResult.swift b/Sources/PackageModelSyntax/PackageEditResult.swift new file mode 100644 index 00000000000..6de70765eeb --- /dev/null +++ b/Sources/PackageModelSyntax/PackageEditResult.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +@_spi(FixItApplier) import SwiftIDEUtils +import SwiftSyntax + +/// The result of editing a package, including any edits to the package +/// manifest and any new files that are introduced. +public struct PackageEditResult { + /// Edits to perform to the package manifest. + public var manifestEdits: [SourceEdit] = [] + + /// Auxiliary files to write. + public var auxiliaryFiles: [(RelativePath, SourceFileSyntax)] = [] +} + +extension PackageEditResult { + /// Apply the edits for the given manifest to the specified file system, + /// updating the manifest to the given manifest + public func applyEdits( + to filesystem: any FileSystem, + manifest: SourceFileSyntax, + manifestPath: AbsolutePath, + verbose: Bool + ) throws { + let rootPath = manifestPath.parentDirectory + + // Update the manifest + if verbose { + print("Updating package manifest at \(manifestPath.relative(to: rootPath))...", terminator: "") + } + + let updatedManifestSource = FixItApplier.apply( + edits: manifestEdits, + to: manifest + ) + try filesystem.writeFileContents( + manifestPath, + string: updatedManifestSource + ) + if verbose { + print(" done.") + } + + // Write all of the auxiliary files. + for (auxiliaryFileRelPath, auxiliaryFileSyntax) in auxiliaryFiles { + // If the file already exists, skip it. + let filePath = rootPath.appending(auxiliaryFileRelPath) + if filesystem.exists(filePath) { + if verbose { + print("Skipping \(filePath.relative(to: rootPath)) because it already exists.") + } + + continue + } + + // If the directory does not exist yet, create it. + let fileDir = filePath.parentDirectory + if !filesystem.exists(fileDir) { + if verbose { + print("Creating directory \(fileDir.relative(to: rootPath))...", terminator: "") + } + + try filesystem.createDirectory(fileDir, recursive: true) + + if verbose { + print(" done.") + } + } + + // Write the file. + if verbose { + print("Writing \(filePath.relative(to: rootPath))...", terminator: "") + } + + try filesystem.writeFileContents( + filePath, + string: auxiliaryFileSyntax.description + ) + + if verbose { + print(" done.") + } + } + } + +} diff --git a/Sources/PackageModelSyntax/SyntaxEditUtils.swift b/Sources/PackageModelSyntax/SyntaxEditUtils.swift new file mode 100644 index 00000000000..774853a46f7 --- /dev/null +++ b/Sources/PackageModelSyntax/SyntaxEditUtils.swift @@ -0,0 +1,462 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift 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 http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import PackageModel +import SwiftBasicFormat +import SwiftSyntax +import SwiftParser + +/// Default indent when we have to introduce indentation but have no context +/// to get it right. +let defaultIndent = TriviaPiece.spaces(4) + +extension Trivia { + /// Determine whether this trivia has newlines or not. + var hasNewlines: Bool { + contains(where: \.isNewline) + } +} + +/// Syntax walker to find the first occurrence of a given node kind that +/// matches a specific predicate. +private class FirstNodeFinder: SyntaxAnyVisitor { + var predicate: (Node) -> Bool + var found: Node? = nil + + init(predicate: @escaping (Node) -> Bool) { + self.predicate = predicate + super.init(viewMode: .sourceAccurate) + } + + override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { + if found != nil { + return .skipChildren + } + + if let matchedNode = node.as(Node.self), predicate(matchedNode) { + found = matchedNode + return .skipChildren + } + + return .visitChildren + } +} + +extension SyntaxProtocol { + /// Find the first node of the Self type that matches the given predicate. + static func findFirst( + in node: some SyntaxProtocol, + matching predicate: (Self) -> Bool + ) -> Self? { + withoutActuallyEscaping(predicate) { escapingPredicate in + let visitor = FirstNodeFinder(predicate: escapingPredicate) + visitor.walk(node) + return visitor.found + } + } +} + +extension FunctionCallExprSyntax { + /// Check whether this call expression has a callee that is a reference + /// to a declaration with the given name. + func hasCallee(named name: String) -> Bool { + guard let calleeDeclRef = calledExpression.as(DeclReferenceExprSyntax.self) else { + return false + } + + return calleeDeclRef.baseName.text == name + } + + /// Find a call argument based on its label. + func findArgument(labeled label: String) -> LabeledExprSyntax? { + arguments.first { $0.label?.text == label } + } +} + +extension LabeledExprListSyntax { + /// Find the index at which the one would insert a new argument given + /// the set of argument labels that could come after the argument we + /// want to insert. + func findArgumentInsertionPosition( + labelsAfter: Set + ) -> SyntaxChildrenIndex { + firstIndex { + guard let label = $0.label else { + return false + } + + return labelsAfter.contains(label.text) + } ?? endIndex + } + + /// Form a new argument list that inserts a new argument at the specified + /// position in this argument list. + /// + /// This operation will attempt to introduce trivia to match the + /// surrounding context where possible. The actual argument will be + /// created by the `generator` function, which is provided with leading + /// trivia and trailing comma it should use to match the surrounding + /// context. + func insertingArgument( + at position: SyntaxChildrenIndex, + generator: (Trivia, TokenSyntax?) -> LabeledExprSyntax + ) -> LabeledExprListSyntax { + // Turn the arguments into an array so we can manipulate them. + var arguments = Array(self) + + let positionIdx = distance(from: startIndex, to: position) + + let commaToken = TokenSyntax.commaToken() + + // Figure out leading trivia and adjust the prior argument (if there is + // one) by adding a comma, if necessary. + let leadingTrivia: Trivia + if position > startIndex { + let priorArgument = arguments[positionIdx - 1] + + // Our leading trivia will be based on the prior argument's leading + // trivia. + leadingTrivia = priorArgument.leadingTrivia + + // If the prior argument is missing a trailing comma, add one. + if priorArgument.trailingComma == nil { + arguments[positionIdx - 1].trailingComma = commaToken + } + } else if positionIdx + 1 < count { + leadingTrivia = arguments[positionIdx + 1].leadingTrivia + } else { + leadingTrivia = Trivia() + } + + // Determine whether we need a trailing comma on this argument. + let trailingComma: TokenSyntax? + if position < endIndex { + trailingComma = commaToken + } else { + trailingComma = nil + } + + // Create the argument and insert it into the argument list. + let argument = generator(leadingTrivia, trailingComma) + arguments.insert(argument, at: positionIdx) + + return LabeledExprListSyntax(arguments) + } +} + +extension SyntaxProtocol { + /// Look for a call expression to a callee with the given name. + func findCall(calleeName: String) -> FunctionCallExprSyntax? { + return FunctionCallExprSyntax.findFirst(in: self) { call in + return call.hasCallee(named: calleeName) + } + } +} + +extension ArrayExprSyntax { + /// Produce a new array literal expression that appends the given + /// element, while trying to maintain similar indentation. + func appending( + element: ExprSyntax, + outerLeadingTrivia: Trivia + ) -> ArrayExprSyntax { + var elements = self.elements + + let commaToken = TokenSyntax.commaToken() + + // If there are already elements, tack it on. + let leadingTrivia: Trivia + let trailingTrivia: Trivia + let leftSquareTrailingTrivia: Trivia + if let last = elements.last { + // The leading trivia of the new element should match that of the + // last element. + leadingTrivia = last.leadingTrivia + + // Add a trailing comma to the last element if it isn't already + // there. + if last.trailingComma == nil { + var newElements = Array(elements) + newElements[newElements.count - 1].trailingComma = commaToken + newElements[newElements.count - 1].expression.trailingTrivia = + Trivia() + newElements[newElements.count - 1].trailingTrivia = last.trailingTrivia + elements = ArrayElementListSyntax(newElements) + } + + trailingTrivia = Trivia() + leftSquareTrailingTrivia = leftSquare.trailingTrivia + } else { + leadingTrivia = outerLeadingTrivia.appending(defaultIndent) + trailingTrivia = outerLeadingTrivia + if leftSquare.trailingTrivia.hasNewlines { + leftSquareTrailingTrivia = leftSquare.trailingTrivia + } else { + leftSquareTrailingTrivia = Trivia() + } + } + + elements.append( + ArrayElementSyntax( + expression: element.with(\.leadingTrivia, leadingTrivia), + trailingComma: commaToken.with(\.trailingTrivia, trailingTrivia) + ) + ) + + let newLeftSquare = leftSquare.with( + \.trailingTrivia, + leftSquareTrailingTrivia + ) + + return with(\.elements, elements).with(\.leftSquare, newLeftSquare) + } +} + +extension ExprSyntax { + /// Find an array argument either at the top level or within a sequence + /// expression. + func findArrayArgument() -> ArrayExprSyntax? { + if let arrayExpr = self.as(ArrayExprSyntax.self) { + return arrayExpr + } + + if let sequenceExpr = self.as(SequenceExprSyntax.self) { + return sequenceExpr.elements.lazy.compactMap { + $0.findArrayArgument() + }.first + } + + return nil + } +} + +// MARK: Utilities to oeprate on arrays of array literal elements. +extension Array { + /// Append a new argument expression. + mutating func append(expression: ExprSyntax) { + // Add a comma on the prior expression, if there is one. + let leadingTrivia: Trivia? + if count > 0 { + self[count - 1].trailingComma = TokenSyntax.commaToken() + leadingTrivia = .newline + + // Adjust the first element to start with a newline + if count == 1 { + self[0].leadingTrivia = .newline + } + } else { + leadingTrivia = nil + } + + append( + ArrayElementSyntax( + leadingTrivia: leadingTrivia, + expression: expression + ) + ) + } +} + +// MARK: Utilities to operate on arrays of call arguments. + +extension Array { + /// Append a potentially labeled argument with the argument expression. + mutating func append(label: String?, expression: ExprSyntax) { + // Add a comma on the prior expression, if there is one. + let leadingTrivia: Trivia + if count > 0 { + self[count - 1].trailingComma = TokenSyntax.commaToken() + leadingTrivia = .newline + + // Adjust the first element to start with a newline + if count == 1 { + self[0].leadingTrivia = .newline + } + } else { + leadingTrivia = Trivia() + } + + // Add the new expression. + append( + LabeledExprSyntax( + label: label, + expression: expression + ).with(\.leadingTrivia, leadingTrivia) + ) + } + + /// Append a potentially labeled argument with a string literal. + mutating func append(label: String?, stringLiteral: String) { + append(label: label, expression: "\(literal: stringLiteral)") + } + + /// Append a potentially labeled argument with a string literal, but only + /// when the string literal is not nil. + mutating func appendIf(label: String?, stringLiteral: String?) { + if let stringLiteral { + append(label: label, stringLiteral: stringLiteral) + } + } + + /// Append an array literal containing elements that can be rendered + /// into expression syntax nodes. + mutating func append( + label: String?, + arrayLiteral: [T] + ) where T: ManifestSyntaxRepresentable, T.PreferredSyntax == ExprSyntax { + var elements: [ArrayElementSyntax] = [] + for element in arrayLiteral { + elements.append(expression: element.asSyntax()) + } + + // When we have more than one element in the array literal, we add + // newlines at the beginning of each element. Do the same for the + // right square bracket. + let rightSquareLeadingTrivia: Trivia = elements.count > 0 + ? .newline + : Trivia() + + let array = ArrayExprSyntax( + elements: ArrayElementListSyntax(elements), + rightSquare: .rightSquareToken( + leadingTrivia: rightSquareLeadingTrivia + ) + ) + append(label: label, expression: ExprSyntax(array)) + } + + /// Append an array literal containing elements that can be rendered + /// into expression syntax nodes. + mutating func appendIf( + label: String?, + arrayLiteral: [T]? + ) where T: ManifestSyntaxRepresentable, T.PreferredSyntax == ExprSyntax { + guard let arrayLiteral else { return } + append(label: label, arrayLiteral: arrayLiteral) + } + + /// Append an array literal containing elements that can be rendered + /// into expression syntax nodes, but only if it's not empty. + mutating func appendIfNonEmpty( + label: String?, + arrayLiteral: [T] + ) where T: ManifestSyntaxRepresentable, T.PreferredSyntax == ExprSyntax { + if arrayLiteral.isEmpty { return } + + append(label: label, arrayLiteral: arrayLiteral) + } +} + +// MARK: Utilities for adding arguments into calls. +extension FunctionCallExprSyntax { + /// Produce source edits that will add the given new element to the + /// array for an argument with the given label (if there is one), or + /// introduce a new argument with an array literal containing only the + /// new element. + /// + /// - Parameters: + /// - label: The argument label for the argument whose array will be + /// added or modified. + /// - trailingLabels: The argument labels that could follow the label, + /// which helps determine where the argument should be inserted if + /// it doesn't exist yet. + /// - newElement: The new element. + /// - Returns: the resulting source edits to make this change. + func appendingToArrayArgument( + label: String, + trailingLabels: Set, + newElement: ExprSyntax + ) throws -> [SourceEdit] { + // If there is already an argument with this name, append to the array + // literal in there. + if let arg = findArgument(labeled: label) { + guard let argArray = arg.expression.findArrayArgument() else { + throw ManifestEditError.cannotFindArrayLiteralArgument( + argumentName: label, + node: Syntax(arg.expression) + ) + } + + // Format the element appropriately for the context. + let indentation = Trivia( + pieces: arg.leadingTrivia.filter { $0.isSpaceOrTab } + ) + let format = BasicFormat( + indentationWidth: [ defaultIndent ], + initialIndentation: indentation.appending(defaultIndent) + ) + let formattedElement = newElement.formatted(using: format) + .cast(ExprSyntax.self) + + let updatedArgArray = argArray.appending( + element: formattedElement, + outerLeadingTrivia: arg.leadingTrivia + ) + return [ .replace(argArray, with: updatedArgArray.description) ] + } + + // There was no argument, so we need to create one. + + // Insert the new argument at the appropriate place in the call. + let insertionPos = arguments.findArgumentInsertionPosition( + labelsAfter: trailingLabels + ) + let newArguments = arguments.insertingArgument( + at: insertionPos + ) { (leadingTrivia, trailingComma) in + // Format the element appropriately for the context. + let indentation = Trivia(pieces: leadingTrivia.filter { $0.isSpaceOrTab }) + let format = BasicFormat( + indentationWidth: [ defaultIndent ], + initialIndentation: indentation.appending(defaultIndent) + ) + let formattedElement = newElement.formatted(using: format) + .cast(ExprSyntax.self) + + // Form the array. + let newArgument = ArrayExprSyntax( + leadingTrivia: .space, + leftSquare: .leftSquareToken( + trailingTrivia: .newline + ), + elements: ArrayElementListSyntax( + [ + ArrayElementSyntax( + expression: formattedElement, + trailingComma: .commaToken() + ) + ] + ), + rightSquare: .rightSquareToken( + leadingTrivia: leadingTrivia + ) + ) + + // Create the labeled argument for the array. + return LabeledExprSyntax( + leadingTrivia: leadingTrivia, + label: "\(raw: label)", + colon: .colonToken(), + expression: ExprSyntax(newArgument), + trailingComma: trailingComma + ) + } + + return [ + SourceEdit.replace( + arguments, + with: newArguments.description + ) + ] + } +} diff --git a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift new file mode 100644 index 00000000000..f47f6590f06 --- /dev/null +++ b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift 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 http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import PackageModel +import SwiftSyntax +import SwiftParser + + +extension TargetDescription: ManifestSyntaxRepresentable { + /// The function name in the package manifest. + private var functionName: String { + switch type { + case .binary: "binaryTarget" + case .executable: "executableTarget" + case .macro: "macro" + case .plugin: "plugin" + case .regular: "target" + case .system: "systemLibrary" + case .test: "testTarget" + } + } + + func asSyntax() -> ExprSyntax { + var arguments: [LabeledExprSyntax] = [] + arguments.append(label: "name", stringLiteral: name) + // FIXME: pluginCapability + + arguments.appendIfNonEmpty( + label: "dependencies", + arrayLiteral: dependencies + ) + + arguments.appendIf(label: "path", stringLiteral: path) + arguments.appendIf(label: "url", stringLiteral: url) + arguments.appendIfNonEmpty(label: "exclude", arrayLiteral: exclude) + arguments.appendIf(label: "sources", arrayLiteral: sources) + + // FIXME: resources + + arguments.appendIf( + label: "publicHeadersPath", + stringLiteral: publicHeadersPath + ) + + if !packageAccess { + arguments.append( + label: "packageAccess", + expression: "false" + ) + } + + // FIXME: cSettings + // FIXME: cxxSettings + // FIXME: swiftSettings + // FIXME: linkerSettings + // FIXME: plugins + + arguments.appendIf(label: "pkgConfig", stringLiteral: pkgConfig) + // FIXME: providers + + // Only for plugins + arguments.appendIf(label: "checksum", stringLiteral: checksum) + + let separateParen: String = arguments.count > 1 ? "\n" : "" + let argumentsSyntax = LabeledExprListSyntax(arguments) + return ".\(raw: functionName)(\(argumentsSyntax)\(raw: separateParen))" + } +} + +extension TargetDescription.Dependency: ManifestSyntaxRepresentable { + func asSyntax() -> ExprSyntax { + switch self { + case .byName(name: let name, condition: nil): + "\(literal: name)" + + case .target(name: let name, condition: nil): + ".target(name: \(literal: name))" + + case .product(name: let name, package: nil, moduleAliases: nil, condition: nil): + ".product(name: \(literal: name))" + + case .product(name: let name, package: let package, moduleAliases: nil, condition: nil): + ".product(name: \(literal: name), package: \(literal: package))" + + default: + fatalError() + } + } +} diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 38af1b7362b..e6016e3d554 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -96,7 +96,7 @@ public class Workspace { public let pinsStore: LoadableResult /// The file system on which the workspace will operate. - let fileSystem: any FileSystem + package let fileSystem: any FileSystem /// The host toolchain to use. private let hostToolchain: UserToolchain diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index d34e4c898fa..82c4810ef1d 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -793,6 +793,64 @@ final class PackageCommandTests: CommandsTestCase { } } + func testPackageAddDependency() throws { + try testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("PackageB") + try fs.createDirectory(path) + + try fs.writeFileContents(path.appending("Package.swift"), string: + """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + targets: [ .target(name: "client", dependencies: [ "library" ]) ] + ) + """ + ) + + _ = try execute(["add-dependency", "--branch", "main", "https://github.com/apple/swift-syntax.git"], packagePath: path) + + let manifest = path.appending("Package.swift") + XCTAssertFileExists(manifest) + let contents: String = try fs.readFileContents(manifest) + + XCTAssertMatch(contents, .contains(#".package(url: "https://github.com/apple/swift-syntax.git", branch: "main"),"#)) + } + } + + func testPackageAddTarget() throws { + try testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("PackageB") + try fs.createDirectory(path) + + try fs.writeFileContents(path.appending("Package.swift"), string: + """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client" + ) + """ + ) + + _ = try execute(["add-target", "client", "--dependencies", "MyLib", "OtherLib", "--type", "executable"], packagePath: path) + + let manifest = path.appending("Package.swift") + XCTAssertFileExists(manifest) + let contents: String = try fs.readFileContents(manifest) + + XCTAssertMatch(contents, .contains(#"targets:"#)) + XCTAssertMatch(contents, .contains(#".executableTarget"#)) + XCTAssertMatch(contents, .contains(#"name: "client""#)) + XCTAssertMatch(contents, .contains(#"dependencies:"#)) + XCTAssertMatch(contents, .contains(#""MyLib""#)) + XCTAssertMatch(contents, .contains(#""OtherLib""#)) + } + } + func testPackageEditAndUnedit() throws { try fixture(name: "Miscellaneous/PackageEdit") { fixturePath in let fooPath = fixturePath.appending("foo") @@ -1926,13 +1984,13 @@ final class PackageCommandTests: CommandsTestCase { try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in func runPlugin(flags: [String], diagnostics: [String], completion: (String, String) -> Void) throws { - let (stdout, stderr) = try SwiftPM.Package.execute(flags + ["print-diagnostics"] + diagnostics, packagePath: fixturePath) + let (stdout, stderr) = try SwiftPM.Package.execute(flags + ["print-diagnostics"] + diagnostics, packagePath: fixturePath, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) completion(stdout, stderr) } // Diagnostics.error causes SwiftPM to return a non-zero exit code, but we still need to check stdout and stderr func runPluginWithError(flags: [String], diagnostics: [String], completion: (String, String) -> Void) throws { - XCTAssertThrowsError(try SwiftPM.Package.execute(flags + ["print-diagnostics"] + diagnostics, packagePath: fixturePath)) { error in + XCTAssertThrowsError(try SwiftPM.Package.execute(flags + ["print-diagnostics"] + diagnostics, packagePath: fixturePath, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"])) { error in guard case SwiftPMError.executionFailure(_, let stdout, let stderr) = error else { return XCTFail("invalid error \(error)") } @@ -2124,28 +2182,28 @@ final class PackageCommandTests: CommandsTestCase { // Check than nothing is echoed when echoLogs is false try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in - let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build"], packagePath: fixturePath) + let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build"], packagePath: fixturePath, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) XCTAssertMatch(stdout, isEmpty) XCTAssertMatch(stderr, isEmpty) } // Check that logs are returned to the plugin when echoLogs is false try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in - let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "printlogs"], packagePath: fixturePath) + let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "printlogs"], packagePath: fixturePath, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) XCTAssertMatch(stdout, containsLogtext) XCTAssertMatch(stderr, isEmpty) } // Check that logs echoed to the console (on stderr) when echoLogs is true try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in - let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "echologs"], packagePath: fixturePath) + let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "echologs"], packagePath: fixturePath, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) XCTAssertMatch(stdout, isEmpty) XCTAssertMatch(stderr, containsLogecho) } // Check that logs are returned to the plugin and echoed to the console (on stderr) when echoLogs is true try fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in - let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "printlogs", "echologs"], packagePath: fixturePath) + let (stdout, stderr) = try SwiftPM.Package.execute(["print-diagnostics", "build", "printlogs", "echologs"], packagePath: fixturePath, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) XCTAssertMatch(stdout, containsLogtext) XCTAssertMatch(stderr, containsLogecho) } @@ -2438,14 +2496,14 @@ final class PackageCommandTests: CommandsTestCase { // Check arguments do { - let (stdout, stderr) = try SwiftPM.Package.execute(["plugin", "MyPlugin", "--foo", "--help", "--version", "--verbose"], packagePath: packageDir) + let (stdout, stderr) = try SwiftPM.Package.execute(["plugin", "MyPlugin", "--foo", "--help", "--version", "--verbose"], packagePath: packageDir, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) XCTAssertMatch(stdout, .contains("success")) XCTAssertEqual(stderr, "") } // Check default command arguments do { - let (stdout, stderr) = try SwiftPM.Package.execute(["MyPlugin", "--foo", "--help", "--version", "--verbose"], packagePath: packageDir) + let (stdout, stderr) = try SwiftPM.Package.execute(["MyPlugin", "--foo", "--help", "--version", "--verbose"], packagePath: packageDir, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) XCTAssertMatch(stdout, .contains("success")) XCTAssertEqual(stderr, "") } diff --git a/Tests/FunctionalTests/MiscellaneousTests.swift b/Tests/FunctionalTests/MiscellaneousTests.swift index b5e5dc3e28f..badae071951 100644 --- a/Tests/FunctionalTests/MiscellaneousTests.swift +++ b/Tests/FunctionalTests/MiscellaneousTests.swift @@ -665,7 +665,7 @@ class MiscellaneousTestCase: XCTestCase { func testRootPackageWithConditionals() throws { try fixture(name: "Miscellaneous/RootPackageWithConditionals") { path in - let (_, stderr) = try SwiftPM.Build.execute(packagePath: path) + let (_, stderr) = try SwiftPM.Build.execute(packagePath: path, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) let errors = stderr.components(separatedBy: .newlines).filter { !$0.contains("[logging] misuse") && !$0.isEmpty } XCTAssertEqual(errors, [], "unexpected errors: \(errors)") } diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index c9bb4794df6..47218b62df3 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -227,12 +227,12 @@ final class PluginTests: XCTestCase { let pathOfGeneratedFile = packageDir.appending(components: [".build", "plugins", "outputs", "mypackage", "SomeTarget", "Plugin", "best.txt"]) try createPackageUnderTest(packageDir: packageDir, toolsVersion: .v5_9) - let (_, stderr) = try executeSwiftBuild(packageDir) + let (_, stderr) = try executeSwiftBuild(packageDir, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) XCTAssertTrue(stderr.contains("warning: Build tool command 'empty' (applied to target 'SomeTarget') does not declare any output files"), "expected warning not emitted") XCTAssertFalse(localFileSystem.exists(pathOfGeneratedFile), "plugin generated file unexpectedly exists at \(pathOfGeneratedFile.pathString)") try createPackageUnderTest(packageDir: packageDir, toolsVersion: .v6_0) - let (_, stderr2) = try executeSwiftBuild(packageDir) + let (_, stderr2) = try executeSwiftBuild(packageDir, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) XCTAssertEqual("", stderr2) XCTAssertTrue(localFileSystem.exists(pathOfGeneratedFile), "plugin did not run, generated file does not exist at \(pathOfGeneratedFile.pathString)") } diff --git a/Tests/FunctionalTests/ResourcesTests.swift b/Tests/FunctionalTests/ResourcesTests.swift index 85c10611032..95e6a192bb4 100644 --- a/Tests/FunctionalTests/ResourcesTests.swift +++ b/Tests/FunctionalTests/ResourcesTests.swift @@ -157,7 +157,7 @@ class ResourcesTests: XCTestCase { try localFileSystem.createDirectory(resource.parentDirectory, recursive: true) try localFileSystem.writeFileContents(resource, string: "best") - let (_, stderr) = try executeSwiftBuild(packageDir) + let (_, stderr) = try executeSwiftBuild(packageDir, env: ["SWIFT_DRIVER_SWIFTSCAN_LIB" : "/this/is/a/bad/path"]) // Filter some unrelated output that could show up on stderr. let filteredStderr = stderr.components(separatedBy: "\n").filter { !$0.contains("[logging]") }.joined(separator: "\n") XCTAssertEqual(filteredStderr, "", "unexpectedly received error output: \(stderr)") diff --git a/Tests/PackageModelSyntaxTests/ManifestEditTests.swift b/Tests/PackageModelSyntaxTests/ManifestEditTests.swift new file mode 100644 index 00000000000..c61a6036007 --- /dev/null +++ b/Tests/PackageModelSyntaxTests/ManifestEditTests.swift @@ -0,0 +1,666 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import Basics +import PackageModel +import PackageModelSyntax +import SPMTestSupport +@_spi(FixItApplier) import SwiftIDEUtils +import SwiftParser +import SwiftSyntax +import struct TSCUtility.Version +import XCTest + +/// Assert that applying the given edit/refactor operation to the manifest +/// produces the expected manifest source file and the expected auxiliary +/// files. +func assertManifestRefactor( + _ originalManifest: SourceFileSyntax, + expectedManifest: SourceFileSyntax, + expectedAuxiliarySources: [RelativePath: SourceFileSyntax] = [:], + file: StaticString = #filePath, + line: UInt = #line, + operation: (SourceFileSyntax) throws -> PackageEditResult +) rethrows { + let edits = try operation(originalManifest) + let editedManifestSource = FixItApplier.apply( + edits: edits.manifestEdits, + to: originalManifest + ) + + let editedManifest = Parser.parse(source: editedManifestSource) + assertStringsEqualWithDiff( + editedManifest.description, + expectedManifest.description, + file: file, + line: line + ) + + // Check all of the auxiliary sources. + for (auxSourcePath, auxSourceSyntax) in edits.auxiliaryFiles { + guard let expectedSyntax = expectedAuxiliarySources[auxSourcePath] else { + XCTFail("unexpected auxiliary source file \(auxSourcePath)") + return + } + + assertStringsEqualWithDiff( + auxSourceSyntax.description, + expectedSyntax.description, + file: file, + line: line + ) + } + + XCTAssertEqual( + edits.auxiliaryFiles.count, + expectedAuxiliarySources.count, + "didn't get all of the auxiliary files we expected" + ) +} + +class ManifestEditTests: XCTestCase { + static let swiftSystemURL: SourceControlURL = "https://github.com/apple/swift-system.git" + static let swiftSystemPackageDependency = PackageDependency.remoteSourceControl( + identity: PackageIdentity(url: swiftSystemURL), + nameForTargetDependencyResolutionOnly: nil, + url: swiftSystemURL, + requirement: .branch("main"), productFilter: .nothing + ) + + func testAddPackageDependencyExistingComma() throws { + try assertManifestRefactor(""" + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1"), + ] + ) + """, expectedManifest: """ + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1"), + .package(url: "https://github.com/apple/swift-system.git", branch: "main"), + ] + ) + """) { manifest in + try AddPackageDependency.addPackageDependency( + PackageDependency.remoteSourceControl( + identity: PackageIdentity(url: Self.swiftSystemURL), + nameForTargetDependencyResolutionOnly: nil, + url: Self.swiftSystemURL, + requirement: .branch("main"), productFilter: .nothing + ), + to: manifest + ) + } + } + + func testAddPackageDependencyExistingNoComma() throws { + try assertManifestRefactor(""" + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1") + ] + ) + """, expectedManifest: """ + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1"), + .package(url: "https://github.com/apple/swift-system.git", exact: "510.0.0"), + ] + ) + """) { manifest in + try AddPackageDependency.addPackageDependency( + PackageDependency.remoteSourceControl( + identity: PackageIdentity(url: Self.swiftSystemURL), + nameForTargetDependencyResolutionOnly: nil, + url: Self.swiftSystemURL, + requirement: .exact("510.0.0"), + productFilter: .nothing + ), + to: manifest + ) + } + } + + func testAddPackageDependencyExistingAppended() throws { + try assertManifestRefactor(""" + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1") + ] + [] + ) + """, expectedManifest: """ + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1"), + .package(url: "https://github.com/apple/swift-system.git", from: "510.0.0"), + ] + [] + ) + """) { manifest in + let versionRange = Range.upToNextMajor(from: Version(510, 0, 0)) + + return try AddPackageDependency.addPackageDependency( + PackageDependency.remoteSourceControl( + identity: PackageIdentity(url: Self.swiftSystemURL), + nameForTargetDependencyResolutionOnly: nil, + url: Self.swiftSystemURL, + requirement: .range(versionRange), + productFilter: .nothing + ), + to: manifest + ) + } + } + + func testAddPackageDependencyExistingOneLine() throws { + try assertManifestRefactor(""" + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1") ] + ) + """, expectedManifest: """ + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.1"), .package(url: "https://github.com/apple/swift-system.git", from: "510.0.0"),] + ) + """) { manifest in + let versionRange = Range.upToNextMajor(from: Version(510, 0, 0)) + + return try AddPackageDependency.addPackageDependency( + PackageDependency.remoteSourceControl( + identity: PackageIdentity(url: Self.swiftSystemURL), + nameForTargetDependencyResolutionOnly: nil, + url: Self.swiftSystemURL, + requirement: .range(versionRange), + productFilter: .nothing + ), + to: manifest + ) + } + } + func testAddPackageDependencyExistingEmpty() throws { + try assertManifestRefactor(""" + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ ] + ) + """, + expectedManifest: """ + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/apple/swift-system.git", "508.0.0" ..< "510.0.0"), + ] + ) + """) { manifest in + try AddPackageDependency.addPackageDependency( + PackageDependency.remoteSourceControl( + identity: PackageIdentity(url: Self.swiftSystemURL), + nameForTargetDependencyResolutionOnly: nil, + url: Self.swiftSystemURL, + requirement: .range(Version(508,0,0).. ExpressionMacro + /// @attached(member) macro --> MemberMacro + } + """, + RelativePath("Sources/MyMacro/ProvidedMacros.swift") : """ + import SwiftCompilerPlugin + + @main + struct MyMacroMacros: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + MyMacro.self, + ] + } + """ + ] + ) { manifest in + try AddTarget.addTarget( + TargetDescription(name: "MyMacro", type: .macro), + to: manifest + ) + } + } + +} + + +// FIXME: Copy-paste from _SwiftSyntaxTestSupport + +/// Asserts that the two strings are equal, providing Unix `diff`-style output if they are not. +/// +/// - Parameters: +/// - actual: The actual string. +/// - expected: The expected string. +/// - message: An optional description of the failure. +/// - additionalInfo: Additional information about the failed test case that will be printed after the diff +/// - file: The file in which failure occurred. Defaults to the file name of the test case in +/// which this function was called. +/// - line: The line number on which failure occurred. Defaults to the line number on which this +/// function was called. +public func assertStringsEqualWithDiff( + _ actual: String, + _ expected: String, + _ message: String = "", + additionalInfo: @autoclosure () -> String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) { + if actual == expected { + return + } + + failStringsEqualWithDiff( + actual, + expected, + message, + additionalInfo: additionalInfo(), + file: file, + line: line + ) +} + +/// Asserts that the two data are equal, providing Unix `diff`-style output if they are not. +/// +/// - Parameters: +/// - actual: The actual string. +/// - expected: The expected string. +/// - message: An optional description of the failure. +/// - additionalInfo: Additional information about the failed test case that will be printed after the diff +/// - file: The file in which failure occurred. Defaults to the file name of the test case in +/// which this function was called. +/// - line: The line number on which failure occurred. Defaults to the line number on which this +/// function was called. +public func assertDataEqualWithDiff( + _ actual: Data, + _ expected: Data, + _ message: String = "", + additionalInfo: @autoclosure () -> String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) { + if actual == expected { + return + } + + // NOTE: Converting to `Stirng` here looses invalid UTF8 sequence difference, + // but at least we can see something is different. + failStringsEqualWithDiff( + String(decoding: actual, as: UTF8.self), + String(decoding: expected, as: UTF8.self), + message, + additionalInfo: additionalInfo(), + file: file, + line: line + ) +} + +/// `XCTFail` with `diff`-style output. +public func failStringsEqualWithDiff( + _ actual: String, + _ expected: String, + _ message: String = "", + additionalInfo: @autoclosure () -> String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) { + let stringComparison: String + + // Use `CollectionDifference` on supported platforms to get `diff`-like line-based output. On + // older platforms, fall back to simple string comparison. + if #available(macOS 10.15, *) { + let actualLines = actual.components(separatedBy: .newlines) + let expectedLines = expected.components(separatedBy: .newlines) + + let difference = actualLines.difference(from: expectedLines) + + var result = "" + + var insertions = [Int: String]() + var removals = [Int: String]() + + for change in difference { + switch change { + case .insert(let offset, let element, _): + insertions[offset] = element + case .remove(let offset, let element, _): + removals[offset] = element + } + } + + var expectedLine = 0 + var actualLine = 0 + + while expectedLine < expectedLines.count || actualLine < actualLines.count { + if let removal = removals[expectedLine] { + result += "–\(removal)\n" + expectedLine += 1 + } else if let insertion = insertions[actualLine] { + result += "+\(insertion)\n" + actualLine += 1 + } else { + result += " \(expectedLines[expectedLine])\n" + expectedLine += 1 + actualLine += 1 + } + } + + stringComparison = result + } else { + // Fall back to simple message on platforms that don't support CollectionDifference. + stringComparison = """ + Expected: + \(expected) + + Actual: + \(actual) + """ + } + + var fullMessage = """ + \(message.isEmpty ? "Actual output does not match the expected" : message) + \(stringComparison) + """ + if let additional = additionalInfo() { + fullMessage = """ + \(fullMessage) + \(additional) + """ + } + XCTFail(fullMessage, file: file, line: line) +}