Skip to content

Commit 7942619

Browse files
authored
Configure localization automation for widgets extension (#7691)
2 parents 29c95b9 + 4874e1e commit 7942619

File tree

7 files changed

+499
-13
lines changed

7 files changed

+499
-13
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/bash -eu
2+
3+
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
4+
SCRIPT_SRC="${SCRIPT_DIR}/LintAppLocalizedStringsUsage.swift"
5+
6+
LINTER_BUILD_DIR="${BUILD_DIR:-${TMPDIR}}"
7+
LINTER_EXEC="${LINTER_BUILD_DIR}/$(basename "${SCRIPT_SRC}" .swift)"
8+
9+
if [ ! -x "${LINTER_EXEC}" ] || ! (shasum -c "${LINTER_EXEC}.shasum" >/dev/null 2>/dev/null); then
10+
echo "Pre-compiling linter script to ${LINTER_EXEC}..."
11+
swiftc -O -sdk "$(xcrun --sdk macosx --show-sdk-path)" "${SCRIPT_SRC}" -o "${LINTER_EXEC}"
12+
shasum "${SCRIPT_SRC}" >"${LINTER_EXEC}.shasum"
13+
chmod +x "${LINTER_EXEC}"
14+
echo "Pre-compiled linter script ready"
15+
fi
16+
17+
if [ -z "${PROJECT_FILE_PATH:=${1:-}}" ]; then
18+
echo "error: Please provide the path to the xcodeproj to scan"
19+
exit 1
20+
fi
21+
"$LINTER_EXEC" "${PROJECT_FILE_PATH}" "${@:2}"
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import Foundation
2+
3+
// MARK: Xcodeproj entry point type
4+
5+
/// The main entry point type to parse `.xcodeproj` files
6+
class Xcodeproj {
7+
let projectURL: URL // points to the "<projectDirectory>/<projectName>.xcodeproj/project.pbxproj" file
8+
private let pbxproj: PBXProjFile
9+
10+
/// Semantic type for strings that correspond to an object' UUID in the `pbxproj` file
11+
typealias ObjectUUID = String
12+
13+
/// Builds an `Xcodeproj` instance by parsing the `.xcodeproj` or `.pbxproj` file at the provided URL.
14+
init(url: URL) throws {
15+
projectURL = url.pathExtension == "xcodeproj" ? URL(fileURLWithPath: "project.pbxproj", relativeTo: url) : url
16+
let data = try Data(contentsOf: projectURL)
17+
let decoder = PropertyListDecoder()
18+
pbxproj = try decoder.decode(PBXProjFile.self, from: data)
19+
}
20+
21+
/// An internal mapping listing the parent ObjectUUID for each ObjectUUID.
22+
/// - Built by recursing top-to-bottom in the various `PBXGroup` objects of the project to visit all the children objects,
23+
/// and storing which parent object they belong to.
24+
/// - Used by the `resolveURL` method to find the real path of a `PBXReference`, as we need to navigate from the `PBXReference` object
25+
/// up into the chain of parent `PBXGroup` containers to construct the successive relative paths of groups using `sourceTree = "<group>"`
26+
private lazy var referrers: [ObjectUUID: ObjectUUID] = {
27+
var referrers: [ObjectUUID: ObjectUUID] = [:]
28+
func recurseIfGroup(objectID: ObjectUUID) {
29+
guard let group = try? (self.pbxproj.object(id: objectID) as PBXGroup) else { return }
30+
for childID in group.children {
31+
referrers[childID] = objectID
32+
recurseIfGroup(objectID: childID)
33+
}
34+
}
35+
recurseIfGroup(objectID: self.pbxproj.rootProject.mainGroup)
36+
return referrers
37+
}()
38+
}
39+
40+
// Convenience methods and properties
41+
extension Xcodeproj {
42+
/// Builds an `Xcodeproj` instance by parsing the `.xcodeproj` or `pbxproj` file at the provided path
43+
convenience init(path: String) throws {
44+
try self.init(url: URL(fileURLWithPath: path))
45+
}
46+
47+
/// The directory where the `.xcodeproj` resides.
48+
var projectDirectory: URL { projectURL.deletingLastPathComponent().deletingLastPathComponent() }
49+
/// The list of `PBXNativeTarget` targets in the project. Convenience getter for `PBXProjFile.nativeTargets`
50+
var nativeTargets: [PBXNativeTarget] { pbxproj.nativeTargets }
51+
/// The list of `PBXBuildFile` files a given `PBXNativeTarget` will build. Convenience getter for `PBXProjFile.buildFiles(for:)`
52+
func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] { pbxproj.buildFiles(for: target) }
53+
54+
/// Finds the full path / URL of a `PBXBuildFile` based on the groups it belongs to and their `sourceTree` attribute
55+
func resolveURL(to buildFile: PBXBuildFile) throws -> URL? {
56+
if let fileRefID = buildFile.fileRef, let fileRefObject = try? self.pbxproj.object(id: fileRefID) as PBXFileReference {
57+
return try resolveURL(objectUUID: fileRefID, object: fileRefObject)
58+
} else {
59+
// If the `PBXBuildFile` is pointing to `XCVersionGroup` (like `*.xcdatamodel`) and `PBXVariantGroup` (like `*.strings`)
60+
// (instead of a `PBXFileReference`), then in practice each file in the group's `children` will be built by the Build Phase.
61+
// In practice we can skip parsing those in our case and save some CPU, as we don't have a need to lint those non-source-code files.
62+
return nil // just skip those (but don't throw — those are valid use cases in any pbxproj, just ones we don't care about)
63+
}
64+
}
65+
66+
/// Finds the full path / URL of a PBXReference (`PBXFileReference` of `PBXGroup`) based on the groups it belongs to and their `sourceTree` attribute
67+
private func resolveURL<T: PBXReference>(objectUUID: ObjectUUID, object: T) throws -> URL? {
68+
if objectUUID == self.pbxproj.rootProject.mainGroup { return URL(fileURLWithPath: ".", relativeTo: projectDirectory) }
69+
70+
switch object.sourceTree {
71+
case .absolute:
72+
guard let path = object.path else { throw ProjectInconsistencyError.incorrectAbsolutePath(id: objectUUID) }
73+
return URL(fileURLWithPath: path)
74+
case .group:
75+
guard let parentUUID = referrers[objectUUID] else { throw ProjectInconsistencyError.orphanObject(id: objectUUID, object: object) }
76+
let parentGroup = try self.pbxproj.object(id: parentUUID) as PBXGroup
77+
guard let groupURL = try resolveURL(objectUUID: parentUUID, object: parentGroup) else { return nil }
78+
return object.path.map { groupURL.appendingPathComponent($0) } ?? groupURL
79+
case .projectRoot:
80+
return object.path.map { URL(fileURLWithPath: $0, relativeTo: projectDirectory) } ?? projectDirectory
81+
case .buildProductsDir, .devDir, .sdkDir:
82+
print("\(self.projectURL.path): warning: Reference \(objectUUID) is relative to \(object.sourceTree.rawValue) which is not supported by the linter")
83+
return nil
84+
}
85+
}
86+
}
87+
88+
// MARK: - Implementation Details
89+
90+
/// "Parent" type for all the PBX... types of objects encountered in a pbxproj
91+
protocol PBXObject: Decodable {
92+
static var isa: String { get }
93+
}
94+
extension PBXObject {
95+
static var isa: String { String(describing: self) }
96+
}
97+
98+
/// "Parent" type for PBXObjects referencing relative path information (`PBXFileReference`, `PBXGroup`)
99+
protocol PBXReference: PBXObject {
100+
var name: String? { get }
101+
var path: String? { get }
102+
var sourceTree: Xcodeproj.SourceTree { get }
103+
}
104+
105+
/// Types used to parse and decode the internals of a `*.xcodeproj/project.pbxproj` file
106+
extension Xcodeproj {
107+
/// An error `thrown` when an inconsistency is found while parsing the `.pbxproj` file.
108+
enum ProjectInconsistencyError: Swift.Error, CustomStringConvertible {
109+
case objectNotFound(id: ObjectUUID)
110+
case unexpectedObjectType(id: ObjectUUID, expectedType: Any.Type, found: PBXObject)
111+
case incorrectAbsolutePath(id: ObjectUUID)
112+
case orphanObject(id: ObjectUUID, object: PBXObject)
113+
114+
var description: String {
115+
switch self {
116+
case .objectNotFound(id: let id):
117+
return "Unable to find object with UUID `\(id)`"
118+
case .unexpectedObjectType(id: let id, expectedType: let expectedType, found: let found):
119+
return "Object with UUID `\(id)` was expected to be of type \(expectedType) but found \(found) instead"
120+
case .incorrectAbsolutePath(id: let id):
121+
return "Object `\(id)` has `sourceTree = \(Xcodeproj.SourceTree.absolute)` but no `path`"
122+
case .orphanObject(id: let id, object: let object):
123+
return "Unable to find parent group of \(object) (`\(id)`) during file path resolution"
124+
}
125+
}
126+
}
127+
128+
/// Type used to represent and decode the root object of a `.pbxproj` file.
129+
struct PBXProjFile: Decodable {
130+
let rootObject: ObjectUUID
131+
let objects: [String: PBXObjectWrapper]
132+
133+
// Convenience methods
134+
135+
/// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project.
136+
func object<T: PBXObject>(id: ObjectUUID) throws -> T {
137+
guard let wrapped = objects[id] else { throw ProjectInconsistencyError.objectNotFound(id: id) }
138+
guard let obj = wrapped.wrappedValue as? T else {
139+
throw ProjectInconsistencyError.unexpectedObjectType(id: id, expectedType: T.self, found: wrapped.wrappedValue)
140+
}
141+
return obj
142+
}
143+
144+
/// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project.
145+
func object<T: PBXObject>(id: ObjectUUID) -> T? {
146+
try? object(id: id) as T
147+
}
148+
149+
/// The `PBXProject` corresponding to the `rootObject` of the project file.
150+
var rootProject: PBXProject { try! object(id: rootObject) }
151+
152+
/// The `PBXGroup` corresponding to the main groop serving as root for the whole hierarchy of files and groups in the project.
153+
var mainGroup: PBXGroup { try! object(id: rootProject.mainGroup) }
154+
155+
/// The list of `PBXNativeTarget` targets found in the project.
156+
var nativeTargets: [PBXNativeTarget] { rootProject.targets.compactMap(object(id:)) }
157+
158+
/// The list of `PBXBuildFile` build file references included in a given target.
159+
func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] {
160+
guard let sourceBuildPhase: PBXSourcesBuildPhase = target.buildPhases.lazy.compactMap(object(id:)).first else { return [] }
161+
return sourceBuildPhase.files.compactMap(object(id:)) as [PBXBuildFile]
162+
}
163+
}
164+
165+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
166+
/// Represents the root project object.
167+
struct PBXProject: PBXObject {
168+
let mainGroup: ObjectUUID
169+
let targets: [ObjectUUID]
170+
}
171+
172+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
173+
/// Represents a native target (i.e. a target building an app, app extension, bundle...).
174+
/// - note: Does not represent other types of targets like `PBXAggregateTarget`, only native ones.
175+
struct PBXNativeTarget: PBXObject {
176+
let name: String
177+
let buildPhases: [ObjectUUID]
178+
let productType: String
179+
var knownProductType: ProductType? { ProductType(rawValue: productType) }
180+
181+
enum ProductType: String, Decodable {
182+
case app = "com.apple.product-type.application"
183+
case appExtension = "com.apple.product-type.app-extension"
184+
case unitTest = "com.apple.product-type.bundle.unit-test"
185+
case uiTest = "com.apple.product-type.bundle.ui-testing"
186+
case framework = "com.apple.product-type.framework"
187+
}
188+
}
189+
190+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
191+
/// Represents a "Compile Sources" build phase containing a list of files to compile.
192+
/// - note: Does not represent other types of Build Phases that could exist in the project, only "Compile Sources" one
193+
struct PBXSourcesBuildPhase: PBXObject {
194+
let files: [ObjectUUID]
195+
}
196+
197+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
198+
/// Represents a single build file in a `PBXSourcesBuildPhase` build phase.
199+
struct PBXBuildFile: PBXObject {
200+
let fileRef: ObjectUUID?
201+
}
202+
203+
/// This type is used to indicate what a file reference in the project is actually relative to
204+
enum SourceTree: String, Decodable, CustomStringConvertible {
205+
case absolute = "<absolute>"
206+
case group = "<group>"
207+
case projectRoot = "SOURCE_ROOT"
208+
case buildProductsDir = "BUILT_PRODUCTS_DIR"
209+
case devDir = "DEVELOPER_DIR"
210+
case sdkDir = "SDKROOT"
211+
var description: String { rawValue }
212+
}
213+
214+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
215+
/// Represents a reference to a file contained in the project tree.
216+
struct PBXFileReference: PBXReference {
217+
let name: String?
218+
let path: String?
219+
let sourceTree: SourceTree
220+
}
221+
222+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
223+
/// Represents a group (aka "folder") contained in the project tree.
224+
struct PBXGroup: PBXReference {
225+
let name: String?
226+
let path: String?
227+
let sourceTree: SourceTree
228+
let children: [ObjectUUID]
229+
}
230+
231+
/// Fallback type for any unknown `PBXObject` type.
232+
struct UnknownPBXObject: PBXObject {
233+
let isa: String
234+
}
235+
236+
/// Wrapper helper to decode any `PBXObject` based on the value of their `isa` field
237+
@propertyWrapper
238+
struct PBXObjectWrapper: Decodable, CustomDebugStringConvertible {
239+
let wrappedValue: PBXObject
240+
241+
static let knownTypes: [PBXObject.Type] = [
242+
PBXProject.self,
243+
PBXGroup.self,
244+
PBXFileReference.self,
245+
PBXNativeTarget.self,
246+
PBXSourcesBuildPhase.self,
247+
PBXBuildFile.self
248+
]
249+
250+
init(from decoder: Decoder) throws {
251+
let untypedObject = try UnknownPBXObject(from: decoder)
252+
if let objectType = Self.knownTypes.first(where: { $0.isa == untypedObject.isa }) {
253+
self.wrappedValue = try objectType.init(from: decoder)
254+
} else {
255+
self.wrappedValue = untypedObject
256+
}
257+
}
258+
var debugDescription: String { String(describing: wrappedValue) }
259+
}
260+
}
261+
262+
263+
264+
// MARK: - Lint method
265+
266+
/// The outcome of running our lint logic on a file
267+
enum LintResult { case ok, skipped, violationsFound([(line: Int, col: Int)]) }
268+
269+
/// Lint a given file for usages of `NSLocalizedString` instead of `AppLocalizedString`
270+
func lint(fileAt url: URL, targetName: String) throws -> LintResult {
271+
guard ["m", "swift"].contains(url.pathExtension) else { return .skipped }
272+
let content = try String(contentsOf: url)
273+
var lineNo = 0
274+
var violations: [(line: Int, col: Int)] = []
275+
content.enumerateLines { line, _ in
276+
lineNo += 1
277+
guard line.range(of: "\\s*//", options: .regularExpression) == nil else { return } // Skip commented lines
278+
guard let range = line.range(of: "NSLocalizedString") else { return }
279+
280+
// Violation found, report it
281+
let colNo = line.distance(from: line.startIndex, to: range.lowerBound)
282+
let message = """
283+
Use `AppLocalizedString` instead of `NSLocalizedString` in source files that are used in the `\(targetName)` extension target. See paNNhX-nP-p2 for more info.
284+
"""
285+
print("\(url.path):\(lineNo):\(colNo): error: \(message)")
286+
violations.append((lineNo, colNo))
287+
}
288+
return violations.isEmpty ? .ok : .violationsFound(violations)
289+
}
290+
291+
292+
293+
// MARK: - Main (Script Code entry point)
294+
295+
// 1st arg = project path
296+
let args = CommandLine.arguments.dropFirst()
297+
guard let projectPath = args.first, !projectPath.isEmpty else { print("You must provide the path to the xcodeproj as first argument."); exit(1) }
298+
do {
299+
let project = try Xcodeproj(path: projectPath)
300+
301+
// 2nd arg (optional) = name of target to lint
302+
let targetsToLint: [Xcodeproj.PBXNativeTarget]
303+
if let targetName = args.dropFirst().first, !targetName.isEmpty {
304+
print("Selected target: \(targetName)")
305+
targetsToLint = project.nativeTargets.filter { $0.name == targetName }
306+
} else {
307+
print("Linting all app extension targets")
308+
targetsToLint = project.nativeTargets.filter { $0.knownProductType == .appExtension }
309+
}
310+
311+
// Lint each requested target
312+
var violationsFound = 0
313+
for target in targetsToLint {
314+
let buildFiles: [Xcodeproj.PBXBuildFile] = project.buildFiles(for: target)
315+
print("Linting the Build Files for \(target.name):")
316+
for buildFile in buildFiles {
317+
guard let fileURL = try project.resolveURL(to: buildFile) else { continue }
318+
let result = try lint(fileAt: fileURL.absoluteURL, targetName: target.name)
319+
print(" - \(fileURL.relativePath) [\(result)]")
320+
if case .violationsFound(let list) = result { violationsFound += list.count }
321+
}
322+
}
323+
print("Done! \(violationsFound) violation(s) found.")
324+
exit(violationsFound > 0 ? 1 : 0)
325+
} catch let error {
326+
print("\(projectPath): error: Error while parsing the project file \(projectPath): \(error.localizedDescription)")
327+
exit(2)
328+
}

0 commit comments

Comments
 (0)