Fix duplicate localized resource files (#363)

Resolves https://github.com/tuist/tuist/issues/361

### Short description

Localized resources can up getting duplicated within the resources build phase and the project files elements.

The reason this was occurring:

- Give a path to a resource e.g. `/resources/en.lproj/foo.png`
- `/resources/en.lproj/` is used detected
- All files within `/resources/en.lproj/` are enumerated and added 
- When another path to a resource is encountered e.g. `/resources/en.lproj/bar.png`
- The same process is repeated, resulting in duplicate file elements!

### Solution

- Update project files element logic to better accommodate multiple localized files 
- Add a cache to keep track of which build files were added the resources build phase to prevent adding duplicates.
- Improved file element missing file warnings for directories 

e.g. `resources: ["Resources/en.lproj"]` would yield the following warnings:

```
Warning: 'Resources/en.lproj' is a directory, try using: 'Resources/en.lproj/**' to list its files
``` 

### Test Plan

- Verify unit tests pass via `swift tests`
- Verify acceptance tests pass via `bundle rake exec features`
- Manually generate `fixtures/ios_app_with_framework_and_resources` via `tuist generate`
- Verify the generated project includes the localized string files without any duplicates
This commit is contained in:
Kas 2019-05-20 21:42:37 +01:00 committed by GitHub
parent d32dcf0b70
commit c87a1cda62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 266 additions and 145 deletions

View File

@ -22,6 +22,7 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
- Fixing generation failures due to asset catalog & `**/*.png` glob patterns handling https://github.com/tuist/tuist/pull/346 by @kwridan - Fixing generation failures due to asset catalog & `**/*.png` glob patterns handling https://github.com/tuist/tuist/pull/346 by @kwridan
- Supporting bundle target dependencies that reside in different projects (in `TuistGenerator`) https://github.com/tuist/tuist/pull/348 by @kwridan - Supporting bundle target dependencies that reside in different projects (in `TuistGenerator`) https://github.com/tuist/tuist/pull/348 by @kwridan
- Fixing header paths including folders and non-header files https://github.com/tuist/tuist/pull/356 by @kwridan - Fixing header paths including folders and non-header files https://github.com/tuist/tuist/pull/356 by @kwridan
- Fix duplicate localized resource files https://github.com/tuist/tuist/pull/363 by @kwridan
## 0.14.0 ## 0.14.0

View File

@ -171,6 +171,7 @@ final class BuildPhaseGenerator: BuildPhaseGenerating {
fileElements: ProjectFileElements, fileElements: ProjectFileElements,
pbxproj: PBXProj, pbxproj: PBXProj,
resourcesBuildPhase: PBXResourcesBuildPhase) throws { resourcesBuildPhase: PBXResourcesBuildPhase) throws {
var buildFilesCache = Set<AbsolutePath>()
try files.forEach { buildFilePath in try files.forEach { buildFilePath in
let pathString = buildFilePath.pathString let pathString = buildFilePath.pathString
let pathRange = NSRange(location: 0, length: pathString.count) let pathRange = NSRange(location: 0, length: pathString.count)
@ -185,7 +186,7 @@ final class BuildPhaseGenerator: BuildPhaseGenerating {
return return
} }
var element: PBXFileElement? var element: (element: PBXFileElement, path: AbsolutePath)?
if isLocalized { if isLocalized {
let name = buildFilePath.components.last! let name = buildFilePath.components.last!
@ -193,18 +194,18 @@ final class BuildPhaseGenerator: BuildPhaseGenerating {
guard let group = fileElements.group(path: path) else { guard let group = fileElements.group(path: path) else {
throw BuildPhaseGenerationError.missingFileReference(buildFilePath) throw BuildPhaseGenerationError.missingFileReference(buildFilePath)
} }
element = group element = (group, path)
} else if !isLproj { } else if !isLproj {
guard let fileReference = fileElements.file(path: buildFilePath) else { guard let fileReference = fileElements.file(path: buildFilePath) else {
throw BuildPhaseGenerationError.missingFileReference(buildFilePath) throw BuildPhaseGenerationError.missingFileReference(buildFilePath)
} }
element = fileReference element = (fileReference, buildFilePath)
} }
if let element = element { if let element = element, buildFilesCache.contains(element.path) == false {
let pbxBuildFile = PBXBuildFile(file: element) let pbxBuildFile = PBXBuildFile(file: element.element)
pbxproj.add(object: pbxBuildFile) pbxproj.add(object: pbxBuildFile)
resourcesBuildPhase.files?.append(pbxBuildFile) resourcesBuildPhase.files?.append(pbxBuildFile)
buildFilesCache.insert(element.path)
} }
} }
} }

