import Foundation /// Scans `mac/Sources` for user-facing string literals that never reach the /// `Localizable.strings` catalog (#1219). /// /// # Why this exists /// /// `LocalizationCatalogTests` diffs the `en` and `zh-Hans` tables against each /// other. That catches a key translated in one locale and not the other, but it /// is blind to the failure that actually happens: a new feature ships a bare /// `Text("Second row")`, the literal never becomes a key, both tables stay in /// perfect agreement, and a zh-Hans build renders English. That is exactly how /// #1252 and #1243 landed — neither PR's diff contained a single `L(` call. /// /// So this scanner reads the source instead of the catalog: it finds the call /// sites whose argument AppKit or SwiftUI puts on screen and fails when one of /// them is handed a literal rather than `L(...)`. /// /// # Kept out of the app target on purpose /// /// Nothing here runs in the shipped binary — it reads `.swift` files off disk, /// which only makes sense from the test bundle. It lives in its own file rather /// than inside the test so the standalone `swiftc` harness (this host cannot run /// `swift test`) can exercise the same code the suite does, instead of a /// reimplementation that could drift from it. enum LocalizationSourceScanner { // MARK: - What counts as user-facing /// A call site whose string argument is rendered to the user. /// /// Each entry is matched as a literal prefix immediately followed by the /// argument, so `Text(L("…"))` never matches and `Text("…")` always does. /// Adding a new SwiftUI or AppKit surface that displays a string means /// adding it here — the list is the definition of "user-facing", and a /// surface missing from it is a hole in the guard. static let userFacingCallSites: [String] = [ // SwiftUI views whose first argument is the visible title. "Text(", "Button(", "Toggle(", "Picker(", "Menu(", "Section(", "Label(", "TextField(", "SecureField(", "Stepper(", "Link(", // SwiftUI accessibility, which VoiceOver reads aloud. ".accessibilityLabel(", ".accessibilityValue(", ".accessibilityHint(", ".accessibilityAction(named:", // Tooltips and window/navigation chrome. ".help(", ".navigationTitle(", // AppKit: the status-item menu, alerts, and window titles. "NSMenuItem(title:", ".messageText =", ".informativeText =", "addButton(withTitle:", ".title =", ".toolTip =", // Notification copy, which the notifier hands straight to // UNMutableNotificationContent. "post(title:", // This app's own section-header helper. "sectionCaption(", ] /// Words that are the same in every locale, so a literal made only of them /// is not a translation failure. /// /// Deliberately tiny, and deliberately a vocabulary rather than a list of /// exempt call sites: an exemption keyed to a file and line goes stale the /// moment the file is edited, and quietly stops guarding anything. These are /// the three that actually occur: /// /// - `CodeBurn` — the product name. /// - `tok` — the token unit, kept verbatim next to a formatted figure. /// - `USD` — a currency code. /// /// Provider, model and plan names never appear here because they are never /// literals in a view: they arrive as values and are substituted into a /// `%@`, which is the routed form this scanner is asking for. static let untranslatableWords: Set = ["codeburn", "tok", "usd"] // MARK: - Findings struct Finding: Equatable, CustomStringConvertible { let file: String let line: Int let callSite: String let literal: String var description: String { "\(file):\(line): \(callSite)\"\(literal)\" is shown to the user but never reaches the catalog — wrap it in L(\"…\")" } } // MARK: - Scanning /// Every user-facing literal in `directory` that is not routed through `L(…)`. static func unroutedLiterals(in directory: URL) throws -> [Finding] { var findings: [Finding] = [] for file in try swiftFiles(in: directory) { let source = try String(contentsOf: file, encoding: .utf8) findings += unroutedLiterals( inSource: source, fileName: file.lastPathComponent ) } return findings.sorted { ($0.file, $0.line, $0.literal) < ($1.file, $1.line, $1.literal) } } static func swiftFiles(in directory: URL) throws -> [URL] { guard let walker = FileManager.default.enumerator( at: directory, includingPropertiesForKeys: nil ) else { return [] } return walker .compactMap { $0 as? URL } .filter { $0.pathExtension == "swift" } .sorted { $0.path < $1.path } } /// The scan for one file's text. Split out so the rules are testable /// against a source snippet rather than the repository. static func unroutedLiterals(inSource source: String, fileName: String) -> [Finding] { let code = Array(strippingComments(source)) var findings: [Finding] = [] let bindings = literalBindings(in: code) for callSite in userFacingCallSites { let needle = Array(callSite) var index = 0 while index + needle.count <= code.count { guard Array(code[index..<(index + needle.count)]) == needle, startsAWord(needle, at: index, in: code) else { index += 1 continue } var cursor = index + needle.count // The argument may be on the next line; whitespace is not a // reason to stop looking for it. while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } if cursor < code.count, code[cursor] == "\"", let literal = stringLiteral(in: code, startingAt: cursor), needsTranslation(literal.value) { findings.append( Finding( file: fileName, line: lineNumber(of: index, in: code), callSite: callSite, literal: literal.value ) ) } else if cursor < code.count, code[cursor] != "\"" { // The argument is an expression, not a bare literal: a // ternary (`Text(flag ? "A" : "B")`), a concatenation // (`.help(prefix + " suffix")`), or a variable holding a // literal bound earlier in the file (#1331). Those all put // copy on screen while looking like machinery to the // first-character check above. findings += unroutedExpressionLiterals( after: cursor, at: index, callSite: callSite, in: code, bindings: bindings, fileName: fileName ) } index += needle.count } } // Call sites are scanned one kind at a time, so sort back into reading // order — a failure message that jumps around the file is hard to act on. return findings.sorted { ($0.line, $0.literal) < ($1.line, $1.literal) } } // MARK: - Expression arguments (#1331) /// A `let`/`var` whose right-hand side is a single string literal (or a /// single `L("…")`), collected per file so a call site handed a bare /// identifier can be traced back to the copy it carries. struct LiteralBinding { let name: String let literal: String /// True when the bound literal already sits inside `L(…)`. let routed: Bool } /// The first `let`/`var name =` binding per identifier in the file. Swift /// allows shadowing, so this is an approximation — but the shape the issue /// names (one literal, one use in a `.help`) is exactly the file-local one, /// and a cross-file constant is out of reach for a source scan anyway. static func literalBindings(in code: [Character]) -> [String: LiteralBinding] { var bindings: [String: LiteralBinding] = [:] for keyword in ["let ", "var "] { let needle = Array(keyword) var index = 0 while index + needle.count <= code.count { guard Array(code[index..<(index + needle.count)]) == needle, startsAWord(needle, at: index, in: code) else { index += 1 continue } var cursor = index + needle.count while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } var name = "" while cursor < code.count, code[cursor].isLetter || code[cursor].isNumber || code[cursor] == "_" { name.append(code[cursor]) cursor += 1 } while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } guard !name.isEmpty, cursor < code.count, code[cursor] == "=" else { index += needle.count continue } cursor += 1 while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } if cursor + 1 < code.count, code[cursor] == "L", code[cursor + 1] == "(" { cursor += 2 while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } if cursor < code.count, code[cursor] == "\"", let literal = stringLiteral(in: code, startingAt: cursor) { if bindings[name] == nil { bindings[name] = LiteralBinding(name: name, literal: unescaped(literal.value), routed: true) } } } else if cursor < code.count, code[cursor] == "\"", let literal = stringLiteral(in: code, startingAt: cursor) { if bindings[name] == nil { bindings[name] = LiteralBinding(name: name, literal: unescaped(literal.value), routed: false) } } index += needle.count } } return bindings } /// The extent of one call argument: from just after the open parenthesis to /// the enclosing close (or a top-level comma, since only the first /// argument of each call site is copy). Bracket-balanced so closures and /// nested calls inside the argument do not end it early. static func argumentSpan(in code: [Character], from start: Int) -> Range { var depth = 0 var index = start while index < code.count { switch code[index] { case "(", "[", "{": depth += 1 case ")", "]", "}": depth -= 1 if depth < 0 { return start..