Files
Termind/Scripts/check-appstrings.swift
T

88 lines
4.4 KiB
Swift

import Foundation
import SwiftParser
import SwiftSyntax
/// Uses Xcode's parser so comments, interpolation expressions, URLs, symbols,
/// HTTP fields and identifiers cannot be mistaken for UI copy by a text grep.
final class CopyBoundaryVisitor: SyntaxVisitor {
private let path: String
private let locations: SourceLocationConverter
private(set) var violations: [String] = []
private let presentationCalls: Set<String> = [
"Text", "Button", "Label", "TextField", "SecureField", "Section", "LabeledContent", "Picker",
"ContentUnavailableView", "ProgressView", "Toggle", "Menu", "Link", "CommandMenu", "TableColumn", "Window",
"LocalizedStringResource", "LocalizedStringKey", "navigationTitle", "help", "accessibilityLabel",
"accessibilityValue", "accessibilityHint", "alert", "confirmationDialog", "dismissalConfirmationDialog",
"localized", "pageTitle", "feature", "statusCard", "emptyRow", "permissionHeader",
]
init(path: String, tree: SourceFileSyntax) {
self.path = path
locations = SourceLocationConverter(fileName: path, tree: tree)
super.init(viewMode: .sourceAccurate)
}
override func visit(_ node: StringLiteralExprSyntax) -> SyntaxVisitorContinueKind {
let prose = node.segments.compactMap { $0.as(StringSegmentSyntax.self)?.content.text }.joined()
guard prose.contains(where: { $0.isLetter }), isPresentationLiteral(node) else { return .visitChildren }
let line = locations.location(for: node.positionAfterSkippingLeadingTrivia).line
violations.append(
"\(path):\(line): move strings into TermindPresentation/Strings: \(node.trimmedDescription)")
return .visitChildren
}
private func isPresentationLiteral(_ node: StringLiteralExprSyntax) -> Bool {
var ancestor = node.parent
var argument: LabeledExprSyntax?
while let current = ancestor {
if current.is(MacroExpansionExprSyntax.self) { return false }
if let labeled = current.as(LabeledExprSyntax.self), argument == nil { argument = labeled }
if let call = current.as(FunctionCallExprSyntax.self) {
let name =
call.calledExpression.as(MemberAccessExprSyntax.self)?.declName.baseName.text
?? call.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text ?? ""
guard let argument else { return false }
let label = argument.label?.text ?? ""
if name == "String" { return label == "localized" }
if name == "Text", label == "verbatim" { return true }
if ["title", "prompt", "help", "emptyDescription", "actionTitle"].contains(label) { return true }
return label.isEmpty && call.arguments.first?.id == argument.id && presentationCalls.contains(name)
}
if let binding = current.as(PatternBindingSyntax.self), let type = binding.typeAnnotation?.type {
return type.trimmedDescription.contains("LocalizedString")
}
if let function = current.as(FunctionDeclSyntax.self), let type = function.signature.returnClause?.type {
return type.trimmedDescription.contains("LocalizedString")
}
ancestor = current.parent
}
return false
}
}
let repository = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL
let sharedStrings = repository.appending(path: "Frameworks/TermindPresentation/Strings/AppStrings.swift")
guard FileManager.default.fileExists(atPath: sharedStrings.path) else {
throw CocoaError(.fileReadNoSuchFile)
}
var violations: [String] = []
for app in ["TFM", "TFI"] {
let directory = repository.appending(path: app)
guard let files = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else {
throw CocoaError(.fileReadNoSuchFile)
}
for case let file as URL in files where file.pathExtension == "swift" {
let source = try String(contentsOf: file, encoding: .utf8)
let tree = Parser.parse(source: source)
let visitor = CopyBoundaryVisitor(path: file.path, tree: tree)
visitor.walk(tree)
violations += visitor.violations
}
}
if !violations.isEmpty {
FileHandle.standardError.write(Data((violations.sorted().joined(separator: "\n") + "\n").utf8))
exit(1)
}
print("TFM and TFI presentation copy uses shared AppStrings resources.")