View File

@ -304,11 +304,16 @@ class ProjectFileElements {
// Add the file element // Add the file element
if isLocalized(path: absolutePath) { if isLocalized(path: absolutePath) {
addVariantGroup(from: from, // Localized container (e.g. /path/to/en.lproj) we don't add it directly
absolutePath: absolutePath, // an element will get added once the next path component is evaluated
relativePath: relativePath, //
toGroup: toGroup, // note: assumption here is a path to a nested resource is always provided
pbxproj: pbxproj) return (element: toGroup, path: absolutePath)
} else if isLocalized(path: from) {
// Localized file (e.g. /path/to/en.lproj/foo.png)
addLocalizedFile(localizedFile: absolutePath,
toGroup: toGroup,
pbxproj: pbxproj)
return nil return nil
} else if isVersionGroup(path: absolutePath) { } else if isVersionGroup(path: absolutePath) {
return addVersionGroupElement(from: from, return addVersionGroupElement(from: from,
@ -335,36 +340,60 @@ class ProjectFileElements {
} }
} }
func addVariantGroup(from: AbsolutePath, func addLocalizedFile(localizedFile: AbsolutePath,
absolutePath: AbsolutePath, toGroup: PBXGroup,
relativePath _: RelativePath, pbxproj: PBXProj) {
toGroup: PBXGroup, // e.g.
pbxproj: PBXProj) { // from: resources/en.lproj/
// /path/to/*.lproj/* // localizedFile: resources/en.lproj/App.strings
absolutePath.glob("*").sorted().forEach { localizedFile in
let localizedName = localizedFile.components.last!
// Variant group // Variant Group
let variantGroupPath = absolutePath.parentDirectory.appending(component: localizedName) let localizedName = localizedFile.basename // e.g. App.strings
var variantGroup: PBXVariantGroup! = elements[variantGroupPath] as? PBXVariantGroup let localizedContainer = localizedFile.parentDirectory // e.g. resources/en.lproj
if variantGroup == nil { let variantGroupPath = localizedContainer
variantGroup = PBXVariantGroup(children: [], sourceTree: .group, name: localizedName) .parentDirectory
pbxproj.add(object: variantGroup) .appending(component: localizedName) // e.g. resources/App.strings
toGroup.children.append(variantGroup)
elements[variantGroupPath] = variantGroup
}
// Localized element let variantGroup = addVariantGroup(variantGroupPath: variantGroupPath,
let localizedFilePath = "\(absolutePath.components.last!)/\(localizedName)" // e.g: en.lproj/Main.storyboard localizedName: localizedName,
let lastKnownFileType = Xcode.filetype(extension: localizedName) // e.g. Main.storyboard toGroup: toGroup,
let name = absolutePath.components.last!.split(separator: ".").first! // e.g. en pbxproj: pbxproj)
let localizedFileReference = PBXFileReference(sourceTree: .group,
name: String(name), // Localized element
lastKnownFileType: lastKnownFileType, addLocalizedFileElement(localizedFile: localizedFile,
path: localizedFilePath) variantGroup: variantGroup,
pbxproj.add(object: localizedFileReference) localizedContainer: localizedContainer,
variantGroup.children.append(localizedFileReference) pbxproj: pbxproj)
}
private func addVariantGroup(variantGroupPath: AbsolutePath,
localizedName: String,
toGroup: PBXGroup,
pbxproj: PBXProj) -> PBXVariantGroup {
if let variantGroup = elements[variantGroupPath] as? PBXVariantGroup {
return variantGroup
} }
let variantGroup = PBXVariantGroup(children: [], sourceTree: .group, name: localizedName)
pbxproj.add(object: variantGroup)
toGroup.children.append(variantGroup)
elements[variantGroupPath] = variantGroup
return variantGroup
}
private func addLocalizedFileElement(localizedFile: AbsolutePath,
variantGroup: PBXVariantGroup,
localizedContainer: AbsolutePath,
pbxproj: PBXProj) {
let localizedFilePath = localizedFile.relative(to: localizedContainer.parentDirectory)
let lastKnownFileType = Xcode.filetype(extension: localizedFile.basename)
let name = localizedContainer.basename.split(separator: ".").first
let localizedFileReference = PBXFileReference(sourceTree: .group,
name: name.map { String($0) },
lastKnownFileType: lastKnownFileType,
path: localizedFilePath.pathString)
pbxproj.add(object: localizedFileReference)
variantGroup.children.append(localizedFileReference)
} }
func addVersionGroupElement(from: AbsolutePath, func addVersionGroupElement(from: AbsolutePath,

View File

@ -110,7 +110,11 @@ extension TuistGenerator.FileElement {
.filter(includeFiles) .filter(includeFiles)
if files.isEmpty { if files.isEmpty {
printer.print(warning: "No files found at: \(string)") if fileHandler.isFolder(path.appending(RelativePath(string))) {
printer.print(warning: "'\(string)' is a directory, try using: '\(string)/**' to list its files")
} else {
printer.print(warning: "No files found at: \(string)")
}
} }
return files return files

View File

@ -162,27 +162,37 @@ final class BuildPhaseGeneratorTests: XCTestCase {
} }
func test_generateResourcesBuildPhase_whenLocalizedFile() throws { func test_generateResourcesBuildPhase_whenLocalizedFile() throws {
let path = AbsolutePath("/en.lproj/Main.storyboard") // Given
let target = Target.test(resources: [.file(path: path)]) let files: [AbsolutePath] = [
let fileElements = ProjectFileElements() "/path/resources/en.lproj/Main.storyboard",
let pbxproj = PBXProj() "/path/resources/en.lproj/App.strings",
let group = PBXVariantGroup() "/path/resources/fr.lproj/Main.storyboard",
pbxproj.add(object: group) "/path/resources/fr.lproj/App.strings",
fileElements.elements[AbsolutePath("/Main.storyboard")] = group ]
let nativeTarget = PBXNativeTarget(name: "Test")
let resources = files.map { FileElement.file(path: $0) }
let fileElements = createLocalizedResourceFileElements(for: [
"/path/resources/Main.storyboard",
"/path/resources/App.strings",
])
let nativeTarget = PBXNativeTarget(name: "Test")
let pbxproj = PBXProj()
// When
try subject.generateResourcesBuildPhase(path: "/path", try subject.generateResourcesBuildPhase(path: "/path",
target: target, target: .test(resources: resources),
graph: Graph.test(), graph: Graph.test(),
pbxTarget: nativeTarget, pbxTarget: nativeTarget,
fileElements: fileElements, fileElements: fileElements,
pbxproj: pbxproj) pbxproj: pbxproj)
let pbxBuildPhase: PBXBuildPhase? = nativeTarget.buildPhases.first // Then
XCTAssertNotNil(pbxBuildPhase) let buildPhase = nativeTarget.buildPhases.first
XCTAssertTrue(pbxBuildPhase is PBXResourcesBuildPhase) XCTAssertEqual(buildPhase?.files?.map { $0.file }, [
let pbxBuildFile: PBXBuildFile? = pbxBuildPhase?.files?.first fileElements.elements["/path/resources/Main.storyboard"],
XCTAssertEqual(pbxBuildFile?.file, group) fileElements.elements["/path/resources/App.strings"],
])
} }
func test_generateResourcesBuildPhase_whenCoreDataModel() throws { func test_generateResourcesBuildPhase_whenCoreDataModel() throws {
@ -317,4 +327,12 @@ final class BuildPhaseGeneratorTests: XCTestCase {
}) })
return fileElements return fileElements
} }
private func createLocalizedResourceFileElements(for files: [AbsolutePath]) -> ProjectFileElements {
let fileElements = ProjectFileElements()
fileElements.elements = Dictionary(uniqueKeysWithValues: files.map {
($0, PBXVariantGroup())
})
return fileElements
}
} }

