# !/ usr / bin / xcrun -- sdk macosx swift
// C o p y r i g h t © 2 0 2 3 R a n g e p r o o f P t y L t d . A l l r i g h t s r e s e r v e d .
//
// s t r i n g l i n t : d i s a b l e
//
// / T h i s s c r i p t i s b a s e d o n h t t p s : / / g i t h u b . c o m / g i n o w u 7 / C l e a n S w i f t L o c a l i z a b l e E x a m p l e
// / T h e m a i n d i f f e r e n c e s a r e :
// / 1 . C h a n g e s t o t h e l o c a l i z e d u s a g e r e g e x
// / 2 . A d d i t i o n t o e x c l u d e d u n l o c a l i z e d c a s e s
// / 3 . F u n c t i o n a l i t y t o u p d a t e a n d c o p y l o c a l i z e d p e r m i s s i o n r e q u i r e m e n t s t r i n g s t o i n f o P l i s t . x c s t r i n g s
import Foundation
import Dispatch
typealias JSON = [ String : AnyHashable ]
extension ProjectState {
// / L i n t i n g c o n t r o l c o m m a n d s
enum LintControl : String , CaseIterable {
// / A d d ` / / s t r i n g l i n t : d i s a b l e ` t o t h e t o p o f a s o u r c e f i l e ( b e f o r e i m p o r t s ) t o m a k e t h i s s c r i p t i g n o r e a f i l e
case disable = " stringlint:disable "
// / A d d ` / / s t r i n g l i n t : i g n o r e ` a f t e r a l i n e t o i g n o r e i t
case ignoreLine = " stringlint:ignore "
// / A d d ` / / s t r i n g l i n t : i g n o r e _ s t a r t ` a n d ` / / s t r i n g l i n t : i g n o r e _ s t o p ` b e f o r e a n d a f t e r a
// / l i n e s o f c o d e t o m a k e t h i s s c r i p t i g n o r e t h e c o n t e n t s f o r s t r i n g l i n t i n g p u r p o s e s
case ignoreStart = " stringlint:ignore_start "
case ignoreStop = " stringlint:ignore_stop "
// / A d d ` / / s t r i n g l i n t : i g n o r e _ c o n t e n t s ` b e f o r e a n y t h i n g w i t h c u r l y b r a c e s ( e g . f u n c t i o n , c l a s s , c l o s u r e , e t c . )
// / t o e v e r y t h i n g w i t h i n t h e c u r l y b r a c e s
case ignoreContents = " stringlint:ignore_contents "
}
static let primaryLocalisation : String = " en "
static let permissionStrings : Set < String > = [
" permissionsStorageSend " ,
" permissionsFaceId " ,
" cameraGrantAccessDescription " ,
" permissionsAppleMusic " ,
" permissionsStorageSave " ,
" permissionsMicrophoneAccessRequiredIos "
]
static let permissionStringsMap : [ String : String ] = [
" permissionsStorageSend " : " NSPhotoLibraryUsageDescription " ,
" permissionsFaceId " : " NSFaceIDUsageDescription " ,
" cameraGrantAccessDescription " : " NSCameraUsageDescription " ,
" permissionsAppleMusic " : " NSAppleMusicUsageDescription " ,
" permissionsStorageSave " : " NSPhotoLibraryAddUsageDescription " ,
" permissionsMicrophoneAccessRequiredIos " : " NSMicrophoneUsageDescription "
]
static let validSourceSuffixes : Set < String > = [ " .swift " , " .m " ]
static let excludedPaths : Set < String > = [
" build/ " , // F i l e s u n d e r t h e b u i l d f o l d e r ( C I )
" Pods/ " , // T h e p o d s f o l d e r
" Protos/ " , // T h e p r o t o b u f f i l e s
" .xcassets/ " , // A s s e t b u n d l e s
" .app/ " , // A p p b u i l d d i r e c t o r i e s
" .appex/ " , // E x t e n s i o n b u i l d d i r e c t o r i e s
" tests/ " , // E x c l u d e t e s t d i r e c t o r i e s
" _SharedTestUtilities/ " , // E x c l u d e s h a r e d t e s t d i r e c t o r y
" external/ " // E x t e r n a l d e p e n d e n c i e s
]
static let excludedPhrases : Set < String > = [ " " , " " , " " , " , " , " , " , " null " , " \" " , " @[0-9a-fA-F]{66} " , " ^[0-9A-Fa-f]+$ " , " / " ]
static let excludedUnlocalizedStringLineMatching : [ MatchType ] = [
. prefix ( " #import " , caseSensitive : false ) ,
. prefix ( " @available( " , caseSensitive : false ) ,
. prefix ( " print( " , caseSensitive : false ) ,
. prefix ( " Log.Category = " , caseSensitive : false ) ,
. contains ( " fatalError( " , caseSensitive : false ) ,
. contains ( " precondition( " , caseSensitive : false ) ,
. contains ( " preconditionFailure( " , caseSensitive : false ) ,
. contains ( " logMessage: " , caseSensitive : false ) ,
. contains ( " owsFailDebug( " , caseSensitive : false ) ,
. contains ( " #imageLiteral(resourceName: " , caseSensitive : false ) ,
. contains ( " [UIImage imageNamed: " , caseSensitive : false ) ,
. contains ( " UIFont(name: " , caseSensitive : false ) ,
. contains ( " .dateFormat = " , caseSensitive : false ) ,
. contains ( " accessibilityLabel = " , caseSensitive : false ) ,
. contains ( " accessibilityValue = " , caseSensitive : false ) ,
. contains ( " accessibilityIdentifier = " , caseSensitive : false ) ,
. contains ( " accessibilityIdentifier: " , caseSensitive : false ) ,
. contains ( " accessibilityLabel: " , caseSensitive : false ) ,
. contains ( " Accessibility(identifier: " , caseSensitive : false ) ,
. contains ( " Accessibility(label: " , caseSensitive : false ) ,
. contains ( " NSAttributedString.Key( " , caseSensitive : false ) ,
. contains ( " Notification.Name( " , caseSensitive : false ) ,
. contains ( " Notification.Key( " , caseSensitive : false ) ,
. contains ( " DispatchQueue( " , caseSensitive : false ) ,
. and (
. prefix ( " static let identifier: String = " , caseSensitive : false ) ,
. previousLine ( numEarlier : 2 , . suffix ( " : Migration { " , caseSensitive : false ) )
) ,
. and (
. contains ( " identifier: " , caseSensitive : false ) ,
. previousLine ( . contains ( " Accessibility( " , caseSensitive : false ) )
) ,
. and (
. contains ( " label: " , caseSensitive : false ) ,
. previousLine ( . contains ( " Accessibility( " , caseSensitive : false ) )
) ,
. and (
. contains ( " label: " , caseSensitive : false ) ,
. previousLine ( numEarlier : 2 , . contains ( " Accessibility( " , caseSensitive : false ) )
) ,
. contains ( " SQL( " , caseSensitive : false ) ,
. contains ( " forResource: " , caseSensitive : false ) ,
. contains ( " imageName: " , caseSensitive : false ) ,
. contains ( " systemName: " , caseSensitive : false ) ,
. contains ( " .userInfo[ " , caseSensitive : false ) ,
. contains ( " payload[ " , caseSensitive : false ) ,
. contains ( " .infoDictionary?[ " , caseSensitive : false ) ,
. contains ( " accessibilityId: " , caseSensitive : false ) ,
. belowLineContaining ( " PreviewProvider " ) ,
. regex ( Regex . logging ) ,
. regex ( Regex . errorCreation ) ,
. regex ( Regex . databaseTableName ) ,
. regex ( Regex . enumCaseDefinition ) ,
. regex ( Regex . imageInitialization ) ,
. regex ( Regex . variableToStringConversion )
]
}
// E x e c u t e t h e d e s i r e d a c t i o n s
let targetActions : Set < ScriptAction > = {
let args = CommandLine . arguments
// T h e f i r s t a r g u m e n t i s t h e f i l e n a m e
guard args . count > 1 else { return [ . lintStrings ] }
return Set ( args . suffix ( from : 1 ) . map { ( ScriptAction ( rawValue : $0 ) ? ? . lintStrings ) } )
} ( )
print ( " ------------ Searching Through Files ------------ " )
let projectState : ProjectState = ProjectState ( path :
ProcessInfo . processInfo . environment [ " PROJECT_DIR " ] ? ?
FileManager . default . currentDirectoryPath
)
print ( " ------------ Processing \( projectState . localizationFile . path ) ------------ " )
targetActions . forEach { $0 . perform ( projectState : projectState ) }
// MARK: - S c r i p t A c t i o n
enum ScriptAction : String {
case validateFilesCopied = " validate "
case lintStrings = " lint "
case updatePermissionStrings = " update "
func perform ( projectState : ProjectState ) {
// P e r f o r m t h e a c t i o n
switch self {
case . validateFilesCopied :
print ( " ------------ Checking Copied Files ------------ " )
guard
let builtProductsPath : String = ProcessInfo . processInfo . environment [ " BUILT_PRODUCTS_DIR " ] ,
let productName : String = ProcessInfo . processInfo . environment [ " FULL_PRODUCT_NAME " ] ,
let productPathInfo = try ? URL ( fileURLWithPath : " \( builtProductsPath ) / \( productName ) " )
. resourceValues ( forKeys : [ . isSymbolicLinkKey , . isAliasFileKey ] ) ,
let finalProductUrl : URL = try ? { ( ) -> URL in
let possibleAliasUrl : URL = URL ( fileURLWithPath : " \( builtProductsPath ) / \( productName ) " )
guard productPathInfo . isSymbolicLink = = true || productPathInfo . isAliasFile = = true else {
return possibleAliasUrl
}
return try URL ( resolvingAliasFileAt : possibleAliasUrl , options : URL . BookmarkResolutionOptions ( ) )
} ( ) ,
let enumerator : FileManager . DirectoryEnumerator = FileManager . default . enumerator (
at : finalProductUrl ,
includingPropertiesForKeys : [ . isDirectoryKey ] ,
options : [ . skipsHiddenFiles ]
) ,
let fileUrls : [ URL ] = enumerator . allObjects as ? [ URL ]
else { return Output . error ( " Could not retrieve list of files within built product " ) }
let localizationFiles : Set < String > = Set ( fileUrls
. filter { $0 . path . hasSuffix ( " .lproj " ) }
. map { $0 . lastPathComponent . replacingOccurrences ( of : " .lproj " , with : " " ) } )
let missingFiles : Set < String > = projectState . localizationFile . locales
. subtracting ( localizationFiles )
guard missingFiles . isEmpty else {
return Output . error ( " Translations missing from \( productName ) : \( missingFiles . joined ( separator : " , " ) ) " )
}
break
case . lintStrings :
guard ! projectState . localizationFile . strings . isEmpty else {
return print ( " ------------ Nothing to lint ------------ " )
}
var allKeys : [ String ] = [ ]
var duplicates : [ String ] = [ ]
projectState . localizationFile . strings . forEach { key , value in
if allKeys . contains ( key ) {
duplicates . append ( key )
} else {
allKeys . append ( key )
}
// A d d w a r n i n g f o r p r o b a b l y f a u l t y t r a n s l a t i o n
if let localizations : JSON = ( value as ? JSON ) ? [ " localizations " ] as ? JSON {
if let original : String = ( ( localizations [ " en " ] as ? JSON ) ? [ " stringUnit " ] as ? JSON ) ? [ " value " ] as ? String {
localizations . forEach { locale , translation in
if let phrase : String = ( ( translation as ? JSON ) ? [ " stringUnit " ] as ? JSON ) ? [ " value " ] as ? String {
// Z e r o - w i d t h c h a r a c t e r s c a n m e s s w i t h r e g e x m a t c h i n g s o w e n e e d t o c l e a n t h e m
// o u t b e f o r e m a t c h i n g
let numberOfVarablesOrignal : Int = original
. removingUnwantedScalars ( )
. matches ( of : Regex . dynamicStringVariable )
. count
let numberOfVarablesPhrase : Int = phrase
. removingUnwantedScalars ( )
. matches ( of : Regex . dynamicStringVariable )
. count
if numberOfVarablesPhrase != numberOfVarablesOrignal {
Output . warning ( " \( key ) in \( locale ) may be faulty (' \( original ) ' contains \( numberOfVarablesOrignal ) vs. ' \( phrase ) ' contains \( numberOfVarablesPhrase ) ) " )
}
}
}
}
}
}
// A d d w a r n i n g s f o r a n y d u p l i c a t e k e y s
duplicates . forEach { Output . duplicate ( key : $0 ) }
// P r o c e s s t h e s o u r c e c o d e
print ( " ------------ Processing Source Files ------------ " )
let results = projectState . lintSourceFiles ( )
print ( " ------------ Processed \( results . sourceFiles . count ) File(s), Ignored \( results . ignoredPaths . count ) File(s) ------------ " )
var totalUnlocalisedStrings : Int = 0
results . sourceFiles . forEach { file in
// A d d l o g s f o r u n l o c a l i z e d s t r i n g s
file . unlocalizedPhrases . forEach { phrase in
totalUnlocalisedStrings += 1
Output . warning ( phrase , " Found unlocalized string ' \( phrase . key ) ' " )
}
// A d d e r r o r s f o r m i s s i n g l o c a l i z e d s t r i n g s
let missingKeys : Set < String > = Set ( file . keyPhrase . keys ) . subtracting ( Set ( allKeys ) )
missingKeys . forEach { key in
switch file . keyPhrase [ key ] {
case . some ( let phrase ) : Output . error ( phrase , " Localized phrase ' \( key ) ' missing from strings files " )
case . none : Output . error ( file , " Localized phrase ' \( key ) ' missing from strings files " )
}
}
}
print ( " ------------ Found \( totalUnlocalisedStrings ) unlocalized string(s) ------------ " )
break
case . updatePermissionStrings :
print ( " ------------ Updating permission strings ------------ " )
var strings : JSON = projectState . infoPlistLocalizationFile . strings
var updatedInfoPlistJSON : JSON = projectState . infoPlistLocalizationFile . json
ProjectState . permissionStrings . forEach { key in
guard let nsKey : String = ProjectState . permissionStringsMap [ key ] else { return }
if
let stringsData : Data = try ? JSONSerialization . data ( withJSONObject : ( projectState . localizationFile . strings [ key ] as ! JSON ) , options : [ . fragmentsAllowed ] ) ,
let stringsJSONString : String = String ( data : stringsData , encoding : . utf8 )
{
let updatedStringsJSONString = stringsJSONString . replacingOccurrences ( of : " {app_name} " , with : " Session " )
if
let updatedStringsData : Data = updatedStringsJSONString . data ( using : . utf8 ) ,
let updatedStrings : JSON = try ? JSONSerialization . jsonObject ( with : updatedStringsData , options : [ . fragmentsAllowed ] ) as ? JSON
{
strings [ nsKey ] = updatedStrings
}
}
}
updatedInfoPlistJSON [ " strings " ] = strings
guard updatedInfoPlistJSON != projectState . infoPlistLocalizationFile . json else {
return
}
if let data : Data = try ? JSONSerialization . data ( withJSONObject : updatedInfoPlistJSON , options : [ . fragmentsAllowed , . sortedKeys ] ) {
do {
try data . write ( to : URL ( fileURLWithPath : projectState . infoPlistLocalizationFile . path ) , options : [ . atomic ] )
} catch {
fatalError ( " Could not write to InfoPlist.xcstrings, error: \( error ) " )
}
}
break
}
print ( " ------------ Complete ------------ " )
}
}
// MARK: - F u n c t i o n a l i t y
enum Regex {
// I n i t i a l i z i n g t h e s e a s s t a t i c v a r i a b l e s m e a n s w e d o n ' t i n i t t h e m e v e r y t i m e t h e y a r e u s e d
// w h i c h c a n s p e e d u p p r o c e s s i n g
static let comment = # / \ / \ / [ ^ " ]*(?: " [ ^ " ]* " [ ^ " ]*)*/#
static let allStrings = # / " [^ " \ \ ] * ( ? : \ \ . [ ^ " \\ ]*)* " / #
static let localizedString = # /^ ( ? : \ . put ( ? : Number ) ? \ ( [ ^ ) ] + \ ) ) * \ . localized / #
static let localizedFunctionCall = # / \ . localized ( ? : Formatted ) ? \ ( . * \ ) / #
static let logging = # / ( ? : SN ) ? Log . * \ ( / #
static let errorCreation = # / Error . * \ ( / #
static let databaseTableName = # / . * static var databaseTableName : String / #
static let enumCaseDefinition = # / case . * = / #
static let imageInitialization = # / ( ? : UI ) ? Image \ ( ( ? : named : ) ? ( ? : imageName : ) ? ( ? : systemName : ) ? . * \ ) / #
static let variableToStringConversion = # / " \\ (.*) " / #
static let dynamicStringVariable = # / \ { \ w + \ } / #
// / R e t u r n s a l i s t o f s t r i n g s t h a t m a t c h r e g e x p a t t e r n f r o m c o n t e n t
// /
// / - P a r a m e t e r s :
// / - p a t t e r n : r e g e x p a t t e r n
// / - c o n t e n t : c o n t e n t t o m a t c h
// / - R e t u r n s : l i s t o f r e s u l t s
static func matches ( _ regex : some RegexComponent , content : String ) -> [ String ] {
return content . matches ( of : regex ) . map { match in
String ( content [ match . range ] )
}
}
}
// MARK: - O u t p u t
enum Output {
static func error ( _ error : String ) {
print ( " error: \( error ) " )
}
static func error ( _ location : Locatable , _ error : String ) {
print ( " \( location . location ) : error: \( error ) " )
}
static func warning ( _ warning : String ) {
print ( " warning: \( warning ) " )
}
static func warning ( _ location : Locatable , _ warning : String ) {
print ( " \( location . location ) : warning: \( warning ) " )
}
static func duplicate (
_ duplicate : KeyedLocatable ,
original : KeyedLocatable
) {
print ( " \( duplicate . location ) : error: duplicate key ' \( original . key ) ' " )
// L o o k s l i k e t h e ` n o t e : ` d o e s n ' t w o r k t h e s a m e a s w h e n X C o d e d o e s i t u n f o r t u n a t e l y s o w e c a n ' t
// c u r r e n t l y i n c l u d e t h e r e f e r e n c e t o t h e o r i g i n a l e n t r y
// p r i n t ( " \ ( o r i g i n a l . l o c a t i o n ) : n o t e : p r e v i o u s l y f o u n d h e r e " )
}
static func duplicate ( key : String ) {
print ( " Error: duplicate key ' \( key ) ' " )
}
}
// MARK: - P r o j e c t S t a t e
struct ProjectState {
let queue = DispatchQueue ( label : " session.stringlint " , attributes : . concurrent )
let group = DispatchGroup ( )
let validFileUrls : [ URL ]
let localizationFile : XCStringsFile
let infoPlistLocalizationFile : XCStringsFile
init ( path : String ) {
guard
let enumerator : FileManager . DirectoryEnumerator = FileManager . default . enumerator (
at : URL ( fileURLWithPath : path ) ,
includingPropertiesForKeys : [ . isDirectoryKey ] ,
options : [ . skipsHiddenFiles ]
) ,
let fileUrls : [ URL ] = enumerator . allObjects as ? [ URL ]
else { fatalError ( " Could not locate files in path directory: \( path ) " ) }
// G e t a l i s t o f v a l i d U R L s
let lowerCaseExcludedPaths : Set < String > = Set ( ProjectState . excludedPaths . map { $0 . lowercased ( ) } )
validFileUrls = fileUrls . filter { fileUrl in
( ( try ? fileUrl . resourceValues ( forKeys : [ . isDirectoryKey ] ) ) ? . isDirectory = = false ) &&
! lowerCaseExcludedPaths . contains { fileUrl . path . lowercased ( ) . contains ( $0 ) }
}
self . localizationFile = validFileUrls
. filter { fileUrl in fileUrl . path . contains ( " Localizable.xcstrings " ) }
. map { XCStringsFile ( path : $0 . path ) }
. last !
self . infoPlistLocalizationFile = validFileUrls
. filter { fileUrl in fileUrl . path . contains ( " InfoPlist.xcstrings " ) }
. map { XCStringsFile ( path : $0 . path ) }
. last !
}
func lintSourceFiles ( ) -> ( sourceFiles : [ SourceFile ] , ignoredPaths : [ String ] ) {
let resultLock : NSLock = NSLock ( )
let lowerCaseSourceSuffixes : Set < String > = Set ( ProjectState . validSourceSuffixes . map { $0 . lowercased ( ) } )
var results : [ ( path : String , file : SourceFile ? ) ] = [ ]
validFileUrls
. filter { fileUrl in lowerCaseSourceSuffixes . contains ( " . \( fileUrl . pathExtension ) " ) }
. forEach { fileUrl in
queue . async ( group : group ) {
let file : SourceFile ? = SourceFile ( path : fileUrl . path )
resultLock . lock ( )
results . append ( ( fileUrl . path , file ) )
resultLock . unlock ( )
}
}
group . wait ( )
let sourceFiles : [ SourceFile ] = results . compactMap { _ , file in file }
let ignoredPaths : [ String ] = results . filter { _ , file in file = = nil } . map { path , _ in path }
return ( sourceFiles , ignoredPaths )
}
}
protocol Locatable {
var location : String { get }
}
protocol KeyedLocatable : Locatable {
var key : String { get }
}
extension ProjectState {
// MARK: - X C S t r i n g s F i l e
struct XCStringsFile : Locatable {
let name : String
let path : String
var json : JSON
var strings : JSON
var locales : Set < String > = Set ( )
var location : String { path }
init ( path : String ) {
self . name = ( path
. replacingOccurrences ( of : " .xcstrings " , with : " " )
. components ( separatedBy : " / " )
. last ? ? " Unknown " )
self . path = path
self . json = XCStringsFile . parse ( path )
self . strings = self . json [ " strings " ] as ! JSON
self . strings . values . forEach { value in
if let localizations : JSON = ( value as ? JSON ) ? [ " localizations " ] as ? JSON {
self . locales . formUnion ( localizations . map { $0 . key } )
}
}
}
static func parse ( _ path : String ) -> JSON {
guard
let data : Data = FileManager . default . contents ( atPath : path ) ,
let json : JSON = try ? JSONSerialization . jsonObject ( with : data , options : [ . fragmentsAllowed ] ) as ? JSON
else { fatalError ( " Could not read from path: \( path ) " ) }
return json
}
}
// MARK: - S o u r c e F i l e
struct SourceFile : Locatable {
struct LintState {
var isDisabled : Bool = false
var isInIgnoredSection : Bool = false
var isInIgnoredContents : Bool = false
var ignoredContentsDepth : Int = 0
}
struct TemplateStringState {
var key : String
var lineNumber : Int
var chainedCalls : [ String ]
}
struct Phrase : KeyedLocatable {
let term : String
let filePath : String
let lineNumber : Int
var key : String { term }
var location : String { " \( filePath ) : \( lineNumber ) " }
}
let path : String
let keyPhrase : [ String : Phrase ]
let unlocalizedKeyPhrase : [ String : Phrase ]
let phrases : [ Phrase ]
let unlocalizedPhrases : [ Phrase ]
var location : String { path }
init ? ( path : String ) {
guard let result = SourceFile . parse ( path ) else { return nil }
self . path = path
self . keyPhrase = result . keyPhrase
self . unlocalizedKeyPhrase = result . unlocalizedKeyPhrase
self . phrases = result . phrases
self . unlocalizedPhrases = result . unlocalizedPhrases
}
static func parse ( _ path : String ) -> ( keyPhrase : [ String : Phrase ] , phrases : [ Phrase ] , unlocalizedKeyPhrase : [ String : Phrase ] , unlocalizedPhrases : [ Phrase ] ) ? {
guard
let data : Data = FileManager . default . contents ( atPath : path ) ,
let content : String = String ( data : data , encoding : . utf8 )
else { fatalError ( " Could not read from path: \( path ) " ) }
// I f t h e f i l e h a s t h e l i n t s u p r e s s i o n b e f o r e t h e f i r s t i m p o r t t h e n i g n o r e t h e f i l e
let preImportContent : String = ( content . components ( separatedBy : " import " ) . first ? ? " " )
guard ! preImportContent . contains ( ProjectState . LintControl . disable . rawValue ) else {
return nil
}
// O t h e r w i s e c o n t i n u e a n d p r o c e s s t h e f i l e
let lines : [ String ] = content . components ( separatedBy : . newlines )
var keyPhrase : [ String : Phrase ] = [ : ]
var unlocalizedKeyPhrase : [ String : Phrase ] = [ : ]
var phrases : [ Phrase ] = [ ]
var unlocalizedPhrases : [ Phrase ] = [ ]
var lintState = LintState ( )
var templateState : TemplateStringState ?
lines . enumerated ( ) . forEach { lineNumber , line in
let trimmedLine : String = line . trimmingCharacters ( in : . whitespacesAndNewlines )
// C h e c k f o r l i n t c o n t r o l c o m m a n d s
if let controlCommand : ProjectState . LintControl = checkLintControl ( line : trimmedLine ) {
updateLintState ( & lintState , command : controlCommand , line : trimmedLine )
return
}
// T r a c k f u n c t i o n d e p t h f o r i g n o r e d f u n c t i o n s
if lintState . isInIgnoredContents {
updateContentsDepth ( & lintState , line : trimmedLine )
}
// S k i p l i n t i n g i f d i s a b l e d
guard ! shouldSkipLinting ( state : lintState ) else { return }
// S k i p l i n e s w i t h o u t q u o t e s ( o p t i m i z a t i o n )
guard trimmedLine . contains ( " \" " ) else { return }
// S k i p e x p l i c i t l y e x c l u d e d l i n e s
guard
! ProjectState . excludedUnlocalizedStringLineMatching
. contains ( where : { $0 . matches ( trimmedLine , lineNumber , lines ) } )
else { return }
// P r o c e s s t h e l i n e f o r s t r i n g s
processLine (
line : line ,
lineNumber : lineNumber ,
path : path ,
keyPhrase : & keyPhrase ,
unlocalizedKeyPhrase : & unlocalizedKeyPhrase ,
phrases : & phrases ,
unlocalizedPhrases : & unlocalizedPhrases ,
templateState : & templateState ,
lines : lines
)
}
return ( keyPhrase , phrases , unlocalizedKeyPhrase , unlocalizedPhrases )
}
private static func checkLintControl ( line : String ) -> ProjectState . LintControl ? {
// / N e e d t o s o r t b y l e n g t h t o e n s u r e w e d o n ' t u n i n t e n t i o n a l l y d e t e c t o n e c o n t r o l m e c h a n i s m o v e r a n o t h e r
// / d u e t o i t c o n t a i n i n g t h e f u l l o t h e r c o m m a n d - e g . ` d i s a b l e _ s t a r t ` v s ` d i s a b l e `
ProjectState . LintControl . allCases
. sorted { lhs , rhs in lhs . rawValue . count > rhs . rawValue . count }
. first { line . contains ( $0 . rawValue ) }
}
private static func updateLintState ( _ state : inout LintState , command : ProjectState . LintControl , line : String ) {
switch command {
case . disable : state . isDisabled = true
case . ignoreStart : state . isInIgnoredSection = true
case . ignoreStop : state . isInIgnoredSection = false
case . ignoreContents :
guard ! state . isInIgnoredContents else { return }
state . isInIgnoredContents = true
state . ignoredContentsDepth = 0
case . ignoreLine : break // H a n d l e s i n g l e - l i n e i g n o r e i n t h e c a l l e r
}
}
private static func updateContentsDepth ( _ state : inout LintState , line : String ) {
if line . contains ( " { " ) {
state . ignoredContentsDepth += 1
}
if line . contains ( " } " ) {
state . ignoredContentsDepth -= 1
if state . ignoredContentsDepth = = 0 {
state . isInIgnoredContents = false
}
}
}
private static func shouldSkipLinting ( state : LintState ) -> Bool {
state . isDisabled || state . isInIgnoredSection || state . isInIgnoredContents
}
private static func processLine (
line : String ,
lineNumber : Int ,
path : String ,
keyPhrase : inout [ String : Phrase ] ,
unlocalizedKeyPhrase : inout [ String : Phrase ] ,
phrases : inout [ Phrase ] ,
unlocalizedPhrases : inout [ Phrase ] ,
templateState : inout TemplateStringState ? ,
lines : [ String ]
) {
// H a n d l e c o m m e n t e d s e c t i o n s
let commentMatches = line . matches ( of : Regex . comment )
let targetLine : String = ( commentMatches . isEmpty ?
line :
String ( line [ . . < commentMatches [ 0 ] . range . lowerBound ] )
)
switch templateState {
case . none :
// E x t r a c t t h e s t r i n g s a n d r e m o v e a n y e x c l u d e d p h r a s e s
let keyMatches : [ String ] = extractMatches ( from : targetLine , with : Regex . allStrings )
. filter { ! ProjectState . excludedPhrases . contains ( $0 ) }
if ! keyMatches . isEmpty {
// I t e r a t e t h r o u g h e a c h m a t c h t o d e t e r m i n e l o c a l i z a t i o n
for match in keyMatches {
// F i n d t h e r a n g e o f t h e m a t c h e d s t r i n g
if let range = targetLine . range ( of : " \" \( match ) \" " ) {
// C h e c k i f . l o c a l i z e d o r a t e m p l a t e f u n c i s c a l l e d i m m e d i a t e l y f o l l o w i n g
// t h i s s p e c i f i c s t r i n g
let afterString = targetLine [ range . upperBound . . . ]
let isLocalized : Bool = ( afterString . firstMatch ( of : Regex . localizedString ) != nil )
if isLocalized {
// A d d a s a l o c a l i z e d p h r a s e
let phrase = Phrase (
term : match ,
filePath : path ,
lineNumber : lineNumber + 1 // F i l e s a r e 1 - i n d e x e d s o a d d 1 t o l i n e N u m b e r
)
keyPhrase [ match ] = phrase
phrases . append ( phrase )
return
}
else if ( match != keyMatches . last ) {
// T h e r e i s a n o t h e r s t r i n g a f t e r t h i s o n e s o t h i s c o u l d n ' t b e l o c a l i z e d
// o r a m u l t i - l i n e t e m p l a t e
let unlocalizedPhrase = Phrase (
term : match ,
filePath : path ,
lineNumber : lineNumber + 1 // F i l e s a r e 1 - i n d e x e d s o a d d 1 t o l i n e N u m b e r
)
unlocalizedKeyPhrase [ match ] = unlocalizedPhrase
unlocalizedPhrases . append ( unlocalizedPhrase )
}
else {
// L o o k a h e a d t o v e r i f y i f p u t / p u t N u m b e r / l o c a l i z e d a r e c a l l e d i n t h e n e x t l i n e s
let lookAheadLimit : Int = 2
var isTemplateChain : Bool = false
for offset in 1. . . lookAheadLimit {
let lookAheadIndex : Int = lineNumber + offset
if lookAheadIndex < lines . count {
let lookAheadLine = lines [ lookAheadIndex ] . trimmingCharacters ( in : . whitespacesAndNewlines )
if
lookAheadLine . hasPrefix ( " .put( " ) ||
lookAheadLine . hasPrefix ( " .putNumber( " ) ||
lookAheadLine . hasPrefix ( " .localized " )
{
isTemplateChain = true
break
}
}
}
if isTemplateChain {
templateState = TemplateStringState (
key : keyMatches [ 0 ] ,
lineNumber : lineNumber ,
chainedCalls : [ ]
)
return
}
else {
// W e d i d n ' t f i n d a n y o f t h e e x p e c t e d f u n c t i o n s w h e n l o o k i n g a h e a d
// s o w e c a n a s s u m e i t ' s a n u n l o c a l i s e d s t r i n g
let unlocalizedPhrase = Phrase (
term : match ,
filePath : path ,
lineNumber : lineNumber + 1 // F i l e s a r e 1 - i n d e x e d s o a d d 1 t o l i n e N u m b e r
)
unlocalizedKeyPhrase [ match ] = unlocalizedPhrase
unlocalizedPhrases . append ( unlocalizedPhrase )
}
}
}
}
}
case . some ( let state ) :
switch targetLine . firstMatch ( of : Regex . localizedFunctionCall ) {
case . some :
// W e f i n i s h e d t h e c h a n g e s o a d d a s a l o c a l i z e d p h r a s e
let phrase = Phrase (
term : state . key ,
filePath : path ,
lineNumber : state . lineNumber + 1 // F i l e s a r e 1 - i n d e x e d s o a d d 1 t o l i n e N u m b e r
)
keyPhrase [ state . key ] = phrase
phrases . append ( phrase )
templateState = nil
case . none :
// T h e c h a i n i s s t i l l g o i n g t o a p p e n d t h e l i n e
templateState ? . chainedCalls . append ( targetLine )
}
}
}
private static func extractMatches ( from line : String , with regex : some RegexComponent ) -> [ String ] {
return line . matches ( of : regex ) . map { match in
// C l e a n u p t h e m a t c h e s
return String ( line [ match . range ] )
. removingPrefixIfPresent ( " NSLocalizedString(@ \" " )
. removingPrefixIfPresent ( " NSLocalizedString( \" " )
. removingPrefixIfPresent ( " \" " )
. removingSuffixIfPresent ( " \" .localized " )
. removingSuffixIfPresent ( " \" ) " )
. removingSuffixIfPresent ( " \" " )
}
}
private static func createPhrases (
from matches : Set < String > ,
isUnlocalized : Bool ,
lineNumber : Int ,
path : String ,
keyPhrase : inout [ String : Phrase ] ,
unlocalizedKeyPhrase : inout [ String : Phrase ] ,
phrases : inout [ Phrase ] ,
unlocalizedPhrases : inout [ Phrase ]
) {
matches . forEach { match in
let result = Phrase (
term : match ,
filePath : path ,
lineNumber : lineNumber + 1
)
if ! isUnlocalized {
keyPhrase [ match ] = result
phrases . append ( result )
} else {
unlocalizedKeyPhrase [ match ] = result
unlocalizedPhrases . append ( result )
}
}
}
}
}
indirect enum MatchType {
case and ( MatchType , MatchType )
case prefix ( String , caseSensitive : Bool )
case suffix ( String , caseSensitive : Bool )
case contains ( String , caseSensitive : Bool )
case regex ( any RegexComponent )
case previousLine ( numEarlier : Int , MatchType )
case nextLine ( numLater : Int , MatchType )
case belowLineContaining ( String )
static func previousLine ( _ type : MatchType ) -> MatchType { return . previousLine ( numEarlier : 1 , type ) }
static func nextLine ( _ type : MatchType ) -> MatchType { return . nextLine ( numLater : 1 , type ) }
func matches ( _ value : String , _ index : Int , _ lines : [ String ] ) -> Bool {
switch self {
case . and ( let firstMatch , let secondMatch ) :
guard firstMatch . matches ( value , index , lines ) else { return false }
return secondMatch . matches ( value , index , lines )
case . prefix ( let prefix , false ) :
return value
. lowercased ( )
. trimmingCharacters ( in : . whitespacesAndNewlines )
. hasPrefix ( prefix . lowercased ( ) )
case . prefix ( let prefix , true ) :
return value
. trimmingCharacters ( in : . whitespacesAndNewlines )
. hasPrefix ( prefix )
case . suffix ( let suffix , false ) :
return value
. lowercased ( )
. trimmingCharacters ( in : . whitespacesAndNewlines )
. hasSuffix ( suffix . lowercased ( ) )
case . suffix ( let suffix , true ) :
return value
. trimmingCharacters ( in : . whitespacesAndNewlines )
. hasSuffix ( suffix )
case . contains ( let other , false ) : return value . lowercased ( ) . contains ( other . lowercased ( ) )
case . contains ( let other , true ) : return value . contains ( other )
case . regex ( let regex ) : return ! Regex . matches ( regex , content : value ) . isEmpty
case . previousLine ( let numEarlier , let type ) :
guard index >= numEarlier else { return false }
let targetIndex : Int = ( index - numEarlier )
return type . matches ( lines [ targetIndex ] , targetIndex , lines )
case . nextLine ( let numLater , let type ) :
guard index + numLater < lines . count else { return false }
let targetIndex : Int = ( index + numLater )
return type . matches ( lines [ targetIndex ] , targetIndex , lines )
case . belowLineContaining ( let other ) :
return lines [ 0. . < index ] . contains ( where : { $0 . lowercased ( ) . contains ( other . lowercased ( ) ) } )
}
}
}
extension String {
func removingPrefixIfPresent ( _ value : String ) -> String {
guard hasPrefix ( value ) else { return self }
return String ( self . suffix ( from : self . index ( self . startIndex , offsetBy : value . count ) ) )
}
func removingSuffixIfPresent ( _ value : String ) -> String {
guard hasSuffix ( value ) else { return self }
return String ( self . prefix ( upTo : self . index ( self . endIndex , offsetBy : - value . count ) ) )
}
func removingUnwantedScalars ( ) -> String {
let unwantedScalars : Set < Unicode . Scalar > = [
" \ u{200B} " , // Z E R O W I D T H S P A C E
" \ u{200C} " , // Z E R O W I D T H N O N - J O I N E R
" \ u{200D} " , // Z E R O W I D T H J O I N E R
" \ u{FEFF} " // Z E R O W I D T H N O - B R E A K S P A C E
]
return String ( self . unicodeScalars . filter { ! unwantedScalars . contains ( $0 ) } . map { Character ( $0 ) } )
}
}