@ -78,6 +78,7 @@ extension ProjectState {
. contains ( " #imageLiteral(resourceName: " , caseSensitive : false ) ,
. contains ( " [UIImage imageNamed: " , caseSensitive : false ) ,
. contains ( " Image( " , caseSensitive : false ) ,
. contains ( " image: " , caseSensitive : false ) ,
. contains ( " logo: " , caseSensitive : false ) ,
. contains ( " UIFont(name: " , caseSensitive : false ) ,
. contains ( " .dateFormat = " , caseSensitive : false ) ,
@ -129,13 +130,13 @@ extension ProjectState {
) ,
. belowLineContaining ( " PreviewProvider " ) ,
. belowLineContaining ( " #Preview " ) ,
. belowLineContaining ( " : Migration { " ) ,
. regex ( Regex . logging ) ,
. regex ( Regex . errorCreation ) ,
. regex ( Regex . databaseTableName ) ,
. regex ( Regex . enumCaseDefinition ) ,
. regex ( Regex . imageInitialization ) ,
. regex ( Regex . variableToStringConversion ) ,
. regex ( Regex . localizedParameter )
. regex ( Regex . variableToStringConversion )
]
}
@ -208,6 +209,7 @@ enum ScriptAction: String {
}
var allKeys : [ String ] = [ ]
var tokensForKey : [ String : Set < String > ] = [ : ]
var duplicates : [ String ] = [ ]
projectState . localizationFile . strings . forEach { key , value in
if allKeys . contains ( key ) {
@ -219,21 +221,32 @@ enum ScriptAction: String {
// 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 {
let processedOriginal : String = original . removingUnwantedScalars ( )
let tokensInOriginal : [ String ] = processedOriginal
. matches ( of : Regex . dynamicStringVariable )
. map { match in
String ( processedOriginal [ match . range ] )
. trimmingCharacters ( in : CharacterSet ( charactersIn : " {} " ) )
}
let numberOfTokensOrignal : Int = tokensInOriginal . count
// O n l y a d d t o t h e d i c t i f t h e r e a r e t o k e n s
if ! tokensInOriginal . isEmpty {
tokensForKey [ key ] = Set ( tokensInOriginal )
}
// C h e c k t h a t t h e n u m b e r o f t o k e n s m a t c h ( i n c l u d i n g 0 t o k e n s )
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
let numberOfTokensPhrase : 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 ) ) " )
if numberOf TokensPhrase != numberOfToken sOrignal {
Output . warning ( " \( key ) in \( locale ) may be faulty (' \( original ) ' contains \( numberOf Token sOrignal) vs. ' \( phrase ) ' contains \( numberOf Token sPhrase) ) " )
}
}
}
@ -265,10 +278,39 @@ enum ScriptAction: String {
case . none : Output . error ( file , " Localized phrase ' \( key ) ' missing from strings files " )
}
}
// A d d e r r o r s f o r i n c o r r e c t / m i s s i n g t o k e n s
file . keyPhrase . forEach { key , phrase in
guard
let tokens : Set < String > = tokensForKey [ key ] ,
tokens != phrase . providedTokens
else { return }
let extra : Set < String > = phrase . providedTokens . subtracting ( tokens )
let missing : Set < String > = tokens . subtracting ( phrase . providedTokens )
let tokensString : String = tokens . map { " { \( $0 ) } " } . joined ( separator : " , " )
let providedString : String = {
let result : String = phrase . providedTokens . map { " { \( $0 ) } " } . joined ( separator : " , " )
guard ! result . isEmpty else { return " no tokens " }
return " ' \( result ) ' "
} ( )
let description : String = [
( ! extra . isEmpty || ! missing . isEmpty ? " ( " : nil ) ,
( ! extra . isEmpty ? " Extra: ' \( extra . map { " { \( $0 ) } " } . joined ( separator : " , " ) ) ' " : nil ) ,
( ! extra . isEmpty && ! missing . isEmpty ? " , " : " " ) ,
( ! missing . isEmpty ? " Missing: ' \( missing . map { " { \( $0 ) } " } . joined ( separator : " , " ) ) ' " : nil ) ,
( ! extra . isEmpty || ! missing . isEmpty ? " ) " : nil )
] . compactMap { $0 } . joined ( )
Output . error ( phrase , " Localized phrase ' \( key ) ' requires the token(s) ' \( tokensString ) ' and has \( providedString ) \( description ) " )
}
}
print ( " ------------ Found \( totalUnlocalisedStrings ) unlocalized string(s) ------------ " )
break
case . updatePermissionStrings :
print ( " ------------ Updating permission strings ------------ " )
var strings : JSON = projectState . infoPlistLocalizationFile . strings
@ -317,15 +359,17 @@ enum Regex {
static let comment = # / \ / \ / [ ^ " ]*(?: " [ ^ " ]* " [ ^ " ]*)*/#
static let allStrings = # / " [^ " \ \ ] * ( ? : \ \ . [ ^ " \\ ]*)* " / #
static let localizedString = # /^ ( ? : \ . put ( ? : Number ) ? \ ( [ ^ ) ] + \ ) ) * \ . localized / #
static let localizedFunctionCall = # / \ . localized ( ? : Formatted ) ? \ ( . * \ ) / #
static let localizedFunctionCall = # / \ . localized ( ? : Formatted ) ? ( ? : Deformatted ) ? \ ( . * \ ) / #
static let localizationHelperCall = # / LocalizationHelper \ ( template : \ s * ( ? : " [^ " ] + " |(?!self \ b)[A-Za-z_] \ w*) \ s* \ )/#
static let logging = # / ( ? : SN ) ? Log . * \ ( / #
static let errorCreation = # / Error . * \ ( / #
static let databaseTableName = # / . * static var databaseTableName : String / #
static let enumCaseDefinition = # / case . * = / #
static let enumCaseDefinition = # / case [^ : ] * = / #
static let imageInitialization = # / ( ? : UI ) ? Image \ ( ( ? : named : ) ? ( ? : imageName : ) ? ( ? : systemName : ) ? . * \ ) / #
static let variableToStringConversion = # / " \\ (.*) " / #
static let localizedParameter = # /^ ( ? : \ . put ( ? : Number ) ? \ ( [ ^ ) ] + \ ) ) */ #
static let localizedParameterToken = # / ( ? : \ . put \ ( key : \ s * " (?<token>[^ " ] + ) " )/#
static let crypto = # / Crypto . * \ ( / #
@ -499,10 +543,13 @@ extension ProjectState {
var key : String
var lineNumber : Int
var chainedCalls : [ String ]
let isExplicitLocalizationMatch : Bool
let possibleKeyPhrases : [ Phrase ]
}
struct Phrase : KeyedLocatable {
struct Phrase : KeyedLocatable , Equatable {
let term : String
let providedTokens : Set < String >
let filePath : String
let lineNumber : Int
@ -567,8 +614,13 @@ extension ProjectState {
// 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 l i n e s w i t h o u t q u o t e s o r a n e x p l i c i t L o c a l i z a t i o n H e l p e r d e f i n i t i o n i f w e
// a r e n ' t i n t e m p l a t e c o n s t r u c t i o n ( o p t i m i z a t i o n )
guard
trimmedLine . contains ( " \" " ) ||
trimmedLine . contains ( " LocalizationHelper(template: " ) ||
templateState != nil
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
@ -653,14 +705,22 @@ extension ProjectState {
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 )
var isExplicitLocalizationMatch : Bool = false
var keyMatches : [ String ] = extractMatches ( from : targetLine , with : Regex . allStrings )
. filter { ! ProjectState . excludedPhrases . contains ( $0 ) }
if let explicitLocalizationMatch = targetLine . firstMatch ( of : Regex . localizationHelperCall ) {
keyMatches . append ( String ( targetLine [ explicitLocalizationMatch . range ] ) )
isExplicitLocalizationMatch = true
}
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 {
let explicitStringRange = targetLine . range ( of : " \" \( match ) \" " )
// 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 ) \" " ) {
if let range = explicitStringRange {
// 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 . . . ]
@ -670,6 +730,9 @@ extension ProjectState {
// 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 ,
providedTokens : Set ( targetLine
. matches ( of : Regex . localizedParameterToken )
. map { String ( $0 . output . token ) } ) ,
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
)
@ -682,14 +745,18 @@ extension ProjectState {
// 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 ,
providedTokens : [ ] ,
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 )
continue
}
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
}
// I f i t d o e s n ' t m a t c h o n e o f t h e t w o c a s e s a b o v e o r i s n ' t a n e x p l i c i t s t r i n g
// t h e n 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
@ -711,18 +778,41 @@ extension ProjectState {
}
if isTemplateChain {
var possibleKeyPhrases : [ Phrase ] = [ ]
// I f t h e m a t c h w a s d u e t o a n e x p l i c i t ` L o c a l i z a t i o n H e l p e r ( t e m p l a t e : ) `
// t h e n w e n e e d t o l o o k b a c k t h r o u g h t h e c o d e t o f i n d t h e d e f i n i t i o n
// o f t h e t e m p l a t e v a l u e ( a s s u m i n g i t ' s a v a r i a b l e )
if isExplicitLocalizationMatch {
let variableName : String = keyMatches [ 0 ]
. replacingOccurrences ( of : " LocalizationHelper(template: " , with : " " )
. trimmingCharacters ( in : CharacterSet ( charactersIn : " ) " ) )
. trimmingCharacters ( in : . whitespacesAndNewlines )
// N o t e : F i l e s a r e 1 - i n d e x e d s o n e e d t o ` $ 0 . l i n e N u m b e r - 1 `
possibleKeyPhrases = unlocalizedPhrases
. filter { lines [ $0 . lineNumber - 1 ] . contains ( " \( variableName ) = " ) }
}
templateState = TemplateStringState (
key : keyMatches [ 0 ] ,
lineNumber : lineNumber ,
chainedCalls : [ ]
chainedCalls : [ ] ,
isExplicitLocalizationMatch : isExplicitLocalizationMatch ,
possibleKeyPhrases : possibleKeyPhrases
)
return
}
else {
else if explicitStringRange != nil {
if targetLine . contains ( " LocalizationHelper " ) {
Output . error ( " RAWR not a template chain " )
}
// 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 ,
providedTokens : [ ] ,
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
)
@ -731,23 +821,68 @@ extension ProjectState {
}
}
}
}
}
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 trimmedLine : String = targetLine . trimmingCharacters ( in : CharacterSet ( charactersIn : " , " ) )
let localizedMatch = trimmedLine . firstMatch ( of : Regex . localizedFunctionCall )
let lineEndsInLocalized : Bool ? = localizedMatch . map { match in
let matchString : String = String ( trimmedLine [ match . range ] )
// N e e d t o m a k e s u r e t h e p a r e n t h e s e s a r e b a l a n c e d a s ` l o c a l i z e d ( ) ) ` w o u l d b e
// c o n s i d e r e d a v a l i d m a t c h w h e n w e d o n ' t w a n t i t t o b e f o r t h e p u r p o s e s o f t h i s
return (
match . range . upperBound = = trimmedLine . endIndex &&
matchString . count ( where : { $0 = = " ( " } ) = = matchString . count ( where : { $0 = = " ) " } )
)
}
switch ( localizedMatch , lineEndsInLocalized , targetLine . firstMatch ( of : Regex . localizedParameter ) ) {
// I f t h e s t r i n g c o n t a i n s o n l y a ` l o c a l i z e d ` c a l l , o r c o n t a i n s b o t h a ` l o c a l i z e d `
// c a l l a n d a l s o a ` . p u t ( N u m b e r ) ` b u t e n d s w i t h t h e ` l o c a l i z e d ` c a l l t h e n a s s u m e
// w e f i n i s h i n g t h e l o c a l i z e d s t r i n g ( a s o p p o s e d t o l o c a l i z i n g t h e v a l u e f o r a
// t o k e n t o b e i n c l u d e d i n t h e s t r i n g )
case ( . some , true , _ ) , ( . some , false , . none ) :
// 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 ( s )
let keys : [ String ] = ( state . isExplicitLocalizationMatch && ! state . possibleKeyPhrases . isEmpty ?
state . possibleKeyPhrases . map { $0 . key } :
[ state . key ]
)
keys . forEach { key in
let phrase = Phrase (
term : state . key ,
term : key ,
providedTokens : Set ( state . chainedCalls
. compactMap { callLine -> String ? in
guard
let tokenName = callLine
. firstMatch ( of : Regex . localizedParameterToken ) ?
. output
. token
else { return nil }
return String ( tokenName )
} ) ,
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
keyPhrase [ key ] = phrase
phrases . append ( phrase )
}
templateState = nil
case . none :
// I f i t w a s a n e x p l i c i t L o c a l i z a t i o n H e l p e r t e m p l a t e ( p r o v i d e d w i t h a v a r i a b l e )
// t h e n w e w a n t t o r e m o v e t h o s e v a l u e s f r o m t h e u n l o c a l i z e d s t r i n g s
if state . isExplicitLocalizationMatch && ! state . possibleKeyPhrases . isEmpty {
state . possibleKeyPhrases . forEach { phrase in
unlocalizedKeyPhrase . removeValue ( forKey : phrase . key )
}
unlocalizedPhrases = unlocalizedPhrases . filter {
! state . possibleKeyPhrases . contains ( $0 )
}
}
default :
// 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 )
}
@ -780,6 +915,7 @@ extension ProjectState {
matches . forEach { match in
let result = Phrase (
term : match ,
providedTokens : [ ] ,
filePath : path ,
lineNumber : lineNumber + 1
)