View File

@ -132,8 +132,7 @@ final class ProjectFileElementsTests: XCTestCase {
func test_addElement_xcassets() throws { func test_addElement_xcassets() throws {
// Given // Given
let element = GroupFileElement(path: "/path/myfolder/resources/assets.xcassets/foo/bar.png", let element = GroupFileElement(path: "/path/myfolder/resources/assets.xcassets/foo/bar.png",
group: .group(name: "Project"), group: .group(name: "Project"))
isReference: true)
// When // When
try subject.generate(fileElement: element, try subject.generate(fileElement: element,
@ -158,8 +157,7 @@ final class ProjectFileElementsTests: XCTestCase {
] ]
let elements = resouces.map { let elements = resouces.map {
GroupFileElement(path: AbsolutePath($0), GroupFileElement(path: AbsolutePath($0),
group: .group(name: "Project"), group: .group(name: "Project"))
isReference: true)
} }
// When // When
@ -177,6 +175,44 @@ final class ProjectFileElementsTests: XCTestCase {
]) ])
} }
func test_addElement_lproj_multiple_files() throws {
// Given
let resouces = try fileHandler.createFiles([
"resources/en.lproj/App.strings",
"resources/en.lproj/Extension.strings",
"resources/fr.lproj/App.strings",
"resources/fr.lproj/Extension.strings",
])
let elements = resouces.map {
GroupFileElement(path: $0,
group: .group(name: "Project"),
isReference: true)
}
// When
try elements.forEach {
try subject.generate(fileElement: $0,
groups: groups,
pbxproj: pbxproj,
sourceRootPath: fileHandler.currentPath)
}
// Then
let projectGroup = groups.main.group(named: "Project")
XCTAssertEqual(projectGroup?.debugChildPaths, [
"resources/App.strings/en",
"resources/App.strings/fr",
"resources/Extension.strings/en",
"resources/Extension.strings/fr",
])
XCTAssertEqual(projectGroup?.debugVariantGroupPaths, [
"resources/App.strings",
"resources/Extension.strings",
])
}
func test_targetProducts() { func test_targetProducts() {
let target = Target.test() let target = Target.test()
let products = subject.targetProducts(target: target).sorted() let products = subject.targetProducts(target: target).sorted()
@ -362,32 +398,23 @@ final class ProjectFileElementsTests: XCTestCase {
XCTAssertEqual(file.sourceTree, .group) XCTAssertEqual(file.sourceTree, .group)
} }
func test_addVariantGroup() throws { func test_addLocalizedFile() throws {
let fileName = "localizable.strings" // Given
let dir = try TemporaryDirectory(removeTreeOnDeinit: true)
let localizedDir = dir.path.appending(component: "en.lproj")
try fileHandler.createFolder(localizedDir)
try "test".write(to: localizedDir.appending(component: fileName).url, atomically: true, encoding: .utf8)
let from = dir.path.parentDirectory
let absolutePath = localizedDir
let relativePath = RelativePath("en.lproj")
let group = PBXGroup()
let pbxproj = PBXProj() let pbxproj = PBXProj()
pbxproj.add(object: group) let group = PBXGroup()
subject.addVariantGroup(from: from, let file: AbsolutePath = "/path/to/resources/en.lproj/App.strings"
absolutePath: absolutePath,
relativePath: relativePath,
toGroup: group,
pbxproj: pbxproj)
let variantGroupPath = dir.path.appending(component: fileName)
let variantGroup: PBXVariantGroup = subject.group(path: variantGroupPath) as! PBXVariantGroup
XCTAssertEqual(variantGroup.name, fileName)
XCTAssertEqual(variantGroup.sourceTree, .group)
let fileReference: PBXFileReference? = variantGroup.children.first as? PBXFileReference // When
XCTAssertEqual(fileReference?.name, "en") subject.addLocalizedFile(localizedFile: file,
XCTAssertEqual(fileReference?.sourceTree, .group) toGroup: group,
XCTAssertEqual(fileReference?.path, "en.lproj/\(fileName)") pbxproj: pbxproj)
// Then
let variantGroup = group.children.first as? PBXVariantGroup
XCTAssertEqual(variantGroup?.name, "App.strings")
XCTAssertNil(variantGroup?.path)
XCTAssertEqual(variantGroup?.children.map { $0.name }, ["en"])
XCTAssertEqual(variantGroup?.children.map { $0.path }, ["en.lproj/App.strings"])
} }
func test_addVersionGroupElement() throws { func test_addVersionGroupElement() throws {
@ -527,4 +554,18 @@ private extension PBXGroup {
} }
} }
} }
/// Retuns all the child variant groups (recursively)
var debugVariantGroupPaths: [String] {
return children.flatMap { (element: PBXFileElement) -> [String] in
switch element {
case let group as PBXVariantGroup:
return [group.nameOrPath]
case let group as PBXGroup:
return group.debugVariantGroupPaths.map { group.nameOrPath + "/" + $0 }
default:
return []
}
}
}
} }

