Skip to content

Commit 3f3f278

Browse files
committed
Merge branch 'trunk' into feature/18502-prompts_initial_view
2 parents 02cbdb8 + a49ad5d commit 3f3f278

33 files changed

+631
-464
lines changed

Scripts/BuildPhases/LintAppLocalizedStringsUsage.rb

Lines changed: 0 additions & 41 deletions
This file was deleted.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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 -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+
"$LINTER_EXEC" "${PROJECT_FILE_PATH:-$1}" # "${TARGET_NAME:-$2}"
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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, indent: String = "") {
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, indent: "\(indent) ")
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 an `.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) -> URL {
56+
do {
57+
let fileRef = try self.pbxproj.object(id: buildFile.fileRef) as PBXFileReference
58+
return resolveURL(objectUUID: buildFile.fileRef, object: fileRef)
59+
} catch {
60+
// Cover `XCVersionGroup` (like `*.xcdatamodel`) and `PBXVariantGroup` (like `*.strings`)
61+
let fileRef = try! self.pbxproj.object(id: buildFile.fileRef) as PBXGroup
62+
return resolveURL(objectUUID: buildFile.fileRef, object: fileRef)
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) -> URL {
68+
if objectUUID == self.pbxproj.rootProject.mainGroup { return URL(fileURLWithPath: ".", relativeTo: projectDirectory) }
69+
70+
switch object.sourceTree {
71+
case .absolute:
72+
return URL(fileURLWithPath: object.path!)
73+
case .group:
74+
guard let parentUUID = referrers[objectUUID] else { fatalError("Unable to find parent of \(object) (\(objectUUID))") }
75+
let parentGroup = try! self.pbxproj.object(id: parentUUID) as PBXGroup
76+
let groupURL = resolveURL(objectUUID: parentUUID, object: parentGroup)
77+
return object.path.map { groupURL.appendingPathComponent($0) } ?? groupURL
78+
case .projectRoot:
79+
return object.path.map { URL(fileURLWithPath: $0, relativeTo: projectDirectory) } ?? projectDirectory
80+
case .buildProductsDir, .devDir, .sdkDir:
81+
fatalError("Unsupported relative reference (relative to: \(object.sourceTree)")
82+
}
83+
}
84+
}
85+
86+
// MARK: - Implementation Details
87+
88+
/// "Parent" type for all the PBX... types of objects encountered in a pbxproj
89+
protocol PBXObject: Decodable {}
90+
91+
/// "Parent" type for PBXObjects referencing relative path information (`PBXFileReference`, `PBXGroup`)
92+
protocol PBXReference: PBXObject {
93+
var name: String? { get }
94+
var path: String? { get }
95+
var sourceTree: Xcodeproj.SourceTree { get }
96+
}
97+
98+
/// Types used to parse and decode the internals of an `.xcodeproj/project.pbxproj` file
99+
extension Xcodeproj {
100+
/// An error `thrown` when an inconsistency is found while parsing the `.pbxproj` file.
101+
enum DecodingError: Swift.Error, CustomStringConvertible {
102+
case objectNotFound(id: ObjectUUID)
103+
case unexpectedObjectType(id: ObjectUUID, expectedType: Any.Type, found: PBXObject)
104+
var description: String {
105+
switch self {
106+
case .objectNotFound(id: let id):
107+
return "Unable to find object with UUID \(id)"
108+
case .unexpectedObjectType(let id, let expectedType, let found):
109+
return "Object with UUID \(id) was expected to be of type \(expectedType) but found \(found) instead."
110+
}
111+
}
112+
}
113+
114+
/// Type used to represent and decode the root object of a `.pbxproj` file.
115+
struct PBXProjFile: Decodable {
116+
let rootObject: ObjectUUID
117+
let objects: [String: PBXObjectWrapper]
118+
119+
// Convenience methods
120+
121+
/// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project.
122+
func object<T: PBXObject>(id: ObjectUUID) throws -> T {
123+
guard let wrapped = objects[id] else { throw DecodingError.objectNotFound(id: id) }
124+
guard let obj = wrapped.wrappedValue as? T else {
125+
throw DecodingError.unexpectedObjectType(id: id, expectedType: T.self, found: wrapped.wrappedValue)
126+
}
127+
return obj
128+
}
129+
130+
/// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project.
131+
func object<T: PBXObject>(id: ObjectUUID) -> T? {
132+
try? object(id: id) as T
133+
}
134+
135+
/// The `PBXProject` corresponding to the `rootObject` of the project file.
136+
var rootProject: PBXProject { try! object(id: rootObject) }
137+
138+
/// The `PBXGroup` corresponding to the main groop serving as root for the whole hierarchy of files and groups in the project.
139+
var mainGroup: PBXGroup { try! object(id: rootProject.mainGroup) }
140+
141+
/// The list of `PBXNativeTarget` targets found in the project.
142+
var nativeTargets: [PBXNativeTarget] { rootProject.targets.compactMap(object(id:)) }
143+
144+
/// The list of `PBXBuildFile` build file references included in a given target.
145+
func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] {
146+
guard let sourceBuildPhase: PBXSourcesBuildPhase = target.buildPhases.lazy.compactMap(object(id:)).first else { return [] }
147+
return sourceBuildPhase.files.compactMap(object(id:)) as [PBXBuildFile]
148+
}
149+
}
150+
151+
/// Helper type to ensure the `isa` field of a `PBXObject` contains the name of the expected type to decode as its value.
152+
struct ISA<T>: RawRepresentable, Decodable, CustomDebugStringConvertible {
153+
var rawValue: String
154+
init?(rawValue: String) {
155+
guard rawValue == String(describing: T.self) else { return nil }
156+
self.rawValue = rawValue
157+
}
158+
var debugDescription: String { self.rawValue }
159+
}
160+
161+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
162+
/// Represents the root project object.
163+
struct PBXProject: PBXObject {
164+
private let isa: ISA<Self>
165+
166+
let mainGroup: ObjectUUID
167+
let targets: [ObjectUUID]
168+
}
169+
170+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
171+
/// Represents a native target (i.e. a target building an app, app extension, bundle...).
172+
/// - note: Does not represent other types of targets like `PBXAggregateTarget`, only native ones.
173+
struct PBXNativeTarget: PBXObject {
174+
private let isa: ISA<Self>
175+
176+
let name: String
177+
let buildPhases: [ObjectUUID]
178+
let productType: ProductType
179+
180+
enum ProductType: String, Decodable {
181+
case app = "com.apple.product-type.application"
182+
case appExtension = "com.apple.product-type.app-extension"
183+
case unitTest = "com.apple.product-type.bundle.unit-test"
184+
case uiTest = "com.apple.product-type.bundle.ui-testing"
185+
}
186+
}
187+
188+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
189+
/// Represents a "Compile Sources" build phase containing a list of files to compile.
190+
/// - note: Does not represent other types of Build Phases that could exist in the project, only "Compile Sources" one
191+
struct PBXSourcesBuildPhase: PBXObject {
192+
private let isa: ISA<Self>
193+
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+
private let isa: ISA<Self>
201+
202+
let fileRef: ObjectUUID
203+
}
204+
205+
/// This type is used to indicate what a file reference in the project is actually relative to
206+
enum SourceTree: String, Decodable {
207+
case absolute = "<absolute>"
208+
case group = "<group>"
209+
case projectRoot = "SOURCE_ROOT"
210+
case buildProductsDir = "BUILT_PRODUCTS_DIR"
211+
case devDir = "DEVELOPER_DIR"
212+
case sdkDir = "SDKROOT"
213+
}
214+
215+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
216+
/// Represents a reference to a file contained in the project tree.
217+
struct PBXFileReference: PBXReference {
218+
private let isa: ISA<Self>
219+
220+
let name: String?
221+
let path: String?
222+
let sourceTree: SourceTree
223+
}
224+
225+
/// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
226+
/// Represents a group (aka "folder") contained in the project tree.
227+
struct PBXGroup: PBXReference {
228+
enum ISA: String, Decodable { case PBXGroup, XCVersionGroup, PBXVariantGroup }
229+
// We don't have a `ISA<Self>` here because we want multiple `isa` values to all be allowed and all decode as a `PBXGroup` instance (`"PBXGroup"`, `"XCVersionGroup"`, `"PBXVariantGroup"`)
230+
private let isa: ISA
231+
232+
let name: String?
233+
let path: String?
234+
let sourceTree: SourceTree
235+
let children: [ObjectUUID]
236+
}
237+
238+
/// Fallback type for any unknown `PBXObject` type.
239+
struct UnknownPBXObject: PBXObject {
240+
let isa: String
241+
}
242+
243+
/// Wrapper helper to decode any `PBXObject` based on the value of their `isa` field
244+
@propertyWrapper
245+
struct PBXObjectWrapper: Decodable, CustomDebugStringConvertible {
246+
let wrappedValue: PBXObject
247+
static let knownTypes: [PBXObject.Type] = [
248+
PBXProject.self,
249+
PBXGroup.self,
250+
PBXFileReference.self,
251+
PBXNativeTarget.self,
252+
PBXSourcesBuildPhase.self,
253+
PBXBuildFile.self
254+
]
255+
256+
init(from decoder: Decoder) throws {
257+
// Try to decode each known types in turn, until we find one that succeeds decoding — by having the expected `isa` field value.
258+
for objectType in Self.knownTypes {
259+
if let object = try? objectType.init(from: decoder) as PBXObject {
260+
self.wrappedValue = object
261+
return
262+
}
263+
}
264+
self.wrappedValue = try UnknownPBXObject(from: decoder) as PBXObject // Fallback
265+
}
266+
var debugDescription: String { String(describing: wrappedValue) }
267+
}
268+
}
269+
270+
271+
272+
// MARK: - Lint method
273+
274+
/// The outcome of running our lint logic on a file
275+
enum LintResult { case ok, skipped, violationsFound([(line: Int, col: Int)]) }
276+
277+
/// Lint a given file for usages of `NSLocalizedString` instead of `AppLocalizedString`
278+
func lint(fileAt url: URL, target: String) throws -> LintResult {
279+
guard ["m", "swift"].contains(url.pathExtension) else { return .skipped }
280+
let content = try String(contentsOf: url)
281+
var lineNo = 0
282+
var violations: [(line: Int, col: Int)] = []
283+
content.enumerateLines { line, _ in
284+
lineNo += 1
285+
guard line.range(of: "\\s*//", options: .regularExpression) == nil else { return } // Skip commented lines
286+
guard let range = line.range(of: "NSLocalizedString") else { return }
287+
288+
let colNo = line.distance(from: line.startIndex, to: range.lowerBound)
289+
let message = "Use `AppLocalizedString` instead of `NSLocalizedString` in source files that are used in the `\(target)` extension target. See paNNhX-nP-p2 for more info."
290+
print("\(url.path):\(lineNo):\(colNo): error: \(message)")
291+
violations.append((lineNo, colNo))
292+
}
293+
return violations.isEmpty ? .ok : .violationsFound(violations)
294+
}
295+
296+
297+
298+
// MARK: - Main (Script Code entry point)
299+
300+
// 1st arg = project path
301+
let args = CommandLine.arguments.dropFirst()
302+
guard let projectPath = args.first, !projectPath.isEmpty else { print("You must provide the path to the xcodeproj as first argument."); exit(1) }
303+
let project = try Xcodeproj(path: projectPath)
304+
305+
// 2nd arg (optional) = name of target to lint
306+
let targetsToLint: [Xcodeproj.PBXNativeTarget]
307+
if let targetName = args.dropFirst().first, !targetName.isEmpty {
308+
targetsToLint = project.nativeTargets.filter { $0.name == targetName }
309+
} else {
310+
targetsToLint = project.nativeTargets.filter { $0.productType == .appExtension }
311+
}
312+
313+
// Lint each requested target
314+
var violationsFound = 0
315+
for target in targetsToLint {
316+
let files: [Xcodeproj.PBXBuildFile] = project.buildFiles(for: target)
317+
print("Linting the Build Files for \(target.name):")
318+
for file in files {
319+
let result = try lint(fileAt: project.resolveURL(to: file).absoluteURL, target: target.name)
320+
print(" - \(project.resolveURL(to: file).relativePath) [\(result)]")
321+
if case .violationsFound(let list) = result { violationsFound += list.count }
322+
}
323+
}
324+
print("Done! \(violationsFound) violation(s) found.")
325+
exit(violationsFound > 0 ? 1 : 0)

0 commit comments

Comments
 (0)