Add support for SPM dependencies with Core Data models (#4237)

* Improve opaque folders detection like xcdatamodeld, docc and playground

* Add test_isInOpaqueDirectory

* Add code review comment

* Add fixture for CoreData example

* chore: lint

* Fix duplicated core data models in build phase

* Build to specific simulator

Co-authored-by: Daniele Formichelli <df@bendingspoons.com>
This commit is contained in:
Alfredo Delli Bovi 2022-03-22 12:50:59 +01:00 committed by GitHub
parent a72d6ae855
commit 6001d23278
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 148 additions and 193 deletions

View File

@ -7,6 +7,8 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
### Added
- Add `.optional` option to `.cloud` [#4262](https://github.com/tuist/tuist/pull/4262) by [@fortmarek](https://github.com/fortmarek).
- Add support for SPM dependencies with Core Data models. [#4237](https://github.com/tuist/tuist/pull/4237) by [@adellibovi](https://github.com/adellibovi)
- Add support for Core Data models in Resources. [#4237](https://github.com/tuist/tuist/pull/4237) by [@adellibovi](https://github.com/adellibovi)
### Fixed

View File

@ -67,7 +67,9 @@ extension Target {
let paths: [AbsolutePath]
do {
paths = try base.throwingGlob(sourcePath.basename)
paths = try FileHandler.shared
.throwingGlob(base, glob: sourcePath.basename)
.filter { !$0.isInOpaqueDirectory }
} catch let GlobError.nonExistentDirectory(invalidGlob) {
paths = []
invalidGlobs.append(invalidGlob)

View File

@ -194,9 +194,7 @@ final class BuildPhaseGenerator: BuildPhaseGenerating {
pbxTarget.buildPhases.append(sourcesBuildPhase)
var buildFilesCache = Set<AbsolutePath>()
// Ignore DocC Swift tutorial files from `Sources`
let sortedFiles = files
.filter { !fileElements.isDocCTutorialFile(path: $0.path) }
.sorted(by: { $0.path < $1.path })
var pbxBuildFiles = [PBXBuildFile]()
@ -378,14 +376,6 @@ final class BuildPhaseGenerator: BuildPhaseGenerating {
let pathString = buildFilePath.pathString
let isLocalized = pathString.contains(".lproj/")
let isLproj = buildFilePath.extension == "lproj"
let isWithinAssets = pathString.contains(".xcassets/") || pathString.contains(".scnassets/")
/// Assets that are part of a .xcassets or .scnassets folder
/// are not added individually. The whole folder is added
/// instead as a group.
if isWithinAssets {
return
}
var element: (element: PBXFileElement, path: AbsolutePath)?

View File

@ -367,7 +367,7 @@ class ProjectFileElements {
toGroup: toGroup,
pbxproj: pbxproj
)
} else if !(isFolderTypeFileSource(path: absolutePath) || isLeaf) {
} else if !isLeaf {
return addGroupElement(
from: from,
folderAbsolutePath: absolutePath,
@ -376,17 +376,6 @@ class ProjectFileElements {
toGroup: toGroup,
pbxproj: pbxproj
)
} else if isPlayground(path: absolutePath) {
addPlayground(
from: from,
fileAbsolutePath: absolutePath,
fileRelativePath: relativePath,
name: name,
toGroup: toGroup,
pbxproj: pbxproj
)
return nil
} else {
addFileElement(
from: from,
@ -521,32 +510,13 @@ class ProjectFileElements {
pbxproj: PBXProj
) {
let lastKnownFileType = fileAbsolutePath.extension.flatMap { Xcode.filetype(extension: $0) }
let file = PBXFileReference(
sourceTree: .group,
name: name,
lastKnownFileType: lastKnownFileType,
path: fileRelativePath.pathString
)
pbxproj.add(object: file)
toGroup.children.append(file)
elements[fileAbsolutePath] = file
}
func addPlayground(
from _: AbsolutePath,
fileAbsolutePath: AbsolutePath,
fileRelativePath: RelativePath,
name: String?,
toGroup: PBXGroup,
pbxproj: PBXProj
) {
let lastKnownFileType = fileAbsolutePath.extension.flatMap { Xcode.filetype(extension: $0) }
let xcLanguageSpecificationIdentifier = lastKnownFileType == "file.playground" ? "xcode.lang.swift" : nil
let file = PBXFileReference(
sourceTree: .group,
name: name,
lastKnownFileType: lastKnownFileType,
path: fileRelativePath.pathString,
xcLanguageSpecificationIdentifier: "xcode.lang.swift"
xcLanguageSpecificationIdentifier: xcLanguageSpecificationIdentifier
)
pbxproj.add(object: file)
toGroup.children.append(file)
@ -604,35 +574,10 @@ class ProjectFileElements {
path.extension == "lproj"
}
func isPlayground(path: AbsolutePath) -> Bool {
path.extension == "playground"
}
func isVersionGroup(path: AbsolutePath) -> Bool {
path.extension == "xcdatamodeld"
}
func isFolderTypeFileSource(path: AbsolutePath) -> Bool {
isXcassets(path: path) || isDocCArchive(path: path) || isScnassets(path: path)
}
func isXcassets(path: AbsolutePath) -> Bool {
path.extension == "xcassets"
}
func isDocCArchive(path: AbsolutePath) -> Bool {
path.extension == "docc"
}
func isDocCTutorialFile(path: AbsolutePath) -> Bool {
// Skip the initial DocC source directory
!isDocCArchive(path: path) && path.pathString.contains(".docc")
}
func isScnassets(path: AbsolutePath) -> Bool {
path.extension == "scnassets"
}
/// Normalizes a path. Some paths have no direct representation in Xcode,
/// like localizable files. This method normalizes those and returns a project
/// representable path.

View File

@ -8,6 +8,7 @@ public struct Target: Equatable, Hashable, Comparable, Codable {
// in order to compile the documentation archive (including Tutorials, Articles, etc.)
public static let validSourceExtensions: [String] = [
"m", "swift", "mm", "cpp", "cc", "c", "d", "s", "intentdefinition", "xcmappingmodel", "metal", "mlmodel", "docc",
"playground",
]
public static let validFolderExtensions: [String] = [
"framework", "bundle", "app", "xcassets", "appiconset", "scnassets",

View File

@ -22,10 +22,27 @@ extension TuistGraph.CoreDataModel {
} else if CoreDataVersionExtractor.isVersioned(at: modelPath) {
return try CoreDataVersionExtractor.version(fromVersionFileAtPath: modelPath)
} else {
return modelPath.url.lastPathComponent.dropSuffix(".xcdatamodeld")
return modelPath.basenameWithoutExt
}
}()
return CoreDataModel(path: modelPath, versions: versions, currentVersion: currentVersion)
}
}
extension TuistGraph.CoreDataModel {
/// Maps a `.xcdatamodeld` package into a TuistGraph.CoreDataModel instance.
/// - Parameters:
/// - path: The path for a `.xcdatamodeld` package.
static func from(path modelPath: AbsolutePath) throws -> TuistGraph.CoreDataModel {
let versions = FileHandler.shared.glob(modelPath, glob: "*.xcdatamodel")
let currentVersion: String = try {
if CoreDataVersionExtractor.isVersioned(at: modelPath) {
return try CoreDataVersionExtractor.version(fromVersionFileAtPath: modelPath)
} else {
return modelPath.basenameWithoutExt
}
}()
return CoreDataModel(path: modelPath, versions: versions, currentVersion: currentVersion)
}
}

View File

@ -26,7 +26,9 @@ extension TuistGraph.ResourceFileElement {
excluded.formUnion(globs)
}
let files = try FileHandler.shared.throwingGlob(AbsolutePath.root, glob: String(path.pathString.dropFirst()))
let files = try FileHandler.shared
.throwingGlob(.root, glob: String(path.pathString.dropFirst()))
.filter { !$0.isInOpaqueDirectory }
.filter(includeFiles)
.filter { !excluded.contains($0) }

View File

@ -57,11 +57,10 @@ extension TuistGraph.Target {
generatorPaths: generatorPaths
)
var (resources, resourcesPlaygrounds, invalidResourceGlobs) = try resourcesAndPlaygrounds(
let (resources, resourcesPlaygrounds, resourcesCoreDatas, invalidResourceGlobs) = try resourcesAndOthers(
manifest: manifest,
generatorPaths: generatorPaths
)
resources = resourcesFlatteningBundles(resources: resources)
if !invalidResourceGlobs.isEmpty {
throw TargetManifestMapperError.invalidResourcesGlob(targetName: name, invalidGlobs: invalidResourceGlobs)
@ -79,7 +78,7 @@ extension TuistGraph.Target {
let coreDataModels = try manifest.coreDataModels.map {
try TuistGraph.CoreDataModel.from(manifest: $0, generatorPaths: generatorPaths)
}
} + resourcesCoreDatas.map { try TuistGraph.CoreDataModel.from(path: $0) }
let scripts = try manifest.scripts.map {
try TuistGraph.TargetScript.from(manifest: $0, generatorPaths: generatorPaths)
@ -120,18 +119,24 @@ extension TuistGraph.Target {
// MARK: - Fileprivate
fileprivate static func resourcesAndPlaygrounds(
fileprivate static func resourcesAndOthers(
manifest: ProjectDescription.Target,
generatorPaths: GeneratorPaths
// swiftlint:disable:next large_tuple
) throws -> (resources: [TuistGraph.ResourceFileElement], playgrounds: [AbsolutePath], invalidResourceGlobs: [InvalidGlob]) {
) throws -> (
resources: [TuistGraph.ResourceFileElement],
playgrounds: [AbsolutePath],
coreDataModels: [AbsolutePath],
invalidResourceGlobs: [InvalidGlob]
) {
let resourceFilter = { (path: AbsolutePath) -> Bool in
TuistGraph.Target.isResource(path: path)
}
var invalidResourceGlobs: [InvalidGlob] = []
var resourcesWithoutPlaygrounds: [TuistGraph.ResourceFileElement] = []
var filteredResources: [TuistGraph.ResourceFileElement] = []
var playgrounds: Set<AbsolutePath> = []
var coreDataModels: Set<AbsolutePath> = []
let allResources = try (manifest.resources?.resources ?? []).flatMap { manifest -> [TuistGraph.ResourceFileElement] in
do {
@ -146,42 +151,29 @@ extension TuistGraph.Target {
}
}
allResources.forEach { fileElement in
allResources
.forEach { fileElement in
switch fileElement {
case .folderReference: resourcesWithoutPlaygrounds.append(fileElement)
case .folderReference: filteredResources.append(fileElement)
case let .file(path, _):
if path.pathString.contains(".playground/") {
playgrounds.insert(path.upToComponentMatching(extension: "playground"))
if path.extension == "playground" {
playgrounds.insert(path)
} else if path.extension == "xcdatamodeld" {
coreDataModels.insert(path)
} else {
resourcesWithoutPlaygrounds.append(fileElement)
filteredResources.append(fileElement)
}
}
}
return (
resources: resourcesWithoutPlaygrounds,
resources: filteredResources,
playgrounds: Array(playgrounds),
coreDataModels: Array(coreDataModels),
invalidResourceGlobs: invalidResourceGlobs
)
}
fileprivate static func resourcesFlatteningBundles(resources: [TuistGraph.ResourceFileElement])
-> [TuistGraph.ResourceFileElement]
{
Array(resources.reduce(into: Set<TuistGraph.ResourceFileElement>()) { flattenedResources, resourceElement in
switch resourceElement {
case let .file(path, _):
if path.pathString.contains(".bundle/") {
flattenedResources.formUnion([.file(path: path.upToComponentMatching(extension: "bundle"))])
} else {
flattenedResources.formUnion([resourceElement])
}
case .folderReference:
flattenedResources.formUnion([resourceElement])
}
})
}
fileprivate static func sourcesAndPlaygrounds(
manifest: ProjectDescription.Target,
targetName: String,
@ -204,8 +196,8 @@ extension TuistGraph.Target {
} ?? [])
allSources.forEach { sourceFile in
if sourceFile.path.pathString.contains(".playground/") {
playgrounds.insert(sourceFile.path.upToComponentMatching(extension: "playground"))
if sourceFile.path.extension == "playground" {
playgrounds.insert(sourceFile.path)
} else {
sourcesWithoutPlaygrounds.append(sourceFile)
}

View File

@ -66,6 +66,31 @@ extension AbsolutePath {
return UTTypeConformsTo(uti.takeRetainedValue(), kUTTypePackage)
}
private static let opaqueDirectoriesExtensions: Set<String> = [
"xcassets",
"scnassets",
"xcdatamodeld",
"docc",
"playground",
"bundle",
]
/// An opaque directory is a directory that should be treated like a file, therefor ignoring its content.
/// I.e.: .xcassets, .xcdatamodeld, etc...
/// This property returns true when a file is contained in such directory.
public var isInOpaqueDirectory: Bool {
var currentDirectory = parentDirectory
while currentDirectory != .root {
if let `extension` = currentDirectory.extension,
Self.opaqueDirectoriesExtensions.contains(`extension`)
{
return true
}
currentDirectory = currentDirectory.parentDirectory
}
return false
}
/// Returns the path with the last component removed. For example, given the path
/// /test/path/to/file it returns /test/path/to
///
@ -99,24 +124,6 @@ extension AbsolutePath {
return ancestorPath
}
public func upToComponentMatching(regex: String) -> AbsolutePath {
if isRoot { return self }
if basename.range(of: regex, options: .regularExpression) == nil {
return parentDirectory.upToComponentMatching(regex: regex)
} else {
return self
}
}
public func upToComponentMatching(extension: String) -> AbsolutePath {
if isRoot { return self }
if self.extension == `extension` {
return self
} else {
return parentDirectory.upToComponentMatching(extension: `extension`)
}
}
public var upToLastNonGlob: AbsolutePath {
guard let index = components.firstIndex(where: { $0.isGlobComponent }) else {
return self

View File

@ -170,7 +170,7 @@ final class BuildPhaseGeneratorTests: TuistUnitTestCase {
}
}
func test_generateSourcesBuildPhase_withDocCArchive_ArticleAndTutorial() throws {
func test_generateSourcesBuildPhase_withDocCArchive() throws {
// Given
let target = PBXNativeTarget(name: "Test")
let pbxproj = PBXProj()
@ -179,10 +179,6 @@ final class BuildPhaseGeneratorTests: TuistUnitTestCase {
let sources: [SourceFile] = [
SourceFile(path: "/path/sources/Foo.swift", compilerFlags: nil),
SourceFile(path: "/path/sources/Doc.docc", compilerFlags: nil),
SourceFile(path: "/path/sources/Doc.docc/Articles/Article.md", compilerFlags: nil),
SourceFile(path: "/path/sources/Doc.docc/Tutorials/Tutorials.md", compilerFlags: nil),
SourceFile(path: "/path/sources/Doc.docc/Tutorials/Step-1.swift", compilerFlags: nil),
SourceFile(path: "/path/sources/Doc.docc/Tutorials/Step-2.swift", compilerFlags: nil),
]
let fileElements = createFileElements(for: sources.map(\.path))

View File

@ -163,7 +163,7 @@ final class ProjectFileElementsTests: TuistUnitTestCase {
func test_addElement_xcassets() throws {
// Given
let element = GroupFileElement(
path: "/path/myfolder/resources/assets.xcassets/foo/bar.png",
path: "/path/myfolder/resources/assets.xcassets",
group: .group(name: "Project")
)
@ -207,7 +207,7 @@ final class ProjectFileElementsTests: TuistUnitTestCase {
func test_addElement_scnassets() throws {
// Given
let element = GroupFileElement(
path: "/path/myfolder/resources/assets.scnassets/foo.exr",
path: "/path/myfolder/resources/assets.scnassets",
group: .group(name: "Project")
)
@ -226,42 +226,6 @@ final class ProjectFileElementsTests: TuistUnitTestCase {
])
}
func test_addElement_xcassets_and_scnassets_multiple_files() throws {
// Given
let resources = [
"/path/myfolder/resources/assets.xcassets/foo/a.png",
"/path/myfolder/resources/assets.xcassets/foo/abc/b.png",
"/path/myfolder/resources/assets.xcassets/foo/def/c.png",
"/path/myfolder/resources/assets.xcassets",
"/path/myfolder/resources/assets.scnassets/foo.exr",
"/path/myfolder/resources/assets.scnassets/bar.exr",
"/path/myfolder/resources/assets.scnassets",
]
let elements = resources.map {
GroupFileElement(
path: AbsolutePath($0),
group: .group(name: "Project")
)
}
// When
try elements.forEach {
try subject.generate(
fileElement: $0,
groups: groups,
pbxproj: pbxproj,
sourceRootPath: "/path"
)
}
// Then
let projectGroup = groups.sortedMain.group(named: "Project")
XCTAssertEqual(projectGroup?.flattenedChildren.sorted(), [
"myfolder/resources/assets.scnassets",
"myfolder/resources/assets.xcassets",
])
}
func test_addElement_lproj_multiple_files() throws {
// Given
let temporaryPath = try temporaryPath()
@ -683,7 +647,7 @@ final class ProjectFileElementsTests: TuistUnitTestCase {
pbxproj.add(object: group)
// When
subject.addPlayground(
subject.addFileElement(
from: from,
fileAbsolutePath: fileAbsolutePath,
fileRelativePath: fileRelativePath,
@ -768,11 +732,6 @@ final class ProjectFileElementsTests: TuistUnitTestCase {
XCTAssertTrue(subject.isLocalized(path: path))
}
func test_isPlayground() {
let path = AbsolutePath("/path/to/MyPlayground.playground")
XCTAssertTrue(subject.isPlayground(path: path))
}
func test_isVersionGroup() {
let path = AbsolutePath("/path/to/model.xcdatamodeld")
XCTAssertTrue(subject.isVersionGroup(path: path))

View File

@ -35,7 +35,22 @@ final class TargetTests: TuistUnitTestCase {
func test_validSourceExtensions() {
XCTAssertEqual(
Target.validSourceExtensions,
["m", "swift", "mm", "cpp", "cc", "c", "d", "s", "intentdefinition", "xcmappingmodel", "metal", "mlmodel", "docc"]
[
"m",
"swift",
"mm",
"cpp",
"cc",
"c",
"d",
"s",
"intentdefinition",
"xcmappingmodel",
"metal",
"mlmodel",
"docc",
"playground",
]
)
}

View File

@ -69,25 +69,27 @@ final class AbsolutePathExtrasTests: TuistUnitTestCase {
)
}
func test_upToComponentMatchingRegex() throws {
// Given
let path = AbsolutePath("/path/to/sources/Playground.playground/Content.swift")
func test_isInOpaqueDirectory() throws {
XCTAssertFalse(AbsolutePath("/test/directory.bundle").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.xcassets").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.xcassets").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.scnassets").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.xcdatamodeld").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.docc").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.playground").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.bundle").isInOpaqueDirectory)
// When
let got = path.upToComponentMatching(regex: ".+\\.playground")
XCTAssertFalse(AbsolutePath("/").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.notopaque/file.notopaque").isInOpaqueDirectory)
XCTAssertFalse(AbsolutePath("/test/directory.notopaque/directory.bundle").isInOpaqueDirectory)
XCTAssertTrue(AbsolutePath("/test/directory.notopaque/directory.bundle/file.png").isInOpaqueDirectory)
// Then
XCTAssertEqual(got, "/path/to/sources/Playground.playground")
}
func test_upToComponentMatchingExtension() throws {
// Given
let path = AbsolutePath("/path/to/sources/Playground.playground/Content.swift")
// When
let got = path.upToComponentMatching(extension: "playground")
// Then
XCTAssertEqual(got, "/path/to/sources/Playground.playground")
XCTAssertTrue(AbsolutePath("/test/directory.bundle/file.png").isInOpaqueDirectory)
XCTAssertTrue(AbsolutePath("/test/directory.xcassets/file.png").isInOpaqueDirectory)
XCTAssertTrue(AbsolutePath("/test/directory.xcassets/file.png").isInOpaqueDirectory)
XCTAssertTrue(AbsolutePath("/test/directory.scnassets/file.png").isInOpaqueDirectory)
XCTAssertTrue(AbsolutePath("/test/directory.xcdatamodeld/file.png").isInOpaqueDirectory)
XCTAssertTrue(AbsolutePath("/test/directory.docc/file.png").isInOpaqueDirectory)
XCTAssertTrue(AbsolutePath("/test/directory.playground/file.png").isInOpaqueDirectory)
}
}

View File

@ -14,7 +14,7 @@ Feature: Tuist dependencies.
Then I copy the fixture app_with_spm_dependencies into the working directory
Then tuist fetches dependencies
Then tuist generates the project
Then tuist builds the scheme App from the project
Then tuist builds the scheme App from the project with device "iPhone 8"
Scenario: The project is a sub project within a workspace with SPM Dependencies.swift (app_with_spm_dependencies)
Given that tuist is available

View File

@ -1,4 +1,5 @@
# frozen_string_literal: true
require "xcodeproj"
Then(/^tuist builds the project$/) do
system(@tuist, "build", "--path", @dir)
@ -8,6 +9,25 @@ Then(/^tuist builds the scheme ([a-zA-Z\-]+) from the project$/) do |scheme|
system(@tuist, "build", scheme, "--path", @dir)
end
Then(/^tuist builds the scheme ([a-zA-Z\-]+) from the project with device "(.+)"$/) do |scheme, device|
args = [
"-scheme", scheme,
"-sdk", "iphonesimulator",
]
if @workspace_path.nil?
args.concat(["-project", @xcodeproj_path]) unless @xcodeproj_path.nil?
else
args.concat(["-workspace", @workspace_path]) unless @workspace_path.nil?
end
args.concat(["-destination", "'name=#{device}'"])
args << "build"
xcodebuild(*args)
end
Then(%r{^tuist builds the scheme ([a-zA-Z\-]+) from the project at ([a-zA-Z/]+)$}) do |scheme, path|
system(@tuist, "build", scheme, "--path", File.join(@dir, path))
end

View File

@ -31,6 +31,7 @@ let project = Project(
.external(name: "FirebaseAnalytics"),
.external(name: "FirebaseDatabase"),
.external(name: "FirebaseFirestore"),
.external(name: "IterableSDK"),
]
),
]

View File

@ -6,6 +6,7 @@ import FirebaseAnalytics
import FirebaseCore
import FirebaseDatabase
import FirebaseFirestore
import IterableSDK
public enum AppKit {
public static func start() {
@ -26,5 +27,7 @@ public enum AppKit {
// Use FirebaseFirestore to make sure it links fine
_ = Firestore.firestore()
_ = IterableSDK.IterableAPI.sdkVersion
}
}

View File

@ -7,6 +7,7 @@ let dependencies = Dependencies(
.package(url: "https://github.com/facebook/facebook-ios-sdk", .upToNextMajor(from: "12.1.0")),
.package(url: "https://github.com/firebase/firebase-ios-sdk", .upToNextMajor(from: "8.0.0")),
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", .upToNextMinor(from: "0.22.0")),
.package(url: "https://github.com/iterable/swift-sdk", .upToNextMajor(from: "6.0.0")),
],
platforms: [.iOS]
)