View File

@ -269,70 +269,6 @@ class GeneratorModelLoaderTest: XCTestCase {
XCTAssertEqual(model.projects, []) XCTAssertEqual(model.projects, [])
} }
func test_loadWorkspace_withInvalidFilePaths() throws {
// Given
let path = fileHandler.currentPath
try fileHandler.createFolders([
"Documentation",
])
let manifests = [
path: WorkspaceManifest.test(name: "SomeWorkspace",
projects: [],
additionalFiles: [
"Documentation/**/*.md",
]),
]
let manifestLoader = createManifestLoader(with: manifests)
let subject = GeneratorModelLoader(fileHandler: fileHandler,
manifestLoader: manifestLoader,
manifestTargetGenerator: manifestTargetGenerator,
printer: printer)
// When
let model = try subject.loadWorkspace(at: path)
// Then
XCTAssertEqual(printer.printWarningArgs, [
"No files found at: Documentation/**/*.md",
])
XCTAssertEqual(model.additionalFiles, [])
}
func test_loadWorkspace_withInvalidFolderReferencePaths() throws {
// Given
let path = fileHandler.currentPath
try fileHandler.createFiles([
"README.md",
])
let manifests = [
path: WorkspaceManifest.test(name: "SomeWorkspace",
projects: [],
additionalFiles: [
.folderReference(path: "Documentation"),
.folderReference(path: "README.md"),
]),
]
let manifestLoader = createManifestLoader(with: manifests)
let subject = GeneratorModelLoader(fileHandler: fileHandler,
manifestLoader: manifestLoader,
manifestTargetGenerator: manifestTargetGenerator,
printer: printer)
// When
let model = try subject.loadWorkspace(at: path)
// Then
XCTAssertEqual(printer.printWarningArgs, [
"Documentation does not exist",
"README.md is not a directory - folder reference paths need to point to directories",
])
XCTAssertEqual(model.additionalFiles, [])
}
func test_settings() throws { func test_settings() throws {
// Given // Given
let debug = ConfigurationManifest(settings: ["Debug": "Debug"], xcconfig: "debug.xcconfig") let debug = ConfigurationManifest(settings: ["Debug": "Debug"], xcconfig: "debug.xcconfig")
@ -474,6 +410,88 @@ class GeneratorModelLoaderTest: XCTestCase {
XCTAssertEqual(GeneratorModelLoaderError.missingFile("/missing/path").description, "Couldn't find file at path '/missing/path'") XCTAssertEqual(GeneratorModelLoaderError.missingFile("/missing/path").description, "Couldn't find file at path '/missing/path'")
} }
func test_fileElement_warning_withDirectoryPathsAsFiles() throws {
// Given
let path = fileHandler.currentPath
try fileHandler.createFiles([
"Documentation/README.md",
"Documentation/USAGE.md",
])
let manifest = ProjectDescription.FileElement.glob(pattern: "Documentation")
// When
let model = TuistGenerator.FileElement.from(manifest: manifest,
path: path,
fileHandler: fileHandler,
printer: printer,
includeFiles: { !self.fileHandler.isFolder($0) })
// Then
XCTAssertEqual(printer.printWarningArgs, [
"'Documentation' is a directory, try using: 'Documentation/**' to list its files",
])
XCTAssertEqual(model, [])
}
func test_fileElement_warning_withMisingPaths() throws {
// Given
let path = fileHandler.currentPath
let manifest = ProjectDescription.FileElement.glob(pattern: "Documentation/**")
// When
let model = TuistGenerator.FileElement.from(manifest: manifest,
path: path,
fileHandler: fileHandler,
printer: printer)
// Then
XCTAssertEqual(printer.printWarningArgs, [
"No files found at: Documentation/**",
])
XCTAssertEqual(model, [])
}
func test_fileElement_warning_withInvalidFolderReference() throws {
// Given
let path = fileHandler.currentPath
try fileHandler.createFiles([
"README.md",
])
let manifest = ProjectDescription.FileElement.folderReference(path: "README.md")
// When
let model = TuistGenerator.FileElement.from(manifest: manifest,
path: path,
fileHandler: fileHandler,
printer: printer)
// Then
XCTAssertEqual(printer.printWarningArgs, [
"README.md is not a directory - folder reference paths need to point to directories",
])
XCTAssertEqual(model, [])
}
func test_fileElement_warning_withMissingFolderReference() throws {
// Given
let path = fileHandler.currentPath
let manifest = ProjectDescription.FileElement.folderReference(path: "Documentation")
// When
let model = TuistGenerator.FileElement.from(manifest: manifest,
path: path,
fileHandler: fileHandler,
printer: printer)
// Then
XCTAssertEqual(printer.printWarningArgs, [
"Documentation does not exist",
])
XCTAssertEqual(model, [])
}
// MARK: - Helpers // MARK: - Helpers
func createManifestLoader(with projects: [AbsolutePath: ProjectDescription.Project]) -> GraphManifestLoading { func createManifestLoader(with projects: [AbsolutePath: ProjectDescription.Project]) -> GraphManifestLoading {

View File

@ -76,6 +76,10 @@ Scenario: The project is an iOS application that has resources (ios_app_with_fra
Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'Examples/list.json' Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'Examples/list.json'
Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'Assets.car' Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'Assets.car'
Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'resource.txt' Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'resource.txt'
Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'en.lproj/App.strings'
Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'en.lproj/Greetings.strings'
Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'fr.lproj/App.strings'
Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'fr.lproj/Greetings.strings'
Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'resource_without_extension' Then the product 'App.app' with destination 'Debug-iphoneos' contains resource 'resource_without_extension'
Then the product 'App.app' with destination 'Debug-iphoneos' does not contain resource 'do_not_include.dat' Then the product 'App.app' with destination 'Debug-iphoneos' does not contain resource 'do_not_include.dat'

View File

@ -14,6 +14,7 @@ let project = Project(
"Resources/**/*.png", "Resources/**/*.png",
"Resources/*.xcassets", "Resources/*.xcassets",
"Resources/**/*.txt", "Resources/**/*.txt",
"Resources/**/*.strings",
"Resources/resource_without_extension", "Resources/resource_without_extension",
.folderReference(path: "Examples") .folderReference(path: "Examples")
], ],

View File

@ -0,0 +1,2 @@
"morning" = "Good Morning";

View File

@ -0,0 +1,2 @@
"morning" = "Bonjour";